@hamedb89/localghost 0.6.1 → 0.6.3

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);
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
+ };
649
+ }
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;
672
+ }
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
+ };
703
681
  }
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);
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}`);
710
687
  }
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;
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.");
717
697
  }
718
- async enqueueRequest(request) {
719
- const queue = this.queues.get(request.host) ?? [];
720
- queue.push(request);
721
- this.queues.set(request.host, queue);
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(/^\/+/, "")}`;
722
713
  }
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;
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));
729
720
  }
730
721
  }
731
- return null;
732
722
  }
733
- async writeResponse(response, ttlSeconds) {
734
- this.responses.set(response.id, {
735
- value: response,
736
- expiresAt: new Date(Date.now() + ttlSeconds * 1e3).toISOString()
737
- });
723
+ return url.toString();
724
+ }
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);
730
+ }
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);
735
+ }
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] : [];
742
+ }
743
+ function getGhostTunnelPreviewUrl(options) {
744
+ const config = toGhostTunnelConfig(options);
745
+ if (!config.enabled) return null;
746
+ return config.previewUrl ?? maybeConstructPreviewUrl(config) ?? null;
747
+ }
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;
761
+ return {
762
+ host: normalizedHost,
763
+ slug,
764
+ namespace,
765
+ entryHost,
766
+ wildcardHost: `*.${entryHost}`,
767
+ domain: normalizedDomain
768
+ };
769
+ }
770
+ function assertSecureGhostTunnelRequest(input) {
771
+ const config = toGhostTunnelConfig(input.ghostTunnel);
772
+ if (!config.enabled) {
773
+ throw new Error("Ghost tunnel is not enabled.");
738
774
  }
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;
775
+ if (config.requireHttps && input.protocol !== "https") {
776
+ throw new Error("Ghost tunnel requests must use HTTPS.");
745
777
  }
746
- async cleanup(requestId) {
747
- this.responses.delete(requestId);
778
+ if (config.requireAuth && input.authenticated !== true) {
779
+ throw new Error("Ghost tunnel requests must be authenticated.");
748
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
+ }
787
+
788
+ // packages/ghost-tunnel/src/relay.ts
789
+ import { createHmac, timingSafeEqual } from "crypto";
790
+ import { domainToASCII as domainToASCII2 } from "url";
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
749
800
  };
750
- function createMemoryGhostTunnelStore() {
751
- return new MemoryGhostTunnelStore();
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");
752
822
  }
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;
823
+ function base64UrlDecode(value) {
824
+ return Buffer.from(value, "base64url").toString("utf8");
825
+ }
826
+ function signPayload(payload, secret) {
827
+ return createHmac("sha256", secret).update(payload).digest("base64url");
828
+ }
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);
833
+ }
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;
840
+ }
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);
847
+ }
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;
852
+ });
853
+ }
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;
858
+ }
859
+ function isLocalTargetHost(host) {
860
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
861
+ }
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
+ };
868
+ }
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
+ }
763
878
  }
764
- key(kind, id) {
765
- return `${this.namespace}:ghost-tunnel:${kind}:${keyPart(id)}`;
879
+ return merged;
880
+ }
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}`);
766
885
  }
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;
886
+ return normalized;
887
+ }
888
+ function assertRelayLocalTarget(target, policyInput) {
889
+ if (!target || typeof target !== "object") {
890
+ throw new Error("Relay target must be an explicit local target object.");
784
891
  }
785
- async heartbeatRoute(route, ttlSeconds) {
786
- await this.command("SET", this.key("route", route.host), serializeJson(route), "EX", ttlSeconds);
892
+ const policy = mergeTargetPolicy(policyInput);
893
+ const host = normalizeTargetHost(target.host);
894
+ if (!host) {
895
+ throw new Error(`Invalid relay target host: ${target.host}`);
787
896
  }
788
- async getRoute(host) {
789
- const route = parseJson(await this.command("GET", this.key("route", host)));
790
- return route && !isExpired(route.expiresAt) ? route : null;
897
+ if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
898
+ throw new Error(`Invalid relay target port: ${target.port}`);
791
899
  }
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);
900
+ const protocol = target.protocol ?? "http";
901
+ if (protocol !== "http" && protocol !== "https") {
902
+ throw new Error(`Invalid relay target protocol: ${String(protocol)}`);
796
903
  }
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;
803
- }
904
+ if (policy.blockedPorts.includes(target.port)) {
905
+ throw new Error(`Relay target port is blocked: ${target.port}`);
804
906
  }
805
- async writeResponse(response, ttlSeconds) {
806
- await this.command("SET", this.key("response", response.id), serializeJson(response), "EX", ttlSeconds);
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}`);
807
910
  }
808
- async readResponse(requestId) {
809
- return parseJson(await this.command("GET", this.key("response", requestId)));
911
+ if (!policy.allowPrivateNetworkTargets && !isLocalTargetHost(host) && (host === "::1" || isPrivateIpv4(host))) {
912
+ throw new Error(`Private-network relay target requires explicit opt-in: ${host}`);
810
913
  }
811
- async cleanup(requestId) {
812
- await this.command("DEL", this.key("response", requestId));
914
+ return {
915
+ protocol,
916
+ host,
917
+ port: target.port
918
+ };
919
+ }
920
+ function authenticateRelayAgentToken(input) {
921
+ const expected = `Bearer ${input.agentToken}`;
922
+ return typeof input.authorizationHeader === "string" && secureEqual(input.authorizationHeader, expected);
923
+ }
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.");
813
943
  }
814
- };
815
- function createRedisGhostTunnelStore(options) {
816
- return new RedisGhostTunnelStore(options);
944
+ const expectedSignature = signPayload(encodedPayload, signingSecret);
945
+ if (!secureEqual(signature, expectedSignature)) {
946
+ throw new Error("Invalid relay route claim signature.");
947
+ }
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.");
952
+ }
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.");
956
+ }
957
+ if (!parsed.agentId) {
958
+ throw new Error("Relay route claim requires an agentId.");
959
+ }
960
+ return { ...parsed, host };
817
961
  }
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.");
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.");
828
968
  }
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 } : {}
969
+ const claim = verifyRelayRouteClaim(input.claimToken, input.signingSecret, {
970
+ expectedScope: input.expectedScope,
971
+ ...input.now ? { now: input.now } : {}
838
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.");
979
+ }
980
+ if (access === "private" && !passwordProtected && !authRequired) {
981
+ throw new Error("Private relay previews require password or auth.");
982
+ }
983
+ return {
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)
993
+ };
839
994
  }
840
-
841
- // src/ghost-agent.ts
842
- function isStopped(signal, localSignal) {
843
- return localSignal.aborted || signal?.aborted === true;
844
- }
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
- });
995
+ function isRelayRouteActive(route, options) {
996
+ if (!options.agentConnected) return false;
997
+ return Date.parse(route.expiresAt) > (options.now ?? /* @__PURE__ */ new Date()).getTime();
856
998
  }
857
- function toHeaderRecord(headers) {
858
- const result = {};
859
- headers.forEach((value, name) => {
860
- result[name] = value;
861
- });
862
- return result;
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;
1007
+ }
1008
+ return stripped;
863
1009
  }
864
- function hasRequestBody(method) {
865
- return method !== "GET" && method !== "HEAD";
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;
1015
+ }
1016
+ return redacted;
866
1017
  }
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.`);
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]");
887
1023
  }
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
1024
  }
1025
+ return input.startsWith("http://") || input.startsWith("https://") ? url.toString() : `${url.pathname}${url.search}`;
908
1026
  }
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
- }
1027
+ function renderRelayOfflineResponse() {
1028
+ return {
1029
+ status: 503,
1030
+ headers: {
1031
+ "content-type": "text/html; charset=utf-8",
1032
+ "cache-control": "no-store"
1033
+ },
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("")
1041
+ };
919
1042
  }
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;
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];
932
1058
  }
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.");
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}"`);
946
1069
  }
947
- options.log?.(`localghost tunnel agent ${agentId}`);
948
- for (const entry of options.entries) {
949
- options.log?.(` ${entry.host} -> ${targetHost}:${entry.port}`);
1070
+ if (!/^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*\.?$/i.test(host)) {
1071
+ throw new Error(`Invalid host on line ${index + 1}: "${host}"`);
950
1072
  }
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
- }
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}"`);
978
1076
  }
979
- })();
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;
980
1083
  return {
981
- agentId,
982
- stop() {
983
- controller.abort();
984
- },
985
- done
1084
+ ...resolved,
1085
+ fileName: resolved.fileName ?? LOCALGHOST_GHOST_TUNNEL_FILE
986
1086
  };
987
1087
  }
988
-
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
994
- 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;
1088
+ function normalizeHost2(value) {
1089
+ return value.trim().toLowerCase().replace(/\.$/, "");
1090
+ }
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 };
1101
+ }
1102
+ function getGhostTunnelPath(options = {}) {
1103
+ return resolveGhostTunnelPath(options).path;
1104
+ }
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);
1109
+ }
1110
+ function listGhostTunnelEntries(options = {}) {
1111
+ const resolved = resolveGhostTunnelPath(options);
1112
+ if (!resolved.exists) return [];
1113
+ return readGhostTunnelEntries(options);
1114
+ }
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");
1012
1130
  }
1013
- function toGhostTunnelConfig(options) {
1014
- return isResolvedGhostTunnelConfig(options) ? options : resolveGhostTunnelConfig(options);
1131
+ function signPayload2(payload, secret) {
1132
+ return createHmac2("sha256", secret).update(payload).digest("base64url");
1015
1133
  }
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;
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);
1023
1138
  }
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;
1139
+ function isValidIpv42(value) {
1140
+ return isIP(value) === 4;
1032
1141
  }
1033
- function isValidHostLabel(value) {
1034
- return value.length > 0 && value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
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;
1035
1146
  }
1036
- function isValidNamespaceTag(value) {
1037
- return /^[a-z][a-z0-9_]*$/i.test(value);
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}`);
1151
+ }
1152
+ if (!allowPrivateNetworkAddress && isPrivateIpv42(normalized)) {
1153
+ throw new Error(`Ghost tunnel IP transport requires explicit private-network opt-in: ${normalized}`);
1154
+ }
1155
+ return normalized;
1038
1156
  }
1039
- function isNamespaceTagList(options) {
1040
- return Array.isArray(options);
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)}`);
1161
+ }
1162
+ return protocol;
1041
1163
  }
1042
- function assertValidSubdomain(value) {
1043
- if (!isValidHostLabel(value)) {
1044
- throw new Error(`Invalid ghost tunnel subdomain: ${value}`);
1164
+ function resolveTransportConfig(input) {
1165
+ return resolveGhostTunnelConfig({
1166
+ enabled: true,
1167
+ ...typeof input !== "undefined" ? { transport: input } : {}
1168
+ }).transport;
1169
+ }
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;
1045
1176
  }
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}`);
1180
+ }
1181
+ return new Date(now.getTime() + ttlSeconds * 1e3).toISOString();
1046
1182
  }
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;
1183
+ function getBaseGhostTunnelUrl(input) {
1184
+ if ("url" in input) {
1185
+ return new URL(input.url);
1186
+ }
1187
+ return new URL(constructGhostTunnelUrl(input));
1188
+ }
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
+ };
1203
+ }
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
+ };
1230
+ }
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();
1246
+ }
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 } : {}
1053
1262
  });
1054
- return [...new Set(normalized)];
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
+ };
1055
1282
  }
1056
- function parseGhostTunnelMode(value) {
1057
- return value ?? DEFAULT_GHOST_TUNNEL_MODE;
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");
1058
1289
  }
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)}`);
1290
+ function encodeGhostTunnelBody(value) {
1291
+ return base64Encode(Buffer.isBuffer(value) ? value : Buffer.from(value));
1063
1292
  }
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)}`);
1293
+ function decodeGhostTunnelBody(value) {
1294
+ return value ? Buffer.from(value, "base64") : void 0;
1068
1295
  }
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}`);
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;
1073
1333
  }
1074
- return value;
1075
1334
  }
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)}`);
1335
+ function keyPart(value) {
1336
+ return value.toLowerCase().replace(/[^a-z0-9._:-]/g, "_");
1080
1337
  }
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)}`);
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);
1085
1342
  }
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}`);
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);
1090
1349
  }
1091
- return namespace;
1092
- }
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)}`);
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;
1098
1356
  }
1099
- return {
1100
- provider,
1101
- strategy: typeof input === "string" ? DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY : parseGhostTunnelAdapterStrategy(input.strategy)
1102
- };
1103
- }
1104
- function getLegacyGhostTunnelTransport(input) {
1105
- if (!input || typeof input === "string" || !("transport" in input)) return void 0;
1106
- return input.transport;
1107
- }
1108
- function resolveGhostTunnelTransport(input) {
1109
- if (!input) {
1110
- return { kind: "none" };
1357
+ async enqueueRequest(request) {
1358
+ const queue = this.queues.get(request.host) ?? [];
1359
+ queue.push(request);
1360
+ this.queues.set(request.host, queue);
1111
1361
  }
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
- };
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;
1118
1371
  }
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
- };
1372
+ async writeResponse(response, ttlSeconds) {
1373
+ this.responses.set(response.id, {
1374
+ value: response,
1375
+ expiresAt: new Date(Date.now() + ttlSeconds * 1e3).toISOString()
1376
+ });
1136
1377
  }
1137
- return { kind: "none" };
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();
1138
1391
  }
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;
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;
1146
1402
  }
1147
- if (tags.length === 0) {
1148
- throw new Error("Ghost tunnel namespace must include at least one tag.");
1403
+ key(kind, id) {
1404
+ return `${this.namespace}:ghost-tunnel:${kind}:${keyPart(id)}`;
1149
1405
  }
1150
- for (const tag of tags) {
1151
- if (!isValidNamespaceTag(tag)) {
1152
- throw new Error(`Invalid ghost tunnel namespace tag: ${tag}`);
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}`);
1153
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;
1154
1423
  }
1155
- if (spreadTag && !tags.includes(spreadTag)) {
1156
- throw new Error(`Ghost tunnel namespace spreadTag must be listed in tags: ${spreadTag}`);
1157
- }
1158
- if (!separator || separator.length > 8 || !/^[a-z0-9-]+$/.test(separator)) {
1159
- throw new Error(`Invalid ghost tunnel namespace separator: ${separator}`);
1424
+ async heartbeatRoute(route, ttlSeconds) {
1425
+ await this.command("SET", this.key("route", route.host), serializeJson(route), "EX", ttlSeconds);
1160
1426
  }
1161
- return {
1162
- tags,
1163
- separator,
1164
- ...spreadTag ? { spreadTag } : {}
1165
- };
1166
- }
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}`);
1427
+ async getRoute(host) {
1428
+ const route = parseJson(await this.command("GET", this.key("route", host)));
1429
+ return route && !isExpired(route.expiresAt) ? route : null;
1171
1430
  }
1172
- if (!options.allowSeparator && normalized.includes(separator)) {
1173
- throw new Error(`Ghost tunnel namespace value for ${tag} cannot include separator "${separator}": ${value}`);
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);
1174
1435
  }
1175
- return normalized;
1176
- }
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}`);
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;
1182
1442
  }
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
1443
  }
1189
- return slug;
1190
- }
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) {
1203
- 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 ?? {}
1211
- },
1212
- path: preview?.path,
1213
- protocol: preview?.protocol
1214
- };
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);
1215
1456
  }
1216
- function getDisplayValues(input) {
1217
- return {
1218
- ...input.route ? { route: input.route } : {},
1219
- ...input.project ? { project: input.project } : {},
1220
- ...input.owner ? { owner: input.owner } : {},
1221
- ...input.values
1222
- };
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;
1223
1469
  }
1224
- function getDisplayDefaults(defaults) {
1225
- return defaults;
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
+ });
1226
1478
  }
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(/^\/+/, "")}`;
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;
1235
1484
  }
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)];
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
+ });
1241
1496
  }
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
1497
+ function toHeaderRecord(headers) {
1498
+ const result = {};
1499
+ headers.forEach((value, name) => {
1500
+ result[name] = value;
1255
1501
  });
1502
+ return result;
1256
1503
  }
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;
1504
+ function hasRequestBody(method) {
1505
+ return method !== "GET" && method !== "HEAD";
1273
1506
  }
1274
- function resolveGhostTunnelConfig(options, defaults) {
1275
- if (options === false || typeof options === "undefined") {
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
+ }
1276
1528
  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)
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.")
1286
1546
  };
1287
1547
  }
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;
1310
- }
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
- };
1319
1548
  }
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}`);
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);
1325
1558
  }
1326
- return `${config.subdomain}.${normalizedDomain}`;
1327
- }
1328
- function getGhostTunnelWildcardHost(domain, options = {}) {
1329
- return `*.${getGhostTunnelEntryHost(domain, options)}`;
1330
1559
  }
1331
- function constructGhostTunnelHost(input) {
1332
- const config = toGhostTunnelConfig(input.ghostTunnel ?? {});
1333
- if (!config.enabled) {
1334
- throw new Error("Ghost tunnel is not enabled.");
1335
- }
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)}`;
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;
1344
1572
  }
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(/^\/+/, "")}`;
1351
- }
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));
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);
1358
1617
  }
1359
1618
  }
1360
- }
1361
- return url.toString();
1362
- }
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);
1368
- }
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);
1619
+ })();
1620
+ return {
1621
+ agentId,
1622
+ stop() {
1623
+ controller.abort();
1624
+ },
1625
+ done
1626
+ };
1373
1627
  }
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] : [];
1628
+
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
+ };
1380
1635
  }
1381
- function getGhostTunnelPreviewUrl(options) {
1382
- const config = toGhostTunnelConfig(options);
1383
- if (!config.enabled) return null;
1384
- return config.previewUrl ?? maybeConstructPreviewUrl(config) ?? null;
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
+ };
1385
1658
  }
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;
1659
+ function renderGhostTunnelRouteMissingResponse(resolved) {
1399
1660
  return {
1400
- host: normalizedHost,
1401
- slug,
1402
- namespace,
1403
- entryHost,
1404
- wildcardHost: `*.${entryHost}`,
1405
- domain: normalizedDomain
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("")
1406
1678
  };
1407
1679
  }
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;
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
+ };
1424
1702
  }
1425
1703
 
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");
1431
- }
1432
- function base64UrlDecode2(value) {
1433
- return Buffer.from(value, "base64url").toString("utf8");
1434
- }
1435
- function signPayload2(payload, secret) {
1436
- return createHmac2("sha256", secret).update(payload).digest("base64url");
1437
- }
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);
1704
+ // packages/ghost-tunnel/src/vercel.ts
1705
+ function getHeaderValue(value) {
1706
+ if (Array.isArray(value)) return value[0] ?? "";
1707
+ return value ?? "";
1442
1708
  }
1443
- function isValidIpv42(value) {
1444
- return isIP(value) === 4;
1709
+ function getTrustedProtocol(request) {
1710
+ const forwarded = getHeaderValue(request.headers["x-forwarded-proto"]).split(",")[0]?.trim().toLowerCase();
1711
+ return forwarded === "http" ? "http" : "https";
1445
1712
  }
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;
1713
+ function getTrustedRequestUrl(request, host, protocol) {
1714
+ return new URL(request.url ?? "/", `${protocol}://${host}`).toString();
1450
1715
  }
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;
1716
+ function getTunnelRequestPath(request) {
1717
+ const url = new URL(request.url ?? "/", "http://localghost.invalid");
1718
+ return `${url.pathname}${url.search}`;
1460
1719
  }
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;
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
+ ]));
1467
1726
  }
1468
- function resolveTransportConfig(input) {
1469
- return resolveGhostTunnelConfig({
1470
- enabled: true,
1471
- ...typeof input !== "undefined" ? { transport: input } : {}
1472
- }).transport;
1727
+ function getTunnelStore(options, namespace) {
1728
+ return options.tunnelStore ?? createRedisGhostTunnelStoreFromEnv({
1729
+ ...options.tunnelEnv ? { env: options.tunnelEnv } : {},
1730
+ namespace
1731
+ });
1473
1732
  }
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.");
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("")
1506
1792
  };
1507
1793
  }
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
- }
1794
+ function renderGhostTunnelTunnelTimeoutResponse() {
1527
1795
  return {
1528
- kind: "ip",
1529
- host,
1530
- address: assertGhostTunnelIpAddress(parsed.address, options.allowPrivateNetworkAddress),
1531
- protocol: assertRelayProtocol(parsed.protocol),
1532
- expiresAt: parsed.expiresAt
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("")
1533
1812
  };
1534
1813
  }
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();
1814
+ function renderGhostTunnelQueuedResponse(response) {
1815
+ const body = decodeGhostTunnelBody(response.bodyBase64)?.toString() ?? "";
1816
+ return {
1817
+ status: response.status,
1818
+ headers: {
1819
+ ...response.headers,
1820
+ "x-localghost-relay": response.error ? "target-error" : "tunnel"
1821
+ },
1822
+ body
1823
+ };
1550
1824
  }
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: [] };
@@ -1767,7 +2111,7 @@ async function readJson(path) {
1767
2111
  }
1768
2112
  }
1769
2113
  function createLocalghostRegistry(options = {}) {
1770
- const root = resolve2(options.stateRoot ?? getLocalghostRegistryRoot());
2114
+ const root = resolve3(options.stateRoot ?? getLocalghostRegistryRoot());
1771
2115
  const registryPath = join4(root, LOCALGHOST_REGISTRY_FILE);
1772
2116
  const lockPath = join4(root, LOCALGHOST_REGISTRY_LOCK_FILE);
1773
2117
  const cwd = canonicalizeLocalghostProjectCwd(options.cwd);
@@ -1973,7 +2317,7 @@ function envHttps() {
1973
2317
  }
1974
2318
  function getPackageName(cwd) {
1975
2319
  try {
1976
- const pkg = JSON.parse(readFileSync4(join5(cwd, "package.json"), "utf8"));
2320
+ const pkg = JSON.parse(readFileSync5(join5(cwd, "package.json"), "utf8"));
1977
2321
  return typeof pkg.name === "string" ? pkg.name : void 0;
1978
2322
  } catch {
1979
2323
  return void 0;
@@ -2032,7 +2376,7 @@ async function readLocalghostProjectConfig(options = {}) {
2032
2376
  const cwd = options.cwd ?? process.cwd();
2033
2377
  if (options.configFile === false) return { config: {} };
2034
2378
  const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
2035
- 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));
2036
2380
  if (!path) return { config: {} };
2037
2381
  const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
2038
2382
  const config = imported.default ?? imported;
@@ -2109,83 +2453,21 @@ async function resolveLocalghostContext(options = {}) {
2109
2453
  ...releasePort ? { releasePort } : {}
2110
2454
  };
2111
2455
  }
2112
-
2113
- // src/ghost-request.ts
2114
- function getGhostTunnelReadOptions(input) {
2115
- return {
2116
- ...input.cwd ? { cwd: input.cwd } : {},
2117
- ...input.ghostTunnelFile ? { fileName: input.ghostTunnelFile } : {}
2118
- };
2119
- }
2120
- async function resolveGhostTunnelRequest(input) {
2121
- const projectConfig = await readLocalghostProjectConfig({
2122
- ...input.cwd ? { cwd: input.cwd } : {},
2123
- ...typeof input.localghostConfig !== "undefined" ? { configFile: input.localghostConfig } : {}
2124
- });
2125
- const ghostTunnel = resolveGhostTunnelConfig(projectConfig.config.ghostTunnel, {
2126
- domain: input.domain
2127
- });
2128
- const route = assertSecureGhostTunnelRequest({
2129
- host: input.host,
2130
- domain: input.domain,
2131
- protocol: input.protocol,
2132
- ghostTunnel,
2133
- ...typeof input.authenticated === "boolean" ? { authenticated: input.authenticated } : {}
2134
- });
2135
- const ghostTunnelPath = resolveGhostTunnelPath(getGhostTunnelReadOptions(input));
2136
- const entry = findGhostTunnelEntry(route.host, getGhostTunnelReadOptions(input));
2137
- const target = entry ? assertRelayLocalTarget({ host: "127.0.0.1", port: entry.port }) : void 0;
2138
- return {
2139
- route,
2140
- ghostTunnel,
2141
- ...entry ? { entry } : {},
2142
- ...target ? { target } : {},
2143
- ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {},
2144
- ...ghostTunnelPath.exists ? { ghostTunnelPath: ghostTunnelPath.path } : {}
2145
- };
2146
- }
2147
- function renderGhostTunnelRouteMissingResponse(resolved) {
2148
- return {
2149
- status: 404,
2150
- headers: {
2151
- "content-type": "text/html; charset=utf-8",
2152
- "cache-control": "no-store",
2153
- "x-localghost-relay": "missing",
2154
- "x-localghost-route": resolved.route.slug
2155
- },
2156
- body: [
2157
- "<!doctype html>",
2158
- "<html>",
2159
- '<head><meta charset="utf-8"><title>Ghost Tunnel route not configured</title></head>',
2160
- "<body>",
2161
- "<h1>Ghost Tunnel route not configured</h1>",
2162
- `<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>`,
2163
- "</body>",
2164
- "</html>"
2165
- ].join("")
2166
- };
2167
- }
2168
- function renderGhostTunnelRelayOfflineResponse(resolved) {
2169
- 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
+ });
2170
2468
  return {
2171
- ...response,
2172
- headers: {
2173
- ...response.headers,
2174
- "x-localghost-relay": "offline",
2175
- "x-localghost-route": resolved.route.slug,
2176
- "x-localghost-entry": resolved.entry ? "configured" : "missing"
2177
- },
2178
- body: [
2179
- "<!doctype html>",
2180
- "<html>",
2181
- '<head><meta charset="utf-8"><title>Ghost Tunnel offline</title></head>',
2182
- "<body>",
2183
- "<h1>Ghost Tunnel offline</h1>",
2184
- `<p>The wildcard host <code>${resolved.route.host}</code> reached the deployed Ghost Tunnel handler.</p>`,
2185
- 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>",
2186
- "</body>",
2187
- "</html>"
2188
- ].join("")
2469
+ ...resolved,
2470
+ ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
2189
2471
  };
2190
2472
  }
2191
2473
 
@@ -2257,15 +2539,15 @@ async function runDoctor(options = {}) {
2257
2539
  }
2258
2540
 
2259
2541
  // src/command.ts
2260
- import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
2261
- 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";
2262
2544
  function readPackageJson(cwd) {
2263
2545
  const path = join6(cwd, "package.json");
2264
- if (!existsSync4(path)) {
2546
+ if (!existsSync5(path)) {
2265
2547
  throw new Error(`No package.json found in ${cwd}. Pass an explicit command with \`localghost run -- <command>\`.`);
2266
2548
  }
2267
2549
  try {
2268
- return JSON.parse(readFileSync5(path, "utf8"));
2550
+ return JSON.parse(readFileSync6(path, "utf8"));
2269
2551
  } catch {
2270
2552
  throw new Error(`Could not parse ${path}.`);
2271
2553
  }
@@ -2275,9 +2557,9 @@ function detectDevPackageManager(cwd, packageManager) {
2275
2557
  const name = packageManager.split("@")[0];
2276
2558
  if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") return name;
2277
2559
  }
2278
- if (existsSync4(join6(cwd, "pnpm-lock.yaml"))) return "pnpm";
2279
- if (existsSync4(join6(cwd, "yarn.lock"))) return "yarn";
2280
- 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";
2281
2563
  return "npm";
2282
2564
  }
2283
2565
  function scriptCommand(packageManager, script) {
@@ -2323,7 +2605,7 @@ function formatDetectedDevCommand(detected) {
2323
2605
  return `${command} (${source})`;
2324
2606
  }
2325
2607
  function assertServicePath(root, serviceCwd, name) {
2326
- const cwd = resolve3(root, serviceCwd);
2608
+ const cwd = resolve4(root, serviceCwd);
2327
2609
  const relativeCwd = relative(root, cwd);
2328
2610
  if (isAbsolute(relativeCwd) || relativeCwd === ".." || relativeCwd.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
2329
2611
  throw new Error(`Service ${name} cwd must stay inside the project root.`);
@@ -2474,12 +2756,12 @@ async function removeSystemHosts(projectName) {
2474
2756
  }
2475
2757
 
2476
2758
  // src/init.ts
2477
- import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
2478
- 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";
2479
2761
  function detectPackageManager(cwd = process.cwd()) {
2480
- if (existsSync5(join8(cwd, "pnpm-lock.yaml"))) return "pnpm";
2481
- if (existsSync5(join8(cwd, "yarn.lock"))) return "yarn";
2482
- 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";
2483
2765
  return "npm";
2484
2766
  }
2485
2767
  function packageRunCommand(packageManager, script) {
@@ -2506,7 +2788,7 @@ function renderConfig(options) {
2506
2788
  }
2507
2789
  function readPackageJson2(path) {
2508
2790
  try {
2509
- return JSON.parse(readFileSync6(path, "utf8"));
2791
+ return JSON.parse(readFileSync7(path, "utf8"));
2510
2792
  } catch {
2511
2793
  return null;
2512
2794
  }
@@ -2574,7 +2856,7 @@ function initLocalghost(options = {}) {
2574
2856
  const packageManager = options.packageManager ?? detectPackageManager(cwd);
2575
2857
  const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
2576
2858
  const configPath = join8(cwd, configFile);
2577
- const configExists = existsSync5(configPath);
2859
+ const configExists = existsSync6(configPath);
2578
2860
  if (configExists && !options.force) {
2579
2861
  return {
2580
2862
  configPath,
@@ -2595,7 +2877,7 @@ function initLocalghost(options = {}) {
2595
2877
  return {
2596
2878
  configPath,
2597
2879
  configCreated: true,
2598
- ...existsSync5(packageJsonPath) ? { packageJsonPath } : {},
2880
+ ...existsSync6(packageJsonPath) ? { packageJsonPath } : {},
2599
2881
  packageJsonChanged,
2600
2882
  packageManager,
2601
2883
  nextSteps: [
@@ -2743,7 +3025,7 @@ function formatGhostTunnel(config, options = {}) {
2743
3025
  }
2744
3026
 
2745
3027
  // src/state.ts
2746
- import { existsSync as existsSync6 } from "fs";
3028
+ import { existsSync as existsSync7 } from "fs";
2747
3029
  import { join as join9 } from "path";
2748
3030
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
2749
3031
  function getLocalghostStatePath(cwd = process.cwd()) {
@@ -2751,7 +3033,7 @@ function getLocalghostStatePath(cwd = process.cwd()) {
2751
3033
  }
2752
3034
  function readLocalghostState(cwd = process.cwd()) {
2753
3035
  const path = getLocalghostStatePath(cwd);
2754
- if (!existsSync6(path)) return null;
3036
+ if (!existsSync7(path)) return null;
2755
3037
  return JSON.parse(readTextFile(path));
2756
3038
  }
2757
3039
  function writeLocalghostState(cwd, state) {
@@ -2767,238 +3049,26 @@ function patchLocalghostState(cwd, patch) {
2767
3049
  }
2768
3050
 
2769
3051
  // src/vercel.ts
2770
- function getHeaderValue(value) {
2771
- if (Array.isArray(value)) return value[0] ?? "";
2772
- return value ?? "";
2773
- }
2774
- function getTrustedProtocol(request) {
2775
- const forwarded = getHeaderValue(request.headers["x-forwarded-proto"]).split(",")[0]?.trim().toLowerCase();
2776
- return forwarded === "http" ? "http" : "https";
2777
- }
2778
- function getTrustedRequestUrl(request, host, protocol) {
2779
- return new URL(request.url ?? "/", `${protocol}://${host}`).toString();
2780
- }
2781
- function getTunnelRequestPath(request) {
2782
- const url = new URL(request.url ?? "/", "http://localghost.invalid");
2783
- return `${url.pathname}${url.search}`;
2784
- }
2785
- function normalizeRequestHeaders(headers) {
2786
- const stripped = stripRelayForwardHeaders(headers);
2787
- return Object.fromEntries(Object.entries(stripped).map(([name, value]) => [
2788
- name,
2789
- Array.isArray(value) ? value.join(", ") : value
2790
- ]));
2791
- }
2792
- function getTunnelStore(options, namespace) {
2793
- return options.tunnelStore ?? createRedisGhostTunnelStoreFromEnv({
2794
- ...options.tunnelEnv ? { env: options.tunnelEnv } : {},
2795
- namespace
2796
- });
2797
- }
2798
- async function readRequestBody(request, maxBytes) {
2799
- if (!request[Symbol.asyncIterator]) return void 0;
2800
- const chunks = [];
2801
- let size = 0;
2802
- for await (const chunk of request) {
2803
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2804
- size += buffer.byteLength;
2805
- if (size > maxBytes) {
2806
- throw new Error(`Ghost Tunnel request exceeded ${maxBytes} bytes.`);
2807
- }
2808
- chunks.push(buffer);
2809
- }
2810
- return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
2811
- }
2812
- function writeResponse(response, payload) {
2813
- response.statusCode = payload.status;
2814
- for (const [name, value] of Object.entries(payload.headers)) {
2815
- response.setHeader(name, value);
2816
- }
2817
- response.end(payload.body);
2818
- }
2819
- function renderGhostTunnelIpRedirectResponse(url) {
2820
- return {
2821
- status: 307,
2822
- headers: {
2823
- location: url,
2824
- "cache-control": "no-store",
2825
- "content-type": "text/html; charset=utf-8",
2826
- "x-localghost-relay": "ip"
2827
- },
2828
- body: [
2829
- "<!doctype html>",
2830
- "<html>",
2831
- '<head><meta charset="utf-8"><title>Redirecting to local preview</title></head>',
2832
- "<body>",
2833
- `<p>Redirecting to <a href="${url}">${url}</a>.</p>`,
2834
- "</body>",
2835
- "</html>"
2836
- ].join("")
2837
- };
2838
- }
2839
- function renderGhostTunnelTransportRejectedResponse(message, status = 400) {
2840
- return {
2841
- status,
2842
- headers: {
2843
- "content-type": "text/html; charset=utf-8",
2844
- "cache-control": "no-store",
2845
- "x-localghost-relay": "rejected"
2846
- },
2847
- body: [
2848
- "<!doctype html>",
2849
- "<html>",
2850
- '<head><meta charset="utf-8"><title>Ghost Tunnel transport rejected</title></head>',
2851
- "<body>",
2852
- "<h1>Ghost Tunnel transport rejected</h1>",
2853
- `<p>${message}</p>`,
2854
- "</body>",
2855
- "</html>"
2856
- ].join("")
2857
- };
2858
- }
2859
- function renderGhostTunnelTunnelTimeoutResponse() {
2860
- return {
2861
- status: 504,
2862
- headers: {
2863
- "content-type": "text/html; charset=utf-8",
2864
- "cache-control": "no-store",
2865
- "x-localghost-relay": "timeout"
2866
- },
2867
- body: [
2868
- "<!doctype html>",
2869
- "<html>",
2870
- '<head><meta charset="utf-8"><title>Ghost Tunnel timed out</title></head>',
2871
- "<body>",
2872
- "<h1>Ghost Tunnel timed out</h1>",
2873
- "<p>The deployed handler did not receive a local response before the request window closed.</p>",
2874
- "</body>",
2875
- "</html>"
2876
- ].join("")
2877
- };
2878
- }
2879
- function renderGhostTunnelQueuedResponse(response) {
2880
- const body = decodeGhostTunnelBody(response.bodyBase64)?.toString() ?? "";
2881
- return {
2882
- status: response.status,
2883
- headers: {
2884
- ...response.headers,
2885
- "x-localghost-relay": response.error ? "target-error" : "tunnel"
2886
- },
2887
- body
2888
- };
2889
- }
2890
- async function waitForTunnelResponse(input) {
2891
- const startedAt = Date.now();
2892
- while (Date.now() - startedAt < input.waitMs) {
2893
- const response = await input.store.readResponse(input.requestId);
2894
- if (response) {
2895
- await input.store.cleanup(input.requestId);
2896
- return response;
2897
- }
2898
- await new Promise((resolve5) => setTimeout(resolve5, input.pollIntervalMs));
2899
- }
2900
- return null;
2901
- }
2902
- async function resolveAuthenticatedState(input, request) {
2903
- if (typeof input === "function") {
2904
- return await input(request);
2905
- }
2906
- return input;
2907
- }
2908
- function createVercelGhostTunnelHandler(options) {
2909
- return async function handler(request, response) {
2910
- const host = getHeaderValue(request.headers.host);
2911
- const protocol = getTrustedProtocol(request);
2912
- try {
2913
- const authenticated = typeof options.authenticated !== "undefined" ? await resolveAuthenticatedState(options.authenticated, request) : void 0;
2914
- const resolved = await resolveGhostTunnelRequest({
2915
- ...options.cwd ? { cwd: options.cwd } : {},
2916
- ...typeof options.localghostConfig !== "undefined" ? { localghostConfig: options.localghostConfig } : {},
2917
- ...options.ghostTunnelFile ? { ghostTunnelFile: options.ghostTunnelFile } : {},
2918
- host,
2919
- domain: options.domain,
2920
- protocol,
2921
- ...typeof authenticated === "boolean" ? { authenticated } : {}
2922
- });
2923
- if (!resolved.entry) {
2924
- writeResponse(response, renderGhostTunnelRouteMissingResponse(resolved));
2925
- return;
2926
- }
2927
- if (resolved.ghostTunnel.transport.kind === "ip") {
2928
- if (!options.ipSigningSecret) {
2929
- writeResponse(response, renderGhostTunnelTransportRejectedResponse("Ghost Tunnel IP transport requires ipSigningSecret in the deployed handler.", 500));
2930
- return;
2931
- }
2932
- try {
2933
- const redirect = resolveGhostTunnelIpRedirect({
2934
- requestUrl: getTrustedRequestUrl(request, host, protocol),
2935
- host: resolved.route.host,
2936
- entryPort: resolved.entry.port,
2937
- signingSecret: options.ipSigningSecret,
2938
- transport: resolved.ghostTunnel.transport
2939
- });
2940
- writeResponse(response, renderGhostTunnelIpRedirectResponse(redirect.url));
2941
- } catch (error) {
2942
- const message = error instanceof Error ? error.message : String(error);
2943
- const status = /expired/i.test(message) ? 410 : 400;
2944
- writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, status));
2945
- }
2946
- return;
2947
- }
2948
- if (resolved.ghostTunnel.transport.kind === "tunnel") {
2949
- const transport = resolved.ghostTunnel.transport;
2950
- const store = getTunnelStore(options, transport.store.namespace);
2951
- const route = await store.getRoute(resolved.route.host);
2952
- if (!route) {
2953
- writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
2954
- return;
2955
- }
2956
- try {
2957
- const requestBody = await readRequestBody(request, transport.maxRequestBodyBytes);
2958
- const queuedRequest = createGhostTunnelQueuedRequest({
2959
- host: resolved.route.host,
2960
- method: request.method ?? "GET",
2961
- path: getTunnelRequestPath(request),
2962
- headers: normalizeRequestHeaders(request.headers),
2963
- ...requestBody ? { body: requestBody } : {},
2964
- ttlSeconds: transport.requestTtlSeconds
2965
- });
2966
- await store.enqueueRequest(queuedRequest, transport.requestTtlSeconds);
2967
- const tunnelResponse = await waitForTunnelResponse({
2968
- store,
2969
- requestId: queuedRequest.id,
2970
- waitMs: transport.waitMs,
2971
- pollIntervalMs: transport.pollIntervalMs
2972
- });
2973
- writeResponse(response, tunnelResponse ? renderGhostTunnelQueuedResponse(tunnelResponse) : renderGhostTunnelTunnelTimeoutResponse());
2974
- } catch (error) {
2975
- const message = error instanceof Error ? error.message : String(error);
2976
- writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, 400));
2977
- }
2978
- return;
2979
- }
2980
- writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
2981
- } catch (error) {
2982
- const message = error instanceof Error ? error.message : String(error);
2983
- writeResponse(response, {
2984
- status: /authenticated/i.test(message) ? 401 : 404,
2985
- headers: {
2986
- "content-type": "text/plain; charset=utf-8",
2987
- "cache-control": "no-store",
2988
- "x-localghost-relay": "rejected"
2989
- },
2990
- 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 } : {}
2991
3060
  });
3061
+ return projectConfig.config.ghostTunnel ?? false;
2992
3062
  }
2993
- };
3063
+ });
2994
3064
  }
2995
3065
 
2996
3066
  // src/update-check.ts
2997
- 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";
2998
3068
  import { homedir as homedir3 } from "os";
2999
3069
  import { dirname as dirname5, join as join10 } from "path";
3000
3070
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
3001
- var LOCALGHOST_VERSION = "0.6.1";
3071
+ var LOCALGHOST_VERSION = "0.6.3";
3002
3072
  var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
3003
3073
  var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
3004
3074
  var UPDATE_CHECK_TIMEOUT_MS = 900;
@@ -3014,9 +3084,9 @@ function getUpdateCheckCachePath(env = process.env) {
3014
3084
  return join10(cacheRoot, "localghost", "update-check.json");
3015
3085
  }
3016
3086
  function readCache(path = getUpdateCheckCachePath()) {
3017
- if (!existsSync7(path)) return null;
3087
+ if (!existsSync8(path)) return null;
3018
3088
  try {
3019
- return JSON.parse(readFileSync7(path, "utf8"));
3089
+ return JSON.parse(readFileSync8(path, "utf8"));
3020
3090
  } catch {
3021
3091
  return null;
3022
3092
  }
@@ -3201,7 +3271,7 @@ export {
3201
3271
  createRedisGhostTunnelStore,
3202
3272
  createRedisGhostTunnelStoreFromEnv,
3203
3273
  createRelayRouteRegistration,
3204
- createVercelGhostTunnelHandler,
3274
+ createVercelGhostTunnelHandler2 as createVercelGhostTunnelHandler,
3205
3275
  decodeGhostTunnelBody,
3206
3276
  defineLocalghostConfig,
3207
3277
  detectDevCommand,
@@ -3277,7 +3347,7 @@ export {
3277
3347
  resolveGhostTunnelConfig,
3278
3348
  resolveGhostTunnelIpRedirect,
3279
3349
  resolveGhostTunnelPath,
3280
- resolveGhostTunnelRequest,
3350
+ resolveGhostTunnelRequest2 as resolveGhostTunnelRequest,
3281
3351
  resolveLocalghostContext,
3282
3352
  resolveRedisGhostTunnelEnv,
3283
3353
  runCaddy,