@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
@@ -1,116 +0,0 @@
1
- import type { Middleware, MiddlewareContext } from '@pyreon/server'
2
-
3
- // ─── Compression middleware ─────────────────────────────────────────────────
4
-
5
- export interface CompressionConfig {
6
- /** Minimum response size in bytes to compress. Default: `1024` (1KB) */
7
- threshold?: number
8
- /** Encoding preference order. Default: `["gzip", "deflate"]` */
9
- encodings?: ('gzip' | 'deflate')[]
10
- }
11
-
12
- /**
13
- * Compression middleware — compresses responses using gzip or deflate
14
- * based on the client's Accept-Encoding header.
15
- *
16
- * Only compresses text-based content types (HTML, JSON, JS, CSS, XML, SVG).
17
- * Skips responses below the size threshold and already-encoded responses.
18
- *
19
- * @example
20
- * import { compressionMiddleware } from "@pyreon/zero/compression"
21
- *
22
- * compressionMiddleware() // gzip with 1KB threshold
23
- * compressionMiddleware({ threshold: 512, encodings: ["gzip"] })
24
- */
25
- export function compressionMiddleware(config: CompressionConfig = {}): Middleware {
26
- const { threshold = 1024, encodings = ['gzip', 'deflate'] } = config
27
-
28
- return (ctx: MiddlewareContext) => {
29
- const acceptEncoding = ctx.req.headers.get('accept-encoding') ?? ''
30
-
31
- // Find the best supported encoding
32
- const encoding = encodings.find((enc) => acceptEncoding.includes(enc))
33
- if (!encoding) return
34
-
35
- // Store the encoding choice for post-processing
36
- ctx.locals.__compressionEncoding = encoding
37
- ctx.locals.__compressionThreshold = threshold
38
- ctx.headers.append('Vary', 'Accept-Encoding')
39
- }
40
- }
41
-
42
- /**
43
- * Compress a Response body if it meets the criteria.
44
- * Use this to post-process responses after the handler runs.
45
- *
46
- * @example
47
- * const response = await handler(request)
48
- * const compressed = await compressResponse(response, 'gzip', 1024)
49
- */
50
- export async function compressResponse(
51
- response: Response,
52
- encoding: 'gzip' | 'deflate',
53
- threshold: number,
54
- ): Promise<Response> {
55
- const contentType = response.headers.get('content-type') ?? ''
56
-
57
- // Only compress text-based content
58
- if (!isCompressible(contentType)) return response
59
-
60
- // Skip if already encoded
61
- if (response.headers.get('content-encoding')) return response
62
-
63
- const body = await response.arrayBuffer()
64
-
65
- // Skip below threshold
66
- if (body.byteLength < threshold) return response
67
-
68
- const compressed = await compress(body, encoding)
69
-
70
- const headers = new Headers(response.headers)
71
- headers.set('Content-Encoding', encoding)
72
- headers.delete('Content-Length')
73
- headers.append('Vary', 'Accept-Encoding')
74
-
75
- return new Response(compressed, {
76
- status: response.status,
77
- statusText: response.statusText,
78
- headers,
79
- })
80
- }
81
-
82
- const COMPRESSIBLE_TYPES = [
83
- 'text/',
84
- 'application/json',
85
- 'application/javascript',
86
- 'application/xml',
87
- 'application/xhtml+xml',
88
- 'image/svg+xml',
89
- ]
90
-
91
- /** Check if a content type is compressible. Exported for testing. */
92
- export function isCompressible(contentType: string): boolean {
93
- return COMPRESSIBLE_TYPES.some((t) => contentType.includes(t))
94
- }
95
-
96
- async function compress(data: ArrayBuffer, encoding: 'gzip' | 'deflate'): Promise<ArrayBuffer> {
97
- // CompressionStream is available in modern browsers and Node 18+/Bun.
98
- // Fallback: try node:zlib for older runtimes.
99
- if (typeof CompressionStream !== 'undefined') {
100
- const format = encoding === 'gzip' ? 'gzip' : 'deflate'
101
- const stream = new Blob([data]).stream().pipeThrough(new CompressionStream(format))
102
- return new Response(stream).arrayBuffer()
103
- }
104
-
105
- // Node.js fallback via zlib
106
- try {
107
- const zlib = await import('node:zlib')
108
- const { promisify } = await import('node:util')
109
- const fn = encoding === 'gzip' ? promisify(zlib.gzip) : promisify(zlib.deflate)
110
- const result = await fn(Buffer.from(data))
111
- return result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength)
112
- } catch {
113
- // No compression available — return uncompressed
114
- return data
115
- }
116
- }
package/src/config.ts DELETED
@@ -1,35 +0,0 @@
1
- import type { ZeroConfig } from './types'
2
-
3
- /**
4
- * Define a Zero configuration.
5
- * Used in `zero.config.ts` at the project root.
6
- *
7
- * @example
8
- * import { defineConfig } from "@pyreon/zero/config"
9
- *
10
- * export default defineConfig({
11
- * mode: "ssr",
12
- * ssr: { mode: "stream" },
13
- * port: 3000,
14
- * })
15
- */
16
- export function defineConfig(config: ZeroConfig): ZeroConfig {
17
- return config
18
- }
19
-
20
- /** Merge user config with defaults. */
21
- export function resolveConfig(
22
- userConfig: ZeroConfig = {},
23
- ): Required<Pick<ZeroConfig, 'mode' | 'base' | 'port' | 'adapter'>> & ZeroConfig {
24
- return {
25
- mode: 'ssr',
26
- base: '/',
27
- port: 3000,
28
- adapter: 'node',
29
- ...userConfig,
30
- ssr: {
31
- mode: 'string',
32
- ...userConfig.ssr,
33
- },
34
- }
35
- }
package/src/cors.ts DELETED
@@ -1,94 +0,0 @@
1
- import type { Middleware, MiddlewareContext } from '@pyreon/server'
2
-
3
- // ─── CORS middleware ────────────────────────────────────────────────────────
4
-
5
- export interface CorsConfig {
6
- /** Allowed origins. Use `"*"` for any origin. Default: `"*"` */
7
- origin?: string | string[] | ((origin: string) => boolean)
8
- /** Allowed HTTP methods. Default: `["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]` */
9
- methods?: string[]
10
- /** Allowed request headers. Default: `["Content-Type", "Authorization"]` */
11
- allowedHeaders?: string[]
12
- /** Headers exposed to the client. Default: `[]` */
13
- exposedHeaders?: string[]
14
- /** Allow credentials (cookies, auth headers). Default: `false` */
15
- credentials?: boolean
16
- /** Preflight cache duration in seconds. Default: `86400` (24 hours) */
17
- maxAge?: number
18
- }
19
-
20
- const DEFAULT_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
21
- const DEFAULT_HEADERS = ['Content-Type', 'Authorization']
22
-
23
- /**
24
- * CORS middleware — handles preflight requests and sets appropriate
25
- * Access-Control headers on all responses.
26
- *
27
- * @example
28
- * import { corsMiddleware } from "@pyreon/zero/cors"
29
- *
30
- * corsMiddleware({ origin: "https://example.com", credentials: true })
31
- *
32
- * // Allow any origin
33
- * corsMiddleware({ origin: "*" })
34
- *
35
- * // Multiple origins
36
- * corsMiddleware({ origin: ["https://app.com", "https://admin.com"] })
37
- */
38
- export function corsMiddleware(config: CorsConfig = {}): Middleware {
39
- const {
40
- origin = '*',
41
- methods = DEFAULT_METHODS,
42
- allowedHeaders = DEFAULT_HEADERS,
43
- exposedHeaders = [],
44
- credentials = false,
45
- maxAge = 86400,
46
- } = config
47
-
48
- return (ctx: MiddlewareContext) => {
49
- const requestOrigin = ctx.req.headers.get('origin') ?? ''
50
- const resolvedOrigin = resolveOrigin(origin, requestOrigin)
51
-
52
- if (!resolvedOrigin) return
53
-
54
- // Set CORS headers on all responses
55
- ctx.headers.set('Access-Control-Allow-Origin', resolvedOrigin)
56
- if (credentials) {
57
- ctx.headers.set('Access-Control-Allow-Credentials', 'true')
58
- }
59
- if (exposedHeaders.length > 0) {
60
- ctx.headers.set('Access-Control-Expose-Headers', exposedHeaders.join(', '))
61
- }
62
- if (resolvedOrigin !== '*') {
63
- ctx.headers.append('Vary', 'Origin')
64
- }
65
-
66
- // Handle preflight
67
- if (ctx.req.method === 'OPTIONS') {
68
- return new Response(null, {
69
- status: 204,
70
- headers: {
71
- 'Access-Control-Allow-Origin': resolvedOrigin,
72
- 'Access-Control-Allow-Methods': methods.join(', '),
73
- 'Access-Control-Allow-Headers': allowedHeaders.join(', '),
74
- 'Access-Control-Max-Age': String(maxAge),
75
- ...(credentials ? { 'Access-Control-Allow-Credentials': 'true' } : {}),
76
- },
77
- })
78
- }
79
- }
80
- }
81
-
82
- function resolveOrigin(config: CorsConfig['origin'], requestOrigin: string): string | null {
83
- if (config === '*') return '*'
84
- if (typeof config === 'string') {
85
- return config === requestOrigin ? config : null
86
- }
87
- if (typeof config === 'function') {
88
- return config(requestOrigin) ? requestOrigin : null
89
- }
90
- if (Array.isArray(config)) {
91
- return config.includes(requestOrigin) ? requestOrigin : null
92
- }
93
- return null
94
- }
package/src/csp.ts DELETED
@@ -1,226 +0,0 @@
1
- /**
2
- * Content Security Policy middleware.
3
- *
4
- * Generates a CSP header from a typed configuration object.
5
- * Supports all CSP directives, nonces for inline scripts,
6
- * and report-only mode for testing.
7
- *
8
- * @example
9
- * ```ts
10
- * import { cspMiddleware } from "@pyreon/zero"
11
- *
12
- * const csp = cspMiddleware({
13
- * directives: {
14
- * defaultSrc: ["'self'"],
15
- * scriptSrc: ["'self'", "'nonce'"],
16
- * styleSrc: ["'self'", "'unsafe-inline'"],
17
- * imgSrc: ["'self'", "data:", "https:"],
18
- * connectSrc: ["'self'", "https://api.example.com"],
19
- * },
20
- * reportOnly: false,
21
- * })
22
- * ```
23
- */
24
- import type { Middleware, MiddlewareContext } from '@pyreon/server'
25
- import { useRequestLocals } from '@pyreon/server'
26
-
27
- /**
28
- * Read the current CSP nonce in a component.
29
- *
30
- * SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context
31
- * system — fully isolated between concurrent requests via AsyncLocalStorage.
32
- *
33
- * Returns `''` outside an active request context (client-side after
34
- * hydration, dev preview, or any render path that bypassed the CSP
35
- * middleware). Nonces are SSR-only by design: a client-side nonce
36
- * mirrored from the last SSR request is a cross-request bleed waiting
37
- * to happen, and a build-time-baked nonce would defeat the entire CSP
38
- * mechanism. If you need a script-tag nonce, render the script during
39
- * SSR through `useNonce()` so the value the browser sees IS the value
40
- * the response's `Content-Security-Policy` header authorized.
41
- *
42
- * @example
43
- * ```tsx
44
- * import { useNonce } from "@pyreon/zero/csp"
45
- *
46
- * function InlineScript() {
47
- * const nonce = useNonce()
48
- * return <script nonce={nonce}>console.log("safe")</script>
49
- * }
50
- * ```
51
- */
52
- export function useNonce(): string {
53
- const locals = useRequestLocals()
54
- if (locals.cspNonce) return locals.cspNonce as string
55
- return ''
56
- }
57
-
58
- export interface CspDirectives {
59
- defaultSrc?: string[]
60
- scriptSrc?: string[]
61
- styleSrc?: string[]
62
- imgSrc?: string[]
63
- fontSrc?: string[]
64
- connectSrc?: string[]
65
- mediaSrc?: string[]
66
- objectSrc?: string[]
67
- frameSrc?: string[]
68
- childSrc?: string[]
69
- workerSrc?: string[]
70
- frameAncestors?: string[]
71
- formAction?: string[]
72
- baseUri?: string[]
73
- manifestSrc?: string[]
74
- /** Reporting endpoint URL. */
75
- reportUri?: string
76
- /** Reporting endpoint name (CSP Level 3). */
77
- reportTo?: string
78
- /** Upgrade insecure requests. */
79
- upgradeInsecureRequests?: boolean
80
- /** Block all mixed content. */
81
- blockAllMixedContent?: boolean
82
- }
83
-
84
- export interface CspConfig {
85
- /** CSP directives. */
86
- directives: CspDirectives
87
- /**
88
- * Report-only mode — logs violations without blocking.
89
- * Uses Content-Security-Policy-Report-Only header instead.
90
- * Default: false
91
- */
92
- reportOnly?: boolean
93
- }
94
-
95
- const DIRECTIVE_MAP: Record<string, string> = {
96
- defaultSrc: 'default-src',
97
- scriptSrc: 'script-src',
98
- styleSrc: 'style-src',
99
- imgSrc: 'img-src',
100
- fontSrc: 'font-src',
101
- connectSrc: 'connect-src',
102
- mediaSrc: 'media-src',
103
- objectSrc: 'object-src',
104
- frameSrc: 'frame-src',
105
- childSrc: 'child-src',
106
- workerSrc: 'worker-src',
107
- frameAncestors: 'frame-ancestors',
108
- formAction: 'form-action',
109
- baseUri: 'base-uri',
110
- manifestSrc: 'manifest-src',
111
- reportUri: 'report-uri',
112
- reportTo: 'report-to',
113
- }
114
-
115
- /**
116
- * Build a CSP header string from directives.
117
- * Exported for testing.
118
- */
119
- export function buildCspHeader(directives: CspDirectives, nonce?: string): string {
120
- const parts: string[] = []
121
-
122
- for (const [key, cssProp] of Object.entries(DIRECTIVE_MAP)) {
123
- const value = (directives as Record<string, unknown>)[key]
124
- if (!value) continue
125
-
126
- if (Array.isArray(value)) {
127
- // Replace "'nonce'" placeholder with actual nonce
128
- const resolved = nonce
129
- ? value.map((v: string) => (v === "'nonce'" ? `'nonce-${nonce}'` : v))
130
- : value.filter((v: string) => v !== "'nonce'")
131
- parts.push(`${cssProp} ${resolved.join(' ')}`)
132
- } else if (typeof value === 'string') {
133
- parts.push(`${cssProp} ${value}`)
134
- }
135
- }
136
-
137
- if (directives.upgradeInsecureRequests) {
138
- parts.push('upgrade-insecure-requests')
139
- }
140
- if (directives.blockAllMixedContent) {
141
- parts.push('block-all-mixed-content')
142
- }
143
-
144
- return parts.join('; ')
145
- }
146
-
147
- /**
148
- * Generate a cryptographically-random nonce string (base64, 16 bytes).
149
- *
150
- * Throws when `crypto.getRandomValues` is unavailable. CSP nonces protect
151
- * against XSS by gating inline script execution; a predictable nonce
152
- * (`Math.random` ~31 bits of entropy) bypasses CSP entirely. Silent
153
- * degradation here was a security anti-pattern — we surface the
154
- * misconfiguration loudly instead.
155
- *
156
- * Realistic deployments always have `crypto.getRandomValues`: Node 18+,
157
- * Bun, Deno, browsers, edge workers (Cloudflare/Vercel/Netlify), and
158
- * vitest/happy-dom all expose it via `globalThis.crypto`. If you hit
159
- * this throw, your environment is unusual — fix the env, don't downgrade
160
- * the security primitive.
161
- */
162
- function generateNonce(): string {
163
- if (typeof crypto === 'undefined' || !crypto.getRandomValues) {
164
- throw new Error(
165
- '[Pyreon] CSP nonce generation requires `crypto.getRandomValues` (Web Crypto API). ' +
166
- 'No secure RNG is available in this environment. CSP nonces must be cryptographically ' +
167
- 'random — falling back to `Math.random` would silently weaken XSS protection. ' +
168
- 'Ensure Node 18+, Bun, Deno, an edge runtime, or a browser environment.',
169
- )
170
- }
171
- const bytes = new Uint8Array(16)
172
- crypto.getRandomValues(bytes)
173
- // Convert to base64 using btoa
174
- let binary = ''
175
- for (const byte of bytes) binary += String.fromCharCode(byte)
176
- return typeof btoa === 'function'
177
- ? btoa(binary)
178
- : Buffer.from(bytes).toString('base64')
179
- }
180
-
181
- /**
182
- * CSP middleware — sets Content-Security-Policy header.
183
- *
184
- * When directives contain `"'nonce'"`, a fresh nonce is generated per-request
185
- * and attached to `ctx.locals.cspNonce` for use in inline script tags.
186
- *
187
- * @example
188
- * ```ts
189
- * // Apply to all routes
190
- * export default defineConfig({
191
- * middleware: [
192
- * cspMiddleware({
193
- * directives: {
194
- * defaultSrc: ["'self'"],
195
- * scriptSrc: ["'self'", "'nonce'"],
196
- * styleSrc: ["'self'", "'unsafe-inline'"],
197
- * imgSrc: ["'self'", "data:", "https:"],
198
- * },
199
- * }),
200
- * ],
201
- * })
202
- * ```
203
- */
204
- export function cspMiddleware(config: CspConfig): Middleware {
205
- const headerName = config.reportOnly
206
- ? 'Content-Security-Policy-Report-Only'
207
- : 'Content-Security-Policy'
208
-
209
- // Check if nonce is needed
210
- const needsNonce = Object.values(config.directives).some(
211
- (v) => Array.isArray(v) && v.includes("'nonce'"),
212
- )
213
-
214
- // Pre-build header for static case (no nonce)
215
- const staticHeader = needsNonce ? null : buildCspHeader(config.directives)
216
-
217
- return (ctx: MiddlewareContext) => {
218
- if (staticHeader) {
219
- ctx.headers.set(headerName, staticHeader)
220
- } else {
221
- const nonce = generateNonce()
222
- ;(ctx.locals as Record<string, unknown>).cspNonce = nonce
223
- ctx.headers.set(headerName, buildCspHeader(config.directives, nonce))
224
- }
225
- }
226
- }
@@ -1,224 +0,0 @@
1
- import type { ComponentFn } from "@pyreon/core";
2
- import type { RouteRecord } from "@pyreon/router";
3
- import type { Middleware, MiddlewareContext } from "@pyreon/server";
4
- import { createHandler } from "@pyreon/server";
5
- import type { ApiRouteEntry } from "./api-routes";
6
- import { createApiMiddleware } from "./api-routes";
7
- import { createApp } from "./app";
8
- import { render404Page } from "./not-found";
9
- import type { RouteMiddlewareEntry, ZeroConfig } from "./types";
10
-
11
- // ─── Server entry factory ───────────────────────────────────────────────────
12
-
13
- export interface CreateServerOptions {
14
- /** Route definitions. */
15
- routes: RouteRecord[];
16
- /** Zero config. */
17
- config?: ZeroConfig;
18
- /** Additional middleware. */
19
- middleware?: Middleware[];
20
- /** Per-route middleware from virtual:zero/route-middleware. */
21
- routeMiddleware?: RouteMiddlewareEntry[];
22
- /** API route entries from virtual:zero/api-routes. */
23
- apiRoutes?: ApiRouteEntry[];
24
- /** HTML template override. */
25
- template?: string;
26
- /** Client entry path. */
27
- clientEntry?: string;
28
- /** Component to render when no route matches (from _404.tsx). */
29
- notFoundComponent?: ComponentFn;
30
- }
31
-
32
- /**
33
- * Create a middleware that dispatches per-route middleware based on URL pattern matching.
34
- */
35
- function createRouteMiddlewareDispatcher(
36
- entries: RouteMiddlewareEntry[],
37
- ): Middleware {
38
- return async (ctx: MiddlewareContext) => {
39
- for (const entry of entries) {
40
- if (matchPattern(entry.pattern, ctx.path)) {
41
- const mw = Array.isArray(entry.middleware)
42
- ? entry.middleware
43
- : [entry.middleware];
44
- for (const fn of mw) {
45
- const result = await fn(ctx);
46
- if (result) return result;
47
- }
48
- }
49
- }
50
- };
51
- }
52
-
53
- /**
54
- * URL pattern matcher supporting :param and :param* segments.
55
- *
56
- * Rules:
57
- * - Static segments must match exactly
58
- * - `:param` matches a single path segment
59
- * - `:param*` matches all remaining segments (must be last, and path must
60
- * have matched all preceding segments)
61
- * - Path length must match pattern length (unless catch-all)
62
- */
63
- export function matchPattern(pattern: string, path: string): boolean {
64
- const patternParts = pattern.split("/").filter(Boolean);
65
- const pathParts = path.split("/").filter(Boolean);
66
-
67
- for (let i = 0; i < patternParts.length; i++) {
68
- const pp = patternParts[i]!;
69
-
70
- // Catch-all: matches remaining segments, but only if we've matched
71
- // all preceding segments up to this point
72
- if (pp.endsWith("*")) {
73
- // All segments before the catch-all must have matched (we got here)
74
- // and there must be at least one remaining path segment
75
- return i <= pathParts.length;
76
- }
77
-
78
- // No more path segments to match against
79
- if (i >= pathParts.length) return false;
80
-
81
- // Dynamic segment matches any single segment
82
- if (pp.startsWith(":")) continue;
83
-
84
- // Static segment must match exactly
85
- if (pp !== pathParts[i]) return false;
86
- }
87
-
88
- // All pattern parts consumed — path must also be fully consumed
89
- return patternParts.length === pathParts.length;
90
- }
91
-
92
- /**
93
- * Create the SSR request handler for production.
94
- *
95
- * @example
96
- * import { routes } from "virtual:zero/routes"
97
- * import { routeMiddleware } from "virtual:zero/route-middleware"
98
- * import { createServer } from "@pyreon/zero"
99
- *
100
- * export default createServer({ routes, routeMiddleware, apiRoutes })
101
- */
102
- export function createServer(options: CreateServerOptions) {
103
- const config = options.config ?? {};
104
-
105
- const allMiddleware: Middleware[] = [];
106
-
107
- // API routes run first — they short-circuit before SSR
108
- if (options.apiRoutes?.length) {
109
- allMiddleware.push(createApiMiddleware(options.apiRoutes));
110
- }
111
-
112
- // Per-route middleware runs next
113
- if (options.routeMiddleware?.length) {
114
- allMiddleware.push(
115
- createRouteMiddlewareDispatcher(options.routeMiddleware),
116
- );
117
- }
118
-
119
- // Then global middleware from config and options
120
- allMiddleware.push(...(config.middleware ?? []));
121
- allMiddleware.push(...(options.middleware ?? []));
122
-
123
- const { App } = createApp({
124
- routes: options.routes,
125
- routerMode: "history",
126
- // Forward zero's `base` to createRouter so RouterLinks render
127
- // correctly prefixed hrefs during SSR — must match the value
128
- // the client-side `startClient` reads from `__ZERO_BASE__` so
129
- // hydration doesn't mismatch.
130
- ...(config.base && config.base !== "/" ? { base: config.base } : {}),
131
- });
132
-
133
- const handler = createHandler({
134
- App,
135
- routes: options.routes,
136
- middleware: allMiddleware,
137
- mode: config.ssr?.mode ?? "string",
138
- ...(options.template ? { template: options.template } : {}),
139
- ...(options.clientEntry ? { clientEntry: options.clientEntry } : {}),
140
- });
141
-
142
- // M1.2 — Runtime SSR 404 routes through the router (PR L5).
143
- // When a URL doesn't match any leaf, @pyreon/router's resolveRoute
144
- // walks up to the closest parent `notFoundComponent` and builds a
145
- // synthetic chain `[...ancestorLayouts, syntheticLeaf]`. The handler
146
- // renders that chain, producing 404 HTML INSIDE the layout's chrome,
147
- // and reads `resolved.isNotFound` to set HTTP status 404. This
148
- // replaces the pre-M1 URL-pattern wrapper that bypassed the router
149
- // for unmatched URLs and rendered the not-found component standalone
150
- // (no layout wrapping).
151
- //
152
- // `options.notFoundComponent` is a legacy fallback for apps that
153
- // don't carry `_404.tsx` in their routes tree. When set AND the
154
- // routes tree has no reachable `notFoundComponent`, we render the
155
- // standalone shape as a final fallback. The canonical pattern is
156
- // `_404.tsx` inside a `_layout.tsx` directory — that goes through
157
- // PR L5's router-driven path and gets layout chrome for free.
158
- if (!options.notFoundComponent) return handler;
159
-
160
- const NotFound = options.notFoundComponent;
161
- const hasRouteTreeNotFound = routeTreeHasNotFound(options.routes);
162
-
163
- return async (req: Request) => {
164
- // Route-tree notFoundComponent present → handler handles 404 via
165
- // resolveRoute's `isNotFound` fallback (PR L5). Skip the legacy
166
- // wrapper entirely — handler.ts sets status 404 + renders layout
167
- // chrome correctly.
168
- if (hasRouteTreeNotFound) return handler(req);
169
-
170
- // Legacy fallback: routes tree has no notFoundComponent but the
171
- // caller passed `options.notFoundComponent`. Run the URL-pattern
172
- // check + standalone render for backward compat.
173
- const url = new URL(req.url);
174
- const pathname = url.pathname;
175
- if (!routePatternsCache(options.routes).some((p) => matchPattern(p, pathname))) {
176
- const fullHtml = await render404Page(NotFound, options.template);
177
- return new Response(fullHtml, {
178
- status: 404,
179
- headers: { "Content-Type": "text/html; charset=utf-8" },
180
- });
181
- }
182
-
183
- return handler(req);
184
- };
185
- }
186
-
187
- /** Walk the route tree looking for any record with a `notFoundComponent`. */
188
- function routeTreeHasNotFound(routes: RouteRecord[]): boolean {
189
- for (const r of routes) {
190
- if (typeof (r as { notFoundComponent?: unknown }).notFoundComponent === "function") {
191
- return true;
192
- }
193
- if (r.children && routeTreeHasNotFound(r.children as RouteRecord[])) {
194
- return true;
195
- }
196
- }
197
- return false;
198
- }
199
-
200
- /** Lazy cache of flattened patterns — only computed if legacy fallback fires. */
201
- const _routePatternsCache = new WeakMap<RouteRecord[], string[]>();
202
- function routePatternsCache(routes: RouteRecord[]): string[] {
203
- const cached = _routePatternsCache.get(routes);
204
- if (cached) return cached;
205
- const out = flattenRoutePatterns(routes);
206
- _routePatternsCache.set(routes, out);
207
- return out;
208
- }
209
-
210
- /** Extract all URL patterns from a nested route tree. */
211
- function flattenRoutePatterns(routes: RouteRecord[], prefix = ""): string[] {
212
- const patterns: string[] = [];
213
- for (const route of routes) {
214
- const fullPath =
215
- route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
216
- patterns.push(fullPath);
217
- if (route.children) {
218
- patterns.push(
219
- ...flattenRoutePatterns(route.children as RouteRecord[], fullPath),
220
- );
221
- }
222
- }
223
- return patterns;
224
- }