@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,526 @@
1
+ /**
2
+ * File-based routing: scans a routes directory and registers a route per file,
3
+ * deriving URL paths, names, layouts, and stacked `_middleware.ts` from the file
4
+ * tree, with an extension point for framework packages to claim route files.
5
+ *
6
+ * Why not Bun's native `Bun.FileSystemRouter`? That router models Next-style
7
+ * pages only — it has no concept of route groups `(auth)`/`(guest)`, shared
8
+ * `_layout`/`_middleware` stacks, named middleware groups, or feeding the
9
+ * framework's middleware pipeline. This resolver produces ordinary
10
+ * `Router.*` registrations, which still compile down to Bun's native `routes`
11
+ * map, so we keep the native fast path while supporting those extra features.
12
+ */
13
+ import { existsSync } from "node:fs";
14
+ import { resolve, join } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { Router } from "./Router.ts";
17
+ import type { HttpMethod, MiddlewareClass, FileHandler, ViewComponent } from "./Route.ts";
18
+
19
+ // ── Public types ──────────────────────────────────────────────────────────────
20
+
21
+ export type { FileHandler };
22
+
23
+ /**
24
+ * Per-method middleware map for a route file's `export const middleware`.
25
+ * `ALL` applies to every method the file handles; the per-verb arrays add to it.
26
+ */
27
+ export interface RouteMethodMiddleware {
28
+ ALL?: MiddlewareClass[];
29
+ GET?: MiddlewareClass[];
30
+ POST?: MiddlewareClass[];
31
+ PUT?: MiddlewareClass[];
32
+ PATCH?: MiddlewareClass[];
33
+ DELETE?: MiddlewareClass[];
34
+ }
35
+
36
+ /**
37
+ * A route file's `export const middleware` — either an array applied to all methods,
38
+ * or a {@link RouteMethodMiddleware} map for per-method control.
39
+ *
40
+ * @example
41
+ * export const middleware = [AuthMiddleware]; // all methods
42
+ * export const middleware = { ALL: [AuthMiddleware], POST: [ThrottleMiddleware] };
43
+ */
44
+ export type RouteMiddleware = MiddlewareClass[] | RouteMethodMiddleware;
45
+
46
+ /** Shape of a route module. `default` may be a FileHandler or a ViewComponent;
47
+ * `layout` overrides the directory `_layout` (null = no layout). */
48
+ export interface RouteModule {
49
+ default?: FileHandler | ViewComponent;
50
+ GET?: FileHandler;
51
+ POST?: FileHandler;
52
+ PUT?: FileHandler;
53
+ PATCH?: FileHandler;
54
+ DELETE?: FileHandler;
55
+ layout?: ViewComponent | null;
56
+ /** Per-file middleware (in addition to any stacked `_middleware.ts`). */
57
+ middleware?: RouteMiddleware;
58
+ }
59
+
60
+ /** Optional `export const meta = { GET: { name: 'users.show' } }` in route files. */
61
+ export interface RouteFileMeta {
62
+ GET?: { name?: string };
63
+ POST?: { name?: string };
64
+ PUT?: { name?: string };
65
+ PATCH?: { name?: string };
66
+ DELETE?: { name?: string };
67
+ }
68
+
69
+ /** Shape of a `_middleware.ts` file. */
70
+ export interface MiddlewareModule {
71
+ middleware: MiddlewareClass[];
72
+ }
73
+
74
+ // ── File-route resolvers (framework extension point) ─────────────────────────
75
+
76
+ /**
77
+ * Context handed to a file-route resolver for each scanned route file.
78
+ */
79
+ export interface FileRouteContext {
80
+ /** URL path derived from the file location (e.g. '/users/:id'). */
81
+ urlPath: string;
82
+ /** The imported module's exports. */
83
+ module: Record<string, unknown>;
84
+ /** Middleware collected from _middleware.ts files (outermost first). */
85
+ middleware: MiddlewareClass[];
86
+ /** Auto-generated GET route name for this path. */
87
+ name: string;
88
+ /** True when the file is an index.ts/index.tsx. */
89
+ isIndex: boolean;
90
+ /** Absolute path to the source file on disk (useful for build-time compilation). */
91
+ filePath: string;
92
+ /** Nearest _layout component for this dir, when layouts are enabled. */
93
+ layout?: ViewComponent | undefined;
94
+ }
95
+
96
+ /**
97
+ * A resolver lets framework packages claim a route file's exports before the
98
+ * default verb-function handling runs. Return `true` to claim the file.
99
+ *
100
+ * @example
101
+ * // @zerotal/flow registers Component classes found in file routes:
102
+ * registerFileRouteResolver(({ urlPath, module, middleware }) => {
103
+ * const PageClass = findFlowPageExport(module);
104
+ * if (!PageClass) return false;
105
+ * Router.flow(urlPath, PageClass, middleware);
106
+ * return true;
107
+ * });
108
+ */
109
+ export type FileRouteResolver = (ctx: FileRouteContext) => boolean;
110
+
111
+ const _fileRouteResolvers: FileRouteResolver[] = [];
112
+
113
+ /** Register a resolver. Call from a provider's onRegister() so it is in place
114
+ * before Application.boot() scans file routes. */
115
+ export function registerFileRouteResolver(resolver: FileRouteResolver): void {
116
+ _fileRouteResolvers.push(resolver);
117
+ }
118
+
119
+ /** @internal Clear all registered file-route resolvers. Used in tests. */
120
+ export function _resetFileRouteResolvers(): void {
121
+ _fileRouteResolvers.length = 0;
122
+ }
123
+
124
+ let _layoutsEnabled = false;
125
+
126
+ /** Opt into nearest-`_layout` discovery for file-based routes. */
127
+ export function enableFileRouteLayouts(): void {
128
+ _layoutsEnabled = true;
129
+ }
130
+
131
+ /** @internal Disable file-route layout discovery. Used in tests. */
132
+ export function _resetFileRouteLayouts(): void {
133
+ _layoutsEnabled = false;
134
+ }
135
+
136
+ // ── Constants ─────────────────────────────────────────────────────────────────
137
+
138
+ const FILE_METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
139
+
140
+ // ── Path conversion ───────────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Convert a file path (relative to the routes base dir) to a URL path.
144
+ *
145
+ * Rules applied in order:
146
+ * 1. Normalise Windows backslashes
147
+ * 2. Strip `.ts` / `.tsx` extension
148
+ * 3. Strip `(group)` directory segments — they organise files without adding URL segments
149
+ * 4. `[...name]` → `*` (catch-all — Phase 1 basic support)
150
+ * 5. `[param]` → `:param` (dynamic segment)
151
+ * 6. `index` → `` (directory URL, index file)
152
+ * 7. Reassemble with `/`, ensure leading slash
153
+ *
154
+ * @example
155
+ * filePathToRoutePath('index.ts') // '/'
156
+ * filePathToRoutePath('about.ts') // '/about'
157
+ * filePathToRoutePath('users/index.ts') // '/users'
158
+ * filePathToRoutePath('users/[id].ts') // '/users/:id'
159
+ * filePathToRoutePath('(admin)/users/index.ts') // '/users'
160
+ * filePathToRoutePath('api/users/[id]/posts.ts') // '/api/users/:id/posts'
161
+ */
162
+ export function filePathToRoutePath(filePath: string): string {
163
+ const normalised = filePath.replace(/\\/g, "/").replace(/\.(tsx?)$/, "");
164
+
165
+ const segments = normalised.split("/").flatMap((segment): string[] => {
166
+ // Route groups: (admin) → stripped (contributes no URL segment)
167
+ if (/^\(.*\)$/.test(segment)) return [];
168
+
169
+ // Catch-all: [...slug] → *
170
+ if (/^\[\.\.\.([^\]]+)\]$/.test(segment)) return ["*"];
171
+
172
+ // Dynamic: [id] → :id
173
+ const dynamicMatch = segment.match(/^\[([^\]]+)\]$/);
174
+ if (dynamicMatch) return [`:${dynamicMatch[1]}`];
175
+
176
+ // index → '' (directory URL)
177
+ if (segment === "index") return [""];
178
+
179
+ return [segment];
180
+ });
181
+
182
+ const url = "/" + segments.filter(Boolean).join("/");
183
+ return url === "/" ? "/" : url.replace(/\/$/, "") || "/";
184
+ }
185
+
186
+ // ── Route name generation ─────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * Auto-generate a route name from a URL path and HTTP method.
190
+ *
191
+ * Follows a conventional RESTful naming scheme:
192
+ *
193
+ * | URL | Method | Name |
194
+ * |-------------------|--------|-------------------|
195
+ * | `/` | GET | `home` |
196
+ * | `/about` | GET | `about` |
197
+ * | `/about` | POST | `about.store` |
198
+ * | `/api/users` | GET | `api.users.index` |
199
+ * | `/api/users` | POST | `api.users.store` |
200
+ * | `/api/users/:id` | GET | `api.users.show` |
201
+ * | `/api/users/:id` | PUT | `api.users.update`|
202
+ * | `/api/users/:id` | DELETE | `api.users.destroy`|
203
+ *
204
+ * @param isIndex Pass `true` when the route came from an `index.ts` file —
205
+ * this adds the `.index` suffix to multi-segment GET routes.
206
+ * Leaf files (`about.ts`) never get `.index`.
207
+ */
208
+ export function generateRouteName(urlPath: string, method: HttpMethod, isIndex = false): string {
209
+ if (urlPath === "/") return "home";
210
+
211
+ const hasParam = urlPath.includes(":") || urlPath.includes("*");
212
+
213
+ // Strip leading /, replace slashes with dots, strip :param and * segments
214
+ const base = urlPath
215
+ .replace(/^\//, "")
216
+ .replace(/\//g, ".")
217
+ .replace(/:[^./]+/g, "")
218
+ .replace(/\*/g, "")
219
+ .replace(/\.+/g, ".")
220
+ .replace(/^\.+|\.+$/g, "");
221
+
222
+ if (!hasParam) {
223
+ if (method === "GET") {
224
+ // index.ts collection endpoint (e.g. api/users/index.ts → /api/users) → api.users.index
225
+ // leaf page (e.g. about.ts → /about or named/about.ts → /named/about) → about / named.about
226
+ return isIndex && base.includes(".") ? `${base}.index` : base || "home";
227
+ }
228
+ if (method === "POST") return `${base}.store`;
229
+ return `${base}.${method.toLowerCase()}`;
230
+ }
231
+
232
+ switch (method) {
233
+ case "GET":
234
+ return `${base}.show`;
235
+ case "POST":
236
+ return `${base}.store`;
237
+ case "PUT":
238
+ case "PATCH":
239
+ return `${base}.update`;
240
+ case "DELETE":
241
+ return `${base}.destroy`;
242
+ }
243
+ }
244
+
245
+ // ── Middleware stacking ───────────────────────────────────────────────────────
246
+
247
+ /**
248
+ * For a relative file path inside the routes directory, collect all
249
+ * `_middleware.ts` modules from the base to the file's directory (inclusive),
250
+ * outermost-first.
251
+ *
252
+ * @example
253
+ * // For 'dashboard/settings/profile.ts' checks:
254
+ * // _middleware.ts
255
+ * // dashboard/_middleware.ts
256
+ * // dashboard/settings/_middleware.ts
257
+ */
258
+ /**
259
+ * The `_middleware` file for a directory, or `undefined` when there is none.
260
+ *
261
+ * @param baseDir - Routes root.
262
+ * @param dir - Directory relative to the root (`""` for the root itself).
263
+ * @returns Absolute path to `_middleware.ts` or `_middleware.tsx`.
264
+ */
265
+ async function _findMiddlewareFile(baseDir: string, dir: string): Promise<string | undefined> {
266
+ for (const ext of ["ts", "tsx"] as const) {
267
+ const path = dir
268
+ ? join(baseDir, dir, `_middleware.${ext}`)
269
+ : join(baseDir, `_middleware.${ext}`);
270
+ if (await Bun.file(path).exists()) return path;
271
+ }
272
+ return undefined;
273
+ }
274
+
275
+ async function _collectMiddleware(
276
+ baseDir: string,
277
+ relativeFile: string,
278
+ reloadId?: string,
279
+ ): Promise<MiddlewareClass[]> {
280
+ const normalised = relativeFile.replace(/\\/g, "/");
281
+ const parts = normalised.split("/");
282
+ parts.pop(); // remove file name
283
+
284
+ // Build list of directory paths to check, root-first
285
+ const directories: string[] = [""];
286
+ let accumulated = "";
287
+ for (const segment of parts) {
288
+ accumulated = accumulated ? `${accumulated}/${segment}` : segment;
289
+ directories.push(accumulated);
290
+ }
291
+
292
+ const result: MiddlewareClass[] = [];
293
+ for (const dir of directories) {
294
+ // Both extensions. The scanner skips `_middleware.tsx` as a route, so probing only
295
+ // `.ts` here meant a JSX-authored middleware file was silently never applied — its
296
+ // sibling routes stayed live and unguarded, with no error and no log. A convention
297
+ // that fails open is worse than one that does not exist.
298
+ const middlewarePath = await _findMiddlewareFile(baseDir, dir);
299
+ try {
300
+ if (middlewarePath) {
301
+ // Convert to a file:// URL so dynamic import works on Windows (raw
302
+ // absolute paths with backslashes are not valid import specifiers).
303
+ // Append ?t=<reloadId> to bust the module cache on hot-reload.
304
+ const href = pathToFileURL(middlewarePath).href;
305
+ const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
306
+ const loadedModule = (await import(importUrl)) as MiddlewareModule;
307
+ if (Array.isArray(loadedModule.middleware)) {
308
+ result.push(...loadedModule.middleware);
309
+ }
310
+ }
311
+ } catch {
312
+ // The file is missing or failed to import — skip it.
313
+ }
314
+ }
315
+ return result;
316
+ }
317
+
318
+ /**
319
+ * Combine the stacked `_middleware.ts` chain (`base`) with a route file's own
320
+ * `export const middleware` for a specific verb. Array form applies to every method;
321
+ * object form applies `ALL` plus the per-verb list. File middleware runs after (inside)
322
+ * the directory middleware.
323
+ */
324
+ function _fileRouteMiddleware(
325
+ module: RouteModule,
326
+ base: MiddlewareClass[],
327
+ verb: HttpMethod,
328
+ ): MiddlewareClass[] {
329
+ const m = module.middleware;
330
+ if (!m) return base;
331
+ if (Array.isArray(m)) return [...base, ...m];
332
+ if (typeof m === "object") return [...base, ...(m.ALL ?? []), ...(m[verb] ?? [])];
333
+ return base;
334
+ }
335
+
336
+ // ── Layout discovery ──
337
+
338
+ const _LAYOUT_EXTS = [".tsx", ".ts", ".jsx", ".js"] as const;
339
+
340
+ async function _collectLayout(
341
+ baseDir: string,
342
+ relativeFile: string,
343
+ reloadId?: string,
344
+ ): Promise<ViewComponent | undefined> {
345
+ const parts = relativeFile.replace(/\\/g, "/").split("/");
346
+ parts.pop();
347
+ for (let depth = parts.length; depth >= 0; depth--) {
348
+ const dir = parts.slice(0, depth).join("/");
349
+ for (const extension of _LAYOUT_EXTS) {
350
+ const layoutPath = dir
351
+ ? join(baseDir, dir, `_layout${extension}`)
352
+ : join(baseDir, `_layout${extension}`);
353
+ try {
354
+ if (await Bun.file(layoutPath).exists()) {
355
+ const href = pathToFileURL(layoutPath).href;
356
+ const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
357
+ const loadedModule = (await import(importUrl)) as { default?: unknown };
358
+ if (typeof loadedModule.default === "function") {
359
+ return loadedModule.default as ViewComponent;
360
+ }
361
+ }
362
+ } catch {
363
+ // A page module that fails to import is skipped — component resolution is best-effort.
364
+ }
365
+ }
366
+ }
367
+ return undefined;
368
+ }
369
+
370
+ // ── Main scanner ──────────────────────────────────────────────────────────────
371
+
372
+ /**
373
+ * Scan `baseDir` for route files and register them with the global Router.
374
+ *
375
+ * Called from `Application.fileBasedRouting()` during the boot phase.
376
+ * Returns the number of individual method handlers registered.
377
+ *
378
+ * **Skipped files:**
379
+ * - `_middleware.ts` files (middleware config, not routes)
380
+ * - `*.test.ts` / `*.spec.ts` files
381
+ * - Any file with a leading underscore (`_*.ts`)
382
+ * - `.d.ts` declaration files
383
+ *
384
+ * **Registration order:** file-based routes are registered during boot, before
385
+ * `routes/index.ts` runs. Explicit routes registered afterward take precedence
386
+ * (last write wins in `Router.state.routes`).
387
+ *
388
+ * @param reloadId When provided (during a hot-reload), route and middleware
389
+ * files are imported with `?t=<reloadId>` appended so Bun
390
+ * creates a fresh module namespace instead of returning the
391
+ * cached version. Omit on first boot.
392
+ * @returns The number of individual method handlers registered.
393
+ */
394
+ export async function scanFileRoutes(baseDir: string, reloadId?: string): Promise<number> {
395
+ const absoluteBase = _resolveAbsoluteBase(baseDir);
396
+
397
+ if (!existsSync(absoluteBase)) {
398
+ return 0;
399
+ }
400
+
401
+ const glob = new Bun.Glob("**/*.{ts,tsx}");
402
+ let count = 0;
403
+
404
+ for await (const relativeFile of glob.scan({ cwd: absoluteBase })) {
405
+ const normalised = relativeFile.replace(/\\/g, "/");
406
+
407
+ if (_shouldSkip(normalised)) continue;
408
+
409
+ // Apply any active group prefix (set by Router.groupAsync wrapping scanFileRoutes).
410
+ const groupPrefix = Router._activePrefix;
411
+ const groupMiddleware = Router._activeMiddleware;
412
+
413
+ const rawPath = filePathToRoutePath(normalised);
414
+ const urlPath = groupPrefix + rawPath;
415
+ // Use join() so path separators are correct on every platform.
416
+ const absoluteFile = join(absoluteBase, normalised);
417
+ // `index.ts` files are collection endpoints (GET → .index suffix in auto-names).
418
+ const isIndex = /(?:^|\/)index\.(tsx?)$/.test(normalised);
419
+ const fileMiddleware = await _collectMiddleware(absoluteBase, normalised, reloadId);
420
+ // Stacked directory middleware: group prefix, then `_middleware.ts` chain. A route
421
+ // file's own `export const middleware` is layered on per-verb after the import below.
422
+ const baseMiddleware = [...groupMiddleware, ...fileMiddleware];
423
+
424
+ const layout = _layoutsEnabled
425
+ ? await _collectLayout(absoluteBase, normalised, reloadId)
426
+ : undefined;
427
+
428
+ const fileHref = pathToFileURL(absoluteFile).href;
429
+ const importUrl = reloadId ? `${fileHref}?t=${reloadId}` : fileHref;
430
+ let loadedModule: RouteModule & { meta?: RouteFileMeta };
431
+ try {
432
+ loadedModule = (await import(importUrl)) as RouteModule & { meta?: RouteFileMeta };
433
+ } catch (error) {
434
+ console.error(`[FileRouter] Failed to import ${normalised}:`, error);
435
+ continue;
436
+ }
437
+
438
+ const meta: RouteFileMeta = (loadedModule.meta ?? {}) as RouteFileMeta;
439
+
440
+ // Framework resolvers get first claim (e.g. @zerotal/flow Component exports).
441
+ let claimed = false;
442
+ for (const resolver of _fileRouteResolvers) {
443
+ try {
444
+ if (
445
+ resolver({
446
+ urlPath,
447
+ module: loadedModule as Record<string, unknown>,
448
+ middleware: _fileRouteMiddleware(loadedModule, baseMiddleware, "GET"),
449
+ name: generateRouteName(urlPath, "GET", isIndex),
450
+ isIndex,
451
+ filePath: absoluteFile,
452
+ layout,
453
+ })
454
+ ) {
455
+ claimed = true;
456
+ count++;
457
+ break;
458
+ }
459
+ } catch (error) {
460
+ console.error(`[FileRouter] Resolver failed for ${normalised}:`, error);
461
+ }
462
+ }
463
+ // A claimed file (e.g. a Flow Component owning GET) can still export other
464
+ // verb handlers — POST/PUT/PATCH/DELETE functions register normally, so a
465
+ // login page and its form handler can live in the same file.
466
+
467
+ // `export default` is a GET alias when no explicit GET export exists.
468
+ if (
469
+ !claimed &&
470
+ typeof loadedModule.default === "function" &&
471
+ typeof loadedModule.GET !== "function"
472
+ ) {
473
+ const name = meta.GET?.name ?? generateRouteName(urlPath, "GET", isIndex);
474
+ Router._registerFileHandler(
475
+ "GET",
476
+ urlPath,
477
+ loadedModule.default as FileHandler,
478
+ _fileRouteMiddleware(loadedModule, baseMiddleware, "GET"),
479
+ name,
480
+ );
481
+ count++;
482
+ }
483
+
484
+ for (const verb of FILE_METHODS) {
485
+ if (claimed && verb === "GET") continue; // GET belongs to the claimed page
486
+ const handler = loadedModule[verb];
487
+ if (typeof handler !== "function") continue;
488
+ const name = meta[verb]?.name ?? generateRouteName(urlPath, verb, isIndex);
489
+ Router._registerFileHandler(
490
+ verb,
491
+ urlPath,
492
+ handler,
493
+ _fileRouteMiddleware(loadedModule, baseMiddleware, verb),
494
+ name,
495
+ );
496
+ count++;
497
+ }
498
+ }
499
+
500
+ return count;
501
+ }
502
+
503
+ // ── Private helpers ───────────────────────────────────────────────────────────
504
+
505
+ function _resolveAbsoluteBase(dir: string): string {
506
+ return resolve(dir);
507
+ }
508
+
509
+ /**
510
+ * Whether a scanned file is convention plumbing rather than a route.
511
+ *
512
+ * `_`-prefixed names are reserved by the convention, so the leading-underscore rule covers
513
+ * `_middleware.*` and anything else an app parks in the routes tree. Tests and declaration
514
+ * files are excluded because they are colocated with routes and are not endpoints.
515
+ */
516
+ function _shouldSkip(relativePath: string): boolean {
517
+ const file = relativePath.split("/").at(-1) ?? "";
518
+ return (
519
+ file.startsWith("_") ||
520
+ file.endsWith(".test.ts") ||
521
+ file.endsWith(".test.tsx") ||
522
+ file.endsWith(".spec.ts") ||
523
+ file.endsWith(".spec.tsx") ||
524
+ file.endsWith(".d.ts")
525
+ );
526
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The shared routing type vocabulary: HTTP methods, controller/middleware
3
+ * class shapes, the handler `Context` bag, model-binding contracts, and the
4
+ * compiled `RouteDefinition` that the router and file router both build.
5
+ */
6
+ import type { HttpContext } from "../pipeline/HttpContext.ts";
7
+ import type { Pipe } from "../pipeline/types.ts";
8
+
9
+ /** The HTTP methods the router can register routes for. */
10
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
11
+
12
+ /** A controller class the router can instantiate to dispatch an action. */
13
+ export type ControllerClass = new (...args: unknown[]) => unknown;
14
+ /** A middleware class the pipeline can instantiate into a {@link Pipe}. */
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- middleware constructors take provider-specific dependency args that aren't known at this boundary.
16
+ export type MiddlewareClass = new (...args: any[]) => Pipe<HttpContext>;
17
+
18
+ /** Handler signature for file-based routes. Receives the request {@link HttpContext}
19
+ * directly — route params and resolved model bindings live on `ctx.params`. May return
20
+ * a Response or mutate `ctx` directly. */
21
+ export type FileHandler = (ctx: HttpContext) => void | Response | Promise<void | Response>;
22
+
23
+ /**
24
+ * Closure handler for an inline route (e.g. `Router.get('/', handler)`). Receives the
25
+ * request {@link HttpContext} directly. The optional type parameter types `ctx.params`
26
+ * with the route's bound models / raw route params.
27
+ *
28
+ * @example
29
+ * Router.get('/', (ctx) => ctx.html('<h1>Home</h1>'));
30
+ * Router.get('/posts/:slug', (ctx: HttpContext<{ slug: string }>) =>
31
+ * ctx.json({ slug: ctx.params.slug }),
32
+ * );
33
+ */
34
+ export type RouteHandler<T extends Record<string, unknown> = Record<string, never>> = (
35
+ ctx: HttpContext<T>,
36
+ ) => void | Response | Promise<void | Response>;
37
+
38
+ /** A view function rendered for a route, receiving the request context and explicit props. */
39
+ export type ViewComponent<P extends Record<string, unknown> = Record<string, unknown>> = (
40
+ ctx: HttpContext,
41
+ props: P,
42
+ ) => unknown;
43
+
44
+ /** A layout that wraps a rendered view's output as its `children`. */
45
+ export type ViewLayout = (ctx: HttpContext, props: { children: unknown }) => unknown;
46
+
47
+ /**
48
+ * A callable resolver for a single route-model binding.
49
+ * Receives the raw string param value and the current context;
50
+ * must return (or resolve to) the model instance.
51
+ * Throw `ModelNotFoundError` (or any 404 error) when the record does not exist.
52
+ */
53
+ export type ModelBindingResolver = (value: string, ctx: HttpContext) => Promise<unknown>;
54
+
55
+ /**
56
+ * Duck-typed interface for ActiveRecord model classes.
57
+ * Any class with a static `findOrFail(id)` method satisfies this contract
58
+ * without importing `BaseModel` from `@zerotal/orm` (which would create a cycle).
59
+ */
60
+ export interface ModelClass {
61
+ findOrFail(id: number | string): Promise<unknown>;
62
+ }
63
+
64
+ /** A fully compiled route ready for matching and dispatch. */
65
+ export interface RouteDefinition {
66
+ method: HttpMethod;
67
+ path: string;
68
+ controller: ControllerClass;
69
+ action: string;
70
+ middleware: MiddlewareClass[];
71
+ name: string | undefined;
72
+ /** Per-route model bindings, keyed by param name. Merged with global bindings at compile time. */
73
+ bindings: Map<string, ModelBindingResolver>;
74
+ /** Host pattern from `Router.group({ domain })`; matched per request. */
75
+ domain?: string;
76
+ }