@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.
Files changed (71) hide show
  1. package/README.md +68 -103
  2. package/package.json +3 -2
  3. package/reference/data/bench.json +513 -0
  4. package/reference/performance.md +87 -29
  5. package/src/api/router/runtime.ts +10 -2
  6. package/src/cache/context.ts +4 -1
  7. package/src/cli/build.ts +39 -5
  8. package/src/cli/create.ts +146 -0
  9. package/src/cli/dev.ts +6 -0
  10. package/src/cli/index.ts +28 -4
  11. package/src/cli/migrate/index.ts +96 -0
  12. package/src/cli/migrate/package-json.ts +183 -0
  13. package/src/cli/migrate/report.ts +80 -0
  14. package/src/cli/migrate/scan.ts +135 -0
  15. package/src/cli/migrate/spinner.ts +20 -0
  16. package/src/cli/migrate/tsconfig.ts +77 -0
  17. package/src/cli/request-pipeline.ts +1340 -0
  18. package/src/cli/server-entry.ts +180 -0
  19. package/src/cli/start.ts +39 -1311
  20. package/src/client/build.ts +59 -20
  21. package/src/client/chunk-fold.ts +40 -0
  22. package/src/client/compat-surface.ts +175 -0
  23. package/src/client/entry.ts +67 -52
  24. package/src/compat/actions/action-client.ts +8 -1
  25. package/src/compat/actions/action-dispatch.ts +11 -1
  26. package/src/compat/actions/discovery.ts +23 -6
  27. package/src/compat/bundler/optimize-package-imports.ts +5 -1
  28. package/src/compat/bundler/worker.ts +2 -1
  29. package/src/compat/client/errors/bare-boundary.ts +32 -0
  30. package/src/compat/client/errors/error-boundary.ts +1 -15
  31. package/src/compat/client/errors/primitive-throw.ts +16 -0
  32. package/src/compat/client/link-status.ts +1 -1
  33. package/src/compat/css/lightningcss.ts +2 -1
  34. package/src/compat/css/modules.ts +4 -3
  35. package/src/compat/index.ts +1 -1
  36. package/src/compat/lifecycle/instrumentation-client.ts +1 -1
  37. package/src/compat/lifecycle/instrumentation.ts +5 -2
  38. package/src/compat/next/config-loader.ts +33 -5
  39. package/src/compat/next/dynamic.tsx +9 -5
  40. package/src/compat/next/link-validation-transform.ts +5 -1
  41. package/src/compat/next/link.tsx +51 -58
  42. package/src/compat/pages/client-plugin.ts +2 -1
  43. package/src/compat/react/action-state.ts +159 -0
  44. package/src/compat/react/client-lite.ts +74 -0
  45. package/src/compat/react/hooks-extra.ts +92 -0
  46. package/src/compat/react/parity.ts +128 -0
  47. package/src/compat/react/preact.ts +33 -420
  48. package/src/compat/react/server-inserted-html.ts +14 -7
  49. package/src/compat/react/use.ts +72 -0
  50. package/src/compat/register/actions.ts +27 -7
  51. package/src/compat/tsconfig-defaults.ts +6 -3
  52. package/src/config.ts +15 -1
  53. package/src/css/build.ts +13 -2
  54. package/src/dev/imports.ts +34 -5
  55. package/src/dev/module-cache.ts +35 -2
  56. package/src/dev/module-transform.ts +7 -1
  57. package/src/dev/server.ts +91 -22
  58. package/src/ppr.ts +5 -4
  59. package/src/proxy.ts +5 -1
  60. package/src/render/island-context.ts +21 -3
  61. package/src/render/renderer.ts +102 -26
  62. package/src/resolve/engine.ts +12 -2
  63. package/src/resolve/scan-facts.ts +5 -1
  64. package/src/routing/href.ts +4 -5
  65. package/src/routing/routes.ts +14 -2
  66. package/src/runtime/server.ts +8 -4
  67. package/src/runtime/vendor.ts +1 -1
  68. package/src/typegen.ts +3 -3
  69. package/src/utils/esbuild.ts +58 -0
  70. package/src/utils/fs.ts +14 -2
  71. package/src/utils/native-require.ts +28 -0
@@ -22,7 +22,10 @@ export interface CacheScope {
22
22
  controller?: AbortController;
23
23
  }
24
24
 
25
- const storage = new AsyncLocalStorage<CacheScope>();
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
- await ensureEmptyDir(config.outPath);
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
- const actionFiles = new Set(actionSources.map(source => path.resolve(config.root, source)));
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 => actionFiles.has(path.resolve(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 { start } = await import('./start');
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: positionalRoot(args),
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
- console.error(formatted.message);
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
+ }