@zerotal/core 1.4.0 β†’ 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +370 -0
  2. package/package.json +1 -1
  3. package/src/application/Application.ts +107 -9
  4. package/src/application/DevErrorPage.ts +82 -0
  5. package/src/application/diagnostics.ts +111 -0
  6. package/src/command/CommandRunner.ts +82 -1
  7. package/src/command/builtin/AssetsBuildCommand.ts +102 -0
  8. package/src/command/builtin/DeployCommand.ts +315 -0
  9. package/src/command/builtin/DevCommand.ts +88 -0
  10. package/src/command/builtin/DoctorCommand.ts +97 -0
  11. package/src/command/builtin/MakeCommandCommand.ts +2 -0
  12. package/src/command/builtin/RouteTypesCommand.ts +56 -0
  13. package/src/command/builtin/ServeCommand.ts +232 -44
  14. package/src/command/builtin/index.ts +5 -0
  15. package/src/command/scaffold/zerotal.ts.txt +2 -10
  16. package/src/config/AppConfig.ts +109 -2
  17. package/src/config/DeployConfig.ts +71 -0
  18. package/src/config/index.ts +2 -0
  19. package/src/config/registry.ts +1 -0
  20. package/src/container/Container.ts +3 -3
  21. package/src/container/inject.ts +3 -2
  22. package/src/context/RequestContext.ts +60 -0
  23. package/src/contracts/session.ts +18 -3
  24. package/src/dev/BuildCache.ts +312 -0
  25. package/src/dev/CssPlugins.ts +93 -7
  26. package/src/dev/DevBuildHook.ts +14 -1
  27. package/src/dev/DevDeck.ts +549 -0
  28. package/src/dev/DevOrchestrator.ts +166 -31
  29. package/src/dev/DevProcess.ts +221 -0
  30. package/src/dev/DevReloadMiddleware.ts +1 -1
  31. package/src/dev/DevSupervisor.ts +363 -0
  32. package/src/dev/bootBuild.ts +94 -0
  33. package/src/dev/index.ts +24 -0
  34. package/src/dev/startDevMode.ts +145 -0
  35. package/src/doctor/AppDoctor.ts +399 -0
  36. package/src/doctor/TransportProbe.ts +169 -0
  37. package/src/events/Emitter.ts +4 -3
  38. package/src/facade/facades/App.ts +10 -2
  39. package/src/helpers/index.ts +24 -4
  40. package/src/helpers/response.ts +18 -8
  41. package/src/http/Uri.ts +7 -3
  42. package/src/http/originGuard.ts +1 -1
  43. package/src/http/url.ts +10 -4
  44. package/src/index.ts +43 -0
  45. package/src/lock/LockManager.ts +190 -14
  46. package/src/lock/drivers/LockDriver.ts +11 -0
  47. package/src/lock/drivers/MemoryLockDriver.ts +21 -1
  48. package/src/lock/drivers/RedisLockDriver.ts +64 -8
  49. package/src/lock/drivers/SqliteLockDriver.ts +13 -0
  50. package/src/lock/errors.ts +26 -0
  51. package/src/lock/facades/Lock.ts +30 -5
  52. package/src/lock/index.ts +2 -2
  53. package/src/macros/config.macro.ts +2 -0
  54. package/src/provider/ServiceProvider.ts +40 -0
  55. package/src/router/Router.ts +111 -13
  56. package/src/router/registry.ts +123 -0
  57. package/src/router/routeTypes.ts +132 -0
  58. package/src/support/classRef.ts +27 -0
  59. package/src/support/env.ts +99 -3
  60. package/src/support/unroutedRoutes.ts +37 -0
@@ -1,11 +1,14 @@
1
- /**
1
+ ο»Ώ/**
2
2
  * The `serve` command, which starts the HTTP server. Also drives dev mode: the
3
3
  * file-watching orchestrator (process 1) and the managed dev worker (process 2).
4
4
  */
5
5
  import { Command } from "../Command.ts";
6
- import { DevOrchestrator } from "../../dev/DevOrchestrator.ts";
6
+ import type { FlagDef } from "../Command.ts";
7
7
  import { hasDevBuildHooks, runDevBuildHooks } from "../../dev/DevBuildHook.ts";
8
8
  import { buildConfiguredAssets, type AssetBuildConfig } from "../../dev/CssPlugins.ts";
9
+ import { bootBuildDecision } from "../../dev/bootBuild.ts";
10
+ import { collectDevProcesses, type ResolvedDevProcess } from "../../dev/DevProcess.ts";
11
+ import { startDevMode, SERVER_PROCESS_NAME } from "../../dev/startDevMode.ts";
9
12
  import type { ConfigManager } from "../../config/ConfigManager.ts";
10
13
  import type { Application } from "../../application/Application.ts";
11
14
  import * as DevWsServer from "../../dev/DevWsServer.ts";
@@ -34,36 +37,36 @@ export class ServeCommand extends Command {
34
37
  static description = "Start the HTTP server";
35
38
  static needsApp = true;
36
39
 
37
- static flags = [
40
+ static flags: FlagDef[] = [
38
41
  {
39
42
  name: "port",
40
43
  short: "p",
41
- type: "number" as const,
44
+ type: "number",
42
45
  description: "Port to listen on",
43
46
  default: 3000,
44
47
  },
45
48
  {
46
49
  name: "dev",
47
- type: "boolean" as const,
50
+ type: "boolean",
48
51
  description: "Start in dev mode with file watching, auto-rebuild, and browser reload",
49
52
  default: false,
50
53
  },
51
54
  {
52
55
  name: "force",
53
- type: "boolean" as const,
56
+ type: "boolean",
54
57
  description: "If the port is busy, stop the process holding it",
55
58
  default: false,
56
59
  },
57
60
  {
58
61
  name: "auto-port",
59
- type: "boolean" as const,
62
+ type: "boolean",
60
63
  description: "If the port is busy, start on the next free port",
61
64
  default: false,
62
65
  },
63
66
  // Internal flag β€” spawned by DevOrchestrator; not shown in help
64
67
  {
65
68
  name: "dev-worker",
66
- type: "boolean" as const,
69
+ type: "boolean",
67
70
  description: "Internal: run as the managed server process under DevOrchestrator",
68
71
  default: false,
69
72
  },
@@ -72,49 +75,23 @@ export class ServeCommand extends Command {
72
75
  async run(): Promise<void> {
73
76
  const isDev = this.flags["dev"] as boolean;
74
77
  const isWorker = this.flags["dev-worker"] as boolean;
75
- const port = await this._resolvePort(this.flags["port"] as number, isWorker);
76
- const assets = this._assetsConfig();
77
78
 
78
79
  // ── DEV ORCHESTRATOR (Process 1) ────────────────────────────────────────
79
80
  // Spawns and manages the server child process, watches files, drives builds.
81
+ //
82
+ // Handled before the port is resolved, because in dev mode the port is not
83
+ // always needed: `--list` starts nothing, and `--only=queue` starts no
84
+ // server. Resolving first turns either into a prompt about killing whatever
85
+ // holds port 3000 β€” or a hard failure with no TTY β€” over a port that was
86
+ // never going to be bound.
80
87
  if (isDev) {
81
- this._announce("dev", port);
82
-
83
- // Every view package (Inertia/Flow) that registered a build routine gets
84
- // run on each change; otherwise synthesise one from `app.assets` so
85
- // configured assets rebuild on change.
86
- let buildHook: (() => Promise<{ success: boolean; logs?: unknown[] }>) | undefined =
87
- hasDevBuildHooks() ? runDevBuildHooks : undefined;
88
- if (!buildHook && assets) {
89
- buildHook = (): Promise<{ success: boolean; logs?: unknown[] }> =>
90
- buildConfiguredAssets(assets, process.cwd());
91
- }
92
-
93
- if (!buildHook) {
94
- // No Inertia (or no frontend build configured). Fall back to simple
95
- // bun --watch restart so the developer still gets auto-reload.
96
- console.warn(" [zerotal:dev] no build hook registered β€” falling back to bun --watch");
97
- const subprocess = Bun.spawn(
98
- ["bun", "--watch", Bun.main, "serve", "--port", String(port)],
99
- {
100
- stdin: "inherit",
101
- stdout: "inherit",
102
- stderr: "inherit",
103
- // The port is already settled here, and each --watch restart races
104
- // the socket the previous run is still letting go of. Without this
105
- // the child would re-prompt on every save.
106
- env: { ...Bun.env, [PORT_RESOLVED_ENV_VAR]: "1" },
107
- },
108
- );
109
- await subprocess.exited;
110
- return;
111
- }
112
-
113
- const orchestrator = new DevOrchestrator(port, process.cwd(), buildHook);
114
- await orchestrator.start();
88
+ await this._runDevMode();
115
89
  return;
116
90
  }
117
91
 
92
+ const port = await this._resolvePort(this.flags["port"] as number, isWorker);
93
+ const assets = this._assetsConfig();
94
+
118
95
  // ── DEV WORKER (Process 2) ──────────────────────────────────────────────
119
96
  // Normal server start, but:
120
97
  // 1. Registers GET /__dev/events for browser SSE reload.
@@ -122,6 +99,13 @@ export class ServeCommand extends Command {
122
99
  if (isWorker) {
123
100
  const app = this.app as import("../../application/Application.ts").Application;
124
101
 
102
+ // Refresh types/routes.generated.ts from the routes this boot registered.
103
+ // The worker is the right place for it: the orchestrator restarts the
104
+ // worker on every route-file change, so this runs exactly when the route
105
+ // table can have changed, and it reads a booted router rather than
106
+ // re-deriving names from filenames.
107
+ await this._writeRouteTypes();
108
+
125
109
  // Enable the /__dev/ws HMR WebSocket endpoint.
126
110
  app.enableDevWs();
127
111
 
@@ -147,6 +131,180 @@ export class ServeCommand extends Command {
147
131
  await new Promise<never>(() => {});
148
132
  }
149
133
 
134
+ // ── Dev mode ───────────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Everything `--dev` does, shared verbatim with `bun zt dev`.
138
+ *
139
+ * `protected` rather than private because {@link DevCommand} is this command
140
+ * with a richer flag set β€” keeping one body is what stops the two from
141
+ * disagreeing about what dev mode is.
142
+ */
143
+ protected async _runDevMode(): Promise<void> {
144
+ const processes = await this._devProcesses();
145
+
146
+ if (this.flags["list"]) {
147
+ this._listDevProcesses(processes);
148
+ return;
149
+ }
150
+
151
+ // Item C: the cache is keyed on file mtime and size, which a same-size edit
152
+ // that preserves mtime slips past. This is the escape hatch for that, and
153
+ // for the "I don't believe the cache" moment every cache eventually causes.
154
+ if (this.flags["force-build"]) {
155
+ (Bun.env as Record<string, string>)["ZT_NO_BUILD_CACHE"] = "1";
156
+ }
157
+
158
+ const wantsServer = processes.some((entry) => entry.name === SERVER_PROCESS_NAME);
159
+ const assets = this._assetsConfig();
160
+
161
+ if (!wantsServer) {
162
+ // `--only=queue`, say. No port to bind and no assets to build β€” just the
163
+ // supervisor and the deck, so a busy port is nobody's problem.
164
+ await startDevMode({
165
+ port: 0,
166
+ cwd: process.cwd(),
167
+ processes,
168
+ writer: this._writer,
169
+ deckMode: this.flags["stream"] ? "stream" : undefined,
170
+ });
171
+ return;
172
+ }
173
+
174
+ const port = await this._resolvePort(this.flags["port"] as number, false);
175
+ this._announce("dev", port);
176
+
177
+ // Every view package (Inertia/Flow) that registered a build routine gets
178
+ // run on each change; otherwise synthesise one from `app.assets` so
179
+ // configured assets rebuild on change.
180
+ let buildHook: (() => Promise<{ success: boolean; logs?: unknown[] }>) | undefined =
181
+ hasDevBuildHooks() ? runDevBuildHooks : undefined;
182
+ if (!buildHook && assets) {
183
+ buildHook = (): Promise<{ success: boolean; logs?: unknown[] }> =>
184
+ buildConfiguredAssets(assets, process.cwd());
185
+ }
186
+
187
+ if (!buildHook) {
188
+ // No Inertia (or no frontend build configured). Fall back to simple
189
+ // bun --watch restart so the developer still gets auto-reload.
190
+ //
191
+ // Deliberately keeps the terminal to itself: `bun --watch` needs stdin,
192
+ // and a deck drawn over a process we are not supervising would show tabs
193
+ // that no key could restart.
194
+ this.warn(" [zerotal:dev] no build hook registered β€” falling back to bun --watch");
195
+ if (processes.some((entry) => entry.name !== SERVER_PROCESS_NAME)) {
196
+ this.dim(" [zerotal:dev] dev processes are not supervised on this path.");
197
+ }
198
+ const subprocess = Bun.spawn(["bun", "--watch", Bun.main, "serve", "--port", String(port)], {
199
+ stdin: "inherit",
200
+ stdout: "inherit",
201
+ stderr: "inherit",
202
+ // The port is already settled here, and each --watch restart races
203
+ // the socket the previous run is still letting go of. Without this
204
+ // the child would re-prompt on every save.
205
+ env: { ...Bun.env, [PORT_RESOLVED_ENV_VAR]: "1" },
206
+ });
207
+ await subprocess.exited;
208
+ return;
209
+ }
210
+
211
+ await startDevMode({
212
+ port,
213
+ cwd: process.cwd(),
214
+ build: buildHook,
215
+ processes,
216
+ writer: this._writer,
217
+ deckMode: this.flags["stream"] ? "stream" : undefined,
218
+ });
219
+ }
220
+
221
+ /**
222
+ * What dev mode should run, after `--only` / `--without`.
223
+ *
224
+ * The server is prepended as an ordinary entry so the two filters need no
225
+ * special case for it β€” `--only=queue` really does mean "just the queue", and
226
+ * says so by leaving the server out of the list rather than by ignoring you.
227
+ */
228
+ protected async _devProcesses(): Promise<ResolvedDevProcess[]> {
229
+ const app = this.app as Application;
230
+ const server: ResolvedDevProcess = {
231
+ name: SERVER_PROCESS_NAME,
232
+ label: SERVER_PROCESS_NAME,
233
+ color: "green",
234
+ restart: "on-failure",
235
+ after: "none",
236
+ registrant: "@zerotal/core",
237
+ };
238
+
239
+ let contributed: ResolvedDevProcess[] = [];
240
+ try {
241
+ contributed = await collectDevProcesses(app, this._config());
242
+ } catch (error) {
243
+ // A badly-formed definition names its registrant. Reporting and carrying
244
+ // on beats refusing to start the server over another package's typo.
245
+ this.error(` ${error instanceof Error ? error.message : String(error)}`);
246
+ }
247
+
248
+ const only = _names(this.flags["only"]);
249
+ const without = _names(this.flags["without"]);
250
+
251
+ return [server, ...contributed].filter((entry) => {
252
+ if (only.length > 0 && !only.includes(entry.name)) return false;
253
+ return !without.includes(entry.name);
254
+ });
255
+ }
256
+
257
+ /** `--list`: what would run, and who asked for it. */
258
+ private _listDevProcesses(processes: ResolvedDevProcess[]): void {
259
+ this.section("Dev processes");
260
+ if (processes.length === 0) {
261
+ this.dim(" Nothing to run β€” every process was filtered out.");
262
+ return;
263
+ }
264
+ for (const entry of processes) {
265
+ const command = entry.run
266
+ ? "(in-process)"
267
+ : (entry.argv?.join(" ") ?? "(managed by the orchestrator)");
268
+ this.line(` ${entry.name}`);
269
+ this.table(
270
+ [
271
+ ["command", command],
272
+ ["registered by", entry.registrant],
273
+ ["restart", entry.restart],
274
+ ["after", entry.after],
275
+ ],
276
+ 4,
277
+ );
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Refresh the generated route-name map from this boot's router.
283
+ *
284
+ * Best-effort by design: a read-only project directory (a container mount, a
285
+ * sandbox) must not take the dev server down over a types file, and the app
286
+ * still runs perfectly well without it β€” only the compile-time check is lost.
287
+ */
288
+ private async _writeRouteTypes(): Promise<void> {
289
+ try {
290
+ const { writeRouteTypes } = await import("../../router/routeTypes.ts");
291
+ const { Router } = await import("../../router/Router.ts");
292
+ const result = await writeRouteTypes(Router.namedRoutes);
293
+ if (result.changed) this.dim(` [zerotal:dev] wrote ${result.path} (${result.count} routes)`);
294
+ } catch (error) {
295
+ this.dim(` [zerotal:dev] could not write route types: ${error}`);
296
+ }
297
+ }
298
+
299
+ /** The config manager, or undefined when the app has none bound. */
300
+ private _config(): { get<T>(key: string, fallback?: T): T | undefined } | undefined {
301
+ try {
302
+ return (this.app as Application).container.makeSync("config") as ConfigManager;
303
+ } catch {
304
+ return undefined;
305
+ }
306
+ }
307
+
150
308
  // ── Private ────────────────────────────────────────────────────────────────
151
309
 
152
310
  /**
@@ -282,8 +440,29 @@ export class ServeCommand extends Command {
282
440
  }
283
441
  }
284
442
 
443
+ /** The resolved `app.env`, or undefined when the config store is unavailable. */
444
+ private _appEnv(): string | undefined {
445
+ try {
446
+ const config = (this.app as Application).container.makeSync("config") as ConfigManager;
447
+ return config.get("app.env") as string | undefined;
448
+ } catch {
449
+ return undefined;
450
+ }
451
+ }
452
+
285
453
  /** Bundle the configured assets, reporting any build failure (non-fatal β€” the server still starts). */
286
454
  private async _buildAssets(assets: AssetBuildConfig): Promise<void> {
455
+ // A production deployment that built its assets ahead of time and locked the tree
456
+ // down is doing the right thing; rebuilding at boot would only fail. See bootBuild.ts.
457
+ const decision = await bootBuildDecision(
458
+ [`${process.cwd()}/${assets.outDir}`],
459
+ (this._appEnv() ?? Bun.env["APP_ENV"]) as string | undefined,
460
+ );
461
+ if (!decision.build) {
462
+ this.info(decision.reason ?? "Skipping the boot-time asset build.");
463
+ return;
464
+ }
465
+
287
466
  const entries = Array.isArray(assets.entrypoint)
288
467
  ? assets.entrypoint.join(", ")
289
468
  : assets.entrypoint;
@@ -314,6 +493,15 @@ export class ServeCommand extends Command {
314
493
  }
315
494
  }
316
495
 
496
+ /** Split a `--only` / `--without` value into names. Absent means "no filter". */
497
+ function _names(flag: unknown): string[] {
498
+ if (typeof flag !== "string") return [];
499
+ return flag
500
+ .split(",")
501
+ .map((name) => name.trim())
502
+ .filter(Boolean);
503
+ }
504
+
317
505
  /** `" by bun.exe (pid 1234)"`, or `""` when the holder could not be identified. */
318
506
  function _describe(owner: PortOwner | undefined): string {
319
507
  if (!owner) return "";
@@ -25,6 +25,7 @@
25
25
  * @packageDocumentation
26
26
  */
27
27
  export { ServeCommand } from "./ServeCommand.ts";
28
+ export { DevCommand } from "./DevCommand.ts";
28
29
  export { ReplCommand } from "./ReplCommand.ts";
29
30
  export { StartCommand } from "./StartCommand.ts";
30
31
  export { WorkerCommand } from "./WorkerCommand.ts";
@@ -47,7 +48,11 @@ export { MakeResourceCommand } from "./MakeResourceCommand.ts";
47
48
  export { MakeTestCommand } from "./MakeTestCommand.ts";
48
49
  export { TestCommand } from "./TestCommand.ts";
49
50
  export { RouteListCommand } from "./RouteListCommand.ts";
51
+ export { RouteTypesCommand } from "./RouteTypesCommand.ts";
52
+ export { DoctorCommand } from "./DoctorCommand.ts";
53
+ export { DeployCommand, makeDeployCommand } from "./DeployCommand.ts";
50
54
  export { MakeProviderCommand } from "./MakeProviderCommand.ts";
51
55
  export { CssBuildCommand } from "./CssBuildCommand.ts";
56
+ export { AssetsBuildCommand } from "./AssetsBuildCommand.ts";
52
57
  export { LintPackagesCommand } from "./LintPackagesCommand.ts";
53
58
  export { MakePackageCommand } from "./MakePackageCommand.ts";
@@ -4,16 +4,8 @@
4
4
  // DO NOT MODIFY THIS FILE.
5
5
  // This file is managed by the Zerotal framework.
6
6
  //
7
- // To add custom commands, create classes in app/commands/ and
8
- // register them in your AppServiceProvider.onBooted():
9
- //
10
- // override async onBooted(): Promise<void> {
11
- // if (this.app._env === 'console') {
12
- // const runner = this.app.container.makeSync('commands');
13
- // const { MyCommand } = await import('../commands/MyCommand.ts');
14
- // runner.register(MyCommand);
15
- // }
16
- // }
7
+ // To add custom commands, create classes in app/commands/ β€”
8
+ // they are discovered automatically (`bun zerotal.ts make:command MyCommand`).
17
9
  //
18
10
  // Help: bun zerotal.ts list
19
11
  // bun zerotal.ts help <command>
@@ -4,7 +4,9 @@
4
4
  * defaults, conventions), with sensible defaults applied over caller overrides.
5
5
  */
6
6
  import { deepMerge } from "../support/deepMerge.ts";
7
+ import { isProdLike } from "../support/env.ts";
7
8
  import type { HealthConfigShape } from "../health/Health.ts";
9
+ import type { DevConfigShape } from "../dev/DevProcess.ts";
8
10
 
9
11
  /** TLS certificate and key paths that enable HTTPS when provided. */
10
12
  export interface AppTlsConfig {
@@ -30,6 +32,7 @@ export interface ConventionsConfig {
30
32
  jobs: string;
31
33
  schedules: string;
32
34
  validators: string;
35
+ commands: string;
33
36
  };
34
37
  }
35
38
 
@@ -45,9 +48,32 @@ export interface AppThrottleConfig {
45
48
  windowSeconds: number;
46
49
  }
47
50
 
48
- /** App-level security-header defaults (consumed by `SecureHeadersMiddleware`). */
51
+ /**
52
+ * App-level security-header defaults (consumed by `SecureHeadersMiddleware`).
53
+ *
54
+ * The middleware reads this whole block and layers it over its own defaults, so
55
+ * every one of its options has always worked here β€” but only `frameOptions` was
56
+ * declared, which made the rest a type error to write down. `secure` is the one
57
+ * that matters: HSTS is emitted only when it is true, so an app that could not
58
+ * name it in config had no supported way to turn HSTS on.
59
+ */
49
60
  export interface AppSecureHeadersConfig {
50
61
  frameOptions: "DENY" | "SAMEORIGIN";
62
+ /**
63
+ * Enable HSTS (and the `Secure` flag on the XSRF-TOKEN cookie). Defaults to
64
+ * `false` β€” turn it on for any deployment served over HTTPS.
65
+ */
66
+ secure?: boolean;
67
+ /** `Strict-Transport-Security` max-age in seconds. Defaults to one year. */
68
+ hstsMaxAge?: number;
69
+ /** Include `includeSubDomains` in the HSTS header. Defaults to `true`. */
70
+ hstsIncludeSubDomains?: boolean;
71
+ /** Set the HSTS `preload` directive. Only with the domain registered. Defaults to `false`. */
72
+ hstsPreload?: boolean;
73
+ /** `Content-Security-Policy` value. Omitted by default. */
74
+ contentSecurityPolicy?: string;
75
+ /** `Referrer-Policy` value. Defaults to `strict-origin-when-cross-origin`. */
76
+ referrerPolicy?: string;
51
77
  }
52
78
 
53
79
  /**
@@ -121,6 +147,7 @@ const DEFAULT_CONVENTION_PATHS: ConventionsConfig["paths"] = {
121
147
  jobs: "app/jobs",
122
148
  schedules: "app/schedules",
123
149
  validators: "app/validators",
150
+ commands: "app/commands",
124
151
  };
125
152
 
126
153
  // ── Full shape (resolved β€” what the config store holds after AppConfig()) ────
@@ -180,6 +207,26 @@ export interface AppConfigShape {
180
207
  */
181
208
  health: boolean | HealthConfigShape;
182
209
 
210
+ // ── Transport origins ─────────────────────────────────────────────────────
211
+ /**
212
+ * Origins accepted for credentialed endpoints that bypass the middleware pipeline:
213
+ * WebSocket upgrades and raw routes, of which Flow's `/__flow/http` action fallback
214
+ * is one. Compared exactly β€” no wildcards, no suffix matching.
215
+ *
216
+ * The app's own origin is always accepted, but "own" means
217
+ * `new URL(request.url).origin` β€” behind a reverse proxy that is the loopback
218
+ * address it was bound to (`http://127.0.0.1:3000`), never the public URL the
219
+ * browser truthfully sends. So the origin of {@link AppConfigShape.url} is always
220
+ * merged into this list: without it a proxied app renders every page correctly and
221
+ * refuses every browser-initiated action with a 403, which is a far quieter failure
222
+ * than a 500 and passes any health check that reads a status code.
223
+ *
224
+ * Add entries only for a *different* host that legitimately drives this app β€” an SPA
225
+ * on `app.example.com` calling `api.example.com`. Unlike every other array in this
226
+ * config, what you pass is added to the URL's origin rather than replacing it.
227
+ */
228
+ allowedOrigins: string[];
229
+
183
230
  // ── Middleware defaults ───────────────────────────────────────────────────
184
231
  /** CORS defaults applied by `CorsMiddleware` when registered without explicit options. */
185
232
  cors: AppCorsConfig;
@@ -192,11 +239,60 @@ export interface AppConfigShape {
192
239
  /** Front-end asset bundling, built on `serve`. Omitted means no asset build. */
193
240
  assets?: AppAssetsConfig;
194
241
 
242
+ // ── Dev mode ──────────────────────────────────────────────────────────────
243
+ /**
244
+ * Extra processes for `bun zt dev`, and names to drop.
245
+ *
246
+ * App entries are registered after every provider's, so reusing a provider's
247
+ * name replaces it rather than adding a second tab β€” which is how an app swaps
248
+ * out, say, the queue worker for its own. `disable` removes by name whoever
249
+ * registered it.
250
+ *
251
+ * @example
252
+ * ```ts
253
+ * dev: {
254
+ * processes: [{ name: "stripe", command: ["stripe", "listen", "--forward-to", "localhost:3000"] }],
255
+ * disable: ["queue"],
256
+ * }
257
+ * ```
258
+ */
259
+ dev?: DevConfigShape;
260
+
195
261
  // ── Conventions ───────────────────────────────────────────────────────────
196
262
  /** Auto-discovery settings (enabled + per-concern paths). */
197
263
  conventions: ConventionsConfig;
198
264
  }
199
265
 
266
+ /**
267
+ * Reduce a list of URLs or origins to the deduplicated origins an `Origin` header can be
268
+ * compared against.
269
+ *
270
+ * Full URLs are accepted because the two things that feed this list β€” `app.url` and an
271
+ * `ALLOWED_ORIGINS` env var someone pasted a browser address into β€” usually carry a path
272
+ * or a trailing slash, and `https://app.com/` never equals the `https://app.com` a browser
273
+ * sends. Entries that do not parse are kept verbatim rather than dropped: an unmatchable
274
+ * entry is inert, whereas silently discarding one hides the typo that caused it. `bun zt
275
+ * doctor` reports those.
276
+ */
277
+ function _normaliseOrigins(entries: string[]): string[] {
278
+ const out: string[] = [];
279
+ for (const entry of entries) {
280
+ if (typeof entry !== "string") continue;
281
+ const trimmed = entry.trim();
282
+ if (trimmed === "") continue;
283
+ let origin: string;
284
+ try {
285
+ origin = new URL(trimmed).origin;
286
+ // `new URL("mailto:a@b").origin` is "null" β€” parsed, but not an origin.
287
+ if (origin === "null") origin = trimmed;
288
+ } catch {
289
+ origin = trimmed;
290
+ }
291
+ if (!out.includes(origin)) out.push(origin);
292
+ }
293
+ return out;
294
+ }
295
+
200
296
  // ── AppConfig factory ─────────────────────────────────────────────────────────
201
297
 
202
298
  /**
@@ -232,6 +328,7 @@ export function AppConfig(options: {
232
328
  tls?: AppTlsConfig;
233
329
  maxRequestBodySize?: number;
234
330
  health?: boolean | HealthConfigShape;
331
+ allowedOrigins?: string[];
235
332
  cors?: Partial<AppCorsConfig>;
236
333
  throttle?: Partial<AppThrottleConfig>;
237
334
  secureHeaders?: Partial<AppSecureHeadersConfig>;
@@ -242,6 +339,7 @@ export function AppConfig(options: {
242
339
  minify?: boolean;
243
340
  loader?: Record<string, AssetLoaderKind>;
244
341
  };
342
+ dev?: DevConfigShape;
245
343
  conventions?: { enabled?: boolean; paths?: Partial<ConventionsConfig["paths"]> };
246
344
  }): AppConfigShape {
247
345
  // Resolve env-derived defaults, then deep-merge the caller's overrides so partial nested
@@ -258,6 +356,7 @@ export function AppConfig(options: {
258
356
  http3: false,
259
357
  maxRequestBodySize: DEFAULT_MAX_REQUEST_BODY_SIZE,
260
358
  health: false,
359
+ allowedOrigins: [],
261
360
  cors: DEFAULT_CORS,
262
361
  throttle: DEFAULT_THROTTLE,
263
362
  secureHeaders: DEFAULT_SECURE_HEADERS,
@@ -265,10 +364,18 @@ export function AppConfig(options: {
265
364
  };
266
365
  const resolved = deepMerge(defaults, options as Partial<AppConfigShape>);
267
366
 
367
+ // `allowedOrigins` always contains the origin of `url`, and is the one array here that
368
+ // unions rather than replaces. An app naming a second origin does not mean "and stop
369
+ // trusting my own public URL", and the cost of getting that wrong is asymmetric: the
370
+ // app keeps rendering and every action 403s, with nothing in the logs to say why.
371
+ resolved.allowedOrigins = _normaliseOrigins([resolved.url, ...resolved.allowedOrigins]);
372
+
268
373
  // Normalise the optional `assets` block: fill outDir/prefix/minify defaults
269
374
  // only when the app actually declares an asset entrypoint.
270
375
  if (options.assets) {
271
- const isProduction = resolved.env === "production" || resolved.env === "prod";
376
+ // `isProdLike`, so `staging` minifies too. This runs at config-load time, where
377
+ // `resolved.env` is still the deployment name rather than the runtime mode.
378
+ const isProduction = isProdLike(resolved.env);
272
379
  resolved.assets = {
273
380
  entrypoint: options.assets.entrypoint,
274
381
  outDir: options.assets.outDir ?? "public",
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Deploy targets β€” the environments this app is released to, and the pipeline each
3
+ * one runs.
4
+ *
5
+ * Every target gets its own command: declare `production` and `staging` and you get
6
+ * `bun zt deploy:production` and `bun zt deploy:staging`. The name is the whole
7
+ * point of the split β€” it is checked against the deployment this process was
8
+ * actually started as, so running the production pipeline against a staging box
9
+ * stops on the first line rather than migrating the wrong database.
10
+ *
11
+ * The file is optional. Without it, `production` and `staging` exist with the
12
+ * default pipeline, which is what most apps want.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * // config/deploy.ts
17
+ * import { DeployConfig } from "zerotal/config";
18
+ *
19
+ * export default DeployConfig({
20
+ * targets: {
21
+ * production: { url: "https://example.com" },
22
+ * staging: { url: "https://staging.example.com" },
23
+ * },
24
+ * });
25
+ * ```
26
+ */
27
+
28
+ /**
29
+ * The release steps, in order. Each names a `zt` command; one that is not
30
+ * registered is skipped, so an app without Inertia simply has no `inertia:build`.
31
+ *
32
+ * Ordered so that everything able to refuse runs before anything that mutates:
33
+ * the build cannot corrupt a database and the migration cannot half-apply if the
34
+ * preflight has already stopped the deploy.
35
+ */
36
+ export const DEFAULT_DEPLOY_STEPS: readonly string[] = ["assets:build", "inertia:build", "migrate"];
37
+
38
+ /** One environment this app is released to. */
39
+ export interface DeployTarget {
40
+ /**
41
+ * The app's public URL in this environment. Used by `--probe` to run a real
42
+ * WebSocket handshake against the deployed site, the way a browser would.
43
+ */
44
+ url?: string;
45
+ /**
46
+ * Override the release steps for this target. Defaults to
47
+ * {@link DEFAULT_DEPLOY_STEPS}. Names a `zt` command per entry.
48
+ */
49
+ steps?: readonly string[];
50
+ }
51
+
52
+ /** The `deploy` config namespace. */
53
+ export interface DeployConfigShape {
54
+ targets: Record<string, DeployTarget>;
55
+ }
56
+
57
+ /** The targets assumed when an app ships no `config/deploy.ts`. */
58
+ export const DEFAULT_DEPLOY_TARGETS: Record<string, DeployTarget> = {
59
+ production: {},
60
+ staging: {},
61
+ };
62
+
63
+ /**
64
+ * Build the `deploy` config block, filling in defaults.
65
+ *
66
+ * @param options - The targets this app releases to.
67
+ */
68
+ export function DeployConfig(options: Partial<DeployConfigShape> = {}): DeployConfigShape {
69
+ const targets = options.targets ?? DEFAULT_DEPLOY_TARGETS;
70
+ return { targets };
71
+ }
@@ -36,6 +36,8 @@ export type {
36
36
  AppAssetsConfig,
37
37
  AssetLoaderKind,
38
38
  } from "./AppConfig.ts";
39
+ export { DeployConfig, DEFAULT_DEPLOY_STEPS, DEFAULT_DEPLOY_TARGETS } from "./DeployConfig.ts";
40
+ export type { DeployConfigShape, DeployTarget } from "./DeployConfig.ts";
39
41
  export type { ConfigRegistry, ConfigPath, ConfigValue } from "./registry.ts";
40
42
  export { ConfigValidationError } from "./validation.ts";
41
43
  export type {
@@ -21,6 +21,7 @@ import type { HealthConfigShape } from "../health/Health.ts";
21
21
  */
22
22
  export interface ConfigRegistry {
23
23
  app: AppConfigShape;
24
+ deploy: import("./DeployConfig.ts").DeployConfigShape;
24
25
  health: HealthConfigShape;
25
26
  lock: import("../lock/config.ts").LockConfigShape;
26
27
  logging: import("../logger/config.ts").LoggingConfigShape;