@pyreon/zero 0.24.5 → 0.24.6

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 (54) hide show
  1. package/package.json +10 -39
  2. package/src/actions.ts +0 -196
  3. package/src/adapters/bun.ts +0 -114
  4. package/src/adapters/cloudflare.ts +0 -166
  5. package/src/adapters/index.ts +0 -61
  6. package/src/adapters/netlify.ts +0 -154
  7. package/src/adapters/node.ts +0 -163
  8. package/src/adapters/static.ts +0 -42
  9. package/src/adapters/validate.ts +0 -23
  10. package/src/adapters/vercel.ts +0 -182
  11. package/src/adapters/warn-missing-env.ts +0 -49
  12. package/src/ai.ts +0 -623
  13. package/src/api-routes.ts +0 -219
  14. package/src/app.ts +0 -92
  15. package/src/cache.ts +0 -136
  16. package/src/client.ts +0 -143
  17. package/src/compression.ts +0 -116
  18. package/src/config.ts +0 -35
  19. package/src/cors.ts +0 -94
  20. package/src/csp.ts +0 -226
  21. package/src/entry-server.ts +0 -224
  22. package/src/env.ts +0 -344
  23. package/src/error-overlay.ts +0 -118
  24. package/src/favicon.ts +0 -841
  25. package/src/font.ts +0 -511
  26. package/src/fs-router.ts +0 -1519
  27. package/src/i18n-routing.ts +0 -533
  28. package/src/icon.tsx +0 -182
  29. package/src/icons-plugin.ts +0 -296
  30. package/src/image-plugin.ts +0 -751
  31. package/src/image-types.ts +0 -60
  32. package/src/image.tsx +0 -340
  33. package/src/index.ts +0 -92
  34. package/src/isr.ts +0 -394
  35. package/src/link.tsx +0 -304
  36. package/src/logger.ts +0 -144
  37. package/src/manifest.ts +0 -787
  38. package/src/meta.tsx +0 -354
  39. package/src/middleware.ts +0 -65
  40. package/src/not-found.ts +0 -44
  41. package/src/og-image.ts +0 -378
  42. package/src/rate-limit.ts +0 -140
  43. package/src/script.tsx +0 -260
  44. package/src/seo.ts +0 -617
  45. package/src/server.ts +0 -89
  46. package/src/sharp.d.ts +0 -22
  47. package/src/ssg-plugin.ts +0 -1582
  48. package/src/testing.ts +0 -146
  49. package/src/theme.tsx +0 -257
  50. package/src/types.ts +0 -624
  51. package/src/utils/use-intersection-observer.ts +0 -36
  52. package/src/utils/with-headers.ts +0 -13
  53. package/src/vercel-revalidate-handler.ts +0 -204
  54. package/src/vite-plugin.ts +0 -848
package/src/api-routes.ts DELETED
@@ -1,219 +0,0 @@
1
- import type { Middleware, MiddlewareContext } from '@pyreon/server'
2
-
3
- // ─── Types ───────────────────────────────────────────────────────────────────
4
-
5
- /** HTTP methods supported by API routes. */
6
- export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
7
-
8
- /** Context passed to API route handlers. */
9
- export interface ApiContext {
10
- /** The incoming request. */
11
- request: Request
12
- /** Parsed URL. */
13
- url: URL
14
- /** URL path. */
15
- path: string
16
- /** Dynamic route parameters (e.g., { id: "123" }). */
17
- params: Record<string, string>
18
- /** Request headers. */
19
- headers: Headers
20
- }
21
-
22
- /** An API route handler function. */
23
- export type ApiHandler = (ctx: ApiContext) => Response | Promise<Response>
24
-
25
- /** An API route module — exports named HTTP method handlers. */
26
- export interface ApiRouteModule {
27
- GET?: ApiHandler
28
- POST?: ApiHandler
29
- PUT?: ApiHandler
30
- PATCH?: ApiHandler
31
- DELETE?: ApiHandler
32
- HEAD?: ApiHandler
33
- OPTIONS?: ApiHandler
34
- }
35
-
36
- /** A registered API route entry. */
37
- export interface ApiRouteEntry {
38
- /** URL pattern (e.g., "/api/posts/:id"). */
39
- pattern: string
40
- /** The route module with method handlers. */
41
- module: ApiRouteModule
42
- }
43
-
44
- // ─── Pattern matching ────────────────────────────────────────────────────────
45
-
46
- /**
47
- * Match a URL path against an API route pattern.
48
- * Returns extracted params or null if no match.
49
- */
50
- export function matchApiRoute(pattern: string, path: string): Record<string, string> | null {
51
- const patternParts = pattern.split('/').filter(Boolean)
52
- const pathParts = path.split('/').filter(Boolean)
53
- const params: Record<string, string> = {}
54
-
55
- // A param NAME comes from the route pattern (file-based route like
56
- // `[__proto__].ts`) — developer-controlled, so this is defense-in-depth
57
- // rather than an attacker vector, but assigning `params['__proto__'] =
58
- // …` still mutates the result object's prototype instead of setting a
59
- // param. Skip the dangerous names (consistent with reconcile / i18n
60
- // deepMerge guards) so a stray route name can't pollute.
61
- const isUnsafeParam = (name: string): boolean =>
62
- name === '__proto__' || name === 'constructor' || name === 'prototype'
63
-
64
- for (let i = 0; i < patternParts.length; i++) {
65
- const pp = patternParts[i]
66
- if (!pp) continue
67
-
68
- // Catch-all: :param*
69
- if (pp.endsWith('*')) {
70
- const paramName = pp.slice(1, -1)
71
- if (!isUnsafeParam(paramName)) params[paramName] = pathParts.slice(i).join('/')
72
- return params
73
- }
74
-
75
- // No more path segments
76
- if (i >= pathParts.length) return null
77
-
78
- // Dynamic segment: :param
79
- if (pp.startsWith(':')) {
80
- const paramName = pp.slice(1)
81
- if (!isUnsafeParam(paramName)) params[paramName] = pathParts[i]!
82
- continue
83
- }
84
-
85
- // Static segment
86
- if (pp !== pathParts[i]) return null
87
- }
88
-
89
- return patternParts.length === pathParts.length ? params : null
90
- }
91
-
92
- // ─── Middleware ───────────────────────────────────────────────────────────────
93
-
94
- const HTTP_METHODS: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
95
-
96
- /**
97
- * Create a middleware that dispatches API route requests.
98
- * API routes are matched by URL pattern and HTTP method.
99
- */
100
- export function createApiMiddleware(routes: ApiRouteEntry[]): Middleware {
101
- return async (ctx: MiddlewareContext) => {
102
- for (const route of routes) {
103
- const params = matchApiRoute(route.pattern, ctx.path)
104
- if (!params) continue
105
-
106
- const method = ctx.req.method.toUpperCase() as HttpMethod
107
- const handler = route.module[method]
108
-
109
- if (!handler) {
110
- // Route matched but method not supported
111
- const allowed = HTTP_METHODS.filter((m) => route.module[m]).join(', ')
112
- return new Response(null, {
113
- status: 405,
114
- headers: {
115
- Allow: allowed,
116
- 'Content-Type': 'application/json',
117
- },
118
- })
119
- }
120
-
121
- return handler({
122
- request: ctx.req,
123
- url: ctx.url,
124
- path: ctx.path,
125
- params,
126
- headers: ctx.req.headers,
127
- })
128
- }
129
- }
130
- }
131
-
132
- // ─── Virtual module generation ───────────────────────────────────────────────
133
-
134
- /**
135
- * Detect whether a route file is an API route.
136
- * API routes are `.ts` or `.js` files inside an `api/` directory.
137
- */
138
- export function isApiRoute(filePath: string): boolean {
139
- const normalized = filePath.replace(/\\/g, '/')
140
- return (
141
- normalized.startsWith('api/') &&
142
- (normalized.endsWith('.ts') || normalized.endsWith('.js')) &&
143
- !normalized.endsWith('.tsx') &&
144
- !normalized.endsWith('.jsx')
145
- )
146
- }
147
-
148
- /**
149
- * Convert an API route file path to a URL pattern.
150
- *
151
- * Examples:
152
- * "api/posts.ts" → "/api/posts"
153
- * "api/posts/index.ts" → "/api/posts"
154
- * "api/posts/[id].ts" → "/api/posts/:id"
155
- * "api/[...path].ts" → "/api/:path*"
156
- */
157
- export function apiFilePathToPattern(filePath: string): string {
158
- let route = filePath
159
- // Remove extension
160
- for (const ext of ['.ts', '.js']) {
161
- if (route.endsWith(ext)) {
162
- route = route.slice(0, -ext.length)
163
- break
164
- }
165
- }
166
-
167
- const segments = route.split('/')
168
- const urlSegments: string[] = []
169
-
170
- for (const seg of segments) {
171
- if (seg === 'index') continue
172
-
173
- // Catch-all: [...param]
174
- const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/)
175
- if (catchAll) {
176
- urlSegments.push(`:${catchAll[1]}*`)
177
- continue
178
- }
179
-
180
- // Dynamic: [param]
181
- const dynamic = seg.match(/^\[(\w+)\]$/)
182
- if (dynamic) {
183
- urlSegments.push(`:${dynamic[1]}`)
184
- continue
185
- }
186
-
187
- urlSegments.push(seg)
188
- }
189
-
190
- return `/${urlSegments.join('/')}`
191
- }
192
-
193
- /**
194
- * Generate a virtual module that exports API route entries.
195
- * Each entry maps a URL pattern to a module with HTTP method handlers.
196
- */
197
- export function generateApiRouteModule(files: string[], routesDir: string): string {
198
- const apiFiles = files.filter(isApiRoute)
199
-
200
- if (apiFiles.length === 0) {
201
- return 'export const apiRoutes = []\n'
202
- }
203
-
204
- const imports: string[] = []
205
- const entries: string[] = []
206
-
207
- for (let i = 0; i < apiFiles.length; i++) {
208
- const name = `_api${i}`
209
- const file = apiFiles[i]
210
- if (!file) continue
211
- const fullPath = `${routesDir}/${file}`
212
- const pattern = apiFilePathToPattern(file)
213
-
214
- imports.push(`import * as ${name} from "${fullPath}"`)
215
- entries.push(` { pattern: ${JSON.stringify(pattern)}, module: ${name} }`)
216
- }
217
-
218
- return [...imports, '', 'export const apiRoutes = [', entries.join(',\n'), ']'].join('\n')
219
- }
package/src/app.ts DELETED
@@ -1,92 +0,0 @@
1
- import type { ComponentFn, Props } from '@pyreon/core'
2
- import { Fragment, h } from '@pyreon/core'
3
- import { HeadProvider } from '@pyreon/head'
4
- import type { RouteRecord } from '@pyreon/router'
5
- import { createRouter, RouterProvider, RouterView } from '@pyreon/router'
6
-
7
- // ─── App assembly ────────────────────────────────────────────────────────────
8
-
9
- export interface CreateAppOptions {
10
- /** Route definitions (from file-based routing or manual). */
11
- routes: RouteRecord[]
12
-
13
- /** Router mode. Default: "history" for SSR, "hash" for SPA. */
14
- routerMode?: 'hash' | 'history'
15
-
16
- /** Initial URL for SSR. */
17
- url?: string
18
-
19
- /** Root layout component wrapping all routes. */
20
- layout?: ComponentFn
21
-
22
- /** Global error component. */
23
- errorComponent?: ComponentFn
24
-
25
- /**
26
- * Base URL prefix for the deployed app (e.g. `/blog/`). Forwarded to
27
- * `createRouter({ base })` so RouterLinks render correctly prefixed
28
- * hrefs (`<a href="/blog/about">` instead of `<a href="/about">`) and
29
- * the router strips the prefix from incoming URLs before matching.
30
- *
31
- * Default: `'/'`. Pre-fix this was disconnected from `zero({ base })`
32
- * — RouterLinks rendered un-prefixed hrefs even when Vite's asset URL
33
- * rewriting was correctly using the prefix, causing client-side
34
- * navigation to break against subpath deploys.
35
- */
36
- base?: string
37
- }
38
-
39
- /**
40
- * Create a full Zero app — assembles router, head provider, and root layout.
41
- *
42
- * Used internally by entry-server and entry-client.
43
- */
44
- export function createApp(options: CreateAppOptions) {
45
- const router = createRouter({
46
- routes: options.routes,
47
- mode: options.routerMode ?? 'history',
48
- ...(options.url ? { url: options.url } : {}),
49
- ...(options.base && options.base !== '/' ? { base: options.base } : {}),
50
- scrollBehavior: 'top',
51
- })
52
-
53
- // Detect the "double layout" footgun. fs-router emits `_layout.tsx` as a
54
- // parent route record (the canonical Pyreon way to register a layout via
55
- // file-system routing). If the user ALSO passes `options.layout` referring
56
- // to the same component, the layout mounts twice — once via App's wrapper
57
- // and once via the matched route chain. Result on hydration mismatch:
58
- // 3× `nav.sidebar` + 3× `main.content`.
59
- //
60
- // Defense: when `options.layout` references the same component as ANY
61
- // top-level route's `component`, drop the explicit option (the route-chain
62
- // path is canonical) and warn in dev. Anyone who genuinely wants two
63
- // layout wrappers can compose them inside a single component themselves.
64
- const hasLayoutInRoutes =
65
- options.layout !== undefined &&
66
- options.routes.some((r) => r.component === options.layout)
67
- if (hasLayoutInRoutes && process.env.NODE_ENV !== 'production') {
68
- // oxlint-disable-next-line no-console
69
- console.warn(
70
- '[Pyreon] `createApp({ layout })` was passed a component that is ALSO a parent route in the matched chain (likely an fs-router `_layout.tsx`). The explicit `layout` option is being ignored to prevent double-mount. Remove the `layout` argument from `createApp`/`startClient` — the fs-router-emitted route handles it.',
71
- )
72
- }
73
- const Layout = hasLayoutInRoutes ? DefaultLayout : (options.layout ?? DefaultLayout)
74
-
75
- function App() {
76
- return h(
77
- HeadProvider,
78
- null,
79
- h(
80
- RouterProvider as ComponentFn<Props>,
81
- { router },
82
- h(Layout, null, h(RouterView as ComponentFn<Props>, null)),
83
- ),
84
- )
85
- }
86
-
87
- return { App, router }
88
- }
89
-
90
- function DefaultLayout(props: Props) {
91
- return h(Fragment, null, ...(Array.isArray(props.children) ? props.children : [props.children]))
92
- }
package/src/cache.ts DELETED
@@ -1,136 +0,0 @@
1
- import type { Middleware, MiddlewareContext } from '@pyreon/server'
2
-
3
- // ─── Cache control middleware ───────────────────────────────────────────────
4
- //
5
- // Smart caching middleware that sets appropriate cache headers based on
6
- // asset type, URL patterns, and build hashes.
7
- //
8
- // Strategies:
9
- // - Immutable: hashed assets (JS/CSS bundles) — cached forever
10
- // - Static: images, fonts, media — long cache with revalidation
11
- // - Dynamic: HTML pages — short or no cache, stale-while-revalidate
12
- // - API: JSON responses — no cache by default
13
-
14
- export interface CacheConfig {
15
- /** Cache duration for immutable hashed assets (seconds). Default: 31536000 (1 year) */
16
- immutable?: number
17
- /** Cache duration for static assets like images/fonts (seconds). Default: 86400 (1 day) */
18
- static?: number
19
- /** Cache duration for pages (seconds). Default: 0 (no cache) */
20
- pages?: number
21
- /** Stale-while-revalidate window for pages (seconds). Default: 60 */
22
- staleWhileRevalidate?: number
23
- /** Custom rules by URL pattern. */
24
- rules?: CacheRule[]
25
- }
26
-
27
- export interface CacheRule {
28
- /** URL pattern to match (glob-style). e.g. "/api/*" */
29
- match: string
30
- /** Cache-Control header value. */
31
- control: string
32
- }
33
-
34
- const HASHED_ASSET = /\.[a-f0-9]{8,}\.\w+$/
35
- const STATIC_EXT = /\.(png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|mp4|webm|ogg|mp3|wav)$/i
36
- const SCRIPT_EXT = /\.(js|css|mjs)$/i
37
-
38
- /** @internal Exported for testing */
39
- export function matchGlob(pattern: string, path: string): boolean {
40
- // Escape regex special chars, then convert glob wildcards
41
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
42
- const regex = escaped.replace(/\*/g, '.*').replace(/\?/g, '.')
43
- return new RegExp(`^${regex}$`).test(path)
44
- }
45
-
46
- function resolveControl(
47
- path: string,
48
- immutableDuration: number,
49
- staticDuration: number,
50
- pageDuration: number,
51
- swr: number,
52
- ): string {
53
- if (HASHED_ASSET.test(path)) {
54
- return `public, max-age=${immutableDuration}, immutable`
55
- }
56
- if (SCRIPT_EXT.test(path)) {
57
- return `public, max-age=3600, stale-while-revalidate=${swr}`
58
- }
59
- if (STATIC_EXT.test(path)) {
60
- return `public, max-age=${staticDuration}, stale-while-revalidate=${swr}`
61
- }
62
- if (pageDuration > 0) {
63
- return `public, max-age=${pageDuration}, stale-while-revalidate=${swr}`
64
- }
65
- return 'no-cache'
66
- }
67
-
68
- /**
69
- * Cache control middleware for Zero.
70
- * Sets Cache-Control headers on the response based on asset type.
71
- *
72
- * @example
73
- * import { cacheMiddleware } from "@pyreon/zero/cache"
74
- *
75
- * export default createHandler({
76
- * routes,
77
- * middleware: [
78
- * cacheMiddleware({
79
- * pages: 60,
80
- * staleWhileRevalidate: 300,
81
- * rules: [
82
- * { match: "/api/*", control: "no-store" },
83
- * ],
84
- * }),
85
- * ],
86
- * })
87
- */
88
- export function cacheMiddleware(config: CacheConfig = {}): Middleware {
89
- const immutableDuration = config.immutable ?? 31536000
90
- const staticDuration = config.static ?? 86400
91
- const pageDuration = config.pages ?? 0
92
- const swr = config.staleWhileRevalidate ?? 60
93
- const rules = config.rules ?? []
94
-
95
- return (ctx: MiddlewareContext) => {
96
- const path = ctx.url.pathname
97
-
98
- for (const rule of rules) {
99
- if (matchGlob(rule.match, path)) {
100
- ctx.headers.set('Cache-Control', rule.control)
101
- return
102
- }
103
- }
104
-
105
- const control = resolveControl(path, immutableDuration, staticDuration, pageDuration, swr)
106
- ctx.headers.set('Cache-Control', control)
107
- }
108
- }
109
-
110
- /**
111
- * Security headers middleware.
112
- * Adds common security headers to all responses.
113
- */
114
- export function securityHeaders(): Middleware {
115
- return (ctx: MiddlewareContext) => {
116
- ctx.headers.set('X-Content-Type-Options', 'nosniff')
117
- ctx.headers.set('X-Frame-Options', 'DENY')
118
- ctx.headers.set('X-XSS-Protection', '1; mode=block')
119
- ctx.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
120
- ctx.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
121
- }
122
- }
123
-
124
- /**
125
- * Compression detection middleware.
126
- * Sets Vary: Accept-Encoding header so caches can serve compressed variants.
127
- * Actual compression is handled by the runtime (Bun/Node) or reverse proxy.
128
- */
129
- export function varyEncoding(): Middleware {
130
- return (ctx: MiddlewareContext) => {
131
- const existing = ctx.headers.get('Vary')
132
- if (!existing?.includes('Accept-Encoding')) {
133
- ctx.headers.set('Vary', existing ? `${existing}, Accept-Encoding` : 'Accept-Encoding')
134
- }
135
- }
136
- }
package/src/client.ts DELETED
@@ -1,143 +0,0 @@
1
- import type { ComponentFn } from '@pyreon/core'
2
- import { h } from '@pyreon/core'
3
- import type { RouteRecord } from '@pyreon/router'
4
- import { hydrateLoaderData } from '@pyreon/router'
5
- import { hydrateRoot, mount } from '@pyreon/runtime-dom'
6
- import { createApp } from './app'
7
-
8
- // Vite-injected build-time constant. Defined in `vite-plugin.ts`'s
9
- // `config()` hook from `zero({ base })`. Falls back to `'/'` for
10
- // non-Vite builds (test environments, etc.) so the read is always
11
- // safe. The fallback is documented intent — there's no Pyreon
12
- // deployment outside Vite that consumes this.
13
- declare const __ZERO_BASE__: string
14
-
15
- // ─── Client entry factory ───────────────────────────────────────────────────
16
-
17
- export interface StartClientOptions {
18
- /** Route definitions. */
19
- routes: RouteRecord[]
20
- /** Root layout component. */
21
- layout?: ComponentFn
22
- }
23
-
24
- /**
25
- * Start the client-side app — hydrates SSR content or mounts fresh for SPA.
26
- *
27
- * ## Loader data flow
28
- *
29
- * Direct navigation to a route with a `loader` function needs data to be
30
- * available on the VERY FIRST render. This is handled in two modes:
31
- *
32
- * - **SSR mode (zero's default)**: the server pre-runs loaders, renders the
33
- * HTML with loader data already applied, and embeds a JSON blob in the
34
- * HTML as `window.__PYREON_LOADER_DATA__`. On the client we read that
35
- * blob and call `hydrateLoaderData(router, data)` BEFORE hydrating — so
36
- * the hydration pass sees the same data the SSR render produced
37
- * (avoids hydration mismatches and the flash of "not found" fallback).
38
- *
39
- * - **SPA cold start (no SSR content)**: no `__PYREON_LOADER_DATA__` was
40
- * embedded, so we call `router.replace(currentPath)` after mount to
41
- * trigger the loader pipeline for the initial route. The first render
42
- * shows whatever the component displays for `useLoaderData() === undefined`
43
- * (typically a loading state or fallback); once loaders resolve, the
44
- * reactive `useLoaderData` re-renders with the data. This matches
45
- * standard SPA loading behavior.
46
- *
47
- * Without this wiring, direct URL navigation to a loader-backed route
48
- * (e.g. `/posts/3`) showed the "Post not found" fallback indefinitely
49
- * because `useLoaderData()` returned `undefined` forever. The router
50
- * only ran loaders on in-app navigation (push/replace), not on initial
51
- * mount.
52
- *
53
- * @example
54
- * import { routes } from "virtual:zero/routes"
55
- * import { startClient } from "@pyreon/zero/client"
56
- *
57
- * startClient({ routes })
58
- */
59
- export function startClient(options: StartClientOptions) {
60
- // `startClient` is the browser entry point — only ever called from a
61
- // user's `client.ts` mounted in the browser. Explicit guard documents
62
- // that contract and gives a clearer error than `document is not defined`.
63
- if (typeof document === 'undefined') {
64
- throw new Error('[Pyreon] startClient() can only be called in the browser.')
65
- }
66
- const container = document.getElementById('app')
67
- if (!container) throw new Error('[Pyreon] Missing #app container element')
68
-
69
- // Read the Vite-injected base so `createRouter({ base })` matches the
70
- // value Vite used to rewrite asset URLs. `typeof` guard covers the
71
- // edge case where the constant isn't defined (non-Vite test contexts);
72
- // missing the constant in a real Vite build is impossible because the
73
- // plugin's `config()` hook always declares it via `define`.
74
- const base =
75
- typeof __ZERO_BASE__ !== 'undefined' && __ZERO_BASE__ !== '/'
76
- ? __ZERO_BASE__
77
- : undefined
78
-
79
- const { App, router } = createApp({
80
- routes: options.routes,
81
- routerMode: 'history',
82
- ...(options.layout ? { layout: options.layout } : {}),
83
- ...(base ? { base } : {}),
84
- })
85
-
86
- // ── Loader data hydration (SSR path) ───────────────────────────────────────
87
- // If the server embedded loader data, hydrate it BEFORE mounting so the
88
- // initial render sees the same data the SSR pass produced. This avoids
89
- // hydration mismatches and eliminates the flash-of-fallback.
90
- const ssrLoaderData = (window as unknown as Record<string, unknown>)
91
- .__PYREON_LOADER_DATA__
92
- const hasSSRLoaderData =
93
- ssrLoaderData !== undefined &&
94
- typeof ssrLoaderData === 'object' &&
95
- ssrLoaderData !== null
96
- if (hasSSRLoaderData) {
97
- // `router` is the public Router<> type; hydrateLoaderData uses the
98
- // internal RouterInstance shape. The cast is safe because they're
99
- // the same object at runtime — just narrower/wider type views.
100
- hydrateLoaderData(router as never, ssrLoaderData as Record<string, unknown>)
101
- }
102
-
103
- const vnode = h(App, null)
104
-
105
- // ── Mount vs hydrate ───────────────────────────────────────────────────────
106
- // Ignore comment nodes (Vite injects <!--app-html-->) — only real DOM
107
- // elements or text nodes count as SSR content worth hydrating.
108
- const hasSSRContent = Array.from(container.childNodes).some(
109
- (n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent!.trim().length > 0),
110
- )
111
- const cleanup = hasSSRContent ? hydrateRoot(container, vnode) : mount(vnode, container)
112
-
113
- // ── Loader run (SPA cold-start path) ───────────────────────────────────────
114
- // If we had no SSR loader data AND no SSR content, this is a true SPA
115
- // cold start. Trigger the router's loader pipeline for the current route
116
- // via `replace()` with the same path — doesn't change the URL, just kicks
117
- // off the loader batch. Guards, middleware, and redirects run too, which
118
- // matches what any other route navigation would do.
119
- //
120
- // If we DID have SSR content but NO loader data — that's an unusual case
121
- // (SSR disabled for this route but loader defined). Run loaders anyway so
122
- // the client catches up.
123
- if (!hasSSRLoaderData) {
124
- const currentPath = router.currentRoute().path
125
- router.replace(currentPath).catch((err: unknown) => {
126
- // Loader failures are already reported via the route's error handling
127
- // pipeline. We swallow the promise rejection here to prevent unhandled
128
- // rejection warnings — the route's `errorComponent` (if any) already
129
- // handled the display.
130
- // @ts-ignore — `import.meta.env.DEV` is provided by Vite/Rolldown at build time
131
- if (import.meta.env?.DEV === true) {
132
- // oxlint-disable-next-line no-console
133
- console.warn(
134
- '[Pyreon] Initial loader run failed for route:',
135
- currentPath,
136
- err,
137
- )
138
- }
139
- })
140
- }
141
-
142
- return cleanup
143
- }