@wular/pnext 0.0.16 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.0.16",
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": {
@@ -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) {
@@ -466,7 +487,7 @@ async function copyTree(
466
487
  // Nothing to skip at this level: the whole tree clones in one syscall.
467
488
  if (exclude.size === 0 && !existsSync(to)) {
468
489
  await mkdir(path.dirname(to), { recursive: true })
469
- if (clone?.(from, to)) return normalizeClone(to, pack, visited)
490
+ if (clone?.(from, to)) return normalizeClone(to, from, pack, visited)
470
491
  }
471
492
  // The build output is still live while the walk runs — typegen rewrites
472
493
  // `.pnext/types` under it — so a directory can vanish between the parent's
@@ -480,7 +501,8 @@ async function copyTree(
480
501
  const target = path.join(to, entry.name)
481
502
  // Matched by name before type: a symlinked `node_modules` must be pruned
482
503
  // as the directory it points at, not followed.
483
- 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
484
506
  if (entry.isSymbolicLink()) {
485
507
  const link = resolveLinkTarget(source)
486
508
  if (!link) return
@@ -494,7 +516,7 @@ async function copyTree(
494
516
  // One syscall for the whole subtree beats one per file — the excluded
495
517
  // names are stripped from the copy afterwards.
496
518
  if (entry.isDirectory() && clone?.(source, target)) {
497
- return normalizeClone(target, pack, visited)
519
+ return normalizeClone(target, source, pack, visited)
498
520
  }
499
521
  if (entry.isDirectory()) return copyTree(source, target, pack, [], visited)
500
522
  if (entry.isFile() && shipsFile(pack, entry.name)) return linkOrCopyFile(source, target)
@@ -555,18 +577,25 @@ async function directoryCloner() {
555
577
  * both paths emit the same tree. Reading directory entries costs nothing next
556
578
  * to the per-file copies this replaces.
557
579
  */
558
- async function normalizeClone(dir: string, pack: PackRules, visited: Set<string>) {
559
- 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]]
560
589
  const pending: Promise<unknown>[] = []
561
590
  while (stack.length > 0) {
562
- const current = stack.pop()!
591
+ const [current, currentFrom] = stack.pop()!
563
592
  for (const entry of readdirSync(current, { withFileTypes: true })) {
564
593
  const full = path.join(current, entry.name)
594
+ const source = path.join(currentFrom, entry.name)
565
595
  // Matched by name before type, as the walk does: the pages-compat shim
566
596
  // links `node_modules` at the app root, and following it would pull the
567
- // whole store into the function. The clone is already inside the
568
- // function, so the walk's keep-path exemption cannot apply here.
569
- if (pack.prunedDirs.has(entry.name)) {
597
+ // whole store into the function.
598
+ if (!shipsDir(pack, entry.name, source, entry.isSymbolicLink())) {
570
599
  rmSync(full, { recursive: true, force: true })
571
600
  } else if (entry.isSymbolicLink()) {
572
601
  const link = resolveLinkTarget(full)
@@ -580,7 +609,7 @@ async function normalizeClone(dir: string, pack: PackRules, visited: Set<string>
580
609
  pending.push(linkOrCopyFile(link.path, full))
581
610
  }
582
611
  } else if (entry.isDirectory()) {
583
- stack.push(full)
612
+ stack.push([full, source])
584
613
  } else if (!shipsFile(pack, entry.name)) {
585
614
  rmSync(full, { force: true })
586
615
  }
@@ -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
  }
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
+ }