@proteinjs/server 3.5.3 → 3.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.
- package/CHANGELOG.md +22 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/DevClientBuild.d.ts +6 -0
- package/dist/src/DevClientBuild.d.ts.map +1 -1
- package/dist/src/DevClientBuild.js.map +1 -1
- package/dist/src/GracefulShutdown.d.ts +56 -3
- package/dist/src/GracefulShutdown.d.ts.map +1 -1
- package/dist/src/GracefulShutdown.js +148 -7
- package/dist/src/GracefulShutdown.js.map +1 -1
- package/dist/src/routes/reactApp.d.ts.map +1 -1
- package/dist/src/routes/reactApp.js +65 -60
- package/dist/src/routes/reactApp.js.map +1 -1
- package/dist/src/startServer.js +17 -2
- package/dist/src/startServer.js.map +1 -1
- package/generated/index.ts +1 -1
- package/package.json +3 -3
- package/src/DevClientBuild.ts +6 -0
- package/src/GracefulShutdown.ts +133 -5
- package/src/routes/reactApp.ts +57 -31
- package/src/startServer.ts +16 -2
- package/test/fixture/gracefulShutdownServer.js +21 -2
- package/test/gracefulShutdown.test.ts +68 -1
- package/test/holdSeam.test.ts +82 -0
- package/test/reactAppBoot.test.ts +168 -0
package/src/routes/reactApp.ts
CHANGED
|
@@ -24,6 +24,14 @@ export const createReactApp = (serverConfig: ServerConfig) => {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const helmet = ReactHelmet.renderStatic();
|
|
27
|
+
// ONE boot round: the server-rendered scripts (each a settings read for the logged-in user)
|
|
28
|
+
// and the bundle listing run concurrently — the page's TTFB is the SLOWEST of them, not their
|
|
29
|
+
// sum (measured on a phone: 5–6 reads awaited one after another sat in front of every byte
|
|
30
|
+
// of the page).
|
|
31
|
+
const [serverRenderedScripts, bundleUrls] = await Promise.all([
|
|
32
|
+
serverRenderedScriptTags(),
|
|
33
|
+
bundleScriptUrls(serverConfig),
|
|
34
|
+
]);
|
|
27
35
|
// The page must ALWAYS revalidate (found live 2026-09-01, the mobile-app stale-page
|
|
28
36
|
// investigation): without an explicit policy, HTTP heuristic caching applies (RFC 9111
|
|
29
37
|
// §4.2.2) — WKWebView in particular can serve the cached page without revalidating,
|
|
@@ -39,6 +47,7 @@ export const createReactApp = (serverConfig: ServerConfig) => {
|
|
|
39
47
|
<meta name='theme-color' content='${THEME_COLOR_LIGHT}' media='(prefers-color-scheme: light)'>
|
|
40
48
|
<meta name='theme-color' content='${THEME_COLOR_DARK}' media='(prefers-color-scheme: dark)'>
|
|
41
49
|
<link href='${serverConfig.staticContent?.faviconPath ? path.join('/static/', serverConfig.staticContent.faviconPath) : ''}' rel='icon' type='image/png' />
|
|
50
|
+
${bundlePreloadTags(bundleUrls)}
|
|
42
51
|
${helmet.title.toString()}
|
|
43
52
|
${helmet.meta.toString()}
|
|
44
53
|
${helmet.link.toString()}
|
|
@@ -46,52 +55,69 @@ export const createReactApp = (serverConfig: ServerConfig) => {
|
|
|
46
55
|
<body ${helmet.bodyAttributes.toString()}>
|
|
47
56
|
<div id='app'></div>
|
|
48
57
|
<script>proteinjs = {};</script>
|
|
49
|
-
${
|
|
50
|
-
${
|
|
58
|
+
${serverRenderedScripts}
|
|
59
|
+
${bundleScriptTags(bundleUrls)}
|
|
51
60
|
</body>
|
|
52
61
|
</html>`);
|
|
53
62
|
},
|
|
54
63
|
};
|
|
55
64
|
};
|
|
56
65
|
|
|
57
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The bundle URLs the page loads, in load order. Dev: the entrypoint's files as the LAST compile
|
|
68
|
+
* recorded them (DevClientBuild — the page follows the chunk graph the webpack config declares),
|
|
69
|
+
* each stamped `?v=<hash>` so the page is verifiable against /dev/build-info (matching hash =
|
|
70
|
+
* provably running the current build). Prod: the configured bundle paths, or every `.js` under the
|
|
71
|
+
* bundles dir in a stable (sorted) order — webpack's runtime registers chunks in any order.
|
|
72
|
+
*/
|
|
73
|
+
async function bundleScriptUrls(serverConfig: ServerConfig): Promise<string[]> {
|
|
58
74
|
if (!(serverConfig.staticContent?.bundlePaths || serverConfig.staticContent?.bundlesDir)) {
|
|
59
|
-
return;
|
|
75
|
+
return [];
|
|
60
76
|
}
|
|
61
77
|
|
|
62
|
-
const scriptTags: string[] = [];
|
|
63
78
|
if (process.env.DEVELOPMENT && !process.env.DISABLE_HOT_CLIENT_BUILDS) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
scriptTags.push(`<script src='${path.join('/static/', 'vendor.js')}${stamp}'></script>`);
|
|
70
|
-
} else if (serverConfig.staticContent?.bundlePaths) {
|
|
71
|
-
for (const bundlePath of serverConfig.staticContent.bundlePaths) {
|
|
72
|
-
scriptTags.push(`<script src='${path.join('/static/', bundlePath)}'></script>`);
|
|
79
|
+
const build = DevClientBuild.get();
|
|
80
|
+
if (!build) {
|
|
81
|
+
// start() gates the listen READY on the first compile, so a page is never served before
|
|
82
|
+
// a build is recorded; a missing record is a wiring fault, said aloud rather than papered.
|
|
83
|
+
throw new Error('the dev client build has not been recorded yet — no bundle to serve');
|
|
73
84
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
);
|
|
85
|
+
return build.assets.map((asset) => `${path.join('/static/', asset)}?v=${build.hash}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (serverConfig.staticContent?.bundlePaths) {
|
|
89
|
+
return serverConfig.staticContent.bundlePaths.map((bundlePath) => path.join('/static/', bundlePath));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (serverConfig.staticContent?.bundlesDir && serverConfig.staticContent?.staticContentDir) {
|
|
93
|
+
const staticContentDir = serverConfig.staticContent.staticContentDir;
|
|
94
|
+
const resolvedBundlesDir = path.join(staticContentDir, serverConfig.staticContent.bundlesDir);
|
|
79
95
|
const filePaths = await Fs.getFilePathsMatchingGlob(resolvedBundlesDir, '**/*.js');
|
|
80
|
-
|
|
81
|
-
const relativePath = path.relative(serverConfig.staticContent.staticContentDir, filePath);
|
|
82
|
-
scriptTags.push(`<script src='${path.join('/static/', relativePath)}'></script>`);
|
|
83
|
-
}
|
|
96
|
+
return filePaths.map((filePath) => path.join('/static/', path.relative(staticContentDir, filePath))).sort();
|
|
84
97
|
}
|
|
85
98
|
|
|
86
|
-
return
|
|
99
|
+
return [];
|
|
87
100
|
}
|
|
88
101
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
/**
|
|
103
|
+
* `defer`: the bundles download in parallel WHILE the HTML parses and execute in document order
|
|
104
|
+
* once it has — a synchronous `<script src>` blocked the parser on every byte of the vendor chunk,
|
|
105
|
+
* and on iOS the previous page stayed painted (and tappable) under the user's thumb until the new
|
|
106
|
+
* document's first paint. The inline server-rendered scripts above them still run at parse time,
|
|
107
|
+
* so `proteinjs[...]` globals exist before any bundle executes.
|
|
108
|
+
*/
|
|
109
|
+
function bundleScriptTags(bundleUrls: string[]): string {
|
|
110
|
+
return bundleUrls.map((url) => `<script defer src='${url}'></script>`).join('\n');
|
|
111
|
+
}
|
|
95
112
|
|
|
96
|
-
|
|
113
|
+
/** `<link rel=preload>` in the head: the fetches start from the first bytes of the document. */
|
|
114
|
+
function bundlePreloadTags(bundleUrls: string[]): string {
|
|
115
|
+
return bundleUrls.map((url) => `<link rel='preload' href='${url}' as='script'>`).join('\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Every server-rendered script rendered CONCURRENTLY; emitted in registration order. */
|
|
119
|
+
async function serverRenderedScriptTags(): Promise<string> {
|
|
120
|
+
const scripts = getServerRenderedScripts();
|
|
121
|
+
const rendered = await Promise.all(scripts.map((script) => script.script()));
|
|
122
|
+
return rendered.map((script) => `<script>${script}</script>`).join('\n');
|
|
97
123
|
}
|
package/src/startServer.ts
CHANGED
|
@@ -153,9 +153,23 @@ function initializeHotReloading(app: express.Express, config: ServerConfig): Pro
|
|
|
153
153
|
// Every completed compile is recorded (for the `?v=<hash>` script-tag stamp + /dev/build-info)
|
|
154
154
|
// and logged, so "which build is this page running?" is answerable instead of guessable.
|
|
155
155
|
compiler.hooks.done.tap('proteinjs-server', (stats: any) => {
|
|
156
|
-
const { hash, time, errors } = stats.toJson({
|
|
156
|
+
const { hash, time, errors, entrypoints } = stats.toJson({
|
|
157
|
+
all: false,
|
|
158
|
+
hash: true,
|
|
159
|
+
timings: true,
|
|
160
|
+
errors: true,
|
|
161
|
+
entrypoints: true,
|
|
162
|
+
chunkGroupAssets: true,
|
|
163
|
+
});
|
|
157
164
|
const errorCount = errors?.length ?? 0;
|
|
158
|
-
|
|
165
|
+
// The entrypoint's script files in the compile's own load order — what the dev page's bundle
|
|
166
|
+
// tags render (reactApp.ts). Source maps and hot-update chunks are not entrypoint files.
|
|
167
|
+
const assets: string[] = Object.values(entrypoints ?? {}).flatMap((entrypoint: any) =>
|
|
168
|
+
(entrypoint.assets ?? [])
|
|
169
|
+
.map((asset: any) => (typeof asset === 'string' ? asset : asset.name))
|
|
170
|
+
.filter((name: string) => name.endsWith('.js'))
|
|
171
|
+
);
|
|
172
|
+
DevClientBuild.record({ hash, builtAt: new Date().toISOString(), durationMs: time, errorCount, assets });
|
|
159
173
|
if (errorCount > 0) {
|
|
160
174
|
logger.error({ message: `Client bundle compiled WITH ERRORS`, obj: { hash, durationMs: time, errorCount } });
|
|
161
175
|
} else {
|
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
* Test fixture: a REAL @proteinjs/server (the built dist — the same code prod runs) with a
|
|
3
3
|
* deliberately slow endpoint, so the graceful-shutdown suite can hold a request in flight
|
|
4
4
|
* while it signals the process. Run with:
|
|
5
|
-
* FIXTURE_PORT=<port> [FIXTURE_DRAIN_DELAY_MS=..] [FIXTURE_DRAIN_TIMEOUT_MS=..] node gracefulShutdownServer.js
|
|
5
|
+
* FIXTURE_PORT=<port> [FIXTURE_DRAIN_DELAY_MS=..] [FIXTURE_DRAIN_TIMEOUT_MS=..] [FIXTURE_TURN_DRAIN_MS=..] node gracefulShutdownServer.js
|
|
6
6
|
*
|
|
7
7
|
* Markers on stdout (the suite's synchronization points):
|
|
8
8
|
* FIXTURE_READY — startServer resolved (the listener is up)
|
|
9
9
|
* SLOW_REQUEST_STARTED — the /slow handler is executing (a request is now in flight)
|
|
10
|
+
* HOLD_ACQUIRED <label> — /hold took a process hold (GracefulShutdown.hold) that outlives
|
|
11
|
+
* its request: the response ends at once, the hold releases itself
|
|
12
|
+
* ?ms= later — a detached chat turn's shape (no connection, work live)
|
|
13
|
+
* HOLD_RELEASED <label> — that hold released
|
|
10
14
|
*
|
|
11
15
|
* Also serves /server-timeouts: the LIVE http.Server's keepAliveTimeout/headersTimeout (read off
|
|
12
16
|
* the request's own socket), so the keep-alive suite asserts the running instance through the
|
|
13
17
|
* front door instead of re-deriving values from source.
|
|
14
18
|
*/
|
|
15
19
|
const expressSession = require('express-session');
|
|
16
|
-
const { startServer } = require('../../dist/generated/index.js');
|
|
20
|
+
const { startServer, GracefulShutdown } = require('../../dist/generated/index.js');
|
|
17
21
|
|
|
18
22
|
const port = Number(process.env.FIXTURE_PORT);
|
|
19
23
|
if (!port) {
|
|
@@ -44,6 +48,20 @@ startServer({
|
|
|
44
48
|
response.status(200).send('session-cookie-set');
|
|
45
49
|
return;
|
|
46
50
|
}
|
|
51
|
+
if (request.path === '/hold') {
|
|
52
|
+
// A hold that OUTLIVES its request — the detached-turn shape the drain must wait for:
|
|
53
|
+
// the response ends now (no connection remains), the work stays live for ?ms=.
|
|
54
|
+
const label = String(request.query.label ?? 'fixture-hold');
|
|
55
|
+
const ms = Number(request.query.ms ?? 5000);
|
|
56
|
+
const release = GracefulShutdown.hold(label, { source: 'fixture', ms });
|
|
57
|
+
console.log(`HOLD_ACQUIRED ${label}`);
|
|
58
|
+
setTimeout(() => {
|
|
59
|
+
release();
|
|
60
|
+
console.log(`HOLD_RELEASED ${label}`);
|
|
61
|
+
}, ms);
|
|
62
|
+
response.status(200).send('held');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
47
65
|
if (request.path !== '/slow') {
|
|
48
66
|
next();
|
|
49
67
|
return;
|
|
@@ -61,5 +79,6 @@ startServer({
|
|
|
61
79
|
shutdown: {
|
|
62
80
|
drainDelayMs: process.env.FIXTURE_DRAIN_DELAY_MS ? Number(process.env.FIXTURE_DRAIN_DELAY_MS) : undefined,
|
|
63
81
|
drainTimeoutMs: process.env.FIXTURE_DRAIN_TIMEOUT_MS ? Number(process.env.FIXTURE_DRAIN_TIMEOUT_MS) : undefined,
|
|
82
|
+
turnDrainMs: process.env.FIXTURE_TURN_DRAIN_MS ? Number(process.env.FIXTURE_TURN_DRAIN_MS) : undefined,
|
|
64
83
|
},
|
|
65
84
|
}).then(() => console.log('FIXTURE_READY'));
|
|
@@ -13,6 +13,11 @@ import { ChildProcess, spawn } from 'child_process';
|
|
|
13
13
|
* idle keep-alives are closed, and the process exits 0 — bounded by
|
|
14
14
|
* shutdown.drainTimeoutMs (past it, remaining connections are force-closed and
|
|
15
15
|
* the exit is still 0).
|
|
16
|
+
* HOLDS (GracefulShutdown.hold): work no connection represents — a chat turn whose
|
|
17
|
+
* client disconnected (the 2026-09-05 prod kill, plans/FREE_AGENT.md §M.14) — keeps
|
|
18
|
+
* the process alive past the connection drain until it releases, bounded by
|
|
19
|
+
* shutdown.turnDrainMs; past THAT bound the holds still outstanding are logged by
|
|
20
|
+
* label and abandoned, and the exit is still 0.
|
|
16
21
|
* SIGINT → immediate exit 0 (dev ctrl-C: fast, quiet).
|
|
17
22
|
* exit 86 → RESTART_REQUEST_EXIT_CODE, untouched by this feature: process.exit(86) is not
|
|
18
23
|
* signal-driven (ServePackageSupervisor's respawn contract rides it).
|
|
@@ -101,9 +106,70 @@ describe('graceful shutdown', () => {
|
|
|
101
106
|
expect(exit).toEqual({ code: 0, signal: null });
|
|
102
107
|
expect(Date.now() - sigintAt).toBeLessThan(2000); // no drain delay on the fast path
|
|
103
108
|
}, 30000);
|
|
109
|
+
|
|
110
|
+
describe('holds — work no connection represents (a detached chat turn)', () => {
|
|
111
|
+
it('SIGTERM: a hold whose request already ended keeps the process alive past the connection drain; its release lets the exit proceed', async () => {
|
|
112
|
+
// No delay, a short connection bound, a long hold bound: the ONLY thing that can keep this
|
|
113
|
+
// process alive after SIGTERM is the hold.
|
|
114
|
+
fixture = await startFixture({ drainDelayMs: 0, drainTimeoutMs: 1000, turnDrainMs: 20000 });
|
|
115
|
+
|
|
116
|
+
// The detached-turn shape: the request ends immediately (no connection remains), the work
|
|
117
|
+
// it started stays live for 5s and releases its hold when done.
|
|
118
|
+
const held = await request(fixture.port, '/hold?label=chat-turn:detached-turn&ms=5000');
|
|
119
|
+
expect(held).toEqual({ status: 200, body: 'held' });
|
|
120
|
+
await fixture.waitForMarker('HOLD_ACQUIRED chat-turn:detached-turn');
|
|
121
|
+
|
|
122
|
+
const sigtermAt = Date.now();
|
|
123
|
+
fixture.child.kill('SIGTERM');
|
|
124
|
+
|
|
125
|
+
// 1. Past the connection drain (nothing was in flight — it completes at once; the listener
|
|
126
|
+
// is closed) the process is STILL RUNNING: the hold is what keeps it.
|
|
127
|
+
await sleep(2500);
|
|
128
|
+
expect(fixture.child.exitCode).toBeNull();
|
|
129
|
+
expect(fixture.child.signalCode).toBeNull();
|
|
130
|
+
await expect(request(fixture.port, '/health-check')).rejects.toMatchObject({
|
|
131
|
+
code: expect.stringMatching(/ECONNREFUSED|ECONNRESET/),
|
|
132
|
+
});
|
|
133
|
+
expect(fixture.stdout()).toContain('waiting for 1 hold(s) to release (bound: 20000ms)');
|
|
134
|
+
|
|
135
|
+
// 2. The work finishes and releases; the exit follows the RELEASE (~5s), not the 20s bound.
|
|
136
|
+
const exit = await fixture.exited;
|
|
137
|
+
expect(exit).toEqual({ code: 0, signal: null });
|
|
138
|
+
const elapsed = Date.now() - sigtermAt;
|
|
139
|
+
expect(elapsed).toBeGreaterThanOrEqual(4500);
|
|
140
|
+
expect(elapsed).toBeLessThan(12000);
|
|
141
|
+
expect(fixture.stdout()).toContain('HOLD_RELEASED chat-turn:detached-turn');
|
|
142
|
+
expect(fixture.stdout()).toContain('Every hold released');
|
|
143
|
+
}, 40000);
|
|
144
|
+
|
|
145
|
+
it('SIGTERM: the hold drain is BOUNDED — past turnDrainMs the outstanding holds are logged by label, abandoned, and the exit is still 0', async () => {
|
|
146
|
+
fixture = await startFixture({ drainDelayMs: 0, drainTimeoutMs: 1000, turnDrainMs: 3000 });
|
|
147
|
+
|
|
148
|
+
// A hold that would outlive the bound by far.
|
|
149
|
+
const held = await request(fixture.port, '/hold?label=chat-turn:abandoned-turn&ms=60000');
|
|
150
|
+
expect(held.status).toBe(200);
|
|
151
|
+
await fixture.waitForMarker('HOLD_ACQUIRED chat-turn:abandoned-turn');
|
|
152
|
+
|
|
153
|
+
const sigtermAt = Date.now();
|
|
154
|
+
fixture.child.kill('SIGTERM');
|
|
155
|
+
|
|
156
|
+
const exit = await fixture.exited;
|
|
157
|
+
expect(exit).toEqual({ code: 0, signal: null });
|
|
158
|
+
const elapsed = Date.now() - sigtermAt;
|
|
159
|
+
expect(elapsed).toBeGreaterThanOrEqual(3000); // the bound was actually served
|
|
160
|
+
expect(elapsed).toBeLessThan(9000); // ...and it ended the wait, not the 60s hold
|
|
161
|
+
// The abandoned work is NAMED — the line the 2026-09-05 kill never left behind.
|
|
162
|
+
expect(fixture.stdout()).toContain('abandoning 1 hold(s): chat-turn:abandoned-turn');
|
|
163
|
+
expect(fixture.stdout()).not.toContain('HOLD_RELEASED');
|
|
164
|
+
}, 30000);
|
|
165
|
+
});
|
|
104
166
|
});
|
|
105
167
|
|
|
106
|
-
async function startFixture(shutdown: {
|
|
168
|
+
async function startFixture(shutdown: {
|
|
169
|
+
drainDelayMs: number;
|
|
170
|
+
drainTimeoutMs: number;
|
|
171
|
+
turnDrainMs?: number;
|
|
172
|
+
}): Promise<Fixture> {
|
|
107
173
|
const port = await ephemeralPort();
|
|
108
174
|
// Scrub the env vars startServer reads (dev machines export some of these): the fixture's
|
|
109
175
|
// behavior must come from its own config only.
|
|
@@ -119,6 +185,7 @@ async function startFixture(shutdown: { drainDelayMs: number; drainTimeoutMs: nu
|
|
|
119
185
|
FIXTURE_PORT: String(port),
|
|
120
186
|
FIXTURE_DRAIN_DELAY_MS: String(shutdown.drainDelayMs),
|
|
121
187
|
FIXTURE_DRAIN_TIMEOUT_MS: String(shutdown.drainTimeoutMs),
|
|
188
|
+
...(shutdown.turnDrainMs !== undefined ? { FIXTURE_TURN_DRAIN_MS: String(shutdown.turnDrainMs) } : {}),
|
|
122
189
|
},
|
|
123
190
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
124
191
|
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { GracefulShutdown } from '../src/GracefulShutdown';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The holds seam as a process-wide static contract — what a holder (a chat turn registry) and a
|
|
5
|
+
* follower (the dev supervisor's lease projection) can rely on without a server instance:
|
|
6
|
+
* one hold per label, idempotent release, acquisition-ordered listing with the holder's context,
|
|
7
|
+
* and observers told of every transition exactly once.
|
|
8
|
+
*/
|
|
9
|
+
describe('GracefulShutdown holds', () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
for (const hold of GracefulShutdown.outstandingHolds()) {
|
|
12
|
+
GracefulShutdown.hold(hold.label)(); // hold() on a held label returns its release
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('a hold is listed with its context until its release; releasing twice is a no-op', () => {
|
|
17
|
+
const release = GracefulShutdown.hold('chat-turn:t1', { chatId: 'c1', userId: 'u1' });
|
|
18
|
+
expect(GracefulShutdown.outstandingHolds()).toEqual([
|
|
19
|
+
{ label: 'chat-turn:t1', context: { chatId: 'c1', userId: 'u1' } },
|
|
20
|
+
]);
|
|
21
|
+
release();
|
|
22
|
+
expect(GracefulShutdown.outstandingHolds()).toEqual([]);
|
|
23
|
+
release();
|
|
24
|
+
expect(GracefulShutdown.outstandingHolds()).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('holding a label already held is the SAME hold — one entry, one release', () => {
|
|
28
|
+
const first = GracefulShutdown.hold('chat-turn:t2', { chatId: 'c2' });
|
|
29
|
+
const second = GracefulShutdown.hold('chat-turn:t2', { chatId: 'other' });
|
|
30
|
+
expect(second).toBe(first);
|
|
31
|
+
expect(GracefulShutdown.outstandingHolds()).toEqual([{ label: 'chat-turn:t2', context: { chatId: 'c2' } }]);
|
|
32
|
+
second();
|
|
33
|
+
expect(GracefulShutdown.outstandingHolds()).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('a stale release never drops a label re-held since', () => {
|
|
37
|
+
const stale = GracefulShutdown.hold('chat-turn:t3');
|
|
38
|
+
stale();
|
|
39
|
+
const fresh = GracefulShutdown.hold('chat-turn:t3');
|
|
40
|
+
stale(); // the first hold's release, called again after the label was re-held
|
|
41
|
+
expect(GracefulShutdown.outstandingHolds()).toEqual([{ label: 'chat-turn:t3' }]);
|
|
42
|
+
fresh();
|
|
43
|
+
expect(GracefulShutdown.outstandingHolds()).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('lists holds in acquisition order', () => {
|
|
47
|
+
GracefulShutdown.hold('b');
|
|
48
|
+
GracefulShutdown.hold('a');
|
|
49
|
+
expect(GracefulShutdown.outstandingHolds().map((hold) => hold.label)).toEqual(['b', 'a']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('observers see each acquisition and release once; a re-hold of a held label is silent; unsubscribing stops the stream', () => {
|
|
53
|
+
const events: string[] = [];
|
|
54
|
+
const unsubscribe = GracefulShutdown.observeHolds({
|
|
55
|
+
acquired: (label) => events.push(`+${label}`),
|
|
56
|
+
released: (label) => events.push(`-${label}`),
|
|
57
|
+
});
|
|
58
|
+
const release = GracefulShutdown.hold('chat-turn:t4');
|
|
59
|
+
GracefulShutdown.hold('chat-turn:t4');
|
|
60
|
+
release();
|
|
61
|
+
release();
|
|
62
|
+
unsubscribe();
|
|
63
|
+
GracefulShutdown.hold('chat-turn:t5')();
|
|
64
|
+
expect(events).toEqual(['+chat-turn:t4', '-chat-turn:t4']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('a throwing observer never touches the hold', () => {
|
|
68
|
+
const unsubscribe = GracefulShutdown.observeHolds({
|
|
69
|
+
acquired: () => {
|
|
70
|
+
throw new Error('follower down');
|
|
71
|
+
},
|
|
72
|
+
released: () => {
|
|
73
|
+
throw new Error('follower down');
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const release = GracefulShutdown.hold('chat-turn:t6');
|
|
77
|
+
expect(GracefulShutdown.outstandingHolds()).toEqual([{ label: 'chat-turn:t6' }]);
|
|
78
|
+
release();
|
|
79
|
+
expect(GracefulShutdown.outstandingHolds()).toEqual([]);
|
|
80
|
+
unsubscribe();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The react app page's boot shape:
|
|
7
|
+
*
|
|
8
|
+
* 1. ONE boot round — every server-rendered script (each a settings read for the logged-in user)
|
|
9
|
+
* is rendered CONCURRENTLY, and emitted in registration order regardless of which resolves
|
|
10
|
+
* first. Pre-fix red: reactApp.ts awaited the scripts one after another (the page's TTFB was
|
|
11
|
+
* the SUM of 5–6 serial reads), so the second script did not START until the first resolved.
|
|
12
|
+
* 2. The bundles are `defer`red and `<link rel=preload>`ed from the head — a synchronous body
|
|
13
|
+
* `<script src>` blocked the parser on every byte of the vendor chunk. Pre-fix red: no defer,
|
|
14
|
+
* no preload.
|
|
15
|
+
* 3. The dev page renders its bundle tags from the compile's own entrypoint files (DevClientBuild
|
|
16
|
+
* `assets`), each stamped `?v=<hash>` — the page follows the chunk graph the webpack config
|
|
17
|
+
* declares, and stays verifiable against /dev/build-info. Pre-fix red: two hard-coded names.
|
|
18
|
+
*/
|
|
19
|
+
const scripts: { script: () => Promise<string> }[] = [];
|
|
20
|
+
jest.mock('@proteinjs/server-api', () => ({
|
|
21
|
+
...jest.requireActual('@proteinjs/server-api'),
|
|
22
|
+
getServerRenderedScripts: () => scripts,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import { createReactApp } from '../src/routes/reactApp';
|
|
26
|
+
import { DevClientBuild } from '../src/DevClientBuild';
|
|
27
|
+
|
|
28
|
+
type Rendered = { headers: Record<string, string>; html: string };
|
|
29
|
+
|
|
30
|
+
async function render(staticContent: Record<string, unknown>): Promise<Rendered> {
|
|
31
|
+
const rendered: Rendered = { headers: {}, html: '' };
|
|
32
|
+
await renderInto(rendered, staticContent);
|
|
33
|
+
return rendered;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderInto(rendered: Rendered, staticContent: Record<string, unknown>): Promise<void> {
|
|
37
|
+
const response = {
|
|
38
|
+
set: (name: string, value: string) => {
|
|
39
|
+
rendered.headers[name] = value;
|
|
40
|
+
},
|
|
41
|
+
send: (html: string) => {
|
|
42
|
+
rendered.html = html;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
return createReactApp({ staticContent } as any).onRequest({ path: '/' }, response);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const flushMicrotasks = async () => {
|
|
49
|
+
for (let i = 0; i < 10; i++) {
|
|
50
|
+
await Promise.resolve();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
describe('the react app page boots in one round', () => {
|
|
55
|
+
let tmp: string;
|
|
56
|
+
const env = {
|
|
57
|
+
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
58
|
+
DISABLE_HOT_CLIENT_BUILDS: process.env.DISABLE_HOT_CLIENT_BUILDS,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
scripts.length = 0;
|
|
63
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'react-app-boot-'));
|
|
64
|
+
fs.mkdirSync(path.join(tmp, 'bundles'));
|
|
65
|
+
delete process.env.DEVELOPMENT;
|
|
66
|
+
delete process.env.DISABLE_HOT_CLIENT_BUILDS;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
71
|
+
if (env.DEVELOPMENT === undefined) {
|
|
72
|
+
delete process.env.DEVELOPMENT;
|
|
73
|
+
} else {
|
|
74
|
+
process.env.DEVELOPMENT = env.DEVELOPMENT;
|
|
75
|
+
}
|
|
76
|
+
if (env.DISABLE_HOT_CLIENT_BUILDS === undefined) {
|
|
77
|
+
delete process.env.DISABLE_HOT_CLIENT_BUILDS;
|
|
78
|
+
} else {
|
|
79
|
+
process.env.DISABLE_HOT_CLIENT_BUILDS = env.DISABLE_HOT_CLIENT_BUILDS;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const prodStaticContent = () => ({ staticContentDir: tmp, bundlesDir: 'bundles' });
|
|
84
|
+
|
|
85
|
+
it('pin 1: every server-rendered script STARTS before any resolves, and they are emitted in registration order', async () => {
|
|
86
|
+
fs.writeFileSync(path.join(tmp, 'bundles', 'app.js'), '');
|
|
87
|
+
const started: string[] = [];
|
|
88
|
+
const release: Record<string, () => void> = {};
|
|
89
|
+
const gated = (name: string) => ({
|
|
90
|
+
script: () =>
|
|
91
|
+
new Promise<string>((resolve) => {
|
|
92
|
+
started.push(name);
|
|
93
|
+
release[name] = () => resolve(`window.${name} = 1;`);
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
scripts.push(gated('a'), gated('b'), { script: async () => (started.push('c'), 'window.c = 1;') });
|
|
97
|
+
|
|
98
|
+
const rendered: Rendered = { headers: {}, html: '' };
|
|
99
|
+
const done = renderInto(rendered, prodStaticContent());
|
|
100
|
+
await flushMicrotasks();
|
|
101
|
+
// Pre-fix red: ['a'] — the page awaited a's read before b's began.
|
|
102
|
+
expect(started).toEqual(['a', 'b', 'c']);
|
|
103
|
+
expect(rendered.html).toBe('');
|
|
104
|
+
|
|
105
|
+
// b answers first; the page still carries a, b, c in registration order.
|
|
106
|
+
release.b();
|
|
107
|
+
await flushMicrotasks();
|
|
108
|
+
release.a();
|
|
109
|
+
await done;
|
|
110
|
+
const order = ['window.a = 1;', 'window.b = 1;', 'window.c = 1;'].map((s) =>
|
|
111
|
+
rendered.html.indexOf(`<script>${s}</script>`)
|
|
112
|
+
);
|
|
113
|
+
expect(order.every((i) => i >= 0)).toBe(true);
|
|
114
|
+
expect(order).toEqual([...order].sort((x, y) => x - y));
|
|
115
|
+
// The globals object the scripts write into is declared ahead of them.
|
|
116
|
+
expect(rendered.html.indexOf('<script>proteinjs = {};</script>')).toBeLessThan(order[0]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('pin 2: the production bundles are deferred and preloaded from the head, in a stable order', async () => {
|
|
120
|
+
fs.writeFileSync(path.join(tmp, 'bundles', 'vendor.def456.js'), '');
|
|
121
|
+
fs.writeFileSync(path.join(tmp, 'bundles', 'app.abc123.js'), '');
|
|
122
|
+
fs.writeFileSync(path.join(tmp, 'bundles', 'app.abc123.js.map'), '');
|
|
123
|
+
fs.writeFileSync(path.join(tmp, 'bundles', 'app.abc123.js.br'), '');
|
|
124
|
+
scripts.push({ script: async () => 'window.settings = {};' });
|
|
125
|
+
|
|
126
|
+
const { html, headers } = await render(prodStaticContent());
|
|
127
|
+
const head = html.slice(0, html.indexOf('<body'));
|
|
128
|
+
const body = html.slice(html.indexOf('<body'));
|
|
129
|
+
for (const bundle of ['/static/bundles/app.abc123.js', '/static/bundles/vendor.def456.js']) {
|
|
130
|
+
// Pre-fix red: `<script src=…>` with no defer; no preload anywhere.
|
|
131
|
+
expect(body).toContain(`<script defer src='${bundle}'></script>`);
|
|
132
|
+
expect(head).toContain(`<link rel='preload' href='${bundle}' as='script'>`);
|
|
133
|
+
}
|
|
134
|
+
expect(body).not.toMatch(/<script src=/);
|
|
135
|
+
expect(html).not.toContain('.js.map');
|
|
136
|
+
expect(html).not.toContain('.js.br');
|
|
137
|
+
// Stable order: sorted paths, app before vendor.
|
|
138
|
+
expect(body.indexOf('app.abc123.js')).toBeLessThan(body.indexOf('vendor.def456.js'));
|
|
139
|
+
// The inline scripts run at parse time, ahead of the deferred bundles.
|
|
140
|
+
expect(body.indexOf('<script>window.settings = {};</script>')).toBeLessThan(body.indexOf('<script defer'));
|
|
141
|
+
expect(headers['Cache-Control']).toBe('no-cache');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('pin 3: the dev page renders the compile’s own entrypoint files, each stamped ?v=<hash>, deferred and preloaded', async () => {
|
|
145
|
+
process.env.DEVELOPMENT = 'true';
|
|
146
|
+
DevClientBuild.record({
|
|
147
|
+
hash: 'cafe0123',
|
|
148
|
+
builtAt: new Date().toISOString(),
|
|
149
|
+
errorCount: 0,
|
|
150
|
+
assets: ['react.js', 'vendor.js', 'app.js'],
|
|
151
|
+
});
|
|
152
|
+
scripts.push({ script: async () => 'window.settings = {};' });
|
|
153
|
+
|
|
154
|
+
const { html } = await render(prodStaticContent());
|
|
155
|
+
const body = html.slice(html.indexOf('<body'));
|
|
156
|
+
const head = html.slice(0, html.indexOf('<body'));
|
|
157
|
+
const tags = ['react.js', 'vendor.js', 'app.js'].map((asset) =>
|
|
158
|
+
body.indexOf(`<script defer src='/static/${asset}?v=cafe0123'></script>`)
|
|
159
|
+
);
|
|
160
|
+
// Pre-fix red: two hard-coded names (app.js, vendor.js), no react.js, no defer.
|
|
161
|
+
expect(tags.every((i) => i >= 0)).toBe(true);
|
|
162
|
+
expect(tags).toEqual([...tags].sort((x, y) => x - y));
|
|
163
|
+
for (const asset of ['react.js', 'vendor.js', 'app.js']) {
|
|
164
|
+
expect(head).toContain(`<link rel='preload' href='/static/${asset}?v=cafe0123' as='script'>`);
|
|
165
|
+
}
|
|
166
|
+
expect(body).not.toContain('bundles/');
|
|
167
|
+
});
|
|
168
|
+
});
|