@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
|
@@ -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 {
|
|
@@ -1,66 +1,34 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import { mkdir } from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
|
-
import { CSR_DEV_DIRNAME,
|
|
5
|
-
import { isAkanRuntimeMetadataFile } from "akanjs/server/hmr/runtimeMetadataFile";
|
|
3
|
+
import { CSR_DEV_DIRNAME, CSR_DEV_ROUTE_PREFIX } from "akanjs/server/hmr/csrDevManifest";
|
|
6
4
|
import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
7
5
|
import type { App } from "../commandDecorators";
|
|
6
|
+
import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
|
|
8
7
|
import { bundleDefine } from "./bundleDefine";
|
|
9
|
-
import { CsrArtifactBuilder } from "./csrArtifactBuilder";
|
|
10
|
-
import { CsrDevArtifactWriter } from "./csrDevArtifactWriter";
|
|
11
|
-
import { CsrDevModuleCompiler } from "./csrDevModuleCompiler";
|
|
12
8
|
import { CsrDevPaths } from "./csrDevPaths";
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
9
|
+
import type { CsrDevContext } from "./csrDevTypes";
|
|
10
|
+
import { CsrEntryFiles } from "./csrEntryFiles";
|
|
11
|
+
import { DevRegistryBundler } from "./devRegistryBundler";
|
|
15
12
|
import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
|
|
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 {
|
|
14
|
+
//* Dev-only CSR as a module registry, under `.akan/artifact/csr-dev`: one entry per basePath boots every page.
|
|
15
|
+
export class CsrDevBundler extends DevRegistryBundler {
|
|
29
16
|
static readonly #formatVersion = 3;
|
|
30
|
-
readonly
|
|
31
|
-
|
|
32
|
-
readonly #
|
|
33
|
-
readonly #entryDir: string;
|
|
34
|
-
readonly #writer: CsrDevArtifactWriter;
|
|
17
|
+
readonly reloadsOnEntryChange = true;
|
|
18
|
+
//? Detecting a page's async default parses it with TypeScript: on apps/akan every page each save cost ~100ms.
|
|
19
|
+
readonly #asyncDefaults = new Map<string, { mtimeMs: number; detected: Promise<boolean> }>();
|
|
35
20
|
|
|
36
21
|
constructor(app: App) {
|
|
37
|
-
|
|
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);
|
|
22
|
+
super(app, { dirName: CSR_DEV_DIRNAME, routePrefix: CSR_DEV_ROUTE_PREFIX, library: false });
|
|
42
23
|
}
|
|
43
24
|
|
|
44
|
-
async
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
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());
|
|
25
|
+
async context(): Promise<CsrDevContext | null> {
|
|
26
|
+
const akanConfig = await this.app.getConfig();
|
|
27
|
+
const pageEntries = await resolveSsrPageEntriesForApp(this.app, await this.app.getPageKeys());
|
|
28
|
+
if (pageEntries.length === 0) return null;
|
|
61
29
|
const basePaths = [...akanConfig.basePaths];
|
|
62
30
|
const htmlBasePaths = basePaths.length > 0 ? basePaths : [""];
|
|
63
|
-
const define = bundleDefine(this
|
|
31
|
+
const define = bundleDefine(this.app, "start", "csr");
|
|
64
32
|
const optimizeImports = [...akanConfig.optimizeImports];
|
|
65
33
|
const configKey = Bun.hash(
|
|
66
34
|
JSON.stringify([CsrDevBundler.#formatVersion, Bun.version, define, optimizeImports, htmlBasePaths]),
|
|
@@ -76,152 +44,21 @@ export class CsrDevBundler {
|
|
|
76
44
|
};
|
|
77
45
|
}
|
|
78
46
|
|
|
79
|
-
|
|
80
|
-
|
|
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 });
|
|
47
|
+
async writeEntries(context: CsrDevContext): Promise<{ files: Record<string, string>; changed: string[] }> {
|
|
48
|
+
await mkdir(this.entryDir, { recursive: true });
|
|
214
49
|
const files: Record<string, string> = {};
|
|
215
50
|
const changed: string[] = [];
|
|
216
51
|
for (const basePath of context.htmlBasePaths) {
|
|
217
|
-
const file = path.join(this
|
|
218
|
-
const entryPages =
|
|
219
|
-
const generator = new PagesEntrySourceGenerator(entryPages
|
|
220
|
-
|
|
52
|
+
const file = path.join(this.entryDir, CsrEntryFiles.entryFilename(basePath));
|
|
53
|
+
const entryPages = CsrEntryFiles.pageEntriesForBasePath(context.pageEntries, basePath, context.basePaths);
|
|
54
|
+
const generator = new PagesEntrySourceGenerator(entryPages, {
|
|
55
|
+
isAsyncDefault: (moduleAbsPath) => this.#isAsyncDefault(moduleAbsPath),
|
|
56
|
+
});
|
|
57
|
+
const pages = await generator.generateStatic({ fromDir: this.entryDir });
|
|
221
58
|
const hot = await generator.generateHotReplace({
|
|
222
|
-
fromDir: this
|
|
223
|
-
ownerId: this
|
|
224
|
-
moduleIds: entryPages.map(({ moduleAbsPath }) => this
|
|
59
|
+
fromDir: this.entryDir,
|
|
60
|
+
ownerId: this.paths.idOf(file),
|
|
61
|
+
moduleIds: entryPages.map(({ moduleAbsPath }) => this.paths.idOf(moduleAbsPath)),
|
|
225
62
|
});
|
|
226
63
|
const source = `import * as akanWebkit from "akanjs/webkit";\n${pages}\nvoid akanWebkit.bootCsr(pages);\n${hot}`;
|
|
227
64
|
const unchanged = (await Bun.file(file).exists()) && (await Bun.file(file).text()) === source;
|
|
@@ -233,24 +70,26 @@ export class CsrDevBundler {
|
|
|
233
70
|
return { files, changed };
|
|
234
71
|
}
|
|
235
72
|
|
|
236
|
-
|
|
237
|
-
return
|
|
73
|
+
protected async rootFiles(entryFiles: string[]): Promise<string[]> {
|
|
74
|
+
return entryFiles;
|
|
238
75
|
}
|
|
239
76
|
|
|
240
|
-
#
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
...(module.helpers ? { helpers: module.helpers.hash } : {}),
|
|
248
|
-
};
|
|
249
|
-
graph.pending = [];
|
|
250
|
-
graph.resolution = resolver.serialize();
|
|
77
|
+
#isAsyncDefault(moduleAbsPath: string): Promise<boolean> {
|
|
78
|
+
const mtimeMs = CsrDevPaths.mtimeOf(moduleAbsPath);
|
|
79
|
+
const cached = this.#asyncDefaults.get(moduleAbsPath);
|
|
80
|
+
if (cached?.mtimeMs === mtimeMs) return cached.detected;
|
|
81
|
+
const detected = AsyncDefaultExportDetector.detect(moduleAbsPath);
|
|
82
|
+
this.#asyncDefaults.set(moduleAbsPath, { mtimeMs, detected });
|
|
83
|
+
return detected;
|
|
251
84
|
}
|
|
252
85
|
|
|
253
|
-
#
|
|
254
|
-
|
|
86
|
+
#resolveRefreshRuntime(): string {
|
|
87
|
+
const akanWebkit = CsrDevPaths.tryResolve("akanjs/webkit", this.app.cwdPath);
|
|
88
|
+
const bases = [this.app.cwdPath, this.paths.root, ...(akanWebkit ? [path.dirname(path.dirname(akanWebkit))] : [])];
|
|
89
|
+
for (const base of bases) {
|
|
90
|
+
const resolved = CsrDevPaths.tryResolve("react-refresh/runtime", base);
|
|
91
|
+
if (resolved) return CsrDevPaths.realpath(resolved);
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`[csr-dev] react-refresh/runtime is not resolvable from ${bases.join(", ")}`);
|
|
255
94
|
}
|
|
256
95
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
import type { App } from "../commandDecorators";
|
|
6
|
+
import { CsrDevModuleCompiler } from "./csrDevModuleCompiler";
|
|
7
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
8
|
+
import { CsrDevResolver } from "./csrDevResolver";
|
|
9
|
+
import type { CsrDevContext } from "./csrDevTypes";
|
|
10
|
+
|
|
11
|
+
const context: CsrDevContext = {
|
|
12
|
+
pageEntries: [],
|
|
13
|
+
basePaths: [],
|
|
14
|
+
htmlBasePaths: [],
|
|
15
|
+
define: {},
|
|
16
|
+
optimizeImports: [],
|
|
17
|
+
configKey: "key",
|
|
18
|
+
refreshFile: null,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//? esbuild's own interop helpers, as a pre-bundled chunk ships them (@mermaid-js/parser's dist/chunks).
|
|
22
|
+
const prebundled = `var __create = Object.create;
|
|
23
|
+
var __defProp = Object.defineProperty;
|
|
24
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
25
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
26
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
27
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
28
|
+
var __copyProps = (to, from, except, desc) => {
|
|
29
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
30
|
+
for (let key of __getOwnPropNames(from))
|
|
31
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
32
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
33
|
+
}
|
|
34
|
+
return to;
|
|
35
|
+
};
|
|
36
|
+
var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
|
|
37
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod));
|
|
38
|
+
var parts = {};
|
|
39
|
+
__reExport(parts, { a: 1 });
|
|
40
|
+
var wrapped = __toESM({ b: 2 });
|
|
41
|
+
export { parts, wrapped };
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
describe("CsrDevModuleCompiler", () => {
|
|
45
|
+
let root: string;
|
|
46
|
+
const file = (relative: string) => path.join(root, relative);
|
|
47
|
+
const compile = async (relative: string) => {
|
|
48
|
+
const paths = new CsrDevPaths(root);
|
|
49
|
+
const compiler = new CsrDevModuleCompiler({
|
|
50
|
+
app: { verbose: () => undefined } as unknown as App,
|
|
51
|
+
paths,
|
|
52
|
+
resolver: new CsrDevResolver({ paths, context, entryFiles: [] }),
|
|
53
|
+
context,
|
|
54
|
+
outDir: file("out"),
|
|
55
|
+
routePrefix: "/_akan/registry/",
|
|
56
|
+
});
|
|
57
|
+
return await compiler.compile([file(relative)], new Set());
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
root = CsrDevPaths.realpath(await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-compiler-")));
|
|
62
|
+
await Bun.write(file("node_modules/prebundled/chunk.mjs"), prebundled);
|
|
63
|
+
await Bun.write(file("node_modules/bare-reexport/index.mjs"), "export const parts = __reExport({}, { a: 1 });\n");
|
|
64
|
+
await Bun.write(file("node_modules/bare-toesm/index.mjs"), "export const wrapped = __toESM({ b: 2 });\n");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(async () => {
|
|
68
|
+
await rm(root, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a pre-bundled file keeps its own __reExport and __toESM, which Bun leaves in place of its own", async () => {
|
|
72
|
+
const { modules } = await compile("node_modules/prebundled/chunk.mjs");
|
|
73
|
+
const [module] = modules;
|
|
74
|
+
expect(module?.id).toBe("node_modules/prebundled/chunk.mjs");
|
|
75
|
+
const factory = module?.factory ?? "";
|
|
76
|
+
expect(factory).toContain("var __reExport = (target, mod, secondTarget) => (");
|
|
77
|
+
expect(factory).toContain("var __toESM = (mod, isNodeMode, target) => (");
|
|
78
|
+
expect(factory).not.toContain("__akan.reExport");
|
|
79
|
+
expect(factory).not.toContain("__akan.toESM");
|
|
80
|
+
expect(() => new Function(`return (${factory});`)).not.toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test.each([
|
|
84
|
+
["__reExport", "node_modules/bare-reexport/index.mjs"],
|
|
85
|
+
["__toESM", "node_modules/bare-toesm/index.mjs"],
|
|
86
|
+
])("a %s call with no definition anywhere still stops the build", async (name, relative) => {
|
|
87
|
+
await expect(compile(relative)).rejects.toThrow(`Bun's ${name} helper no longer reads`);
|
|
88
|
+
});
|
|
89
|
+
});
|