@pyreon/zero 0.1.1 → 0.3.0

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 (57) hide show
  1. package/README.md +17 -6
  2. package/lib/fs-router-BkbIWqek.js.map +1 -1
  3. package/lib/{fs-router-jfd1QGLB.js → fs-router-n4VA4lxu.js} +29 -4
  4. package/lib/fs-router-n4VA4lxu.js.map +1 -0
  5. package/lib/image.js +50 -1
  6. package/lib/image.js.map +1 -1
  7. package/lib/index.js +651 -11
  8. package/lib/index.js.map +1 -1
  9. package/lib/link.js +49 -1
  10. package/lib/link.js.map +1 -1
  11. package/lib/script.js +49 -1
  12. package/lib/script.js.map +1 -1
  13. package/lib/theme.js +50 -1
  14. package/lib/theme.js.map +1 -1
  15. package/lib/types/actions.d.ts +57 -0
  16. package/lib/types/actions.d.ts.map +1 -0
  17. package/lib/types/api-routes.d.ts +66 -0
  18. package/lib/types/api-routes.d.ts.map +1 -0
  19. package/lib/types/compression.d.ts +33 -0
  20. package/lib/types/compression.d.ts.map +1 -0
  21. package/lib/types/cors.d.ts +32 -0
  22. package/lib/types/cors.d.ts.map +1 -0
  23. package/lib/types/entry-server.d.ts +10 -2
  24. package/lib/types/entry-server.d.ts.map +1 -1
  25. package/lib/types/error-overlay.d.ts +6 -0
  26. package/lib/types/error-overlay.d.ts.map +1 -0
  27. package/lib/types/fs-router.d.ts +5 -0
  28. package/lib/types/fs-router.d.ts.map +1 -1
  29. package/lib/types/image.d.ts +1 -1
  30. package/lib/types/image.d.ts.map +1 -1
  31. package/lib/types/index.d.ts +12 -2
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/rate-limit.d.ts +34 -0
  34. package/lib/types/rate-limit.d.ts.map +1 -0
  35. package/lib/types/script.d.ts +1 -1
  36. package/lib/types/script.d.ts.map +1 -1
  37. package/lib/types/testing.d.ts +85 -0
  38. package/lib/types/testing.d.ts.map +1 -0
  39. package/lib/types/theme.d.ts +1 -1
  40. package/lib/types/theme.d.ts.map +1 -1
  41. package/lib/types/types.d.ts +5 -0
  42. package/lib/types/types.d.ts.map +1 -1
  43. package/lib/types/vite-plugin.d.ts.map +1 -1
  44. package/package.json +40 -9
  45. package/src/actions.ts +168 -0
  46. package/src/api-routes.ts +233 -0
  47. package/src/compression.ts +107 -0
  48. package/src/cors.ts +102 -0
  49. package/src/entry-server.ts +62 -7
  50. package/src/error-overlay.ts +121 -0
  51. package/src/fs-router.ts +34 -2
  52. package/src/index.ts +37 -0
  53. package/src/rate-limit.ts +122 -0
  54. package/src/testing.ts +150 -0
  55. package/src/types.ts +8 -0
  56. package/src/vite-plugin.ts +75 -10
  57. package/lib/fs-router-jfd1QGLB.js.map +0 -1
@@ -0,0 +1,233 @@
1
+ import type { Middleware, MiddlewareContext } from '@pyreon/server'
2
+
3
+ // ─── Types ───────────────────────────────────────────────────────────────────
4
+
5
+ /** HTTP methods supported by API routes. */
6
+ export type HttpMethod =
7
+ | 'GET'
8
+ | 'POST'
9
+ | 'PUT'
10
+ | 'PATCH'
11
+ | 'DELETE'
12
+ | 'HEAD'
13
+ | 'OPTIONS'
14
+
15
+ /** Context passed to API route handlers. */
16
+ export interface ApiContext {
17
+ /** The incoming request. */
18
+ request: Request
19
+ /** Parsed URL. */
20
+ url: URL
21
+ /** URL path. */
22
+ path: string
23
+ /** Dynamic route parameters (e.g., { id: "123" }). */
24
+ params: Record<string, string>
25
+ /** Request headers. */
26
+ headers: Headers
27
+ }
28
+
29
+ /** An API route handler function. */
30
+ export type ApiHandler = (ctx: ApiContext) => Response | Promise<Response>
31
+
32
+ /** An API route module — exports named HTTP method handlers. */
33
+ export interface ApiRouteModule {
34
+ GET?: ApiHandler
35
+ POST?: ApiHandler
36
+ PUT?: ApiHandler
37
+ PATCH?: ApiHandler
38
+ DELETE?: ApiHandler
39
+ HEAD?: ApiHandler
40
+ OPTIONS?: ApiHandler
41
+ }
42
+
43
+ /** A registered API route entry. */
44
+ export interface ApiRouteEntry {
45
+ /** URL pattern (e.g., "/api/posts/:id"). */
46
+ pattern: string
47
+ /** The route module with method handlers. */
48
+ module: ApiRouteModule
49
+ }
50
+
51
+ // ─── Pattern matching ────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Match a URL path against an API route pattern.
55
+ * Returns extracted params or null if no match.
56
+ */
57
+ export function matchApiRoute(
58
+ pattern: string,
59
+ path: string,
60
+ ): Record<string, string> | null {
61
+ const patternParts = pattern.split('/').filter(Boolean)
62
+ const pathParts = path.split('/').filter(Boolean)
63
+ const params: Record<string, string> = {}
64
+
65
+ for (let i = 0; i < patternParts.length; i++) {
66
+ const pp = patternParts[i]
67
+
68
+ // Catch-all: :param*
69
+ if (pp.endsWith('*')) {
70
+ const paramName = pp.slice(1, -1)
71
+ 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
+ params[pp.slice(1)] = pathParts[i]
81
+ continue
82
+ }
83
+
84
+ // Static segment
85
+ if (pp !== pathParts[i]) return null
86
+ }
87
+
88
+ return patternParts.length === pathParts.length ? params : null
89
+ }
90
+
91
+ // ─── Middleware ───────────────────────────────────────────────────────────────
92
+
93
+ const HTTP_METHODS: HttpMethod[] = [
94
+ 'GET',
95
+ 'POST',
96
+ 'PUT',
97
+ 'PATCH',
98
+ 'DELETE',
99
+ 'HEAD',
100
+ 'OPTIONS',
101
+ ]
102
+
103
+ /**
104
+ * Create a middleware that dispatches API route requests.
105
+ * API routes are matched by URL pattern and HTTP method.
106
+ */
107
+ export function createApiMiddleware(routes: ApiRouteEntry[]): Middleware {
108
+ return async (ctx: MiddlewareContext) => {
109
+ for (const route of routes) {
110
+ const params = matchApiRoute(route.pattern, ctx.path)
111
+ if (!params) continue
112
+
113
+ const method = ctx.req.method.toUpperCase() as HttpMethod
114
+ const handler = route.module[method]
115
+
116
+ if (!handler) {
117
+ // Route matched but method not supported
118
+ const allowed = HTTP_METHODS.filter((m) => route.module[m]).join(', ')
119
+ return new Response(null, {
120
+ status: 405,
121
+ headers: {
122
+ Allow: allowed,
123
+ 'Content-Type': 'application/json',
124
+ },
125
+ })
126
+ }
127
+
128
+ return handler({
129
+ request: ctx.req,
130
+ url: ctx.url,
131
+ path: ctx.path,
132
+ params,
133
+ headers: ctx.req.headers,
134
+ })
135
+ }
136
+ }
137
+ }
138
+
139
+ // ─── Virtual module generation ───────────────────────────────────────────────
140
+
141
+ /**
142
+ * Detect whether a route file is an API route.
143
+ * API routes are `.ts` or `.js` files inside an `api/` directory.
144
+ */
145
+ export function isApiRoute(filePath: string): boolean {
146
+ const normalized = filePath.replace(/\\/g, '/')
147
+ return (
148
+ normalized.startsWith('api/') &&
149
+ (normalized.endsWith('.ts') || normalized.endsWith('.js')) &&
150
+ !normalized.endsWith('.tsx') &&
151
+ !normalized.endsWith('.jsx')
152
+ )
153
+ }
154
+
155
+ /**
156
+ * Convert an API route file path to a URL pattern.
157
+ *
158
+ * Examples:
159
+ * "api/posts.ts" → "/api/posts"
160
+ * "api/posts/index.ts" → "/api/posts"
161
+ * "api/posts/[id].ts" → "/api/posts/:id"
162
+ * "api/[...path].ts" → "/api/:path*"
163
+ */
164
+ export function apiFilePathToPattern(filePath: string): string {
165
+ let route = filePath
166
+ // Remove extension
167
+ for (const ext of ['.ts', '.js']) {
168
+ if (route.endsWith(ext)) {
169
+ route = route.slice(0, -ext.length)
170
+ break
171
+ }
172
+ }
173
+
174
+ const segments = route.split('/')
175
+ const urlSegments: string[] = []
176
+
177
+ for (const seg of segments) {
178
+ if (seg === 'index') continue
179
+
180
+ // Catch-all: [...param]
181
+ const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/)
182
+ if (catchAll) {
183
+ urlSegments.push(`:${catchAll[1]}*`)
184
+ continue
185
+ }
186
+
187
+ // Dynamic: [param]
188
+ const dynamic = seg.match(/^\[(\w+)\]$/)
189
+ if (dynamic) {
190
+ urlSegments.push(`:${dynamic[1]}`)
191
+ continue
192
+ }
193
+
194
+ urlSegments.push(seg)
195
+ }
196
+
197
+ return `/${urlSegments.join('/')}`
198
+ }
199
+
200
+ /**
201
+ * Generate a virtual module that exports API route entries.
202
+ * Each entry maps a URL pattern to a module with HTTP method handlers.
203
+ */
204
+ export function generateApiRouteModule(
205
+ files: string[],
206
+ routesDir: string,
207
+ ): string {
208
+ const apiFiles = files.filter(isApiRoute)
209
+
210
+ if (apiFiles.length === 0) {
211
+ return 'export const apiRoutes = []\n'
212
+ }
213
+
214
+ const imports: string[] = []
215
+ const entries: string[] = []
216
+
217
+ for (let i = 0; i < apiFiles.length; i++) {
218
+ const name = `_api${i}`
219
+ const fullPath = `${routesDir}/${apiFiles[i]}`
220
+ const pattern = apiFilePathToPattern(apiFiles[i])
221
+
222
+ imports.push(`import * as ${name} from "${fullPath}"`)
223
+ entries.push(` { pattern: ${JSON.stringify(pattern)}, module: ${name} }`)
224
+ }
225
+
226
+ return [
227
+ ...imports,
228
+ '',
229
+ 'export const apiRoutes = [',
230
+ entries.join(',\n'),
231
+ ']',
232
+ ].join('\n')
233
+ }
@@ -0,0 +1,107 @@
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(
26
+ config: CompressionConfig = {},
27
+ ): Middleware {
28
+ const { threshold = 1024, encodings = ['gzip', 'deflate'] } = config
29
+
30
+ return (ctx: MiddlewareContext) => {
31
+ const acceptEncoding = ctx.req.headers.get('accept-encoding') ?? ''
32
+
33
+ // Find the best supported encoding
34
+ const encoding = encodings.find((enc) => acceptEncoding.includes(enc))
35
+ if (!encoding) return
36
+
37
+ // Store the encoding choice for post-processing
38
+ ctx.locals.__compressionEncoding = encoding
39
+ ctx.locals.__compressionThreshold = threshold
40
+ ctx.headers.append('Vary', 'Accept-Encoding')
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Compress a Response body if it meets the criteria.
46
+ * Use this to post-process responses after the handler runs.
47
+ *
48
+ * @example
49
+ * const response = await handler(request)
50
+ * const compressed = await compressResponse(response, 'gzip', 1024)
51
+ */
52
+ export async function compressResponse(
53
+ response: Response,
54
+ encoding: 'gzip' | 'deflate',
55
+ threshold: number,
56
+ ): Promise<Response> {
57
+ const contentType = response.headers.get('content-type') ?? ''
58
+
59
+ // Only compress text-based content
60
+ if (!isCompressible(contentType)) return response
61
+
62
+ // Skip if already encoded
63
+ if (response.headers.get('content-encoding')) return response
64
+
65
+ const body = await response.arrayBuffer()
66
+
67
+ // Skip below threshold
68
+ if (body.byteLength < threshold) return response
69
+
70
+ const compressed = await compress(body, encoding)
71
+
72
+ const headers = new Headers(response.headers)
73
+ headers.set('Content-Encoding', encoding)
74
+ headers.delete('Content-Length')
75
+ headers.append('Vary', 'Accept-Encoding')
76
+
77
+ return new Response(compressed, {
78
+ status: response.status,
79
+ statusText: response.statusText,
80
+ headers,
81
+ })
82
+ }
83
+
84
+ const COMPRESSIBLE_TYPES = [
85
+ 'text/',
86
+ 'application/json',
87
+ 'application/javascript',
88
+ 'application/xml',
89
+ 'application/xhtml+xml',
90
+ 'image/svg+xml',
91
+ ]
92
+
93
+ /** Check if a content type is compressible. Exported for testing. */
94
+ export function isCompressible(contentType: string): boolean {
95
+ return COMPRESSIBLE_TYPES.some((t) => contentType.includes(t))
96
+ }
97
+
98
+ async function compress(
99
+ data: ArrayBuffer,
100
+ encoding: 'gzip' | 'deflate',
101
+ ): Promise<ArrayBuffer> {
102
+ const format = encoding === 'gzip' ? 'gzip' : 'deflate'
103
+ const stream = new Blob([data])
104
+ .stream()
105
+ .pipeThrough(new CompressionStream(format))
106
+ return new Response(stream).arrayBuffer()
107
+ }
package/src/cors.ts ADDED
@@ -0,0 +1,102 @@
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(
61
+ 'Access-Control-Expose-Headers',
62
+ exposedHeaders.join(', '),
63
+ )
64
+ }
65
+ if (resolvedOrigin !== '*') {
66
+ ctx.headers.append('Vary', 'Origin')
67
+ }
68
+
69
+ // Handle preflight
70
+ if (ctx.req.method === 'OPTIONS') {
71
+ return new Response(null, {
72
+ status: 204,
73
+ headers: {
74
+ 'Access-Control-Allow-Origin': resolvedOrigin,
75
+ 'Access-Control-Allow-Methods': methods.join(', '),
76
+ 'Access-Control-Allow-Headers': allowedHeaders.join(', '),
77
+ 'Access-Control-Max-Age': String(maxAge),
78
+ ...(credentials
79
+ ? { 'Access-Control-Allow-Credentials': 'true' }
80
+ : {}),
81
+ },
82
+ })
83
+ }
84
+ }
85
+ }
86
+
87
+ function resolveOrigin(
88
+ config: CorsConfig['origin'],
89
+ requestOrigin: string,
90
+ ): string | null {
91
+ if (config === '*') return '*'
92
+ if (typeof config === 'string') {
93
+ return config === requestOrigin ? config : null
94
+ }
95
+ if (typeof config === 'function') {
96
+ return config(requestOrigin) ? requestOrigin : null
97
+ }
98
+ if (Array.isArray(config)) {
99
+ return config.includes(requestOrigin) ? requestOrigin : null
100
+ }
101
+ return null
102
+ }
@@ -1,8 +1,10 @@
1
1
  import type { RouteRecord } from '@pyreon/router'
2
- import type { Middleware } from '@pyreon/server'
2
+ import type { Middleware, MiddlewareContext } from '@pyreon/server'
3
3
  import { createHandler } from '@pyreon/server'
4
+ import type { ApiRouteEntry } from './api-routes'
5
+ import { createApiMiddleware } from './api-routes'
4
6
  import { createApp } from './app'
5
- import type { ZeroConfig } from './types'
7
+ import type { RouteMiddlewareEntry, ZeroConfig } from './types'
6
8
 
7
9
  // ─── Server entry factory ───────────────────────────────────────────────────
8
10
 
@@ -13,27 +15,80 @@ export interface CreateServerOptions {
13
15
  config?: ZeroConfig
14
16
  /** Additional middleware. */
15
17
  middleware?: Middleware[]
18
+ /** Per-route middleware from virtual:zero/route-middleware. */
19
+ routeMiddleware?: RouteMiddlewareEntry[]
20
+ /** API route entries from virtual:zero/api-routes. */
21
+ apiRoutes?: ApiRouteEntry[]
16
22
  /** HTML template override. */
17
23
  template?: string
18
24
  /** Client entry path. */
19
25
  clientEntry?: string
20
26
  }
21
27
 
28
+ /**
29
+ * Create a middleware that dispatches per-route middleware based on URL pattern matching.
30
+ */
31
+ function createRouteMiddlewareDispatcher(
32
+ entries: RouteMiddlewareEntry[],
33
+ ): Middleware {
34
+ return async (ctx: MiddlewareContext) => {
35
+ for (const entry of entries) {
36
+ if (matchPattern(entry.pattern, ctx.path)) {
37
+ const mw = Array.isArray(entry.middleware)
38
+ ? entry.middleware
39
+ : [entry.middleware]
40
+ for (const fn of mw) {
41
+ const result = await fn(ctx)
42
+ if (result) return result
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ /** Simple URL pattern matcher supporting :param and :param* segments. */
50
+ export function matchPattern(pattern: string, path: string): boolean {
51
+ const patternParts = pattern.split('/').filter(Boolean)
52
+ const pathParts = path.split('/').filter(Boolean)
53
+
54
+ for (let i = 0; i < patternParts.length; i++) {
55
+ const pp = patternParts[i]
56
+ if (pp.endsWith('*')) return true // catch-all matches everything after
57
+ if (pp.startsWith(':')) continue // dynamic segment matches anything
58
+ if (pp !== pathParts[i]) return false
59
+ }
60
+
61
+ return patternParts.length === pathParts.length
62
+ }
63
+
22
64
  /**
23
65
  * Create the SSR request handler for production.
24
66
  *
25
67
  * @example
26
68
  * import { routes } from "virtual:zero/routes"
69
+ * import { routeMiddleware } from "virtual:zero/route-middleware"
27
70
  * import { createServer } from "@pyreon/zero"
28
71
  *
29
- * export default createServer({ routes })
72
+ * export default createServer({ routes, routeMiddleware, apiRoutes })
30
73
  */
31
74
  export function createServer(options: CreateServerOptions) {
32
75
  const config = options.config ?? {}
33
- const allMiddleware = [
34
- ...(config.middleware ?? []),
35
- ...(options.middleware ?? []),
36
- ]
76
+
77
+ const allMiddleware: Middleware[] = []
78
+
79
+ // API routes run first — they short-circuit before SSR
80
+ if (options.apiRoutes?.length) {
81
+ allMiddleware.push(createApiMiddleware(options.apiRoutes))
82
+ }
83
+
84
+ // Per-route middleware runs next
85
+ if (options.routeMiddleware?.length) {
86
+ allMiddleware.push(createRouteMiddlewareDispatcher(options.routeMiddleware))
87
+ }
88
+
89
+ // Then global middleware from config and options
90
+ allMiddleware.push(...(config.middleware ?? []))
91
+ allMiddleware.push(...(options.middleware ?? []))
37
92
 
38
93
  const { App } = createApp({
39
94
  routes: options.routes,
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Dev-only error overlay for SSR/loader errors.
3
+ * Renders a styled HTML page with the error stack trace.
4
+ */
5
+ export function renderErrorOverlay(error: Error): string {
6
+ const title = escapeHtml(error.message || 'Unknown error')
7
+ const stack = escapeHtml(error.stack || '')
8
+
9
+ return `<!DOCTYPE html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="UTF-8">
13
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
14
+ <title>SSR Error — Pyreon Zero</title>
15
+ <style>
16
+ * { margin: 0; padding: 0; box-sizing: border-box; }
17
+ body {
18
+ font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
19
+ background: #1a1a2e;
20
+ color: #e0e0e0;
21
+ min-height: 100vh;
22
+ padding: 2rem;
23
+ }
24
+ .overlay {
25
+ max-width: 900px;
26
+ margin: 0 auto;
27
+ }
28
+ .header {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 0.75rem;
32
+ margin-bottom: 1.5rem;
33
+ }
34
+ .badge {
35
+ background: #e74c3c;
36
+ color: white;
37
+ padding: 0.25rem 0.75rem;
38
+ border-radius: 4px;
39
+ font-size: 0.75rem;
40
+ font-weight: 600;
41
+ text-transform: uppercase;
42
+ letter-spacing: 0.05em;
43
+ }
44
+ .label {
45
+ color: #888;
46
+ font-size: 0.85rem;
47
+ }
48
+ .message {
49
+ font-size: 1.25rem;
50
+ color: #ff6b6b;
51
+ margin-bottom: 1.5rem;
52
+ line-height: 1.5;
53
+ word-break: break-word;
54
+ }
55
+ .stack {
56
+ background: #16213e;
57
+ border: 1px solid #2a2a4a;
58
+ border-radius: 8px;
59
+ padding: 1.25rem;
60
+ overflow-x: auto;
61
+ font-size: 0.8rem;
62
+ line-height: 1.7;
63
+ white-space: pre-wrap;
64
+ word-break: break-all;
65
+ }
66
+ .stack .at { color: #888; }
67
+ .stack .file { color: #4ecdc4; }
68
+ .hint {
69
+ margin-top: 1.5rem;
70
+ padding: 1rem;
71
+ background: #1e2a45;
72
+ border-radius: 6px;
73
+ border-left: 3px solid #3498db;
74
+ font-size: 0.8rem;
75
+ color: #aaa;
76
+ line-height: 1.5;
77
+ }
78
+ </style>
79
+ </head>
80
+ <body>
81
+ <div class="overlay">
82
+ <div class="header">
83
+ <span class="badge">SSR Error</span>
84
+ <span class="label">Pyreon Zero — Dev Mode</span>
85
+ </div>
86
+ <div class="message">${title}</div>
87
+ <pre class="stack">${formatStack(stack)}</pre>
88
+ <div class="hint">
89
+ This error occurred during server-side rendering. Check the terminal for
90
+ the full stack trace. This overlay is only shown in development.
91
+ </div>
92
+ </div>
93
+ </body>
94
+ </html>`
95
+ }
96
+
97
+ function escapeHtml(str: string): string {
98
+ return str
99
+ .replace(/&/g, '&amp;')
100
+ .replace(/</g, '&lt;')
101
+ .replace(/>/g, '&gt;')
102
+ .replace(/"/g, '&quot;')
103
+ }
104
+
105
+ function formatStack(stack: string): string {
106
+ return stack
107
+ .split('\n')
108
+ .map((line) => {
109
+ if (line.includes('at ')) {
110
+ const fileMatch = line.match(/\(([^)]+)\)/)
111
+ if (fileMatch) {
112
+ return line.replace(
113
+ fileMatch[0],
114
+ `(<span class="file">${fileMatch[1]}</span>)`,
115
+ )
116
+ }
117
+ }
118
+ return line
119
+ })
120
+ .join('\n')
121
+ }