@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.
Files changed (201) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +128 -0
  4. package/package.json +72 -0
  5. package/src/application/Application.ts +1671 -0
  6. package/src/application/BootDoctor.ts +108 -0
  7. package/src/application/DevErrorPage.ts +567 -0
  8. package/src/application/ExceptionHandler.ts +183 -0
  9. package/src/application/currentApp.ts +73 -0
  10. package/src/assets/assets.ts +79 -0
  11. package/src/assets/index.ts +16 -0
  12. package/src/auth/AuthenticatedUser.ts +18 -0
  13. package/src/build/PackageLinter.ts +146 -0
  14. package/src/build/PackageScaffold.ts +127 -0
  15. package/src/build/codemod.ts +64 -0
  16. package/src/build/index.ts +12 -0
  17. package/src/command/Command.ts +254 -0
  18. package/src/command/CommandRunner.ts +593 -0
  19. package/src/command/OutputWriter.ts +61 -0
  20. package/src/command/builtin/CompileCommand.ts +46 -0
  21. package/src/command/builtin/CssBuildCommand.ts +71 -0
  22. package/src/command/builtin/KeyGenerateCommand.ts +58 -0
  23. package/src/command/builtin/LintPackagesCommand.ts +72 -0
  24. package/src/command/builtin/MakeCommandCommand.ts +85 -0
  25. package/src/command/builtin/MakeControllerCommand.ts +95 -0
  26. package/src/command/builtin/MakeEventCommand.ts +85 -0
  27. package/src/command/builtin/MakeJobCommand.ts +53 -0
  28. package/src/command/builtin/MakeListenerCommand.ts +35 -0
  29. package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
  30. package/src/command/builtin/MakeNotificationCommand.ts +48 -0
  31. package/src/command/builtin/MakeObserverCommand.ts +78 -0
  32. package/src/command/builtin/MakePackageCommand.ts +45 -0
  33. package/src/command/builtin/MakePolicyCommand.ts +66 -0
  34. package/src/command/builtin/MakeProviderCommand.ts +75 -0
  35. package/src/command/builtin/MakeRequestCommand.ts +47 -0
  36. package/src/command/builtin/MakeResourceCommand.ts +61 -0
  37. package/src/command/builtin/MakeTestCommand.ts +120 -0
  38. package/src/command/builtin/ReloadCommand.ts +52 -0
  39. package/src/command/builtin/ReplCommand.ts +174 -0
  40. package/src/command/builtin/RouteListCommand.ts +188 -0
  41. package/src/command/builtin/ServeCommand.ts +321 -0
  42. package/src/command/builtin/StartCommand.ts +3 -0
  43. package/src/command/builtin/StatusCommand.ts +71 -0
  44. package/src/command/builtin/TestCommand.ts +172 -0
  45. package/src/command/builtin/WorkerCommand.ts +27 -0
  46. package/src/command/builtin/index.ts +53 -0
  47. package/src/command/scaffold/worker.ts.txt +12 -0
  48. package/src/command/scaffold/zerotal.ts.txt +26 -0
  49. package/src/command/startZerotal.ts +55 -0
  50. package/src/config/AppConfig.ts +253 -0
  51. package/src/config/ConfigLoader.ts +117 -0
  52. package/src/config/ConfigManager.ts +169 -0
  53. package/src/config/index.ts +46 -0
  54. package/src/config/registry.ts +59 -0
  55. package/src/config/validation.ts +117 -0
  56. package/src/container/Container.ts +606 -0
  57. package/src/container/ContextualBindingBuilder.ts +57 -0
  58. package/src/container/ScopedResolver.ts +117 -0
  59. package/src/container/index.ts +32 -0
  60. package/src/container/inject.ts +55 -0
  61. package/src/container/types.ts +71 -0
  62. package/src/context/RequestContext.ts +91 -0
  63. package/src/contracts/auth.ts +24 -0
  64. package/src/contracts/index.ts +23 -0
  65. package/src/contracts/session.ts +70 -0
  66. package/src/contracts/transaction.ts +26 -0
  67. package/src/conventions/ConventionLoader.ts +128 -0
  68. package/src/conventions/builtinConcerns.ts +131 -0
  69. package/src/crypt/Crypt.ts +141 -0
  70. package/src/crypt/URLSigner.ts +96 -0
  71. package/src/datetime/Carbon.ts +1396 -0
  72. package/src/datetime/CarbonInterval.ts +421 -0
  73. package/src/datetime/clock.ts +28 -0
  74. package/src/datetime/index.ts +23 -0
  75. package/src/datetime/temporal-shim.ts +1 -0
  76. package/src/dev/BuildOutput.ts +131 -0
  77. package/src/dev/CssPlugins.ts +184 -0
  78. package/src/dev/DevBuildHook.ts +74 -0
  79. package/src/dev/DevOrchestrator.ts +213 -0
  80. package/src/dev/DevReloadMiddleware.ts +101 -0
  81. package/src/dev/DevReloadServer.ts +85 -0
  82. package/src/dev/DevWsServer.ts +45 -0
  83. package/src/dev/index.ts +19 -0
  84. package/src/dev/reloadClient.ts +39 -0
  85. package/src/env/Def.ts +232 -0
  86. package/src/env/EnvSchema.ts +105 -0
  87. package/src/env/index.ts +34 -0
  88. package/src/env/t.ts +128 -0
  89. package/src/errors/ConfigError.ts +12 -0
  90. package/src/errors/ContainerErrors.ts +143 -0
  91. package/src/errors/HttpError.ts +127 -0
  92. package/src/errors/ValidationError.ts +19 -0
  93. package/src/errors/ZerotalError.ts +25 -0
  94. package/src/errors/index.ts +46 -0
  95. package/src/events/CallQueuedListener.ts +66 -0
  96. package/src/events/Emitter.ts +280 -0
  97. package/src/events/EventFake.ts +160 -0
  98. package/src/events/FrameworkEvents.ts +252 -0
  99. package/src/facade/Facade.ts +101 -0
  100. package/src/facade/facades/App.ts +155 -0
  101. package/src/facade/facades/Artisan.ts +63 -0
  102. package/src/facade/facades/Config.ts +21 -0
  103. package/src/facade/facades/Events.ts +19 -0
  104. package/src/facade/facades/index.ts +28 -0
  105. package/src/global.d.ts +9 -0
  106. package/src/hash/Hash.ts +60 -0
  107. package/src/health/Health.ts +221 -0
  108. package/src/health/index.ts +27 -0
  109. package/src/helpers/Collection.ts +435 -0
  110. package/src/helpers/config.ts +59 -0
  111. package/src/helpers/fluent.ts +52 -0
  112. package/src/helpers/html.ts +11 -0
  113. package/src/helpers/index.ts +266 -0
  114. package/src/helpers/make.ts +35 -0
  115. package/src/helpers/markdown.ts +73 -0
  116. package/src/helpers/pageElements.ts +27 -0
  117. package/src/helpers/request.ts +62 -0
  118. package/src/helpers/response.ts +411 -0
  119. package/src/helpers/str.ts +208 -0
  120. package/src/http/Http.ts +298 -0
  121. package/src/http/HttpClient.ts +289 -0
  122. package/src/http/Resource.ts +171 -0
  123. package/src/http/UploadedFile.ts +204 -0
  124. package/src/http/Uri.ts +490 -0
  125. package/src/http/index.ts +46 -0
  126. package/src/http/negotiate.ts +213 -0
  127. package/src/http/originGuard.ts +76 -0
  128. package/src/http/sniffContentType.ts +105 -0
  129. package/src/http/url.ts +204 -0
  130. package/src/http/withHeaders.ts +24 -0
  131. package/src/index.ts +250 -0
  132. package/src/lock/LockManager.ts +228 -0
  133. package/src/lock/config.ts +49 -0
  134. package/src/lock/drivers/LockDriver.ts +32 -0
  135. package/src/lock/drivers/MemoryLockDriver.ts +52 -0
  136. package/src/lock/drivers/RedisLockDriver.ts +58 -0
  137. package/src/lock/drivers/SqliteLockDriver.ts +85 -0
  138. package/src/lock/errors.ts +20 -0
  139. package/src/lock/facades/Lock.ts +114 -0
  140. package/src/lock/index.ts +53 -0
  141. package/src/logger/Log.ts +35 -0
  142. package/src/logger/LogManager.ts +430 -0
  143. package/src/logger/LoggerMiddleware.ts +125 -0
  144. package/src/logger/channels/ConsoleChannel.ts +139 -0
  145. package/src/logger/channels/DailyChannel.ts +74 -0
  146. package/src/logger/channels/NullChannel.ts +17 -0
  147. package/src/logger/channels/SingleChannel.ts +34 -0
  148. package/src/logger/channels/StackChannel.ts +29 -0
  149. package/src/logger/config.ts +90 -0
  150. package/src/logger/format.ts +96 -0
  151. package/src/logger/frameworkLog.ts +93 -0
  152. package/src/logger/index.ts +68 -0
  153. package/src/logger/renderTable.ts +111 -0
  154. package/src/logger/types.ts +212 -0
  155. package/src/macros/config.macro.ts +50 -0
  156. package/src/metrics/HttpMetrics.ts +114 -0
  157. package/src/metrics/index.ts +18 -0
  158. package/src/middleware/BaseMiddleware.ts +72 -0
  159. package/src/middleware/CorsMiddleware.ts +152 -0
  160. package/src/middleware/RateLimiter.ts +255 -0
  161. package/src/middleware/SecureHeadersMiddleware.ts +127 -0
  162. package/src/middleware/ThrottleMiddleware.ts +252 -0
  163. package/src/middleware/WebhookMiddleware.ts +204 -0
  164. package/src/pipeline/ContextRegistry.ts +42 -0
  165. package/src/pipeline/HttpContext.ts +865 -0
  166. package/src/pipeline/Pipeline.ts +150 -0
  167. package/src/pipeline/currentPage.ts +46 -0
  168. package/src/pipeline/types.ts +80 -0
  169. package/src/provider/LockProvider.ts +64 -0
  170. package/src/provider/LogProvider.ts +137 -0
  171. package/src/provider/ServiceProvider.ts +84 -0
  172. package/src/provider/StorageProvider.ts +45 -0
  173. package/src/router/FileRouter.ts +526 -0
  174. package/src/router/Route.ts +76 -0
  175. package/src/router/RouteHandler.ts +335 -0
  176. package/src/router/Router.ts +1247 -0
  177. package/src/router/domain.ts +65 -0
  178. package/src/security/index.ts +22 -0
  179. package/src/storage/FakeDisk.ts +233 -0
  180. package/src/storage/StorageFilesMiddleware.ts +150 -0
  181. package/src/storage/StorageManager.ts +173 -0
  182. package/src/storage/config.ts +47 -0
  183. package/src/storage/drivers/LocalDriver.ts +138 -0
  184. package/src/storage/drivers/S3Driver.ts +169 -0
  185. package/src/storage/errors.ts +135 -0
  186. package/src/storage/facades/Storage.ts +3 -0
  187. package/src/storage/global.d.ts +7 -0
  188. package/src/storage/index.ts +22 -0
  189. package/src/storage/root.ts +59 -0
  190. package/src/storage/types.ts +104 -0
  191. package/src/support/appKey.ts +38 -0
  192. package/src/support/cookie.ts +72 -0
  193. package/src/support/crypto.ts +52 -0
  194. package/src/support/deepMerge.ts +117 -0
  195. package/src/support/env.ts +71 -0
  196. package/src/support/network.ts +79 -0
  197. package/src/support/port.ts +197 -0
  198. package/src/support/str.ts +122 -0
  199. package/src/view/FileRouteResolver.ts +59 -0
  200. package/src/view/index.ts +144 -0
  201. 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
+ }