@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,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
|
+
}
|