@akanjs/devkit 3.0.0-beta.25 → 3.0.0-beta.27
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/csrE2e/csrE2eHarness.fixture.ts +55 -5
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/frontendBuild/clientEntryDiscovery.test.ts +75 -0
- package/frontendBuild/clientEntryDiscovery.ts +112 -70
- package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
- package/frontendBuild/csrDevArtifactWriter.ts +4 -0
- package/frontendBuild/csrDevBundler.ts +1 -1
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +261 -0
- package/frontendBuild/csrDevModuleCompiler.ts +95 -20
- package/frontendBuild/csrDevPatcher.test.ts +234 -2
- package/frontendBuild/csrDevPatcher.ts +184 -10
- package/frontendBuild/csrDevPaths.ts +32 -11
- package/frontendBuild/csrDevResolver.test.ts +76 -2
- package/frontendBuild/csrDevResolver.ts +141 -12
- package/frontendBuild/csrDevTypes.ts +2 -0
- package/frontendBuild/devRegistryBundler.ts +18 -3
- package/frontendBuild/ssrDevBundler.ts +2 -8
- 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/mobile/nativeApp.test.ts +15 -0
- package/mobile/nativeApp.ts +54 -14
- package/mobile/nativeAppLine.test.ts +62 -0
- package/mobile/nativeAppLine.ts +63 -0
- 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
|
}
|
|
@@ -76,6 +76,14 @@ describe("CsrDevArtifactWriter", () => {
|
|
|
76
76
|
expect(existsSync(path.join(dir, "graph.json"))).toBe(true);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test("a module a package's browser field turns off is written empty, and a Node built-in still throws", async () => {
|
|
80
|
+
const { writer, graph, dir } = await makeWriter();
|
|
81
|
+
graph.modules["a.ts"] = { vendor: false, mtimeMs: 0, hash: "", deps: ["stub:empty:fs", "stub:path"] };
|
|
82
|
+
const vendor = readFileSync(path.join(dir, await writer.writeVendor(graph)), "utf8");
|
|
83
|
+
expect(vendor).toContain('__akan.define("stub:empty:fs", function () {});');
|
|
84
|
+
expect(vendor).toContain("[akan-csr] path is a Node built-in the browser does not have");
|
|
85
|
+
});
|
|
86
|
+
|
|
79
87
|
test("rewrites an app.js a crash left behind the manifest's generation", async () => {
|
|
80
88
|
const { writer, graph, read } = await makeWriter();
|
|
81
89
|
await writer.writeApp(graph, { generation: 5, vendorFile: "v.js" });
|
|
@@ -114,6 +114,10 @@ export class CsrDevArtifactWriter {
|
|
|
114
114
|
const stubs = new Set(Object.values(graph.modules).flatMap((module) => module.deps.filter(CsrDevPaths.isStub)));
|
|
115
115
|
const lines = [await this.#helperDefinitions(graph, ids), ...(await this.#defineLines(ids))];
|
|
116
116
|
for (const stub of [...stubs].sort()) {
|
|
117
|
+
if (stub.startsWith(CsrDevPaths.emptyStubPrefix)) {
|
|
118
|
+
lines.push(`__akan.define(${JSON.stringify(stub)}, function () {});\n`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
117
121
|
const message = `[akan-csr] ${stub.slice(CsrDevPaths.stubPrefix.length)} is a Node built-in the browser does not have`;
|
|
118
122
|
lines.push(
|
|
119
123
|
`__akan.define(${JSON.stringify(stub)}, function () {\n throw new Error(${JSON.stringify(message)});\n});\n`,
|
|
@@ -13,7 +13,7 @@ import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
|
13
13
|
|
|
14
14
|
//* Dev-only CSR as a module registry, under `.akan/artifact/csr-dev`: one entry per basePath boots every page.
|
|
15
15
|
export class CsrDevBundler extends DevRegistryBundler {
|
|
16
|
-
static readonly #formatVersion =
|
|
16
|
+
static readonly #formatVersion = 4;
|
|
17
17
|
readonly reloadsOnEntryChange = true;
|
|
18
18
|
//? Detecting a page's async default parses it with TypeScript: on apps/akan every page each save cost ~100ms.
|
|
19
19
|
readonly #asyncDefaults = new Map<string, { mtimeMs: number; detected: Promise<boolean> }>();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CsrDevFactoryCheck } from "./csrDevFactoryCheck";
|
|
3
|
+
|
|
4
|
+
const factoryOf = (body: string) =>
|
|
5
|
+
`function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {\n${body}\n}`;
|
|
6
|
+
|
|
7
|
+
describe("CsrDevFactoryCheck", () => {
|
|
8
|
+
test("a factory that parses as a classic script has no problem, twice over", () => {
|
|
9
|
+
const factory = factoryOf('var a = require("akan-module:x");\nmodule.exports = { a, label: "import.meta" };');
|
|
10
|
+
expect(CsrDevFactoryCheck.problemOf(factory)).toBeNull();
|
|
11
|
+
expect(CsrDevFactoryCheck.problemOf(factory)).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("an import.meta left in a factory is named with its compiled line", () => {
|
|
15
|
+
const problem = CsrDevFactoryCheck.problemOf(factoryOf("var a = 1;\nvar url = import.meta.url;"));
|
|
16
|
+
expect(problem).toStartWith("import.meta is left in it (compiled line 2), which a classic script cannot hold");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a top-level await left in a factory is named as one", () => {
|
|
20
|
+
const problem = CsrDevFactoryCheck.problemOf(factoryOf("var a = await Promise.resolve(1);"));
|
|
21
|
+
expect(problem).toStartWith("a top-level await is left in it, which a CommonJS factory cannot wait on");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("any other syntax error answers the parser's message, and a broken helper preamble counts too", () => {
|
|
25
|
+
expect(CsrDevFactoryCheck.problemOf(factoryOf("var = ;"))).toBeString();
|
|
26
|
+
expect(CsrDevFactoryCheck.problemOf(factoryOf("module.exports = 1;"), "var { a } = ;\n")).toBeString();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("the stand-in for a module left out throws its id and the reason", () => {
|
|
30
|
+
const thrower = new Function(`return (${CsrDevFactoryCheck.thrower("node_modules/x/index.js", "why")});`)();
|
|
31
|
+
expect(() => thrower()).toThrow("[akan-csr] node_modules/x/index.js was left out of the dev registry: why");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//* A module factory runs inside one classic script with every module written beside it, so a factory that does not
|
|
2
|
+
//* parse as a script drops them all, far from the cause. Each is parsed once, as the page will, before it is written.
|
|
3
|
+
export class CsrDevFactoryCheck {
|
|
4
|
+
static readonly #parsed = new Set<number | bigint>();
|
|
5
|
+
|
|
6
|
+
/** Why the page could not parse `factory` after `helperDefinition`, or null when it can. */
|
|
7
|
+
static problemOf(factory: string, helperDefinition = ""): string | null {
|
|
8
|
+
const key = Bun.hash(`${helperDefinition}\0${factory}`);
|
|
9
|
+
if (CsrDevFactoryCheck.#parsed.has(key)) return null;
|
|
10
|
+
const problem = CsrDevFactoryCheck.#syntaxError(`${helperDefinition}return (${factory});`);
|
|
11
|
+
if (problem === null) {
|
|
12
|
+
CsrDevFactoryCheck.#parsed.add(key);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
if (problem.includes("import.meta")) {
|
|
16
|
+
//? JSC's own line is the caller's, not the factory's; the factory's line N is line N of Bun's output.
|
|
17
|
+
const line = factory.split("\n").findIndex((text) => /(?<![\w$.])import\.meta\b/.test(text));
|
|
18
|
+
return `import.meta is left in it${line > 0 ? ` (compiled line ${line})` : ""}, which a classic script cannot hold: ${problem}`;
|
|
19
|
+
}
|
|
20
|
+
if (CsrDevFactoryCheck.#syntaxError(`${helperDefinition}return (async ${factory});`) === null)
|
|
21
|
+
return `a top-level await is left in it, which a CommonJS factory cannot wait on: ${problem}`;
|
|
22
|
+
return problem;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Stands in for a module left out of the registry: the page runs, and requiring the module says why. */
|
|
26
|
+
static thrower(id: string, problem: string): string {
|
|
27
|
+
const message = `[akan-csr] ${id} was left out of the dev registry: ${problem}`;
|
|
28
|
+
return `function () {\n throw new Error(${JSON.stringify(message)});\n}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static #syntaxError(source: string): string | null {
|
|
32
|
+
try {
|
|
33
|
+
new Function(source);
|
|
34
|
+
return null;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return error instanceof Error ? error.message : String(error);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
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 { CsrDevCompiledModule, 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
|
+
//? The development checks jotai's and zustand's ESM builds ship, as written in jotai/esm/vanilla.mjs.
|
|
45
|
+
const jotai = `let keyCount = 0;
|
|
46
|
+
export function atom(read) {
|
|
47
|
+
const key = \`atom\${++keyCount}\`;
|
|
48
|
+
return {
|
|
49
|
+
read,
|
|
50
|
+
toString() {
|
|
51
|
+
return (import.meta.env ? import.meta.env.MODE : void 0) !== "production" && this.debugLabel ? key + ":" + this.debugLabel : key;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
const zustand = `export const devtools = (fn) => (import.meta.env ? import.meta.env.MODE : void 0) !== "production" ? "dev" : fn;
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
describe("CsrDevModuleCompiler", () => {
|
|
60
|
+
let root: string;
|
|
61
|
+
const origin = "http://localhost:8283";
|
|
62
|
+
const file = (relative: string) => path.join(root, relative);
|
|
63
|
+
const logged: string[] = [];
|
|
64
|
+
const compile = async (relative: string | string[], { prepass = false }: { prepass?: boolean } = {}) => {
|
|
65
|
+
const files = (Array.isArray(relative) ? relative : [relative]).map(file);
|
|
66
|
+
const paths = new CsrDevPaths(root);
|
|
67
|
+
const resolver = new CsrDevResolver({ paths, context, entryFiles: prepass ? files : [] });
|
|
68
|
+
if (prepass) await resolver.prepass();
|
|
69
|
+
const compiler = new CsrDevModuleCompiler({
|
|
70
|
+
app: { verbose: () => undefined, logger: { error: (message: string) => logged.push(message) } } as unknown as App,
|
|
71
|
+
paths,
|
|
72
|
+
resolver,
|
|
73
|
+
context,
|
|
74
|
+
outDir: file("out"),
|
|
75
|
+
routePrefix: "/_akan/registry/",
|
|
76
|
+
});
|
|
77
|
+
return { resolver, ...(await compiler.compile(files, new Set())) };
|
|
78
|
+
};
|
|
79
|
+
//? As the page runs a factory: a classic script whose only globals are the runtime and the window.
|
|
80
|
+
const run = (module: CsrDevCompiledModule | undefined) => {
|
|
81
|
+
const helpers = new Map<string, Record<string, unknown>>();
|
|
82
|
+
const akan = {
|
|
83
|
+
defineHelpers: (hash: string, define: () => Record<string, unknown>) => helpers.set(hash, define()),
|
|
84
|
+
helpers: (hash: string) => helpers.get(hash),
|
|
85
|
+
};
|
|
86
|
+
if (module?.helpers) new Function("__akan", module.helpers.definition)(akan);
|
|
87
|
+
const factory = new Function("__akan", "self", `return (${module?.factory});`)(akan, {
|
|
88
|
+
location: { href: `${origin}/en/dashboard` },
|
|
89
|
+
});
|
|
90
|
+
const record = { exports: {} as Record<string, unknown> };
|
|
91
|
+
factory.call(
|
|
92
|
+
record.exports,
|
|
93
|
+
() => ({}),
|
|
94
|
+
record,
|
|
95
|
+
record.exports,
|
|
96
|
+
() => undefined,
|
|
97
|
+
() => (type: unknown) => type,
|
|
98
|
+
async () => ({}),
|
|
99
|
+
);
|
|
100
|
+
return record.exports;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
beforeAll(async () => {
|
|
104
|
+
root = CsrDevPaths.realpath(await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-compiler-")));
|
|
105
|
+
await Bun.write(file("node_modules/prebundled/chunk.mjs"), prebundled);
|
|
106
|
+
await Bun.write(file("node_modules/bare-reexport/index.mjs"), "export const parts = __reExport({}, { a: 1 });\n");
|
|
107
|
+
await Bun.write(file("node_modules/bare-toesm/index.mjs"), "export const wrapped = __toESM({ b: 2 });\n");
|
|
108
|
+
await Bun.write(file("node_modules/jotai/esm/vanilla.mjs"), jotai);
|
|
109
|
+
await Bun.write(file("node_modules/zustand/esm/middleware.mjs"), zustand);
|
|
110
|
+
await Bun.write(
|
|
111
|
+
file("apps/demo/ui/meta.ts"),
|
|
112
|
+
[
|
|
113
|
+
"export const url = import.meta.url;",
|
|
114
|
+
"export const env = import.meta.env;",
|
|
115
|
+
'export const asset = new URL("./x.wasm", import.meta.url).href;',
|
|
116
|
+
'export const label = "import.meta.env";',
|
|
117
|
+
"",
|
|
118
|
+
].join("\n"),
|
|
119
|
+
);
|
|
120
|
+
await Bun.write(file("node_modules/with-bin/index.mjs"), "#!/usr/bin/env node\nexport const a = 1;\n");
|
|
121
|
+
await Bun.write(file("node_modules/awaits/index.mjs"), "export const a = await Promise.resolve(1);\n");
|
|
122
|
+
await Bun.write(file("apps/demo/ui/awaits.ts"), "export const a = await Promise.resolve(1);\n");
|
|
123
|
+
await Bun.write(
|
|
124
|
+
file("node_modules/barrel/package.json"),
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
name: "barrel",
|
|
127
|
+
type: "module",
|
|
128
|
+
main: "index.js",
|
|
129
|
+
sideEffects: false,
|
|
130
|
+
imports: {
|
|
131
|
+
"#where": { node: "./where.node.js", default: "./where.browser.js" },
|
|
132
|
+
"#other": { node: "./other.node.js", default: "./other.browser.js" },
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
await Bun.write(
|
|
137
|
+
file("node_modules/barrel/index.js"),
|
|
138
|
+
'export { used } from "./used.js";\nexport { unused } from "./unused.js";\nexport { other } from "#other";\n',
|
|
139
|
+
);
|
|
140
|
+
await Bun.write(file("node_modules/barrel/used.js"), "export const used = 1;\n");
|
|
141
|
+
await Bun.write(
|
|
142
|
+
file("node_modules/barrel/unused.js"),
|
|
143
|
+
'import { where } from "#where";\nexport const unused = where;\n',
|
|
144
|
+
);
|
|
145
|
+
await Bun.write(
|
|
146
|
+
file("node_modules/barrel/where.node.js"),
|
|
147
|
+
'import path from "node:path";\nexport const where = path.sep;\n',
|
|
148
|
+
);
|
|
149
|
+
await Bun.write(file("node_modules/barrel/where.browser.js"), 'export const where = "browser";\n');
|
|
150
|
+
await Bun.write(
|
|
151
|
+
file("node_modules/barrel/other.node.js"),
|
|
152
|
+
'import path from "node:path";\nexport const other = path.sep;\n',
|
|
153
|
+
);
|
|
154
|
+
await Bun.write(file("node_modules/barrel/other.browser.js"), 'export const other = "browser";\n');
|
|
155
|
+
await Bun.write(file("apps/demo/entry.ts"), 'import { used } from "barrel";\nexport const run = used;\n');
|
|
156
|
+
await Bun.write(
|
|
157
|
+
file("node_modules/inspectish/package.json"),
|
|
158
|
+
JSON.stringify({ name: "inspectish", main: "index.js", browser: { "./util.inspect.js": false, fs: false } }),
|
|
159
|
+
);
|
|
160
|
+
await Bun.write(
|
|
161
|
+
file("node_modules/inspectish/index.js"),
|
|
162
|
+
'var inspect = require("./util.inspect");\nvar fs = require("fs");\nmodule.exports = { inspect, fs };\n',
|
|
163
|
+
);
|
|
164
|
+
await Bun.write(file("node_modules/inspectish/util.inspect.js"), 'module.exports = require("util").inspect;\n');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
afterAll(async () => {
|
|
168
|
+
await rm(root, { recursive: true, force: true });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a pre-bundled file keeps its own __reExport and __toESM, which Bun leaves in place of its own", async () => {
|
|
172
|
+
const { modules } = await compile("node_modules/prebundled/chunk.mjs");
|
|
173
|
+
const [module] = modules;
|
|
174
|
+
expect(module?.id).toBe("node_modules/prebundled/chunk.mjs");
|
|
175
|
+
const factory = module?.factory ?? "";
|
|
176
|
+
expect(factory).toContain("var __reExport = (target, mod, secondTarget) => (");
|
|
177
|
+
expect(factory).toContain("var __toESM = (mod, isNodeMode, target) => (");
|
|
178
|
+
expect(factory).not.toContain("__akan.reExport");
|
|
179
|
+
expect(factory).not.toContain("__akan.toESM");
|
|
180
|
+
expect(factory).not.toContain("__akanMeta");
|
|
181
|
+
expect(run(module)).toMatchObject({ parts: { a: 1 }, wrapped: { b: 2 } });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test.each([
|
|
185
|
+
["__reExport", "node_modules/bare-reexport/index.mjs"],
|
|
186
|
+
["__toESM", "node_modules/bare-toesm/index.mjs"],
|
|
187
|
+
])("a %s call with no definition anywhere still stops the build", async (name, relative) => {
|
|
188
|
+
await expect(compile(relative)).rejects.toThrow(`Bun's ${name} helper no longer reads`);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("jotai's and zustand's import.meta.env checks run as a classic script and take the ESM bundle's branch", async () => {
|
|
192
|
+
const [{ modules: jotaiModules }, { modules: zustandModules }] = await Promise.all([
|
|
193
|
+
compile("node_modules/jotai/esm/vanilla.mjs"),
|
|
194
|
+
compile("node_modules/zustand/esm/middleware.mjs"),
|
|
195
|
+
]);
|
|
196
|
+
const [jotaiModule] = jotaiModules;
|
|
197
|
+
const [zustandModule] = zustandModules;
|
|
198
|
+
expect(jotaiModule?.factory).not.toContain("import.meta");
|
|
199
|
+
expect(zustandModule?.factory).not.toContain("import.meta");
|
|
200
|
+
const labeled = Object.assign((run(jotaiModule).atom as (read: unknown) => object)(null), { debugLabel: "count" });
|
|
201
|
+
expect(String(labeled)).toBe("atom1:count");
|
|
202
|
+
expect((run(zustandModule).devtools as (fn: unknown) => unknown)("prod")).toBe("dev");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("an app module reads import.meta as a browser module does, on its own path and with no env", async () => {
|
|
206
|
+
const [module] = (await compile("apps/demo/ui/meta.ts")).modules;
|
|
207
|
+
expect(module?.factory).not.toContain("file://");
|
|
208
|
+
expect(run(module)).toEqual({
|
|
209
|
+
url: `${origin}/apps/demo/ui/meta.ts`,
|
|
210
|
+
env: undefined,
|
|
211
|
+
asset: `${origin}/apps/demo/ui/x.wasm`,
|
|
212
|
+
label: "import.meta.env",
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a file or re-export the browser build tree-shook away resolves with the browser condition", async () => {
|
|
217
|
+
const { modules, resolver } = await compile("apps/demo/entry.ts", { prepass: true });
|
|
218
|
+
const ids = modules.map((module) => module.id);
|
|
219
|
+
expect(ids).toContain("node_modules/barrel/unused.js");
|
|
220
|
+
expect(ids).toContain("node_modules/barrel/where.browser.js");
|
|
221
|
+
expect(ids).not.toContain("node_modules/barrel/where.node.js");
|
|
222
|
+
expect(ids).toContain("node_modules/barrel/other.browser.js");
|
|
223
|
+
expect(ids).not.toContain("node_modules/barrel/other.node.js");
|
|
224
|
+
expect([...resolver.fallbacks]).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("what a package's browser field maps to false is an empty stub, not the file or the Node built-in", async () => {
|
|
228
|
+
const { modules } = await compile("node_modules/inspectish/index.js");
|
|
229
|
+
expect(modules.map((module) => module.id)).toEqual(["node_modules/inspectish/index.js"]);
|
|
230
|
+
expect(modules[0]?.deps.sort()).toEqual(["stub:empty:fs", "stub:empty:node_modules/inspectish/util.inspect.js"]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a package's #! line is a comment in its factory, so the package loads", async () => {
|
|
234
|
+
const [module] = (await compile("node_modules/with-bin/index.mjs")).modules;
|
|
235
|
+
expect(module?.factory).not.toContain("#!");
|
|
236
|
+
expect(run(module)).toEqual({ a: 1 });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a package file Bun cannot build as CommonJS is left out, named in the log, and the rest still builds", async () => {
|
|
240
|
+
logged.length = 0;
|
|
241
|
+
const { modules } = await compile(["node_modules/awaits/index.mjs", "node_modules/zustand/esm/middleware.mjs"]);
|
|
242
|
+
const left = modules.find((module) => module.id === "node_modules/awaits/index.mjs");
|
|
243
|
+
expect(modules.map((module) => module.id)).toContain("node_modules/zustand/esm/middleware.mjs");
|
|
244
|
+
expect(left?.deps).toEqual([]);
|
|
245
|
+
expect(() => run(left)).toThrow(
|
|
246
|
+
"[akan-csr] node_modules/awaits/index.mjs was left out of the dev registry: a top-level await",
|
|
247
|
+
);
|
|
248
|
+
expect(logged).toEqual([
|
|
249
|
+
expect.stringContaining(
|
|
250
|
+
"[csr-dev] node_modules/awaits/index.mjs is left out of the dev registry: a top-level await",
|
|
251
|
+
),
|
|
252
|
+
]);
|
|
253
|
+
expect(logged[0]).toContain("(node_modules/awaits/index.mjs:1)");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("app code Bun cannot build as CommonJS still fails the build with Bun's messages", async () => {
|
|
257
|
+
const failure = await compile("apps/demo/ui/awaits.ts").catch((error: unknown) => error);
|
|
258
|
+
expect(failure).toBeInstanceOf(AggregateError);
|
|
259
|
+
expect(String((failure as AggregateError).errors[0])).toContain('"await" can only be used');
|
|
260
|
+
});
|
|
261
|
+
});
|