@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,12 +1,15 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import type { BaseBuildArtifact, ClientManifest, SsrManifest } from "akanjs/server";
|
|
3
|
+
import type { BaseBuildArtifact, ClientManifest, ClientManifestEntry, SsrManifest } from "akanjs/server";
|
|
4
|
+
import { SSR_DEV_CHUNK, SSR_DEV_ID_PREFIX } from "akanjs/server/hmr/csrDevManifest";
|
|
4
5
|
import type { App } from "../commandDecorators";
|
|
5
6
|
import { createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
|
|
7
|
+
import { loaderFor } from "../transforms/moduleSyntax";
|
|
6
8
|
import { scanUseClientExports, toClientReferencePath } from "../transforms/rscUseClientTransform";
|
|
7
9
|
import type { ClientBundleTarget, ClientEntryDiscovery } from "./clientBuildTypes";
|
|
8
10
|
import { ClientEntriesBundler } from "./clientEntriesBundler";
|
|
9
11
|
import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
|
|
12
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
10
13
|
import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
|
|
11
14
|
|
|
12
15
|
const SSR_CLIENT_ALIAS_EXTERNALS = [
|
|
@@ -30,6 +33,8 @@ export interface BuildRouteClientOptions {
|
|
|
30
33
|
discovery?: ClientEntryDiscovery;
|
|
31
34
|
/** Pre-resolved client entries: skips discovery and bundles exactly this list. */
|
|
32
35
|
entries?: string[];
|
|
36
|
+
/** Defaults to `registry` under `start`: the browser loads each entry from the SSR dev registry, so only `client-ssr` builds. */
|
|
37
|
+
browser?: "chunks" | "registry";
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export interface BuildRouteClientResult {
|
|
@@ -39,6 +44,8 @@ export interface BuildRouteClientResult {
|
|
|
39
44
|
discoveredEntries?: string[];
|
|
40
45
|
clientDeps: string[];
|
|
41
46
|
clientDepsByEntry?: Record<string, string[]>;
|
|
47
|
+
/** Registry mode: the entries the manifest rows name, which the SSR dev registry must hold before they are served. */
|
|
48
|
+
registryEntries?: string[];
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
interface BootstrapEntries {
|
|
@@ -54,6 +61,7 @@ export class RouteClientBuilder {
|
|
|
54
61
|
#command: "build" | "start";
|
|
55
62
|
#discovery?: ClientEntryDiscovery;
|
|
56
63
|
#entries?: string[];
|
|
64
|
+
#browser: "chunks" | "registry";
|
|
57
65
|
|
|
58
66
|
constructor(options: BuildRouteClientOptions) {
|
|
59
67
|
this.#app = options.app;
|
|
@@ -63,6 +71,7 @@ export class RouteClientBuilder {
|
|
|
63
71
|
this.#command = options.command ?? "start";
|
|
64
72
|
this.#discovery = options.discovery;
|
|
65
73
|
this.#entries = options.entries;
|
|
74
|
+
this.#browser = options.browser ?? (this.#command === "start" ? "registry" : "chunks");
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
async build(): Promise<BuildRouteClientResult> {
|
|
@@ -73,8 +82,11 @@ export class RouteClientBuilder {
|
|
|
73
82
|
: discovered.filter((e) => !this.#knownEntries.has(e));
|
|
74
83
|
|
|
75
84
|
const bootstrapEntries = await this.#createBootstrapEntries(entries);
|
|
76
|
-
|
|
85
|
+
//? Registry mode builds no browser bundle: the SSR bundle has the same entries, exports and imports to read.
|
|
86
|
+
const registry = this.#browser === "registry" ? new CsrDevPaths(this.#app.workspace.workspaceRoot) : null;
|
|
87
|
+
const browserBundle = registry ? null : await this.#buildBrowserBundle(bootstrapEntries);
|
|
77
88
|
const ssrBundle = await this.#buildSsrBundle(bootstrapEntries);
|
|
89
|
+
const referenceBundle = browserBundle ?? ssrBundle;
|
|
78
90
|
|
|
79
91
|
const acceptedEntries = new Set(entries);
|
|
80
92
|
const routeEntries = new Set(discovered);
|
|
@@ -82,15 +94,18 @@ export class RouteClientBuilder {
|
|
|
82
94
|
const ssrModuleMap: SsrManifest["moduleMap"] = {};
|
|
83
95
|
const clientDeps = new Set<string>();
|
|
84
96
|
const clientDepsByEntry: Record<string, string[]> = {};
|
|
85
|
-
for (const [key,
|
|
97
|
+
for (const [key, bundleRow] of Object.entries(referenceBundle.manifest)) {
|
|
86
98
|
const manifestEntry = RouteClientBuilder.resolveOriginalManifestEntry(
|
|
87
99
|
key,
|
|
88
100
|
bootstrapEntries.originalByBuildEntry,
|
|
89
|
-
|
|
101
|
+
referenceBundle.clientReferenceIdByAbsPath,
|
|
90
102
|
this.#app.workspace.workspaceRoot,
|
|
91
103
|
);
|
|
92
104
|
if (!manifestEntry) continue;
|
|
93
105
|
if (!acceptedEntries.has(manifestEntry.originalEntry)) continue;
|
|
106
|
+
const row = registry
|
|
107
|
+
? RouteClientBuilder.#registryRow(registry, manifestEntry.originalEntry, bundleRow)
|
|
108
|
+
: bundleRow;
|
|
94
109
|
manifestDelta[manifestEntry.key] = row;
|
|
95
110
|
|
|
96
111
|
const ssrOutput = ssrBundle.entryOutputAbsByAbsPath.get(manifestEntry.buildEntry);
|
|
@@ -104,7 +119,7 @@ export class RouteClientBuilder {
|
|
|
104
119
|
const originalEntry = path.resolve(bootstrapEntries.originalByBuildEntry.get(buildEntry) ?? buildEntry);
|
|
105
120
|
if (!acceptedEntries.has(originalEntry)) continue;
|
|
106
121
|
const deps = new Set<string>([originalEntry]);
|
|
107
|
-
for (const dep of
|
|
122
|
+
for (const dep of referenceBundle.entryDepsByAbsPath.get(buildEntry) ?? []) deps.add(path.resolve(dep));
|
|
108
123
|
const sortedDeps = [...deps].sort();
|
|
109
124
|
clientDepsByEntry[originalEntry] = sortedDeps;
|
|
110
125
|
if (routeEntries.has(originalEntry)) for (const dep of sortedDeps) clientDeps.add(dep);
|
|
@@ -117,6 +132,16 @@ export class RouteClientBuilder {
|
|
|
117
132
|
discoveredEntries: discovered,
|
|
118
133
|
clientDeps: [...clientDeps].sort(),
|
|
119
134
|
clientDepsByEntry,
|
|
135
|
+
...(registry ? { registryEntries: [...acceptedEntries].sort() } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
static #registryRow(paths: CsrDevPaths, entry: string, row: ClientManifestEntry): ClientManifestEntry {
|
|
140
|
+
return {
|
|
141
|
+
id: `${SSR_DEV_ID_PREFIX}${paths.idOf(entry)}`,
|
|
142
|
+
chunks: [SSR_DEV_CHUNK, SSR_DEV_CHUNK],
|
|
143
|
+
name: row.name,
|
|
144
|
+
async: true,
|
|
120
145
|
};
|
|
121
146
|
}
|
|
122
147
|
|
|
@@ -199,19 +224,55 @@ export class RouteClientBuilder {
|
|
|
199
224
|
return scanUseClientExports(await Bun.file(absEntry).text(), absEntry, this.#app.workspace.workspaceRoot);
|
|
200
225
|
}
|
|
201
226
|
|
|
202
|
-
static normalizeNamedDefaultFunctionForFastRefresh(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
227
|
+
static normalizeNamedDefaultFunctionForFastRefresh(
|
|
228
|
+
source: string,
|
|
229
|
+
{ path: filePath }: { path?: string } = {},
|
|
230
|
+
): string | null {
|
|
231
|
+
const declared = RouteClientBuilder.#declaredNamedDefaultFunctions(source, filePath);
|
|
232
|
+
if (declared.length === 0) return null;
|
|
233
|
+
let next = "";
|
|
234
|
+
let cursor = 0;
|
|
235
|
+
for (const { index, text, lineStart, indent, asyncKeyword, name } of declared) {
|
|
236
|
+
next += `${source.slice(cursor, index)}${lineStart}${indent}${asyncKeyword}function ${name}`;
|
|
237
|
+
cursor = index + text.length;
|
|
238
|
+
}
|
|
239
|
+
next += source.slice(cursor);
|
|
240
|
+
return `${next}\n${declared.map(({ name }) => `export default ${name};`).join("\n")}\n`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
//? The pattern also matches inside strings and comments (a docs page quoting code), so every candidate is renamed to
|
|
244
|
+
//? a probe export and only the ones the parser reports as exports are declarations.
|
|
245
|
+
static #declaredNamedDefaultFunctions(source: string, filePath?: string) {
|
|
246
|
+
const candidates = [
|
|
247
|
+
...source.matchAll(/(^|\n)(\s*)export\s+default\s+(async\s+)?function\s+([A-Za-z_$][\w$]*)(?=\s*(?:<|\())/g),
|
|
248
|
+
].map((match) => ({
|
|
249
|
+
index: match.index,
|
|
250
|
+
text: match[0],
|
|
251
|
+
lineStart: match[1] ?? "",
|
|
252
|
+
indent: match[2] ?? "",
|
|
253
|
+
asyncKeyword: match[3] ?? "",
|
|
254
|
+
name: match[4] ?? "",
|
|
255
|
+
}));
|
|
256
|
+
if (candidates.length === 0) return [];
|
|
257
|
+
const probeName = (idx: number) => `__akanNamedDefault${idx}`;
|
|
258
|
+
let probe = "";
|
|
259
|
+
let cursor = 0;
|
|
260
|
+
candidates.forEach(({ index, text, lineStart, indent, asyncKeyword }, idx) => {
|
|
261
|
+
probe += `${source.slice(cursor, index)}${lineStart}${indent}export ${asyncKeyword}function ${probeName(idx)}`;
|
|
262
|
+
cursor = index + text.length;
|
|
263
|
+
});
|
|
264
|
+
probe += source.slice(cursor);
|
|
265
|
+
const exported = RouteClientBuilder.#scanExports(probe, filePath);
|
|
266
|
+
return candidates.filter((_, idx) => exported.has(probeName(idx)));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
static #scanExports(source: string, filePath?: string): Set<string> {
|
|
270
|
+
try {
|
|
271
|
+
return new Set(new Bun.Transpiler({ loader: filePath ? loaderFor(filePath) : "tsx" }).scan(source).exports);
|
|
272
|
+
} catch {
|
|
273
|
+
// Unparseable source is left alone, so the bundler reports its syntax error at the real line.
|
|
274
|
+
return new Set();
|
|
275
|
+
}
|
|
215
276
|
}
|
|
216
277
|
|
|
217
278
|
static resolveSsrClientRuntimeAliases(): Record<string, string> {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import fs 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 { type ServerGraph, ServerGraphFile } from "./serverGraphFile";
|
|
7
|
+
|
|
8
|
+
//? Resolved, as the graph holds real paths: on Windows a missing `/repo/...` resolves onto the current drive.
|
|
9
|
+
const at = (relative: string) => path.resolve("/repo/apps/a", relative);
|
|
10
|
+
|
|
11
|
+
const graph: ServerGraph = {
|
|
12
|
+
inputs: [at("page/_index.tsx"), at("lib/task.constant.ts"), at("ui/Card.tsx")],
|
|
13
|
+
clientExports: { [at("ui/Card.tsx")]: ["Card", "CardBody"] },
|
|
14
|
+
};
|
|
15
|
+
const touches = (files: string[], exportsNow: string[] | null = ["CardBody", "Card"]) =>
|
|
16
|
+
ServerGraphFile.touches(graph, files, () => exportsNow);
|
|
17
|
+
|
|
18
|
+
describe("ServerGraphFile.touches", () => {
|
|
19
|
+
test("a file the server graph never read changes nothing it renders", async () => {
|
|
20
|
+
expect(await touches([at("ui/OnlyClient.tsx")])).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a module the server renders counts", async () => {
|
|
24
|
+
expect(await touches([at("lib/task.constant.ts")])).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a client module that keeps its export names does not count, in any order", async () => {
|
|
28
|
+
expect(await touches([at("ui/Card.tsx")])).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a client module whose names changed, or that stopped being one, counts", async () => {
|
|
32
|
+
expect(await touches([at("ui/Card.tsx")], ["Card"])).toBe(true);
|
|
33
|
+
expect(await touches([at("ui/Card.tsx")], null)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("without a graph every save counts", async () => {
|
|
37
|
+
expect(await ServerGraphFile.touches(null, [at("ui/OnlyClient.tsx")], () => null)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a changed file the build just read for the first time counts: a failed import was waiting for it", async () => {
|
|
41
|
+
const next: ServerGraph = { ...graph, inputs: [...graph.inputs, at("ui/card.ts")] };
|
|
42
|
+
expect(await ServerGraphFile.touches(graph, [at("ui/card.ts")], () => null, next)).toBe(true);
|
|
43
|
+
expect(await ServerGraphFile.touches(graph, [at("ui/card.ts")], () => null)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a failed build keeps the last good graph and carries its files to the next build's check", async () => {
|
|
47
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "akan-server-graph-"));
|
|
48
|
+
try {
|
|
49
|
+
await ServerGraphFile.write(dir, graph);
|
|
50
|
+
await ServerGraphFile.carry(dir, [at("page/_index.tsx")]);
|
|
51
|
+
const kept = await ServerGraphFile.read(dir);
|
|
52
|
+
expect(kept?.inputs).toEqual(graph.inputs);
|
|
53
|
+
expect(kept?.carried).toEqual([at("page/_index.tsx")]);
|
|
54
|
+
expect(await ServerGraphFile.touches(kept, [at("ui/Card.tsx")], () => ["Card", "CardBody"])).toBe(false);
|
|
55
|
+
const changed = [at("ui/Card.tsx"), ...(kept?.carried ?? [])];
|
|
56
|
+
expect(await ServerGraphFile.touches(kept, changed, () => ["Card", "CardBody"])).toBe(true);
|
|
57
|
+
} finally {
|
|
58
|
+
await rm(dir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a graph written again inside one mtime tick is read afresh", async () => {
|
|
63
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "akan-server-graph-"));
|
|
64
|
+
try {
|
|
65
|
+
const file = path.join(dir, ServerGraphFile.fileName);
|
|
66
|
+
const tick = new Date(Math.floor(Date.now() / 1000) * 1000);
|
|
67
|
+
await ServerGraphFile.write(dir, graph);
|
|
68
|
+
fs.utimesSync(file, tick, tick);
|
|
69
|
+
await ServerGraphFile.read(dir);
|
|
70
|
+
await ServerGraphFile.carry(dir, [at("page/_index.tsx")]);
|
|
71
|
+
fs.utimesSync(file, tick, tick);
|
|
72
|
+
expect((await ServerGraphFile.read(dir))?.carried).toEqual([at("page/_index.tsx")]);
|
|
73
|
+
} finally {
|
|
74
|
+
await rm(dir, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { FileSys } from "../fileSys";
|
|
4
|
+
import { hasUseClientDirective, scanUseClientExports } from "../transforms/rscUseClientTransform";
|
|
5
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
6
|
+
|
|
7
|
+
export interface ServerGraph {
|
|
8
|
+
/** Real paths of the workspace sources the dev pages bundle read. */
|
|
9
|
+
inputs: string[];
|
|
10
|
+
/** Export names of every `"use client"` module among them: the server holds each only as a reference by name. */
|
|
11
|
+
clientExports: Record<string, string[]>;
|
|
12
|
+
/** Files saved in batches whose pages build failed since this graph was written: the next build's changes too. */
|
|
13
|
+
carried?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
//* What the dev server bundle read, written by every dev pages build that succeeds, so a save can be told apart as one
|
|
17
|
+
//* the server renders (it needs an RSC refresh) or one only the client registry holds. A failed build keeps the last
|
|
18
|
+
//* good graph and carries its files over, so the fixing save still refreshes what the failed ones changed.
|
|
19
|
+
export class ServerGraphFile {
|
|
20
|
+
static readonly fileName = "server-graph.json";
|
|
21
|
+
static #cache: { file: string; stamp: string; graph: ServerGraph } | null = null;
|
|
22
|
+
|
|
23
|
+
static async read(artifactDir: string): Promise<ServerGraph | null> {
|
|
24
|
+
const file = path.join(artifactDir, ServerGraphFile.fileName);
|
|
25
|
+
const stat = fs.statSync(file, { throwIfNoEntry: false });
|
|
26
|
+
if (!stat) return null;
|
|
27
|
+
//? Not the mtime alone: two writes inside one mtime tick (seen on Linux) read back the first. A write replaces the
|
|
28
|
+
//? file, so its inode moves too.
|
|
29
|
+
const stamp = `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
30
|
+
const cached = ServerGraphFile.#cache;
|
|
31
|
+
if (cached?.file === file && cached.stamp === stamp) return cached.graph;
|
|
32
|
+
const graph = (await Bun.file(file)
|
|
33
|
+
.json()
|
|
34
|
+
.catch(() => null)) as ServerGraph | null;
|
|
35
|
+
if (graph) ServerGraphFile.#cache = { file, stamp, graph };
|
|
36
|
+
return graph;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static async write(artifactDir: string, graph: ServerGraph): Promise<void> {
|
|
40
|
+
const file = path.join(artifactDir, ServerGraphFile.fileName);
|
|
41
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
42
|
+
await Bun.write(temp, JSON.stringify(graph));
|
|
43
|
+
await FileSys.replace(temp, file);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static async carry(artifactDir: string, files: string[]): Promise<void> {
|
|
47
|
+
const graph = await ServerGraphFile.read(artifactDir);
|
|
48
|
+
if (!graph || files.length === 0) return;
|
|
49
|
+
const carried = [...new Set([...(graph.carried ?? []), ...files.map((file) => CsrDevPaths.realpath(file))])];
|
|
50
|
+
await ServerGraphFile.write(artifactDir, { ...graph, carried });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `exportsNow` answers a client module's current export names, or null once it is no longer one. `next` is the graph
|
|
54
|
+
// the build just wrote: a changed file it reads for the first time (a module a failed import was waiting for) counts.
|
|
55
|
+
static async touches(
|
|
56
|
+
graph: ServerGraph | null,
|
|
57
|
+
files: string[],
|
|
58
|
+
exportsNow: (file: string) => string[] | null | Promise<string[] | null>,
|
|
59
|
+
next?: ServerGraph | null,
|
|
60
|
+
): Promise<boolean> {
|
|
61
|
+
if (!graph) return true;
|
|
62
|
+
const inputs = new Set(graph.inputs);
|
|
63
|
+
const joined = new Set((next?.inputs ?? []).filter((input) => !inputs.has(input)));
|
|
64
|
+
for (const rawFile of files) {
|
|
65
|
+
const file = CsrDevPaths.realpath(rawFile);
|
|
66
|
+
if (joined.has(file)) return true;
|
|
67
|
+
if (!inputs.has(file)) continue;
|
|
68
|
+
const before = graph.clientExports[file];
|
|
69
|
+
if (!before) return true;
|
|
70
|
+
//? The server holds a "use client" module only as references by name: an edit that keeps them renders the same.
|
|
71
|
+
const now = await exportsNow(file);
|
|
72
|
+
if (!now || ServerGraphFile.#names(now) !== ServerGraphFile.#names(before)) return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static async clientExportsOf(file: string): Promise<string[] | null> {
|
|
78
|
+
const source = await Bun.file(file)
|
|
79
|
+
.text()
|
|
80
|
+
.catch(() => null);
|
|
81
|
+
if (source === null || !hasUseClientDirective(source)) return null;
|
|
82
|
+
try {
|
|
83
|
+
return scanUseClientExports(source, file);
|
|
84
|
+
} catch {
|
|
85
|
+
// A module the server can no longer read as client references is a server change; the pages build says why.
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static #names(exports: string[]): string {
|
|
91
|
+
return [...exports].sort().join(",");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SSR_DEV_DIRNAME, SSR_DEV_ROUTE_PREFIX } from "akanjs/server/hmr/csrDevManifest";
|
|
4
|
+
import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
5
|
+
import { computeRouteSeedIndex } from "../artifact/routeSeedIndex";
|
|
6
|
+
import type { App } from "../commandDecorators";
|
|
7
|
+
import { bundleDefine } from "./bundleDefine";
|
|
8
|
+
import type { ClientEntryDiscovery } from "./clientBuildTypes";
|
|
9
|
+
import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
|
|
10
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
11
|
+
import type { CsrDevContext } from "./csrDevTypes";
|
|
12
|
+
import { DevRegistryBundler } from "./devRegistryBundler";
|
|
13
|
+
import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
|
|
14
|
+
|
|
15
|
+
//* Dev-only client code of SSR pages as a module registry, under `.akan/artifact/ssr-dev`. Its roots are a bootstrap
|
|
16
|
+
//* (the app's client runtime) and every `"use client"` entry; React and the akanjs vendor facets stay the import map's,
|
|
17
|
+
//* so the page keeps one React and one store registry.
|
|
18
|
+
export class SsrDevBundler extends DevRegistryBundler {
|
|
19
|
+
static readonly #formatVersion = 1;
|
|
20
|
+
static readonly bootstrapKey = "";
|
|
21
|
+
readonly reloadsOnEntryChange = false;
|
|
22
|
+
#discovery: ClientEntryDiscovery | null = null;
|
|
23
|
+
readonly #sharedDiscovery: (() => ClientEntryDiscovery) | null;
|
|
24
|
+
|
|
25
|
+
/** `discovery` is the resident builder's, which it keeps current with every save; a worker discovers afresh. */
|
|
26
|
+
constructor(app: App, { discovery }: { discovery?: () => ClientEntryDiscovery } = {}) {
|
|
27
|
+
super(app, { dirName: SSR_DEV_DIRNAME, routePrefix: SSR_DEV_ROUTE_PREFIX, library: true });
|
|
28
|
+
this.#sharedDiscovery = discovery ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async context(): Promise<CsrDevContext> {
|
|
32
|
+
const akanConfig = await this.app.getConfig();
|
|
33
|
+
const define = bundleDefine(this.app, "start", "ssr");
|
|
34
|
+
const optimizeImports = [...akanConfig.optimizeImports];
|
|
35
|
+
const configKey = Bun.hash(
|
|
36
|
+
JSON.stringify(["ssr", SsrDevBundler.#formatVersion, Bun.version, define, optimizeImports, VENDOR_SPECIFIERS]),
|
|
37
|
+
).toString(36);
|
|
38
|
+
return {
|
|
39
|
+
pageEntries: [],
|
|
40
|
+
basePaths: [...akanConfig.basePaths],
|
|
41
|
+
htmlBasePaths: [],
|
|
42
|
+
define,
|
|
43
|
+
optimizeImports,
|
|
44
|
+
configKey,
|
|
45
|
+
refreshFile: null,
|
|
46
|
+
externals: VENDOR_SPECIFIERS,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
//? The client runtime of an SSR page inlines no dictionary (`useClient.ts` reads one only when rendering csr), so
|
|
51
|
+
//? a dictionary save is none of this registry's business.
|
|
52
|
+
override isMetadataFile(file: string): boolean {
|
|
53
|
+
return super.isMetadataFile(file) && !file.endsWith(".dictionary.ts") && path.basename(file) !== "dict.ts";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Run first in the tab, before any client module the payload names: the store registry must exist by then.
|
|
57
|
+
async writeEntries(): Promise<{ files: Record<string, string>; changed: string[] }> {
|
|
58
|
+
await mkdir(this.entryDir, { recursive: true });
|
|
59
|
+
const file = path.join(this.entryDir, "bootstrap.ts");
|
|
60
|
+
const hasStore = await Bun.file(path.join(this.app.cwdPath, "lib", "st.ts")).exists();
|
|
61
|
+
const source = hasStore ? `import ${JSON.stringify(`@apps/${this.app.name}/client`)};\n` : "export {};\n";
|
|
62
|
+
const unchanged = (await Bun.file(file).exists()) && (await Bun.file(file).text()) === source;
|
|
63
|
+
if (!unchanged) await Bun.write(file, source);
|
|
64
|
+
const entryFile = CsrDevPaths.realpath(file);
|
|
65
|
+
return { files: { [SsrDevBundler.bootstrapKey]: entryFile }, changed: unchanged ? [] : [entryFile] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Every `"use client"` entry the app's routes reach, the ones a route build names in its manifest. */
|
|
69
|
+
async clientEntries(): Promise<string[]> {
|
|
70
|
+
const pageEntries = await resolveSsrPageEntriesForApp(this.app, await this.app.getPageKeys());
|
|
71
|
+
const seedIndex = computeRouteSeedIndex(pageEntries);
|
|
72
|
+
const seeds = [...new Set([...seedIndex.globalLayoutFiles, ...seedIndex.entries.flatMap((entry) => entry.seeds)])];
|
|
73
|
+
const discovery = this.#sharedDiscovery?.() ?? (await this.#ownDiscovery());
|
|
74
|
+
return (await discovery.discover(seeds)).map((file) => CsrDevPaths.realpath(file));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async #ownDiscovery(): Promise<ClientEntryDiscovery> {
|
|
78
|
+
this.#discovery ??= await GraphClientEntryDiscovery.create(this.app);
|
|
79
|
+
return this.#discovery;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protected async rootFiles(entryFiles: string[]): Promise<string[]> {
|
|
83
|
+
return [...entryFiles, ...(await this.clientEntries())];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -11,10 +11,12 @@ import {
|
|
|
11
11
|
CssCompiler,
|
|
12
12
|
FontOptimizer,
|
|
13
13
|
PagesBundleBuilder,
|
|
14
|
+
ServerGraphFile,
|
|
14
15
|
SsrBaseArtifactBuilder,
|
|
16
|
+
SsrDevBundler,
|
|
15
17
|
} from "@akanjs/devkit/frontendBuild";
|
|
16
18
|
import { Logger } from "akanjs/common";
|
|
17
|
-
import type { BuilderMessage, BuildPhase } from "akanjs/server";
|
|
19
|
+
import type { BuilderMessage, BuildPhase, HmrTrace } from "akanjs/server";
|
|
18
20
|
import { resolveDevCsrMode } from "akanjs/server/hmr/csrDevManifest";
|
|
19
21
|
import type { BuildBatchRequest, BuildBatchResult, OptimizedFonts, PagesBatchCssAssets } from "./buildBatchProtocol";
|
|
20
22
|
|
|
@@ -32,10 +34,11 @@ class BuildBatch {
|
|
|
32
34
|
|
|
33
35
|
async run(): Promise<BuildBatchResult> {
|
|
34
36
|
if (this.#request.needs.includes("base")) await this.#buildBase();
|
|
35
|
-
// Order kept from the in-process build: csr before pages, css last because it depends on the rebuilt client.
|
|
36
37
|
if (this.#request.needs.includes("csr")) await this.#buildCsr();
|
|
37
|
-
if (this.#request.needs.includes("
|
|
38
|
+
if (this.#request.needs.includes("ssr")) await this.#buildSsrRegistry();
|
|
39
|
+
// Css before pages: it scans the sources and reads nothing pages produces, so a class edit need not wait for pages.
|
|
38
40
|
if (this.#request.needs.includes("css")) await this.#buildCss();
|
|
41
|
+
if (this.#request.needs.includes("pages")) await this.#buildPages();
|
|
39
42
|
return this.#result;
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -45,6 +48,12 @@ class BuildBatch {
|
|
|
45
48
|
process.send?.(message);
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
#sentTrace(): HmrTrace | undefined {
|
|
52
|
+
if (!this.#request.trace) return undefined;
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
return { ...this.#request.trace, patchAt: now, sentAt: now };
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
#emitStatus(phase: BuildPhase, message?: string): void {
|
|
49
58
|
this.#emit({
|
|
50
59
|
type: "build-status",
|
|
@@ -102,7 +111,21 @@ class BuildBatch {
|
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
async #updateCsrRegistry(started: number): Promise<void> {
|
|
105
|
-
const update = await new CsrDevBundler(this.#app).update(this.#request.changedFiles
|
|
114
|
+
const update = await new CsrDevBundler(this.#app).update(this.#request.changedFiles, {
|
|
115
|
+
announce: (announced) =>
|
|
116
|
+
this.#emit({
|
|
117
|
+
type: "csr-updated",
|
|
118
|
+
data: {
|
|
119
|
+
generation: announced.generation,
|
|
120
|
+
mode: "registry",
|
|
121
|
+
reload: announced.reload,
|
|
122
|
+
reason: announced.reason,
|
|
123
|
+
patchUrl: announced.patchUrl,
|
|
124
|
+
changedIds: announced.changedIds,
|
|
125
|
+
trace: this.#sentTrace(),
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
106
129
|
if (!update) {
|
|
107
130
|
this.#logger.verbose(`csr-dev unchanged (${Date.now() - started}ms)`);
|
|
108
131
|
return;
|
|
@@ -110,17 +133,48 @@ class BuildBatch {
|
|
|
110
133
|
this.#logger.verbose(
|
|
111
134
|
`csr-dev generation=${update.generation} ${update.reload ? `reload (${update.reason})` : `patch modules=${update.changedIds.length}`} graph=${update.moduleCount} (${Date.now() - started}ms)`,
|
|
112
135
|
);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Takes every entry the routes reach as a root, so a route build naming any of them finds it in the registry.
|
|
139
|
+
async #buildSsrRegistry(): Promise<void> {
|
|
140
|
+
const started = Date.now();
|
|
141
|
+
try {
|
|
142
|
+
const bundler = new SsrDevBundler(this.#app);
|
|
143
|
+
//? Runs before pages, so the graph is the last build's, as in the resident builder: a save it holds still waits.
|
|
144
|
+
const hold = await ServerGraphFile.touches(
|
|
145
|
+
await ServerGraphFile.read(this.#request.artifactDir),
|
|
146
|
+
this.#request.changedFiles,
|
|
147
|
+
(file) => ServerGraphFile.clientExportsOf(file),
|
|
148
|
+
);
|
|
149
|
+
const update = await bundler.update(this.#request.changedFiles, {
|
|
150
|
+
roots: await bundler.clientEntries(),
|
|
151
|
+
announce: (announced) =>
|
|
152
|
+
this.#emit({
|
|
153
|
+
type: "ssr-updated",
|
|
154
|
+
data: {
|
|
155
|
+
generation: announced.generation,
|
|
156
|
+
reload: announced.reload,
|
|
157
|
+
reason: announced.reason,
|
|
158
|
+
patchUrl: announced.patchUrl,
|
|
159
|
+
changedIds: announced.changedIds,
|
|
160
|
+
trace: this.#sentTrace(),
|
|
161
|
+
...(announced.epoch !== undefined ? { epoch: announced.epoch } : {}),
|
|
162
|
+
...(announced.first ? { first: true } : {}),
|
|
163
|
+
...(hold && this.#request.changedFiles.length > 0
|
|
164
|
+
? { hold, batchGeneration: this.#request.generation }
|
|
165
|
+
: {}),
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
this.#emitStatus("ssr");
|
|
170
|
+
this.#logger.verbose(
|
|
171
|
+
update
|
|
172
|
+
? `ssr-dev generation=${update.generation} ${update.reload ? `reload (${update.reason})` : `patch modules=${update.changedIds.length}`} graph=${update.moduleCount} (${Date.now() - started}ms)`
|
|
173
|
+
: `ssr-dev unchanged (${Date.now() - started}ms)`,
|
|
174
|
+
);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
this.#fail("ssr", "ssr-dev", err);
|
|
177
|
+
}
|
|
124
178
|
}
|
|
125
179
|
|
|
126
180
|
// Rewritten with the bundle: the backend rereads it on `pages-updated` to pick up added, moved or deleted routes.
|
|
@@ -129,8 +183,16 @@ class BuildBatch {
|
|
|
129
183
|
try {
|
|
130
184
|
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, await this.#app.getPageKeys());
|
|
131
185
|
const seedIndex = computeRouteSeedIndex(pageEntries);
|
|
186
|
+
const previousGraph = await ServerGraphFile.read(this.#request.artifactDir);
|
|
132
187
|
const next = await new PagesBundleBuilder(this.#app, "start", pageEntries).build();
|
|
133
188
|
await saveRouteSeedIndex(this.#request.artifactDir, seedIndex);
|
|
189
|
+
const nextGraph = await ServerGraphFile.read(this.#request.artifactDir);
|
|
190
|
+
const serverTouched = await ServerGraphFile.touches(
|
|
191
|
+
previousGraph,
|
|
192
|
+
[...this.#request.changedFiles, ...(previousGraph?.carried ?? [])],
|
|
193
|
+
(file) => nextGraph?.clientExports[file] ?? null,
|
|
194
|
+
nextGraph,
|
|
195
|
+
);
|
|
134
196
|
this.#emit({
|
|
135
197
|
type: "pages-updated",
|
|
136
198
|
data: {
|
|
@@ -138,12 +200,17 @@ class BuildBatch {
|
|
|
138
200
|
buildId: next.buildId,
|
|
139
201
|
generation: this.#request.generation,
|
|
140
202
|
changedFiles: this.#request.changedFiles,
|
|
203
|
+
trace: this.#sentTrace(),
|
|
204
|
+
serverTouched,
|
|
141
205
|
},
|
|
142
206
|
});
|
|
143
207
|
this.#emitStatus("pages");
|
|
144
208
|
this.#logger.verbose(`pages-rebundle ok buildId=${next.buildId} (${Date.now() - started}ms)`);
|
|
145
209
|
} catch (err) {
|
|
146
210
|
this.#fail("pages", "pages-rebundle", err);
|
|
211
|
+
await ServerGraphFile.carry(this.#request.artifactDir, this.#request.changedFiles).catch((carryError: unknown) =>
|
|
212
|
+
this.#logger.warn(`server graph carry failed; the next save may skip its RSC refresh: ${String(carryError)}`),
|
|
213
|
+
);
|
|
147
214
|
}
|
|
148
215
|
}
|
|
149
216
|
|
|
@@ -213,7 +280,13 @@ class BuildBatch {
|
|
|
213
280
|
static async main(): Promise<void> {
|
|
214
281
|
const raw = process.argv[2];
|
|
215
282
|
if (!raw) throw new Error("[build-batch] missing request argument");
|
|
216
|
-
const
|
|
283
|
+
const parsed = JSON.parse(raw) as BuildBatchRequest;
|
|
284
|
+
const request = parsed.trace
|
|
285
|
+
? {
|
|
286
|
+
...parsed,
|
|
287
|
+
trace: { ...parsed.trace, workerStartAt: Math.round(performance.timeOrigin), workerAt: Date.now() },
|
|
288
|
+
}
|
|
289
|
+
: parsed;
|
|
217
290
|
const workspace = WorkspaceExecutor.fromRoot({
|
|
218
291
|
workspaceRoot: request.workspaceRoot,
|
|
219
292
|
repoName: request.repoName,
|
|
@@ -221,8 +294,13 @@ class BuildBatch {
|
|
|
221
294
|
const app = AppExecutor.from(workspace, request.appName);
|
|
222
295
|
// Seeded, not rediscovered: route discovery would be the largest cost of spawning this process.
|
|
223
296
|
if (request.pageKeys) app.setPageKeys(request.pageKeys);
|
|
297
|
+
//? Exits with the builder that spawned it (a kill, a crash, a restart for a metadata save): left running, it would
|
|
298
|
+
//? write a registry its replacement is rebuilding. Removed before returning, since the listener keeps Bun's IPC open.
|
|
299
|
+
const orphaned = () => process.exit(1);
|
|
300
|
+
process.on("disconnect", orphaned);
|
|
224
301
|
const result = await new BuildBatch(request, app).run();
|
|
225
302
|
process.send?.({ type: "build-batch-result", data: result });
|
|
303
|
+
process.off("disconnect", orphaned);
|
|
226
304
|
}
|
|
227
305
|
}
|
|
228
306
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { FontOptimizer } from "@akanjs/devkit/frontendBuild";
|
|
2
|
-
import type { BaseBuildArtifact, CssPayload, PagesBundlePayload } from "akanjs/server";
|
|
2
|
+
import type { BaseBuildArtifact, CssPayload, HmrTrace, PagesBundlePayload } from "akanjs/server";
|
|
3
3
|
|
|
4
4
|
export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
|
|
5
5
|
|
|
6
6
|
// `base` is the boot build: it always runs alone, before the builder can serve anything.
|
|
7
|
-
export type BuildBatchNeed = "base" | "pages" | "css" | "csr";
|
|
7
|
+
export type BuildBatchNeed = "base" | "pages" | "css" | "csr" | "ssr";
|
|
8
8
|
|
|
9
9
|
// JSON only: the worker is spawned per batch and receives this in its argv.
|
|
10
10
|
export interface BuildBatchRequest {
|
|
@@ -18,6 +18,7 @@ export interface BuildBatchRequest {
|
|
|
18
18
|
optimizedFonts: OptimizedFonts | null; // reused unless this batch touched one of its files
|
|
19
19
|
cssAssets: PagesBatchCssAssets | null; // an unchanged compile skips the broadcast instead of busting hashes
|
|
20
20
|
artifactDir: string;
|
|
21
|
+
trace?: HmrTrace;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export type PagesBatchCssAssets = CssPayload["cssAssets"];
|
|
@@ -29,7 +30,9 @@ export interface BuildBatchResult {
|
|
|
29
30
|
optimizedFonts?: OptimizedFonts;
|
|
30
31
|
artifact?: BaseBuildArtifact; // `base` batches only
|
|
31
32
|
errors: Partial<Record<BuildBatchNeed, string>>;
|
|
32
|
-
crashed?: boolean; // died before reporting
|
|
33
|
+
crashed?: boolean; // died before reporting its result
|
|
34
|
+
/** Of a crashed batch, the needs it died before reporting: the others streamed their build-status first. */
|
|
35
|
+
crashedNeeds?: BuildBatchNeed[];
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export type BuildBatchMessage = { type: "build-batch-result"; data: BuildBatchResult };
|