@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.
Files changed (34) hide show
  1. package/DEV_RUNTIME_KNOBS.md +9 -6
  2. package/akanApp/akanApp.host.ts +79 -22
  3. package/akanApp/devHostPolicy.test.ts +56 -11
  4. package/akanApp/devHostPolicy.ts +10 -10
  5. package/csrE2e/csrE2eHarness.fixture.ts +55 -5
  6. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  7. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  8. package/frontendBuild/clientEntryDiscovery.test.ts +75 -0
  9. package/frontendBuild/clientEntryDiscovery.ts +112 -70
  10. package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
  11. package/frontendBuild/csrDevArtifactWriter.ts +4 -0
  12. package/frontendBuild/csrDevBundler.ts +1 -1
  13. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  14. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  15. package/frontendBuild/csrDevModuleCompiler.test.ts +261 -0
  16. package/frontendBuild/csrDevModuleCompiler.ts +95 -20
  17. package/frontendBuild/csrDevPatcher.test.ts +234 -2
  18. package/frontendBuild/csrDevPatcher.ts +184 -10
  19. package/frontendBuild/csrDevPaths.ts +32 -11
  20. package/frontendBuild/csrDevResolver.test.ts +76 -2
  21. package/frontendBuild/csrDevResolver.ts +141 -12
  22. package/frontendBuild/csrDevTypes.ts +2 -0
  23. package/frontendBuild/devRegistryBundler.ts +18 -3
  24. package/frontendBuild/ssrDevBundler.ts +2 -8
  25. package/incrementalBuilder/incrementalBuilder.host.test.ts +34 -0
  26. package/incrementalBuilder/incrementalBuilder.host.ts +18 -3
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +62 -1
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +58 -24
  29. package/integration/devStability.integration.test.ts +9 -2
  30. package/mobile/nativeApp.test.ts +15 -0
  31. package/mobile/nativeApp.ts +54 -14
  32. package/mobile/nativeAppLine.test.ts +62 -0
  33. package/mobile/nativeAppLine.ts +63 -0
  34. 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, Promise<boolean>>();
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, Promise<string | null>>();
32
- #resolvedSpecifierCache = new Map<string, Promise<string | null>>();
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
- #missingFiles = new Set<string>();
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
- for (const entry of await this.#discoverFromFile(seed, new Set())) entries.add(entry);
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
- this.#forgetMissing();
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
- // Negatives are keyed by extension-less path or `dir\0specifier`, which a new file's path cannot reach, so all go.
74
- #forgetMissing(): void {
75
- for (const key of this.#missingFiles) this.#fileExistsCache.delete(key);
76
- for (const key of this.#unresolvedPaths) this.#resolvedFileCache.delete(key);
77
- for (const key of this.#unresolvedSpecifiers) this.#resolvedSpecifierCache.delete(key);
78
- this.#missingFiles.clear();
79
- this.#unresolvedPaths.clear();
80
- this.#unresolvedSpecifiers.clear();
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
- let cached = this.#fileExistsCache.get(absPath);
86
- if (!cached) {
87
- cached = Bun.file(absPath)
88
- .exists()
89
- .then((exists) => {
90
- if (!exists) this.#missingFiles.add(absPath);
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
- let cached = this.#resolvedFileCache.get(cacheKey);
135
- if (cached) return cached;
136
- cached = (async () => {
137
- if (await this.#fileExists(cacheKey)) return cacheKey;
138
- for (const ext of SOURCE_EXTS) {
139
- const f = `${cacheKey}${ext}`;
140
- if (await this.#fileExists(f)) return f;
141
- }
142
- for (const ext of SOURCE_EXTS) {
143
- const f = path.join(cacheKey, `index${ext}`);
144
- if (await this.#fileExists(f)) return f;
145
- }
146
- this.#unresolvedPaths.add(cacheKey);
147
- return null;
148
- })();
149
- this.#resolvedFileCache.set(cacheKey, cached);
150
- return cached;
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
- let cached = this.#resolvedSpecifierCache.get(cacheKey);
156
- if (cached) return cached;
157
- cached = (async () => {
158
- if (spec.startsWith(".") || spec.startsWith("/")) {
159
- const abs = spec.startsWith("/") ? spec : path.resolve(importerDir, spec);
160
- return this.#resolveFileCandidate(abs);
161
- }
162
- const pkg = await this.#resolvePackage(spec);
163
- if (pkg) return pkg.entryFile;
164
- this.#unresolvedSpecifiers.add(cacheKey);
165
- return null;
166
- })();
167
- this.#resolvedSpecifierCache.set(cacheKey, cached);
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, visiting: Set<string>): Promise<Set<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
- if (visiting.has(absPath) || shouldSkipNodeModule(absPath)) return new Set();
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, visiting, entries, epoch);
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, visiting, entries, epoch);
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, visiting)) entries.add(entry);
237
+ for (const entry of await this.#discoverFromFile(resolved, walk)) entries.add(entry);
197
238
  }
198
239
 
199
- return this.#finishDiscovery(absPath, visiting, entries, epoch);
240
+ return this.#finishDiscovery(absPath, walk, entries, epoch);
200
241
  }
201
242
 
202
- #finishDiscovery(absPath: string, visiting: Set<string>, entries: Set<string>, epoch: number): Set<string> {
203
- visiting.delete(absPath);
204
- if (epoch === this.#epoch) this.#reachableEntriesCache.set(absPath, entries);
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 = 3;
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
+ });