@akanjs/devkit 2.3.5 → 2.3.6-rc.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.
@@ -0,0 +1,157 @@
1
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit"]);
5
+ const FACET_SOURCE_FILE_RE = /\.(ts|tsx)$/;
6
+ const FACET_EXCLUDED_FILE_RE = /(^index\.tsx?$|\.d\.ts$|\.(test|spec)\.(ts|tsx)$|\.css$|\.scss$|\.sass$)/;
7
+ const MODULE_UI_TYPES = ["Template", "Unit", "Util", "View", "Zone"] as const;
8
+ const SERVICE_UI_TYPES = ["Util", "Zone"] as const;
9
+ const SCALAR_UI_TYPES = ["Template", "Unit"] as const;
10
+
11
+ export interface GeneratedIndexSyncResult {
12
+ changedFiles: string[];
13
+ errors: string[];
14
+ }
15
+
16
+ export interface DevGeneratedIndexSyncOptions {
17
+ workspaceRoot: string;
18
+ }
19
+
20
+ export class DevGeneratedIndexSync {
21
+ readonly #workspaceRoot: string;
22
+
23
+ constructor({ workspaceRoot }: DevGeneratedIndexSyncOptions) {
24
+ this.#workspaceRoot = path.resolve(workspaceRoot);
25
+ }
26
+
27
+ async syncForBatch(files: string[]): Promise<GeneratedIndexSyncResult> {
28
+ const indexPaths = new Set<string>();
29
+ const errors: string[] = [];
30
+
31
+ for (const file of files) {
32
+ const facetIndex = this.#facetIndexFor(file);
33
+ if (facetIndex) indexPaths.add(facetIndex);
34
+ const moduleIndex = await this.#moduleIndexForDirectoryEvent(file).catch((err) => {
35
+ errors.push(`[generated-index] module detection failed for ${file}: ${formatError(err)}`);
36
+ return null;
37
+ });
38
+ if (moduleIndex) indexPaths.add(moduleIndex);
39
+ }
40
+
41
+ const changedFiles: string[] = [];
42
+ for (const indexPath of [...indexPaths].sort()) {
43
+ try {
44
+ const changed = await this.#syncIndex(indexPath);
45
+ if (changed) changedFiles.push(indexPath);
46
+ } catch (err) {
47
+ errors.push(`[generated-index] sync failed for ${indexPath}: ${formatError(err)}`);
48
+ }
49
+ }
50
+
51
+ return { changedFiles, errors };
52
+ }
53
+
54
+ #facetIndexFor(file: string): string | null {
55
+ const abs = path.resolve(file);
56
+ const rel = path.relative(this.#workspaceRoot, abs);
57
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
58
+ const parts = rel.split(path.sep).filter(Boolean);
59
+ if (parts.length < 4) return null;
60
+ const [scope, project, facet, child] = parts;
61
+ if ((scope !== "apps" && scope !== "libs") || !project || !facet || !BARREL_FACETS.has(facet)) return null;
62
+ if (!child || child.startsWith(".") || child === "index.ts" || child === "index.tsx") return null;
63
+ return path.join(this.#workspaceRoot, scope, project, facet, "index.ts");
64
+ }
65
+
66
+ async #moduleIndexForDirectoryEvent(file: string): Promise<string | null> {
67
+ const abs = path.resolve(file);
68
+ const rel = path.relative(this.#workspaceRoot, abs);
69
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
70
+ const parts = rel.split(path.sep).filter(Boolean);
71
+ if (parts.length !== 4 && parts.length !== 5) return null;
72
+ const [scope, project, libSegment, moduleSegment, scalarSegment] = parts;
73
+ if ((scope !== "apps" && scope !== "libs") || !project || libSegment !== "lib") return null;
74
+
75
+ const isExistingDirectory = await stat(abs)
76
+ .then((s) => s.isDirectory())
77
+ .catch(() => false);
78
+ const looksLikeDeletedDirectory = !path.extname(abs);
79
+ if (!isExistingDirectory && !looksLikeDeletedDirectory) return null;
80
+
81
+ if (moduleSegment === "__scalar" && scalarSegment) {
82
+ return path.join(this.#workspaceRoot, scope, project, "lib", "__scalar", scalarSegment, "index.ts");
83
+ }
84
+ if (!moduleSegment || moduleSegment === "__scalar") return null;
85
+ return path.join(this.#workspaceRoot, scope, project, "lib", moduleSegment, "index.ts");
86
+ }
87
+
88
+ async #syncIndex(indexPath: string): Promise<boolean> {
89
+ const dir = path.dirname(indexPath);
90
+ const content = await this.#contentForIndex(indexPath);
91
+ if (content === null) {
92
+ if (!(await exists(indexPath))) return false;
93
+ await rm(indexPath, { force: true });
94
+ return true;
95
+ }
96
+
97
+ const current = await readFile(indexPath, "utf8").catch(() => null);
98
+ if (current === content) return false;
99
+ await mkdir(dir, { recursive: true });
100
+ await writeFile(indexPath, content);
101
+ return true;
102
+ }
103
+
104
+ async #contentForIndex(indexPath: string): Promise<string | null> {
105
+ const parts = path.relative(this.#workspaceRoot, indexPath).split(path.sep).filter(Boolean);
106
+ const facet = parts.at(-2);
107
+ if (facet && BARREL_FACETS.has(facet)) return this.#facetContent(path.dirname(indexPath));
108
+ return this.#moduleContent(path.dirname(indexPath));
109
+ }
110
+
111
+ async #facetContent(dir: string): Promise<string | null> {
112
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
113
+ const exportNames = entries
114
+ .flatMap((entry) => {
115
+ const name = entry.name;
116
+ if (name.startsWith(".")) return [];
117
+ if (entry.isDirectory()) return [name];
118
+ if (!entry.isFile()) return [];
119
+ if (!FACET_SOURCE_FILE_RE.test(name) || FACET_EXCLUDED_FILE_RE.test(name)) return [];
120
+ return [name.replace(FACET_SOURCE_FILE_RE, "")];
121
+ })
122
+ .sort();
123
+ if (exportNames.length === 0) return null;
124
+ return `${exportNames.map((name) => `export * from "./${name}";`).join("\n")}\n`;
125
+ }
126
+
127
+ async #moduleContent(dir: string): Promise<string | null> {
128
+ const rel = path.relative(this.#workspaceRoot, dir);
129
+ const parts = rel.split(path.sep).filter(Boolean);
130
+ const moduleSegment = parts.at(-1);
131
+ if (!moduleSegment) return null;
132
+ const isScalar = parts.at(-2) === "__scalar";
133
+ const rawModel = moduleSegment.startsWith("_") ? moduleSegment.slice(1) : moduleSegment;
134
+ if (!rawModel) return null;
135
+ const modelName = capitalize(rawModel);
136
+ const allowedTypes = isScalar
137
+ ? SCALAR_UI_TYPES
138
+ : moduleSegment.startsWith("_")
139
+ ? SERVICE_UI_TYPES
140
+ : MODULE_UI_TYPES;
141
+ const fileTypes: string[] = [];
142
+ for (const type of allowedTypes) {
143
+ if (await exists(path.join(dir, `${modelName}.${type}.tsx`))) fileTypes.push(type);
144
+ }
145
+ if (fileTypes.length === 0) return null;
146
+ return `\n${fileTypes.map((type) => `import * as ${type} from "./${modelName}.${type}";`).join("\n")}\n\nexport const ${modelName} = { ${fileTypes.join(", ")} };`;
147
+ }
148
+ }
149
+
150
+ const exists = async (file: string) =>
151
+ stat(file)
152
+ .then(() => true)
153
+ .catch(() => false);
154
+
155
+ const capitalize = (value: string) => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
156
+
157
+ const formatError = (err: unknown) => (err instanceof Error ? err.message : String(err));
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -7,6 +7,8 @@ import type { RoutesManifest } from "akanjs/server";
7
7
  import { CsrArtifactBuilder } from "./csrArtifactBuilder";
8
8
  import { CssCompiler, isIgnoredNodeModuleSource } from "./cssCompiler";
9
9
  import { CssImportResolver } from "./cssImportResolver";
10
+ import { DevChangePlanner } from "./devChangePlanner";
11
+ import { DevGeneratedIndexSync } from "./devGeneratedIndexSync";
10
12
  import { HmrChangeClassifier } from "./hmrChangeClassifier";
11
13
  import { PagesBundleBuilder } from "./pagesBundleBuilder";
12
14
  import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
@@ -252,6 +254,92 @@ describe("HmrChangeClassifier", () => {
252
254
  });
253
255
  });
254
256
 
257
+ describe("DevGeneratedIndexSync", () => {
258
+ test("updates barrel facet index for file add and delete", async () => {
259
+ const root = await makeTempRoot();
260
+ const foo = path.join(root, "libs/shared/common/foo.ts");
261
+ const index = path.join(root, "libs/shared/common/index.ts");
262
+ const sync = new DevGeneratedIndexSync({ workspaceRoot: root });
263
+
264
+ await write(foo, "export const foo = 1;\n");
265
+ const added = await sync.syncForBatch([foo]);
266
+
267
+ expect(added.errors).toEqual([]);
268
+ expect(added.changedFiles).toEqual([index]);
269
+ expect(await readFile(index, "utf8")).toBe('export * from "./foo";\n');
270
+
271
+ await rm(foo);
272
+ const removed = await sync.syncForBatch([foo]);
273
+
274
+ expect(removed.errors).toEqual([]);
275
+ expect(removed.changedFiles).toEqual([index]);
276
+ expect(await Bun.file(index).exists()).toBe(false);
277
+ });
278
+
279
+ test("ignores server/client folders as barrel facets", async () => {
280
+ const root = await makeTempRoot();
281
+ const serverFile = path.join(root, "libs/shared/server/foo.ts");
282
+ const clientFile = path.join(root, "libs/shared/client/foo.ts");
283
+ const sync = new DevGeneratedIndexSync({ workspaceRoot: root });
284
+
285
+ await write(serverFile, "export const foo = 1;\n");
286
+ await write(clientFile, "export const foo = 1;\n");
287
+ const result = await sync.syncForBatch([serverFile, clientFile]);
288
+
289
+ expect(result.errors).toEqual([]);
290
+ expect(result.changedFiles).toEqual([]);
291
+ });
292
+
293
+ test("ignores individual module UI file changes for module index sync", async () => {
294
+ const root = await makeTempRoot();
295
+ const template = path.join(root, "libs/shared/lib/admin/Admin.Template.tsx");
296
+ const sync = new DevGeneratedIndexSync({ workspaceRoot: root });
297
+
298
+ await write(template, "export const Admin = () => null;\n");
299
+ const result = await sync.syncForBatch([template]);
300
+
301
+ expect(result.errors).toEqual([]);
302
+ expect(result.changedFiles).toEqual([]);
303
+ });
304
+ });
305
+
306
+ describe("DevChangePlanner", () => {
307
+ test("classifies server, client, shared, and generated barrel changes", () => {
308
+ const root = "/repo";
309
+ const planner = new DevChangePlanner({ workspaceRoot: root });
310
+ const generatedIndex = `${root}/libs/shared/common/index.ts`;
311
+ const plan = planner.plan({
312
+ generation: 7,
313
+ files: [
314
+ `${root}/libs/shared/lib/admin/admin.service.ts`,
315
+ `${root}/libs/shared/lib/admin/Admin.Template.tsx`,
316
+ `${root}/libs/shared/lib/admin/admin.constant.ts`,
317
+ `${root}/libs/shared/common/foo.ts`,
318
+ ],
319
+ kinds: ["code"],
320
+ generatedFiles: [generatedIndex],
321
+ });
322
+
323
+ expect(plan.generatedFiles).toEqual([generatedIndex]);
324
+ expect(plan.files).toContain(generatedIndex);
325
+ expect(plan.roles).toEqual(["barrel", "client", "server", "shared"]);
326
+ expect(plan.actions).toEqual(["rebuild-client", "restart-backend", "sync-generated"]);
327
+ expect(plan.reasonByFile[generatedIndex]).toContain("generated-index");
328
+ });
329
+
330
+ test("keeps css-only and config changes separate from backend restarts", () => {
331
+ const root = "/repo";
332
+ const planner = new DevChangePlanner({ workspaceRoot: root });
333
+
334
+ expect(
335
+ planner.plan({ generation: 1, files: [`${root}/apps/akan/page/style.css`], kinds: ["css"] }).actions,
336
+ ).toEqual(["rebuild-css"]);
337
+ expect(
338
+ planner.plan({ generation: 2, files: [`${root}/apps/akan/akan.config.ts`], kinds: ["config"] }).actions,
339
+ ).toEqual(["restart-dev-host"]);
340
+ });
341
+ });
342
+
255
343
  describe("CssImportResolver", () => {
256
344
  test("identifies package names and css files", () => {
257
345
  expect(CssImportResolver.getPackageName("@scope/pkg/button")).toBe("@scope/pkg");
@@ -5,6 +5,8 @@ export * from "./clientEntryDiscovery";
5
5
  export * from "./csrArtifactBuilder";
6
6
  export * from "./cssCompiler";
7
7
  export * from "./cssImportResolver";
8
+ export * from "./devChangePlanner";
9
+ export * from "./devGeneratedIndexSync";
8
10
  export * from "./fontOptimizer";
9
11
  export * from "./hmrChangeClassifier";
10
12
  export * from "./hmrWatcher";
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DevChangePlanner } from "../frontendBuild";
3
+ import { prepareDevWatchBatch } from "./devWatchBatch";
4
+
5
+ describe("prepareDevWatchBatch", () => {
6
+ test("includes generated indexes in the same invalidate generation", () => {
7
+ const root = "/repo";
8
+ const changedFile = `${root}/libs/shared/common/foo.ts`;
9
+ const generatedIndex = `${root}/libs/shared/common/index.ts`;
10
+ const prepared = prepareDevWatchBatch({
11
+ generation: 12,
12
+ batch: { files: [changedFile], kinds: new Set(["code"]) },
13
+ indexSync: { changedFiles: [generatedIndex], errors: [] },
14
+ changePlanner: new DevChangePlanner({ workspaceRoot: root }),
15
+ });
16
+
17
+ expect(prepared.hasSyncErrors).toBe(false);
18
+ expect(prepared.files).toEqual([changedFile, generatedIndex]);
19
+ expect(prepared.event.generation).toBe(12);
20
+ expect(prepared.event.files).toEqual(prepared.files);
21
+ expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
22
+ expect(prepared.event.devPlan?.files).toEqual(prepared.files);
23
+ });
24
+
25
+ test.each([
26
+ "common",
27
+ "srvkit",
28
+ "ui",
29
+ "webkit",
30
+ ])("keeps %s facet add/delete generated index in the same generation", (facet) => {
31
+ const root = "/repo";
32
+ const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
33
+ const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
34
+ const prepared = prepareDevWatchBatch({
35
+ generation: 20,
36
+ batch: { files: [changedFile], kinds: new Set(["code"]) },
37
+ indexSync: { changedFiles: [generatedIndex], errors: [] },
38
+ changePlanner: new DevChangePlanner({ workspaceRoot: root }),
39
+ });
40
+
41
+ expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
42
+ expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
43
+ expect(prepared.event.devPlan?.roles).toContain("barrel");
44
+ expect(prepared.event.devPlan?.actions).toContain("sync-generated");
45
+ });
46
+
47
+ test("marks failed generated index sync as an error generation", () => {
48
+ const root = "/repo";
49
+ const prepared = prepareDevWatchBatch({
50
+ generation: 13,
51
+ batch: { files: [`${root}/libs/shared/common/foo.ts`], kinds: new Set(["code"]) },
52
+ indexSync: { changedFiles: [], errors: ["sync failed"] },
53
+ changePlanner: new DevChangePlanner({ workspaceRoot: root }),
54
+ });
55
+
56
+ expect(prepared.hasSyncErrors).toBe(true);
57
+ expect(prepared.event.devPlan?.actions).toContain("report-error");
58
+ });
59
+ });
@@ -0,0 +1,48 @@
1
+ import type { BuilderEvent, ChangeBatch } from "akanjs/server";
2
+ import type { DevChangePlanner, GeneratedIndexSyncResult } from "../frontendBuild";
3
+
4
+ export interface PrepareDevWatchBatchOptions {
5
+ generation: number;
6
+ batch: ChangeBatch;
7
+ indexSync: GeneratedIndexSyncResult;
8
+ changePlanner: DevChangePlanner;
9
+ }
10
+
11
+ export interface PreparedDevWatchBatch {
12
+ files: string[];
13
+ kinds: ("code" | "css" | "config")[];
14
+ expandedBatch: ChangeBatch;
15
+ event: Extract<BuilderEvent, { type: "invalidate" }>;
16
+ hasSyncErrors: boolean;
17
+ }
18
+
19
+ export const prepareDevWatchBatch = ({
20
+ generation,
21
+ batch,
22
+ indexSync,
23
+ changePlanner,
24
+ }: PrepareDevWatchBatchOptions): PreparedDevWatchBatch => {
25
+ const files = [...new Set([...batch.files, ...indexSync.changedFiles])].sort();
26
+ const kindSet = new Set(batch.kinds);
27
+ if (indexSync.changedFiles.length > 0) kindSet.add("code");
28
+ const kinds = [...kindSet] as ("code" | "css" | "config")[];
29
+ const expandedBatch: ChangeBatch = { files, kinds: kindSet };
30
+ const devPlan = changePlanner.plan({
31
+ generation,
32
+ files,
33
+ kinds,
34
+ generatedFiles: indexSync.changedFiles,
35
+ });
36
+
37
+ if (indexSync.errors.length > 0 && !devPlan.actions.includes("report-error")) {
38
+ devPlan.actions = [...devPlan.actions, "report-error"].sort();
39
+ }
40
+
41
+ return {
42
+ files,
43
+ kinds,
44
+ expandedBatch,
45
+ event: { type: "invalidate", kinds, files, generation, devPlan },
46
+ hasSyncErrors: indexSync.errors.length > 0,
47
+ };
48
+ };
@@ -9,6 +9,7 @@ const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
9
9
  "invalidate",
10
10
  "css-updated",
11
11
  "pages-updated",
12
+ "build-status",
12
13
  ]);
13
14
  interface IncrementalBuilderHostOptions {
14
15
  app: App;
@@ -6,6 +6,8 @@ import {
6
6
  type ClientEntryDiscovery,
7
7
  CsrArtifactBuilder,
8
8
  type CssCompiler,
9
+ DevChangePlanner,
10
+ DevGeneratedIndexSync,
9
11
  FontOptimizer,
10
12
  GraphClientEntryDiscovery,
11
13
  HmrWatcher,
@@ -18,12 +20,13 @@ import {
18
20
  import { Logger } from "akanjs/common";
19
21
  import type {
20
22
  BaseBuildArtifact,
21
- BuilderEvent,
22
23
  BuilderMessage,
23
24
  BuilderReq,
24
25
  BuilderRes,
26
+ BuildPhase,
25
27
  BuildRouteResultPayload,
26
28
  } from "akanjs/server";
29
+ import { prepareDevWatchBatch } from "./devWatchBatch";
27
30
 
28
31
  interface IncrementalBuilderOptions {
29
32
  app: App;
@@ -42,6 +45,8 @@ class IncrementalBuilder {
42
45
  #cssCompiler: CssCompiler;
43
46
  #optimizedFonts: Awaited<ReturnType<FontOptimizer["optimize"]>>;
44
47
  #discovery: ClientEntryDiscovery;
48
+ #changePlanner: DevChangePlanner;
49
+ #generatedIndexSync: DevGeneratedIndexSync;
45
50
  #generation = 0;
46
51
  #workQueue: Promise<void> = Promise.resolve();
47
52
  #cssRebuildQueue: Promise<void> = Promise.resolve();
@@ -55,6 +60,8 @@ class IncrementalBuilder {
55
60
  this.#cssCompiler = options.cssCompiler;
56
61
  this.#optimizedFonts = options.optimizedFonts;
57
62
  this.#discovery = options.discovery;
63
+ this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
64
+ this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
58
65
  }
59
66
 
60
67
  async handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
@@ -72,6 +79,7 @@ class IncrementalBuilder {
72
79
  discovery: this.#discovery,
73
80
  }).build();
74
81
  this.#logger.verbose(`build-route ok routeId=${msg.routeId} newEntries=${delta.newEntries.length}`);
82
+ this.#sendBuildStatus("route", { generation: msg.generation, ok: true, files: msg.seeds });
75
83
  return {
76
84
  type: "build-route-res",
77
85
  id: msg.id,
@@ -90,9 +98,26 @@ class IncrementalBuilder {
90
98
  } catch (err) {
91
99
  const errMsg = err instanceof Error ? err.message : String(err);
92
100
  this.#logger.error(`build-route failed routeId=${msg.routeId}: ${errMsg}`);
101
+ this.#sendBuildStatus("route", { generation: msg.generation, ok: false, files: msg.seeds, message: errMsg });
93
102
  return { type: "build-route-res", id: msg.id, ok: false, error: errMsg };
94
103
  }
95
104
  }
105
+ #sendBuildStatus(
106
+ phase: BuildPhase,
107
+ { generation, ok, files, message }: { generation?: number; ok: boolean; files?: string[]; message?: string },
108
+ ): void {
109
+ if (typeof generation !== "number") return;
110
+ process.send?.({
111
+ type: "build-status",
112
+ data: {
113
+ generation,
114
+ phase,
115
+ ok,
116
+ files: files ?? [],
117
+ message,
118
+ },
119
+ });
120
+ }
96
121
  async #enqueueWork<T>(label: string, fn: () => Promise<T>): Promise<T> {
97
122
  const started = Date.now();
98
123
  const run = this.#workQueue.then(fn, fn);
@@ -191,10 +216,18 @@ class IncrementalBuilder {
191
216
  generation: next.generation,
192
217
  changedFiles: next.changedFiles,
193
218
  });
219
+ this.#sendBuildStatus("css", { generation: next.generation, ok: true, files: next.changedFiles });
194
220
  this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
195
221
  })
196
222
  .catch((err) => {
197
- this.#logger.error(`css-rebuild failed: ${err instanceof Error ? err.message : err}`);
223
+ const message = err instanceof Error ? err.message : String(err);
224
+ this.#logger.error(`css-rebuild failed: ${message}`);
225
+ this.#sendBuildStatus("css", {
226
+ generation: next.generation,
227
+ ok: false,
228
+ files: next.changedFiles,
229
+ message,
230
+ });
198
231
  });
199
232
  }, 150);
200
233
  }
@@ -233,25 +266,43 @@ class IncrementalBuilder {
233
266
  }
234
267
 
235
268
  async #handleWatchBatch(appDir: string, artifactDir: string, batch: ChangeBatch) {
236
- const kinds = [...batch.kinds] as ("code" | "css" | "config")[];
237
- if (kinds.length === 0) return;
269
+ const rawKinds = new Set(batch.kinds);
270
+ if (rawKinds.size === 0) return;
238
271
  const generation = ++this.#generation;
239
- this.#logger.verbose(`[hmr] batch generation=${generation} kinds=${kinds.join(",")} files=${batch.files.length}`);
272
+ const indexSync = await this.#generatedIndexSync.syncForBatch(batch.files);
273
+ const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
274
+ generation,
275
+ batch,
276
+ indexSync,
277
+ changePlanner: this.#changePlanner,
278
+ });
279
+ const devPlan = event.devPlan;
280
+ this.#logger.verbose(
281
+ `[hmr] batch generation=${generation} kinds=${kinds.join(",")} files=${files.length} generated=${indexSync.changedFiles.length} roles=${devPlan.roles.join(",") || "(none)"} actions=${devPlan.actions.join(",") || "(none)"}`,
282
+ );
283
+ for (const error of indexSync.errors) this.#logger.error(error);
240
284
 
241
285
  if (kinds.includes("code")) {
242
286
  const started = Date.now();
243
287
  if (kinds.includes("config")) this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
244
- else this.#discovery.invalidate?.(batch.files);
288
+ else this.#discovery.invalidate?.(files);
245
289
  this.#logger.verbose(
246
290
  `client-entry-discovery ${kinds.includes("config") ? "refreshed" : "invalidated"} (${Date.now() - started}ms)`,
247
291
  );
248
292
  }
249
293
 
250
- if (kinds.includes("code") && (await this.batchMayChangePageKeys(appDir, batch))) {
294
+ if (hasSyncErrors) {
295
+ this.#sendBuildStatus("barrel", { generation, ok: false, files, message: indexSync.errors.join("\n") });
296
+ process.send?.(event);
297
+ return;
298
+ }
299
+ if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
300
+
301
+ if (kinds.includes("code") && (await this.batchMayChangePageKeys(appDir, expandedBatch))) {
251
302
  const started = Date.now();
252
303
  await this.#app.getPageKeys({ refresh: true });
253
304
  this.#logger.verbose(`pageKeys updated, app pageKeys are refreshed (${Date.now() - started}ms)`);
254
- } else if (kinds.includes("code") && this.batchTouchesPagesTree(appDir, batch)) {
305
+ } else if (kinds.includes("code") && this.batchTouchesPagesTree(appDir, expandedBatch)) {
255
306
  this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
256
307
  }
257
308
 
@@ -259,15 +310,17 @@ class IncrementalBuilder {
259
310
  try {
260
311
  const started = Date.now();
261
312
  await new CsrArtifactBuilder(this.#app).build();
313
+ this.#sendBuildStatus("csr", { generation, ok: true, files });
262
314
  this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
263
315
  } catch (err) {
264
- this.#logger.error(`csr-rebundle failed: ${err instanceof Error ? err.message : err}`);
316
+ const message = err instanceof Error ? err.message : String(err);
317
+ this.#logger.error(`csr-rebundle failed: ${message}`);
318
+ this.#sendBuildStatus("csr", { generation, ok: false, files, message });
265
319
  }
266
320
  } else if (kinds.includes("code")) {
267
321
  this.#logger.verbose(`csr-rebundle skipped; set AKAN_DEV_CSR_REBUILD=1 to enable per-save CSR rebuilds`);
268
322
  }
269
323
 
270
- const event: BuilderEvent = { type: "invalidate", kinds, files: batch.files, generation };
271
324
  process.send?.(event);
272
325
 
273
326
  if (kinds.includes("code")) {
@@ -276,15 +329,18 @@ class IncrementalBuilder {
276
329
  const next = await new PagesBundleBuilder(this.#app).build();
277
330
  process.send?.({
278
331
  type: "pages-updated",
279
- data: { bundlePath: next.bundlePath, buildId: next.buildId, generation, changedFiles: batch.files },
332
+ data: { bundlePath: next.bundlePath, buildId: next.buildId, generation, changedFiles: files },
280
333
  });
334
+ this.#sendBuildStatus("pages", { generation, ok: true, files });
281
335
  this.#logger.verbose(`pages-rebundle ok buildId=${next.buildId} (${Date.now() - started}ms)`);
282
336
  } catch (err) {
283
- this.#logger.error(`pages-rebundle failed: ${err instanceof Error ? err.message : err}`);
337
+ const message = err instanceof Error ? err.message : String(err);
338
+ this.#logger.error(`pages-rebundle failed: ${message}`);
339
+ this.#sendBuildStatus("pages", { generation, ok: false, files, message });
284
340
  }
285
341
  }
286
342
  if (kinds.includes("code") || kinds.includes("css")) {
287
- this.scheduleCssRebuild(artifactDir, { refresh: true, generation, changedFiles: batch.files });
343
+ this.scheduleCssRebuild(artifactDir, { refresh: true, generation, changedFiles: files });
288
344
  this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
289
345
  }
290
346
  }
@@ -1 +1,2 @@
1
+ export * from "./devWatchBatch";
1
2
  export * from "./incrementalBuilder.host";