@akanjs/devkit 3.0.0-beta.23 → 3.0.0-beta.24
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/cloud/globalConfig.ts +16 -1
- package/csrE2e/csrE2eHarness.fixture.ts +400 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +187 -0
- package/frontendBuild/csrDevArtifactWriter.ts +169 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +256 -0
- package/frontendBuild/csrDevModuleCompiler.ts +262 -0
- package/frontendBuild/csrDevPaths.ts +64 -0
- package/frontendBuild/csrDevResolver.ts +182 -0
- package/frontendBuild/csrDevTypes.ts +46 -0
- package/frontendBuild/index.ts +1 -0
- package/frontendBuild/pagesEntrySourceGenerator.ts +48 -5
- package/incrementalBuilder/buildBatch.proc.ts +37 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -0
- package/integration/devStability.integration.test.ts +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { CSR_DEV_ROUTE_PREFIX } from "akanjs/server/hmr/csrDevManifest";
|
|
3
|
+
import type { BunPlugin } from "bun";
|
|
4
|
+
import type { App } from "../commandDecorators";
|
|
5
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
6
|
+
import { CsrDevResolver } from "./csrDevResolver";
|
|
7
|
+
import type { CsrDevCompiledModule, CsrDevContext, CsrDevSharedHelpers } from "./csrDevTypes";
|
|
8
|
+
import { RouteClientBuilder } from "./routeClientBuilder";
|
|
9
|
+
|
|
10
|
+
interface OutputMetafile {
|
|
11
|
+
outputs: Record<string, { entryPoint?: string; inputs: Record<string, unknown> }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface BuildRound {
|
|
15
|
+
modules: CsrDevCompiledModule[];
|
|
16
|
+
misses: string[];
|
|
17
|
+
tangled: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CsrDevModuleCompilerOptions {
|
|
21
|
+
app: App;
|
|
22
|
+
paths: CsrDevPaths;
|
|
23
|
+
resolver: CsrDevResolver;
|
|
24
|
+
context: CsrDevContext;
|
|
25
|
+
outDir: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
//* Every module becomes its own CJS factory: one Bun.build with each import external, so a save re-runs only the
|
|
29
|
+
//* modules it changed instead of reloading one scope-hoisted file.
|
|
30
|
+
export class CsrDevModuleCompiler {
|
|
31
|
+
static readonly #storeRoot = /(?:^|\/)lib\/st\.ts$/;
|
|
32
|
+
static readonly #factoryArgument =
|
|
33
|
+
/(?<![\w$.])(?:require|module|exports|__akanImport|\$RefreshReg\$|\$RefreshSig\$)(?![\w$]|\s*:)/;
|
|
34
|
+
static readonly #helperPatches = [
|
|
35
|
+
["__toESM", "(mod, isNodeMode, target)", "toESM"],
|
|
36
|
+
["__reExport", "(target, mod, secondTarget)", "reExport"],
|
|
37
|
+
] as const;
|
|
38
|
+
readonly #app: App;
|
|
39
|
+
readonly #paths: CsrDevPaths;
|
|
40
|
+
readonly #resolver: CsrDevResolver;
|
|
41
|
+
readonly #context: CsrDevContext;
|
|
42
|
+
readonly #outDir: string;
|
|
43
|
+
|
|
44
|
+
constructor({ app, paths, resolver, context, outDir }: CsrDevModuleCompilerOptions) {
|
|
45
|
+
this.#app = app;
|
|
46
|
+
this.#paths = paths;
|
|
47
|
+
this.#resolver = resolver;
|
|
48
|
+
this.#context = context;
|
|
49
|
+
this.#outDir = outDir;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async compile(startFiles: string[], known: Set<string>): Promise<CsrDevCompiledModule[]> {
|
|
53
|
+
const compiled = new Map<string, CsrDevCompiledModule>();
|
|
54
|
+
const seen = new Set([...known, ...startFiles]);
|
|
55
|
+
let frontier = [...new Set(startFiles)];
|
|
56
|
+
while (frontier.length > 0) {
|
|
57
|
+
const round = await this.#compileRound(frontier);
|
|
58
|
+
if (round.misses.length > 0 && !this.#resolver.prepassDone) {
|
|
59
|
+
this.#app.verbose(`[csr-dev] ${round.misses.length} unresolved import(s); rerunning the resolution build`);
|
|
60
|
+
await this.#resolver.prepass();
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const next: string[] = [];
|
|
64
|
+
for (const module of round.modules) {
|
|
65
|
+
compiled.set(module.file, module);
|
|
66
|
+
for (const dep of module.deps) {
|
|
67
|
+
if (CsrDevPaths.isStub(dep) || seen.has(dep)) continue;
|
|
68
|
+
seen.add(dep);
|
|
69
|
+
next.push(dep);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
frontier = next;
|
|
73
|
+
}
|
|
74
|
+
const { fallbacks } = this.#resolver;
|
|
75
|
+
if (fallbacks.size > 0)
|
|
76
|
+
this.#app.verbose(
|
|
77
|
+
`[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 ? ", ..." : ""}`,
|
|
78
|
+
);
|
|
79
|
+
return [...compiled.values()];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async #compileRound(files: string[]): Promise<Omit<BuildRound, "tangled">> {
|
|
83
|
+
const appFiles = files.filter((file) => !CsrDevPaths.isVendorFile(file));
|
|
84
|
+
const vendorFiles = files.filter((file) => CsrDevPaths.isVendorFile(file));
|
|
85
|
+
const rounds = await Promise.all([
|
|
86
|
+
appFiles.length > 0 ? this.#bunBuild(appFiles, false) : null,
|
|
87
|
+
vendorFiles.length > 0 ? this.#bunBuild(vendorFiles, true) : null,
|
|
88
|
+
]);
|
|
89
|
+
const modules = rounds.flatMap((round) => round?.modules ?? []);
|
|
90
|
+
const misses = rounds.flatMap((round) => round?.misses ?? []);
|
|
91
|
+
// One entrypoint per build cannot inline another: Bun ignores an external for an absolute import of an entrypoint.
|
|
92
|
+
for (const file of rounds.flatMap((round) => round?.tangled ?? [])) {
|
|
93
|
+
const single = await this.#bunBuild([file], CsrDevPaths.isVendorFile(file));
|
|
94
|
+
if (single.tangled.length > 0)
|
|
95
|
+
throw new Error(`[csr-dev] ${this.#paths.idOf(file)} kept inlining another module`);
|
|
96
|
+
modules.push(...single.modules);
|
|
97
|
+
misses.push(...single.misses);
|
|
98
|
+
}
|
|
99
|
+
return { modules, misses };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async #bunBuild(files: string[], vendor: boolean): Promise<BuildRound> {
|
|
103
|
+
const mtimes = new Map(files.map((file) => [file, CsrDevPaths.mtimeOf(file)]));
|
|
104
|
+
const hashes = new Map(
|
|
105
|
+
await Promise.all(files.map(async (file) => [file, vendor ? "" : await CsrDevPaths.hashOf(file)] as const)),
|
|
106
|
+
);
|
|
107
|
+
const misses: string[] = [];
|
|
108
|
+
const result = await Bun.build({
|
|
109
|
+
entrypoints: files,
|
|
110
|
+
root: this.#paths.root,
|
|
111
|
+
target: "browser",
|
|
112
|
+
format: "cjs",
|
|
113
|
+
reactFastRefresh: !vendor,
|
|
114
|
+
sourcemap: vendor ? "none" : "external",
|
|
115
|
+
naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
|
|
116
|
+
publicPath: CSR_DEV_ROUTE_PREFIX,
|
|
117
|
+
metafile: true,
|
|
118
|
+
env: "AKAN_PUBLIC_*",
|
|
119
|
+
define: this.#context.define,
|
|
120
|
+
optimizeImports: this.#context.optimizeImports,
|
|
121
|
+
plugins: [this.#registryPlugin(vendor, misses)],
|
|
122
|
+
});
|
|
123
|
+
const { outputs } = result.metafile as unknown as OutputMetafile;
|
|
124
|
+
const sourceMaps = new Map(
|
|
125
|
+
result.outputs.filter((artifact) => artifact.kind === "sourcemap").map((artifact) => [artifact.path, artifact]),
|
|
126
|
+
);
|
|
127
|
+
const modules: CsrDevCompiledModule[] = [];
|
|
128
|
+
const tangled: string[] = [];
|
|
129
|
+
for (const artifact of result.outputs) {
|
|
130
|
+
if (artifact.kind === "asset") {
|
|
131
|
+
await Bun.write(path.join(this.#outDir, artifact.path), artifact);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (artifact.kind !== "entry-point") continue;
|
|
135
|
+
const meta = outputs[artifact.path];
|
|
136
|
+
if (!meta?.entryPoint) throw new Error(`[csr-dev] no entry point recorded for ${artifact.path}`);
|
|
137
|
+
const file = CsrDevPaths.realpath(path.resolve(meta.entryPoint));
|
|
138
|
+
const inputs = Object.keys(meta.inputs).map((input) => CsrDevPaths.realpath(path.resolve(input)));
|
|
139
|
+
if (inputs.some((input) => input !== file && this.#resolver.isRegistryModule(input))) {
|
|
140
|
+
tangled.push(file);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const code = await artifact.text();
|
|
144
|
+
const id = this.#paths.idOf(file);
|
|
145
|
+
const sourceMap = sourceMaps.get(`${artifact.path}.map`);
|
|
146
|
+
modules.push({
|
|
147
|
+
id,
|
|
148
|
+
file,
|
|
149
|
+
vendor,
|
|
150
|
+
...CsrDevModuleCompiler.#factory(id, code),
|
|
151
|
+
sourceMap: sourceMap ? this.#rebaseSourceMap(await sourceMap.text()) : undefined,
|
|
152
|
+
deps: this.#emittedDeps(code),
|
|
153
|
+
mtimeMs: mtimes.get(file) ?? CsrDevPaths.mtimeOf(file),
|
|
154
|
+
hash: hashes.get(file) ?? "",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return { modules, misses, tangled };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#registryPlugin(vendor: boolean, misses: string[]): BunPlugin {
|
|
161
|
+
return {
|
|
162
|
+
name: "akan-csr-registry",
|
|
163
|
+
setup: (build) => {
|
|
164
|
+
build.onLoad({ filter: /\.css$/ }, () => ({ contents: "", loader: "js" }));
|
|
165
|
+
if (!vendor)
|
|
166
|
+
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
|
|
167
|
+
if (CsrDevPaths.isVendorFile(args.path)) return undefined;
|
|
168
|
+
const normalized = RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(
|
|
169
|
+
await Bun.file(args.path).text(),
|
|
170
|
+
);
|
|
171
|
+
return normalized ? { contents: normalized, loader: "tsx" } : undefined;
|
|
172
|
+
});
|
|
173
|
+
build.onResolve({ filter: /.*/ }, (args) => {
|
|
174
|
+
if (args.kind === "entry-point-build" || !args.importer) return undefined;
|
|
175
|
+
const importer = CsrDevPaths.realpath(args.importer);
|
|
176
|
+
const target = this.#resolver.resolve(importer, args.path);
|
|
177
|
+
if (target === CsrDevResolver.inline) return undefined;
|
|
178
|
+
if (target === null) {
|
|
179
|
+
misses.push(`${args.path} from ${this.#paths.idOf(importer)}`);
|
|
180
|
+
return { path: `akan-miss:${args.path}`, external: true };
|
|
181
|
+
}
|
|
182
|
+
const external = CsrDevPaths.isStub(target)
|
|
183
|
+
? target
|
|
184
|
+
: `${CsrDevPaths.modulePrefix}${this.#paths.idOf(target)}`;
|
|
185
|
+
return { path: external, external: true };
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
//? Bun writes sources relative to the working directory; the page shows them by workspace path instead. A package
|
|
192
|
+
//? barrel inlined into its importer (react-icons) would carry its whole text into every importer's map.
|
|
193
|
+
#rebaseSourceMap(text: string): string {
|
|
194
|
+
const map = JSON.parse(text) as { sources: string[]; sourcesContent?: (string | null)[] };
|
|
195
|
+
const files = map.sources.map((source) => path.resolve(source));
|
|
196
|
+
map.sources = files.map((file) => `/${this.#paths.idOf(file)}`);
|
|
197
|
+
map.sourcesContent = files.map((file, index) =>
|
|
198
|
+
CsrDevPaths.isVendorFile(file) ? null : (map.sourcesContent?.[index] ?? null),
|
|
199
|
+
);
|
|
200
|
+
return JSON.stringify(map);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Read off the emitted code rather than onResolve: Bun resolves both arms of `NODE_ENV ? require(a) : require(b)`
|
|
204
|
+
// before dropping the dead one, and following it would pull every package's production build in too.
|
|
205
|
+
#emittedDeps(code: string): string[] {
|
|
206
|
+
const deps = new Set<string>();
|
|
207
|
+
const references = /\b(?:require|import|__akanImport)\("((?:akan-module|stub):[^"]+)"\)/g;
|
|
208
|
+
for (const [, reference] of code.matchAll(references)) {
|
|
209
|
+
if (!reference) continue;
|
|
210
|
+
deps.add(
|
|
211
|
+
CsrDevPaths.isStub(reference)
|
|
212
|
+
? reference
|
|
213
|
+
: this.#paths.fileOf(reference.slice(CsrDevPaths.modulePrefix.length)),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return [...deps];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Every rewrite keeps the line count, so a module's source map still lines up inside the factory.
|
|
220
|
+
static #factory(id: string, code: string): { factory: string; helpers: CsrDevSharedHelpers | null } {
|
|
221
|
+
const prefix = CsrDevPaths.modulePrefix;
|
|
222
|
+
let patched = code.replaceAll(`import("${prefix}`, `__akanImport("${prefix}`).replace(/^\/\/# debugId=.*$/m, "");
|
|
223
|
+
for (const [name, params, method] of CsrDevModuleCompiler.#helperPatches) {
|
|
224
|
+
const definition = `var ${name} = ${params} => {`;
|
|
225
|
+
if (patched.includes(definition))
|
|
226
|
+
patched = patched.replace(definition, `var ${name} = __akan.${method}, __bun${name} = ${params} => {`);
|
|
227
|
+
else if (patched.includes(`${name}(`))
|
|
228
|
+
throw new Error(
|
|
229
|
+
`[csr-dev] ${id}: Bun's ${name} helper no longer reads "${definition}"; CsrDevModuleCompiler must follow`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
// StoreRegistry.build merges into one global store instance, so every importer already holds the updated `st`:
|
|
233
|
+
// re-running the store root is the whole update, and bubbling past it would only reach the page modules.
|
|
234
|
+
if (CsrDevModuleCompiler.#storeRoot.test(id)) patched += "\nmodule.hot.accept();";
|
|
235
|
+
const shared = CsrDevModuleCompiler.#shareHelpers(patched);
|
|
236
|
+
return {
|
|
237
|
+
factory: `function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {\n${shared?.code ?? patched}\n}`,
|
|
238
|
+
helpers: shared?.helpers ?? null,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
//? Bun's interop helpers are everything before its first `// <path>` comment. One naming a factory argument would
|
|
243
|
+
//? bind to whichever module ran it first, so only a self-contained preamble is shared.
|
|
244
|
+
static #shareHelpers(code: string): { code: string; helpers: CsrDevSharedHelpers } | null {
|
|
245
|
+
const marker = code.search(/^\/\/ /m);
|
|
246
|
+
if (marker <= 0) return null;
|
|
247
|
+
const preamble = code.slice(0, marker);
|
|
248
|
+
const names = [...preamble.matchAll(/^(?:var|let|const|function)\s+([\w$]+)/gm)]
|
|
249
|
+
.map(([, name]) => name)
|
|
250
|
+
.filter((name): name is string => !!name);
|
|
251
|
+
if (names.length === 0 || CsrDevModuleCompiler.#factoryArgument.test(preamble)) return null;
|
|
252
|
+
const hash = Bun.hash(preamble).toString(36);
|
|
253
|
+
const binding = `var { ${names.join(", ")} } = __akan.helpers(${JSON.stringify(hash)});`;
|
|
254
|
+
return {
|
|
255
|
+
code: `${binding}${"\n".repeat(CsrDevPaths.lineCount(preamble))}${code.slice(marker)}`,
|
|
256
|
+
helpers: {
|
|
257
|
+
hash,
|
|
258
|
+
definition: `__akan.defineHelpers(${JSON.stringify(hash)}, function () {\n${preamble}return { ${names.join(", ")} };\n});\n`,
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export class CsrDevPaths {
|
|
5
|
+
static readonly modulePrefix = "akan-module:";
|
|
6
|
+
static readonly stubPrefix = "stub:";
|
|
7
|
+
readonly root: string;
|
|
8
|
+
|
|
9
|
+
constructor(workspaceRoot: string) {
|
|
10
|
+
this.root = CsrDevPaths.realpath(workspaceRoot);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
idOf(file: string): string {
|
|
14
|
+
const real = CsrDevPaths.realpath(file);
|
|
15
|
+
const relative = path.relative(this.root, real);
|
|
16
|
+
const id = relative.startsWith("..") || path.isAbsolute(relative) ? real : relative;
|
|
17
|
+
return id.split(path.sep).join("/");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fileOf(id: string): string {
|
|
21
|
+
return path.isAbsolute(id) ? id : path.join(this.root, id);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static isStub(target: string): boolean {
|
|
25
|
+
return target.startsWith(CsrDevPaths.stubPrefix);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static isVendorFile(file: string): boolean {
|
|
29
|
+
return file.includes(`${path.sep}node_modules${path.sep}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static isScript(file: string): boolean {
|
|
33
|
+
return /\.[cm]?[jt]sx?$/.test(file);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static mtimeOf(file: string): number {
|
|
37
|
+
return fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? -1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static async hashOf(file: string): Promise<string> {
|
|
41
|
+
const source = Bun.file(file);
|
|
42
|
+
return (await source.exists()) ? Bun.hash(await source.arrayBuffer()).toString(36) : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static realpath(file: string): string {
|
|
46
|
+
// A deleted file keeps its own path, so its id still matches the graph entry it leaves behind.
|
|
47
|
+
return fs.existsSync(file) ? fs.realpathSync(file) : path.resolve(file);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static tryResolve(specifier: string, from: string): string | null {
|
|
51
|
+
try {
|
|
52
|
+
return Bun.resolveSync(specifier, from);
|
|
53
|
+
} catch {
|
|
54
|
+
// Unresolvable is an answer here; the caller decides whether it is an error.
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static lineCount(text: string): number {
|
|
60
|
+
let count = 0;
|
|
61
|
+
for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) count += 1;
|
|
62
|
+
return count;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
4
|
+
import type { CsrDevContext } from "./csrDevTypes";
|
|
5
|
+
import { PagesBundleBuilder } from "./pagesBundleBuilder";
|
|
6
|
+
|
|
7
|
+
interface MetafileImport {
|
|
8
|
+
path: string;
|
|
9
|
+
kind: string;
|
|
10
|
+
original?: string;
|
|
11
|
+
external?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PrepassMetafile {
|
|
15
|
+
inputs: Record<string, { imports: MetafileImport[] }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CsrDevResolverOptions {
|
|
19
|
+
paths: CsrDevPaths;
|
|
20
|
+
context: CsrDevContext;
|
|
21
|
+
entryFiles: string[];
|
|
22
|
+
resolution?: Record<string, Record<string, string>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
//* Resolution comes from a plain browser build: Bun's runtime resolver ignores the `browser` condition and picks Node
|
|
26
|
+
//* builds (`@firebase/util` resolves to its node-esm entry), which the registry build would then ship.
|
|
27
|
+
export class CsrDevResolver {
|
|
28
|
+
static readonly inline = "inline";
|
|
29
|
+
readonly #paths: CsrDevPaths;
|
|
30
|
+
readonly #context: CsrDevContext;
|
|
31
|
+
readonly #entryFiles: string[];
|
|
32
|
+
readonly #resolution = new Map<string, Map<string, string>>();
|
|
33
|
+
readonly #fallbacks = new Set<string>();
|
|
34
|
+
#prepassDone = false;
|
|
35
|
+
|
|
36
|
+
constructor({ paths, context, entryFiles, resolution = {} }: CsrDevResolverOptions) {
|
|
37
|
+
this.#paths = paths;
|
|
38
|
+
this.#context = context;
|
|
39
|
+
this.#entryFiles = entryFiles;
|
|
40
|
+
for (const [importer, bySpecifier] of Object.entries(resolution))
|
|
41
|
+
this.#resolution.set(
|
|
42
|
+
paths.fileOf(importer),
|
|
43
|
+
new Map(
|
|
44
|
+
Object.entries(bySpecifier).map(([specifier, target]) => [
|
|
45
|
+
specifier,
|
|
46
|
+
CsrDevPaths.isStub(target) ? target : paths.fileOf(target),
|
|
47
|
+
]),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get prepassDone(): boolean {
|
|
53
|
+
return this.#prepassDone;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get fallbacks(): ReadonlySet<string> {
|
|
57
|
+
return this.#fallbacks;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async prepass(): Promise<void> {
|
|
61
|
+
const result = await Bun.build({
|
|
62
|
+
entrypoints: [...this.#entryFiles, this.#context.refreshFile],
|
|
63
|
+
target: "browser",
|
|
64
|
+
metafile: true,
|
|
65
|
+
env: "AKAN_PUBLIC_*",
|
|
66
|
+
define: this.#context.define,
|
|
67
|
+
optimizeImports: this.#context.optimizeImports,
|
|
68
|
+
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
69
|
+
});
|
|
70
|
+
const { inputs } = result.metafile as unknown as PrepassMetafile;
|
|
71
|
+
for (const [input, { imports }] of Object.entries(inputs)) {
|
|
72
|
+
const importer = CsrDevPaths.realpath(path.resolve(input));
|
|
73
|
+
for (const record of imports) {
|
|
74
|
+
if (!record.original) continue;
|
|
75
|
+
if (record.external) {
|
|
76
|
+
if (record.original.startsWith("node:"))
|
|
77
|
+
this.#remember(importer, record.original, `${CsrDevPaths.stubPrefix}${record.original}`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const target = path.resolve(record.path);
|
|
81
|
+
if (fs.existsSync(target)) this.#remember(importer, record.original, CsrDevPaths.realpath(target));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
this.#prepassDone = true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
resolve(importer: string, specifier: string): string | null {
|
|
88
|
+
if (this.#isInlineSpecifier(specifier)) return CsrDevResolver.inline;
|
|
89
|
+
const known = this.#resolution.get(importer)?.get(specifier);
|
|
90
|
+
const usable = known && (CsrDevPaths.isStub(known) || fs.existsSync(known)) ? known : null;
|
|
91
|
+
const target = usable ?? this.#resolveUnknown(importer, specifier);
|
|
92
|
+
if (target === null || CsrDevPaths.isStub(target)) return target;
|
|
93
|
+
if (!CsrDevPaths.isScript(target)) return CsrDevResolver.inline;
|
|
94
|
+
if (!usable) this.#remember(importer, specifier, target);
|
|
95
|
+
return target;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
isRegistryModule(file: string): boolean {
|
|
99
|
+
if (!CsrDevPaths.isScript(file)) return false;
|
|
100
|
+
const packageName = CsrDevResolver.#packageNameOf(file);
|
|
101
|
+
if (packageName === null) return true;
|
|
102
|
+
return !this.#context.optimizeImports.some((pattern) => {
|
|
103
|
+
const base = CsrDevResolver.#patternBase(pattern);
|
|
104
|
+
return base === packageName || base.startsWith(`${packageName}/`);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
serialize(): Record<string, Record<string, string>> {
|
|
109
|
+
return Object.fromEntries(
|
|
110
|
+
[...this.#resolution].map(([importer, bySpecifier]) => [
|
|
111
|
+
this.#paths.idOf(importer),
|
|
112
|
+
Object.fromEntries(
|
|
113
|
+
[...bySpecifier].map(([specifier, target]) => [
|
|
114
|
+
specifier,
|
|
115
|
+
CsrDevPaths.isStub(target) ? target : this.#paths.idOf(target),
|
|
116
|
+
]),
|
|
117
|
+
),
|
|
118
|
+
]),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#resolveUnknown(importer: string, specifier: string): string | null {
|
|
123
|
+
if (specifier.startsWith("node:")) return `${CsrDevPaths.stubPrefix}${specifier}`;
|
|
124
|
+
const relative = specifier.startsWith(".") || path.isAbsolute(specifier);
|
|
125
|
+
if (!relative && !this.#prepassDone) return null;
|
|
126
|
+
if (!relative) {
|
|
127
|
+
const sibling = this.#resolvedBySibling(importer, specifier);
|
|
128
|
+
if (sibling) return sibling;
|
|
129
|
+
}
|
|
130
|
+
const resolved = CsrDevPaths.tryResolve(specifier, path.dirname(importer));
|
|
131
|
+
if (!resolved) throw new Error(`[csr-dev] cannot resolve "${specifier}" from ${this.#paths.idOf(importer)}`);
|
|
132
|
+
if (!path.isAbsolute(resolved)) return `${CsrDevPaths.stubPrefix}${specifier}`;
|
|
133
|
+
if (!relative) this.#fallbacks.add(specifier);
|
|
134
|
+
return CsrDevPaths.realpath(resolved);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
//? The browser build tree-shakes the unused re-exports of a side-effect-free barrel, so their imports have no
|
|
138
|
+
//? recorded resolution; a file of the same package resolves a bare specifier to the same target, conditions included.
|
|
139
|
+
#resolvedBySibling(importer: string, specifier: string): string | null {
|
|
140
|
+
const scope = CsrDevResolver.#packageScopeOf(importer);
|
|
141
|
+
for (const [other, bySpecifier] of this.#resolution) {
|
|
142
|
+
const target = bySpecifier.get(specifier);
|
|
143
|
+
if (target && CsrDevResolver.#packageScopeOf(other) === scope) return target;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#remember(importer: string, specifier: string, target: string): void {
|
|
149
|
+
const byImporter = this.#resolution.get(importer) ?? new Map<string, string>();
|
|
150
|
+
this.#resolution.set(importer, byImporter.set(specifier, target));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
//? Only the barrel a bare import names is inlined, so its icons tree-shake per importer; what the barrel itself
|
|
154
|
+
//? imports stays a registry module, keeping shared state such as react-icons' IconContext a single instance.
|
|
155
|
+
#isInlineSpecifier(specifier: string): boolean {
|
|
156
|
+
if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("node:")) return false;
|
|
157
|
+
return this.#context.optimizeImports.some((pattern) => {
|
|
158
|
+
const base = CsrDevResolver.#patternBase(pattern);
|
|
159
|
+
return specifier === base || specifier.startsWith(`${base}/`);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
static #patternBase(pattern: string): string {
|
|
164
|
+
return pattern.endsWith("/*") ? pattern.slice(0, -2) : pattern;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
static #packageNameOf(file: string): string | null {
|
|
168
|
+
const marker = `${path.sep}node_modules${path.sep}`;
|
|
169
|
+
const index = file.lastIndexOf(marker);
|
|
170
|
+
if (index < 0) return null;
|
|
171
|
+
const [scope, name] = file.slice(index + marker.length).split(path.sep);
|
|
172
|
+
return (scope?.startsWith("@") ? `${scope}/${name}` : scope) || null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
static #packageScopeOf(file: string): string {
|
|
176
|
+
const marker = `${path.sep}node_modules${path.sep}`;
|
|
177
|
+
const index = file.lastIndexOf(marker);
|
|
178
|
+
if (index < 0) return "";
|
|
179
|
+
const [scope, name] = file.slice(index + marker.length).split(path.sep);
|
|
180
|
+
return file.slice(0, index + marker.length) + (scope?.startsWith("@") ? `${scope}${path.sep}${name}` : scope);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
2
|
+
|
|
3
|
+
export interface CsrDevGraphModule {
|
|
4
|
+
vendor: boolean;
|
|
5
|
+
mtimeMs: number;
|
|
6
|
+
hash: string;
|
|
7
|
+
deps: string[];
|
|
8
|
+
helpers?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CsrDevGraph {
|
|
12
|
+
version: 1;
|
|
13
|
+
configKey: string;
|
|
14
|
+
refresh: string;
|
|
15
|
+
entries: Record<string, string>;
|
|
16
|
+
modules: Record<string, CsrDevGraphModule>;
|
|
17
|
+
resolution: Record<string, Record<string, string>>;
|
|
18
|
+
pending: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CsrDevContext {
|
|
22
|
+
pageEntries: PageEntry[];
|
|
23
|
+
basePaths: string[];
|
|
24
|
+
htmlBasePaths: string[];
|
|
25
|
+
define: Record<string, string>;
|
|
26
|
+
optimizeImports: string[];
|
|
27
|
+
configKey: string;
|
|
28
|
+
refreshFile: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CsrDevSharedHelpers {
|
|
32
|
+
hash: string;
|
|
33
|
+
definition: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CsrDevCompiledModule {
|
|
37
|
+
id: string;
|
|
38
|
+
file: string;
|
|
39
|
+
vendor: boolean;
|
|
40
|
+
factory: string;
|
|
41
|
+
helpers: CsrDevSharedHelpers | null;
|
|
42
|
+
sourceMap?: string;
|
|
43
|
+
deps: string[];
|
|
44
|
+
mtimeMs: number;
|
|
45
|
+
hash: string;
|
|
46
|
+
}
|
package/frontendBuild/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./clientBuildTypes";
|
|
|
4
4
|
export * from "./clientEntriesBundler";
|
|
5
5
|
export * from "./clientEntryDiscovery";
|
|
6
6
|
export * from "./csrArtifactBuilder";
|
|
7
|
+
export * from "./csrDevBundler";
|
|
7
8
|
export * from "./cssCompiler";
|
|
8
9
|
export * from "./cssImportResolver";
|
|
9
10
|
export * from "./devChangePlanner";
|
|
@@ -4,6 +4,7 @@ import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDete
|
|
|
4
4
|
|
|
5
5
|
export class PagesEntrySourceGenerator {
|
|
6
6
|
#pageEntries: PageEntry[];
|
|
7
|
+
readonly #asyncDefaults = new Map<string, Promise<boolean>>();
|
|
7
8
|
|
|
8
9
|
constructor(pageEntries: PageEntry[]) {
|
|
9
10
|
this.#pageEntries = pageEntries;
|
|
@@ -21,25 +22,67 @@ export class PagesEntrySourceGenerator {
|
|
|
21
22
|
return `export const pages = {\n${lines.join("\n")}\n};\n`;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
static async generateStatic(pageEntries: PageEntry[]): Promise<string> {
|
|
25
|
-
return await new PagesEntrySourceGenerator(pageEntries).generateStatic();
|
|
25
|
+
static async generateStatic(pageEntries: PageEntry[], { fromDir }: { fromDir?: string } = {}): Promise<string> {
|
|
26
|
+
return await new PagesEntrySourceGenerator(pageEntries).generateStatic({ fromDir });
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
// `fromDir` makes the specifiers relative: Bun inlines an absolute import of a file that is also an entrypoint.
|
|
30
|
+
async generateStatic({ fromDir }: { fromDir?: string } = {}): Promise<string> {
|
|
29
31
|
const imports = this.#pageEntries.map(({ moduleAbsPath }, index) => {
|
|
30
|
-
const specifier =
|
|
32
|
+
const specifier = fromDir
|
|
33
|
+
? PagesEntrySourceGenerator.#toRelativeSpecifier(fromDir, moduleAbsPath)
|
|
34
|
+
: PagesEntrySourceGenerator.#toImportSpecifier(moduleAbsPath);
|
|
31
35
|
return `import * as page${index} from ${JSON.stringify(specifier)};`;
|
|
32
36
|
});
|
|
33
37
|
const entries = await Promise.all(
|
|
34
38
|
this.#pageEntries.map(async ({ key, moduleAbsPath }, index) => {
|
|
35
|
-
const isAsyncDefault = await
|
|
39
|
+
const isAsyncDefault = await this.#isAsyncDefault(moduleAbsPath);
|
|
36
40
|
return ` ${JSON.stringify(key)}: { loader: async () => page${index}, isAsyncDefault: ${isAsyncDefault} },`;
|
|
37
41
|
}),
|
|
38
42
|
);
|
|
39
43
|
return `${imports.join("\n")}\nexport const pages = {\n${entries.join("\n")}\n};\n`;
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
// Route HMR in the dev registry bundle: `akanWebkit` is the entry's namespace import of `akanjs/webkit`.
|
|
47
|
+
async generateHotReplace({
|
|
48
|
+
fromDir,
|
|
49
|
+
ownerId,
|
|
50
|
+
moduleIds,
|
|
51
|
+
}: {
|
|
52
|
+
fromDir: string;
|
|
53
|
+
ownerId: string;
|
|
54
|
+
moduleIds: string[];
|
|
55
|
+
}): Promise<string> {
|
|
56
|
+
const entries = await Promise.all(
|
|
57
|
+
this.#pageEntries.map(async ({ key, moduleAbsPath }) => {
|
|
58
|
+
const specifier = PagesEntrySourceGenerator.#toRelativeSpecifier(fromDir, moduleAbsPath);
|
|
59
|
+
const isAsyncDefault = await this.#isAsyncDefault(moduleAbsPath);
|
|
60
|
+
return ` ${JSON.stringify(key)}: { loader: async () => require(${JSON.stringify(specifier)}), isAsyncDefault: ${isAsyncDefault} },`;
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
return `__akan.accept(${JSON.stringify(ownerId)}, ${JSON.stringify(moduleIds)}, async () => {
|
|
64
|
+
const replaced = await akanWebkit.replacePages({
|
|
65
|
+
${entries.join("\n")}
|
|
66
|
+
});
|
|
67
|
+
if (!replaced) throw new Error("the route table no longer matches the pages it was built from");
|
|
68
|
+
});
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#isAsyncDefault(moduleAbsPath: string): Promise<boolean> {
|
|
73
|
+
const known = this.#asyncDefaults.get(moduleAbsPath);
|
|
74
|
+
if (known) return known;
|
|
75
|
+
const detected = AsyncDefaultExportDetector.detect(moduleAbsPath);
|
|
76
|
+
this.#asyncDefaults.set(moduleAbsPath, detected);
|
|
77
|
+
return detected;
|
|
78
|
+
}
|
|
79
|
+
|
|
42
80
|
static #toImportSpecifier(moduleAbsPath: string): string {
|
|
43
81
|
return path.resolve(moduleAbsPath).split(path.sep).join("/");
|
|
44
82
|
}
|
|
83
|
+
|
|
84
|
+
static #toRelativeSpecifier(fromDir: string, moduleAbsPath: string): string {
|
|
85
|
+
const relative = path.relative(fromDir, path.resolve(moduleAbsPath)).split(path.sep).join("/");
|
|
86
|
+
return relative.startsWith(".") ? relative : `./${relative}`;
|
|
87
|
+
}
|
|
45
88
|
}
|