@arcote.tech/arc-cli 0.7.20 → 0.7.22

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/src/deploy/ssh.ts CHANGED
@@ -43,6 +43,27 @@ export interface SshExecResult {
43
43
  exitCode: number;
44
44
  }
45
45
 
46
+ /**
47
+ * SSH connection multiplexing. Without this every remote command spawns a
48
+ * fresh `ssh` (full TCP + auth handshake) — a single deploy fires ~15-25 of
49
+ * them, so the handshake overhead alone runs into minutes. `ControlMaster=auto`
50
+ * makes the first connection open a master socket; every later ssh/scp to the
51
+ * same host reuses it with no handshake. `ControlPersist` keeps the master
52
+ * alive for the whole (short) deploy. The socket lives in ~/.ssh (private) and
53
+ * `%C` is ssh's hash of host+port+user — short enough for the ~104-byte socket
54
+ * path limit, and distinct per target (so the root fallback gets its own).
55
+ */
56
+ function sshMuxArgs(): string[] {
57
+ return [
58
+ "-o",
59
+ "ControlMaster=auto",
60
+ "-o",
61
+ `ControlPath=${join(homedir(), ".ssh", "cm-arc-%C")}`,
62
+ "-o",
63
+ "ControlPersist=120",
64
+ ];
65
+ }
66
+
46
67
  export function baseSshArgs(target: DeployTarget): string[] {
47
68
  // Pin to a single identity to avoid "Too many authentication failures":
48
69
  // the server's MaxAuthTries=3 (set by ansible) trips when ssh-agent has
@@ -50,6 +71,7 @@ export function baseSshArgs(target: DeployTarget): string[] {
50
71
  // PreferredAuthentications=publickey skips gssapi/keyboard prompts entirely.
51
72
  const key = pickSshKey(target);
52
73
  const args = [
74
+ ...sshMuxArgs(),
53
75
  "-o",
54
76
  "BatchMode=yes",
55
77
  "-o",
@@ -159,6 +181,8 @@ export async function scpUpload(
159
181
  ): Promise<void> {
160
182
  const key = pickSshKey(target);
161
183
  const args = [
184
+ // Same ControlPath as baseSshArgs — scp reuses the ssh master socket.
185
+ ...sshMuxArgs(),
162
186
  "-o",
163
187
  "BatchMode=yes",
164
188
  "-o",
@@ -184,3 +208,28 @@ export async function scpUpload(
184
208
  throw new Error(`scp failed (${exitCode}): ${stderr}`);
185
209
  }
186
210
  }
211
+
212
+ /**
213
+ * Close the multiplexed SSH master connection (best-effort). Call once at the
214
+ * end of a deploy so the control socket doesn't linger for ControlPersist
215
+ * seconds after the process exits. Safe to call even if no master was opened —
216
+ * `ssh -O exit` against a missing socket just returns non-zero, which we ignore.
217
+ */
218
+ export async function closeSshMaster(target: DeployTarget): Promise<void> {
219
+ try {
220
+ const proc = spawn({
221
+ cmd: [
222
+ "ssh",
223
+ ...baseSshArgs(target),
224
+ "-O",
225
+ "exit",
226
+ `${target.user}@${target.host}`,
227
+ ],
228
+ stdout: "ignore",
229
+ stderr: "ignore",
230
+ });
231
+ await proc.exited;
232
+ } catch {
233
+ // best-effort cleanup — a missing/already-closed master is fine.
234
+ }
235
+ }
@@ -29,6 +29,13 @@ export interface PlatformServerOptions {
29
29
  dbPath?: string;
30
30
  /** If true, enables SSE reload stream + mutable manifest (dev mode) */
31
31
  devMode?: boolean;
32
+ /**
33
+ * Cross-origin isolation policy. Default `"unsafe-none"`. Apps z SQLite
34
+ * WASM/OPFS lub SharedArrayBuffer ustawiają `"require-corp"` w
35
+ * `deploy.arc.json` — wtedy każdy cross-origin resource (3rd-party
36
+ * widgets) musi mieć `Cross-Origin-Resource-Policy`. Patrz `ArcServerConfig.coep`.
37
+ */
38
+ coep?: "unsafe-none" | "credentialless" | "require-corp";
32
39
  }
33
40
 
34
41
  export interface PlatformServer {
@@ -507,6 +514,15 @@ export async function startPlatformServer(
507
514
  opts: PlatformServerOptions,
508
515
  ): Promise<PlatformServer> {
509
516
  const { ws, port, devMode, context } = opts;
517
+ // Default COEP: env var > opts > unsafe-none. Apps z SQLite WASM/OPFS
518
+ // muszą explicit ustawić "require-corp" (przez ARC_COEP w .env / Docker
519
+ // envVars). Default unsafe-none pozwala apps używać 3rd-party widgetów
520
+ // (PayU Secure Form, Stripe Elements, Google/Apple Pay) bez proxy.
521
+ const coep = (process.env.ARC_COEP as
522
+ | "unsafe-none"
523
+ | "credentialless"
524
+ | "require-corp"
525
+ | undefined) ?? opts.coep ?? "unsafe-none";
510
526
  ensureModuleSigSecret(ws, !!devMode);
511
527
 
512
528
  // OpenTelemetry — only when explicitly enabled (deploy injects the env
@@ -567,10 +583,10 @@ export async function startPlatformServer(
567
583
  "Access-Control-Allow-Methods":
568
584
  "GET, POST, PUT, DELETE, PATCH, OPTIONS",
569
585
  "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Arc-Tokens",
570
- // Cross-origin isolation — wymagane dla SharedArrayBuffer + OPFS
571
- // (SQLite WASM persistent storage w przeglądarce).
586
+ // Cross-origin isolation — domyślnie unsafe-none (3rd-party widgets);
587
+ // require-corp tylko dla apps używających SharedArrayBuffer/OPFS.
572
588
  "Cross-Origin-Opener-Policy": "same-origin",
573
- "Cross-Origin-Embedder-Policy": "require-corp",
589
+ "Cross-Origin-Embedder-Policy": coep ?? "unsafe-none",
574
590
  "Cross-Origin-Resource-Policy": "cross-origin",
575
591
  };
576
592
 
@@ -641,6 +657,7 @@ export async function startPlatformServer(
641
657
  context,
642
658
  dbAdapterFactory,
643
659
  port,
660
+ coep,
644
661
  httpHandlers: [
645
662
  // Platform-specific handlers (checked AFTER arc handlers)
646
663
  apiEndpointsHandler(ws, getManifest, null, moduleAccessMap, telemetry),
@@ -4,10 +4,12 @@ import { dirname, join } from "path";
4
4
  import {
5
5
  buildBrowserApp,
6
6
  buildContextPackages,
7
+ buildServerApp,
7
8
  buildStyles,
8
9
  buildTranslations,
9
10
  discoverPackages,
10
11
  isContextPackage,
12
+ SERVER_ENTRY_FILE,
11
13
  type BrowserAppResult,
12
14
  type BuildManifest,
13
15
  type ModuleDescriptor,
@@ -161,20 +163,30 @@ export async function buildAll(
161
163
  // else chunk grouping (per-package) silently mis-assigns the extra module.
162
164
  assertOneModulePerPackage(ws.packages);
163
165
 
164
- // Phase 1 — context packages must finish FIRST. The access-extractor
165
- // subprocess imports workspace packages by name, which resolve through
166
- // node_modules to packages' `main` field (typically `dist/server/main/`).
166
+ // Phase 1 — per-package BROWSER context bundles + type declarations.
167
167
  await buildContextPackages(ws.rootDir, ws.packages, cache, noCache);
168
168
 
169
- // Phase 1b — relocate per-package server bundles into `.arc/platform/server/`
170
- // so the deploy image can be self-contained (image COPY needs everything
171
- // server-side under one root). loadServerContext reads from here in prod.
172
- copyContextServerBundles(ws);
169
+ // Phase 1b — combined server bundle at `.arc/platform/server/_server.js`.
170
+ // ONE Bun.build for all context modules (deduped from source, cycle-safe)
171
+ // replaces the old per-package server bundles that nested each other's dist
172
+ // into multi-hundred-MB files. The deploy image COPYs this dir wholesale;
173
+ // loadServerContext + the access extractor import the entry, chunks ride along.
174
+ const serverDir = join(ws.arcDir, "server");
175
+ const { entryFile: serverEntry } = await buildServerApp(
176
+ ws.rootDir,
177
+ serverDir,
178
+ ws.packages,
179
+ cache,
180
+ noCache,
181
+ );
173
182
 
174
183
  // Phase 2 — extract access metadata (token name + hasCheck per module) in
175
- // an isolated subprocess. This MUST run before chunk planning so we know
176
- // which token group each module belongs to.
177
- const accessMap = await extractAccessMap(ws.rootDir, ws.packages);
184
+ // an isolated subprocess that imports the combined server bundle. MUST run
185
+ // before chunk planning so we know which token group each module belongs to.
186
+ const accessMap = await extractAccessMap(
187
+ ws.rootDir,
188
+ join(serverDir, serverEntry),
189
+ );
178
190
 
179
191
  // Persist access map for the runtime host (server.ts reads at startup to
180
192
  // wire up moduleAccessMap for filterManifestForTokens / signed URLs).
@@ -242,34 +254,6 @@ function assembleManifest(
242
254
  };
243
255
  }
244
256
 
245
- // ---------------------------------------------------------------------------
246
- // Context server bundles — flatten to `<arcDir>/server/<safeName>.js`
247
- // ---------------------------------------------------------------------------
248
-
249
- /**
250
- * Copy each context package's compiled server bundle from
251
- * `packages/<pkg>/dist/server/main/index.js` to a flat location at
252
- * `<arcDir>/server/<safeName>.js`. The flat layout makes the deploy image
253
- * self-contained — `COPY .arc/platform/` pulls everything server-side, no
254
- * need to drag the entire `packages/` tree into the image.
255
- */
256
- function copyContextServerBundles(ws: WorkspaceInfo): void {
257
- const outDir = join(ws.arcDir, "server");
258
- mkdirSync(outDir, { recursive: true });
259
-
260
- for (const pkg of ws.packages) {
261
- if (!isContextPackage(pkg.packageJson)) continue;
262
- const src = join(pkg.path, "dist", "server", "main", "index.js");
263
- if (!existsSync(src)) {
264
- err(`Server bundle missing for ${pkg.name}: ${src}`);
265
- continue;
266
- }
267
- const safeName = pkg.path.split("/").pop()!;
268
- const dst = join(outDir, `${safeName}.js`);
269
- copyFileSync(src, dst);
270
- }
271
- }
272
-
273
257
  // ---------------------------------------------------------------------------
274
258
  // Browser assets — @arcote.tech/* deps deklarują w `arc.browserAssets` jakie
275
259
  // pliki muszą być dostępne w przeglądarce (np. SQLite WASM worker + .wasm).
@@ -419,44 +403,21 @@ export async function loadServerContext(
419
403
 
420
404
  await import(platformEntry);
421
405
 
422
- // Primary source: flattened server bundles at `<arcDir>/server/<safeName>.js`.
406
+ // The combined server bundle lives at `<arcDir>/server/_server.js` (entry)
407
+ // next to its `chunk-<hash>.js` siblings. Importing the entry pulls the
408
+ // chunks transitively and registers every module via the platform singleton.
423
409
  // The deploy image only has this directory — there's no workspace `packages/`
424
- // tree. In dev, `copyContextServerBundles` populates this same location, so
425
- // both modes go through the same code path.
426
- const serverDir = join(ws.arcDir, "server");
427
- const bundles = existsSync(serverDir)
428
- ? readdirSync(serverDir).filter((f) => f.endsWith(".js"))
429
- : [];
430
-
431
- if (bundles.length > 0) {
432
- for (const file of bundles) {
433
- const bundlePath = join(serverDir, file);
434
- try {
435
- await import(bundlePath);
436
- } catch (e) {
437
- err(`Failed to load server bundle ${file}: ${e}`);
438
- }
439
- }
440
- } else if (ws.packages.length > 0) {
441
- // Fallback for the "no .arc/platform/server/ yet" case (e.g. somebody
442
- // wired up loadServerContext before running the build). This path goes
443
- // through workspace packages directly — only meaningful in dev.
444
- const ctxPackages = ws.packages.filter((p) => isContextPackage(p.packageJson));
445
- for (const ctx of ctxPackages) {
446
- const serverDist = join(ctx.path, "dist", "server", "main", "index.js");
447
- if (!existsSync(serverDist)) {
448
- err(`Context server dist not found: ${serverDist}`);
449
- continue;
450
- }
451
- try {
452
- await import(serverDist);
453
- } catch (e) {
454
- err(`Failed to load server context from ${ctx.name}: ${e}`);
455
- }
456
- }
457
- } else {
410
+ // tree; dev and prod go through the exact same path.
411
+ const serverEntry = join(ws.arcDir, "server", SERVER_ENTRY_FILE);
412
+ if (!existsSync(serverEntry)) {
413
+ // No build yet (or a static-only workspace) — nothing to register.
458
414
  return { context: null, moduleAccess: new Map() };
459
415
  }
416
+ try {
417
+ await import(serverEntry);
418
+ } catch (e) {
419
+ err(`Failed to load server bundle ${SERVER_ENTRY_FILE}: ${e}`);
420
+ }
460
421
 
461
422
  const { getContext, getAllModuleAccess } = await import(platformEntry);
462
423
  return {
@@ -62,8 +62,8 @@ export async function startPlatform(
62
62
  }
63
63
 
64
64
  // 2. Server context — same code path in both modes; loadServerContext
65
- // prefers .arc/platform/server/*.js (the canonical location after the
66
- // copyContextServerBundles step in buildAll).
65
+ // imports the combined server bundle at .arc/platform/server/_server.js
66
+ // (produced by the buildServerApp step in buildAll).
67
67
  log("Loading server context...");
68
68
  const { context, moduleAccess } = await loadServerContext(ws);
69
69
  if (context) {