@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
@@ -16,9 +16,9 @@ interface Mount {
16
16
 
17
17
  export interface StorageFilesOptions {
18
18
  /** Disks to serve, in match order. Built from config by {@link StorageProvider}. */
19
- mounts?: Mount[];
19
+ mounts?: Mount[] | undefined;
20
20
  /** The manager the files are read through. */
21
- storage?: StorageManager;
21
+ storage?: StorageManager | undefined;
22
22
  }
23
23
 
24
24
  /**
@@ -1,6 +1,6 @@
1
1
  export interface PutOptions {
2
- contentType?: string;
3
- visibility?: "public" | "private";
2
+ contentType?: string | undefined;
3
+ visibility?: "public" | "private" | undefined;
4
4
  }
5
5
 
6
6
  export interface StorageDriver {
@@ -61,14 +61,14 @@ export interface DiskServeConfig {
61
61
  * only someone holding a link you signed can read a file, and only until it
62
62
  * expires.
63
63
  */
64
- signed?: boolean;
64
+ signed?: boolean | undefined;
65
65
  /**
66
66
  * How long a signed link stays valid, in seconds. Only meaningful with
67
67
  * `signed`. Defaults to 900 (15 minutes).
68
68
  */
69
- expiresIn?: number;
69
+ expiresIn?: number | undefined;
70
70
  /** Extra response headers, e.g. `Cache-Control`. */
71
- headers?: Record<string, string>;
71
+ headers?: Record<string, string> | undefined;
72
72
  }
73
73
 
74
74
  export interface LocalDiskConfig {
@@ -76,9 +76,9 @@ export interface LocalDiskConfig {
76
76
  /** Absolute or relative root directory for file storage. */
77
77
  root: string;
78
78
  /** Optional base URL for url() — e.g. '/storage' or 'https://cdn.example.com'. */
79
- url?: string;
79
+ url?: string | undefined;
80
80
  /** Expose this disk over HTTP. Omit to keep it unreachable. */
81
- serve?: DiskServeConfig;
81
+ serve?: DiskServeConfig | undefined;
82
82
  }
83
83
 
84
84
  export interface S3DiskConfig {
@@ -88,11 +88,11 @@ export interface S3DiskConfig {
88
88
  region: string;
89
89
  bucket: string;
90
90
  /** Override endpoint for R2, MinIO, etc. E.g. 'https://<account>.r2.cloudflarestorage.com' */
91
- endpoint?: string;
91
+ endpoint?: string | undefined;
92
92
  /** Custom public URL base (e.g. CDN or R2 public domain). */
93
- url?: string;
93
+ url?: string | undefined;
94
94
  /** Expose this disk over HTTP, proxied through the app. Omit to keep it unreachable. */
95
- serve?: DiskServeConfig;
95
+ serve?: DiskServeConfig | undefined;
96
96
  }
97
97
 
98
98
  export type DiskConfig = LocalDiskConfig | S3DiskConfig;
@@ -16,17 +16,17 @@ export interface CookieOptions {
16
16
  /** Cookie value (caller is responsible for encoding if needed). */
17
17
  value: string;
18
18
  /** `Path` attribute. Defaults to `/`. */
19
- path?: string;
19
+ path?: string | undefined;
20
20
  /** `Max-Age` in seconds. Omit for a session cookie. `0` clears the cookie. */
21
- maxAge?: number;
21
+ maxAge?: number | undefined;
22
22
  /** `SameSite` attribute. Defaults to `Lax`. */
23
- sameSite?: SameSite;
23
+ sameSite?: SameSite | undefined;
24
24
  /** Add the `HttpOnly` attribute. Defaults to `true` — pass `false` for cookies JS must read (e.g. XSRF-TOKEN). */
25
- httpOnly?: boolean;
25
+ httpOnly?: boolean | undefined;
26
26
  /** Add the `Secure` attribute. Defaults to `false`. */
27
- secure?: boolean;
27
+ secure?: boolean | undefined;
28
28
  /** `Domain` attribute. Omitted when absent. */
29
- domain?: string;
29
+ domain?: string | undefined;
30
30
  }
31
31
 
32
32
  /**
@@ -155,3 +155,52 @@ export function deepMerge<T extends object>(base: T, override: DeepPartial<T>):
155
155
  }
156
156
  return result;
157
157
  }
158
+
159
+ /**
160
+ * A shape whose optional properties have all been given a value.
161
+ *
162
+ * `Required<T>` is not this, which is the trap. `-?` removes the *optionality* a
163
+ * `?` introduced; it does not remove an `undefined` that was written into the type.
164
+ * So once an options shape says `retentionDays?: number | undefined` — as every
165
+ * public option shape here does, so that `{ x: a ?? undefined }` is writable —
166
+ * `Required<Options>` still hands back `number | undefined`, and the "defaults have
167
+ * been applied" type quietly stops meaning that.
168
+ *
169
+ * Use this for the object a constructor builds after applying its defaults.
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * private readonly _opts: Resolved<Pick<Options, "retentionDays">> & Omit<Options, "retentionDays">;
174
+ * ```
175
+ */
176
+ export type Resolved<T> = { [K in keyof T]-?: Exclude<T[K], undefined> };
177
+
178
+ /**
179
+ * `value` without the keys whose value is `undefined`.
180
+ *
181
+ * Object spread copies own properties even when they hold `undefined`, so
182
+ * `{ ...defaults, ...options }` lets an explicitly-`undefined` field overwrite a
183
+ * default rather than leave it alone. Every public option shape accepts an explicit
184
+ * `undefined` — that is what makes `{ x: a ?? undefined }` writable — so the merge
185
+ * is where "supplied as undefined" has to be turned back into "not supplied".
186
+ *
187
+ * The return type keeps each key optional while dropping `undefined` from its type,
188
+ * which is exactly the guarantee this provides.
189
+ *
190
+ * @param value - The bag to filter.
191
+ * @returns A new object with the `undefined`-valued keys absent.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * const merged = { ...DEFAULTS, ...definedOnly(options) };
196
+ * ```
197
+ */
198
+ export function definedOnly<T extends object>(
199
+ value: T,
200
+ ): { [K in keyof T]?: Exclude<T[K], undefined> } {
201
+ const out: Record<string, unknown> = {};
202
+ for (const [key, item] of Object.entries(value)) {
203
+ if (item !== undefined) out[key] = item;
204
+ }
205
+ return out as { [K in keyof T]?: Exclude<T[K], undefined> };
206
+ }
@@ -24,7 +24,7 @@
24
24
  import { readFileSync } from "node:fs";
25
25
  import { dirname, join } from "node:path";
26
26
 
27
- /** Set to `1`/`true` to downgrade a runtime mismatch from a refusal to a warning. */
27
+ /** Set to `1`/`true` to downgrade a runtime disagreement from a refusal to a warning. */
28
28
  export const RUNTIME_MISMATCH_ESCAPE = "ZT_ALLOW_RUNTIME_MISMATCH";
29
29
 
30
30
  /** A running runtime and the installed one it disagrees with. */
@@ -35,6 +35,12 @@ export interface RuntimeMismatch {
35
35
  installed: string;
36
36
  /** Absolute path of the manifest `installed` was read from. */
37
37
  manifest: string;
38
+ /**
39
+ * Whether the project *asked* for a `bun` package — named it in its own
40
+ * dependencies — or merely acquired one as a transitive peer. A mismatch the
41
+ * project chose is a refusal; one it did not is a warning.
42
+ */
43
+ chosen?: boolean | undefined;
38
44
  }
39
45
 
40
46
  /**
@@ -83,7 +89,54 @@ export function runtimeMismatch(cwd: string = process.cwd()): RuntimeMismatch |
83
89
  const installed = installedBunVersion(cwd);
84
90
  if (!installed) return null;
85
91
  if (installed.version === running) return null;
86
- return { running, installed: installed.version, manifest: installed.manifest };
92
+ return {
93
+ running,
94
+ installed: installed.version,
95
+ manifest: installed.manifest,
96
+ chosen: declaresBunDependency(cwd),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Whether the project asked for a `bun` package, or merely ended up with one.
102
+ *
103
+ * This is the difference between two runtimes and one runtime plus a stray npm
104
+ * package, and it decides whether the mismatch is a refusal or a warning.
105
+ *
106
+ * It matters because the common way to acquire a `node_modules/bun` is not to want
107
+ * one. `bun-plugin-tailwind@0.1.2` — which the scaffold itself installs — declares
108
+ * `"peerDependencies": { "bun": ">=1.0.0" }` with no `peerDependenciesMeta` marking
109
+ * it optional, so `bun install` auto-installs the Bun *npm package* as a second
110
+ * runtime, newer than the one executing. An app hit exactly that and took two
111
+ * production outages: the first when the refusal crash-looped behind a 502, the
112
+ * second when the obvious fix (`rm -rf node_modules/bun` after install) could not
113
+ * work, because that package's postinstall runs *during* install and its removal
114
+ * leaves the tree incomplete.
115
+ *
116
+ * Nothing about that situation is a project running two runtimes. Nobody executes
117
+ * the stray copy; it is a directory. Refusing to boot over it is the guard being
118
+ * confidently wrong, and the remedies it prints — `bun update bun`, or run
119
+ * everything through `node_modules/.bin/bun` — are both wrong answers to it.
120
+ *
121
+ * @param cwd - Project root to look under.
122
+ * @returns `true` when a manifest up the tree names `bun` in its own dependencies.
123
+ */
124
+ export function declaresBunDependency(cwd: string): boolean {
125
+ let dir = cwd;
126
+ for (;;) {
127
+ try {
128
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
129
+ dependencies?: Record<string, string>;
130
+ devDependencies?: Record<string, string>;
131
+ };
132
+ if (manifest.dependencies?.["bun"] ?? manifest.devDependencies?.["bun"]) return true;
133
+ } catch {
134
+ // No manifest here, or unreadable — keep climbing.
135
+ }
136
+ const parent = dirname(dir);
137
+ if (parent === dir) return false;
138
+ dir = parent;
139
+ }
87
140
  }
88
141
 
89
142
  /**
@@ -93,12 +146,34 @@ export function runtimeMismatch(cwd: string = process.cwd()): RuntimeMismatch |
93
146
  * @param mismatch - The disagreement to describe.
94
147
  */
95
148
  export function runtimeMismatchMessage(mismatch: RuntimeMismatch): string {
96
- return (
149
+ const head =
97
150
  `Two Bun runtimes are in play. This process is Bun ${mismatch.running}, but the ` +
98
151
  `project installs Bun ${mismatch.installed} (${mismatch.manifest}).\n\n` +
99
152
  ` Whichever one is not running this command is still running something else — ` +
100
153
  `the server, the suite, a deploy step — and a test that passes under one is not ` +
101
- `evidence about the other.\n\n` +
154
+ `evidence about the other.\n\n`;
155
+
156
+ // Nothing named `bun`, so it arrived as a transitive peer. Naming the usual
157
+ // culprit is most of the fix, because the remedies below are the opposite of the
158
+ // ones for a runtime somebody chose — and following those instead cost an app two
159
+ // production outages.
160
+ if (mismatch.chosen === false) {
161
+ return (
162
+ head +
163
+ ` Nothing in this project depends on "bun", so it arrived as a transitive peer —\n` +
164
+ ` usually bun-plugin-tailwind, which declares "bun" as a required peer dependency\n` +
165
+ ` and has Bun auto-install it as a second runtime.\n\n` +
166
+ ` Fix it by not installing it:\n` +
167
+ ` bun install --omit=peer # skips every peer; safe when yours are direct deps\n\n` +
168
+ ` Do NOT remove node_modules/bun after installing. That package's postinstall runs\n` +
169
+ ` *during* install, so deleting it afterwards leaves the tree incomplete and the\n` +
170
+ ` next install fails — which is its own outage.\n\n` +
171
+ ` To boot anyway, set ${RUNTIME_MISMATCH_ESCAPE}=1 — it downgrades this to a warning.`
172
+ );
173
+ }
174
+
175
+ return (
176
+ head +
102
177
  ` Fix it by picking one:\n` +
103
178
  ` bun update bun # move the installed one to match your shell\n` +
104
179
  ` node_modules/.bin/bun # or run everything through the installed one\n\n` +
@@ -134,3 +209,93 @@ export function bunBinary(): string {
134
209
  const path = process.execPath;
135
210
  return path && path.length > 0 ? path : "bun";
136
211
  }
212
+
213
+ /** A running runtime and the floor the project says it needs. */
214
+ export interface RuntimeFloor {
215
+ /** `Bun.version` — the binary this process is executing under. */
216
+ running: string;
217
+ /** The `engines.bun` range the project declares. */
218
+ required: string;
219
+ /** Absolute path of the manifest `required` was read from. */
220
+ manifest: string;
221
+ }
222
+
223
+ /**
224
+ * The project's declared Bun floor, by walking up from `cwd` for a `package.json`
225
+ * with an `engines.bun`.
226
+ *
227
+ * Walking rather than reading one fixed path, for the same reason
228
+ * {@link installedBunVersion} walks: in a workspace the app and the manifest that
229
+ * pins the runtime are not always the same directory. The first `engines.bun` found
230
+ * wins — the nearest one is the app's own.
231
+ *
232
+ * @param cwd - Directory to start from.
233
+ * @returns `{ range, manifest }`, or `null` when nothing up the tree declares one.
234
+ */
235
+ export function declaredBunFloor(cwd: string): { range: string; manifest: string } | null {
236
+ let dir = cwd;
237
+ // Bounded by the filesystem root: dirname("/") === "/" and dirname("C:\\") === "C:\\".
238
+ for (;;) {
239
+ const manifest = join(dir, "package.json");
240
+ try {
241
+ const parsed = JSON.parse(readFileSync(manifest, "utf8")) as {
242
+ engines?: { bun?: string };
243
+ };
244
+ const range = parsed.engines?.bun;
245
+ if (range) return { range, manifest };
246
+ } catch {
247
+ // No manifest here, or not one we can read — keep climbing.
248
+ }
249
+ const parent = dirname(dir);
250
+ if (parent === dir) return null;
251
+ dir = parent;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Whether this process is running below the Bun version the project says it needs.
257
+ *
258
+ * `engines.bun` is written by every generated app and enforced by nothing, which is
259
+ * the gap {@link runtimeMismatch} does not cover: that one compares the running
260
+ * runtime against an *installed* one, and most projects do not install Bun as a
261
+ * package, so it has nothing to compare against and correctly says nothing.
262
+ *
263
+ * The failure this catches is narrow and expensive. `Intl` output differs between
264
+ * Bun releases, so a suite with currency or date assertions goes red on a runtime
265
+ * that is otherwise fine, and the failures name the code they touch rather than the
266
+ * binary. The version is the last thing anyone checks.
267
+ *
268
+ * An unparseable range is treated as satisfied. A refusal because we could not read
269
+ * a version range is worse than the mismatch it would have prevented.
270
+ *
271
+ * @param cwd - Project root to look under. Defaults to the working directory.
272
+ * @returns The shortfall, or `null` when the floor is met or none is declared.
273
+ */
274
+ export function runtimeBelowFloor(cwd: string = process.cwd()): RuntimeFloor | null {
275
+ const running = typeof Bun === "undefined" ? "" : Bun.version;
276
+ if (!running) return null;
277
+ const declared = declaredBunFloor(cwd);
278
+ if (!declared) return null;
279
+ if (Bun.semver.satisfies(running, declared.range)) return null;
280
+ return { running, required: declared.range, manifest: declared.manifest };
281
+ }
282
+
283
+ /**
284
+ * The message shown when the runtime is below the project's floor.
285
+ *
286
+ * @param floor - The shortfall to describe.
287
+ */
288
+ export function runtimeBelowFloorMessage(floor: RuntimeFloor): string {
289
+ return (
290
+ `This process is Bun ${floor.running}, and the project requires ${floor.required} ` +
291
+ `(${floor.manifest}).\n\n` +
292
+ ` The difference between two Bun releases is real and narrow: Intl formatting, the ` +
293
+ `SQLite bindings and node: compatibility all move, so a handful of assertions are ` +
294
+ `runtime-sensitive and the rest are not. When two of them fail you go looking for a bug ` +
295
+ `in the code they touch, because nothing in the failure says "wrong binary".\n\n` +
296
+ ` Fix it by upgrading the runtime, or by lowering engines.bun if this version is ` +
297
+ `genuinely supported:\n` +
298
+ ` bun upgrade\n\n` +
299
+ ` To run anyway, set ${RUNTIME_MISMATCH_ESCAPE}=1 — it downgrades this to a warning.`
300
+ );
301
+ }