@zerotal/core 1.3.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/package.json +1 -1
  3. package/src/application/Application.ts +107 -9
  4. package/src/application/DevErrorPage.ts +82 -0
  5. package/src/application/diagnostics.ts +111 -0
  6. package/src/command/CommandRunner.ts +82 -1
  7. package/src/command/builtin/AssetsBuildCommand.ts +102 -0
  8. package/src/command/builtin/DeployCommand.ts +315 -0
  9. package/src/command/builtin/DevCommand.ts +88 -0
  10. package/src/command/builtin/DoctorCommand.ts +97 -0
  11. package/src/command/builtin/MakeCommandCommand.ts +2 -0
  12. package/src/command/builtin/RouteTypesCommand.ts +56 -0
  13. package/src/command/builtin/ServeCommand.ts +232 -44
  14. package/src/command/builtin/index.ts +5 -0
  15. package/src/command/scaffold/zerotal.ts.txt +2 -10
  16. package/src/config/AppConfig.ts +109 -2
  17. package/src/config/DeployConfig.ts +71 -0
  18. package/src/config/index.ts +2 -0
  19. package/src/config/registry.ts +1 -0
  20. package/src/container/Container.ts +3 -3
  21. package/src/container/inject.ts +3 -2
  22. package/src/context/RequestContext.ts +60 -0
  23. package/src/contracts/session.ts +18 -3
  24. package/src/dev/BuildCache.ts +312 -0
  25. package/src/dev/CssPlugins.ts +93 -7
  26. package/src/dev/DevBuildHook.ts +14 -1
  27. package/src/dev/DevDeck.ts +549 -0
  28. package/src/dev/DevOrchestrator.ts +166 -31
  29. package/src/dev/DevProcess.ts +221 -0
  30. package/src/dev/DevReloadMiddleware.ts +1 -1
  31. package/src/dev/DevSupervisor.ts +363 -0
  32. package/src/dev/bootBuild.ts +94 -0
  33. package/src/dev/index.ts +24 -0
  34. package/src/dev/startDevMode.ts +145 -0
  35. package/src/doctor/AppDoctor.ts +399 -0
  36. package/src/doctor/TransportProbe.ts +169 -0
  37. package/src/events/Emitter.ts +4 -3
  38. package/src/facade/facades/App.ts +10 -2
  39. package/src/helpers/index.ts +23 -1
  40. package/src/helpers/response.ts +18 -8
  41. package/src/http/Uri.ts +7 -3
  42. package/src/http/originGuard.ts +1 -1
  43. package/src/http/url.ts +10 -4
  44. package/src/index.ts +43 -0
  45. package/src/lock/LockManager.ts +190 -14
  46. package/src/lock/drivers/LockDriver.ts +11 -0
  47. package/src/lock/drivers/MemoryLockDriver.ts +21 -1
  48. package/src/lock/drivers/RedisLockDriver.ts +64 -8
  49. package/src/lock/drivers/SqliteLockDriver.ts +13 -0
  50. package/src/lock/errors.ts +26 -0
  51. package/src/lock/facades/Lock.ts +30 -5
  52. package/src/lock/index.ts +2 -2
  53. package/src/macros/config.macro.ts +2 -0
  54. package/src/provider/ServiceProvider.ts +40 -0
  55. package/src/router/Router.ts +111 -13
  56. package/src/router/registry.ts +123 -0
  57. package/src/router/routeTypes.ts +132 -0
  58. package/src/support/classRef.ts +27 -0
  59. package/src/support/env.ts +69 -2
  60. package/src/support/unroutedRoutes.ts +37 -0
@@ -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
+ }
@@ -1,7 +1,13 @@
1
1
  import { currentApp } from "../../application/currentApp.ts";
2
2
  import { ZerotalError } from "../../errors/index.ts";
3
- import type { LockManager, ManagedLock, BlockOptions } from "../LockManager.ts";
4
- import { LockNotAcquiredError } from "../errors.ts";
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>(key: string, ttlSeconds: number, callback: () => Promise<T> | T): Promise<T> {
83
- return _manager().try(key, ttlSeconds, callback);
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: () => Promise<T> | T,
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
  }
@@ -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
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- intentional augmentation target: external packages merge macros in via `declare module`.
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 :param segments.
276
- * Extra params that don't match a :segment become query-string entries.
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
- * @throws {Error} when the route name is unknown or a required `:param` is missing.
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' }) // '/posts/hello'
282
- * route('search', { q: 'reno', page: 2 }) // '/search?q=reno&page=2'
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 function route(name: string, params: Record<string, string | number> = {}): string {
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
- const url = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key: string) => {
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
- const queryParams = Object.entries(params)
304
- .filter(([key]) => !usedKeys.has(key))
305
- .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
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
- return queryParams.length > 0 ? `${url}?${queryParams.join("&")}` : url;
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 (Livewire-style persistent middleware).
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;