@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.
- package/lib/client.js +41 -5
- package/lib/client.js.map +1 -1
- package/lib/env.js +6 -6
- package/lib/env.js.map +1 -1
- package/lib/favicon.js +2 -2
- package/lib/favicon.js.map +1 -1
- package/lib/font.js +2 -2
- package/lib/font.js.map +1 -1
- package/lib/i18n-routing.js.map +1 -1
- package/lib/image-plugin.js +1 -1
- package/lib/image-plugin.js.map +1 -1
- package/lib/index.js +39 -10
- package/lib/index.js.map +1 -1
- package/lib/link.js +12 -4
- package/lib/link.js.map +1 -1
- package/lib/meta.js.map +1 -1
- package/lib/og-image.js +2 -2
- package/lib/og-image.js.map +1 -1
- package/lib/script.js +1 -0
- package/lib/script.js.map +1 -1
- package/lib/server.js +132 -12
- package/lib/server.js.map +1 -1
- package/lib/theme.js +27 -7
- package/lib/theme.js.map +1 -1
- package/lib/types/client.d.ts +26 -0
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/config.d.ts +7 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/index.d.ts +13 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/link.d.ts.map +1 -1
- package/lib/types/server.d.ts +14 -0
- package/lib/types/server.d.ts.map +1 -1
- package/lib/types/theme.d.ts +6 -1
- package/lib/types/theme.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/adapters/index.ts +1 -1
- package/src/adapters/validate.ts +2 -2
- package/src/client.ts +84 -6
- package/src/env.ts +6 -6
- package/src/favicon.ts +3 -3
- package/src/font.ts +2 -2
- package/src/i18n-routing.ts +1 -1
- package/src/image-plugin.ts +1 -1
- package/src/isr.ts +34 -2
- package/src/link.tsx +21 -5
- package/src/og-image.ts +2 -2
- package/src/script.tsx +4 -0
- package/src/theme.tsx +33 -11
- package/src/types.ts +7 -0
- package/src/vite-plugin.ts +204 -2
package/src/i18n-routing.ts
CHANGED
|
@@ -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: [
|
|
16
|
+
// export default { plugins: [Pyreon], defaultLocale: "en" })] }
|
|
17
17
|
|
|
18
18
|
export interface I18nRoutingConfig {
|
|
19
19
|
/** Supported locales. e.g. ["en", "de", "cs"] */
|
package/src/image-plugin.ts
CHANGED
|
@@ -9,7 +9,7 @@ function warnSharpMissing() {
|
|
|
9
9
|
sharpWarned = true
|
|
10
10
|
// oxlint-disable-next-line no-console
|
|
11
11
|
console.warn(
|
|
12
|
-
'\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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
if (
|
|
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[
|
|
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(`[
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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 ─────────────────────────────────────────────────────────────
|
package/src/vite-plugin.ts
CHANGED
|
@@ -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('[
|
|
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[] }>,
|