@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.
- package/package.json +10 -39
- package/src/actions.ts +0 -196
- package/src/adapters/bun.ts +0 -114
- package/src/adapters/cloudflare.ts +0 -166
- package/src/adapters/index.ts +0 -61
- package/src/adapters/netlify.ts +0 -154
- package/src/adapters/node.ts +0 -163
- package/src/adapters/static.ts +0 -42
- package/src/adapters/validate.ts +0 -23
- package/src/adapters/vercel.ts +0 -182
- package/src/adapters/warn-missing-env.ts +0 -49
- package/src/ai.ts +0 -623
- package/src/api-routes.ts +0 -219
- package/src/app.ts +0 -92
- package/src/cache.ts +0 -136
- package/src/client.ts +0 -143
- package/src/compression.ts +0 -116
- package/src/config.ts +0 -35
- package/src/cors.ts +0 -94
- package/src/csp.ts +0 -226
- package/src/entry-server.ts +0 -224
- package/src/env.ts +0 -344
- package/src/error-overlay.ts +0 -118
- package/src/favicon.ts +0 -841
- package/src/font.ts +0 -511
- package/src/fs-router.ts +0 -1519
- package/src/i18n-routing.ts +0 -533
- package/src/icon.tsx +0 -182
- package/src/icons-plugin.ts +0 -296
- package/src/image-plugin.ts +0 -751
- package/src/image-types.ts +0 -60
- package/src/image.tsx +0 -340
- package/src/index.ts +0 -92
- package/src/isr.ts +0 -394
- package/src/link.tsx +0 -304
- package/src/logger.ts +0 -144
- package/src/manifest.ts +0 -787
- package/src/meta.tsx +0 -354
- package/src/middleware.ts +0 -65
- package/src/not-found.ts +0 -44
- package/src/og-image.ts +0 -378
- package/src/rate-limit.ts +0 -140
- package/src/script.tsx +0 -260
- package/src/seo.ts +0 -617
- package/src/server.ts +0 -89
- package/src/sharp.d.ts +0 -22
- package/src/ssg-plugin.ts +0 -1582
- package/src/testing.ts +0 -146
- package/src/theme.tsx +0 -257
- package/src/types.ts +0 -624
- package/src/utils/use-intersection-observer.ts +0 -36
- package/src/utils/with-headers.ts +0 -13
- package/src/vercel-revalidate-handler.ts +0 -204
- 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
|
-
}
|