@@ -62,7 +62,7 @@ app.listen(3000);
6262| Feature | Import |
6363| ---------| --------|
6464| Routing, Route Grouping, WebSockets, OpenAPI, Plugins, Lifecycle Hooks, Cookies, IP | ` princejs ` |
65- | CORS, Logger, JWT, JWKS, Auth, Rate Limit, Validate, Compress, Session, API Key, Secure Headers, Timeout, Request ID, IP Restriction, Static Files | ` princejs/middleware ` |
65+ | CORS, Logger, JWT, JWKS, Auth, Rate Limit, Validate, Compress, Session, API Key, Secure Headers, Timeout, Request ID, IP Restriction, Static Files, Trim Trailing Slash, Middleware Combinators ( ` every ` , ` some ` , ` except ` ), ` guard() ` | ` princejs/middleware ` |
6666| File Uploads, SSE, Streaming, In-memory Cache | ` princejs/helpers ` |
6767| Cron Scheduler | ` princejs/scheduler ` |
6868| JSX / SSR | ` princejs/jsx ` |
@@ -268,6 +268,90 @@ app.use(ipRestriction({ denyList: ["1.2.3.4"] }));
268268
269269---
270270
271+ ## ✂️ Trim Trailing Slash
272+
273+ Automatically redirect ` /users/ ` → ` /users ` so you never get mysterious 404s from a stray trailing slash:
274+
275+ ``` ts
276+ import { trimTrailingSlash } from " princejs/middleware" ;
277+
278+ app .use (trimTrailingSlash ()); // 301 by default
279+ app .use (trimTrailingSlash (302 )); // or 302 temporary redirect
280+ ```
281+
282+ Root ` / ` is never redirected. Query strings are preserved — ` /search/?q=bun ` → ` /search?q=bun ` .
283+
284+ ---
285+
286+ ## 🔀 Middleware Combinators
287+
288+ Compose complex auth rules in a single readable line.
289+
290+ ### ` every() ` — all must pass
291+
292+ ``` ts
293+ import { every } from " princejs/middleware" ;
294+
295+ const isAdmin = async (req , next ) => {
296+ if (req .user ?.role !== " admin" )
297+ return new Response (JSON .stringify ({ error: " Forbidden" }), { status: 403 });
298+ return next ();
299+ };
300+
301+ app .get (" /admin" , every (auth (), isAdmin ), () => ({ ok: true }));
302+ // short-circuits on first rejection — isAdmin never runs if auth() fails
303+ ```
304+
305+ ### ` some() ` — either must pass
306+
307+ ``` ts
308+ import { some } from " princejs/middleware" ;
309+
310+ // Accept a JWT token OR an API key — whichever the client sends
311+ app .get (" /resource" , some (auth (), apiKey ({ keys: [" key_123" ] })), () => ({ ok: true }));
312+ ```
313+
314+ ### ` except() ` — skip middleware for certain paths
315+
316+ ``` ts
317+ import { except } from " princejs/middleware" ;
318+
319+ // Apply auth everywhere except /health and /
320+ app .use (except ([" /health" , " /" ], auth ()));
321+
322+ app .get (" /health" , () => ({ ok: true })); // no auth
323+ app .get (" /private" , (req ) => ({ user: req .user })); // auth required
324+ ```
325+
326+ ---
327+
328+ ## 🛡️ guard()
329+
330+ Apply a validation schema to every route in a group at once — no need to repeat ` validate() ` on each handler:
331+
332+ ``` ts
333+ import { guard } from " princejs/middleware" ;
334+ import { z } from " zod" ;
335+
336+ app .group (" /users" , guard ({ body: z .object ({ name: z .string ().min (1 ) }) }), (r ) => {
337+ r .post (" /" , (req ) => ({ created: req .parsedBody .name })); // auto-validated
338+ r .put (" /:id" , (req ) => ({ updated: req .parsedBody .name })); // auto-validated
339+ });
340+ // Bad body → 400 { error: "Validation failed", details: [...] }
341+ ```
342+
343+ Also works as standalone route middleware:
344+
345+ ``` ts
346+ app .post (
347+ " /items" ,
348+ guard ({ body: z .object ({ name: z .string (), price: z .number () }) }),
349+ (req ) => ({ created: req .parsedBody })
350+ );
351+ ```
352+
353+ ---
354+
271355## 📁 Static Files
272356
273357Serve a directory of static files. Falls through to your routes if the file doesn't exist:
@@ -537,6 +621,11 @@ import {
537621 secureHeaders ,
538622 timeout ,
539623 requestId ,
624+ trimTrailingSlash ,
625+ every ,
626+ some ,
627+ except ,
628+ guard ,
540629} from " princejs/middleware" ;
541630import { cache , upload , sse , stream } from " princejs/helpers" ;
542631import { cron } from " princejs/scheduler" ;
@@ -559,6 +648,7 @@ app.onError((err, req, path, method) => {
559648// ── Global middleware ─────────────────────────────────────
560649app .use (secureHeaders ());
561650app .use (requestId ());
651+ app .use (trimTrailingSlash ());
562652app .use (timeout (10000 ));
563653app .use (cors ());
564654app .use (logger ());
@@ -600,6 +690,16 @@ app.ws("/chat", {
600690// ── Auth & API keys ───────────────────────────────────────
601691app .get (" /protected" , auth (), (req ) => ({ user: req .user }));
602692app .get (" /api" , apiKey ({ keys: [" key_123" ] }), () => ({ ok: true }));
693+ app .get (" /admin" , every (auth (), async (req , next ) => {
694+ if (req .user ?.role !== " admin" )
695+ return new Response (JSON .stringify ({ error: " Forbidden" }), { status: 403 });
696+ return next ();
697+ }), () => ({ admin: true }));
698+
699+ // ── Validated route group ─────────────────────────────────
700+ app .group (" /items" , guard ({ body: z .object ({ name: z .string ().min (1 ) }) }), (r ) => {
701+ r .post (" /" , (req ) => ({ created: req .parsedBody .name }));
702+ });
603703
604704// ── Helpers ───────────────────────────────────────────────
605705app .get (" /cached" , cache (60 )(() => ({ time: Date .now () })));
0 commit comments