@arcote.tech/arc-cli 0.8.3 → 0.8.5

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.3",
3
+ "version": "0.8.5",
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.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",
15
+ "@arcote.tech/arc": "^0.8.5",
16
+ "@arcote.tech/arc-ds": "^0.8.5",
17
+ "@arcote.tech/arc-react": "^0.8.5",
18
+ "@arcote.tech/arc-host": "^0.8.5",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.8.5",
20
+ "@arcote.tech/arc-adapter-db-postgres": "^0.8.5",
21
+ "@arcote.tech/arc-otel": "^0.8.5",
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.3",
35
- "@arcote.tech/arc-map": "^0.8.3",
34
+ "@arcote.tech/platform": "^0.8.5",
35
+ "@arcote.tech/arc-map": "^0.8.5",
36
36
  "@clack/prompts": "^0.9.0",
37
37
  "commander": "^11.1.0",
38
38
  "chokidar": "^3.5.3",
@@ -840,10 +840,14 @@ export interface BrowserGroupEntry {
840
840
  readonly hash: string;
841
841
  /** Module names registered by this group (for manifest filterability). */
842
842
  readonly modules: readonly string[];
843
+ /** Shared chunks this entry statically imports (transitive closure). The
844
+ * server `<link rel=modulepreload>`s them so the browser fetches them in
845
+ * parallel with the entry instead of discovering them after parse. */
846
+ readonly staticChunks: readonly string[];
843
847
  }
844
848
 
845
849
  export interface BrowserAppResult {
846
- readonly initial: { file: string; hash: string };
850
+ readonly initial: { file: string; hash: string; staticChunks: readonly string[] };
847
851
  /** Keyed by token.name. `initial` is NOT here — it's separate. */
848
852
  readonly groups: Record<string, BrowserGroupEntry>;
849
853
  /** Auto-shared chunks emitted by Bun.build splitting. Public, unsigned. */
@@ -859,6 +863,33 @@ interface BrowserOutFile {
859
863
  bytes: Uint8Array;
860
864
  }
861
865
 
866
+ /** Statyczne `import"./chunk-x.js"` / `from"./chunk-x.js"` (NIE `import(...)`). */
867
+ const STATIC_CHUNK_IMPORT =
868
+ /(?:^|[;}\s])import\s*["']\.\/(chunk-[a-z0-9]+\.js)["']|from\s*["']\.\/(chunk-[a-z0-9]+\.js)["']/g;
869
+
870
+ /** Tranzytywne domknięcie shared-chunków statycznie importowanych przez entry
871
+ * — z bajtów w pamięci. `chunkBytes` = nazwa chunku → bajty. */
872
+ function staticChunkClosure(
873
+ entryBytes: Uint8Array,
874
+ chunkBytes: Map<string, Uint8Array>,
875
+ ): string[] {
876
+ const seen = new Set<string>();
877
+ const stack: Uint8Array[] = [entryBytes];
878
+ while (stack.length) {
879
+ const src = Buffer.from(stack.pop()!).toString("utf8");
880
+ STATIC_CHUNK_IMPORT.lastIndex = 0;
881
+ let m: RegExpExecArray | null;
882
+ while ((m = STATIC_CHUNK_IMPORT.exec(src))) {
883
+ const chunk = m[1] ?? m[2]!;
884
+ if (seen.has(chunk)) continue;
885
+ seen.add(chunk);
886
+ const b = chunkBytes.get(chunk);
887
+ if (b) stack.push(b);
888
+ }
889
+ }
890
+ return [...seen];
891
+ }
892
+
862
893
  // ---------------------------------------------------------------------------
863
894
  // Build stats (ARC_BUILD_STATS=1) — rozbicie każdego chunku na biblioteki.
864
895
  //
@@ -1343,24 +1374,34 @@ export async function buildBrowserApp(
1343
1374
  // their name (Bun auto-emits `chunk-<hash>.js`); we leave those alone.
1344
1375
  let initialFile = "";
1345
1376
  let initialHash = "";
1377
+ let initialStaticChunks: string[] = [];
1346
1378
  const groups: Record<string, BrowserGroupEntry> = {};
1347
1379
  const sharedChunks: string[] = [];
1348
1380
 
1381
+ // Nazwa chunku → bajty (do policzenia per-wejście domknięcia importów).
1382
+ const chunkBytes = new Map<string, Uint8Array>();
1383
+ for (const out of outFiles) {
1384
+ if (out.kind === "chunk") chunkBytes.set(out.name, out.bytes);
1385
+ }
1386
+
1349
1387
  for (const out of outFiles) {
1350
1388
  if (out.kind === "entry-point") {
1351
1389
  const hash = sha256Hex(out.bytes).slice(0, 16);
1352
1390
  const stem = out.name.replace(/\.js$/, "");
1353
1391
  const finalName = `${stem}.${hash}.js`;
1354
1392
  writeFileSync(join(outDir, finalName), out.bytes);
1393
+ const staticChunks = staticChunkClosure(out.bytes, chunkBytes);
1355
1394
 
1356
1395
  if (stem === "initial") {
1357
1396
  initialFile = finalName;
1358
1397
  initialHash = hash;
1398
+ initialStaticChunks = staticChunks;
1359
1399
  } else {
1360
1400
  groups[stem] = {
1361
1401
  file: finalName,
1362
1402
  hash,
1363
1403
  modules: groupModuleMap.get(stem) ?? [],
1404
+ staticChunks,
1364
1405
  };
1365
1406
  }
1366
1407
  } else if (out.kind === "chunk") {
@@ -1402,7 +1443,7 @@ export async function buildBrowserApp(
1402
1443
  }
1403
1444
 
1404
1445
  const manifest: BrowserAppResult = {
1405
- initial: { file: initialFile, hash: initialHash },
1446
+ initial: { file: initialFile, hash: initialHash, staticChunks: initialStaticChunks },
1406
1447
  groups,
1407
1448
  sharedChunks,
1408
1449
  cached: false,
@@ -0,0 +1,143 @@
1
+ import { existsSync, readdirSync, readFileSync } from "fs";
2
+ import { join, relative, sep } from "path";
3
+
4
+ import type { WorkspacePackage } from "./module-builder";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Seed leak guard
8
+ //
9
+ // `aggregate(...).seedWith([...])` / `.withSeeds([...])` przekazuje dane seed
10
+ // jako ZWYKŁY argument-literał. Element wchodzi do modułu przez `context([...])`,
11
+ // a build przeglądarki importuje CAŁY moduł (`import "${pkg.name}"`). Kasowanie
12
+ // kodu serwerowego z bundla klienta stoi wyłącznie na `define ONLY_SERVER:"false"`
13
+ // → składa `ONLY_SERVER && (...)` / `ONLY_SERVER ? ... : ...` do martwej gałęzi
14
+ // → minify DCE. Argument seedWith NIE jest owinięty niczym, więc literał (często
15
+ // dane referencyjne, konta serwisowe, sekrety w wierszach) PRZEŻYWA i ląduje
16
+ // w PUBLICZNYM bundlu przeglądarki. Seedy czyta wyłącznie host (`runSeeds()`),
17
+ // po stronie klienta nie mają żadnego zastosowania — czysty wyciek.
18
+ //
19
+ // Gejt wywołania to WARUNEK KONIECZNY (ten guard go egzekwuje), ale to, gdzie
20
+ // fizycznie leży treść seeda, decyduje czy bajty realnie znikają. Zweryfikowane
21
+ // empirycznie (migracja ndt+bmf, grep bundla klienta):
22
+ //
23
+ // // mały seed — inline w gejtowanym literale (ZAWSZE działa: constant-fold):
24
+ // .seedWith(ONLY_SERVER ? [ { /* wiersze */ } ] : [])
25
+ //
26
+ // // duży seed / osobny plik — gejtowany DYNAMICZNY import (jedyny niezawodny):
27
+ // const s = ONLY_SERVER ? await import("./seeds") : ({} as typeof import("./seeds"));
28
+ // ... .seedWith(ONLY_SERVER ? [...s.rows] : [])
29
+ //
30
+ // UWAGA (pułapki, MIMO gejtu wywołania treść zostaje w kliencie):
31
+ // - STATYCZNY `import * as`/`import` osobnego modułu seed — arc wymusza
32
+ // `sideEffects:true` na pakietach workspace, więc moduł bywa zatrzymany „dla
33
+ // side-effectów" (namespace = cały; nieczysty top-level `id.parse()`/`new Date()`).
34
+ // Named-import czystego modułu BYWA zdrzewkowany, ale nie polegaj na tym.
35
+ // - ZMIENNE POŚREDNIE z danymi (`const SEED_EMAIL = "…"`, `const rows = [...]`)
36
+ // w ciele modułu/funkcji — const referencjonowany tylko w martwej gałęzi i tak
37
+ // się bundluje. Wpisuj wartości WPROST w gejtowany literał lub ładuj dynamicznie.
38
+ //
39
+ // Ten guard to TWARDA bramka builda: skanuje źródła i wywala build, gdy
40
+ // `.seedWith(`/`.withSeeds(` nie jest owinięte `ONLY_SERVER`. Rusza w Phase 0
41
+ // `buildAll` (obok assertOneModulePerPackage) — NAJWCZEŚNIEJ, przed drogim
42
+ // buildem serwera i access-extractorem, żeby fail był szybki i czytelny.
43
+ // Skan idzie po ŹRÓDLE (nie po cache), więc łapie też stary niezgejtowany kod,
44
+ // którego hash się nie zmienił.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** Wywołanie `.seedWith(` / `.withSeeds(` (dowolne białe znaki wokół `(`). */
48
+ const CALL = /\.(?:seedWith|withSeeds)\s*\(/g;
49
+ /** To samo wywołanie, ale poprawnie zgejtowane `ONLY_SERVER` jako pierwszy token. */
50
+ const GATED = /^\.(?:seedWith|withSeeds)\s*\(\s*ONLY_SERVER\b/;
51
+
52
+ export interface SeedLeak {
53
+ /** Ścieżka względem rootDir, POSIX-owe separatory. */
54
+ relpath: string;
55
+ line: number;
56
+ snippet: string;
57
+ }
58
+
59
+ function walkTsFiles(dir: string, out: string[]): void {
60
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
61
+ if (entry.name === "node_modules" || entry.name === "dist") continue;
62
+ const abs = join(dir, entry.name);
63
+ if (entry.isDirectory()) {
64
+ if (entry.name === "__tests__") continue;
65
+ walkTsFiles(abs, out);
66
+ } else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
67
+ out.push(abs);
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Skanuje `src/` każdego pakietu i zwraca niezgejtowane wywołania seedWith. */
73
+ export function findUngatedSeeds(
74
+ pkgs: readonly WorkspacePackage[],
75
+ rootDir: string,
76
+ ): SeedLeak[] {
77
+ const leaks: SeedLeak[] = [];
78
+ const scanned = new Set<string>();
79
+
80
+ for (const pkg of pkgs) {
81
+ const srcDir = join(pkg.path, "src");
82
+ if (scanned.has(srcDir) || !existsSync(srcDir)) continue;
83
+ scanned.add(srcDir);
84
+
85
+ const files: string[] = [];
86
+ walkTsFiles(srcDir, files);
87
+
88
+ for (const abs of files) {
89
+ const content = readFileSync(abs, "utf-8");
90
+ CALL.lastIndex = 0;
91
+ let m: RegExpExecArray | null;
92
+ while ((m = CALL.exec(content)) !== null) {
93
+ const rest = content.slice(m.index);
94
+ if (GATED.test(rest)) continue;
95
+
96
+ // Odsiej wzmianki w komentarzu liniowym (`// … .seedWith(…)`).
97
+ const lineStart = content.lastIndexOf("\n", m.index) + 1;
98
+ if (content.slice(lineStart, m.index).includes("//")) continue;
99
+
100
+ // Odsiej wzmianki w komentarzu blokowym / JSDoc (`/** … .seedWith() … */`):
101
+ // wewnątrz bloku, gdy ostatni `/*` przed trafieniem nie został jeszcze
102
+ // zamknięty przez `*/`.
103
+ const before = content.slice(0, m.index);
104
+ if (before.lastIndexOf("/*") > before.lastIndexOf("*/")) continue;
105
+
106
+ const nlAfter = content.indexOf("\n", m.index);
107
+ const lineEnd = nlAfter === -1 ? content.length : nlAfter;
108
+ const line = content.slice(0, m.index).split("\n").length;
109
+ const relpath = relative(rootDir, abs).split(sep).join("/");
110
+ leaks.push({
111
+ relpath,
112
+ line,
113
+ snippet: content.slice(lineStart, lineEnd).trim().slice(0, 120),
114
+ });
115
+ }
116
+ }
117
+ }
118
+
119
+ return leaks;
120
+ }
121
+
122
+ /** Twardy fail builda przeglądarki, jeśli jakikolwiek seed nie jest server-gated. */
123
+ export function assertSeedsServerGated(
124
+ pkgs: readonly WorkspacePackage[],
125
+ rootDir: string,
126
+ ): void {
127
+ const leaks = findUngatedSeeds(pkgs, rootDir);
128
+ if (leaks.length === 0) return;
129
+
130
+ const lines = leaks
131
+ .map((l) => ` • ${l.relpath}:${l.line}\n ${l.snippet}`)
132
+ .join("\n");
133
+
134
+ throw new Error(
135
+ `Seed leak do bundla klienta: ${leaks.length} wywołań .seedWith(...)/.withSeeds(...) ` +
136
+ `nie jest owiniętych w ONLY_SERVER.\n\n${lines}\n\n` +
137
+ `Dane seed czyta wyłącznie host (runSeeds) — po stronie klienta są zbędne i ` +
138
+ `wyciekają do PUBLICZNEGO bundla przeglądarki. Zgejtuj argument w miejscu:\n` +
139
+ ` .seedWith(ONLY_SERVER ? [ /* wiersze */ ] : [])\n` +
140
+ ` .seedWith(ONLY_SERVER ? (await import("./seeds.server")).rows : [])\n` +
141
+ `Statyczny import z osobnego pliku NIE pomaga (build wymusza sideEffects:true).`,
142
+ );
143
+ }
@@ -62,7 +62,11 @@ export interface BootstrapInputs {
62
62
  cliVersion: string;
63
63
  /** sha256 of deploy.arc.json — used for the remote state marker. */
64
64
  configHash: string;
65
- /** Force the ansible run even when the host is already bootstrapped. */
65
+ /**
66
+ * `--force-bootstrap`: wymusza PEŁNY bootstrap mimo markera „ready" —
67
+ * zarówno Ansible, jak i upStack (re-render + wgranie Caddyfile/compose).
68
+ * Escape hatch po ręcznych edycjach hosta albo gdy marker się rozjechał.
69
+ */
66
70
  forceAnsible?: boolean;
67
71
  }
68
72
 
@@ -137,6 +141,17 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
137
141
  ok("Host bootstrapped");
138
142
  }
139
143
 
144
+ // Hash wyrenderowanych artefaktów stacku (Caddyfile + compose). Wykrywa
145
+ // zmianę GENERATORA w obrębie tej samej wersji CLI — bez tego republish tej
146
+ // samej wersji z poprawionym Caddyfile (np. dodane `encode`) NIE odświeżał
147
+ // configu na hoście (obraz aplikacji i tak leci co deploy, ale upStack był
148
+ // pomijany). Marker bez `artifactsHash` (starszy deploy) → undefined ≠ hash
149
+ // → jednorazowy upStack, po którym marker dostaje hash.
150
+ const artifactsHash = new Bun.CryptoHasher("sha256")
151
+ .update(generateCaddyfile(cfg))
152
+ .update(generateCompose({ cfg }))
153
+ .digest("hex");
154
+
140
155
  // Force upStack whenever:
141
156
  // - stack isn't fully ready, OR
142
157
  // - marker is missing (legacy v0.5 deploy with no .arc-state.json), OR
@@ -144,6 +159,9 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
144
159
  // - the CLI version changed (generators evolve — compose/Caddyfile/
145
160
  // observability configs must be re-rendered + re-uploaded even when
146
161
  // deploy.arc.json itself is unchanged), OR
162
+ // - the RENDERED artifacts differ (generator changed within same CLI
163
+ // version — e.g. republished patch), OR
164
+ // - `--force-bootstrap` (wymusza pełny bootstrap, nie tylko Ansible), OR
147
165
  // - registry container isn't running (e.g. legacy stack predates v0.7)
148
166
  // Without this, an old v0.5 stack (no registry container) is classified as
149
167
  // "ready" and bootstrap is skipped — then `docker login` on the next step
@@ -153,6 +171,8 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
153
171
  state.marker === null ||
154
172
  state.marker.configHash !== inputs.configHash ||
155
173
  state.marker.cliVersion !== inputs.cliVersion ||
174
+ state.marker.artifactsHash !== artifactsHash ||
175
+ inputs.forceAnsible === true ||
156
176
  !(await isRegistryRunning(cfg));
157
177
 
158
178
  if (needUpStack) {
@@ -180,6 +200,7 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
180
200
  await writeStateMarker(cfg.target, {
181
201
  cliVersion: inputs.cliVersion,
182
202
  configHash: inputs.configHash,
203
+ artifactsHash,
183
204
  updatedAt: new Date().toISOString(),
184
205
  });
185
206
  }
@@ -393,6 +414,19 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
393
414
  `cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures caddy registry && docker compose up -d caddy registry`,
394
415
  );
395
416
 
417
+ // KRYTYCZNE: Caddyfile jest bind-mountowany (`:ro`), a `docker compose up -d`
418
+ // NIE recreatuje kontenera gdy zmienia się tylko TREŚĆ pliku (spec kontenera
419
+ // bez zmian) — Caddy dalej serwuje config wczytany przy starcie. Generowany
420
+ // Caddyfile ma `admin off`, więc `caddy reload` (admin API) też odpada.
421
+ // Bez restartu upStack wgrywał nowy Caddyfile (np. świeżo dodane `encode`),
422
+ // ale efekt był ZEROWY do najbliższego ręcznego restartu. `restart` wczytuje
423
+ // config od nowa (certy TLS są w wolumenie → bez re-issuance). Odpala się
424
+ // tylko w upStack, czyli gdy config faktycznie się zmienił — ~1s przestoju.
425
+ await assertExec(
426
+ cfg.target,
427
+ `cd ${cfg.target.remoteDir} && docker compose restart caddy`,
428
+ );
429
+
396
430
  // Wait for the registry vhost to respond (Caddy ACME issuance takes a few
397
431
  // seconds on first start), then docker login on the host. This caches
398
432
  // credentials in /home/<user>/.docker/config.json so subsequent `docker
@@ -24,6 +24,15 @@ export type RemoteState =
24
24
  export interface RemoteStateMarker {
25
25
  cliVersion: string;
26
26
  configHash: string;
27
+ /**
28
+ * sha256 wyrenderowanych artefaktów stacku (Caddyfile + docker-compose).
29
+ * Wykrywa zmianę GENERATORA w obrębie tej samej wersji CLI — bez tego
30
+ * republish tej samej wersji z poprawionym Caddyfile (np. dodane `encode`)
31
+ * nie odświeżał configu na hoście (needUpStack patrzył tylko na cliVersion
32
+ * i configHash). Opcjonalny: starsze markery go nie mają → traktowane jako
33
+ * zmiana (wymusza jednorazowy upStack, po którym marker dostaje hash).
34
+ */
35
+ artifactsHash?: string;
27
36
  updatedAt: string;
28
37
  }
29
38
 
@@ -8,7 +8,7 @@ import {
8
8
  type ArcServer,
9
9
  } from "@arcote.tech/arc-host";
10
10
  import { timingSafeEqual } from "crypto";
11
- import { existsSync, mkdirSync } from "fs";
11
+ import { existsSync, mkdirSync, readFileSync } from "fs";
12
12
  import { join } from "path";
13
13
  import { readTranslationsConfig } from "../i18n";
14
14
  import { err, ok, type BuildManifest, type WorkspaceInfo } from "./shared";
@@ -125,19 +125,104 @@ async function resolveDbAdapterFactory(dbPath: string) {
125
125
  // Shell HTML
126
126
  // ---------------------------------------------------------------------------
127
127
 
128
- export function generateShellHtml(
129
- appName: string,
130
- manifest?: { title: string; favicon?: string },
131
- initial?: { file: string; hash: string },
132
- stylesHash?: string,
133
- shell?: {
134
- imports: Readonly<Record<string, string>>;
135
- peers: Readonly<Record<string, string>>;
136
- },
128
+ /** Escape a JSON string for safe inlining inside `<script>` (prevents an
129
+ * embedded `</script>` or `<!--` from breaking out of the element). */
130
+ function inlineJson(value: unknown): string {
131
+ return JSON.stringify(value)
132
+ .replace(/</g, "\\u003c")
133
+ .replace(/>/g, "\\u003e")
134
+ // U+2028/U+2029 są terminatorami linii w JS — zepsułyby inline <script>.
135
+ .replace(/[\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`);
136
+ }
137
+
138
+ /** Decode every `arc_token*` cookie in a Cookie header to token payloads (JWT
139
+ * decoded WITHOUT verification — same as `/api/modules` manifest filtering;
140
+ * runtime API calls re-verify). Lets the server personalize the HTML shell. */
141
+ function tokenPayloadsFromCookies(cookieHeader: string | null): any[] {
142
+ if (!cookieHeader) return [];
143
+ const payloads: any[] = [];
144
+ for (const part of cookieHeader.split(";")) {
145
+ const eq = part.indexOf("=");
146
+ if (eq < 0) continue;
147
+ const name = part.slice(0, eq).trim();
148
+ if (name !== "arc_token" && !name.startsWith("arc_token_")) continue;
149
+ const payload = decodeTokenPayload(decodeURIComponent(part.slice(eq + 1).trim()));
150
+ if (payload) payloads.push(payload);
151
+ }
152
+ return payloads;
153
+ }
154
+
155
+ /** Pick the render locale: `arc-locale` cookie → Accept-Language (first match
156
+ * against available locales, exact or language-prefix) → sourceLocale. */
157
+ function pickLocale(
158
+ cookieHeader: string | null,
159
+ acceptLanguage: string | null,
160
+ locales: string[],
161
+ sourceLocale: string,
137
162
  ): string {
163
+ const fromCookie = cookieHeader?.match(/(?:^|;)\s*arc-locale=([^;]+)/)?.[1];
164
+ if (fromCookie && locales.includes(decodeURIComponent(fromCookie))) {
165
+ return decodeURIComponent(fromCookie);
166
+ }
167
+ for (const tag of (acceptLanguage ?? "").split(",")) {
168
+ const lang = tag.split(";")[0].trim();
169
+ if (!lang) continue;
170
+ const exact = locales.find((l) => l.toLowerCase() === lang.toLowerCase());
171
+ if (exact) return exact;
172
+ const prefix = locales.find(
173
+ (l) => l.toLowerCase().split("-")[0] === lang.toLowerCase().split("-")[0],
174
+ );
175
+ if (prefix) return prefix;
176
+ }
177
+ return sourceLocale;
178
+ }
179
+
180
+ export interface ShellHtmlOptions {
181
+ appName: string;
182
+ /** PWA/app manifest metadata ({title, favicon}). */
183
+ appManifest?: { title: string; favicon?: string };
184
+ /** Full build manifest (initial + groups + sharedChunks + stylesHash + shell). */
185
+ buildManifest?: BuildManifest;
186
+ /** Access rules for per-token manifest filtering at render time. */
187
+ moduleAccessMap: Map<string, ModuleAccess>;
188
+ /** Workspace `.arc/platform` dir — to load compiled locale catalogs + config. */
189
+ arcDir: string;
190
+ rootDir: string;
191
+ /** Request `Cookie` header (tokens + locale) and `Accept-Language`. */
192
+ cookieHeader: string | null;
193
+ acceptLanguage: string | null;
194
+ }
195
+
196
+ /**
197
+ * Render the SPA shell HTML — PERSONALIZED per request from cookies. Reads the
198
+ * caller's `arc_token*` cookies to inline exactly the module manifest they can
199
+ * access (`window.__ARC_MANIFEST__`) + `<link rel=modulepreload>` for the
200
+ * chunks they'll load, and their locale's catalog (`window.__ARC_I18N__`). This
201
+ * collapses the boot waterfall: no `/api/modules`, `/api/translations`, or
202
+ * startup `/locales/*.json` round-trip, and chunks fetch in parallel with the
203
+ * initial bundle instead of being discovered after parse.
204
+ */
205
+ export async function generateShellHtml(opts: ShellHtmlOptions): Promise<string> {
206
+ const {
207
+ appName,
208
+ appManifest,
209
+ buildManifest,
210
+ moduleAccessMap,
211
+ arcDir,
212
+ rootDir,
213
+ cookieHeader,
214
+ acceptLanguage,
215
+ } = opts;
216
+
217
+ if (!buildManifest?.initial) {
218
+ throw new Error("generateShellHtml: initial bundle missing from manifest");
219
+ }
220
+ const initial = buildManifest.initial;
221
+ const initialUrl = `/browser/${initial.file}`;
222
+ const shell = buildManifest.shell;
223
+
138
224
  // OpenTelemetry config — injected as a global so the browser SDK chunk
139
- // (lazy-loaded by start-app.ts) can pick it up without a fetch. Endpoint
140
- // is same-origin so Caddy can apply CORS + auth uniformly.
225
+ // (lazy-loaded by start-app.ts) can pick it up without a fetch.
141
226
  const otelConfig = process.env.ARC_OTEL_ENABLED === "true"
142
227
  ? {
143
228
  enabled: true,
@@ -148,36 +233,82 @@ export function generateShellHtml(
148
233
  }
149
234
  : null;
150
235
  const otelTag = otelConfig
151
- ? `\n <script>window.__ARC_OTEL_CONFIG=${JSON.stringify(otelConfig)};</script>`
236
+ ? `\n <script>window.__ARC_OTEL_CONFIG=${inlineJson(otelConfig)};</script>`
152
237
  : "";
153
- // Initial bundle carries framework, public modules, and PlatformApp re-export.
154
- // Bundled build: no importmap splitting:true dedups everything into
155
- // chunk-<hash>.js. Federated build (arc.federation): framework peers są
156
- // external i rozwiązuje je import mapa peer-shella; obowiązuje ona też
157
- // moduły importowane cross-origin (chunki aplikacji partnerskich) —
158
- // spec/module-federation.md.
159
- const initialUrl = initial ? `/browser/${initial.file}` : null;
160
- if (!initialUrl) {
161
- throw new Error("generateShellHtml: initial bundle missing from manifest");
162
- }
238
+
239
+ // Federated build only: framework peers are external import map (peer-shell).
163
240
  const importMapTag = shell
164
- ? `\n <script type="importmap">${JSON.stringify({ imports: shell.imports })}</script>` +
165
- `\n <script>window.__ARC_PEERS__=${JSON.stringify(shell.peers)};</script>`
241
+ ? `\n <script type="importmap">${inlineJson({ imports: shell.imports })}</script>` +
242
+ `\n <script>window.__ARC_PEERS__=${inlineJson(shell.peers)};</script>`
243
+ : "";
244
+
245
+ const stylesQs = buildManifest.stylesHash
246
+ ? `?v=${buildManifest.stylesHash.slice(0, 16)}`
166
247
  : "";
167
- // Append the styles content hash as a query string. Filenames are stable
168
- // (/styles.css, /theme.css) but their content changes between builds; the
169
- // hash invalidates the browser cache exactly when the content changes,
170
- // letting us serve them with `Cache-Control: immutable`.
171
- const stylesQs = stylesHash ? `?v=${stylesHash.slice(0, 16)}` : "";
248
+
249
+ // --- Per-request manifest: only the groups the caller's tokens unlock, each
250
+ // with a signed URL. Same function `/api/modules` uses, so the inlined
251
+ // manifest is identical to what the client would have fetched. ---
252
+ const tokenPayloads = tokenPayloadsFromCookies(cookieHeader);
253
+ const filtered = await filterManifestForTokens(
254
+ buildManifest,
255
+ moduleAccessMap,
256
+ tokenPayloads,
257
+ );
258
+
259
+ // --- Preloads: chunks the browser will need, fetched in parallel with the
260
+ // initial bundle. initial's static closure + each accessible group's entry +
261
+ // its static closure. Deduped; all `/browser/` immutable content-addressed. ---
262
+ const preloadUrls = new Set<string>();
263
+ preloadUrls.add(initialUrl);
264
+ for (const c of initial.staticChunks ?? []) preloadUrls.add(`/browser/${c}`);
265
+ for (const group of Object.values(filtered.groups)) {
266
+ if (group.url) preloadUrls.add(group.url);
267
+ for (const c of group.staticChunks ?? []) preloadUrls.add(`/browser/${c}`);
268
+ }
269
+ const preloadTags = [...preloadUrls]
270
+ .map((u) => `\n <link rel="modulepreload" href="${u}" />`)
271
+ .join("");
272
+
273
+ const manifestTag = `\n <script>window.__ARC_MANIFEST__=${inlineJson({
274
+ initial: filtered.initial,
275
+ groups: filtered.groups,
276
+ sharedChunks: filtered.sharedChunks,
277
+ stylesHash: filtered.stylesHash,
278
+ buildTime: filtered.buildTime,
279
+ })};</script>`;
280
+
281
+ // --- i18n: detect locale from cookie/Accept-Language, inline the catalog so
282
+ // the client renders translated on first paint (no /api/translations +
283
+ // /locales round-trips, no render-blocking wait). ---
284
+ // Always inline __ARC_I18N__ (even when the app has no translations) so the
285
+ // client NEVER round-trips to /api/translations or the startup /locales/*.json.
286
+ let lang = "en";
287
+ const tconfig = readTranslationsConfig(rootDir);
288
+ let messages: Record<string, string> = {};
289
+ if (tconfig) {
290
+ lang = pickLocale(cookieHeader, acceptLanguage, tconfig.locales, tconfig.sourceLocale);
291
+ try {
292
+ messages = JSON.parse(readFileSync(join(arcDir, "locales", `${lang}.json`), "utf-8"));
293
+ } catch {
294
+ // catalog missing (e.g. sourceLocale has no .po) → empty; <Trans> falls
295
+ // back to the source string. No round-trip regression either way.
296
+ }
297
+ }
298
+ const i18nTag = `\n <script>window.__ARC_I18N__=${inlineJson({
299
+ locale: lang,
300
+ config: tconfig,
301
+ messages,
302
+ })};</script>`;
303
+
172
304
  return `<!doctype html>
173
- <html lang="en">
305
+ <html lang="${lang}">
174
306
  <head>
175
307
  <meta charset="UTF-8" />
176
308
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
177
- <title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `\n <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `\n <link rel="manifest" href="/manifest.json${stylesQs}">` : ""}
309
+ <title>${appManifest?.title ?? appName}</title>${appManifest?.favicon ? `\n <link rel="icon" href="${appManifest.favicon}">` : ""}${appManifest ? `\n <link rel="manifest" href="/manifest.json${stylesQs}">` : ""}
178
310
  <link rel="stylesheet" href="/styles.css${stylesQs}" />
179
- <link rel="stylesheet" href="/theme.css${stylesQs}" />${importMapTag}
180
- <link rel="modulepreload" href="${initialUrl}" />${otelTag}
311
+ <link rel="stylesheet" href="/theme.css${stylesQs}" />${importMapTag}${preloadTags}${manifestTag}${i18nTag}${otelTag}
181
312
  </head>
182
313
  <body>
183
314
  <div id="root"></div>
@@ -868,17 +999,20 @@ function headlessFallbackHandler(
868
999
  };
869
1000
  }
870
1001
 
871
- function spaFallbackHandler(getShellHtml: () => string): ArcHttpHandler {
872
- return (_req, _url, ctx) => {
873
- return new Response(getShellHtml(), {
1002
+ function spaFallbackHandler(
1003
+ getShellHtml: (req: Request) => Promise<string>,
1004
+ ): ArcHttpHandler {
1005
+ return async (req, _url, ctx) => {
1006
+ return new Response(await getShellHtml(req), {
874
1007
  headers: {
875
1008
  ...ctx.corsHeaders,
876
1009
  "Content-Type": "text/html",
877
- // Stabilny URL, zmienna treść (embeduje initial.<hash>.js + styles ?v=).
878
- // Kotwica całego łańcucha musi być zawsze rewalidowana, inaczej po
879
- // deployu przeglądarka/CDN podaje stary shell wskazujący na nieistniejące
880
- // już bundle. Hash nie pomoże: to URL trasy, którego nie da się zahashować.
881
- "Cache-Control": "no-cache, must-revalidate",
1010
+ // Stabilny URL, zmienna treść teraz PERSONALIZOWANA per cookie
1011
+ // (manifest modułów + preloady + katalog i18n zależą od tokenów/locale
1012
+ // usera). `private` żeby proxy/CDN nie współdzieliło HTML między userami;
1013
+ // `no-cache` bo initial.<hash>.js/styles ?v= zmieniają się co build.
1014
+ "Cache-Control": "no-cache, must-revalidate, private",
1015
+ Vary: "Cookie, Accept-Language",
882
1016
  },
883
1017
  });
884
1018
  };
@@ -942,16 +1076,20 @@ export async function startPlatformServer(
942
1076
  };
943
1077
  const getMapUrl = () => mapUrl;
944
1078
 
945
- // Recompute on every request — manifest.initial.hash changes when public
946
- // modules are rebuilt in dev, and we want the new URL in the HTML.
947
- const getShellHtml = (): string =>
948
- generateShellHtml(
949
- ws.appName,
950
- ws.manifest,
951
- manifest?.initial,
952
- manifest?.stylesHash,
953
- manifest?.shell,
954
- );
1079
+ // Recompute on every request — the shell is personalized from the request's
1080
+ // cookies (module manifest + preloads + i18n) and manifest.initial.hash
1081
+ // changes when public modules are rebuilt in dev.
1082
+ const getShellHtml = (req: Request): Promise<string> =>
1083
+ generateShellHtml({
1084
+ appName: ws.appName,
1085
+ appManifest: ws.manifest,
1086
+ buildManifest: manifest,
1087
+ moduleAccessMap,
1088
+ arcDir: ws.arcDir,
1089
+ rootDir: ws.rootDir,
1090
+ cookieHeader: req.headers.get("Cookie"),
1091
+ acceptLanguage: req.headers.get("Accept-Language"),
1092
+ });
955
1093
  const sseClients = new Set<ReadableStreamDefaultController>();
956
1094
 
957
1095
  // Headless: bez shella SPA — fallback JSON (spec/platform-headless.md).