@zerotal/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +72 -0
- package/src/application/Application.ts +1671 -0
- package/src/application/BootDoctor.ts +108 -0
- package/src/application/DevErrorPage.ts +567 -0
- package/src/application/ExceptionHandler.ts +183 -0
- package/src/application/currentApp.ts +73 -0
- package/src/assets/assets.ts +79 -0
- package/src/assets/index.ts +16 -0
- package/src/auth/AuthenticatedUser.ts +18 -0
- package/src/build/PackageLinter.ts +146 -0
- package/src/build/PackageScaffold.ts +127 -0
- package/src/build/codemod.ts +64 -0
- package/src/build/index.ts +12 -0
- package/src/command/Command.ts +254 -0
- package/src/command/CommandRunner.ts +593 -0
- package/src/command/OutputWriter.ts +61 -0
- package/src/command/builtin/CompileCommand.ts +46 -0
- package/src/command/builtin/CssBuildCommand.ts +71 -0
- package/src/command/builtin/KeyGenerateCommand.ts +58 -0
- package/src/command/builtin/LintPackagesCommand.ts +72 -0
- package/src/command/builtin/MakeCommandCommand.ts +85 -0
- package/src/command/builtin/MakeControllerCommand.ts +95 -0
- package/src/command/builtin/MakeEventCommand.ts +85 -0
- package/src/command/builtin/MakeJobCommand.ts +53 -0
- package/src/command/builtin/MakeListenerCommand.ts +35 -0
- package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
- package/src/command/builtin/MakeNotificationCommand.ts +48 -0
- package/src/command/builtin/MakeObserverCommand.ts +78 -0
- package/src/command/builtin/MakePackageCommand.ts +45 -0
- package/src/command/builtin/MakePolicyCommand.ts +66 -0
- package/src/command/builtin/MakeProviderCommand.ts +75 -0
- package/src/command/builtin/MakeRequestCommand.ts +47 -0
- package/src/command/builtin/MakeResourceCommand.ts +61 -0
- package/src/command/builtin/MakeTestCommand.ts +120 -0
- package/src/command/builtin/ReloadCommand.ts +52 -0
- package/src/command/builtin/ReplCommand.ts +174 -0
- package/src/command/builtin/RouteListCommand.ts +188 -0
- package/src/command/builtin/ServeCommand.ts +321 -0
- package/src/command/builtin/StartCommand.ts +3 -0
- package/src/command/builtin/StatusCommand.ts +71 -0
- package/src/command/builtin/TestCommand.ts +172 -0
- package/src/command/builtin/WorkerCommand.ts +27 -0
- package/src/command/builtin/index.ts +53 -0
- package/src/command/scaffold/worker.ts.txt +12 -0
- package/src/command/scaffold/zerotal.ts.txt +26 -0
- package/src/command/startZerotal.ts +55 -0
- package/src/config/AppConfig.ts +253 -0
- package/src/config/ConfigLoader.ts +117 -0
- package/src/config/ConfigManager.ts +169 -0
- package/src/config/index.ts +46 -0
- package/src/config/registry.ts +59 -0
- package/src/config/validation.ts +117 -0
- package/src/container/Container.ts +606 -0
- package/src/container/ContextualBindingBuilder.ts +57 -0
- package/src/container/ScopedResolver.ts +117 -0
- package/src/container/index.ts +32 -0
- package/src/container/inject.ts +55 -0
- package/src/container/types.ts +71 -0
- package/src/context/RequestContext.ts +91 -0
- package/src/contracts/auth.ts +24 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/session.ts +70 -0
- package/src/contracts/transaction.ts +26 -0
- package/src/conventions/ConventionLoader.ts +128 -0
- package/src/conventions/builtinConcerns.ts +131 -0
- package/src/crypt/Crypt.ts +141 -0
- package/src/crypt/URLSigner.ts +96 -0
- package/src/datetime/Carbon.ts +1396 -0
- package/src/datetime/CarbonInterval.ts +421 -0
- package/src/datetime/clock.ts +28 -0
- package/src/datetime/index.ts +23 -0
- package/src/datetime/temporal-shim.ts +1 -0
- package/src/dev/BuildOutput.ts +131 -0
- package/src/dev/CssPlugins.ts +184 -0
- package/src/dev/DevBuildHook.ts +74 -0
- package/src/dev/DevOrchestrator.ts +213 -0
- package/src/dev/DevReloadMiddleware.ts +101 -0
- package/src/dev/DevReloadServer.ts +85 -0
- package/src/dev/DevWsServer.ts +45 -0
- package/src/dev/index.ts +19 -0
- package/src/dev/reloadClient.ts +39 -0
- package/src/env/Def.ts +232 -0
- package/src/env/EnvSchema.ts +105 -0
- package/src/env/index.ts +34 -0
- package/src/env/t.ts +128 -0
- package/src/errors/ConfigError.ts +12 -0
- package/src/errors/ContainerErrors.ts +143 -0
- package/src/errors/HttpError.ts +127 -0
- package/src/errors/ValidationError.ts +19 -0
- package/src/errors/ZerotalError.ts +25 -0
- package/src/errors/index.ts +46 -0
- package/src/events/CallQueuedListener.ts +66 -0
- package/src/events/Emitter.ts +280 -0
- package/src/events/EventFake.ts +160 -0
- package/src/events/FrameworkEvents.ts +252 -0
- package/src/facade/Facade.ts +101 -0
- package/src/facade/facades/App.ts +155 -0
- package/src/facade/facades/Artisan.ts +63 -0
- package/src/facade/facades/Config.ts +21 -0
- package/src/facade/facades/Events.ts +19 -0
- package/src/facade/facades/index.ts +28 -0
- package/src/global.d.ts +9 -0
- package/src/hash/Hash.ts +60 -0
- package/src/health/Health.ts +221 -0
- package/src/health/index.ts +27 -0
- package/src/helpers/Collection.ts +435 -0
- package/src/helpers/config.ts +59 -0
- package/src/helpers/fluent.ts +52 -0
- package/src/helpers/html.ts +11 -0
- package/src/helpers/index.ts +266 -0
- package/src/helpers/make.ts +35 -0
- package/src/helpers/markdown.ts +73 -0
- package/src/helpers/pageElements.ts +27 -0
- package/src/helpers/request.ts +62 -0
- package/src/helpers/response.ts +411 -0
- package/src/helpers/str.ts +208 -0
- package/src/http/Http.ts +298 -0
- package/src/http/HttpClient.ts +289 -0
- package/src/http/Resource.ts +171 -0
- package/src/http/UploadedFile.ts +204 -0
- package/src/http/Uri.ts +490 -0
- package/src/http/index.ts +46 -0
- package/src/http/negotiate.ts +213 -0
- package/src/http/originGuard.ts +76 -0
- package/src/http/sniffContentType.ts +105 -0
- package/src/http/url.ts +204 -0
- package/src/http/withHeaders.ts +24 -0
- package/src/index.ts +250 -0
- package/src/lock/LockManager.ts +228 -0
- package/src/lock/config.ts +49 -0
- package/src/lock/drivers/LockDriver.ts +32 -0
- package/src/lock/drivers/MemoryLockDriver.ts +52 -0
- package/src/lock/drivers/RedisLockDriver.ts +58 -0
- package/src/lock/drivers/SqliteLockDriver.ts +85 -0
- package/src/lock/errors.ts +20 -0
- package/src/lock/facades/Lock.ts +114 -0
- package/src/lock/index.ts +53 -0
- package/src/logger/Log.ts +35 -0
- package/src/logger/LogManager.ts +430 -0
- package/src/logger/LoggerMiddleware.ts +125 -0
- package/src/logger/channels/ConsoleChannel.ts +139 -0
- package/src/logger/channels/DailyChannel.ts +74 -0
- package/src/logger/channels/NullChannel.ts +17 -0
- package/src/logger/channels/SingleChannel.ts +34 -0
- package/src/logger/channels/StackChannel.ts +29 -0
- package/src/logger/config.ts +90 -0
- package/src/logger/format.ts +96 -0
- package/src/logger/frameworkLog.ts +93 -0
- package/src/logger/index.ts +68 -0
- package/src/logger/renderTable.ts +111 -0
- package/src/logger/types.ts +212 -0
- package/src/macros/config.macro.ts +50 -0
- package/src/metrics/HttpMetrics.ts +114 -0
- package/src/metrics/index.ts +18 -0
- package/src/middleware/BaseMiddleware.ts +72 -0
- package/src/middleware/CorsMiddleware.ts +152 -0
- package/src/middleware/RateLimiter.ts +255 -0
- package/src/middleware/SecureHeadersMiddleware.ts +127 -0
- package/src/middleware/ThrottleMiddleware.ts +252 -0
- package/src/middleware/WebhookMiddleware.ts +204 -0
- package/src/pipeline/ContextRegistry.ts +42 -0
- package/src/pipeline/HttpContext.ts +865 -0
- package/src/pipeline/Pipeline.ts +150 -0
- package/src/pipeline/currentPage.ts +46 -0
- package/src/pipeline/types.ts +80 -0
- package/src/provider/LockProvider.ts +64 -0
- package/src/provider/LogProvider.ts +137 -0
- package/src/provider/ServiceProvider.ts +84 -0
- package/src/provider/StorageProvider.ts +45 -0
- package/src/router/FileRouter.ts +526 -0
- package/src/router/Route.ts +76 -0
- package/src/router/RouteHandler.ts +335 -0
- package/src/router/Router.ts +1247 -0
- package/src/router/domain.ts +65 -0
- package/src/security/index.ts +22 -0
- package/src/storage/FakeDisk.ts +233 -0
- package/src/storage/StorageFilesMiddleware.ts +150 -0
- package/src/storage/StorageManager.ts +173 -0
- package/src/storage/config.ts +47 -0
- package/src/storage/drivers/LocalDriver.ts +138 -0
- package/src/storage/drivers/S3Driver.ts +169 -0
- package/src/storage/errors.ts +135 -0
- package/src/storage/facades/Storage.ts +3 -0
- package/src/storage/global.d.ts +7 -0
- package/src/storage/index.ts +22 -0
- package/src/storage/root.ts +59 -0
- package/src/storage/types.ts +104 -0
- package/src/support/appKey.ts +38 -0
- package/src/support/cookie.ts +72 -0
- package/src/support/crypto.ts +52 -0
- package/src/support/deepMerge.ts +117 -0
- package/src/support/env.ts +71 -0
- package/src/support/network.ts +79 -0
- package/src/support/port.ts +197 -0
- package/src/support/str.ts +122 -0
- package/src/view/FileRouteResolver.ts +59 -0
- package/src/view/index.ts +144 -0
- package/src/view/jsx-runtime.ts +233 -0
|
@@ -0,0 +1,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
|
+
}
|