@wular/pnext 0.1.5 → 0.1.6
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/package.json +1 -1
- package/src/cli/build.ts +2 -10
- package/src/cli/index.ts +1 -8
- package/src/cli/serve/entry.ts +16 -161
- package/src/cli/start.ts +1 -1
package/package.json
CHANGED
package/src/cli/build.ts
CHANGED
|
@@ -91,6 +91,7 @@ import {
|
|
|
91
91
|
import { interceptionMarkerLevels } from '../routing/slots'
|
|
92
92
|
import { writeTypegen } from './typegen'
|
|
93
93
|
import { lookupBuildCache, writeBuildCache } from './build/cache'
|
|
94
|
+
import { emitServerEntry } from './serve/entry'
|
|
94
95
|
import { createVerboseLogger, type VerboseLogger } from '../utils/verbose'
|
|
95
96
|
import { bold, cyan, dim, green } from '../utils/ansi'
|
|
96
97
|
import type {
|
|
@@ -230,15 +231,6 @@ async function runBuild(
|
|
|
230
231
|
await ensureEmptyDir(config.outPath, [devOutSegment])
|
|
231
232
|
await copyPublicDir(config.publicPath, path.join(config.outPath, 'public'))
|
|
232
233
|
})
|
|
233
|
-
// Prebundled server entry for `pnext start`: framework-only, independent of the
|
|
234
|
-
// app build, so it runs in a child process for the whole build — its bundling
|
|
235
|
-
// heap never stacks on the build's peak RSS and its wall hides under the build.
|
|
236
|
-
// Best-effort — a failure only costs start time. Awaited before the summary.
|
|
237
|
-
const serverEntryDone = import('./serve/entry')
|
|
238
|
-
.then(entry => entry.emitServerEntryChild(config.outPath))
|
|
239
|
-
.catch((error: Error) => {
|
|
240
|
-
console.warn(`pnext build: server entry bundling skipped — ${error.message}`)
|
|
241
|
-
})
|
|
242
234
|
// The document-level stylesheets run their postcss/Tailwind pass on the CSS
|
|
243
235
|
// worker, so they overlap with the route scan below instead of serializing
|
|
244
236
|
// ahead of it. Awaited before prepareRouteCssChunks — route CSS still builds
|
|
@@ -938,7 +930,7 @@ async function runBuild(
|
|
|
938
930
|
}
|
|
939
931
|
await writeBuildManifest(config.outPath, manifest)
|
|
940
932
|
log.log(`wrote manifest.json (${routes.length} route${routes.length === 1 ? '' : 's'})`)
|
|
941
|
-
await log.step('server entry', () =>
|
|
933
|
+
await log.step('server entry', () => emitServerEntry(config.outPath))
|
|
942
934
|
for (const hook of getBuildExtensions().completeHooks) {
|
|
943
935
|
await hook({ config, manifest, log })
|
|
944
936
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -90,14 +90,7 @@ try {
|
|
|
90
90
|
} else if (command === 'start') {
|
|
91
91
|
warnUnknownFlags(command, args)
|
|
92
92
|
const root = positionalRoot(args)
|
|
93
|
-
|
|
94
|
-
// instead of walking the framework's source graph is most of `start`'s
|
|
95
|
-
// spawn→first-200. Absent (no build yet, custom outDir) → source path.
|
|
96
|
-
const { prebuiltServerEntry } = await import('./serve/entry')
|
|
97
|
-
const prebuilt = prebuiltServerEntry(root)
|
|
98
|
-
const { start } = prebuilt
|
|
99
|
-
? ((await import(prebuilt)) as typeof import('./start'))
|
|
100
|
-
: await import('./start')
|
|
93
|
+
const { start } = await import('./start')
|
|
101
94
|
await start({
|
|
102
95
|
root,
|
|
103
96
|
port: optionNumber(args, '--port'),
|
package/src/cli/serve/entry.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Server entry for hosts that serve a build through `createRequestHandler`: a thin loader that
|
|
3
|
+
* resolves `@wular/pnext` from the output's own location (as `.next/server` loads `next`), so the
|
|
4
|
+
* output carries no framework code and no build-machine paths.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
7
8
|
import path from 'node:path'
|
|
8
|
-
import { pathToFileURL } from 'node:url'
|
|
9
|
-
import type { Plugin as EsbuildPlugin } from 'esbuild'
|
|
10
9
|
import { frameworkFingerprint } from '../../runtime/fingerprint'
|
|
11
10
|
|
|
12
11
|
const frameworkRoot = path.resolve(import.meta.dirname, '..', '..', '..')
|
|
@@ -28,163 +27,19 @@ function frameworkVersion(): string {
|
|
|
28
27
|
* cache-root record so a restart pays one stat per file rather than re-reading the tree.
|
|
29
28
|
*/
|
|
30
29
|
export function serverEntryDir(outPath: string): string {
|
|
31
|
-
// Cache root spelled inline, not imported: this module is parsed before anything else on the
|
|
32
|
-
// start path, and pulling in the module cache for one path.join would drag its whole graph along.
|
|
33
30
|
const generation = frameworkFingerprint(path.join(outPath, 'cache', 'server'))
|
|
34
31
|
return path.join(outPath, 'server', `bundle-${frameworkVersion()}-${generation}`)
|
|
35
32
|
}
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
export function prebuiltServerEntry(root = process.cwd()): string | undefined {
|
|
49
|
-
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
50
|
-
if (process.env.PNEXT_NO_SERVER_BUNDLE === '1') return undefined
|
|
51
|
-
const file = serverEntryFile(path.resolve(root, '.pnext'))
|
|
52
|
-
return existsSync(file) ? pathToFileURL(file).href : undefined
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Bundle src/cli/start.ts under `<outPath>/server/bundle-<version>-<fingerprint>/`. Bare
|
|
57
|
-
* specifiers stay external (the framework's deps are already plain JS), so only
|
|
58
|
-
* the framework's own source is inlined. Splitting is on so the graph behind a
|
|
59
|
-
* dynamic import (compat, dev, build) stays in its own chunk — folding it into
|
|
60
|
-
* the entry would evaluate eagerly what the source graph never even reads.
|
|
61
|
-
* Returns the emitted entry, or undefined when bundling failed — the build must
|
|
62
|
-
* not fail over a start-time optimization.
|
|
63
|
-
*/
|
|
64
|
-
export async function emitServerEntry(outPath: string): Promise<string | undefined> {
|
|
65
|
-
const { build } = await import('../../utils/esbuild')
|
|
66
|
-
try {
|
|
67
|
-
await build({
|
|
68
|
-
entryPoints: [path.join(import.meta.dirname, '..', 'start.ts')],
|
|
69
|
-
outdir: serverEntryDir(outPath),
|
|
70
|
-
entryNames: 'entry',
|
|
71
|
-
chunkNames: '[name]-[hash]',
|
|
72
|
-
bundle: true,
|
|
73
|
-
splitting: true,
|
|
74
|
-
format: 'esm',
|
|
75
|
-
platform: 'node',
|
|
76
|
-
target: 'esnext',
|
|
77
|
-
sourcemap: false,
|
|
78
|
-
tsconfig: path.join(frameworkRoot, 'tsconfig.json'),
|
|
79
|
-
logLevel: 'silent',
|
|
80
|
-
// esbuild's ESM `require` shim throws for anything it did not bundle; the
|
|
81
|
-
// framework's lazy `require('esbuild' | 'oxc-*')` facades need a real one,
|
|
82
|
-
// anchored at the framework so they load ITS copy, not the app's.
|
|
83
|
-
banner: {
|
|
84
|
-
js:
|
|
85
|
-
'import { createRequire as __pnextCreateRequire } from "node:module";\n' +
|
|
86
|
-
`const require = __pnextCreateRequire(${JSON.stringify(
|
|
87
|
-
pathToFileURL(path.join(frameworkRoot, 'package.json')).href,
|
|
88
|
-
)});`,
|
|
89
|
-
},
|
|
90
|
-
plugins: [await serverEntryPlugin(outPath)],
|
|
91
|
-
})
|
|
92
|
-
return serverEntryFile(outPath)
|
|
93
|
-
} catch (error) {
|
|
94
|
-
console.warn(`pnext build: server entry bundling skipped — ${(error as Error).message}`)
|
|
95
|
-
return undefined
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Emit in a short-lived child process: the bundle's source strings and parse
|
|
101
|
-
* allocations land in the child's heap (well under the build's own peak), not
|
|
102
|
-
* on top of it. Falls back to in-process emission if the spawn fails.
|
|
103
|
-
*/
|
|
104
|
-
export async function emitServerEntryChild(outPath: string): Promise<void> {
|
|
105
|
-
try {
|
|
106
|
-
const child = Bun.spawn([process.execPath, import.meta.filename, outPath], {
|
|
107
|
-
stdout: 'ignore',
|
|
108
|
-
stderr: 'inherit',
|
|
109
|
-
})
|
|
110
|
-
if ((await child.exited) === 0) return
|
|
111
|
-
} catch {
|
|
112
|
-
// fall through to in-process emission
|
|
113
|
-
}
|
|
114
|
-
await emitServerEntry(outPath)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function serverEntryPlugin(outPath: string): Promise<EsbuildPlugin> {
|
|
118
|
-
const { rewriteFacts } = await import('../../resolve/scan-facts')
|
|
119
|
-
const { spliceSource } = await import('../../runtime/module-transform')
|
|
120
|
-
return {
|
|
121
|
-
name: 'pnext-server-entry',
|
|
122
|
-
setup(build) {
|
|
123
|
-
// Bare specifiers (and node builtins) stay out of the bundle. They must
|
|
124
|
-
// still resolve to the SAME file the framework's own source graph would
|
|
125
|
-
// reach: keep the specifier bare when the output directory resolves it
|
|
126
|
-
// identically (portable), otherwise pin the absolute path.
|
|
127
|
-
build.onResolve({ filter: /^[^./]/ }, args => {
|
|
128
|
-
if (args.path.startsWith('node:')) return { path: args.path, external: true }
|
|
129
|
-
const target = resolveFrom(args.path, frameworkRoot)
|
|
130
|
-
if (!target) return { path: args.path, external: true }
|
|
131
|
-
const fromOutput = resolveFrom(args.path, serverEntryDir(outPath))
|
|
132
|
-
return { path: fromOutput === target ? args.path : target, external: true }
|
|
133
|
-
})
|
|
134
|
-
// `import.meta` in a bundled module still means the ORIGINAL source file
|
|
135
|
-
// (the framework locates its own runtime shims that way). oxc's exact
|
|
136
|
-
// spans keep occurrences in strings and comments untouched.
|
|
137
|
-
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async args => {
|
|
138
|
-
const source = await Bun.file(args.path).text()
|
|
139
|
-
if (!source.includes('import.meta')) return undefined
|
|
140
|
-
const facts = rewriteFacts(args.path, source)
|
|
141
|
-
if (facts.unreliable) return undefined
|
|
142
|
-
const edits = facts.importMetas.flatMap(meta => {
|
|
143
|
-
const value = importMetaValue(source.slice(meta.end, meta.end + 16), args.path)
|
|
144
|
-
return value
|
|
145
|
-
? [{ start: meta.start, end: meta.end + value.length, value: value.code }]
|
|
146
|
-
: []
|
|
147
|
-
})
|
|
148
|
-
if (edits.length === 0) return undefined
|
|
149
|
-
return { contents: spliceSource(source, edits), loader: loaderFor(args.path) }
|
|
150
|
-
})
|
|
151
|
-
},
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const importMetaProperties = ['dirname', 'filename', 'resolve', 'url', 'dir'] as const
|
|
156
|
-
|
|
157
|
-
/** The literal a bundled `import.meta.<prop>` must become, given the tail after `import.meta`. */
|
|
158
|
-
function importMetaValue(tail: string, file: string): { code: string; length: number } | undefined {
|
|
159
|
-
const property = importMetaProperties.find(name => new RegExp(`^\\.${name}\\b`).test(tail))
|
|
160
|
-
if (!property) return undefined
|
|
161
|
-
const length = property.length + 1
|
|
162
|
-
const dir = path.dirname(file)
|
|
163
|
-
if (property === 'dirname' || property === 'dir') return { code: JSON.stringify(dir), length }
|
|
164
|
-
if (property === 'filename') return { code: JSON.stringify(file), length }
|
|
165
|
-
if (property === 'url') return { code: JSON.stringify(pathToFileURL(file).href), length }
|
|
166
|
-
return {
|
|
167
|
-
code: `(specifier => Bun.pathToFileURL(Bun.resolveSync(specifier, ${JSON.stringify(dir)})).href)`,
|
|
168
|
-
length,
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function loaderFor(file: string): 'ts' | 'tsx' | 'js' | 'jsx' {
|
|
173
|
-
if (file.endsWith('.tsx')) return 'tsx'
|
|
174
|
-
if (file.endsWith('.jsx')) return 'jsx'
|
|
175
|
-
return /\.[cm]?ts$/.test(file) ? 'ts' : 'js'
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function resolveFrom(specifier: string, dir: string): string | undefined {
|
|
179
|
-
try {
|
|
180
|
-
return Bun.resolveSync(specifier, dir)
|
|
181
|
-
} catch {
|
|
182
|
-
return undefined
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Child-process entry for emitServerEntryChild; last so every binding above is initialized.
|
|
187
|
-
if (import.meta.main && process.argv[2]) {
|
|
188
|
-
const emitted = await emitServerEntry(process.argv[2])
|
|
189
|
-
process.exit(emitted ? 0 : 1)
|
|
34
|
+
/** Write `<outPath>/server/bundle-<version>-<fingerprint>/entry.js`, re-exporting src/cli/start.ts. */
|
|
35
|
+
export async function emitServerEntry(outPath: string): Promise<void> {
|
|
36
|
+
const start = await Bun.file(path.join(import.meta.dirname, '..', 'start.ts')).text()
|
|
37
|
+
const names = new Bun.Transpiler({ loader: 'ts' }).scan(start).exports
|
|
38
|
+
const dir = serverEntryDir(outPath)
|
|
39
|
+
await mkdir(dir, { recursive: true })
|
|
40
|
+
await writeFile(
|
|
41
|
+
path.join(dir, 'entry.js'),
|
|
42
|
+
`const pnext = await import(new URL('./cli/start.ts', import.meta.resolve('@wular/pnext')).href)\n` +
|
|
43
|
+
`export const { ${names.join(', ')} } = pnext\n`,
|
|
44
|
+
)
|
|
190
45
|
}
|
package/src/cli/start.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `pnext start`: the listen path only. Everything a request needs — renderer,
|
|
3
3
|
* routing, server runtime — lives in `./serve/pipeline` and is imported
|
|
4
4
|
* dynamically once the port is bound, so neither the listen syscall nor the
|
|
5
|
-
* ready banner waits on parsing it
|
|
5
|
+
* ready banner waits on parsing it.
|
|
6
6
|
*/
|
|
7
7
|
import { readFile } from 'node:fs/promises'
|
|
8
8
|
import path from 'node:path'
|