@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.
- package/CHANGELOG.md +351 -0
- package/package.json +1 -1
- package/src/application/Application.ts +107 -9
- package/src/application/DevErrorPage.ts +82 -0
- package/src/application/diagnostics.ts +111 -0
- package/src/command/CommandRunner.ts +82 -1
- package/src/command/builtin/AssetsBuildCommand.ts +102 -0
- package/src/command/builtin/DeployCommand.ts +315 -0
- package/src/command/builtin/DevCommand.ts +88 -0
- package/src/command/builtin/DoctorCommand.ts +97 -0
- package/src/command/builtin/MakeCommandCommand.ts +2 -0
- package/src/command/builtin/RouteTypesCommand.ts +56 -0
- package/src/command/builtin/ServeCommand.ts +232 -44
- package/src/command/builtin/index.ts +5 -0
- package/src/command/scaffold/zerotal.ts.txt +2 -10
- package/src/config/AppConfig.ts +109 -2
- package/src/config/DeployConfig.ts +71 -0
- package/src/config/index.ts +2 -0
- package/src/config/registry.ts +1 -0
- package/src/container/Container.ts +3 -3
- package/src/container/inject.ts +3 -2
- package/src/context/RequestContext.ts +60 -0
- package/src/contracts/session.ts +18 -3
- package/src/dev/BuildCache.ts +312 -0
- package/src/dev/CssPlugins.ts +93 -7
- package/src/dev/DevBuildHook.ts +14 -1
- package/src/dev/DevDeck.ts +549 -0
- package/src/dev/DevOrchestrator.ts +166 -31
- package/src/dev/DevProcess.ts +221 -0
- package/src/dev/DevReloadMiddleware.ts +1 -1
- package/src/dev/DevSupervisor.ts +363 -0
- package/src/dev/bootBuild.ts +94 -0
- package/src/dev/index.ts +24 -0
- package/src/dev/startDevMode.ts +145 -0
- package/src/doctor/AppDoctor.ts +399 -0
- package/src/doctor/TransportProbe.ts +169 -0
- package/src/events/Emitter.ts +4 -3
- package/src/facade/facades/App.ts +10 -2
- package/src/helpers/index.ts +23 -1
- package/src/helpers/response.ts +18 -8
- package/src/http/Uri.ts +7 -3
- package/src/http/originGuard.ts +1 -1
- package/src/http/url.ts +10 -4
- package/src/index.ts +43 -0
- package/src/lock/LockManager.ts +190 -14
- package/src/lock/drivers/LockDriver.ts +11 -0
- package/src/lock/drivers/MemoryLockDriver.ts +21 -1
- package/src/lock/drivers/RedisLockDriver.ts +64 -8
- package/src/lock/drivers/SqliteLockDriver.ts +13 -0
- package/src/lock/errors.ts +26 -0
- package/src/lock/facades/Lock.ts +30 -5
- package/src/lock/index.ts +2 -2
- package/src/macros/config.macro.ts +2 -0
- package/src/provider/ServiceProvider.ts +40 -0
- package/src/router/Router.ts +111 -13
- package/src/router/registry.ts +123 -0
- package/src/router/routeTypes.ts +132 -0
- package/src/support/classRef.ts +27 -0
- package/src/support/env.ts +69 -2
- 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" ?
|
|
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" ?
|
|
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" ?
|
|
445
|
+
typeof entry === "function" ? entry.name : String(entry),
|
|
446
446
|
);
|
|
447
447
|
throw new CircularDependencyError(errorChain);
|
|
448
448
|
}
|
package/src/container/inject.ts
CHANGED
|
@@ -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<
|
|
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
|
|
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
|
}
|
package/src/contracts/session.ts
CHANGED
|
@@ -24,17 +24,32 @@ export interface SessionContract {
|
|
|
24
24
|
/**
|
|
25
25
|
* Read the value stored under `key`, or `undefined` if absent.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `<T>`
|
|
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
|
+
}
|
package/src/dev/CssPlugins.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
191
|
-
|
|
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
|
}
|
package/src/dev/DevBuildHook.ts
CHANGED
|
@@ -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 {
|
|
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
|
}
|