@akanjs/devkit 3.0.0-beta.25 → 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 +9 -6
- package/akanApp/akanApp.host.ts +79 -22
- package/akanApp/devHostPolicy.test.ts +56 -11
- package/akanApp/devHostPolicy.ts +10 -10
- package/frontendBuild/clientEntryDiscovery.test.ts +75 -0
- package/frontendBuild/clientEntryDiscovery.ts +112 -70
- package/frontendBuild/csrDevModuleCompiler.test.ts +89 -0
- package/frontendBuild/csrDevModuleCompiler.ts +6 -2
- package/frontendBuild/csrDevPatcher.test.ts +234 -2
- package/frontendBuild/csrDevPatcher.ts +184 -10
- package/frontendBuild/csrDevPaths.ts +31 -11
- package/frontendBuild/csrDevResolver.test.ts +76 -2
- package/frontendBuild/csrDevResolver.ts +59 -10
- package/frontendBuild/csrDevTypes.ts +2 -0
- package/frontendBuild/devRegistryBundler.ts +18 -3
- package/frontendBuild/ssrDevBundler.ts +1 -7
- package/incrementalBuilder/incrementalBuilder.host.test.ts +34 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -3
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +62 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +58 -24
- package/integration/devStability.integration.test.ts +9 -2
- package/package.json +2 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { SOURCE_EXTS } from "../akanApp/devHostPolicy";
|
|
3
4
|
import type { App } from "../commandDecorators";
|
|
@@ -17,6 +18,21 @@ interface FileFacts {
|
|
|
17
18
|
imports: ScannedImport[];
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
interface Lookup<T> {
|
|
22
|
+
epoch: number;
|
|
23
|
+
promise: Promise<T>;
|
|
24
|
+
found?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DiscoveryWalk {
|
|
28
|
+
visiting: Set<string>;
|
|
29
|
+
//? Walked below a file the walk was already inside (an import cycle): their result lacks what that file reaches.
|
|
30
|
+
partial: Set<string>;
|
|
31
|
+
//? Every file's result within this walk, partial ones included: the file the cycle went back to is an ancestor here
|
|
32
|
+
//? and brings the rest itself, and re-walking each path through a dense cycle grows exponentially.
|
|
33
|
+
memo: Map<string, Set<string>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
const shouldSkipNodeModule = (absPath: string) => NODE_MODULES_RE.test(absPath) && !AKANJS_NODE_MODULE_RE.test(absPath);
|
|
21
37
|
|
|
22
38
|
/** `"use client"` discovery over the import graph (dynamic imports included), flattening barrels as the bundler does. */
|
|
@@ -25,15 +41,13 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
25
41
|
#resolvePackage: PackageResolver;
|
|
26
42
|
#analyzer: BarrelAnalyzer;
|
|
27
43
|
#tsTranspiler = new Bun.Transpiler({ loader: "tsx" });
|
|
28
|
-
#fileExistsCache = new Map<string,
|
|
44
|
+
#fileExistsCache = new Map<string, Lookup<boolean>>();
|
|
29
45
|
// Facts, never source text: this instance lives as long as the builder process, so texts would pin every file walked.
|
|
30
46
|
#factsCache = new Map<string, Promise<FileFacts | null>>();
|
|
31
|
-
#resolvedFileCache = new Map<string,
|
|
32
|
-
#resolvedSpecifierCache = new Map<string,
|
|
47
|
+
#resolvedFileCache = new Map<string, Lookup<string | null>>();
|
|
48
|
+
#resolvedSpecifierCache = new Map<string, Lookup<string | null>>();
|
|
33
49
|
#reachableEntriesCache = new Map<string, Set<string>>();
|
|
34
|
-
#
|
|
35
|
-
#unresolvedPaths = new Set<string>();
|
|
36
|
-
#unresolvedSpecifiers = new Set<string>();
|
|
50
|
+
#readFiles = new Set<string>();
|
|
37
51
|
//? Bumped by every invalidation: a walk that began before one computed from facts it may have read stale, so it
|
|
38
52
|
//? answers its caller but caches nothing (a registry check walks this instance beside the slow lane's route builds).
|
|
39
53
|
#epoch = 0;
|
|
@@ -51,48 +65,61 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
51
65
|
async discover(seeds: string[]): Promise<string[]> {
|
|
52
66
|
const entries = new Set<string>();
|
|
53
67
|
for (const seed of seeds) {
|
|
54
|
-
|
|
68
|
+
const walk: DiscoveryWalk = { visiting: new Set(), partial: new Set(), memo: new Map() };
|
|
69
|
+
for (const entry of await this.#discoverFromFile(seed, walk)) entries.add(entry);
|
|
55
70
|
}
|
|
56
71
|
return Array.from(entries).sort();
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
invalidate(files: string[]): void {
|
|
75
|
+
if (files.length === 0) return;
|
|
76
|
+
let moved = false;
|
|
60
77
|
for (const file of files) {
|
|
61
78
|
const absPath = path.resolve(file);
|
|
79
|
+
const exists = fs.existsSync(absPath);
|
|
80
|
+
if (exists !== this.#readFiles.has(absPath)) moved = true;
|
|
81
|
+
if (!exists) this.#readFiles.delete(absPath);
|
|
62
82
|
this.#factsCache.delete(absPath);
|
|
63
83
|
this.#fileExistsCache.delete(absPath);
|
|
64
|
-
this.#reachableEntriesCache.delete(absPath);
|
|
65
84
|
}
|
|
66
|
-
if (files.length === 0) return;
|
|
67
85
|
this.#epoch += 1;
|
|
68
86
|
// Reachable-entry results are transitive, so a changed child invalidates every ancestor.
|
|
69
87
|
this.#reachableEntriesCache.clear();
|
|
70
|
-
|
|
88
|
+
//? A file gone, or one never read, can leave a found resolution on its old target (`Chart.tsx` to
|
|
89
|
+
//? `Chart/index.tsx`), so a move drops them all; a save of a file already read keeps them (a walk re-resolving
|
|
90
|
+
//? every package cost 3x). Misses lapse with the epoch on their own (#lookup).
|
|
91
|
+
if (!moved) return;
|
|
92
|
+
this.#fileExistsCache.clear();
|
|
93
|
+
this.#resolvedFileCache.clear();
|
|
94
|
+
this.#resolvedSpecifierCache.clear();
|
|
71
95
|
}
|
|
72
96
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
this.#
|
|
79
|
-
|
|
80
|
-
|
|
97
|
+
//? A found answer outlives later epochs; a miss, or a lookup still in flight, answers only walks of its own epoch, so
|
|
98
|
+
//? a file created meanwhile is looked up again.
|
|
99
|
+
#lookup<T>(cache: Map<string, Lookup<T>>, key: string, run: () => Promise<T>, isFound: (value: T) => boolean) {
|
|
100
|
+
const cached = cache.get(key);
|
|
101
|
+
if (cached && (cached.found || cached.epoch === this.#epoch)) return cached.promise;
|
|
102
|
+
const entry: Lookup<T> = { epoch: this.#epoch, promise: run() };
|
|
103
|
+
entry.promise.then(
|
|
104
|
+
(value) => {
|
|
105
|
+
entry.found = isFound(value);
|
|
106
|
+
},
|
|
107
|
+
() => {
|
|
108
|
+
entry.found = false;
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
cache.set(key, entry);
|
|
112
|
+
return entry.promise;
|
|
81
113
|
}
|
|
82
114
|
|
|
83
115
|
async #fileExists(p: string): Promise<boolean> {
|
|
84
116
|
const absPath = path.resolve(p);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return exists;
|
|
92
|
-
});
|
|
93
|
-
this.#fileExistsCache.set(absPath, cached);
|
|
94
|
-
}
|
|
95
|
-
return cached;
|
|
117
|
+
return await this.#lookup(
|
|
118
|
+
this.#fileExistsCache,
|
|
119
|
+
absPath,
|
|
120
|
+
() => Bun.file(absPath).exists(),
|
|
121
|
+
(exists) => exists,
|
|
122
|
+
);
|
|
96
123
|
}
|
|
97
124
|
|
|
98
125
|
#facts(file: string): Promise<FileFacts | null> {
|
|
@@ -104,6 +131,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
104
131
|
.text()
|
|
105
132
|
.catch(() => null);
|
|
106
133
|
if (content === null) return null;
|
|
134
|
+
this.#readFiles.add(absPath);
|
|
107
135
|
if (USE_CLIENT_RE.test(content)) return { isClientEntry: true, imports: [] };
|
|
108
136
|
return { isClientEntry: false, imports: this.#scanImports(await this.#rewrite(content)) };
|
|
109
137
|
})();
|
|
@@ -131,58 +159,71 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
131
159
|
|
|
132
160
|
async #resolveFileCandidate(absPathNoExt: string): Promise<string | null> {
|
|
133
161
|
const cacheKey = path.resolve(absPathNoExt);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
162
|
+
return await this.#lookup(
|
|
163
|
+
this.#resolvedFileCache,
|
|
164
|
+
cacheKey,
|
|
165
|
+
async () => {
|
|
166
|
+
if (await this.#fileExists(cacheKey)) return cacheKey;
|
|
167
|
+
for (const ext of SOURCE_EXTS) {
|
|
168
|
+
const f = `${cacheKey}${ext}`;
|
|
169
|
+
if (await this.#fileExists(f)) return f;
|
|
170
|
+
}
|
|
171
|
+
for (const ext of SOURCE_EXTS) {
|
|
172
|
+
const f = path.join(cacheKey, `index${ext}`);
|
|
173
|
+
if (await this.#fileExists(f)) return f;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
},
|
|
177
|
+
(file) => file !== null,
|
|
178
|
+
);
|
|
151
179
|
}
|
|
152
180
|
|
|
153
181
|
async #resolveSpecifier(spec: string, importerDir: string): Promise<string | null> {
|
|
154
182
|
const cacheKey = `${importerDir}\0${spec}`;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return cached;
|
|
183
|
+
return await this.#lookup(
|
|
184
|
+
this.#resolvedSpecifierCache,
|
|
185
|
+
cacheKey,
|
|
186
|
+
async () => {
|
|
187
|
+
if (spec.startsWith(".") || spec.startsWith("/")) {
|
|
188
|
+
const abs = spec.startsWith("/") ? spec : path.resolve(importerDir, spec);
|
|
189
|
+
return await this.#resolveFileCandidate(abs);
|
|
190
|
+
}
|
|
191
|
+
const pkg = await this.#resolvePackage(spec);
|
|
192
|
+
return pkg ? pkg.entryFile : null;
|
|
193
|
+
},
|
|
194
|
+
(file) => file !== null,
|
|
195
|
+
);
|
|
169
196
|
}
|
|
170
197
|
|
|
171
|
-
async #discoverFromFile(file: string,
|
|
198
|
+
async #discoverFromFile(file: string, walk: DiscoveryWalk): Promise<Set<string>> {
|
|
172
199
|
const epoch = this.#epoch;
|
|
173
200
|
const absPath = path.resolve(file);
|
|
174
201
|
const cached = this.#reachableEntriesCache.get(absPath);
|
|
175
202
|
if (cached) return new Set(cached);
|
|
176
|
-
|
|
203
|
+
const memo = walk.memo.get(absPath);
|
|
204
|
+
if (memo) {
|
|
205
|
+
//? Reused from another path: whatever this path is walking now inherits its gap and stays out of the cache.
|
|
206
|
+
if (walk.partial.has(absPath)) for (const onPath of walk.visiting) walk.partial.add(onPath);
|
|
207
|
+
return new Set(memo);
|
|
208
|
+
}
|
|
209
|
+
if (walk.visiting.has(absPath)) {
|
|
210
|
+
let below = false;
|
|
211
|
+
for (const onPath of walk.visiting) {
|
|
212
|
+
if (below) walk.partial.add(onPath);
|
|
213
|
+
else below = onPath === absPath;
|
|
214
|
+
}
|
|
215
|
+
return new Set();
|
|
216
|
+
}
|
|
217
|
+
if (shouldSkipNodeModule(absPath)) return new Set();
|
|
177
218
|
|
|
178
|
-
visiting.add(absPath);
|
|
219
|
+
walk.visiting.add(absPath);
|
|
179
220
|
const entries = new Set<string>();
|
|
180
221
|
const facts = await this.#facts(absPath);
|
|
181
|
-
if (!facts) return this.#finishDiscovery(absPath,
|
|
222
|
+
if (!facts) return this.#finishDiscovery(absPath, walk, entries, epoch);
|
|
182
223
|
|
|
183
224
|
if (facts.isClientEntry) {
|
|
184
225
|
entries.add(absPath);
|
|
185
|
-
return this.#finishDiscovery(absPath,
|
|
226
|
+
return this.#finishDiscovery(absPath, walk, entries, epoch);
|
|
186
227
|
}
|
|
187
228
|
|
|
188
229
|
const importerDir = path.dirname(absPath);
|
|
@@ -193,15 +234,16 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
193
234
|
const resolved = await this.#resolveSpecifier(spec, importerDir);
|
|
194
235
|
if (!resolved) continue;
|
|
195
236
|
if (shouldSkipNodeModule(resolved)) continue;
|
|
196
|
-
for (const entry of await this.#discoverFromFile(resolved,
|
|
237
|
+
for (const entry of await this.#discoverFromFile(resolved, walk)) entries.add(entry);
|
|
197
238
|
}
|
|
198
239
|
|
|
199
|
-
return this.#finishDiscovery(absPath,
|
|
240
|
+
return this.#finishDiscovery(absPath, walk, entries, epoch);
|
|
200
241
|
}
|
|
201
242
|
|
|
202
|
-
#finishDiscovery(absPath: string,
|
|
203
|
-
visiting.delete(absPath);
|
|
204
|
-
|
|
243
|
+
#finishDiscovery(absPath: string, walk: DiscoveryWalk, entries: Set<string>, epoch: number): Set<string> {
|
|
244
|
+
walk.visiting.delete(absPath);
|
|
245
|
+
walk.memo.set(absPath, entries);
|
|
246
|
+
if (epoch === this.#epoch && !walk.partial.has(absPath)) this.#reachableEntriesCache.set(absPath, entries);
|
|
205
247
|
return new Set(entries);
|
|
206
248
|
}
|
|
207
249
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { App } from "../commandDecorators";
|
|
6
|
+
import { CsrDevModuleCompiler } from "./csrDevModuleCompiler";
|
|
7
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
8
|
+
import { CsrDevResolver } from "./csrDevResolver";
|
|
9
|
+
import type { CsrDevContext } from "./csrDevTypes";
|
|
10
|
+
|
|
11
|
+
const context: CsrDevContext = {
|
|
12
|
+
pageEntries: [],
|
|
13
|
+
basePaths: [],
|
|
14
|
+
htmlBasePaths: [],
|
|
15
|
+
define: {},
|
|
16
|
+
optimizeImports: [],
|
|
17
|
+
configKey: "key",
|
|
18
|
+
refreshFile: null,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//? esbuild's own interop helpers, as a pre-bundled chunk ships them (@mermaid-js/parser's dist/chunks).
|
|
22
|
+
const prebundled = `var __create = Object.create;
|
|
23
|
+
var __defProp = Object.defineProperty;
|
|
24
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
25
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
26
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
27
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
28
|
+
var __copyProps = (to, from, except, desc) => {
|
|
29
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
30
|
+
for (let key of __getOwnPropNames(from))
|
|
31
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
32
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
33
|
+
}
|
|
34
|
+
return to;
|
|
35
|
+
};
|
|
36
|
+
var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
|
|
37
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod));
|
|
38
|
+
var parts = {};
|
|
39
|
+
__reExport(parts, { a: 1 });
|
|
40
|
+
var wrapped = __toESM({ b: 2 });
|
|
41
|
+
export { parts, wrapped };
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
describe("CsrDevModuleCompiler", () => {
|
|
45
|
+
let root: string;
|
|
46
|
+
const file = (relative: string) => path.join(root, relative);
|
|
47
|
+
const compile = async (relative: string) => {
|
|
48
|
+
const paths = new CsrDevPaths(root);
|
|
49
|
+
const compiler = new CsrDevModuleCompiler({
|
|
50
|
+
app: { verbose: () => undefined } as unknown as App,
|
|
51
|
+
paths,
|
|
52
|
+
resolver: new CsrDevResolver({ paths, context, entryFiles: [] }),
|
|
53
|
+
context,
|
|
54
|
+
outDir: file("out"),
|
|
55
|
+
routePrefix: "/_akan/registry/",
|
|
56
|
+
});
|
|
57
|
+
return await compiler.compile([file(relative)], new Set());
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
root = CsrDevPaths.realpath(await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-compiler-")));
|
|
62
|
+
await Bun.write(file("node_modules/prebundled/chunk.mjs"), prebundled);
|
|
63
|
+
await Bun.write(file("node_modules/bare-reexport/index.mjs"), "export const parts = __reExport({}, { a: 1 });\n");
|
|
64
|
+
await Bun.write(file("node_modules/bare-toesm/index.mjs"), "export const wrapped = __toESM({ b: 2 });\n");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(async () => {
|
|
68
|
+
await rm(root, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a pre-bundled file keeps its own __reExport and __toESM, which Bun leaves in place of its own", async () => {
|
|
72
|
+
const { modules } = await compile("node_modules/prebundled/chunk.mjs");
|
|
73
|
+
const [module] = modules;
|
|
74
|
+
expect(module?.id).toBe("node_modules/prebundled/chunk.mjs");
|
|
75
|
+
const factory = module?.factory ?? "";
|
|
76
|
+
expect(factory).toContain("var __reExport = (target, mod, secondTarget) => (");
|
|
77
|
+
expect(factory).toContain("var __toESM = (mod, isNodeMode, target) => (");
|
|
78
|
+
expect(factory).not.toContain("__akan.reExport");
|
|
79
|
+
expect(factory).not.toContain("__akan.toESM");
|
|
80
|
+
expect(() => new Function(`return (${factory});`)).not.toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test.each([
|
|
84
|
+
["__reExport", "node_modules/bare-reexport/index.mjs"],
|
|
85
|
+
["__toESM", "node_modules/bare-toesm/index.mjs"],
|
|
86
|
+
])("a %s call with no definition anywhere still stops the build", async (name, relative) => {
|
|
87
|
+
await expect(compile(relative)).rejects.toThrow(`Bun's ${name} helper no longer reads`);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -214,7 +214,7 @@ export class CsrDevModuleCompiler {
|
|
|
214
214
|
const target = this.#resolver.resolve(
|
|
215
215
|
importer,
|
|
216
216
|
args.path,
|
|
217
|
-
args.kind === "require-call" ? "require" : "import",
|
|
217
|
+
args.kind === "require-call" || args.kind === "require-resolve" ? "require" : "import",
|
|
218
218
|
);
|
|
219
219
|
if (target === CsrDevResolver.inline) return undefined;
|
|
220
220
|
if (target === null) {
|
|
@@ -262,11 +262,15 @@ export class CsrDevModuleCompiler {
|
|
|
262
262
|
static #factory(id: string, code: string): { factory: string; helpers: CsrDevSharedHelpers | null } {
|
|
263
263
|
const prefix = CsrDevPaths.modulePrefix;
|
|
264
264
|
let patched = code.replaceAll(`import("${prefix}`, `__akanImport("${prefix}`).replace(/^\/\/# debugId=.*$/m, "");
|
|
265
|
+
//? A file esbuild already bundled (mermaid's parser chunks) defines its own helper under the same name, below Bun's
|
|
266
|
+
//? preamble, and Bun then emits none. Only a copy there is exempt: one in the preamble is Bun's own, changed.
|
|
267
|
+
const marker = patched.search(/^\/\/ /m);
|
|
268
|
+
const body = marker < 0 ? "" : patched.slice(marker);
|
|
265
269
|
for (const [name, params, method] of CsrDevModuleCompiler.#helperPatches) {
|
|
266
270
|
const definition = `var ${name} = ${params} => {`;
|
|
267
271
|
if (patched.includes(definition))
|
|
268
272
|
patched = patched.replace(definition, `var ${name} = __akan.${method}, __bun${name} = ${params} => {`);
|
|
269
|
-
else if (patched.includes(`${name}(`))
|
|
273
|
+
else if (patched.includes(`${name}(`) && !new RegExp(`\\b(?:var|let|const|function)\\s+${name}\\b`).test(body))
|
|
270
274
|
throw new Error(
|
|
271
275
|
`[csr-dev] ${id}: Bun's ${name} helper no longer reads "${definition}"; CsrDevModuleCompiler must follow`,
|
|
272
276
|
);
|
|
@@ -129,8 +129,10 @@ describe("CsrDevPatcher", () => {
|
|
|
129
129
|
async writeEntries() {
|
|
130
130
|
return { files: { "": this.#entry }, changed: [] };
|
|
131
131
|
}
|
|
132
|
+
//? Roots beside the entries, as the SSR registry takes every client entry a route reaches.
|
|
133
|
+
extraRoots: string[] = [];
|
|
132
134
|
protected async rootFiles(entryFiles: string[]) {
|
|
133
|
-
return entryFiles;
|
|
135
|
+
return [...entryFiles, ...this.extraRoots];
|
|
134
136
|
}
|
|
135
137
|
override async metadataFingerprint() {
|
|
136
138
|
return "same";
|
|
@@ -143,12 +145,13 @@ describe("CsrDevPatcher", () => {
|
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
const movedRegistry = async (files: Record<string, string
|
|
148
|
+
const movedRegistry = async (files: Record<string, string>, { roots = [] }: { roots?: string[] } = {}) => {
|
|
147
149
|
const root = await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-move-"));
|
|
148
150
|
dirs.push(root);
|
|
149
151
|
const file = (relative: string) => path.join(CsrDevPaths.realpath(root), "apps/demo", relative);
|
|
150
152
|
for (const [relative, text] of Object.entries(files)) await Bun.write(file(relative), text);
|
|
151
153
|
const bundler = new MovedRegistry(root, file("entry.ts"));
|
|
154
|
+
bundler.extraRoots = roots.map((relative) => file(relative));
|
|
152
155
|
expect(await bundler.update([])).toMatchObject({ generation: 1, reload: true, first: true });
|
|
153
156
|
const patcher = new CsrDevPatcher(bundler, { resident: true });
|
|
154
157
|
expect(await patcher.update([])).toEqual({ kind: "unchanged" });
|
|
@@ -286,6 +289,235 @@ describe("CsrDevPatcher", () => {
|
|
|
286
289
|
expect(changedIds(await patcher.update([file("ui/Card.tsx")]))).toEqual(["apps/demo/ui/Card.tsx"]);
|
|
287
290
|
});
|
|
288
291
|
|
|
292
|
+
test("a broken module in the graph whose last importer drops it leaves the graph instead of failing every save", async () => {
|
|
293
|
+
const { bundler, patcher, file, changedIds } = await movedRegistry({
|
|
294
|
+
"ui/X.tsx": "export const x = 1;\n",
|
|
295
|
+
"ui/C.tsx": 'export const C = () => "c1";\n',
|
|
296
|
+
"ui/I.tsx": 'import { x } from "./X";\nexport const I = () => x;\n',
|
|
297
|
+
"entry.ts": 'import { C } from "./ui/C";\nimport { I } from "./ui/I";\nexport const run = [C, I];\n',
|
|
298
|
+
});
|
|
299
|
+
await Bun.write(file("ui/X.tsx"), "export const x = ;\n");
|
|
300
|
+
await expect(patcher.update([file("ui/X.tsx")])).rejects.toThrow();
|
|
301
|
+
await Bun.write(file("ui/I.tsx"), 'import { x } from "./X";\nexport const I = () => x + 1;\n');
|
|
302
|
+
await expect(patcher.update([file("ui/I.tsx")])).rejects.toThrow();
|
|
303
|
+
|
|
304
|
+
await Bun.write(file("ui/I.tsx"), "export const I = () => 0;\n");
|
|
305
|
+
expect(changedIds(await patcher.update([file("ui/I.tsx")]))).toEqual(["apps/demo/ui/I.tsx"]);
|
|
306
|
+
expect(((await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph).pending).toEqual([]);
|
|
307
|
+
await Bun.write(file("ui/C.tsx"), 'export const C = () => "c2";\n');
|
|
308
|
+
expect(changedIds(await patcher.update([file("ui/C.tsx")]))).toEqual(["apps/demo/ui/C.tsx"]);
|
|
309
|
+
const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
|
|
310
|
+
expect(graph.modules["apps/demo/ui/X.tsx"]).toBeUndefined();
|
|
311
|
+
expect(graph.pending).toEqual([]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("a broken module left behind by a detached importer, or in a cycle, leaves with everything unreachable", async () => {
|
|
315
|
+
for (const shape of ["detached", "cycle"] as const) {
|
|
316
|
+
const { bundler, patcher, file, changedIds } = await movedRegistry({
|
|
317
|
+
"ui/X.tsx":
|
|
318
|
+
shape === "cycle" ? 'import { y } from "./Y";\nexport const x = () => y;\n' : "export const x = 1;\n",
|
|
319
|
+
"ui/Y.tsx": 'import { x } from "./X";\nexport const y = () => x;\n',
|
|
320
|
+
"ui/C.tsx": 'export const C = () => "c1";\n',
|
|
321
|
+
"ui/I.tsx": 'import { y } from "./Y";\nexport const I = () => y;\n',
|
|
322
|
+
"entry.ts": 'import { C } from "./ui/C";\nimport { I } from "./ui/I";\nexport const run = [C, I];\n',
|
|
323
|
+
});
|
|
324
|
+
await Bun.write(file("ui/X.tsx"), "export const x = ;\n");
|
|
325
|
+
await expect(patcher.update([file("ui/X.tsx")])).rejects.toThrow();
|
|
326
|
+
await Bun.write(file("ui/I.tsx"), "export const I = () => 0;\n");
|
|
327
|
+
expect(changedIds(await patcher.update([file("ui/I.tsx")]))).toEqual(["apps/demo/ui/I.tsx"]);
|
|
328
|
+
await Bun.write(file("ui/C.tsx"), 'export const C = () => "c2";\n');
|
|
329
|
+
expect(changedIds(await patcher.update([file("ui/C.tsx")]))).toEqual(["apps/demo/ui/C.tsx"]);
|
|
330
|
+
const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
|
|
331
|
+
expect(Object.keys(graph.modules).sort()).toEqual([
|
|
332
|
+
"apps/demo/entry.ts",
|
|
333
|
+
"apps/demo/ui/C.tsx",
|
|
334
|
+
"apps/demo/ui/I.tsx",
|
|
335
|
+
]);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("a broken client entry no page names any more leaves the registry, though its last importer was a server file", async () => {
|
|
340
|
+
const { bundler, patcher, file, changedIds } = await movedRegistry(
|
|
341
|
+
{
|
|
342
|
+
"ui/X.tsx": 'export const X = () => "x";\n',
|
|
343
|
+
"ui/Y.tsx": 'export const Y = () => "y1";\n',
|
|
344
|
+
"entry.ts": "export {};\n",
|
|
345
|
+
},
|
|
346
|
+
{ roots: ["ui/X.tsx", "ui/Y.tsx"] },
|
|
347
|
+
);
|
|
348
|
+
await Bun.write(file("ui/X.tsx"), "export const X = () => ;\n");
|
|
349
|
+
await expect(patcher.update([file("ui/X.tsx")])).rejects.toThrow();
|
|
350
|
+
bundler.extraRoots = [file("ui/Y.tsx")];
|
|
351
|
+
await Bun.write(file("page/P.tsx"), "export default () => null;\n");
|
|
352
|
+
expect(await patcher.update([file("page/P.tsx")])).toEqual({ kind: "unchanged" });
|
|
353
|
+
const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
|
|
354
|
+
expect(graph.modules["apps/demo/ui/X.tsx"]).toBeUndefined();
|
|
355
|
+
expect(graph.pending).toEqual([]);
|
|
356
|
+
await Bun.write(file("ui/Y.tsx"), 'export const Y = () => "y2";\n');
|
|
357
|
+
expect(changedIds(await patcher.update([file("ui/Y.tsx")]))).toEqual(["apps/demo/ui/Y.tsx"]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("a require.resolve call weighs extensions as a require does", async () => {
|
|
361
|
+
const { bundler } = await movedRegistry({
|
|
362
|
+
"ui/pair/x.mjs": "export const x = 1;\n",
|
|
363
|
+
"ui/pair/x.js": "exports.x = 2;\n",
|
|
364
|
+
"ui/R.ts": 'export const where = require.resolve("./pair/x");\n',
|
|
365
|
+
"entry.ts": 'import { where } from "./ui/R";\nexport const run = where;\n',
|
|
366
|
+
});
|
|
367
|
+
const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
|
|
368
|
+
expect(graph.resolution["apps/demo/ui/R.ts"]?.["./pair/x"]).toBe("apps/demo/ui/pair/x.js");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("two routes' checks that both fail keep both roots pending", async () => {
|
|
372
|
+
const { bundler, patcher, file } = await movedRegistry({
|
|
373
|
+
"ui/Card.tsx": 'export const card = () => "v1";\n',
|
|
374
|
+
"ui/E1.tsx": 'import { gone } from "./missing1";\nexport const e1 = () => gone;\n',
|
|
375
|
+
"ui/E4.tsx": 'import { gone } from "./missing4";\nexport const e4 = () => gone;\n',
|
|
376
|
+
"entry.ts": 'import { card } from "./ui/Card";\nexport const run = card;\n',
|
|
377
|
+
});
|
|
378
|
+
bundler.wanted = new Set([file("ui/E1.tsx"), file("ui/E4.tsx")]);
|
|
379
|
+
await expect(patcher.update([], { roots: [file("ui/E1.tsx")], onlyRoots: true })).rejects.toThrow();
|
|
380
|
+
await expect(patcher.update([], { roots: [file("ui/E4.tsx")], onlyRoots: true })).rejects.toThrow();
|
|
381
|
+
const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
|
|
382
|
+
expect(graph.pending.sort()).toEqual(["apps/demo/ui/E1.tsx", "apps/demo/ui/E4.tsx"]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("a route's check that adds its own root leaves another route's failed root pending", async () => {
|
|
386
|
+
const { bundler, patcher, file, changedIds } = await movedRegistry({
|
|
387
|
+
"ui/Card.tsx": 'export const card = () => "v1";\n',
|
|
388
|
+
"ui/E1.tsx": 'import { gone } from "./missingHelper";\nexport const e1 = () => gone;\n',
|
|
389
|
+
"ui/E3.tsx": "export const e3 = 3;\n",
|
|
390
|
+
"entry.ts": 'import { card } from "./ui/Card";\nexport const run = card;\n',
|
|
391
|
+
});
|
|
392
|
+
bundler.wanted = new Set([file("ui/E1.tsx"), file("ui/E3.tsx")]);
|
|
393
|
+
await expect(patcher.update([], { roots: [file("ui/E1.tsx")], onlyRoots: true })).rejects.toThrow();
|
|
394
|
+
await patcher.update([], { roots: [file("ui/E3.tsx")], onlyRoots: true });
|
|
395
|
+
const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
|
|
396
|
+
expect(graph.pending).toEqual(["apps/demo/ui/E1.tsx"]);
|
|
397
|
+
|
|
398
|
+
await Bun.write(file("ui/E1.tsx"), "export const e1 = () => 1;\n");
|
|
399
|
+
expect(changedIds(await patcher.update([file("ui/E1.tsx")]))).toEqual(["apps/demo/ui/E1.tsx"]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("a pending root a worker failed on waits for an edit of its own or of a file its failure named", async () => {
|
|
403
|
+
const { bundler, patcher, file, changedIds } = await movedRegistry({
|
|
404
|
+
"ui/Card.tsx": 'export const card = () => "v1";\n',
|
|
405
|
+
"ui/Other.tsx": 'export const other = () => "o1";\n',
|
|
406
|
+
"ui/Broken.tsx": 'import { gone } from "./missingHelper";\nexport const broken = () => gone;\n',
|
|
407
|
+
"entry.ts":
|
|
408
|
+
'import { card } from "./ui/Card";\nimport { other } from "./ui/Other";\nexport const run = [card, other];\n',
|
|
409
|
+
"../../node_modules/fake-pkg/package.json": '{"name":"fake-pkg","main":"index.js"}',
|
|
410
|
+
"../../node_modules/fake-pkg/index.js": "export const gone = 1;\n",
|
|
411
|
+
});
|
|
412
|
+
bundler.wanted = new Set([file("ui/Broken.tsx")]);
|
|
413
|
+
await expect(patcher.update([], { roots: [file("ui/Broken.tsx")], onlyRoots: true })).rejects.toThrow();
|
|
414
|
+
const broken =
|
|
415
|
+
'import { gone } from "fake-pkg";\nimport { nope } from "./Card";\nexport const broken = () => gone + nope;\n';
|
|
416
|
+
await Bun.write(file("ui/Broken.tsx"), broken);
|
|
417
|
+
expect((await patcher.update([file("ui/Broken.tsx")])).kind).toBe("delegate");
|
|
418
|
+
//? The worker the builder runs next fails in Broken, naming Card: the resident patcher reads the graph it wrote.
|
|
419
|
+
await expect(bundler.update([file("ui/Broken.tsx")])).rejects.toThrow();
|
|
420
|
+
patcher.forget();
|
|
421
|
+
|
|
422
|
+
await Bun.write(file("ui/Other.tsx"), 'export const other = () => "o2";\n');
|
|
423
|
+
expect(changedIds(await patcher.update([file("ui/Other.tsx")]))).toEqual(["apps/demo/ui/Other.tsx"]);
|
|
424
|
+
expect(await patcher.update([], { roots: [file("ui/Broken.tsx")], onlyRoots: true })).toEqual({
|
|
425
|
+
kind: "unchanged",
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await Bun.write(file("ui/Card.tsx"), 'export const card = () => "v2";\nexport const nope = 1;\n');
|
|
429
|
+
expect((await patcher.update([file("ui/Card.tsx")])).kind).toBe("delegate");
|
|
430
|
+
//? Its worker has not run yet: a route's check waits for it rather than handing the root over again.
|
|
431
|
+
expect(await patcher.update([], { roots: [file("ui/Broken.tsx")], onlyRoots: true })).toEqual({
|
|
432
|
+
kind: "unchanged",
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("a root handed to a worker that died before writing its failure is handed over again", async () => {
|
|
437
|
+
const { bundler, patcher, file } = await movedRegistry({
|
|
438
|
+
"ui/Card.tsx": 'export const card = () => "v1";\n',
|
|
439
|
+
"entry.ts": 'import { card } from "./ui/Card";\nexport const run = card;\n',
|
|
440
|
+
"../../node_modules/fake-pkg/package.json": '{"name":"fake-pkg","main":"index.js"}',
|
|
441
|
+
"../../node_modules/fake-pkg/index.js": "export const gone = 1;\n",
|
|
442
|
+
});
|
|
443
|
+
await Bun.write(file("ui/B.tsx"), 'import { gone } from "fake-pkg";\nexport const b = () => gone;\n');
|
|
444
|
+
bundler.wanted = new Set([file("ui/B.tsx")]);
|
|
445
|
+
const check = async () => await patcher.update([], { roots: [file("ui/B.tsx")], onlyRoots: true });
|
|
446
|
+
expect((await check()).kind).toBe("delegate");
|
|
447
|
+
expect(await check()).toEqual({ kind: "unchanged" });
|
|
448
|
+
//? What the builder does when that worker is killed (an OOM) before it writes anything.
|
|
449
|
+
patcher.releaseHandedOver();
|
|
450
|
+
patcher.forget();
|
|
451
|
+
expect((await check()).kind).toBe("delegate");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("a pending root a worker failed on because of another file is tried again once that file is fixed", async () => {
|
|
455
|
+
const { bundler, patcher, file } = await movedRegistry({
|
|
456
|
+
"ui/Card.tsx": 'export const card = () => "v1";\n',
|
|
457
|
+
"ui/C.tsx": 'export const c = () => "c1";\n',
|
|
458
|
+
"entry.ts": 'import { card } from "./ui/Card";\nimport { c } from "./ui/C";\nexport const run = [card, c];\n',
|
|
459
|
+
"../../node_modules/fake-pkg/package.json": '{"name":"fake-pkg","main":"index.js"}',
|
|
460
|
+
"../../node_modules/fake-pkg/index.js": "export const gone = 1;\n",
|
|
461
|
+
});
|
|
462
|
+
await Bun.write(file("ui/B.tsx"), 'import { gone } from "fake-pkg";\nexport const b = () => gone;\n');
|
|
463
|
+
bundler.wanted = new Set([file("ui/B.tsx")]);
|
|
464
|
+
await Bun.write(file("ui/C.tsx"), "export const c = () => ;\n");
|
|
465
|
+
await expect(patcher.update([file("ui/C.tsx")])).rejects.toThrow();
|
|
466
|
+
expect((await patcher.update([], { roots: [file("ui/B.tsx")], onlyRoots: true })).kind).toBe("delegate");
|
|
467
|
+
await expect(bundler.update([], { roots: [file("ui/B.tsx")] })).rejects.toThrow();
|
|
468
|
+
patcher.forget();
|
|
469
|
+
|
|
470
|
+
await Bun.write(file("ui/C.tsx"), 'export const c = () => "c2";\n');
|
|
471
|
+
expect((await patcher.update([file("ui/C.tsx")])).kind).toBe("delegate");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test.skipIf(!foldsCase)(
|
|
475
|
+
"an importer still naming the old spelling compiles with the new one, so app.js defines what it requires",
|
|
476
|
+
async () => {
|
|
477
|
+
const { patcher, file, app, changedIds } = await movedRegistry({
|
|
478
|
+
"ui/card.tsx": 'export const card = () => "v1";\n',
|
|
479
|
+
"ui/Page.tsx": 'import { card } from "./card";\nexport const page = () => card();\n',
|
|
480
|
+
"entry.ts": 'import { page } from "./ui/Page";\nexport const run = page;\n',
|
|
481
|
+
});
|
|
482
|
+
fs.renameSync(file("ui/card.tsx"), file("ui/Card.tsx"));
|
|
483
|
+
await Bun.write(file("ui/Card.tsx"), 'export const card = () => "v2";\n');
|
|
484
|
+
expect(changedIds(await patcher.update([file("ui/Card.tsx")]))).toEqual([
|
|
485
|
+
"apps/demo/ui/Card.tsx",
|
|
486
|
+
"apps/demo/ui/Page.tsx",
|
|
487
|
+
]);
|
|
488
|
+
expect(await app()).toContain('__akan.define("apps/demo/ui/Card.tsx"');
|
|
489
|
+
expect(await app()).not.toContain('__akan.define("apps/demo/ui/card.tsx"');
|
|
490
|
+
await Bun.write(file("ui/Other.tsx"), "export const other = 1;\n");
|
|
491
|
+
expect(await patcher.update([file("ui/Other.tsx")])).toEqual({ kind: "unchanged" });
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
test.skipIf(!foldsCase)(
|
|
496
|
+
"a case-only rename the patcher never saw moves the module once, and later saves leave it alone",
|
|
497
|
+
async () => {
|
|
498
|
+
for (const resident of [false, true]) {
|
|
499
|
+
const { bundler, patcher, file, changedIds } = await movedRegistry({
|
|
500
|
+
"ui/card.tsx": 'export const card = () => "v1";\n',
|
|
501
|
+
"ui/Page.tsx": 'import { card } from "./card";\nexport const page = () => card();\n',
|
|
502
|
+
"entry.ts": 'import { page } from "./ui/Page";\nexport const run = page;\n',
|
|
503
|
+
});
|
|
504
|
+
fs.renameSync(file("ui/card.tsx"), file("ui/Card.tsx"));
|
|
505
|
+
await Bun.write(file("ui/Page.tsx"), 'import { card } from "./Card";\nexport const page = () => card();\n');
|
|
506
|
+
const later = resident ? patcher : new CsrDevPatcher(bundler, { resident: true });
|
|
507
|
+
expect(changedIds(await later.update([file("ui/Page.tsx")]))).toEqual([
|
|
508
|
+
"apps/demo/ui/Card.tsx",
|
|
509
|
+
"apps/demo/ui/Page.tsx",
|
|
510
|
+
]);
|
|
511
|
+
await Bun.write(file("ui/Card.tsx"), 'export const card = () => "v2";\n');
|
|
512
|
+
expect(changedIds(await later.update([file("ui/Card.tsx")]))).toEqual(["apps/demo/ui/Card.tsx"]);
|
|
513
|
+
await Bun.write(file("ui/Other.tsx"), "export const other = 1;\n");
|
|
514
|
+
expect(await later.update([file("ui/Other.tsx")])).toEqual({ kind: "unchanged" });
|
|
515
|
+
const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
|
|
516
|
+
expect(graph.modules["apps/demo/ui/card.tsx"]).toBeUndefined();
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
);
|
|
520
|
+
|
|
289
521
|
test("by default a pending root counts as reached only while an entry or a module imports it", async () => {
|
|
290
522
|
const { bundler, patcher, file, changedIds } = await movedRegistry({
|
|
291
523
|
"ui/Card.tsx": 'export const card = () => "v1";\n',
|