@arcote.tech/arc-cli 0.7.31 → 0.8.0

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.
@@ -23,6 +23,8 @@ import {
23
23
  type BuildCache,
24
24
  } from "../builder/build-cache";
25
25
  import { extractAccessMap } from "../builder/access-extractor";
26
+ import { buildPeerShell, type PeerShellResult } from "../builder/peer-shell";
27
+ import type { FederationConfig } from "@arcote.tech/platform";
26
28
  import { assertOneModulePerPackage, planChunks } from "../builder/chunk-planner";
27
29
  import { collectFrameworkDeps } from "../builder/dependency-collector";
28
30
  import {
@@ -156,6 +158,7 @@ export async function buildAll(
156
158
  const cache = loadBuildCache(ws.arcDir);
157
159
  const noCache = opts.noCache ?? false;
158
160
  const themePath = ws.rootPkg.arc?.theme as string | undefined;
161
+ const federation = getFederationConfig(ws);
159
162
 
160
163
  log(`Building (concurrency parallel${noCache ? ", no-cache" : ""})...`);
161
164
 
@@ -188,27 +191,77 @@ export async function buildAll(
188
191
  mkdirSync(ws.arcDir, { recursive: true });
189
192
  writeFileSync(
190
193
  join(ws.arcDir, "access.json"),
191
- JSON.stringify(accessMap, null, 2) + "\n",
194
+ JSON.stringify(accessMap.access, null, 2) + "\n",
195
+ );
196
+
197
+ // Federacja per-moduł (spec/module-federation.md): rola HOSTA (arc.federation
198
+ // .host — importuje cudze moduły) i rola PARTNERA (moduły z exposeToHosts —
199
+ // eksportowane) są niezależne.
200
+ const isHost = Boolean(federation?.host);
201
+ const moduleNameOf = (name: string) => name.split("/").pop() ?? name;
202
+ const exposureOf = (pkgName: string): "local" | "external" | "both" =>
203
+ accessMap.exposure[moduleNameOf(pkgName)]?.exposure ?? "local";
204
+ // Normalna platforma: moduły local/both (external-only ukryte w macierzystej
205
+ // platformie). Host bierze wszystko (nie ukrywa niczego przed sobą).
206
+ const localPackages = isHost
207
+ ? ws.packages
208
+ : ws.packages.filter((p) => exposureOf(p.name) !== "external");
209
+ // Build federowany: moduły external/both → browser-fed/.
210
+ const fedPackages = ws.packages.filter((p) =>
211
+ ["external", "both"].includes(exposureOf(p.name)),
212
+ );
213
+ const hasFederatedModules = fedPackages.length > 0;
214
+ // Peer-shell (import mapa) potrzebny, gdy współdzielimy runtime: host
215
+ // (ładuje gości) lub partner (jego chunki są external-peers).
216
+ const needsPeerShell = isHost || hasFederatedModules;
217
+ log(
192
218
  );
193
219
 
194
220
  // Phase 3 — group modules into chunks (one Bun.build per group).
195
- const plan = planChunks(ws.packages, accessMap);
221
+ const plan = planChunks(localPackages, accessMap.access);
196
222
  ok(
197
223
  `Chunks: ${plan.chunks
198
224
  .map((c) => `${c}(${plan.groups.get(c)?.length ?? 0})`)
199
225
  .join(", ")}`,
200
226
  );
227
+ const fedPlan = hasFederatedModules
228
+ ? planChunks(fedPackages, accessMap.access)
229
+ : null;
230
+ if (fedPlan) {
231
+ ok(
232
+ `Federated modules: ${fedPackages.map((p) => moduleNameOf(p.name)).join(", ")}`,
233
+ );
234
+ }
201
235
 
202
- // Phase 4 — independent parallel build units. The browser app is a single
203
- // Bun.build call covering initial + all token groups (replaces ~25 separate
204
- // Bun.builds for shell + per-chunk + per-package).
236
+ // Phase 4 — independent parallel build units. Normalny build (bundled, chyba
237
+ // że host external peers) + opcjonalny build federowany (browser-fed/,
238
+ // zawsze external peers) + peer-shell.
205
239
  const i18nCollector = new Map<string, Set<string>>();
206
240
 
207
- const [browserResult] = await Promise.all([
208
- buildBrowserApp(ws.rootDir, ws.browserDir, plan, cache, noCache, i18nCollector),
241
+ const [browserResult, , , , shellResult, fedResult] = await Promise.all([
242
+ // HOST buduje się BUNDLED (React inline, eager bez lazy-race), a initial
243
+ // eksponuje runtime na globalThis; gościa obsłuży peer-shell (globalThis).
244
+ buildBrowserApp(ws.rootDir, ws.browserDir, plan, cache, noCache, i18nCollector, {
245
+ federated: false,
246
+ exposeRuntime: isHost,
247
+ }),
209
248
  buildStyles(ws.rootDir, ws.arcDir, ws.packages, themePath, cache, noCache),
210
249
  copyBrowserAssets(ws, cache, noCache),
211
250
  buildTranslations(ws.rootDir, ws.arcDir, cache, noCache),
251
+ needsPeerShell
252
+ ? buildPeerShell(ws.rootDir, join(ws.arcDir, "shell"), cache, noCache)
253
+ : Promise.resolve(undefined),
254
+ fedPlan
255
+ ? buildBrowserApp(
256
+ ws.rootDir,
257
+ join(ws.arcDir, "browser-fed"),
258
+ fedPlan,
259
+ cache,
260
+ noCache,
261
+ i18nCollector,
262
+ { federated: true, unitId: "browser-app-fed" },
263
+ )
264
+ : Promise.resolve(undefined),
212
265
  ]);
213
266
 
214
267
  // Finalize i18n catalogs once after all chunks + initial bundle collected.
@@ -222,7 +275,43 @@ export async function buildAll(
222
275
 
223
276
  saveBuildCache(ws.arcDir, cache);
224
277
 
225
- const finalManifest = assembleManifest(ws, browserResult, cache);
278
+ const federationBuild =
279
+ fedResult && hasFederatedModules
280
+ ? {
281
+ config: {
282
+ slug: federation?.slug,
283
+ auth: federation?.auth,
284
+ } as FederationConfig,
285
+ modules: fedPackages.map((p) => moduleNameOf(p.name)),
286
+ permissions: [
287
+ ...new Set(
288
+ fedPackages.flatMap(
289
+ (p) => accessMap.exposure[moduleNameOf(p.name)]?.permissions ?? [],
290
+ ),
291
+ ),
292
+ ],
293
+ slots: [
294
+ ...new Set(
295
+ fedPackages.flatMap(
296
+ (p) => accessMap.exposure[moduleNameOf(p.name)]?.slots ?? [],
297
+ ),
298
+ ),
299
+ ],
300
+ build: {
301
+ initial: fedResult.initial,
302
+ groups: fedResult.groups,
303
+ sharedChunks: fedResult.sharedChunks,
304
+ },
305
+ }
306
+ : undefined;
307
+
308
+ const finalManifest = assembleManifest(
309
+ ws,
310
+ browserResult,
311
+ cache,
312
+ shellResult,
313
+ federationBuild,
314
+ );
226
315
  writeFileSync(
227
316
  join(ws.arcDir, "manifest.json"),
228
317
  JSON.stringify(finalManifest, null, 2),
@@ -237,6 +326,8 @@ function assembleManifest(
237
326
  ws: WorkspaceInfo,
238
327
  browser: BrowserAppResult,
239
328
  cache: BuildCache,
329
+ shell?: PeerShellResult,
330
+ federation?: BuildManifest["federation"],
240
331
  ): BuildManifest {
241
332
  const stylesHash = cache.units["styles"]?.outputHash ?? "";
242
333
 
@@ -246,9 +337,29 @@ function assembleManifest(
246
337
  sharedChunks: browser.sharedChunks,
247
338
  stylesHash,
248
339
  buildTime: new Date().toISOString(),
340
+ ...(shell ? { shell: { imports: shell.imports, peers: shell.peers } } : {}),
341
+ ...(federation ? { federation } : {}),
249
342
  };
250
343
  }
251
344
 
345
+ /**
346
+ * Konfiguracja federacji z root package.json (`arc.federation`) — tożsamość
347
+ * aplikacji (slug/auth) + rola hosta. Które MODUŁY są wystawione, decyduje
348
+ * `module().exposeToHosts()` (spec/module-federation.md). Waliduje slug.
349
+ */
350
+ export function getFederationConfig(
351
+ ws: WorkspaceInfo,
352
+ ): FederationConfig | undefined {
353
+ const raw = ws.rootPkg.arc?.federation as FederationConfig | undefined;
354
+ if (!raw) return undefined;
355
+ if (raw.slug && !/^[a-z0-9-]+$/.test(raw.slug)) {
356
+ throw new Error(
357
+ `arc.federation.slug "${raw.slug}" — dozwolone tylko [a-z0-9-]+`,
358
+ );
359
+ }
360
+ return raw;
361
+ }
362
+
252
363
  // ---------------------------------------------------------------------------
253
364
  // Browser assets — @arcote.tech/* deps deklarują w `arc.browserAssets` jakie
254
365
  // pliki muszą być dostępne w przeglądarce (np. SQLite WASM worker + .wasm).
@@ -1,4 +1,5 @@
1
- import { existsSync, readFileSync, watch } from "fs";
1
+ import { randomBytes } from "crypto";
2
+ import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "fs";
2
3
  import { join } from "path";
3
4
  import { startPlatformServer, type PlatformServer } from "./server";
4
5
  import type { BuildManifest } from "./shared";
@@ -37,6 +38,12 @@ export interface StartPlatformOptions {
37
38
  devMode: boolean;
38
39
  /** Dev-only: also start the Arc Context Map tool on a separate port. */
39
40
  map?: boolean;
41
+ /**
42
+ * Headless (spec/platform-headless.md): pełny host API + chunki federowane
43
+ * + mapa, ale BEZ shella SPA — dla aplikacji rozwijanych jako moduły
44
+ * konsumowane przez hosta federacji (BMF). Implikuje `map`.
45
+ */
46
+ headless?: boolean;
40
47
  }
41
48
 
42
49
  export async function startPlatform(
@@ -63,6 +70,20 @@ export async function startPlatform(
63
70
  manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
64
71
  }
65
72
 
73
+ // 1b. Tożsamość federacji — MUSI być gotowa przed `loadServerContext`, bo kod
74
+ // aplikacji czyta `ARC_APP_SECRET` na poziomie modułu (`token().secret(...)`).
75
+ // Jeden token parowania obsługuje mapę (discovery) i rejestrację hosta na
76
+ // platformie — deweloper wkleja hostowi JEDEN token, nie dwa.
77
+ assertProdFederationSecrets(devMode, !!manifest.federation);
78
+ const needsPairing =
79
+ !!manifest.federation || (devMode && (opts.map || opts.headless));
80
+ const pairingToken = needsPairing
81
+ ? resolvePairingToken(ws.rootDir)
82
+ : undefined;
83
+ if (manifest.federation) {
84
+ process.env.ARC_APP_SECRET = resolveAppSecret(ws.rootDir);
85
+ }
86
+
66
87
  // 2. Server context — same code path in both modes; loadServerContext
67
88
  // imports the combined server bundle at .arc/platform/server/_server.js
68
89
  // (produced by the buildServerApp step in buildAll).
@@ -90,6 +111,14 @@ export async function startPlatform(
90
111
  moduleAccess,
91
112
  dbPath,
92
113
  devMode,
114
+ headless: opts.headless,
115
+ federation:
116
+ manifest.federation && pairingToken
117
+ ? {
118
+ registrationToken: pairingToken,
119
+ appSecret: process.env.ARC_APP_SECRET!,
120
+ }
121
+ : undefined,
93
122
  });
94
123
  break;
95
124
  } catch (e) {
@@ -115,9 +144,26 @@ export async function startPlatform(
115
144
 
116
145
  // 4a. Dev-only: optional Arc Context Map tool on a separate port, starting
117
146
  // just above the platform's actual port (auto-increments if busy).
147
+ // Headless implikuje mapę (parowanie + discovery to jego interfejs).
118
148
  let onReload: (() => void) | undefined;
119
- if (devMode && opts.map) {
120
- onReload = await startMapTool(ws, actualPort + 1);
149
+ if (devMode && (opts.map || opts.headless)) {
150
+ // Mapa startuje PO platformie, więc jej adres ogłaszamy w `/api/discovery`
151
+ // dopiero teraz (setMapUrl). W prod mapy nie ma i pole zostaje `null`.
152
+ onReload = await startMapTool(ws, actualPort + 1, pairingToken!, platform, {
153
+ platformUrl: `http://localhost:${actualPort}`,
154
+ // Discovery: meta modułów wystawionych (federation.build pominięte —
155
+ // konsument bierze artefakty z /api/federation/manifest).
156
+ federation: manifest.federation
157
+ ? {
158
+ slug: manifest.federation.config.slug,
159
+ auth: manifest.federation.config.auth,
160
+ modules: manifest.federation.modules,
161
+ permissions: manifest.federation.permissions,
162
+ slots: manifest.federation.slots,
163
+ }
164
+ : undefined,
165
+ peers: manifest.shell?.peers,
166
+ });
121
167
  }
122
168
 
123
169
  // 4. Dev-only: file watcher + debounced rebuild + SSE notify.
@@ -142,22 +188,42 @@ export async function startPlatform(
142
188
  async function startMapTool(
143
189
  ws: WorkspaceInfo,
144
190
  port: number,
191
+ /** Token parowania — ten sam, którym host rejestruje się na platformie. */
192
+ authToken: string,
193
+ /** Serwer platformy — dostaje adres mapy do ogłoszenia w `/api/discovery`. */
194
+ platformServer: PlatformServer,
195
+ platform?: {
196
+ /** Adres serwera platformy (discovery: gdzie żyją komendy/query/WS). */
197
+ platformUrl: string;
198
+ federation?: unknown;
199
+ peers?: Readonly<Record<string, string>>;
200
+ },
145
201
  ): Promise<(() => void) | undefined> {
146
202
  try {
147
- const { startMapServer } = await import("@arcote.tech/arc-map");
203
+ const { startMapServer, MAP_SCHEMA_VERSION } = await import(
204
+ "@arcote.tech/arc-map"
205
+ );
148
206
  const { getContext } = await import("@arcote.tech/platform");
149
207
  const globs = ws.packages
150
208
  .map((p) => join(p.path, "src", "**", "*.{ts,tsx}"));
151
209
  const packageOf = (absFile: string): string | undefined =>
152
210
  ws.packages.find((p) => absFile.startsWith(join(p.path, "src")))?.name;
153
211
 
212
+ // Tożsamość aplikacji dla zewnętrznych konsumentów (import do BMF).
213
+ const meta = readAppMeta(ws.rootDir, MAP_SCHEMA_VERSION);
214
+
154
215
  const map = await startMapServer({
155
216
  getContext: () => getContext(),
156
217
  scan: { globs, workspaceRoot: ws.rootDir, packageOf },
157
218
  useCasePackages: ws.packages.map((p) => ({ name: p.name, path: p.path })),
158
219
  port,
220
+ authToken,
221
+ meta,
222
+ platform,
159
223
  });
224
+ platformServer.setMapUrl(map.url);
160
225
  ok(`Context map → ${map.url}`);
226
+ ok(`Pairing token: ${authToken}`);
161
227
  return map.notifyReload;
162
228
  } catch (e) {
163
229
  err(`Context map failed to start: ${(e as Error).message}`);
@@ -165,6 +231,144 @@ async function startMapTool(
165
231
  }
166
232
  }
167
233
 
234
+ /**
235
+ * PROD + federacja → sekrety MUSZĄ pochodzić z env. Twarda odmowa startu.
236
+ *
237
+ * DLACZEGO tak ostro: `.arc/` nie trafia ani do obrazu (deploy kopiuje tylko
238
+ * `.arc/platform/`), ani na wolumen (montowane jest tylko `.arc/data`). Plik
239
+ * wygenerowany wewnątrz kontenera przeżywa `restart`, ale **ginie przy pierwszym
240
+ * `recreate`** (nowy obraz = nowy kontener). Wtedy:
241
+ * - nowy `ARC_APP_SECRET` → hosty trzymają STARY sekret, więc żadna asercja
242
+ * tożsamości się nie zweryfikuje i aplikacja przestaje działać u wszystkich,
243
+ * bez jednego błędu przy starcie,
244
+ * - nowy token parowania → istniejące parowania martwe.
245
+ *
246
+ * Cicha awaria dzień po deployu jest dużo gorsza niż głośna odmowa startu.
247
+ * Wymieniamy WSZYSTKIE braki naraz — inaczej operator poprawia po jednej na deploy.
248
+ */
249
+ function assertProdFederationSecrets(
250
+ devMode: boolean,
251
+ hasFederation: boolean,
252
+ ): void {
253
+ if (devMode || !hasFederation) return;
254
+ const missing: string[] = [];
255
+ if (!process.env.ARC_APP_SECRET) missing.push("ARC_APP_SECRET");
256
+ if (!process.env.ARC_PAIRING_TOKEN && !process.env.ARC_MAP_TOKEN) {
257
+ missing.push("ARC_PAIRING_TOKEN");
258
+ }
259
+ if (missing.length === 0) return;
260
+
261
+ err(
262
+ `Federacja w produkcji wymaga zmiennych środowiskowych: ${missing.join(", ")}.\n` +
263
+ ` Bez nich sekret i token są generowane w kontenerze i GINĄ przy pierwszym\n` +
264
+ ` recreate — hosty musiałyby przeinstalować aplikację, bez żadnego błędu.\n` +
265
+ ` Ustaw je w deployu (deploy.arc.json → envVars + deploy.arc.<env>.env).`,
266
+ );
267
+ process.exit(1);
268
+ }
269
+
270
+ /**
271
+ * Sekret aplikacji — WŁASNOŚĆ PARTNERA, stały między restartami. Kolejność:
272
+ * 1. `ARC_APP_SECRET` (env) — jawna kontrola (prod, rotacja)
273
+ * 2. `.arc/app-secret` — wygenerowany raz, przeżywa restart
274
+ * 3. wygeneruj i zapisz
275
+ *
276
+ * Tym sekretem host federacji (BMF) podpisuje asercje tożsamości, a aplikacja
277
+ * je weryfikuje (`token("hostIdentity", …).secret(process.env.ARC_APP_SECRET)`).
278
+ * Sekret NALEŻY do aplikacji i host go POBIERA przy rejestracji
279
+ * (`POST /api/federation/register`) — nie odwrotnie. Dzięki temu aplikacja ma
280
+ * go już przy starcie i instalacja u hosta nie wymaga wklejania niczego do env
281
+ * ani restartu partnera (to było źródłem błędnego koła: instalacja → restart →
282
+ * nowy token parowania → parowanie martwe).
283
+ *
284
+ * Ustawiany do `process.env` PRZED `loadServerContext`, bo kod aplikacji czyta
285
+ * go na poziomie modułu. `.arc/` jest gitignorowane.
286
+ */
287
+ function resolveAppSecret(rootDir: string): string {
288
+ const fromEnv = process.env.ARC_APP_SECRET;
289
+ if (fromEnv) return fromEnv;
290
+
291
+ const file = join(rootDir, ".arc", "app-secret");
292
+ try {
293
+ const saved = readFileSync(file, "utf-8").trim();
294
+ if (saved) return saved;
295
+ } catch {
296
+ // brak pliku lub nieczytelny — generujemy nowy poniżej
297
+ }
298
+
299
+ const secret = randomBytes(32).toString("base64url");
300
+ try {
301
+ mkdirSync(join(rootDir, ".arc"), { recursive: true });
302
+ writeFileSync(file, secret, { mode: 0o600 });
303
+ } catch (e) {
304
+ // Nie fatal, ale sekret zmieni się po restarcie → hosty stracą ważność.
305
+ err(
306
+ `App secret not persisted (${(e as Error).message}) — hosts will need re-install after restart`,
307
+ );
308
+ }
309
+ return secret;
310
+ }
311
+
312
+ /**
313
+ * Token parowania — STAŁY między restartami. Kolejność:
314
+ * 1. `ARC_PAIRING_TOKEN` (env), alias `ARC_MAP_TOKEN` — jawna kontrola
315
+ * (prod, CI, wiele instancji obok siebie)
316
+ * 2. `.arc/pairing-token` — wygenerowany raz, przeżywa restart
317
+ * 3. wygeneruj i zapisz
318
+ *
319
+ * Nazwa `ARC_MAP_TOKEN` jest historyczna: token bramkuje dziś także
320
+ * `/api/discovery` i `/api/federation/register` NA PLATFORMIE, nie tylko mapę.
321
+ * Zostaje jako alias, żeby nie łamać istniejących deployów.
322
+ *
323
+ * Token losowany przy każdym starcie unieważniał parowanie z hostem (host
324
+ * trzyma go po swojej stronie), przez co instalacja aplikacji federowanej
325
+ * wpadała w błędne koło: instalacja wymagała restartu partnera, a restart
326
+ * zabijał parowanie. `.arc/` jest gitignorowane, więc token nie wycieka do repo.
327
+ */
328
+ function resolvePairingToken(rootDir: string): string {
329
+ const fromEnv = process.env.ARC_PAIRING_TOKEN || process.env.ARC_MAP_TOKEN;
330
+ if (fromEnv) return fromEnv;
331
+
332
+ const file = join(rootDir, ".arc", "pairing-token");
333
+ try {
334
+ const saved = readFileSync(file, "utf-8").trim();
335
+ if (saved) return saved;
336
+ } catch {
337
+ // brak pliku lub nieczytelny — generujemy nowy poniżej
338
+ }
339
+
340
+ const token = randomBytes(16).toString("base64url");
341
+ try {
342
+ mkdirSync(join(rootDir, ".arc"), { recursive: true });
343
+ writeFileSync(file, token, { mode: 0o600 });
344
+ } catch (e) {
345
+ // Nie fatal, ale użytkownik MUSI wiedzieć — inaczej wraca błędne koło.
346
+ err(
347
+ `Pairing token not persisted (${(e as Error).message}) — it will change on restart`,
348
+ );
349
+ }
350
+ return token;
351
+ }
352
+
353
+ /** Nazwa+wersja aplikacji z root package.json (best effort). */
354
+ function readAppMeta(
355
+ rootDir: string,
356
+ schemaVersion: number,
357
+ ): { schemaVersion: number; name: string; version?: string } {
358
+ try {
359
+ const pkg = JSON.parse(
360
+ readFileSync(join(rootDir, "package.json"), "utf-8"),
361
+ ) as { name?: string; version?: string };
362
+ return {
363
+ schemaVersion,
364
+ name: pkg.name ?? rootDir.split("/").pop() ?? "arc-app",
365
+ version: pkg.version,
366
+ };
367
+ } catch {
368
+ return { schemaVersion, name: rootDir.split("/").pop() ?? "arc-app" };
369
+ }
370
+ }
371
+
168
372
  function attachDevWatcher(
169
373
  ws: WorkspaceInfo,
170
374
  platform: PlatformServer,