@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.
@@ -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
+ }