@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.
- package/CHANGELOG.md +7 -0
- package/akanApp/akanApp.host.test.ts +202 -0
- package/akanApp/akanApp.host.ts +311 -27
- package/frontendBuild/devChangePlanner.ts +167 -0
- package/frontendBuild/devGeneratedIndexSync.ts +157 -0
- package/frontendBuild/frontendBuild.test.ts +89 -1
- package/frontendBuild/index.ts +2 -0
- package/incrementalBuilder/devWatchBatch.test.ts +59 -0
- package/incrementalBuilder/devWatchBatch.ts +48 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +69 -13
- package/incrementalBuilder/index.ts +1 -0
- package/integration/devStability.integration.test.ts +214 -0
- package/integration/devStabilityHarness.ts +444 -0
- package/lint/no-deep-internal-import.grit +2 -2
- package/package.json +2 -2
|
@@ -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");
|
package/frontendBuild/index.ts
CHANGED
|
@@ -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
|
+
};
|
|
@@ -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
|
-
|
|
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
|
|
237
|
-
if (
|
|
269
|
+
const rawKinds = new Set(batch.kinds);
|
|
270
|
+
if (rawKinds.size === 0) return;
|
|
238
271
|
const generation = ++this.#generation;
|
|
239
|
-
|
|
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?.(
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
343
|
+
this.scheduleCssRebuild(artifactDir, { refresh: true, generation, changedFiles: files });
|
|
288
344
|
this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
|
|
289
345
|
}
|
|
290
346
|
}
|