@tridha643/hestia 1.0.1 → 1.1.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 join24 } 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 join23, 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,12 @@ 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 join21 } from "path";
11251
11583
 
11252
11584
  // packages/engine/src/tunnel/verify.ts
11253
11585
  var POLL_MS4 = 300;
@@ -11295,24 +11627,24 @@ async function quickTunnelUrl(metricsPort, timeoutMs) {
11295
11627
  var CONNECTOR = "connector";
11296
11628
  var READY_TIMEOUT_MS2 = 30000;
11297
11629
  function tunnelDir(uuid) {
11298
- return join20(hestiaHome(), "tunnel", uuid);
11630
+ return join21(hestiaHome(), "tunnel", uuid);
11299
11631
  }
11300
11632
  function configPath(uuid) {
11301
- return join20(tunnelDir(uuid), "config.yml");
11633
+ return join21(tunnelDir(uuid), "config.yml");
11302
11634
  }
11303
11635
  function adoptedMarker(uuid) {
11304
- return join20(tunnelDir(uuid), "adopted.json");
11636
+ return join21(tunnelDir(uuid), "adopted.json");
11305
11637
  }
11306
11638
  function isAdopted(uuid) {
11307
- return existsSync18(adoptedMarker(uuid));
11639
+ return existsSync19(adoptedMarker(uuid));
11308
11640
  }
11309
11641
  function readAdopted(uuid) {
11310
11642
  const p = adoptedMarker(uuid);
11311
- if (!existsSync18(p))
11643
+ if (!existsSync19(p))
11312
11644
  return null;
11313
11645
  let marker;
11314
11646
  try {
11315
- marker = JSON.parse(readFileSync16(p, "utf8"));
11647
+ marker = JSON.parse(readFileSync17(p, "utf8"));
11316
11648
  } catch {
11317
11649
  marker = {};
11318
11650
  }
@@ -11320,14 +11652,14 @@ function readAdopted(uuid) {
11320
11652
  return { uuid, name: marker.name, credFile: marker.credFile, reconstructed: false };
11321
11653
  }
11322
11654
  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))
11655
+ const stacksDir = join21(hestiaHome(), "stacks");
11656
+ if (existsSync19(stacksDir)) {
11657
+ for (const project of readdirSync7(stacksDir)) {
11658
+ const sp = join21(stacksDir, project, "stack.json");
11659
+ if (!existsSync19(sp))
11328
11660
  continue;
11329
11661
  try {
11330
- const record = parseStackRecord(readFileSync16(sp, "utf8"), sp);
11662
+ const record = parseStackRecord(readFileSync17(sp, "utf8"), sp);
11331
11663
  if (record.tunnel?.uuid === uuid) {
11332
11664
  name = record.tunnel.name;
11333
11665
  break;
@@ -11343,24 +11675,24 @@ function readAdopted(uuid) {
11343
11675
  };
11344
11676
  }
11345
11677
  function listAdopted() {
11346
- const root = join20(hestiaHome(), "tunnel");
11347
- if (!existsSync18(root))
11678
+ const root = join21(hestiaHome(), "tunnel");
11679
+ if (!existsSync19(root))
11348
11680
  return [];
11349
- return readdirSync6(root).filter((uuid) => isAdopted(uuid));
11681
+ return readdirSync7(root).filter((uuid) => isAdopted(uuid));
11350
11682
  }
11351
11683
  function collectDynamicRules(uuid) {
11352
- const stacksDir = join20(hestiaHome(), "stacks");
11684
+ const stacksDir = join21(hestiaHome(), "stacks");
11353
11685
  const rules = [];
11354
11686
  const dropped = [];
11355
- if (!existsSync18(stacksDir))
11687
+ if (!existsSync19(stacksDir))
11356
11688
  return { rules, dropped };
11357
- for (const project of readdirSync6(stacksDir)) {
11358
- const p = join20(stacksDir, project, "stack.json");
11359
- if (!existsSync18(p))
11689
+ for (const project of readdirSync7(stacksDir)) {
11690
+ const p = join21(stacksDir, project, "stack.json");
11691
+ if (!existsSync19(p))
11360
11692
  continue;
11361
11693
  let record;
11362
11694
  try {
11363
- record = parseStackRecord(readFileSync16(p, "utf8"), p);
11695
+ record = parseStackRecord(readFileSync17(p, "utf8"), p);
11364
11696
  } catch {
11365
11697
  continue;
11366
11698
  }
@@ -11389,20 +11721,22 @@ async function reconcileTunnel(ref, opts) {
11389
11721
  for (const d of dropped) {
11390
11722
  warnings.push(`exposure ${d.hostname} dropped \u2014 service "${d.service}" of ` + `${d.project} is not running (hostname now 404s)`);
11391
11723
  }
11724
+ const sharedRules = listSharedHostnames().filter((record) => record.tunnelUuid === ref.uuid).map((record) => ({ name: record.name, hostname: record.hostname }));
11392
11725
  const cfgPath = configPath(ref.uuid);
11393
11726
  const nextConfig = generateMergedConfig({
11394
11727
  uuid: ref.uuid,
11395
11728
  credFile: ref.credFile,
11396
11729
  baseRules,
11397
- dynamicRules: rules
11730
+ dynamicRules: rules,
11731
+ sharedRules
11398
11732
  });
11399
11733
  const pf = readPidfile(dir, CONNECTOR);
11400
11734
  const live = pf !== null && isLive(pf);
11401
- const currentConfig = existsSync18(cfgPath) ? readFileSync16(cfgPath, "utf8") : null;
11735
+ const currentConfig = existsSync19(cfgPath) ? readFileSync17(cfgPath, "utf8") : null;
11402
11736
  if (live && currentConfig === nextConfig) {
11403
11737
  return { restarted: false, metricsPort: pf.port, ready: true, warnings };
11404
11738
  }
11405
- if (baseRules.length === 0 && rules.length === 0) {
11739
+ if (baseRules.length === 0 && rules.length === 0 && sharedRules.length === 0) {
11406
11740
  if (pf !== null) {
11407
11741
  await stopProcTree(pf);
11408
11742
  removePidfile(dir, CONNECTOR);
@@ -11887,11 +12221,225 @@ async function* streamStackLogs(record, options = {}) {
11887
12221
  await Promise.allSettled(pumps);
11888
12222
  }
11889
12223
  }
12224
+ // packages/engine/src/daemon/shared-arbiter.ts
12225
+ class SharedArbiter {
12226
+ onChange;
12227
+ #mutex = Promise.resolve();
12228
+ #polls = new Map;
12229
+ constructor(onChange) {
12230
+ this.onChange = onChange;
12231
+ }
12232
+ #locked(fn) {
12233
+ const next = this.#mutex.then(fn, fn);
12234
+ this.#mutex = next.catch(() => {});
12235
+ return next;
12236
+ }
12237
+ async#notify() {
12238
+ try {
12239
+ await this.onChange?.();
12240
+ } catch {}
12241
+ }
12242
+ #result(record, granted) {
12243
+ return { granted, holder: record.holder, queued: [...record.queue ?? []] };
12244
+ }
12245
+ #wake(name, project, result) {
12246
+ const key = `${name}\x00${project}`;
12247
+ for (const poll of this.#polls.get(key) ?? []) {
12248
+ clearTimeout(poll.timer);
12249
+ poll.resolve(result);
12250
+ }
12251
+ this.#polls.delete(key);
12252
+ }
12253
+ #grantNext(record) {
12254
+ const queue = [...record.queue ?? []];
12255
+ for (;; ) {
12256
+ const head = queue.shift();
12257
+ if (head === undefined) {
12258
+ const next = { ...record, queue: [] };
12259
+ delete next.holder;
12260
+ return next;
12261
+ }
12262
+ if (readMirrorState(head.project) === null)
12263
+ continue;
12264
+ return {
12265
+ ...record,
12266
+ holder: {
12267
+ project: head.project,
12268
+ worktree: head.worktree,
12269
+ service: record.service,
12270
+ at: new Date().toISOString()
12271
+ },
12272
+ queue
12273
+ };
12274
+ }
12275
+ }
12276
+ async request(name, requester, waitMs) {
12277
+ const outcome = await this.#locked(async () => {
12278
+ let granted = false;
12279
+ const updated = await updateSharedHostname(name, (record) => {
12280
+ if (record.holder?.project === requester.project) {
12281
+ granted = true;
12282
+ return {
12283
+ ...record,
12284
+ holder: { ...record.holder, worktree: requester.worktree },
12285
+ queue: (record.queue ?? []).filter((waiter) => waiter.project !== requester.project)
12286
+ };
12287
+ }
12288
+ if (record.holder === undefined) {
12289
+ granted = true;
12290
+ return {
12291
+ ...record,
12292
+ holder: {
12293
+ project: requester.project,
12294
+ worktree: requester.worktree,
12295
+ service: record.service,
12296
+ at: new Date().toISOString()
12297
+ },
12298
+ queue: (record.queue ?? []).filter((waiter) => waiter.project !== requester.project)
12299
+ };
12300
+ }
12301
+ const queue = [...record.queue ?? []];
12302
+ const existing = queue.find((waiter) => waiter.project === requester.project);
12303
+ if (existing !== undefined)
12304
+ existing.worktree = requester.worktree;
12305
+ else
12306
+ queue.push({ project: requester.project, worktree: requester.worktree, at: new Date().toISOString() });
12307
+ return { ...record, queue };
12308
+ });
12309
+ if (updated === null) {
12310
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12311
+ }
12312
+ return { granted, record: updated };
12313
+ });
12314
+ if (outcome.granted) {
12315
+ await this.#notify();
12316
+ return this.#result(outcome.record, true);
12317
+ }
12318
+ if (waitMs <= 0)
12319
+ return this.#result(outcome.record, false);
12320
+ return new Promise((resolve2) => {
12321
+ const key = `${name}\x00${requester.project}`;
12322
+ const polls = this.#polls.get(key) ?? [];
12323
+ const poll = {
12324
+ resolve: resolve2,
12325
+ timer: setTimeout(() => {
12326
+ const remaining = (this.#polls.get(key) ?? []).filter((candidate) => candidate !== poll);
12327
+ if (remaining.length === 0)
12328
+ this.#polls.delete(key);
12329
+ else
12330
+ this.#polls.set(key, remaining);
12331
+ const record = readSharedHostname(name);
12332
+ resolve2(record === null ? { granted: false, queued: [] } : this.#result(record, record.holder?.project === requester.project));
12333
+ }, waitMs)
12334
+ };
12335
+ polls.push(poll);
12336
+ this.#polls.set(key, polls);
12337
+ });
12338
+ }
12339
+ async cancel(name, project) {
12340
+ const record = await this.#locked(() => updateSharedHostname(name, (current) => ({
12341
+ ...current,
12342
+ queue: (current.queue ?? []).filter((waiter) => waiter.project !== project)
12343
+ })));
12344
+ if (record === null)
12345
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12346
+ this.#wake(name, project, this.#result(record, false));
12347
+ return this.#result(record, false);
12348
+ }
12349
+ #assertHolder(record, project, verb) {
12350
+ if (record.holder?.project !== project) {
12351
+ 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}`);
12352
+ }
12353
+ }
12354
+ async allow(name, callerProject) {
12355
+ const record = await this.#transfer(name, callerProject, "allow");
12356
+ return this.#result(record, false);
12357
+ }
12358
+ async deny(name, callerProject) {
12359
+ const record = await this.#locked(async () => {
12360
+ const current = readSharedHostname(name);
12361
+ if (current === null)
12362
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12363
+ this.#assertHolder(current, callerProject, "deny");
12364
+ const updated = await updateSharedHostname(name, (candidate) => {
12365
+ const queue = [...candidate.queue ?? []];
12366
+ if (queue.length > 0)
12367
+ queue[0] = { ...queue[0], denied: true };
12368
+ return { ...candidate, queue };
12369
+ });
12370
+ return updated;
12371
+ });
12372
+ return this.#result(record, false);
12373
+ }
12374
+ async release(name, callerProject) {
12375
+ const record = await this.#transfer(name, callerProject, "release");
12376
+ return this.#result(record, false);
12377
+ }
12378
+ async#transfer(name, callerProject, verb) {
12379
+ const record = await this.#locked(async () => {
12380
+ const current = readSharedHostname(name);
12381
+ if (current === null)
12382
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12383
+ this.#assertHolder(current, callerProject, verb);
12384
+ return await updateSharedHostname(name, (candidate) => this.#grantNext(candidate));
12385
+ });
12386
+ if (record.holder !== undefined) {
12387
+ this.#wake(record.name, record.holder.project, this.#result(record, true));
12388
+ }
12389
+ await this.#notify();
12390
+ return record;
12391
+ }
12392
+ async releaseProject(project, service) {
12393
+ const held = listSharedHostnames().filter((record) => record.holder?.project === project && (service === undefined || record.service === service));
12394
+ let changed = false;
12395
+ for (const record of held) {
12396
+ const updated = await this.#locked(async () => {
12397
+ const current = readSharedHostname(record.name);
12398
+ if (current?.holder?.project !== project)
12399
+ return null;
12400
+ return await updateSharedHostname(record.name, (candidate) => this.#grantNext(candidate));
12401
+ });
12402
+ if (updated === null || updated === undefined)
12403
+ continue;
12404
+ changed = true;
12405
+ if (updated.holder !== undefined) {
12406
+ this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
12407
+ }
12408
+ }
12409
+ if (changed)
12410
+ await this.#notify();
12411
+ }
12412
+ async sweep(occupied) {
12413
+ let changed = false;
12414
+ for (const record of listSharedHostnames()) {
12415
+ const holderDead = record.holder !== undefined && !occupied.has(record.holder.project) && readMirrorState(record.holder.project) === null;
12416
+ const deadWaiters = (record.queue ?? []).filter((waiter) => readMirrorState(waiter.project) === null);
12417
+ if (!holderDead && deadWaiters.length === 0)
12418
+ continue;
12419
+ const updated = await this.#locked(() => updateSharedHostname(record.name, (candidate) => {
12420
+ const pruned = {
12421
+ ...candidate,
12422
+ queue: (candidate.queue ?? []).filter((waiter) => readMirrorState(waiter.project) !== null)
12423
+ };
12424
+ const currentHolderDead = pruned.holder !== undefined && !occupied.has(pruned.holder.project) && readMirrorState(pruned.holder.project) === null;
12425
+ return currentHolderDead ? this.#grantNext(pruned) : pruned;
12426
+ }));
12427
+ if (updated === null)
12428
+ continue;
12429
+ changed = true;
12430
+ if (updated.holder !== undefined && updated.holder.project !== record.holder?.project) {
12431
+ this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
12432
+ }
12433
+ }
12434
+ if (changed)
12435
+ await this.#notify();
12436
+ }
12437
+ }
11890
12438
  // packages/engine/src/daemon/fleet-monitor.ts
11891
12439
  import { execFile as execFile9 } from "child_process";
11892
12440
  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";
12441
+ import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync8 } from "fs";
12442
+ import { join as join22 } from "path";
11895
12443
  var pexec8 = promisify8(execFile9);
11896
12444
  var DEFAULT_REFRESH_MS = 1000;
11897
12445
  var DEFAULT_HEARTBEAT_MS = 15000;
@@ -11938,13 +12486,13 @@ class LatestFleetChannel {
11938
12486
  function readManagedMirrors() {
11939
12487
  const records = [];
11940
12488
  const warnings = [];
11941
- const stacksDirectory = join21(hestiaHome(), "stacks");
11942
- if (!existsSync19(stacksDirectory))
12489
+ const stacksDirectory = join22(hestiaHome(), "stacks");
12490
+ if (!existsSync20(stacksDirectory))
11943
12491
  return { records, warnings };
11944
- for (const project of readdirSync7(stacksDirectory).sort()) {
11945
- const path = join21(stacksDirectory, project, "stack.json");
12492
+ for (const project of readdirSync8(stacksDirectory).sort()) {
12493
+ const path = join22(stacksDirectory, project, "stack.json");
11946
12494
  try {
11947
- const source = readFileSync17(path);
12495
+ const source = readFileSync18(path);
11948
12496
  if (source.byteLength > MAX_MIRROR_BYTES2) {
11949
12497
  warnings.push(`Fleet mirror too large for ${project}`);
11950
12498
  continue;
@@ -11964,7 +12512,7 @@ function readManagedMirrors() {
11964
12512
  async function attributeLegacyRepoId(record) {
11965
12513
  if (record.repoId !== undefined)
11966
12514
  return record.repoId;
11967
- if (!existsSync19(record.worktree))
12515
+ if (!existsSync20(record.worktree))
11968
12516
  return null;
11969
12517
  const info = await getRepoInfo(record.worktree);
11970
12518
  if (projectName(info.repoId, info.repo, info.branch, info.worktreeRoot) !== record.project)
@@ -12070,6 +12618,25 @@ function reservationIdentity(reservation, records) {
12070
12618
  worktree: record.worktree
12071
12619
  };
12072
12620
  }
12621
+ function collectFleetShared(repoProjects) {
12622
+ return listSharedHostnames().map((record) => ({
12623
+ name: record.name,
12624
+ hostname: record.hostname,
12625
+ ...record.path === undefined ? {} : { path: record.path },
12626
+ url: `https://${record.hostname}${record.path ?? ""}`,
12627
+ holder: record.holder === undefined ? undefined : {
12628
+ project: record.holder.project,
12629
+ worktree: record.holder.worktree,
12630
+ mine: repoProjects.has(record.holder.project)
12631
+ },
12632
+ queue: (record.queue ?? []).map((waiter) => ({
12633
+ project: waiter.project,
12634
+ worktree: waiter.worktree,
12635
+ ...waiter.denied === true ? { denied: true } : {},
12636
+ mine: repoProjects.has(waiter.project)
12637
+ }))
12638
+ })).sort((left, right) => left.name.localeCompare(right.name));
12639
+ }
12073
12640
  async function collectFleetSnapshot(repoId, admission) {
12074
12641
  const mirrorResult = readManagedMirrors();
12075
12642
  const machineConfig = readHestiaMachineConfig();
@@ -12165,6 +12732,7 @@ async function collectFleetSnapshot(repoId, admission) {
12165
12732
  const allRecordedProjects = new Set(attributed.map((record) => record.project));
12166
12733
  const unbackedReservations = reservations.filter((reservation) => !allRecordedProjects.has(reservation.project));
12167
12734
  const { maxStacks, warnings: capWarnings } = resolveMaxStacks();
12735
+ const repoProjects = new Set(attributed.filter((record) => record.repoId === repoId).map((record) => record.project));
12168
12736
  return {
12169
12737
  repoId,
12170
12738
  observedAt: new Date().toISOString(),
@@ -12175,6 +12743,7 @@ async function collectFleetSnapshot(repoId, admission) {
12175
12743
  queued: queued.length
12176
12744
  },
12177
12745
  stacks: stackViews,
12746
+ shared: collectFleetShared(repoProjects),
12178
12747
  warnings: [...capWarnings, ...warnings].sort()
12179
12748
  };
12180
12749
  }
@@ -12183,6 +12752,7 @@ function semanticFleetSnapshot(snapshot) {
12183
12752
  repoId: snapshot.repoId,
12184
12753
  capacity: snapshot.capacity,
12185
12754
  stacks: snapshot.stacks,
12755
+ shared: snapshot.shared,
12186
12756
  warnings: snapshot.warnings
12187
12757
  });
12188
12758
  }
@@ -12398,7 +12968,7 @@ async function assertHestiaStateIgnored(worktreeRoot) {
12398
12968
  try {
12399
12969
  await pexec9("git", ["-C", worktreeRoot, "check-ignore", "-q", ".hestia/"], { timeout: 5000 });
12400
12970
  } 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") });
12971
+ throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${join23(worktreeRoot, ".gitignore")}`, { remedy: `.hestia/`, path: join23(worktreeRoot, ".gitignore") });
12402
12972
  }
12403
12973
  }
12404
12974
  async function prepareCompose(worktreeRoot, project, repo, branch, opts, configuredBaseFile) {
@@ -12415,7 +12985,7 @@ async function prepareCompose(worktreeRoot, project, repo, branch, opts, configu
12415
12985
  services
12416
12986
  });
12417
12987
  ensureDir(hestiaDir(worktreeRoot));
12418
- const overridePath = join22(hestiaDir(worktreeRoot), OVERRIDE_FILE);
12988
+ const overridePath = join23(hestiaDir(worktreeRoot), OVERRIDE_FILE);
12419
12989
  writeAtomicTextFile(overridePath, yaml);
12420
12990
  return {
12421
12991
  ctx: {
@@ -12448,7 +13018,7 @@ function writeDockerfileComposeModel(worktreeRoot, workloads, conventionalCompos
12448
13018
  ...conventionalComposeFile === undefined ? {} : { include: [{ path: conventionalComposeFile }] },
12449
13019
  services
12450
13020
  };
12451
- const path = join22(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
13021
+ const path = join23(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
12452
13022
  writeAtomicTextFile(path, $stringify(model));
12453
13023
  return path;
12454
13024
  }
@@ -12468,7 +13038,7 @@ function freshRecord(project, repoId, repo, branch, worktree) {
12468
13038
  };
12469
13039
  }
12470
13040
  function assertCurrentStackIdentity(record, current) {
12471
- const path = join22(hestiaDir(current.worktreeRoot), "stack.json");
13041
+ const path = join23(hestiaDir(current.worktreeRoot), "stack.json");
12472
13042
  assertMutableStackRecord(record, path);
12473
13043
  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
13044
  if (!matches) {
@@ -12603,6 +13173,27 @@ function applyLocalRouteProjection(record) {
12603
13173
  record.env[directUrlKey(endpoint.name)] = endpoint.url;
12604
13174
  record.env[localUrlKey(endpoint.name)] = endpoint.localUrl;
12605
13175
  }
13176
+ syncSharedProjection(record);
13177
+ }
13178
+ function syncSharedProjection(record) {
13179
+ for (const shared of listSharedHostnames()) {
13180
+ const url = `https://${shared.hostname}${shared.path ?? ""}`;
13181
+ if (shared.holder?.project === record.project) {
13182
+ const endpoint = record.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
13183
+ if (endpoint !== undefined) {
13184
+ endpoint.publicUrl = url;
13185
+ record.env[urlKey(shared.service)] = url;
13186
+ }
13187
+ continue;
13188
+ }
13189
+ for (const endpoint of record.endpoints) {
13190
+ if (endpoint.publicUrl === url)
13191
+ delete endpoint.publicUrl;
13192
+ }
13193
+ if (record.env[urlKey(shared.service)] === url) {
13194
+ delete record.env[urlKey(shared.service)];
13195
+ }
13196
+ }
12606
13197
  }
12607
13198
  function syncExposures(record) {
12608
13199
  const t = record.tunnel;
@@ -12627,7 +13218,7 @@ function matchesExpectedStack(record, expected) {
12627
13218
  return record !== null && (record.repoId === undefined || record.repoId === expected.repoId) && record.worktree === expected.worktree && record.createdAt === expected.createdAt;
12628
13219
  }
12629
13220
  function projectMutationRoot(project) {
12630
- return join22(hestiaHome(), "project-locks", project);
13221
+ return join23(hestiaHome(), "project-locks", project);
12631
13222
  }
12632
13223
  async function assertNamedTunnelDns(hostname, tunnelUuid) {
12633
13224
  const expected = `${tunnelUuid}.cfargotunnel.com`;
@@ -13033,6 +13624,7 @@ class ComposeEngine {
13033
13624
  const { repo, repoId, branch, worktreeRoot } = currentIdentity;
13034
13625
  let tunnel;
13035
13626
  let tunnelDirty = false;
13627
+ const stoppedAliases = [];
13036
13628
  const project = readState(worktreeRoot)?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13037
13629
  await withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
13038
13630
  const record = readState(worktreeRoot);
@@ -13041,6 +13633,11 @@ class ComposeEngine {
13041
13633
  if (record.services.some((service) => service.name === name && service.backend === "docker")) {
13042
13634
  throw new HestiaError("backend-not-stoppable", `Docker workload ${name} cannot be stopped individually; use hestia down`);
13043
13635
  }
13636
+ for (const endpoint of record.endpoints) {
13637
+ if ((endpoint.workload ?? endpoint.name) === name) {
13638
+ stoppedAliases.push(endpoint.alias ?? endpoint.name);
13639
+ }
13640
+ }
13044
13641
  }
13045
13642
  const pf = readPidfile(worktreeRoot, name);
13046
13643
  if (pf !== null) {
@@ -13086,6 +13683,9 @@ class ComposeEngine {
13086
13683
  }
13087
13684
  }
13088
13685
  }));
13686
+ for (const alias of new Set(stoppedAliases)) {
13687
+ await this.#releaseSharedHoldings(project, alias);
13688
+ }
13089
13689
  if (tunnelDirty)
13090
13690
  await this.#reconcileAdopted(tunnel);
13091
13691
  await this.#refreshLocalRoutes();
@@ -13221,12 +13821,12 @@ class ComposeEngine {
13221
13821
  await stopProcTree(pf);
13222
13822
  removePidfile(worktreeRoot, pf.name);
13223
13823
  }
13224
- rmSync9(privateRegistryDir(worktreeRoot), { recursive: true, force: true });
13824
+ rmSync10(privateRegistryDir(worktreeRoot), { recursive: true, force: true });
13225
13825
  const composeFile = record?.composeFile ?? tryLoadConfig(worktreeRoot)?.composeFile;
13226
13826
  if (composeFile !== undefined) {
13227
13827
  const project2 = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13228
- const overrideFile = record?.overrideFile ?? join22(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13229
- if (existsSync20(overrideFile)) {
13828
+ const overrideFile = record?.overrideFile ?? join23(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13829
+ if (existsSync21(overrideFile)) {
13230
13830
  await composeDown({ project: project2, baseFile: composeFile, overrideFile, cwd: worktreeRoot }, opts?.destroy ?? false);
13231
13831
  } else if (record?.composeFile !== undefined) {
13232
13832
  const rest = ["compose", "-p", project2, "down", "--remove-orphans"];
@@ -13238,6 +13838,7 @@ class ComposeEngine {
13238
13838
  const project = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13239
13839
  clearState(worktreeRoot, project);
13240
13840
  await this.#releaseAdmission(project);
13841
+ await this.#releaseSharedHoldings(project);
13241
13842
  }));
13242
13843
  await this.#reconcileAdopted(tunnel);
13243
13844
  await this.#refreshLocalRoutes();
@@ -13247,6 +13848,11 @@ class ComposeEngine {
13247
13848
  if (j !== null)
13248
13849
  await releaseSlot(j.port, project);
13249
13850
  }
13851
+ async#releaseSharedHoldings(project, service) {
13852
+ const j = readDaemonJson();
13853
+ if (j !== null)
13854
+ await releaseSharedForProject(j.port, project, service);
13855
+ }
13250
13856
  async downProject(project, opts) {
13251
13857
  if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(project)) {
13252
13858
  throw new HestiaError("usage", `invalid project name ${JSON.stringify(project)}`);
@@ -13289,9 +13895,9 @@ class ComposeEngine {
13289
13895
  throw new HestiaError("compose-failed", `docker compose -p ${project} down failed: ${err.message}`);
13290
13896
  }
13291
13897
  }
13292
- rmSync9(mirrorDir(project), { recursive: true, force: true });
13898
+ rmSync10(mirrorDir(project), { recursive: true, force: true });
13293
13899
  };
13294
- if (record !== null && existsSync20(record.worktree)) {
13900
+ if (record !== null && existsSync21(record.worktree)) {
13295
13901
  const info = await getRepoInfo(record.worktree);
13296
13902
  const identityMatches = resolve2(info.worktreeRoot) === resolve2(record.worktree) && (record.repoId === undefined ? record.repo === info.repo : record.repoId === info.repoId);
13297
13903
  const localRecord = readState(record.worktree);
@@ -13312,6 +13918,7 @@ class ComposeEngine {
13312
13918
  return;
13313
13919
  }
13314
13920
  await this.#releaseAdmission(project);
13921
+ await this.#releaseSharedHoldings(project);
13315
13922
  await this.#reconcileAdopted(record.tunnel);
13316
13923
  await this.#refreshLocalRoutes();
13317
13924
  return;
@@ -13320,6 +13927,7 @@ class ComposeEngine {
13320
13927
  const projectLockRoot = projectMutationRoot(project);
13321
13928
  await withLock(projectLockRoot, teardownFromMirror);
13322
13929
  await this.#releaseAdmission(project);
13930
+ await this.#releaseSharedHoldings(project);
13323
13931
  await this.#reconcileAdopted(record?.tunnel);
13324
13932
  await this.#refreshLocalRoutes();
13325
13933
  }
@@ -13338,11 +13946,100 @@ class ComposeEngine {
13338
13946
  }
13339
13947
  await ensureDaemon();
13340
13948
  await this.#refreshLocalRoutes(true);
13949
+ if (opts?.shared !== undefined) {
13950
+ if (tunnelName === undefined) {
13951
+ throw new HestiaError("shared-requires-named-tunnel", "shared hostnames need an adopted named tunnel (stable DNS is the point) \u2014 pass --tunnel <name>");
13952
+ }
13953
+ return this.#exposeShared(worktreeRoot, services, tunnelName, opts);
13954
+ }
13341
13955
  if (tunnelName === undefined) {
13342
13956
  return this.#exposeQuick(worktreeRoot, services, opts);
13343
13957
  }
13344
13958
  return this.#exposeNamed(worktreeRoot, services, tunnelName, opts);
13345
13959
  }
13960
+ async#exposeShared(worktreeRoot, services, tunnelName, opts) {
13961
+ if (services.length !== 1) {
13962
+ throw new HestiaError("usage", "--shared declares exactly one service per shared hostname");
13963
+ }
13964
+ const name = opts.shared;
13965
+ assertSharedName(name);
13966
+ const project = readState(worktreeRoot)?.project;
13967
+ if (project === undefined) {
13968
+ throw new HestiaError("service-not-found", "no stack in this worktree \u2014 `hestia up`/`run` something first");
13969
+ }
13970
+ const adopted = await adoptTunnel(tunnelName);
13971
+ if (adopted.connections > 0 && !isAdopted(adopted.uuid) && !opts?.force) {
13972
+ 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`);
13973
+ }
13974
+ const preflightRecord = readState(worktreeRoot);
13975
+ if (preflightRecord === null) {
13976
+ throw new HestiaError("service-not-found", "stack disappeared while preparing the shared hostname");
13977
+ }
13978
+ const baseRules = importBaseRules(adopted.uuid, tunnelName);
13979
+ const zoneHint = opts?.zone ?? preflightRecord.tunnel?.zone ?? inferZone(baseRules);
13980
+ let hostname;
13981
+ let zone;
13982
+ if (opts?.hostname !== undefined) {
13983
+ hostname = opts.hostname.toLowerCase();
13984
+ assertSharedHostnameFqdn(hostname);
13985
+ zone = zoneOf(hostname) ?? zoneHint ?? hostname;
13986
+ } else {
13987
+ if (zoneHint === undefined) {
13988
+ throw new HestiaError("usage", "cannot infer a zone from the tunnel's existing rules \u2014 pass --zone or an explicit --hostname");
13989
+ }
13990
+ zone = zoneHint;
13991
+ hostname = `${name}.${zone}`;
13992
+ }
13993
+ const path = normalizeSharedPath(opts?.path);
13994
+ const selection = resolveEndpointSelection(preflightRecord, services[0]);
13995
+ if (selection.endpoint.kind !== undefined && selection.endpoint.kind !== "http") {
13996
+ throw new HestiaError("usage", `public tunnels require an HTTP endpoint; ${services[0]} is ${selection.endpoint.kind}`);
13997
+ }
13998
+ const alias = selection.endpoint.alias ?? selection.endpoint.name;
13999
+ if (baseRules.some((rule) => rule.hostname === hostname)) {
14000
+ throw new HestiaError("hostname-conflict", `shared hostname ${hostname} is already claimed by the user's static rules`);
14001
+ }
14002
+ const dynamicConflict = collectDynamicRules(adopted.uuid).rules.find((rule) => rule.hostname === hostname);
14003
+ if (dynamicConflict !== undefined) {
14004
+ throw new HestiaError("hostname-conflict", `shared hostname ${hostname} is already claimed by project ${dynamicConflict.project}`);
14005
+ }
14006
+ await assertNamedTunnelDns(hostname, adopted.uuid);
14007
+ await declareSharedHostname({
14008
+ name,
14009
+ hostname,
14010
+ path,
14011
+ tunnelUuid: adopted.uuid,
14012
+ zone,
14013
+ service: alias
14014
+ });
14015
+ const handle = await ensureDaemon();
14016
+ const claim = await sharedVerb(handle.port, "claim", {
14017
+ name,
14018
+ project,
14019
+ worktree: worktreeRoot,
14020
+ waitMs: 0
14021
+ });
14022
+ if (!claim.granted) {
14023
+ 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`);
14024
+ }
14025
+ const outcome = await reconcileTunnel({ name: tunnelName, uuid: adopted.uuid, credFile: adopted.credFile }, { force: opts?.force, readyTimeoutMs: opts?.readyTimeoutMs });
14026
+ for (const w of outcome.warnings)
14027
+ process.stderr.write(`warning: ${w}
14028
+ `);
14029
+ const final = await withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
14030
+ const record = readState(worktreeRoot);
14031
+ if (record === null) {
14032
+ throw new HestiaError("service-not-found", "stack disappeared while exposing (concurrent down?)");
14033
+ }
14034
+ applyLocalRouteProjection(record);
14035
+ writeState(worktreeRoot, record);
14036
+ return record;
14037
+ }));
14038
+ if (outcome.error !== undefined)
14039
+ throw outcome.error;
14040
+ await this.#refreshLocalRoutes(true);
14041
+ return final;
14042
+ }
13346
14043
  async#exposeQuick(worktreeRoot, services, opts) {
13347
14044
  const project = readState(worktreeRoot)?.project;
13348
14045
  if (project === undefined) {
@@ -13361,7 +14058,7 @@ class ComposeEngine {
13361
14058
  const alias = selection.endpoint.alias ?? selection.endpoint.name;
13362
14059
  const authority = internalEndpointAuthority(record.project, alias);
13363
14060
  const name = `aux-quick-${createHash7("sha256").update(alias).digest("hex").slice(0, 10)}`;
13364
- const quickCfg = join22(hestiaDir(worktreeRoot), `quick-${name}.yml`);
14061
+ const quickCfg = join23(hestiaDir(worktreeRoot), `quick-${name}.yml`);
13365
14062
  writeAtomicTextFile(quickCfg, `ingress:
13366
14063
  - service: ${JSON.stringify(`unix:${publicGatewaySocketPath()}`)}
13367
14064
  ` + ` originRequest:
@@ -13553,6 +14250,80 @@ class ComposeEngine {
13553
14250
  await this.#refreshLocalRoutes(true);
13554
14251
  return final;
13555
14252
  }
14253
+ async#sharedContext(cwd, name) {
14254
+ assertSharedName(name);
14255
+ const { worktreeRoot } = await getRepoInfo(cwd);
14256
+ const record = readState(worktreeRoot);
14257
+ if (record === null) {
14258
+ throw new HestiaError("service-not-found", "no stack in this worktree \u2014 `hestia up`/`run` something first");
14259
+ }
14260
+ const shared = readSharedHostname(name);
14261
+ if (shared === null) {
14262
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared \u2014 \`hestia expose <svc> --shared ${name}\` creates one`);
14263
+ }
14264
+ return { worktreeRoot, record, shared };
14265
+ }
14266
+ async#applySharedProjection(worktreeRoot, project) {
14267
+ return withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
14268
+ const record = readState(worktreeRoot);
14269
+ if (record === null) {
14270
+ throw new HestiaError("service-not-found", "stack disappeared during the shared-hostname transition");
14271
+ }
14272
+ applyLocalRouteProjection(record);
14273
+ writeState(worktreeRoot, record);
14274
+ return record;
14275
+ }));
14276
+ }
14277
+ async claimShared(cwd, name, opts) {
14278
+ const { worktreeRoot, record, shared } = await this.#sharedContext(cwd, name);
14279
+ const endpoint = record.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
14280
+ if (endpoint === undefined) {
14281
+ 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`);
14282
+ }
14283
+ if (endpoint.kind !== undefined && endpoint.kind !== "http") {
14284
+ throw new HestiaError("usage", `shared hostnames require an HTTP endpoint; ${shared.service} is ${endpoint.kind}`);
14285
+ }
14286
+ const handle = await ensureDaemon();
14287
+ const result = await sharedVerb(handle.port, "claim", {
14288
+ name,
14289
+ project: record.project,
14290
+ worktree: worktreeRoot,
14291
+ waitMs: opts?.waitMs ?? 0
14292
+ });
14293
+ if (!result.granted) {
14294
+ const position = result.queued.findIndex((waiter) => waiter.project === record.project) + 1;
14295
+ 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 });
14296
+ }
14297
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14298
+ await this.#refreshLocalRoutes();
14299
+ return { record: updated, result };
14300
+ }
14301
+ async releaseShared(cwd, name) {
14302
+ const { worktreeRoot, record } = await this.#sharedContext(cwd, name);
14303
+ const handle = await ensureDaemon();
14304
+ await sharedVerb(handle.port, "release", { name, project: record.project });
14305
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14306
+ await this.#refreshLocalRoutes();
14307
+ return updated;
14308
+ }
14309
+ async cancelSharedClaim(cwd, name) {
14310
+ const { record } = await this.#sharedContext(cwd, name);
14311
+ const handle = await ensureDaemon();
14312
+ await sharedVerb(handle.port, "cancel", { name, project: record.project });
14313
+ }
14314
+ async allowShared(cwd, name) {
14315
+ const { worktreeRoot, record } = await this.#sharedContext(cwd, name);
14316
+ const handle = await ensureDaemon();
14317
+ await sharedVerb(handle.port, "allow", { name, project: record.project });
14318
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14319
+ await this.#refreshLocalRoutes();
14320
+ return updated;
14321
+ }
14322
+ async denyShared(cwd, name) {
14323
+ const { record } = await this.#sharedContext(cwd, name);
14324
+ const handle = await ensureDaemon();
14325
+ await sharedVerb(handle.port, "deny", { name, project: record.project });
14326
+ }
13556
14327
  async status(cwd) {
13557
14328
  const { worktreeRoot } = await getRepoInfo(cwd);
13558
14329
  const record = readState(worktreeRoot);
@@ -13672,6 +14443,14 @@ function startDuties(admission, opts) {
13672
14443
  } catch (err) {
13673
14444
  log(`sweep: pump failed: ${err.message}`);
13674
14445
  }
14446
+ if (opts?.shared !== undefined) {
14447
+ try {
14448
+ const state = admission.healthSnapshot();
14449
+ await opts.shared.sweep(new Set([...state.live, ...state.reserved]));
14450
+ } catch (err) {
14451
+ log(`sweep: shared-hostname sweep failed: ${err.message}`);
14452
+ }
14453
+ }
13675
14454
  for (const uuid of listAdopted()) {
13676
14455
  try {
13677
14456
  const pf = connectorPidfile(uuid);
@@ -13729,6 +14508,7 @@ async function main() {
13729
14508
  const engine2 = new ComposeEngine;
13730
14509
  const localRouter = new HestiaLocalHttpRouter;
13731
14510
  const routerPort = await localRouter.start();
14511
+ const shared = new SharedArbiter(() => localRouter.refreshRoutes());
13732
14512
  const token = randomBytes(32).toString("hex");
13733
14513
  const server = serveSessionBrokerDaemon({
13734
14514
  daemon,
@@ -13739,6 +14519,7 @@ async function main() {
13739
14519
  fleet,
13740
14520
  routerPort,
13741
14521
  gatewaySocket: localRouter.socketPath,
14522
+ shared,
13742
14523
  refreshLocalRoutes: () => localRouter.refreshRoutes(),
13743
14524
  logsProject: (project, options) => engine2.logsProject(project, options)
13744
14525
  })
@@ -13755,11 +14536,11 @@ async function main() {
13755
14536
  port: "none",
13756
14537
  backend: "proc"
13757
14538
  }),
13758
- logPath: join23(root, "daemon.log"),
14539
+ logPath: join24(root, "daemon.log"),
13759
14540
  signal: "term",
13760
14541
  backend: "proc"
13761
14542
  });
13762
- writeAtomicJsonFile(join23(root, "daemon.json"), {
14543
+ writeAtomicJsonFile(join24(root, "daemon.json"), {
13763
14544
  schemaVersion: STATE_SCHEMA_VERSION,
13764
14545
  pid: process.pid,
13765
14546
  port: server.port,
@@ -13769,13 +14550,13 @@ async function main() {
13769
14550
  routerPort,
13770
14551
  gatewaySocket: localRouter.socketPath
13771
14552
  }, { mode: 384 });
13772
- const stopDuties = startDuties(admission);
14553
+ const stopDuties = startDuties(admission, { shared });
13773
14554
  const shutdown = () => {
13774
14555
  stopDuties();
13775
14556
  fleet.stop();
13776
14557
  localRouter.stop();
13777
14558
  removePidfile(root, PIDFILE_NAME2);
13778
- rmSync10(join23(root, "daemon.json"), { force: true });
14559
+ rmSync11(join24(root, "daemon.json"), { force: true });
13779
14560
  server.stop(true);
13780
14561
  process.exit(0);
13781
14562
  };
@@ -13790,4 +14571,4 @@ async function main() {
13790
14571
  }
13791
14572
  main();
13792
14573
 
13793
- //# debugId=855573FAB285270F64756E2164756E21
14574
+ //# debugId=9176C6E8002FF49E64756E2164756E21