@arcote.tech/arc-cli 0.8.1 → 0.8.3

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.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -12,13 +12,13 @@
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 --external @arcote.tech/arc-map --external '@opentelemetry/*' && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.8.1",
16
- "@arcote.tech/arc-ds": "^0.8.1",
17
- "@arcote.tech/arc-react": "^0.8.1",
18
- "@arcote.tech/arc-host": "^0.8.1",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.8.1",
20
- "@arcote.tech/arc-adapter-db-postgres": "^0.8.1",
21
- "@arcote.tech/arc-otel": "^0.8.1",
15
+ "@arcote.tech/arc": "^0.8.3",
16
+ "@arcote.tech/arc-ds": "^0.8.3",
17
+ "@arcote.tech/arc-react": "^0.8.3",
18
+ "@arcote.tech/arc-host": "^0.8.3",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.8.3",
20
+ "@arcote.tech/arc-adapter-db-postgres": "^0.8.3",
21
+ "@arcote.tech/arc-otel": "^0.8.3",
22
22
  "@opentelemetry/api": "^1.9.0",
23
23
  "@opentelemetry/api-logs": "^0.57.0",
24
24
  "@opentelemetry/core": "^1.30.0",
@@ -31,8 +31,8 @@
31
31
  "@opentelemetry/sdk-trace-base": "^1.30.0",
32
32
  "@opentelemetry/sdk-trace-node": "^1.30.0",
33
33
  "@opentelemetry/semantic-conventions": "^1.27.0",
34
- "@arcote.tech/platform": "^0.8.1",
35
- "@arcote.tech/arc-map": "^0.8.1",
34
+ "@arcote.tech/platform": "^0.8.3",
35
+ "@arcote.tech/arc-map": "^0.8.3",
36
36
  "@clack/prompts": "^0.9.0",
37
37
  "commander": "^11.1.0",
38
38
  "chokidar": "^3.5.3",
@@ -120,6 +120,20 @@ function collectModuleNames(dir: string, acc: Set<string>): void {
120
120
  }
121
121
  }
122
122
 
123
+ /**
124
+ * Czy pakiet DEKLARUJE moduł Arc (`module("...")` w źródłach). Tylko takie
125
+ * pakiety są „członkami" wchodzącymi do initial/grup — czysta biblioteka bez
126
+ * modułu (np. pakiet z szablonami maili) NIE może być side-effect-importowana
127
+ * przez initial, bo z `sideEffects:true` wciągałaby całe swoje drzewo
128
+ * zależności do pierwszego wczytania (patrz: @react-email w NDT — 1,4 MB
129
+ * stosu maili w bundlu klienta przez samo `import "@ndt/email"`).
130
+ */
131
+ export function packageDeclaresModule(pkg: WorkspacePackage): boolean {
132
+ const names = new Set<string>();
133
+ collectModuleNames(join(pkg.path, "src"), names);
134
+ return names.size > 0;
135
+ }
136
+
123
137
  /**
124
138
  * Assert every workspace package declares at most one `module()`. Throws a
125
139
  * build error listing offenders, with the fix (split each extra module into
@@ -239,6 +239,51 @@ function workspaceSourcePlugin(srcByName: Map<string, string>): import("bun").Bu
239
239
  };
240
240
  }
241
241
 
242
+ // Builtiny node, które w przeglądarce NIGDY nie mają sensu (I/O, krypto,
243
+ // procesy, sieć). Domyślnie Bun polyfilluje je ciężkimi shimami (crypto →
244
+ // crypto-browserify ~360 KB gzip) — a że kod serwerowy (webhooki, hashowanie,
245
+ // szyfrowanie OAuth) importuje je statycznie na górze modułu, polyfill ląduje
246
+ // w bundlu klienta, mimo że ten kod nigdy nie biegnie w przeglądarce.
247
+ //
248
+ // CELOWO NIE stubujemy „przeglądarkowo-bezpiecznych" builtinów (buffer, util,
249
+ // events, path, url, string_decoder, querystring, assert, process) — te bywają
250
+ // realnie używane po stronie klienta i mają lekkie, działające polyfille.
251
+ const SERVER_ONLY_NODE_BUILTINS = new Set([
252
+ "crypto", "stream", "zlib", "fs", "fs/promises", "net", "tls", "http",
253
+ "https", "http2", "dns", "dgram", "child_process", "worker_threads",
254
+ "cluster", "os", "v8", "vm", "readline", "repl", "tty", "perf_hooks",
255
+ "inspector", "async_hooks", "diagnostics_channel", "module", "constants",
256
+ ]);
257
+
258
+ /**
259
+ * Zastępuje serwerowe builtiny node (SERVER_ONLY_NODE_BUILTINS) rzucającym
260
+ * stubem w buildzie przeglądarki — zamiast polyfilla. Dowolny named/default
261
+ * import przechodzi (interop CJS-Proxy), ale UŻYCIE na kliencie rzuca jasny
262
+ * błąd „server-only". Efekt: server-only kod importujący `node:crypto` itd.
263
+ * nie wciąga polyfilla do bundla klienta (NDT: −~360 KB gzip).
264
+ */
265
+ function nodeServerBuiltinStubPlugin(): import("bun").BunPlugin {
266
+ return {
267
+ name: "node-server-builtin-stub",
268
+ setup(build) {
269
+ build.onResolve({ filter: /^(node:)?[a-z_/]+$/ }, (args) => {
270
+ const bare = args.path.replace(/^node:/, "");
271
+ if (!SERVER_ONLY_NODE_BUILTINS.has(bare)) return undefined;
272
+ return { path: args.path, namespace: "node-server-stub" };
273
+ });
274
+ build.onLoad({ filter: /.*/, namespace: "node-server-stub" }, (args) => ({
275
+ // CJS Proxy: `import { createHash } from "node:crypto"` czyta
276
+ // `module.exports.createHash` przez interop → getter rzuca dopiero przy
277
+ // realnym użyciu. `__esModule:false` żeby interop potraktował to jak CJS.
278
+ contents:
279
+ `const e=()=>{throw new Error("Node builtin '${args.path}' jest server-only — niedostępny w przeglądarce");};` +
280
+ `module.exports=new Proxy({},{get:(_,p)=>p==="__esModule"?false:e});`,
281
+ loader: "js",
282
+ }));
283
+ },
284
+ };
285
+ }
286
+
242
287
  function jsxDevShimPlugin(): import("bun").BunPlugin {
243
288
  return {
244
289
  name: "jsx-dev-runtime-shim",
@@ -456,7 +501,13 @@ async function buildContextClient(
456
501
  format: "esm",
457
502
  naming: "index.[ext]",
458
503
  external: externals,
459
- plugins: [jsxDevShimPlugin()],
504
+ plugins: [
505
+ jsxDevShimPlugin(),
506
+ // Browser dist: serwerowe builtiny node → rzucający stub zamiast ciężkiego
507
+ // polyfilla (np. @ndt/social szyfruje tokeny OAuth przez node:crypto —
508
+ // server-only, ale ląduje w dist/browser i wciągało crypto-browserify).
509
+ ...(client.target === "browser" ? [nodeServerBuiltinStubPlugin()] : []),
510
+ ],
460
511
  define: client.defines,
461
512
  });
462
513
 
@@ -677,9 +728,13 @@ export async function buildServerApp(
677
728
 
678
729
  let result;
679
730
  try {
731
+ // Bez `outdir` — artefakty w pamięci, pliki piszemy sami (niżej).
732
+ // Równoległy builder tego samego workspace'u (dev watcher) potrafi
733
+ // wipe'ować serverDir w trakcie zapisu outputów Buna (losowe `ENOENT:
734
+ // writing sourcemap for chunk` wywalało cały build). Ten sam wzorzec
735
+ // ochrony co w buildBrowserApp.
680
736
  result = await Bun.build({
681
737
  entrypoints: [entrySrc],
682
- outdir: serverDir,
683
738
  target: "bun",
684
739
  format: "esm",
685
740
  // splitting:true is the whole point — it makes Bun emit eager top-level
@@ -720,6 +775,15 @@ export async function buildServerApp(
720
775
  );
721
776
  }
722
777
 
778
+ // Zapis wszystkich artefaktów (entry + chunki + sourcemapy) własną ręką —
779
+ // patrz komentarz przy Bun.build wyżej.
780
+ for (const out of result.outputs) {
781
+ writeFileSync(
782
+ join(serverDir, basename(out.path)),
783
+ new Uint8Array(await out.arrayBuffer()),
784
+ );
785
+ }
786
+
723
787
  // Third-party externals only — framework peers carry their own resolution in
724
788
  // collectFrameworkDeps, so drop them here to avoid double-sourcing versions.
725
789
  const externals: ServerExternal[] = [...recorded]
@@ -787,6 +851,194 @@ export interface BrowserAppResult {
787
851
  readonly cached: boolean;
788
852
  }
789
853
 
854
+ /** Output builda przeglądarki trzymany w pamięci — bajty prosto z artefaktu
855
+ * Bun.build, zapisywane na dysk przez NAS (patrz komentarz przy runBuild). */
856
+ interface BrowserOutFile {
857
+ kind: "entry-point" | "chunk" | string;
858
+ name: string;
859
+ bytes: Uint8Array;
860
+ }
861
+
862
+ // ---------------------------------------------------------------------------
863
+ // Build stats (ARC_BUILD_STATS=1) — rozbicie każdego chunku na biblioteki.
864
+ //
865
+ // Skąd dane: build w trybie stats emituje sourcemapy (`sourcemap: "external"`),
866
+ // których `sources[]` niosą ścieżki KAŻDEGO pliku źródłowego wmieszanego do
867
+ // chunku, a `sourcesContent[]` jego treść. Przypisujemy każdy plik do pakietu
868
+ // (node_modules → zewnętrzny, packages/* → wewnętrzny) i sumujemy bajty źródeł
869
+ // jako wagę wkładu. To odpowiada na pytanie "co i skąd wpadło do bundla" —
870
+ // w szczególności KTÓRE wejście (initial vs grupa tokenu) ciągnie ciężką libę.
871
+ // ---------------------------------------------------------------------------
872
+
873
+ /** Czy build ma policzyć i wypisać rozbicie chunków na biblioteki. */
874
+ export function buildStatsEnabled(): boolean {
875
+ return process.env.ARC_BUILD_STATS === "1";
876
+ }
877
+
878
+ interface PkgWeight {
879
+ pkg: string;
880
+ bytes: number;
881
+ external: boolean;
882
+ }
883
+
884
+ /** Ścieżka źródła (z sourcemapy) → nazwa pakietu + czy zewnętrzny. */
885
+ function classifySource(src: string): { pkg: string; external: boolean } {
886
+ const nm = src.lastIndexOf("node_modules/");
887
+ if (nm >= 0) {
888
+ const rest = src.slice(nm + "node_modules/".length);
889
+ const parts = rest.split("/");
890
+ const pkg = parts[0].startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0];
891
+ // @ndt/* i @arcote.tech/* rozwiązane przez symlink w node_modules to
892
+ // faktycznie kod wewnętrzny/frameworkowy — nie "zewnętrzna zależność npm".
893
+ const external = !pkg.startsWith("@ndt/") && !pkg.startsWith("@arcote.tech/");
894
+ return { pkg, external };
895
+ }
896
+ // Źródło workspace (np. "packages/mailing/src/ids.ts").
897
+ const m = src.match(/(?:^|\/)packages\/([^/]+)\//);
898
+ if (m) return { pkg: `packages/${m[1]}`, external: false };
899
+ return { pkg: src.split("/").slice(0, 2).join("/") || src, external: false };
900
+ }
901
+
902
+ /** Rozbij chunk (po sourcemapie) na wagi per pakiet, malejąco. */
903
+ function chunkPkgWeights(map: {
904
+ sources?: string[];
905
+ sourcesContent?: (string | null)[];
906
+ }): PkgWeight[] {
907
+ const acc = new Map<string, PkgWeight>();
908
+ const sources = map.sources ?? [];
909
+ const contents = map.sourcesContent ?? [];
910
+ for (let i = 0; i < sources.length; i++) {
911
+ const bytes = contents[i]?.length ?? 0;
912
+ const { pkg, external } = classifySource(sources[i]);
913
+ const cur = acc.get(pkg) ?? { pkg, bytes: 0, external };
914
+ cur.bytes += bytes;
915
+ acc.set(pkg, cur);
916
+ }
917
+ return [...acc.values()].sort((a, b) => b.bytes - a.bytes);
918
+ }
919
+
920
+ const kb = (n: number) => `${Math.round(n / 1024)} KB`;
921
+
922
+ /** Usuń końcowe linie `//# debugId=` / `//# sourceMappingURL=` dopięte przez
923
+ * Bun przy `sourcemap:"external"`, tak by bajty JS == build bez sourcemap. */
924
+ function stripTrailingSourcemapComment(bytes: Uint8Array): Uint8Array {
925
+ let text = Buffer.from(bytes).toString("utf8");
926
+ text = text.replace(/\n*\/\/# (?:debugId|sourceMappingURL)=[^\n]*\s*$/, "");
927
+ if (!text.endsWith("\n")) text += "\n";
928
+ return new Uint8Array(Buffer.from(text, "utf8"));
929
+ }
930
+
931
+ /**
932
+ * Wypisz rozbicie buildu: dla każdego WEJŚCIA (initial + grupy tokenów)
933
+ * jego pierwsze wczytanie (statyczne domknięcie chunków) z wagą raw/gzip
934
+ * i najcięższymi bibliotekami, plus globalny ranking zewnętrznych libów
935
+ * z informacją, w którym chunku i pod którym wejściem lądują.
936
+ */
937
+ function reportBrowserBuildStats(opts: {
938
+ label: string;
939
+ entries: { name: string; file: string }[];
940
+ jsByName: Map<string, Uint8Array>;
941
+ mapsByJs: Map<string, { sources?: string[]; sourcesContent?: (string | null)[] }>;
942
+ }): void {
943
+ const { label, entries, jsByName, mapsByJs } = opts;
944
+
945
+ // Graf statycznych importów (bare `import"..."` / `from"..."`, NIE `import(...)`).
946
+ const staticEdge =
947
+ /(?:^|[;}\s])import\s*["'](\.\/[^"']+\.js)["']|from\s*["'](\.\/[^"']+\.js)["']/g;
948
+ const staticImports = (name: string): string[] => {
949
+ const bytes = jsByName.get(name);
950
+ if (!bytes) return [];
951
+ const s = Buffer.from(bytes).toString("utf8");
952
+ const out: string[] = [];
953
+ let m: RegExpExecArray | null;
954
+ staticEdge.lastIndex = 0;
955
+ while ((m = staticEdge.exec(s))) out.push(basename(m[1] ?? m[2]!));
956
+ return out;
957
+ };
958
+ const closure = (start: string): Set<string> => {
959
+ const seen = new Set<string>();
960
+ const stack = [start];
961
+ while (stack.length) {
962
+ const f = stack.pop()!;
963
+ if (seen.has(f) || !jsByName.has(f)) continue;
964
+ seen.add(f);
965
+ stack.push(...staticImports(f));
966
+ }
967
+ return seen;
968
+ };
969
+
970
+ const gz = (bytes: Uint8Array) =>
971
+ Bun.gzipSync(bytes as Uint8Array<ArrayBuffer>, { level: 6 }).length;
972
+
973
+ console.log(`\n━━━━━ Build stats: ${label} ━━━━━`);
974
+ const globalExt = new Map<string, { bytes: number; chunks: Set<string>; entries: Set<string> }>();
975
+
976
+ for (const entry of entries) {
977
+ const files = closure(entry.file);
978
+ let raw = 0;
979
+ let gzip = 0;
980
+ const pkgAcc = new Map<string, PkgWeight>();
981
+ for (const f of files) {
982
+ const bytes = jsByName.get(f)!;
983
+ raw += bytes.length;
984
+ gzip += gz(bytes);
985
+ const map = mapsByJs.get(f);
986
+ if (!map) continue;
987
+ for (const w of chunkPkgWeights(map)) {
988
+ const cur = pkgAcc.get(w.pkg) ?? { pkg: w.pkg, bytes: 0, external: w.external };
989
+ cur.bytes += w.bytes;
990
+ pkgAcc.set(w.pkg, cur);
991
+ if (w.external) {
992
+ const g = globalExt.get(w.pkg) ?? { bytes: 0, chunks: new Set(), entries: new Set() };
993
+ g.bytes = Math.max(g.bytes, w.bytes);
994
+ g.chunks.add(f);
995
+ g.entries.add(entry.name);
996
+ globalExt.set(w.pkg, g);
997
+ }
998
+ }
999
+ }
1000
+ const top = [...pkgAcc.values()].sort((a, b) => b.bytes - a.bytes).slice(0, 10);
1001
+ console.log(
1002
+ `\n▸ ${entry.name}: pierwsze wczytanie ${kb(raw)} raw / ${kb(gzip)} gzip (${files.size} plików)`,
1003
+ );
1004
+ for (const w of top) {
1005
+ const tag = w.external ? "ext" : "wewn";
1006
+ console.log(` ${kb(w.bytes).padStart(8)} [${tag}] ${w.pkg}`);
1007
+ }
1008
+ }
1009
+
1010
+ const heaviest = [...globalExt.entries()].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 15);
1011
+ if (heaviest.length) {
1012
+ console.log(`\n▸ Najcięższe biblioteki ZEWNĘTRZNE (źródła) i gdzie lądują:`);
1013
+ for (const [pkg, g] of heaviest) {
1014
+ console.log(
1015
+ ` ${kb(g.bytes).padStart(8)} ${pkg} → ${[...g.entries].join(", ")}`,
1016
+ );
1017
+ }
1018
+ }
1019
+ console.log(`\n━━━━━ koniec stats: ${label} ━━━━━\n`);
1020
+ }
1021
+
1022
+ /**
1023
+ * Heurystyka "czy output wygląda na zminifikowany": w plikach ≥ 16 KB liczymy
1024
+ * średnią długość linii pierwszych 64 KB. Kod niezminifikowany z Bun to
1025
+ * ~35-45 B/linię; zminifikowany to pojedyncze, bardzo długie linie (tysiące
1026
+ * bajtów). Próg 200 B/linię zostawia szeroki margines po obu stronach.
1027
+ * Małe pliki pomijamy — trywialny entry (np. federowany initial, ~150 B)
1028
+ * ma za mało treści, żeby heurystyka miała sens.
1029
+ */
1030
+ function looksMinified(files: readonly BrowserOutFile[]): boolean {
1031
+ for (const f of files) {
1032
+ if (!f.name.endsWith(".js") || f.bytes.length < 16 * 1024) continue;
1033
+ const sample = f.bytes.subarray(0, 64 * 1024);
1034
+ let newlines = 0;
1035
+ for (const b of sample) if (b === 10) newlines++;
1036
+ const avgLineLen = sample.length / Math.max(1, newlines);
1037
+ if (avgLineLen < 200) return false;
1038
+ }
1039
+ return true;
1040
+ }
1041
+
790
1042
  export async function buildBrowserApp(
791
1043
  rootDir: string,
792
1044
  outDir: string,
@@ -810,10 +1062,19 @@ export async function buildBrowserApp(
810
1062
  * — React inline, bez lazy-race. Patrz peer-shell.ts.
811
1063
  */
812
1064
  exposeRuntime?: boolean;
1065
+ /**
1066
+ * Minifikacja bundli przeglądarki (domyślnie true). Poza rozmiarem
1067
+ * kluczowy jest jej `syntax`-pass: składa `ONLY_SERVER && (...)` do
1068
+ * martwego kodu i usuwa go, dzięki czemu tree-shaking wycina serwerowe
1069
+ * zależności (np. szablony maili, klientów płatności) z bundla klienta.
1070
+ * Dev wyłącza (czytelny kod, szybszy rebuild watchera).
1071
+ */
1072
+ minify?: boolean;
813
1073
  },
814
1074
  ): Promise<BrowserAppResult> {
815
1075
  const federated = opts?.federated ?? false;
816
1076
  const exposeRuntime = opts?.exposeRuntime ?? false;
1077
+ const minify = opts?.minify ?? true;
817
1078
  mkdirSync(outDir, { recursive: true });
818
1079
 
819
1080
  const publicMembers = plan.groups.get("public") ?? [];
@@ -844,6 +1105,7 @@ export async function buildBrowserApp(
844
1105
  define: { ONLY_SERVER: "false", ONLY_BROWSER: "true", ONLY_CLIENT: "true" },
845
1106
  federated,
846
1107
  exposeRuntime,
1108
+ minify,
847
1109
  // Zmiana listy peers (SHELL_EXTERNALS) musi unieważnić build federowany —
848
1110
  // stary bundle miałby zbundlowany peer, który teraz jest external.
849
1111
  externals: federated ? [...SHELL_EXTERNALS] : [],
@@ -852,7 +1114,9 @@ export async function buildBrowserApp(
852
1114
  builderRev: 3,
853
1115
  });
854
1116
 
855
- if (!noCache && isCacheHit(cache, unitId, inputHash)) {
1117
+ // Tryb stats zawsze buduje od nowa — cache-hit pominąłby analizę i nie
1118
+ // byłoby czego wypisać.
1119
+ if (!noCache && !buildStatsEnabled() && isCacheHit(cache, unitId, inputHash)) {
856
1120
  const cached = cache.units[unitId]?.outputHashes;
857
1121
  if (cached?._manifest) {
858
1122
  try {
@@ -885,30 +1149,41 @@ export async function buildBrowserApp(
885
1149
  }
886
1150
  }
887
1151
 
888
- const tmpDir = join(outDir, "_entries");
889
- mkdirSync(tmpDir, { recursive: true });
1152
+ // Entries POZA outDir (osobny katalog per unitId): równoległy builder tego
1153
+ // samego workspace'u (dev watcher) wipe'uje i sprząta browser/_entries,
1154
+ // wycinając nam pliki wejściowe spod Bun.build (ENOENT na wszystkich
1155
+ // entry). Dedykowana ścieżka zawęża pole rażenia; resztę domyka retry
1156
+ // (writeEntryFiles przed każdą próbą).
1157
+ const tmpDir = join(rootDir, ".arc", "tmp", `entries-${unitId}`);
890
1158
 
891
1159
  const importLines = (pkgs: { pkg: { name: string } }[]): string =>
892
1160
  pkgs.map((m) => `import "${m.pkg.name}";`).join("\n");
893
1161
 
894
1162
  const initialEntry = join(tmpDir, "initial.ts");
895
- writeFileSync(
896
- initialEntry,
897
- // Prelude ekspozycji runtime MUSI być PIERWSZY (przed importami modułów) —
898
- // globalThis.__ARC_RT gotowe zanim cokolwiek innego się załaduje.
899
- (exposeRuntime ? runtimeExposePrelude() + "\n" : "") +
900
- `${importLines(publicMembers)}\nexport { startApp } from "@arcote.tech/platform";\n`,
901
- );
902
-
903
1163
  const entryPaths: string[] = [initialEntry];
904
1164
  const groupModuleMap = new Map<string, string[]>();
905
1165
  for (const g of protectedGroups) {
906
- const entry = join(tmpDir, `${g.name}.ts`);
907
- writeFileSync(entry, `${importLines(g.members)}\n`);
908
- entryPaths.push(entry);
1166
+ entryPaths.push(join(tmpDir, `${g.name}.ts`));
909
1167
  groupModuleMap.set(g.name, g.members.map((m) => m.moduleName));
910
1168
  }
911
1169
 
1170
+ // Idempotentny zapis WSZYSTKICH plików entry. Wołany przed każdą próbą
1171
+ // Bun.build (nie raz) — retry po wyścigu minify musi odtworzyć entries,
1172
+ // gdyby cokolwiek zdążyło je sprzątnąć między próbami.
1173
+ const writeEntryFiles = () => {
1174
+ mkdirSync(tmpDir, { recursive: true });
1175
+ writeFileSync(
1176
+ initialEntry,
1177
+ // Prelude ekspozycji runtime MUSI być PIERWSZY (przed importami modułów) —
1178
+ // globalThis.__ARC_RT gotowe zanim cokolwiek innego się załaduje.
1179
+ (exposeRuntime ? runtimeExposePrelude() + "\n" : "") +
1180
+ `${importLines(publicMembers)}\nexport { startApp } from "@arcote.tech/platform";\n`,
1181
+ );
1182
+ for (const g of protectedGroups) {
1183
+ writeFileSync(join(tmpDir, `${g.name}.ts`), `${importLines(g.members)}\n`);
1184
+ }
1185
+ };
1186
+
912
1187
  // ---------------------------------------------------------------------
913
1188
  // Temporarily flip `"sideEffects"` on every workspace package's
914
1189
  // package.json from `false` to `true` for the duration of the build.
@@ -941,11 +1216,22 @@ export async function buildBrowserApp(
941
1216
  patchedPkgJsons.push({ path: pkgJsonPath, original });
942
1217
  }
943
1218
 
1219
+ const statsMode = buildStatsEnabled();
1220
+ // JS-name → sparsowana sourcemapa (tylko w trybie stats).
1221
+ const mapsByJs = new Map<string, { sources?: string[]; sourcesContent?: (string | null)[] }>();
1222
+
944
1223
  let result;
945
- try {
946
- result = await Bun.build({
1224
+ const runBuild = () => {
1225
+ writeEntryFiles();
1226
+ // CELOWO bez `outdir` — artefakty zostają w pamięci i to MY piszemy pliki
1227
+ // (niżej). Chroni build przed RÓWNOLEGŁYM procesem piszącym do tego samego
1228
+ // workspace'u (klasyk: `arc platform dev` w drugim terminalu — patch
1229
+ // sideEffects w package.json triggeruje jego watcher, a jego rebuild
1230
+ // wypluwa NIEZMINIFIKOWANE bundle i wipe'uje outDir w trakcie naszego
1231
+ // builda). Bajty z artefaktów + własny zapis + weryfikacja po zapisie
1232
+ // (niżej) gwarantują, że wynik pochodzi z jednego spójnego passu.
1233
+ return Bun.build({
947
1234
  entrypoints: entryPaths,
948
- outdir: outDir,
949
1235
  // splitting:true is the whole point: shared deps (workspace context,
950
1236
  // framework, lucide, etc.) land in chunk-<hash>.js, referenced by both
951
1237
  // initial and token-group entries. One instance, no provider duplication.
@@ -964,9 +1250,19 @@ export async function buildBrowserApp(
964
1250
  // teraz eager re-exportem, więc działa też przy external jsx-runtime.
965
1251
  ...(federated ? [] : [singleReactPlugin(rootDir)]),
966
1252
  jsxDevShimPlugin(),
1253
+ // Serwerowe builtiny node → rzucający stub zamiast ciężkiego polyfilla
1254
+ // (patrz plugin). Musi być PRZED i18nExtractPlugin (onResolve pierwszy
1255
+ // wygrywa nie dotyczy onLoad, ale kolejność trzymamy spójnie).
1256
+ nodeServerBuiltinStubPlugin(),
967
1257
  i18nExtractPlugin(i18nCollector, rootDir),
968
1258
  ],
969
1259
  naming: "[name].[ext]",
1260
+ minify,
1261
+ // Tryb stats: sourcemapy niosą `sources[]`/`sourcesContent[]` do rozbicia
1262
+ // chunków na biblioteki. NIE zapisujemy .map na dysk (analiza w pamięci),
1263
+ // a komentarz `//# debugId=` zdejmujemy z JS niżej — bajty i hashe zostają
1264
+ // identyczne jak w zwykłym buildzie.
1265
+ sourcemap: statsMode ? "external" : "none",
970
1266
  define: {
971
1267
  ONLY_SERVER: "false",
972
1268
  ONLY_BROWSER: "true",
@@ -976,6 +1272,58 @@ export async function buildBrowserApp(
976
1272
  "process.env.NODE_ENV": '"production"',
977
1273
  },
978
1274
  });
1275
+ };
1276
+
1277
+ let outFiles: BrowserOutFile[] | null = null;
1278
+ try {
1279
+ // Retry: równoległy proces budujący ten sam workspace (dev watcher)
1280
+ // potrafi w trakcie naszego passu podmienić wejścia/wyjścia — objawy to
1281
+ // niezminifikowany output mimo minify:true albo ENOENT na plikach entry.
1282
+ // Ponawiamy do 3 prób; ostatnia porażka = twardy fail (lepszy głośny
1283
+ // build-error niż niezauważony, ~4-5× cięższy deploy).
1284
+ for (let attempt = 1; ; attempt++) {
1285
+ result = await runBuild();
1286
+ if (result.success) {
1287
+ const files: BrowserOutFile[] = [];
1288
+ mapsByJs.clear();
1289
+ for (const out of result.outputs) {
1290
+ const name = basename(out.path);
1291
+ // Sourcemapy (tylko stats): sparsuj do pamięci, nie zapisuj na dysk.
1292
+ if (name.endsWith(".map")) {
1293
+ try {
1294
+ mapsByJs.set(name.replace(/\.map$/, ""), JSON.parse(await out.text()));
1295
+ } catch {
1296
+ // uszkodzona mapa — pomiń, stats po prostu ją opuści
1297
+ }
1298
+ continue;
1299
+ }
1300
+ let bytes: Uint8Array = new Uint8Array(await out.arrayBuffer());
1301
+ // Zdejmij dopięty przez `sourcemap:"external"` komentarz `//# debugId=`
1302
+ // — inaczej tryb stats zmieniałby bajty (a więc i content-hash) JS.
1303
+ if (statsMode && name.endsWith(".js")) {
1304
+ bytes = stripTrailingSourcemapComment(bytes);
1305
+ }
1306
+ files.push({ kind: out.kind, name, bytes });
1307
+ }
1308
+ if (!minify || looksMinified(files)) {
1309
+ outFiles = files;
1310
+ break;
1311
+ }
1312
+ }
1313
+ if (attempt >= 3) {
1314
+ if (!result.success) break; // standardowa ścieżka błędu niżej wypisze logi
1315
+ throw new Error(
1316
+ "Browser app build: output NIEZMINIFIKOWANY mimo minify:true (3 próby). Przerwane, żeby nie wypuścić ciężkich bundli.",
1317
+ );
1318
+ }
1319
+ console.warn(
1320
+ ` ! browser build pass ${attempt} ${
1321
+ result.success
1322
+ ? "niezminifikowany mimo minify:true (wyścig minify Bun.build)"
1323
+ : "nieudany (wyścig Bun.build — np. zniknięte pliki entry)"
1324
+ } — powtarzam`,
1325
+ );
1326
+ }
979
1327
  } finally {
980
1328
  // Always restore — a crash here MUST NOT leave the user with mutated
981
1329
  // workspace package.json files.
@@ -984,7 +1332,7 @@ export async function buildBrowserApp(
984
1332
 
985
1333
  rmSync(tmpDir, { recursive: true, force: true });
986
1334
 
987
- if (!result.success) {
1335
+ if (!result.success || !outFiles) {
988
1336
  for (const log of result.logs) console.error(log);
989
1337
  throw new Error("Browser app build failed");
990
1338
  }
@@ -998,17 +1346,12 @@ export async function buildBrowserApp(
998
1346
  const groups: Record<string, BrowserGroupEntry> = {};
999
1347
  const sharedChunks: string[] = [];
1000
1348
 
1001
- for (const out of result.outputs) {
1002
- const name = basename(out.path);
1349
+ for (const out of outFiles) {
1003
1350
  if (out.kind === "entry-point") {
1004
- const bytes = readFileSync(out.path);
1005
- const hash = sha256Hex(bytes).slice(0, 16);
1006
- const stem = name.replace(/\.js$/, "");
1351
+ const hash = sha256Hex(out.bytes).slice(0, 16);
1352
+ const stem = out.name.replace(/\.js$/, "");
1007
1353
  const finalName = `${stem}.${hash}.js`;
1008
- const finalPath = join(outDir, finalName);
1009
- rmSync(finalPath, { force: true });
1010
- writeFileSync(finalPath, bytes);
1011
- rmSync(out.path, { force: true });
1354
+ writeFileSync(join(outDir, finalName), out.bytes);
1012
1355
 
1013
1356
  if (stem === "initial") {
1014
1357
  initialFile = finalName;
@@ -1021,7 +1364,8 @@ export async function buildBrowserApp(
1021
1364
  };
1022
1365
  }
1023
1366
  } else if (out.kind === "chunk") {
1024
- sharedChunks.push(name);
1367
+ writeFileSync(join(outDir, out.name), out.bytes);
1368
+ sharedChunks.push(out.name);
1025
1369
  }
1026
1370
  }
1027
1371
 
@@ -1029,6 +1373,34 @@ export async function buildBrowserApp(
1029
1373
  throw new Error("Browser app build: initial entry not found in outputs");
1030
1374
  }
1031
1375
 
1376
+ // Weryfikacja po zapisie: katalog ma zawierać DOKŁADNIE nasze pliki z
1377
+ // DOKŁADNIE naszymi bajtami. Równoległy builder tego samego workspace'u
1378
+ // (dev watcher) dorzucał do outDir własny, niezminifikowany zestaw plików
1379
+ // (patrz komentarz przy runBuild) — jakikolwiek obcy plik/rozjazd treści
1380
+ // = głośny fail zamiast cichego wypuszczenia niespójnego builda.
1381
+ const written = new Map<string, string>();
1382
+ for (const out of outFiles) {
1383
+ const name =
1384
+ out.kind === "entry-point"
1385
+ ? `${out.name.replace(/\.js$/, "")}.${sha256Hex(out.bytes).slice(0, 16)}.js`
1386
+ : out.name;
1387
+ if (out.kind === "entry-point" || out.kind === "chunk") {
1388
+ written.set(name, sha256Hex(out.bytes));
1389
+ }
1390
+ }
1391
+ for (const f of readdirSync(outDir)) {
1392
+ if (!f.endsWith(".js")) continue;
1393
+ const expected = written.get(f);
1394
+ const actual = sha256Hex(readFileSync(join(outDir, f)));
1395
+ if (!expected || expected !== actual) {
1396
+ throw new Error(
1397
+ `Browser app build: plik "${f}" w ${outDir} nie zgadza się z artefaktami builda ` +
1398
+ `(${expected ? "treść rozjechana z zapisem" : "obcy plik — coś innego pisze do outDir"}). ` +
1399
+ `Przerwane, żeby nie wypuścić niespójnego builda.`,
1400
+ );
1401
+ }
1402
+ }
1403
+
1032
1404
  const manifest: BrowserAppResult = {
1033
1405
  initial: { file: initialFile, hash: initialHash },
1034
1406
  groups,
@@ -1036,6 +1408,36 @@ export async function buildBrowserApp(
1036
1408
  cached: false,
1037
1409
  };
1038
1410
 
1411
+ if (statsMode) {
1412
+ // Zmapuj bajty i sourcemapy na FINALNE nazwy plików (entry z hashem),
1413
+ // żeby graf importów raportu (chunki referują `chunk-<hash>.js`) spinał się
1414
+ // z tym, co realnie leży na dysku.
1415
+ const jsByName = new Map<string, Uint8Array>();
1416
+ const mapsByFinal = new Map<
1417
+ string,
1418
+ { sources?: string[]; sourcesContent?: (string | null)[] }
1419
+ >();
1420
+ const finalNameOf = (out: BrowserOutFile) =>
1421
+ out.kind === "entry-point"
1422
+ ? `${out.name.replace(/\.js$/, "")}.${sha256Hex(out.bytes).slice(0, 16)}.js`
1423
+ : out.name;
1424
+ for (const out of outFiles) {
1425
+ const fn = finalNameOf(out);
1426
+ jsByName.set(fn, out.bytes);
1427
+ const map = mapsByJs.get(out.name);
1428
+ if (map) mapsByFinal.set(fn, map);
1429
+ }
1430
+ reportBrowserBuildStats({
1431
+ label: federated ? "browser-fed" : "browser",
1432
+ entries: [
1433
+ { name: "initial", file: initialFile },
1434
+ ...Object.entries(groups).map(([stem, g]) => ({ name: stem, file: g.file })),
1435
+ ],
1436
+ jsByName,
1437
+ mapsByJs: mapsByFinal,
1438
+ });
1439
+ }
1440
+
1039
1441
  updateCache(cache, unitId, inputHash, {
1040
1442
  outputHashes: { _manifest: JSON.stringify(manifest) },
1041
1443
  });
@@ -1,6 +1,11 @@
1
1
  import { buildAll, ok, resolveWorkspace } from "../platform/shared";
2
2
 
3
- export async function platformBuild(opts: { noCache?: boolean } = {}): Promise<void> {
3
+ export async function platformBuild(
4
+ opts: { noCache?: boolean; stats?: boolean } = {},
5
+ ): Promise<void> {
6
+ // `--stats` = alias na ARC_BUILD_STATS=1 (env zostaje jako escape hatch, np.
7
+ // dla buildu w `dev`). Tryb stats wymusza pełny build (patrz buildBrowserApp).
8
+ if (opts.stats) process.env.ARC_BUILD_STATS = "1";
4
9
  const ws = resolveWorkspace();
5
10
  const manifest = await buildAll(ws, { noCache: opts.noCache });
6
11
  const groupCount = Object.keys(manifest.groups).length;