@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/dev.ts CHANGED
@@ -125,16 +125,11 @@ function watchEsbuildMemory() {
125
125
  ),
126
126
  )
127
127
  void (async () => {
128
- try {
129
- const esbuild = await import('esbuild')
130
- await esbuild.stop()
131
- } catch {
132
- // ignore: the service may already be gone
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
+ }
@@ -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
@@ -27,10 +27,45 @@ function track<T>(promise: Promise<T>): Promise<T> {
27
27
  })
28
28
  }
29
29
 
30
- export const build: typeof Esbuild.build = options => track(load().build(options))
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
- track(load().transform(input, options))
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