@arcote.tech/arc-cli 0.8.1 → 0.8.3
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 +369 -67
- package/package.json +10 -10
- package/src/builder/chunk-planner.ts +14 -0
- package/src/builder/module-builder.ts +432 -30
- package/src/commands/platform-build.ts +6 -1
- package/src/commands/platform-pairing-token.ts +53 -0
- package/src/deploy/caddyfile.ts +3 -0
- package/src/index.ts +14 -2
- package/src/platform/server.ts +6 -8
- package/src/platform/shared.ts +68 -21
- package/src/platform/startup.ts +32 -22
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { initContextHandler } from "../platform/server";
|
|
4
|
+
import { err, loadServerContext, resolveWorkspace } from "../platform/shared";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `arc platform pairing-token` — generuje JEDNORAZOWY kod parowania federacji
|
|
8
|
+
* i zapisuje go do bazy (agregat `pairingCode`), po czym wypisuje go operatorowi.
|
|
9
|
+
*
|
|
10
|
+
* Kod podaje się w HOŚCIE przy instalacji aplikacji; host rejestruje nim swój
|
|
11
|
+
* klucz publiczny (komenda `federationHost.register`), a kod zostaje zużyty.
|
|
12
|
+
* Uruchamiane po SSH na serwerze aplikacji — czyta ten sam kontekst i DB co
|
|
13
|
+
* serwer (postgres przez `DATABASE_URL`, inaczej sqlite w `.arc/data`).
|
|
14
|
+
*/
|
|
15
|
+
export async function platformPairingToken(): Promise<void> {
|
|
16
|
+
const ws = resolveWorkspace();
|
|
17
|
+
const { context } = await loadServerContext(ws);
|
|
18
|
+
if (!context) {
|
|
19
|
+
err("Brak zbudowanego kontekstu. Uruchom najpierw `arc platform build`.");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// TEN SAM plik/baza co serwer platformy (startup.ts): sqlite
|
|
24
|
+
// `.arc/data/<mode>.db` w dev/prod-sqlite, albo postgres przez DATABASE_URL
|
|
25
|
+
// (wtedy dbPath jest ignorowany). Bez tego kod trafiłby do innej bazy niż
|
|
26
|
+
// serwer i rejestracja by go nie znalazła.
|
|
27
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
28
|
+
const dbPath = join(ws.rootDir, ".arc", "data", isProd ? "prod.db" : "dev.db");
|
|
29
|
+
const handler = await initContextHandler(context, dbPath);
|
|
30
|
+
|
|
31
|
+
// Kod = sekret pokazywany raz operatorowi. `_id` agregatu generuje handler.
|
|
32
|
+
const code = randomBytes(24).toString("base64url");
|
|
33
|
+
const res = (await handler.executeCommand(
|
|
34
|
+
"pairingCode.issue",
|
|
35
|
+
{ code },
|
|
36
|
+
null,
|
|
37
|
+
{ internal: true },
|
|
38
|
+
)) as { ok?: boolean; error?: string } | undefined;
|
|
39
|
+
|
|
40
|
+
if (!res?.ok) {
|
|
41
|
+
err(
|
|
42
|
+
`Nie udało się zapisać kodu parowania${res?.error ? `: ${res.error}` : ""}. ` +
|
|
43
|
+
"Czy aplikacja wystawia agregat `pairingCode` (federacja)?",
|
|
44
|
+
);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
process.stdout.write(
|
|
49
|
+
`\n Kod parowania (jednorazowy):\n\n ${code}\n\n` +
|
|
50
|
+
" Podaj go w hoście przy instalacji aplikacji. Wygasa po pierwszym użyciu.\n\n",
|
|
51
|
+
);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
package/src/deploy/caddyfile.ts
CHANGED
|
@@ -61,6 +61,9 @@ export function generateCaddyfile(cfg: DeployConfig): string {
|
|
|
61
61
|
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
62
62
|
lines.push(`${env.domain} {${tlsDirective}`);
|
|
63
63
|
lines.push(...logDirective);
|
|
64
|
+
// Kompresja odpowiedzi (JS/CSS/JSON z hosta idą plaintext — host nie
|
|
65
|
+
// kompresuje, więc bez tej dyrektywy przeglądarka pobiera pełne bajty).
|
|
66
|
+
lines.push(" encode zstd gzip");
|
|
64
67
|
if (observability) {
|
|
65
68
|
lines.push(" handle_path /otel/* {");
|
|
66
69
|
lines.push(" reverse_proxy otel-collector:4318");
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { dev } from "./commands/dev";
|
|
|
6
6
|
import { platformBuild } from "./commands/platform-build";
|
|
7
7
|
import { platformDeploy } from "./commands/platform-deploy";
|
|
8
8
|
import { platformDev } from "./commands/platform-dev";
|
|
9
|
+
import { platformPairingToken } from "./commands/platform-pairing-token";
|
|
9
10
|
import { platformStart } from "./commands/platform-start";
|
|
10
11
|
|
|
11
12
|
// Create the program
|
|
@@ -51,8 +52,12 @@ platform
|
|
|
51
52
|
.command("build")
|
|
52
53
|
.description("Build platform for production")
|
|
53
54
|
.option("--no-cache", "Force full rebuild")
|
|
54
|
-
.
|
|
55
|
-
|
|
55
|
+
.option(
|
|
56
|
+
"--stats",
|
|
57
|
+
"Wypisz rozbicie chunków na biblioteki (per-wejście raw/gzip + najcięższe liby, ext/wewn). Wymusza pełny build.",
|
|
58
|
+
)
|
|
59
|
+
.action((opts: { cache?: boolean; stats?: boolean }) =>
|
|
60
|
+
platformBuild({ noCache: opts.cache === false, stats: opts.stats }),
|
|
56
61
|
);
|
|
57
62
|
|
|
58
63
|
platform
|
|
@@ -60,6 +65,13 @@ platform
|
|
|
60
65
|
.description("Start platform in production mode (requires prior build)")
|
|
61
66
|
.action(platformStart);
|
|
62
67
|
|
|
68
|
+
platform
|
|
69
|
+
.command("pairing-token")
|
|
70
|
+
.description(
|
|
71
|
+
"Generate a one-time federation pairing code (writes to DB, prints it)",
|
|
72
|
+
)
|
|
73
|
+
.action(platformPairingToken);
|
|
74
|
+
|
|
63
75
|
platform
|
|
64
76
|
.command("deploy [env]")
|
|
65
77
|
.description(
|
package/src/platform/server.ts
CHANGED
|
@@ -573,14 +573,12 @@ function handleDiscovery(
|
|
|
573
573
|
if (!federation) {
|
|
574
574
|
return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
|
|
575
575
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
583
|
-
|
|
576
|
+
// Discovery jest PUBLICZNE (bez bramy tokenem). Zwraca wyłącznie metadane
|
|
577
|
+
// federacji (slug, moduły, uprawnienia, peers) — a te i tak wskazują na
|
|
578
|
+
// publiczne chunki `/browser-fed/`. Bramą PAROWANIA jest osobny JEDNORAZOWY
|
|
579
|
+
// kod sprawdzany przy rejestracji klucza (`federationHost.register`), nie ten
|
|
580
|
+
// odczyt. Host dzięki temu może pokazać ekran zgody (co instaluje), zanim
|
|
581
|
+
// operator poda kod.
|
|
584
582
|
const fed = manifest.federation;
|
|
585
583
|
// Adres własny: za proxy `req.url` jest wewnętrzny, więc pozwalamy nadpisać
|
|
586
584
|
// przez env. Konsument i tak zna nas z adresu, którego użył — to pole jest
|
package/src/platform/shared.ts
CHANGED
|
@@ -25,7 +25,11 @@ import {
|
|
|
25
25
|
import { extractAccessMap } from "../builder/access-extractor";
|
|
26
26
|
import { buildPeerShell, type PeerShellResult } from "../builder/peer-shell";
|
|
27
27
|
import type { FederationConfig } from "@arcote.tech/platform";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
assertOneModulePerPackage,
|
|
30
|
+
packageDeclaresModule,
|
|
31
|
+
planChunks,
|
|
32
|
+
} from "../builder/chunk-planner";
|
|
29
33
|
import { collectFrameworkDeps } from "../builder/dependency-collector";
|
|
30
34
|
import {
|
|
31
35
|
mtimeOf,
|
|
@@ -144,6 +148,11 @@ export function resolveWorkspace(): WorkspaceInfo {
|
|
|
144
148
|
|
|
145
149
|
export interface BuildOptions {
|
|
146
150
|
noCache?: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Minifikacja bundli przeglądarki (domyślnie true — build/deploy).
|
|
153
|
+
* Dev watcher podaje false: czytelny kod w devtools, szybszy rebuild.
|
|
154
|
+
*/
|
|
155
|
+
minify?: boolean;
|
|
147
156
|
}
|
|
148
157
|
|
|
149
158
|
/**
|
|
@@ -157,6 +166,7 @@ export async function buildAll(
|
|
|
157
166
|
): Promise<BuildManifest> {
|
|
158
167
|
const cache = loadBuildCache(ws.arcDir);
|
|
159
168
|
const noCache = opts.noCache ?? false;
|
|
169
|
+
const minify = opts.minify ?? true;
|
|
160
170
|
const themePath = ws.rootPkg.arc?.theme as string | undefined;
|
|
161
171
|
const federation = getFederationConfig(ws);
|
|
162
172
|
|
|
@@ -201,13 +211,20 @@ export async function buildAll(
|
|
|
201
211
|
const moduleNameOf = (name: string) => name.split("/").pop() ?? name;
|
|
202
212
|
const exposureOf = (pkgName: string): "local" | "external" | "both" =>
|
|
203
213
|
accessMap.exposure[moduleNameOf(pkgName)]?.exposure ?? "local";
|
|
214
|
+
// Tylko pakiety DEKLARUJĄCE moduł Arc są członami wchodzącymi do initial/grup.
|
|
215
|
+
// Czyste biblioteki (np. @ndt/email — szablony maili) NIE mogą być tu, bo
|
|
216
|
+
// initial robiłoby `import "@pkg"` i przy `sideEffects:true` (patch buildu)
|
|
217
|
+
// wciągałoby całe ich drzewo zależności do pierwszego wczytania. Taka libka
|
|
218
|
+
// trafia do bundla NORMALNIE — dopiero gdy realnie zaimportuje ją moduł
|
|
219
|
+
// (wtedy podlega tree-shakingowi / lazy-split).
|
|
220
|
+
const modulePackages = ws.packages.filter(packageDeclaresModule);
|
|
204
221
|
// Normalna platforma: moduły local/both (external-only ukryte w macierzystej
|
|
205
222
|
// platformie). Host bierze wszystko (nie ukrywa niczego przed sobą).
|
|
206
223
|
const localPackages = isHost
|
|
207
|
-
?
|
|
208
|
-
:
|
|
224
|
+
? modulePackages
|
|
225
|
+
: modulePackages.filter((p) => exposureOf(p.name) !== "external");
|
|
209
226
|
// Build federowany: moduły external/both → browser-fed/.
|
|
210
|
-
const fedPackages =
|
|
227
|
+
const fedPackages = modulePackages.filter((p) =>
|
|
211
228
|
["external", "both"].includes(exposureOf(p.name)),
|
|
212
229
|
);
|
|
213
230
|
const hasFederatedModules = fedPackages.length > 0;
|
|
@@ -238,31 +255,47 @@ export async function buildAll(
|
|
|
238
255
|
// zawsze external peers) + peer-shell.
|
|
239
256
|
const i18nCollector = new Map<string, Set<string>>();
|
|
240
257
|
|
|
241
|
-
|
|
258
|
+
// Dwa buildy przeglądarkowe (bundled + federated) biegną SZEREGOWO, nie w
|
|
259
|
+
// Promise.all: oba mutują TE SAME package.json (patch sideEffects na czas
|
|
260
|
+
// builda) — przy równoległym biegu `finally` szybszego builda przywraca
|
|
261
|
+
// oryginał, zanim wolniejszy skończy bundlować, i tree-shaking wolniejszego
|
|
262
|
+
// biegnie na złych metadanych. Koszt: +czas builda fed; zysk: deterministyka.
|
|
263
|
+
const browserSequence = (async () => {
|
|
242
264
|
// HOST buduje się BUNDLED (React inline, eager — bez lazy-race), a initial
|
|
243
265
|
// eksponuje runtime na globalThis; gościa obsłuży peer-shell (globalThis).
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
? buildBrowserApp(
|
|
266
|
+
const bundled = await buildBrowserApp(
|
|
267
|
+
ws.rootDir,
|
|
268
|
+
ws.browserDir,
|
|
269
|
+
plan,
|
|
270
|
+
cache,
|
|
271
|
+
noCache,
|
|
272
|
+
i18nCollector,
|
|
273
|
+
{ federated: false, exposeRuntime: isHost, minify },
|
|
274
|
+
);
|
|
275
|
+
const fed = fedPlan
|
|
276
|
+
? await buildBrowserApp(
|
|
256
277
|
ws.rootDir,
|
|
257
278
|
join(ws.arcDir, "browser-fed"),
|
|
258
279
|
fedPlan,
|
|
259
280
|
cache,
|
|
260
281
|
noCache,
|
|
261
282
|
i18nCollector,
|
|
262
|
-
{ federated: true, unitId: "browser-app-fed" },
|
|
283
|
+
{ federated: true, unitId: "browser-app-fed", minify },
|
|
263
284
|
)
|
|
264
|
-
:
|
|
265
|
-
|
|
285
|
+
: undefined;
|
|
286
|
+
return { bundled, fed };
|
|
287
|
+
})();
|
|
288
|
+
|
|
289
|
+
const [{ bundled: browserResult, fed: fedResult }, , , , shellResult] =
|
|
290
|
+
await Promise.all([
|
|
291
|
+
browserSequence,
|
|
292
|
+
buildStyles(ws.rootDir, ws.arcDir, ws.packages, themePath, cache, noCache),
|
|
293
|
+
copyBrowserAssets(ws, cache, noCache),
|
|
294
|
+
buildTranslations(ws.rootDir, ws.arcDir, cache, noCache),
|
|
295
|
+
needsPeerShell
|
|
296
|
+
? buildPeerShell(ws.rootDir, join(ws.arcDir, "shell"), cache, noCache)
|
|
297
|
+
: Promise.resolve(undefined),
|
|
298
|
+
]);
|
|
266
299
|
|
|
267
300
|
// Finalize i18n catalogs once after all chunks + initial bundle collected.
|
|
268
301
|
const { finalizeTranslations } = await import("../i18n");
|
|
@@ -493,7 +526,18 @@ async function copyBrowserAssets(
|
|
|
493
526
|
|
|
494
527
|
export async function loadServerContext(
|
|
495
528
|
ws: WorkspaceInfo,
|
|
496
|
-
): Promise<{
|
|
529
|
+
): Promise<{
|
|
530
|
+
context: any | null;
|
|
531
|
+
moduleAccess: Map<string, any>;
|
|
532
|
+
/**
|
|
533
|
+
* Set gdy import bundla serwerowego RZUCIŁ (np. fail-fast na brakującym env).
|
|
534
|
+
* Kontekst jest wtedy CZĘŚCIOWY — część modułów się nie zarejestrowała, więc
|
|
535
|
+
* ich query rzucają `Element not found`. Caller uruchamiający serwer MUSI to
|
|
536
|
+
* potraktować jako twardy błąd startu (patrz startup.ts), inaczej aplikacja
|
|
537
|
+
* wstaje połowiczna i cicho. Build/access-extractor może to zignorować.
|
|
538
|
+
*/
|
|
539
|
+
serverError?: unknown;
|
|
540
|
+
}> {
|
|
497
541
|
// Set globals for server context — framework packages (arc-auth etc.)
|
|
498
542
|
// use these at runtime to tree-shake browser/server code paths.
|
|
499
543
|
(globalThis as any).ONLY_SERVER = true;
|
|
@@ -519,15 +563,18 @@ export async function loadServerContext(
|
|
|
519
563
|
// No build yet (or a static-only workspace) — nothing to register.
|
|
520
564
|
return { context: null, moduleAccess: new Map() };
|
|
521
565
|
}
|
|
566
|
+
let serverError: unknown;
|
|
522
567
|
try {
|
|
523
568
|
await import(serverEntry);
|
|
524
569
|
} catch (e) {
|
|
525
570
|
err(`Failed to load server bundle ${SERVER_ENTRY_FILE}: ${e}`);
|
|
571
|
+
serverError = e;
|
|
526
572
|
}
|
|
527
573
|
|
|
528
574
|
const { getContext, getAllModuleAccess } = await import(platformEntry);
|
|
529
575
|
return {
|
|
530
576
|
context: getContext() ?? null,
|
|
531
577
|
moduleAccess: getAllModuleAccess(),
|
|
578
|
+
serverError,
|
|
532
579
|
};
|
|
533
580
|
}
|
package/src/platform/startup.ts
CHANGED
|
@@ -60,7 +60,7 @@ export async function startPlatform(
|
|
|
60
60
|
// missing manifest is a hard error.
|
|
61
61
|
let manifest: BuildManifest;
|
|
62
62
|
if (devMode) {
|
|
63
|
-
manifest = await buildAll(ws);
|
|
63
|
+
manifest = await buildAll(ws, { minify: false });
|
|
64
64
|
} else {
|
|
65
65
|
const manifestPath = join(ws.arcDir, "manifest.json");
|
|
66
66
|
if (!existsSync(manifestPath)) {
|
|
@@ -88,7 +88,21 @@ export async function startPlatform(
|
|
|
88
88
|
// imports the combined server bundle at .arc/platform/server/_server.js
|
|
89
89
|
// (produced by the buildServerApp step in buildAll).
|
|
90
90
|
log("Loading server context...");
|
|
91
|
-
const { context, moduleAccess } = await loadServerContext(ws);
|
|
91
|
+
const { context, moduleAccess, serverError } = await loadServerContext(ws);
|
|
92
|
+
// TWARDY STOP: import bundla serwerowego rzucił (np. fail-fast na brakującym
|
|
93
|
+
// env). Kontekst jest wtedy CZĘŚCIOWY — część agregatów się nie
|
|
94
|
+
// zarejestrowała, więc ich query rzucałyby `Element not found`, a aplikacja
|
|
95
|
+
// wyglądałaby na działającą (login OK), tyle że połowiczną. Przerywamy start
|
|
96
|
+
// GŁOŚNO, zamiast wstawać po cichu — to była realna produkcyjna pułapka.
|
|
97
|
+
if (serverError) {
|
|
98
|
+
err(
|
|
99
|
+
"Serwer NIE wstanie: moduł serwerowy nie załadował się (błąd wyżej). " +
|
|
100
|
+
"Częściowy kontekst = połowiczna aplikacja (query rzucają " +
|
|
101
|
+
"'Element not found'). Napraw przyczynę (np. brakujące zmienne " +
|
|
102
|
+
"środowiskowe) i wystartuj ponownie.",
|
|
103
|
+
);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
92
106
|
if (context) {
|
|
93
107
|
ok("Context loaded");
|
|
94
108
|
} else {
|
|
@@ -232,37 +246,33 @@ async function startMapTool(
|
|
|
232
246
|
}
|
|
233
247
|
|
|
234
248
|
/**
|
|
235
|
-
* PROD + federacja →
|
|
249
|
+
* PROD + federacja → token parowania MUSI pochodzić z env. Twarda odmowa startu.
|
|
236
250
|
*
|
|
237
251
|
* DLACZEGO tak ostro: `.arc/` nie trafia ani do obrazu (deploy kopiuje tylko
|
|
238
|
-
* `.arc/platform/`), ani na wolumen (montowane jest tylko `.arc/data`).
|
|
252
|
+
* `.arc/platform/`), ani na wolumen (montowane jest tylko `.arc/data`). Token
|
|
239
253
|
* wygenerowany wewnątrz kontenera przeżywa `restart`, ale **ginie przy pierwszym
|
|
240
|
-
* `recreate`**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* bez jednego błędu przy starcie,
|
|
244
|
-
* - nowy token parowania → istniejące parowania martwe.
|
|
254
|
+
* `recreate`** — a wtedy nowy token parowania unieważnia istniejące parowania,
|
|
255
|
+
* bez żadnego błędu przy starcie. Cicha awaria dzień po deployu jest gorsza niż
|
|
256
|
+
* głośna odmowa startu.
|
|
245
257
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
258
|
+
* `ARC_APP_SECRET` NIE jest już wymagany: tożsamość federacji jest asymetryczna
|
|
259
|
+
* (host podpisuje kluczem prywatnym, partner weryfikuje publicznym z rejestru w
|
|
260
|
+
* bazie — patrz app-identity.md), więc partner nie trzyma współdzielonego
|
|
261
|
+
* sekretu HMAC. Klucze publiczne żyją w projekcji (wolumen `.arc/data`), nie w
|
|
262
|
+
* ulotnym pliku.
|
|
248
263
|
*/
|
|
249
264
|
function assertProdFederationSecrets(
|
|
250
265
|
devMode: boolean,
|
|
251
266
|
hasFederation: boolean,
|
|
252
267
|
): void {
|
|
253
268
|
if (devMode || !hasFederation) return;
|
|
254
|
-
|
|
255
|
-
if (!process.env.ARC_APP_SECRET) missing.push("ARC_APP_SECRET");
|
|
256
|
-
if (!process.env.ARC_PAIRING_TOKEN && !process.env.ARC_MAP_TOKEN) {
|
|
257
|
-
missing.push("ARC_PAIRING_TOKEN");
|
|
258
|
-
}
|
|
259
|
-
if (missing.length === 0) return;
|
|
269
|
+
if (process.env.ARC_PAIRING_TOKEN || process.env.ARC_MAP_TOKEN) return;
|
|
260
270
|
|
|
261
271
|
err(
|
|
262
|
-
`Federacja w produkcji wymaga
|
|
263
|
-
` Bez
|
|
264
|
-
` recreate —
|
|
265
|
-
` Ustaw
|
|
272
|
+
`Federacja w produkcji wymaga zmiennej środowiskowej ARC_PAIRING_TOKEN.\n` +
|
|
273
|
+
` Bez niej token parowania jest generowany w kontenerze i GINIE przy\n` +
|
|
274
|
+
` pierwszym recreate — parowania hostów stają się martwe, bez błędu.\n` +
|
|
275
|
+
` Ustaw ją w deployu (deploy.arc.json → envVars + deploy.arc.<env>.env).`,
|
|
266
276
|
);
|
|
267
277
|
process.exit(1);
|
|
268
278
|
}
|
|
@@ -385,7 +395,7 @@ function attachDevWatcher(
|
|
|
385
395
|
isRebuilding = true;
|
|
386
396
|
log("Rebuilding...");
|
|
387
397
|
try {
|
|
388
|
-
const next = await buildAll(ws);
|
|
398
|
+
const next = await buildAll(ws, { minify: false });
|
|
389
399
|
platform.setManifest(next);
|
|
390
400
|
platform.notifyReload(next);
|
|
391
401
|
onReload?.();
|