Skip to content

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/cors

Quick 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)
}
OptionDefaultDescription
origin*allowed origins. String / array exact-match; RegExp test; predicate fn returning boolean
credentialsfalseecho Access-Control-Allow-Credentials: true; when on, the origin is echoed exactly, never *
methodscommon setmethods advertised in preflight
allowedHeadersecho request headersheaders advertised in preflight
exposedHeadersresponse headers exposed to browser JS
maxAgeAccess-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), optional Access-Control-Max-Age, plus Access-Control-Allow-Credentials: true when 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: Origin is appended on exact echo (doesn't clobber a handler-provided Vary).
  • An OPTIONS request 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" };
});

Built with VitePress · MIT Licensed