@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.
- package/README.md +395 -29
- package/build/actions.mjs +4 -0
- package/build/app-config.mjs +2 -0
- package/build/app.mjs +20 -5
- package/build/bundle-runtime.mjs +82 -0
- package/build/cache-handlers.mjs +4 -1
- package/build/compiler.mjs +4 -1
- package/build/config.mjs +20 -2
- package/build/font-cache.mjs +50 -0
- package/build/font-files.mjs +5 -3
- package/build/fonts.mjs +1 -1
- package/build/index.mjs +12 -1
- package/build/metadata-conflicts.mjs +13 -0
- package/build/metadata-images.mjs +3 -3
- package/build/metadata-routes.mjs +3 -3
- package/build/native-packages.mjs +5 -1
- package/build/npm-packages.mjs +14 -2
- package/build/optimize-imports.mjs +112 -0
- package/build/proxy-body-config.mjs +10 -0
- package/build/runtime-contract.mjs +16 -0
- package/build/scan.mjs +9 -7
- package/build/typescript-check.mjs +20 -0
- package/build/typescript.mjs +49 -0
- package/build/worker-retirement.mjs +14 -0
- package/cli.mjs +17 -0
- package/compat/after.cjs +51 -0
- package/compat/index.d.ts +13 -1
- package/compat/server.cjs +2 -1
- package/compat/server.d.cts +2 -0
- package/native/launch.mjs +19 -0
- package/package.json +25 -9
- package/release/verify.mjs +9 -0
- package/runtime/api.mjs +15 -5
- package/runtime/app-render.mjs +164 -53
- package/runtime/app-route-plan.mjs +45 -0
- package/runtime/app-routing-state.mjs +101 -21
- package/runtime/app-routing.mjs +37 -18
- package/runtime/app-segments.mjs +25 -11
- package/runtime/app-static.mjs +1 -1
- package/runtime/app-stream-html.mjs +3 -53
- package/runtime/cache-request.mjs +10 -2
- package/runtime/cloud/archive.mjs +316 -0
- package/runtime/cloud/client.mjs +658 -0
- package/runtime/cloud/environment.mjs +60 -0
- package/runtime/cloud/state.mjs +141 -0
- package/runtime/cloud/upload.mjs +48 -0
- package/runtime/html-envelope.mjs +136 -0
- package/runtime/http.mjs +9 -6
- package/runtime/middleware.mjs +3 -3
- package/runtime/profiles.json +13 -8
- package/runtime/request-transport.mjs +1 -1
- package/runtime/request-work.mjs +2 -2
- package/runtime/route-static.mjs +1 -1
- package/runtime/rsc-lifecycle.mjs +119 -0
- package/runtime/rsc-work-watchdog.mjs +75 -0
- package/runtime/rsc-worker.mjs +36 -9
- package/runtime/stream-utils.mjs +22 -3
- package/runtime/transport.mjs +89 -53
- package/runtime/worker-metadata.mjs +23 -0
- package/runtime/worker.mjs +119 -27
package/runtime/app-routing.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 (!
|
|
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:
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
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
|
|
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.
|
|
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;
|
package/runtime/app-segments.mjs
CHANGED
|
@@ -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
|
|
7
|
-
if (
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
if (!
|
|
17
|
-
const
|
|
18
|
-
if (!
|
|
19
|
-
|
|
20
|
-
|
|
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
|
}
|
package/runtime/app-static.mjs
CHANGED
|
@@ -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
|
|
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
|
|
24
|
+
finalizeCache };
|
|
18
25
|
} catch (error) {
|
|
19
|
-
|
|
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
|
+
}
|