@zerotal/core 1.8.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/api-surface.md +192 -424
  3. package/package.json +3 -1
  4. package/src/application/Application.ts +10 -4
  5. package/src/application/currentApp.ts +7 -1
  6. package/src/build/codemod.ts +1 -1
  7. package/src/command/CommandRunner.ts +2 -0
  8. package/src/command/OutputWriter.ts +5 -1
  9. package/src/command/builtin/AssetsPruneCommand.ts +138 -0
  10. package/src/command/builtin/DeployCommand.ts +91 -1
  11. package/src/command/builtin/TestCommand.ts +9 -2
  12. package/src/command/builtin/index.ts +1 -0
  13. package/src/command/startZerotal.ts +62 -1
  14. package/src/config/AppConfig.ts +10 -10
  15. package/src/config/ConfigLoader.ts +5 -1
  16. package/src/config/DeployConfig.ts +33 -0
  17. package/src/config/index.ts +6 -1
  18. package/src/config/registry.ts +5 -1
  19. package/src/config/validation.ts +10 -2
  20. package/src/conventions/ConventionLoader.ts +44 -3
  21. package/src/dev/BuildOutput.ts +58 -6
  22. package/src/dev/CssPlugins.ts +13 -3
  23. package/src/dev/DevBuildHook.ts +10 -2
  24. package/src/dev/DevDeck.ts +9 -3
  25. package/src/dev/DevOrchestrator.ts +2 -0
  26. package/src/dev/DevProcess.ts +19 -5
  27. package/src/dev/DevReloadMiddleware.ts +7 -1
  28. package/src/dev/DevSupervisor.ts +27 -9
  29. package/src/dev/bootBuild.ts +9 -1
  30. package/src/dev/index.ts +6 -1
  31. package/src/dev/reloadClient.ts +2 -0
  32. package/src/dev/startDevMode.ts +8 -2
  33. package/src/doctor/AppDoctor.ts +43 -1
  34. package/src/doctor/throttleIdentity.ts +118 -0
  35. package/src/errors/RuntimeMismatchError.ts +21 -0
  36. package/src/errors/index.ts +1 -0
  37. package/src/events/Emitter.ts +5 -1
  38. package/src/health/Health.ts +5 -5
  39. package/src/helpers/html.ts +2 -0
  40. package/src/helpers/index.ts +2 -1
  41. package/src/helpers/markdown.ts +14 -1
  42. package/src/helpers/pageElements.ts +2 -0
  43. package/src/http/HttpClient.ts +5 -1
  44. package/src/http/UploadedFile.ts +2 -2
  45. package/src/http/Uri.ts +5 -1
  46. package/src/http/negotiate.ts +5 -1
  47. package/src/http/originGuard.ts +2 -0
  48. package/src/http/sniffContentType.ts +7 -1
  49. package/src/index.ts +2 -1
  50. package/src/lock/LockManager.ts +4 -4
  51. package/src/logger/LoggerMiddleware.ts +1 -1
  52. package/src/logger/types.ts +4 -4
  53. package/src/macros/config.macro.ts +2 -0
  54. package/src/metrics/HttpMetrics.ts +10 -2
  55. package/src/middleware/BaseMiddleware.ts +20 -0
  56. package/src/middleware/CorsMiddleware.ts +6 -6
  57. package/src/middleware/RateLimiter.ts +105 -22
  58. package/src/middleware/SecureHeadersMiddleware.ts +8 -8
  59. package/src/middleware/ThrottleMiddleware.ts +57 -4
  60. package/src/middleware/WebhookMiddleware.ts +7 -7
  61. package/src/pipeline/ContextRegistry.ts +12 -2
  62. package/src/pipeline/HttpContext.ts +4 -0
  63. package/src/pipeline/currentPage.ts +4 -0
  64. package/src/pipeline/types.ts +2 -0
  65. package/src/router/FileRouter.ts +20 -2
  66. package/src/router/Route.ts +3 -0
  67. package/src/router/RouteHandler.ts +2 -2
  68. package/src/router/Router.ts +7 -5
  69. package/src/router/domain.ts +1 -0
  70. package/src/router/registry.ts +12 -2
  71. package/src/security/redactGraph.ts +1 -1
  72. package/src/shared/format.ts +135 -0
  73. package/src/shared/index.ts +34 -0
  74. package/src/storage/StorageFilesMiddleware.ts +4 -2
  75. package/src/storage/types.ts +10 -10
  76. package/src/support/cookie.ts +13 -7
  77. package/src/support/deepMerge.ts +49 -0
  78. package/src/support/runtime.ts +226 -0
  79. package/src/view/FileRouteResolver.ts +2 -0
  80. package/src/view/jsx-runtime.ts +4 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Can the rate limiter tell two people apart?
3
+ *
4
+ * `ThrottleMiddleware` keys its buckets on the client IP, and resolves that IP
5
+ * from the socket address unless `trustedProxies` says how many proxies sit in
6
+ * front of the app. That default is right: `X-Forwarded-For` is written by the
7
+ * client, and trusting it without being told how deep the real value sits is how a
8
+ * limiter is bypassed with one header.
9
+ *
10
+ * But behind a reverse proxy the socket address is `127.0.0.1` — for every request
11
+ * ever made. Every visitor shares one bucket per form, and the middleware inverts
12
+ * into the thing it was installed to prevent: one attacker making twenty bad
13
+ * sign-ins a minute locks the entire staff out of the console, and five
14
+ * registrations in five minutes stops anybody in the world from creating an
15
+ * account. A limiter that cannot tell two people apart is a denial-of-service tool
16
+ * aimed at its own users.
17
+ *
18
+ * Nothing observable says this is happening. The proxy goes in, everything works,
19
+ * and the limiter quietly stops distinguishing people. The docblock on the option
20
+ * explains it, but the option is read while writing middleware and the mistake is
21
+ * made while writing a Caddyfile — so the two never meet. That gap is what this
22
+ * check closes.
23
+ *
24
+ * @module
25
+ */
26
+ import type { Application } from "../application/Application.ts";
27
+ import { ThrottleMiddleware, _isIpDerived } from "../middleware/ThrottleMiddleware.ts";
28
+ import { Router } from "../router/Router.ts";
29
+
30
+ /** A registered throttle and how it decides who is who. */
31
+ export interface ThrottleIdentity {
32
+ /** The middleware class name, as it appears in a pipeline listing. */
33
+ name: string;
34
+ /** Where it is registered — `global`, or the route it guards. */
35
+ where: string;
36
+ /** Whether it resolves identity from a client-supplied header chain. */
37
+ trustsProxies: boolean;
38
+ /** Whether the app replaced IP-keying with its own resolver. */
39
+ customKey: boolean;
40
+ }
41
+
42
+ /** Whether a middleware class is `ThrottleMiddleware` or one of its `.with()` subclasses. */
43
+ function isThrottle(cls: unknown): boolean {
44
+ if (typeof cls !== "function") return false;
45
+ if (cls === ThrottleMiddleware) return true;
46
+ return Object.prototype.isPrototypeOf.call(ThrottleMiddleware, cls);
47
+ }
48
+
49
+ /**
50
+ * Read a throttle's configured identity strategy.
51
+ *
52
+ * `.with()` applies its options in the constructor, so the only way to see them is
53
+ * to build one. That is safe here — the constructor merges `app.throttle` config
54
+ * over the defaults and does nothing else — and it is also the only way to observe
55
+ * the config layer, which can set `trustedProxies` for every throttle at once.
56
+ */
57
+ function readIdentity(cls: unknown, where: string): ThrottleIdentity | null {
58
+ try {
59
+ const instance = new (cls as new () => unknown)() as {
60
+ options?: { trustedProxies?: number; keyResolver?: unknown };
61
+ };
62
+ const options = instance.options ?? {};
63
+ return {
64
+ name: (cls as { name?: string }).name || "ThrottleMiddleware",
65
+ where,
66
+ trustsProxies: typeof options.trustedProxies === "number" && options.trustedProxies > 0,
67
+ // A resolver that keys on the client address is not an exemption — it is
68
+ // exactly the case this check exists for. `RateLimiter`'s `.byIp()`,
69
+ // `.byUser()` and `.byApiKey()` all mark themselves, so the named-limiter API
70
+ // stops slipping past the audit that covers the middleware it builds on.
71
+ customKey: typeof options.keyResolver === "function" && !_isIpDerived(options.keyResolver),
72
+ };
73
+ } catch {
74
+ // A middleware that will not construct is a different problem, and not one
75
+ // the doctor should report as a rate-limiting finding.
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Every throttle registered in this app, global and per-route.
82
+ *
83
+ * @param app - The booted application.
84
+ */
85
+ export function registeredThrottles(app: Application): ThrottleIdentity[] {
86
+ const found: ThrottleIdentity[] = [];
87
+
88
+ for (const cls of app.globalMiddleware ?? []) {
89
+ if (!isThrottle(cls)) continue;
90
+ const identity = readIdentity(cls, "global");
91
+ if (identity) found.push(identity);
92
+ }
93
+
94
+ try {
95
+ for (const route of Router.routes.values()) {
96
+ for (const cls of route.middleware ?? []) {
97
+ if (!isThrottle(cls)) continue;
98
+ const identity = readIdentity(cls, `${route.method} ${route.path}`);
99
+ if (identity) found.push(identity);
100
+ }
101
+ }
102
+ } catch {
103
+ // No compiled router — a console-only app, or a test harness. Global
104
+ // middleware alone is still worth reporting on.
105
+ }
106
+
107
+ return found;
108
+ }
109
+
110
+ /**
111
+ * The throttles that will key every visitor to the same bucket behind a proxy.
112
+ *
113
+ * A custom `keyResolver` is excluded: an app that keys on a user id or an API key
114
+ * has already decided identity for itself, and this check has nothing to add.
115
+ */
116
+ export function throttlesKeyedOnSocket(app: Application): ThrottleIdentity[] {
117
+ return registeredThrottles(app).filter((t) => !t.trustsProxies && !t.customKey);
118
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The error raised when a project has two Bun runtimes in it — the one this
3
+ * process is executing under, and a different one installed in `node_modules`.
4
+ */
5
+ import { ZerotalError } from "./ZerotalError.ts";
6
+ import type { RuntimeMismatch } from "../support/runtime.ts";
7
+
8
+ /**
9
+ * Raised by `startZerotal()` when the running Bun and the installed Bun disagree.
10
+ *
11
+ * Carries both versions in `context` so a harness can report the disagreement
12
+ * rather than re-deriving it from the message.
13
+ */
14
+ export class RuntimeMismatchError extends ZerotalError {
15
+ constructor(
16
+ message: string,
17
+ public readonly mismatch: RuntimeMismatch,
18
+ ) {
19
+ super(message, "E_RUNTIME_MISMATCH", 500, { ...mismatch });
20
+ }
21
+ }
@@ -31,6 +31,7 @@ export {
31
31
  } from "./HttpError.ts";
32
32
  export { ValidationError } from "./ValidationError.ts";
33
33
  export { ConfigError } from "./ConfigError.ts";
34
+ export { RuntimeMismatchError } from "./RuntimeMismatchError.ts";
34
35
  export { BootCheckError } from "../application/BootDoctor.ts";
35
36
  export type { BootCheckFailure } from "../application/BootDoctor.ts";
36
37
  export { ConfigValidationError } from "../config/validation.ts";
@@ -14,7 +14,11 @@ type ListenerClass<T extends object> = new (...args: unknown[]) => {
14
14
  retryDelay?: number;
15
15
  };
16
16
 
17
- /** A listener that opts into deferred execution by declaring a `queue` target. */
17
+ /**
18
+ * A listener that opts into deferred execution by declaring a `queue` target.
19
+ *
20
+ * @internal
21
+ */
18
22
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the event payload type is listener-specific and not known at this boundary.
19
23
  export interface QueuedListener<T = any> {
20
24
  queue: boolean | string;
@@ -149,20 +149,20 @@ export const Health = new HealthRegistry();
149
149
  /** The health config, authored under the `health` key of `config/app.ts`. */
150
150
  export interface HealthConfigShape {
151
151
  /** Serve the endpoint. Default: on outside production, off in production. */
152
- enabled?: boolean;
152
+ enabled?: boolean | undefined;
153
153
  /** Route path. Default: `/health`. */
154
- path?: string;
154
+ path?: string | undefined;
155
155
  /** Shared secret. Required in production; supplied via `?key=` or `X-Health-Key`. */
156
- secret?: string;
156
+ secret?: string | undefined;
157
157
  /** Include per-check details. Set false for a bare `{ status }` body. Default: true. */
158
- showDetails?: boolean;
158
+ showDetails?: boolean | undefined;
159
159
  }
160
160
 
161
161
  /** The health config after defaults and back-compat have been applied. */
162
162
  export interface ResolvedHealthConfig {
163
163
  enabled: boolean;
164
164
  path: string;
165
- secret?: string;
165
+ secret?: string | undefined;
166
166
  showDetails: boolean;
167
167
  }
168
168
 
@@ -5,6 +5,8 @@
5
5
  * set (`&` `<` `>` `"` `'`, emitting `&#x27;` for the apostrophe). Safe for
6
6
  * both text content and double-quoted attribute values; both JSX runtimes and
7
7
  * the docs app render through it, so it runs on every SSR text child.
8
+ *
9
+ * @internal
8
10
  */
9
11
  export function escapeHtml(value: string): string {
10
12
  return Bun.escapeHTML(value);
@@ -298,5 +298,6 @@ export { markdownPage, type BunMarkdownOptions } from "./markdown.ts";
298
298
  // must not carry private copies.
299
299
  export { escapeHtml } from "./html.ts";
300
300
  export { pluralize, singularize, tableNameFor } from "../support/str.ts";
301
- export { deepMerge } from "../support/deepMerge.ts";
301
+ export { deepMerge, definedOnly } from "../support/deepMerge.ts";
302
+ export type { Resolved } from "../support/deepMerge.ts";
302
303
  export { parseCookieHeader } from "../support/cookie.ts";
@@ -8,7 +8,11 @@ export function markdownExtractTitle(content: string): string | undefined {
8
8
  return content.match(/^#{1,2}\s+(.+)$/m)?.[1]?.trim();
9
9
  }
10
10
 
11
- /** Minimal HTML shell for rendered markdown pages. */
11
+ /**
12
+ * Minimal HTML shell for rendered markdown pages.
13
+ *
14
+ * @internal
15
+ */
12
16
  export function markdownPage(title: string, body: string): string {
13
17
  return `<html lang="en">
14
18
  <head>
@@ -44,6 +48,15 @@ ${body}
44
48
  /**
45
49
  * Options for `Bun.markdown.html()`. bun-types exposes the options type only inside
46
50
  * its `markdown` namespace; this is the named, exported equivalent the framework uses.
51
+ *
52
+ * Its optional properties stay `?: T` rather than `?: T | undefined`, unlike the
53
+ * framework's own option shapes. This one is not an option shape, it is a *mirror* of
54
+ * somebody else's signature, and a mirror that is wider than what it reflects stops
55
+ * being assignable to it — the value built here is handed straight to
56
+ * `Bun.markdown.html()`.
57
+ *
58
+ * @exact-optional-ignore
59
+ * @internal
47
60
  */
48
61
  export interface BunMarkdownOptions {
49
62
  tables?: boolean;
@@ -8,6 +8,8 @@
8
8
  * @param last - The last page number.
9
9
  * @param each - How many page links to show on each side of the current page (default `1`).
10
10
  * @returns Page numbers interleaved with `'...'` for elided ranges.
11
+ *
12
+ * @internal
11
13
  */
12
14
  export function pageElements(current: number, last: number, each = 1): (number | "...")[] {
13
15
  if (last <= 1) return [1];
@@ -9,7 +9,11 @@ import { FrameworkEvents, OutgoingRequestCompleted } from "../events/FrameworkEv
9
9
  /** An HTTP request method. */
10
10
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
11
11
 
12
- /** A fake response definition matched by URL while `Http.fake()` is active. */
12
+ /**
13
+ * A fake response definition matched by URL while `Http.fake()` is active.
14
+ *
15
+ * @internal
16
+ */
13
17
  export interface FakeStub {
14
18
  /** URL to match — exact string or glob-style `*` wildcard. */
15
19
  url: string;
@@ -19,9 +19,9 @@ export type StorageDisk = Pick<StorageDriver, "url"> & {
19
19
  */
20
20
  export interface FileValidationOptions {
21
21
  /** Maximum allowed size in bytes. */
22
- maxSize?: number;
22
+ maxSize?: number | undefined;
23
23
  /** Allowed MIME types — e.g. `['image/jpeg', 'image/png', 'application/pdf']`. */
24
- mimes?: string[];
24
+ mimes?: string[] | undefined;
25
25
  }
26
26
 
27
27
  /**
package/src/http/Uri.ts CHANGED
@@ -40,7 +40,11 @@ interface UriParts {
40
40
  fragment?: string;
41
41
  }
42
42
 
43
- /** Read-only view over a URI's query string, returned by `Uri.query()`. */
43
+ /**
44
+ * Read-only view over a URI's query string, returned by `Uri.query()`.
45
+ *
46
+ * @internal
47
+ */
44
48
  export interface UriQueryString {
45
49
  /** All query parameters as a plain object. */
46
50
  all(): Record<string, QueryValue>;
@@ -17,7 +17,11 @@ const ANSI: Record<string, string> = {
17
17
  dim: "\x1b[2m",
18
18
  };
19
19
 
20
- /** A supported ANSI text color for CLI output. */
20
+ /**
21
+ * A supported ANSI text color for CLI output.
22
+ *
23
+ * @internal
24
+ */
21
25
  export type AnsiColor = "red" | "green" | "yellow" | "blue" | "cyan" | "dim";
22
26
 
23
27
  // ── Channel detection ──────────────────────────────────────────────────────────
@@ -36,6 +36,8 @@
36
36
  * return new Response('Forbidden origin', { status: 403 });
37
37
  * }
38
38
  * ```
39
+ *
40
+ * @internal
39
41
  */
40
42
  export function isAllowedOrigin(request: Request, allowedOrigins: string[] = []): boolean {
41
43
  const origin = request.headers.get("origin");
@@ -65,7 +65,11 @@ export const FALLBACK_CONTENT_TYPE = "application/octet-stream";
65
65
  /** Extension stored when the bytes match nothing known. */
66
66
  export const FALLBACK_EXTENSION = "bin";
67
67
 
68
- /** What {@link sniffContentType} determined about a file. */
68
+ /**
69
+ * What {@link sniffContentType} determined about a file.
70
+ *
71
+ * @internal
72
+ */
69
73
  export interface SniffedType {
70
74
  /** The detected media type, or {@link FALLBACK_CONTENT_TYPE} when unrecognised. */
71
75
  contentType: string;
@@ -88,6 +92,8 @@ export interface SniffedType {
88
92
  *
89
93
  * @example
90
94
  * const { contentType, extension } = sniffContentType(await file.bytes());
95
+ *
96
+ * @internal
91
97
  */
92
98
  export function sniffContentType(bytes: Uint8Array): SniffedType {
93
99
  for (const sig of SIGNATURES) {
package/src/index.ts CHANGED
@@ -162,7 +162,8 @@ export {
162
162
  export { config } from "./helpers/config.ts";
163
163
  export { pluralize, singularize, snakeCase, camelCase, tableNameFor } from "./support/str.ts";
164
164
  export { deepMerge } from "./support/deepMerge.ts";
165
- export type { DeepPartial } from "./support/deepMerge.ts";
165
+ // `definedOnly` is a helper, not kernel surface — it lives on `@zerotal/core/helpers`.
166
+ export type { DeepPartial, Resolved } from "./support/deepMerge.ts";
166
167
  // The type every class-keyed registry uses — a class rather than an instance.
167
168
  export type { ClassRef } from "./support/classRef.ts";
168
169
  export { safeEqual, sha256Hex, hmacHex } from "./support/crypto.ts";
@@ -17,13 +17,13 @@ export interface RefreshOptions {
17
17
  * question nobody can answer — and becomes "how long after a crash before
18
18
  * someone else may take over", which is a decision rather than a guess.
19
19
  */
20
- refresh?: boolean;
20
+ refresh?: boolean | undefined;
21
21
 
22
22
  /**
23
23
  * Seconds between refreshes. Defaults to a third of the TTL, so two
24
24
  * consecutive failures still leave a full attempt before the lock lapses.
25
25
  */
26
- refreshEvery?: number;
26
+ refreshEvery?: number | undefined;
27
27
  }
28
28
 
29
29
  /**
@@ -44,13 +44,13 @@ export interface BlockOptions extends RefreshOptions {
44
44
  * Maximum seconds to wait for the lock before throwing.
45
45
  * Defaults to the lock TTL.
46
46
  */
47
- timeout?: number;
47
+ timeout?: number | undefined;
48
48
 
49
49
  /**
50
50
  * Milliseconds between polling attempts while waiting.
51
51
  * Default: 100 ms.
52
52
  */
53
- retryDelay?: number;
53
+ retryDelay?: number | undefined;
54
54
  }
55
55
 
56
56
  /**
@@ -42,7 +42,7 @@ const LINE_WIDTH = 60;
42
42
 
43
43
  export interface LoggerOptions {
44
44
  /** Output format. Defaults to LOG_FORMAT env var, then 'text'. */
45
- format?: "text" | "json";
45
+ format?: "text" | "json" | undefined;
46
46
  }
47
47
 
48
48
  /**
@@ -186,11 +186,11 @@ export interface LoggingConfigShape {
186
186
  /**
187
187
  * The terminal sink, on unless `false`. See {@link ConsoleSinkConfig}.
188
188
  */
189
- console?: ConsoleSinkConfig;
189
+ console?: ConsoleSinkConfig | undefined;
190
190
  /**
191
191
  * The durable file trail, on unless `false`. See {@link FileSinkConfig}.
192
192
  */
193
- file?: FileSinkConfig;
193
+ file?: FileSinkConfig | undefined;
194
194
  /** Name of the channel used by unqualified `Log.*` calls. */
195
195
  default: string;
196
196
  /**
@@ -203,10 +203,10 @@ export interface LoggingConfigShape {
203
203
  */
204
204
  channels: Record<string, ChannelConfig>;
205
205
  /** Threshold, in ms, above which ORM queries are logged as slow (default 1000). */
206
- slowQueryMs?: number;
206
+ slowQueryMs?: number | undefined;
207
207
  /**
208
208
  * Log every HTTP request (method, path, status, duration) via `LoggerMiddleware`.
209
209
  * On by default; set `false` to silence the per-request access log.
210
210
  */
211
- requests?: boolean;
211
+ requests?: boolean | undefined;
212
212
  }
@@ -34,6 +34,8 @@ import { join } from "node:path";
34
34
  * Note: config files that read `Bun.env` capture their values from the
35
35
  * environment at macro-evaluation time. For bun build --compile this is the
36
36
  * CI/CD environment — the same intended behaviour as any immutable artifact.
37
+ *
38
+ * @internal
37
39
  */
38
40
  export function loadConfigsSync(configDir: string): Record<string, Record<string, unknown>> {
39
41
  const glob = new Bun.Glob("*.ts");
@@ -25,12 +25,20 @@ let _2xx = 0,
25
25
  let _sumMs = 0,
26
26
  _maxMs = 0;
27
27
 
28
- /** Mark a request as started — increments the in-flight (currently-processing) gauge. */
28
+ /**
29
+ * Mark a request as started — increments the in-flight (currently-processing) gauge.
30
+ *
31
+ * @internal
32
+ */
29
33
  export function beginHttp(): void {
30
34
  _inFlight++;
31
35
  }
32
36
 
33
- /** Mark a request as finished — decrements the in-flight gauge (floored at 0). */
37
+ /**
38
+ * Mark a request as finished — decrements the in-flight gauge (floored at 0).
39
+ *
40
+ * @internal
41
+ */
34
42
  export function endHttp(): void {
35
43
  if (_inFlight > 0) _inFlight--;
36
44
  }
@@ -46,6 +46,26 @@ export abstract class BaseMiddleware<O extends object = object> implements Pipe<
46
46
  * Returns a zero-arg subclass with the given options deep-merged on top of
47
47
  * the subclass defaults, usable directly in app.use([...]).
48
48
  *
49
+ * **Each call creates a distinct class, and any per-class state goes with it —
50
+ * so sharing one `.with()` export across two routes shares that state.** For
51
+ * `ThrottleMiddleware` that state is the hit counter, which means two routes on
52
+ * one exported instance share a budget: a handful of fumbled sign-ins can spend
53
+ * the allowance a legitimate person needs to answer their second factor. That is
54
+ * defensible behaviour and it is not what a reader expects from a factory, so it
55
+ * is worth saying where the factory is. Call `.with()` once per thing that
56
+ * deserves its own budget.
57
+ *
58
+ * ```ts
59
+ * // One bucket, shared by both forms — 5 attempts across the pair.
60
+ * const AuthThrottle = ThrottleMiddleware.with({ maxAttempts: 5 });
61
+ * Router.post("/login", ...[AuthThrottle]);
62
+ * Router.post("/two-factor", ...[AuthThrottle]);
63
+ *
64
+ * // A bucket each, which is almost always what was meant.
65
+ * Router.post("/login", ...[ThrottleMiddleware.with({ maxAttempts: 5 })]);
66
+ * Router.post("/two-factor", ...[ThrottleMiddleware.with({ maxAttempts: 5 })]);
67
+ * ```
68
+ *
49
69
  * `NoInfer` on the parameter is what makes this type-check at all. `Opts` has
50
70
  * a default computed from the middleware class, but a type parameter that
51
71
  * appears in an argument position is inferred from the *argument* first and
@@ -26,17 +26,17 @@ export interface CorsOptions {
26
26
  * `new URL(o).hostname.endsWith('.example.com') && o.startsWith('https://')`, or just
27
27
  * list the origins.
28
28
  */
29
- origin?: string | string[] | ((origin: string) => boolean);
29
+ origin?: string | string[] | ((origin: string) => boolean) | undefined;
30
30
  /** Allowed HTTP methods. Default: `['GET','POST','PUT','PATCH','DELETE','OPTIONS']`. */
31
- methods?: string[];
31
+ methods?: string[] | undefined;
32
32
  /** Allowed request headers. Default: `['Content-Type','Authorization','X-Requested-With']`. */
33
- allowedHeaders?: string[];
33
+ allowedHeaders?: string[] | undefined;
34
34
  /** Headers the browser may expose to JS. Default: `[]`. */
35
- exposedHeaders?: string[];
35
+ exposedHeaders?: string[] | undefined;
36
36
  /** Allow cookies / auth headers in cross-origin requests. Default: `false`. */
37
- credentials?: boolean;
37
+ credentials?: boolean | undefined;
38
38
  /** Preflight cache duration in seconds. Default: `600`. */
39
- maxAge?: number;
39
+ maxAge?: number | undefined;
40
40
  }
41
41
 
42
42
  /**