@wular/pnext 0.0.20 → 0.0.22

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.20",
3
+ "version": "0.0.22",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,324 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync } from 'node:fs'
3
+ import { lstat, mkdir, readFile, readdir, readlink } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import type { ResolvedConfig } from '../../config'
6
+ import { frameworkFingerprint } from '../../runtime/fingerprint'
7
+ import type { BuildManifest } from '../../types'
8
+ import { writeFileAtomic } from '../../utils/fs'
9
+
10
+ const CACHE_VERSION = 2
11
+ const CACHE_SEGMENTS = ['cache', 'build'] as const
12
+ const SKIP_INPUT_DIRS = new Set([
13
+ '.claude',
14
+ '.git',
15
+ '.husky',
16
+ '.next',
17
+ '.pnext',
18
+ '.tmp',
19
+ '.turbo',
20
+ '.vercel',
21
+ '.vscode',
22
+ 'node_modules',
23
+ ])
24
+ const SKIP_INPUT_FILE = /(?:^\.DS_Store$|\.(?:log|pid)$)/i
25
+ const LOCK_FILES = [
26
+ 'bun.lock',
27
+ 'bun.lockb',
28
+ 'package-lock.json',
29
+ 'npm-shrinkwrap.json',
30
+ 'pnpm-lock.yaml',
31
+ 'yarn.lock',
32
+ ]
33
+
34
+ type InputRecord = [mtimeMs: number, size: number, hash: string]
35
+
36
+ interface CacheRecord {
37
+ version: number
38
+ key: string
39
+ inputs: Record<string, InputRecord>
40
+ outputHash: string
41
+ outputs: Record<string, string>
42
+ }
43
+
44
+ export interface BuildCacheOptions {
45
+ adapter?: 'vercel'
46
+ debugBuildPaths?: string
47
+ buildMode?: 'compile' | 'generate'
48
+ debugPrerender?: boolean
49
+ }
50
+
51
+ export interface BuildCacheSession {
52
+ enabled: boolean
53
+ context?: string
54
+ inputs?: Record<string, InputRecord>
55
+ manifest?: BuildManifest
56
+ reason?: string
57
+ }
58
+
59
+ /** Validate the standard production build before its output directory is touched. */
60
+ export async function lookupBuildCache(
61
+ config: ResolvedConfig,
62
+ options: BuildCacheOptions,
63
+ ): Promise<BuildCacheSession> {
64
+ if (!cacheEnabled(options)) return { enabled: false }
65
+ const record = await readRecord(config.outPath)
66
+ const inputs = await inputRecords(config, record?.inputs)
67
+ const context = buildContext()
68
+ const key = buildKey(context, inputs)
69
+ if (record?.key !== key) return { enabled: true, context, inputs, reason: 'inputs' }
70
+ const output = await outputSnapshot(config.outPath)
71
+ if (output.hash !== record.outputHash) {
72
+ const changed = changedFiles(record.outputs, output.files).slice(0, 3).join(', ')
73
+ return { enabled: true, context, inputs, reason: `output (${changed || 'unknown'})` }
74
+ }
75
+ try {
76
+ const manifest = JSON.parse(
77
+ await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
78
+ ) as BuildManifest
79
+ return { enabled: true, context, inputs, manifest, reason: 'hit' }
80
+ } catch {
81
+ return { enabled: true, context, inputs, reason: 'manifest' }
82
+ }
83
+ }
84
+
85
+ /** Publish a successful build only after every production artifact has landed. */
86
+ export async function writeBuildCache(
87
+ config: ResolvedConfig,
88
+ options: BuildCacheOptions,
89
+ session: BuildCacheSession,
90
+ ) {
91
+ if (!session.enabled || !session.context || !cacheEnabled(options)) return
92
+ const inputs = await inputRecords(config, session.inputs)
93
+ const output = await outputSnapshot(config.outPath)
94
+ const record: CacheRecord = {
95
+ version: CACHE_VERSION,
96
+ key: buildKey(session.context, inputs),
97
+ inputs,
98
+ outputHash: output.hash,
99
+ outputs: output.files,
100
+ }
101
+ await mkdir(cacheDir(config.outPath), { recursive: true })
102
+ await writeFileAtomic(recordFile(config.outPath), JSON.stringify(record))
103
+ }
104
+
105
+ function cacheEnabled(options: BuildCacheOptions) {
106
+ // Opt-in until the no-change short-circuit is finished: the key currently hashes the whole
107
+ // environment, so cross-shell hits are rare and the cache adds bookkeeping without payoff.
108
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
109
+ if (process.env.PNEXT_BUILD_CACHE !== '1') return false
110
+ return (
111
+ options.adapter === undefined &&
112
+ options.debugBuildPaths === undefined &&
113
+ options.buildMode === undefined &&
114
+ options.debugPrerender !== true
115
+ )
116
+ }
117
+
118
+ function cacheDir(outPath: string) {
119
+ return path.join(outPath, ...CACHE_SEGMENTS)
120
+ }
121
+
122
+ function recordFile(outPath: string) {
123
+ return path.join(cacheDir(outPath), 'result.json')
124
+ }
125
+
126
+ async function readRecord(outPath: string): Promise<CacheRecord | undefined> {
127
+ try {
128
+ const parsed = JSON.parse(await readFile(recordFile(outPath), 'utf8')) as CacheRecord
129
+ if (
130
+ parsed.version === CACHE_VERSION &&
131
+ parsed.key &&
132
+ parsed.inputs &&
133
+ parsed.outputHash &&
134
+ parsed.outputs
135
+ ) {
136
+ return parsed
137
+ }
138
+ } catch {
139
+ // A partial or older entry is a miss, never a stale hit.
140
+ }
141
+ return undefined
142
+ }
143
+
144
+ function buildContext() {
145
+ const hash = createHash('sha256')
146
+ hash.update(`pnext-build-cache:${CACHE_VERSION}\0`)
147
+ hash.update(frameworkFingerprint())
148
+ hash.update(`\0bun:${Bun.version}\0`)
149
+ for (const [name, value] of Object.entries(process.env).sort(([a], [b]) => a.localeCompare(b))) {
150
+ hash.update(`${name}=${value ?? ''}\0`)
151
+ }
152
+ return hash.digest('hex')
153
+ }
154
+
155
+ function buildKey(context: string, inputs: Record<string, InputRecord>) {
156
+ const hash = createHash('sha256')
157
+ hash.update(context)
158
+ for (const [file, record] of Object.entries(inputs).sort(([a], [b]) => a.localeCompare(b))) {
159
+ hash.update(`${file}\0${record[2]}\0`)
160
+ }
161
+ return hash.digest('hex')
162
+ }
163
+
164
+ async function inputRecords(
165
+ config: ResolvedConfig,
166
+ previous: Record<string, InputRecord> | undefined,
167
+ ) {
168
+ const files = new Map<string, string>()
169
+ await collectTree(config.root, 'root', files, config.outPath)
170
+ await collectModuleGraphInputs(config, files)
171
+
172
+ // Dependency trees are represented by package-manager content identities,
173
+ // while first-party modules above are keyed by their own bytes.
174
+ for (const [prefix, root] of [
175
+ ['root-deps', config.root],
176
+ ['workspace-deps', config.workspaceRoot],
177
+ ] as const) {
178
+ for (const name of LOCK_FILES) addFile(files, `${prefix}:${name}`, path.join(root, name))
179
+ addFile(
180
+ files,
181
+ `${prefix}:node_modules/.package-lock.json`,
182
+ path.join(root, 'node_modules', '.package-lock.json'),
183
+ )
184
+ }
185
+
186
+ const frameworkRoot = path.resolve(import.meta.dirname, '..', '..')
187
+ addFile(files, 'framework:package.json', path.join(frameworkRoot, 'package.json'))
188
+ for (const name of LOCK_FILES) {
189
+ addFile(files, `framework:${name}`, path.join(frameworkRoot, name))
190
+ addFile(files, `framework-workspace:${name}`, path.join(frameworkRoot, '..', name))
191
+ }
192
+
193
+ const entries = await Promise.all(
194
+ [...files].map(async ([key, file]): Promise<[string, InputRecord] | undefined> => {
195
+ const stats = await lstat(file).catch(() => undefined)
196
+ if (!stats?.isFile() && !stats?.isSymbolicLink()) return undefined
197
+ const old = previous?.[key]
198
+ if (old?.[0] === stats.mtimeMs && old[1] === stats.size) return [key, old]
199
+ const contents = stats.isSymbolicLink()
200
+ ? Buffer.from(`link:${await readlink(file)}`)
201
+ : await readFile(file)
202
+ return [key, [stats.mtimeMs, stats.size, contentHash(contents)]]
203
+ }),
204
+ )
205
+ return Object.fromEntries(
206
+ entries.filter((entry): entry is [string, InputRecord] => Boolean(entry)),
207
+ )
208
+ }
209
+
210
+ interface ModuleGraphIndex {
211
+ files?: Record<string, unknown>
212
+ }
213
+
214
+ async function collectModuleGraphInputs(config: ResolvedConfig, files: Map<string, string>) {
215
+ let parsed: ModuleGraphIndex
216
+ try {
217
+ parsed = JSON.parse(
218
+ await readFile(path.join(config.outPath, 'cache', 'server', 'graph.json'), 'utf8'),
219
+ ) as ModuleGraphIndex
220
+ } catch {
221
+ return
222
+ }
223
+ const frameworkRoot = path.resolve(import.meta.dirname, '..', '..')
224
+ for (const source of Object.keys(parsed.files ?? {})) {
225
+ const value = source.slice(2).split('/').join(path.sep)
226
+ const file = source.startsWith('w:')
227
+ ? path.resolve(config.workspaceRoot, value)
228
+ : source.startsWith('f:')
229
+ ? path.resolve(frameworkRoot, value)
230
+ : source.startsWith('a:')
231
+ ? value
232
+ : undefined
233
+ if (file) addFile(files, `module:${source}`, file)
234
+ }
235
+ }
236
+
237
+ async function collectTree(
238
+ root: string,
239
+ prefix: string,
240
+ files: Map<string, string>,
241
+ outPath: string,
242
+ dir = root,
243
+ ): Promise<void> {
244
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
245
+ await Promise.all(
246
+ entries.map(async entry => {
247
+ const file = path.join(dir, entry.name)
248
+ if (file === outPath || file.startsWith(`${outPath}${path.sep}`)) return
249
+ if (entry.isDirectory()) {
250
+ if (!SKIP_INPUT_DIRS.has(entry.name)) await collectTree(root, prefix, files, outPath, file)
251
+ return
252
+ }
253
+ if (!entry.isFile() && !entry.isSymbolicLink()) return
254
+ const relative = path.relative(root, file)
255
+ // Local server logs and top-level response captures are not compiler
256
+ // inputs; watching processes commonly rewrite them during a build.
257
+ if (
258
+ SKIP_INPUT_FILE.test(entry.name) ||
259
+ (!relative.includes(path.sep) && /\.html?$/i.test(entry.name))
260
+ ) {
261
+ return
262
+ }
263
+ addFile(files, `${prefix}:${toPosix(relative)}`, file)
264
+ }),
265
+ )
266
+ }
267
+
268
+ function addFile(files: Map<string, string>, key: string, file: string) {
269
+ if (existsSync(file)) files.set(key, file)
270
+ }
271
+
272
+ async function outputSnapshot(outPath: string) {
273
+ const files = new Map<string, string>()
274
+ const entries = await readdir(outPath, { withFileTypes: true }).catch(() => [])
275
+ await Promise.all(
276
+ entries.map(async entry => {
277
+ if (entry.name === 'cache' || entry.name === 'dev') return
278
+ const file = path.join(outPath, entry.name)
279
+ if (entry.isDirectory()) await collectOutputTree(outPath, file, files)
280
+ else if (entry.isFile() || entry.isSymbolicLink()) {
281
+ files.set(toPosix(entry.name), file)
282
+ }
283
+ }),
284
+ )
285
+ const hash = createHash('sha256')
286
+ const hashes: Record<string, string> = {}
287
+ for (const [name, file] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
288
+ const stats = await lstat(file)
289
+ const contents = stats.isSymbolicLink()
290
+ ? Buffer.from(`link:${await readlink(file)}`)
291
+ : await readFile(file)
292
+ const contentsHash = contentHash(contents)
293
+ hashes[name] = contentsHash
294
+ hash.update(`${name}\0${contentsHash}\0`)
295
+ }
296
+ return { hash: hash.digest('hex'), files: hashes }
297
+ }
298
+
299
+ function changedFiles(before: Record<string, string>, after: Record<string, string>) {
300
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(
301
+ file => before[file] !== after[file],
302
+ )
303
+ }
304
+
305
+ async function collectOutputTree(root: string, dir: string, files: Map<string, string>) {
306
+ const entries = await readdir(dir, { withFileTypes: true })
307
+ await Promise.all(
308
+ entries.map(async entry => {
309
+ const file = path.join(dir, entry.name)
310
+ if (entry.isDirectory()) await collectOutputTree(root, file, files)
311
+ else if (entry.isFile() || entry.isSymbolicLink()) {
312
+ files.set(toPosix(path.relative(root, file)), file)
313
+ }
314
+ }),
315
+ )
316
+ }
317
+
318
+ function contentHash(contents: Uint8Array) {
319
+ return createHash('sha256').update(contents).digest('hex')
320
+ }
321
+
322
+ function toPosix(file: string) {
323
+ return path.sep === '/' ? file : file.split(path.sep).join('/')
324
+ }
package/src/cli/build.ts CHANGED
@@ -84,6 +84,7 @@ import {
84
84
  } from '../routing/routes'
85
85
  import { interceptionMarkerLevels } from '../routing/slots'
86
86
  import { writeTypegen } from './typegen'
87
+ import { lookupBuildCache, writeBuildCache } from './build/cache'
87
88
  import { createVerboseLogger, type VerboseLogger } from '../utils/verbose'
88
89
  import { bold, cyan, dim, green } from '../utils/ansi'
89
90
  import type {
@@ -179,9 +180,7 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
179
180
  `${cyan('▲')} ${bold('pnext')} ${dim('— Creating an optimized production build ...\n')}`,
180
181
  )
181
182
  const config = await loadConfig(root)
182
- // Load Tailwind on the CSS worker while the steps below run, so the first
183
- // stylesheet doesn't pay its cold boot.
184
- warmCssPipeline(config)
183
+ const buildCache = lookupBuildCache(config, options)
185
184
  // Compat plugin loader: the single gated seam that populates the core
186
185
  // extension registries when compat is enabled (no-op for pure-core apps).
187
186
  // A build compiles immediately, so it takes both tiers up front.
@@ -192,6 +191,21 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
192
191
  // server-boot-only hooks out of the build - Next never runs register() during `next build`, so
193
192
  // build prerenders must see a noop tracer.
194
193
  runInitHooks(config, { build: true })
194
+ const cached = await buildCache
195
+ log.log(`build cache ${cached.reason ?? 'disabled'}`)
196
+ if (cached.manifest) {
197
+ printBuildSummary(
198
+ config,
199
+ cached.manifest.routes,
200
+ cached.manifest.staticFiles ?? {},
201
+ performance.now() - startedAt,
202
+ Boolean(cached.manifest.proxyModule),
203
+ )
204
+ return cached.manifest
205
+ }
206
+ // Load Tailwind on the CSS worker while the steps below run, so the first
207
+ // stylesheet doesn't pay its cold boot.
208
+ warmCssPipeline(config)
195
209
  // The vercel adapter's warm pass runs in its own process; start it here so
196
210
  // its boot overlaps the build and only the warming itself lands on the
197
211
  // adapter step.
@@ -917,6 +931,8 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
917
931
  // (typecheck) are awaited next and reported on their own lines.
918
932
  const buildDurationMs = performance.now() - startedAt
919
933
  const phases = await settleBuildParallelPhases()
934
+ flushDevModuleCaches()
935
+ await writeBuildCache(config, options, cached)
920
936
 
921
937
  printBuildSummary(
922
938
  config,
package/src/cli/create.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { mkdir, readdir } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { bold, cyan, dim, green } from '../utils/ansi'
5
- import { pnextVersionRange } from '../utils/fs'
5
+ import { latestPnextVersionRange } from '../utils/registry'
6
6
  import { formatDuration } from '../utils/verbose'
7
7
 
8
8
  export interface CreateAppOptions {
@@ -27,7 +27,7 @@ export async function createApp(dir: string | undefined, options: CreateAppOptio
27
27
  const name = sanitizePackageName(path.basename(target))
28
28
  console.log(`${cyan('▲')} ${bold('pnext create')} ${dim(`— scaffolding ${name}`)}\n`)
29
29
 
30
- const files = scaffoldFiles(name)
30
+ const files = scaffoldFiles(name, await latestPnextVersionRange('create'))
31
31
  await Promise.all(
32
32
  Object.entries(files).map(([file, source]) => Bun.write(path.join(target, file), source)),
33
33
  )
@@ -112,7 +112,7 @@ function printNextSteps(dir: string, installed: boolean) {
112
112
  console.log(dim(`└${'─'.repeat(width + 2)}┘`))
113
113
  }
114
114
 
115
- function scaffoldFiles(name: string): Record<string, string> {
115
+ function scaffoldFiles(name: string, pnextRange: string): Record<string, string> {
116
116
  return {
117
117
  'package.json': `${JSON.stringify(
118
118
  {
@@ -126,7 +126,7 @@ function scaffoldFiles(name: string): Record<string, string> {
126
126
  },
127
127
  dependencies: { preact: '^10' },
128
128
  devDependencies: {
129
- '@wular/pnext': pnextVersionRange(),
129
+ '@wular/pnext': pnextRange,
130
130
  typescript: '^5',
131
131
  '@types/bun': '^1',
132
132
  },
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { readFile, writeFile } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
- import { pnextOptionalDependencyRange, pnextVersionRange } from '../../utils/fs'
6
+ import { pnextOptionalDependencyRange } from '../../utils/fs'
7
+ import { latestPnextVersionRange } from '../../utils/registry'
7
8
  import type { MigrationResult } from './report'
8
9
 
9
10
  const SEPARATORS = new Set(['&&', '||', ';', '|', '&'])
@@ -124,7 +125,7 @@ export async function migratePackageJson(
124
125
  }
125
126
  }
126
127
  if (!('@wular/pnext' in devDependencies) && !('@wular/pnext' in dependencies)) {
127
- const versionRange = pnextVersionRange()
128
+ const versionRange = await latestPnextVersionRange('migrate')
128
129
  devDependencies['@wular/pnext'] = versionRange
129
130
  result.edits.push({
130
131
  file: 'package.json',
@@ -7,6 +7,7 @@ import { existsSync, readFileSync } from 'node:fs'
7
7
  import path from 'node:path'
8
8
  import { pathToFileURL } from 'node:url'
9
9
  import type { Plugin as EsbuildPlugin } from 'esbuild'
10
+ import { frameworkFingerprint } from '../../runtime/fingerprint'
10
11
 
11
12
  const frameworkRoot = path.resolve(import.meta.dirname, '..', '..', '..')
12
13
 
@@ -20,8 +21,17 @@ function frameworkVersion(): string {
20
21
  }
21
22
  }
22
23
 
23
- function serverEntryDir(outPath: string): string {
24
- return path.join(outPath, 'server', `bundle-${frameworkVersion()}`)
24
+ /**
25
+ * Version AND framework fingerprint: the version alone let an edited `src` keep serving the bundle
26
+ * built from the previous source, so a fix appeared to have no effect until the app was rebuilt.
27
+ * The fingerprint is the same one every other compiled artifact is keyed on, taken through the
28
+ * cache-root record so a restart pays one stat per file rather than re-reading the tree.
29
+ */
30
+ export function serverEntryDir(outPath: string): string {
31
+ // Cache root spelled inline, not imported: this module is parsed before anything else on the
32
+ // start path, and pulling in the module cache for one path.join would drag its whole graph along.
33
+ const generation = frameworkFingerprint(path.join(outPath, 'cache', 'server'))
34
+ return path.join(outPath, 'server', `bundle-${frameworkVersion()}-${generation}`)
25
35
  }
26
36
 
27
37
  function serverEntryFile(outPath: string): string {
@@ -30,7 +40,7 @@ function serverEntryFile(outPath: string): string {
30
40
 
31
41
  /**
32
42
  * The prebuilt entry for a project root, or undefined when there is none to
33
- * use (no build, a custom `outDir`, a version mismatch, or the opt-out). The
43
+ * use (no build, a custom `outDir`, a version or source-generation mismatch, or the opt-out). The
34
44
  * caller then falls back to importing src/cli/start.ts directly, so a miss only
35
45
  * costs speed. Deliberately cheap: one existsSync and one package.json read,
36
46
  * because it runs before anything else on the start path.
@@ -43,7 +53,7 @@ export function prebuiltServerEntry(root = process.cwd()): string | undefined {
43
53
  }
44
54
 
45
55
  /**
46
- * Bundle src/cli/start.ts under `<outPath>/server/bundle-<version>/`. Bare
56
+ * Bundle src/cli/start.ts under `<outPath>/server/bundle-<version>-<fingerprint>/`. Bare
47
57
  * specifiers stay external (the framework's deps are already plain JS), so only
48
58
  * the framework's own source is inlined. Splitting is on so the graph behind a
49
59
  * dynamic import (compat, dev, build) stays in its own chunk — folding it into
@@ -15,6 +15,7 @@ import type { ResolvedConfig } from '../../config'
15
15
  import { bootstrapCompat } from '../../compat-bootstrap'
16
16
  import { registerServerRuntime, serverBundleTargetForRuntime } from '../../runtime/loader'
17
17
  import { importModuleOnce } from '../../runtime/modules'
18
+ import { abortUpstreamFetchOnDisconnect } from '../../runtime/fetch-host'
18
19
  import { applyProxyResponse, createProxyRunner } from '../../routing/proxy'
19
20
  import {
20
21
  isPprShellUpgradeEligible,
@@ -287,6 +288,9 @@ export async function createRequestHandler(
287
288
  },
288
289
  )
289
290
  const response = maybeCloseNodeFetchConnection(finalized, request)
291
+ // Wired before the close-observer wraps the body: the stream a handler
292
+ // passed through from fetch is still the one the abort map knows.
293
+ abortUpstreamFetchOnDisconnect(response.body, request.signal)
290
294
  return flushWorkUnitOnClose(response, unit, request.signal)
291
295
  } catch (error) {
292
296
  // A dropped promise here would close the socket without a response
package/src/cli/start.ts CHANGED
@@ -48,14 +48,9 @@ export async function start(options: StartOptions = {}) {
48
48
  const startedAt = Date.now()
49
49
  const port = options.port ?? 3000
50
50
  const hostname = options.hostname ?? 'localhost'
51
- // Export builds are not served by a production start (Next throws). Standalone builds still serve -
52
- // Next only WARNS, pointing at the standalone launcher, and the launcher re-execs this path with
53
- // `__NEXT_PRIVATE_STANDALONE_CONFIG` set to suppress that warning. Handle both before binding a
54
- // port, matching Next's messages.
51
+ // Export builds are not served; pnext serves its standalone output directly.
55
52
  const config = await guardUnservableOutputMode(options.root)
56
- const manifest = JSON.parse(
57
- await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
58
- ) as BuildManifest
53
+ const manifest = await readProductionManifest(config)
59
54
  await ensurePortFree(port, hostname)
60
55
 
61
56
  // The pipeline import starts right after listen; a request that beats it
@@ -110,10 +105,20 @@ function installProcessErrorHandlers(): void {
110
105
  process.on('uncaughtException', report)
111
106
  }
112
107
 
113
- // Next's output-mode handling on start: an export build throws (fatal - it is unservable), while a
114
- // standalone build only warns and serves on, unless the standalone launcher re-execs with
115
- // `__NEXT_PRIVATE_STANDALONE_CONFIG` set. pnext mirrors that: load the compat config, read the output
116
- // mode from the build-compat registry, and reproduce Next's exact messages.
108
+ export async function readProductionManifest(config: ResolvedConfig): Promise<BuildManifest> {
109
+ try {
110
+ return JSON.parse(
111
+ await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
112
+ ) as BuildManifest
113
+ } catch (error) {
114
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
115
+ throw new Error("No production build found. Run 'pnext build' first.")
116
+ }
117
+ throw error
118
+ }
119
+ }
120
+
121
+ // Export output is unservable; pnext production output remains serveable for standalone apps.
117
122
  async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig> {
118
123
  const config = await loadConfig(root, { serve: true })
119
124
  await bootstrapCompat(config)
@@ -125,6 +130,8 @@ async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig>
125
130
  )
126
131
  process.exit(1)
127
132
  }
133
+ // Next warns on `next start` with standalone output; its standalone launcher re-execs this path
134
+ // with __NEXT_PRIVATE_STANDALONE_CONFIG set to suppress the warning. Mirror both.
128
135
  // eslint-disable-next-line turbo/no-undeclared-env-vars
129
136
  if (mode === 'standalone' && !process.env.__NEXT_PRIVATE_STANDALONE_CONFIG) {
130
137
  console.warn(
@@ -57,7 +57,10 @@ function routeEntry(route: RouteManifestEntry) {
57
57
  }
58
58
 
59
59
  async function writeChecks(config: ResolvedConfig, routes: RouteManifestEntry[]) {
60
- const checksDir = path.join(config.typesPath, 'checks')
60
+ const checksDir = config.checksPath
61
+ // Checks used to live under typesPath, which the app's own tsconfig includes; a stale
62
+ // copy there fails the app's compiler on pnext internals, so heal that on every generate.
63
+ await rm(path.join(config.typesPath, 'checks'), { recursive: true, force: true })
61
64
  await rm(checksDir, { recursive: true, force: true })
62
65
  await ensureDir(checksDir)
63
66
 
@@ -479,16 +479,19 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
479
479
  const outfile = path.join(outDir, `${entryName}.js`)
480
480
  const pipeline = createClientSourcePipeline(config, dev === true, route.id)
481
481
  pipeline.warmRoutes([route])
482
- const runtimeFacts = clientRuntimeFacts(
483
- [
484
- {
485
- route,
486
- shell: hasClientRootLayout(config, route),
487
- },
488
- ],
489
- nextCompatEnabled(config),
490
- suspense,
491
- )
482
+ const runtimeFacts: ClientRuntimeFacts = {
483
+ ...clientRuntimeFacts(
484
+ [
485
+ {
486
+ route,
487
+ shell: hasClientRootLayout(config, route),
488
+ },
489
+ ],
490
+ nextCompatEnabled(config),
491
+ suspense,
492
+ ),
493
+ dev: dev === true,
494
+ }
492
495
  const prebuilt = await clientProfile.timeAsync('prebuilt', () =>
493
496
  preparePrebuilt(
494
497
  config,