From 88b77cb699278af949363d9b5c1a76f818affdc0 Mon Sep 17 00:00:00 2001 From: LazyBouy Date: Fri, 29 May 2026 18:26:43 +0200 Subject: [PATCH] feat(server): opt-in FRONT_AUTO_BASE_URL for hostname-relative API URL (#20504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) → `` (unchanged from main) - [ ] `SERVER_URL` unset → `` (new fallback path) - [ ] `SERVER_URL=http://x.com FRONT_AUTO_BASE_URL=true` → `` (toggle wins) - [ ] `FRONT_AUTO_BASE_URL=false SERVER_URL=http://x.com` → unchanged (only `'true'` triggers the toggle) --------- Co-authored-by: martmull --- .../twenty-config/config-variables.ts | 17 ++++ .../__test__/generate-front-config.spec.ts | 84 +++++++++++++++++++ .../src/utils/generate-front-config.ts | 20 +++-- 3 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 packages/twenty-server/src/utils/__test__/generate-front-config.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 79f3c5c28f..45d0ec10fe 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -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: diff --git a/packages/twenty-server/src/utils/__test__/generate-front-config.spec.ts b/packages/twenty-server/src/utils/__test__/generate-front-config.spec.ts new file mode 100644 index 0000000000..fe85c9fc11 --- /dev/null +++ b/packages/twenty-server/src/utils/__test__/generate-front-config.spec.ts @@ -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; + +const INDEX_TEMPLATE = ` + + + + + +`; + +// 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"}', + ); + }); +}); diff --git a/packages/twenty-server/src/utils/generate-front-config.ts b/packages/twenty-server/src/utils/generate-front-config.ts index 8eca4c230b..43878dc94a 100644 --- a/packages/twenty-server/src/utils/generate-front-config.ts +++ b/packages/twenty-server/src/utils/generate-front-config.ts @@ -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:// 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 = ` `;