@akanjs/devkit 2.3.5 → 2.3.6-rc.1

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.
@@ -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";
@@ -0,0 +1,248 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { DevGeneratedIndexSync } from "../frontendBuild";
3
+ import { DevStabilityHarness } from "./devStabilityHarness";
4
+
5
+ const integrationEnabled = process.env.AKAN_DEV_STABILITY_INTEGRATION === "1";
6
+ const INTEGRATION_TIMEOUT_MS = 120_000;
7
+ const harnesses: DevStabilityHarness[] = [];
8
+
9
+ const integrationTest = (name: string, fn: () => Promise<void>): void => {
10
+ if (integrationEnabled) test(name, fn, INTEGRATION_TIMEOUT_MS);
11
+ else test.skip(name, fn);
12
+ };
13
+
14
+ const createHarness = async (): Promise<DevStabilityHarness> => {
15
+ const harness = new DevStabilityHarness();
16
+ harnesses.push(harness);
17
+ await harness.createFixture();
18
+ return harness;
19
+ };
20
+
21
+ const isRefreshMessage = (msg: unknown): boolean =>
22
+ typeof msg === "object" &&
23
+ msg !== null &&
24
+ "type" in msg &&
25
+ (msg.type === "client-refresh" || msg.type === "rsc-refresh" || msg.type === "reload");
26
+
27
+ const isBuildStatus =
28
+ (status: "error" | "ok") =>
29
+ (msg: unknown): boolean =>
30
+ typeof msg === "object" &&
31
+ msg !== null &&
32
+ "type" in msg &&
33
+ msg.type === "build-status" &&
34
+ "status" in msg &&
35
+ msg.status === status;
36
+
37
+ const waitForFileIncludes = async (filePath: string, text: string, timeoutMs = 5_000): Promise<string | null> => {
38
+ const started = Date.now();
39
+ while (Date.now() - started < timeoutMs) {
40
+ const file = Bun.file(filePath);
41
+ const contents = (await file.exists()) ? await file.text() : "";
42
+ if (contents.includes(text)) return contents;
43
+ await new Promise((resolve) => setTimeout(resolve, 100));
44
+ }
45
+ return null;
46
+ };
47
+
48
+ afterEach(async () => {
49
+ await Promise.all(harnesses.splice(0).map((harness) => harness.cleanup()));
50
+ });
51
+
52
+ describe("dev stability integration harness", () => {
53
+ integrationTest("server-only valid edits restart backend without client refresh", async () => {
54
+ const harness = await createHarness();
55
+ const host = await harness.startHost();
56
+ const hmr = await harness.tryConnectHmrProbe();
57
+ const mark = host.markLog();
58
+ const hmrMark = hmr?.mark() ?? 0;
59
+
60
+ await harness.replaceText("srvkit/backendMarker.ts", "initial-backend-marker", "updated-backend-marker");
61
+
62
+ await host.waitForLogSince(mark, /\[backend-reload\]|Shutting down gracefully|stopping backend/);
63
+ await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
64
+ expect(host.proc.killed).toBe(false);
65
+ expect(host.logs.join("").slice(mark)).not.toMatch(/\[hmr\].*(client-refresh|rsc-refresh)/);
66
+ await hmr?.waitForNoMessageSince(hmrMark, isRefreshMessage);
67
+ hmr?.close();
68
+ });
69
+
70
+ integrationTest("client-only valid edits refresh browser state without backend restart", async () => {
71
+ const harness = await createHarness();
72
+ const host = await harness.startHost();
73
+ const hmr = await harness.tryConnectHmrProbe();
74
+ const initialHtml = await harness.tryWaitForHttpText("initial-client-marker", 3_000);
75
+ if (!initialHtml) {
76
+ expect(host.proc.killed).toBe(false);
77
+ hmr?.close();
78
+ return;
79
+ }
80
+ const mark = host.markLog();
81
+ const hmrMark = hmr?.mark() ?? 0;
82
+
83
+ await harness.replaceText("ui/ClientMarker.tsx", "initial-client-marker", "updated-client-marker");
84
+
85
+ await host.waitForLogSince(mark, /\[dev-plan\].*roles=.*client.*actions=.*rebuild-client/);
86
+ if (hmr) {
87
+ const message = await hmr.waitForMessageSince(hmrMark, isRefreshMessage);
88
+ expect(message).toBeTruthy();
89
+ } else {
90
+ await host.waitForLogSince(mark, /\[hmr\].*(client-refresh|rsc-refresh|reload)|\[SSR\] pages-updated/);
91
+ }
92
+ expect(host.logs.join("").slice(mark)).not.toMatch(/\[backend-reload\]/);
93
+ hmr?.close();
94
+ });
95
+
96
+ integrationTest("shared valid edits rebuild client and restart backend in one generation", async () => {
97
+ const harness = await createHarness();
98
+ const host = await harness.startHost();
99
+ const hmr = await harness.tryConnectHmrProbe();
100
+ const initialHtml = await harness.tryWaitForHttpText("initial-shared-marker", 3_000);
101
+ if (!initialHtml) {
102
+ expect(host.proc.killed).toBe(false);
103
+ hmr?.close();
104
+ return;
105
+ }
106
+ const mark = host.markLog();
107
+ const hmrMark = hmr?.mark() ?? 0;
108
+
109
+ await harness.replaceText("common/marker.ts", "initial-shared-marker", "updated-shared-marker");
110
+
111
+ const plan = await host.waitForLogSince(
112
+ mark,
113
+ /\[dev-plan\] generation=(\d+).*roles=.*shared.*actions=.*rebuild-client.*restart-backend/,
114
+ );
115
+ const generation = plan[1];
116
+ await host.waitForLogSince(mark, new RegExp(`\\[backend-reload\\].*generation=${generation}`));
117
+ if (hmr)
118
+ await hmr.waitForMessageSince(
119
+ hmrMark,
120
+ (msg) =>
121
+ typeof msg === "object" && msg !== null && "generation" in msg && String(msg.generation) === generation,
122
+ );
123
+ else await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
124
+ await harness.waitForHttpText("updated-shared-marker");
125
+ hmr?.close();
126
+ });
127
+
128
+ integrationTest("dictionary edits recycle runtime metadata and replace stale snapshots", async () => {
129
+ const harness = await createHarness();
130
+ const host = await harness.startHost();
131
+ const hmr = await harness.tryConnectHmrProbe();
132
+ const initialHtml = await harness.tryWaitForHttpText("initial-shared-marker", 3_000);
133
+ if (!initialHtml) {
134
+ expect(host.proc.killed).toBe(false);
135
+ hmr?.close();
136
+ return;
137
+ }
138
+ const mark = host.markLog();
139
+
140
+ await harness.writeFile(
141
+ "lib/_fixture/fixture.dictionary.ts",
142
+ `import { serviceDictionary } from "akanjs/dictionary";
143
+
144
+ import type { FixtureEndpoint } from "./fixture.signal";
145
+
146
+ export const dictionary = serviceDictionary(["en", "ko"])
147
+ .endpoint<FixtureEndpoint>(() => ({}))
148
+ .translate({
149
+ hello: ["Updated Dictionary", "업데이트 사전"],
150
+ });
151
+ `,
152
+ );
153
+
154
+ await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-builder/);
155
+ await host.waitForLogSince(mark, /\[dev-host\] recycling builder\/backend for runtime metadata/);
156
+ await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
157
+ await harness.waitForHttpText("initial-shared-marker");
158
+ expect(host.proc.killed).toBe(false);
159
+ hmr?.close();
160
+ });
161
+
162
+ integrationTest("client build failure reports error and recovers after fix", async () => {
163
+ const harness = await createHarness();
164
+ const host = await harness.startHost();
165
+ const hmr = await harness.tryConnectHmrProbe();
166
+ const initialHtml = await harness.tryWaitForHttpText("initial-client-marker", 3_000);
167
+ if (!initialHtml) {
168
+ expect(host.proc.killed).toBe(false);
169
+ hmr?.close();
170
+ return;
171
+ }
172
+ const failureMark = host.markLog();
173
+ const failureHmrMark = hmr?.mark() ?? 0;
174
+
175
+ await harness.writeFile(
176
+ "ui/ClientMarker.tsx",
177
+ `export function ClientMarker() {
178
+ return <p>broken</p>
179
+ `,
180
+ );
181
+
182
+ await host.waitForLogSince(
183
+ failureMark,
184
+ /\[build-status\].*phase=pages.*ok=false|\[build-status\].*phase=csr.*ok=false/,
185
+ );
186
+ if (hmr) await hmr.waitForMessageSince(failureHmrMark, isBuildStatus("error"));
187
+ await harness.waitForHttpText("initial-client-marker");
188
+ const recoveryMark = host.markLog();
189
+ const recoveryHmrMark = hmr?.mark() ?? 0;
190
+
191
+ await harness.writeFile(
192
+ "ui/ClientMarker.tsx",
193
+ `export function ClientMarker() {
194
+ return <p data-testid="client-marker">recovered-client-marker</p>;
195
+ }
196
+ `,
197
+ );
198
+
199
+ await host.waitForLogSince(recoveryMark, /\[build-status\].*ok=true/);
200
+ if (hmr) await hmr.waitForMessageSince(recoveryHmrMark, isBuildStatus("ok"));
201
+ await harness.waitForHttpText("recovered-client-marker");
202
+ hmr?.close();
203
+ });
204
+
205
+ integrationTest("barrel add/delete includes generated indexes in watch generation", async () => {
206
+ const harness = await createHarness();
207
+ const sync = new DevGeneratedIndexSync({ workspaceRoot: harness.workspaceRoot });
208
+ const facets = ["common", "ui"] as const;
209
+
210
+ for (const facet of facets) {
211
+ const exportName = `${facet}TmpExample`;
212
+ const indexPath = `${facet}/index.ts`;
213
+ const absChangedFile = `${harness.appDir}/${facet}/tmpExample.ts`;
214
+ const absIndexPath = `${harness.appDir}/${indexPath}`;
215
+
216
+ await harness.writeFile(
217
+ `${facet}/tmpExample.ts`,
218
+ `export const ${exportName} = "added-${facet}-example";
219
+ `,
220
+ );
221
+
222
+ const added = await sync.syncForBatch([absChangedFile]);
223
+ expect(added.errors).toEqual([]);
224
+ expect(added.changedFiles).toContain(absIndexPath);
225
+ const addedIndex = await waitForFileIncludes(absIndexPath, "tmpExample");
226
+ expect(addedIndex).not.toBeNull();
227
+ expect(addedIndex ?? "").toContain("tmpExample");
228
+
229
+ await harness.removeFile(`${facet}/tmpExample.ts`);
230
+ const removed = await sync.syncForBatch([absChangedFile]);
231
+ expect(removed.errors).toEqual([]);
232
+ expect(removed.changedFiles).toContain(absIndexPath);
233
+ const deletedIndex = await waitForFileIncludes(absIndexPath, "tmpExample", 1_000);
234
+ if (deletedIndex) throw new Error(`${indexPath} still contains tmpExample after delete`);
235
+ const finalIndex = await Bun.file(absIndexPath).text();
236
+ expect(finalIndex).toBeString();
237
+ }
238
+ });
239
+
240
+ integrationTest("route and css phase-5 scope remains smoke-level in this harness", async () => {
241
+ const manualSmoke = [
242
+ "route add/delete should be covered by a later browser-driven test",
243
+ "css build failure should preserve active stylesheet and report build-status",
244
+ ];
245
+
246
+ expect(manualSmoke).toHaveLength(2);
247
+ });
248
+ });