@wular/pnext 0.0.17 → 0.0.19

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.17",
3
+ "version": "0.0.19",
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
  /**
@@ -167,15 +167,6 @@ export async function writeVercelOutput(
167
167
  const warm = options.warm ?? startWarmChild(config)
168
168
  await log.step('prepare output directory', () => emptyDir(outputPath))
169
169
 
170
- await log.step('copy static files', () =>
171
- copyStaticFiles(
172
- path.join(config.outPath, 'public'),
173
- path.join(outputPath, 'static'),
174
- relative => !staticFiles[relative] || canServeStaticOnVercel(staticFiles[relative]),
175
- ),
176
- )
177
- const overrides = await staticOverrides(path.join(config.outPath, 'public'), staticFiles)
178
-
179
170
  // The compiled set is already final once the child reaches its handler
180
171
  // phase, so `onCompiled` resolves this ahead of full completion and
181
172
  // writeServerFunction starts tracing while the child's handler imports
@@ -196,6 +187,17 @@ export async function writeVercelOutput(
196
187
  const functionPath = path.join(outputPath, 'functions', `${SERVER_FUNCTION}.func`)
197
188
  await writeServerFunction(config, manifest, functionPath, warmedModules, warmSettled, log)
198
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
+
199
201
  const routes: NonNullable<VercelConfig['routes']> = [
200
202
  // Chunk and font filenames are content-hashed; serve them immutable.
201
203
  {
@@ -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(
package/src/config.ts CHANGED
@@ -193,18 +193,27 @@ export function pathToFileHref(filePath: string) {
193
193
  }
194
194
 
195
195
  export function publicEnvDefines() {
196
- return Object.fromEntries(
197
- [
198
- // eslint-disable-next-line turbo/no-undeclared-env-vars
199
- ['NODE_ENV', process.env.NODE_ENV ?? 'production'],
200
- ...Object.entries(process.env).filter(
201
- ([key]) => key.startsWith('NEXT_PUBLIC_') || key.startsWith('PNEXT_PUBLIC_'),
202
- ),
203
- ].map(([key, value]) => [
204
- `process.env.${key}`,
205
- value === undefined ? 'undefined' : JSON.stringify(value),
206
- ]),
207
- )
196
+ const publicEnv = Object.fromEntries([
197
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
198
+ ['NODE_ENV', process.env.NODE_ENV ?? 'production'],
199
+ ...Object.entries(process.env).filter(
200
+ ([key]) => key.startsWith('NEXT_PUBLIC_') || key.startsWith('PNEXT_PUBLIC_'),
201
+ ),
202
+ ]) as Record<string, string>
203
+ return {
204
+ // The whole object, not just the keys that happen to be SET at build time. A browser has no
205
+ // `process`, so a `NEXT_PUBLIC_*` the source reads but the environment never defined would
206
+ // otherwise survive as a bare reference and throw ReferenceError on first evaluation - taking
207
+ // hydration, and every event handler on the page, down with it. Reading a missing key off this
208
+ // yields undefined, which is what Next inlines for one.
209
+ 'process.env': JSON.stringify(publicEnv),
210
+ ...Object.fromEntries(
211
+ Object.entries(publicEnv).map(([key, value]) => [
212
+ `process.env.${key}`,
213
+ JSON.stringify(value),
214
+ ]),
215
+ ),
216
+ }
208
217
  }
209
218
 
210
219
  type FrameworkRuntimeAlias =
@@ -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,