@thomas.f/prnext 0.1.11-alpha → 0.1.12-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 +139 -2
- package/build/cache-handlers.mjs +4 -1
- package/build/compiler.mjs +1 -0
- package/build/config.mjs +11 -0
- package/build/index.mjs +7 -1
- package/build/npm-packages.mjs +12 -1
- package/build/runtime-contract.mjs +16 -0
- package/build/typescript-check.mjs +20 -0
- package/build/typescript.mjs +49 -0
- package/build/worker-retirement.mjs +14 -0
- package/cli.mjs +1 -0
- package/compat/index.d.ts +10 -0
- package/package.json +25 -9
- package/release/verify.mjs +9 -0
- package/runtime/app-render.mjs +95 -29
- package/runtime/app-stream-html.mjs +1 -52
- package/runtime/html-envelope.mjs +73 -0
- package/runtime/http.mjs +2 -2
- package/runtime/profiles.json +13 -8
- package/runtime/request-work.mjs +2 -2
- package/runtime/rsc-lifecycle.mjs +119 -0
- package/runtime/rsc-work-watchdog.mjs +75 -0
- package/runtime/rsc-worker.mjs +9 -5
- package/runtime/transport.mjs +73 -53
- package/runtime/worker.mjs +90 -25
package/runtime/app-render.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import diagnostics from '../compat/diagnostics.cjs';
|
|
2
3
|
import { runtimeProfile } from './profile.mjs';
|
|
3
4
|
import { fontPreloads } from './font-preload.mjs';
|
|
4
5
|
import { advancedRoutingSources } from './app-routing-state.mjs';
|
|
@@ -25,10 +26,14 @@ import { partialArtifact, partialShell } from './app-partial.mjs';
|
|
|
25
26
|
import { partialKeyMap } from './app-partial-model.mjs';
|
|
26
27
|
import { partialIdentity } from './partial-artifact-cache.mjs';
|
|
27
28
|
import { isDraftRequest } from '../compat/draft.cjs';
|
|
29
|
+
import { createRscLifecycle } from './rsc-lifecycle.mjs';
|
|
30
|
+
import { trackRequestWork } from './request-work.mjs';
|
|
31
|
+
import { createRscWorkWatchdog } from './rsc-work-watchdog.mjs';
|
|
28
32
|
|
|
29
33
|
installFetchCache();
|
|
30
34
|
|
|
31
35
|
const workers = new Map();
|
|
36
|
+
const lifecycles = new WeakMap();
|
|
32
37
|
const metadata = new WeakMap();
|
|
33
38
|
const artifacts = new WeakMap();
|
|
34
39
|
const edgeManifests = new WeakMap();
|
|
@@ -97,6 +102,7 @@ globalThis.__webpack_chunk_load__ = id => {
|
|
|
97
102
|
|
|
98
103
|
function getWorker(production) {
|
|
99
104
|
if (workers.has(production)) return workers.get(production);
|
|
105
|
+
const created = diagnostics.enabled ? performance.now() : 0;
|
|
100
106
|
const current = new Worker(new URL('./rsc-worker.mjs', import.meta.url), {
|
|
101
107
|
execArgv: ['--conditions=react-server'], stdout: true, stderr: true,
|
|
102
108
|
// Production nursery policy; an explicit Node semi-space flag takes
|
|
@@ -105,37 +111,68 @@ function getWorker(production) {
|
|
|
105
111
|
env: { ...process.env, NODE_ENV: production ? 'production' : 'development' },
|
|
106
112
|
});
|
|
107
113
|
workers.set(production, current);
|
|
114
|
+
if (diagnostics.enabled) {
|
|
115
|
+
current.once('online', () => diagnostics.record('wake', {phase:'rsc-online', durationMs:performance.now()-created}));
|
|
116
|
+
const ready = message => {
|
|
117
|
+
if (message.type !== 'rsc-ready') return;
|
|
118
|
+
diagnostics.record('wake', {phase:'rsc-ready', durationMs:performance.now()-created});
|
|
119
|
+
current.off('message', ready);
|
|
120
|
+
};
|
|
121
|
+
current.on('message', ready);
|
|
122
|
+
}
|
|
123
|
+
let workWatchdog;
|
|
124
|
+
const lifecycle = createRscLifecycle(() => { workWatchdog?.close(); return current.terminate(); });
|
|
125
|
+
workWatchdog = createRscWorkWatchdog({
|
|
126
|
+
probe: token => current.postMessage({ type: 'work-probe', token }),
|
|
127
|
+
retire: error => {
|
|
128
|
+
console.error('[prnext] RSC background worker stopped responding:', error);
|
|
129
|
+
return lifecycle.terminate(error);
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
lifecycles.set(current, lifecycle);
|
|
108
133
|
metadata.set(current, workerMetadata(current));
|
|
109
134
|
artifacts.set(current, workerArtifacts(current));
|
|
110
135
|
// The HTTP worker's stdout belongs to its Rust transport. User code and React
|
|
111
136
|
// diagnostics in the separate RSC environment must never corrupt that stream.
|
|
112
137
|
current.stdout.pipe(process.stderr);
|
|
113
138
|
current.stderr.pipe(process.stderr);
|
|
114
|
-
function fail(error) {
|
|
115
|
-
|
|
139
|
+
function fail(error, exited = false) {
|
|
140
|
+
// Keep a retiring isolate registered until its actual exit; callers may
|
|
141
|
+
// wait for replacement but cannot start an unbounded series of retirees.
|
|
142
|
+
if (exited && workers.get(production) === current) workers.delete(production);
|
|
143
|
+
lifecycle.close(error);
|
|
144
|
+
workWatchdog.close();
|
|
116
145
|
for (const [id, request] of pending) {
|
|
117
146
|
if (request.worker !== current) continue;
|
|
118
147
|
clearTimeout(request.timeout);
|
|
119
148
|
pending.delete(id);
|
|
149
|
+
request.lease.release();
|
|
150
|
+
request.settle();
|
|
151
|
+
request.releaseWorkWatch?.();
|
|
120
152
|
request.detach?.();
|
|
121
153
|
request.channel?.error(error);
|
|
122
154
|
request.reject(error);
|
|
123
155
|
}
|
|
124
156
|
}
|
|
125
|
-
current.on('message', ({ id, type, chunk, byteOffset, byteLength, done, body, status, rscError, boundaryIndex, headers, error, staticParams, staticMetadata, navigation }) => {
|
|
157
|
+
current.on('message', ({ id, type, token, chunk, byteOffset, byteLength, done, body, status, rscError, boundaryIndex, headers, error, staticParams, staticMetadata, navigation }) => {
|
|
158
|
+
if (type === 'work-responsive') { workWatchdog.ack(token); return; }
|
|
126
159
|
const request = pending.get(id);
|
|
127
160
|
if (!request || request.worker !== current) return;
|
|
128
161
|
// A timed-out mutation can still be running after its HTTP caller leaves.
|
|
129
162
|
// Its retirement marker must be honored before discarding cancelled output.
|
|
130
163
|
if (error?.retireWorker) {
|
|
131
|
-
|
|
132
|
-
void current.terminate();
|
|
164
|
+
void lifecycle.terminate(new Error(error.message)).catch(error => console.error('[prnext] RSC worker termination failed:', error));
|
|
133
165
|
}
|
|
134
166
|
if (request.cancelled && type !== 'settled') return;
|
|
135
167
|
if (type === 'settled') {
|
|
136
|
-
if (!request.cancelled) return;
|
|
137
168
|
pending.delete(id); clearTimeout(request.timeout);
|
|
138
|
-
|
|
169
|
+
request.lease.release();
|
|
170
|
+
request.detach?.();
|
|
171
|
+
request.settle();
|
|
172
|
+
request.releaseWorkWatch?.();
|
|
173
|
+
// terminate() keeps its exit event referenced. A late settlement must
|
|
174
|
+
// not undo that reference while build publication awaits shutdown.
|
|
175
|
+
if (!hasPending(current) && !lifecycle.retiring) current.unref();
|
|
139
176
|
return;
|
|
140
177
|
}
|
|
141
178
|
if (type === 'start') {
|
|
@@ -145,11 +182,19 @@ function getWorker(production) {
|
|
|
145
182
|
}
|
|
146
183
|
if (type === 'chunk') {
|
|
147
184
|
try { request.channel.chunk(chunk, byteOffset, byteLength); }
|
|
148
|
-
catch (error) { request.channel.error(error); current.postMessage({ id, type: 'cancel' }); }
|
|
185
|
+
catch (error) { request.channel.error(error); current.postMessage({ id, type: 'cancel' }); request.cancel(error); }
|
|
149
186
|
if (!done) return;
|
|
150
187
|
}
|
|
151
|
-
|
|
188
|
+
if (request.completed) {
|
|
189
|
+
if (error) console.error('[prnext] RSC background work failed:', error.stack || error.message);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
request.completed = true;
|
|
193
|
+
// The response deadline ends with its successful terminal frame. Cache
|
|
194
|
+
// work can legitimately continue afterwards and retains its own bounds;
|
|
195
|
+
// supervise isolate responsiveness, not total background-task duration.
|
|
152
196
|
clearTimeout(request.timeout);
|
|
197
|
+
request.releaseWorkWatch = workWatchdog.add();
|
|
153
198
|
request.detach?.();
|
|
154
199
|
if (type === 'end' || done) request.channel.end();
|
|
155
200
|
else if (error) {
|
|
@@ -171,51 +216,71 @@ function getWorker(production) {
|
|
|
171
216
|
// ArrayBuffer overload: a view of the transferred allocation, no copy.
|
|
172
217
|
request.resolve({ body: Buffer.from(body), status: status || 200, rscError, boundaryIndex, headers, staticMetadata, navigation });
|
|
173
218
|
}
|
|
174
|
-
|
|
219
|
+
// Keep only settlement bookkeeping after sending the body. The
|
|
220
|
+
// isolate can still be flushing cache writes after its terminal frame.
|
|
221
|
+
request.channel = undefined;
|
|
222
|
+
request.resolve = request.reject = () => {};
|
|
175
223
|
});
|
|
176
224
|
current.on('error', fail);
|
|
177
|
-
current.on('exit', code => fail(new Error(`App Router worker exited (${code})`)));
|
|
225
|
+
current.on('exit', code => fail(new Error(`App Router worker exited (${code})`), true));
|
|
178
226
|
current.unref();
|
|
179
227
|
return current;
|
|
180
228
|
}
|
|
181
229
|
|
|
182
230
|
export function closeAppRuntime() {
|
|
183
231
|
const current = [...workers.values()];
|
|
184
|
-
|
|
185
|
-
return Promise.all(current.map(worker => worker.terminate()));
|
|
232
|
+
return Promise.all(current.map(worker => lifecycles.get(worker).terminate()));
|
|
186
233
|
}
|
|
187
234
|
|
|
188
|
-
export function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs = 28_000, signal } = {}) {
|
|
235
|
+
export async function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs = 28_000, signal } = {}) {
|
|
236
|
+
const deadline = performance.now() + hardTimeoutMs;
|
|
237
|
+
let current, lifecycle, lease;
|
|
238
|
+
for (;;) {
|
|
239
|
+
signal?.throwIfAborted();
|
|
240
|
+
current = getWorker(Boolean(request.production));
|
|
241
|
+
lifecycle = lifecycles.get(current);
|
|
242
|
+
if (lifecycle.retiring) {
|
|
243
|
+
await lifecycle.waitForRetirement({ signal, timeoutMs: deadline - performance.now() });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
try { lease = await lifecycle.acquire({ signal, timeoutMs: deadline - performance.now() }); }
|
|
247
|
+
catch (error) { if (lifecycle.retiring) continue; throw error; }
|
|
248
|
+
if (lifecycle.closed) { lease.release(); continue; }
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
189
251
|
return new Promise((resolve, reject) => {
|
|
190
|
-
|
|
252
|
+
if (signal?.aborted) { lease.release(); reject(signal.reason); return; }
|
|
253
|
+
if (performance.now() >= deadline) { lease.release(); reject(appTimeoutError('RSC admission', hardTimeoutMs)); return; }
|
|
191
254
|
current.ref();
|
|
192
255
|
const id = ++nextId;
|
|
256
|
+
let settle;
|
|
257
|
+
trackRequestWork(new Promise(resolve => { settle = resolve; }), signal, { deadline, supervised: true });
|
|
193
258
|
const timeout = setTimeout(() => {
|
|
194
259
|
// A blocking npm dependency cannot honor an AbortSignal. Retire the
|
|
195
260
|
// isolate at the hard deadline so subsequent requests can recover.
|
|
196
|
-
|
|
197
|
-
void current.terminate();
|
|
261
|
+
void lifecycle.terminate(appTimeoutError('RSC worker', hardTimeoutMs)).finally(settle).catch(error => console.error('[prnext] RSC worker termination failed:', error));
|
|
198
262
|
pending.delete(id);
|
|
263
|
+
lease.release();
|
|
199
264
|
const error = appTimeoutError('RSC worker', hardTimeoutMs);
|
|
265
|
+
if (entry.completed) console.error('[prnext] RSC background work failed:', error);
|
|
200
266
|
entry.channel?.error(error);
|
|
201
267
|
entry.detach?.();
|
|
202
268
|
reject(error);
|
|
203
|
-
},
|
|
204
|
-
const entry = { worker: current, resolve, reject, timeout, operation: request.operation };
|
|
269
|
+
}, Math.max(0, deadline - performance.now()));
|
|
270
|
+
const entry = { worker: current, resolve, reject, timeout, operation: request.operation, lease, settle };
|
|
205
271
|
function cancel(reason) {
|
|
272
|
+
if (entry.cancelled) return;
|
|
206
273
|
// Keep the independent deadline until the isolate confirms settlement.
|
|
207
274
|
// A disconnected client must not disable recovery of blocked npm code.
|
|
208
275
|
entry.cancelled = true;
|
|
276
|
+
lease.cancel();
|
|
209
277
|
entry.detach?.();
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
if (hasPending(current
|
|
213
|
-
if (workers.get(Boolean(request.production)) === current) workers.delete(Boolean(request.production));
|
|
214
|
-
void current.terminate();
|
|
215
|
-
}
|
|
216
|
-
if (!hasPending(current)) current.unref();
|
|
278
|
+
// Pause new work at 16 abandoned requests while their acknowledgements
|
|
279
|
+
// settle. Existing mutation/cache work keeps its original hard deadline.
|
|
280
|
+
if (!hasPending(current) && !lifecycle.retiring) current.unref();
|
|
217
281
|
reject(reason instanceof Error ? reason : new Error('App Router response was cancelled'));
|
|
218
282
|
}
|
|
283
|
+
entry.cancel = cancel;
|
|
219
284
|
if (request.stream) entry.channel = parentStreamChannel(current, id, cancel);
|
|
220
285
|
if (signal) {
|
|
221
286
|
const onAbort = () => {
|
|
@@ -225,11 +290,10 @@ export function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs =
|
|
|
225
290
|
};
|
|
226
291
|
entry.detach = () => signal.removeEventListener('abort', onAbort);
|
|
227
292
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
228
|
-
if (signal.aborted) { clearTimeout(timeout); onAbort(); return; }
|
|
229
293
|
}
|
|
230
294
|
pending.set(id, entry);
|
|
231
295
|
try {
|
|
232
|
-
const wire = { ...request, renderTimeoutMs: softTimeoutMs };
|
|
296
|
+
const wire = { ...request, renderTimeoutMs: Math.min(softTimeoutMs, Math.max(1, deadline - performance.now())) };
|
|
233
297
|
if (request.production) {
|
|
234
298
|
const artifactId = artifacts.get(current)(wire.partialFlight);
|
|
235
299
|
if (artifactId !== undefined) { wire.partialFlightId = artifactId; delete wire.partialFlight; }
|
|
@@ -243,9 +307,11 @@ export function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs =
|
|
|
243
307
|
catch (error) {
|
|
244
308
|
clearTimeout(timeout);
|
|
245
309
|
pending.delete(id);
|
|
310
|
+
lease.release();
|
|
311
|
+
settle();
|
|
246
312
|
entry.detach?.();
|
|
247
313
|
reject(error);
|
|
248
|
-
if (!hasPending(current)) current.unref();
|
|
314
|
+
if (!hasPending(current) && !lifecycle.retiring) current.unref();
|
|
249
315
|
}
|
|
250
316
|
});
|
|
251
317
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { htmlEnvelope } from './html-envelope.mjs';
|
|
1
2
|
import React from 'react';
|
|
2
3
|
import { fontPreloads } from './font-preload.mjs';
|
|
3
4
|
import { isDynamicBailout } from '../compat/dynamic-bailout.cjs';
|
|
@@ -12,58 +13,6 @@ import { renderAppRecoveryShell } from './app-recovery.mjs';
|
|
|
12
13
|
import { partialModel } from './app-partial-model.mjs';
|
|
13
14
|
import { streamSelector } from './stream-select.mjs';
|
|
14
15
|
|
|
15
|
-
// Do not insert bootstrap scripts inside a split HTML tag or raw-text element.
|
|
16
|
-
// React may split a large inline script across arbitrary byte chunks.
|
|
17
|
-
function htmlEnvelope({ resumed = false } = {}) {
|
|
18
|
-
let pending = '';
|
|
19
|
-
let closing = '';
|
|
20
|
-
let received = 0;
|
|
21
|
-
return {
|
|
22
|
-
append(text) {
|
|
23
|
-
received += Buffer.byteLength(text);
|
|
24
|
-
if (received > MAX_RESPONSE_BYTES) throw new Error('HTML response exceeds the 16 MiB PRNext limit');
|
|
25
|
-
pending += text;
|
|
26
|
-
let ready = '';
|
|
27
|
-
while (pending) {
|
|
28
|
-
const start = pending.indexOf('<');
|
|
29
|
-
if (start < 0) { ready += pending; pending = ''; break; }
|
|
30
|
-
if (start) { ready += pending.slice(0, start); pending = pending.slice(start); }
|
|
31
|
-
let end = -1;
|
|
32
|
-
if (pending.startsWith('<!--')) {
|
|
33
|
-
const comment = pending.indexOf('-->');
|
|
34
|
-
if (comment >= 0) end = comment + 2;
|
|
35
|
-
} else {
|
|
36
|
-
let quote;
|
|
37
|
-
for (let index = 1; index < pending.length; index++) {
|
|
38
|
-
const char = pending[index];
|
|
39
|
-
if (quote) { if (char === quote) quote = undefined; }
|
|
40
|
-
else if (char === '"' || char === "'") quote = char;
|
|
41
|
-
else if (char === '>') { end = index; break; }
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
if (end < 0) break;
|
|
45
|
-
const tag = pending.slice(0, end + 1);
|
|
46
|
-
const raw = /^<(script|style|textarea|title|svg|math)(?:\s|>)/i.exec(tag);
|
|
47
|
-
if (raw && !tag.endsWith('/>')) {
|
|
48
|
-
const match = new RegExp(`</${raw[1]}\\s*>`, 'i').exec(pending.slice(end + 1));
|
|
49
|
-
if (!match) break;
|
|
50
|
-
end += 1 + match.index + match[0].length;
|
|
51
|
-
ready += pending.slice(0, end); pending = pending.slice(end);
|
|
52
|
-
} else {
|
|
53
|
-
if (/^<\/(body|html)\s*>$/i.test(tag)) closing += tag;
|
|
54
|
-
else ready += tag;
|
|
55
|
-
pending = pending.slice(end + 1);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return ready;
|
|
59
|
-
},
|
|
60
|
-
finish() {
|
|
61
|
-
if (pending || !(resumed ? /^(?:<\/body><\/html>){1,2}$/i : /^<\/body><\/html>$/i).test(closing)) throw new Error('App Router root layout did not close its body and html elements');
|
|
62
|
-
return resumed ? '</body></html>' : closing;
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
16
|
// The SSR decoder and HTML bootstrap share each Flight allocation. Unlike
|
|
68
17
|
// ReadableStream.tee(), this tap stops reading when the HTML consumer stalls.
|
|
69
18
|
// Before a document shell exists, at most the overall 16 MiB response limit can
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { MAX_RESPONSE_BYTES } from './http.mjs';
|
|
2
|
+
|
|
3
|
+
// Do not insert Flight/bootstrap scripts inside a split tag or raw-text element.
|
|
4
|
+
// Scan by index: ordinary tags stay in one contiguous slice, instead of creating
|
|
5
|
+
// a new tail and output string for every cell of a rendered table.
|
|
6
|
+
export function htmlEnvelope({ resumed = false } = {}) {
|
|
7
|
+
let pending = '';
|
|
8
|
+
let closing = '';
|
|
9
|
+
let received = 0;
|
|
10
|
+
const rawStart = /<(script|style|textarea|title|svg|math)(?:\s|>)/iy;
|
|
11
|
+
const rawEnds = new Map();
|
|
12
|
+
const documentEnd = /<\/(body|html)\s*>/iy;
|
|
13
|
+
return {
|
|
14
|
+
append(text) {
|
|
15
|
+
received += Buffer.byteLength(text);
|
|
16
|
+
if (received > MAX_RESPONSE_BYTES) throw new Error('HTML response exceeds the 16 MiB PRNext limit');
|
|
17
|
+
pending += text;
|
|
18
|
+
let cursor = 0, output = 0, ready = '';
|
|
19
|
+
while (cursor < pending.length) {
|
|
20
|
+
const start = pending.indexOf('<', cursor);
|
|
21
|
+
if (start < 0) { cursor = pending.length; break; }
|
|
22
|
+
cursor = start;
|
|
23
|
+
let end = -1;
|
|
24
|
+
if (pending.startsWith('<!--', start)) {
|
|
25
|
+
const comment = pending.indexOf('-->', start);
|
|
26
|
+
if (comment >= 0) end = comment + 2;
|
|
27
|
+
} else {
|
|
28
|
+
let quote;
|
|
29
|
+
for (let index = start + 1; index < pending.length; index++) {
|
|
30
|
+
const char = pending[index];
|
|
31
|
+
if (quote) { if (char === quote) quote = undefined; }
|
|
32
|
+
else if (char === '"' || char === "'") quote = char;
|
|
33
|
+
else if (char === '>') { end = index; break; }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (end < 0) break;
|
|
37
|
+
// Most tags need no special handling. Reject their ASCII prefixes
|
|
38
|
+
// before running the raw-element and document-closing expressions;
|
|
39
|
+
// the expressions still validate every candidate in full.
|
|
40
|
+
const first = pending.charCodeAt(start + 1) | 32;
|
|
41
|
+
const second = pending.charCodeAt(start + 2) | 32;
|
|
42
|
+
const rawCandidate = first === 115 && (second === 99 || second === 116 || second === 118) ||
|
|
43
|
+
first === 116 && (second === 101 || second === 105) || first === 109 && second === 97;
|
|
44
|
+
rawStart.lastIndex = start;
|
|
45
|
+
const raw = rawCandidate && rawStart.exec(pending);
|
|
46
|
+
if (raw && pending[end - 1] !== '/') {
|
|
47
|
+
const name = raw[1].toLowerCase();
|
|
48
|
+
let pattern = rawEnds.get(name);
|
|
49
|
+
if (!pattern) { pattern = new RegExp(`</${name}\\s*>`, 'ig'); rawEnds.set(name, pattern); }
|
|
50
|
+
pattern.lastIndex = end + 1;
|
|
51
|
+
const match = pattern.exec(pending);
|
|
52
|
+
if (!match) break;
|
|
53
|
+
cursor = pattern.lastIndex;
|
|
54
|
+
} else {
|
|
55
|
+
documentEnd.lastIndex = start;
|
|
56
|
+
if (first === 47 && (second === 98 || second === 104) && documentEnd.test(pending) && documentEnd.lastIndex === end + 1) {
|
|
57
|
+
ready += pending.slice(output, start);
|
|
58
|
+
closing += pending.slice(start, end + 1);
|
|
59
|
+
output = end + 1;
|
|
60
|
+
}
|
|
61
|
+
cursor = end + 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
ready += pending.slice(output, cursor);
|
|
65
|
+
pending = pending.slice(cursor);
|
|
66
|
+
return ready;
|
|
67
|
+
},
|
|
68
|
+
finish() {
|
|
69
|
+
if (pending || !(resumed ? /^(?:<\/body><\/html>){1,2}$/i : /^<\/body><\/html>$/i).test(closing)) throw new Error('App Router root layout did not close its body and html elements');
|
|
70
|
+
return resumed ? '</body></html>' : closing;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/runtime/http.mjs
CHANGED
|
@@ -184,7 +184,7 @@ export function plainResponse(status, message) {
|
|
|
184
184
|
|
|
185
185
|
export function errorResponse(error, production = process.env.NODE_ENV === 'production') {
|
|
186
186
|
console.error('[prnext]', error?.stack || error);
|
|
187
|
-
const status = [400, 504].includes(error?.statusCode) ? error.statusCode : 500;
|
|
188
|
-
const message = status === 400 ? 'Bad Request' : status === 504 ? 'Gateway Timeout' : 'Internal Server Error';
|
|
187
|
+
const status = [400, 503, 504].includes(error?.statusCode) ? error.statusCode : 500;
|
|
188
|
+
const message = status === 400 ? 'Bad Request' : status === 503 ? 'Service Unavailable' : status === 504 ? 'Gateway Timeout' : 'Internal Server Error';
|
|
189
189
|
return plainResponse(status, production || status === 400 ? message : String(error?.stack || error));
|
|
190
190
|
}
|
package/runtime/profiles.json
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
"renderStarts": 16,
|
|
7
7
|
"liveResponses": 32,
|
|
8
8
|
"apiConcurrency": 512,
|
|
9
|
-
"responseBufferMiB": 8
|
|
9
|
+
"responseBufferMiB": 8,
|
|
10
|
+
"renderIdleSeconds": null
|
|
10
11
|
},
|
|
11
12
|
"compact": {
|
|
12
13
|
"optimizeForSize": true,
|
|
@@ -15,16 +16,18 @@
|
|
|
15
16
|
"renderStarts": 16,
|
|
16
17
|
"liveResponses": 32,
|
|
17
18
|
"apiConcurrency": 512,
|
|
18
|
-
"responseBufferMiB": 8
|
|
19
|
+
"responseBufferMiB": 8,
|
|
20
|
+
"renderIdleSeconds": 30
|
|
19
21
|
},
|
|
20
22
|
"balanced": {
|
|
21
|
-
"optimizeForSize":
|
|
22
|
-
"semiSpaceMiB":
|
|
23
|
+
"optimizeForSize": false,
|
|
24
|
+
"semiSpaceMiB": 4,
|
|
23
25
|
"youngGenerationMiB": 16,
|
|
24
26
|
"renderStarts": 16,
|
|
25
27
|
"liveResponses": 32,
|
|
26
28
|
"apiConcurrency": 256,
|
|
27
|
-
"responseBufferMiB": 4
|
|
29
|
+
"responseBufferMiB": 4,
|
|
30
|
+
"renderIdleSeconds": 300
|
|
28
31
|
},
|
|
29
32
|
"speed": {
|
|
30
33
|
"optimizeForSize": false,
|
|
@@ -33,15 +36,17 @@
|
|
|
33
36
|
"renderStarts": 32,
|
|
34
37
|
"liveResponses": 128,
|
|
35
38
|
"apiConcurrency": 512,
|
|
36
|
-
"responseBufferMiB": 16
|
|
39
|
+
"responseBufferMiB": 16,
|
|
40
|
+
"renderIdleSeconds": null
|
|
37
41
|
},
|
|
38
42
|
"memory": {
|
|
39
43
|
"optimizeForSize": true,
|
|
40
44
|
"semiSpaceMiB": 2,
|
|
41
45
|
"youngGenerationMiB": 4,
|
|
42
46
|
"renderStarts": 8,
|
|
43
|
-
"liveResponses":
|
|
47
|
+
"liveResponses": 28,
|
|
44
48
|
"apiConcurrency": 64,
|
|
45
|
-
"responseBufferMiB": 1
|
|
49
|
+
"responseBufferMiB": 1,
|
|
50
|
+
"renderIdleSeconds": 30
|
|
46
51
|
}
|
|
47
52
|
}
|
package/runtime/request-work.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Track the actual application promise as well as the HTTP wrapper. A wrapper
|
|
2
2
|
// can reject on abort while npm code keeps running and retaining its context.
|
|
3
3
|
export const requestWork = Symbol.for('prnext.request-work');
|
|
4
|
-
export function trackRequestWork(promise, signal) {
|
|
5
|
-
signal?.[requestWork]?.(promise);
|
|
4
|
+
export function trackRequestWork(promise, signal, options) {
|
|
5
|
+
signal?.[requestWork]?.(promise, options);
|
|
6
6
|
return promise;
|
|
7
7
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { appTimeoutError } from './app-errors.mjs';
|
|
2
|
+
|
|
3
|
+
// Per-isolate admission includes abandoned requests until the isolate confirms
|
|
4
|
+
// settlement. A burst of cooperative cancellations gets time to acknowledge
|
|
5
|
+
// without immediately terminating unrelated active responses.
|
|
6
|
+
export function createRscLifecycle(retire, {
|
|
7
|
+
cancellationLimit = 16, maxActive = 512, maxWaiters = 512,
|
|
8
|
+
} = {}) {
|
|
9
|
+
for (const [name, value] of Object.entries({ cancellationLimit, maxActive, maxWaiters })) {
|
|
10
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive safe integer`);
|
|
11
|
+
}
|
|
12
|
+
if (typeof retire !== 'function') throw new TypeError('retire must be a function');
|
|
13
|
+
const active = new Set(), waiters = new Set(), retirementWaiters = new Set();
|
|
14
|
+
let cancelled = 0, closedError, retirement, retired = false, retirementFailed = false, retirementError;
|
|
15
|
+
const timeoutError = milliseconds => appTimeoutError('RSC admission', milliseconds);
|
|
16
|
+
const available = () => !closedError && cancelled < cancellationLimit && active.size < maxActive;
|
|
17
|
+
|
|
18
|
+
function lease() {
|
|
19
|
+
const token = { cancelled: false };
|
|
20
|
+
active.add(token);
|
|
21
|
+
return {
|
|
22
|
+
cancel() {
|
|
23
|
+
if (!active.has(token) || token.cancelled) return;
|
|
24
|
+
token.cancelled = true;
|
|
25
|
+
cancelled++;
|
|
26
|
+
// Pressure closes admission, never shortens an existing request's
|
|
27
|
+
// deadline. Mutations and cache work may legitimately outlive callers.
|
|
28
|
+
},
|
|
29
|
+
release() {
|
|
30
|
+
if (!active.delete(token)) return;
|
|
31
|
+
if (token.cancelled) cancelled--;
|
|
32
|
+
drain();
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function drain() {
|
|
38
|
+
for (const waiter of waiters) {
|
|
39
|
+
if (!available()) break;
|
|
40
|
+
waiter.cleanup();
|
|
41
|
+
if (waiter.signal?.aborted) waiter.reject(waiter.signal.reason);
|
|
42
|
+
else if (performance.now() >= waiter.deadline) waiter.reject(timeoutError(waiter.timeoutMs));
|
|
43
|
+
else waiter.resolve(lease());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function acquire({ signal, timeoutMs = 28000 } = {}) {
|
|
48
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
49
|
+
if (closedError) return Promise.reject(closedError);
|
|
50
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return Promise.reject(timeoutError(timeoutMs));
|
|
51
|
+
if (available()) return Promise.resolve(lease());
|
|
52
|
+
return queue(waiters, signal, timeoutMs);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function queue(collection, signal, timeoutMs) {
|
|
56
|
+
if (waiters.size + retirementWaiters.size >= maxWaiters) {
|
|
57
|
+
const error = new Error('App Router RSC admission queue is full');
|
|
58
|
+
error.statusCode = 503;
|
|
59
|
+
return Promise.reject(error);
|
|
60
|
+
}
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
let timer;
|
|
63
|
+
const onAbort = () => { waiter.cleanup(); reject(signal.reason); };
|
|
64
|
+
const waiter = { signal, timeoutMs, deadline: performance.now() + timeoutMs, resolve, reject,
|
|
65
|
+
cleanup() { collection.delete(waiter); clearTimeout(timer); signal?.removeEventListener('abort', onAbort); },
|
|
66
|
+
};
|
|
67
|
+
collection.add(waiter);
|
|
68
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
69
|
+
timer = setTimeout(() => { waiter.cleanup(); reject(timeoutError(timeoutMs)); }, timeoutMs);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function waitForRetirement({ signal, timeoutMs = 28000 } = {}) {
|
|
74
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
75
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return Promise.reject(timeoutError(timeoutMs));
|
|
76
|
+
if (!retirement) return Promise.reject(new Error('App Router worker is not retiring'));
|
|
77
|
+
if (retired) return retirementFailed ? Promise.reject(retirementError) : Promise.resolve();
|
|
78
|
+
return queue(retirementWaiters, signal, timeoutMs);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function finishRetirement(error, failed = false) {
|
|
82
|
+
retired = true;
|
|
83
|
+
retirementFailed = failed;
|
|
84
|
+
retirementError = error;
|
|
85
|
+
for (const waiter of retirementWaiters) {
|
|
86
|
+
waiter.cleanup();
|
|
87
|
+
if (waiter.signal?.aborted) waiter.reject(waiter.signal.reason);
|
|
88
|
+
else if (performance.now() >= waiter.deadline) waiter.reject(timeoutError(waiter.timeoutMs));
|
|
89
|
+
else if (failed) waiter.reject(error);
|
|
90
|
+
else waiter.resolve();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function close(error = new Error('App Router worker closed')) {
|
|
95
|
+
if (closedError) return;
|
|
96
|
+
closedError = error;
|
|
97
|
+
for (const waiter of waiters) { waiter.cleanup(); waiter.reject(error); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function terminate(error = new Error('App Router worker retired')) {
|
|
101
|
+
close(error);
|
|
102
|
+
// Worker.terminate() adds an exit listener. Share exactly one call/promise
|
|
103
|
+
// across all request deadlines, cancellation pressure and shutdown paths.
|
|
104
|
+
if (!retirement) {
|
|
105
|
+
retirement = Promise.resolve().then(retire);
|
|
106
|
+
// One observer per isolate, rather than one Promise reaction retained
|
|
107
|
+
// for every caller that times out/aborts while the isolate is stopping.
|
|
108
|
+
retirement.then(() => finishRetirement(), error => finishRetirement(error, true));
|
|
109
|
+
}
|
|
110
|
+
return retirement;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
acquire, close, terminate, waitForRetirement,
|
|
115
|
+
get retiring() { return retirement ?? null; },
|
|
116
|
+
get closed() { return !!closedError; },
|
|
117
|
+
get stats() { return { active: active.size, cancelled, waiting: waiters.size + retirementWaiters.size, closed: !!closedError }; },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Response deadlines stop when a response completes. While its cache work is
|
|
2
|
+
// still settling, probe the shared isolate's responsiveness instead of imposing
|
|
3
|
+
// another deadline on legitimate asynchronous work. Fully idle isolates have
|
|
4
|
+
// no watchdog timer.
|
|
5
|
+
export function createRscWorkWatchdog({ probe, retire, intervalMs = 5000, timeoutMs = 30000 } = {}) {
|
|
6
|
+
if (typeof probe !== 'function' || typeof retire !== 'function') throw new TypeError('probe and retire must be functions');
|
|
7
|
+
for (const [name, value] of Object.entries({ intervalMs, timeoutMs })) {
|
|
8
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive safe integer`);
|
|
9
|
+
}
|
|
10
|
+
let work = 0, timer, expected, nextToken = 0, closed = false;
|
|
11
|
+
|
|
12
|
+
function clear() {
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
timer = undefined;
|
|
15
|
+
expected = undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function close() {
|
|
19
|
+
if (closed) return;
|
|
20
|
+
closed = true;
|
|
21
|
+
work = 0;
|
|
22
|
+
clear();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fail(token, error) {
|
|
26
|
+
if (closed || !work || expected !== token) return;
|
|
27
|
+
close();
|
|
28
|
+
// Close first so concurrent timeout/probe failures cannot retire twice.
|
|
29
|
+
void Promise.resolve().then(() => retire(error)).catch(failure => {
|
|
30
|
+
console.error('[prnext] RSC work watchdog retirement failed:', failure);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function schedule() {
|
|
35
|
+
if (closed || !work || timer !== undefined) return;
|
|
36
|
+
timer = setTimeout(() => {
|
|
37
|
+
timer = undefined;
|
|
38
|
+
if (closed || !work) return;
|
|
39
|
+
const token = ++nextToken;
|
|
40
|
+
expected = token;
|
|
41
|
+
// The default exceeds the existing foreground render deadline: a
|
|
42
|
+
// synchronous foreground render must not lose its established budget.
|
|
43
|
+
timer = setTimeout(() => fail(token, new Error(`App Router worker did not acknowledge its background-work probe within ${timeoutMs} ms`)), timeoutMs);
|
|
44
|
+
timer.unref();
|
|
45
|
+
try {
|
|
46
|
+
Promise.resolve(probe(token)).catch(error => fail(token, error));
|
|
47
|
+
} catch (error) { fail(token, error); }
|
|
48
|
+
}, intervalMs);
|
|
49
|
+
timer.unref();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function add() {
|
|
53
|
+
if (closed) return () => {};
|
|
54
|
+
work++;
|
|
55
|
+
schedule();
|
|
56
|
+
let released = false;
|
|
57
|
+
return () => {
|
|
58
|
+
if (released) return;
|
|
59
|
+
released = true;
|
|
60
|
+
if (closed) return;
|
|
61
|
+
if (--work === 0) clear();
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ack(token) {
|
|
66
|
+
if (closed || !work || expected === undefined || token !== expected) return;
|
|
67
|
+
clear();
|
|
68
|
+
schedule();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
add, ack, close,
|
|
73
|
+
get stats() { return { work, scheduled: timer !== undefined, awaitingAck: expected !== undefined, closed }; },
|
|
74
|
+
};
|
|
75
|
+
}
|