@sigitex/route 1.0.0 → 1.0.2

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
@@ -1,7 +1,379 @@
1
1
  # @sigitex/route
2
2
 
3
- `bun add @sigitex/route`
4
-
5
3
  A server-side web framework.
6
4
 
5
+ `bun add @sigitex/route`
6
+
7
7
  > **Note:** This package currently exports TypeScript sources directly. A TypeScript-compatible runtime or bundler (Bun, etc.) is required.
8
+
9
+ ## Quick Start
10
+
11
+ ```ts
12
+ import { route, get, post, prefix, cors, hardened, cookies } from "@sigitex/route"
13
+ import { bun } from "@sigitex/route/bun"
14
+
15
+ const fetch = route(
16
+ bun({ assets: "./public" }),
17
+ get("/health", () => ({ status: "ok" })),
18
+ prefix("/api/", [cors(), cookies(), hardened()],
19
+ get("/users", async () => {
20
+ return Response.json(await getUsers())
21
+ }),
22
+ get("/users/:id", async ({ params }: { params: { id: string } }) => {
23
+ return Response.json(await getUser(params.id))
24
+ }),
25
+ post("/users", async ({ request }: { request: Request }) => {
26
+ const body = await request.json()
27
+ return Response.json(await createUser(body))
28
+ }),
29
+ ),
30
+ )
31
+
32
+ Bun.serve({ fetch })
33
+ ```
34
+
35
+ On Cloudflare Workers:
36
+
37
+ ```ts
38
+ import { route, get } from "@sigitex/route"
39
+ import { cloudflare } from "@sigitex/route/cloudflare"
40
+
41
+ export default {
42
+ fetch: route(
43
+ cloudflare(),
44
+ get("/hello", () => ({ hello: "world" })),
45
+ ),
46
+ }
47
+ ```
48
+
49
+ ## `route(...handlers)`
50
+
51
+ Creates a fetch function `(request: Request, env: Env) => Promise<Response>` from a list of handlers. Handlers are tried in order; the first to return a value produces the response. If none match, a 404 is returned.
52
+
53
+ ```ts
54
+ const fetch = route(handler1, handler2, handler3)
55
+ ```
56
+
57
+ An optional `RouterOptions` object can be passed as the first argument:
58
+
59
+ ```ts
60
+ const fetch = route({ container, middlewares: [cors()] }, handler1, handler2)
61
+ ```
62
+
63
+ Falsy values (`null`, `undefined`, `false`, `0`) are silently ignored, allowing conditional handlers:
64
+
65
+ ```ts
66
+ route(
67
+ isDev && get("/debug", debugHandler),
68
+ get("/", homeHandler),
69
+ )
70
+ ```
71
+
72
+ ### `RouterOptions`
73
+
74
+ | Option | Type | Description |
75
+ | ------------- | ----------------- | ------------------------------------------------------ |
76
+ | `container` | `Container` | A `@sigitex/bind` IoC container for dependency injection |
77
+ | `middlewares` | `RouteMiddleware[]` | Global middlewares applied to every dispatched handler |
78
+
79
+ ## Handlers
80
+
81
+ Handlers are functions that receive a context object and return a `Response`, a JSON-serializable value, or `undefined` to skip.
82
+
83
+ ### `get(path, handler, ...middlewares)`
84
+
85
+ Matches GET requests against `path`. Path parameters use `regexparam` syntax (`:param`, `*`).
86
+
87
+ ```ts
88
+ get("/users/:id", ({ params }: { params: { id: string } }) => {
89
+ return Response.json({ id: params.id })
90
+ })
91
+ ```
92
+
93
+ ### `post(path, handler, ...middlewares)`
94
+
95
+ Matches POST requests.
96
+
97
+ ### `put(path, handler, ...middlewares)`
98
+
99
+ Matches PUT requests.
100
+
101
+ ### `del(path, handler, ...middlewares)`
102
+
103
+ Matches DELETE requests.
104
+
105
+ ### `patch(path, handler, ...middlewares)`
106
+
107
+ Matches PATCH requests.
108
+
109
+ ### `pattern(method, path, handler, ...middlewares)`
110
+
111
+ Generic version -- pass `null` as the method to match any HTTP method.
112
+
113
+ ```ts
114
+ pattern(null, "/any-method/:id", handler)
115
+ pattern("GET", "/explicit", handler)
116
+ ```
117
+
118
+ ### `prefix(prefix, ...handlers)`
119
+
120
+ Groups handlers under a URL prefix. The prefix is stripped from the URL before child handlers see it.
121
+
122
+ ```ts
123
+ prefix("/api/v1/",
124
+ get("/users", listUsers), // matches /api/v1/users
125
+ get("/posts", listPosts), // matches /api/v1/posts
126
+ )
127
+ ```
128
+
129
+ Accepts an optional middlewares array as the second argument:
130
+
131
+ ```ts
132
+ prefix("/api/", [cors(), bodyLimit()],
133
+ post("/upload", uploadHandler),
134
+ )
135
+ ```
136
+
137
+ ### `mount(fetchFn)`
138
+
139
+ Wraps a standard `(request: Request) => Promise<Response>` function as a handler. Useful for mounting sub-applications or external fetch handlers.
140
+
141
+ ```ts
142
+ mount(subApp.fetch)
143
+ ```
144
+
145
+ ### `assets()`
146
+
147
+ Serves static files via the platform's `Assets` binding. Returns `undefined` on 404 so subsequent handlers can match.
148
+
149
+ ```ts
150
+ route(bun(), assets(), get("/", homeHandler))
151
+ ```
152
+
153
+ ### `app(routes)`
154
+
155
+ Serves `index.html` for paths matching a client-side `RouteTree`. Intended for single-page applications where the client handles routing.
156
+
157
+ ```ts
158
+ app({
159
+ users: "/users",
160
+ user: "/users/:id",
161
+ settings: { general: "/settings/general" },
162
+ })
163
+ ```
164
+
165
+ ### `noop`
166
+
167
+ A handler that does nothing and returns `undefined`. Used internally by middleware composition.
168
+
169
+ ### `use(middlewares, ...handlers)`
170
+
171
+ Applies a set of middlewares to a group of handlers without creating a prefix.
172
+
173
+ ```ts
174
+ use([cors(), cookies()],
175
+ get("/a", handlerA),
176
+ get("/b", handlerB),
177
+ )
178
+ ```
179
+
180
+ ### `filter(predicate)`
181
+
182
+ Higher-order function that conditionally runs a handler based on a predicate.
183
+
184
+ ```ts
185
+ const onlyJson = filter(({ request }) =>
186
+ request.headers.get("Accept")?.includes("application/json") ?? false
187
+ )
188
+
189
+ onlyJson(get("/data", dataHandler))
190
+ ```
191
+
192
+ ### `https()`
193
+
194
+ Redirects HTTP requests to HTTPS with a 301.
195
+
196
+ ```ts
197
+ route(https(), get("/", homeHandler))
198
+ ```
199
+
200
+ ### `www(options)`
201
+
202
+ Redirects non-www requests to the www subdomain.
203
+
204
+ ```ts
205
+ www({ secure: true }) // also upgrades to https
206
+ ```
207
+
208
+ ## Middleware
209
+
210
+ Middlewares are objects with optional `before` and `after` hooks. `before` runs before the handler; `after` runs after. Either can return a `Response` to short-circuit.
211
+
212
+ ```ts
213
+ const myMiddleware: RouteMiddleware = {
214
+ before: ({ request, bind }) => {
215
+ bind({ startTime: Date.now() })
216
+ },
217
+ after: ({ response, startTime }) => {
218
+ response.headers.set("X-Duration", String(Date.now() - startTime))
219
+ },
220
+ }
221
+ ```
222
+
223
+ ### `cors(options?)`
224
+
225
+ Handles CORS preflight and response headers.
226
+
227
+ | Option | Type | Default |
228
+ | --------------- | ------------------------------------------------- | ---------- |
229
+ | `origin` | `string \| string[] \| (origin: string) => boolean` | `"*"` |
230
+ | `methods` | `string[]` | all standard |
231
+ | `allowHeaders` | `string[]` | mirrors request |
232
+ | `exposeHeaders` | `string[]` | -- |
233
+ | `credentials` | `boolean` | `false` |
234
+ | `maxAge` | `number` | -- |
235
+
236
+ ### `cookies()`
237
+
238
+ Parses request cookies and collects `Set-Cookie` headers on the response. Binds a `Cookies` object to context:
239
+
240
+ ```ts
241
+ cookies.get("session") // read
242
+ cookies.set("session", token, { // write
243
+ httpOnly: true,
244
+ secure: true,
245
+ sameSite: "strict",
246
+ maxAge: 86400,
247
+ path: "/",
248
+ })
249
+ ```
250
+
251
+ #### `CookieOptions`
252
+
253
+ `domain`, `expires`, `httpOnly`, `maxAge`, `path`, `sameSite` (`"strict" | "lax" | "none"`), `secure`.
254
+
255
+ ### `bodyLimit(options?)`
256
+
257
+ Rejects requests exceeding a body size or with disallowed content types.
258
+
259
+ | Option | Type | Default |
260
+ | -------------- | ---------- | --------- |
261
+ | `maxSize` | `number` | 1 MB |
262
+ | `contentTypes` | `string[]` | any |
263
+
264
+ ### `cache(options)`
265
+
266
+ Sets `Cache-Control` (and optionally `Vary`) headers on responses.
267
+
268
+ ```ts
269
+ cache({ public: true, maxAge: 3600 })
270
+ cache("no-store")
271
+ ```
272
+
273
+ #### `CacheOptions`
274
+
275
+ `public`, `private`, `maxAge`, `sMaxAge`, `noCache`, `noStore`, `mustRevalidate`, `proxyRevalidate`, `immutable`, `staleWhileRevalidate`, `staleIfError`, `vary`.
276
+
277
+ ### `csp(options)`
278
+
279
+ Sets `Content-Security-Policy` headers. Supports automatic nonce generation via the `CSP.nonce` symbol -- the nonce is bound to context as `cspNonce`.
280
+
281
+ ```ts
282
+ import { CSP } from "@sigitex/route"
283
+
284
+ csp({
285
+ defaultSrc: [CSP.self],
286
+ scriptSrc: [CSP.self, CSP.nonce],
287
+ styleSrc: [CSP.self, CSP.unsafeInline],
288
+ imgSrc: [CSP.self, CSP.data],
289
+ reportOnly: true,
290
+ })
291
+ ```
292
+
293
+ ### `csrf(options?)`
294
+
295
+ Double-submit cookie CSRF protection. Requires `cookies()` in the middleware stack.
296
+
297
+ | Option | Type | Default |
298
+ | --------- | ---------- | -------------------------------- |
299
+ | `cookie` | `string` | `"csrf-token"` |
300
+ | `header` | `string` | `"X-CSRF-Token"` |
301
+ | `methods` | `string[]` | POST, PUT, PATCH, DELETE |
302
+
303
+ ### `rateLimit(options?)`
304
+
305
+ IP-based rate limiting with pluggable storage.
306
+
307
+ | Option | Type | Default |
308
+ | --------- | ------------------ | ------------------------ |
309
+ | `window` | `number` (seconds) | `60` |
310
+ | `max` | `number` | `100` |
311
+ | `key` | `(ctx) => string` | `rateLimit.ip` |
312
+ | `store` | `RateLimitStore` | `rateLimit.memory()` |
313
+ | `headers` | `boolean` | `true` |
314
+
315
+ Built-in helpers:
316
+
317
+ - `rateLimit.ip` -- key extractor using `CF-Connecting-IP` or `X-Forwarded-For`
318
+ - `rateLimit.memory()` -- in-memory sliding window store
319
+
320
+ ### `hardened(options?)`
321
+
322
+ Convenience bundle applying `noSniff`, `frameGuard`, `referrerPolicy`, and `hsts`. Any can be disabled:
323
+
324
+ ```ts
325
+ hardened() // all defaults
326
+ hardened({ hsts: false }) // skip HSTS
327
+ hardened({ frameGuard: "sameOrigin" }) // override frame guard
328
+ ```
329
+
330
+ ### `hsts(options?)`
331
+
332
+ Sets `Strict-Transport-Security`. Defaults: `max-age=31536000; includeSubDomains`.
333
+
334
+ | Option | Type | Default |
335
+ | ------------------- | --------- | ----------- |
336
+ | `maxAge` | `number` | `31536000` |
337
+ | `includeSubDomains` | `boolean` | `true` |
338
+ | `preload` | `boolean` | `false` |
339
+
340
+ ### `noSniff`
341
+
342
+ Sets `X-Content-Type-Options: nosniff`.
343
+
344
+ ### `frameGuard.deny` / `frameGuard.sameOrigin`
345
+
346
+ Sets `X-Frame-Options`.
347
+
348
+ ### `referrerPolicy.*`
349
+
350
+ Pre-built policies: `noReferrer`, `noReferrerWhenDowngrade`, `origin`, `originWhenCrossOrigin`, `sameOrigin`, `strictOrigin`, `strictOriginWhenCrossOrigin`, `unsafeUrl`.
351
+
352
+ ### `requestId(options?)`
353
+
354
+ Reads or generates a request ID, binds it to context as `requestId`, and echoes it on the response.
355
+
356
+ | Option | Type | Default |
357
+ | ---------- | -------------- | -------------------- |
358
+ | `header` | `string` | `"X-Request-Id"` |
359
+ | `generate` | `() => string` | `crypto.randomUUID` |
360
+
361
+ ### `setHeader(header, value)`
362
+
363
+ Sets a header on every response. Returns a `ResponseHandler` (use as an `after` hook).
364
+
365
+ ```ts
366
+ { after: setHeader("X-Powered-By", "sigitex") }
367
+ ```
368
+
369
+ ## Errors
370
+
371
+ Error classes that can be thrown from handlers:
372
+
373
+ | Class | Status | Default Message |
374
+ | ------------------ | ------ | ------------------------ |
375
+ | `RouterError` | any | -- |
376
+ | `NotFound` | 404 | `"Not found."` |
377
+ | `MethodNotAllowed` | 405 | `"Method not allowed."` |
378
+ | `InvalidRequest` | 400 | `"Invalid request."` |
379
+ | `ServerError` | -- | `"Internal error."` |
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/sigitex/route.git"
11
+ "url": "git+https://github.com/sigitex/route.git"
12
12
  },
13
13
  "exports": {
14
14
  ".": "./src/index.ts",
@@ -21,9 +21,9 @@
21
21
  "@commitlint/config-conventional": "^20.5.3",
22
22
  "@markwylde/semantic-release-gitea": "^2.2.0",
23
23
  "@semantic-release/exec": "^7.0.3",
24
- "@sigitex/bind": "*",
25
- "@sigitex/ssjs": "*",
26
- "@types/bun": "1.3.11",
24
+ "@sigitex/bind": "^1.0.0",
25
+ "@sigitex/ssjs": "^1.1.0",
26
+ "@types/bun": "^1.3.13",
27
27
  "@typescript/native-preview": "beta",
28
28
  "husky": "^9.1.7",
29
29
  "multi-semantic-release": "^3.1.0",
@@ -35,13 +35,13 @@
35
35
  "regexparam": "3.0.0"
36
36
  },
37
37
  "scripts": {
38
- "check": "tsgo --noEmit && find . -mindepth 2 -name tsconfig.json -not -path '*/node_modules/*' -exec tsgo --noEmit -p {} \\;",
39
- "test": "bun test --pass-with-no-tests",
38
+ "check": "tsgo --build",
39
+ "test": "bun test --pass-with-no-tests --tsconfig-override tsconfig.test.json",
40
40
  "prepare": "husky",
41
41
  "lint": "oxlint"
42
42
  },
43
43
  "files": [
44
44
  "src"
45
45
  ],
46
- "version": "1.0.0"
46
+ "version": "1.0.2"
47
47
  }
package/src/Assets.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /** Platform-agnostic interface for serving static files. */
1
2
  export type Assets = {
2
3
  static(path: string): Promise<Response>
3
4
  file(request: Request): Promise<Response | undefined>
package/src/CSP.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  const nonce = Symbol("csp-nonce")
2
2
 
3
+ /** Content-Security-Policy source value constants. */
3
4
  export const CSP = {
4
5
  self: "'self'",
5
6
  none: "'none'",
@@ -15,4 +16,5 @@ export const CSP = {
15
16
  wss: "wss:",
16
17
  } as const
17
18
 
19
+ /** A CSP source value: a string directive or the nonce symbol. */
18
20
  export type CspSource = string | typeof nonce
package/src/HTTP.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /** HTTP constants for methods, statuses, headers, and content types. */
1
2
  export const HTTP = {
2
3
  method: {
3
4
  GET: "GET",
package/src/Router.ts CHANGED
@@ -3,9 +3,10 @@ import type {
3
3
  RequestHandler,
4
4
  RouterOptions,
5
5
  RouteMiddleware,
6
- RequestContext
6
+ RequestContext,
7
7
  } from "./router.types"
8
8
 
9
+ /** Core router that dispatches requests through a handler chain. */
9
10
  export class Router {
10
11
  private readonly handlers: RequestHandler[]
11
12
  private readonly container: Container | undefined
@@ -17,10 +18,7 @@ export class Router {
17
18
  this.middlewares = options.middlewares ?? []
18
19
  }
19
20
 
20
- async route(
21
- request: Request,
22
- env: Env,
23
- ): Promise<Response> {
21
+ async route(request: Request, env: Env): Promise<Response> {
24
22
  const url = new URL(request.url)
25
23
  const context: RequestContext = {
26
24
  request,
@@ -36,7 +34,9 @@ export class Router {
36
34
  }
37
35
  for (const handler of this.handlers) {
38
36
  const result = await dispatch(handler, this.middlewares)
39
- if (result === undefined) continue
37
+ if (result === undefined) {
38
+ continue
39
+ }
40
40
  return result
41
41
  }
42
42
  return fail(404, "Not found.")
@@ -59,9 +59,13 @@ export class Router {
59
59
  middlewares: RouteMiddleware[],
60
60
  ): Promise<Response | undefined> {
61
61
  for (const { before } of middlewares) {
62
- if (!before) continue
62
+ if (!before) {
63
+ continue
64
+ }
63
65
  const interrupt = await Promise.resolve(invoke(before))
64
- if (interrupt !== undefined) return respond(interrupt)
66
+ if (interrupt !== undefined) {
67
+ return respond(interrupt)
68
+ }
65
69
  }
66
70
  const result = await Promise.resolve(invoke(handler))
67
71
  if (result === undefined) {
@@ -70,9 +74,13 @@ export class Router {
70
74
  const response = respond(result)
71
75
  bind({ response })
72
76
  for (const { after } of middlewares) {
73
- if (!after) continue
77
+ if (!after) {
78
+ continue
79
+ }
74
80
  const interrupt = await Promise.resolve(invoke(after))
75
- if (interrupt !== undefined) return respond(interrupt)
81
+ if (interrupt !== undefined) {
82
+ return respond(interrupt)
83
+ }
76
84
  }
77
85
  return respond(result)
78
86
  }
@@ -87,7 +95,7 @@ export class Router {
87
95
  }
88
96
 
89
97
  function fail(status: number, error: string) {
90
- return new Response(JSON.stringify({ error }), {
98
+ return Response.json({ error }, {
91
99
  status,
92
100
  statusText: error,
93
101
  })
@@ -96,5 +104,5 @@ function fail(status: number, error: string) {
96
104
  function respond(result: unknown) {
97
105
  return result instanceof Response
98
106
  ? result
99
- : new Response(JSON.stringify(result))
107
+ : Response.json(result)
100
108
  }
@@ -1,31 +1,42 @@
1
+ // oxlint-disable unicorn/custom-error-definition
2
+ /** Base error class with an HTTP status code. */
1
3
  export class RouterError extends Error {
2
4
  readonly code: number
3
5
  constructor(code: number, message: string) {
4
6
  super(message)
5
7
  this.code = code
8
+ this.name = "RouterError"
6
9
  }
7
10
  }
8
11
 
12
+ /** 404 Not Found error. */
9
13
  export class NotFound extends RouterError {
10
14
  constructor(message?: string) {
11
15
  super(404, message ?? "Not found.")
16
+ this.name = "NotFound"
12
17
  }
13
18
  }
14
19
 
20
+ /** Generic server error (no HTTP status code). */
15
21
  export class ServerError extends Error {
16
22
  constructor(message?: string) {
17
23
  super(message ?? "Internal error.")
24
+ this.name = "ServerError"
18
25
  }
19
26
  }
20
27
 
28
+ /** 405 Method Not Allowed error. */
21
29
  export class MethodNotAllowed extends RouterError {
22
30
  constructor(message?: string) {
23
31
  super(405, message ?? "Method not allowed.")
32
+ this.name = "MethodNotAllowed"
24
33
  }
25
34
  }
26
35
 
36
+ /** 400 Invalid Request error. */
27
37
  export class InvalidRequest extends RouterError {
28
38
  constructor(message?: string) {
29
39
  super(400, message ?? "Invalid request.")
40
+ this.name = "InvalidRequest"
30
41
  }
31
42
  }
package/src/bun/bun.ts CHANGED
@@ -6,6 +6,7 @@ type BunOptions = {
6
6
  readonly assets?: string
7
7
  }
8
8
 
9
+ /** Bun runtime adapter; binds a local-filesystem Assets implementation. */
9
10
  export function bun(options: BunOptions = {}): RequestHandler {
10
11
  const assets = bunAssets(options.assets ?? "./assets")
11
12
  return ({ bind }: { bind: (bindings: { assets: Assets }) => void }) => {
@@ -19,7 +20,9 @@ function bunAssets(dir: string): Assets {
19
20
  return {
20
21
  async static(path) {
21
22
  const cached = cache.get(path)
22
- if (cached) return cached.clone() as Response
23
+ if (cached) {
24
+ return cached.clone() as Response
25
+ }
23
26
  const file = Bun.file(join(dir, path))
24
27
  const response = new Response(await file.bytes(), {
25
28
  headers: { "Content-Type": file.type },
@@ -30,7 +33,9 @@ function bunAssets(dir: string): Assets {
30
33
  async file(request) {
31
34
  const path = new URL(request.url).pathname
32
35
  const file = Bun.file(join(dir, path))
33
- if (!await file.exists()) return undefined
36
+ if (!(await file.exists())) {
37
+ return undefined
38
+ }
34
39
  return new Response(file) as Response
35
40
  },
36
41
  }
@@ -9,6 +9,7 @@ type CloudflareOptions = {
9
9
  readonly assets?: string
10
10
  }
11
11
 
12
+ /** Cloudflare Workers adapter; binds a Workers Assets implementation. */
12
13
  export function cloudflare(options: CloudflareOptions = {}): RequestHandler {
13
14
  const assetsKey = options.assets ?? "ASSETS"
14
15
  return ({
@@ -0,0 +1 @@
1
+ interface Env {}
@@ -2,6 +2,7 @@ import * as RegexParam from "regexparam"
2
2
  import type { Assets } from "../Assets"
3
3
  import type { RequestHandler, RouteTree } from "../router.types"
4
4
 
5
+ /** Serves index.html for paths matching a client-side RouteTree (SPA support). */
5
6
  export function app(routes: RouteTree): RequestHandler {
6
7
  const patterns: RegExp[] = getAppPatterns(routes)
7
8
  return async ({ assets, url }: { assets: Assets, url: URL }) => {
@@ -1,10 +1,13 @@
1
1
  import type { Assets } from "../Assets"
2
2
  import type { RequestHandler } from "../router.types"
3
3
 
4
+ /** Serves static files via the platform's Assets binding. */
4
5
  export function assets(): RequestHandler {
5
- return async ({ assets, request }: { assets: Assets, request: Request }) => {
6
+ return async ({ assets, request }: { assets: Assets; request: Request }) => {
6
7
  const response = await assets.file(request)
7
- if (!response || response.status === 404) return
8
+ if (!response || response.status === 404) {
9
+ return
10
+ }
8
11
  return response
9
12
  }
10
13
  }
@@ -1,3 +1,4 @@
1
1
  import type { RequestHandler } from "../router.types"
2
2
 
3
+ /** A handler that does nothing; used internally by middleware composition. */
3
4
  export const noop: RequestHandler = () => {}
@@ -1,7 +1,12 @@
1
1
  import * as RegexParam from "regexparam"
2
2
  import { MethodNotAllowed } from "../RouterError"
3
- import type { RequestContext, RequestHandler, RouteMiddleware } from "../router.types"
3
+ import type {
4
+ RequestContext,
5
+ RequestHandler,
6
+ RouteMiddleware,
7
+ } from "../router.types"
4
8
 
9
+ /** Matches a request by method and path pattern, binding extracted params. */
5
10
  export function pattern(
6
11
  method: string | null,
7
12
  path: string,
@@ -11,7 +16,9 @@ export function pattern(
11
16
  const { pattern, keys } = RegexParam.parse(path)
12
17
  return ({ url, request, bind, dispatch }: RequestContext) => {
13
18
  const match = pattern.exec(url.pathname)
14
- if (!match) return
19
+ if (!match) {
20
+ return
21
+ }
15
22
  if (method !== null && request.method !== method) {
16
23
  return new MethodNotAllowed(`Please use "${method}".`)
17
24
  }
@@ -30,6 +37,7 @@ export function pattern(
30
37
  }
31
38
  }
32
39
 
40
+ /** Matches GET requests against a path pattern. */
33
41
  export function get(
34
42
  path: string,
35
43
  handler: RequestHandler,
@@ -38,6 +46,7 @@ export function get(
38
46
  return pattern("GET", path, handler, ...middlewares)
39
47
  }
40
48
 
49
+ /** Matches POST requests against a path pattern. */
41
50
  export function post(
42
51
  path: string,
43
52
  handler: RequestHandler,
@@ -46,6 +55,7 @@ export function post(
46
55
  return pattern("POST", path, handler, ...middlewares)
47
56
  }
48
57
 
58
+ /** Matches PUT requests against a path pattern. */
49
59
  export function put(
50
60
  path: string,
51
61
  handler: RequestHandler,
@@ -54,6 +64,7 @@ export function put(
54
64
  return pattern("PUT", path, handler, ...middlewares)
55
65
  }
56
66
 
67
+ /** Matches DELETE requests against a path pattern. */
57
68
  export function del(
58
69
  path: string,
59
70
  handler: RequestHandler,
@@ -62,6 +73,7 @@ export function del(
62
73
  return pattern("DELETE", path, handler, ...middlewares)
63
74
  }
64
75
 
76
+ /** Matches PATCH requests against a path pattern. */
65
77
  export function patch(
66
78
  path: string,
67
79
  handler: RequestHandler,
@@ -1,6 +1,11 @@
1
- import type { RequestContext, RequestHandler, RouteMiddleware } from "../router.types"
1
+ import type {
2
+ RequestContext,
3
+ RequestHandler,
4
+ RouteMiddleware,
5
+ } from "../router.types"
2
6
  import { use } from "./use"
3
7
 
8
+ /** Groups handlers under a URL prefix, stripping it before dispatch. */
4
9
  export function prefix(
5
10
  prefix: string,
6
11
  ...handlers: RequestHandler[]
@@ -1,6 +1,11 @@
1
- import type { RequestContext, RequestHandler, RouteMiddleware } from "../router.types"
1
+ import type {
2
+ RequestContext,
3
+ RequestHandler,
4
+ RouteMiddleware,
5
+ } from "../router.types"
2
6
  import { noop } from "./noop"
3
7
 
8
+ /** Applies middlewares to a group of handlers without creating a prefix. */
4
9
  export function use(
5
10
  middlewares: RouteMiddleware[],
6
11
  ...handlers: RequestHandler[]
@@ -19,7 +24,9 @@ export function use(
19
24
  let response: Response | undefined
20
25
  for (const handler of handlers) {
21
26
  response = await dispatch(handler, [])
22
- if (response !== undefined) break
27
+ if (response !== undefined) {
28
+ break
29
+ }
23
30
  }
24
31
  if (response === undefined) {
25
32
  return
@@ -6,6 +6,7 @@ export type BodyLimitOptions = {
6
6
  readonly contentTypes?: string[]
7
7
  }
8
8
 
9
+ /** Rejects requests exceeding a body size or with disallowed content types. */
9
10
  export function bodyLimit(options?: BodyLimitOptions): RouteMiddleware {
10
11
  const maxSize = options?.maxSize ?? 1_048_576
11
12
  const contentTypes = options?.contentTypes
@@ -14,8 +15,8 @@ export function bodyLimit(options?: BodyLimitOptions): RouteMiddleware {
14
15
  before: ({ request }: RequestContext) => {
15
16
  const contentLength = request.headers.get(HTTP.header.ContentLength)
16
17
  if (contentLength && Number.parseInt(contentLength, 10) > maxSize) {
17
- return new Response(
18
- JSON.stringify({ error: HTTP.statusText.PayloadTooLarge }),
18
+ return Response.json(
19
+ { error: HTTP.statusText.PayloadTooLarge },
19
20
  {
20
21
  status: HTTP.status.PayloadTooLarge,
21
22
  statusText: HTTP.statusText.PayloadTooLarge,
@@ -29,8 +30,8 @@ export function bodyLimit(options?: BodyLimitOptions): RouteMiddleware {
29
30
  contentType &&
30
31
  !contentTypes.some((allowed) => contentType.startsWith(allowed))
31
32
  ) {
32
- return new Response(
33
- JSON.stringify({ error: HTTP.statusText.UnsupportedMediaType }),
33
+ return Response.json(
34
+ { error: HTTP.statusText.UnsupportedMediaType },
34
35
  {
35
36
  status: HTTP.status.UnsupportedMediaType,
36
37
  statusText: HTTP.statusText.UnsupportedMediaType,
@@ -18,6 +18,7 @@ export type CacheOptions = {
18
18
  readonly vary?: string | string[]
19
19
  }
20
20
 
21
+ /** Sets Cache-Control (and optionally Vary) headers on responses. */
21
22
  export function cache(options: string | CacheOptions): RouteMiddleware {
22
23
  const directive =
23
24
  typeof options === "string" ? options : buildDirective(options)
@@ -1,3 +1,4 @@
1
+ // oxlint-disable curly
1
2
  import type {
2
3
  RequestContext,
3
4
  ResponseContext,
@@ -14,11 +15,13 @@ export type CookieOptions = {
14
15
  secure?: boolean
15
16
  }
16
17
 
18
+ /** Cookie reader/writer bound to the request context. */
17
19
  export type Cookies = {
18
20
  get(name: string): string | undefined
19
21
  set(name: string, value: string, options?: CookieOptions): void
20
22
  }
21
23
 
24
+ /** Parses request cookies and collects Set-Cookie headers on the response. */
22
25
  export function cookies(): RouteMiddleware {
23
26
  const pending: { name: string; value: string; options?: CookieOptions }[] = []
24
27
  let parsed: Record<string, string> = {}
@@ -49,10 +52,14 @@ function parseCookies(header: string): Record<string, string> {
49
52
  const result: Record<string, string> = {}
50
53
  for (const pair of header.split(";")) {
51
54
  const index = pair.indexOf("=")
52
- if (index === -1) continue
55
+ if (index === -1) {
56
+ continue
57
+ }
53
58
  const key = pair.slice(0, index).trim()
54
59
  const value = pair.slice(index + 1).trim()
55
- if (key) result[key] = decodeURIComponent(value)
60
+ if (key) {
61
+ result[key] = decodeURIComponent(value)
62
+ }
56
63
  }
57
64
  return result
58
65
  }
@@ -14,6 +14,7 @@ export type CorsOptions = {
14
14
  readonly maxAge?: number
15
15
  }
16
16
 
17
+ /** Handles CORS preflight and response headers. */
17
18
  export function cors(options?: CorsOptions): RouteMiddleware {
18
19
  const originOption = options?.origin ?? "*"
19
20
  const methods = options?.methods ?? [
@@ -31,10 +32,14 @@ export function cors(options?: CorsOptions): RouteMiddleware {
31
32
 
32
33
  return {
33
34
  before: ({ request }: RequestContext) => {
34
- if (request.method !== HTTP.method.OPTIONS) return
35
+ if (request.method !== HTTP.method.OPTIONS) {
36
+ return
37
+ }
35
38
 
36
39
  const requestOrigin = request.headers.get(HTTP.header.Origin)
37
- if (!requestOrigin) return
40
+ if (!requestOrigin) {
41
+ return
42
+ }
38
43
 
39
44
  const response = new Response(null, { status: HTTP.status.NoContent })
40
45
  setOriginHeader(response, requestOrigin, originOption)
@@ -71,7 +76,9 @@ export function cors(options?: CorsOptions): RouteMiddleware {
71
76
  },
72
77
  after: ({ request, response }: ResponseContext) => {
73
78
  const requestOrigin = request.headers.get(HTTP.header.Origin)
74
- if (!requestOrigin) return
79
+ if (!requestOrigin) {
80
+ return
81
+ }
75
82
 
76
83
  setOriginHeader(response, requestOrigin, originOption)
77
84
 
@@ -45,6 +45,7 @@ const directiveMap: Record<string, string> = {
45
45
  manifestSrc: "manifest-src",
46
46
  }
47
47
 
48
+ /** Sets Content-Security-Policy headers with optional automatic nonce generation. */
48
49
  export function csp(options: CspOptions): RouteMiddleware {
49
50
  const usesNonce = detectsNonce(options)
50
51
  const reportOnly = options.reportOnly ?? false
@@ -87,10 +88,14 @@ function buildPolicy(options: CspOptions, nonce?: string): string {
87
88
 
88
89
  for (const [key, directive] of Object.entries(directiveMap)) {
89
90
  const sources = options[key as keyof CspOptions]
90
- if (!Array.isArray(sources) || sources.length === 0) continue
91
+ if (!Array.isArray(sources) || sources.length === 0) {
92
+ continue
93
+ }
91
94
 
92
95
  const resolved = sources.map((source) => {
93
- if (source === CSP.nonce) return `'nonce-${nonce}'`
96
+ if (source === CSP.nonce) {
97
+ return `'nonce-${nonce}'`
98
+ }
94
99
  return source as string
95
100
  })
96
101
 
@@ -8,6 +8,7 @@ export type CsrfOptions = {
8
8
  readonly methods?: string[]
9
9
  }
10
10
 
11
+ /** Double-submit cookie CSRF protection; requires cookies() middleware. */
11
12
  export function csrf(options?: CsrfOptions): RouteMiddleware {
12
13
  const cookieName = options?.cookie ?? "csrf-token"
13
14
  const headerName = options?.header ?? HTTP.header.XCsrfToken
@@ -26,13 +27,15 @@ export function csrf(options?: CsrfOptions): RouteMiddleware {
26
27
  )
27
28
  }
28
29
 
29
- if (!methods.includes(request.method)) return
30
+ if (!methods.includes(request.method)) {
31
+ return
32
+ }
30
33
 
31
34
  const origin = request.headers.get(HTTP.header.Origin)
32
35
  const url = new URL(request.url)
33
36
  if (origin && origin !== url.origin) {
34
- return new Response(
35
- JSON.stringify({ error: HTTP.statusText.Forbidden }),
37
+ return Response.json(
38
+ { error: HTTP.statusText.Forbidden },
36
39
  {
37
40
  status: HTTP.status.Forbidden,
38
41
  statusText: HTTP.statusText.Forbidden,
@@ -44,8 +47,8 @@ export function csrf(options?: CsrfOptions): RouteMiddleware {
44
47
  const headerToken = request.headers.get(headerName)
45
48
 
46
49
  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
47
- return new Response(
48
- JSON.stringify({ error: HTTP.statusText.Forbidden }),
50
+ return Response.json(
51
+ { error: HTTP.statusText.Forbidden },
49
52
  {
50
53
  status: HTTP.status.Forbidden,
51
54
  statusText: HTTP.statusText.Forbidden,
@@ -54,7 +57,9 @@ export function csrf(options?: CsrfOptions): RouteMiddleware {
54
57
  }
55
58
  },
56
59
  after: ({ cookies }: { cookies?: Cookies }) => {
57
- if (!cookies) return
60
+ if (!cookies) {
61
+ return
62
+ }
58
63
  const token = crypto.randomUUID()
59
64
  cookies.set(cookieName, token, {
60
65
  httpOnly: false,
@@ -1,10 +1,13 @@
1
1
  import type { RequestContext, RequestHandler } from "../router.types"
2
2
 
3
+ /** Higher-order function that conditionally runs a handler based on a predicate. */
3
4
  export function filter(
4
5
  predicate: (context: RequestContext) => boolean | Promise<boolean>,
5
6
  ): (handler: RequestHandler) => RequestHandler {
6
7
  return (handler) => async (context: RequestContext) => {
7
- if (!(await predicate(context))) return
8
+ if (!(await predicate(context))) {
9
+ return
10
+ }
8
11
  return context.dispatch(handler, [])
9
12
  }
10
13
  }
@@ -9,6 +9,7 @@ function make(value: string): RouteMiddleware {
9
9
  }
10
10
  }
11
11
 
12
+ /** Sets X-Frame-Options header to prevent clickjacking. */
12
13
  export namespace frameGuard {
13
14
  export const deny = make(HTTP.frameOption.deny)
14
15
  export const sameOrigin = make(HTTP.frameOption.sameOrigin)
@@ -11,6 +11,7 @@ export type HardenedOptions = {
11
11
  readonly hsts?: false | HstsOptions
12
12
  }
13
13
 
14
+ /** Convenience bundle applying noSniff, frameGuard, referrerPolicy, and hsts. */
14
15
  export function hardened(options?: HardenedOptions): RouteMiddleware {
15
16
  const parts: RouteMiddleware[] = []
16
17
 
@@ -36,7 +37,9 @@ export function hardened(options?: HardenedOptions): RouteMiddleware {
36
37
  return {
37
38
  after: (context: ResponseContext) => {
38
39
  for (const part of parts) {
39
- if (part.after) part.after(context)
40
+ if (part.after) {
41
+ part.after(context)
42
+ }
40
43
  }
41
44
  },
42
45
  }
@@ -7,14 +7,19 @@ export type HstsOptions = {
7
7
  readonly preload?: boolean
8
8
  }
9
9
 
10
+ /** Sets Strict-Transport-Security header. */
10
11
  export function hsts(options?: HstsOptions): RouteMiddleware {
11
12
  const maxAge = options?.maxAge ?? 31536000
12
13
  const includeSubDomains = options?.includeSubDomains ?? true
13
14
  const preload = options?.preload ?? false
14
15
 
15
16
  let value = `max-age=${maxAge}`
16
- if (includeSubDomains) value += "; includeSubDomains"
17
- if (preload) value += "; preload"
17
+ if (includeSubDomains) {
18
+ value += "; includeSubDomains"
19
+ }
20
+ if (preload) {
21
+ value += "; preload"
22
+ }
18
23
 
19
24
  return {
20
25
  after: ({ response }: ResponseContext) => {
@@ -1,8 +1,11 @@
1
1
  import type { RequestContext, RequestHandler } from "../router.types"
2
2
 
3
+ /** Redirects HTTP requests to HTTPS with a 301. */
3
4
  export function https(): RequestHandler {
4
5
  return ({ url }: RequestContext) => {
5
- if (url.protocol === "https:") return
6
+ if (url.protocol === "https:") {
7
+ return
8
+ }
6
9
  url.protocol = "https:"
7
10
  return Response.redirect(url, 301)
8
11
  }
@@ -1,6 +1,7 @@
1
1
  import { HTTP } from "../HTTP"
2
2
  import type { ResponseContext, RouteMiddleware } from "../router.types"
3
3
 
4
+ /** Sets X-Content-Type-Options: nosniff. */
4
5
  export const noSniff: RouteMiddleware = {
5
6
  after: ({ response }: ResponseContext) => {
6
7
  response.headers.set(
@@ -1,9 +1,9 @@
1
- import { HTTP } from "../HTTP";
1
+ import { HTTP } from "../HTTP"
2
2
  import type {
3
3
  RequestContext,
4
4
  ResponseContext,
5
5
  RouteMiddleware,
6
- } from "../router.types";
6
+ } from "../router.types"
7
7
 
8
8
  export type RateLimitStore = {
9
9
  increment(
@@ -20,6 +20,7 @@ export type RateLimitOptions = {
20
20
  readonly headers?: boolean
21
21
  }
22
22
 
23
+ /** IP-based rate limiting with pluggable storage. */
23
24
  export function rateLimit(options?: RateLimitOptions): RouteMiddleware {
24
25
  const window = options?.window ?? 60
25
26
  const max = options?.max ?? 100
@@ -36,8 +37,8 @@ export function rateLimit(options?: RateLimitOptions): RouteMiddleware {
36
37
  lastResult = result
37
38
 
38
39
  if (result.count > max) {
39
- const response = new Response(
40
- JSON.stringify({ error: HTTP.statusText.TooManyRequests }),
40
+ const response = Response.json(
41
+ { error: HTTP.statusText.TooManyRequests },
41
42
  {
42
43
  status: HTTP.status.TooManyRequests,
43
44
  statusText: HTTP.statusText.TooManyRequests,
@@ -97,7 +98,9 @@ export namespace rateLimit {
97
98
 
98
99
  // Clean up expired windows
99
100
  for (const [k, v] of windows) {
100
- if (v.reset < now) windows.delete(k)
101
+ if (v.reset < now) {
102
+ windows.delete(k)
103
+ }
101
104
  }
102
105
 
103
106
  return { count: 1, reset }
@@ -9,6 +9,7 @@ function make(value: string): RouteMiddleware {
9
9
  }
10
10
  }
11
11
 
12
+ /** Pre-built Referrer-Policy header middlewares. */
12
13
  export namespace referrerPolicy {
13
14
  export const noReferrer = make(HTTP.referrerPolicy.noReferrer)
14
15
  export const noReferrerWhenDowngrade = make(
@@ -10,6 +10,7 @@ export type RequestIdOptions = {
10
10
  readonly generate?: () => string
11
11
  }
12
12
 
13
+ /** Reads or generates a request ID, binds it to context, and echoes it on the response. */
13
14
  export function requestId(options?: RequestIdOptions): RouteMiddleware {
14
15
  const header = options?.header ?? HTTP.header.XRequestId
15
16
  const generate = options?.generate ?? (() => crypto.randomUUID())
@@ -1,5 +1,6 @@
1
1
  import type { ResponseContext, ResponseHandler } from "../router.types"
2
2
 
3
+ /** Sets a header on every response. */
3
4
  export function setHeader(header: string, value: string): ResponseHandler {
4
5
  return ({ response }: ResponseContext) => {
5
6
  response.headers.set(header, value)
@@ -1,12 +1,15 @@
1
1
  import type { RequestContext, RequestHandler } from "../router.types"
2
2
 
3
+ /** Redirects non-www requests to the www subdomain. */
3
4
  export function www({ secure }: { readonly secure?: boolean }): RequestHandler {
4
5
  return ({ url }: RequestContext) => {
5
6
  const isWww = url.hostname.startsWith("www.")
6
7
  const isSecure = url.protocol === "https:"
7
8
  if (isWww && (secure ? isSecure : true)) return
8
9
  url.hostname = `www.${url.hostname}`
9
- if (secure) url.protocol = "https:"
10
+ if (secure) {
11
+ url.protocol = "https:"
12
+ }
10
13
  return Response.redirect(url, 301)
11
14
  }
12
15
  }
package/src/route.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { Router } from "./Router"
2
2
  import type { RequestHandler, RouterFetch, RouterOptions } from "./router.types"
3
3
 
4
+ /** A handler value that may be falsy, allowing conditional handlers. */
4
5
  export type MaybeHandler = RequestHandler | null | undefined | false | 0
5
6
 
7
+ /** Creates a fetch function from a list of handlers, tried in order. */
6
8
  export function route(...handlers: MaybeHandler[]): RouterFetch
7
9
  export function route(
8
10
  options: RouterOptions,
@@ -1,15 +1,18 @@
1
1
  // oxlint-disable typescript/no-explicit-any
2
2
  import type { Container } from "@sigitex/bind"
3
3
 
4
+ /** Nested map of route names to path patterns for client-side routing. */
4
5
  export type RouteTree = {
5
6
  [key: string]: string | RouteTree
6
7
  }
7
8
 
9
+ /** The fetch function signature returned by `route()`. */
8
10
  export type RouterFetch = (
9
11
  request: Request,
10
12
  env: Env,
11
13
  ) => Promise<Response>
12
14
 
15
+ /** Context object passed to request handlers. */
13
16
  export type RequestContext = {
14
17
  readonly request: Request
15
18
  readonly env: Env
@@ -18,30 +21,37 @@ export type RequestContext = {
18
21
  readonly dispatch: RouterDispatch
19
22
  }
20
23
 
24
+ /** Adds values to the current request context. */
21
25
  export type RouterBind = (bindings: { [key: string]: any }) => void
22
26
 
27
+ /** Dispatches a handler through a middleware chain. */
23
28
  export type RouterDispatch = (
24
29
  handler: RequestHandler,
25
30
  middlewares: RouteMiddleware[],
26
31
  ) => Promise<Response | undefined>
27
32
 
33
+ /** Context available to after-middleware, includes the response. */
28
34
  export type ResponseContext = RequestContext & {
29
35
  readonly response: Response
30
36
  }
31
37
 
38
+ /** A function that handles a request and optionally returns a response. */
32
39
  export type RequestHandler = (
33
40
  context: any,
34
41
  ) => unknown
35
42
 
43
+ /** A function that runs after a response is produced. */
36
44
  export type ResponseHandler = (
37
45
  context: any,
38
46
  ) => unknown
39
47
 
48
+ /** Options for the `route()` entry point. */
40
49
  export type RouterOptions = {
41
50
  readonly container?: Container
42
51
  readonly middlewares?: RouteMiddleware[]
43
52
  }
44
53
 
54
+ /** A middleware with optional before/after hooks. */
45
55
  export type RouteMiddleware = {
46
56
  readonly before?: RequestHandler
47
57
  readonly after?: ResponseHandler
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "types": ["bun"]
5
- },
6
- "include": [".", "../env.d.ts"],
7
- "exclude": []
8
- }
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "types": ["@cloudflare/workers-types"]
5
- },
6
- "include": [".", "../env.d.ts"],
7
- "exclude": []
8
- }