@pyreon/zero 0.1.1 → 0.2.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
package/src/fs-router.ts
CHANGED
|
@@ -263,7 +263,7 @@ export function generateRouteModule(
|
|
|
263
263
|
`${indent} component: ${comp}`,
|
|
264
264
|
`${indent} loader: ${mod}.loader`,
|
|
265
265
|
`${indent} beforeEnter: ${mod}.guard`,
|
|
266
|
-
`${indent} meta: ${mod}.
|
|
266
|
+
`${indent} meta: { ...${mod}.meta, renderMode: ${mod}.renderMode }`,
|
|
267
267
|
]
|
|
268
268
|
|
|
269
269
|
if (errorName) {
|
|
@@ -290,7 +290,7 @@ export function generateRouteModule(
|
|
|
290
290
|
`${indent}component: ${layoutComp}`,
|
|
291
291
|
`${indent}loader: ${layoutMod}.loader`,
|
|
292
292
|
`${indent}beforeEnter: ${layoutMod}.guard`,
|
|
293
|
-
`${indent}meta: ${layoutMod}.
|
|
293
|
+
`${indent}meta: { ...${layoutMod}.meta, renderMode: ${layoutMod}.renderMode }`,
|
|
294
294
|
]
|
|
295
295
|
if (errorName) {
|
|
296
296
|
props.push(`${indent}errorComponent: ${errorName}`)
|
|
@@ -353,6 +353,38 @@ export function generateRouteModule(
|
|
|
353
353
|
].join('\n')
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Generate a virtual module that maps URL patterns to their middleware exports.
|
|
358
|
+
* Used by the server entry to dispatch per-route middleware.
|
|
359
|
+
*/
|
|
360
|
+
export function generateMiddlewareModule(
|
|
361
|
+
files: string[],
|
|
362
|
+
routesDir: string,
|
|
363
|
+
): string {
|
|
364
|
+
const routes = parseFileRoutes(files)
|
|
365
|
+
const imports: string[] = []
|
|
366
|
+
const entries: string[] = []
|
|
367
|
+
let counter = 0
|
|
368
|
+
|
|
369
|
+
for (const route of routes) {
|
|
370
|
+
if (route.isLayout || route.isError || route.isLoading) continue
|
|
371
|
+
const name = `_mw${counter++}`
|
|
372
|
+
const fullPath = `${routesDir}/${route.filePath}`
|
|
373
|
+
imports.push(`import { middleware as ${name} } from "${fullPath}"`)
|
|
374
|
+
entries.push(
|
|
375
|
+
` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`,
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return [
|
|
380
|
+
...imports,
|
|
381
|
+
'',
|
|
382
|
+
`export const routeMiddleware = [`,
|
|
383
|
+
entries.join(',\n'),
|
|
384
|
+
`].filter(e => e.middleware)`,
|
|
385
|
+
].join('\n')
|
|
386
|
+
}
|
|
387
|
+
|
|
356
388
|
/**
|
|
357
389
|
* Scan a directory for route files.
|
|
358
390
|
* Returns paths relative to the routes directory.
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { zeroPlugin as default } from './vite-plugin'
|
|
|
13
13
|
|
|
14
14
|
export {
|
|
15
15
|
filePathToUrlPath,
|
|
16
|
+
generateMiddlewareModule,
|
|
16
17
|
generateRouteModule,
|
|
17
18
|
parseFileRoutes,
|
|
18
19
|
scanRouteFiles,
|
|
@@ -104,6 +105,41 @@ export {
|
|
|
104
105
|
seoPlugin,
|
|
105
106
|
} from './seo'
|
|
106
107
|
|
|
108
|
+
// ─── API routes ──────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export type {
|
|
111
|
+
ApiContext,
|
|
112
|
+
ApiHandler,
|
|
113
|
+
ApiRouteEntry,
|
|
114
|
+
ApiRouteModule,
|
|
115
|
+
HttpMethod,
|
|
116
|
+
} from './api-routes'
|
|
117
|
+
export { createApiMiddleware, generateApiRouteModule } from './api-routes'
|
|
118
|
+
|
|
119
|
+
// ─── CORS ────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export type { CorsConfig } from './cors'
|
|
122
|
+
export { corsMiddleware } from './cors'
|
|
123
|
+
|
|
124
|
+
// ─── Rate limiting ──────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export type { RateLimitConfig } from './rate-limit'
|
|
127
|
+
export { rateLimitMiddleware } from './rate-limit'
|
|
128
|
+
|
|
129
|
+
// ─── Compression ────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export type { CompressionConfig } from './compression'
|
|
132
|
+
export {
|
|
133
|
+
compressionMiddleware,
|
|
134
|
+
compressResponse,
|
|
135
|
+
isCompressible,
|
|
136
|
+
} from './compression'
|
|
137
|
+
|
|
138
|
+
// ─── Actions ─────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
export type { Action, ActionContext, ActionHandler } from './actions'
|
|
141
|
+
export { createActionMiddleware, defineAction } from './actions'
|
|
142
|
+
|
|
107
143
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
108
144
|
|
|
109
145
|
export type {
|
|
@@ -114,6 +150,7 @@ export type {
|
|
|
114
150
|
LoaderContext,
|
|
115
151
|
RenderMode,
|
|
116
152
|
RouteMeta,
|
|
153
|
+
RouteMiddlewareEntry,
|
|
117
154
|
RouteModule,
|
|
118
155
|
ZeroConfig,
|
|
119
156
|
} from './types'
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Middleware, MiddlewareContext } from '@pyreon/server'
|
|
2
|
+
|
|
3
|
+
// ─── Rate limiting middleware ───────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface RateLimitConfig {
|
|
6
|
+
/** Maximum requests per window. Default: `100` */
|
|
7
|
+
max?: number
|
|
8
|
+
/** Time window in seconds. Default: `60` */
|
|
9
|
+
window?: number
|
|
10
|
+
/** Function to extract the client identifier. Default: IP from headers. */
|
|
11
|
+
keyFn?: (ctx: MiddlewareContext) => string
|
|
12
|
+
/** Custom response when rate limited. */
|
|
13
|
+
onLimit?: (ctx: MiddlewareContext) => Response
|
|
14
|
+
/** URL patterns to rate limit (glob-style). Default: all paths. */
|
|
15
|
+
include?: string[]
|
|
16
|
+
/** URL patterns to exclude from rate limiting. */
|
|
17
|
+
exclude?: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface RateLimitEntry {
|
|
21
|
+
count: number
|
|
22
|
+
resetAt: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rate limiting middleware — limits requests per client within a time window.
|
|
27
|
+
* Uses an in-memory store (suitable for single-instance deployments).
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
|
|
31
|
+
*
|
|
32
|
+
* // 100 requests per minute (default)
|
|
33
|
+
* rateLimitMiddleware()
|
|
34
|
+
*
|
|
35
|
+
* // Strict API rate limiting
|
|
36
|
+
* rateLimitMiddleware({
|
|
37
|
+
* max: 20,
|
|
38
|
+
* window: 60,
|
|
39
|
+
* include: ["/api/*"],
|
|
40
|
+
* })
|
|
41
|
+
*/
|
|
42
|
+
export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
|
|
43
|
+
const {
|
|
44
|
+
max = 100,
|
|
45
|
+
window: windowSec = 60,
|
|
46
|
+
keyFn = defaultKeyFn,
|
|
47
|
+
onLimit,
|
|
48
|
+
include,
|
|
49
|
+
exclude,
|
|
50
|
+
} = config
|
|
51
|
+
|
|
52
|
+
const windowMs = windowSec * 1000
|
|
53
|
+
const store = new Map<string, RateLimitEntry>()
|
|
54
|
+
|
|
55
|
+
// Periodic cleanup of expired entries
|
|
56
|
+
const cleanupInterval = setInterval(() => {
|
|
57
|
+
const now = Date.now()
|
|
58
|
+
for (const [key, entry] of store) {
|
|
59
|
+
if (entry.resetAt <= now) store.delete(key)
|
|
60
|
+
}
|
|
61
|
+
}, windowMs)
|
|
62
|
+
|
|
63
|
+
// Allow GC to clean up the interval
|
|
64
|
+
if (typeof cleanupInterval === 'object' && 'unref' in cleanupInterval) {
|
|
65
|
+
cleanupInterval.unref()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (ctx: MiddlewareContext) => {
|
|
69
|
+
// Check include/exclude patterns
|
|
70
|
+
if (include && !include.some((p) => matchSimpleGlob(p, ctx.path))) return
|
|
71
|
+
if (exclude?.some((p) => matchSimpleGlob(p, ctx.path))) return
|
|
72
|
+
|
|
73
|
+
const key = keyFn(ctx)
|
|
74
|
+
const now = Date.now()
|
|
75
|
+
let entry = store.get(key)
|
|
76
|
+
|
|
77
|
+
if (!entry || entry.resetAt <= now) {
|
|
78
|
+
entry = { count: 0, resetAt: now + windowMs }
|
|
79
|
+
store.set(key, entry)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
entry.count++
|
|
83
|
+
const remaining = Math.max(0, max - entry.count)
|
|
84
|
+
const resetSeconds = Math.ceil((entry.resetAt - now) / 1000)
|
|
85
|
+
|
|
86
|
+
// Set rate limit headers on all responses
|
|
87
|
+
ctx.headers.set('X-RateLimit-Limit', String(max))
|
|
88
|
+
ctx.headers.set('X-RateLimit-Remaining', String(remaining))
|
|
89
|
+
ctx.headers.set('X-RateLimit-Reset', String(resetSeconds))
|
|
90
|
+
|
|
91
|
+
if (entry.count > max) {
|
|
92
|
+
if (onLimit) return onLimit(ctx)
|
|
93
|
+
|
|
94
|
+
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
|
95
|
+
status: 429,
|
|
96
|
+
headers: {
|
|
97
|
+
'Content-Type': 'application/json',
|
|
98
|
+
'Retry-After': String(resetSeconds),
|
|
99
|
+
'X-RateLimit-Limit': String(max),
|
|
100
|
+
'X-RateLimit-Remaining': '0',
|
|
101
|
+
'X-RateLimit-Reset': String(resetSeconds),
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function defaultKeyFn(ctx: MiddlewareContext): string {
|
|
109
|
+
return (
|
|
110
|
+
ctx.req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
|
111
|
+
ctx.req.headers.get('x-real-ip') ??
|
|
112
|
+
'unknown'
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Simple glob matching for path patterns. Supports trailing `*`. */
|
|
117
|
+
function matchSimpleGlob(pattern: string, path: string): boolean {
|
|
118
|
+
if (pattern.endsWith('/*')) {
|
|
119
|
+
return path.startsWith(pattern.slice(0, -1))
|
|
120
|
+
}
|
|
121
|
+
return pattern === path
|
|
122
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Middleware, MiddlewareContext } from '@pyreon/server'
|
|
2
|
+
import type { ApiHandler, ApiRouteEntry } from './api-routes'
|
|
3
|
+
import { createApiMiddleware } from './api-routes'
|
|
4
|
+
|
|
5
|
+
// ─── Test helpers for Zero applications ─────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a mock MiddlewareContext for testing middleware.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { createTestContext } from "@pyreon/zero/testing"
|
|
12
|
+
*
|
|
13
|
+
* const ctx = createTestContext("/api/posts", { method: "POST", body: { title: "Hello" } })
|
|
14
|
+
* const result = await myMiddleware(ctx)
|
|
15
|
+
*/
|
|
16
|
+
export function createTestContext(
|
|
17
|
+
path: string,
|
|
18
|
+
options: {
|
|
19
|
+
method?: string
|
|
20
|
+
headers?: Record<string, string>
|
|
21
|
+
body?: unknown
|
|
22
|
+
} = {},
|
|
23
|
+
): MiddlewareContext {
|
|
24
|
+
const { method = 'GET', headers = {}, body } = options
|
|
25
|
+
const url = new URL(`http://localhost${path}`)
|
|
26
|
+
|
|
27
|
+
const requestHeaders: Record<string, string> = { ...headers }
|
|
28
|
+
let requestBody: string | undefined
|
|
29
|
+
|
|
30
|
+
if (body !== undefined) {
|
|
31
|
+
requestHeaders['Content-Type'] = 'application/json'
|
|
32
|
+
requestBody = JSON.stringify(body)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const req = new Request(url.toString(), {
|
|
36
|
+
method,
|
|
37
|
+
headers: requestHeaders,
|
|
38
|
+
body: requestBody,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
req,
|
|
43
|
+
url,
|
|
44
|
+
path,
|
|
45
|
+
headers: new Headers(),
|
|
46
|
+
locals: {},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Test a middleware by running it with a mock context and returning
|
|
52
|
+
* the result along with the response headers it set.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* import { testMiddleware } from "@pyreon/zero/testing"
|
|
56
|
+
*
|
|
57
|
+
* const { response, headers } = await testMiddleware(
|
|
58
|
+
* corsMiddleware({ origin: "*" }),
|
|
59
|
+
* "/api/posts"
|
|
60
|
+
* )
|
|
61
|
+
* expect(headers.get("Access-Control-Allow-Origin")).toBe("*")
|
|
62
|
+
*/
|
|
63
|
+
export async function testMiddleware(
|
|
64
|
+
middleware: Middleware,
|
|
65
|
+
path: string,
|
|
66
|
+
options: {
|
|
67
|
+
method?: string
|
|
68
|
+
headers?: Record<string, string>
|
|
69
|
+
body?: unknown
|
|
70
|
+
} = {},
|
|
71
|
+
): Promise<{ response: Response | undefined; headers: Headers }> {
|
|
72
|
+
const ctx = createTestContext(path, options)
|
|
73
|
+
const response = (await middleware(ctx)) as Response | undefined
|
|
74
|
+
return { response, headers: ctx.headers }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a test server for API routes. Returns a function that
|
|
79
|
+
* accepts Request objects and dispatches to the correct handler.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* import { createTestApiServer } from "@pyreon/zero/testing"
|
|
83
|
+
*
|
|
84
|
+
* const server = createTestApiServer([
|
|
85
|
+
* { pattern: "/api/posts", module: postsApi },
|
|
86
|
+
* { pattern: "/api/posts/:id", module: postByIdApi },
|
|
87
|
+
* ])
|
|
88
|
+
*
|
|
89
|
+
* const response = await server.request("/api/posts")
|
|
90
|
+
* expect(response.status).toBe(200)
|
|
91
|
+
*
|
|
92
|
+
* const data = await server.request("/api/posts", { method: "POST", body: { title: "Hi" } })
|
|
93
|
+
* expect(data.status).toBe(201)
|
|
94
|
+
*/
|
|
95
|
+
export function createTestApiServer(routes: ApiRouteEntry[]) {
|
|
96
|
+
const middleware = createApiMiddleware(routes)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
async request(
|
|
100
|
+
path: string,
|
|
101
|
+
options: {
|
|
102
|
+
method?: string
|
|
103
|
+
headers?: Record<string, string>
|
|
104
|
+
body?: unknown
|
|
105
|
+
} = {},
|
|
106
|
+
): Promise<Response> {
|
|
107
|
+
const ctx = createTestContext(path, options)
|
|
108
|
+
const result = await middleware(ctx)
|
|
109
|
+
if (!result) {
|
|
110
|
+
return new Response('Not Found', { status: 404 })
|
|
111
|
+
}
|
|
112
|
+
return result
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create a mock API handler for testing.
|
|
119
|
+
* Records all calls and returns a configurable response.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* import { createMockHandler } from "@pyreon/zero/testing"
|
|
123
|
+
*
|
|
124
|
+
* const handler = createMockHandler({ status: 200, body: { ok: true } })
|
|
125
|
+
* // ... use handler in your API route module
|
|
126
|
+
* expect(handler.calls).toHaveLength(1)
|
|
127
|
+
* expect(handler.calls[0].params).toEqual({ id: "123" })
|
|
128
|
+
*/
|
|
129
|
+
export function createMockHandler(
|
|
130
|
+
responseConfig: {
|
|
131
|
+
status?: number
|
|
132
|
+
body?: unknown
|
|
133
|
+
headers?: Record<string, string>
|
|
134
|
+
} = {},
|
|
135
|
+
): ApiHandler & {
|
|
136
|
+
calls: Array<{ path: string; params: Record<string, string> }>
|
|
137
|
+
} {
|
|
138
|
+
const { status = 200, body = null, headers = {} } = responseConfig
|
|
139
|
+
const calls: Array<{ path: string; params: Record<string, string> }> = []
|
|
140
|
+
|
|
141
|
+
const handler: ApiHandler = (ctx) => {
|
|
142
|
+
calls.push({ path: ctx.path, params: ctx.params })
|
|
143
|
+
return new Response(JSON.stringify(body), {
|
|
144
|
+
status,
|
|
145
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return Object.assign(handler, { calls })
|
|
150
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -111,6 +111,14 @@ export interface FileRoute {
|
|
|
111
111
|
renderMode: RenderMode
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// ─── Route middleware ────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** Entry mapping a URL pattern to its route-level middleware. */
|
|
117
|
+
export interface RouteMiddlewareEntry {
|
|
118
|
+
pattern: string
|
|
119
|
+
middleware: Middleware | Middleware[]
|
|
120
|
+
}
|
|
121
|
+
|
|
114
122
|
// ─── Adapter ─────────────────────────────────────────────────────────────────
|
|
115
123
|
|
|
116
124
|
export interface Adapter {
|
package/src/vite-plugin.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import type { Plugin } from 'vite'
|
|
2
|
+
import { generateApiRouteModule } from './api-routes'
|
|
2
3
|
import { resolveConfig } from './config'
|
|
3
|
-
import {
|
|
4
|
+
import { renderErrorOverlay } from './error-overlay'
|
|
5
|
+
import {
|
|
6
|
+
generateMiddlewareModule,
|
|
7
|
+
generateRouteModule,
|
|
8
|
+
scanRouteFiles,
|
|
9
|
+
} from './fs-router'
|
|
4
10
|
import type { ZeroConfig } from './types'
|
|
5
11
|
|
|
6
12
|
const VIRTUAL_ROUTES_ID = 'virtual:zero/routes'
|
|
7
13
|
const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`
|
|
8
14
|
|
|
15
|
+
const VIRTUAL_MIDDLEWARE_ID = 'virtual:zero/route-middleware'
|
|
16
|
+
const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`
|
|
17
|
+
|
|
18
|
+
const VIRTUAL_API_ROUTES_ID = 'virtual:zero/api-routes'
|
|
19
|
+
const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`
|
|
20
|
+
|
|
9
21
|
/**
|
|
10
22
|
* Zero Vite plugin — adds file-based routing and zero-config conventions
|
|
11
23
|
* on top of @pyreon/vite-plugin.
|
|
@@ -35,9 +47,9 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
35
47
|
},
|
|
36
48
|
|
|
37
49
|
resolveId(id) {
|
|
38
|
-
if (id === VIRTUAL_ROUTES_ID)
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
if (id === VIRTUAL_ROUTES_ID) return RESOLVED_VIRTUAL_ROUTES_ID
|
|
51
|
+
if (id === VIRTUAL_MIDDLEWARE_ID) return RESOLVED_VIRTUAL_MIDDLEWARE_ID
|
|
52
|
+
if (id === VIRTUAL_API_ROUTES_ID) return RESOLVED_VIRTUAL_API_ROUTES_ID
|
|
41
53
|
},
|
|
42
54
|
|
|
43
55
|
async load(id) {
|
|
@@ -49,25 +61,78 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
49
61
|
return `export const routes = []`
|
|
50
62
|
}
|
|
51
63
|
}
|
|
64
|
+
|
|
65
|
+
if (id === RESOLVED_VIRTUAL_MIDDLEWARE_ID) {
|
|
66
|
+
try {
|
|
67
|
+
const files = await scanRouteFiles(routesDir)
|
|
68
|
+
return generateMiddlewareModule(files, routesDir)
|
|
69
|
+
} catch (_err) {
|
|
70
|
+
return `export const routeMiddleware = []`
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (id === RESOLVED_VIRTUAL_API_ROUTES_ID) {
|
|
75
|
+
try {
|
|
76
|
+
const files = await scanRouteFiles(routesDir)
|
|
77
|
+
return generateApiRouteModule(files, routesDir)
|
|
78
|
+
} catch (_err) {
|
|
79
|
+
return `export const apiRoutes = []`
|
|
80
|
+
}
|
|
81
|
+
}
|
|
52
82
|
},
|
|
53
83
|
|
|
54
84
|
configureServer(server) {
|
|
85
|
+
// SSR error overlay — intercept HTML requests and catch SSR errors
|
|
86
|
+
// This runs as a late middleware (return function) so it wraps
|
|
87
|
+
// Vite's own SSR handling and catches rendering failures.
|
|
88
|
+
server.middlewares.use((req, res, next) => {
|
|
89
|
+
const accept = req.headers.accept ?? ''
|
|
90
|
+
if (!accept.includes('text/html')) return next()
|
|
91
|
+
|
|
92
|
+
// Monkey-patch res.end to catch errors from SSR rendering
|
|
93
|
+
const originalEnd = res.end.bind(res)
|
|
94
|
+
let errored = false
|
|
95
|
+
|
|
96
|
+
const handleError = (err: unknown) => {
|
|
97
|
+
if (errored) return
|
|
98
|
+
errored = true
|
|
99
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
100
|
+
server.ssrFixStacktrace(error)
|
|
101
|
+
const html = renderErrorOverlay(error)
|
|
102
|
+
res.statusCode = 500
|
|
103
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
104
|
+
res.setHeader('Content-Length', Buffer.byteLength(html))
|
|
105
|
+
originalEnd(html)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
res.on('error', handleError)
|
|
109
|
+
|
|
110
|
+
// Wrap next() in try/catch to handle synchronous errors
|
|
111
|
+
try {
|
|
112
|
+
next()
|
|
113
|
+
} catch (err) {
|
|
114
|
+
handleError(err)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
55
118
|
// Watch routes directory for changes
|
|
56
119
|
server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`)
|
|
57
120
|
|
|
58
|
-
// Invalidate virtual
|
|
121
|
+
// Invalidate virtual modules when route files change
|
|
59
122
|
server.watcher.on('all', (event, path) => {
|
|
60
123
|
if (
|
|
61
124
|
path.startsWith(routesDir) &&
|
|
62
125
|
(event === 'add' || event === 'unlink')
|
|
63
126
|
) {
|
|
64
|
-
const
|
|
127
|
+
for (const resolvedId of [
|
|
65
128
|
RESOLVED_VIRTUAL_ROUTES_ID,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
server.
|
|
129
|
+
RESOLVED_VIRTUAL_MIDDLEWARE_ID,
|
|
130
|
+
RESOLVED_VIRTUAL_API_ROUTES_ID,
|
|
131
|
+
]) {
|
|
132
|
+
const mod = server.moduleGraph.getModuleById(resolvedId)
|
|
133
|
+
if (mod) server.moduleGraph.invalidateModule(mod)
|
|
70
134
|
}
|
|
135
|
+
server.ws.send({ type: 'full-reload' })
|
|
71
136
|
}
|
|
72
137
|
})
|
|
73
138
|
},
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"fs-router-jfd1QGLB.js","names":[],"sources":["../src/fs-router.ts"],"sourcesContent":["import type { FileRoute, RenderMode } from './types'\n\n// ─── File-system route conventions ──────────────────────────────────────────\n//\n// src/routes/\n// _layout.tsx → layout for all routes\n// index.tsx → /\n// about.tsx → /about\n// users/\n// _layout.tsx → layout for /users/*\n// _loading.tsx → loading fallback for /users/*\n// _error.tsx → error boundary for /users/*\n// index.tsx → /users\n// [id].tsx → /users/:id\n// [id]/\n// settings.tsx → /users/:id/settings\n// blog/\n// [...slug].tsx → /blog/* (catch-all)\n//\n// Conventions:\n// [param] → dynamic segment → :param\n// [...param] → catch-all → :param*\n// _layout → layout wrapper (not a route itself)\n// _error → error component\n// _loading → loading component\n// (group) → route group (directory ignored in URL)\n\nconst ROUTE_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js']\n\n/**\n * Parse a set of file paths (relative to routes dir) into FileRoute objects.\n *\n * @param files Array of file paths like [\"index.tsx\", \"users/[id].tsx\"]\n * @param defaultMode Default rendering mode from config\n */\nexport function parseFileRoutes(\n files: string[],\n defaultMode: RenderMode = 'ssr',\n): FileRoute[] {\n return files\n .filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext)))\n .map((filePath) => parseFilePath(filePath, defaultMode))\n .sort(sortRoutes)\n}\n\nfunction parseFilePath(filePath: string, defaultMode: RenderMode): FileRoute {\n // Remove extension\n let route = filePath\n for (const ext of ROUTE_EXTENSIONS) {\n if (route.endsWith(ext)) {\n route = route.slice(0, -ext.length)\n break\n }\n }\n\n const fileName = getFileName(route)\n const isLayout = fileName === '_layout'\n const isError = fileName === '_error'\n const isLoading = fileName === '_loading'\n const isCatchAll = route.includes('[...')\n\n // Get directory path (strip groups for consistent grouping)\n const parts = route.split('/')\n parts.pop() // remove filename\n const dirPath = parts\n .filter((s) => !(s.startsWith('(') && s.endsWith(')')))\n .join('/')\n\n // Convert file path to URL pattern\n const urlPath = filePathToUrlPath(route)\n const depth = urlPath === '/' ? 0 : urlPath.split('/').filter(Boolean).length\n\n return {\n filePath,\n urlPath,\n dirPath,\n depth,\n isLayout,\n isError,\n isLoading,\n isCatchAll,\n renderMode: defaultMode,\n }\n}\n\n/**\n * Convert a file path (without extension) to a URL path pattern.\n *\n * Examples:\n * \"index\" → \"/\"\n * \"about\" → \"/about\"\n * \"users/index\" → \"/users\"\n * \"users/[id]\" → \"/users/:id\"\n * \"blog/[...slug]\" → \"/blog/:slug*\"\n * \"(auth)/login\" → \"/login\" (group stripped)\n * \"_layout\" → \"/\" (layout marker)\n */\nexport function filePathToUrlPath(filePath: string): string {\n const segments = filePath.split('/')\n const urlSegments: string[] = []\n\n for (const seg of segments) {\n // Skip route groups \"(name)\"\n if (seg.startsWith('(') && seg.endsWith(')')) continue\n\n // Skip special files\n if (seg === '_layout' || seg === '_error' || seg === '_loading') continue\n\n // \"index\" maps to the parent path\n if (seg === 'index') continue\n\n // Catch-all: [...param] → :param*\n const catchAll = seg.match(/^\\[\\.\\.\\.(\\w+)\\]$/)\n if (catchAll) {\n urlSegments.push(`:${catchAll[1]}*`)\n continue\n }\n\n // Dynamic: [param] → :param\n const dynamic = seg.match(/^\\[(\\w+)\\]$/)\n if (dynamic) {\n urlSegments.push(`:${dynamic[1]}`)\n continue\n }\n\n urlSegments.push(seg)\n }\n\n const path = `/${urlSegments.join('/')}`\n return path || '/'\n}\n\n/** Sort routes: static before dynamic, catch-all last. */\nfunction sortRoutes(a: FileRoute, b: FileRoute): number {\n // Catch-all routes go last\n if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1\n // Layouts go first within same depth\n if (a.isLayout !== b.isLayout) return a.isLayout ? -1 : 1\n // Static segments before dynamic\n const aDynamic = a.urlPath.includes(':')\n const bDynamic = b.urlPath.includes(':')\n if (aDynamic !== bDynamic) return aDynamic ? 1 : -1\n // Alphabetical\n return a.urlPath.localeCompare(b.urlPath)\n}\n\nfunction getFileName(filePath: string): string {\n const parts = filePath.split('/')\n return parts[parts.length - 1] ?? ''\n}\n\n// ─── Route generation (for Vite plugin) ─────────────────────────────────────\n\n/** Internal tree node for building nested route structures. */\ninterface RouteNode {\n /** Page routes at this directory level. */\n pages: FileRoute[]\n /** Layout file for this directory (if any). */\n layout?: FileRoute\n /** Error boundary file (if any). */\n error?: FileRoute\n /** Loading fallback file (if any). */\n loading?: FileRoute\n /** Child directories. */\n children: Map<string, RouteNode>\n}\n\n/**\n * Group flat file routes into a directory tree.\n */\nfunction getOrCreateChild(node: RouteNode, segment: string): RouteNode {\n let child = node.children.get(segment)\n if (!child) {\n child = { pages: [], children: new Map() }\n node.children.set(segment, child)\n }\n return child\n}\n\nfunction resolveNode(root: RouteNode, dirPath: string): RouteNode {\n let node = root\n if (dirPath) {\n for (const segment of dirPath.split('/')) {\n node = getOrCreateChild(node, segment)\n }\n }\n return node\n}\n\nfunction placeRoute(node: RouteNode, route: FileRoute) {\n if (route.isLayout) node.layout = route\n else if (route.isError) node.error = route\n else if (route.isLoading) node.loading = route\n else node.pages.push(route)\n}\n\nfunction buildRouteTree(routes: FileRoute[]): RouteNode {\n const root: RouteNode = { pages: [], children: new Map() }\n for (const route of routes) {\n placeRoute(resolveNode(root, route.dirPath), route)\n }\n return root\n}\n\n/**\n * Generate a virtual module that exports a nested route tree.\n * Wires up layouts as parent routes with children, loaders, guards,\n * error/loading components, middleware, and meta from route module exports.\n */\nexport function generateRouteModule(\n files: string[],\n routesDir: string,\n): string {\n const routes = parseFileRoutes(files)\n const tree = buildRouteTree(routes)\n const imports: string[] = []\n let importCounter = 0\n\n function nextImport(filePath: string, exportName = 'default'): string {\n const name = `_${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n if (exportName === 'default') {\n imports.push(`import ${name} from \"${fullPath}\"`)\n } else {\n imports.push(`import { ${exportName} as ${name} } from \"${fullPath}\"`)\n }\n return name\n }\n\n function nextLazy(\n filePath: string,\n loadingName?: string,\n errorName?: string,\n ): string {\n const name = `_${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n const opts: string[] = []\n if (loadingName) opts.push(`loading: ${loadingName}`)\n if (errorName) opts.push(`error: ${errorName}`)\n const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''\n imports.push(`const ${name} = lazy(() => import(\"${fullPath}\")${optsStr})`)\n return name\n }\n\n function nextModuleImport(filePath: string): string {\n const name = `_m${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n imports.push(`import * as ${name} from \"${fullPath}\"`)\n return name\n }\n\n function generatePageRoute(\n page: FileRoute,\n indent: string,\n loadingName: string | undefined,\n errorName: string | undefined,\n ): string {\n const mod = nextModuleImport(page.filePath)\n const comp = nextLazy(page.filePath, loadingName, errorName)\n\n const props: string[] = [\n `${indent} path: ${JSON.stringify(page.urlPath)}`,\n `${indent} component: ${comp}`,\n `${indent} loader: ${mod}.loader`,\n `${indent} beforeEnter: ${mod}.guard`,\n `${indent} meta: ${mod}.meta`,\n ]\n\n if (errorName) {\n props.push(`${indent} errorComponent: ${mod}.error || ${errorName}`)\n } else {\n props.push(`${indent} errorComponent: ${mod}.error`)\n }\n\n return `${indent}{\\n${props.join(',\\n')}\\n${indent}}`\n }\n\n function wrapWithLayout(\n node: RouteNode,\n children: string[],\n indent: string,\n errorName: string | undefined,\n ): string {\n const layout = node.layout as FileRoute\n const layoutMod = nextModuleImport(layout.filePath)\n const layoutComp = nextImport(layout.filePath, 'layout')\n\n const props: string[] = [\n `${indent}path: ${JSON.stringify(layout.urlPath)}`,\n `${indent}component: ${layoutComp}`,\n `${indent}loader: ${layoutMod}.loader`,\n `${indent}beforeEnter: ${layoutMod}.guard`,\n `${indent}meta: ${layoutMod}.meta`,\n ]\n if (errorName) {\n props.push(`${indent}errorComponent: ${errorName}`)\n }\n if (children.length > 0) {\n props.push(`${indent}children: [\\n${children.join(',\\n')}\\n${indent}]`)\n }\n\n return `${indent}{\\n${props.map((p) => ` ${p}`).join(',\\n')}\\n${indent}}`\n }\n\n /**\n * Generate route definitions for a tree node.\n */\n function generateNode(node: RouteNode, depth: number): string[] {\n const indent = ' '.repeat(depth + 1)\n\n const errorName = node.error ? nextImport(node.error.filePath) : undefined\n const loadingName = node.loading\n ? nextImport(node.loading.filePath)\n : undefined\n\n const childRouteDefs: string[] = []\n for (const [, childNode] of node.children) {\n childRouteDefs.push(...generateNode(childNode, depth + 1))\n }\n\n const pageRouteDefs = node.pages.map((page) =>\n generatePageRoute(page, indent, loadingName, errorName),\n )\n\n const allChildren = [...pageRouteDefs, ...childRouteDefs]\n\n if (node.layout) {\n return [wrapWithLayout(node, allChildren, indent, errorName)]\n }\n return allChildren\n }\n\n const routeDefs = generateNode(tree, 0)\n\n return [\n `import { lazy } from \"@pyreon/router\"`,\n '',\n ...imports,\n '',\n // Filter out undefined properties at runtime\n `function clean(routes) {`,\n ` return routes.map(r => {`,\n ` const c = {}`,\n ` for (const k in r) if (r[k] !== undefined) c[k] = r[k]`,\n ` if (c.children) c.children = clean(c.children)`,\n ` return c`,\n ` })`,\n `}`,\n '',\n `export const routes = clean([`,\n routeDefs.join(',\\n'),\n `])`,\n ].join('\\n')\n}\n\n/**\n * Scan a directory for route files.\n * Returns paths relative to the routes directory.\n */\nexport async function scanRouteFiles(routesDir: string): Promise<string[]> {\n const { readdir } = await import('node:fs/promises')\n const { join, relative } = await import('node:path')\n\n const files: string[] = []\n\n async function walk(dir: string) {\n const entries = await readdir(dir, { withFileTypes: true })\n for (const entry of entries) {\n const fullPath = join(dir, entry.name)\n if (entry.isDirectory()) {\n await walk(fullPath)\n } else if (ROUTE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {\n files.push(relative(routesDir, fullPath))\n }\n }\n }\n\n await walk(routesDir)\n return files\n}\n"],"mappings":"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"}
|