@wular/pnext 0.0.1 → 0.0.2
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 +30 -121
- package/package.json +1 -1
- package/reference/performance.md +47 -16
- package/src/cli/build.ts +2 -0
- package/src/cli/create.ts +146 -0
- package/src/cli/index.ts +11 -1
- 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/compat/index.ts +1 -1
- package/src/compat/tsconfig-defaults.ts +6 -3
- package/src/dev/module-cache.ts +16 -2
package/README.md
CHANGED
|
@@ -6,148 +6,57 @@
|
|
|
6
6
|
|
|
7
7
|
</div>
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
The same fixture source runs under both frameworks; `bun bench` measures them side by side and rewrites [`reference/performance.md`](./reference/performance.md) with your machine's absolute numbers. The ranges below span the fixtures — hello-world, an SSR site, and a mid-size admin dashboard (30 routes, 18 client islands):
|
|
12
|
-
|
|
13
|
-
| | pnext vs Next.js |
|
|
14
|
-
|---|--:|
|
|
15
|
-
| Dev server ready | **2.5–3× faster** |
|
|
16
|
-
| First page, cold | **6.5–13× faster** |
|
|
17
|
-
| Warm request | **4–7.5× faster** |
|
|
18
|
-
| HMR save → visible | **1–2.5× faster** |
|
|
19
|
-
| Production build | **4.5–10× faster** |
|
|
20
|
-
| First-page client JS (gzip) | **6–65× less** |
|
|
21
|
-
|
|
22
|
-
Nothing is prebundled — the dev server compiles what a request needs and caches it content-addressed, so it's ready in ~100 ms at any app size.
|
|
23
|
-
|
|
24
|
-
Core pnext (no Next compat) is its own story, and it's smaller still:
|
|
9
|
+
Server-rendered pages ship **0 KB** of JavaScript — **~1 KB gzip** with client-side navigation and prefetching. Interactive pages hydrate on Preact for **~7.5 KB** of framework, or **~12.5 KB** with React compatibility. Everything is instant: the dev server starts **~3× faster** than Next's on **~4× less memory**, and production builds run **4.5–10× faster**. The Next.js App Router compatibility is validated against Next's own test suite, 4,400+ assertions passing.
|
|
25
10
|
|
|
26
|
-
|
|
27
|
-
- A server-rendered page with links ships **~1 KB gzip** of framework, total; the router + prefetch runtime alone is **348 bytes**.
|
|
28
|
-
- A fully hydrated route pays **~7.5 KB gzip** of framework — **12.5 KB** with React compat, **15 KB** with the full `next/*` surface. Next.js ships 130+ KB before your first component.
|
|
11
|
+
## Getting started
|
|
29
12
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
## How
|
|
33
|
-
|
|
34
|
-
One pipeline, three fast tools: [Bun](https://bun.sh) as runtime, esbuild as the only bundler (dev and prod are the same artifacts — no dev/prod drift), and oxc for parsing, resolving, and transforms. Preact renders and hydrates; React APIs work through compat. The dev server compiles exactly what a request needs, when it needs it.
|
|
35
|
-
|
|
36
|
-
## Install
|
|
13
|
+
A new app:
|
|
37
14
|
|
|
38
15
|
```sh
|
|
39
|
-
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
```json
|
|
43
|
-
{
|
|
44
|
-
"scripts": {
|
|
45
|
-
"dev": "pnext dev --port 3000",
|
|
46
|
-
"build": "pnext build",
|
|
47
|
-
"build:vercel": "pnext build --adapter vercel",
|
|
48
|
-
"start": "pnext start --port 3000",
|
|
49
|
-
"analyze": "pnext analyze"
|
|
50
|
-
}
|
|
51
|
-
}
|
|
16
|
+
bunx @wular/pnext create my-app
|
|
52
17
|
```
|
|
53
18
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
pnext looks for `app/` or `src/app/`:
|
|
57
|
-
|
|
58
|
-
```txt
|
|
59
|
-
src/app/
|
|
60
|
-
globals.css
|
|
61
|
-
page.tsx
|
|
62
|
-
layout.tsx
|
|
63
|
-
users/
|
|
64
|
-
[id]/
|
|
65
|
-
page.tsx
|
|
66
|
-
api/
|
|
67
|
-
hello/
|
|
68
|
-
route.ts
|
|
69
|
-
public/
|
|
70
|
-
logo.svg
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
`public/` is served from `/`. Global CSS imported from the root layout is compiled and linked in the head, with your PostCSS/Tailwind setup. CSS from pages and components becomes route CSS; `.module.css` is scoped. `layout.tsx` can export `metadata`, a default wrapper component, or both — a metadata-only root layout lets pnext create the document shell.
|
|
74
|
-
|
|
75
|
-
## Pages
|
|
76
|
-
|
|
77
|
-
Pages and layouts are Server Components by default:
|
|
78
|
-
|
|
79
|
-
```tsx
|
|
80
|
-
export const metadata = {
|
|
81
|
-
title: 'My app',
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
export default async function Page() {
|
|
85
|
-
const post = await getPost();
|
|
86
|
-
return <article>{post.title}</article>;
|
|
87
|
-
}
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
Add a Client Component only where the browser matters:
|
|
91
|
-
|
|
92
|
-
```tsx
|
|
93
|
-
'use client';
|
|
94
|
-
|
|
95
|
-
import { useState } from 'preact/hooks';
|
|
19
|
+
Migrating a Next.js app — rewrites scripts and config in place, scans your source, and reports anything that needs a look (never edits your code):
|
|
96
20
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return <button onClick={() => setCount(count + 1)}>{count}</button>;
|
|
100
|
-
}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
Server Components render Client Components with serializable props; Client Components server-render and hydrate. Server-rendered children passed into a Client Component stay on the server and ship no code.
|
|
104
|
-
|
|
105
|
-
Dedupe repeated reads with server-only `cache()`:
|
|
106
|
-
|
|
107
|
-
```tsx
|
|
108
|
-
import { cache } from '@wular/pnext/cache';
|
|
109
|
-
|
|
110
|
-
export const getPost = cache(async (id: string) => {
|
|
111
|
-
return db.post.findUnique({ where: { id } });
|
|
112
|
-
});
|
|
21
|
+
```sh
|
|
22
|
+
bunx @wular/pnext migrate
|
|
113
23
|
```
|
|
114
24
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
`route.ts` files are HTTP handlers:
|
|
118
|
-
|
|
119
|
-
```tsx
|
|
120
|
-
import type { NextRequest } from '@wular/pnext/server';
|
|
121
|
-
|
|
122
|
-
export function GET(request: NextRequest) {
|
|
123
|
-
const name = request.nextUrl.searchParams.get('name');
|
|
124
|
-
return Response.json({ hello: name ?? 'world' });
|
|
125
|
-
}
|
|
126
|
-
```
|
|
25
|
+
Or by hand: `bun add -d @wular/pnext`, then `pnext dev`.
|
|
127
26
|
|
|
128
|
-
|
|
27
|
+
Adoption is incremental: core pnext is based on pure Preact, `compat.react` runs React components and libraries on it, and `compat.next` runs a whole Next.js App Router app unchanged — start anywhere on that ladder and move when it suits you. See [Compatibility](./reference/compat.md).
|
|
129
28
|
|
|
130
|
-
##
|
|
29
|
+
## Measured against Next.js
|
|
131
30
|
|
|
132
|
-
|
|
31
|
+
The same fixture source runs under both frameworks; `bun bench` measures them side by side and rewrites [`reference/performance.md`](./reference/performance.md) with your machine's absolute numbers. The ranges below span the fixtures — hello-world, an SSR site, and a mid-size admin dashboard (30 routes, 18 client islands):
|
|
133
32
|
|
|
134
|
-
|
|
33
|
+
| | pnext vs Next.js |
|
|
34
|
+
| --------------------------- | ---------------------------------: |
|
|
35
|
+
| Dev server ready | **2.5–3× faster** |
|
|
36
|
+
| First page, cold | **6.5–13× faster** |
|
|
37
|
+
| Warm request | **4–7.5× faster** |
|
|
38
|
+
| HMR save → visible | **1–2.5× faster** |
|
|
39
|
+
| Production build | **4.5–10× faster** |
|
|
40
|
+
| Dev server memory | **3.5–4× less** |
|
|
41
|
+
| Build peak memory | **3–4× less** |
|
|
42
|
+
| Framework install size | **42× smaller** (6.7 MB vs 286 MB) |
|
|
43
|
+
| First-page client JS (gzip) | **6–65× less** |
|
|
135
44
|
|
|
136
|
-
|
|
137
|
-
bun bench
|
|
138
|
-
```
|
|
45
|
+
Nothing is prebundled — the dev server compiles what a request needs and caches it content-addressed, which is why readiness doesn't scale with app size. One metric goes the other way: the production server idles ~10 MB heavier than Next's (~113 vs ~101 MB, Bun's runtime baseline) while answering warm requests up to 2× faster.
|
|
139
46
|
|
|
140
|
-
|
|
47
|
+
## Learn
|
|
141
48
|
|
|
142
|
-
|
|
49
|
+
Apps are file-routed from `app/`: `page.tsx` and `layout.tsx` are Server Components, `route.ts` files are HTTP handlers, `public/` is served from `/`. The reference covers the rest:
|
|
143
50
|
|
|
144
51
|
- [Overview](./reference/overview.md)
|
|
52
|
+
- [Dev server](./reference/dev.md)
|
|
145
53
|
- [Routing](./reference/routing.md)
|
|
146
|
-
- [
|
|
54
|
+
- [Navigation](./reference/navigation.md)
|
|
147
55
|
- [Rendering](./reference/rendering.md)
|
|
56
|
+
- [Metadata](./reference/metadata.md)
|
|
148
57
|
- [CSS](./reference/css.md)
|
|
149
58
|
- [Environment Variables](./reference/env.md)
|
|
150
|
-
- [Compatibility](./reference/compat.md)
|
|
151
|
-
- [Typegen](./reference/typegen.md)
|
|
152
59
|
- [Config](./reference/config.md)
|
|
60
|
+
- [Typegen](./reference/typegen.md)
|
|
61
|
+
- [Compatibility](./reference/compat.md)
|
|
153
62
|
- [Performance](./reference/performance.md)
|
package/package.json
CHANGED
package/reference/performance.md
CHANGED
|
@@ -15,11 +15,17 @@ two columns render the same app from the same source.
|
|
|
15
15
|
|
|
16
16
|
| Metric | pnext | Next.js | Ratio |
|
|
17
17
|
| --- | --- | --- | --- |
|
|
18
|
-
| Dev cold start (ready) |
|
|
19
|
-
| Dev first page HTML |
|
|
20
|
-
| Dev warm request (p50 of 7) | 2.
|
|
21
|
-
|
|
|
22
|
-
|
|
|
18
|
+
| Dev cold start (ready) | 98.9 ms | 269.1 ms | 2.72x |
|
|
19
|
+
| Dev first page HTML | 81.7 ms | 1053.6 ms | 12.89x |
|
|
20
|
+
| Dev warm request (p50 of 7) | 2.9 ms | 13.0 ms | 4.54x |
|
|
21
|
+
| Dev server RSS (ready + 7 warm) | 140.4 MB | 585.7 MB | 4.17x |
|
|
22
|
+
| HMR save → visible | 12.4 ms | 40.2 ms | 3.24x |
|
|
23
|
+
| Prod build (wall) | 329.6 ms | 3008.5 ms | 9.13x |
|
|
24
|
+
| Prod build peak RSS | 130.2 MB | 497.1 MB | 3.82x |
|
|
25
|
+
| Prod start (ready) | 122.3 ms | 137.5 ms | 1.12x |
|
|
26
|
+
| Prod warm request (p50 of 7) | 0.6 ms | 1.0 ms | 1.71x |
|
|
27
|
+
| Prod server RSS (ready + 7 warm) | 112.7 MB | 100.6 MB | 0.89x |
|
|
28
|
+
| Framework install size | 6.7 MB | 285.6 MB | 42.43x |
|
|
23
29
|
| First-page client JS (raw) | 4.43 KB | 502.47 KB | 113.36x |
|
|
24
30
|
| First-page client JS (gzip) | 2.19 KB | 141.78 KB | 64.73x |
|
|
25
31
|
| First-page JS files | 3 | 5 | 1.67x |
|
|
@@ -31,11 +37,17 @@ Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
|
|
|
31
37
|
|
|
32
38
|
| Metric | pnext | Next.js | Ratio |
|
|
33
39
|
| --- | --- | --- | --- |
|
|
34
|
-
| Dev cold start (ready) |
|
|
35
|
-
| Dev first page HTML |
|
|
36
|
-
| Dev warm request (p50 of 7) |
|
|
37
|
-
|
|
|
38
|
-
|
|
|
40
|
+
| Dev cold start (ready) | 97.6 ms | 265.3 ms | 2.72x |
|
|
41
|
+
| Dev first page HTML | 105.1 ms | 1179.0 ms | 11.22x |
|
|
42
|
+
| Dev warm request (p50 of 7) | 4.6 ms | 28.2 ms | 6.18x |
|
|
43
|
+
| Dev server RSS (ready + 7 warm) | 164.7 MB | 639.8 MB | 3.89x |
|
|
44
|
+
| HMR save → visible | 22.8 ms | 66.1 ms | 2.90x |
|
|
45
|
+
| Prod build (wall) | 360.0 ms | 3192.1 ms | 8.87x |
|
|
46
|
+
| Prod build peak RSS | 136.1 MB | 511.4 MB | 3.76x |
|
|
47
|
+
| Prod start (ready) | 112.7 ms | 137.7 ms | 1.22x |
|
|
48
|
+
| Prod warm request (p50 of 7) | 0.5 ms | 1.2 ms | 2.24x |
|
|
49
|
+
| Prod server RSS (ready + 7 warm) | 114.0 MB | 100.8 MB | 0.88x |
|
|
50
|
+
| Framework install size | 6.7 MB | 285.6 MB | 42.43x |
|
|
39
51
|
| First-page client JS (raw) | 40.37 KB | 502.78 KB | 12.45x |
|
|
40
52
|
| First-page client JS (gzip) | 16.13 KB | 142.03 KB | 8.80x |
|
|
41
53
|
| First-page JS files | 4 | 6 | 1.50x |
|
|
@@ -47,11 +59,17 @@ Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
|
|
|
47
59
|
|
|
48
60
|
| Metric | pnext | Next.js | Ratio |
|
|
49
61
|
| --- | --- | --- | --- |
|
|
50
|
-
| Dev cold start (ready) |
|
|
51
|
-
| Dev first page HTML |
|
|
52
|
-
| Dev warm request (p50 of 7) |
|
|
53
|
-
|
|
|
54
|
-
|
|
|
62
|
+
| Dev cold start (ready) | 105.8 ms | 304.9 ms | 2.88x |
|
|
63
|
+
| Dev first page HTML | 170.8 ms | 1323.9 ms | 7.75x |
|
|
64
|
+
| Dev warm request (p50 of 7) | 10.7 ms | 30.7 ms | 2.86x |
|
|
65
|
+
| Dev server RSS (ready + 7 warm) | 186.4 MB | 665.7 MB | 3.57x |
|
|
66
|
+
| HMR save → visible | 71.9 ms | 78.9 ms | 1.10x |
|
|
67
|
+
| Prod build (wall) | 947.2 ms | 4246.6 ms | 4.48x |
|
|
68
|
+
| Prod build peak RSS | 189.0 MB | 608.5 MB | 3.22x |
|
|
69
|
+
| Prod start (ready) | 149.1 ms | 138.7 ms | 0.93x |
|
|
70
|
+
| Prod warm request (p50 of 7) | 1.4 ms | 1.4 ms | 0.96x |
|
|
71
|
+
| Prod server RSS (ready + 7 warm) | 114.8 MB | 105.7 MB | 0.92x |
|
|
72
|
+
| Framework install size | 6.7 MB | 285.6 MB | 42.43x |
|
|
55
73
|
| First-page client JS (raw) | 63.26 KB | 507.42 KB | 8.02x |
|
|
56
74
|
| First-page client JS (gzip) | 23.63 KB | 144.85 KB | 6.13x |
|
|
57
75
|
| First-page JS files | 5 | 9 | 1.80x |
|
|
@@ -69,7 +87,7 @@ Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
|
|
|
69
87
|
|
|
70
88
|
| Target | Limit | Measured | Status |
|
|
71
89
|
| --- | --- | --- | --- |
|
|
72
|
-
| Dev cold start, ssr fixture (pnext) | <= 150 ms |
|
|
90
|
+
| Dev cold start, ssr fixture (pnext) | <= 150 ms | 97.6 ms | PASS |
|
|
73
91
|
| Router runtime | <= 1.00 KB gzip | 348 B gzip | PASS |
|
|
74
92
|
| Hydrated-route framework tax | <= 5.00 KB gzip | 4.47 KB gzip | PASS |
|
|
75
93
|
| Zero-island route client JS (ssr `/about`) | 0 B (core pnext) | 4.46 KB (compat.next) | not exercised |
|
|
@@ -88,6 +106,19 @@ Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
|
|
|
88
106
|
pnext's Next-compat navigation client, which a core pnext app does not carry — the
|
|
89
107
|
0 B zero-island budget is a core-pnext invariant this suite does not exercise.
|
|
90
108
|
|
|
109
|
+
## Memory
|
|
110
|
+
|
|
111
|
+
- RSS is summed across the whole process tree (parent + spawned workers), read once at a
|
|
112
|
+
fixed point: right after the ready signal and the 7 warm requests, dev and prod alike.
|
|
113
|
+
It is never sampled at an arbitrary time, since RSS is pressure-sensitive.
|
|
114
|
+
- Build peak RSS comes from `/usr/bin/time` wrapping the build process directly (`-l` on
|
|
115
|
+
macOS, `-v` on Linux), not the tree-sum helper — it is the OS-reported peak over the
|
|
116
|
+
whole build, not a single snapshot.
|
|
117
|
+
- Framework install size is each framework's own package cost, not the fixture's total
|
|
118
|
+
`node_modules`, which both frameworks share: `next` + its platform `@next/swc-*` binary,
|
|
119
|
+
or `@wular/pnext`'s npm-publish footprint (its `package.json` "files" list, since this
|
|
120
|
+
workspace resolves it to source rather than an installed build) + `preact`.
|
|
121
|
+
|
|
91
122
|
## Commands
|
|
92
123
|
|
|
93
124
|
```sh
|
package/src/cli/build.ts
CHANGED
|
@@ -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';
|
|
@@ -160,6 +161,7 @@ export async function buildProject(root?: string, options: BuildOptions = {}) {
|
|
|
160
161
|
} finally {
|
|
161
162
|
endSourceScope();
|
|
162
163
|
restoreSpecifiersManifest();
|
|
164
|
+
flushDevModuleCaches();
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
167
|
|
|
@@ -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/index.ts
CHANGED
|
@@ -67,6 +67,14 @@ try {
|
|
|
67
67
|
// Like `next typegen`, exit even when the loaded next.config leaves open
|
|
68
68
|
// handles (timers/connections) alive — a one-shot command must not hang.
|
|
69
69
|
process.exit(0);
|
|
70
|
+
} else if (command === 'create') {
|
|
71
|
+
const { createApp } = await import('./create');
|
|
72
|
+
await createApp(positionals(args)[0], { install: !args.includes('--no-install') });
|
|
73
|
+
process.exit(0);
|
|
74
|
+
} else if (command === 'migrate') {
|
|
75
|
+
const { migrateApp } = await import('./migrate');
|
|
76
|
+
const code = await migrateApp(positionalRoot(args), { dryRun: args.includes('--dry-run') });
|
|
77
|
+
process.exit(code);
|
|
70
78
|
} else {
|
|
71
79
|
printHelp();
|
|
72
80
|
process.exit(command ? 1 : 0);
|
|
@@ -192,5 +200,7 @@ function printHelp() {
|
|
|
192
200
|
pnext build [directory] [--adapter vercel] [--verbose]
|
|
193
201
|
pnext start [directory] [--port 3000] [--hostname 127.0.0.1]
|
|
194
202
|
pnext analyze [route] [directory] [--brotli] [--files] [--json]
|
|
195
|
-
pnext typegen [directory]
|
|
203
|
+
pnext typegen [directory]
|
|
204
|
+
pnext create <directory> [--no-install]
|
|
205
|
+
pnext migrate [directory] [--dry-run]`);
|
|
196
206
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Shared result types and rendering for the migrate command. Edits are what
|
|
2
|
+
// migrate changes on disk; report items are things the user must look at by
|
|
3
|
+
// hand. The body of the output is identical under --dry-run — only the header
|
|
4
|
+
// says whether anything was written.
|
|
5
|
+
|
|
6
|
+
import { bold, cyan, dim, green } from '../../utils/ansi';
|
|
7
|
+
|
|
8
|
+
/** Amber for warnings — ansi.ts has no yellow, so hand-roll the same shape. */
|
|
9
|
+
const amber = (value: string) =>
|
|
10
|
+
process.stdout.isTTY ? `\x1b[38;5;214m${value}\x1b[39m` : value;
|
|
11
|
+
|
|
12
|
+
export interface Edit {
|
|
13
|
+
file: string;
|
|
14
|
+
/** Short phrase joined into the file's summary line. */
|
|
15
|
+
description: string;
|
|
16
|
+
/** Dim lines printed under the summary. */
|
|
17
|
+
details?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReportItem {
|
|
21
|
+
title: string;
|
|
22
|
+
detail: string;
|
|
23
|
+
files?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MigrationResult {
|
|
27
|
+
edits: Edit[];
|
|
28
|
+
reports: ReportItem[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function emptyResult(): MigrationResult {
|
|
32
|
+
return { edits: [], reports: [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function printHeader(root: string, dryRun: boolean) {
|
|
36
|
+
const suffix = dryRun ? 'dry run, nothing will be written' : root;
|
|
37
|
+
console.log(`\n${cyan('▲')} ${bold('pnext migrate')} ${dim(`— ${suffix}`)}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function printResult(result: MigrationResult, options: { install: string }) {
|
|
41
|
+
const lines: string[] = [];
|
|
42
|
+
|
|
43
|
+
lines.push(`\n${bold('Changes')}`);
|
|
44
|
+
if (result.edits.length === 0) {
|
|
45
|
+
lines.push(` ${dim('nothing to change')}`);
|
|
46
|
+
}
|
|
47
|
+
for (const [file, edits] of groupByFile(result.edits)) {
|
|
48
|
+
lines.push(
|
|
49
|
+
`${green('✓')} ${bold(file)} ${dim(`— ${edits.map(edit => edit.description).join(', ')}`)}`,
|
|
50
|
+
);
|
|
51
|
+
for (const edit of edits) {
|
|
52
|
+
for (const detail of edit.details ?? []) lines.push(` ${dim(detail)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push(`\n${bold('Review')}${result.reports.length > 0 ? ` (${result.reports.length})` : ''}`);
|
|
57
|
+
if (result.reports.length === 0) {
|
|
58
|
+
lines.push(` ${dim('nothing flagged')}`);
|
|
59
|
+
}
|
|
60
|
+
for (const item of result.reports) {
|
|
61
|
+
lines.push(`${amber('!')} ${bold(item.title)}`);
|
|
62
|
+
lines.push(` ${dim(item.detail)}`);
|
|
63
|
+
for (const file of item.files ?? []) lines.push(` ${dim(`· ${file}`)}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
lines.push(`\n${bold('Next steps')}`);
|
|
67
|
+
lines.push(` Run ${cyan(options.install)} to install the new dependencies.`);
|
|
68
|
+
lines.push(` ${cyan('pnext dev')} to start the app.`);
|
|
69
|
+
console.log(lines.join('\n'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function groupByFile(edits: Edit[]) {
|
|
73
|
+
const grouped = new Map<string, Edit[]>();
|
|
74
|
+
for (const edit of edits) {
|
|
75
|
+
const bucket = grouped.get(edit.file);
|
|
76
|
+
if (bucket) bucket.push(edit);
|
|
77
|
+
else grouped.set(edit.file, [edit]);
|
|
78
|
+
}
|
|
79
|
+
return grouped;
|
|
80
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Report-only source scan. Import specifiers come from Bun's transpiler, never
|
|
2
|
+
// from regexes over application code; text checks here only classify what to
|
|
3
|
+
// report and never drive a rewrite.
|
|
4
|
+
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import type { MigrationResult } from './report';
|
|
9
|
+
|
|
10
|
+
const SKIP_DIRS = new Set(['node_modules', '.next', '.pnext', '.git', 'dist', 'build']);
|
|
11
|
+
const LOADERS: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = {
|
|
12
|
+
'.ts': 'ts',
|
|
13
|
+
'.tsx': 'tsx',
|
|
14
|
+
'.jsx': 'jsx',
|
|
15
|
+
'.js': 'js',
|
|
16
|
+
'.mjs': 'js',
|
|
17
|
+
'.cjs': 'js',
|
|
18
|
+
};
|
|
19
|
+
const STREAMING_APIS = ['renderToReadableStream', 'renderToPipeableStream'];
|
|
20
|
+
const SPECIAL_PAGES = ['_app', '_document', '_error'];
|
|
21
|
+
|
|
22
|
+
export async function scanSources(root: string, result: MigrationResult) {
|
|
23
|
+
const { SHIMMED_NEXT_DIST_PATHS } = await import('../../compat');
|
|
24
|
+
const shimmed = new Set<string>(SHIMMED_NEXT_DIST_PATHS);
|
|
25
|
+
const deepImports: string[] = [];
|
|
26
|
+
const streaming: string[] = [];
|
|
27
|
+
const headImports: string[] = [];
|
|
28
|
+
|
|
29
|
+
for await (const file of walk(root)) {
|
|
30
|
+
const loader = LOADERS[path.extname(file)];
|
|
31
|
+
if (!loader) continue;
|
|
32
|
+
const text = await readFile(file, 'utf8');
|
|
33
|
+
let specifiers: string[];
|
|
34
|
+
try {
|
|
35
|
+
specifiers = new Bun.Transpiler({ loader }).scanImports(text).map(item => item.path);
|
|
36
|
+
} catch {
|
|
37
|
+
continue; // unparseable source is the app's problem, not migrate's
|
|
38
|
+
}
|
|
39
|
+
const relative = path.relative(root, file);
|
|
40
|
+
for (const specifier of specifiers) {
|
|
41
|
+
if (specifier.startsWith('next/dist/') && !shimmed.has(specifier.replace(/\.js$/, ''))) {
|
|
42
|
+
deepImports.push(`${relative} → ${specifier}`);
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
/^react-dom\/server(\.|\/|$)/.test(specifier) &&
|
|
46
|
+
STREAMING_APIS.some(api => text.includes(api))
|
|
47
|
+
) {
|
|
48
|
+
streaming.push(relative);
|
|
49
|
+
}
|
|
50
|
+
if (specifier === 'next/head') headImports.push(relative);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (deepImports.length > 0) {
|
|
55
|
+
result.reports.push({
|
|
56
|
+
title: 'Unsupported next/dist/* deep imports',
|
|
57
|
+
detail:
|
|
58
|
+
'Only five next/dist paths are shimmed — replace these with public APIs. See reference/compat.md ("Smaller surfaces").',
|
|
59
|
+
files: deepImports,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (streaming.length > 0) {
|
|
63
|
+
result.reports.push({
|
|
64
|
+
title: 'react-dom/server streaming APIs',
|
|
65
|
+
detail:
|
|
66
|
+
'preact/compat does not provide renderToReadableStream/renderToPipeableStream — use pnext rendering instead. See reference/compat.md.',
|
|
67
|
+
files: [...new Set(streaming)],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (headImports.length > 0) {
|
|
72
|
+
result.reports.push({
|
|
73
|
+
title: 'next/head renders nothing',
|
|
74
|
+
detail:
|
|
75
|
+
'Move these tags to metadata exports (or the root layout <head>) — next/head is a silent no-op under pnext. See reference/compat.md.',
|
|
76
|
+
files: [...new Set(headImports)],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
scanSpecialPages(root, result);
|
|
81
|
+
await scanNextConfig(root, result);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function scanSpecialPages(root: string, result: MigrationResult) {
|
|
85
|
+
const found: string[] = [];
|
|
86
|
+
for (const base of ['pages', path.join('src', 'pages')]) {
|
|
87
|
+
for (const name of SPECIAL_PAGES) {
|
|
88
|
+
for (const extension of ['.tsx', '.ts', '.jsx', '.js']) {
|
|
89
|
+
const relative = path.join(base, `${name}${extension}`);
|
|
90
|
+
if (existsSync(path.join(root, relative))) found.push(relative);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (found.length === 0) return;
|
|
95
|
+
result.reports.push({
|
|
96
|
+
title: 'pages/_app, _document and _error are ignored',
|
|
97
|
+
detail:
|
|
98
|
+
'Move this setup into the app-router root layout — pnext never loads these files. See reference/compat.md.',
|
|
99
|
+
files: found,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function scanNextConfig(root: string, result: MigrationResult) {
|
|
104
|
+
for (const name of ['next.config.ts', 'next.config.js', 'next.config.mjs', 'next.config.cjs']) {
|
|
105
|
+
const file = path.join(root, name);
|
|
106
|
+
if (!existsSync(file)) continue;
|
|
107
|
+
const text = await readFile(file, 'utf8');
|
|
108
|
+
if (!text.includes('webpack:') && !text.includes('webpack(')) return;
|
|
109
|
+
const svgr = text.includes('@svgr/webpack');
|
|
110
|
+
result.reports.push({
|
|
111
|
+
title: `${name} defines a webpack function`,
|
|
112
|
+
detail: svgr
|
|
113
|
+
? 'The webpack function is never executed; SVGR is auto-detected from @svgr/webpack, so SVG imports keep working. See reference/compat.md.'
|
|
114
|
+
: 'The webpack function is never executed — port any custom loaders/plugins to pnext config. See reference/compat.md.',
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function* walk(dir: string): AsyncGenerator<string> {
|
|
121
|
+
let entries: import('node:fs').Dirent[];
|
|
122
|
+
try {
|
|
123
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (entry.isDirectory()) {
|
|
129
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
130
|
+
yield* walk(path.join(dir, entry.name));
|
|
131
|
+
} else if (entry.isFile()) {
|
|
132
|
+
yield path.join(dir, entry.name);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Minimal braille spinner for the one step that can take a while (the source
|
|
2
|
+
// scan over a large app). TTY-only, so piped/CI output stays clean.
|
|
3
|
+
|
|
4
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
5
|
+
|
|
6
|
+
export async function withSpinner<T>(label: string, run: () => Promise<T>): Promise<T> {
|
|
7
|
+
if (!process.stdout.isTTY) return run();
|
|
8
|
+
|
|
9
|
+
let frame = 0;
|
|
10
|
+
const timer = setInterval(() => {
|
|
11
|
+
process.stdout.write(`\r${FRAMES[frame % FRAMES.length]} ${label}`);
|
|
12
|
+
frame += 1;
|
|
13
|
+
}, 80);
|
|
14
|
+
try {
|
|
15
|
+
return await run();
|
|
16
|
+
} finally {
|
|
17
|
+
clearInterval(timer);
|
|
18
|
+
process.stdout.write('\r\x1b[2K');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// tsconfig.json: drop the `next` TS plugin and repoint the generated-types
|
|
2
|
+
// include glob at .pnext/types. Configs with comments (JSONC) are reported,
|
|
3
|
+
// never rewritten — the same bail compat/tsconfig-defaults.ts takes, since a
|
|
4
|
+
// JSON round-trip would strip the user's comments.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import type { MigrationResult } from './report';
|
|
10
|
+
|
|
11
|
+
const MANUAL_STEPS =
|
|
12
|
+
'remove { "name": "next" } from compilerOptions.plugins and replace ".next/types" includes with ".pnext/types/**/*.ts".';
|
|
13
|
+
|
|
14
|
+
export async function migrateTsconfig(root: string, result: MigrationResult, dryRun: boolean) {
|
|
15
|
+
const file = path.join(root, 'tsconfig.json');
|
|
16
|
+
if (!existsSync(file)) return;
|
|
17
|
+
const text = await readFile(file, 'utf8');
|
|
18
|
+
|
|
19
|
+
let config: Record<string, unknown>;
|
|
20
|
+
try {
|
|
21
|
+
config = JSON.parse(text) as Record<string, unknown>;
|
|
22
|
+
} catch {
|
|
23
|
+
result.reports.push({
|
|
24
|
+
title: 'tsconfig.json has comments (JSONC) — not edited',
|
|
25
|
+
detail: `Apply by hand: ${MANUAL_STEPS}`,
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (typeof config !== 'object' || config === null) return;
|
|
30
|
+
|
|
31
|
+
const changes: string[] = [];
|
|
32
|
+
const compilerOptions = config.compilerOptions;
|
|
33
|
+
if (isObject(compilerOptions) && Array.isArray(compilerOptions.plugins)) {
|
|
34
|
+
const kept = compilerOptions.plugins.filter(
|
|
35
|
+
plugin => !(isObject(plugin) && plugin.name === 'next'),
|
|
36
|
+
);
|
|
37
|
+
if (kept.length !== compilerOptions.plugins.length) {
|
|
38
|
+
if (kept.length === 0) delete compilerOptions.plugins;
|
|
39
|
+
else compilerOptions.plugins = kept;
|
|
40
|
+
changes.push('next TypeScript plugin removed');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (Array.isArray(config.include)) {
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
const include: unknown[] = [];
|
|
47
|
+
let repointed = false;
|
|
48
|
+
for (const entry of config.include) {
|
|
49
|
+
if (typeof entry !== 'string') {
|
|
50
|
+
include.push(entry);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const next =
|
|
54
|
+
entry.includes('.next/types') || entry.includes('.next/dev/types')
|
|
55
|
+
? '.pnext/types/**/*.ts'
|
|
56
|
+
: entry;
|
|
57
|
+
if (next !== entry) repointed = true;
|
|
58
|
+
if (seen.has(next)) continue;
|
|
59
|
+
seen.add(next);
|
|
60
|
+
include.push(next);
|
|
61
|
+
}
|
|
62
|
+
if (repointed) {
|
|
63
|
+
config.include = include;
|
|
64
|
+
changes.push('type includes repointed at .pnext/types');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (changes.length === 0) return;
|
|
69
|
+
if (!dryRun) await writeFile(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
70
|
+
for (const change of changes) {
|
|
71
|
+
result.edits.push({ file: 'tsconfig.json', description: change });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
76
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
77
|
+
}
|
package/src/compat/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ export function nextCompatEnabled(config: CompatConfig) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** The only `next/dist/*` deep imports pnext shims (see reference/compat.md "Smaller surfaces"). */
|
|
26
|
-
const SHIMMED_NEXT_DIST_PATHS = [
|
|
26
|
+
export const SHIMMED_NEXT_DIST_PATHS = [
|
|
27
27
|
'next/dist/client/components/app-router-headers',
|
|
28
28
|
'next/dist/server/web/spec-extension/unstable-cache',
|
|
29
29
|
'next/dist/server/web/spec-extension/unstable-no-store',
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// When no tsconfig.json exists but the project contains TypeScript sources, one is created from scratch.
|
|
14
14
|
|
|
15
15
|
import { existsSync, readdirSync } from 'node:fs';
|
|
16
|
-
import { readFile
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { writeFileAtomic } from '../utils/fs';
|
|
17
18
|
import { createRequire } from 'node:module';
|
|
18
19
|
import path from 'node:path';
|
|
19
20
|
import type { ResolvedConfig } from '../config';
|
|
@@ -170,7 +171,9 @@ export async function writeTsconfigDefaults(config: ResolvedConfig): Promise<voi
|
|
|
170
171
|
if (!existsSync(tsConfigPath)) {
|
|
171
172
|
if (!hasTypescriptSources(config.root)) return;
|
|
172
173
|
isFirstTimeSetup = true;
|
|
173
|
-
|
|
174
|
+
// Atomic: the client build's tsconfig-paths reader runs in a parallel phase
|
|
175
|
+
// and must never see a truncated file.
|
|
176
|
+
await writeFileAtomic(tsConfigPath, '{}\n');
|
|
174
177
|
}
|
|
175
178
|
|
|
176
179
|
let userTsConfig: TsconfigShape;
|
|
@@ -268,7 +271,7 @@ export async function writeTsconfigDefaults(config: ResolvedConfig): Promise<voi
|
|
|
268
271
|
|
|
269
272
|
if (suggestedActions.length === 0 && requiredActions.length === 0) return;
|
|
270
273
|
|
|
271
|
-
await
|
|
274
|
+
await writeFileAtomic(tsConfigPath, `${JSON.stringify(userTsConfig, null, 2)}\n`);
|
|
272
275
|
|
|
273
276
|
if (isFirstTimeSetup) {
|
|
274
277
|
console.log(
|
package/src/dev/module-cache.ts
CHANGED
|
@@ -158,6 +158,12 @@ export interface DevModuleCache {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
const caches = new Map<string, DevModuleCache>();
|
|
161
|
+
const persistFlushes = new Set<() => void>();
|
|
162
|
+
|
|
163
|
+
/** Write every cache's pending graph.json now. A finished build must leave no unref'd timer renaming temp files under .pnext. */
|
|
164
|
+
export function flushDevModuleCaches() {
|
|
165
|
+
for (const flushNow of persistFlushes) flushNow();
|
|
166
|
+
}
|
|
161
167
|
|
|
162
168
|
/** The cache for `config.outPath`, created once per process. */
|
|
163
169
|
export function devModuleCache(
|
|
@@ -242,7 +248,13 @@ function createDevModuleCache(
|
|
|
242
248
|
|
|
243
249
|
// A short-lived process (a one-off compile, a build) can exit before the
|
|
244
250
|
// debounce fires; without this its work would not survive to the next boot.
|
|
245
|
-
|
|
251
|
+
// Also runnable on demand: a build must not leave the unref'd timer racing
|
|
252
|
+
// whoever copies or walks .pnext right after it returns (see flushDevModuleCaches).
|
|
253
|
+
function flushPersistNow() {
|
|
254
|
+
if (flush) {
|
|
255
|
+
clearTimeout(flush);
|
|
256
|
+
flush = undefined;
|
|
257
|
+
}
|
|
246
258
|
if (!dirty) return;
|
|
247
259
|
dirty = false;
|
|
248
260
|
try {
|
|
@@ -250,7 +262,9 @@ function createDevModuleCache(
|
|
|
250
262
|
} catch {
|
|
251
263
|
// Best effort: a missing index only costs the next boot a rescan.
|
|
252
264
|
}
|
|
253
|
-
}
|
|
265
|
+
}
|
|
266
|
+
persistFlushes.add(flushPersistNow);
|
|
267
|
+
process.on('exit', flushPersistNow);
|
|
254
268
|
|
|
255
269
|
async function sourceRecord(file: string): Promise<SourceRecord> {
|
|
256
270
|
const inflight = pending.get(file) ?? (graphFast && passes > 0 ? settledThisPass.get(file) : undefined);
|