@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.
@@ -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
- ${await serverRenderedScriptTags()}
50
- ${await bundleScriptTags(serverConfig)}
58
+ ${serverRenderedScripts}
59
+ ${bundleScriptTags(bundleUrls)}
51
60
  </body>
52
61
  </html>`);
53
62
  },
54
63
  };
55
64
  };
56
65
 
57
- async function bundleScriptTags(serverConfig: ServerConfig) {
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
- // `?v=<hash>` stamps the page with the compile it was served against: it deterministically
65
- // busts the browser cache on new builds, and lets tooling verify a live page against
66
- // /dev/build-info (matching hash = provably running the current build).
67
- const stamp = DevClientBuild.get() ? `?v=${DevClientBuild.get()!.hash}` : '';
68
- scriptTags.push(`<script src='${path.join('/static/', 'app.js')}${stamp}'></script>`);
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
- } else if (serverConfig.staticContent?.bundlesDir && serverConfig.staticContent?.staticContentDir) {
75
- const resolvedBundlesDir = path.join(
76
- serverConfig.staticContent.staticContentDir,
77
- serverConfig.staticContent.bundlesDir
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
- for (const filePath of filePaths) {
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 scriptTags.join('\n');
99
+ return [];
87
100
  }
88
101
 
89
- async function serverRenderedScriptTags() {
90
- const scripts = getServerRenderedScripts();
91
- const scriptTags: string[] = [];
92
- for (const script of scripts) {
93
- scriptTags.push(`<script>${await script.script()}</script>`);
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
- return scriptTags.join('\n');
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
  }
@@ -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({ all: false, hash: true, timings: true, errors: true });
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
- DevClientBuild.record({ hash, builtAt: new Date().toISOString(), durationMs: time, errorCount });
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: { drainDelayMs: number; drainTimeoutMs: number }): Promise<Fixture> {
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
+ });