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 = ` `;