@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
|
@@ -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
|
+
}
|