@zerotal/core 1.3.0 → 1.5.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 (60) hide show
  1. package/CHANGELOG.md +351 -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 +23 -1
  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 +69 -2
  60. package/src/support/unroutedRoutes.ts +37 -0
@@ -246,7 +246,7 @@ export class Container {
246
246
 
247
247
  if (!binding) {
248
248
  throw new BindingNotFoundError(
249
- typeof canonical === "function" ? (canonical as Function).name : String(canonical),
249
+ typeof canonical === "function" ? canonical.name : String(canonical),
250
250
  );
251
251
  }
252
252
 
@@ -353,7 +353,7 @@ export class Container {
353
353
  }
354
354
 
355
355
  throw new BindingNotFoundError(
356
- typeof canonical === "function" ? (canonical as Function).name : String(canonical),
356
+ typeof canonical === "function" ? canonical.name : String(canonical),
357
357
  );
358
358
  }
359
359
 
@@ -442,7 +442,7 @@ export class Container {
442
442
  private _guardCycle(token: unknown, _chain: readonly unknown[]): readonly unknown[] {
443
443
  if (_chain.includes(token)) {
444
444
  const errorChain = [..._chain, token].map((entry) =>
445
- typeof entry === "function" ? (entry as Function).name : String(entry),
445
+ typeof entry === "function" ? entry.name : String(entry),
446
446
  );
447
447
  throw new CircularDependencyError(errorChain);
448
448
  }
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { BindingToken } from "./types.ts";
19
+ import type { ClassRef } from "../support/classRef.ts";
19
20
 
20
21
  /**
21
22
  * Module-level registry: constructor → ordered dependency tokens.
@@ -23,7 +24,7 @@ import type { BindingToken } from "./types.ts";
23
24
  * @internal Populated by {@link inject} and read by the container during
24
25
  * auto-wiring; not part of the public API.
25
26
  */
26
- export const injectRegistry = new Map<Function, BindingToken[]>();
27
+ export const injectRegistry = new Map<ClassRef, BindingToken[]>();
27
28
 
28
29
  /**
29
30
  * Mark a class for auto-wiring by the container, declaring its constructor
@@ -46,7 +47,7 @@ export function inject(...tokens: BindingToken[]) {
46
47
  // assignable from classes with typed constructors — `unknown[]` would fail the
47
48
  // constructor-parameter contravariance check at the decoration site.
48
49
  return function (
49
- // eslint-disable-next-line @typescript-eslint/no-explicit-any decorator target must accept any class shape
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- decorator target must accept any class shape
50
51
  target: new (...args: any[]) => unknown,
51
52
  _context: ClassDecoratorContext,
52
53
  ): void {
@@ -88,4 +88,64 @@ export class RequestContext {
88
88
  static transaction(): TransactionContext | undefined {
89
89
  return this.tryGet()?._transaction;
90
90
  }
91
+
92
+ // ── Per-request memoisation ───────────────────────────────────────────
93
+
94
+ /**
95
+ * Run `factory` at most once per request for a given `key`, and hand every
96
+ * later caller the same answer.
97
+ *
98
+ * The N+1 detector tells you a query ran too many times; the fix is almost
99
+ * always "ask once per request", and this is that. Outside a request it is a
100
+ * pass-through — a queue worker or a CLI command has no request to scope to,
101
+ * and silently sharing a value across jobs would be worse than not caching.
102
+ *
103
+ * Two details are the whole point, and both were learned the expensive way by
104
+ * everyone who hand-rolls this:
105
+ *
106
+ * - **The promise is cached, not the resolved value.** Cache after the
107
+ * `await` and a `Promise.all` of ten readers all miss, because none of them
108
+ * has resolved when the others look. Caching the promise makes the first
109
+ * caller's in-flight work the answer for the other nine.
110
+ * - **A rejected promise is evicted.** Leave it in and one transient failure
111
+ * poisons every later read in the same request, including the retry.
112
+ *
113
+ * @param key - Unique within the request. Include the arguments: `user:${id}`.
114
+ * @param factory - Runs on the first call for this key.
115
+ *
116
+ * @example
117
+ * const settings = await RequestContext.remember(
118
+ * `household:${id}:settings`,
119
+ * () => Settings.query().where("household_id", id).first(),
120
+ * );
121
+ */
122
+ static async remember<T>(key: string, factory: () => Promise<T> | T): Promise<T> {
123
+ const ctx = this.tryGet();
124
+ if (!ctx) return factory();
125
+
126
+ const cacheKey = `memo:${key}`;
127
+ const hit = ctx.getInternal<Promise<T>>(cacheKey);
128
+ if (hit) return hit;
129
+
130
+ // Store the promise synchronously, before the first await — that is what
131
+ // makes concurrent callers share one round trip instead of racing.
132
+ const pending = (async () => factory())();
133
+ ctx.setInternal(cacheKey, pending);
134
+
135
+ try {
136
+ return await pending;
137
+ } catch (error) {
138
+ ctx.deleteInternal(cacheKey);
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Drop a memoised value so the next {@link remember} recomputes it.
145
+ *
146
+ * For the case a write invalidates a read taken earlier in the same request.
147
+ */
148
+ static forget(key: string): void {
149
+ this.tryGet()?.deleteInternal(`memo:${key}`);
150
+ }
91
151
  }
@@ -24,17 +24,32 @@ export interface SessionContract {
24
24
  /**
25
25
  * Read the value stored under `key`, or `undefined` if absent.
26
26
  *
27
- * Returns `unknown` the caller asserts the stored shape. Higher-level
28
- * surfaces (the `Session` facade, `ctx.session` helpers) layer a generic
29
- * `<T>` cast on top.
27
+ * Defaults to `unknown`, so the safe form read, then narrow — still works
28
+ * and is still the honest one for anything that came off the wire. The
29
+ * optional `<T>` is the caller asserting a shape they control, and it lives
30
+ * here rather than only on the facade because `ctx.session` is typed as this
31
+ * contract: without it, `ctx.session.get<number>(k)` was a compile error while
32
+ * `ctx.flashed<T>(k)` on the same object was not.
33
+ *
34
+ * Two overloads rather than one defaulted parameter: `T = unknown` would make
35
+ * the return `T | undefined`, which is *not* the same type as `unknown` at a
36
+ * call site TypeScript has to resolve an overload against — enough to break
37
+ * existing `expect(session.get(k))` assertions. The un-parameterised form
38
+ * therefore keeps its exact original signature.
39
+ *
40
+ * @example
41
+ * const issuedAt = ctx.session.get<number>(SESSION_ISSUED_AT);
42
+ * const raw = ctx.session.get(SOMETHING_EXTERNAL); // still exactly `unknown`
30
43
  */
31
44
  get(key: string): unknown;
45
+ get<T>(key: string): T | undefined;
32
46
 
33
47
  /**
34
48
  * Read the value under `key` and remove it in one step ("read once").
35
49
  * Returns the stored value, or `undefined` if absent.
36
50
  */
37
51
  pull(key: string): unknown;
52
+ pull<T>(key: string): T | undefined;
38
53
 
39
54
  /**
40
55
  * Store `value` under `key`, overwriting any existing entry.
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Skip a bundle whose inputs have not changed since the last successful build.
3
+ *
4
+ * `serve --dev` rebuilds every bundle on boot, including the common case where
5
+ * the project has not been touched since the last run. Bun's bundler has no
6
+ * incremental API to drive, so the granularity available is all-or-nothing per
7
+ * bundle: work out what went in, and if none of it moved, leave the output alone.
8
+ *
9
+ * ## What counts as an input
10
+ *
11
+ * Two sources, because one is not enough:
12
+ *
13
+ * - **The module graph**, read back from the external sourcemaps the dev build
14
+ * already emits. Their `sources` arrays are the real list of files the
15
+ * bundler pulled in, lazily-imported chunks included.
16
+ * - **Scanned trees**, passed in by the caller. Tailwind v4 discovers utility
17
+ * classes by reading `@source` globs across `app/` and `resources/`, and
18
+ * none of that appears in any sourcemap — a stylesheet's true input set is
19
+ * "the source tree". Stat-only, so it costs ~10 ms for a small app.
20
+ *
21
+ * ## Failing safe
22
+ *
23
+ * Every uncertainty resolves to *build*. A missing cache file, a corrupt one, an
24
+ * unreadable input, a Bun upgrade, an output someone deleted — all of them mean
25
+ * rebuild. The cache is only ever allowed to cause an unnecessary build, never
26
+ * to skip a necessary one.
27
+ *
28
+ * @internal
29
+ */
30
+ import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
31
+ import { dirname, join, relative, resolve } from "node:path";
32
+
33
+ /** Where cache entries live, alongside the prune manifests. */
34
+ const CACHE_DIR = ".zerotal/build";
35
+
36
+ /** Set to `1` to make every build unconditional. */
37
+ const DISABLE_ENV_VAR = "ZT_NO_BUILD_CACHE";
38
+
39
+ /** What a previous successful build consumed and produced. */
40
+ interface CacheEntry {
41
+ /** Fingerprint of the build's configuration. */
42
+ key: string;
43
+ /** Bun version that produced the output. */
44
+ bun: string;
45
+ /** Absolute input path → `"<mtimeMs>:<size>"`. */
46
+ inputs: Record<string, string>;
47
+ /** Absolute paths this build wrote. */
48
+ outputs: string[];
49
+ }
50
+
51
+ /** The subset of a `Bun.build()` artifact this module reads. */
52
+ export interface BuildArtifactLike {
53
+ path: string;
54
+ kind?: string;
55
+ text?: () => Promise<string>;
56
+ }
57
+
58
+ /** Everything that should invalidate a cached build when it changes. */
59
+ export interface BuildCacheKey {
60
+ entrypoints: string[];
61
+ outdir: string;
62
+ minify: boolean;
63
+ /** Per-extension loader overrides. */
64
+ loader?: Record<string, string> | undefined;
65
+ /** Plugin identities — a different Tailwind plugin must rebuild. */
66
+ plugins?: readonly string[] | undefined;
67
+ /** Anything else the caller considers part of the build's identity. */
68
+ extra?: Record<string, unknown> | undefined;
69
+ }
70
+
71
+ /**
72
+ * A cache entry for one bundle, scoped to one build configuration.
73
+ *
74
+ * @example
75
+ * const cache = BuildCache.for({ entrypoints, outdir, minify });
76
+ * if (await cache.isFresh()) return { success: true, logs: [] };
77
+ *
78
+ * const result = await Bun.build({ entrypoints, outdir, sourcemap: "external" });
79
+ * if (result.success) {
80
+ * await pruneBuildOutput(outdir, result.outputs);
81
+ * await cache.record(result.outputs, { scanRoots: [`${cwd}/app`] });
82
+ * }
83
+ */
84
+ export class BuildCache {
85
+ private constructor(
86
+ private readonly path: string,
87
+ private readonly key: string,
88
+ private readonly entrypoints: string[],
89
+ ) {}
90
+
91
+ /** Open the cache for one build configuration. */
92
+ static for(key: BuildCacheKey, cwd: string = process.cwd()): BuildCache {
93
+ const outdir = resolve(cwd, key.outdir);
94
+ const entrypoints = key.entrypoints.map((entry) => resolve(cwd, entry));
95
+
96
+ // The identity of the *entry*, so two bundles writing to the same directory
97
+ // (Flow's CSS and JS both land in `public/`) do not share one record.
98
+ const identity = JSON.stringify({
99
+ outdir,
100
+ entrypoints: [...entrypoints].sort(),
101
+ minify: key.minify,
102
+ loader: key.loader ?? null,
103
+ plugins: key.plugins ? [...key.plugins].sort() : null,
104
+ extra: key.extra ?? null,
105
+ });
106
+
107
+ const slug = (relative(cwd, outdir) || "out").replace(/[^a-z0-9]+/gi, "-");
108
+ const hash = Bun.hash(identity).toString(36);
109
+
110
+ return new BuildCache(
111
+ join(cwd, CACHE_DIR, `cache-${slug}-${hash}.json`),
112
+ Bun.hash(identity).toString(36),
113
+ entrypoints,
114
+ );
115
+ }
116
+
117
+ /** Whether the cache is consulted at all. */
118
+ static get enabled(): boolean {
119
+ return Bun.env[DISABLE_ENV_VAR] !== "1";
120
+ }
121
+
122
+ /**
123
+ * Whether the last build's output is still good.
124
+ *
125
+ * False whenever anything is uncertain — see the module note on failing safe.
126
+ */
127
+ async isFresh(): Promise<boolean> {
128
+ if (!BuildCache.enabled) return false;
129
+
130
+ const entry = await this.#read();
131
+ if (entry === null) return false;
132
+
133
+ // A different build configuration, or a different Bun. Bun's output changes
134
+ // between versions, and serving a stale bundle after `bun upgrade` is a
135
+ // memorably bad afternoon.
136
+ if (entry.key !== this.key) return false;
137
+ if (entry.bun !== Bun.version) return false;
138
+
139
+ // An empty input set would make every future build "fresh" forever.
140
+ if (Object.keys(entry.inputs).length === 0) return false;
141
+
142
+ for (const [path, stamp] of Object.entries(entry.inputs)) {
143
+ if ((await _stamp(path)) !== stamp) return false;
144
+ }
145
+
146
+ // Cheap, and it means `rm -rf public/` always recovers.
147
+ for (const path of entry.outputs) {
148
+ if (!(await Bun.file(path).exists())) return false;
149
+ }
150
+
151
+ return true;
152
+ }
153
+
154
+ /**
155
+ * Record what a successful build consumed and produced.
156
+ *
157
+ * Call only after a build that succeeded. Recording a failed build would
158
+ * cache the inputs of output that was never written.
159
+ *
160
+ * @param outputs The build's artifacts.
161
+ * @param options.scanRoots Directories whose whole contents count as inputs
162
+ * (Tailwind's `@source` scan). Missing directories are skipped.
163
+ */
164
+ async record(
165
+ outputs: readonly BuildArtifactLike[],
166
+ options: { scanRoots?: readonly string[] } = {},
167
+ ): Promise<void> {
168
+ if (!BuildCache.enabled) return;
169
+
170
+ const inputs: Record<string, string> = {};
171
+
172
+ // Entry points always count, even when sourcemaps are off.
173
+ for (const entry of this.entrypoints) {
174
+ const stamp = await _stamp(entry);
175
+ if (stamp !== null) inputs[entry] = stamp;
176
+ }
177
+
178
+ for (const path of await _graphInputs(outputs)) {
179
+ if (inputs[path] === undefined) {
180
+ const stamp = await _stamp(path);
181
+ if (stamp !== null) inputs[path] = stamp;
182
+ }
183
+ }
184
+
185
+ for (const root of options.scanRoots ?? []) {
186
+ for (const [path, stamp] of Object.entries(await fingerprintTree(root))) {
187
+ inputs[path] ??= stamp;
188
+ }
189
+ }
190
+
191
+ await this.#write({
192
+ key: this.key,
193
+ bun: Bun.version,
194
+ inputs,
195
+ outputs: outputs.map((output) => resolve(output.path)),
196
+ });
197
+ }
198
+
199
+ /** Forget this entry, forcing the next build to run. */
200
+ async invalidate(): Promise<void> {
201
+ try {
202
+ await writeFile(this.path, "{}");
203
+ } catch {
204
+ // Nothing to invalidate is the same outcome as invalidating.
205
+ }
206
+ }
207
+
208
+ async #read(): Promise<CacheEntry | null> {
209
+ try {
210
+ const contents = (await Bun.file(this.path).json()) as unknown;
211
+ if (typeof contents !== "object" || contents === null) return null;
212
+
213
+ const entry = contents as Partial<CacheEntry>;
214
+ if (typeof entry.key !== "string" || typeof entry.bun !== "string") return null;
215
+ if (typeof entry.inputs !== "object" || entry.inputs === null) return null;
216
+ if (!Array.isArray(entry.outputs)) return null;
217
+
218
+ return entry as CacheEntry;
219
+ } catch {
220
+ // Absent, unreadable, or not JSON — all mean "build".
221
+ return null;
222
+ }
223
+ }
224
+
225
+ async #write(entry: CacheEntry): Promise<void> {
226
+ try {
227
+ await mkdir(dirname(this.path), { recursive: true });
228
+ await writeFile(this.path, JSON.stringify(entry, null, 2));
229
+ } catch {
230
+ // A cache that cannot be written costs a rebuild next time, not correctness.
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Stat every file under `root`, as absolute path → `"<mtimeMs>:<size>"`.
237
+ *
238
+ * Contents are never read: `mtime` plus size is enough to notice an edit, and
239
+ * hashing a whole source tree on every boot would cost more than the build it
240
+ * is trying to avoid. The gap — a same-size edit that preserves mtime — needs
241
+ * deliberate effort to produce, and `ZT_NO_BUILD_CACHE=1` covers it.
242
+ */
243
+ export async function fingerprintTree(root: string): Promise<Record<string, string>> {
244
+ const out: Record<string, string> = {};
245
+
246
+ let entries: string[];
247
+ try {
248
+ entries = await readdir(root, { recursive: true });
249
+ } catch {
250
+ // A directory that does not exist contributes nothing rather than failing —
251
+ // an app without `app/` is a valid app.
252
+ return out;
253
+ }
254
+
255
+ for (const entry of entries) {
256
+ const path = join(root, entry);
257
+ const stamp = await _stamp(path);
258
+ // Directories stat fine but have no meaningful size; `_stamp` returns null
259
+ // for anything that is not a regular file.
260
+ if (stamp !== null) out[path] = stamp;
261
+ }
262
+
263
+ return out;
264
+ }
265
+
266
+ // ── Private ──────────────────────────────────────────────────────────────────
267
+
268
+ /** `"<mtimeMs>:<size>"` for a regular file, or `null` for anything else. */
269
+ async function _stamp(path: string): Promise<string | null> {
270
+ try {
271
+ const info = await stat(path);
272
+ if (!info.isFile()) return null;
273
+ return `${info.mtimeMs}:${info.size}`;
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Every source file the bundler read, recovered from the emitted sourcemaps.
281
+ *
282
+ * A sourcemap's `sources` are relative to the map itself and use the host's
283
+ * separators, so both need normalising before they name a real file.
284
+ */
285
+ async function _graphInputs(outputs: readonly BuildArtifactLike[]): Promise<string[]> {
286
+ const found = new Set<string>();
287
+
288
+ for (const output of outputs) {
289
+ const isMap = output.kind === "sourcemap" || output.path.endsWith(".map");
290
+ if (!isMap) continue;
291
+
292
+ try {
293
+ const text = output.text ? await output.text() : await Bun.file(output.path).text();
294
+ const map = JSON.parse(text) as { sources?: unknown };
295
+ if (!Array.isArray(map.sources)) continue;
296
+
297
+ const base = dirname(resolve(output.path));
298
+ for (const source of map.sources) {
299
+ if (typeof source !== "string" || source === "") continue;
300
+ // Virtual entries a plugin injected have no file behind them.
301
+ if (source.includes("://")) continue;
302
+ found.add(resolve(base, source.split("\\").join("/")));
303
+ }
304
+ } catch {
305
+ // A map we cannot parse just means a smaller input set, and a smaller
306
+ // input set only ever causes extra builds.
307
+ continue;
308
+ }
309
+ }
310
+
311
+ return [...found];
312
+ }
@@ -5,6 +5,26 @@
5
5
  */
6
6
  import type { BunPlugin } from "bun";
7
7
  import { pruneBuildOutput } from "./BuildOutput.ts";
8
+ import { BuildCache } from "./BuildCache.ts";
9
+
10
+ /** Outcome of one bundling helper. `skipped` means the cache answered. */
11
+ export interface BundleResult {
12
+ success: boolean;
13
+ logs: unknown[];
14
+ /** True when nothing was rebuilt because no input had changed. */
15
+ skipped?: boolean;
16
+ }
17
+
18
+ /**
19
+ * Directories whose contents feed Tailwind's `@source` scan.
20
+ *
21
+ * A stylesheet's inputs are not its imports — utility classes are discovered by
22
+ * reading templates and components, none of which appear in a sourcemap. These
23
+ * are the conventional locations; a missing one contributes nothing.
24
+ */
25
+ function _scanRoots(cwd: string): string[] {
26
+ return [`${cwd}/app`, `${cwd}/resources`, `${cwd}/routes`, `${cwd}/config`];
27
+ }
8
28
 
9
29
  /**
10
30
  * Detect and load `bun-plugin-tailwind` from the app's own node_modules.
@@ -46,13 +66,29 @@ export async function buildCssBundle(
46
66
  outdir: string,
47
67
  minify = false,
48
68
  loader?: Record<string, string>,
49
- ): Promise<{ success: boolean; logs: unknown[] }> {
69
+ ): Promise<BundleResult> {
50
70
  const cwd = process.cwd();
51
71
  const plugins = await detectCssPlugins(cwd);
52
72
 
73
+ // The whole source tree is this bundle's input set, because that is what
74
+ // Tailwind reads. Broad, but a stat sweep is far cheaper than the build.
75
+ const cache = BuildCache.for(
76
+ {
77
+ entrypoints: [input],
78
+ outdir,
79
+ minify,
80
+ loader,
81
+ plugins: plugins.map((plugin) => plugin.name ?? "anonymous"),
82
+ extra: { kind: "css", cli: plugins.length === 0 },
83
+ },
84
+ cwd,
85
+ );
86
+
87
+ if (!minify && (await cache.isFresh())) return { success: true, logs: [], skipped: true };
88
+
53
89
  if (plugins.length > 0) {
54
90
  // bun-plugin-tailwind is available — use Bun's native CSS bundler
55
- return Bun.build({
91
+ const result = await Bun.build({
56
92
  entrypoints: [input],
57
93
  outdir,
58
94
  target: "browser",
@@ -62,6 +98,9 @@ export async function buildCssBundle(
62
98
  ? { loader: loader as NonNullable<Parameters<typeof Bun.build>[0]["loader"]> }
63
99
  : {}),
64
100
  });
101
+
102
+ if (result.success) await cache.record(result.outputs, { scanRoots: _scanRoots(cwd) });
103
+ return { success: result.success, logs: result.logs as unknown[] };
65
104
  }
66
105
 
67
106
  // Fallback: run the Tailwind CLI as a subprocess
@@ -91,6 +130,12 @@ export async function buildCssBundle(
91
130
  new Response(subprocess.stderr).text(),
92
131
  ]);
93
132
 
133
+ if (exitCode === 0) {
134
+ // The CLI emits no sourcemap and returns no artifact list, so the one
135
+ // output is named rather than discovered.
136
+ await cache.record([{ path: outputFile }], { scanRoots: _scanRoots(cwd) });
137
+ }
138
+
94
139
  return {
95
140
  success: exitCode === 0,
96
141
  logs: exitCode !== 0 ? [stderr] : [],
@@ -115,15 +160,30 @@ export async function buildJsBundle(
115
160
  input: string,
116
161
  outdir: string,
117
162
  minify = false,
118
- ): Promise<{ success: boolean; logs: unknown[] }> {
163
+ ): Promise<BundleResult> {
164
+ const cwd = process.cwd();
165
+ const cache = BuildCache.for(
166
+ { entrypoints: [input], outdir, minify, extra: { kind: "js" } },
167
+ cwd,
168
+ );
169
+
170
+ if (!minify && (await cache.isFresh())) return { success: true, logs: [], skipped: true };
171
+
119
172
  try {
120
- return await Bun.build({
173
+ const result = await Bun.build({
121
174
  entrypoints: [input],
122
175
  outdir,
123
176
  target: "browser",
124
177
  format: "esm",
125
178
  minify,
179
+ // Not only for debugging: the map's `sources` are how the cache learns
180
+ // which files this bundle actually pulled in.
181
+ ...(minify ? {} : { sourcemap: "external" as const }),
126
182
  });
183
+
184
+ // No scan roots — a JS bundle's inputs are exactly its module graph.
185
+ if (result.success) await cache.record(result.outputs);
186
+ return { success: result.success, logs: result.logs as unknown[] };
127
187
  } catch (error) {
128
188
  return { success: false, logs: [error] };
129
189
  }
@@ -162,13 +222,32 @@ export interface AssetBuildConfig {
162
222
  export async function buildConfiguredAssets(
163
223
  assets: AssetBuildConfig,
164
224
  cwd: string,
165
- ): Promise<{ success: boolean; logs: unknown[] }> {
225
+ ): Promise<BundleResult> {
166
226
  const entries = Array.isArray(assets.entrypoint) ? assets.entrypoint : [assets.entrypoint];
167
227
  const entrypoints = entries.map((entry) => `${cwd}/${entry}`);
168
228
  const outdir = `${cwd}/${assets.outDir}`;
169
229
 
170
230
  try {
171
231
  const plugins = await detectCssPlugins(cwd);
232
+
233
+ const cache = BuildCache.for(
234
+ {
235
+ entrypoints,
236
+ outdir,
237
+ minify: assets.minify,
238
+ loader: assets.loader,
239
+ plugins: plugins.map((plugin) => plugin.name ?? "anonymous"),
240
+ extra: { kind: "configured" },
241
+ },
242
+ cwd,
243
+ );
244
+
245
+ // Never in a minified (production) build: the cost of a wrong skip there is
246
+ // shipping stale assets, and the build runs once rather than on every save.
247
+ if (!assets.minify && (await cache.isFresh())) {
248
+ return { success: true, logs: [], skipped: true };
249
+ }
250
+
172
251
  const result = await Bun.build({
173
252
  entrypoints,
174
253
  outdir,
@@ -187,8 +266,15 @@ export async function buildConfiguredAssets(
187
266
  : {}),
188
267
  });
189
268
 
190
- if (result.success) await pruneBuildOutput(outdir, result.outputs);
191
- return result;
269
+ // Prune only after a build that actually ran. A skipped build returns no
270
+ // outputs, and pruning against an empty set would delete the entire
271
+ // previous build — which is why the cache check returns early above rather
272
+ // than falling through to here.
273
+ if (result.success) {
274
+ await pruneBuildOutput(outdir, result.outputs);
275
+ await cache.record(result.outputs, { scanRoots: _scanRoots(cwd) });
276
+ }
277
+ return { success: result.success, logs: result.logs as unknown[] };
192
278
  } catch (error) {
193
279
  return { success: false, logs: [error] };
194
280
  }
@@ -16,6 +16,13 @@
16
16
  export interface BuildResult {
17
17
  success: boolean;
18
18
  logs?: unknown[];
19
+ /**
20
+ * True when every registered routine skipped — nothing had changed since the
21
+ * last build. Reported so the dev log can say so: a developer who saves a file
22
+ * and sees no rebuild, with no explanation, deletes `.zerotal/` and stops
23
+ * trusting the tool.
24
+ */
25
+ skipped?: boolean;
19
26
  }
20
27
 
21
28
  /** A frontend build routine that resolves once the build finishes. */
@@ -60,7 +67,11 @@ export async function runDevBuildHooks(): Promise<BuildResult> {
60
67
  Array.from(_hooks, async ([name, fn]): Promise<BuildResult> => {
61
68
  try {
62
69
  const result = await fn();
63
- return { success: result.success, logs: (result.logs ?? []).map((l) => `[${name}] ${l}`) };
70
+ return {
71
+ success: result.success,
72
+ logs: (result.logs ?? []).map((l) => `[${name}] ${l}`),
73
+ ...(result.skipped === true ? { skipped: true } : {}),
74
+ };
64
75
  } catch (error) {
65
76
  return { success: false, logs: [`[${name}] ${error}`] };
66
77
  }
@@ -70,5 +81,7 @@ export async function runDevBuildHooks(): Promise<BuildResult> {
70
81
  return {
71
82
  success: results.every((result) => result.success),
72
83
  logs: results.flatMap((result) => result.logs ?? []),
84
+ // Only when *every* routine skipped: one bundle rebuilding is a rebuild.
85
+ skipped: results.length > 0 && results.every((result) => result.skipped === true),
73
86
  };
74
87
  }