@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
@@ -0,0 +1,60 @@
1
+ import { lstat, open } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { parse } from 'dotenv';
5
+ import { expand } from 'dotenv-expand';
6
+
7
+ export const productionEnvFiles = ['.env.production.local', '.env.local', '.env.production', '.env'];
8
+ export async function detectEnvFiles(directory) {
9
+ const files = [];
10
+ for (const name of productionEnvFiles) {
11
+ try { await lstat(path.join(directory, name)); files.push(name); }
12
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
13
+ }
14
+ return files;
15
+ }
16
+ export async function readDeploymentEnv(directory, files) {
17
+ const values = Object.create(null), combined = Object.create(null);
18
+ let bytes = 0;
19
+ for (const name of productionEnvFiles.filter(name => files.includes(name))) {
20
+ const filename = path.join(directory, name), before = await lstat(filename);
21
+ if (!before.isFile() || before.isSymbolicLink() || before.size > 65536 || (bytes += before.size) > 65536)
22
+ throw new Error('Environment files must be regular files, up to 64 KiB in total.');
23
+ const handle = await open(filename, 'r');
24
+ let contents;
25
+ try {
26
+ const info = await handle.stat();
27
+ if (info.ino !== before.ino || info.dev !== before.dev || info.size !== before.size || (await lstat(filename)).isSymbolicLink())
28
+ throw new Error('Environment file changed while reading. Try again.');
29
+ const data = Buffer.alloc(info.size);
30
+ let offset = 0;
31
+ while (offset < data.length) {
32
+ const {bytesRead} = await handle.read(data,offset,data.length-offset,offset);
33
+ if (!bytesRead) throw new Error('Environment file changed while reading. Try again.');
34
+ offset += bytesRead;
35
+ }
36
+ contents = data.toString('utf8');
37
+ const after = await handle.stat();
38
+ if (after.size !== info.size || after.mtimeMs !== info.mtimeMs) throw new Error('Environment file changed while reading. Try again.');
39
+ } finally { await handle.close(); }
40
+ let parsed;
41
+ // No shell environment: never import machine credentials through expansion.
42
+ try { parsed = expand({ parsed: parse(contents), processEnv: combined }).parsed; }
43
+ catch { throw new Error('Could not expand environment variables. Check for circular references.'); }
44
+ for (const [key, value] of Object.entries(parsed)) if (!Object.hasOwn(values, key)) values[key] = value;
45
+ }
46
+ if (Object.keys(values).length > 64 || Buffer.byteLength(JSON.stringify({ variables: values })) > 60000)
47
+ throw new Error('Environment import exceeds 64 variables or 60 KiB.');
48
+ for (const [key, value] of Object.entries(values)) {
49
+ if (!/^[A-Z_][A-Z0-9_]{0,95}$/.test(key) || /^(CLOUD_|DOCKER_|PRNEXT_CACHE_|PRNEXT_BINARY$|PATH$|HOME$|NODE_OPTIONS$|NODE_PATH$|NODE_ENV$|PORT$|HOSTNAME$)/.test(key))
50
+ throw new Error(`Environment variable ${key.replace(/[^A-Za-z0-9_]/g, '')} is reserved or invalid. Remove it before importing.`);
51
+ if (value.length > 8192 || value.includes('\0')) throw new Error('An environment value exceeds 8192 characters or contains a null byte.');
52
+ }
53
+ return values;
54
+ }
55
+ export async function confirmEnvImport(message, { signal } = {}) {
56
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
57
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
58
+ try { return /^(y|yes|o|oui)$/i.test((await prompt.question(message + ' [y/N] ', { signal })).trim()); }
59
+ finally { prompt.close(); }
60
+ }
@@ -0,0 +1,141 @@
1
+ import { mkdir, lstat, readFile, writeFile, rm, chmod } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ import { rename } from '../fs.mjs';
6
+
7
+ export function cloudOrigin(value = 'https://app.prnext.dev') {
8
+ let url;
9
+ try {
10
+ url = new URL(value);
11
+ } catch {
12
+ throw new Error(
13
+ 'Cloud server must be an HTTPS origin or a loopback HTTP origin.'
14
+ );
15
+ }
16
+ if (
17
+ url.username ||
18
+ url.password ||
19
+ url.pathname !== '/' ||
20
+ url.search ||
21
+ url.hash ||
22
+ !(
23
+ url.protocol === 'https:' ||
24
+ (url.protocol === 'http:' &&
25
+ ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
26
+ )
27
+ )
28
+ throw new Error(
29
+ 'Cloud server must be an HTTPS origin or a loopback HTTP origin.'
30
+ );
31
+ return url.origin;
32
+ }
33
+ export async function readState(filename) {
34
+ try {
35
+ const info = await lstat(filename);
36
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 65536)
37
+ throw new Error('Invalid PRNext Cloud state file.');
38
+ const value = JSON.parse(await readFile(filename, 'utf8'));
39
+ if (!value || Array.isArray(value) || typeof value !== 'object')
40
+ throw new Error('Invalid PRNext Cloud state file.');
41
+ return value;
42
+ } catch (error) {
43
+ if (error.code === 'ENOENT') return null;
44
+ throw error;
45
+ }
46
+ }
47
+ export async function writeState(filename, data) {
48
+ const directory = path.dirname(filename);
49
+ await mkdir(directory, { recursive: true, mode: 0o700 });
50
+ const info = await lstat(directory);
51
+ if (info.isSymbolicLink() || !info.isDirectory())
52
+ throw new Error('PRNext Cloud state directory cannot be a link.');
53
+ await readState(filename);
54
+ if (process.platform !== 'win32') await chmod(directory, 0o700);
55
+ const temporary = `${filename}.${randomBytes(8).toString('hex')}.tmp`;
56
+ try {
57
+ await writeFile(temporary, JSON.stringify(data, null, 2) + '\n', {
58
+ flag: 'wx',
59
+ mode: 0o600
60
+ });
61
+ await rename(temporary, filename);
62
+ } finally {
63
+ await rm(temporary, { force: true });
64
+ }
65
+ }
66
+ export function credentialPath(origin, env = process.env) {
67
+ const directory = path.resolve(
68
+ env.PRNEXT_CLOUD_HOME || path.join(homedir(), '.prnext', 'cloud')
69
+ );
70
+ return path.join(
71
+ directory,
72
+ createHash('sha256').update(cloudOrigin(origin)).digest('hex') + '.json'
73
+ );
74
+ }
75
+ export const linkPath = (directory) =>
76
+ path.join(directory, '.prnext-cloud', 'project.json');
77
+ export async function withStateLock(filename, task) {
78
+ await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
79
+ if ((await lstat(path.dirname(filename))).isSymbolicLink())
80
+ throw new Error('Cloud state directory cannot be a link.');
81
+ const marker = { pid: process.pid, nonce: randomBytes(16).toString('hex') };
82
+ for (let attempt = 0; ; attempt++) {
83
+ try {
84
+ await writeFile(filename, JSON.stringify(marker), {
85
+ flag: 'wx',
86
+ mode: 0o600
87
+ });
88
+ break;
89
+ } catch (error) {
90
+ if (error.code !== 'EEXIST') throw error;
91
+ const previous = await readState(filename);
92
+ let active = true;
93
+ if (Number.isSafeInteger(previous?.pid) && previous.pid > 0) {
94
+ try {
95
+ process.kill(previous.pid, 0);
96
+ } catch (failure) {
97
+ if (failure.code === 'ESRCH') active = false;
98
+ }
99
+ }
100
+ if (active || attempt > 0)
101
+ throw new Error(
102
+ 'Another PRNext Cloud command is using this project. Wait for it to finish.'
103
+ );
104
+ if ((await readState(filename))?.nonce === previous.nonce)
105
+ await rm(filename);
106
+ }
107
+ }
108
+ try {
109
+ return await task();
110
+ } finally {
111
+ if ((await readState(filename))?.nonce === marker.nonce) await rm(filename);
112
+ }
113
+ }
114
+ export async function saveLink(directory, link) {
115
+ await writeState(linkPath(directory), link);
116
+ const gitignore = path.join(directory, '.gitignore');
117
+ let contents = '';
118
+ try {
119
+ const stat = await lstat(gitignore);
120
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65536)
121
+ throw new Error(
122
+ 'Cannot safely update .gitignore; ignore .prnext-cloud/ manually.'
123
+ );
124
+ contents = await readFile(gitignore, 'utf8');
125
+ } catch (error) {
126
+ if (error.code !== 'ENOENT') throw error;
127
+ }
128
+ if (
129
+ !contents
130
+ .split(/\r?\n/)
131
+ .some((line) =>
132
+ ['.prnext-cloud/', '/.prnext-cloud/', '.prnext-cloud'].includes(line)
133
+ )
134
+ )
135
+ await writeFile(
136
+ gitignore,
137
+ contents +
138
+ (contents.endsWith('\n') || !contents ? '' : '\n') +
139
+ '.prnext-cloud/\n'
140
+ );
141
+ }
@@ -0,0 +1,48 @@
1
+ import { open } from 'node:fs/promises';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+
5
+ // The checkpoint contains identifiers/checksums only, never source or env contents.
6
+ export async function uploadSource(api, link, archive, {save, signal, log = () => {}}) {
7
+ const base = `/projects/${encodeURIComponent(link.projectId)}/uploads`;
8
+ const retry = async fn => {
9
+ for (let attempt=0;;attempt++) {
10
+ signal?.throwIfAborted();
11
+ try { return await fn(); }
12
+ catch (error) {
13
+ if (signal?.aborted || attempt >= 3 || error.status && ![408,429,500,502,503,504].includes(error.status) && error.code !== 'upload_busy') throw error;
14
+ await delay(500 * 2**attempt,undefined,{signal});
15
+ }
16
+ }
17
+ };
18
+ if (link.upload && link.upload.digest !== archive.digest) {
19
+ await api(`${base}/${link.upload.id}`,{method:'DELETE'}).catch(error => {if (![404,410].includes(error.status)) throw error;});
20
+ delete link.upload;
21
+ }
22
+ link.upload ||= {id:randomUUID(),digest:archive.digest};
23
+ await save(link);
24
+ let session;
25
+ try {
26
+ session = await retry(() => api(base,{method:'POST',body:{requestId:link.upload.id,bytes:archive.size,sourceBytes:archive.expandedBytes,digest:archive.digest}}));
27
+ } catch(error) {
28
+ if ([404,410].includes(error.status)) {delete link.upload;await save(link);}
29
+ throw error;
30
+ }
31
+ if (session.result) return session.result;
32
+ if (!Number.isSafeInteger(session.chunkBytes) || session.chunkBytes < 1 || session.chunkBytes > 4*1048576) throw new Error('Unsupported server upload chunk size.');
33
+ const parts = new Map(session.parts.map(p => [p.part,p.digest]));
34
+ const handle = await open(archive.path,'r');
35
+ try {
36
+ for (let offset=0,part=0;offset<archive.size;offset+=session.chunkBytes,part++) {
37
+ signal?.throwIfAborted();
38
+ const body = Buffer.alloc(Math.min(session.chunkBytes,archive.size-offset));
39
+ let read=0;
40
+ while (read<body.length) {const result=await handle.read(body,read,body.length-read,offset+read);if(!result.bytesRead)throw new Error('Temporary archive changed. Run the command again.');read+=result.bytesRead;}
41
+ const digest = createHash('sha256').update(body).digest('hex');
42
+ if (parts.has(part) && parts.get(part) !== digest) throw new Error('Resumed upload checksum differs. Cancel this upload and try again.');
43
+ if (!parts.has(part)) await retry(() => api(`${base}/${link.upload.id}/chunks/${part}`,{method:'PUT',body}));
44
+ log(`Uploading sources: ${Math.round(100*(offset+body.length)/archive.size)}%`);
45
+ }
46
+ } finally { await handle.close(); }
47
+ return retry(() => api(`${base}/${link.upload.id}/complete`,{method:'POST',body:{}}));
48
+ }
@@ -0,0 +1,136 @@
1
+ import { MAX_RESPONSE_BYTES } from './http.mjs';
2
+
3
+ // A raw element cannot be interrupted with a Flight script. Keep its chunks
4
+ // until its closing tag arrives, but only scan newly received characters.
5
+ // Joining and rescanning a growing inline script on every chunk is quadratic.
6
+ const whitespace = /\s/;
7
+ function holdRaw(state, text) {
8
+ if (!text) return;
9
+ // Compact small chunks in tiers: even single-character input cannot retain
10
+ // millions of array entries. Each tier joins once per 64 chunks, rather
11
+ // than repeatedly copying the entire accumulated element.
12
+ for (let level = 0;; level++) {
13
+ const parts = state.parts[level] ??= [];
14
+ parts.push(text);
15
+ if (parts.length < 64) return;
16
+ text = parts.join('');
17
+ parts.length = 0;
18
+ }
19
+ }
20
+ function releaseRaw(state) {
21
+ const parts = [];
22
+ for (let level = state.parts.length - 1; level >= 0; level--) parts.push(...state.parts[level]);
23
+ return parts.join('');
24
+ }
25
+ function rawEnd(text, state, from = 0) {
26
+ let index = from;
27
+ while (index < text.length) {
28
+ if (!state.matched) {
29
+ const start = text.indexOf('<', index);
30
+ if (start < 0) return -1;
31
+ state.matched = 1;
32
+ index = start + 1;
33
+ } else if (state.matched < state.prefix.length) {
34
+ const code = text.charCodeAt(index);
35
+ if (state.matched === 1 ? code === 47 : (code | 32) === state.prefix.charCodeAt(state.matched)) {
36
+ state.matched++;
37
+ index++;
38
+ } else state.matched = 0;
39
+ } else if (text[index] === '>') {
40
+ return index + 1;
41
+ } else if (whitespace.test(text[index])) {
42
+ index++;
43
+ } else state.matched = 0;
44
+ }
45
+ return -1;
46
+ }
47
+
48
+ // Do not insert Flight/bootstrap scripts inside a split tag or raw-text element.
49
+ // Scan by index: ordinary tags stay in one contiguous slice, instead of creating
50
+ // a new tail and output string for every cell of a rendered table.
51
+ export function htmlEnvelope({ resumed = false } = {}) {
52
+ let pending = '';
53
+ let closing = '';
54
+ let received = 0;
55
+ let rawPending;
56
+ const rawStart = /<(script|style|textarea|title|svg|math)(?:\s|>)/iy;
57
+ const rawEnds = new Map();
58
+ const documentEnd = /<\/(body|html)\s*>/iy;
59
+ return {
60
+ append(text) {
61
+ received += Buffer.byteLength(text);
62
+ if (received > MAX_RESPONSE_BYTES) throw new Error('HTML response exceeds the 16 MiB PRNext limit');
63
+ let ready = '';
64
+ if (rawPending) {
65
+ const end = rawEnd(text, rawPending);
66
+ if (end < 0) { holdRaw(rawPending, text); return ''; }
67
+ holdRaw(rawPending, text.slice(0, end));
68
+ ready = releaseRaw(rawPending);
69
+ rawPending = undefined;
70
+ text = text.slice(end);
71
+ }
72
+ pending += text;
73
+ let cursor = 0, output = 0;
74
+ while (cursor < pending.length) {
75
+ const start = pending.indexOf('<', cursor);
76
+ if (start < 0) { cursor = pending.length; break; }
77
+ cursor = start;
78
+ let end = -1;
79
+ if (pending.startsWith('<!--', start)) {
80
+ const comment = pending.indexOf('-->', start);
81
+ if (comment >= 0) end = comment + 2;
82
+ } else {
83
+ for (let index = start + 1; index < pending.length; index++) {
84
+ const char = pending[index];
85
+ if (char === '"' || char === "'") {
86
+ const close = pending.indexOf(char, index + 1);
87
+ if (close < 0) break;
88
+ index = close;
89
+ } else if (char === '>') { end = index; break; }
90
+ }
91
+ }
92
+ if (end < 0) break;
93
+ // Most tags need no special handling. Reject their ASCII prefixes
94
+ // before running the raw-element and document-closing expressions;
95
+ // the expressions still validate every candidate in full.
96
+ const first = pending.charCodeAt(start + 1) | 32;
97
+ const second = pending.charCodeAt(start + 2) | 32;
98
+ const rawCandidate = first === 115 && (second === 99 || second === 116 || second === 118) ||
99
+ first === 116 && (second === 101 || second === 105) || first === 109 && second === 97;
100
+ rawStart.lastIndex = start;
101
+ const raw = rawCandidate && rawStart.exec(pending);
102
+ if (raw && pending[end - 1] !== '/') {
103
+ const name = raw[1].toLowerCase();
104
+ let pattern = rawEnds.get(name);
105
+ if (!pattern) { pattern = new RegExp(`</${name}\\s*>`, 'ig'); rawEnds.set(name, pattern); }
106
+ pattern.lastIndex = end + 1;
107
+ const match = pattern.exec(pending);
108
+ if (!match) {
109
+ rawPending = { prefix: `</${name}`, matched: 0, parts: [] };
110
+ holdRaw(rawPending, pending.slice(start));
111
+ rawEnd(pending, rawPending, end + 1);
112
+ ready += pending.slice(output, start);
113
+ pending = '';
114
+ return ready;
115
+ }
116
+ cursor = pattern.lastIndex;
117
+ } else {
118
+ documentEnd.lastIndex = start;
119
+ if (first === 47 && (second === 98 || second === 104) && documentEnd.test(pending) && documentEnd.lastIndex === end + 1) {
120
+ ready += pending.slice(output, start);
121
+ closing += pending.slice(start, end + 1);
122
+ output = end + 1;
123
+ }
124
+ cursor = end + 1;
125
+ }
126
+ }
127
+ ready += pending.slice(output, cursor);
128
+ pending = pending.slice(cursor);
129
+ return ready;
130
+ },
131
+ finish() {
132
+ if (pending || rawPending || !(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');
133
+ return resumed ? '</body></html>' : closing;
134
+ },
135
+ };
136
+ }
package/runtime/http.mjs CHANGED
@@ -1,4 +1,7 @@
1
- import { IncomingMessage, validateHeaderName, validateHeaderValue } from 'node:http';
1
+ import { createRequire } from 'node:module';
2
+ // ESM node:http evaluates lazy WebSocket exports and initializes Undici.
3
+ // Use the module object to avoid that startup cost while preserving HTTP patches.
4
+ const http = createRequire(import.meta.url)('node:http');
2
5
  import { Duplex, Writable } from 'node:stream';
3
6
  import { revalidatePage } from './pages-revalidate.mjs';
4
7
  import { currentRequest } from '../compat/headers.cjs';
@@ -45,7 +48,7 @@ export function createRequest({ url, originalUrl, method = 'GET', headers = {},
45
48
  const visible = originalUrl ? new URL(originalUrl, parsed) : parsed;
46
49
  const socket = new Duplex({ read() {}, write(_chunk, _encoding, callback) { callback(); } });
47
50
  socket.remoteAddress = '127.0.0.1';
48
- const request = new IncomingMessage(socket);
51
+ const request = new http.IncomingMessage(socket);
49
52
  request.method = method;
50
53
  request.url = `${visible.pathname}${visible.search}`;
51
54
  request.headers = Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
@@ -85,9 +88,9 @@ export class CapturedResponse extends Writable {
85
88
  get finished() { return this.writableEnded; }
86
89
  setHeader(name, value) {
87
90
  if (this.headersSent) throw new Error('Cannot set headers after they are sent');
88
- validateHeaderName(name);
91
+ http.validateHeaderName(name);
89
92
  const normalized = Array.isArray(value) ? value.map(String) : String(value);
90
- for (const item of Array.isArray(normalized) ? normalized : [normalized]) validateHeaderValue(name, item);
93
+ for (const item of Array.isArray(normalized) ? normalized : [normalized]) http.validateHeaderValue(name, item);
91
94
  this._headers[name.toLowerCase()] = normalized;
92
95
  return this;
93
96
  }
@@ -184,7 +187,7 @@ export function plainResponse(status, message) {
184
187
 
185
188
  export function errorResponse(error, production = process.env.NODE_ENV === 'production') {
186
189
  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';
190
+ const status = [400, 503, 504].includes(error?.statusCode) ? error.statusCode : 500;
191
+ const message = status === 400 ? 'Bad Request' : status === 503 ? 'Service Unavailable' : status === 504 ? 'Gateway Timeout' : 'Internal Server Error';
189
192
  return plainResponse(status, production || status === 400 ? message : String(error?.stack || error));
190
193
  }
@@ -12,7 +12,7 @@ installFetchCache();
12
12
 
13
13
  const FLIGHT_HEADERS = ['rsc', 'next-router-state-tree', 'x-prnext-router-state', 'next-router-prefetch', 'next-hmr-refresh', 'next-router-segment-prefetch'];
14
14
  const HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'content-length']);
15
- const MAX_BODY = 8 * 1024 * 1024;
15
+ const MAX_BODY = 32 * 1024 * 1024;
16
16
  const MAX_SCOPES = 32;
17
17
  const MAX_PROMISES = 128;
18
18
  const scopes = new Set();
@@ -133,9 +133,9 @@ export async function runMiddleware(options) {
133
133
  if (typeof handler !== 'function') throw new TypeError(`Middleware must export a ${exportName} function`);
134
134
  const init = { method: options.method || 'GET', headers, signal: controller.signal };
135
135
  if (!['GET', 'HEAD'].includes(init.method)) {
136
- if (typeof options.body === 'string' && options.body.length > Math.ceil(MAX_BODY / 3) * 4) throw Object.assign(new Error('Middleware request exceeds the 8 MiB PRNext limit'), { statusCode: 413 });
136
+ if (typeof options.body === 'string' && options.body.length > Math.ceil(MAX_BODY / 3) * 4) throw Object.assign(new Error('Middleware request exceeds the 32 MiB PRNext limit'), { statusCode: 413 });
137
137
  const body = Buffer.isBuffer(options.body) ? options.body : Buffer.from(options.body || '', 'base64');
138
- if (body.byteLength > MAX_BODY) throw Object.assign(new Error('Middleware request exceeds the 8 MiB PRNext limit'), { statusCode: 413 });
138
+ if (body.byteLength > MAX_BODY) throw Object.assign(new Error('Middleware request exceeds the 32 MiB PRNext limit'), { statusCode: 413 });
139
139
  if (body.byteLength) init.body = body;
140
140
  }
141
141
  const request = new NextRequest(exposedUrl, { ...init, nextConfig: options.manifest?.config });
@@ -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": true,
22
- "semiSpaceMiB": 8,
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": 16,
47
+ "liveResponses": 28,
44
48
  "apiConcurrency": 64,
45
- "responseBufferMiB": 1
49
+ "responseBufferMiB": 1,
50
+ "renderIdleSeconds": 30
46
51
  }
47
52
  }
@@ -1,5 +1,5 @@
1
1
  const MAX_HEADER = 256 * 1024;
2
- const MAX_BODY = 8 * 1024 * 1024;
2
+ const MAX_BODY = 32 * 1024 * 1024;
3
3
  const EMPTY_BODY = Buffer.alloc(0);
4
4
 
5
5
  // Awaiting the consumer preserves socket backpressure. At most one body is
@@ -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
  }
@@ -30,7 +30,7 @@ function canonicalOptions(options) {
30
30
 
31
31
  function deadline(options, phase) {
32
32
  const controller = new AbortController();
33
- const timeoutMs = options.timeoutMs ?? 25_000;
33
+ const timeoutMs = options.timeoutMs ?? (options.route?.cacheConfig?.maxDuration ? options.route.cacheConfig.maxDuration * 1000 : 25_000);
34
34
  // Keep the timer alive while awaiting an otherwise handle-free import or
35
35
  // generator so Node reports the timeout rather than exiting on pending TLA.
36
36
  const timer = setTimeout(() => controller.abort(apiTimeoutError(phase, timeoutMs)), timeoutMs);
@@ -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
+ }