@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.
@@ -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._key(chunk.toString());
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
- private _key(sequence: string): void {
280
- if (this._searching) {
281
- this._searchKey(sequence);
282
- return;
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
- this._paint();
370
+ return true;
333
371
  }
334
372
 
335
- private _searchKey(sequence: string): void {
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
- this._paint();
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 total = this._visibleLines(card).length;
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
- APP_ENV: "web",
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
@@ -22,7 +22,11 @@
22
22
  */
23
23
  import { join } from "node:path";
24
24
  import { ConfigError } from "../errors/ConfigError.ts";
25
- import { DEPLOY_ENV_VAR, RUNTIME_MODES as _RUNTIME_MODES } from "../support/env.ts";
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 `APP_ENV` from the CLI command name call this in `zerotal.ts` BEFORE
50
- * the dynamic import of `bootstrap/app.ts` so `Application.create()` sees
51
- * the correct environment.
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
- * If `APP_ENV` is already set (e.g. from `.env` or the shell), this is a no-op.
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
- * | Command | APP_ENV |
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
- // Preserve the deployment name before it is overwritten. Every branch below
75
- // replaces `APP_ENV` with a runtime mode, which is why six different gates that
76
- // asked "is this production?" of `Bun.env["APP_ENV"]` were reading `"web"` and
77
- // quietly answering no including the weak-`APP_KEY` refusal and the ORM's
78
- // N+1 detector. `deployEnv()` reads this back; see {@link DEPLOY_ENV_VAR}.
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
- // The guard is what protects a re-entrant call: `current` is only ever written
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 env it booted
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
- if (!current || !_RUNTIME_MODES.has(current.toLowerCase())) {
99
- environment["APP_ENV"] = "web";
100
- }
119
+ environment[_RUNTIME_MODE_VAR] = "web";
101
120
  } else if (["worker", "queue:work"].includes(normalizedCommand)) {
102
- if (!current || !_RUNTIME_MODES.has(current.toLowerCase())) {
103
- environment["APP_ENV"] = "worker";
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
 
@@ -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;
@@ -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 and the URL pattern it compiles to. `route()` reads this",
36
- "// through declaration merging, so an unknown name or a missing :param is a",
37
- "// compile error. Commit this file: editors and CI need it without booting the app.",
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
- ([name, pattern]) =>
63
- ` ${/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name)}: ` +
64
- `${JSON.stringify(pattern)},`,
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()`) and `check` (compare only, never write).
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
- const content = generateRouteTypes(namedRoutes);
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
+ }