@arcote.tech/arc-cli 0.8.4 → 0.8.6

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.4",
3
+ "version": "0.8.6",
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.4",
16
- "@arcote.tech/arc-ds": "^0.8.4",
17
- "@arcote.tech/arc-react": "^0.8.4",
18
- "@arcote.tech/arc-host": "^0.8.4",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.8.4",
20
- "@arcote.tech/arc-adapter-db-postgres": "^0.8.4",
21
- "@arcote.tech/arc-otel": "^0.8.4",
15
+ "@arcote.tech/arc": "^0.8.6",
16
+ "@arcote.tech/arc-ds": "^0.8.6",
17
+ "@arcote.tech/arc-react": "^0.8.6",
18
+ "@arcote.tech/arc-host": "^0.8.6",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.8.6",
20
+ "@arcote.tech/arc-adapter-db-postgres": "^0.8.6",
21
+ "@arcote.tech/arc-otel": "^0.8.6",
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.4",
35
- "@arcote.tech/arc-map": "^0.8.4",
34
+ "@arcote.tech/platform": "^0.8.6",
35
+ "@arcote.tech/arc-map": "^0.8.6",
36
36
  "@clack/prompts": "^0.9.0",
37
37
  "commander": "^11.1.0",
38
38
  "chokidar": "^3.5.3",
@@ -248,12 +248,27 @@ function workspaceSourcePlugin(srcByName: Map<string, string>): import("bun").Bu
248
248
  // CELOWO NIE stubujemy „przeglądarkowo-bezpiecznych" builtinów (buffer, util,
249
249
  // events, path, url, string_decoder, querystring, assert, process) — te bywają
250
250
  // realnie używane po stronie klienta i mają lekkie, działające polyfille.
251
- const SERVER_ONLY_NODE_BUILTINS = new Set([
251
+ const SERVER_ONLY_NODE_BUILTINS = [
252
252
  "crypto", "stream", "zlib", "fs", "fs/promises", "net", "tls", "http",
253
253
  "https", "http2", "dns", "dgram", "child_process", "worker_threads",
254
254
  "cluster", "os", "v8", "vm", "readline", "repl", "tty", "perf_hooks",
255
255
  "inspector", "async_hooks", "diagnostics_channel", "module", "constants",
256
- ]);
256
+ ];
257
+
258
+ /**
259
+ * Filtr onResolve dopasowujący DOKŁADNIE serwerowe builtiny (`node:crypto`,
260
+ * `crypto`, `fs/promises`, …) i NIC innego. KRYTYCZNE, że jest precyzyjny:
261
+ * szeroki filtr `/^(node:)?[a-z_/]+$/` łapał też gołe pakiety npm (np. `unified`,
262
+ * `vfile`, `micromark`), a samo przepuszczenie ich przez onResolve — nawet ze
263
+ * zwrotem `undefined` — korumpuje tree-shaking Buna pod minify: pakiet znika z
264
+ * bundla, ale zostaje wiszące wywołanie (`ReferenceError: X is not defined` w
265
+ * produkcji — react-markdown/`unified()` na stronach z markdownem).
266
+ */
267
+ const NODE_STUB_FILTER = new RegExp(
268
+ `^(node:)?(${SERVER_ONLY_NODE_BUILTINS.map((b) =>
269
+ b.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"),
270
+ ).join("|")})$`,
271
+ );
257
272
 
258
273
  /**
259
274
  * Zastępuje serwerowe builtiny node (SERVER_ONLY_NODE_BUILTINS) rzucającym
@@ -266,9 +281,7 @@ function nodeServerBuiltinStubPlugin(): import("bun").BunPlugin {
266
281
  return {
267
282
  name: "node-server-builtin-stub",
268
283
  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;
284
+ build.onResolve({ filter: NODE_STUB_FILTER }, (args) => {
272
285
  return { path: args.path, namespace: "node-server-stub" };
273
286
  });
274
287
  build.onLoad({ filter: /.*/, namespace: "node-server-stub" }, (args) => ({
@@ -840,10 +853,14 @@ export interface BrowserGroupEntry {
840
853
  readonly hash: string;
841
854
  /** Module names registered by this group (for manifest filterability). */
842
855
  readonly modules: readonly string[];
856
+ /** Shared chunks this entry statically imports (transitive closure). The
857
+ * server `<link rel=modulepreload>`s them so the browser fetches them in
858
+ * parallel with the entry instead of discovering them after parse. */
859
+ readonly staticChunks: readonly string[];
843
860
  }
844
861
 
845
862
  export interface BrowserAppResult {
846
- readonly initial: { file: string; hash: string };
863
+ readonly initial: { file: string; hash: string; staticChunks: readonly string[] };
847
864
  /** Keyed by token.name. `initial` is NOT here — it's separate. */
848
865
  readonly groups: Record<string, BrowserGroupEntry>;
849
866
  /** Auto-shared chunks emitted by Bun.build splitting. Public, unsigned. */
@@ -859,6 +876,33 @@ interface BrowserOutFile {
859
876
  bytes: Uint8Array;
860
877
  }
861
878
 
879
+ /** Statyczne `import"./chunk-x.js"` / `from"./chunk-x.js"` (NIE `import(...)`). */
880
+ const STATIC_CHUNK_IMPORT =
881
+ /(?:^|[;}\s])import\s*["']\.\/(chunk-[a-z0-9]+\.js)["']|from\s*["']\.\/(chunk-[a-z0-9]+\.js)["']/g;
882
+
883
+ /** Tranzytywne domknięcie shared-chunków statycznie importowanych przez entry
884
+ * — z bajtów w pamięci. `chunkBytes` = nazwa chunku → bajty. */
885
+ function staticChunkClosure(
886
+ entryBytes: Uint8Array,
887
+ chunkBytes: Map<string, Uint8Array>,
888
+ ): string[] {
889
+ const seen = new Set<string>();
890
+ const stack: Uint8Array[] = [entryBytes];
891
+ while (stack.length) {
892
+ const src = Buffer.from(stack.pop()!).toString("utf8");
893
+ STATIC_CHUNK_IMPORT.lastIndex = 0;
894
+ let m: RegExpExecArray | null;
895
+ while ((m = STATIC_CHUNK_IMPORT.exec(src))) {
896
+ const chunk = m[1] ?? m[2]!;
897
+ if (seen.has(chunk)) continue;
898
+ seen.add(chunk);
899
+ const b = chunkBytes.get(chunk);
900
+ if (b) stack.push(b);
901
+ }
902
+ }
903
+ return [...seen];
904
+ }
905
+
862
906
  // ---------------------------------------------------------------------------
863
907
  // Build stats (ARC_BUILD_STATS=1) — rozbicie każdego chunku na biblioteki.
864
908
  //
@@ -1343,24 +1387,34 @@ export async function buildBrowserApp(
1343
1387
  // their name (Bun auto-emits `chunk-<hash>.js`); we leave those alone.
1344
1388
  let initialFile = "";
1345
1389
  let initialHash = "";
1390
+ let initialStaticChunks: string[] = [];
1346
1391
  const groups: Record<string, BrowserGroupEntry> = {};
1347
1392
  const sharedChunks: string[] = [];
1348
1393
 
1394
+ // Nazwa chunku → bajty (do policzenia per-wejście domknięcia importów).
1395
+ const chunkBytes = new Map<string, Uint8Array>();
1396
+ for (const out of outFiles) {
1397
+ if (out.kind === "chunk") chunkBytes.set(out.name, out.bytes);
1398
+ }
1399
+
1349
1400
  for (const out of outFiles) {
1350
1401
  if (out.kind === "entry-point") {
1351
1402
  const hash = sha256Hex(out.bytes).slice(0, 16);
1352
1403
  const stem = out.name.replace(/\.js$/, "");
1353
1404
  const finalName = `${stem}.${hash}.js`;
1354
1405
  writeFileSync(join(outDir, finalName), out.bytes);
1406
+ const staticChunks = staticChunkClosure(out.bytes, chunkBytes);
1355
1407
 
1356
1408
  if (stem === "initial") {
1357
1409
  initialFile = finalName;
1358
1410
  initialHash = hash;
1411
+ initialStaticChunks = staticChunks;
1359
1412
  } else {
1360
1413
  groups[stem] = {
1361
1414
  file: finalName,
1362
1415
  hash,
1363
1416
  modules: groupModuleMap.get(stem) ?? [],
1417
+ staticChunks,
1364
1418
  };
1365
1419
  }
1366
1420
  } else if (out.kind === "chunk") {
@@ -1402,7 +1456,7 @@ export async function buildBrowserApp(
1402
1456
  }
1403
1457
 
1404
1458
  const manifest: BrowserAppResult = {
1405
- initial: { file: initialFile, hash: initialHash },
1459
+ initial: { file: initialFile, hash: initialHash, staticChunks: initialStaticChunks },
1406
1460
  groups,
1407
1461
  sharedChunks,
1408
1462
  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
+ }
@@ -414,6 +414,19 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
414
414
  `cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures caddy registry && docker compose up -d caddy registry`,
415
415
  );
416
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
+
417
430
  // Wait for the registry vhost to respond (Caddy ACME issuance takes a few
418
431
  // seconds on first start), then docker login on the host. This caches
419
432
  // credentials in /home/<user>/.docker/config.json so subsequent `docker
@@ -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).
@@ -23,6 +23,7 @@ import {
23
23
  type BuildCache,
24
24
  } from "../builder/build-cache";
25
25
  import { extractAccessMap } from "../builder/access-extractor";
26
+ import { assertSeedsServerGated } from "../builder/seed-guard";
26
27
  import { buildPeerShell, type PeerShellResult } from "../builder/peer-shell";
27
28
  import type { FederationConfig } from "@arcote.tech/platform";
28
29
  import {
@@ -172,9 +173,14 @@ export async function buildAll(
172
173
 
173
174
  log(`Building (concurrency parallel${noCache ? ", no-cache" : ""})...`);
174
175
 
175
- // Phase 0 — invariant check before any work: one module() per package,
176
- // else chunk grouping (per-package) silently mis-assigns the extra module.
176
+ // Phase 0 — invariant checks before any work.
177
+ // (a) one module() per package, else chunk grouping (per-package) silently
178
+ // mis-assigns the extra module.
177
179
  assertOneModulePerPackage(ws.packages);
180
+ // (b) seedy owinięte ONLY_SERVER — inaczej literał danych seed wycieka do
181
+ // publicznego bundla klienta (patrz seed-guard.ts). Rusza NAJWCZEŚNIEJ:
182
+ // przed drogim buildem serwera i access-extractorem — szybki, czytelny fail.
183
+ assertSeedsServerGated(ws.packages, ws.rootDir);
178
184
 
179
185
  // Phase 1 — per-package BROWSER context bundles + type declarations.
180
186
  await buildContextPackages(ws.rootDir, ws.packages, cache, noCache);