@thomas.f/prnext 0.1.12-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 (50) hide show
  1. package/README.md +262 -33
  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/compiler.mjs +3 -1
  7. package/build/config.mjs +9 -2
  8. package/build/font-cache.mjs +50 -0
  9. package/build/font-files.mjs +5 -3
  10. package/build/fonts.mjs +1 -1
  11. package/build/index.mjs +5 -0
  12. package/build/metadata-conflicts.mjs +13 -0
  13. package/build/metadata-images.mjs +3 -3
  14. package/build/metadata-routes.mjs +3 -3
  15. package/build/native-packages.mjs +5 -1
  16. package/build/npm-packages.mjs +2 -1
  17. package/build/optimize-imports.mjs +112 -0
  18. package/build/proxy-body-config.mjs +10 -0
  19. package/build/scan.mjs +9 -7
  20. package/cli.mjs +16 -0
  21. package/compat/after.cjs +51 -0
  22. package/compat/index.d.ts +3 -1
  23. package/compat/server.cjs +2 -1
  24. package/compat/server.d.cts +2 -0
  25. package/native/launch.mjs +19 -0
  26. package/package.json +7 -7
  27. package/runtime/api.mjs +15 -5
  28. package/runtime/app-render.mjs +78 -33
  29. package/runtime/app-route-plan.mjs +45 -0
  30. package/runtime/app-routing-state.mjs +101 -21
  31. package/runtime/app-routing.mjs +37 -18
  32. package/runtime/app-segments.mjs +25 -11
  33. package/runtime/app-static.mjs +1 -1
  34. package/runtime/app-stream-html.mjs +2 -1
  35. package/runtime/cache-request.mjs +10 -2
  36. package/runtime/cloud/archive.mjs +316 -0
  37. package/runtime/cloud/client.mjs +658 -0
  38. package/runtime/cloud/environment.mjs +60 -0
  39. package/runtime/cloud/state.mjs +141 -0
  40. package/runtime/cloud/upload.mjs +48 -0
  41. package/runtime/html-envelope.mjs +70 -7
  42. package/runtime/http.mjs +7 -4
  43. package/runtime/middleware.mjs +3 -3
  44. package/runtime/request-transport.mjs +1 -1
  45. package/runtime/route-static.mjs +1 -1
  46. package/runtime/rsc-worker.mjs +27 -4
  47. package/runtime/stream-utils.mjs +22 -3
  48. package/runtime/transport.mjs +16 -0
  49. package/runtime/worker-metadata.mjs +23 -0
  50. package/runtime/worker.mjs +36 -9
@@ -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
+ }
@@ -1,5 +1,50 @@
1
1
  import { MAX_RESPONSE_BYTES } from './http.mjs';
2
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
+
3
48
  // Do not insert Flight/bootstrap scripts inside a split tag or raw-text element.
4
49
  // Scan by index: ordinary tags stay in one contiguous slice, instead of creating
5
50
  // a new tail and output string for every cell of a rendered table.
@@ -7,6 +52,7 @@ export function htmlEnvelope({ resumed = false } = {}) {
7
52
  let pending = '';
8
53
  let closing = '';
9
54
  let received = 0;
55
+ let rawPending;
10
56
  const rawStart = /<(script|style|textarea|title|svg|math)(?:\s|>)/iy;
11
57
  const rawEnds = new Map();
12
58
  const documentEnd = /<\/(body|html)\s*>/iy;
@@ -14,8 +60,17 @@ export function htmlEnvelope({ resumed = false } = {}) {
14
60
  append(text) {
15
61
  received += Buffer.byteLength(text);
16
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
+ }
17
72
  pending += text;
18
- let cursor = 0, output = 0, ready = '';
73
+ let cursor = 0, output = 0;
19
74
  while (cursor < pending.length) {
20
75
  const start = pending.indexOf('<', cursor);
21
76
  if (start < 0) { cursor = pending.length; break; }
@@ -25,12 +80,13 @@ export function htmlEnvelope({ resumed = false } = {}) {
25
80
  const comment = pending.indexOf('-->', start);
26
81
  if (comment >= 0) end = comment + 2;
27
82
  } else {
28
- let quote;
29
83
  for (let index = start + 1; index < pending.length; index++) {
30
84
  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; }
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; }
34
90
  }
35
91
  }
36
92
  if (end < 0) break;
@@ -49,7 +105,14 @@ export function htmlEnvelope({ resumed = false } = {}) {
49
105
  if (!pattern) { pattern = new RegExp(`</${name}\\s*>`, 'ig'); rawEnds.set(name, pattern); }
50
106
  pattern.lastIndex = end + 1;
51
107
  const match = pattern.exec(pending);
52
- if (!match) break;
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
+ }
53
116
  cursor = pattern.lastIndex;
54
117
  } else {
55
118
  documentEnd.lastIndex = start;
@@ -66,7 +129,7 @@ export function htmlEnvelope({ resumed = false } = {}) {
66
129
  return ready;
67
130
  },
68
131
  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');
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');
70
133
  return resumed ? '</body></html>' : closing;
71
134
  },
72
135
  };
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
  }
@@ -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 });
@@ -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
@@ -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);
@@ -24,6 +24,7 @@ import { installFetchCache } from './fetch-cache.mjs';
24
24
  import { flightConsumer } from './cache-components-rsc.mjs';
25
25
  import { partialModel, isPartialHole, boundedPartialFlight } from './app-partial-model.mjs';
26
26
  import { PartialTemplates, canResumePartialDirectly, partialTemplateReuse } from './partial-template.mjs';
27
+ import { createAfterScope } from '../compat/after.cjs';
27
28
 
28
29
  installFetchCache();
29
30
  if (process.env.PRNEXT_INSPECT_DIR) parentPort.postMessage({type:'rsc-ready'});
@@ -75,7 +76,7 @@ function createTree(entry, request, fallback = null) {
75
76
  const pageKey = `${request.routePattern || new URL(request.url).pathname}:${JSON.stringify(request.params || {})}`;
76
77
  const keyScopes = [];
77
78
  const instantTrees = [];
78
- const instantDisabled = [entry.pageConfig, ...segments.map(segment => segment.staticConfig)].some(config => config?.instant && typeof config.instant === 'object' && (config.instant.unstable_disableValidation || (request.production ? config.instant.unstable_disableBuildValidation : config.instant.unstable_disableDevValidation)));
79
+ const instantDisabled = request.instantValidation && [entry.pageConfig, ...segments.map(segment => segment.staticConfig)].some(config => config?.instant && typeof config.instant === 'object' && (config.instant.unstable_disableValidation || (request.production ? config.instant.unstable_disableBuildValidation : config.instant.unstable_disableDevValidation)));
79
80
  function keyScope(prefix, values, liveProps) {
80
81
  const key = prefix + JSON.stringify(values);
81
82
  keyScopes.push({ key, prefix, names: Object.keys(values), ...(liveProps ? { liveProps } : {}) });
@@ -98,8 +99,9 @@ function createTree(entry, request, fallback = null) {
98
99
  scopes.set(segment, `${segment.path || ''}:${JSON.stringify(scopedParams.get(segment))}`);
99
100
  if (request.partialParams?.length) for (const prefix of ['', 'error:', 'navigation:', 'layout:']) keyScope(`${prefix}${segment.path || ''}:`, scopedParams.get(segment), prefix === 'layout:' ? ['segments'] : undefined);
100
101
  }
101
- let instantConfig = entry.pageConfig?.instant;
102
- for (const segment of [...(fallback ? segments.slice(0, fallback.index + 1) : segments)].reverse()) {
102
+ let instantConfig = request.instantValidation ? entry.pageConfig?.instant : undefined;
103
+ for (let index = fallback ? Math.min(fallback.index, segments.length - 1) : segments.length - 1; index >= 0; index--) {
104
+ const segment = segments[index];
103
105
  if (segment.loading) tree = React.createElement(React.Suspense, {
104
106
  fallback: React.createElement(component(segment.loading, 'loading')),
105
107
  }, tree);
@@ -126,7 +128,7 @@ function createTree(entry, request, fallback = null) {
126
128
  if (request.cacheComponents && segment === rootLayout) tree = React.createElement(React.Fragment, null, head, tree);
127
129
  tree = React.createElement(component(segment.layout, 'layout'), { params: staticParams(scopedParams.get(segment)), key: scopes.get(segment) }, tree);
128
130
  if (entry.LayoutProvider) tree = React.createElement(entry.LayoutProvider, { key: `layout:${scopes.get(segment)}`,
129
- segments: { children: selectedLayoutSegments(segments.slice(segments.indexOf(segment) + 1), request.params) } }, tree);
131
+ segments: { children: selectedLayoutSegments(segments, request.params, segments.indexOf(segment) + 1) } }, tree);
130
132
  }
131
133
  }
132
134
  // Keep module namespaces out of development owner props: they can contain
@@ -381,9 +383,11 @@ async function handleRequest(request, channel = null) {
381
383
 
382
384
  const streamChannels = new Map();
383
385
  const activeRequests = new Set();
386
+ const responseCompletions = new Map();
384
387
  const manifests = new Map();
385
388
  const artifacts = new Map();
386
389
  parentPort.on('message', async ({ id, request, type, value, token }) => {
390
+ if (type === 'after-response') { responseCompletions.get(id)?.(); return; }
387
391
  // Only the host arms these probes, while completed responses still have
388
392
  // tracked cache work. There is no timer or heartbeat in an idle RSC isolate.
389
393
  if (type === 'work-probe') { parentPort.postMessage({ type: 'work-responsive', token }); return; }
@@ -400,6 +404,17 @@ parentPort.on('message', async ({ id, request, type, value, token }) => {
400
404
  if (request[field + 'Id'] !== undefined) request[field] = manifests.get(request[field + 'Id']);
401
405
  }
402
406
  const channel = request.stream && (!request.action || request.action.id) ? workerStreamChannel(parentPort, id) : null;
407
+ let responseDone, responseTimer;
408
+ if (request.action && request.cacheConfig?.runtime !== 'edge') {
409
+ const timeoutMs = request.renderTimeoutMs ?? 25000;
410
+ request.afterScope = createAfterScope({ timeoutMs, onComplete: flushCacheWork,
411
+ onTimeout: error => parentPort.postMessage({ id, error: { message: error.message, retireWorker: true } }) });
412
+ if (request.waitForResponse) responseDone = new Promise(resolve => {
413
+ const finish = () => { clearTimeout(responseTimer); resolve(); };
414
+ responseCompletions.set(id, finish);
415
+ responseTimer = setTimeout(finish, timeoutMs);
416
+ });
417
+ }
403
418
  activeRequests.add(id);
404
419
  if (channel) streamChannels.set(id, channel);
405
420
  try {
@@ -422,6 +437,14 @@ parentPort.on('message', async ({ id, request, type, value, token }) => {
422
437
  code: error?.code, staticMode: error?.staticMode, dynamicReason: error?.dynamicReason,
423
438
  } });
424
439
  } finally {
440
+ // Flight completion can precede HTML decoding and socket backpressure.
441
+ // The HTTP worker acknowledges the actual response end/error/disconnect.
442
+ // Keep the RSC lease until bounded after-response work has settled.
443
+ if (request.afterScope) {
444
+ try { await responseDone; await request.afterScope.finish(); }
445
+ catch (error) { console.error('[prnext] Server Action after() failed:', error?.stack || error); }
446
+ }
447
+ clearTimeout(responseTimer); responseCompletions.delete(id);
425
448
  streamChannels.delete(id); activeRequests.delete(id);
426
449
  // A streamed terminal frame can precede asynchronous cache writes. Only
427
450
  // this acknowledgement confirms that all request work has actually ended.
@@ -1,3 +1,5 @@
1
+ const immediate = () => new Promise(resolve => setImmediate(resolve));
2
+
1
3
  export const STREAM_CHUNK_BYTES = 64 * 1024;
2
4
 
3
5
  export function abortError(reason = 'Response body was canceled') {
@@ -48,6 +50,7 @@ export function streamingBody(source, { signal, onCancel, onComplete, timeoutMs
48
50
 
49
51
  function clean() {
50
52
  clearTimeout(timer);
53
+ timer = undefined;
51
54
  signal?.removeEventListener('abort', aborted);
52
55
  chunk = undefined;
53
56
  if (reader) {
@@ -70,10 +73,14 @@ export function streamingBody(source, { signal, onCancel, onComplete, timeoutMs
70
73
  }
71
74
 
72
75
  function touch() {
73
- clearTimeout(timer);
74
76
  if (timeoutMs > 0) {
75
- timer = setTimeout(() => stop(apiTimeoutError('response idle', timeoutMs), true), timeoutMs);
76
- timer.unref?.();
77
+ // Keep one request-scoped timer: splitting or reading another chunk must
78
+ // extend the same idle deadline without allocating another async resource.
79
+ if (timer) timer.refresh();
80
+ else {
81
+ timer = setTimeout(() => stop(apiTimeoutError('response idle', timeoutMs), true), timeoutMs);
82
+ timer.unref?.();
83
+ }
77
84
  }
78
85
  }
79
86
  function aborted() { stop(signal.reason, true); }
@@ -87,6 +94,7 @@ export function streamingBody(source, { signal, onCancel, onComplete, timeoutMs
87
94
  if (stopped) return { done: true, value: undefined };
88
95
  if (nextInProgress) throw new Error('Response body reads must be sequential');
89
96
  nextInProgress = true;
97
+ let emptyReads = 0;
90
98
  try {
91
99
  while (!chunk || offset >= chunk.byteLength) {
92
100
  chunk = undefined;
@@ -105,6 +113,17 @@ export function streamingBody(source, { signal, onCancel, onComplete, timeoutMs
105
113
  if (!(next.value instanceof Uint8Array)) throw new TypeError('Response body chunks must be Uint8Array values');
106
114
  chunk = next.value;
107
115
  offset = 0;
116
+ if (!chunk.byteLength && ++emptyReads === 32) {
117
+ // Empty, immediately resolved reads produce no writable I/O and
118
+ // can otherwise starve cancellation, idle timers and other lanes.
119
+ // They are not progress: keep the original idle deadline running.
120
+ emptyReads = 0;
121
+ await immediate();
122
+ if (stopped) {
123
+ if (failure) throw failure;
124
+ return { done: true, value: undefined };
125
+ }
126
+ }
108
127
  }
109
128
  const value = chunk.subarray(offset, Math.min(offset + STREAM_CHUNK_BYTES, chunk.byteLength));
110
129
  offset += value.byteLength;
@@ -1,5 +1,8 @@
1
+ const yieldToEvents = () => new Promise(resolve => setImmediate(resolve));
2
+
1
3
  export const MAX_STREAM_CHUNK = 64 * 1024;
2
4
  const MAX_CONTROL_BYTES = 64 * 1024;
5
+ const STREAM_YIELD_CHUNKS = 32;
3
6
  const NO_BODY = new Set([204, 205, 304]);
4
7
 
5
8
  export class ProtocolWriteError extends Error {
@@ -85,6 +88,15 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
85
88
  }
86
89
  await send(head);
87
90
  responseStarted = true;
91
+ let chunksSinceYield = 0;
92
+ const yieldStream = async () => {
93
+ // A writable callback may settle on nextTick without polling I/O.
94
+ // Bound ready producer/write chains so other lanes can authenticate,
95
+ // timers can run and disconnects can abort this producer promptly.
96
+ chunksSinceYield = 0;
97
+ await yieldToEvents();
98
+ signalController?.signal.throwIfAborted();
99
+ };
88
100
  // Iterate the producer directly: an intermediate async generator adds
89
101
  // promise reactions and Buffer views for every already bounded chunk.
90
102
  // Each write still settles before the next chunk or producer read.
@@ -93,6 +105,7 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
93
105
  const value = bytes(responseBody);
94
106
  for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) {
95
107
  await sendChunk(id, value.subarray(offset, offset + MAX_STREAM_CHUNK));
108
+ if (++chunksSinceYield === STREAM_YIELD_CHUNKS) await yieldStream();
96
109
  }
97
110
  } else {
98
111
  if (typeof responseBody[Symbol.asyncIterator] !== 'function' && typeof responseBody[Symbol.iterator] !== 'function') {
@@ -102,9 +115,12 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
102
115
  const value = bytes(item);
103
116
  if (value.length <= MAX_STREAM_CHUNK) {
104
117
  if (value.length) await sendChunk(id, value);
118
+ // Empty iterable chunks also consume work without writing bytes.
119
+ if (++chunksSinceYield === STREAM_YIELD_CHUNKS) await yieldStream();
105
120
  } else {
106
121
  for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) {
107
122
  await sendChunk(id, value.subarray(offset, offset + MAX_STREAM_CHUNK));
123
+ if (++chunksSinceYield === STREAM_YIELD_CHUNKS) await yieldStream();
108
124
  }
109
125
  }
110
126
  }
@@ -1,5 +1,28 @@
1
1
  // Only build manifests belong here. Never register headers, models, closures or
2
2
  // request contexts. Eviction is ordered on the same MessagePort as requests.
3
+ const immutableClientManifests = new WeakSet();
4
+
5
+ // Production build metadata comes from JSON and does not change until a new
6
+ // worker loads the next build. Only explicitly sealed maps can skip validation;
7
+ // callers using mutable manifests (including development) keep the old checks.
8
+ export function freezeClientModules(value) {
9
+ if (!value || typeof value !== 'object') return value;
10
+ if (immutableClientManifests.has(value)) return value;
11
+ const entries = Object.getOwnPropertyDescriptors(value);
12
+ // An accessor may change even on a frozen object. Do not trust such a map.
13
+ if (!Object.values(entries).every(entry => Object.hasOwn(entry, 'value') &&
14
+ entry.value && typeof entry.value === 'object' &&
15
+ typeof Object.getOwnPropertyDescriptor(entry.value, 'ssrModule')?.value === 'string')) return value;
16
+ for (const entry of Object.values(entries)) Object.freeze(entry.value);
17
+ Object.freeze(value);
18
+ immutableClientManifests.add(value);
19
+ return value;
20
+ }
21
+
22
+ export function isImmutableClientModules(value) {
23
+ return immutableClientManifests.has(value);
24
+ }
25
+
3
26
  export function workerMetadata(port, limit = 32) {
4
27
  const entries = new Map();
5
28
  let sequence = 0;