Skip to content

Commit f0fc04b

Browse files
treodenclaude
andcommitted
feat(base-url): support EVERSHOP_HOME_URL env override with boot validation
Resolve the store base URL with precedence: EVERSHOP_HOME_URL env > shop.homeUrl config > http://localhost:<PORT>. getBaseUrl() is the single chokepoint for absolute URLs (router, emails, GraphQL context), so the override applies everywhere. EVERSHOP_HOME_URL is validated at boot in the base module bootstrap: if it is set but not a valid absolute http(s) URL it throws, and the server refuses to start (build/dev/start all wrap bootstrap in a try/catch that exits) before any broken absolute link or email can be emitted. An unset/empty value falls back to shop.homeUrl, which the configuration schema validates. Adds util.getBaseUrl unit tests: precedence, whitespace-as-unset, trailing slash stripping, and the boot-guard accept/reject cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b5cd6bd commit f0fc04b

3 files changed

Lines changed: 154 additions & 1 deletion

File tree

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,54 @@
11
import { normalizePort } from '../../bin/lib/normalizePort.js';
22
import { getConfig } from './getConfig.js';
33

4+
/**
5+
* Environment variable that overrides the configured `shop.homeUrl`. When set,
6+
* it takes precedence over every config file; when unset (or empty) the store
7+
* falls back to `shop.homeUrl` and then to `http://localhost:<PORT>`.
8+
*/
9+
export const HOME_URL_ENV = 'EVERSHOP_HOME_URL';
10+
11+
/**
12+
* Resolve the store's base URL. Precedence (highest first):
13+
* 1. process.env.EVERSHOP_HOME_URL
14+
* 2. config `shop.homeUrl`
15+
* 3. http://localhost:<PORT>
16+
* Trailing slashes are always stripped.
17+
*/
418
export function getBaseUrl(): string {
519
const port = normalizePort();
6-
const baseUrl = getConfig('shop.homeUrl', `http://localhost:${port}`);
20+
const envUrl = process.env[HOME_URL_ENV]?.trim();
21+
const baseUrl =
22+
envUrl || getConfig('shop.homeUrl', `http://localhost:${port}`);
723
return baseUrl.replace(/\/+$/, ''); // Remove trailing slashes
824
}
25+
26+
/**
27+
* Boot-time guard for the `EVERSHOP_HOME_URL` override. If the variable is set,
28+
* it must be a valid absolute http(s) URL — otherwise every absolute link and
29+
* email the store generates would be broken. Throwing here halts boot (the
30+
* caller in module bootstrap is wrapped in the start/build try/catch that calls
31+
* `process.exit`). A missing/empty variable is allowed: the store then falls
32+
* back to `shop.homeUrl`, which the configuration schema validates separately.
33+
*/
34+
export function assertValidHomeUrlEnv(): void {
35+
const raw = process.env[HOME_URL_ENV];
36+
if (raw === undefined || raw.trim() === '') {
37+
return;
38+
}
39+
const value = raw.trim();
40+
let parsed: URL;
41+
try {
42+
parsed = new URL(value);
43+
} catch {
44+
throw new Error(
45+
`Invalid ${HOME_URL_ENV}: "${value}" is not a valid URL. ` +
46+
`Set an absolute URL such as "https://example.com".`
47+
);
48+
}
49+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
50+
throw new Error(
51+
`Invalid ${HOME_URL_ENV}: "${value}" must use the http or https protocol.`
52+
);
53+
}
54+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import {
5+
jest,
6+
describe,
7+
it,
8+
expect,
9+
beforeEach,
10+
afterEach
11+
} from '@jest/globals';
12+
13+
// `undefined` => the getConfig mock returns the default argument passed by
14+
// getBaseUrl (i.e. the localhost fallback). Any string => that config value.
15+
let mockConfigValue;
16+
17+
await jest.unstable_mockModule('../../getConfig.js', () => ({
18+
getConfig: (_path, def) => (mockConfigValue !== undefined ? mockConfigValue : def)
19+
}));
20+
21+
const { getBaseUrl, assertValidHomeUrlEnv, HOME_URL_ENV } = await import(
22+
'../../getBaseUrl.js'
23+
);
24+
25+
const ORIGINAL_ENV = process.env[HOME_URL_ENV];
26+
const restoreEnv = () => {
27+
if (ORIGINAL_ENV === undefined) {
28+
delete process.env[HOME_URL_ENV];
29+
} else {
30+
process.env[HOME_URL_ENV] = ORIGINAL_ENV;
31+
}
32+
};
33+
34+
describe('getBaseUrl — EVERSHOP_HOME_URL override precedence', () => {
35+
beforeEach(() => {
36+
mockConfigValue = undefined;
37+
delete process.env[HOME_URL_ENV];
38+
});
39+
afterEach(restoreEnv);
40+
41+
it('uses the env var when set (highest precedence)', () => {
42+
mockConfigValue = 'https://from-config.example.com';
43+
process.env[HOME_URL_ENV] = 'https://from-env.example.com';
44+
expect(getBaseUrl()).toBe('https://from-env.example.com');
45+
});
46+
47+
it('falls back to config when env is unset', () => {
48+
mockConfigValue = 'https://from-config.example.com';
49+
expect(getBaseUrl()).toBe('https://from-config.example.com');
50+
});
51+
52+
it('treats whitespace-only env as unset and falls back to config', () => {
53+
mockConfigValue = 'https://from-config.example.com';
54+
process.env[HOME_URL_ENV] = ' ';
55+
expect(getBaseUrl()).toBe('https://from-config.example.com');
56+
});
57+
58+
it('falls back to http://localhost:<port> when neither env nor config is set', () => {
59+
expect(getBaseUrl()).toMatch(/^http:\/\/localhost:\d+$/);
60+
});
61+
62+
it('strips trailing slashes from the env value', () => {
63+
process.env[HOME_URL_ENV] = 'https://from-env.example.com///';
64+
expect(getBaseUrl()).toBe('https://from-env.example.com');
65+
});
66+
});
67+
68+
describe('assertValidHomeUrlEnv — boot guard', () => {
69+
beforeEach(() => {
70+
delete process.env[HOME_URL_ENV];
71+
});
72+
afterEach(restoreEnv);
73+
74+
it('does not throw when the env var is unset', () => {
75+
expect(() => assertValidHomeUrlEnv()).not.toThrow();
76+
});
77+
78+
it('does not throw when the env var is empty/whitespace', () => {
79+
process.env[HOME_URL_ENV] = ' ';
80+
expect(() => assertValidHomeUrlEnv()).not.toThrow();
81+
});
82+
83+
it('accepts a valid https URL', () => {
84+
process.env[HOME_URL_ENV] = 'https://example.com';
85+
expect(() => assertValidHomeUrlEnv()).not.toThrow();
86+
});
87+
88+
it('accepts a valid http URL with a port', () => {
89+
process.env[HOME_URL_ENV] = 'http://localhost:3000';
90+
expect(() => assertValidHomeUrlEnv()).not.toThrow();
91+
});
92+
93+
it('throws for a non-URL string', () => {
94+
process.env[HOME_URL_ENV] = 'not a url';
95+
expect(() => assertValidHomeUrlEnv()).toThrow(/not a valid URL/);
96+
});
97+
98+
it('throws for a non-http(s) protocol', () => {
99+
process.env[HOME_URL_ENV] = 'ftp://example.com';
100+
expect(() => assertValidHomeUrlEnv()).toThrow(/http or https/);
101+
});
102+
});

packages/evershop/src/modules/base/bootstrap.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { loadAllLocales } from '../../lib/locale/dictionary.js';
2+
import { assertValidHomeUrlEnv } from '../../lib/util/getBaseUrl.js';
23
import { merge } from '../../lib/util/merge.js';
34
import { addProcessor } from '../../lib/util/registry.js';
45

56
export default async () => {
7+
// Fail fast if EVERSHOP_HOME_URL is set to something that isn't a valid
8+
// http(s) URL. Throwing here halts boot (build/dev/start all wrap bootstrap
9+
// in a try/catch that exits), before any broken absolute URL can be emitted.
10+
assertValidHomeUrlEnv();
611
// Build the runtime locale registry from disk (spec §6.2/§6.3). `translate()` and
712
// `_()` now read this registry / the per-request ALS context — no separate loadCsv.
813
await loadAllLocales();

0 commit comments

Comments
 (0)