@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.
Files changed (60) hide show
  1. package/README.md +395 -29
  2. package/build/actions.mjs +4 -0
  3. package/build/app-config.mjs +2 -0
  4. package/build/app.mjs +20 -5
  5. package/build/bundle-runtime.mjs +82 -0
  6. package/build/cache-handlers.mjs +4 -1
  7. package/build/compiler.mjs +4 -1
  8. package/build/config.mjs +20 -2
  9. package/build/font-cache.mjs +50 -0
  10. package/build/font-files.mjs +5 -3
  11. package/build/fonts.mjs +1 -1
  12. package/build/index.mjs +12 -1
  13. package/build/metadata-conflicts.mjs +13 -0
  14. package/build/metadata-images.mjs +3 -3
  15. package/build/metadata-routes.mjs +3 -3
  16. package/build/native-packages.mjs +5 -1
  17. package/build/npm-packages.mjs +14 -2
  18. package/build/optimize-imports.mjs +112 -0
  19. package/build/proxy-body-config.mjs +10 -0
  20. package/build/runtime-contract.mjs +16 -0
  21. package/build/scan.mjs +9 -7
  22. package/build/typescript-check.mjs +20 -0
  23. package/build/typescript.mjs +49 -0
  24. package/build/worker-retirement.mjs +14 -0
  25. package/cli.mjs +17 -0
  26. package/compat/after.cjs +51 -0
  27. package/compat/index.d.ts +13 -1
  28. package/compat/server.cjs +2 -1
  29. package/compat/server.d.cts +2 -0
  30. package/native/launch.mjs +19 -0
  31. package/package.json +25 -9
  32. package/release/verify.mjs +9 -0
  33. package/runtime/api.mjs +15 -5
  34. package/runtime/app-render.mjs +164 -53
  35. package/runtime/app-route-plan.mjs +45 -0
  36. package/runtime/app-routing-state.mjs +101 -21
  37. package/runtime/app-routing.mjs +37 -18
  38. package/runtime/app-segments.mjs +25 -11
  39. package/runtime/app-static.mjs +1 -1
  40. package/runtime/app-stream-html.mjs +3 -53
  41. package/runtime/cache-request.mjs +10 -2
  42. package/runtime/cloud/archive.mjs +316 -0
  43. package/runtime/cloud/client.mjs +658 -0
  44. package/runtime/cloud/environment.mjs +60 -0
  45. package/runtime/cloud/state.mjs +141 -0
  46. package/runtime/cloud/upload.mjs +48 -0
  47. package/runtime/html-envelope.mjs +136 -0
  48. package/runtime/http.mjs +9 -6
  49. package/runtime/middleware.mjs +3 -3
  50. package/runtime/profiles.json +13 -8
  51. package/runtime/request-transport.mjs +1 -1
  52. package/runtime/request-work.mjs +2 -2
  53. package/runtime/route-static.mjs +1 -1
  54. package/runtime/rsc-lifecycle.mjs +119 -0
  55. package/runtime/rsc-work-watchdog.mjs +75 -0
  56. package/runtime/rsc-worker.mjs +36 -9
  57. package/runtime/stream-utils.mjs +22 -3
  58. package/runtime/transport.mjs +89 -53
  59. package/runtime/worker-metadata.mjs +23 -0
  60. package/runtime/worker.mjs +119 -27
@@ -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 => import(pathToFileURL(path.join(distDir, `runtime/${name}.mjs`)).href);
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 import(pathToFileURL(path.join(distDir, 'runtime/app-render.mjs')).href);
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
- try { await output(input.id, result, { ...input, production, signalController }); }
101
- finally { await result.finalizeCache?.(); }
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 replaces this failed worker.
119
+ // response on the same request. Rust discards the incomplete lane.
107
120
  throw error;
108
121
  }
109
- await output(input?.id ?? null, navigationResponse(error) || errorResponse(error, production), { ...input, production, signalController });
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
- active.signal[requestWork] = promise => {
194
+ announceActivity();
195
+ active.signal[requestWork] = (promise, options) => {
155
196
  pendingWork++;
156
- const settled = () => { if (--pendingWork === 0 && finished) invocations.delete(active); };
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
- const timer = setTimeout(() => { if (invocations.has(active)) process.exit(70); }, 5000);
161
- timer.unref();
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 if (!active.signal.aborted) {
169
- // Background code after sending a complete response still has a
170
- // finite lifetime, matching the native request deadline.
171
- const timer = setTimeout(() => { if (invocations.has(active)) process.exit(70); }, 30000);
172
- timer.unref();
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', () => { for (const socket of sockets) socket.destroy(); server.close(); process.exit(0); });
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(() => writeProtocol('alive\n'), 1000).unref();
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
  }