@wular/pnext 0.0.11 → 0.0.13

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.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync }
3
3
  import { builtinModules } from 'node:module'
4
4
  import path from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
- import { pathToFileHref } from '../../config'
6
+ import { DEFAULT_OUT_DIR, pathToFileHref } from '../../config'
7
7
  import type { ResolvedConfig } from '../../config'
8
8
  import { globalCssSources } from '../../css/build'
9
9
  import { getImportAliasExtensions } from '../../extensions'
@@ -109,8 +109,10 @@ function packRules(config: ResolvedConfig): PackRules {
109
109
  suffix => !keep.has(suffix),
110
110
  ),
111
111
  // The app's outDir shares its name with the prune list; it holds the
112
- // compiled module cache the function serves from and must survive.
113
- keepPaths: new Set([path.resolve(config.outPath)]),
112
+ // compiled module cache the function serves from and must survive. The
113
+ // default out root goes with it: a `distDir` app moved its output away, but
114
+ // the next.config bundle the function imports at boot still lives there.
115
+ keepPaths: new Set([path.resolve(config.outPath), path.resolve(config.root, DEFAULT_OUT_DIR)]),
114
116
  }
115
117
  }
116
118
 
@@ -348,7 +350,7 @@ async function writeServerFunction(
348
350
  // Bun does honor tsconfig paths for bare imports, so map the aliased specifiers to their shims in
349
351
  // every shipped tsconfig.
350
352
  await log.step('inject compat tsconfig paths', () =>
351
- injectCompatPaths(config, functionPath, replicaPathFor),
353
+ injectCompatPaths(config, functionPath, pnextRoot, replicaPathFor),
352
354
  )
353
355
 
354
356
  const startEntry = toPosixPath(path.join(pnextTargetRel, 'src/cli/start.ts'))
@@ -731,13 +733,31 @@ function isInsideDir(root: string, file: string) {
731
733
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
732
734
  }
733
735
 
736
+ /**
737
+ * Whether a compat alias resolves to framework source (react, next/*) rather than to a real package.
738
+ * Containment, not a `node_modules` substring: an installed framework lives under node_modules
739
+ * itself, and a substring test there classifies every next/* shim as a package to ship.
740
+ * @internal Exported for tests.
741
+ */
742
+ export function isFrameworkOwnedAliasTarget(frameworkRoot: string, target: string) {
743
+ if (!path.isAbsolute(target) || !isInsideDir(frameworkRoot, target)) return false
744
+ // A nested store under the framework root is still a real package.
745
+ return !path.relative(frameworkRoot, target).split(path.sep).includes('node_modules')
746
+ }
747
+
748
+ function frameworkOwnedAliases(config: ResolvedConfig, frameworkRoot: string) {
749
+ return Object.entries(getImportAliasExtensions().aliases(config, 'server')).filter(([, target]) =>
750
+ isFrameworkOwnedAliasTarget(frameworkRoot, target),
751
+ )
752
+ }
753
+
734
754
  async function injectCompatPaths(
735
755
  config: ResolvedConfig,
736
756
  functionPath: string,
757
+ frameworkRoot: string,
737
758
  replicaPathFor: (file: string) => string | undefined,
738
759
  ) {
739
- const aliasTargets = Object.entries(getImportAliasExtensions().aliases(config, 'server'))
740
- .filter(([, target]) => path.isAbsolute(target) && !target.includes('node_modules'))
760
+ const aliasTargets = frameworkOwnedAliases(config, frameworkRoot)
741
761
  .map(([specifier, target]) => [specifier, replicaPathFor(target)] as const)
742
762
  .filter((entry): entry is readonly [string, string] => Boolean(entry[1]))
743
763
  if (aliasTargets.length === 0) return
@@ -882,9 +902,7 @@ async function traceNodeModulesClosure(
882
902
  // them would drag in the full next/react trees. Aliases that point into
883
903
  // node_modules (the pinned preact family) still need their packages.
884
904
  const frameworkAliasedSpecifiers = new Set(
885
- Object.entries(getImportAliasExtensions().aliases(config, 'server'))
886
- .filter(([, target]) => path.isAbsolute(target) && !target.includes('node_modules'))
887
- .map(([specifier]) => specifier),
905
+ frameworkOwnedAliases(config, path.resolve(framework.root)).map(([specifier]) => specifier),
888
906
  )
889
907
  await log.step('trace runtime imports', async () => {
890
908
  // Only code Bun imports raw at request time needs real node_modules: route handlers, the proxy,
@@ -88,7 +88,7 @@ export async function createRequestHandler(
88
88
  // `start` already resolved (and bootstrapped) the config for its output-mode
89
89
  // guard; reusing it skips a second loadConfig — env load, pnext.config +
90
90
  // next.config imports, app-path resolution — before the ready banner.
91
- const config = options.config ?? (await loadConfig(options.root))
91
+ const config = options.config ?? (await loadConfig(options.root, { serve: true }))
92
92
  // Compat plugin loader: the single gated seam that populates the core
93
93
  // extension registries when compat is enabled (no-op for pure-core apps).
94
94
  // A prod server answers the very next request, so it takes both tiers here.
@@ -578,8 +578,13 @@ export async function createRequestHandler(
578
578
  method === 'GET' &&
579
579
  handlerResponse.status === 200
580
580
  ) {
581
- await writeFile(built.file, new Uint8Array(await handlerResponse.clone().arrayBuffer()))
582
- handlerResponse.headers.set('x-nextjs-cache', 'MISS')
581
+ try {
582
+ await writeFile(built.file, new Uint8Array(await handlerResponse.clone().arrayBuffer()))
583
+ handlerResponse.headers.set('x-nextjs-cache', 'MISS')
584
+ } catch {
585
+ // Best-effort like the page path below: a read-only serving filesystem
586
+ // (a serverless host) costs the cache write, never the response.
587
+ }
583
588
  }
584
589
  if (
585
590
  !built &&
@@ -593,7 +598,9 @@ export async function createRequestHandler(
593
598
  handlerResponse.status < 500
594
599
  ) {
595
600
  const file = lazyStaticHandlerPath(config.outPath, url.pathname)
596
- if (file) {
601
+ // Same best-effort contract as the page path below.
602
+ try {
603
+ if (!file) throw new Error('unsafe path')
597
604
  await mkdir(path.dirname(file), { recursive: true })
598
605
  await writeFile(file, new Uint8Array(await handlerResponse.clone().arrayBuffer()))
599
606
  const relative = path
@@ -617,6 +624,8 @@ export async function createRequestHandler(
617
624
  }
618
625
  await persistManifest()
619
626
  handlerResponse.headers.set('x-nextjs-cache', 'MISS')
627
+ } catch {
628
+ // Lazy caching is best-effort; the response is still served.
620
629
  }
621
630
  }
622
631
  return applyProxyResponse(handlerResponse, proxyResponse)
@@ -831,8 +840,12 @@ export async function createRequestHandler(
831
840
  pageResponse.status === 200 &&
832
841
  (pageResponse.headers.get('content-type') ?? '').includes('text/html')
833
842
  ) {
834
- await writeFile(stalePage, await pageResponse.clone().text())
835
- pageResponse.headers.set('x-nextjs-cache', 'MISS')
843
+ try {
844
+ await writeFile(stalePage, await pageResponse.clone().text())
845
+ pageResponse.headers.set('x-nextjs-cache', 'MISS')
846
+ } catch {
847
+ // Best-effort write-back; the stale copy stays and the fresh render still serves.
848
+ }
836
849
  }
837
850
  // On-demand revalidation (valid x-prerender-revalidate): the fresh render
838
851
  // replaces any prebuilt copy and is reported as REVALIDATED (Next's
@@ -844,7 +857,12 @@ export async function createRequestHandler(
844
857
  pageResponse.status === 200 &&
845
858
  (pageResponse.headers.get('content-type') ?? '').includes('text/html')
846
859
  ) {
847
- await writeFile(built.file, await pageResponse.clone().text())
860
+ try {
861
+ await writeFile(built.file, await pageResponse.clone().text())
862
+ } catch {
863
+ // Best-effort: an on-demand revalidation still returns the fresh
864
+ // render when the prebuilt copy cannot be replaced.
865
+ }
848
866
  }
849
867
  pageResponse.headers.set('x-nextjs-cache', 'REVALIDATED')
850
868
  }
package/src/cli/start.ts CHANGED
@@ -113,7 +113,7 @@ function installProcessErrorHandlers(): void {
113
113
  // `__NEXT_PRIVATE_STANDALONE_CONFIG` set. pnext mirrors that: load the compat config, read the output
114
114
  // mode from the build-compat registry, and reproduce Next's exact messages.
115
115
  async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig> {
116
- const config = await loadConfig(root)
116
+ const config = await loadConfig(root, { serve: true })
117
117
  await bootstrapCompat(config)
118
118
  const mode = getBuildExtensions().compat.nextOutputMode()
119
119
  if (mode === 'export') {
@@ -13,7 +13,7 @@
13
13
  // export / top-level await / dynamic import() and CJS+ESM authoring styles.
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
17
17
  import { createRequire } from 'node:module'
18
18
  import path from 'node:path'
19
19
  import { pathToFileURL } from 'node:url'
@@ -130,11 +130,17 @@ function findTsconfig(root: string): string | undefined {
130
130
  * Bundle the config with esbuild (external node_modules) and import the result.
131
131
  * The emitted file is written next to the config so Node resolves the app's
132
132
  * node_modules and relative assets from the original location.
133
+ *
134
+ * The bundle is also a BUILD artifact: the build leaves it on disk, deployment adapters ship it with
135
+ * the app's out dir, and a serve imports that file rather than re-running the pass. Request time is
136
+ * read-only on a serverless host (Vercel's `/var/task`), where the pass cannot write at all - and
137
+ * where it could, it would still put an esbuild spawn on every cold start. A serve whose build left
138
+ * no bundle (the config changed since, matching `next start` re-reading next.config) still bundles.
133
139
  */
134
140
  async function importConfigModule(
135
141
  configPath: string,
136
142
  root: string,
137
- dev: boolean,
143
+ options: { dev: boolean; serve: boolean },
138
144
  ): Promise<NextConfigExport> {
139
145
  const cjs = cjsConfigFormat(configPath, root)
140
146
  if (process.env.PNEXT_CONFIG_FAST !== '0' && selfContainedConfig(configPath)) {
@@ -147,66 +153,112 @@ async function importConfigModule(
147
153
  }
148
154
  // Dev keeps its scratch bundle in its own subtree; a concurrent build wipes
149
155
  // only the build-owned `config/`.
150
- const outDir = path.join(root, '.pnext', ...(dev ? ['dev'] : []), 'config')
151
- mkdirSync(outDir, { recursive: true })
156
+ const outDir = path.join(root, '.pnext', ...(options.dev ? ['dev'] : []), 'config')
152
157
  // Content-addressed, not timestamped: a per-boot name leaked one bundle per
153
158
  // dev start and put a path that changes every boot into the module graph,
154
- // which renamed every artifact that could reach it.
159
+ // which renamed every artifact that could reach it. It also makes the name a
160
+ // pure function of the config source, so a serve recomputes what its build wrote.
155
161
  const outfile = path.join(
156
162
  outDir,
157
163
  `next.config.${configSourceKey(configPath)}.${cjs ? 'cjs' : 'mjs'}`,
158
164
  )
165
+ if (options.serve && existsSync(outfile)) return importConfigBundle(outfile, cjs)
159
166
  try {
160
- await build({
161
- entryPoints: [configPath],
162
- outfile,
163
- bundle: true,
164
- platform: 'node',
165
- format: cjs ? 'cjs' : 'esm',
166
- target: 'esnext',
167
- // Keep bare package imports external so Node resolves them from the app's
168
- // node_modules at import time (preserves CJS/ESM interop and identity).
169
- packages: 'external',
170
- // Fixture/app configs may require() or require.resolve() Next internals
171
- // (next/dist/*) that don't exist without a real `next` install. Stub them
172
- // so the config still loads; features pointing at the stubs degrade
173
- // gracefully instead of crashing config loading.
174
- plugins: [nextDistStubPlugin(outDir), nextMdxConfigStubPlugin()],
175
- banner: cjs
176
- ? {
177
- js:
178
- 'var __pnextNodeRequire = module.require.bind(module);\n' +
179
- `var require = __pnextNodeRequire("node:module").createRequire(${JSON.stringify(pathToFileURL(configPath).href)});`,
180
- }
181
- : {
182
- js:
183
- 'import { createRequire as __pnextCreateRequire } from "node:module";\n' +
184
- `const require = __pnextCreateRequire(${JSON.stringify(pathToFileURL(configPath).href)});`,
185
- },
186
- // Relative/absolute imports (incl. .ts/.cts/.mts/.cjs/.mjs and tsconfig
187
- // `paths` aliases) are bundled in. esbuild reads `paths`/`extends` and
188
- // `baseUrl` from the app tsconfig.
189
- tsconfig: findTsconfig(root),
190
- // __dirname / __filename in CJS-authored .ts configs (node-api-cjs) and
191
- // import.meta.url in ESM configs (node-api-esm) must point at the config's
192
- // original location, not the emitted file under .pnext/config.
193
- define: {
194
- __dirname: JSON.stringify(path.dirname(configPath)),
195
- __filename: JSON.stringify(configPath),
196
- 'import.meta.url': JSON.stringify(pathToFileURL(configPath).href),
197
- },
198
- logLevel: 'silent',
199
- sourcemap: false,
200
- metafile: false,
201
- })
202
- if (cjs) {
203
- const require = createRequire(pathToFileURL(outfile))
204
- return require(outfile) as NextConfigExport
167
+ mkdirSync(outDir, { recursive: true })
168
+ await bundleConfig({ configPath, root, outDir, outfile, cjs })
169
+ } catch (error) {
170
+ // The only way here on a serve is a build that shipped no bundle for this config, and the most
171
+ // likely reason the rebuild then failed is the read-only request-time filesystem it was meant to
172
+ // avoid. Name the artifact rather than surfacing an esbuild write error from a boot path.
173
+ if (!options.serve) throw error
174
+ throw new Error(
175
+ `pnext: ${path.relative(root, outfile)} is missing and could not be rebuilt here — run ` +
176
+ '`pnext build` and deploy its output dir, which carries the config bundle. Cause: ' +
177
+ (error instanceof Error ? error.message : String(error)),
178
+ { cause: error },
179
+ )
180
+ }
181
+ pruneStaleConfigBundles(outDir, outfile)
182
+ return importConfigBundle(outfile, cjs)
183
+ }
184
+
185
+ function bundleConfig({
186
+ configPath,
187
+ root,
188
+ outDir,
189
+ outfile,
190
+ cjs,
191
+ }: {
192
+ configPath: string
193
+ root: string
194
+ outDir: string
195
+ outfile: string
196
+ cjs: boolean
197
+ }) {
198
+ return build({
199
+ entryPoints: [configPath],
200
+ outfile,
201
+ bundle: true,
202
+ platform: 'node',
203
+ format: cjs ? 'cjs' : 'esm',
204
+ target: 'esnext',
205
+ // Keep bare package imports external so Node resolves them from the app's
206
+ // node_modules at import time (preserves CJS/ESM interop and identity).
207
+ packages: 'external',
208
+ // Fixture/app configs may require() or require.resolve() Next internals
209
+ // (next/dist/*) that don't exist without a real `next` install. Stub them
210
+ // so the config still loads; features pointing at the stubs degrade
211
+ // gracefully instead of crashing config loading.
212
+ plugins: [nextDistStubPlugin(outDir), nextMdxConfigStubPlugin()],
213
+ banner: cjs
214
+ ? {
215
+ js:
216
+ 'var __pnextNodeRequire = module.require.bind(module);\n' +
217
+ `var require = __pnextNodeRequire("node:module").createRequire(${JSON.stringify(pathToFileURL(configPath).href)});`,
218
+ }
219
+ : {
220
+ js:
221
+ 'import { createRequire as __pnextCreateRequire } from "node:module";\n' +
222
+ `const require = __pnextCreateRequire(${JSON.stringify(pathToFileURL(configPath).href)});`,
223
+ },
224
+ // Relative/absolute imports (incl. .ts/.cts/.mts/.cjs/.mjs and tsconfig
225
+ // `paths` aliases) are bundled in. esbuild reads `paths`/`extends` and
226
+ // `baseUrl` from the app tsconfig.
227
+ tsconfig: findTsconfig(root),
228
+ // __dirname / __filename in CJS-authored .ts configs (node-api-cjs) and
229
+ // import.meta.url in ESM configs (node-api-esm) must point at the config's
230
+ // original location, not the emitted file under .pnext/config.
231
+ define: {
232
+ __dirname: JSON.stringify(path.dirname(configPath)),
233
+ __filename: JSON.stringify(configPath),
234
+ 'import.meta.url': JSON.stringify(pathToFileURL(configPath).href),
235
+ },
236
+ logLevel: 'silent',
237
+ sourcemap: false,
238
+ metafile: false,
239
+ })
240
+ }
241
+
242
+ async function importConfigBundle(outfile: string, cjs: boolean): Promise<NextConfigExport> {
243
+ if (cjs) return createRequire(pathToFileURL(outfile))(outfile) as NextConfigExport
244
+ const module = (await import(pathToFileURL(outfile).href)) as { default?: NextConfigExport }
245
+ return module.default ?? {}
246
+ }
247
+
248
+ /**
249
+ * Drop bundles left by an earlier config source. The emitted file used to be deleted right after it
250
+ * was imported, which is what made a serve re-bundle; keeping only the current key holds the line
251
+ * that motivated the delete - one bundle per app, not one per edit.
252
+ */
253
+ function pruneStaleConfigBundles(outDir: string, outfile: string): void {
254
+ try {
255
+ for (const entry of readdirSync(outDir)) {
256
+ if (!/^next\.config\..+\.[cm]js$/.test(entry)) continue
257
+ if (path.join(outDir, entry) === outfile) continue
258
+ rmSync(path.join(outDir, entry), { force: true })
205
259
  }
206
- const module = (await import(pathToFileURL(outfile).href)) as { default?: NextConfigExport }
207
- return module.default ?? {}
208
- } finally {
209
- rmSync(outfile, { force: true })
260
+ } catch {
261
+ // Housekeeping only: a missing or unreadable out dir costs nothing here.
210
262
  }
211
263
  }
212
264
 
@@ -589,7 +641,7 @@ function validateDevIndicatorPosition(config: NextConfigObject): void {
589
641
  */
590
642
  export async function loadNextConfig(
591
643
  root: string,
592
- options: { dev?: boolean } = {},
644
+ options: { dev?: boolean; serve?: boolean } = {},
593
645
  ): Promise<LoadNextConfigResult> {
594
646
  const configPath = findConfigPath(root)
595
647
  if (!configPath) {
@@ -599,7 +651,10 @@ export async function loadNextConfig(
599
651
  }
600
652
  let exported: NextConfigExport
601
653
  try {
602
- exported = await importConfigModule(configPath, root, Boolean(options.dev))
654
+ exported = await importConfigModule(configPath, root, {
655
+ dev: Boolean(options.dev),
656
+ serve: Boolean(options.serve),
657
+ })
603
658
  } catch (error) {
604
659
  // A next.config that pulls in an optional wrapper dependency not installed
605
660
  // in this app (e.g. @next/mdx when deps are declared but not installed)
package/src/config.ts CHANGED
@@ -23,8 +23,15 @@ export type ResolvedConfig = Required<Pick<PNextConfig, 'outDir' | 'basePath'>>
23
23
  typesPath: string
24
24
  }
25
25
 
26
+ /**
27
+ * The out root before any `outDir`/`distDir` override. Also where a config source keeps the build
28
+ * artifacts it must emit BEFORE the override it carries is known — the next.config bundle sits here
29
+ * even for an app whose own output moved elsewhere, so deployment adapters ship it from here.
30
+ */
31
+ export const DEFAULT_OUT_DIR = '.pnext'
32
+
26
33
  const defaultConfig = {
27
- outDir: '.pnext',
34
+ outDir: DEFAULT_OUT_DIR,
28
35
  basePath: '',
29
36
  } satisfies Required<Pick<PNextConfig, 'outDir' | 'basePath'>>
30
37
 
@@ -48,7 +55,7 @@ export interface ConfigSourceOverrides {
48
55
  }
49
56
  export type ConfigSource = (
50
57
  root: string,
51
- options: { dev?: boolean },
58
+ options: { dev?: boolean; serve?: boolean },
52
59
  ) => Promise<ConfigSourceOverrides>
53
60
 
54
61
  let configSource: ConfigSource | undefined
@@ -60,7 +67,7 @@ export function registerConfigSource(source: ConfigSource): void {
60
67
  async function resolveConfigSource(
61
68
  config: PNextConfig,
62
69
  root: string,
63
- options: { dev?: boolean },
70
+ options: { dev?: boolean; serve?: boolean },
64
71
  ): Promise<ConfigSourceOverrides> {
65
72
  if (!config.compat?.next) return {}
66
73
  if (!configSource) {
@@ -70,9 +77,11 @@ async function resolveConfigSource(
70
77
  return configSource(root, options)
71
78
  }
72
79
 
80
+ // `serve` marks a production start (including a deployment adapter's request handler): config sources
81
+ // then consume what the build emitted instead of regenerating it, since request time can be read-only.
73
82
  export async function loadConfig(
74
83
  rootInput = process.cwd(),
75
- options: { dev?: boolean } = {},
84
+ options: { dev?: boolean; serve?: boolean } = {},
76
85
  ): Promise<ResolvedConfig> {
77
86
  const root = canonicalRoot(path.resolve(rootInput))
78
87
  await loadEnv(root, options)