@akanjs/devkit 3.0.0-beta.22 → 3.0.0-beta.24
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/cloud/globalConfig.ts +16 -1
- package/csrE2e/csrE2eHarness.fixture.ts +400 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +187 -0
- package/frontendBuild/csrDevArtifactWriter.ts +169 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +256 -0
- package/frontendBuild/csrDevModuleCompiler.ts +262 -0
- package/frontendBuild/csrDevPaths.ts +64 -0
- package/frontendBuild/csrDevResolver.ts +182 -0
- package/frontendBuild/csrDevTypes.ts +46 -0
- package/frontendBuild/frontendBuild.test.ts +11 -1
- package/frontendBuild/index.ts +1 -0
- package/frontendBuild/pagesEntrySourceGenerator.ts +48 -5
- package/frontendBuild/ssrBaseArtifactBuilder.ts +8 -1
- package/incrementalBuilder/buildBatch.proc.ts +37 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -0
- package/integration/devStability.integration.test.ts +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { CsrE2eHarness } from "./csrE2eHarness.fixture";
|
|
3
|
+
|
|
4
|
+
const TAB_A = "/e2e/stack/tab-a";
|
|
5
|
+
const TAB_B = "/e2e/stack/tab-b";
|
|
6
|
+
const item = (id: string) => `/e2e/stack/item?id=${id}`;
|
|
7
|
+
|
|
8
|
+
const routeOf = (pathWithSearch: string) => pathWithSearch.split("?")[0].replace(/^\/[^/]+/, "/:lang");
|
|
9
|
+
const CURRENT = '[id^="pageContainer-"]:not([aria-hidden="true"])';
|
|
10
|
+
const UNDER = '[id^="pageContainer-"][aria-hidden="true"]';
|
|
11
|
+
|
|
12
|
+
describe.skipIf(!CsrE2eHarness.enabled)("CSR page stack (minimal, /e2e/stack)", () => {
|
|
13
|
+
let csr: CsrE2eHarness;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
csr = await CsrE2eHarness.start({ app: "minimal" });
|
|
17
|
+
}, 240_000);
|
|
18
|
+
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
// biome-ignore lint/suspicious/noUnnecessaryConditions: unassigned when beforeAll threw before the harness started
|
|
21
|
+
await csr?.close();
|
|
22
|
+
}, 60_000);
|
|
23
|
+
|
|
24
|
+
const itemIdsIn = async (scope: string, options?: { mounted?: boolean }) =>
|
|
25
|
+
await csr.text(`${scope} [data-e2e-probe="item"] [data-e2e="item-id"]`, options);
|
|
26
|
+
const currentInput = `${CURRENT} [data-e2e-probe="item"] [data-e2e="input"]`;
|
|
27
|
+
|
|
28
|
+
test("an entry of the same route is its own page, and the one it covers waits under it with its state", async () => {
|
|
29
|
+
await csr.open(TAB_A);
|
|
30
|
+
await csr.navigate(item("1"));
|
|
31
|
+
await csr.type(currentInput, "first");
|
|
32
|
+
await csr.navigate(item("2"));
|
|
33
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["2"]);
|
|
34
|
+
expect(await itemIdsIn(UNDER)).toEqual(["1"]);
|
|
35
|
+
expect(await csr.values(currentInput)).toEqual([""]);
|
|
36
|
+
await csr.back();
|
|
37
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["1"]);
|
|
38
|
+
expect(await csr.values(currentInput)).toEqual(["first"]);
|
|
39
|
+
expect(await csr.reloaded()).toBe(false);
|
|
40
|
+
}, 60_000);
|
|
41
|
+
|
|
42
|
+
test("the stack keeps three hidden entries under the previous one, releases older ones, and reveals each on back", async () => {
|
|
43
|
+
await csr.open(TAB_A);
|
|
44
|
+
for (const id of ["11", "12", "13"]) await csr.navigate(item(id));
|
|
45
|
+
await csr.type(currentInput, "thirteen");
|
|
46
|
+
for (const id of ["14", "15", "16"]) await csr.navigate(item(id));
|
|
47
|
+
expect((await itemIdsIn('[id^="pageContainer-"]', { mounted: true })).sort()).toEqual([
|
|
48
|
+
"12",
|
|
49
|
+
"13",
|
|
50
|
+
"14",
|
|
51
|
+
"15",
|
|
52
|
+
"16",
|
|
53
|
+
]);
|
|
54
|
+
for (const expected of ["15", "14", "13"]) {
|
|
55
|
+
await csr.back();
|
|
56
|
+
expect(await itemIdsIn(CURRENT)).toEqual([expected]);
|
|
57
|
+
}
|
|
58
|
+
expect(await csr.values(currentInput)).toEqual(["thirteen"]);
|
|
59
|
+
await csr.back();
|
|
60
|
+
await csr.back();
|
|
61
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["11"]);
|
|
62
|
+
expect(await csr.values(currentInput)).toEqual([""]);
|
|
63
|
+
expect(await csr.reloaded()).toBe(false);
|
|
64
|
+
}, 90_000);
|
|
65
|
+
|
|
66
|
+
test("a replace within one route updates the page in place", async () => {
|
|
67
|
+
await csr.open(item("21"));
|
|
68
|
+
await csr.type(currentInput, "kept");
|
|
69
|
+
await csr.navigate(item("22"), "replace");
|
|
70
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["22"]);
|
|
71
|
+
expect(await csr.values(currentInput)).toEqual(["kept"]);
|
|
72
|
+
expect((await csr.containers()).filter((container) => container.path.endsWith("/item"))).toHaveLength(1);
|
|
73
|
+
}, 60_000);
|
|
74
|
+
|
|
75
|
+
test("the page the session opened on is cached like any other cached page", async () => {
|
|
76
|
+
await csr.open(TAB_A);
|
|
77
|
+
const mountedAs = (await csr.probe("tab-a"))?.mountId;
|
|
78
|
+
await csr.navigate(TAB_B);
|
|
79
|
+
await csr.navigate(item("3"));
|
|
80
|
+
await csr.navigate(TAB_A);
|
|
81
|
+
expect((await csr.probe("tab-a"))?.mountId).toBe(mountedAs);
|
|
82
|
+
expect(await csr.reloaded()).toBe(false);
|
|
83
|
+
}, 60_000);
|
|
84
|
+
|
|
85
|
+
test("a cached page that is not visible pauses its effects and resumes them with its state", async () => {
|
|
86
|
+
await csr.open(TAB_B);
|
|
87
|
+
await csr.navigate(TAB_A);
|
|
88
|
+
const mountedAs = (await csr.probe("tab-a"))?.mountId;
|
|
89
|
+
await csr.navigate(TAB_B);
|
|
90
|
+
await csr.navigate(item("4"));
|
|
91
|
+
expect((await csr.containers()).find((container) => container.path.endsWith("/tab-a"))?.hidden).toBe(true);
|
|
92
|
+
expect(await csr.ticksOver("tab-a")).toBe(0);
|
|
93
|
+
await csr.navigate(TAB_A);
|
|
94
|
+
expect((await csr.probe("tab-a"))?.mountId).toBe(mountedAs);
|
|
95
|
+
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
96
|
+
}, 60_000);
|
|
97
|
+
|
|
98
|
+
test("the page a transition-less switch left pauses at once, and runs again when switched back", async () => {
|
|
99
|
+
await csr.open(TAB_A);
|
|
100
|
+
const mountedAs = (await csr.probe("tab-a"))?.mountId;
|
|
101
|
+
await csr.navigate(TAB_B);
|
|
102
|
+
expect(await csr.ticksOver("tab-a")).toBe(0);
|
|
103
|
+
await csr.navigate(TAB_A);
|
|
104
|
+
expect((await csr.probe("tab-a"))?.mountId).toBe(mountedAs);
|
|
105
|
+
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
106
|
+
}, 60_000);
|
|
107
|
+
|
|
108
|
+
test("every page container but the current one is inert and hidden from assistive tech", async () => {
|
|
109
|
+
await csr.open(TAB_A);
|
|
110
|
+
await csr.navigate(TAB_B);
|
|
111
|
+
await csr.navigate(item("5"));
|
|
112
|
+
const current = routeOf(await csr.currentPath());
|
|
113
|
+
const containers = await csr.containers();
|
|
114
|
+
expect(containers.filter((container) => !container.inert).map((container) => container.path)).toEqual([current]);
|
|
115
|
+
expect(containers.every((container) => container.inert === container.ariaHidden)).toBe(true);
|
|
116
|
+
}, 60_000);
|
|
117
|
+
|
|
118
|
+
test("only the current page offers the agent its tools, not the page kept under it for a swipe back", async () => {
|
|
119
|
+
await csr.open(TAB_A);
|
|
120
|
+
expect(await csr.agentTools()).toContain("bumpTabA");
|
|
121
|
+
await csr.navigate(item("7"));
|
|
122
|
+
const tools = await csr.agentTools();
|
|
123
|
+
expect(tools).toContain("bumpItem");
|
|
124
|
+
expect(tools).not.toContain("bumpTabA");
|
|
125
|
+
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
126
|
+
await csr.back();
|
|
127
|
+
const back = await csr.agentTools();
|
|
128
|
+
expect(back).toContain("bumpTabA");
|
|
129
|
+
expect(back).not.toContain("bumpItem");
|
|
130
|
+
}, 60_000);
|
|
131
|
+
|
|
132
|
+
test("a page is focused while current and settled; the page under it stays live but unfocused", async () => {
|
|
133
|
+
await csr.open(TAB_A);
|
|
134
|
+
expect((await csr.probe("tab-a"))?.focused).toBe(true);
|
|
135
|
+
const focusCount = (await csr.probe("tab-a"))?.focusCount ?? 0;
|
|
136
|
+
await csr.navigate(item("8"));
|
|
137
|
+
expect(await csr.probe("tab-a")).toMatchObject({ activity: "prev", focused: false, mounted: true });
|
|
138
|
+
expect(await csr.probe("item")).toMatchObject({ activity: "current", focused: true });
|
|
139
|
+
await csr.back();
|
|
140
|
+
expect(await csr.probe("tab-a")).toMatchObject({ activity: "current", focused: true, focusCount: focusCount + 1 });
|
|
141
|
+
}, 60_000);
|
|
142
|
+
|
|
143
|
+
test("a reload keeps the stack: the page under the current one is back, the rest waits, and back walks it", async () => {
|
|
144
|
+
const runtimeItem = (id: string) => `${item(id)}&csr=true&akanMobileTarget=e2e`;
|
|
145
|
+
await csr.open(TAB_A);
|
|
146
|
+
await csr.navigate(runtimeItem("31"));
|
|
147
|
+
await csr.navigate(runtimeItem("32"));
|
|
148
|
+
await csr.reload();
|
|
149
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["32"]);
|
|
150
|
+
expect(await itemIdsIn(UNDER)).toEqual(["31"]);
|
|
151
|
+
expect((await csr.containers()).some((container) => container.path.endsWith("/tab-a"))).toBe(false);
|
|
152
|
+
//? The first back after a reload mounts the entry under the one it reveals, which a boot left dormant.
|
|
153
|
+
await csr.back();
|
|
154
|
+
await csr.waitForText(`${CURRENT} [data-e2e-probe="item"] [data-e2e="item-id"]`, ["31"]);
|
|
155
|
+
await csr.back();
|
|
156
|
+
expect(routeOf(await csr.currentPath())).toBe("/:lang/e2e/stack/tab-a");
|
|
157
|
+
expect((await csr.probe("tab-a"))?.mounted).toBe(true);
|
|
158
|
+
expect(await csr.reloaded()).toBe(false);
|
|
159
|
+
}, 90_000);
|
|
160
|
+
|
|
161
|
+
test("a popstate several entries away lands on that entry", async () => {
|
|
162
|
+
await csr.open(TAB_A);
|
|
163
|
+
for (const id of ["41", "42", "43"]) await csr.navigate(item(id));
|
|
164
|
+
await csr.go(-2);
|
|
165
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["41"]);
|
|
166
|
+
expect(await csr.reloaded()).toBe(false);
|
|
167
|
+
}, 60_000);
|
|
168
|
+
|
|
169
|
+
test("the page under the current one pauses while the app is in the background", async () => {
|
|
170
|
+
await csr.open(TAB_A);
|
|
171
|
+
await csr.navigate(item("51"));
|
|
172
|
+
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
173
|
+
await csr.setVisibility("hidden");
|
|
174
|
+
expect(await csr.ticksOver("tab-a")).toBe(0);
|
|
175
|
+
await csr.setVisibility("visible");
|
|
176
|
+
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
177
|
+
}, 60_000);
|
|
178
|
+
|
|
179
|
+
test("back returns to the previous entry without a reload", async () => {
|
|
180
|
+
await csr.open(TAB_A);
|
|
181
|
+
await csr.navigate(item("6"));
|
|
182
|
+
await csr.back();
|
|
183
|
+
expect(routeOf(await csr.currentPath())).toBe("/:lang/e2e/stack/tab-a");
|
|
184
|
+
expect(await csr.text('[data-e2e-probe="tab-a"] [data-e2e="item-id"]')).toHaveLength(1);
|
|
185
|
+
expect(await csr.reloaded()).toBe(false);
|
|
186
|
+
}, 60_000);
|
|
187
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { mkdir, readdir, rename, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
CSR_DEV_APP_FILE,
|
|
5
|
+
CSR_DEV_MANIFEST_FILE,
|
|
6
|
+
CSR_DEV_ROUTE_PREFIX,
|
|
7
|
+
type CsrDevLayout,
|
|
8
|
+
type CsrDevManifest,
|
|
9
|
+
csrDevModuleFile,
|
|
10
|
+
} from "akanjs/server/hmr/csrDevManifest";
|
|
11
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
12
|
+
import type { CsrDevCompiledModule, CsrDevGraph } from "./csrDevTypes";
|
|
13
|
+
|
|
14
|
+
export class CsrDevArtifactWriter {
|
|
15
|
+
static readonly #keptPatches = 40;
|
|
16
|
+
static readonly #keptVendors = 3;
|
|
17
|
+
readonly #outDir: string;
|
|
18
|
+
|
|
19
|
+
constructor(outDir: string) {
|
|
20
|
+
this.#outDir = outDir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async reset(): Promise<void> {
|
|
24
|
+
await rm(this.#outDir, { recursive: true, force: true });
|
|
25
|
+
await mkdir(path.join(this.#outDir, "modules"), { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async readJson<T>(name: string): Promise<T | null> {
|
|
29
|
+
const file = Bun.file(path.join(this.#outDir, name));
|
|
30
|
+
if (!(await file.exists())) return null;
|
|
31
|
+
return (await file.json().catch(() => null)) as T | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async writeJson(name: string, value: unknown): Promise<void> {
|
|
35
|
+
await this.#writeAtomic(name, JSON.stringify(value));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async writeModules(compiled: CsrDevCompiledModule[]): Promise<void> {
|
|
39
|
+
await mkdir(path.join(this.#outDir, "modules"), { recursive: true });
|
|
40
|
+
const helpers = new Map(
|
|
41
|
+
compiled.flatMap((module) => (module.helpers ? [[module.helpers.hash, module.helpers]] : [])),
|
|
42
|
+
);
|
|
43
|
+
await Promise.all([
|
|
44
|
+
...compiled.flatMap((module) => [
|
|
45
|
+
Bun.write(this.#modulePath(module.id, ".js"), module.factory),
|
|
46
|
+
...(module.sourceMap ? [Bun.write(this.#modulePath(module.id, ".js.map"), module.sourceMap)] : []),
|
|
47
|
+
]),
|
|
48
|
+
...[...helpers.values()].map((shared) => Bun.write(this.#helpersPath(shared.hash), shared.definition)),
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async forgetModules(ids: string[]): Promise<void> {
|
|
53
|
+
await Promise.all(
|
|
54
|
+
ids.flatMap((id) => [
|
|
55
|
+
rm(this.#modulePath(id, ".js"), { force: true }),
|
|
56
|
+
rm(this.#modulePath(id, ".js.map"), { force: true }),
|
|
57
|
+
]),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async writeVendor(graph: CsrDevGraph): Promise<string> {
|
|
62
|
+
const ids = Object.keys(graph.modules)
|
|
63
|
+
.filter((id) => graph.modules[id]?.vendor)
|
|
64
|
+
.sort();
|
|
65
|
+
const stubs = new Set(Object.values(graph.modules).flatMap((module) => module.deps.filter(CsrDevPaths.isStub)));
|
|
66
|
+
const lines = [await this.#helperDefinitions(graph, ids), ...(await this.#defineLines(ids))];
|
|
67
|
+
for (const stub of [...stubs].sort()) {
|
|
68
|
+
const message = `[akan-csr] ${stub.slice(CsrDevPaths.stubPrefix.length)} is a Node built-in the browser does not have`;
|
|
69
|
+
lines.push(
|
|
70
|
+
`__akan.define(${JSON.stringify(stub)}, function () {\n throw new Error(${JSON.stringify(message)});\n});\n`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const code = lines.join("");
|
|
74
|
+
const vendorFile = `vendor-${Bun.hash(code).toString(36)}.js`;
|
|
75
|
+
await this.#writeAtomic(vendorFile, code);
|
|
76
|
+
return vendorFile;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async writeApp(graph: CsrDevGraph, generation: number): Promise<void> {
|
|
80
|
+
const ids = Object.keys(graph.modules)
|
|
81
|
+
.filter((id) => !graph.modules[id]?.vendor)
|
|
82
|
+
.sort();
|
|
83
|
+
const lines = await this.#defineLines(ids);
|
|
84
|
+
const blocks = ids.map((id, index) => [id, lines[index] ?? ""] as [string, string]);
|
|
85
|
+
const start = `__akan.start(${JSON.stringify({ generation, refresh: graph.refresh })});\n`;
|
|
86
|
+
await this.#writeWithSourceMap(CSR_DEV_APP_FILE, await this.#helperDefinitions(graph, ids), blocks, start);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async writePatch(generation: number, modules: CsrDevCompiledModule[]): Promise<string> {
|
|
90
|
+
const patchFile = `patch-${generation}.js`;
|
|
91
|
+
const blocks = modules.map(
|
|
92
|
+
(module, index) =>
|
|
93
|
+
[module.id, `${JSON.stringify(module.id)}: ${module.factory}${index < modules.length - 1 ? ",\n" : "\n"}`] as [
|
|
94
|
+
string,
|
|
95
|
+
string,
|
|
96
|
+
],
|
|
97
|
+
);
|
|
98
|
+
const helpers = new Map(
|
|
99
|
+
modules.flatMap((module) => (module.helpers ? [[module.helpers.hash, module.helpers]] : [])),
|
|
100
|
+
);
|
|
101
|
+
const header = `${[...helpers.values()].map((shared) => shared.definition).join("")}__akan.update(${generation}, {\n`;
|
|
102
|
+
await this.#writeWithSourceMap(patchFile, header, blocks, "});\n");
|
|
103
|
+
return `${CSR_DEV_ROUTE_PREFIX}${patchFile}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The manifest goes last: the server renders shells from it, so it must never name a file not yet written.
|
|
107
|
+
async writeState(graph: CsrDevGraph, manifest: CsrDevManifest): Promise<void> {
|
|
108
|
+
await this.writeJson("graph.json", graph);
|
|
109
|
+
await this.writeJson(CSR_DEV_MANIFEST_FILE, manifest);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async prune(vendorFile: string, generation: number): Promise<void> {
|
|
113
|
+
const names = await readdir(this.#outDir);
|
|
114
|
+
const stale = names.filter((name) => {
|
|
115
|
+
const patch = /^patch-(\d+)\.js(?:\.layout\.json)?$/.exec(name);
|
|
116
|
+
return patch ? Number(patch[1]) <= generation - CsrDevArtifactWriter.#keptPatches : false;
|
|
117
|
+
});
|
|
118
|
+
const vendors = names
|
|
119
|
+
.filter((name) => /^vendor-[\w-]+\.js$/.test(name) && name !== vendorFile)
|
|
120
|
+
.map((name) => ({ name, mtimeMs: CsrDevPaths.mtimeOf(path.join(this.#outDir, name)) }))
|
|
121
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
122
|
+
.slice(CsrDevArtifactWriter.#keptVendors - 1)
|
|
123
|
+
.map(({ name }) => name);
|
|
124
|
+
await Promise.all([...stale, ...vendors].map((name) => rm(path.join(this.#outDir, name), { force: true })));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async #helperDefinitions(graph: CsrDevGraph, ids: string[]): Promise<string> {
|
|
128
|
+
const hashes = [...new Set(ids.flatMap((id) => graph.modules[id]?.helpers ?? []))].sort();
|
|
129
|
+
return (await Promise.all(hashes.map((hash) => Bun.file(this.#helpersPath(hash)).text()))).join("");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async #defineLines(ids: string[]): Promise<string[]> {
|
|
133
|
+
return await Promise.all(
|
|
134
|
+
ids.map(
|
|
135
|
+
async (id) => `__akan.define(${JSON.stringify(id)}, ${await Bun.file(this.#modulePath(id, ".js")).text()});\n`,
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
//? A factory's code starts on the line after its header, so each module's map is placed one line below it.
|
|
141
|
+
async #writeWithSourceMap(name: string, header: string, blocks: [id: string, text: string][], footer: string) {
|
|
142
|
+
const layout: CsrDevLayout = { lineCount: 0, modules: [] };
|
|
143
|
+
let line = CsrDevPaths.lineCount(header);
|
|
144
|
+
for (const [id, text] of blocks) {
|
|
145
|
+
layout.modules.push([id, line + 1]);
|
|
146
|
+
line += CsrDevPaths.lineCount(text);
|
|
147
|
+
}
|
|
148
|
+
const code = `${header}${blocks.map(([, text]) => text).join("")}${footer}//# sourceMappingURL=${name}.map\n`;
|
|
149
|
+
layout.lineCount = CsrDevPaths.lineCount(code);
|
|
150
|
+
await this.writeJson(`${name}.layout.json`, layout);
|
|
151
|
+
await this.#writeAtomic(name, code);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A rename is atomic: the dev server may be streaming the previous app.js to a reloading page right now.
|
|
155
|
+
async #writeAtomic(name: string, content: string): Promise<void> {
|
|
156
|
+
const target = path.join(this.#outDir, name);
|
|
157
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
158
|
+
await Bun.write(temp, content);
|
|
159
|
+
await rename(temp, target);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#modulePath(id: string, extension: ".js" | ".js.map"): string {
|
|
163
|
+
return path.join(this.#outDir, csrDevModuleFile(id, extension));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#helpersPath(hash: string): string {
|
|
167
|
+
return path.join(this.#outDir, "modules", `helpers-${hash}.js`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
//* Pins the Bun behaviour CsrDevBundler is built on, so a Bun upgrade that changes it fails here, not in a browser.
|
|
7
|
+
describe("Bun output the CSR registry bundle relies on", () => {
|
|
8
|
+
let dir: string;
|
|
9
|
+
const externalizeAll = {
|
|
10
|
+
name: "externalize-all",
|
|
11
|
+
setup(build: Bun.PluginBuilder) {
|
|
12
|
+
build.onResolve({ filter: /.*/ }, (args) =>
|
|
13
|
+
args.kind === "entry-point-build" ? undefined : { path: `akan-module:${args.path}`, external: true },
|
|
14
|
+
);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
dir = await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-bundler-"));
|
|
20
|
+
await Bun.write(path.join(dir, "a.ts"), "export const a = 1;\n");
|
|
21
|
+
await Bun.write(
|
|
22
|
+
path.join(dir, "entry.ts"),
|
|
23
|
+
'import value from "./a";\nexport * from "./a";\nexport const lazy = () => import("./a");\nconsole.log(value);\n',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await rm(dir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const build = async (entrypoints: string[]) =>
|
|
32
|
+
await Bun.build({
|
|
33
|
+
entrypoints: entrypoints.map((file) => path.join(dir, file)),
|
|
34
|
+
root: dir,
|
|
35
|
+
target: "browser",
|
|
36
|
+
format: "cjs",
|
|
37
|
+
naming: { entry: "[dir]/[name].[ext]" },
|
|
38
|
+
plugins: [externalizeAll],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the CJS helpers keep the shapes CsrDevBundler rewrites", async () => {
|
|
42
|
+
const [output] = (await build(["entry.ts"])).outputs;
|
|
43
|
+
const code = (await output?.text()) ?? "";
|
|
44
|
+
expect(code).toContain("var __toESM = (mod, isNodeMode, target) => {");
|
|
45
|
+
expect(code).toContain("var __reExport = (target, mod, secondTarget) => {");
|
|
46
|
+
expect(code).toContain('require("akan-module:./a")');
|
|
47
|
+
expect(code).toContain('import("akan-module:./a")');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("the helper preamble ends at the first path comment and names no factory argument", async () => {
|
|
51
|
+
const [output] = (await build(["entry.ts"])).outputs;
|
|
52
|
+
const code = (await output?.text()) ?? "";
|
|
53
|
+
const marker = code.search(/^\/\/ /m);
|
|
54
|
+
expect(marker).toBeGreaterThan(0);
|
|
55
|
+
const preamble = code.slice(0, marker);
|
|
56
|
+
expect(preamble).toMatch(/^var __toESM = /m);
|
|
57
|
+
expect(preamble).not.toMatch(/(?<![\w$.])(?:require|module|exports)(?![\w$]|\s*:)/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a relative import of another entrypoint in the same build stays external", async () => {
|
|
61
|
+
const outputs = (await build(["entry.ts", "a.ts"])).outputs;
|
|
62
|
+
const entry = await outputs.find((output) => output.path.includes("entry"))?.text();
|
|
63
|
+
expect(entry).toContain('require("akan-module:./a")');
|
|
64
|
+
expect(entry).not.toContain("var a = 1");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CSR_DEV_DIRNAME, CSR_DEV_MANIFEST_FILE, type CsrDevManifest } from "akanjs/server/hmr/csrDevManifest";
|
|
5
|
+
import { isAkanRuntimeMetadataFile } from "akanjs/server/hmr/runtimeMetadataFile";
|
|
6
|
+
import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
7
|
+
import type { App } from "../commandDecorators";
|
|
8
|
+
import { bundleDefine } from "./bundleDefine";
|
|
9
|
+
import { CsrArtifactBuilder } from "./csrArtifactBuilder";
|
|
10
|
+
import { CsrDevArtifactWriter } from "./csrDevArtifactWriter";
|
|
11
|
+
import { CsrDevModuleCompiler } from "./csrDevModuleCompiler";
|
|
12
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
13
|
+
import { CsrDevResolver } from "./csrDevResolver";
|
|
14
|
+
import type { CsrDevCompiledModule, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
|
|
15
|
+
import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
16
|
+
|
|
17
|
+
export interface CsrDevUpdate {
|
|
18
|
+
generation: number;
|
|
19
|
+
reload: boolean;
|
|
20
|
+
reason?: string;
|
|
21
|
+
patchUrl?: string;
|
|
22
|
+
changedIds: string[];
|
|
23
|
+
moduleCount: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//* Dev-only CSR as a module registry. The build worker is a fresh process per save, so the graph, every module's
|
|
27
|
+
//* factory and the resolutions live on disk under `.akan/artifact/csr-dev`.
|
|
28
|
+
export class CsrDevBundler {
|
|
29
|
+
static readonly #formatVersion = 3;
|
|
30
|
+
readonly #app: App;
|
|
31
|
+
readonly #paths: CsrDevPaths;
|
|
32
|
+
readonly #outDir: string;
|
|
33
|
+
readonly #entryDir: string;
|
|
34
|
+
readonly #writer: CsrDevArtifactWriter;
|
|
35
|
+
|
|
36
|
+
constructor(app: App) {
|
|
37
|
+
this.#app = app;
|
|
38
|
+
this.#paths = new CsrDevPaths(app.workspace.workspaceRoot);
|
|
39
|
+
this.#outDir = path.join(app.cwdPath, ".akan/artifact", CSR_DEV_DIRNAME);
|
|
40
|
+
this.#entryDir = path.join(app.cwdPath, ".akan/generated", CSR_DEV_DIRNAME);
|
|
41
|
+
this.#writer = new CsrDevArtifactWriter(this.#outDir);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async update(changedFiles: string[] = []): Promise<CsrDevUpdate | null> {
|
|
45
|
+
const context = await this.#context();
|
|
46
|
+
if (context.pageEntries.length === 0) return null;
|
|
47
|
+
const manifest = await this.#writer.readJson<CsrDevManifest>(CSR_DEV_MANIFEST_FILE);
|
|
48
|
+
const graph = await this.#writer.readJson<CsrDevGraph>("graph.json");
|
|
49
|
+
const generation = (manifest?.generation ?? 0) + 1;
|
|
50
|
+
if (!manifest || !graph || graph.version !== 1 || graph.configKey !== context.configKey)
|
|
51
|
+
return await this.#fullBuild(context, generation, manifest ? "the dev bundle config changed" : "first build");
|
|
52
|
+
const metadataFile = changedFiles.find(isAkanRuntimeMetadataFile);
|
|
53
|
+
// `lib/useClient.ts` inlines signal and dictionary metadata through a macro at build time.
|
|
54
|
+
if (metadataFile) return await this.#fullBuild(context, generation, `${path.basename(metadataFile)} changed`);
|
|
55
|
+
return await this.#incrementalBuild(context, graph, manifest, changedFiles, generation);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async #context(): Promise<CsrDevContext> {
|
|
59
|
+
const akanConfig = await this.#app.getConfig();
|
|
60
|
+
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, await this.#app.getPageKeys());
|
|
61
|
+
const basePaths = [...akanConfig.basePaths];
|
|
62
|
+
const htmlBasePaths = basePaths.length > 0 ? basePaths : [""];
|
|
63
|
+
const define = bundleDefine(this.#app, "start", "csr");
|
|
64
|
+
const optimizeImports = [...akanConfig.optimizeImports];
|
|
65
|
+
const configKey = Bun.hash(
|
|
66
|
+
JSON.stringify([CsrDevBundler.#formatVersion, Bun.version, define, optimizeImports, htmlBasePaths]),
|
|
67
|
+
).toString(36);
|
|
68
|
+
return {
|
|
69
|
+
pageEntries,
|
|
70
|
+
basePaths,
|
|
71
|
+
htmlBasePaths,
|
|
72
|
+
define,
|
|
73
|
+
optimizeImports,
|
|
74
|
+
configKey,
|
|
75
|
+
refreshFile: this.#resolveRefreshRuntime(),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#resolveRefreshRuntime(): string {
|
|
80
|
+
const akanWebkit = CsrDevPaths.tryResolve("akanjs/webkit", this.#app.cwdPath);
|
|
81
|
+
const bases = [
|
|
82
|
+
this.#app.cwdPath,
|
|
83
|
+
this.#paths.root,
|
|
84
|
+
...(akanWebkit ? [path.dirname(path.dirname(akanWebkit))] : []),
|
|
85
|
+
];
|
|
86
|
+
for (const base of bases) {
|
|
87
|
+
const resolved = CsrDevPaths.tryResolve("react-refresh/runtime", base);
|
|
88
|
+
if (resolved) return CsrDevPaths.realpath(resolved);
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`[csr-dev] react-refresh/runtime is not resolvable from ${bases.join(", ")}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async #fullBuild(context: CsrDevContext, generation: number, reason: string): Promise<CsrDevUpdate> {
|
|
94
|
+
await this.#writer.reset();
|
|
95
|
+
const entries = await this.#writeEntries(context);
|
|
96
|
+
const entryFiles = Object.values(entries.files);
|
|
97
|
+
const resolver = new CsrDevResolver({ paths: this.#paths, context, entryFiles });
|
|
98
|
+
await resolver.prepass();
|
|
99
|
+
const compiled = await this.#compiler(context, resolver).compile([...entryFiles, context.refreshFile], new Set());
|
|
100
|
+
const graph: CsrDevGraph = {
|
|
101
|
+
version: 1,
|
|
102
|
+
configKey: context.configKey,
|
|
103
|
+
refresh: this.#paths.idOf(context.refreshFile),
|
|
104
|
+
entries: this.#entryIds(entries.files),
|
|
105
|
+
modules: {},
|
|
106
|
+
resolution: {},
|
|
107
|
+
pending: [],
|
|
108
|
+
};
|
|
109
|
+
this.#merge(graph, resolver, compiled);
|
|
110
|
+
await this.#writer.writeModules(compiled);
|
|
111
|
+
const vendorFile = await this.#writer.writeVendor(graph);
|
|
112
|
+
await this.#writer.writeApp(graph, generation);
|
|
113
|
+
await this.#writer.writeState(graph, { version: 1, generation, vendorFile, entries: graph.entries });
|
|
114
|
+
await this.#writer.prune(vendorFile, generation);
|
|
115
|
+
return { generation, reload: true, reason, changedIds: [], moduleCount: Object.keys(graph.modules).length };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async #incrementalBuild(
|
|
119
|
+
context: CsrDevContext,
|
|
120
|
+
graph: CsrDevGraph,
|
|
121
|
+
manifest: CsrDevManifest,
|
|
122
|
+
changedFiles: string[],
|
|
123
|
+
generation: number,
|
|
124
|
+
): Promise<CsrDevUpdate | null> {
|
|
125
|
+
const entries = await this.#writeEntries(context);
|
|
126
|
+
const entryIds = this.#entryIds(entries.files);
|
|
127
|
+
await this.#forgetDeletedModules(graph);
|
|
128
|
+
const changed = await this.#changedModules(graph, changedFiles);
|
|
129
|
+
for (const file of entries.changed) changed.add(this.#paths.idOf(file));
|
|
130
|
+
const routesMoved = entries.changed.length > 0 || JSON.stringify(entryIds) !== JSON.stringify(graph.entries);
|
|
131
|
+
if (changed.size === 0) return null;
|
|
132
|
+
|
|
133
|
+
const resolver = new CsrDevResolver({
|
|
134
|
+
paths: this.#paths,
|
|
135
|
+
context,
|
|
136
|
+
entryFiles: Object.values(entries.files),
|
|
137
|
+
resolution: graph.resolution,
|
|
138
|
+
});
|
|
139
|
+
const known = new Set(Object.keys(graph.modules).map((id) => this.#paths.fileOf(id)));
|
|
140
|
+
let compiled: CsrDevCompiledModule[];
|
|
141
|
+
try {
|
|
142
|
+
compiled = await this.#compiler(context, resolver).compile(
|
|
143
|
+
[...changed].map((id) => this.#paths.fileOf(id)),
|
|
144
|
+
known,
|
|
145
|
+
);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
graph.pending = [...changed];
|
|
148
|
+
await this.#writer.writeJson("graph.json", graph);
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
const vendorJoined = compiled.some((module) => module.vendor && !graph.modules[module.id]);
|
|
152
|
+
this.#merge(graph, resolver, compiled);
|
|
153
|
+
graph.entries = entryIds;
|
|
154
|
+
await this.#writer.writeModules(compiled);
|
|
155
|
+
const vendorFile = vendorJoined ? await this.#writer.writeVendor(graph) : manifest.vendorFile;
|
|
156
|
+
await this.#writer.writeApp(graph, generation);
|
|
157
|
+
const changedIds = compiled.filter((module) => !module.vendor).map((module) => module.id);
|
|
158
|
+
const constantId = changedIds.find((id) => id.endsWith(".constant.ts"));
|
|
159
|
+
// A model class swapped under live store state would mix old and new instances; reload instead.
|
|
160
|
+
const reason = vendorJoined
|
|
161
|
+
? "an npm module joined the graph"
|
|
162
|
+
: routesMoved
|
|
163
|
+
? "the route table changed"
|
|
164
|
+
: constantId
|
|
165
|
+
? `${path.basename(constantId)} changed`
|
|
166
|
+
: undefined;
|
|
167
|
+
const patchUrl = reason
|
|
168
|
+
? undefined
|
|
169
|
+
: await this.#writer.writePatch(
|
|
170
|
+
generation,
|
|
171
|
+
compiled.filter((module) => !module.vendor),
|
|
172
|
+
);
|
|
173
|
+
await this.#writer.writeState(graph, { ...manifest, generation, vendorFile, entries: entryIds });
|
|
174
|
+
await this.#writer.prune(vendorFile, generation);
|
|
175
|
+
return {
|
|
176
|
+
generation,
|
|
177
|
+
reload: !!reason,
|
|
178
|
+
reason,
|
|
179
|
+
patchUrl,
|
|
180
|
+
changedIds,
|
|
181
|
+
moduleCount: Object.keys(graph.modules).length,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async #changedModules(graph: CsrDevGraph, changedFiles: string[]): Promise<Set<string>> {
|
|
186
|
+
const changed = new Set<string>(graph.pending.filter((id) => graph.modules[id]));
|
|
187
|
+
for (const file of changedFiles) {
|
|
188
|
+
const id = this.#paths.idOf(file);
|
|
189
|
+
if (graph.modules[id]) changed.add(id);
|
|
190
|
+
}
|
|
191
|
+
// Stat, not trust: a save made while CSR was unarmed, or a build that failed, never reached this graph. The hash
|
|
192
|
+
// settles it because route discovery rewrites the generated layout wrappers, touching them on every call.
|
|
193
|
+
for (const [id, module] of Object.entries(graph.modules)) {
|
|
194
|
+
if (module.vendor || changed.has(id)) continue;
|
|
195
|
+
const file = this.#paths.fileOf(id);
|
|
196
|
+
if (CsrDevPaths.mtimeOf(file) === module.mtimeMs) continue;
|
|
197
|
+
if ((await CsrDevPaths.hashOf(file)) !== module.hash) changed.add(id);
|
|
198
|
+
}
|
|
199
|
+
return changed;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A deleted module leaves the graph instead of being rebuilt: rebuilding a missing file fails every save after it.
|
|
203
|
+
// An importer that still names it fails on its own rebuild, with the resolution error that says why.
|
|
204
|
+
async #forgetDeletedModules(graph: CsrDevGraph): Promise<void> {
|
|
205
|
+
const deleted = Object.keys(graph.modules).filter(
|
|
206
|
+
(id) => !graph.modules[id]?.vendor && !fs.existsSync(this.#paths.fileOf(id)),
|
|
207
|
+
);
|
|
208
|
+
for (const id of deleted) delete graph.modules[id];
|
|
209
|
+
await this.#writer.forgetModules(deleted);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async #writeEntries(context: CsrDevContext): Promise<{ files: Record<string, string>; changed: string[] }> {
|
|
213
|
+
await mkdir(this.#entryDir, { recursive: true });
|
|
214
|
+
const files: Record<string, string> = {};
|
|
215
|
+
const changed: string[] = [];
|
|
216
|
+
for (const basePath of context.htmlBasePaths) {
|
|
217
|
+
const file = path.join(this.#entryDir, CsrArtifactBuilder.entryFilename(basePath));
|
|
218
|
+
const entryPages = CsrArtifactBuilder.pageEntriesForBasePath(context.pageEntries, basePath, context.basePaths);
|
|
219
|
+
const generator = new PagesEntrySourceGenerator(entryPages);
|
|
220
|
+
const pages = await generator.generateStatic({ fromDir: this.#entryDir });
|
|
221
|
+
const hot = await generator.generateHotReplace({
|
|
222
|
+
fromDir: this.#entryDir,
|
|
223
|
+
ownerId: this.#paths.idOf(file),
|
|
224
|
+
moduleIds: entryPages.map(({ moduleAbsPath }) => this.#paths.idOf(moduleAbsPath)),
|
|
225
|
+
});
|
|
226
|
+
const source = `import * as akanWebkit from "akanjs/webkit";\n${pages}\nvoid akanWebkit.bootCsr(pages);\n${hot}`;
|
|
227
|
+
const unchanged = (await Bun.file(file).exists()) && (await Bun.file(file).text()) === source;
|
|
228
|
+
if (!unchanged) await Bun.write(file, source);
|
|
229
|
+
const entryFile = CsrDevPaths.realpath(file);
|
|
230
|
+
files[basePath] = entryFile;
|
|
231
|
+
if (!unchanged) changed.push(entryFile);
|
|
232
|
+
}
|
|
233
|
+
return { files, changed };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#compiler(context: CsrDevContext, resolver: CsrDevResolver): CsrDevModuleCompiler {
|
|
237
|
+
return new CsrDevModuleCompiler({ app: this.#app, paths: this.#paths, resolver, context, outDir: this.#outDir });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#merge(graph: CsrDevGraph, resolver: CsrDevResolver, compiled: CsrDevCompiledModule[]): void {
|
|
241
|
+
for (const module of compiled)
|
|
242
|
+
graph.modules[module.id] = {
|
|
243
|
+
vendor: module.vendor,
|
|
244
|
+
mtimeMs: module.mtimeMs,
|
|
245
|
+
hash: module.hash,
|
|
246
|
+
deps: module.deps.map((dep) => (CsrDevPaths.isStub(dep) ? dep : this.#paths.idOf(dep))),
|
|
247
|
+
...(module.helpers ? { helpers: module.helpers.hash } : {}),
|
|
248
|
+
};
|
|
249
|
+
graph.pending = [];
|
|
250
|
+
graph.resolution = resolver.serialize();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#entryIds(files: Record<string, string>): Record<string, string> {
|
|
254
|
+
return Object.fromEntries(Object.entries(files).map(([basePath, file]) => [basePath, this.#paths.idOf(file)]));
|
|
255
|
+
}
|
|
256
|
+
}
|