@akanjs/devkit 3.0.0-beta.24 → 3.0.0-beta.26
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 +44 -26
- package/akanApp/akanApp.host.ts +113 -23
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +71 -1
- package/akanApp/devHostPolicy.ts +29 -4
- 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 +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +117 -70
- 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.test.ts +89 -0
- package/frontendBuild/csrDevModuleCompiler.ts +56 -10
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +81 -1
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +119 -18
- package/frontendBuild/csrDevTypes.ts +16 -1
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCompiler.ts +2 -4
- package/frontendBuild/devRegistryBundler.ts +192 -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 +85 -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 +35 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +50 -6
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +190 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +560 -60
- package/integration/devStability.integration.test.ts +25 -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,112 @@
|
|
|
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
|
+
|
|
38
|
+
const tempDir = async () => {
|
|
39
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "akan-discovery-"));
|
|
40
|
+
dirs.push(dir);
|
|
41
|
+
return (name: string) => path.join(dir, name);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
test("an entry moved into a folder, or to another extension, is found once the move is invalidated", async () => {
|
|
45
|
+
const file = await tempDir();
|
|
46
|
+
await Bun.write(file("S.tsx"), 'import "./ui/Chart";\nimport "./ui/Card";\n');
|
|
47
|
+
await Bun.write(file("ui/Chart.tsx"), '"use client";\nexport const Chart = () => null;\n');
|
|
48
|
+
await Bun.write(file("ui/Card.tsx"), '"use client";\nexport const Card = () => null;\n');
|
|
49
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
|
|
50
|
+
expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Card.tsx"), file("ui/Chart.tsx")]);
|
|
51
|
+
|
|
52
|
+
await rm(file("ui/Chart.tsx"));
|
|
53
|
+
await Bun.write(file("ui/Chart/index.tsx"), '"use client";\nexport const Chart = () => null;\n');
|
|
54
|
+
await rm(file("ui/Card.tsx"));
|
|
55
|
+
await Bun.write(file("ui/Card.ts"), '"use client";\nexport const Card = () => null;\n');
|
|
56
|
+
discovery.invalidate([file("ui/Chart.tsx"), file("ui/Chart/index.tsx"), file("ui/Card.tsx"), file("ui/Card.ts")]);
|
|
57
|
+
expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Card.ts"), file("ui/Chart/index.tsx")]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a resolution still in flight when its target appears is not what the next walk reads", async () => {
|
|
61
|
+
const file = await tempDir();
|
|
62
|
+
await Bun.write(file("S.tsx"), 'import "./B";\n');
|
|
63
|
+
await Bun.write(file("B.tsx"), 'import "@demo/C";\nexport const b = 1;\n');
|
|
64
|
+
const gate = Promise.withResolvers<void>();
|
|
65
|
+
const seen = Promise.withResolvers<void>();
|
|
66
|
+
let calls = 0;
|
|
67
|
+
const resolvePackage = async () => {
|
|
68
|
+
calls += 1;
|
|
69
|
+
const exists = await Bun.file(file("C.tsx")).exists();
|
|
70
|
+
if (calls === 1) {
|
|
71
|
+
seen.resolve();
|
|
72
|
+
await gate.promise;
|
|
73
|
+
}
|
|
74
|
+
return exists ? { entryFile: file("C.tsx") } : null;
|
|
75
|
+
};
|
|
76
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, resolvePackage as never);
|
|
77
|
+
const walk = discovery.discover([file("S.tsx")]);
|
|
78
|
+
await seen.promise;
|
|
79
|
+
await Bun.write(file("C.tsx"), '"use client";\nexport const C = () => null;\n');
|
|
80
|
+
discovery.invalidate([file("C.tsx")]);
|
|
81
|
+
gate.resolve();
|
|
82
|
+
await walk;
|
|
83
|
+
expect(await discovery.discover([file("S.tsx")])).toEqual([file("C.tsx")]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a dense import cycle is walked once per file, not once per path", async () => {
|
|
87
|
+
const file = await tempDir();
|
|
88
|
+
const names = Array.from({ length: 12 }, (_, idx) => `M${idx}.tsx`);
|
|
89
|
+
for (const name of names)
|
|
90
|
+
await Bun.write(file(name), names.map((other) => `import "./${other.replace(".tsx", "")}";\n`).join(""));
|
|
91
|
+
await Bun.write(file("C.tsx"), '"use client";\nexport const C = () => null;\n');
|
|
92
|
+
await Bun.write(
|
|
93
|
+
file("M11.tsx"),
|
|
94
|
+
`${names.map((other) => `import "./${other.replace(".tsx", "")}";\n`).join("")}import "./C";\n`,
|
|
95
|
+
);
|
|
96
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
|
|
97
|
+
const started = performance.now();
|
|
98
|
+
expect(await discovery.discover([file("M0.tsx")])).toEqual([file("C.tsx")]);
|
|
99
|
+
expect(performance.now() - started).toBeLessThan(2_000);
|
|
100
|
+
expect(await discovery.discover([file("M5.tsx")])).toEqual([file("C.tsx")]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a file walked inside an import cycle keeps no partial result for a walk that starts at it", async () => {
|
|
104
|
+
const file = await tempDir();
|
|
105
|
+
await Bun.write(file("A.tsx"), 'import "./B";\nimport "./C";\n');
|
|
106
|
+
await Bun.write(file("B.tsx"), 'import "./A";\n');
|
|
107
|
+
await Bun.write(file("C.tsx"), '"use client";\nexport const C = () => null;\n');
|
|
108
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
|
|
109
|
+
expect(await discovery.discover([file("A.tsx")])).toEqual([file("C.tsx")]);
|
|
110
|
+
expect(await discovery.discover([file("B.tsx")])).toEqual([file("C.tsx")]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { SOURCE_EXTS } from "../akanApp/devHostPolicy";
|
|
3
4
|
import type { App } from "../commandDecorators";
|
|
@@ -17,6 +18,21 @@ interface FileFacts {
|
|
|
17
18
|
imports: ScannedImport[];
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
interface Lookup<T> {
|
|
22
|
+
epoch: number;
|
|
23
|
+
promise: Promise<T>;
|
|
24
|
+
found?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DiscoveryWalk {
|
|
28
|
+
visiting: Set<string>;
|
|
29
|
+
//? Walked below a file the walk was already inside (an import cycle): their result lacks what that file reaches.
|
|
30
|
+
partial: Set<string>;
|
|
31
|
+
//? Every file's result within this walk, partial ones included: the file the cycle went back to is an ancestor here
|
|
32
|
+
//? and brings the rest itself, and re-walking each path through a dense cycle grows exponentially.
|
|
33
|
+
memo: Map<string, Set<string>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
const shouldSkipNodeModule = (absPath: string) => NODE_MODULES_RE.test(absPath) && !AKANJS_NODE_MODULE_RE.test(absPath);
|
|
21
37
|
|
|
22
38
|
/** `"use client"` discovery over the import graph (dynamic imports included), flattening barrels as the bundler does. */
|
|
@@ -25,15 +41,16 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
25
41
|
#resolvePackage: PackageResolver;
|
|
26
42
|
#analyzer: BarrelAnalyzer;
|
|
27
43
|
#tsTranspiler = new Bun.Transpiler({ loader: "tsx" });
|
|
28
|
-
#fileExistsCache = new Map<string,
|
|
44
|
+
#fileExistsCache = new Map<string, Lookup<boolean>>();
|
|
29
45
|
// Facts, never source text: this instance lives as long as the builder process, so texts would pin every file walked.
|
|
30
46
|
#factsCache = new Map<string, Promise<FileFacts | null>>();
|
|
31
|
-
#resolvedFileCache = new Map<string,
|
|
32
|
-
#resolvedSpecifierCache = new Map<string,
|
|
47
|
+
#resolvedFileCache = new Map<string, Lookup<string | null>>();
|
|
48
|
+
#resolvedSpecifierCache = new Map<string, Lookup<string | null>>();
|
|
33
49
|
#reachableEntriesCache = new Map<string, Set<string>>();
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
#readFiles = new Set<string>();
|
|
51
|
+
//? Bumped by every invalidation: a walk that began before one computed from facts it may have read stale, so it
|
|
52
|
+
//? answers its caller but caches nothing (a registry check walks this instance beside the slow lane's route builds).
|
|
53
|
+
#epoch = 0;
|
|
37
54
|
|
|
38
55
|
constructor(akanConfig: Pick<AkanConfig, "barrelImports">, resolvePackage: PackageResolver) {
|
|
39
56
|
this.#akanConfig = akanConfig;
|
|
@@ -48,47 +65,61 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
48
65
|
async discover(seeds: string[]): Promise<string[]> {
|
|
49
66
|
const entries = new Set<string>();
|
|
50
67
|
for (const seed of seeds) {
|
|
51
|
-
|
|
68
|
+
const walk: DiscoveryWalk = { visiting: new Set(), partial: new Set(), memo: new Map() };
|
|
69
|
+
for (const entry of await this.#discoverFromFile(seed, walk)) entries.add(entry);
|
|
52
70
|
}
|
|
53
71
|
return Array.from(entries).sort();
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
invalidate(files: string[]): void {
|
|
75
|
+
if (files.length === 0) return;
|
|
76
|
+
let moved = false;
|
|
57
77
|
for (const file of files) {
|
|
58
78
|
const absPath = path.resolve(file);
|
|
79
|
+
const exists = fs.existsSync(absPath);
|
|
80
|
+
if (exists !== this.#readFiles.has(absPath)) moved = true;
|
|
81
|
+
if (!exists) this.#readFiles.delete(absPath);
|
|
59
82
|
this.#factsCache.delete(absPath);
|
|
60
83
|
this.#fileExistsCache.delete(absPath);
|
|
61
|
-
this.#reachableEntriesCache.delete(absPath);
|
|
62
84
|
}
|
|
63
|
-
|
|
85
|
+
this.#epoch += 1;
|
|
64
86
|
// Reachable-entry results are transitive, so a changed child invalidates every ancestor.
|
|
65
87
|
this.#reachableEntriesCache.clear();
|
|
66
|
-
|
|
88
|
+
//? A file gone, or one never read, can leave a found resolution on its old target (`Chart.tsx` to
|
|
89
|
+
//? `Chart/index.tsx`), so a move drops them all; a save of a file already read keeps them (a walk re-resolving
|
|
90
|
+
//? every package cost 3x). Misses lapse with the epoch on their own (#lookup).
|
|
91
|
+
if (!moved) return;
|
|
92
|
+
this.#fileExistsCache.clear();
|
|
93
|
+
this.#resolvedFileCache.clear();
|
|
94
|
+
this.#resolvedSpecifierCache.clear();
|
|
67
95
|
}
|
|
68
96
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
this.#
|
|
75
|
-
|
|
76
|
-
|
|
97
|
+
//? A found answer outlives later epochs; a miss, or a lookup still in flight, answers only walks of its own epoch, so
|
|
98
|
+
//? a file created meanwhile is looked up again.
|
|
99
|
+
#lookup<T>(cache: Map<string, Lookup<T>>, key: string, run: () => Promise<T>, isFound: (value: T) => boolean) {
|
|
100
|
+
const cached = cache.get(key);
|
|
101
|
+
if (cached && (cached.found || cached.epoch === this.#epoch)) return cached.promise;
|
|
102
|
+
const entry: Lookup<T> = { epoch: this.#epoch, promise: run() };
|
|
103
|
+
entry.promise.then(
|
|
104
|
+
(value) => {
|
|
105
|
+
entry.found = isFound(value);
|
|
106
|
+
},
|
|
107
|
+
() => {
|
|
108
|
+
entry.found = false;
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
cache.set(key, entry);
|
|
112
|
+
return entry.promise;
|
|
77
113
|
}
|
|
78
114
|
|
|
79
115
|
async #fileExists(p: string): Promise<boolean> {
|
|
80
116
|
const absPath = path.resolve(p);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return exists;
|
|
88
|
-
});
|
|
89
|
-
this.#fileExistsCache.set(absPath, cached);
|
|
90
|
-
}
|
|
91
|
-
return cached;
|
|
117
|
+
return await this.#lookup(
|
|
118
|
+
this.#fileExistsCache,
|
|
119
|
+
absPath,
|
|
120
|
+
() => Bun.file(absPath).exists(),
|
|
121
|
+
(exists) => exists,
|
|
122
|
+
);
|
|
92
123
|
}
|
|
93
124
|
|
|
94
125
|
#facts(file: string): Promise<FileFacts | null> {
|
|
@@ -100,6 +131,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
100
131
|
.text()
|
|
101
132
|
.catch(() => null);
|
|
102
133
|
if (content === null) return null;
|
|
134
|
+
this.#readFiles.add(absPath);
|
|
103
135
|
if (USE_CLIENT_RE.test(content)) return { isClientEntry: true, imports: [] };
|
|
104
136
|
return { isClientEntry: false, imports: this.#scanImports(await this.#rewrite(content)) };
|
|
105
137
|
})();
|
|
@@ -127,57 +159,71 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
127
159
|
|
|
128
160
|
async #resolveFileCandidate(absPathNoExt: string): Promise<string | null> {
|
|
129
161
|
const cacheKey = path.resolve(absPathNoExt);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
162
|
+
return await this.#lookup(
|
|
163
|
+
this.#resolvedFileCache,
|
|
164
|
+
cacheKey,
|
|
165
|
+
async () => {
|
|
166
|
+
if (await this.#fileExists(cacheKey)) return cacheKey;
|
|
167
|
+
for (const ext of SOURCE_EXTS) {
|
|
168
|
+
const f = `${cacheKey}${ext}`;
|
|
169
|
+
if (await this.#fileExists(f)) return f;
|
|
170
|
+
}
|
|
171
|
+
for (const ext of SOURCE_EXTS) {
|
|
172
|
+
const f = path.join(cacheKey, `index${ext}`);
|
|
173
|
+
if (await this.#fileExists(f)) return f;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
},
|
|
177
|
+
(file) => file !== null,
|
|
178
|
+
);
|
|
147
179
|
}
|
|
148
180
|
|
|
149
181
|
async #resolveSpecifier(spec: string, importerDir: string): Promise<string | null> {
|
|
150
182
|
const cacheKey = `${importerDir}\0${spec}`;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return cached;
|
|
183
|
+
return await this.#lookup(
|
|
184
|
+
this.#resolvedSpecifierCache,
|
|
185
|
+
cacheKey,
|
|
186
|
+
async () => {
|
|
187
|
+
if (spec.startsWith(".") || spec.startsWith("/")) {
|
|
188
|
+
const abs = spec.startsWith("/") ? spec : path.resolve(importerDir, spec);
|
|
189
|
+
return await this.#resolveFileCandidate(abs);
|
|
190
|
+
}
|
|
191
|
+
const pkg = await this.#resolvePackage(spec);
|
|
192
|
+
return pkg ? pkg.entryFile : null;
|
|
193
|
+
},
|
|
194
|
+
(file) => file !== null,
|
|
195
|
+
);
|
|
165
196
|
}
|
|
166
197
|
|
|
167
|
-
async #discoverFromFile(file: string,
|
|
198
|
+
async #discoverFromFile(file: string, walk: DiscoveryWalk): Promise<Set<string>> {
|
|
199
|
+
const epoch = this.#epoch;
|
|
168
200
|
const absPath = path.resolve(file);
|
|
169
201
|
const cached = this.#reachableEntriesCache.get(absPath);
|
|
170
202
|
if (cached) return new Set(cached);
|
|
171
|
-
|
|
203
|
+
const memo = walk.memo.get(absPath);
|
|
204
|
+
if (memo) {
|
|
205
|
+
//? Reused from another path: whatever this path is walking now inherits its gap and stays out of the cache.
|
|
206
|
+
if (walk.partial.has(absPath)) for (const onPath of walk.visiting) walk.partial.add(onPath);
|
|
207
|
+
return new Set(memo);
|
|
208
|
+
}
|
|
209
|
+
if (walk.visiting.has(absPath)) {
|
|
210
|
+
let below = false;
|
|
211
|
+
for (const onPath of walk.visiting) {
|
|
212
|
+
if (below) walk.partial.add(onPath);
|
|
213
|
+
else below = onPath === absPath;
|
|
214
|
+
}
|
|
215
|
+
return new Set();
|
|
216
|
+
}
|
|
217
|
+
if (shouldSkipNodeModule(absPath)) return new Set();
|
|
172
218
|
|
|
173
|
-
visiting.add(absPath);
|
|
219
|
+
walk.visiting.add(absPath);
|
|
174
220
|
const entries = new Set<string>();
|
|
175
221
|
const facts = await this.#facts(absPath);
|
|
176
|
-
if (!facts) return this.#finishDiscovery(absPath,
|
|
222
|
+
if (!facts) return this.#finishDiscovery(absPath, walk, entries, epoch);
|
|
177
223
|
|
|
178
224
|
if (facts.isClientEntry) {
|
|
179
225
|
entries.add(absPath);
|
|
180
|
-
return this.#finishDiscovery(absPath,
|
|
226
|
+
return this.#finishDiscovery(absPath, walk, entries, epoch);
|
|
181
227
|
}
|
|
182
228
|
|
|
183
229
|
const importerDir = path.dirname(absPath);
|
|
@@ -188,15 +234,16 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
188
234
|
const resolved = await this.#resolveSpecifier(spec, importerDir);
|
|
189
235
|
if (!resolved) continue;
|
|
190
236
|
if (shouldSkipNodeModule(resolved)) continue;
|
|
191
|
-
for (const entry of await this.#discoverFromFile(resolved,
|
|
237
|
+
for (const entry of await this.#discoverFromFile(resolved, walk)) entries.add(entry);
|
|
192
238
|
}
|
|
193
239
|
|
|
194
|
-
return this.#finishDiscovery(absPath,
|
|
240
|
+
return this.#finishDiscovery(absPath, walk, entries, epoch);
|
|
195
241
|
}
|
|
196
242
|
|
|
197
|
-
#finishDiscovery(absPath: string,
|
|
198
|
-
visiting.delete(absPath);
|
|
199
|
-
|
|
243
|
+
#finishDiscovery(absPath: string, walk: DiscoveryWalk, entries: Set<string>, epoch: number): Set<string> {
|
|
244
|
+
walk.visiting.delete(absPath);
|
|
245
|
+
walk.memo.set(absPath, entries);
|
|
246
|
+
if (epoch === this.#epoch && !walk.partial.has(absPath)) this.#reachableEntriesCache.set(absPath, entries);
|
|
200
247
|
return new Set(entries);
|
|
201
248
|
}
|
|
202
249
|
}
|
|
@@ -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
|
+
});
|