@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,1247 @@
1
+ /**
2
+ * The global route registry and fluent registration API. Collects route, view,
3
+ * resource, static, and markdown definitions at boot, then compiles them into a
4
+ * `Bun.serve()`-compatible routes object with domain matching and model binding.
5
+ */
6
+ import type { Container } from "../container/Container.ts";
7
+ import type {
8
+ RouteDefinition,
9
+ HttpMethod,
10
+ ControllerClass,
11
+ MiddlewareClass,
12
+ FileHandler,
13
+ RouteHandler,
14
+ ModelClass,
15
+ ModelBindingResolver,
16
+ ViewLayout,
17
+ } from "./Route.ts";
18
+ import type { HttpContext } from "../pipeline/HttpContext.ts";
19
+ import type { ExceptionHandler } from "../application/ExceptionHandler.ts";
20
+ import { createRouteHandler } from "./RouteHandler.ts";
21
+ import { compileDomain, matchDomain, setRequestSubdomains } from "./domain.ts";
22
+ import type { ProviderHooks } from "./RouteHandler.ts";
23
+ import { tryCurrentApp } from "../application/currentApp.ts";
24
+ import { frameworkLog } from "../logger/frameworkLog.ts";
25
+ import {
26
+ markdownExtractTitle,
27
+ markdownPage,
28
+ DEFAULT_MD_OPTIONS,
29
+ type BunMarkdownOptions,
30
+ } from "../helpers/markdown.ts";
31
+
32
+ /**
33
+ * Augmentable by external packages. Declare additional methods here with
34
+ * `declare module '@zerotal/core' { interface RouterMacros { ... } }`.
35
+ *
36
+ * The `Route` export from '@zerotal/core' is typed as `typeof Router & RouterMacros`
37
+ * so every declared macro is callable as a static method.
38
+ */
39
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- intentional augmentation target: external packages merge macros in via `declare module`.
40
+ export interface RouterMacros {}
41
+
42
+ // ── Handler wrappers ──────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Wrap a single-arg handler function in a synthetic controller class with a
46
+ * `handle` action, so closure-style routes flow through the standard
47
+ * `createRouteHandler` pipeline unchanged. `label` becomes the class name shown
48
+ * in route:list and error traces.
49
+ */
50
+ function _wrapHandler(fn: FileHandler, label: string): ControllerClass {
51
+ const handlerController = class {
52
+ async handle(args: unknown): Promise<unknown> {
53
+ // Handler functions receive the request HttpContext, same as controller actions.
54
+ return (fn as (args: unknown) => unknown)(args);
55
+ }
56
+ };
57
+ Object.defineProperty(handlerController, "name", { value: label });
58
+ return handlerController as unknown as ControllerClass;
59
+ }
60
+
61
+ function _wrapFileHandler(fn: FileHandler, debugName: string): ControllerClass {
62
+ return _wrapHandler(fn, `FileRoute<${debugName}>`);
63
+ }
64
+
65
+ /**
66
+ * Loose handler type used only by the verb-method *implementation* signatures.
67
+ * The public overloads carry the precise, generic `RouteHandler<T>` typing; the
68
+ * implementation just needs a supertype of every `RouteHandler<T>` a caller may pass.
69
+ */
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- supertype of every RouteHandler<T> the public overloads accept.
71
+ type AnyRouteHandler = RouteHandler<any>;
72
+
73
+ type RouteHandlerFn = (req: Request, server?: unknown) => Response | Promise<Response>;
74
+ /**
75
+ * A path entry is either a method-keyed map of handlers, or a bare static
76
+ * `Response` (Bun.serve serves the latter at zero JS cost per request).
77
+ */
78
+ type CompiledRoutes = Record<string, Record<string, RouteHandlerFn> | Response>;
79
+
80
+ /** Options for {@link Router.static}. */
81
+ export interface StaticOptions {
82
+ /** Extra response headers applied to every served file (e.g. Cache-Control). */
83
+ headers?: Record<string, string>;
84
+ /**
85
+ * When true (default) every file in the directory is pre-registered at
86
+ * compile() time as a static `Response`, so Bun serves it without invoking
87
+ * JS. Set false to fall back to the per-request lookup in Application.fetch.
88
+ */
89
+ eager?: boolean;
90
+ }
91
+
92
+ type StaticDir = { prefix: string; rootDir: string; options?: StaticOptions | undefined };
93
+ type MarkdownDir = {
94
+ prefix: string;
95
+ rootDir: string;
96
+ options?: (BunMarkdownOptions & { title?: string }) | undefined;
97
+ };
98
+
99
+ /** Options for Router.group(). All fields are optional. */
100
+ export interface GroupOptions {
101
+ /** URL prefix applied to every route registered inside the group. */
102
+ prefix?: string;
103
+ /**
104
+ * Middleware to prepend to every route inside the group.
105
+ * Accepts a named group (string), an array of names/classes, or middleware class(es) directly.
106
+ *
107
+ * @example
108
+ * Router.group({ middleware: 'api' }, () => { ... });
109
+ * Router.group({ middleware: ['web', AuthMiddleware] }, () => { ... });
110
+ */
111
+ middleware?: string | string[] | MiddlewareClass | MiddlewareClass[];
112
+ /** Host pattern; routes inside the group only match this host (e.g. ':tenant.app.com'). */
113
+ domain?: string;
114
+ }
115
+
116
+ // ── Internal helpers ──────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Convert a model class (has static `findOrFail`) or a raw resolver function
120
+ * into a `ModelBindingResolver`. Class constructors are functions in JS, so we
121
+ * distinguish them by the presence of the `findOrFail` property.
122
+ */
123
+ function _toResolver(modelOrResolver: ModelClass | ModelBindingResolver): ModelBindingResolver {
124
+ if (typeof modelOrResolver === "function" && "findOrFail" in modelOrResolver) {
125
+ return (value: string) =>
126
+ (modelOrResolver as unknown as ModelClass).findOrFail(
127
+ /^\d+$/.test(value) ? Number(value) : value,
128
+ );
129
+ }
130
+ return modelOrResolver as ModelBindingResolver;
131
+ }
132
+
133
+ /**
134
+ * All mutable router state in one swappable object: registered routes, the
135
+ * active group prefix/middleware/domain, static and markdown directories, named
136
+ * routes, and named middleware groups.
137
+ */
138
+ export class RouterState {
139
+ routes = new Map<string, RouteDefinition>();
140
+ prefix = "";
141
+ domain: string | undefined = undefined;
142
+ staticDirs: StaticDir[] = [];
143
+ markdownDirs: MarkdownDir[] = [];
144
+ groupMiddleware: MiddlewareClass[] = [];
145
+ rawRoutes = new Map<string, (req: Request) => Response | Promise<Response>>();
146
+ namedRoutes = new Map<string, string>();
147
+ middlewareGroups = new Map<string, MiddlewareClass[]>();
148
+ /**
149
+ * Resolver contributed by the ORM (via {@link setImplicitModelResolver}) that maps a route
150
+ * parameter name to a model-binding resolver, or `undefined` when no model claims it. Consulted
151
+ * at compile() time for any `:param` without an explicit `.bind()`.
152
+ */
153
+ implicitModelResolver: ((paramName: string) => ModelBindingResolver | undefined) | null = null;
154
+ }
155
+
156
+ /**
157
+ * The routing table lives on the current {@link Application} (its
158
+ * `routerState`), reached through {@link currentApp}. A module-level fallback
159
+ * serves the appless case — `Router` used in a unit test, or before an
160
+ * application is created — so the static API works standalone.
161
+ */
162
+ const _fallbackState = new RouterState();
163
+ function _s(): RouterState {
164
+ return tryCurrentApp()?.routerState ?? _fallbackState;
165
+ }
166
+
167
+ // ── Implicit model binding ────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Register the resolver that powers implicit route-model binding on the current
171
+ * application's routing state. Called by the ORM's `DatabaseProvider`; apps never
172
+ * call this directly. Pass `null` to disable.
173
+ *
174
+ * @internal
175
+ */
176
+ export function setImplicitModelResolver(
177
+ fn: ((paramName: string) => ModelBindingResolver | undefined) | null,
178
+ ): void {
179
+ _s().implicitModelResolver = fn;
180
+ }
181
+
182
+ /**
183
+ * The registry key for a route.
184
+ *
185
+ * The domain is part of the key, not just the definition, because two `Router.group({ domain })`
186
+ * blocks routinely register the same method and path for different hosts. Keying on
187
+ * `method path` alone collapsed them onto one entry, so a later public group silently erased
188
+ * an earlier admin group's routes *and its auth middleware*, and the survivor answered on any
189
+ * host. Only `_register` carried the domain; `_registerAbsolute` (resource routes) and
190
+ * `_registerFileHandler` (view + file routes) did not, which is why they were the ones that
191
+ * lost it. `\0` separates because it cannot occur in a path or a host.
192
+ */
193
+ function _routeKey(method: HttpMethod, fullPath: string, domain: string | undefined): string {
194
+ return domain ? `${method} ${fullPath}\u0000${domain}` : `${method} ${fullPath}`;
195
+ }
196
+
197
+ /**
198
+ * Derive a `HEAD` handler from a route's `GET` handler.
199
+ *
200
+ * `HEAD` must answer with the status and headers `GET` would, and no body (RFC 9110 §9.3.2).
201
+ * Bun does not derive it, and `HttpMethod` has no `HEAD` member — so every uptime monitor,
202
+ * load-balancer probe, CDN origin check and `curl -I` against a Zerotal app got a 404. Running
203
+ * the real handler and dropping the body is the only way to keep the headers honest;
204
+ * `Content-Length` is preserved explicitly, since constructing a bodiless Response otherwise
205
+ * loses it.
206
+ */
207
+ function _headFrom(get: RouteHandlerFn): RouteHandlerFn {
208
+ return async (req: Request, server?: unknown): Promise<Response> => {
209
+ const response = await get(req, server);
210
+ const body = await response.arrayBuffer();
211
+ const headers = new Headers(response.headers);
212
+ headers.set("Content-Length", String(body.byteLength));
213
+ return new Response(null, {
214
+ status: response.status,
215
+ statusText: response.statusText,
216
+ headers,
217
+ });
218
+ };
219
+ }
220
+
221
+ /** Extract `:param` names from a route path pattern. */
222
+ function _pathParams(path: string): string[] {
223
+ return [...path.matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)].map((match) => match[1]!);
224
+ }
225
+
226
+ /**
227
+ * Returned by Router.get/post/put/delete/patch — allows chaining .name() and .bind()
228
+ * to register the route for URL generation and model binding.
229
+ *
230
+ * @example
231
+ * Router.get('/posts/:slug', PostController, 'show').name('posts.show');
232
+ * route('posts.show', { slug: 'hello' }); // → '/posts/hello'
233
+ *
234
+ * Router.get('/users/:user', UserController, 'show').bind('user', User);
235
+ * // controller: const user = ctx.model<User>('user');
236
+ */
237
+ export interface RouteRegistration {
238
+ /**
239
+ * Name this route so it can be resolved to a URL with the {@link route} helper.
240
+ *
241
+ * @param routeName - Dot-notation name, e.g. `'posts.show'`.
242
+ */
243
+ name(routeName: string): RouteRegistration;
244
+ /**
245
+ * Attach a model binding to a specific route parameter.
246
+ *
247
+ * When a request matches this route, the framework calls `Model.findOrFail(id)`
248
+ * before the controller runs and stores the result in `ctx.model('paramName')`.
249
+ * If the record does not exist, a `ModelNotFoundError` (404) is thrown automatically.
250
+ *
251
+ * @param paramName The `:param` segment name (without the colon).
252
+ * @param model A model class with a `static findOrFail(id)` method,
253
+ * OR a custom async resolver `(value, ctx) => Promise<T>`.
254
+ *
255
+ * @example
256
+ * // Model class (uses findOrFail internally):
257
+ * Router.get('/users/:user', UserController, 'show').bind('user', User);
258
+ *
259
+ * // Custom resolver — resolve by slug instead of id:
260
+ * Router.get('/posts/:post', PostController, 'show')
261
+ * .bind('post', (value) => Post.where('slug', value).firstOrFail());
262
+ */
263
+ bind(paramName: string, model: ModelClass | ModelBindingResolver): RouteRegistration;
264
+ }
265
+
266
+ /** Returned by {@link Router.view} — chain `.name()` and `.withLayout()` to configure the view route. */
267
+ export interface ViewRegistration {
268
+ /** Name this view route for URL generation via the {@link route} helper. */
269
+ name(routeName: string): ViewRegistration;
270
+ /** Wrap the rendered component in a layout that receives it as `children`. */
271
+ withLayout(layout: ViewLayout): ViewRegistration;
272
+ }
273
+
274
+ /**
275
+ * Generate a URL for a named route, substituting :param segments.
276
+ * Extra params that don't match a :segment become query-string entries.
277
+ *
278
+ * @throws {Error} when the route name is unknown or a required `:param` is missing.
279
+ *
280
+ * @example
281
+ * route('posts.show', { slug: 'hello' }) // '/posts/hello'
282
+ * route('search', { q: 'reno', page: 2 }) // '/search?q=reno&page=2'
283
+ *
284
+ * @category Naming & URLs
285
+ */
286
+ export function route(name: string, params: Record<string, string | number> = {}): string {
287
+ const pattern = _s().namedRoutes.get(name);
288
+ if (pattern === undefined) {
289
+ throw new Error(`[Zerotal] Named route not found: "${name}"`);
290
+ }
291
+
292
+ const usedKeys = new Set<string>();
293
+ const url = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key: string) => {
294
+ const value = params[key];
295
+ if (value === undefined) {
296
+ throw new Error(`[Zerotal] Missing parameter "${key}" for route "${name}"`);
297
+ }
298
+ usedKeys.add(key);
299
+ // Encode path params so values containing `/ ? #` cannot mangle the URL.
300
+ return encodeURIComponent(String(value));
301
+ });
302
+
303
+ const queryParams = Object.entries(params)
304
+ .filter(([key]) => !usedKeys.has(key))
305
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
306
+
307
+ return queryParams.length > 0 ? `${url}?${queryParams.join("&")}` : url;
308
+ }
309
+
310
+ /**
311
+ * Global static router. Collects route definitions at boot time and compiles
312
+ * them into a Bun.serve()-compatible routes object.
313
+ *
314
+ * Routes are stored in a module-level Map (singleton pattern).
315
+ * Call Router.reset() between tests to clear state.
316
+ *
317
+ * @example
318
+ * Router.get('/users', UserController, 'index');
319
+ * Router.post('/users', UserController, 'store');
320
+ * Router.get('/posts/:slug', PostController, 'show').name('posts.show');
321
+ * Router.resource('comments', CommentController);
322
+ *
323
+ * // In Application.start():
324
+ * const routes = Router.compile(container, globalMiddleware);
325
+ */
326
+ export class Router {
327
+ /**
328
+ * The live routing state — the current application's `routerState` (or the
329
+ * standalone fallback when no application is active).
330
+ *
331
+ * @category Introspection
332
+ */
333
+ static get state(): RouterState {
334
+ return _s();
335
+ }
336
+
337
+ /** @internal Current group prefix — read by FileRouter to support groupAsync wrapping. */
338
+ static get _activePrefix(): string {
339
+ return _s().prefix;
340
+ }
341
+ /** @internal Current group middleware — read by FileRouter to support groupAsync wrapping. */
342
+ static get _activeMiddleware(): MiddlewareClass[] {
343
+ return _s().groupMiddleware;
344
+ }
345
+
346
+ // ── Internal helpers ──────────────────────────────────────────────────
347
+
348
+ /** Resolve a middleware option (named group / class / array) to a flat class array. */
349
+ private static _resolveMiddleware(
350
+ middleware: string | string[] | MiddlewareClass | MiddlewareClass[],
351
+ ): MiddlewareClass[] {
352
+ if (typeof middleware === "string") {
353
+ return _s().middlewareGroups.get(middleware) ?? [];
354
+ }
355
+ if (typeof middleware === "function") {
356
+ return [middleware];
357
+ }
358
+ const result: MiddlewareClass[] = [];
359
+ for (const item of middleware as (string | MiddlewareClass)[]) {
360
+ if (typeof item === "string") {
361
+ result.push(...(_s().middlewareGroups.get(item) ?? []));
362
+ } else {
363
+ result.push(item);
364
+ }
365
+ }
366
+ return result;
367
+ }
368
+
369
+ private static _register(
370
+ method: HttpMethod,
371
+ path: string,
372
+ controller: ControllerClass,
373
+ action: string,
374
+ middleware: MiddlewareClass[] = [],
375
+ ): RouteRegistration {
376
+ const fullPath = _s().prefix + path;
377
+ const domain = _s().domain;
378
+ const key = _routeKey(method, fullPath, domain);
379
+ _s().routes.set(key, {
380
+ method,
381
+ path: fullPath,
382
+ controller,
383
+ action,
384
+ // Group middleware is prepended so outer groups run before inner middleware.
385
+ middleware: [..._s().groupMiddleware, ...middleware],
386
+ name: undefined,
387
+ bindings: new Map(),
388
+ ...(domain ? { domain } : {}),
389
+ });
390
+
391
+ const registration: RouteRegistration = {
392
+ name: (routeName: string) => {
393
+ _s().namedRoutes.set(routeName, fullPath);
394
+ return registration;
395
+ },
396
+ bind: (paramName: string, modelOrResolver: ModelClass | ModelBindingResolver) => {
397
+ const def = _s().routes.get(key)!;
398
+ def.bindings.set(paramName, _toResolver(modelOrResolver));
399
+ return registration;
400
+ },
401
+ };
402
+ return registration;
403
+ }
404
+
405
+ /**
406
+ * Shared dispatcher for the HTTP-verb methods. Accepts both the controller form
407
+ * `(path, Controller, 'action', middleware?)` and the closure form
408
+ * `(path, handler, middleware?)`, discriminating on whether the third argument
409
+ * is an action name (string) or a middleware array.
410
+ */
411
+ private static _route(
412
+ method: HttpMethod,
413
+ path: string,
414
+ handlerOrController: ControllerClass | AnyRouteHandler,
415
+ actionOrMiddleware?: string | MiddlewareClass[],
416
+ middleware: MiddlewareClass[] = [],
417
+ ): RouteRegistration {
418
+ // Controller + action form: Router.get('/users', UserController, 'index').
419
+ if (typeof actionOrMiddleware === "string") {
420
+ return Router._register(
421
+ method,
422
+ path,
423
+ handlerOrController as ControllerClass,
424
+ actionOrMiddleware,
425
+ middleware,
426
+ );
427
+ }
428
+ // Closure form: Router.get('/users', (ctx) => ctx.json(...), [middleware]).
429
+ const controller = _wrapHandler(
430
+ handlerOrController as FileHandler,
431
+ `Route<${method} ${_s().prefix + path}>`,
432
+ );
433
+ return Router._register(method, path, controller, "handle", actionOrMiddleware ?? []);
434
+ }
435
+
436
+ /**
437
+ * @internal Register a route at an absolute path, bypassing the current prefix.
438
+ * Used by ResourceRouteBuilder which captures the prefix at resource() call time.
439
+ */
440
+ static _registerAbsolute(
441
+ method: HttpMethod,
442
+ fullPath: string,
443
+ controller: ControllerClass,
444
+ action: string,
445
+ middleware: MiddlewareClass[],
446
+ name?: string,
447
+ domain: string | undefined = _s().domain,
448
+ ): void {
449
+ _s().routes.set(_routeKey(method, fullPath, domain), {
450
+ method,
451
+ path: fullPath,
452
+ controller,
453
+ action,
454
+ middleware: [..._s().groupMiddleware, ...middleware],
455
+ name,
456
+ bindings: new Map(),
457
+ ...(domain ? { domain } : {}),
458
+ });
459
+ if (name) _s().namedRoutes.set(name, fullPath);
460
+ }
461
+
462
+ /** @internal Remove a single route by its map key. Used by ResourceRouteBuilder. */
463
+ static _delete(key: string): void {
464
+ _s().routes.delete(key);
465
+ }
466
+
467
+ /**
468
+ * @internal Register a file-based route handler.
469
+ * Wraps the function in a synthetic controller class so it flows through
470
+ * the standard `createRouteHandler` pipeline unchanged.
471
+ * Called by `FileRouter.scanFileRoutes()`.
472
+ */
473
+ static _registerFileHandler(
474
+ method: HttpMethod,
475
+ fullPath: string,
476
+ handler: FileHandler,
477
+ middleware: MiddlewareClass[],
478
+ name?: string,
479
+ domain: string | undefined = _s().domain,
480
+ ): void {
481
+ const controller = _wrapFileHandler(handler, `${method} ${fullPath}`);
482
+ const key = _routeKey(method, fullPath, domain);
483
+ _s().routes.set(key, {
484
+ method,
485
+ path: fullPath,
486
+ controller,
487
+ action: "handle",
488
+ middleware: [..._s().groupMiddleware, ...middleware],
489
+ name,
490
+ bindings: new Map(),
491
+ ...(domain ? { domain } : {}),
492
+ });
493
+ if (name) _s().namedRoutes.set(name, fullPath);
494
+ }
495
+
496
+ // ── Registration ──────────────────────────────────────────────────────
497
+
498
+ /**
499
+ * Register a GET route. Accepts either a controller action or an inline
500
+ * closure handler.
501
+ *
502
+ * @example
503
+ * // Controller + action:
504
+ * Router.get('/users', UserController, 'index');
505
+ * Router.get('/users', UserController, 'index', [AuthMiddleware]);
506
+ *
507
+ * // Inline closure — receives the request HttpContext, same as a controller action:
508
+ * Router.get('/', (ctx) => ctx.html('<h1>Home</h1>'));
509
+ * Router.get('/posts/:slug', (ctx: HttpContext<{ slug: string }>) =>
510
+ * ctx.json({ slug: ctx.params.slug }),
511
+ * );
512
+ * Router.get('/admin', (ctx) => ctx.json({ ok: true }), [AuthMiddleware]);
513
+ *
514
+ * @category Route definition
515
+ */
516
+ static get(
517
+ path: string,
518
+ controller: ControllerClass,
519
+ action: string,
520
+ middleware?: MiddlewareClass[],
521
+ ): RouteRegistration;
522
+ static get<T extends Record<string, unknown> = Record<string, never>>(
523
+ path: string,
524
+ handler: RouteHandler<T>,
525
+ middleware?: MiddlewareClass[],
526
+ ): RouteRegistration;
527
+ static get(
528
+ path: string,
529
+ handlerOrController: ControllerClass | AnyRouteHandler,
530
+ actionOrMiddleware?: string | MiddlewareClass[],
531
+ middleware: MiddlewareClass[] = [],
532
+ ): RouteRegistration {
533
+ return Router._route("GET", path, handlerOrController, actionOrMiddleware, middleware);
534
+ }
535
+
536
+ /**
537
+ * Register a POST route mapping `path` to a controller action or inline closure handler.
538
+ *
539
+ * @category Route definition
540
+ */
541
+ static post(
542
+ path: string,
543
+ controller: ControllerClass,
544
+ action: string,
545
+ middleware?: MiddlewareClass[],
546
+ ): RouteRegistration;
547
+ static post<T extends Record<string, unknown> = Record<string, never>>(
548
+ path: string,
549
+ handler: RouteHandler<T>,
550
+ middleware?: MiddlewareClass[],
551
+ ): RouteRegistration;
552
+ static post(
553
+ path: string,
554
+ handlerOrController: ControllerClass | AnyRouteHandler,
555
+ actionOrMiddleware?: string | MiddlewareClass[],
556
+ middleware: MiddlewareClass[] = [],
557
+ ): RouteRegistration {
558
+ return Router._route("POST", path, handlerOrController, actionOrMiddleware, middleware);
559
+ }
560
+
561
+ /**
562
+ * Register a PUT route mapping `path` to a controller action or inline closure handler.
563
+ *
564
+ * @category Route definition
565
+ */
566
+ static put(
567
+ path: string,
568
+ controller: ControllerClass,
569
+ action: string,
570
+ middleware?: MiddlewareClass[],
571
+ ): RouteRegistration;
572
+ static put<T extends Record<string, unknown> = Record<string, never>>(
573
+ path: string,
574
+ handler: RouteHandler<T>,
575
+ middleware?: MiddlewareClass[],
576
+ ): RouteRegistration;
577
+ static put(
578
+ path: string,
579
+ handlerOrController: ControllerClass | AnyRouteHandler,
580
+ actionOrMiddleware?: string | MiddlewareClass[],
581
+ middleware: MiddlewareClass[] = [],
582
+ ): RouteRegistration {
583
+ return Router._route("PUT", path, handlerOrController, actionOrMiddleware, middleware);
584
+ }
585
+
586
+ /**
587
+ * Register a PATCH route mapping `path` to a controller action or inline closure handler.
588
+ *
589
+ * @category Route definition
590
+ */
591
+ static patch(
592
+ path: string,
593
+ controller: ControllerClass,
594
+ action: string,
595
+ middleware?: MiddlewareClass[],
596
+ ): RouteRegistration;
597
+ static patch<T extends Record<string, unknown> = Record<string, never>>(
598
+ path: string,
599
+ handler: RouteHandler<T>,
600
+ middleware?: MiddlewareClass[],
601
+ ): RouteRegistration;
602
+ static patch(
603
+ path: string,
604
+ handlerOrController: ControllerClass | AnyRouteHandler,
605
+ actionOrMiddleware?: string | MiddlewareClass[],
606
+ middleware: MiddlewareClass[] = [],
607
+ ): RouteRegistration {
608
+ return Router._route("PATCH", path, handlerOrController, actionOrMiddleware, middleware);
609
+ }
610
+
611
+ /**
612
+ * Register a DELETE route mapping `path` to a controller action or inline closure handler.
613
+ *
614
+ * @category Route definition
615
+ */
616
+ static delete(
617
+ path: string,
618
+ controller: ControllerClass,
619
+ action: string,
620
+ middleware?: MiddlewareClass[],
621
+ ): RouteRegistration;
622
+ static delete<T extends Record<string, unknown> = Record<string, never>>(
623
+ path: string,
624
+ handler: RouteHandler<T>,
625
+ middleware?: MiddlewareClass[],
626
+ ): RouteRegistration;
627
+ static delete(
628
+ path: string,
629
+ handlerOrController: ControllerClass | AnyRouteHandler,
630
+ actionOrMiddleware?: string | MiddlewareClass[],
631
+ middleware: MiddlewareClass[] = [],
632
+ ): RouteRegistration {
633
+ return Router._route("DELETE", path, handlerOrController, actionOrMiddleware, middleware);
634
+ }
635
+
636
+ /**
637
+ * Register a macro — a provider-contributed static method on `Route`.
638
+ *
639
+ * Call this in `onRegister()` so the method is available before routes load.
640
+ *
641
+ * @example
642
+ * // In FlowProvider.onRegister():
643
+ * Router.macro('flow', flowRoute);
644
+ *
645
+ * // In routes/index.ts:
646
+ * Router.flow('/dashboard', Dashboard);
647
+ *
648
+ * @category Route definition
649
+ */
650
+ static macro<K extends keyof RouterMacros>(name: K, fn: RouterMacros[K]): void {
651
+ (Router as unknown as Record<string, unknown>)[name as string] = fn;
652
+ }
653
+
654
+ /**
655
+ * Register a GET route that renders a core TSX view component directly —
656
+ * no controller needed for simple server-rendered pages.
657
+ *
658
+ * `props` can be a static object (evaluated once at route registration) or a
659
+ * per-request factory function that receives `HttpContext` and may be async.
660
+ * When omitted, the component is called with an empty object.
661
+ *
662
+ * @example
663
+ * // Static props — great for marketing/info pages:
664
+ * Router.view('/about', AboutPage, { title: 'About Us' });
665
+ *
666
+ * // Dynamic props — resolved per request:
667
+ * Router.view('/dashboard', DashboardPage, ctx => ({
668
+ * user: ctx.user,
669
+ * greeting: `Hello, ${ctx.user?.name ?? 'guest'}`,
670
+ * }));
671
+ *
672
+ * // No props needed:
673
+ * Router.view('/privacy', PrivacyPage);
674
+ *
675
+ * @category Route definition
676
+ */
677
+ static view<P extends Record<string, unknown>>(
678
+ path: string,
679
+ component: (props: P) => { toString(): string },
680
+ props?: P | ((ctx: HttpContext) => P | Promise<P>),
681
+ middleware: MiddlewareClass[] = [],
682
+ ): ViewRegistration {
683
+ let layout: ViewLayout | undefined;
684
+
685
+ const handler: FileHandler = async (http) => {
686
+ const resolved: P =
687
+ typeof props === "function"
688
+ ? await (props as (ctx: HttpContext) => P | Promise<P>)(http)
689
+ : (props ?? ({} as P));
690
+ const rendered = component(resolved);
691
+ if (layout) {
692
+ http.view((await layout(http, { children: rendered })) as { toString(): string });
693
+ } else {
694
+ http.view(rendered);
695
+ }
696
+ };
697
+
698
+ const fullPath = _s().prefix + path;
699
+ Router._registerFileHandler("GET", fullPath, handler, middleware);
700
+
701
+ const registration: ViewRegistration = {
702
+ name: (routeName: string) => {
703
+ _s().namedRoutes.set(routeName, fullPath);
704
+ return registration;
705
+ },
706
+ withLayout: (viewLayout: ViewLayout) => {
707
+ layout = viewLayout;
708
+ return registration;
709
+ },
710
+ };
711
+ return registration;
712
+ }
713
+
714
+ /**
715
+ * Serve static files from a local directory under a URL prefix.
716
+ *
717
+ * By default every file is pre-registered at compile() time as a static
718
+ * `Response`, which Bun serves without ever entering JS — far cheaper than a
719
+ * per-request filesystem lookup. Pass `headers` for cache-control etc., or
720
+ * `eager: false` to keep the per-request fallback only.
721
+ *
722
+ * @example
723
+ * Router.static('/assets', './public/assets');
724
+ * Router.static('/assets', './public', {
725
+ * headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
726
+ * });
727
+ *
728
+ * @category Route definition
729
+ */
730
+ static static(prefix: string, rootDir: string, options?: StaticOptions): void {
731
+ _s().staticDirs.push({ prefix, rootDir, options });
732
+ }
733
+
734
+ /**
735
+ * Discover and self-register controllers from a directory. Each exported class
736
+ * with a static `register()` method is registered. Best-effort: scan failures
737
+ * and unimportable files are skipped. Returns the number registered.
738
+ *
739
+ * @category Route definition
740
+ */
741
+ static async controllers(dir: string): Promise<number> {
742
+ let count = 0;
743
+ let glob: { scan(options: { cwd: string; onlyFiles: boolean }): AsyncIterable<string> };
744
+ try {
745
+ glob = new Bun.Glob("**/*.{ts,js}");
746
+ } catch {
747
+ return 0;
748
+ }
749
+ try {
750
+ for await (const file of glob.scan({ cwd: dir, onlyFiles: true })) {
751
+ if (file.endsWith(".test.ts") || file.endsWith(".test.js")) continue;
752
+ let loadedModule: Record<string, unknown>;
753
+ try {
754
+ loadedModule = (await import(`${dir}/${file}`)) as Record<string, unknown>;
755
+ } catch {
756
+ continue;
757
+ }
758
+ for (const exported of Object.values(loadedModule)) {
759
+ if (
760
+ typeof exported === "function" &&
761
+ typeof (exported as { register?: unknown }).register === "function"
762
+ ) {
763
+ (exported as unknown as { register: () => void }).register();
764
+ count++;
765
+ }
766
+ }
767
+ }
768
+ } catch {
769
+ // Scanning is best-effort — a directory that can't be walked just contributes no controllers.
770
+ }
771
+ if (count > 0) {
772
+ frameworkLog("router").info(`Registered ${count} controller${count === 1 ? "" : "s"}`, {
773
+ dir,
774
+ });
775
+ }
776
+ return count;
777
+ }
778
+
779
+ /**
780
+ * Serve a directory of `.md` files as rendered HTML pages.
781
+ *
782
+ * Maps `GET /prefix/foo/bar` → `rootDir/foo/bar.md` (also tries `index.md`
783
+ * for bare directory paths like `/prefix/foo/`).
784
+ *
785
+ * Uses `Bun.markdown.html()` with GFM extensions enabled by default.
786
+ * Pass `options` to override parser settings or supply a default page title.
787
+ *
788
+ * @example
789
+ * Router.markdown('/docs', './docs');
790
+ * Router.markdown('/docs', './docs', { headings: { ids: true } });
791
+ *
792
+ * @category Route definition
793
+ */
794
+ static markdown(
795
+ prefix: string,
796
+ rootDir: string,
797
+ options?: BunMarkdownOptions & { title?: string },
798
+ ): void {
799
+ _s().markdownDirs.push({ prefix, rootDir, options });
800
+ }
801
+
802
+ // ── Resource routing ─────────────────────────────────────────────────
803
+
804
+ /**
805
+ * Register RESTful resource routes for a controller.
806
+ * Returns a ResourceRouteBuilder for optional .only() / .except() filtering.
807
+ *
808
+ * Route map:
809
+ * GET /{name} → index
810
+ * GET /{name}/create → create
811
+ * POST /{name} → store
812
+ * GET /{name}/:id → show
813
+ * GET /{name}/:id/edit → edit
814
+ * PUT /{name}/:id → update
815
+ * DELETE /{name}/:id → destroy
816
+ *
817
+ * @example
818
+ * Router.resource('posts', PostController);
819
+ * Router.resource('photos', PhotoController).only(['index', 'show']);
820
+ * Router.resource('tags', TagController).except(['create', 'edit']);
821
+ *
822
+ * @category Resource routes
823
+ */
824
+ static resource(
825
+ name: string,
826
+ controller: ControllerClass,
827
+ middleware: MiddlewareClass[] = [],
828
+ ): ResourceRouteBuilder {
829
+ // Capture the current group prefix NOW — before it might be popped by group().
830
+ const basePath = _s().prefix + "/" + name.replace(/^\/+/, "");
831
+ return new ResourceRouteBuilder(basePath, controller, middleware);
832
+ }
833
+
834
+ // ── Grouping and named middleware ─────────────────────────────────────
835
+
836
+ /**
837
+ * Register a named middleware group for use in Router.group({ middleware }).
838
+ *
839
+ * @example
840
+ * Router.middlewareGroup('api', [ThrottleMiddleware, JsonMiddleware]);
841
+ * Router.middlewareGroup('web', [SessionMiddleware, CsrfMiddleware]);
842
+ *
843
+ * Router.group({ prefix: '/api', middleware: 'api' }, () => {
844
+ * Router.resource('posts', PostController);
845
+ * });
846
+ *
847
+ * @category Middleware
848
+ */
849
+ static middlewareGroup(name: string, middlewares: MiddlewareClass[]): void {
850
+ _s().middlewareGroups.set(name, middlewares);
851
+ }
852
+
853
+ /**
854
+ * Run a callback with routes inside a group.
855
+ * Groups can be nested — prefix and middleware stack accumulate.
856
+ *
857
+ * @example
858
+ * Router.group({ prefix: '/api/v1', middleware: 'api' }, () => {
859
+ * Router.get('/users', UserController, 'index');
860
+ * });
861
+ * // registers GET /api/v1/users with 'api' group middleware
862
+ *
863
+ * Router.group({ middleware: ['web', AuthMiddleware] }, () => {
864
+ * Router.resource('posts', PostController);
865
+ * });
866
+ *
867
+ * @category Route groups
868
+ */
869
+ static group(options: GroupOptions, fn: () => void): void {
870
+ const savedPrefix = _s().prefix;
871
+ const savedMiddleware = _s().groupMiddleware;
872
+ const savedDomain = _s().domain;
873
+
874
+ _s().domain = options.domain ?? savedDomain;
875
+ _s().prefix = savedPrefix + (options.prefix ?? "");
876
+ _s().groupMiddleware =
877
+ options.middleware !== undefined
878
+ ? [...savedMiddleware, ...Router._resolveMiddleware(options.middleware)]
879
+ : savedMiddleware;
880
+
881
+ try {
882
+ fn();
883
+ } finally {
884
+ // Mirror groupAsync: a throwing callback must not corrupt group state.
885
+ _s().prefix = savedPrefix;
886
+ _s().groupMiddleware = savedMiddleware;
887
+ _s().domain = savedDomain;
888
+ }
889
+ }
890
+
891
+ /**
892
+ * Async variant of {@link Router.group} — awaits the callback before restoring group state.
893
+ *
894
+ * @category Route groups
895
+ */
896
+ static async groupAsync(options: GroupOptions, fn: () => Promise<void>): Promise<void> {
897
+ const savedPrefix = _s().prefix;
898
+ const savedMiddleware = _s().groupMiddleware;
899
+ const savedDomain = _s().domain;
900
+
901
+ _s().domain = options.domain ?? savedDomain;
902
+ _s().prefix = savedPrefix + (options.prefix ?? "");
903
+ _s().groupMiddleware =
904
+ options.middleware !== undefined
905
+ ? [...savedMiddleware, ...Router._resolveMiddleware(options.middleware)]
906
+ : savedMiddleware;
907
+
908
+ try {
909
+ await fn();
910
+ } finally {
911
+ _s().prefix = savedPrefix;
912
+ _s().groupMiddleware = savedMiddleware;
913
+ _s().domain = savedDomain;
914
+ }
915
+ }
916
+
917
+ // ── Compilation ───────────────────────────────────────────────────────
918
+
919
+ /**
920
+ * Compile all registered routes into a `Bun.serve()`-compatible routes object,
921
+ * resolving model bindings, grouping domain-scoped routes for host dispatch,
922
+ * and registering static files, markdown pages, and raw routes.
923
+ *
924
+ * @category Compilation
925
+ */
926
+ static compile(
927
+ container: Container,
928
+ globalMiddleware: MiddlewareClass[] = [],
929
+ exceptionHandler?: ExceptionHandler,
930
+ providerHooks?: ProviderHooks,
931
+ ): CompiledRoutes {
932
+ const compiled: CompiledRoutes = {};
933
+
934
+ // Merge global bindings, then group definitions by (path, method) so that
935
+ // domain-scoped routes sharing a path can be dispatched by host.
936
+ const groups = new Map<string, RouteDefinition[]>();
937
+ for (const definition of _s().routes.values()) {
938
+ // Implicit model binding — for any path param with no explicit binding, ask the ORM
939
+ // resolver whether a registered model claims it (by `implicitBindingKey` or class-name
940
+ // convention). An explicit `.bind()` always wins.
941
+ const implicitResolver = _s().implicitModelResolver;
942
+ if (implicitResolver) {
943
+ for (const paramName of _pathParams(definition.path)) {
944
+ if (definition.bindings.has(paramName)) continue;
945
+ const resolver = implicitResolver(paramName);
946
+ if (resolver) definition.bindings.set(paramName, resolver);
947
+ }
948
+ }
949
+ const groupKey = `${definition.method} ${definition.path}`;
950
+ const existing = groups.get(groupKey);
951
+ if (existing) existing.push(definition);
952
+ else groups.set(groupKey, [definition]);
953
+ }
954
+
955
+ for (const definitions of groups.values()) {
956
+ const { path, method } = definitions[0]!;
957
+ const map = (compiled[path] ??= {}) as Record<string, RouteHandlerFn>;
958
+ const makeHandler = (definition: RouteDefinition): RouteHandlerFn =>
959
+ createRouteHandler(
960
+ definition,
961
+ container,
962
+ globalMiddleware,
963
+ exceptionHandler,
964
+ providerHooks,
965
+ );
966
+ const domainDefinitions = definitions.filter((definition) => definition.domain);
967
+ const plainDefinition = definitions.find((definition) => !definition.domain);
968
+
969
+ if (domainDefinitions.length === 0) {
970
+ map[method] = makeHandler(plainDefinition!);
971
+ if (method === "GET") map["HEAD"] ??= _headFrom(map[method]!);
972
+ continue;
973
+ }
974
+
975
+ const hostRoutes = domainDefinitions.map((definition) => ({
976
+ matcher: compileDomain(definition.domain!),
977
+ handler: makeHandler(definition),
978
+ }));
979
+ const plainHandler = plainDefinition ? makeHandler(plainDefinition) : undefined;
980
+ map[method] = async (req: Request, server?: unknown): Promise<Response> => {
981
+ const host = req.headers.get("host") ?? new URL(req.url).host;
982
+ for (const { matcher, handler } of hostRoutes) {
983
+ const params = matchDomain(matcher, host);
984
+ if (params) {
985
+ setRequestSubdomains(req, params);
986
+ return handler(req, server as never);
987
+ }
988
+ }
989
+ if (plainHandler) return plainHandler(req, server as never);
990
+ return new Response("Not Found", { status: 404 });
991
+ };
992
+ if (method === "GET") map["HEAD"] ??= _headFrom(map[method]!);
993
+ }
994
+
995
+ for (const { prefix, rootDir, options } of _s().staticDirs) {
996
+ if (options?.eager === false) continue;
997
+ let files: string[];
998
+ try {
999
+ files = Array.from(new Bun.Glob("**/*").scanSync({ cwd: rootDir, onlyFiles: true }));
1000
+ } catch {
1001
+ continue;
1002
+ }
1003
+ const basePrefix = prefix.replace(/\/$/, "");
1004
+ let registered = 0;
1005
+ for (const relativePath of files) {
1006
+ const urlPath = `${basePrefix}/${relativePath.replace(/\\/g, "/")}`.replace(/\/+/g, "/");
1007
+ if (compiled[urlPath]) continue;
1008
+ const headers = options?.headers;
1009
+ compiled[urlPath] = new Response(
1010
+ Bun.file(`${rootDir}/${relativePath}`) as unknown as BodyInit,
1011
+ headers ? { headers } : undefined,
1012
+ );
1013
+ registered++;
1014
+ }
1015
+ if (registered > 0) {
1016
+ frameworkLog("router").info(
1017
+ `Registered ${registered} static asset route${registered === 1 ? "" : "s"}`,
1018
+ { dir: rootDir },
1019
+ );
1020
+ }
1021
+ }
1022
+
1023
+ for (const { prefix, rootDir, options } of _s().markdownDirs) {
1024
+ const stripped = prefix.replace(/\/$/, "");
1025
+ const pattern = stripped + "/*";
1026
+ const mdMap = (compiled[pattern] ??= {}) as Record<string, RouteHandlerFn>;
1027
+ mdMap["GET"] = async (req: Request): Promise<Response> => {
1028
+ const pathname = new URL(req.url).pathname;
1029
+ const relative = pathname.slice(stripped.length).replace(/^\//, "") || "index";
1030
+ const candidates = [`${rootDir}/${relative}.md`, `${rootDir}/${relative}/index.md`];
1031
+ for (const candidate of candidates) {
1032
+ const file = Bun.file(candidate);
1033
+ if (await file.exists()) {
1034
+ const content = await file.text();
1035
+ const { title: titleOption, ...markdownOptions } = options ?? {};
1036
+ const body = Bun.markdown.html(content, { ...DEFAULT_MD_OPTIONS, ...markdownOptions });
1037
+ const title = titleOption ?? markdownExtractTitle(content) ?? relative;
1038
+ return new Response(markdownPage(title, body), {
1039
+ headers: { "Content-Type": "text/html; charset=utf-8" },
1040
+ });
1041
+ }
1042
+ }
1043
+ return new Response("Not Found", { status: 404 });
1044
+ };
1045
+ }
1046
+
1047
+ // Raw routes bypass the middleware pipeline entirely — added last so they
1048
+ // take precedence over any same-path pipeline routes.
1049
+ for (const [key, handler] of _s().rawRoutes) {
1050
+ const spaceIndex = key.indexOf(" ");
1051
+ const method = key.slice(0, spaceIndex) as HttpMethod;
1052
+ const path = key.slice(spaceIndex + 1);
1053
+ const rawMap = (compiled[path] ??= {}) as Record<string, RouteHandlerFn>;
1054
+ rawMap[method] = handler;
1055
+ }
1056
+
1057
+ return compiled;
1058
+ }
1059
+
1060
+ // ── Utilities ─────────────────────────────────────────────────────────
1061
+
1062
+ /**
1063
+ * Register a raw Bun route handler that bypasses the global middleware pipeline.
1064
+ *
1065
+ * Use this for internal framework endpoints (health checks, asset servers, etc.)
1066
+ * that must not go through session / auth middleware. The handler receives the
1067
+ * raw `Request` and returns a `Response` directly — no `HttpContext`, no pipeline.
1068
+ *
1069
+ * @example
1070
+ * Router.raw('GET', '/__internal/ping', () => new Response('pong'));
1071
+ *
1072
+ * @category Route definition
1073
+ */
1074
+ static raw(
1075
+ method: HttpMethod | string,
1076
+ path: string,
1077
+ handler: (req: Request) => Response | Promise<Response>,
1078
+ ): void {
1079
+ _s().rawRoutes.set(`${method.toUpperCase()} ${path}`, handler);
1080
+ }
1081
+
1082
+ /**
1083
+ * Clear all registered routes, static dirs, named routes, groups, and model bindings. Used in tests.
1084
+ *
1085
+ * @category Introspection
1086
+ */
1087
+ static reset(): void {
1088
+ _s().routes.clear();
1089
+ _s().rawRoutes.clear();
1090
+ _s().staticDirs = [];
1091
+ _s().markdownDirs = [];
1092
+ _s().prefix = "";
1093
+ _s().groupMiddleware = [];
1094
+ _s().namedRoutes.clear();
1095
+ _s().middlewareGroups.clear();
1096
+ }
1097
+
1098
+ /**
1099
+ * Middleware attached to a registered route (group middleware included,
1100
+ * in execution order). Returns an empty array for unknown routes.
1101
+ *
1102
+ * Lets framework packages re-run a route's middleware outside the normal
1103
+ * HTTP pipeline — e.g. @zerotal/flow re-applies the original page route's
1104
+ * middleware on every WebSocket update (Livewire-style persistent middleware).
1105
+ *
1106
+ * @example
1107
+ * const middleware = Router.middlewareFor('GET', '/dashboard');
1108
+ * await runMiddleware(ctx, middleware, container);
1109
+ *
1110
+ * @category Middleware
1111
+ */
1112
+ static middlewareFor(method: HttpMethod, path: string): MiddlewareClass[] {
1113
+ return [...(_s().routes.get(`${method} ${path}`)?.middleware ?? [])];
1114
+ }
1115
+
1116
+ /**
1117
+ * The currently accumulated Router.group() prefix ('' outside a group).
1118
+ * For framework packages that register derived routes and need the full
1119
+ * runtime path (e.g. @zerotal/flow storing the page path in its snapshot).
1120
+ *
1121
+ * @category Route groups
1122
+ */
1123
+ static get groupPrefix(): string {
1124
+ return _s().prefix;
1125
+ }
1126
+
1127
+ /**
1128
+ * Read-only view of registered static directories. Used by Application.start() to serve files in the fetch handler.
1129
+ *
1130
+ * @category Introspection
1131
+ */
1132
+ static get staticDirs(): ReadonlyArray<{
1133
+ prefix: string;
1134
+ rootDir: string;
1135
+ options?: StaticOptions | undefined;
1136
+ }> {
1137
+ return _s().staticDirs;
1138
+ }
1139
+
1140
+ /**
1141
+ * Read-only view of registered routes. Useful for debugging and tests.
1142
+ *
1143
+ * @category Introspection
1144
+ */
1145
+ static get routes(): ReadonlyMap<string, RouteDefinition> {
1146
+ return _s().routes;
1147
+ }
1148
+
1149
+ /**
1150
+ * Read-only view of named routes (name → path). Useful for debugging.
1151
+ *
1152
+ * @category Naming & URLs
1153
+ */
1154
+ static get namedRoutes(): ReadonlyMap<string, string> {
1155
+ return _s().namedRoutes;
1156
+ }
1157
+ }
1158
+
1159
+ // ── ResourceRouteBuilder ──────────────────────────────────────────────────────
1160
+
1161
+ type ResourceAction = "index" | "create" | "store" | "show" | "edit" | "update" | "destroy";
1162
+
1163
+ interface ResourceRoute {
1164
+ method: HttpMethod;
1165
+ suffix: string;
1166
+ action: ResourceAction;
1167
+ }
1168
+
1169
+ const ALL_RESOURCE_ROUTES: ResourceRoute[] = [
1170
+ { method: "GET", suffix: "", action: "index" },
1171
+ { method: "GET", suffix: "/create", action: "create" },
1172
+ { method: "POST", suffix: "", action: "store" },
1173
+ { method: "GET", suffix: "/:id", action: "show" },
1174
+ { method: "GET", suffix: "/:id/edit", action: "edit" },
1175
+ { method: "PUT", suffix: "/:id", action: "update" },
1176
+ { method: "PATCH", suffix: "/:id", action: "update" },
1177
+ { method: "DELETE", suffix: "/:id", action: "destroy" },
1178
+ ];
1179
+
1180
+ /** Builds and manages the set of RESTful routes for a resource, with `.only()`/`.except()` filtering. */
1181
+ export class ResourceRouteBuilder {
1182
+ private _registeredKeys = new Set<string>();
1183
+
1184
+ /**
1185
+ * The domain in force when `Router.resource()` was called.
1186
+ *
1187
+ * Captured for the same reason `_base` is: `.only()` / `.except()` re-register after the
1188
+ * enclosing `group()` has already restored the previous domain, so reading it at commit
1189
+ * time would drop the host constraint on exactly the routes an app narrowed by hand.
1190
+ */
1191
+ private readonly _domain: string | undefined = _s().domain;
1192
+
1193
+ constructor(
1194
+ private readonly _base: string,
1195
+ private readonly _controller: ControllerClass,
1196
+ private readonly _middleware: MiddlewareClass[],
1197
+ ) {
1198
+ this._commit(ALL_RESOURCE_ROUTES);
1199
+ }
1200
+
1201
+ /**
1202
+ * Limit this resource to only the specified actions.
1203
+ *
1204
+ * @example
1205
+ * Router.resource('photos', PhotoController).only(['index', 'show']);
1206
+ */
1207
+ only(actions: ResourceAction[]): this {
1208
+ this._deregisterAll();
1209
+ this._commit(ALL_RESOURCE_ROUTES.filter((route) => actions.includes(route.action)));
1210
+ return this;
1211
+ }
1212
+
1213
+ /**
1214
+ * Exclude the specified actions from this resource.
1215
+ *
1216
+ * @example
1217
+ * Router.resource('tags', TagController).except(['create', 'edit']);
1218
+ */
1219
+ except(actions: ResourceAction[]): this {
1220
+ this._deregisterAll();
1221
+ this._commit(ALL_RESOURCE_ROUTES.filter((route) => !actions.includes(route.action)));
1222
+ return this;
1223
+ }
1224
+
1225
+ private _commit(routes: ResourceRoute[]): void {
1226
+ for (const { method, suffix, action } of routes) {
1227
+ const fullPath = this._base + suffix;
1228
+ Router._registerAbsolute(
1229
+ method,
1230
+ fullPath,
1231
+ this._controller,
1232
+ action,
1233
+ this._middleware,
1234
+ undefined,
1235
+ this._domain,
1236
+ );
1237
+ this._registeredKeys.add(_routeKey(method, fullPath, this._domain));
1238
+ }
1239
+ }
1240
+
1241
+ private _deregisterAll(): void {
1242
+ for (const key of this._registeredKeys) {
1243
+ Router._delete(key);
1244
+ }
1245
+ this._registeredKeys.clear();
1246
+ }
1247
+ }