@hamedb89/localghost 0.5.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -352,1236 +352,1580 @@ function formatLocalghostAgentGuide(format = "text") {
352
352
  return LOCALGHOST_AGENT_GUIDE;
353
353
  }
354
354
 
355
- // src/ghost-file.ts
356
- var LOCALGHOST_GHOST_TUNNEL_FILE = ".ghosttunnel";
357
- function toGhostTunnelOptions(options = {}) {
358
- const resolved = typeof options === "string" ? { cwd: options } : options;
359
- return {
360
- ...resolved,
361
- fileName: resolved.fileName ?? LOCALGHOST_GHOST_TUNNEL_FILE
362
- };
355
+ // packages/ghost-tunnel/src/tunnel.ts
356
+ import { domainToASCII } from "url";
357
+ var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
358
+ var DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS = ["route", "project", "owner"];
359
+ var DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR = "-";
360
+ var DEFAULT_GHOST_TUNNEL_MODE = "manual";
361
+ var DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY = "same-project";
362
+ var DEFAULT_GHOST_TUNNEL_TRANSPORT_KIND = "none";
363
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_PROVIDER = "vercel-redis";
364
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_ENV = "auto";
365
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_NAMESPACE = "localghost";
366
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_WAIT_MS = 25e3;
367
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_POLL_INTERVAL_MS = 250;
368
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_ROUTE_TTL_SECONDS = 30;
369
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_REQUEST_TTL_SECONDS = 60;
370
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
371
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024;
372
+ function isResolvedGhostTunnelConfig(value) {
373
+ return typeof value === "object" && value !== null && "enabled" in value;
363
374
  }
364
- function normalizeHost(value) {
365
- return value.trim().toLowerCase().replace(/\.$/, "");
375
+ function toGhostTunnelConfig(options) {
376
+ return isResolvedGhostTunnelConfig(options) ? options : resolveGhostTunnelConfig(options);
366
377
  }
367
- function resolveGhostTunnelPath(options = {}) {
368
- return resolveDevHostsPath(toGhostTunnelOptions(options));
378
+ function stripHostPort(value) {
379
+ const trimmed = value.trim().toLowerCase();
380
+ if (trimmed.startsWith("[") || trimmed.includes("/")) return "";
381
+ const portSeparator = trimmed.lastIndexOf(":");
382
+ if (portSeparator === -1) return trimmed;
383
+ const port = trimmed.slice(portSeparator + 1);
384
+ return /^\d+$/.test(port) ? trimmed.slice(0, portSeparator) : trimmed;
369
385
  }
370
- function getGhostTunnelPath(options = {}) {
371
- return resolveGhostTunnelPath(options).path;
386
+ function normalizeDomain(value) {
387
+ const host = stripHostPort(value.replace(/^\*\./, ""));
388
+ const ascii = domainToASCII(host);
389
+ if (!ascii || ascii.length > 253 || ascii.includes("..")) return null;
390
+ if (ascii.startsWith(".") || ascii.endsWith(".")) return null;
391
+ if (ascii.includes("*")) return null;
392
+ if (!ascii.split(".").every(isValidHostLabel)) return null;
393
+ return ascii;
372
394
  }
373
- function readGhostTunnelEntries(options = {}) {
374
- return readDevHosts(toGhostTunnelOptions(options));
395
+ function isValidHostLabel(value) {
396
+ return value.length > 0 && value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
375
397
  }
376
- function listGhostTunnelEntries(options = {}) {
377
- const resolved = resolveGhostTunnelPath(options);
378
- if (!resolved.exists) return [];
379
- return readGhostTunnelEntries(options);
398
+ function isValidNamespaceTag(value) {
399
+ return /^[a-z][a-z0-9_]*$/i.test(value);
380
400
  }
381
- function findGhostTunnelEntry(host, options = {}) {
382
- const normalizedHost = normalizeHost(host);
383
- return listGhostTunnelEntries(options).find((entry) => entry.host === normalizedHost);
401
+ function isNamespaceTagList(options) {
402
+ return Array.isArray(options);
384
403
  }
385
-
386
- // src/ghost-agent.ts
387
- import { randomUUID as randomUUID2 } from "crypto";
388
-
389
- // src/relay.ts
390
- import { createHmac, timingSafeEqual } from "crypto";
391
- import { domainToASCII } from "url";
392
- var DEFAULT_RELAY_ALLOWED_TARGET_HOSTS = ["localhost", "127.0.0.1", "::1"];
393
- var DEFAULT_RELAY_BLOCKED_PORTS = [22, 2375, 2376, 5432, 6379, 9200, 9229, 27017];
394
- var DEFAULT_RELAY_LIMITS = {
395
- requestBodyBytes: 5 * 1024 * 1024,
396
- responseBytes: 25 * 1024 * 1024,
397
- timeoutMs: 3e4,
398
- maxConcurrentRequests: 20,
399
- perRouteRequestsPerMinute: 120,
400
- perIpRequestsPerMinute: 60
401
- };
402
- var DEFAULT_RELAY_TARGET_POLICY = {
403
- allowedHosts: [...DEFAULT_RELAY_ALLOWED_TARGET_HOSTS],
404
- blockedPorts: [...DEFAULT_RELAY_BLOCKED_PORTS],
405
- allowPrivateNetworkTargets: false
406
- };
407
- var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
408
- "connection",
409
- "keep-alive",
410
- "proxy-authenticate",
411
- "proxy-authorization",
412
- "te",
413
- "trailer",
414
- "transfer-encoding",
415
- "upgrade"
416
- ]);
417
- var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "set-cookie"]);
418
- var TOKEN_QUERY_PATTERN = /(token|secret|key|password|session|jwt|auth)/i;
419
- var HOST_PATTERN2 = /^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*$/i;
420
- var IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/;
421
- function base64UrlEncode(value) {
422
- return Buffer.from(value).toString("base64url");
404
+ function assertValidSubdomain(value) {
405
+ if (!isValidHostLabel(value)) {
406
+ throw new Error(`Invalid ghost tunnel subdomain: ${value}`);
407
+ }
423
408
  }
424
- function base64UrlDecode(value) {
425
- return Buffer.from(value, "base64url").toString("utf8");
409
+ function normalizeDomains(domains) {
410
+ const values = typeof domains === "string" ? [domains] : [...domains ?? []];
411
+ const normalized = values.map((value) => value.trim()).filter(Boolean).map((value) => {
412
+ const domain = normalizeDomain(value);
413
+ if (!domain) throw new Error(`Invalid ghost tunnel domain: ${value}`);
414
+ return domain;
415
+ });
416
+ return [...new Set(normalized)];
426
417
  }
427
- function signPayload(payload, secret) {
428
- return createHmac("sha256", secret).update(payload).digest("base64url");
418
+ function parseGhostTunnelMode(value) {
419
+ return value ?? DEFAULT_GHOST_TUNNEL_MODE;
429
420
  }
430
- function secureEqual(left, right) {
431
- const leftBuffer = Buffer.from(left);
432
- const rightBuffer = Buffer.from(right);
433
- return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
421
+ function parseGhostTunnelAdapterStrategy(value) {
422
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY;
423
+ if (value === "same-project" || value === "separate-relay") return value;
424
+ throw new Error(`Unsupported ghost tunnel adapter strategy: ${String(value)}`);
434
425
  }
435
- function normalizeHost2(host) {
436
- const trimmed = host.trim().toLowerCase().replace(/\.$/, "");
437
- if (!trimmed || trimmed.includes("*") || trimmed.includes("/") || trimmed.includes(":")) return null;
438
- const ascii = domainToASCII(trimmed);
439
- if (!ascii || ascii.includes("..")) return null;
440
- return HOST_PATTERN2.test(ascii) ? ascii : null;
426
+ function parseGhostTunnelTransportKind(value) {
427
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TRANSPORT_KIND;
428
+ if (value === "none" || value === "ip" || value === "tunnel") return value;
429
+ throw new Error(`Unsupported ghost tunnel transport: ${String(value)}`);
441
430
  }
442
- function normalizeTargetHost(host) {
443
- const trimmed = host.trim().toLowerCase();
444
- if (trimmed === "::1" || trimmed === "[::1]") return "::1";
445
- if (trimmed.includes("/") || trimmed.includes("*")) return null;
446
- if (IPV4_PATTERN.test(trimmed)) return isValidIpv4(trimmed) ? trimmed : null;
447
- return normalizeHost2(trimmed);
431
+ function parsePositiveInteger(value, fallback, name) {
432
+ if (typeof value === "undefined") return fallback;
433
+ if (!Number.isInteger(value) || value < 1) {
434
+ throw new Error(`Invalid ghost tunnel ${name}: ${value}`);
435
+ }
436
+ return value;
448
437
  }
449
- function isValidIpv4(value) {
450
- return value.split(".").every((part) => {
451
- const octet = Number(part);
452
- return Number.isInteger(octet) && octet >= 0 && octet <= 255 && String(octet) === part;
453
- });
438
+ function parseTunnelStoreProvider(value) {
439
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_PROVIDER;
440
+ if (value === "vercel-redis" || value === "redis") return value;
441
+ throw new Error(`Unsupported ghost tunnel tunnel store provider: ${String(value)}`);
454
442
  }
455
- function isPrivateIpv4(value) {
456
- if (!isValidIpv4(value)) return false;
457
- const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
458
- return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
443
+ function parseTunnelStoreEnv(value) {
444
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_ENV;
445
+ if (value === "auto") return value;
446
+ throw new Error(`Unsupported ghost tunnel tunnel store env: ${String(value)}`);
459
447
  }
460
- function isLocalTargetHost(host) {
461
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
448
+ function parseTunnelStoreNamespace(value) {
449
+ const namespace = value ?? DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_NAMESPACE;
450
+ if (!/^[a-z][a-z0-9:_-]{0,63}$/i.test(namespace)) {
451
+ throw new Error(`Invalid ghost tunnel tunnel store namespace: ${namespace}`);
452
+ }
453
+ return namespace;
462
454
  }
463
- function mergeTargetPolicy(policy) {
455
+ function resolveGhostTunnelAdapter(input) {
456
+ if (!input) return void 0;
457
+ const provider = typeof input === "string" ? input : input.provider;
458
+ if (provider !== "vercel") {
459
+ throw new Error(`Unsupported ghost tunnel adapter provider: ${String(provider)}`);
460
+ }
464
461
  return {
465
- allowedHosts: policy?.allowedHosts ?? DEFAULT_RELAY_TARGET_POLICY.allowedHosts,
466
- blockedPorts: policy?.blockedPorts ?? DEFAULT_RELAY_TARGET_POLICY.blockedPorts,
467
- allowPrivateNetworkTargets: policy?.allowPrivateNetworkTargets ?? DEFAULT_RELAY_TARGET_POLICY.allowPrivateNetworkTargets
462
+ provider,
463
+ strategy: typeof input === "string" ? DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY : parseGhostTunnelAdapterStrategy(input.strategy)
468
464
  };
469
465
  }
470
- function mergeLimits(limits) {
471
- const merged = {
472
- ...DEFAULT_RELAY_LIMITS,
473
- ...limits ?? {}
474
- };
475
- for (const [key, value] of Object.entries(merged)) {
476
- if (!Number.isInteger(value) || value < 1) {
477
- throw new Error(`Invalid relay limit ${key}: ${value}`);
478
- }
479
- }
480
- return merged;
466
+ function getLegacyGhostTunnelTransport(input) {
467
+ if (!input || typeof input === "string" || !("transport" in input)) return void 0;
468
+ return input.transport;
481
469
  }
482
- function assertExactRelayHost(host) {
483
- const normalized = normalizeHost2(host);
484
- if (!normalized) {
485
- throw new Error(`Relay route claims must use an exact hostname: ${host}`);
470
+ function resolveGhostTunnelTransport(input) {
471
+ if (!input) {
472
+ return { kind: "none" };
486
473
  }
487
- return normalized;
488
- }
489
- function assertRelayLocalTarget(target, policyInput) {
490
- if (!target || typeof target !== "object") {
491
- throw new Error("Relay target must be an explicit local target object.");
474
+ const kind = typeof input === "string" ? parseGhostTunnelTransportKind(input) : parseGhostTunnelTransportKind(input.kind);
475
+ if (kind === "ip") {
476
+ return {
477
+ kind,
478
+ allowPrivateNetworkAddress: typeof input === "string" ? false : input.kind === "ip" ? input.allowPrivateNetworkAddress ?? false : false
479
+ };
492
480
  }
493
- const policy = mergeTargetPolicy(policyInput);
494
- const host = normalizeTargetHost(target.host);
495
- if (!host) {
496
- throw new Error(`Invalid relay target host: ${target.host}`);
481
+ if (kind === "tunnel") {
482
+ const config = typeof input === "string" || input.kind !== "tunnel" ? void 0 : input;
483
+ const store = config?.store ?? {};
484
+ return {
485
+ kind,
486
+ store: {
487
+ provider: parseTunnelStoreProvider(store.provider),
488
+ env: parseTunnelStoreEnv(store.env),
489
+ namespace: parseTunnelStoreNamespace(store.namespace)
490
+ },
491
+ waitMs: parsePositiveInteger(config?.waitMs, DEFAULT_GHOST_TUNNEL_TUNNEL_WAIT_MS, "tunnel waitMs"),
492
+ pollIntervalMs: parsePositiveInteger(config?.pollIntervalMs, DEFAULT_GHOST_TUNNEL_TUNNEL_POLL_INTERVAL_MS, "tunnel pollIntervalMs"),
493
+ routeTtlSeconds: parsePositiveInteger(config?.routeTtlSeconds, DEFAULT_GHOST_TUNNEL_TUNNEL_ROUTE_TTL_SECONDS, "tunnel routeTtlSeconds"),
494
+ requestTtlSeconds: parsePositiveInteger(config?.requestTtlSeconds, DEFAULT_GHOST_TUNNEL_TUNNEL_REQUEST_TTL_SECONDS, "tunnel requestTtlSeconds"),
495
+ maxRequestBodyBytes: parsePositiveInteger(config?.maxRequestBodyBytes, DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_REQUEST_BODY_BYTES, "tunnel maxRequestBodyBytes"),
496
+ maxResponseBodyBytes: parsePositiveInteger(config?.maxResponseBodyBytes, DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_RESPONSE_BODY_BYTES, "tunnel maxResponseBodyBytes")
497
+ };
497
498
  }
498
- if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
499
- throw new Error(`Invalid relay target port: ${target.port}`);
499
+ return { kind: "none" };
500
+ }
501
+ function resolveNamespaceConfig(options) {
502
+ const tags = isNamespaceTagList(options) ? [...options] : [...options?.tags ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS];
503
+ let separator = DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
504
+ let spreadTag = tags.includes("project") ? "project" : void 0;
505
+ if (options && !isNamespaceTagList(options)) {
506
+ separator = options.separator ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
507
+ spreadTag = options.spreadTag === false ? false : options.spreadTag ?? spreadTag;
500
508
  }
501
- const protocol = target.protocol ?? "http";
502
- if (protocol !== "http" && protocol !== "https") {
503
- throw new Error(`Invalid relay target protocol: ${String(protocol)}`);
509
+ if (tags.length === 0) {
510
+ throw new Error("Ghost tunnel namespace must include at least one tag.");
504
511
  }
505
- if (policy.blockedPorts.includes(target.port)) {
506
- throw new Error(`Relay target port is blocked: ${target.port}`);
512
+ for (const tag of tags) {
513
+ if (!isValidNamespaceTag(tag)) {
514
+ throw new Error(`Invalid ghost tunnel namespace tag: ${tag}`);
515
+ }
507
516
  }
508
- const allowedHosts = new Set(policy.allowedHosts.map((allowedHost) => normalizeTargetHost(allowedHost)).filter((value) => Boolean(value)));
509
- if (!allowedHosts.has(host)) {
510
- throw new Error(`Relay target host is not explicitly allowed: ${host}`);
517
+ if (spreadTag && !tags.includes(spreadTag)) {
518
+ throw new Error(`Ghost tunnel namespace spreadTag must be listed in tags: ${spreadTag}`);
511
519
  }
512
- if (!policy.allowPrivateNetworkTargets && !isLocalTargetHost(host) && (host === "::1" || isPrivateIpv4(host))) {
513
- throw new Error(`Private-network relay target requires explicit opt-in: ${host}`);
520
+ if (!separator || separator.length > 8 || !/^[a-z0-9-]+$/.test(separator)) {
521
+ throw new Error(`Invalid ghost tunnel namespace separator: ${separator}`);
514
522
  }
515
523
  return {
516
- protocol,
517
- host,
518
- port: target.port
519
- };
520
- }
521
- function authenticateRelayAgentToken(input) {
522
- const expected = `Bearer ${input.agentToken}`;
523
- return typeof input.authorizationHeader === "string" && secureEqual(input.authorizationHeader, expected);
524
- }
525
- function signRelayRouteClaim(claim, signingSecret) {
526
- const payload = {
527
- ...claim,
528
- host: assertExactRelayHost(claim.host)
529
- };
530
- if (!payload.scope) throw new Error("Relay route claim requires a scope.");
531
- if (!payload.agentId) throw new Error("Relay route claim requires an agentId.");
532
- if (Number.isNaN(Date.parse(payload.expiresAt))) throw new Error("Relay route claim requires a valid expiresAt.");
533
- const encodedPayload = base64UrlEncode(JSON.stringify(payload));
534
- const signature = signPayload(encodedPayload, signingSecret);
535
- return {
536
- payload,
537
- token: `${encodedPayload}.${signature}`
524
+ tags,
525
+ separator,
526
+ ...spreadTag ? { spreadTag } : {}
538
527
  };
539
528
  }
540
- function verifyRelayRouteClaim(token, signingSecret, options) {
541
- const [encodedPayload, signature] = token.split(".");
542
- if (!encodedPayload || !signature || token.split(".").length !== 2) {
543
- throw new Error("Invalid relay route claim token.");
544
- }
545
- const expectedSignature = signPayload(encodedPayload, signingSecret);
546
- if (!secureEqual(signature, expectedSignature)) {
547
- throw new Error("Invalid relay route claim signature.");
548
- }
549
- const parsed = JSON.parse(base64UrlDecode(encodedPayload));
550
- const host = assertExactRelayHost(parsed.host);
551
- if (parsed.scope !== options.expectedScope) {
552
- throw new Error("Relay route claim scope mismatch.");
553
- }
554
- const now = options.now ?? /* @__PURE__ */ new Date();
555
- if (Date.parse(parsed.expiresAt) <= now.getTime()) {
556
- throw new Error("Relay route claim has expired.");
529
+ function normalizeNamespaceValue(tag, value, separator, options = {}) {
530
+ const normalized = normalizeDomain(value);
531
+ if (!normalized || normalized.includes(".")) {
532
+ throw new Error(`Invalid ghost tunnel namespace value for ${tag}: ${value}`);
557
533
  }
558
- if (!parsed.agentId) {
559
- throw new Error("Relay route claim requires an agentId.");
534
+ if (!options.allowSeparator && normalized.includes(separator)) {
535
+ throw new Error(`Ghost tunnel namespace value for ${tag} cannot include separator "${separator}": ${value}`);
560
536
  }
561
- return { ...parsed, host };
537
+ return normalized;
562
538
  }
563
- function createRelayRouteRegistration(input) {
564
- if (!authenticateRelayAgentToken({
565
- agentToken: input.agentToken,
566
- ...typeof input.authorizationHeader !== "undefined" ? { authorizationHeader: input.authorizationHeader } : {}
567
- })) {
568
- throw new Error("Relay route registration requires an authenticated local agent.");
569
- }
570
- const claim = verifyRelayRouteClaim(input.claimToken, input.signingSecret, {
571
- expectedScope: input.expectedScope,
572
- ...input.now ? { now: input.now } : {}
539
+ function createNamespaceSlug(config, values) {
540
+ const parts = config.tags.map((tag) => {
541
+ const value = values[tag];
542
+ if (!value) {
543
+ throw new Error(`Missing ghost tunnel namespace value: ${tag}`);
544
+ }
545
+ return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
573
546
  });
574
- const target = assertRelayLocalTarget(input.target, input.targetPolicy);
575
- const access = input.publicMode === true ? "public" : input.access ?? "private";
576
- const passwordProtected = input.passwordProtected ?? false;
577
- const authRequired = input.authRequired ?? false;
578
- if (access === "public" && input.publicMode !== true) {
579
- throw new Error("Relay public mode must be explicitly enabled.");
580
- }
581
- if (access === "private" && !passwordProtected && !authRequired) {
582
- throw new Error("Private relay previews require password or auth.");
583
- }
584
- return {
585
- host: claim.host,
586
- scope: claim.scope,
587
- agentId: claim.agentId,
588
- expiresAt: claim.expiresAt,
589
- target,
590
- access,
591
- passwordProtected,
592
- authRequired,
593
- limits: mergeLimits(input.limits)
594
- };
595
- }
596
- function isRelayRouteActive(route, options) {
597
- if (!options.agentConnected) return false;
598
- return Date.parse(route.expiresAt) > (options.now ?? /* @__PURE__ */ new Date()).getTime();
599
- }
600
- function stripRelayForwardHeaders(headers) {
601
- const stripped = {};
602
- for (const [name, value] of Object.entries(headers)) {
603
- if (typeof value === "undefined") continue;
604
- const lowerName = name.toLowerCase();
605
- if (HOP_BY_HOP_HEADERS.has(lowerName)) continue;
606
- if (lowerName.startsWith("x-localghost-")) continue;
607
- stripped[name] = value;
608
- }
609
- return stripped;
610
- }
611
- function redactRelayHeaders(headers) {
612
- const redacted = {};
613
- for (const [name, value] of Object.entries(headers)) {
614
- if (typeof value === "undefined") continue;
615
- redacted[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? "[redacted]" : value;
547
+ const slug = parts.join(config.separator);
548
+ if (!isValidHostLabel(slug)) {
549
+ throw new Error(`Ghost tunnel namespace is too long for a DNS label: ${slug}`);
616
550
  }
617
- return redacted;
551
+ return slug;
618
552
  }
619
- function redactRelayLogUrl(input) {
620
- const url = new URL(input, "http://localghost.invalid");
621
- for (const key of [...url.searchParams.keys()]) {
622
- if (TOKEN_QUERY_PATTERN.test(key)) {
623
- url.searchParams.set(key, "[redacted]");
553
+ function createNamespaceDisplaySlug(config, values = {}) {
554
+ return config.tags.map((tag) => {
555
+ const value = values[tag];
556
+ if (!value) return `<${tag}>`;
557
+ try {
558
+ return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
559
+ } catch {
560
+ return `<${tag}>`;
624
561
  }
625
- }
626
- return input.startsWith("http://") || input.startsWith("https://") ? url.toString() : `${url.pathname}${url.search}`;
562
+ }).join(config.separator);
627
563
  }
628
- function renderRelayOfflineResponse() {
564
+ function getPreviewDefaults(preview, defaults) {
629
565
  return {
630
- status: 503,
631
- headers: {
632
- "content-type": "text/html; charset=utf-8",
633
- "cache-control": "no-store"
566
+ domain: preview?.domain ?? defaults?.domain,
567
+ route: preview?.route ?? defaults?.route,
568
+ project: preview?.project ?? defaults?.project,
569
+ owner: preview?.owner ?? defaults?.owner,
570
+ values: {
571
+ ...defaults?.values ?? {},
572
+ ...preview?.values ?? {}
634
573
  },
635
- body: [
636
- "<!doctype html>",
637
- "<html>",
638
- '<head><meta charset="utf-8"><title>Preview offline</title></head>',
639
- "<body><h1>Preview offline</h1><p>The local agent is not connected. Try again later.</p></body>",
640
- "</html>"
641
- ].join("")
574
+ path: preview?.path,
575
+ protocol: preview?.protocol
642
576
  };
643
577
  }
644
-
645
- // src/ghost-tunnel-store.ts
646
- import { randomUUID } from "crypto";
647
- var DEFAULT_GHOST_TUNNEL_RESPONSE_TTL_SECONDS = 60;
648
- function base64Encode(value) {
649
- return value.toString("base64");
650
- }
651
- function encodeGhostTunnelBody(value) {
652
- return base64Encode(Buffer.isBuffer(value) ? value : Buffer.from(value));
653
- }
654
- function decodeGhostTunnelBody(value) {
655
- return value ? Buffer.from(value, "base64") : void 0;
656
- }
657
- function createGhostTunnelQueuedRequest(input) {
658
- const now = input.now ?? /* @__PURE__ */ new Date();
659
- const bodyBase64 = typeof input.body === "undefined" ? void 0 : encodeGhostTunnelBody(input.body);
578
+ function getDisplayValues(input) {
660
579
  return {
661
- id: randomUUID(),
662
- host: input.host,
663
- method: input.method.toUpperCase(),
664
- path: input.path,
665
- headers: input.headers ?? {},
666
- createdAt: now.toISOString(),
667
- expiresAt: new Date(now.getTime() + input.ttlSeconds * 1e3).toISOString(),
668
- ...bodyBase64 ? { bodyBase64 } : {}
580
+ ...input.route ? { route: input.route } : {},
581
+ ...input.project ? { project: input.project } : {},
582
+ ...input.owner ? { owner: input.owner } : {},
583
+ ...input.values
669
584
  };
670
585
  }
671
- function createGhostTunnelRouteHeartbeat(input) {
672
- const now = input.now ?? /* @__PURE__ */ new Date();
673
- return {
674
- host: input.host,
675
- agentId: input.agentId,
676
- target: input.target,
677
- updatedAt: now.toISOString(),
678
- expiresAt: new Date(now.getTime() + input.ttlSeconds * 1e3).toISOString()
679
- };
586
+ function getDisplayDefaults(defaults) {
587
+ return defaults;
680
588
  }
681
- function isExpired(expiresAt, now = /* @__PURE__ */ new Date()) {
682
- const timestamp = Date.parse(expiresAt);
683
- return Number.isNaN(timestamp) || timestamp <= now.getTime();
589
+ function createDisplayUrl(config, defaults, domain) {
590
+ const input = getPreviewDefaults(config.preview, getDisplayDefaults(defaults));
591
+ const protocol = input.protocol ?? "https";
592
+ const slug = createNamespaceDisplaySlug(config.namespace, getDisplayValues(input));
593
+ const entryHost = domain ? getGhostTunnelEntryHost(domain, config) : input.domain ? getGhostTunnelEntryHost(input.domain, config) : `${config.subdomain}.*`;
594
+ const url = `${protocol}://${slug}.${entryHost}/`;
595
+ if (!input.path) return url;
596
+ return `${url}${input.path.replace(/^\/+/, "")}`;
684
597
  }
685
- function serializeJson(value) {
686
- return JSON.stringify(value);
598
+ function createDisplayUrls(config, defaults) {
599
+ const displayDefaults = getDisplayDefaults(defaults);
600
+ const domains = config.domains.length > 0 ? config.domains : displayDefaults?.domain ? [displayDefaults.domain] : [];
601
+ const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, displayDefaults, domain)) : [createDisplayUrl(config, displayDefaults)];
602
+ return [...new Set(urls)];
687
603
  }
688
- function parseJson(value) {
689
- if (typeof value !== "string") return null;
690
- try {
691
- return JSON.parse(value);
692
- } catch {
693
- return null;
694
- }
604
+ function maybeConstructPreviewUrl(config, defaults) {
605
+ if (!config.preview) return void 0;
606
+ const input = getPreviewDefaults(config.preview, defaults);
607
+ if (!input.domain || !input.route || !input.project || !input.owner) return void 0;
608
+ return constructGhostTunnelUrl({
609
+ domain: input.domain,
610
+ route: input.route,
611
+ project: input.project,
612
+ owner: input.owner,
613
+ values: input.values,
614
+ ...input.path ? { path: input.path } : {},
615
+ ...input.protocol ? { protocol: input.protocol } : {},
616
+ ghostTunnel: config
617
+ });
695
618
  }
696
- function keyPart(value) {
697
- return value.toLowerCase().replace(/[^a-z0-9._:-]/g, "_");
619
+ function parseNamespaceSlug(slug, config) {
620
+ const parts = slug.split(config.separator);
621
+ if (parts.length < config.tags.length) return null;
622
+ if (parts.length !== config.tags.length && !config.spreadTag) return null;
623
+ const namespace = {};
624
+ const spreadIndex = config.spreadTag ? config.tags.indexOf(config.spreadTag) : -1;
625
+ const spreadWidth = spreadIndex >= 0 ? parts.length - config.tags.length + 1 : 1;
626
+ let partIndex = 0;
627
+ for (const [tagIndex, tag] of config.tags.entries()) {
628
+ const value = tagIndex === spreadIndex ? parts.slice(partIndex, partIndex + spreadWidth).join(config.separator) : parts[partIndex];
629
+ if (!value || !isValidHostLabel(value)) return null;
630
+ if (tagIndex !== spreadIndex && value.includes(config.separator)) return null;
631
+ namespace[tag] = value;
632
+ partIndex += tagIndex === spreadIndex ? spreadWidth : 1;
633
+ }
634
+ return namespace;
698
635
  }
699
- function removeTrailingSlashes(value) {
700
- let end = value.length;
701
- while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
702
- return value.slice(0, end);
703
- }
704
- var MemoryGhostTunnelStore = class {
705
- routes = /* @__PURE__ */ new Map();
706
- queues = /* @__PURE__ */ new Map();
707
- responses = /* @__PURE__ */ new Map();
708
- async heartbeatRoute(route) {
709
- this.routes.set(route.host, route);
710
- }
711
- async getRoute(host) {
712
- const route = this.routes.get(host);
713
- if (!route) return null;
714
- if (!isExpired(route.expiresAt)) return route;
715
- this.routes.delete(host);
716
- return null;
717
- }
718
- async enqueueRequest(request) {
719
- const queue = this.queues.get(request.host) ?? [];
720
- queue.push(request);
721
- this.queues.set(request.host, queue);
722
- }
723
- async claimRequest(host) {
724
- const queue = this.queues.get(host) ?? [];
725
- while (queue.length > 0) {
726
- const request = queue.shift();
727
- if (request && !isExpired(request.expiresAt)) {
728
- return request;
729
- }
730
- }
731
- return null;
732
- }
733
- async writeResponse(response, ttlSeconds) {
734
- this.responses.set(response.id, {
735
- value: response,
736
- expiresAt: new Date(Date.now() + ttlSeconds * 1e3).toISOString()
737
- });
738
- }
739
- async readResponse(requestId) {
740
- const response = this.responses.get(requestId);
741
- if (!response) return null;
742
- if (!isExpired(response.expiresAt)) return response.value;
743
- this.responses.delete(requestId);
744
- return null;
636
+ function resolveGhostTunnelConfig(options, defaults) {
637
+ if (options === false || typeof options === "undefined") {
638
+ return {
639
+ enabled: false,
640
+ mode: DEFAULT_GHOST_TUNNEL_MODE,
641
+ domains: [],
642
+ subdomain: DEFAULT_GHOST_TUNNEL_SUBDOMAIN,
643
+ namespace: resolveNamespaceConfig(void 0),
644
+ displayUrls: [],
645
+ requireHttps: true,
646
+ requireAuth: true,
647
+ transport: resolveGhostTunnelTransport(void 0)
648
+ };
745
649
  }
746
- async cleanup(requestId) {
747
- this.responses.delete(requestId);
650
+ const config = typeof options === "string" ? { mode: options } : options;
651
+ const subdomain = config.subdomain ?? DEFAULT_GHOST_TUNNEL_SUBDOMAIN;
652
+ assertValidSubdomain(subdomain);
653
+ const domains = normalizeDomains(config.domains);
654
+ const enabled = config.enabled ?? true;
655
+ const adapter = resolveGhostTunnelAdapter(config.adapter);
656
+ const transport = resolveGhostTunnelTransport(config.transport ?? getLegacyGhostTunnelTransport(config.adapter));
657
+ const resolved = {
658
+ enabled,
659
+ mode: parseGhostTunnelMode(config.mode),
660
+ domains,
661
+ subdomain,
662
+ namespace: resolveNamespaceConfig(config.namespace),
663
+ ...config.preview ? { preview: config.preview } : {},
664
+ displayUrls: [],
665
+ requireHttps: config.requireHttps ?? true,
666
+ requireAuth: config.requireAuth ?? true,
667
+ transport,
668
+ ...adapter ? { adapter } : {}
669
+ };
670
+ if (!enabled) {
671
+ return resolved;
748
672
  }
749
- };
750
- function createMemoryGhostTunnelStore() {
751
- return new MemoryGhostTunnelStore();
673
+ const previewUrl = maybeConstructPreviewUrl(resolved, defaults);
674
+ const displayUrls = previewUrl ? [previewUrl] : createDisplayUrls(resolved, defaults);
675
+ return {
676
+ ...resolved,
677
+ displayUrls,
678
+ ...displayUrls[0] ? { displayUrl: displayUrls[0] } : {},
679
+ ...previewUrl ? { previewUrl } : {}
680
+ };
752
681
  }
753
- var RedisGhostTunnelStore = class {
754
- url;
755
- token;
756
- namespace;
757
- fetchImpl;
758
- constructor(options) {
759
- this.url = removeTrailingSlashes(options.url);
760
- this.token = options.token;
761
- this.namespace = options.namespace ?? "localghost";
762
- this.fetchImpl = options.fetch ?? fetch;
763
- }
764
- key(kind, id) {
765
- return `${this.namespace}:ghost-tunnel:${kind}:${keyPart(id)}`;
766
- }
767
- async command(command, ...args) {
768
- const response = await this.fetchImpl(this.url, {
769
- method: "POST",
770
- headers: {
771
- authorization: `Bearer ${this.token}`,
772
- "content-type": "application/json"
773
- },
774
- body: JSON.stringify([command, ...args])
775
- });
776
- if (!response.ok) {
777
- throw new Error(`Redis Ghost Tunnel command failed: ${response.status} ${response.statusText}`);
778
- }
779
- const payload = await response.json();
780
- if (payload.error) {
781
- throw new Error(`Redis Ghost Tunnel command failed: ${payload.error}`);
782
- }
783
- return typeof payload.result === "undefined" ? null : payload.result;
784
- }
785
- async heartbeatRoute(route, ttlSeconds) {
786
- await this.command("SET", this.key("route", route.host), serializeJson(route), "EX", ttlSeconds);
682
+ function getGhostTunnelEntryHost(domain, options = {}) {
683
+ const config = toGhostTunnelConfig(options);
684
+ const normalizedDomain = normalizeDomain(domain);
685
+ if (!normalizedDomain) {
686
+ throw new Error(`Invalid ghost tunnel domain: ${domain}`);
787
687
  }
788
- async getRoute(host) {
789
- const route = parseJson(await this.command("GET", this.key("route", host)));
790
- return route && !isExpired(route.expiresAt) ? route : null;
688
+ return `${config.subdomain}.${normalizedDomain}`;
689
+ }
690
+ function getGhostTunnelWildcardHost(domain, options = {}) {
691
+ return `*.${getGhostTunnelEntryHost(domain, options)}`;
692
+ }
693
+ function constructGhostTunnelHost(input) {
694
+ const config = toGhostTunnelConfig(input.ghostTunnel ?? {});
695
+ if (!config.enabled) {
696
+ throw new Error("Ghost tunnel is not enabled.");
791
697
  }
792
- async enqueueRequest(request, ttlSeconds) {
793
- const queueKey = this.key("queue", request.host);
794
- await this.command("RPUSH", queueKey, serializeJson(request));
795
- await this.command("EXPIRE", queueKey, ttlSeconds);
698
+ const namespaceValues = {
699
+ route: input.route,
700
+ project: input.project,
701
+ owner: input.owner,
702
+ ...input.values ?? {}
703
+ };
704
+ const slug = createNamespaceSlug(config.namespace, namespaceValues);
705
+ return `${slug}.${getGhostTunnelEntryHost(input.domain, config)}`;
706
+ }
707
+ function constructGhostTunnelUrl(input) {
708
+ const protocol = input.protocol ?? "https";
709
+ const host = constructGhostTunnelHost(input);
710
+ const url = new URL(`${protocol}://${host}/`);
711
+ if (input.path) {
712
+ url.pathname = `/${input.path.replace(/^\/+/, "")}`;
796
713
  }
797
- async claimRequest(host) {
798
- const queueKey = this.key("queue", host);
799
- while (true) {
800
- const request = parseJson(await this.command("LPOP", queueKey));
801
- if (!request) return null;
802
- if (!isExpired(request.expiresAt)) return request;
714
+ if (input.searchParams instanceof URLSearchParams) {
715
+ url.search = input.searchParams.toString();
716
+ } else if (input.searchParams) {
717
+ for (const [key, value] of Object.entries(input.searchParams)) {
718
+ if (typeof value !== "undefined" && value !== null) {
719
+ url.searchParams.set(key, String(value));
720
+ }
803
721
  }
804
722
  }
805
- async writeResponse(response, ttlSeconds) {
806
- await this.command("SET", this.key("response", response.id), serializeJson(response), "EX", ttlSeconds);
807
- }
808
- async readResponse(requestId) {
809
- return parseJson(await this.command("GET", this.key("response", requestId)));
810
- }
811
- async cleanup(requestId) {
812
- await this.command("DEL", this.key("response", requestId));
813
- }
814
- };
815
- function createRedisGhostTunnelStore(options) {
816
- return new RedisGhostTunnelStore(options);
817
- }
818
- function resolveRedisGhostTunnelEnv(env = process.env) {
819
- const candidates = [
820
- env.LOCALGHOST_REDIS_REST_URL && env.LOCALGHOST_REDIS_REST_TOKEN ? { url: env.LOCALGHOST_REDIS_REST_URL, token: env.LOCALGHOST_REDIS_REST_TOKEN, source: "localghost" } : null,
821
- env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN ? { url: env.UPSTASH_REDIS_REST_URL, token: env.UPSTASH_REDIS_REST_TOKEN, source: "upstash" } : null,
822
- env.KV_REST_API_URL && env.KV_REST_API_TOKEN ? { url: env.KV_REST_API_URL, token: env.KV_REST_API_TOKEN, source: "vercel-kv" } : null,
823
- env.REDIS_REST_API_URL && env.REDIS_REST_API_TOKEN ? { url: env.REDIS_REST_API_URL, token: env.REDIS_REST_API_TOKEN, source: "redis" } : null
824
- ];
825
- const match = candidates.find((candidate) => Boolean(candidate));
826
- if (!match) {
827
- throw new Error("Ghost Tunnel Redis transport requires REST env vars: UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN, KV_REST_API_URL/KV_REST_API_TOKEN, or LOCALGHOST_REDIS_REST_URL/LOCALGHOST_REDIS_REST_TOKEN.");
828
- }
829
- return match;
830
- }
831
- function createRedisGhostTunnelStoreFromEnv(input = {}) {
832
- const resolved = resolveRedisGhostTunnelEnv(input.env);
833
- return createRedisGhostTunnelStore({
834
- url: resolved.url,
835
- token: resolved.token,
836
- ...input.namespace ? { namespace: input.namespace } : {},
837
- ...input.fetch ? { fetch: input.fetch } : {}
838
- });
723
+ return url.toString();
839
724
  }
840
-
841
- // src/ghost-agent.ts
842
- function isStopped(signal, localSignal) {
843
- return localSignal.aborted || signal?.aborted === true;
725
+ var constructGhostTunnelURL = constructGhostTunnelUrl;
726
+ function getGhostTunnelDefaultDisplayUrl(options = {}, defaults) {
727
+ const config = toGhostTunnelConfig(options);
728
+ if (!config.enabled) return null;
729
+ return createDisplayUrl(config, defaults);
844
730
  }
845
- function wait(ms, signal, localSignal) {
846
- if (isStopped(signal, localSignal)) return Promise.resolve();
847
- return new Promise((resolve5) => {
848
- const timeout = setTimeout(resolve5, ms);
849
- const stop = () => {
850
- clearTimeout(timeout);
851
- resolve5();
852
- };
853
- signal?.addEventListener("abort", stop, { once: true });
854
- localSignal.addEventListener("abort", stop, { once: true });
855
- });
731
+ function getGhostTunnelDisplayUrl(options, defaults) {
732
+ const config = toGhostTunnelConfig(options);
733
+ if (!config.enabled) return null;
734
+ return config.displayUrl ?? config.previewUrl ?? getGhostTunnelDefaultDisplayUrl(config, defaults);
856
735
  }
857
- function toHeaderRecord(headers) {
858
- const result = {};
859
- headers.forEach((value, name) => {
860
- result[name] = value;
861
- });
862
- return result;
736
+ function getGhostTunnelDisplayUrls(options, defaults) {
737
+ const config = toGhostTunnelConfig(options);
738
+ if (!config.enabled) return [];
739
+ if (config.displayUrls.length > 0) return config.displayUrls;
740
+ const displayUrl = getGhostTunnelDisplayUrl(config, defaults);
741
+ return displayUrl ? [displayUrl] : [];
863
742
  }
864
- function hasRequestBody(method) {
865
- return method !== "GET" && method !== "HEAD";
743
+ function getGhostTunnelPreviewUrl(options) {
744
+ const config = toGhostTunnelConfig(options);
745
+ if (!config.enabled) return null;
746
+ return config.previewUrl ?? maybeConstructPreviewUrl(config) ?? null;
866
747
  }
867
- async function serveGhostTunnelLocalRequest(input) {
868
- const fetchImpl = input.fetch ?? fetch;
869
- const localUrl = new URL(`${input.target.protocol}://${input.target.host}:${input.target.port}/`);
870
- const requestPath = new URL(input.request.path, "http://localghost.invalid");
871
- localUrl.pathname = requestPath.pathname;
872
- localUrl.search = requestPath.search;
873
- try {
874
- const body = hasRequestBody(input.request.method) ? decodeGhostTunnelBody(input.request.bodyBase64) : void 0;
875
- const response = await fetchImpl(localUrl, {
876
- method: input.request.method,
877
- headers: {
878
- ...stripRelayForwardHeaders(input.request.headers),
879
- "x-forwarded-host": input.request.host,
880
- "x-localghost-tunnel": "1"
881
- },
882
- ...body ? { body } : {}
883
- });
884
- const responseBody = Buffer.from(await response.arrayBuffer());
885
- if (responseBody.byteLength > input.maxResponseBodyBytes) {
886
- throw new Error(`Ghost Tunnel response exceeded ${input.maxResponseBodyBytes} bytes.`);
887
- }
888
- return {
889
- id: input.request.id,
890
- status: response.status,
891
- headers: toHeaderRecord(response.headers),
892
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
893
- ...responseBody.byteLength > 0 ? { bodyBase64: encodeGhostTunnelBody(responseBody) } : {}
894
- };
895
- } catch (error) {
896
- return {
897
- id: input.request.id,
898
- status: 502,
899
- headers: {
900
- "content-type": "text/plain; charset=utf-8",
901
- "cache-control": "no-store"
902
- },
903
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
904
- error: error instanceof Error ? error.message : String(error),
905
- bodyBase64: encodeGhostTunnelBody("Ghost Tunnel local target failed.")
906
- };
907
- }
908
- }
909
- async function heartbeatRoutes(input) {
910
- for (const entry of input.entries) {
911
- const target = assertRelayLocalTarget({ host: input.targetHost, port: entry.port });
912
- await input.store.heartbeatRoute(createGhostTunnelRouteHeartbeat({
913
- host: entry.host,
914
- agentId: input.agentId,
915
- target,
916
- ttlSeconds: input.routeTtlSeconds
917
- }), input.routeTtlSeconds);
918
- }
919
- }
920
- async function claimAndServe(input) {
921
- const request = await input.store.claimRequest(input.entry.host);
922
- if (!request) return false;
923
- const target = assertRelayLocalTarget({ host: input.targetHost, port: input.entry.port });
924
- const response = await serveGhostTunnelLocalRequest({
925
- request,
926
- target,
927
- maxResponseBodyBytes: input.maxResponseBodyBytes,
928
- ...input.fetch ? { fetch: input.fetch } : {}
929
- });
930
- await input.store.writeResponse(response, input.requestTtlSeconds);
931
- return true;
932
- }
933
- function startGhostTunnelAgent(options) {
934
- const controller = new AbortController();
935
- const localSignal = controller.signal;
936
- const signal = options.signal;
937
- const agentId = options.agentId ?? `localghost-${randomUUID2()}`;
938
- const targetHost = options.targetHost ?? "127.0.0.1";
939
- const routeTtlSeconds = options.routeTtlSeconds ?? 30;
940
- const requestTtlSeconds = options.requestTtlSeconds ?? 60;
941
- const pollIntervalMs = options.pollIntervalMs ?? 500;
942
- const maxResponseBodyBytes = options.maxResponseBodyBytes ?? 5 * 1024 * 1024;
943
- const done = (async () => {
944
- if (options.entries.length === 0) {
945
- throw new Error("Ghost Tunnel agent requires at least one .ghosttunnel entry.");
946
- }
947
- options.log?.(`localghost tunnel agent ${agentId}`);
948
- for (const entry of options.entries) {
949
- options.log?.(` ${entry.host} -> ${targetHost}:${entry.port}`);
950
- }
951
- let lastHeartbeat = 0;
952
- while (!isStopped(signal, localSignal)) {
953
- const now = Date.now();
954
- if (now - lastHeartbeat >= Math.max(1e3, Math.floor(routeTtlSeconds * 1e3 / 3))) {
955
- await heartbeatRoutes({
956
- entries: options.entries,
957
- store: options.store,
958
- agentId,
959
- targetHost,
960
- routeTtlSeconds
961
- });
962
- lastHeartbeat = now;
963
- }
964
- let served = false;
965
- for (const entry of options.entries) {
966
- served = await claimAndServe({
967
- entry,
968
- store: options.store,
969
- targetHost,
970
- requestTtlSeconds,
971
- maxResponseBodyBytes,
972
- ...options.fetch ? { fetch: options.fetch } : {}
973
- }) || served;
974
- }
975
- if (!served) {
976
- await wait(pollIntervalMs, signal, localSignal);
977
- }
978
- }
979
- })();
748
+ function parseGhostTunnelHost(host, domain, options = {}) {
749
+ const config = toGhostTunnelConfig(options);
750
+ if (!config.enabled) return null;
751
+ const normalizedHost = normalizeDomain(host);
752
+ const normalizedDomain = normalizeDomain(domain);
753
+ if (!normalizedHost || !normalizedDomain) return null;
754
+ const entryHost = getGhostTunnelEntryHost(normalizedDomain, config);
755
+ const suffix = `.${entryHost}`;
756
+ if (!normalizedHost.endsWith(suffix)) return null;
757
+ const slug = normalizedHost.slice(0, -suffix.length);
758
+ if (!isValidHostLabel(slug)) return null;
759
+ const namespace = parseNamespaceSlug(slug, config.namespace);
760
+ if (!namespace) return null;
980
761
  return {
981
- agentId,
982
- stop() {
983
- controller.abort();
984
- },
985
- done
762
+ host: normalizedHost,
763
+ slug,
764
+ namespace,
765
+ entryHost,
766
+ wildcardHost: `*.${entryHost}`,
767
+ domain: normalizedDomain
986
768
  };
987
769
  }
770
+ function assertSecureGhostTunnelRequest(input) {
771
+ const config = toGhostTunnelConfig(input.ghostTunnel);
772
+ if (!config.enabled) {
773
+ throw new Error("Ghost tunnel is not enabled.");
774
+ }
775
+ if (config.requireHttps && input.protocol !== "https") {
776
+ throw new Error("Ghost tunnel requests must use HTTPS.");
777
+ }
778
+ if (config.requireAuth && input.authenticated !== true) {
779
+ throw new Error("Ghost tunnel requests must be authenticated.");
780
+ }
781
+ const route = parseGhostTunnelHost(input.host, input.domain, config);
782
+ if (!route) {
783
+ throw new Error(`Host is not a valid ghost tunnel host for ${input.domain}.`);
784
+ }
785
+ return route;
786
+ }
988
787
 
989
- // src/ghost-transport.ts
990
- import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
991
- import { isIP } from "net";
992
-
993
- // src/tunnel.ts
788
+ // packages/ghost-tunnel/src/relay.ts
789
+ import { createHmac, timingSafeEqual } from "crypto";
994
790
  import { domainToASCII as domainToASCII2 } from "url";
995
- var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
996
- var DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS = ["route", "project", "owner"];
997
- var DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR = "-";
998
- var DEFAULT_GHOST_TUNNEL_MODE = "manual";
999
- var DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY = "same-project";
1000
- var DEFAULT_GHOST_TUNNEL_TRANSPORT_KIND = "none";
1001
- var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_PROVIDER = "vercel-redis";
1002
- var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_ENV = "auto";
1003
- var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_NAMESPACE = "localghost";
1004
- var DEFAULT_GHOST_TUNNEL_TUNNEL_WAIT_MS = 25e3;
1005
- var DEFAULT_GHOST_TUNNEL_TUNNEL_POLL_INTERVAL_MS = 250;
1006
- var DEFAULT_GHOST_TUNNEL_TUNNEL_ROUTE_TTL_SECONDS = 30;
1007
- var DEFAULT_GHOST_TUNNEL_TUNNEL_REQUEST_TTL_SECONDS = 60;
1008
- var DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
1009
- var DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024;
1010
- function isResolvedGhostTunnelConfig(value) {
1011
- return typeof value === "object" && value !== null && "enabled" in value;
1012
- }
1013
- function toGhostTunnelConfig(options) {
1014
- return isResolvedGhostTunnelConfig(options) ? options : resolveGhostTunnelConfig(options);
1015
- }
1016
- function stripHostPort(value) {
1017
- const trimmed = value.trim().toLowerCase();
1018
- if (trimmed.startsWith("[") || trimmed.includes("/")) return "";
1019
- const portSeparator = trimmed.lastIndexOf(":");
1020
- if (portSeparator === -1) return trimmed;
1021
- const port = trimmed.slice(portSeparator + 1);
1022
- return /^\d+$/.test(port) ? trimmed.slice(0, portSeparator) : trimmed;
791
+ var DEFAULT_RELAY_ALLOWED_TARGET_HOSTS = ["localhost", "127.0.0.1", "::1"];
792
+ var DEFAULT_RELAY_BLOCKED_PORTS = [22, 2375, 2376, 5432, 6379, 9200, 9229, 27017];
793
+ var DEFAULT_RELAY_LIMITS = {
794
+ requestBodyBytes: 5 * 1024 * 1024,
795
+ responseBytes: 25 * 1024 * 1024,
796
+ timeoutMs: 3e4,
797
+ maxConcurrentRequests: 20,
798
+ perRouteRequestsPerMinute: 120,
799
+ perIpRequestsPerMinute: 60
800
+ };
801
+ var DEFAULT_RELAY_TARGET_POLICY = {
802
+ allowedHosts: [...DEFAULT_RELAY_ALLOWED_TARGET_HOSTS],
803
+ blockedPorts: [...DEFAULT_RELAY_BLOCKED_PORTS],
804
+ allowPrivateNetworkTargets: false
805
+ };
806
+ var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
807
+ "connection",
808
+ "keep-alive",
809
+ "proxy-authenticate",
810
+ "proxy-authorization",
811
+ "te",
812
+ "trailer",
813
+ "transfer-encoding",
814
+ "upgrade"
815
+ ]);
816
+ var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "set-cookie"]);
817
+ var TOKEN_QUERY_PATTERN = /(token|secret|key|password|session|jwt|auth)/i;
818
+ var HOST_PATTERN2 = /^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*$/i;
819
+ var IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/;
820
+ function base64UrlEncode(value) {
821
+ return Buffer.from(value).toString("base64url");
1023
822
  }
1024
- function normalizeDomain(value) {
1025
- const host = stripHostPort(value.replace(/^\*\./, ""));
1026
- const ascii = domainToASCII2(host);
1027
- if (!ascii || ascii.length > 253 || ascii.includes("..")) return null;
1028
- if (ascii.startsWith(".") || ascii.endsWith(".")) return null;
1029
- if (ascii.includes("*")) return null;
1030
- if (!ascii.split(".").every(isValidHostLabel)) return null;
1031
- return ascii;
823
+ function base64UrlDecode(value) {
824
+ return Buffer.from(value, "base64url").toString("utf8");
1032
825
  }
1033
- function isValidHostLabel(value) {
1034
- return value.length > 0 && value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
826
+ function signPayload(payload, secret) {
827
+ return createHmac("sha256", secret).update(payload).digest("base64url");
1035
828
  }
1036
- function isValidNamespaceTag(value) {
1037
- return /^[a-z][a-z0-9_]*$/i.test(value);
829
+ function secureEqual(left, right) {
830
+ const leftBuffer = Buffer.from(left);
831
+ const rightBuffer = Buffer.from(right);
832
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
1038
833
  }
1039
- function isNamespaceTagList(options) {
1040
- return Array.isArray(options);
834
+ function normalizeHost(host) {
835
+ const trimmed = host.trim().toLowerCase().replace(/\.$/, "");
836
+ if (!trimmed || trimmed.includes("*") || trimmed.includes("/") || trimmed.includes(":")) return null;
837
+ const ascii = domainToASCII2(trimmed);
838
+ if (!ascii || ascii.includes("..")) return null;
839
+ return HOST_PATTERN2.test(ascii) ? ascii : null;
1041
840
  }
1042
- function assertValidSubdomain(value) {
1043
- if (!isValidHostLabel(value)) {
1044
- throw new Error(`Invalid ghost tunnel subdomain: ${value}`);
1045
- }
841
+ function normalizeTargetHost(host) {
842
+ const trimmed = host.trim().toLowerCase();
843
+ if (trimmed === "::1" || trimmed === "[::1]") return "::1";
844
+ if (trimmed.includes("/") || trimmed.includes("*")) return null;
845
+ if (IPV4_PATTERN.test(trimmed)) return isValidIpv4(trimmed) ? trimmed : null;
846
+ return normalizeHost(trimmed);
1046
847
  }
1047
- function normalizeDomains(domains) {
1048
- const values = typeof domains === "string" ? [domains] : [...domains ?? []];
1049
- const normalized = values.map((value) => value.trim()).filter(Boolean).map((value) => {
1050
- const domain = normalizeDomain(value);
1051
- if (!domain) throw new Error(`Invalid ghost tunnel domain: ${value}`);
1052
- return domain;
848
+ function isValidIpv4(value) {
849
+ return value.split(".").every((part) => {
850
+ const octet = Number(part);
851
+ return Number.isInteger(octet) && octet >= 0 && octet <= 255 && String(octet) === part;
1053
852
  });
1054
- return [...new Set(normalized)];
1055
- }
1056
- function parseGhostTunnelMode(value) {
1057
- return value ?? DEFAULT_GHOST_TUNNEL_MODE;
1058
853
  }
1059
- function parseGhostTunnelAdapterStrategy(value) {
1060
- if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY;
1061
- if (value === "same-project" || value === "separate-relay") return value;
1062
- throw new Error(`Unsupported ghost tunnel adapter strategy: ${String(value)}`);
1063
- }
1064
- function parseGhostTunnelTransportKind(value) {
1065
- if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TRANSPORT_KIND;
1066
- if (value === "none" || value === "ip" || value === "tunnel") return value;
1067
- throw new Error(`Unsupported ghost tunnel transport: ${String(value)}`);
854
+ function isPrivateIpv4(value) {
855
+ if (!isValidIpv4(value)) return false;
856
+ const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
857
+ return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
1068
858
  }
1069
- function parsePositiveInteger(value, fallback, name) {
1070
- if (typeof value === "undefined") return fallback;
1071
- if (!Number.isInteger(value) || value < 1) {
1072
- throw new Error(`Invalid ghost tunnel ${name}: ${value}`);
1073
- }
1074
- return value;
859
+ function isLocalTargetHost(host) {
860
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
1075
861
  }
1076
- function parseTunnelStoreProvider(value) {
1077
- if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_PROVIDER;
1078
- if (value === "vercel-redis" || value === "redis") return value;
1079
- throw new Error(`Unsupported ghost tunnel tunnel store provider: ${String(value)}`);
862
+ function mergeTargetPolicy(policy) {
863
+ return {
864
+ allowedHosts: policy?.allowedHosts ?? DEFAULT_RELAY_TARGET_POLICY.allowedHosts,
865
+ blockedPorts: policy?.blockedPorts ?? DEFAULT_RELAY_TARGET_POLICY.blockedPorts,
866
+ allowPrivateNetworkTargets: policy?.allowPrivateNetworkTargets ?? DEFAULT_RELAY_TARGET_POLICY.allowPrivateNetworkTargets
867
+ };
1080
868
  }
1081
- function parseTunnelStoreEnv(value) {
1082
- if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_ENV;
1083
- if (value === "auto") return value;
1084
- throw new Error(`Unsupported ghost tunnel tunnel store env: ${String(value)}`);
869
+ function mergeLimits(limits) {
870
+ const merged = {
871
+ ...DEFAULT_RELAY_LIMITS,
872
+ ...limits ?? {}
873
+ };
874
+ for (const [key, value] of Object.entries(merged)) {
875
+ if (!Number.isInteger(value) || value < 1) {
876
+ throw new Error(`Invalid relay limit ${key}: ${value}`);
877
+ }
878
+ }
879
+ return merged;
1085
880
  }
1086
- function parseTunnelStoreNamespace(value) {
1087
- const namespace = value ?? DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_NAMESPACE;
1088
- if (!/^[a-z][a-z0-9:_-]{0,63}$/i.test(namespace)) {
1089
- throw new Error(`Invalid ghost tunnel tunnel store namespace: ${namespace}`);
881
+ function assertExactRelayHost(host) {
882
+ const normalized = normalizeHost(host);
883
+ if (!normalized) {
884
+ throw new Error(`Relay route claims must use an exact hostname: ${host}`);
1090
885
  }
1091
- return namespace;
886
+ return normalized;
1092
887
  }
1093
- function resolveGhostTunnelAdapter(input) {
1094
- if (!input) return void 0;
1095
- const provider = typeof input === "string" ? input : input.provider;
1096
- if (provider !== "vercel") {
1097
- throw new Error(`Unsupported ghost tunnel adapter provider: ${String(provider)}`);
888
+ function assertRelayLocalTarget(target, policyInput) {
889
+ if (!target || typeof target !== "object") {
890
+ throw new Error("Relay target must be an explicit local target object.");
891
+ }
892
+ const policy = mergeTargetPolicy(policyInput);
893
+ const host = normalizeTargetHost(target.host);
894
+ if (!host) {
895
+ throw new Error(`Invalid relay target host: ${target.host}`);
896
+ }
897
+ if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
898
+ throw new Error(`Invalid relay target port: ${target.port}`);
899
+ }
900
+ const protocol = target.protocol ?? "http";
901
+ if (protocol !== "http" && protocol !== "https") {
902
+ throw new Error(`Invalid relay target protocol: ${String(protocol)}`);
903
+ }
904
+ if (policy.blockedPorts.includes(target.port)) {
905
+ throw new Error(`Relay target port is blocked: ${target.port}`);
906
+ }
907
+ const allowedHosts = new Set(policy.allowedHosts.map((allowedHost) => normalizeTargetHost(allowedHost)).filter((value) => Boolean(value)));
908
+ if (!allowedHosts.has(host)) {
909
+ throw new Error(`Relay target host is not explicitly allowed: ${host}`);
910
+ }
911
+ if (!policy.allowPrivateNetworkTargets && !isLocalTargetHost(host) && (host === "::1" || isPrivateIpv4(host))) {
912
+ throw new Error(`Private-network relay target requires explicit opt-in: ${host}`);
1098
913
  }
1099
914
  return {
1100
- provider,
1101
- strategy: typeof input === "string" ? DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY : parseGhostTunnelAdapterStrategy(input.strategy)
915
+ protocol,
916
+ host,
917
+ port: target.port
1102
918
  };
1103
919
  }
1104
- function getLegacyGhostTunnelTransport(input) {
1105
- if (!input || typeof input === "string" || !("transport" in input)) return void 0;
1106
- return input.transport;
920
+ function authenticateRelayAgentToken(input) {
921
+ const expected = `Bearer ${input.agentToken}`;
922
+ return typeof input.authorizationHeader === "string" && secureEqual(input.authorizationHeader, expected);
1107
923
  }
1108
- function resolveGhostTunnelTransport(input) {
1109
- if (!input) {
1110
- return { kind: "none" };
924
+ function signRelayRouteClaim(claim, signingSecret) {
925
+ const payload = {
926
+ ...claim,
927
+ host: assertExactRelayHost(claim.host)
928
+ };
929
+ if (!payload.scope) throw new Error("Relay route claim requires a scope.");
930
+ if (!payload.agentId) throw new Error("Relay route claim requires an agentId.");
931
+ if (Number.isNaN(Date.parse(payload.expiresAt))) throw new Error("Relay route claim requires a valid expiresAt.");
932
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
933
+ const signature = signPayload(encodedPayload, signingSecret);
934
+ return {
935
+ payload,
936
+ token: `${encodedPayload}.${signature}`
937
+ };
938
+ }
939
+ function verifyRelayRouteClaim(token, signingSecret, options) {
940
+ const [encodedPayload, signature] = token.split(".");
941
+ if (!encodedPayload || !signature || token.split(".").length !== 2) {
942
+ throw new Error("Invalid relay route claim token.");
1111
943
  }
1112
- const kind = typeof input === "string" ? parseGhostTunnelTransportKind(input) : parseGhostTunnelTransportKind(input.kind);
1113
- if (kind === "ip") {
1114
- return {
1115
- kind,
1116
- allowPrivateNetworkAddress: typeof input === "string" ? false : input.kind === "ip" ? input.allowPrivateNetworkAddress ?? false : false
1117
- };
944
+ const expectedSignature = signPayload(encodedPayload, signingSecret);
945
+ if (!secureEqual(signature, expectedSignature)) {
946
+ throw new Error("Invalid relay route claim signature.");
1118
947
  }
1119
- if (kind === "tunnel") {
1120
- const config = typeof input === "string" || input.kind !== "tunnel" ? void 0 : input;
1121
- const store = config?.store ?? {};
1122
- return {
1123
- kind,
1124
- store: {
1125
- provider: parseTunnelStoreProvider(store.provider),
1126
- env: parseTunnelStoreEnv(store.env),
1127
- namespace: parseTunnelStoreNamespace(store.namespace)
1128
- },
1129
- waitMs: parsePositiveInteger(config?.waitMs, DEFAULT_GHOST_TUNNEL_TUNNEL_WAIT_MS, "tunnel waitMs"),
1130
- pollIntervalMs: parsePositiveInteger(config?.pollIntervalMs, DEFAULT_GHOST_TUNNEL_TUNNEL_POLL_INTERVAL_MS, "tunnel pollIntervalMs"),
1131
- routeTtlSeconds: parsePositiveInteger(config?.routeTtlSeconds, DEFAULT_GHOST_TUNNEL_TUNNEL_ROUTE_TTL_SECONDS, "tunnel routeTtlSeconds"),
1132
- requestTtlSeconds: parsePositiveInteger(config?.requestTtlSeconds, DEFAULT_GHOST_TUNNEL_TUNNEL_REQUEST_TTL_SECONDS, "tunnel requestTtlSeconds"),
1133
- maxRequestBodyBytes: parsePositiveInteger(config?.maxRequestBodyBytes, DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_REQUEST_BODY_BYTES, "tunnel maxRequestBodyBytes"),
1134
- maxResponseBodyBytes: parsePositiveInteger(config?.maxResponseBodyBytes, DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_RESPONSE_BODY_BYTES, "tunnel maxResponseBodyBytes")
1135
- };
948
+ const parsed = JSON.parse(base64UrlDecode(encodedPayload));
949
+ const host = assertExactRelayHost(parsed.host);
950
+ if (parsed.scope !== options.expectedScope) {
951
+ throw new Error("Relay route claim scope mismatch.");
1136
952
  }
1137
- return { kind: "none" };
1138
- }
1139
- function resolveNamespaceConfig(options) {
1140
- const tags = isNamespaceTagList(options) ? [...options] : [...options?.tags ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS];
1141
- let separator = DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
1142
- let spreadTag = tags.includes("project") ? "project" : void 0;
1143
- if (options && !isNamespaceTagList(options)) {
1144
- separator = options.separator ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
1145
- spreadTag = options.spreadTag === false ? false : options.spreadTag ?? spreadTag;
953
+ const now = options.now ?? /* @__PURE__ */ new Date();
954
+ if (Date.parse(parsed.expiresAt) <= now.getTime()) {
955
+ throw new Error("Relay route claim has expired.");
1146
956
  }
1147
- if (tags.length === 0) {
1148
- throw new Error("Ghost tunnel namespace must include at least one tag.");
957
+ if (!parsed.agentId) {
958
+ throw new Error("Relay route claim requires an agentId.");
1149
959
  }
1150
- for (const tag of tags) {
1151
- if (!isValidNamespaceTag(tag)) {
1152
- throw new Error(`Invalid ghost tunnel namespace tag: ${tag}`);
1153
- }
960
+ return { ...parsed, host };
961
+ }
962
+ function createRelayRouteRegistration(input) {
963
+ if (!authenticateRelayAgentToken({
964
+ agentToken: input.agentToken,
965
+ ...typeof input.authorizationHeader !== "undefined" ? { authorizationHeader: input.authorizationHeader } : {}
966
+ })) {
967
+ throw new Error("Relay route registration requires an authenticated local agent.");
1154
968
  }
1155
- if (spreadTag && !tags.includes(spreadTag)) {
1156
- throw new Error(`Ghost tunnel namespace spreadTag must be listed in tags: ${spreadTag}`);
969
+ const claim = verifyRelayRouteClaim(input.claimToken, input.signingSecret, {
970
+ expectedScope: input.expectedScope,
971
+ ...input.now ? { now: input.now } : {}
972
+ });
973
+ const target = assertRelayLocalTarget(input.target, input.targetPolicy);
974
+ const access = input.publicMode === true ? "public" : input.access ?? "private";
975
+ const passwordProtected = input.passwordProtected ?? false;
976
+ const authRequired = input.authRequired ?? false;
977
+ if (access === "public" && input.publicMode !== true) {
978
+ throw new Error("Relay public mode must be explicitly enabled.");
1157
979
  }
1158
- if (!separator || separator.length > 8 || !/^[a-z0-9-]+$/.test(separator)) {
1159
- throw new Error(`Invalid ghost tunnel namespace separator: ${separator}`);
980
+ if (access === "private" && !passwordProtected && !authRequired) {
981
+ throw new Error("Private relay previews require password or auth.");
1160
982
  }
1161
983
  return {
1162
- tags,
1163
- separator,
1164
- ...spreadTag ? { spreadTag } : {}
984
+ host: claim.host,
985
+ scope: claim.scope,
986
+ agentId: claim.agentId,
987
+ expiresAt: claim.expiresAt,
988
+ target,
989
+ access,
990
+ passwordProtected,
991
+ authRequired,
992
+ limits: mergeLimits(input.limits)
1165
993
  };
1166
994
  }
1167
- function normalizeNamespaceValue(tag, value, separator, options = {}) {
1168
- const normalized = normalizeDomain(value);
1169
- if (!normalized || normalized.includes(".")) {
1170
- throw new Error(`Invalid ghost tunnel namespace value for ${tag}: ${value}`);
995
+ function isRelayRouteActive(route, options) {
996
+ if (!options.agentConnected) return false;
997
+ return Date.parse(route.expiresAt) > (options.now ?? /* @__PURE__ */ new Date()).getTime();
998
+ }
999
+ function stripRelayForwardHeaders(headers) {
1000
+ const stripped = {};
1001
+ for (const [name, value] of Object.entries(headers)) {
1002
+ if (typeof value === "undefined") continue;
1003
+ const lowerName = name.toLowerCase();
1004
+ if (HOP_BY_HOP_HEADERS.has(lowerName)) continue;
1005
+ if (lowerName.startsWith("x-localghost-")) continue;
1006
+ stripped[name] = value;
1171
1007
  }
1172
- if (!options.allowSeparator && normalized.includes(separator)) {
1173
- throw new Error(`Ghost tunnel namespace value for ${tag} cannot include separator "${separator}": ${value}`);
1008
+ return stripped;
1009
+ }
1010
+ function redactRelayHeaders(headers) {
1011
+ const redacted = {};
1012
+ for (const [name, value] of Object.entries(headers)) {
1013
+ if (typeof value === "undefined") continue;
1014
+ redacted[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? "[redacted]" : value;
1174
1015
  }
1175
- return normalized;
1016
+ return redacted;
1176
1017
  }
1177
- function createNamespaceSlug(config, values) {
1178
- const parts = config.tags.map((tag) => {
1179
- const value = values[tag];
1180
- if (!value) {
1181
- throw new Error(`Missing ghost tunnel namespace value: ${tag}`);
1018
+ function redactRelayLogUrl(input) {
1019
+ const url = new URL(input, "http://localghost.invalid");
1020
+ for (const key of [...url.searchParams.keys()]) {
1021
+ if (TOKEN_QUERY_PATTERN.test(key)) {
1022
+ url.searchParams.set(key, "[redacted]");
1182
1023
  }
1183
- return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
1184
- });
1185
- const slug = parts.join(config.separator);
1186
- if (!isValidHostLabel(slug)) {
1187
- throw new Error(`Ghost tunnel namespace is too long for a DNS label: ${slug}`);
1188
1024
  }
1189
- return slug;
1025
+ return input.startsWith("http://") || input.startsWith("https://") ? url.toString() : `${url.pathname}${url.search}`;
1190
1026
  }
1191
- function createNamespaceDisplaySlug(config, values = {}) {
1192
- return config.tags.map((tag) => {
1193
- const value = values[tag];
1194
- if (!value) return `<${tag}>`;
1195
- try {
1196
- return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
1197
- } catch {
1198
- return `<${tag}>`;
1199
- }
1200
- }).join(config.separator);
1201
- }
1202
- function getPreviewDefaults(preview, defaults) {
1027
+ function renderRelayOfflineResponse() {
1203
1028
  return {
1204
- domain: preview?.domain ?? defaults?.domain,
1205
- route: preview?.route ?? defaults?.route,
1206
- project: preview?.project ?? defaults?.project,
1207
- owner: preview?.owner ?? defaults?.owner,
1208
- values: {
1209
- ...defaults?.values ?? {},
1210
- ...preview?.values ?? {}
1029
+ status: 503,
1030
+ headers: {
1031
+ "content-type": "text/html; charset=utf-8",
1032
+ "cache-control": "no-store"
1211
1033
  },
1212
- path: preview?.path,
1213
- protocol: preview?.protocol
1034
+ body: [
1035
+ "<!doctype html>",
1036
+ "<html>",
1037
+ '<head><meta charset="utf-8"><title>Preview offline</title></head>',
1038
+ "<body><h1>Preview offline</h1><p>The local agent is not connected. Try again later.</p></body>",
1039
+ "</html>"
1040
+ ].join("")
1214
1041
  };
1215
1042
  }
1216
- function getDisplayValues(input) {
1043
+
1044
+ // packages/ghost-tunnel/src/ghost-file.ts
1045
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
1046
+ import { basename as basename2, resolve as resolve2 } from "path";
1047
+ var LOCALGHOST_GHOST_TUNNEL_FILE = ".ghosttunnel";
1048
+ function getCandidates(options) {
1049
+ const exact = [.../* @__PURE__ */ new Set([...options.fileName ? [options.fileName] : [], ...options.configFiles ?? []])];
1050
+ const pattern = options.configPattern ? readdirSync2(options.cwd ?? process.cwd(), { withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => entry.name).filter((name) => {
1051
+ const matcher = typeof options.configPattern === "string" ? new RegExp(options.configPattern) : options.configPattern;
1052
+ if (!matcher) return false;
1053
+ matcher.lastIndex = 0;
1054
+ return matcher.test(name);
1055
+ }).sort() : [];
1056
+ if (exact.length > 0 || pattern.length > 0) return [.../* @__PURE__ */ new Set([...exact, ...pattern])];
1057
+ return [LOCALGHOST_GHOST_TUNNEL_FILE];
1058
+ }
1059
+ function parseGhostTunnelEntries(input, fileName) {
1060
+ const entries = [];
1061
+ input.split(/\r?\n/).forEach((rawLine, index) => {
1062
+ const line = rawLine.replace(/#.*/, "").trim();
1063
+ if (!line) return;
1064
+ const parts = line.split(/\s+/);
1065
+ const host = parts[0];
1066
+ const portRaw = parts[1];
1067
+ if (!host || !portRaw || parts.length > 2) {
1068
+ throw new Error(`Invalid ${fileName} line ${index + 1}: "${rawLine}"`);
1069
+ }
1070
+ if (!/^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*\.?$/i.test(host)) {
1071
+ throw new Error(`Invalid host on line ${index + 1}: "${host}"`);
1072
+ }
1073
+ const port = Number(portRaw);
1074
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1075
+ throw new Error(`Invalid port on line ${index + 1}: "${portRaw}"`);
1076
+ }
1077
+ entries.push({ host: host.toLowerCase().replace(/\.$/, ""), port, target: `127.0.0.1:${port}` });
1078
+ });
1079
+ return entries;
1080
+ }
1081
+ function toGhostTunnelOptions(options = {}) {
1082
+ const resolved = typeof options === "string" ? { cwd: options } : options;
1217
1083
  return {
1218
- ...input.route ? { route: input.route } : {},
1219
- ...input.project ? { project: input.project } : {},
1220
- ...input.owner ? { owner: input.owner } : {},
1221
- ...input.values
1084
+ ...resolved,
1085
+ fileName: resolved.fileName ?? LOCALGHOST_GHOST_TUNNEL_FILE
1222
1086
  };
1223
1087
  }
1224
- function getDisplayDefaults(defaults) {
1225
- return defaults;
1088
+ function normalizeHost2(value) {
1089
+ return value.trim().toLowerCase().replace(/\.$/, "");
1226
1090
  }
1227
- function createDisplayUrl(config, defaults, domain) {
1228
- const input = getPreviewDefaults(config.preview, getDisplayDefaults(defaults));
1229
- const protocol = input.protocol ?? "https";
1230
- const slug = createNamespaceDisplaySlug(config.namespace, getDisplayValues(input));
1231
- const entryHost = domain ? getGhostTunnelEntryHost(domain, config) : input.domain ? getGhostTunnelEntryHost(input.domain, config) : `${config.subdomain}.*`;
1232
- const url = `${protocol}://${slug}.${entryHost}/`;
1233
- if (!input.path) return url;
1234
- return `${url}${input.path.replace(/^\/+/, "")}`;
1091
+ function resolveGhostTunnelPath(options = {}) {
1092
+ const resolved = toGhostTunnelOptions(options);
1093
+ const cwd = resolved.cwd ?? process.cwd();
1094
+ const searchedFiles = getCandidates(resolved);
1095
+ for (const fileName2 of searchedFiles) {
1096
+ const path = resolve2(cwd, fileName2);
1097
+ if (existsSync3(path)) return { path, fileName: basename2(fileName2), exists: true, searchedFiles };
1098
+ }
1099
+ const fileName = searchedFiles[0] ?? LOCALGHOST_GHOST_TUNNEL_FILE;
1100
+ return { path: resolve2(cwd, fileName), fileName: basename2(fileName), exists: false, searchedFiles };
1235
1101
  }
1236
- function createDisplayUrls(config, defaults) {
1237
- const displayDefaults = getDisplayDefaults(defaults);
1238
- const domains = config.domains.length > 0 ? config.domains : displayDefaults?.domain ? [displayDefaults.domain] : [];
1239
- const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, displayDefaults, domain)) : [createDisplayUrl(config, displayDefaults)];
1240
- return [...new Set(urls)];
1102
+ function getGhostTunnelPath(options = {}) {
1103
+ return resolveGhostTunnelPath(options).path;
1241
1104
  }
1242
- function maybeConstructPreviewUrl(config, defaults) {
1243
- if (!config.preview) return void 0;
1244
- const input = getPreviewDefaults(config.preview, defaults);
1245
- if (!input.domain || !input.route || !input.project || !input.owner) return void 0;
1246
- return constructGhostTunnelUrl({
1247
- domain: input.domain,
1248
- route: input.route,
1249
- project: input.project,
1250
- owner: input.owner,
1251
- values: input.values,
1252
- ...input.path ? { path: input.path } : {},
1253
- ...input.protocol ? { protocol: input.protocol } : {},
1254
- ghostTunnel: config
1255
- });
1105
+ function readGhostTunnelEntries(options = {}) {
1106
+ const resolved = resolveGhostTunnelPath(options);
1107
+ if (!resolved.exists) throw new Error(`Missing Ghost Tunnel file: ${resolved.path}`);
1108
+ return parseGhostTunnelEntries(readFileSync3(resolved.path, "utf8"), resolved.fileName);
1256
1109
  }
1257
- function parseNamespaceSlug(slug, config) {
1258
- const parts = slug.split(config.separator);
1259
- if (parts.length < config.tags.length) return null;
1260
- if (parts.length !== config.tags.length && !config.spreadTag) return null;
1261
- const namespace = {};
1262
- const spreadIndex = config.spreadTag ? config.tags.indexOf(config.spreadTag) : -1;
1263
- const spreadWidth = spreadIndex >= 0 ? parts.length - config.tags.length + 1 : 1;
1264
- let partIndex = 0;
1265
- for (const [tagIndex, tag] of config.tags.entries()) {
1266
- const value = tagIndex === spreadIndex ? parts.slice(partIndex, partIndex + spreadWidth).join(config.separator) : parts[partIndex];
1267
- if (!value || !isValidHostLabel(value)) return null;
1268
- if (tagIndex !== spreadIndex && value.includes(config.separator)) return null;
1269
- namespace[tag] = value;
1270
- partIndex += tagIndex === spreadIndex ? spreadWidth : 1;
1271
- }
1272
- return namespace;
1110
+ function listGhostTunnelEntries(options = {}) {
1111
+ const resolved = resolveGhostTunnelPath(options);
1112
+ if (!resolved.exists) return [];
1113
+ return readGhostTunnelEntries(options);
1273
1114
  }
1274
- function resolveGhostTunnelConfig(options, defaults) {
1275
- if (options === false || typeof options === "undefined") {
1276
- return {
1277
- enabled: false,
1278
- mode: DEFAULT_GHOST_TUNNEL_MODE,
1279
- domains: [],
1280
- subdomain: DEFAULT_GHOST_TUNNEL_SUBDOMAIN,
1281
- namespace: resolveNamespaceConfig(void 0),
1282
- displayUrls: [],
1283
- requireHttps: true,
1284
- requireAuth: true,
1285
- transport: resolveGhostTunnelTransport(void 0)
1286
- };
1115
+ function findGhostTunnelEntry(host, options = {}) {
1116
+ const normalizedHost = normalizeHost2(host);
1117
+ return listGhostTunnelEntries(options).find((entry) => entry.host === normalizedHost);
1118
+ }
1119
+
1120
+ // packages/ghost-tunnel/src/ghost-transport.ts
1121
+ import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1122
+ import { isIP } from "net";
1123
+ var DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM = "__localghost";
1124
+ var DEFAULT_GHOST_TUNNEL_IP_TRANSPORT_TTL_SECONDS = 10 * 60;
1125
+ function base64UrlEncode2(value) {
1126
+ return Buffer.from(value).toString("base64url");
1127
+ }
1128
+ function base64UrlDecode2(value) {
1129
+ return Buffer.from(value, "base64url").toString("utf8");
1130
+ }
1131
+ function signPayload2(payload, secret) {
1132
+ return createHmac2("sha256", secret).update(payload).digest("base64url");
1133
+ }
1134
+ function secureEqual2(left, right) {
1135
+ const leftBuffer = Buffer.from(left);
1136
+ const rightBuffer = Buffer.from(right);
1137
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual2(leftBuffer, rightBuffer);
1138
+ }
1139
+ function isValidIpv42(value) {
1140
+ return isIP(value) === 4;
1141
+ }
1142
+ function isPrivateIpv42(value) {
1143
+ if (!isValidIpv42(value)) return false;
1144
+ const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
1145
+ return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
1146
+ }
1147
+ function assertGhostTunnelIpAddress(address, allowPrivateNetworkAddress = false) {
1148
+ const normalized = address.trim();
1149
+ if (!isValidIpv42(normalized)) {
1150
+ throw new Error(`Ghost tunnel IP transport requires a valid IPv4 address: ${address}`);
1287
1151
  }
1288
- const config = typeof options === "string" ? { mode: options } : options;
1289
- const subdomain = config.subdomain ?? DEFAULT_GHOST_TUNNEL_SUBDOMAIN;
1290
- assertValidSubdomain(subdomain);
1291
- const domains = normalizeDomains(config.domains);
1292
- const enabled = config.enabled ?? true;
1293
- const adapter = resolveGhostTunnelAdapter(config.adapter);
1294
- const transport = resolveGhostTunnelTransport(config.transport ?? getLegacyGhostTunnelTransport(config.adapter));
1295
- const resolved = {
1296
- enabled,
1297
- mode: parseGhostTunnelMode(config.mode),
1298
- domains,
1299
- subdomain,
1300
- namespace: resolveNamespaceConfig(config.namespace),
1301
- ...config.preview ? { preview: config.preview } : {},
1302
- displayUrls: [],
1303
- requireHttps: config.requireHttps ?? true,
1304
- requireAuth: config.requireAuth ?? true,
1305
- transport,
1306
- ...adapter ? { adapter } : {}
1307
- };
1308
- if (!enabled) {
1309
- return resolved;
1152
+ if (!allowPrivateNetworkAddress && isPrivateIpv42(normalized)) {
1153
+ throw new Error(`Ghost tunnel IP transport requires explicit private-network opt-in: ${normalized}`);
1310
1154
  }
1311
- const previewUrl = maybeConstructPreviewUrl(resolved, defaults);
1312
- const displayUrls = previewUrl ? [previewUrl] : createDisplayUrls(resolved, defaults);
1313
- return {
1314
- ...resolved,
1315
- displayUrls,
1316
- ...displayUrls[0] ? { displayUrl: displayUrls[0] } : {},
1317
- ...previewUrl ? { previewUrl } : {}
1318
- };
1155
+ return normalized;
1319
1156
  }
1320
- function getGhostTunnelEntryHost(domain, options = {}) {
1321
- const config = toGhostTunnelConfig(options);
1322
- const normalizedDomain = normalizeDomain(domain);
1323
- if (!normalizedDomain) {
1324
- throw new Error(`Invalid ghost tunnel domain: ${domain}`);
1157
+ function assertRelayProtocol(value) {
1158
+ const protocol = value ?? "http";
1159
+ if (protocol !== "http" && protocol !== "https") {
1160
+ throw new Error(`Invalid ghost tunnel IP transport protocol: ${String(value)}`);
1325
1161
  }
1326
- return `${config.subdomain}.${normalizedDomain}`;
1162
+ return protocol;
1327
1163
  }
1328
- function getGhostTunnelWildcardHost(domain, options = {}) {
1329
- return `*.${getGhostTunnelEntryHost(domain, options)}`;
1164
+ function resolveTransportConfig(input) {
1165
+ return resolveGhostTunnelConfig({
1166
+ enabled: true,
1167
+ ...typeof input !== "undefined" ? { transport: input } : {}
1168
+ }).transport;
1330
1169
  }
1331
- function constructGhostTunnelHost(input) {
1332
- const config = toGhostTunnelConfig(input.ghostTunnel ?? {});
1333
- if (!config.enabled) {
1334
- throw new Error("Ghost tunnel is not enabled.");
1170
+ function resolveExpiresAt(input, now = /* @__PURE__ */ new Date()) {
1171
+ if (input.expiresAt) {
1172
+ if (Number.isNaN(Date.parse(input.expiresAt))) {
1173
+ throw new Error("Ghost tunnel IP transport requires a valid expiresAt value.");
1174
+ }
1175
+ return input.expiresAt;
1335
1176
  }
1336
- const namespaceValues = {
1337
- route: input.route,
1338
- project: input.project,
1339
- owner: input.owner,
1340
- ...input.values ?? {}
1341
- };
1342
- const slug = createNamespaceSlug(config.namespace, namespaceValues);
1343
- return `${slug}.${getGhostTunnelEntryHost(input.domain, config)}`;
1344
- }
1345
- function constructGhostTunnelUrl(input) {
1346
- const protocol = input.protocol ?? "https";
1347
- const host = constructGhostTunnelHost(input);
1348
- const url = new URL(`${protocol}://${host}/`);
1349
- if (input.path) {
1350
- url.pathname = `/${input.path.replace(/^\/+/, "")}`;
1177
+ const ttlSeconds = input.ttlSeconds ?? DEFAULT_GHOST_TUNNEL_IP_TRANSPORT_TTL_SECONDS;
1178
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) {
1179
+ throw new Error(`Ghost tunnel IP transport ttlSeconds must be a positive integer: ${ttlSeconds}`);
1351
1180
  }
1352
- if (input.searchParams instanceof URLSearchParams) {
1353
- url.search = input.searchParams.toString();
1354
- } else if (input.searchParams) {
1355
- for (const [key, value] of Object.entries(input.searchParams)) {
1356
- if (typeof value !== "undefined" && value !== null) {
1357
- url.searchParams.set(key, String(value));
1358
- }
1359
- }
1181
+ return new Date(now.getTime() + ttlSeconds * 1e3).toISOString();
1182
+ }
1183
+ function getBaseGhostTunnelUrl(input) {
1184
+ if ("url" in input) {
1185
+ return new URL(input.url);
1360
1186
  }
1361
- return url.toString();
1187
+ return new URL(constructGhostTunnelUrl(input));
1362
1188
  }
1363
- var constructGhostTunnelURL = constructGhostTunnelUrl;
1364
- function getGhostTunnelDefaultDisplayUrl(options = {}, defaults) {
1365
- const config = toGhostTunnelConfig(options);
1366
- if (!config.enabled) return null;
1367
- return createDisplayUrl(config, defaults);
1189
+ function signGhostTunnelIpTransportClaim(claim, signingSecret, options = {}) {
1190
+ const payload = {
1191
+ kind: "ip",
1192
+ host: assertExactRelayHost(claim.host),
1193
+ address: assertGhostTunnelIpAddress(claim.address, options.allowPrivateNetworkAddress),
1194
+ protocol: assertRelayProtocol(claim.protocol),
1195
+ expiresAt: resolveExpiresAt({ expiresAt: claim.expiresAt })
1196
+ };
1197
+ const encodedPayload = base64UrlEncode2(JSON.stringify(payload));
1198
+ const signature = signPayload2(encodedPayload, signingSecret);
1199
+ return {
1200
+ payload,
1201
+ token: `${encodedPayload}.${signature}`
1202
+ };
1368
1203
  }
1369
- function getGhostTunnelDisplayUrl(options, defaults) {
1370
- const config = toGhostTunnelConfig(options);
1371
- if (!config.enabled) return null;
1372
- return config.displayUrl ?? config.previewUrl ?? getGhostTunnelDefaultDisplayUrl(config, defaults);
1204
+ function verifyGhostTunnelIpTransportClaim(token, signingSecret, options) {
1205
+ const [encodedPayload, signature] = token.split(".");
1206
+ if (!encodedPayload || !signature || token.split(".").length !== 2) {
1207
+ throw new Error("Invalid ghost tunnel IP transport token.");
1208
+ }
1209
+ const expectedSignature = signPayload2(encodedPayload, signingSecret);
1210
+ if (!secureEqual2(signature, expectedSignature)) {
1211
+ throw new Error("Invalid ghost tunnel IP transport signature.");
1212
+ }
1213
+ const parsed = JSON.parse(base64UrlDecode2(encodedPayload));
1214
+ const host = assertExactRelayHost(parsed.host);
1215
+ const expectedHost = assertExactRelayHost(options.host);
1216
+ if (host !== expectedHost) {
1217
+ throw new Error("Ghost tunnel IP transport host mismatch.");
1218
+ }
1219
+ const now = options.now ?? /* @__PURE__ */ new Date();
1220
+ if (Number.isNaN(Date.parse(parsed.expiresAt)) || Date.parse(parsed.expiresAt) <= now.getTime()) {
1221
+ throw new Error("Ghost tunnel IP transport token has expired.");
1222
+ }
1223
+ return {
1224
+ kind: "ip",
1225
+ host,
1226
+ address: assertGhostTunnelIpAddress(parsed.address, options.allowPrivateNetworkAddress),
1227
+ protocol: assertRelayProtocol(parsed.protocol),
1228
+ expiresAt: parsed.expiresAt
1229
+ };
1373
1230
  }
1374
- function getGhostTunnelDisplayUrls(options, defaults) {
1375
- const config = toGhostTunnelConfig(options);
1376
- if (!config.enabled) return [];
1377
- if (config.displayUrls.length > 0) return config.displayUrls;
1378
- const displayUrl = getGhostTunnelDisplayUrl(config, defaults);
1379
- return displayUrl ? [displayUrl] : [];
1231
+ function constructGhostTunnelIpUrl(input) {
1232
+ const baseUrl = getBaseGhostTunnelUrl(input);
1233
+ const host = assertExactRelayHost(baseUrl.host);
1234
+ const token = signGhostTunnelIpTransportClaim({
1235
+ kind: "ip",
1236
+ host,
1237
+ address: input.address,
1238
+ protocol: input.targetProtocol ?? "http",
1239
+ expiresAt: resolveExpiresAt(input)
1240
+ }, input.signingSecret, {
1241
+ ...typeof input.allowPrivateNetworkAddress === "boolean" ? { allowPrivateNetworkAddress: input.allowPrivateNetworkAddress } : {}
1242
+ }).token;
1243
+ const queryParam = input.queryParam ?? DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM;
1244
+ baseUrl.searchParams.set(queryParam, token);
1245
+ return baseUrl.toString();
1380
1246
  }
1381
- function getGhostTunnelPreviewUrl(options) {
1382
- const config = toGhostTunnelConfig(options);
1383
- if (!config.enabled) return null;
1384
- return config.previewUrl ?? maybeConstructPreviewUrl(config) ?? null;
1247
+ function resolveGhostTunnelIpRedirect(input) {
1248
+ const transport = resolveTransportConfig(input.transport);
1249
+ if (transport.kind !== "ip") {
1250
+ throw new Error(`Ghost tunnel transport is not configured for IP redirect: ${transport.kind}`);
1251
+ }
1252
+ const queryParam = input.queryParam ?? DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM;
1253
+ const requestUrl = new URL(input.requestUrl);
1254
+ const token = requestUrl.searchParams.get(queryParam);
1255
+ if (!token) {
1256
+ throw new Error(`Ghost tunnel IP transport token is missing. Add ${queryParam}=... to the URL.`);
1257
+ }
1258
+ const claim = verifyGhostTunnelIpTransportClaim(token, input.signingSecret, {
1259
+ host: input.host,
1260
+ allowPrivateNetworkAddress: transport.allowPrivateNetworkAddress,
1261
+ ...input.now ? { now: input.now } : {}
1262
+ });
1263
+ requestUrl.searchParams.delete(queryParam);
1264
+ const target = assertRelayLocalTarget({
1265
+ protocol: claim.protocol,
1266
+ host: claim.address,
1267
+ port: input.entryPort
1268
+ }, {
1269
+ allowedHosts: [claim.address],
1270
+ allowPrivateNetworkTargets: transport.allowPrivateNetworkAddress
1271
+ });
1272
+ const redirectUrl = new URL(`${target.protocol}://${target.host}:${target.port}/`);
1273
+ redirectUrl.pathname = requestUrl.pathname;
1274
+ redirectUrl.search = requestUrl.searchParams.toString();
1275
+ redirectUrl.hash = requestUrl.hash;
1276
+ return {
1277
+ claim,
1278
+ queryParam,
1279
+ target,
1280
+ url: redirectUrl.toString()
1281
+ };
1385
1282
  }
1386
- function parseGhostTunnelHost(host, domain, options = {}) {
1387
- const config = toGhostTunnelConfig(options);
1388
- if (!config.enabled) return null;
1389
- const normalizedHost = normalizeDomain(host);
1390
- const normalizedDomain = normalizeDomain(domain);
1391
- if (!normalizedHost || !normalizedDomain) return null;
1392
- const entryHost = getGhostTunnelEntryHost(normalizedDomain, config);
1393
- const suffix = `.${entryHost}`;
1394
- if (!normalizedHost.endsWith(suffix)) return null;
1395
- const slug = normalizedHost.slice(0, -suffix.length);
1396
- if (!isValidHostLabel(slug)) return null;
1397
- const namespace = parseNamespaceSlug(slug, config.namespace);
1398
- if (!namespace) return null;
1283
+
1284
+ // packages/ghost-tunnel/src/ghost-tunnel-store.ts
1285
+ import { randomUUID } from "crypto";
1286
+ var DEFAULT_GHOST_TUNNEL_RESPONSE_TTL_SECONDS = 60;
1287
+ function base64Encode(value) {
1288
+ return value.toString("base64");
1289
+ }
1290
+ function encodeGhostTunnelBody(value) {
1291
+ return base64Encode(Buffer.isBuffer(value) ? value : Buffer.from(value));
1292
+ }
1293
+ function decodeGhostTunnelBody(value) {
1294
+ return value ? Buffer.from(value, "base64") : void 0;
1295
+ }
1296
+ function createGhostTunnelQueuedRequest(input) {
1297
+ const now = input.now ?? /* @__PURE__ */ new Date();
1298
+ const bodyBase64 = typeof input.body === "undefined" ? void 0 : encodeGhostTunnelBody(input.body);
1299
+ return {
1300
+ id: randomUUID(),
1301
+ host: input.host,
1302
+ method: input.method.toUpperCase(),
1303
+ path: input.path,
1304
+ headers: input.headers ?? {},
1305
+ createdAt: now.toISOString(),
1306
+ expiresAt: new Date(now.getTime() + input.ttlSeconds * 1e3).toISOString(),
1307
+ ...bodyBase64 ? { bodyBase64 } : {}
1308
+ };
1309
+ }
1310
+ function createGhostTunnelRouteHeartbeat(input) {
1311
+ const now = input.now ?? /* @__PURE__ */ new Date();
1312
+ return {
1313
+ host: input.host,
1314
+ agentId: input.agentId,
1315
+ target: input.target,
1316
+ updatedAt: now.toISOString(),
1317
+ expiresAt: new Date(now.getTime() + input.ttlSeconds * 1e3).toISOString()
1318
+ };
1319
+ }
1320
+ function isExpired(expiresAt, now = /* @__PURE__ */ new Date()) {
1321
+ const timestamp = Date.parse(expiresAt);
1322
+ return Number.isNaN(timestamp) || timestamp <= now.getTime();
1323
+ }
1324
+ function serializeJson(value) {
1325
+ return JSON.stringify(value);
1326
+ }
1327
+ function parseJson(value) {
1328
+ if (typeof value !== "string") return null;
1329
+ try {
1330
+ return JSON.parse(value);
1331
+ } catch {
1332
+ return null;
1333
+ }
1334
+ }
1335
+ function keyPart(value) {
1336
+ return value.toLowerCase().replace(/[^a-z0-9._:-]/g, "_");
1337
+ }
1338
+ function removeTrailingSlashes(value) {
1339
+ let end = value.length;
1340
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
1341
+ return value.slice(0, end);
1342
+ }
1343
+ var MemoryGhostTunnelStore = class {
1344
+ routes = /* @__PURE__ */ new Map();
1345
+ queues = /* @__PURE__ */ new Map();
1346
+ responses = /* @__PURE__ */ new Map();
1347
+ async heartbeatRoute(route) {
1348
+ this.routes.set(route.host, route);
1349
+ }
1350
+ async getRoute(host) {
1351
+ const route = this.routes.get(host);
1352
+ if (!route) return null;
1353
+ if (!isExpired(route.expiresAt)) return route;
1354
+ this.routes.delete(host);
1355
+ return null;
1356
+ }
1357
+ async enqueueRequest(request) {
1358
+ const queue = this.queues.get(request.host) ?? [];
1359
+ queue.push(request);
1360
+ this.queues.set(request.host, queue);
1361
+ }
1362
+ async claimRequest(host) {
1363
+ const queue = this.queues.get(host) ?? [];
1364
+ while (queue.length > 0) {
1365
+ const request = queue.shift();
1366
+ if (request && !isExpired(request.expiresAt)) {
1367
+ return request;
1368
+ }
1369
+ }
1370
+ return null;
1371
+ }
1372
+ async writeResponse(response, ttlSeconds) {
1373
+ this.responses.set(response.id, {
1374
+ value: response,
1375
+ expiresAt: new Date(Date.now() + ttlSeconds * 1e3).toISOString()
1376
+ });
1377
+ }
1378
+ async readResponse(requestId) {
1379
+ const response = this.responses.get(requestId);
1380
+ if (!response) return null;
1381
+ if (!isExpired(response.expiresAt)) return response.value;
1382
+ this.responses.delete(requestId);
1383
+ return null;
1384
+ }
1385
+ async cleanup(requestId) {
1386
+ this.responses.delete(requestId);
1387
+ }
1388
+ };
1389
+ function createMemoryGhostTunnelStore() {
1390
+ return new MemoryGhostTunnelStore();
1391
+ }
1392
+ var RedisGhostTunnelStore = class {
1393
+ url;
1394
+ token;
1395
+ namespace;
1396
+ fetchImpl;
1397
+ constructor(options) {
1398
+ this.url = removeTrailingSlashes(options.url);
1399
+ this.token = options.token;
1400
+ this.namespace = options.namespace ?? "localghost";
1401
+ this.fetchImpl = options.fetch ?? fetch;
1402
+ }
1403
+ key(kind, id) {
1404
+ return `${this.namespace}:ghost-tunnel:${kind}:${keyPart(id)}`;
1405
+ }
1406
+ async command(command, ...args) {
1407
+ const response = await this.fetchImpl(this.url, {
1408
+ method: "POST",
1409
+ headers: {
1410
+ authorization: `Bearer ${this.token}`,
1411
+ "content-type": "application/json"
1412
+ },
1413
+ body: JSON.stringify([command, ...args])
1414
+ });
1415
+ if (!response.ok) {
1416
+ throw new Error(`Redis Ghost Tunnel command failed: ${response.status} ${response.statusText}`);
1417
+ }
1418
+ const payload = await response.json();
1419
+ if (payload.error) {
1420
+ throw new Error(`Redis Ghost Tunnel command failed: ${payload.error}`);
1421
+ }
1422
+ return typeof payload.result === "undefined" ? null : payload.result;
1423
+ }
1424
+ async heartbeatRoute(route, ttlSeconds) {
1425
+ await this.command("SET", this.key("route", route.host), serializeJson(route), "EX", ttlSeconds);
1426
+ }
1427
+ async getRoute(host) {
1428
+ const route = parseJson(await this.command("GET", this.key("route", host)));
1429
+ return route && !isExpired(route.expiresAt) ? route : null;
1430
+ }
1431
+ async enqueueRequest(request, ttlSeconds) {
1432
+ const queueKey = this.key("queue", request.host);
1433
+ await this.command("RPUSH", queueKey, serializeJson(request));
1434
+ await this.command("EXPIRE", queueKey, ttlSeconds);
1435
+ }
1436
+ async claimRequest(host) {
1437
+ const queueKey = this.key("queue", host);
1438
+ while (true) {
1439
+ const request = parseJson(await this.command("LPOP", queueKey));
1440
+ if (!request) return null;
1441
+ if (!isExpired(request.expiresAt)) return request;
1442
+ }
1443
+ }
1444
+ async writeResponse(response, ttlSeconds) {
1445
+ await this.command("SET", this.key("response", response.id), serializeJson(response), "EX", ttlSeconds);
1446
+ }
1447
+ async readResponse(requestId) {
1448
+ return parseJson(await this.command("GET", this.key("response", requestId)));
1449
+ }
1450
+ async cleanup(requestId) {
1451
+ await this.command("DEL", this.key("response", requestId));
1452
+ }
1453
+ };
1454
+ function createRedisGhostTunnelStore(options) {
1455
+ return new RedisGhostTunnelStore(options);
1456
+ }
1457
+ function resolveRedisGhostTunnelEnv(env = process.env) {
1458
+ const candidates = [
1459
+ env.LOCALGHOST_REDIS_REST_URL && env.LOCALGHOST_REDIS_REST_TOKEN ? { url: env.LOCALGHOST_REDIS_REST_URL, token: env.LOCALGHOST_REDIS_REST_TOKEN, source: "localghost" } : null,
1460
+ env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN ? { url: env.UPSTASH_REDIS_REST_URL, token: env.UPSTASH_REDIS_REST_TOKEN, source: "upstash" } : null,
1461
+ env.KV_REST_API_URL && env.KV_REST_API_TOKEN ? { url: env.KV_REST_API_URL, token: env.KV_REST_API_TOKEN, source: "vercel-kv" } : null,
1462
+ env.REDIS_REST_API_URL && env.REDIS_REST_API_TOKEN ? { url: env.REDIS_REST_API_URL, token: env.REDIS_REST_API_TOKEN, source: "redis" } : null
1463
+ ];
1464
+ const match = candidates.find((candidate) => Boolean(candidate));
1465
+ if (!match) {
1466
+ throw new Error("Ghost Tunnel Redis transport requires REST env vars: UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN, KV_REST_API_URL/KV_REST_API_TOKEN, or LOCALGHOST_REDIS_REST_URL/LOCALGHOST_REDIS_REST_TOKEN.");
1467
+ }
1468
+ return match;
1469
+ }
1470
+ function createRedisGhostTunnelStoreFromEnv(input = {}) {
1471
+ const resolved = resolveRedisGhostTunnelEnv(input.env);
1472
+ return createRedisGhostTunnelStore({
1473
+ url: resolved.url,
1474
+ token: resolved.token,
1475
+ ...input.namespace ? { namespace: input.namespace } : {},
1476
+ ...input.fetch ? { fetch: input.fetch } : {}
1477
+ });
1478
+ }
1479
+
1480
+ // packages/ghost-tunnel/src/ghost-agent.ts
1481
+ import { randomUUID as randomUUID2 } from "crypto";
1482
+ function isStopped(signal, localSignal) {
1483
+ return localSignal.aborted || signal?.aborted === true;
1484
+ }
1485
+ function wait(ms, signal, localSignal) {
1486
+ if (isStopped(signal, localSignal)) return Promise.resolve();
1487
+ return new Promise((resolve6) => {
1488
+ const timeout = setTimeout(resolve6, ms);
1489
+ const stop = () => {
1490
+ clearTimeout(timeout);
1491
+ resolve6();
1492
+ };
1493
+ signal?.addEventListener("abort", stop, { once: true });
1494
+ localSignal.addEventListener("abort", stop, { once: true });
1495
+ });
1496
+ }
1497
+ function toHeaderRecord(headers) {
1498
+ const result = {};
1499
+ headers.forEach((value, name) => {
1500
+ result[name] = value;
1501
+ });
1502
+ return result;
1503
+ }
1504
+ function hasRequestBody(method) {
1505
+ return method !== "GET" && method !== "HEAD";
1506
+ }
1507
+ async function serveGhostTunnelLocalRequest(input) {
1508
+ const fetchImpl = input.fetch ?? fetch;
1509
+ const localUrl = new URL(`${input.target.protocol}://${input.target.host}:${input.target.port}/`);
1510
+ const requestPath = new URL(input.request.path, "http://localghost.invalid");
1511
+ localUrl.pathname = requestPath.pathname;
1512
+ localUrl.search = requestPath.search;
1513
+ try {
1514
+ const body = hasRequestBody(input.request.method) ? decodeGhostTunnelBody(input.request.bodyBase64) : void 0;
1515
+ const response = await fetchImpl(localUrl, {
1516
+ method: input.request.method,
1517
+ headers: {
1518
+ ...stripRelayForwardHeaders(input.request.headers),
1519
+ "x-forwarded-host": input.request.host,
1520
+ "x-localghost-tunnel": "1"
1521
+ },
1522
+ ...body ? { body } : {}
1523
+ });
1524
+ const responseBody = Buffer.from(await response.arrayBuffer());
1525
+ if (responseBody.byteLength > input.maxResponseBodyBytes) {
1526
+ throw new Error(`Ghost Tunnel response exceeded ${input.maxResponseBodyBytes} bytes.`);
1527
+ }
1528
+ return {
1529
+ id: input.request.id,
1530
+ status: response.status,
1531
+ headers: toHeaderRecord(response.headers),
1532
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1533
+ ...responseBody.byteLength > 0 ? { bodyBase64: encodeGhostTunnelBody(responseBody) } : {}
1534
+ };
1535
+ } catch (error) {
1536
+ return {
1537
+ id: input.request.id,
1538
+ status: 502,
1539
+ headers: {
1540
+ "content-type": "text/plain; charset=utf-8",
1541
+ "cache-control": "no-store"
1542
+ },
1543
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1544
+ error: error instanceof Error ? error.message : String(error),
1545
+ bodyBase64: encodeGhostTunnelBody("Ghost Tunnel local target failed.")
1546
+ };
1547
+ }
1548
+ }
1549
+ async function heartbeatRoutes(input) {
1550
+ for (const entry of input.entries) {
1551
+ const target = assertRelayLocalTarget({ host: input.targetHost, port: entry.port });
1552
+ await input.store.heartbeatRoute(createGhostTunnelRouteHeartbeat({
1553
+ host: entry.host,
1554
+ agentId: input.agentId,
1555
+ target,
1556
+ ttlSeconds: input.routeTtlSeconds
1557
+ }), input.routeTtlSeconds);
1558
+ }
1559
+ }
1560
+ async function claimAndServe(input) {
1561
+ const request = await input.store.claimRequest(input.entry.host);
1562
+ if (!request) return false;
1563
+ const target = assertRelayLocalTarget({ host: input.targetHost, port: input.entry.port });
1564
+ const response = await serveGhostTunnelLocalRequest({
1565
+ request,
1566
+ target,
1567
+ maxResponseBodyBytes: input.maxResponseBodyBytes,
1568
+ ...input.fetch ? { fetch: input.fetch } : {}
1569
+ });
1570
+ await input.store.writeResponse(response, input.requestTtlSeconds);
1571
+ return true;
1572
+ }
1573
+ function startGhostTunnelAgent(options) {
1574
+ const controller = new AbortController();
1575
+ const localSignal = controller.signal;
1576
+ const signal = options.signal;
1577
+ const agentId = options.agentId ?? `localghost-${randomUUID2()}`;
1578
+ const targetHost = options.targetHost ?? "127.0.0.1";
1579
+ const routeTtlSeconds = options.routeTtlSeconds ?? 30;
1580
+ const requestTtlSeconds = options.requestTtlSeconds ?? 60;
1581
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
1582
+ const maxResponseBodyBytes = options.maxResponseBodyBytes ?? 5 * 1024 * 1024;
1583
+ const done = (async () => {
1584
+ if (options.entries.length === 0) {
1585
+ throw new Error("Ghost Tunnel agent requires at least one .ghosttunnel entry.");
1586
+ }
1587
+ options.log?.(`localghost tunnel agent ${agentId}`);
1588
+ for (const entry of options.entries) {
1589
+ options.log?.(` ${entry.host} -> ${targetHost}:${entry.port}`);
1590
+ }
1591
+ let lastHeartbeat = 0;
1592
+ while (!isStopped(signal, localSignal)) {
1593
+ const now = Date.now();
1594
+ if (now - lastHeartbeat >= Math.max(1e3, Math.floor(routeTtlSeconds * 1e3 / 3))) {
1595
+ await heartbeatRoutes({
1596
+ entries: options.entries,
1597
+ store: options.store,
1598
+ agentId,
1599
+ targetHost,
1600
+ routeTtlSeconds
1601
+ });
1602
+ lastHeartbeat = now;
1603
+ }
1604
+ let served = false;
1605
+ for (const entry of options.entries) {
1606
+ served = await claimAndServe({
1607
+ entry,
1608
+ store: options.store,
1609
+ targetHost,
1610
+ requestTtlSeconds,
1611
+ maxResponseBodyBytes,
1612
+ ...options.fetch ? { fetch: options.fetch } : {}
1613
+ }) || served;
1614
+ }
1615
+ if (!served) {
1616
+ await wait(pollIntervalMs, signal, localSignal);
1617
+ }
1618
+ }
1619
+ })();
1399
1620
  return {
1400
- host: normalizedHost,
1401
- slug,
1402
- namespace,
1403
- entryHost,
1404
- wildcardHost: `*.${entryHost}`,
1405
- domain: normalizedDomain
1621
+ agentId,
1622
+ stop() {
1623
+ controller.abort();
1624
+ },
1625
+ done
1406
1626
  };
1407
1627
  }
1408
- function assertSecureGhostTunnelRequest(input) {
1409
- const config = toGhostTunnelConfig(input.ghostTunnel);
1410
- if (!config.enabled) {
1411
- throw new Error("Ghost tunnel is not enabled.");
1412
- }
1413
- if (config.requireHttps && input.protocol !== "https") {
1414
- throw new Error("Ghost tunnel requests must use HTTPS.");
1415
- }
1416
- if (config.requireAuth && input.authenticated !== true) {
1417
- throw new Error("Ghost tunnel requests must be authenticated.");
1418
- }
1419
- const route = parseGhostTunnelHost(input.host, input.domain, config);
1420
- if (!route) {
1421
- throw new Error(`Host is not a valid ghost tunnel host for ${input.domain}.`);
1422
- }
1423
- return route;
1424
- }
1425
1628
 
1426
- // src/ghost-transport.ts
1427
- var DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM = "__localghost";
1428
- var DEFAULT_GHOST_TUNNEL_IP_TRANSPORT_TTL_SECONDS = 10 * 60;
1429
- function base64UrlEncode2(value) {
1430
- return Buffer.from(value).toString("base64url");
1629
+ // packages/ghost-tunnel/src/ghost-request.ts
1630
+ function getGhostTunnelReadOptions(input) {
1631
+ return {
1632
+ ...input.cwd ? { cwd: input.cwd } : {},
1633
+ ...input.ghostTunnelFile ? { fileName: input.ghostTunnelFile } : {}
1634
+ };
1431
1635
  }
1432
- function base64UrlDecode2(value) {
1433
- return Buffer.from(value, "base64url").toString("utf8");
1636
+ async function resolveGhostTunnelRequest(input) {
1637
+ const configured = input.resolveGhostTunnel ? await input.resolveGhostTunnel({ domain: input.domain, ...input.cwd ? { cwd: input.cwd } : {} }) : input.ghostTunnel;
1638
+ const ghostTunnel = typeof configured === "object" && configured !== null && "enabled" in configured ? configured : resolveGhostTunnelConfig(configured, {
1639
+ domain: input.domain
1640
+ });
1641
+ const route = assertSecureGhostTunnelRequest({
1642
+ host: input.host,
1643
+ domain: input.domain,
1644
+ protocol: input.protocol,
1645
+ ghostTunnel,
1646
+ ...typeof input.authenticated === "boolean" ? { authenticated: input.authenticated } : {}
1647
+ });
1648
+ const ghostTunnelPath = resolveGhostTunnelPath(getGhostTunnelReadOptions(input));
1649
+ const entry = findGhostTunnelEntry(route.host, getGhostTunnelReadOptions(input));
1650
+ const target = entry ? assertRelayLocalTarget({ host: "127.0.0.1", port: entry.port }) : void 0;
1651
+ return {
1652
+ route,
1653
+ ghostTunnel,
1654
+ ...entry ? { entry } : {},
1655
+ ...target ? { target } : {},
1656
+ ...ghostTunnelPath.exists ? { ghostTunnelPath: ghostTunnelPath.path } : {}
1657
+ };
1434
1658
  }
1435
- function signPayload2(payload, secret) {
1436
- return createHmac2("sha256", secret).update(payload).digest("base64url");
1659
+ function renderGhostTunnelRouteMissingResponse(resolved) {
1660
+ return {
1661
+ status: 404,
1662
+ headers: {
1663
+ "content-type": "text/html; charset=utf-8",
1664
+ "cache-control": "no-store",
1665
+ "x-localghost-relay": "missing",
1666
+ "x-localghost-route": resolved.route.slug
1667
+ },
1668
+ body: [
1669
+ "<!doctype html>",
1670
+ "<html>",
1671
+ '<head><meta charset="utf-8"><title>Ghost Tunnel route not configured</title></head>',
1672
+ "<body>",
1673
+ "<h1>Ghost Tunnel route not configured</h1>",
1674
+ `<p>The wildcard host <code>${resolved.route.host}</code> reached the deployed Ghost Tunnel handler, but no exact <code>.ghosttunnel</code> entry matched it.</p>`,
1675
+ "</body>",
1676
+ "</html>"
1677
+ ].join("")
1678
+ };
1437
1679
  }
1438
- function secureEqual2(left, right) {
1439
- const leftBuffer = Buffer.from(left);
1440
- const rightBuffer = Buffer.from(right);
1441
- return leftBuffer.length === rightBuffer.length && timingSafeEqual2(leftBuffer, rightBuffer);
1680
+ function renderGhostTunnelRelayOfflineResponse(resolved) {
1681
+ const response = renderRelayOfflineResponse();
1682
+ return {
1683
+ ...response,
1684
+ headers: {
1685
+ ...response.headers,
1686
+ "x-localghost-relay": "offline",
1687
+ "x-localghost-route": resolved.route.slug,
1688
+ "x-localghost-entry": resolved.entry ? "configured" : "missing"
1689
+ },
1690
+ body: [
1691
+ "<!doctype html>",
1692
+ "<html>",
1693
+ '<head><meta charset="utf-8"><title>Ghost Tunnel offline</title></head>',
1694
+ "<body>",
1695
+ "<h1>Ghost Tunnel offline</h1>",
1696
+ `<p>The wildcard host <code>${resolved.route.host}</code> reached the deployed Ghost Tunnel handler.</p>`,
1697
+ resolved.entry ? "<p>The route is configured locally, but no active local relay connection is available yet.</p>" : "<p>No exact <code>.ghosttunnel</code> entry matched this host.</p>",
1698
+ "</body>",
1699
+ "</html>"
1700
+ ].join("")
1701
+ };
1442
1702
  }
1443
- function isValidIpv42(value) {
1444
- return isIP(value) === 4;
1703
+
1704
+ // packages/ghost-tunnel/src/vercel.ts
1705
+ function getHeaderValue(value) {
1706
+ if (Array.isArray(value)) return value[0] ?? "";
1707
+ return value ?? "";
1445
1708
  }
1446
- function isPrivateIpv42(value) {
1447
- if (!isValidIpv42(value)) return false;
1448
- const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
1449
- return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
1709
+ function getTrustedProtocol(request) {
1710
+ const forwarded = getHeaderValue(request.headers["x-forwarded-proto"]).split(",")[0]?.trim().toLowerCase();
1711
+ return forwarded === "http" ? "http" : "https";
1450
1712
  }
1451
- function assertGhostTunnelIpAddress(address, allowPrivateNetworkAddress = false) {
1452
- const normalized = address.trim();
1453
- if (!isValidIpv42(normalized)) {
1454
- throw new Error(`Ghost tunnel IP transport requires a valid IPv4 address: ${address}`);
1455
- }
1456
- if (!allowPrivateNetworkAddress && isPrivateIpv42(normalized)) {
1457
- throw new Error(`Ghost tunnel IP transport requires explicit private-network opt-in: ${normalized}`);
1458
- }
1459
- return normalized;
1713
+ function getTrustedRequestUrl(request, host, protocol) {
1714
+ return new URL(request.url ?? "/", `${protocol}://${host}`).toString();
1460
1715
  }
1461
- function assertRelayProtocol(value) {
1462
- const protocol = value ?? "http";
1463
- if (protocol !== "http" && protocol !== "https") {
1464
- throw new Error(`Invalid ghost tunnel IP transport protocol: ${String(value)}`);
1465
- }
1466
- return protocol;
1716
+ function getTunnelRequestPath(request) {
1717
+ const url = new URL(request.url ?? "/", "http://localghost.invalid");
1718
+ return `${url.pathname}${url.search}`;
1467
1719
  }
1468
- function resolveTransportConfig(input) {
1469
- return resolveGhostTunnelConfig({
1470
- enabled: true,
1471
- ...typeof input !== "undefined" ? { transport: input } : {}
1472
- }).transport;
1720
+ function normalizeRequestHeaders(headers) {
1721
+ const stripped = stripRelayForwardHeaders(headers);
1722
+ return Object.fromEntries(Object.entries(stripped).map(([name, value]) => [
1723
+ name,
1724
+ Array.isArray(value) ? value.join(", ") : value
1725
+ ]));
1473
1726
  }
1474
- function resolveExpiresAt(input, now = /* @__PURE__ */ new Date()) {
1475
- if (input.expiresAt) {
1476
- if (Number.isNaN(Date.parse(input.expiresAt))) {
1477
- throw new Error("Ghost tunnel IP transport requires a valid expiresAt value.");
1727
+ function getTunnelStore(options, namespace) {
1728
+ return options.tunnelStore ?? createRedisGhostTunnelStoreFromEnv({
1729
+ ...options.tunnelEnv ? { env: options.tunnelEnv } : {},
1730
+ namespace
1731
+ });
1732
+ }
1733
+ async function readRequestBody(request, maxBytes) {
1734
+ if (!request[Symbol.asyncIterator]) return void 0;
1735
+ const chunks = [];
1736
+ let size = 0;
1737
+ for await (const chunk of request) {
1738
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1739
+ size += buffer.byteLength;
1740
+ if (size > maxBytes) {
1741
+ throw new Error(`Ghost Tunnel request exceeded ${maxBytes} bytes.`);
1478
1742
  }
1479
- return input.expiresAt;
1480
- }
1481
- const ttlSeconds = input.ttlSeconds ?? DEFAULT_GHOST_TUNNEL_IP_TRANSPORT_TTL_SECONDS;
1482
- if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) {
1483
- throw new Error(`Ghost tunnel IP transport ttlSeconds must be a positive integer: ${ttlSeconds}`);
1743
+ chunks.push(buffer);
1484
1744
  }
1485
- return new Date(now.getTime() + ttlSeconds * 1e3).toISOString();
1745
+ return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
1486
1746
  }
1487
- function getBaseGhostTunnelUrl(input) {
1488
- if ("url" in input) {
1489
- return new URL(input.url);
1747
+ function writeResponse(response, payload) {
1748
+ response.statusCode = payload.status;
1749
+ for (const [name, value] of Object.entries(payload.headers)) {
1750
+ response.setHeader(name, value);
1490
1751
  }
1491
- return new URL(constructGhostTunnelUrl(input));
1752
+ response.end(payload.body);
1492
1753
  }
1493
- function signGhostTunnelIpTransportClaim(claim, signingSecret, options = {}) {
1494
- const payload = {
1495
- kind: "ip",
1496
- host: assertExactRelayHost(claim.host),
1497
- address: assertGhostTunnelIpAddress(claim.address, options.allowPrivateNetworkAddress),
1498
- protocol: assertRelayProtocol(claim.protocol),
1499
- expiresAt: resolveExpiresAt({ expiresAt: claim.expiresAt })
1754
+ function renderGhostTunnelIpRedirectResponse(url) {
1755
+ return {
1756
+ status: 307,
1757
+ headers: {
1758
+ location: url,
1759
+ "cache-control": "no-store",
1760
+ "content-type": "text/html; charset=utf-8",
1761
+ "x-localghost-relay": "ip"
1762
+ },
1763
+ body: [
1764
+ "<!doctype html>",
1765
+ "<html>",
1766
+ '<head><meta charset="utf-8"><title>Redirecting to local preview</title></head>',
1767
+ "<body>",
1768
+ `<p>Redirecting to <a href="${url}">${url}</a>.</p>`,
1769
+ "</body>",
1770
+ "</html>"
1771
+ ].join("")
1500
1772
  };
1501
- const encodedPayload = base64UrlEncode2(JSON.stringify(payload));
1502
- const signature = signPayload2(encodedPayload, signingSecret);
1773
+ }
1774
+ function renderGhostTunnelTransportRejectedResponse(message, status = 400) {
1503
1775
  return {
1504
- payload,
1505
- token: `${encodedPayload}.${signature}`
1776
+ status,
1777
+ headers: {
1778
+ "content-type": "text/html; charset=utf-8",
1779
+ "cache-control": "no-store",
1780
+ "x-localghost-relay": "rejected"
1781
+ },
1782
+ body: [
1783
+ "<!doctype html>",
1784
+ "<html>",
1785
+ '<head><meta charset="utf-8"><title>Ghost Tunnel transport rejected</title></head>',
1786
+ "<body>",
1787
+ "<h1>Ghost Tunnel transport rejected</h1>",
1788
+ `<p>${message}</p>`,
1789
+ "</body>",
1790
+ "</html>"
1791
+ ].join("")
1792
+ };
1793
+ }
1794
+ function renderGhostTunnelTunnelTimeoutResponse() {
1795
+ return {
1796
+ status: 504,
1797
+ headers: {
1798
+ "content-type": "text/html; charset=utf-8",
1799
+ "cache-control": "no-store",
1800
+ "x-localghost-relay": "timeout"
1801
+ },
1802
+ body: [
1803
+ "<!doctype html>",
1804
+ "<html>",
1805
+ '<head><meta charset="utf-8"><title>Ghost Tunnel timed out</title></head>',
1806
+ "<body>",
1807
+ "<h1>Ghost Tunnel timed out</h1>",
1808
+ "<p>The deployed handler did not receive a local response before the request window closed.</p>",
1809
+ "</body>",
1810
+ "</html>"
1811
+ ].join("")
1506
1812
  };
1507
1813
  }
1508
- function verifyGhostTunnelIpTransportClaim(token, signingSecret, options) {
1509
- const [encodedPayload, signature] = token.split(".");
1510
- if (!encodedPayload || !signature || token.split(".").length !== 2) {
1511
- throw new Error("Invalid ghost tunnel IP transport token.");
1512
- }
1513
- const expectedSignature = signPayload2(encodedPayload, signingSecret);
1514
- if (!secureEqual2(signature, expectedSignature)) {
1515
- throw new Error("Invalid ghost tunnel IP transport signature.");
1516
- }
1517
- const parsed = JSON.parse(base64UrlDecode2(encodedPayload));
1518
- const host = assertExactRelayHost(parsed.host);
1519
- const expectedHost = assertExactRelayHost(options.host);
1520
- if (host !== expectedHost) {
1521
- throw new Error("Ghost tunnel IP transport host mismatch.");
1522
- }
1523
- const now = options.now ?? /* @__PURE__ */ new Date();
1524
- if (Number.isNaN(Date.parse(parsed.expiresAt)) || Date.parse(parsed.expiresAt) <= now.getTime()) {
1525
- throw new Error("Ghost tunnel IP transport token has expired.");
1526
- }
1814
+ function renderGhostTunnelQueuedResponse(response) {
1815
+ const body = decodeGhostTunnelBody(response.bodyBase64)?.toString() ?? "";
1527
1816
  return {
1528
- kind: "ip",
1529
- host,
1530
- address: assertGhostTunnelIpAddress(parsed.address, options.allowPrivateNetworkAddress),
1531
- protocol: assertRelayProtocol(parsed.protocol),
1532
- expiresAt: parsed.expiresAt
1817
+ status: response.status,
1818
+ headers: {
1819
+ ...response.headers,
1820
+ "x-localghost-relay": response.error ? "target-error" : "tunnel"
1821
+ },
1822
+ body
1533
1823
  };
1534
1824
  }
1535
- function constructGhostTunnelIpUrl(input) {
1536
- const baseUrl = getBaseGhostTunnelUrl(input);
1537
- const host = assertExactRelayHost(baseUrl.host);
1538
- const token = signGhostTunnelIpTransportClaim({
1539
- kind: "ip",
1540
- host,
1541
- address: input.address,
1542
- protocol: input.targetProtocol ?? "http",
1543
- expiresAt: resolveExpiresAt(input)
1544
- }, input.signingSecret, {
1545
- ...typeof input.allowPrivateNetworkAddress === "boolean" ? { allowPrivateNetworkAddress: input.allowPrivateNetworkAddress } : {}
1546
- }).token;
1547
- const queryParam = input.queryParam ?? DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM;
1548
- baseUrl.searchParams.set(queryParam, token);
1549
- return baseUrl.toString();
1550
- }
1551
- function resolveGhostTunnelIpRedirect(input) {
1552
- const transport = resolveTransportConfig(input.transport);
1553
- if (transport.kind !== "ip") {
1554
- throw new Error(`Ghost tunnel transport is not configured for IP redirect: ${transport.kind}`);
1825
+ async function waitForTunnelResponse(input) {
1826
+ const startedAt = Date.now();
1827
+ while (Date.now() - startedAt < input.waitMs) {
1828
+ const response = await input.store.readResponse(input.requestId);
1829
+ if (response) {
1830
+ await input.store.cleanup(input.requestId);
1831
+ return response;
1832
+ }
1833
+ await new Promise((resolve6) => setTimeout(resolve6, input.pollIntervalMs));
1555
1834
  }
1556
- const queryParam = input.queryParam ?? DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM;
1557
- const requestUrl = new URL(input.requestUrl);
1558
- const token = requestUrl.searchParams.get(queryParam);
1559
- if (!token) {
1560
- throw new Error(`Ghost tunnel IP transport token is missing. Add ${queryParam}=... to the URL.`);
1835
+ return null;
1836
+ }
1837
+ async function resolveAuthenticatedState(input, request) {
1838
+ if (typeof input === "function") {
1839
+ return await input(request);
1561
1840
  }
1562
- const claim = verifyGhostTunnelIpTransportClaim(token, input.signingSecret, {
1563
- host: input.host,
1564
- allowPrivateNetworkAddress: transport.allowPrivateNetworkAddress,
1565
- ...input.now ? { now: input.now } : {}
1566
- });
1567
- requestUrl.searchParams.delete(queryParam);
1568
- const target = assertRelayLocalTarget({
1569
- protocol: claim.protocol,
1570
- host: claim.address,
1571
- port: input.entryPort
1572
- }, {
1573
- allowedHosts: [claim.address],
1574
- allowPrivateNetworkTargets: transport.allowPrivateNetworkAddress
1575
- });
1576
- const redirectUrl = new URL(`${target.protocol}://${target.host}:${target.port}/`);
1577
- redirectUrl.pathname = requestUrl.pathname;
1578
- redirectUrl.search = requestUrl.searchParams.toString();
1579
- redirectUrl.hash = requestUrl.hash;
1580
- return {
1581
- claim,
1582
- queryParam,
1583
- target,
1584
- url: redirectUrl.toString()
1841
+ return input;
1842
+ }
1843
+ function createVercelGhostTunnelHandler(options) {
1844
+ return async function handler(request, response) {
1845
+ const host = getHeaderValue(request.headers.host);
1846
+ const protocol = getTrustedProtocol(request);
1847
+ try {
1848
+ const authenticated = typeof options.authenticated !== "undefined" ? await resolveAuthenticatedState(options.authenticated, request) : void 0;
1849
+ const resolved = await resolveGhostTunnelRequest({
1850
+ ...options.cwd ? { cwd: options.cwd } : {},
1851
+ ...typeof options.ghostTunnel !== "undefined" ? { ghostTunnel: options.ghostTunnel } : {},
1852
+ ...options.resolveGhostTunnel ? { resolveGhostTunnel: options.resolveGhostTunnel } : {},
1853
+ ...options.ghostTunnelFile ? { ghostTunnelFile: options.ghostTunnelFile } : {},
1854
+ host,
1855
+ domain: options.domain,
1856
+ protocol,
1857
+ ...typeof authenticated === "boolean" ? { authenticated } : {}
1858
+ });
1859
+ if (!resolved.entry) {
1860
+ writeResponse(response, renderGhostTunnelRouteMissingResponse(resolved));
1861
+ return;
1862
+ }
1863
+ if (resolved.ghostTunnel.transport.kind === "ip") {
1864
+ if (!options.ipSigningSecret) {
1865
+ writeResponse(response, renderGhostTunnelTransportRejectedResponse("Ghost Tunnel IP transport requires ipSigningSecret in the deployed handler.", 500));
1866
+ return;
1867
+ }
1868
+ try {
1869
+ const redirect = resolveGhostTunnelIpRedirect({
1870
+ requestUrl: getTrustedRequestUrl(request, host, protocol),
1871
+ host: resolved.route.host,
1872
+ entryPort: resolved.entry.port,
1873
+ signingSecret: options.ipSigningSecret,
1874
+ transport: resolved.ghostTunnel.transport
1875
+ });
1876
+ writeResponse(response, renderGhostTunnelIpRedirectResponse(redirect.url));
1877
+ } catch (error) {
1878
+ const message = error instanceof Error ? error.message : String(error);
1879
+ const status = /expired/i.test(message) ? 410 : 400;
1880
+ writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, status));
1881
+ }
1882
+ return;
1883
+ }
1884
+ if (resolved.ghostTunnel.transport.kind === "tunnel") {
1885
+ const transport = resolved.ghostTunnel.transport;
1886
+ const store = getTunnelStore(options, transport.store.namespace);
1887
+ const route = await store.getRoute(resolved.route.host);
1888
+ if (!route) {
1889
+ writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
1890
+ return;
1891
+ }
1892
+ try {
1893
+ const requestBody = await readRequestBody(request, transport.maxRequestBodyBytes);
1894
+ const queuedRequest = createGhostTunnelQueuedRequest({
1895
+ host: resolved.route.host,
1896
+ method: request.method ?? "GET",
1897
+ path: getTunnelRequestPath(request),
1898
+ headers: normalizeRequestHeaders(request.headers),
1899
+ ...requestBody ? { body: requestBody } : {},
1900
+ ttlSeconds: transport.requestTtlSeconds
1901
+ });
1902
+ await store.enqueueRequest(queuedRequest, transport.requestTtlSeconds);
1903
+ const tunnelResponse = await waitForTunnelResponse({
1904
+ store,
1905
+ requestId: queuedRequest.id,
1906
+ waitMs: transport.waitMs,
1907
+ pollIntervalMs: transport.pollIntervalMs
1908
+ });
1909
+ writeResponse(response, tunnelResponse ? renderGhostTunnelQueuedResponse(tunnelResponse) : renderGhostTunnelTunnelTimeoutResponse());
1910
+ } catch (error) {
1911
+ const message = error instanceof Error ? error.message : String(error);
1912
+ writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, 400));
1913
+ }
1914
+ return;
1915
+ }
1916
+ writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
1917
+ } catch (error) {
1918
+ const message = error instanceof Error ? error.message : String(error);
1919
+ writeResponse(response, {
1920
+ status: /authenticated/i.test(message) ? 401 : 404,
1921
+ headers: {
1922
+ "content-type": "text/plain; charset=utf-8",
1923
+ "cache-control": "no-store",
1924
+ "x-localghost-relay": "rejected"
1925
+ },
1926
+ body: message
1927
+ });
1928
+ }
1585
1929
  };
1586
1930
  }
1587
1931
 
@@ -1590,10 +1934,10 @@ import { dirname as dirname3, join as join3 } from "path";
1590
1934
  import { execa } from "execa";
1591
1935
 
1592
1936
  // src/fs.ts
1593
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1937
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
1594
1938
  import { dirname as dirname2 } from "path";
1595
1939
  function readTextFile(path) {
1596
- return readFileSync3(path, "utf8");
1940
+ return readFileSync4(path, "utf8");
1597
1941
  }
1598
1942
  function writeTextFile(path, value) {
1599
1943
  mkdirSync2(dirname2(path), { recursive: true });
@@ -1689,20 +2033,20 @@ async function trustCaddy(path) {
1689
2033
  }
1690
2034
 
1691
2035
  // src/context.ts
1692
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
2036
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
1693
2037
  import { join as join5 } from "path";
1694
2038
  import { pathToFileURL } from "url";
1695
2039
 
1696
2040
  // src/port.ts
1697
2041
  import { createServer } from "net";
1698
2042
  async function isPortAvailable(port, host = "127.0.0.1") {
1699
- return new Promise((resolve5) => {
2043
+ return new Promise((resolve6) => {
1700
2044
  const server = createServer();
1701
2045
  server.once("error", () => {
1702
- resolve5(false);
2046
+ resolve6(false);
1703
2047
  });
1704
2048
  server.once("listening", () => {
1705
- server.close(() => resolve5(true));
2049
+ server.close(() => resolve6(true));
1706
2050
  });
1707
2051
  server.listen(port, host);
1708
2052
  });
@@ -1723,7 +2067,7 @@ async function findAvailablePort(startPort, options = {}) {
1723
2067
  import { randomUUID as randomUUID3 } from "crypto";
1724
2068
  import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from "fs/promises";
1725
2069
  import { homedir as homedir2 } from "os";
1726
- import { join as join4, normalize, resolve as resolve2 } from "path";
2070
+ import { join as join4, normalize, resolve as resolve3 } from "path";
1727
2071
  var LOCALGHOST_REGISTRY_FILE = "registry.json";
1728
2072
  var LOCALGHOST_REGISTRY_LOCK_FILE = "registry.lock";
1729
2073
  function defaultProcessRunning(pid) {
@@ -1736,10 +2080,10 @@ function defaultProcessRunning(pid) {
1736
2080
  }
1737
2081
  }
1738
2082
  function getLocalghostRegistryRoot(env = process.env) {
1739
- return resolve2(env.LOCALGHOST_HOME || join4(homedir2(), ".localghost"));
2083
+ return resolve3(env.LOCALGHOST_HOME || join4(homedir2(), ".localghost"));
1740
2084
  }
1741
2085
  function canonicalizeLocalghostProjectCwd(cwd = process.cwd()) {
1742
- return normalize(resolve2(cwd));
2086
+ return normalize(resolve3(cwd));
1743
2087
  }
1744
2088
  function emptyRegistry() {
1745
2089
  return { version: 1, allocations: [], leases: [] };
@@ -1755,6 +2099,9 @@ function validRegistry(value) {
1755
2099
  function pruneRegistry(registry, now, isRunning) {
1756
2100
  registry.leases = registry.leases.filter((lease) => lease.expiresAt > now && isRunning(lease.pid));
1757
2101
  }
2102
+ function isTestSessionKey(instanceKey) {
2103
+ return instanceKey.startsWith("test:");
2104
+ }
1758
2105
  async function readJson(path) {
1759
2106
  try {
1760
2107
  return JSON.parse(await readFile(path, "utf8"));
@@ -1764,7 +2111,7 @@ async function readJson(path) {
1764
2111
  }
1765
2112
  }
1766
2113
  function createLocalghostRegistry(options = {}) {
1767
- const root = resolve2(options.stateRoot ?? getLocalghostRegistryRoot());
2114
+ const root = resolve3(options.stateRoot ?? getLocalghostRegistryRoot());
1768
2115
  const registryPath = join4(root, LOCALGHOST_REGISTRY_FILE);
1769
2116
  const lockPath = join4(root, LOCALGHOST_REGISTRY_LOCK_FILE);
1770
2117
  const cwd = canonicalizeLocalghostProjectCwd(options.cwd);
@@ -1851,6 +2198,37 @@ function createLocalghostRegistry(options = {}) {
1851
2198
  await releaseLock();
1852
2199
  }
1853
2200
  },
2201
+ async pruneTestSessions() {
2202
+ const releaseLock = await lock();
2203
+ try {
2204
+ const registry = await readRegistry();
2205
+ const staleTestKeys = new Set(
2206
+ registry.leases.filter((lease) => isTestSessionKey(lease.instanceKey) && (lease.expiresAt <= now() || !isRunning(lease.pid))).map((lease) => leaseKey(lease.projectCwd, lease.instanceKey))
2207
+ );
2208
+ const beforeLeases = registry.leases.length;
2209
+ registry.leases = registry.leases.filter((lease) => !staleTestKeys.has(leaseKey(lease.projectCwd, lease.instanceKey)));
2210
+ const activeKeys = new Set(registry.leases.map((lease) => leaseKey(lease.projectCwd, lease.instanceKey)));
2211
+ const beforeAllocations = registry.allocations.length;
2212
+ registry.allocations = registry.allocations.filter(
2213
+ (allocation) => !isTestSessionKey(allocation.instanceKey) || activeKeys.has(leaseKey(allocation.projectCwd, allocation.instanceKey))
2214
+ );
2215
+ await writeRegistry(registry);
2216
+ return {
2217
+ removedLeases: beforeLeases - registry.leases.length,
2218
+ removedAllocations: beforeAllocations - registry.allocations.length
2219
+ };
2220
+ } finally {
2221
+ await releaseLock();
2222
+ }
2223
+ },
2224
+ async reset() {
2225
+ const releaseLock = await lock();
2226
+ try {
2227
+ await writeRegistry({ version: 1, allocations: [], leases: [] });
2228
+ } finally {
2229
+ await releaseLock();
2230
+ }
2231
+ },
1854
2232
  async acquirePort(acquireOptions) {
1855
2233
  const projectCwd = canonicalizeLocalghostProjectCwd(acquireOptions.projectCwd ?? cwd);
1856
2234
  if (!acquireOptions.instanceKey) throw new Error("instanceKey is required");
@@ -1939,7 +2317,7 @@ function envHttps() {
1939
2317
  }
1940
2318
  function getPackageName(cwd) {
1941
2319
  try {
1942
- const pkg = JSON.parse(readFileSync4(join5(cwd, "package.json"), "utf8"));
2320
+ const pkg = JSON.parse(readFileSync5(join5(cwd, "package.json"), "utf8"));
1943
2321
  return typeof pkg.name === "string" ? pkg.name : void 0;
1944
2322
  } catch {
1945
2323
  return void 0;
@@ -1998,7 +2376,7 @@ async function readLocalghostProjectConfig(options = {}) {
1998
2376
  const cwd = options.cwd ?? process.cwd();
1999
2377
  if (options.configFile === false) return { config: {} };
2000
2378
  const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
2001
- const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync3(candidate));
2379
+ const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync4(candidate));
2002
2380
  if (!path) return { config: {} };
2003
2381
  const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
2004
2382
  const config = imported.default ?? imported;
@@ -2075,83 +2453,21 @@ async function resolveLocalghostContext(options = {}) {
2075
2453
  ...releasePort ? { releasePort } : {}
2076
2454
  };
2077
2455
  }
2078
-
2079
- // src/ghost-request.ts
2080
- function getGhostTunnelReadOptions(input) {
2081
- return {
2082
- ...input.cwd ? { cwd: input.cwd } : {},
2083
- ...input.ghostTunnelFile ? { fileName: input.ghostTunnelFile } : {}
2084
- };
2085
- }
2086
- async function resolveGhostTunnelRequest(input) {
2087
- const projectConfig = await readLocalghostProjectConfig({
2088
- ...input.cwd ? { cwd: input.cwd } : {},
2089
- ...typeof input.localghostConfig !== "undefined" ? { configFile: input.localghostConfig } : {}
2090
- });
2091
- const ghostTunnel = resolveGhostTunnelConfig(projectConfig.config.ghostTunnel, {
2092
- domain: input.domain
2093
- });
2094
- const route = assertSecureGhostTunnelRequest({
2095
- host: input.host,
2096
- domain: input.domain,
2097
- protocol: input.protocol,
2098
- ghostTunnel,
2099
- ...typeof input.authenticated === "boolean" ? { authenticated: input.authenticated } : {}
2100
- });
2101
- const ghostTunnelPath = resolveGhostTunnelPath(getGhostTunnelReadOptions(input));
2102
- const entry = findGhostTunnelEntry(route.host, getGhostTunnelReadOptions(input));
2103
- const target = entry ? assertRelayLocalTarget({ host: "127.0.0.1", port: entry.port }) : void 0;
2104
- return {
2105
- route,
2106
- ghostTunnel,
2107
- ...entry ? { entry } : {},
2108
- ...target ? { target } : {},
2109
- ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {},
2110
- ...ghostTunnelPath.exists ? { ghostTunnelPath: ghostTunnelPath.path } : {}
2111
- };
2112
- }
2113
- function renderGhostTunnelRouteMissingResponse(resolved) {
2114
- return {
2115
- status: 404,
2116
- headers: {
2117
- "content-type": "text/html; charset=utf-8",
2118
- "cache-control": "no-store",
2119
- "x-localghost-relay": "missing",
2120
- "x-localghost-route": resolved.route.slug
2121
- },
2122
- body: [
2123
- "<!doctype html>",
2124
- "<html>",
2125
- '<head><meta charset="utf-8"><title>Ghost Tunnel route not configured</title></head>',
2126
- "<body>",
2127
- "<h1>Ghost Tunnel route not configured</h1>",
2128
- `<p>The wildcard host <code>${resolved.route.host}</code> reached the deployed Ghost Tunnel handler, but no exact <code>.ghosttunnel</code> entry matched it.</p>`,
2129
- "</body>",
2130
- "</html>"
2131
- ].join("")
2132
- };
2133
- }
2134
- function renderGhostTunnelRelayOfflineResponse(resolved) {
2135
- const response = renderRelayOfflineResponse();
2456
+
2457
+ // src/ghost-request.ts
2458
+ async function resolveGhostTunnelRequest2(input) {
2459
+ const projectConfig = await readLocalghostProjectConfig({
2460
+ ...input.cwd ? { cwd: input.cwd } : {},
2461
+ ...typeof input.localghostConfig !== "undefined" ? { configFile: input.localghostConfig } : {}
2462
+ });
2463
+ const { localghostConfig: _localghostConfig, ...requestInput } = input;
2464
+ const resolved = await resolveGhostTunnelRequest({
2465
+ ...requestInput,
2466
+ ...projectConfig.config.ghostTunnel ? { ghostTunnel: projectConfig.config.ghostTunnel } : {}
2467
+ });
2136
2468
  return {
2137
- ...response,
2138
- headers: {
2139
- ...response.headers,
2140
- "x-localghost-relay": "offline",
2141
- "x-localghost-route": resolved.route.slug,
2142
- "x-localghost-entry": resolved.entry ? "configured" : "missing"
2143
- },
2144
- body: [
2145
- "<!doctype html>",
2146
- "<html>",
2147
- '<head><meta charset="utf-8"><title>Ghost Tunnel offline</title></head>',
2148
- "<body>",
2149
- "<h1>Ghost Tunnel offline</h1>",
2150
- `<p>The wildcard host <code>${resolved.route.host}</code> reached the deployed Ghost Tunnel handler.</p>`,
2151
- resolved.entry ? "<p>The route is configured locally, but no active local relay connection is available yet.</p>" : "<p>No exact <code>.ghosttunnel</code> entry matched this host.</p>",
2152
- "</body>",
2153
- "</html>"
2154
- ].join("")
2469
+ ...resolved,
2470
+ ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
2155
2471
  };
2156
2472
  }
2157
2473
 
@@ -2223,15 +2539,15 @@ async function runDoctor(options = {}) {
2223
2539
  }
2224
2540
 
2225
2541
  // src/command.ts
2226
- import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
2227
- import { isAbsolute, join as join6, relative, resolve as resolve3 } from "path";
2542
+ import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
2543
+ import { isAbsolute, join as join6, relative, resolve as resolve4 } from "path";
2228
2544
  function readPackageJson(cwd) {
2229
2545
  const path = join6(cwd, "package.json");
2230
- if (!existsSync4(path)) {
2546
+ if (!existsSync5(path)) {
2231
2547
  throw new Error(`No package.json found in ${cwd}. Pass an explicit command with \`localghost run -- <command>\`.`);
2232
2548
  }
2233
2549
  try {
2234
- return JSON.parse(readFileSync5(path, "utf8"));
2550
+ return JSON.parse(readFileSync6(path, "utf8"));
2235
2551
  } catch {
2236
2552
  throw new Error(`Could not parse ${path}.`);
2237
2553
  }
@@ -2241,9 +2557,9 @@ function detectDevPackageManager(cwd, packageManager) {
2241
2557
  const name = packageManager.split("@")[0];
2242
2558
  if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") return name;
2243
2559
  }
2244
- if (existsSync4(join6(cwd, "pnpm-lock.yaml"))) return "pnpm";
2245
- if (existsSync4(join6(cwd, "yarn.lock"))) return "yarn";
2246
- if (existsSync4(join6(cwd, "bun.lock")) || existsSync4(join6(cwd, "bun.lockb"))) return "bun";
2560
+ if (existsSync5(join6(cwd, "pnpm-lock.yaml"))) return "pnpm";
2561
+ if (existsSync5(join6(cwd, "yarn.lock"))) return "yarn";
2562
+ if (existsSync5(join6(cwd, "bun.lock")) || existsSync5(join6(cwd, "bun.lockb"))) return "bun";
2247
2563
  return "npm";
2248
2564
  }
2249
2565
  function scriptCommand(packageManager, script) {
@@ -2289,7 +2605,7 @@ function formatDetectedDevCommand(detected) {
2289
2605
  return `${command} (${source})`;
2290
2606
  }
2291
2607
  function assertServicePath(root, serviceCwd, name) {
2292
- const cwd = resolve3(root, serviceCwd);
2608
+ const cwd = resolve4(root, serviceCwd);
2293
2609
  const relativeCwd = relative(root, cwd);
2294
2610
  if (isAbsolute(relativeCwd) || relativeCwd === ".." || relativeCwd.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
2295
2611
  throw new Error(`Service ${name} cwd must stay inside the project root.`);
@@ -2440,12 +2756,12 @@ async function removeSystemHosts(projectName) {
2440
2756
  }
2441
2757
 
2442
2758
  // src/init.ts
2443
- import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
2444
- import { dirname as dirname4, join as join8, resolve as resolve4 } from "path";
2759
+ import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
2760
+ import { dirname as dirname4, join as join8, resolve as resolve5 } from "path";
2445
2761
  function detectPackageManager(cwd = process.cwd()) {
2446
- if (existsSync5(join8(cwd, "pnpm-lock.yaml"))) return "pnpm";
2447
- if (existsSync5(join8(cwd, "yarn.lock"))) return "yarn";
2448
- if (existsSync5(join8(cwd, "bun.lock")) || existsSync5(join8(cwd, "bun.lockb"))) return "bun";
2762
+ if (existsSync6(join8(cwd, "pnpm-lock.yaml"))) return "pnpm";
2763
+ if (existsSync6(join8(cwd, "yarn.lock"))) return "yarn";
2764
+ if (existsSync6(join8(cwd, "bun.lock")) || existsSync6(join8(cwd, "bun.lockb"))) return "bun";
2449
2765
  return "npm";
2450
2766
  }
2451
2767
  function packageRunCommand(packageManager, script) {
@@ -2472,7 +2788,7 @@ function renderConfig(options) {
2472
2788
  }
2473
2789
  function readPackageJson2(path) {
2474
2790
  try {
2475
- return JSON.parse(readFileSync6(path, "utf8"));
2791
+ return JSON.parse(readFileSync7(path, "utf8"));
2476
2792
  } catch {
2477
2793
  return null;
2478
2794
  }
@@ -2540,7 +2856,7 @@ function initLocalghost(options = {}) {
2540
2856
  const packageManager = options.packageManager ?? detectPackageManager(cwd);
2541
2857
  const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
2542
2858
  const configPath = join8(cwd, configFile);
2543
- const configExists = existsSync5(configPath);
2859
+ const configExists = existsSync6(configPath);
2544
2860
  if (configExists && !options.force) {
2545
2861
  return {
2546
2862
  configPath,
@@ -2561,7 +2877,7 @@ function initLocalghost(options = {}) {
2561
2877
  return {
2562
2878
  configPath,
2563
2879
  configCreated: true,
2564
- ...existsSync5(packageJsonPath) ? { packageJsonPath } : {},
2880
+ ...existsSync6(packageJsonPath) ? { packageJsonPath } : {},
2565
2881
  packageJsonChanged,
2566
2882
  packageManager,
2567
2883
  nextSteps: [
@@ -2628,7 +2944,7 @@ async function createLocalghostTestSession(options) {
2628
2944
 
2629
2945
  // src/process.ts
2630
2946
  function signalManagedProcessPid(pid, signal, killProcess = (value, processSignal) => process.kill(value, processSignal)) {
2631
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid < 1) return false;
2947
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
2632
2948
  try {
2633
2949
  killProcess(process.platform === "win32" ? pid : -pid, signal);
2634
2950
  return true;
@@ -2709,7 +3025,7 @@ function formatGhostTunnel(config, options = {}) {
2709
3025
  }
2710
3026
 
2711
3027
  // src/state.ts
2712
- import { existsSync as existsSync6 } from "fs";
3028
+ import { existsSync as existsSync7 } from "fs";
2713
3029
  import { join as join9 } from "path";
2714
3030
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
2715
3031
  function getLocalghostStatePath(cwd = process.cwd()) {
@@ -2717,7 +3033,7 @@ function getLocalghostStatePath(cwd = process.cwd()) {
2717
3033
  }
2718
3034
  function readLocalghostState(cwd = process.cwd()) {
2719
3035
  const path = getLocalghostStatePath(cwd);
2720
- if (!existsSync6(path)) return null;
3036
+ if (!existsSync7(path)) return null;
2721
3037
  return JSON.parse(readTextFile(path));
2722
3038
  }
2723
3039
  function writeLocalghostState(cwd, state) {
@@ -2733,238 +3049,26 @@ function patchLocalghostState(cwd, patch) {
2733
3049
  }
2734
3050
 
2735
3051
  // src/vercel.ts
2736
- function getHeaderValue(value) {
2737
- if (Array.isArray(value)) return value[0] ?? "";
2738
- return value ?? "";
2739
- }
2740
- function getTrustedProtocol(request) {
2741
- const forwarded = getHeaderValue(request.headers["x-forwarded-proto"]).split(",")[0]?.trim().toLowerCase();
2742
- return forwarded === "http" ? "http" : "https";
2743
- }
2744
- function getTrustedRequestUrl(request, host, protocol) {
2745
- return new URL(request.url ?? "/", `${protocol}://${host}`).toString();
2746
- }
2747
- function getTunnelRequestPath(request) {
2748
- const url = new URL(request.url ?? "/", "http://localghost.invalid");
2749
- return `${url.pathname}${url.search}`;
2750
- }
2751
- function normalizeRequestHeaders(headers) {
2752
- const stripped = stripRelayForwardHeaders(headers);
2753
- return Object.fromEntries(Object.entries(stripped).map(([name, value]) => [
2754
- name,
2755
- Array.isArray(value) ? value.join(", ") : value
2756
- ]));
2757
- }
2758
- function getTunnelStore(options, namespace) {
2759
- return options.tunnelStore ?? createRedisGhostTunnelStoreFromEnv({
2760
- ...options.tunnelEnv ? { env: options.tunnelEnv } : {},
2761
- namespace
2762
- });
2763
- }
2764
- async function readRequestBody(request, maxBytes) {
2765
- if (!request[Symbol.asyncIterator]) return void 0;
2766
- const chunks = [];
2767
- let size = 0;
2768
- for await (const chunk of request) {
2769
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2770
- size += buffer.byteLength;
2771
- if (size > maxBytes) {
2772
- throw new Error(`Ghost Tunnel request exceeded ${maxBytes} bytes.`);
2773
- }
2774
- chunks.push(buffer);
2775
- }
2776
- return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
2777
- }
2778
- function writeResponse(response, payload) {
2779
- response.statusCode = payload.status;
2780
- for (const [name, value] of Object.entries(payload.headers)) {
2781
- response.setHeader(name, value);
2782
- }
2783
- response.end(payload.body);
2784
- }
2785
- function renderGhostTunnelIpRedirectResponse(url) {
2786
- return {
2787
- status: 307,
2788
- headers: {
2789
- location: url,
2790
- "cache-control": "no-store",
2791
- "content-type": "text/html; charset=utf-8",
2792
- "x-localghost-relay": "ip"
2793
- },
2794
- body: [
2795
- "<!doctype html>",
2796
- "<html>",
2797
- '<head><meta charset="utf-8"><title>Redirecting to local preview</title></head>',
2798
- "<body>",
2799
- `<p>Redirecting to <a href="${url}">${url}</a>.</p>`,
2800
- "</body>",
2801
- "</html>"
2802
- ].join("")
2803
- };
2804
- }
2805
- function renderGhostTunnelTransportRejectedResponse(message, status = 400) {
2806
- return {
2807
- status,
2808
- headers: {
2809
- "content-type": "text/html; charset=utf-8",
2810
- "cache-control": "no-store",
2811
- "x-localghost-relay": "rejected"
2812
- },
2813
- body: [
2814
- "<!doctype html>",
2815
- "<html>",
2816
- '<head><meta charset="utf-8"><title>Ghost Tunnel transport rejected</title></head>',
2817
- "<body>",
2818
- "<h1>Ghost Tunnel transport rejected</h1>",
2819
- `<p>${message}</p>`,
2820
- "</body>",
2821
- "</html>"
2822
- ].join("")
2823
- };
2824
- }
2825
- function renderGhostTunnelTunnelTimeoutResponse() {
2826
- return {
2827
- status: 504,
2828
- headers: {
2829
- "content-type": "text/html; charset=utf-8",
2830
- "cache-control": "no-store",
2831
- "x-localghost-relay": "timeout"
2832
- },
2833
- body: [
2834
- "<!doctype html>",
2835
- "<html>",
2836
- '<head><meta charset="utf-8"><title>Ghost Tunnel timed out</title></head>',
2837
- "<body>",
2838
- "<h1>Ghost Tunnel timed out</h1>",
2839
- "<p>The deployed handler did not receive a local response before the request window closed.</p>",
2840
- "</body>",
2841
- "</html>"
2842
- ].join("")
2843
- };
2844
- }
2845
- function renderGhostTunnelQueuedResponse(response) {
2846
- const body = decodeGhostTunnelBody(response.bodyBase64)?.toString() ?? "";
2847
- return {
2848
- status: response.status,
2849
- headers: {
2850
- ...response.headers,
2851
- "x-localghost-relay": response.error ? "target-error" : "tunnel"
2852
- },
2853
- body
2854
- };
2855
- }
2856
- async function waitForTunnelResponse(input) {
2857
- const startedAt = Date.now();
2858
- while (Date.now() - startedAt < input.waitMs) {
2859
- const response = await input.store.readResponse(input.requestId);
2860
- if (response) {
2861
- await input.store.cleanup(input.requestId);
2862
- return response;
2863
- }
2864
- await new Promise((resolve5) => setTimeout(resolve5, input.pollIntervalMs));
2865
- }
2866
- return null;
2867
- }
2868
- async function resolveAuthenticatedState(input, request) {
2869
- if (typeof input === "function") {
2870
- return await input(request);
2871
- }
2872
- return input;
2873
- }
2874
- function createVercelGhostTunnelHandler(options) {
2875
- return async function handler(request, response) {
2876
- const host = getHeaderValue(request.headers.host);
2877
- const protocol = getTrustedProtocol(request);
2878
- try {
2879
- const authenticated = typeof options.authenticated !== "undefined" ? await resolveAuthenticatedState(options.authenticated, request) : void 0;
2880
- const resolved = await resolveGhostTunnelRequest({
2881
- ...options.cwd ? { cwd: options.cwd } : {},
2882
- ...typeof options.localghostConfig !== "undefined" ? { localghostConfig: options.localghostConfig } : {},
2883
- ...options.ghostTunnelFile ? { ghostTunnelFile: options.ghostTunnelFile } : {},
2884
- host,
2885
- domain: options.domain,
2886
- protocol,
2887
- ...typeof authenticated === "boolean" ? { authenticated } : {}
2888
- });
2889
- if (!resolved.entry) {
2890
- writeResponse(response, renderGhostTunnelRouteMissingResponse(resolved));
2891
- return;
2892
- }
2893
- if (resolved.ghostTunnel.transport.kind === "ip") {
2894
- if (!options.ipSigningSecret) {
2895
- writeResponse(response, renderGhostTunnelTransportRejectedResponse("Ghost Tunnel IP transport requires ipSigningSecret in the deployed handler.", 500));
2896
- return;
2897
- }
2898
- try {
2899
- const redirect = resolveGhostTunnelIpRedirect({
2900
- requestUrl: getTrustedRequestUrl(request, host, protocol),
2901
- host: resolved.route.host,
2902
- entryPort: resolved.entry.port,
2903
- signingSecret: options.ipSigningSecret,
2904
- transport: resolved.ghostTunnel.transport
2905
- });
2906
- writeResponse(response, renderGhostTunnelIpRedirectResponse(redirect.url));
2907
- } catch (error) {
2908
- const message = error instanceof Error ? error.message : String(error);
2909
- const status = /expired/i.test(message) ? 410 : 400;
2910
- writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, status));
2911
- }
2912
- return;
2913
- }
2914
- if (resolved.ghostTunnel.transport.kind === "tunnel") {
2915
- const transport = resolved.ghostTunnel.transport;
2916
- const store = getTunnelStore(options, transport.store.namespace);
2917
- const route = await store.getRoute(resolved.route.host);
2918
- if (!route) {
2919
- writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
2920
- return;
2921
- }
2922
- try {
2923
- const requestBody = await readRequestBody(request, transport.maxRequestBodyBytes);
2924
- const queuedRequest = createGhostTunnelQueuedRequest({
2925
- host: resolved.route.host,
2926
- method: request.method ?? "GET",
2927
- path: getTunnelRequestPath(request),
2928
- headers: normalizeRequestHeaders(request.headers),
2929
- ...requestBody ? { body: requestBody } : {},
2930
- ttlSeconds: transport.requestTtlSeconds
2931
- });
2932
- await store.enqueueRequest(queuedRequest, transport.requestTtlSeconds);
2933
- const tunnelResponse = await waitForTunnelResponse({
2934
- store,
2935
- requestId: queuedRequest.id,
2936
- waitMs: transport.waitMs,
2937
- pollIntervalMs: transport.pollIntervalMs
2938
- });
2939
- writeResponse(response, tunnelResponse ? renderGhostTunnelQueuedResponse(tunnelResponse) : renderGhostTunnelTunnelTimeoutResponse());
2940
- } catch (error) {
2941
- const message = error instanceof Error ? error.message : String(error);
2942
- writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, 400));
2943
- }
2944
- return;
2945
- }
2946
- writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
2947
- } catch (error) {
2948
- const message = error instanceof Error ? error.message : String(error);
2949
- writeResponse(response, {
2950
- status: /authenticated/i.test(message) ? 401 : 404,
2951
- headers: {
2952
- "content-type": "text/plain; charset=utf-8",
2953
- "cache-control": "no-store",
2954
- "x-localghost-relay": "rejected"
2955
- },
2956
- body: message
3052
+ function createVercelGhostTunnelHandler2(options) {
3053
+ const { localghostConfig, ...packageOptions } = options;
3054
+ return createVercelGhostTunnelHandler({
3055
+ ...packageOptions,
3056
+ resolveGhostTunnel: async ({ cwd }) => {
3057
+ const projectConfig = await readLocalghostProjectConfig({
3058
+ ...cwd ? { cwd } : {},
3059
+ ...typeof localghostConfig !== "undefined" ? { configFile: localghostConfig } : {}
2957
3060
  });
3061
+ return projectConfig.config.ghostTunnel ?? false;
2958
3062
  }
2959
- };
3063
+ });
2960
3064
  }
2961
3065
 
2962
3066
  // src/update-check.ts
2963
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
3067
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
2964
3068
  import { homedir as homedir3 } from "os";
2965
3069
  import { dirname as dirname5, join as join10 } from "path";
2966
3070
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
2967
- var LOCALGHOST_VERSION = "0.5.0";
3071
+ var LOCALGHOST_VERSION = "0.6.2";
2968
3072
  var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2969
3073
  var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
2970
3074
  var UPDATE_CHECK_TIMEOUT_MS = 900;
@@ -2980,9 +3084,9 @@ function getUpdateCheckCachePath(env = process.env) {
2980
3084
  return join10(cacheRoot, "localghost", "update-check.json");
2981
3085
  }
2982
3086
  function readCache(path = getUpdateCheckCachePath()) {
2983
- if (!existsSync7(path)) return null;
3087
+ if (!existsSync8(path)) return null;
2984
3088
  try {
2985
- return JSON.parse(readFileSync7(path, "utf8"));
3089
+ return JSON.parse(readFileSync8(path, "utf8"));
2986
3090
  } catch {
2987
3091
  return null;
2988
3092
  }
@@ -3167,7 +3271,7 @@ export {
3167
3271
  createRedisGhostTunnelStore,
3168
3272
  createRedisGhostTunnelStoreFromEnv,
3169
3273
  createRelayRouteRegistration,
3170
- createVercelGhostTunnelHandler,
3274
+ createVercelGhostTunnelHandler2 as createVercelGhostTunnelHandler,
3171
3275
  decodeGhostTunnelBody,
3172
3276
  defineLocalghostConfig,
3173
3277
  detectDevCommand,
@@ -3243,7 +3347,7 @@ export {
3243
3347
  resolveGhostTunnelConfig,
3244
3348
  resolveGhostTunnelIpRedirect,
3245
3349
  resolveGhostTunnelPath,
3246
- resolveGhostTunnelRequest,
3350
+ resolveGhostTunnelRequest2 as resolveGhostTunnelRequest,
3247
3351
  resolveLocalghostContext,
3248
3352
  resolveRedisGhostTunnelEnv,
3249
3353
  runCaddy,