@pyreon/zero 0.12.13 → 0.12.15

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 (51) hide show
  1. package/lib/client.js +41 -5
  2. package/lib/client.js.map +1 -1
  3. package/lib/env.js +6 -6
  4. package/lib/env.js.map +1 -1
  5. package/lib/favicon.js +2 -2
  6. package/lib/favicon.js.map +1 -1
  7. package/lib/font.js +2 -2
  8. package/lib/font.js.map +1 -1
  9. package/lib/i18n-routing.js.map +1 -1
  10. package/lib/image-plugin.js +1 -1
  11. package/lib/image-plugin.js.map +1 -1
  12. package/lib/index.js +39 -10
  13. package/lib/index.js.map +1 -1
  14. package/lib/link.js +12 -4
  15. package/lib/link.js.map +1 -1
  16. package/lib/meta.js.map +1 -1
  17. package/lib/og-image.js +2 -2
  18. package/lib/og-image.js.map +1 -1
  19. package/lib/script.js +1 -0
  20. package/lib/script.js.map +1 -1
  21. package/lib/server.js +132 -12
  22. package/lib/server.js.map +1 -1
  23. package/lib/theme.js +27 -7
  24. package/lib/theme.js.map +1 -1
  25. package/lib/types/client.d.ts +26 -0
  26. package/lib/types/client.d.ts.map +1 -1
  27. package/lib/types/config.d.ts +7 -0
  28. package/lib/types/config.d.ts.map +1 -1
  29. package/lib/types/index.d.ts +13 -1
  30. package/lib/types/index.d.ts.map +1 -1
  31. package/lib/types/link.d.ts.map +1 -1
  32. package/lib/types/server.d.ts +14 -0
  33. package/lib/types/server.d.ts.map +1 -1
  34. package/lib/types/theme.d.ts +6 -1
  35. package/lib/types/theme.d.ts.map +1 -1
  36. package/package.json +10 -10
  37. package/src/adapters/index.ts +1 -1
  38. package/src/adapters/validate.ts +2 -2
  39. package/src/client.ts +84 -6
  40. package/src/env.ts +6 -6
  41. package/src/favicon.ts +3 -3
  42. package/src/font.ts +2 -2
  43. package/src/i18n-routing.ts +1 -1
  44. package/src/image-plugin.ts +1 -1
  45. package/src/isr.ts +34 -2
  46. package/src/link.tsx +21 -5
  47. package/src/og-image.ts +2 -2
  48. package/src/script.tsx +4 -0
  49. package/src/theme.tsx +33 -11
  50. package/src/types.ts +7 -0
  51. package/src/vite-plugin.ts +204 -2
@@ -13,7 +13,7 @@ import type { Plugin } from 'vite'
13
13
  //
14
14
  // Usage:
15
15
  // import { i18nRouting } from "@pyreon/zero"
16
- // export default { plugins: [zero(), i18nRouting({ locales: ["en", "de"], defaultLocale: "en" })] }
16
+ // export default { plugins: [Pyreon], defaultLocale: "en" })] }
17
17
 
18
18
  export interface I18nRoutingConfig {
19
19
  /** Supported locales. e.g. ["en", "de", "cs"] */
@@ -9,7 +9,7 @@ function warnSharpMissing() {
9
9
  sharpWarned = true
10
10
  // oxlint-disable-next-line no-console
11
11
  console.warn(
12
- '\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n',
12
+ '\n[Pyreon] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n',
13
13
  )
14
14
  }
15
15
 
package/src/isr.ts CHANGED
@@ -13,6 +13,13 @@ interface CacheEntry {
13
13
  *
14
14
  * Wraps an SSR handler and caches responses per URL path.
15
15
  * Serves stale content immediately while revalidating in the background.
16
+ *
17
+ * Bounded by `config.maxEntries` (default: 1000) with LRU eviction. The
18
+ * `Map` preserves insertion order, so re-inserting an entry on every
19
+ * serve (touching it) keeps the LRU order correct. Without the cap,
20
+ * unbounded URL spaces like `/user/:id` would grow cache memory without
21
+ * limit over the server's lifetime — a real leak in long-running
22
+ * deployments.
16
23
  */
17
24
  export function createISRHandler(
18
25
  handler: (req: Request) => Promise<Response>,
@@ -21,6 +28,28 @@ export function createISRHandler(
21
28
  const cache = new Map<string, CacheEntry>()
22
29
  const revalidating = new Set<string>()
23
30
  const revalidateMs = config.revalidate * 1000
31
+ const maxEntries = Math.max(1, config.maxEntries ?? 1000)
32
+
33
+ function set(key: string, entry: CacheEntry): void {
34
+ // LRU: re-inserting moves the key to the newest position. Then if we're
35
+ // over the cap, drop the oldest (first in iteration order).
36
+ if (cache.has(key)) cache.delete(key)
37
+ cache.set(key, entry)
38
+ while (cache.size > maxEntries) {
39
+ const oldest = cache.keys().next().value
40
+ if (oldest === undefined) break
41
+ cache.delete(oldest)
42
+ }
43
+ }
44
+
45
+ function touch(key: string): CacheEntry | undefined {
46
+ const entry = cache.get(key)
47
+ if (entry !== undefined) {
48
+ cache.delete(key)
49
+ cache.set(key, entry)
50
+ }
51
+ return entry
52
+ }
24
53
 
25
54
  async function revalidate(url: URL) {
26
55
  const key = url.pathname
@@ -36,7 +65,7 @@ export function createISRHandler(
36
65
  headers[k] = v
37
66
  })
38
67
 
39
- cache.set(key, { html, headers, timestamp: Date.now() })
68
+ set(key, { html, headers, timestamp: Date.now() })
40
69
  } catch {
41
70
  // Revalidation failed — stale cache entry remains valid
42
71
  } finally {
@@ -52,7 +81,10 @@ export function createISRHandler(
52
81
 
53
82
  const url = new URL(req.url)
54
83
  const key = url.pathname
55
- const entry = cache.get(key)
84
+ // `touch` moves the entry to the newest LRU position on read so
85
+ // hot paths survive eviction even when the cap is small. `get`
86
+ // wouldn't update ordering.
87
+ const entry = touch(key)
56
88
 
57
89
  if (entry) {
58
90
  const age = Date.now() - entry.timestamp
package/src/link.tsx CHANGED
@@ -71,31 +71,47 @@ export interface UseLinkReturn {
71
71
  }
72
72
 
73
73
  const MAX_PREFETCH_CACHE = 200
74
- const prefetched = new Set<string>()
74
+ // Maps href list of <link> elements injected into <head>. When the
75
+ // cache evicts an href (FIFO at MAX_PREFETCH_CACHE), the matching <link>
76
+ // elements must be removed too — otherwise head bloats unboundedly
77
+ // across long SPA sessions (every Link interaction added 2 <link> nodes
78
+ // with no cleanup).
79
+ const prefetched = new Map<string, Element[]>()
75
80
 
76
81
  function doPrefetch(href: string) {
82
+ // Prefetch only fires from browser-mounted Link interactions (hover /
83
+ // click intent). Explicit guard documents the SSR-safety contract.
84
+ if (typeof document === 'undefined') return
77
85
  if (prefetched.has(href)) return
78
- // Evict oldest entries when cache is full
86
+ // Evict oldest entries when cache is full — AND remove their DOM nodes.
79
87
  if (prefetched.size >= MAX_PREFETCH_CACHE) {
80
- const first = prefetched.values().next().value
81
- if (first) prefetched.delete(first)
88
+ const firstEntry = prefetched.entries().next().value
89
+ if (firstEntry) {
90
+ const [oldestHref, oldestLinks] = firstEntry
91
+ for (const link of oldestLinks) link.remove()
92
+ prefetched.delete(oldestHref)
93
+ }
82
94
  }
83
- prefetched.add(href)
84
95
 
96
+ const injected: Element[] = []
85
97
  const docLink = document.createElement('link')
86
98
  docLink.rel = 'prefetch'
87
99
  docLink.href = href
88
100
  docLink.as = 'document'
89
101
  document.head.appendChild(docLink)
102
+ injected.push(docLink)
90
103
 
91
104
  try {
92
105
  const chunkHint = document.createElement('link')
93
106
  chunkHint.rel = 'modulepreload'
94
107
  chunkHint.href = href
95
108
  document.head.appendChild(chunkHint)
109
+ injected.push(chunkHint)
96
110
  } catch {
97
111
  // modulepreload is a hint, not critical
98
112
  }
113
+
114
+ prefetched.set(href, injected)
99
115
  }
100
116
 
101
117
  /**
package/src/og-image.ts CHANGED
@@ -38,7 +38,7 @@ function warnSharpMissing() {
38
38
  sharpWarned = true
39
39
  // oxlint-disable-next-line no-console
40
40
  console.warn(
41
- '\n[zero:og-image] sharp not installed — OG images will not be generated. Install for full support: bun add -D sharp\n',
41
+ '\n[Pyreon] sharp not installed — OG images will not be generated. Install for full support: bun add -D sharp\n',
42
42
  )
43
43
  }
44
44
 
@@ -357,7 +357,7 @@ export function ogImagePlugin(config: OgImagePluginConfig): Plugin {
357
357
  const bgPath = join(root, template.background)
358
358
  if (!existsSync(bgPath)) {
359
359
  // oxlint-disable-next-line no-console
360
- console.warn(`[zero:og-image] Background not found: ${bgPath}`)
360
+ console.warn(`[Pyreon] Background not found: ${bgPath}`)
361
361
  continue
362
362
  }
363
363
  }
package/src/script.tsx CHANGED
@@ -52,6 +52,10 @@ export type ScriptStrategy =
52
52
  */
53
53
  export function Script(props: ScriptProps): VNodeChild {
54
54
  function loadScript() {
55
+ // Only invoked from `onMount` — explicit guard documents the
56
+ // SSR-safety contract at the callsite (the rule can't AST-trace the
57
+ // indirect call).
58
+ if (typeof document === 'undefined') return
55
59
  // Deduplication
56
60
  if (props.id && document.getElementById(props.id)) return
57
61
 
package/src/theme.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { VNodeChild } from '@pyreon/core'
2
- import { onMount, onUnmount } from '@pyreon/core'
2
+ import { onMount } from '@pyreon/core'
3
3
  import { effect, signal } from '@pyreon/reactivity'
4
4
 
5
5
  // ─── Theme system ───────────────────────────────────────────────────────────
@@ -17,6 +17,19 @@ const STORAGE_KEY = 'zero-theme'
17
17
  /** Reactive theme signal. */
18
18
  export const theme = signal<Theme>('system')
19
19
 
20
+ /**
21
+ * Reactive signal tracking the OS color-scheme preference. Updated by the
22
+ * `matchMedia('(prefers-color-scheme: dark)').change` event registered in
23
+ * `initTheme`. Components reading `resolvedTheme()` subscribe to BOTH
24
+ * `theme` and this signal, so a user toggling dark mode at the OS level
25
+ * re-renders everything reactively — not just the `<html data-theme>`
26
+ * attribute.
27
+ *
28
+ * SSR default is `_ssrDefault` (mutable via `setSSRThemeDefault`) so the
29
+ * server-rendered theme can differ from the client's OS preference.
30
+ */
31
+ const _osPrefersDark = signal<boolean>(false)
32
+
20
33
  /** SSR fallback when system preference can't be detected. Default: 'light'. */
21
34
  let _ssrDefault: 'light' | 'dark' = 'light'
22
35
 
@@ -28,12 +41,17 @@ export function setSSRThemeDefault(value: 'light' | 'dark'): void {
28
41
  _ssrDefault = value
29
42
  }
30
43
 
31
- /** Computed resolved theme (what's actually applied). */
44
+ /**
45
+ * Reactive read of the resolved theme. Subscribes to `theme` (explicit
46
+ * user choice) and — when `theme === 'system'` — to `_osPrefersDark`
47
+ * (OS color-scheme preference). Components using `resolvedTheme()`
48
+ * inside JSX / effects / computeds re-render when either changes.
49
+ */
32
50
  export function resolvedTheme(): 'light' | 'dark' {
33
51
  const t = theme()
34
52
  if (t === 'system') {
35
53
  if (typeof window === 'undefined') return _ssrDefault
36
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
54
+ return _osPrefersDark() ? 'dark' : 'light'
37
55
  }
38
56
  return t
39
57
  }
@@ -76,15 +94,17 @@ export function initTheme() {
76
94
  // Apply to document
77
95
  document.documentElement.dataset.theme = resolvedTheme()
78
96
 
79
- // Watch for system preference changes
97
+ // Watch for system preference changes. Seed the signal from the
98
+ // current media-query state, then update reactively on each OS
99
+ // preference flip. Components reading `resolvedTheme()` pick up the
100
+ // change automatically (they subscribe to `_osPrefersDark` when
101
+ // `theme === 'system'`).
80
102
  const mq = window.matchMedia('(prefers-color-scheme: dark)')
81
- function onChange() {
82
- if (theme() === 'system') {
83
- document.documentElement.dataset.theme = resolvedTheme()
84
- }
103
+ _osPrefersDark.set(mq.matches)
104
+ function onChange(e: MediaQueryListEvent) {
105
+ _osPrefersDark.set(e.matches)
85
106
  }
86
107
  mq.addEventListener('change', onChange)
87
- onUnmount(() => mq.removeEventListener('change', onChange))
88
108
 
89
109
  // Re-apply when theme signal changes — updates data-theme + favicons
90
110
  const dispose = effect(() => {
@@ -97,9 +117,11 @@ export function initTheme() {
97
117
  link.media = link.dataset.faviconTheme === mode ? '' : 'not all'
98
118
  }
99
119
  })
100
- if (dispose) onUnmount(() => dispose.dispose())
101
120
 
102
- return undefined
121
+ return () => {
122
+ mq.removeEventListener('change', onChange)
123
+ dispose?.dispose()
124
+ }
103
125
  })
104
126
  }
105
127
 
package/src/types.ts CHANGED
@@ -48,6 +48,13 @@ export type RenderMode = 'ssr' | 'ssg' | 'spa' | 'isr'
48
48
  export interface ISRConfig {
49
49
  /** Revalidation interval in seconds. */
50
50
  revalidate: number
51
+ /**
52
+ * Maximum number of distinct URL paths to keep in the in-memory cache.
53
+ * Oldest-first LRU eviction once the cap is reached. Default: `1000`.
54
+ * Set higher for SSG-heavy sites, lower for routes with unbounded URL
55
+ * space (e.g. `/user/:id` where `:id` is free-form).
56
+ */
57
+ maxEntries?: number
51
58
  }
52
59
 
53
60
  // ─── Zero config ─────────────────────────────────────────────────────────────
@@ -1,6 +1,7 @@
1
+ import { readFile } from 'node:fs/promises'
1
2
  import { existsSync, readdirSync } from 'node:fs'
2
3
  import { join } from 'node:path'
3
- import type { Plugin } from 'vite'
4
+ import type { Plugin, ViteDevServer } from 'vite'
4
5
  import { generateApiRouteModule } from './api-routes'
5
6
  import { resolveConfig } from './config'
6
7
 
@@ -20,6 +21,21 @@ function scanPyreonPackages(root: string): string[] {
20
21
  return []
21
22
  }
22
23
  }
24
+
25
+ /**
26
+ * Resolve a package that isn't at the app's top-level `node_modules` but is
27
+ * nested under another `@pyreon/*` package. Used to alias `@pyreon/runtime-server`
28
+ * to the copy under `node_modules/@pyreon/zero/node_modules/@pyreon/runtime-server`
29
+ * so `ssrLoadModule` works without requiring the app to declare it as a
30
+ * direct dep.
31
+ */
32
+ function resolveNestedPackage(root: string, name: string): string | undefined {
33
+ const direct = join(root, 'node_modules', name)
34
+ if (existsSync(direct)) return direct
35
+ const nested = join(root, 'node_modules', '@pyreon', 'zero', 'node_modules', name)
36
+ if (existsSync(nested)) return nested
37
+ return undefined
38
+ }
23
39
  import { matchPattern } from "./entry-server";
24
40
  import { renderErrorOverlay } from "./error-overlay";
25
41
  import {
@@ -111,6 +127,41 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
111
127
  },
112
128
 
113
129
  configureServer(server) {
130
+ // Dev-mode SSR middleware — for mode: "ssr", actually render each
131
+ // matched route server-side instead of serving the SPA shell.
132
+ // Runs BEFORE the 404 handler so matched routes are SSR'd and
133
+ // unmatched ones fall through to the 404 handler.
134
+ if (config.mode === "ssr") {
135
+ server.middlewares.use((req, res, next) => {
136
+ const accept = req.headers.accept ?? "";
137
+ if (!accept.includes("text/html") && !accept.includes("*/*"))
138
+ return next();
139
+ const pathname = req.url?.split("?")[0] ?? "/";
140
+ if (pathname.startsWith("/@") || pathname.startsWith("/__"))
141
+ return next();
142
+ if (/\.\w+$/.test(pathname)) return next();
143
+
144
+ renderSsr(server, root, req.originalUrl ?? pathname, pathname).then(
145
+ (result) => {
146
+ if (result === null) return next();
147
+ res.statusCode = 200;
148
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
149
+ res.setHeader("Content-Length", Buffer.byteLength(result));
150
+ res.end(result);
151
+ },
152
+ (err: unknown) => {
153
+ const error = err instanceof Error ? err : new Error(String(err));
154
+ server.ssrFixStacktrace(error);
155
+ const html = renderErrorOverlay(error);
156
+ res.statusCode = 500;
157
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
158
+ res.setHeader("Content-Length", Buffer.byteLength(html));
159
+ res.end(html);
160
+ },
161
+ );
162
+ });
163
+ }
164
+
114
165
  // 404 handler — check if the requested path matches any route.
115
166
  // If not, render the nearest _404.tsx component with a 404 status.
116
167
  // Uses a sync wrapper that calls the async handler, since Connect
@@ -134,7 +185,7 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
134
185
  },
135
186
  (err) => {
136
187
  // oxlint-disable-next-line no-console
137
- console.error('[zero] Error in 404 handler:', err);
188
+ console.error('[Pyreon] Error in 404 handler:', err);
138
189
  next();
139
190
  },
140
191
  );
@@ -210,9 +261,33 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
210
261
  const root = userConfig.root ?? process.cwd()
211
262
  const pyreonExclude = scanPyreonPackages(root)
212
263
 
264
+ // `@pyreon/runtime-server` is only imported by zero's dev SSR
265
+ // middleware and the production server entry — apps rarely list it
266
+ // as a direct dep. Resolve it to the copy nested under zero so
267
+ // `ssrLoadModule("@pyreon/runtime-server")` works uniformly.
268
+ const runtimeServerAlias = resolveNestedPackage(
269
+ root,
270
+ "@pyreon/runtime-server",
271
+ )
272
+
213
273
  return {
214
274
  resolve: {
215
275
  conditions: ['bun'],
276
+ ...(runtimeServerAlias
277
+ ? { alias: { '@pyreon/runtime-server': runtimeServerAlias } }
278
+ : {}),
279
+ },
280
+ // Vite's SSR module graph has its own resolver that defaults to the
281
+ // "node" condition — which would pick the built `lib/index.js` for
282
+ // every `@pyreon/*` package and bypass workspace source edits. Mirror
283
+ // the client-side "bun" condition + alias so dev SSR uses `src/`.
284
+ ssr: {
285
+ resolve: {
286
+ conditions: ['bun'],
287
+ ...(runtimeServerAlias
288
+ ? { alias: { '@pyreon/runtime-server': runtimeServerAlias } }
289
+ : {}),
290
+ },
216
291
  },
217
292
  optimizeDeps: {
218
293
  exclude: pyreonExclude,
@@ -267,6 +342,133 @@ async function handle404(
267
342
  return true;
268
343
  }
269
344
 
345
+ /**
346
+ * Dev-mode SSR render pipeline. Returns the composed HTML string, or `null`
347
+ * if the URL doesn't match any known route (caller falls through to the 404
348
+ * middleware). Mirrors the production `createServer` flow:
349
+ * 1. Load virtual:zero/routes + app.ts via Vite's ssrLoadModule
350
+ * 2. Create a per-request router bound to the request URL
351
+ * 3. Pre-run loaders for the matched route(s)
352
+ * 4. Render app tree with head tag collection
353
+ * 5. Serialize loader data into `window.__PYREON_LOADER_DATA__`
354
+ * 6. Inject everything into the user's transformed index.html (so Vite
355
+ * still gets a chance to inject its HMR client + JSX runtime prelude)
356
+ */
357
+ async function renderSsr(
358
+ server: ViteDevServer,
359
+ root: string,
360
+ originalUrl: string,
361
+ pathname: string,
362
+ ): Promise<string | null> {
363
+ // Pattern check FIRST — otherwise SSR would try (and likely crash) on
364
+ // asset paths that happened to accept text/html (e.g. curl-style).
365
+ const routesMod = await server.ssrLoadModule(VIRTUAL_ROUTES_ID);
366
+ const routes = routesMod.routes as Array<{
367
+ path?: string;
368
+ children?: unknown[];
369
+ }>;
370
+ const patterns = flattenRoutePatterns(routes);
371
+ if (!patterns.some((pattern) => matchPattern(pattern, pathname))) {
372
+ return null;
373
+ }
374
+
375
+ // Read + transform index.html (Vite injects the HMR client / JSX prelude).
376
+ let template = await readFile(join(root, "index.html"), "utf-8");
377
+ template = await server.transformIndexHtml(originalUrl, template);
378
+
379
+ // Framework modules load through Vite's SSR module graph so user code (which
380
+ // imports the same packages) shares a single module instance — otherwise two
381
+ // copies of `@pyreon/router` would hold separate `RouterContext` IDs and
382
+ // `useContext` in RouterLink would miss the RouterProvider's value.
383
+ // `@pyreon/runtime-server` isn't a direct dep of most apps, so zero's
384
+ // `config()` hook registers an alias that points it at the copy under
385
+ // zero's own `node_modules` — same path → same Vite module → same instance.
386
+ const [core, headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all(
387
+ [
388
+ server.ssrLoadModule("@pyreon/core") as Promise<
389
+ typeof import("@pyreon/core")
390
+ >,
391
+ server.ssrLoadModule("@pyreon/head") as Promise<
392
+ typeof import("@pyreon/head")
393
+ >,
394
+ server.ssrLoadModule("@pyreon/head/ssr") as Promise<
395
+ typeof import("@pyreon/head/ssr")
396
+ >,
397
+ server.ssrLoadModule("@pyreon/router") as Promise<
398
+ typeof import("@pyreon/router")
399
+ >,
400
+ server.ssrLoadModule("@pyreon/runtime-server") as Promise<
401
+ typeof import("@pyreon/runtime-server")
402
+ >,
403
+ ],
404
+ );
405
+
406
+ // Build the SAME app tree the client will hydrate against. `entry-client`
407
+ // imports `layout` from `_layout.tsx` and passes it explicitly to
408
+ // `startClient` → `createApp`. We mirror that here: discover the user's
409
+ // `_layout` (if present) via Vite's SSR module graph and pass it along.
410
+ // Without this, SSR renders a different tree (no outer Layout wrapper)
411
+ // and hydration mismatches at the very first nesting level — cascading
412
+ // into duplicated mounts of every section below.
413
+ let userLayout: unknown
414
+ for (const ext of ['tsx', 'ts', 'jsx', 'js']) {
415
+ try {
416
+ const layoutMod = (await server.ssrLoadModule(
417
+ `/src/routes/_layout.${ext}`,
418
+ )) as { layout?: unknown; default?: unknown }
419
+ userLayout = layoutMod.layout ?? layoutMod.default
420
+ if (userLayout) break
421
+ } catch {
422
+ // Try the next extension. If none exist, createApp uses DefaultLayout.
423
+ }
424
+ }
425
+
426
+ // Use zero's own `createApp` rather than reassembling the tree by hand —
427
+ // guarantees server and client agree on every wrapper component (any
428
+ // future change to the App tree only needs to happen in one place).
429
+ // Load via `ssrLoadModule` so app.ts shares Vite's SSR module graph with
430
+ // the user's code: both end up importing the SAME `@pyreon/router` /
431
+ // `@pyreon/core` / `@pyreon/head` instances, so contexts (RouterContext,
432
+ // HeadContext, etc.) match between provider and consumer. A direct Node
433
+ // `import("./app")` would resolve those packages via Node's module graph,
434
+ // producing duplicate context registries that never connect.
435
+ const appMod = (await server.ssrLoadModule(
436
+ "@pyreon/zero/server",
437
+ )) as typeof import("./server")
438
+ type CreateAppLayout = NonNullable<
439
+ Parameters<typeof appMod.createApp>[0]["layout"]
440
+ >
441
+ const { App, router: routerInst } = appMod.createApp({
442
+ routes: routes as import("@pyreon/router").RouteRecord[],
443
+ routerMode: "history",
444
+ url: pathname,
445
+ ...(userLayout ? { layout: userLayout as CreateAppLayout } : {}),
446
+ })
447
+
448
+ // `preload` loads lazy route components AND runs loaders for `pathname` so
449
+ // the synchronous render pass produces final HTML — no loading fallbacks,
450
+ // no `useLoaderData() === undefined`.
451
+ await routerInst.preload(pathname);
452
+
453
+ return runtimeServer.runWithRequestContext(async () => {
454
+ const app = core.h(App as Parameters<typeof core.h>[0], null);
455
+
456
+ const { html: appHtml, head } = await headSsr.renderWithHead(app);
457
+ const loaderData = routerPkg.serializeLoaderData(
458
+ routerInst as Parameters<typeof routerPkg.serializeLoaderData>[0],
459
+ );
460
+ const hasData = loaderData && Object.keys(loaderData).length > 0;
461
+ const loaderScript = hasData
462
+ ? `<script>window.__PYREON_LOADER_DATA__=${JSON.stringify(loaderData).replace(/<\//g, "<\\/")}</script>`
463
+ : "";
464
+
465
+ return template
466
+ .replace("<!--pyreon-head-->", head)
467
+ .replace("<!--pyreon-app-->", appHtml)
468
+ .replace("<!--pyreon-scripts-->", loaderScript);
469
+ });
470
+ }
471
+
270
472
  /** Extract all URL patterns from a nested route tree. */
271
473
  function flattenRoutePatterns(
272
474
  routes: Array<{ path?: string; children?: unknown[] }>,