@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.
- package/LICENSE +21 -0
- package/README.md +830 -0
- package/dist/auth-middlewares.d.ts +119 -0
- package/dist/auth-middlewares.d.ts.map +1 -0
- package/dist/auth-middlewares.js +168 -0
- package/dist/auth-middlewares.js.map +1 -0
- package/dist/cjs/auth-middlewares.d.ts +119 -0
- package/dist/cjs/auth-middlewares.d.ts.map +1 -0
- package/dist/cjs/auth-middlewares.js +168 -0
- package/dist/cjs/auth-middlewares.js.map +1 -0
- package/dist/cjs/helpers.d.ts +180 -0
- package/dist/cjs/helpers.d.ts.map +1 -0
- package/dist/cjs/helpers.js +366 -0
- package/dist/cjs/helpers.js.map +1 -0
- package/dist/cjs/index.d.ts +11 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +30 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/middlewares.d.ts +88 -0
- package/dist/cjs/middlewares.d.ts.map +1 -0
- package/dist/cjs/middlewares.js +262 -0
- package/dist/cjs/middlewares.js.map +1 -0
- package/dist/cjs/parsers.d.ts +177 -0
- package/dist/cjs/parsers.d.ts.map +1 -0
- package/dist/cjs/parsers.js +1021 -0
- package/dist/cjs/parsers.js.map +1 -0
- package/dist/cjs/router.d.ts +124 -0
- package/dist/cjs/router.d.ts.map +1 -0
- package/dist/cjs/router.js +1378 -0
- package/dist/cjs/router.js.map +1 -0
- package/dist/cjs/status.d.ts +77 -0
- package/dist/cjs/status.d.ts.map +1 -0
- package/dist/cjs/status.js +252 -0
- package/dist/cjs/status.js.map +1 -0
- package/dist/cjs/types.d.ts +135 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +109 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/cjs/utils.d.ts +11 -0
- package/dist/cjs/utils.d.ts.map +1 -0
- package/dist/cjs/utils.js +76 -0
- package/dist/cjs/utils.js.map +1 -0
- package/dist/cjs/utils.node.d.ts +4 -0
- package/dist/cjs/utils.node.d.ts.map +1 -0
- package/dist/cjs/utils.node.js +99 -0
- package/dist/cjs/utils.node.js.map +1 -0
- package/dist/helpers.d.ts +180 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +366 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/middlewares.d.ts +88 -0
- package/dist/middlewares.d.ts.map +1 -0
- package/dist/middlewares.js +262 -0
- package/dist/middlewares.js.map +1 -0
- package/dist/parsers.d.ts +177 -0
- package/dist/parsers.d.ts.map +1 -0
- package/dist/parsers.js +1021 -0
- package/dist/parsers.js.map +1 -0
- package/dist/router.d.ts +124 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +1378 -0
- package/dist/router.js.map +1 -0
- package/dist/status.d.ts +77 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +252 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +135 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +109 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +76 -0
- package/dist/utils.js.map +1 -0
- package/dist/utils.node.d.ts +4 -0
- package/dist/utils.node.d.ts.map +1 -0
- package/dist/utils.node.js +66 -0
- package/dist/utils.node.js.map +1 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
# 🏆 @bepalo/spine
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@bepalo/spine)
|
|
6
|
+
[](https://github.com/bepalo/spine/actions/workflows/ci.yaml)
|
|
7
|
+
[](https://github.com/bepalo/spine/actions/workflows/testing.yaml)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
<!--
|
|
12
|
+
[](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>
|