@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
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', () => serverEntryDone)
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
- // The build emits src/cli/start.ts prebundled into one file; parsing that
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'),
@@ -1,12 +1,11 @@
1
1
  /**
2
- * Prebundled production server entry: `pnext build` bundles the framework's server graph into one JS
3
- * file (entry point IS src/cli/start.ts, so the identical `start()` runs) and `pnext start` parses
4
- * that instead of walking hundreds of source modules - the dominant share of spawn->first-200.
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 { existsSync, readFileSync } from 'node:fs'
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
- function serverEntryFile(outPath: string): string {
38
- return path.join(serverEntryDir(outPath), 'entry.js')
39
- }
40
-
41
- /**
42
- * The prebuilt entry for a project root, or undefined when there is none to
43
- * use (no build, a custom `outDir`, a version or source-generation mismatch, or the opt-out). The
44
- * caller then falls back to importing src/cli/start.ts directly, so a miss only
45
- * costs speed. Deliberately cheap: one existsSync and one package.json read,
46
- * because it runs before anything else on the start path.
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 (it is ~700 KB of the prebundled entry).
5
+ * ready banner waits on parsing it.
6
6
  */
7
7
  import { readFile } from 'node:fs/promises'
8
8
  import path from 'node:path'