@wular/pnext 0.0.22 → 0.0.24

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.22",
3
+ "version": "0.0.24",
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/create.ts CHANGED
@@ -27,11 +27,11 @@ export async function createApp(dir: string | undefined, options: CreateAppOptio
27
27
  const name = sanitizePackageName(path.basename(target))
28
28
  console.log(`${cyan('▲')} ${bold('pnext create')} ${dim(`— scaffolding ${name}`)}\n`)
29
29
 
30
- const files = scaffoldFiles(name, await latestPnextVersionRange('create'))
30
+ const files = await scaffoldFiles(name, await latestPnextVersionRange('create'))
31
31
  await Promise.all(
32
32
  Object.entries(files).map(([file, source]) => Bun.write(path.join(target, file), source)),
33
33
  )
34
- for (const file of Object.keys(files)) console.log(` ${green('+')} ${dim(file)}`)
34
+ for (const file of Object.keys(files).sort()) console.log(` ${green('+')} ${dim(file)}`)
35
35
 
36
36
  let installed = false
37
37
  if (options.install) {
@@ -112,43 +112,28 @@ function printNextSteps(dir: string, installed: boolean) {
112
112
  console.log(dim(`└${'─'.repeat(width + 2)}┘`))
113
113
  }
114
114
 
115
- function scaffoldFiles(name: string, pnextRange: string): Record<string, string> {
116
- return {
117
- 'package.json': `${JSON.stringify(
118
- {
119
- name,
120
- private: true,
121
- scripts: {
122
- dev: 'pnext dev',
123
- build: 'pnext build',
124
- start: 'pnext start',
125
- analyze: 'pnext analyze',
126
- },
127
- dependencies: { preact: '^10' },
128
- devDependencies: {
129
- '@wular/pnext': pnextRange,
130
- typescript: '^5',
131
- '@types/bun': '^1',
132
- },
133
- },
134
- null,
135
- 2,
136
- )}\n`,
137
- 'pnext.config.ts': `// See node_modules/@wular/pnext/reference/config.md for available options.\nexport default {};\n`,
138
- 'tsconfig.json': `${JSON.stringify(
139
- {
140
- extends: '@wular/pnext/config/ts/react.json',
141
- compilerOptions: { jsxImportSource: 'preact', paths: { '#gen/*': ['./.pnext/types/*'] } },
142
- include: ['**/*.ts', '**/*.tsx', '.pnext/types/**/*.ts'],
143
- },
144
- null,
145
- 2,
146
- )}\n`,
147
- 'app/layout.tsx': `import type { LayoutProps } from '@wular/pnext';\nimport './globals.css';\n\nexport const metadata = {\n title: '${name}',\n};\n\nexport default function RootLayout({ children }: LayoutProps) {\n return (\n <html>\n <body>{children}</body>\n </html>\n );\n}\n`,
148
- 'app/page.tsx': `import Counter from './counter';\n\nexport default async function Home() {\n return (\n <>\n <h1>Welcome to ${name}</h1>\n <p>Edit app/page.tsx to get started.</p>\n <Counter />\n </>\n );\n}\n`,
149
- 'app/counter.tsx': `'use client';\n\nimport { useState } from 'preact/hooks';\n\nexport default function Counter() {\n const [count, setCount] = useState(0);\n return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;\n}\n`,
150
- 'app/globals.css': `body {\n margin: 0;\n font-family: system-ui, sans-serif;\n}\n`,
151
- '.gitignore': `node_modules\n.pnext\n`,
152
- 'README.md': `# ${name}\n\nA pnext app.\n\n\`\`\`\nbun install\nbun dev\n\`\`\`\n\nDocs: node_modules/@wular/pnext/reference/getting-started.md\n`,
115
+ // The template is a real runnable app in src/cli/template; files are copied verbatim with the
116
+ // placeholder name swapped, and package.json gets the app name and pnext version stamped in.
117
+ const TEMPLATE_DIR = path.join(import.meta.dir, 'template')
118
+ const PLACEHOLDER_NAME = 'pnext-app'
119
+
120
+ async function scaffoldFiles(name: string, pnextRange: string): Promise<Record<string, string>> {
121
+ const files: Record<string, string> = {}
122
+ for (const entry of await readdir(TEMPLATE_DIR, { recursive: true, withFileTypes: true })) {
123
+ if (!entry.isFile()) continue
124
+ const rel = path.relative(TEMPLATE_DIR, path.join(entry.parentPath, entry.name))
125
+ const source = await Bun.file(path.join(TEMPLATE_DIR, rel)).text()
126
+ // npm strips .gitignore from published tarballs, so the template stores it unprefixed.
127
+ if (rel === 'gitignore') {
128
+ files['.gitignore'] = source
129
+ } else if (rel === 'package.json') {
130
+ const pkg = JSON.parse(source) as { name: string; devDependencies: Record<string, string> }
131
+ pkg.name = name
132
+ pkg.devDependencies['@wular/pnext'] = pnextRange
133
+ files[rel] = `${JSON.stringify(pkg, null, 2)}\n`
134
+ } else {
135
+ files[rel] = source.replaceAll(PLACEHOLDER_NAME, name)
136
+ }
153
137
  }
138
+ return files
154
139
  }
@@ -138,7 +138,7 @@ export function openBrowser(url: string) {
138
138
  // eslint-disable-next-line turbo/no-undeclared-env-vars
139
139
  const browser = process.env.BROWSER
140
140
  // eslint-disable-next-line turbo/no-undeclared-env-vars
141
- if (browser === 'none' || process.env.CI) return
141
+ if (browser === 'none' || process.env.CI || process.env.NODE_ENV === 'test') return
142
142
 
143
143
  // eslint-disable-next-line turbo/no-undeclared-env-vars
144
144
  const browserArgs = process.env.BROWSER_ARGS?.split(/\s+/).filter(Boolean) ?? []
@@ -0,0 +1,10 @@
1
+ # pnext-app
2
+
3
+ A pnext app.
4
+
5
+ ```
6
+ bun install
7
+ bun dev
8
+ ```
9
+
10
+ Docs: node_modules/@wular/pnext/reference/getting-started.md
@@ -0,0 +1,30 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'preact/hooks'
4
+
5
+ export default function ClientPage() {
6
+ const [text, setText] = useState('')
7
+ const words = text.trim() ? text.trim().split(/\s+/).length : 0
8
+ return (
9
+ <div class="page">
10
+ <span class="chip">client component</span>
11
+ <h1>Running in the browser</h1>
12
+ <input
13
+ class="field"
14
+ placeholder="Type something…"
15
+ value={text}
16
+ onInput={e => setText(e.currentTarget.value)}
17
+ />
18
+ <dl class="panel">
19
+ <div>
20
+ <dt>Characters</dt>
21
+ <dd>{text.length}</dd>
22
+ </div>
23
+ <div>
24
+ <dt>Words</dt>
25
+ <dd>{words}</dd>
26
+ </div>
27
+ </dl>
28
+ </div>
29
+ )
30
+ }
@@ -0,0 +1,322 @@
1
+ :root {
2
+ --bg: #ffffff;
3
+ --ink: #111111;
4
+ --muted: #6b6b6b;
5
+ --line: #e4e4e4;
6
+ --card: #f7f7f7;
7
+ --accent: #4f46e5;
8
+ }
9
+
10
+ @media (prefers-color-scheme: dark) {
11
+ :root {
12
+ --bg: #0a0a0a;
13
+ --ink: #ededed;
14
+ --muted: #8f8f8f;
15
+ --line: #262626;
16
+ --card: #141414;
17
+ --accent: #8583ff;
18
+ }
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ body {
26
+ margin: 0;
27
+ min-height: 100vh;
28
+ display: flex;
29
+ flex-direction: column;
30
+ background: var(--bg);
31
+ color: var(--ink);
32
+ font-family: system-ui, sans-serif;
33
+ line-height: 1.5;
34
+ overflow-x: hidden;
35
+ }
36
+
37
+ /* One quiet accent glow, top center. */
38
+ body::before {
39
+ content: '';
40
+ position: fixed;
41
+ z-index: -1;
42
+ top: -24rem;
43
+ left: 50%;
44
+ width: 64rem;
45
+ height: 48rem;
46
+ transform: translateX(-50%);
47
+ border-radius: 50%;
48
+ background: radial-gradient(
49
+ ellipse,
50
+ color-mix(in srgb, var(--accent) 10%, transparent),
51
+ transparent 70%
52
+ );
53
+ filter: blur(60px);
54
+ pointer-events: none;
55
+ }
56
+
57
+ .container {
58
+ width: min(72rem, 100% - 4rem);
59
+ margin: 0 auto;
60
+ }
61
+
62
+ .site-header {
63
+ border-bottom: 1px solid var(--line);
64
+ }
65
+
66
+ .bar {
67
+ display: flex;
68
+ align-items: center;
69
+ justify-content: space-between;
70
+ padding: 1rem 0;
71
+ }
72
+
73
+ .brand {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 0.5rem;
77
+ font-weight: 650;
78
+ letter-spacing: -0.01em;
79
+ text-decoration: none;
80
+ }
81
+
82
+ .mark {
83
+ color: var(--accent);
84
+ }
85
+
86
+ .site-header nav {
87
+ display: flex;
88
+ gap: 1.75rem;
89
+ }
90
+
91
+ .site-header nav a {
92
+ color: var(--muted);
93
+ text-decoration: none;
94
+ font-size: 0.92rem;
95
+ font-weight: 500;
96
+ transition: color 130ms ease;
97
+ }
98
+
99
+ .site-header nav a:hover {
100
+ color: var(--ink);
101
+ }
102
+
103
+ main {
104
+ flex: 1;
105
+ display: grid;
106
+ align-content: center;
107
+ padding: 3rem 0 5rem;
108
+ }
109
+
110
+ @keyframes rise {
111
+ from {
112
+ opacity: 0;
113
+ transform: translateY(10px);
114
+ }
115
+
116
+ to {
117
+ opacity: 1;
118
+ transform: translateY(0);
119
+ }
120
+ }
121
+
122
+ .hero {
123
+ display: grid;
124
+ grid-template-columns: 1.4fr 1fr;
125
+ align-items: center;
126
+ gap: 4rem;
127
+ }
128
+
129
+ .hero > * {
130
+ animation: rise 500ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
131
+ }
132
+
133
+ .hero > :last-child {
134
+ animation-delay: 120ms;
135
+ }
136
+
137
+ @media (max-width: 52rem) {
138
+ .hero {
139
+ grid-template-columns: 1fr;
140
+ gap: 2.5rem;
141
+ }
142
+ }
143
+
144
+ h1 {
145
+ margin: 0 0 1rem;
146
+ font-size: clamp(2.2rem, 4.5vw, 3.4rem);
147
+ font-weight: 700;
148
+ letter-spacing: -0.03em;
149
+ line-height: 1.08;
150
+ }
151
+
152
+ .lede {
153
+ margin: 0 0 2rem;
154
+ color: var(--muted);
155
+ font-size: 1.15rem;
156
+ max-width: 32rem;
157
+ }
158
+
159
+ a {
160
+ color: inherit;
161
+ }
162
+
163
+ .button-link,
164
+ .field {
165
+ font: inherit;
166
+ font-weight: 500;
167
+ padding: 0.55rem 1.3rem;
168
+ border-radius: 8px;
169
+ }
170
+
171
+ .button-link {
172
+ display: inline-block;
173
+ text-decoration: none;
174
+ background: var(--ink);
175
+ color: var(--bg);
176
+ border: 1px solid var(--ink);
177
+ transition:
178
+ background 150ms ease,
179
+ border-color 150ms ease;
180
+ }
181
+
182
+ .button-link:hover {
183
+ background: var(--accent);
184
+ border-color: var(--accent);
185
+ color: #ffffff;
186
+ }
187
+
188
+ .panel {
189
+ margin: 0;
190
+ background: var(--card);
191
+ border: 1px solid var(--line);
192
+ border-radius: 12px;
193
+ max-width: 26rem;
194
+ width: 100%;
195
+ overflow: hidden;
196
+ }
197
+
198
+ .panel > div,
199
+ .panel > a {
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: space-between;
203
+ gap: 1.5rem;
204
+ padding: 0.85rem 1.2rem;
205
+ }
206
+
207
+ .panel > * + * {
208
+ border-top: 1px solid var(--line);
209
+ }
210
+
211
+ .panel > a {
212
+ text-decoration: none;
213
+ transition: background 130ms ease;
214
+ }
215
+
216
+ .panel > a:hover {
217
+ background: color-mix(in srgb, var(--accent) 8%, var(--card));
218
+ }
219
+
220
+ .panel .path {
221
+ display: block;
222
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
223
+ font-size: 0.92rem;
224
+ }
225
+
226
+ .panel .desc {
227
+ display: block;
228
+ margin-top: 0.15rem;
229
+ color: var(--muted);
230
+ font-size: 0.85rem;
231
+ }
232
+
233
+ .panel .arrow {
234
+ color: var(--muted);
235
+ transition:
236
+ transform 130ms ease,
237
+ color 130ms ease;
238
+ }
239
+
240
+ .panel > a:hover .arrow {
241
+ color: var(--accent);
242
+ transform: translateX(3px);
243
+ }
244
+
245
+ .panel dt {
246
+ color: var(--muted);
247
+ font-size: 0.95rem;
248
+ }
249
+
250
+ .panel dd {
251
+ margin: 0;
252
+ font-size: 0.92rem;
253
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
254
+ font-variant-numeric: tabular-nums;
255
+ }
256
+
257
+ .page {
258
+ max-width: 36rem;
259
+ animation: rise 500ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
260
+ }
261
+
262
+ .chip {
263
+ display: inline-block;
264
+ margin-bottom: 1.1rem;
265
+ padding: 0.18rem 0.7rem;
266
+ border-radius: 999px;
267
+ border: 1px solid var(--line);
268
+ background: var(--card);
269
+ color: var(--muted);
270
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
271
+ font-size: 0.72rem;
272
+ text-transform: uppercase;
273
+ letter-spacing: 0.12em;
274
+ font-weight: 600;
275
+ }
276
+
277
+ .page h1 {
278
+ margin-bottom: 1.75rem;
279
+ }
280
+
281
+ .page .panel {
282
+ margin-top: 1.25rem;
283
+ }
284
+
285
+ .field {
286
+ display: block;
287
+ width: 100%;
288
+ max-width: 24rem;
289
+ background: var(--card);
290
+ color: var(--ink);
291
+ border: 1px solid var(--line);
292
+ }
293
+
294
+ .field:focus {
295
+ border-color: var(--accent);
296
+ outline: none;
297
+ }
298
+
299
+ .field::placeholder {
300
+ color: var(--muted);
301
+ }
302
+
303
+ .hint {
304
+ margin: 0.9rem 0 0;
305
+ min-height: 1.5em;
306
+ color: var(--muted);
307
+ font-size: 0.95rem;
308
+ font-variant-numeric: tabular-nums;
309
+ }
310
+
311
+ a:focus-visible {
312
+ outline: 2px solid var(--accent);
313
+ outline-offset: 3px;
314
+ }
315
+
316
+ @media (prefers-reduced-motion: reduce) {
317
+ *,
318
+ *::before {
319
+ animation: none !important;
320
+ transition: none !important;
321
+ }
322
+ }
@@ -0,0 +1,33 @@
1
+ import type { LayoutProps } from '@wular/pnext'
2
+ import './globals.css'
3
+
4
+ export const metadata = {
5
+ title: 'pnext-app',
6
+ }
7
+
8
+ export default function RootLayout({ children }: LayoutProps) {
9
+ return (
10
+ <html>
11
+ <body>
12
+ <header class="site-header">
13
+ <div class="container bar">
14
+ <a class="brand" href="/">
15
+ <span class="mark">▲</span> pnext-app
16
+ </a>
17
+ <nav>
18
+ <a href="/server">Server component</a>
19
+ <a href="/client">Client component</a>
20
+ <a href="https://www.pnext.dev/docs" target="_blank" rel="noopener">
21
+ Docs
22
+ </a>
23
+ <a href="https://github.com/muzam1l/pnext" target="_blank" rel="noopener">
24
+ GitHub
25
+ </a>
26
+ </nav>
27
+ </div>
28
+ </header>
29
+ <main class="container">{children}</main>
30
+ </body>
31
+ </html>
32
+ )
33
+ }
@@ -0,0 +1,29 @@
1
+ export default async function Home() {
2
+ return (
3
+ <div class="hero">
4
+ <div>
5
+ <h1>Hello, world.</h1>
6
+ <p class="lede">Your app is up and running.</p>
7
+ <a class="button-link" href="https://www.pnext.dev/docs" target="_blank" rel="noopener">
8
+ Read the docs
9
+ </a>
10
+ </div>
11
+ <nav class="panel">
12
+ <a href="/server">
13
+ <span>
14
+ <span class="path">/server</span>
15
+ <span class="desc">Server component, rendered per request</span>
16
+ </span>
17
+ <span class="arrow">→</span>
18
+ </a>
19
+ <a href="/client">
20
+ <span>
21
+ <span class="path">/client</span>
22
+ <span class="desc">Client component, interactive in the browser</span>
23
+ </span>
24
+ <span class="arrow">→</span>
25
+ </a>
26
+ </nav>
27
+ </div>
28
+ )
29
+ }
@@ -0,0 +1,27 @@
1
+ export const metadata = {
2
+ title: 'Server component',
3
+ }
4
+
5
+ export default async function ServerPage() {
6
+ const renderedAt = new Date().toLocaleTimeString('en-US', { hour12: false })
7
+ return (
8
+ <div class="page">
9
+ <span class="chip">server component</span>
10
+ <h1>Rendered on the server</h1>
11
+ <dl class="panel">
12
+ <div>
13
+ <dt>Rendered at</dt>
14
+ <dd>{renderedAt}</dd>
15
+ </div>
16
+ <div>
17
+ <dt>Runtime</dt>
18
+ <dd>Bun {Bun.version}</dd>
19
+ </div>
20
+ <div>
21
+ <dt>Client JS</dt>
22
+ <dd>none</dd>
23
+ </div>
24
+ </dl>
25
+ </div>
26
+ )
27
+ }
@@ -0,0 +1,2 @@
1
+ node_modules
2
+ .pnext
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "pnext-app",
3
+ "private": true,
4
+ "scripts": {
5
+ "dev": "pnext dev",
6
+ "build": "pnext build",
7
+ "start": "pnext start",
8
+ "analyze": "pnext analyze"
9
+ },
10
+ "dependencies": {
11
+ "preact": "^10"
12
+ },
13
+ "devDependencies": {
14
+ "@wular/pnext": "latest",
15
+ "typescript": "^5",
16
+ "@types/bun": "^1"
17
+ }
18
+ }
@@ -0,0 +1,2 @@
1
+ // See node_modules/@wular/pnext/reference/config.md for available options.
2
+ export default {}
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "@wular/pnext/config/ts/react.json",
3
+ "compilerOptions": {
4
+ "jsxImportSource": "preact",
5
+ "paths": {
6
+ "#gen/*": ["./.pnext/types/*"]
7
+ }
8
+ },
9
+ "include": ["**/*.ts", "**/*.tsx", ".pnext/types/**/*.ts"]
10
+ }
package/src/dev/server.ts CHANGED
@@ -32,7 +32,12 @@ import {
32
32
  serverBundleTargetForRuntime,
33
33
  } from '../runtime/loader'
34
34
  import { applyProxyResponse, createProxyRunner, validateProxyFiles } from '../routing/proxy'
35
- import { errorLogTrace, renderGlobalNotFoundResponse, renderPageResponse } from '../render/renderer'
35
+ import {
36
+ errorLogTrace,
37
+ renderGlobalNotFoundResponse,
38
+ renderPageResponse,
39
+ withDevReloadScript,
40
+ } from '../render/renderer'
36
41
  import { markErrorLogged } from '../utils/error-log'
37
42
  import { escapeHtml } from '../utils/html'
38
43
  import { internalErrorHtml } from './internal-error'
@@ -792,10 +797,15 @@ function devLastResortResponse(error: unknown, request: Request): Response {
792
797
  } catch {
793
798
  // A malformed URL is exactly the kind of request that gets here; keep the raw text.
794
799
  }
795
- return devLastResortError(error, route, request.url)
800
+ return devLastResortError(error, route, request.url, request)
796
801
  }
797
802
 
798
- function devLastResortError(error: unknown, route = '(unknown)', url = route): Response {
803
+ export function devLastResortError(
804
+ error: unknown,
805
+ route = '(unknown)',
806
+ url = route,
807
+ request?: Request,
808
+ ): Response {
799
809
  const resolved = error instanceof Error ? error : new Error(String(error))
800
810
  if (markErrorLogged(resolved)) console.error(errorLogTrace(resolved))
801
811
  let html: string
@@ -809,7 +819,7 @@ function devLastResortError(error: unknown, route = '(unknown)', url = route): R
809
819
  } catch {
810
820
  html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>pnext error</title></head><body><h1>pnext could not render this request</h1><pre>${escapeHtml(resolved.stack ?? resolved.message)}</pre></body></html>`
811
821
  }
812
- return new Response(html, {
822
+ return new Response(withDevReloadScript(html, request), {
813
823
  status: 500,
814
824
  headers: { 'content-type': 'text/html; charset=utf-8' },
815
825
  })
@@ -3060,6 +3060,7 @@ async function pageRender(options: RenderOptions, stream: boolean): Promise<Page
3060
3060
  url: String(options.url),
3061
3061
  digest: (resolved as Error & { digest?: string }).digest,
3062
3062
  }),
3063
+ options,
3063
3064
  )
3064
3065
  }
3065
3066
  }
@@ -3161,6 +3162,7 @@ async function renderGlobalErrorPage(
3161
3162
  withGlobalErrorReportScript(`<!DOCTYPE html>${rendered}`, errorProps),
3162
3163
  options.config,
3163
3164
  ),
3165
+ options,
3164
3166
  )
3165
3167
  }
3166
3168
  try {
@@ -3190,6 +3192,7 @@ async function renderGlobalErrorPage(
3190
3192
  withGlobalErrorReportScript(`<!DOCTYPE html>${rendered}`, errorProps),
3191
3193
  options.config,
3192
3194
  ),
3195
+ options,
3193
3196
  )
3194
3197
  } catch {
3195
3198
  const defaultUi = nextCompatEnabled(options.config)
@@ -3202,6 +3205,7 @@ async function renderGlobalErrorPage(
3202
3205
  withGlobalErrorReportScript(`<!DOCTYPE html>${rendered}`, errorProps),
3203
3206
  options.config,
3204
3207
  ),
3208
+ options,
3205
3209
  )
3206
3210
  }
3207
3211
  }
@@ -3216,7 +3220,19 @@ async function renderGlobalErrorPage(
3216
3220
  function withGlobalErrorReportScript(html: string, error: SerializedError): string {
3217
3221
  const script = getRenderExtensions().globalErrorReportScript(error)
3218
3222
  if (!script) return html
3219
- return html.includes('</body>') ? html.replace('</body>', `${script}</body>`) : html + script
3223
+ return appendToDocumentBody(html, script)
3224
+ }
3225
+
3226
+ function appendToDocumentBody(html: string, content: string): string {
3227
+ const closingBodies = [...html.matchAll(/<\/body\s*>/gi)]
3228
+ const closingBody = closingBodies.at(-1)?.index
3229
+ if (closingBody === undefined) return html + content
3230
+ return html.slice(0, closingBody) + content + html.slice(closingBody)
3231
+ }
3232
+
3233
+ /** Keep a standalone development document connected to the dev server's reload stream. */
3234
+ export function withDevReloadScript(html: string, request?: Request): string {
3235
+ return appendToDocumentBody(html, devReloadScript(documentNonce(request)))
3220
3236
  }
3221
3237
 
3222
3238
  function withGlobalErrorStylesheet(html: string, config: ResolvedConfig): string {
@@ -3227,13 +3243,16 @@ function withGlobalErrorStylesheet(html: string, config: ResolvedConfig): string
3227
3243
  return html.replace(/<html(?:\s[^>]*)?>/i, tag => `${tag}<head>${link}</head>`)
3228
3244
  }
3229
3245
 
3230
- // A pre-rendered document (its own <html>/<body>) served verbatim. The document
3231
- // shell is skipped entirely: renderGlobalDocument wraps the markup, not
3232
- // renderDocumentStart/End.
3233
- function globalErrorPage(html: string): PageRender {
3246
+ // A pre-rendered document (its own <html>/<body>). The ordinary document shell
3247
+ // is skipped entirely, so development adds its reload client directly.
3248
+ function globalErrorPage(
3249
+ html: string,
3250
+ options: Pick<RenderOptions, 'dev' | 'request'>,
3251
+ ): PageRender {
3252
+ const body = options.dev ? withDevReloadScript(html, options.request) : html
3234
3253
  return {
3235
3254
  shell: 'global-error',
3236
- body: html,
3255
+ body,
3237
3256
  htmlProps: {},
3238
3257
  bodyProps: {},
3239
3258
  head: '',
@@ -4535,15 +4554,17 @@ async function renderTreeOrFallback(
4535
4554
  try {
4536
4555
  return await renderTree(options, tree, stream, metadata, extra)
4537
4556
  } catch {
4538
- return generatedFallbackPage(tree, metadata, extra.status ?? 500)
4557
+ return generatedFallbackPage(options, tree, metadata, extra.status ?? 500)
4539
4558
  }
4540
4559
  }
4541
4560
 
4542
4561
  function generatedFallbackPage(
4562
+ options: RenderOptions,
4543
4563
  tree: ComponentChildren,
4544
4564
  metadata: Metadata,
4545
4565
  status: number,
4546
4566
  ): PageRender {
4567
+ const nonce = documentNonce(options.request)
4547
4568
  return {
4548
4569
  shell: 'generated',
4549
4570
  body: serializeNeutralClientIslands(renderToString(h(Fragment, null, tree))),
@@ -4557,11 +4578,12 @@ function generatedFallbackPage(
4557
4578
  propsScript: '',
4558
4579
  routeScript: '',
4559
4580
  clientScript: '',
4560
- devScript: '',
4581
+ devScript: options.dev ? devReloadScript() : '',
4561
4582
  streamChunks: [],
4562
4583
  status,
4563
4584
  viewport: {},
4564
4585
  fontCss: '',
4586
+ ...(nonce ? { nonce } : {}),
4565
4587
  }
4566
4588
  }
4567
4589
 
@@ -7679,12 +7701,13 @@ function componentChildren(value: ServerComponentResult): ComponentChildren {
7679
7701
 
7680
7702
  // data-pnext-dev tells the soft-navigation runtime to skip this script on
7681
7703
  // body swaps — the current document already holds an open event stream.
7682
- function devReloadScript() {
7704
+ function devReloadScript(nonce?: string) {
7683
7705
  // css-update swaps every same-origin stylesheet for a cache-busted copy and
7684
7706
  // drops the old one once the new one has loaded, so the page never flashes
7685
7707
  // unstyled and never loses client state. Anything the server could not prove
7686
7708
  // was CSS-only still arrives as `reload`.
7687
- return `<script data-pnext-dev>
7709
+ const nonceAttribute = nonce ? ` nonce="${escapeHtml(nonce)}"` : ''
7710
+ return `<script${nonceAttribute} data-pnext-dev>
7688
7711
  let events;
7689
7712
  let generation;
7690
7713
  // The stream is a long-lived connection counted against the browser's per-origin
package/tsconfig.json CHANGED
@@ -6,5 +6,5 @@
6
6
  "allowImportingTsExtensions": true
7
7
  },
8
8
  "include": ["."],
9
- "exclude": ["node_modules", ".pnext", "bench/fixtures"]
9
+ "exclude": ["node_modules", ".pnext", "bench/fixtures", "src/cli/template"]
10
10
  }