@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
@@ -6,8 +6,9 @@ import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
  import { currentRequest, runRequestContext } from '../compat/headers.cjs';
8
8
  import { staticParams } from '../compat/static-generation.cjs';
9
+ import { appRoutePlan } from './app-route-plan.mjs';
9
10
 
10
- import { matchAppPattern, readRouterState } from './app-routing-state.mjs';
11
+ import { matchAppPattern, createAppMatcher, readRouterState } from './app-routing-state.mjs';
11
12
  export { matchAppPattern, readRouterState, advancedRoutingSources } from './app-routing-state.mjs';
12
13
 
13
14
  export async function loadAdvancedRouting(entry, request) {
@@ -51,10 +52,12 @@ function selectedSegments(chain, params) {
51
52
  /** Parallel slots retain their original Flight children on soft navigation. */
52
53
  export function createAdvancedTree(entry, request, searchParams) {
53
54
  const pathname = removeBasePath(new URL(request.url).pathname, request.basePath || '');
55
+ const matchCurrent = createAppMatcher(pathname);
54
56
  const old = readRouterState(request), slots = {}, activeParams = { ...request.params };
55
57
  const rebuild = !!old && (old.refresh || old.restore || request.method === 'POST');
56
58
  let invalid = false;
57
59
  const root = entry.routing;
60
+ const plan = appRoutePlan(root);
58
61
  let metadataHead;
59
62
  const keyScopes = new Map();
60
63
  const instantTrees = [];
@@ -62,18 +65,20 @@ export function createAdvancedTree(entry, request, searchParams) {
62
65
  if (request.partialParams?.length) keyScopes.set(key, { key, prefix, suffix, names: Object.keys(values), pattern: node.pattern, ...(liveProps ? { liveProps } : {}), ...(segments ? { segments } : {}) });
63
66
  return key;
64
67
  }
65
- const rootLayouts = new Set();
68
+ const rootLayouts = plan?.rootLayouts || new Set();
66
69
  const metadataSelections = new Map();
67
70
  let missing = entry.interceptionOnly && !old, rootPage;
68
71
  const all = [];
69
72
  function walk(node, chain = []) {
70
73
  const next = [...chain, node];
71
74
  if (node.files.layout && !chain.some(parent => parent.files.layout)) rootLayouts.add(node.path);
72
- if (node.files.page) all.push({ node, chain: next, params: matchAppPattern(node.pattern, pathname) });
75
+ if (node.files.page) all.push({ node, chain: next, params: matchCurrent(node.pattern) });
73
76
  for (const child of node.children) walk(child, next);
74
77
  for (const child of Object.values(node.slots)) walk(child, next);
75
78
  }
76
- walk(root);
79
+ if (plan) {
80
+ for (const page of plan.pages) all.push({ ...page, params: matchCurrent(page.node.pattern) });
81
+ } else walk(root);
77
82
  let activeInterceptions;
78
83
  function canIntercept(candidate) {
79
84
  if (!candidate.node.interception || !old) return false;
@@ -97,14 +102,29 @@ export function createAdvancedTree(entry, request, searchParams) {
97
102
  function candidates(node, chain = []) {
98
103
  const result = [], next = [...chain, node];
99
104
  if (node.files.page) {
100
- const params = matchAppPattern(node.pattern, pathname);
105
+ const params = matchCurrent(node.pattern);
101
106
  if (params && (!node.interception || canIntercept({ node }))) result.push({ node, chain: next, params });
102
107
  }
103
108
  for (const child of node.children) result.push(...candidates(child, next));
104
109
  return result.sort((left, right) => Number(!!right.node.interception) - Number(!!left.node.interception) || score(right.node.pattern) - score(left.node.pattern));
105
110
  }
111
+ function firstCandidate(node) {
112
+ if (!plan) return candidates(node)[0];
113
+ const scope = plan.nodes.get(node);
114
+ let best;
115
+ for (const candidate of all) {
116
+ if (!candidate.params || candidate.branch !== scope.branch || candidate.start < scope.start || candidate.start >= scope.end
117
+ || (candidate.node.interception && !canIntercept(candidate))) continue;
118
+ if (!best || Number(!!candidate.node.interception) > Number(!!best.node.interception)
119
+ || (!!candidate.node.interception === !!best.node.interception && candidate.score > best.score)) best = candidate;
120
+ }
121
+ // Separate request-owned params from the interception/scaffolding candidates,
122
+ // as in the live traversal. No parameter objects survive this request.
123
+ return best && { node: best.node, chain: best.chain.slice(scope.depth), params: matchCurrent(best.node.pattern) };
124
+ }
125
+ const slotEntries = node => plan?.nodes.get(node).slots || Object.entries(node.slots);
106
126
  function scopedParams(node) {
107
- const current = matchAppPattern(node.pattern, pathname, true) || (old && matchAppPattern(node.pattern, old.source, true));
127
+ const current = matchCurrent(node.pattern, true) || (old && matchAppPattern(node.pattern, old.source, true));
108
128
  if (current) return current;
109
129
  for (const [key, value] of Object.entries(old?.slots || {})) {
110
130
  if (!key.startsWith(`${node.path}::`)) continue;
@@ -117,7 +137,7 @@ export function createAdvancedTree(entry, request, searchParams) {
117
137
  return registerKey(`${node.path}::${JSON.stringify(scopedParams(node))}`, node, `${node.path}::`);
118
138
  }
119
139
  function sourceURL(node, previous) {
120
- return matchAppPattern(node.pattern, pathname, true) ? pathname + new URL(request.url).search : previous?.source || previous?.url || old?.source || pathname;
140
+ return matchCurrent(node.pattern, true) ? pathname + new URL(request.url).search : previous?.source || previous?.url || old?.source || pathname;
121
141
  }
122
142
  function accessURL(url, previous) {
123
143
  if (previous?.url === url && previous.accessUrl) return previous.accessUrl;
@@ -148,8 +168,7 @@ export function createAdvancedTree(entry, request, searchParams) {
148
168
  function slot(node, owner, name) {
149
169
  const key = registerKey(`${scope(owner)}::${name}`, owner, `${owner.path}::`, `::${name}`, ['id']);
150
170
  const previous = old?.slots[key];
151
- let choices = candidates(node);
152
- let selected = rebuild && previous ? savedBranch(node, previous) : choices[0];
171
+ let selected = rebuild && previous ? savedBranch(node, previous) : firstCandidate(node);
153
172
  if (rebuild && previous && !selected) { invalid = true; return { tree: null, segments: [] }; }
154
173
  if (!rebuild) {
155
174
  for (const candidate of interceptions) {
@@ -189,9 +208,9 @@ export function createAdvancedTree(entry, request, searchParams) {
189
208
  // Find the nearest default whose route prefix contains the destination.
190
209
  const defaults = [];
191
210
  function findDefault(value, chain = []) {
192
- if (!matchAppPattern(value.pattern, pathname, true) || (value.interception && !old)) return;
211
+ if (!matchCurrent(value.pattern, true) || (value.interception && !old)) return;
193
212
  const next = [...chain, value];
194
- if (value.files.default) defaults.push({ node: value, chain: next, params: matchAppPattern(value.pattern, pathname, true), default: true });
213
+ if (value.files.default) defaults.push({ node: value, chain: next, params: matchCurrent(value.pattern, true), default: true });
195
214
  for (const child of value.children) findDefault(child, next);
196
215
  }
197
216
  findDefault(node);
@@ -207,9 +226,9 @@ export function createAdvancedTree(entry, request, searchParams) {
207
226
  let selectedTree = renderPath(selected.chain, selected, 0, name === 'children' && owner === node);
208
227
  const context = request.routingContexts?.get(selectedURL);
209
228
  if (context && request.renderRoutingBranch) selectedTree = request.renderRoutingBranch(selectedTree, context);
210
- const instant=selected.node.config?.page?.instant ?? [...selected.chain].reverse().find(item=>item.config?.layout?.instant!==undefined)?.config.layout.instant;
211
- if(request.instantValidation && name!=='children' && instantEnabled(instant,request.production)){
212
- instantTrees.push({path:(owner.path || '/')+' @'+name,tree:selectedTree});
229
+ if(request.instantValidation && name!=='children'){
230
+ const instant=selected.node.config?.page?.instant ?? selected.chain.findLast(item=>item.config?.layout?.instant!==undefined)?.config.layout.instant;
231
+ if(instantEnabled(instant,request.production)) instantTrees.push({path:(owner.path || '/')+' @'+name,tree:selectedTree});
213
232
  }
214
233
  return { tree: React.createElement(entry.LayoutSlot, { key, id: key, preserve: false }, selectedTree), segments: selected.default ? [] : selectedSegments(selected.chain.slice(node === owner ? 1 : 0), selected.params) };
215
234
  }
@@ -248,7 +267,7 @@ export function createAdvancedTree(entry, request, searchParams) {
248
267
  const selected = { children: selection.default ? [] : selectedSegments(chain.slice(index + 1), selection.params) };
249
268
  if (preserveChild) selected.children = null;
250
269
  const parallel = {};
251
- for (const [name, child] of Object.entries(node.slots)) {
270
+ for (const [name, child] of slotEntries(node)) {
252
271
  const rendered = slot(child, node, name);
253
272
  parallel[name] = rendered.tree; selected[name] = rendered.segments;
254
273
  }
@@ -286,7 +305,7 @@ export function createAdvancedTree(entry, request, searchParams) {
286
305
  if (layout) metadataItems.push({ module: node.files.layout, params: staticParams(layout.params, currentRequest()), filePath: page ? undefined : node.path, context: layout.context });
287
306
  // Next orders paths containing named slots before ordinary children,
288
307
  // with slot names in locale order (compareAppPaths in its app loader).
289
- for (const [, child] of Object.entries(node.slots).sort(([a], [b]) => a.localeCompare(b))) visit(child);
308
+ for (const [, child] of plan?.nodes.get(node).metadataSlots || Object.entries(node.slots).sort(([a], [b]) => a.localeCompare(b))) visit(child);
290
309
  if (page) metadataItems.push({ module: node.files[page.default ? 'default' : 'page'], params: staticParams(page.params, currentRequest()), searchParams: page.searchParams || searchParams, filePath: node.path, context: request.routingContexts?.get(page.url) });
291
310
  for (const child of node.children) visit(child);
292
311
  }
@@ -300,7 +319,7 @@ export function createAdvancedTree(entry, request, searchParams) {
300
319
  metadataHead = head;
301
320
  const main = slot(root, root, 'children');
302
321
  const parallel = {}, selected = { children: main.segments };
303
- for (const [name, child] of Object.entries(root.slots)) {
322
+ for (const [name, child] of slotEntries(root)) {
304
323
  const result = slot(child, root, name);
305
324
  parallel[name] = result.tree; selected[name] = result.segments;
306
325
  }
@@ -310,7 +329,7 @@ export function createAdvancedTree(entry, request, searchParams) {
310
329
  const content = React.createElement(React.Fragment, null, main.tree, ...Object.values(parallel));
311
330
  instantTrees.push({ path: root.path || '/', tree: root.files.loading ? React.createElement(React.Suspense, { fallback: React.createElement(component(root.files.loading, 'loading')) }, content) : content });
312
331
  }
313
- const instantDisabled = all.flatMap(item => item.chain).some(node => Object.values(node.config || {}).some(config => config.instant && typeof config.instant === 'object' && (config.instant.unstable_disableValidation || (request.production ? config.instant.unstable_disableBuildValidation : config.instant.unstable_disableDevValidation))));
332
+ const instantDisabled = request.instantValidation && all.some(item => item.chain.some(node => Object.values(node.config || {}).some(config => config.instant && typeof config.instant === 'object' && (config.instant.unstable_disableValidation || (request.production ? config.instant.unstable_disableBuildValidation : config.instant.unstable_disableDevValidation)))));
314
333
  let tree = wrap(root, main.tree, parallel, selected, scope(root), {}, rootPage?.chain.slice(1).map(item => item.segment), rootPage?.node);
315
334
  if (missing) {
316
335
  request.responseStatus = 404;
@@ -1,22 +1,36 @@
1
+ // Weak ownership follows the manifest. Mutable development segment names are
2
+ // re-read, and no request parameter or selected segment value is retained.
3
+ const syntax = new WeakMap();
4
+ function segmentSyntax(segment) {
5
+ const value = segment.segment || '';
6
+ const existing = syntax.get(segment);
7
+ if (existing?.value === value) return existing;
8
+ const parsed = { value, name: /^\[\[?(?:\.\.\.)?([^\]]+)\]\]?$/.exec(value)?.[1] };
9
+ if ((typeof segment === 'object' && segment !== null) || typeof segment === 'function') syntax.set(segment, parsed);
10
+ return parsed;
11
+ }
12
+
1
13
  /** Each layout sees only dynamic parameters declared at or above its segment. */
2
14
  export function paramsBySegment(segments, params = {}) {
3
15
  const result = new Map();
4
16
  const ancestors = Object.create(null);
5
17
  for (const segment of segments) {
6
- const match = /^\[\[?(?:\.\.\.)?([^\]]+)\]\]?$/.exec(segment.segment || '');
7
- if (match && Object.hasOwn(params, match[1])) ancestors[match[1]] = params[match[1]];
18
+ const { name } = segmentSyntax(segment);
19
+ if (name && Object.hasOwn(params, name)) ancestors[name] = params[name];
8
20
  result.set(segment, { ...ancestors });
9
21
  }
10
22
  return result;
11
23
  }
12
24
 
13
- export function selectedLayoutSegments(segments, params = {}) {
14
- return segments.flatMap(segment => {
15
- const value = segment.segment || '';
16
- if (!value || value.startsWith('@')) return [];
17
- const match = /^\[\[?(?:\.\.\.)?([^\]]+)\]\]?$/.exec(value);
18
- if (!match) return [value];
19
- const parameter = params[match[1]];
20
- return parameter === undefined ? [] : [Array.isArray(parameter) ? parameter.join('/') : parameter];
21
- });
25
+ export function selectedLayoutSegments(segments, params = {}, start = 0) {
26
+ const selected = [];
27
+ for (let index = start, length = segments.length; index < length; index++) {
28
+ if (!(index in segments)) continue;
29
+ const { value, name } = segmentSyntax(segments[index]);
30
+ if (!value || value.startsWith('@')) continue;
31
+ if (!name) { selected.push(value); continue; }
32
+ const parameter = params[name];
33
+ if (parameter !== undefined) selected.push(Array.isArray(parameter) ? parameter.join('/') : parameter);
34
+ }
35
+ return selected;
22
36
  }
@@ -61,7 +61,7 @@ export async function prerenderAppRoute(options) {
61
61
  }
62
62
  html = completeAppHtml(shell.shell, result.body, { ...options.route, css: [], fonts: [] });
63
63
  } else {
64
- html = completeAppHtml(await renderHtml(model.tree, model.router, { staticGeneration: true, strictMode: options.manifest?.config?.reactStrictMode !== false }), result.body, options.route);
64
+ html = completeAppHtml(await renderHtml(model.tree, model.router, { timeoutMs: options.route?.cacheConfig?.maxDuration ? options.route.cacheConfig.maxDuration * 1000 : undefined, staticGeneration: true, strictMode: options.manifest?.config?.reactStrictMode !== false }), result.body, options.route);
65
65
  }
66
66
  }
67
67
  if (staticMetadata(context)?.externalCache) return { dynamic: true, reason: 'External cache handlers require validation on every request' };
@@ -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
@@ -113,7 +62,8 @@ function flightTap(source) {
113
62
  };
114
63
  }
115
64
 
116
- export async function renderProgressiveAppHtml({ result, request, route, responseHeaders, decodeFlight, signal, timeoutMs = 25_000, partial }) {
65
+ export async function renderProgressiveAppHtml({ result, request, route, responseHeaders, decodeFlight, signal, timeoutMs, partial }) {
66
+ timeoutMs ??= route?.cacheConfig?.maxDuration ? route.cacheConfig.maxDuration * 1000 : 25_000;
117
67
  const nonceAttribute = request.nonce ? ` nonce="${escapeHtml(request.nonce)}"` : '';
118
68
  const tap = flightTap(result.body);
119
69
  const abort = new AbortController();
@@ -1,5 +1,6 @@
1
1
  import { runRequestContext, currentRequest } from '../compat/headers.cjs';
2
2
  import { flushCacheInvalidations, flushCacheWork } from '../compat/data-cache.cjs';
3
+ import { createAfterScope } from '../compat/after.cjs';
3
4
 
4
5
  // Keep background fills alive until the transport has sent the response. In
5
6
  // particular, serving stale data must not wait for its replacement to finish.
@@ -8,15 +9,22 @@ export function withCacheRequest(options, phase, callback) {
8
9
  // only needs metadata, and may survive the response for stale revalidation.
9
10
  return runRequestContext({ ...options, body: undefined, routePattern: options.route?.pattern, cacheConfig: options.route?.cacheConfig, phase }, async () => {
10
11
  const context = currentRequest();
12
+ if (phase === 'route' && options.route?.cacheConfig?.runtime !== 'edge') context.afterScope = createAfterScope({ signal: options.signal, timeoutMs: options.timeoutMs ?? (options.route?.cacheConfig?.maxDuration ? options.route.cacheConfig.maxDuration * 1000 : 25000) });
13
+ let finalized;
14
+ const finalizeCache = () => finalized ||= (async () => { await context.afterScope?.finish(); await flushCacheWork(context); })();
15
+ // The worker owns response completion even when application code throws.
16
+ // Register before invoking it so error responses do not await after().
17
+ options.deferFinalizer?.(finalizeCache);
11
18
  try {
12
19
  const response = await callback(context);
13
20
  await flushCacheInvalidations(context);
14
21
  context.cacheState.closed = true;
15
22
  return { ...response,
16
23
  ...(context.draftMode || context.draftChanged ? { headers: { ...response.headers, 'cache-control': 'private, no-cache, no-store, max-age=0' } } : {}),
17
- finalizeCache: () => flushCacheWork(context) };
24
+ finalizeCache };
18
25
  } catch (error) {
19
- await flushCacheWork(context);
26
+ context.cacheState.closed = true;
27
+ if (!options.deferFinalizer) await finalizeCache();
20
28
  throw error;
21
29
  }
22
30
  });
@@ -0,0 +1,316 @@
1
+ import { lstat, readdir, readFile, realpath, open } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { deflateRawSync } from 'node:zlib';
4
+ import { createHash } from 'node:crypto';
5
+ import picomatch from 'picomatch';
6
+
7
+ const MiB = 1048576;
8
+ const alwaysExcluded = (name) =>
9
+ /^(?:node_modules|\.git|\.hg|\.svn|\.next|\.local|\.npm|\.ssh|\.aws|\.azure|\.vercel|coverage|\.DS_Store|__MACOSX)$/i.test(
10
+ name
11
+ ) ||
12
+ /^\.prnext(?:[.-]|$)/i.test(name) ||
13
+ /^\.env(?:\.|$)/i.test(name) ||
14
+ /^(?:\.npmrc|\.yarnrc\.yml|\.netrc|id_rsa|id_ed25519)$/i.test(name) ||
15
+ /\.(?:pem|key|p12|pfx|log|tsbuildinfo)$/i.test(name);
16
+ const crcTable = Array.from({ length: 256 }, (_, n) => {
17
+ for (let i = 0; i < 8; i++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1;
18
+ return n >>> 0;
19
+ });
20
+ const crc32 = (bytes) => {
21
+ let crc = 0xffffffff;
22
+ for (const byte of bytes) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8);
23
+ return (crc ^ 0xffffffff) >>> 0;
24
+ };
25
+ async function ignoreRules(root) {
26
+ const rules = [];
27
+ for (const name of ['.gitignore', '.prnextignore']) {
28
+ const filename = path.join(root, name);
29
+ let source;
30
+ try {
31
+ const stat = await lstat(filename);
32
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65536)
33
+ throw new Error(`${name} must be a regular file of at most 64 KiB.`);
34
+ source = await readFile(filename, 'utf8');
35
+ } catch (error) {
36
+ if (error.code === 'ENOENT') continue;
37
+ throw error;
38
+ }
39
+ for (let pattern of source.split(/\r?\n/)) {
40
+ pattern = pattern.trim();
41
+ if (!pattern || pattern.startsWith('#')) continue;
42
+ const include = pattern.startsWith('!');
43
+ if (include) pattern = pattern.slice(1);
44
+ const anchored = pattern.startsWith('/');
45
+ pattern = pattern.replace(/^\//, '').replace(/\/$/, '');
46
+ if (!pattern || pattern.length > 512)
47
+ throw new Error(`Invalid pattern in ${name}.`);
48
+ const match = picomatch(
49
+ !anchored && !pattern.includes('/') ? `**/${pattern}` : pattern,
50
+ { dot: true }
51
+ );
52
+ rules.push({
53
+ include,
54
+ match,
55
+ prefix:
56
+ !anchored && !pattern.includes('/')
57
+ ? ''
58
+ : picomatch.scan(pattern).base
59
+ });
60
+ if (rules.length > 2000)
61
+ throw new Error('Too many deployment ignore patterns.');
62
+ }
63
+ }
64
+ const ignored = (name) => {
65
+ let ignored = false;
66
+ const parts = name.split('/');
67
+ const prefixes = parts.map((_, i) => parts.slice(0, i + 1).join('/'));
68
+ for (const rule of rules)
69
+ if (prefixes.some((prefix) => rule.match(prefix)))
70
+ ignored = !rule.include;
71
+ return ignored;
72
+ };
73
+ ignored.canReinclude = (directory) =>
74
+ rules.some(
75
+ (rule) =>
76
+ rule.include &&
77
+ (!rule.prefix ||
78
+ rule.prefix === directory ||
79
+ rule.prefix.startsWith(directory + '/') ||
80
+ directory.startsWith(rule.prefix + '/'))
81
+ );
82
+ return ignored;
83
+ }
84
+
85
+ /** Build a deterministic, bounded ZIP without following links or running code. */
86
+ export async function archiveProject(
87
+ directory,
88
+ {
89
+ maxArchiveBytes = 7 * MiB,
90
+ maxSourceBytes = 100 * MiB,
91
+ maxFileBytes = 15 * MiB,
92
+ maxFiles = 3000,
93
+ signal,
94
+ outputFile
95
+ } = {}
96
+ ) {
97
+ const root = await realpath(directory);
98
+ if (outputFile) {
99
+ const relative = path.relative(root,path.resolve(outputFile));
100
+ if (!relative || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) throw new Error('Temporary archive must be outside the project.');
101
+ }
102
+ const output = outputFile ? await open(outputFile, 'wx', 0o600) : null;
103
+ const hash = createHash('sha256');
104
+ const chunks = [];
105
+ const append = async (...parts) => {
106
+ for (const part of parts) { hash.update(part); if (output) await output.writeFile(part); else chunks.push(part); }
107
+ };
108
+ try {
109
+ const ignored = await ignoreRules(root);
110
+ const files = [],
111
+ central = [],
112
+ names = new Set();
113
+ let expanded = 0,
114
+ offset = 0,
115
+ centralSize = 0,
116
+ visited = 0,
117
+ excluded = 0,
118
+ packageJson;
119
+ async function walk(relative = '') {
120
+ for (const entry of (
121
+ await readdir(path.join(root, relative), { withFileTypes: true })
122
+ ).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
123
+ signal?.throwIfAborted();
124
+ if (++visited > 20000)
125
+ throw new Error(
126
+ 'Too many source entries. Deploy a smaller project folder.'
127
+ );
128
+ const name = relative ? `${relative}/${entry.name}` : entry.name;
129
+ if (alwaysExcluded(entry.name)) {
130
+ excluded++;
131
+ continue;
132
+ }
133
+ if (
134
+ /[\x00-\x1f\x7f\\:]/.test(name) ||
135
+ name
136
+ .split('/')
137
+ .some(
138
+ (p) =>
139
+ p === '..' ||
140
+ /[. ]$/.test(p) ||
141
+ /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(p)
142
+ ) ||
143
+ Buffer.byteLength(name) > 1024
144
+ )
145
+ throw new Error(
146
+ 'A source filename is not portable. Rename files with control characters, backslashes or trailing dots/spaces.'
147
+ );
148
+ if (entry.isSymbolicLink()) {
149
+ if (ignored(name)) {
150
+ excluded++;
151
+ continue;
152
+ }
153
+ throw new Error(
154
+ `Symbolic links are not uploaded: ${name}. Exclude it in .prnextignore.`
155
+ );
156
+ }
157
+ if (entry.isDirectory()) {
158
+ if (ignored(name) && !ignored.canReinclude(name)) {
159
+ excluded++;
160
+ continue;
161
+ }
162
+ await walk(name);
163
+ continue;
164
+ }
165
+ if (ignored(name)) {
166
+ excluded++;
167
+ continue;
168
+ }
169
+ if (!entry.isFile())
170
+ throw new Error(`Only regular source files can be uploaded: ${name}`);
171
+ if (files.length >= maxFiles)
172
+ throw new Error(
173
+ `Source exceeds ${maxFiles} files. Add exclusions to .prnextignore.`
174
+ );
175
+ const folded = name.toLowerCase();
176
+ if (names.has(folded))
177
+ throw new Error(`Case-conflicting source filename: ${name}`);
178
+ names.add(folded);
179
+ const filename = path.join(root, name);
180
+ const before = await lstat(filename);
181
+ if (
182
+ !before.isFile() ||
183
+ before.isSymbolicLink() ||
184
+ before.size > maxFileBytes
185
+ )
186
+ throw new Error(
187
+ `Source file is not regular or exceeds ${maxFileBytes / MiB} MiB: ${name}`
188
+ );
189
+ if (expanded + before.size > maxSourceBytes)
190
+ throw new Error(
191
+ `Expanded sources exceed ${maxSourceBytes / MiB} MiB. Add exclusions to .prnextignore.`
192
+ );
193
+ // Recheck identity after opening and after reading to catch concurrent edits.
194
+ const handle = await open(filename, 'r');
195
+ let contents;
196
+ try {
197
+ const opened = await handle.stat();
198
+ const current = await lstat(filename);
199
+ if (
200
+ current.isSymbolicLink() ||
201
+ !opened.isFile() ||
202
+ opened.dev !== before.dev ||
203
+ opened.ino !== before.ino ||
204
+ opened.size !== before.size
205
+ )
206
+ throw new Error(
207
+ `Source changed while packaging: ${name}. Try again.`
208
+ );
209
+ contents = Buffer.alloc(opened.size);
210
+ let read = 0;
211
+ while (read < contents.length) {
212
+ const next = await handle.read(
213
+ contents,
214
+ read,
215
+ contents.length - read,
216
+ read
217
+ );
218
+ if (!next.bytesRead)
219
+ throw new Error(
220
+ `Source changed while packaging: ${name}. Try again.`
221
+ );
222
+ read += next.bytesRead;
223
+ }
224
+ const after = await handle.stat();
225
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs)
226
+ throw new Error(
227
+ `Source changed while packaging: ${name}. Try again.`
228
+ );
229
+ } finally {
230
+ await handle.close();
231
+ }
232
+ if (name === 'package.json') {
233
+ try {
234
+ packageJson = JSON.parse(contents.toString('utf8'));
235
+ } catch {
236
+ throw new Error('package.json is not valid JSON.');
237
+ }
238
+ if (
239
+ !packageJson ||
240
+ typeof packageJson.scripts?.build !== 'string' ||
241
+ !packageJson.scripts.build.trim()
242
+ )
243
+ throw new Error(
244
+ 'Add a production build script to package.json before deploying.'
245
+ );
246
+ }
247
+ let compressed = deflateRawSync(contents, { level: 6 });
248
+ // The server rejects expansion ratios over 150. Store very repetitive files.
249
+ const method =
250
+ compressed.length < contents.length &&
251
+ contents.length / Math.max(1, compressed.length) <= 140
252
+ ? 8
253
+ : 0;
254
+ if (!method) compressed = contents;
255
+ const encoded = Buffer.from(name),
256
+ crc = crc32(contents);
257
+ const header = Buffer.alloc(30),
258
+ record = Buffer.alloc(46);
259
+ header.writeUInt32LE(0x04034b50);
260
+ header.writeUInt16LE(20, 4);
261
+ header.writeUInt16LE(0x800, 6);
262
+ header.writeUInt16LE(method, 8);
263
+ header.writeUInt16LE(33, 12);
264
+ header.writeUInt32LE(crc, 14);
265
+ header.writeUInt32LE(compressed.length, 18);
266
+ header.writeUInt32LE(contents.length, 22);
267
+ header.writeUInt16LE(encoded.length, 26);
268
+ record.writeUInt32LE(0x02014b50);
269
+ record.writeUInt16LE(0x0314, 4);
270
+ record.writeUInt16LE(20, 6);
271
+ record.writeUInt16LE(0x800, 8);
272
+ record.writeUInt16LE(method, 10);
273
+ record.writeUInt16LE(33, 14);
274
+ record.writeUInt32LE(crc, 16);
275
+ record.writeUInt32LE(compressed.length, 20);
276
+ record.writeUInt32LE(contents.length, 24);
277
+ record.writeUInt16LE(encoded.length, 28);
278
+ record.writeUInt32LE(0x81a40000, 38);
279
+ record.writeUInt32LE(offset, 42);
280
+ offset += header.length + encoded.length + compressed.length;
281
+ centralSize += record.length + encoded.length;
282
+ if (offset + centralSize + 22 > maxArchiveBytes)
283
+ throw new Error(
284
+ `Source archive exceeds ${(maxArchiveBytes / MiB).toFixed(0)} MiB. Exclude unnecessary files in .prnextignore.`
285
+ );
286
+ await append(header, encoded, compressed);
287
+ central.push(record, encoded);
288
+ files.push(name);
289
+ expanded += contents.length;
290
+ }
291
+ }
292
+ await walk();
293
+ if (!packageJson)
294
+ throw new Error(
295
+ 'The selected directory needs a package.json with a build script; do not exclude it.'
296
+ );
297
+ const end = Buffer.alloc(22);
298
+ end.writeUInt32LE(0x06054b50);
299
+ end.writeUInt16LE(files.length, 8);
300
+ end.writeUInt16LE(files.length, 10);
301
+ end.writeUInt32LE(centralSize, 12);
302
+ end.writeUInt32LE(offset, 16);
303
+ await append(...central, end);
304
+ const buffer = output ? undefined : Buffer.concat(chunks);
305
+ return {
306
+ buffer,
307
+ path: outputFile,
308
+ size: offset + centralSize + 22,
309
+ files,
310
+ expandedBytes: expanded,
311
+ excluded,
312
+ digest: hash.digest('hex'),
313
+ packageJson
314
+ };
315
+ } finally { await output?.close(); }
316
+ }