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.
@@ -71,3 +71,35 @@ test('router cache config resolves granular public asset policies', () => {
71
71
  'dev-cache',
72
72
  );
73
73
  });
74
+
75
+ test('router cache config serves the hashed production entry files immutable', () => {
76
+ const cache = resolveHttpCacheConfig();
77
+ const cacheControlFor = (asset) =>
78
+ resolvePublicAssetCacheControl({
79
+ res: { req: { originalUrl: `/public/${asset}` } },
80
+ filePath: `/app/bin/public/${asset}`,
81
+ profile: 'prod',
82
+ cache: cache.publicAssets,
83
+ });
84
+
85
+ // The names Proteum emits in production since entries carry a content hash
86
+ for (const asset of [
87
+ 'runtime.1a2b3c4d.js',
88
+ 'framework.0f9e8d7c.js',
89
+ 'vendors.aabbccdd.js',
90
+ 'client.12345678.js',
91
+ 'client.12345678.js.map',
92
+ 'client.9abcdef0.css',
93
+ 'pages_domains_index.5860cf66.css',
94
+ '7385.5860cf66.js',
95
+ // responsive-loader's `<hash>-<width>.ext`, and fonts since 2.6.1
96
+ '345657c085f4e24e-64.png',
97
+ '01437e1e65adfa92-1300.jpg',
98
+ 'fonts/figtree-latin.1a2b3c4d.woff2',
99
+ ])
100
+ assert.equal(cacheControlFor(asset), 'public, max-age=31536000, immutable', asset);
101
+
102
+ // A file without a hash stays revalidated: icons, fonts, and any app file served under its own name
103
+ for (const asset of ['runtime.js', 'client.css', 'pages_domains_index.css', '7385.js', 'app/favicon.ico', 'og/master-1200x630.png', 'fonts/figtree-latin..woff2'])
104
+ assert.equal(cacheControlFor(asset), 'public, max-age=0, must-revalidate', asset);
105
+ });
@@ -0,0 +1,206 @@
1
+ const assert = require('node:assert/strict');
2
+
3
+ const { createRouter, restore } = require('./clientRouterHarness.cjs');
4
+ const {
5
+ STALE_BUILD_RELOAD_KEY,
6
+ clearStaleBuildReloads,
7
+ isChunkLoadError,
8
+ reloadOnceForStaleBuild,
9
+ } = require('../client/app/staleBuild.ts');
10
+
11
+ /*----------------------------------
12
+ - HARNESS
13
+ ----------------------------------*/
14
+
15
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
16
+
17
+ const createStorage = () => {
18
+ const values = new Map();
19
+ return {
20
+ getItem: (key) => (values.has(key) ? values.get(key) : null),
21
+ setItem: (key, value) => values.set(key, String(value)),
22
+ removeItem: (key) => values.delete(key),
23
+ };
24
+ };
25
+
26
+ // What rspack's runtime rejects with when a deploy removed the chunk file
27
+ const createChunkLoadError = (chunkId = '7385') => {
28
+ const error = new Error(`Loading chunk ${chunkId} failed.\n(missing: /public/${chunkId}.5860cf66.js)`);
29
+ error.name = 'ChunkLoadError';
30
+ return error;
31
+ };
32
+
33
+ const createCssChunkLoadError = () => {
34
+ const error = new Error('Loading CSS chunk 7385 failed.');
35
+ error.code = 'CSS_CHUNK_LOAD_FAILED';
36
+ return error;
37
+ };
38
+
39
+ // A router with one route whose chunk fails with `error`, a stubbed sessionStorage and reload
40
+ const createFailingRouter = (error, { buildId = 1761025500000, storage = createStorage() } = {}) => {
41
+ const calls = { reloads: 0, updates: 0 };
42
+ global.BUILD_ID = buildId;
43
+ window.sessionStorage = storage;
44
+ window.location.reload = () => calls.reloads++;
45
+
46
+ const router = createRouter();
47
+ router.app.handleUpdate = () => calls.updates++;
48
+ const route = { index: 0, chunk: 'pages_domains', regex: /^\/domains$/, keys: [], load: () => Promise.reject(error) };
49
+ router.routes = [route];
50
+
51
+ return { router, route, calls, storage };
52
+ };
53
+
54
+ const cleanupGlobals = () => {
55
+ delete global.BUILD_ID;
56
+ delete window.sessionStorage;
57
+ delete window.location.reload;
58
+ };
59
+
60
+ afterEach(cleanupGlobals);
61
+ afterAll(restore);
62
+
63
+ /*----------------------------------
64
+ - HELPER
65
+ ----------------------------------*/
66
+
67
+ test('chunk load errors are told apart from other route load failures', () => {
68
+ assert.equal(isChunkLoadError(createChunkLoadError()), true);
69
+ assert.equal(isChunkLoadError(createCssChunkLoadError()), true);
70
+ // A module that loaded but threw while evaluating is not a stale build
71
+ assert.equal(isChunkLoadError(new Error('ChunkLoadError')), false);
72
+ assert.equal(isChunkLoadError(new TypeError('x is undefined')), false);
73
+ });
74
+
75
+ test('a stale build reloads once per build id, then leaves the error to the caller', () => {
76
+ const storage = createStorage();
77
+ let reloads = 0;
78
+ const options = { storage: () => storage, reload: () => reloads++ };
79
+
80
+ assert.equal(reloadOnceForStaleBuild('100', options), true);
81
+ assert.equal(storage.getItem(STALE_BUILD_RELOAD_KEY), '100');
82
+ assert.equal(reloadOnceForStaleBuild('100', options), false);
83
+ assert.equal(reloads, 1);
84
+
85
+ // The reloaded page runs the new build: one more reload is allowed if it misses a chunk too
86
+ assert.equal(reloadOnceForStaleBuild('200', options), true);
87
+ assert.equal(reloadOnceForStaleBuild('200', options), false);
88
+ assert.equal(reloads, 2);
89
+ });
90
+
91
+ test('two builds answering during a rollout reload once each, never in turn forever', () => {
92
+ const storage = createStorage();
93
+ let reloads = 0;
94
+ const options = { storage: () => storage, reload: () => reloads++ };
95
+
96
+ // Old build A, new HTML from B, a chunk served by A's instance, back to B...
97
+ for (const buildId of ['A', 'B', 'A', 'B', 'A']) reloadOnceForStaleBuild(buildId, options);
98
+
99
+ assert.equal(reloads, 2);
100
+ assert.equal(storage.getItem(STALE_BUILD_RELOAD_KEY), 'A,B');
101
+ });
102
+
103
+ test('a route chunk that loads gives the next missing chunk its reload again', () => {
104
+ const storage = createStorage();
105
+ let reloads = 0;
106
+ const options = { storage: () => storage, reload: () => reloads++ };
107
+
108
+ // A network blip spent the reload; the reloaded page then loaded its route chunk
109
+ assert.equal(reloadOnceForStaleBuild('100', options), true);
110
+ clearStaleBuildReloads(() => storage);
111
+ assert.equal(storage.getItem(STALE_BUILD_RELOAD_KEY), null);
112
+
113
+ // The real deploy later in the same tab, same running build
114
+ assert.equal(reloadOnceForStaleBuild('100', options), true);
115
+ assert.equal(reloads, 2);
116
+ });
117
+
118
+ test('offline, a failed chunk is the network, not a deploy: no reload and the guard stays free', () => {
119
+ const storage = createStorage();
120
+ let reloads = 0;
121
+
122
+ assert.equal(reloadOnceForStaleBuild('100', { storage: () => storage, reload: () => reloads++, isOnline: () => false }), false);
123
+ assert.equal(reloads, 0);
124
+ assert.equal(storage.getItem(STALE_BUILD_RELOAD_KEY), null);
125
+ });
126
+
127
+ test('no sessionStorage means no reload, so a blocked guard can never loop', () => {
128
+ let reloads = 0;
129
+ const blocked = () => {
130
+ throw new Error('SecurityError: access denied');
131
+ };
132
+ const originalWarn = console.warn;
133
+ console.warn = () => {};
134
+
135
+ try {
136
+ assert.equal(reloadOnceForStaleBuild('100', { storage: blocked, reload: () => reloads++ }), false);
137
+ } finally {
138
+ console.warn = originalWarn;
139
+ }
140
+ assert.equal(reloads, 0);
141
+ });
142
+
143
+ /*----------------------------------
144
+ - ROUTER
145
+ ----------------------------------*/
146
+
147
+ const silenceConsoleError = async (run) => {
148
+ const originalError = console.error;
149
+ console.error = () => {};
150
+ try {
151
+ return await run();
152
+ } finally {
153
+ console.error = originalError;
154
+ }
155
+ };
156
+
157
+ test('a route chunk the deploy removed reloads the page once instead of opening the update notice', async () => {
158
+ const { router, route, calls, storage } = createFailingRouter(createChunkLoadError());
159
+
160
+ await silenceConsoleError(async () => {
161
+ let settled = false;
162
+ router['load'](route).then(
163
+ () => (settled = true),
164
+ () => (settled = true),
165
+ );
166
+ await flush();
167
+
168
+ assert.equal(calls.reloads, 1);
169
+ assert.equal(calls.updates, 0);
170
+ // The navigation waits for the new document instead of rendering the error page
171
+ assert.equal(settled, false);
172
+ assert.equal(storage.getItem('proteum:stale-build-reload'), '1761025500000');
173
+ });
174
+ });
175
+
176
+ test('the same build missing a chunk again surfaces the error and the update notice', async () => {
177
+ const storage = createStorage();
178
+ storage.setItem('proteum:stale-build-reload', '1761025500000');
179
+ const { router, route, calls } = createFailingRouter(createCssChunkLoadError(), { storage });
180
+
181
+ await silenceConsoleError(async () => {
182
+ await assert.rejects(router['load'](route), /A new version of the website is available/);
183
+ });
184
+ assert.equal(calls.reloads, 0);
185
+ assert.equal(calls.updates, 1);
186
+ });
187
+
188
+ test('a route that loads clears the guard the previous reload left', async () => {
189
+ const storage = createStorage();
190
+ storage.setItem('proteum:stale-build-reload', '1761025500000');
191
+ const { router, route } = createFailingRouter(new Error('unused'), { storage });
192
+ route.load = () => Promise.resolve({ __register: () => ({ method: 'GET', path: '/domains', options: {}, controller: () => null }) });
193
+
194
+ await router['load'](route);
195
+ assert.equal(storage.getItem('proteum:stale-build-reload'), null);
196
+ });
197
+
198
+ test('a route module that fails for another reason keeps the update notice and never reloads', async () => {
199
+ const { router, route, calls } = createFailingRouter(new TypeError('x is undefined'));
200
+
201
+ await silenceConsoleError(async () => {
202
+ await assert.rejects(router['load'](route), /A new version of the website is available/);
203
+ });
204
+ assert.equal(calls.reloads, 0);
205
+ assert.equal(calls.updates, 1);
206
+ });
@@ -68,6 +68,15 @@ declare module 'morgan' {
68
68
  export default morgan;
69
69
  }
70
70
 
71
+ declare module 'on-headers' {
72
+ import type { ServerResponse } from 'http';
73
+
74
+ export default function onHeaders<TResponse extends ServerResponse>(
75
+ res: TResponse,
76
+ listener: (this: TResponse) => void,
77
+ ): void;
78
+ }
79
+
71
80
  declare module 'object-hash' {
72
81
  export type ObjectHashOptions = {
73
82
  unorderedArrays?: boolean;
@@ -68,6 +68,15 @@ declare module 'morgan' {
68
68
  export default morgan;
69
69
  }
70
70
 
71
+ declare module 'on-headers' {
72
+ import type { ServerResponse } from 'http';
73
+
74
+ export default function onHeaders<TResponse extends ServerResponse>(
75
+ res: TResponse,
76
+ listener: (this: TResponse) => void,
77
+ ): void;
78
+ }
79
+
71
80
  declare module 'stopword' {
72
81
  export const eng: string[];
73
82
  export function removeStopwords(words: string[], stopwords?: string[]): string[];