@thomas.f/prnext 0.1.0-alpha.1
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 +54 -0
- package/build/action-exports.mjs +107 -0
- package/build/actions.mjs +214 -0
- package/build/app-config.mjs +138 -0
- package/build/app-routing.mjs +119 -0
- package/build/app-static.mjs +158 -0
- package/build/app.mjs +536 -0
- package/build/cache-components.mjs +91 -0
- package/build/cache-handlers.mjs +54 -0
- package/build/check.mjs +40 -0
- package/build/compiler.mjs +222 -0
- package/build/config.mjs +193 -0
- package/build/css-modules.mjs +114 -0
- package/build/custom-routes.mjs +363 -0
- package/build/dev-worker.mjs +18 -0
- package/build/dev.mjs +76 -0
- package/build/directory-cache.mjs +29 -0
- package/build/dynamic.mjs +125 -0
- package/build/edge.mjs +141 -0
- package/build/font-data.LICENSE.txt +25 -0
- package/build/font-data.json +1 -0
- package/build/font-files.mjs +221 -0
- package/build/fonts.mjs +131 -0
- package/build/framework-imports.mjs +17 -0
- package/build/i18n.mjs +39 -0
- package/build/images-config.mjs +62 -0
- package/build/images.mjs +86 -0
- package/build/incremental.mjs +76 -0
- package/build/index.mjs +356 -0
- package/build/loader-cache.mjs +69 -0
- package/build/metadata-images.mjs +72 -0
- package/build/metadata-routes.mjs +77 -0
- package/build/middleware.mjs +231 -0
- package/build/migrate.mjs +289 -0
- package/build/module-resolution.mjs +78 -0
- package/build/native-discovery.mjs +134 -0
- package/build/native-packages.mjs +105 -0
- package/build/npm-packages.mjs +110 -0
- package/build/og.mjs +14 -0
- package/build/page-extensions.mjs +12 -0
- package/build/passthrough-loader.cjs +4 -0
- package/build/precompress.mjs +122 -0
- package/build/scan.mjs +178 -0
- package/build/script-workers.mjs +25 -0
- package/build/server-actions-config.mjs +27 -0
- package/build/source-maps.mjs +34 -0
- package/build/standalone-config.mjs +19 -0
- package/build/standalone-runner.mjs +21 -0
- package/build/standalone-worker.mjs +5 -0
- package/build/standalone.mjs +240 -0
- package/build/static-export.mjs +93 -0
- package/build/styles.mjs +174 -0
- package/build/transform-cache.mjs +50 -0
- package/build/transform.mjs +155 -0
- package/build/turbopack-conditions.mjs +59 -0
- package/build/webpack-bridge.cjs +3 -0
- package/build/webpack-graph.mjs +256 -0
- package/build/webpack-loader.cjs +9 -0
- package/cli.mjs +210 -0
- package/compat/app-client-page.cjs +32 -0
- package/compat/app-context.cjs +40 -0
- package/compat/app-context.d.cts +20 -0
- package/compat/app-dynamic.cjs +34 -0
- package/compat/app-error-boundary.cjs +53 -0
- package/compat/app-global-error-boundary.cjs +34 -0
- package/compat/app-layout-context.cjs +27 -0
- package/compat/app-navigation-boundary.cjs +47 -0
- package/compat/app.cjs +33 -0
- package/compat/app.d.cts +9 -0
- package/compat/cache-handlers.cjs +223 -0
- package/compat/cache-life.cjs +34 -0
- package/compat/cache.cjs +99 -0
- package/compat/cache.d.cts +36 -0
- package/compat/compat/router.d.cts +2 -0
- package/compat/compat-router.cjs +7 -0
- package/compat/constants.cjs +8 -0
- package/compat/constants.d.cts +7 -0
- package/compat/cookies.cjs +129 -0
- package/compat/data-cache.cjs +389 -0
- package/compat/dist/shared/lib/app-router-context.shared-runtime.d.cts +3 -0
- package/compat/dist/shared/lib/router-context.d.cts +1 -0
- package/compat/dist/shared/lib/router-context.shared-runtime.d.cts +3 -0
- package/compat/document.cjs +73 -0
- package/compat/document.d.cts +41 -0
- package/compat/draft.cjs +28 -0
- package/compat/dynamic-bailout.cjs +13 -0
- package/compat/dynamic.cjs +119 -0
- package/compat/dynamic.d.cts +32 -0
- package/compat/error.cjs +36 -0
- package/compat/error.d.cts +15 -0
- package/compat/fetch.d.cts +10 -0
- package/compat/font/google.d.cts +1 -0
- package/compat/font/local.d.cts +2 -0
- package/compat/font-google.cjs +2 -0
- package/compat/font-google.d.cts +1944 -0
- package/compat/font-local.cjs +5 -0
- package/compat/font-local.d.cts +3 -0
- package/compat/font-types.d.cts +25 -0
- package/compat/head.cjs +180 -0
- package/compat/head.d.cts +3 -0
- package/compat/headers.cjs +89 -0
- package/compat/headers.d.cts +26 -0
- package/compat/image-config.cjs +3 -0
- package/compat/image-imports.d.ts +12 -0
- package/compat/image-shared.cjs +62 -0
- package/compat/image.cjs +64 -0
- package/compat/image.d.cts +35 -0
- package/compat/incremental-cache.cjs +186 -0
- package/compat/index.d.ts +159 -0
- package/compat/instant-samples.cjs +39 -0
- package/compat/instant.cjs +13 -0
- package/compat/link.cjs +68 -0
- package/compat/link.d.cts +17 -0
- package/compat/locale.cjs +30 -0
- package/compat/metadata.d.cts +77 -0
- package/compat/navigation-controls.cjs +27 -0
- package/compat/navigation-server.cjs +25 -0
- package/compat/navigation.cjs +52 -0
- package/compat/navigation.d.cts +28 -0
- package/compat/next-app-router-context.cjs +6 -0
- package/compat/next-router-context.cjs +3 -0
- package/compat/og.cjs +32 -0
- package/compat/og.d.cts +1 -0
- package/compat/paths.cjs +33 -0
- package/compat/preview.cjs +41 -0
- package/compat/rewrite.cjs +41 -0
- package/compat/route-request.cjs +64 -0
- package/compat/router.cjs +137 -0
- package/compat/router.d.cts +58 -0
- package/compat/script-context.cjs +5 -0
- package/compat/script-loader.cjs +135 -0
- package/compat/script.cjs +69 -0
- package/compat/script.d.cts +17 -0
- package/compat/server-primitives.cjs +109 -0
- package/compat/server.cjs +13 -0
- package/compat/server.d.cts +37 -0
- package/compat/static-generation.cjs +108 -0
- package/compat/use-cache.cjs +129 -0
- package/index.mjs +1 -0
- package/native/platforms.json +6 -0
- package/native/resolve.mjs +81 -0
- package/package.json +127 -0
- package/release/verify.mjs +59 -0
- package/runtime/action-client.mjs +19 -0
- package/runtime/action-crypto.mjs +138 -0
- package/runtime/action-reference.mjs +2 -0
- package/runtime/action-server.mjs +161 -0
- package/runtime/action-ssr.mjs +5 -0
- package/runtime/api.mjs +326 -0
- package/runtime/app-client.mjs +539 -0
- package/runtime/app-content.mjs +8 -0
- package/runtime/app-errors.mjs +12 -0
- package/runtime/app-instant.mjs +30 -0
- package/runtime/app-metadata.mjs +410 -0
- package/runtime/app-partial-model.mjs +228 -0
- package/runtime/app-partial.mjs +130 -0
- package/runtime/app-prefetch.mjs +34 -0
- package/runtime/app-recovery.mjs +87 -0
- package/runtime/app-render.mjs +544 -0
- package/runtime/app-routing-access.mjs +143 -0
- package/runtime/app-routing-context.mjs +35 -0
- package/runtime/app-routing-state.mjs +61 -0
- package/runtime/app-routing.mjs +323 -0
- package/runtime/app-segments.mjs +22 -0
- package/runtime/app-static-params.mjs +120 -0
- package/runtime/app-static.mjs +86 -0
- package/runtime/app-stream-channel.mjs +92 -0
- package/runtime/app-stream-html.mjs +279 -0
- package/runtime/build-directory.mjs +48 -0
- package/runtime/cache-arguments.mjs +33 -0
- package/runtime/cache-components-rsc.mjs +64 -0
- package/runtime/cache-request.mjs +23 -0
- package/runtime/client-navigation.mjs +40 -0
- package/runtime/dev-client.mjs +129 -0
- package/runtime/dev-file-changes.mjs +110 -0
- package/runtime/document.mjs +96 -0
- package/runtime/edge.mjs +105 -0
- package/runtime/env.mjs +68 -0
- package/runtime/fetch-cache.mjs +379 -0
- package/runtime/font-preload.mjs +4 -0
- package/runtime/http.mjs +190 -0
- package/runtime/incremental-cache.mjs +83 -0
- package/runtime/metadata-route.mjs +83 -0
- package/runtime/middleware.mjs +186 -0
- package/runtime/module-loader.mjs +21 -0
- package/runtime/navigation.mjs +16 -0
- package/runtime/og-loader.mjs +17 -0
- package/runtime/pages-client.mjs +197 -0
- package/runtime/pages-error-props.mjs +6 -0
- package/runtime/pages-errors.mjs +85 -0
- package/runtime/pages-initial-props.mjs +43 -0
- package/runtime/pages-navigation.mjs +565 -0
- package/runtime/pages-paths.mjs +80 -0
- package/runtime/pages-revalidate.mjs +33 -0
- package/runtime/partial-artifact-cache.mjs +37 -0
- package/runtime/partial-template.mjs +174 -0
- package/runtime/profile.mjs +9 -0
- package/runtime/profiles.json +47 -0
- package/runtime/render.mjs +304 -0
- package/runtime/request-transport.mjs +41 -0
- package/runtime/request-work.mjs +7 -0
- package/runtime/route-static.mjs +117 -0
- package/runtime/rsc-worker.mjs +426 -0
- package/runtime/script-html.mjs +61 -0
- package/runtime/stream-select.mjs +25 -0
- package/runtime/stream-utils.mjs +127 -0
- package/runtime/transport.mjs +108 -0
- package/runtime/watch-project.mjs +130 -0
- package/runtime/worker-artifacts.mjs +22 -0
- package/runtime/worker-metadata.mjs +31 -0
- package/runtime/worker.mjs +197 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<a href="https://prnext.dev"><img src="https://prnext.dev/favicon.svg" alt="PRNext logo" width="80" height="80"></a>
|
|
2
|
+
|
|
3
|
+
# PRNext
|
|
4
|
+
|
|
5
|
+
[Website](https://prnext.dev) · [GitHub](https://github.com/Lorkydey/PRNext) · [Report an issue](https://github.com/Lorkydey/PRNext/issues)
|
|
6
|
+
|
|
7
|
+
A Rust-powered runtime for Next.js projects, with React and npm modules running on Node.js.
|
|
8
|
+
|
|
9
|
+
> **Very early alpha — 0.1.0-alpha.1**
|
|
10
|
+
>
|
|
11
|
+
> Built for experimentation and feedback, not production applications. Next.js compatibility is partial. Expect bugs, missing features and breaking changes.
|
|
12
|
+
|
|
13
|
+
## Install and migrate
|
|
14
|
+
|
|
15
|
+
Node.js 22+ is required. This alpha uses matching React, React DOM and React Server Components versions:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install @thomas.f/prnext@alpha react@19.3.0 react-dom@19.3.0 react-server-dom-webpack@19.3.0
|
|
19
|
+
npx --no-install prn migrate --dry-run
|
|
20
|
+
npx --no-install prn migrate
|
|
21
|
+
npx --no-install prn check
|
|
22
|
+
npx --no-install prn dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The package provides both `prnext` and its shortcut `prn`. Migration backs up your package files and keeps Next.js and your application source. The preflight check does not guarantee full compatibility with your application.
|
|
26
|
+
|
|
27
|
+
## Build and run
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npx --no-install prn build
|
|
31
|
+
npx --no-install prn start
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Balanced is the default production profile. Choose a profile with:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npx --no-install prn start --profile balanced
|
|
38
|
+
npx --no-install prn start --profile speed
|
|
39
|
+
npx --no-install prn start --profile memory
|
|
40
|
+
npx --no-install prn start --profile classic
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Platforms
|
|
44
|
+
|
|
45
|
+
Prebuilt packages support macOS arm64/x64 and Linux glibc arm64/x64. Use WSL on Windows. Alpine/musl and native Windows do not yet have prebuilt packages. Linux binaries are built on Ubuntu 22.04 with glibc 2.35.
|
|
46
|
+
|
|
47
|
+
Keep npm optional dependencies enabled: PRNext automatically installs the binary for your platform. Rust is not required to use the published package.
|
|
48
|
+
|
|
49
|
+
## Links and feedback
|
|
50
|
+
|
|
51
|
+
- Website and benchmarks: [prnext.dev](https://prnext.dev)
|
|
52
|
+
- Source and contributions: [Lorkydey/PRNext](https://github.com/Lorkydey/PRNext)
|
|
53
|
+
- Bug reports: [GitHub Issues](https://github.com/Lorkydey/PRNext/issues)
|
|
54
|
+
- Questions and suggestions: [contact@heythomas.dev](mailto:contact@heythomas.dev)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {generateMapped} from './source-maps.mjs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parse } from '@babel/parser';
|
|
5
|
+
import generateImport from '@babel/generator';
|
|
6
|
+
import * as t from '@babel/types';
|
|
7
|
+
|
|
8
|
+
const generate = generateImport.default || generateImport;
|
|
9
|
+
const astFor = (source, file) => parse(source, { sourceType: 'module', sourceFilename: file, plugins: ['jsx', ...(/\.[cm]?tsx?$/.test(file) ? ['typescript'] : [])] });
|
|
10
|
+
const exportName = node => node.name ?? node.value;
|
|
11
|
+
|
|
12
|
+
/** Resolve wildcard exports statically, including cycles and ESM ambiguity rules. */
|
|
13
|
+
export async function expandActionExports(source, file, resolve) {
|
|
14
|
+
if (!source.includes('use server') || !source.includes('export') || !source.includes('*')) return source;
|
|
15
|
+
const ast = astFor(source, file);
|
|
16
|
+
if (!ast.program.directives.some(item => item.value.value === 'use server') || !ast.program.body.some(item => t.isExportAllDeclaration(item) && item.exportKind !== 'type')) return source;
|
|
17
|
+
const graph = new Map([[file, { source, ast }]]);
|
|
18
|
+
const pending = [file];
|
|
19
|
+
let sourceBytes = 0;
|
|
20
|
+
for (let index = 0; index < pending.length; index++) {
|
|
21
|
+
const filename = pending[index];
|
|
22
|
+
const node = graph.get(filename);
|
|
23
|
+
node.source ??= await readFile(filename, 'utf8');
|
|
24
|
+
sourceBytes += Buffer.byteLength(node.source);
|
|
25
|
+
if (sourceBytes > 2 * 1024 * 1024) throw new Error('Server Action export graph exceeds 2 MiB');
|
|
26
|
+
node.ast ??= astFor(node.source, filename);
|
|
27
|
+
node.explicit = new Map(); node.stars = []; node.locals = new Map();
|
|
28
|
+
const imports = new Map(), resolutions = new Map();
|
|
29
|
+
async function target(specifier) {
|
|
30
|
+
if (resolutions.has(specifier)) return resolutions.get(specifier);
|
|
31
|
+
const result = await resolve(specifier, filename);
|
|
32
|
+
if (!result || !path.isAbsolute(result)) throw new Error(`Cannot enumerate Server Action exports from ${specifier} in ${filename}; use explicit named exports`);
|
|
33
|
+
if (!graph.has(result)) {
|
|
34
|
+
if (graph.size >= 256) throw new Error('Server Action export graph exceeds 256 modules');
|
|
35
|
+
graph.set(result, {}); pending.push(result);
|
|
36
|
+
}
|
|
37
|
+
resolutions.set(specifier, result);
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
for (const item of node.ast.program.body) {
|
|
41
|
+
const declaration = t.isExportNamedDeclaration(item) || t.isExportDefaultDeclaration(item) ? item.declaration : item;
|
|
42
|
+
if (t.isFunctionDeclaration(declaration) && declaration.id) node.locals.set(declaration.id.name, { id: filename + '#' + declaration.id.name, async: declaration.async && !declaration.generator });
|
|
43
|
+
if (t.isVariableDeclaration(declaration)) for (const binding of declaration.declarations) if (t.isIdentifier(binding.id)) {
|
|
44
|
+
node.locals.set(binding.id.name, t.isIdentifier(binding.init) ? { local: binding.init.name } :
|
|
45
|
+
{ id: filename + '#' + binding.id.name, ...(t.isFunction(binding.init) ? { async: binding.init.async && !binding.init.generator } : t.isLiteral(binding.init) || t.isObjectExpression(binding.init) || t.isArrayExpression(binding.init) || t.isClassExpression(binding.init) ? { async: false } : {}) });
|
|
46
|
+
}
|
|
47
|
+
if (t.isClassDeclaration(declaration) && declaration.id) node.locals.set(declaration.id.name, { id: filename + '#' + declaration.id.name, async: false });
|
|
48
|
+
if (t.isImportDeclaration(item) && item.importKind !== 'type') for (const binding of item.specifiers) {
|
|
49
|
+
if (binding.importKind === 'type') continue;
|
|
50
|
+
imports.set(binding.local.name, { source: item.source.value, name: t.isImportDefaultSpecifier(binding) ? 'default' : t.isImportNamespaceSpecifier(binding) ? '*' : exportName(binding.imported) });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function local(name, seen = new Set()) {
|
|
54
|
+
if (seen.has(name)) return { id: filename + '#' + name };
|
|
55
|
+
seen.add(name);
|
|
56
|
+
if (imports.has(name)) { const imported = imports.get(name); return { file: await target(imported.source), name: imported.name }; }
|
|
57
|
+
const value = node.locals.get(name);
|
|
58
|
+
return value?.local ? local(value.local, seen) : value || { id: filename + '#' + name };
|
|
59
|
+
}
|
|
60
|
+
for (const item of node.ast.program.body) {
|
|
61
|
+
if (item.exportKind === 'type') continue;
|
|
62
|
+
if (t.isExportAllDeclaration(item)) { node.stars.push({ source: item.source.value, file: await target(item.source.value), node: item }); continue; }
|
|
63
|
+
if (t.isExportDefaultDeclaration(item)) {
|
|
64
|
+
node.explicit.set('default', t.isIdentifier(item.declaration) ? await local(item.declaration.name) : { id: filename + '#default', ...(t.isFunction(item.declaration) ? { async: item.declaration.async && !item.declaration.generator } : {}) });
|
|
65
|
+
}
|
|
66
|
+
if (!t.isExportNamedDeclaration(item)) continue;
|
|
67
|
+
if (item.declaration && !t.isTSInterfaceDeclaration(item.declaration) && !t.isTSTypeAliasDeclaration(item.declaration) && !item.declaration.declare) {
|
|
68
|
+
for (const name of Object.keys(t.getOuterBindingIdentifiers(item.declaration))) node.explicit.set(name, await local(name));
|
|
69
|
+
}
|
|
70
|
+
for (const binding of item.specifiers) if (binding.exportKind !== 'type') {
|
|
71
|
+
node.explicit.set(exportName(binding.exported), item.source ? { file: await target(item.source.value), name: binding.local ? exportName(binding.local) : '*' } : await local(binding.local.name));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const names = new Set([...graph.values()].flatMap(node => [...node.explicit.keys()]));
|
|
76
|
+
if (names.size > 4096) throw new Error('Server Action export graph exceeds 4096 names');
|
|
77
|
+
function resolveExport(filename, name, seen = new Set()) {
|
|
78
|
+
const identity = filename + '\0' + name;
|
|
79
|
+
if (seen.has(identity)) return undefined;
|
|
80
|
+
// ESM ResolveExport shares resolveSet across sibling branches, not just
|
|
81
|
+
// ancestors. Visit each module/name once per resolution, including diamonds.
|
|
82
|
+
seen.add(identity);
|
|
83
|
+
const node = graph.get(filename), own = node.explicit.get(name);
|
|
84
|
+
if (own) return own.file ? own.name === '*' ? { id: own.file + '#namespace', async: false } : resolveExport(own.file, own.name, seen) : own;
|
|
85
|
+
if (name === 'default') return undefined;
|
|
86
|
+
let found;
|
|
87
|
+
for (const star of node.stars) {
|
|
88
|
+
const value = resolveExport(star.file, name, seen);
|
|
89
|
+
if (!value) continue;
|
|
90
|
+
if (value.ambiguous || (found && found.id !== value.id)) return { ambiguous: true };
|
|
91
|
+
found = value;
|
|
92
|
+
}
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
const root = graph.get(file), replacements = new Map(root.stars.map(star => [star.node, []]));
|
|
96
|
+
for (const name of names) {
|
|
97
|
+
if (name === 'default' || root.explicit.has(name)) continue;
|
|
98
|
+
const value = resolveExport(file, name);
|
|
99
|
+
if (!value || value.ambiguous) continue;
|
|
100
|
+
if (value.async === false) throw new Error(`Server Action export '${name}' must be an async function (${file}).`);
|
|
101
|
+
const origin = root.stars.find(star => resolveExport(star.file, name)?.id === value.id);
|
|
102
|
+
replacements.get(origin.node).push({ type: 'ExportSpecifier', local: t.isValidIdentifier(name) ? t.identifier(name) : t.stringLiteral(name), exported: t.stringLiteral(name) });
|
|
103
|
+
}
|
|
104
|
+
ast.program.body = ast.program.body.flatMap(item => replacements.has(item)
|
|
105
|
+
? replacements.get(item).length ? [t.exportNamedDeclaration(null, replacements.get(item), item.source)] : [] : [item]);
|
|
106
|
+
return generateMapped(generate, ast, { comments: true }, source, file);
|
|
107
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {cachedTransform} from './transform-cache.mjs';
|
|
2
|
+
import {generateMapped} from './source-maps.mjs';
|
|
3
|
+
import { parse } from '@babel/parser';
|
|
4
|
+
import traverseModule from '@babel/traverse';
|
|
5
|
+
import generateModule from '@babel/generator';
|
|
6
|
+
import * as t from '@babel/types';
|
|
7
|
+
import { createHmac, createHash, randomBytes } from 'node:crypto';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const traverse = traverseModule.default || traverseModule;
|
|
11
|
+
const generate = generateModule.default || generateModule;
|
|
12
|
+
function hasDirective(node,value) { return node?.directives?.some(item=>item.value.value===value); }
|
|
13
|
+
function removeDirective(node,value) { if(node?.directives) node.directives=node.directives.filter(item=>item.value.value!==value); }
|
|
14
|
+
|
|
15
|
+
export function createServerActions({ projectRoot }) {
|
|
16
|
+
const salt = randomBytes(32);
|
|
17
|
+
const modules = new Map();
|
|
18
|
+
const actions = new Map();
|
|
19
|
+
const configured = process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY;
|
|
20
|
+
if (configured && (!/^[A-Za-z0-9+/]+={0,2}$/.test(configured) || ![16,24,32].includes(Buffer.from(configured,'base64').length))) throw new Error('NEXT_SERVER_ACTIONS_ENCRYPTION_KEY must be a base64-encoded 16, 24, or 32 byte AES key.');
|
|
21
|
+
const actionKey = configured ? Buffer.from(configured,'base64').toString('base64') : randomBytes(32).toString('base64');
|
|
22
|
+
function identity(file,name) { return createHmac('sha256',salt).update(path.relative(projectRoot,file).replaceAll(path.sep,'/')+'\0'+name).digest('hex'); }
|
|
23
|
+
function register(file,name,binding) {
|
|
24
|
+
const id=identity(file,name);
|
|
25
|
+
if(!actions.has(id)) actions.set(id,{id,file,binding});
|
|
26
|
+
return id;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function analyze(source,file) {
|
|
30
|
+
const key=file+'\0'+createHash('sha256').update(source).digest('hex');
|
|
31
|
+
if(modules.has(key))return modules.get(key);
|
|
32
|
+
const template=JSON.parse(cachedTransform('server-action-analysis',source,[projectRoot,file],()=>JSON.stringify(analyzeTemplate(source,file))));
|
|
33
|
+
const ids=new Map(template.registrations.map(item=>[item.id,register(file,item.name,item.binding)]));
|
|
34
|
+
let server=template.server;
|
|
35
|
+
for(const [previous,current]of ids)server=server.replaceAll(previous,current);
|
|
36
|
+
const result={...template,server,declared:template.declared.map(item=>({...item,id:ids.get(item.id)}))};
|
|
37
|
+
delete result.registrations;
|
|
38
|
+
modules.set(key,result);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
function analyzeTemplate(source,file) {
|
|
42
|
+
const registrations=[],templateSalt=randomBytes(32);
|
|
43
|
+
function register(_file,name,binding) {
|
|
44
|
+
const id=createHmac('sha256',templateSalt).update(name).digest('hex');
|
|
45
|
+
registrations.push({name,binding,id});return id;
|
|
46
|
+
}
|
|
47
|
+
const ast=parse(source,{sourceType:'unambiguous',sourceFilename:file,plugins:['jsx',...(/\.tsx?$/.test(file)?['typescript']:[])]});
|
|
48
|
+
const moduleServer=hasDirective(ast.program,'use server');
|
|
49
|
+
const moduleClient=hasDirective(ast.program,'use client');
|
|
50
|
+
if(moduleServer&&moduleClient) throw new Error(`A module cannot use both 'use client' and 'use server': ${file}`);
|
|
51
|
+
let program;
|
|
52
|
+
traverse(ast,{Program(value){program=value;value.stop();}});
|
|
53
|
+
const registration=program.scope.generateUidIdentifier('registerServerReference');
|
|
54
|
+
const decrypt=program.scope.generateUidIdentifier('decryptBoundArgs');
|
|
55
|
+
const bind=program.scope.generateUidIdentifier('bindEncryptedReference');
|
|
56
|
+
const lifted=[];
|
|
57
|
+
const moduleExports=[];
|
|
58
|
+
let inlineCount=0;
|
|
59
|
+
let encrypted=false;
|
|
60
|
+
|
|
61
|
+
if(moduleServer) {
|
|
62
|
+
removeDirective(ast.program,'use server');
|
|
63
|
+
for(const statement of program.get('body')) {
|
|
64
|
+
if(statement.isExportAllDeclaration()) {
|
|
65
|
+
if(statement.node.exportKind==='type') { statement.remove(); continue; }
|
|
66
|
+
throw new Error(`Server Action wildcard exports must be resolved by the build graph (${file}).`);
|
|
67
|
+
}
|
|
68
|
+
if(statement.isExportDefaultDeclaration()) {
|
|
69
|
+
const declaration=statement.node.declaration;
|
|
70
|
+
const name=program.scope.generateUidIdentifier('defaultAction');
|
|
71
|
+
if(t.isFunctionDeclaration(declaration)) {
|
|
72
|
+
if(!declaration.async||declaration.generator) throw new Error(`Server Action default export must be an async function (${file}).`);
|
|
73
|
+
declaration.id ||= name;
|
|
74
|
+
moduleExports.push({name:'default',local:declaration.id.name});
|
|
75
|
+
statement.replaceWith(declaration);
|
|
76
|
+
} else {
|
|
77
|
+
if(t.isFunction(declaration)&&(!declaration.async||declaration.generator)) throw new Error(`Server Action default export must be an async function (${file}).`);
|
|
78
|
+
statement.replaceWith(t.variableDeclaration('const',[t.variableDeclarator(name,declaration)]));
|
|
79
|
+
moduleExports.push({name:'default',local:name.name});
|
|
80
|
+
}
|
|
81
|
+
} else if(statement.isExportNamedDeclaration()) {
|
|
82
|
+
if(statement.node.exportKind==='type') continue;
|
|
83
|
+
const declaration=statement.node.declaration;
|
|
84
|
+
if(declaration) {
|
|
85
|
+
if(t.isTSInterfaceDeclaration(declaration)||t.isTSTypeAliasDeclaration(declaration)||declaration.declare) continue;
|
|
86
|
+
for(const name of Object.keys(t.getOuterBindingIdentifiers(declaration))) moduleExports.push({name,local:name});
|
|
87
|
+
statement.replaceWith(declaration);
|
|
88
|
+
} else {
|
|
89
|
+
const imports=[];
|
|
90
|
+
for(const specifier of statement.node.specifiers) {
|
|
91
|
+
if(specifier.exportKind==='type') continue;
|
|
92
|
+
const name=specifier.exported.name??specifier.exported.value;
|
|
93
|
+
let local=specifier.local?.name;
|
|
94
|
+
if(statement.node.source) {
|
|
95
|
+
const binding=program.scope.generateUidIdentifier('importedAction');
|
|
96
|
+
imports.push(t.importSpecifier(binding,t.cloneNode(specifier.local)));
|
|
97
|
+
local=binding.name;
|
|
98
|
+
}
|
|
99
|
+
if(!local) throw new Error(`Unsupported Server Action export ${name} (${file}).`);
|
|
100
|
+
moduleExports.push({name,local});
|
|
101
|
+
}
|
|
102
|
+
if(imports.length) statement.replaceWith(t.importDeclaration(imports,statement.node.source)); else statement.remove();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
program.scope.crawl();
|
|
107
|
+
for(const item of moduleExports) {
|
|
108
|
+
let binding=program.scope.getBinding(item.local);
|
|
109
|
+
let value;
|
|
110
|
+
const visited=new Set();
|
|
111
|
+
while(binding&&!visited.has(binding)) {
|
|
112
|
+
visited.add(binding);
|
|
113
|
+
value=binding.path.isVariableDeclarator()?binding.path.node.init:binding.path.node;
|
|
114
|
+
if(!t.isIdentifier(value)) break;
|
|
115
|
+
binding=program.scope.getBinding(value.name);
|
|
116
|
+
}
|
|
117
|
+
if(value&&!t.isImportSpecifier(value)&&!t.isImportDefaultSpecifier(value)&&(!t.isFunction(value)||!value.async||value.generator)) throw new Error(`Server Action export '${item.name}' must be an async function (${file}).`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
traverse(ast,{Function:{exit(functionPath){
|
|
122
|
+
if(!hasDirective(functionPath.node.body,'use server')) return;
|
|
123
|
+
if(moduleClient) throw new Error(`Inline 'use server' functions cannot be defined in a Client Component (${file}); import an action from a module marked 'use server'.`);
|
|
124
|
+
if(!functionPath.node.async||functionPath.node.generator) throw new Error(`Inline Server Actions must be async functions (${file}).`);
|
|
125
|
+
if(functionPath.isObjectMethod()||functionPath.isClassMethod()||functionPath.isClassPrivateMethod()) throw new Error(`Server Actions must be functions, not object or class methods (${file}).`);
|
|
126
|
+
removeDirective(functionPath.node.body,'use server');
|
|
127
|
+
const captures=new Map();
|
|
128
|
+
function capture(reference,name) {
|
|
129
|
+
if(name==='arguments'&&functionPath.isArrowFunctionExpression()) throw new Error(`Inline arrow Server Actions cannot capture arguments (${file}).`);
|
|
130
|
+
const binding=reference.scope.getBinding(name);
|
|
131
|
+
if(!binding||binding.scope===program.scope) return;
|
|
132
|
+
if(binding.path===functionPath||binding.path.node===functionPath.node) return;
|
|
133
|
+
if(binding.scope===functionPath.scope||binding.scope.path.findParent(parent=>parent===functionPath)) return;
|
|
134
|
+
captures.set(name,binding.kind==='const'?'const':'let');
|
|
135
|
+
}
|
|
136
|
+
functionPath.traverse({
|
|
137
|
+
ReferencedIdentifier(reference){ capture(reference,reference.node.name); },
|
|
138
|
+
AssignmentExpression(reference){for(const name of Object.keys(t.getBindingIdentifiers(reference.node.left)))capture(reference,name);},
|
|
139
|
+
UpdateExpression(reference){if(t.isIdentifier(reference.node.argument))capture(reference,reference.node.argument.name);},
|
|
140
|
+
ThisExpression(reference){ if(functionPath.isArrowFunctionExpression()) throw new Error(`Inline arrow Server Actions cannot capture this (${file}).`); },
|
|
141
|
+
});
|
|
142
|
+
const names=[...captures.keys()];
|
|
143
|
+
const exported=program.scope.generateUidIdentifier('inlineAction');
|
|
144
|
+
const id=register(file,`inline:${functionPath.node.start}:${inlineCount++}`,exported.name);
|
|
145
|
+
const args=program.scope.generateUidIdentifier('actionArgs');
|
|
146
|
+
const cipher=program.scope.generateUidIdentifier('encryptedClosure');
|
|
147
|
+
const original=t.cloneNode(functionPath.node,true);
|
|
148
|
+
if(t.isFunctionDeclaration(original)) original.type='FunctionExpression';
|
|
149
|
+
const body=[];
|
|
150
|
+
if(names.length) {
|
|
151
|
+
encrypted=true;
|
|
152
|
+
const values=program.scope.generateUidIdentifier('closureValues');
|
|
153
|
+
body.push(t.variableDeclaration('const',[t.variableDeclarator(values,t.awaitExpression(t.callExpression(decrypt,[t.stringLiteral(id),cipher])))]));
|
|
154
|
+
for(const [index,name] of names.entries()) body.push(t.variableDeclaration(captures.get(name),[t.variableDeclarator(t.identifier(name),t.memberExpression(values,t.numericLiteral(index),true))]));
|
|
155
|
+
}
|
|
156
|
+
body.push(t.returnStatement(t.callExpression(t.memberExpression(original,t.identifier('apply')),[t.unaryExpression('void',t.numericLiteral(0)),args])));
|
|
157
|
+
lifted.push(t.exportNamedDeclaration(t.functionDeclaration(exported,[...(names.length?[cipher]:[]),t.restElement(args)],t.blockStatement(body),false,true)));
|
|
158
|
+
lifted.push(t.expressionStatement(t.callExpression(registration,[exported,t.stringLiteral(id),t.nullLiteral()])));
|
|
159
|
+
function reference(value) {
|
|
160
|
+
const registered=t.callExpression(registration,[value,t.stringLiteral(id),t.nullLiteral()]);
|
|
161
|
+
return names.length?t.callExpression(bind,[registered,t.stringLiteral(id),t.arrowFunctionExpression([],t.arrayExpression(names.map(name=>t.identifier(name))))]):registered;
|
|
162
|
+
}
|
|
163
|
+
if(functionPath.isFunctionDeclaration()) {
|
|
164
|
+
if(!functionPath.node.id) throw new Error(`Anonymous inline Server Actions must be expressions (${file}).`);
|
|
165
|
+
let parent=functionPath.parentPath;
|
|
166
|
+
if(parent.isExportNamedDeclaration()||parent.isExportDefaultDeclaration()) parent=parent.parentPath;
|
|
167
|
+
if(!parent.isBlockStatement()&&!parent.isProgram()) throw new Error(`Inline Server Action declarations need a block scope (${file}).`);
|
|
168
|
+
parent.unshiftContainer('body',t.expressionStatement(reference(t.cloneNode(functionPath.node.id))));
|
|
169
|
+
} else {
|
|
170
|
+
functionPath.replaceWith(reference(t.cloneNode(functionPath.node,true)));
|
|
171
|
+
functionPath.skip();
|
|
172
|
+
}
|
|
173
|
+
}}});
|
|
174
|
+
const declared=moduleExports.map(item=>({...item,id:register(file,`export:${item.name}`,item.name)}));
|
|
175
|
+
if(!moduleServer&&!inlineCount) { return {server:source,declared:[],inline:false,registrations}; }
|
|
176
|
+
const imports=[t.importDeclaration([t.importSpecifier(registration,t.identifier('registerServerReference'))],t.stringLiteral('react-server-dom-webpack/server.node'))];
|
|
177
|
+
if(encrypted) imports.push(t.importDeclaration([t.importSpecifier(decrypt,t.identifier('decryptBoundArgs')),t.importSpecifier(bind,t.identifier('bindEncryptedReference'))],t.stringLiteral('prnext-internal:action-crypto')));
|
|
178
|
+
ast.program.body.unshift(...imports);
|
|
179
|
+
ast.program.body.push(...lifted);
|
|
180
|
+
// Internal references to exported actions must retain React's server marker
|
|
181
|
+
// and patched bind method too. Keep an existing marker on imported actions;
|
|
182
|
+
// each public alias still has its own stable proxy/manifest registration.
|
|
183
|
+
const registeredLocals=new Set();
|
|
184
|
+
for(const item of declared) {
|
|
185
|
+
if(registeredLocals.has(item.local)) continue;
|
|
186
|
+
registeredLocals.add(item.local);
|
|
187
|
+
ast.program.body.push(t.ifStatement(
|
|
188
|
+
t.binaryExpression('!==',t.memberExpression(t.identifier(item.local),t.identifier('$$typeof')),t.callExpression(t.memberExpression(t.identifier('Symbol'),t.identifier('for')),[t.stringLiteral('react.server.reference')])),
|
|
189
|
+
t.expressionStatement(t.callExpression(registration,[t.identifier(item.local),t.stringLiteral(item.id),t.nullLiteral()]))
|
|
190
|
+
));
|
|
191
|
+
}
|
|
192
|
+
for(const item of declared) {
|
|
193
|
+
const exported=program.scope.generateUidIdentifier('serverAction');
|
|
194
|
+
const args=program.scope.generateUidIdentifier('args');
|
|
195
|
+
const wrapper=t.arrowFunctionExpression([t.restElement(args)],t.callExpression(t.identifier(item.local),[t.spreadElement(args)]),true);
|
|
196
|
+
ast.program.body.push(t.variableDeclaration('const',[t.variableDeclarator(exported,t.callExpression(registration,[wrapper,t.stringLiteral(item.id),t.nullLiteral()]))]));
|
|
197
|
+
ast.program.body.push(t.exportNamedDeclaration(null,[t.exportSpecifier(exported,t.stringLiteral(item.name))]));
|
|
198
|
+
}
|
|
199
|
+
const result={server:generateMapped(generate,ast,{comments:true},source,file),declared,inline:inlineCount>0,moduleServer,registrations};
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
actions, actionKey,
|
|
205
|
+
transform(source,file,mode) {
|
|
206
|
+
if(!source.includes('use server')) return source;
|
|
207
|
+
const result=analyze(source,file);
|
|
208
|
+
if(mode==='rsc') return result.server;
|
|
209
|
+
if(result.moduleServer) return `import {createServerReference} from 'prnext-internal:action-${mode==='browser'?'client':'ssr'}';\n`+result.declared.map((item,index)=>`const action${index}=createServerReference(${JSON.stringify(item.id)});export {action${index} as ${JSON.stringify(item.name)}};`).join('\n');
|
|
210
|
+
if(result.inline) throw new Error(`Inline Server Actions cannot be imported into the client graph (${file}); move the action to a module marked 'use server'.`);
|
|
211
|
+
return source;
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parse } from '@babel/parser';
|
|
3
|
+
import {validSamples} from '../compat/instant-samples.cjs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const parsedConfigs = new Map();
|
|
7
|
+
let parsedBytes = 0, parsedHits = 0, parsedMisses = 0;
|
|
8
|
+
export const appConfigCacheStats = () => ({ entries: parsedConfigs.size, bytes: parsedBytes, hits: parsedHits, misses: parsedMisses });
|
|
9
|
+
|
|
10
|
+
// Platform and cache modes without implemented semantics fail during scanning.
|
|
11
|
+
const supported = new Map([
|
|
12
|
+
['runtime', ['nodejs', 'edge']],
|
|
13
|
+
['dynamic', ['auto', 'force-dynamic', 'force-static', 'error']],
|
|
14
|
+
['revalidate', [false]],
|
|
15
|
+
['dynamicParams', [true, false]],
|
|
16
|
+
['instant', [true, false]],
|
|
17
|
+
['fetchCache', ['auto', 'default-cache', 'only-cache', 'force-cache', 'force-no-store', 'default-no-store', 'only-no-store']],
|
|
18
|
+
['preferredRegion', ['auto']],
|
|
19
|
+
['maxDuration', []],
|
|
20
|
+
['generateStaticParams', []],
|
|
21
|
+
['experimental_ppr', [false]],
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function literal(node) {
|
|
25
|
+
while (['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TypeCastExpression'].includes(node?.type)) node = node.expression;
|
|
26
|
+
if (node?.type === 'NullLiteral') return null;
|
|
27
|
+
if (node?.type === 'ArrayExpression') { const values = node.elements.map(literal); return values.includes(undefined) ? undefined : values; }
|
|
28
|
+
if (node?.type === 'ObjectExpression') {
|
|
29
|
+
const output = {};
|
|
30
|
+
for (const item of node.properties) {
|
|
31
|
+
if (item.type !== 'ObjectProperty' || item.computed || item.shorthand) return undefined;
|
|
32
|
+
const key = item.key.name ?? item.key.value, value = literal(item.value);
|
|
33
|
+
if (typeof key !== 'string' || value === undefined || key === '__proto__') return undefined;
|
|
34
|
+
Object.defineProperty(output, key, { value, enumerable: true });
|
|
35
|
+
}
|
|
36
|
+
return output;
|
|
37
|
+
}
|
|
38
|
+
return ['StringLiteral', 'BooleanLiteral', 'NumericLiteral'].includes(node?.type) ? node.value : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function validateAppConfig(file) {
|
|
42
|
+
const source = await readFile(file, 'utf8');
|
|
43
|
+
const digest = createHash('sha256').update(source).digest('hex');
|
|
44
|
+
const hit = parsedConfigs.get(file);
|
|
45
|
+
if (hit?.digest === digest) {
|
|
46
|
+
parsedHits++; parsedConfigs.delete(file); parsedConfigs.set(file, hit);
|
|
47
|
+
return JSON.parse(hit.json);
|
|
48
|
+
}
|
|
49
|
+
parsedMisses++;
|
|
50
|
+
if (hit) { parsedConfigs.delete(file); parsedBytes -= hit.size; }
|
|
51
|
+
const ast = parse(source, { sourceType: 'unambiguous', sourceFilename: file,
|
|
52
|
+
plugins: ['jsx', ...(/\.tsx?$/.test(file) ? ['typescript'] : [])] });
|
|
53
|
+
const locals = new Map();
|
|
54
|
+
const exports = [];
|
|
55
|
+
for (const statement of ast.program.body) {
|
|
56
|
+
const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
|
|
57
|
+
if (declaration?.type === 'VariableDeclaration') for (const item of declaration.declarations) {
|
|
58
|
+
if (item.id.type !== 'Identifier') continue;
|
|
59
|
+
locals.set(item.id.name, literal(item.init));
|
|
60
|
+
if (statement.type === 'ExportNamedDeclaration') exports.push({ name: item.id.name, local: item.id.name });
|
|
61
|
+
}
|
|
62
|
+
if (statement.type !== 'ExportNamedDeclaration' || statement.exportKind === 'type') continue;
|
|
63
|
+
if (declaration?.type === 'FunctionDeclaration' || declaration?.type === 'ClassDeclaration') exports.push({ name: declaration.id.name });
|
|
64
|
+
for (const item of statement.specifiers) if (item.exportKind !== 'type') exports.push({
|
|
65
|
+
name: item.exported.name ?? item.exported.value, local: statement.source ? undefined : item.local?.name,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const config = {};
|
|
69
|
+
for (const item of exports) {
|
|
70
|
+
if (!supported.has(item.name)) continue;
|
|
71
|
+
const value = locals.get(item.local);
|
|
72
|
+
const allowed = supported.get(item.name);
|
|
73
|
+
if (item.name === 'instant') {
|
|
74
|
+
if (ast.program.directives.some(directive => directive.value.value === 'use client')) throw new Error(`instant cannot be exported by a Client Component (${file}).`);
|
|
75
|
+
if (value && typeof value === 'object' && Object.entries(value).every(([name, value]) => name === 'unstable_samples' ? validSamples(value) : name === 'level' ? ['warning', 'experimental-error'].includes(value) : ['unstable_disableValidation', 'unstable_disableDevValidation', 'unstable_disableBuildValidation'].includes(name) && value === true)) { config.instant = value; continue; }
|
|
76
|
+
}
|
|
77
|
+
if (item.name === 'generateStaticParams') {
|
|
78
|
+
if (ast.program.directives.some(directive => directive.value.value === 'use client')) throw new Error(`generateStaticParams cannot be exported by a Client Component (${file}).`);
|
|
79
|
+
config.generateStaticParams = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (allowed.includes(value) || (item.name === 'revalidate' && Number.isSafeInteger(value) && value >= 0)) { config[item.name] = value; continue; }
|
|
83
|
+
throw new Error(`App Router export ${item.name} in ${file} is not supported with this value.${allowed.length ? ` Supported literal values: ${allowed.map(value => JSON.stringify(value)).join(', ')}${item.name === 'revalidate' ? ', or a nonnegative integer' : ''}.` : ''}`);
|
|
84
|
+
}
|
|
85
|
+
if (config.runtime === 'edge') {
|
|
86
|
+
if (config.revalidate !== undefined || ['force-static', 'error'].includes(config.dynamic)) throw new Error(`Edge Runtime does not support static generation or ISR configuration (${file}).`);
|
|
87
|
+
}
|
|
88
|
+
const json = JSON.stringify(config), size = Buffer.byteLength(file) + Buffer.byteLength(json);
|
|
89
|
+
if (size <= 64 * 1024) {
|
|
90
|
+
while (parsedConfigs.size && (parsedConfigs.size >= 256 || parsedBytes + size > 1024 * 1024)) {
|
|
91
|
+
const [key, value] = parsedConfigs.entries().next().value;
|
|
92
|
+
parsedConfigs.delete(key); parsedBytes -= value.size;
|
|
93
|
+
}
|
|
94
|
+
parsedConfigs.set(file, {digest, json, size}); parsedBytes += size;
|
|
95
|
+
}
|
|
96
|
+
return config;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function mergeAppConfig(configs) {
|
|
100
|
+
const merged = { dynamic: 'auto', revalidate: false, dynamicParams: true, fetchCache: 'auto', forceNoStore: false };
|
|
101
|
+
const policies = new Set(configs.map(config => config.fetchCache).filter(Boolean));
|
|
102
|
+
if (configs.some(config => config.dynamic === 'force-dynamic')) policies.add('force-no-store');
|
|
103
|
+
if (policies.has('force-cache') && policies.has('force-no-store') || policies.has('only-cache') && policies.has('only-no-store')) {
|
|
104
|
+
throw new Error('Incompatible App Router fetchCache guarantees across route segments.');
|
|
105
|
+
}
|
|
106
|
+
let forceDynamic = false;
|
|
107
|
+
for (const config of configs) {
|
|
108
|
+
if (config.runtime === 'edge') merged.runtime = 'edge';
|
|
109
|
+
else if (config.runtime === 'nodejs') delete merged.runtime;
|
|
110
|
+
// The first explicit ancestor decides whether a blocking route is allowed.
|
|
111
|
+
// A child cannot weaken a parent's requirement for an instant shell.
|
|
112
|
+
if (config.instant !== undefined && merged.instant === undefined) merged.instant = config.instant;
|
|
113
|
+
if (config.dynamic !== undefined) merged.dynamic = config.dynamic;
|
|
114
|
+
if (config.dynamic === 'force-dynamic') forceDynamic = true;
|
|
115
|
+
if (typeof config.revalidate === 'number') merged.revalidate = merged.revalidate === false ? config.revalidate : Math.min(merged.revalidate, config.revalidate);
|
|
116
|
+
if (config.dynamicParams === false) merged.dynamicParams = false;
|
|
117
|
+
if (config.fetchCache !== undefined) merged.fetchCache = config.fetchCache;
|
|
118
|
+
if (config.fetchCache === 'force-no-store') merged.forceNoStore = true;
|
|
119
|
+
}
|
|
120
|
+
if (forceDynamic) merged.dynamic = 'force-dynamic';
|
|
121
|
+
if (policies.has('force-cache')) merged.fetchCache = 'force-cache';
|
|
122
|
+
else if (!merged.forceNoStore && !forceDynamic) {
|
|
123
|
+
if (policies.has('only-cache')) merged.fetchCache = 'only-cache';
|
|
124
|
+
if (policies.has('only-no-store')) merged.fetchCache = 'only-no-store';
|
|
125
|
+
}
|
|
126
|
+
if (forceDynamic || merged.forceNoStore) { merged.forceNoStore = true; merged.fetchCache = 'force-no-store'; }
|
|
127
|
+
if (merged.dynamic === 'error' && (merged.revalidate === 0 || merged.forceNoStore)) {
|
|
128
|
+
throw new Error("Incompatible App Router configuration: dynamic='error' cannot be combined with revalidate=0 or fetchCache='force-no-store'.");
|
|
129
|
+
}
|
|
130
|
+
if (!merged.forceNoStore) {
|
|
131
|
+
let parentNoStore = false;
|
|
132
|
+
for (const config of configs) {
|
|
133
|
+
if (parentNoStore && (config.fetchCache === 'auto' || config.fetchCache?.endsWith('-cache'))) throw new Error("Incompatible App Router configuration: a parent fetchCache='default-no-store' cannot have a child fetchCache='auto' or '*-cache'.");
|
|
134
|
+
if (config.fetchCache === 'default-no-store') parentNoStore = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return merged;
|
|
138
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mergeAppConfig } from './app-config.mjs';
|
|
4
|
+
|
|
5
|
+
const group = part => /^\([^()]+\)$/.test(part);
|
|
6
|
+
const signature = pattern => pattern.replace(/\[\[\.\.\.[^\]]+\]\]/g, '[[...]]').replace(/\[\.\.\.[^\]]+\]/g, '[...]').replace(/\[[^\]]+\]/g, '[]');
|
|
7
|
+
export const rootLayoutId = file => file ? createHash('sha256').update(file).digest('hex').slice(0, 20) : undefined;
|
|
8
|
+
export function appRouteParts(parts) {
|
|
9
|
+
let url = [], interception;
|
|
10
|
+
for (let index = 0; index < parts.length; index++) {
|
|
11
|
+
const part = parts[index];
|
|
12
|
+
if (part.startsWith('@') || group(part)) continue;
|
|
13
|
+
const marker = /^(\(\.\.\.\)|\(\.\)|(?:\(\.\.\))+)(.+)$/.exec(part);
|
|
14
|
+
if (marker) {
|
|
15
|
+
const source = '/' + url.join('/');
|
|
16
|
+
const levels = marker[1] === '(...)' ? url.length : marker[1] === '(.)' ? 0 : marker[1].length / 4;
|
|
17
|
+
if (levels > url.length) throw new Error(`Intercepting route ${parts.join('/')} traverses above the app root.`);
|
|
18
|
+
url = [...url.slice(0, url.length - levels), marker[2]];
|
|
19
|
+
interception = { source, anchor: parts.slice(0, index).join('/'), marker: marker[1], ...(interception ? { parent: interception } : {}) };
|
|
20
|
+
} else url.push(part.replace(/^%5f/i, '_'));
|
|
21
|
+
}
|
|
22
|
+
return { url, interception };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function compatible(left, right, prefix = false) {
|
|
26
|
+
const a = left.split('/').filter(Boolean), b = right.split('/').filter(Boolean);
|
|
27
|
+
for (let index = 0; index < Math.min(a.length, b.length); index++) {
|
|
28
|
+
if (a[index].startsWith('[...') || a[index].startsWith('[[...') || b[index].startsWith('[...') || b[index].startsWith('[[...')) return true;
|
|
29
|
+
if (!a[index].startsWith('[') && !b[index].startsWith('[') && a[index] !== b[index]) return false;
|
|
30
|
+
}
|
|
31
|
+
return prefix ? a.length <= b.length : a.length === b.length || a.at(-1)?.startsWith('[[...') || b.at(-1)?.startsWith('[[...');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Build one public route for all slots sharing a pathname, with a pruned component tree. */
|
|
35
|
+
export function scanAdvancedAppRoutes({ root, appRoot, directories, configs, routes, patterns, validatePattern }) {
|
|
36
|
+
const nodes = new Map();
|
|
37
|
+
function node(directory) {
|
|
38
|
+
if (nodes.has(directory)) return nodes.get(directory);
|
|
39
|
+
const parts = directory ? directory.split('/') : [];
|
|
40
|
+
const info = appRouteParts(parts);
|
|
41
|
+
const files = directories.get(directory)?.files || {};
|
|
42
|
+
const value = { path: directory, segment: parts.at(-1) || '', pattern: '/' + info.url.join('/'), files,
|
|
43
|
+
layoutId: rootLayoutId(files.layout),
|
|
44
|
+
config: Object.fromEntries(['layout', 'page'].filter(name => files[name]).map(name => [name, configs.get(files[name]) || {}])), children: [], slots: {},
|
|
45
|
+
...(info.interception ? { interception: info.interception } : {}) };
|
|
46
|
+
nodes.set(directory, value);
|
|
47
|
+
if (parts.length) {
|
|
48
|
+
const parent = node(parts.slice(0, -1).join('/'));
|
|
49
|
+
if (value.segment.startsWith('@')) {
|
|
50
|
+
const name = value.segment.slice(1);
|
|
51
|
+
if (!name || name === 'children' || !/^[A-Za-z_$][\w$-]*$/.test(name)) throw new Error(`Invalid parallel route slot ${value.segment} in ${directory}.`);
|
|
52
|
+
parent.slots[name] = value;
|
|
53
|
+
} else parent.children.push(value);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
node('');
|
|
58
|
+
for (const directory of directories.keys()) node(directory);
|
|
59
|
+
const leaves = [];
|
|
60
|
+
for (const value of nodes.values()) {
|
|
61
|
+
if (!value.files.page && !value.files.route) continue;
|
|
62
|
+
if (value.files.page && value.files.route) throw new Error(`App page and Route Handler conflict in ${value.path}.`);
|
|
63
|
+
const file = value.files.page || value.files.route;
|
|
64
|
+
value.pattern = validatePattern(appRouteParts(value.path.split('/').filter(Boolean)).url, path.relative(root, file));
|
|
65
|
+
if (value.files.route) {
|
|
66
|
+
if (value.interception) throw new Error(`Route Handlers cannot be intercepted: ${file}.`);
|
|
67
|
+
const key = signature(value.pattern);
|
|
68
|
+
if (patterns.has(key)) throw new Error(`Conflicting routes: ${patterns.get(key)} and ${file}.`);
|
|
69
|
+
patterns.set(key, file);
|
|
70
|
+
routes.push({ id: `app-api-${createHash('sha256').update(value.pattern).digest('hex').slice(0, 12)}`, pattern: value.pattern, kind: 'api', router: 'app', file,
|
|
71
|
+
cacheConfig: mergeAppConfig([configs.get(file) || {}]), handlerConfig: configs.get(file) || {} });
|
|
72
|
+
} else leaves.push(value);
|
|
73
|
+
}
|
|
74
|
+
const publicPatterns = new Map();
|
|
75
|
+
for (const leaf of leaves) {
|
|
76
|
+
const key = signature(leaf.pattern);
|
|
77
|
+
if (!publicPatterns.has(key)) publicPatterns.set(key, { pattern: leaf.pattern, leaves: [] });
|
|
78
|
+
const collection = publicPatterns.get(key).leaves;
|
|
79
|
+
const channel = leaf.path.split('/').map((part, index, parts) => part.startsWith('@') ? parts.slice(0, index + 1).join('/') : '').filter(Boolean).join('|');
|
|
80
|
+
if (!leaf.interception && collection.some(other => !other.interception && other.channel === channel)) throw new Error(`Conflicting App routes ${leaf.files.page} and another page in ${channel || 'children'}.`);
|
|
81
|
+
leaf.channel = channel;
|
|
82
|
+
collection.push(leaf);
|
|
83
|
+
}
|
|
84
|
+
function chain(leaf) {
|
|
85
|
+
const parts = leaf.path ? leaf.path.split('/') : [];
|
|
86
|
+
return Array.from({ length: parts.length + 1 }, (_, index) => nodes.get(parts.slice(0, index).join('/'))).filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
function prune(value, pattern) {
|
|
89
|
+
const children = value.children.map(child => prune(child, pattern)).filter(Boolean);
|
|
90
|
+
const slots = Object.fromEntries(Object.entries(value.slots).map(([name, child]) => [name, prune(child, pattern) || { path: child.path, segment: child.segment, pattern: child.pattern, files: {}, children: [], slots: {} }]));
|
|
91
|
+
const page = value.files.page && compatible(value.pattern, pattern) ? value.files.page : undefined;
|
|
92
|
+
const defaultFile = value.files.default && compatible(value.pattern, pattern, true) ? value.files.default : undefined;
|
|
93
|
+
if (value.path && !page && !defaultFile && !children.length && !Object.keys(slots).length && !value.segment.startsWith('@') && !compatible(value.pattern, pattern, true)) return null;
|
|
94
|
+
const { route, page: ignoredPage, default: ignoredDefault, ...files } = value.files;
|
|
95
|
+
return { ...value, files: { ...files, ...(page ? { page } : {}), ...(defaultFile ? { default: defaultFile } : {}) }, children, slots };
|
|
96
|
+
}
|
|
97
|
+
for (const [key, item] of publicPatterns) {
|
|
98
|
+
if (patterns.has(key)) throw new Error(`Conflicting routes: ${patterns.get(key)} and ${item.pattern} (App Router).`);
|
|
99
|
+
patterns.set(key, item.leaves[0].files.page);
|
|
100
|
+
const primary = leaves.find(leaf => !leaf.channel && !leaf.interception && leaf.pattern === item.pattern)
|
|
101
|
+
|| leaves.find(leaf => !leaf.channel && !leaf.interception && compatible(leaf.pattern, item.pattern))
|
|
102
|
+
|| item.leaves.find(leaf => !leaf.interception) || item.leaves[0];
|
|
103
|
+
// Native matching uses one name per public URL. Keep each slot's own names
|
|
104
|
+
// in its component tree, but choose the canonical children names publicly.
|
|
105
|
+
if (signature(primary.pattern) === key) item.pattern = primary.pattern;
|
|
106
|
+
const parents = chain(primary);
|
|
107
|
+
const segments = parents.filter(value => !value.segment.startsWith('@') && !value.interception).map(value => {
|
|
108
|
+
const { page, route, default: defaultFile, ...files } = value.files;
|
|
109
|
+
return { segment: value.segment, path: value.path, ...files, ...(files.layout ? { staticConfig: configs.get(files.layout) || {} } : {}) };
|
|
110
|
+
});
|
|
111
|
+
if (!segments.some(segment => segment.layout)) throw new Error(`App route ${item.pattern} needs a root layout.`);
|
|
112
|
+
const activeLeaves = leaves.filter(leaf => !leaf.interception && compatible(leaf.pattern, item.pattern));
|
|
113
|
+
const configuration = [...new Set(activeLeaves.flatMap(leaf => [...chain(leaf).map(parent => parent.files.layout).filter(Boolean), leaf.files.page]))];
|
|
114
|
+
routes.push({ id: `app-page-${createHash('sha256').update(item.pattern).digest('hex').slice(0, 12)}`, pattern: item.pattern, kind: 'page', router: 'app', file: primary.files.page,
|
|
115
|
+
segments, pageConfig: configs.get(primary.files.page) || {}, cacheConfig: mergeAppConfig(configuration.map(file => configs.get(file) || {})),
|
|
116
|
+
routing: prune(nodes.get(''), item.pattern), parallel: true, interception: item.leaves.some(leaf => !!leaf.interception),
|
|
117
|
+
interceptionOnly: activeLeaves.length === 0 });
|
|
118
|
+
}
|
|
119
|
+
}
|