@@ -489,6 +489,124 @@ import { varyEncoding } from "@pyreon/zero"
489489varyEncoding ()
490490```
491491
492+ ## API Routes
493+
494+ API routes are ` .ts ` files in ` src/routes/api/ ` that export HTTP method handlers. They run on the server and return ` Response ` objects directly.
495+
496+ ``` ts title="src/routes/api/posts.ts"
497+ import type { ApiContext } from " @pyreon/zero"
498+
499+ export function GET(ctx : ApiContext ) {
500+ return Response .json ([
501+ { id: 1 , title: " Hello World" },
502+ ])
503+ }
504+
505+ export async function POST(ctx : ApiContext ) {
506+ const body = await ctx .request .json ()
507+ return Response .json ({ id: 2 , ... body }, { status: 201 })
508+ }
509+ ```
510+
511+ ### File path conventions
512+
513+ | File | URL |
514+ | ------| -----|
515+ | ` src/routes/api/posts.ts ` | ` /api/posts ` |
516+ | ` src/routes/api/posts/index.ts ` | ` /api/posts ` |
517+ | ` src/routes/api/posts/[id].ts ` | ` /api/posts/:id ` |
518+ | ` src/routes/api/[...path].ts ` | ` /api/* ` (catch-all) |
519+
520+ ### ApiContext
521+
522+ | Property | Type | Description |
523+ | ----------| ------| -------------|
524+ | ` request ` | ` Request ` | The incoming HTTP request |
525+ | ` url ` | ` URL ` | Parsed URL |
526+ | ` path ` | ` string ` | URL path |
527+ | ` params ` | ` Record<string, string> ` | Dynamic route parameters |
528+ | ` headers ` | ` Headers ` | Request headers |
529+
530+ ### Wiring API routes
531+
532+ ``` ts title="src/entry-server.ts"
533+ import { apiRoutes } from " virtual:zero/api-routes"
534+
535+ export default createServer ({
536+ routes ,
537+ apiRoutes , // API routes run before SSR — matched by URL + HTTP method
538+ middleware: [... ],
539+ })
540+ ```
541+
542+ Unsupported methods automatically return ` 405 Method Not Allowed ` with an ` Allow ` header.
543+
544+ ### CORS Middleware
545+
546+ ``` ts
547+ import { corsMiddleware } from " @pyreon/zero/cors"
548+
549+ // Allow any origin
550+ corsMiddleware ()
551+
552+ // Specific origins with credentials
553+ corsMiddleware ({
554+ origin: [" https://app.com" , " https://admin.com" ],
555+ credentials: true ,
556+ maxAge: 86400 ,
557+ })
558+
559+ // Dynamic origin matching
560+ corsMiddleware ({
561+ origin : (o ) => o .endsWith (" .example.com" ),
562+ })
563+ ```
564+
565+ | Option | Type | Default | Description |
566+ | --------| ------| ---------| -------------|
567+ | ` origin ` | ` string \| string[] \| (origin: string) => boolean ` | ` "*" ` | Allowed origins |
568+ | ` methods ` | ` string[] ` | ` ["GET","POST","PUT","PATCH","DELETE","OPTIONS"] ` | Allowed methods |
569+ | ` allowedHeaders ` | ` string[] ` | ` ["Content-Type","Authorization"] ` | Allowed request headers |
570+ | ` exposedHeaders ` | ` string[] ` | ` [] ` | Headers exposed to client |
571+ | ` credentials ` | ` boolean ` | ` false ` | Allow credentials |
572+ | ` maxAge ` | ` number ` | ` 86400 ` | Preflight cache (seconds) |
573+
574+ ### Rate Limiting
575+
576+ ``` ts
577+ import { rateLimitMiddleware } from " @pyreon/zero/rate-limit"
578+
579+ // 100 requests per minute (default)
580+ rateLimitMiddleware ()
581+
582+ // Strict API rate limiting
583+ rateLimitMiddleware ({
584+ max: 20 ,
585+ window: 60 ,
586+ include: [" /api/*" ],
587+ })
588+ ```
589+
590+ | Option | Type | Default | Description |
591+ | --------| ------| ---------| -------------|
592+ | ` max ` | ` number ` | ` 100 ` | Max requests per window |
593+ | ` window ` | ` number ` | ` 60 ` | Window in seconds |
594+ | ` keyFn ` | ` (ctx) => string ` | IP-based | Client identifier function |
595+ | ` include ` | ` string[] ` | all paths | URL patterns to rate limit |
596+ | ` exclude ` | ` string[] ` | ` [] ` | URL patterns to skip |
597+
598+ Sets ` X-RateLimit-Limit ` , ` X-RateLimit-Remaining ` , ` X-RateLimit-Reset ` headers. Returns ` 429 Too Many Requests ` with ` Retry-After ` when exceeded.
599+
600+ ### Compression
601+
602+ ``` ts
603+ import { compressionMiddleware } from " @pyreon/zero/compression"
604+
605+ compressionMiddleware ({ threshold: 1024 , encodings: [" gzip" ] })
606+ ```
607+
608+ Compresses text-based responses (HTML, JSON, JS, CSS, XML, SVG) using the native ` CompressionStream ` API. Skips binary content and responses below the threshold.
609+
492610## Server Actions
493611
494612Define server-side mutations that are callable from the client. Actions receive parsed JSON or FormData and are mounted at ` /_zero/actions/* ` .
@@ -888,6 +1006,60 @@ startClient({
8881006
8891007The client automatically detects whether to hydrate (if SSR-rendered HTML is present) or mount fresh (SPA mode).
8901008
1009+ ## Testing Utilities
1010+
1011+ Test helpers for middleware and API routes, imported from ` @pyreon/zero/testing ` .
1012+
1013+ ### Testing Middleware
1014+
1015+ ``` ts
1016+ import { testMiddleware } from " @pyreon/zero/testing"
1017+ import { corsMiddleware } from " @pyreon/zero/cors"
1018+
1019+ const { response, headers } = await testMiddleware (
1020+ corsMiddleware ({ origin: " *" }),
1021+ " /api/posts"
1022+ )
1023+ expect (headers .get (" Access-Control-Allow-Origin" )).toBe (" *" )
1024+ ```
1025+
1026+ ### Testing API Routes
1027+
1028+ ``` ts
1029+ import { createTestApiServer } from " @pyreon/zero/testing"
1030+
1031+ const server = createTestApiServer ([
1032+ { pattern: " /api/posts" , module: { GET : () => Response .json ([]) } },
1033+ ])
1034+
1035+ const res = await server .request (" /api/posts" )
1036+ expect (res .status ).toBe (200 )
1037+
1038+ const res2 = await server .request (" /api/posts" , {
1039+ method: " POST" ,
1040+ body: { title: " Hello" },
1041+ })
1042+ expect (res2 .status ).toBe (201 )
1043+ ```
1044+
1045+ ### Mock Handlers
1046+
1047+ ``` ts
1048+ import { createMockHandler } from " @pyreon/zero/testing"
1049+
1050+ const handler = createMockHandler ({ status: 200 , body: { ok: true } })
1051+ // ... use in API route module
1052+ expect (handler .calls ).toHaveLength (1 )
1053+ expect (handler .calls [0 ].params ).toEqual ({ id: " 123" })
1054+ ```
1055+
1056+ | Export | Description |
1057+ | --------| -------------|
1058+ | ` createTestContext(path, options) ` | Create mock ` MiddlewareContext ` |
1059+ | ` testMiddleware(mw, path, options) ` | Run middleware, return response + headers |
1060+ | ` createTestApiServer(routes) ` | Test API routes via ` server.request() ` |
1061+ | ` createMockHandler(config) ` | Mock handler that records calls |
1062+
8911063## Exports Summary
8921064
8931065| Export | Signature | Description |
@@ -927,6 +1099,10 @@ The client automatically detects whether to hydrate (if SSR-rendered HTML is pre
9271099| ` staticAdapter ` | ` () => Adapter ` | Static output adapter |
9281100| ` defineAction ` | ` (handler: ActionHandler) => Action ` | Define a server action |
9291101| ` createActionMiddleware ` | ` () => Middleware ` | Mount action handler at ` /_zero/actions/* ` |
1102+ | ` createApiMiddleware ` | ` (routes: ApiRouteEntry[]) => Middleware ` | Mount API route handler |
1103+ | ` corsMiddleware ` | ` (config?: CorsConfig) => Middleware ` | CORS middleware |
1104+ | ` rateLimitMiddleware ` | ` (config?: RateLimitConfig) => Middleware ` | Rate limiting middleware |
1105+ | ` compressionMiddleware ` | ` (config?: CompressionConfig) => Middleware ` | Compression middleware |
9301106
9311107## Subpath Exports
9321108
@@ -944,6 +1120,11 @@ The client automatically detects whether to hydrate (if SSR-rendered HTML is pre
9441120| ` @pyreon/zero/theme ` | Theme signals and ` ThemeToggle ` component |
9451121| ` @pyreon/zero/image-plugin ` | ` imagePlugin ` Vite plugin |
9461122| ` @pyreon/zero/actions ` | ` defineAction ` , ` createActionMiddleware ` |
1123+ | ` @pyreon/zero/api-routes ` | API route utilities and ` createApiMiddleware ` |
1124+ | ` @pyreon/zero/cors ` | ` corsMiddleware ` |
1125+ | ` @pyreon/zero/rate-limit ` | ` rateLimitMiddleware ` |
1126+ | ` @pyreon/zero/compression ` | ` compressionMiddleware ` , ` compressResponse ` |
1127+ | ` @pyreon/zero/testing ` | Test helpers for middleware and API routes |
9471128
9481129## Type Exports
9491130
@@ -975,3 +1156,10 @@ The client automatically detects whether to hydrate (if SSR-rendered HTML is pre
9751156| ` Action ` | Client-callable action returned by ` defineAction ` |
9761157| ` ActionHandler ` | Server action handler function type |
9771158| ` RouteMiddlewareEntry ` | Maps URL pattern to route middleware |
1159+ | ` ApiContext ` | Context passed to API route handlers |
1160+ | ` ApiRouteEntry ` | Maps URL pattern to API route module |
1161+ | ` ApiRouteModule ` | API route module with HTTP method handlers |
1162+ | ` HttpMethod ` | ` "GET" \| "POST" \| "PUT" \| "PATCH" \| "DELETE" \| "HEAD" \| "OPTIONS" ` |
1163+ | ` CorsConfig ` | Configuration for ` corsMiddleware ` |
1164+ | ` RateLimitConfig ` | Configuration for ` rateLimitMiddleware ` |
1165+ | ` CompressionConfig ` | Configuration for ` compressionMiddleware ` |
0 commit comments