@arcote.tech/arc-cli 0.7.0 → 0.7.2

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.
@@ -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,32 @@ 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 depend on the target client:
281
+ //
282
+ // - BROWSER client: workspace deps MUST be external. Inlining them per
283
+ // package would make every context package's dist ship its own copy
284
+ // of every workspace dep — the top-level browser Bun.build would see
285
+ // N pre-inlined copies of context singletons (`WorkspaceContext =
286
+ // createContext`) and could not dedupe them, breaking provider lookup.
287
+ //
288
+ // - SERVER client: workspace deps MUST be bundled inline. The deploy
289
+ // image flattens each package's server bundle to
290
+ // `.arc/platform/server/<pkg>.js` and runs them via a single
291
+ // `loadServerContext()` import loop. There is no `node_modules/@ndt/*`
292
+ // tree inside the image, so bare `@ndt/workspace` specifiers would
293
+ // fail to resolve at startup. Inline duplication is harmless on the
294
+ // server: it's a single Node/Bun process and Arc modules register via
295
+ // a shared platform registry singleton (registry.ts), so two physical
296
+ // copies of the workspace module still merge into one context.
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 isBrowser = client.name === "browser";
300
+ const workspaceDeps = isBrowser
301
+ ? Object.keys(allDeps)
302
+ : Object.entries(allDeps)
303
+ .filter(([, spec]) => !spec.startsWith("workspace:"))
304
+ .map(([name]) => name);
305
+ const externals = [...peerDeps, ...workspaceDeps];
240
306
 
241
307
  const result = await Bun.build({
242
308
  entrypoints: [pkg.entrypoint],
@@ -289,14 +355,51 @@ export async function buildContextPackages(
289
355
  const contexts = packages.filter((p) => isContextPackage(p.packageJson));
290
356
  if (contexts.length === 0) return { declarationErrors: [] };
291
357
 
292
- const tasks = contexts.flatMap((pkg) =>
293
- CONTEXT_CLIENTS.map((client) => () =>
294
- buildContextClient(pkg, rootDir, client, cache, noCache),
295
- ),
296
- );
358
+ // Topological order each package's server bundle inlines its workspace
359
+ // deps, so those deps must have their `dist/` ready before Bun.build tries
360
+ // to resolve them. Without this, a fresh checkout (no dist/ yet) fails the
361
+ // first build because content's resolve of @ndt/strategy hits a missing
362
+ // file. Inside a topological level, packages are built in parallel.
363
+ const byName = new Map(contexts.map((p) => [p.name, p]));
364
+ const remaining = new Set(contexts.map((p) => p.name));
365
+ const done = new Set<string>();
366
+ const ordered: WorkspacePackage[][] = [];
367
+
368
+ const workspaceDepsOf = (pkg: WorkspacePackage): string[] => {
369
+ const deps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
370
+ return Object.entries(deps)
371
+ .filter(([name, spec]) => spec.startsWith("workspace:") && byName.has(name))
372
+ .map(([name]) => name);
373
+ };
297
374
 
298
- const results = await pAll(tasks);
299
- const declarationErrors = results.flatMap((r) => r.declarationErrors);
375
+ while (remaining.size > 0) {
376
+ const layer: WorkspacePackage[] = [];
377
+ for (const name of remaining) {
378
+ const pkg = byName.get(name)!;
379
+ const unmetDeps = workspaceDepsOf(pkg).filter((d) => !done.has(d));
380
+ if (unmetDeps.length === 0) layer.push(pkg);
381
+ }
382
+ if (layer.length === 0) {
383
+ const cycle = [...remaining].join(", ");
384
+ throw new Error(`Workspace dependency cycle detected: ${cycle}`);
385
+ }
386
+ ordered.push(layer);
387
+ for (const pkg of layer) {
388
+ done.add(pkg.name);
389
+ remaining.delete(pkg.name);
390
+ }
391
+ }
392
+
393
+ const declarationErrors: string[] = [];
394
+ for (const layer of ordered) {
395
+ const tasks = layer.flatMap((pkg) =>
396
+ CONTEXT_CLIENTS.map((client) => () =>
397
+ buildContextClient(pkg, rootDir, client, cache, noCache),
398
+ ),
399
+ );
400
+ const results = await pAll(tasks);
401
+ for (const r of results) declarationErrors.push(...r.declarationErrors);
402
+ }
300
403
 
301
404
  if (declarationErrors.length > 0) {
302
405
  console.warn("\n\x1b[33mType declaration errors:\x1b[0m");
@@ -307,187 +410,267 @@ export async function buildContextPackages(
307
410
  return { declarationErrors };
308
411
  }
309
412
 
413
+
310
414
  // ---------------------------------------------------------------------------
311
- // Modules bundles — one Bun.build per chunk group (public + per-token-name)
415
+ // Browser app build — one Bun.build with multiple entrypoints.
416
+ //
417
+ // Replaces the previous architecture of 25+ separate Bun.build calls (per
418
+ // shell peer + per context package × client + per token chunk group + initial
419
+ // bundle). The big win is `splitting: true` across ALL entries:
420
+ //
421
+ // - workspace context, framework, common deps land in ONE shared chunk
422
+ // referenced by both `initial.*.js` and `<token>.*.js`. No more multi-
423
+ // instance bugs (e.g. createWorkspace().build() running twice, each
424
+ // producing its own aggregate refs).
425
+ // - No importmap. Nothing is external — Bun bundles everything inline,
426
+ // dedups via shared chunks. The HTML loads `initial.<hash>.js` and that
427
+ // pulls shared chunks as needed.
428
+ // - Token chunks are first-class entries (`<token-name>.<hash>.js`) that
429
+ // side-effect-import all their member modules. Server signs the URL for
430
+ // the entry; shared chunks ride along unsigned (filenames are content-
431
+ // addressed so they're not enumerable without the manifest).
432
+ //
433
+ // Output layout under <outDir> = `<arcDir>/browser/`:
434
+ // initial.<hash>.js ← public modules + bootstrap entry
435
+ // <tokenName>.<hash>.js × N ← one per token group
436
+ // chunk-<hash>.js × N ← auto-shared (workspace ctx, framework, etc.)
312
437
  // ---------------------------------------------------------------------------
313
438
 
314
- interface ModulesBundleResult {
315
- modules: ModuleDescriptor[];
316
- cached: boolean;
439
+ export interface BrowserGroupEntry {
440
+ /** Filename relative to outDir (`<token>.<hash>.js`). */
441
+ readonly file: string;
442
+ readonly hash: string;
443
+ /** Module names registered by this group (for manifest filterability). */
444
+ readonly modules: readonly string[];
317
445
  }
318
446
 
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(
447
+ export interface BrowserAppResult {
448
+ readonly initial: { file: string; hash: string };
449
+ /** Keyed by token.name. `initial` is NOT here it's separate. */
450
+ readonly groups: Record<string, BrowserGroupEntry>;
451
+ /** Auto-shared chunks emitted by Bun.build splitting. Public, unsigned. */
452
+ readonly sharedChunks: readonly string[];
453
+ readonly cached: boolean;
454
+ }
455
+
456
+ export async function buildBrowserApp(
331
457
  rootDir: string,
332
458
  outDir: string,
333
459
  plan: ChunkPlan,
334
460
  cache: BuildCache,
335
461
  noCache: boolean,
336
- ): Promise<ModulesBundleResult> {
462
+ i18nCollector: Map<string, Set<string>>,
463
+ ): Promise<BrowserAppResult> {
337
464
  mkdirSync(outDir, { recursive: true });
338
465
 
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);
366
-
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}`;
466
+ const publicMembers = plan.groups.get("public") ?? [];
467
+ const protectedGroups = plan.chunks
468
+ .filter((c) => c !== "public")
469
+ .map((c) => ({ name: c, members: plan.groups.get(c) ?? [] }))
470
+ .filter((g) => g.members.length > 0);
380
471
 
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
- }));
472
+ const unitId = "browser-app";
387
473
 
474
+ // Cache key spans every package's source plus the build config that
475
+ // matters. If anything changes, full rebuild.
476
+ const allMembers: { name: string; group: string; srcHash: string }[] = [];
477
+ for (const m of publicMembers) {
478
+ allMembers.push({ name: m.pkg.name, group: "public", srcHash: pkgSourceHash(m.pkg) });
479
+ }
480
+ for (const g of protectedGroups) {
481
+ for (const m of g.members) {
482
+ allMembers.push({ name: m.pkg.name, group: g.name, srcHash: pkgSourceHash(m.pkg) });
483
+ }
484
+ }
388
485
  const inputHash = sha256OfJson({
389
- chunk,
390
- pkgHashes,
391
- externals: SHELL_EXTERNALS,
486
+ members: allMembers,
487
+ groups: [
488
+ "initial",
489
+ ...protectedGroups.map((g) => g.name).sort(),
490
+ ],
392
491
  define: { ONLY_SERVER: "false", ONLY_BROWSER: "true", ONLY_CLIENT: "true" },
393
492
  });
394
493
 
395
494
  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;
495
+ const cached = cache.units[unitId]?.outputHashes;
496
+ if (cached?._manifest) {
497
+ try {
498
+ const m = JSON.parse(cached._manifest) as BrowserAppResult;
499
+ const allFiles = [
500
+ m.initial.file,
501
+ ...Object.values(m.groups).map((g) => g.file),
502
+ ...m.sharedChunks,
503
+ ];
504
+ if (allFiles.every((f) => existsSync(join(outDir, f)))) {
505
+ console.log(` ✓ cached: ${unitId}`);
506
+ return { ...m, cached: true };
507
+ }
508
+ } catch {
509
+ // fall through to rebuild
405
510
  }
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
511
  }
417
- console.log(` rebuilding ${unitId}: output file missing`);
418
512
  }
419
513
 
420
- console.log(` building: ${unitId} (${members.length} module(s))`);
514
+ console.log(
515
+ ` building: ${unitId} (initial: ${publicMembers.length} modules, groups: ${protectedGroups
516
+ .map((g) => `${g.name}=${g.members.length}`)
517
+ .join(",") || "none"})`,
518
+ );
421
519
 
422
- const tmpDir = join(chunkOutDir, "_entries");
520
+ // Wipe outDir so stale-hash files don't linger.
521
+ if (existsSync(outDir)) {
522
+ for (const f of readdirSync(outDir)) {
523
+ if (f.endsWith(".js")) rmSync(join(outDir, f), { force: true });
524
+ }
525
+ }
526
+
527
+ const tmpDir = join(outDir, "_entries");
423
528
  mkdirSync(tmpDir, { recursive: true });
424
529
 
425
- const entrypoints: string[] = [];
426
- const fileToModuleName = new Map<string, string>();
530
+ const importLines = (pkgs: { pkg: { name: string } }[]): string =>
531
+ pkgs.map((m) => `import "${m.pkg.name}";`).join("\n");
532
+
533
+ const initialEntry = join(tmpDir, "initial.ts");
534
+ writeFileSync(
535
+ initialEntry,
536
+ `${importLines(publicMembers)}\nexport { startApp } from "@arcote.tech/platform";\n`,
537
+ );
427
538
 
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);
539
+ const entryPaths: string[] = [initialEntry];
540
+ const groupModuleMap = new Map<string, string[]>();
541
+ for (const g of protectedGroups) {
542
+ const entry = join(tmpDir, `${g.name}.ts`);
543
+ writeFileSync(entry, `${importLines(g.members)}\n`);
544
+ entryPaths.push(entry);
545
+ groupModuleMap.set(g.name, g.members.map((m) => m.moduleName));
433
546
  }
434
547
 
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
- };
548
+ // ---------------------------------------------------------------------
549
+ // Temporarily flip `"sideEffects"` on every workspace package's
550
+ // package.json from `false` to `true` for the duration of the build.
551
+ //
552
+ // Why: Arc modules auto-register via top-level `module(...).build()` —
553
+ // a real side effect. But packages ship with `"sideEffects": false` (a
554
+ // good ESM-library default), which makes Bun.build:
555
+ // (1) tree-shake side-effect-only imports → module().build() never runs,
556
+ // (2) freely duplicate the module across entry chunks → context
557
+ // singletons (`createContext`) get cloned, breaking provider lookup.
558
+ //
559
+ // Bun has no plugin hook to override package.json `sideEffects` and
560
+ // `build.resolve()` is not implemented (Bun#2771), so the cleanest
561
+ // reliable lever is a tiny on-disk patch with guaranteed restore.
562
+ // ---------------------------------------------------------------------
563
+ const allMemberPkgs = new Map<string, WorkspacePackage>();
564
+ for (const m of publicMembers) allMemberPkgs.set(m.pkg.name, m.pkg);
565
+ for (const g of protectedGroups)
566
+ for (const m of g.members) allMemberPkgs.set(m.pkg.name, m.pkg);
567
+
568
+ const patchedPkgJsons: { path: string; original: string }[] = [];
569
+ for (const pkg of allMemberPkgs.values()) {
570
+ const pkgJsonPath = join(pkg.path, "package.json");
571
+ if (!existsSync(pkgJsonPath)) continue;
572
+ const original = readFileSync(pkgJsonPath, "utf-8");
573
+ const parsed = JSON.parse(original);
574
+ if (parsed.sideEffects === true) continue; // already correct
575
+ parsed.sideEffects = true;
576
+ writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n");
577
+ patchedPkgJsons.push({ path: pkgJsonPath, original });
578
+ }
443
579
 
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
- });
580
+ let result;
581
+ try {
582
+ result = await Bun.build({
583
+ entrypoints: entryPaths,
584
+ outdir: outDir,
585
+ // splitting:true is the whole point: shared deps (workspace context,
586
+ // framework, lucide, etc.) land in chunk-<hash>.js, referenced by both
587
+ // initial and token-group entries. One instance, no provider duplication.
588
+ splitting: true,
589
+ format: "esm",
590
+ target: "browser",
591
+ // No externals. Framework peers (react, @arcote.tech/*) get bundled and
592
+ // deduped into shared chunks. No importmap needed in the HTML.
593
+ external: [],
594
+ plugins: [
595
+ singleReactPlugin(rootDir),
596
+ jsxDevShimPlugin(),
597
+ i18nExtractPlugin(i18nCollector, rootDir),
598
+ ],
599
+ naming: "[name].[ext]",
600
+ define: {
601
+ ONLY_SERVER: "false",
602
+ ONLY_BROWSER: "true",
603
+ ONLY_CLIENT: "true",
604
+ // CRITICAL: React's index.js does `if (process.env.NODE_ENV === 'production') require('./cjs/react.production.js') else require('./cjs/react.development.js')`.
605
+ // Without inlining NODE_ENV at build time Bun bundles BOTH branches → TWO ReactSharedInternals → multi-instance "Invalid hook call" crash.
606
+ "process.env.NODE_ENV": '"production"',
607
+ },
608
+ });
609
+ } finally {
610
+ // Always restore — a crash here MUST NOT leave the user with mutated
611
+ // workspace package.json files.
612
+ for (const p of patchedPkgJsons) writeFileSync(p.path, p.original);
613
+ }
614
+
615
+ rmSync(tmpDir, { recursive: true, force: true });
463
616
 
464
617
  if (!result.success) {
465
- console.error(`Chunk "${chunk}" build failed:`);
466
618
  for (const log of result.logs) console.error(log);
467
- throw new Error(`Module chunk build failed: ${chunk}`);
619
+ throw new Error("Browser app build failed");
468
620
  }
469
621
 
470
- rmSync(tmpDir, { recursive: true, force: true });
622
+ // Bun's `[name]` in naming preserves entry filename without hash. We add
623
+ // content hashes ourselves so identical bytes produce identical URLs across
624
+ // deploys (good for browser caching). Shared chunks already have a hash in
625
+ // their name (Bun auto-emits `chunk-<hash>.js`); we leave those alone.
626
+ let initialFile = "";
627
+ let initialHash = "";
628
+ const groups: Record<string, BrowserGroupEntry> = {};
629
+ const sharedChunks: string[] = [];
630
+
631
+ for (const out of result.outputs) {
632
+ const name = basename(out.path);
633
+ if (out.kind === "entry-point") {
634
+ const bytes = readFileSync(out.path);
635
+ const hash = sha256Hex(bytes).slice(0, 16);
636
+ const stem = name.replace(/\.js$/, "");
637
+ const finalName = `${stem}.${hash}.js`;
638
+ const finalPath = join(outDir, finalName);
639
+ rmSync(finalPath, { force: true });
640
+ writeFileSync(finalPath, bytes);
641
+ rmSync(out.path, { force: true });
642
+
643
+ if (stem === "initial") {
644
+ initialFile = finalName;
645
+ initialHash = hash;
646
+ } else {
647
+ groups[stem] = {
648
+ file: finalName,
649
+ hash,
650
+ modules: groupModuleMap.get(stem) ?? [],
651
+ };
652
+ }
653
+ } else if (out.kind === "chunk") {
654
+ sharedChunks.push(name);
655
+ }
656
+ }
471
657
 
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
- });
658
+ if (!initialFile) {
659
+ throw new Error("Browser app build: initial entry not found in outputs");
660
+ }
661
+
662
+ const manifest: BrowserAppResult = {
663
+ initial: { file: initialFile, hash: initialHash },
664
+ groups,
665
+ sharedChunks,
666
+ cached: false,
667
+ };
668
+
669
+ updateCache(cache, unitId, inputHash, {
670
+ outputHashes: { _manifest: JSON.stringify(manifest) },
671
+ });
488
672
 
489
- updateCache(cache, unitId, inputHash, { outputHashes });
490
- return { modules, cached: false };
673
+ return manifest;
491
674
  }
492
675
 
493
676
  // ---------------------------------------------------------------------------
@@ -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
  }