@wular/pnext 0.0.9 → 0.0.11

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 (60) hide show
  1. package/package.json +1 -1
  2. package/reference/dev.md +17 -4
  3. package/src/api/dynamic.tsx +1 -0
  4. package/src/api/link.tsx +1 -0
  5. package/src/cli/analyze.ts +1 -1
  6. package/src/cli/boot/trace.ts +40 -7
  7. package/src/cli/build.ts +2 -2
  8. package/src/cli/dev.ts +5 -10
  9. package/src/cli/migrate/package-json.ts +1 -1
  10. package/src/cli/migrate/report.ts +5 -3
  11. package/src/cli/migrate/run.ts +30 -4
  12. package/src/cli/migrate/scan.ts +34 -7
  13. package/src/cli/typegen.ts +8 -1
  14. package/src/client/build.ts +4 -5
  15. package/src/client/profile.ts +4 -3
  16. package/src/client/router/runtime.ts +8 -0
  17. package/src/compat/actions/config.ts +2 -0
  18. package/src/compat/aliases.ts +2 -2
  19. package/src/compat/bundler/react-profiler.tsx +1 -0
  20. package/src/compat/cache-control.ts +49 -119
  21. package/src/compat/client/errors/global-error.ts +10 -5
  22. package/src/compat/client/errors/install.ts +30 -6
  23. package/src/compat/client/errors/lazy.ts +32 -2
  24. package/src/compat/next/client-script.tsx +1 -0
  25. package/src/compat/next/config-headers.ts +139 -0
  26. package/src/compat/next/config-loader.ts +32 -2
  27. package/src/compat/next/dynamic.tsx +1 -0
  28. package/src/compat/next/error.tsx +1 -0
  29. package/src/compat/next/form.tsx +1 -0
  30. package/src/compat/next/head.tsx +1 -0
  31. package/src/compat/next/headers.ts +12 -6
  32. package/src/compat/next/image/client.tsx +1 -0
  33. package/src/compat/next/image.tsx +1 -0
  34. package/src/compat/next/legacy-image.tsx +1 -0
  35. package/src/compat/next/legacy-request-apis.ts +62 -0
  36. package/src/compat/next/link.tsx +1 -0
  37. package/src/compat/next/rewrites.ts +7 -0
  38. package/src/compat/next/script.tsx +1 -0
  39. package/src/compat/register/bundler.ts +6 -1
  40. package/src/compat/segment/loading-boundary.ts +1 -5
  41. package/src/compat/validation/validate.ts +10 -2
  42. package/src/config.ts +12 -1
  43. package/src/css/build.ts +111 -4
  44. package/src/dev/internal-error.ts +128 -0
  45. package/src/dev/restart/global-css.ts +3 -3
  46. package/src/dev/restart/route-facts.ts +3 -3
  47. package/src/dev/server.ts +2 -2
  48. package/src/render/renderer.ts +20 -2
  49. package/src/render/slots.tsx +1 -0
  50. package/src/request/context.ts +15 -0
  51. package/src/runtime/loader.ts +2 -2
  52. package/src/runtime/module-cache.ts +12 -5
  53. package/src/runtime/modules.ts +44 -31
  54. package/src/runtime/vendor-build.ts +5 -7
  55. package/src/runtime/vendor.ts +29 -13
  56. package/src/types.ts +10 -1
  57. package/src/utils/code.ts +5 -0
  58. package/src/utils/dev-profile.ts +1 -1
  59. package/src/utils/esbuild.ts +37 -2
  60. package/src/utils/trace-flags.ts +66 -0
@@ -2,9 +2,11 @@ import {
2
2
  currentRequest,
3
3
  currentResponseHeaders,
4
4
  getWorkUnit,
5
+ legacyRequestAPIs,
5
6
  requireRequest,
6
7
  requestApiPromise,
7
8
  } from '../../request/context'
9
+ import { withSyncAccess } from './legacy-request-apis'
8
10
  import type { NextRequestCookies } from '../../types'
9
11
  import {
10
12
  assertDraftModeToggleAllowedInAfter,
@@ -63,9 +65,9 @@ export function headers() {
63
65
  assertRequestApiAllowedInAfter('headers()')
64
66
  assertDynamicErrorRequestApi('headers()')
65
67
  assertNoDynamicApiInsideCache('headers()')
66
- return requestApiPromise('headers()', () =>
67
- readonlyHeaders(new Headers(requireRequest('headers()').headers)),
68
- )
68
+ const produce = () => readonlyHeaders(new Headers(requireRequest('headers()').headers))
69
+ const promise = requestApiPromise('headers()', produce)
70
+ return legacyRequestAPIs() ? withSyncAccess(promise, 'headers', produce) : promise
69
71
  }
70
72
 
71
73
  export interface CookieSetOptions {
@@ -91,7 +93,9 @@ export function cookies() {
91
93
  assertRequestApiAllowedInAfter('cookies()')
92
94
  assertDynamicErrorRequestApi('cookies()')
93
95
  assertNoDynamicApiInsideCache('cookies()')
94
- return requestApiPromise('cookies()', () => wrapCookieJar(requireRequest('cookies()').cookies))
96
+ const produce = () => wrapCookieJar(requireRequest('cookies()').cookies)
97
+ const promise = requestApiPromise('cookies()', produce)
98
+ return legacyRequestAPIs() ? withSyncAccess(promise, 'cookies', produce) : promise
95
99
  }
96
100
 
97
101
  export function assertDynamicErrorRequestApi(
@@ -225,7 +229,7 @@ export function draftMode(): Promise<DraftMode> {
225
229
  // draft is simply off, so reading isEnabled must not postpone or throw.
226
230
  const request = currentRequest()
227
231
  const requestCookies = request?.cookies
228
- return Promise.resolve({
232
+ const draft: DraftMode = {
229
233
  get isEnabled() {
230
234
  return requestCookies?.has(draftCookieName) ?? false
231
235
  },
@@ -247,7 +251,9 @@ export function draftMode(): Promise<DraftMode> {
247
251
  `${draftCookieName}=; Path=/; Max-Age=0; HttpOnly; SameSite=None; Secure`,
248
252
  )
249
253
  },
250
- })
254
+ }
255
+ const promise = Promise.resolve(draft)
256
+ return legacyRequestAPIs() ? withSyncAccess(promise, 'draftMode', () => draft) : promise
251
257
  }
252
258
 
253
259
  function bypassToken() {
@@ -1,4 +1,5 @@
1
1
  'use client'
2
+ /** @jsxImportSource preact */
2
3
 
3
4
  import {
4
5
  forwardRef,
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource preact */
1
2
  import { registerResourceHint } from '../../render/resource-hints'
2
3
  import { forwardRef } from 'preact/compat'
3
4
  import ClientImage from './image/client'
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource preact */
1
2
  import { forwardRef } from 'preact/compat'
2
3
  import { registerResourceHint } from '../../render/resource-hints'
3
4
  import { getImagesConfig, resolveImageProps } from './image/props'
@@ -0,0 +1,62 @@
1
+ // Next 15's transitional sync request APIs, behind compat.next.legacyRequestAPIs. cookies()/headers()/
2
+ // draftMode() still return a Promise, but that promise also carries the resolved value's surface, so
3
+ // Next-14-era code (`cookies().get('x')`) keeps working while it migrates to `await`. Each sync access
4
+ // logs Next's sync-dynamic-apis deprecation warning, deduped by userland callsite.
5
+ //
6
+ // Nothing here is reachable — or even allocated — when the option is off.
7
+
8
+ import { currentRenderCacheMeta } from '../cache/revalidate'
9
+
10
+ const PROMISE_PROPS = new Set<PropertyKey>(['then', 'catch', 'finally', 'constructor'])
11
+ const warned = new Set<string>()
12
+
13
+ /**
14
+ * Wrap a request-API promise so unawaited property reads hit the produced value. The value is produced
15
+ * lazily (and once), so a promise nobody touches synchronously costs a single Proxy.
16
+ */
17
+ export function withSyncAccess<T extends object>(
18
+ promise: Promise<T>,
19
+ api: 'cookies' | 'headers' | 'draftMode',
20
+ produce: () => T,
21
+ ): Promise<T> {
22
+ let value: T | undefined
23
+ const resolve = () => (value ??= produce())
24
+ return new Proxy(promise, {
25
+ get(target, prop) {
26
+ if (PROMISE_PROPS.has(prop) || prop === Symbol.toStringTag) {
27
+ const own: unknown = Reflect.get(target, prop, target)
28
+ return typeof own === 'function'
29
+ ? (own as (...args: unknown[]) => unknown).bind(target)
30
+ : own
31
+ }
32
+ warnSyncAccess(api, prop)
33
+ const source = resolve() as object
34
+ const read: unknown = Reflect.get(source, prop, source)
35
+ return typeof read === 'function'
36
+ ? (read as (...args: unknown[]) => unknown).bind(source)
37
+ : read
38
+ },
39
+ })
40
+ }
41
+
42
+ function warnSyncAccess(api: string, prop: PropertyKey): void {
43
+ const expression =
44
+ prop === Symbol.iterator
45
+ ? `\`...${api}()\` or similar iteration`
46
+ : `\`${api}().${String(prop)}\``
47
+ const route = currentRenderCacheMeta()?.route
48
+ const key = `${route ?? ''}|${expression}|${callsite()}`
49
+ if (warned.has(key)) return
50
+ warned.add(key)
51
+ console.warn(
52
+ `${route ? `Route "${route}" ` : 'This route '}used ${expression}. ` +
53
+ `\`${api}()\` should be awaited before using its value. ` +
54
+ 'Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis',
55
+ )
56
+ }
57
+
58
+ /** First stack frame outside this module — the userland call site Next dedupes on. */
59
+ function callsite(): string {
60
+ const frames = new Error().stack?.split('\n').slice(1) ?? []
61
+ return frames.find(frame => !frame.includes('legacy-request-apis')) ?? ''
62
+ }
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource preact */
1
2
  // preact core + hooks only (no preact/compat): this module rides in every compat app's first-page
2
3
  // bundle, and the lite client tier must not pull compat through it. Children.only/count are replaced
3
4
  // with toChildArray below; the ref arrives React-19-style as a prop (the parity vnode pass moves
@@ -163,6 +163,13 @@ export function compileSource(source: string): { regex: RegExp; paramNames: stri
163
163
  parts.push('([^/]+)')
164
164
  continue
165
165
  }
166
+ // A bare regex group segment — `/(.*)` is Next's documented way to match
167
+ // every path in a headers rule. Non-capturing so it never shifts the
168
+ // capture indexes the named params above are read from.
169
+ if (segment.startsWith('(') && segment.endsWith(')')) {
170
+ parts.push(`(?:${segment.slice(1, -1)})`)
171
+ continue
172
+ }
166
173
  // Plain literal segments may carry regex-significant filename characters
167
174
  // (`favicon.ico`, `sitemap.xml`) — escape them. Anything with actual
168
175
  // pattern syntax beyond that is unsupported.
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource preact */
1
2
  import type { JSX } from 'preact'
2
3
  import { escapeHtml } from '../../utils/html'
3
4
  import { getNextConfig } from './config-loader'
@@ -514,7 +514,12 @@ function serverSourceLoadPlugin(
514
514
  // `use client` in a CommonJS file -> tag its exports as references.
515
515
  if (/\.c?js$/.test(args.path)) {
516
516
  const references = await clientReferenceLoad(args.path)
517
- if (references) return references
517
+ if (references) {
518
+ // On the react-server layer the boundary stub wins over the in-place tag: a CommonJS
519
+ // `'use client'` leaf (a package's own `require()`d client half) must never EVALUATE
520
+ // server-side, exactly like the ESM ones this same scan already replaces.
521
+ return (await vendorClientBoundary?.(args.path)) ?? references
522
+ }
518
523
  }
519
524
  // First-party (or transpilePackages-opted) source: font/OPI rewrites.
520
525
  // Everything else under node_modules keeps esbuild's native load path.
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { existsSync, readFileSync, statSync } from 'node:fs'
13
13
  import path from 'node:path'
14
- import { escapeRegex as escapeRegExp } from '../../utils/code'
14
+ import { escapeRegex as escapeRegExp, stripComments } from '../../utils/code'
15
15
 
16
16
  /**
17
17
  * True when the route's PAGE file wraps content in a React `<Suspense>`.
@@ -105,7 +105,3 @@ function namespaceBindings(clauses: readonly string[]): string[] {
105
105
  }
106
106
  return names
107
107
  }
108
-
109
- function stripComments(source: string): string {
110
- return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
111
- }
@@ -16,6 +16,7 @@ import type { ResolvedConfig } from '../../config'
16
16
  import { extraPageExtensions } from '../../extensions'
17
17
  import type { RouteManifestEntry } from '../../types'
18
18
  import { getNextConfig } from '../next/config-loader'
19
+ import { stripComments } from '../../utils/code'
19
20
  import {
20
21
  conflictingAppAndPageMessage,
21
22
  conflictingParallelPagesMessage,
@@ -246,7 +247,9 @@ function validateStaticImageImports(config: ResolvedConfig, routes: RouteManifes
246
247
  if (!isAppSourceFile(config, file)) continue
247
248
  const source = readSource(file)
248
249
  if (!source) continue
249
- const imports = source.matchAll(
250
+ // Comment-stripped: a commented-out image import (or a doc-comment example) is not an
251
+ // import, and webpack under Next would never error on one.
252
+ const imports = stripComments(source).matchAll(
250
253
  /\bimport\s+(?:[^'";]+?\s+from\s+)?['"]([^'"]+\.(?:png|jpe?g|gif|webp|avif|ico|bmp|svg))['"]/gi,
251
254
  )
252
255
  for (const match of imports) {
@@ -376,7 +379,12 @@ function routeFiles(route: RouteManifestEntry): string[] {
376
379
  function isAppSourceFile(config: ResolvedConfig, file: string): boolean {
377
380
  if (isInside(config.outPath, file)) return false
378
381
  const relative = path.relative(config.root, file)
379
- if (relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)) return true
382
+ if (relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)) {
383
+ // A standalone npm layout keeps node_modules INSIDE the root; framework-supplied route
384
+ // files (pnext's own defaults) are not app sources and must never be validated as such
385
+ // (a JSDoc example in pnext's extensions.ts failed a real app's build as './pic.png').
386
+ return !relative.split(path.sep).includes('node_modules')
387
+ }
380
388
  return (
381
389
  file.includes(`${path.sep}source-app${path.sep}`) ||
382
390
  file.includes(`${path.sep}source-pages${path.sep}`)
package/src/config.ts CHANGED
@@ -5,6 +5,7 @@ import { loadEnv } from './env'
5
5
  import { findWorkspaceRoot } from './resolve/imports'
6
6
  import { PREFETCH_MODES, type PNextConfig } from './types'
7
7
  import { setCacheComponents } from './render/ppr'
8
+ import { setLegacyRequestAPIs } from './request/context'
8
9
 
9
10
  export type ResolvedConfig = Required<Pick<PNextConfig, 'outDir' | 'basePath'>> &
10
11
  Omit<PNextConfig, 'outDir' | 'basePath'> & {
@@ -83,10 +84,20 @@ export async function loadConfig(
83
84
  ...(loadedConfig.default ?? {}),
84
85
  // eslint-disable-next-line turbo/no-undeclared-env-vars
85
86
  ...(process.env.PNEXT_COMPAT === 'next'
86
- ? { compat: { ...(loadedConfig.default?.compat ?? {}), next: true } }
87
+ ? {
88
+ compat: {
89
+ ...(loadedConfig.default?.compat ?? {}),
90
+ // An object `next` already enables compat — keep its options rather than flattening to true.
91
+ next: loadedConfig.default?.compat?.next || true,
92
+ },
93
+ }
87
94
  : {}),
88
95
  }
89
96
  validateConfig(config)
97
+ // Per-app flag on a process-global cell (like cacheComponents): always written, never inherited.
98
+ setLegacyRequestAPIs(
99
+ typeof config.compat?.next === 'object' && config.compat.next.legacyRequestAPIs === true,
100
+ )
90
101
  // cacheComponents is a per-app flag on a process-global cell: clear it before
91
102
  // the next.config source re-derives it, so a pure-core (or flag-off) app
92
103
  // loaded after a cacheComponents app never inherits the previous build's flag.
package/src/css/build.ts CHANGED
@@ -282,6 +282,7 @@ export async function buildGlobalCss(config: ResolvedConfig, options: CssBuildOp
282
282
  .join(', ')}`,
283
283
  )
284
284
 
285
+ const postcss = hasPostcssConfig(config.root)
285
286
  await log.step('global css: esbuild bundle', () =>
286
287
  build({
287
288
  stdin: {
@@ -295,7 +296,7 @@ export async function buildGlobalCss(config: ResolvedConfig, options: CssBuildOp
295
296
  minify: !options.dev,
296
297
  conditions: ['style', 'browser'],
297
298
  assetNames: 'files/[name]-[hash]',
298
- plugins: getCssExtensions().cssChunkPlugins(),
299
+ plugins: [...getCssExtensions().cssChunkPlugins(), ...cssDirectiveOrderPlugins(postcss)],
299
300
  loader: {
300
301
  '.css': 'css',
301
302
  '.module.css': 'css',
@@ -311,7 +312,7 @@ export async function buildGlobalCss(config: ResolvedConfig, options: CssBuildOp
311
312
  },
312
313
  }),
313
314
  )
314
- if (hasPostcssConfig(config.root)) {
315
+ if (postcss) {
315
316
  await rewriteTailwindConfigDirectives(outfile, sources)
316
317
  await log.step('global css: postcss', () =>
317
318
  runPostcssOffThread(config, outfile, options, sources[0]),
@@ -473,6 +474,7 @@ async function bundleCssChunk(
473
474
 
474
475
  log.log(`${label}: ${cssImports.length} import${cssImports.length === 1 ? '' : 's'}`)
475
476
 
477
+ const postcss = hasPostcssConfig(config.root)
476
478
  await build({
477
479
  stdin: {
478
480
  contents: cssImports.map(file => `@import ${JSON.stringify(file)};`).join('\n'),
@@ -488,7 +490,11 @@ async function bundleCssChunk(
488
490
  conditions: ['style', 'browser'],
489
491
  assetNames: 'files/[name]-[hash]',
490
492
  // Lazy registry read: compat (sass) plugins register at bootstrap.
491
- plugins: [...getCssExtensions().cssChunkPlugins(), cssModuleBuildPlugin()],
493
+ plugins: [
494
+ ...getCssExtensions().cssChunkPlugins(),
495
+ cssModuleBuildPlugin(),
496
+ ...cssDirectiveOrderPlugins(postcss),
497
+ ],
492
498
  loader: {
493
499
  '.css': 'css',
494
500
  '.module.css': 'css',
@@ -504,7 +510,7 @@ async function bundleCssChunk(
504
510
  },
505
511
  })
506
512
 
507
- if (hasPostcssConfig(config.root)) {
513
+ if (postcss) {
508
514
  await rewriteTailwindConfigDirectives(outfile, cssImports)
509
515
  await log.step(`${label}: postcss`, () => runPostcssOffThread(config, outfile, options))
510
516
  }
@@ -554,6 +560,107 @@ export function cssModuleClientPlugin(): Plugin {
554
560
  }
555
561
  }
556
562
 
563
+ // Tailwind's own pipeline consumes @source/@plugin/@config wherever they sit, so apps write them
564
+ // above their @import lines. esbuild bundles first and rejects any @import that follows another
565
+ // rule ("invalid-@import"), leaving that sheet unbundled — a broken stylesheet. Only postcss apps
566
+ // can have these directives, so plain-CSS apps keep esbuild's native (plugin-free) load path.
567
+ function cssDirectiveOrderPlugins(postcss: boolean): Plugin[] {
568
+ return postcss ? [cssDirectiveOrderPlugin()] : []
569
+ }
570
+
571
+ function cssDirectiveOrderPlugin(): Plugin {
572
+ return {
573
+ name: 'pnext-css-directive-order',
574
+ setup(plugin) {
575
+ plugin.onLoad({ filter: /\.css$/ }, async ({ path: file }) => ({
576
+ contents: hoistCssImports(await readText(file)),
577
+ loader: 'css',
578
+ resolveDir: path.dirname(file),
579
+ }))
580
+ },
581
+ }
582
+ }
583
+
584
+ /** At-rules that configure Tailwind and emit no CSS, so their position is free. */
585
+ const CSS_CONFIG_AT_RULES = new Set(['source', 'plugin', 'config'])
586
+
587
+ /**
588
+ * Move leading @import statements above the Tailwind config directives they follow. Only the
589
+ * statement prefix of the file is considered: the first rule that is neither an @import, a
590
+ * config directive, a comment nor @charset stops the scan, so real CSS never moves.
591
+ */
592
+ export function hoistCssImports(source: string) {
593
+ if (!/@(?:source|plugin|config)\b/.test(source)) return source
594
+
595
+ const imports: string[] = []
596
+ const others: string[] = []
597
+ let charset = ''
598
+ let sawDirective = false
599
+ let hoist = false
600
+ let index = 0
601
+
602
+ while (index < source.length) {
603
+ const code = source.charCodeAt(index)
604
+ // space, tab, LF, CR, FF — CSS whitespace, without a regex call per character.
605
+ if (code === 32 || code === 9 || code === 10 || code === 13 || code === 12) {
606
+ index += 1
607
+ continue
608
+ }
609
+ const char = source[index]!
610
+ if (char === '/' && source[index + 1] === '*') {
611
+ const end = source.indexOf('*/', index + 2)
612
+ if (end === -1) break
613
+ others.push(source.slice(index, end + 2))
614
+ index = end + 2
615
+ continue
616
+ }
617
+ const name = char === '@' ? /^@([\w-]+)/.exec(source.slice(index))?.[1] : undefined
618
+ if (!name) break
619
+ if (name !== 'import' && name !== 'charset' && !CSS_CONFIG_AT_RULES.has(name)) break
620
+ const end = statementEnd(source, index)
621
+ if (end === -1) break
622
+
623
+ const statement = source.slice(index, end)
624
+ if (name === 'charset') charset ||= statement
625
+ else if (name === 'import') {
626
+ imports.push(statement)
627
+ if (sawDirective) hoist = true
628
+ } else {
629
+ others.push(statement)
630
+ sawDirective = true
631
+ }
632
+ index = end
633
+ }
634
+
635
+ if (!hoist) return source
636
+ return `${[charset, ...imports, ...others].filter(Boolean).join('\n')}\n${source.slice(index)}`
637
+ }
638
+
639
+ /** End offset (past the `;`) of a block-less at-rule, or -1 if it isn't one. */
640
+ function statementEnd(source: string, start: number) {
641
+ for (let index = start; index < source.length; index += 1) {
642
+ const char = source[index]!
643
+ if (char === ';') return index + 1
644
+ if (char === '{' || char === '}') return -1
645
+ if (char === '"' || char === "'") {
646
+ index = skipString(source, index)
647
+ if (index === -1) return -1
648
+ }
649
+ }
650
+ return -1
651
+ }
652
+
653
+ /** Index of the closing quote of the string starting at `start`, or -1. */
654
+ function skipString(source: string, start: number) {
655
+ const quote = source[start]
656
+ for (let index = start + 1; index < source.length; index += 1) {
657
+ const char = source[index]
658
+ if (char === '\\') index += 1
659
+ else if (char === quote) return index
660
+ }
661
+ return -1
662
+ }
663
+
557
664
  function cssModuleBuildPlugin(): Plugin {
558
665
  return {
559
666
  name: 'pnext-css-module-build',
@@ -0,0 +1,128 @@
1
+ // Dev-only loud page for errors that originate inside pnext itself. App errors keep the normal
2
+ // error/global-error documents; a framework failure instead gets the full trace and a prefilled
3
+ // GitHub issue, because "This page couldn't load" hides exactly the information a bug report needs.
4
+
5
+ import { pnextVersionRange } from '../utils/fs'
6
+ import { escapeHtml } from '../utils/html'
7
+
8
+ const ISSUES_URL = 'https://github.com/muzam1l/pnext/issues/new'
9
+ // Keep the prefilled issue URL well under browser/GitHub limits.
10
+ const MAX_ISSUE_BODY = 5_000
11
+
12
+ const FRAME_PATH = /\(?((?:file:\/\/)?\/[^\s):]+)(?::\d+){0,2}\)?/
13
+
14
+ /**
15
+ * A located stack frame is "pnext" when it resolves inside pnext's own source (a workspace
16
+ * checkout's `pnext/src/` or an installed `@wular/pnext/`). The error classifies as internal when
17
+ * every located frame is pnext — one app or foreign-package frame means app code was on the throw
18
+ * path and the normal error documents apply.
19
+ */
20
+ export function isPnextInternalError(error: unknown): boolean {
21
+ if (!(error instanceof Error) || !error.stack) return false
22
+ let located = 0
23
+ for (const line of error.stack.split('\n').slice(1)) {
24
+ const file = FRAME_PATH.exec(line)?.[1]
25
+ if (!file || file.includes('native')) continue
26
+ located += 1
27
+ const pnextOwn =
28
+ file.includes('/@wular/pnext/') || /\/pnext\/(src|dist)\//.test(file.replace(/\\/g, '/'))
29
+ if (!pnextOwn) return false
30
+ }
31
+ return located > 0
32
+ }
33
+
34
+ export interface InternalErrorContext {
35
+ error: Error
36
+ route: string
37
+ url: string
38
+ digest?: string
39
+ }
40
+
41
+ function reportUrl(context: InternalErrorContext, version: string): string {
42
+ const title = `[dev] ${context.error.message.split('\n')[0]!.slice(0, 90)}`
43
+ // Home-relative paths: less noise, and the reporter's username stays out of the issue.
44
+ const stack = (context.error.stack ?? context.error.message)
45
+ .split('\n')
46
+ .map(line => {
47
+ const file = FRAME_PATH.exec(line)?.[1]
48
+ return file ? line.replace(file, shortPath(file)) : line
49
+ })
50
+ .join('\n')
51
+ const runtime = `${typeof Bun !== 'undefined' ? `bun ${Bun.version}` : `node ${process.version}`} · ${process.platform} ${process.arch}`
52
+ const body = [
53
+ `**pnext:** ${version} (dev)`,
54
+ `**Runtime:** ${runtime}`,
55
+ `**Route:** \`${context.route}\``,
56
+ context.digest ? `**Digest:** \`${context.digest}\`` : '',
57
+ '',
58
+ '**Error:**',
59
+ '```',
60
+ stack.slice(0, MAX_ISSUE_BODY),
61
+ '```',
62
+ '',
63
+ '<!-- What were you doing when this happened? Does it reproduce after a reload? -->',
64
+ ]
65
+ .filter(Boolean)
66
+ .join('\n')
67
+ return `${ISSUES_URL}?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
68
+ }
69
+
70
+ const css = `:root{--bg:#fafafa;--fg:#1a1a1a;--muted:#767676;--faint:#ababab;--accent:#c43030;--card:#f1f1f1;--border:rgba(0,0,0,.09);--btn-bg:#1a1a1a;--btn-fg:#fafafa}
71
+ @media (prefers-color-scheme:dark){:root{--bg:#111;--fg:#e6e6e6;--muted:#888;--faint:#5c5c5c;--accent:#f07878;--card:#1a1a1a;--border:rgba(255,255,255,.09);--btn-bg:#e6e6e6;--btn-fg:#111}}
72
+ *{box-sizing:border-box}
73
+ body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh;display:flex;align-items:center;justify-content:center}
74
+ main{width:min(640px,calc(100vw - 32px));background:var(--card);border:1px solid var(--border);border-radius:8px;padding:24px 26px;box-shadow:0 8px 32px rgba(0,0,0,.12);margin:24px 0}
75
+ .badge{display:inline-block;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-bottom:14px}
76
+ h1{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14.5px;font-weight:600;margin:0 0 8px;line-height:1.55;overflow-wrap:anywhere}
77
+ p.meta{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--muted);margin:0 0 18px;overflow-wrap:anywhere}
78
+ .trace{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:12px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;line-height:1.9;overflow:auto;max-height:280px}
79
+ .frame{white-space:nowrap}
80
+ .frame .fn{color:var(--fg)}
81
+ .frame .loc{color:var(--muted);margin-left:12px}
82
+ .frame.native{color:var(--faint)}
83
+ .frame.native .loc{color:var(--faint)}
84
+ .actions{display:flex;gap:8px;margin-top:18px;flex-wrap:wrap}
85
+ .actions a,.actions button{display:inline-flex;align-items:center;height:30px;padding:0 12px;font-family:inherit;font-size:12.5px;font-weight:500;border-radius:5px;cursor:pointer;text-decoration:none;border:1px solid var(--border);background:transparent;color:var(--fg)}
86
+ .actions .primary{background:var(--btn-bg);color:var(--btn-fg);border:1px solid transparent}
87
+ p.note{font-size:12px;color:var(--muted);margin:16px 0 0;line-height:1.6}`
88
+
89
+ // `at fn (path:line:col)` / `at path:line:col`; frames render as aligned fn + location rows.
90
+ const FRAME = /^\s*at\s+(?:(.+?)\s+\()?(.+?)(?::(\d+):(\d+))?\)?\s*$/
91
+
92
+ function shortPath(file: string): string {
93
+ const modules = file.lastIndexOf('node_modules/')
94
+ if (modules >= 0) return file.slice(modules + 'node_modules/'.length)
95
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
96
+ const home = process.env.HOME
97
+ return home && file.startsWith(home) ? `~${file.slice(home.length)}` : file
98
+ }
99
+
100
+ function traceHtml(stack: string): string {
101
+ const rows = stack
102
+ .split('\n')
103
+ .slice(1)
104
+ .map(line => {
105
+ const match = FRAME.exec(line)
106
+ if (!match) return `<div class="frame native">${escapeHtml(line.trim())}</div>`
107
+ const [, fn, file, lineNo, col] = match
108
+ const native = !file!.startsWith('/') && !file!.startsWith('file:')
109
+ const location = native ? file! : `${shortPath(file!)}${lineNo ? `:${lineNo}:${col}` : ''}`
110
+ return `<div class="frame${native ? ' native' : ''}"><span class="fn">${escapeHtml(fn ?? '<anonymous>')}</span><span class="loc">${escapeHtml(location)}</span></div>`
111
+ })
112
+ return rows.join('')
113
+ }
114
+
115
+ /** The full standalone document (own `<html>`), served like a global-error page. Dev only. */
116
+ export function internalErrorHtml(context: InternalErrorContext): string {
117
+ const version = pnextVersionRange().slice(1)
118
+ const rawStack = context.error.stack ?? context.error.message
119
+ const details = [
120
+ `pnext ${version}`,
121
+ context.route,
122
+ context.digest ? `digest ${context.digest}` : '',
123
+ context.url,
124
+ ]
125
+ .filter(Boolean)
126
+ .join(' · ')
127
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>pnext internal error</title><style>${css}</style></head><body><main><span class="badge">pnext internal error</span><h1>${escapeHtml(context.error.message.split('\n')[0]!)}</h1><p class="meta">${escapeHtml(details)}</p><div class="trace" data-raw="${escapeHtml(rawStack)}">${traceHtml(rawStack)}</div><div class="actions"><a class="primary" href="${escapeHtml(reportUrl(context, version))}" target="_blank" rel="noreferrer">Report on GitHub</a><button onclick="navigator.clipboard.writeText(document.querySelector('.trace').dataset.raw).then(()=>{this.textContent='Copied'})">Copy trace</button><form style="margin:0"><button type="submit">Reload</button></form></div><p class="note">This error came from inside pnext, not your application code. Reload usually recovers in dev; if it repeats, the GitHub report above is prefilled with everything we know.</p></main></body></html>`
128
+ }
@@ -14,6 +14,7 @@ import { mkdirSync, existsSync, readFileSync, statSync, writeFileSync } from 'no
14
14
  import path from 'node:path'
15
15
  import type { ResolvedConfig } from '../../config'
16
16
  import { setGlobalCssSourceStore, type GlobalCssSourceStore } from '../../css/build'
17
+ import { traceEnabled } from '../../utils/trace-flags'
17
18
  import { restartCacheEnabled } from './enabled'
18
19
 
19
20
  /** `[file, mtimeMs, size, contentHash]` — the shape the other restart caches use. */
@@ -212,9 +213,8 @@ function revalidate(files: SourceEntry[]): SourceEntry[] | undefined {
212
213
  return moved ? checked : files
213
214
  }
214
215
 
215
- /** Bisect seam: `PNEXT_DEV_GLOBAL_CSS_STATS=1` prints the hit/miss split at exit. */
216
+ /** Bisect seam: `PNEXT_TRACE=server` prints the hit/miss split at exit. */
216
217
  const stats = { hits: 0, misses: 0 }
217
- // eslint-disable-next-line turbo/no-undeclared-env-vars
218
- if (process.env.PNEXT_DEV_GLOBAL_CSS_STATS === '1') {
218
+ if (traceEnabled('server')) {
219
219
  process.on('exit', () => console.log(`global-css hits=${stats.hits} misses=${stats.misses}`))
220
220
  }
@@ -17,6 +17,7 @@ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
17
17
  import path from 'node:path'
18
18
  import type { ResolvedConfig } from '../../config'
19
19
  import { setRouteFactsStore, type RouteFacts, type RouteFactsStore } from '../../routing/routes'
20
+ import { traceEnabled } from '../../utils/trace-flags'
20
21
  import { restartCacheEnabled } from './enabled'
21
22
 
22
23
  /** `[file, mtimeMs, size, contentHash]` — the same record shape the key cache uses. */
@@ -215,9 +216,8 @@ function revalidate(files: SourceEntry[]): SourceEntry[] | undefined {
215
216
  return moved ? checked : files
216
217
  }
217
218
 
218
- /** Bisect seam: `PNEXT_DEV_FACTS_STATS=1` prints the hit/miss split at exit. */
219
+ /** Bisect seam: `PNEXT_TRACE=server` prints the hit/miss split at exit. */
219
220
  const stats = { hits: 0, misses: 0 }
220
- // eslint-disable-next-line turbo/no-undeclared-env-vars
221
- if (process.env.PNEXT_DEV_FACTS_STATS === '1') {
221
+ if (traceEnabled('server')) {
222
222
  process.on('exit', () => console.log(`route-facts hits=${stats.hits} misses=${stats.misses}`))
223
223
  }
package/src/dev/server.ts CHANGED
@@ -72,6 +72,7 @@ import {
72
72
  import { setRequestRuntime } from '../routing/request-environment'
73
73
  import { ensureDir, listFiles, writeText } from '../utils/fs'
74
74
  import { contentType } from '../utils/content-type'
75
+ import { traceEnabled } from '../utils/trace-flags'
75
76
  import {
76
77
  flushDevProfileLines,
77
78
  formatProfileDuration,
@@ -888,8 +889,7 @@ interface DevRequestProfile {
888
889
  }
889
890
 
890
891
  function devRequestProfile(request: Request, url: URL): DevRequestProfile | undefined {
891
- // eslint-disable-next-line turbo/no-undeclared-env-vars
892
- if (!process.env.PNEXT_DEV_PROFILE) return undefined
892
+ if (!traceEnabled('server')) return undefined
893
893
  return {
894
894
  label: `${request.method} ${url.pathname}`,
895
895
  start: performance.now(),