@wular/pnext 0.0.16 → 0.0.18

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.18",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,15 +26,16 @@ import path from 'node:path'
26
26
  import { fileURLToPath } from 'node:url'
27
27
  import { loadConfig, pathToFileHref, type ResolvedConfig } from '../../config'
28
28
  import { bootstrapCompat } from '../../compat-bootstrap'
29
- import { ssrClientReference } from '../../client/reference'
29
+ import { compiledClientReferenceFiles, ssrClientReference } from '../../client/reference'
30
30
  import {
31
31
  devClientModuleHref,
32
32
  devServerModuleHref,
33
33
  setEmitCompiledSpecifiersManifest,
34
34
  } from '../../runtime/modules'
35
35
  import { getCompatModeExtensions } from '../../extensions'
36
+ import { getFontExtensions } from '../../render/hooks'
36
37
  import { findConventionFiles, findLayouts } from '../../routing/routes'
37
- import { registerServerRuntime } from '../../runtime/loader'
38
+ import { registerServerRuntime, serverBundleTargetForRuntime } from '../../runtime/loader'
38
39
  import { namedBunBinary } from '../boot/named-bin'
39
40
  import type { VerboseLogger } from '../../utils/verbose'
40
41
  import type { BuildManifest } from '../../types'
@@ -281,6 +282,7 @@ export async function warmRouteModules(
281
282
  ) {
282
283
  const clientFiles = new Set<string>()
283
284
  const handlers: BuildManifest['routes'] = []
285
+ const pages: BuildManifest['routes'] = []
284
286
  const pageRoutes: string[] = []
285
287
 
286
288
  for (const route of manifest.routes) {
@@ -288,6 +290,7 @@ export async function warmRouteModules(
288
290
  if (!skip.has(route.file)) handlers.push(route)
289
291
  continue
290
292
  }
293
+ pages.push(route)
291
294
  pageRoutes.push(route.file)
292
295
  for (const reference of route.clientReferences) {
293
296
  if (ssrClientReference(reference) && existsSync(reference.file)) {
@@ -327,11 +330,93 @@ export async function warmRouteModules(
327
330
  // process' lifetime anyway, so a retry could not succeed either).
328
331
  report(route.file)
329
332
  try {
330
- await import(pathToFileHref(route.file))
333
+ await import(await handlerModuleHref(config, route))
331
334
  } catch (error) {
332
335
  console.warn(`vercel adapter: importing ${route.file} failed during warmup:`, error)
333
336
  }
334
337
  }
338
+
339
+ // Pages last, one at a time: a module's vendor bundles are written when it is IMPORTED, not when
340
+ // it compiles, and a bundle this pass misses is one the read-only function cannot create later.
341
+ await importPageModules(config, pages, skip)
342
+
343
+ // Client modules for the same reason, plus the references only the compile saw - a 'use client'
344
+ // module inside a dependency is reached from a server component, so no route owns it. Vendoring
345
+ // one late is worse than failing: it is a second copy, whose React context the render never holds.
346
+ await importClientModules(
347
+ config,
348
+ new Set([...clientFiles, ...compiledClientReferenceFiles()]),
349
+ skip,
350
+ )
351
+
352
+ // Fonts resolve during a render, so a fully dynamic app emits none at build. The imports above ran
353
+ // the module-scope loader calls that declare them; flush to disk while it is still writable.
354
+ try {
355
+ await getFontExtensions().prewarmFontAssets(config, {})
356
+ } catch (error) {
357
+ console.warn('vercel adapter: emitting next/font assets failed during warmup:', error)
358
+ }
359
+ }
360
+
361
+ /** Import each SSR-eligible client module through its compiled href, the way the render does. */
362
+ async function importClientModules(
363
+ config: ResolvedConfig,
364
+ clientFiles: ReadonlySet<string>,
365
+ skip: ReadonlySet<string>,
366
+ ) {
367
+ if (!getCompatModeExtensions().reactEnabled(config)) return
368
+ for (const file of clientFiles) {
369
+ if (skip.has(file)) continue
370
+ const href = await devClientModuleHref(config, file, 'build')
371
+ report(file, fileURLToPath(href))
372
+ try {
373
+ await import(href)
374
+ } catch (error) {
375
+ console.warn(`vercel adapter: importing ${file} failed during warmup:`, error)
376
+ }
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Where the serve pipeline imports a route handler's module from — mirrors `moduleHrefForRoute` in
382
+ * cli/serve/pipeline.ts, guard included. Warming through the raw source instead leaves the compiled
383
+ * artifact unwritten, and the function then tries to compile it on the first request, against a
384
+ * read-only filesystem.
385
+ */
386
+ function handlerModuleHref(config: ResolvedConfig, route: BuildManifest['routes'][number]) {
387
+ return config.compat?.next || config.compat?.react || config.compat?.reactCompiler
388
+ ? devServerModuleHref(config, route.file, 'build', {
389
+ conditionTarget: serverBundleTargetForRuntime(route.segmentConfig?.runtime),
390
+ })
391
+ : Promise.resolve(pathToFileHref(route.file))
392
+ }
393
+
394
+ /**
395
+ * Import every server module the page routes reach, deduped across routes so a shared layout is
396
+ * evaluated once. Reported with its artifact before the import, exactly like the handler loop: one
397
+ * that takes the process down is unambiguous and must not run again on the restart.
398
+ */
399
+ async function importPageModules(
400
+ config: ResolvedConfig,
401
+ pages: BuildManifest['routes'],
402
+ skip: ReadonlySet<string>,
403
+ ) {
404
+ if (!getCompatModeExtensions().reactEnabled(config)) return
405
+ const imported = new Set<string>()
406
+ for (const route of pages) {
407
+ registerServerRuntime(config, route.sourceFiles)
408
+ for (const file of pageServerFiles(config, [route.file])) {
409
+ if (skip.has(file) || imported.has(file)) continue
410
+ imported.add(file)
411
+ const href = await devServerModuleHref(config, file, 'build')
412
+ report(file, fileURLToPath(href))
413
+ try {
414
+ await import(href)
415
+ } catch (error) {
416
+ console.warn(`vercel adapter: importing ${file} failed during warmup:`, error)
417
+ }
418
+ }
419
+ }
335
420
  }
336
421
 
337
422
  /**
@@ -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) {
@@ -146,15 +167,6 @@ export async function writeVercelOutput(
146
167
  const warm = options.warm ?? startWarmChild(config)
147
168
  await log.step('prepare output directory', () => emptyDir(outputPath))
148
169
 
149
- await log.step('copy static files', () =>
150
- copyStaticFiles(
151
- path.join(config.outPath, 'public'),
152
- path.join(outputPath, 'static'),
153
- relative => !staticFiles[relative] || canServeStaticOnVercel(staticFiles[relative]),
154
- ),
155
- )
156
- const overrides = await staticOverrides(path.join(config.outPath, 'public'), staticFiles)
157
-
158
170
  // The compiled set is already final once the child reaches its handler
159
171
  // phase, so `onCompiled` resolves this ahead of full completion and
160
172
  // writeServerFunction starts tracing while the child's handler imports
@@ -175,6 +187,17 @@ export async function writeVercelOutput(
175
187
  const functionPath = path.join(outputPath, 'functions', `${SERVER_FUNCTION}.func`)
176
188
  await writeServerFunction(config, manifest, functionPath, warmedModules, warmSettled, log)
177
189
 
190
+ // After the warm pass, never before: it is what emits next/font bytes under public/, and a fully
191
+ // dynamic app renders nothing at build, so copying earlier ships a CDN with no fonts.
192
+ await log.step('copy static files', () =>
193
+ copyStaticFiles(
194
+ path.join(config.outPath, 'public'),
195
+ path.join(outputPath, 'static'),
196
+ relative => !staticFiles[relative] || canServeStaticOnVercel(staticFiles[relative]),
197
+ ),
198
+ )
199
+ const overrides = await staticOverrides(path.join(config.outPath, 'public'), staticFiles)
200
+
178
201
  const routes: NonNullable<VercelConfig['routes']> = [
179
202
  // Chunk and font filenames are content-hashed; serve them immutable.
180
203
  {
@@ -466,7 +489,7 @@ async function copyTree(
466
489
  // Nothing to skip at this level: the whole tree clones in one syscall.
467
490
  if (exclude.size === 0 && !existsSync(to)) {
468
491
  await mkdir(path.dirname(to), { recursive: true })
469
- if (clone?.(from, to)) return normalizeClone(to, pack, visited)
492
+ if (clone?.(from, to)) return normalizeClone(to, from, pack, visited)
470
493
  }
471
494
  // The build output is still live while the walk runs — typegen rewrites
472
495
  // `.pnext/types` under it — so a directory can vanish between the parent's
@@ -480,7 +503,8 @@ async function copyTree(
480
503
  const target = path.join(to, entry.name)
481
504
  // Matched by name before type: a symlinked `node_modules` must be pruned
482
505
  // as the directory it points at, not followed.
483
- if (exclude.has(entry.name) || !shipsDir(pack, entry.name, source)) return
506
+ if (exclude.has(entry.name) || !shipsDir(pack, entry.name, source, entry.isSymbolicLink()))
507
+ return
484
508
  if (entry.isSymbolicLink()) {
485
509
  const link = resolveLinkTarget(source)
486
510
  if (!link) return
@@ -494,7 +518,7 @@ async function copyTree(
494
518
  // One syscall for the whole subtree beats one per file — the excluded
495
519
  // names are stripped from the copy afterwards.
496
520
  if (entry.isDirectory() && clone?.(source, target)) {
497
- return normalizeClone(target, pack, visited)
521
+ return normalizeClone(target, source, pack, visited)
498
522
  }
499
523
  if (entry.isDirectory()) return copyTree(source, target, pack, [], visited)
500
524
  if (entry.isFile() && shipsFile(pack, entry.name)) return linkOrCopyFile(source, target)
@@ -555,18 +579,25 @@ async function directoryCloner() {
555
579
  * both paths emit the same tree. Reading directory entries costs nothing next
556
580
  * to the per-file copies this replaces.
557
581
  */
558
- async function normalizeClone(dir: string, pack: PackRules, visited: Set<string>) {
559
- const stack = [dir]
582
+ async function normalizeClone(
583
+ dir: string,
584
+ // Where `dir` was cloned from. Carried so the keep-path exemption reads the same here as in the
585
+ // walk: the clone's own paths are inside the function and would never match a source keep path.
586
+ from: string,
587
+ pack: PackRules,
588
+ visited: Set<string>,
589
+ ) {
590
+ const stack: [string, string][] = [[dir, from]]
560
591
  const pending: Promise<unknown>[] = []
561
592
  while (stack.length > 0) {
562
- const current = stack.pop()!
593
+ const [current, currentFrom] = stack.pop()!
563
594
  for (const entry of readdirSync(current, { withFileTypes: true })) {
564
595
  const full = path.join(current, entry.name)
596
+ const source = path.join(currentFrom, entry.name)
565
597
  // Matched by name before type, as the walk does: the pages-compat shim
566
598
  // 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)) {
599
+ // whole store into the function.
600
+ if (!shipsDir(pack, entry.name, source, entry.isSymbolicLink())) {
570
601
  rmSync(full, { recursive: true, force: true })
571
602
  } else if (entry.isSymbolicLink()) {
572
603
  const link = resolveLinkTarget(full)
@@ -580,7 +611,7 @@ async function normalizeClone(dir: string, pack: PackRules, visited: Set<string>
580
611
  pending.push(linkOrCopyFile(link.path, full))
581
612
  }
582
613
  } else if (entry.isDirectory()) {
583
- stack.push(full)
614
+ stack.push([full, source])
584
615
  } else if (!shipsFile(pack, entry.name)) {
585
616
  rmSync(full, { force: true })
586
617
  }
@@ -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
  }
@@ -1,4 +1,22 @@
1
1
  import { createHash } from 'node:crypto'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * Every file this process stubbed as a client reference. The route manifest only records what the
6
+ * scan attributes to a route, so a `'use client'` module inside a dependency - which only the vendor
7
+ * pass sees, the package entry carrying no directive - is missing from it.
8
+ */
9
+ const compiledClientReferences = new Set<string>()
10
+
11
+ /** @internal */
12
+ export function noteCompiledClientReference(file: string) {
13
+ compiledClientReferences.add(path.resolve(file))
14
+ }
15
+
16
+ /** @internal Read by the vercel adapter's warm pass, so it compiles these before the deploy. */
17
+ export function compiledClientReferenceFiles(): ReadonlySet<string> {
18
+ return compiledClientReferences
19
+ }
2
20
 
3
21
  export interface ClientReference {
4
22
  id: string
@@ -98,15 +98,28 @@ interface NormalizedLocalSource {
98
98
  weight?: string
99
99
  }
100
100
 
101
- const fontStorageKey = Symbol.for('pnext.compat.font.storage')
102
- const fontRuntimeGlobal = globalThis as typeof globalThis &
103
- Record<symbol, AsyncLocalStorage<FontScope> | undefined>
104
- const fontStorage = (fontRuntimeGlobal[fontStorageKey] ??= new AsyncLocalStorage<FontScope>())
101
+ /**
102
+ * State every copy of this module must agree on: the app reaches next/font through the COMPILED
103
+ * runtime, the framework through node_modules, so a plain module-level value exists once per copy.
104
+ */
105
+ const fontRuntimeGlobal = globalThis as typeof globalThis & Record<symbol, unknown>
106
+ const sharedAcrossInstances = <T>(key: symbol, create: () => T) =>
107
+ (fontRuntimeGlobal[key] ??= create()) as T
108
+
109
+ const fontStorage = sharedAcrossInstances(
110
+ Symbol.for('pnext.compat.font.storage'),
111
+ () => new AsyncLocalStorage<FontScope>(),
112
+ )
105
113
  const allowedDisplayValues = new Set(['auto', 'block', 'swap', 'fallback', 'optional'])
106
114
  const require = createRequire(import.meta.url)
107
115
  const googleCssCache = new Map<string, Promise<string>>()
108
116
  const googleFontFileCache = new Map<string, Promise<Uint8Array>>()
109
- const resolvedFontCache = new Map<string, Promise<ResolvedFont>>()
117
+ // Shared for the same reason as declaredFonts below, and so a request-time render reuses the
118
+ // build's resolution instead of re-fetching the Google stylesheet on every cold start.
119
+ const resolvedFontCache = sharedAcrossInstances(
120
+ Symbol.for('pnext.compat.font.resolved'),
121
+ () => new Map<string, Promise<ResolvedFont>>(),
122
+ )
110
123
  let googleMetadataCache: Record<string, GoogleFontMetadata> | undefined
111
124
 
112
125
  /**
@@ -114,7 +127,10 @@ let googleMetadataCache: Record<string, GoogleFontMetadata> | undefined
114
127
  * Definitions are created when the app's font module evaluates - well before the render that registers
115
128
  * them - which is what makes the resolve prewarmable.
116
129
  */
117
- const declaredFonts = new Map<string, FontDefinition>()
130
+ const declaredFonts = sharedAcrossInstances(
131
+ Symbol.for('pnext.compat.font.declared'),
132
+ () => new Map<string, FontDefinition>(),
133
+ )
118
134
 
119
135
  /**
120
136
  * Resolve the declared fonts ahead of the render that will ask for them. Fire-and-forget: this only
@@ -751,12 +767,19 @@ async function emitFontBytes(
751
767
  const mediaSegments = ['_next', 'static', 'media']
752
768
  const outDir = path.join(context.config.outPath, 'public', ...mediaSegments)
753
769
  const file = path.join(outDir, filename)
754
- await mkdir(outDir, { recursive: true })
755
- if (!existsSync(file)) {
756
- if (sourceFile) await copyFile(sourceFile, file)
757
- else await writeFile(file, bytes)
770
+ const asset = {
771
+ ext,
772
+ file,
773
+ type: fontMimeType(ext),
774
+ url: `/${mediaSegments.join('/')}/${filename}`,
758
775
  }
759
- return { ext, file, type: fontMimeType(ext), url: `/${mediaSegments.join('/')}/${filename}` }
776
+ // Existence first, mkdir second: at request time the filesystem is read-only, and an
777
+ // unconditional mkdir throws EROFS just to discover the build already emitted these bytes.
778
+ if (existsSync(file)) return asset
779
+ await mkdir(outDir, { recursive: true })
780
+ if (sourceFile) await copyFile(sourceFile, file)
781
+ else await writeFile(file, bytes)
782
+ return asset
760
783
  }
761
784
 
762
785
  function normalizeLocalSources(
@@ -12,6 +12,7 @@ import {
12
12
  clientReferenceModuleSource,
13
13
  hasUseClientDirective,
14
14
  } from '../client/reference-stub'
15
+ import { noteCompiledClientReference } from '../client/reference'
15
16
  import { getClientActionBundler } from '../dev/client-actions'
16
17
  import { deferredDynamicImportSpecifiers, devDynamicSplitEnabled } from '../resolve/dynamic'
17
18
  import { noteModuleGeneration, noteModuleImported } from './module-generations'
@@ -2190,6 +2191,7 @@ async function writeClientReferenceModules(
2190
2191
  await writeClientReferenceModule(stub, reference.file, [...reference.exports].sort())
2191
2192
  specifiers.set(specifier, stub)
2192
2193
  files.add(path.resolve(reference.file))
2194
+ noteCompiledClientReference(reference.file)
2193
2195
  }),
2194
2196
  )
2195
2197
 
@@ -26,7 +26,7 @@ import {
26
26
  type ServerEsbuildPluginOptions,
27
27
  } from '../extensions'
28
28
  import { pathToFileHref, type CompatAliasTarget, type ResolvedConfig } from '../config'
29
- import { devArtifactUsable, noteDevArtifactWritten } from './module-cache'
29
+ import { devArtifactUsable, devSourceIdentity, noteDevArtifactWritten } from './module-cache'
30
30
  import {
31
31
  getExternalPackagePolicy,
32
32
  isEsmModuleEntry,
@@ -47,6 +47,7 @@ import {
47
47
  clientReferenceModuleSource,
48
48
  hasUseClientDirective,
49
49
  } from '../client/reference-stub'
50
+ import { noteCompiledClientReference } from '../client/reference'
50
51
  import { reactCompatEnabled } from '../render/hooks'
51
52
  import { writeFileAtomic } from '../utils/fs'
52
53
  import { formatDuration } from '../utils/verbose'
@@ -156,6 +157,11 @@ export async function externalServerPackageHref(
156
157
  * Artifact identity: what the bundle is compiled FROM, never who asked. Keying on the importer's
157
158
  * directory (what `require.resolve` fell back to when a package publishes no `require` condition)
158
159
  * split one package into one build per importing directory.
160
+ *
161
+ * Every path in it is workspace-relative, never absolute — for the same reason the module graph
162
+ * names artifacts that way (see `portableSourcePath`). This key names the file, and a deployed
163
+ * function replays the same workspace from a different root: an absolute key misses the whole
164
+ * shipped vendor cache and re-vendors onto a read-only filesystem.
159
165
  */
160
166
  function vendorArtifactKey(
161
167
  config: ResolvedConfig,
@@ -163,9 +169,12 @@ function vendorArtifactKey(
163
169
  conditionTarget: ServerBundleTarget,
164
170
  source: string,
165
171
  ) {
166
- return `${config.outPath}\0${target}\0${conditionTarget}\0${source}`
172
+ return `${vendorPortablePath(config, config.outPath)}\0${target}\0${conditionTarget}\0${source}`
167
173
  }
168
174
 
175
+ const vendorPortablePath = (config: ResolvedConfig, file: string) =>
176
+ devSourceIdentity(file, config.workspaceRoot)
177
+
169
178
  function vendorArtifactFile(config: ResolvedConfig, key: string) {
170
179
  return path.join(config.outPath, 'cache', 'server', 'vendor', `${hashBundleSpecifier(key)}.mjs`)
171
180
  }
@@ -214,7 +223,9 @@ function vendorBuildPlan(
214
223
  config,
215
224
  target,
216
225
  conditionTarget,
217
- entry ?? `${path.resolve(resolveDir)}\0${specifier}`,
226
+ entry
227
+ ? vendorPortablePath(config, entry)
228
+ : `${vendorPortablePath(config, path.resolve(resolveDir))}\0${specifier}`,
218
229
  )
219
230
  const file = vendorArtifactFile(config, key)
220
231
  return {
@@ -1290,6 +1301,10 @@ function vendorClientBoundaryLoad(config: ResolvedConfig) {
1290
1301
  const scan = async (file: string): Promise<OnLoadResult | undefined> => {
1291
1302
  const source = await readFile(file, 'utf8').catch(() => undefined)
1292
1303
  if (source === undefined || !hasUseClientDirective(source)) return undefined
1304
+ // A 'use client' file the vendor pass found inside a package — the render will import it as a
1305
+ // client module, and nothing else records it (the server-source scan only sees the package
1306
+ // entry, which carries no directive). The vercel warm pass reads this to compile it at build.
1307
+ noteCompiledClientReference(file)
1293
1308
  return {
1294
1309
  contents: clientReferenceModuleSource(
1295
1310
  file,
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
+ }