@bepalo/spine 3.13.28 → 3.15.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,1288 +11,1033 @@
11
11
  <!--
12
12
  [![Vitest](https://img.shields.io/badge/vitest-6E9F18?style=for-the-badge&logo=vitest&logoColor=white)](test-result.md) -->
13
13
 
14
- **A fast, runtime-agnostic HTTP router for JavaScript and TypeScript.**
14
+ **The Next-Generation Web-Standard HTTP Router & Pipeline Engine for TypeScript & JavaScript.**
15
15
 
16
- Spine is a low-level routing layer built around the Web `Request`/`Response` APIs. It gives you fast, predictable route matching, typed contexts, composable handler pipelines, and the freedom to run it on top of any HTTP server.
16
+ Spine is built from first principles around Web Standard APIs (`Request`, `Response`, `Headers`, `URL`). It replaces traditional recursive middleware onions with **deterministic flat array pipelines**, introduces **pipeline parameter linking**, features **specialized $O(1)$ routing tables**, and delivers zero server lock-in across Bun, Deno, Node.js, and edge runtimes.
17
17
 
18
18
  ```text
19
- ( @Bepalo/spine )
20
-
21
- ( The Router Pipeline )
22
-
23
- ┌───────────────────────┐
24
- ▼ │
25
- ┌──────┴───────┐ │
26
- ┌─────────│ Filters │─────────┐ <request>
27
- │ └──────┬───────┘ │ │
28
- │ <no match nor response> │ │
29
- │ ▼ │ │
30
- │ ┌──────┴───────┐ │ ┌──┴────────┐
31
- ├─────────│ Handlers │─────────┤ │ Server │◄───┐
32
- │ └──────┬───────┘ │ └──┬─────┬──┘ │
33
- │ <no match nor response> │ ▲ │ <request>
34
- <error> ▼ │ │ <response> │
35
- │ ┌──────┴───────┐ │ │ ▼ │
36
- ├─────────│ Fallbacks │─────────┤ │ ┌─┴───────┴─┐
37
- ▼ └──────┬───────┘ │ │ │ Client │
38
- ┌─────┴──────┐ │ ┌──<response>─┘ │ └───────────┘
39
- │ Catchers │ ▼ ▼ │
40
- └─────┬──────┘ ┌──────┴───┴───┐ │
41
- └────────►│ Afters │───────────────┘
42
- <error-response> └──────────────┘ <final-response>
19
+ ( @bepalo/spine )
20
+
21
+ The 5-Phase Pipeline Flow
22
+ ─────────────────────────
23
+
24
+ Incoming Web Request
25
+ │
26
+ ▼
27
+ ┌───────────────┐
28
+ ┌──────────│ 1. Filters │──────────┐
29
+ │ └───────┬───────┘ │
30
+ │ │ <no response> │
31
+ │ ▼ │
32
+ │ ┌───────────────┐ │
33
+ ├──────────│ 2. Handlers │──────────┤
34
+ │ └───────┬───────┘ │
35
+ │ │ <no response> │
36
+ │ ▼ │
37
+ │ ┌───────────────┐ │
38
+ <error> │ 3. Fallbacks │──────────┤
39
+ │ └───────┬───────┘ │
40
+ │ │ │
41
+ ▼ ▼ ▼
42
+ ┌───────────────┐ ┌───────────────────────────────┐
43
+ │ 4. Catchers │─►│ Response Assembly │
44
+ └───────────────┘ └───────────────┬───────────────┘
45
+ │
46
+ ▼
47
+ ┌───────────────┐
48
+ │ 5. Afters │
49
+ └───────┬───────┘
50
+ │
51
+ <afterCatcher> (on after-error)
52
+ │
53
+ ▼
54
+ Final Response
43
55
  ```
44
56
 
45
- ```text
46
- Benchmark Bun.serve baseline: @bepalo/spine vs Hono
47
- Bun runtime · localhost · 20,000 sequential requests per route
48
-
49
- Bun Spine Hono ┌──────────────┐
50
- ─────────────────────────────────────────────── │ Server │
51
- / 9.91k 8.64k 8.19k └──────┬───────┘
52
- exact 10.33k 8.42k 8.19k Request
53
- long exact 10.34k 8.28k 7.99k ▼
54
- one param 9.99k 8.16k 7.86k ┌──────────────┐
55
- two params 10.17k 8.04k 7.66k │ Spine │
56
- three params 9.99k 7.68k 6.76k │ Router │
57
- six params 9.88k 7.91k 7.51k └──────┬───────┘
58
- ten params 9.78k 7.90k 7.12k Response
59
- ▼
60
- Average 10.05k 8.13k 7.65k ┌──────────────┐
61
- ops/s │ Server │
62
- └──────────────┘
63
- ████████████████████████████████████████ Bun
64
- █████████████████████████████████ Spine
65
- ████████████████████████████████ Hono
66
- ```
57
+ _NOTE: All pipes except handlers are optional and can be disabled for performance. Default pipes can be defined used instead. You can basically use handler pipes with or without default pipes if you want._
67
58
 
68
- ## Why Spine?
59
+ ---
69
60
 
70
- - ⚡ **Low-overhead routing** — specialized route tables for exact, glob, and super-glob routes
71
- - 🎯 **Powerful route patterns** — parameters, alternatives, `*`, `*!`, `**`, and `**!`
72
- - 🔗 **Composable pipelines** — filters, handlers, fallbacks, catchers, and after-hooks
73
- - 🧠 **TypeScript-first** — extend the request context with your own application data
74
- - 🌐 **Runtime agnostic** — works with Bun, Deno, Node.js, and other Web API-compatible runtimes
75
- - 📁 **File-based routing** — optionally load routes directly from a directory structure
76
- - 📖 OpenAPI 3.0 generation — route metadata, tags, parameters, schemas, security, components, and configurable sorting
77
- - 🛠️ **Built-in utilities** — request parsing, responses, CORS, rate limiting, and authentication
78
- - 🪶 **No server lock-in** — Spine only deals with `Request` in and `Response` out
61
+ ## ⚡ What Makes Spine Different?
62
+
63
+ | Feature | Spine (`@bepalo/spine`) | Express / Koa | Hono / Fastify |
64
+ | :------------------------ | :-------------------------------------------------------------------------------------------------- | :------------------------------------------------------ | :--------------------------------- |
65
+ | **Pipeline Architecture** | **Flat Array Pipeline** with explicit signals (`Break_Pipe`, `Break_Pipeline`) | Recursive `next()` callback onion (call-stack overhead) | Nested async middleware cascade |
66
+ | **Execution Guarantees** | Deterministic 5-phase lifecycle: Filters $\to$ Handlers $\to$ Fallbacks $\to$ Catchers $\to$ Afters | Order depends on middleware registration sequence | Mixed middleware / route execution |
67
+ | **Parameter Linking** | **Built-in**: Params parsed/mutated in filters automatically link downstream | ❌ Manual `req` object mutation | ❌ Manual `c.set()` state passing |
68
+ | **HTTP Standards** | **Full Support** including **HTTP `QUERY`** (RFC 9535) | ❌ Standard methods only | ❌ Standard methods only |
69
+ | **Routing Tables** | **Specialized $O(1)$ tables** partitioned by depth, globs (`*`), and super-globs (`**`) | Linear regex iteration | Radix Tree (RegExp-heavy) |
70
+ | **Runtime Portability** | **Native Web Standards**: Pure `Request` in, `Response` out (Bun, Deno, Node, Edge) | Node.js `IncomingMessage` / `ServerResponse` tied | Web Standards / Adapters |
71
+ | **Type Safety** | **Automatic compile-time path parameter inference** directly from string literals | Manual typing / `any` | Generic context inference |
72
+ | **Streaming Multipart** | **Zero-dependency state machine** parsing streams chunk-by-chunk (down to 5 bytes) | Requires `multer` / `busboy` | Requires external plugins |
73
+ | **Built-in Caching** | **Memory-bounded LRU & TTL caches** tracking actual bytes in memory | ❌ None | ❌ None |
74
+ | **OpenAPI Generation** | **Native OpenAPI 3.0 document generation** with auto-tagging and sorting | Requires swagger-jsdoc / plugins | Requires `@hono/zod-openapi` |
79
75
 
80
- ## 📑 Table of Contents
81
-
82
- - [Quick Start](#quick-start)
83
- - [Real World Usecase Example](#real-world-usecase-example)
84
- - [Routing](#routing)
85
- - [Parameters](#parameters)
86
- - [Alternatives](#alternatives)
87
- - [Wildcards](#wildcards)
88
- - [File-Based Wildcards](#file-based-wildcards)
89
-
90
- - [Handler Pipeline](#handler-pipeline)
91
- - [Filter Pipes](#filter-pipes)
92
- - [Handler Pipes](#handler-pipes)
93
-
94
- - [Type-Safe Context](#type-safe-context)
95
- - [File-Based Routing](#file-based-routing)
96
- - [Built for HTTP APIs](#built-for-http-apis)
97
- - [Request parsing](#request-parsing)
98
- - [Responses](#responses)
99
- - [CORS and rate limiting](#cors-and-rate-limiting)
100
- - [Authentication](#authentication)
101
-
102
- - [OpenAPI](#openapi)
103
- - [Error Handling](#error-handling)
104
- - [Performance](#performance)
105
- - [License](#-license)
106
- - [Thanks and Enjoy](#️-thanks-and-enjoy)
107
- - [Be a Sponsor](#-be-a-sponsor)
108
-
109
- ## Quick Start
76
+ ---
110
77
 
111
- Install
112
-
113
- ```sh
114
- pnpm add @bepalo/spine
115
- # or
116
- npm install @bepalo/spine
117
- # or
118
- bun add @bepalo/spine
119
- ```
78
+ ## 🚀 Spine in 30 Seconds
120
79
 
121
80
  ```ts
122
- import {
123
- Router,
124
- json,
125
- text,
126
- toBase64UUID,
127
- parseBody,
128
- parseMultipart,
129
- } from "@bepalo/spine";
130
-
131
- // A user defined custom context shared accross the router
132
- type CTSpineApp = { clientId: string; requestId: string };
81
+ import { Router, json, rjson, text, logRequestsWithColor, HttpError } from "@bepalo/spine";
82
+ import { validate, cors, limitRate, securityHeaders, Status } from "@bepalo/spine";
83
+ import { type, ArkErrors } from "arktype";
84
+
85
+
86
+ // Define constants and context types
87
+ const isProduction = process.env.NODE_ENV !== "production";
88
+ export type CTApp = {}; // Router scope context
89
+
90
+ // Initialize router with maximum path segment depth and defaults
91
+ const spine = new Router<CTApp>({
92
+ maxPath: 24,
93
+ disable: {
94
+ after: true,
95
+ catcher: true,
96
+ fallback: true,
97
+ },
98
+ defaultHandler: () => {
99
+ return json({ message: "Not found" }, { status: Status._404_NotFound });
100
+ },
101
+ defaultCatcher: ({ error }) => {
102
+ if (!isProduction) console.error(error);
103
+ return json(
104
+ { error: error.message },
105
+ { status: (error as HttpError).status || Status._500_InternalServerError },
106
+ );
107
+ },
108
+ defaultAfter: logRequestsWithColor({
109
+ status: { color: "auto", bold: true }, // 2xx green, 3xx cyan, 4xx yellow, 5xx red
110
+ method: { color: "auto", bold: true },
111
+ duration: { color: "yellow", dim: true },
112
+ }),
113
+ afterCatcher: ({ error }) => {
114
+ isProduction && console.error(error);
115
+ },
116
+ });
133
117
 
134
- const spine = new Router<CTSpineApp>();
118
+ spine.get("/health", () => json({ status: "healthy" }));
135
119
 
136
- spine.get("/", () => text("Hello, Spine!"));
120
+ const secRoutes = new Router<CTApp>({ maxPath: 2 });
137
121
 
138
- // pipe specific context extension using CT* context extension types.
139
- spine.get<CTQuery<"q" | "page">>("/search", [
140
- parseQuery(),
141
- ({ query: { q, page } }) => json({ q, page }),
122
+ // Global Filters: Security headers, CORS, and Token-Bucket Rate Limiting
123
+ secRoutes.filterAll("/**", [
124
+ securityHeaders(),
125
+ cors({
126
+ origins: "*",
127
+ methods: ["Get", "Query", "Post", "Put", "Patch", "Delete"],
128
+ }),
129
+ limitRate({
130
+ key: (ctx) => ctx.request.headers.get("x-forwarded-for") || "anonymous",
131
+ maxTokens: 100,
132
+ refillInterval: 60, // 100 tokens per minute
133
+ setXRateLimitHeaders: true,
134
+ }),
142
135
  ]);
143
136
 
144
- spine.get("/users/:id", ({ params: { id } }) => json({ id }));
145
-
146
- spine.post<CTBody<object>>("/users", [
147
- parseBody({ accept: ["application/json"], maxSize: 1024 }),
148
- () => json({ created: true }, { status: 201 }),
137
+ const usersApiRoutes = new Router<CTApp>({ maxPath: 2 });
138
+
139
+ // Parameter Linking & Validation:
140
+ // Validates & parses parameters in a filter; downstream handlers receive typed numbers!
141
+ usersApiRoutes.filterPost("/:id", [
142
+ validate({
143
+ responseType: "json",
144
+ errors: [ArkErrors],
145
+ paramsMutation: true,
146
+ params: type({ id: "string.numeric.parse" }), // parses "123" -> 123
147
+ // params: { id: (id: string) => parseInt(id) }, // you can use your custom type validator too
148
+
149
+
150
+ bodyParse: true,
151
+ bodyMutation: true,
152
+ bodyParseOptions: {
153
+ accept: [
154
+ "application/json",
155
+ "application/rjson",
156
+ "application/x-www-form-urlencoded"
157
+ ],
158
+ maxSize: 1024
159
+ },
160
+ body: type({
161
+ username: "3 <= string <= 20",
162
+ email: "string.email",
163
+ role: "'admin' | 'user'",
164
+ }),
165
+ strange: false, // strips unexpected fields
166
+ }),
149
167
  ]);
150
168
 
151
- // Serve with Bun
152
- Bun.serve({
153
- port: 3000,
154
- fetch: async (request, server) =>
155
- await spine.respond(request, {
156
- headers: new Header({ "X-Powered-By": "@bepalo/spine" }),
157
- requestId: toBase64UUID(crypto.randomUUID()), // compress UUID to base64url 'I6qNV82UTmulXhEhxHpZxw'
158
- clientId: server.requestIP(req).address ?? "anonymous",
159
- }),
169
+ // Main Handlers: Type-safe route parameters with zero boilerplate
170
+ usersApiRoutes.post("/:id", ({ params, body }) => {
171
+ return json({ created: true, id: params.id, user: body }, { status: 201 });
160
172
  });
161
173
 
162
- // Serve with Deno
163
- Deno.serve(
164
- {
165
- port: 3000,
166
- },
167
- (request) => async (request, server) =>
168
- await spine.respond(request, {
169
- headers: new Header({ "X-Powered-By": "@bepalo/spine" }),
170
- requestId: toBase64UUID(crypto.randomUUID()),
171
- clientId: remoteAddr.hostname ?? "anonymous",
172
- }),
173
- );
174
- ```
175
-
176
- That's the core API.
177
-
178
- Spine does not create or manage your server. Your runtime gives Spine a standard `Request`, and Spine returns a standard `Response`.
179
-
180
- ```ts
181
- const response = await spine.respond(request);
182
- ```
183
-
184
- This makes the spine easy to embed into servers, frameworks, workers, and custom runtimes.
185
-
186
- ## Sneek peek of what is possible
187
-
188
- ### `src/utils/generate.ts`
189
-
190
- <details open>
191
-
192
- <summary> Generator utilities to watch for changes and generate static-routes-imports and static-assets-manifest.</summary>
193
-
194
- ```ts
195
- // src/utils/generate.ts
196
- import {
197
- generateStaticAssetsManifestWatcher,
198
- generateStaticRoutesWatcher,
199
- } from "@bepalo/spine";
200
- import { writeFile } from "node:fs/promises";
201
- import { readFile } from "node:fs/promises";
174
+ // It is also okay to do validation in handlers.
175
+ usersApiRoutes.get("/:id", [
176
+ validate({
177
+ responseType: "json",
178
+ errors: [ArkErrors],
179
+ paramsMutation: true,
180
+ params: type({ id: "string.numeric.parse" }), // parses "123" -> 123
181
+ }),
182
+ ({ params }) => {
183
+ return json({ userId: params.id });
184
+ }
185
+ ]);
202
186
 
203
- const abortController = new AbortController();
187
+ // HTTP QUERY Method (RFC 9535 Safe Method with Body)
188
+ usersApiRoutes.query("/search", [
189
+ validate({ bodyParse: true }),
190
+ ({ body }) => rjson({ results: [], query: body }),
191
+ ]);
204
192
 
205
- // setTimeout(() => abortController.abort(), 10000);
193
+ // Append the routes to the main router.
194
+ spine.append(secRoutes);
195
+ spine.appendTo("/api/users", usersApiRoutes)
206
196
 
207
- generateStaticRoutesWatcher({
208
- routesPath: "./routes",
209
- importRoot: "./routes/",
210
- output: "./routes.ts",
211
- read: (filepath) => readFile(filepath, { encoding: "utf-8" }),
212
- write: (filepath, content) =>
213
- writeFile(filepath, content, { encoding: "utf-8" }),
214
- abortSignal: abortController.signal,
215
- // generateDelay: 1000,
216
- });
197
+ // Introspect route definitions
198
+ console.dir(spine.getRoutesByPathnameThenMethod(), { depth: 0 });
217
199
 
218
- generateStaticAssetsManifestWatcher({
219
- assetsPath: "./public",
220
- output: "./static-assets.json",
221
- abortSignal: abortController.signal,
222
- sortOrder: 1,
223
- // exclude: ({ name, ext }) => !name || ext === ".env",
224
- read: (filepath) => readFile(filepath, { encoding: "utf-8" }),
225
- write: (filepath, content) =>
226
- writeFile(filepath, content, { encoding: "utf-8" }),
227
- // generateDelay: 1000,
228
- });
200
+ // Serve natively on Bun, Deno, Node.js, or workers!
201
+ export default {
202
+ fetch: (request: Request) => spine.respond(request),
203
+ };
229
204
  ```
230
205
 
231
- </details>
232
-
233
- ### Static assets
234
-
235
- #### `404.html`
236
-
237
- <details>
238
- <summary>404.html</summary>
239
-
240
- ```html
241
- <!DOCTYPE html>
242
- <html lang="en">
243
- <head>
244
- <meta charset="UTF-8" />
245
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
246
- <title>Document</title>
247
- </head>
206
+ ---
248
207
 
249
- <body>
250
- <h1>404 Page not found!</h1>
251
- <p>We couldn't locate the page you were looking for</p>
252
- </body>
253
- </html>
254
- ```
208
+ ## 📑 Table of Contents
255
209
 
256
- </details>
210
+ - [Why Spine?](#-what-makes-spine-different)
211
+ - [Spine in 30 Seconds](#-spine-in-30-seconds)
212
+ - [Quick Start](#quick-start)
213
+ - [Bun](#bun)
214
+ - [Deno](#deno)
215
+ - [Node.js (v18+)](#nodejs-v18)
216
+ - [The Pipeline Architecture](#the-pipeline-architecture)
217
+ - [The 5 Phases](#the-5-phases)
218
+ - [Control Signals (`Break_Pipe`, `Break_Pipeline`)](#control-signals)
219
+ - [Routing](#routing)
220
+ - [HTTP Methods & Shorthands](#http-methods--shorthands)
221
+ - [Parameters & Compile-Time Inference](#parameters--compile-time-inference)
222
+ - [Path Alternatives](#path-alternatives)
223
+ - [Wildcards (`*`, `*!`, `**`, `**!`, `::slug`)](#wildcards)
224
+ - [File-Based Wildcards Table](#file-based-wildcards-table)
225
+ - [Parameter Linking](#parameter-linking)
226
+ - [Validation Engine (`validate`)](#validation-engine-validate)
227
+ - [Request Logging (`logRequestsWithColor`)](#request-logging-logrequestswithcolor)
228
+ - [Built-In Middlewares & Security](#built-in-middlewares--security)
229
+ - [CORS](#cors)
230
+ - [Rate Limiting (`limitRate`)](#rate-limiting-limitrate)
231
+ - [Security Headers (`securityHeaders`)](#security-headers-securityheaders)
232
+ - [HTTPS Redirection (`forceHttps`)](#https-redirection-forcehttps)
233
+ - [Authentication & Authorization](#authentication--authorization)
234
+ - [Request Parsers & Responses](#request-parsers--responses)
235
+ - [Request Parsers](#request-parsers)
236
+ - [Responses & Helpers (`rjson`, `json`, etc.)](#responses--helpers)
237
+ - [Streaming Multipart Upload Parser](#streaming-multipart-upload-parser)
238
+ - [Caching & Data Structures (`Cache`, `ExpCache`)](#caching--data-structures-cache-expcache)
239
+ - [OpenAPI 3.0 Document Generation](#openapi-30-document-generation)
240
+ - [Router Composition (`appendTo`) & Introspection](#router-composition-appendto--introspection)
241
+ - [File-Based Routing & Watchers](#file-based-routing--watchers)
242
+ - [Performance & Design Invariants](#performance--design-invariants)
243
+ - [📄 License](#-license)
244
+ - [🕊️ Thanks and Enjoy](#️-thanks-and-enjoy)
245
+ - [💖 Be a Sponsor](#-be-a-sponsor)
257
246
 
258
- #### `500.html`
247
+ ---
259
248
 
260
- </details>
249
+ ## Quick Start
261
250
 
262
- <details>
263
- <summary>500.html</summary>
251
+ ### Installation
264
252
 
265
- ```html
266
- <!DOCTYPE html>
267
- <html lang="en">
268
- <head>
269
- <meta charset="UTF-8" />
270
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
271
- <title>Document</title>
272
- </head>
253
+ ```sh
254
+ # npm
255
+ npm install @bepalo/spine
273
256
 
274
- <body>
275
- <h1>{{STATUS}} {{STATUS_TEXT}}</h1>
276
- <p><strong>{{ERROR}}!</strong></p>
277
- </body>
278
- </html>
279
- ```
257
+ # pnpm
258
+ pnpm add @bepalo/spine
280
259
 
281
- </details>
282
-
283
- #### `Swagger`
284
-
285
- <details>
286
- <summary> public/openapi/index.html</summary>
287
-
288
- ```html
289
- <!DOCTYPE html>
290
- <html lang="en">
291
- <head>
292
- <meta charset="utf-8" />
293
- <meta name="viewport" content="width=device-width, initial-scale=1" />
294
- <meta name="description" content="SwaggerUI" />
295
- <title>SwaggerUI</title>
296
- <link
297
- rel="stylesheet"
298
- href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css"
299
- />
300
- </head>
301
-
302
- <body>
303
- <div id="swagger-ui"></div>
304
- <script
305
- src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js"
306
- crossorigin
307
- ></script>
308
- <script>
309
- window.onload = () => {
310
- window.ui = SwaggerUIBundle({
311
- url: "/openapi/doc.json",
312
- dom_id: "#swagger-ui",
313
- });
314
- };
315
- </script>
316
- </body>
317
- </html>
260
+ # bun
261
+ bun add @bepalo/spine
318
262
  ```
319
263
 
320
- </details>
321
-
322
- ### Source Codes
323
-
324
- ### `/user/:id Route`
325
-
326
- <details>
327
- <summary>src/routes/users/[id].ts</summary>
264
+ ### Bun
328
265
 
329
266
  ```ts
330
- // src/routes/user/[id].ts
267
+ import Router, { text, json } from "@bepalo/spine";
331
268
 
332
- import {
333
- json,
334
- parseBody,
335
- type CTBody,
336
- type CTParams,
337
- type HandlerDef,
338
- type PipeDef,
339
- } from "@bepalo/spine";
340
- import { ArkErrors, type } from "arktype";
269
+ const spine = new Router({ maxPath: 24 });
270
+ spine.get("/", () => text("Hello from Bun!"));
271
+ spine.get("/users/:id", ({ params }) => json({ id: params.id }));
341
272
 
342
- export const get_filter: HandlerDef = [
343
- ({ params }) => {
344
- // valdate params
345
- const r = type({
346
- id: "3 <= string.numeric <= 5",
347
- }).assert(params);
348
- if (r instanceof ArkErrors) {
349
- return json({ error: r.toJSON() }, { status: 400 });
350
- }
351
- },
352
- ];
353
-
354
- export const get: PipeDef<CTParams<"id">> = {
355
- pipe: ({ params: { id } }) => {
356
- return parseInt(id) < 0
357
- ? json({ error: "User not found" })
358
- : json({ user: { name: `user-${id}` } });
359
- },
360
-
361
- openApi: {
362
- summary: "Get user by ID",
363
- responses: {
364
- "200": {
365
- description: "Successfull response",
366
- content: {
367
- "application/json": {
368
- schema: {
369
- type: "object",
370
- properties: {
371
- user: {
372
- type: "object",
373
- properties: {
374
- name: {
375
- type: "string",
376
- },
377
- },
378
- },
379
- },
380
- },
381
- },
382
- },
383
- },
384
- "404": {
385
- description: "User not found",
386
- content: {
387
- "application/json": {
388
- schema: {
389
- type: "object",
390
- properties: {
391
- error: {
392
- type: "string",
393
- },
394
- },
395
- },
396
- },
397
- },
398
- },
399
- "400": {
400
- description: "Bad request",
401
- content: {
402
- "application/json": {
403
- schema: {
404
- type: "object",
405
- properties: {
406
- error: {
407
- type: "string",
408
- },
409
- },
410
- },
411
- },
412
- },
413
- },
414
- },
415
- },
416
- };
417
-
418
- export const post_filter: HandlerDef<CTBody> = [parseBody({ maxSize: 1024 })];
419
-
420
- export const post: HandlerDef<CTBody> = ({
421
- url: { pathname },
422
- params,
423
- body,
424
- }) => {
425
- return json({ pathname, params, body });
426
- };
273
+ Bun.serve({
274
+ port: 3000,
275
+ fetch: (req) => spine.respond(req),
276
+ });
427
277
  ```
428
278
 
429
- </details>
430
-
431
- #### `Main`
432
-
433
- <details>
434
- <summary>src/index.ts</summary>
279
+ ### Deno
435
280
 
436
281
  ```ts
437
- import type { Path, StaticAssetsManifestFile } from "@bepalo/spine";
438
- import {
439
- Router,
440
- ExpCache,
441
- HttpError,
442
- Status,
443
- json,
444
- cors,
445
- limitRate,
446
- getHttpStatusText,
447
- status,
448
- securityHeaders,
449
- Break_Pipeline,
450
- } from "@bepalo/spine";
451
- import { readFileSync } from "node:fs";
452
- import { writeFile } from "node:fs/promises";
453
- // Import generated static routes imports
454
- import setRoutes from "./routes";
455
- // Import generated static assets manifest
456
- import staticAssetsManifest from "./static-assets.json";
457
-
458
- const {
459
- "/404": notFoundAsset,
460
- "/500": serverErrorAsset,
461
- ...staticAssets
462
- } = staticAssetsManifest.files;
463
-
464
- // lru-exp cache for static assets
465
- const assetsCache: ExpCache<string, Buffer<ArrayBuffer>> = new ExpCache({
466
- maxMemory: 32 * 1024 * 1024, // 32Mb
467
- onMiss(key, entry, reason, cache) {
468
- if (!(key in staticAssetsManifest.files)) return;
469
- const asset = (staticAssetsManifest.files as any)[key];
470
- // Read from file into cache because it is missing.
471
- // You only have to call `assetsCache.get` elsewhere
472
- // as this will automatically load it on cache-miss.
473
- cache.set(
474
- key,
475
- readFileSync(asset.path, { encoding: undefined }),
476
- asset.size,
477
- {
478
- ttl: 3_600_000, // 1 hour
479
- },
480
- );
481
- return true;
482
- },
483
- });
282
+ import Router, { text, json } from "jsr:@bepalo/spine";
283
+ // or import Router from "@bepalo/spine";
484
284
 
485
- // Static assets cache cleanup timer.
486
- // You could also setup a cron api.
487
- setInterval(() => {
488
- console.log("Cleared ", assetsCache.evictExpired());
489
- }, 3_600_000);
285
+ const spine = new Router({ maxPath: 24 });
286
+ spine.get("/", () => text("Hello from Deno!"));
287
+ spine.get("/users/:id", ({ params }) => json({ id: params.id }));
490
288
 
491
- export type CTMain = { clientIP: string };
289
+ Deno.serve({ port: 3000 }, (req) => spine.respond(req));
290
+ ```
492
291
 
493
- // Create router instance
494
- export const spine = new Router<CTMain>({
495
- maxPath: 10,
496
- });
292
+ ### Node.js (v18+)
497
293
 
498
- // Serve
499
- const server = Bun.serve({
500
- port: 3000,
501
- fetch: (request, server) =>
502
- spine.respond(request, {
503
- clientIP: server.requestIP(request)?.address || "anonymous",
504
- }),
294
+ ```ts
295
+ import { createServer } from "node:http";
296
+ import { Readable } from "node:stream";
297
+ import Router, { text, json } from "@bepalo/spine";
298
+
299
+ const spine = new Router({ maxPath: 24 });
300
+ spine.get("/", () => text("Hello from Node.js!"));
301
+ spine.get("/users/:id", ({ params }) => json({ id: params.id }));
302
+
303
+ const server = createServer(async (req, res) => {
304
+ const url = `http://${req.headers.host || "localhost"}${req.url}`;
305
+ const isBodyAllowed = !["GET", "HEAD"].includes(req.method!);
306
+
307
+ const webReq = new Request(url, {
308
+ method: req.method,
309
+ headers: req.headers as any,
310
+ body: isBodyAllowed ? (Readable.toWeb(req) as any) : undefined,
311
+ duplex: isBodyAllowed ? "half" : undefined,
312
+ } as any);
313
+
314
+ const webRes = await spine.respond(webReq);
315
+
316
+ res.statusCode = webRes.status;
317
+ res.statusMessage = webRes.statusText;
318
+ webRes.headers.forEach((val, key) => res.setHeader(key, val));
319
+
320
+ if (webRes.body) {
321
+ const reader = webRes.body.getReader();
322
+ while (true) {
323
+ const { done, value } = await reader.read();
324
+ if (done) break;
325
+ res.write(value);
326
+ }
327
+ }
328
+ res.end();
505
329
  });
506
- console.log(`Listening on ${server.url}`);
507
-
508
- //////////////////////////////////////////////
509
330
 
510
- // Set generated dynamic-routes' static-imports
511
- setRoutes(spine);
331
+ server.listen(3000);
332
+ ```
512
333
 
513
- // Security Headers, CORS, Rate Limiting, ... for /**
514
- spine.filterAll("/**", [
515
- // forceHttps({ toPort: server.port }),
516
- limitRate<CTMain>({
517
- key: ({ clientIP }) => clientIP,
518
- maxTokens: 100,
519
- refillInterval: 60 * 1000, // every minute
520
- // refillRate:
521
- setXRateLimitHeaders: process.env.NODE_ENV !== "production",
522
- }),
523
- securityHeaders({
524
- headers: {
525
- "Reporting-Endpoints": `coep-endpoint="${server.url.origin + "/coep"}"`,
526
- },
527
- crossOriginEmbedderPolicy: 'credentialless; report-to="coep-endpoint"',
528
- crossOriginResourcePolicy: "same-site",
529
- crossOriginOpenerPolicy: "same-origin-allow-popups",
530
- referrerPolicy: "strict-origin-when-cross-origin",
531
- xFrameOptions: "DENY",
532
- contentSecurityPolicy: [
533
- ["default-src", "'self'"],
534
- ["object-src", "'none'"],
535
- ["frame-ancestors", "'none'"],
536
- [
537
- "script-src style-src font-src",
538
- "'self'",
539
- "https://unpkg.com",
540
- "'unsafe-inline'",
541
- ],
542
- ["script-src", "'self'", "'strict-dynamic'", "'unsafe-inline'"],
543
- ["img-src", "'self'", "data:", "'unsafe-inline'"],
544
- ["upgrade-insecure-requests"],
545
- ],
546
- }),
547
- cors({
548
- origins: ["https://example.com", server.url.origin],
549
- methods: ["Get", "Head", "Options"],
550
- allowedHeaders: ["Authorization", "X-API-Key"],
551
- credentials: true,
552
- maxAge: 60 * 60,
553
- }),
554
- ]);
334
+ ---
555
335
 
556
- // Security Headers, CORS, Rate Limiting, ... for /api/**
557
- spine.filterAll("/api/**", [
558
- // forceHttps({ toPort: server.port }),
559
- limitRate<CTMain>({
560
- key: ({ clientIP }) => clientIP,
561
- maxTokens: 300,
562
- refillInterval: 60 * 1000, // every minute
563
- refillRate: 100, // 100 tokens every minute
564
- setXRateLimitHeaders: process.env.NODE_ENV !== "production",
565
- }),
566
- securityHeaders({
567
- // crossOriginResourcePolicy: "same-site",
568
- // referrerPolicy: "strict-origin-when-cross-origin",
569
- xFrameOptions: "DENY",
570
- contentSecurityPolicy: [["upgrade-insecure-requests"]],
571
- }),
572
- cors({
573
- origins: ["https://example.com", server.url.origin],
574
- methods: ["Get", "Post", "Put", "Patch", "Delete", "Head", "Options"],
575
- allowedHeaders: ["Content-Type", "Authorization", "X-API-Key"],
576
- credentials: true,
577
- maxAge: 60 * 60,
578
- }),
579
- // do not bubble to other matching filters such as /**
580
- () => Break_Pipeline,
581
- ]);
336
+ ## The Pipeline Architecture
582
337
 
583
- // Handle Options for all /** to return no content
584
- spine.handleOptions("/**", () => status(204, null));
585
-
586
- // Handler get and head of static assets using generated manifest
587
- spine.handle(
588
- [["Head", "Get"], ...(Object.keys(staticAssets) as Path[])],
589
- ({ url, request, headers }) => {
590
- const asset: StaticAssetsManifestFile = (staticAssets as any)[url.pathname];
591
- headers.set("Content-Type", asset.contentType);
592
- headers.set("Content-Length", asset.size.toFixed());
593
- const fileContent = assetsCache.get(asset.pathname);
594
- if (request.method === "HEAD") {
595
- return status(200, null);
596
- }
597
- return new Response(fileContent);
598
- },
599
- );
338
+ In Spine, middleware is not an "onion". You do not call `next()` or manage nested promise stacks.
600
339
 
601
- // Handle fallbacks of get /** using 404.html static page
602
- spine.fallback([["Head", "Get"], "/**"], ({ url, request, headers }) => {
603
- const asset: StaticAssetsManifestFile = notFoundAsset;
604
- headers.set("Content-Type", asset.contentType);
605
- headers.set("Content-Length", asset.size.toFixed());
606
- if (request.method === "HEAD") {
607
- return status(200, null);
608
- }
609
- const fileContent = assetsCache.get(notFoundAsset.pathname);
610
- return new Response(fileContent, { status: 404 });
611
- });
340
+ Handlers are structured as **flat, sequential array pipelines**:
612
341
 
613
- // Set error handler of All /**
614
- // Note: The string replacement is just for demonstration.
615
- // You are probably going to use a framework like
616
- // pug, react, Nextjs or others.
617
- spine.catchAll("/**", ({ error, request, headers }) => {
618
- // process.env.NODE_ENV !== "production" && console.error(error);
619
- const statusCode = (error as HttpError).status || 500;
620
- const asset: StaticAssetsManifestFile = serverErrorAsset;
621
- headers.set("Content-Type", asset.contentType);
622
- headers.set("Content-Length", asset.size.toFixed());
623
- if (request.method === "HEAD") {
624
- return status(200, null);
625
- }
626
- const fileContent = assetsCache.get(asset.pathname);
627
- const vars = {
628
- STATUS: String(statusCode),
629
- STATUS_TEXT: getHttpStatusText(statusCode),
630
- ERROR: error.message,
631
- };
632
- return new Response(
633
- fileContent!
634
- .toString()
635
- .replace(/(\\)?\{\{(.+?)\}\}/g, (match, escape, id) =>
636
- escape ? match : ((vars as any)[id] ?? match),
637
- ),
638
- { status: statusCode },
639
- );
640
- });
342
+ ```ts
343
+ type Handler<ExtendContext> = (
344
+ ctx: Context<ExtendContext>,
345
+ ) => Promise<HandlerReturn> | HandlerReturn;
346
+ type Pipe<ExtendContext> = Array<Handler<ExtendContext>>;
347
+ ```
641
348
 
642
- // Set error handler of All /api/**
643
- // Takes precedence over /**
644
- spine.catchAll("/api/**", ({ error }) => {
645
- process.env.NODE_ENV !== "production" && console.error(error);
646
- const status = (error as HttpError).status || 500;
647
- return json({ error: error.message }, { status });
648
- });
349
+ ### The 5 Phases
649
350
 
650
- // Set fallback handler of (Get,Post,Put,Patch,Delete) /api/**
651
- spine.fallbackCrud("/api/**", () =>
652
- json({ error: "Not found" }, { status: 404 }),
653
- );
351
+ When `spine.respond(request)` is executed, requests travel in strict order through 5 phases:
654
352
 
655
- // Error test
656
- spine.get(["/error", "/api/error"], () => {
657
- throw new HttpError(Status._503_ServiceUnavailable, "Come back tomorrow");
658
- });
353
+ 1. **Filters** (`filterGet`, `filterPost`, `filterAll`, etc.): Run before handlers. Used for authentication, CORS, rate limiting, and request parsing. Filters bubble across all matching route entries (Exact $\to$ Glob $\to$ SuperGlob $\to$ `defaultFilter`). If any filter returns a `Response`, execution jumps directly to Response Assembly and Afters.
354
+ 2. **Handlers** (`get`, `query`, `post`, `handle`, etc.): Main business logic. **Only the single most specific match runs (`noBubble: true`)**. If no response is returned, runs `defaultHandler` (if defined) before moving to Fallbacks.
355
+ 3. **Fallbacks** (`fallbackGet`, `fallbackAll`, etc.): Evaluated if no handler responded. Runs matching fallback routes and `defaultFallback`.
356
+ 4. **Catchers** (`catchGet`, `catchAll`, etc.): Evaluated when an uncaught error is thrown during Filters, Handlers, or Fallbacks.
357
+ 5. **Afters** (`afterGet`, `afterAll`, `defaultAfter`): Always executes on the final `Response` object for logging, auditing, and header injection. Errors thrown here are captured by `afterCatcher` without breaking the outgoing response.
659
358
 
660
- // Stats
661
- spine.get("/api/stats", () => json({ staticAssetsCache: assetsCache.stats }));
662
-
663
- spine.afterAll(
664
- "/**",
665
- ({
666
- request: { method },
667
- response: { status, statusText, headers, body },
668
- url,
669
- timestamps,
670
- }) => {
671
- const { request, start, end } = timestamps;
672
- const size = ["OPTIONS", "HEAD"].includes(method)
673
- ? 0
674
- : Number(headers.get("Content-Length") || "0");
675
- const kbSize = ((size ?? 0) / 1024).toFixed(2).padStart(5);
676
- const time = (end - start).toFixed(3).padStart(6);
677
- let logstr = `[${new Date(request).toISOString()}]`;
678
- logstr += `[${status}]`;
679
- logstr += ` ${time}ms ${kbSize}KB`;
680
- logstr += ` -- ${method} ${url.pathname} ${url.search}`;
681
- logstr += ` -- ${statusText}`;
682
- console.log(logstr);
683
- },
684
- );
359
+ ### Control Signals
685
360
 
686
- //////////////////////////////////////////
361
+ Handlers communicate with the pipeline using explicit return values:
687
362
 
688
- // generate public/openapi/doc.json
689
- spine
690
- .generateOpenAPI(
691
- {
692
- title: "@bepalo/spine Demo",
693
- version: "1.0.0",
694
- // ...
695
- },
696
- {
697
- pick: ({ path }) => path.startsWith("/api"),
698
- // autoTag: false,
699
- // autoSummary: false,
700
- includeOperationId: true,
701
- sortTagsOrder: 1,
702
- sortPathnameOrder: 1,
703
- sortMethodOrder: 1,
704
- },
705
- )
706
- .then(async (openapi) => {
707
- const output = "./public/openapi/doc.json";
708
- const content = JSON.stringify(openapi, null, 2);
709
- await writeFile(output, content, { encoding: "utf-8" });
710
- });
363
+ ```ts
364
+ import { Break_Pipe, Break_Pipeline } from "@bepalo/spine";
711
365
  ```
712
366
 
713
- </details>
367
+ - **`void` / `undefined`**: Continues execution to the next handler in the current pipe.
368
+ - **`Response` instance**: **Short-circuits immediately**. Sets the active response and proceeds directly to Response Assembly and Afters.
369
+ - **`Break_Pipe`**: Breaks out of the **current** route pipe without returning a response, allowing parent wildcard pipes in the same phase to continue.
370
+ - **`Break_Pipeline`**: Breaks out of the **entire stage** (e.g. stops filter bubbling to `/**`), without returning a response.
714
371
 
715
372
  ---
716
373
 
717
374
  ## Routing
718
375
 
719
- Use the convenient HTTP method helpers:
720
-
721
- ```ts
722
- spine.get("/users", handler);
723
- spine.post("/users", handler);
724
- spine.put("/users/:id", handler);
725
- spine.patch("/users/:id", handler);
726
- spine.delete("/users/:id", handler);
727
- ```
376
+ ### HTTP Methods & Shorthands
728
377
 
729
- Or register several methods at once:
378
+ Spine provides direct shorthands for all HTTP methods:
730
379
 
731
380
  ```ts
732
- spine.all("/health", handler);
733
- spine.crud("/users/:id", handler);
381
+ spine.get("/items", handler);
382
+ spine.query("/items", handler); // RFC 9535 HTTP QUERY method
383
+ spine.post("/items", handler);
384
+ spine.put("/items/:id", handler);
385
+ spine.patch("/items/:id", handler);
386
+ spine.delete("/items/:id", handler);
387
+ spine.head("/items", handler);
388
+ spine.options("/items", handler);
389
+ spine.trace("/items", handler);
390
+ spine.connect("/items", handler);
391
+
392
+ // Multi-method shorthands
393
+ spine.all("/health", handler); // Matches ALL HTTP methods
394
+ spine.crud("/users/:id", handler); // Matches GET, QUERY, POST, PUT, PATCH, DELETE
734
395
  ```
735
396
 
736
- All standard HTTP methods are supported:
397
+ Batch registration via method strings or matrix arrays:
737
398
 
738
- ```text
739
- HEAD GET POST PUT PATCH
740
- DELETE OPTIONS TRACE CONNECT
399
+ ```ts
400
+ spine.handle("Get /users", handler);
401
+ spine.handle(["Get /users", "Post /users"], handler);
402
+ spine.handle([["Get", "Query", "Post"], "/users", "/accounts"], handler);
741
403
  ```
742
404
 
743
- ### Parameters
405
+ ### Parameters & Compile-Time Inference
744
406
 
745
- Parameters are detected from pathname using typescript. So, you have typesafety and autocomplete for that. Cool!
407
+ Route parameters are automatically parsed from string literals with zero manual generic typing:
746
408
 
747
409
  ```ts
410
+ // 'userId' and 'postId' are strongly-typed string parameters on ctx.params!
748
411
  spine.get("/users/:userId/posts/:postId", ({ params }) => {
749
- return json({
750
- userId: params.userId,
751
- postId: params.postId,
752
- });
412
+ return json({ user: params.userId, post: params.postId });
753
413
  });
754
414
  ```
755
415
 
756
- ### Alternatives
416
+ ### Path Alternatives
757
417
 
758
- A route segment can contain alternatives:
418
+ Declare branching route segments inline using pipe syntax `|`:
759
419
 
760
420
  ```ts
761
- spine.get("/|about|contact", handler);
421
+ spine.get("/|about|contact", (ctx) => text(`Matched: ${ctx.pathname}`));
422
+ // Matches: "/", "/about", and "/contact"
762
423
  ```
763
424
 
764
- This matches:
425
+ Combine alternatives with named parameters:
765
426
 
766
- ```text
767
- /
768
- /about
769
- /contact
427
+ ```ts
428
+ spine.get("/api/users|accounts/:type", ({ params }) => {
429
+ return json({ type: params.type }); // "users" or "accounts"
430
+ });
431
+
432
+ spine.get("/status/active|pending:state", ({ params }) => {
433
+ return json({ state: params.state }); // "active" or "pending"
434
+ });
770
435
  ```
771
436
 
772
- Alternatives can also be combined with parameters:
437
+ ### Wildcards
438
+
439
+ - `*` — Matches exactly one path segment (e.g. `/files/*`).
440
+ - `*!` — Optional single-segment wildcard at the end (e.g. `/api/*!` matches `/api` and `/api/users`).
441
+ - `**` — Multi-segment wildcard matching any depth (e.g. `/static/**`).
442
+ - `**!` — Optional multi-segment wildcard (e.g. `/assets/**!` matches `/assets`, `/assets/`, and `/assets/a/b/c`).
443
+ - `::slug` — Named super-glob capturing the remaining path into `ctx.params.slug`.
444
+ - `::slug!` — Optional named super-glob.
773
445
 
774
446
  ```ts
775
- spine.get("/api/|users|accounts/:id", handler);
447
+ spine.get("/files/::filepath", ({ params }) => {
448
+ return json({ file: params.filepath }); // e.g. "docs/2026/report.pdf"
449
+ });
776
450
  ```
777
451
 
778
- ### Wildcards
452
+ ### File-Based Wildcards Table
453
+
454
+ | ROUTER PATH | FILE PATH | MATCHES |
455
+ | :--------------------- | :---------------------------- | :-------------------------------------------- |
456
+ | `/exact/path` | `/exact/path.ts` | `/exact/path` |
457
+ | `/wild/glob/*` | `/wild/glob/[#].ts` | `/wild/glob/abc` |
458
+ | `/wild/glob/match/*!` | `/wild/glob/match/[[#]].ts` | `/wild/glob/match`, `/wild/glob/match/abc` |
459
+ | `/named/:id/view` | `/named/[id]/view.ts` | `/named/123/view` |
460
+ | `/super/globs/**` | `/super/globs/[##].ts` | `/super/globs/a/b/c` |
461
+ | `/super/globs/**!` | `/super/globs/[[##]].ts` | `/super/globs`, `/super/globs/a/b` |
462
+ | `/named/super/::slug` | `/named/super/[## slug].ts` | `/named/super/path/to/file.png` |
463
+ | `/named/super/::slug!` | `/named/super/[[## slug]].ts` | `/named/super`, `/named/super/path/to/file` |
464
+ | `/docs/a\|b\|c:page` | `/docs/[[a,b,c] page].ts` | `/docs/a`, `/docs/b`, `/docs/c` |
465
+ | `#filename` | `/#index.ts` | Escapes filename (prevents collapsing to `/`) |
779
466
 
780
- Spine supports single-segment and multi-segment wildcards:
467
+ ---
781
468
 
782
- ```text
783
- \* one path segment
784
- \*\! optional single-segment suffix
785
- \*\* multiple path segments
786
- \*\*\! optional multi-segment suffix
787
- ```
469
+ ## Parameter Linking
788
470
 
789
- For example:
471
+ Spine features **Pipeline Parameter Linking**. When a filter validates or transforms a route parameter (e.g. converting a string ID into a number using `validate({ paramsMutation: true })`), that mutated value automatically forwards to downstream handlers matching that parameter name and index.
790
472
 
791
473
  ```ts
792
- spine.get("/files/*", handler);
793
-
794
- spine.get("/api/**", ({ params }) => {
795
- console.log(params.$);
796
- console.log(params.$$);
474
+ import { validate } from "@bepalo/spine";
475
+ import { type, ArkErrors } from "arktype";
476
+
477
+ // 1. Filter mutates params.id into a number
478
+ spine.filterGet("/api/users/:id", [
479
+ validate({
480
+ errors: [ArkErrors],
481
+ paramsMutation: true,
482
+ params: type({ id: "string.numeric.parse" }), // "123" -> 123
483
+ }),
484
+ ]);
797
485
 
798
- return json({ ok: true });
486
+ // 2. Main handler receives the parsed number directly in params!
487
+ spine.get("/api/users/:id", ({ params }) => {
488
+ // typeof params.id is number!
489
+ return json({ id: params.id });
799
490
  });
800
491
  ```
801
492
 
802
- Use `*!` and `**!` when the wildcard portion is optional. `/abc/def/*!` will
803
- match `/abc/def` while `/abc/def/*` will not.
804
-
805
- ### File-Based Wildcards
493
+ > [!NOTE]
494
+ > Single-glob parameters (`:id`) and super-glob parameters (`::id`) use isolated namespaces (`id#1` vs `id##1`), preventing variable collision.
806
495
 
807
- Filesystem-safe route names are provided for wildcard patterns:
496
+ ---
808
497
 
809
- ```text
810
- [#] → *
811
- [[#]] → *!
498
+ ## Validation Engine (`validate`)
812
499
 
813
- [##] → **
814
- [[##]] → **!
815
- ```
500
+ The built-in `validate` middleware validates `params`, `query`, `cookie`, and `body` using Regex, custom functions, or schema libraries (such as ArkType or Zod):
816
501
 
817
- For example:
502
+ ```ts
503
+ import { validate } from "@bepalo/spine";
504
+ import { type, ArkErrors } from "arktype";
818
505
 
819
- ```text
820
- routes/
821
- └── api/
822
- └── [##].ts
823
- ```
506
+ spine.filterPost("/api/users/:id", [
507
+ validate({
508
+ // Response format on error: "json" | "text" | "status"
509
+ responseType: "json",
824
510
 
825
- maps to:
511
+ // Catch custom schema error classes
512
+ errors: [ArkErrors],
826
513
 
827
- ```text
828
- /api/**
829
- ```
830
-
831
- while:
514
+ // Validate and parse route parameters
515
+ paramsMutation: true,
516
+ params: type({
517
+ id: "string.numeric.parse",
518
+ }),
832
519
 
833
- ```text
834
- routes/
835
- └── api/
836
- └── [[##]].ts
837
- ```
520
+ // Automatically parse query before validation
521
+ queryParse: true,
522
+ queryMutation: true,
523
+ query: {
524
+ tab: (val) =>
525
+ ["profile", "billing"].includes(val) || new Error("Invalid tab"),
526
+ },
838
527
 
839
- maps to:
528
+ // Automatically parse body before validation
529
+ bodyParse: true,
530
+ bodyParseOptions: { accept: "application/json", maxSize: 1024 * 1024 },
531
+ bodyMutation: true,
532
+ body: type({
533
+ username: "3 <= string <= 20",
534
+ role: "'admin' | 'user'",
535
+ password: "string >= 8",
536
+ }),
840
537
 
841
- ```text
842
- /api/**!
538
+ // Strip unexpected properties from mutated objects
539
+ strange: false,
540
+ }),
541
+ ]);
843
542
  ```
844
543
 
845
544
  ---
846
545
 
847
- | ROUTER PATH | FILE PATH | MATCHES `highlighted` |
848
- | --------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------ |
849
- | `/exact/path` | `/exact/path` | "/exact/path" |
850
- | `/slash/matters/` | `/slash/matters/` | "/slash/matters/" |
851
- | `/wild/glob/*` | `/wild/glob/[#]` | "/wild/glob/` `" "/wild/glob/`y`" "/wild/glob/`n`" |
852
- | `/wild/glob/match/base/*!` | `/wild/glob/match/base/[[#]]` | "/wild/glob/match/base" "/wild/glob/match/base/` `" "/wild/glob/match/base/`y`" |
853
- | `/globs/*/cool/*` | `/globs/[#]/cool/[#]` | "/globs/` `/cool/" "/globs/`are`/cool/" "/globs/`are`/cool/`breath`" |
854
- | `/globs/*/cool/*!` | `/globs/[#]/cool/[[#]]` | "/globs/` `/cool" "/globs/`are`/cool/` `" "/globs/` `/cool/breath" "/globs/`are`/cool/`y`" |
855
- | `/named/:glob/here` | `/named/[glob]/here` | "/named/` `/here" "/named/`pet`/here/" |
856
- | `/named/optional/:glob!` | `/named/optional/[[glob]]` | "/named/optional" "/named/optional/` `" "/named/optional/`pet`" |
857
- | `/super/globs/**` | `/super/globs/[##]` | "/super/globs/` `" "/super/globs/`here`" "/super/globs/`here/and/there`" |
858
- | `/super/globs/**!` | `/super/globs/[[##]]` | "/super/globs" "/super/globs/` `" "/super/globs/`here`" "/super/globs/`1/2/3/4`" |
859
- | `/named/super/::slug` | `/named/super/[## slug]` | "/named/super/` `" "/named/super/`pet`" "/named/super/`man/town`" |
860
- | `/named/super/::slug!` | `/named/super/[[## slug]]` | "/named/super" "/named/super/` `" "/named/super/`pet`" "/named/super/`man/town`" |
861
- | `/certain/a\|b\|c\|:options/y\|n` | `/certain/[[,a,b,c] options ]/y` | "/certain/`a`/n" "/certain/`b`/y" "/certain/`c`/n" "/certain/` `/y" |
862
- | `/certain/a\|b\|c:options!/y\|n` | `/certain/[[a,b,c] [options] ]/n` | "/certain/`a`/y" "/certain/`b`/n" "/certain/`c`/y" |
863
-
864
- ## Handler Pipeline
865
-
866
- Spine separates request processing into explicit phases:
867
-
868
- ```text
869
- ( @Bepalo/spine )
870
- router pipe
871
- ┌───────────────────────┐
872
- ▼ │
873
- ┌──────┴───────┐ │
874
- ┌─────────│ Filters │─────────┐ <request>
875
- │ └──────┬───────┘ │ │
876
- │ <no match nor response> │ │
877
- │ ▼ │ │
878
- │ ┌──────┴───────┐ │ ┌──┴────────┐
879
- ├─────────│ Handlers │─────────┤ │ Server │◄───┐
880
- │ └──────┬───────┘ │ └──┬─────┬──┘ │
881
- │ <no match nor response> │ ▲ │ <request>
882
- <error> ▼ │ │ <response> │
883
- │ ┌──────┴───────┐ │ │ ▼ │
884
- ├─────────│ Fallbacks │─────────┤ │ ┌─┴───────┴─┐
885
- ▼ └──────┬───────┘ │ │ │ Client │
886
- ┌─────┴──────┐ │ ┌──<response>─┘ │ └───────────┘
887
- │ Catchers │ ▼ ▼ │
888
- └─────┬──────┘ ┌──────┴───┴───┐ │
889
- └────────►│ Afters │───────────────┘
890
- <error-response> └──────────────┘ <final-response>
891
- ```
892
-
893
- ### Filter Pipes
546
+ ## Request Logging (`logRequestsWithColor`)
894
547
 
895
- The first handling stage of a request is done through the filter stage.
896
- Use this stage to parse and validate the request.
548
+ High-performance After-hook middlewares for request logging:
897
549
 
898
550
  ```ts
899
- spine.filterCrud<CTAuth>("/user/**!", [
900
- parseQuery(),
901
- parseCookie(),
902
- authenticate(),
903
- authorize(),
904
- ]);
905
- spine.filterPost("/user", [
906
- parseCookie(),
907
- authenticate(),
908
- authorize(),
909
- parseBody(),
551
+ import { logRequestsWithColor } from "@bepalo/spine";
552
+
553
+ spine.afterAll("/**", [
554
+ logRequestsWithColor({
555
+ enable: {
556
+ requestTime: true,
557
+ duration: true,
558
+ status: "status",
559
+ search: "singleline",
560
+ },
561
+ status: { color: "auto", bold: true }, // Auto-colored: 2xx green, 3xx cyan, 4xx yellow, 5xx red
562
+ method: { color: "auto", bold: true }, // Per-method coloring
563
+ duration: { color: "yellow", dim: true },
564
+ }),
910
565
  ]);
911
566
  ```
912
567
 
913
- ### Handler Pipes
568
+ ---
914
569
 
915
- This is main handling stage of a request.
570
+ ## Built-In Middlewares & Security
571
+
572
+ ### CORS
916
573
 
917
574
  ```ts
918
- spine.get("/user/:id", []);
919
- spine.filterPost("/user", [
920
- parseCookie(),
921
- authenticate(),
922
- authorize(),
923
- parseBody(),
575
+ import { cors } from "@bepalo/spine";
576
+
577
+ spine.filterAll("/api/**", [
578
+ cors({
579
+ origins: ["https://example.com"], // or "*"
580
+ methods: [ "Get", "Query", "Post", "Put", "Patch", "Delete", "Head", "Options" ],
581
+ allowedHeaders: ["Content-Type", "Authorization"],
582
+ credentials: true, // Note: credentials cannot be used with origins: "*"
583
+ maxAge: 86400,
584
+ responseType: "json",
585
+ }),
924
586
  ]);
925
587
  ```
926
588
 
927
- This lets cross-cutting behavior remain separate from your actual route handlers.
589
+ ### Rate Limiting (`limitRate`)
590
+
591
+ High-performance token-bucket rate limiter supporting interval or continuous refill rates:
928
592
 
929
593
  ```ts
930
- spine.filterGet("/api/**", [limitRate(), cors(), authenticate()]);
594
+ import { limitRate } from "@bepalo/spine";
931
595
 
932
- spine.get("/api/users", listUsers());
596
+ spine.filterAll("/api/**", [
597
+ limitRate({
598
+ key: (ctx) => ctx.request.headers.get("x-forwarded-for") || "anonymous",
599
+ maxTokens: 100,
600
+ refillInterval: 60, // 100 tokens per 60s
601
+ refillRate: 10,
602
+ setXRateLimitHeaders: true, // Sets X-RateLimit-Limit & X-RateLimit-Remaining
603
+ responseType: "json",
604
+ }),
605
+ ]);
606
+ ```
933
607
 
934
- spine.fallbackGet("/api/**", () =>
935
- json({ error: "Not Found" }, { status: 404 }),
936
- );
608
+ ### Security Headers (`securityHeaders`)
937
609
 
938
- spine.catchGet("/api/**", ({ error }) =>
939
- json({ error: error?.message }, { status: 500 }),
940
- );
610
+ ```ts
611
+ import { securityHeaders } from "@bepalo/spine";
941
612
 
942
- spine.afterGet("/api/**", ({ response }) => {
943
- console.log(response.status);
944
- // even the response after a caught error will pass through the after-pipe
945
- // error thrown here is not caught.
946
- // afters are best used for logging or modifying the final response
947
- });
613
+ spine.filterAll("/**", [
614
+ securityHeaders({
615
+ xFrameOptions: "DENY", // or null to unset default
616
+ xContentTypeOptions: "nosniff", // or null to unset default
617
+ referrerPolicy: "strict-origin-when-cross-origin",
618
+ strictTransportSecurity: {
619
+ maxAge: 31536000,
620
+ includeSubDomains: true,
621
+ preload: true,
622
+ },
623
+ contentSecurityPolicy: {
624
+ "default-src": "'self'",
625
+ "script-src": ["'self'", "https://cdn.example.com"],
626
+ "object-src": "'none'",
627
+ "upgrade-insecure-requests": true,
628
+ },
629
+ crossOriginOpenerPolicy: "same-origin",
630
+ crossOriginEmbedderPolicy: "credentialless",
631
+ crossOriginResourcePolicy: "same-site",
632
+ }),
633
+ ]);
948
634
  ```
949
635
 
950
- Handlers can also be composed into pipelines:
636
+ ### HTTPS Redirection (`forceHttps`)
951
637
 
952
638
  ```ts
953
- spine.post("/users", [parseBody(), validateUser(), createUser()]);
639
+ import { forceHttps } from "@bepalo/spine";
640
+
641
+ spine.filterAll("/**", [
642
+ forceHttps({ toPort: 443 }), // 308 Permanent Redirect preserving method and body
643
+ ]);
954
644
  ```
955
645
 
956
- A pipe can stop normally by returning a `Response`, or use Spine's explicit control symbols:
646
+ ### Authentication & Authorization
957
647
 
958
648
  ```ts
959
- import { Break_Pipe, Break_Pipeline } from "@bepalo/spine";
960
-
961
- spine.filterGet("/**", cors({ maxTokens: 60 }));
962
- spine.filterGet("/api/**", [cors({ maxTokens: 200 }), () => Break_Pipeline]);. /* '/**' cors wont be called */
649
+ import {
650
+ Router,
651
+ Status,
652
+ authenticate,
653
+ authorize,
654
+ json,
655
+ type CTAuth,
656
+ } from "@bepalo/spine";
657
+ import { JWT } from "@bepalo/jwt";
658
+
659
+ type UserRole = "admin" | "user";
660
+ type AuthData = { userId: string; role: UserRole };
661
+ // console.log(JWT.genHmac("HS256"));
662
+ const adminJWT = JWT.createSymmetric<AuthData>("<secret>", "HS256");
663
+
664
+ const spine = new Router<CTAuth<AuthData>>({ maxPath: 5 });
665
+
666
+ spine.filterAll("/admin/**", [
667
+ authenticate<AuthData>({
668
+ responseType: "json",
669
+ parseAuth: async (ctx) => {
670
+ const token = ctx.request.headers
671
+ .get("authorization")
672
+ ?.replace("Bearer ", "");
673
+ if (!token) return null; // Returns 401 Unauthorized
674
+ const { valid, error, payload } = adminJWT.verifySync(token);
675
+ if (error) {
676
+ return json(
677
+ { error: error.message },
678
+ { status: Status._401_Unauthorized },
679
+ );
680
+ }
681
+ const { userId, role } = payload!;
682
+ return { userId, role };
683
+ },
684
+ }),
963
685
 
964
- // Break_Pipeline breaks from the overall handlers pipe while
965
- // Break_Pipe breaks from the current handler pipe without returning a Response.
686
+ authorize<AuthData>({
687
+ responseType: "json",
688
+ allowRole: (role) => role === "admin", // Returns 403 Forbidden on failure
689
+ }),
690
+ ]);
966
691
 
692
+ spine.get("/admin/users", ({ auth }) => {
693
+ const { role, userId } = auth!;
694
+ // ...
695
+ });
967
696
  ```
968
697
 
969
- ## Type-Safe Context
698
+ ---
970
699
 
971
- Every handler receives a context containing the request, URL, pathname, headers, route parameters, and spine.
700
+ ## Request Parsers & Responses
972
701
 
973
- You can extend it with your own application state:
702
+ ### Request Parsers
974
703
 
975
704
  ```ts
976
- type AppContext = {
977
- requestId: string;
978
- user?: {
979
- id: string;
980
- role: string;
981
- };
982
- };
705
+ import { parseQuery, parseCookie, parseBody } from "@bepalo/spine";
983
706
 
984
- const spine = new Router<AppContext>();
707
+ // Parse URL search parameters into ctx.query
708
+ spine.filterGet("/search", [parseQuery({ responseType: "json" })]);
985
709
 
986
- type CTMore = { counter: { count: 0 } };
710
+ // Parse request cookies into ctx.cookie
711
+ spine.filterAll("/**", [parseCookie({ responseType: "json" })]);
987
712
 
988
- // context can be passed to the handler method for more specificity and need.
989
- // Eg. cookie parsing, query parsing, body parsing, ... per pipe
990
- spine.get<CTMore>("/profile", ({ requestId, user, counter }) =>
991
- json({
992
- requestId,
993
- user,
994
- counter,
713
+ // Parse JSON, RJSON, URL-encoded, or plain text bodies into ctx.body
714
+ spine.filterPost("/data", [
715
+ parseBody({
716
+ accept: "application/json",
717
+ maxSize: 1024 * 1024, // 1MB limit
718
+ responseType: "json",
995
719
  }),
996
- );
720
+ ]);
997
721
  ```
998
722
 
999
- Context values can be supplied when processing a request:
723
+ ### Responses & Helpers
1000
724
 
1001
725
  ```ts
1002
- spine.respond(request, {
1003
- requestId: crypto.randomUUID(),
1004
- });
1005
- ```
1006
-
1007
- This keeps runtime-specific concerns outside the spine itself.
726
+ import {
727
+ json,
728
+ rjson,
729
+ text,
730
+ html,
731
+ status,
732
+ redirect,
733
+ redirectPermanentPreserve,
734
+ blob,
735
+ octetStream,
736
+ setCookie,
737
+ clearCookie,
738
+ } from "@bepalo/spine";
1008
739
 
1009
- ## File-Based Routing
740
+ // Standard JSON response
741
+ json({ message: "Success" });
1010
742
 
1011
- If you prefer filesystem-based routing, Spine can load routes from a directory:
743
+ // RJSON response using @bepalo/rjson
744
+ rjson({ message: "RJSON Success" });
1012
745
 
1013
- ```ts
1014
- const spine = new Router();
746
+ // Plain text and HTML
747
+ text("Hello, world!");
748
+ html("<h1>Hello, world!</h1>");
1015
749
 
1016
- await spine.load({
1017
- routesPath: "routes",
1018
- // pattern: /\.route\.(.ts|.js)$/,
1019
- // dirPattern: /.*/,
1020
- // processName: (name) => name.substring(0, name.lastIndexOf(".")),
1021
- });
1022
- ```
750
+ // Status response
751
+ status(204);
1023
752
 
1024
- For example:
753
+ // Redirects
754
+ redirectPermanentPreserve("/new-url"); // 308 Permanent Redirect
1025
755
 
1026
- ```text
1027
- routes/
1028
- ├── index.ts
1029
- ├── users.ts
1030
- ├── [[products,pricing,contact] page]
1031
- ├── users/
1032
- │ └── [id].ts
1033
- └── api/
1034
- └── [##].ts
756
+ // Cookie helpers
757
+ ctx.headers.append(
758
+ ...setCookie("token", "secret", {
759
+ path: "/",
760
+ httpOnly: true,
761
+ secure: true,
762
+ maxAge: 3600,
763
+ }),
764
+ );
1035
765
  ```
1036
766
 
1037
- A route file exports its HTTP method handlers: in the format \<method\>\_\<handler-type\> or
1038
- a shortcut for handler \<method\>. eg. `Get`, `Get_Filter`.
767
+ ---
1039
768
 
1040
- **NOTE:** both \<handler-type\> and \<method\> are case-insensitive and you can decide how to name them as long as you adhere to the format \<method\>\_\<handler-type\> or \<method\>.
769
+ ## Streaming Multipart Upload Parser
1041
770
 
1042
- ```ts
1043
- // routes/users.ts
1044
- import { json } from "@bepalo/spine";
771
+ Spine features a zero-dependency chunked streaming multipart parser capable of handling boundaries split across chunk fragments down to 5 bytes:
1045
772
 
1046
- const auth = [parseCookie(), authenticate()];
773
+ _NOTE: `parseUpload` is an abstraction of `parseMultipart`_
1047
774
 
1048
- export const Get_Filter = [...auth, parseQuery()];
775
+ ```ts
776
+ import { parseUpload } from "@bepalo/spine";
777
+ import { openSync, closeSync, writeSync } from "node:fs";
778
+
779
+ type UploadData = { fd: number; offset: number };
780
+
781
+ const mimeExtension = (mime: string) => {
782
+ switch (mime) {
783
+ case "image/jpeg":
784
+ return ".jpeg";
785
+ case "image/png":
786
+ return ".png";
787
+ default:
788
+ "";
789
+ }
790
+ };
1049
791
 
1050
- export const Post_Filter = [...auth, parseQuery(), parseBody(), vallidate()];
792
+ spine.post("/upload", [
793
+ parseUpload<UploadData>({
794
+ responseType: "json",
795
+ dontCatch: true,
796
+
797
+ fileHandle: (path) => ({ fd: openSync(path, "w"), offset: 0 }),
798
+
799
+ // idGenerator: () => toBase64UUID(crypto.randomUUID()),
800
+
801
+ // path: "./uploads"
802
+ path: (id, file) => `.uploads/${id}${mimeExtension(file.type)}`,
803
+
804
+ write: async ({ handle }, chunk) => {
805
+ const { fd, offset } = handle;
806
+ let written = writeSync(fd, chunk, 0, chunk.length, offset);
807
+ while (written < chunk.length) {
808
+ written += writeSync(
809
+ fd,
810
+ chunk,
811
+ written,
812
+ chunk.length,
813
+ offset + written,
814
+ );
815
+ }
816
+ handle.offset += written;
817
+ return written;
818
+ },
1051
819
 
1052
- export const Get = () => json({ users: [] });
820
+ end: async ({ handle: { fd }, ...file }) => closeSync(fd),
821
+
822
+ maxTotalSize: 100 * 1024 * 1024, // 100MB total
823
+ maxFileSize: 50 * 1024 * 1024, // 50MB max file
824
+ maxFiles: 10,
825
+ maxFields: 1,
826
+ maxFieldSize: 20,
827
+ // progress only works if Content-Length is specified
828
+ progressIncrement: 10, // progress report every 10% but actual callback depends on chunk size
829
+
830
+ onFileHeader: (ctx, { headers }) => {
831
+ const contentType = headers.get("content-type");
832
+ if (
833
+ !["image/jpeg", "image/png"].some((type) =>
834
+ contentType?.startsWith(type),
835
+ )
836
+ ) {
837
+ return json(
838
+ { error: "Unsupported Content-Type", contentType },
839
+ { status: Status._400_BadRequest },
840
+ );
841
+ }
842
+ },
1053
843
 
1054
- export const Post = () => json({ created: true }, { status: 201 });
844
+ onFileProgress: (ctx, { file, filename, headers, id, name }) => {
845
+ console.log(`[${name}:${file.fullpath}] ${file.progress.toFixed(2)}%`);
846
+ },
847
+ }),
848
+ ({ fields, files }) => {
849
+ return json({
850
+ success: true,
851
+ fields,
852
+ files: Object.fromEntries(files),
853
+ totalSize: Object.values(files).reduce((sum, f) => sum + f.size, 0),
854
+ });
855
+ },
856
+ ]);
1055
857
  ```
1056
858
 
1057
- A parameterized file:
859
+ ---
1058
860
 
1059
- ```text
1060
- users/[id].ts
1061
- ```
861
+ ## Caching & Data Structures (`Cache`, `ExpCache`)
1062
862
 
1063
- maps to:
863
+ Spine includes memory-bounded LRU caches that track capacity in **bytes** rather than entry counts:
1064
864
 
1065
- ```text
1066
- /users/:id
1067
- ```
865
+ ```ts
866
+ import { ExpCache } from "@bepalo/spine";
1068
867
 
1069
- Special filesystem-safe patterns are available for wildcard routes:
868
+ const cache = new ExpCache<string, string>({
869
+ maxMemory: 32 * 1024 * 1024, // 32MB maximum byte capacity
870
+ defaultTTL: 3600 * 1000, // 1 hour TTL
871
+ onMiss: (key, entry, reason, cache) => {
872
+ // Automatically called on cache miss or expiration
873
+ },
874
+ });
1070
875
 
1071
- ```text
1072
- [#] → *
1073
- [[#]] → *!
876
+ const data = "user-data";
877
+ cache.set("session-1", data, data.length, { ttl: 60 * 1000 });
878
+ const value = cache.get("session-1");
1074
879
 
1075
- [##] → **
1076
- [[##]] → **!
880
+ setInterval(() => {
881
+ cache.evictExpired(); // Purges expired keys
882
+ }, 300_000);
1077
883
  ```
1078
884
 
1079
- File routing is completely optional. The normal programmatic API remains the core of Spine.
1080
-
1081
- ## Built for HTTP APIs
885
+ ---
1082
886
 
1083
- Spine includes the common building blocks you usually end up adding around a spine.
887
+ ## OpenAPI 3.0 Document Generation
1084
888
 
1085
- ### Request parsing
889
+ Generate full OpenAPI 3.0.0 specifications directly from your routes and metadata:
1086
890
 
1087
891
  ```ts
1088
- spine.post("/users", [parseBody(), ({ body }) => json(body)]);
1089
- ```
1090
-
1091
- Available parsers include:
892
+ spine.get("/users/:id", ({ params }) => json({ id: params.id }), {
893
+ openApi: {
894
+ summary: "Get user by ID",
895
+ tags: ["Users"],
896
+ responses: {
897
+ "200": { description: "User found" },
898
+ "404": { description: "User not found" },
899
+ },
900
+ },
901
+ });
1092
902
 
1093
- - `parseBody`
1094
- - `parseQuery`
1095
- - `parseCookie`
1096
- - `parseHeaders`
1097
- - `parseMultipart`
903
+ const openapi = await spine.generateOpenAPI(
904
+ {
905
+ title: "Application API",
906
+ version: "1.0.0",
907
+ servers: [{ url: "https://api.example.com/v1" }],
908
+ },
909
+ {
910
+ pick: ({ path }) => path.startsWith("/api"),
911
+ autoTag: true,
912
+ autoSummary: true,
913
+ includeOperationId: true,
914
+ sortPathnameOrder: 1,
915
+ sortMethodOrder: 1,
916
+ },
917
+ );
918
+ ```
1098
919
 
1099
- Multipart parsing is streaming-oriented, making it suitable for large uploads.
920
+ ---
1100
921
 
1101
- ### Responses
922
+ ## Router Composition (`append`,`appendTo`) & Introspection
1102
923
 
1103
- Common response helpers are included:
924
+ Mount sub-routers with path prefixes:
1104
925
 
1105
926
  ```ts
1106
- json(data);
1107
- text("Hello");
1108
- html("<h1>Hello</h1>");
1109
- status(204);
1110
- redirect("/login");
1111
- blob(file);
1112
- octetStream(data);
1113
- formData(data);
1114
- usp(params);
1115
- send(data);
1116
- ```
927
+ const apiRoutes = new Router({ maxPath: 16 });
928
+ apiRoutes.get("/users", () => json({ users: [] }));
929
+ apiRoutes.get("/posts", () => json({ posts: [] }));
1117
930
 
1118
- Cookie helpers are also provided:
931
+ const mainRouter = new Router({ maxPath: 24 });
932
+ mainRouter.appendTo("/v1", apiRoutes);
1119
933
 
1120
- ```ts
1121
- setCookie(name, value, options);
1122
- clearCookie(name, options);
934
+ // mainRouter now handles: GET /v1/users, GET /v1/posts
1123
935
  ```
1124
936
 
1125
- ### CORS and rate limiting
1126
-
1127
937
  ```ts
1128
- spine.filterAll("/api/**", [
1129
- cors({
1130
- origins: "*",
1131
- }),
938
+ const secRoutes = new Router({ maxPath: 16 });
939
+ secRoutes.filterAll("/**", [cors(), securityHeaders()]);
940
+ secRoutes.filterAll("/api/**", [cors(), securityHeaders()]);
1132
941
 
1133
- limitRate({
1134
- key: ({ request }) => request.headers.get("x-forwarded-for") ?? "unknown",
1135
- maxTokens: 100,
1136
- refillRate: 10,
1137
- }),
1138
- ]);
1139
- ```
942
+ const mainRouter = new Router({ maxPath: 24 });
943
+ mainRouter.append(secRoutes);
1140
944
 
1141
- ### Authentication
945
+ // mainRouter now filters: All /**, All /api/**
946
+ ```
1142
947
 
1143
- Authentication is intentionally application-defined:
948
+ Inspect active route registrations across dimensions:
1144
949
 
1145
950
  ```ts
1146
- spine.filterGet("/private/**", [
1147
- authenticate({
1148
- parseAuth: async ({ request }) => {
1149
- const token = request.headers.get("authorization");
1150
-
1151
- if (!token) return undefined;
1152
-
1153
- return {
1154
- role: "user",
1155
- };
1156
- },
1157
- }),
1158
-
1159
- authorize({
1160
- allowRole: (role) => role === "user",
1161
- }),
1162
- ]);
951
+ console.dir(spine.getRoutesByPathnameThenMethod(), { depth: 5 });
952
+ console.dir(spine.getRoutesByPathnameThenHandlerType(), { depth: 5 });
953
+ console.dir(spine.getRoutesByHandlerTypeThenMethod(), { depth: 5 });
954
+ console.dir(spine.getRoutesByHandlerTypeThenPathname(), { depth: 5 });
955
+ console.dir(spine.getRoutesByMethodThenHandlerType(), { depth: 5 });
956
+ console.dir(spine.getRoutesByMethodThenPathname(), { depth: 5 });
1163
957
  ```
1164
958
 
1165
- Basic Authentication is also supported through `basicAuthParser()`.
1166
-
1167
- ## OpenAPI
959
+ ---
1168
960
 
1169
- Add OpenAPI metadata directly to a handler:
961
+ ## File-Based Routing & Watchers
1170
962
 
1171
- ```ts
1172
- spine.get(
1173
- "/users/:id",
1174
- ({ params }) =>
1175
- json({
1176
- id: params.id,
1177
- }),
1178
- {
1179
- openApi: {
1180
- summary: "Get a user",
1181
- tags: ["Users"],
1182
- responses: {
1183
- "200": {
1184
- description: "User",
1185
- },
1186
- },
1187
- },
1188
- },
1189
- );
1190
- ```
963
+ ### Loading Routes Dynamically
1191
964
 
1192
- Then generate an OpenAPI 3.0 document:
965
+ _NOTE: It is best to use generated static imports for production. see [static-route-watchers](#static-route-watchers-for-zero-reflection-production)_
1193
966
 
1194
967
  ```ts
1195
- const document = await spine.generateOpenAPI({
1196
- title: "My API",
1197
- version: "1.0.0",
968
+ const spine = new Router({ maxPath: 24 });
969
+
970
+ await spine.load({
971
+ routesPath: "./routes",
972
+ // pattern: /\.(ts|js)$/,
1198
973
  });
1199
974
  ```
1200
975
 
1201
- Route parameters are automatically represented using OpenAPI's `{parameter}` syntax.
976
+ A route file exports handlers matching `<method>` or `<method>_<handlerType>`:
1202
977
 
1203
- ## Error Handling
1204
-
1205
- Throw an `HttpError` when you need an HTTP-specific failure:
978
+ _NOTE: method and handlerType are both case insensitive so you are free to use any casing you want._
1206
979
 
1207
980
  ```ts
1208
- import { HttpError } from "@bepalo/spine";
1209
-
1210
- spine.get("/users/:id", ({ params }) => {
1211
- const user = findUser(params.id);
1212
-
1213
- if (!user) {
1214
- throw new HttpError(404, "User not found");
1215
- }
981
+ // routes/users/[id].ts
982
+ import { json, type HandlerPipe, type CTBody } from "@bepalo/spine";
1216
983
 
1217
- return json(user);
1218
- });
1219
- ```
984
+ export const get: HandlerPipe = {
985
+ pipe: ({ params }) => json({ user: params.id }),
986
+ openApi: {
987
+ summary: "Get user by ID",
988
+ },
989
+ };
1220
990
 
1221
- Handle errors with a catcher:
991
+ export const get: HandlerPipe = {
992
+ pipe: ({ params }) => json({ user: params.id }),
993
+ openApi: {
994
+ summary: "Get user by ID",
995
+ },
996
+ };
1222
997
 
1223
- ```ts
1224
- spine.catchGet("/users/**", ({ error }) =>
1225
- json({ error: error?.message }, { status: 500 }),
1226
- );
998
+ export const post: HandlerPipe<CTBody> = ({ body }) => json({ created: body });
1227
999
  ```
1228
1000
 
1229
- ## Multipart Parser Demo
1001
+ ### Static Route Watchers for Zero-Reflection Production
1230
1002
 
1231
- This is a well tested multipart-form-data parser that parses by streaming chunks.
1232
- It can even handle edge cases like boundary across multiple chunks and very small chunks (down to 5 bytes of chunk). Thank God!
1003
+ Generate static import files during development that register routes directly in production:
1233
1004
 
1234
1005
  ```ts
1235
- router.post("/upload", [
1236
- parseUpload<{}, { writer: Bun.FileSink; hash: Hash | string }>({
1237
- // maxFields: 2,
1238
- // maxFiles: 1,
1239
- // maxFieldSize: 203,
1240
- // maxFileSize: 200 * 1024 * 1024,
1241
-
1242
- path: process.cwd() + "/uploads",
1243
-
1244
- fileHandle: (fullpath: string) => ({
1245
- writer: Bun.file(fullpath).writer(),
1246
- hash: createHash("sha256"),
1247
- }),
1248
-
1249
- write: ({ handle }, chunk) => {
1250
- handle.writer.write(chunk);
1251
- (handle.hash as Hash).update(chunk);
1252
- },
1006
+ // scripts/watch-routes.ts
1007
+ import { generateStaticRoutesWatcher } from "@bepalo/spine";
1008
+ import { readFile, writeFile } from "node:fs/promises";
1253
1009
 
1254
- end: ({ handle, fullpath, name }, success) => {
1255
- handle.writer.end();
1256
- handle.hash = (handle.hash as Hash).digest().toString("hex");
1257
- if (!success) {
1258
- Bun.file(fullpath).delete();
1259
- console.log(`[FileUpload](${name}) failed`);
1260
- }
1261
- },
1010
+ generateStaticRoutesWatcher({
1011
+ routesPath: "./routes",
1012
+ importRoot: "./routes/",
1013
+ output: "./routes.gen.ts",
1014
+ read: (f) => readFile(f, "utf-8"),
1015
+ write: (f, c) => writeFile(f, c, "utf-8"),
1016
+ });
1017
+ ```
1262
1018
 
1263
- onEnd: ({ files, fields }) => {
1264
- console.dir(
1265
- {
1266
- files: Object.fromEntries(files.entries()),
1267
- fields: Object.fromEntries(fields.entries()),
1268
- },
1269
- { depth: 3 },
1270
- );
1271
- },
1019
+ ```ts
1020
+ // server.ts
1021
+ import Router from "@bepalo/spine";
1022
+ import setRoutes from "./routes.gen.ts";
1272
1023
 
1273
- onFileProgress: (ctx, { file }) => {
1274
- console.log(
1275
- `[FileUpload](${file.name}) progress`,
1276
- file.progress.toFixed(2),
1277
- "%",
1278
- );
1279
- },
1280
- }),
1281
- ]);
1024
+ const spine = new Router({ maxPath: 24 });
1025
+ setRoutes(spine); // Zero filesystem latency on startup!
1282
1026
  ```
1283
1027
 
1284
- ## Performance
1028
+ ---
1285
1029
 
1286
- Spine keeps routing deliberately simple and specialized:
1030
+ ## Performance & Design Invariants
1287
1031
 
1288
- - Exact routes use direct route tables.
1289
- - Glob routes are stored separately from exact routes.
1290
- - Super-glob routes are handled independently.
1291
- - Routes are organized by HTTP method.
1292
- - Pathnames are split once and reused during matching.
1293
- - Parameter extraction happens only for the selected route candidates.
1032
+ - **Specialized Routing Tables**: Exact routes use direct $O(1)$ Map lookups. Globs and super-globs are stored in separate index arrays by path segment count.
1033
+ - **No String Regex at Matching Time**: Path segments are split once per request and compared by segment.
1034
+ - **Flat Execution Pipelines**: Array iteration avoids recursive function call stack overhead.
1035
+ - **Strict Depth Boundaries**: `maxPath` enforces a hard limit on segment count, guarding against URI exhaustion attacks.
1036
+ - **Zero Cross-Request State Leakage**: Each incoming request receives a fresh `Context` object that can be customized.
1037
+ - **Protected After-Hooks**: Errors in After-hooks are caught by `afterCatcher` and never corrupt the outgoing Response.
1038
+ - **Optional pipes**: Optional pipes to disable for performance optimization.
1294
1039
 
1295
- The result is a spine focused on **fast matching, low overhead, and predictable behavior** without tying the routing layer to a particular server.
1040
+ ---
1296
1041
 
1297
1042
  ## 📄 License
1298
1043