@akanjs/devkit 3.0.0-beta.23 → 3.0.0-beta.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEV_RUNTIME_KNOBS.md +37 -22
- package/akanApp/akanApp.host.ts +34 -1
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +25 -0
- package/akanApp/devHostPolicy.ts +27 -2
- package/cloud/globalConfig.ts +3 -2
- package/csrE2e/csrE2eHarness.fixture.ts +455 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +187 -0
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/fileSys.ts +16 -1
- package/frontendBuild/buildRouteClient.test.ts +61 -0
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +5 -1
- package/frontendBuild/clientEntryDiscovery.test.ts +37 -0
- package/frontendBuild/clientEntryDiscovery.ts +10 -5
- package/frontendBuild/csrArtifactBuilder.ts +3 -6
- package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
- package/frontendBuild/csrDevArtifactWriter.ts +303 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +95 -0
- package/frontendBuild/csrDevModuleCompiler.ts +304 -0
- package/frontendBuild/csrDevPatcher.test.ts +340 -0
- package/frontendBuild/csrDevPatcher.ts +374 -0
- package/frontendBuild/csrDevPaths.ts +124 -0
- package/frontendBuild/csrDevResolver.test.ts +165 -0
- package/frontendBuild/csrDevResolver.ts +234 -0
- package/frontendBuild/csrDevTypes.ts +59 -0
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCompiler.ts +2 -4
- package/frontendBuild/devRegistryBundler.ts +177 -0
- package/frontendBuild/hmrWatcher.test.ts +51 -0
- package/frontendBuild/hmrWatcher.ts +17 -3
- package/frontendBuild/index.ts +4 -0
- package/frontendBuild/pagesBundleBuilder.ts +24 -1
- package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +79 -18
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/ssrDevBundler.ts +91 -0
- package/incrementalBuilder/buildBatch.proc.ts +119 -6
- package/incrementalBuilder/buildBatchProtocol.ts +6 -3
- package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
- package/incrementalBuilder/buildBatchRunner.ts +17 -8
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +1 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +33 -3
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +129 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +518 -52
- package/integration/devStability.integration.test.ts +18 -7
- package/package.json +3 -3
- package/transforms/rscUseClientTransform.ts +7 -0
- package/transforms/transforms.test.ts +13 -0
- package/transforms/useClientBundlePlugin.ts +4 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
//* Pins the Bun behaviour CsrDevBundler is built on, so a Bun upgrade that changes it fails here, not in a browser.
|
|
7
|
+
describe("Bun output the CSR registry bundle relies on", () => {
|
|
8
|
+
let dir: string;
|
|
9
|
+
const externalizeAll = {
|
|
10
|
+
name: "externalize-all",
|
|
11
|
+
setup(build: Bun.PluginBuilder) {
|
|
12
|
+
build.onResolve({ filter: /.*/ }, (args) =>
|
|
13
|
+
args.kind === "entry-point-build" ? undefined : { path: `akan-module:${args.path}`, external: true },
|
|
14
|
+
);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
dir = await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-bundler-"));
|
|
20
|
+
await Bun.write(path.join(dir, "a.ts"), "export const a = 1;\n");
|
|
21
|
+
await Bun.write(
|
|
22
|
+
path.join(dir, "entry.ts"),
|
|
23
|
+
'import value from "./a";\nexport * from "./a";\nexport const lazy = () => import("./a");\nconsole.log(value);\n',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await rm(dir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const build = async (entrypoints: string[]) =>
|
|
32
|
+
await Bun.build({
|
|
33
|
+
entrypoints: entrypoints.map((file) => path.join(dir, file)),
|
|
34
|
+
root: dir,
|
|
35
|
+
target: "browser",
|
|
36
|
+
format: "cjs",
|
|
37
|
+
naming: { entry: "[dir]/[name].[ext]" },
|
|
38
|
+
plugins: [externalizeAll],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the CJS helpers keep the shapes CsrDevBundler rewrites", async () => {
|
|
42
|
+
const [output] = (await build(["entry.ts"])).outputs;
|
|
43
|
+
const code = (await output?.text()) ?? "";
|
|
44
|
+
expect(code).toContain("var __toESM = (mod, isNodeMode, target) => {");
|
|
45
|
+
expect(code).toContain("var __reExport = (target, mod, secondTarget) => {");
|
|
46
|
+
expect(code).toContain('require("akan-module:./a")');
|
|
47
|
+
expect(code).toContain('import("akan-module:./a")');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("the helper preamble ends at the first path comment and names no factory argument", async () => {
|
|
51
|
+
const [output] = (await build(["entry.ts"])).outputs;
|
|
52
|
+
const code = (await output?.text()) ?? "";
|
|
53
|
+
const marker = code.search(/^\/\/ /m);
|
|
54
|
+
expect(marker).toBeGreaterThan(0);
|
|
55
|
+
const preamble = code.slice(0, marker);
|
|
56
|
+
expect(preamble).toMatch(/^var __toESM = /m);
|
|
57
|
+
expect(preamble).not.toMatch(/(?<![\w$.])(?:require|module|exports)(?![\w$]|\s*:)/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a relative import of another entrypoint in the same build stays external", async () => {
|
|
61
|
+
const outputs = (await build(["entry.ts", "a.ts"])).outputs;
|
|
62
|
+
const entry = await outputs.find((output) => output.path.includes("entry"))?.text();
|
|
63
|
+
expect(entry).toContain('require("akan-module:./a")');
|
|
64
|
+
expect(entry).not.toContain("var a = 1");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CSR_DEV_DIRNAME, CSR_DEV_ROUTE_PREFIX } from "akanjs/server/hmr/csrDevManifest";
|
|
4
|
+
import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
5
|
+
import type { App } from "../commandDecorators";
|
|
6
|
+
import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
|
|
7
|
+
import { bundleDefine } from "./bundleDefine";
|
|
8
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
9
|
+
import type { CsrDevContext } from "./csrDevTypes";
|
|
10
|
+
import { CsrEntryFiles } from "./csrEntryFiles";
|
|
11
|
+
import { DevRegistryBundler } from "./devRegistryBundler";
|
|
12
|
+
import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
13
|
+
|
|
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 {
|
|
16
|
+
static readonly #formatVersion = 3;
|
|
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> }>();
|
|
20
|
+
|
|
21
|
+
constructor(app: App) {
|
|
22
|
+
super(app, { dirName: CSR_DEV_DIRNAME, routePrefix: CSR_DEV_ROUTE_PREFIX, library: false });
|
|
23
|
+
}
|
|
24
|
+
|
|
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;
|
|
29
|
+
const basePaths = [...akanConfig.basePaths];
|
|
30
|
+
const htmlBasePaths = basePaths.length > 0 ? basePaths : [""];
|
|
31
|
+
const define = bundleDefine(this.app, "start", "csr");
|
|
32
|
+
const optimizeImports = [...akanConfig.optimizeImports];
|
|
33
|
+
const configKey = Bun.hash(
|
|
34
|
+
JSON.stringify([CsrDevBundler.#formatVersion, Bun.version, define, optimizeImports, htmlBasePaths]),
|
|
35
|
+
).toString(36);
|
|
36
|
+
return {
|
|
37
|
+
pageEntries,
|
|
38
|
+
basePaths,
|
|
39
|
+
htmlBasePaths,
|
|
40
|
+
define,
|
|
41
|
+
optimizeImports,
|
|
42
|
+
configKey,
|
|
43
|
+
refreshFile: this.#resolveRefreshRuntime(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async writeEntries(context: CsrDevContext): Promise<{ files: Record<string, string>; changed: string[] }> {
|
|
48
|
+
await mkdir(this.entryDir, { recursive: true });
|
|
49
|
+
const files: Record<string, string> = {};
|
|
50
|
+
const changed: string[] = [];
|
|
51
|
+
for (const basePath of context.htmlBasePaths) {
|
|
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 });
|
|
58
|
+
const hot = await generator.generateHotReplace({
|
|
59
|
+
fromDir: this.entryDir,
|
|
60
|
+
ownerId: this.paths.idOf(file),
|
|
61
|
+
moduleIds: entryPages.map(({ moduleAbsPath }) => this.paths.idOf(moduleAbsPath)),
|
|
62
|
+
});
|
|
63
|
+
const source = `import * as akanWebkit from "akanjs/webkit";\n${pages}\nvoid akanWebkit.bootCsr(pages);\n${hot}`;
|
|
64
|
+
const unchanged = (await Bun.file(file).exists()) && (await Bun.file(file).text()) === source;
|
|
65
|
+
if (!unchanged) await Bun.write(file, source);
|
|
66
|
+
const entryFile = CsrDevPaths.realpath(file);
|
|
67
|
+
files[basePath] = entryFile;
|
|
68
|
+
if (!unchanged) changed.push(entryFile);
|
|
69
|
+
}
|
|
70
|
+
return { files, changed };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
protected async rootFiles(entryFiles: string[]): Promise<string[]> {
|
|
74
|
+
return entryFiles;
|
|
75
|
+
}
|
|
76
|
+
|
|
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;
|
|
84
|
+
}
|
|
85
|
+
|
|
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(", ")}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { BunPlugin } from "bun";
|
|
3
|
+
import type { App } from "../commandDecorators";
|
|
4
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
5
|
+
import { CsrDevResolver } from "./csrDevResolver";
|
|
6
|
+
import type { CsrDevCompiledModule, CsrDevContext, CsrDevSharedHelpers } from "./csrDevTypes";
|
|
7
|
+
import { RouteClientBuilder } from "./routeClientBuilder";
|
|
8
|
+
|
|
9
|
+
interface OutputMetafile {
|
|
10
|
+
outputs: Record<string, { entryPoint?: string; inputs: Record<string, unknown> }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface BuildRound {
|
|
14
|
+
modules: CsrDevCompiledModule[];
|
|
15
|
+
misses: string[];
|
|
16
|
+
tangled: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CsrDevModuleCompilerOptions {
|
|
20
|
+
app: App;
|
|
21
|
+
paths: CsrDevPaths;
|
|
22
|
+
resolver: CsrDevResolver;
|
|
23
|
+
context: CsrDevContext;
|
|
24
|
+
outDir: string;
|
|
25
|
+
/** Where the dev server serves `outDir`: an imported asset's URL starts with it. */
|
|
26
|
+
routePrefix: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CsrDevCompileOptions {
|
|
30
|
+
/** Stop before building an npm module the graph does not know yet, and name it instead. */
|
|
31
|
+
refuseNewVendor?: boolean;
|
|
32
|
+
/** Stop at an import no recorded resolution answers, instead of running the resolution build (a whole-app build). */
|
|
33
|
+
refusePrepass?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CsrDevCompileResult {
|
|
37
|
+
modules: CsrDevCompiledModule[];
|
|
38
|
+
refusedVendors: string[];
|
|
39
|
+
unresolved: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
//* Every module becomes its own CJS factory: one Bun.build with each import external, so a save re-runs only the
|
|
43
|
+
//* modules it changed instead of reloading one scope-hoisted file.
|
|
44
|
+
export class CsrDevModuleCompiler {
|
|
45
|
+
static readonly #storeRoot = /(?:^|\/)lib\/st\.ts$/;
|
|
46
|
+
static readonly #factoryArgument =
|
|
47
|
+
/(?<![\w$.])(?:require|module|exports|__akanImport|\$RefreshReg\$|\$RefreshSig\$)(?![\w$]|\s*:)/;
|
|
48
|
+
static readonly #helperPatches = [
|
|
49
|
+
["__toESM", "(mod, isNodeMode, target)", "toESM"],
|
|
50
|
+
["__reExport", "(target, mod, secondTarget)", "reExport"],
|
|
51
|
+
] as const;
|
|
52
|
+
readonly #app: App;
|
|
53
|
+
readonly #paths: CsrDevPaths;
|
|
54
|
+
readonly #resolver: CsrDevResolver;
|
|
55
|
+
readonly #context: CsrDevContext;
|
|
56
|
+
readonly #outDir: string;
|
|
57
|
+
readonly #routePrefix: string;
|
|
58
|
+
readonly #assets = new Set<string>();
|
|
59
|
+
|
|
60
|
+
constructor({ app, paths, resolver, context, outDir, routePrefix }: CsrDevModuleCompilerOptions) {
|
|
61
|
+
this.#app = app;
|
|
62
|
+
this.#paths = paths;
|
|
63
|
+
this.#resolver = resolver;
|
|
64
|
+
this.#context = context;
|
|
65
|
+
this.#outDir = outDir;
|
|
66
|
+
this.#routePrefix = routePrefix;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every asset file this compiler wrote into the registry directory. */
|
|
70
|
+
get assets(): string[] {
|
|
71
|
+
return [...this.#assets];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async compile(
|
|
75
|
+
startFiles: string[],
|
|
76
|
+
known: Set<string>,
|
|
77
|
+
{ refuseNewVendor = false, refusePrepass = false }: CsrDevCompileOptions = {},
|
|
78
|
+
): Promise<CsrDevCompileResult> {
|
|
79
|
+
const compiled = new Map<string, CsrDevCompiledModule>();
|
|
80
|
+
const seen = new Set([...known, ...startFiles]);
|
|
81
|
+
let frontier = [...new Set(startFiles)];
|
|
82
|
+
while (frontier.length > 0) {
|
|
83
|
+
const refusedVendors = refuseNewVendor ? frontier.filter((file) => CsrDevPaths.isVendorFile(file)) : [];
|
|
84
|
+
if (refusedVendors.length > 0) return { modules: [...compiled.values()], refusedVendors, unresolved: [] };
|
|
85
|
+
const round = await this.#compileRound(frontier);
|
|
86
|
+
if (round.misses.length > 0 && !this.#resolver.prepassDone && refusePrepass)
|
|
87
|
+
return { modules: [...compiled.values()], refusedVendors: [], unresolved: round.misses };
|
|
88
|
+
if (round.misses.length > 0 && !this.#resolver.prepassDone) {
|
|
89
|
+
this.#app.verbose(`[csr-dev] ${round.misses.length} unresolved import(s); rerunning the resolution build`);
|
|
90
|
+
await this.#resolver.prepass();
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const next: string[] = [];
|
|
94
|
+
for (const module of round.modules) {
|
|
95
|
+
compiled.set(module.file, module);
|
|
96
|
+
for (const dep of module.deps) {
|
|
97
|
+
if (CsrDevPaths.isStub(dep) || seen.has(dep)) continue;
|
|
98
|
+
seen.add(dep);
|
|
99
|
+
next.push(dep);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
frontier = next;
|
|
103
|
+
}
|
|
104
|
+
const { fallbacks } = this.#resolver;
|
|
105
|
+
if (fallbacks.size > 0)
|
|
106
|
+
this.#app.verbose(
|
|
107
|
+
`[csr-dev] ${fallbacks.size} import(s) outside the browser build's graph resolved with Bun's runtime resolver: ${[...fallbacks].slice(0, 5).join(", ")}${fallbacks.size > 5 ? ", ..." : ""}`,
|
|
108
|
+
);
|
|
109
|
+
return { modules: [...compiled.values()], refusedVendors: [], unresolved: [] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async #compileRound(files: string[]): Promise<Omit<BuildRound, "tangled">> {
|
|
113
|
+
const appFiles = files.filter((file) => !CsrDevPaths.isVendorFile(file));
|
|
114
|
+
const vendorFiles = files.filter((file) => CsrDevPaths.isVendorFile(file));
|
|
115
|
+
const rounds = await Promise.all([
|
|
116
|
+
appFiles.length > 0 ? this.#bunBuild(appFiles, false) : null,
|
|
117
|
+
vendorFiles.length > 0 ? this.#bunBuild(vendorFiles, true) : null,
|
|
118
|
+
]);
|
|
119
|
+
const modules = rounds.flatMap((round) => round?.modules ?? []);
|
|
120
|
+
const misses = rounds.flatMap((round) => round?.misses ?? []);
|
|
121
|
+
// One entrypoint per build cannot inline another: Bun ignores an external for an absolute import of an entrypoint.
|
|
122
|
+
for (const file of rounds.flatMap((round) => round?.tangled ?? [])) {
|
|
123
|
+
const single = await this.#bunBuild([file], CsrDevPaths.isVendorFile(file));
|
|
124
|
+
if (single.tangled.length > 0)
|
|
125
|
+
throw new Error(`[csr-dev] ${this.#paths.idOf(file)} kept inlining another module`);
|
|
126
|
+
modules.push(...single.modules);
|
|
127
|
+
misses.push(...single.misses);
|
|
128
|
+
}
|
|
129
|
+
return { modules, misses };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async #bunBuild(files: string[], vendor: boolean): Promise<BuildRound> {
|
|
133
|
+
//? Keyed as the output is: by the disk's spelling, which a case-only rename leaves apart from the requested path.
|
|
134
|
+
const mtimes = new Map(files.map((file) => [CsrDevPaths.realpath(file), CsrDevPaths.mtimeOf(file)]));
|
|
135
|
+
const hashes = new Map(
|
|
136
|
+
await Promise.all(
|
|
137
|
+
files.map(async (file) => [CsrDevPaths.realpath(file), vendor ? "" : await CsrDevPaths.hashOf(file)] as const),
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
const misses: string[] = [];
|
|
141
|
+
const result = await Bun.build({
|
|
142
|
+
entrypoints: files,
|
|
143
|
+
root: this.#paths.root,
|
|
144
|
+
target: "browser",
|
|
145
|
+
format: "cjs",
|
|
146
|
+
reactFastRefresh: !vendor,
|
|
147
|
+
sourcemap: vendor ? "none" : "external",
|
|
148
|
+
naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
|
|
149
|
+
publicPath: this.#routePrefix,
|
|
150
|
+
metafile: true,
|
|
151
|
+
env: "AKAN_PUBLIC_*",
|
|
152
|
+
define: this.#context.define,
|
|
153
|
+
optimizeImports: this.#context.optimizeImports,
|
|
154
|
+
plugins: [this.#registryPlugin(vendor, misses)],
|
|
155
|
+
});
|
|
156
|
+
const { outputs } = result.metafile as unknown as OutputMetafile;
|
|
157
|
+
const sourceMaps = new Map(
|
|
158
|
+
result.outputs.filter((artifact) => artifact.kind === "sourcemap").map((artifact) => [artifact.path, artifact]),
|
|
159
|
+
);
|
|
160
|
+
const modules: CsrDevCompiledModule[] = [];
|
|
161
|
+
const tangled: string[] = [];
|
|
162
|
+
for (const artifact of result.outputs) {
|
|
163
|
+
if (artifact.kind === "asset") {
|
|
164
|
+
const assetPath = path.join(this.#outDir, artifact.path);
|
|
165
|
+
await Bun.write(assetPath, artifact);
|
|
166
|
+
this.#assets.add(assetPath);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (artifact.kind !== "entry-point") continue;
|
|
170
|
+
const meta = outputs[artifact.path];
|
|
171
|
+
if (!meta?.entryPoint) throw new Error(`[csr-dev] no entry point recorded for ${artifact.path}`);
|
|
172
|
+
const file = CsrDevPaths.realpath(path.resolve(meta.entryPoint));
|
|
173
|
+
const inputs = Object.keys(meta.inputs).map((input) => CsrDevPaths.realpath(path.resolve(input)));
|
|
174
|
+
if (inputs.some((input) => input !== file && this.#resolver.isRegistryModule(input))) {
|
|
175
|
+
tangled.push(file);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const code = await artifact.text();
|
|
179
|
+
const id = this.#paths.idOf(file);
|
|
180
|
+
const sourceMap = sourceMaps.get(`${artifact.path}.map`);
|
|
181
|
+
modules.push({
|
|
182
|
+
id,
|
|
183
|
+
file,
|
|
184
|
+
vendor,
|
|
185
|
+
...CsrDevModuleCompiler.#factory(id, code),
|
|
186
|
+
sourceMap: sourceMap ? this.#rebaseSourceMap(await sourceMap.text()) : undefined,
|
|
187
|
+
deps: this.#emittedDeps(code),
|
|
188
|
+
mtimeMs: mtimes.get(file) ?? CsrDevPaths.mtimeOf(file),
|
|
189
|
+
hash: hashes.get(file) ?? "",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return { modules, misses, tangled };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#registryPlugin(vendor: boolean, misses: string[]): BunPlugin {
|
|
196
|
+
return {
|
|
197
|
+
name: "akan-csr-registry",
|
|
198
|
+
setup: (build) => {
|
|
199
|
+
build.onLoad({ filter: /\.css$/ }, () => ({ contents: "", loader: "js" }));
|
|
200
|
+
if (!vendor)
|
|
201
|
+
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
|
|
202
|
+
if (CsrDevPaths.isVendorFile(args.path)) return undefined;
|
|
203
|
+
const normalized = RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(
|
|
204
|
+
await Bun.file(args.path).text(),
|
|
205
|
+
{ path: args.path },
|
|
206
|
+
);
|
|
207
|
+
return normalized ? { contents: normalized, loader: "tsx" } : undefined;
|
|
208
|
+
});
|
|
209
|
+
build.onResolve({ filter: /.*/ }, (args) => {
|
|
210
|
+
if (args.kind === "entry-point-build" || !args.importer) return undefined;
|
|
211
|
+
if (this.#context.externals?.includes(args.path))
|
|
212
|
+
return { path: `${CsrDevPaths.vendorPrefix}${args.path}`, external: true };
|
|
213
|
+
const importer = CsrDevPaths.realpath(args.importer);
|
|
214
|
+
const target = this.#resolver.resolve(
|
|
215
|
+
importer,
|
|
216
|
+
args.path,
|
|
217
|
+
args.kind === "require-call" ? "require" : "import",
|
|
218
|
+
);
|
|
219
|
+
if (target === CsrDevResolver.inline) return undefined;
|
|
220
|
+
if (target === null) {
|
|
221
|
+
misses.push(`${args.path} from ${this.#paths.idOf(importer)}`);
|
|
222
|
+
return { path: `akan-miss:${args.path}`, external: true };
|
|
223
|
+
}
|
|
224
|
+
const external = CsrDevPaths.isStub(target)
|
|
225
|
+
? target
|
|
226
|
+
: `${CsrDevPaths.modulePrefix}${this.#paths.idOf(target)}`;
|
|
227
|
+
return { path: external, external: true };
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
//? Bun writes sources relative to the working directory; the page shows them by workspace path instead. A package
|
|
234
|
+
//? barrel inlined into its importer (react-icons) would carry its whole text into every importer's map.
|
|
235
|
+
#rebaseSourceMap(text: string): string {
|
|
236
|
+
const map = JSON.parse(text) as { sources: string[]; sourcesContent?: (string | null)[] };
|
|
237
|
+
const files = map.sources.map((source) => path.resolve(source));
|
|
238
|
+
map.sources = files.map((file) => `/${this.#paths.idOf(file)}`);
|
|
239
|
+
map.sourcesContent = files.map((file, index) =>
|
|
240
|
+
CsrDevPaths.isVendorFile(file) ? null : (map.sourcesContent?.[index] ?? null),
|
|
241
|
+
);
|
|
242
|
+
return JSON.stringify(map);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Read off the emitted code rather than onResolve: Bun resolves both arms of `NODE_ENV ? require(a) : require(b)`
|
|
246
|
+
// before dropping the dead one, and following it would pull every package's production build in too.
|
|
247
|
+
#emittedDeps(code: string): string[] {
|
|
248
|
+
const deps = new Set<string>();
|
|
249
|
+
const references = /\b(?:require|import|__akanImport)\("((?:akan-module|stub):[^"]+)"\)/g;
|
|
250
|
+
for (const [, reference] of code.matchAll(references)) {
|
|
251
|
+
if (!reference) continue;
|
|
252
|
+
deps.add(
|
|
253
|
+
CsrDevPaths.isStub(reference)
|
|
254
|
+
? reference
|
|
255
|
+
: this.#paths.fileOf(reference.slice(CsrDevPaths.modulePrefix.length)),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return [...deps];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Every rewrite keeps the line count, so a module's source map still lines up inside the factory.
|
|
262
|
+
static #factory(id: string, code: string): { factory: string; helpers: CsrDevSharedHelpers | null } {
|
|
263
|
+
const prefix = CsrDevPaths.modulePrefix;
|
|
264
|
+
let patched = code.replaceAll(`import("${prefix}`, `__akanImport("${prefix}`).replace(/^\/\/# debugId=.*$/m, "");
|
|
265
|
+
for (const [name, params, method] of CsrDevModuleCompiler.#helperPatches) {
|
|
266
|
+
const definition = `var ${name} = ${params} => {`;
|
|
267
|
+
if (patched.includes(definition))
|
|
268
|
+
patched = patched.replace(definition, `var ${name} = __akan.${method}, __bun${name} = ${params} => {`);
|
|
269
|
+
else if (patched.includes(`${name}(`))
|
|
270
|
+
throw new Error(
|
|
271
|
+
`[csr-dev] ${id}: Bun's ${name} helper no longer reads "${definition}"; CsrDevModuleCompiler must follow`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
// StoreRegistry.build merges into one global store instance, so every importer already holds the updated `st`:
|
|
275
|
+
// re-running the store root is the whole update, and bubbling past it would only reach the page modules.
|
|
276
|
+
if (CsrDevModuleCompiler.#storeRoot.test(id)) patched += "\nmodule.hot.accept();";
|
|
277
|
+
const shared = CsrDevModuleCompiler.#shareHelpers(patched);
|
|
278
|
+
return {
|
|
279
|
+
factory: `function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {\n${shared?.code ?? patched}\n}`,
|
|
280
|
+
helpers: shared?.helpers ?? null,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
//? Bun's interop helpers are everything before its first `// <path>` comment. One naming a factory argument would
|
|
285
|
+
//? bind to whichever module ran it first, so only a self-contained preamble is shared.
|
|
286
|
+
static #shareHelpers(code: string): { code: string; helpers: CsrDevSharedHelpers } | null {
|
|
287
|
+
const marker = code.search(/^\/\/ /m);
|
|
288
|
+
if (marker <= 0) return null;
|
|
289
|
+
const preamble = code.slice(0, marker);
|
|
290
|
+
const names = [...preamble.matchAll(/^(?:var|let|const|function)\s+([\w$]+)/gm)]
|
|
291
|
+
.map(([, name]) => name)
|
|
292
|
+
.filter((name): name is string => !!name);
|
|
293
|
+
if (names.length === 0 || CsrDevModuleCompiler.#factoryArgument.test(preamble)) return null;
|
|
294
|
+
const hash = Bun.hash(preamble).toString(36);
|
|
295
|
+
const binding = `var { ${names.join(", ")} } = __akan.helpers(${JSON.stringify(hash)});`;
|
|
296
|
+
return {
|
|
297
|
+
code: `${binding}${"\n".repeat(CsrDevPaths.lineCount(preamble))}${code.slice(marker)}`,
|
|
298
|
+
helpers: {
|
|
299
|
+
hash,
|
|
300
|
+
definition: `__akan.defineHelpers(${JSON.stringify(hash)}, function () {\n${preamble}return { ${names.join(", ")} };\n});\n`,
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|