@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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { parse } from '@babel/parser';
|
|
5
|
+
import { validateAppConfig, mergeAppConfig } from './app-config.mjs';
|
|
6
|
+
import { scanMetadataImages } from './metadata-images.mjs';
|
|
7
|
+
import { pageExtensionPattern, validatePageSource } from './page-extensions.mjs';
|
|
8
|
+
|
|
9
|
+
const contentTypes = { robots: 'text/plain', sitemap: 'application/xml', manifest: 'application/manifest+json' };
|
|
10
|
+
|
|
11
|
+
export async function scanMetadataRoutes({ root, appRoot, files, routes, patterns, validatePattern, basePath, pageExtensions, appNotFound }) {
|
|
12
|
+
let manifest;
|
|
13
|
+
const sourceExtension = pageExtensionPattern(pageExtensions);
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
const relative = path.relative(appRoot, file).replaceAll(path.sep, '/');
|
|
16
|
+
const parts = relative.split('/');
|
|
17
|
+
const name = parts.pop();
|
|
18
|
+
if (parts.some(part => part.startsWith('_'))) continue;
|
|
19
|
+
const dynamic = sourceExtension.test(name);
|
|
20
|
+
const match = dynamic ? /^(robots|sitemap|manifest)$/.exec(name.replace(sourceExtension, '')) : /^(robots|sitemap|manifest)\.(txt|xml|json|webmanifest)$/.exec(name);
|
|
21
|
+
if (!match) continue;
|
|
22
|
+
const [, kind, extension] = match;
|
|
23
|
+
if (dynamic) validatePageSource(file);
|
|
24
|
+
if (!dynamic && !{ robots: ['txt'], sitemap: ['xml'], manifest: ['json', 'webmanifest'] }[kind].includes(extension)) continue;
|
|
25
|
+
if (kind !== 'sitemap' && parts.length) continue;
|
|
26
|
+
if (parts.some(part => part.startsWith('@') || /^\(\.{1,3}\)/.test(part))) throw new Error(`Parallel and intercepting metadata routes are not implemented: ${relative}.`);
|
|
27
|
+
const names = dynamic ? metadataExportNames(await readFile(file, 'utf8'), file) : [];
|
|
28
|
+
const multiple = kind === 'sitemap' && names.includes('generateSitemaps');
|
|
29
|
+
const filename = kind === 'robots' ? 'robots.txt' : kind === 'sitemap' ? multiple ? 'sitemap' : 'sitemap.xml' : dynamic ? 'manifest.webmanifest' : name;
|
|
30
|
+
const pattern = validatePattern([...parts.filter(part => !/^\([^()]+\)$/.test(part)), filename, ...(multiple ? ['[__metadata_id__]'] : [])], relative);
|
|
31
|
+
const signature = pattern.replace(/\[[^\]]+\]/g, '[]');
|
|
32
|
+
if (patterns.has(signature)) throw new Error(`Conflicting metadata route ${relative} and ${patterns.get(signature)} at ${pattern}.`);
|
|
33
|
+
if (kind === 'manifest' && manifest) throw new Error('Multiple manifest metadata files found in the App Router root.');
|
|
34
|
+
try { await access(path.join(root, 'public', pattern.slice(1))); throw new Error(`Metadata route ${pattern} conflicts with a file in public/.`); }
|
|
35
|
+
catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
|
|
36
|
+
patterns.set(signature, relative);
|
|
37
|
+
const handlerConfig = dynamic ? await validateAppConfig(file) : {};
|
|
38
|
+
if (dynamic) {
|
|
39
|
+
if (names.some(name => ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'].includes(name))) throw new Error(`${relative} must export a default metadata function, not HTTP methods.`);
|
|
40
|
+
}
|
|
41
|
+
handlerConfig.revalidate ??= false;
|
|
42
|
+
if (multiple) handlerConfig.generateStaticParams = true;
|
|
43
|
+
routes.push({ id: `app-metadata-${createHash('sha256').update(pattern).digest('hex').slice(0, 12)}`, pattern, router: 'app', kind: 'api', file, handlerConfig,
|
|
44
|
+
cacheConfig: mergeAppConfig([handlerConfig]), metadataFile: { kind, dynamic, multiple, contentType: contentTypes[kind] } });
|
|
45
|
+
if (kind === 'manifest') manifest = `${basePath}${pattern}`;
|
|
46
|
+
}
|
|
47
|
+
await scanMetadataImages({ root, appRoot, files, routes, patterns, validatePattern, basePath, pageExtensions, appNotFound });
|
|
48
|
+
if (appNotFound && manifest) appNotFound.staticMetadata = { manifest };
|
|
49
|
+
return manifest;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function metadataExportNames(source, file) {
|
|
53
|
+
const ast = parse(source, { sourceType: 'module', plugins: ['jsx', ...(/\.tsx?$/.test(file) ? ['typescript'] : [])] });
|
|
54
|
+
return ast.program.body.flatMap(node => node.type === 'ExportNamedDeclaration' ? [node.declaration?.id?.name, ...(node.declaration?.declarations || []).map(item => item.id.name), ...node.specifiers.map(item => item.exported?.name)] : []);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function prepareMetadataRoutes(project, stage) {
|
|
58
|
+
for (const route of project.routes) {
|
|
59
|
+
if (!route.metadataFile) continue;
|
|
60
|
+
const { kind, dynamic, contentType, multiple, image } = route.metadataFile;
|
|
61
|
+
let source;
|
|
62
|
+
if (dynamic) {
|
|
63
|
+
const config = await validateAppConfig(route.file);
|
|
64
|
+
source = `import * as source from ${JSON.stringify(route.file)};import {metadataHandler,metadataStaticParams} from '../runtime/metadata-route.mjs';export * from ${JSON.stringify(route.file)};\n`;
|
|
65
|
+
if (config.revalidate === undefined) source += 'export const revalidate=false;\n';
|
|
66
|
+
const options = JSON.stringify({ kind, image, multiple, contentType });
|
|
67
|
+
source += `export const GET=metadataHandler(source,${options});\n`;
|
|
68
|
+
if (multiple) source += `export const generateStaticParams=(props)=>metadataStaticParams(source,${options},props);\n`;
|
|
69
|
+
} else {
|
|
70
|
+
const body = await readFile(route.file);
|
|
71
|
+
if (body.length > 16 * 1024 * 1024) throw new Error(`Metadata file ${route.file} exceeds the 16 MiB response limit.`);
|
|
72
|
+
source = `export const revalidate=false;const body=Buffer.from(${JSON.stringify(body.toString('base64'))},'base64');export function GET(){return new Response(body,{headers:{'content-type':${JSON.stringify(contentType)}}});}`;
|
|
73
|
+
}
|
|
74
|
+
route.file = path.join(stage, '.entries', route.id + '.mjs');
|
|
75
|
+
await writeFile(route.file, source);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { withLocale } from '../compat/locale.cjs';
|
|
2
|
+
import {frameworkImportName, frameworkImportPattern} from './framework-imports.mjs';
|
|
3
|
+
import { moduleResolutionPlugin } from './module-resolution.mjs';
|
|
4
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { parse } from '@babel/parser';
|
|
7
|
+
import { build as bundle } from './compiler.mjs';
|
|
8
|
+
import { compileRouteMatcher } from './custom-routes.mjs';
|
|
9
|
+
import { createNativePackages } from './native-packages.mjs';
|
|
10
|
+
import { compileEdge } from './edge.mjs';
|
|
11
|
+
|
|
12
|
+
const extensions = ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'];
|
|
13
|
+
const loaders = { '.js': 'jsx', '.jsx': 'jsx', '.mjs': 'jsx', '.cjs': 'jsx', '.ts': 'ts', '.tsx': 'tsx' };
|
|
14
|
+
const compatibility = new Map([['server', 'server'], ['headers', 'headers'], ['cache', 'cache'], ['navigation', 'navigation-server'], ['constants', 'constants']]);
|
|
15
|
+
const forbiddenConfig = new Set(['preferredRegion', 'maxDuration', 'dynamic', 'dynamicParams', 'revalidate', 'fetchCache']);
|
|
16
|
+
const MAX_MATCHERS = 1000;
|
|
17
|
+
const MAX_CONFIG_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
const MAX_CONFIG_VISITS = 300_000;
|
|
19
|
+
|
|
20
|
+
function fail(file, message) { throw new Error(`Invalid middleware/proxy ${file}: ${message}`); }
|
|
21
|
+
function unwrap(node) {
|
|
22
|
+
while (['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TypeCastExpression', 'ParenthesizedExpression'].includes(node?.type)) node = node.expression;
|
|
23
|
+
return node;
|
|
24
|
+
}
|
|
25
|
+
function keyOf(property) {
|
|
26
|
+
return !property.computed && (property.key?.type === 'Identifier' ? property.key.name : property.key?.value);
|
|
27
|
+
}
|
|
28
|
+
function member(node) {
|
|
29
|
+
if (node?.type === 'Identifier') return node.name;
|
|
30
|
+
if (node?.type !== 'MemberExpression' || node.optional) return;
|
|
31
|
+
const owner = member(node.object);
|
|
32
|
+
const name = node.computed ? node.property.type === 'StringLiteral' && node.property.value : node.property.name;
|
|
33
|
+
return owner && typeof name === 'string' ? `${owner}.${name}` : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function configBudget(file) {
|
|
37
|
+
let visits = 0, bytes = 0;
|
|
38
|
+
return {
|
|
39
|
+
visit() { if (++visits > MAX_CONFIG_VISITS) fail(file, 'configuration constant expansion exceeds the evaluation budget'); },
|
|
40
|
+
bytes(length) { if ((bytes += length) > MAX_CONFIG_BYTES) fail(file, 'configuration constant expansion exceeds 2 MiB'); },
|
|
41
|
+
string(value) {
|
|
42
|
+
// Reject a large literal before JSON escaping can allocate another copy.
|
|
43
|
+
if (Buffer.byteLength(value) > MAX_CONFIG_BYTES - bytes) fail(file, 'configuration constant expansion exceeds 2 MiB');
|
|
44
|
+
this.bytes(Buffer.byteLength(JSON.stringify(value)));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function literal(node, locals, file, seen = new Set(), depth = 0, budget = configBudget(file)) {
|
|
50
|
+
budget.visit();
|
|
51
|
+
if (depth > 32) fail(file, 'configuration nesting exceeds 32 levels');
|
|
52
|
+
node = unwrap(node);
|
|
53
|
+
if (!node) { budget.bytes(4); return undefined; }
|
|
54
|
+
if (node.type === 'StringLiteral') { budget.string(node.value); return node.value; }
|
|
55
|
+
if (['NumericLiteral', 'BooleanLiteral'].includes(node.type)) { budget.bytes(String(node.value).length); return node.value; }
|
|
56
|
+
if (node.type === 'NullLiteral') { budget.bytes(4); return null; }
|
|
57
|
+
if (node.type === 'TemplateLiteral' && !node.expressions.length) {
|
|
58
|
+
budget.string(node.quasis[0].value.cooked);
|
|
59
|
+
return node.quasis[0].value.cooked;
|
|
60
|
+
}
|
|
61
|
+
if (node.type === 'Identifier') {
|
|
62
|
+
if (node.name === 'undefined') { budget.bytes(4); return undefined; }
|
|
63
|
+
if (!locals.has(node.name) || seen.has(node.name)) fail(file, `configuration must be statically analyzable; cannot resolve ${node.name}`);
|
|
64
|
+
return literal(locals.get(node.name), locals, file, new Set([...seen, node.name]), depth + 1, budget);
|
|
65
|
+
}
|
|
66
|
+
if (node.type === 'ArrayExpression') {
|
|
67
|
+
if (node.elements.length > MAX_MATCHERS) fail(file, `configuration arrays may contain at most ${MAX_MATCHERS} items`);
|
|
68
|
+
budget.bytes(2 + Math.max(0, node.elements.length - 1));
|
|
69
|
+
return node.elements.map(item => {
|
|
70
|
+
if (!item || item.type === 'SpreadElement') fail(file, 'configuration arrays cannot contain holes or spreads');
|
|
71
|
+
return literal(item, locals, file, seen, depth + 1, budget);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (node.type === 'ObjectExpression') {
|
|
75
|
+
budget.bytes(2 + Math.max(0, node.properties.length - 1));
|
|
76
|
+
const result = {};
|
|
77
|
+
for (const property of node.properties) {
|
|
78
|
+
const key = keyOf(property);
|
|
79
|
+
if (property.type !== 'ObjectProperty' || typeof key !== 'string') fail(file, 'configuration requires literal object properties without spreads or computed keys');
|
|
80
|
+
if (Object.hasOwn(result, key)) fail(file, `duplicate configuration field ${key}`);
|
|
81
|
+
budget.string(key); budget.bytes(1);
|
|
82
|
+
Object.defineProperty(result, key, { value: literal(property.value, locals, file, seen, depth + 1, budget), enumerable: true });
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
fail(file, 'configuration must be statically analyzable literals, not executable expressions');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function collectExports(ast, file) {
|
|
90
|
+
const locals = new Map();
|
|
91
|
+
const exported = new Map();
|
|
92
|
+
let commonjs = false;
|
|
93
|
+
for (const statement of ast.program.body) {
|
|
94
|
+
const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
|
|
95
|
+
if (declaration?.type === 'VariableDeclaration') for (const item of declaration.declarations) {
|
|
96
|
+
if (item.id.type !== 'Identifier') continue;
|
|
97
|
+
if (declaration.kind === 'const') locals.set(item.id.name, item.init);
|
|
98
|
+
if (statement.type === 'ExportNamedDeclaration') exported.set(item.id.name, item.init);
|
|
99
|
+
}
|
|
100
|
+
if (['FunctionDeclaration', 'ClassDeclaration'].includes(declaration?.type) && declaration.id) {
|
|
101
|
+
locals.set(declaration.id.name, declaration);
|
|
102
|
+
if (statement.type === 'ExportNamedDeclaration') exported.set(declaration.id.name, declaration);
|
|
103
|
+
}
|
|
104
|
+
if (statement.type === 'ExportDefaultDeclaration') exported.set('default', statement.declaration);
|
|
105
|
+
if (statement.type === 'ExportNamedDeclaration' && statement.exportKind !== 'type') for (const specifier of statement.specifiers) {
|
|
106
|
+
if (specifier.exportKind === 'type') continue;
|
|
107
|
+
const name = specifier.exported.name ?? specifier.exported.value;
|
|
108
|
+
if (statement.source && (name === 'config' || name === 'runtime')) fail(file, `${name} must be declared in this file, not re-exported`);
|
|
109
|
+
exported.set(name, statement.source ? null : specifier.local);
|
|
110
|
+
}
|
|
111
|
+
if (statement.type === 'ExportAllDeclaration') fail(file, 'wildcard exports cannot define middleware or its static configuration; use explicit exports');
|
|
112
|
+
if (statement.type !== 'ExpressionStatement' || statement.expression.type !== 'AssignmentExpression' || statement.expression.operator !== '=') continue;
|
|
113
|
+
const assignment = statement.expression;
|
|
114
|
+
const target = member(assignment.left);
|
|
115
|
+
if (!target || !(target === 'module.exports' || /^(?:module\.exports|exports)\.[^.]+$/.test(target))) continue;
|
|
116
|
+
commonjs = true;
|
|
117
|
+
if (target === 'module.exports') {
|
|
118
|
+
const value = unwrap(assignment.right);
|
|
119
|
+
if (value.type === 'ObjectExpression') {
|
|
120
|
+
for (const property of value.properties) {
|
|
121
|
+
const key = keyOf(property);
|
|
122
|
+
if (typeof key !== 'string' || !['ObjectProperty', 'ObjectMethod'].includes(property.type)) fail(file, 'CommonJS middleware exports require explicit named properties');
|
|
123
|
+
exported.set(key, property.type === 'ObjectMethod' ? property : property.value);
|
|
124
|
+
}
|
|
125
|
+
} else exported.set('default', value);
|
|
126
|
+
} else exported.set(target.split('.').at(-1), assignment.right);
|
|
127
|
+
}
|
|
128
|
+
return { locals, exported, commonjs };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function inspectMiddleware(file, convention = path.basename(file).split('.')[0], { basePath = '', i18n } = {}) {
|
|
132
|
+
const ast = parse(await readFile(file, 'utf8'), { sourceType: 'unambiguous', sourceFilename: file,
|
|
133
|
+
plugins: ['jsx', ...(/\.tsx?$/.test(file) ? ['typescript'] : [])] });
|
|
134
|
+
if (ast.program.directives.some(directive => ['use client', 'use server'].includes(directive.value.value))) fail(file, 'React Client Components and Server Actions cannot define middleware');
|
|
135
|
+
const { locals, exported, commonjs } = collectExports(ast, file);
|
|
136
|
+
for (const name of forbiddenConfig) if (exported.has(name)) fail(file, `route segment option ${name} is unsupported here`);
|
|
137
|
+
const raw = exported.has('config') ? literal(exported.get('config'), locals, file) : {};
|
|
138
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail(file, 'config must be a literal object');
|
|
139
|
+
const unknown = Object.keys(raw).filter(key => !['matcher', 'runtime'].includes(key));
|
|
140
|
+
if (unknown.length) fail(file, `unsupported config fields: ${unknown.join(', ')}`);
|
|
141
|
+
const declaredRuntime = exported.has('runtime') ? literal(exported.get('runtime'), locals, file) : raw.runtime;
|
|
142
|
+
if (convention === 'proxy' && (exported.has('runtime') || raw.runtime !== undefined)) fail(file, 'proxy always uses Node.js and cannot configure runtime');
|
|
143
|
+
if ([declaredRuntime, raw.runtime].some(value => value !== undefined && !['nodejs', 'edge', 'experimental-edge'].includes(value))) fail(file, 'runtime must be nodejs, edge or experimental-edge');
|
|
144
|
+
const exportName = exported.has(convention) ? convention : exported.has('default') ? 'default' : undefined;
|
|
145
|
+
if (!exportName) fail(file, `export a default function or a named ${convention} function`);
|
|
146
|
+
let handler = unwrap(exported.get(exportName));
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
while (handler?.type === 'Identifier' && locals.has(handler.name) && !seen.has(handler.name)) {
|
|
149
|
+
seen.add(handler.name); handler = unwrap(locals.get(handler.name));
|
|
150
|
+
}
|
|
151
|
+
if (handler && ['StringLiteral', 'NumericLiteral', 'BooleanLiteral', 'NullLiteral', 'ObjectExpression', 'ArrayExpression', 'ClassDeclaration', 'ClassExpression'].includes(handler.type)) fail(file, `${exportName} must be a function`);
|
|
152
|
+
const matchers = raw.matcher === undefined ? ['/:path*'] : typeof raw.matcher === 'string' ? [raw.matcher] : raw.matcher;
|
|
153
|
+
if (!Array.isArray(matchers) || matchers.length > MAX_MATCHERS) fail(file, `matcher must be a string or at most ${MAX_MATCHERS} matcher entries`);
|
|
154
|
+
const localized = i18n ? matchers.flatMap(input => {const value = typeof input === 'string' ? {source: input} : input; return value.locale === false ? [value] : i18n.locales.map(locale=>({...value,source:withLocale(value.source,locale,i18n.defaultLocale)}));}) : matchers;
|
|
155
|
+
if (localized.length > MAX_MATCHERS) fail(file, `localized matchers exceed ${MAX_MATCHERS} entries`);
|
|
156
|
+
const compiled = localized.map((matcher, index) => compileRouteMatcher(matcher, `${file}.config.matcher[${index}]`, { basePath }));
|
|
157
|
+
if (Buffer.byteLength(JSON.stringify(compiled)) > MAX_CONFIG_BYTES) fail(file, 'compiled matchers exceed 2 MiB');
|
|
158
|
+
return { file, convention, exportName, commonjs, runtime: declaredRuntime === 'edge' || declaredRuntime === 'experimental-edge' ? 'edge' : 'nodejs',
|
|
159
|
+
...(declaredRuntime !== undefined ? { declaredRuntime } : {}), matchers: compiled };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function scanMiddleware(root, { basePath = '', pageExtensions = extensions, i18n } = {}) {
|
|
163
|
+
const candidates = [];
|
|
164
|
+
for (const directory of ['', 'src']) for (const convention of ['middleware', 'proxy']) for (const extension of pageExtensions) {
|
|
165
|
+
const file = path.join(root, directory, `${convention}.${extension}`);
|
|
166
|
+
try { await access(file); candidates.push({ file, convention }); }
|
|
167
|
+
catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
|
|
168
|
+
}
|
|
169
|
+
if (candidates.length > 1) throw new Error(`Conflicting middleware/proxy files: ${candidates.map(item => path.relative(root, item.file)).join(', ')}. Keep exactly one middleware or proxy entry.`);
|
|
170
|
+
return candidates.length ? inspectMiddleware(candidates[0].file, candidates[0].convention, { basePath, i18n }) : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Build request interception independently of either React router graph. */
|
|
174
|
+
export async function compileMiddleware({ project, stage, manifest, dev, defineEnvironment = {}, moduleResolution = {} }) {
|
|
175
|
+
const descriptor = project.middleware;
|
|
176
|
+
if (!descriptor) return;
|
|
177
|
+
if (descriptor.runtime === 'edge') {
|
|
178
|
+
await compileEdge({ file: descriptor.file, outfile: path.join(stage, 'server/middleware.mjs'), projectRoot: project.root,
|
|
179
|
+
dev, defineEnvironment, plugins: [moduleResolutionPlugin(moduleResolution, project.root)], defaultExport: descriptor.exportName === 'default' });
|
|
180
|
+
manifest.middleware = { module: 'server/middleware.mjs', exportName: descriptor.exportName, convention: descriptor.convention,
|
|
181
|
+
runtime: 'edge', declaredRuntime: descriptor.declaredRuntime, matchers: descriptor.matchers };
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const entry = path.join(stage, '.entries', 'middleware.mjs');
|
|
185
|
+
const selected = descriptor.exportName;
|
|
186
|
+
const statement = descriptor.commonjs
|
|
187
|
+
? `import module from ${JSON.stringify(descriptor.file)};\n${selected === 'default' ? "export default typeof module==='function'?module:module.default;" : `export const ${selected}=module[${JSON.stringify(selected)}];`}`
|
|
188
|
+
: `export {${selected}} from ${JSON.stringify(descriptor.file)};`;
|
|
189
|
+
await writeFile(entry, statement);
|
|
190
|
+
const native = createNativePackages(project.root, { isClientSource: () => false });
|
|
191
|
+
const compatibilityPlugin = {
|
|
192
|
+
name: 'prnext-middleware-compatibility',
|
|
193
|
+
setup(esbuild) {
|
|
194
|
+
esbuild.onResolve({ filter: frameworkImportPattern }, args => {
|
|
195
|
+
const name = frameworkImportName(args.path);
|
|
196
|
+
const compatible = compatibility.get(name);
|
|
197
|
+
return compatible ? { path: `../compat/${compatible}.cjs`, external: true }
|
|
198
|
+
: { errors: [{ text: `Unsupported middleware import ${args.path}; use next/server, next/headers, next/cache, next/navigation or next/constants.` }] };
|
|
199
|
+
});
|
|
200
|
+
esbuild.onResolve({ filter: /^(?:server-only|client-only)$/ }, args => args.path === 'server-only'
|
|
201
|
+
? { path: args.path, namespace: 'prnext-middleware-empty' }
|
|
202
|
+
: { errors: [{ text: 'client-only modules cannot run in middleware/proxy.' }] });
|
|
203
|
+
esbuild.onResolve({ filter: /\.css$/ }, () => ({ errors: [{ text: 'Middleware/proxy cannot import stylesheet assets.' }] }));
|
|
204
|
+
esbuild.onLoad({ filter: /.*/, namespace: 'prnext-middleware-empty' }, () => ({ contents: '', loader: 'js' }));
|
|
205
|
+
esbuild.onLoad({ filter: /\.(?:[cm]?js|jsx|tsx?)$/, namespace: 'file' }, async args => {
|
|
206
|
+
const contents = args.prnextSource ?? await readFile(args.path, 'utf8');
|
|
207
|
+
if (/['"]use (?:client|server)['"]/.test(contents)) {
|
|
208
|
+
const ast = parse(contents, { sourceType: 'unambiguous', plugins: ['jsx', ...(/\.tsx?$/.test(args.path) ? ['typescript'] : [])] });
|
|
209
|
+
if (ast.program.directives.some(directive => ['use client', 'use server'].includes(directive.value.value))) fail(args.path, 'React Client Components and Server Actions cannot run in middleware');
|
|
210
|
+
}
|
|
211
|
+
return { contents, loader: loaders[path.extname(args.path)] || 'jsx', resolveDir: path.dirname(args.path) };
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
let output;
|
|
216
|
+
for (;;) {
|
|
217
|
+
const version = native.version;
|
|
218
|
+
try {
|
|
219
|
+
output = await bundle({ absWorkingDir: project.root, entryPoints: [entry], outfile: path.join(stage, 'server/middleware.mjs'),
|
|
220
|
+
bundle: true, write: false, platform: 'node', format: 'esm', target: 'node22', conditions: ['node'], jsx: 'automatic',
|
|
221
|
+
sourcemap: dev, logLevel: 'silent', define: { 'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production'), ...defineEnvironment },
|
|
222
|
+
banner: { js: "import {createRequire as __prnextCreateRequire} from 'node:module';const require=__prnextCreateRequire(import.meta.url);" },
|
|
223
|
+
plugins: [moduleResolutionPlugin(moduleResolution, project.root), compatibilityPlugin, native.plugin('middleware')],
|
|
224
|
+
});
|
|
225
|
+
if (version === native.version) break;
|
|
226
|
+
} catch (error) { if (version === native.version) throw error; }
|
|
227
|
+
}
|
|
228
|
+
for (const file of output.outputFiles) { await mkdir(path.dirname(file.path), { recursive: true }); await writeFile(file.path, file.contents); }
|
|
229
|
+
manifest.middleware = { module: 'server/middleware.mjs', exportName: selected, convention: descriptor.convention,
|
|
230
|
+
runtime: 'nodejs', ...(descriptor.declaredRuntime !== undefined ? { declaredRuntime: descriptor.declaredRuntime } : {}), matchers: descriptor.matchers };
|
|
231
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { constants } from 'node:fs';
|
|
6
|
+
import { readFile, writeFile, rename, rm, lstat, realpath, access } from 'node:fs/promises';
|
|
7
|
+
import { checkProject } from './check.mjs';
|
|
8
|
+
import { sourceCheckout } from '../native/resolve.mjs';
|
|
9
|
+
|
|
10
|
+
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
11
|
+
const dependencyFields = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
12
|
+
const locks = ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'];
|
|
13
|
+
const own = (object, key) => Object.hasOwn(object, key);
|
|
14
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
|
|
16
|
+
async function optionalFile(file) {
|
|
17
|
+
try { return await readFile(file); }
|
|
18
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseMigrationArgs(args) {
|
|
22
|
+
const options = { dryRun: false, install: true, json: false };
|
|
23
|
+
let directory;
|
|
24
|
+
for (const arg of args) {
|
|
25
|
+
if (arg === '--dry-run') options.dryRun = true;
|
|
26
|
+
else if (arg === '--no-install') options.install = false;
|
|
27
|
+
else if (arg === '--json') options.json = true;
|
|
28
|
+
else if (arg.startsWith('-')) throw new Error(`Unknown migrate option: ${arg}. Use --dry-run, --no-install or --json.`);
|
|
29
|
+
else if (directory === undefined) directory = arg;
|
|
30
|
+
else throw new Error('migrate accepts a single project directory.');
|
|
31
|
+
}
|
|
32
|
+
return { directory: path.resolve(directory || '.'), options };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Preserve literal shell words without evaluating them. Complex shell programs
|
|
36
|
+
// need a manual edit: replacing a substring could drop a build step or rewrite
|
|
37
|
+
// a quoted example instead of the executable command.
|
|
38
|
+
function words(script) {
|
|
39
|
+
if (/[\r\n]/.test(script)) throw new Error('contains multiple shell lines');
|
|
40
|
+
const result = [];
|
|
41
|
+
const token = /\s*((?:[^\s'"\\;&|<>`$()*?#[\]{}~!]+|'[^']*'|"[^"\\`$]*")+)/gy;
|
|
42
|
+
let offset = 0;
|
|
43
|
+
while (offset < script.length) {
|
|
44
|
+
if (!script.slice(offset).trim()) break;
|
|
45
|
+
token.lastIndex = offset;
|
|
46
|
+
const match = token.exec(script);
|
|
47
|
+
if (!match) throw new Error('contains shell chaining, substitution or escaping');
|
|
48
|
+
result.push({ raw: match[1], value: match[1].replace(/'([^']*)'|"([^"]*)"/g, (_all, a, b) => a ?? b) });
|
|
49
|
+
offset = token.lastIndex;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function migrateScript(script, command) {
|
|
55
|
+
if (script === undefined) return { value: `prn ${command}`, removed: [] };
|
|
56
|
+
if (typeof script !== 'string') throw new Error(`scripts.${command} must be a string.`);
|
|
57
|
+
// Existing PRNext scripts, including customized wrappers, are not rewritten.
|
|
58
|
+
if (/^(?:prn|prnext)\s+/.test(script.trim())) return { value: script, removed: [] };
|
|
59
|
+
let tokens;
|
|
60
|
+
try { tokens = words(script); }
|
|
61
|
+
catch (error) { throw new Error(`scripts.${command} ${error.message}. Adapt it to prn ${command} manually before migrating; no scripts were overwritten.`); }
|
|
62
|
+
const prefix = [];
|
|
63
|
+
let environmentPort;
|
|
64
|
+
if (tokens[0]?.value === 'cross-env') prefix.push(tokens.shift().raw);
|
|
65
|
+
while (tokens[0] && /^[A-Za-z_][A-Za-z\d_]*=/.test(tokens[0].value)) {
|
|
66
|
+
const assignment = tokens.shift();
|
|
67
|
+
prefix.push(assignment.raw);
|
|
68
|
+
if (assignment.value.startsWith('PORT=')) environmentPort = assignment.value.slice(5);
|
|
69
|
+
}
|
|
70
|
+
if (tokens[0]?.value === 'npx') {
|
|
71
|
+
tokens.shift();
|
|
72
|
+
if (tokens[0]?.value === '--no-install') tokens.shift();
|
|
73
|
+
}
|
|
74
|
+
const executable = tokens.shift()?.value;
|
|
75
|
+
if (!['next', 'rx'].includes(executable) || tokens.shift()?.value !== command) {
|
|
76
|
+
throw new Error(`scripts.${command} is customized. Adapt it to prn ${command} manually before migrating; no scripts were overwritten.`);
|
|
77
|
+
}
|
|
78
|
+
// Upgrade the former shortcut without interpreting its native PRNext options
|
|
79
|
+
// as Next.js bundler flags. Complex shell programs still require a manual edit.
|
|
80
|
+
if (executable === 'rx') return { value: [...prefix, 'prn', command, ...tokens.map(token => token.raw)].join(' '), removed: [] };
|
|
81
|
+
const kept = [], removed = [];
|
|
82
|
+
let directory = false, explicitPort = false;
|
|
83
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
84
|
+
const { value, raw } = tokens[i];
|
|
85
|
+
if (['--turbo', '--turbopack', '--webpack'].includes(value) && command !== 'start') { removed.push(value); continue; }
|
|
86
|
+
const option = /^(-p|--port|-H|--hostname)(?:=(.*))?$/.exec(value);
|
|
87
|
+
if (option && command !== 'build') {
|
|
88
|
+
const flag = ['-p', '--port'].includes(option[1]) ? '--port' : '--hostname';
|
|
89
|
+
if (flag === '--port') explicitPort = true;
|
|
90
|
+
if (option[2] !== undefined) kept.push(raw.replace(/^(['"]?)(?:-p|--port|-H|--hostname)/, `$1${flag}`));
|
|
91
|
+
else {
|
|
92
|
+
const argument = tokens[++i];
|
|
93
|
+
if (!argument || argument.value.startsWith('-')) throw new Error(`scripts.${command}: ${option[1]} requires a value.`);
|
|
94
|
+
kept.push(flag, argument.raw);
|
|
95
|
+
}
|
|
96
|
+
} else if (!value.startsWith('-') && !directory) {
|
|
97
|
+
directory = true;
|
|
98
|
+
// The PRNext CLI takes the directory before its server options.
|
|
99
|
+
kept.unshift(raw);
|
|
100
|
+
} else throw new Error(`scripts.${command}: unsupported Next option ${raw}. Adapt the script before migrating.`);
|
|
101
|
+
}
|
|
102
|
+
// The native server takes an explicit port; preserve Next's literal PORT
|
|
103
|
+
// prefix when the script does not already override it with a CLI option.
|
|
104
|
+
if (command !== 'build' && !explicitPort && environmentPort !== undefined) {
|
|
105
|
+
if (!/^\d+$/.test(environmentPort) || Number(environmentPort) > 65535) throw new Error(`scripts.${command}: PORT must be a literal port number. Adapt the script before migrating.`);
|
|
106
|
+
kept.push('--port', environmentPort);
|
|
107
|
+
}
|
|
108
|
+
return { value: [...prefix, 'prn', command, ...kept].join(' '), removed };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function migrationPackage(input, framework, spec) {
|
|
112
|
+
if (!record(input)) throw new Error('package.json must contain an object.');
|
|
113
|
+
for (const field of ['scripts', ...dependencyFields]) {
|
|
114
|
+
if (input[field] !== undefined && !record(input[field])) throw new Error(`package.json ${field} must be an object.`);
|
|
115
|
+
}
|
|
116
|
+
const output = structuredClone(input);
|
|
117
|
+
const changes = [], notes = [];
|
|
118
|
+
output.scripts ||= {};
|
|
119
|
+
for (const command of ['dev', 'build', 'start']) {
|
|
120
|
+
const previous = input.scripts?.[command];
|
|
121
|
+
const migrated = migrateScript(previous, command);
|
|
122
|
+
if (previous === migrated.value) continue;
|
|
123
|
+
if (previous !== undefined) {
|
|
124
|
+
let backup = `${command}:next`, suffix = 2;
|
|
125
|
+
while (own(output.scripts, backup) && output.scripts[backup] !== previous) backup = `${command}:next:${suffix++}`;
|
|
126
|
+
if (!own(output.scripts, backup)) {
|
|
127
|
+
output.scripts[backup] = previous;
|
|
128
|
+
changes.push({ field: `scripts.${backup}`, before: null, after: previous });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
output.scripts[command] = migrated.value;
|
|
132
|
+
changes.push({ field: `scripts.${command}`, before: previous ?? null, after: migrated.value });
|
|
133
|
+
if (migrated.removed.length) notes.push(`scripts.${command}: removed Next bundler flags ${migrated.removed.join(', ')}; PRNext selects its compiler from the project configuration.`);
|
|
134
|
+
}
|
|
135
|
+
const expected = framework.peerDependencies['react-server-dom-webpack'];
|
|
136
|
+
const wanted = { [framework.name]: spec, react: expected, 'react-dom': expected, 'react-server-dom-webpack': expected };
|
|
137
|
+
// Remove the former local package name so two packages cannot compete for
|
|
138
|
+
// the same prn/prnext executable after installing the scoped release.
|
|
139
|
+
if (framework.name !== 'prnext') {
|
|
140
|
+
for (const field of dependencyFields) {
|
|
141
|
+
if (!output[field] || !own(output[field], 'prnext')) continue;
|
|
142
|
+
changes.push({ field: `${field}.prnext`, before: output[field].prnext, after: null });
|
|
143
|
+
delete output[field].prnext;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
output.dependencies ||= {};
|
|
147
|
+
for (const [name, version] of Object.entries(wanted)) {
|
|
148
|
+
if (output.dependencies[name] !== version) {
|
|
149
|
+
changes.push({ field: `dependencies.${name}`, before: output.dependencies[name] ?? null, after: version });
|
|
150
|
+
output.dependencies[name] = version;
|
|
151
|
+
}
|
|
152
|
+
for (const field of ['devDependencies', 'optionalDependencies']) {
|
|
153
|
+
if (output[field] && own(output[field], name)) {
|
|
154
|
+
changes.push({ field: `${field}.${name}`, before: output[field][name], after: null });
|
|
155
|
+
delete output[field][name];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (changes.some(change => /^dependencies\.react/.test(change.field))) notes.push(`React protocol packages are aligned to ${expected}. Check other libraries' peer dependencies during installation.`);
|
|
160
|
+
notes.push('Next.js, application sources and next.config files are kept. Existing Next scripts are saved as dev:next, build:next and start:next when applicable.');
|
|
161
|
+
return { package: output, changes, notes };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function installContext(root, pkg) {
|
|
165
|
+
const present = [];
|
|
166
|
+
for (const name of locks) if (await optionalFile(path.join(root, name))) present.push(name);
|
|
167
|
+
const manager = pkg.packageManager?.split('@')[0];
|
|
168
|
+
if (manager && manager !== 'npm') return { allowed: false, reason: `This project uses ${manager}. Use --no-install, then install with ${manager}.`, locks: present };
|
|
169
|
+
if (present.some(name => !['package-lock.json', 'npm-shrinkwrap.json'].includes(name))) {
|
|
170
|
+
return { allowed: false, reason: 'A non-npm lockfile is present. Use --no-install and the existing package manager to preserve its lockfile.', locks: present };
|
|
171
|
+
}
|
|
172
|
+
for (let directory = root; ; directory = path.dirname(directory)) {
|
|
173
|
+
const contents = directory === root ? null : await optionalFile(path.join(directory, 'package.json'));
|
|
174
|
+
let ancestor;
|
|
175
|
+
try { ancestor = directory === root ? pkg : contents && JSON.parse(contents); } catch { ancestor = null; }
|
|
176
|
+
if (ancestor?.workspaces) return { allowed: false, reason: 'A workspace package manager must be run from its workspace root. Use --no-install, then install from that root.', locks: present };
|
|
177
|
+
if (directory === path.dirname(directory)) break;
|
|
178
|
+
}
|
|
179
|
+
return { allowed: true, locks: present };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function backup(file, contents) {
|
|
183
|
+
for (let index = 0; index < 1000; index++) {
|
|
184
|
+
const destination = `${file}.prnext-backup${index ? '.' + index : ''}`;
|
|
185
|
+
try { await writeFile(destination, contents, { flag: 'wx', mode: 0o600 }); return destination; }
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error.code !== 'EEXIST') throw error;
|
|
188
|
+
if ((await readFile(destination)).equals(contents)) return destination;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw new Error(`Too many migration backups for ${file}.`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function replacePackage(file, original, text, mode) {
|
|
195
|
+
const temporary = `${file}.prnext-${randomUUID()}.tmp`;
|
|
196
|
+
try {
|
|
197
|
+
await writeFile(temporary, text, { flag: 'wx', mode });
|
|
198
|
+
if (!(await readFile(file)).equals(original)) throw new Error('package.json changed during migration. Retry after the other edit finishes.');
|
|
199
|
+
await rename(temporary, file);
|
|
200
|
+
} finally { await rm(temporary, { force: true }); }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function installMigrationDependencies(root, { json = false, local = false } = {}) {
|
|
204
|
+
const npm = process.env.npm_execpath;
|
|
205
|
+
// Never use a shell to interpolate a project path or a package specification.
|
|
206
|
+
const args = ['install', '--workspaces=false', ...(local ? ['--install-links=false'] : [])];
|
|
207
|
+
const command = npm && /(?:^|[/\\])npm(?:-cli)?\.(?:c?js)$/.test(npm) ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
208
|
+
const argv = command === process.execPath ? [npm, ...args] : args;
|
|
209
|
+
await new Promise((resolve, reject) => {
|
|
210
|
+
// Windows .cmd launchers require a shell; argv contains only fixed flags,
|
|
211
|
+
// never project data. npx/npm invocations use npm-cli.js directly instead.
|
|
212
|
+
const child = spawn(command, argv, { cwd: root, stdio: json ? ['inherit', 2, 2] : 'inherit', shell: process.platform === 'win32' && command === 'npm.cmd' });
|
|
213
|
+
child.once('error', reject);
|
|
214
|
+
child.once('exit', (code, signal) => code === 0 ? resolve() : reject(new Error(`npm install failed (${signal || code}). Backups are available; fix the dependency conflict and retry. No --force or --legacy-peer-deps override was used.`)));
|
|
215
|
+
});
|
|
216
|
+
for (const name of ['prn', 'prnext']) {
|
|
217
|
+
const executable = path.join(root, 'node_modules/.bin', name + (process.platform === 'win32' ? '.cmd' : ''));
|
|
218
|
+
try { await access(executable, process.platform === 'win32' ? constants.F_OK : constants.X_OK); }
|
|
219
|
+
catch { throw new Error(`npm did not install the ${name} executable. Check npm's bin-links setting and the local PRNext dependency path, then retry.`); }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function migrateProject(directory, { dryRun = false, install = true, json = false } = {}, services = {}) {
|
|
224
|
+
const root = path.resolve(directory), file = path.join(root, 'package.json');
|
|
225
|
+
const report = { root, ok: false, status: 'checking', dryRun, changes: [], backups: [], notes: [], preflight: null };
|
|
226
|
+
try {
|
|
227
|
+
const stat = await lstat(file);
|
|
228
|
+
if (!stat.isFile()) throw new Error('Migration requires a regular package.json file, not a symlink or directory.');
|
|
229
|
+
const original = await readFile(file);
|
|
230
|
+
const source = original.toString('utf8');
|
|
231
|
+
const input = JSON.parse(source.replace(/^\uFEFF/, ''));
|
|
232
|
+
if (!record(input)) throw new Error('package.json must contain an object.');
|
|
233
|
+
const framework = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
234
|
+
if ([framework.name, 'prnext', 'prnext-monorepo'].includes(input.name)) throw new Error('Choose the Next.js application directory, not the PRNext framework repository.');
|
|
235
|
+
const local = sourceCheckout() !== null;
|
|
236
|
+
// npm resolves file dependencies from the physical working directory. A
|
|
237
|
+
// symlink such as macOS /var -> /private/var otherwise changes ../ depth.
|
|
238
|
+
const relative = path.relative(await realpath(root), await realpath(packageRoot)).split(path.sep).join('/') || '.';
|
|
239
|
+
const spec = local ? `file:${relative}` : framework.version;
|
|
240
|
+
const plan = migrationPackage(input, framework, spec);
|
|
241
|
+
report.changes = plan.changes;
|
|
242
|
+
report.notes = plan.notes;
|
|
243
|
+
if (local) report.notes.push(`This unpublished checkout uses a local PRNext dependency (${spec}); keep this checkout available. Rust is required on the first native build.`);
|
|
244
|
+
const context = await installContext(root, input);
|
|
245
|
+
if (!context.allowed) {
|
|
246
|
+
report.notes.push(context.reason);
|
|
247
|
+
if (install && !dryRun) throw new Error(context.reason);
|
|
248
|
+
}
|
|
249
|
+
const check = services.check || checkProject;
|
|
250
|
+
report.preflight = await check(root, { validateDependencies: false });
|
|
251
|
+
if (!report.preflight.ok) throw new Error(`Project preflight failed: ${report.preflight.errors.join(' ')}`);
|
|
252
|
+
if (!report.preflight.routes.length) throw new Error('No application routes found. Choose the Next.js app directory containing app/ or pages/.');
|
|
253
|
+
if (dryRun) { report.ok = true; report.status = 'preview'; return report; }
|
|
254
|
+
if (plan.changes.length || install) {
|
|
255
|
+
report.backups.push(await backup(file, original));
|
|
256
|
+
for (const name of context.locks) {
|
|
257
|
+
const lockFile = path.join(root, name);
|
|
258
|
+
const stat = await lstat(lockFile);
|
|
259
|
+
if (!stat.isFile()) throw new Error(`Migration requires a regular ${name} file.`);
|
|
260
|
+
report.backups.push(await backup(lockFile, await readFile(lockFile)));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (plan.changes.length) {
|
|
264
|
+
const indent = /\n([\t ]+)"/.exec(source)?.[1] || 2;
|
|
265
|
+
const newline = source.includes('\r\n') ? '\r\n' : '\n';
|
|
266
|
+
const text = (source.startsWith('\uFEFF') ? '\uFEFF' : '') + JSON.stringify(plan.package, null, indent).replaceAll('\n', newline) + (/\r?\n$/.test(source) ? newline : '');
|
|
267
|
+
await replacePackage(file, original, text, stat.mode & 0o777);
|
|
268
|
+
}
|
|
269
|
+
report.status = plan.changes.length ? 'prepared' : 'unchanged';
|
|
270
|
+
if (!install) {
|
|
271
|
+
report.ok = true;
|
|
272
|
+
report.notes.push('Dependency installation and installed-version validation are pending. Install with your package manager, then run prn check and prn build.');
|
|
273
|
+
return report;
|
|
274
|
+
}
|
|
275
|
+
report.status = 'installing';
|
|
276
|
+
await (services.install || installMigrationDependencies)(root, { json, local });
|
|
277
|
+
report.preflight = await check(root);
|
|
278
|
+
if (!report.preflight.ok) throw new Error(`Dependencies were installed, but final preflight failed: ${report.preflight.errors.join(' ')}`);
|
|
279
|
+
report.ok = true;
|
|
280
|
+
report.status = 'migrated';
|
|
281
|
+
report.notes.push('Migration prepared and dependencies checked. Run npm run build and test your application before deploying.');
|
|
282
|
+
return report;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
report.error = error.message;
|
|
285
|
+
if (report.status === 'checking') report.status = 'blocked';
|
|
286
|
+
else report.status = 'incomplete';
|
|
287
|
+
return report;
|
|
288
|
+
}
|
|
289
|
+
}
|