CORS (@zebra-web/cors)
@zebra-web/cors is a complete CORS middleware: preflight handling (204 + full header set), origin allowlists (string / array / RegExp / predicate), exact-origin credentials echo, and Vary: Origin on dynamic matches.
Install
sh
bun add @zebra-web/corsQuick start
ts
import { cors } from "@zebra-web/cors";
import { Zebra } from "@zebra-web/core";
const app = new Zebra();
app.use(cors({ origin: ["http://localhost:3002"], credentials: true }));Options
ts
interface CorsOptions {
origin?: string | string[] | RegExp | ((origin: string) => boolean);
credentials?: boolean;
methods?: string[]; // default [GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS]
allowedHeaders?: string[]; // default: echo Access-Control-Request-Headers
exposedHeaders?: string[];
maxAge?: number; // preflight cache TTL (seconds)
}| Option | Default | Description |
|---|---|---|
origin | * | allowed origins. String / array exact-match; RegExp test; predicate fn returning boolean |
credentials | false | echo Access-Control-Allow-Credentials: true; when on, the origin is echoed exactly, never * |
methods | common set | methods advertised in preflight |
allowedHeaders | echo request headers | headers advertised in preflight |
exposedHeaders | — | response headers exposed to browser JS |
maxAge | — | Access-Control-Max-Age (seconds) |
Behavior
Preflight (OPTIONS + Access-Control-Request-Method)
- Origin check: on mismatch → a 204 with no CORS headers, and the browser blocks on its side (no 403 needed).
- On match →
204+ full header set:Access-Control-Allow-Origin(exact echo or*),Access-Control-Allow-Methods,Access-Control-Allow-Headers(echoes the request by default), optionalAccess-Control-Max-Age, plusAccess-Control-Allow-Credentials: truewhen enabled. - An echoed origin carries
Vary: Origin(*doesn't need it).
Actual requests
- Only a request carrying a matching Origin header is cross-origin and gets CORS headers; no Origin (same-origin / non-browser) or a disallowed origin passes the response through untouched.
- The response is wrapped, never mutated — body / status / status text preserved.
Vary: Originisappended on exact echo (doesn't clobber a handler-providedVary).- An
OPTIONSrequest without an Origin header (not a preflight) passes through.
ts
app.use(cors({
origin: (origin) => origin.endsWith(".example.com"),
credentials: true,
exposedHeaders: ["X-Total-Count"],
maxAge: 600,
}));Relation to route-level middleware
CORS preflight answers are generated by Zebra's terminal handler (automatic OPTIONS on known paths), which does not run through route-level middleware — preflights stay unauthenticated, by design (preflight requests must not trigger auth). Register an explicit OPTIONS route when you need custom preflight behavior or guards. See Routing.
Full example
ts
import { Zebra } from "@zebra-web/core";
import { cors } from "@zebra-web/cors";
const app = new Zebra();
app.use(cors({ origin: ["https://app.example.com"], credentials: true, maxAge: 86400 }));
app.get("/api/me", async (req) => {
return { user: "zebra" };
});