@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.
- package/package.json +1 -1
- package/reference/dev.md +17 -4
- package/src/api/dynamic.tsx +1 -0
- package/src/api/link.tsx +1 -0
- package/src/cli/analyze.ts +1 -1
- package/src/cli/boot/trace.ts +40 -7
- package/src/cli/build.ts +2 -2
- package/src/cli/dev.ts +5 -10
- package/src/cli/migrate/package-json.ts +1 -1
- package/src/cli/migrate/report.ts +5 -3
- package/src/cli/migrate/run.ts +30 -4
- package/src/cli/migrate/scan.ts +34 -7
- package/src/cli/typegen.ts +8 -1
- package/src/client/build.ts +4 -5
- package/src/client/profile.ts +4 -3
- package/src/client/router/runtime.ts +8 -0
- package/src/compat/actions/config.ts +2 -0
- package/src/compat/aliases.ts +2 -2
- package/src/compat/bundler/react-profiler.tsx +1 -0
- package/src/compat/cache-control.ts +49 -119
- package/src/compat/client/errors/global-error.ts +10 -5
- package/src/compat/client/errors/install.ts +30 -6
- package/src/compat/client/errors/lazy.ts +32 -2
- package/src/compat/next/client-script.tsx +1 -0
- package/src/compat/next/config-headers.ts +139 -0
- package/src/compat/next/config-loader.ts +32 -2
- package/src/compat/next/dynamic.tsx +1 -0
- package/src/compat/next/error.tsx +1 -0
- package/src/compat/next/form.tsx +1 -0
- package/src/compat/next/head.tsx +1 -0
- package/src/compat/next/headers.ts +12 -6
- package/src/compat/next/image/client.tsx +1 -0
- package/src/compat/next/image.tsx +1 -0
- package/src/compat/next/legacy-image.tsx +1 -0
- package/src/compat/next/legacy-request-apis.ts +62 -0
- package/src/compat/next/link.tsx +1 -0
- package/src/compat/next/rewrites.ts +7 -0
- package/src/compat/next/script.tsx +1 -0
- package/src/compat/register/bundler.ts +6 -1
- package/src/compat/segment/loading-boundary.ts +1 -5
- package/src/compat/validation/validate.ts +10 -2
- package/src/config.ts +12 -1
- package/src/css/build.ts +111 -4
- package/src/dev/internal-error.ts +128 -0
- package/src/dev/restart/global-css.ts +3 -3
- package/src/dev/restart/route-facts.ts +3 -3
- package/src/dev/server.ts +2 -2
- package/src/render/renderer.ts +20 -2
- package/src/render/slots.tsx +1 -0
- package/src/request/context.ts +15 -0
- package/src/runtime/loader.ts +2 -2
- package/src/runtime/module-cache.ts +12 -5
- package/src/runtime/modules.ts +44 -31
- package/src/runtime/vendor-build.ts +5 -7
- package/src/runtime/vendor.ts +29 -13
- package/src/types.ts +10 -1
- package/src/utils/code.ts +5 -0
- package/src/utils/dev-profile.ts +1 -1
- package/src/utils/esbuild.ts +37 -2
- 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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|
|
@@ -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
|
+
}
|
package/src/compat/next/link.tsx
CHANGED
|
@@ -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.
|
|
@@ -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)
|
|
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
|
-
|
|
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))
|
|
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
|
-
? {
|
|
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 (
|
|
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: [
|
|
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 (
|
|
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: `
|
|
216
|
+
/** Bisect seam: `PNEXT_TRACE=server` prints the hit/miss split at exit. */
|
|
216
217
|
const stats = { hits: 0, misses: 0 }
|
|
217
|
-
|
|
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: `
|
|
219
|
+
/** Bisect seam: `PNEXT_TRACE=server` prints the hit/miss split at exit. */
|
|
219
220
|
const stats = { hits: 0, misses: 0 }
|
|
220
|
-
|
|
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
|
-
|
|
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(),
|