@akanjs/devkit 2.4.1-rc.2 → 2.4.1-rc.4
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/akanApp/akanApp.host.test.ts +283 -2
- package/akanApp/akanApp.host.ts +578 -12
- package/executors.test.ts +60 -0
- package/executors.ts +11 -0
- package/frontendBuild/fontOptimizer.test.ts +111 -0
- package/frontendBuild/fontOptimizer.ts +102 -17
- package/frontendBuild/hmrWatcher.test.ts +191 -0
- package/frontendBuild/hmrWatcher.ts +176 -5
- package/frontendBuild/index.ts +1 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +280 -0
- package/frontendBuild/sourceMtimeIndex.ts +326 -0
- package/incrementalBuilder/buildBatch.proc.ts +194 -0
- package/incrementalBuilder/buildBatchProtocol.ts +53 -0
- package/incrementalBuilder/buildBatchRunner.ts +85 -0
- package/incrementalBuilder/builderReply.test.ts +73 -0
- package/incrementalBuilder/builderReply.ts +30 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +199 -9
- package/incrementalBuilder/incrementalBuilder.host.ts +119 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +260 -170
- package/integration/devStability.integration.test.ts +308 -78
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +555 -40
- package/local/optimize-resource/ipcprobe/child.ts +1 -0
- package/local/optimize-resource/ipcprobe/parent.ts +11 -0
- package/package.json +2 -2
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { Dirent, Stats } from "node:fs";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { HmrChangeClassifier } from "./hmrChangeClassifier";
|
|
5
|
+
|
|
6
|
+
interface TrackedFile {
|
|
7
|
+
mtimeMs: number;
|
|
8
|
+
size: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A directory the index could not read, held with this mtime so every subsequent scan sees a mismatch
|
|
13
|
+
* and retries it. `NaN !== NaN`, which is exactly the "always stale" semantics wanted here.
|
|
14
|
+
*/
|
|
15
|
+
const UNREADABLE = Number.NaN;
|
|
16
|
+
|
|
17
|
+
export interface SourceMtimeIndexOptions {
|
|
18
|
+
roots: string[];
|
|
19
|
+
classifier?: HmrChangeClassifier;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A snapshot of every interesting source file's mtime under the watch roots, so the watcher can answer
|
|
24
|
+
* "what actually changed" without trusting `fs.watch` event payloads.
|
|
25
|
+
*
|
|
26
|
+
* Bun 1.3.14's recursive `fs.watch` on macOS reports roughly **one path per ~200ms coalescing window**
|
|
27
|
+
* and discards every other path changed in that window. Measured on this repo: saving 5 files together
|
|
28
|
+
* reports 1; saving 20 reports 2; a single unrelated write landing first in the window hides a
|
|
29
|
+
* concurrent save entirely. Node 22 reports all 20 on the same tree, so this is a Bun defect rather
|
|
30
|
+
* than an FSEvents limitation (`local/optimize-resource/06-watcher-dropped-event.md`).
|
|
31
|
+
*
|
|
32
|
+
* Re-stating the tracked set costs ~15ms against ~1300 source files here, where a fresh walk costs
|
|
33
|
+
* ~70ms — the walk has to visit ~40k entries (`ios/`, `android/`, `public/`) to find those 1300. So
|
|
34
|
+
* changes are found by re-stating known files plus re-reading only the directories whose own mtime
|
|
35
|
+
* moved, which is what adding or removing an entry bumps.
|
|
36
|
+
*/
|
|
37
|
+
export class SourceMtimeIndex {
|
|
38
|
+
readonly #roots: string[];
|
|
39
|
+
readonly #classifier: HmrChangeClassifier;
|
|
40
|
+
readonly #files = new Map<string, TrackedFile>();
|
|
41
|
+
readonly #dirs = new Map<string, number>();
|
|
42
|
+
readonly #unreadable = new Map<string, string>();
|
|
43
|
+
#primed = false;
|
|
44
|
+
#queue: Promise<unknown> = Promise.resolve();
|
|
45
|
+
|
|
46
|
+
constructor({ roots, classifier }: SourceMtimeIndexOptions) {
|
|
47
|
+
this.#roots = SourceMtimeIndex.#pruneNestedRoots(roots);
|
|
48
|
+
this.#classifier = classifier ?? new HmrChangeClassifier();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get primed(): boolean {
|
|
52
|
+
return this.#primed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get trackedFileCount(): number {
|
|
56
|
+
return this.#files.size;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Paths the index currently cannot read, as `{ path, code }`. Non-empty means change detection has a
|
|
61
|
+
* blind spot right now: anything edited underneath one of these is not reported until it recovers.
|
|
62
|
+
*
|
|
63
|
+
* Worth surfacing rather than swallowing, because the failure is silent by nature — a walk cannot tell
|
|
64
|
+
* how much it did not see. Each entry is retried on every scan and clears itself on success.
|
|
65
|
+
*/
|
|
66
|
+
get coverageGaps(): { path: string; code: string }[] {
|
|
67
|
+
return [...this.#unreadable].map(([file, code]) => ({ path: file, code }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Record the current state as the baseline. Reports nothing; call before the first `collectChanges`. */
|
|
71
|
+
async prime(): Promise<void> {
|
|
72
|
+
await this.#serialize(async () => {
|
|
73
|
+
this.#files.clear();
|
|
74
|
+
this.#dirs.clear();
|
|
75
|
+
this.#unreadable.clear();
|
|
76
|
+
await Promise.all(this.#roots.map((root) => this.#walk(root, null)));
|
|
77
|
+
this.#primed = true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Absolute paths whose content changed (or which disappeared) since the last call, with the baseline
|
|
83
|
+
* advanced to match. Returns an empty list until primed, so a failed prime degrades to watcher events
|
|
84
|
+
* rather than reporting the entire tree as changed.
|
|
85
|
+
*/
|
|
86
|
+
async collectChanges(): Promise<string[]> {
|
|
87
|
+
return this.#serialize(async () => {
|
|
88
|
+
if (!this.#primed) return [];
|
|
89
|
+
const changed = new Set<string>();
|
|
90
|
+
await this.#collectFileChanges(changed);
|
|
91
|
+
await this.#collectDirChanges(changed);
|
|
92
|
+
return [...changed];
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Every scan runs to completion before the next begins.
|
|
98
|
+
*
|
|
99
|
+
* Overlapping scans corrupt each other rather than merely racing: one advances the baseline and prunes
|
|
100
|
+
* entries while the other is midway through a key snapshot it took earlier, so a path the first has
|
|
101
|
+
* already accounted for reads back as missing and is reported as a change that never happened. Observed
|
|
102
|
+
* directly — a file nothing had written was reported, which downstream is a wasted rebuild.
|
|
103
|
+
*/
|
|
104
|
+
async #serialize<T>(work: () => Promise<T>): Promise<T> {
|
|
105
|
+
const run = this.#queue.then(work, work);
|
|
106
|
+
// Swallowed on the chain only; the caller still sees the rejection through `run`.
|
|
107
|
+
this.#queue = run.catch(() => undefined);
|
|
108
|
+
return run;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Adopt writes the caller made itself — regenerated barrels, inserted imports — into the baseline so
|
|
113
|
+
* the next `collectChanges` does not report them as a user edit.
|
|
114
|
+
*
|
|
115
|
+
* Narrow race worth knowing: a user save of the same path landing between the caller's write and this
|
|
116
|
+
* call is baselined too, and therefore lost. It is sub-millisecond and no wider than the window the
|
|
117
|
+
* event-based watcher already had.
|
|
118
|
+
*/
|
|
119
|
+
async absorb(paths: string[]): Promise<void> {
|
|
120
|
+
await this.#serialize(async () => {
|
|
121
|
+
if (!this.#primed) return;
|
|
122
|
+
await Promise.all(
|
|
123
|
+
paths.map(async (file) => {
|
|
124
|
+
const abs = path.resolve(file);
|
|
125
|
+
const stats = await stat(abs).catch(() => null);
|
|
126
|
+
if (!stats?.isFile()) {
|
|
127
|
+
this.#files.delete(abs);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.#files.set(abs, { mtimeMs: stats.mtimeMs, size: stats.size });
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async #collectFileChanges(changed: Set<string>): Promise<void> {
|
|
137
|
+
await Promise.all(
|
|
138
|
+
[...this.#files.keys()].map(async (abs) => {
|
|
139
|
+
const { stats, err } = await SourceMtimeIndex.#statPath(abs);
|
|
140
|
+
if (!stats) {
|
|
141
|
+
// Unreadable is not deleted. Dropping it here would both invent a change nobody made and stop
|
|
142
|
+
// tracking the file, so a later real edit would go unreported.
|
|
143
|
+
if (err && !SourceMtimeIndex.#isMissing(err)) {
|
|
144
|
+
this.#unreadable.set(abs, err.code ?? "EUNKNOWN");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.#files.delete(abs);
|
|
148
|
+
this.#unreadable.delete(abs);
|
|
149
|
+
changed.add(abs);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.#unreadable.delete(abs);
|
|
153
|
+
if (!stats.isFile()) {
|
|
154
|
+
this.#files.delete(abs);
|
|
155
|
+
changed.add(abs);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const known = this.#files.get(abs);
|
|
159
|
+
if (known && known.mtimeMs === stats.mtimeMs && known.size === stats.size) return;
|
|
160
|
+
this.#files.set(abs, { mtimeMs: stats.mtimeMs, size: stats.size });
|
|
161
|
+
changed.add(abs);
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A directory's own mtime moves when an entry is added or removed but not when a tracked file's
|
|
168
|
+
* content changes, so this finds creations and deletions the file pass cannot see.
|
|
169
|
+
*/
|
|
170
|
+
async #collectDirChanges(changed: Set<string>): Promise<void> {
|
|
171
|
+
const moved: string[] = [];
|
|
172
|
+
const gone: string[] = [];
|
|
173
|
+
await Promise.all(
|
|
174
|
+
[...this.#dirs.entries()].map(async ([dir, mtimeMs]) => {
|
|
175
|
+
const { stats, err } = await SourceMtimeIndex.#statPath(dir);
|
|
176
|
+
if (!stats?.isDirectory()) {
|
|
177
|
+
if (err && !SourceMtimeIndex.#isMissing(err)) {
|
|
178
|
+
this.#markUnreadable(dir, err);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
gone.push(dir);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// `UNREADABLE` is NaN, so a directory retained from a failed read always mismatches and is
|
|
185
|
+
// rewalked here — that retry is what lets a transient failure recover on its own.
|
|
186
|
+
if (stats.mtimeMs !== mtimeMs) moved.push(dir);
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
for (const dir of gone) this.#forget(dir);
|
|
190
|
+
// Sequential: a moved directory can reveal a new subtree, and walking those in order keeps the
|
|
191
|
+
// number of concurrent `readdir` calls proportional to the change rather than to the tree.
|
|
192
|
+
for (const dir of moved) await this.#walk(dir, changed);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Re-read one directory, tracking entries that are new or changed. Recurses only into subdirectories
|
|
197
|
+
* that are new to the index; known ones are covered by their own mtime check in `#collectDirChanges`.
|
|
198
|
+
*/
|
|
199
|
+
async #walk(dir: string, changed: Set<string> | null): Promise<void> {
|
|
200
|
+
const [listing, dirStat] = await Promise.all([SourceMtimeIndex.#readdirPath(dir), SourceMtimeIndex.#statPath(dir)]);
|
|
201
|
+
const entries = listing.entries;
|
|
202
|
+
const dirStats = dirStat.stats;
|
|
203
|
+
if (!entries || !dirStats?.isDirectory()) {
|
|
204
|
+
// A directory that cannot be read is not a directory that is gone. Forgetting it here is
|
|
205
|
+
// unrecoverable: nothing re-stats a dir the index has dropped, and the parent's own mtime does not
|
|
206
|
+
// move when a child merely becomes unreadable, so the whole subtree stays invisible for the life of
|
|
207
|
+
// the process. Measured: one `EACCES` at prime silently hid 2 of 3 files, permanently.
|
|
208
|
+
const err = listing.err ?? dirStat.err;
|
|
209
|
+
if (err && !SourceMtimeIndex.#isMissing(err)) {
|
|
210
|
+
this.#markUnreadable(dir, err);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.#forget(dir);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const known = this.#dirs.has(dir);
|
|
217
|
+
this.#dirs.set(dir, dirStats.mtimeMs);
|
|
218
|
+
this.#unreadable.delete(dir);
|
|
219
|
+
const descend: string[] = [];
|
|
220
|
+
const present = new Set<string>();
|
|
221
|
+
let blind = false;
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (SourceMtimeIndex.#skipDirent(entry.name)) continue;
|
|
224
|
+
const abs = path.join(dir, entry.name);
|
|
225
|
+
if (entry.isDirectory()) {
|
|
226
|
+
descend.push(abs);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!entry.isFile()) continue;
|
|
230
|
+
if (this.#classifier.classify(abs) === "ignore") continue;
|
|
231
|
+
present.add(abs);
|
|
232
|
+
const { stats, err } = await SourceMtimeIndex.#statPath(abs);
|
|
233
|
+
if (!stats?.isFile()) {
|
|
234
|
+
// A file listed but not stat-able has no baseline, so an edit to it would compare against nothing.
|
|
235
|
+
// Leaving the directory stale is what brings it back; the file pass cannot, since it only revisits
|
|
236
|
+
// paths already tracked.
|
|
237
|
+
if (err && !SourceMtimeIndex.#isMissing(err)) {
|
|
238
|
+
this.#unreadable.set(abs, err.code ?? "EUNKNOWN");
|
|
239
|
+
blind = true;
|
|
240
|
+
}
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
this.#unreadable.delete(abs);
|
|
244
|
+
const previous = this.#files.get(abs);
|
|
245
|
+
if (previous && previous.mtimeMs === stats.mtimeMs && previous.size === stats.size) continue;
|
|
246
|
+
this.#files.set(abs, { mtimeMs: stats.mtimeMs, size: stats.size });
|
|
247
|
+
changed?.add(abs);
|
|
248
|
+
}
|
|
249
|
+
// Entries this directory used to hold and no longer does. Only worth scanning for a directory the
|
|
250
|
+
// index already knew, which during `prime` is none of them.
|
|
251
|
+
if (known) {
|
|
252
|
+
for (const abs of [...this.#files.keys()]) {
|
|
253
|
+
if (present.has(abs) || path.dirname(abs) !== dir) continue;
|
|
254
|
+
this.#files.delete(abs);
|
|
255
|
+
changed?.add(abs);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (blind) this.#dirs.set(dir, UNREADABLE);
|
|
259
|
+
// Known subdirectories carry their own mtime check, so only unseen ones need walking. Concurrent
|
|
260
|
+
// because `prime` reaches every directory through here.
|
|
261
|
+
await Promise.all(descend.filter((sub) => !this.#dirs.has(sub)).map((sub) => this.#walk(sub, changed)));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Keep a directory the index could not read, flagged for retry on every scan, and keep whatever it
|
|
266
|
+
* already knows underneath it — those files stay tracked so a real edit is still caught the moment
|
|
267
|
+
* access returns.
|
|
268
|
+
*/
|
|
269
|
+
#markUnreadable(dir: string, err: NodeJS.ErrnoException): void {
|
|
270
|
+
this.#dirs.set(dir, UNREADABLE);
|
|
271
|
+
this.#unreadable.set(dir, err.code ?? "EUNKNOWN");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Drop a directory and everything the index holds beneath it. */
|
|
275
|
+
#forget(dir: string): void {
|
|
276
|
+
const prefix = `${dir}${path.sep}`;
|
|
277
|
+
this.#dirs.delete(dir);
|
|
278
|
+
this.#unreadable.delete(dir);
|
|
279
|
+
for (const known of [...this.#dirs.keys()]) if (known.startsWith(prefix)) this.#dirs.delete(known);
|
|
280
|
+
// Otherwise a gap under a deleted directory is reported forever, since nothing revisits it to clear.
|
|
281
|
+
for (const known of [...this.#unreadable.keys()]) if (known.startsWith(prefix)) this.#unreadable.delete(known);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** `null` stats with the errno kept, so callers can tell "not there" from "could not look". */
|
|
285
|
+
static async #statPath(abs: string): Promise<{ stats: Stats | null; err: NodeJS.ErrnoException | null }> {
|
|
286
|
+
try {
|
|
287
|
+
return { stats: await stat(abs), err: null };
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return { stats: null, err: err as NodeJS.ErrnoException };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
static async #readdirPath(dir: string): Promise<{ entries: Dirent[] | null; err: NodeJS.ErrnoException | null }> {
|
|
294
|
+
try {
|
|
295
|
+
return { entries: await readdir(dir, { withFileTypes: true }), err: null };
|
|
296
|
+
} catch (err) {
|
|
297
|
+
return { entries: null, err: err as NodeJS.ErrnoException };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Whether an errno means the path genuinely is not there, which is the only case where dropping it from
|
|
303
|
+
* the baseline is right. Everything else — `EACCES`, `EIO`, `EMFILE`, a stalled network mount — means the
|
|
304
|
+
* index failed to look, and treating that as a deletion both invents a change and blinds it to real ones.
|
|
305
|
+
*/
|
|
306
|
+
static #isMissing(err: NodeJS.ErrnoException): boolean {
|
|
307
|
+
return err.code === "ENOENT" || err.code === "ENOTDIR";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Mirrors `HmrChangeClassifier`'s path rules for directory names — dotted names cover `.git` and
|
|
312
|
+
* `.akan`, which is where every build artifact lands. Symlinked entries are neither `isFile` nor
|
|
313
|
+
* `isDirectory` and so are skipped, matching `fs.watch`, which does not follow them either.
|
|
314
|
+
*/
|
|
315
|
+
static #skipDirent(name: string): boolean {
|
|
316
|
+
return !name || name.startsWith(".") || name === "node_modules";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** `WatchRootResolver` can return `apps/<app>/page` alongside `apps/`; walking both doubles the work. */
|
|
320
|
+
static #pruneNestedRoots(roots: string[]): string[] {
|
|
321
|
+
const resolved = [...new Set(roots.map((root) => path.resolve(root)))].sort();
|
|
322
|
+
return resolved.filter(
|
|
323
|
+
(root) => !resolved.some((other) => other !== root && root.startsWith(`${other}${path.sep}`)),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { App } from "@akanjs/devkit/commandDecorators";
|
|
3
|
+
// Subpath imports only, and as few as possible: this process is spawned once per generation, so every
|
|
4
|
+
// eager import is paid on every save. Measured on a 177-route app: `executors` 24ms, `frontendBuild`
|
|
5
|
+
// ~110ms, app config 5ms.
|
|
6
|
+
import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
|
|
7
|
+
import { CsrArtifactBuilder, CssCompiler, FontOptimizer, PagesBundleBuilder } from "@akanjs/devkit/frontendBuild";
|
|
8
|
+
import { Logger } from "akanjs/common";
|
|
9
|
+
import type { BuilderMessage, BuildPhase } from "akanjs/server";
|
|
10
|
+
import type { BuildBatchRequest, BuildBatchResult, OptimizedFonts, PagesBatchCssAssets } from "./buildBatchProtocol";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One generation of frontend build work, in a process that exits when it is done.
|
|
14
|
+
*
|
|
15
|
+
* This exists for one reason: `Bun.build` retains native bundler arenas that the process never returns
|
|
16
|
+
* to the OS — `Bun.gc(true)` reclaims nothing and the JS heap stays flat while RSS climbs ~250MB per
|
|
17
|
+
* save. Exit is the only mechanism that gives that memory back, so the work that scales per save lives
|
|
18
|
+
* here rather than in the long-lived watcher.
|
|
19
|
+
*
|
|
20
|
+
* Nothing is cached here, by design. That costs less than it appears to: `CssCompiler` rebuilds its
|
|
21
|
+
* tailwind compilers on every `compileCss` call, and the watcher always asked for `refresh: true`, so
|
|
22
|
+
* there was no warm state to lose. What genuinely had to be preserved travels in the request — the
|
|
23
|
+
* validated page keys and the previous font optimization.
|
|
24
|
+
*/
|
|
25
|
+
class BuildBatch {
|
|
26
|
+
#logger = new Logger("BuildBatch");
|
|
27
|
+
#request: BuildBatchRequest;
|
|
28
|
+
#app: App;
|
|
29
|
+
#result: BuildBatchResult;
|
|
30
|
+
constructor(request: BuildBatchRequest, app: App) {
|
|
31
|
+
this.#request = request;
|
|
32
|
+
this.#app = app;
|
|
33
|
+
this.#result = { generation: request.generation, errors: {} };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async run(): Promise<BuildBatchResult> {
|
|
37
|
+
// Ordered the way the watcher used to run them: csr before pages so a csr failure cannot delay the
|
|
38
|
+
// pages bundle the browser is waiting on, and css last because it depends on the rebuilt client.
|
|
39
|
+
if (this.#request.needs.includes("csr")) await this.#buildCsr();
|
|
40
|
+
if (this.#request.needs.includes("pages")) await this.#buildPages();
|
|
41
|
+
if (this.#request.needs.includes("css")) await this.#buildCss();
|
|
42
|
+
return this.#result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Broadcast as soon as a need finishes rather than when the batch does. The browser is waiting on the
|
|
47
|
+
* pages bundle; making it wait for the css compile behind it would add latency the in-process version
|
|
48
|
+
* never had, and it would move every artifact write into the window right before the watcher reports
|
|
49
|
+
* the generation complete — which is where a save issued immediately afterwards gets dropped by Bun's
|
|
50
|
+
* recursive `fs.watch` (`local/optimize-resource/06-watcher-dropped-event.md`).
|
|
51
|
+
*/
|
|
52
|
+
#emit(message: BuilderMessage): void {
|
|
53
|
+
process.send?.(message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#emitStatus(phase: BuildPhase, message?: string): void {
|
|
57
|
+
this.#emit({
|
|
58
|
+
type: "build-status",
|
|
59
|
+
data: {
|
|
60
|
+
generation: this.#request.generation,
|
|
61
|
+
phase,
|
|
62
|
+
ok: !message,
|
|
63
|
+
files: this.#request.changedFiles,
|
|
64
|
+
message,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async #buildCsr(): Promise<void> {
|
|
70
|
+
const started = Date.now();
|
|
71
|
+
try {
|
|
72
|
+
await new CsrArtifactBuilder(this.#app).build();
|
|
73
|
+
this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
|
|
74
|
+
this.#emitStatus("csr");
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
77
|
+
this.#logger.error(`csr-rebundle failed: ${message}`);
|
|
78
|
+
this.#result.errors.csr = message;
|
|
79
|
+
this.#emitStatus("csr", message);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async #buildPages(): Promise<void> {
|
|
84
|
+
const started = Date.now();
|
|
85
|
+
try {
|
|
86
|
+
const next = await new PagesBundleBuilder(this.#app).build();
|
|
87
|
+
this.#emit({
|
|
88
|
+
type: "pages-updated",
|
|
89
|
+
data: {
|
|
90
|
+
bundlePath: next.bundlePath,
|
|
91
|
+
buildId: next.buildId,
|
|
92
|
+
generation: this.#request.generation,
|
|
93
|
+
changedFiles: this.#request.changedFiles,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
this.#emitStatus("pages");
|
|
97
|
+
this.#logger.verbose(`pages-rebundle ok buildId=${next.buildId} (${Date.now() - started}ms)`);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
100
|
+
this.#logger.error(`pages-rebundle failed: ${message}`);
|
|
101
|
+
this.#result.errors.pages = message;
|
|
102
|
+
this.#emitStatus("pages", message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async #buildCss(): Promise<void> {
|
|
107
|
+
const started = Date.now();
|
|
108
|
+
try {
|
|
109
|
+
const cssByBasePath = await new CssCompiler(this.#app).getCssByBasePath({ refresh: true });
|
|
110
|
+
const optimizedFonts = await this.#optimizeFonts();
|
|
111
|
+
const cssAssetEntries: Array<[string, { cssUrl: string; cssRelPath: string }]> = [];
|
|
112
|
+
const cssBase64ByUrl: Record<string, string> = {};
|
|
113
|
+
await Promise.all(
|
|
114
|
+
Object.entries(cssByBasePath).map(async ([basePath, baseCssText]) => {
|
|
115
|
+
const cssText = [baseCssText, optimizedFonts.css].filter(Boolean).join("\n");
|
|
116
|
+
if (!cssText) return;
|
|
117
|
+
const cssAssetName = basePath || "root";
|
|
118
|
+
const cssHash = Bun.hash(`${basePath}\n${cssText}`).toString(36);
|
|
119
|
+
const cssRelPath = `styles/${cssAssetName}-${cssHash}.css`;
|
|
120
|
+
const cssUrl = `/_akan/styles/${cssAssetName}-${cssHash}.css`;
|
|
121
|
+
await Bun.write(path.join(this.#request.artifactDir, cssRelPath), cssText);
|
|
122
|
+
cssAssetEntries.push([basePath, { cssUrl, cssRelPath }]);
|
|
123
|
+
cssBase64ByUrl[cssUrl] = Buffer.from(new TextEncoder().encode(cssText)).toString("base64");
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
const cssAssets = Object.fromEntries(cssAssetEntries) as PagesBatchCssAssets;
|
|
127
|
+
this.#result.cssAssets = cssAssets;
|
|
128
|
+
this.#emitStatus("css");
|
|
129
|
+
if (JSON.stringify(this.#request.cssAssets ?? {}) === JSON.stringify(cssAssets)) {
|
|
130
|
+
this.#logger.verbose("css-rebuild unchanged assets; broadcast skipped");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.#emit({
|
|
134
|
+
type: "css-updated",
|
|
135
|
+
data: {
|
|
136
|
+
cssAssets,
|
|
137
|
+
cssBase64ByUrl,
|
|
138
|
+
generation: this.#request.generation,
|
|
139
|
+
changedFiles: this.#request.changedFiles,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
this.#logger.verbose(`css-compile ok assets=${Object.keys(cssAssets).length} (${Date.now() - started}ms)`);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
145
|
+
this.#logger.error(`css-rebuild failed: ${message}`);
|
|
146
|
+
this.#result.errors.css = message;
|
|
147
|
+
this.#emitStatus("css", message);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Fonts are expensive and rarely change, so the previous result is reused unless this batch touched it. */
|
|
152
|
+
async #optimizeFonts(): Promise<OptimizedFonts> {
|
|
153
|
+
const previous = this.#request.optimizedFonts;
|
|
154
|
+
if (previous && !BuildBatch.#shouldReoptimizeFonts(previous, this.#request.changedFiles)) {
|
|
155
|
+
this.#logger.verbose(`font-optimize cached files=${previous.files.length}`);
|
|
156
|
+
return previous;
|
|
157
|
+
}
|
|
158
|
+
const started = Date.now();
|
|
159
|
+
const optimizedFonts = await new FontOptimizer(this.#app, "start").optimize();
|
|
160
|
+
this.#result.optimizedFonts = optimizedFonts;
|
|
161
|
+
this.#logger.verbose(`font-optimize ok files=${optimizedFonts.files.length} (${Date.now() - started}ms)`);
|
|
162
|
+
return optimizedFonts;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
static #shouldReoptimizeFonts(previous: OptimizedFonts, changedFiles: string[]): boolean {
|
|
166
|
+
if (changedFiles.length === 0) return false;
|
|
167
|
+
return changedFiles.some((file) => {
|
|
168
|
+
const normalized = path.resolve(file);
|
|
169
|
+
if (/\.(woff2?|ttf|otf)$/i.test(normalized)) return true;
|
|
170
|
+
return previous.files.some((fontFile) => path.resolve(fontFile) === normalized);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
static async main(): Promise<void> {
|
|
175
|
+
const raw = process.argv[2];
|
|
176
|
+
if (!raw) throw new Error("[build-batch] missing request argument");
|
|
177
|
+
const request = JSON.parse(raw) as BuildBatchRequest;
|
|
178
|
+
const workspace = WorkspaceExecutor.fromRoot({
|
|
179
|
+
workspaceRoot: request.workspaceRoot,
|
|
180
|
+
repoName: request.repoName,
|
|
181
|
+
});
|
|
182
|
+
const app = AppExecutor.from(workspace, request.appName);
|
|
183
|
+
// Seeded rather than rediscovered: the watcher already globbed and validated every route source,
|
|
184
|
+
// and repeating that here would be the single largest cost of spawning this process.
|
|
185
|
+
if (request.pageKeys) app.setPageKeys(request.pageKeys);
|
|
186
|
+
const result = await new BuildBatch(request, app).run();
|
|
187
|
+
process.send?.({ type: "build-batch-result", data: result });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
void BuildBatch.main().catch((err) => {
|
|
192
|
+
console.error(err);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FontOptimizer } from "@akanjs/devkit/frontendBuild";
|
|
2
|
+
import type { CssPayload, PagesBundlePayload } from "akanjs/server";
|
|
3
|
+
|
|
4
|
+
export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
|
|
5
|
+
|
|
6
|
+
export type BuildBatchNeed = "pages" | "css" | "csr";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One generation of build work, handed to a process that exits when it is done.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is JSON: the worker is spawned per batch, so state that the long-lived watcher
|
|
12
|
+
* caches for the session has to travel by value. Two fields exist purely to keep behaviour identical
|
|
13
|
+
* to the in-process version — `pageKeys` because rediscovering them costs ~220ms on a 177-route app
|
|
14
|
+
* (route-export validation), and `optimizedFonts` because fonts are only re-optimized when a font file
|
|
15
|
+
* actually changed.
|
|
16
|
+
*/
|
|
17
|
+
export interface BuildBatchRequest {
|
|
18
|
+
appName: string;
|
|
19
|
+
workspaceRoot: string;
|
|
20
|
+
repoName: string;
|
|
21
|
+
generation: number;
|
|
22
|
+
needs: BuildBatchNeed[];
|
|
23
|
+
changedFiles: string[];
|
|
24
|
+
/** Page keys the watcher already validated, or null to make the worker discover them itself. */
|
|
25
|
+
pageKeys: string[] | null;
|
|
26
|
+
/** Previous font optimization, reused unless this batch touched one of its files. */
|
|
27
|
+
optimizedFonts: OptimizedFonts | null;
|
|
28
|
+
/** Previous css assets, so an unchanged compile skips the broadcast instead of busting hashes. */
|
|
29
|
+
cssAssets: PagesBatchCssAssets | null;
|
|
30
|
+
/** Absolute artifact directory; the worker writes css assets under it. */
|
|
31
|
+
artifactDir: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type PagesBatchCssAssets = CssPayload["cssAssets"];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What the watcher folds back into its own state once the worker is done.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately small: the payloads browsers are waiting on are *streamed* as each need finishes
|
|
40
|
+
* (`pages-updated`, `css-updated`, `build-status`, relayed straight through), so a page reload is not
|
|
41
|
+
* held back by a css compile that has not started yet. Only state the next batch needs travels here.
|
|
42
|
+
*/
|
|
43
|
+
export interface BuildBatchResult {
|
|
44
|
+
generation: number;
|
|
45
|
+
cssAssets?: PagesBatchCssAssets;
|
|
46
|
+
optimizedFonts?: OptimizedFonts;
|
|
47
|
+
errors: Partial<Record<BuildBatchNeed, string>>;
|
|
48
|
+
/** The worker died before reporting, so it streamed no `build-status` of its own for these needs. */
|
|
49
|
+
crashed?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type BuildBatchMessage = { type: "build-batch-result"; data: BuildBatchResult };
|
|
53
|
+
export type { PagesBundlePayload };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Logger } from "akanjs/common";
|
|
3
|
+
import type { BuilderMessage } from "akanjs/server";
|
|
4
|
+
import type { BuildBatchMessage, BuildBatchRequest, BuildBatchResult } from "./buildBatchProtocol";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Runs one `BuildBatchRequest` in a fresh process and resolves with what it produced.
|
|
8
|
+
*
|
|
9
|
+
* The watcher serializes every batch through its own work queue, so this deliberately has no pool: one
|
|
10
|
+
* worker exists at a time, and it exits before the next one starts. That is the whole point — the
|
|
11
|
+
* bundler arenas `Bun.build` never frees go back to the OS with the process.
|
|
12
|
+
*
|
|
13
|
+
* A worker that dies without reporting is not fatal. Every need it was given comes back as an error, the
|
|
14
|
+
* watcher reports a red build-status for that generation, and the last-good artifact keeps serving —
|
|
15
|
+
* the same contract a failed in-process build had. It must never take the watcher down with it: the
|
|
16
|
+
* watcher is the dev server's file watcher, so nothing would notice the fix.
|
|
17
|
+
*/
|
|
18
|
+
export class BuildBatchRunner {
|
|
19
|
+
static readonly #entryCandidates = (workspaceRoot: string) => [
|
|
20
|
+
path.join(workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
21
|
+
path.join(workspaceRoot, "node_modules/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
22
|
+
path.join(import.meta.dir, "buildBatch.proc.js"),
|
|
23
|
+
path.join(import.meta.dir, "buildBatch.proc.ts"),
|
|
24
|
+
];
|
|
25
|
+
#logger = new Logger("BuildBatchRunner");
|
|
26
|
+
#entry: string | null = null;
|
|
27
|
+
#workspaceRoot: string;
|
|
28
|
+
#cwd: string;
|
|
29
|
+
constructor({ workspaceRoot, cwd }: { workspaceRoot: string; cwd: string }) {
|
|
30
|
+
this.#workspaceRoot = workspaceRoot;
|
|
31
|
+
this.#cwd = cwd;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async #resolveEntry(): Promise<string> {
|
|
35
|
+
if (this.#entry) return this.#entry;
|
|
36
|
+
const candidates = BuildBatchRunner.#entryCandidates(this.#workspaceRoot);
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
if (!(await Bun.file(candidate).exists())) continue;
|
|
39
|
+
this.#entry = candidate;
|
|
40
|
+
return candidate;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`[build-batch] worker entry not found; looked in: ${candidates.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `onMessage` receives everything the worker streams as it goes — `pages-updated`, `css-updated`,
|
|
47
|
+
* `build-status` — so the watcher can relay each one the moment it is produced instead of holding a
|
|
48
|
+
* page reload until the whole batch is done.
|
|
49
|
+
*/
|
|
50
|
+
async run(
|
|
51
|
+
request: BuildBatchRequest,
|
|
52
|
+
onMessage: (message: BuilderMessage) => void = () => undefined,
|
|
53
|
+
): Promise<BuildBatchResult> {
|
|
54
|
+
const started = Date.now();
|
|
55
|
+
const entry = await this.#resolveEntry();
|
|
56
|
+
let result: BuildBatchResult | null = null;
|
|
57
|
+
// The request travels in argv rather than over IPC so the worker can start on its first tick
|
|
58
|
+
// instead of waiting for a handshake it would have to synchronize against.
|
|
59
|
+
const proc = Bun.spawn(["bun", entry, JSON.stringify(request)], {
|
|
60
|
+
cwd: this.#cwd,
|
|
61
|
+
env: process.env,
|
|
62
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
63
|
+
serialization: "advanced",
|
|
64
|
+
ipc: (message: BuildBatchMessage | BuilderMessage) => {
|
|
65
|
+
if (!message || typeof message !== "object") return;
|
|
66
|
+
if (message.type === "build-batch-result") result = message.data;
|
|
67
|
+
else onMessage(message);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const exitCode = await proc.exited;
|
|
71
|
+
if (result) {
|
|
72
|
+
this.#logger.verbose(
|
|
73
|
+
`[build-batch] generation=${request.generation} needs=${request.needs.join(",")} done in ${Date.now() - started}ms`,
|
|
74
|
+
);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
const message = `build worker exited with code ${exitCode} before reporting a result`;
|
|
78
|
+
this.#logger.error(`[build-batch] generation=${request.generation} ${message}`);
|
|
79
|
+
return {
|
|
80
|
+
generation: request.generation,
|
|
81
|
+
errors: Object.fromEntries(request.needs.map((need) => [need, message])),
|
|
82
|
+
crashed: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|