@fresho/router 0.0.1 → 0.1.0
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 +401 -28
- package/dist/index.d.mts +377 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1046 -0
- package/dist/index.mjs.map +1 -0
- package/dist/middleware/index.d.mts +396 -0
- package/dist/middleware/index.d.mts.map +1 -0
- package/dist/middleware/index.mjs +675 -0
- package/dist/middleware/index.mjs.map +1 -0
- package/dist/middleware-BpFh0XrR.mjs +50 -0
- package/dist/middleware-BpFh0XrR.mjs.map +1 -0
- package/dist/middleware-CqtYOc6D.d.mts +382 -0
- package/dist/middleware-CqtYOc6D.d.mts.map +1 -0
- package/docs/authentication.md +385 -0
- package/docs/middleware.md +441 -0
- package/package.json +63 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 fresho.dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,45 +1,418 @@
|
|
|
1
1
|
# @fresho/router
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Type-safe routing for Cloudflare Workers, Deno, Bun, and Node.js. Define routes once, get validated handlers, typed clients, and OpenAPI docs.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**~2KB gzipped. Zero dependencies.**
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
```typescript
|
|
8
|
+
import { route, router } from "@fresho/router";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
const api = router({
|
|
11
|
+
health: router({
|
|
12
|
+
get: async () => ({ status: "ok" }),
|
|
13
|
+
}),
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
users: router({
|
|
16
|
+
// GET /users - list with optional limit
|
|
17
|
+
get: route({
|
|
18
|
+
query: { limit: "number?" },
|
|
19
|
+
handler: async (c) => db.users.list(c.query.limit),
|
|
20
|
+
}),
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
// POST /users - create user
|
|
23
|
+
post: route({
|
|
24
|
+
body: { name: "string", email: "string" },
|
|
25
|
+
handler: async (c) => db.users.create(c.body),
|
|
26
|
+
}),
|
|
17
27
|
|
|
18
|
-
|
|
28
|
+
// GET /users/:id - get by id
|
|
29
|
+
$id: router({
|
|
30
|
+
// Bare function shorthand instead of `route` type
|
|
31
|
+
get: async (c) => db.users.get(c.path.id),
|
|
32
|
+
}),
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
19
35
|
|
|
20
|
-
|
|
36
|
+
// Cloudflare Worker / Deno / Bun
|
|
37
|
+
export default { fetch: api.handler() };
|
|
38
|
+
```
|
|
21
39
|
|
|
22
|
-
|
|
40
|
+
## Features
|
|
23
41
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
- **Type-safe path params** — `$id` creates dynamic segments, typed via `route.ctx<>()`
|
|
43
|
+
- **Schema validation** — query and body validated at runtime, typed at compile time
|
|
44
|
+
- **Property-based routing** — property names become URL segments
|
|
45
|
+
- **Typed HTTP client** — call your API with full type safety
|
|
46
|
+
- **Typed local client** — test handlers directly without HTTP
|
|
47
|
+
- **OpenAPI generation** — auto-generate docs from your routes
|
|
48
|
+
- **Middleware** — cors, auth, rate limiting, and more
|
|
49
|
+
- **Streaming** — SSE and JSON lines helpers
|
|
50
|
+
- **HTTP-compliant** — automatic HEAD support for GET routes
|
|
51
|
+
- **Zero dependencies** — just Web APIs
|
|
28
52
|
|
|
29
|
-
##
|
|
53
|
+
## Installation
|
|
30
54
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
- Should not be installed as a dependency
|
|
35
|
-
- Exists only for administrative purposes
|
|
55
|
+
```bash
|
|
56
|
+
npm install @fresho/router
|
|
57
|
+
```
|
|
36
58
|
|
|
37
|
-
##
|
|
59
|
+
## Path Convention
|
|
38
60
|
|
|
39
|
-
|
|
40
|
-
- [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
|
|
41
|
-
- [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
|
|
61
|
+
Property names become URL path segments:
|
|
42
62
|
|
|
43
|
-
|
|
63
|
+
```typescript
|
|
64
|
+
router({
|
|
65
|
+
api: router({ // /api
|
|
66
|
+
users: router({ // /api/users
|
|
67
|
+
get: async () => ...,
|
|
68
|
+
$id: router({ // /api/users/:id
|
|
69
|
+
get: async (c) => c.path.id,
|
|
70
|
+
}),
|
|
71
|
+
}),
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
```
|
|
44
75
|
|
|
45
|
-
|
|
76
|
+
- Regular properties → static segments (`users` → `/users`)
|
|
77
|
+
- `$param` properties → dynamic segments (`$id` → `/:id`)
|
|
78
|
+
- HTTP methods (`get`, `post`, etc.) → handlers at that path
|
|
79
|
+
|
|
80
|
+
## Schemas
|
|
81
|
+
|
|
82
|
+
Define query and body schemas using shorthand syntax:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// Primitives
|
|
86
|
+
{ name: 'string' } // required string
|
|
87
|
+
{ age: 'number' } // required number (coerced from string in query)
|
|
88
|
+
{ active: 'boolean' } // required boolean (accepts "true"/"false"/"1"/"0")
|
|
89
|
+
|
|
90
|
+
// Optional (append ?)
|
|
91
|
+
{ name: 'string?' } // optional string
|
|
92
|
+
|
|
93
|
+
// Arrays
|
|
94
|
+
{ tags: 'string[]' } // string array
|
|
95
|
+
{ scores: 'number[]' } // number array
|
|
96
|
+
|
|
97
|
+
// Nested objects
|
|
98
|
+
{
|
|
99
|
+
address: {
|
|
100
|
+
street: 'string',
|
|
101
|
+
city: 'string',
|
|
102
|
+
zip: 'number?'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Types are automatically inferred:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
post: route({
|
|
111
|
+
body: {
|
|
112
|
+
title: "string",
|
|
113
|
+
tags: "string[]",
|
|
114
|
+
metadata: { priority: "number", draft: "boolean?" },
|
|
115
|
+
},
|
|
116
|
+
handler: async (c) => {
|
|
117
|
+
c.body.title; // string
|
|
118
|
+
c.body.tags; // string[]
|
|
119
|
+
c.body.metadata; // { priority: number, draft: boolean | undefined }
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Typing Best Practices
|
|
125
|
+
|
|
126
|
+
**Schemas** (`query`/`body`) provide runtime validation AND type inference:
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
get: route({
|
|
130
|
+
query: { limit: "number?" },
|
|
131
|
+
handler: async (c) => c.query.limit, // number | undefined
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Context** (`route.ctx<T>()`) provides types only for things schemas don't cover:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
interface MyContext {
|
|
139
|
+
path: { id: string }; // from $id segment
|
|
140
|
+
env: { DB: Database }; // runtime environment
|
|
141
|
+
user: { name: string }; // from auth middleware
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get: route.ctx<MyContext>()({
|
|
145
|
+
query: { include: "string?" }, // validated
|
|
146
|
+
handler: async (c) => ({
|
|
147
|
+
id: c.path.id, // from context
|
|
148
|
+
user: c.user.name, // from context
|
|
149
|
+
include: c.query.include, // from schema
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Don't add explicit type annotations to handlers** — let types flow from schemas and context:
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
// GOOD: types inferred
|
|
158
|
+
handler: async (c) => c.query.limit;
|
|
159
|
+
|
|
160
|
+
// BAD: redundant annotation
|
|
161
|
+
handler: async (c: { query: { limit?: number } }) => c.query.limit;
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Nested Routers
|
|
165
|
+
|
|
166
|
+
Compose routers:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
const users = router({
|
|
170
|
+
get: async () => db.users.list(),
|
|
171
|
+
post: route({
|
|
172
|
+
body: { name: 'string' },
|
|
173
|
+
handler: async (c) => db.users.create(c.body),
|
|
174
|
+
}),
|
|
175
|
+
$id: router({
|
|
176
|
+
get: async (c) => db.users.get(c.path.id),
|
|
177
|
+
delete: async (c) => db.users.delete(c.path.id),
|
|
178
|
+
}),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const api = router({
|
|
182
|
+
users,
|
|
183
|
+
posts: router({ ... }),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Routes:
|
|
187
|
+
// GET /users
|
|
188
|
+
// POST /users
|
|
189
|
+
// GET /users/:id
|
|
190
|
+
// DELETE /users/:id
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Middleware
|
|
194
|
+
|
|
195
|
+
Add middleware to routers:
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
import { router, route } from "@fresho/router";
|
|
199
|
+
import { cors, errorHandler, jwtAuth } from "@fresho/router/middleware";
|
|
200
|
+
|
|
201
|
+
const api = router(
|
|
202
|
+
{
|
|
203
|
+
hello: router({
|
|
204
|
+
get: async () => ({ message: "world" }),
|
|
205
|
+
}),
|
|
206
|
+
},
|
|
207
|
+
cors(),
|
|
208
|
+
errorHandler(),
|
|
209
|
+
jwtAuth({ secret: process.env.JWT_SECRET, claims: (p) => ({ user: p.sub }) })
|
|
210
|
+
);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Built-in middleware: `cors`, `errorHandler`, `logger`, `rateLimit`, `requestId`, `timeout`, `basicAuth`, `bearerAuth`, `jwtAuth`, `contentType`.
|
|
214
|
+
|
|
215
|
+
See **[Middleware Documentation](docs/middleware.md)** for detailed usage.
|
|
216
|
+
|
|
217
|
+
## HTTP Client
|
|
218
|
+
|
|
219
|
+
Generate a typed client for your API:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
// === Server (api.ts) ===
|
|
223
|
+
import { route, router } from "@fresho/router";
|
|
224
|
+
|
|
225
|
+
export const api = router({
|
|
226
|
+
users: router({
|
|
227
|
+
get: route({
|
|
228
|
+
query: { limit: "number?" },
|
|
229
|
+
handler: async (c) => ({ users: [], limit: c.query.limit }),
|
|
230
|
+
}),
|
|
231
|
+
$id: router({
|
|
232
|
+
get: async (c) => ({ id: c.path.id, name: "Alice" }),
|
|
233
|
+
}),
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// === Client ===
|
|
238
|
+
import { createHttpClient } from "@fresho/router";
|
|
239
|
+
import type { api } from "./api"; // Type-only import!
|
|
240
|
+
|
|
241
|
+
const client = createHttpClient<typeof api>({
|
|
242
|
+
baseUrl: "https://api.example.com",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Direct call for implicit GET
|
|
246
|
+
const users = await client.users({ query: { limit: 10 } });
|
|
247
|
+
|
|
248
|
+
// Path params
|
|
249
|
+
const user = await client.users.$id({ path: { id: "123" } });
|
|
250
|
+
// user is typed as { id: string, name: string }
|
|
251
|
+
|
|
252
|
+
// Explicit HTTP methods use $-prefix
|
|
253
|
+
await client.users.$get(); // GET /users
|
|
254
|
+
await client.users.$post({ body: { name: "Bob" } }); // POST /users
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### HTTP Method Syntax
|
|
258
|
+
|
|
259
|
+
HTTP methods are prefixed with `$` to distinguish them from path navigation:
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
// $-prefixed = execute HTTP method
|
|
263
|
+
client.users.$get(); // GET /users
|
|
264
|
+
client.users.$post({}); // POST /users
|
|
265
|
+
client.users.$put({}); // PUT /users
|
|
266
|
+
client.users.$delete(); // DELETE /users
|
|
267
|
+
|
|
268
|
+
// No prefix = navigate to path segment
|
|
269
|
+
client.api.get.$get(); // GET /api/get (navigate to "get", then execute GET)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
This allows routes with path segments named after HTTP methods (e.g., `/api/get`, `/resources/delete`).
|
|
273
|
+
|
|
274
|
+
## Local Client
|
|
275
|
+
|
|
276
|
+
Test handlers directly without HTTP overhead:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
import { createLocalClient } from "@fresho/router";
|
|
280
|
+
|
|
281
|
+
const client = createLocalClient(api);
|
|
282
|
+
client.configure({ env: { DB: mockDb } });
|
|
283
|
+
|
|
284
|
+
const user = await client.users.$id({ path: { id: "123" } });
|
|
285
|
+
assert.equal(user.name, "Alice");
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## OpenAPI Generation
|
|
289
|
+
|
|
290
|
+
Generate OpenAPI 3.0 documentation:
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
import { generateDocs } from "@fresho/router";
|
|
294
|
+
|
|
295
|
+
const spec = generateDocs(api, {
|
|
296
|
+
title: "My API",
|
|
297
|
+
version: "1.0.0",
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Serve at /openapi.json
|
|
301
|
+
const docs = router({
|
|
302
|
+
openapi: router({
|
|
303
|
+
get: async () => spec,
|
|
304
|
+
}),
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Streaming
|
|
309
|
+
|
|
310
|
+
### Server-Sent Events
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
import { sseResponse } from "@fresho/router";
|
|
314
|
+
|
|
315
|
+
events: router({
|
|
316
|
+
get: async () =>
|
|
317
|
+
sseResponse(async (send, close) => {
|
|
318
|
+
send({ data: "connected" });
|
|
319
|
+
send({ event: "update", data: { count: 1 } });
|
|
320
|
+
close();
|
|
321
|
+
}),
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### JSON Lines
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
import { streamJsonLines } from "@fresho/router";
|
|
329
|
+
|
|
330
|
+
logs: router({
|
|
331
|
+
get: async () =>
|
|
332
|
+
streamJsonLines(async (send, close) => {
|
|
333
|
+
send({ level: "info", message: "Starting..." });
|
|
334
|
+
send({ level: "info", message: "Done" });
|
|
335
|
+
close();
|
|
336
|
+
}),
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Cloudflare Workers
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
import { route, router } from '@fresho/router';
|
|
344
|
+
|
|
345
|
+
const api = router({ ... });
|
|
346
|
+
|
|
347
|
+
export default {
|
|
348
|
+
fetch: api.handler(),
|
|
349
|
+
};
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Typed Context
|
|
353
|
+
|
|
354
|
+
Use `route.ctx<T>()` for environment bindings and middleware-added properties:
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
interface AppContext {
|
|
358
|
+
env: { DB: D1Database };
|
|
359
|
+
user: { id: string }; // from auth middleware
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const api = router(
|
|
363
|
+
{
|
|
364
|
+
profile: router({
|
|
365
|
+
get: route.ctx<AppContext>()({
|
|
366
|
+
handler: async (c) => {
|
|
367
|
+
const data = await c.env.DB.prepare("...").all();
|
|
368
|
+
return { userId: c.user.id, data };
|
|
369
|
+
},
|
|
370
|
+
}),
|
|
371
|
+
}),
|
|
372
|
+
},
|
|
373
|
+
jwtAuth({
|
|
374
|
+
secret: (c) => c.env.JWT_SECRET,
|
|
375
|
+
claims: (p) => ({ user: { id: p.sub } }),
|
|
376
|
+
})
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
export default { fetch: api.handler() };
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## HEAD Requests
|
|
383
|
+
|
|
384
|
+
Per [RFC 9110](https://httpwg.org/specs/rfc9110.html#HEAD), HEAD requests are automatically handled for any GET route:
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
users: router({
|
|
388
|
+
get: async () => ({ users: await db.getUsers() }),
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// GET /users → 200 with body
|
|
392
|
+
// HEAD /users → 200 with no body (same headers)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Define an explicit `head` handler if you need different behavior:
|
|
396
|
+
|
|
397
|
+
```typescript
|
|
398
|
+
users: router({
|
|
399
|
+
head: async () => new Response(null, { headers: { "X-Count": "100" } }),
|
|
400
|
+
get: async () => ({ users: await db.getUsers() }),
|
|
401
|
+
});
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
## Size
|
|
405
|
+
|
|
406
|
+
| Usage | Minified | Gzipped |
|
|
407
|
+
| --------------------------- | -------- | ------- |
|
|
408
|
+
| Core (routing + validation) | 4.2 KB | 1.9 KB |
|
|
409
|
+
| + HTTP client | 5.8 KB | 2.4 KB |
|
|
410
|
+
| + OpenAPI docs | 5.3 KB | 2.3 KB |
|
|
411
|
+
| + cors, errorHandler | 6.5 KB | 2.7 KB |
|
|
412
|
+
| + all middleware | 11.6 KB | 4.4 KB |
|
|
413
|
+
|
|
414
|
+
Tree-shakeable: only pay for what you import.
|
|
415
|
+
|
|
416
|
+
## License
|
|
417
|
+
|
|
418
|
+
MIT
|