proteum 2.5.25 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +4 -0
  2. package/agents/project/AGENTS.md +2 -0
  3. package/agents/project/client/pages/AGENTS.md +22 -1
  4. package/cli/compiler/client/index.ts +10 -5
  5. package/cli/compiler/client/outputNames.ts +28 -0
  6. package/cli/compiler/common/files/autres.ts +3 -1
  7. package/cli/scaffold/templates.ts +4 -0
  8. package/client/app/staleBuild.ts +80 -0
  9. package/client/services/router/components/Link.tsx +25 -0
  10. package/client/services/router/components/Page.tsx +19 -3
  11. package/client/services/router/components/router.tsx +165 -64
  12. package/client/services/router/index.tsx +132 -31
  13. package/client/services/router/navigation.ts +235 -0
  14. package/client/services/router/request/api.ts +123 -75
  15. package/client/services/router/response/index.tsx +5 -0
  16. package/client/services/router/response/page.ts +20 -4
  17. package/common/router/contracts.ts +2 -2
  18. package/common/router/definitions.ts +28 -10
  19. package/common/router/index.ts +2 -0
  20. package/common/router/pageData.ts +20 -2
  21. package/common/router/response/page.ts +73 -7
  22. package/docs/HISTORY.md +8 -0
  23. package/docs/client-assets.md +37 -0
  24. package/docs/client-navigation.md +128 -0
  25. package/docs/http-security-headers.md +114 -0
  26. package/package.json +2 -1
  27. package/server/services/router/http/cache.ts +4 -1
  28. package/server/services/router/http/index.ts +26 -25
  29. package/server/services/router/http/securityHeaders.ts +343 -0
  30. package/server/services/router/response/page/index.tsx +2 -2
  31. package/tests/app-shaped-typecheck.test.cjs +74 -0
  32. package/tests/client-api-fetch-sync.test.cjs +180 -0
  33. package/tests/client-output-names.test.cjs +114 -0
  34. package/tests/client-router-hooks.test.cjs +73 -0
  35. package/tests/client-router-navigation.test.cjs +857 -0
  36. package/tests/client-router-prefetch.test.cjs +197 -0
  37. package/tests/clientRouterHarness.cjs +70 -0
  38. package/tests/definition-contracts.test.cjs +45 -1
  39. package/tests/fakeDom.cjs +90 -0
  40. package/tests/http-server-security-headers.test.cjs +669 -0
  41. package/tests/router-cache-config.test.cjs +32 -0
  42. package/tests/stale-build-recovery.test.cjs +206 -0
  43. package/types/global/vendors.d.ts +9 -0
  44. package/types/vendors.d.ts +9 -0
@@ -0,0 +1,343 @@
1
+ /*----------------------------------
2
+ - DEPENDANCES
3
+ ----------------------------------*/
4
+
5
+ // Npm
6
+ import type express from 'express';
7
+ import onHeaders from 'on-headers';
8
+
9
+ /*----------------------------------
10
+ - TYPES
11
+ ----------------------------------*/
12
+
13
+ /** A Content-Security-Policy written by its real directive names, e.g. `{ 'frame-ancestors': ["'self'"] }`. */
14
+ export type TCspDirectives = Readonly<Record<string, readonly string[]>>;
15
+
16
+ export type TCspReportOnly = {
17
+ /** A complete policy, sent as `Content-Security-Policy-Report-Only` on the answers that carry the app policy. */
18
+ policy: TCspDirectives;
19
+ /** Appended to the report-only policy as `report-uri`. */
20
+ reportUri?: string;
21
+ };
22
+
23
+ /** The four historical lists. Proteum adds `'unsafe-inline' 'self' 'unsafe-eval'` to the scripts. */
24
+ export type TCspLegacyConfig = {
25
+ default?: readonly string[];
26
+ styles?: readonly string[];
27
+ images?: readonly string[];
28
+ scripts: readonly string[];
29
+ /** More enforced directives by their real names, appended after the four lists. */
30
+ directives?: TCspDirectives;
31
+ policy?: undefined;
32
+ reportOnly?: TCspReportOnly;
33
+ };
34
+
35
+ /** A complete enforced policy written in full by the app: Proteum adds nothing to it. */
36
+ export type TCspPolicyConfig = {
37
+ policy: TCspDirectives;
38
+ default?: undefined;
39
+ styles?: undefined;
40
+ images?: undefined;
41
+ scripts?: undefined;
42
+ directives?: undefined;
43
+ reportOnly?: TCspReportOnly;
44
+ };
45
+
46
+ export type TCspConfig = TCspLegacyConfig | TCspPolicyConfig;
47
+
48
+ export type TContentSecurityPolicyHeaders = {
49
+ /** The enforced `Content-Security-Policy` value: router answers outside `/api`, HTML under `/api`. */
50
+ policy: string;
51
+ /** The `Content-Security-Policy-Report-Only` value, on the answers that carry the app policy, when configured. */
52
+ reportOnly?: string;
53
+ };
54
+
55
+ type TDirectiveEntries = Array<[string, string[]]>;
56
+
57
+ /*----------------------------------
58
+ - CONSTANTS
59
+ ----------------------------------*/
60
+
61
+ export const contentSecurityPolicyHeader = 'Content-Security-Policy';
62
+ export const contentSecurityPolicyReportOnlyHeader = 'Content-Security-Policy-Report-Only';
63
+
64
+ /** Directives the four legacy lists own, whether or not the list is filled. */
65
+ const legacyDirectiveNames = new Set(['default-src', 'script-src', 'style-src', 'img-src']);
66
+ const legacyListNames = ['default', 'styles', 'images', 'scripts'] as const;
67
+ const cspKeys = new Set<string>([...legacyListNames, 'directives', 'policy', 'reportOnly']);
68
+ const reportOnlyKeys = new Set(['policy', 'reportUri']);
69
+
70
+ /** The CSP 3 directives a browser understands: any other name is a typo the browser would ignore. */
71
+ const knownDirectiveNames = new Set([
72
+ 'base-uri',
73
+ 'block-all-mixed-content',
74
+ 'child-src',
75
+ 'connect-src',
76
+ 'default-src',
77
+ 'fenced-frame-src',
78
+ 'font-src',
79
+ 'form-action',
80
+ 'frame-ancestors',
81
+ 'frame-src',
82
+ 'img-src',
83
+ 'manifest-src',
84
+ 'media-src',
85
+ 'object-src',
86
+ 'report-to',
87
+ 'report-uri',
88
+ 'require-trusted-types-for',
89
+ 'sandbox',
90
+ 'script-src',
91
+ 'script-src-attr',
92
+ 'script-src-elem',
93
+ 'style-src',
94
+ 'style-src-attr',
95
+ 'style-src-elem',
96
+ 'trusted-types',
97
+ 'upgrade-insecure-requests',
98
+ 'webrtc',
99
+ 'worker-src',
100
+ ]);
101
+ /** Directives that take no value at all. */
102
+ const valuelessDirectiveNames = new Set(['upgrade-insecure-requests', 'block-all-mixed-content']);
103
+ /** Directives whose value list may be empty. */
104
+ const optionalValueDirectiveNames = new Set(['sandbox', 'trusted-types']);
105
+ /** Directives whose values are tokens or addresses, not sources. */
106
+ const nonSourceDirectiveNames = new Set(['sandbox', 'report-uri', 'report-to']);
107
+ /** Keywords that only mean something inside single quotes: bare, they name a host. */
108
+ const quotedKeywords = new Set([
109
+ 'self',
110
+ 'none',
111
+ 'unsafe-inline',
112
+ 'unsafe-eval',
113
+ 'unsafe-hashes',
114
+ 'strict-dynamic',
115
+ 'report-sample',
116
+ 'wasm-unsafe-eval',
117
+ ]);
118
+ /** Printable ASCII with no whitespace, `;` or `,`: anything else breaks the header or the directive list. */
119
+ const headerTokenPattern = /^[\x21-\x2b\x2d-\x3a\x3c-\x7e]+$/;
120
+ /** HTML documents: under `/api`, the only answers that get the app policy. */
121
+ const documentContentTypePattern = /^\s*(?:text\/html|application\/xhtml\+xml)\s*(?:;|$)/i;
122
+
123
+ /*----------------------------------
124
+ - BUILD (boot time)
125
+ ----------------------------------*/
126
+
127
+ const fail = (message: string): never => {
128
+ throw new Error(`[http.csp] ${message}`);
129
+ };
130
+
131
+ // @boundary The csp config may be written in plain JavaScript (or spread from shared objects), so every
132
+ // reader below takes its input as untyped and checks each field before use.
133
+ type TConfigInput = unknown;
134
+
135
+ const isPlainRecord = (value: TConfigInput): value is Record<string, TConfigInput> =>
136
+ typeof value === 'object' && value !== null && !Array.isArray(value);
137
+
138
+ const readToken = (label: string, token: TConfigInput): string => {
139
+ if (typeof token !== 'string' || !headerTokenPattern.test(token))
140
+ return fail(`${label} must be a non-empty source in printable ASCII with no whitespace, ";" or "," (got ${JSON.stringify(token)}).`);
141
+ return token;
142
+ };
143
+
144
+ /**
145
+ * A legacy list entry keeps its 2.6 meaning: helmet joined the entries with spaces, so an entry holding
146
+ * several space-separated sources is kept as written (each source checked) and writes the same bytes.
147
+ */
148
+ const readLegacyList = (label: string, value: TConfigInput): string[] => {
149
+ if (value === undefined) return [];
150
+ if (!Array.isArray(value)) return fail(`${label} must be an array of sources.`);
151
+
152
+ return value.map((entry, index) => {
153
+ if (typeof entry !== 'string') return fail(`${label}[${index}] must be a string.`);
154
+
155
+ const sources = entry.split(' ').filter((source) => source !== '');
156
+ if (sources.length === 0)
157
+ return fail(`${label}[${index}] is empty: an empty entry would send an empty source list, which blocks everything.`);
158
+ for (const source of sources) readToken(`${label}[${index}]`, source);
159
+
160
+ return entry;
161
+ });
162
+ };
163
+
164
+ const readSource = (label: string, directiveName: string, source: TConfigInput): string => {
165
+ const token = readToken(label, source);
166
+ if (nonSourceDirectiveNames.has(directiveName)) return token;
167
+
168
+ if (quotedKeywords.has(token.toLowerCase()))
169
+ return fail(`${label} is the bare word ${JSON.stringify(token)}, which names a host: write "'${token}'" with its single quotes.`);
170
+ const startsQuoted = token.startsWith("'");
171
+ const endsQuoted = token.endsWith("'");
172
+ if ((startsQuoted || endsQuoted) && !(startsQuoted && endsQuoted && token.length > 2 && !token.slice(1, -1).includes("'")))
173
+ return fail(`${label} has unbalanced single quotes: ${JSON.stringify(token)}.`);
174
+
175
+ return token;
176
+ };
177
+
178
+ /** Validates a directive record and returns it as ordered `[name, sources]` entries. */
179
+ const readDirectives = (label: string, value: TConfigInput): TDirectiveEntries => {
180
+ if (!isPlainRecord(value)) return fail(`${label} must be an object of directive name to sources.`);
181
+
182
+ return Object.entries(value).map(([name, sources]) => {
183
+ if (!knownDirectiveNames.has(name))
184
+ return fail(`${label} has an unknown directive name ${JSON.stringify(name)}: use a real CSP directive name, lowercase, e.g. "frame-ancestors".`);
185
+ if (!Array.isArray(sources)) return fail(`${label}['${name}'] must be an array of sources.`);
186
+
187
+ const values = sources.map((source, index) => readSource(`${label}['${name}'][${index}]`, name, source));
188
+ if (valuelessDirectiveNames.has(name) && values.length > 0)
189
+ return fail(`${label}['${name}'] takes no value: write an empty array.`);
190
+ if (values.length === 0 && !valuelessDirectiveNames.has(name) && !optionalValueDirectiveNames.has(name))
191
+ return fail(`${label}['${name}'] is empty: a directive needs at least one source.`);
192
+
193
+ return [name, values];
194
+ });
195
+ };
196
+
197
+ /** A misspelt key (`reportonly`, `directive`) would otherwise boot and send nothing. */
198
+ const assertKnownKeys = (label: string, value: Record<string, TConfigInput>, known: ReadonlySet<string>) => {
199
+ const unknownKeys = Object.keys(value).filter((key) => !known.has(key));
200
+ if (unknownKeys.length > 0)
201
+ fail(`${label} has unknown keys ${unknownKeys.map((key) => JSON.stringify(key)).join(', ')}: expected ${[...known].join(', ')}.`);
202
+ };
203
+
204
+ /** Serializes directives in helmet 4.6's exact format, so legacy headers stay byte for byte. */
205
+ const serializeDirectives = (directives: TDirectiveEntries) =>
206
+ directives.map(([name, values]) => (values.length === 0 ? name : `${name} ${values.join(' ')}`)).join(';');
207
+
208
+ const buildLegacyDirectives = (config: Record<string, TConfigInput>): TDirectiveEntries => {
209
+ if (config.scripts === undefined) fail('csp.scripts is required with the list form (an empty array is fine), or write the whole policy in csp.policy.');
210
+ const defaults = readLegacyList('csp.default', config.default);
211
+ const scripts = readLegacyList('csp.scripts', config.scripts);
212
+ const styles = readLegacyList('csp.styles', config.styles);
213
+ const images = readLegacyList('csp.images', config.images);
214
+
215
+ const directives: TDirectiveEntries = [];
216
+ if (defaults.length > 0) directives.push(['default-src', defaults]);
217
+ directives.push(['script-src', ["'unsafe-inline'", "'self'", "'unsafe-eval'", ...scripts]]);
218
+ if (styles.length > 0) directives.push(['style-src', styles]);
219
+ if (images.length > 0) directives.push(['img-src', images]);
220
+
221
+ if (config.directives !== undefined) {
222
+ const extra = readDirectives('csp.directives', config.directives);
223
+ for (const [name] of extra) {
224
+ if (legacyDirectiveNames.has(name))
225
+ fail(`csp.directives names ${JSON.stringify(name)}, which the legacy lists own: use default, scripts, styles or images, or write the whole policy in csp.policy.`);
226
+ }
227
+ directives.push(...extra);
228
+ }
229
+
230
+ return directives;
231
+ };
232
+
233
+ const buildReportOnlyDirectives = (value: TConfigInput): TDirectiveEntries => {
234
+ if (!isPlainRecord(value)) return fail('csp.reportOnly must be an object with a policy.');
235
+ assertKnownKeys('csp.reportOnly', value, reportOnlyKeys);
236
+
237
+ const directives = readDirectives('csp.reportOnly.policy', value.policy);
238
+ if (directives.length === 0) fail('csp.reportOnly.policy has no directive.');
239
+
240
+ if (value.reportUri !== undefined) {
241
+ const reportUri = readToken('csp.reportOnly.reportUri', value.reportUri);
242
+ if (directives.some(([name]) => name === 'report-uri'))
243
+ fail('csp.reportOnly sets report-uri twice: keep reportUri or the policy entry, not both.');
244
+ directives.push(['report-uri', [reportUri]]);
245
+ }
246
+
247
+ return directives;
248
+ };
249
+
250
+ /**
251
+ * Builds the policy header values once, at boot.
252
+ * Throws on any malformed config, so a broken header can never reach production.
253
+ */
254
+ export const buildContentSecurityPolicyHeaders = (cspConfig: TCspConfig): TContentSecurityPolicyHeaders => {
255
+ const config: TConfigInput = cspConfig;
256
+ if (!isPlainRecord(config)) return fail('csp must be an object.');
257
+ assertKnownKeys('csp', config, cspKeys);
258
+
259
+ let directives: TDirectiveEntries;
260
+ if (config.policy !== undefined) {
261
+ const legacyFields = [...legacyListNames, 'directives'].filter((name) => config[name] !== undefined);
262
+ if (legacyFields.length > 0)
263
+ fail(`csp.policy replaces the whole policy, so ${legacyFields.join(', ')} must be absent.`);
264
+
265
+ directives = readDirectives('csp.policy', config.policy);
266
+ if (directives.length === 0) fail('csp.policy has no directive.');
267
+ } else {
268
+ directives = buildLegacyDirectives(config);
269
+ }
270
+
271
+ const headers: TContentSecurityPolicyHeaders = { policy: serializeDirectives(directives) };
272
+ if (config.reportOnly !== undefined) headers.reportOnly = serializeDirectives(buildReportOnlyDirectives(config.reportOnly));
273
+
274
+ return headers;
275
+ };
276
+
277
+ /*----------------------------------
278
+ - WRITE (request time)
279
+ ----------------------------------*/
280
+
281
+ const readContentType = (res: express.Response) => {
282
+ const value = res.getHeader('Content-Type');
283
+ if (Array.isArray(value)) return value[0];
284
+ return typeof value === 'string' ? value : undefined;
285
+ };
286
+
287
+ /** Adds the report-only policy when the headers are written, if the answer still carries the app policy. */
288
+ const writeReportOnlyWithAppPolicy = (res: express.Response, headers: TContentSecurityPolicyHeaders) => {
289
+ if (headers.reportOnly === undefined) return;
290
+ if (res.getHeader(contentSecurityPolicyHeader) !== headers.policy) return;
291
+ if (res.hasHeader(contentSecurityPolicyReportOnlyHeader)) return;
292
+
293
+ res.setHeader(contentSecurityPolicyReportOnlyHeader, headers.reportOnly);
294
+ };
295
+
296
+ /**
297
+ * Outside `/api`: every router answer gets the app policy, whatever its type, as in 2.6
298
+ * (a worker takes its policy from its own script's answer, so a route-served worker must keep it).
299
+ * Set at the start of the request, so a route that writes its own policy replaces it.
300
+ */
301
+ export const createAppContentSecurityPolicy =
302
+ (headers: TContentSecurityPolicyHeaders): express.RequestHandler =>
303
+ (_req, res, next) => {
304
+ res.setHeader(contentSecurityPolicyHeader, headers.policy);
305
+ if (headers.reportOnly !== undefined)
306
+ onHeaders(res, function writeReportOnly(this: express.Response) {
307
+ writeReportOnlyWithAppPolicy(this, headers);
308
+ });
309
+
310
+ next();
311
+ };
312
+
313
+ /**
314
+ * Under `/api`: decides the policy when the headers are about to be written, by the final status and Content-Type.
315
+ * Mount it after helmet: the value helmet wrote at the start of the request is the baseline.
316
+ * - a route wrote or removed its own policy: everything is left as it is;
317
+ * - a 304: no policy at all, so the browser keeps the one its stored copy came with;
318
+ * - an HTML document: the app policy, plus the report-only policy when configured;
319
+ * - anything else: helmet's value stays.
320
+ */
321
+ export const createApiContentSecurityPolicyWriter =
322
+ (headers: TContentSecurityPolicyHeaders): express.RequestHandler =>
323
+ (_req, res, next) => {
324
+ const baseline = res.getHeader(contentSecurityPolicyHeader);
325
+
326
+ onHeaders(res, function writeContentSecurityPolicy(this: express.Response) {
327
+ if (this.getHeader(contentSecurityPolicyHeader) !== baseline) return;
328
+
329
+ // A 304 refreshes the stored copy's headers: helmet's default there would replace the page's policy.
330
+ if (this.statusCode === 304) {
331
+ this.removeHeader(contentSecurityPolicyHeader);
332
+ return;
333
+ }
334
+
335
+ const contentType = readContentType(this);
336
+ if (contentType === undefined || !documentContentTypePattern.test(contentType)) return;
337
+
338
+ this.setHeader(contentSecurityPolicyHeader, headers.policy);
339
+ writeReportOnlyWithAppPolicy(this, headers);
340
+ });
341
+
342
+ next();
343
+ };
@@ -9,7 +9,7 @@ import renderToString from 'preact-render-to-string';
9
9
  // Core
10
10
  import { type TServerRouter, TRouterContext } from '@server/services/router';
11
11
  import type { Layout, TRoute, TErrorRoute, TClientOrServerContext } from '@common/router';
12
- import PageResponse, { TFrontRenderer, TPageRenderContext } from '@common/router/response/page';
12
+ import PageResponse, { TPageRenderContext, TPageRenderer } from '@common/router/response/page';
13
13
  import { getClientBuildManifest } from './clientManifest';
14
14
  import { buildMetaTags } from './metas';
15
15
  import { buildDefaultJsonLd } from './jsonld';
@@ -41,7 +41,7 @@ export default class ServerPage<TRouter extends TServerRouter = TServerRouter> e
41
41
 
42
42
  public constructor(
43
43
  public route: TRoute | TErrorRoute,
44
- public renderer: TFrontRenderer,
44
+ public renderer: TPageRenderer,
45
45
  context: TRouterContext<TRouter>,
46
46
  public layout?: Layout,
47
47
  ) {
@@ -0,0 +1,74 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const ts = require('typescript');
6
+
7
+ const coreRoot = path.join(__dirname, '..');
8
+
9
+ /** The `declare module '<name>'` names of a declaration file. */
10
+ const declaredModules = (filepath) =>
11
+ [...fs.readFileSync(filepath, 'utf8').matchAll(/^declare module '([^']+)'/gm)].map((match) => match[1]);
12
+
13
+ // Apps include `types/global` only (see the scaffold's server tsconfig), while Proteum's own tsc also reads
14
+ // `types/vendors.d.ts`: a vendor declared in the root file alone typechecks here and fails in every app.
15
+ test('every vendor module declared for Proteum is declared for apps too', () => {
16
+ const root = declaredModules(path.join(coreRoot, 'types/vendors.d.ts'));
17
+ const global = new Set(declaredModules(path.join(coreRoot, 'types/global/vendors.d.ts')));
18
+
19
+ assert.deepEqual(
20
+ root.filter((name) => !global.has(name)),
21
+ [],
22
+ 'add these blocks to types/global/vendors.d.ts',
23
+ );
24
+ });
25
+
26
+ test('an app-shaped program reaching the HTTP server finds a declaration for every untyped vendor', () => {
27
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-app-typecheck-'));
28
+ try {
29
+ fs.mkdirSync(path.join(appRoot, 'node_modules'));
30
+ fs.mkdirSync(path.join(appRoot, 'server'));
31
+ fs.symlinkSync(coreRoot, path.join(appRoot, 'node_modules', 'proteum'), 'dir');
32
+ fs.writeFileSync(
33
+ path.join(appRoot, 'server', 'probe.ts'),
34
+ "import HttpServer from '@server/services/router/http';\nexport type Probe = typeof HttpServer;\n",
35
+ );
36
+
37
+ // The shape of an app's server tsconfig: Proteum's base config, `types/global` only, noImplicitAny.
38
+ const proteum = './node_modules/proteum';
39
+ const config = ts.parseJsonConfigFileContent(
40
+ {
41
+ extends: `${proteum}/tsconfig.common.json`,
42
+ compilerOptions: {
43
+ noEmit: true,
44
+ rootDir: '.',
45
+ baseUrl: '.',
46
+ noImplicitAny: true,
47
+ paths: {
48
+ '@client/*': [`${proteum}/client/*`],
49
+ '@common/*': [`${proteum}/common/*`],
50
+ '@server/*': [`${proteum}/server/*`],
51
+ react: [`${proteum}/node_modules/preact/compat`],
52
+ 'react-dom': [`${proteum}/node_modules/preact/compat`],
53
+ 'react/jsx-runtime': [`${proteum}/node_modules/preact/jsx-runtime`],
54
+ },
55
+ },
56
+ include: ['./server/probe.ts', `${proteum}/types/global`],
57
+ },
58
+ ts.sys,
59
+ appRoot,
60
+ );
61
+ const program = ts.createProgram(config.fileNames, config.options);
62
+
63
+ // TS7016 is the B1 failure: a vendor with no types and no declaration. The harness has no generated
64
+ // `.proteum` files and no app code, so the other diagnostics are not this test's subject.
65
+ const missingDeclarations = ts
66
+ .getPreEmitDiagnostics(program)
67
+ .filter((diagnostic) => diagnostic.code === 7016)
68
+ .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n').split('.')[0]);
69
+
70
+ assert.deepEqual(missingDeclarations, []);
71
+ } finally {
72
+ fs.rmSync(appRoot, { recursive: true, force: true });
73
+ }
74
+ });
@@ -0,0 +1,180 @@
1
+ const assert = require('node:assert/strict');
2
+ const path = require('node:path');
3
+
4
+ const coreRoot = path.join(__dirname, '..');
5
+ require('module-alias').addAliases({
6
+ '@client': path.join(coreRoot, 'client'),
7
+ '@common': path.join(coreRoot, 'common'),
8
+ '@server': path.join(coreRoot, 'server'),
9
+ });
10
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
11
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
12
+ require('ts-node/register/transpile-only');
13
+
14
+ const previousDev = global.__DEV__;
15
+ const previousFetch = global.fetch;
16
+ global.__DEV__ = false;
17
+
18
+ const ApiClient = require('../client/services/router/request/api.ts').default;
19
+
20
+ /*----------------------------------
21
+ - HARNESS
22
+ ----------------------------------*/
23
+
24
+ const createClient = (respond) => {
25
+ const calls = [];
26
+ const handledErrors = [];
27
+ const app = { handleError: (error) => handledErrors.push(error) };
28
+ const request = { router: { url: (requestPath) => requestPath } };
29
+
30
+ global.fetch = async (url, config) => {
31
+ calls.push({ url, method: config.method, body: config.body ? JSON.parse(config.body) : undefined });
32
+ const { status = 200, body } = respond(url, config);
33
+ return { ok: status < 400, status, json: async () => body, headers: new Headers() };
34
+ };
35
+
36
+ return { api: new ApiClient(app, request), calls, handledErrors };
37
+ };
38
+
39
+ const quietly = async (run) => {
40
+ const originalLog = console.log;
41
+ const originalWarn = console.warn;
42
+ console.log = () => {};
43
+ console.warn = () => {};
44
+ try {
45
+ return await run();
46
+ } finally {
47
+ console.log = originalLog;
48
+ console.warn = originalWarn;
49
+ }
50
+ };
51
+
52
+ /*----------------------------------
53
+ - TESTS
54
+ ----------------------------------*/
55
+
56
+ test('fetchSync sends api fetchers in one batch, awaits other promises locally and keeps plain values', async () => {
57
+ const { api, calls } = createClient(() => ({ body: { rows: [1, 2], stats: { total: 2 } } }));
58
+
59
+ const data = await quietly(() =>
60
+ api.fetchSync(
61
+ {
62
+ rows: api.post('/api/Rows/list', { page: 1 }),
63
+ stats: api.post('/api/Rows/stats'),
64
+ local: Promise.resolve({ ok: true }),
65
+ thenable: { then: (resolve) => resolve(5) },
66
+ initialFilters: { q: 'word' },
67
+ dateKey: '2026-10-04',
68
+ skipped: undefined,
69
+ cached: api.post('/api/Rows/cached'),
70
+ },
71
+ { cached: 'kept' },
72
+ ),
73
+ );
74
+
75
+ assert.equal(calls.length, 1);
76
+ assert.equal(calls[0].url, '/api');
77
+ assert.equal(calls[0].method, 'POST');
78
+ assert.deepEqual(Object.keys(calls[0].body.fetchers), ['rows', 'stats']);
79
+ assert.deepEqual(calls[0].body.fetchers.rows, { method: 'POST', path: '/api/Rows/list', data: { page: 1 } });
80
+
81
+ assert.deepEqual(data, {
82
+ cached: 'kept',
83
+ rows: [1, 2],
84
+ stats: { total: 2 },
85
+ // Not an /api call: resolved here instead of serializing to {}
86
+ local: { ok: true },
87
+ thenable: 5,
88
+ initialFilters: { q: 'word' },
89
+ dateKey: '2026-10-04',
90
+ });
91
+ assert.deepEqual(Object.keys(data), ['cached', 'rows', 'stats', 'local', 'thenable', 'initialFilters', 'dateKey']);
92
+ });
93
+
94
+ test('fetchSync makes no request when no entry needs the server', async () => {
95
+ const { api, calls } = createClient(() => ({ body: {} }));
96
+
97
+ const data = await api.fetchSync({ deepLink: { tab: 'sales' }, today: Promise.resolve('2026-10-04') }, {});
98
+
99
+ assert.equal(calls.length, 0);
100
+ assert.deepEqual(data, { deepLink: { tab: 'sales' }, today: '2026-10-04' });
101
+ });
102
+
103
+ test('a failed batch goes through the app error handler and rejects', async () => {
104
+ const { api, handledErrors } = createClient(() => ({ status: 500, body: { code: 500, message: 'Boom' } }));
105
+
106
+ await quietly(() =>
107
+ assert.rejects(() => api.fetchSync({ rows: api.post('/api/Rows/list') }, {}), (error) => error.message === 'Boom'),
108
+ );
109
+ assert.equal(handledErrors.length, 1);
110
+ });
111
+
112
+ test('a rejected local promise goes through the app error handler and rejects', async () => {
113
+ const { api, calls, handledErrors } = createClient(() => ({ body: {} }));
114
+
115
+ await assert.rejects(() => api.fetchSync({ local: Promise.reject(new Error('local failure')) }, {}), /local failure/);
116
+ assert.equal(calls.length, 0);
117
+ assert.equal(handledErrors.length, 1);
118
+ assert.equal(handledErrors[0].message, 'local failure');
119
+ });
120
+
121
+ test('locally resolved values go through JSON, like SSR data and /api responses', async () => {
122
+ const { api } = createClient(() => ({ body: {} }));
123
+ const filters = { q: 'word', page: undefined };
124
+ const day = new Date('2026-10-04T00:00:00.000Z');
125
+
126
+ const data = await api.fetchSync(
127
+ {
128
+ filters,
129
+ day,
130
+ money: { toJSON: () => '12.50' },
131
+ seen: new Set(['a']),
132
+ resolved: Promise.resolve({ at: day, missing: undefined }),
133
+ nothing: Promise.resolve(undefined),
134
+ },
135
+ {},
136
+ );
137
+
138
+ assert.deepEqual(data, {
139
+ filters: { q: 'word' },
140
+ day: '2026-10-04T00:00:00.000Z',
141
+ money: '12.50',
142
+ seen: {},
143
+ resolved: { at: '2026-10-04T00:00:00.000Z' },
144
+ });
145
+ // The page gets its own copy: the provider's object is not shared
146
+ assert.notEqual(data.filters, filters);
147
+ });
148
+
149
+ test('api.reload re-runs the data step through navigation on a deferred page', () => {
150
+ const { api } = createClient(() => ({ body: {} }));
151
+ let retries = 0;
152
+ let fetches = 0;
153
+ const page = {
154
+ fetchers: { rows: {}, stats: {} },
155
+ data: { rows: [1], stats: { total: 1 } },
156
+ context: { request: { data: { page: '1' } } },
157
+ isDeferred: () => true,
158
+ navigationRetry: () => retries++,
159
+ fetchData: () => {
160
+ fetches++;
161
+ return Promise.resolve({});
162
+ },
163
+ };
164
+ api.router = { context: { page } };
165
+
166
+ api.reload('rows', { page: '2' });
167
+
168
+ assert.equal(retries, 1);
169
+ assert.equal(fetches, 0);
170
+ // The retry fetches what reload removed, with the new params
171
+ assert.deepEqual(page.data, { stats: { total: 1 } });
172
+ assert.deepEqual(page.context.request.data, { page: '2' });
173
+ });
174
+
175
+ afterAll(() => {
176
+ if (previousDev === undefined) delete global.__DEV__;
177
+ else global.__DEV__ = previousDev;
178
+ if (previousFetch === undefined) delete global.fetch;
179
+ else global.fetch = previousFetch;
180
+ });