@zerotal/core 1.7.0 → 1.7.3
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 +131 -0
- package/api-surface.md +226 -1
- package/package.json +2 -1
- package/src/application/Application.ts +53 -3
- package/src/command/builtin/MakeCommandCommand.ts +2 -2
- package/src/command/builtin/MakeControllerCommand.ts +2 -2
- package/src/command/builtin/MakeJobCommand.ts +1 -1
- package/src/command/builtin/MakeMiddlewareCommand.ts +1 -1
- package/src/command/builtin/MakeObserverCommand.ts +1 -1
- package/src/command/builtin/MakePolicyCommand.ts +1 -1
- package/src/command/builtin/MakeProviderCommand.ts +1 -1
- package/src/command/builtin/MakeRequestCommand.ts +14 -4
- package/src/command/builtin/MakeResourceCommand.ts +2 -2
- package/src/command/builtin/MakeTestCommand.ts +2 -2
- package/src/command/builtin/RouteTypesCommand.ts +1 -0
- package/src/dev/DevDeck.ts +144 -20
- package/src/dev/DevOrchestrator.ts +1 -1
- package/src/helpers/index.ts +43 -28
- package/src/router/Router.ts +54 -1
- package/src/router/routeTypes.ts +52 -11
- package/src/router/routes.ts +125 -0
- package/src/security/redactGraph.ts +10 -1
- package/src/support/env.ts +48 -8
package/src/router/routes.ts
CHANGED
|
@@ -48,6 +48,31 @@ import type {
|
|
|
48
48
|
} from "./registry.ts";
|
|
49
49
|
import { buildRouteUrl, unknownRouteError } from "./buildRoute.ts";
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Re-exported so a browser bundle can write its own typed wrappers.
|
|
53
|
+
*
|
|
54
|
+
* These live in `registry.ts`, which is reachable from the `@zerotal/core` root
|
|
55
|
+
* — and that root drags the CLI command modules into any bundle that imports it.
|
|
56
|
+
* A component building a helper around `route()` needs the types without the
|
|
57
|
+
* server, so they surface here, on the entry that is already browser-safe.
|
|
58
|
+
*/
|
|
59
|
+
export type { RouteArgs, RouteParamValues, RouteQuery, RouteTarget } from "./registry.ts";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `route()` without an import.
|
|
63
|
+
*
|
|
64
|
+
* {@link defineRoutes} puts the builder on `globalThis`, and this is the
|
|
65
|
+
* declaration that lets a call site use it: a page writes `route("posts.show")`
|
|
66
|
+
* with no import line, typed exactly as the named export is — the same
|
|
67
|
+
* `RouteBuilder`, so an unknown name or a missing `:param` still fails the build.
|
|
68
|
+
*
|
|
69
|
+
* `var` rather than `const`, because only `var` in a `declare global` block
|
|
70
|
+
* creates a matching property on `globalThis` for the assignment to satisfy.
|
|
71
|
+
*/
|
|
72
|
+
declare global {
|
|
73
|
+
var route: RouteBuilder;
|
|
74
|
+
}
|
|
75
|
+
|
|
51
76
|
/**
|
|
52
77
|
* The name → pattern map `route()` resolves against. A plain object is what
|
|
53
78
|
* `types/routes.generated.ts` exports; a `Map` is accepted so a server-side
|
|
@@ -73,6 +98,25 @@ let _table: ReadonlyMap<string, string> | null = null;
|
|
|
73
98
|
*/
|
|
74
99
|
export function defineRoutes(table: RouteTable): void {
|
|
75
100
|
_table = table instanceof Map ? table : new Map(Object.entries(table));
|
|
101
|
+
_installGlobal();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Put `route()` on `globalThis`, so nothing has to import it.
|
|
106
|
+
*
|
|
107
|
+
* This is the one function both processes already call — the server from
|
|
108
|
+
* `Application._installRouteTable()` at boot, a browser entry beside its
|
|
109
|
+
* generated `ROUTES` — which makes it the only place that can install the global
|
|
110
|
+
* for both without an app remembering to do it in two files.
|
|
111
|
+
*
|
|
112
|
+
* The table is installed first, deliberately: a global that exists but throws
|
|
113
|
+
* "no route table" is worse than one that appears at the same moment it works.
|
|
114
|
+
*
|
|
115
|
+
* `route` stays a named export. Removing it would break every existing import
|
|
116
|
+
* for no gain, and a test that wants a clean global can still reach for it.
|
|
117
|
+
*/
|
|
118
|
+
function _installGlobal(): void {
|
|
119
|
+
(globalThis as { route?: typeof route }).route = route;
|
|
76
120
|
}
|
|
77
121
|
|
|
78
122
|
/**
|
|
@@ -147,3 +191,84 @@ export const route: RouteBuilder = Object.assign(
|
|
|
147
191
|
// compile time, so `import type { RouteName } from "@zerotal/core"` costs a
|
|
148
192
|
// browser bundle nothing — and a second export path for the same names is a
|
|
149
193
|
// second entry in every surface report, forever, for no runtime benefit.
|
|
194
|
+
|
|
195
|
+
// ── Verb-aware routes ─────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Augmented by `types/routes.generated.ts` with the HTTP method of every named
|
|
199
|
+
* route, exactly as {@link RouteRegistry} is augmented with their patterns.
|
|
200
|
+
*
|
|
201
|
+
* @internal The generator writes the augmentation; an app never names this.
|
|
202
|
+
*/
|
|
203
|
+
export interface RouteMethodRegistry {}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A name the generated table knows a verb for.
|
|
207
|
+
*
|
|
208
|
+
* @internal Derived from {@link RouteMethodRegistry}, which the generator owns.
|
|
209
|
+
*/
|
|
210
|
+
export type MethodedRouteName = Extract<keyof RouteMethodRegistry, string>;
|
|
211
|
+
|
|
212
|
+
const methodTable = new Map<string, string>();
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Register the generated `METHODS` table.
|
|
216
|
+
*
|
|
217
|
+
* Called once at boot beside {@link defineRoutes}. Kept separate because the two
|
|
218
|
+
* tables have different audiences: a page that only builds links needs the
|
|
219
|
+
* patterns and never the verbs, and a bundler can then drop the verbs entirely.
|
|
220
|
+
*/
|
|
221
|
+
export function defineRouteMethods(table: Readonly<Record<string, string>>): void {
|
|
222
|
+
methodTable.clear();
|
|
223
|
+
for (const [name, method] of Object.entries(table)) methodTable.set(name, method);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The verb a named route answers on, or undefined when it was never registered.
|
|
228
|
+
*
|
|
229
|
+
* @internal The read side of {@link defineRouteMethods}; apps call `action()`.
|
|
230
|
+
*/
|
|
231
|
+
export function routeMethod(name: string): string | undefined {
|
|
232
|
+
return methodTable.get(name);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A resolved endpoint: where to send a request, and how. */
|
|
236
|
+
export interface RouteAction {
|
|
237
|
+
url: string;
|
|
238
|
+
method: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Resolve a named route to both its URL and its HTTP method.
|
|
243
|
+
*
|
|
244
|
+
* The pair is the point. A form that hardcodes a URL can still send the wrong
|
|
245
|
+
* verb, and the failure — a 404 or a 405 on submit — looks nothing like its
|
|
246
|
+
* cause. Taking both from one generated record means a route that changes verb
|
|
247
|
+
* changes it everywhere at once.
|
|
248
|
+
*
|
|
249
|
+
* Throws when the name has no registered verb. An earlier version defaulted to
|
|
250
|
+
* `GET`, and that default cost a real bug: a regenerated table came back empty,
|
|
251
|
+
* every `action()` reported `GET`, and a file upload submitted as a GET to its
|
|
252
|
+
* own store route and 404'd. The point of resolving a verb from a table is that
|
|
253
|
+
* a wrong verb becomes impossible — a silent fallback gives that away for a
|
|
254
|
+
* failure mode nobody reads, so this is loud instead.
|
|
255
|
+
*
|
|
256
|
+
* Use {@link route} for links, which need no verb.
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* const submit = action("projects.issues.comments.store", { project: "apollo", issue: 4 });
|
|
260
|
+
* // → { url: "/projects/apollo/issues/4/comments", method: "POST" }
|
|
261
|
+
*/
|
|
262
|
+
export function action<N extends RouteTarget>(name: N, ...args: RouteArgs<N>): RouteAction {
|
|
263
|
+
const method = methodTable.get(name as string);
|
|
264
|
+
if (method === undefined) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`action("${String(name)}"): no HTTP method registered for this route. ` +
|
|
267
|
+
`On the server this is installed at boot, so an empty table means the route ` +
|
|
268
|
+
`is not registered. In a browser bundle, call defineRouteMethods(METHODS) ` +
|
|
269
|
+
`from types/routes.generated.ts at your entry point. ` +
|
|
270
|
+
`Use route() instead for links, which need no verb.`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return { url: route(name, ...args), method };
|
|
274
|
+
}
|
|
@@ -89,7 +89,16 @@ function _walk(
|
|
|
89
89
|
}
|
|
90
90
|
const out: Record<string, unknown> = {};
|
|
91
91
|
for (const [key, item] of Object.entries(object as Record<string, unknown>)) {
|
|
92
|
-
|
|
92
|
+
// A boolean is never a secret. It has two possible values, so masking one
|
|
93
|
+
// conceals nothing a reader could not guess — while destroying the answer
|
|
94
|
+
// they came for. Names are matched by substring, so this is not
|
|
95
|
+
// hypothetical: `cors.credentials` contains "credential" and came back as
|
|
96
|
+
// `‹redacted›` on the DevTools Config tab, hiding whether credentialed
|
|
97
|
+
// CORS was on. That is a security setting a reader is checking *because*
|
|
98
|
+
// it matters.
|
|
99
|
+
const maskable = typeof item !== "boolean";
|
|
100
|
+
out[key] =
|
|
101
|
+
maskable && options.sensitive(key) ? options.mask : _walk(item, options, depth + 1, seen);
|
|
93
102
|
}
|
|
94
103
|
return out;
|
|
95
104
|
} finally {
|
package/src/support/env.ts
CHANGED
|
@@ -53,6 +53,21 @@ export const DEV_WORKER_ENV_VAR = "ZT_DEV";
|
|
|
53
53
|
*/
|
|
54
54
|
export const DEPLOY_ENV_VAR = "ZT_APP_ENV";
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Environment variable holding the *runtime mode* — `web`, `worker`, `console`.
|
|
58
|
+
*
|
|
59
|
+
* Separate from `APP_ENV`, which holds the deployment name, because they answer
|
|
60
|
+
* different questions and one variable cannot hold both. It used to try: every
|
|
61
|
+
* boot overwrote `APP_ENV` with the mode, so `APP_ENV=production` read back as
|
|
62
|
+
* `"console"` inside a CLI command and a guard written `if (env("APP_ENV") ===
|
|
63
|
+
* "production") refuse()` was inert exactly where destructive commands live.
|
|
64
|
+
*
|
|
65
|
+
* Written by `setAppEnv()`; read through {@link runtimeMode}. Settable by hand to
|
|
66
|
+
* force a mode — `APP_TYPE=web bun zt.ts something` — which is what the dev
|
|
67
|
+
* orchestrator does for the server it supervises.
|
|
68
|
+
*/
|
|
69
|
+
export const RUNTIME_MODE_VAR = "APP_TYPE";
|
|
70
|
+
|
|
56
71
|
/**
|
|
57
72
|
* The values of `APP_ENV` that name a runtime *mode* rather than a deployment.
|
|
58
73
|
* `setAppEnv()` writes these; {@link deployEnv} recognises them to know whether
|
|
@@ -73,17 +88,18 @@ export const RUNTIME_MODES: ReadonlySet<string> = new Set([
|
|
|
73
88
|
* The deployment name this process was started with — `production`, `staging`,
|
|
74
89
|
* `local`, whatever the operator set — as opposed to the runtime *mode*.
|
|
75
90
|
*
|
|
76
|
-
* `APP_ENV`
|
|
77
|
-
* `setAppEnv()`
|
|
78
|
-
*
|
|
91
|
+
* `APP_ENV` used to carry both meanings, and the second destroyed the first:
|
|
92
|
+
* `setAppEnv()` overwrote it with `web` / `console` / `worker` before the app
|
|
93
|
+
* booted, so a gate asking `isProdLike(Bun.env["APP_ENV"])` after startup was
|
|
79
94
|
* asking whether `"web"` is production and always getting no. That was not
|
|
80
95
|
* theoretical — it silently disabled the weak-`APP_KEY` refusal and left the
|
|
81
|
-
* ORM's N+1 detector wrapping every query in production
|
|
96
|
+
* ORM's N+1 detector wrapping every query in production, and it later made
|
|
97
|
+
* `env("APP_ENV")` return `"console"` inside a seeder.
|
|
82
98
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
99
|
+
* The mode now lives in its own variable ({@link RUNTIME_MODE_VAR}) and `APP_ENV`
|
|
100
|
+
* is left alone, so this is usually just a read of it. The runtime-mode branch
|
|
101
|
+
* below stays for a process started by an older launcher, or one where somebody
|
|
102
|
+
* still exports `APP_ENV=web` by hand.
|
|
87
103
|
*
|
|
88
104
|
* @internal
|
|
89
105
|
*/
|
|
@@ -101,6 +117,30 @@ export function deployEnv(): string {
|
|
|
101
117
|
return Bun.env[DEPLOY_ENV_VAR] ?? current;
|
|
102
118
|
}
|
|
103
119
|
|
|
120
|
+
/**
|
|
121
|
+
* How this process is running — `web`, `worker`, or `console`.
|
|
122
|
+
*
|
|
123
|
+
* The other half of what `APP_ENV` used to mean. Providers are filtered on it
|
|
124
|
+
* (`static environments = ["console"]`), which is why getting it wrong is not a
|
|
125
|
+
* cosmetic problem: a provider is simply never asked to register, with no error
|
|
126
|
+
* and nothing missing from the logs.
|
|
127
|
+
*
|
|
128
|
+
* `fallback` is what an unset environment means, and it differs by caller:
|
|
129
|
+
* `setAppEnv()` treats a process that never declared itself as a script
|
|
130
|
+
* (`console`), while `Application.create()` has always treated one as a server
|
|
131
|
+
* (`web`) — an app constructed directly, in a test or a script, expects its
|
|
132
|
+
* web providers to register.
|
|
133
|
+
*/
|
|
134
|
+
export function runtimeMode(fallback = "console"): string {
|
|
135
|
+
const mode = (Bun.env[RUNTIME_MODE_VAR] ?? "").toLowerCase();
|
|
136
|
+
if (RUNTIME_MODES.has(mode)) return mode;
|
|
137
|
+
|
|
138
|
+
// A process started by an older launcher, which put the mode in `APP_ENV`.
|
|
139
|
+
// eslint-disable-next-line no-restricted-syntax -- reading the legacy location is the fallback's entire job
|
|
140
|
+
const legacy = (Bun.env["APP_ENV"] ?? "").toLowerCase();
|
|
141
|
+
return RUNTIME_MODES.has(legacy) ? legacy : fallback;
|
|
142
|
+
}
|
|
143
|
+
|
|
104
144
|
/**
|
|
105
145
|
* Whether *this process* may expose dev-only surfaces — the stack-trace error
|
|
106
146
|
* page, the trace inspector, an open monitor panel.
|