@akanjs/devkit 3.0.0-beta.26 → 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.
@@ -6,7 +6,7 @@ import type { App } from "../commandDecorators";
6
6
  import { CsrDevModuleCompiler } from "./csrDevModuleCompiler";
7
7
  import { CsrDevPaths } from "./csrDevPaths";
8
8
  import { CsrDevResolver } from "./csrDevResolver";
9
- import type { CsrDevContext } from "./csrDevTypes";
9
+ import type { CsrDevCompiledModule, CsrDevContext } from "./csrDevTypes";
10
10
 
11
11
  const context: CsrDevContext = {
12
12
  pageEntries: [],
@@ -41,20 +41,63 @@ var wrapped = __toESM({ b: 2 });
41
41
  export { parts, wrapped };
42
42
  `;
43
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
+
44
59
  describe("CsrDevModuleCompiler", () => {
45
60
  let root: string;
61
+ const origin = "http://localhost:8283";
46
62
  const file = (relative: string) => path.join(root, relative);
47
- const compile = async (relative: string) => {
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);
48
66
  const paths = new CsrDevPaths(root);
67
+ const resolver = new CsrDevResolver({ paths, context, entryFiles: prepass ? files : [] });
68
+ if (prepass) await resolver.prepass();
49
69
  const compiler = new CsrDevModuleCompiler({
50
- app: { verbose: () => undefined } as unknown as App,
70
+ app: { verbose: () => undefined, logger: { error: (message: string) => logged.push(message) } } as unknown as App,
51
71
  paths,
52
- resolver: new CsrDevResolver({ paths, context, entryFiles: [] }),
72
+ resolver,
53
73
  context,
54
74
  outDir: file("out"),
55
75
  routePrefix: "/_akan/registry/",
56
76
  });
57
- return await compiler.compile([file(relative)], new Set());
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;
58
101
  };
59
102
 
60
103
  beforeAll(async () => {
@@ -62,6 +105,63 @@ describe("CsrDevModuleCompiler", () => {
62
105
  await Bun.write(file("node_modules/prebundled/chunk.mjs"), prebundled);
63
106
  await Bun.write(file("node_modules/bare-reexport/index.mjs"), "export const parts = __reExport({}, { a: 1 });\n");
64
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');
65
165
  });
66
166
 
67
167
  afterAll(async () => {
@@ -77,7 +177,8 @@ describe("CsrDevModuleCompiler", () => {
77
177
  expect(factory).toContain("var __toESM = (mod, isNodeMode, target) => (");
78
178
  expect(factory).not.toContain("__akan.reExport");
79
179
  expect(factory).not.toContain("__akan.toESM");
80
- expect(() => new Function(`return (${factory});`)).not.toThrow();
180
+ expect(factory).not.toContain("__akanMeta");
181
+ expect(run(module)).toMatchObject({ parts: { a: 1 }, wrapped: { b: 2 } });
81
182
  });
82
183
 
83
184
  test.each([
@@ -86,4 +187,75 @@ describe("CsrDevModuleCompiler", () => {
86
187
  ])("a %s call with no definition anywhere still stops the build", async (name, relative) => {
87
188
  await expect(compile(relative)).rejects.toThrow(`Bun's ${name} helper no longer reads`);
88
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
+ });
89
261
  });
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import type { BunPlugin } from "bun";
3
3
  import type { App } from "../commandDecorators";
4
+ import { CsrDevFactoryCheck } from "./csrDevFactoryCheck";
4
5
  import { CsrDevPaths } from "./csrDevPaths";
5
6
  import { CsrDevResolver } from "./csrDevResolver";
6
7
  import type { CsrDevCompiledModule, CsrDevContext, CsrDevSharedHelpers } from "./csrDevTypes";
@@ -82,6 +83,7 @@ export class CsrDevModuleCompiler {
82
83
  while (frontier.length > 0) {
83
84
  const refusedVendors = refuseNewVendor ? frontier.filter((file) => CsrDevPaths.isVendorFile(file)) : [];
84
85
  if (refusedVendors.length > 0) return { modules: [...compiled.values()], refusedVendors, unresolved: [] };
86
+ await this.#resolver.cover(frontier);
85
87
  const round = await this.#compileRound(frontier);
86
88
  if (round.misses.length > 0 && !this.#resolver.prepassDone && refusePrepass)
87
89
  return { modules: [...compiled.values()], refusedVendors: [], unresolved: round.misses };
@@ -138,21 +140,29 @@ export class CsrDevModuleCompiler {
138
140
  ),
139
141
  );
140
142
  const misses: string[] = [];
141
- const result = await Bun.build({
142
- entrypoints: files,
143
- root: this.#paths.root,
144
- target: "browser",
145
- format: "cjs",
146
- reactFastRefresh: !vendor,
147
- sourcemap: vendor ? "none" : "external",
148
- naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
149
- publicPath: this.#routePrefix,
150
- metafile: true,
151
- env: "AKAN_PUBLIC_*",
152
- define: this.#context.define,
153
- optimizeImports: this.#context.optimizeImports,
154
- plugins: [this.#registryPlugin(vendor, misses)],
155
- });
143
+ let result: Awaited<ReturnType<typeof Bun.build>>;
144
+ try {
145
+ result = await Bun.build({
146
+ entrypoints: files,
147
+ root: this.#paths.root,
148
+ target: "browser",
149
+ format: "cjs",
150
+ reactFastRefresh: !vendor,
151
+ sourcemap: vendor ? "none" : "external",
152
+ naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
153
+ publicPath: this.#routePrefix,
154
+ metafile: true,
155
+ env: "AKAN_PUBLIC_*",
156
+ //? A factory runs as a classic script, where `import.meta` is a SyntaxError that takes the whole vendor file
157
+ //? down, and Bun's cjs output keeps `import.meta.env` and spells `import.meta.url` as this disk's `file://` path.
158
+ define: { ...this.#context.define, "import.meta": "__akanMeta" },
159
+ optimizeImports: this.#context.optimizeImports,
160
+ plugins: [this.#registryPlugin(vendor, misses)],
161
+ });
162
+ } catch (error) {
163
+ if (vendor && error instanceof AggregateError) return await this.#withoutFailed(files, error);
164
+ throw error;
165
+ }
156
166
  const { outputs } = result.metafile as unknown as OutputMetafile;
157
167
  const sourceMaps = new Map(
158
168
  result.outputs.filter((artifact) => artifact.kind === "sourcemap").map((artifact) => [artifact.path, artifact]),
@@ -177,12 +187,20 @@ export class CsrDevModuleCompiler {
177
187
  }
178
188
  const code = await artifact.text();
179
189
  const id = this.#paths.idOf(file);
190
+ const built = CsrDevModuleCompiler.#factory(id, code);
191
+ const problem = CsrDevFactoryCheck.problemOf(built.factory, built.helpers?.definition);
192
+ if (problem && !vendor)
193
+ throw new Error(`[csr-dev] ${id}: its factory does not parse as a classic script: ${problem}`);
194
+ if (problem) {
195
+ modules.push(this.#leftOut(file, problem));
196
+ continue;
197
+ }
180
198
  const sourceMap = sourceMaps.get(`${artifact.path}.map`);
181
199
  modules.push({
182
200
  id,
183
201
  file,
184
202
  vendor,
185
- ...CsrDevModuleCompiler.#factory(id, code),
203
+ ...built,
186
204
  sourceMap: sourceMap ? this.#rebaseSourceMap(await sourceMap.text()) : undefined,
187
205
  deps: this.#emittedDeps(code),
188
206
  mtimeMs: mtimes.get(file) ?? CsrDevPaths.mtimeOf(file),
@@ -192,6 +210,50 @@ export class CsrDevModuleCompiler {
192
210
  return { modules, misses, tangled };
193
211
  }
194
212
 
213
+ //? A package file that cannot be a CommonJS factory would fail every module built beside it, and no save fixes a
214
+ //? package: it is left out and named in the log, and the module requiring it gets the reason. App code still fails.
215
+ async #withoutFailed(files: string[], error: AggregateError): Promise<BuildRound> {
216
+ const reasons = new Map<string, string[]>();
217
+ for (const message of error.errors as {
218
+ message?: unknown;
219
+ position?: { file?: unknown; line?: number } | null;
220
+ }[]) {
221
+ const file = message.position?.file;
222
+ if (typeof file !== "string" || !path.isAbsolute(file)) continue;
223
+ const real = CsrDevPaths.realpath(file);
224
+ const at = message.position?.line ? `:${message.position.line}` : "";
225
+ reasons.set(real, [...(reasons.get(real) ?? []), `${String(message.message)} (${this.#paths.idOf(real)}${at})`]);
226
+ }
227
+ const failed = new Set(files.filter((file) => reasons.has(CsrDevPaths.realpath(file))));
228
+ if (failed.size === 0) throw error;
229
+ const rest = files.filter((file) => !failed.has(file));
230
+ const round = rest.length > 0 ? await this.#bunBuild(rest, true) : { modules: [], misses: [], tangled: [] };
231
+ for (const file of failed) {
232
+ const text = (reasons.get(CsrDevPaths.realpath(file)) ?? []).join("; ");
233
+ const awaits = text.includes('"await" can only be used');
234
+ round.modules.push(
235
+ this.#leftOut(file, awaits ? `a top-level await, which a CommonJS factory cannot wait on: ${text}` : text),
236
+ );
237
+ }
238
+ return round;
239
+ }
240
+
241
+ #leftOut(file: string, problem: string): CsrDevCompiledModule {
242
+ const real = CsrDevPaths.realpath(file);
243
+ const id = this.#paths.idOf(real);
244
+ this.#app.logger.error(`[csr-dev] ${id} is left out of the dev registry: ${problem}`);
245
+ return {
246
+ id,
247
+ file: real,
248
+ vendor: true,
249
+ factory: CsrDevFactoryCheck.thrower(id, problem),
250
+ helpers: null,
251
+ deps: [],
252
+ mtimeMs: CsrDevPaths.mtimeOf(real),
253
+ hash: "",
254
+ };
255
+ }
256
+
195
257
  #registryPlugin(vendor: boolean, misses: string[]): BunPlugin {
196
258
  return {
197
259
  name: "akan-csr-registry",
@@ -261,7 +323,11 @@ export class CsrDevModuleCompiler {
261
323
  // Every rewrite keeps the line count, so a module's source map still lines up inside the factory.
262
324
  static #factory(id: string, code: string): { factory: string; helpers: CsrDevSharedHelpers | null } {
263
325
  const prefix = CsrDevPaths.modulePrefix;
264
- let patched = code.replaceAll(`import("${prefix}`, `__akanImport("${prefix}`).replace(/^\/\/# debugId=.*$/m, "");
326
+ //? Bun keeps a package's `#!` line atop its output, where a script inside a function cannot have one.
327
+ let patched = code
328
+ .replace(/^#!/, "//")
329
+ .replaceAll(`import("${prefix}`, `__akanImport("${prefix}`)
330
+ .replace(/^\/\/# debugId=.*$/m, "");
265
331
  //? A file esbuild already bundled (mermaid's parser chunks) defines its own helper under the same name, below Bun's
266
332
  //? preamble, and Bun then emits none. Only a copy there is exempt: one in the preamble is Bun's own, changed.
267
333
  const marker = patched.search(/^\/\/ /m);
@@ -279,8 +345,13 @@ export class CsrDevModuleCompiler {
279
345
  // re-running the store root is the whole update, and bubbling past it would only reach the page modules.
280
346
  if (CsrDevModuleCompiler.#storeRoot.test(id)) patched += "\nmodule.hot.accept();";
281
347
  const shared = CsrDevModuleCompiler.#shareHelpers(patched);
348
+ //? What `import.meta` is in a browser module: a `url` and no `env`, so `import.meta.env` checks (jotai, zustand)
349
+ //? branch as they do in the ESM bundle. On the header line, outside the preamble that modules share.
350
+ const meta = /(?<![\w$.])__akanMeta(?![\w$])/.test(patched)
351
+ ? ` var __akanMeta = { url: new URL(${JSON.stringify(id.startsWith("/") ? id : `/${id}`)}, self.location.href).href };`
352
+ : "";
282
353
  return {
283
- factory: `function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {\n${shared?.code ?? patched}\n}`,
354
+ factory: `function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {${meta}\n${shared?.code ?? patched}\n}`,
284
355
  helpers: shared?.helpers ?? null,
285
356
  };
286
357
  }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  export class CsrDevPaths {
5
5
  static readonly modulePrefix = "akan-module:";
6
6
  static readonly stubPrefix = "stub:";
7
+ static readonly emptyStubPrefix = "stub:empty:";
7
8
  static readonly vendorPrefix = "vendor:";
8
9
  readonly root: string;
9
10
 
@@ -15,6 +15,12 @@ interface PrepassMetafile {
15
15
  inputs: Record<string, { imports: MetafileImport[] }>;
16
16
  }
17
17
 
18
+ interface BrowserMap {
19
+ /** What the `browser` object maps to `false`: an empty module in a browser build. */
20
+ offFiles: Set<string>;
21
+ offNames: Set<string>;
22
+ }
23
+
18
24
  export interface CsrDevResolverOptions {
19
25
  paths: CsrDevPaths;
20
26
  context: CsrDevContext;
@@ -36,7 +42,9 @@ export class CsrDevResolver {
36
42
  readonly #resolution = new Map<string, Map<string, string>>();
37
43
  readonly #fallbacks = new Set<string>();
38
44
  readonly #runtimeResolved = new Map<string, Set<string>>();
39
- readonly #browserMaps = new Map<string, boolean>();
45
+ readonly #browserMaps = new Map<string, BrowserMap | null>();
46
+ /** Files a browser build loaded, so every import they make has its browser resolution recorded. */
47
+ readonly #loaded = new Set<string>();
40
48
  readonly #startedAt: number;
41
49
  #prepassDone = false;
42
50
 
@@ -85,9 +93,52 @@ export class CsrDevResolver {
85
93
  optimizeImports: this.#context.optimizeImports,
86
94
  plugins: [PagesBundleBuilder.createCssStubPlugin()],
87
95
  });
88
- const { inputs } = result.metafile as unknown as PrepassMetafile;
96
+ this.#record((result.metafile as unknown as PrepassMetafile).inputs);
97
+ this.#prepassDone = true;
98
+ }
99
+
100
+ //? The browser build drops a side-effect-free barrel's unused re-exports without loading them, yet the registry
101
+ //? compiles every file an import names. Such a file's own imports are resolved by a browser build of its own, one
102
+ //? level deep, not by the runtime resolver's Node conditions (vfile's `#minpath` would reach `node:path`).
103
+ async cover(files: string[]): Promise<void> {
104
+ if (!this.#prepassDone) return;
105
+ const pending = files.filter((file) => CsrDevPaths.isScript(file) && !this.#loaded.has(file));
106
+ if (pending.length === 0) return;
107
+ for (const file of pending) this.#loaded.add(file);
108
+ const entries = new Set(pending);
109
+ const result = await Bun.build({
110
+ entrypoints: pending,
111
+ target: "browser",
112
+ metafile: true,
113
+ throw: false,
114
+ external: [...(this.#context.externals ?? [])],
115
+ env: "AKAN_PUBLIC_*",
116
+ define: this.#context.define,
117
+ plugins: [
118
+ {
119
+ name: "akan-csr-cover",
120
+ //? What an entry imports is resolved, never parsed: a CommonJS stub, since an empty ESM module fails a named
121
+ //? import ("No matching export") and a failed build hands back no metafile at all.
122
+ setup: (build) => {
123
+ build.onLoad({ filter: /.*/ }, (args) =>
124
+ entries.has(CsrDevPaths.realpath(args.path))
125
+ ? undefined
126
+ : { contents: "module.exports = {};", loader: "js" },
127
+ );
128
+ },
129
+ },
130
+ ],
131
+ });
132
+ //? Left unrecorded when the build fails: those imports keep the runtime resolver's answer, as before.
133
+ if (result.metafile) this.#record((result.metafile as unknown as PrepassMetafile).inputs, entries);
134
+ }
135
+
136
+ #record(inputs: PrepassMetafile["inputs"], only?: Set<string>): void {
89
137
  for (const [input, { imports }] of Object.entries(inputs)) {
90
138
  const importer = CsrDevPaths.realpath(path.resolve(input));
139
+ if (only && !only.has(importer)) continue;
140
+ //? A re-export the build tree-shook keeps no specifier, so its file is resolved again as an entry of its own.
141
+ if (imports.every((record) => record.original)) this.#loaded.add(importer);
91
142
  for (const record of imports) {
92
143
  if (!record.original) continue;
93
144
  if (record.external) {
@@ -101,11 +152,12 @@ export class CsrDevResolver {
101
152
  this.#runtimeResolved.get(importer)?.delete(record.original);
102
153
  }
103
154
  }
104
- this.#prepassDone = true;
105
155
  }
106
156
 
107
157
  resolve(importer: string, specifier: string, kind: "import" | "require" = "import"): string | null {
108
158
  if (this.#isInlineSpecifier(specifier)) return CsrDevResolver.inline;
159
+ const disabled = this.#disabledInBrowser(importer, specifier, kind);
160
+ if (disabled) return disabled;
109
161
  const known = this.#resolution.get(importer)?.get(specifier);
110
162
  const usable = known && this.#stillAnswers(importer, specifier, known, kind) ? known : null;
111
163
  const target = usable ?? this.#resolveUnknown(importer, specifier, kind);
@@ -139,23 +191,51 @@ export class CsrDevResolver {
139
191
  });
140
192
  }
141
193
 
194
+ //? `false` in a package's `browser` object is an empty module to a browser build (object-inspect's
195
+ //? `./util.inspect.js`, which requires `util`). Its record carries no specifier, so the field is read here.
196
+ #disabledInBrowser(importer: string, specifier: string, kind: "import" | "require"): string | null {
197
+ const map = this.#browserMapOf(path.dirname(importer));
198
+ if (!map || (map.offFiles.size === 0 && map.offNames.size === 0)) return null;
199
+ if (!specifier.startsWith(".") && !path.isAbsolute(specifier))
200
+ return map.offNames.has(specifier) ? `${CsrDevPaths.emptyStubPrefix}${specifier}` : null;
201
+ const target = CsrDevPaths.resolveRelative(path.dirname(importer), specifier, kind);
202
+ return target && map.offFiles.has(CsrDevPaths.realpath(target))
203
+ ? `${CsrDevPaths.emptyStubPrefix}${this.#paths.idOf(target)}`
204
+ : null;
205
+ }
206
+
142
207
  #hasBrowserMap(dir: string): boolean {
208
+ return this.#browserMapOf(dir) !== null;
209
+ }
210
+
211
+ #browserMapOf(dir: string): BrowserMap | null {
143
212
  const cached = this.#browserMaps.get(dir);
144
213
  if (cached !== undefined) return cached;
145
214
  const pkgFile = path.join(dir, "package.json");
146
215
  const parent = path.dirname(dir);
147
- let mapped: boolean;
216
+ let map: BrowserMap | null = null;
148
217
  if (fs.existsSync(pkgFile)) {
149
218
  try {
150
219
  const browser = (JSON.parse(fs.readFileSync(pkgFile, "utf8")) as { browser?: unknown }).browser;
151
- mapped = typeof browser === "object" && browser !== null;
220
+ if (typeof browser === "object" && browser !== null) {
221
+ const off = Object.entries(browser)
222
+ .filter(([, target]) => target === false)
223
+ .map(([key]) => key);
224
+ const files = off
225
+ .filter((key) => key.startsWith("."))
226
+ .map((key) => CsrDevPaths.resolveRelative(dir, key))
227
+ .filter((file): file is string => !!file);
228
+ map = {
229
+ offFiles: new Set(files.map((file) => CsrDevPaths.realpath(file))),
230
+ offNames: new Set(off.filter((key) => !key.startsWith("."))),
231
+ };
232
+ }
152
233
  } catch {
153
234
  // An unreadable package.json maps nothing; Bun's build fails on it anyway.
154
- mapped = false;
155
235
  }
156
- } else mapped = parent !== dir && this.#hasBrowserMap(parent);
157
- this.#browserMaps.set(dir, mapped);
158
- return mapped;
236
+ } else if (parent !== dir) map = this.#browserMapOf(parent);
237
+ this.#browserMaps.set(dir, map);
238
+ return map;
159
239
  }
160
240
 
161
241
  /** A module that left the graph takes its records with it. */
@@ -16,7 +16,7 @@ import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
16
16
  //* (the app's client runtime) and every `"use client"` entry; React and the akanjs vendor facets stay the import map's,
17
17
  //* so the page keeps one React and one store registry.
18
18
  export class SsrDevBundler extends DevRegistryBundler {
19
- static readonly #formatVersion = 1;
19
+ static readonly #formatVersion = 2;
20
20
  static readonly bootstrapKey = "";
21
21
  readonly reloadsOnEntryChange = false;
22
22
  #discovery: ClientEntryDiscovery | null = null;
@@ -38,6 +38,21 @@ describe("NativeApp", () => {
38
38
  expect(admin.outDir("android")).toBe(path.join(appDir, ".akan/mobile/admin/native/android"));
39
39
  });
40
40
 
41
+ test("a dev boot that came up says so in one line: platform, device, dev server, time", () => {
42
+ const app = { ...fakeApp(), name: "portal" } as unknown as App;
43
+ const startedAt = performance.now() - 12_400;
44
+ expect(new NativeApp(app, target()).readyLine("macos", { upstream: "http://localhost:8283", startedAt })).toMatch(
45
+ /^portal desktop ready · http:\/\/localhost:8283 · 12\.\ds$/,
46
+ );
47
+ expect(
48
+ new NativeApp(app, target({ name: "admin" })).readyLine("ios", {
49
+ upstream: "http://localhost:8283",
50
+ device: { name: "iPhone 16" },
51
+ startedAt,
52
+ }),
53
+ ).toMatch(/^portal\/admin ios ready · iPhone 16 · http:\/\/localhost:8283 · 12\.\ds$/);
54
+ });
55
+
41
56
  test("opens a dev build on its target's home, the CSR shell under the locale", () => {
42
57
  expect(new NativeApp(fakeApp(), target({ indexPath: "/explore" })).startPath("en")).toBe(
43
58
  "/en/explore?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2Fexplore",