@wular/pnext 0.0.15 → 0.0.17

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/bin/pnext CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/bin/sh
2
2
 
3
+ # Presence only. The version floor is enforced in src/cli/index.ts, where
4
+ # Bun.version costs nothing — asking `bun --version` here would fork per command.
3
5
  if ! command -v bun >/dev/null 2>&1; then
4
- echo "pnext requires Bun. Install it from https://bun.sh/get" >&2
6
+ echo "pnext requires Bun >=1.3.6. Install it from https://bun.sh/get" >&2
5
7
  exit 1
6
8
  fi
7
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,9 @@
33
33
  "publishConfig": {
34
34
  "access": "public"
35
35
  },
36
+ "engines": {
37
+ "bun": ">=1.3.6"
38
+ },
36
39
  "exports": {
37
40
  ".": "./src/index.ts",
38
41
  "./internal": "./src/internal.ts",
@@ -2,7 +2,7 @@
2
2
 
3
3
  pnext is a Preact framework for file-routed apps. It renders on the server by default and ships client JavaScript only where a component opts in.
4
4
 
5
- > pnext requires [Bun](https://bun.sh/get) - make sure it is installed first.
5
+ > pnext requires [Bun](https://bun.sh/get) 1.3.6 or newer - make sure it is installed first.
6
6
 
7
7
  <!-- tabs:start -->
8
8
 
@@ -116,8 +116,29 @@ function packRules(config: ResolvedConfig): PackRules {
116
116
  }
117
117
  }
118
118
 
119
- function shipsDir(pack: PackRules, name: string, source: string) {
120
- return !pack.prunedDirs.has(name) || pack.keepPaths.has(path.resolve(source))
119
+ /**
120
+ * A keep path and everything under it. Prefix, not equality: the compiled module cache mirrors the
121
+ * source layout, so a dependency's artifacts land in `<outDir>/cache/server/<profile>/node_modules/
122
+ * ...`. That segment names a source tree, not a package store, and pruning it by name ships the
123
+ * importer without the module it imports — the function then 500s on the first request that reaches
124
+ * it. Only the app's own build output is exempt; a real `node_modules` beside it still prunes.
125
+ */
126
+ function withinKeepPath(pack: PackRules, source: string) {
127
+ const resolved = path.resolve(source)
128
+ for (const keep of pack.keepPaths) {
129
+ if (resolved === keep || resolved.startsWith(`${keep}${path.sep}`)) return true
130
+ }
131
+ return false
132
+ }
133
+
134
+ /**
135
+ * `isLink` is load-bearing, not a detail: the exemption covers real directories the build output
136
+ * owns, never a link out of it. The proxy shim links `node_modules` into `.pnext`, and following
137
+ * that would pull the whole store into the function.
138
+ */
139
+ function shipsDir(pack: PackRules, name: string, source: string, isLink = false) {
140
+ if (!pack.prunedDirs.has(name)) return true
141
+ return !isLink && withinKeepPath(pack, source)
121
142
  }
122
143
 
123
144
  function shipsFile(pack: PackRules, name: string) {
@@ -381,9 +402,12 @@ export default Object.assign(handler, { fetch: handler });
381
402
  `${JSON.stringify(
382
403
  {
383
404
  // The Bun function runtime (project-level `bunVersion` does not apply
384
- // to prebuilt Build Output API functions). Note: bun1.x functions have
385
- // a 150 MiB uncompressed size limit vs 250 MiB for nodejs.
386
- runtime: 'bun1.x',
405
+ // to prebuilt Build Output API functions, so the version is pinned
406
+ // here). Not `bun1.x`: that resolves to Bun 1.3.14, which segfaults a
407
+ // second into serving and takes the whole function down with SIGABRT.
408
+ // Note: bun functions have a 150 MiB uncompressed size limit vs 250
409
+ // MiB for nodejs.
410
+ runtime: 'bun1.4.x',
387
411
  handler: 'index.mjs',
388
412
  launcherType: 'Nodejs',
389
413
  architecture: FUNCTION_PLATFORM.architecture,
@@ -463,7 +487,7 @@ async function copyTree(
463
487
  // Nothing to skip at this level: the whole tree clones in one syscall.
464
488
  if (exclude.size === 0 && !existsSync(to)) {
465
489
  await mkdir(path.dirname(to), { recursive: true })
466
- if (clone?.(from, to)) return normalizeClone(to, pack, visited)
490
+ if (clone?.(from, to)) return normalizeClone(to, from, pack, visited)
467
491
  }
468
492
  // The build output is still live while the walk runs — typegen rewrites
469
493
  // `.pnext/types` under it — so a directory can vanish between the parent's
@@ -477,7 +501,8 @@ async function copyTree(
477
501
  const target = path.join(to, entry.name)
478
502
  // Matched by name before type: a symlinked `node_modules` must be pruned
479
503
  // as the directory it points at, not followed.
480
- if (exclude.has(entry.name) || !shipsDir(pack, entry.name, source)) return
504
+ if (exclude.has(entry.name) || !shipsDir(pack, entry.name, source, entry.isSymbolicLink()))
505
+ return
481
506
  if (entry.isSymbolicLink()) {
482
507
  const link = resolveLinkTarget(source)
483
508
  if (!link) return
@@ -491,7 +516,7 @@ async function copyTree(
491
516
  // One syscall for the whole subtree beats one per file — the excluded
492
517
  // names are stripped from the copy afterwards.
493
518
  if (entry.isDirectory() && clone?.(source, target)) {
494
- return normalizeClone(target, pack, visited)
519
+ return normalizeClone(target, source, pack, visited)
495
520
  }
496
521
  if (entry.isDirectory()) return copyTree(source, target, pack, [], visited)
497
522
  if (entry.isFile() && shipsFile(pack, entry.name)) return linkOrCopyFile(source, target)
@@ -552,18 +577,25 @@ async function directoryCloner() {
552
577
  * both paths emit the same tree. Reading directory entries costs nothing next
553
578
  * to the per-file copies this replaces.
554
579
  */
555
- async function normalizeClone(dir: string, pack: PackRules, visited: Set<string>) {
556
- const stack = [dir]
580
+ async function normalizeClone(
581
+ dir: string,
582
+ // Where `dir` was cloned from. Carried so the keep-path exemption reads the same here as in the
583
+ // walk: the clone's own paths are inside the function and would never match a source keep path.
584
+ from: string,
585
+ pack: PackRules,
586
+ visited: Set<string>,
587
+ ) {
588
+ const stack: [string, string][] = [[dir, from]]
557
589
  const pending: Promise<unknown>[] = []
558
590
  while (stack.length > 0) {
559
- const current = stack.pop()!
591
+ const [current, currentFrom] = stack.pop()!
560
592
  for (const entry of readdirSync(current, { withFileTypes: true })) {
561
593
  const full = path.join(current, entry.name)
594
+ const source = path.join(currentFrom, entry.name)
562
595
  // Matched by name before type, as the walk does: the pages-compat shim
563
596
  // links `node_modules` at the app root, and following it would pull the
564
- // whole store into the function. The clone is already inside the
565
- // function, so the walk's keep-path exemption cannot apply here.
566
- if (pack.prunedDirs.has(entry.name)) {
597
+ // whole store into the function.
598
+ if (!shipsDir(pack, entry.name, source, entry.isSymbolicLink())) {
567
599
  rmSync(full, { recursive: true, force: true })
568
600
  } else if (entry.isSymbolicLink()) {
569
601
  const link = resolveLinkTarget(full)
@@ -577,7 +609,7 @@ async function normalizeClone(dir: string, pack: PackRules, visited: Set<string>
577
609
  pending.push(linkOrCopyFile(link.path, full))
578
610
  }
579
611
  } else if (entry.isDirectory()) {
580
- stack.push(full)
612
+ stack.push([full, source])
581
613
  } else if (!shipsFile(pack, entry.name)) {
582
614
  rmSync(full, { force: true })
583
615
  }
package/src/cli/index.ts CHANGED
@@ -4,9 +4,29 @@ import path from 'node:path'
4
4
  import { markBoot } from './boot/trace'
5
5
  import { commandBinaryName, nameEsbuildProcess, namedBunBinary } from './boot/named-bin'
6
6
 
7
+ const MIN_BUN = '1.3.6'
8
+ // Not a floor but a hole in one: 1.3.14 segfaults about a second into serving,
9
+ // so a pnext server dies before its first response with no error of its own to
10
+ // show. 1.3.13 and 1.4.x are both fine.
11
+ const BROKEN_BUN = new Set(['1.3.14'])
12
+
7
13
  // Reached only if something bypassed bin/pnext (which guards the same way).
8
14
  if (typeof Bun === 'undefined') {
9
- console.error('pnext requires Bun. Install it from https://bun.sh/get')
15
+ console.error(`pnext requires Bun >=${MIN_BUN}. Install it from https://bun.sh/get`)
16
+ process.exit(1)
17
+ }
18
+ // A prerelease sorts below its own release, so compare the release: a 1.4.0-canary is a 1.4.0 here.
19
+ const bunVersion = Bun.version.split('-')[0] ?? Bun.version
20
+ if (BROKEN_BUN.has(bunVersion)) {
21
+ console.error(
22
+ `pnext does not support Bun ${Bun.version}: it crashes the process at runtime. ` +
23
+ 'Upgrade to latest Bun with `bun upgrade`.',
24
+ )
25
+ process.exit(1)
26
+ } else if (!Bun.semver.satisfies(bunVersion, `>=${MIN_BUN}`)) {
27
+ console.error(
28
+ `pnext requires Bun >=${MIN_BUN}, found ${Bun.version}. Upgrade with \`bun upgrade\`.`,
29
+ )
10
30
  process.exit(1)
11
31
  }
12
32
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { readFile, writeFile } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
- import { pnextVersionRange } from '../../utils/fs'
6
+ import { pnextOptionalDependencyRange, pnextVersionRange } from '../../utils/fs'
7
7
  import type { MigrationResult } from './report'
8
8
 
9
9
  const SEPARATORS = new Set(['&&', '||', ';', '|', '&'])
@@ -132,6 +132,26 @@ export async function migratePackageJson(
132
132
  details: [`devDependencies: @wular/pnext ${versionRange}`],
133
133
  })
134
134
  }
135
+ // next/font/google resolves its metadata from `next-font`, one of pnext's optional dependencies.
136
+ // A hoisted store makes it resolve locally whether or not the app declares it, so an undeclared
137
+ // one only shows up as a 500 from a deployed function, where the closure ships what the app asks
138
+ // for. Declare it here, where the migration already knows the app uses the loader.
139
+ const fontRange = pnextOptionalDependencyRange('next-font')
140
+ if (
141
+ result.googleFontImports.length > 0 &&
142
+ fontRange &&
143
+ !('next-font' in dependencies) &&
144
+ !('next-font' in devDependencies)
145
+ ) {
146
+ dependencies['next-font'] = fontRange
147
+ result.edits.push({
148
+ file: 'package.json',
149
+ description: 'next-font added',
150
+ details: [
151
+ `dependencies: next-font ${fontRange} (next/font/google metadata, used by ${result.googleFontImports.length} file${result.googleFontImports.length === 1 ? '' : 's'})`,
152
+ ],
153
+ })
154
+ }
135
155
  if (!('preact' in dependencies) && !('preact' in devDependencies)) {
136
156
  dependencies.preact = '^10'
137
157
  result.edits.push({
@@ -27,10 +27,12 @@ export interface MigrationResult {
27
27
  reports: ReportItem[]
28
28
  /** Files reading cookies()/headers()/draftMode() synchronously (Next 14 style). */
29
29
  syncRequestApis: string[]
30
+ /** Files importing next/font/google, which needs the `next-font` metadata package. */
31
+ googleFontImports: string[]
30
32
  }
31
33
 
32
34
  export function emptyResult(): MigrationResult {
33
- return { edits: [], reports: [], syncRequestApis: [] }
35
+ return { edits: [], reports: [], syncRequestApis: [], googleFontImports: [] }
34
36
  }
35
37
 
36
38
  export function printHeader(root: string, dryRun: boolean) {
@@ -36,9 +36,10 @@ export async function migrateApp(directory: string | undefined, options: { dryRu
36
36
 
37
37
  printHeader(root, options.dryRun)
38
38
  const result = emptyResult()
39
- if (pkg) await migratePackageJson(root, pkg, result, options.dryRun)
40
- // The scan runs first: what it finds (sync request APIs) decides what the config enables.
39
+ // The scan runs first: what it finds decides what the config enables (sync request APIs) and
40
+ // which dependencies the app needs (next/font/google → next-font).
41
41
  await withSpinner('Scanning sources ...', () => scanSources(root, result))
42
+ if (pkg) await migratePackageJson(root, pkg, result, options.dryRun)
42
43
  await createPnextConfig(root, result, options.dryRun)
43
44
  await migrateTsconfig(root, result, options.dryRun)
44
45
  await removeNextEnv(root, result, options.dryRun)
@@ -53,6 +53,9 @@ export async function scanSources(root: string, result: MigrationResult) {
53
53
  streaming.push(relative)
54
54
  }
55
55
  if (specifier === 'next/head') headImports.push(relative)
56
+ // next/font/local reads the app's own files; only the Google loader needs the metadata
57
+ // package, so only it earns the dependency.
58
+ if (specifier === 'next/font/google') result.googleFontImports.push(relative)
56
59
  }
57
60
  if (readsRequestApiSynchronously(text)) result.syncRequestApis.push(relative)
58
61
  }
@@ -14,6 +14,7 @@ import { loadConfig, pathToFileHref } from '../../config'
14
14
  import type { ResolvedConfig } from '../../config'
15
15
  import { bootstrapCompat } from '../../compat-bootstrap'
16
16
  import { registerServerRuntime, serverBundleTargetForRuntime } from '../../runtime/loader'
17
+ import { importModuleOnce } from '../../runtime/modules'
17
18
  import { applyProxyResponse, createProxyRunner } from '../../routing/proxy'
18
19
  import {
19
20
  isPprShellUpgradeEligible,
@@ -146,7 +147,7 @@ export async function createRequestHandler(
146
147
  const href = compatModuleMode(config)
147
148
  ? await moduleHrefForRoute(config, route)
148
149
  : pathToFileHref(route.file)
149
- const module = (await import(href)) as Parameters<typeof handleRouteModule>[0]
150
+ const module = await importModuleOnce<Parameters<typeof handleRouteModule>[0]>(href)
150
151
  const rendered = await getRenderExtensions().collectRenderMeta(
151
152
  () =>
152
153
  withRouteRuntime(route.segmentConfig?.runtime, () =>
@@ -980,8 +981,9 @@ async function handleRoute(
980
981
  const href = compatModuleMode(config)
981
982
  ? await moduleHrefForRoute(config, route)
982
983
  : new URL(`file://${route.file}`).href
983
- const imported = (await import(href)) as RouteHandlerModule &
984
- Parameters<typeof metadataRouteHandlerModule>[0]
984
+ const imported = await importModuleOnce<
985
+ RouteHandlerModule & Parameters<typeof metadataRouteHandlerModule>[0]
986
+ >(href)
985
987
  return (metadataRouteHandlerModule(imported, route) ?? imported) as RouteHandlerModule
986
988
  })
987
989
  return spans.withRouteHandlerSpan(route.route, () =>
@@ -26,7 +26,12 @@ import {
26
26
  import { clientEntryName } from '../client/chunk-name'
27
27
  import { pathToFileHref, type ResolvedConfig } from '../config'
28
28
  import { markErrorLogged } from '../utils/error-log'
29
- import { devClientModuleHref, devServerModuleHref, importDevModule } from '../runtime/modules'
29
+ import {
30
+ devClientModuleHref,
31
+ devServerModuleHref,
32
+ importDevModule,
33
+ importModuleOnce,
34
+ } from '../runtime/modules'
30
35
  import { serverBundleTargetForRuntime } from '../runtime/loader'
31
36
  import {
32
37
  getActionModuleExtensions,
@@ -663,8 +668,8 @@ function clientModuleHref(file: string, options: ModuleLoadOptions) {
663
668
  }
664
669
 
665
670
  async function importModule(file: string, options: ModuleLoadOptions) {
666
- // In dev, re-throw a module's FIRST evaluation error on every later request
667
- // rather than the downstream symptom a half-evaluated module produces.
671
+ // Re-throw a module's FIRST evaluation error on every later request rather
672
+ // than the downstream symptom a half-evaluated module produces.
668
673
  let module: Record<string, unknown>
669
674
  if (options.moduleLoader) module = await options.moduleLoader(file)
670
675
  else if (options.dev) {
@@ -672,7 +677,7 @@ async function importModule(file: string, options: ModuleLoadOptions) {
672
677
  } else {
673
678
  const href = await moduleHref(file, options)
674
679
  await drainPreplanBuilds()
675
- module = (await import(href)) as Record<string, unknown>
680
+ module = await importModuleOnce<Record<string, unknown>>(href)
676
681
  }
677
682
  // Tag any 'use server' action module's exports with their wire id so
678
683
  // <form action={fn}> can progressively enhance. Compat-owned; a no-op for a
@@ -687,7 +692,7 @@ async function importClientModule(file: string, options: ModuleLoadOptions) {
687
692
  const href = await clientModuleHref(file, options)
688
693
  if (options.dev) return importDevModule<Record<string, unknown>>(href)
689
694
  await drainPreplanBuilds()
690
- return import(href) as Promise<Record<string, unknown>>
695
+ return importModuleOnce<Record<string, unknown>>(href)
691
696
  }
692
697
 
693
698
  function clientScriptPath(route: RouteManifestEntry, dev?: boolean) {
@@ -185,21 +185,34 @@ const routeModuleLoaders = new Map<string, Promise<DevRouteModuleLoaders>>()
185
185
  // and the entry is never consulted again.
186
186
  const moduleEvalErrors = new Map<string, unknown>()
187
187
 
188
- /** Import a compiled dev module, re-throwing its first evaluation error on later imports. */
189
- export async function importDevModule<T>(href: string): Promise<T> {
188
+ /**
189
+ * Import a compiled module, re-throwing its first evaluation error on later imports.
190
+ *
191
+ * Production needs this as much as dev: a re-import resolves with a half-evaluated namespace whose
192
+ * `export const` bindings are still in TDZ, so the render's next read (`module.metadata`) throws
193
+ * `Cannot access 'metadata' before initialization` and that replaces the real error in the response.
194
+ * Prod hrefs are content-addressed too, so the entry keys the same way dev's does.
195
+ */
196
+ export async function importModuleOnce<T>(href: string): Promise<T> {
190
197
  if (moduleEvalErrors.has(href)) throw moduleEvalErrors.get(href)
191
- // Pre-planned vendor builds are enqueued without an awaiter; nothing
192
- // evaluates until they all settle (no-op when the pipeline is empty).
193
- await drainPreplanBuilds()
194
198
  try {
195
- const module = (await import(href)) as T
196
- noteModuleImported(href)
197
- return module
199
+ return (await import(href)) as T
198
200
  } catch (error) {
199
201
  moduleEvalErrors.set(href, error)
200
202
  throw error
201
203
  }
202
204
  }
205
+
206
+ /** `importModuleOnce` plus dev's registry-generation accounting. */
207
+ export async function importDevModule<T>(href: string): Promise<T> {
208
+ if (moduleEvalErrors.has(href)) throw moduleEvalErrors.get(href)
209
+ // Pre-planned vendor builds are enqueued without an awaiter; nothing
210
+ // evaluates until they all settle (no-op when the pipeline is empty).
211
+ await drainPreplanBuilds()
212
+ const module = await importModuleOnce<T>(href)
213
+ noteModuleImported(href)
214
+ return module
215
+ }
203
216
  const extensionlessDynamicImportExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
204
217
  const externalLoadNamespaces = {
205
218
  server: 'pnext-route-external-server',
package/src/utils/fs.ts CHANGED
@@ -137,3 +137,16 @@ export function pnextVersionRange() {
137
137
  const { version } = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }
138
138
  return `^${version}`
139
139
  }
140
+
141
+ /**
142
+ * The range pnext declares for one of its optional dependencies. An app that uses the feature has to
143
+ * declare the package itself — the vercel adapter ships pnext's optional deps only where the app
144
+ * depends on them too — so the range has to come from here rather than being written down twice.
145
+ */
146
+ export function pnextOptionalDependencyRange(name: string) {
147
+ const pkgPath = fileURLToPath(new URL('../../package.json', import.meta.url))
148
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
149
+ optionalDependencies?: Record<string, string>
150
+ }
151
+ return pkg.optionalDependencies?.[name]
152
+ }