@thomas.f/prnext 0.1.12-alpha → 0.1.15-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 (73) hide show
  1. package/README.md +305 -65
  2. package/build/ADAPTERS.md +121 -0
  3. package/build/actions.mjs +4 -0
  4. package/build/app-config.mjs +2 -0
  5. package/build/app.mjs +23 -5
  6. package/build/build-lock.mjs +71 -0
  7. package/build/bundle-runtime.mjs +82 -0
  8. package/build/compiler.mjs +3 -1
  9. package/build/config.mjs +9 -2
  10. package/build/font-cache.mjs +50 -0
  11. package/build/font-files.mjs +5 -3
  12. package/build/fonts.mjs +1 -1
  13. package/build/index.mjs +12 -1
  14. package/build/inspect.mjs +20 -2
  15. package/build/inspection-summary.mjs +37 -0
  16. package/build/metadata-conflicts.mjs +13 -0
  17. package/build/metadata-images.mjs +3 -3
  18. package/build/metadata-routes.mjs +3 -3
  19. package/build/native-packages.mjs +5 -1
  20. package/build/npm-packages.mjs +2 -1
  21. package/build/og.mjs +3 -9
  22. package/build/optimize-imports.mjs +112 -0
  23. package/build/output-contract.mjs +41 -0
  24. package/build/precompress.mjs +13 -0
  25. package/build/proxy-body-config.mjs +10 -0
  26. package/build/scan.mjs +10 -8
  27. package/build/static-adapter.mjs +100 -0
  28. package/cli.mjs +64 -11
  29. package/compat/after.cjs +51 -0
  30. package/compat/diagnostics.cjs +4 -0
  31. package/compat/index.d.ts +32 -1
  32. package/compat/server.cjs +2 -1
  33. package/compat/server.d.cts +2 -0
  34. package/index.mjs +2 -0
  35. package/native/launch.mjs +19 -0
  36. package/package.json +8 -9
  37. package/release/verify.mjs +2 -0
  38. package/runtime/api.mjs +20 -7
  39. package/runtime/app-render.mjs +78 -33
  40. package/runtime/app-route-plan.mjs +45 -0
  41. package/runtime/app-routing-state.mjs +101 -21
  42. package/runtime/app-routing.mjs +37 -18
  43. package/runtime/app-segments.mjs +25 -11
  44. package/runtime/app-static.mjs +1 -1
  45. package/runtime/app-stream-html.mjs +2 -1
  46. package/runtime/cache-request.mjs +10 -2
  47. package/runtime/cloud/archive.mjs +317 -0
  48. package/runtime/cloud/client.mjs +641 -0
  49. package/runtime/cloud/environment.mjs +60 -0
  50. package/runtime/cloud/state.mjs +156 -0
  51. package/runtime/cloud/upload.mjs +48 -0
  52. package/runtime/html-envelope.mjs +70 -7
  53. package/runtime/http.mjs +7 -4
  54. package/runtime/middleware.mjs +3 -3
  55. package/runtime/og/Geist-Regular.ttf +0 -0
  56. package/runtime/og/LICENSE +373 -0
  57. package/runtime/og/LICENSE.Geist +93 -0
  58. package/runtime/og/LICENSE.fflate +21 -0
  59. package/runtime/og/LICENSE.harfbuzzjs +22 -0
  60. package/runtime/og/LICENSE.satori +373 -0
  61. package/runtime/og/NOTICE.txt +4 -0
  62. package/runtime/og/hb.wasm +0 -0
  63. package/runtime/og/index.node.mjs +31260 -0
  64. package/runtime/og/resvg.wasm +0 -0
  65. package/runtime/og-backend.mjs +8 -0
  66. package/runtime/og-loader.mjs +2 -15
  67. package/runtime/request-transport.mjs +1 -1
  68. package/runtime/route-static.mjs +1 -1
  69. package/runtime/rsc-worker.mjs +27 -4
  70. package/runtime/stream-utils.mjs +22 -3
  71. package/runtime/transport.mjs +16 -0
  72. package/runtime/worker-metadata.mjs +23 -0
  73. package/runtime/worker.mjs +36 -9
@@ -0,0 +1,156 @@
1
+ import { mkdir, lstat, readFile, realpath, 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
+ // Resolve existing parents too when a destination has not been created yet.
8
+ // On macOS, for example, /var and /private/var refer to the same directory.
9
+ export async function prospectivePath(value) {
10
+ let parent = path.resolve(value);
11
+ const missing = [];
12
+ for (;;) {
13
+ try { return path.join(await realpath(parent), ...missing); }
14
+ catch (error) {
15
+ if (error.code !== 'ENOENT' || path.dirname(parent) === parent) throw error;
16
+ missing.unshift(path.basename(parent));
17
+ parent = path.dirname(parent);
18
+ }
19
+ }
20
+ }
21
+
22
+ export function cloudOrigin(value = 'https://app.prnext.dev') {
23
+ let url;
24
+ try {
25
+ url = new URL(value);
26
+ } catch {
27
+ throw new Error(
28
+ 'Cloud server must be an HTTPS origin or a loopback HTTP origin.'
29
+ );
30
+ }
31
+ if (
32
+ url.username ||
33
+ url.password ||
34
+ url.pathname !== '/' ||
35
+ url.search ||
36
+ url.hash ||
37
+ !(
38
+ url.protocol === 'https:' ||
39
+ (url.protocol === 'http:' &&
40
+ ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
41
+ )
42
+ )
43
+ throw new Error(
44
+ 'Cloud server must be an HTTPS origin or a loopback HTTP origin.'
45
+ );
46
+ return url.origin;
47
+ }
48
+ export async function readState(filename) {
49
+ try {
50
+ const info = await lstat(filename);
51
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 65536)
52
+ throw new Error('Invalid PRNext Cloud state file.');
53
+ const value = JSON.parse(await readFile(filename, 'utf8'));
54
+ if (!value || Array.isArray(value) || typeof value !== 'object')
55
+ throw new Error('Invalid PRNext Cloud state file.');
56
+ return value;
57
+ } catch (error) {
58
+ if (error.code === 'ENOENT') return null;
59
+ throw error;
60
+ }
61
+ }
62
+ export async function writeState(filename, data) {
63
+ const directory = path.dirname(filename);
64
+ await mkdir(directory, { recursive: true, mode: 0o700 });
65
+ const info = await lstat(directory);
66
+ if (info.isSymbolicLink() || !info.isDirectory())
67
+ throw new Error('PRNext Cloud state directory cannot be a link.');
68
+ await readState(filename);
69
+ if (process.platform !== 'win32') await chmod(directory, 0o700);
70
+ const temporary = `${filename}.${randomBytes(8).toString('hex')}.tmp`;
71
+ try {
72
+ await writeFile(temporary, JSON.stringify(data, null, 2) + '\n', {
73
+ flag: 'wx',
74
+ mode: 0o600
75
+ });
76
+ await rename(temporary, filename);
77
+ } finally {
78
+ await rm(temporary, { force: true });
79
+ }
80
+ }
81
+ export function credentialPath(origin, env = process.env) {
82
+ const directory = path.resolve(
83
+ env.PRNEXT_CLOUD_HOME || path.join(homedir(), '.prnext', 'cloud')
84
+ );
85
+ return path.join(
86
+ directory,
87
+ createHash('sha256').update(cloudOrigin(origin)).digest('hex') + '.json'
88
+ );
89
+ }
90
+ export const linkPath = (directory) =>
91
+ path.join(directory, '.prnext-cloud', 'project.json');
92
+ export async function withStateLock(filename, task) {
93
+ await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
94
+ if ((await lstat(path.dirname(filename))).isSymbolicLink())
95
+ throw new Error('Cloud state directory cannot be a link.');
96
+ const marker = { pid: process.pid, nonce: randomBytes(16).toString('hex') };
97
+ for (let attempt = 0; ; attempt++) {
98
+ try {
99
+ await writeFile(filename, JSON.stringify(marker), {
100
+ flag: 'wx',
101
+ mode: 0o600
102
+ });
103
+ break;
104
+ } catch (error) {
105
+ if (error.code !== 'EEXIST') throw error;
106
+ const previous = await readState(filename);
107
+ let active = true;
108
+ if (Number.isSafeInteger(previous?.pid) && previous.pid > 0) {
109
+ try {
110
+ process.kill(previous.pid, 0);
111
+ } catch (failure) {
112
+ if (failure.code === 'ESRCH') active = false;
113
+ }
114
+ }
115
+ if (active || attempt > 0)
116
+ throw new Error(
117
+ 'Another PRNext Cloud command is using this project. Wait for it to finish.'
118
+ );
119
+ if ((await readState(filename))?.nonce === previous.nonce)
120
+ await rm(filename);
121
+ }
122
+ }
123
+ try {
124
+ return await task();
125
+ } finally {
126
+ if ((await readState(filename))?.nonce === marker.nonce) await rm(filename);
127
+ }
128
+ }
129
+ export async function saveLink(directory, link) {
130
+ await writeState(linkPath(directory), link);
131
+ const gitignore = path.join(directory, '.gitignore');
132
+ let contents = '';
133
+ try {
134
+ const stat = await lstat(gitignore);
135
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65536)
136
+ throw new Error(
137
+ 'Cannot safely update .gitignore; ignore .prnext-cloud/ manually.'
138
+ );
139
+ contents = await readFile(gitignore, 'utf8');
140
+ } catch (error) {
141
+ if (error.code !== 'ENOENT') throw error;
142
+ }
143
+ if (
144
+ !contents
145
+ .split(/\r?\n/)
146
+ .some((line) =>
147
+ ['.prnext-cloud/', '/.prnext-cloud/', '.prnext-cloud'].includes(line)
148
+ )
149
+ )
150
+ await writeFile(
151
+ gitignore,
152
+ contents +
153
+ (contents.endsWith('\n') || !contents ? '' : '\n') +
154
+ '.prnext-cloud/\n'
155
+ );
156
+ }
@@ -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 });
Binary file