@zerotal/core 1.5.1 → 1.6.1
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 +84 -0
- package/package.json +2 -1
- package/src/application/Application.ts +11 -3
- package/src/command/builtin/ServeCommand.ts +2 -1
- package/src/config/AppConfig.ts +1 -0
- package/src/dev/buildEnv.ts +41 -0
- package/src/dev/index.ts +1 -0
- package/src/helpers/index.ts +1 -0
- package/src/logger/config.ts +1 -0
- package/src/provider/LogProvider.ts +3 -2
- package/src/router/Router.ts +17 -90
- package/src/router/buildRoute.ts +110 -0
- package/src/router/registry.ts +42 -0
- package/src/router/routes.ts +149 -0
- package/src/support/env.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,90 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.6.1] — 2026-08-15
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`import.meta.env` is defined for bundled browser builds.** `DEV`, `PROD` and `MODE`,
|
|
16
|
+
set from whether the build is a production one. It is a Vite convention rather than a web
|
|
17
|
+
standard — `import.meta.env` is undefined in a browser module — but enough of the npm
|
|
18
|
+
ecosystem branches on it that a bundler leaving it alone ships code reading a property off
|
|
19
|
+
`undefined`. Bun leaves it alone, so Zerotal defines it.
|
|
20
|
+
|
|
21
|
+
`BASE_URL` and `SSR` are deliberately omitted: Zerotal has its own answers for both, and a
|
|
22
|
+
wrong value is worse than an absent one for a package that feature-detects.
|
|
23
|
+
|
|
24
|
+
## [1.6.0] — 2026-08-15
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **`route()` in the browser — `@zerotal/core/routes`.** The typed `route()` helper now has a
|
|
29
|
+
browser twin. Hand it the table `bun zt route:types` already generates, once, at your
|
|
30
|
+
entry point:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { defineRoutes } from "zerotal/routes";
|
|
34
|
+
import { ROUTES } from "../../types/routes.generated";
|
|
35
|
+
|
|
36
|
+
defineRoutes(ROUTES);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
and `route("posts.show", { slug })` works in a component exactly as it does in a
|
|
40
|
+
controller. The table is a build-time constant, so it costs one static import, ships
|
|
41
|
+
nothing per response, and needs no fetch before the first link renders. `hasRoute(name)`
|
|
42
|
+
answers the conditional-link question without a try/catch, and `resetRoutes()` clears the
|
|
43
|
+
table for tests.
|
|
44
|
+
|
|
45
|
+
The two helpers are one implementation, not two that agree today. Everything a caller can
|
|
46
|
+
observe — param encoding, catch-all handling, which mistakes throw and what they say —
|
|
47
|
+
moved into a shared builder; only the table lookup differs (the live router on the server,
|
|
48
|
+
the generated map in the browser). Both are typed as the same `RouteBuilder` interface, and
|
|
49
|
+
both read the same `RouteRegistry`, so a name that type-checks in a controller type-checks
|
|
50
|
+
in a component and a missing `:param` fails the build on either side. A parity test asserts
|
|
51
|
+
the two produce byte-identical URLs and identical error messages.
|
|
52
|
+
|
|
53
|
+
The entry point is browser-safe by construction: it imports nothing that touches `Bun`,
|
|
54
|
+
the container, or request state.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **Ten more places asked `APP_ENV` a question it cannot answer.** 1.5.0 and 1.5.1 each
|
|
59
|
+
fixed the instances that had been noticed; this is the audit of every remaining reader,
|
|
60
|
+
and it found more than either. `APP_ENV` holds the runtime mode once `setAppEnv()` has
|
|
61
|
+
run, so any check comparing it against a deployment name was asking whether `"web"` is
|
|
62
|
+
production. In core and its packages that meant:
|
|
63
|
+
|
|
64
|
+
- **auto-`synchronize` was never hard-off in production**, so the only thing between a
|
|
65
|
+
production database and boot-time schema sync was the config default. The comment above
|
|
66
|
+
the guard claimed `APP_ENV` still held the deployment name, which is precisely the
|
|
67
|
+
mistake;
|
|
68
|
+
- **the Flow client bundle was never minified in production**, shipping ~183 KB
|
|
69
|
+
unminified to every visitor;
|
|
70
|
+
- **`forceState()`** did not refuse to run on live data despite the throw written for it;
|
|
71
|
+
- **environment-scoped scheduled tasks never matched**, so `.environments(["production"])`
|
|
72
|
+
silently never ran;
|
|
73
|
+
- the N+1 detector's own gate, the monitor's reported environment, the admin environment
|
|
74
|
+
badge, the large-snapshot warning, and four config-first reads whose fallback was the
|
|
75
|
+
runtime mode.
|
|
76
|
+
|
|
77
|
+
All of them read `deployEnv()` now. Everything still fails closed: production, staging and
|
|
78
|
+
an unset environment behave exactly as before.
|
|
79
|
+
|
|
80
|
+
- **`useOnce()` demanded a cast from every caller.** `PipeClass` used `unknown[]` for its
|
|
81
|
+
constructor arguments, which fails parameter contravariance for any middleware class with
|
|
82
|
+
a typed constructor — so all eight packages that register middleware wrote
|
|
83
|
+
`useOnce(Middleware as never)`, twelve times over. It uses the codebase's standard
|
|
84
|
+
`any[]` constructor shape now, the same reasoning `container/types.ts` already documented
|
|
85
|
+
for container tokens, and all twelve casts are gone.
|
|
86
|
+
|
|
87
|
+
### Changed
|
|
88
|
+
|
|
89
|
+
- **Reading `Bun.env["APP_ENV"]` directly is now a lint error.** Fourteen instances of one
|
|
90
|
+
mistake across seven packages were each found separately; the rule is so the fifteenth is
|
|
91
|
+
found by CI. Tests are exempt — pinning the variable is how a test reproduces what a
|
|
92
|
+
server sees — and the handful of genuine runtime-mode reads carry an inline disable saying
|
|
93
|
+
which of the two meanings they want.
|
|
94
|
+
|
|
11
95
|
## [1.5.1] — 2026-08-15
|
|
12
96
|
|
|
13
97
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"types": "./src/index.ts",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.ts",
|
|
12
|
+
"./routes": "./src/router/routes.ts",
|
|
12
13
|
"./contracts": "./src/contracts/index.ts",
|
|
13
14
|
"./lock": "./src/lock/index.ts",
|
|
14
15
|
"./logger": "./src/logger/index.ts",
|
|
@@ -157,7 +157,14 @@ type DeferrableProviderClass = ProviderClass & {
|
|
|
157
157
|
provides: readonly (keyof ContainerBindings)[];
|
|
158
158
|
};
|
|
159
159
|
type Environment = "web" | "console" | "worker" | "test" | "repl";
|
|
160
|
-
|
|
160
|
+
// `args: any[]`, the codebase's standard constructor shape — the same reasoning
|
|
161
|
+
// `container/types.ts` already writes down for `ClassToken`: `unknown[]` fails
|
|
162
|
+
// constructor-parameter contravariance at the call site, so any middleware class
|
|
163
|
+
// with a typed constructor is rejected. Every provider that registers one worked
|
|
164
|
+
// around it the same way, `useOnce(Middleware)`, eleven times across eight
|
|
165
|
+
// packages — a cast the framework was asking for rather than one anybody wanted.
|
|
166
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- middleware classes carry their own constructor shapes
|
|
167
|
+
type PipeClass = new (...args: any[]) => Pipe<HttpContext>;
|
|
161
168
|
|
|
162
169
|
/** Options form for `Application.create({ ... })`. */
|
|
163
170
|
export interface CreateOptions {
|
|
@@ -452,6 +459,7 @@ export class Application {
|
|
|
452
459
|
);
|
|
453
460
|
}
|
|
454
461
|
|
|
462
|
+
// eslint-disable-next-line no-restricted-syntax -- runtime mode is exactly what Application.create() wants; _normaliseEnv maps deployment names onto it
|
|
455
463
|
const rawEnv = options.env ?? Bun.env["APP_ENV"] ?? "web";
|
|
456
464
|
const resolvedEnv: Environment = _normaliseEnv(rawEnv);
|
|
457
465
|
|
|
@@ -861,7 +869,7 @@ export class Application {
|
|
|
861
869
|
// Inject the live-reload client (and any registered dev snippets) into HTML
|
|
862
870
|
// responses, so auto-reload works for every view layer, not just Inertia.
|
|
863
871
|
setDevReloadClientActive(true);
|
|
864
|
-
this.useOnce(DevReloadMiddleware
|
|
872
|
+
this.useOnce(DevReloadMiddleware);
|
|
865
873
|
return this;
|
|
866
874
|
}
|
|
867
875
|
|
|
@@ -1055,7 +1063,7 @@ export class Application {
|
|
|
1055
1063
|
// elsewhere it warns. Skipped in the test harness to keep output clean.
|
|
1056
1064
|
if (this._env !== "test" && this._configValidators.length > 0) {
|
|
1057
1065
|
const configManager = this.container.makeSync("config") as ConfigManager;
|
|
1058
|
-
const appEnv = configManager.get<string>("app.env",
|
|
1066
|
+
const appEnv = configManager.get<string>("app.env", deployEnv() || "development");
|
|
1059
1067
|
runConfigValidators(this._configValidators, configManager, isProdLike(appEnv));
|
|
1060
1068
|
}
|
|
1061
1069
|
|
|
@@ -7,6 +7,7 @@ import type { FlagDef } from "../Command.ts";
|
|
|
7
7
|
import { hasDevBuildHooks, runDevBuildHooks } from "../../dev/DevBuildHook.ts";
|
|
8
8
|
import { buildConfiguredAssets, type AssetBuildConfig } from "../../dev/CssPlugins.ts";
|
|
9
9
|
import { bootBuildDecision } from "../../dev/bootBuild.ts";
|
|
10
|
+
import { deployEnv } from "../../support/env.ts";
|
|
10
11
|
import { collectDevProcesses, type ResolvedDevProcess } from "../../dev/DevProcess.ts";
|
|
11
12
|
import { startDevMode, SERVER_PROCESS_NAME } from "../../dev/startDevMode.ts";
|
|
12
13
|
import type { ConfigManager } from "../../config/ConfigManager.ts";
|
|
@@ -456,7 +457,7 @@ export class ServeCommand extends Command {
|
|
|
456
457
|
// down is doing the right thing; rebuilding at boot would only fail. See bootBuild.ts.
|
|
457
458
|
const decision = await bootBuildDecision(
|
|
458
459
|
[`${process.cwd()}/${assets.outDir}`],
|
|
459
|
-
(this._appEnv() ??
|
|
460
|
+
(this._appEnv() ?? deployEnv()) as string | undefined,
|
|
460
461
|
);
|
|
461
462
|
if (!decision.build) {
|
|
462
463
|
this.info(decision.reason ?? "Skipping the boot-time asset build.");
|
package/src/config/AppConfig.ts
CHANGED
|
@@ -346,6 +346,7 @@ export function AppConfig(options: {
|
|
|
346
346
|
// overrides (e.g. `conventions.paths.models`) keep every other default in place.
|
|
347
347
|
const defaults: AppConfigShape = {
|
|
348
348
|
name: "Zerotal App",
|
|
349
|
+
// eslint-disable-next-line no-restricted-syntax -- config is loaded before setAppEnv() runs, so APP_ENV still holds the deployment name here
|
|
349
350
|
env: Bun.env["APP_ENV"] ?? "development",
|
|
350
351
|
key: Bun.env["APP_KEY"] ?? "",
|
|
351
352
|
debug: Bun.env["APP_DEBUG"] !== "false",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `import.meta.env` members a bundled browser build is expected to carry.
|
|
3
|
+
*
|
|
4
|
+
* These are a Vite convention, not a web standard — `import.meta.env` is
|
|
5
|
+
* undefined in a browser module — but enough of the npm ecosystem branches on
|
|
6
|
+
* them that a bundler which leaves them alone ships code that reads a property
|
|
7
|
+
* off `undefined`. Bun's bundler leaves them alone, so we define them.
|
|
8
|
+
*
|
|
9
|
+
* The case that found this: the Inertia DevTools browser extension relies on
|
|
10
|
+
* client-side hooks that `createInertiaApp()` enables from its `dev` option,
|
|
11
|
+
* and that option's default is literally `import.meta.env.DEV`. Under Bun that
|
|
12
|
+
* expression survived into the bundle, so the panel reported the app was "not
|
|
13
|
+
* running in dev mode" and told the developer to start a Vite server they do
|
|
14
|
+
* not have — advice that cannot be followed in a Zerotal app.
|
|
15
|
+
*
|
|
16
|
+
* Only the three members whose meaning is unambiguous are defined. `BASE_URL`
|
|
17
|
+
* and `SSR` are deliberately omitted: Zerotal has its own answers for both
|
|
18
|
+
* (`app.assets.prefix`, and SSR being a server concern), and a wrong value is
|
|
19
|
+
* worse than an absent one for a package that feature-detects.
|
|
20
|
+
*
|
|
21
|
+
* @param isProduction Whether this build is for a deployment.
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export function browserEnvDefines(isProduction: boolean): Record<string, string> {
|
|
25
|
+
return {
|
|
26
|
+
// The WHOLE object, not `import.meta.env.DEV` member by member. Adapters write
|
|
27
|
+
// `import.meta.env?.DEV` — optional-chained, which is why an unbundled
|
|
28
|
+
// `import.meta.env` yields `false` instead of throwing — and a define keyed on
|
|
29
|
+
// `import.meta.env.DEV` does not match `import.meta.env?.DEV`, so the member
|
|
30
|
+
// form silently changes nothing. Replacing the object satisfies both spellings.
|
|
31
|
+
//
|
|
32
|
+
// Members this does not name resolve to `undefined` rather than throwing, which
|
|
33
|
+
// is the right answer for a bundler that is not Vite: code that feature-detects
|
|
34
|
+
// `import.meta.env.SSR` or a `VITE_*` variable gets a clean miss.
|
|
35
|
+
"import.meta.env": JSON.stringify({
|
|
36
|
+
DEV: !isProduction,
|
|
37
|
+
PROD: isProduction,
|
|
38
|
+
MODE: isProduction ? "production" : "development",
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/dev/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export type { BuildHookFn, BuildResult } from "./DevBuildHook.ts";
|
|
|
30
30
|
export { DevReloadMiddleware, registerDevHtmlSnippet } from "./DevReloadMiddleware.ts";
|
|
31
31
|
export type { DevHtmlSnippet } from "./DevReloadMiddleware.ts";
|
|
32
32
|
export { DEV_RELOAD_CLIENT } from "./reloadClient.ts";
|
|
33
|
+
export { browserEnvDefines } from "./buildEnv.ts";
|
|
33
34
|
export { detectCssPlugins, buildCssBundle, buildJsBundle } from "./CssPlugins.ts";
|
|
34
35
|
export type { AssetBuildConfig } from "./CssPlugins.ts";
|
|
35
36
|
export { pruneBuildOutput } from "./BuildOutput.ts";
|
package/src/helpers/index.ts
CHANGED
|
@@ -67,6 +67,7 @@ export function basePath(...segments: string[]): string {
|
|
|
67
67
|
*/
|
|
68
68
|
export function setAppEnv(command?: string): void {
|
|
69
69
|
const normalizedCommand = (command ?? "").toLowerCase();
|
|
70
|
+
// eslint-disable-next-line no-restricted-syntax -- this IS setAppEnv — reading the pre-overwrite value is the whole job
|
|
70
71
|
const current = Bun.env["APP_ENV"];
|
|
71
72
|
const environment = Bun.env as Record<string, string>;
|
|
72
73
|
|
package/src/logger/config.ts
CHANGED
|
@@ -21,6 +21,7 @@ export const DEFAULT_LOG_RETENTION_DAYS = 14;
|
|
|
21
21
|
* A test that wants the trail asks for it explicitly.
|
|
22
22
|
*/
|
|
23
23
|
function _fileDefault(): FileSinkConfig {
|
|
24
|
+
// eslint-disable-next-line no-restricted-syntax -- asks about the test/testing runtime modes, not the deployment
|
|
24
25
|
const env = (Bun.env["APP_ENV"] ?? "").trim().toLowerCase();
|
|
25
26
|
if (env === "test" || env === "testing") return false;
|
|
26
27
|
return { path: DEFAULT_LOG_PATH, days: DEFAULT_LOG_RETENTION_DAYS };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ServiceProvider } from "./ServiceProvider.ts";
|
|
2
2
|
import type { AppEnvironment } from "./ServiceProvider.ts";
|
|
3
3
|
import { FrameworkEvents } from "../events/FrameworkEvents.ts";
|
|
4
|
+
import { deployEnv } from "../support/env.ts";
|
|
4
5
|
import type {
|
|
5
6
|
// Application lifecycle
|
|
6
7
|
AppBooted,
|
|
@@ -60,7 +61,7 @@ export class LogProvider extends ServiceProvider {
|
|
|
60
61
|
const cfg = (await c.make("config")) as { get<T>(path: string): T | undefined };
|
|
61
62
|
const logging = cfg.get<LoggingConfigShape>("logging") ?? _defaultConfig();
|
|
62
63
|
const appName = cfg.get<string>("app.name");
|
|
63
|
-
const appEnv = cfg.get<string>("app.env") ??
|
|
64
|
+
const appEnv = cfg.get<string>("app.env") ?? deployEnv();
|
|
64
65
|
|
|
65
66
|
return new LogManager(logging, { app: appName, env: appEnv });
|
|
66
67
|
});
|
|
@@ -75,7 +76,7 @@ export class LogProvider extends ServiceProvider {
|
|
|
75
76
|
get<T>(path: string): T | undefined;
|
|
76
77
|
};
|
|
77
78
|
const requests = cfg.get<LoggingConfigShape>("logging")?.requests ?? true;
|
|
78
|
-
if (requests) this.app.useOnce(LoggerMiddleware
|
|
79
|
+
if (requests) this.app.useOnce(LoggerMiddleware);
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
override async onBooted(): Promise<void> {
|
package/src/router/Router.ts
CHANGED
|
@@ -17,11 +17,12 @@ import type {
|
|
|
17
17
|
} from "./Route.ts";
|
|
18
18
|
import type {
|
|
19
19
|
RouteArgs,
|
|
20
|
-
|
|
20
|
+
RouteBuilder,
|
|
21
21
|
RouteParamValues,
|
|
22
22
|
RouteQuery,
|
|
23
23
|
RouteTarget,
|
|
24
24
|
} from "./registry.ts";
|
|
25
|
+
import { buildRouteUrl, unknownRouteError } from "./buildRoute.ts";
|
|
25
26
|
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
26
27
|
import type { ExceptionHandler } from "../application/ExceptionHandler.ts";
|
|
27
28
|
import { createRouteHandler } from "./RouteHandler.ts";
|
|
@@ -279,58 +280,12 @@ export interface ViewRegistration {
|
|
|
279
280
|
withLayout(layout: ViewLayout): ViewRegistration;
|
|
280
281
|
}
|
|
281
282
|
|
|
282
|
-
/**
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
}
|
|
283
|
+
/**
|
|
284
|
+
* The {@link route} helper's call signature. Declared in `registry.ts` and
|
|
285
|
+
* shared with the browser's `route()` (`@zerotal/core/routes`) so the two are
|
|
286
|
+
* the same signature, not two that happen to look alike.
|
|
287
|
+
*/
|
|
288
|
+
export type { RouteBuilder } from "./registry.ts";
|
|
334
289
|
|
|
335
290
|
/**
|
|
336
291
|
* Generate a URL for a named route, substituting `:param` segments.
|
|
@@ -364,45 +319,17 @@ export const route: RouteBuilder = Object.assign(
|
|
|
364
319
|
},
|
|
365
320
|
);
|
|
366
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Look the name up in the live router, then hand off to the shared builder.
|
|
324
|
+
*
|
|
325
|
+
* The lookup is the *only* server-specific part of `route()`: it reads
|
|
326
|
+
* `_s().namedRoutes`, which is per-application and resets between tests. The
|
|
327
|
+
* URL semantics live in `buildRoute.ts`, shared with the browser build.
|
|
328
|
+
*/
|
|
367
329
|
function _buildRoute(name: string, params: RouteParamValues, query: RouteQuery): string {
|
|
368
330
|
const pattern = _s().namedRoutes.get(name);
|
|
369
|
-
if (pattern === undefined)
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const usedKeys = new Set<string>();
|
|
374
|
-
let url = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key: string) => {
|
|
375
|
-
const value = params[key];
|
|
376
|
-
if (value === undefined) {
|
|
377
|
-
throw new Error(`[Zerotal] Missing parameter "${key}" for route "${name}"`);
|
|
378
|
-
}
|
|
379
|
-
usedKeys.add(key);
|
|
380
|
-
// Encode path params so values containing `/ ? #` cannot mangle the URL.
|
|
381
|
-
return encodeURIComponent(String(value));
|
|
382
|
-
});
|
|
383
|
-
|
|
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
|
-
}
|
|
403
|
-
|
|
404
|
-
const search = _encodeQuery(query);
|
|
405
|
-
return search ? `${url}?${search}` : url;
|
|
331
|
+
if (pattern === undefined) throw unknownRouteError(name);
|
|
332
|
+
return buildRouteUrl(name, pattern, params, query);
|
|
406
333
|
}
|
|
407
334
|
|
|
408
335
|
/**
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The URL-building half of `route()`: pattern in, URL out. No table, no router,
|
|
3
|
+
* no request — which is the point.
|
|
4
|
+
*
|
|
5
|
+
* `route()` exists twice, once against the server's live `Router.namedRoutes`
|
|
6
|
+
* and once against the generated `ROUTES` table in the browser bundle. Only the
|
|
7
|
+
* *lookup* differs; everything a caller can actually observe — how a `:param` is
|
|
8
|
+
* encoded, what a catch-all accepts, which mistakes throw and what they say —
|
|
9
|
+
* lives here so the two cannot drift. A client `route()` that encoded params
|
|
10
|
+
* differently from the server's would be worse than no client `route()` at all:
|
|
11
|
+
* the links would be wrong only for the values nobody tests with.
|
|
12
|
+
*
|
|
13
|
+
* Nothing in this file may import anything that touches `Bun`, `process`, the
|
|
14
|
+
* container, or request state. It is bundled into browsers.
|
|
15
|
+
*/
|
|
16
|
+
import type { RouteParamValue, RouteParamValues, RouteQuery } from "./registry.ts";
|
|
17
|
+
|
|
18
|
+
/** Encode one catch-all value: `'guides/intro'` and `['guides','intro']` both give `guides/intro`. */
|
|
19
|
+
function encodeWildcard(value: RouteParamValue | readonly RouteParamValue[]): string {
|
|
20
|
+
const segments = Array.isArray(value) ? value : String(value).split("/");
|
|
21
|
+
return (segments as readonly RouteParamValue[])
|
|
22
|
+
.map((segment) => encodeURIComponent(String(segment)))
|
|
23
|
+
.filter((segment) => segment.length > 0)
|
|
24
|
+
.join("/");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Serialise the query bag: `null`/`undefined` drop out, arrays repeat the key. */
|
|
28
|
+
export function encodeRouteQuery(query: RouteQuery): string {
|
|
29
|
+
const pairs: string[] = [];
|
|
30
|
+
for (const [key, value] of Object.entries(query)) {
|
|
31
|
+
if (value === null || value === undefined) continue;
|
|
32
|
+
const values = Array.isArray(value) ? value : [value];
|
|
33
|
+
for (const entry of values as readonly (string | number | boolean)[]) {
|
|
34
|
+
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(entry))}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return pairs.join("&");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The error both `route()` implementations throw for a name that isn't registered.
|
|
42
|
+
*
|
|
43
|
+
* Shared so the server and the browser report an unknown name identically — a
|
|
44
|
+
* name that resolves in one and throws in the other is the exact confusion this
|
|
45
|
+
* module exists to prevent.
|
|
46
|
+
*
|
|
47
|
+
* @param name - The name that was looked up.
|
|
48
|
+
* @returns The error to throw; the caller throws it so the stack starts at the call site.
|
|
49
|
+
*/
|
|
50
|
+
export function unknownRouteError(name: string): Error {
|
|
51
|
+
return new Error(`[Zerotal] Named route not found: "${name}"`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Substitute `params` into a URL pattern and append `query`.
|
|
56
|
+
*
|
|
57
|
+
* Params are **exact**: a key the pattern has no segment for throws rather than
|
|
58
|
+
* quietly becoming a query-string entry. That rule is the reason the typed
|
|
59
|
+
* signature is worth having — a typo'd param name that silently became
|
|
60
|
+
* `?slugg=hello` is the bug the whole feature exists to catch — so it is
|
|
61
|
+
* enforced at runtime too, for callers on the untyped path (`route.dynamic`, or
|
|
62
|
+
* an app that has never run `zt route:types`).
|
|
63
|
+
*
|
|
64
|
+
* @param name - The route name, used only in error messages.
|
|
65
|
+
* @param pattern - The URL pattern the name resolved to, e.g. `/posts/:slug`.
|
|
66
|
+
* @param params - One value per `:param`; a catch-all takes the `"*"` key.
|
|
67
|
+
* @param query - Query-string values, appended after the path.
|
|
68
|
+
* @returns The built URL path (with query string when there is one).
|
|
69
|
+
* @throws {Error} when a required `:param` is missing or a param key matches no segment.
|
|
70
|
+
*/
|
|
71
|
+
export function buildRouteUrl(
|
|
72
|
+
name: string,
|
|
73
|
+
pattern: string,
|
|
74
|
+
params: RouteParamValues,
|
|
75
|
+
query: RouteQuery,
|
|
76
|
+
): string {
|
|
77
|
+
const usedKeys = new Set<string>();
|
|
78
|
+
let url = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key: string) => {
|
|
79
|
+
const value = params[key];
|
|
80
|
+
if (value === undefined) {
|
|
81
|
+
throw new Error(`[Zerotal] Missing parameter "${key}" for route "${name}"`);
|
|
82
|
+
}
|
|
83
|
+
usedKeys.add(key);
|
|
84
|
+
// Encode path params so values containing `/ ? #` cannot mangle the URL.
|
|
85
|
+
return encodeURIComponent(String(value));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// A catch-all segment reaches the router as `*` — the `[...slug]` name is gone
|
|
89
|
+
// by then — so the wildcard is its own param key.
|
|
90
|
+
if (url.includes("*")) {
|
|
91
|
+
const value = params["*"];
|
|
92
|
+
if (value === undefined) {
|
|
93
|
+
throw new Error(`[Zerotal] Missing catch-all parameter "*" for route "${name}"`);
|
|
94
|
+
}
|
|
95
|
+
usedKeys.add("*");
|
|
96
|
+
url = url.replace("*", encodeWildcard(value));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const unknown = Object.keys(params).filter((key) => !usedKeys.has(key));
|
|
100
|
+
if (unknown.length > 0) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`[Zerotal] Unknown parameter${unknown.length > 1 ? "s" : ""} ` +
|
|
103
|
+
`${unknown.map((key) => `"${key}"`).join(", ")} for route "${name}" (${pattern}). ` +
|
|
104
|
+
`Query-string values go in the third argument: route(name, params, query).`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const search = encodeRouteQuery(query);
|
|
109
|
+
return search ? `${url}?${search}` : url;
|
|
110
|
+
}
|
package/src/router/registry.ts
CHANGED
|
@@ -121,3 +121,45 @@ export type RouteArgs<N extends string> = [RouteName] extends [never]
|
|
|
121
121
|
// any literal, so a static route would quietly swallow `{ page: 2 }`.
|
|
122
122
|
[params?: Record<string, never>, query?: RouteQuery]
|
|
123
123
|
: [params: RouteParams<N>, query?: RouteQuery];
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The `route()` helper: a checked call signature plus its `dynamic` escape hatch.
|
|
127
|
+
*
|
|
128
|
+
* Declared here, next to the types it is built from, because `route()` is
|
|
129
|
+
* implemented twice — once on the server against `Router.namedRoutes`, once in
|
|
130
|
+
* the browser against the generated `ROUTES` table. Both are typed as this
|
|
131
|
+
* interface, so the two call sites cannot drift apart in what they accept: a
|
|
132
|
+
* component that renders on the server and hydrates in the browser sees one
|
|
133
|
+
* signature either way.
|
|
134
|
+
*
|
|
135
|
+
* @category Naming & URLs
|
|
136
|
+
*/
|
|
137
|
+
export interface RouteBuilder {
|
|
138
|
+
/**
|
|
139
|
+
* Generate a URL for a named route, substituting `:param` segments.
|
|
140
|
+
*
|
|
141
|
+
* @param name - The registered route name, e.g. `'posts.show'`.
|
|
142
|
+
* @param args - `params` (one value per `:param`; a catch-all takes the `"*"` key and accepts an array of segments) then optional `query` values.
|
|
143
|
+
* @throws {Error} when the route name is unknown, a required `:param` is missing, or a param key matches no segment.
|
|
144
|
+
*/
|
|
145
|
+
<N extends RouteTarget>(name: N, ...args: RouteArgs<N>): string;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build a URL for a route name that isn't known at compile time — a name from
|
|
149
|
+
* config, a database row, or a package that registers routes conditionally.
|
|
150
|
+
*
|
|
151
|
+
* The escape hatch is a separate function rather than an overload on purpose:
|
|
152
|
+
* an overload taking `string` is matched by every string, which would let
|
|
153
|
+
* every typo through the front door. This one is greppable, and reads as the
|
|
154
|
+
* exception it is.
|
|
155
|
+
*
|
|
156
|
+
* @param name - The route name, resolved at runtime.
|
|
157
|
+
* @param params - One value per `:param` in the pattern.
|
|
158
|
+
* @param query - Optional query-string values.
|
|
159
|
+
* @throws {Error} on the same conditions as `route()` — an unknown name still throws.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* route.dynamic(config('app.home_route'), { id })
|
|
163
|
+
*/
|
|
164
|
+
dynamic(name: string, params?: RouteParamValues, query?: RouteQuery): string;
|
|
165
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `route()` for the browser — the `@zerotal/core/routes` entry point.
|
|
3
|
+
*
|
|
4
|
+
* The server's `route()` (from `@zerotal/core`) reads the live router, which
|
|
5
|
+
* only exists in the server process. This one reads a table you hand it at
|
|
6
|
+
* boot: the `ROUTES` object that `bun zt route:types` already generates and
|
|
7
|
+
* commits.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // resources/js/app.js — once, before anything renders
|
|
11
|
+
* import { defineRoutes } from "@zerotal/core/routes";
|
|
12
|
+
* import { ROUTES } from "../../types/routes.generated";
|
|
13
|
+
*
|
|
14
|
+
* defineRoutes(ROUTES);
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* // anywhere in a page or component
|
|
19
|
+
* import { route } from "@zerotal/core/routes";
|
|
20
|
+
*
|
|
21
|
+
* route("posts.show", { slug }); // "/posts/hello"
|
|
22
|
+
* route("posts.index", {}, { page: 2 }); // "/posts?page=2"
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* The table is a build-time constant, so it costs one static import and
|
|
26
|
+
* tree-shakes to the routes you keep — nothing is shipped per response and
|
|
27
|
+
* nothing has to be fetched before the first link renders.
|
|
28
|
+
*
|
|
29
|
+
* **Types come from the same place as the server's.** The generated file
|
|
30
|
+
* augments `RouteRegistry` in `@zerotal/core`, and both `route()`s are typed as
|
|
31
|
+
* the one `RouteBuilder` interface, so a name that type-checks in a controller
|
|
32
|
+
* type-checks in a component and a missing `:param` fails the build on either
|
|
33
|
+
* side.
|
|
34
|
+
*
|
|
35
|
+
* **This module is isomorphic.** Inertia renders pages twice — once in the SSR
|
|
36
|
+
* process, once in the browser — so a component importing `route` from here
|
|
37
|
+
* runs in both. Call {@link defineRoutes} in each entry (`app.js` and `ssr.js`);
|
|
38
|
+
* it is the same static import, and the same table.
|
|
39
|
+
*
|
|
40
|
+
* @module
|
|
41
|
+
*/
|
|
42
|
+
import type {
|
|
43
|
+
RouteArgs,
|
|
44
|
+
RouteBuilder,
|
|
45
|
+
RouteParamValues,
|
|
46
|
+
RouteQuery,
|
|
47
|
+
RouteTarget,
|
|
48
|
+
} from "./registry.ts";
|
|
49
|
+
import { buildRouteUrl, unknownRouteError } from "./buildRoute.ts";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The name → pattern map `route()` resolves against. A plain object is what
|
|
53
|
+
* `types/routes.generated.ts` exports; a `Map` is accepted so a server-side
|
|
54
|
+
* caller can pass `Router.namedRoutes` straight through.
|
|
55
|
+
*/
|
|
56
|
+
export type RouteTable = Readonly<Record<string, string>> | ReadonlyMap<string, string>;
|
|
57
|
+
|
|
58
|
+
/** `null` until `defineRoutes()` runs — distinct from "defined but empty", which is a valid state. */
|
|
59
|
+
let _table: ReadonlyMap<string, string> | null = null;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Install the route table `route()` resolves against.
|
|
63
|
+
*
|
|
64
|
+
* Call it once per entry point, before the first render. Calling it again
|
|
65
|
+
* replaces the table, which is what makes the dev server's hot reload work —
|
|
66
|
+
* the entry re-runs and the new table wins.
|
|
67
|
+
*
|
|
68
|
+
* @param table - The generated `ROUTES` object, or any name → pattern map.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* import { ROUTES } from "../../types/routes.generated";
|
|
72
|
+
* defineRoutes(ROUTES);
|
|
73
|
+
*/
|
|
74
|
+
export function defineRoutes(table: RouteTable): void {
|
|
75
|
+
_table = table instanceof Map ? table : new Map(Object.entries(table));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Forget the installed table, putting `route()` back into its "not configured"
|
|
80
|
+
* state. For tests that assert on the unconfigured error — application code
|
|
81
|
+
* wants {@link defineRoutes} instead.
|
|
82
|
+
*/
|
|
83
|
+
export function resetRoutes(): void {
|
|
84
|
+
_table = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether `name` is in the installed table.
|
|
89
|
+
*
|
|
90
|
+
* Useful for the conditional links a client bundle cannot resolve by other
|
|
91
|
+
* means: a nav item for a route that only exists when a package is installed,
|
|
92
|
+
* or an admin link a public build never registers. Returns `false` — rather
|
|
93
|
+
* than throwing — when no table has been installed.
|
|
94
|
+
*
|
|
95
|
+
* @param name - The route name to look for.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* {hasRoute("admin.index") && <a href={route("admin.index")}>Admin</a>}
|
|
99
|
+
*/
|
|
100
|
+
export function hasRoute(name: string): boolean {
|
|
101
|
+
return _table?.has(name) ?? false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Resolve a pattern or throw the message that tells the caller what to fix. */
|
|
105
|
+
function _pattern(name: string): string {
|
|
106
|
+
if (_table === null) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[Zerotal] route("${name}") was called before the route table was installed. ` +
|
|
109
|
+
`Add this to your client entry (and your SSR entry, if you have one):\n` +
|
|
110
|
+
` import { defineRoutes } from "@zerotal/core/routes";\n` +
|
|
111
|
+
` import { ROUTES } from "./types/routes.generated";\n` +
|
|
112
|
+
` defineRoutes(ROUTES);\n` +
|
|
113
|
+
`Generate the table with: bun zt route:types`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const pattern = _table.get(name);
|
|
117
|
+
if (pattern === undefined) throw unknownRouteError(name);
|
|
118
|
+
return pattern;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Generate a URL for a named route, substituting `:param` segments.
|
|
123
|
+
*
|
|
124
|
+
* The browser twin of the server's `route()` — same signature, same encoding,
|
|
125
|
+
* same errors, resolved against the table {@link defineRoutes} installed
|
|
126
|
+
* instead of against the live router.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* route("posts.show", { slug: "hello" }) // "/posts/hello"
|
|
130
|
+
* route("search", {}, { q: "reno", page: 2 }) // "/search?q=reno&page=2"
|
|
131
|
+
* route("docs.show", { "*": "guides/intro" }) // "/docs/guides/intro"
|
|
132
|
+
*
|
|
133
|
+
* @category Naming & URLs
|
|
134
|
+
*/
|
|
135
|
+
export const route: RouteBuilder = Object.assign(
|
|
136
|
+
<N extends RouteTarget>(name: N, ...args: RouteArgs<N>): string => {
|
|
137
|
+
const [params = {}, query = {}] = args as [RouteParamValues?, RouteQuery?];
|
|
138
|
+
return buildRouteUrl(name, _pattern(name), params, query);
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
dynamic: (name: string, params: RouteParamValues = {}, query: RouteQuery = {}): string =>
|
|
142
|
+
buildRouteUrl(name, _pattern(name), params, query),
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// The route *types* are deliberately not re-exported here. They erase at
|
|
147
|
+
// compile time, so `import type { RouteName } from "@zerotal/core"` costs a
|
|
148
|
+
// browser bundle nothing — and a second export path for the same names is a
|
|
149
|
+
// second entry in every surface report, forever, for no runtime benefit.
|
package/src/support/env.ts
CHANGED
|
@@ -88,6 +88,7 @@ export const RUNTIME_MODES: ReadonlySet<string> = new Set([
|
|
|
88
88
|
* @internal
|
|
89
89
|
*/
|
|
90
90
|
export function deployEnv(): string {
|
|
91
|
+
// eslint-disable-next-line no-restricted-syntax -- this IS deployEnv — it decides whether APP_ENV still holds the deployment name
|
|
91
92
|
const current = Bun.env["APP_ENV"] ?? "";
|
|
92
93
|
// If `APP_ENV` still holds a deployment name, it has not been overwritten yet —
|
|
93
94
|
// or something set it deliberately since — so it is the freshest answer. Only
|