@zerotal/core 1.4.0 → 1.5.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 +351 -0
- package/package.json +1 -1
- package/src/application/Application.ts +107 -9
- package/src/application/DevErrorPage.ts +82 -0
- package/src/application/diagnostics.ts +111 -0
- package/src/command/CommandRunner.ts +82 -1
- package/src/command/builtin/AssetsBuildCommand.ts +102 -0
- package/src/command/builtin/DeployCommand.ts +315 -0
- package/src/command/builtin/DevCommand.ts +88 -0
- package/src/command/builtin/DoctorCommand.ts +97 -0
- package/src/command/builtin/MakeCommandCommand.ts +2 -0
- package/src/command/builtin/RouteTypesCommand.ts +56 -0
- package/src/command/builtin/ServeCommand.ts +232 -44
- package/src/command/builtin/index.ts +5 -0
- package/src/command/scaffold/zerotal.ts.txt +2 -10
- package/src/config/AppConfig.ts +109 -2
- package/src/config/DeployConfig.ts +71 -0
- package/src/config/index.ts +2 -0
- package/src/config/registry.ts +1 -0
- package/src/container/Container.ts +3 -3
- package/src/container/inject.ts +3 -2
- package/src/context/RequestContext.ts +60 -0
- package/src/contracts/session.ts +18 -3
- package/src/dev/BuildCache.ts +312 -0
- package/src/dev/CssPlugins.ts +93 -7
- package/src/dev/DevBuildHook.ts +14 -1
- package/src/dev/DevDeck.ts +549 -0
- package/src/dev/DevOrchestrator.ts +166 -31
- package/src/dev/DevProcess.ts +221 -0
- package/src/dev/DevReloadMiddleware.ts +1 -1
- package/src/dev/DevSupervisor.ts +363 -0
- package/src/dev/bootBuild.ts +94 -0
- package/src/dev/index.ts +24 -0
- package/src/dev/startDevMode.ts +145 -0
- package/src/doctor/AppDoctor.ts +399 -0
- package/src/doctor/TransportProbe.ts +169 -0
- package/src/events/Emitter.ts +4 -3
- package/src/facade/facades/App.ts +10 -2
- package/src/helpers/index.ts +23 -1
- package/src/helpers/response.ts +18 -8
- package/src/http/Uri.ts +7 -3
- package/src/http/originGuard.ts +1 -1
- package/src/http/url.ts +10 -4
- package/src/index.ts +43 -0
- package/src/lock/LockManager.ts +190 -14
- package/src/lock/drivers/LockDriver.ts +11 -0
- package/src/lock/drivers/MemoryLockDriver.ts +21 -1
- package/src/lock/drivers/RedisLockDriver.ts +64 -8
- package/src/lock/drivers/SqliteLockDriver.ts +13 -0
- package/src/lock/errors.ts +26 -0
- package/src/lock/facades/Lock.ts +30 -5
- package/src/lock/index.ts +2 -2
- package/src/macros/config.macro.ts +2 -0
- package/src/provider/ServiceProvider.ts +40 -0
- package/src/router/Router.ts +111 -13
- package/src/router/registry.ts +123 -0
- package/src/router/routeTypes.ts +132 -0
- package/src/support/classRef.ts +27 -0
- package/src/support/env.ts +69 -2
- package/src/support/unroutedRoutes.ts +37 -0
package/src/lock/errors.ts
CHANGED
|
@@ -18,3 +18,29 @@ export class LockNotAcquiredError extends ZerotalError {
|
|
|
18
18
|
this.key = key;
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Thrown when a lock that was being auto-refreshed could not be extended —
|
|
24
|
+
* the TTL lapsed and another holder took the key.
|
|
25
|
+
*
|
|
26
|
+
* Distinct from {@link LockNotAcquiredError} on purpose: that one means "you
|
|
27
|
+
* never got in", this one means "you were in and you are not any more", and the
|
|
28
|
+
* work in flight has to be treated as no longer exclusive. The callback's
|
|
29
|
+
* `AbortSignal` is aborted before this is thrown, so cooperative work stops
|
|
30
|
+
* rather than running on outside the lock it thinks it holds.
|
|
31
|
+
*
|
|
32
|
+
* @category Acquiring
|
|
33
|
+
*/
|
|
34
|
+
export class LockLostError extends ZerotalError {
|
|
35
|
+
/** The lock key that was lost. */
|
|
36
|
+
readonly key: string;
|
|
37
|
+
|
|
38
|
+
constructor(key: string) {
|
|
39
|
+
super(
|
|
40
|
+
`[Zerotal Lock] Lost the lock for key: "${key}" — it expired and was taken by another holder.`,
|
|
41
|
+
"E_LOCK_LOST",
|
|
42
|
+
409,
|
|
43
|
+
);
|
|
44
|
+
this.key = key;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/lock/facades/Lock.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { currentApp } from "../../application/currentApp.ts";
|
|
2
2
|
import { ZerotalError } from "../../errors/index.ts";
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type {
|
|
4
|
+
LockManager,
|
|
5
|
+
ManagedLock,
|
|
6
|
+
BlockOptions,
|
|
7
|
+
TryOptions,
|
|
8
|
+
LockedCallback,
|
|
9
|
+
} from "../LockManager.ts";
|
|
10
|
+
import { LockNotAcquiredError, LockLostError } from "../errors.ts";
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* Resolve the live LockManager from the application container on every call.
|
|
@@ -75,12 +81,20 @@ export class Lock {
|
|
|
75
81
|
* @param key - Logical lock name.
|
|
76
82
|
* @param ttlSeconds - Lock time-to-live in seconds.
|
|
77
83
|
* @param callback - Critical section to run while the lock is held.
|
|
84
|
+
* @param options - Pass `{ refresh: true }` to hold the lock across work
|
|
85
|
+
* longer than its TTL.
|
|
78
86
|
* @returns The value returned by `callback`.
|
|
79
87
|
* @throws {LockNotAcquiredError} Immediately, if the lock is busy.
|
|
88
|
+
* @throws {LockLostError} If refreshing was on and the lock was lost mid-run.
|
|
80
89
|
* @category Acquiring
|
|
81
90
|
*/
|
|
82
|
-
static async try<T>(
|
|
83
|
-
|
|
91
|
+
static async try<T>(
|
|
92
|
+
key: string,
|
|
93
|
+
ttlSeconds: number,
|
|
94
|
+
callback: LockedCallback<T>,
|
|
95
|
+
options?: TryOptions,
|
|
96
|
+
): Promise<T> {
|
|
97
|
+
return _manager().try(key, ttlSeconds, callback, options);
|
|
84
98
|
}
|
|
85
99
|
|
|
86
100
|
/**
|
|
@@ -98,7 +112,7 @@ export class Lock {
|
|
|
98
112
|
static async block<T>(
|
|
99
113
|
key: string,
|
|
100
114
|
ttlSeconds: number,
|
|
101
|
-
callback:
|
|
115
|
+
callback: LockedCallback<T>,
|
|
102
116
|
options?: BlockOptions,
|
|
103
117
|
): Promise<T> {
|
|
104
118
|
return _manager().block(key, ttlSeconds, callback, options);
|
|
@@ -111,4 +125,15 @@ export class Lock {
|
|
|
111
125
|
* @category Acquiring
|
|
112
126
|
*/
|
|
113
127
|
static readonly NotAcquired = LockNotAcquiredError;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The {@link LockLostError} class, for `err instanceof Lock.Lost`.
|
|
131
|
+
*
|
|
132
|
+
* Worth catching separately from {@link Lock.NotAcquired}: that one means the
|
|
133
|
+
* work never started, this one means it started and must not be trusted to
|
|
134
|
+
* have finished exclusively.
|
|
135
|
+
*
|
|
136
|
+
* @category Acquiring
|
|
137
|
+
*/
|
|
138
|
+
static readonly Lost = LockLostError;
|
|
114
139
|
}
|
package/src/lock/index.ts
CHANGED
|
@@ -38,13 +38,13 @@
|
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
40
|
export { LockManager, ManagedLock } from "./LockManager.ts";
|
|
41
|
-
export type { BlockOptions } from "./LockManager.ts";
|
|
41
|
+
export type { BlockOptions, TryOptions, RefreshOptions, LockedCallback } from "./LockManager.ts";
|
|
42
42
|
|
|
43
43
|
export { Lock } from "./facades/Lock.ts";
|
|
44
44
|
export { LockProvider } from "../provider/LockProvider.ts";
|
|
45
45
|
export { LockConfig } from "./config.ts";
|
|
46
46
|
export type { LockConfigShape } from "./config.ts";
|
|
47
|
-
export { LockNotAcquiredError } from "./errors.ts";
|
|
47
|
+
export { LockNotAcquiredError, LockLostError } from "./errors.ts";
|
|
48
48
|
|
|
49
49
|
// Drivers — exported for custom driver registration and direct instantiation
|
|
50
50
|
export type { LockDriver } from "./drivers/LockDriver.ts";
|
|
@@ -42,6 +42,8 @@ export function loadConfigsSync(configDir: string): Record<string, Record<string
|
|
|
42
42
|
for (const file of glob.scanSync({ cwd: configDir })) {
|
|
43
43
|
if (file === "index.ts") continue;
|
|
44
44
|
const key = file.replace(/\.ts$/, "");
|
|
45
|
+
// Bun macros execute at bundle time, where module loading is synchronous by design.
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
47
|
const loadedModule = require(join(configDir, file)) as Record<string, unknown>;
|
|
46
48
|
result[key] = (loadedModule["default"] ?? loadedModule) as Record<string, unknown>;
|
|
47
49
|
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import type { Application } from "../application/Application.ts";
|
|
6
6
|
import type { ContainerBindings } from "../container/types.ts";
|
|
7
7
|
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
8
|
+
import type { DevProcessDefinition } from "../dev/DevProcess.ts";
|
|
9
|
+
import type { DoctorCheck } from "../doctor/AppDoctor.ts";
|
|
8
10
|
|
|
9
11
|
/** The runtime modes a provider can declare it participates in. */
|
|
10
12
|
export type AppEnvironment = "web" | "console" | "worker" | "test" | "repl";
|
|
@@ -81,4 +83,42 @@ export abstract class ServiceProvider {
|
|
|
81
83
|
replContext(): Record<string, unknown> {
|
|
82
84
|
return {};
|
|
83
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Long-running processes to run beside the server under `bun zt dev`.
|
|
89
|
+
*
|
|
90
|
+
* A package that ships a companion process — a queue worker, a listener, a
|
|
91
|
+
* watcher — declares it here and it appears as its own tab in the deck,
|
|
92
|
+
* individually restartable, without the developer running a second terminal.
|
|
93
|
+
*
|
|
94
|
+
* Called once on a booted app, so it may read config.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* override devProcesses() {
|
|
98
|
+
* return [{ name: "queue", command: "queue:work", enabled: () => this._hasQueue() }];
|
|
99
|
+
* }
|
|
100
|
+
*/
|
|
101
|
+
devProcesses(): DevProcessDefinition[] {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Checks this package contributes to `bun zt doctor`.
|
|
107
|
+
*
|
|
108
|
+
* The declarative counterpart to `app.registerDoctorCheck()`: same checks,
|
|
109
|
+
* same report, but asked of the provider rather than pushed from inside
|
|
110
|
+
* `onRegister()`. Prefer this — it keeps a package's checks next to its other
|
|
111
|
+
* contributions and readable without tracing a registration call.
|
|
112
|
+
*
|
|
113
|
+
* Keep findings machine-readable. `zt doctor` is what an agent runs as the
|
|
114
|
+
* last step of a task, and "looks fine to me" is not a result it can act on.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* override doctorChecks() {
|
|
118
|
+
* return [{ id: "queue-driver", label: "Queue", run: () => ({ status: "ok", message: "sqlite" }) }];
|
|
119
|
+
* }
|
|
120
|
+
*/
|
|
121
|
+
doctorChecks(): DoctorCheck[] {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
84
124
|
}
|
package/src/router/Router.ts
CHANGED
|
@@ -15,6 +15,13 @@ import type {
|
|
|
15
15
|
ModelBindingResolver,
|
|
16
16
|
ViewLayout,
|
|
17
17
|
} from "./Route.ts";
|
|
18
|
+
import type {
|
|
19
|
+
RouteArgs,
|
|
20
|
+
RouteParamValue,
|
|
21
|
+
RouteParamValues,
|
|
22
|
+
RouteQuery,
|
|
23
|
+
RouteTarget,
|
|
24
|
+
} from "./registry.ts";
|
|
18
25
|
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
19
26
|
import type { ExceptionHandler } from "../application/ExceptionHandler.ts";
|
|
20
27
|
import { createRouteHandler } from "./RouteHandler.ts";
|
|
@@ -36,7 +43,8 @@ import {
|
|
|
36
43
|
* The `Route` export from '@zerotal/core' is typed as `typeof Router & RouterMacros`
|
|
37
44
|
* so every declared macro is callable as a static method.
|
|
38
45
|
*/
|
|
39
|
-
//
|
|
46
|
+
// Intentionally empty: it is an augmentation target, and external packages merge
|
|
47
|
+
// their macros in via `declare module`.
|
|
40
48
|
export interface RouterMacros {}
|
|
41
49
|
|
|
42
50
|
// ── Handler wrappers ──────────────────────────────────────────────────────────
|
|
@@ -271,26 +279,99 @@ export interface ViewRegistration {
|
|
|
271
279
|
withLayout(layout: ViewLayout): ViewRegistration;
|
|
272
280
|
}
|
|
273
281
|
|
|
282
|
+
/** Encode one catch-all value: `'guides/intro'` and `['guides','intro']` both give `guides/intro`. */
|
|
283
|
+
function _encodeWildcard(value: RouteParamValue | readonly RouteParamValue[]): string {
|
|
284
|
+
const segments = Array.isArray(value) ? value : String(value).split("/");
|
|
285
|
+
return (segments as readonly RouteParamValue[])
|
|
286
|
+
.map((segment) => encodeURIComponent(String(segment)))
|
|
287
|
+
.filter((segment) => segment.length > 0)
|
|
288
|
+
.join("/");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Serialise the query bag: `null`/`undefined` drop out, arrays repeat the key. */
|
|
292
|
+
function _encodeQuery(query: RouteQuery): string {
|
|
293
|
+
const pairs: string[] = [];
|
|
294
|
+
for (const [key, value] of Object.entries(query)) {
|
|
295
|
+
if (value === null || value === undefined) continue;
|
|
296
|
+
const values = Array.isArray(value) ? value : [value];
|
|
297
|
+
for (const entry of values as readonly (string | number | boolean)[]) {
|
|
298
|
+
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(entry))}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return pairs.join("&");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The {@link route} helper: a checked call signature plus its `dynamic` escape hatch. */
|
|
305
|
+
export interface RouteBuilder {
|
|
306
|
+
/**
|
|
307
|
+
* Generate a URL for a named route, substituting `:param` segments.
|
|
308
|
+
*
|
|
309
|
+
* @param name - The registered route name, e.g. `'posts.show'`.
|
|
310
|
+
* @param args - `params` (one value per `:param`; a catch-all takes the `"*"` key and accepts an array of segments) then optional `query` values.
|
|
311
|
+
* @throws {Error} when the route name is unknown, a required `:param` is missing, or a param key matches no segment.
|
|
312
|
+
*/
|
|
313
|
+
<N extends RouteTarget>(name: N, ...args: RouteArgs<N>): string;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Build a URL for a route name that isn't known at compile time — a name from
|
|
317
|
+
* config, a database row, or a package that registers routes conditionally.
|
|
318
|
+
*
|
|
319
|
+
* The escape hatch is a separate function rather than an overload on purpose:
|
|
320
|
+
* an overload taking `string` is matched by every string, which would let
|
|
321
|
+
* every typo through the front door. This one is greppable, and reads as the
|
|
322
|
+
* exception it is.
|
|
323
|
+
*
|
|
324
|
+
* @param name - The route name, resolved at runtime.
|
|
325
|
+
* @param params - One value per `:param` in the pattern.
|
|
326
|
+
* @param query - Optional query-string values.
|
|
327
|
+
* @throws {Error} on the same conditions as {@link route} — an unknown name still throws.
|
|
328
|
+
*
|
|
329
|
+
* @example
|
|
330
|
+
* route.dynamic(config('app.home_route'), { id })
|
|
331
|
+
*/
|
|
332
|
+
dynamic(name: string, params?: RouteParamValues, query?: RouteQuery): string;
|
|
333
|
+
}
|
|
334
|
+
|
|
274
335
|
/**
|
|
275
|
-
* Generate a URL for a named route, substituting
|
|
276
|
-
*
|
|
336
|
+
* Generate a URL for a named route, substituting `:param` segments.
|
|
337
|
+
*
|
|
338
|
+
* Params are **exact**: a key the pattern has no segment for is a mistake, not a
|
|
339
|
+
* query-string entry, so query values go in the third argument. That is what
|
|
340
|
+
* makes the types worth having — a typo'd param name that silently became
|
|
341
|
+
* `?slugg=hello` is precisely the bug this signature exists to catch.
|
|
277
342
|
*
|
|
278
|
-
*
|
|
343
|
+
* Once `types/routes.generated.ts` exists (`bun zt route:types`) the name and
|
|
344
|
+
* its params are checked at compile time: `route('nope')` and
|
|
345
|
+
* `route('posts.show', {})` are both errors, and the second one names the
|
|
346
|
+
* `slug` it wants. Until then every name is accepted and nothing is checked.
|
|
347
|
+
* For a name that is only known at runtime, use `route.dynamic`.
|
|
279
348
|
*
|
|
280
349
|
* @example
|
|
281
|
-
* route('posts.show', { slug: 'hello' })
|
|
282
|
-
* route('search',
|
|
350
|
+
* route('posts.show', { slug: 'hello' }) // '/posts/hello'
|
|
351
|
+
* route('search', {}, { q: 'reno', page: 2 }) // '/search?q=reno&page=2'
|
|
352
|
+
* route('docs.show', { '*': 'guides/intro' }) // '/docs/guides/intro'
|
|
283
353
|
*
|
|
284
354
|
* @category Naming & URLs
|
|
285
355
|
*/
|
|
286
|
-
export
|
|
356
|
+
export const route: RouteBuilder = Object.assign(
|
|
357
|
+
<N extends RouteTarget>(name: N, ...args: RouteArgs<N>): string => {
|
|
358
|
+
const [params = {}, query = {}] = args as [RouteParamValues?, RouteQuery?];
|
|
359
|
+
return _buildRoute(name, params, query);
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
dynamic: (name: string, params: RouteParamValues = {}, query: RouteQuery = {}): string =>
|
|
363
|
+
_buildRoute(name, params, query),
|
|
364
|
+
},
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
function _buildRoute(name: string, params: RouteParamValues, query: RouteQuery): string {
|
|
287
368
|
const pattern = _s().namedRoutes.get(name);
|
|
288
369
|
if (pattern === undefined) {
|
|
289
370
|
throw new Error(`[Zerotal] Named route not found: "${name}"`);
|
|
290
371
|
}
|
|
291
372
|
|
|
292
373
|
const usedKeys = new Set<string>();
|
|
293
|
-
|
|
374
|
+
let url = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key: string) => {
|
|
294
375
|
const value = params[key];
|
|
295
376
|
if (value === undefined) {
|
|
296
377
|
throw new Error(`[Zerotal] Missing parameter "${key}" for route "${name}"`);
|
|
@@ -300,11 +381,28 @@ export function route(name: string, params: Record<string, string | number> = {}
|
|
|
300
381
|
return encodeURIComponent(String(value));
|
|
301
382
|
});
|
|
302
383
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
384
|
+
// A catch-all segment reaches the router as `*` — the `[...slug]` name is gone
|
|
385
|
+
// by then — so the wildcard is its own param key.
|
|
386
|
+
if (url.includes("*")) {
|
|
387
|
+
const value = params["*"];
|
|
388
|
+
if (value === undefined) {
|
|
389
|
+
throw new Error(`[Zerotal] Missing catch-all parameter "*" for route "${name}"`);
|
|
390
|
+
}
|
|
391
|
+
usedKeys.add("*");
|
|
392
|
+
url = url.replace("*", _encodeWildcard(value));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const unknown = Object.keys(params).filter((key) => !usedKeys.has(key));
|
|
396
|
+
if (unknown.length > 0) {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`[Zerotal] Unknown parameter${unknown.length > 1 ? "s" : ""} ` +
|
|
399
|
+
`${unknown.map((key) => `"${key}"`).join(", ")} for route "${name}" (${pattern}). ` +
|
|
400
|
+
`Query-string values go in the third argument: route(name, params, query).`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
306
403
|
|
|
307
|
-
|
|
404
|
+
const search = _encodeQuery(query);
|
|
405
|
+
return search ? `${url}?${search}` : url;
|
|
308
406
|
}
|
|
309
407
|
|
|
310
408
|
/**
|
|
@@ -1101,7 +1199,7 @@ export class Router {
|
|
|
1101
1199
|
*
|
|
1102
1200
|
* Lets framework packages re-run a route's middleware outside the normal
|
|
1103
1201
|
* HTTP pipeline — e.g. @zerotal/flow re-applies the original page route's
|
|
1104
|
-
* middleware on every WebSocket update (
|
|
1202
|
+
* middleware on every WebSocket update (persistent middleware).
|
|
1105
1203
|
*
|
|
1106
1204
|
* @example
|
|
1107
1205
|
* const middleware = Router.middlewareFor('GET', '/dashboard');
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed route registry and the param types derived from it. `RouteRegistry`
|
|
3
|
+
* is the routing analogue of `ConfigRegistry`: an empty interface filled by
|
|
4
|
+
* declaration merging, so `route("posts.show", { slug })` is checked against the
|
|
5
|
+
* routes the application actually registered.
|
|
6
|
+
*
|
|
7
|
+
* Nothing here is hand-written by an app. `bun zt route:types` boots the app,
|
|
8
|
+
* reads `Router.namedRoutes`, and writes `types/routes.generated.ts`:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* export const ROUTES = {
|
|
12
|
+
* "home": "/",
|
|
13
|
+
* "posts.show": "/posts/:slug",
|
|
14
|
+
* } as const;
|
|
15
|
+
*
|
|
16
|
+
* declare module "@zerotal/core" {
|
|
17
|
+
* interface RouteRegistry extends Routes {}
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Params are *derived* from the pattern rather than generated, so adding a
|
|
22
|
+
* segment to a route changes one string in that file and every call site
|
|
23
|
+
* updates with it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A value that can be substituted into a `:param` segment. */
|
|
27
|
+
export type RouteParamValue = string | number;
|
|
28
|
+
|
|
29
|
+
/** The loose param bag accepted by the untyped `route()` overload. */
|
|
30
|
+
export type RouteParamValues = Record<string, RouteParamValue | readonly RouteParamValue[]>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Query-string values accepted as `route()`'s third argument. `null` and
|
|
34
|
+
* `undefined` entries are dropped; an array repeats the key
|
|
35
|
+
* (`{ tag: ['a','b'] }` → `?tag=a&tag=b`).
|
|
36
|
+
*/
|
|
37
|
+
export type RouteQuery = Record<
|
|
38
|
+
string,
|
|
39
|
+
string | number | boolean | null | undefined | readonly (string | number | boolean)[]
|
|
40
|
+
>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Maps each registered route name to its URL pattern. Augmented by the
|
|
44
|
+
* generated `types/routes.generated.ts`; empty until that file exists, which is
|
|
45
|
+
* why `route()` keeps an untyped overload — an app that never runs the
|
|
46
|
+
* generator (or registers routes behind a config flag) still compiles.
|
|
47
|
+
*
|
|
48
|
+
* @category Extension registries
|
|
49
|
+
*/
|
|
50
|
+
export interface RouteRegistry {}
|
|
51
|
+
|
|
52
|
+
/** Flatten an intersection so editors show `{ slug: string | number }`, not `A & B`. */
|
|
53
|
+
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
54
|
+
|
|
55
|
+
/** Every route name known to the augmented {@link RouteRegistry}. */
|
|
56
|
+
export type RouteName = Extract<keyof RouteRegistry, string>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What `route()` accepts as a name: the registered names once the registry is
|
|
60
|
+
* augmented, any string before that.
|
|
61
|
+
*
|
|
62
|
+
* There is deliberately **no** `route(name: string)` overload alongside the
|
|
63
|
+
* typed one. An overload that accepts every string is matched by every string,
|
|
64
|
+
* so `route("nope")` would compile and the types would be decorative — the
|
|
65
|
+
* failure mode this whole feature exists to remove. A name that genuinely is
|
|
66
|
+
* not known until runtime goes through `route.dynamic()`, which says so at the
|
|
67
|
+
* call site.
|
|
68
|
+
*/
|
|
69
|
+
export type RouteTarget = [RouteName] extends [never] ? string : RouteName;
|
|
70
|
+
|
|
71
|
+
/** The URL pattern registered for route `N`, or plain `string` when it isn't a known name. */
|
|
72
|
+
export type RoutePattern<N extends string> = N extends RouteName
|
|
73
|
+
? RouteRegistry[N] extends string
|
|
74
|
+
? RouteRegistry[N]
|
|
75
|
+
: string
|
|
76
|
+
: string;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The params a URL pattern requires, read straight off the pattern string.
|
|
80
|
+
*
|
|
81
|
+
* - `/posts/:slug` → `{ slug: string | number }`
|
|
82
|
+
* - `/a/:x/b/:y` → `{ x: …; y: … }`
|
|
83
|
+
* - `/docs/*` → `{ "*": … }` — a catch-all (`[...slug]`) compiles to `*` in the
|
|
84
|
+
* URL pattern, so the segment name is gone by the time routing sees it. The
|
|
85
|
+
* key is the wildcard itself, and it accepts an array of segments.
|
|
86
|
+
* - `/about` → `{}`
|
|
87
|
+
*/
|
|
88
|
+
export type ParamsOf<P extends string> = P extends `${string}:${infer Name}/${infer Rest}`
|
|
89
|
+
? { [K in Name]: RouteParamValue } & ParamsOf<`/${Rest}`>
|
|
90
|
+
: P extends `${string}:${infer Name}`
|
|
91
|
+
? { [K in Name]: RouteParamValue }
|
|
92
|
+
: P extends `${string}*${string}`
|
|
93
|
+
? { "*": RouteParamValue | readonly RouteParamValue[] }
|
|
94
|
+
: Record<never, never>;
|
|
95
|
+
|
|
96
|
+
/** The params required by route `N`. */
|
|
97
|
+
export type RouteParams<N extends string> = Prettify<ParamsOf<RoutePattern<N>>>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The param bag a route helper accepts for `N`: exact once the registry is
|
|
101
|
+
* generated, loose before that. Used by the helpers that take params without
|
|
102
|
+
* being able to take `route()`'s rest-tuple — `redirect().to(name, params,
|
|
103
|
+
* status)` and Flow's `redirectRoute`.
|
|
104
|
+
*/
|
|
105
|
+
export type RouteParamsArg<N extends string> = [RouteName] extends [never]
|
|
106
|
+
? RouteParamValues
|
|
107
|
+
: RouteParams<N>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `route()`'s arguments after the name: params are required only when the
|
|
111
|
+
* pattern has a `:param` (or a wildcard), and the query bag is always optional.
|
|
112
|
+
*
|
|
113
|
+
* Before the registry is generated there is no pattern to read, so params stay
|
|
114
|
+
* loose — an app that has never run `zt route:types` compiles exactly as it did
|
|
115
|
+
* before, it just isn't checked.
|
|
116
|
+
*/
|
|
117
|
+
export type RouteArgs<N extends string> = [RouteName] extends [never]
|
|
118
|
+
? [params?: RouteParamValues, query?: RouteQuery]
|
|
119
|
+
: [keyof RouteParams<N>] extends [never]
|
|
120
|
+
? // `Record<string, never>` rather than `{}`: an empty object type accepts
|
|
121
|
+
// any literal, so a static route would quietly swallow `{ page: 2 }`.
|
|
122
|
+
[params?: Record<string, never>, query?: RouteQuery]
|
|
123
|
+
: [params: RouteParams<N>, query?: RouteQuery];
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes `types/routes.generated.ts` — the name → URL pattern map that makes
|
|
3
|
+
* `route()` type-checked.
|
|
4
|
+
*
|
|
5
|
+
* The map is read from a **booted application's** `Router.namedRoutes`, never
|
|
6
|
+
* from the filesystem. Route names come from three places and only one of them
|
|
7
|
+
* is a file path: the file-router's naming convention, an explicit
|
|
8
|
+
* `export const meta = { GET: { name } }`, and programmatic registrations
|
|
9
|
+
* (`Router.get(path, C, 'name')`, a provider's `Router.group()`, Flow's file
|
|
10
|
+
* routes). A globber would see the first and miss the other two, and a second
|
|
11
|
+
* implementation of the naming rules is a second implementation to disagree
|
|
12
|
+
* with the first. Booting makes the generated file true by construction — it is
|
|
13
|
+
* the same map `route()` reads at runtime.
|
|
14
|
+
*
|
|
15
|
+
* The emitted shape is data, not code:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* export const ROUTES = { "posts.show": "/posts/:slug" } as const;
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* one line per route, so adding a param changes one string rather than
|
|
22
|
+
* regenerating a function per route. Params are derived from the pattern by
|
|
23
|
+
* `ParamsOf` (see `registry.ts`), and the same plain object is importable from
|
|
24
|
+
* the browser bundle.
|
|
25
|
+
*/
|
|
26
|
+
import { relative } from "node:path";
|
|
27
|
+
|
|
28
|
+
/** Where the generated map is written, relative to the project root. */
|
|
29
|
+
export const ROUTE_TYPES_FILE = "types/routes.generated.ts";
|
|
30
|
+
|
|
31
|
+
const HEADER = [
|
|
32
|
+
"// Auto-generated by @zerotal/core — do not edit manually.",
|
|
33
|
+
"// Regenerate with: bun zt route:types",
|
|
34
|
+
"//",
|
|
35
|
+
"// Every named route and the URL pattern it compiles to. `route()` reads this",
|
|
36
|
+
"// through declaration merging, so an unknown name or a missing :param is a",
|
|
37
|
+
"// compile error. Commit this file: editors and CI need it without booting the app.",
|
|
38
|
+
"",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render the contents of `types/routes.generated.ts` from a name → pattern map.
|
|
43
|
+
*
|
|
44
|
+
* Names are sorted so the file is stable across boots — provider registration
|
|
45
|
+
* order is not, and an unstable generated file shows up as noise in every diff
|
|
46
|
+
* and as a false failure in `--check`.
|
|
47
|
+
*
|
|
48
|
+
* @param namedRoutes - The router's `namedRoutes` map (name → URL pattern).
|
|
49
|
+
* @param importSpecifier - Module to augment. Defaults to `@zerotal/core`; apps that import the framework through the `zerotal` meta-package still augment core, since that is where `RouteRegistry` is declared.
|
|
50
|
+
* @returns The full file contents, ending in a newline.
|
|
51
|
+
*/
|
|
52
|
+
export function generateRouteTypes(
|
|
53
|
+
namedRoutes: ReadonlyMap<string, string>,
|
|
54
|
+
importSpecifier = "@zerotal/core",
|
|
55
|
+
): string {
|
|
56
|
+
const entries = Array.from(namedRoutes.entries()).sort(([a], [b]) => a.localeCompare(b));
|
|
57
|
+
|
|
58
|
+
// Quote the key only when it isn't a bare identifier — `home` stays bare and
|
|
59
|
+
// `posts.show` stays quoted, which is what a formatter would do to this file
|
|
60
|
+
// anyway. Matching it here keeps `format:check` off a file nobody edits.
|
|
61
|
+
const lines = entries.map(
|
|
62
|
+
([name, pattern]) =>
|
|
63
|
+
` ${/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name)}: ` +
|
|
64
|
+
`${JSON.stringify(pattern)},`,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Empty on one line: an app with no named routes still gets a file a formatter
|
|
68
|
+
// leaves alone (and a registry that types nothing, so `route()` stays on its
|
|
69
|
+
// untyped overload).
|
|
70
|
+
const table =
|
|
71
|
+
lines.length > 0
|
|
72
|
+
? ["export const ROUTES = {", ...lines, "} as const;"]
|
|
73
|
+
: ["export const ROUTES = {} as const;"];
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
...HEADER,
|
|
77
|
+
...table,
|
|
78
|
+
"",
|
|
79
|
+
"/** The generated route table, as a type. */",
|
|
80
|
+
"export type Routes = typeof ROUTES;",
|
|
81
|
+
"",
|
|
82
|
+
`declare module ${JSON.stringify(importSpecifier)} {`,
|
|
83
|
+
" interface RouteRegistry extends Routes {}",
|
|
84
|
+
"}",
|
|
85
|
+
// The file is rewritten on every dev boot; without a trailing newline a
|
|
86
|
+
// formatter in the app would put one back, forever.
|
|
87
|
+
"",
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Outcome of {@link writeRouteTypes}. */
|
|
92
|
+
export interface RouteTypesResult {
|
|
93
|
+
/** Project-relative path of the generated file. */
|
|
94
|
+
path: string;
|
|
95
|
+
/** The contents that should be on disk. */
|
|
96
|
+
content: string;
|
|
97
|
+
/** True when the file on disk differed (or was missing) — i.e. the write changed something. */
|
|
98
|
+
changed: boolean;
|
|
99
|
+
/** How many named routes were written. */
|
|
100
|
+
count: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Write (or, with `check`, verify) `types/routes.generated.ts` for a booted app.
|
|
105
|
+
*
|
|
106
|
+
* @param namedRoutes - The router's `namedRoutes` map.
|
|
107
|
+
* @param options - `cwd` (project root, default `process.cwd()`) and `check` (compare only, never write).
|
|
108
|
+
* @returns Whether the on-disk file was stale, plus the contents it should have.
|
|
109
|
+
*/
|
|
110
|
+
export async function writeRouteTypes(
|
|
111
|
+
namedRoutes: ReadonlyMap<string, string>,
|
|
112
|
+
options: { cwd?: string; check?: boolean } = {},
|
|
113
|
+
): Promise<RouteTypesResult> {
|
|
114
|
+
const cwd = options.cwd ?? process.cwd();
|
|
115
|
+
const content = generateRouteTypes(namedRoutes);
|
|
116
|
+
const target = `${cwd}/${ROUTE_TYPES_FILE}`;
|
|
117
|
+
|
|
118
|
+
const file = Bun.file(target);
|
|
119
|
+
const existing = (await file.exists()) ? await file.text() : null;
|
|
120
|
+
const changed = existing !== content;
|
|
121
|
+
|
|
122
|
+
if (changed && options.check !== true) {
|
|
123
|
+
await Bun.write(target, content);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
path: relative(cwd, target).replace(/\\/g, "/"),
|
|
128
|
+
content,
|
|
129
|
+
changed,
|
|
130
|
+
count: namedRoutes.size,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework's one name for "a class, used as a key".
|
|
3
|
+
*
|
|
4
|
+
* Policies, event listeners, auto-registered middleware, injection metadata,
|
|
5
|
+
* column and relation definitions — all of them are registered against the class
|
|
6
|
+
* itself and read back by walking its prototype chain. A dozen signatures
|
|
7
|
+
* therefore need a type that means *the constructor*, not *an instance*. That
|
|
8
|
+
* type used to be the built-in `Function`, which also accepts a plain arrow
|
|
9
|
+
* function and says nothing about being constructible: the registries would
|
|
10
|
+
* happily key off a callback.
|
|
11
|
+
*
|
|
12
|
+
* `abstract` and `never[]` are both deliberate. A base class is often abstract, a
|
|
13
|
+
* mixin-composed class takes whatever arguments its bases take, and neither
|
|
14
|
+
* should be excluded from a registry key. Nothing that holds a `ClassRef` ever
|
|
15
|
+
* calls the constructor — code that genuinely needs an instance narrows first.
|
|
16
|
+
*
|
|
17
|
+
* This is deliberately not the container's `AbstractToken`: a token is something
|
|
18
|
+
* the container can *resolve*, and its `<T>` is the instance you get back. A
|
|
19
|
+
* `ClassRef` is only ever a map key, so it asserts nothing about instances and
|
|
20
|
+
* returns `unknown` — narrowing `object` here would reject the container's own
|
|
21
|
+
* `new (...args: unknown[]) => T` tokens for a guarantee no caller uses.
|
|
22
|
+
*
|
|
23
|
+
* @internal Every signature that takes one is itself `@internal`: this is the
|
|
24
|
+
* vocabulary first-party packages share for registering metadata, not surface an
|
|
25
|
+
* app writes against.
|
|
26
|
+
*/
|
|
27
|
+
export type ClassRef = abstract new (...args: never[]) => unknown;
|