@zerotal/core 1.7.0 → 1.7.2
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 +103 -0
- package/package.json +2 -1
- package/src/application/Application.ts +53 -3
- 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 +115 -0
- package/src/security/redactGraph.ts +10 -1
- package/src/support/env.ts +48 -8
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,109 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
6
6
|
|
|
7
7
|
**Maturity: `stable`**
|
|
8
8
|
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`@zerotal/core/errors`** — a subpath for the error classes, so a module that can run in a
|
|
14
|
+
browser can import `ZerotalError` without reaching the root entry. The root re-exports
|
|
15
|
+
`CommandRunner`, which reaches the built-in CLI commands and `await import("bun")`, so a single
|
|
16
|
+
root import is enough to make a browser bundle fail at resolution. `@zerotal/core/helpers`
|
|
17
|
+
already covered `deepMerge` the same way.
|
|
18
|
+
|
|
19
|
+
The rule this makes workable: **core's root entry is server-only.** Anything that might be
|
|
20
|
+
bundled for a browser imports from a narrow subpath.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## [1.7.1] — 2026-08-16
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **`APP_ENV` is the deployment name; the runtime mode moved to `APP_TYPE`.** They shared one
|
|
28
|
+
variable and the mode won: `setAppEnv()` overwrote `APP_ENV` with `web` / `worker` /
|
|
29
|
+
`console` at boot, so an app whose `.env` said `APP_ENV=development` read `"console"` back
|
|
30
|
+
from `env("APP_ENV")` inside every CLI command.
|
|
31
|
+
|
|
32
|
+
The dangerous direction is the one nobody hits in development. A guard written the obvious
|
|
33
|
+
way —
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
if (env("APP_ENV") === "production") refuseToWipe();
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
— was **inert in every console command**, which is exactly where destructive commands live.
|
|
40
|
+
1.7.0 patched the framework's own gates by parking a copy that `deployEnv()` read back, but
|
|
41
|
+
application code reading the documented variable the documented way still got the mode.
|
|
42
|
+
|
|
43
|
+
Two questions, two variables. `setAppEnv()` no longer touches `APP_ENV` at all and writes
|
|
44
|
+
the mode to `APP_TYPE`; `runtimeMode()` reads it, and falls back to the legacy location so a
|
|
45
|
+
process started by an older launcher still boots the right providers. An explicit
|
|
46
|
+
`APP_TYPE` wins over the command, which is how `serve --dev` boots its supervised server as
|
|
47
|
+
`web`. `deployEnv()` and `config("app.env")` are unchanged and still correct.
|
|
48
|
+
|
|
49
|
+
No action needed in an app unless it sets `APP_ENV=web` by hand to force web mode — that
|
|
50
|
+
still works, and `APP_TYPE=web` is the spelling to move to.
|
|
51
|
+
|
|
52
|
+
Found seeding the first cookbook app, where a guard fired that should not have.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **`Router.raw()` did not answer `HEAD`.** The pipeline derives a `HEAD` handler from every
|
|
57
|
+
`GET` — its own docblock notes that not doing so gives "every uptime monitor,
|
|
58
|
+
load-balancer probe, CDN origin check and `curl -I`" a 404 — and the raw path was left out
|
|
59
|
+
of it. So `curl -I` against a raw route answered 404 while the `GET` beside it answered 200. This framework's own site serves `/docs/*` and `/blog` from raw routes, so every link
|
|
60
|
+
checker and uptime probe aimed at the documentation was told the page did not exist.
|
|
61
|
+
|
|
62
|
+
Derived from the wrapped handler rather than the bare one, so the security headers below
|
|
63
|
+
ride along and a `HEAD` cannot answer with fewer than the `GET` it mirrors. A `HEAD` the
|
|
64
|
+
app registered itself still wins. Third gap in the same family, after the headers and
|
|
65
|
+
static files: any path that answers a request without running the pipeline needs whatever
|
|
66
|
+
the pipeline was doing for it.
|
|
67
|
+
|
|
68
|
+
- **The dev deck would not scroll.** On the alternate screen a terminal has no scrollback of
|
|
69
|
+
its own, so the wheel and the scrollbar had nothing to move and the deck read as frozen —
|
|
70
|
+
from the moment tabs mode starts, every way of looking at an older line has to come from
|
|
71
|
+
the deck itself, and only Page Up/Down did.
|
|
72
|
+
|
|
73
|
+
It now asks the terminal to send the wheel as cursor keys (`?1007h`, released again on
|
|
74
|
+
exit) and handles `↑`/`↓` and Home/End. Deliberately not mouse tracking, which would give
|
|
75
|
+
real wheel events at the price of the terminal's own text selection.
|
|
76
|
+
|
|
77
|
+
Two things had to change underneath. A read from stdin is not one key: a wheel tick arrives
|
|
78
|
+
as the same arrow repeated once per line, all in one chunk, and two fast keystrokes arrive
|
|
79
|
+
together — so a chunk is split into keys and the frame painted once at the end. And a card
|
|
80
|
+
that has been scrolled up now holds its place: `scroll` counts up from the newest line, so a
|
|
81
|
+
busy process used to drag the window down by a line for every line it printed, sliding the
|
|
82
|
+
text somebody had stopped to read off the top while they read it. A card pinned to the
|
|
83
|
+
bottom still follows its output, which is the one that should.
|
|
84
|
+
|
|
85
|
+
Both of the next two were found by wiring DevTools into this repo's own `apps/docs` and
|
|
86
|
+
driving it in a browser.
|
|
87
|
+
|
|
88
|
+
- **`Router.raw()` responses carried no security headers.** A raw route opts out of the
|
|
89
|
+
_request_ pipeline — CSRF on a transport endpoint, session resolution on a relay — and was
|
|
90
|
+
silently opting its response out of `SecureHeadersMiddleware` too. This framework's own
|
|
91
|
+
documentation site serves every `/docs/*` page from a raw route, so every page of it went
|
|
92
|
+
out with no `X-Content-Type-Options: nosniff`, no `X-Frame-Options`, no
|
|
93
|
+
`Referrer-Policy` and no `Permissions-Policy`. In production the reverse proxy happened
|
|
94
|
+
to add two of them, which is why nothing had noticed.
|
|
95
|
+
|
|
96
|
+
The header set is now applied to raw responses at compile time, **add-if-absent** rather
|
|
97
|
+
than overwrite: a raw route is the one place a handler owns its whole response, and an
|
|
98
|
+
endpoint that deliberately allows framing has a reason the framework cannot see. The
|
|
99
|
+
response is only reconstructed when something is missing, so the hot path — Flow's action
|
|
100
|
+
endpoint is a raw route — pays nothing when it already has them.
|
|
101
|
+
|
|
102
|
+
This is the third surface in the same family, after the pipeline and static files. Any
|
|
103
|
+
path that answers a request without running middleware needs the same treatment.
|
|
104
|
+
|
|
105
|
+
- **`redactGraph` masked booleans.** Sensitivity is judged by key name, by substring, so
|
|
106
|
+
`cors.credentials` matched "credential" and the DevTools Config tab reported
|
|
107
|
+
`‹redacted›` where the answer was `false`. A boolean has two possible values: masking one
|
|
108
|
+
conceals nothing a reader could not guess, and hides the security setting they opened the
|
|
109
|
+
tab to check. Booleans now pass through; numbers still mask, since a number can be a PIN
|
|
110
|
+
or an account. The helper also gained the test file it shipped without.
|
|
111
|
+
|
|
9
112
|
## [1.7.0] — 2026-08-16
|
|
10
113
|
|
|
11
114
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/core",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
".": "./src/index.ts",
|
|
12
12
|
"./routes": "./src/router/routes.ts",
|
|
13
13
|
"./contracts": "./src/contracts/index.ts",
|
|
14
|
+
"./errors": "./src/errors/index.ts",
|
|
14
15
|
"./lock": "./src/lock/index.ts",
|
|
15
16
|
"./logger": "./src/logger/index.ts",
|
|
16
17
|
"./commands": "./src/command/builtin/index.ts",
|
|
@@ -14,6 +14,7 @@ import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
|
14
14
|
import { Pipeline } from "../pipeline/Pipeline.ts";
|
|
15
15
|
import { ExceptionHandler } from "./ExceptionHandler.ts";
|
|
16
16
|
import { Router, RouterState } from "../router/Router.ts";
|
|
17
|
+
import { defineRouteMethods, defineRoutes } from "../router/routes.ts";
|
|
17
18
|
import type { StaticOptions } from "../router/Router.ts";
|
|
18
19
|
import { Health, resolveHealthConfig, checkHealthAccess } from "../health/Health.ts";
|
|
19
20
|
import type { HealthConfigShape } from "../health/Health.ts";
|
|
@@ -42,7 +43,7 @@ import { NotFoundError } from "../errors/HttpError.ts";
|
|
|
42
43
|
import type { ContainerBindings } from "../container/types.ts";
|
|
43
44
|
import { dispatchRequest } from "../router/RouteHandler.ts";
|
|
44
45
|
import type { ProviderHooks } from "../router/RouteHandler.ts";
|
|
45
|
-
import { isProdLike, deployEnv } from "../support/env.ts";
|
|
46
|
+
import { isProdLike, deployEnv, runtimeMode } from "../support/env.ts";
|
|
46
47
|
import { appKeyStrengthWarning } from "../support/appKey.ts";
|
|
47
48
|
import { runBootDoctor } from "./BootDoctor.ts";
|
|
48
49
|
import { runConfigValidators } from "../config/validation.ts";
|
|
@@ -254,6 +255,8 @@ export async function _lazyStaticResponse(
|
|
|
254
255
|
|
|
255
256
|
/** Minimal WebSocket handler shape accepted by Bun.serve(). */
|
|
256
257
|
export interface WebSocketHandlers {
|
|
258
|
+
/** Seconds a connection may go quiet before Bun closes it. Bun's default is 10. */
|
|
259
|
+
idleTimeout?: number;
|
|
257
260
|
open?(ws: unknown): void;
|
|
258
261
|
message(ws: unknown, message: string | Uint8Array): void;
|
|
259
262
|
close?(ws: unknown, code: number, reason: string): void;
|
|
@@ -501,8 +504,12 @@ export class Application {
|
|
|
501
504
|
);
|
|
502
505
|
}
|
|
503
506
|
|
|
504
|
-
//
|
|
505
|
-
|
|
507
|
+
// The runtime mode, which is what provider filtering is keyed on. Reading
|
|
508
|
+
// `APP_ENV` here used to be right only because `setAppEnv()` had overwritten
|
|
509
|
+
// it with the mode; now the mode has its own variable and this asks for it
|
|
510
|
+
// directly. `_normaliseEnv` still maps a deployment name onto a mode, for an
|
|
511
|
+
// explicit `options.env`.
|
|
512
|
+
const rawEnv = options.env ?? runtimeMode("web");
|
|
506
513
|
const resolvedEnv: Environment = _normaliseEnv(rawEnv);
|
|
507
514
|
|
|
508
515
|
const app = new Application();
|
|
@@ -1068,6 +1075,17 @@ export class Application {
|
|
|
1068
1075
|
};
|
|
1069
1076
|
|
|
1070
1077
|
return {
|
|
1078
|
+
// Bun closes an idle WebSocket after 10 seconds by default, and the client
|
|
1079
|
+
// pings every 30 — so a connection that is merely *quiet* was being cut
|
|
1080
|
+
// before it ever had reason to speak, taking its channel subscriptions
|
|
1081
|
+
// with it. Nothing surfaced: the page stayed rendered, the client kept its
|
|
1082
|
+
// channel objects, and broadcasts simply stopped arriving for anyone who
|
|
1083
|
+
// had been reading for more than ten seconds.
|
|
1084
|
+
//
|
|
1085
|
+
// 120s leaves room for four missed pings before a genuinely dead socket is
|
|
1086
|
+
// reaped, which is the direction to err: a stale connection costs memory,
|
|
1087
|
+
// a reaped live one costs the feature.
|
|
1088
|
+
idleTimeout: 120,
|
|
1071
1089
|
open: (ws: unknown) => {
|
|
1072
1090
|
if ((ws as AnyWS).data._dev) {
|
|
1073
1091
|
DevWsServer.open(ws as AnyWS);
|
|
@@ -1213,6 +1231,21 @@ export class Application {
|
|
|
1213
1231
|
await this._loadFileRoutes();
|
|
1214
1232
|
}
|
|
1215
1233
|
|
|
1234
|
+
// Install the route table for `zerotal/routes`, now that every route is
|
|
1235
|
+
// registered.
|
|
1236
|
+
//
|
|
1237
|
+
// That module is the standalone URL builder a browser bundle imports, so it
|
|
1238
|
+
// cannot reach for `Router` itself — importing the router would drag the
|
|
1239
|
+
// server into every client bundle. The dependency therefore points this way:
|
|
1240
|
+
// the server, which already has both, pushes the table in.
|
|
1241
|
+
//
|
|
1242
|
+
// Without this, `route()` threw on the server for any app that renders its
|
|
1243
|
+
// own markup — a `view` build produces every href and form action there —
|
|
1244
|
+
// and the fix was a `defineRoutes()` call each app had to know to write.
|
|
1245
|
+
// A browser entry still calls it; that is a different process with no router
|
|
1246
|
+
// to read. See T24.
|
|
1247
|
+
this._installRouteTable();
|
|
1248
|
+
|
|
1216
1249
|
// A routes/ directory nobody routed is a silent 404 for every path in it — the file
|
|
1217
1250
|
// imports cleanly and registers nothing, which looks identical to a typo'd URL.
|
|
1218
1251
|
this._warnUnroutedRoutesDir(process.cwd());
|
|
@@ -1266,6 +1299,23 @@ export class Application {
|
|
|
1266
1299
|
if (warning) frameworkLog("app").warn(warning);
|
|
1267
1300
|
}
|
|
1268
1301
|
|
|
1302
|
+
/**
|
|
1303
|
+
* Hand the registered routes to the standalone `route()` builder.
|
|
1304
|
+
*
|
|
1305
|
+
* Name → pattern for the URLs, and name → verb for `action()`. `RouteDefinition`
|
|
1306
|
+
* carries its own `name` beside `method`, so the pair comes from one record —
|
|
1307
|
+
* a path join would be wrong, since `GET /login` and `POST /login` share a path.
|
|
1308
|
+
*/
|
|
1309
|
+
private _installRouteTable(): void {
|
|
1310
|
+
const methods = new Map<string, string>();
|
|
1311
|
+
for (const definition of Router.routes.values()) {
|
|
1312
|
+
if (definition.name) methods.set(definition.name, definition.method);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
defineRoutes(Router.namedRoutes);
|
|
1316
|
+
defineRouteMethods(Object.fromEntries(methods));
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1269
1319
|
private async _loadFileRoutes(): Promise<void> {
|
|
1270
1320
|
for (const { dir, prefix, middleware } of this._fileRouteGroups) {
|
|
1271
1321
|
await Router.groupAsync({ prefix, middleware }, () => scanFileRoutes(dir).then(() => {}));
|
package/src/dev/DevDeck.ts
CHANGED
|
@@ -19,10 +19,19 @@
|
|
|
19
19
|
* ## The terminal must survive us
|
|
20
20
|
*
|
|
21
21
|
* Raw mode plus the alternate screen left on is the classic TUI bug: the shell
|
|
22
|
-
* comes back with no echo, no line editing, and no scrollback. The restore is
|
|
22
|
+
* comes back with no echo, no line editing, and no shell scrollback. The restore is
|
|
23
23
|
* therefore registered *before* raw mode is entered, and it runs on every exit
|
|
24
24
|
* path there is — `q`, a signal, and an uncaught throw. It is also idempotent,
|
|
25
25
|
* because several of those can happen at once.
|
|
26
|
+
*
|
|
27
|
+
* ## Scrolling is ours to do
|
|
28
|
+
*
|
|
29
|
+
* The alternate screen has no scrollback of its own, so the terminal's scrollbar
|
|
30
|
+
* and wheel have nothing to move: from the moment tabs mode starts, every way of
|
|
31
|
+
* looking at an older line has to come from here. Each card keeps its own buffer
|
|
32
|
+
* and its own position in it, the wheel arrives as cursor keys, and a card that
|
|
33
|
+
* has been scrolled up holds its place while the process behind it keeps
|
|
34
|
+
* printing.
|
|
26
35
|
*/
|
|
27
36
|
import type { OutputWriter } from "../command/OutputWriter.ts";
|
|
28
37
|
import type { DevProcessColor } from "./DevProcess.ts";
|
|
@@ -184,7 +193,7 @@ export class TabsDeck implements Deck {
|
|
|
184
193
|
private _restored = false;
|
|
185
194
|
private _stream: StreamDeck | undefined;
|
|
186
195
|
|
|
187
|
-
private readonly _onData = (chunk: Buffer | string): void => this.
|
|
196
|
+
private readonly _onData = (chunk: Buffer | string): void => this._input(chunk.toString());
|
|
188
197
|
private readonly _onResize = (): void => this._paint();
|
|
189
198
|
private readonly _onExit = (): void => this.stop();
|
|
190
199
|
|
|
@@ -207,7 +216,7 @@ export class TabsDeck implements Deck {
|
|
|
207
216
|
process.on("uncaughtException", this._onExit);
|
|
208
217
|
process.on("unhandledRejection", this._onExit);
|
|
209
218
|
|
|
210
|
-
this._write(ALT_SCREEN_ON + CURSOR_HIDE);
|
|
219
|
+
this._write(ALT_SCREEN_ON + CURSOR_HIDE + ALT_SCROLL_ON);
|
|
211
220
|
this._stdin.setRawMode?.(true);
|
|
212
221
|
this._stdin.resume?.();
|
|
213
222
|
this._stdin.on?.("data", this._onData);
|
|
@@ -224,6 +233,7 @@ export class TabsDeck implements Deck {
|
|
|
224
233
|
const body = stream === "stderr" ? _paint(text, "red") : text;
|
|
225
234
|
card.lines.push(`${stamp}${body}`);
|
|
226
235
|
if (card.lines.length > SCROLLBACK) card.lines.shift();
|
|
236
|
+
this._anchor(card, body);
|
|
227
237
|
|
|
228
238
|
// A tab the user is not looking at still buffers; only the visible one costs
|
|
229
239
|
// a repaint. In stream mode every line prints regardless of focus.
|
|
@@ -271,26 +281,38 @@ export class TabsDeck implements Deck {
|
|
|
271
281
|
}
|
|
272
282
|
this._stdin.pause?.();
|
|
273
283
|
if (this._repaintTimer) clearTimeout(this._repaintTimer);
|
|
274
|
-
this._write(CURSOR_SHOW + ALT_SCREEN_OFF);
|
|
284
|
+
this._write(ALT_SCROLL_OFF + CURSOR_SHOW + ALT_SCREEN_OFF);
|
|
275
285
|
}
|
|
276
286
|
|
|
277
287
|
// ── Input ──────────────────────────────────────────────────────────────────
|
|
278
288
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
289
|
+
/**
|
|
290
|
+
* One read from stdin, which is not the same thing as one key.
|
|
291
|
+
*
|
|
292
|
+
* A wheel tick arrives as the same arrow sequence repeated as many times as
|
|
293
|
+
* there are lines to scroll, all in a single read, and two fast keystrokes
|
|
294
|
+
* arrive together — so a chunk is split into keys first and the frame is
|
|
295
|
+
* painted once at the end rather than once per key.
|
|
296
|
+
*/
|
|
297
|
+
private _input(chunk: string): void {
|
|
298
|
+
let dirty = false;
|
|
299
|
+
for (const key of _keys(chunk)) dirty = this._key(key) || dirty;
|
|
300
|
+
if (dirty) this._paint();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Handle one key. Returns whether the frame needs repainting. */
|
|
304
|
+
private _key(sequence: string): boolean {
|
|
305
|
+
if (this._searching) return this._searchKey(sequence);
|
|
284
306
|
|
|
285
307
|
switch (sequence) {
|
|
286
308
|
case "q":
|
|
287
309
|
case "\x03": // Ctrl-C — the deck owns the terminal, so it owns the quit.
|
|
288
310
|
this._options.onQuit();
|
|
289
|
-
return;
|
|
311
|
+
return false;
|
|
290
312
|
case "r": {
|
|
291
313
|
const card = this._cards[this._focused];
|
|
292
314
|
if (card) this._options.onRestart(card.status.name);
|
|
293
|
-
return;
|
|
315
|
+
return false;
|
|
294
316
|
}
|
|
295
317
|
case "c": {
|
|
296
318
|
const card = this._cards[this._focused];
|
|
@@ -306,7 +328,7 @@ export class TabsDeck implements Deck {
|
|
|
306
328
|
break;
|
|
307
329
|
case "s":
|
|
308
330
|
this._toggleStream();
|
|
309
|
-
return;
|
|
331
|
+
return false;
|
|
310
332
|
case "t":
|
|
311
333
|
this._timestamps = !this._timestamps;
|
|
312
334
|
break;
|
|
@@ -317,22 +339,39 @@ export class TabsDeck implements Deck {
|
|
|
317
339
|
case ARROW_LEFT:
|
|
318
340
|
this._focus(this._focused - 1);
|
|
319
341
|
break;
|
|
342
|
+
// The wheel is these two: in the alternate screen a terminal has no
|
|
343
|
+
// scrollback to move, so it sends the cursor keys instead (which is what
|
|
344
|
+
// `ALT_SCROLL_ON` asks for). Ignoring them is what makes the deck look
|
|
345
|
+
// like a terminal that will not scroll.
|
|
346
|
+
case ARROW_UP:
|
|
347
|
+
this._scroll(1);
|
|
348
|
+
break;
|
|
349
|
+
case ARROW_DOWN:
|
|
350
|
+
this._scroll(-1);
|
|
351
|
+
break;
|
|
320
352
|
case PAGE_UP:
|
|
321
353
|
this._scroll(this._bodyHeight());
|
|
322
354
|
break;
|
|
323
355
|
case PAGE_DOWN:
|
|
324
356
|
this._scroll(-this._bodyHeight());
|
|
325
357
|
break;
|
|
358
|
+
case HOME:
|
|
359
|
+
this._scroll(Number.MAX_SAFE_INTEGER);
|
|
360
|
+
break;
|
|
361
|
+
case END:
|
|
362
|
+
this._scroll(-Number.MAX_SAFE_INTEGER);
|
|
363
|
+
break;
|
|
326
364
|
default: {
|
|
327
365
|
if (sequence >= "1" && sequence <= "9") this._focus(Number(sequence) - 1);
|
|
328
|
-
else return;
|
|
366
|
+
else return false;
|
|
329
367
|
}
|
|
330
368
|
}
|
|
331
369
|
|
|
332
|
-
|
|
370
|
+
return true;
|
|
333
371
|
}
|
|
334
372
|
|
|
335
|
-
|
|
373
|
+
/** Handle one key while the search box has the keyboard. Always repaints. */
|
|
374
|
+
private _searchKey(sequence: string): boolean {
|
|
336
375
|
if (sequence === "\r" || sequence === "\n" || sequence === "\x1b") {
|
|
337
376
|
// Enter keeps the filter and hands the keyboard back; Escape drops it.
|
|
338
377
|
this._searching = false;
|
|
@@ -348,7 +387,7 @@ export class TabsDeck implements Deck {
|
|
|
348
387
|
|
|
349
388
|
const card = this._cards[this._focused];
|
|
350
389
|
if (card) card.scroll = 0;
|
|
351
|
-
|
|
390
|
+
return true;
|
|
352
391
|
}
|
|
353
392
|
|
|
354
393
|
private _focus(index: number): void {
|
|
@@ -361,11 +400,36 @@ export class TabsDeck implements Deck {
|
|
|
361
400
|
private _scroll(by: number): void {
|
|
362
401
|
const card = this._cards[this._focused];
|
|
363
402
|
if (!card) return;
|
|
364
|
-
const
|
|
365
|
-
const max = Math.max(0, total - this._bodyHeight());
|
|
403
|
+
const max = Math.max(0, this._visibleLines(card).length - this._bodyHeight());
|
|
366
404
|
card.scroll = Math.min(max, Math.max(0, card.scroll + by));
|
|
367
405
|
}
|
|
368
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Hold a scrolled-up view on the lines it was left on.
|
|
409
|
+
*
|
|
410
|
+
* `scroll` counts lines up from the newest, so a busy process would otherwise
|
|
411
|
+
* drag the window down by one line for every line it printed and the text
|
|
412
|
+
* somebody stopped to read would slide off the top while they read it. A card
|
|
413
|
+
* pinned to the bottom (`scroll === 0`) is left alone — that one *should*
|
|
414
|
+
* follow the output.
|
|
415
|
+
*/
|
|
416
|
+
private _anchor(card: Card, appended: string): void {
|
|
417
|
+
if (card.scroll === 0) return;
|
|
418
|
+
// Only the focused card is ever filtered — `_focus()` clears the search —
|
|
419
|
+
// so only there can an arriving line be invisible and move nothing.
|
|
420
|
+
const needle = this._cards[this._focused] === card ? this._search.toLowerCase() : "";
|
|
421
|
+
const visible = !needle || appended.toLowerCase().includes(needle);
|
|
422
|
+
// Unfiltered, the count is the buffer's own — worth the branch, because this
|
|
423
|
+
// runs once per line of output and the buffer holds thousands.
|
|
424
|
+
const total = needle ? this._visibleLines(card).length : card.lines.length;
|
|
425
|
+
// Clamped even when nothing was added: at the scrollback cap every new line
|
|
426
|
+
// evicts an old one, and without this the view would walk off the top.
|
|
427
|
+
card.scroll = Math.min(
|
|
428
|
+
card.scroll + (visible ? 1 : 0),
|
|
429
|
+
Math.max(0, total - this._bodyHeight()),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
369
433
|
/**
|
|
370
434
|
* Hand the terminal back and print plainly from here on.
|
|
371
435
|
*
|
|
@@ -384,7 +448,7 @@ export class TabsDeck implements Deck {
|
|
|
384
448
|
return;
|
|
385
449
|
}
|
|
386
450
|
this._stream = undefined as StreamDeck | undefined;
|
|
387
|
-
this._write(ALT_SCREEN_ON + CURSOR_HIDE);
|
|
451
|
+
this._write(ALT_SCREEN_ON + CURSOR_HIDE + ALT_SCROLL_ON);
|
|
388
452
|
this._stdin.setRawMode?.(true);
|
|
389
453
|
this._paint();
|
|
390
454
|
}
|
|
@@ -453,7 +517,7 @@ export class TabsDeck implements Deck {
|
|
|
453
517
|
const scrolled = card && card.scroll > 0 ? ` ↑${card.scroll}` : "";
|
|
454
518
|
const filtered = this._search ? ` /${this._search}` : "";
|
|
455
519
|
const keys =
|
|
456
|
-
"1-9 tab · ←/→ cycle · r restart · c clear · / search · t time · s stream · q quit";
|
|
520
|
+
"1-9 tab · ←/→ cycle · ↑/↓ scroll · r restart · c clear · / search · t time · s stream · q quit";
|
|
457
521
|
return _fit(_paint(keys + filtered + scrolled, "dim"), width);
|
|
458
522
|
}
|
|
459
523
|
|
|
@@ -486,10 +550,70 @@ const CURSOR_HOME = "\x1b[H";
|
|
|
486
550
|
const CLEAR_LINE = "\x1b[K";
|
|
487
551
|
const CLEAR_BELOW = "\x1b[J";
|
|
488
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Alternate scroll: ask the terminal to send cursor keys when the wheel turns.
|
|
555
|
+
*
|
|
556
|
+
* On the alternate screen there is no scrollback for the wheel to move, so
|
|
557
|
+
* without this the wheel does nothing at all and the deck reads as frozen.
|
|
558
|
+
* Most terminals do it by default, some do not, and asking costs one sequence.
|
|
559
|
+
* Deliberately *not* mouse tracking (`?1000h` and friends), which would hand us
|
|
560
|
+
* real wheel events at the price of the terminal's own text selection.
|
|
561
|
+
*/
|
|
562
|
+
const ALT_SCROLL_ON = "\x1b[?1007h";
|
|
563
|
+
const ALT_SCROLL_OFF = "\x1b[?1007l";
|
|
564
|
+
|
|
565
|
+
const ARROW_UP = "\x1b[A";
|
|
566
|
+
const ARROW_DOWN = "\x1b[B";
|
|
489
567
|
const ARROW_LEFT = "\x1b[D";
|
|
490
568
|
const ARROW_RIGHT = "\x1b[C";
|
|
491
569
|
const PAGE_UP = "\x1b[5~";
|
|
492
570
|
const PAGE_DOWN = "\x1b[6~";
|
|
571
|
+
const HOME = "\x1b[H";
|
|
572
|
+
const END = "\x1b[F";
|
|
573
|
+
|
|
574
|
+
/** A CSI sequence ends at the first byte in this range; everything before is parameters. */
|
|
575
|
+
const _CSI_END = /[@-~]/;
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Split one read from stdin into individual key presses.
|
|
579
|
+
*
|
|
580
|
+
* SS3 arrows (`ESC O A`, what a terminal in application cursor mode sends) are
|
|
581
|
+
* rewritten to their CSI spelling so the switch upstairs only has to know one
|
|
582
|
+
* form of each key, and text is walked by code point so that an emoji typed
|
|
583
|
+
* into the search box stays one key rather than two broken halves.
|
|
584
|
+
*/
|
|
585
|
+
function _keys(chunk: string): string[] {
|
|
586
|
+
const out: string[] = [];
|
|
587
|
+
let at = 0;
|
|
588
|
+
|
|
589
|
+
while (at < chunk.length) {
|
|
590
|
+
if (chunk[at] !== "\x1b") {
|
|
591
|
+
const point = String.fromCodePoint(chunk.codePointAt(at)!);
|
|
592
|
+
out.push(point);
|
|
593
|
+
at += point.length;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const next = chunk[at + 1];
|
|
598
|
+
if (next === "[") {
|
|
599
|
+
let end = at + 2;
|
|
600
|
+
while (end < chunk.length && !_CSI_END.test(chunk[end]!)) end++;
|
|
601
|
+
// A sequence cut off by the end of the read is passed through whole
|
|
602
|
+
// rather than split into an Escape and some letters, which is what would
|
|
603
|
+
// type `[` and `A` into the search box.
|
|
604
|
+
out.push(chunk.slice(at, end + 1));
|
|
605
|
+
at = end + 1;
|
|
606
|
+
} else if (next === "O" && at + 2 < chunk.length) {
|
|
607
|
+
out.push(`\x1b[${chunk[at + 2]}`);
|
|
608
|
+
at += 3;
|
|
609
|
+
} else {
|
|
610
|
+
out.push("\x1b"); // A bare Escape — the key, not the start of anything.
|
|
611
|
+
at += 1;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return out;
|
|
616
|
+
}
|
|
493
617
|
|
|
494
618
|
const STATE_GLYPH: Record<DevProcessStatus["state"], string> = {
|
|
495
619
|
starting: "◌",
|
|
@@ -166,7 +166,7 @@ export class DevOrchestrator {
|
|
|
166
166
|
cwd: this._cwd,
|
|
167
167
|
env: {
|
|
168
168
|
...Bun.env,
|
|
169
|
-
|
|
169
|
+
APP_TYPE: "web",
|
|
170
170
|
// Mark the worker as developer-supervised. APP_ENV above is the
|
|
171
171
|
// runtime mode, not a deployment name, so it cannot carry this —
|
|
172
172
|
// without the flag the worker looks production-like to every
|
package/src/helpers/index.ts
CHANGED
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { ConfigError } from "../errors/ConfigError.ts";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
DEPLOY_ENV_VAR,
|
|
27
|
+
RUNTIME_MODES as _RUNTIME_MODES,
|
|
28
|
+
RUNTIME_MODE_VAR as _RUNTIME_MODE_VAR,
|
|
29
|
+
} from "../support/env.ts";
|
|
26
30
|
|
|
27
31
|
// ── basePath() ────────────────────────────────────────────────────────────────
|
|
28
32
|
|
|
@@ -46,13 +50,19 @@ export function basePath(...segments: string[]): string {
|
|
|
46
50
|
// ── setAppEnv() ───────────────────────────────────────────────────────────────
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
|
-
* Set `
|
|
50
|
-
* the dynamic import of `bootstrap/app.ts
|
|
51
|
-
* the
|
|
53
|
+
* Set `APP_TYPE` — the runtime mode — from the CLI command name. Call it in
|
|
54
|
+
* `zerotal.ts` BEFORE the dynamic import of `bootstrap/app.ts`, so
|
|
55
|
+
* `Application.create()` sees the mode it should boot providers for.
|
|
52
56
|
*
|
|
53
|
-
*
|
|
57
|
+
* **`APP_ENV` is not touched.** That variable holds the deployment name the
|
|
58
|
+
* operator set, and it is what answers "is this production?". The two used to
|
|
59
|
+
* share one variable and the mode won, so `APP_ENV=production` read back as
|
|
60
|
+
* `"console"` in every CLI command — see {@link RUNTIME_MODE_VAR}.
|
|
54
61
|
*
|
|
55
|
-
*
|
|
62
|
+
* An `APP_TYPE` already in the environment wins over the command, which is how
|
|
63
|
+
* the dev orchestrator boots a supervised server as `web`.
|
|
64
|
+
*
|
|
65
|
+
* | Command | APP_TYPE |
|
|
56
66
|
* |----------------------|-----------|
|
|
57
67
|
* | serve / start / s | web |
|
|
58
68
|
* | dev / d | web |
|
|
@@ -71,41 +81,46 @@ export function setAppEnv(command?: string): void {
|
|
|
71
81
|
const current = Bun.env["APP_ENV"];
|
|
72
82
|
const environment = Bun.env as Record<string, string>;
|
|
73
83
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
84
|
+
// `APP_ENV` is left alone. It holds the deployment name the operator set —
|
|
85
|
+
// `production`, `staging`, `local` — and it is the only place that answers
|
|
86
|
+
// "is this production?". This function used to overwrite it with the runtime
|
|
87
|
+
// mode, which is why six gates asking that question of `Bun.env["APP_ENV"]`
|
|
88
|
+
// read `"web"` and quietly answered no, including the weak-`APP_KEY` refusal
|
|
89
|
+
// and the ORM's N+1 detector; a preserved copy patched those, and then
|
|
90
|
+
// `env("APP_ENV")` still returned `"console"` inside a seeder, because the
|
|
91
|
+
// copy was only ever read through `deployEnv()`.
|
|
79
92
|
//
|
|
80
|
-
//
|
|
81
|
-
// when it is a genuine deployment name, so a second `setAppEnv` — which sees the
|
|
82
|
-
// runtime mode this one just wrote — cannot stamp `"web"` over `"production"`.
|
|
93
|
+
// Two questions, two variables. The mode goes to `APP_TYPE`.
|
|
83
94
|
if (current && !_RUNTIME_MODES.has(current.toLowerCase())) {
|
|
95
|
+
// Still mirrored, for anything already reading the preserved copy.
|
|
84
96
|
environment[DEPLOY_ENV_VAR] = current;
|
|
85
97
|
}
|
|
86
98
|
|
|
99
|
+
// Deliberately no attempt to "migrate" a legacy `APP_ENV=web` into `APP_TYPE`
|
|
100
|
+
// and put a deployment name back. `RUNTIME_MODES` contains `test` — which
|
|
101
|
+
// `zt test` sets as a *deployment* name — so the two sets overlap and any
|
|
102
|
+
// rewrite here would eventually clobber the one thing this change exists to
|
|
103
|
+
// protect. `runtimeMode()` reads the legacy location instead, where a wrong
|
|
104
|
+
// guess costs a mode rather than an environment.
|
|
105
|
+
|
|
106
|
+
// An explicit `APP_TYPE` wins: it is how the dev orchestrator tells the server
|
|
107
|
+
// it supervises to boot as `web` regardless of the command that started it.
|
|
108
|
+
const explicit = environment[_RUNTIME_MODE_VAR];
|
|
109
|
+
if (explicit && _RUNTIME_MODES.has(explicit.toLowerCase())) return;
|
|
110
|
+
|
|
87
111
|
if (["serve", "start", "s", "dev", "d"].includes(normalizedCommand)) {
|
|
88
|
-
// Always force web mode for the HTTP server — deployment-env names like
|
|
89
|
-
// "local" or "production" must not leave the app in console mode.
|
|
90
|
-
//
|
|
91
112
|
// `dev` belongs here with `serve`, and the reason is not cosmetic. Dev mode's
|
|
92
113
|
// process 1 boots the app purely to ask its providers what to run, and a
|
|
93
|
-
// provider is only asked if `static environments` includes the
|
|
114
|
+
// provider is only asked if `static environments` includes the mode it booted
|
|
94
115
|
// under. Falling through to "console" below would silently drop every
|
|
95
116
|
// web-only provider — no error, no empty tab, just a process that never
|
|
96
117
|
// appears — and would make `zt dev` and `serve --dev` disagree about what
|
|
97
118
|
// dev mode consists of.
|
|
98
|
-
|
|
99
|
-
environment["APP_ENV"] = "web";
|
|
100
|
-
}
|
|
119
|
+
environment[_RUNTIME_MODE_VAR] = "web";
|
|
101
120
|
} else if (["worker", "queue:work"].includes(normalizedCommand)) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
} else if (!current || !_RUNTIME_MODES.has(current.toLowerCase())) {
|
|
106
|
-
// Mirror the serve/worker branches: a deployment-env name like "local" or
|
|
107
|
-
// "production" must not leave the app in web mode for a CLI command.
|
|
108
|
-
environment["APP_ENV"] = "console";
|
|
121
|
+
environment[_RUNTIME_MODE_VAR] = "worker";
|
|
122
|
+
} else {
|
|
123
|
+
environment[_RUNTIME_MODE_VAR] = "console";
|
|
109
124
|
}
|
|
110
125
|
}
|
|
111
126
|
|
package/src/router/Router.ts
CHANGED
|
@@ -81,6 +81,40 @@ function _wrapFileHandler(fn: FileHandler, debugName: string): ControllerClass {
|
|
|
81
81
|
type AnyRouteHandler = RouteHandler<any>;
|
|
82
82
|
|
|
83
83
|
type RouteHandlerFn = (req: Request, server?: unknown) => Response | Promise<Response>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Wrap a raw handler so its response carries the security headers, without
|
|
87
|
+
* touching any the handler set for itself.
|
|
88
|
+
*
|
|
89
|
+
* Add-if-absent rather than `withHeaders`, which overwrites: a raw route is the
|
|
90
|
+
* one place a handler is fully in charge of its own response, and a transport
|
|
91
|
+
* endpoint that deliberately sets `X-Frame-Options` for its own reasons must
|
|
92
|
+
* keep it. The response is only reconstructed when something is actually
|
|
93
|
+
* missing, so a handler that already set everything pays nothing — and
|
|
94
|
+
* reconstruction is required rather than optional when it happens, because a
|
|
95
|
+
* `Response.redirect()` has an immutable headers guard that throws on `set`.
|
|
96
|
+
*/
|
|
97
|
+
function _withSecurityDefaults(
|
|
98
|
+
handler: RouteHandlerFn,
|
|
99
|
+
defaults: Record<string, string>,
|
|
100
|
+
): RouteHandlerFn {
|
|
101
|
+
const names = Object.keys(defaults);
|
|
102
|
+
if (names.length === 0) return handler;
|
|
103
|
+
|
|
104
|
+
return async (req, server) => {
|
|
105
|
+
const response = await handler(req, server);
|
|
106
|
+
const missing = names.filter((name) => !response.headers.has(name));
|
|
107
|
+
if (missing.length === 0) return response;
|
|
108
|
+
|
|
109
|
+
const merged = new Headers(response.headers);
|
|
110
|
+
for (const name of missing) merged.set(name, defaults[name]!);
|
|
111
|
+
return new Response(response.body, {
|
|
112
|
+
status: response.status,
|
|
113
|
+
statusText: response.statusText,
|
|
114
|
+
headers: merged,
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
}
|
|
84
118
|
/**
|
|
85
119
|
* A path entry is either a method-keyed map of handlers, or a bare static
|
|
86
120
|
* `Response` (Bun.serve serves the latter at zero JS cost per request).
|
|
@@ -1077,12 +1111,31 @@ export class Router {
|
|
|
1077
1111
|
|
|
1078
1112
|
// Raw routes bypass the middleware pipeline entirely — added last so they
|
|
1079
1113
|
// take precedence over any same-path pipeline routes.
|
|
1114
|
+
//
|
|
1115
|
+
// Bypassing the pipeline also bypasses `SecureHeadersMiddleware`, and that is
|
|
1116
|
+
// not what anyone opts out for: `Router.raw()` exists to skip *request*
|
|
1117
|
+
// handling — CSRF on a transport endpoint, session resolution on a relay —
|
|
1118
|
+
// not to opt a response out of the headers the framework advertises as
|
|
1119
|
+
// automatic. This framework's own documentation site serves every `/docs/*`
|
|
1120
|
+
// page from a raw route, and every one of them went out with no
|
|
1121
|
+
// `X-Content-Type-Options: nosniff`.
|
|
1122
|
+
//
|
|
1123
|
+
// Computed once here rather than per request: raw routes include Flow's
|
|
1124
|
+
// action endpoint, which is as hot as anything in the app.
|
|
1125
|
+
const rawDefaults = staticSecurityHeaders();
|
|
1080
1126
|
for (const [key, handler] of _s().rawRoutes) {
|
|
1081
1127
|
const spaceIndex = key.indexOf(" ");
|
|
1082
1128
|
const method = key.slice(0, spaceIndex) as HttpMethod;
|
|
1083
1129
|
const path = key.slice(spaceIndex + 1);
|
|
1084
1130
|
const rawMap = (compiled[path] ??= {}) as Record<string, RouteHandlerFn>;
|
|
1085
|
-
rawMap[method] = handler;
|
|
1131
|
+
rawMap[method] = _withSecurityDefaults(handler, rawDefaults);
|
|
1132
|
+
// And `HEAD`, for the same reason the pipeline derives it — a raw route is
|
|
1133
|
+
// still a route, and `curl -I` against one answered 404 while the `GET`
|
|
1134
|
+
// beside it answered 200. This site's own `/docs/*` and `/blog` are raw,
|
|
1135
|
+
// so every link checker and uptime probe pointed at them was told the page
|
|
1136
|
+
// did not exist. Derived from the wrapped handler, so the headers above
|
|
1137
|
+
// ride along.
|
|
1138
|
+
if (method === "GET") rawMap["HEAD"] ??= _headFrom(rawMap[method]!);
|
|
1086
1139
|
}
|
|
1087
1140
|
|
|
1088
1141
|
return compiled;
|
package/src/router/routeTypes.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* the browser bundle.
|
|
25
25
|
*/
|
|
26
26
|
import { relative } from "node:path";
|
|
27
|
+
import { Router } from "./Router.ts";
|
|
27
28
|
|
|
28
29
|
/** Where the generated map is written, relative to the project root. */
|
|
29
30
|
export const ROUTE_TYPES_FILE = "types/routes.generated.ts";
|
|
@@ -32,9 +33,11 @@ const HEADER = [
|
|
|
32
33
|
"// Auto-generated by @zerotal/core — do not edit manually.",
|
|
33
34
|
"// Regenerate with: bun zt route:types",
|
|
34
35
|
"//",
|
|
35
|
-
"// Every named route
|
|
36
|
-
"// through declaration merging, so an unknown
|
|
37
|
-
"//
|
|
36
|
+
"// Every named route, the URL pattern it compiles to, and the HTTP method it",
|
|
37
|
+
"// answers on. `route()` reads ROUTES through declaration merging, so an unknown",
|
|
38
|
+
"// name or a missing :param is a compile error; `action()` also reads METHODS, so",
|
|
39
|
+
"// a form cannot submit a route with the wrong verb.",
|
|
40
|
+
"// Commit this file: editors and CI need it without booting the app.",
|
|
38
41
|
"",
|
|
39
42
|
];
|
|
40
43
|
|
|
@@ -52,17 +55,28 @@ const HEADER = [
|
|
|
52
55
|
export function generateRouteTypes(
|
|
53
56
|
namedRoutes: ReadonlyMap<string, string>,
|
|
54
57
|
importSpecifier = "@zerotal/core",
|
|
58
|
+
methods: ReadonlyMap<string, string> = new Map(),
|
|
55
59
|
): string {
|
|
56
60
|
const entries = Array.from(namedRoutes.entries()).sort(([a], [b]) => a.localeCompare(b));
|
|
61
|
+
const key = (name: string) => (/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name));
|
|
57
62
|
|
|
58
63
|
// Quote the key only when it isn't a bare identifier — `home` stays bare and
|
|
59
64
|
// `posts.show` stays quoted, which is what a formatter would do to this file
|
|
60
65
|
// anyway. Matching it here keeps `format:check` off a file nobody edits.
|
|
61
|
-
const lines = entries.map(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
const lines = entries.map(([name, pattern]) => ` ${key(name)}: ${JSON.stringify(pattern)},`);
|
|
67
|
+
|
|
68
|
+
// A second table rather than one of `{ url, method }` objects: `ROUTES` is what
|
|
69
|
+
// `route()` and `RouteRegistry` already read, and widening its value type would
|
|
70
|
+
// break every app that has generated this file. Two flat maps also stay
|
|
71
|
+
// tree-shakeable — a bundle that only builds links never pulls the verbs in.
|
|
72
|
+
const methodLines = entries
|
|
73
|
+
.filter(([name]) => methods.has(name))
|
|
74
|
+
.map(([name]) => ` ${key(name)}: ${JSON.stringify(methods.get(name))},`);
|
|
75
|
+
|
|
76
|
+
const methodTable =
|
|
77
|
+
methodLines.length > 0
|
|
78
|
+
? ["export const METHODS = {", ...methodLines, "} as const;"]
|
|
79
|
+
: ["export const METHODS = {} as const;"];
|
|
66
80
|
|
|
67
81
|
// Empty on one line: an app with no named routes still gets a file a formatter
|
|
68
82
|
// leaves alone (and a registry that types nothing, so `route()` stays on its
|
|
@@ -76,11 +90,17 @@ export function generateRouteTypes(
|
|
|
76
90
|
...HEADER,
|
|
77
91
|
...table,
|
|
78
92
|
"",
|
|
93
|
+
...methodTable,
|
|
94
|
+
"",
|
|
79
95
|
"/** The generated route table, as a type. */",
|
|
80
96
|
"export type Routes = typeof ROUTES;",
|
|
81
97
|
"",
|
|
98
|
+
"/** The HTTP method each named route answers on. */",
|
|
99
|
+
"export type RouteMethods = typeof METHODS;",
|
|
100
|
+
"",
|
|
82
101
|
`declare module ${JSON.stringify(importSpecifier)} {`,
|
|
83
102
|
" interface RouteRegistry extends Routes {}",
|
|
103
|
+
" interface RouteMethodRegistry extends RouteMethods {}",
|
|
84
104
|
"}",
|
|
85
105
|
// The file is rewritten on every dev boot; without a trailing newline a
|
|
86
106
|
// formatter in the app would put one back, forever.
|
|
@@ -104,15 +124,21 @@ export interface RouteTypesResult {
|
|
|
104
124
|
* Write (or, with `check`, verify) `types/routes.generated.ts` for a booted app.
|
|
105
125
|
*
|
|
106
126
|
* @param namedRoutes - The router's `namedRoutes` map.
|
|
107
|
-
* @param options - `cwd` (project root, default `process.cwd()`)
|
|
127
|
+
* @param options - `cwd` (project root, default `process.cwd()`), `check` (compare only, never write), and `methods` (name → HTTP verb).
|
|
108
128
|
* @returns Whether the on-disk file was stale, plus the contents it should have.
|
|
109
129
|
*/
|
|
110
130
|
export async function writeRouteTypes(
|
|
111
131
|
namedRoutes: ReadonlyMap<string, string>,
|
|
112
|
-
options: { cwd?: string; check?: boolean } = {},
|
|
132
|
+
options: { cwd?: string; check?: boolean; methods?: ReadonlyMap<string, string> } = {},
|
|
113
133
|
): Promise<RouteTypesResult> {
|
|
114
134
|
const cwd = options.cwd ?? process.cwd();
|
|
115
|
-
|
|
135
|
+
// Derived here rather than asked of the caller. `zt dev` regenerates this file
|
|
136
|
+
// on every boot through its own call site, and when only `route:types` passed
|
|
137
|
+
// the verbs, a dev restart silently rewrote the table empty — which turned a
|
|
138
|
+
// form POST into a GET and 404'd. A default a caller must remember is a
|
|
139
|
+
// default that eventually gets forgotten.
|
|
140
|
+
const methods = options.methods ?? routeMethods();
|
|
141
|
+
const content = generateRouteTypes(namedRoutes, "@zerotal/core", methods);
|
|
116
142
|
const target = `${cwd}/${ROUTE_TYPES_FILE}`;
|
|
117
143
|
|
|
118
144
|
const file = Bun.file(target);
|
|
@@ -130,3 +156,18 @@ export async function writeRouteTypes(
|
|
|
130
156
|
count: namedRoutes.size,
|
|
131
157
|
};
|
|
132
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Name → HTTP verb for every named route the router knows.
|
|
162
|
+
*
|
|
163
|
+
* `RouteDefinition` carries its own `name` beside `method`, so the pair comes
|
|
164
|
+
* from one record. A path join would be wrong: `GET /login` and `POST /login`
|
|
165
|
+
* share a path and differ only by name.
|
|
166
|
+
*/
|
|
167
|
+
export function routeMethods(): Map<string, string> {
|
|
168
|
+
const methods = new Map<string, string>();
|
|
169
|
+
for (const definition of Router.routes.values()) {
|
|
170
|
+
if (definition.name) methods.set(definition.name, definition.method);
|
|
171
|
+
}
|
|
172
|
+
return methods;
|
|
173
|
+
}
|
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,74 @@ 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
|
+
export interface RouteMethodRegistry {}
|
|
202
|
+
|
|
203
|
+
/** A name the generated table knows a verb for. */
|
|
204
|
+
export type MethodedRouteName = Extract<keyof RouteMethodRegistry, string>;
|
|
205
|
+
|
|
206
|
+
const methodTable = new Map<string, string>();
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Register the generated `METHODS` table.
|
|
210
|
+
*
|
|
211
|
+
* Called once at boot beside {@link defineRoutes}. Kept separate because the two
|
|
212
|
+
* tables have different audiences: a page that only builds links needs the
|
|
213
|
+
* patterns and never the verbs, and a bundler can then drop the verbs entirely.
|
|
214
|
+
*/
|
|
215
|
+
export function defineRouteMethods(table: Readonly<Record<string, string>>): void {
|
|
216
|
+
methodTable.clear();
|
|
217
|
+
for (const [name, method] of Object.entries(table)) methodTable.set(name, method);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The verb a named route answers on, or undefined when it was never registered. */
|
|
221
|
+
export function routeMethod(name: string): string | undefined {
|
|
222
|
+
return methodTable.get(name);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A resolved endpoint: where to send a request, and how. */
|
|
226
|
+
export interface RouteAction {
|
|
227
|
+
url: string;
|
|
228
|
+
method: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve a named route to both its URL and its HTTP method.
|
|
233
|
+
*
|
|
234
|
+
* The pair is the point. A form that hardcodes a URL can still send the wrong
|
|
235
|
+
* verb, and the failure — a 404 or a 405 on submit — looks nothing like its
|
|
236
|
+
* cause. Taking both from one generated record means a route that changes verb
|
|
237
|
+
* changes it everywhere at once.
|
|
238
|
+
*
|
|
239
|
+
* Throws when the name has no registered verb. An earlier version defaulted to
|
|
240
|
+
* `GET`, and that default cost a real bug: a regenerated table came back empty,
|
|
241
|
+
* every `action()` reported `GET`, and a file upload submitted as a GET to its
|
|
242
|
+
* own store route and 404'd. The point of resolving a verb from a table is that
|
|
243
|
+
* a wrong verb becomes impossible — a silent fallback gives that away for a
|
|
244
|
+
* failure mode nobody reads, so this is loud instead.
|
|
245
|
+
*
|
|
246
|
+
* Use {@link route} for links, which need no verb.
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* const submit = action("projects.issues.comments.store", { project: "apollo", issue: 4 });
|
|
250
|
+
* // → { url: "/projects/apollo/issues/4/comments", method: "POST" }
|
|
251
|
+
*/
|
|
252
|
+
export function action<N extends RouteTarget>(name: N, ...args: RouteArgs<N>): RouteAction {
|
|
253
|
+
const method = methodTable.get(name as string);
|
|
254
|
+
if (method === undefined) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`action("${String(name)}"): no HTTP method registered for this route. ` +
|
|
257
|
+
`On the server this is installed at boot, so an empty table means the route ` +
|
|
258
|
+
`is not registered. In a browser bundle, call defineRouteMethods(METHODS) ` +
|
|
259
|
+
`from types/routes.generated.ts at your entry point. ` +
|
|
260
|
+
`Use route() instead for links, which need no verb.`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return { url: route(name, ...args), method };
|
|
264
|
+
}
|
|
@@ -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.
|