@tridha643/hestia 1.0.1 → 1.2.0

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/daemon.js CHANGED
@@ -6945,9 +6945,9 @@ var require_public_api = __commonJS((exports) => {
6945
6945
  });
6946
6946
 
6947
6947
  // packages/engine/src/daemon/main.ts
6948
- import { chmodSync as chmodSync6, rmSync as rmSync10 } from "fs";
6948
+ import { chmodSync as chmodSync6, rmSync as rmSync11 } from "fs";
6949
6949
  import { randomBytes } from "crypto";
6950
- import { join as join23 } from "path";
6950
+ import { join as join25 } from "path";
6951
6951
 
6952
6952
  // packages/session-broker-core/src/limits.ts
6953
6953
  var MAX_HTTP_BODY_BYTES = 4 * 1024 * 1024;
@@ -7807,6 +7807,19 @@ import {
7807
7807
  } from "fs";
7808
7808
  import { dirname } from "path";
7809
7809
  import { randomUUID as randomUUID2 } from "crypto";
7810
+ function fsyncDir(path) {
7811
+ let fd;
7812
+ try {
7813
+ fd = openSync(dirname(path), "r");
7814
+ } catch {
7815
+ return;
7816
+ }
7817
+ try {
7818
+ fsyncSync(fd);
7819
+ } catch {} finally {
7820
+ closeSync(fd);
7821
+ }
7822
+ }
7810
7823
  function writeAtomicJsonFile(path, value, options = {}) {
7811
7824
  mkdirSync(dirname(path), { recursive: true });
7812
7825
  const temporaryPath = `${path}.${process.pid}.${randomUUID2()}.tmp`;
@@ -7828,6 +7841,7 @@ function writeAtomicJsonFile(path, value, options = {}) {
7828
7841
  rmSync(temporaryPath, { force: true });
7829
7842
  throw error;
7830
7843
  }
7844
+ fsyncDir(path);
7831
7845
  }
7832
7846
  function writeAtomicTextFile(path, source, mode = 384) {
7833
7847
  mkdirSync(dirname(path), { recursive: true });
@@ -7849,6 +7863,7 @@ function writeAtomicTextFile(path, source, mode = 384) {
7849
7863
  rmSync(temporaryPath, { force: true });
7850
7864
  throw error;
7851
7865
  }
7866
+ fsyncDir(path);
7852
7867
  }
7853
7868
 
7854
7869
  // packages/engine/src/proc/pidfile.ts
@@ -8203,10 +8218,10 @@ function clearState(worktreeRoot, project) {
8203
8218
  }
8204
8219
 
8205
8220
  // packages/engine/src/index.ts
8206
- import { existsSync as existsSync20, rmSync as rmSync9 } from "fs";
8221
+ import { existsSync as existsSync21, rmSync as rmSync10 } from "fs";
8207
8222
  import { execFile as execFile10 } from "child_process";
8208
8223
  import { promisify as promisify9 } from "util";
8209
- import { join as join22, resolve as resolve2 } from "path";
8224
+ import { join as join24, resolve as resolve2 } from "path";
8210
8225
  import { createHash as createHash7 } from "crypto";
8211
8226
  import { resolve4, resolve6, resolveCname } from "dns/promises";
8212
8227
 
@@ -9402,10 +9417,10 @@ import { execFile as execFile7 } from "child_process";
9402
9417
  import { Agent, createServer as createServer2, request as httpRequest } from "http";
9403
9418
  import { createConnection, createServer as createNetServer } from "net";
9404
9419
  import { promisify as promisify6 } from "util";
9405
- import { existsSync as existsSync13, readFileSync as readFileSync13, readdirSync as readdirSync4 } from "fs";
9406
- import { chmodSync as chmodSync5, lstatSync, mkdirSync as mkdirSync7, rmSync as rmSync8 } from "fs";
9420
+ import { existsSync as existsSync14, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "fs";
9421
+ import { chmodSync as chmodSync5, lstatSync, mkdirSync as mkdirSync7, rmSync as rmSync9 } from "fs";
9407
9422
  import { tmpdir } from "os";
9408
- import { join as join16 } from "path";
9423
+ import { join as join17 } from "path";
9409
9424
  import { createHash as createHash5 } from "crypto";
9410
9425
 
9411
9426
  // packages/engine/src/router/router-config.ts
@@ -9876,6 +9891,39 @@ async function releaseSlot(port, project) {
9876
9891
  });
9877
9892
  } catch {}
9878
9893
  }
9894
+ async function sharedVerb(port, verb, body) {
9895
+ const timeoutMs = (body.waitMs ?? 0) + 5000;
9896
+ let response;
9897
+ try {
9898
+ response = await fetch(`http://127.0.0.1:${port}/hestia/shared/${verb}`, {
9899
+ method: "POST",
9900
+ headers: { "content-type": "application/json", ...daemonAuthHeaders(port) },
9901
+ body: JSON.stringify(body),
9902
+ signal: AbortSignal.timeout(timeoutMs)
9903
+ });
9904
+ } catch (error) {
9905
+ throw new HestiaError("daemon-unreachable", `could not reach hestiad on 127.0.0.1:${port}: ${error.message}`);
9906
+ }
9907
+ const payload = await response.json().catch(() => ({}));
9908
+ if (!response.ok) {
9909
+ throw new HestiaError(payload.code ?? "daemon-unreachable", payload.error ?? `daemon rejected shared ${verb}: HTTP ${response.status}`);
9910
+ }
9911
+ return {
9912
+ granted: payload.granted === true,
9913
+ holder: payload.holder,
9914
+ queued: payload.queued ?? []
9915
+ };
9916
+ }
9917
+ async function releaseSharedForProject(port, project, service) {
9918
+ try {
9919
+ await fetch(`http://127.0.0.1:${port}/hestia/shared/release-project`, {
9920
+ method: "POST",
9921
+ headers: { "content-type": "application/json", ...daemonAuthHeaders(port) },
9922
+ body: JSON.stringify({ project, service }),
9923
+ signal: AbortSignal.timeout(2000)
9924
+ });
9925
+ } catch {}
9926
+ }
9879
9927
 
9880
9928
  // packages/engine/src/daemon/launchd.ts
9881
9929
  import { existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync11, rmSync as rmSync6 } from "fs";
@@ -10258,9 +10306,57 @@ function createRoutes(admission, startedAt, dependencies) {
10258
10306
  await admission.release(body.project);
10259
10307
  return json({ ok: true });
10260
10308
  }
10309
+ if (url.pathname.startsWith("/hestia/shared/") && request.method === "POST") {
10310
+ let body;
10311
+ try {
10312
+ body = await parseJsonBody(request);
10313
+ } catch (error) {
10314
+ return json({ error: `invalid JSON body: ${error.message}` }, 400);
10315
+ }
10316
+ const verb = url.pathname.slice("/hestia/shared/".length);
10317
+ if (!isProject(body.project))
10318
+ return json({ error: "project is invalid" }, 400);
10319
+ const project = body.project;
10320
+ try {
10321
+ if (verb === "release-project") {
10322
+ const service = body.service === undefined ? undefined : isService(body.service) ? body.service : null;
10323
+ if (service === null)
10324
+ return json({ error: "service is invalid" }, 400);
10325
+ await dependencies.shared.releaseProject(project, service);
10326
+ return json({ ok: true });
10327
+ }
10328
+ if (!isSharedName(body.name))
10329
+ return json({ error: "name is invalid" }, 400);
10330
+ const name = body.name;
10331
+ if (verb === "claim") {
10332
+ if (typeof body.worktree !== "string" || !body.worktree.startsWith("/") || body.worktree.length > 4096) {
10333
+ return json({ error: "worktree is invalid" }, 400);
10334
+ }
10335
+ const waitMs = typeof body.waitMs === "number" && Number.isFinite(body.waitMs) && body.waitMs > 0 ? Math.min(body.waitMs, 24 * 60 * 60 * 1000) : 0;
10336
+ return json(await dependencies.shared.request(name, { project, worktree: body.worktree }, waitMs));
10337
+ }
10338
+ if (verb === "allow")
10339
+ return json(await dependencies.shared.allow(name, project));
10340
+ if (verb === "deny")
10341
+ return json(await dependencies.shared.deny(name, project));
10342
+ if (verb === "release")
10343
+ return json(await dependencies.shared.release(name, project));
10344
+ if (verb === "cancel")
10345
+ return json(await dependencies.shared.cancel(name, project));
10346
+ } catch (error) {
10347
+ if (error instanceof HestiaError) {
10348
+ return json({ error: error.message, code: error.code }, error.code === "shared-not-found" ? 404 : 409);
10349
+ }
10350
+ return json({ error: error.message }, 500);
10351
+ }
10352
+ return json({ error: `unknown route ${url.pathname}` }, 404);
10353
+ }
10261
10354
  return json({ error: `unknown route ${url.pathname}` }, 404);
10262
10355
  };
10263
10356
  }
10357
+ function isSharedName(value) {
10358
+ return typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,62}$/.test(value);
10359
+ }
10264
10360
 
10265
10361
  // packages/engine/src/daemon/ensure.ts
10266
10362
  var START_TIMEOUT_MS = 5000;
@@ -10477,6 +10573,149 @@ async function keychainHasFingerprint(keychain, fingerprint) {
10477
10573
  }
10478
10574
  }
10479
10575
 
10576
+ // packages/engine/src/tunnel/shared.ts
10577
+ import { existsSync as existsSync13, readFileSync as readFileSync13, readdirSync as readdirSync4, rmSync as rmSync8 } from "fs";
10578
+ import { join as join16 } from "path";
10579
+ var NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
10580
+ var LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
10581
+ var PATH_SEGMENT_RE = /^[A-Za-z0-9._~!$&'()*+,;=:@%-]+$/;
10582
+ function sharedRoot() {
10583
+ return join16(hestiaHome(), "shared");
10584
+ }
10585
+ function sharedPath(name) {
10586
+ return join16(sharedRoot(), `${name}.json`);
10587
+ }
10588
+ function assertSharedName(name) {
10589
+ if (!NAME_RE.test(name)) {
10590
+ throw new HestiaError("usage", `shared hostname handle ${JSON.stringify(name)} must be a DNS label: ` + `lowercase alphanumerics and hyphens, at most 63 characters`);
10591
+ }
10592
+ }
10593
+ function assertSharedHostnameFqdn(hostname) {
10594
+ const labels = hostname.split(".");
10595
+ const valid = hostname.length <= 253 && hostname === hostname.toLowerCase() && labels.length >= 2 && labels.every((label) => LABEL_RE.test(label));
10596
+ if (!valid) {
10597
+ throw new HestiaError("usage", `shared hostname ${JSON.stringify(hostname)} must be a fully-qualified ` + `lowercase domain (e.g. slack.acme.com)`);
10598
+ }
10599
+ }
10600
+ function normalizeSharedPath(input) {
10601
+ if (input === undefined)
10602
+ return;
10603
+ const trimmed = input.trim();
10604
+ const segments = trimmed.split("/").filter((segment) => segment !== "");
10605
+ for (const segment of segments) {
10606
+ if (segment === "." || segment === ".." || !PATH_SEGMENT_RE.test(segment)) {
10607
+ throw new HestiaError("usage", `shared hostname path ${JSON.stringify(input)} must be a URL path prefix ` + `like /webhooks/slack (no query, fragment, or ".." segments)`);
10608
+ }
10609
+ }
10610
+ if (segments.length === 0)
10611
+ return;
10612
+ return `/${segments.join("/")}`;
10613
+ }
10614
+ function sharedPathMatches(prefix, requestPath) {
10615
+ if (prefix === undefined)
10616
+ return true;
10617
+ return requestPath === prefix || requestPath.startsWith(`${prefix}/`);
10618
+ }
10619
+ function withSharedLock(fn) {
10620
+ return withLock(sharedRoot(), fn);
10621
+ }
10622
+ function parseShared(source) {
10623
+ let value;
10624
+ try {
10625
+ value = JSON.parse(source);
10626
+ } catch {
10627
+ return null;
10628
+ }
10629
+ if (value.schemaVersion !== STATE_SCHEMA_VERSION || typeof value.name !== "string" || !NAME_RE.test(value.name) || typeof value.hostname !== "string" || value.hostname.length === 0 || typeof value.tunnelUuid !== "string" || value.tunnelUuid.length === 0 || typeof value.zone !== "string" || value.zone.length === 0 || typeof value.service !== "string" || value.service.length === 0 || typeof value.createdAt !== "string")
10630
+ return null;
10631
+ if (value.path !== undefined && (typeof value.path !== "string" || !value.path.startsWith("/"))) {
10632
+ return null;
10633
+ }
10634
+ if (value.holder !== undefined) {
10635
+ const holder = value.holder;
10636
+ if (typeof holder.project !== "string" || typeof holder.worktree !== "string" || typeof holder.service !== "string" || typeof holder.at !== "string")
10637
+ return null;
10638
+ }
10639
+ if (value.queue !== undefined) {
10640
+ if (!Array.isArray(value.queue))
10641
+ return null;
10642
+ for (const entry of value.queue) {
10643
+ if (typeof entry !== "object" || entry === null || typeof entry.project !== "string" || typeof entry.worktree !== "string" || typeof entry.at !== "string")
10644
+ return null;
10645
+ }
10646
+ }
10647
+ return value;
10648
+ }
10649
+ function readSharedHostname(name) {
10650
+ const p = sharedPath(name);
10651
+ if (!existsSync13(p))
10652
+ return null;
10653
+ try {
10654
+ return parseShared(readFileSync13(p, "utf8"));
10655
+ } catch {
10656
+ return null;
10657
+ }
10658
+ }
10659
+ function listSharedHostnames() {
10660
+ const root = sharedRoot();
10661
+ if (!existsSync13(root))
10662
+ return [];
10663
+ const records = [];
10664
+ for (const entry of readdirSync4(root).sort()) {
10665
+ if (!entry.endsWith(".json"))
10666
+ continue;
10667
+ const record = readSharedHostname(entry.slice(0, -".json".length));
10668
+ if (record !== null && `${record.name}.json` === entry)
10669
+ records.push(record);
10670
+ }
10671
+ return records;
10672
+ }
10673
+ async function declareSharedHostname(record) {
10674
+ assertSharedName(record.name);
10675
+ assertSharedHostnameFqdn(record.hostname);
10676
+ const path = normalizeSharedPath(record.path);
10677
+ return withSharedLock(async () => {
10678
+ const collision = listSharedHostnames().find((candidate) => candidate.name !== record.name && candidate.hostname === record.hostname && (candidate.path ?? undefined) === (path ?? undefined));
10679
+ if (collision !== undefined) {
10680
+ throw new HestiaError("shared-conflict", `${record.hostname}${path ?? ""} is already declared as shared hostname ` + `"${collision.name}" \u2014 pick a distinct path or handle`);
10681
+ }
10682
+ const existing = readSharedHostname(record.name);
10683
+ if (existing !== null) {
10684
+ if (existing.hostname !== record.hostname || existing.tunnelUuid !== record.tunnelUuid || (existing.path ?? undefined) !== (path ?? undefined)) {
10685
+ throw new HestiaError("shared-conflict", `shared hostname "${record.name}" already declared as ${existing.hostname}` + `${existing.path ?? ""} on tunnel ${existing.tunnelUuid} \u2014 release and remove it before re-pointing`);
10686
+ }
10687
+ const merged = { ...existing, service: record.service, zone: record.zone };
10688
+ writeAtomicJsonFile(sharedPath(record.name), merged);
10689
+ return merged;
10690
+ }
10691
+ const fresh = {
10692
+ schemaVersion: STATE_SCHEMA_VERSION,
10693
+ ...record,
10694
+ createdAt: new Date().toISOString()
10695
+ };
10696
+ if (path === undefined)
10697
+ delete fresh.path;
10698
+ else
10699
+ fresh.path = path;
10700
+ writeAtomicJsonFile(sharedPath(record.name), fresh);
10701
+ return fresh;
10702
+ });
10703
+ }
10704
+ async function updateSharedHostname(name, mutate) {
10705
+ return withSharedLock(async () => {
10706
+ const existing = readSharedHostname(name);
10707
+ if (existing === null)
10708
+ return null;
10709
+ const next = mutate(structuredClone(existing));
10710
+ if (next.queue !== undefined && next.queue.length === 0)
10711
+ delete next.queue;
10712
+ if (next.holder === undefined)
10713
+ delete next.holder;
10714
+ writeAtomicJsonFile(sharedPath(name), next);
10715
+ return next;
10716
+ });
10717
+ }
10718
+
10480
10719
  // packages/engine/src/router/local-http-router.ts
10481
10720
  var pexec6 = promisify6(execFile7);
10482
10721
  var ROUTE_REFRESH_MS = 1000;
@@ -10492,18 +10731,25 @@ var HOP_BY_HOP_HEADERS = new Set([
10492
10731
  "transfer-encoding",
10493
10732
  "upgrade"
10494
10733
  ]);
10734
+ function pathnameOf(target) {
10735
+ if (target === undefined || target.length === 0)
10736
+ return "/";
10737
+ const cut = target.search(/[?#]/);
10738
+ const path = cut < 0 ? target : target.slice(0, cut);
10739
+ return path.startsWith("/") ? path : "/";
10740
+ }
10495
10741
 
10496
10742
  class OriginOwnershipError extends Error {
10497
10743
  }
10498
10744
  function readRouterStackRecords() {
10499
- const stacksDir = join16(hestiaHome(), "stacks");
10500
- if (!existsSync13(stacksDir))
10745
+ const stacksDir = join17(hestiaHome(), "stacks");
10746
+ if (!existsSync14(stacksDir))
10501
10747
  return [];
10502
10748
  const records = [];
10503
- for (const project of readdirSync4(stacksDir).sort()) {
10504
- const path = join16(stacksDir, project, "stack.json");
10749
+ for (const project of readdirSync5(stacksDir).sort()) {
10750
+ const path = join17(stacksDir, project, "stack.json");
10505
10751
  try {
10506
- const source = readFileSync13(path);
10752
+ const source = readFileSync14(path);
10507
10753
  if (source.byteLength > MAX_MIRROR_BYTES)
10508
10754
  continue;
10509
10755
  const record = parseStackRecord(source.toString("utf8"), path);
@@ -10655,11 +10901,11 @@ function internalEndpointAuthority(project, service) {
10655
10901
  function publicGatewaySocketPath() {
10656
10902
  const uid = process.getuid?.() ?? 501;
10657
10903
  const homeHash = createHash5("sha256").update(hestiaHome()).digest("hex").slice(0, 12);
10658
- return join16(tmpdir(), `hestia-${uid}`, homeHash, "origin.sock");
10904
+ return join17(tmpdir(), `hestia-${uid}`, homeHash, "origin.sock");
10659
10905
  }
10660
10906
  function prepareGatewaySocket(path) {
10661
10907
  const uid = process.getuid?.();
10662
- const directories = [join16(tmpdir(), `hestia-${uid ?? 501}`), join16(tmpdir(), `hestia-${uid ?? 501}`, createHash5("sha256").update(hestiaHome()).digest("hex").slice(0, 12))];
10908
+ const directories = [join17(tmpdir(), `hestia-${uid ?? 501}`), join17(tmpdir(), `hestia-${uid ?? 501}`, createHash5("sha256").update(hestiaHome()).digest("hex").slice(0, 12))];
10663
10909
  for (const directory of directories) {
10664
10910
  mkdirSync7(directory, { recursive: true, mode: 448 });
10665
10911
  const stat2 = lstatSync(directory);
@@ -10668,13 +10914,13 @@ function prepareGatewaySocket(path) {
10668
10914
  }
10669
10915
  chmodSync5(directory, 448);
10670
10916
  }
10671
- if (!existsSync13(path))
10917
+ if (!existsSync14(path))
10672
10918
  return;
10673
10919
  const stat = lstatSync(path);
10674
10920
  if (stat.isSymbolicLink() || !stat.isSocket() || uid !== undefined && stat.uid !== uid) {
10675
10921
  throw new Error(`refusing unsafe existing Hestia gateway socket ${path}`);
10676
10922
  }
10677
- rmSync8(path);
10923
+ rmSync9(path);
10678
10924
  }
10679
10925
  function sendRouterResponse(response, status, message) {
10680
10926
  response.writeHead(status, { "content-type": "text/plain; charset=utf-8", "x-hestia-router": "1" });
@@ -10687,6 +10933,7 @@ class HestiaLocalHttpRouter {
10687
10933
  #frontServer = createNetServer((socket) => this.#acceptFrontSocket(socket));
10688
10934
  #unixServer = createNetServer((socket) => this.#acceptFrontSocket(socket));
10689
10935
  #targets = new Map;
10936
+ #sharedRoutes = new Map;
10690
10937
  #timer;
10691
10938
  async start() {
10692
10939
  await new Promise((resolve2, reject) => {
@@ -10790,12 +11037,52 @@ class HestiaLocalHttpRouter {
10790
11037
  }
10791
11038
  }
10792
11039
  }
11040
+ const sharedRoutes = new Map;
11041
+ for (const shared of listSharedHostnames()) {
11042
+ const hostname = shared.hostname.toLowerCase();
11043
+ targets.delete(hostname);
11044
+ const holder = shared.holder;
11045
+ let target;
11046
+ if (holder !== undefined) {
11047
+ const stack = records.find((record) => record.project === holder.project);
11048
+ const endpoint = stack?.endpoints.find((candidate2) => (candidate2.alias ?? candidate2.name) === holder.service);
11049
+ const service = stack?.services.find((candidate2) => candidate2.name === (endpoint?.workload ?? endpoint?.name));
11050
+ const binding = service?.bindings?.find((candidate2) => `${candidate2.target}/${candidate2.protocol}` === endpoint?.binding);
11051
+ const publishedPort = binding?.publishedPort ?? service?.publishedPort;
11052
+ const candidate = {
11053
+ hostname,
11054
+ project: holder.project,
11055
+ service: service === undefined || service.backend === "tunnel" || publishedPort === undefined ? undefined : { ...service, publishedPort }
11056
+ };
11057
+ const previous = this.#sharedRoutes.get(hostname)?.find((entry) => entry.name === shared.name)?.target;
11058
+ target = previous !== undefined && targetIdentity(previous) === targetIdentity(candidate) ? previous : candidate;
11059
+ }
11060
+ const list = sharedRoutes.get(hostname) ?? [];
11061
+ list.push({ path: shared.path, name: shared.name, claimed: holder !== undefined, target });
11062
+ sharedRoutes.set(hostname, list);
11063
+ }
11064
+ for (const list of sharedRoutes.values()) {
11065
+ list.sort((left, right) => (right.path?.length ?? -1) - (left.path?.length ?? -1));
11066
+ }
10793
11067
  reconcilePortlessAliases([...targets.keys()].filter((hostname) => hostname.endsWith(".localhost")), this.port);
10794
- for (const [hostname, target] of this.#targets) {
10795
- if (targets.get(hostname) !== target)
11068
+ const retained = new Set(targets.values());
11069
+ for (const list of sharedRoutes.values()) {
11070
+ for (const entry of list)
11071
+ if (entry.target !== undefined)
11072
+ retained.add(entry.target);
11073
+ }
11074
+ const priorTargets = [...this.#targets.values()];
11075
+ for (const list of this.#sharedRoutes.values()) {
11076
+ for (const entry of list)
11077
+ if (entry.target !== undefined)
11078
+ priorTargets.push(entry.target);
11079
+ }
11080
+ for (const target of priorTargets) {
11081
+ if (!retained.has(target))
10796
11082
  target.agent?.destroy();
10797
11083
  }
10798
11084
  this.#targets = targets;
11085
+ this.#sharedRoutes = sharedRoutes;
10799
11086
  }
10800
11087
  stop() {
10801
11088
  if (this.#timer)
@@ -10809,7 +11096,7 @@ class HestiaLocalHttpRouter {
10809
11096
  target.agent?.destroy();
10810
11097
  this.#frontServer.close();
10811
11098
  this.#unixServer.close();
10812
- rmSync8(this.socketPath, { force: true });
11099
+ rmSync9(this.socketPath, { force: true });
10813
11100
  this.#server.close();
10814
11101
  }
10815
11102
  #acceptFrontSocket(socket) {
@@ -10854,16 +11141,40 @@ Connection: close\r
10854
11141
  `));
10855
11142
  socket.once("error", () => internal.destroy());
10856
11143
  }
11144
+ #matchTarget(hostname, requestPath) {
11145
+ const routes = this.#sharedRoutes.get(hostname);
11146
+ if (routes !== undefined) {
11147
+ const entry = routes.find((candidate) => sharedPathMatches(candidate.path, requestPath));
11148
+ if (entry === undefined)
11149
+ return { status: 404, message: "Hestia route not found" };
11150
+ if (!entry.claimed) {
11151
+ return {
11152
+ status: 503,
11153
+ message: `Shared hostname unclaimed \u2014 run \`hestia claim ${entry.name}\` in the worktree that should receive this traffic`
11154
+ };
11155
+ }
11156
+ if (entry.target?.service?.publishedPort === undefined) {
11157
+ return { status: 503, message: "Hestia route origin unavailable" };
11158
+ }
11159
+ return { target: entry.target };
11160
+ }
11161
+ const target = this.#targets.get(hostname);
11162
+ if (target === undefined)
11163
+ return { status: 404, message: "Hestia route not found" };
11164
+ if (target.service?.publishedPort === undefined) {
11165
+ return { status: 503, message: "Hestia route origin unavailable" };
11166
+ }
11167
+ return { target };
11168
+ }
10857
11169
  async#proxyHttp(request, response) {
10858
11170
  const hostname = parseRouteAuthority(request.headers.host);
10859
11171
  if (hostname === null)
10860
11172
  return sendRouterResponse(response, 400, "Malformed Host authority");
10861
- const target = this.#targets.get(hostname);
10862
- if (target === undefined)
10863
- return sendRouterResponse(response, 404, "Hestia route not found");
10864
- const port = target.service?.publishedPort;
10865
- if (port === undefined)
10866
- return sendRouterResponse(response, 503, "Hestia route origin unavailable");
11173
+ const match = this.#matchTarget(hostname, pathnameOf(request.url));
11174
+ if ("status" in match)
11175
+ return sendRouterResponse(response, match.status, match.message);
11176
+ const target = match.target;
11177
+ const port = target.service.publishedPort;
10867
11178
  if (await verifyLocalRouterTarget(target) !== port) {
10868
11179
  return sendRouterResponse(response, 503, "Hestia route origin unavailable");
10869
11180
  }
@@ -10904,16 +11215,19 @@ Connection: close\r
10904
11215
  `);
10905
11216
  return;
10906
11217
  }
10907
- const target = this.#targets.get(host);
10908
- const port = target?.service?.publishedPort;
10909
- if (target === undefined || port === undefined) {
10910
- socket.end(`HTTP/1.1 ${target === undefined ? "404 Not Found" : "503 Service Unavailable"}\r
11218
+ const rawHeaderLines = header.slice(0, -4).split(`\r
11219
+ `);
11220
+ const requestPath = pathnameOf(rawHeaderLines[0]?.split(" ")[1]);
11221
+ const match = this.#matchTarget(host, requestPath);
11222
+ if ("status" in match) {
11223
+ const reason = match.status === 404 ? "404 Not Found" : "503 Service Unavailable";
11224
+ socket.end(`HTTP/1.1 ${reason}\r
10911
11225
  \r
10912
11226
  `);
10913
11227
  return;
10914
11228
  }
10915
- const rawHeaderLines = header.slice(0, -4).split(`\r
10916
- `);
11229
+ const target = match.target;
11230
+ const port = target.service.publishedPort;
10917
11231
  const connectionValues = rawHeaderLines.slice(1).filter((line) => /^connection:/i.test(line)).map((line) => line.slice(line.indexOf(":") + 1));
10918
11232
  const connectionNames = connectionHeaderTokens(connectionValues);
10919
11233
  const retainedHeaders = rawHeaderLines.slice(1).filter((line) => {
@@ -10963,15 +11277,15 @@ Connection: close\r
10963
11277
  }
10964
11278
 
10965
11279
  // packages/engine/src/wrangler/adapter.ts
10966
- import { existsSync as existsSync14, readFileSync as readFileSync14, statSync as statSync2 } from "fs";
11280
+ import { existsSync as existsSync15, readFileSync as readFileSync15, statSync as statSync2 } from "fs";
10967
11281
  import { homedir as homedir3 } from "os";
10968
- import { dirname as dirname5, join as join17, relative as relative3 } from "path";
11282
+ import { dirname as dirname5, join as join18, relative as relative3 } from "path";
10969
11283
  import { connect } from "net";
10970
11284
  function privateRegistryDir(worktreeRoot) {
10971
- return join17(worktreeRoot, ".hestia", "wrangler-registry");
11285
+ return join18(worktreeRoot, ".hestia", "wrangler-registry");
10972
11286
  }
10973
11287
  function globalRegistryDir() {
10974
- return process.env.WRANGLER_REGISTRY_PATH ?? join17(homedir3(), ".wrangler", "config", "registry");
11288
+ return process.env.WRANGLER_REGISTRY_PATH ?? join18(homedir3(), ".wrangler", "config", "registry");
10975
11289
  }
10976
11290
  var HEARTBEAT_FRESH_MS = 60000;
10977
11291
  function tcpConnectable(address, timeoutMs = 500) {
@@ -10995,19 +11309,19 @@ function tcpConnectable(address, timeoutMs = 500) {
10995
11309
  }
10996
11310
  async function preflightForeignSessions(workers) {
10997
11311
  const dir = globalRegistryDir();
10998
- if (!existsSync14(dir))
11312
+ if (!existsSync15(dir))
10999
11313
  return;
11000
11314
  for (const w of workers) {
11001
11315
  if (w.name === null)
11002
11316
  continue;
11003
- const entryPath = join17(dir, w.name);
11004
- if (!existsSync14(entryPath))
11317
+ const entryPath = join18(dir, w.name);
11318
+ if (!existsSync15(entryPath))
11005
11319
  continue;
11006
11320
  const freshMs = Date.now() - statSync2(entryPath).mtimeMs;
11007
11321
  let live = freshMs < HEARTBEAT_FRESH_MS;
11008
11322
  if (!live) {
11009
11323
  try {
11010
- const entry = JSON.parse(readFileSync14(entryPath, "utf8"));
11324
+ const entry = JSON.parse(readFileSync15(entryPath, "utf8"));
11011
11325
  live = entry.debugPortAddress ? await tcpConnectable(entry.debugPortAddress) : false;
11012
11326
  } catch {
11013
11327
  live = false;
@@ -11030,8 +11344,8 @@ async function planWorkers(worktreeRoot, opts) {
11030
11344
  const wranglerBinFor = (configPath) => {
11031
11345
  let dir = dirname5(configPath);
11032
11346
  for (;; ) {
11033
- const candidate = join17(dir, "node_modules", ".bin", "wrangler");
11034
- if (existsSync14(candidate))
11347
+ const candidate = join18(dir, "node_modules", ".bin", "wrangler");
11348
+ if (existsSync15(candidate))
11035
11349
  return candidate;
11036
11350
  if (dir === worktreeRoot)
11037
11351
  return null;
@@ -11096,11 +11410,11 @@ async function planWorkers(worktreeRoot, opts) {
11096
11410
  }
11097
11411
 
11098
11412
  // packages/engine/src/wrangler/verify.ts
11099
- import { existsSync as existsSync15, readdirSync as readdirSync5 } from "fs";
11413
+ import { existsSync as existsSync16, readdirSync as readdirSync6 } from "fs";
11100
11414
  function namesIn(dir) {
11101
- if (!existsSync15(dir))
11415
+ if (!existsSync16(dir))
11102
11416
  return new Set;
11103
- return new Set(readdirSync5(dir));
11417
+ return new Set(readdirSync6(dir));
11104
11418
  }
11105
11419
  function snapshotGlobalRegistry() {
11106
11420
  return namesIn(globalRegistryDir());
@@ -11125,16 +11439,16 @@ function globalGainWarnings(before, ourNames) {
11125
11439
  // packages/engine/src/tunnel/cloudflared.ts
11126
11440
  import { execFile as execFile8 } from "child_process";
11127
11441
  import { promisify as promisify7 } from "util";
11128
- import { existsSync as existsSync16 } from "fs";
11442
+ import { existsSync as existsSync17 } from "fs";
11129
11443
  import { homedir as homedir4 } from "os";
11130
- import { join as join18 } from "path";
11444
+ import { join as join19 } from "path";
11131
11445
  var pexec7 = promisify7(execFile8);
11132
11446
  var BIN = "cloudflared";
11133
11447
  function cloudflaredHome() {
11134
- return process.env.HESTIA_CLOUDFLARED_HOME ?? join18(homedir4(), ".cloudflared");
11448
+ return process.env.HESTIA_CLOUDFLARED_HOME ?? join19(homedir4(), ".cloudflared");
11135
11449
  }
11136
11450
  function credFilePath(uuid) {
11137
- return process.env.TUNNEL_CRED_FILE ?? join18(cloudflaredHome(), `${uuid}.json`);
11451
+ return process.env.TUNNEL_CRED_FILE ?? join19(cloudflaredHome(), `${uuid}.json`);
11138
11452
  }
11139
11453
  async function run(args, timeoutMs = 30000) {
11140
11454
  try {
@@ -11163,7 +11477,7 @@ async function adoptTunnel(name) {
11163
11477
  throw new HestiaError("tunnel-not-found", `no tunnel named "${name}" on this account \u2014 create it once with ` + `\`cloudflared tunnel create ${name}\``);
11164
11478
  }
11165
11479
  const credFile = credFilePath(entry.id);
11166
- if (!existsSync16(credFile)) {
11480
+ if (!existsSync17(credFile)) {
11167
11481
  throw new HestiaError("tunnel-auth-missing", `credentials for tunnel "${name}" not found at ${credFile} \u2014 ` + `run \`cloudflared tunnel create\` on this machine or copy the JSON`);
11168
11482
  }
11169
11483
  return { uuid: entry.id, credFile, connections: entry.connections.length };
@@ -11171,8 +11485,8 @@ async function adoptTunnel(name) {
11171
11485
 
11172
11486
  // packages/engine/src/tunnel/ingress.ts
11173
11487
  import { createHash as createHash6 } from "crypto";
11174
- import { existsSync as existsSync17, readFileSync as readFileSync15 } from "fs";
11175
- import { join as join19 } from "path";
11488
+ import { existsSync as existsSync18, readFileSync as readFileSync16 } from "fs";
11489
+ import { join as join20 } from "path";
11176
11490
  var LABEL_MAX = 63;
11177
11491
  var HASH_LEN = 6;
11178
11492
  function hostnameFor(tunnelName, branch, service, zone) {
@@ -11192,12 +11506,12 @@ function inferZone(baseRules) {
11192
11506
  return zones.size === 1 ? [...zones][0] : undefined;
11193
11507
  }
11194
11508
  function importBaseRules(uuid, name) {
11195
- const p = join19(cloudflaredHome(), "config.yml");
11196
- if (!existsSync17(p))
11509
+ const p = join20(cloudflaredHome(), "config.yml");
11510
+ if (!existsSync18(p))
11197
11511
  return [];
11198
11512
  let parsed;
11199
11513
  try {
11200
- parsed = $parse(readFileSync15(p, "utf8"));
11514
+ parsed = $parse(readFileSync16(p, "utf8"));
11201
11515
  } catch {
11202
11516
  return [];
11203
11517
  }
@@ -11209,7 +11523,12 @@ function importBaseRules(uuid, name) {
11209
11523
  return parsed.ingress.filter((r) => r.hostname !== undefined || r.path !== undefined);
11210
11524
  }
11211
11525
  function generateMergedConfig(opts) {
11212
- assertDisjoint(opts.baseRules, opts.dynamicRules);
11526
+ assertDisjoint(opts.baseRules, opts.dynamicRules, opts.sharedRules ?? []);
11527
+ const sharedHostnames = [...new Set((opts.sharedRules ?? []).map((s) => s.hostname))];
11528
+ const shared = sharedHostnames.map((hostname) => ({
11529
+ hostname,
11530
+ service: `unix:${publicGatewaySocketPath()}`
11531
+ }));
11213
11532
  const dynamic = opts.dynamicRules.map((d) => ({
11214
11533
  hostname: d.hostname,
11215
11534
  service: `unix:${publicGatewaySocketPath()}`,
@@ -11220,17 +11539,30 @@ function generateMergedConfig(opts) {
11220
11539
  "credentials-file": opts.credFile,
11221
11540
  ingress: [
11222
11541
  ...opts.baseRules,
11542
+ ...shared,
11223
11543
  ...dynamic,
11224
11544
  { service: "http_status:404" }
11225
11545
  ]
11226
11546
  });
11227
11547
  }
11228
- function assertDisjoint(base, dynamic) {
11548
+ function assertDisjoint(base, dynamic, shared) {
11229
11549
  const seen = new Map;
11230
11550
  for (const r of base) {
11231
11551
  if (r.hostname !== undefined)
11232
11552
  seen.set(r.hostname, "the user's static rules");
11233
11553
  }
11554
+ const sharedByHost = new Map;
11555
+ for (const s of shared) {
11556
+ if (!sharedByHost.has(s.hostname))
11557
+ sharedByHost.set(s.hostname, s.name);
11558
+ }
11559
+ for (const [hostname, name] of sharedByHost) {
11560
+ const holder = seen.get(hostname);
11561
+ if (holder !== undefined) {
11562
+ throw new HestiaError("hostname-conflict", `shared hostname ${hostname} ("${name}") is already claimed by ${holder}`);
11563
+ }
11564
+ seen.set(hostname, `shared hostname "${name}"`);
11565
+ }
11234
11566
  for (const d of dynamic) {
11235
11567
  const holder = seen.get(d.hostname);
11236
11568
  if (holder !== undefined) {
@@ -11242,12 +11574,91 @@ function assertDisjoint(base, dynamic) {
11242
11574
 
11243
11575
  // packages/engine/src/tunnel/registry.ts
11244
11576
  import {
11245
- existsSync as existsSync18,
11577
+ existsSync as existsSync19,
11246
11578
  mkdirSync as mkdirSync8,
11247
- readFileSync as readFileSync16,
11248
- readdirSync as readdirSync6
11579
+ readFileSync as readFileSync17,
11580
+ readdirSync as readdirSync7
11249
11581
  } from "fs";
11250
- import { join as join20 } from "path";
11582
+ import { join as join22 } from "path";
11583
+
11584
+ // packages/engine/src/tunnel/orphans.ts
11585
+ import { execFileSync as execFileSync4 } from "child_process";
11586
+ import { join as join21 } from "path";
11587
+ function hestiaTunnelMarker(uuid) {
11588
+ return join21(hestiaHome(), "tunnel", uuid);
11589
+ }
11590
+ function parseLocalHestiaConnectors(psOutput, marker) {
11591
+ const rows = [];
11592
+ for (const line of psOutput.split(`
11593
+ `)) {
11594
+ const trimmed = line.trim();
11595
+ if (trimmed === "")
11596
+ continue;
11597
+ const m = trimmed.match(/^(\d+)\s+(\d+)\s+(.*)$/);
11598
+ if (m === null)
11599
+ continue;
11600
+ const command = m[3];
11601
+ if (!command.includes(marker))
11602
+ continue;
11603
+ if (!/(^|[\/\s])cloudflared(\s|$)/.test(command))
11604
+ continue;
11605
+ rows.push({ pid: Number(m[1]), pgid: Number(m[2]), command });
11606
+ }
11607
+ return rows;
11608
+ }
11609
+ function listLocalHestiaConnectors(uuid) {
11610
+ let out;
11611
+ try {
11612
+ out = execFileSync4("ps", ["-Ao", "pid=,pgid=,command="], {
11613
+ encoding: "utf8",
11614
+ maxBuffer: 16 * 1024 * 1024,
11615
+ env: { ...process.env, LC_ALL: "C", LANG: "C" }
11616
+ });
11617
+ } catch {
11618
+ return [];
11619
+ }
11620
+ return parseLocalHestiaConnectors(out, hestiaTunnelMarker(uuid));
11621
+ }
11622
+ var REAP_GRACE_MS = 5000;
11623
+ var REAP_POLL_MS = 100;
11624
+ async function reapOrphanConnectors(uuid, keep) {
11625
+ const procs = listLocalHestiaConnectors(uuid).filter((p) => {
11626
+ if (keep === undefined)
11627
+ return true;
11628
+ return p.pgid !== keep.pgid && p.pid !== keep.pid;
11629
+ });
11630
+ if (procs.length === 0)
11631
+ return { reapedGroups: 0, pids: [] };
11632
+ const groups = new Set(procs.map((p) => p.pgid));
11633
+ const pids = procs.map((p) => p.pid);
11634
+ for (const pgid of groups) {
11635
+ try {
11636
+ process.kill(-pgid, "SIGTERM");
11637
+ } catch {}
11638
+ }
11639
+ const deadline = Date.now() + REAP_GRACE_MS;
11640
+ while (Date.now() < deadline) {
11641
+ const still = listLocalHestiaConnectors(uuid).filter((p) => {
11642
+ if (keep === undefined)
11643
+ return true;
11644
+ return p.pgid !== keep.pgid && p.pid !== keep.pid;
11645
+ });
11646
+ if (still.length === 0)
11647
+ return { reapedGroups: groups.size, pids };
11648
+ await new Promise((r) => setTimeout(r, REAP_POLL_MS));
11649
+ }
11650
+ for (const p of listLocalHestiaConnectors(uuid)) {
11651
+ if (keep !== undefined && (p.pgid === keep.pgid || p.pid === keep.pid))
11652
+ continue;
11653
+ try {
11654
+ process.kill(-p.pgid, "SIGKILL");
11655
+ } catch {}
11656
+ try {
11657
+ process.kill(p.pid, "SIGKILL");
11658
+ } catch {}
11659
+ }
11660
+ return { reapedGroups: groups.size, pids };
11661
+ }
11251
11662
 
11252
11663
  // packages/engine/src/tunnel/verify.ts
11253
11664
  var POLL_MS4 = 300;
@@ -11295,24 +11706,24 @@ async function quickTunnelUrl(metricsPort, timeoutMs) {
11295
11706
  var CONNECTOR = "connector";
11296
11707
  var READY_TIMEOUT_MS2 = 30000;
11297
11708
  function tunnelDir(uuid) {
11298
- return join20(hestiaHome(), "tunnel", uuid);
11709
+ return join22(hestiaHome(), "tunnel", uuid);
11299
11710
  }
11300
11711
  function configPath(uuid) {
11301
- return join20(tunnelDir(uuid), "config.yml");
11712
+ return join22(tunnelDir(uuid), "config.yml");
11302
11713
  }
11303
11714
  function adoptedMarker(uuid) {
11304
- return join20(tunnelDir(uuid), "adopted.json");
11715
+ return join22(tunnelDir(uuid), "adopted.json");
11305
11716
  }
11306
11717
  function isAdopted(uuid) {
11307
- return existsSync18(adoptedMarker(uuid));
11718
+ return existsSync19(adoptedMarker(uuid));
11308
11719
  }
11309
11720
  function readAdopted(uuid) {
11310
11721
  const p = adoptedMarker(uuid);
11311
- if (!existsSync18(p))
11722
+ if (!existsSync19(p))
11312
11723
  return null;
11313
11724
  let marker;
11314
11725
  try {
11315
- marker = JSON.parse(readFileSync16(p, "utf8"));
11726
+ marker = JSON.parse(readFileSync17(p, "utf8"));
11316
11727
  } catch {
11317
11728
  marker = {};
11318
11729
  }
@@ -11320,14 +11731,14 @@ function readAdopted(uuid) {
11320
11731
  return { uuid, name: marker.name, credFile: marker.credFile, reconstructed: false };
11321
11732
  }
11322
11733
  let name;
11323
- const stacksDir = join20(hestiaHome(), "stacks");
11324
- if (existsSync18(stacksDir)) {
11325
- for (const project of readdirSync6(stacksDir)) {
11326
- const sp = join20(stacksDir, project, "stack.json");
11327
- if (!existsSync18(sp))
11734
+ const stacksDir = join22(hestiaHome(), "stacks");
11735
+ if (existsSync19(stacksDir)) {
11736
+ for (const project of readdirSync7(stacksDir)) {
11737
+ const sp = join22(stacksDir, project, "stack.json");
11738
+ if (!existsSync19(sp))
11328
11739
  continue;
11329
11740
  try {
11330
- const record = parseStackRecord(readFileSync16(sp, "utf8"), sp);
11741
+ const record = parseStackRecord(readFileSync17(sp, "utf8"), sp);
11331
11742
  if (record.tunnel?.uuid === uuid) {
11332
11743
  name = record.tunnel.name;
11333
11744
  break;
@@ -11343,24 +11754,24 @@ function readAdopted(uuid) {
11343
11754
  };
11344
11755
  }
11345
11756
  function listAdopted() {
11346
- const root = join20(hestiaHome(), "tunnel");
11347
- if (!existsSync18(root))
11757
+ const root = join22(hestiaHome(), "tunnel");
11758
+ if (!existsSync19(root))
11348
11759
  return [];
11349
- return readdirSync6(root).filter((uuid) => isAdopted(uuid));
11760
+ return readdirSync7(root).filter((uuid) => isAdopted(uuid));
11350
11761
  }
11351
11762
  function collectDynamicRules(uuid) {
11352
- const stacksDir = join20(hestiaHome(), "stacks");
11763
+ const stacksDir = join22(hestiaHome(), "stacks");
11353
11764
  const rules = [];
11354
11765
  const dropped = [];
11355
- if (!existsSync18(stacksDir))
11766
+ if (!existsSync19(stacksDir))
11356
11767
  return { rules, dropped };
11357
- for (const project of readdirSync6(stacksDir)) {
11358
- const p = join20(stacksDir, project, "stack.json");
11359
- if (!existsSync18(p))
11768
+ for (const project of readdirSync7(stacksDir)) {
11769
+ const p = join22(stacksDir, project, "stack.json");
11770
+ if (!existsSync19(p))
11360
11771
  continue;
11361
11772
  let record;
11362
11773
  try {
11363
- record = parseStackRecord(readFileSync16(p, "utf8"), p);
11774
+ record = parseStackRecord(readFileSync17(p, "utf8"), p);
11364
11775
  } catch {
11365
11776
  continue;
11366
11777
  }
@@ -11389,20 +11800,26 @@ async function reconcileTunnel(ref, opts) {
11389
11800
  for (const d of dropped) {
11390
11801
  warnings.push(`exposure ${d.hostname} dropped \u2014 service "${d.service}" of ` + `${d.project} is not running (hostname now 404s)`);
11391
11802
  }
11803
+ const sharedRules = listSharedHostnames().filter((record) => record.tunnelUuid === ref.uuid).map((record) => ({ name: record.name, hostname: record.hostname }));
11392
11804
  const cfgPath = configPath(ref.uuid);
11393
11805
  const nextConfig = generateMergedConfig({
11394
11806
  uuid: ref.uuid,
11395
11807
  credFile: ref.credFile,
11396
11808
  baseRules,
11397
- dynamicRules: rules
11809
+ dynamicRules: rules,
11810
+ sharedRules
11398
11811
  });
11399
11812
  const pf = readPidfile(dir, CONNECTOR);
11400
11813
  const live = pf !== null && isLive(pf);
11401
- const currentConfig = existsSync18(cfgPath) ? readFileSync16(cfgPath, "utf8") : null;
11814
+ const { reapedGroups } = await reapOrphanConnectors(ref.uuid, live ? pf : undefined);
11815
+ if (reapedGroups > 0) {
11816
+ warnings.push(`reaped ${reapedGroups} orphan hestia connector process group(s) for this tunnel`);
11817
+ }
11818
+ const currentConfig = existsSync19(cfgPath) ? readFileSync17(cfgPath, "utf8") : null;
11402
11819
  if (live && currentConfig === nextConfig) {
11403
11820
  return { restarted: false, metricsPort: pf.port, ready: true, warnings };
11404
11821
  }
11405
- if (baseRules.length === 0 && rules.length === 0) {
11822
+ if (baseRules.length === 0 && rules.length === 0 && sharedRules.length === 0) {
11406
11823
  if (pf !== null) {
11407
11824
  await stopProcTree(pf);
11408
11825
  removePidfile(dir, CONNECTOR);
@@ -11887,11 +12304,225 @@ async function* streamStackLogs(record, options = {}) {
11887
12304
  await Promise.allSettled(pumps);
11888
12305
  }
11889
12306
  }
12307
+ // packages/engine/src/daemon/shared-arbiter.ts
12308
+ class SharedArbiter {
12309
+ onChange;
12310
+ #mutex = Promise.resolve();
12311
+ #polls = new Map;
12312
+ constructor(onChange) {
12313
+ this.onChange = onChange;
12314
+ }
12315
+ #locked(fn) {
12316
+ const next = this.#mutex.then(fn, fn);
12317
+ this.#mutex = next.catch(() => {});
12318
+ return next;
12319
+ }
12320
+ async#notify() {
12321
+ try {
12322
+ await this.onChange?.();
12323
+ } catch {}
12324
+ }
12325
+ #result(record, granted) {
12326
+ return { granted, holder: record.holder, queued: [...record.queue ?? []] };
12327
+ }
12328
+ #wake(name, project, result) {
12329
+ const key = `${name}\x00${project}`;
12330
+ for (const poll of this.#polls.get(key) ?? []) {
12331
+ clearTimeout(poll.timer);
12332
+ poll.resolve(result);
12333
+ }
12334
+ this.#polls.delete(key);
12335
+ }
12336
+ #grantNext(record) {
12337
+ const queue = [...record.queue ?? []];
12338
+ for (;; ) {
12339
+ const head = queue.shift();
12340
+ if (head === undefined) {
12341
+ const next = { ...record, queue: [] };
12342
+ delete next.holder;
12343
+ return next;
12344
+ }
12345
+ if (readMirrorState(head.project) === null)
12346
+ continue;
12347
+ return {
12348
+ ...record,
12349
+ holder: {
12350
+ project: head.project,
12351
+ worktree: head.worktree,
12352
+ service: record.service,
12353
+ at: new Date().toISOString()
12354
+ },
12355
+ queue
12356
+ };
12357
+ }
12358
+ }
12359
+ async request(name, requester, waitMs) {
12360
+ const outcome = await this.#locked(async () => {
12361
+ let granted = false;
12362
+ const updated = await updateSharedHostname(name, (record) => {
12363
+ if (record.holder?.project === requester.project) {
12364
+ granted = true;
12365
+ return {
12366
+ ...record,
12367
+ holder: { ...record.holder, worktree: requester.worktree },
12368
+ queue: (record.queue ?? []).filter((waiter) => waiter.project !== requester.project)
12369
+ };
12370
+ }
12371
+ if (record.holder === undefined) {
12372
+ granted = true;
12373
+ return {
12374
+ ...record,
12375
+ holder: {
12376
+ project: requester.project,
12377
+ worktree: requester.worktree,
12378
+ service: record.service,
12379
+ at: new Date().toISOString()
12380
+ },
12381
+ queue: (record.queue ?? []).filter((waiter) => waiter.project !== requester.project)
12382
+ };
12383
+ }
12384
+ const queue = [...record.queue ?? []];
12385
+ const existing = queue.find((waiter) => waiter.project === requester.project);
12386
+ if (existing !== undefined)
12387
+ existing.worktree = requester.worktree;
12388
+ else
12389
+ queue.push({ project: requester.project, worktree: requester.worktree, at: new Date().toISOString() });
12390
+ return { ...record, queue };
12391
+ });
12392
+ if (updated === null) {
12393
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12394
+ }
12395
+ return { granted, record: updated };
12396
+ });
12397
+ if (outcome.granted) {
12398
+ await this.#notify();
12399
+ return this.#result(outcome.record, true);
12400
+ }
12401
+ if (waitMs <= 0)
12402
+ return this.#result(outcome.record, false);
12403
+ return new Promise((resolve2) => {
12404
+ const key = `${name}\x00${requester.project}`;
12405
+ const polls = this.#polls.get(key) ?? [];
12406
+ const poll = {
12407
+ resolve: resolve2,
12408
+ timer: setTimeout(() => {
12409
+ const remaining = (this.#polls.get(key) ?? []).filter((candidate) => candidate !== poll);
12410
+ if (remaining.length === 0)
12411
+ this.#polls.delete(key);
12412
+ else
12413
+ this.#polls.set(key, remaining);
12414
+ const record = readSharedHostname(name);
12415
+ resolve2(record === null ? { granted: false, queued: [] } : this.#result(record, record.holder?.project === requester.project));
12416
+ }, waitMs)
12417
+ };
12418
+ polls.push(poll);
12419
+ this.#polls.set(key, polls);
12420
+ });
12421
+ }
12422
+ async cancel(name, project) {
12423
+ const record = await this.#locked(() => updateSharedHostname(name, (current) => ({
12424
+ ...current,
12425
+ queue: (current.queue ?? []).filter((waiter) => waiter.project !== project)
12426
+ })));
12427
+ if (record === null)
12428
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12429
+ this.#wake(name, project, this.#result(record, false));
12430
+ return this.#result(record, false);
12431
+ }
12432
+ #assertHolder(record, project, verb) {
12433
+ if (record.holder?.project !== project) {
12434
+ throw new HestiaError("shared-not-holder", record.holder === undefined ? `cannot ${verb} "${record.name}" \u2014 it is unclaimed` : `cannot ${verb} "${record.name}" \u2014 it is held by ${record.holder.project}, not ${project}`);
12435
+ }
12436
+ }
12437
+ async allow(name, callerProject) {
12438
+ const record = await this.#transfer(name, callerProject, "allow");
12439
+ return this.#result(record, false);
12440
+ }
12441
+ async deny(name, callerProject) {
12442
+ const record = await this.#locked(async () => {
12443
+ const current = readSharedHostname(name);
12444
+ if (current === null)
12445
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12446
+ this.#assertHolder(current, callerProject, "deny");
12447
+ const updated = await updateSharedHostname(name, (candidate) => {
12448
+ const queue = [...candidate.queue ?? []];
12449
+ if (queue.length > 0)
12450
+ queue[0] = { ...queue[0], denied: true };
12451
+ return { ...candidate, queue };
12452
+ });
12453
+ return updated;
12454
+ });
12455
+ return this.#result(record, false);
12456
+ }
12457
+ async release(name, callerProject) {
12458
+ const record = await this.#transfer(name, callerProject, "release");
12459
+ return this.#result(record, false);
12460
+ }
12461
+ async#transfer(name, callerProject, verb) {
12462
+ const record = await this.#locked(async () => {
12463
+ const current = readSharedHostname(name);
12464
+ if (current === null)
12465
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12466
+ this.#assertHolder(current, callerProject, verb);
12467
+ return await updateSharedHostname(name, (candidate) => this.#grantNext(candidate));
12468
+ });
12469
+ if (record.holder !== undefined) {
12470
+ this.#wake(record.name, record.holder.project, this.#result(record, true));
12471
+ }
12472
+ await this.#notify();
12473
+ return record;
12474
+ }
12475
+ async releaseProject(project, service) {
12476
+ const held = listSharedHostnames().filter((record) => record.holder?.project === project && (service === undefined || record.service === service));
12477
+ let changed = false;
12478
+ for (const record of held) {
12479
+ const updated = await this.#locked(async () => {
12480
+ const current = readSharedHostname(record.name);
12481
+ if (current?.holder?.project !== project)
12482
+ return null;
12483
+ return await updateSharedHostname(record.name, (candidate) => this.#grantNext(candidate));
12484
+ });
12485
+ if (updated === null || updated === undefined)
12486
+ continue;
12487
+ changed = true;
12488
+ if (updated.holder !== undefined) {
12489
+ this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
12490
+ }
12491
+ }
12492
+ if (changed)
12493
+ await this.#notify();
12494
+ }
12495
+ async sweep(occupied) {
12496
+ let changed = false;
12497
+ for (const record of listSharedHostnames()) {
12498
+ const holderDead = record.holder !== undefined && !occupied.has(record.holder.project) && readMirrorState(record.holder.project) === null;
12499
+ const deadWaiters = (record.queue ?? []).filter((waiter) => readMirrorState(waiter.project) === null);
12500
+ if (!holderDead && deadWaiters.length === 0)
12501
+ continue;
12502
+ const updated = await this.#locked(() => updateSharedHostname(record.name, (candidate) => {
12503
+ const pruned = {
12504
+ ...candidate,
12505
+ queue: (candidate.queue ?? []).filter((waiter) => readMirrorState(waiter.project) !== null)
12506
+ };
12507
+ const currentHolderDead = pruned.holder !== undefined && !occupied.has(pruned.holder.project) && readMirrorState(pruned.holder.project) === null;
12508
+ return currentHolderDead ? this.#grantNext(pruned) : pruned;
12509
+ }));
12510
+ if (updated === null)
12511
+ continue;
12512
+ changed = true;
12513
+ if (updated.holder !== undefined && updated.holder.project !== record.holder?.project) {
12514
+ this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
12515
+ }
12516
+ }
12517
+ if (changed)
12518
+ await this.#notify();
12519
+ }
12520
+ }
11890
12521
  // packages/engine/src/daemon/fleet-monitor.ts
11891
12522
  import { execFile as execFile9 } from "child_process";
11892
12523
  import { promisify as promisify8 } from "util";
11893
- import { existsSync as existsSync19, readFileSync as readFileSync17, readdirSync as readdirSync7 } from "fs";
11894
- import { join as join21 } from "path";
12524
+ import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync8 } from "fs";
12525
+ import { join as join23 } from "path";
11895
12526
  var pexec8 = promisify8(execFile9);
11896
12527
  var DEFAULT_REFRESH_MS = 1000;
11897
12528
  var DEFAULT_HEARTBEAT_MS = 15000;
@@ -11938,13 +12569,13 @@ class LatestFleetChannel {
11938
12569
  function readManagedMirrors() {
11939
12570
  const records = [];
11940
12571
  const warnings = [];
11941
- const stacksDirectory = join21(hestiaHome(), "stacks");
11942
- if (!existsSync19(stacksDirectory))
12572
+ const stacksDirectory = join23(hestiaHome(), "stacks");
12573
+ if (!existsSync20(stacksDirectory))
11943
12574
  return { records, warnings };
11944
- for (const project of readdirSync7(stacksDirectory).sort()) {
11945
- const path = join21(stacksDirectory, project, "stack.json");
12575
+ for (const project of readdirSync8(stacksDirectory).sort()) {
12576
+ const path = join23(stacksDirectory, project, "stack.json");
11946
12577
  try {
11947
- const source = readFileSync17(path);
12578
+ const source = readFileSync18(path);
11948
12579
  if (source.byteLength > MAX_MIRROR_BYTES2) {
11949
12580
  warnings.push(`Fleet mirror too large for ${project}`);
11950
12581
  continue;
@@ -11964,7 +12595,7 @@ function readManagedMirrors() {
11964
12595
  async function attributeLegacyRepoId(record) {
11965
12596
  if (record.repoId !== undefined)
11966
12597
  return record.repoId;
11967
- if (!existsSync19(record.worktree))
12598
+ if (!existsSync20(record.worktree))
11968
12599
  return null;
11969
12600
  const info = await getRepoInfo(record.worktree);
11970
12601
  if (projectName(info.repoId, info.repo, info.branch, info.worktreeRoot) !== record.project)
@@ -12070,6 +12701,25 @@ function reservationIdentity(reservation, records) {
12070
12701
  worktree: record.worktree
12071
12702
  };
12072
12703
  }
12704
+ function collectFleetShared(repoProjects) {
12705
+ return listSharedHostnames().map((record) => ({
12706
+ name: record.name,
12707
+ hostname: record.hostname,
12708
+ ...record.path === undefined ? {} : { path: record.path },
12709
+ url: `https://${record.hostname}${record.path ?? ""}`,
12710
+ holder: record.holder === undefined ? undefined : {
12711
+ project: record.holder.project,
12712
+ worktree: record.holder.worktree,
12713
+ mine: repoProjects.has(record.holder.project)
12714
+ },
12715
+ queue: (record.queue ?? []).map((waiter) => ({
12716
+ project: waiter.project,
12717
+ worktree: waiter.worktree,
12718
+ ...waiter.denied === true ? { denied: true } : {},
12719
+ mine: repoProjects.has(waiter.project)
12720
+ }))
12721
+ })).sort((left, right) => left.name.localeCompare(right.name));
12722
+ }
12073
12723
  async function collectFleetSnapshot(repoId, admission) {
12074
12724
  const mirrorResult = readManagedMirrors();
12075
12725
  const machineConfig = readHestiaMachineConfig();
@@ -12165,6 +12815,7 @@ async function collectFleetSnapshot(repoId, admission) {
12165
12815
  const allRecordedProjects = new Set(attributed.map((record) => record.project));
12166
12816
  const unbackedReservations = reservations.filter((reservation) => !allRecordedProjects.has(reservation.project));
12167
12817
  const { maxStacks, warnings: capWarnings } = resolveMaxStacks();
12818
+ const repoProjects = new Set(attributed.filter((record) => record.repoId === repoId).map((record) => record.project));
12168
12819
  return {
12169
12820
  repoId,
12170
12821
  observedAt: new Date().toISOString(),
@@ -12175,6 +12826,7 @@ async function collectFleetSnapshot(repoId, admission) {
12175
12826
  queued: queued.length
12176
12827
  },
12177
12828
  stacks: stackViews,
12829
+ shared: collectFleetShared(repoProjects),
12178
12830
  warnings: [...capWarnings, ...warnings].sort()
12179
12831
  };
12180
12832
  }
@@ -12183,6 +12835,7 @@ function semanticFleetSnapshot(snapshot) {
12183
12835
  repoId: snapshot.repoId,
12184
12836
  capacity: snapshot.capacity,
12185
12837
  stacks: snapshot.stacks,
12838
+ shared: snapshot.shared,
12186
12839
  warnings: snapshot.warnings
12187
12840
  });
12188
12841
  }
@@ -12398,7 +13051,7 @@ async function assertHestiaStateIgnored(worktreeRoot) {
12398
13051
  try {
12399
13052
  await pexec9("git", ["-C", worktreeRoot, "check-ignore", "-q", ".hestia/"], { timeout: 5000 });
12400
13053
  } catch {
12401
- throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${join22(worktreeRoot, ".gitignore")}`, { remedy: `.hestia/`, path: join22(worktreeRoot, ".gitignore") });
13054
+ throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${join24(worktreeRoot, ".gitignore")}`, { remedy: `.hestia/`, path: join24(worktreeRoot, ".gitignore") });
12402
13055
  }
12403
13056
  }
12404
13057
  async function prepareCompose(worktreeRoot, project, repo, branch, opts, configuredBaseFile) {
@@ -12415,7 +13068,7 @@ async function prepareCompose(worktreeRoot, project, repo, branch, opts, configu
12415
13068
  services
12416
13069
  });
12417
13070
  ensureDir(hestiaDir(worktreeRoot));
12418
- const overridePath = join22(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13071
+ const overridePath = join24(hestiaDir(worktreeRoot), OVERRIDE_FILE);
12419
13072
  writeAtomicTextFile(overridePath, yaml);
12420
13073
  return {
12421
13074
  ctx: {
@@ -12448,7 +13101,7 @@ function writeDockerfileComposeModel(worktreeRoot, workloads, conventionalCompos
12448
13101
  ...conventionalComposeFile === undefined ? {} : { include: [{ path: conventionalComposeFile }] },
12449
13102
  services
12450
13103
  };
12451
- const path = join22(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
13104
+ const path = join24(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
12452
13105
  writeAtomicTextFile(path, $stringify(model));
12453
13106
  return path;
12454
13107
  }
@@ -12468,7 +13121,7 @@ function freshRecord(project, repoId, repo, branch, worktree) {
12468
13121
  };
12469
13122
  }
12470
13123
  function assertCurrentStackIdentity(record, current) {
12471
- const path = join22(hestiaDir(current.worktreeRoot), "stack.json");
13124
+ const path = join24(hestiaDir(current.worktreeRoot), "stack.json");
12472
13125
  assertMutableStackRecord(record, path);
12473
13126
  const matches = record.repoId === current.repoId && record.repo === current.repo && record.branch === current.branch && resolve2(record.worktree) === resolve2(current.worktreeRoot) && record.project === projectName(current.repoId, current.repo, current.branch, current.worktreeRoot);
12474
13127
  if (!matches) {
@@ -12603,6 +13256,27 @@ function applyLocalRouteProjection(record) {
12603
13256
  record.env[directUrlKey(endpoint.name)] = endpoint.url;
12604
13257
  record.env[localUrlKey(endpoint.name)] = endpoint.localUrl;
12605
13258
  }
13259
+ syncSharedProjection(record);
13260
+ }
13261
+ function syncSharedProjection(record) {
13262
+ for (const shared of listSharedHostnames()) {
13263
+ const url = `https://${shared.hostname}${shared.path ?? ""}`;
13264
+ if (shared.holder?.project === record.project) {
13265
+ const endpoint = record.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
13266
+ if (endpoint !== undefined) {
13267
+ endpoint.publicUrl = url;
13268
+ record.env[urlKey(shared.service)] = url;
13269
+ }
13270
+ continue;
13271
+ }
13272
+ for (const endpoint of record.endpoints) {
13273
+ if (endpoint.publicUrl === url)
13274
+ delete endpoint.publicUrl;
13275
+ }
13276
+ if (record.env[urlKey(shared.service)] === url) {
13277
+ delete record.env[urlKey(shared.service)];
13278
+ }
13279
+ }
12606
13280
  }
12607
13281
  function syncExposures(record) {
12608
13282
  const t = record.tunnel;
@@ -12627,7 +13301,7 @@ function matchesExpectedStack(record, expected) {
12627
13301
  return record !== null && (record.repoId === undefined || record.repoId === expected.repoId) && record.worktree === expected.worktree && record.createdAt === expected.createdAt;
12628
13302
  }
12629
13303
  function projectMutationRoot(project) {
12630
- return join22(hestiaHome(), "project-locks", project);
13304
+ return join24(hestiaHome(), "project-locks", project);
12631
13305
  }
12632
13306
  async function assertNamedTunnelDns(hostname, tunnelUuid) {
12633
13307
  const expected = `${tunnelUuid}.cfargotunnel.com`;
@@ -13033,6 +13707,7 @@ class ComposeEngine {
13033
13707
  const { repo, repoId, branch, worktreeRoot } = currentIdentity;
13034
13708
  let tunnel;
13035
13709
  let tunnelDirty = false;
13710
+ const stoppedAliases = [];
13036
13711
  const project = readState(worktreeRoot)?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13037
13712
  await withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
13038
13713
  const record = readState(worktreeRoot);
@@ -13041,6 +13716,11 @@ class ComposeEngine {
13041
13716
  if (record.services.some((service) => service.name === name && service.backend === "docker")) {
13042
13717
  throw new HestiaError("backend-not-stoppable", `Docker workload ${name} cannot be stopped individually; use hestia down`);
13043
13718
  }
13719
+ for (const endpoint of record.endpoints) {
13720
+ if ((endpoint.workload ?? endpoint.name) === name) {
13721
+ stoppedAliases.push(endpoint.alias ?? endpoint.name);
13722
+ }
13723
+ }
13044
13724
  }
13045
13725
  const pf = readPidfile(worktreeRoot, name);
13046
13726
  if (pf !== null) {
@@ -13086,6 +13766,9 @@ class ComposeEngine {
13086
13766
  }
13087
13767
  }
13088
13768
  }));
13769
+ for (const alias of new Set(stoppedAliases)) {
13770
+ await this.#releaseSharedHoldings(project, alias);
13771
+ }
13089
13772
  if (tunnelDirty)
13090
13773
  await this.#reconcileAdopted(tunnel);
13091
13774
  await this.#refreshLocalRoutes();
@@ -13221,12 +13904,12 @@ class ComposeEngine {
13221
13904
  await stopProcTree(pf);
13222
13905
  removePidfile(worktreeRoot, pf.name);
13223
13906
  }
13224
- rmSync9(privateRegistryDir(worktreeRoot), { recursive: true, force: true });
13907
+ rmSync10(privateRegistryDir(worktreeRoot), { recursive: true, force: true });
13225
13908
  const composeFile = record?.composeFile ?? tryLoadConfig(worktreeRoot)?.composeFile;
13226
13909
  if (composeFile !== undefined) {
13227
13910
  const project2 = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13228
- const overrideFile = record?.overrideFile ?? join22(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13229
- if (existsSync20(overrideFile)) {
13911
+ const overrideFile = record?.overrideFile ?? join24(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13912
+ if (existsSync21(overrideFile)) {
13230
13913
  await composeDown({ project: project2, baseFile: composeFile, overrideFile, cwd: worktreeRoot }, opts?.destroy ?? false);
13231
13914
  } else if (record?.composeFile !== undefined) {
13232
13915
  const rest = ["compose", "-p", project2, "down", "--remove-orphans"];
@@ -13238,6 +13921,7 @@ class ComposeEngine {
13238
13921
  const project = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13239
13922
  clearState(worktreeRoot, project);
13240
13923
  await this.#releaseAdmission(project);
13924
+ await this.#releaseSharedHoldings(project);
13241
13925
  }));
13242
13926
  await this.#reconcileAdopted(tunnel);
13243
13927
  await this.#refreshLocalRoutes();
@@ -13247,6 +13931,11 @@ class ComposeEngine {
13247
13931
  if (j !== null)
13248
13932
  await releaseSlot(j.port, project);
13249
13933
  }
13934
+ async#releaseSharedHoldings(project, service) {
13935
+ const j = readDaemonJson();
13936
+ if (j !== null)
13937
+ await releaseSharedForProject(j.port, project, service);
13938
+ }
13250
13939
  async downProject(project, opts) {
13251
13940
  if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(project)) {
13252
13941
  throw new HestiaError("usage", `invalid project name ${JSON.stringify(project)}`);
@@ -13289,9 +13978,9 @@ class ComposeEngine {
13289
13978
  throw new HestiaError("compose-failed", `docker compose -p ${project} down failed: ${err.message}`);
13290
13979
  }
13291
13980
  }
13292
- rmSync9(mirrorDir(project), { recursive: true, force: true });
13981
+ rmSync10(mirrorDir(project), { recursive: true, force: true });
13293
13982
  };
13294
- if (record !== null && existsSync20(record.worktree)) {
13983
+ if (record !== null && existsSync21(record.worktree)) {
13295
13984
  const info = await getRepoInfo(record.worktree);
13296
13985
  const identityMatches = resolve2(info.worktreeRoot) === resolve2(record.worktree) && (record.repoId === undefined ? record.repo === info.repo : record.repoId === info.repoId);
13297
13986
  const localRecord = readState(record.worktree);
@@ -13312,6 +14001,7 @@ class ComposeEngine {
13312
14001
  return;
13313
14002
  }
13314
14003
  await this.#releaseAdmission(project);
14004
+ await this.#releaseSharedHoldings(project);
13315
14005
  await this.#reconcileAdopted(record.tunnel);
13316
14006
  await this.#refreshLocalRoutes();
13317
14007
  return;
@@ -13320,6 +14010,7 @@ class ComposeEngine {
13320
14010
  const projectLockRoot = projectMutationRoot(project);
13321
14011
  await withLock(projectLockRoot, teardownFromMirror);
13322
14012
  await this.#releaseAdmission(project);
14013
+ await this.#releaseSharedHoldings(project);
13323
14014
  await this.#reconcileAdopted(record?.tunnel);
13324
14015
  await this.#refreshLocalRoutes();
13325
14016
  }
@@ -13338,11 +14029,100 @@ class ComposeEngine {
13338
14029
  }
13339
14030
  await ensureDaemon();
13340
14031
  await this.#refreshLocalRoutes(true);
14032
+ if (opts?.shared !== undefined) {
14033
+ if (tunnelName === undefined) {
14034
+ throw new HestiaError("shared-requires-named-tunnel", "shared hostnames need an adopted named tunnel (stable DNS is the point) \u2014 pass --tunnel <name>");
14035
+ }
14036
+ return this.#exposeShared(worktreeRoot, services, tunnelName, opts);
14037
+ }
13341
14038
  if (tunnelName === undefined) {
13342
14039
  return this.#exposeQuick(worktreeRoot, services, opts);
13343
14040
  }
13344
14041
  return this.#exposeNamed(worktreeRoot, services, tunnelName, opts);
13345
14042
  }
14043
+ async#exposeShared(worktreeRoot, services, tunnelName, opts) {
14044
+ if (services.length !== 1) {
14045
+ throw new HestiaError("usage", "--shared declares exactly one service per shared hostname");
14046
+ }
14047
+ const name = opts.shared;
14048
+ assertSharedName(name);
14049
+ const project = readState(worktreeRoot)?.project;
14050
+ if (project === undefined) {
14051
+ throw new HestiaError("service-not-found", "no stack in this worktree \u2014 `hestia up`/`run` something first");
14052
+ }
14053
+ const adopted = await adoptTunnel(tunnelName);
14054
+ if (adopted.connections > 0 && !isAdopted(adopted.uuid) && !opts?.force) {
14055
+ throw new HestiaError("tunnel-busy", `tunnel "${tunnelName}" already has ${adopted.connections} live edge ` + `connection(s) from a foreign connector \u2014 stop the other cloudflared first (hestia's ` + `connector serves your static hostnames too), or pass --force to ` + `accept nondeterministic routing`);
14056
+ }
14057
+ const preflightRecord = readState(worktreeRoot);
14058
+ if (preflightRecord === null) {
14059
+ throw new HestiaError("service-not-found", "stack disappeared while preparing the shared hostname");
14060
+ }
14061
+ const baseRules = importBaseRules(adopted.uuid, tunnelName);
14062
+ const zoneHint = opts?.zone ?? preflightRecord.tunnel?.zone ?? inferZone(baseRules);
14063
+ let hostname;
14064
+ let zone;
14065
+ if (opts?.hostname !== undefined) {
14066
+ hostname = opts.hostname.toLowerCase();
14067
+ assertSharedHostnameFqdn(hostname);
14068
+ zone = zoneOf(hostname) ?? zoneHint ?? hostname;
14069
+ } else {
14070
+ if (zoneHint === undefined) {
14071
+ throw new HestiaError("usage", "cannot infer a zone from the tunnel's existing rules \u2014 pass --zone or an explicit --hostname");
14072
+ }
14073
+ zone = zoneHint;
14074
+ hostname = `${name}.${zone}`;
14075
+ }
14076
+ const path = normalizeSharedPath(opts?.path);
14077
+ const selection = resolveEndpointSelection(preflightRecord, services[0]);
14078
+ if (selection.endpoint.kind !== undefined && selection.endpoint.kind !== "http") {
14079
+ throw new HestiaError("usage", `public tunnels require an HTTP endpoint; ${services[0]} is ${selection.endpoint.kind}`);
14080
+ }
14081
+ const alias = selection.endpoint.alias ?? selection.endpoint.name;
14082
+ if (baseRules.some((rule) => rule.hostname === hostname)) {
14083
+ throw new HestiaError("hostname-conflict", `shared hostname ${hostname} is already claimed by the user's static rules`);
14084
+ }
14085
+ const dynamicConflict = collectDynamicRules(adopted.uuid).rules.find((rule) => rule.hostname === hostname);
14086
+ if (dynamicConflict !== undefined) {
14087
+ throw new HestiaError("hostname-conflict", `shared hostname ${hostname} is already claimed by project ${dynamicConflict.project}`);
14088
+ }
14089
+ await assertNamedTunnelDns(hostname, adopted.uuid);
14090
+ await declareSharedHostname({
14091
+ name,
14092
+ hostname,
14093
+ path,
14094
+ tunnelUuid: adopted.uuid,
14095
+ zone,
14096
+ service: alias
14097
+ });
14098
+ const handle = await ensureDaemon();
14099
+ const claim = await sharedVerb(handle.port, "claim", {
14100
+ name,
14101
+ project,
14102
+ worktree: worktreeRoot,
14103
+ waitMs: 0
14104
+ });
14105
+ if (!claim.granted) {
14106
+ throw new HestiaError("shared-held", `shared hostname "${name}" is held by ${claim.holder?.project ?? "another stack"} ` + `(${claim.queued.length} queued) \u2014 \`hestia claim ${name} --wait\` to queue for it`);
14107
+ }
14108
+ const outcome = await reconcileTunnel({ name: tunnelName, uuid: adopted.uuid, credFile: adopted.credFile }, { force: opts?.force, readyTimeoutMs: opts?.readyTimeoutMs });
14109
+ for (const w of outcome.warnings)
14110
+ process.stderr.write(`warning: ${w}
14111
+ `);
14112
+ const final = await withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
14113
+ const record = readState(worktreeRoot);
14114
+ if (record === null) {
14115
+ throw new HestiaError("service-not-found", "stack disappeared while exposing (concurrent down?)");
14116
+ }
14117
+ applyLocalRouteProjection(record);
14118
+ writeState(worktreeRoot, record);
14119
+ return record;
14120
+ }));
14121
+ if (outcome.error !== undefined)
14122
+ throw outcome.error;
14123
+ await this.#refreshLocalRoutes(true);
14124
+ return final;
14125
+ }
13346
14126
  async#exposeQuick(worktreeRoot, services, opts) {
13347
14127
  const project = readState(worktreeRoot)?.project;
13348
14128
  if (project === undefined) {
@@ -13361,7 +14141,7 @@ class ComposeEngine {
13361
14141
  const alias = selection.endpoint.alias ?? selection.endpoint.name;
13362
14142
  const authority = internalEndpointAuthority(record.project, alias);
13363
14143
  const name = `aux-quick-${createHash7("sha256").update(alias).digest("hex").slice(0, 10)}`;
13364
- const quickCfg = join22(hestiaDir(worktreeRoot), `quick-${name}.yml`);
14144
+ const quickCfg = join24(hestiaDir(worktreeRoot), `quick-${name}.yml`);
13365
14145
  writeAtomicTextFile(quickCfg, `ingress:
13366
14146
  - service: ${JSON.stringify(`unix:${publicGatewaySocketPath()}`)}
13367
14147
  ` + ` originRequest:
@@ -13553,6 +14333,80 @@ class ComposeEngine {
13553
14333
  await this.#refreshLocalRoutes(true);
13554
14334
  return final;
13555
14335
  }
14336
+ async#sharedContext(cwd, name) {
14337
+ assertSharedName(name);
14338
+ const { worktreeRoot } = await getRepoInfo(cwd);
14339
+ const record = readState(worktreeRoot);
14340
+ if (record === null) {
14341
+ throw new HestiaError("service-not-found", "no stack in this worktree \u2014 `hestia up`/`run` something first");
14342
+ }
14343
+ const shared = readSharedHostname(name);
14344
+ if (shared === null) {
14345
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared \u2014 \`hestia expose <svc> --shared ${name}\` creates one`);
14346
+ }
14347
+ return { worktreeRoot, record, shared };
14348
+ }
14349
+ async#applySharedProjection(worktreeRoot, project) {
14350
+ return withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
14351
+ const record = readState(worktreeRoot);
14352
+ if (record === null) {
14353
+ throw new HestiaError("service-not-found", "stack disappeared during the shared-hostname transition");
14354
+ }
14355
+ applyLocalRouteProjection(record);
14356
+ writeState(worktreeRoot, record);
14357
+ return record;
14358
+ }));
14359
+ }
14360
+ async claimShared(cwd, name, opts) {
14361
+ const { worktreeRoot, record, shared } = await this.#sharedContext(cwd, name);
14362
+ const endpoint = record.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
14363
+ if (endpoint === undefined) {
14364
+ throw new HestiaError("service-not-found", `shared hostname "${name}" routes to endpoint alias "${shared.service}", which this ` + `stack does not expose \u2014 \`hestia up\`/\`run\` it first`);
14365
+ }
14366
+ if (endpoint.kind !== undefined && endpoint.kind !== "http") {
14367
+ throw new HestiaError("usage", `shared hostnames require an HTTP endpoint; ${shared.service} is ${endpoint.kind}`);
14368
+ }
14369
+ const handle = await ensureDaemon();
14370
+ const result = await sharedVerb(handle.port, "claim", {
14371
+ name,
14372
+ project: record.project,
14373
+ worktree: worktreeRoot,
14374
+ waitMs: opts?.waitMs ?? 0
14375
+ });
14376
+ if (!result.granted) {
14377
+ const position = result.queued.findIndex((waiter) => waiter.project === record.project) + 1;
14378
+ throw new HestiaError("shared-held", `shared hostname "${name}" is held by ${result.holder?.project ?? "another stack"}` + (position > 0 ? ` \u2014 queued durably at position ${position}/${result.queued.length}; the holder can ` + `\`hestia share allow ${name}\`, or re-run \`hestia claim ${name} --wait\` to keep waiting` : ""), { holder: result.holder, queued: result.queued });
14379
+ }
14380
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14381
+ await this.#refreshLocalRoutes();
14382
+ return { record: updated, result };
14383
+ }
14384
+ async releaseShared(cwd, name) {
14385
+ const { worktreeRoot, record } = await this.#sharedContext(cwd, name);
14386
+ const handle = await ensureDaemon();
14387
+ await sharedVerb(handle.port, "release", { name, project: record.project });
14388
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14389
+ await this.#refreshLocalRoutes();
14390
+ return updated;
14391
+ }
14392
+ async cancelSharedClaim(cwd, name) {
14393
+ const { record } = await this.#sharedContext(cwd, name);
14394
+ const handle = await ensureDaemon();
14395
+ await sharedVerb(handle.port, "cancel", { name, project: record.project });
14396
+ }
14397
+ async allowShared(cwd, name) {
14398
+ const { worktreeRoot, record } = await this.#sharedContext(cwd, name);
14399
+ const handle = await ensureDaemon();
14400
+ await sharedVerb(handle.port, "allow", { name, project: record.project });
14401
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14402
+ await this.#refreshLocalRoutes();
14403
+ return updated;
14404
+ }
14405
+ async denyShared(cwd, name) {
14406
+ const { record } = await this.#sharedContext(cwd, name);
14407
+ const handle = await ensureDaemon();
14408
+ await sharedVerb(handle.port, "deny", { name, project: record.project });
14409
+ }
13556
14410
  async status(cwd) {
13557
14411
  const { worktreeRoot } = await getRepoInfo(cwd);
13558
14412
  const record = readState(worktreeRoot);
@@ -13672,11 +14526,24 @@ function startDuties(admission, opts) {
13672
14526
  } catch (err) {
13673
14527
  log(`sweep: pump failed: ${err.message}`);
13674
14528
  }
14529
+ if (opts?.shared !== undefined) {
14530
+ try {
14531
+ const state = admission.healthSnapshot();
14532
+ await opts.shared.sweep(new Set([...state.live, ...state.reserved]));
14533
+ } catch (err) {
14534
+ log(`sweep: shared-hostname sweep failed: ${err.message}`);
14535
+ }
14536
+ }
13675
14537
  for (const uuid of listAdopted()) {
13676
14538
  try {
13677
14539
  const pf = connectorPidfile(uuid);
13678
- if (pf !== null && isLive(pf))
14540
+ if (pf !== null && isLive(pf)) {
14541
+ const { reapedGroups } = await reapOrphanConnectors(uuid, pf);
14542
+ if (reapedGroups > 0) {
14543
+ log(`sweep: reaped ${reapedGroups} orphan connector group(s) for ${uuid}`);
14544
+ }
13679
14545
  continue;
14546
+ }
13680
14547
  const ref = readAdopted(uuid);
13681
14548
  if (ref === null)
13682
14549
  continue;
@@ -13686,6 +14553,11 @@ function startDuties(admission, opts) {
13686
14553
  const outcome = await reconcileTunnel(ref);
13687
14554
  if (outcome.restarted) {
13688
14555
  log(`sweep: connector for ${ref.name} (${uuid}) revived (ready=${outcome.ready})`);
14556
+ } else {
14557
+ for (const w of outcome.warnings) {
14558
+ if (w.includes("reaped"))
14559
+ log(`sweep: ${w}`);
14560
+ }
13689
14561
  }
13690
14562
  } catch (err) {
13691
14563
  log(`sweep: connector revival for ${uuid} failed: ${err.message}`);
@@ -13729,6 +14601,7 @@ async function main() {
13729
14601
  const engine2 = new ComposeEngine;
13730
14602
  const localRouter = new HestiaLocalHttpRouter;
13731
14603
  const routerPort = await localRouter.start();
14604
+ const shared = new SharedArbiter(() => localRouter.refreshRoutes());
13732
14605
  const token = randomBytes(32).toString("hex");
13733
14606
  const server = serveSessionBrokerDaemon({
13734
14607
  daemon,
@@ -13739,6 +14612,7 @@ async function main() {
13739
14612
  fleet,
13740
14613
  routerPort,
13741
14614
  gatewaySocket: localRouter.socketPath,
14615
+ shared,
13742
14616
  refreshLocalRoutes: () => localRouter.refreshRoutes(),
13743
14617
  logsProject: (project, options) => engine2.logsProject(project, options)
13744
14618
  })
@@ -13755,11 +14629,11 @@ async function main() {
13755
14629
  port: "none",
13756
14630
  backend: "proc"
13757
14631
  }),
13758
- logPath: join23(root, "daemon.log"),
14632
+ logPath: join25(root, "daemon.log"),
13759
14633
  signal: "term",
13760
14634
  backend: "proc"
13761
14635
  });
13762
- writeAtomicJsonFile(join23(root, "daemon.json"), {
14636
+ writeAtomicJsonFile(join25(root, "daemon.json"), {
13763
14637
  schemaVersion: STATE_SCHEMA_VERSION,
13764
14638
  pid: process.pid,
13765
14639
  port: server.port,
@@ -13769,13 +14643,13 @@ async function main() {
13769
14643
  routerPort,
13770
14644
  gatewaySocket: localRouter.socketPath
13771
14645
  }, { mode: 384 });
13772
- const stopDuties = startDuties(admission);
14646
+ const stopDuties = startDuties(admission, { shared });
13773
14647
  const shutdown = () => {
13774
14648
  stopDuties();
13775
14649
  fleet.stop();
13776
14650
  localRouter.stop();
13777
14651
  removePidfile(root, PIDFILE_NAME2);
13778
- rmSync10(join23(root, "daemon.json"), { force: true });
14652
+ rmSync11(join25(root, "daemon.json"), { force: true });
13779
14653
  server.stop(true);
13780
14654
  process.exit(0);
13781
14655
  };
@@ -13790,4 +14664,4 @@ async function main() {
13790
14664
  }
13791
14665
  main();
13792
14666
 
13793
- //# debugId=855573FAB285270F64756E2164756E21
14667
+ //# debugId=E5997ABC0405B8AE64756E2164756E21