Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.

Commit f85dafd

Browse files
vitbokischclaude
andcommitted
Add API routes, CORS middleware, and rate limiting
File-based API routes: .ts files in src/routes/api/ export HTTP method handlers (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS). Auto-generated via virtual:zero/api-routes virtual module with URL pattern matching and dynamic parameter extraction. CORS middleware: configurable origins (string, array, function), methods, credentials, preflight handling with Access-Control headers. Rate limit middleware: in-memory per-client limiting with X-RateLimit headers, configurable max/window, include/exclude patterns, custom key function. - Add api-routes.ts with route detection, pattern matching, middleware - Add cors.ts with corsMiddleware() - Add rate-limit.ts with rateLimitMiddleware() - Wire virtual:zero/api-routes into Vite plugin and server entry - Add ./api-routes, ./cors, ./rate-limit subpath exports - Add template API examples (posts CRUD, health check) - Update template entry-server with CORS + rate limiting - 38 new tests (202 total) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ddcd61b commit f85dafd

15 files changed

Lines changed: 931 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,26 @@ Route files can `export const middleware` — the Vite plugin generates a virtua
109109

110110
Dev-only styled HTML overlay for SSR errors with source-mapped stack traces. Injected via Vite plugin's `configureServer` hook.
111111

112+
### API Routes (`api-routes.ts`)
113+
114+
File-based API routes: `.ts` files in `src/routes/api/` export HTTP method handlers (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`). Auto-generated via `virtual:zero/api-routes`. Handlers receive `ApiContext` with `request`, `url`, `params`, `headers`. Matched by URL pattern with dynamic params.
115+
116+
### CORS (`cors.ts`)
117+
118+
`corsMiddleware(config)` — configurable origins (string, array, function), methods, credentials, preflight handling. Handles `OPTIONS` automatically.
119+
120+
### Rate Limiting (`rate-limit.ts`)
121+
122+
`rateLimitMiddleware(config)` — in-memory per-client limiting with `X-RateLimit-*` headers. Configurable `max`, `window`, `include`/`exclude` patterns, custom key function.
123+
112124
### Shared Utilities (`utils/`)
113125

114126
- `use-intersection-observer.ts` — reusable observer composable (used by Image, Link)
115127
- `with-headers.ts` — clone Response with modified headers (used by cache middleware)
116128

117129
## Package Exports
118130

119-
`.` (core), `./client`, `./config`, `./image`, `./link`, `./script`, `./font`, `./cache`, `./seo`, `./theme`, `./image-plugin`, `./actions`
131+
`.` (core), `./client`, `./config`, `./image`, `./link`, `./script`, `./font`, `./cache`, `./seo`, `./theme`, `./image-plugin`, `./actions`, `./api-routes`, `./cors`, `./rate-limit`
120132

121133
## Starter Template (`packages/create-zero/templates/default/`)
122134

packages/create-zero/templates/default/env.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ declare module 'virtual:zero/route-middleware' {
99
import type { RouteMiddlewareEntry } from '@pyreon/zero'
1010
export const routeMiddleware: RouteMiddlewareEntry[]
1111
}
12+
13+
declare module 'virtual:zero/api-routes' {
14+
import type { ApiRouteEntry } from '@pyreon/zero'
15+
export const apiRoutes: ApiRouteEntry[]
16+
}

packages/create-zero/templates/default/src/entry-server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
1-
import { routes } from 'virtual:zero/routes'
1+
import { apiRoutes } from 'virtual:zero/api-routes'
22
import { routeMiddleware } from 'virtual:zero/route-middleware'
3+
import { routes } from 'virtual:zero/routes'
34
import { createServer } from '@pyreon/zero'
45
import {
56
cacheMiddleware,
67
securityHeaders,
78
varyEncoding,
89
} from '@pyreon/zero/cache'
10+
import { corsMiddleware } from '@pyreon/zero/cors'
11+
import { rateLimitMiddleware } from '@pyreon/zero/rate-limit'
912

1013
export default createServer({
1114
routes,
1215
routeMiddleware,
16+
apiRoutes,
1317
config: {
1418
ssr: { mode: 'stream' },
1519
},
1620
middleware: [
21+
corsMiddleware(),
22+
rateLimitMiddleware({ max: 100, window: 60, include: ['/api/*'] }),
1723
securityHeaders(),
1824
cacheMiddleware({ staleWhileRevalidate: 120 }),
1925
varyEncoding(),
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Health check endpoint — /api/health
3+
* Useful for load balancers and uptime monitoring.
4+
*/
5+
export function GET() {
6+
return Response.json({ status: 'ok', timestamp: new Date().toISOString() })
7+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { ApiContext } from '@pyreon/zero'
2+
3+
/**
4+
* API route example — /api/posts
5+
*
6+
* API routes are plain .ts files in src/routes/api/ that export
7+
* HTTP method handlers: GET, POST, PUT, PATCH, DELETE, OPTIONS.
8+
*
9+
* They run on the server and return Response objects directly.
10+
*/
11+
12+
const POSTS = [
13+
{ id: 1, title: 'Getting Started with Pyreon Zero', published: true },
14+
{ id: 2, title: 'Understanding Signals', published: true },
15+
{ id: 3, title: 'Server-Side Rendering Made Simple', published: false },
16+
]
17+
18+
export function GET(_ctx: ApiContext) {
19+
return Response.json(POSTS.filter((p) => p.published))
20+
}
21+
22+
export async function POST(ctx: ApiContext) {
23+
const body = (await ctx.request.json()) as {
24+
title: string
25+
published?: boolean
26+
}
27+
28+
if (!body.title) {
29+
return Response.json({ error: 'Title is required' }, { status: 400 })
30+
}
31+
32+
const post = {
33+
id: POSTS.length + 1,
34+
title: body.title,
35+
published: body.published ?? false,
36+
}
37+
38+
POSTS.push(post)
39+
return Response.json(post, { status: 201 })
40+
}

packages/zero/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@
8181
"bun": "./src/actions.ts",
8282
"import": "./lib/actions.js",
8383
"types": "./lib/types/actions.d.ts"
84+
},
85+
"./api-routes": {
86+
"bun": "./src/api-routes.ts",
87+
"import": "./lib/api-routes.js",
88+
"types": "./lib/types/api-routes.d.ts"
89+
},
90+
"./cors": {
91+
"bun": "./src/cors.ts",
92+
"import": "./lib/cors.js",
93+
"types": "./lib/types/cors.d.ts"
94+
},
95+
"./rate-limit": {
96+
"bun": "./src/rate-limit.ts",
97+
"import": "./lib/rate-limit.js",
98+
"types": "./lib/types/rate-limit.d.ts"
8499
}
85100
},
86101
"scripts": {

packages/zero/src/api-routes.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)