@zerotal/core 1.9.0 → 1.11.0

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 (42) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/api-surface.md +151 -81
  3. package/package.json +2 -1
  4. package/src/application/Application.ts +4 -4
  5. package/src/build/codemod.ts +1 -1
  6. package/src/command/CommandRunner.ts +35 -4
  7. package/src/command/builtin/AssetsPruneCommand.ts +138 -0
  8. package/src/command/builtin/DeployCommand.ts +19 -0
  9. package/src/command/builtin/index.ts +1 -0
  10. package/src/command/startZerotal.ts +51 -8
  11. package/src/config/AppConfig.ts +10 -10
  12. package/src/conventions/ConventionLoader.ts +39 -2
  13. package/src/dev/BuildOutput.ts +58 -6
  14. package/src/dev/CssPlugins.ts +2 -2
  15. package/src/dev/DevDeck.ts +3 -3
  16. package/src/dev/DevProcess.ts +2 -2
  17. package/src/dev/DevSupervisor.ts +5 -5
  18. package/src/dev/index.ts +6 -1
  19. package/src/dev/startDevMode.ts +1 -1
  20. package/src/doctor/throttleIdentity.ts +6 -2
  21. package/src/health/Health.ts +5 -5
  22. package/src/helpers/index.ts +2 -1
  23. package/src/helpers/markdown.ts +7 -0
  24. package/src/http/UploadedFile.ts +2 -2
  25. package/src/index.ts +2 -1
  26. package/src/lock/LockManager.ts +4 -4
  27. package/src/logger/LoggerMiddleware.ts +1 -1
  28. package/src/logger/types.ts +4 -4
  29. package/src/middleware/CorsMiddleware.ts +6 -6
  30. package/src/middleware/RateLimiter.ts +105 -22
  31. package/src/middleware/SecureHeadersMiddleware.ts +8 -8
  32. package/src/middleware/ThrottleMiddleware.ts +57 -4
  33. package/src/middleware/WebhookMiddleware.ts +7 -7
  34. package/src/router/RouteHandler.ts +2 -2
  35. package/src/router/Router.ts +5 -5
  36. package/src/security/redactGraph.ts +1 -1
  37. package/src/shared/format.ts +8 -8
  38. package/src/storage/StorageFilesMiddleware.ts +2 -2
  39. package/src/storage/types.ts +10 -10
  40. package/src/support/cookie.ts +6 -6
  41. package/src/support/deepMerge.ts +49 -0
  42. package/src/support/runtime.ts +169 -4
@@ -363,6 +363,7 @@ export class CommandRunner {
363
363
  MakeProviderCommand,
364
364
  CssBuildCommand,
365
365
  AssetsBuildCommand,
366
+ AssetsPruneCommand,
366
367
  LintPackagesCommand,
367
368
  MakePackageCommand,
368
369
  } = await import("./builtin/index.ts");
@@ -414,6 +415,7 @@ export class CommandRunner {
414
415
  TestCommand,
415
416
  CssBuildCommand,
416
417
  AssetsBuildCommand,
418
+ AssetsPruneCommand,
417
419
  LintPackagesCommand,
418
420
  MakePackageCommand,
419
421
  ]);
@@ -531,8 +533,21 @@ export class CommandRunner {
531
533
  const startedAt = performance.now();
532
534
  try {
533
535
  await instance.run();
534
- FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, 0, true));
535
- process.exit(0);
536
+
537
+ // Honour `process.exitCode` if the command set one.
538
+ //
539
+ // This used to be an unconditional `process.exit(0)`, so a command could
540
+ // only fail by throwing — and `process.exitCode = 1` is the idiomatic way
541
+ // to fail a Bun/Node CLI without an exception, which is what most people
542
+ // reach for. It was silently discarded. An app wrote a `release:check`
543
+ // gate that printed six blockers, set the code, and exited 0; the deploy
544
+ // script's `if ! …` read success and would have restarted a broken
545
+ // production deployment. A gate that cannot fail is not a gate, and that
546
+ // one failed *open*, which is the worst direction available.
547
+ const code = process.exitCode ?? 0;
548
+ const ok = Number(code) === 0;
549
+ FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, Number(code), ok));
550
+ process.exit(Number(code));
536
551
  } catch (error) {
537
552
  FrameworkEvents.emit(
538
553
  new CommandRan(name, performance.now() - startedAt, 1, false, (error as Error).message),
@@ -592,16 +607,32 @@ export class CommandRunner {
592
607
 
593
608
  const name = (Cmd as { commandName?: string }).commandName ?? commandName;
594
609
  const startedAt = performance.now();
610
+ // `process.exitCode` is process-global, and this runs the command *in this
611
+ // process* — so it has to be cleared before and restored after, or a command
612
+ // that sets it poisons the runner and everything called after it. Reading it
613
+ // at all is the point: `zt deploy` gates on these return codes, and a
614
+ // `release:check` that printed its blockers and set the code used to come back
615
+ // as success. The gate failed open, which is the one direction a gate must not.
616
+ //
617
+ // `0`, not `undefined`, for both the clear and the restore: in Bun,
618
+ // `process.exitCode = undefined` is a no-op that leaves the previous value in
619
+ // place, so clearing with it does nothing and one blocked command marks every
620
+ // command after it as failed.
621
+ const outerExitCode = process.exitCode ?? 0;
622
+ process.exitCode = 0;
595
623
  try {
596
624
  await instance.run();
597
- FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, 0, true));
598
- return { code: 0, output: writer.flush() };
625
+ const code = Number(process.exitCode ?? 0);
626
+ FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, code, code === 0));
627
+ return { code, output: writer.flush() };
599
628
  } catch (error) {
600
629
  FrameworkEvents.emit(
601
630
  new CommandRan(name, performance.now() - startedAt, 1, false, (error as Error).message),
602
631
  );
603
632
  const errorLine = `\x1b[31m✖ ${(error as Error).message}\x1b[0m\n`;
604
633
  return { code: 1, output: writer.flush() + errorLine };
634
+ } finally {
635
+ process.exitCode = outerExitCode;
605
636
  }
606
637
  }
607
638
 
@@ -0,0 +1,138 @@
1
+ import { readdir, unlink } from "node:fs/promises";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { Command } from "../Command.ts";
4
+ import type { Application } from "../../application/Application.ts";
5
+ import type { ConfigManager } from "../../config/ConfigManager.ts";
6
+ import { _recordedBuildOutput } from "../../dev/BuildOutput.ts";
7
+ import type { AssetBuildConfig } from "../../dev/CssPlugins.ts";
8
+
9
+ /** Bundler-generated code-split chunk, e.g. `chunk-2502z4dn.js` (+ `.map`). */
10
+ const CHUNK_NAME = /^chunk-[a-z0-9]+\.js(\.map)?$/i;
11
+
12
+ /**
13
+ * `bun zt assets:prune` — remove the chunks a previous release left behind.
14
+ *
15
+ * `assets:build --clean` cleans the directory it *builds into*, which is the build
16
+ * machine. It does nothing for the common release shape: build here, tar the output,
17
+ * extract it over `public/` there. Extracting a tarball merges — nothing removes a
18
+ * file the new release does not contain — so every deploy adds another set of
19
+ * content-hashed chunks to a directory that only ever grows. One app reached 225
20
+ * chunk files on disk for the 49 its entry point references.
21
+ *
22
+ * Nothing breaks while that happens, which is why it runs for months unnoticed. The
23
+ * accumulation even *masks* a stale-cache bug, because the pruned chunk an old
24
+ * browser asks for is usually still lying there. But it grows without bound on a box
25
+ * with a disk quota, and a release process that cannot say which files belong to the
26
+ * current release is one that cannot clean up after itself.
27
+ *
28
+ * This is that answer. `assets:build` records what it wrote; ship `.zerotal/` with
29
+ * the release and this removes what the record does not claim.
30
+ *
31
+ * ## What it will and will not delete
32
+ *
33
+ * Only two things qualify: a file an *earlier* recorded build wrote that the current
34
+ * one did not, and a file named the way the bundler names a code-split chunk.
35
+ * Everything else is left alone, because an output directory is usually `public/`,
36
+ * where a blanket sweep takes the app's images and favicon with it.
37
+ *
38
+ * @example
39
+ * ```bash
40
+ * # on the server, after extracting the release and before the restart
41
+ * bun zt assets:prune --dry-run # list what would go
42
+ * bun zt assets:prune
43
+ * ```
44
+ *
45
+ * @category Build & assets
46
+ */
47
+ export class AssetsPruneCommand extends Command {
48
+ static override commandName = "assets:prune";
49
+ static override description = "Remove asset files left behind by an earlier release";
50
+ static override needsApp = true;
51
+
52
+ static override flags = [
53
+ {
54
+ name: "dir",
55
+ type: "string" as const,
56
+ description: "Output directory to prune (defaults to the configured `app.assets.outDir`)",
57
+ },
58
+ {
59
+ name: "dry-run",
60
+ type: "boolean" as const,
61
+ description: "List what would be removed, and remove none of it",
62
+ default: false,
63
+ },
64
+ ];
65
+
66
+ async run(): Promise<void> {
67
+ const cwd = process.cwd();
68
+ const dir = (this.flags["dir"] as string | undefined) ?? this._configuredOutDir();
69
+ if (!dir) {
70
+ this.warn(
71
+ "No output directory to prune. Pass --dir, or configure app.assets.outDir in config/app.ts.",
72
+ );
73
+ return;
74
+ }
75
+
76
+ const root = resolve(cwd, dir);
77
+ const recorded = new Set(await _recordedBuildOutput(root, cwd));
78
+ if (recorded.size === 0) {
79
+ // Without a record, "what belongs to this release" has no answer, and the
80
+ // by-name sweep alone would be guessing about a directory it does not own.
81
+ this.warn(
82
+ `No build record for ${dir}. Run \`zt assets:build\` (and ship .zerotal/ with the ` +
83
+ `release) so a prune knows which files are the current one's.`,
84
+ );
85
+ return;
86
+ }
87
+
88
+ const stale: string[] = [];
89
+ for (const entry of await _listEntries(root)) {
90
+ if (recorded.has(entry)) continue;
91
+ if (!CHUNK_NAME.test(entry.split("/").at(-1) ?? "")) continue;
92
+ stale.push(entry);
93
+ }
94
+
95
+ if (stale.length === 0) {
96
+ this.info(`${dir}: nothing to prune — ${recorded.size} file(s) all accounted for.`);
97
+ return;
98
+ }
99
+
100
+ if (this.flags["dry-run"] === true) {
101
+ this.info(`${dir}: ${stale.length} file(s) would be removed:`);
102
+ for (const path of stale.sort()) this.line(` ${path}`);
103
+ return;
104
+ }
105
+
106
+ let removed = 0;
107
+ for (const path of stale) {
108
+ try {
109
+ await unlink(join(root, path));
110
+ removed++;
111
+ } catch {
112
+ // Already gone, or held open. The next prune tries again, and a file we
113
+ // could not delete is not worth failing a release over.
114
+ }
115
+ }
116
+ this.info(`${dir}: removed ${removed} stale file(s), kept ${recorded.size}.`);
117
+ }
118
+
119
+ /** The configured asset output directory, or undefined when the app declares none. */
120
+ private _configuredOutDir(): string | undefined {
121
+ try {
122
+ const config = (this.app as Application).container.makeSync("config") as ConfigManager;
123
+ return (config.get("app.assets") as AssetBuildConfig | undefined)?.outDir;
124
+ } catch {
125
+ return undefined;
126
+ }
127
+ }
128
+ }
129
+
130
+ /** Every entry under `root`, relative and slash-normalised. Empty when unreadable. */
131
+ async function _listEntries(root: string): Promise<string[]> {
132
+ try {
133
+ const entries = await readdir(root, { recursive: true });
134
+ return entries.map((entry) => relative(".", entry).split("\\").join("/"));
135
+ } catch {
136
+ return [];
137
+ }
138
+ }
@@ -51,6 +51,12 @@ export abstract class DeployCommand extends Command {
51
51
  description: "Print the steps that would run, and run none of them",
52
52
  default: false,
53
53
  },
54
+ {
55
+ name: "check",
56
+ type: "boolean" as const,
57
+ description: "Run the preflight gate and stop — build nothing, migrate nothing",
58
+ default: false,
59
+ },
54
60
  {
55
61
  name: "skip-migrations",
56
62
  type: "boolean" as const,
@@ -77,6 +83,19 @@ export abstract class DeployCommand extends Command {
77
83
  const steps = this._steps(app, target);
78
84
  if (dryRun) return this._printPlan(app, target, steps);
79
85
 
86
+ // `--check` is the gate on its own, for the moment in a release script where the
87
+ // new code is on disk and the service has not restarted yet. Everything that can
88
+ // refuse has already run by the end of preflight and none of it mutates, so
89
+ // stopping here is a complete answer: exit 0 and restart, exit 1 and keep serving
90
+ // the previous release. A workspace that has lost its banking details or had its
91
+ // mail driver knocked back to `log` never goes live broken.
92
+ if (this.flags["check"] === true) {
93
+ await this._preflight(app, target);
94
+ this.newLine();
95
+ this.info(`${target} passed preflight. Nothing was built, migrated or restarted.`);
96
+ return;
97
+ }
98
+
80
99
  await this._preflight(app, target);
81
100
  await this._runSteps(app, steps);
82
101
  await this._verify(app);
@@ -55,5 +55,6 @@ export { DeployCommand, makeDeployCommand } from "./DeployCommand.ts";
55
55
  export { MakeProviderCommand } from "./MakeProviderCommand.ts";
56
56
  export { CssBuildCommand } from "./CssBuildCommand.ts";
57
57
  export { AssetsBuildCommand } from "./AssetsBuildCommand.ts";
58
+ export { AssetsPruneCommand } from "./AssetsPruneCommand.ts";
58
59
  export { LintPackagesCommand } from "./LintPackagesCommand.ts";
59
60
  export { MakePackageCommand } from "./MakePackageCommand.ts";
@@ -8,17 +8,20 @@ import { CommandRunner } from "./CommandRunner.ts";
8
8
  import { configLoader } from "../config/ConfigLoader.ts";
9
9
  import { setAppEnv } from "../helpers/index.ts";
10
10
  import {
11
+ runtimeBelowFloor,
12
+ runtimeBelowFloorMessage,
11
13
  runtimeMismatch,
12
14
  runtimeMismatchAllowed,
13
15
  runtimeMismatchMessage,
14
16
  } from "../support/runtime.ts";
17
+ import type { RuntimeMismatch } from "../support/runtime.ts";
15
18
  import { RuntimeMismatchError } from "../errors/RuntimeMismatchError.ts";
16
19
  import type { Application } from "../application/Application.ts";
17
20
 
18
21
  /** Options for {@link startZerotal}. */
19
22
  export interface StartZerotalOptions {
20
23
  /** Directory to load config from, resolved against the cwd. Default: `./config`. */
21
- configDir?: string;
24
+ configDir?: string | undefined;
22
25
  }
23
26
 
24
27
  /**
@@ -66,21 +69,61 @@ export async function startZerotal(
66
69
  }
67
70
 
68
71
  /**
69
- * Refuse to run when the project has two Bun runtimes in it.
72
+ * Refuse to run on a Bun the project does not agree with.
73
+ *
74
+ * Two disagreements, both about the same thing — which binary the assertions
75
+ * downstream of here are assertions *about*:
76
+ *
77
+ * - The runtime is below the `engines.bun` the project declares. Every generated
78
+ * app writes that floor and nothing had ever enforced it.
79
+ * - The project installs a different Bun in `node_modules` than the one running.
80
+ * Most projects do not install Bun as a package, so most never see this one.
70
81
  *
71
82
  * At the top of the entry point rather than in a check somebody remembers to run,
72
- * because the value is in it being unmissable: the failure it prevents is a suite
73
- * that passes about the wrong binary, and nothing downstream of here can notice
74
- * that. A project with no `node_modules/bun` — most of them — never sees this.
83
+ * because the value is in it being unmissable: the failure both prevent is a suite
84
+ * that passes about the wrong binary, and nothing downstream of here can notice that.
75
85
  *
76
- * @throws When the running Bun and the installed one disagree, unless
77
- * `ZT_ALLOW_RUNTIME_MISMATCH` is set, which downgrades it to a warning on stderr.
86
+ * @throws When either check fails, unless `ZT_ALLOW_RUNTIME_MISMATCH` is set, which
87
+ * downgrades it to a warning on stderr.
78
88
  * @internal
79
89
  */
80
90
  function assertOneRuntime(): void {
91
+ const floor = runtimeBelowFloor();
92
+ if (floor) {
93
+ _refuse(runtimeBelowFloorMessage(floor), {
94
+ running: floor.running,
95
+ installed: floor.required,
96
+ manifest: floor.manifest,
97
+ });
98
+ }
99
+
81
100
  const mismatch = runtimeMismatch();
82
101
  if (!mismatch) return;
83
- const message = runtimeMismatchMessage(mismatch);
102
+
103
+ // A runtime the project never asked for is a warning, not a refusal. It arrives
104
+ // as a transitive peer — `bun-plugin-tailwind` declares `bun` as a required peer
105
+ // and Bun auto-installs it — and nothing executes the stray copy, so "two
106
+ // runtimes in play" is not what is happening. Refusing over it crash-looped an
107
+ // app behind a 502 twice, and the remedies the refusal prints are the wrong ones
108
+ // for this cause. Loud, because the version skew is still real if anything ever
109
+ // *does* run it.
110
+ if (mismatch.chosen === false) {
111
+ console.warn(`
112
+ ⚠ ${runtimeMismatchMessage(mismatch)}
113
+ `);
114
+ return;
115
+ }
116
+
117
+ _refuse(runtimeMismatchMessage(mismatch), mismatch);
118
+ }
119
+
120
+ /**
121
+ * Stop, or warn and continue when the escape hatch is set.
122
+ *
123
+ * @param message - The full explanation, already formatted.
124
+ * @param mismatch - The two versions, carried on the error for a harness to read.
125
+ */
126
+ function _refuse(message: string, mismatch: RuntimeMismatch): void {
84
127
  if (runtimeMismatchAllowed()) {
85
128
  console.warn(`\n⚠ ${message}\n`);
86
129
  return;
@@ -63,17 +63,17 @@ export interface AppSecureHeadersConfig {
63
63
  * Enable HSTS (and the `Secure` flag on the XSRF-TOKEN cookie). Defaults to
64
64
  * `false` — turn it on for any deployment served over HTTPS.
65
65
  */
66
- secure?: boolean;
66
+ secure?: boolean | undefined;
67
67
  /** `Strict-Transport-Security` max-age in seconds. Defaults to one year. */
68
- hstsMaxAge?: number;
68
+ hstsMaxAge?: number | undefined;
69
69
  /** Include `includeSubDomains` in the HSTS header. Defaults to `true`. */
70
- hstsIncludeSubDomains?: boolean;
70
+ hstsIncludeSubDomains?: boolean | undefined;
71
71
  /** Set the HSTS `preload` directive. Only with the domain registered. Defaults to `false`. */
72
- hstsPreload?: boolean;
72
+ hstsPreload?: boolean | undefined;
73
73
  /** `Content-Security-Policy` value. Omitted by default. */
74
- contentSecurityPolicy?: string;
74
+ contentSecurityPolicy?: string | undefined;
75
75
  /** `Referrer-Policy` value. Defaults to `strict-origin-when-cross-origin`. */
76
- referrerPolicy?: string;
76
+ referrerPolicy?: string | undefined;
77
77
  }
78
78
 
79
79
  /**
@@ -106,7 +106,7 @@ export interface AppAssetsConfig {
106
106
  * // config/app.ts
107
107
  * assets: { entrypoint: 'resources/css/app.css', loader: { '.woff2': 'file', '.woff': 'file' } }
108
108
  */
109
- loader?: Record<string, AssetLoaderKind>;
109
+ loader?: Record<string, AssetLoaderKind> | undefined;
110
110
  }
111
111
 
112
112
  /**
@@ -176,7 +176,7 @@ export interface AppConfigShape {
176
176
  /** Enable HTTP/3 over QUIC. Requires a valid tls config. Default: false */
177
177
  http3: boolean;
178
178
  /** TLS certificate + key for HTTPS. Enables HTTPS when set. */
179
- tls?: AppTlsConfig;
179
+ tls?: AppTlsConfig | undefined;
180
180
  /**
181
181
  * Largest request body the server will accept, in bytes. Default: 8 MiB.
182
182
  *
@@ -237,7 +237,7 @@ export interface AppConfigShape {
237
237
 
238
238
  // ── Assets ────────────────────────────────────────────────────────────────
239
239
  /** Front-end asset bundling, built on `serve`. Omitted means no asset build. */
240
- assets?: AppAssetsConfig;
240
+ assets?: AppAssetsConfig | undefined;
241
241
 
242
242
  // ── Dev mode ──────────────────────────────────────────────────────────────
243
243
  /**
@@ -256,7 +256,7 @@ export interface AppConfigShape {
256
256
  * }
257
257
  * ```
258
258
  */
259
- dev?: DevConfigShape;
259
+ dev?: DevConfigShape | undefined;
260
260
 
261
261
  // ── Conventions ───────────────────────────────────────────────────────────
262
262
  /** Auto-discovery settings (enabled + per-concern paths). */
@@ -45,7 +45,7 @@ export interface RunConventionsOptions {
45
45
  root: string;
46
46
  env: AppEnvironment;
47
47
  /** Per-concern directory overrides (config `discovery.paths`). */
48
- paths?: Record<string, string>;
48
+ paths?: Record<string, string> | undefined;
49
49
  ctx: ConcernContext;
50
50
  }
51
51
 
@@ -105,7 +105,10 @@ export async function runConventions(
105
105
  ): Promise<void> {
106
106
  const orderedConcerns = [...concerns].sort((first, second) => first.order - second.order);
107
107
  for (const concern of orderedConcerns) {
108
- if (concern.envs && !concern.envs.includes(options.env)) continue;
108
+ if (concern.envs && !concern.envs.includes(options.env)) {
109
+ await _announceSkipped(concern, options);
110
+ continue;
111
+ }
109
112
 
110
113
  if (concern.dir && concern.register) {
111
114
  const relativeDirectory = options.paths?.[concern.name] ?? concern.dir;
@@ -130,3 +133,37 @@ export async function runConventions(
130
133
  }
131
134
  }
132
135
  }
136
+
137
+ /**
138
+ * Say once, at boot, that this process is not running a convention the app has
139
+ * written files for.
140
+ *
141
+ * An env-restricted concern is skipped by *not looking*, which is correct and
142
+ * completely silent: from a web process's point of view nothing is wrong, because
143
+ * from a web process's point of view nothing exists. An app ran for weeks in
144
+ * production with `app/schedules` full and no worker process — no inventory hold
145
+ * released, no payment reminder sent, nothing logged, and it was found by going
146
+ * looking rather than by being told.
147
+ *
148
+ * Only announced when the directory actually holds files: "skipping 0 schedules" on
149
+ * every boot of every app is noise, and noise is how the line that matters gets
150
+ * scrolled past.
151
+ */
152
+ async function _announceSkipped(
153
+ concern: ConcernDescriptor,
154
+ options: RunConventionsOptions,
155
+ ): Promise<void> {
156
+ if (!concern.dir || !concern.register) return;
157
+
158
+ const relativeDirectory = options.paths?.[concern.name] ?? concern.dir;
159
+ const files = (await _scanDir(`${options.root}/${relativeDirectory}`)).filter(
160
+ (file) => !_shouldSkip(file),
161
+ );
162
+ if (files.length === 0) return;
163
+
164
+ frameworkLog("app").info(
165
+ `Skipping ${files.length} file(s) in ${relativeDirectory} — the "${concern.name}" convention ` +
166
+ `does not run in env=${options.env} (it runs in: ${(concern.envs ?? []).join(", ")}).`,
167
+ { convention: concern.name, env: options.env, files: files.length },
168
+ );
169
+ }
@@ -98,15 +98,67 @@ function _relative(root: string, path: string): string {
98
98
  }
99
99
 
100
100
  /**
101
- * The manifest lives outside `outdir` — writing it inside would put a file the
102
- * app then serves (and registers a route for) into the public directory.
101
+ * Where a directory's manifest lives.
102
+ *
103
+ * Outside `outdir` — writing it inside would put a file the app then serves (and
104
+ * registers a route for) into the public directory.
105
+ *
106
+ * Named from the output directory's path **relative to the project**, not its
107
+ * absolute one. The hash used to be `Bun.hash(absolutePath)`, which made the
108
+ * manifest belong to a machine rather than to a release: the same project built at
109
+ * `/home/me/app` and unpacked at `/opt/app` produced two different filenames, so a
110
+ * manifest that travelled with a deploy matched nothing at the other end and the
111
+ * server could not say which files belonged to the current release. It also meant
112
+ * moving a checkout silently orphaned the record and stopped pruning.
113
+ *
114
+ * @internal
103
115
  */
104
- function _manifestPath(root: string): string {
105
- const slug = _relative(resolve(process.cwd()), root).replace(/[^a-z0-9]+/gi, "-") || "out";
116
+ export function _buildManifestPath(root: string, cwd: string = process.cwd()): string {
117
+ const relativeRoot = _relative(resolve(cwd), resolve(root));
118
+ const slug = relativeRoot.replace(/[^a-z0-9]+/gi, "-") || "out";
106
119
  // The slug alone can collide (two different roots normalising to the same
107
120
  // name); the hash makes each directory's manifest its own.
108
- const hash = Bun.hash(root).toString(36);
109
- return join(process.cwd(), MANIFEST_DIR, `${slug}-${hash}.json`);
121
+ const hash = Bun.hash(relativeRoot).toString(36);
122
+ return join(cwd, MANIFEST_DIR, `${slug}-${hash}.json`);
123
+ }
124
+
125
+ /** Back-compat alias used throughout this module. */
126
+ function _manifestPath(root: string): string {
127
+ return _buildManifestPath(root);
128
+ }
129
+
130
+ /**
131
+ * Every file the recorded builds wrote to `outdir`, relative and slash-normalised.
132
+ *
133
+ * The answer to "which assets belong to this release" — the question a deploy has
134
+ * to answer before it can remove anything, and the one that has no answer at all
135
+ * from inside a directory somebody extracted a tarball over.
136
+ *
137
+ * @param outdir - The output directory to describe.
138
+ * @param cwd - Project root the manifest is stored under. Defaults to the working directory.
139
+ * @returns The recorded files, or `[]` when no manifest exists.
140
+ *
141
+ * @internal
142
+ */
143
+ export async function _recordedBuildOutput(
144
+ outdir: string,
145
+ cwd: string = process.cwd(),
146
+ ): Promise<string[]> {
147
+ try {
148
+ const contents = (await Bun.file(_buildManifestPath(outdir, cwd)).json()) as unknown;
149
+ if (Array.isArray(contents)) {
150
+ return contents.filter((entry): entry is string => typeof entry === "string").sort();
151
+ }
152
+ if (!contents || typeof contents !== "object") return [];
153
+ const files = new Set<string>();
154
+ for (const list of Object.values(contents as Record<string, unknown>)) {
155
+ if (!Array.isArray(list)) continue;
156
+ for (const entry of list) if (typeof entry === "string") files.add(entry);
157
+ }
158
+ return [...files].sort();
159
+ } catch {
160
+ return [];
161
+ }
110
162
  }
111
163
 
112
164
  /**
@@ -210,13 +210,13 @@ export interface AssetBuildConfig {
210
210
  prefix: string;
211
211
  minify: boolean;
212
212
  /** Per-extension loader overrides (e.g. `{ ".woff2": "file" }`). See AppAssetsConfig. */
213
- loader?: Record<string, string>;
213
+ loader?: Record<string, string> | undefined;
214
214
  /**
215
215
  * Delete everything in `outDir` this build did not write, rather than only what
216
216
  * the last build on this machine recorded. See {@link cleanBuildOutput} — it
217
217
  * refuses a directory that holds more than build output.
218
218
  */
219
- clean?: boolean;
219
+ clean?: boolean | undefined;
220
220
  }
221
221
 
222
222
  /**
@@ -59,10 +59,10 @@ export interface DeckOptions {
59
59
  /** The user asked to quit — bound to `q`. */
60
60
  onQuit: () => void;
61
61
  /** Force a renderer. Defaults to tabs on a TTY, stream otherwise. */
62
- mode?: "tabs" | "stream";
62
+ mode?: "tabs" | "stream" | undefined;
63
63
  /** Overridable for tests. Defaults to `process.stdout`/`process.stdin`. */
64
- stdout?: DeckStdout;
65
- stdin?: DeckStdin;
64
+ stdout?: DeckStdout | undefined;
65
+ stdin?: DeckStdin | undefined;
66
66
  }
67
67
 
68
68
  /** The bits of `process.stdout` the deck reads. */
@@ -110,9 +110,9 @@ export interface ResolvedDevProcess {
110
110
  */
111
111
  export interface DevConfigShape {
112
112
  /** App-level processes, registered after every provider's. */
113
- processes?: DevProcessDefinition[];
113
+ processes?: DevProcessDefinition[] | undefined;
114
114
  /** Names to drop, whoever registered them. */
115
- disable?: string[];
115
+ disable?: string[] | undefined;
116
116
  }
117
117
 
118
118
  /** The bits of `Application` this module needs — kept structural to avoid a cycle. */
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * Runs the dev processes and keeps them running.
3
3
  *
4
4
  * The supervisor owns lifetimes, not presentation: it spawns, watches, restarts,
@@ -72,17 +72,17 @@ export interface DevSupervisorHooks {
72
72
  /** @internal */
73
73
  export interface DevSupervisorOptions extends DevSupervisorHooks {
74
74
  cwd: string;
75
- env?: Record<string, string | undefined>;
75
+ env?: Record<string, string | undefined> | undefined;
76
76
  /** Defaults to `Bun.spawn` with output piped and **stdin ignored**. */
77
- spawn?: DevSpawnFn;
77
+ spawn?: DevSpawnFn | undefined;
78
78
  /**
79
79
  * Backoff before each retry, in ms. Overridable for the same reason `spawn`
80
80
  * is: a test of the restart policy should assert the policy, not spend three
81
81
  * seconds proving that `setTimeout` works.
82
82
  */
83
- backoffMs?: number[];
83
+ backoffMs?: number[] | undefined;
84
84
  /** How long a run must last to be considered healthy. See {@link HEALTHY_AFTER_MS}. */
85
- healthyAfterMs?: number;
85
+ healthyAfterMs?: number | undefined;
86
86
  }
87
87
 
88
88
  /** Give up after this many consecutive failed starts. */
package/src/dev/index.ts CHANGED
@@ -33,7 +33,12 @@ export { DEV_RELOAD_CLIENT } from "./reloadClient.ts";
33
33
  export { browserEnvDefines } from "./buildEnv.ts";
34
34
  export { detectCssPlugins, buildCssBundle, buildJsBundle } from "./CssPlugins.ts";
35
35
  export type { AssetBuildConfig } from "./CssPlugins.ts";
36
- export { pruneBuildOutput, cleanBuildOutput } from "./BuildOutput.ts";
36
+ export {
37
+ pruneBuildOutput,
38
+ cleanBuildOutput,
39
+ _recordedBuildOutput,
40
+ _buildManifestPath,
41
+ } from "./BuildOutput.ts";
37
42
  // Lets `serve` and any view provider agree on whether to build at boot, so a hardened
38
43
  // production unit with a read-only output directory logs a line instead of restart-looping.
39
44
  export { bootBuildDecision, isWritableDir } from "./bootBuild.ts";
@@ -31,7 +31,7 @@ export interface StartDevModeOptions {
31
31
  * and nothing to serve it to — `--only=queue` is supervision and a deck, and
32
32
  * the orchestrator that would call this never starts.
33
33
  */
34
- build?: BuildHookFn;
34
+ build?: BuildHookFn | undefined;
35
35
  /** Everything the providers and the app contributed, already filtered. */
36
36
  processes: ResolvedDevProcess[];
37
37
  writer: OutputWriter;
@@ -24,7 +24,7 @@
24
24
  * @module
25
25
  */
26
26
  import type { Application } from "../application/Application.ts";
27
- import { ThrottleMiddleware } from "../middleware/ThrottleMiddleware.ts";
27
+ import { ThrottleMiddleware, _isIpDerived } from "../middleware/ThrottleMiddleware.ts";
28
28
  import { Router } from "../router/Router.ts";
29
29
 
30
30
  /** A registered throttle and how it decides who is who. */
@@ -64,7 +64,11 @@ function readIdentity(cls: unknown, where: string): ThrottleIdentity | null {
64
64
  name: (cls as { name?: string }).name || "ThrottleMiddleware",
65
65
  where,
66
66
  trustsProxies: typeof options.trustedProxies === "number" && options.trustedProxies > 0,
67
- customKey: typeof options.keyResolver === "function",
67
+ // A resolver that keys on the client address is not an exemption — it is
68
+ // exactly the case this check exists for. `RateLimiter`'s `.byIp()`,
69
+ // `.byUser()` and `.byApiKey()` all mark themselves, so the named-limiter API
70
+ // stops slipping past the audit that covers the middleware it builds on.
71
+ customKey: typeof options.keyResolver === "function" && !_isIpDerived(options.keyResolver),
68
72
  };
69
73
  } catch {
70
74
  // A middleware that will not construct is a different problem, and not one