@thomas.f/prnext 0.1.1-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 +256 -18
- 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/compat/index.d.ts
CHANGED
|
@@ -112,6 +112,10 @@ export interface Rewrite extends CustomRoute { destination: string }
|
|
|
112
112
|
export type ConfigRedirect = CustomRoute & Redirect;
|
|
113
113
|
/** Implemented project options. Unsupported behavior-changing options fail the build. */
|
|
114
114
|
export interface NextConfig {
|
|
115
|
+
/** PRNext only. CLI > environment > build configuration > profile.
|
|
116
|
+
* 0 disables the corresponding retirement; keepWarm retains one lane per used host.
|
|
117
|
+
*/
|
|
118
|
+
workerRetirement?: { idleSeconds?: number; laneIdleSeconds?: number; keepWarm?: boolean };
|
|
115
119
|
/** Next dev page-retention hints; PRNext builds all routes without that eviction queue. */
|
|
116
120
|
onDemandEntries?: { maxInactiveAge?: number; pagesBufferLength?: number };
|
|
117
121
|
i18n?: { locales: string[]; defaultLocale: string; localeDetection?: boolean; domains?: Array<{domain: string; defaultLocale: string; locales?: string[]; http?: boolean}> };
|
|
@@ -120,6 +124,12 @@ export interface NextConfig {
|
|
|
120
124
|
images?: import('./image.cjs').ImageConfig;
|
|
121
125
|
pageExtensions?: string[];
|
|
122
126
|
reactStrictMode?: boolean;
|
|
127
|
+
/** Enabled React Compiler transforms are not implemented. */
|
|
128
|
+
reactCompiler?: false;
|
|
129
|
+
/** Typed route generation and validation are not implemented. */
|
|
130
|
+
typedRoutes?: false;
|
|
131
|
+
/** Production checks use the project's installed TypeScript without emitting files. */
|
|
132
|
+
typescript?: { ignoreBuildErrors?: boolean; tsconfigPath?: string };
|
|
123
133
|
cacheComponents?: boolean;
|
|
124
134
|
cacheLife?: Record<string, import('./cache.cjs').CacheLife>;
|
|
125
135
|
transpilePackages?: string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thomas.f/prnext",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12-alpha",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Experimental Rust-powered runtime for Next.js apps, React and npm",
|
|
6
6
|
"homepage": "https://prnext.dev",
|
|
@@ -17,14 +17,30 @@
|
|
|
17
17
|
"name": "Thomas",
|
|
18
18
|
"url": "https://github.com/Lorkydey"
|
|
19
19
|
},
|
|
20
|
-
"keywords": [
|
|
20
|
+
"keywords": [
|
|
21
|
+
"prnext",
|
|
22
|
+
"nextjs",
|
|
23
|
+
"react",
|
|
24
|
+
"rust",
|
|
25
|
+
"framework",
|
|
26
|
+
"ssr",
|
|
27
|
+
"server-components"
|
|
28
|
+
],
|
|
21
29
|
"type": "module",
|
|
22
30
|
"publishConfig": {
|
|
23
31
|
"access": "public",
|
|
24
32
|
"tag": "alpha",
|
|
25
33
|
"registry": "https://registry.npmjs.org/"
|
|
26
34
|
},
|
|
27
|
-
"files": [
|
|
35
|
+
"files": [
|
|
36
|
+
"cli.mjs",
|
|
37
|
+
"index.mjs",
|
|
38
|
+
"build/",
|
|
39
|
+
"runtime/",
|
|
40
|
+
"compat/",
|
|
41
|
+
"native/",
|
|
42
|
+
"release/"
|
|
43
|
+
],
|
|
28
44
|
"scripts": {
|
|
29
45
|
"prepublishOnly": "node release/verify.mjs"
|
|
30
46
|
},
|
|
@@ -117,12 +133,12 @@
|
|
|
117
133
|
"react-server-dom-webpack": "19.3.0"
|
|
118
134
|
},
|
|
119
135
|
"optionalDependencies": {
|
|
120
|
-
"prnext-darwin-arm64": "0.1.
|
|
121
|
-
"prnext-darwin-x64": "0.1.
|
|
122
|
-
"prnext-linux-arm64-gnu": "0.1.
|
|
123
|
-
"prnext-linux-x64-gnu": "0.1.
|
|
124
|
-
"@thomas.f/prnext-win32-
|
|
125
|
-
"@thomas.f/prnext-win32-
|
|
136
|
+
"prnext-darwin-arm64": "0.1.12-alpha",
|
|
137
|
+
"prnext-darwin-x64": "0.1.12-alpha",
|
|
138
|
+
"prnext-linux-arm64-gnu": "0.1.12-alpha",
|
|
139
|
+
"prnext-linux-x64-gnu": "0.1.12-alpha",
|
|
140
|
+
"@thomas.f/prnext-win32-x64-msvc": "0.1.12-alpha",
|
|
141
|
+
"@thomas.f/prnext-win32-arm64-msvc": "0.1.12-alpha"
|
|
126
142
|
},
|
|
127
143
|
"engines": {
|
|
128
144
|
"node": ">=22"
|
package/release/verify.mjs
CHANGED
|
@@ -26,6 +26,15 @@ export async function verifyPackage() {
|
|
|
26
26
|
if (source) {
|
|
27
27
|
const cargo = await readFile(path.join(source, 'crates/prnext/Cargo.toml'), 'utf8');
|
|
28
28
|
if (!cargo.includes(`version = "${pkg.version}"`)) throw new Error('Cargo and npm versions differ. Update crates/prnext/Cargo.toml and Cargo.lock before building release binaries.');
|
|
29
|
+
const top = JSON.parse(await readFile(path.join(source, 'package.json'), 'utf8'));
|
|
30
|
+
const lock = JSON.parse(await readFile(path.join(source, 'package-lock.json'), 'utf8'));
|
|
31
|
+
const cargoLock = (await readFile(path.join(source, 'Cargo.lock'), 'utf8')).replaceAll('\r\n', '\n');
|
|
32
|
+
if ([top.version, lock.version, lock.packages[''].version, lock.packages['packages/prnext'].version].some(version => version !== pkg.version) ||
|
|
33
|
+
!cargoLock.includes(`name = "prnext"\nversion = "${pkg.version}"`)) throw new Error('Workspace and lockfile versions differ; run scripts/release-version.mjs before building.');
|
|
34
|
+
for (const target of platforms) {
|
|
35
|
+
const native = lock.packages[`node_modules/${target.package}`];
|
|
36
|
+
if (native?.version !== pkg.version || native.optional !== true || lock.packages['packages/prnext'].optionalDependencies[target.package] !== pkg.version) throw new Error(`Missing or stale native lock entry: ${target.package}`);
|
|
37
|
+
}
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
40
|
|
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
|
}
|