@thomas.f/prnext 0.1.11-alpha → 0.1.13-alpha
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/README.md +395 -29
- package/build/actions.mjs +4 -0
- package/build/app-config.mjs +2 -0
- package/build/app.mjs +20 -5
- package/build/bundle-runtime.mjs +82 -0
- package/build/cache-handlers.mjs +4 -1
- package/build/compiler.mjs +4 -1
- package/build/config.mjs +20 -2
- package/build/font-cache.mjs +50 -0
- package/build/font-files.mjs +5 -3
- package/build/fonts.mjs +1 -1
- package/build/index.mjs +12 -1
- package/build/metadata-conflicts.mjs +13 -0
- package/build/metadata-images.mjs +3 -3
- package/build/metadata-routes.mjs +3 -3
- package/build/native-packages.mjs +5 -1
- package/build/npm-packages.mjs +14 -2
- package/build/optimize-imports.mjs +112 -0
- package/build/proxy-body-config.mjs +10 -0
- package/build/runtime-contract.mjs +16 -0
- package/build/scan.mjs +9 -7
- package/build/typescript-check.mjs +20 -0
- package/build/typescript.mjs +49 -0
- package/build/worker-retirement.mjs +14 -0
- package/cli.mjs +17 -0
- package/compat/after.cjs +51 -0
- package/compat/index.d.ts +13 -1
- package/compat/server.cjs +2 -1
- package/compat/server.d.cts +2 -0
- package/native/launch.mjs +19 -0
- package/package.json +25 -9
- package/release/verify.mjs +9 -0
- package/runtime/api.mjs +15 -5
- package/runtime/app-render.mjs +164 -53
- package/runtime/app-route-plan.mjs +45 -0
- package/runtime/app-routing-state.mjs +101 -21
- package/runtime/app-routing.mjs +37 -18
- package/runtime/app-segments.mjs +25 -11
- package/runtime/app-static.mjs +1 -1
- package/runtime/app-stream-html.mjs +3 -53
- package/runtime/cache-request.mjs +10 -2
- package/runtime/cloud/archive.mjs +316 -0
- package/runtime/cloud/client.mjs +658 -0
- package/runtime/cloud/environment.mjs +60 -0
- package/runtime/cloud/state.mjs +141 -0
- package/runtime/cloud/upload.mjs +48 -0
- package/runtime/html-envelope.mjs +136 -0
- package/runtime/http.mjs +9 -6
- package/runtime/middleware.mjs +3 -3
- package/runtime/profiles.json +13 -8
- package/runtime/request-transport.mjs +1 -1
- package/runtime/request-work.mjs +2 -2
- package/runtime/route-static.mjs +1 -1
- package/runtime/rsc-lifecycle.mjs +119 -0
- package/runtime/rsc-work-watchdog.mjs +75 -0
- package/runtime/rsc-worker.mjs +36 -9
- package/runtime/stream-utils.mjs +22 -3
- package/runtime/transport.mjs +89 -53
- package/runtime/worker-metadata.mjs +23 -0
- package/runtime/worker.mjs +119 -27
package/runtime/worker.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// prnext-concurrency:512
|
|
3
3
|
import { requestFrames } from './request-transport.mjs';
|
|
4
4
|
import { requestWork } from './request-work.mjs';
|
|
5
|
+
import { freezeClientModules } from './worker-metadata.mjs';
|
|
5
6
|
import { AsyncResource } from 'node:async_hooks';
|
|
6
7
|
const workScope = new AsyncResource('PRNextWorkObserver', { triggerAsyncId: 0 });
|
|
7
8
|
import { readFile } from 'node:fs/promises';
|
|
@@ -12,8 +13,11 @@ import net from 'node:net';
|
|
|
12
13
|
import { timingSafeEqual } from 'node:crypto';
|
|
13
14
|
import { Console } from 'node:console';
|
|
14
15
|
import { navigationResponse } from './navigation.mjs';
|
|
15
|
-
import { createProtocolOutput } from './transport.mjs';
|
|
16
|
+
import { createProtocolOutput, ProtocolWriteError } from './transport.mjs';
|
|
16
17
|
import { loadEnvConfig } from './env.mjs';
|
|
18
|
+
import diagnostics from '../compat/diagnostics.cjs';
|
|
19
|
+
|
|
20
|
+
if (diagnostics.enabled) diagnostics.record('wake', { phase: 'node-entry', uptimeMs: process.uptime() * 1000, bootstrapMs: performance.nodeTiming.bootstrapComplete });
|
|
17
21
|
|
|
18
22
|
// stdout is exclusively the transport, including when user packages log during import.
|
|
19
23
|
const writeProtocol = process.stdout.write.bind(process.stdout);
|
|
@@ -32,9 +36,15 @@ loadEnvConfig(projectRoot, { dev: !!manifest.dev });
|
|
|
32
36
|
// browser graph and the RSC worker's mode, including dev launched from CI.
|
|
33
37
|
process.env.NODE_ENV = manifest.dev ? 'development' : 'production';
|
|
34
38
|
const production = !manifest.dev;
|
|
39
|
+
if (production) freezeClientModules(manifest.app?.clientModules);
|
|
35
40
|
// Load each renderer beside the application only when that request needs it.
|
|
36
41
|
// Middleware and API workers do not pay for React or the Pages/App renderers.
|
|
37
|
-
const runtimeModule = name =>
|
|
42
|
+
const runtimeModule = async name => {
|
|
43
|
+
const started = diagnostics.enabled ? performance.now() : 0;
|
|
44
|
+
const module = await import(pathToFileURL(path.join(distDir, `runtime/${name}.mjs`)).href);
|
|
45
|
+
if (diagnostics.enabled) diagnostics.record('wake', { phase: 'module', module: name, durationMs: performance.now() - started });
|
|
46
|
+
return module;
|
|
47
|
+
};
|
|
38
48
|
let errorResponse;
|
|
39
49
|
try { ({ errorResponse } = await runtimeModule('http')); }
|
|
40
50
|
catch (error) { console.error('PRNext worker could not load the project runtime:', error.message); process.exit(1); }
|
|
@@ -48,8 +58,9 @@ let pagesRuntime;
|
|
|
48
58
|
let apiRuntime;
|
|
49
59
|
let incrementalRuntime;
|
|
50
60
|
|
|
51
|
-
async function handleRequest(input, output, signalController) {
|
|
61
|
+
async function handleRequest(input, output, signalController, responseComplete) {
|
|
52
62
|
let responseStarted = false;
|
|
63
|
+
const finalizers = new Set();
|
|
53
64
|
try {
|
|
54
65
|
if (input.renderMode === 'incremental-cache' && input.routeId === '__prnext_incremental_cache') {
|
|
55
66
|
incrementalRuntime ??= await runtimeModule('incremental-cache');
|
|
@@ -68,7 +79,7 @@ async function handleRequest(input, output, signalController) {
|
|
|
68
79
|
if (route.router !== 'app' && !['isr', 'error404', 'error500'].includes(input.renderMode) && route.fallback === false && !prerendered.has(requestedPath) && !prerendered.has(requestedPath.replace(/\/$/, '') || '/')) {
|
|
69
80
|
await output(input.id, { status: 404, body: 'Not Found' }, input); return;
|
|
70
81
|
}
|
|
71
|
-
const options = { ...input, modulePath: path.resolve(distDir, route.module), route, production, manifest, distDir, signal: signalController.signal };
|
|
82
|
+
const options = { ...input, modulePath: path.resolve(distDir, route.module), route, production, manifest, distDir, signal: signalController.signal, deferFinalizer: finalize => finalizers.add(finalize) };
|
|
72
83
|
let result;
|
|
73
84
|
if (isMiddleware) {
|
|
74
85
|
middlewareRuntime ??= await import(pathToFileURL(path.join(distDir, 'runtime/middleware.mjs')).href);
|
|
@@ -78,7 +89,7 @@ async function handleRequest(input, output, signalController) {
|
|
|
78
89
|
appStaticRuntime ??= await import(pathToFileURL(path.join(distDir, 'runtime/app-static.mjs')).href);
|
|
79
90
|
result = await appStaticRuntime.renderAppIsrPage(options);
|
|
80
91
|
} else {
|
|
81
|
-
appRuntime ??= await
|
|
92
|
+
appRuntime ??= await runtimeModule('app-render');
|
|
82
93
|
result = await appRuntime.renderAppPage(options);
|
|
83
94
|
}
|
|
84
95
|
} else if (route.router === 'app' && route.kind === 'api' && input.renderMode === 'isr') {
|
|
@@ -97,16 +108,36 @@ async function handleRequest(input, output, signalController) {
|
|
|
97
108
|
input.body = undefined;
|
|
98
109
|
options.body = undefined;
|
|
99
110
|
responseStarted = true;
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
if (result.finalizeCache) finalizers.add(result.finalizeCache);
|
|
112
|
+
await output(input.id, result, { ...input, production, signalController });
|
|
113
|
+
responseComplete?.();
|
|
102
114
|
} catch (error) {
|
|
115
|
+
const cancelled = signalController.signal.aborted || error instanceof ProtocolWriteError;
|
|
103
116
|
signalController.abort(error);
|
|
104
|
-
if (responseStarted) {
|
|
117
|
+
if (responseStarted || cancelled) {
|
|
105
118
|
// A broken pipe or invalid transport cannot be repaired with a second
|
|
106
|
-
// response on the same request. Rust
|
|
119
|
+
// response on the same request. Rust discards the incomplete lane.
|
|
107
120
|
throw error;
|
|
108
121
|
}
|
|
109
|
-
|
|
122
|
+
// The application's scope was aborted above, but the lane is still valid
|
|
123
|
+
// for its one error/redirect response. Do not mistake that cleanup signal
|
|
124
|
+
// for a client disconnect while writing the replacement headers.
|
|
125
|
+
await output(input?.id ?? null, navigationResponse(error) || errorResponse(error, production), { ...input, production });
|
|
126
|
+
responseComplete?.();
|
|
127
|
+
} finally {
|
|
128
|
+
if (finalizers.size) {
|
|
129
|
+
const pending = (async () => {
|
|
130
|
+
for (const finalize of finalizers) {
|
|
131
|
+
try { await finalize(); }
|
|
132
|
+
catch (error) { console.error('[prnext] Post-response work failed:', error?.stack || error); }
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
135
|
+
// Release this socket's response lane while retaining worker activity.
|
|
136
|
+
// Otherwise an immediate follow-up request waits behind after() work.
|
|
137
|
+
const track = signalController.signal[requestWork];
|
|
138
|
+
if (track) track(pending);
|
|
139
|
+
else await pending;
|
|
140
|
+
}
|
|
110
141
|
}
|
|
111
142
|
}
|
|
112
143
|
|
|
@@ -114,12 +145,22 @@ async function handleRequest(input, output, signalController) {
|
|
|
114
145
|
// response never blocks another response's bytes or aborts its request context.
|
|
115
146
|
const laneLimit = Math.min(512, Math.max(1, Number(process.env.PRNEXT_WORKER_SOCKET) || 1));
|
|
116
147
|
const token = process.env.PRNEXT_WORKER_TOKEN;
|
|
148
|
+
const reportActivity = process.env.PRNEXT_WORKER_ACTIVITY === '1';
|
|
117
149
|
delete process.env.PRNEXT_WORKER_TOKEN;
|
|
118
150
|
delete process.env.PRNEXT_WORKER_SOCKET;
|
|
151
|
+
delete process.env.PRNEXT_WORKER_ACTIVITY;
|
|
119
152
|
if (token && laneLimit > 1) {
|
|
120
153
|
const expected = Buffer.from(token);
|
|
121
154
|
const sockets = new Set();
|
|
122
155
|
const invocations = new Set();
|
|
156
|
+
let shuttingDown = false;
|
|
157
|
+
let reportedBusy = false;
|
|
158
|
+
const announceActivity = () => {
|
|
159
|
+
if (!reportActivity || !invocations.size || reportedBusy || shuttingDown) return;
|
|
160
|
+
reportedBusy = true;
|
|
161
|
+
writeProtocol('busy\n');
|
|
162
|
+
};
|
|
163
|
+
const exitWhenDrained = () => { if (shuttingDown && !invocations.size) process.exit(0); };
|
|
123
164
|
const server = net.createServer(socket => {
|
|
124
165
|
if (sockets.size >= laneLimit) { socket.destroy(); return; }
|
|
125
166
|
sockets.add(socket); socket.setNoDelay(true);
|
|
@@ -134,7 +175,6 @@ if (token && laneLimit > 1) {
|
|
|
134
175
|
const abandoned = controller;
|
|
135
176
|
if (!abandoned || abandoned.signal.aborted) return;
|
|
136
177
|
abandoned.abort(new Error('Request transport closed'));
|
|
137
|
-
|
|
138
178
|
};
|
|
139
179
|
socket.on('end', disconnected);
|
|
140
180
|
socket.on('close', disconnected);
|
|
@@ -149,42 +189,87 @@ if (token && laneLimit > 1) {
|
|
|
149
189
|
if (invocations.size >= laneLimit) throw new Error('Worker invocation capacity exceeded');
|
|
150
190
|
controller = new AbortController();
|
|
151
191
|
const active = controller;
|
|
152
|
-
let pendingWork = 0, finished = false;
|
|
192
|
+
let pendingWork = 0, unsupervisedWork = 0, finished = false, backgroundDeadline = 0;
|
|
153
193
|
invocations.add(active);
|
|
154
|
-
|
|
194
|
+
announceActivity();
|
|
195
|
+
active.signal[requestWork] = (promise, options) => {
|
|
155
196
|
pendingWork++;
|
|
156
|
-
|
|
197
|
+
// RSC owns both its foreground deadline and its post-response
|
|
198
|
+
// responsiveness watchdog. Do not apply legacy orphan timeouts to
|
|
199
|
+
// legitimate cache work merely because its HTTP body has ended.
|
|
200
|
+
const supervised = options?.supervised === true;
|
|
201
|
+
if (!supervised) unsupervisedWork++;
|
|
202
|
+
if (!supervised && Number.isFinite(options?.timeoutMs) && options.timeoutMs > 0 && options.timeoutMs <= 300000) {
|
|
203
|
+
backgroundDeadline = Math.max(backgroundDeadline, performance.now() + options.timeoutMs);
|
|
204
|
+
if (active.signal.aborted) { clearTimeout(workTimer); retireIfAbandoned(); }
|
|
205
|
+
}
|
|
206
|
+
const settled = () => {
|
|
207
|
+
pendingWork--;
|
|
208
|
+
if (!supervised) unsupervisedWork--;
|
|
209
|
+
if (pendingWork && !unsupervisedWork) clearTimeout(workTimer);
|
|
210
|
+
if (!pendingWork && finished) {
|
|
211
|
+
clearTimeout(workTimer);
|
|
212
|
+
invocations.delete(active); exitWhenDrained();
|
|
213
|
+
}
|
|
214
|
+
};
|
|
157
215
|
workScope.runInAsyncScope(() => Promise.resolve(promise).then(settled, settled));
|
|
158
216
|
};
|
|
217
|
+
let workTimer;
|
|
159
218
|
const retireIfAbandoned = () => {
|
|
160
|
-
|
|
161
|
-
|
|
219
|
+
if (pendingWork && !unsupervisedWork) return;
|
|
220
|
+
workTimer = setTimeout(() => {
|
|
221
|
+
if (invocations.has(active) && (!pendingWork || unsupervisedWork)) process.exit(70);
|
|
222
|
+
}, Math.max(5000, backgroundDeadline - performance.now()));
|
|
223
|
+
workTimer.unref();
|
|
162
224
|
};
|
|
163
225
|
active.signal.addEventListener('abort', retireIfAbandoned, { once: true });
|
|
164
|
-
try { await handleRequest(input, output, active); }
|
|
226
|
+
try { await handleRequest(input, output, active, () => { controller = undefined; }); }
|
|
165
227
|
finally {
|
|
166
228
|
finished = true;
|
|
167
|
-
if (!pendingWork) invocations.delete(active);
|
|
168
|
-
else
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
229
|
+
if (!pendingWork) { invocations.delete(active); clearTimeout(workTimer); }
|
|
230
|
+
else {
|
|
231
|
+
if (unsupervisedWork && !active.signal.aborted) {
|
|
232
|
+
workTimer = setTimeout(() => { if (invocations.has(active) && unsupervisedWork) process.exit(70); }, Math.max(30000, backgroundDeadline - performance.now()));
|
|
233
|
+
workTimer.unref();
|
|
234
|
+
}
|
|
173
235
|
}
|
|
174
236
|
controller = undefined;
|
|
237
|
+
exitWhenDrained();
|
|
175
238
|
}
|
|
176
239
|
}
|
|
177
240
|
} catch (error) {
|
|
178
|
-
if (!socket.destroyed) console.error('[prnext] Request lane failed:', error?.message || error);
|
|
241
|
+
if (!socket.destroyed && !socket.readableEnded && !(error instanceof ProtocolWriteError)) console.error('[prnext] Request lane failed:', error?.message || error);
|
|
179
242
|
} finally { socket.destroy(); }
|
|
180
243
|
})();
|
|
181
244
|
});
|
|
182
245
|
// The stdin pipe is a parent-lifetime guard even though requests use sockets.
|
|
246
|
+
let heartbeat;
|
|
247
|
+
const shutdown = () => {
|
|
248
|
+
if (shuttingDown) return;
|
|
249
|
+
shuttingDown = true;
|
|
250
|
+
clearInterval(heartbeat);
|
|
251
|
+
for (const socket of sockets) socket.destroy();
|
|
252
|
+
server.close();
|
|
253
|
+
exitWhenDrained();
|
|
254
|
+
};
|
|
183
255
|
process.stdin.resume();
|
|
184
|
-
process.stdin.once('end',
|
|
256
|
+
process.stdin.once('end', shutdown);
|
|
257
|
+
// The native pool can close its control reader before Node observes stdin
|
|
258
|
+
// EOF. Treat that broken parent pipe as the same graceful shutdown; ordinary
|
|
259
|
+
// stdout failures remain fatal instead of silently losing protocol errors.
|
|
260
|
+
process.stdout.on('error', error => {
|
|
261
|
+
if (error.code === 'EPIPE') shutdown();
|
|
262
|
+
else throw error;
|
|
263
|
+
});
|
|
185
264
|
server.listen(0, '127.0.0.1', () => {
|
|
186
|
-
writeProtocol(JSON.stringify({ port: server.address().port, binary: true, heartbeat: true }) + '\n');
|
|
187
|
-
setInterval(() =>
|
|
265
|
+
writeProtocol(JSON.stringify({ port: server.address().port, binary: true, heartbeat: true, activity: reportActivity, gracefulShutdown: true }) + '\n');
|
|
266
|
+
heartbeat = setInterval(() => {
|
|
267
|
+
// Coalesce the idle transition with the existing heartbeat. Normal
|
|
268
|
+
// request completion adds no stdout messages, and short cache bursts
|
|
269
|
+
// cannot add two messages for every response.
|
|
270
|
+
reportedBusy = reportActivity && invocations.size > 0;
|
|
271
|
+
writeProtocol(reportedBusy ? 'busy\n' : 'alive\n');
|
|
272
|
+
}, 1000).unref();
|
|
188
273
|
});
|
|
189
274
|
} else {
|
|
190
275
|
const output = createProtocolOutput(writeProtocol, { cork: () => process.stdout.cork(), uncork: () => process.stdout.uncork() });
|
|
@@ -194,4 +279,11 @@ if (token && laneLimit > 1) {
|
|
|
194
279
|
try { await handleRequest(input, output, new AbortController()); }
|
|
195
280
|
catch (error) { console.error('[prnext] Worker transport failed:', error?.stack || error); process.exit(1); }
|
|
196
281
|
}
|
|
282
|
+
// The maintenance/ISR transport is serial and awaits its own finalizers.
|
|
283
|
+
// RSC settlement can still outlive its terminal frame; drain it explicitly
|
|
284
|
+
// before closing the isolate's MessagePorts after parent EOF.
|
|
285
|
+
if (appRuntime || appStaticRuntime) {
|
|
286
|
+
const runtime = appRuntime || await runtimeModule('app-render');
|
|
287
|
+
await runtime.drainAppRuntime();
|
|
288
|
+
}
|
|
197
289
|
}
|