@sigitex/route 1.0.1 → 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 +0 -8
- package/package.json +2 -2
- package/src/Assets.ts +1 -0
- package/src/CSP.ts +2 -0
- package/src/HTTP.ts +1 -0
- package/src/Router.ts +20 -12
- package/src/RouterError.ts +11 -0
- package/src/bun/bun.ts +7 -2
- package/src/cloudflare/cloudflare.ts +1 -0
- package/src/handler/app.ts +1 -0
- package/src/handler/assets.ts +5 -2
- package/src/handler/noop.ts +1 -0
- package/src/handler/pattern.ts +14 -2
- package/src/handler/prefix.ts +6 -1
- package/src/handler/use.ts +9 -2
- package/src/middleware/bodyLimit.ts +5 -4
- package/src/middleware/cache.ts +1 -0
- package/src/middleware/cookies.ts +9 -2
- package/src/middleware/cors.ts +10 -3
- package/src/middleware/csp.ts +7 -2
- package/src/middleware/csrf.ts +11 -6
- package/src/middleware/filter.ts +4 -1
- package/src/middleware/frameGuard.ts +1 -0
- package/src/middleware/hardened.ts +4 -1
- package/src/middleware/hsts.ts +7 -2
- package/src/middleware/https.ts +4 -1
- package/src/middleware/noSniff.ts +1 -0
- package/src/middleware/rateLimit.ts +8 -5
- package/src/middleware/referrerPolicy.ts +1 -0
- package/src/middleware/requestId.ts +1 -0
- package/src/middleware/setHeader.ts +1 -0
- package/src/middleware/www.ts +4 -1
- package/src/route.ts +2 -0
- package/src/router.types.ts +10 -0
package/README.md
CHANGED
|
@@ -46,14 +46,6 @@ export default {
|
|
|
46
46
|
}
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
## Entry Points
|
|
50
|
-
|
|
51
|
-
| Import | Description |
|
|
52
|
-
| --------------------------- | ----------------------------------- |
|
|
53
|
-
| `@sigitex/route` | Core router, handlers, middleware |
|
|
54
|
-
| `@sigitex/route/bun` | Bun runtime adapter |
|
|
55
|
-
| `@sigitex/route/cloudflare` | Cloudflare Workers runtime adapter |
|
|
56
|
-
|
|
57
49
|
## `route(...handlers)`
|
|
58
50
|
|
|
59
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.
|
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",
|
|
@@ -43,5 +43,5 @@
|
|
|
43
43
|
"files": [
|
|
44
44
|
"src"
|
|
45
45
|
],
|
|
46
|
-
"version": "1.0.
|
|
46
|
+
"version": "1.0.2"
|
|
47
47
|
}
|
package/src/Assets.ts
CHANGED
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
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)
|
|
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)
|
|
62
|
+
if (!before) {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
63
65
|
const interrupt = await Promise.resolve(invoke(before))
|
|
64
|
-
if (interrupt !== undefined)
|
|
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)
|
|
77
|
+
if (!after) {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
74
80
|
const interrupt = await Promise.resolve(invoke(after))
|
|
75
|
-
if (interrupt !== undefined)
|
|
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
|
|
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
|
-
:
|
|
107
|
+
: Response.json(result)
|
|
100
108
|
}
|
package/src/RouterError.ts
CHANGED
|
@@ -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)
|
|
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())
|
|
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 ({
|
package/src/handler/app.ts
CHANGED
|
@@ -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 }) => {
|
package/src/handler/assets.ts
CHANGED
|
@@ -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
|
|
6
|
+
return async ({ assets, request }: { assets: Assets; request: Request }) => {
|
|
6
7
|
const response = await assets.file(request)
|
|
7
|
-
if (!response || response.status === 404)
|
|
8
|
+
if (!response || response.status === 404) {
|
|
9
|
+
return
|
|
10
|
+
}
|
|
8
11
|
return response
|
|
9
12
|
}
|
|
10
13
|
}
|
package/src/handler/noop.ts
CHANGED
package/src/handler/pattern.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import * as RegexParam from "regexparam"
|
|
2
2
|
import { MethodNotAllowed } from "../RouterError"
|
|
3
|
-
import type {
|
|
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)
|
|
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,
|
package/src/handler/prefix.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import type {
|
|
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[]
|
package/src/handler/use.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import type {
|
|
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)
|
|
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
|
|
18
|
-
|
|
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
|
|
33
|
-
|
|
33
|
+
return Response.json(
|
|
34
|
+
{ error: HTTP.statusText.UnsupportedMediaType },
|
|
34
35
|
{
|
|
35
36
|
status: HTTP.status.UnsupportedMediaType,
|
|
36
37
|
statusText: HTTP.statusText.UnsupportedMediaType,
|
package/src/middleware/cache.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
60
|
+
if (key) {
|
|
61
|
+
result[key] = decodeURIComponent(value)
|
|
62
|
+
}
|
|
56
63
|
}
|
|
57
64
|
return result
|
|
58
65
|
}
|
package/src/middleware/cors.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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)
|
|
79
|
+
if (!requestOrigin) {
|
|
80
|
+
return
|
|
81
|
+
}
|
|
75
82
|
|
|
76
83
|
setOriginHeader(response, requestOrigin, originOption)
|
|
77
84
|
|
package/src/middleware/csp.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
96
|
+
if (source === CSP.nonce) {
|
|
97
|
+
return `'nonce-${nonce}'`
|
|
98
|
+
}
|
|
94
99
|
return source as string
|
|
95
100
|
})
|
|
96
101
|
|
package/src/middleware/csrf.ts
CHANGED
|
@@ -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))
|
|
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
|
|
35
|
-
|
|
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
|
|
48
|
-
|
|
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)
|
|
60
|
+
if (!cookies) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
58
63
|
const token = crypto.randomUUID()
|
|
59
64
|
cookies.set(cookieName, token, {
|
|
60
65
|
httpOnly: false,
|
package/src/middleware/filter.ts
CHANGED
|
@@ -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)))
|
|
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)
|
|
40
|
+
if (part.after) {
|
|
41
|
+
part.after(context)
|
|
42
|
+
}
|
|
40
43
|
}
|
|
41
44
|
},
|
|
42
45
|
}
|
package/src/middleware/hsts.ts
CHANGED
|
@@ -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)
|
|
17
|
-
|
|
17
|
+
if (includeSubDomains) {
|
|
18
|
+
value += "; includeSubDomains"
|
|
19
|
+
}
|
|
20
|
+
if (preload) {
|
|
21
|
+
value += "; preload"
|
|
22
|
+
}
|
|
18
23
|
|
|
19
24
|
return {
|
|
20
25
|
after: ({ response }: ResponseContext) => {
|
package/src/middleware/https.ts
CHANGED
|
@@ -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:")
|
|
6
|
+
if (url.protocol === "https:") {
|
|
7
|
+
return
|
|
8
|
+
}
|
|
6
9
|
url.protocol = "https:"
|
|
7
10
|
return Response.redirect(url, 301)
|
|
8
11
|
}
|
|
@@ -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 =
|
|
40
|
-
|
|
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)
|
|
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)
|
package/src/middleware/www.ts
CHANGED
|
@@ -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)
|
|
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,
|
package/src/router.types.ts
CHANGED
|
@@ -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
|