@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/compression.ts
DELETED
|
@@ -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
|
-
}
|
package/src/entry-server.ts
DELETED
|
@@ -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
|
-
}
|