@bepalo/spine 1.0.3

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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +830 -0
  3. package/dist/auth-middlewares.d.ts +119 -0
  4. package/dist/auth-middlewares.d.ts.map +1 -0
  5. package/dist/auth-middlewares.js +168 -0
  6. package/dist/auth-middlewares.js.map +1 -0
  7. package/dist/cjs/auth-middlewares.d.ts +119 -0
  8. package/dist/cjs/auth-middlewares.d.ts.map +1 -0
  9. package/dist/cjs/auth-middlewares.js +168 -0
  10. package/dist/cjs/auth-middlewares.js.map +1 -0
  11. package/dist/cjs/helpers.d.ts +180 -0
  12. package/dist/cjs/helpers.d.ts.map +1 -0
  13. package/dist/cjs/helpers.js +366 -0
  14. package/dist/cjs/helpers.js.map +1 -0
  15. package/dist/cjs/index.d.ts +11 -0
  16. package/dist/cjs/index.d.ts.map +1 -0
  17. package/dist/cjs/index.js +30 -0
  18. package/dist/cjs/index.js.map +1 -0
  19. package/dist/cjs/middlewares.d.ts +88 -0
  20. package/dist/cjs/middlewares.d.ts.map +1 -0
  21. package/dist/cjs/middlewares.js +262 -0
  22. package/dist/cjs/middlewares.js.map +1 -0
  23. package/dist/cjs/parsers.d.ts +177 -0
  24. package/dist/cjs/parsers.d.ts.map +1 -0
  25. package/dist/cjs/parsers.js +1021 -0
  26. package/dist/cjs/parsers.js.map +1 -0
  27. package/dist/cjs/router.d.ts +124 -0
  28. package/dist/cjs/router.d.ts.map +1 -0
  29. package/dist/cjs/router.js +1378 -0
  30. package/dist/cjs/router.js.map +1 -0
  31. package/dist/cjs/status.d.ts +77 -0
  32. package/dist/cjs/status.d.ts.map +1 -0
  33. package/dist/cjs/status.js +252 -0
  34. package/dist/cjs/status.js.map +1 -0
  35. package/dist/cjs/types.d.ts +135 -0
  36. package/dist/cjs/types.d.ts.map +1 -0
  37. package/dist/cjs/types.js +109 -0
  38. package/dist/cjs/types.js.map +1 -0
  39. package/dist/cjs/utils.d.ts +11 -0
  40. package/dist/cjs/utils.d.ts.map +1 -0
  41. package/dist/cjs/utils.js +76 -0
  42. package/dist/cjs/utils.js.map +1 -0
  43. package/dist/cjs/utils.node.d.ts +4 -0
  44. package/dist/cjs/utils.node.d.ts.map +1 -0
  45. package/dist/cjs/utils.node.js +99 -0
  46. package/dist/cjs/utils.node.js.map +1 -0
  47. package/dist/helpers.d.ts +180 -0
  48. package/dist/helpers.d.ts.map +1 -0
  49. package/dist/helpers.js +366 -0
  50. package/dist/helpers.js.map +1 -0
  51. package/dist/index.d.ts +11 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +30 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/middlewares.d.ts +88 -0
  56. package/dist/middlewares.d.ts.map +1 -0
  57. package/dist/middlewares.js +262 -0
  58. package/dist/middlewares.js.map +1 -0
  59. package/dist/parsers.d.ts +177 -0
  60. package/dist/parsers.d.ts.map +1 -0
  61. package/dist/parsers.js +1021 -0
  62. package/dist/parsers.js.map +1 -0
  63. package/dist/router.d.ts +124 -0
  64. package/dist/router.d.ts.map +1 -0
  65. package/dist/router.js +1378 -0
  66. package/dist/router.js.map +1 -0
  67. package/dist/status.d.ts +77 -0
  68. package/dist/status.d.ts.map +1 -0
  69. package/dist/status.js +252 -0
  70. package/dist/status.js.map +1 -0
  71. package/dist/types.d.ts +135 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/types.js +109 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/utils.d.ts +11 -0
  76. package/dist/utils.d.ts.map +1 -0
  77. package/dist/utils.js +76 -0
  78. package/dist/utils.js.map +1 -0
  79. package/dist/utils.node.d.ts +4 -0
  80. package/dist/utils.node.d.ts.map +1 -0
  81. package/dist/utils.node.js +66 -0
  82. package/dist/utils.node.js.map +1 -0
  83. package/package.json +57 -0
package/README.md ADDED
@@ -0,0 +1,830 @@
1
+ # 🏆 @bepalo/spine
2
+
3
+ ![hero](./assets/hero.png)
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@bepalo/spine.svg)](https://www.npmjs.com/package/@bepalo/spine)
6
+ [![CI](https://img.shields.io/github/actions/workflow/status/bepalo/spine/ci.yaml?label=ci)](https://github.com/bepalo/spine/actions/workflows/ci.yaml)
7
+ [![tests](https://img.shields.io/github/actions/workflow/status/bepalo/spine/testing.yaml?label=tests)](https://github.com/bepalo/spine/actions/workflows/testing.yaml)
8
+ [![license](https://img.shields.io/npm/l/@bepalo/spine.svg)](LICENSE)
9
+ ![Benchmarked](https://img.shields.io/badge/benchmarked-yes-green)
10
+
11
+ <!--
12
+ [![Vitest](https://img.shields.io/badge/vitest-6E9F18?style=for-the-badge&logo=vitest&logoColor=white)](test-result.md) -->
13
+
14
+ **A fast, runtime-agnostic HTTP router for JavaScript and TypeScript.**
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.
17
+
18
+ ```text
19
+ ( @Bepalo/spine )
20
+
21
+ ( The Router Pipeline )
22
+
23
+ ┌───────────────────────┐ ┌──────────────┐
24
+ ▼ │ │ Server │
25
+ ┌──────┴───────┐ │ └──────┬───────┘
26
+ ┌─────────│ Filters │─────────┐ <request> │
27
+ │ └──────┬───────┘ │ │ Request
28
+ │ <no match nor response> │ │ │
29
+ │ ▼ │ │ ▼
30
+ │ ┌──────┴───────┐ │ ┌──┴────────┐ ┌──────────────┐
31
+ ├─────────│ Handlers │─────────┤ │ Server │◄───┐ │ Spine │
32
+ │ └──────┬───────┘ │ └──┬─────┬──┘ │ │ Router │
33
+ │ <no match nor response> │ ▲ │ <request> └──────┬───────┘
34
+ <error> ▼ │ │ <response> │ │
35
+ │ ┌──────┴───────┐ │ │ ▼ │ Response
36
+ ├─────────│ Fallbacks │─────────┤ │ ┌─┴───────┴─┐ │
37
+ ▼ └──────┬───────┘ │ │ │ Client │ ▼
38
+ ┌─────┴──────┐ │ ┌──<response>─┘ │ └───────────┘ ┌──────────────┐
39
+ │ Catchers │ ▼ ▼ │ │ Server │
40
+ └─────┬──────┘ ┌──────┴───┴───┐ │ └──────────────┘
41
+ └────────►│ Afters │───────────────┘
42
+ <error-response> └──────────────┘ <final-response>
43
+ ```
44
+
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
+ ────────────────────────────────────────────────
51
+ / 9.91k 8.64k 8.19k
52
+ exact 10.33k 8.42k 8.19k
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
56
+ three params 9.99k 7.68k 6.76k
57
+ six params 9.88k 7.91k 7.51k
58
+ ten params 9.78k 7.90k 7.12k
59
+
60
+ Average 10.05k 8.13k 7.65k ops/s
61
+
62
+ ████████████████████████████████████████ Bun
63
+ █████████████████████████████████ Spine
64
+ ████████████████████████████████ Hono
65
+ ```
66
+
67
+ ## Why Spine?
68
+
69
+ - ⚡ **Low-overhead routing** — specialized route tables for exact, glob, and super-glob routes
70
+ - 🎯 **Powerful route patterns** — parameters, alternatives, `*`, `*!`, `**`, and `**!`
71
+ - 🔗 **Composable pipelines** — filters, handlers, fallbacks, catchers, and after-hooks
72
+ - 🧠 **TypeScript-first** — extend the request context with your own application data
73
+ - 🌐 **Runtime agnostic** — works with Bun, Deno, Node.js, and other Web API-compatible runtimes
74
+ - 📁 **File-based routing** — optionally load routes directly from a directory structure
75
+ - 📖 **OpenAPI generation** — attach metadata to routes and generate an OpenAPI 3.0 document
76
+ - 🛠️ **Built-in utilities** — request parsing, responses, CORS, rate limiting, and authentication
77
+ - 🪶 **No server lock-in** — Spine only deals with `Request` in and `Response` out
78
+
79
+ ## 📑 Table of Contents
80
+
81
+ - [Why Spine?](#why-spine)
82
+ - [Quick Start](#quick-start)
83
+ - [Routing](#routing)
84
+ - [Parameters](#parameters)
85
+ - [Alternatives](#alternatives)
86
+ - [Wildcards](#wildcards)
87
+ - [File-Based Wildcards](#file-based-wildcards)
88
+
89
+ - [Handler Pipeline](#handler-pipeline)
90
+ - [Filter Pipes](#filter-pipes)
91
+ - [Handler Pipes](#handler-pipes)
92
+
93
+ - [Type-Safe Context](#type-safe-context)
94
+ - [File-Based Routing](#file-based-routing)
95
+ - [Built for HTTP APIs](#built-for-http-apis)
96
+ - [Request parsing](#request-parsing)
97
+ - [Responses](#responses)
98
+ - [CORS and rate limiting](#cors-and-rate-limiting)
99
+ - [Authentication](#authentication)
100
+
101
+ - [OpenAPI](#openapi)
102
+ - [Error Handling](#error-handling)
103
+ - [Performance](#performance)
104
+ - [License](#-license)
105
+ - [Thanks and Enjoy](#️-thanks-and-enjoy)
106
+ - [Be a Sponsor](#-be-a-sponsor)
107
+
108
+ ## Quick Start
109
+
110
+ Install
111
+
112
+ ```sh
113
+ pnpm add @bepalo/spine
114
+ # or
115
+ npm install @bepalo/spine
116
+ # or
117
+ bun add @bepalo/spine
118
+ ```
119
+
120
+ ```ts
121
+ import {
122
+ Router,
123
+ json,
124
+ text,
125
+ toBase64UUID,
126
+ parseBody,
127
+ parseMultipart,
128
+ } from "@bepalo/spine";
129
+
130
+ // A user defined custom context shared accross the router
131
+ type CTSpineApp = { clientId: string; requestId: string };
132
+
133
+ const spine = new Router<CTSpineApp>();
134
+
135
+ spine.get("/", () => text("Hello, Spine!"));
136
+
137
+ // pipe specific context extension using CT* context extension types.
138
+ spine.get<CTQuery<"q" | "page">>("/search", [
139
+ parseQuery(),
140
+ ({ query: { q, page } }) => json({ q, page }),
141
+ ]);
142
+
143
+ spine.get("/users/:id", ({ params: { id } }) => json({ id }));
144
+
145
+ spine.post<CTBody<object>>("/users", [
146
+ parseBody({ accept: ["application/json"], maxSize: 1024 }),
147
+ () => json({ created: true }, { status: 201 }),
148
+ ]);
149
+
150
+ // Serve with Bun
151
+ Bun.serve({
152
+ port: 3000,
153
+ fetch: async (request, server) =>
154
+ await spine.respond(request, {
155
+ headers: new Header({ "X-Powered-By": "@bepalo/spine" }),
156
+ requestId: toBase64UUID(crypto.randomUUID()), // compress UUID to base64url 'I6qNV82UTmulXhEhxHpZxw'
157
+ clientId: server.requestIP(req).address ?? "anonymous",
158
+ }),
159
+ });
160
+
161
+ // Serve with Deno
162
+ Deno.serve(
163
+ {
164
+ port: 3000,
165
+ },
166
+ (request) => async (request, server) =>
167
+ await spine.respond(request, {
168
+ headers: new Header({ "X-Powered-By": "@bepalo/spine" }),
169
+ requestId: toBase64UUID(crypto.randomUUID()),
170
+ clientId: remoteAddr.hostname ?? "anonymous",
171
+ }),
172
+ );
173
+ ```
174
+
175
+ That's the core API.
176
+
177
+ Spine does not create or manage your server. Your runtime gives Spine a standard `Request`, and Spine returns a standard `Response`.
178
+
179
+ ```ts
180
+ const response = await spine.respond(request);
181
+ ```
182
+
183
+ This makes the spine easy to embed into servers, frameworks, workers, and custom runtimes.
184
+
185
+ ## Routing
186
+
187
+ Use the convenient HTTP method helpers:
188
+
189
+ ```ts
190
+ spine.get("/users", handler);
191
+ spine.post("/users", handler);
192
+ spine.put("/users/:id", handler);
193
+ spine.patch("/users/:id", handler);
194
+ spine.delete("/users/:id", handler);
195
+ ```
196
+
197
+ Or register several methods at once:
198
+
199
+ ```ts
200
+ spine.all("/health", handler);
201
+ spine.crud("/users/:id", handler);
202
+ ```
203
+
204
+ All standard HTTP methods are supported:
205
+
206
+ ```text
207
+ HEAD GET POST PUT PATCH
208
+ DELETE OPTIONS TRACE CONNECT
209
+ ```
210
+
211
+ ### Parameters
212
+
213
+ Parameters are detected from pathname using typescript. So, you have typesafety and autocomplete for that. Cool!
214
+
215
+ ```ts
216
+ spine.get("/users/:userId/posts/:postId", ({ params }) => {
217
+ return json({
218
+ userId: params.userId,
219
+ postId: params.postId,
220
+ });
221
+ });
222
+ ```
223
+
224
+ ### Alternatives
225
+
226
+ A route segment can contain alternatives:
227
+
228
+ ```ts
229
+ spine.get("/|about|contact", handler);
230
+ ```
231
+
232
+ This matches:
233
+
234
+ ```text
235
+ /
236
+ /about
237
+ /contact
238
+ ```
239
+
240
+ Alternatives can also be combined with parameters:
241
+
242
+ ```ts
243
+ spine.get("/api/|users|accounts/:id", handler);
244
+ ```
245
+
246
+ ### Wildcards
247
+
248
+ Spine supports single-segment and multi-segment wildcards:
249
+
250
+ ```text
251
+ \* one path segment
252
+ \*\! optional single-segment suffix
253
+ \*\* multiple path segments
254
+ \*\*\! optional multi-segment suffix
255
+ ```
256
+
257
+ For example:
258
+
259
+ ```ts
260
+ spine.get("/files/*", handler);
261
+
262
+ spine.get("/api/**", ({ params }) => {
263
+ console.log(params.$);
264
+ console.log(params.$$);
265
+
266
+ return json({ ok: true });
267
+ });
268
+ ```
269
+
270
+ Use `*!` and `**!` when the wildcard portion is optional. `/abc/def/*!` will
271
+ match `/abc/def` while `/abc/def/*` will not.
272
+
273
+ ### File-Based Wildcards
274
+
275
+ Filesystem-safe route names are provided for wildcard patterns:
276
+
277
+ ```text
278
+ [#] → *
279
+ [[#]] → *!
280
+
281
+ [##] → **
282
+ [[##]] → **!
283
+ ```
284
+
285
+ For example:
286
+
287
+ ```text
288
+ routes/
289
+ └── api/
290
+ └── [##].ts
291
+ ```
292
+
293
+ maps to:
294
+
295
+ ```text
296
+ /api/**
297
+ ```
298
+
299
+ while:
300
+
301
+ ```text
302
+ routes/
303
+ └── api/
304
+ └── [[##]].ts
305
+ ```
306
+
307
+ maps to:
308
+
309
+ ```text
310
+ /api/**!
311
+ ```
312
+
313
+ ---
314
+
315
+ | ROUTER PATH | FILE PATH | MATCHES `highlighted` |
316
+ | --------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------ |
317
+ | `/exact/path` | `/exact/path` | "/exact/path" |
318
+ | `/slash/matters/` | `/slash/matters/` | "/slash/matters/" |
319
+ | `/wild/glob/*` | `/wild/glob/[#]` | "/wild/glob/` `" "/wild/glob/`y`" "/wild/glob/`n`" |
320
+ | `/wild/glob/match/base/*!` | `/wild/glob/match/base/[[#]]` | "/wild/glob/match/base" "/wild/glob/match/base/` `" "/wild/glob/match/base/`y`" |
321
+ | `/globs/*/cool/*` | `/globs/[#]/cool/[#]` | "/globs/` `/cool/" "/globs/`are`/cool/" "/globs/`are`/cool/`breath`" |
322
+ | `/globs/*/cool/*!` | `/globs/[#]/cool/[[#]]` | "/globs/` `/cool" "/globs/`are`/cool/` `" "/globs/` `/cool/breath" "/globs/`are`/cool/`y`" |
323
+ | `/named/:glob/here` | `/named/[glob]/here` | "/named/` `/here" "/named/`pet`/here/" |
324
+ | `/named/optional/:glob!` | `/named/optional/[[glob]]` | "/named/optional" "/named/optional/` `" "/named/optional/`pet`" |
325
+ | `/super/globs/**` | `/super/globs/[##]` | "/super/globs/` `" "/super/globs/`here`" "/super/globs/`here/and/there`" |
326
+ | `/super/globs/**!` | `/super/globs/[[##]]` | "/super/globs" "/super/globs/` `" "/super/globs/`here`" "/super/globs/`1/2/3/4`" |
327
+ | `/named/super/::slug` | `/named/super/[## slug]` | "/named/super/` `" "/named/super/`pet`" "/named/super/`man/town`" |
328
+ | `/named/super/::slug!` | `/named/super/[[## slug]]` | "/named/super" "/named/super/` `" "/named/super/`pet`" "/named/super/`man/town`" |
329
+ | `/certain/a\|b\|c\|:options/y\|n` | `/certain/[[,a,b,c] options ]/y` | "/certain/`a`/n" "/certain/`b`/y" "/certain/`c`/n" "/certain/` `/y" |
330
+ | `/certain/a\|b\|c:options!/y\|n` | `/certain/[[a,b,c] [options] ]/n` | "/certain/`a`/y" "/certain/`b`/n" "/certain/`c`/y" |
331
+
332
+ ## Handler Pipeline
333
+
334
+ Spine separates request processing into explicit phases:
335
+
336
+ ```text
337
+ ( @Bepalo/spine )
338
+ router pipeline
339
+ ┌───────────────────────┐
340
+ ▼ │
341
+ ┌──────┴───────┐ │
342
+ ┌─────────│ Filters │─────────┐ <request>
343
+ │ └──────┬───────┘ │ │
344
+ │ <no match nor response> │ │
345
+ │ ▼ │ │
346
+ │ ┌──────┴───────┐ │ ┌──┴────────┐
347
+ ├─────────│ Handlers │─────────┤ │ Server │◄───┐
348
+ │ └──────┬───────┘ │ └──┬─────┬──┘ │
349
+ │ <no match nor response> │ ▲ │ <request>
350
+ <error> ▼ │ │ <response> │
351
+ │ ┌──────┴───────┐ │ │ ▼ │
352
+ ├─────────│ Fallbacks │─────────┤ │ ┌─┴───────┴─┐
353
+ ▼ └──────┬───────┘ │ │ │ Client │
354
+ ┌─────┴──────┐ │ ┌──<response>─┘ │ └───────────┘
355
+ │ Catchers │ ▼ ▼ │
356
+ └─────┬──────┘ ┌──────┴───┴───┐ │
357
+ └────────►│ Afters │───────────────┘
358
+ <error-response> └──────────────┘ <final-response>
359
+ ```
360
+
361
+ ### Filter Pipes
362
+
363
+ The first handling stage of a request is done through the filter stage.
364
+ Use this stage to parse and validate the request.
365
+
366
+ ```ts
367
+ spine.filterCrud<CTAuth>("/user/**!", [
368
+ parseQuery(),
369
+ parseCookie(),
370
+ authenticate(),
371
+ authorize(),
372
+ ]);
373
+ spine.filterPost("/user", [
374
+ parseCookie(),
375
+ authenticate(),
376
+ authorize(),
377
+ parseBody(),
378
+ ]);
379
+ ```
380
+
381
+ ### Handler Pipes
382
+
383
+ This is main handling stage of a request.
384
+
385
+ ```ts
386
+ spine.get("/user/:id", []);
387
+ spine.filterPost("/user", [
388
+ parseCookie(),
389
+ authenticate(),
390
+ authorize(),
391
+ parseBody(),
392
+ ]);
393
+ ```
394
+
395
+ This lets cross-cutting behavior remain separate from your actual route handlers.
396
+
397
+ ```ts
398
+ spine.filterGet("/api/**", [limitRate(), cors(), authenticate()]);
399
+
400
+ spine.get("/api/users", listUsers());
401
+
402
+ spine.fallbackGet("/api/**", () =>
403
+ json({ error: "Not Found" }, { status: 404 }),
404
+ );
405
+
406
+ spine.catchGet("/api/**", ({ error }) =>
407
+ json({ error: error?.message }, { status: 500 }),
408
+ );
409
+
410
+ spine.afterGet("/api/**", ({ response }) => {
411
+ console.log(response.status);
412
+ // even the response after a caught error will pass through the after-pipeline
413
+ // error thrown here is not caught.
414
+ // afters are best used for logging or modifying the final response
415
+ });
416
+ ```
417
+
418
+ Handlers can also be composed into pipelines:
419
+
420
+ ```ts
421
+ spine.post("/users", [parseBody(), validateUser(), createUser()]);
422
+ ```
423
+
424
+ A pipeline can stop normally by returning a `Response`, or use Spine's explicit control symbols:
425
+
426
+ ```ts
427
+ import { Break_Pipe, Break_Pipeline } from "@bepalo/spine";
428
+
429
+ spine.filterGet("/**", cors({ maxTokens: 60 }));
430
+ spine.filterGet("/api/**", [cors({ maxTokens: 200 }), () => Break_Pipeline]);. /* '/**' cors wont be called */
431
+
432
+ // Break_Pipeline breaks from the overall handlers pipeline while
433
+ // Break_Pipe breaks from the current handler pipe without returning a Response.
434
+
435
+ ```
436
+
437
+ ## Type-Safe Context
438
+
439
+ Every handler receives a context containing the request, URL, pathname, headers, route parameters, and spine.
440
+
441
+ You can extend it with your own application state:
442
+
443
+ ```ts
444
+ type AppContext = {
445
+ requestId: string;
446
+ user?: {
447
+ id: string;
448
+ role: string;
449
+ };
450
+ };
451
+
452
+ const spine = new Router<AppContext>();
453
+
454
+ type CTMore = { counter: { count: 0 } };
455
+
456
+ // context can be passed to the handler method for more specificity and need.
457
+ // Eg. cookie parsing, query parsing, body parsing, ... per pipe
458
+ spine.get<CTMore>("/profile", ({ requestId, user, counter }) =>
459
+ json({
460
+ requestId,
461
+ user,
462
+ counter,
463
+ }),
464
+ );
465
+ ```
466
+
467
+ Context values can be supplied when processing a request:
468
+
469
+ ```ts
470
+ spine.respond(request, {
471
+ requestId: crypto.randomUUID(),
472
+ });
473
+ ```
474
+
475
+ This keeps runtime-specific concerns outside the spine itself.
476
+
477
+ ## File-Based Routing
478
+
479
+ If you prefer filesystem-based routing, Spine can load routes from a directory:
480
+
481
+ ```ts
482
+ const spine = new Router();
483
+
484
+ await spine.load({
485
+ routesPath: "routes",
486
+ // pattern: /\.route\.(.ts|.js)$/,
487
+ // dirPattern: /.*/,
488
+ // processName: (name) => name.substring(0, name.lastIndexOf(".")),
489
+ });
490
+ ```
491
+
492
+ For example:
493
+
494
+ ```text
495
+ routes/
496
+ ├── index.ts
497
+ ├── users.ts
498
+ ├── [[products,pricing,contact] page]
499
+ ├── users/
500
+ │ └── [id].ts
501
+ └── api/
502
+ └── [##].ts
503
+ ```
504
+
505
+ A route file exports its HTTP method handlers: in the format \<method\>\_\<handler-type\> or
506
+ a shortcut for handler \<method\>. eg. `Get`, `Get_Filter`.
507
+
508
+ **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\>.
509
+
510
+ ```ts
511
+ // routes/users.ts
512
+ import { json } from "@bepalo/spine";
513
+
514
+ const auth = [parseCookie(), authenticate()];
515
+
516
+ export const Get_Filter = [...auth, parseQuery()];
517
+
518
+ export const Post_Filter = [...auth, parseQuery(), parseBody(), vallidate()];
519
+
520
+ export const Get = () => json({ users: [] });
521
+
522
+ export const Post = () => json({ created: true }, { status: 201 });
523
+ ```
524
+
525
+ A parameterized file:
526
+
527
+ ```text
528
+ users/[id].ts
529
+ ```
530
+
531
+ maps to:
532
+
533
+ ```text
534
+ /users/:id
535
+ ```
536
+
537
+ Special filesystem-safe patterns are available for wildcard routes:
538
+
539
+ ```text
540
+ [#] → *
541
+ [[#]] → *!
542
+
543
+ [##] → **
544
+ [[##]] → **!
545
+ ```
546
+
547
+ File routing is completely optional. The normal programmatic API remains the core of Spine.
548
+
549
+ ## Built for HTTP APIs
550
+
551
+ Spine includes the common building blocks you usually end up adding around a spine.
552
+
553
+ ### Request parsing
554
+
555
+ ```ts
556
+ spine.post("/users", [parseBody(), ({ body }) => json(body)]);
557
+ ```
558
+
559
+ Available parsers include:
560
+
561
+ - `parseBody`
562
+ - `parseQuery`
563
+ - `parseCookie`
564
+ - `parseHeaders`
565
+ - `parseMultipart`
566
+
567
+ Multipart parsing is streaming-oriented, making it suitable for large uploads.
568
+
569
+ ### Responses
570
+
571
+ Common response helpers are included:
572
+
573
+ ```ts
574
+ json(data);
575
+ text("Hello");
576
+ html("<h1>Hello</h1>");
577
+ status(204);
578
+ redirect("/login");
579
+ blob(file);
580
+ octetStream(data);
581
+ formData(data);
582
+ usp(params);
583
+ send(data);
584
+ ```
585
+
586
+ Cookie helpers are also provided:
587
+
588
+ ```ts
589
+ setCookie(name, value, options);
590
+ clearCookie(name, options);
591
+ ```
592
+
593
+ ### CORS and rate limiting
594
+
595
+ ```ts
596
+ spine.filterAll("/api/**", [
597
+ cors({
598
+ origins: "*",
599
+ }),
600
+
601
+ limitRate({
602
+ key: ({ request }) => request.headers.get("x-forwarded-for") ?? "unknown",
603
+ maxTokens: 100,
604
+ refillRate: 10,
605
+ }),
606
+ ]);
607
+ ```
608
+
609
+ ### Authentication
610
+
611
+ Authentication is intentionally application-defined:
612
+
613
+ ```ts
614
+ spine.filterGet("/private/**", [
615
+ authenticate({
616
+ parseAuth: async ({ request }) => {
617
+ const token = request.headers.get("authorization");
618
+
619
+ if (!token) return undefined;
620
+
621
+ return {
622
+ role: "user",
623
+ };
624
+ },
625
+ }),
626
+
627
+ authorize({
628
+ allowRole: (role) => role === "user",
629
+ }),
630
+ ]);
631
+ ```
632
+
633
+ Basic Authentication is also supported through `basicAuthParser()`.
634
+
635
+ ## OpenAPI
636
+
637
+ Add OpenAPI metadata directly to a handler:
638
+
639
+ ```ts
640
+ spine.get(
641
+ "/users/:id",
642
+ ({ params }) =>
643
+ json({
644
+ id: params.id,
645
+ }),
646
+ {
647
+ openApi: {
648
+ summary: "Get a user",
649
+ tags: ["Users"],
650
+ responses: {
651
+ "200": {
652
+ description: "User",
653
+ },
654
+ },
655
+ },
656
+ },
657
+ );
658
+ ```
659
+
660
+ Then generate an OpenAPI 3.0 document:
661
+
662
+ ```ts
663
+ const document = await spine.generateOpenAPI({
664
+ title: "My API",
665
+ version: "1.0.0",
666
+ });
667
+ ```
668
+
669
+ Route parameters are automatically represented using OpenAPI's `{parameter}` syntax.
670
+
671
+ ## Error Handling
672
+
673
+ Throw an `HttpError` when you need an HTTP-specific failure:
674
+
675
+ ```ts
676
+ import { HttpError } from "@bepalo/spine";
677
+
678
+ spine.get("/users/:id", ({ params }) => {
679
+ const user = findUser(params.id);
680
+
681
+ if (!user) {
682
+ throw new HttpError(404, "User not found");
683
+ }
684
+
685
+ return json(user);
686
+ });
687
+ ```
688
+
689
+ Handle errors with a catcher:
690
+
691
+ ```ts
692
+ spine.catchGet("/users/**", ({ error }) =>
693
+ json({ error: error?.message }, { status: 500 }),
694
+ );
695
+ ```
696
+
697
+ ## Multipart Parser Demo
698
+
699
+ This is a well tested multipart-form-data parser that parses by streaming chunks.
700
+ It can even handle edge cases like boundary across multiple chunks and very small chunks (down to 5 bytes of chunk). Thank God!
701
+
702
+ ```ts
703
+ router.post("/upload", [
704
+ parseMultipart<
705
+ // Context extension
706
+ {},
707
+ // Parsed formdata file extension
708
+ // we will use this to store uploaded file related state
709
+ {
710
+ totalChunks: number;
711
+ prevProgress: number;
712
+ handle: Bun.FileBlob;
713
+ writer: Bun.FileSink;
714
+ }
715
+ >({
716
+ // // same as default implementation
717
+ // idGenerator: () => toBase64UUID(crypto.randomUUID()),
718
+ // idGenerator: ({ name }) => name,
719
+
720
+ onHeader: async (ctx, { headers, id, name, filename, file }) => {
721
+ console.log("[Upload](onHeader)", id);
722
+ // ctx.currentId = "upload_" + toBase64UUID(crypto.randomUUID());
723
+ if (file) {
724
+ const ext = file.name.substring(file.name.lastIndexOf("."));
725
+ const uploadPath = process.cwd() + "/uploads/" + id + ext;
726
+ const targetFile = Bun.file(uploadPath);
727
+ if (await targetFile.exists()) {
728
+ throw new HttpError(500, `File already exists`);
729
+ }
730
+ file.totalChunks = 0;
731
+ file.prevProgress = 0;
732
+ file.handle = targetFile;
733
+ file.writer = targetFile.writer();
734
+ // print progress start
735
+ console.log(
736
+ name,
737
+ filename,
738
+ "0%",
739
+ file.totalSize ? file.totalSize + " bytes" : "-",
740
+ );
741
+ } else {
742
+ ctx.fields.set(name, "");
743
+ }
744
+ },
745
+
746
+ onData: (ctx, { chunk, id, name, filename, file }) => {
747
+ if (file) {
748
+ file.writer.write(chunk);
749
+ file.totalChunks++;
750
+ // file.crc.update(chunk);
751
+ // progress
752
+ if (file.totalSize) {
753
+ const progress = (file.size / file.totalSize) * 100;
754
+ // print progress every 10% increment
755
+ const truncProgress = Math.trunc(progress / 10);
756
+ if (truncProgress > file.prevProgress) {
757
+ console.log(name, filename, progress.toFixed(2) + "%");
758
+ file.prevProgress = truncProgress;
759
+ }
760
+ }
761
+ } else {
762
+ ctx.fields.set(
763
+ name,
764
+ ctx.fields.get(name)! + new TextDecoder().decode(chunk),
765
+ );
766
+ }
767
+ },
768
+
769
+ onDataCompletion: (ctx, { headers, id, name, filename, file }) => {
770
+ console.log("[Upload](onDataCompletion)", id);
771
+ if (file) {
772
+ file.writer.end();
773
+ delete file.handle;
774
+ delete file.writer;
775
+ console.log(file);
776
+ } else {
777
+ // parse json
778
+ const contentTypeHeader = headers.get("content-type");
779
+ if (contentTypeHeader?.startsWith("application/json")) {
780
+ ctx.fields.set(name, JSON.parse(ctx.fields.get(name)!));
781
+ }
782
+ console.log({ [name]: ctx.fields.get(name)! });
783
+ }
784
+ },
785
+
786
+ onStart: () => {
787
+ console.log("[Upload](onStart)");
788
+ },
789
+
790
+ onEnd: ({ files, fields }, { success, error }) => {
791
+ if (!success) {
792
+ console.error("[Upload](onEnd)", error);
793
+ return json({ error: error?.message });
794
+ }
795
+ console.log("[Upload](onEnd)");
796
+ console.log({ fields, files });
797
+ return json({ message: "Upload successful" });
798
+ },
799
+ }),
800
+ ]);
801
+ ```
802
+
803
+ ## Performance
804
+
805
+ Spine keeps routing deliberately simple and specialized:
806
+
807
+ - Exact routes use direct route tables.
808
+ - Glob routes are stored separately from exact routes.
809
+ - Super-glob routes are handled independently.
810
+ - Routes are organized by HTTP method.
811
+ - Pathnames are split once and reused during matching.
812
+ - Parameter extraction happens only for the selected route candidates.
813
+
814
+ The result is a spine focused on **fast matching, low overhead, and predictable behavior** without tying the routing layer to a particular server.
815
+
816
+ ## 📄 License
817
+
818
+ [MIT](/LICENSE)
819
+
820
+ ## 🕊️ Thanks and Enjoy
821
+
822
+ If you find Spine useful, please consider starring the repository and sharing it with others.
823
+
824
+ ## 💖 Be a Sponsor
825
+
826
+ Support development and future improvements.
827
+
828
+ <a href="https://ko-fi.com/natieshzed">
829
+ <img height="32" src="https://img.shields.io/badge/Ko--fi-donate-orange?style=for-the-badge&logo=ko-fi&logoColor=white">
830
+ </a>