@wular/pnext 0.0.9 → 0.0.10
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/src/cli/dev.ts +5 -10
- package/src/dev/internal-error.ts +128 -0
- package/src/render/renderer.ts +18 -0
- package/src/utils/esbuild.ts +37 -2
package/package.json
CHANGED
package/src/cli/dev.ts
CHANGED
|
@@ -125,16 +125,11 @@ function watchEsbuildMemory() {
|
|
|
125
125
|
),
|
|
126
126
|
)
|
|
127
127
|
void (async () => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
// stop() can strand in-flight build promises unsettled; fail their
|
|
135
|
-
// waiters so requests retry against the respawned service.
|
|
136
|
-
const { failInFlightClientBuilds } = await import('../dev/server')
|
|
137
|
-
failInFlightClientBuilds('esbuild service restarted by the memory watchdog')
|
|
128
|
+
// The idle-guarded stop: a raw esbuild.stop() under an in-flight compile
|
|
129
|
+
// fails that request with a 500, so a busy service drains first. In-flight
|
|
130
|
+
// builds are untouched, so their waiters must not be failed here.
|
|
131
|
+
const { stopEsbuildService } = await import('../utils/esbuild')
|
|
132
|
+
stopEsbuildService()
|
|
138
133
|
})()
|
|
139
134
|
}, 15_000)
|
|
140
135
|
timer.unref()
|
|
@@ -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
|
+
}
|
package/src/render/renderer.ts
CHANGED
|
@@ -168,6 +168,7 @@ import {
|
|
|
168
168
|
type RouteParamsSnapshot,
|
|
169
169
|
} from './island-context'
|
|
170
170
|
import { escapeHtml } from '../utils/html'
|
|
171
|
+
import { internalErrorHtml, isPnextInternalError } from '../dev/internal-error'
|
|
171
172
|
import type {
|
|
172
173
|
Metadata,
|
|
173
174
|
MetadataLink,
|
|
@@ -2992,6 +2993,23 @@ async function pageRender(options: RenderOptions, stream: boolean): Promise<Page
|
|
|
2992
2993
|
},
|
|
2993
2994
|
)
|
|
2994
2995
|
}
|
|
2996
|
+
// An error thrown entirely inside pnext's own frames is a framework bug the app cannot fix:
|
|
2997
|
+
// in dev it gets the loud internal-error document (full trace + prefilled GitHub report)
|
|
2998
|
+
// instead of hiding behind the app's error boundaries or the generic global-error page.
|
|
2999
|
+
if (options.dev) {
|
|
3000
|
+
const resolved = resolveThrownError(error, nextCompatEnabled(options.config))
|
|
3001
|
+
if (isPnextInternalError(resolved)) {
|
|
3002
|
+
logDevError(options, resolved)
|
|
3003
|
+
return globalErrorPage(
|
|
3004
|
+
internalErrorHtml({
|
|
3005
|
+
error: resolved,
|
|
3006
|
+
route: options.route.route,
|
|
3007
|
+
url: String(options.url),
|
|
3008
|
+
digest: (resolved as Error & { digest?: string }).digest,
|
|
3009
|
+
}),
|
|
3010
|
+
)
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
2995
3013
|
// A nearest error.* boundary catches first (rendered inside the document).
|
|
2996
3014
|
// Only when no error boundary exists does global-error.* take over the whole
|
|
2997
3015
|
// document (it supplies its own <html>/<body>); otherwise the normal error
|
package/src/utils/esbuild.ts
CHANGED
|
@@ -27,10 +27,45 @@ function track<T>(promise: Promise<T>): Promise<T> {
|
|
|
27
27
|
})
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// A service killed out from under an in-flight call (memory watchdog, crash) fails it with this
|
|
31
|
+
// exact message; the next call respawns the service, so one retry turns a user-visible 500 into
|
|
32
|
+
// a slightly slower response.
|
|
33
|
+
// "no longer running": issued against a dead cached service. "was stopped": in flight when a
|
|
34
|
+
// stop() (watchdog or the reset below) tore the service down.
|
|
35
|
+
const serviceDied = (error: unknown) =>
|
|
36
|
+
error instanceof Error &&
|
|
37
|
+
(error.message.includes('The service is no longer running') ||
|
|
38
|
+
error.message.includes('The service was stopped'))
|
|
39
|
+
|
|
40
|
+
// esbuild caches the dead service until stop() resets it. The reset is epoch-guarded: every call
|
|
41
|
+
// that raced the same dead service shares one stop(), because a second stop() would kill the fresh
|
|
42
|
+
// child under the retries already running against it.
|
|
43
|
+
let resetEpoch = 0
|
|
44
|
+
let serviceReset: Promise<void> | undefined
|
|
45
|
+
|
|
46
|
+
async function withServiceRetry<T>(run: () => Promise<T>): Promise<T> {
|
|
47
|
+
const epoch = resetEpoch
|
|
48
|
+
try {
|
|
49
|
+
return await track(run())
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (!serviceDied(error)) throw error
|
|
52
|
+
if (epoch === resetEpoch) {
|
|
53
|
+
serviceReset ??= Promise.resolve(esbuild?.stop())
|
|
54
|
+
.catch(() => undefined)
|
|
55
|
+
.then(() => {
|
|
56
|
+
resetEpoch += 1
|
|
57
|
+
serviceReset = undefined
|
|
58
|
+
})
|
|
59
|
+
await serviceReset
|
|
60
|
+
}
|
|
61
|
+
return track(run())
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const build: typeof Esbuild.build = options => withServiceRetry(() => load().build(options))
|
|
31
66
|
|
|
32
67
|
export const transform: typeof Esbuild.transform = (input, options) =>
|
|
33
|
-
|
|
68
|
+
withServiceRetry(() => load().transform(input, options))
|
|
34
69
|
|
|
35
70
|
/**
|
|
36
71
|
* Spawn the resident service child ahead of the first real compile. Fire-and-forget: the spawn and
|