@arcote.tech/arc-cli 0.8.4 → 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/dist/index.js +397 -193
- package/package.json +10 -10
- package/src/builder/module-builder.ts +43 -2
- package/src/builder/seed-guard.ts +143 -0
- package/src/deploy/bootstrap.ts +13 -0
- package/src/platform/server.ts +190 -52
- package/src/platform/shared.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-cli",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
16
|
-
"@arcote.tech/arc-ds": "^0.8.
|
|
17
|
-
"@arcote.tech/arc-react": "^0.8.
|
|
18
|
-
"@arcote.tech/arc-host": "^0.8.
|
|
19
|
-
"@arcote.tech/arc-adapter-db-sqlite": "^0.8.
|
|
20
|
-
"@arcote.tech/arc-adapter-db-postgres": "^0.8.
|
|
21
|
-
"@arcote.tech/arc-otel": "^0.8.
|
|
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.
|
|
35
|
-
"@arcote.tech/arc-map": "^0.8.
|
|
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
|
+
}
|
package/src/deploy/bootstrap.ts
CHANGED
|
@@ -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
|
package/src/platform/server.ts
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
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=${
|
|
236
|
+
? `\n <script>window.__ARC_OTEL_CONFIG=${inlineJson(otelConfig)};</script>`
|
|
152
237
|
: "";
|
|
153
|
-
|
|
154
|
-
//
|
|
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">${
|
|
165
|
-
`\n <script>window.__ARC_PEERS__=${
|
|
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
|
-
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
const
|
|
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="
|
|
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>${
|
|
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(
|
|
872
|
-
|
|
873
|
-
|
|
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ść
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
//
|
|
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 —
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
ws.
|
|
951
|
-
manifest
|
|
952
|
-
manifest
|
|
953
|
-
|
|
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).
|
package/src/platform/shared.ts
CHANGED
|
@@ -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
|
|
176
|
-
// else chunk grouping (per-package) silently
|
|
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);
|