@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.
- package/CHANGELOG.md +204 -0
- package/api-surface.md +192 -424
- package/package.json +3 -1
- package/src/application/Application.ts +10 -4
- package/src/application/currentApp.ts +7 -1
- package/src/build/codemod.ts +1 -1
- package/src/command/CommandRunner.ts +2 -0
- package/src/command/OutputWriter.ts +5 -1
- package/src/command/builtin/AssetsPruneCommand.ts +138 -0
- package/src/command/builtin/DeployCommand.ts +91 -1
- package/src/command/builtin/TestCommand.ts +9 -2
- package/src/command/builtin/index.ts +1 -0
- package/src/command/startZerotal.ts +62 -1
- package/src/config/AppConfig.ts +10 -10
- package/src/config/ConfigLoader.ts +5 -1
- package/src/config/DeployConfig.ts +33 -0
- package/src/config/index.ts +6 -1
- package/src/config/registry.ts +5 -1
- package/src/config/validation.ts +10 -2
- package/src/conventions/ConventionLoader.ts +44 -3
- package/src/dev/BuildOutput.ts +58 -6
- package/src/dev/CssPlugins.ts +13 -3
- package/src/dev/DevBuildHook.ts +10 -2
- package/src/dev/DevDeck.ts +9 -3
- package/src/dev/DevOrchestrator.ts +2 -0
- package/src/dev/DevProcess.ts +19 -5
- package/src/dev/DevReloadMiddleware.ts +7 -1
- package/src/dev/DevSupervisor.ts +27 -9
- package/src/dev/bootBuild.ts +9 -1
- package/src/dev/index.ts +6 -1
- package/src/dev/reloadClient.ts +2 -0
- package/src/dev/startDevMode.ts +8 -2
- package/src/doctor/AppDoctor.ts +43 -1
- package/src/doctor/throttleIdentity.ts +118 -0
- package/src/errors/RuntimeMismatchError.ts +21 -0
- package/src/errors/index.ts +1 -0
- package/src/events/Emitter.ts +5 -1
- package/src/health/Health.ts +5 -5
- package/src/helpers/html.ts +2 -0
- package/src/helpers/index.ts +2 -1
- package/src/helpers/markdown.ts +14 -1
- package/src/helpers/pageElements.ts +2 -0
- package/src/http/HttpClient.ts +5 -1
- package/src/http/UploadedFile.ts +2 -2
- package/src/http/Uri.ts +5 -1
- package/src/http/negotiate.ts +5 -1
- package/src/http/originGuard.ts +2 -0
- package/src/http/sniffContentType.ts +7 -1
- package/src/index.ts +2 -1
- package/src/lock/LockManager.ts +4 -4
- package/src/logger/LoggerMiddleware.ts +1 -1
- package/src/logger/types.ts +4 -4
- package/src/macros/config.macro.ts +2 -0
- package/src/metrics/HttpMetrics.ts +10 -2
- package/src/middleware/BaseMiddleware.ts +20 -0
- package/src/middleware/CorsMiddleware.ts +6 -6
- package/src/middleware/RateLimiter.ts +105 -22
- package/src/middleware/SecureHeadersMiddleware.ts +8 -8
- package/src/middleware/ThrottleMiddleware.ts +57 -4
- package/src/middleware/WebhookMiddleware.ts +7 -7
- package/src/pipeline/ContextRegistry.ts +12 -2
- package/src/pipeline/HttpContext.ts +4 -0
- package/src/pipeline/currentPage.ts +4 -0
- package/src/pipeline/types.ts +2 -0
- package/src/router/FileRouter.ts +20 -2
- package/src/router/Route.ts +3 -0
- package/src/router/RouteHandler.ts +2 -2
- package/src/router/Router.ts +7 -5
- package/src/router/domain.ts +1 -0
- package/src/router/registry.ts +12 -2
- package/src/security/redactGraph.ts +1 -1
- package/src/shared/format.ts +135 -0
- package/src/shared/index.ts +34 -0
- package/src/storage/StorageFilesMiddleware.ts +4 -2
- package/src/storage/types.ts +10 -10
- package/src/support/cookie.ts +13 -7
- package/src/support/deepMerge.ts +49 -0
- package/src/support/runtime.ts +226 -0
- package/src/view/FileRouteResolver.ts +2 -0
- 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
|
+
}
|
package/src/errors/index.ts
CHANGED
|
@@ -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";
|
package/src/events/Emitter.ts
CHANGED
|
@@ -14,7 +14,11 @@ type ListenerClass<T extends object> = new (...args: unknown[]) => {
|
|
|
14
14
|
retryDelay?: number;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
/**
|
|
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;
|
package/src/health/Health.ts
CHANGED
|
@@ -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
|
|
package/src/helpers/html.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* set (`&` `<` `>` `"` `'`, emitting `'` 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);
|
package/src/helpers/index.ts
CHANGED
|
@@ -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";
|
package/src/helpers/markdown.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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];
|
package/src/http/HttpClient.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/src/http/UploadedFile.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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>;
|
package/src/http/negotiate.ts
CHANGED
|
@@ -17,7 +17,11 @@ const ANSI: Record<string, string> = {
|
|
|
17
17
|
dim: "\x1b[2m",
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
/**
|
|
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 ──────────────────────────────────────────────────────────
|
package/src/http/originGuard.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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";
|
package/src/lock/LockManager.ts
CHANGED
|
@@ -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
|
/**
|
package/src/logger/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
/**
|