@monster-orange/den 0.2.3 → 0.2.4

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/CHANGELOG.md CHANGED
@@ -4,6 +4,117 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.2.4] - 2026-11-04
8
+
9
+ ### Fixed
10
+
11
+ - **`GET /api/services` no longer blocks the web server.** It looked up
12
+ each workspace checkout's branch with a synchronous `git rev-parse`, one
13
+ after another (~40ms each), so with a dozen repos every request held the
14
+ event loop for 0.5-1.5s and everything else waited behind it: the UI,
15
+ the MCP server's reads, and `/api/server-info`, which `<cli> mcp connect`
16
+ probes with a 1.5s timeout. The lookups now run as async child processes
17
+ in parallel; concurrent requests (one per open tab) share one build, and
18
+ a settled build is reused for 2s (`?refresh=1` forces a new one; any
19
+ mutating request clears it). Concurrent `/api/health` polls also share
20
+ one run instead of each starting its own.
21
+ - **`<cli> mcp connect` / `mcp clients` tell a slow web server from a
22
+ stopped one.** A server that accepts the connection but does not answer
23
+ within 1.5s is reported as "not answering within 1.5s", not "not
24
+ running". When the org config sets `web.service` (the web UI runs as a
25
+ launchd service), the advice is `<cli> service status` instead of
26
+ `<cli> web up`.
27
+ - **Web server shutdown no longer leaks an unhandled rejection, or hangs.**
28
+ On SIGTERM/SIGINT the server reads the running-services registry, whose
29
+ path resolves the distribution root again; if that walk fails (the dist's
30
+ den.yml moved or removed while the server ran), the async shutdown
31
+ rejected unhandled ("unhandled rejection (continuing): Error: cannot
32
+ locate the distribution root: walked up from
33
+ <dist>/node_modules/@monster-orange/den/src/ui/server") and, its signal
34
+ guard already set, the process never exited, so a service manager had to
35
+ wait out its timeout and kill it. The shutdown now logs the failure and
36
+ exits.
37
+ - **Deactivating a workspace no longer stops a service that moved to
38
+ another workspace.** A daemon's owners were the workspaces recorded when
39
+ it started, so a repo moved from `default` into `ws-b` while its daemon
40
+ ran was still "owned" only by `default`, and `workspace deactivate
41
+ default` stopped it. Release now re-derives the owners from the current
42
+ workspaces.yml membership: a daemon is stopped only when no active
43
+ workspace still lists its repo, and otherwise its recorded owners become
44
+ the active workspaces that do.
45
+ - **`<cli> workspace --help` lists every subcommand**: activate,
46
+ deactivate and focus were missing (and `workspace focus <name>` now
47
+ exists in the CLI, as it did in the web UI). Deactivate's help and output
48
+ say that it releases only the services Den tracks: compose stacks of the
49
+ workspace's repos keep running, and the output names the running ones
50
+ with `<cli> down <repo>` for each (from one `docker ps`; a generic line
51
+ when docker can't be asked).
52
+ - **The first `/api/health/stream` (and `/api/health`) cycle no longer
53
+ blocks the web server for a second.** Resolving the manifest of a repo
54
+ without its own (a held manifest under `manifests/`) looked up the
55
+ checkout's origin with a synchronous `git config --get
56
+ remote.origin.url`, one repo after another (~40-100ms each), so a
57
+ server's first health run held the event loop for 1-1.5s. The origin is
58
+ now read from the checkout's git config file (worktrees and
59
+ subdirectories included), never by spawning git. Parsed manifests are
60
+ also reused while the file is unchanged: one request read the same
61
+ manifests several times, and parsing them held the loop ~100-200ms per
62
+ health run.
63
+ - **`GET /api/workspaces/available-repos` no longer blocks the web
64
+ server.** It ran `git remote get-url origin` (and, for managed clones,
65
+ `git rev-parse`) synchronously per checkout; the lookups are now async
66
+ and in parallel, and concurrent requests share one walk (reused for 2s;
67
+ `?refresh=1` forces a new one, any mutating request clears it).
68
+ - **`workspace deactivate` names the compose stacks a repo runs itself.**
69
+ It only knew Den stacks (a manifest's `compose:` section, project
70
+ `<stack>-<workspace>`), so a distribution whose repos start their own
71
+ compose projects (`make up`) got no line at all. A running project now
72
+ counts when Compose started it from inside one of the workspace's
73
+ checkouts (its `com.docker.compose.project.working_dir` label), as well
74
+ as when it is a Den stack's project.
75
+ - **`<cli> workspace <sub> --help`** prints that subcommand's help, not the
76
+ whole `workspace` help.
77
+ - **No synchronous directory walks or spawns on the web server's hot
78
+ request paths** (`/api/workspaces/available-repos`, the `/api/health`
79
+ stream). On a loaded machine (load average ~50) the dev.1 fixes still
80
+ left stalls of 1-5s; a CPU profile showed why:
81
+ - available-repos recursed into every directory without a manifest file
82
+ of its own, so in a distribution whose repos use held manifests it
83
+ walked each repo's whole tree (to depth 4) with `readdirSync` /
84
+ `existsSync`: seconds per call. It now never descends into a git
85
+ checkout or `node_modules`, and the walk is `fs.promises`.
86
+ - The distribution root (and so `product()`) was re-resolved on every
87
+ call, a den.yml `existsSync` per `*_ROOT` variable each time, hundreds
88
+ of times per request. It is now resolved once per set of inputs (the
89
+ env vars and entry script it reads); the engine root likewise.
90
+ - Manifests, the edge config and the derived graph order are reused
91
+ across a request and the polls after it (5s; any mutating request
92
+ clears them), and the graph order is recomputed after a request, never
93
+ inside one.
94
+ - HTTPS health checks built a TLS context from ~150 root certificates on
95
+ every check; one context is built once, at startup.
96
+ - The startup sweep of stale daemon records ran `docker ps` / `opctl
97
+ container ls` synchronously; it is async now (concurrent callers share
98
+ one call), and `/api/service/daemons` waits for it.
99
+ A new test runs the server with a spy on every synchronous fs and
100
+ child_process call and fails if either route makes a sync spawn or
101
+ directory walk, or any sync call outside a short allowlist of single
102
+ config-file reads.
103
+ - **The web UI service no longer runs at background priority.**
104
+ `service install` wrote `ProcessType: Background` into the LaunchAgent,
105
+ so macOS ran the UI server on efficiency cores with throttled I/O: on a
106
+ busy machine its first requests stalled for 5-11s. It is now
107
+ `Interactive`, and `service status` shows launchd's spawn type.
108
+ - **`workspace deactivate` never suggests stopping a stack another active
109
+ workspace still holds.** Such a stack is listed as "in use by another
110
+ workspace: <project> (still in <ws>)" instead of with a `down <repo>`
111
+ command, and a repo with a stack still in use gets no `down` suggestion.
112
+
113
+ ### Migrations
114
+
115
+ - Re-run `<cli> service install` to pick up the new LaunchAgent priority
116
+ (`<cli> service status` should show `spawn type = interactive`).
117
+
7
118
  ## [0.2.3] - 2026-11-04
8
119
 
9
120
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -65,7 +65,9 @@ data = {
65
65
  "RunAtLoad": True,
66
66
  "KeepAlive": True,
67
67
  "ThrottleInterval": 10,
68
- "ProcessType": "Background",
68
+ # Interactive, not Background: macOS gives Background agents efficiency
69
+ # cores and throttled I/O, and the UI stalled for seconds under load.
70
+ "ProcessType": "Interactive",
69
71
  "StandardOutPath": f"{logs}/web.log",
70
72
  "StandardErrorPath": f"{logs}/web.log",
71
73
  }
@@ -115,7 +117,7 @@ case "${1:-install}" in
115
117
  status)
116
118
  # launchctl's own "Could not find service" is noise when it isn't loaded.
117
119
  if out="$(launchctl print "$DOMAIN/$LABEL" 2>/dev/null)"; then
118
- printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs)'
120
+ printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs|spawn type)'
119
121
  else
120
122
  echo "$LABEL is not loaded"
121
123
  fi
@@ -31,6 +31,7 @@ import * as yaml from "yaml";
31
31
  import { brandSlug, defaultBrand } from "../brand.js";
32
32
  import { DEFAULT_PORT } from "../constants.js";
33
33
  import { getDistRoot } from "../paths.js";
34
+ import { cachedRead } from "../read-cache.js";
34
35
  import { readManifestStacks } from "../stacks/config.js";
35
36
  import { getActiveWorkspaces } from "../environment-manager/workspaces.js";
36
37
  import { assertDomain } from "./render.js";
@@ -154,6 +155,21 @@ function manifestStackNames(): string[] {
154
155
  }
155
156
 
156
157
  export function readEdgeConfig(root: string = getDistRoot()): EdgeConfig {
158
+ // The web server reads this on every request (open links, health rows);
159
+ // reused for the read cache's TTL there, read fresh everywhere else. An
160
+ // error is cached as such: most instances have no edge block at all.
161
+ const r = cachedRead(`edge-config:${root}`, () => {
162
+ try {
163
+ return { config: computeEdgeConfig(root) };
164
+ } catch (e) {
165
+ return { error: (e as Error).message };
166
+ }
167
+ });
168
+ if ("error" in r) throw new Error(r.error);
169
+ return r.config;
170
+ }
171
+
172
+ function computeEdgeConfig(root: string): EdgeConfig {
157
173
  const { team: t, user: u } = layers(root);
158
174
  const team = (t.edge ?? {}) as RawEdge;
159
175
  const user = (u.edge ?? {}) as RawEdge;
@@ -175,6 +175,36 @@ export function __setExtraCaForTests(pems: string[] | null): void {
175
175
  _extraCa = pems;
176
176
  }
177
177
 
178
+ /**
179
+ * One TLS context with the public roots plus extraCa(), built once. Passing
180
+ * `ca` per request made Node parse ~150 root certificates on every HTTPS
181
+ * health check, synchronously: tens of ms per check, per poll.
182
+ */
183
+ /** Build the HTTPS checks' trust context now (web server startup). */
184
+ export function prewarmHealthTls(): void {
185
+ trustContext();
186
+ }
187
+
188
+ let _trust: {
189
+ ca: string[];
190
+ context: tls.SecureContext;
191
+ agent: https.Agent;
192
+ } | null = null;
193
+ function trustContext(): { context: tls.SecureContext; agent: https.Agent } {
194
+ const extra = extraCa();
195
+ if (_trust?.ca !== extra) {
196
+ _trust?.agent.destroy();
197
+ _trust = {
198
+ ca: extra,
199
+ context: tls.createSecureContext({
200
+ ca: [...tls.rootCertificates, ...extra],
201
+ }),
202
+ agent: new https.Agent({ keepAlive: false }),
203
+ };
204
+ }
205
+ return _trust;
206
+ }
207
+
178
208
  /**
179
209
  * HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
180
210
  * hostname for SNI and certificate verification; rewriting the URL to the IP,
@@ -201,7 +231,12 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
201
231
  {
202
232
  method: "GET",
203
233
  servername: isIpLiteral(host) ? undefined : host,
204
- ca: [...tls.rootCertificates, ...extraCa()],
234
+ // tls.connect's option, passed through by the agent (not in the
235
+ // https.RequestOptions type).
236
+ ...({ secureContext: trustContext().context } as https.RequestOptions),
237
+ // Its own agent: a socket kept alive under one trust set must never
238
+ // be reused for a check under another.
239
+ agent: trustContext().agent,
205
240
  lookup: (_h, opts, cb) => {
206
241
  // Node may ask for all addresses (autoSelectFamily).
207
242
  if ((opts as { all?: boolean }).all)
@@ -10,7 +10,7 @@
10
10
  * repo's dev/up op, captured at spawn time and/or parsed on demand.
11
11
  */
12
12
 
13
- import { execFileSync, execSync } from "child_process";
13
+ import { execFile, execFileSync, execSync } from "child_process";
14
14
  import * as fs from "fs";
15
15
  import * as path from "path";
16
16
  import { parse as parseYaml } from "yaml";
@@ -333,3 +333,85 @@ export function diffSpawnedContainers(
333
333
  for (const c of after) if (!had.has(c.base)) fresh.add(c.base);
334
334
  return [...fresh];
335
335
  }
336
+
337
+ // ── Async variants (the web server) ──────────────────────────────────────────
338
+ // The sync calls above block the event loop for the whole `docker ps` /
339
+ // `opctl container ls` (hundreds of ms on a loaded machine). The server uses
340
+ // these instead; concurrent callers share one in-flight call.
341
+
342
+ function execOut(cmd: string, args: string[]): Promise<string | null> {
343
+ return new Promise((resolve) => {
344
+ execFile(
345
+ cmd,
346
+ args,
347
+ { encoding: "utf-8", timeout: 5_000, maxBuffer: 16 * 1024 * 1024 },
348
+ (err, stdout) => resolve(err ? null : stdout),
349
+ );
350
+ });
351
+ }
352
+
353
+ function shared<T>(run: () => Promise<T>): () => Promise<T> {
354
+ let inFlight: Promise<T> | null = null;
355
+ return () =>
356
+ (inFlight ??= run().finally(() => {
357
+ inFlight = null;
358
+ }));
359
+ }
360
+
361
+ /** Whether `opctl` is on PATH, without spawning a shell. Cached. */
362
+ export async function isOpctlAvailableAsync(): Promise<boolean> {
363
+ if (opctlAvailableCache !== null) return opctlAvailableCache;
364
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
365
+ if (!dir) continue;
366
+ try {
367
+ await fs.promises.access(path.join(dir, "opctl"), fs.constants.X_OK);
368
+ return (opctlAvailableCache = true);
369
+ } catch {
370
+ /* next */
371
+ }
372
+ }
373
+ return (opctlAvailableCache = false);
374
+ }
375
+
376
+ /** runningDockerNames, async; null when docker is unavailable. */
377
+ export const runningDockerNamesAsync = shared(
378
+ async (): Promise<string[] | null> => {
379
+ const out = await execOut("docker", ["ps", "--format", "{{.Names}}"]);
380
+ return out === null
381
+ ? null
382
+ : out
383
+ .split("\n")
384
+ .map((l) => l.trim())
385
+ .filter(Boolean);
386
+ },
387
+ );
388
+
389
+ /** listRunningOpctlContainers, async. */
390
+ export const listRunningOpctlContainersAsync = shared(
391
+ async (): Promise<{ name: string; base: string }[]> => {
392
+ const byBase = new Map<string, { name: string; base: string }>();
393
+ const [opctlOut, dockerNames] = await Promise.all([
394
+ isOpctlAvailableAsync().then((ok) =>
395
+ ok ? execOut("opctl", ["container", "ls"]) : null,
396
+ ),
397
+ runningDockerNamesAsync(),
398
+ ]);
399
+ if (opctlOut !== null) {
400
+ const lines = opctlOut.split("\n");
401
+ const firstToken = lines[0]?.trim().split(/\s+/)[0];
402
+ const body =
403
+ firstToken === "NAME" || firstToken === "ID" ? lines.slice(1) : lines;
404
+ for (const line of body) {
405
+ const name = line.trim().split(/\s+/)[0];
406
+ if (!name) continue;
407
+ const base = name.replace(/-[0-9a-f]{8}$/i, "");
408
+ byBase.set(base, { name, base });
409
+ }
410
+ }
411
+ for (const name of dockerNames ?? []) {
412
+ const m = name.match(DOCKER_OPCTL_NAME);
413
+ if (m && !byBase.has(m[1])) byBase.set(m[1], { name, base: m[1] });
414
+ }
415
+ return [...byBase.values()];
416
+ },
417
+ );
@@ -44,6 +44,8 @@ import {
44
44
  inspectOpctlContainer,
45
45
  isOpctlAvailable,
46
46
  listRunningOpctlContainers,
47
+ listRunningOpctlContainersAsync,
48
+ isOpctlAvailableAsync,
47
49
  } from "./opctl.js";
48
50
  import {
49
51
  getActiveWorkspace,
@@ -357,6 +359,24 @@ export function activeDaemonOwners(
357
359
  return (record?.workspaces ?? []).filter((w) => active.has(w));
358
360
  }
359
361
 
362
+ /** Whether workspace `name`'s current config lists `daemon`'s service. */
363
+ function workspaceContainsDaemon(
364
+ name: string,
365
+ daemon: Pick<DaemonRecord, "address" | "repoName">,
366
+ ): boolean {
367
+ const services = getWorkspaceByName(name)?.config.services ?? {};
368
+ return daemon.address in services || daemon.repoName in services;
369
+ }
370
+
371
+ /** Replace a running daemon's owners (re-derived from membership). */
372
+ function setDaemonOwners(address: string, workspaces: string[]): void {
373
+ loadDaemonsFromFile();
374
+ const existing = _daemonRegistry.get(address);
375
+ if (!existing) return;
376
+ _daemonRegistry.set(address, { ...existing, workspaces });
377
+ saveDaemonsFile(getDaemonsFile());
378
+ }
379
+
360
380
  /** Outcome of releasing one daemon during a workspace deactivation. */
361
381
  export interface DaemonRelease {
362
382
  address: string;
@@ -382,15 +402,19 @@ export async function releaseWorkspaceDaemons(
382
402
  const owned = listDaemons().filter((d) =>
383
403
  (d.workspaces ?? []).includes(workspace),
384
404
  );
385
- const active = new Set(getActiveWorkspaces());
405
+ const active = getActiveWorkspaces();
386
406
  const releases: DaemonRelease[] = [];
387
407
 
388
408
  for (const daemon of owned) {
389
- const remaining = activeDaemonOwners(daemon, active).filter(
390
- (w) => w !== workspace,
409
+ // Owners re-derived from the CURRENT membership, not the ones recorded at
410
+ // start: a repo moved into another workspace while its daemon ran belongs
411
+ // to that workspace now, and one moved out no longer belongs to this one.
412
+ // Stopped only when no active workspace still contains it.
413
+ const remaining = active.filter(
414
+ (w) => w !== workspace && workspaceContainsDaemon(w, daemon),
391
415
  );
392
416
  if (remaining.length > 0) {
393
- detachDaemonOwner(daemon.address, workspace);
417
+ setDaemonOwners(daemon.address, remaining);
394
418
  onLine(
395
419
  `detached "${workspace}" from ${daemon.address} — still running for [${remaining.join(", ")}]`,
396
420
  );
@@ -639,6 +663,27 @@ export function pruneDeadDaemons(): string[] {
639
663
  const running = isOpctlAvailable()
640
664
  ? new Set(listRunningOpctlContainers().map((c) => c.base))
641
665
  : null;
666
+ return pruneAgainst(daemons, running);
667
+ }
668
+
669
+ /**
670
+ * pruneDeadDaemons without blocking: the container listing runs as async
671
+ * child processes (the web server's startup, which used to hold the event
672
+ * loop for the `docker ps`).
673
+ */
674
+ export async function pruneDeadDaemonsAsync(): Promise<string[]> {
675
+ const daemons = listDaemons();
676
+ if (daemons.length === 0) return [];
677
+ const running = (await isOpctlAvailableAsync())
678
+ ? new Set((await listRunningOpctlContainersAsync()).map((c) => c.base))
679
+ : null;
680
+ return pruneAgainst(daemons, running);
681
+ }
682
+
683
+ function pruneAgainst(
684
+ daemons: DaemonRecord[],
685
+ running: Set<string> | null,
686
+ ): string[] {
642
687
  const pruned: string[] = [];
643
688
  for (const d of daemons) {
644
689
  try {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A checkout's `origin` URL, read from its git config file instead of by
3
+ * running `git`. Callers on the web server's request path (manifest
4
+ * resolution for every repo without its own manifest) used to spawn
5
+ * `git config --get remote.origin.url` synchronously, one repo after another:
6
+ * ~40-100ms each, which held the event loop for a second on a cold server.
7
+ *
8
+ * Same answer as `git -C <dir> config --get remote.origin.url` for ordinary
9
+ * checkouts and linked worktrees: the repo is found by walking up from `dir`
10
+ * to the first `.git` (a directory, or a `gitdir:` file), and a worktree's
11
+ * config is its common dir's.
12
+ */
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+
16
+ /** The git directory for `start` (walking up), or null outside a repo. */
17
+ export function findGitDir(start: string): string | null {
18
+ let dir = path.resolve(start);
19
+ for (;;) {
20
+ const dotGit = path.join(dir, ".git");
21
+ try {
22
+ const st = fs.statSync(dotGit);
23
+ if (st.isDirectory()) return dotGit;
24
+ if (st.isFile()) {
25
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(
26
+ fs.readFileSync(dotGit, "utf-8"),
27
+ );
28
+ if (m) return path.resolve(dir, m[1]);
29
+ }
30
+ } catch {
31
+ /* no .git here: keep walking */
32
+ }
33
+ const parent = path.dirname(dir);
34
+ if (parent === dir) return null;
35
+ dir = parent;
36
+ }
37
+ }
38
+
39
+ /** The `url` of `[remote "origin"]` in a git config file's text, or null. */
40
+ export function originUrlFromConfig(text: string): string | null {
41
+ let inOrigin = false;
42
+ for (const raw of text.split(/\r?\n/)) {
43
+ const line = raw.trim();
44
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
45
+ if (line.startsWith("[")) {
46
+ inOrigin = /^\[\s*remote\s+"origin"\s*\]$/i.test(line);
47
+ continue;
48
+ }
49
+ if (!inOrigin) continue;
50
+ const m = /^url\s*=\s*(.*)$/i.exec(line);
51
+ if (m) {
52
+ const value = m[1].replace(/\s+[#;].*$/, "").trim();
53
+ return value.replace(/^"(.*)"$/, "$1") || null;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /** `dir`'s origin URL, or null (not a checkout, or no origin). Never spawns. */
60
+ export function readOriginUrl(dir: string): string | null {
61
+ const gitDir = findGitDir(dir);
62
+ if (!gitDir) return null;
63
+ let common = gitDir;
64
+ try {
65
+ const rel = fs.readFileSync(path.join(gitDir, "commondir"), "utf-8").trim();
66
+ if (rel) common = path.resolve(gitDir, rel);
67
+ } catch {
68
+ /* not a linked worktree */
69
+ }
70
+ try {
71
+ return originUrlFromConfig(
72
+ fs.readFileSync(path.join(common, "config"), "utf-8"),
73
+ );
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
@@ -15,7 +15,8 @@
15
15
 
16
16
  import * as fs from "fs";
17
17
  import * as path from "path";
18
- import { execFileSync } from "child_process";
18
+ import { readOriginUrl } from "../git/origin-url.js";
19
+ import { cachedRead } from "../read-cache.js";
19
20
  import { getDistRoot } from "../paths.js";
20
21
  import { parse as parseYaml } from "yaml";
21
22
  import type { RawManifest } from "../types/index.js";
@@ -48,21 +49,15 @@ const originCache = new Map<string, string | null>();
48
49
  /** Repo name of `repoPath`'s git origin (`…/<name>.git`), cached. */
49
50
  export function originRepoName(repoPath: string): string | null {
50
51
  if (originCache.has(repoPath)) return originCache.get(repoPath)!;
51
- let name: string | null = null;
52
- try {
53
- const url = execFileSync(
54
- "git",
55
- ["-C", repoPath, "config", "--get", "remote.origin.url"],
56
- { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 },
57
- ).trim();
58
- name =
59
- url
52
+ // Read from the git config file, never by spawning git: this runs on the
53
+ // web server's request path for every repo without its own manifest.
54
+ const url = readOriginUrl(repoPath);
55
+ const name = url
56
+ ? url
60
57
  .replace(/\.git$/, "")
61
58
  .split(/[/:]/)
62
- .pop() || null;
63
- } catch {
64
- /* not a git checkout, or no origin */
65
- }
59
+ .pop() || null
60
+ : null;
66
61
  originCache.set(repoPath, name);
67
62
  return name;
68
63
  }
@@ -169,6 +164,29 @@ export function parseManifestText(
169
164
  return parsed as Record<string, unknown>;
170
165
  }
171
166
 
167
+ /**
168
+ * Parsed manifests by file, reused while the file's text is unchanged. A
169
+ * single web request reads the same manifests several times (kind, health
170
+ * plan, stack names, edge links), and parsing YAML is the expensive part:
171
+ * it held the event loop ~100-200ms per /api/health run. Callers get their
172
+ * own copy, so mutating a result never leaks into the cache.
173
+ */
174
+ const parsedCache = new Map<
175
+ string,
176
+ { raw: string; parsed: Record<string, unknown> | null }
177
+ >();
178
+
179
+ function parseCached(
180
+ file: string,
181
+ raw: string,
182
+ ): Record<string, unknown> | null {
183
+ const hit = parsedCache.get(file);
184
+ if (hit && hit.raw === raw) return hit.parsed && structuredClone(hit.parsed);
185
+ const parsed = parseManifestText(file, raw);
186
+ parsedCache.set(file, { raw, parsed });
187
+ return parsed && structuredClone(parsed);
188
+ }
189
+
172
190
  /** The message for an override that has no base to apply to. */
173
191
  export function orphanOverrideMessage(): string {
174
192
  return `an override with no base manifest is not a manifest: add a ${product().manifestYml} to the repo, or to manifests/`;
@@ -198,7 +216,7 @@ function combine(
198
216
  ): ManifestLoad {
199
217
  const out: ManifestLoad = { manifest: null, layers: [], ...at };
200
218
  const parse = (file: string, raw: string): Record<string, unknown> => {
201
- const parsed = parseManifestText(file, raw);
219
+ const parsed = parseCached(file, raw);
202
220
  if (!parsed) throw new Error(`${file}: not a map`);
203
221
  return parsed;
204
222
  };
@@ -224,6 +242,12 @@ function combine(
224
242
 
225
243
  /** Resolve, read and merge `repoPath`'s manifest layers. Never throws. */
226
244
  export function loadManifest(repoPath: string): ManifestLoad {
245
+ // The web server resolves the same manifests many times per request; it
246
+ // reuses them for the read cache's TTL (see read-cache.ts).
247
+ return cachedRead(`manifest:${repoPath}`, () => loadManifestFresh(repoPath));
248
+ }
249
+
250
+ function loadManifestFresh(repoPath: string): ManifestLoad {
227
251
  const at = locate(repoPath);
228
252
  try {
229
253
  return combine(
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import {
4
4
  loadProfile,
5
+ onProfileCacheReset,
5
6
  PROFILE_FILE,
6
7
  readEnv,
7
8
  type ProductProfile,
@@ -74,6 +75,34 @@ function rootFromEnv(): string | null {
74
75
  * anchor fails loudly.
75
76
  */
76
77
  export function getDistRoot(): string {
78
+ // Resolved once per set of inputs: the walk and its den.yml checks used to
79
+ // run on every call (hundreds per web request, via product()). The inputs
80
+ // are the env vars it reads and the entry script; change any and it
81
+ // re-resolves. A failure is never cached.
82
+ const key = distRootKey();
83
+ if (distRootMemo?.key === key) return distRootMemo.root;
84
+ const root = resolveDistRoot();
85
+ distRootMemo = { key, root };
86
+ return root;
87
+ }
88
+
89
+ let distRootMemo: { key: string; root: string } | null = null;
90
+
91
+ /** Everything getDistRoot's answer depends on, as one string. */
92
+ function distRootKey(): string {
93
+ let key = `${process.argv[1] ?? ""}\0${process.env.DEN_PROFILE ?? ""}`;
94
+ for (const name in process.env)
95
+ if (name.endsWith("_ROOT")) key += `\0${name}=${process.env[name]}`;
96
+ return key;
97
+ }
98
+
99
+ /** Forget the resolved root (tests that rebuild a root in place). */
100
+ export function resetDistRootCache(): void {
101
+ distRootMemo = null;
102
+ }
103
+ onProfileCacheReset(resetDistRootCache);
104
+
105
+ function resolveDistRoot(): string {
77
106
  const fromEnv = rootFromEnv();
78
107
  if (fromEnv) return fromEnv;
79
108
  const start = entryDir();
@@ -128,6 +157,19 @@ function isProductRootVar(dir: string, key: string): boolean {
128
157
  * node_modules/@monster-orange/den; in Den's own checkout, its root.
129
158
  */
130
159
  export function getEngineRoot(): string {
160
+ const key = `${process.env.DEN_ENGINE_ROOT ?? ""}\0${process.argv[1] ?? ""}\0${process.cwd()}`;
161
+ if (engineRootMemo?.key === key) return engineRootMemo.root;
162
+ const root = resolveEngineRoot();
163
+ engineRootMemo = { key, root };
164
+ return root;
165
+ }
166
+
167
+ let engineRootMemo: { key: string; root: string } | null = null;
168
+ onProfileCacheReset(() => {
169
+ engineRootMemo = null;
170
+ });
171
+
172
+ function resolveEngineRoot(): string {
131
173
  const env = process.env.DEN_ENGINE_ROOT?.trim();
132
174
  if (env) return path.resolve(env);
133
175
  for (const start of [entryDir(), process.cwd()]) {
@@ -35,10 +35,16 @@ function withDerived(p: ProductProfile): Product {
35
35
  };
36
36
  }
37
37
 
38
+ let derived: { profile: ProductProfile; product: Product } | null = null;
39
+
38
40
  /** The running product. Never throws. */
39
41
  export function product(): Product {
40
42
  try {
41
- return withDerived(loadProfile(getDistRoot()));
43
+ const profile = loadProfile(getDistRoot());
44
+ // Same profile object (loadProfile caches it): same derived names.
45
+ if (derived?.profile !== profile)
46
+ derived = { profile, product: withDerived(profile) };
47
+ return derived.product;
42
48
  } catch {
43
49
  const pinned = process.env.DEN_PROFILE?.trim();
44
50
  try {
@@ -122,8 +122,16 @@ export function loadProfile(root: string): ProductProfile {
122
122
  return cached.profile;
123
123
  }
124
124
 
125
+ const resetHooks: (() => void)[] = [];
126
+
127
+ /** Run `hook` whenever resetProfileCache runs (caches derived from it). */
128
+ export function onProfileCacheReset(hook: () => void): void {
129
+ resetHooks.push(hook);
130
+ }
131
+
125
132
  export function resetProfileCache(): void {
126
133
  cached = null;
134
+ for (const hook of resetHooks) hook();
127
135
  }
128
136
 
129
137
  /** `${PREFIX}_${key}`: the env-var name this distribution uses for `key`. */