@arcote.tech/arc-cli 0.7.19 → 0.7.21
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 +767 -160
- package/package.json +9 -9
- package/src/builder/access-extractor.ts +11 -15
- package/src/builder/module-builder.ts +210 -35
- package/src/deploy/bootstrap.ts +8 -3
- package/src/deploy/caddyfile.ts +43 -8
- package/src/deploy/compose.ts +73 -0
- package/src/deploy/config.ts +15 -0
- package/src/deploy/observability-configs.ts +688 -48
- package/src/platform/server.ts +3 -0
- package/src/platform/shared.ts +34 -73
- package/src/platform/startup.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.21",
|
|
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 '@opentelemetry/*' && chmod +x dist/index.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@arcote.tech/arc": "^0.7.
|
|
16
|
-
"@arcote.tech/arc-ds": "^0.7.
|
|
17
|
-
"@arcote.tech/arc-react": "^0.7.
|
|
18
|
-
"@arcote.tech/arc-host": "^0.7.
|
|
19
|
-
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.
|
|
20
|
-
"@arcote.tech/arc-adapter-db-postgres": "^0.7.
|
|
21
|
-
"@arcote.tech/arc-otel": "^0.7.
|
|
15
|
+
"@arcote.tech/arc": "^0.7.21",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.7.21",
|
|
17
|
+
"@arcote.tech/arc-react": "^0.7.21",
|
|
18
|
+
"@arcote.tech/arc-host": "^0.7.21",
|
|
19
|
+
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.21",
|
|
20
|
+
"@arcote.tech/arc-adapter-db-postgres": "^0.7.21",
|
|
21
|
+
"@arcote.tech/arc-otel": "^0.7.21",
|
|
22
22
|
"@opentelemetry/api": "^1.9.0",
|
|
23
23
|
"@opentelemetry/api-logs": "^0.57.0",
|
|
24
24
|
"@opentelemetry/core": "^1.30.0",
|
|
@@ -31,7 +31,7 @@
|
|
|
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.7.
|
|
34
|
+
"@arcote.tech/platform": "^0.7.21",
|
|
35
35
|
"@clack/prompts": "^0.9.0",
|
|
36
36
|
"commander": "^11.1.0",
|
|
37
37
|
"chokidar": "^3.5.3",
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
writeFileSync,
|
|
8
8
|
} from "fs";
|
|
9
9
|
import { join } from "path";
|
|
10
|
-
import { isContextPackage, type WorkspacePackage } from "./module-builder";
|
|
11
10
|
|
|
12
11
|
// ---------------------------------------------------------------------------
|
|
13
12
|
// access-extractor — discovers per-module access rules (`protectedBy(...)`)
|
|
@@ -27,8 +26,8 @@ import { isContextPackage, type WorkspacePackage } from "./module-builder";
|
|
|
27
26
|
// non-context (pure-browser) packages are skipped. This is sensible: access
|
|
28
27
|
// checks logically belong with server state, not display components.
|
|
29
28
|
//
|
|
30
|
-
// Order requirement:
|
|
31
|
-
//
|
|
29
|
+
// Order requirement: buildServerApp MUST run before extractAccessMap — the
|
|
30
|
+
// combined server bundle at `<arcDir>/server/_server.js` must exist.
|
|
32
31
|
// ---------------------------------------------------------------------------
|
|
33
32
|
|
|
34
33
|
export interface SerializedAccessRule {
|
|
@@ -44,19 +43,16 @@ export type SerializedAccessMap = Record<string, SerializedModuleAccess>;
|
|
|
44
43
|
|
|
45
44
|
export async function extractAccessMap(
|
|
46
45
|
rootDir: string,
|
|
47
|
-
|
|
46
|
+
serverBundlePath: string,
|
|
48
47
|
): Promise<SerializedAccessMap> {
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
path: join(p.path, "dist", "server", "main", "index.js"),
|
|
58
|
-
}))
|
|
59
|
-
.filter((b) => existsSync(b.path));
|
|
48
|
+
// The combined server bundle (`<arcDir>/server/_server.js`) side-effect-
|
|
49
|
+
// registers every module via the platform singleton when imported. The
|
|
50
|
+
// worker imports just this entry; Bun resolves its `chunk-<hash>.js` siblings
|
|
51
|
+
// and external peers (`@arcote.tech/platform` etc.) by walking node_modules
|
|
52
|
+
// from the bundle's directory.
|
|
53
|
+
const serverBundles = existsSync(serverBundlePath)
|
|
54
|
+
? [{ name: "server", path: serverBundlePath }]
|
|
55
|
+
: [];
|
|
60
56
|
|
|
61
57
|
// Worker must live INSIDE the workspace tree so Bun's module resolver can
|
|
62
58
|
// walk up to <rootDir>/node_modules and find @arcote.tech/platform via the
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type BuildCache,
|
|
19
19
|
} from "./build-cache";
|
|
20
20
|
import type { ChunkPlan } from "./chunk-planner";
|
|
21
|
-
import { SHELL_EXTERNALS } from "./framework-peers";
|
|
21
|
+
import { SHELL_EXTERNALS, FRAMEWORK_PEERS } from "./framework-peers";
|
|
22
22
|
import {
|
|
23
23
|
readInstalledVersion,
|
|
24
24
|
sha256Hex,
|
|
@@ -125,6 +125,43 @@ function serverExternalsPlugin(): import("bun").BunPlugin {
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* SERVER bundles inline their workspace (`@ndt/*`) deps so each flattened
|
|
130
|
+
* `.arc/platform/server/<pkg>.js` is self-contained (the deploy image has no
|
|
131
|
+
* `node_modules/@ndt/*` tree). Bun would resolve a bare `@ndt/x` import through
|
|
132
|
+
* package.json `exports` to that dep's PRE-BUILT `dist/server/main/index.js` —
|
|
133
|
+
* an already-fully-inlined bundle. Nesting one fully-inlined bundle inside
|
|
134
|
+
* every consumer is catastrophic:
|
|
135
|
+
* - diamonds duplicate the shared dep per graph edge (2^depth copies),
|
|
136
|
+
* - dist/ is never wiped, so stale bombs from a previous dep graph keep
|
|
137
|
+
* getting re-inlined across rebuilds.
|
|
138
|
+
* Result: bundles ballooned into the hundreds-of-MB / GB range (content =
|
|
139
|
+
* 1.1 GB, sizes lining up as powers of two by graph depth).
|
|
140
|
+
*
|
|
141
|
+
* This plugin pins every workspace package name to its SOURCE entrypoint
|
|
142
|
+
* (`src/index.ts`). The single Bun.build pass then includes each transitive
|
|
143
|
+
* module exactly once (Bun dedups by resolved path), so a server bundle is the
|
|
144
|
+
* size of its UNIQUE source closure — no nesting, no stale re-inlining.
|
|
145
|
+
*
|
|
146
|
+
* `sideEffects: true` keeps the dep's top-level `module(...).build()`
|
|
147
|
+
* registration from being tree-shaken when it's imported only for a named
|
|
148
|
+
* symbol (workspace packages ship `"sideEffects": false`).
|
|
149
|
+
*/
|
|
150
|
+
function workspaceSourcePlugin(srcByName: Map<string, string>): import("bun").BunPlugin {
|
|
151
|
+
return {
|
|
152
|
+
name: "workspace-source",
|
|
153
|
+
setup(build) {
|
|
154
|
+
// Bare specifiers only (skip relative `./` and absolute `/`). Non-
|
|
155
|
+
// workspace bare imports (react, @arcote.tech/*, npm deps) miss the map
|
|
156
|
+
// and fall through to the `external` list unchanged.
|
|
157
|
+
build.onResolve({ filter: /^[^./]/ }, (args) => {
|
|
158
|
+
const src = srcByName.get(args.path);
|
|
159
|
+
return src ? { path: src, sideEffects: true } : null;
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
128
165
|
function jsxDevShimPlugin(): import("bun").BunPlugin {
|
|
129
166
|
return {
|
|
130
167
|
name: "jsx-dev-runtime-shim",
|
|
@@ -145,12 +182,25 @@ export { Fragment };
|
|
|
145
182
|
};
|
|
146
183
|
}
|
|
147
184
|
|
|
148
|
-
|
|
185
|
+
// Per-package context build is BROWSER-only. The server side is produced by a
|
|
186
|
+
// single combined `buildServerApp` pass (see below) — bundling each package's
|
|
187
|
+
// server context separately and inlining its deps' pre-built dist caused
|
|
188
|
+
// catastrophic nested duplication (content.js hit 1.1 GB) and turned the
|
|
189
|
+
// consumer's undeclared cyclic `@ndt/*` imports into broken init order. One
|
|
190
|
+
// shared pass dedups every module once and is cycle-safe (same as the browser
|
|
191
|
+
// app build).
|
|
149
192
|
const CONTEXT_CLIENTS = [
|
|
150
|
-
{ name: "server", target: "bun" as const, defines: { ONLY_SERVER: "true", ONLY_BROWSER: "false", ONLY_CLIENT: "false" } },
|
|
151
193
|
{ name: "browser", target: "browser" as const, defines: { ONLY_SERVER: "false", ONLY_BROWSER: "true", ONLY_CLIENT: "true" } },
|
|
152
194
|
];
|
|
153
195
|
|
|
196
|
+
/** Server-side build defines — applied by the combined `buildServerApp` pass. */
|
|
197
|
+
const SERVER_DEFINES = { ONLY_SERVER: "true", ONLY_BROWSER: "false", ONLY_CLIENT: "false" } as const;
|
|
198
|
+
|
|
199
|
+
/** Filename of the combined server bundle entry under `<arcDir>/server/`.
|
|
200
|
+
* loadServerContext + the access extractor import exactly this file; Bun's
|
|
201
|
+
* auto-emitted `chunk-<hash>.js` siblings are pulled in transitively. */
|
|
202
|
+
export const SERVER_ENTRY_FILE = "_server.js";
|
|
203
|
+
|
|
154
204
|
export interface WorkspacePackage {
|
|
155
205
|
name: string;
|
|
156
206
|
path: string;
|
|
@@ -305,32 +355,16 @@ async function buildContextClient(
|
|
|
305
355
|
|
|
306
356
|
console.log(` building: ${pkg.name} (${client.name})`);
|
|
307
357
|
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
// - SERVER client: workspace deps MUST be bundled inline. The deploy
|
|
317
|
-
// image flattens each package's server bundle to
|
|
318
|
-
// `.arc/platform/server/<pkg>.js` and runs them via a single
|
|
319
|
-
// `loadServerContext()` import loop. There is no `node_modules/@ndt/*`
|
|
320
|
-
// tree inside the image, so bare `@ndt/workspace` specifiers would
|
|
321
|
-
// fail to resolve at startup. Inline duplication is harmless on the
|
|
322
|
-
// server: it's a single Node/Bun process and Arc modules register via
|
|
323
|
-
// a shared platform registry singleton (registry.ts), so two physical
|
|
324
|
-
// copies of the workspace module still merge into one context.
|
|
358
|
+
// BROWSER client: every dep (workspace + npm + framework peers) is external.
|
|
359
|
+
// Inlining workspace deps per package would make each context package's dist
|
|
360
|
+
// ship its own copy of every workspace dep — the top-level browser Bun.build
|
|
361
|
+
// (`buildBrowserApp`) would then see N pre-inlined copies of context
|
|
362
|
+
// singletons (`WorkspaceContext = createContext`) and could not dedupe them,
|
|
363
|
+
// breaking provider lookup. The server side is handled separately by the
|
|
364
|
+
// combined `buildServerApp` pass.
|
|
325
365
|
const peerDeps = Object.keys(pkg.packageJson.peerDependencies ?? {});
|
|
326
366
|
const allDeps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
|
|
327
|
-
const
|
|
328
|
-
const workspaceDeps = isBrowser
|
|
329
|
-
? Object.keys(allDeps)
|
|
330
|
-
: Object.entries(allDeps)
|
|
331
|
-
.filter(([, spec]) => !spec.startsWith("workspace:"))
|
|
332
|
-
.map(([name]) => name);
|
|
333
|
-
const externals = [...peerDeps, ...workspaceDeps];
|
|
367
|
+
const externals = [...peerDeps, ...Object.keys(allDeps)];
|
|
334
368
|
|
|
335
369
|
const result = await Bun.build({
|
|
336
370
|
entrypoints: [pkg.entrypoint],
|
|
@@ -339,9 +373,7 @@ async function buildContextClient(
|
|
|
339
373
|
format: "esm",
|
|
340
374
|
naming: "index.[ext]",
|
|
341
375
|
external: externals,
|
|
342
|
-
plugins:
|
|
343
|
-
? [jsxDevShimPlugin()]
|
|
344
|
-
: [jsxDevShimPlugin(), serverExternalsPlugin()],
|
|
376
|
+
plugins: [jsxDevShimPlugin()],
|
|
345
377
|
define: client.defines,
|
|
346
378
|
});
|
|
347
379
|
|
|
@@ -385,11 +417,13 @@ export async function buildContextPackages(
|
|
|
385
417
|
const contexts = packages.filter((p) => isContextPackage(p.packageJson));
|
|
386
418
|
if (contexts.length === 0) return { declarationErrors: [] };
|
|
387
419
|
|
|
388
|
-
// Topological order
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
420
|
+
// Topological order by declared `workspace:` deps. The browser BUNDLE
|
|
421
|
+
// externalizes every dep (order-independent), but the type-declaration pass
|
|
422
|
+
// (tsc) resolves `@ndt/x` types through that package's emitted
|
|
423
|
+
// `dist/browser/index.d.ts` — so a dep's declarations must exist before a
|
|
424
|
+
// dependent's decl build runs, else tsc reports "Cannot find module @ndt/x".
|
|
425
|
+
// The declared dep graph is acyclic; undeclared cyclic imports live in source
|
|
426
|
+
// only and don't affect this ordering. Inside a layer, packages build in parallel.
|
|
393
427
|
const byName = new Map(contexts.map((p) => [p.name, p]));
|
|
394
428
|
const remaining = new Set(contexts.map((p) => p.name));
|
|
395
429
|
const done = new Set<string>();
|
|
@@ -440,6 +474,147 @@ export async function buildContextPackages(
|
|
|
440
474
|
return { declarationErrors };
|
|
441
475
|
}
|
|
442
476
|
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// Combined server bundle — ONE Bun.build for ALL context packages' server side.
|
|
479
|
+
//
|
|
480
|
+
// Replaces N per-package server builds that each inlined their deps' PRE-BUILT
|
|
481
|
+
// dist (`@ndt/x` → `x/dist/server/main/index.js`). That nested an already-
|
|
482
|
+
// fully-inlined bundle inside every consumer: shared deps duplicated per graph
|
|
483
|
+
// edge (content's server bundle reached 1.1 GB) and the consumer's undeclared
|
|
484
|
+
// cyclic `@ndt/*` imports turned into broken module-init order.
|
|
485
|
+
//
|
|
486
|
+
// One shared pass with `splitting: true` — the same recipe as buildBrowserApp:
|
|
487
|
+
// - every module is bundled from SOURCE exactly once (Bun dedups by path),
|
|
488
|
+
// - `@ndt/*` workspace deps resolve to their `src/` entry (workspaceSourcePlugin)
|
|
489
|
+
// so nothing pulls a pre-built dist,
|
|
490
|
+
// - npm deps + framework peers stay external — the deploy image installs them
|
|
491
|
+
// into /app/node_modules via `.arc/platform/package.json` (collectFrameworkDeps),
|
|
492
|
+
// - splitting makes Bun emit eager top-level init, which is cycle-safe; the
|
|
493
|
+
// old non-split per-package build emitted lazy `__esm` wrappers that broke
|
|
494
|
+
// under the consumer's import cycles (`workspace.ids` was undefined).
|
|
495
|
+
//
|
|
496
|
+
// Output under `<arcDir>/server/`:
|
|
497
|
+
// _server.js ← entry: side-effect-imports every context module
|
|
498
|
+
// chunk-<hash>.js × N ← auto-shared module chunks
|
|
499
|
+
// loadServerContext + the access extractor import `_server.js`; chunks ride along.
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
export interface ServerAppResult {
|
|
503
|
+
readonly entryFile: string;
|
|
504
|
+
readonly cached: boolean;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export async function buildServerApp(
|
|
508
|
+
rootDir: string,
|
|
509
|
+
serverDir: string,
|
|
510
|
+
packages: WorkspacePackage[],
|
|
511
|
+
cache: BuildCache,
|
|
512
|
+
noCache: boolean,
|
|
513
|
+
): Promise<ServerAppResult> {
|
|
514
|
+
void rootDir; // resolution is source-based; rootDir kept for signature parity
|
|
515
|
+
const contexts = packages.filter((p) => isContextPackage(p.packageJson));
|
|
516
|
+
mkdirSync(serverDir, { recursive: true });
|
|
517
|
+
|
|
518
|
+
// Every workspace package name → its SOURCE entry. Spans ALL packages, not
|
|
519
|
+
// just context ones — a context package imports non-context workspace libs
|
|
520
|
+
// (e.g. content-core) that must inline from source too.
|
|
521
|
+
const srcByName = new Map(packages.map((p) => [p.name, p.entrypoint]));
|
|
522
|
+
|
|
523
|
+
// External = framework peers + every non-workspace npm dep any package
|
|
524
|
+
// declares. NOT bundled; resolved at runtime from /app/node_modules (the
|
|
525
|
+
// deploy image installs exactly this set — collectFrameworkDeps). Only the
|
|
526
|
+
// `@ndt/*` workspace source is inlined.
|
|
527
|
+
const externalSet = new Set<string>(FRAMEWORK_PEERS);
|
|
528
|
+
for (const p of packages) {
|
|
529
|
+
for (const name of Object.keys(p.packageJson.peerDependencies ?? {})) {
|
|
530
|
+
externalSet.add(name);
|
|
531
|
+
}
|
|
532
|
+
for (const [name, spec] of Object.entries(
|
|
533
|
+
(p.packageJson.dependencies ?? {}) as Record<string, string>,
|
|
534
|
+
)) {
|
|
535
|
+
if (!spec.startsWith("workspace:")) externalSet.add(name);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const external = [...externalSet];
|
|
539
|
+
|
|
540
|
+
const unitId = "server-app";
|
|
541
|
+
const inputHash = sha256OfJson({
|
|
542
|
+
members: packages
|
|
543
|
+
.map((p) => ({ name: p.name, src: pkgSourceHash(p) }))
|
|
544
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
545
|
+
contexts: contexts.map((p) => p.name).sort(),
|
|
546
|
+
external: [...external].sort(),
|
|
547
|
+
defines: SERVER_DEFINES,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const entryFileAbs = join(serverDir, SERVER_ENTRY_FILE);
|
|
551
|
+
if (!noCache && isCacheHit(cache, unitId, inputHash, [entryFileAbs])) {
|
|
552
|
+
console.log(` ✓ cached: ${unitId}`);
|
|
553
|
+
return { entryFile: SERVER_ENTRY_FILE, cached: true };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
console.log(` building: ${unitId} (${contexts.length} server modules)`);
|
|
557
|
+
|
|
558
|
+
// Wipe stale .js — old per-package flattened bundles AND previous chunks —
|
|
559
|
+
// so a smaller rebuild never leaves orphaned content-addressed chunks behind.
|
|
560
|
+
for (const f of readdirSync(serverDir)) {
|
|
561
|
+
if (f.endsWith(".js")) rmSync(join(serverDir, f), { force: true });
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Entry side-effect-imports every context module so each registers via the
|
|
565
|
+
// platform registry singleton. Written to a tmp subdir so it isn't shipped.
|
|
566
|
+
const tmpDir = join(serverDir, "_entries");
|
|
567
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
568
|
+
const entrySrc = join(tmpDir, SERVER_ENTRY_FILE.replace(/\.js$/, ".ts"));
|
|
569
|
+
writeFileSync(
|
|
570
|
+
entrySrc,
|
|
571
|
+
contexts.map((p) => `import "${p.name}";`).join("\n") + "\n",
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
let result;
|
|
575
|
+
try {
|
|
576
|
+
result = await Bun.build({
|
|
577
|
+
entrypoints: [entrySrc],
|
|
578
|
+
outdir: serverDir,
|
|
579
|
+
target: "bun",
|
|
580
|
+
format: "esm",
|
|
581
|
+
// splitting:true is the whole point — it makes Bun emit eager top-level
|
|
582
|
+
// init (cycle-safe) instead of lazy `__esm` wrappers. Shared modules land
|
|
583
|
+
// in chunk-<hash>.js referenced by the entry.
|
|
584
|
+
splitting: true,
|
|
585
|
+
naming: "[name].[ext]",
|
|
586
|
+
external,
|
|
587
|
+
plugins: [
|
|
588
|
+
jsxDevShimPlugin(),
|
|
589
|
+
serverExternalsPlugin(),
|
|
590
|
+
workspaceSourcePlugin(srcByName),
|
|
591
|
+
],
|
|
592
|
+
define: SERVER_DEFINES,
|
|
593
|
+
});
|
|
594
|
+
} finally {
|
|
595
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!result.success) {
|
|
599
|
+
for (const log of result.logs) console.error(log);
|
|
600
|
+
throw new Error("Server app build failed");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const entryOut = result.outputs.find((o) => o.kind === "entry-point");
|
|
604
|
+
if (!entryOut) {
|
|
605
|
+
throw new Error("Server app build: entry not found in outputs");
|
|
606
|
+
}
|
|
607
|
+
if (basename(entryOut.path) !== SERVER_ENTRY_FILE) {
|
|
608
|
+
throw new Error(
|
|
609
|
+
`Server app build: unexpected entry name ${basename(entryOut.path)} (wanted ${SERVER_ENTRY_FILE})`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const outputHash = sha256OfDir(serverDir);
|
|
614
|
+
updateCache(cache, unitId, inputHash, { outputHash });
|
|
615
|
+
return { entryFile: SERVER_ENTRY_FILE, cached: false };
|
|
616
|
+
}
|
|
617
|
+
|
|
443
618
|
|
|
444
619
|
// ---------------------------------------------------------------------------
|
|
445
620
|
// Browser app build — one Bun.build with multiple entrypoints.
|
package/src/deploy/bootstrap.ts
CHANGED
|
@@ -133,6 +133,9 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
133
133
|
// - stack isn't fully ready, OR
|
|
134
134
|
// - marker is missing (legacy v0.5 deploy with no .arc-state.json), OR
|
|
135
135
|
// - configHash differs from last bootstrap (deploy.arc.json changed), OR
|
|
136
|
+
// - the CLI version changed (generators evolve — compose/Caddyfile/
|
|
137
|
+
// observability configs must be re-rendered + re-uploaded even when
|
|
138
|
+
// deploy.arc.json itself is unchanged), OR
|
|
136
139
|
// - registry container isn't running (e.g. legacy stack predates v0.7)
|
|
137
140
|
// Without this, an old v0.5 stack (no registry container) is classified as
|
|
138
141
|
// "ready" and bootstrap is skipped — then `docker login` on the next step
|
|
@@ -141,6 +144,7 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
141
144
|
state.kind !== "ready" ||
|
|
142
145
|
state.marker === null ||
|
|
143
146
|
state.marker.configHash !== inputs.configHash ||
|
|
147
|
+
state.marker.cliVersion !== inputs.cliVersion ||
|
|
144
148
|
!(await isRegistryRunning(cfg));
|
|
145
149
|
|
|
146
150
|
if (needUpStack) {
|
|
@@ -156,7 +160,7 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
156
160
|
// without forcing a full bootstrap.
|
|
157
161
|
if (cfg.observability?.enabled) {
|
|
158
162
|
log("Ensuring observability sidecars are running...");
|
|
159
|
-
const obsServices = ["otel-collector", "tempo", "loki", "prometheus", "grafana"];
|
|
163
|
+
const obsServices = ["otel-collector", "tempo", "loki", "prometheus", "alloy", "grafana"];
|
|
160
164
|
await assertExec(
|
|
161
165
|
cfg.target,
|
|
162
166
|
`cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures ${obsServices.join(" ")} && docker compose up -d ${obsServices.join(" ")}`,
|
|
@@ -307,10 +311,11 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
307
311
|
// `observability/` directory referenced by compose bind-mounts.
|
|
308
312
|
if (observabilityFiles && observabilityHtpasswd) {
|
|
309
313
|
// Make sure both the top-level `observability/` dir and the nested
|
|
310
|
-
// `grafana-dashboards/` exist before scp tries
|
|
314
|
+
// `grafana-dashboards/` + `grafana-alerting/` exist before scp tries
|
|
315
|
+
// to land files there.
|
|
311
316
|
await assertExec(
|
|
312
317
|
cfg.target,
|
|
313
|
-
`mkdir -p ${cfg.target.remoteDir}/observability/grafana-dashboards`,
|
|
318
|
+
`mkdir -p ${cfg.target.remoteDir}/observability/grafana-dashboards ${cfg.target.remoteDir}/observability/grafana-alerting`,
|
|
314
319
|
);
|
|
315
320
|
// Make sure local nested dir exists too — `generateObservabilityConfigs`
|
|
316
321
|
// returns relative paths like `observability/grafana-dashboards/x.json`
|
package/src/deploy/caddyfile.ts
CHANGED
|
@@ -20,23 +20,48 @@ export function generateCaddyfile(cfg: DeployConfig): string {
|
|
|
20
20
|
cfg.caddy.email === "internal" ? "" : `\n email ${cfg.caddy.email}`;
|
|
21
21
|
const tlsDirective =
|
|
22
22
|
cfg.caddy.email === "internal" ? "\n tls internal" : "";
|
|
23
|
+
const observability = cfg.observability?.enabled === true;
|
|
24
|
+
|
|
25
|
+
// Access logs land on stdout as JSON → Alloy tails the container → Loki
|
|
26
|
+
// (`{compose_service="caddy"}`). Off by default in Caddy, hence per-vhost.
|
|
27
|
+
const logDirective = observability
|
|
28
|
+
? [" log {", " output stdout", " format json", " }"]
|
|
29
|
+
: [];
|
|
23
30
|
|
|
24
31
|
const lines: string[] = [];
|
|
25
32
|
lines.push("# Generated by `arc platform deploy` — do not edit by hand.");
|
|
26
33
|
lines.push("");
|
|
27
34
|
lines.push("{");
|
|
28
35
|
lines.push(" admin off");
|
|
36
|
+
if (observability) {
|
|
37
|
+
// Per-request HTTP metrics (caddy_http_*). Top-level global option —
|
|
38
|
+
// requires Caddy >= 2.9 (the `servers > metrics` form is deprecated).
|
|
39
|
+
lines.push(" metrics {");
|
|
40
|
+
lines.push(" per_host");
|
|
41
|
+
lines.push(" }");
|
|
42
|
+
}
|
|
29
43
|
if (email) lines.push(` ${email.trim()}`);
|
|
30
44
|
lines.push("}");
|
|
31
45
|
lines.push("");
|
|
32
46
|
|
|
47
|
+
// Exposition endpoint for Prometheus (scrapes caddy:2020 on arc-net).
|
|
48
|
+
// Plain HTTP, never exposed publicly; works with `admin off` (only the
|
|
49
|
+
// admin-API /metrics endpoint dies with the admin interface).
|
|
50
|
+
if (observability) {
|
|
51
|
+
lines.push(":2020 {");
|
|
52
|
+
lines.push(" metrics");
|
|
53
|
+
lines.push("}");
|
|
54
|
+
lines.push("");
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
// Public blocks — one per env. When observability is on, add a `/otel/*`
|
|
34
58
|
// path that forwards browser-side OTLP/HTTP to the collector. Strips the
|
|
35
59
|
// /otel prefix so the collector sees the same /v1/{traces,logs,metrics}
|
|
36
60
|
// paths it would receive from same-network senders.
|
|
37
61
|
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
38
62
|
lines.push(`${env.domain} {${tlsDirective}`);
|
|
39
|
-
|
|
63
|
+
lines.push(...logDirective);
|
|
64
|
+
if (observability) {
|
|
40
65
|
lines.push(" handle_path /otel/* {");
|
|
41
66
|
lines.push(" reverse_proxy otel-collector:4318");
|
|
42
67
|
lines.push(" }");
|
|
@@ -54,13 +79,11 @@ export function generateCaddyfile(cfg: DeployConfig): string {
|
|
|
54
79
|
// the apex of the first env's domain (e.g. observability.app.example.com
|
|
55
80
|
// when the primary env is app.example.com). Caddy issues a separate
|
|
56
81
|
// ACME certificate for this hostname.
|
|
57
|
-
if (
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const observabilityDomain = `${subdomain}.${apex}`;
|
|
63
|
-
lines.push(`${observabilityDomain} {${tlsDirective}`);
|
|
82
|
+
if (observability) {
|
|
83
|
+
const domain = observabilityDomain(cfg);
|
|
84
|
+
if (domain) {
|
|
85
|
+
lines.push(`${domain} {${tlsDirective}`);
|
|
86
|
+
lines.push(...logDirective);
|
|
64
87
|
// Basic-auth credentials live in the same htpasswd file used for the
|
|
65
88
|
// registry — bootstrap appends an "admin" line with bcrypted password.
|
|
66
89
|
lines.push(" basic_auth {");
|
|
@@ -77,6 +100,7 @@ export function generateCaddyfile(cfg: DeployConfig): string {
|
|
|
77
100
|
// 5 GiB request body cap fits real app images comfortably (default 100MB
|
|
78
101
|
// triggers 413 on the first layer push).
|
|
79
102
|
lines.push(`${cfg.registry.domain} {${tlsDirective}`);
|
|
103
|
+
lines.push(...logDirective);
|
|
80
104
|
lines.push(" reverse_proxy registry:5000 {");
|
|
81
105
|
lines.push(" header_up Host {host}");
|
|
82
106
|
lines.push(" }");
|
|
@@ -88,6 +112,17 @@ export function generateCaddyfile(cfg: DeployConfig): string {
|
|
|
88
112
|
return lines.join("\n") + "\n";
|
|
89
113
|
}
|
|
90
114
|
|
|
115
|
+
/** Public hostname of the Grafana UI (`<subdomain>.<apex-of-first-env>`), or
|
|
116
|
+
* null when observability is off / no envs exist. Shared by the Caddyfile
|
|
117
|
+
* vhost and Grafana's GF_SERVER_ROOT_URL (compose.ts). */
|
|
118
|
+
export function observabilityDomain(cfg: DeployConfig): string | null {
|
|
119
|
+
if (!cfg.observability?.enabled) return null;
|
|
120
|
+
const firstEnv = Object.values(cfg.envs)[0];
|
|
121
|
+
if (!firstEnv) return null;
|
|
122
|
+
const subdomain = cfg.observability.subdomain ?? "observability";
|
|
123
|
+
return `${subdomain}.${apexOf(firstEnv.domain)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
91
126
|
/** Apex of a (possibly-subdomain) host. `app.example.com` → `example.com`,
|
|
92
127
|
* `example.com` → `example.com`, `example.co.uk` → `co.uk` (approximate —
|
|
93
128
|
* good enough for the observability subdomain). */
|