@proteinjs/server 3.5.2 → 3.5.3
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 +11 -0
- package/dist/src/routes/reactApp.d.ts.map +1 -1
- package/dist/src/routes/reactApp.js +7 -0
- package/dist/src/routes/reactApp.js.map +1 -1
- package/package.json +2 -2
- package/src/routes/reactApp.ts +7 -0
- package/test/fixture/gracefulShutdownServer.js +4 -0
- package/test/htmlCacheControl.test.ts +129 -0
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.5.3](https://github.com/proteinjs/server/compare/@proteinjs/server@3.5.2...@proteinjs/server@3.5.3) (2026-09-01)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **server:** the react-app HTML response says Cache-Control: no-cache — the page always revalidates (found live 2026-09-01, the mobile-app stale-page investigation). The '*' route sent HTML with an ETag but NO Cache-Control: with no explicit policy, HTTP heuristic caching applies (RFC 9111 §4.2.2) and WKWebView in particular can serve the cached page without revalidating — across app force-quits — pinning stale bundle POINTERS long after a deploy. The hashed bundles under /static keep their long cache (a fresh page always points at fresh hashes); the PAGE must always revalidate. no-cache + the ETag express already stamps = a cheap 304 on every load, never a stale page. Red-before-green: htmlCacheControl.test.ts (front door — a real spawned server, the LB's X-Forwarded-Proto topology) read cache-control undefined at the pre-fix dist (2 red), green after; / and /chat both pinned (the catch-all is the one HTML owner). Fixture gains a bundlePaths pointer so the react-app route serves its page; full estate 5 suites / 11 green (9 pre-existing). ([076536f](https://github.com/proteinjs/server/commit/076536f158ce1c91b073061345fe7aae802b4f38))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
6
17
|
## [3.5.2](https://github.com/proteinjs/server/compare/@proteinjs/server@3.5.1...@proteinjs/server@3.5.2) (2026-08-27)
|
|
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;
|
|
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;CAsChE,CAAC"}
|
|
@@ -67,6 +67,13 @@ var createReactApp = function (serverConfig) {
|
|
|
67
67
|
throw new Error("ServerConfig.bundlePath or ServerConfig.bundlesDir must be provided to serve a react app");
|
|
68
68
|
}
|
|
69
69
|
helmet = react_helmet_1.default.renderStatic();
|
|
70
|
+
// The page must ALWAYS revalidate (found live 2026-09-01, the mobile-app stale-page
|
|
71
|
+
// investigation): without an explicit policy, HTTP heuristic caching applies (RFC 9111
|
|
72
|
+
// §4.2.2) — WKWebView in particular can serve the cached page without revalidating,
|
|
73
|
+
// across app force-quits, pinning stale bundle POINTERS long after a deploy. The hashed
|
|
74
|
+
// bundles under /static keep their long cache (a fresh page always points at fresh
|
|
75
|
+
// hashes); `no-cache` + the ETag express already stamps = a cheap 304 on every load.
|
|
76
|
+
response.set('Cache-Control', 'no-cache');
|
|
70
77
|
_b = (_a = response).send;
|
|
71
78
|
_e = (_d = "<!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(((_j = serverConfig.staticContent) === null || _j === void 0 ? void 0 : _j.faviconPath) ? path_1.default.join('/static/', serverConfig.staticContent.faviconPath) : '', "' rel='icon' type='image/png' />\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;
|
|
72
79
|
return [4 /*yield*/, serverRenderedScriptTags()];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reactApp.js","sourceRoot":"","sources":["../../../src/routes/reactApp.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8CAAwB;AACxB,8DAAuC;AACvC,oDAA+E;AAC/E,kDAA0C;AAC1C,oDAAmD;AAEnD,iFAAiF;AACjF,mFAAmF;AACnF,kEAAkE;AAClE,IAAM,iBAAiB,GAAG,SAAS,CAAC;AACpC,IAAM,gBAAgB,GAAG,SAAS,CAAC;AAE5B,IAAM,cAAc,GAAG,UAAC,YAA0B;IACvD,OAAO;QACL,IAAI,EAAE,GAAG;QACT,MAAM,EAAE,KAAc;QACtB,SAAS,EAAE,UAAO,OAAY,EAAE,QAAa;;;;;;wBAC3C,IAAI,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE;4BACtC,sBAAO;yBACR;wBAED,IAAI,CAAC,CAAC,CAAA,MAAA,YAAY,CAAC,aAAa,0CAAE,WAAW,MAAI,MAAA,YAAY,CAAC,aAAa,0CAAE,UAAU,CAAA,CAAC,EAAE;4BACxF,MAAM,IAAI,KAAK,CAAC,0FAA0F,CAAC,CAAC;yBAC7G;wBAEK,MAAM,GAAG,sBAAW,CAAC,YAAY,EAAE,CAAC;wBAC1C,KAAA,CAAA,KAAA,QAAQ,CAAA,CAAC,IAAI,CAAA;oFACK,MAAM,CAAC,cAAc,6RAIe,iBAAiB,iHACjB,gBAAgB,0FACtC,CAAA,MAAA,YAAY,CAAC,aAAa,0CAAE,WAAW,EAAC,CAAC,CAAC,cAAI,CAAC,IAAI,CAAC,UAAU,EAAE,YAAY,CAAC,aAAa,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EAAE,uEACxH,MAAM,CAAC,KAAK,CAAC,QAAQ,EAAE,uCACvB,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,uCACtB,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,sEAEpB,MAAM,CAAC,cAAc,CAAC,QAAQ,EAAE;wBAGlC,qBAAM,wBAAwB,EAAE,EAAA;;iDAAhC,SAAgC;wBAChC,qBAAM,gBAAgB,CAAC,YAAY,CAAC,EAAA;;wBAhBxD,cAAc,cAgBM,SAAoC,4DAEtC,EAAC,CAAC;;;;aACrB;KACF,CAAC;AACJ,CAAC,CAAC;
|
|
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.5.
|
|
3
|
+
"version": "3.5.3",
|
|
4
4
|
"description": "A server impl",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -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": "10387d6d8f4af3b4f6d6a320741504e1ce5d1bdb"
|
|
68
68
|
}
|
package/src/routes/reactApp.ts
CHANGED
|
@@ -24,6 +24,13 @@ export const createReactApp = (serverConfig: ServerConfig) => {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const helmet = ReactHelmet.renderStatic();
|
|
27
|
+
// The page must ALWAYS revalidate (found live 2026-09-01, the mobile-app stale-page
|
|
28
|
+
// investigation): without an explicit policy, HTTP heuristic caching applies (RFC 9111
|
|
29
|
+
// §4.2.2) — WKWebView in particular can serve the cached page without revalidating,
|
|
30
|
+
// across app force-quits, pinning stale bundle POINTERS long after a deploy. The hashed
|
|
31
|
+
// bundles under /static keep their long cache (a fresh page always points at fresh
|
|
32
|
+
// hashes); `no-cache` + the ETag express already stamps = a cheap 304 on every load.
|
|
33
|
+
response.set('Cache-Control', 'no-cache');
|
|
27
34
|
response.send(`<!DOCTYPE html>
|
|
28
35
|
<html ${helmet.htmlAttributes}>
|
|
29
36
|
<head>
|
|
@@ -54,6 +54,10 @@ startServer({
|
|
|
54
54
|
response.status(200).send('slow-done');
|
|
55
55
|
},
|
|
56
56
|
},
|
|
57
|
+
// A bundle pointer so the '*' react-app route serves its HTML — the html-cache-control
|
|
58
|
+
// suite asserts response headers on the page the app actually ships. No staticContentDir:
|
|
59
|
+
// nothing needs the bundle to resolve, only the page response to render.
|
|
60
|
+
staticContent: { bundlePaths: ['bundles/app.test.js'] },
|
|
57
61
|
shutdown: {
|
|
58
62
|
drainDelayMs: process.env.FIXTURE_DRAIN_DELAY_MS ? Number(process.env.FIXTURE_DRAIN_DELAY_MS) : undefined,
|
|
59
63
|
drainTimeoutMs: process.env.FIXTURE_DRAIN_TIMEOUT_MS ? Number(process.env.FIXTURE_DRAIN_TIMEOUT_MS) : undefined,
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import * as net from 'net';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { ChildProcess, spawn } from 'child_process';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HTML cache policy of a real server process (spawns the built dist — run `npm run build`
|
|
8
|
+
* first), asserted through the front door on the page the react-app route actually sends.
|
|
9
|
+
*
|
|
10
|
+
* THE CLASS THIS PINS (found live 2026-09-01, the mobile-app stale-page investigation): the
|
|
11
|
+
* HTML response carried NO Cache-Control header — an ETag only. With no explicit policy,
|
|
12
|
+
* HTTP heuristic caching applies (RFC 9111 §4.2.2), and WKWebView in particular can serve
|
|
13
|
+
* the cached page without revalidating — across app force-quits — pinning stale bundle
|
|
14
|
+
* POINTERS long after a deploy. The hashed bundles under /static keep their long cache (a
|
|
15
|
+
* new page always points at new hashes); the PAGE must always revalidate. `no-cache` states
|
|
16
|
+
* exactly that, and the ETag the response already carries keeps revalidation a cheap 304.
|
|
17
|
+
*/
|
|
18
|
+
const packageRoot = path.join(__dirname, '..');
|
|
19
|
+
const fixturePath = path.join(__dirname, 'fixture', 'gracefulShutdownServer.js');
|
|
20
|
+
|
|
21
|
+
describe('html cache policy', () => {
|
|
22
|
+
let fixture: Fixture | undefined;
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
if (fixture && fixture.child.exitCode === null && fixture.child.signalCode === null) {
|
|
26
|
+
fixture.child.kill('SIGKILL');
|
|
27
|
+
await fixture.exited.catch(() => undefined);
|
|
28
|
+
}
|
|
29
|
+
fixture = undefined;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('the react-app HTML response says Cache-Control: no-cache — the page always revalidates', async () => {
|
|
33
|
+
fixture = await startFixture();
|
|
34
|
+
|
|
35
|
+
const result = await request(fixture.port, '/');
|
|
36
|
+
expect(result.status).toBe(200);
|
|
37
|
+
// Sanity: this IS the react-app page (the configured bundle pointer is in the body).
|
|
38
|
+
expect(result.body).toContain('bundles/app.test.js');
|
|
39
|
+
expect(result.headers['content-type']).toContain('text/html');
|
|
40
|
+
expect(result.headers['cache-control']).toBe('no-cache');
|
|
41
|
+
}, 30000);
|
|
42
|
+
|
|
43
|
+
it('every page path rides the same policy (the catch-all route is the one HTML owner)', async () => {
|
|
44
|
+
fixture = await startFixture();
|
|
45
|
+
|
|
46
|
+
const result = await request(fixture.port, '/chat');
|
|
47
|
+
expect(result.status).toBe(200);
|
|
48
|
+
expect(result.headers['content-type']).toContain('text/html');
|
|
49
|
+
expect(result.headers['cache-control']).toBe('no-cache');
|
|
50
|
+
}, 30000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
type Fixture = {
|
|
54
|
+
child: ChildProcess;
|
|
55
|
+
port: number;
|
|
56
|
+
exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
async function startFixture(): Promise<Fixture> {
|
|
60
|
+
const port = await ephemeralPort();
|
|
61
|
+
// Scrub the env vars startServer reads (dev machines export some of these): the fixture's
|
|
62
|
+
// behavior must come from its own config only.
|
|
63
|
+
const env = { ...process.env };
|
|
64
|
+
delete env.SERVER_PORT;
|
|
65
|
+
delete env.DEVELOPMENT;
|
|
66
|
+
delete env.DISABLE_HOT_CLIENT_BUILDS;
|
|
67
|
+
delete env.HMR_PORT;
|
|
68
|
+
const child = spawn(process.execPath, [fixturePath], {
|
|
69
|
+
cwd: packageRoot,
|
|
70
|
+
env: { ...env, FIXTURE_PORT: String(port) },
|
|
71
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
72
|
+
});
|
|
73
|
+
let output = '';
|
|
74
|
+
child.stdout!.on('data', (chunk) => (output += String(chunk)));
|
|
75
|
+
child.stderr!.on('data', (chunk) => (output += String(chunk)));
|
|
76
|
+
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) =>
|
|
77
|
+
child.once('exit', (code, signal) => resolve({ code, signal }))
|
|
78
|
+
);
|
|
79
|
+
// Ready = the health check answers 200 (readiness through the front door, not log lines).
|
|
80
|
+
const deadline = Date.now() + 20000;
|
|
81
|
+
for (;;) {
|
|
82
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
83
|
+
throw new Error(`Fixture exited during boot; output:\n${output}`);
|
|
84
|
+
}
|
|
85
|
+
const result = await request(port, '/health-check').catch(() => undefined);
|
|
86
|
+
if (result?.status === 200) {
|
|
87
|
+
return { child, port, exited };
|
|
88
|
+
}
|
|
89
|
+
if (Date.now() > deadline) {
|
|
90
|
+
throw new Error(`Fixture never became ready; output:\n${output}`);
|
|
91
|
+
}
|
|
92
|
+
await sleep(100);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One request on its OWN connection (agent: false — no keep-alive pooling). The forwarded
|
|
97
|
+
* proto is the LB topology in miniature: the prod shape 302s plain http to https. */
|
|
98
|
+
function request(
|
|
99
|
+
port: number,
|
|
100
|
+
requestPath: string
|
|
101
|
+
): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const req = http.get(
|
|
104
|
+
{ host: '127.0.0.1', port, path: requestPath, agent: false, headers: { 'X-Forwarded-Proto': 'https' } },
|
|
105
|
+
(res) => {
|
|
106
|
+
let body = '';
|
|
107
|
+
res.on('data', (chunk) => (body += String(chunk)));
|
|
108
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body, headers: res.headers }));
|
|
109
|
+
res.on('error', reject);
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
req.on('error', reject);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function ephemeralPort(): Promise<number> {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const probe = net.createServer();
|
|
119
|
+
probe.once('error', reject);
|
|
120
|
+
probe.listen(0, '127.0.0.1', () => {
|
|
121
|
+
const address = probe.address() as net.AddressInfo;
|
|
122
|
+
probe.close(() => resolve(address.port));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sleep(ms: number): Promise<void> {
|
|
128
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
129
|
+
}
|