feat(server): opt-in FRONT_AUTO_BASE_URL for hostname-relative API URL (#20504)

## Problem

`generateFrontConfig()` writes `window._env_.REACT_APP_SERVER_BASE_URL =
process.env.SERVER_URL` unconditionally. The frontend then pins to that
absolute URL. For self-hosted deployments reachable from multiple
hostnames (Tailscale IP, LAN IP, internal DNS, SSH tunnel to localhost,
public DNS), only the one matching `SERVER_URL` works — others hit CORS
errors or unreachable hosts because the frontend tries to call the API
at the configured URL, not the one the user came in via.

The frontend already supports the right fallback:
`packages/twenty-front/src/config/index.ts:20-21` reads
`window._env_?.REACT_APP_SERVER_BASE_URL` and falls back to
`getDefaultUrl()` (which uses `window.location`) when the env var is
absent. But the server-side `generateFrontConfig` always populates
`_env_`, so the fallback never runs.

## Fix

One file: `packages/twenty-server/src/utils/generate-front-config.ts`.
Add a `FRONT_AUTO_BASE_URL=true` opt-in (also triggered when
`SERVER_URL` is unset entirely). When the toggle is on, inject
`window._env_ = {}` so the frontend's existing `getDefaultUrl()`
fallback resolves the origin from `window.location` at runtime.

## Backwards compatibility

When `SERVER_URL` is set AND `FRONT_AUTO_BASE_URL` is unset (or anything
other than `'true'`): unchanged — `REACT_APP_SERVER_BASE_URL:
process.env.SERVER_URL` is injected exactly as before.

The toggle is strictly additive. Existing single-hostname deployments
are not affected.

## Use case

Self-hosted Twenty reachable via:
- `http://100.115.12.29` over Tailscale
- `http://localhost:4440` over SSH tunnel
- `http://twenty.internal` over LAN DNS
- `http://crm.example.com` public

With `FRONT_AUTO_BASE_URL=true`, all four paths work without rebuilds or
per-hostname server processes.

## Test plan

- [ ] `SERVER_URL=http://x.com` (toggle unset) → `<script>window._env_ =
{"REACT_APP_SERVER_BASE_URL":"http://x.com"};</script>` (unchanged from
main)
- [ ] `SERVER_URL` unset → `<script>window._env_ = {};</script>` (new
fallback path)
- [ ] `SERVER_URL=http://x.com FRONT_AUTO_BASE_URL=true` →
`<script>window._env_ = {};</script>` (toggle wins)
- [ ] `FRONT_AUTO_BASE_URL=false SERVER_URL=http://x.com` → unchanged
(only `'true'` triggers the toggle)

---------

Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
LazyBouy
2026-05-29 18:26:43 +02:00
committed by GitHub
parent 0ed2e9d82d
commit 88b77cb699
3 changed files with 113 additions and 8 deletions
@@ -1121,6 +1121,23 @@ export class ConfigVariables {
@IsOptional()
SERVER_URL = 'http://localhost:3000';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description:
'When enabled, the served frontend resolves the API base URL from ' +
"the browser's current origin (window.location) instead of the " +
'baked-in SERVER_URL. Useful for self-hosted deployments reachable ' +
'from multiple hostnames (Tailscale IP, LAN DNS, SSH tunnel, public ' +
'DNS), where pinning a single SERVER_URL would break every other ' +
'host with CORS or unreachable-host errors. Read at startup by ' +
'generate-front-config; SERVER_URL is still used for all server-side ' +
'URL generation.',
type: ConfigVariableType.BOOLEAN,
isEnvOnly: true,
})
@IsOptional()
FRONT_AUTO_BASE_URL = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description:
@@ -0,0 +1,84 @@
import * as fs from 'fs';
import { generateFrontConfig } from 'src/utils/generate-front-config';
// dotenv runs at import time with override: true, which would clobber the
// per-test process.env we set below. Neutralize it so each test controls env.
jest.mock('dotenv', () => ({ config: jest.fn() }));
jest.mock('fs');
const mockedFs = fs as jest.Mocked<typeof fs>;
const INDEX_TEMPLATE = `<html>
<head>
<!-- BEGIN: Twenty Config -->
<script id="twenty-env-config">
window._env_ = {"REACT_APP_SERVER_BASE_URL":"http://stale-value"};
</script>
<!-- END: Twenty Config -->
</head>
</html>`;
// Pull the injected _env_ object back out of the written index.html and
// normalize whitespace so the multi-line JSON.stringify(..., 2) output can be
// compared against a compact expected string.
const getInjectedEnv = (): string => {
const writtenContent = mockedFs.writeFileSync.mock.calls[0][1] as string;
const match = writtenContent.match(/window\._env_ = (\{[\s\S]*?\});/);
return match ? match[1].replace(/\s+/g, '') : '';
};
describe('generateFrontConfig', () => {
const ORIGINAL_ENV = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...ORIGINAL_ENV };
mockedFs.readFileSync.mockReturnValue(INDEX_TEMPLATE);
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
it('should inject the absolute SERVER_URL when set and the toggle is unset', () => {
process.env.SERVER_URL = 'http://x.com';
delete process.env.FRONT_AUTO_BASE_URL;
generateFrontConfig();
expect(getInjectedEnv()).toBe(
'{"REACT_APP_SERVER_BASE_URL":"http://x.com"}',
);
});
it('should inject an empty _env_ when SERVER_URL is unset', () => {
delete process.env.SERVER_URL;
delete process.env.FRONT_AUTO_BASE_URL;
generateFrontConfig();
expect(getInjectedEnv()).toBe('{}');
});
it('should inject an empty _env_ when FRONT_AUTO_BASE_URL=true even if SERVER_URL is set', () => {
process.env.SERVER_URL = 'http://x.com';
process.env.FRONT_AUTO_BASE_URL = 'true';
generateFrontConfig();
expect(getInjectedEnv()).toBe('{}');
});
it('should keep the absolute SERVER_URL when FRONT_AUTO_BASE_URL is not exactly "true"', () => {
process.env.SERVER_URL = 'http://x.com';
process.env.FRONT_AUTO_BASE_URL = 'false';
generateFrontConfig();
expect(getInjectedEnv()).toBe(
'{"REACT_APP_SERVER_BASE_URL":"http://x.com"}',
);
});
});
@@ -8,17 +8,21 @@ config({
});
export function generateFrontConfig(): void {
const configObject = {
window: {
_env_: {
REACT_APP_SERVER_BASE_URL: process.env.SERVER_URL,
},
},
};
// When FRONT_AUTO_BASE_URL=true (or SERVER_URL is unset), inject an empty
// _env_ so the frontend's getDefaultUrl() fallback resolves the API origin
// from the page's own hostname at request time. This lets the same deploy
// be reached at both http://<external-ip> and http://localhost without a
// hairpin through the public interface.
const useAutoUrl =
process.env.FRONT_AUTO_BASE_URL === 'true' || !process.env.SERVER_URL;
const envForFront = useAutoUrl
? {}
: { REACT_APP_SERVER_BASE_URL: process.env.SERVER_URL };
const configString = `<!-- BEGIN: Twenty Config -->
<script id="twenty-env-config">
window._env_ = ${JSON.stringify(configObject.window._env_, null, 2)};
window._env_ = ${JSON.stringify(envForFront, null, 2)};
</script>
<!-- END: Twenty Config -->`;