@wular/pnext 0.0.24 → 0.1.0

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.
Files changed (53) hide show
  1. package/bin/pnext +1 -1
  2. package/package.json +2 -2
  3. package/reference/data/bench.json +398 -223
  4. package/reference/getting-started.md +1 -1
  5. package/src/api/client-navigation.ts +7 -5
  6. package/src/cli/adapters/vercel-warm.ts +120 -28
  7. package/src/cli/build.ts +56 -15
  8. package/src/cli/create.ts +1 -1
  9. package/src/cli/dev.ts +2 -1
  10. package/src/cli/index.ts +146 -10
  11. package/src/cli/migrate/report.ts +1 -1
  12. package/src/cli/serve/pipeline.ts +23 -6
  13. package/src/cli/serve/ui.ts +1 -1
  14. package/src/cli/start.ts +2 -1
  15. package/src/cli/typegen.ts +5 -2
  16. package/src/client/build.ts +217 -58
  17. package/src/client/chunk-fold.ts +7 -1
  18. package/src/client/entry.ts +44 -7
  19. package/src/client/router/runtime.ts +1016 -230
  20. package/src/client/router/types.ts +15 -0
  21. package/src/compat/actions/client-plugin.ts +10 -0
  22. package/src/compat/actions/rewrite.ts +1 -0
  23. package/src/compat/bundler/optimize-package-imports.ts +375 -28
  24. package/src/compat/client/navigation-scroll.ts +29 -3
  25. package/src/compat/client/optimistic-routing.ts +2 -2
  26. package/src/compat/client/segment-cache-policy.ts +4 -18
  27. package/src/compat/client/segment-cache.ts +104 -0
  28. package/src/compat/client/segment-prefetch.ts +6 -6
  29. package/src/compat/next/config-loader.ts +6 -4
  30. package/src/compat/next/navigation.ts +16 -2
  31. package/src/compat/pages/router.ts +102 -9
  32. package/src/compat/protocol.ts +10 -1
  33. package/src/compat/register/actions.ts +2 -0
  34. package/src/compat/register/bundler.ts +4 -6
  35. package/src/compat/register/routing.ts +3 -4
  36. package/src/compat/tsconfig-defaults.ts +3 -2
  37. package/src/config.ts +3 -3
  38. package/src/css/postcss.ts +450 -17
  39. package/src/dev/client-actions.ts +2 -0
  40. package/src/dev/server.ts +87 -14
  41. package/src/render/island-context.ts +3 -0
  42. package/src/render/ppr.ts +14 -0
  43. package/src/render/renderer.ts +268 -42
  44. package/src/request/context.ts +32 -11
  45. package/src/resolve/scan-facts.ts +23 -4
  46. package/src/routing/proxy.ts +29 -25
  47. package/src/routing/routes.ts +1 -1
  48. package/src/runtime/loader.ts +47 -1
  49. package/src/runtime/modules.ts +546 -65
  50. package/src/runtime/vendor-build.ts +109 -22
  51. package/src/runtime/vendor.ts +7 -3
  52. package/src/utils/ansi.ts +3 -1
  53. package/src/utils/fs.ts +17 -3
@@ -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) 1.3.6 or newer - make sure it is installed first.
5
+ > pnext requires [Bun](https://bun.sh/get) 1.3.10 or newer (1.4.0 recommended) - make sure it is installed first.
6
6
 
7
7
  <!-- tabs:start -->
8
8
 
@@ -176,15 +176,17 @@ function useLocation() {
176
176
  return previous === next ? previous : next
177
177
  })
178
178
  locationListeners().add(listener)
179
- window.addEventListener('popstate', listener)
180
179
  // Re-read once the listener is attached: the URL can move between the
181
180
  // render that captured it and this effect (a history traversal landing
182
181
  // while a swapped-in tree mounts), and nothing was listening for that one.
182
+ // Raw popstate is deliberately NOT a subscription source: on a full
183
+ // traversal the address bar moves before the cached document commits, so
184
+ // rendering that URL into the departing island can consume the only route
185
+ // change before the persistent island is grafted into the committed tree.
186
+ // The router broadcasts shallow traversals immediately and full traversals
187
+ // after their DOM commit.
183
188
  listener()
184
- return () => {
185
- locationListeners().delete(listener)
186
- window.removeEventListener('popstate', listener)
187
- }
189
+ return () => locationListeners().delete(listener)
188
190
  }, [])
189
191
 
190
192
  return location
@@ -1,10 +1,14 @@
1
1
  /**
2
- * Module warm-up for the vercel adapter - run in its own process.
2
+ * Module warm-up for a production build - run in its own process.
3
3
  *
4
- * Request time on Vercel is read-only, so everything a production start would compile or vendor
5
- * lazily has to be on disk already: this step compiles every module the production server can need
6
- * into the build cache and imports the app's route handlers once so their on-demand vendor bundles
7
- * get written now.
4
+ * Every build compiles the modules its production server can need (`compile` mode): a built app that
5
+ * still compiles on its first request pays a whole cold pipeline - esbuild service included - inside
6
+ * the response, which is neither a build output nor something a served request should do.
7
+ *
8
+ * The vercel adapter needs strictly more (`full` mode). Request time on Vercel is read-only, so
9
+ * everything a production start would compile or *vendor* lazily has to be on disk already: that
10
+ * mode also imports the app's route handlers and page modules once, so their on-demand vendor
11
+ * bundles get written now.
8
12
  *
9
13
  * Importing handlers runs the app's own server code, which can crash the runtime rather than throw,
10
14
  * so the whole pass runs in a child that writes into the same on-disk build cache the parent reads
@@ -35,7 +39,13 @@ import {
35
39
  import { getCompatModeExtensions } from '../../extensions'
36
40
  import { getFontExtensions } from '../../render/hooks'
37
41
  import { findConventionFiles, findLayouts } from '../../routing/routes'
38
- import { registerServerRuntime, serverBundleTargetForRuntime } from '../../runtime/loader'
42
+ import {
43
+ prewarmServerTransforms,
44
+ registerServerRuntime,
45
+ serverBundleTargetForRuntime,
46
+ } from '../../runtime/loader'
47
+ import { cacheRoot } from '../../runtime/module-cache'
48
+ import { listFiles } from '../../utils/fs'
39
49
  import { namedBunBinary } from '../boot/named-bin'
40
50
  import type { VerboseLogger } from '../../utils/verbose'
41
51
  import type { BuildManifest } from '../../types'
@@ -73,6 +83,15 @@ const ATTEMPT_TIMEOUT_MS = 10 * 60_000
73
83
  // before concluding the app cannot be warmed.
74
84
  const MAX_ATTEMPTS = 3
75
85
 
86
+ /**
87
+ * `compile` writes every artifact the production server would otherwise build inside a request;
88
+ * `full` additionally imports the app's own modules so their vendor bundles land too (see above).
89
+ */
90
+ export type WarmMode = 'compile' | 'full'
91
+
92
+ /** argv flag rather than a stdin message: the child picks its mode before the first prewarm batch. */
93
+ const FULL_MODE_FLAG = '--full'
94
+
76
95
  export interface WarmChild {
77
96
  /**
78
97
  * Hand the child the page sources from the route scan so it compiles them
@@ -87,17 +106,38 @@ export interface WarmChild {
87
106
  * child runs handlers concurrently.
88
107
  */
89
108
  finish(log: VerboseLogger, onCompiled?: (modules: string[]) => void): Promise<string[]>
109
+ /** Stop every attempt owned by this build. Safe to call after `finish` or more than once. */
110
+ kill(): void
90
111
  }
91
112
 
92
- type WarmProcess = Bun.Subprocess<'pipe', 'pipe', 'inherit'>
113
+ type WarmProcess = Bun.Subprocess<'pipe', 'pipe', 'pipe'>
93
114
 
94
115
  /**
95
116
  * Spawn the warm child. Call as early in the build as possible: it boots while the build runs and
96
117
  * does no work until `finish`, which must be called only after the build manifest is on disk. Never
97
118
  * throws - a warm-up that cannot start degrades to "no warmed modules", which `finish` reports.
98
119
  */
99
- export function startWarmChild(config: ResolvedConfig): WarmChild {
100
- const spawn = () => spawnWarmProcess(config)
120
+ export function startWarmChild(config: ResolvedConfig, mode: WarmMode = 'full'): WarmChild {
121
+ const children = new Set<WarmProcess>()
122
+ let disposed = false
123
+ const spawn = () => {
124
+ if (disposed) return undefined
125
+ const child = spawnWarmProcess(config, mode)
126
+ if (!child) return undefined
127
+ children.add(child)
128
+ void child.exited.finally(() => children.delete(child))
129
+ void forwardWarmStderr(child)
130
+ return child
131
+ }
132
+ const killOnExit = () => kill()
133
+ const kill = () => {
134
+ if (disposed) return
135
+ disposed = true
136
+ process.off('exit', killOnExit)
137
+ for (const child of children) child.kill()
138
+ children.clear()
139
+ }
140
+ process.once('exit', killOnExit)
101
141
  const first = spawn()
102
142
  return {
103
143
  prewarm(files) {
@@ -109,17 +149,37 @@ export function startWarmChild(config: ResolvedConfig): WarmChild {
109
149
  // broken pipe: the child is gone; `finish` reports why
110
150
  }
111
151
  },
112
- finish: (log, onCompiled) => warmWithRestarts(first, spawn, log, onCompiled),
152
+ finish: (log, onCompiled) =>
153
+ warmWithRestarts(first, spawn, mode, log, onCompiled).finally(kill),
154
+ kill,
113
155
  }
114
156
  }
115
157
 
116
- function spawnWarmProcess(config: ResolvedConfig): WarmProcess | undefined {
158
+ /** Forward diagnostics without giving the warm child the caller's stderr descriptor. */
159
+ async function forwardWarmStderr(child: WarmProcess) {
160
+ try {
161
+ const reader = child.stderr.getReader()
162
+ for (;;) {
163
+ const { done, value } = await reader.read()
164
+ if (done) return
165
+ process.stderr.write(value)
166
+ }
167
+ } catch {
168
+ // The build shutting the child down also closes this pipe.
169
+ }
170
+ }
171
+
172
+ function spawnWarmProcess(config: ResolvedConfig, mode: WarmMode): WarmProcess | undefined {
117
173
  const entry = fileURLToPath(new URL(import.meta.url))
174
+ const argv = mode === 'full' ? [config.root, FULL_MODE_FLAG] : [config.root]
118
175
  try {
119
- return Bun.spawn(
120
- [namedBunBinary('pnext-warm'), '--conditions=react-server', entry, config.root],
121
- { cwd: config.root, stdin: 'pipe', stdout: 'pipe', stderr: 'inherit', env: process.env },
122
- )
176
+ return Bun.spawn([namedBunBinary('pnext-warm'), '--conditions=react-server', entry, ...argv], {
177
+ cwd: config.root,
178
+ stdin: 'pipe',
179
+ stdout: 'pipe',
180
+ stderr: 'pipe',
181
+ env: process.env,
182
+ })
123
183
  } catch {
124
184
  return undefined // no child (fork limit, unsupported platform)
125
185
  }
@@ -128,6 +188,7 @@ function spawnWarmProcess(config: ResolvedConfig): WarmProcess | undefined {
128
188
  async function warmWithRestarts(
129
189
  first: WarmProcess | undefined,
130
190
  spawn: () => WarmProcess | undefined,
191
+ mode: WarmMode,
131
192
  log: VerboseLogger,
132
193
  onCompiled?: (modules: string[]) => void,
133
194
  ) {
@@ -162,7 +223,7 @@ async function warmWithRestarts(
162
223
  // thing it would redo is what killed it.
163
224
  child = finished.size > before && attempt < MAX_ATTEMPTS ? spawn() : undefined
164
225
  if (child) log.log(`warm-up ${failure} after ${finished.size} files; restarting past them`)
165
- else warmIncomplete(failure, finished.size)
226
+ else warmIncomplete(failure, finished.size, mode)
166
227
  }
167
228
  fireOnCompiled?.()
168
229
  return compiled()
@@ -222,11 +283,13 @@ async function runAttempt(
222
283
  return child.signalCode ? `was killed by ${child.signalCode}` : `exited with code ${code}`
223
284
  }
224
285
 
225
- function warmIncomplete(failure: string, done: number) {
286
+ function warmIncomplete(failure: string, done: number, mode: WarmMode) {
226
287
  console.warn(
227
- `vercel adapter: module warm-up ${failure} after warming ${done} file(s). The function ships ` +
228
- 'without the rest of its module cache and may fail to render those routes at request time, ' +
229
- "where Vercel's filesystem is read-only.",
288
+ `pnext build: module warm-up ${failure} after warming ${done} file(s). ` +
289
+ (mode === 'full'
290
+ ? 'The function ships without the rest of its module cache and may fail to render those ' +
291
+ "routes at request time, where Vercel's filesystem is read-only."
292
+ : 'The production server compiles the rest on the first request that needs them.'),
230
293
  )
231
294
  }
232
295
 
@@ -279,6 +342,7 @@ export async function warmRouteModules(
279
342
  config: ResolvedConfig,
280
343
  manifest: BuildManifest,
281
344
  skip: ReadonlySet<string> = new Set(),
345
+ mode: WarmMode = 'full',
282
346
  ) {
283
347
  const clientFiles = new Set<string>()
284
348
  const handlers: BuildManifest['routes'] = []
@@ -313,9 +377,31 @@ export async function warmRouteModules(
313
377
  report(file, fileURLToPath(href)),
314
378
  ),
315
379
  ),
380
+ // Handler artifacts are otherwise only written by the import below, which
381
+ // `compile` mode does not run: compiling them here is what keeps a handler
382
+ // route's first served request off the compile pipeline too.
383
+ ...(mode === 'compile'
384
+ ? handlers.map(async route => {
385
+ registerServerRuntime(config, route.sourceFiles)
386
+ report(route.file, fileURLToPath(await handlerModuleHref(config, route)))
387
+ })
388
+ : []),
316
389
  ])
317
390
  }
318
391
  console.log(COMPILED_MARKER)
392
+ if (mode === 'compile') {
393
+ // Transform is the tail of the compile pipeline: an artifact is transformed when it is first
394
+ // imported, so a server that skipped every compile would still start esbuild for this alone.
395
+ // The import pass below covers it in `full` mode; here it runs standalone, without app code.
396
+ for (const route of [...pages, ...handlers]) registerServerRuntime(config, route.sourceFiles)
397
+ const modules = cacheRoot(config.outPath)
398
+ await prewarmServerTransforms(modules, await listFiles(modules))
399
+ return
400
+ }
401
+ // Everything below runs the app's own code to capture what only an import
402
+ // writes (vendor bundles, next/font bytes). A normal build serves from a
403
+ // writable disk and creates those on demand; only a read-only deployment
404
+ // target needs them now.
319
405
 
320
406
  // Handlers come last, and only once nothing else is in flight. Importing them runs the app's own
321
407
  // server code (auth, db pools, telemetry), and that code running *concurrently* with the compile
@@ -447,18 +533,24 @@ async function readWarmRequests(config: ResolvedConfig): Promise<Partial<WarmReq
447
533
 
448
534
  if (import.meta.main) {
449
535
  const root = process.argv[2]
450
- const config = await loadConfig(root)
536
+ const mode: WarmMode = process.argv.includes(FULL_MODE_FLAG) ? 'full' : 'compile'
537
+ // This helper's stderr is forwarded to the user-facing build. The parent
538
+ // already validated and printed next.config warnings, so avoid duplicates.
539
+ const config = await loadConfig(root, { warnings: false })
451
540
  await bootstrapCompat(config)
452
- // This process only ever runs for a vercel-adapter build; record each
453
- // artifact's specifiers so the parent's trace step can read them back.
454
- setEmitCompiledSpecifiersManifest(true)
541
+ // Only the vercel adapter reads the specifier manifests back (its trace step);
542
+ // a plain build would write them for nothing.
543
+ setEmitCompiledSpecifiersManifest(mode === 'full')
455
544
  // Blocks until the parent sends the go-ahead, i.e. until the manifest is
456
545
  // written; a prewarm batch may arrive and run before that.
457
546
  const request = await readWarmRequests(config)
458
- const manifest = JSON.parse(
459
- await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
460
- ) as BuildManifest
461
- await warmRouteModules(config, manifest, new Set(request.skip ?? []))
547
+ // A build that fails closes stdin without a go-ahead and never writes the manifest. There is
548
+ // nothing to warm and nothing to report: exiting quietly keeps the failed build's own error the
549
+ // only thing on stderr.
550
+ const manifestFile = path.join(config.outPath, 'manifest.json')
551
+ if (!request.skip || !existsSync(manifestFile)) process.exit(0)
552
+ const manifest = JSON.parse(await readFile(manifestFile, 'utf8')) as BuildManifest
553
+ await warmRouteModules(config, manifest, new Set(request.skip ?? []), mode)
462
554
  // App code reached through a route handler can leave the loop alive (a db
463
555
  // pool, a stray interval); the warm pass is done either way.
464
556
  process.exit(0)
package/src/cli/build.ts CHANGED
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module'
4
4
  import path from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import { writeVercelOutput } from './adapters/vercel'
7
- import { startWarmChild } from './adapters/vercel-warm'
7
+ import { startWarmChild, type WarmChild } from './adapters/vercel-warm'
8
8
  import { devOutSegment, loadConfig, pathToFileHref } from '../config'
9
9
  import { bootstrapCompat } from '../compat-bootstrap'
10
10
  import {
@@ -67,7 +67,12 @@ import {
67
67
  } from '../render/ppr'
68
68
  import { handleRouteModule, staticRouteParams, type RouteHandlerModule } from '../routing/handler'
69
69
  import { runWithCacheScope } from '../request/cache'
70
- import { devServerModuleHref, setEmitCompiledSpecifiersManifest } from '../runtime/modules'
70
+ import {
71
+ devServerModuleHref,
72
+ resetModuleGraphFailure,
73
+ setEmitCompiledSpecifiersManifest,
74
+ throwIfModuleGraphFailed,
75
+ } from '../runtime/modules'
71
76
  import {
72
77
  beginDynamicBailoutProbe,
73
78
  endDynamicBailoutProbe,
@@ -151,34 +156,39 @@ interface BuildOptions {
151
156
 
152
157
  export async function buildProject(root?: string, options: BuildOptions = {}) {
153
158
  clearBuildParallelPhases()
159
+ resetModuleGraphFailure()
154
160
  // One read per source for the whole build (route-fact walk, action discovery,
155
161
  // client loader), released here so nothing survives the build.
156
162
  beginSourceScope()
157
163
  // Only the vercel adapter's trace step reads the per-artifact specifier
158
164
  // sidecars; every other build path pays nothing for them.
159
165
  const restoreSpecifiersManifest = setEmitCompiledSpecifiersManifest(options.adapter === 'vercel')
166
+ const lifecycle: { warm?: WarmChild } = {}
160
167
  try {
161
- return await runBuild(root, options)
168
+ return await runBuild(root, options, lifecycle)
162
169
  } catch (error) {
163
170
  // A parallel phase that already failed (a type error) is the root cause of
164
171
  // whatever broke downstream; report it instead of the symptom.
165
172
  const phaseError = buildParallelPhaseError()
166
173
  throw phaseError ?? error
167
174
  } finally {
175
+ lifecycle.warm?.kill()
168
176
  endSourceScope()
169
177
  restoreSpecifiersManifest()
170
178
  flushDevModuleCaches()
171
179
  }
172
180
  }
173
181
 
174
- async function runBuild(root: string | undefined, options: BuildOptions) {
182
+ async function runBuild(
183
+ root: string | undefined,
184
+ options: BuildOptions,
185
+ lifecycle: { warm?: WarmChild },
186
+ ) {
175
187
  const verbose = options.verbose ?? false
176
188
  const log = createVerboseLogger(verbose, 'build')
177
189
  const startedAt = performance.now()
178
190
 
179
- console.log(
180
- `${cyan('▲')} ${bold('pnext')} ${dim('— Creating an optimized production build ...\n')}`,
181
- )
191
+ console.log(`⚡ ${bold('pnext')} ${dim('— Creating an optimized production build ...\n')}`)
182
192
  const config = await loadConfig(root)
183
193
  const buildCache = lookupBuildCache(config, options)
184
194
  // Compat plugin loader: the single gated seam that populates the core
@@ -206,10 +216,13 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
206
216
  // Load Tailwind on the CSS worker while the steps below run, so the first
207
217
  // stylesheet doesn't pay its cold boot.
208
218
  warmCssPipeline(config)
209
- // The vercel adapter's warm pass runs in its own process; start it here so
210
- // its boot overlaps the build and only the warming itself lands on the
211
- // adapter step.
212
- const warm = options.adapter === 'vercel' ? startWarmChild(config) : undefined
219
+ // The warm pass runs in its own process; start it here so its boot overlaps the build and only the
220
+ // warming itself lands on its own step. Every build compiles what the production server would
221
+ // otherwise compile inside its first request; the vercel adapter additionally imports app modules
222
+ // for the bundles only an import writes (see adapters/vercel-warm).
223
+ const warmMode = options.adapter === 'vercel' ? 'full' : 'compile'
224
+ const warm = startWarmChild(config, warmMode)
225
+ lifecycle.warm = warm
213
226
  await log.step('prepare output directory', async () => {
214
227
  // Build-owned outputs only: `<outRoot>/dev` belongs to a possibly-running
215
228
  // dev server and must survive.
@@ -252,10 +265,10 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
252
265
  `--debug-build-paths ${options.debugBuildPaths} — ${routes.length} route${routes.length === 1 ? '' : 's'} kept`,
253
266
  )
254
267
  }
255
- // The page routes' server modules are the bulk of the adapter's warm pass and
256
- // depend on nothing but the scan; hand them over now so the child compiles
257
- // them under the client stage instead of after it.
258
- warm?.prewarm(routes.filter(route => route.kind === 'page').map(route => route.file))
268
+ // The page routes' server modules are the bulk of the warm pass and depend on
269
+ // nothing but the scan; hand them over now so the child compiles them under
270
+ // the client stage instead of after it.
271
+ warm.prewarm(routes.filter(route => route.kind === 'page').map(route => route.file))
259
272
  // Compat build steps (action discovery/bundling + server-reference manifest)
260
273
  // populate their output onto this accumulator. Pure-core registers no steps.
261
274
  const buildState: BuildStepState = { actions: [] }
@@ -842,6 +855,12 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
842
855
  for (const line of generateRouteLines) console.log(line)
843
856
  }
844
857
 
858
+ // A render may intentionally turn an exception into a 500 response so the
859
+ // dev server can stay alive. During a production build, however, an emitted
860
+ // module that cannot resolve one of its content-addressed dependencies makes
861
+ // the output unusable and must remain fatal even after that recovery path.
862
+ throwIfModuleGraphFailed()
863
+
845
864
  // Fail the build after every route has been given the chance to log its
846
865
  // prerender error (matching `prerenderEarlyExit: false`).
847
866
  if (hadAfterPrerenderError) {
@@ -926,6 +945,13 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
926
945
  await log.step('vercel adapter output', () =>
927
946
  writeVercelOutput(config, manifest, { verbose, warm }),
928
947
  )
948
+ // Plain build: the adapter owns the child in the branch above. Compiling the
949
+ // rest of the app's modules is build work, so it counts toward the build's own
950
+ // duration rather than hiding after it.
951
+ else await log.step('warm module cache', () => warm.finish(log))
952
+
953
+ // Also cover module loads performed by completion hooks or the warm pass.
954
+ throwIfModuleGraphFailed()
929
955
 
930
956
  // Bundling is done; the build metric stops here. Phases that ran alongside it
931
957
  // (typecheck) are awaited next and reported on their own lines.
@@ -1068,6 +1094,7 @@ function printBuildSummary(
1068
1094
  // Route table: ○ static, ● SSG (params), ◐ partial prerender, ƒ dynamic.
1069
1095
  console.log('')
1070
1096
  console.log('Route (app)')
1097
+ const usedMarkers = new Set<string>()
1071
1098
  for (const route of routes) {
1072
1099
  const intercepted = interceptionPrerenders.get(route.id)
1073
1100
  const marker =
@@ -1089,15 +1116,29 @@ function printBuildSummary(
1089
1116
  const label = route.interception ? interceptionDisplayRoute(route) : route.route || '/'
1090
1117
  // Next names a dynamic route by its source pattern (`/[dyn]`), not pnext's
1091
1118
  // `:param` form; compat apps' e2e output is matched against Next's.
1119
+ usedMarkers.add(marker)
1092
1120
  console.log(` ${marker} ${nextCompatEnabled(config) ? toNextRoutePattern(label) : label}`)
1093
1121
  }
1094
1122
  for (const file of staticMetadataSummaryFiles(staticFiles)) {
1123
+ usedMarkers.add('○')
1095
1124
  console.log(` ${dim('○')} /${file}`)
1096
1125
  }
1097
1126
  if (hasMiddleware) {
1127
+ usedMarkers.add('ƒ')
1098
1128
  console.log('')
1099
1129
  console.log(` ${dim('ƒ')} Middleware`)
1100
1130
  }
1131
+ const legend: [string, string][] = [
1132
+ ['○', 'static'],
1133
+ ['●', 'SSG'],
1134
+ ['◐', 'partial prerender'],
1135
+ ['ƒ', 'dynamic'],
1136
+ ]
1137
+ const legendLine = legend
1138
+ .filter(([marker]) => usedMarkers.has(marker))
1139
+ .map(([marker, meaning]) => `${marker} ${meaning}`)
1140
+ .join(' ')
1141
+ if (legendLine) console.log(`\n ${dim(legendLine)}`)
1101
1142
  console.log('')
1102
1143
  console.log(
1103
1144
  `${green('✓')} ${bold('Build complete')} ${dim(`in ${formatBuildDuration(durationMs)}`)}`,
package/src/cli/create.ts CHANGED
@@ -25,7 +25,7 @@ export async function createApp(dir: string | undefined, options: CreateAppOptio
25
25
  }
26
26
 
27
27
  const name = sanitizePackageName(path.basename(target))
28
- console.log(`${cyan('▲')} ${bold('pnext create')} ${dim(`— scaffolding ${name}`)}\n`)
28
+ console.log(`⚡ ${bold('pnext create')} ${dim(`— scaffolding ${name}`)}\n`)
29
29
 
30
30
  const files = await scaffoldFiles(name, await latestPnextVersionRange('create'))
31
31
  await Promise.all(
package/src/cli/dev.ts CHANGED
@@ -44,7 +44,8 @@ export async function dev(options: DevOptions = {}) {
44
44
  markBoot('dev:config')
45
45
  warnDuplicateSass(config.root, Boolean(config.compat?.next))
46
46
  const port = options.port ?? 3000
47
- const hostname = options.hostname ?? 'localhost'
47
+ // Next's documented default. Bun resolves 'localhost' to ::1 only, refusing IPv4 clients.
48
+ const hostname = options.hostname ?? '0.0.0.0'
48
49
  // A memory-triggered restart can briefly race the previous listener's
49
50
  // shutdown, so give the port a moment to free before failing.
50
51
  await ensurePortFree(port, hostname, { attempts: 10 })