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,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
+ });
@@ -0,0 +1,73 @@
1
+ const assert = require('node:assert/strict');
2
+
3
+ const { createRouter, restore } = require('./clientRouterHarness.cjs');
4
+
5
+ test('on() removes a listener by reference, whatever was removed before', () => {
6
+ const router = createRouter();
7
+ const calls = [];
8
+ const removeFirst = router.on('page.changed', () => calls.push('first'));
9
+ const removeSecond = router.on('page.changed', () => calls.push('second'));
10
+ router.on('page.changed', () => calls.push('third'));
11
+
12
+ // An index-based remover would drop "third" here, since "second" moved to index 0
13
+ removeFirst();
14
+ removeSecond();
15
+ router.runHook('page.changed', {});
16
+
17
+ assert.deepEqual(calls, ['third']);
18
+ });
19
+
20
+ test('a listener can remove itself while its hook runs', () => {
21
+ const router = createRouter();
22
+ const calls = [];
23
+ const removeOnce = router.on('page.ready', () => {
24
+ calls.push('once');
25
+ removeOnce();
26
+ });
27
+ router.on('page.ready', () => calls.push('always'));
28
+
29
+ router.runHook('page.ready', {});
30
+ router.runHook('page.ready', {});
31
+
32
+ assert.deepEqual(calls, ['once', 'always', 'always']);
33
+ });
34
+
35
+ test('a listener added while its hook runs waits for the next call, and one remover drops every registration', () => {
36
+ const router = createRouter();
37
+ const calls = [];
38
+ const late = () => calls.push('late');
39
+ router.on('page.changed', () => {
40
+ calls.push('first');
41
+ if (!calls.includes('added')) {
42
+ calls.push('added');
43
+ router.on('page.changed', late);
44
+ }
45
+ });
46
+
47
+ router.runHook('page.changed', {});
48
+ assert.deepEqual(calls, ['first', 'added']);
49
+ router.runHook('page.changed', {});
50
+ assert.deepEqual(calls, ['first', 'added', 'first', 'late']);
51
+
52
+ const twice = () => calls.push('twice');
53
+ const removeTwice = router.on('page.ready', twice);
54
+ router.on('page.ready', twice);
55
+ removeTwice();
56
+ router.runHook('page.ready', {});
57
+ assert.equal(calls.includes('twice'), false);
58
+ });
59
+
60
+ test('page.ready listeners receive the request', () => {
61
+ const router = createRouter();
62
+ const request = { path: '/radars' };
63
+ let received;
64
+ router.on('page.ready', (value) => {
65
+ received = value;
66
+ });
67
+
68
+ router.runHook('page.ready', request);
69
+
70
+ assert.equal(received, request);
71
+ });
72
+
73
+ afterAll(restore);