@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.
@@ -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
- out[key] = options.sensitive(key) ? options.mask : _walk(item, options, depth + 1, seen);
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 {
@@ -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` carries both meanings, and the second one destroys the first:
77
- * `setAppEnv()` overwrites it with `web` / `console` / `worker` before the app
78
- * boots, so a gate that asks `isProdLike(Bun.env["APP_ENV"])` after startup is
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
- * `setAppEnv()` now preserves the original value, and this reads it back. Prefer
84
- * it to `Bun.env["APP_ENV"]` for **any** production decision. Config is an
85
- * equally correct source where it is available (`config("app.env")`), but this
86
- * works before config is loaded and in processes that have none.
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.