@zerotal/core 1.0.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 +79 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +72 -0
- package/src/application/Application.ts +1671 -0
- package/src/application/BootDoctor.ts +108 -0
- package/src/application/DevErrorPage.ts +567 -0
- package/src/application/ExceptionHandler.ts +183 -0
- package/src/application/currentApp.ts +73 -0
- package/src/assets/assets.ts +79 -0
- package/src/assets/index.ts +16 -0
- package/src/auth/AuthenticatedUser.ts +18 -0
- package/src/build/PackageLinter.ts +146 -0
- package/src/build/PackageScaffold.ts +127 -0
- package/src/build/codemod.ts +64 -0
- package/src/build/index.ts +12 -0
- package/src/command/Command.ts +254 -0
- package/src/command/CommandRunner.ts +593 -0
- package/src/command/OutputWriter.ts +61 -0
- package/src/command/builtin/CompileCommand.ts +46 -0
- package/src/command/builtin/CssBuildCommand.ts +71 -0
- package/src/command/builtin/KeyGenerateCommand.ts +58 -0
- package/src/command/builtin/LintPackagesCommand.ts +72 -0
- package/src/command/builtin/MakeCommandCommand.ts +85 -0
- package/src/command/builtin/MakeControllerCommand.ts +95 -0
- package/src/command/builtin/MakeEventCommand.ts +85 -0
- package/src/command/builtin/MakeJobCommand.ts +53 -0
- package/src/command/builtin/MakeListenerCommand.ts +35 -0
- package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
- package/src/command/builtin/MakeNotificationCommand.ts +48 -0
- package/src/command/builtin/MakeObserverCommand.ts +78 -0
- package/src/command/builtin/MakePackageCommand.ts +45 -0
- package/src/command/builtin/MakePolicyCommand.ts +66 -0
- package/src/command/builtin/MakeProviderCommand.ts +75 -0
- package/src/command/builtin/MakeRequestCommand.ts +47 -0
- package/src/command/builtin/MakeResourceCommand.ts +61 -0
- package/src/command/builtin/MakeTestCommand.ts +120 -0
- package/src/command/builtin/ReloadCommand.ts +52 -0
- package/src/command/builtin/ReplCommand.ts +174 -0
- package/src/command/builtin/RouteListCommand.ts +188 -0
- package/src/command/builtin/ServeCommand.ts +321 -0
- package/src/command/builtin/StartCommand.ts +3 -0
- package/src/command/builtin/StatusCommand.ts +71 -0
- package/src/command/builtin/TestCommand.ts +172 -0
- package/src/command/builtin/WorkerCommand.ts +27 -0
- package/src/command/builtin/index.ts +53 -0
- package/src/command/scaffold/worker.ts.txt +12 -0
- package/src/command/scaffold/zerotal.ts.txt +26 -0
- package/src/command/startZerotal.ts +55 -0
- package/src/config/AppConfig.ts +253 -0
- package/src/config/ConfigLoader.ts +117 -0
- package/src/config/ConfigManager.ts +169 -0
- package/src/config/index.ts +46 -0
- package/src/config/registry.ts +59 -0
- package/src/config/validation.ts +117 -0
- package/src/container/Container.ts +606 -0
- package/src/container/ContextualBindingBuilder.ts +57 -0
- package/src/container/ScopedResolver.ts +117 -0
- package/src/container/index.ts +32 -0
- package/src/container/inject.ts +55 -0
- package/src/container/types.ts +71 -0
- package/src/context/RequestContext.ts +91 -0
- package/src/contracts/auth.ts +24 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/session.ts +70 -0
- package/src/contracts/transaction.ts +26 -0
- package/src/conventions/ConventionLoader.ts +128 -0
- package/src/conventions/builtinConcerns.ts +131 -0
- package/src/crypt/Crypt.ts +141 -0
- package/src/crypt/URLSigner.ts +96 -0
- package/src/datetime/Carbon.ts +1396 -0
- package/src/datetime/CarbonInterval.ts +421 -0
- package/src/datetime/clock.ts +28 -0
- package/src/datetime/index.ts +23 -0
- package/src/datetime/temporal-shim.ts +1 -0
- package/src/dev/BuildOutput.ts +131 -0
- package/src/dev/CssPlugins.ts +184 -0
- package/src/dev/DevBuildHook.ts +74 -0
- package/src/dev/DevOrchestrator.ts +213 -0
- package/src/dev/DevReloadMiddleware.ts +101 -0
- package/src/dev/DevReloadServer.ts +85 -0
- package/src/dev/DevWsServer.ts +45 -0
- package/src/dev/index.ts +19 -0
- package/src/dev/reloadClient.ts +39 -0
- package/src/env/Def.ts +232 -0
- package/src/env/EnvSchema.ts +105 -0
- package/src/env/index.ts +34 -0
- package/src/env/t.ts +128 -0
- package/src/errors/ConfigError.ts +12 -0
- package/src/errors/ContainerErrors.ts +143 -0
- package/src/errors/HttpError.ts +127 -0
- package/src/errors/ValidationError.ts +19 -0
- package/src/errors/ZerotalError.ts +25 -0
- package/src/errors/index.ts +46 -0
- package/src/events/CallQueuedListener.ts +66 -0
- package/src/events/Emitter.ts +280 -0
- package/src/events/EventFake.ts +160 -0
- package/src/events/FrameworkEvents.ts +252 -0
- package/src/facade/Facade.ts +101 -0
- package/src/facade/facades/App.ts +155 -0
- package/src/facade/facades/Artisan.ts +63 -0
- package/src/facade/facades/Config.ts +21 -0
- package/src/facade/facades/Events.ts +19 -0
- package/src/facade/facades/index.ts +28 -0
- package/src/global.d.ts +9 -0
- package/src/hash/Hash.ts +60 -0
- package/src/health/Health.ts +221 -0
- package/src/health/index.ts +27 -0
- package/src/helpers/Collection.ts +435 -0
- package/src/helpers/config.ts +59 -0
- package/src/helpers/fluent.ts +52 -0
- package/src/helpers/html.ts +11 -0
- package/src/helpers/index.ts +266 -0
- package/src/helpers/make.ts +35 -0
- package/src/helpers/markdown.ts +73 -0
- package/src/helpers/pageElements.ts +27 -0
- package/src/helpers/request.ts +62 -0
- package/src/helpers/response.ts +411 -0
- package/src/helpers/str.ts +208 -0
- package/src/http/Http.ts +298 -0
- package/src/http/HttpClient.ts +289 -0
- package/src/http/Resource.ts +171 -0
- package/src/http/UploadedFile.ts +204 -0
- package/src/http/Uri.ts +490 -0
- package/src/http/index.ts +46 -0
- package/src/http/negotiate.ts +213 -0
- package/src/http/originGuard.ts +76 -0
- package/src/http/sniffContentType.ts +105 -0
- package/src/http/url.ts +204 -0
- package/src/http/withHeaders.ts +24 -0
- package/src/index.ts +250 -0
- package/src/lock/LockManager.ts +228 -0
- package/src/lock/config.ts +49 -0
- package/src/lock/drivers/LockDriver.ts +32 -0
- package/src/lock/drivers/MemoryLockDriver.ts +52 -0
- package/src/lock/drivers/RedisLockDriver.ts +58 -0
- package/src/lock/drivers/SqliteLockDriver.ts +85 -0
- package/src/lock/errors.ts +20 -0
- package/src/lock/facades/Lock.ts +114 -0
- package/src/lock/index.ts +53 -0
- package/src/logger/Log.ts +35 -0
- package/src/logger/LogManager.ts +430 -0
- package/src/logger/LoggerMiddleware.ts +125 -0
- package/src/logger/channels/ConsoleChannel.ts +139 -0
- package/src/logger/channels/DailyChannel.ts +74 -0
- package/src/logger/channels/NullChannel.ts +17 -0
- package/src/logger/channels/SingleChannel.ts +34 -0
- package/src/logger/channels/StackChannel.ts +29 -0
- package/src/logger/config.ts +90 -0
- package/src/logger/format.ts +96 -0
- package/src/logger/frameworkLog.ts +93 -0
- package/src/logger/index.ts +68 -0
- package/src/logger/renderTable.ts +111 -0
- package/src/logger/types.ts +212 -0
- package/src/macros/config.macro.ts +50 -0
- package/src/metrics/HttpMetrics.ts +114 -0
- package/src/metrics/index.ts +18 -0
- package/src/middleware/BaseMiddleware.ts +72 -0
- package/src/middleware/CorsMiddleware.ts +152 -0
- package/src/middleware/RateLimiter.ts +255 -0
- package/src/middleware/SecureHeadersMiddleware.ts +127 -0
- package/src/middleware/ThrottleMiddleware.ts +252 -0
- package/src/middleware/WebhookMiddleware.ts +204 -0
- package/src/pipeline/ContextRegistry.ts +42 -0
- package/src/pipeline/HttpContext.ts +865 -0
- package/src/pipeline/Pipeline.ts +150 -0
- package/src/pipeline/currentPage.ts +46 -0
- package/src/pipeline/types.ts +80 -0
- package/src/provider/LockProvider.ts +64 -0
- package/src/provider/LogProvider.ts +137 -0
- package/src/provider/ServiceProvider.ts +84 -0
- package/src/provider/StorageProvider.ts +45 -0
- package/src/router/FileRouter.ts +526 -0
- package/src/router/Route.ts +76 -0
- package/src/router/RouteHandler.ts +335 -0
- package/src/router/Router.ts +1247 -0
- package/src/router/domain.ts +65 -0
- package/src/security/index.ts +22 -0
- package/src/storage/FakeDisk.ts +233 -0
- package/src/storage/StorageFilesMiddleware.ts +150 -0
- package/src/storage/StorageManager.ts +173 -0
- package/src/storage/config.ts +47 -0
- package/src/storage/drivers/LocalDriver.ts +138 -0
- package/src/storage/drivers/S3Driver.ts +169 -0
- package/src/storage/errors.ts +135 -0
- package/src/storage/facades/Storage.ts +3 -0
- package/src/storage/global.d.ts +7 -0
- package/src/storage/index.ts +22 -0
- package/src/storage/root.ts +59 -0
- package/src/storage/types.ts +104 -0
- package/src/support/appKey.ts +38 -0
- package/src/support/cookie.ts +72 -0
- package/src/support/crypto.ts +52 -0
- package/src/support/deepMerge.ts +117 -0
- package/src/support/env.ts +71 -0
- package/src/support/network.ts +79 -0
- package/src/support/port.ts +197 -0
- package/src/support/str.ts +122 -0
- package/src/view/FileRouteResolver.ts +59 -0
- package/src/view/index.ts +144 -0
- package/src/view/jsx-runtime.ts +233 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-request `HttpContext` — the object that travels through the pipeline
|
|
3
|
+
* and lives in request-scoped storage, exposing request input, response
|
|
4
|
+
* helpers, route-model bindings, and after-response hooks to controllers.
|
|
5
|
+
*/
|
|
6
|
+
import { Container } from "../container/Container.ts";
|
|
7
|
+
import { RequestContext } from "../context/RequestContext.ts";
|
|
8
|
+
import { getRequestSubdomains } from "../router/domain.ts";
|
|
9
|
+
import { ScopedResolver } from "../container/ScopedResolver.ts";
|
|
10
|
+
import { UploadedFile } from "../http/UploadedFile.ts";
|
|
11
|
+
import {
|
|
12
|
+
markdownExtractTitle,
|
|
13
|
+
markdownPage,
|
|
14
|
+
DEFAULT_MD_OPTIONS,
|
|
15
|
+
type BunMarkdownOptions,
|
|
16
|
+
} from "../helpers/markdown.ts";
|
|
17
|
+
import type { SessionContract, TransactionContext } from "../contracts/index.ts";
|
|
18
|
+
import type { ContextRegistry, ContextKey } from "./ContextRegistry.ts";
|
|
19
|
+
|
|
20
|
+
/** Markup a view renders to — a string or anything stringifiable (e.g. JSX `SafeHtml`). */
|
|
21
|
+
type ViewMarkup = string | { toString(): string };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A view component invoked by `view()` / `ctx.view()`. It receives the request
|
|
25
|
+
* {@link HttpContext} (route params and model bindings live on `ctx.params`) plus
|
|
26
|
+
* any explicit props, and returns renderable markup.
|
|
27
|
+
*/
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- supertype of every ViewComponent<P> the public overloads accept.
|
|
29
|
+
type AnyViewComponent = (ctx: HttpContext<any>, props: any) => ViewMarkup | Promise<ViewMarkup>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Minimal Bun server interface needed for socket-level IP resolution.
|
|
33
|
+
* Duck-typed so HttpContext has no hard dependency on Bun's global types.
|
|
34
|
+
*/
|
|
35
|
+
export interface RequestIPProvider {
|
|
36
|
+
requestIP(req: Request): { address: string; family: string; port: number } | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The central per-request object, exposing request input, response helpers,
|
|
41
|
+
* route-model bindings, session flash, and after-response hooks to controllers
|
|
42
|
+
* and middleware.
|
|
43
|
+
*
|
|
44
|
+
* Lives in the AsyncLocalStorage store AND travels through the pipeline.
|
|
45
|
+
* Both are the same object reference — no sync needed between them.
|
|
46
|
+
*
|
|
47
|
+
* @typeParam TParams - Shape of `ctx.params` (route params plus resolved model
|
|
48
|
+
* bindings). Defaults to a string-keyed record.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* // A controller action reading input and returning JSON:
|
|
52
|
+
* async show(ctx: HttpContext) {
|
|
53
|
+
* const id = ctx.integer('id');
|
|
54
|
+
* const post = ctx.model<Post>('post');
|
|
55
|
+
* return ctx.json({ id, post });
|
|
56
|
+
* }
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* // An inline route handler — the context is passed directly:
|
|
60
|
+
* Router.post('/subscribe', async (ctx) => {
|
|
61
|
+
* const { email } = await ctx.body<{ email: string }>();
|
|
62
|
+
* ctx.flash('success', `Subscribed ${email}`);
|
|
63
|
+
* ctx.redirect('/thanks', 303);
|
|
64
|
+
* });
|
|
65
|
+
*/
|
|
66
|
+
export class HttpContext<TParams extends Record<string, unknown> = Record<string, string>> {
|
|
67
|
+
readonly requestId: string;
|
|
68
|
+
readonly startedAt: number;
|
|
69
|
+
readonly url: URL;
|
|
70
|
+
|
|
71
|
+
// ── Route parameters (e.g. /users/:id → { id: '42' }) ───────────────
|
|
72
|
+
// Holds raw string params and, once the framework resolves route-model
|
|
73
|
+
// bindings, the loaded instances under their param name (see RouteHandler).
|
|
74
|
+
params: TParams = {} as TParams;
|
|
75
|
+
|
|
76
|
+
sessionId?: string;
|
|
77
|
+
|
|
78
|
+
// ── Session (set by @zerotal/session's SessionMiddleware) ───────────
|
|
79
|
+
// Typed against the SessionContract the kernel owns, so flash()/flashed()
|
|
80
|
+
// depend on a shape rather than on @zerotal/session. Undefined when the
|
|
81
|
+
// session middleware is not active on this request.
|
|
82
|
+
session?: SessionContract;
|
|
83
|
+
|
|
84
|
+
// ── Matched route info (set by RouteHandler, read by devtools) ──────
|
|
85
|
+
_routeDef?: { pattern: string; controller: string; action: string };
|
|
86
|
+
|
|
87
|
+
// ── DB transaction (set by DB.transaction(), cleared on exit) ────────
|
|
88
|
+
// Carried as the kernel-owned TransactionContext; @zerotal/orm narrows it
|
|
89
|
+
// back to its concrete SQL connection at its own boundary.
|
|
90
|
+
_transaction?: TransactionContext | undefined;
|
|
91
|
+
|
|
92
|
+
// ── i18n (set by I18nMiddleware) ──────────────────────────────────────
|
|
93
|
+
locale: string = "en";
|
|
94
|
+
|
|
95
|
+
/** Set by controllers/route handlers. Read by Application after pipeline runs. */
|
|
96
|
+
response: Response | undefined = undefined;
|
|
97
|
+
|
|
98
|
+
// ── After-response callbacks ──────────────────────────────────────────
|
|
99
|
+
_afterResponseCallbacks: Array<() => Promise<void>> = [];
|
|
100
|
+
|
|
101
|
+
// ── Response finalizers ───────────────────────────────────────────────
|
|
102
|
+
// Run once the final Response exists but before it is returned, so they can
|
|
103
|
+
// still attach headers. See {@link onResponseReady}.
|
|
104
|
+
_responseFinalizers: Array<(response: Response) => Promise<void>> = [];
|
|
105
|
+
|
|
106
|
+
// ── Bun server reference (set by Application/RouteHandler) ───────────
|
|
107
|
+
// Exposes requestIP() for socket-level IP resolution. Not available in tests
|
|
108
|
+
// created via HttpContext.fake() unless explicitly set.
|
|
109
|
+
_server: RequestIPProvider | undefined = undefined;
|
|
110
|
+
|
|
111
|
+
// ── Route-model binding storage ───────────────────────────────────────
|
|
112
|
+
// Populated by createRouteHandler before the pipeline runs.
|
|
113
|
+
readonly _models = new Map<string, unknown>();
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Where `paginate()` reads the current page for this request, when a caller doesn't pass
|
|
117
|
+
* one. Set via `setCurrentPageResolver()`; unset means the query string. Per-request
|
|
118
|
+
* because one process serves many requests at once.
|
|
119
|
+
*
|
|
120
|
+
* @internal
|
|
121
|
+
*/
|
|
122
|
+
_pageResolver?: (pageName: string) => number | undefined;
|
|
123
|
+
|
|
124
|
+
// ── Internal meta (for framework packages; keys typed via ContextRegistry) ──
|
|
125
|
+
private _meta = new Map<string, unknown>();
|
|
126
|
+
private _body: Record<string, unknown> | undefined = undefined;
|
|
127
|
+
private _formData: FormData | null | undefined = undefined;
|
|
128
|
+
|
|
129
|
+
constructor(
|
|
130
|
+
readonly request: Request,
|
|
131
|
+
readonly container: ScopedResolver,
|
|
132
|
+
) {
|
|
133
|
+
this.requestId = crypto.randomUUID();
|
|
134
|
+
this.startedAt = performance.now();
|
|
135
|
+
this.url = new URL(request.url);
|
|
136
|
+
|
|
137
|
+
// Bind every prototype method to this instance so handlers can destructure
|
|
138
|
+
// them off the context — `async show({ json, view, params }: HttpContext)` —
|
|
139
|
+
// without losing `this`. The method list is scanned ONCE at module load
|
|
140
|
+
// (see _prototypeMethods below); only the per-instance .bind() runs here.
|
|
141
|
+
for (const [key, method] of _prototypeMethods) {
|
|
142
|
+
(this as Record<string, unknown>)[key] = method.bind(this);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Computed helpers ──────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Elapsed milliseconds since this request started.
|
|
150
|
+
*
|
|
151
|
+
* @category Lifecycle
|
|
152
|
+
*/
|
|
153
|
+
get took(): number {
|
|
154
|
+
return Math.round(performance.now() - this.startedAt);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Response helpers ──────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Set a JSON response.
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ctx.json({ user }); // 200
|
|
164
|
+
* ctx.json({ errors }, 422); // 422
|
|
165
|
+
*
|
|
166
|
+
* @category Responses
|
|
167
|
+
*/
|
|
168
|
+
json(data: unknown, status = 200): void {
|
|
169
|
+
this.response = Response.json(data, { status });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Respond with a server-side view rendered to a full HTML document.
|
|
174
|
+
* Prepends `<!DOCTYPE html>` and sets `Content-Type: text/html`.
|
|
175
|
+
*
|
|
176
|
+
* Accepts either pre-rendered markup, or a **view component** plus its props.
|
|
177
|
+
* A view component receives the request {@link HttpContext} (route params and
|
|
178
|
+
* model bindings live on `ctx.params`) and the props you pass as a second
|
|
179
|
+
* argument. Pair with core's JSX runtime (add `/** @jsxImportSource @zerotal/core *\/`
|
|
180
|
+
* to view files) so JSX evaluates directly to HTML.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* // resources/views/Welcome.tsx
|
|
184
|
+
* export default function Welcome(ctx: HttpContext, { title }: { title: string }) {
|
|
185
|
+
* return <html><body><h1>{title}</h1><p>{ctx.url.pathname}</p></body></html>;
|
|
186
|
+
* }
|
|
187
|
+
*
|
|
188
|
+
* // In a route/controller:
|
|
189
|
+
* ctx.view(Welcome, { title: 'Hello' });
|
|
190
|
+
*
|
|
191
|
+
* // Or pass already-rendered markup:
|
|
192
|
+
* ctx.view(Welcome(ctx, { title: 'Hello' }));
|
|
193
|
+
*
|
|
194
|
+
* @category Responses
|
|
195
|
+
*/
|
|
196
|
+
view(markup: ViewMarkup, status?: number): void;
|
|
197
|
+
view<P extends Record<string, unknown> = Record<string, never>>(
|
|
198
|
+
component: (ctx: HttpContext, props: P) => ViewMarkup | Promise<ViewMarkup>,
|
|
199
|
+
props?: P,
|
|
200
|
+
status?: number,
|
|
201
|
+
): void | Promise<void>;
|
|
202
|
+
view(
|
|
203
|
+
markupOrComponent: ViewMarkup | AnyViewComponent,
|
|
204
|
+
propsOrStatus?: Record<string, unknown> | number,
|
|
205
|
+
status = 200,
|
|
206
|
+
): void | Promise<void> {
|
|
207
|
+
if (typeof markupOrComponent === "function") {
|
|
208
|
+
const props = (typeof propsOrStatus === "object" ? propsOrStatus : undefined) ?? {};
|
|
209
|
+
const result = markupOrComponent(this, props);
|
|
210
|
+
if (result instanceof Promise) {
|
|
211
|
+
return result.then((markup) => this._renderView(markup, status));
|
|
212
|
+
}
|
|
213
|
+
this._renderView(result, status);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Pre-rendered markup form — `propsOrStatus` is the optional status code.
|
|
217
|
+
this._renderView(markupOrComponent, typeof propsOrStatus === "number" ? propsOrStatus : 200);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Set the response to a full HTML document wrapping the rendered markup. */
|
|
221
|
+
private _renderView(markup: ViewMarkup, status: number): void {
|
|
222
|
+
this.response = new Response(`<!DOCTYPE html>\n${markup}`, {
|
|
223
|
+
status,
|
|
224
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Render a Markdown string to a full HTML document using Bun's built-in
|
|
230
|
+
* `Bun.markdown.html()`. Automatically enables tables, strikethrough,
|
|
231
|
+
* tasklists, autolinks, and heading IDs — pass `options` to override.
|
|
232
|
+
*
|
|
233
|
+
* Useful for serving `.md` files as documentation pages:
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* const content = await Bun.file('./docs/getting-started.md').text();
|
|
237
|
+
* ctx.markdown(content);
|
|
238
|
+
*
|
|
239
|
+
* // Custom title / options:
|
|
240
|
+
* ctx.markdown(content, { title: 'Getting Started', headings: { ids: true } });
|
|
241
|
+
*
|
|
242
|
+
* @category Responses
|
|
243
|
+
*/
|
|
244
|
+
markdown(content: string, options?: BunMarkdownOptions & { title?: string }, status = 200): void {
|
|
245
|
+
const { title, ...mdOptions } = options ?? {};
|
|
246
|
+
const body = Bun.markdown.html(content, {
|
|
247
|
+
...DEFAULT_MD_OPTIONS,
|
|
248
|
+
...mdOptions,
|
|
249
|
+
});
|
|
250
|
+
const pageTitle = title ?? markdownExtractTitle(content) ?? "Docs";
|
|
251
|
+
this.view(markdownPage(pageTitle, body), status);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Respond with a raw HTML string. Unlike `view()`, no DOCTYPE is prepended —
|
|
256
|
+
* useful for HTML fragments, partials, or when you manage the document shell
|
|
257
|
+
* yourself (e.g. when returning a partial for htmx or Turbo Streams).
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* ctx.html('<p>Updated!</p>');
|
|
261
|
+
* ctx.html(renderPartial(data), 200);
|
|
262
|
+
*
|
|
263
|
+
* @category Responses
|
|
264
|
+
*/
|
|
265
|
+
html(markup: string | { toString(): string }, status = 200): void {
|
|
266
|
+
this.response = new Response(String(markup), {
|
|
267
|
+
status,
|
|
268
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Set an HTTP redirect response.
|
|
274
|
+
* Default 302 (Found). Use 303 (See Other) after POST/PUT/DELETE — Inertia
|
|
275
|
+
* and browsers always issue a GET on a 303, preventing form re-submission.
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ctx.redirect('/dashboard'); // 302
|
|
279
|
+
* ctx.redirect('/dashboard', 303); // 303 after POST
|
|
280
|
+
*
|
|
281
|
+
* @category Redirects
|
|
282
|
+
*/
|
|
283
|
+
redirect(url: string, status: 301 | 302 | 303 | 307 | 308 = 302): void {
|
|
284
|
+
this.response = new Response(null, {
|
|
285
|
+
status,
|
|
286
|
+
headers: { Location: url },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Redirect back to the previous page using the Referer header.
|
|
292
|
+
* Falls back to '/' when no Referer is present or when the Referer
|
|
293
|
+
* points to a different origin (prevents open-redirect attacks).
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* ctx.back(); // 302 to Referer
|
|
297
|
+
* ctx.back(303); // 303 to Referer (use after Inertia POST)
|
|
298
|
+
*
|
|
299
|
+
* @category Redirects
|
|
300
|
+
*/
|
|
301
|
+
back(status: 301 | 302 | 303 | 307 | 308 = 302): void {
|
|
302
|
+
const referer = this.request.headers.get("Referer");
|
|
303
|
+
const url = safeRedirectPath(referer, this.url.origin) ?? "/";
|
|
304
|
+
this.redirect(url, status);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Write a value into the session for the next request.
|
|
309
|
+
* Requires SessionMiddleware to be active; silently no-ops if absent.
|
|
310
|
+
*
|
|
311
|
+
* Use flash() to pass data across a redirect (errors, status messages, etc.).
|
|
312
|
+
* Read it back with flashed() on the next request.
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* ctx.flash('success', 'Post saved!');
|
|
316
|
+
* ctx.flash('errors', { email: 'Already taken' });
|
|
317
|
+
*
|
|
318
|
+
* @category Session & state
|
|
319
|
+
*/
|
|
320
|
+
flash(key: string, value: unknown): void {
|
|
321
|
+
this.session?.flash(key, value);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read a value that was flashed in the previous request.
|
|
326
|
+
* Returns undefined if the key was not flashed or session is absent.
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* const errors = ctx.flashed<Record<string, string>>('errors');
|
|
330
|
+
* const success = ctx.flashed<string>('success');
|
|
331
|
+
*
|
|
332
|
+
* @category Session & state
|
|
333
|
+
*/
|
|
334
|
+
flashed<T = unknown>(key: string): T | undefined {
|
|
335
|
+
return this.session?.get(key) as T | undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── URI & path helpers ───────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The current request pathname (no query string).
|
|
342
|
+
*
|
|
343
|
+
* @category Request data
|
|
344
|
+
*/
|
|
345
|
+
path(): string {
|
|
346
|
+
return this.url.pathname;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The full request URL including query string.
|
|
351
|
+
*
|
|
352
|
+
* @category Request data
|
|
353
|
+
*/
|
|
354
|
+
fullUrl(): string {
|
|
355
|
+
return this.url.href;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The host portion of the URL (hostname + port if non-standard).
|
|
360
|
+
*
|
|
361
|
+
* @category Request data
|
|
362
|
+
*/
|
|
363
|
+
host(): string {
|
|
364
|
+
return this.url.host;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Subdomain params captured from a `Router.group({ domain })` match.
|
|
369
|
+
*
|
|
370
|
+
* @example
|
|
371
|
+
* // Router.group({ domain: ':tenant.app.com' }, () => { ... });
|
|
372
|
+
* ctx.subdomains; // { tenant: 'acme' } for acme.app.com
|
|
373
|
+
* ctx.subdomain('tenant'); // 'acme'
|
|
374
|
+
*
|
|
375
|
+
* @category Request data
|
|
376
|
+
*/
|
|
377
|
+
get subdomains(): Record<string, string> {
|
|
378
|
+
return getRequestSubdomains(this.request);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A single subdomain param, or null when absent.
|
|
383
|
+
*
|
|
384
|
+
* @category Request data
|
|
385
|
+
*/
|
|
386
|
+
subdomain(name: string): string | null {
|
|
387
|
+
return this.subdomains[name] ?? null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Test the request path against a glob-style pattern.
|
|
392
|
+
* `*` matches any sequence of characters except `/`.
|
|
393
|
+
* `**` matches any sequence including `/`.
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* ctx.is('/admin/*') // true for /admin/users, false for /posts
|
|
397
|
+
* ctx.is('/posts/**') // true for /posts/1/comments
|
|
398
|
+
*
|
|
399
|
+
* @category Request data
|
|
400
|
+
*/
|
|
401
|
+
is(pattern: string): boolean {
|
|
402
|
+
const regexSource =
|
|
403
|
+
"^" +
|
|
404
|
+
pattern
|
|
405
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
406
|
+
.replace(/\*\*/g, ".+")
|
|
407
|
+
.replace(/\*/g, "[^/]+") +
|
|
408
|
+
"$";
|
|
409
|
+
return new RegExp(regexSource).test(this.url.pathname);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Network helpers ───────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The raw client IP address taken directly from the TCP socket.
|
|
416
|
+
*
|
|
417
|
+
* Available when running inside `Bun.serve()` — returns `null` in tests
|
|
418
|
+
* or any context where the Bun server reference was not injected.
|
|
419
|
+
*
|
|
420
|
+
* When Bun sits behind a reverse proxy (nginx, Caddy, etc.) this returns
|
|
421
|
+
* the proxy's IP, not the end-user's. Use `ThrottleMiddleware`'s
|
|
422
|
+
* `trustedProxies` option together with `X-Forwarded-For` to resolve the
|
|
423
|
+
* real client IP in those deployments.
|
|
424
|
+
*
|
|
425
|
+
* @category Request data
|
|
426
|
+
*/
|
|
427
|
+
ip(): string | null {
|
|
428
|
+
return this._server?.requestIP(this.request)?.address ?? null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Retrieve a route-model-bound instance by its parameter name.
|
|
433
|
+
*
|
|
434
|
+
* The instance is resolved automatically by the framework before the controller
|
|
435
|
+
* runs, using the model bound to the param — implicitly by name, or with `.bind()` on the
|
|
436
|
+
* route. Throws if no binding was resolved for the given param name (which means
|
|
437
|
+
* either the route has no such param or no binding was registered).
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* // Route: Router.get('/users/:user', UserController, 'show')
|
|
441
|
+
*
|
|
442
|
+
* async show(ctx: HttpContext) {
|
|
443
|
+
* const user = ctx.model<User>('user');
|
|
444
|
+
* return ctx.json({ user });
|
|
445
|
+
* }
|
|
446
|
+
*
|
|
447
|
+
* @throws {Error} when no binding was resolved for `name` (the route has no
|
|
448
|
+
* such param, or nothing bound it).
|
|
449
|
+
* @category Request data
|
|
450
|
+
*/
|
|
451
|
+
model<T = unknown>(name: string): T {
|
|
452
|
+
if (!this._models.has(name)) {
|
|
453
|
+
throw new Error(
|
|
454
|
+
`[Zerotal] No model binding resolved for param "${name}". ` +
|
|
455
|
+
`Name the param after a model, or bind it with .bind('${name}', MyModel).`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
return this._models.get(name) as T;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── Header helpers ────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Retrieve a request header by name (case-insensitive).
|
|
465
|
+
* Returns `fallback` (default `null`) when the header is absent.
|
|
466
|
+
*
|
|
467
|
+
* @category Request data
|
|
468
|
+
*/
|
|
469
|
+
header(key: string, fallback: string | null = null): string | null {
|
|
470
|
+
return this.request.headers.get(key) ?? fallback;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Extract the Bearer token from the `Authorization` header.
|
|
475
|
+
* Returns `null` when absent or the header does not use the Bearer scheme.
|
|
476
|
+
*
|
|
477
|
+
* @category Request data
|
|
478
|
+
*/
|
|
479
|
+
bearerToken(): string | null {
|
|
480
|
+
const auth = this.request.headers.get("Authorization");
|
|
481
|
+
return auth?.startsWith("Bearer ") ? auth.slice(7) : null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── Content negotiation ───────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* True when the request sends a JSON body (`Content-Type: application/json`).
|
|
488
|
+
*
|
|
489
|
+
* @category Request data
|
|
490
|
+
*/
|
|
491
|
+
isJson(): boolean {
|
|
492
|
+
return (this.request.headers.get("Content-Type") ?? "").includes("application/json");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* True when the client expects a JSON response (`Accept: application/json`).
|
|
497
|
+
* Useful in exception handlers to decide between an error page and a JSON error.
|
|
498
|
+
*
|
|
499
|
+
* @category Request data
|
|
500
|
+
*/
|
|
501
|
+
wantsJson(): boolean {
|
|
502
|
+
return (this.request.headers.get("Accept") ?? "").includes("application/json");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ── Request input helpers ─────────────────────────────────────────────
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Read a URL query-string parameter by name.
|
|
509
|
+
* Returns `fallback` (default `undefined`) when the param is absent.
|
|
510
|
+
*
|
|
511
|
+
* @example
|
|
512
|
+
* const page = ctx.query('page', '1');
|
|
513
|
+
* const q = ctx.query('search');
|
|
514
|
+
*
|
|
515
|
+
* @category Request data
|
|
516
|
+
*/
|
|
517
|
+
query(key: string, fallback?: string): string | undefined {
|
|
518
|
+
return this.url.searchParams.get(key) ?? fallback;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Parse a query param or route param as an integer.
|
|
523
|
+
* Checks route params first, then the query string.
|
|
524
|
+
* Returns `fallback` when the value is absent or not a valid integer.
|
|
525
|
+
*
|
|
526
|
+
* @example
|
|
527
|
+
* const id = ctx.integer('id'); // route param
|
|
528
|
+
* const page = ctx.integer('page', 1); // query string with default
|
|
529
|
+
*
|
|
530
|
+
* @category Request data
|
|
531
|
+
*/
|
|
532
|
+
integer(key: string, fallback?: number): number | undefined {
|
|
533
|
+
const raw = (this.params[key] as string | undefined) ?? this.url.searchParams.get(key);
|
|
534
|
+
if (raw === undefined || raw === null) return fallback;
|
|
535
|
+
const parsed = parseInt(raw, 10);
|
|
536
|
+
return isNaN(parsed) ? fallback : parsed;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Read a query param or route param as a string.
|
|
541
|
+
* Checks route params first, then the query string.
|
|
542
|
+
*
|
|
543
|
+
* @example
|
|
544
|
+
* const slug = ctx.string('slug');
|
|
545
|
+
* const sort = ctx.string('sort', 'asc');
|
|
546
|
+
*
|
|
547
|
+
* @category Request data
|
|
548
|
+
*/
|
|
549
|
+
string(key: string, fallback?: string): string | undefined {
|
|
550
|
+
return (this.params[key] as string | undefined) ?? this.url.searchParams.get(key) ?? fallback;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Read a query param or route param coerced to a boolean.
|
|
555
|
+
* Truthy values: `'1'`, `'true'`, `'yes'`, `'on'` (case-insensitive).
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* const active = ctx.boolean('active', false);
|
|
559
|
+
*
|
|
560
|
+
* @category Request data
|
|
561
|
+
*/
|
|
562
|
+
boolean(key: string, fallback = false): boolean {
|
|
563
|
+
const raw = (this.params[key] as string | undefined) ?? this.url.searchParams.get(key);
|
|
564
|
+
if (raw === undefined || raw === null) return fallback;
|
|
565
|
+
return ["1", "true", "yes", "on"].includes(raw.toLowerCase());
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Parse and cache the JSON request body.
|
|
570
|
+
* Subsequent calls return the cached result — safe to call multiple times.
|
|
571
|
+
* Returns `{}` when the body is absent or not valid JSON.
|
|
572
|
+
*
|
|
573
|
+
* @example
|
|
574
|
+
* const { title, body } = await ctx.body<{ title: string; body: string }>();
|
|
575
|
+
*
|
|
576
|
+
* @category Request data
|
|
577
|
+
*/
|
|
578
|
+
async body<T extends Record<string, unknown> = Record<string, unknown>>(): Promise<T> {
|
|
579
|
+
if (this._body !== undefined) return this._body as T;
|
|
580
|
+
|
|
581
|
+
const contentType = this.request.headers.get("content-type") ?? "";
|
|
582
|
+
|
|
583
|
+
if (contentType.includes("application/json")) {
|
|
584
|
+
try {
|
|
585
|
+
this._body = (await this.request.json()) as Record<string, unknown>;
|
|
586
|
+
} catch {
|
|
587
|
+
this._body = {};
|
|
588
|
+
}
|
|
589
|
+
} else if (
|
|
590
|
+
contentType.includes("multipart/form-data") ||
|
|
591
|
+
contentType.includes("application/x-www-form-urlencoded")
|
|
592
|
+
) {
|
|
593
|
+
const formData = await this._parseFormData();
|
|
594
|
+
const fields: Record<string, unknown> = {};
|
|
595
|
+
if (formData) {
|
|
596
|
+
for (const [key, value] of formData.entries()) {
|
|
597
|
+
// File entries are handled separately by file() / files().
|
|
598
|
+
if (typeof value === "string") fields[key] = value;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
this._body = fields;
|
|
602
|
+
} else {
|
|
603
|
+
this._body = {};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return this._body as T;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private async _parseFormData(): Promise<FormData | null> {
|
|
610
|
+
if (this._formData !== undefined) return this._formData;
|
|
611
|
+
try {
|
|
612
|
+
this._formData = await this.request.formData();
|
|
613
|
+
} catch {
|
|
614
|
+
this._formData = null;
|
|
615
|
+
}
|
|
616
|
+
return this._formData;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** @internal Seed the body cache with already-parsed data, for middleware that reads the raw body. */
|
|
620
|
+
_primeBody(data: Record<string, unknown>): void {
|
|
621
|
+
this._body = data;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Return the first uploaded file for the given form field, or `null` if absent.
|
|
626
|
+
*
|
|
627
|
+
* Parses and caches the multipart body on first call.
|
|
628
|
+
*
|
|
629
|
+
* @example
|
|
630
|
+
* const avatar = await ctx.file('avatar');
|
|
631
|
+
* if (!avatar?.isValid({ maxSize: 2 * 1024 * 1024, mimes: ['image/jpeg', 'image/png'] })) {
|
|
632
|
+
* return redirect().back().withErrors({ avatar: 'Invalid file.' });
|
|
633
|
+
* }
|
|
634
|
+
* const path = await avatar.store('avatars', Storage.disk());
|
|
635
|
+
*
|
|
636
|
+
* @category Files & uploads
|
|
637
|
+
*/
|
|
638
|
+
async file(field: string): Promise<UploadedFile | null> {
|
|
639
|
+
const formData = await this._parseFormData();
|
|
640
|
+
if (!formData) return null;
|
|
641
|
+
const entry = formData.get(field);
|
|
642
|
+
return entry instanceof File ? new UploadedFile(entry) : null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Return all uploaded files for the given form field.
|
|
647
|
+
* Useful for `<input type="file" multiple>` inputs.
|
|
648
|
+
*
|
|
649
|
+
* @example
|
|
650
|
+
* const attachments = await ctx.files('attachments');
|
|
651
|
+
* for (const file of attachments) {
|
|
652
|
+
* await file.store('attachments', Storage.disk('s3'));
|
|
653
|
+
* }
|
|
654
|
+
*
|
|
655
|
+
* @category Files & uploads
|
|
656
|
+
*/
|
|
657
|
+
async files(field: string): Promise<UploadedFile[]> {
|
|
658
|
+
const formData = await this._parseFormData();
|
|
659
|
+
if (!formData) return [];
|
|
660
|
+
return formData
|
|
661
|
+
.getAll(field)
|
|
662
|
+
.filter((entry): entry is File => entry instanceof File)
|
|
663
|
+
.map((file) => new UploadedFile(file));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Read a value from the merged input bag in priority order:
|
|
668
|
+
* route params → cached JSON body → query string.
|
|
669
|
+
*
|
|
670
|
+
* Body data is only available here if `ctx.body()` was awaited earlier in
|
|
671
|
+
* the lifecycle (e.g. by a FormRequest or validate() call). For guaranteed
|
|
672
|
+
* body access, use `await ctx.body()` or a FormRequest.
|
|
673
|
+
*
|
|
674
|
+
* @example
|
|
675
|
+
* ctx.input('id') // route param :id or query ?id=
|
|
676
|
+
* ctx.input('q', 'all') // with fallback
|
|
677
|
+
*
|
|
678
|
+
* @category Request data
|
|
679
|
+
*/
|
|
680
|
+
input<T = unknown>(key: string, fallback?: T): T {
|
|
681
|
+
if (key in this.params) return this.params[key] as unknown as T;
|
|
682
|
+
if (this._body !== undefined && key in this._body) return this._body[key] as T;
|
|
683
|
+
const queryValue = this.url.searchParams.get(key);
|
|
684
|
+
if (queryValue !== null) return queryValue as unknown as T;
|
|
685
|
+
return fallback as T;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── After-response API ────────────────────────────────────────────────
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Register a callback to fire after the Response is sent to the client.
|
|
692
|
+
* Calls container.acquire() synchronously at registration time — before any
|
|
693
|
+
* await — so the request's finally block cannot flush the scope before the
|
|
694
|
+
* callback has a chance to run. This ordering fixes a scope-flush race.
|
|
695
|
+
*
|
|
696
|
+
* @example
|
|
697
|
+
* ctx.afterResponse(async () => {
|
|
698
|
+
* await Analytics.track('page_view', { path: ctx.path() });
|
|
699
|
+
* });
|
|
700
|
+
*
|
|
701
|
+
* @category Lifecycle
|
|
702
|
+
*/
|
|
703
|
+
afterResponse(callback: () => Promise<void>): this {
|
|
704
|
+
// acquire() MUST be called synchronously here — before the callback is stored
|
|
705
|
+
this.container.acquire();
|
|
706
|
+
|
|
707
|
+
this._afterResponseCallbacks.push(async () => {
|
|
708
|
+
try {
|
|
709
|
+
await callback();
|
|
710
|
+
} catch (error) {
|
|
711
|
+
// An afterResponse error must never crash the server, so log and swallow it.
|
|
712
|
+
console.error("[Zerotal] afterResponse callback failed:", error);
|
|
713
|
+
} finally {
|
|
714
|
+
// Release the reference — _doFlush() fires when count reaches 0
|
|
715
|
+
this.container.release();
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
return this;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Register a callback that runs once the final `Response` exists, before it is
|
|
724
|
+
* returned to the client — the last point at which a header can still be set.
|
|
725
|
+
*
|
|
726
|
+
* This is what a middleware needs when its work has to land on *every*
|
|
727
|
+
* response, including one produced by the exception handler. A middleware's own
|
|
728
|
+
* `finally` block cannot do that: when a handler throws, the pipeline unwinds
|
|
729
|
+
* before any response has been built, so there is nothing to write to. Sessions
|
|
730
|
+
* are the motivating case — a `Set-Cookie` that only appears on the success path
|
|
731
|
+
* silently drops flashed validation errors on the redirect that carries them.
|
|
732
|
+
*
|
|
733
|
+
* Finalizers run in registration order and are awaited. An error thrown by one
|
|
734
|
+
* is logged and swallowed, because a failed finalizer must not turn a rendered
|
|
735
|
+
* response into a crash.
|
|
736
|
+
*
|
|
737
|
+
* @example
|
|
738
|
+
* ctx.onResponseReady(async (response) => {
|
|
739
|
+
* response.headers.set('X-Request-Id', ctx.requestId);
|
|
740
|
+
* });
|
|
741
|
+
*
|
|
742
|
+
* @category Lifecycle
|
|
743
|
+
*/
|
|
744
|
+
onResponseReady(callback: (response: Response) => Promise<void>): this {
|
|
745
|
+
this._responseFinalizers.push(callback);
|
|
746
|
+
return this;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ── Internal meta store (typed via ContextRegistry) ───────────────────
|
|
750
|
+
// The backing store is a private Map; the accessors below are typed against
|
|
751
|
+
// the declaration-merged {@link ContextRegistry}, so a registered key carries
|
|
752
|
+
// its value type and any other string key falls back to `unknown`.
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Stash a value on the request-scoped meta store for framework packages.
|
|
756
|
+
* A key registered on {@link ContextRegistry} is type-checked against its
|
|
757
|
+
* declared value; any other string key accepts `unknown`.
|
|
758
|
+
* @internal
|
|
759
|
+
*/
|
|
760
|
+
setInternal<K extends ContextKey>(key: K, value: ContextRegistry[K]): this;
|
|
761
|
+
setInternal(key: string, value: unknown): this;
|
|
762
|
+
setInternal(key: string, value: unknown): this {
|
|
763
|
+
this._meta.set(key, value);
|
|
764
|
+
return this;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Read a value previously stored with {@link setInternal}. Returns the typed
|
|
769
|
+
* value for a registered key, or `undefined` when absent.
|
|
770
|
+
* @internal
|
|
771
|
+
*/
|
|
772
|
+
getInternal<K extends ContextKey>(key: K): ContextRegistry[K] | undefined;
|
|
773
|
+
getInternal<T = unknown>(key: string): T | undefined;
|
|
774
|
+
getInternal(key: string): unknown {
|
|
775
|
+
return this._meta.get(key);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** @internal Report whether a value is present under `key`. */
|
|
779
|
+
hasInternal(key: ContextKey | (string & {})): boolean {
|
|
780
|
+
return this._meta.has(key);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** @internal Remove a value from the request-scoped meta store. */
|
|
784
|
+
deleteInternal(key: ContextKey | (string & {})): this {
|
|
785
|
+
this._meta.delete(key);
|
|
786
|
+
return this;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ── Test factory ──────────────────────────────────────────────────────
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Create a fake HttpContext for unit tests.
|
|
793
|
+
* Does not require a running server.
|
|
794
|
+
*
|
|
795
|
+
* @example
|
|
796
|
+
* const ctx = HttpContext.fake('http://localhost/posts?page=2');
|
|
797
|
+
* expect(ctx.integer('page')).toBe(2);
|
|
798
|
+
*
|
|
799
|
+
* @category Lifecycle
|
|
800
|
+
*/
|
|
801
|
+
static fake(
|
|
802
|
+
url = "http://localhost/",
|
|
803
|
+
init: RequestInit = {},
|
|
804
|
+
container?: ScopedResolver,
|
|
805
|
+
): HttpContext {
|
|
806
|
+
if (!container) {
|
|
807
|
+
container = new ScopedResolver(new Container());
|
|
808
|
+
}
|
|
809
|
+
return new HttpContext(new Request(url, init), container);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ── Ambient access ────────────────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* The current request's `HttpContext`, or `undefined` outside a request.
|
|
816
|
+
* Safe in code that runs both in and out of requests (CLI commands, queue
|
|
817
|
+
* workers, scheduled jobs).
|
|
818
|
+
*
|
|
819
|
+
* @category Lifecycle
|
|
820
|
+
*/
|
|
821
|
+
static tryGet(): HttpContext | undefined {
|
|
822
|
+
return RequestContext.tryGet();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* The bindable prototype methods of {@link HttpContext}, computed once per
|
|
828
|
+
* process instead of once per request. Getters (took, subdomains) have no
|
|
829
|
+
* `.value` and are skipped, so they still evaluate lazily on property access.
|
|
830
|
+
* The constructor only performs the per-instance `.bind()` over this list.
|
|
831
|
+
*/
|
|
832
|
+
const _prototypeMethods: ReadonlyArray<[string, (...args: unknown[]) => unknown]> = (() => {
|
|
833
|
+
const proto = HttpContext.prototype;
|
|
834
|
+
const methods: Array<[string, (...args: unknown[]) => unknown]> = [];
|
|
835
|
+
for (const key of Object.getOwnPropertyNames(proto)) {
|
|
836
|
+
if (key === "constructor") continue;
|
|
837
|
+
const desc = Object.getOwnPropertyDescriptor(proto, key)!;
|
|
838
|
+
if (typeof desc.value === "function") {
|
|
839
|
+
methods.push([key, desc.value as (...args: unknown[]) => unknown]);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return methods;
|
|
843
|
+
})();
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Return `url` only when it belongs to the same `origin`, otherwise `undefined`.
|
|
847
|
+
* Guards `ctx.back()` and similar helpers against open-redirect attacks by
|
|
848
|
+
* rejecting absent, malformed, or cross-origin URLs.
|
|
849
|
+
*
|
|
850
|
+
* @param url - Candidate redirect target (e.g. a `Referer` header value).
|
|
851
|
+
* @param origin - The current request origin to match against.
|
|
852
|
+
* @returns The same-origin `url`, or `undefined` when it is absent, unparseable,
|
|
853
|
+
* or points to a different origin.
|
|
854
|
+
*/
|
|
855
|
+
export function safeRedirectPath(
|
|
856
|
+
url: string | null | undefined,
|
|
857
|
+
origin: string,
|
|
858
|
+
): string | undefined {
|
|
859
|
+
if (!url) return undefined;
|
|
860
|
+
try {
|
|
861
|
+
return new URL(url).origin === origin ? url : undefined;
|
|
862
|
+
} catch {
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
}
|