@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/dist/index.js +610 -298
- package/package.json +10 -10
- package/src/builder/module-builder.ts +61 -7
- 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.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.
|
|
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.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.
|
|
35
|
-
"@arcote.tech/arc-map": "^0.8.
|
|
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 =
|
|
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:
|
|
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
|
+
}
|
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);
|