@wular/pnext 0.0.1 → 0.0.3
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 +68 -103
- package/package.json +3 -2
- package/reference/data/bench.json +513 -0
- package/reference/performance.md +87 -29
- package/src/api/router/runtime.ts +10 -2
- package/src/cache/context.ts +4 -1
- package/src/cli/build.ts +39 -5
- package/src/cli/create.ts +146 -0
- package/src/cli/dev.ts +6 -0
- package/src/cli/index.ts +28 -4
- package/src/cli/migrate/index.ts +96 -0
- package/src/cli/migrate/package-json.ts +183 -0
- package/src/cli/migrate/report.ts +80 -0
- package/src/cli/migrate/scan.ts +135 -0
- package/src/cli/migrate/spinner.ts +20 -0
- package/src/cli/migrate/tsconfig.ts +77 -0
- package/src/cli/request-pipeline.ts +1340 -0
- package/src/cli/server-entry.ts +180 -0
- package/src/cli/start.ts +39 -1311
- package/src/client/build.ts +59 -20
- package/src/client/chunk-fold.ts +40 -0
- package/src/client/compat-surface.ts +175 -0
- package/src/client/entry.ts +67 -52
- package/src/compat/actions/action-client.ts +8 -1
- package/src/compat/actions/action-dispatch.ts +11 -1
- package/src/compat/actions/discovery.ts +23 -6
- package/src/compat/bundler/optimize-package-imports.ts +5 -1
- package/src/compat/bundler/worker.ts +2 -1
- package/src/compat/client/errors/bare-boundary.ts +32 -0
- package/src/compat/client/errors/error-boundary.ts +1 -15
- package/src/compat/client/errors/primitive-throw.ts +16 -0
- package/src/compat/client/link-status.ts +1 -1
- package/src/compat/css/lightningcss.ts +2 -1
- package/src/compat/css/modules.ts +4 -3
- package/src/compat/index.ts +1 -1
- package/src/compat/lifecycle/instrumentation-client.ts +1 -1
- package/src/compat/lifecycle/instrumentation.ts +5 -2
- package/src/compat/next/config-loader.ts +33 -5
- package/src/compat/next/dynamic.tsx +9 -5
- package/src/compat/next/link-validation-transform.ts +5 -1
- package/src/compat/next/link.tsx +51 -58
- package/src/compat/pages/client-plugin.ts +2 -1
- package/src/compat/react/action-state.ts +159 -0
- package/src/compat/react/client-lite.ts +74 -0
- package/src/compat/react/hooks-extra.ts +92 -0
- package/src/compat/react/parity.ts +128 -0
- package/src/compat/react/preact.ts +33 -420
- package/src/compat/react/server-inserted-html.ts +14 -7
- package/src/compat/react/use.ts +72 -0
- package/src/compat/register/actions.ts +27 -7
- package/src/compat/tsconfig-defaults.ts +6 -3
- package/src/config.ts +15 -1
- package/src/css/build.ts +13 -2
- package/src/dev/imports.ts +34 -5
- package/src/dev/module-cache.ts +35 -2
- package/src/dev/module-transform.ts +7 -1
- package/src/dev/server.ts +91 -22
- package/src/ppr.ts +5 -4
- package/src/proxy.ts +5 -1
- package/src/render/island-context.ts +21 -3
- package/src/render/renderer.ts +102 -26
- package/src/resolve/engine.ts +12 -2
- package/src/resolve/scan-facts.ts +5 -1
- package/src/routing/href.ts +4 -5
- package/src/routing/routes.ts +14 -2
- package/src/runtime/server.ts +8 -4
- package/src/runtime/vendor.ts +1 -1
- package/src/typegen.ts +3 -3
- package/src/utils/esbuild.ts +58 -0
- package/src/utils/fs.ts +14 -2
- package/src/utils/native-require.ts +28 -0
package/src/cache/context.ts
CHANGED
|
@@ -22,7 +22,10 @@ export interface CacheScope {
|
|
|
22
22
|
controller?: AbortController;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// globalThis-anchored: the prebundled server entry inlines its own copy of this module.
|
|
26
|
+
const CACHE_SCOPE_STORAGE = Symbol.for('pnext.cacheScopeStorage');
|
|
27
|
+
const storage = ((globalThis as Record<PropertyKey, unknown>)[CACHE_SCOPE_STORAGE] ??=
|
|
28
|
+
new AsyncLocalStorage<CacheScope>()) as AsyncLocalStorage<CacheScope>;
|
|
26
29
|
|
|
27
30
|
export function currentCacheScope() {
|
|
28
31
|
return storage.getStore();
|
package/src/cli/build.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { copyFile, mkdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { writeVercelOutput } from './adapters/vercel';
|
|
7
7
|
import { startWarmChild } from './adapters/vercel-warm';
|
|
8
|
-
import { loadConfig, pathToFileHref } from '../config';
|
|
8
|
+
import { devOutSegment, loadConfig, pathToFileHref } from '../config';
|
|
9
9
|
import { bootstrapCompat } from '../compat-bootstrap';
|
|
10
10
|
import {
|
|
11
11
|
buildParallelPhaseError,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from '../extensions';
|
|
22
22
|
import { buildClientEntries, emitStaticClientChunks, startClientSources } from '../client/build';
|
|
23
23
|
import { beginSourceScope, endSourceScope, sourceCacheStats } from '../resolve/source-text';
|
|
24
|
+
import { flushDevModuleCaches } from '../dev/module-cache';
|
|
24
25
|
import { scanFactsStats } from '../resolve/scan-facts';
|
|
25
26
|
import { clientEntryName } from '../client/paths';
|
|
26
27
|
import { registerServerRuntime, serverBundleTargetForRuntime } from '../runtime/server';
|
|
@@ -99,6 +100,9 @@ interface BuildStepState {
|
|
|
99
100
|
/** Root-relative 'use server' module paths — known from discovery, so the
|
|
100
101
|
* client stage keys off this instead of waiting for their compile. */
|
|
101
102
|
actionSources?: string[];
|
|
103
|
+
/** First-party files that import a node_modules action module (absolute paths) — see
|
|
104
|
+
* ActionDiscovery.actionImporters; route.sourceFiles never reaches into node_modules itself. */
|
|
105
|
+
actionImporters?: string[];
|
|
102
106
|
/** Work a step handed back rather than finishing inline; awaited before the
|
|
103
107
|
* build manifest is written, so it lands under the client stage. */
|
|
104
108
|
deferred?: Promise<void>;
|
|
@@ -160,6 +164,7 @@ export async function buildProject(root?: string, options: BuildOptions = {}) {
|
|
|
160
164
|
} finally {
|
|
161
165
|
endSourceScope();
|
|
162
166
|
restoreSpecifiersManifest();
|
|
167
|
+
flushDevModuleCaches();
|
|
163
168
|
}
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -190,9 +195,20 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
190
195
|
// adapter step.
|
|
191
196
|
const warm = options.adapter === 'vercel' ? startWarmChild(config) : undefined;
|
|
192
197
|
await log.step('prepare output directory', async () => {
|
|
193
|
-
|
|
198
|
+
// Build-owned outputs only: `<outRoot>/dev` belongs to a possibly-running
|
|
199
|
+
// dev server and must survive.
|
|
200
|
+
await ensureEmptyDir(config.outPath, [devOutSegment]);
|
|
194
201
|
await copyPublicDir(config.publicPath, path.join(config.outPath, 'public'));
|
|
195
202
|
});
|
|
203
|
+
// Prebundled server entry for `pnext start`: framework-only, independent of the
|
|
204
|
+
// app build, so it runs in a child process for the whole build — its bundling
|
|
205
|
+
// heap never stacks on the build's peak RSS and its wall hides under the build.
|
|
206
|
+
// Best-effort — a failure only costs start time. Awaited before the summary.
|
|
207
|
+
const serverEntryDone = import('./server-entry')
|
|
208
|
+
.then(entry => entry.emitServerEntryChild(config.outPath))
|
|
209
|
+
.catch((error: Error) => {
|
|
210
|
+
console.warn(`pnext build: server entry bundling skipped — ${error.message}`);
|
|
211
|
+
});
|
|
196
212
|
// The document-level stylesheets run their postcss/Tailwind pass on the CSS
|
|
197
213
|
// worker, so they overlap with the route scan below instead of serializing
|
|
198
214
|
// ahead of it. Awaited before prepareRouteCssChunks — route CSS still builds
|
|
@@ -319,11 +335,19 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
319
335
|
);
|
|
320
336
|
|
|
321
337
|
const actionSources = buildState.actionSources ?? buildState.actions.map(a => a.sourceKey);
|
|
322
|
-
|
|
338
|
+
// realpath, not resolve: a hybrid app's routes are scanned through the pages-compat mirror, whose
|
|
339
|
+
// `source-app` is a symlink to the real app dir - the two spellings of one file must compare equal.
|
|
340
|
+
const actionFiles = new Set(actionSources.map(source => realFilePath(path.resolve(config.root, source))));
|
|
341
|
+
// A node_modules action module never lands in route.sourceFiles itself (that walk stops at the
|
|
342
|
+
// package boundary), so a route reaching one only through a first-party importer is matched here.
|
|
343
|
+
const actionImporters = new Set((buildState.actionImporters ?? []).map(realFilePath));
|
|
323
344
|
for (const route of routes) {
|
|
324
345
|
if (
|
|
325
346
|
route.kind === 'page' &&
|
|
326
|
-
route.sourceFiles.some(file =>
|
|
347
|
+
route.sourceFiles.some(file => {
|
|
348
|
+
const real = realFilePath(file);
|
|
349
|
+
return actionFiles.has(real) || actionImporters.has(real);
|
|
350
|
+
})
|
|
327
351
|
) {
|
|
328
352
|
addClientEntryReason(route, 'actions');
|
|
329
353
|
}
|
|
@@ -871,6 +895,7 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
871
895
|
};
|
|
872
896
|
await writeBuildManifest(config.outPath, manifest);
|
|
873
897
|
log.log(`wrote manifest.json (${routes.length} route${routes.length === 1 ? '' : 's'})`);
|
|
898
|
+
await log.step('server entry', () => serverEntryDone);
|
|
874
899
|
for (const hook of getBuildExtensions().completeHooks) {
|
|
875
900
|
await hook({ config, manifest, log });
|
|
876
901
|
}
|
|
@@ -3053,6 +3078,15 @@ function isAfterPrerenderError(error: unknown): boolean {
|
|
|
3053
3078
|
);
|
|
3054
3079
|
}
|
|
3055
3080
|
|
|
3081
|
+
/** Symlink-resolved path, for comparing two spellings of one file. Missing files keep their path. */
|
|
3082
|
+
function realFilePath(file: string): string {
|
|
3083
|
+
try {
|
|
3084
|
+
return realpathSync.native(file);
|
|
3085
|
+
} catch {
|
|
3086
|
+
return path.resolve(file);
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3056
3090
|
function safePublicPath(outPath: string, ...segments: string[]) {
|
|
3057
3091
|
const publicPath = path.join(outPath, 'public');
|
|
3058
3092
|
const file = path.join(publicPath, ...segments);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { bold, cyan, dim, green } from '../utils/ansi';
|
|
5
|
+
|
|
6
|
+
export interface CreateAppOptions {
|
|
7
|
+
install: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function createApp(dir: string | undefined, options: CreateAppOptions) {
|
|
11
|
+
if (!dir) {
|
|
12
|
+
console.log('Usage: pnext create <directory> [--no-install]');
|
|
13
|
+
throw new Error('pnext create requires a directory argument');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const target = path.resolve(dir);
|
|
17
|
+
if (existsSync(target)) {
|
|
18
|
+
if ((await readdir(target)).length > 0) {
|
|
19
|
+
throw new Error(`Directory already exists and is not empty: ${target}`);
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
await mkdir(target, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const name = sanitizePackageName(path.basename(target));
|
|
26
|
+
console.log(`${cyan('▲')} ${bold('pnext create')} ${dim(`— scaffolding ${name}`)}\n`);
|
|
27
|
+
|
|
28
|
+
const files = scaffoldFiles(name);
|
|
29
|
+
await Promise.all(
|
|
30
|
+
Object.entries(files).map(([file, source]) => Bun.write(path.join(target, file), source)),
|
|
31
|
+
);
|
|
32
|
+
for (const file of Object.keys(files)) console.log(` ${green('+')} ${dim(file)}`);
|
|
33
|
+
|
|
34
|
+
let installed = false;
|
|
35
|
+
if (options.install) {
|
|
36
|
+
console.log('');
|
|
37
|
+
installed = await install(target, dir);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
printNextSteps(dir, installed);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// npm package name rules: lowercase, url-safe, no leading dot/underscore.
|
|
44
|
+
function sanitizePackageName(raw: string) {
|
|
45
|
+
const name = raw
|
|
46
|
+
.trim()
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
49
|
+
.replace(/^[-._]+|[-._]+$/g, '');
|
|
50
|
+
return name || 'pnext-app';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function install(target: string, dir: string) {
|
|
54
|
+
const stop = spinner('Installing dependencies...');
|
|
55
|
+
const start = Bun.nanoseconds();
|
|
56
|
+
const proc = Bun.spawn(['bun', 'install'], { cwd: target, stdout: 'pipe', stderr: 'pipe' });
|
|
57
|
+
// Drain both pipes: an unread pipe can fill and stall the child.
|
|
58
|
+
const [stderr, , code] = await Promise.all([
|
|
59
|
+
new Response(proc.stderr).text(),
|
|
60
|
+
new Response(proc.stdout).text(),
|
|
61
|
+
proc.exited,
|
|
62
|
+
]);
|
|
63
|
+
stop();
|
|
64
|
+
|
|
65
|
+
if (code === 0) {
|
|
66
|
+
const durationMs = (Bun.nanoseconds() - start) / 1e6;
|
|
67
|
+
console.log(`${green('✓')} ${bold('Installed dependencies')} ${dim(`in ${formatDuration(durationMs)}`)}`);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
console.log(`${dim('Install failed. Run it manually:')}`);
|
|
71
|
+
console.log(` ${cyan('cd')} ${dir}`);
|
|
72
|
+
console.log(` ${cyan('bun install')}`);
|
|
73
|
+
if (stderr.trim()) console.log(dim(stderr.trim()));
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Braille spinner, TTY-only; a plain line prints once and stays put otherwise. */
|
|
78
|
+
function spinner(label: string) {
|
|
79
|
+
if (!process.stdout.isTTY) {
|
|
80
|
+
console.log(label);
|
|
81
|
+
return () => undefined;
|
|
82
|
+
}
|
|
83
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
84
|
+
let frame = 0;
|
|
85
|
+
process.stdout.write(`${cyan(frames[0]!)} ${label}`);
|
|
86
|
+
const timer = setInterval(() => {
|
|
87
|
+
frame = (frame + 1) % frames.length;
|
|
88
|
+
process.stdout.write(`\r${cyan(frames[frame]!)} ${label}`);
|
|
89
|
+
}, 80);
|
|
90
|
+
return () => {
|
|
91
|
+
clearInterval(timer);
|
|
92
|
+
process.stdout.write('\r\x1b[K');
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatDuration(durationMs: number) {
|
|
97
|
+
const totalSeconds = Math.max(0, durationMs) / 1000;
|
|
98
|
+
return `${totalSeconds.toFixed(totalSeconds < 10 ? 2 : 1)}s`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function printNextSteps(dir: string, installed: boolean) {
|
|
102
|
+
const commands = installed ? [`cd ${dir}`, 'bun dev'] : [`cd ${dir}`, 'bun install', 'bun dev'];
|
|
103
|
+
const title = 'Next steps';
|
|
104
|
+
const width = Math.max(title.length, ...commands.map(c => c.length)) + 2;
|
|
105
|
+
const row = (text: string, colorFn: (s: string) => string = s => s) =>
|
|
106
|
+
`${dim('│')} ${colorFn(text.padEnd(width))} ${dim('│')}`;
|
|
107
|
+
|
|
108
|
+
console.log('');
|
|
109
|
+
console.log(dim(`┌${'─'.repeat(width + 2)}┐`));
|
|
110
|
+
console.log(row(title, bold));
|
|
111
|
+
console.log(row(''));
|
|
112
|
+
for (const cmd of commands) console.log(row(cmd, cyan));
|
|
113
|
+
console.log(dim(`└${'─'.repeat(width + 2)}┘`));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function scaffoldFiles(name: string): Record<string, string> {
|
|
117
|
+
return {
|
|
118
|
+
'package.json': `${JSON.stringify(
|
|
119
|
+
{
|
|
120
|
+
name,
|
|
121
|
+
private: true,
|
|
122
|
+
scripts: { dev: 'pnext dev', build: 'pnext build', start: 'pnext start', analyze: 'pnext analyze' },
|
|
123
|
+
dependencies: { preact: '^10' },
|
|
124
|
+
devDependencies: { '@wular/pnext': '^0.0.1', typescript: '^5', '@types/bun': '^1' },
|
|
125
|
+
},
|
|
126
|
+
null,
|
|
127
|
+
2,
|
|
128
|
+
)}\n`,
|
|
129
|
+
'pnext.config.ts': `// See node_modules/@wular/pnext/reference/config.md for available options.\nexport default {};\n`,
|
|
130
|
+
'tsconfig.json': `${JSON.stringify(
|
|
131
|
+
{
|
|
132
|
+
extends: '@wular/pnext/config/ts/react.json',
|
|
133
|
+
compilerOptions: { jsxImportSource: 'preact', paths: { '#gen/*': ['./.pnext/types/*'] } },
|
|
134
|
+
include: ['**/*.ts', '**/*.tsx', '.pnext/types/**/*.ts'],
|
|
135
|
+
},
|
|
136
|
+
null,
|
|
137
|
+
2,
|
|
138
|
+
)}\n`,
|
|
139
|
+
'app/layout.tsx': `import type { LayoutProps } from '@wular/pnext';\nimport './globals.css';\n\nexport const metadata = {\n title: '${name}',\n};\n\nexport default function RootLayout({ children }: LayoutProps) {\n return (\n <html>\n <body>{children}</body>\n </html>\n );\n}\n`,
|
|
140
|
+
'app/page.tsx': `import Counter from './counter';\n\nexport default async function Home() {\n return (\n <>\n <h1>Welcome to ${name}</h1>\n <p>Edit app/page.tsx to get started.</p>\n <Counter />\n </>\n );\n}\n`,
|
|
141
|
+
'app/counter.tsx': `'use client';\n\nimport { useState } from 'preact/hooks';\n\nexport default function Counter() {\n const [count, setCount] = useState(0);\n return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;\n}\n`,
|
|
142
|
+
'app/globals.css': `body {\n margin: 0;\n font-family: system-ui, sans-serif;\n}\n`,
|
|
143
|
+
'.gitignore': `node_modules\n.pnext\n`,
|
|
144
|
+
'README.md': `# ${name}\n\nA pnext app.\n\n\`\`\`\nbun install\nbun dev\n\`\`\`\n\nDocs: node_modules/@wular/pnext/reference/overview.md\n`,
|
|
145
|
+
};
|
|
146
|
+
}
|
package/src/cli/dev.ts
CHANGED
|
@@ -57,6 +57,12 @@ export async function dev(options: DevOptions = {}) {
|
|
|
57
57
|
const url = `http://${browserHost(hostname)}:${server.port ?? port}`;
|
|
58
58
|
printServerReady({ mode: 'dev', hostname, port: server.port ?? port, elapsedMs });
|
|
59
59
|
printBootTrace();
|
|
60
|
+
// Boot compiles nothing (config loads natively), so the first compile would pay esbuild's service
|
|
61
|
+
// spawn. Warm it here, after the banner - readiness must not wait on it. PNEXT_DEV_ESBUILD_WARM=0 opts out.
|
|
62
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
63
|
+
if (process.env.PNEXT_DEV_ESBUILD_WARM !== '0') {
|
|
64
|
+
void import('../utils/esbuild').then(module => module.warmEsbuildService());
|
|
65
|
+
}
|
|
60
66
|
registerShutdown(server);
|
|
61
67
|
watchServerMemory(server);
|
|
62
68
|
watchStaleModules(server);
|
package/src/cli/index.ts
CHANGED
|
@@ -33,15 +33,26 @@ try {
|
|
|
33
33
|
buildMode: buildModeOption(args),
|
|
34
34
|
debugPrerender: args.includes('--debug-prerender'),
|
|
35
35
|
});
|
|
36
|
+
// A live CSS worker thread races Bun's exit teardown (Linux: silent exit 1
|
|
37
|
+
// after a successful build); stop it before force-exiting.
|
|
38
|
+
await (await import('../css/build')).stopCssWorker();
|
|
36
39
|
// Nothing here keeps the event loop alive on success, but a project's
|
|
37
40
|
// next.config can (e.g. a stray setInterval) and Next's own `next build`
|
|
38
41
|
// force-exits regardless of such handles. Do the same so `build` always
|
|
39
42
|
// terminates once buildProject resolves.
|
|
40
43
|
process.exit(0);
|
|
41
44
|
} else if (command === 'start') {
|
|
42
|
-
const
|
|
45
|
+
const root = positionalRoot(args);
|
|
46
|
+
// The build emits src/cli/start.ts prebundled into one file; parsing that
|
|
47
|
+
// instead of walking the framework's source graph is most of `start`'s
|
|
48
|
+
// spawn→first-200. Absent (no build yet, custom outDir) → source path.
|
|
49
|
+
const { prebuiltServerEntry } = await import('./server-entry');
|
|
50
|
+
const prebuilt = prebuiltServerEntry(root);
|
|
51
|
+
const { start } = prebuilt
|
|
52
|
+
? ((await import(prebuilt)) as typeof import('./start'))
|
|
53
|
+
: await import('./start');
|
|
43
54
|
await start({
|
|
44
|
-
root
|
|
55
|
+
root,
|
|
45
56
|
port: optionNumber(args, '--port'),
|
|
46
57
|
hostname: optionString(args, '--hostname'),
|
|
47
58
|
});
|
|
@@ -67,13 +78,24 @@ try {
|
|
|
67
78
|
// Like `next typegen`, exit even when the loaded next.config leaves open
|
|
68
79
|
// handles (timers/connections) alive — a one-shot command must not hang.
|
|
69
80
|
process.exit(0);
|
|
81
|
+
} else if (command === 'create') {
|
|
82
|
+
const { createApp } = await import('./create');
|
|
83
|
+
await createApp(positionals(args)[0], { install: !args.includes('--no-install') });
|
|
84
|
+
process.exit(0);
|
|
85
|
+
} else if (command === 'migrate') {
|
|
86
|
+
const { migrateApp } = await import('./migrate');
|
|
87
|
+
const code = await migrateApp(positionalRoot(args), { dryRun: args.includes('--dry-run') });
|
|
88
|
+
process.exit(code);
|
|
70
89
|
} else {
|
|
71
90
|
printHelp();
|
|
72
91
|
process.exit(command ? 1 : 0);
|
|
73
92
|
}
|
|
74
93
|
} catch (error) {
|
|
75
94
|
const formatted = formatCliError(error);
|
|
76
|
-
|
|
95
|
+
// A blank formatted message exits 1 with no explanation; fall back to the
|
|
96
|
+
// raw error so the failure is never silent.
|
|
97
|
+
if (formatted.message.trim()) console.error(formatted.message);
|
|
98
|
+
else console.error(error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error));
|
|
77
99
|
if (formatted.trace) console.log(`\n${formatted.trace}`);
|
|
78
100
|
process.exit(1);
|
|
79
101
|
}
|
|
@@ -192,5 +214,7 @@ function printHelp() {
|
|
|
192
214
|
pnext build [directory] [--adapter vercel] [--verbose]
|
|
193
215
|
pnext start [directory] [--port 3000] [--hostname 127.0.0.1]
|
|
194
216
|
pnext analyze [route] [directory] [--brotli] [--files] [--json]
|
|
195
|
-
pnext typegen [directory]
|
|
217
|
+
pnext typegen [directory]
|
|
218
|
+
pnext create <directory> [--no-install]
|
|
219
|
+
pnext migrate [directory] [--dry-run]`);
|
|
196
220
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// `pnext migrate [directory] [--dry-run]` — converts a Next.js app in place.
|
|
2
|
+
//
|
|
3
|
+
// Only structured, safe edits are applied (package.json, tsconfig.json,
|
|
4
|
+
// pnext.config.ts, .gitignore, next-env.d.ts). Application source is never
|
|
5
|
+
// rewritten: it is scanned and reported so the user stays in control.
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { appendFile, readFile, rm, writeFile } from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { bold } from '../../utils/ansi';
|
|
11
|
+
import { migratePackageJson, readPackageJson } from './package-json';
|
|
12
|
+
import { emptyResult, printHeader, printResult, type MigrationResult } from './report';
|
|
13
|
+
import { scanSources } from './scan';
|
|
14
|
+
import { withSpinner } from './spinner';
|
|
15
|
+
import { migrateTsconfig } from './tsconfig';
|
|
16
|
+
|
|
17
|
+
const NEXT_CONFIG_FILES = ['next.config.ts', 'next.config.js', 'next.config.mjs', 'next.config.cjs'];
|
|
18
|
+
const PNEXT_CONFIG_FILES = ['pnext.config.ts', 'pnext.config.js', 'pnext.config.mjs'];
|
|
19
|
+
|
|
20
|
+
export async function migrateApp(directory: string | undefined, options: { dryRun: boolean }) {
|
|
21
|
+
const root = path.resolve(directory ?? process.cwd());
|
|
22
|
+
const pkg = await readPackageJson(root);
|
|
23
|
+
const hasNextDependency = Boolean(
|
|
24
|
+
pkg &&
|
|
25
|
+
(hasDependency(pkg.dependencies, 'next') || hasDependency(pkg.devDependencies, 'next')),
|
|
26
|
+
);
|
|
27
|
+
const hasNextConfig = NEXT_CONFIG_FILES.some(name => existsSync(path.join(root, name)));
|
|
28
|
+
|
|
29
|
+
if (!hasNextDependency && !hasNextConfig) {
|
|
30
|
+
console.error(
|
|
31
|
+
`${bold('Not a Next.js app')}: ${root}\n` +
|
|
32
|
+
` - ${pkg ? 'package.json has no "next" dependency' : 'no readable package.json'}\n` +
|
|
33
|
+
' - no next.config.{ts,js,mjs,cjs}',
|
|
34
|
+
);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
printHeader(root, options.dryRun);
|
|
39
|
+
const result = emptyResult();
|
|
40
|
+
if (pkg) await migratePackageJson(root, pkg, result, options.dryRun);
|
|
41
|
+
await createPnextConfig(root, result, options.dryRun);
|
|
42
|
+
await migrateTsconfig(root, result, options.dryRun);
|
|
43
|
+
await removeNextEnv(root, result, options.dryRun);
|
|
44
|
+
await updateGitignore(root, result, options.dryRun);
|
|
45
|
+
await withSpinner('Scanning sources ...', () => scanSources(root, result));
|
|
46
|
+
|
|
47
|
+
printResult(result, { install: installCommand(root) });
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function createPnextConfig(root: string, result: MigrationResult, dryRun: boolean) {
|
|
52
|
+
const existing = PNEXT_CONFIG_FILES.find(name => existsSync(path.join(root, name)));
|
|
53
|
+
if (existing) {
|
|
54
|
+
result.reports.push({
|
|
55
|
+
title: `${existing} already exists`,
|
|
56
|
+
detail: 'Make sure it sets compat: { next: true }. See reference/compat.md.',
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!dryRun) {
|
|
61
|
+
await writeFile(
|
|
62
|
+
path.join(root, 'pnext.config.ts'),
|
|
63
|
+
'export default { compat: { next: true } };\n',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
result.edits.push({ file: 'pnext.config.ts', description: 'created with compat.next enabled' });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function removeNextEnv(root: string, result: MigrationResult, dryRun: boolean) {
|
|
70
|
+
const file = path.join(root, 'next-env.d.ts');
|
|
71
|
+
if (!existsSync(file)) return;
|
|
72
|
+
if (!dryRun) await rm(file);
|
|
73
|
+
result.edits.push({ file: 'next-env.d.ts', description: 'deleted' });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function updateGitignore(root: string, result: MigrationResult, dryRun: boolean) {
|
|
77
|
+
const file = path.join(root, '.gitignore');
|
|
78
|
+
if (!existsSync(file)) return;
|
|
79
|
+
const text = await readFile(file, 'utf8');
|
|
80
|
+
if (text.split(/\r?\n/).some(line => line.trim() === '.pnext/')) return;
|
|
81
|
+
if (!dryRun) await appendFile(file, `${text.endsWith('\n') || text === '' ? '' : '\n'}.pnext/\n`);
|
|
82
|
+
result.edits.push({ file: '.gitignore', description: 'appended .pnext/' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function installCommand(root: string) {
|
|
86
|
+
if (existsSync(path.join(root, 'bun.lock')) || existsSync(path.join(root, 'bun.lockb'))) {
|
|
87
|
+
return 'bun install';
|
|
88
|
+
}
|
|
89
|
+
if (existsSync(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm install';
|
|
90
|
+
if (existsSync(path.join(root, 'yarn.lock'))) return 'yarn';
|
|
91
|
+
return 'npm install';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hasDependency(bucket: unknown, name: string) {
|
|
95
|
+
return typeof bucket === 'object' && bucket !== null && name in bucket;
|
|
96
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// package.json rewriting: scripts and dependencies. Structured edit only —
|
|
2
|
+
// JSON.parse -> mutate -> stringify, which preserves key insertion order.
|
|
3
|
+
|
|
4
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import type { MigrationResult } from './report';
|
|
7
|
+
|
|
8
|
+
const SEPARATORS = new Set(['&&', '||', ';', '|', '&']);
|
|
9
|
+
const MAPPED_SUBCOMMANDS = new Set(['dev', 'build', 'start', 'typegen']);
|
|
10
|
+
const DROPPED_FLAGS = new Set(['--turbo', '--turbopack']);
|
|
11
|
+
|
|
12
|
+
type Json = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Rewrite `next` only where it is a command word: the first token of the
|
|
16
|
+
* script or the first token after a shell separator. Substrings like
|
|
17
|
+
* `nextron build` or `npx next-sitemap` are never touched.
|
|
18
|
+
*/
|
|
19
|
+
export function rewriteScript(script: string): { script: string; reports: string[] } {
|
|
20
|
+
const tokens = script.split(/\s+/).filter(Boolean);
|
|
21
|
+
const reports: string[] = [];
|
|
22
|
+
const out: string[] = [];
|
|
23
|
+
let commandStart = true;
|
|
24
|
+
let changed = false;
|
|
25
|
+
|
|
26
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
27
|
+
const token = tokens[index]!;
|
|
28
|
+
if (SEPARATORS.has(token)) {
|
|
29
|
+
out.push(token);
|
|
30
|
+
commandStart = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// Env-var prefixes (NODE_OPTIONS=x next dev) don't end the command position.
|
|
34
|
+
if (commandStart && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
|
|
35
|
+
out.push(token);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!commandStart || token !== 'next') {
|
|
39
|
+
out.push(token);
|
|
40
|
+
commandStart = false;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let end = index + 1;
|
|
45
|
+
while (end < tokens.length && !SEPARATORS.has(tokens[end]!)) end += 1;
|
|
46
|
+
const subcommand = tokens[index + 1];
|
|
47
|
+
const rest = tokens.slice(index + 2, end);
|
|
48
|
+
|
|
49
|
+
if (subcommand && MAPPED_SUBCOMMANDS.has(subcommand)) {
|
|
50
|
+
out.push('pnext', subcommand, ...translateFlags(subcommand, rest));
|
|
51
|
+
changed = true;
|
|
52
|
+
} else if (subcommand === 'lint') {
|
|
53
|
+
out.push(...tokens.slice(index, end));
|
|
54
|
+
reports.push('`next lint` left as-is — pnext has no lint command; run eslint directly.');
|
|
55
|
+
} else {
|
|
56
|
+
out.push(...tokens.slice(index, end));
|
|
57
|
+
const command = subcommand ? `next ${subcommand}` : 'next';
|
|
58
|
+
reports.push(`\`${command}\` has no pnext equivalent — left as-is.`);
|
|
59
|
+
}
|
|
60
|
+
index = end - 1;
|
|
61
|
+
commandStart = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { script: changed ? out.join(' ') : script, reports };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function translateFlags(subcommand: string, rest: string[]) {
|
|
68
|
+
const out: string[] = [];
|
|
69
|
+
for (const flag of rest) {
|
|
70
|
+
if (DROPPED_FLAGS.has(flag)) continue;
|
|
71
|
+
if (flag === '-p' && (subcommand === 'dev' || subcommand === 'start')) {
|
|
72
|
+
out.push('--port');
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
out.push(flag);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function migratePackageJson(
|
|
81
|
+
root: string,
|
|
82
|
+
pkg: Json,
|
|
83
|
+
result: MigrationResult,
|
|
84
|
+
dryRun: boolean,
|
|
85
|
+
) {
|
|
86
|
+
const scripts = isObject(pkg.scripts) ? pkg.scripts : undefined;
|
|
87
|
+
if (scripts) {
|
|
88
|
+
const rewritten: string[] = [];
|
|
89
|
+
for (const [name, value] of Object.entries(scripts)) {
|
|
90
|
+
if (typeof value !== 'string') continue;
|
|
91
|
+
const { script, reports } = rewriteScript(value);
|
|
92
|
+
if (script !== value) {
|
|
93
|
+
scripts[name] = script;
|
|
94
|
+
rewritten.push(`${name}: ${value} → ${script}`);
|
|
95
|
+
}
|
|
96
|
+
for (const detail of reports) {
|
|
97
|
+
result.reports.push({ title: `package.json script "${name}"`, detail });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (rewritten.length > 0) {
|
|
101
|
+
result.edits.push({
|
|
102
|
+
file: 'package.json',
|
|
103
|
+
description: `scripts rewritten (${rewritten.length})`,
|
|
104
|
+
details: rewritten,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const dependencies = ensureObject(pkg, 'dependencies');
|
|
110
|
+
const devDependencies = ensureObject(pkg, 'devDependencies');
|
|
111
|
+
|
|
112
|
+
for (const [field, bucket] of [
|
|
113
|
+
['dependencies', dependencies],
|
|
114
|
+
['devDependencies', devDependencies],
|
|
115
|
+
] as const) {
|
|
116
|
+
if ('next' in bucket) {
|
|
117
|
+
delete bucket.next;
|
|
118
|
+
result.edits.push({
|
|
119
|
+
file: 'package.json',
|
|
120
|
+
description: 'next removed',
|
|
121
|
+
details: [`next removed from ${field}`],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!('@wular/pnext' in devDependencies) && !('@wular/pnext' in dependencies)) {
|
|
126
|
+
devDependencies['@wular/pnext'] = '^0.0.1';
|
|
127
|
+
result.edits.push({
|
|
128
|
+
file: 'package.json',
|
|
129
|
+
description: '@wular/pnext added',
|
|
130
|
+
details: ['devDependencies: @wular/pnext ^0.0.1'],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (!('preact' in dependencies) && !('preact' in devDependencies)) {
|
|
134
|
+
dependencies.preact = '^10';
|
|
135
|
+
result.edits.push({
|
|
136
|
+
file: 'package.json',
|
|
137
|
+
description: 'preact added',
|
|
138
|
+
details: ['dependencies: preact ^10 (react/react-dom kept — compat aliases them)'],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const leftovers = [...Object.keys(dependencies), ...Object.keys(devDependencies)].filter(
|
|
143
|
+
name => name === 'eslint-config-next' || name.startsWith('@next/'),
|
|
144
|
+
);
|
|
145
|
+
if (leftovers.length > 0) {
|
|
146
|
+
result.reports.push({
|
|
147
|
+
title: 'Next-specific packages still installed',
|
|
148
|
+
detail: 'Optional cleanup — these are unused under pnext. See reference/compat.md.',
|
|
149
|
+
files: leftovers,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pruneEmpty(pkg, 'dependencies');
|
|
154
|
+
pruneEmpty(pkg, 'devDependencies');
|
|
155
|
+
|
|
156
|
+
if (!dryRun) {
|
|
157
|
+
await writeFile(path.join(root, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function readPackageJson(root: string): Promise<Json | undefined> {
|
|
162
|
+
try {
|
|
163
|
+
const parsed = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')) as unknown;
|
|
164
|
+
return isObject(parsed) ? parsed : undefined;
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isObject(value: unknown): value is Json {
|
|
171
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Create the bucket in place so an existing key keeps its position.
|
|
175
|
+
function ensureObject(pkg: Json, key: string): Json {
|
|
176
|
+
if (!isObject(pkg[key])) pkg[key] = {};
|
|
177
|
+
return pkg[key] as Json;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function pruneEmpty(pkg: Json, key: string) {
|
|
181
|
+
const value = pkg[key];
|
|
182
|
+
if (isObject(value) && Object.keys(value).length === 0) delete pkg[key];
|
|
183
|
+
}
|