proteum 2.6.0 → 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.
- package/README.md +3 -0
- package/cli/compiler/client/index.ts +10 -5
- package/cli/compiler/client/outputNames.ts +28 -0
- package/cli/compiler/common/files/autres.ts +3 -1
- package/cli/scaffold/templates.ts +4 -0
- package/client/app/staleBuild.ts +80 -0
- package/client/services/router/index.tsx +10 -0
- package/docs/HISTORY.md +8 -0
- package/docs/client-assets.md +37 -0
- package/docs/http-security-headers.md +114 -0
- package/package.json +2 -1
- package/server/services/router/http/cache.ts +4 -1
- package/server/services/router/http/index.ts +26 -25
- package/server/services/router/http/securityHeaders.ts +343 -0
- package/tests/app-shaped-typecheck.test.cjs +74 -0
- package/tests/client-output-names.test.cjs +114 -0
- package/tests/http-server-security-headers.test.cjs +669 -0
- package/tests/router-cache-config.test.cjs +32 -0
- package/tests/stale-build-recovery.test.cjs +206 -0
- package/types/global/vendors.d.ts +9 -0
- 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
|
+
};
|
|
@@ -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,114 @@
|
|
|
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
|
+
|
|
6
|
+
require('ts-node/register/transpile-only');
|
|
7
|
+
|
|
8
|
+
const { rspack } = require('@rspack/core');
|
|
9
|
+
const { getClientOutputNames } = require('../cli/compiler/client/outputNames');
|
|
10
|
+
const createFileRules = require('../cli/compiler/common/files/autres');
|
|
11
|
+
const { writeClientManifest } = require('../cli/compiler/common/clientManifest');
|
|
12
|
+
const { defaultHttpCacheConfig, resolvePublicAssetCacheControl } = require('../server/services/router/http/cache');
|
|
13
|
+
|
|
14
|
+
/*----------------------------------
|
|
15
|
+
- HARNESS
|
|
16
|
+
----------------------------------*/
|
|
17
|
+
|
|
18
|
+
// A client shaped like a Proteum app: an entry with CSS, a runtime chunk and a lazy page chunk with its own CSS
|
|
19
|
+
const writeFixture = (root) => {
|
|
20
|
+
const write = (file, content) => fs.writeFileSync(path.join(root, file), content);
|
|
21
|
+
write('index.js', "import './entry.css';\nimport(/* webpackChunkName: 'pages_domains' */ './page.js').then((page) => page.default());\n");
|
|
22
|
+
write('entry.css', "@font-face { font-family: Brand; src: url('./brand.woff2') format('woff2'); }\nbody { color: #111; }\n");
|
|
23
|
+
write('brand.woff2', 'wOF2 fixture bytes');
|
|
24
|
+
write('page.js', "import './page.css';\nexport default () => document.body.setAttribute('data-page', 'domains');\n");
|
|
25
|
+
write('page.css', '.page { color: #222; }\n');
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const compile = (root, dev) => {
|
|
29
|
+
const names = getClientOutputNames(dev);
|
|
30
|
+
const compiler = rspack({
|
|
31
|
+
mode: dev ? 'development' : 'production',
|
|
32
|
+
context: root,
|
|
33
|
+
target: 'web',
|
|
34
|
+
devtool: false,
|
|
35
|
+
entry: { client: './index.js' },
|
|
36
|
+
output: { path: path.join(root, 'public'), publicPath: '/public/', filename: names.entry, chunkFilename: names.chunk },
|
|
37
|
+
module: {
|
|
38
|
+
rules: [
|
|
39
|
+
{ test: /\.css$/, use: [rspack.CssExtractRspackPlugin.loader, require.resolve('css-loader')], type: 'javascript/auto' },
|
|
40
|
+
// The app compiler's own font rule
|
|
41
|
+
...createFileRules(undefined, dev, true),
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
plugins: [new rspack.CssExtractRspackPlugin({ filename: names.css, chunkFilename: names.css })],
|
|
45
|
+
optimization: { runtimeChunk: { name: 'runtime' }, minimize: false },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return new Promise((resolve, reject) =>
|
|
49
|
+
compiler.run((error, stats) => {
|
|
50
|
+
compiler.close(() => {});
|
|
51
|
+
if (error) return reject(error);
|
|
52
|
+
if (stats.hasErrors()) return reject(new Error(stats.toString({ all: false, errors: true })));
|
|
53
|
+
resolve(writeClientManifest(stats, root));
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const cacheControlFor = (asset) =>
|
|
59
|
+
resolvePublicAssetCacheControl({
|
|
60
|
+
res: { req: { originalUrl: `/public/${asset}` } },
|
|
61
|
+
filePath: `/app/bin/public/${asset}`,
|
|
62
|
+
profile: 'prod',
|
|
63
|
+
cache: defaultHttpCacheConfig.publicAssets,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
let root;
|
|
67
|
+
beforeAll(() => {
|
|
68
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-output-names-'));
|
|
69
|
+
writeFixture(root);
|
|
70
|
+
});
|
|
71
|
+
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
72
|
+
|
|
73
|
+
/*----------------------------------
|
|
74
|
+
- TESTS
|
|
75
|
+
----------------------------------*/
|
|
76
|
+
|
|
77
|
+
test('production names every client file with a content hash, entries included', async () => {
|
|
78
|
+
const manifest = await compile(root, false);
|
|
79
|
+
const entry = manifest.entries.client;
|
|
80
|
+
const page = manifest.chunks.pages_domains;
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(entry.js.map((asset) => asset.replace(/\.[a-f0-9]{8}\.js$/, '.js')), ['runtime.js', 'client.js']);
|
|
83
|
+
assert.deepEqual(entry.css.map((asset) => asset.replace(/\.[a-f0-9]{8}\.css$/, '.css')), ['client.css']);
|
|
84
|
+
assert.match(page.css[0], /^pages_domains\.[a-f0-9]{8}\.css$/);
|
|
85
|
+
assert.match(page.js[0], /^\d+\.[a-f0-9]{8}\.js$/);
|
|
86
|
+
|
|
87
|
+
// The stylesheet names the font, which carries its own hash
|
|
88
|
+
const font = fs.readdirSync(path.join(root, 'public', 'fonts'))[0];
|
|
89
|
+
assert.match(font, /^brand\.[a-f0-9]{8}\.woff2$/);
|
|
90
|
+
assert.match(fs.readFileSync(path.join(root, 'public', entry.css[0]), 'utf8'), new RegExp(`fonts/${font.replace('.', '\\.')}`));
|
|
91
|
+
|
|
92
|
+
// Every file the HTML names is served immutable, so no browser or CDN keeps an old runtime past a deploy
|
|
93
|
+
for (const asset of [...entry.js, ...entry.css, ...page.js, ...page.css, `fonts/${font}`]) {
|
|
94
|
+
assert.equal(fs.existsSync(path.join(root, 'public', asset)), true, asset);
|
|
95
|
+
assert.equal(cacheControlFor(asset), 'public, max-age=31536000, immutable', asset);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a content change renames the file that changed and leaves the others alone', async () => {
|
|
100
|
+
const before = await compile(root, false);
|
|
101
|
+
fs.writeFileSync(path.join(root, 'page.css'), '.page { color: #333; }\n');
|
|
102
|
+
const after = await compile(root, false);
|
|
103
|
+
|
|
104
|
+
assert.notEqual(after.chunks.pages_domains.css[0], before.chunks.pages_domains.css[0]);
|
|
105
|
+
assert.deepEqual(after.entries.client.css, before.entries.client.css);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('dev keeps the stable names HMR and the dev middleware rely on', async () => {
|
|
109
|
+
const manifest = await compile(root, true);
|
|
110
|
+
|
|
111
|
+
assert.deepEqual(manifest.entries.client.js, ['runtime.js', 'client.js']);
|
|
112
|
+
assert.deepEqual(manifest.entries.client.css, ['client.css']);
|
|
113
|
+
assert.deepEqual(manifest.chunks.pages_domains.css, ['pages_domains.css']);
|
|
114
|
+
});
|