@nifrajs/middleware 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/_utils.d.ts +27 -0
  4. package/dist/_utils.d.ts.map +1 -0
  5. package/dist/_utils.js +141 -0
  6. package/dist/_utils.js.map +1 -0
  7. package/dist/basic-auth.d.ts +27 -0
  8. package/dist/basic-auth.d.ts.map +1 -0
  9. package/dist/basic-auth.js +64 -0
  10. package/dist/basic-auth.js.map +1 -0
  11. package/dist/body-limit.d.ts +19 -0
  12. package/dist/body-limit.d.ts.map +1 -0
  13. package/dist/body-limit.js +42 -0
  14. package/dist/body-limit.js.map +1 -0
  15. package/dist/cache-control.d.ts +23 -0
  16. package/dist/cache-control.d.ts.map +1 -0
  17. package/dist/cache-control.js +35 -0
  18. package/dist/cache-control.js.map +1 -0
  19. package/dist/cache.d.ts +60 -0
  20. package/dist/cache.d.ts.map +1 -0
  21. package/dist/cache.js +224 -0
  22. package/dist/cache.js.map +1 -0
  23. package/dist/combine.d.ts +7 -0
  24. package/dist/combine.d.ts.map +1 -0
  25. package/dist/combine.js +21 -0
  26. package/dist/combine.js.map +1 -0
  27. package/dist/compression.d.ts +29 -0
  28. package/dist/compression.d.ts.map +1 -0
  29. package/dist/compression.js +105 -0
  30. package/dist/compression.js.map +1 -0
  31. package/dist/context-storage.d.ts +14 -0
  32. package/dist/context-storage.d.ts.map +1 -0
  33. package/dist/context-storage.js +30 -0
  34. package/dist/context-storage.js.map +1 -0
  35. package/dist/cors.d.ts +25 -0
  36. package/dist/cors.d.ts.map +1 -0
  37. package/dist/cors.js +75 -0
  38. package/dist/cors.js.map +1 -0
  39. package/dist/csrf.d.ts +23 -0
  40. package/dist/csrf.d.ts.map +1 -0
  41. package/dist/csrf.js +68 -0
  42. package/dist/csrf.js.map +1 -0
  43. package/dist/etag.d.ts +14 -0
  44. package/dist/etag.d.ts.map +1 -0
  45. package/dist/etag.js +95 -0
  46. package/dist/etag.js.map +1 -0
  47. package/dist/healthcheck.d.ts +25 -0
  48. package/dist/healthcheck.d.ts.map +1 -0
  49. package/dist/healthcheck.js +41 -0
  50. package/dist/healthcheck.js.map +1 -0
  51. package/dist/idempotency.d.ts +83 -0
  52. package/dist/idempotency.d.ts.map +1 -0
  53. package/dist/idempotency.js +217 -0
  54. package/dist/idempotency.js.map +1 -0
  55. package/dist/index.d.ts +33 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +33 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/ip-restriction.d.ts +21 -0
  60. package/dist/ip-restriction.d.ts.map +1 -0
  61. package/dist/ip-restriction.js +161 -0
  62. package/dist/ip-restriction.js.map +1 -0
  63. package/dist/jwt.d.ts +82 -0
  64. package/dist/jwt.d.ts.map +1 -0
  65. package/dist/jwt.js +327 -0
  66. package/dist/jwt.js.map +1 -0
  67. package/dist/language.d.ts +20 -0
  68. package/dist/language.d.ts.map +1 -0
  69. package/dist/language.js +94 -0
  70. package/dist/language.js.map +1 -0
  71. package/dist/logger.d.ts +19 -0
  72. package/dist/logger.d.ts.map +1 -0
  73. package/dist/logger.js +28 -0
  74. package/dist/logger.js.map +1 -0
  75. package/dist/method-override.d.ts +28 -0
  76. package/dist/method-override.d.ts.map +1 -0
  77. package/dist/method-override.js +67 -0
  78. package/dist/method-override.js.map +1 -0
  79. package/dist/openapi.d.ts +83 -0
  80. package/dist/openapi.d.ts.map +1 -0
  81. package/dist/openapi.js +100 -0
  82. package/dist/openapi.js.map +1 -0
  83. package/dist/powered-by.d.ts +15 -0
  84. package/dist/powered-by.d.ts.map +1 -0
  85. package/dist/powered-by.js +23 -0
  86. package/dist/powered-by.js.map +1 -0
  87. package/dist/pretty-json.d.ts +18 -0
  88. package/dist/pretty-json.d.ts.map +1 -0
  89. package/dist/pretty-json.js +88 -0
  90. package/dist/pretty-json.js.map +1 -0
  91. package/dist/rate-limit.d.ts +92 -0
  92. package/dist/rate-limit.d.ts.map +1 -0
  93. package/dist/rate-limit.js +172 -0
  94. package/dist/rate-limit.js.map +1 -0
  95. package/dist/request-id.d.ts +18 -0
  96. package/dist/request-id.d.ts.map +1 -0
  97. package/dist/request-id.js +21 -0
  98. package/dist/request-id.js.map +1 -0
  99. package/dist/security-headers.d.ts +23 -0
  100. package/dist/security-headers.d.ts.map +1 -0
  101. package/dist/security-headers.js +36 -0
  102. package/dist/security-headers.js.map +1 -0
  103. package/dist/timing.d.ts +25 -0
  104. package/dist/timing.d.ts.map +1 -0
  105. package/dist/timing.js +101 -0
  106. package/dist/timing.js.map +1 -0
  107. package/dist/token-auth.d.ts +65 -0
  108. package/dist/token-auth.d.ts.map +1 -0
  109. package/dist/token-auth.js +90 -0
  110. package/dist/token-auth.js.map +1 -0
  111. package/dist/trailing-slash.d.ts +23 -0
  112. package/dist/trailing-slash.d.ts.map +1 -0
  113. package/dist/trailing-slash.js +70 -0
  114. package/dist/trailing-slash.js.map +1 -0
  115. package/package.json +68 -0
  116. package/src/_utils.ts +163 -0
  117. package/src/basic-auth.ts +109 -0
  118. package/src/body-limit.ts +53 -0
  119. package/src/cache-control.ts +45 -0
  120. package/src/cache.ts +277 -0
  121. package/src/combine.ts +24 -0
  122. package/src/compression.ts +111 -0
  123. package/src/context-storage.ts +36 -0
  124. package/src/cors.ts +90 -0
  125. package/src/csrf.ts +102 -0
  126. package/src/etag.ts +99 -0
  127. package/src/healthcheck.ts +59 -0
  128. package/src/idempotency.ts +313 -0
  129. package/src/index.ts +110 -0
  130. package/src/ip-restriction.ts +187 -0
  131. package/src/jwt.ts +441 -0
  132. package/src/language.ts +109 -0
  133. package/src/logger.ts +44 -0
  134. package/src/method-override.ts +87 -0
  135. package/src/openapi.ts +174 -0
  136. package/src/powered-by.ts +31 -0
  137. package/src/pretty-json.ts +97 -0
  138. package/src/rate-limit.ts +248 -0
  139. package/src/request-id.ts +30 -0
  140. package/src/security-headers.ts +51 -0
  141. package/src/timing.ts +133 -0
  142. package/src/token-auth.ts +172 -0
  143. package/src/trailing-slash.ts +94 -0
@@ -0,0 +1,65 @@
1
+ import { type NifraPlugin } from "@nifrajs/core";
2
+ type MaybePromise<T> = T | Promise<T>;
3
+ /**
4
+ * A token-auth plugin (`bearer` / `apiKey`). Apply it with `app.use(auth)` — it rejects unauthorized
5
+ * requests to **routes defined after it** with `401` (unless `optional`). Read the verified principal
6
+ * inside a handler/loader via {@link AuthPlugin.principal} (nullable) or
7
+ * {@link AuthPlugin.requirePrincipal} (throws `401` when absent). The principal is verified once per
8
+ * request and cached, so reading it is free.
9
+ *
10
+ * `beforeHandle` is **order-scoped**: place `app.use(auth)` before the routes it should guard. Hold the
11
+ * returned instance to read the principal (mirrors `@nifrajs/auth`'s `sessions.get(c)` and
12
+ * `@nifrajs/better-auth`'s `getSession(auth, req)`):
13
+ *
14
+ * ```ts
15
+ * const auth = bearer({ verify: (t) => lookupUser(t) }) // P = User (inferred)
16
+ * const app = server().use(auth).get("/me", (c) => auth.requirePrincipal(c.req))
17
+ * ```
18
+ */
19
+ export type AuthPlugin<P> = NifraPlugin & {
20
+ /** The verified principal for this request, or `null` (no/invalid token in `optional` mode). */
21
+ principal(request: Request): P | null;
22
+ /** The verified principal, or **throws a `401` `Response`** when absent. */
23
+ requirePrincipal(request: Request): P;
24
+ };
25
+ export interface BearerOptions<P> {
26
+ /** Verify a bearer token → a principal (truthy) or `null`/`undefined` (rejected). May be async
27
+ * (DB/JWT lookup). For a constant-secret comparison, use a constant-time compare — never `===`. */
28
+ readonly verify: (token: string) => MaybePromise<P | null | undefined>;
29
+ /** When `true`, requests without a valid token pass through (`principal` is `null`) instead of `401`. */
30
+ readonly optional?: boolean;
31
+ /** `realm` for the `WWW-Authenticate: Bearer` header on `401`. Default `"api"`. */
32
+ readonly realm?: string;
33
+ }
34
+ /**
35
+ * `Authorization: Bearer <token>` authentication. Parses the header, runs `verify`, and rejects with
36
+ * `401` (+ `WWW-Authenticate: Bearer`) when the token is missing/invalid (unless `optional`). The
37
+ * verified principal is read via the returned instance — see {@link AuthPlugin}.
38
+ */
39
+ export declare function bearer<P>(options: BearerOptions<P>): AuthPlugin<P>;
40
+ export interface ApiKeyVerifyOptions<P> {
41
+ /** Verify an API key → a principal or `null`/`undefined` (rejected). May be async. */
42
+ readonly verify: (key: string) => MaybePromise<P | null | undefined>;
43
+ /** Header carrying the key. Default `"x-api-key"`. */
44
+ readonly header?: string;
45
+ readonly optional?: boolean;
46
+ }
47
+ export interface ApiKeyStaticOptions {
48
+ /** A fixed set of valid keys. Compared in **constant time** (see below); the matched key becomes the
49
+ * principal. Use `verify` instead for DB-backed / per-tenant keys. */
50
+ readonly keys: readonly string[];
51
+ readonly header?: string;
52
+ readonly optional?: boolean;
53
+ }
54
+ /**
55
+ * API-key authentication via a header (default `x-api-key`). Two forms:
56
+ * - `apiKey({ keys })` — a fixed key set, compared in **constant time**; the matched key is the principal.
57
+ * - `apiKey({ verify })` — custom (e.g. DB-backed) verification returning a typed principal.
58
+ *
59
+ * Rejects missing/invalid keys with `401` (unless `optional`). Read the principal via the returned
60
+ * instance — see {@link AuthPlugin}.
61
+ */
62
+ export declare function apiKey(options: ApiKeyStaticOptions): AuthPlugin<string>;
63
+ export declare function apiKey<P>(options: ApiKeyVerifyOptions<P>): AuthPlugin<P>;
64
+ export {};
65
+ //# sourceMappingURL=token-auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-auth.d.ts","sourceRoot":"","sources":["../src/token-auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,WAAW,EAAE,MAAM,eAAe,CAAA;AAE9D,KAAK,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;AAErC;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,WAAW,GAAG;IACxC,gGAAgG;IAChG,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,CAAC,GAAG,IAAI,CAAA;IACrC,4EAA4E;IAC5E,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,CAAC,CAAA;CACtC,CAAA;AA8CD,MAAM,WAAW,aAAa,CAAC,CAAC;IAC9B;uGACmG;IACnG,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,YAAY,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,CAAA;IACtE,yGAAyG;IACzG,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAC3B,mFAAmF;IACnF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB;AAED;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAYlE;AAED,MAAM,WAAW,mBAAmB,CAAC,CAAC;IACpC,sFAAsF;IACtF,QAAQ,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,YAAY,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,CAAA;IACpE,sDAAsD;IACtD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAC5B;AAED,MAAM,WAAW,mBAAmB;IAClC;0EACsE;IACtE,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IAChC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAC5B;AAoCD;;;;;;;GAOG;AACH,wBAAgB,MAAM,CAAC,OAAO,EAAE,mBAAmB,GAAG,UAAU,CAAC,MAAM,CAAC,CAAA;AACxE,wBAAgB,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,mBAAmB,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAAA"}
@@ -0,0 +1,90 @@
1
+ import { definePlugin } from "@nifrajs/core";
2
+ function createTokenAuth(config) {
3
+ // Per-request principal cache: verify runs once (in beforeHandle), reads are free + can't desync.
4
+ const store = new WeakMap();
5
+ const reject = () => Response.json({ ok: false, error: "unauthorized" }, {
6
+ status: 401,
7
+ headers: config.challenge !== undefined ? { "www-authenticate": config.challenge } : {},
8
+ });
9
+ const plugin = definePlugin(config.name, (app) => app.beforeHandle(async (c) => {
10
+ const token = config.extract(c.req);
11
+ // Empty string is treated as "no credential" — never passed to verify.
12
+ const principal = token !== undefined && token !== "" ? await config.verify(token) : null;
13
+ if (principal !== null && principal !== undefined) {
14
+ store.set(c.req, principal);
15
+ return undefined; // authorized — continue to the handler
16
+ }
17
+ return config.optional ? undefined : reject(); // 401 short-circuits (skips the handler)
18
+ }));
19
+ return Object.assign(plugin, {
20
+ principal: (request) => store.get(request) ?? null,
21
+ requirePrincipal: (request) => {
22
+ const principal = store.get(request);
23
+ if (principal === undefined)
24
+ throw reject();
25
+ return principal;
26
+ },
27
+ });
28
+ }
29
+ /**
30
+ * `Authorization: Bearer <token>` authentication. Parses the header, runs `verify`, and rejects with
31
+ * `401` (+ `WWW-Authenticate: Bearer`) when the token is missing/invalid (unless `optional`). The
32
+ * verified principal is read via the returned instance — see {@link AuthPlugin}.
33
+ */
34
+ export function bearer(options) {
35
+ const realm = options.realm ?? "api";
36
+ return createTokenAuth({
37
+ name: "bearer",
38
+ extract: (request) => {
39
+ const header = request.headers.get("authorization");
40
+ return header?.startsWith("Bearer ") === true ? header.slice(7).trim() : undefined;
41
+ },
42
+ verify: options.verify,
43
+ optional: options.optional === true,
44
+ challenge: `Bearer realm="${realm}"`,
45
+ });
46
+ }
47
+ const textEncoder = new TextEncoder();
48
+ async function sha256(input) {
49
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", textEncoder.encode(input)));
50
+ }
51
+ /** Constant-time byte compare. Inputs here are always 32-byte SHA-256 digests, so the early
52
+ * length check never leaks key length (digests are fixed-width). */
53
+ function timingSafeEqualBytes(a, b) {
54
+ if (a.length !== b.length)
55
+ return false;
56
+ let diff = 0;
57
+ for (let i = 0; i < a.length; i++)
58
+ diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
59
+ return diff === 0;
60
+ }
61
+ /**
62
+ * Build a constant-time verifier for a fixed key set. Each valid key is hashed once (SHA-256, fixed
63
+ * 32 bytes); per request the candidate is hashed and compared against every digest **without early
64
+ * exit**, so timing depends only on the (public) key count — never on the secret value or which key
65
+ * matched. Portable across all runtimes via WebCrypto.
66
+ */
67
+ function staticKeyVerify(keys) {
68
+ const validDigests = Promise.all(keys.map((key) => sha256(key)));
69
+ return async (candidate) => {
70
+ const candidateDigest = await sha256(candidate);
71
+ let matched = false;
72
+ for (const digest of await validDigests) {
73
+ // No early `break`: every comparison runs so total time is independent of which key (if any) hit.
74
+ if (timingSafeEqualBytes(candidateDigest, digest))
75
+ matched = true;
76
+ }
77
+ return matched ? candidate : null;
78
+ };
79
+ }
80
+ export function apiKey(options) {
81
+ const header = (options.header ?? "x-api-key").toLowerCase();
82
+ const verify = "keys" in options ? staticKeyVerify(options.keys) : options.verify;
83
+ return createTokenAuth({
84
+ name: "apiKey",
85
+ extract: (request) => request.headers.get(header) ?? undefined,
86
+ verify,
87
+ optional: options.optional === true,
88
+ });
89
+ }
90
+ //# sourceMappingURL=token-auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-auth.js","sourceRoot":"","sources":["../src/token-auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAoB,MAAM,eAAe,CAAA;AAsC9D,SAAS,eAAe,CAAI,MAA0B;IACpD,kGAAkG;IAClG,MAAM,KAAK,GAAG,IAAI,OAAO,EAAc,CAAA;IACvC,MAAM,MAAM,GAAG,GAAa,EAAE,CAC5B,QAAQ,CAAC,IAAI,CACX,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,cAAc,EAAE,EACpC;QACE,MAAM,EAAE,GAAG;QACX,OAAO,EAAE,MAAM,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,kBAAkB,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE;KACxF,CACF,CAAA;IACH,MAAM,MAAM,GAAG,YAAY,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,GAAG,EAAE,EAAE,CAC/C,GAAG,CAAC,YAAY,CAAC,KAAK,EAAE,CAA4B,EAAE,EAAE;QACtD,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA;QACnC,uEAAuE;QACvE,MAAM,SAAS,GAAG,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,MAAM,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;QACzF,IAAI,SAAS,KAAK,IAAI,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;YAClD,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,SAAS,CAAC,CAAA;YAC3B,OAAO,SAAS,CAAA,CAAC,uCAAuC;QAC1D,CAAC;QACD,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,EAAE,CAAA,CAAC,yCAAyC;IACzF,CAAC,CAAC,CACH,CAAA;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE;QAC3B,SAAS,EAAE,CAAC,OAAgB,EAAY,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI;QACrE,gBAAgB,EAAE,CAAC,OAAgB,EAAK,EAAE;YACxC,MAAM,SAAS,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;YACpC,IAAI,SAAS,KAAK,SAAS;gBAAE,MAAM,MAAM,EAAE,CAAA;YAC3C,OAAO,SAAS,CAAA;QAClB,CAAC;KACF,CAAkB,CAAA;AACrB,CAAC;AAYD;;;;GAIG;AACH,MAAM,UAAU,MAAM,CAAI,OAAyB;IACjD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,KAAK,CAAA;IACpC,OAAO,eAAe,CAAC;QACrB,IAAI,EAAE,QAAQ;QACd,OAAO,EAAE,CAAC,OAAO,EAAE,EAAE;YACnB,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,CAAA;YACnD,OAAO,MAAM,EAAE,UAAU,CAAC,SAAS,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAA;QACpF,CAAC;QACD,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,QAAQ,EAAE,OAAO,CAAC,QAAQ,KAAK,IAAI;QACnC,SAAS,EAAE,iBAAiB,KAAK,GAAG;KACrC,CAAC,CAAA;AACJ,CAAC;AAkBD,MAAM,WAAW,GAAG,IAAI,WAAW,EAAE,CAAA;AAErC,KAAK,UAAU,MAAM,CAAC,KAAa;IACjC,OAAO,IAAI,UAAU,CAAC,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;AACzF,CAAC;AAED;oEACoE;AACpE,SAAS,oBAAoB,CAAC,CAAa,EAAE,CAAa;IACxD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM;QAAE,OAAO,KAAK,CAAA;IACvC,IAAI,IAAI,GAAG,CAAC,CAAA;IACZ,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE;QAAE,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAA;IACpE,OAAO,IAAI,KAAK,CAAC,CAAA;AACnB,CAAC;AAED;;;;;GAKG;AACH,SAAS,eAAe,CAAC,IAAuB;IAC9C,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;IAChE,OAAO,KAAK,EAAE,SAAS,EAAE,EAAE;QACzB,MAAM,eAAe,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,CAAA;QAC/C,IAAI,OAAO,GAAG,KAAK,CAAA;QACnB,KAAK,MAAM,MAAM,IAAI,MAAM,YAAY,EAAE,CAAC;YACxC,kGAAkG;YAClG,IAAI,oBAAoB,CAAC,eAAe,EAAE,MAAM,CAAC;gBAAE,OAAO,GAAG,IAAI,CAAA;QACnE,CAAC;QACD,OAAO,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAA;IACnC,CAAC,CAAA;AACH,CAAC;AAYD,MAAM,UAAU,MAAM,CACpB,OAAqD;IAErD,MAAM,MAAM,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,WAAW,CAAC,CAAC,WAAW,EAAE,CAAA;IAC5D,MAAM,MAAM,GAAG,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,eAAe,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAA;IACjF,OAAO,eAAe,CAAa;QACjC,IAAI,EAAE,QAAQ;QACd,OAAO,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,SAAS;QAC9D,MAAM;QACN,QAAQ,EAAE,OAAO,CAAC,QAAQ,KAAK,IAAI;KACpC,CAAC,CAAA;AACJ,CAAC"}
@@ -0,0 +1,23 @@
1
+ export interface TrailingSlashOptions {
2
+ /** Redirect (default) or route internally with a rewritten URL. */
3
+ readonly mode?: "redirect" | "rewrite";
4
+ /** Redirect status. Default `308` (method-preserving permanent redirect). */
5
+ readonly status?: 301 | 302 | 307 | 308;
6
+ /** Methods affected. Default `["GET", "HEAD"]`; add write methods only when you want 308 redirects. */
7
+ readonly methods?: readonly string[];
8
+ /** Skip paths whose last segment looks like a file (`/app.css`). Default `true` for append mode. */
9
+ readonly ignoreFileExtensions?: boolean;
10
+ /** App-specific skip predicate. */
11
+ readonly ignore?: (path: string, request: Request) => boolean;
12
+ }
13
+ /**
14
+ * Remove trailing slashes from non-root paths. Redirect mode is the production default because it
15
+ * canonicalizes URLs for clients and caches; rewrite mode is available for compatibility migrations.
16
+ */
17
+ export declare function trimTrailingSlash(options?: TrailingSlashOptions): import("@nifrajs/core").NifraPlugin<import("@nifrajs/core").AnyServer, import("@nifrajs/core").AnyServer>;
18
+ /**
19
+ * Append a trailing slash to non-root paths. By default it skips file-looking paths such as
20
+ * `/app.css`, which keeps static assets and extensionful API routes stable.
21
+ */
22
+ export declare function appendTrailingSlash(options?: TrailingSlashOptions): import("@nifrajs/core").NifraPlugin<import("@nifrajs/core").AnyServer, import("@nifrajs/core").AnyServer>;
23
+ //# sourceMappingURL=trailing-slash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trailing-slash.d.ts","sourceRoot":"","sources":["../src/trailing-slash.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,oBAAoB;IACnC,mEAAmE;IACnE,QAAQ,CAAC,IAAI,CAAC,EAAE,UAAU,GAAG,SAAS,CAAA;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,CAAC,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;IACvC,uGAAuG;IACvG,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC,oGAAoG;IACpG,QAAQ,CAAC,oBAAoB,CAAC,EAAE,OAAO,CAAA;IACvC,mCAAmC;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,CAAA;CAC9D;AAsCD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,oBAAyB,6GAenE;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,GAAE,oBAAyB,6GAiBrE"}
@@ -0,0 +1,70 @@
1
+ import { definePlugin } from "@nifrajs/core";
2
+ const DEFAULT_METHODS = ["GET", "HEAD"];
3
+ const REDIRECT_STATUSES = new Set([301, 302, 307, 308]);
4
+ function validateStatus(status) {
5
+ if (!REDIRECT_STATUSES.has(status)) {
6
+ throw new Error("trailingSlash: status must be 301, 302, 307, or 308");
7
+ }
8
+ }
9
+ function hasFileExtension(path) {
10
+ const slash = path.lastIndexOf("/");
11
+ const segment = path.slice(slash + 1);
12
+ return segment.includes(".");
13
+ }
14
+ function withPath(req, path) {
15
+ const url = new URL(req.url);
16
+ url.pathname = path;
17
+ return url;
18
+ }
19
+ function route(req, url, mode, status) {
20
+ return mode === "rewrite"
21
+ ? new Request(url.toString(), req)
22
+ : new Response(null, { status, headers: { location: url.toString() } });
23
+ }
24
+ function methodsOf(methods) {
25
+ return new Set((methods ?? DEFAULT_METHODS).map((m) => m.toUpperCase()));
26
+ }
27
+ /**
28
+ * Remove trailing slashes from non-root paths. Redirect mode is the production default because it
29
+ * canonicalizes URLs for clients and caches; rewrite mode is available for compatibility migrations.
30
+ */
31
+ export function trimTrailingSlash(options = {}) {
32
+ const mode = options.mode ?? "redirect";
33
+ const status = options.status ?? 308;
34
+ validateStatus(status);
35
+ const methods = methodsOf(options.methods);
36
+ return definePlugin("trimTrailingSlash", (app) => app.onRequest((req) => {
37
+ if (!methods.has(req.method.toUpperCase()))
38
+ return undefined;
39
+ const path = new URL(req.url).pathname;
40
+ if (path === "/" || !path.endsWith("/"))
41
+ return undefined;
42
+ if (options.ignore?.(path, req) === true)
43
+ return undefined;
44
+ return route(req, withPath(req, path.replace(/\/+$/, "") || "/"), mode, status);
45
+ }));
46
+ }
47
+ /**
48
+ * Append a trailing slash to non-root paths. By default it skips file-looking paths such as
49
+ * `/app.css`, which keeps static assets and extensionful API routes stable.
50
+ */
51
+ export function appendTrailingSlash(options = {}) {
52
+ const mode = options.mode ?? "redirect";
53
+ const status = options.status ?? 308;
54
+ const ignoreFileExtensions = options.ignoreFileExtensions !== false;
55
+ validateStatus(status);
56
+ const methods = methodsOf(options.methods);
57
+ return definePlugin("appendTrailingSlash", (app) => app.onRequest((req) => {
58
+ if (!methods.has(req.method.toUpperCase()))
59
+ return undefined;
60
+ const path = new URL(req.url).pathname;
61
+ if (path === "/" || path.endsWith("/"))
62
+ return undefined;
63
+ if (ignoreFileExtensions && hasFileExtension(path))
64
+ return undefined;
65
+ if (options.ignore?.(path, req) === true)
66
+ return undefined;
67
+ return route(req, withPath(req, `${path}/`), mode, status);
68
+ }));
69
+ }
70
+ //# sourceMappingURL=trailing-slash.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trailing-slash.js","sourceRoot":"","sources":["../src/trailing-slash.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@nifrajs/middleware",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Composable middleware for nifra — auth, CSRF, JWT/JWKS, IP restriction, CORS, body limits, response cache, timing, and operational helpers.",
5
+ "keywords": [
6
+ "bun",
7
+ "typescript",
8
+ "nifra",
9
+ "web-framework",
10
+ "full-stack",
11
+ "edge",
12
+ "ssr",
13
+ "cors",
14
+ "rate-limit",
15
+ "jwt",
16
+ "csrf",
17
+ "openapi",
18
+ "security-headers"
19
+ ],
20
+ "type": "module",
21
+ "main": "./dist/index.js",
22
+ "module": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "bun": "./src/index.ts",
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./context-storage": {
31
+ "bun": "./src/context-storage.ts",
32
+ "types": "./dist/context-storage.d.ts",
33
+ "default": "./dist/context-storage.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src"
39
+ ],
40
+ "sideEffects": false,
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
43
+ },
44
+ "license": "MIT",
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dependencies": {
49
+ "@nifrajs/schema": "workspace:*"
50
+ },
51
+ "peerDependencies": {
52
+ "@nifrajs/core": "workspace:*"
53
+ },
54
+ "devDependencies": {
55
+ "@nifrajs/core": "workspace:*",
56
+ "@nifrajs/schema": "workspace:*",
57
+ "@nifrajs/test-utils": "workspace:*"
58
+ },
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "git+https://github.com/nifrajs/nifra.git",
62
+ "directory": "packages/middleware"
63
+ },
64
+ "bugs": {
65
+ "url": "https://github.com/nifrajs/nifra/issues"
66
+ },
67
+ "homepage": "https://github.com/nifrajs/nifra#readme"
68
+ }
package/src/_utils.ts ADDED
@@ -0,0 +1,163 @@
1
+ export type MaybePromise<T> = T | Promise<T>
2
+
3
+ export const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"])
4
+
5
+ const TEXT = new TextEncoder()
6
+
7
+ export function jsonError(
8
+ status: number,
9
+ error: string,
10
+ headers?: Record<string, string>,
11
+ ): Response {
12
+ return Response.json(
13
+ { ok: false, error },
14
+ headers === undefined ? { status } : { status, headers },
15
+ )
16
+ }
17
+
18
+ export function parseCookies(header: string | null | undefined): Record<string, string> {
19
+ // Null-prototype: an untrusted cookie name (`constructor`/`__proto__`/…) is an inert own key,
20
+ // never a prototype-member shadow. Mirrors core's parseCookies + the query/form parsers.
21
+ const out: Record<string, string> = Object.create(null)
22
+ if (!header) return out
23
+ for (const part of header.split(";")) {
24
+ const eq = part.indexOf("=")
25
+ if (eq < 1) continue
26
+ const name = part.slice(0, eq).trim()
27
+ if (name === "") continue
28
+ let value = part.slice(eq + 1).trim()
29
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
30
+ value = value.slice(1, -1)
31
+ }
32
+ try {
33
+ out[name] = decodeURIComponent(value)
34
+ } catch {
35
+ out[name] = value
36
+ }
37
+ }
38
+ return out
39
+ }
40
+
41
+ export function quotedHeaderValue(value: string): string {
42
+ let out = ""
43
+ for (let i = 0; i < value.length; i++) {
44
+ const c = value.charCodeAt(i)
45
+ if (c < 0x20 || c === 0x7f) continue
46
+ const ch = value[i] ?? ""
47
+ out += ch === "\\" || ch === '"' ? `\\${ch}` : ch
48
+ }
49
+ return out
50
+ }
51
+
52
+ export function utf8Bytes(value: string): Uint8Array<ArrayBuffer> {
53
+ return TEXT.encode(value)
54
+ }
55
+
56
+ export function secretBytes(secret: string | Uint8Array, label: string): Uint8Array<ArrayBuffer> {
57
+ const bytes = typeof secret === "string" ? utf8Bytes(secret) : new Uint8Array(secret)
58
+ if (bytes.byteLength < 32) {
59
+ throw new Error(`${label}: secret must be at least 32 bytes`)
60
+ }
61
+ return bytes as Uint8Array<ArrayBuffer>
62
+ }
63
+
64
+ export async function sha256(input: string | Uint8Array): Promise<Uint8Array<ArrayBuffer>> {
65
+ const bytes = typeof input === "string" ? utf8Bytes(input) : new Uint8Array(input)
66
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)) as Uint8Array<ArrayBuffer>
67
+ }
68
+
69
+ export function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
70
+ if (a.byteLength !== b.byteLength) return false
71
+ let diff = 0
72
+ for (let i = 0; i < a.byteLength; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0)
73
+ return diff === 0
74
+ }
75
+
76
+ export async function timingSafeEqualString(a: string, b: string): Promise<boolean> {
77
+ const [left, right] = await Promise.all([sha256(a), sha256(b)])
78
+ return timingSafeEqualBytes(left, right)
79
+ }
80
+
81
+ export function base64UrlEncode(bytes: ArrayBuffer | Uint8Array): string {
82
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
83
+ let bin = ""
84
+ for (const b of view) bin += String.fromCharCode(b)
85
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
86
+ }
87
+
88
+ export function base64UrlDecode(input: string): Uint8Array<ArrayBuffer> | null {
89
+ if (!/^[A-Za-z0-9_-]*$/.test(input) || input.length % 4 === 1) return null
90
+ const padded = input
91
+ .replace(/-/g, "+")
92
+ .replace(/_/g, "/")
93
+ .padEnd(Math.ceil(input.length / 4) * 4, "=")
94
+ try {
95
+ const bin = atob(padded)
96
+ const bytes = new Uint8Array(bin.length)
97
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
98
+ return bytes as Uint8Array<ArrayBuffer>
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
104
+ export function decodeBase64(input: string): Uint8Array<ArrayBuffer> | null {
105
+ try {
106
+ const bin = atob(input)
107
+ const bytes = new Uint8Array(bin.length)
108
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
109
+ return bytes as Uint8Array<ArrayBuffer>
110
+ } catch {
111
+ return null
112
+ }
113
+ }
114
+
115
+ export async function importHmacKey(
116
+ secret: string | Uint8Array,
117
+ hash = "SHA-256",
118
+ ): Promise<CryptoKey> {
119
+ return crypto.subtle.importKey(
120
+ "raw",
121
+ secretBytes(secret, "hmac"),
122
+ { name: "HMAC", hash },
123
+ false,
124
+ ["sign", "verify"],
125
+ )
126
+ }
127
+
128
+ export async function hmacSha256(input: string, secret: string | Uint8Array): Promise<string> {
129
+ const key = await importHmacKey(secret, "SHA-256")
130
+ const sig = await crypto.subtle.sign("HMAC", key, utf8Bytes(input))
131
+ return base64UrlEncode(sig)
132
+ }
133
+
134
+ export async function verifyHmacSha256(
135
+ input: string,
136
+ signature: string,
137
+ secret: string | Uint8Array,
138
+ ): Promise<boolean> {
139
+ const sig = base64UrlDecode(signature)
140
+ if (sig === null) return false
141
+ const key = await importHmacKey(secret, "SHA-256")
142
+ return crypto.subtle.verify("HMAC", key, sig, utf8Bytes(input))
143
+ }
144
+
145
+ /**
146
+ * Apply header mutations to a response, in place when possible. Framework-built responses
147
+ * (`new Response`, `Response.json`) have mutable headers across every runtime — so the common path
148
+ * mutates `res` directly and returns it, allocating nothing. Only an *immutable*-headers response
149
+ * (`Response.redirect()`/`Response.error()`, or a proxied `fetch()` response on Node/Deno/workerd —
150
+ * never on Bun) makes `.set`/`.append` throw; that path clones into a fresh `Headers` + `Response`,
151
+ * exactly the old always-clone behavior. `apply` runs once either way (immutability is all-or-nothing
152
+ * — the first mutation throws before any partial change), so a mutation chain is safe to pass.
153
+ */
154
+ export function withHeaders(res: Response, apply: (headers: Headers) => void): Response {
155
+ try {
156
+ apply(res.headers)
157
+ return res
158
+ } catch {
159
+ const headers = new Headers(res.headers)
160
+ apply(headers)
161
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
162
+ }
163
+ }
@@ -0,0 +1,109 @@
1
+ import { definePlugin, type NifraPlugin } from "@nifrajs/core"
2
+ import {
3
+ decodeBase64,
4
+ jsonError,
5
+ type MaybePromise,
6
+ quotedHeaderValue,
7
+ sha256,
8
+ timingSafeEqualBytes,
9
+ } from "./_utils.ts"
10
+
11
+ export type BasicAuthPlugin<P> = NifraPlugin & {
12
+ principal(request: Request): P | null
13
+ requirePrincipal(request: Request): P
14
+ }
15
+
16
+ export interface BasicAuthStaticOptions<P = string> {
17
+ readonly username: string
18
+ readonly password: string
19
+ readonly principal?: P
20
+ readonly realm?: string
21
+ readonly optional?: boolean
22
+ }
23
+
24
+ export interface BasicAuthVerifyOptions<P> {
25
+ readonly verify: (username: string, password: string) => MaybePromise<P | null | undefined>
26
+ readonly realm?: string
27
+ readonly optional?: boolean
28
+ }
29
+
30
+ const UTF8 = new TextDecoder("utf-8", { fatal: true })
31
+
32
+ function challenge(realm: string): string {
33
+ return `Basic realm="${quotedHeaderValue(realm)}", charset="UTF-8"`
34
+ }
35
+
36
+ function reject(realm: string): Response {
37
+ return jsonError(401, "unauthorized", { "www-authenticate": challenge(realm) })
38
+ }
39
+
40
+ function credentials(request: Request): { username: string; password: string } | null {
41
+ const header = request.headers.get("authorization")
42
+ if (header?.startsWith("Basic ") !== true) return null
43
+ const raw = decodeBase64(header.slice(6).trim())
44
+ if (raw === null) return null
45
+ let decoded: string
46
+ try {
47
+ decoded = UTF8.decode(raw)
48
+ } catch {
49
+ return null
50
+ }
51
+ const colon = decoded.indexOf(":")
52
+ if (colon < 0) return null
53
+ return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) }
54
+ }
55
+
56
+ async function staticVerifier<P>(
57
+ username: string,
58
+ password: string,
59
+ principal: P,
60
+ ): Promise<(u: string, p: string) => Promise<P | null>> {
61
+ const [expectedUser, expectedPass] = await Promise.all([sha256(username), sha256(password)])
62
+ return async (u, p) => {
63
+ const [gotUser, gotPass] = await Promise.all([sha256(u), sha256(p)])
64
+ const userOk = timingSafeEqualBytes(gotUser, expectedUser)
65
+ const passOk = timingSafeEqualBytes(gotPass, expectedPass)
66
+ return userOk && passOk ? principal : null
67
+ }
68
+ }
69
+
70
+ /**
71
+ * HTTP Basic authentication. Prefer short-lived Basic Auth for internal tools and staging gates, not
72
+ * public user login. Static credentials are compared in constant time after SHA-256 hashing; the
73
+ * callback form is available for external stores.
74
+ */
75
+ export function basicAuth(options: BasicAuthStaticOptions): BasicAuthPlugin<string>
76
+ export function basicAuth<P>(options: BasicAuthStaticOptions<P>): BasicAuthPlugin<P>
77
+ export function basicAuth<P>(options: BasicAuthVerifyOptions<P>): BasicAuthPlugin<P>
78
+ export function basicAuth<P>(
79
+ options: BasicAuthStaticOptions<P> | BasicAuthVerifyOptions<P>,
80
+ ): BasicAuthPlugin<P | string> {
81
+ const realm = options.realm ?? "api"
82
+ const optional = options.optional === true
83
+ const store = new WeakMap<Request, P | string>()
84
+ const verifyPromise =
85
+ "verify" in options
86
+ ? Promise.resolve(options.verify)
87
+ : staticVerifier(options.username, options.password, options.principal ?? options.username)
88
+
89
+ const plugin = definePlugin("basicAuth", (app) =>
90
+ app.beforeHandle(async (c: { readonly req: Request }) => {
91
+ const parsed = credentials(c.req)
92
+ const principal =
93
+ parsed === null ? null : await (await verifyPromise)(parsed.username, parsed.password)
94
+ if (principal !== null && principal !== undefined) {
95
+ store.set(c.req, principal)
96
+ return undefined
97
+ }
98
+ return optional ? undefined : reject(realm)
99
+ }),
100
+ )
101
+ return Object.assign(plugin, {
102
+ principal: (request: Request): P | string | null => store.get(request) ?? null,
103
+ requirePrincipal: (request: Request): P | string => {
104
+ const principal = store.get(request)
105
+ if (principal === undefined) throw reject(realm)
106
+ return principal
107
+ },
108
+ }) as BasicAuthPlugin<P | string>
109
+ }
@@ -0,0 +1,53 @@
1
+ import type { Middleware } from "@nifrajs/core"
2
+ import { jsonError, SAFE_METHODS } from "./_utils.ts"
3
+
4
+ export interface BodyLimitOptions {
5
+ /** Maximum raw request-body bytes accepted. */
6
+ readonly maxBytes: number
7
+ /** Methods to inspect. Default: every method except GET/HEAD/OPTIONS/TRACE. */
8
+ readonly methods?: readonly string[]
9
+ /** Response error name for over-limit bodies. Default `"payload_too_large"`. */
10
+ readonly error?: string
11
+ /** Allow bodies without Content-Length. Default false (fail closed with 411). */
12
+ readonly allowLengthless?: boolean
13
+ }
14
+
15
+ function parseContentLength(value: string | null): number | undefined | null {
16
+ if (value === null) return undefined
17
+ if (!/^\d+$/.test(value)) return null
18
+ const n = Number(value)
19
+ return Number.isSafeInteger(n) ? n : Infinity
20
+ }
21
+
22
+ /**
23
+ * Enforce a raw byte cap for request bodies before routing. This middleware is intentionally
24
+ * Content-Length based: reading a cloned Web body is not transparent on every runtime. Lengthless
25
+ * bodies fail closed by default; use route-level `c.boundedBody()` / schema validation for endpoints
26
+ * that intentionally accept streamed bodies.
27
+ */
28
+ export function bodyLimit(options: BodyLimitOptions): Middleware {
29
+ const { maxBytes } = options
30
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
31
+ throw new Error("bodyLimit: maxBytes must be a non-negative safe integer")
32
+ }
33
+ const methods =
34
+ options.methods !== undefined ? new Set(options.methods.map((m) => m.toUpperCase())) : undefined
35
+ const error = options.error ?? "payload_too_large"
36
+ const allowLengthless = options.allowLengthless === true
37
+
38
+ return {
39
+ name: "body-limit",
40
+ onRequest(req) {
41
+ if (methods !== undefined ? !methods.has(req.method) : SAFE_METHODS.has(req.method)) {
42
+ return undefined
43
+ }
44
+ const contentLength = parseContentLength(req.headers.get("content-length"))
45
+ if (contentLength === null) return jsonError(400, "invalid_content_length")
46
+ if (contentLength === undefined && req.body !== null && !allowLengthless) {
47
+ return jsonError(411, "length_required")
48
+ }
49
+ if (contentLength !== undefined && contentLength > maxBytes) return jsonError(413, error)
50
+ return undefined
51
+ },
52
+ }
53
+ }