@proteinjs/server 3.7.0 → 3.8.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/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [3.8.0](https://github.com/proteinjs/server/compare/@proteinjs/server@3.7.0...@proteinjs/server@3.8.0) (2026-09-17)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **server-api:** server-rendered head tags — a ServerRenderedHeadTag loadable rendered into the page's <head> ([816b92e](https://github.com/proteinjs/server/commit/816b92ee1595681db1a9e92a364e0997e82081ad))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
6
17
|
# [3.7.0](https://github.com/proteinjs/server/compare/@proteinjs/server@3.6.0...@proteinjs/server@3.7.0) (2026-09-12)
|
|
7
18
|
|
|
8
19
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reactApp.d.ts","sourceRoot":"","sources":["../../../src/routes/reactApp.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"reactApp.d.ts","sourceRoot":"","sources":["../../../src/routes/reactApp.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAuD,MAAM,uBAAuB,CAAC;AAU1G,eAAO,MAAM,cAAc,iBAAkB,YAAY;;;yBAI1B,GAAG,YAAY,GAAG,KAAG,QAAQ,IAAI,CAAC;CAiDhE,CAAC"}
|
|
@@ -55,7 +55,7 @@ var createReactApp = function (serverConfig) {
|
|
|
55
55
|
path: '*',
|
|
56
56
|
method: 'get',
|
|
57
57
|
onRequest: function (request, response) { return __awaiter(void 0, void 0, void 0, function () {
|
|
58
|
-
var helmet, _a, serverRenderedScripts, bundleUrls;
|
|
58
|
+
var helmet, _a, serverRenderedScripts, bundleUrls, headTags;
|
|
59
59
|
var _b, _c, _d;
|
|
60
60
|
return __generator(this, function (_e) {
|
|
61
61
|
switch (_e.label) {
|
|
@@ -70,9 +70,10 @@ var createReactApp = function (serverConfig) {
|
|
|
70
70
|
return [4 /*yield*/, Promise.all([
|
|
71
71
|
serverRenderedScriptTags(),
|
|
72
72
|
bundleScriptUrls(serverConfig),
|
|
73
|
+
serverRenderedHeadTags(request),
|
|
73
74
|
])];
|
|
74
75
|
case 1:
|
|
75
|
-
_a = _e.sent(), serverRenderedScripts = _a[0], bundleUrls = _a[1];
|
|
76
|
+
_a = _e.sent(), serverRenderedScripts = _a[0], bundleUrls = _a[1], headTags = _a[2];
|
|
76
77
|
// The page must ALWAYS revalidate (found live 2026-09-01, the mobile-app stale-page
|
|
77
78
|
// investigation): without an explicit policy, HTTP heuristic caching applies (RFC 9111
|
|
78
79
|
// §4.2.2) — WKWebView in particular can serve the cached page without revalidating,
|
|
@@ -80,7 +81,7 @@ var createReactApp = function (serverConfig) {
|
|
|
80
81
|
// bundles under /static keep their long cache (a fresh page always points at fresh
|
|
81
82
|
// hashes); `no-cache` + the ETag express already stamps = a cheap 304 on every load.
|
|
82
83
|
response.set('Cache-Control', 'no-cache');
|
|
83
|
-
response.send("<!DOCTYPE html>\n <html ".concat(helmet.htmlAttributes, ">\n <head>\n <meta charset='utf-8' />\n <meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover'>\n <meta name='theme-color' content='").concat(THEME_COLOR_LIGHT, "' media='(prefers-color-scheme: light)'>\n <meta name='theme-color' content='").concat(THEME_COLOR_DARK, "' media='(prefers-color-scheme: dark)'>\n <link href='").concat(((_d = serverConfig.staticContent) === null || _d === void 0 ? void 0 : _d.faviconPath) ? path_1.default.join('/static/', serverConfig.staticContent.faviconPath) : '', "' rel='icon' type='image/png' />\n ").concat(bundlePreloadTags(bundleUrls), "\n ").concat(helmet.title.toString(), "\n ").concat(helmet.meta.toString(), "\n ").concat(helmet.link.toString(), "\n </head>\n <body ").concat(helmet.bodyAttributes.toString(), ">\n <div id='app'></div>\n <script>proteinjs = {};</script>\n ").concat(serverRenderedScripts, "\n ").concat(bundleScriptTags(bundleUrls), "\n </body>\n </html>"));
|
|
84
|
+
response.send("<!DOCTYPE html>\n <html ".concat(helmet.htmlAttributes, ">\n <head>\n <meta charset='utf-8' />\n <meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover'>\n <meta name='theme-color' content='").concat(THEME_COLOR_LIGHT, "' media='(prefers-color-scheme: light)'>\n <meta name='theme-color' content='").concat(THEME_COLOR_DARK, "' media='(prefers-color-scheme: dark)'>\n <link href='").concat(((_d = serverConfig.staticContent) === null || _d === void 0 ? void 0 : _d.faviconPath) ? path_1.default.join('/static/', serverConfig.staticContent.faviconPath) : '', "' rel='icon' type='image/png' />\n ").concat(bundlePreloadTags(bundleUrls), "\n ").concat(helmet.title.toString(), "\n ").concat(helmet.meta.toString(), "\n ").concat(helmet.link.toString(), "\n ").concat(headTags, "\n </head>\n <body ").concat(helmet.bodyAttributes.toString(), ">\n <div id='app'></div>\n <script>proteinjs = {};</script>\n ").concat(serverRenderedScripts, "\n ").concat(bundleScriptTags(bundleUrls), "\n </body>\n </html>"));
|
|
84
85
|
return [2 /*return*/];
|
|
85
86
|
}
|
|
86
87
|
});
|
|
@@ -123,9 +124,7 @@ function bundleScriptUrls(serverConfig) {
|
|
|
123
124
|
return [4 /*yield*/, util_node_1.Fs.getFilePathsMatchingGlob(resolvedBundlesDir, '**/*.js')];
|
|
124
125
|
case 1:
|
|
125
126
|
filePaths = _f.sent();
|
|
126
|
-
return [2 /*return*/, filePaths
|
|
127
|
-
.map(function (filePath) { return path_1.default.join('/static/', path_1.default.relative(staticContentDir_1, filePath)); })
|
|
128
|
-
.sort()];
|
|
127
|
+
return [2 /*return*/, filePaths.map(function (filePath) { return path_1.default.join('/static/', path_1.default.relative(staticContentDir_1, filePath)); }).sort()];
|
|
129
128
|
case 2: return [2 /*return*/, []];
|
|
130
129
|
}
|
|
131
130
|
});
|
|
@@ -161,4 +160,23 @@ function serverRenderedScriptTags() {
|
|
|
161
160
|
});
|
|
162
161
|
});
|
|
163
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Every server-rendered head tag rendered CONCURRENTLY for this request (the same one round the
|
|
165
|
+
* scripts ride); emitted in registration order, an empty render dropped.
|
|
166
|
+
*/
|
|
167
|
+
function serverRenderedHeadTags(request) {
|
|
168
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
169
|
+
var tags, rendered;
|
|
170
|
+
return __generator(this, function (_a) {
|
|
171
|
+
switch (_a.label) {
|
|
172
|
+
case 0:
|
|
173
|
+
tags = (0, server_api_1.getServerRenderedHeadTags)();
|
|
174
|
+
return [4 /*yield*/, Promise.all(tags.map(function (tag) { return tag.headTag(request); }))];
|
|
175
|
+
case 1:
|
|
176
|
+
rendered = _a.sent();
|
|
177
|
+
return [2 /*return*/, rendered.filter(function (tag) { return tag.length > 0; }).join('\n')];
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
164
182
|
//# sourceMappingURL=reactApp.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reactApp.js","sourceRoot":"","sources":["../../../src/routes/reactApp.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8CAAwB;
|
|
1
|
+
{"version":3,"file":"reactApp.js","sourceRoot":"","sources":["../../../src/routes/reactApp.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "A server impl",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@proteinjs/event": "^1.0.18",
|
|
26
26
|
"@proteinjs/logger": "^1.0.21",
|
|
27
27
|
"@proteinjs/reflection": "^1.1.12",
|
|
28
|
-
"@proteinjs/server-api": "^3.
|
|
28
|
+
"@proteinjs/server-api": "^3.3.0",
|
|
29
29
|
"@proteinjs/util-node": "^1.10.1",
|
|
30
30
|
"body-parser": "1.19.0",
|
|
31
31
|
"cookie-parser": "1.4.5",
|
|
@@ -64,5 +64,5 @@
|
|
|
64
64
|
},
|
|
65
65
|
"main": "./dist/generated/index.js",
|
|
66
66
|
"types": "./dist/generated/index.d.ts",
|
|
67
|
-
"gitHead": "
|
|
67
|
+
"gitHead": "b0b0c21b42e6e4feb27ccaae147c047f0786b932"
|
|
68
68
|
}
|
package/src/routes/reactApp.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
+
import express from 'express';
|
|
2
3
|
import ReactHelmet from 'react-helmet';
|
|
3
|
-
import { ServerConfig, getServerRenderedScripts } from '@proteinjs/server-api';
|
|
4
|
+
import { ServerConfig, getServerRenderedHeadTags, getServerRenderedScripts } from '@proteinjs/server-api';
|
|
4
5
|
import { Fs } from '@proteinjs/util-node';
|
|
5
6
|
import { DevClientBuild } from '../DevClientBuild';
|
|
6
7
|
|
|
@@ -28,9 +29,10 @@ export const createReactApp = (serverConfig: ServerConfig) => {
|
|
|
28
29
|
// and the bundle listing run concurrently — the page's TTFB is the SLOWEST of them, not their
|
|
29
30
|
// sum (measured on a phone: 5–6 reads awaited one after another sat in front of every byte
|
|
30
31
|
// of the page).
|
|
31
|
-
const [serverRenderedScripts, bundleUrls] = await Promise.all([
|
|
32
|
+
const [serverRenderedScripts, bundleUrls, headTags] = await Promise.all([
|
|
32
33
|
serverRenderedScriptTags(),
|
|
33
34
|
bundleScriptUrls(serverConfig),
|
|
35
|
+
serverRenderedHeadTags(request),
|
|
34
36
|
]);
|
|
35
37
|
// The page must ALWAYS revalidate (found live 2026-09-01, the mobile-app stale-page
|
|
36
38
|
// investigation): without an explicit policy, HTTP heuristic caching applies (RFC 9111
|
|
@@ -51,6 +53,7 @@ export const createReactApp = (serverConfig: ServerConfig) => {
|
|
|
51
53
|
${helmet.title.toString()}
|
|
52
54
|
${helmet.meta.toString()}
|
|
53
55
|
${helmet.link.toString()}
|
|
56
|
+
${headTags}
|
|
54
57
|
</head>
|
|
55
58
|
<body ${helmet.bodyAttributes.toString()}>
|
|
56
59
|
<div id='app'></div>
|
|
@@ -121,3 +124,13 @@ async function serverRenderedScriptTags(): Promise<string> {
|
|
|
121
124
|
const rendered = await Promise.all(scripts.map((script) => script.script()));
|
|
122
125
|
return rendered.map((script) => `<script>${script}</script>`).join('\n');
|
|
123
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Every server-rendered head tag rendered CONCURRENTLY for this request (the same one round the
|
|
130
|
+
* scripts ride); emitted in registration order, an empty render dropped.
|
|
131
|
+
*/
|
|
132
|
+
async function serverRenderedHeadTags(request: express.Request): Promise<string> {
|
|
133
|
+
const tags = getServerRenderedHeadTags();
|
|
134
|
+
const rendered = await Promise.all(tags.map((tag) => tag.headTag(request)));
|
|
135
|
+
return rendered.filter((tag) => tag.length > 0).join('\n');
|
|
136
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server-rendered HEAD tags (`ServerRenderedHeadTag`): the head counterpart of the server-rendered
|
|
7
|
+
* scripts. A browser reads some tags at parse time — a `<meta>` the client bundle would inject too
|
|
8
|
+
* late — so the page carries them in the served document:
|
|
9
|
+
*
|
|
10
|
+
* 1. every registered tag lands inside `<head>`, in registration order, rendered concurrently in
|
|
11
|
+
* the page's ONE boot round (started before any resolves, like the scripts);
|
|
12
|
+
* 2. the request is handed to each tag, so a tag can name the page being served;
|
|
13
|
+
* 3. an empty render is dropped — nothing for this request, no blank line left behind.
|
|
14
|
+
*/
|
|
15
|
+
const scripts: { script: () => Promise<string> }[] = [];
|
|
16
|
+
const headTags: { headTag: (request: unknown) => Promise<string> }[] = [];
|
|
17
|
+
jest.mock('@proteinjs/server-api', () => ({
|
|
18
|
+
...jest.requireActual('@proteinjs/server-api'),
|
|
19
|
+
getServerRenderedScripts: () => scripts,
|
|
20
|
+
getServerRenderedHeadTags: () => headTags,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { createReactApp } from '../src/routes/reactApp';
|
|
24
|
+
|
|
25
|
+
type Rendered = { headers: Record<string, string>; html: string };
|
|
26
|
+
|
|
27
|
+
function renderInto(rendered: Rendered, staticContent: Record<string, unknown>, request: unknown): Promise<void> {
|
|
28
|
+
const response = {
|
|
29
|
+
set: (name: string, value: string) => {
|
|
30
|
+
rendered.headers[name] = value;
|
|
31
|
+
},
|
|
32
|
+
send: (html: string) => {
|
|
33
|
+
rendered.html = html;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return createReactApp({ staticContent } as any).onRequest(request, response);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function render(staticContent: Record<string, unknown>, request: unknown = { path: '/' }): Promise<Rendered> {
|
|
40
|
+
const rendered: Rendered = { headers: {}, html: '' };
|
|
41
|
+
await renderInto(rendered, staticContent, request);
|
|
42
|
+
return rendered;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const flushMicrotasks = async () => {
|
|
46
|
+
for (let i = 0; i < 10; i++) {
|
|
47
|
+
await Promise.resolve();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const headOf = (html: string) => html.slice(html.indexOf('<head>'), html.indexOf('</head>'));
|
|
52
|
+
|
|
53
|
+
describe('server-rendered head tags', () => {
|
|
54
|
+
let tmp: string;
|
|
55
|
+
const env = {
|
|
56
|
+
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
57
|
+
DISABLE_HOT_CLIENT_BUILDS: process.env.DISABLE_HOT_CLIENT_BUILDS,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
scripts.length = 0;
|
|
62
|
+
headTags.length = 0;
|
|
63
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'react-app-head-'));
|
|
64
|
+
fs.mkdirSync(path.join(tmp, 'bundles'));
|
|
65
|
+
fs.writeFileSync(path.join(tmp, 'bundles', 'app.js'), '');
|
|
66
|
+
delete process.env.DEVELOPMENT;
|
|
67
|
+
delete process.env.DISABLE_HOT_CLIENT_BUILDS;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
72
|
+
for (const [name, value] of Object.entries(env)) {
|
|
73
|
+
if (value === undefined) {
|
|
74
|
+
delete process.env[name];
|
|
75
|
+
} else {
|
|
76
|
+
process.env[name] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const staticContent = () => ({ staticContentDir: tmp, bundlesDir: 'bundles' });
|
|
82
|
+
|
|
83
|
+
it('renders every tag inside <head>, in registration order, handing each the request', async () => {
|
|
84
|
+
headTags.push(
|
|
85
|
+
{ headTag: async (request: any) => `<meta name="page" content="${request.originalUrl}">` },
|
|
86
|
+
{ headTag: async () => `<link rel="manifest" href="/manifest.json">` }
|
|
87
|
+
);
|
|
88
|
+
const { html } = await render(staticContent(), { path: '/', originalUrl: '/things?id=7' });
|
|
89
|
+
const head = headOf(html);
|
|
90
|
+
expect(head).toContain('<meta name="page" content="/things?id=7">');
|
|
91
|
+
expect(head).toContain('<link rel="manifest" href="/manifest.json">');
|
|
92
|
+
expect(head.indexOf('<meta name="page"')).toBeLessThan(head.indexOf('<link rel="manifest"'));
|
|
93
|
+
// Never in the body.
|
|
94
|
+
expect(html.slice(html.indexOf('<body'))).not.toContain('<meta name="page"');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('drops an empty render — nothing for this request leaves nothing behind', async () => {
|
|
98
|
+
const blankLines = (head: string) => head.split('\n').filter((line) => line.trim() === '').length;
|
|
99
|
+
headTags.push({ headTag: async () => '<meta name="only" content="1">' });
|
|
100
|
+
const alone = blankLines(headOf((await render(staticContent())).html));
|
|
101
|
+
|
|
102
|
+
headTags.unshift({ headTag: async () => '' });
|
|
103
|
+
const { html } = await render(staticContent());
|
|
104
|
+
const head = headOf(html);
|
|
105
|
+
expect(head).toContain('<meta name="only" content="1">');
|
|
106
|
+
// The empty render added no line of its own: the head carries exactly the blank lines it
|
|
107
|
+
// carries with the one tag alone (an unfiltered empty would join as a stray newline).
|
|
108
|
+
expect(blankLines(head)).toBe(alone);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('a page with no registered tags renders exactly as before', async () => {
|
|
112
|
+
const { html } = await render(staticContent());
|
|
113
|
+
expect(html).toContain('<head>');
|
|
114
|
+
expect(html).not.toContain('undefined');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('starts every tag before any resolves — the one boot round the scripts ride', async () => {
|
|
118
|
+
const started: string[] = [];
|
|
119
|
+
const release: Record<string, () => void> = {};
|
|
120
|
+
const gated = (name: string) => ({
|
|
121
|
+
headTag: () =>
|
|
122
|
+
new Promise<string>((resolve) => {
|
|
123
|
+
started.push(name);
|
|
124
|
+
release[name] = () => resolve(`<meta name="${name}">`);
|
|
125
|
+
}),
|
|
126
|
+
});
|
|
127
|
+
headTags.push(gated('a'), gated('b'));
|
|
128
|
+
scripts.push({ script: async () => (started.push('script'), 'window.s = 1;') });
|
|
129
|
+
|
|
130
|
+
const rendered: Rendered = { headers: {}, html: '' };
|
|
131
|
+
const done = renderInto(rendered, staticContent(), { path: '/' });
|
|
132
|
+
await flushMicrotasks();
|
|
133
|
+
expect(started).toEqual(['script', 'a', 'b']);
|
|
134
|
+
expect(rendered.html).toBe('');
|
|
135
|
+
release.b();
|
|
136
|
+
await flushMicrotasks();
|
|
137
|
+
release.a();
|
|
138
|
+
await done;
|
|
139
|
+
const head = headOf(rendered.html);
|
|
140
|
+
expect(head.indexOf('<meta name="a">')).toBeLessThan(head.indexOf('<meta name="b">'));
|
|
141
|
+
});
|
|
142
|
+
});
|