@arcote.tech/arc-cli 0.7.0 → 0.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -12,12 +12,12 @@
12
12
  "build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.7.0",
16
- "@arcote.tech/arc-ds": "^0.7.0",
17
- "@arcote.tech/arc-react": "^0.7.0",
18
- "@arcote.tech/arc-host": "^0.7.0",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.7.0",
20
- "@arcote.tech/platform": "^0.7.0",
15
+ "@arcote.tech/arc": "^0.7.1",
16
+ "@arcote.tech/arc-ds": "^0.7.1",
17
+ "@arcote.tech/arc-react": "^0.7.1",
18
+ "@arcote.tech/arc-host": "^0.7.1",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.1",
20
+ "@arcote.tech/platform": "^0.7.1",
21
21
  "@clack/prompts": "^0.9.0",
22
22
  "commander": "^11.1.0",
23
23
  "chokidar": "^3.5.3",
@@ -3,27 +3,12 @@ import {
3
3
  existsSync,
4
4
  mkdirSync,
5
5
  readFileSync,
6
- realpathSync,
7
6
  unlinkSync,
8
7
  writeFileSync,
9
8
  } from "fs";
10
- import { dirname, join } from "path";
11
- import { fileURLToPath } from "url";
9
+ import { join } from "path";
12
10
  import { isContextPackage, type WorkspacePackage } from "./module-builder";
13
11
 
14
- // Locate platform's server entry by walking up from the CLI bundle location.
15
- // `import.meta.url` at runtime points to the bundled `dist/index.js` (the CLI
16
- // is shipped as a single Bun.build artifact), so the path is:
17
- // <arcRoot>/packages/cli/dist/index.js
18
- // Four `dirname` applications reach <arcRoot>. This works regardless of how
19
- // the user invoked the CLI — symlink, npm install, or direct path — as long
20
- // as the CLI binary lives at packages/cli/dist/ inside the arc workspace.
21
- function locatePlatformServerEntry(): string {
22
- const here = fileURLToPath(import.meta.url);
23
- const arcRoot = realpathSync(dirname(dirname(dirname(dirname(here)))));
24
- return join(arcRoot, "packages", "platform", "src", "index.server.ts");
25
- }
26
-
27
12
  // ---------------------------------------------------------------------------
28
13
  // access-extractor — discovers per-module access rules (`protectedBy(...)`)
29
14
  // from already-built server bundles, in an ISOLATED subprocess.
@@ -64,8 +49,7 @@ export async function extractAccessMap(
64
49
  // Each entry: a context-package server bundle path that the worker will
65
50
  // dynamically import. Bun resolves the bundle's internal external imports
66
51
  // (`@arcote.tech/platform` etc.) via node_modules walking from the bundle
67
- // location — workspace symlinks point back to source, conditional exports
68
- // pick the server entry.
52
+ // location — workspace symlinks point back to source.
69
53
  const serverBundles = packages
70
54
  .filter((p) => isContextPackage(p.packageJson))
71
55
  .map((p) => ({
@@ -75,8 +59,9 @@ export async function extractAccessMap(
75
59
  .filter((b) => existsSync(b.path));
76
60
 
77
61
  // Worker must live INSIDE the workspace tree so Bun's module resolver can
78
- // walk up to <rootDir>/node_modules and find @arcote.tech/platform/server.
79
- // A tmpfile in /tmp/ would fail bare-specifier resolution.
62
+ // walk up to <rootDir>/node_modules and find @arcote.tech/platform via the
63
+ // bare specifier `@arcote.tech/platform`. A tmpfile in /tmp/ would fail
64
+ // bare-specifier resolution.
80
65
  const workerDir = join(rootDir, ".arc", ".tmp");
81
66
  mkdirSync(workerDir, { recursive: true });
82
67
  const workerPath = join(workerDir, `access-extractor-${Date.now()}.mjs`);
@@ -91,7 +76,6 @@ export async function extractAccessMap(
91
76
  ...process.env,
92
77
  ARC_ACCESS_BUNDLES: JSON.stringify(serverBundles),
93
78
  ARC_ACCESS_OUT: outPath,
94
- ARC_PLATFORM_ENTRY: locatePlatformServerEntry(),
95
79
  },
96
80
  stdout: "pipe",
97
81
  stderr: "inherit",
@@ -131,9 +115,11 @@ if (!out) {
131
115
  process.exit(2);
132
116
  }
133
117
 
134
- // Direct file-path import — bypasses node_modules resolution entirely.
135
- // Avoids quirks with bun-link snapshots holding stale package.json exports.
136
- const platform = await import(process.env.ARC_PLATFORM_ENTRY);
118
+ // Bare-specifier import — Bun walks up from this worker's location
119
+ // (<rootDir>/.arc/.tmp/) to <rootDir>/node_modules and finds the package.
120
+ // Single entry (./src/index.ts) — React imports on top level are benign
121
+ // (createContext, function defs); no DOM access until actual render.
122
+ const platform = await import("@arcote.tech/platform");
137
123
 
138
124
  for (const { name, path } of bundles) {
139
125
  try {
@@ -17,7 +17,7 @@ import {
17
17
  updateCache,
18
18
  type BuildCache,
19
19
  } from "./build-cache";
20
- import type { ChunkPlan, PackageChunk } from "./chunk-planner";
20
+ import type { ChunkPlan } from "./chunk-planner";
21
21
  import { SHELL_EXTERNALS } from "./framework-peers";
22
22
  import {
23
23
  readInstalledVersion,
@@ -47,6 +47,56 @@ export { SHELL_EXTERNALS };
47
47
  * JSX-typed Trans/translation components from workspace deps; without the
48
48
  * shim, loadServerContext fails on any server bundle that touches them.
49
49
  */
50
+ /**
51
+ * Force every `react*` and `react-dom*` import to resolve from the workspace
52
+ * root's node_modules. Without this, monorepo symlinks (npm-linked
53
+ * @arcote.tech/platform → arc workspace's react@A; @ndt/* → ndt workspace's
54
+ * react@B) produce two physical React copies in the bundle. Two copies =
55
+ * two `ReactSharedInternals` = null dispatcher = "Invalid hook call".
56
+ *
57
+ * We resolve once from `rootDir` and pin every subsequent React-related
58
+ * specifier to that absolute path, defeating Bun's per-importer resolution.
59
+ */
60
+ /**
61
+ * Override `"sideEffects": false` for workspace + framework packages.
62
+ *
63
+ * Arc modules auto-register via top-level `module(...).build()` — that IS a
64
+ * side effect. But packages typically ship with `"sideEffects": false` (a
65
+ * good default for treeshake-friendly libs). Two failure modes follow:
66
+ *
67
+ * 1. Side-effect-only imports (`import "@ndt/strategy"`) get tree-shaken
68
+ * to nothing — module never registers.
69
+ * 2. Splitting heuristics treat such modules as freely duplicatable, so
70
+ * a context-package singleton (`WorkspaceContext = createContext(...)`)
71
+ * ends up cloned per entry; `useWorkspace()` can't find the provider.
72
+ *
73
+ * Bun.build accepts `sideEffects: true` in `onResolve` return values (same
74
+ * as esbuild) — that flag overrides package.json. We flip the bit for any
75
+ * `@ndt/*` or `@arcote.tech/*` import. React itself is fine; it doesn't
76
+ * use sideEffects:false in a problematic way.
77
+ */
78
+
79
+ function singleReactPlugin(rootDir: string): import("bun").BunPlugin {
80
+ const reactPkgs = ["react", "react-dom", "react-dom/client", "react/jsx-runtime"];
81
+ return {
82
+ name: "single-react",
83
+ setup(build) {
84
+ const resolved = new Map<string, string>();
85
+ for (const spec of reactPkgs) {
86
+ try {
87
+ resolved.set(spec, Bun.resolveSync(spec, rootDir));
88
+ } catch {
89
+ // If consumer doesn't have it installed, leave Bun's default behavior.
90
+ }
91
+ }
92
+ build.onResolve({ filter: /^(react|react-dom|react-dom\/client|react\/jsx-runtime)$/ }, (args) => {
93
+ const path = resolved.get(args.path);
94
+ return path ? { path } : null;
95
+ });
96
+ },
97
+ };
98
+ }
99
+
50
100
  function jsxDevShimPlugin(): import("bun").BunPlugin {
51
101
  return {
52
102
  name: "jsx-dev-runtime-shim",
@@ -227,16 +277,26 @@ async function buildContextClient(
227
277
 
228
278
  console.log(` building: ${pkg.name} (${client.name})`);
229
279
 
230
- // Externals: framework peers + npm dependencies. Workspace deps (value
231
- // starts with `workspace:`) are bundled inline — the deploy image has no
232
- // workspace symlinks, so any cross-package import not bundled here would
233
- // fail to resolve at runtime in the container.
280
+ // Externals: framework peers + npm dependencies + workspace deps.
281
+ //
282
+ // Workspace deps used to be bundled inline (because the deploy image has
283
+ // no workspace symlinks). The consequence: every context package's dist
284
+ // carried its own copy of every workspace dep — `@ndt/strategy/dist`
285
+ // shipped `@ndt/workspace` inlined, `@ndt/content/dist` shipped another
286
+ // copy, etc. At deploy-build time the top-level Bun.build saw N
287
+ // pre-inlined copies and could no longer dedupe them — context-package
288
+ // singletons (e.g. `WorkspaceContext = createContext`) duplicated per
289
+ // entry, breaking `useWorkspace()` provider lookup.
290
+ //
291
+ // Treating workspace deps as `external` makes per-package dist emit bare
292
+ // specifiers (`import { workspace } from "@ndt/workspace"`), which the
293
+ // browser-side Bun.build then resolves ONCE across all entries → single
294
+ // module instance, splitting hoists into a shared chunk. The deploy
295
+ // image is unaffected: the browser bundle is self-contained at that
296
+ // layer (we only ship the final chunks, not per-package dist/browser).
234
297
  const peerDeps = Object.keys(pkg.packageJson.peerDependencies ?? {});
235
298
  const allDeps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
236
- const npmDeps = Object.entries(allDeps)
237
- .filter(([, spec]) => !spec.startsWith("workspace:"))
238
- .map(([name]) => name);
239
- const externals = [...peerDeps, ...npmDeps];
299
+ const externals = [...peerDeps, ...Object.keys(allDeps)];
240
300
 
241
301
  const result = await Bun.build({
242
302
  entrypoints: [pkg.entrypoint],
@@ -307,187 +367,267 @@ export async function buildContextPackages(
307
367
  return { declarationErrors };
308
368
  }
309
369
 
370
+
310
371
  // ---------------------------------------------------------------------------
311
- // Modules bundles — one Bun.build per chunk group (public + per-token-name)
372
+ // Browser app build — one Bun.build with multiple entrypoints.
373
+ //
374
+ // Replaces the previous architecture of 25+ separate Bun.build calls (per
375
+ // shell peer + per context package × client + per token chunk group + initial
376
+ // bundle). The big win is `splitting: true` across ALL entries:
377
+ //
378
+ // - workspace context, framework, common deps land in ONE shared chunk
379
+ // referenced by both `initial.*.js` and `<token>.*.js`. No more multi-
380
+ // instance bugs (e.g. createWorkspace().build() running twice, each
381
+ // producing its own aggregate refs).
382
+ // - No importmap. Nothing is external — Bun bundles everything inline,
383
+ // dedups via shared chunks. The HTML loads `initial.<hash>.js` and that
384
+ // pulls shared chunks as needed.
385
+ // - Token chunks are first-class entries (`<token-name>.<hash>.js`) that
386
+ // side-effect-import all their member modules. Server signs the URL for
387
+ // the entry; shared chunks ride along unsigned (filenames are content-
388
+ // addressed so they're not enumerable without the manifest).
389
+ //
390
+ // Output layout under <outDir> = `<arcDir>/browser/`:
391
+ // initial.<hash>.js ← public modules + bootstrap entry
392
+ // <tokenName>.<hash>.js × N ← one per token group
393
+ // chunk-<hash>.js × N ← auto-shared (workspace ctx, framework, etc.)
312
394
  // ---------------------------------------------------------------------------
313
395
 
314
- interface ModulesBundleResult {
315
- modules: ModuleDescriptor[];
316
- cached: boolean;
396
+ export interface BrowserGroupEntry {
397
+ /** Filename relative to outDir (`<token>.<hash>.js`). */
398
+ readonly file: string;
399
+ readonly hash: string;
400
+ /** Module names registered by this group (for manifest filterability). */
401
+ readonly modules: readonly string[];
317
402
  }
318
403
 
319
- /**
320
- * Build each chunk group as an independent Bun.build. Chunks NEVER share
321
- * code across groups a public chunk file can be served unauthenticated;
322
- * per-token chunk files are signed and require the matching token to fetch.
323
- *
324
- * Layout: `<outDir>/<chunk>/<safeName>.js` (+ Bun's shared chunk files
325
- * `chunk-<hash>.js` inside the same chunk dir).
326
- *
327
- * i18n strings extracted from all groups are merged into a single workspace
328
- * translation catalog (extraction is build-input metadata, not bundle output).
329
- */
330
- export async function buildModulesByChunks(
404
+ export interface BrowserAppResult {
405
+ readonly initial: { file: string; hash: string };
406
+ /** Keyed by token.name. `initial` is NOT here it's separate. */
407
+ readonly groups: Record<string, BrowserGroupEntry>;
408
+ /** Auto-shared chunks emitted by Bun.build splitting. Public, unsigned. */
409
+ readonly sharedChunks: readonly string[];
410
+ readonly cached: boolean;
411
+ }
412
+
413
+ export async function buildBrowserApp(
331
414
  rootDir: string,
332
415
  outDir: string,
333
416
  plan: ChunkPlan,
334
417
  cache: BuildCache,
335
418
  noCache: boolean,
336
- ): Promise<ModulesBundleResult> {
419
+ i18nCollector: Map<string, Set<string>>,
420
+ ): Promise<BrowserAppResult> {
337
421
  mkdirSync(outDir, { recursive: true });
338
422
 
339
- const i18nCollector = new Map<string, Set<string>>();
340
- const aggregateModules: ModuleDescriptor[] = [];
341
- let allCached = true;
342
-
343
- for (const chunk of plan.chunks) {
344
- const members = plan.groups.get(chunk) ?? [];
345
- if (members.length === 0) continue;
346
-
347
- const chunkOutDir = join(outDir, chunk);
348
- mkdirSync(chunkOutDir, { recursive: true });
349
-
350
- const result = await buildChunkGroup(
351
- rootDir,
352
- chunkOutDir,
353
- chunk,
354
- members,
355
- cache,
356
- noCache,
357
- i18nCollector,
358
- );
359
-
360
- aggregateModules.push(...result.modules);
361
- if (!result.cached) allCached = false;
362
- }
363
-
364
- // Single workspace-wide i18n write — keeps msgid bookkeeping atomic.
365
- await finalizeTranslations(rootDir, join(outDir, ".."), i18nCollector);
423
+ const publicMembers = plan.groups.get("public") ?? [];
424
+ const protectedGroups = plan.chunks
425
+ .filter((c) => c !== "public")
426
+ .map((c) => ({ name: c, members: plan.groups.get(c) ?? [] }))
427
+ .filter((g) => g.members.length > 0);
366
428
 
367
- return { modules: aggregateModules, cached: allCached };
368
- }
369
-
370
- async function buildChunkGroup(
371
- rootDir: string,
372
- chunkOutDir: string,
373
- chunk: string,
374
- members: readonly PackageChunk[],
375
- cache: BuildCache,
376
- noCache: boolean,
377
- i18nCollector: Map<string, Set<string>>,
378
- ): Promise<ModulesBundleResult> {
379
- const unitId = `modules-chunk:${chunk}`;
380
-
381
- const pkgHashes = members.map((m) => ({
382
- name: m.pkg.name,
383
- safeName: m.safeName,
384
- moduleName: m.moduleName,
385
- srcHash: pkgSourceHash(m.pkg),
386
- }));
429
+ const unitId = "browser-app";
387
430
 
431
+ // Cache key spans every package's source plus the build config that
432
+ // matters. If anything changes, full rebuild.
433
+ const allMembers: { name: string; group: string; srcHash: string }[] = [];
434
+ for (const m of publicMembers) {
435
+ allMembers.push({ name: m.pkg.name, group: "public", srcHash: pkgSourceHash(m.pkg) });
436
+ }
437
+ for (const g of protectedGroups) {
438
+ for (const m of g.members) {
439
+ allMembers.push({ name: m.pkg.name, group: g.name, srcHash: pkgSourceHash(m.pkg) });
440
+ }
441
+ }
388
442
  const inputHash = sha256OfJson({
389
- chunk,
390
- pkgHashes,
391
- externals: SHELL_EXTERNALS,
443
+ members: allMembers,
444
+ groups: [
445
+ "initial",
446
+ ...protectedGroups.map((g) => g.name).sort(),
447
+ ],
392
448
  define: { ONLY_SERVER: "false", ONLY_BROWSER: "true", ONLY_CLIENT: "true" },
393
449
  });
394
450
 
395
451
  if (!noCache && isCacheHit(cache, unitId, inputHash)) {
396
- const existing = cache.units[unitId]?.outputHashes ?? {};
397
- const modules: ModuleDescriptor[] = [];
398
- let missing = false;
399
- for (const h of pkgHashes) {
400
- const file = `${h.safeName}.js`;
401
- const filePath = join(chunkOutDir, file);
402
- if (!existsSync(filePath)) {
403
- missing = true;
404
- break;
452
+ const cached = cache.units[unitId]?.outputHashes;
453
+ if (cached?._manifest) {
454
+ try {
455
+ const m = JSON.parse(cached._manifest) as BrowserAppResult;
456
+ const allFiles = [
457
+ m.initial.file,
458
+ ...Object.values(m.groups).map((g) => g.file),
459
+ ...m.sharedChunks,
460
+ ];
461
+ if (allFiles.every((f) => existsSync(join(outDir, f)))) {
462
+ console.log(` ✓ cached: ${unitId}`);
463
+ return { ...m, cached: true };
464
+ }
465
+ } catch {
466
+ // fall through to rebuild
405
467
  }
406
- modules.push({
407
- file,
408
- name: h.moduleName,
409
- chunk,
410
- hash: existing[h.safeName] ?? sha256Hex(readFileSync(filePath)),
411
- });
412
- }
413
- if (!missing) {
414
- console.log(` ✓ cached: ${unitId} (${modules.length} module(s))`);
415
- return { modules, cached: true };
416
468
  }
417
- console.log(` rebuilding ${unitId}: output file missing`);
418
469
  }
419
470
 
420
- console.log(` building: ${unitId} (${members.length} module(s))`);
471
+ console.log(
472
+ ` building: ${unitId} (initial: ${publicMembers.length} modules, groups: ${protectedGroups
473
+ .map((g) => `${g.name}=${g.members.length}`)
474
+ .join(",") || "none"})`,
475
+ );
476
+
477
+ // Wipe outDir so stale-hash files don't linger.
478
+ if (existsSync(outDir)) {
479
+ for (const f of readdirSync(outDir)) {
480
+ if (f.endsWith(".js")) rmSync(join(outDir, f), { force: true });
481
+ }
482
+ }
421
483
 
422
- const tmpDir = join(chunkOutDir, "_entries");
484
+ const tmpDir = join(outDir, "_entries");
423
485
  mkdirSync(tmpDir, { recursive: true });
424
486
 
425
- const entrypoints: string[] = [];
426
- const fileToModuleName = new Map<string, string>();
487
+ const importLines = (pkgs: { pkg: { name: string } }[]): string =>
488
+ pkgs.map((m) => `import "${m.pkg.name}";`).join("\n");
427
489
 
428
- for (const m of members) {
429
- fileToModuleName.set(m.safeName, m.moduleName);
430
- const wrapperFile = join(tmpDir, `${m.safeName}.ts`);
431
- writeFileSync(wrapperFile, `export * from "${m.pkg.name}";\n`);
432
- entrypoints.push(wrapperFile);
490
+ const initialEntry = join(tmpDir, "initial.ts");
491
+ writeFileSync(
492
+ initialEntry,
493
+ `${importLines(publicMembers)}\nexport { startApp } from "@arcote.tech/platform";\n`,
494
+ );
495
+
496
+ const entryPaths: string[] = [initialEntry];
497
+ const groupModuleMap = new Map<string, string[]>();
498
+ for (const g of protectedGroups) {
499
+ const entry = join(tmpDir, `${g.name}.ts`);
500
+ writeFileSync(entry, `${importLines(g.members)}\n`);
501
+ entryPaths.push(entry);
502
+ groupModuleMap.set(g.name, g.members.map((m) => m.moduleName));
433
503
  }
434
504
 
435
- const arcExternalPlugin: import("bun").BunPlugin = {
436
- name: "arc-external",
437
- setup(build) {
438
- build.onResolve({ filter: /^@arcote\.tech\// }, (args) => {
439
- return { path: args.path, external: true };
440
- });
441
- },
442
- };
505
+ // ---------------------------------------------------------------------
506
+ // Temporarily flip `"sideEffects"` on every workspace package's
507
+ // package.json from `false` to `true` for the duration of the build.
508
+ //
509
+ // Why: Arc modules auto-register via top-level `module(...).build()` —
510
+ // a real side effect. But packages ship with `"sideEffects": false` (a
511
+ // good ESM-library default), which makes Bun.build:
512
+ // (1) tree-shake side-effect-only imports → module().build() never runs,
513
+ // (2) freely duplicate the module across entry chunks → context
514
+ // singletons (`createContext`) get cloned, breaking provider lookup.
515
+ //
516
+ // Bun has no plugin hook to override package.json `sideEffects` and
517
+ // `build.resolve()` is not implemented (Bun#2771), so the cleanest
518
+ // reliable lever is a tiny on-disk patch with guaranteed restore.
519
+ // ---------------------------------------------------------------------
520
+ const allMemberPkgs = new Map<string, WorkspacePackage>();
521
+ for (const m of publicMembers) allMemberPkgs.set(m.pkg.name, m.pkg);
522
+ for (const g of protectedGroups)
523
+ for (const m of g.members) allMemberPkgs.set(m.pkg.name, m.pkg);
524
+
525
+ const patchedPkgJsons: { path: string; original: string }[] = [];
526
+ for (const pkg of allMemberPkgs.values()) {
527
+ const pkgJsonPath = join(pkg.path, "package.json");
528
+ if (!existsSync(pkgJsonPath)) continue;
529
+ const original = readFileSync(pkgJsonPath, "utf-8");
530
+ const parsed = JSON.parse(original);
531
+ if (parsed.sideEffects === true) continue; // already correct
532
+ parsed.sideEffects = true;
533
+ writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n");
534
+ patchedPkgJsons.push({ path: pkgJsonPath, original });
535
+ }
443
536
 
444
- const result = await Bun.build({
445
- entrypoints,
446
- outdir: chunkOutDir,
447
- splitting: true,
448
- format: "esm",
449
- target: "browser",
450
- external: [...SHELL_EXTERNALS],
451
- plugins: [
452
- arcExternalPlugin,
453
- jsxDevShimPlugin(),
454
- i18nExtractPlugin(i18nCollector, rootDir),
455
- ],
456
- naming: "[name].[ext]",
457
- define: {
458
- ONLY_SERVER: "false",
459
- ONLY_BROWSER: "true",
460
- ONLY_CLIENT: "true",
461
- },
462
- });
537
+ let result;
538
+ try {
539
+ result = await Bun.build({
540
+ entrypoints: entryPaths,
541
+ outdir: outDir,
542
+ // splitting:true is the whole point: shared deps (workspace context,
543
+ // framework, lucide, etc.) land in chunk-<hash>.js, referenced by both
544
+ // initial and token-group entries. One instance, no provider duplication.
545
+ splitting: true,
546
+ format: "esm",
547
+ target: "browser",
548
+ // No externals. Framework peers (react, @arcote.tech/*) get bundled and
549
+ // deduped into shared chunks. No importmap needed in the HTML.
550
+ external: [],
551
+ plugins: [
552
+ singleReactPlugin(rootDir),
553
+ jsxDevShimPlugin(),
554
+ i18nExtractPlugin(i18nCollector, rootDir),
555
+ ],
556
+ naming: "[name].[ext]",
557
+ define: {
558
+ ONLY_SERVER: "false",
559
+ ONLY_BROWSER: "true",
560
+ ONLY_CLIENT: "true",
561
+ // CRITICAL: React's index.js does `if (process.env.NODE_ENV === 'production') require('./cjs/react.production.js') else require('./cjs/react.development.js')`.
562
+ // Without inlining NODE_ENV at build time Bun bundles BOTH branches → TWO ReactSharedInternals → multi-instance "Invalid hook call" crash.
563
+ "process.env.NODE_ENV": '"production"',
564
+ },
565
+ });
566
+ } finally {
567
+ // Always restore — a crash here MUST NOT leave the user with mutated
568
+ // workspace package.json files.
569
+ for (const p of patchedPkgJsons) writeFileSync(p.path, p.original);
570
+ }
571
+
572
+ rmSync(tmpDir, { recursive: true, force: true });
463
573
 
464
574
  if (!result.success) {
465
- console.error(`Chunk "${chunk}" build failed:`);
466
575
  for (const log of result.logs) console.error(log);
467
- throw new Error(`Module chunk build failed: ${chunk}`);
576
+ throw new Error("Browser app build failed");
468
577
  }
469
578
 
470
- rmSync(tmpDir, { recursive: true, force: true });
579
+ // Bun's `[name]` in naming preserves entry filename without hash. We add
580
+ // content hashes ourselves so identical bytes produce identical URLs across
581
+ // deploys (good for browser caching). Shared chunks already have a hash in
582
+ // their name (Bun auto-emits `chunk-<hash>.js`); we leave those alone.
583
+ let initialFile = "";
584
+ let initialHash = "";
585
+ const groups: Record<string, BrowserGroupEntry> = {};
586
+ const sharedChunks: string[] = [];
587
+
588
+ for (const out of result.outputs) {
589
+ const name = basename(out.path);
590
+ if (out.kind === "entry-point") {
591
+ const bytes = readFileSync(out.path);
592
+ const hash = sha256Hex(bytes).slice(0, 16);
593
+ const stem = name.replace(/\.js$/, "");
594
+ const finalName = `${stem}.${hash}.js`;
595
+ const finalPath = join(outDir, finalName);
596
+ rmSync(finalPath, { force: true });
597
+ writeFileSync(finalPath, bytes);
598
+ rmSync(out.path, { force: true });
599
+
600
+ if (stem === "initial") {
601
+ initialFile = finalName;
602
+ initialHash = hash;
603
+ } else {
604
+ groups[stem] = {
605
+ file: finalName,
606
+ hash,
607
+ modules: groupModuleMap.get(stem) ?? [],
608
+ };
609
+ }
610
+ } else if (out.kind === "chunk") {
611
+ sharedChunks.push(name);
612
+ }
613
+ }
471
614
 
472
- const outputHashes: Record<string, string> = {};
473
- const modules: ModuleDescriptor[] = result.outputs
474
- .filter((o) => o.kind === "entry-point")
475
- .map((o) => {
476
- const file = basename(o.path);
477
- const safeName = file.replace(/\.js$/, "");
478
- const bytes = readFileSync(o.path);
479
- const hash = sha256Hex(bytes);
480
- outputHashes[safeName] = hash;
481
- return {
482
- file,
483
- name: fileToModuleName.get(safeName) ?? safeName,
484
- chunk,
485
- hash,
486
- };
487
- });
615
+ if (!initialFile) {
616
+ throw new Error("Browser app build: initial entry not found in outputs");
617
+ }
618
+
619
+ const manifest: BrowserAppResult = {
620
+ initial: { file: initialFile, hash: initialHash },
621
+ groups,
622
+ sharedChunks,
623
+ cached: false,
624
+ };
625
+
626
+ updateCache(cache, unitId, inputHash, {
627
+ outputHashes: { _manifest: JSON.stringify(manifest) },
628
+ });
488
629
 
489
- updateCache(cache, unitId, inputHash, { outputHashes });
490
- return { modules, cached: false };
630
+ return manifest;
491
631
  }
492
632
 
493
633
  // ---------------------------------------------------------------------------
@@ -3,5 +3,6 @@ import { buildAll, ok, resolveWorkspace } from "../platform/shared";
3
3
  export async function platformBuild(opts: { noCache?: boolean } = {}): Promise<void> {
4
4
  const ws = resolveWorkspace();
5
5
  const manifest = await buildAll(ws, { noCache: opts.noCache });
6
- ok(`Platform built ${manifest.modules.length} module(s)`);
6
+ const groupCount = Object.keys(manifest.groups).length;
7
+ ok(`Platform built — initial + ${groupCount} group(s)`);
7
8
  }