@arcote.tech/arc-cli 0.6.2 → 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.
Files changed (37) hide show
  1. package/dist/index.js +1696 -1663
  2. package/package.json +7 -7
  3. package/src/builder/access-extractor.ts +64 -46
  4. package/src/builder/build-cache.ts +3 -1
  5. package/src/builder/chunk-planner.ts +107 -0
  6. package/src/builder/dependency-collector.ts +83 -41
  7. package/src/builder/framework-peers.ts +81 -0
  8. package/src/builder/module-builder.ts +322 -106
  9. package/src/commands/platform-build.ts +2 -1
  10. package/src/commands/platform-deploy.ts +121 -64
  11. package/src/commands/platform-dev.ts +11 -100
  12. package/src/commands/platform-start.ts +4 -90
  13. package/src/deploy/ansible.ts +23 -3
  14. package/src/deploy/assets/ansible/site.yml +23 -7
  15. package/src/deploy/assets.ts +23 -7
  16. package/src/deploy/bootstrap.ts +270 -10
  17. package/src/deploy/caddyfile.ts +19 -23
  18. package/src/deploy/compose.ts +44 -27
  19. package/src/deploy/config.ts +67 -3
  20. package/src/deploy/deploy-env.ts +129 -0
  21. package/src/deploy/env-file.ts +103 -0
  22. package/src/deploy/htpasswd.ts +28 -0
  23. package/src/deploy/image-template.ts +74 -0
  24. package/src/deploy/image.ts +243 -0
  25. package/src/deploy/registry.ts +79 -0
  26. package/src/deploy/ssh.ts +52 -122
  27. package/src/deploy/survey.ts +64 -0
  28. package/src/index.ts +20 -13
  29. package/src/platform/server.ts +119 -94
  30. package/src/platform/shared.ts +139 -292
  31. package/src/platform/startup.ts +159 -0
  32. package/runtime/Dockerfile +0 -29
  33. package/runtime/build-and-push.sh +0 -23
  34. package/runtime/entrypoint.sh +0 -58
  35. package/src/commands/build-shell.ts +0 -152
  36. package/src/deploy/remote-sync.ts +0 -321
  37. package/src/platform/deploy-api.ts +0 -400
@@ -17,6 +17,8 @@ import {
17
17
  updateCache,
18
18
  type BuildCache,
19
19
  } from "./build-cache";
20
+ import type { ChunkPlan } from "./chunk-planner";
21
+ import { SHELL_EXTERNALS } from "./framework-peers";
20
22
  import {
21
23
  readInstalledVersion,
22
24
  sha256Hex,
@@ -28,8 +30,92 @@ import { pAll } from "./parallel";
28
30
 
29
31
  /** Re-export for internal CLI consumers (avoid direct platform dependency in consumers). */
30
32
  export type { BuildManifest, ModuleDescriptor };
31
- /** Re-export hash helpers for backward compatibility (deploy/remote-sync uses sha256OfFiles). */
33
+ /** Re-export hash helpers for backward compatibility (deploy code uses sha256OfFiles). */
32
34
  export { sha256Hex, sha256OfFiles };
35
+ /** Re-export externals so callers (shell builder) stay decoupled from framework-peers. */
36
+ export { SHELL_EXTERNALS };
37
+
38
+ /**
39
+ * Bun's automatic JSX transform emits `import { jsxDEV } from "react/jsx-dev-runtime"`
40
+ * regardless of NODE_ENV. React's production module exports `jsxDEV` as undefined
41
+ * (it's a debug-only symbol). At runtime in a production container this crashes
42
+ * the moment a JSX call is reached. The plugin redirects `react/jsx-dev-runtime`
43
+ * imports to a synthetic module that re-exports `jsx` as `jsxDEV` from the
44
+ * production jsx-runtime — semantically equivalent at runtime, no debug data.
45
+ *
46
+ * Applied to BOTH browser and server bundles. Server bundles may import
47
+ * JSX-typed Trans/translation components from workspace deps; without the
48
+ * shim, loadServerContext fails on any server bundle that touches them.
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
+
100
+ function jsxDevShimPlugin(): import("bun").BunPlugin {
101
+ return {
102
+ name: "jsx-dev-runtime-shim",
103
+ setup(build) {
104
+ build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({
105
+ path: "react-jsx-dev-runtime-shim",
106
+ namespace: "jsx-dev-shim",
107
+ }));
108
+ build.onLoad({ filter: /.*/, namespace: "jsx-dev-shim" }, () => ({
109
+ contents: `import { jsx, jsxs, Fragment } from "react/jsx-runtime";
110
+ export const jsxDEV = jsx;
111
+ export const jsxsDEV = jsxs;
112
+ export { Fragment };
113
+ `,
114
+ loader: "ts",
115
+ }));
116
+ },
117
+ };
118
+ }
33
119
 
34
120
  /** Clients that a context package is built for. */
35
121
  const CONTEXT_CLIENTS = [
@@ -37,21 +123,6 @@ const CONTEXT_CLIENTS = [
37
123
  { name: "browser", target: "browser" as const, defines: { ONLY_SERVER: "false", ONLY_BROWSER: "true", ONLY_CLIENT: "true" } },
38
124
  ];
39
125
 
40
- /** Packages that shell provides — modules import them but don't bundle them. */
41
- export const SHELL_EXTERNALS = [
42
- "react",
43
- "react-dom",
44
- "react/jsx-runtime",
45
- "react/jsx-dev-runtime",
46
- "@arcote.tech/arc",
47
- "@arcote.tech/arc-ds",
48
- "@arcote.tech/arc-react",
49
- "@arcote.tech/arc-auth",
50
- "@arcote.tech/arc-utils",
51
- "@arcote.tech/arc-workspace",
52
- "@arcote.tech/platform",
53
- ];
54
-
55
126
  export interface WorkspacePackage {
56
127
  name: string;
57
128
  path: string;
@@ -206,9 +277,26 @@ async function buildContextClient(
206
277
 
207
278
  console.log(` building: ${pkg.name} (${client.name})`);
208
279
 
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).
209
297
  const peerDeps = Object.keys(pkg.packageJson.peerDependencies ?? {});
210
- const deps = Object.keys(pkg.packageJson.dependencies ?? {});
211
- const externals = [...peerDeps, ...deps];
298
+ const allDeps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
299
+ const externals = [...peerDeps, ...Object.keys(allDeps)];
212
300
 
213
301
  const result = await Bun.build({
214
302
  entrypoints: [pkg.entrypoint],
@@ -217,6 +305,7 @@ async function buildContextClient(
217
305
  format: "esm",
218
306
  naming: "index.[ext]",
219
307
  external: externals,
308
+ plugins: [jsxDevShimPlugin()],
220
309
  define: client.defines,
221
310
  });
222
311
 
@@ -278,140 +367,267 @@ export async function buildContextPackages(
278
367
  return { declarationErrors };
279
368
  }
280
369
 
370
+
281
371
  // ---------------------------------------------------------------------------
282
- // Modules bundlesingle Bun.build of all wrapper re-exports
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.)
283
394
  // ---------------------------------------------------------------------------
284
395
 
285
- interface ModulesBundleResult {
286
- modules: ModuleDescriptor[];
287
- 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[];
288
402
  }
289
403
 
290
- /**
291
- * Bundle all workspace packages into ESM chunks for the platform shell.
292
- * Cached as a single unitany source change in any package invalidates.
293
- */
294
- export async function buildModulesBundle(
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(
295
414
  rootDir: string,
296
415
  outDir: string,
297
- packages: WorkspacePackage[],
416
+ plan: ChunkPlan,
298
417
  cache: BuildCache,
299
418
  noCache: boolean,
300
- ): Promise<ModulesBundleResult> {
419
+ i18nCollector: Map<string, Set<string>>,
420
+ ): Promise<BrowserAppResult> {
301
421
  mkdirSync(outDir, { recursive: true });
302
422
 
303
- const unitId = "modules-bundle";
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);
304
428
 
305
- // Per-package source hash — gives us deterministic input identity even though
306
- // we run a single Bun.build for the whole bundle (chunk sharing wins).
307
- const pkgHashes = packages.map((p) => ({
308
- name: p.name,
309
- safeName: basename(p.path),
310
- srcHash: pkgSourceHash(p),
311
- }));
429
+ const unitId = "browser-app";
312
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
+ }
313
442
  const inputHash = sha256OfJson({
314
- pkgHashes,
315
- externals: SHELL_EXTERNALS,
443
+ members: allMembers,
444
+ groups: [
445
+ "initial",
446
+ ...protectedGroups.map((g) => g.name).sort(),
447
+ ],
316
448
  define: { ONLY_SERVER: "false", ONLY_BROWSER: "true", ONLY_CLIENT: "true" },
317
449
  });
318
450
 
319
- // Cache hit → reconstruct module descriptors from existing files in outDir.
320
451
  if (!noCache && isCacheHit(cache, unitId, inputHash)) {
321
- const existing = cache.units[unitId]?.outputHashes ?? {};
322
- const modules: ModuleDescriptor[] = [];
323
- for (const { safeName, name } of pkgHashes) {
324
- const file = `${safeName}.js`;
325
- const filePath = join(outDir, file);
326
- if (!existsSync(filePath)) {
327
- // Output missing despite hash match — fall through to rebuild.
328
- console.log(` rebuilding modules-bundle: output ${file} missing`);
329
- return await actuallyBuild();
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
330
467
  }
331
- modules.push({ file, name, hash: existing[safeName] ?? sha256Hex(readFileSync(filePath)) });
332
468
  }
333
- console.log(` ✓ cached: modules-bundle (${modules.length} module(s))`);
334
- return { modules, cached: true };
335
469
  }
336
470
 
337
- return await actuallyBuild();
338
-
339
- async function actuallyBuild(): Promise<ModulesBundleResult> {
340
- console.log(` building: modules-bundle (${packages.length} package(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
+ );
341
476
 
342
- const tmpDir = join(outDir, "_entries");
343
- mkdirSync(tmpDir, { recursive: true });
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
+ }
344
483
 
345
- const entrypoints: string[] = [];
346
- const fileToName = new Map<string, string>();
484
+ const tmpDir = join(outDir, "_entries");
485
+ mkdirSync(tmpDir, { recursive: true });
347
486
 
348
- for (const pkg of packages) {
349
- const safeName = basename(pkg.path);
350
- const moduleName = pkg.name.includes("/") ? pkg.name.split("/").pop()! : pkg.name;
351
- fileToName.set(safeName, moduleName);
487
+ const importLines = (pkgs: { pkg: { name: string } }[]): string =>
488
+ pkgs.map((m) => `import "${m.pkg.name}";`).join("\n");
352
489
 
353
- const wrapperFile = join(tmpDir, `${safeName}.ts`);
354
- writeFileSync(wrapperFile, `export * from "${pkg.name}";\n`);
355
- entrypoints.push(wrapperFile);
356
- }
490
+ const initialEntry = join(tmpDir, "initial.ts");
491
+ writeFileSync(
492
+ initialEntry,
493
+ `${importLines(publicMembers)}\nexport { startApp } from "@arcote.tech/platform";\n`,
494
+ );
357
495
 
358
- const i18nCollector = new Map<string, Set<string>>();
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));
503
+ }
359
504
 
360
- const arcExternalPlugin: import("bun").BunPlugin = {
361
- name: "arc-external",
362
- setup(build) {
363
- build.onResolve({ filter: /^@arcote\.tech\// }, (args) => {
364
- return { path: args.path, external: true };
365
- });
366
- },
367
- };
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
+ }
368
536
 
369
- const result = await Bun.build({
370
- entrypoints,
537
+ let result;
538
+ try {
539
+ result = await Bun.build({
540
+ entrypoints: entryPaths,
371
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.
372
545
  splitting: true,
373
546
  format: "esm",
374
547
  target: "browser",
375
- external: SHELL_EXTERNALS,
376
- plugins: [arcExternalPlugin, i18nExtractPlugin(i18nCollector, rootDir)],
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
+ ],
377
556
  naming: "[name].[ext]",
378
557
  define: {
379
558
  ONLY_SERVER: "false",
380
559
  ONLY_BROWSER: "true",
381
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"',
382
564
  },
383
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
+ }
384
571
 
385
- if (!result.success) {
386
- console.error("Modules bundle build failed:");
387
- for (const log of result.logs) console.error(log);
388
- throw new Error("Module build failed");
389
- }
572
+ rmSync(tmpDir, { recursive: true, force: true });
390
573
 
391
- // i18n extraction → write/update .po files (compileAllCatalogs handles .json).
392
- await finalizeTranslations(rootDir, join(outDir, ".."), i18nCollector);
393
-
394
- rmSync(tmpDir, { recursive: true, force: true });
395
-
396
- const outputHashes: Record<string, string> = {};
397
- const modules: ModuleDescriptor[] = result.outputs
398
- .filter((o) => o.kind === "entry-point")
399
- .map((o) => {
400
- const file = basename(o.path);
401
- const safeName = file.replace(/\.js$/, "");
402
- const bytes = readFileSync(o.path);
403
- const hash = sha256Hex(bytes);
404
- outputHashes[safeName] = hash;
405
- return {
406
- file,
407
- name: fileToName.get(safeName) ?? safeName,
574
+ if (!result.success) {
575
+ for (const log of result.logs) console.error(log);
576
+ throw new Error("Browser app build failed");
577
+ }
578
+
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,
408
606
  hash,
607
+ modules: groupModuleMap.get(stem) ?? [],
409
608
  };
410
- });
609
+ }
610
+ } else if (out.kind === "chunk") {
611
+ sharedChunks.push(name);
612
+ }
613
+ }
411
614
 
412
- updateCache(cache, unitId, inputHash, { outputHashes });
413
- return { modules, cached: false };
615
+ if (!initialFile) {
616
+ throw new Error("Browser app build: initial entry not found in outputs");
414
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
+ });
629
+
630
+ return manifest;
415
631
  }
416
632
 
417
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
  }