@zerotal/core 1.8.1 → 1.9.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 +87 -0
- package/api-surface.md +56 -355
- package/package.json +2 -1
- package/src/application/Application.ts +6 -0
- package/src/application/currentApp.ts +7 -1
- package/src/command/OutputWriter.ts +5 -1
- package/src/command/builtin/DeployCommand.ts +72 -1
- package/src/command/builtin/TestCommand.ts +9 -2
- package/src/command/startZerotal.ts +34 -0
- 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 +5 -1
- package/src/dev/CssPlugins.ts +11 -1
- package/src/dev/DevBuildHook.ts +10 -2
- package/src/dev/DevDeck.ts +6 -0
- package/src/dev/DevOrchestrator.ts +2 -0
- package/src/dev/DevProcess.ts +17 -3
- package/src/dev/DevReloadMiddleware.ts +7 -1
- package/src/dev/DevSupervisor.ts +22 -4
- package/src/dev/bootBuild.ts +9 -1
- package/src/dev/reloadClient.ts +2 -0
- package/src/dev/startDevMode.ts +7 -1
- package/src/doctor/AppDoctor.ts +43 -1
- package/src/doctor/throttleIdentity.ts +114 -0
- package/src/errors/RuntimeMismatchError.ts +21 -0
- package/src/errors/index.ts +1 -0
- package/src/events/Emitter.ts +5 -1
- package/src/helpers/html.ts +2 -0
- package/src/helpers/markdown.ts +7 -1
- package/src/helpers/pageElements.ts +2 -0
- package/src/http/HttpClient.ts +5 -1
- 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/macros/config.macro.ts +2 -0
- package/src/metrics/HttpMetrics.ts +10 -2
- package/src/middleware/BaseMiddleware.ts +20 -0
- 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/Router.ts +2 -0
- package/src/router/domain.ts +1 -0
- package/src/router/registry.ts +12 -2
- package/src/shared/format.ts +135 -0
- package/src/shared/index.ts +34 -0
- package/src/storage/StorageFilesMiddleware.ts +2 -0
- package/src/support/cookie.ts +7 -1
- package/src/support/runtime.ts +136 -0
- package/src/view/FileRouteResolver.ts +2 -0
- package/src/view/jsx-runtime.ts +4 -0
package/src/doctor/AppDoctor.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { appKeyStrengthWarning } from "../support/appKey.ts";
|
|
|
17
17
|
import { unroutedRoutesWarning } from "../support/unroutedRoutes.ts";
|
|
18
18
|
import { isWritableDir } from "../dev/bootBuild.ts";
|
|
19
19
|
import { isProdLike } from "../support/env.ts";
|
|
20
|
+
import { throttlesKeyedOnSocket } from "./throttleIdentity.ts";
|
|
20
21
|
|
|
21
22
|
/** One finding: ok is silent health, warn is worth reading, fail is broken now. */
|
|
22
23
|
export interface DoctorCheckResult {
|
|
@@ -365,12 +366,53 @@ const secureHeadersCheck: DoctorCheck = {
|
|
|
365
366
|
},
|
|
366
367
|
};
|
|
367
368
|
|
|
368
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* A rate limiter behind a proxy that was never told about the proxy keys every
|
|
371
|
+
* visitor to the same bucket, because the socket address is the proxy's. It then
|
|
372
|
+
* does the opposite of its job: one attacker spends the allowance for everybody.
|
|
373
|
+
*
|
|
374
|
+
* A warning rather than a failure, because the framework cannot see the deployment.
|
|
375
|
+
* An app served directly, with no proxy in front of it, is correctly configured
|
|
376
|
+
* exactly as it stands — and that app should not be blocked from deploying by a
|
|
377
|
+
* guess. What is worth saying is that the combination is almost always wrong, and
|
|
378
|
+
* that nothing else will ever tell you.
|
|
379
|
+
*/
|
|
380
|
+
const throttleIdentityCheck: DoctorCheck = {
|
|
381
|
+
id: "throttle-trusted-proxies",
|
|
382
|
+
label: "Rate-limit identity",
|
|
383
|
+
run(app) {
|
|
384
|
+
const exposed = throttlesKeyedOnSocket(app);
|
|
385
|
+
if (exposed.length === 0) return ok("every registered throttle can identify a client.");
|
|
386
|
+
if (!_isProductionEnv(app)) {
|
|
387
|
+
return ok(`${exposed.length} throttle(s) key on the socket address — fine locally.`);
|
|
388
|
+
}
|
|
389
|
+
const where = exposed
|
|
390
|
+
.slice(0, 3)
|
|
391
|
+
.map((t) => t.where)
|
|
392
|
+
.join(", ");
|
|
393
|
+
return warn(
|
|
394
|
+
`${exposed.length} throttle(s) (${where}${exposed.length > 3 ? ", …" : ""}) key on the ` +
|
|
395
|
+
`socket address and no trustedProxies is set. Behind a reverse proxy that address is ` +
|
|
396
|
+
`the proxy's for every request, so all visitors share one bucket and one of them can ` +
|
|
397
|
+
`lock out the rest.`,
|
|
398
|
+
"If a proxy fronts this app, set trustedProxies to how many — `ThrottleMiddleware.with({ " +
|
|
399
|
+
"maxAttempts: 5, trustedProxies: 1 })`, or `app.throttle.trustedProxies` for all of " +
|
|
400
|
+
"them. If nothing fronts it, this is already correct.",
|
|
401
|
+
);
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The core checks every app gets.
|
|
407
|
+
*
|
|
408
|
+
* @internal
|
|
409
|
+
*/
|
|
369
410
|
export const builtinDoctorChecks: DoctorCheck[] = [
|
|
370
411
|
appKeyCheck,
|
|
371
412
|
allowedOriginsCheck,
|
|
372
413
|
corsWildcardCheck,
|
|
373
414
|
secureHeadersCheck,
|
|
415
|
+
throttleIdentityCheck,
|
|
374
416
|
bootAssetWriteCheck,
|
|
375
417
|
syncVsMigrationsCheck,
|
|
376
418
|
unroutedRoutesCheck,
|
|
@@ -0,0 +1,114 @@
|
|
|
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 } 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
|
+
customKey: typeof options.keyResolver === "function",
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
// A middleware that will not construct is a different problem, and not one
|
|
71
|
+
// the doctor should report as a rate-limiting finding.
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Every throttle registered in this app, global and per-route.
|
|
78
|
+
*
|
|
79
|
+
* @param app - The booted application.
|
|
80
|
+
*/
|
|
81
|
+
export function registeredThrottles(app: Application): ThrottleIdentity[] {
|
|
82
|
+
const found: ThrottleIdentity[] = [];
|
|
83
|
+
|
|
84
|
+
for (const cls of app.globalMiddleware ?? []) {
|
|
85
|
+
if (!isThrottle(cls)) continue;
|
|
86
|
+
const identity = readIdentity(cls, "global");
|
|
87
|
+
if (identity) found.push(identity);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
for (const route of Router.routes.values()) {
|
|
92
|
+
for (const cls of route.middleware ?? []) {
|
|
93
|
+
if (!isThrottle(cls)) continue;
|
|
94
|
+
const identity = readIdentity(cls, `${route.method} ${route.path}`);
|
|
95
|
+
if (identity) found.push(identity);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// No compiled router — a console-only app, or a test harness. Global
|
|
100
|
+
// middleware alone is still worth reporting on.
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return found;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The throttles that will key every visitor to the same bucket behind a proxy.
|
|
108
|
+
*
|
|
109
|
+
* A custom `keyResolver` is excluded: an app that keys on a user id or an API key
|
|
110
|
+
* has already decided identity for itself, and this check has nothing to add.
|
|
111
|
+
*/
|
|
112
|
+
export function throttlesKeyedOnSocket(app: Application): ThrottleIdentity[] {
|
|
113
|
+
return registeredThrottles(app).filter((t) => !t.trustsProxies && !t.customKey);
|
|
114
|
+
}
|
|
@@ -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/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/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,8 @@ ${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
|
+
* @internal
|
|
47
53
|
*/
|
|
48
54
|
export interface BunMarkdownOptions {
|
|
49
55
|
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/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) {
|
|
@@ -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
|
|
@@ -32,11 +32,21 @@
|
|
|
32
32
|
* still compiles and returns `unknown`.
|
|
33
33
|
*
|
|
34
34
|
* @category Extension registries
|
|
35
|
+
*
|
|
36
|
+
* @internal
|
|
35
37
|
*/
|
|
36
38
|
export interface ContextRegistry {}
|
|
37
39
|
|
|
38
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Union of every registered context key. `never` until a package augments {@link ContextRegistry}.
|
|
42
|
+
*
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
39
45
|
export type ContextKey = keyof ContextRegistry;
|
|
40
46
|
|
|
41
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* The value type stored under a registered context key.
|
|
49
|
+
*
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
42
52
|
export type ContextValue<K extends ContextKey> = ContextRegistry[K];
|
|
@@ -31,6 +31,8 @@ type AnyViewComponent = (ctx: HttpContext<any>, props: any) => ViewMarkup | Prom
|
|
|
31
31
|
/**
|
|
32
32
|
* Minimal Bun server interface needed for socket-level IP resolution.
|
|
33
33
|
* Duck-typed so HttpContext has no hard dependency on Bun's global types.
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
34
36
|
*/
|
|
35
37
|
export interface RequestIPProvider {
|
|
36
38
|
requestIP(req: Request): { address: string; family: string; port: number } | null;
|
|
@@ -874,6 +876,8 @@ const _prototypeMethods: ReadonlyArray<[string, (...args: unknown[]) => unknown]
|
|
|
874
876
|
* @param origin - The current request origin to match against.
|
|
875
877
|
* @returns The same-origin `url`, or `undefined` when it is absent, unparseable,
|
|
876
878
|
* or points to a different origin.
|
|
879
|
+
*
|
|
880
|
+
* @internal
|
|
877
881
|
*/
|
|
878
882
|
export function safeRedirectPath(
|
|
879
883
|
url: string | null | undefined,
|
|
@@ -5,6 +5,8 @@ import { HttpContext } from "./HttpContext.ts";
|
|
|
5
5
|
*
|
|
6
6
|
* @param pageName - The paginator's name, so one request can drive several independently.
|
|
7
7
|
* @returns The 1-based page, or `undefined` to fall back to the query string.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
8
10
|
*/
|
|
9
11
|
export type CurrentPageResolver = (pageName: string) => number | undefined;
|
|
10
12
|
|
|
@@ -21,6 +23,8 @@ export type CurrentPageResolver = (pageName: string) => number | undefined;
|
|
|
21
23
|
* another one. Keep it that way — a module-level slot would not be request-scoped.
|
|
22
24
|
*
|
|
23
25
|
* @param resolver - Called with the paginator name; return `undefined` to defer to the query string.
|
|
26
|
+
*
|
|
27
|
+
* @internal
|
|
24
28
|
*/
|
|
25
29
|
export function setCurrentPageResolver(resolver: CurrentPageResolver): void {
|
|
26
30
|
const ctx = HttpContext.tryGet();
|
package/src/pipeline/types.ts
CHANGED
|
@@ -74,6 +74,8 @@ export interface Pipe<T> {
|
|
|
74
74
|
/**
|
|
75
75
|
* A payload that can carry a `Response`. Pipes set `response` and return the
|
|
76
76
|
* payload to short-circuit; the pipeline terminal reads it back.
|
|
77
|
+
*
|
|
78
|
+
* @internal
|
|
77
79
|
*/
|
|
78
80
|
export interface HasResponse {
|
|
79
81
|
response: Response | undefined;
|
package/src/router/FileRouter.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type { FileHandler };
|
|
|
23
23
|
/**
|
|
24
24
|
* Per-method middleware map for a route file's `export const middleware`.
|
|
25
25
|
* `ALL` applies to every method the file handles; the per-verb arrays add to it.
|
|
26
|
+
*
|
|
27
|
+
* @internal
|
|
26
28
|
*/
|
|
27
29
|
export interface RouteMethodMiddleware {
|
|
28
30
|
ALL?: MiddlewareClass[];
|
|
@@ -45,6 +47,7 @@ export type RouteMiddleware = MiddlewareClass[] | RouteMethodMiddleware;
|
|
|
45
47
|
|
|
46
48
|
/** Shape of a route module. `default` may be a FileHandler or a ViewComponent;
|
|
47
49
|
* `layout` overrides the directory `_layout` (null = no layout). */
|
|
50
|
+
/** @internal */
|
|
48
51
|
export interface RouteModule {
|
|
49
52
|
default?: FileHandler | ViewComponent;
|
|
50
53
|
GET?: FileHandler;
|
|
@@ -57,7 +60,11 @@ export interface RouteModule {
|
|
|
57
60
|
middleware?: RouteMiddleware;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Optional `export const meta = { GET: { name: 'users.show' } }` in route files.
|
|
65
|
+
*
|
|
66
|
+
* @internal
|
|
67
|
+
*/
|
|
61
68
|
export interface RouteFileMeta {
|
|
62
69
|
GET?: { name?: string };
|
|
63
70
|
POST?: { name?: string };
|
|
@@ -66,7 +73,11 @@ export interface RouteFileMeta {
|
|
|
66
73
|
DELETE?: { name?: string };
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Shape of a `_middleware.ts` file.
|
|
78
|
+
*
|
|
79
|
+
* @internal
|
|
80
|
+
*/
|
|
70
81
|
export interface MiddlewareModule {
|
|
71
82
|
middleware: MiddlewareClass[];
|
|
72
83
|
}
|
|
@@ -75,6 +86,8 @@ export interface MiddlewareModule {
|
|
|
75
86
|
|
|
76
87
|
/**
|
|
77
88
|
* Context handed to a file-route resolver for each scanned route file.
|
|
89
|
+
*
|
|
90
|
+
* @internal
|
|
78
91
|
*/
|
|
79
92
|
export interface FileRouteContext {
|
|
80
93
|
/** URL path derived from the file location (e.g. '/users/:id'). */
|
|
@@ -105,6 +118,8 @@ export interface FileRouteContext {
|
|
|
105
118
|
* Router.flow(urlPath, PageClass, middleware);
|
|
106
119
|
* return true;
|
|
107
120
|
* });
|
|
121
|
+
*
|
|
122
|
+
* @internal
|
|
108
123
|
*/
|
|
109
124
|
export type FileRouteResolver = (ctx: FileRouteContext) => boolean;
|
|
110
125
|
|
|
@@ -112,6 +127,7 @@ const _fileRouteResolvers: FileRouteResolver[] = [];
|
|
|
112
127
|
|
|
113
128
|
/** Register a resolver. Call from a provider's onRegister() so it is in place
|
|
114
129
|
* before Application.boot() scans file routes. */
|
|
130
|
+
/** @internal */
|
|
115
131
|
export function registerFileRouteResolver(resolver: FileRouteResolver): void {
|
|
116
132
|
_fileRouteResolvers.push(resolver);
|
|
117
133
|
}
|
|
@@ -390,6 +406,8 @@ async function _collectLayout(
|
|
|
390
406
|
* creates a fresh module namespace instead of returning the
|
|
391
407
|
* cached version. Omit on first boot.
|
|
392
408
|
* @returns The number of individual method handlers registered.
|
|
409
|
+
*
|
|
410
|
+
* @internal
|
|
393
411
|
*/
|
|
394
412
|
export async function scanFileRoutes(baseDir: string, reloadId?: string): Promise<number> {
|
|
395
413
|
const absoluteBase = _resolveAbsoluteBase(baseDir);
|
package/src/router/Route.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type MiddlewareClass = new (...args: any[]) => Pipe<HttpContext>;
|
|
|
18
18
|
/** Handler signature for file-based routes. Receives the request {@link HttpContext}
|
|
19
19
|
* directly — route params and resolved model bindings live on `ctx.params`. May return
|
|
20
20
|
* a Response or mutate `ctx` directly. */
|
|
21
|
+
/** @internal */
|
|
21
22
|
export type FileHandler = (ctx: HttpContext) => void | Response | Promise<void | Response>;
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -49,6 +50,8 @@ export type ViewLayout = (ctx: HttpContext, props: { children: unknown }) => unk
|
|
|
49
50
|
* Receives the raw string param value and the current context;
|
|
50
51
|
* must return (or resolve to) the model instance.
|
|
51
52
|
* Throw `ModelNotFoundError` (or any 404 error) when the record does not exist.
|
|
53
|
+
*
|
|
54
|
+
* @internal
|
|
52
55
|
*/
|
|
53
56
|
export type ModelBindingResolver = (value: string, ctx: HttpContext) => Promise<unknown>;
|
|
54
57
|
|
package/src/router/Router.ts
CHANGED
|
@@ -178,6 +178,8 @@ function _toResolver(modelOrResolver: ModelClass | ModelBindingResolver): ModelB
|
|
|
178
178
|
* All mutable router state in one swappable object: registered routes, the
|
|
179
179
|
* active group prefix/middleware/domain, static and markdown directories, named
|
|
180
180
|
* routes, and named middleware groups.
|
|
181
|
+
*
|
|
182
|
+
* @internal
|
|
181
183
|
*/
|
|
182
184
|
export class RouterState {
|
|
183
185
|
routes = new Map<string, RouteDefinition>();
|
package/src/router/domain.ts
CHANGED
package/src/router/registry.ts
CHANGED
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
* updates with it.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* A value that can be substituted into a `:param` segment.
|
|
28
|
+
*
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
27
31
|
export type RouteParamValue = string | number;
|
|
28
32
|
|
|
29
33
|
/** The loose param bag accepted by the untyped `route()` overload. */
|
|
@@ -68,7 +72,11 @@ export type RouteName = Extract<keyof RouteRegistry, string>;
|
|
|
68
72
|
*/
|
|
69
73
|
export type RouteTarget = [RouteName] extends [never] ? string : RouteName;
|
|
70
74
|
|
|
71
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* The URL pattern registered for route `N`, or plain `string` when it isn't a known name.
|
|
77
|
+
*
|
|
78
|
+
* @internal
|
|
79
|
+
*/
|
|
72
80
|
export type RoutePattern<N extends string> = N extends RouteName
|
|
73
81
|
? RouteRegistry[N] extends string
|
|
74
82
|
? RouteRegistry[N]
|
|
@@ -101,6 +109,8 @@ export type RouteParams<N extends string> = Prettify<ParamsOf<RoutePattern<N>>>;
|
|
|
101
109
|
* generated, loose before that. Used by the helpers that take params without
|
|
102
110
|
* being able to take `route()`'s rest-tuple — `redirect().to(name, params,
|
|
103
111
|
* status)` and Flow's `redirectRoute`.
|
|
112
|
+
*
|
|
113
|
+
* @internal
|
|
104
114
|
*/
|
|
105
115
|
export type RouteParamsArg<N extends string> = [RouteName] extends [never]
|
|
106
116
|
? RouteParamValues
|