@akanjs/devkit 3.0.0-beta.24 → 3.0.0-beta.25
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/DEV_RUNTIME_KNOBS.md +37 -22
- package/akanApp/akanApp.host.ts +34 -1
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +25 -0
- package/akanApp/devHostPolicy.ts +27 -2
- package/cloud/globalConfig.ts +3 -17
- package/csrE2e/csrE2eHarness.fixture.ts +66 -11
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/fileSys.ts +16 -1
- package/frontendBuild/buildRouteClient.test.ts +61 -0
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +5 -1
- package/frontendBuild/clientEntryDiscovery.test.ts +37 -0
- package/frontendBuild/clientEntryDiscovery.ts +10 -5
- package/frontendBuild/csrArtifactBuilder.ts +3 -6
- package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
- package/frontendBuild/csrDevArtifactWriter.ts +158 -24
- package/frontendBuild/csrDevBundler.ts +44 -205
- package/frontendBuild/csrDevModuleCompiler.ts +51 -9
- package/frontendBuild/csrDevPatcher.test.ts +340 -0
- package/frontendBuild/csrDevPatcher.ts +374 -0
- package/frontendBuild/csrDevPaths.ts +60 -0
- package/frontendBuild/csrDevResolver.test.ts +165 -0
- package/frontendBuild/csrDevResolver.ts +70 -18
- package/frontendBuild/csrDevTypes.ts +14 -1
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCompiler.ts +2 -4
- package/frontendBuild/devRegistryBundler.ts +177 -0
- package/frontendBuild/hmrWatcher.test.ts +51 -0
- package/frontendBuild/hmrWatcher.ts +17 -3
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/pagesBundleBuilder.ts +24 -1
- package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +79 -18
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/ssrDevBundler.ts +91 -0
- package/incrementalBuilder/buildBatch.proc.ts +94 -16
- package/incrementalBuilder/buildBatchProtocol.ts +6 -3
- package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
- package/incrementalBuilder/buildBatchRunner.ts +17 -8
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +1 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +32 -3
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +129 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +518 -52
- package/integration/devStability.integration.test.ts +18 -7
- package/package.json +2 -2
- package/transforms/rscUseClientTransform.ts +7 -0
- package/transforms/transforms.test.ts +13 -0
- package/transforms/useClientBundlePlugin.ts +4 -1
|
@@ -162,6 +162,7 @@ describe("route client store bootstrap", () => {
|
|
|
162
162
|
knownEntries,
|
|
163
163
|
discovery,
|
|
164
164
|
artifact: {} as never,
|
|
165
|
+
browser: "chunks",
|
|
165
166
|
}).build();
|
|
166
167
|
|
|
167
168
|
const first = await build([pageA], new Set());
|
|
@@ -234,3 +235,63 @@ describe("route client store bootstrap", () => {
|
|
|
234
235
|
});
|
|
235
236
|
});
|
|
236
237
|
});
|
|
238
|
+
|
|
239
|
+
describe("fast refresh named default function", () => {
|
|
240
|
+
test("hoists a named default function into a declaration plus a trailing default export", () => {
|
|
241
|
+
const source = `export default async function Page<T>(props: T) {\n return null;\n}\n`;
|
|
242
|
+
const next = RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(source, { path: "/app/page.tsx" });
|
|
243
|
+
expect(next).toBe(`async function Page<T>(props: T) {\n return null;\n}\n\nexport default Page;\n`);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("leaves code quoted in a template literal or a comment alone", () => {
|
|
247
|
+
const source = [
|
|
248
|
+
`import { page } from "akanjs/client";`,
|
|
249
|
+
`const snippet = \``,
|
|
250
|
+
`export default function Page() {`,
|
|
251
|
+
` return <div />;`,
|
|
252
|
+
`}\`;`,
|
|
253
|
+
`/*`,
|
|
254
|
+
`export default function Commented() {}`,
|
|
255
|
+
`*/`,
|
|
256
|
+
`export default page().render(() => <pre>{snippet}</pre>);`,
|
|
257
|
+
"",
|
|
258
|
+
].join("\n");
|
|
259
|
+
expect(
|
|
260
|
+
RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(source, { path: "/app/page.tsx" }),
|
|
261
|
+
).toBeNull();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("rewrites only the real declaration when the same name is also quoted", () => {
|
|
265
|
+
const source = [
|
|
266
|
+
"const snippet = `",
|
|
267
|
+
"export default function Page() {}",
|
|
268
|
+
"`;",
|
|
269
|
+
"export default function Page() {",
|
|
270
|
+
" return snippet;",
|
|
271
|
+
"}",
|
|
272
|
+
"",
|
|
273
|
+
].join("\n");
|
|
274
|
+
const next = RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(source, { path: "/app/page.tsx" });
|
|
275
|
+
expect(next).toBe(
|
|
276
|
+
[
|
|
277
|
+
"const snippet = `",
|
|
278
|
+
"export default function Page() {}",
|
|
279
|
+
"`;",
|
|
280
|
+
"function Page() {",
|
|
281
|
+
" return snippet;",
|
|
282
|
+
"}",
|
|
283
|
+
"",
|
|
284
|
+
"export default Page;",
|
|
285
|
+
"",
|
|
286
|
+
].join("\n"),
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("leaves a file it cannot parse to the bundler", () => {
|
|
291
|
+
expect(
|
|
292
|
+
RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh("export default function Page( {", {
|
|
293
|
+
path: "/app/page.tsx",
|
|
294
|
+
}),
|
|
295
|
+
).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { App } from "../commandDecorators";
|
|
3
|
+
import { bundleDefine } from "./bundleDefine";
|
|
4
|
+
|
|
5
|
+
describe("bundleDefine", () => {
|
|
6
|
+
test("spells the public env in one order whatever order the process holds it in", () => {
|
|
7
|
+
const appWith = (env: Record<string, string>) => ({ getPublicEnv: () => env }) as unknown as App;
|
|
8
|
+
const builder = bundleDefine(appWith({ AKAN_PUBLIC_Z: "1", AKAN_PUBLIC_A: "2" }), "start", "ssr");
|
|
9
|
+
const worker = bundleDefine(appWith({ AKAN_PUBLIC_A: "2", AKAN_PUBLIC_Z: "1" }), "start", "ssr");
|
|
10
|
+
expect(JSON.stringify(builder)).toBe(JSON.stringify(worker));
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -5,8 +5,12 @@ export const bundleDefine = (app: App, command: "build" | "start", renderEnv: "s
|
|
|
5
5
|
return {
|
|
6
6
|
"process.env.NODE_ENV": JSON.stringify(nodeEnv),
|
|
7
7
|
"process.env.AKAN_PUBLIC_RENDER_ENV": JSON.stringify(renderEnv),
|
|
8
|
+
//? Sorted: Windows hands a child its environment sorted, while keys the config sets at runtime trail the parent's,
|
|
9
|
+
//? and the dev registries hash this object to tell a worker's build from the builder's.
|
|
8
10
|
...Object.fromEntries(
|
|
9
|
-
Object.entries(app.getPublicEnv())
|
|
11
|
+
Object.entries(app.getPublicEnv())
|
|
12
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
13
|
+
.map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)]),
|
|
10
14
|
),
|
|
11
15
|
};
|
|
12
16
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { afterAll, 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
|
+
import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
|
|
6
|
+
|
|
7
|
+
const dirs: string[] = [];
|
|
8
|
+
|
|
9
|
+
afterAll(async () => {
|
|
10
|
+
await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true })));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("GraphClientEntryDiscovery", () => {
|
|
14
|
+
test("a walk an invalidation overtook answers its caller but leaves nothing stale for the next one", async () => {
|
|
15
|
+
for (const yields of [0, 5, 10, 20, 40, 80]) {
|
|
16
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "akan-discovery-"));
|
|
17
|
+
dirs.push(dir);
|
|
18
|
+
const file = (name: string) => path.join(dir, name);
|
|
19
|
+
await Bun.write(file("S.tsx"), 'import "./A";\n');
|
|
20
|
+
await Bun.write(file("A.tsx"), 'import "./B";\nimport "./D";\n');
|
|
21
|
+
await Bun.write(file("B.tsx"), "export const b = 1;\n");
|
|
22
|
+
await Bun.write(file("C.tsx"), '"use client";\nexport const C = () => null;\n');
|
|
23
|
+
await Bun.write(file("D.tsx"), "export const d = 1;\n");
|
|
24
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
|
|
25
|
+
expect(await discovery.discover([file("S.tsx")])).toEqual([]);
|
|
26
|
+
|
|
27
|
+
await Bun.write(file("B.tsx"), 'import "./C";\nexport const b = 1;\n');
|
|
28
|
+
await Bun.write(file("D.tsx"), "export const d = 2;\n");
|
|
29
|
+
discovery.invalidate([file("D.tsx")]);
|
|
30
|
+
const walk = discovery.discover([file("S.tsx")]);
|
|
31
|
+
for (let tick = 0; tick < yields; tick++) await Promise.resolve();
|
|
32
|
+
discovery.invalidate([file("B.tsx")]);
|
|
33
|
+
await walk;
|
|
34
|
+
expect(await discovery.discover([file("S.tsx")])).toEqual([file("C.tsx")]);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -34,6 +34,9 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
34
34
|
#missingFiles = new Set<string>();
|
|
35
35
|
#unresolvedPaths = new Set<string>();
|
|
36
36
|
#unresolvedSpecifiers = new Set<string>();
|
|
37
|
+
//? Bumped by every invalidation: a walk that began before one computed from facts it may have read stale, so it
|
|
38
|
+
//? answers its caller but caches nothing (a registry check walks this instance beside the slow lane's route builds).
|
|
39
|
+
#epoch = 0;
|
|
37
40
|
|
|
38
41
|
constructor(akanConfig: Pick<AkanConfig, "barrelImports">, resolvePackage: PackageResolver) {
|
|
39
42
|
this.#akanConfig = akanConfig;
|
|
@@ -61,6 +64,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
61
64
|
this.#reachableEntriesCache.delete(absPath);
|
|
62
65
|
}
|
|
63
66
|
if (files.length === 0) return;
|
|
67
|
+
this.#epoch += 1;
|
|
64
68
|
// Reachable-entry results are transitive, so a changed child invalidates every ancestor.
|
|
65
69
|
this.#reachableEntriesCache.clear();
|
|
66
70
|
this.#forgetMissing();
|
|
@@ -165,6 +169,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
async #discoverFromFile(file: string, visiting: Set<string>): Promise<Set<string>> {
|
|
172
|
+
const epoch = this.#epoch;
|
|
168
173
|
const absPath = path.resolve(file);
|
|
169
174
|
const cached = this.#reachableEntriesCache.get(absPath);
|
|
170
175
|
if (cached) return new Set(cached);
|
|
@@ -173,11 +178,11 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
173
178
|
visiting.add(absPath);
|
|
174
179
|
const entries = new Set<string>();
|
|
175
180
|
const facts = await this.#facts(absPath);
|
|
176
|
-
if (!facts) return this.#finishDiscovery(absPath, visiting, entries);
|
|
181
|
+
if (!facts) return this.#finishDiscovery(absPath, visiting, entries, epoch);
|
|
177
182
|
|
|
178
183
|
if (facts.isClientEntry) {
|
|
179
184
|
entries.add(absPath);
|
|
180
|
-
return this.#finishDiscovery(absPath, visiting, entries);
|
|
185
|
+
return this.#finishDiscovery(absPath, visiting, entries, epoch);
|
|
181
186
|
}
|
|
182
187
|
|
|
183
188
|
const importerDir = path.dirname(absPath);
|
|
@@ -191,12 +196,12 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
191
196
|
for (const entry of await this.#discoverFromFile(resolved, visiting)) entries.add(entry);
|
|
192
197
|
}
|
|
193
198
|
|
|
194
|
-
return this.#finishDiscovery(absPath, visiting, entries);
|
|
199
|
+
return this.#finishDiscovery(absPath, visiting, entries, epoch);
|
|
195
200
|
}
|
|
196
201
|
|
|
197
|
-
#finishDiscovery(absPath: string, visiting: Set<string>, entries: Set<string
|
|
202
|
+
#finishDiscovery(absPath: string, visiting: Set<string>, entries: Set<string>, epoch: number): Set<string> {
|
|
198
203
|
visiting.delete(absPath);
|
|
199
|
-
this.#reachableEntriesCache.set(absPath, entries);
|
|
204
|
+
if (epoch === this.#epoch) this.#reachableEntriesCache.set(absPath, entries);
|
|
200
205
|
return new Set(entries);
|
|
201
206
|
}
|
|
202
207
|
}
|
|
@@ -4,7 +4,7 @@ import type { BaseBuildArtifact } from "akanjs/server";
|
|
|
4
4
|
import { type PageEntry, resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
6
|
import { bundleDefine } from "./bundleDefine";
|
|
7
|
-
import {
|
|
7
|
+
import { CsrEntryFiles } from "./csrEntryFiles";
|
|
8
8
|
import { PagesBundleBuilder } from "./pagesBundleBuilder";
|
|
9
9
|
import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
10
10
|
|
|
@@ -80,10 +80,7 @@ export class CsrArtifactBuilder {
|
|
|
80
80
|
|
|
81
81
|
/** The routes one basePath's HTML boots: its own plus every route outside any basePath, matching `bootCsr`. */
|
|
82
82
|
static pageEntriesForBasePath(pageEntries: PageEntry[], basePath: string, basePaths: string[]): PageEntry[] {
|
|
83
|
-
return
|
|
84
|
-
const entryBasePath = getPageKeyBasePath(entry.key, basePaths);
|
|
85
|
-
return entryBasePath === null || entryBasePath === basePath;
|
|
86
|
-
});
|
|
83
|
+
return CsrEntryFiles.pageEntriesForBasePath(pageEntries, basePath, basePaths);
|
|
87
84
|
}
|
|
88
85
|
|
|
89
86
|
static htmlFilename(basePath: string): string {
|
|
@@ -91,7 +88,7 @@ export class CsrArtifactBuilder {
|
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
static entryFilename(basePath: string): string {
|
|
94
|
-
return
|
|
91
|
+
return CsrEntryFiles.entryFilename(basePath);
|
|
95
92
|
}
|
|
96
93
|
|
|
97
94
|
static basePathOfHtml(htmlPath: string): string {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
CSR_DEV_MANIFEST_FILE,
|
|
8
|
+
CSR_DEV_ROUTE_PREFIX,
|
|
9
|
+
type CsrDevManifest,
|
|
10
|
+
csrDevModuleFile,
|
|
11
|
+
} from "akanjs/server/hmr/csrDevManifest";
|
|
12
|
+
import { CsrDevArtifactWriter, type CsrDevArtifactWriterOptions } from "./csrDevArtifactWriter";
|
|
13
|
+
import type { CsrDevCompiledModule, CsrDevGraph } from "./csrDevTypes";
|
|
14
|
+
|
|
15
|
+
const dirs: string[] = [];
|
|
16
|
+
const module: CsrDevCompiledModule = {
|
|
17
|
+
id: "a.ts",
|
|
18
|
+
file: "/a.ts",
|
|
19
|
+
vendor: false,
|
|
20
|
+
factory: "function () {}",
|
|
21
|
+
helpers: null,
|
|
22
|
+
deps: [],
|
|
23
|
+
mtimeMs: 0,
|
|
24
|
+
hash: "",
|
|
25
|
+
};
|
|
26
|
+
const readModule = (dir: string) => readFileSync(path.join(dir, csrDevModuleFile("a.ts", ".js")), "utf8");
|
|
27
|
+
const makeWriter = async (
|
|
28
|
+
options: CsrDevArtifactWriterOptions = { routePrefix: CSR_DEV_ROUTE_PREFIX },
|
|
29
|
+
entries: Record<string, string> = {},
|
|
30
|
+
) => {
|
|
31
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-writer-"));
|
|
32
|
+
dirs.push(dir);
|
|
33
|
+
const writer = new CsrDevArtifactWriter(dir, options);
|
|
34
|
+
const graph: CsrDevGraph = {
|
|
35
|
+
version: 1,
|
|
36
|
+
configKey: "key",
|
|
37
|
+
refresh: "refresh.js",
|
|
38
|
+
entries,
|
|
39
|
+
modules: { "a.ts": { vendor: false, mtimeMs: 0, hash: "", deps: [] } },
|
|
40
|
+
resolution: {},
|
|
41
|
+
pending: [],
|
|
42
|
+
};
|
|
43
|
+
await writer.writeModules([module]);
|
|
44
|
+
const read = () => ({
|
|
45
|
+
manifest: JSON.parse(readFileSync(path.join(dir, CSR_DEV_MANIFEST_FILE), "utf8")) as CsrDevManifest,
|
|
46
|
+
app: readFileSync(path.join(dir, "app.js"), "utf8"),
|
|
47
|
+
});
|
|
48
|
+
return { writer, graph, read, dir };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true })));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("CsrDevArtifactWriter", () => {
|
|
56
|
+
test("announces a patch before the module files, graph.json and app.js catch up", async () => {
|
|
57
|
+
const { writer, graph, read, dir } = await makeWriter();
|
|
58
|
+
await writer.writeApp(graph, { generation: 4, vendorFile: "v.js" });
|
|
59
|
+
const edited = { ...module, factory: "function () { edited(); }" };
|
|
60
|
+
let atAnnounce: (ReturnType<typeof read> & { graph: boolean; module: string }) | null = null;
|
|
61
|
+
await writer.commitPatch(
|
|
62
|
+
graph,
|
|
63
|
+
{ version: 1, generation: 5, appGeneration: 4, vendorFile: "v.js", entries: {} },
|
|
64
|
+
() => {
|
|
65
|
+
atAnnounce = { ...read(), graph: existsSync(path.join(dir, "graph.json")), module: readModule(dir) };
|
|
66
|
+
},
|
|
67
|
+
{ modules: [edited] },
|
|
68
|
+
);
|
|
69
|
+
expect(atAnnounce).toMatchObject({ manifest: { generation: 5, appGeneration: 4 }, graph: false });
|
|
70
|
+
expect((atAnnounce as { app: string } | null)?.app).toContain('"generation":4');
|
|
71
|
+
expect((atAnnounce as { module: string } | null)?.module).not.toContain("edited");
|
|
72
|
+
const after = read();
|
|
73
|
+
expect(after.manifest).toMatchObject({ generation: 5, appGeneration: 5 });
|
|
74
|
+
expect(after.app).toContain('"generation":5');
|
|
75
|
+
expect(after.app).toContain("edited()");
|
|
76
|
+
expect(existsSync(path.join(dir, "graph.json"))).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("rewrites an app.js a crash left behind the manifest's generation", async () => {
|
|
80
|
+
const { writer, graph, read } = await makeWriter();
|
|
81
|
+
await writer.writeApp(graph, { generation: 5, vendorFile: "v.js" });
|
|
82
|
+
const behind: CsrDevManifest = { version: 1, generation: 6, appGeneration: 5, vendorFile: "v.js", entries: {} };
|
|
83
|
+
await writer.writeJson(CSR_DEV_MANIFEST_FILE, behind);
|
|
84
|
+
const healed = await writer.healApp(graph, behind);
|
|
85
|
+
expect(healed.appGeneration).toBe(6);
|
|
86
|
+
expect(read().manifest.appGeneration).toBe(6);
|
|
87
|
+
expect(read().app).toContain('"generation":6');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("leaves an app.js that already holds the manifest's generation alone", async () => {
|
|
91
|
+
const { writer, graph } = await makeWriter();
|
|
92
|
+
const current: CsrDevManifest = { version: 1, generation: 3, vendorFile: "v.js", entries: {} };
|
|
93
|
+
expect(await writer.healApp(graph, current)).toBe(current);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a library app.js starts its bootstrap module, and a patch is announced under the writer's route", async () => {
|
|
97
|
+
const { writer, graph, dir } = await makeWriter(
|
|
98
|
+
{ routePrefix: "/_akan/ssr-dev/", library: true },
|
|
99
|
+
{ "": "boot.ts" },
|
|
100
|
+
);
|
|
101
|
+
await writer.writeApp(graph, { generation: 2, vendorFile: "v.js", epoch: 7 });
|
|
102
|
+
expect(readFileSync(path.join(dir, "app.js"), "utf8")).toContain(
|
|
103
|
+
'__akan.startLibrary({"generation":2,"refresh":"refresh.js","bootstrap":"boot.ts","vendorFile":"v.js","epoch":7});',
|
|
104
|
+
);
|
|
105
|
+
expect(await writer.writePatch(3, [module])).toBe("/_akan/ssr-dev/patch-3.js");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("after a whole build, clears what the registry before it left and keeps what the build wrote", async () => {
|
|
109
|
+
const { writer, graph, dir } = await makeWriter();
|
|
110
|
+
const gone = { ...module, id: "gone.ts" };
|
|
111
|
+
await writer.writeModules([gone]);
|
|
112
|
+
await writer.writePatch(7, [module]);
|
|
113
|
+
await Bun.write(path.join(dir, "assets/old-abc.png"), "old");
|
|
114
|
+
await Bun.write(path.join(dir, ".patching"), "123");
|
|
115
|
+
await Bun.write(path.join(dir, "manifest.json.4242.tmp"), "{");
|
|
116
|
+
await Bun.write(path.join(dir, "assets/new-def.png"), "new");
|
|
117
|
+
await writer.pruneAfterFullBuild(graph, "vendor-a.js", {
|
|
118
|
+
generation: 8,
|
|
119
|
+
assets: [path.join(dir, "assets/new-def.png")],
|
|
120
|
+
});
|
|
121
|
+
expect(existsSync(path.join(dir, csrDevModuleFile("a.ts", ".js")))).toBe(true);
|
|
122
|
+
expect(existsSync(path.join(dir, csrDevModuleFile("gone.ts", ".js")))).toBe(false);
|
|
123
|
+
expect(existsSync(path.join(dir, "patch-7.js"))).toBe(false);
|
|
124
|
+
expect(existsSync(path.join(dir, "patch-7.js.layout.json"))).toBe(false);
|
|
125
|
+
expect(existsSync(path.join(dir, "assets/old-abc.png"))).toBe(false);
|
|
126
|
+
expect(existsSync(path.join(dir, "assets/new-def.png"))).toBe(true);
|
|
127
|
+
expect(existsSync(path.join(dir, ".patching"))).toBe(false);
|
|
128
|
+
expect(existsSync(path.join(dir, "manifest.json.4242.tmp"))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a reload drops a patch file left under its generation, and a whole build is marked while it writes", async () => {
|
|
132
|
+
const { writer, dir } = await makeWriter();
|
|
133
|
+
await writer.writePatch(6, [module]);
|
|
134
|
+
await writer.forgetPatch(6);
|
|
135
|
+
expect(existsSync(path.join(dir, "patch-6.js"))).toBe(false);
|
|
136
|
+
expect(existsSync(path.join(dir, "patch-6.js.layout.json"))).toBe(false);
|
|
137
|
+
expect(await writer.isBuilding()).toBe(false);
|
|
138
|
+
await writer.markBuilding();
|
|
139
|
+
expect(await writer.isBuilding()).toBe(true);
|
|
140
|
+
await writer.clearBuilding();
|
|
141
|
+
expect(await writer.isBuilding()).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -1,28 +1,62 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { mkdir, readdir, rm } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import {
|
|
5
|
+
appGenerationOf,
|
|
4
6
|
CSR_DEV_APP_FILE,
|
|
7
|
+
CSR_DEV_KEPT_PATCHES,
|
|
5
8
|
CSR_DEV_MANIFEST_FILE,
|
|
6
|
-
|
|
9
|
+
CSR_DEV_PATCHING_MARKER,
|
|
7
10
|
type CsrDevLayout,
|
|
8
11
|
type CsrDevManifest,
|
|
9
12
|
csrDevModuleFile,
|
|
10
13
|
} from "akanjs/server/hmr/csrDevManifest";
|
|
14
|
+
import { FileSys } from "../fileSys";
|
|
11
15
|
import { CsrDevPaths } from "./csrDevPaths";
|
|
12
|
-
import type { CsrDevCompiledModule, CsrDevGraph } from "./csrDevTypes";
|
|
16
|
+
import type { CsrDevCode, CsrDevCompiledModule, CsrDevGraph } from "./csrDevTypes";
|
|
17
|
+
|
|
18
|
+
export interface CsrDevArtifactWriterOptions {
|
|
19
|
+
/** Where the dev server serves `outDir`; a patch is announced by its URL. */
|
|
20
|
+
routePrefix: string;
|
|
21
|
+
/** `app.js` starts no entry: an SSR page requires the modules its RSC payload names, one at a time. */
|
|
22
|
+
library?: boolean;
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
export class CsrDevArtifactWriter {
|
|
15
|
-
static readonly #keptPatches = 40;
|
|
16
26
|
static readonly #keptVendors = 3;
|
|
27
|
+
static readonly #buildingMarker = ".building";
|
|
17
28
|
readonly #outDir: string;
|
|
29
|
+
readonly #routePrefix: string;
|
|
30
|
+
readonly #library: boolean;
|
|
18
31
|
|
|
19
|
-
constructor(outDir: string) {
|
|
32
|
+
constructor(outDir: string, { routePrefix, library = false }: CsrDevArtifactWriterOptions) {
|
|
20
33
|
this.#outDir = outDir;
|
|
34
|
+
this.#routePrefix = routePrefix;
|
|
35
|
+
this.#library = library;
|
|
21
36
|
}
|
|
22
37
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
//? Set between a whole build's first write and its last: module files are named by id and overwritten in place, so a
|
|
39
|
+
//? build cut short there leaves new modules under the previous graph and app.js, which no patch may read.
|
|
40
|
+
async markBuilding(): Promise<void> {
|
|
41
|
+
await Bun.write(path.join(this.#outDir, CsrDevArtifactWriter.#buildingMarker), String(process.pid));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async clearBuilding(): Promise<void> {
|
|
45
|
+
await CsrDevArtifactWriter.#remove(path.join(this.#outDir, CsrDevArtifactWriter.#buildingMarker));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async isBuilding(): Promise<boolean> {
|
|
49
|
+
return await Bun.file(path.join(this.#outDir, CsrDevArtifactWriter.#buildingMarker)).exists();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
//? A reload's generation has no patch: one an attempt cut short left under that number would be applied by a tab
|
|
53
|
+
//? catching up (catchUp reloads only when the file is missing).
|
|
54
|
+
async forgetPatch(generation: number): Promise<void> {
|
|
55
|
+
await Promise.all(
|
|
56
|
+
[`patch-${generation}.js`, `patch-${generation}.js.layout.json`].map((name) =>
|
|
57
|
+
CsrDevArtifactWriter.#remove(path.join(this.#outDir, name)),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
26
60
|
}
|
|
27
61
|
|
|
28
62
|
async readJson<T>(name: string): Promise<T | null> {
|
|
@@ -49,11 +83,26 @@ export class CsrDevArtifactWriter {
|
|
|
49
83
|
]);
|
|
50
84
|
}
|
|
51
85
|
|
|
86
|
+
//? A module whose file is missing is left out rather than failing the read: the patcher compiles it again.
|
|
87
|
+
async readCode(graph: CsrDevGraph): Promise<CsrDevCode> {
|
|
88
|
+
const ids = Object.keys(graph.modules).filter((id) => !graph.modules[id]?.vendor && this.hasModule(id));
|
|
89
|
+
const hashes = [...new Set(ids.flatMap((id) => graph.modules[id]?.helpers ?? []))];
|
|
90
|
+
const [modules, helpers] = await Promise.all([
|
|
91
|
+
Promise.all(ids.map(async (id) => [id, await Bun.file(this.#modulePath(id, ".js")).text()] as const)),
|
|
92
|
+
Promise.all(hashes.map(async (hash) => [hash, await Bun.file(this.#helpersPath(hash)).text()] as const)),
|
|
93
|
+
]);
|
|
94
|
+
return { modules: new Map(modules), helpers: new Map(helpers) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
hasModule(id: string): boolean {
|
|
98
|
+
return fs.existsSync(this.#modulePath(id, ".js"));
|
|
99
|
+
}
|
|
100
|
+
|
|
52
101
|
async forgetModules(ids: string[]): Promise<void> {
|
|
53
102
|
await Promise.all(
|
|
54
103
|
ids.flatMap((id) => [
|
|
55
|
-
|
|
56
|
-
|
|
104
|
+
CsrDevArtifactWriter.#remove(this.#modulePath(id, ".js")),
|
|
105
|
+
CsrDevArtifactWriter.#remove(this.#modulePath(id, ".js.map")),
|
|
57
106
|
]),
|
|
58
107
|
);
|
|
59
108
|
}
|
|
@@ -76,14 +125,22 @@ export class CsrDevArtifactWriter {
|
|
|
76
125
|
return vendorFile;
|
|
77
126
|
}
|
|
78
127
|
|
|
79
|
-
|
|
128
|
+
//? A library app.js names the vendor file and epoch it was built with: a tab that booted it beside another build's
|
|
129
|
+
//? vendor file (a whole build landing between its render and its load) tells, and keeps the right epoch for hello.
|
|
130
|
+
async writeApp(
|
|
131
|
+
graph: CsrDevGraph,
|
|
132
|
+
{ generation, vendorFile, epoch }: Pick<CsrDevManifest, "generation" | "vendorFile" | "epoch">,
|
|
133
|
+
code?: CsrDevCode,
|
|
134
|
+
): Promise<void> {
|
|
80
135
|
const ids = Object.keys(graph.modules)
|
|
81
136
|
.filter((id) => !graph.modules[id]?.vendor)
|
|
82
137
|
.sort();
|
|
83
|
-
const lines = await this.#defineLines(ids);
|
|
138
|
+
const lines = await this.#defineLines(ids, code);
|
|
84
139
|
const blocks = ids.map((id, index) => [id, lines[index] ?? ""] as [string, string]);
|
|
85
|
-
const start =
|
|
86
|
-
|
|
140
|
+
const start = this.#library
|
|
141
|
+
? `__akan.startLibrary(${JSON.stringify({ generation, refresh: graph.refresh, bootstrap: graph.entries[""], vendorFile, epoch })});\n`
|
|
142
|
+
: `__akan.start(${JSON.stringify({ generation, refresh: graph.refresh })});\n`;
|
|
143
|
+
await this.#writeWithSourceMap(CSR_DEV_APP_FILE, await this.#helperDefinitions(graph, ids, code), blocks, start);
|
|
87
144
|
}
|
|
88
145
|
|
|
89
146
|
async writePatch(generation: number, modules: CsrDevCompiledModule[]): Promise<string> {
|
|
@@ -100,7 +157,7 @@ export class CsrDevArtifactWriter {
|
|
|
100
157
|
);
|
|
101
158
|
const header = `${[...helpers.values()].map((shared) => shared.definition).join("")}__akan.update(${generation}, {\n`;
|
|
102
159
|
await this.#writeWithSourceMap(patchFile, header, blocks, "});\n");
|
|
103
|
-
return `${
|
|
160
|
+
return `${this.#routePrefix}${patchFile}`;
|
|
104
161
|
}
|
|
105
162
|
|
|
106
163
|
// The manifest goes last: the server renders shells from it, so it must never name a file not yet written.
|
|
@@ -109,11 +166,39 @@ export class CsrDevArtifactWriter {
|
|
|
109
166
|
await this.writeJson(CSR_DEV_MANIFEST_FILE, manifest);
|
|
110
167
|
}
|
|
111
168
|
|
|
169
|
+
//? The open tabs need only the patch file and the manifest naming it, so they hear of a patch before the rest lands:
|
|
170
|
+
//? the module files and graph.json (read only by the next build), then app.js (a full rewrite). The manifest says
|
|
171
|
+
//? which generation app.js holds, and the shell holds a tab booting in that gap until it does. A crash in the gap is
|
|
172
|
+
//? recovered by the next build, which sees the edited files' mtimes differ from graph.json and compiles them again.
|
|
173
|
+
async commitPatch(
|
|
174
|
+
graph: CsrDevGraph,
|
|
175
|
+
manifest: CsrDevManifest,
|
|
176
|
+
announce: () => void,
|
|
177
|
+
{ modules, code }: { modules: CsrDevCompiledModule[]; code?: CsrDevCode },
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
await this.writeJson(CSR_DEV_MANIFEST_FILE, manifest);
|
|
180
|
+
announce();
|
|
181
|
+
await CsrDevArtifactWriter.#appWriteDelay();
|
|
182
|
+
await this.writeModules(modules);
|
|
183
|
+
await this.writeJson("graph.json", graph);
|
|
184
|
+
await this.writeApp(graph, manifest, code);
|
|
185
|
+
await this.writeJson(CSR_DEV_MANIFEST_FILE, { ...manifest, appGeneration: manifest.generation });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A process that died between announcing a patch and rewriting app.js left every booting tab one generation behind.
|
|
189
|
+
async healApp(graph: CsrDevGraph, manifest: CsrDevManifest, code?: CsrDevCode): Promise<CsrDevManifest> {
|
|
190
|
+
if (appGenerationOf(manifest) >= manifest.generation) return manifest;
|
|
191
|
+
await this.writeApp(graph, manifest, code);
|
|
192
|
+
const healed = { ...manifest, appGeneration: manifest.generation };
|
|
193
|
+
await this.writeJson(CSR_DEV_MANIFEST_FILE, healed);
|
|
194
|
+
return healed;
|
|
195
|
+
}
|
|
196
|
+
|
|
112
197
|
async prune(vendorFile: string, generation: number): Promise<void> {
|
|
113
198
|
const names = await readdir(this.#outDir);
|
|
114
199
|
const stale = names.filter((name) => {
|
|
115
200
|
const patch = /^patch-(\d+)\.js(?:\.layout\.json)?$/.exec(name);
|
|
116
|
-
return patch ? Number(patch[1]) <= generation -
|
|
201
|
+
return patch ? Number(patch[1]) <= generation - CSR_DEV_KEPT_PATCHES : false;
|
|
117
202
|
});
|
|
118
203
|
const vendors = names
|
|
119
204
|
.filter((name) => /^vendor-[\w-]+\.js$/.test(name) && name !== vendorFile)
|
|
@@ -121,19 +206,55 @@ export class CsrDevArtifactWriter {
|
|
|
121
206
|
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
122
207
|
.slice(CsrDevArtifactWriter.#keptVendors - 1)
|
|
123
208
|
.map(({ name }) => name);
|
|
124
|
-
await Promise.all(
|
|
209
|
+
await Promise.all(
|
|
210
|
+
[...stale, ...vendors].map((name) => CsrDevArtifactWriter.#remove(path.join(this.#outDir, name))),
|
|
211
|
+
);
|
|
125
212
|
}
|
|
126
213
|
|
|
127
|
-
|
|
214
|
+
//? A whole build writes over the registry before it instead of clearing it first, so it clears what that one left:
|
|
215
|
+
//? the modules and helpers it no longer holds, every patch (each was made against the previous registry), the assets
|
|
216
|
+
//? this build did not write (by path: a file's mtime and this process's clock need not agree on a mounted volume),
|
|
217
|
+
//? temp files of a write cut short, and the marker of a builder that died mid-patch.
|
|
218
|
+
async pruneAfterFullBuild(
|
|
219
|
+
graph: CsrDevGraph,
|
|
220
|
+
vendorFile: string,
|
|
221
|
+
{ generation, assets }: { generation: number; assets: string[] },
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
const kept = new Set(
|
|
224
|
+
Object.keys(graph.modules).flatMap((id) => [this.#modulePath(id, ".js"), this.#modulePath(id, ".js.map")]),
|
|
225
|
+
);
|
|
226
|
+
for (const module of Object.values(graph.modules)) if (module.helpers) kept.add(this.#helpersPath(module.helpers));
|
|
227
|
+
for (const asset of assets) kept.add(path.resolve(asset));
|
|
228
|
+
const listed = async (dir: string) =>
|
|
229
|
+
(await readdir(path.join(this.#outDir, dir)).catch(() => [] as string[])).map((name) =>
|
|
230
|
+
path.join(this.#outDir, dir, name),
|
|
231
|
+
);
|
|
232
|
+
const stale = [
|
|
233
|
+
...(await listed("modules")).filter((file) => !kept.has(file)),
|
|
234
|
+
...(await listed("assets")).filter((file) => !kept.has(file)),
|
|
235
|
+
...(await listed(".")).filter((file) => {
|
|
236
|
+
const name = path.basename(file);
|
|
237
|
+
return /^patch-\d+\.js(?:\.layout\.json)?$/.test(name) || name.endsWith(".tmp");
|
|
238
|
+
}),
|
|
239
|
+
path.join(this.#outDir, CSR_DEV_PATCHING_MARKER),
|
|
240
|
+
];
|
|
241
|
+
await Promise.all(stale.map((file) => CsrDevArtifactWriter.#remove(file)));
|
|
242
|
+
await this.prune(vendorFile, generation);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async #helperDefinitions(graph: CsrDevGraph, ids: string[], code?: CsrDevCode): Promise<string> {
|
|
128
246
|
const hashes = [...new Set(ids.flatMap((id) => graph.modules[id]?.helpers ?? []))].sort();
|
|
129
|
-
return (
|
|
247
|
+
return (
|
|
248
|
+
await Promise.all(hashes.map(async (hash) => code?.helpers.get(hash) ?? Bun.file(this.#helpersPath(hash)).text()))
|
|
249
|
+
).join("");
|
|
130
250
|
}
|
|
131
251
|
|
|
132
|
-
async #defineLines(ids: string[]): Promise<string[]> {
|
|
252
|
+
async #defineLines(ids: string[], code?: CsrDevCode): Promise<string[]> {
|
|
133
253
|
return await Promise.all(
|
|
134
|
-
ids.map(
|
|
135
|
-
|
|
136
|
-
|
|
254
|
+
ids.map(async (id) => {
|
|
255
|
+
const factory = code?.modules.get(id) ?? (await Bun.file(this.#modulePath(id, ".js")).text());
|
|
256
|
+
return `__akan.define(${JSON.stringify(id)}, ${factory});\n`;
|
|
257
|
+
}),
|
|
137
258
|
);
|
|
138
259
|
}
|
|
139
260
|
|
|
@@ -156,7 +277,20 @@ export class CsrDevArtifactWriter {
|
|
|
156
277
|
const target = path.join(this.#outDir, name);
|
|
157
278
|
const temp = `${target}.${process.pid}.tmp`;
|
|
158
279
|
await Bun.write(temp, content);
|
|
159
|
-
await
|
|
280
|
+
await FileSys.replace(temp, target);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
//? Best effort, after the tabs already have the update: Windows refuses to delete a file a reader holds open (the
|
|
284
|
+
//? server streaming it to a tab), and throwing here would send a patch that already went out to a build worker.
|
|
285
|
+
//? A file that stays is removed by a later prune.
|
|
286
|
+
static async #remove(file: string): Promise<void> {
|
|
287
|
+
await rm(file, { force: true }).catch(() => undefined);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
//? A test-only hook that widens the gap between the patch and app.js, so an E2E can boot a tab inside it.
|
|
291
|
+
static async #appWriteDelay(): Promise<void> {
|
|
292
|
+
const ms = Number(process.env.AKAN_CSR_DEV_APP_WRITE_DELAY_MS);
|
|
293
|
+
if (Number.isInteger(ms) && ms > 0) await Bun.sleep(ms);
|
|
160
294
|
}
|
|
161
295
|
|
|
162
296
|
#modulePath(id: string, extension: ".js" | ".js.map"): string {
|