@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.
- package/README.md +17 -6
- package/lib/fs-router-BkbIWqek.js.map +1 -1
- package/lib/{fs-router-jfd1QGLB.js → fs-router-n4VA4lxu.js} +29 -4
- package/lib/fs-router-n4VA4lxu.js.map +1 -0
- package/lib/image.js +50 -1
- package/lib/image.js.map +1 -1
- package/lib/index.js +651 -11
- package/lib/index.js.map +1 -1
- package/lib/link.js +49 -1
- package/lib/link.js.map +1 -1
- package/lib/script.js +49 -1
- package/lib/script.js.map +1 -1
- package/lib/theme.js +50 -1
- package/lib/theme.js.map +1 -1
- package/lib/types/actions.d.ts +57 -0
- package/lib/types/actions.d.ts.map +1 -0
- package/lib/types/api-routes.d.ts +66 -0
- package/lib/types/api-routes.d.ts.map +1 -0
- package/lib/types/compression.d.ts +33 -0
- package/lib/types/compression.d.ts.map +1 -0
- package/lib/types/cors.d.ts +32 -0
- package/lib/types/cors.d.ts.map +1 -0
- package/lib/types/entry-server.d.ts +10 -2
- package/lib/types/entry-server.d.ts.map +1 -1
- package/lib/types/error-overlay.d.ts +6 -0
- package/lib/types/error-overlay.d.ts.map +1 -0
- package/lib/types/fs-router.d.ts +5 -0
- package/lib/types/fs-router.d.ts.map +1 -1
- package/lib/types/image.d.ts +1 -1
- package/lib/types/image.d.ts.map +1 -1
- package/lib/types/index.d.ts +12 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/rate-limit.d.ts +34 -0
- package/lib/types/rate-limit.d.ts.map +1 -0
- package/lib/types/script.d.ts +1 -1
- package/lib/types/script.d.ts.map +1 -1
- package/lib/types/testing.d.ts +85 -0
- package/lib/types/testing.d.ts.map +1 -0
- package/lib/types/theme.d.ts +1 -1
- package/lib/types/theme.d.ts.map +1 -1
- package/lib/types/types.d.ts +5 -0
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types/vite-plugin.d.ts.map +1 -1
- package/package.json +40 -9
- package/src/actions.ts +168 -0
- package/src/api-routes.ts +233 -0
- package/src/compression.ts +107 -0
- package/src/cors.ts +102 -0
- package/src/entry-server.ts +62 -7
- package/src/error-overlay.ts +121 -0
- package/src/fs-router.ts +34 -2
- package/src/index.ts +37 -0
- package/src/rate-limit.ts +122 -0
- package/src/testing.ts +150 -0
- package/src/types.ts +8 -0
- package/src/vite-plugin.ts +75 -10
- 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
|
+
}
|
package/src/entry-server.ts
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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, '&')
|
|
100
|
+
.replace(/</g, '<')
|
|
101
|
+
.replace(/>/g, '>')
|
|
102
|
+
.replace(/"/g, '"')
|
|
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
|
+
}
|