@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":"AAEA,OAAO,EAAE,YAAY,EAA4B,MAAM,uBAAuB,CAAC;AAU/E,eAAO,MAAM,cAAc,iBAAkB,YAAY;;;yBAI1B,GAAG,YAAY,GAAG,KAAG,QAAQ,IAAI,CAAC;CA+ChE,CAAC"}
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":"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"}
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.7.0",
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.2.0",
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": "572441dede77a494f9fbfa2753896d0eccfb6d40"
67
+ "gitHead": "b0b0c21b42e6e4feb27ccaae147c047f0786b932"
68
68
  }
@@ -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
+ });