@hamedb89/localghost 0.1.10 → 0.1.13

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
@@ -129,6 +129,24 @@ function unregisterLocalghostSetup(options, path = getLocalghostActivityPath())
129
129
  }
130
130
  }
131
131
 
132
+ // src/brand.ts
133
+ function renderLocalghostBanner() {
134
+ return [
135
+ " .-.",
136
+ " (o o) LOCALGHOST",
137
+ " | O \\ friendly local domains",
138
+ " \\ \\",
139
+ " `~~~'"
140
+ ].join("\n");
141
+ }
142
+ function renderCompactLocalghostBanner() {
143
+ return [
144
+ " .--.",
145
+ " ( oo ) localghost",
146
+ " \\__/ friendly local domains"
147
+ ].join("\n");
148
+ }
149
+
132
150
  // src/config.ts
133
151
  import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync } from "fs";
134
152
  import { basename, join as join2, resolve } from "path";
@@ -251,1089 +269,1931 @@ function sanitizeProjectName(value) {
251
269
  return projectName || "app";
252
270
  }
253
271
 
254
- // src/caddy.ts
255
- import { dirname as dirname3, join as join3 } from "path";
256
- import { execa } from "execa";
257
-
258
- // src/fs.ts
259
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
260
- import { dirname as dirname2 } from "path";
261
- function readTextFile(path) {
262
- return readFileSync3(path, "utf8");
263
- }
264
- function writeTextFile(path, value) {
265
- mkdirSync2(dirname2(path), { recursive: true });
266
- writeFileSync2(path, value, "utf8");
267
- return path;
268
- }
269
-
270
- // src/caddy.ts
271
- function groupByPort(entries) {
272
- const groups = /* @__PURE__ */ new Map();
273
- for (const entry of entries) {
274
- const group = groups.get(entry.port) ?? [];
275
- group.push(entry);
276
- groups.set(entry.port, group);
277
- }
278
- return groups;
279
- }
280
- function getCaddyfilePath(cwd = process.cwd()) {
281
- return join3(cwd, "ops/local/Caddyfile");
282
- }
283
- function renderCaddyfile(entries, options = {}) {
284
- const groups = groupByPort(entries);
285
- const https = options.https === true;
286
- const blocks = [...groups.entries()].sort(([leftPort], [rightPort]) => leftPort - rightPort).map(([port, group]) => {
287
- const hosts = group.map((entry) => https ? entry.host : `http://${entry.host}`).sort().join(", ");
288
- return `${hosts} {
289
- reverse_proxy 127.0.0.1:${port}
290
- }`;
291
- });
292
- const globalOptions = https ? `{
293
- local_certs
272
+ // src/ghost-file.ts
273
+ var LOCALGHOST_GHOST_TUNNEL_FILE = ".ghosttunnel";
274
+ function toGhostTunnelOptions(options = {}) {
275
+ const resolved = typeof options === "string" ? { cwd: options } : options;
276
+ return {
277
+ ...resolved,
278
+ fileName: resolved.fileName ?? LOCALGHOST_GHOST_TUNNEL_FILE
279
+ };
294
280
  }
295
-
296
- ` : "";
297
- return `${globalOptions}${blocks.join("\n\n")}
298
- `;
281
+ function normalizeHost(value) {
282
+ return value.trim().toLowerCase().replace(/\.$/, "");
299
283
  }
300
- async function writeCaddyfile(entries, cwd = process.cwd(), options = {}) {
301
- const path = getCaddyfilePath(cwd);
302
- writeTextFile(path, renderCaddyfile(entries, options));
303
- return path;
284
+ function resolveGhostTunnelPath(options = {}) {
285
+ return resolveDevHostsPath(toGhostTunnelOptions(options));
304
286
  }
305
- async function validateCaddyfile(path) {
306
- await execa("caddy", ["validate", "--config", path], {
307
- cwd: dirname3(path),
308
- stdio: "inherit"
309
- });
287
+ function getGhostTunnelPath(options = {}) {
288
+ return resolveGhostTunnelPath(options).path;
310
289
  }
311
- async function runCaddy(path) {
312
- await execa("caddy", ["run", "--config", path], {
313
- cwd: dirname3(path),
314
- stdio: "inherit"
315
- });
290
+ function readGhostTunnelEntries(options = {}) {
291
+ return readDevHosts(toGhostTunnelOptions(options));
316
292
  }
317
- function startCaddy(path) {
318
- return execa("caddy", ["run", "--config", path], {
319
- cwd: dirname3(path),
320
- stdio: "inherit"
321
- });
293
+ function listGhostTunnelEntries(options = {}) {
294
+ const resolved = resolveGhostTunnelPath(options);
295
+ if (!resolved.exists) return [];
296
+ return readGhostTunnelEntries(options);
322
297
  }
323
- async function trustCaddy(path) {
324
- await execa("caddy", ["trust", "--config", path], {
325
- cwd: dirname3(path),
326
- stdio: "inherit"
327
- });
298
+ function findGhostTunnelEntry(host, options = {}) {
299
+ const normalizedHost = normalizeHost(host);
300
+ return listGhostTunnelEntries(options).find((entry) => entry.host === normalizedHost);
328
301
  }
329
302
 
330
- // src/context.ts
331
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
332
- import { join as join4 } from "path";
333
- import { pathToFileURL } from "url";
334
-
335
- // src/port.ts
336
- import { createServer } from "net";
337
- async function isPortAvailable(port, host = "127.0.0.1") {
338
- return new Promise((resolve2) => {
339
- const server = createServer();
340
- server.once("error", () => {
341
- resolve2(false);
342
- });
343
- server.once("listening", () => {
344
- server.close(() => resolve2(true));
345
- });
346
- server.listen(port, host);
347
- });
348
- }
349
- async function findAvailablePort(startPort, options = {}) {
350
- const host = options.host ?? "127.0.0.1";
351
- const maxAttempts = options.maxAttempts ?? 50;
352
- for (let offset = 0; offset < maxAttempts; offset += 1) {
353
- const port = startPort + offset;
354
- if (await isPortAvailable(port, host)) {
355
- return port;
356
- }
357
- }
358
- throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
359
- }
303
+ // src/ghost-agent.ts
304
+ import { randomUUID as randomUUID2 } from "crypto";
360
305
 
361
- // src/tunnel.ts
306
+ // src/relay.ts
307
+ import { createHmac, timingSafeEqual } from "crypto";
362
308
  import { domainToASCII } from "url";
363
- var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
364
- var DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS = ["route", "project", "owner"];
365
- var DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR = "-";
366
- var DEFAULT_GHOST_TUNNEL_MODE = "manual";
367
- function isResolvedGhostTunnelConfig(value) {
368
- return typeof value === "object" && value !== null && "enabled" in value;
309
+ var DEFAULT_RELAY_ALLOWED_TARGET_HOSTS = ["localhost", "127.0.0.1", "::1"];
310
+ var DEFAULT_RELAY_BLOCKED_PORTS = [22, 2375, 2376, 5432, 6379, 9200, 9229, 27017];
311
+ var DEFAULT_RELAY_LIMITS = {
312
+ requestBodyBytes: 5 * 1024 * 1024,
313
+ responseBytes: 25 * 1024 * 1024,
314
+ timeoutMs: 3e4,
315
+ maxConcurrentRequests: 20,
316
+ perRouteRequestsPerMinute: 120,
317
+ perIpRequestsPerMinute: 60
318
+ };
319
+ var DEFAULT_RELAY_TARGET_POLICY = {
320
+ allowedHosts: [...DEFAULT_RELAY_ALLOWED_TARGET_HOSTS],
321
+ blockedPorts: [...DEFAULT_RELAY_BLOCKED_PORTS],
322
+ allowPrivateNetworkTargets: false
323
+ };
324
+ var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
325
+ "connection",
326
+ "keep-alive",
327
+ "proxy-authenticate",
328
+ "proxy-authorization",
329
+ "te",
330
+ "trailer",
331
+ "transfer-encoding",
332
+ "upgrade"
333
+ ]);
334
+ var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "set-cookie"]);
335
+ var TOKEN_QUERY_PATTERN = /(token|secret|key|password|session|jwt|auth)/i;
336
+ var HOST_PATTERN2 = /^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*$/i;
337
+ var IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/;
338
+ function base64UrlEncode(value) {
339
+ return Buffer.from(value).toString("base64url");
369
340
  }
370
- function toGhostTunnelConfig(options) {
371
- return isResolvedGhostTunnelConfig(options) ? options : resolveGhostTunnelConfig(options);
341
+ function base64UrlDecode(value) {
342
+ return Buffer.from(value, "base64url").toString("utf8");
372
343
  }
373
- function stripHostPort(value) {
374
- const trimmed = value.trim().toLowerCase();
375
- if (trimmed.startsWith("[") || trimmed.includes("/")) return "";
376
- const portSeparator = trimmed.lastIndexOf(":");
377
- if (portSeparator === -1) return trimmed;
378
- const port = trimmed.slice(portSeparator + 1);
379
- return /^\d+$/.test(port) ? trimmed.slice(0, portSeparator) : trimmed;
344
+ function signPayload(payload, secret) {
345
+ return createHmac("sha256", secret).update(payload).digest("base64url");
380
346
  }
381
- function normalizeDomain(value) {
382
- const host = stripHostPort(value.replace(/^\*\./, ""));
383
- const ascii = domainToASCII(host);
384
- if (!ascii || ascii.length > 253 || ascii.includes("..")) return null;
385
- if (ascii.startsWith(".") || ascii.endsWith(".")) return null;
386
- if (ascii.includes("*")) return null;
387
- if (!ascii.split(".").every(isValidHostLabel)) return null;
388
- return ascii;
347
+ function secureEqual(left, right) {
348
+ const leftBuffer = Buffer.from(left);
349
+ const rightBuffer = Buffer.from(right);
350
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
389
351
  }
390
- function isValidHostLabel(value) {
391
- return value.length > 0 && value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
352
+ function normalizeHost2(host) {
353
+ const trimmed = host.trim().toLowerCase().replace(/\.$/, "");
354
+ if (!trimmed || trimmed.includes("*") || trimmed.includes("/") || trimmed.includes(":")) return null;
355
+ const ascii = domainToASCII(trimmed);
356
+ if (!ascii || ascii.includes("..")) return null;
357
+ return HOST_PATTERN2.test(ascii) ? ascii : null;
392
358
  }
393
- function isValidNamespaceTag(value) {
394
- return /^[a-z][a-z0-9_]*$/i.test(value);
359
+ function normalizeTargetHost(host) {
360
+ const trimmed = host.trim().toLowerCase();
361
+ if (trimmed === "::1" || trimmed === "[::1]") return "::1";
362
+ if (trimmed.includes("/") || trimmed.includes("*")) return null;
363
+ if (IPV4_PATTERN.test(trimmed)) return isValidIpv4(trimmed) ? trimmed : null;
364
+ return normalizeHost2(trimmed);
395
365
  }
396
- function isNamespaceTagList(options) {
397
- return Array.isArray(options);
366
+ function isValidIpv4(value) {
367
+ return value.split(".").every((part) => {
368
+ const octet = Number(part);
369
+ return Number.isInteger(octet) && octet >= 0 && octet <= 255 && String(octet) === part;
370
+ });
398
371
  }
399
- function assertValidSubdomain(value) {
400
- if (!isValidHostLabel(value)) {
401
- throw new Error(`Invalid ghost tunnel subdomain: ${value}`);
402
- }
372
+ function isPrivateIpv4(value) {
373
+ if (!isValidIpv4(value)) return false;
374
+ const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
375
+ return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
403
376
  }
404
- function normalizeDomains(domains) {
405
- const values = typeof domains === "string" ? [domains] : [...domains ?? []];
406
- const normalized = values.map((value) => value.trim()).filter(Boolean).map((value) => {
407
- const domain = normalizeDomain(value);
408
- if (!domain) throw new Error(`Invalid ghost tunnel domain: ${value}`);
409
- return domain;
410
- });
411
- return [...new Set(normalized)];
377
+ function isLocalTargetHost(host) {
378
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
412
379
  }
413
- function parseGhostTunnelMode(value) {
414
- return value ?? DEFAULT_GHOST_TUNNEL_MODE;
380
+ function mergeTargetPolicy(policy) {
381
+ return {
382
+ allowedHosts: policy?.allowedHosts ?? DEFAULT_RELAY_TARGET_POLICY.allowedHosts,
383
+ blockedPorts: policy?.blockedPorts ?? DEFAULT_RELAY_TARGET_POLICY.blockedPorts,
384
+ allowPrivateNetworkTargets: policy?.allowPrivateNetworkTargets ?? DEFAULT_RELAY_TARGET_POLICY.allowPrivateNetworkTargets
385
+ };
415
386
  }
416
- function resolveNamespaceConfig(options) {
417
- const tags = isNamespaceTagList(options) ? [...options] : [...options?.tags ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS];
418
- let separator = DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
419
- let spreadTag = tags.includes("project") ? "project" : void 0;
420
- if (options && !isNamespaceTagList(options)) {
421
- separator = options.separator ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
422
- spreadTag = options.spreadTag === false ? false : options.spreadTag ?? spreadTag;
387
+ function mergeLimits(limits) {
388
+ const merged = {
389
+ ...DEFAULT_RELAY_LIMITS,
390
+ ...limits ?? {}
391
+ };
392
+ for (const [key, value] of Object.entries(merged)) {
393
+ if (!Number.isInteger(value) || value < 1) {
394
+ throw new Error(`Invalid relay limit ${key}: ${value}`);
395
+ }
423
396
  }
424
- if (tags.length === 0) {
425
- throw new Error("Ghost tunnel namespace must include at least one tag.");
397
+ return merged;
398
+ }
399
+ function assertExactRelayHost(host) {
400
+ const normalized = normalizeHost2(host);
401
+ if (!normalized) {
402
+ throw new Error(`Relay route claims must use an exact hostname: ${host}`);
426
403
  }
427
- for (const tag of tags) {
428
- if (!isValidNamespaceTag(tag)) {
429
- throw new Error(`Invalid ghost tunnel namespace tag: ${tag}`);
430
- }
404
+ return normalized;
405
+ }
406
+ function assertRelayLocalTarget(target, policyInput) {
407
+ if (!target || typeof target !== "object") {
408
+ throw new Error("Relay target must be an explicit local target object.");
431
409
  }
432
- if (spreadTag && !tags.includes(spreadTag)) {
433
- throw new Error(`Ghost tunnel namespace spreadTag must be listed in tags: ${spreadTag}`);
410
+ const policy = mergeTargetPolicy(policyInput);
411
+ const host = normalizeTargetHost(target.host);
412
+ if (!host) {
413
+ throw new Error(`Invalid relay target host: ${target.host}`);
434
414
  }
435
- if (!separator || separator.length > 8 || !/^[a-z0-9-]+$/.test(separator)) {
436
- throw new Error(`Invalid ghost tunnel namespace separator: ${separator}`);
415
+ if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
416
+ throw new Error(`Invalid relay target port: ${target.port}`);
417
+ }
418
+ const protocol = target.protocol ?? "http";
419
+ if (protocol !== "http" && protocol !== "https") {
420
+ throw new Error(`Invalid relay target protocol: ${String(protocol)}`);
421
+ }
422
+ if (policy.blockedPorts.includes(target.port)) {
423
+ throw new Error(`Relay target port is blocked: ${target.port}`);
424
+ }
425
+ const allowedHosts = new Set(policy.allowedHosts.map((allowedHost) => normalizeTargetHost(allowedHost)).filter((value) => Boolean(value)));
426
+ if (!allowedHosts.has(host)) {
427
+ throw new Error(`Relay target host is not explicitly allowed: ${host}`);
428
+ }
429
+ if (!policy.allowPrivateNetworkTargets && !isLocalTargetHost(host) && (host === "::1" || isPrivateIpv4(host))) {
430
+ throw new Error(`Private-network relay target requires explicit opt-in: ${host}`);
437
431
  }
438
432
  return {
439
- tags,
440
- separator,
441
- ...spreadTag ? { spreadTag } : {}
433
+ protocol,
434
+ host,
435
+ port: target.port
442
436
  };
443
437
  }
444
- function normalizeNamespaceValue(tag, value, separator, options = {}) {
445
- const normalized = normalizeDomain(value);
446
- if (!normalized || normalized.includes(".")) {
447
- throw new Error(`Invalid ghost tunnel namespace value for ${tag}: ${value}`);
438
+ function authenticateRelayAgentToken(input) {
439
+ const expected = `Bearer ${input.agentToken}`;
440
+ return typeof input.authorizationHeader === "string" && secureEqual(input.authorizationHeader, expected);
441
+ }
442
+ function signRelayRouteClaim(claim, signingSecret) {
443
+ const payload = {
444
+ ...claim,
445
+ host: assertExactRelayHost(claim.host)
446
+ };
447
+ if (!payload.scope) throw new Error("Relay route claim requires a scope.");
448
+ if (!payload.agentId) throw new Error("Relay route claim requires an agentId.");
449
+ if (Number.isNaN(Date.parse(payload.expiresAt))) throw new Error("Relay route claim requires a valid expiresAt.");
450
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
451
+ const signature = signPayload(encodedPayload, signingSecret);
452
+ return {
453
+ payload,
454
+ token: `${encodedPayload}.${signature}`
455
+ };
456
+ }
457
+ function verifyRelayRouteClaim(token, signingSecret, options) {
458
+ const [encodedPayload, signature] = token.split(".");
459
+ if (!encodedPayload || !signature || token.split(".").length !== 2) {
460
+ throw new Error("Invalid relay route claim token.");
448
461
  }
449
- if (!options.allowSeparator && normalized.includes(separator)) {
450
- throw new Error(`Ghost tunnel namespace value for ${tag} cannot include separator "${separator}": ${value}`);
462
+ const expectedSignature = signPayload(encodedPayload, signingSecret);
463
+ if (!secureEqual(signature, expectedSignature)) {
464
+ throw new Error("Invalid relay route claim signature.");
451
465
  }
452
- return normalized;
466
+ const parsed = JSON.parse(base64UrlDecode(encodedPayload));
467
+ const host = assertExactRelayHost(parsed.host);
468
+ if (parsed.scope !== options.expectedScope) {
469
+ throw new Error("Relay route claim scope mismatch.");
470
+ }
471
+ const now = options.now ?? /* @__PURE__ */ new Date();
472
+ if (Date.parse(parsed.expiresAt) <= now.getTime()) {
473
+ throw new Error("Relay route claim has expired.");
474
+ }
475
+ if (!parsed.agentId) {
476
+ throw new Error("Relay route claim requires an agentId.");
477
+ }
478
+ return { ...parsed, host };
453
479
  }
454
- function createNamespaceSlug(config, values) {
455
- const parts = config.tags.map((tag) => {
456
- const value = values[tag];
457
- if (!value) {
458
- throw new Error(`Missing ghost tunnel namespace value: ${tag}`);
459
- }
460
- return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
480
+ function createRelayRouteRegistration(input) {
481
+ if (!authenticateRelayAgentToken({
482
+ agentToken: input.agentToken,
483
+ ...typeof input.authorizationHeader !== "undefined" ? { authorizationHeader: input.authorizationHeader } : {}
484
+ })) {
485
+ throw new Error("Relay route registration requires an authenticated local agent.");
486
+ }
487
+ const claim = verifyRelayRouteClaim(input.claimToken, input.signingSecret, {
488
+ expectedScope: input.expectedScope,
489
+ ...input.now ? { now: input.now } : {}
461
490
  });
462
- const slug = parts.join(config.separator);
463
- if (!isValidHostLabel(slug)) {
464
- throw new Error(`Ghost tunnel namespace is too long for a DNS label: ${slug}`);
491
+ const target = assertRelayLocalTarget(input.target, input.targetPolicy);
492
+ const access = input.publicMode === true ? "public" : input.access ?? "private";
493
+ const passwordProtected = input.passwordProtected ?? false;
494
+ const authRequired = input.authRequired ?? false;
495
+ if (access === "public" && input.publicMode !== true) {
496
+ throw new Error("Relay public mode must be explicitly enabled.");
465
497
  }
466
- return slug;
498
+ if (access === "private" && !passwordProtected && !authRequired) {
499
+ throw new Error("Private relay previews require password or auth.");
500
+ }
501
+ return {
502
+ host: claim.host,
503
+ scope: claim.scope,
504
+ agentId: claim.agentId,
505
+ expiresAt: claim.expiresAt,
506
+ target,
507
+ access,
508
+ passwordProtected,
509
+ authRequired,
510
+ limits: mergeLimits(input.limits)
511
+ };
467
512
  }
468
- function createNamespaceDisplaySlug(config, values = {}) {
469
- return config.tags.map((tag) => {
470
- const value = values[tag];
471
- if (!value) return `<${tag}>`;
472
- try {
473
- return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
474
- } catch {
475
- return `<${tag}>`;
513
+ function isRelayRouteActive(route, options) {
514
+ if (!options.agentConnected) return false;
515
+ return Date.parse(route.expiresAt) > (options.now ?? /* @__PURE__ */ new Date()).getTime();
516
+ }
517
+ function stripRelayForwardHeaders(headers) {
518
+ const stripped = {};
519
+ for (const [name, value] of Object.entries(headers)) {
520
+ if (typeof value === "undefined") continue;
521
+ const lowerName = name.toLowerCase();
522
+ if (HOP_BY_HOP_HEADERS.has(lowerName)) continue;
523
+ if (lowerName.startsWith("x-localghost-")) continue;
524
+ stripped[name] = value;
525
+ }
526
+ return stripped;
527
+ }
528
+ function redactRelayHeaders(headers) {
529
+ const redacted = {};
530
+ for (const [name, value] of Object.entries(headers)) {
531
+ if (typeof value === "undefined") continue;
532
+ redacted[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? "[redacted]" : value;
533
+ }
534
+ return redacted;
535
+ }
536
+ function redactRelayLogUrl(input) {
537
+ const url = new URL(input, "http://localghost.invalid");
538
+ for (const key of [...url.searchParams.keys()]) {
539
+ if (TOKEN_QUERY_PATTERN.test(key)) {
540
+ url.searchParams.set(key, "[redacted]");
476
541
  }
477
- }).join(config.separator);
542
+ }
543
+ return input.startsWith("http://") || input.startsWith("https://") ? url.toString() : `${url.pathname}${url.search}`;
478
544
  }
479
- function getPreviewDefaults(preview, defaults) {
545
+ function renderRelayOfflineResponse() {
480
546
  return {
481
- domain: preview?.domain ?? defaults?.domain,
482
- route: preview?.route ?? defaults?.route,
483
- project: preview?.project ?? defaults?.project,
484
- owner: preview?.owner ?? defaults?.owner,
485
- values: {
486
- ...defaults?.values ?? {},
487
- ...preview?.values ?? {}
547
+ status: 503,
548
+ headers: {
549
+ "content-type": "text/html; charset=utf-8",
550
+ "cache-control": "no-store"
488
551
  },
489
- path: preview?.path,
490
- protocol: preview?.protocol
552
+ body: [
553
+ "<!doctype html>",
554
+ "<html>",
555
+ '<head><meta charset="utf-8"><title>Preview offline</title></head>',
556
+ "<body><h1>Preview offline</h1><p>The local agent is not connected. Try again later.</p></body>",
557
+ "</html>"
558
+ ].join("")
491
559
  };
492
560
  }
493
- function getDisplayValues(input) {
561
+
562
+ // src/ghost-tunnel-store.ts
563
+ import { randomUUID } from "crypto";
564
+ var DEFAULT_GHOST_TUNNEL_RESPONSE_TTL_SECONDS = 60;
565
+ function base64Encode(value) {
566
+ return value.toString("base64");
567
+ }
568
+ function encodeGhostTunnelBody(value) {
569
+ return base64Encode(Buffer.isBuffer(value) ? value : Buffer.from(value));
570
+ }
571
+ function decodeGhostTunnelBody(value) {
572
+ return value ? Buffer.from(value, "base64") : void 0;
573
+ }
574
+ function createGhostTunnelQueuedRequest(input) {
575
+ const now = input.now ?? /* @__PURE__ */ new Date();
576
+ const bodyBase64 = typeof input.body === "undefined" ? void 0 : encodeGhostTunnelBody(input.body);
494
577
  return {
495
- ...input.route ? { route: input.route } : {},
496
- ...input.project ? { project: input.project } : {},
497
- ...input.owner ? { owner: input.owner } : {},
498
- ...input.values
578
+ id: randomUUID(),
579
+ host: input.host,
580
+ method: input.method.toUpperCase(),
581
+ path: input.path,
582
+ headers: input.headers ?? {},
583
+ createdAt: now.toISOString(),
584
+ expiresAt: new Date(now.getTime() + input.ttlSeconds * 1e3).toISOString(),
585
+ ...bodyBase64 ? { bodyBase64 } : {}
499
586
  };
500
587
  }
501
- function getDisplayDefaults(config, defaults) {
502
- return config.mode === "public" && !config.preview ? void 0 : defaults;
503
- }
504
- function createDisplayUrl(config, defaults, domain) {
505
- const input = getPreviewDefaults(config.preview, getDisplayDefaults(config, defaults));
506
- const protocol = input.protocol ?? "https";
507
- const slug = createNamespaceDisplaySlug(config.namespace, getDisplayValues(input));
508
- const entryHost = domain ? getGhostTunnelEntryHost(domain, config) : input.domain ? getGhostTunnelEntryHost(input.domain, config) : `${config.subdomain}.*`;
509
- const url = `${protocol}://${slug}.${entryHost}/`;
510
- if (!input.path) return url;
511
- return `${url}${input.path.replace(/^\/+/, "")}`;
588
+ function createGhostTunnelRouteHeartbeat(input) {
589
+ const now = input.now ?? /* @__PURE__ */ new Date();
590
+ return {
591
+ host: input.host,
592
+ agentId: input.agentId,
593
+ target: input.target,
594
+ updatedAt: now.toISOString(),
595
+ expiresAt: new Date(now.getTime() + input.ttlSeconds * 1e3).toISOString()
596
+ };
512
597
  }
513
- function createDisplayUrls(config, defaults) {
514
- const displayDefaults = getDisplayDefaults(config, defaults);
515
- const domains = config.domains.length > 0 ? config.domains : displayDefaults?.domain ? [displayDefaults.domain] : [];
516
- const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, displayDefaults, domain)) : [createDisplayUrl(config, displayDefaults)];
517
- return [...new Set(urls)];
598
+ function isExpired(expiresAt, now = /* @__PURE__ */ new Date()) {
599
+ const timestamp = Date.parse(expiresAt);
600
+ return Number.isNaN(timestamp) || timestamp <= now.getTime();
518
601
  }
519
- function maybeConstructPreviewUrl(config, defaults) {
520
- if (!config.preview) return void 0;
521
- const input = getPreviewDefaults(config.preview, defaults);
522
- if (!input.domain || !input.route || !input.project || !input.owner) return void 0;
523
- return constructGhostTunnelUrl({
524
- domain: input.domain,
525
- route: input.route,
526
- project: input.project,
527
- owner: input.owner,
528
- values: input.values,
529
- ...input.path ? { path: input.path } : {},
530
- ...input.protocol ? { protocol: input.protocol } : {},
531
- ghostTunnel: config
532
- });
602
+ function serializeJson(value) {
603
+ return JSON.stringify(value);
533
604
  }
534
- function parseNamespaceSlug(slug, config) {
535
- const parts = slug.split(config.separator);
536
- if (parts.length < config.tags.length) return null;
537
- if (parts.length !== config.tags.length && !config.spreadTag) return null;
538
- const namespace = {};
539
- const spreadIndex = config.spreadTag ? config.tags.indexOf(config.spreadTag) : -1;
540
- const spreadWidth = spreadIndex >= 0 ? parts.length - config.tags.length + 1 : 1;
541
- let partIndex = 0;
542
- for (const [tagIndex, tag] of config.tags.entries()) {
543
- const value = tagIndex === spreadIndex ? parts.slice(partIndex, partIndex + spreadWidth).join(config.separator) : parts[partIndex];
544
- if (!value || !isValidHostLabel(value)) return null;
545
- if (tagIndex !== spreadIndex && value.includes(config.separator)) return null;
546
- namespace[tag] = value;
547
- partIndex += tagIndex === spreadIndex ? spreadWidth : 1;
605
+ function parseJson(value) {
606
+ if (typeof value !== "string") return null;
607
+ try {
608
+ return JSON.parse(value);
609
+ } catch {
610
+ return null;
548
611
  }
549
- return namespace;
550
612
  }
551
- function resolveGhostTunnelConfig(options, defaults) {
552
- if (options === false || typeof options === "undefined") {
553
- return {
554
- enabled: false,
555
- mode: DEFAULT_GHOST_TUNNEL_MODE,
556
- domains: [],
557
- subdomain: DEFAULT_GHOST_TUNNEL_SUBDOMAIN,
558
- namespace: resolveNamespaceConfig(void 0),
559
- displayUrls: [],
560
- requireHttps: true,
561
- requireAuth: true
562
- };
613
+ function keyPart(value) {
614
+ return value.toLowerCase().replace(/[^a-z0-9._:-]/g, "_");
615
+ }
616
+ var MemoryGhostTunnelStore = class {
617
+ routes = /* @__PURE__ */ new Map();
618
+ queues = /* @__PURE__ */ new Map();
619
+ responses = /* @__PURE__ */ new Map();
620
+ async heartbeatRoute(route) {
621
+ this.routes.set(route.host, route);
563
622
  }
564
- const config = typeof options === "string" ? { mode: options } : options;
565
- const subdomain = config.subdomain ?? DEFAULT_GHOST_TUNNEL_SUBDOMAIN;
566
- assertValidSubdomain(subdomain);
567
- const domains = normalizeDomains(config.domains);
568
- const enabled = config.enabled ?? true;
569
- const resolved = {
570
- enabled,
571
- mode: parseGhostTunnelMode(config.mode),
572
- domains,
573
- subdomain,
574
- namespace: resolveNamespaceConfig(config.namespace),
575
- ...config.preview ? { preview: config.preview } : {},
576
- displayUrls: [],
577
- requireHttps: config.requireHttps ?? true,
578
- requireAuth: config.requireAuth ?? true
579
- };
580
- if (!enabled) {
581
- return resolved;
623
+ async getRoute(host) {
624
+ const route = this.routes.get(host);
625
+ if (!route) return null;
626
+ if (!isExpired(route.expiresAt)) return route;
627
+ this.routes.delete(host);
628
+ return null;
582
629
  }
583
- const previewUrl = maybeConstructPreviewUrl(resolved, defaults);
584
- const displayUrls = previewUrl ? [previewUrl] : createDisplayUrls(resolved, defaults);
585
- return {
586
- ...resolved,
587
- displayUrls,
588
- ...displayUrls[0] ? { displayUrl: displayUrls[0] } : {},
589
- ...previewUrl ? { previewUrl } : {}
590
- };
591
- }
592
- function getGhostTunnelEntryHost(domain, options = {}) {
593
- const config = toGhostTunnelConfig(options);
594
- const normalizedDomain = normalizeDomain(domain);
595
- if (!normalizedDomain) {
596
- throw new Error(`Invalid ghost tunnel domain: ${domain}`);
630
+ async enqueueRequest(request) {
631
+ const queue = this.queues.get(request.host) ?? [];
632
+ queue.push(request);
633
+ this.queues.set(request.host, queue);
634
+ }
635
+ async claimRequest(host) {
636
+ const queue = this.queues.get(host) ?? [];
637
+ while (queue.length > 0) {
638
+ const request = queue.shift();
639
+ if (request && !isExpired(request.expiresAt)) {
640
+ return request;
641
+ }
642
+ }
643
+ return null;
597
644
  }
598
- return `${config.subdomain}.${normalizedDomain}`;
599
- }
600
- function getGhostTunnelWildcardHost(domain, options = {}) {
601
- return `*.${getGhostTunnelEntryHost(domain, options)}`;
602
- }
603
- function constructGhostTunnelHost(input) {
604
- const config = toGhostTunnelConfig(input.ghostTunnel ?? {});
605
- if (!config.enabled) {
606
- throw new Error("Ghost tunnel is not enabled.");
645
+ async writeResponse(response, ttlSeconds) {
646
+ this.responses.set(response.id, {
647
+ value: response,
648
+ expiresAt: new Date(Date.now() + ttlSeconds * 1e3).toISOString()
649
+ });
607
650
  }
608
- const namespaceValues = {
609
- route: input.route,
610
- project: input.project,
611
- owner: input.owner,
612
- ...input.values ?? {}
613
- };
614
- const slug = createNamespaceSlug(config.namespace, namespaceValues);
615
- return `${slug}.${getGhostTunnelEntryHost(input.domain, config)}`;
616
- }
617
- function constructGhostTunnelUrl(input) {
618
- const protocol = input.protocol ?? "https";
619
- const host = constructGhostTunnelHost(input);
620
- const url = new URL(`${protocol}://${host}/`);
621
- if (input.path) {
622
- url.pathname = `/${input.path.replace(/^\/+/, "")}`;
651
+ async readResponse(requestId) {
652
+ const response = this.responses.get(requestId);
653
+ if (!response) return null;
654
+ if (!isExpired(response.expiresAt)) return response.value;
655
+ this.responses.delete(requestId);
656
+ return null;
623
657
  }
624
- if (input.searchParams instanceof URLSearchParams) {
625
- url.search = input.searchParams.toString();
626
- } else if (input.searchParams) {
627
- for (const [key, value] of Object.entries(input.searchParams)) {
628
- if (typeof value !== "undefined" && value !== null) {
629
- url.searchParams.set(key, String(value));
630
- }
658
+ async cleanup(requestId) {
659
+ this.responses.delete(requestId);
660
+ }
661
+ };
662
+ function createMemoryGhostTunnelStore() {
663
+ return new MemoryGhostTunnelStore();
664
+ }
665
+ var RedisGhostTunnelStore = class {
666
+ url;
667
+ token;
668
+ namespace;
669
+ fetchImpl;
670
+ constructor(options) {
671
+ this.url = options.url.replace(/\/+$/, "");
672
+ this.token = options.token;
673
+ this.namespace = options.namespace ?? "localghost";
674
+ this.fetchImpl = options.fetch ?? fetch;
675
+ }
676
+ key(kind, id) {
677
+ return `${this.namespace}:ghost-tunnel:${kind}:${keyPart(id)}`;
678
+ }
679
+ async command(command, ...args) {
680
+ const response = await this.fetchImpl(this.url, {
681
+ method: "POST",
682
+ headers: {
683
+ authorization: `Bearer ${this.token}`,
684
+ "content-type": "application/json"
685
+ },
686
+ body: JSON.stringify([command, ...args])
687
+ });
688
+ if (!response.ok) {
689
+ throw new Error(`Redis Ghost Tunnel command failed: ${response.status} ${response.statusText}`);
690
+ }
691
+ const payload = await response.json();
692
+ if (payload.error) {
693
+ throw new Error(`Redis Ghost Tunnel command failed: ${payload.error}`);
694
+ }
695
+ return typeof payload.result === "undefined" ? null : payload.result;
696
+ }
697
+ async heartbeatRoute(route, ttlSeconds) {
698
+ await this.command("SET", this.key("route", route.host), serializeJson(route), "EX", ttlSeconds);
699
+ }
700
+ async getRoute(host) {
701
+ const route = parseJson(await this.command("GET", this.key("route", host)));
702
+ return route && !isExpired(route.expiresAt) ? route : null;
703
+ }
704
+ async enqueueRequest(request, ttlSeconds) {
705
+ const queueKey = this.key("queue", request.host);
706
+ await this.command("RPUSH", queueKey, serializeJson(request));
707
+ await this.command("EXPIRE", queueKey, ttlSeconds);
708
+ }
709
+ async claimRequest(host) {
710
+ const queueKey = this.key("queue", host);
711
+ while (true) {
712
+ const request = parseJson(await this.command("LPOP", queueKey));
713
+ if (!request) return null;
714
+ if (!isExpired(request.expiresAt)) return request;
631
715
  }
632
716
  }
633
- return url.toString();
717
+ async writeResponse(response, ttlSeconds) {
718
+ await this.command("SET", this.key("response", response.id), serializeJson(response), "EX", ttlSeconds);
719
+ }
720
+ async readResponse(requestId) {
721
+ return parseJson(await this.command("GET", this.key("response", requestId)));
722
+ }
723
+ async cleanup(requestId) {
724
+ await this.command("DEL", this.key("response", requestId));
725
+ }
726
+ };
727
+ function createRedisGhostTunnelStore(options) {
728
+ return new RedisGhostTunnelStore(options);
729
+ }
730
+ function resolveRedisGhostTunnelEnv(env = process.env) {
731
+ const candidates = [
732
+ 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,
733
+ 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,
734
+ 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,
735
+ 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
736
+ ];
737
+ const match = candidates.find((candidate) => Boolean(candidate));
738
+ if (!match) {
739
+ 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.");
740
+ }
741
+ return match;
742
+ }
743
+ function createRedisGhostTunnelStoreFromEnv(input = {}) {
744
+ const resolved = resolveRedisGhostTunnelEnv(input.env);
745
+ return createRedisGhostTunnelStore({
746
+ url: resolved.url,
747
+ token: resolved.token,
748
+ ...input.namespace ? { namespace: input.namespace } : {},
749
+ ...input.fetch ? { fetch: input.fetch } : {}
750
+ });
634
751
  }
635
- var constructGhostTunnelURL = constructGhostTunnelUrl;
636
- function getGhostTunnelDefaultDisplayUrl(options = {}, defaults) {
637
- const config = toGhostTunnelConfig(options);
638
- if (!config.enabled) return null;
639
- return createDisplayUrl(config, defaults);
752
+
753
+ // src/ghost-agent.ts
754
+ function isStopped(signal, localSignal) {
755
+ return localSignal.aborted || signal?.aborted === true;
756
+ }
757
+ function wait(ms, signal, localSignal) {
758
+ if (isStopped(signal, localSignal)) return Promise.resolve();
759
+ return new Promise((resolve3) => {
760
+ const timeout = setTimeout(resolve3, ms);
761
+ const stop = () => {
762
+ clearTimeout(timeout);
763
+ resolve3();
764
+ };
765
+ signal?.addEventListener("abort", stop, { once: true });
766
+ localSignal.addEventListener("abort", stop, { once: true });
767
+ });
640
768
  }
641
- function getGhostTunnelDisplayUrl(options, defaults) {
642
- const config = toGhostTunnelConfig(options);
643
- if (!config.enabled) return null;
644
- return config.displayUrl ?? config.previewUrl ?? getGhostTunnelDefaultDisplayUrl(config, defaults);
769
+ function toHeaderRecord(headers) {
770
+ const result = {};
771
+ headers.forEach((value, name) => {
772
+ result[name] = value;
773
+ });
774
+ return result;
645
775
  }
646
- function getGhostTunnelDisplayUrls(options, defaults) {
647
- const config = toGhostTunnelConfig(options);
648
- if (!config.enabled) return [];
649
- if (config.displayUrls.length > 0) return config.displayUrls;
650
- const displayUrl = getGhostTunnelDisplayUrl(config, defaults);
651
- return displayUrl ? [displayUrl] : [];
776
+ function hasRequestBody(method) {
777
+ return method !== "GET" && method !== "HEAD";
652
778
  }
653
- function getGhostTunnelPreviewUrl(options) {
654
- const config = toGhostTunnelConfig(options);
655
- if (!config.enabled) return null;
656
- return config.previewUrl ?? maybeConstructPreviewUrl(config) ?? null;
779
+ async function serveGhostTunnelLocalRequest(input) {
780
+ const fetchImpl = input.fetch ?? fetch;
781
+ const localUrl = new URL(`${input.target.protocol}://${input.target.host}:${input.target.port}/`);
782
+ const requestPath = new URL(input.request.path, "http://localghost.invalid");
783
+ localUrl.pathname = requestPath.pathname;
784
+ localUrl.search = requestPath.search;
785
+ try {
786
+ const body = hasRequestBody(input.request.method) ? decodeGhostTunnelBody(input.request.bodyBase64) : void 0;
787
+ const response = await fetchImpl(localUrl, {
788
+ method: input.request.method,
789
+ headers: {
790
+ ...stripRelayForwardHeaders(input.request.headers),
791
+ "x-forwarded-host": input.request.host,
792
+ "x-localghost-tunnel": "1"
793
+ },
794
+ ...body ? { body } : {}
795
+ });
796
+ const responseBody = Buffer.from(await response.arrayBuffer());
797
+ if (responseBody.byteLength > input.maxResponseBodyBytes) {
798
+ throw new Error(`Ghost Tunnel response exceeded ${input.maxResponseBodyBytes} bytes.`);
799
+ }
800
+ return {
801
+ id: input.request.id,
802
+ status: response.status,
803
+ headers: toHeaderRecord(response.headers),
804
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
805
+ ...responseBody.byteLength > 0 ? { bodyBase64: encodeGhostTunnelBody(responseBody) } : {}
806
+ };
807
+ } catch (error) {
808
+ return {
809
+ id: input.request.id,
810
+ status: 502,
811
+ headers: {
812
+ "content-type": "text/plain; charset=utf-8",
813
+ "cache-control": "no-store"
814
+ },
815
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
816
+ error: error instanceof Error ? error.message : String(error),
817
+ bodyBase64: encodeGhostTunnelBody("Ghost Tunnel local target failed.")
818
+ };
819
+ }
657
820
  }
658
- function parseGhostTunnelHost(host, domain, options = {}) {
659
- const config = toGhostTunnelConfig(options);
660
- if (!config.enabled) return null;
661
- const normalizedHost = normalizeDomain(host);
662
- const normalizedDomain = normalizeDomain(domain);
663
- if (!normalizedHost || !normalizedDomain) return null;
664
- const entryHost = getGhostTunnelEntryHost(normalizedDomain, config);
665
- const suffix = `.${entryHost}`;
666
- if (!normalizedHost.endsWith(suffix)) return null;
667
- const slug = normalizedHost.slice(0, -suffix.length);
668
- if (!isValidHostLabel(slug)) return null;
669
- const namespace = parseNamespaceSlug(slug, config.namespace);
670
- if (!namespace) return null;
821
+ async function heartbeatRoutes(input) {
822
+ for (const entry of input.entries) {
823
+ const target = assertRelayLocalTarget({ host: input.targetHost, port: entry.port });
824
+ await input.store.heartbeatRoute(createGhostTunnelRouteHeartbeat({
825
+ host: entry.host,
826
+ agentId: input.agentId,
827
+ target,
828
+ ttlSeconds: input.routeTtlSeconds
829
+ }), input.routeTtlSeconds);
830
+ }
831
+ }
832
+ async function claimAndServe(input) {
833
+ const request = await input.store.claimRequest(input.entry.host);
834
+ if (!request) return false;
835
+ const target = assertRelayLocalTarget({ host: input.targetHost, port: input.entry.port });
836
+ const response = await serveGhostTunnelLocalRequest({
837
+ request,
838
+ target,
839
+ maxResponseBodyBytes: input.maxResponseBodyBytes,
840
+ ...input.fetch ? { fetch: input.fetch } : {}
841
+ });
842
+ await input.store.writeResponse(response, input.requestTtlSeconds);
843
+ return true;
844
+ }
845
+ function startGhostTunnelAgent(options) {
846
+ const controller = new AbortController();
847
+ const localSignal = controller.signal;
848
+ const signal = options.signal;
849
+ const agentId = options.agentId ?? `localghost-${randomUUID2()}`;
850
+ const targetHost = options.targetHost ?? "127.0.0.1";
851
+ const routeTtlSeconds = options.routeTtlSeconds ?? 30;
852
+ const requestTtlSeconds = options.requestTtlSeconds ?? 60;
853
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
854
+ const maxResponseBodyBytes = options.maxResponseBodyBytes ?? 5 * 1024 * 1024;
855
+ const done = (async () => {
856
+ if (options.entries.length === 0) {
857
+ throw new Error("Ghost Tunnel agent requires at least one .ghosttunnel entry.");
858
+ }
859
+ options.log?.(`localghost tunnel agent ${agentId}`);
860
+ for (const entry of options.entries) {
861
+ options.log?.(` ${entry.host} -> ${targetHost}:${entry.port}`);
862
+ }
863
+ let lastHeartbeat = 0;
864
+ while (!isStopped(signal, localSignal)) {
865
+ const now = Date.now();
866
+ if (now - lastHeartbeat >= Math.max(1e3, Math.floor(routeTtlSeconds * 1e3 / 3))) {
867
+ await heartbeatRoutes({
868
+ entries: options.entries,
869
+ store: options.store,
870
+ agentId,
871
+ targetHost,
872
+ routeTtlSeconds
873
+ });
874
+ lastHeartbeat = now;
875
+ }
876
+ let served = false;
877
+ for (const entry of options.entries) {
878
+ served = await claimAndServe({
879
+ entry,
880
+ store: options.store,
881
+ targetHost,
882
+ requestTtlSeconds,
883
+ maxResponseBodyBytes,
884
+ ...options.fetch ? { fetch: options.fetch } : {}
885
+ }) || served;
886
+ }
887
+ if (!served) {
888
+ await wait(pollIntervalMs, signal, localSignal);
889
+ }
890
+ }
891
+ })();
671
892
  return {
672
- host: normalizedHost,
673
- slug,
674
- namespace,
675
- entryHost,
676
- wildcardHost: `*.${entryHost}`,
677
- domain: normalizedDomain
893
+ agentId,
894
+ stop() {
895
+ controller.abort();
896
+ },
897
+ done
678
898
  };
679
899
  }
680
- function assertSecureGhostTunnelRequest(input) {
681
- const config = toGhostTunnelConfig(input.ghostTunnel);
682
- if (!config.enabled) {
683
- throw new Error("Ghost tunnel is not enabled.");
684
- }
685
- if (config.requireHttps && input.protocol !== "https") {
686
- throw new Error("Ghost tunnel requests must use HTTPS.");
687
- }
688
- if (config.requireAuth && input.authenticated !== true) {
689
- throw new Error("Ghost tunnel requests must be authenticated.");
690
- }
691
- const route = parseGhostTunnelHost(input.host, input.domain, config);
692
- if (!route) {
693
- throw new Error(`Host is not a valid ghost tunnel host for ${input.domain}.`);
694
- }
695
- return route;
696
- }
697
900
 
698
- // src/context.ts
699
- var LOCALGHOST_PROJECT_CONFIG_FILES = [
700
- "localghost.config.mjs",
701
- "localghost.config.js",
702
- "localghost.config.cjs"
703
- ];
704
- function parsePort(value) {
705
- if (!value) return void 0;
706
- const port = Number.parseInt(value, 10);
707
- return Number.isInteger(port) && port > 0 && port <= 65535 ? port : void 0;
901
+ // src/ghost-transport.ts
902
+ import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
903
+ import { isIP } from "net";
904
+
905
+ // src/tunnel.ts
906
+ import { domainToASCII as domainToASCII2 } from "url";
907
+ var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
908
+ var DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS = ["route", "project", "owner"];
909
+ var DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR = "-";
910
+ var DEFAULT_GHOST_TUNNEL_MODE = "manual";
911
+ var DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY = "same-project";
912
+ var DEFAULT_GHOST_TUNNEL_TRANSPORT_KIND = "none";
913
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_PROVIDER = "vercel-redis";
914
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_ENV = "auto";
915
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_NAMESPACE = "localghost";
916
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_WAIT_MS = 25e3;
917
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_POLL_INTERVAL_MS = 250;
918
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_ROUTE_TTL_SECONDS = 30;
919
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_REQUEST_TTL_SECONDS = 60;
920
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
921
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024;
922
+ function isResolvedGhostTunnelConfig(value) {
923
+ return typeof value === "object" && value !== null && "enabled" in value;
708
924
  }
709
- function envPort() {
710
- return parsePort(process.env.LOCALGHOST_PORT) ?? parsePort(process.env.VITE_PORT);
925
+ function toGhostTunnelConfig(options) {
926
+ return isResolvedGhostTunnelConfig(options) ? options : resolveGhostTunnelConfig(options);
711
927
  }
712
- function envDynamicPort() {
713
- const value = process.env.LOCALGHOST_DYNAMIC_PORT;
714
- if (!value) return void 0;
715
- return ["1", "true", "yes", "on"].includes(value.toLowerCase());
928
+ function stripHostPort(value) {
929
+ const trimmed = value.trim().toLowerCase();
930
+ if (trimmed.startsWith("[") || trimmed.includes("/")) return "";
931
+ const portSeparator = trimmed.lastIndexOf(":");
932
+ if (portSeparator === -1) return trimmed;
933
+ const port = trimmed.slice(portSeparator + 1);
934
+ return /^\d+$/.test(port) ? trimmed.slice(0, portSeparator) : trimmed;
716
935
  }
717
- function envHttps() {
718
- const value = process.env.LOCALGHOST_HTTPS;
719
- if (!value) return void 0;
720
- return ["1", "true", "yes", "on"].includes(value.toLowerCase());
936
+ function normalizeDomain(value) {
937
+ const host = stripHostPort(value.replace(/^\*\./, ""));
938
+ const ascii = domainToASCII2(host);
939
+ if (!ascii || ascii.length > 253 || ascii.includes("..")) return null;
940
+ if (ascii.startsWith(".") || ascii.endsWith(".")) return null;
941
+ if (ascii.includes("*")) return null;
942
+ if (!ascii.split(".").every(isValidHostLabel)) return null;
943
+ return ascii;
721
944
  }
722
- function getPackageName(cwd) {
723
- try {
724
- const pkg = JSON.parse(readFileSync4(join4(cwd, "package.json"), "utf8"));
725
- return typeof pkg.name === "string" ? pkg.name : void 0;
726
- } catch {
727
- return void 0;
945
+ function isValidHostLabel(value) {
946
+ return value.length > 0 && value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
947
+ }
948
+ function isValidNamespaceTag(value) {
949
+ return /^[a-z][a-z0-9_]*$/i.test(value);
950
+ }
951
+ function isNamespaceTagList(options) {
952
+ return Array.isArray(options);
953
+ }
954
+ function assertValidSubdomain(value) {
955
+ if (!isValidHostLabel(value)) {
956
+ throw new Error(`Invalid ghost tunnel subdomain: ${value}`);
728
957
  }
729
958
  }
730
- function getPackageOwner(cwd) {
731
- const packageName = getPackageName(cwd);
732
- if (!packageName?.startsWith("@")) return void 0;
733
- return packageName.slice(1).split("/")[0];
959
+ function normalizeDomains(domains) {
960
+ const values = typeof domains === "string" ? [domains] : [...domains ?? []];
961
+ const normalized = values.map((value) => value.trim()).filter(Boolean).map((value) => {
962
+ const domain = normalizeDomain(value);
963
+ if (!domain) throw new Error(`Invalid ghost tunnel domain: ${value}`);
964
+ return domain;
965
+ });
966
+ return [...new Set(normalized)];
734
967
  }
735
- function getLocalOwner(cwd) {
736
- return sanitizeProjectName(process.env.LOCALGHOST_OWNER ?? getPackageOwner(cwd) ?? process.env.USER ?? process.env.USERNAME ?? "local");
968
+ function parseGhostTunnelMode(value) {
969
+ return value ?? DEFAULT_GHOST_TUNNEL_MODE;
737
970
  }
738
- function getRouteName(primaryHost, fallback) {
739
- return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
971
+ function parseGhostTunnelAdapterStrategy(value) {
972
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY;
973
+ if (value === "same-project" || value === "separate-relay") return value;
974
+ throw new Error(`Unsupported ghost tunnel adapter strategy: ${String(value)}`);
740
975
  }
741
- function readOptionsFromContext(options) {
742
- return {
743
- cwd: options.cwd ?? process.cwd(),
744
- ...options.fileName ? { fileName: options.fileName } : {},
745
- ...options.configFiles ? { configFiles: options.configFiles } : {},
746
- ...options.configPattern ? { configPattern: options.configPattern } : {}
747
- };
976
+ function parseGhostTunnelTransportKind(value) {
977
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TRANSPORT_KIND;
978
+ if (value === "none" || value === "ip" || value === "tunnel") return value;
979
+ throw new Error(`Unsupported ghost tunnel transport: ${String(value)}`);
748
980
  }
749
- function withRuntimePort(entries, requestedPort, port) {
750
- if (requestedPort === port) return entries;
751
- const hasRequestedPort = entries.some((entry) => entry.port === requestedPort);
752
- if (!hasRequestedPort) return entries;
753
- return entries.map((entry) => entry.port === requestedPort ? { ...entry, port } : entry);
981
+ function parsePositiveInteger(value, fallback, name) {
982
+ if (typeof value === "undefined") return fallback;
983
+ if (!Number.isInteger(value) || value < 1) {
984
+ throw new Error(`Invalid ghost tunnel ${name}: ${value}`);
985
+ }
986
+ return value;
754
987
  }
755
- function uniqueHosts(entries) {
756
- return [...new Set(entries.map((entry) => entry.host))];
988
+ function parseTunnelStoreProvider(value) {
989
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_PROVIDER;
990
+ if (value === "vercel-redis" || value === "redis") return value;
991
+ throw new Error(`Unsupported ghost tunnel tunnel store provider: ${String(value)}`);
757
992
  }
758
- function isAliasableHost(host) {
759
- return host.includes(".") && !host.startsWith("www.") && !host.includes(":");
993
+ function parseTunnelStoreEnv(value) {
994
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_ENV;
995
+ if (value === "auto") return value;
996
+ throw new Error(`Unsupported ghost tunnel tunnel store env: ${String(value)}`);
760
997
  }
761
- function getDefaultWwwAlias(host) {
762
- return isAliasableHost(host) ? `www.${host}` : null;
998
+ function parseTunnelStoreNamespace(value) {
999
+ const namespace = value ?? DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_NAMESPACE;
1000
+ if (!/^[a-z][a-z0-9:_-]{0,63}$/i.test(namespace)) {
1001
+ throw new Error(`Invalid ghost tunnel tunnel store namespace: ${namespace}`);
1002
+ }
1003
+ return namespace;
763
1004
  }
764
- function addDefaultWwwAliases(entries) {
765
- const seen = new Set(entries.map((entry) => entry.host));
766
- const aliases = [];
767
- for (const entry of entries) {
768
- const alias = getDefaultWwwAlias(entry.host);
769
- if (alias && !seen.has(alias)) {
770
- aliases.push({ host: alias, port: entry.port, target: `127.0.0.1:${entry.port}` });
771
- seen.add(alias);
772
- }
1005
+ function resolveGhostTunnelAdapter(input) {
1006
+ if (!input) return void 0;
1007
+ const provider = typeof input === "string" ? input : input.provider;
1008
+ if (provider !== "vercel") {
1009
+ throw new Error(`Unsupported ghost tunnel adapter provider: ${String(provider)}`);
773
1010
  }
774
- return [...entries, ...aliases];
1011
+ return {
1012
+ provider,
1013
+ strategy: typeof input === "string" ? DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY : parseGhostTunnelAdapterStrategy(input.strategy)
1014
+ };
775
1015
  }
776
- function defined(input) {
777
- return Object.fromEntries(Object.entries(input).filter(([, value]) => typeof value !== "undefined"));
1016
+ function getLegacyGhostTunnelTransport(input) {
1017
+ if (!input || typeof input === "string" || !("transport" in input)) return void 0;
1018
+ return input.transport;
778
1019
  }
779
- async function readLocalghostProjectConfig(options = {}) {
780
- const cwd = options.cwd ?? process.cwd();
781
- if (options.configFile === false) return { config: {} };
782
- const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
783
- const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync3(candidate));
784
- if (!path) return { config: {} };
785
- const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
786
- const config = imported.default ?? imported;
787
- return { config, path };
1020
+ function resolveGhostTunnelTransport(input) {
1021
+ if (!input) {
1022
+ return { kind: "none" };
1023
+ }
1024
+ const kind = typeof input === "string" ? parseGhostTunnelTransportKind(input) : parseGhostTunnelTransportKind(input.kind);
1025
+ if (kind === "ip") {
1026
+ return {
1027
+ kind,
1028
+ allowPrivateNetworkAddress: typeof input === "string" ? false : input.kind === "ip" ? input.allowPrivateNetworkAddress ?? false : false
1029
+ };
1030
+ }
1031
+ if (kind === "tunnel") {
1032
+ const config = typeof input === "string" || input.kind !== "tunnel" ? void 0 : input;
1033
+ const store = config?.store ?? {};
1034
+ return {
1035
+ kind,
1036
+ store: {
1037
+ provider: parseTunnelStoreProvider(store.provider),
1038
+ env: parseTunnelStoreEnv(store.env),
1039
+ namespace: parseTunnelStoreNamespace(store.namespace)
1040
+ },
1041
+ waitMs: parsePositiveInteger(config?.waitMs, DEFAULT_GHOST_TUNNEL_TUNNEL_WAIT_MS, "tunnel waitMs"),
1042
+ pollIntervalMs: parsePositiveInteger(config?.pollIntervalMs, DEFAULT_GHOST_TUNNEL_TUNNEL_POLL_INTERVAL_MS, "tunnel pollIntervalMs"),
1043
+ routeTtlSeconds: parsePositiveInteger(config?.routeTtlSeconds, DEFAULT_GHOST_TUNNEL_TUNNEL_ROUTE_TTL_SECONDS, "tunnel routeTtlSeconds"),
1044
+ requestTtlSeconds: parsePositiveInteger(config?.requestTtlSeconds, DEFAULT_GHOST_TUNNEL_TUNNEL_REQUEST_TTL_SECONDS, "tunnel requestTtlSeconds"),
1045
+ maxRequestBodyBytes: parsePositiveInteger(config?.maxRequestBodyBytes, DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_REQUEST_BODY_BYTES, "tunnel maxRequestBodyBytes"),
1046
+ maxResponseBodyBytes: parsePositiveInteger(config?.maxResponseBodyBytes, DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_RESPONSE_BODY_BYTES, "tunnel maxResponseBodyBytes")
1047
+ };
1048
+ }
1049
+ return { kind: "none" };
788
1050
  }
789
- function defineLocalghostConfig(config) {
790
- return config;
1051
+ function resolveNamespaceConfig(options) {
1052
+ const tags = isNamespaceTagList(options) ? [...options] : [...options?.tags ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS];
1053
+ let separator = DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
1054
+ let spreadTag = tags.includes("project") ? "project" : void 0;
1055
+ if (options && !isNamespaceTagList(options)) {
1056
+ separator = options.separator ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
1057
+ spreadTag = options.spreadTag === false ? false : options.spreadTag ?? spreadTag;
1058
+ }
1059
+ if (tags.length === 0) {
1060
+ throw new Error("Ghost tunnel namespace must include at least one tag.");
1061
+ }
1062
+ for (const tag of tags) {
1063
+ if (!isValidNamespaceTag(tag)) {
1064
+ throw new Error(`Invalid ghost tunnel namespace tag: ${tag}`);
1065
+ }
1066
+ }
1067
+ if (spreadTag && !tags.includes(spreadTag)) {
1068
+ throw new Error(`Ghost tunnel namespace spreadTag must be listed in tags: ${spreadTag}`);
1069
+ }
1070
+ if (!separator || separator.length > 8 || !/^[a-z0-9-]+$/.test(separator)) {
1071
+ throw new Error(`Invalid ghost tunnel namespace separator: ${separator}`);
1072
+ }
1073
+ return {
1074
+ tags,
1075
+ separator,
1076
+ ...spreadTag ? { spreadTag } : {}
1077
+ };
791
1078
  }
792
- async function resolveLocalghostContext(options = {}) {
793
- const cwd = options.cwd ?? process.cwd();
794
- const projectConfig = await readLocalghostProjectConfig({
795
- cwd,
796
- ...typeof options.localghostConfig !== "undefined" ? { configFile: options.localghostConfig } : {}
1079
+ function normalizeNamespaceValue(tag, value, separator, options = {}) {
1080
+ const normalized = normalizeDomain(value);
1081
+ if (!normalized || normalized.includes(".")) {
1082
+ throw new Error(`Invalid ghost tunnel namespace value for ${tag}: ${value}`);
1083
+ }
1084
+ if (!options.allowSeparator && normalized.includes(separator)) {
1085
+ throw new Error(`Ghost tunnel namespace value for ${tag} cannot include separator "${separator}": ${value}`);
1086
+ }
1087
+ return normalized;
1088
+ }
1089
+ function createNamespaceSlug(config, values) {
1090
+ const parts = config.tags.map((tag) => {
1091
+ const value = values[tag];
1092
+ if (!value) {
1093
+ throw new Error(`Missing ghost tunnel namespace value: ${tag}`);
1094
+ }
1095
+ return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
797
1096
  });
798
- const merged = {
799
- ...projectConfig.config,
800
- ...defined(options)
1097
+ const slug = parts.join(config.separator);
1098
+ if (!isValidHostLabel(slug)) {
1099
+ throw new Error(`Ghost tunnel namespace is too long for a DNS label: ${slug}`);
1100
+ }
1101
+ return slug;
1102
+ }
1103
+ function createNamespaceDisplaySlug(config, values = {}) {
1104
+ return config.tags.map((tag) => {
1105
+ const value = values[tag];
1106
+ if (!value) return `<${tag}>`;
1107
+ try {
1108
+ return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
1109
+ } catch {
1110
+ return `<${tag}>`;
1111
+ }
1112
+ }).join(config.separator);
1113
+ }
1114
+ function getPreviewDefaults(preview, defaults) {
1115
+ return {
1116
+ domain: preview?.domain ?? defaults?.domain,
1117
+ route: preview?.route ?? defaults?.route,
1118
+ project: preview?.project ?? defaults?.project,
1119
+ owner: preview?.owner ?? defaults?.owner,
1120
+ values: {
1121
+ ...defaults?.values ?? {},
1122
+ ...preview?.values ?? {}
1123
+ },
1124
+ path: preview?.path,
1125
+ protocol: preview?.protocol
801
1126
  };
802
- const readOptions = readOptionsFromContext({ ...merged, cwd });
803
- const resolvedPath = resolveDevHostsPath(readOptions);
804
- const configEntries = readDevHosts(readOptions);
805
- const requestedPort = merged.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
806
- const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? true;
807
- const bindHost = merged.bindHost ?? "127.0.0.1";
808
- const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
809
- const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
810
- const wwwAlias = merged.wwwAlias ?? true;
811
- const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
812
- const hosts = uniqueHosts(entries);
813
- const primaryHost = merged.primaryHost ?? entries.find((entry) => entry.port === port)?.host ?? hosts[0] ?? `${sanitizeProjectName(getProjectName(cwd))}.localhost`;
814
- const projectName = sanitizeProjectName(merged.project ?? getProjectName(cwd));
815
- const ghostTunnel = resolveGhostTunnelConfig(merged.ghostTunnel, {
816
- route: getRouteName(primaryHost, projectName),
817
- project: projectName,
818
- owner: getLocalOwner(cwd)
1127
+ }
1128
+ function getDisplayValues(input) {
1129
+ return {
1130
+ ...input.route ? { route: input.route } : {},
1131
+ ...input.project ? { project: input.project } : {},
1132
+ ...input.owner ? { owner: input.owner } : {},
1133
+ ...input.values
1134
+ };
1135
+ }
1136
+ function getDisplayDefaults(defaults) {
1137
+ return defaults;
1138
+ }
1139
+ function createDisplayUrl(config, defaults, domain) {
1140
+ const input = getPreviewDefaults(config.preview, getDisplayDefaults(defaults));
1141
+ const protocol = input.protocol ?? "https";
1142
+ const slug = createNamespaceDisplaySlug(config.namespace, getDisplayValues(input));
1143
+ const entryHost = domain ? getGhostTunnelEntryHost(domain, config) : input.domain ? getGhostTunnelEntryHost(input.domain, config) : `${config.subdomain}.*`;
1144
+ const url = `${protocol}://${slug}.${entryHost}/`;
1145
+ if (!input.path) return url;
1146
+ return `${url}${input.path.replace(/^\/+/, "")}`;
1147
+ }
1148
+ function createDisplayUrls(config, defaults) {
1149
+ const displayDefaults = getDisplayDefaults(defaults);
1150
+ const domains = config.domains.length > 0 ? config.domains : displayDefaults?.domain ? [displayDefaults.domain] : [];
1151
+ const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, displayDefaults, domain)) : [createDisplayUrl(config, displayDefaults)];
1152
+ return [...new Set(urls)];
1153
+ }
1154
+ function maybeConstructPreviewUrl(config, defaults) {
1155
+ if (!config.preview) return void 0;
1156
+ const input = getPreviewDefaults(config.preview, defaults);
1157
+ if (!input.domain || !input.route || !input.project || !input.owner) return void 0;
1158
+ return constructGhostTunnelUrl({
1159
+ domain: input.domain,
1160
+ route: input.route,
1161
+ project: input.project,
1162
+ owner: input.owner,
1163
+ values: input.values,
1164
+ ...input.path ? { path: input.path } : {},
1165
+ ...input.protocol ? { protocol: input.protocol } : {},
1166
+ ghostTunnel: config
819
1167
  });
1168
+ }
1169
+ function parseNamespaceSlug(slug, config) {
1170
+ const parts = slug.split(config.separator);
1171
+ if (parts.length < config.tags.length) return null;
1172
+ if (parts.length !== config.tags.length && !config.spreadTag) return null;
1173
+ const namespace = {};
1174
+ const spreadIndex = config.spreadTag ? config.tags.indexOf(config.spreadTag) : -1;
1175
+ const spreadWidth = spreadIndex >= 0 ? parts.length - config.tags.length + 1 : 1;
1176
+ let partIndex = 0;
1177
+ for (const [tagIndex, tag] of config.tags.entries()) {
1178
+ const value = tagIndex === spreadIndex ? parts.slice(partIndex, partIndex + spreadWidth).join(config.separator) : parts[partIndex];
1179
+ if (!value || !isValidHostLabel(value)) return null;
1180
+ if (tagIndex !== spreadIndex && value.includes(config.separator)) return null;
1181
+ namespace[tag] = value;
1182
+ partIndex += tagIndex === spreadIndex ? spreadWidth : 1;
1183
+ }
1184
+ return namespace;
1185
+ }
1186
+ function resolveGhostTunnelConfig(options, defaults) {
1187
+ if (options === false || typeof options === "undefined") {
1188
+ return {
1189
+ enabled: false,
1190
+ mode: DEFAULT_GHOST_TUNNEL_MODE,
1191
+ domains: [],
1192
+ subdomain: DEFAULT_GHOST_TUNNEL_SUBDOMAIN,
1193
+ namespace: resolveNamespaceConfig(void 0),
1194
+ displayUrls: [],
1195
+ requireHttps: true,
1196
+ requireAuth: true,
1197
+ transport: resolveGhostTunnelTransport(void 0)
1198
+ };
1199
+ }
1200
+ const config = typeof options === "string" ? { mode: options } : options;
1201
+ const subdomain = config.subdomain ?? DEFAULT_GHOST_TUNNEL_SUBDOMAIN;
1202
+ assertValidSubdomain(subdomain);
1203
+ const domains = normalizeDomains(config.domains);
1204
+ const enabled = config.enabled ?? true;
1205
+ const adapter = resolveGhostTunnelAdapter(config.adapter);
1206
+ const transport = resolveGhostTunnelTransport(config.transport ?? getLegacyGhostTunnelTransport(config.adapter));
1207
+ const resolved = {
1208
+ enabled,
1209
+ mode: parseGhostTunnelMode(config.mode),
1210
+ domains,
1211
+ subdomain,
1212
+ namespace: resolveNamespaceConfig(config.namespace),
1213
+ ...config.preview ? { preview: config.preview } : {},
1214
+ displayUrls: [],
1215
+ requireHttps: config.requireHttps ?? true,
1216
+ requireAuth: config.requireAuth ?? true,
1217
+ transport,
1218
+ ...adapter ? { adapter } : {}
1219
+ };
1220
+ if (!enabled) {
1221
+ return resolved;
1222
+ }
1223
+ const previewUrl = maybeConstructPreviewUrl(resolved, defaults);
1224
+ const displayUrls = previewUrl ? [previewUrl] : createDisplayUrls(resolved, defaults);
1225
+ return {
1226
+ ...resolved,
1227
+ displayUrls,
1228
+ ...displayUrls[0] ? { displayUrl: displayUrls[0] } : {},
1229
+ ...previewUrl ? { previewUrl } : {}
1230
+ };
1231
+ }
1232
+ function getGhostTunnelEntryHost(domain, options = {}) {
1233
+ const config = toGhostTunnelConfig(options);
1234
+ const normalizedDomain = normalizeDomain(domain);
1235
+ if (!normalizedDomain) {
1236
+ throw new Error(`Invalid ghost tunnel domain: ${domain}`);
1237
+ }
1238
+ return `${config.subdomain}.${normalizedDomain}`;
1239
+ }
1240
+ function getGhostTunnelWildcardHost(domain, options = {}) {
1241
+ return `*.${getGhostTunnelEntryHost(domain, options)}`;
1242
+ }
1243
+ function constructGhostTunnelHost(input) {
1244
+ const config = toGhostTunnelConfig(input.ghostTunnel ?? {});
1245
+ if (!config.enabled) {
1246
+ throw new Error("Ghost tunnel is not enabled.");
1247
+ }
1248
+ const namespaceValues = {
1249
+ route: input.route,
1250
+ project: input.project,
1251
+ owner: input.owner,
1252
+ ...input.values ?? {}
1253
+ };
1254
+ const slug = createNamespaceSlug(config.namespace, namespaceValues);
1255
+ return `${slug}.${getGhostTunnelEntryHost(input.domain, config)}`;
1256
+ }
1257
+ function constructGhostTunnelUrl(input) {
1258
+ const protocol = input.protocol ?? "https";
1259
+ const host = constructGhostTunnelHost(input);
1260
+ const url = new URL(`${protocol}://${host}/`);
1261
+ if (input.path) {
1262
+ url.pathname = `/${input.path.replace(/^\/+/, "")}`;
1263
+ }
1264
+ if (input.searchParams instanceof URLSearchParams) {
1265
+ url.search = input.searchParams.toString();
1266
+ } else if (input.searchParams) {
1267
+ for (const [key, value] of Object.entries(input.searchParams)) {
1268
+ if (typeof value !== "undefined" && value !== null) {
1269
+ url.searchParams.set(key, String(value));
1270
+ }
1271
+ }
1272
+ }
1273
+ return url.toString();
1274
+ }
1275
+ var constructGhostTunnelURL = constructGhostTunnelUrl;
1276
+ function getGhostTunnelDefaultDisplayUrl(options = {}, defaults) {
1277
+ const config = toGhostTunnelConfig(options);
1278
+ if (!config.enabled) return null;
1279
+ return createDisplayUrl(config, defaults);
1280
+ }
1281
+ function getGhostTunnelDisplayUrl(options, defaults) {
1282
+ const config = toGhostTunnelConfig(options);
1283
+ if (!config.enabled) return null;
1284
+ return config.displayUrl ?? config.previewUrl ?? getGhostTunnelDefaultDisplayUrl(config, defaults);
1285
+ }
1286
+ function getGhostTunnelDisplayUrls(options, defaults) {
1287
+ const config = toGhostTunnelConfig(options);
1288
+ if (!config.enabled) return [];
1289
+ if (config.displayUrls.length > 0) return config.displayUrls;
1290
+ const displayUrl = getGhostTunnelDisplayUrl(config, defaults);
1291
+ return displayUrl ? [displayUrl] : [];
1292
+ }
1293
+ function getGhostTunnelPreviewUrl(options) {
1294
+ const config = toGhostTunnelConfig(options);
1295
+ if (!config.enabled) return null;
1296
+ return config.previewUrl ?? maybeConstructPreviewUrl(config) ?? null;
1297
+ }
1298
+ function parseGhostTunnelHost(host, domain, options = {}) {
1299
+ const config = toGhostTunnelConfig(options);
1300
+ if (!config.enabled) return null;
1301
+ const normalizedHost = normalizeDomain(host);
1302
+ const normalizedDomain = normalizeDomain(domain);
1303
+ if (!normalizedHost || !normalizedDomain) return null;
1304
+ const entryHost = getGhostTunnelEntryHost(normalizedDomain, config);
1305
+ const suffix = `.${entryHost}`;
1306
+ if (!normalizedHost.endsWith(suffix)) return null;
1307
+ const slug = normalizedHost.slice(0, -suffix.length);
1308
+ if (!isValidHostLabel(slug)) return null;
1309
+ const namespace = parseNamespaceSlug(slug, config.namespace);
1310
+ if (!namespace) return null;
820
1311
  return {
821
- cwd,
822
- projectName,
823
- readOptions,
824
- configPath: resolvedPath.path,
825
- configFileName: resolvedPath.fileName,
826
- configEntries,
827
- entries,
828
- hosts,
829
- requestedPort,
830
- port,
831
- dynamicPort,
832
- bindHost,
833
- primaryHost,
834
- https: merged.https ?? envHttps() ?? false,
835
- wwwAlias,
836
- ghostTunnel,
837
- ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
1312
+ host: normalizedHost,
1313
+ slug,
1314
+ namespace,
1315
+ entryHost,
1316
+ wildcardHost: `*.${entryHost}`,
1317
+ domain: normalizedDomain
838
1318
  };
839
1319
  }
1320
+ function assertSecureGhostTunnelRequest(input) {
1321
+ const config = toGhostTunnelConfig(input.ghostTunnel);
1322
+ if (!config.enabled) {
1323
+ throw new Error("Ghost tunnel is not enabled.");
1324
+ }
1325
+ if (config.requireHttps && input.protocol !== "https") {
1326
+ throw new Error("Ghost tunnel requests must use HTTPS.");
1327
+ }
1328
+ if (config.requireAuth && input.authenticated !== true) {
1329
+ throw new Error("Ghost tunnel requests must be authenticated.");
1330
+ }
1331
+ const route = parseGhostTunnelHost(input.host, input.domain, config);
1332
+ if (!route) {
1333
+ throw new Error(`Host is not a valid ghost tunnel host for ${input.domain}.`);
1334
+ }
1335
+ return route;
1336
+ }
840
1337
 
841
- // src/doctor.ts
842
- import { execa as execa2 } from "execa";
843
- async function checkCaddy() {
844
- try {
845
- const result = await execa2("caddy", ["version"], { reject: false });
846
- const version = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
847
- return {
848
- found: result.exitCode === 0,
849
- ...version ? { version } : {},
850
- installHint: "brew install caddy"
851
- };
852
- } catch {
853
- return {
854
- found: false,
855
- installHint: "brew install caddy"
856
- };
1338
+ // src/ghost-transport.ts
1339
+ var DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM = "__localghost";
1340
+ var DEFAULT_GHOST_TUNNEL_IP_TRANSPORT_TTL_SECONDS = 10 * 60;
1341
+ function base64UrlEncode2(value) {
1342
+ return Buffer.from(value).toString("base64url");
1343
+ }
1344
+ function base64UrlDecode2(value) {
1345
+ return Buffer.from(value, "base64url").toString("utf8");
1346
+ }
1347
+ function signPayload2(payload, secret) {
1348
+ return createHmac2("sha256", secret).update(payload).digest("base64url");
1349
+ }
1350
+ function secureEqual2(left, right) {
1351
+ const leftBuffer = Buffer.from(left);
1352
+ const rightBuffer = Buffer.from(right);
1353
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual2(leftBuffer, rightBuffer);
1354
+ }
1355
+ function isValidIpv42(value) {
1356
+ return isIP(value) === 4;
1357
+ }
1358
+ function isPrivateIpv42(value) {
1359
+ if (!isValidIpv42(value)) return false;
1360
+ const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
1361
+ return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
1362
+ }
1363
+ function assertGhostTunnelIpAddress(address, allowPrivateNetworkAddress = false) {
1364
+ const normalized = address.trim();
1365
+ if (!isValidIpv42(normalized)) {
1366
+ throw new Error(`Ghost tunnel IP transport requires a valid IPv4 address: ${address}`);
857
1367
  }
1368
+ if (!allowPrivateNetworkAddress && isPrivateIpv42(normalized)) {
1369
+ throw new Error(`Ghost tunnel IP transport requires explicit private-network opt-in: ${normalized}`);
1370
+ }
1371
+ return normalized;
858
1372
  }
859
- async function runDoctor() {
860
- const caddy = await checkCaddy();
1373
+ function assertRelayProtocol(value) {
1374
+ const protocol = value ?? "http";
1375
+ if (protocol !== "http" && protocol !== "https") {
1376
+ throw new Error(`Invalid ghost tunnel IP transport protocol: ${String(value)}`);
1377
+ }
1378
+ return protocol;
1379
+ }
1380
+ function resolveTransportConfig(input) {
1381
+ return resolveGhostTunnelConfig({
1382
+ enabled: true,
1383
+ ...typeof input !== "undefined" ? { transport: input } : {}
1384
+ }).transport;
1385
+ }
1386
+ function resolveExpiresAt(input, now = /* @__PURE__ */ new Date()) {
1387
+ if (input.expiresAt) {
1388
+ if (Number.isNaN(Date.parse(input.expiresAt))) {
1389
+ throw new Error("Ghost tunnel IP transport requires a valid expiresAt value.");
1390
+ }
1391
+ return input.expiresAt;
1392
+ }
1393
+ const ttlSeconds = input.ttlSeconds ?? DEFAULT_GHOST_TUNNEL_IP_TRANSPORT_TTL_SECONDS;
1394
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) {
1395
+ throw new Error(`Ghost tunnel IP transport ttlSeconds must be a positive integer: ${ttlSeconds}`);
1396
+ }
1397
+ return new Date(now.getTime() + ttlSeconds * 1e3).toISOString();
1398
+ }
1399
+ function getBaseGhostTunnelUrl(input) {
1400
+ if ("url" in input) {
1401
+ return new URL(input.url);
1402
+ }
1403
+ return new URL(constructGhostTunnelUrl(input));
1404
+ }
1405
+ function signGhostTunnelIpTransportClaim(claim, signingSecret, options = {}) {
1406
+ const payload = {
1407
+ kind: "ip",
1408
+ host: assertExactRelayHost(claim.host),
1409
+ address: assertGhostTunnelIpAddress(claim.address, options.allowPrivateNetworkAddress),
1410
+ protocol: assertRelayProtocol(claim.protocol),
1411
+ expiresAt: resolveExpiresAt({ expiresAt: claim.expiresAt })
1412
+ };
1413
+ const encodedPayload = base64UrlEncode2(JSON.stringify(payload));
1414
+ const signature = signPayload2(encodedPayload, signingSecret);
861
1415
  return {
862
- ok: caddy.found,
863
- caddy
1416
+ payload,
1417
+ token: `${encodedPayload}.${signature}`
864
1418
  };
865
1419
  }
866
-
867
- // src/env.ts
868
- var PRODUCTION_ENV_KEYS = ["NODE_ENV", "VERCEL_ENV", "NETLIFY", "CF_PAGES_BRANCH", "LOCALGHOST_ENV"];
869
- function getProductionReason(env = process.env) {
870
- if (env.LOCALGHOST_ENV === "production") return "LOCALGHOST_ENV=production";
871
- if (env.NODE_ENV === "production") return "NODE_ENV=production";
872
- if (env.VERCEL_ENV === "production") return "VERCEL_ENV=production";
873
- if (env.NETLIFY === "true" && env.CONTEXT === "production") return "NETLIFY=true and CONTEXT=production";
874
- if (env.CF_PAGES_BRANCH && env.CF_PAGES_BRANCH === env.CF_PAGES_PRODUCTION_BRANCH) {
875
- return "CF_PAGES_BRANCH matches CF_PAGES_PRODUCTION_BRANCH";
1420
+ function verifyGhostTunnelIpTransportClaim(token, signingSecret, options) {
1421
+ const [encodedPayload, signature] = token.split(".");
1422
+ if (!encodedPayload || !signature || token.split(".").length !== 2) {
1423
+ throw new Error("Invalid ghost tunnel IP transport token.");
876
1424
  }
877
- return null;
1425
+ const expectedSignature = signPayload2(encodedPayload, signingSecret);
1426
+ if (!secureEqual2(signature, expectedSignature)) {
1427
+ throw new Error("Invalid ghost tunnel IP transport signature.");
1428
+ }
1429
+ const parsed = JSON.parse(base64UrlDecode2(encodedPayload));
1430
+ const host = assertExactRelayHost(parsed.host);
1431
+ const expectedHost = assertExactRelayHost(options.host);
1432
+ if (host !== expectedHost) {
1433
+ throw new Error("Ghost tunnel IP transport host mismatch.");
1434
+ }
1435
+ const now = options.now ?? /* @__PURE__ */ new Date();
1436
+ if (Number.isNaN(Date.parse(parsed.expiresAt)) || Date.parse(parsed.expiresAt) <= now.getTime()) {
1437
+ throw new Error("Ghost tunnel IP transport token has expired.");
1438
+ }
1439
+ return {
1440
+ kind: "ip",
1441
+ host,
1442
+ address: assertGhostTunnelIpAddress(parsed.address, options.allowPrivateNetworkAddress),
1443
+ protocol: assertRelayProtocol(parsed.protocol),
1444
+ expiresAt: parsed.expiresAt
1445
+ };
878
1446
  }
879
- function isProductionLike(env = process.env) {
880
- return getProductionReason(env) !== null;
1447
+ function constructGhostTunnelIpUrl(input) {
1448
+ const baseUrl = getBaseGhostTunnelUrl(input);
1449
+ const host = assertExactRelayHost(baseUrl.host);
1450
+ const token = signGhostTunnelIpTransportClaim({
1451
+ kind: "ip",
1452
+ host,
1453
+ address: input.address,
1454
+ protocol: input.targetProtocol ?? "http",
1455
+ expiresAt: resolveExpiresAt(input)
1456
+ }, input.signingSecret, {
1457
+ ...typeof input.allowPrivateNetworkAddress === "boolean" ? { allowPrivateNetworkAddress: input.allowPrivateNetworkAddress } : {}
1458
+ }).token;
1459
+ const queryParam = input.queryParam ?? DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM;
1460
+ baseUrl.searchParams.set(queryParam, token);
1461
+ return baseUrl.toString();
1462
+ }
1463
+ function resolveGhostTunnelIpRedirect(input) {
1464
+ const transport = resolveTransportConfig(input.transport);
1465
+ if (transport.kind !== "ip") {
1466
+ throw new Error(`Ghost tunnel transport is not configured for IP redirect: ${transport.kind}`);
1467
+ }
1468
+ const queryParam = input.queryParam ?? DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM;
1469
+ const requestUrl = new URL(input.requestUrl);
1470
+ const token = requestUrl.searchParams.get(queryParam);
1471
+ if (!token) {
1472
+ throw new Error(`Ghost tunnel IP transport token is missing. Add ${queryParam}=... to the URL.`);
1473
+ }
1474
+ const claim = verifyGhostTunnelIpTransportClaim(token, input.signingSecret, {
1475
+ host: input.host,
1476
+ allowPrivateNetworkAddress: transport.allowPrivateNetworkAddress,
1477
+ ...input.now ? { now: input.now } : {}
1478
+ });
1479
+ requestUrl.searchParams.delete(queryParam);
1480
+ const target = assertRelayLocalTarget({
1481
+ protocol: claim.protocol,
1482
+ host: claim.address,
1483
+ port: input.entryPort
1484
+ }, {
1485
+ allowedHosts: [claim.address],
1486
+ allowPrivateNetworkTargets: transport.allowPrivateNetworkAddress
1487
+ });
1488
+ const redirectUrl = new URL(`${target.protocol}://${target.host}:${target.port}/`);
1489
+ redirectUrl.pathname = requestUrl.pathname;
1490
+ redirectUrl.search = requestUrl.searchParams.toString();
1491
+ redirectUrl.hash = requestUrl.hash;
1492
+ return {
1493
+ claim,
1494
+ queryParam,
1495
+ target,
1496
+ url: redirectUrl.toString()
1497
+ };
881
1498
  }
882
- function assertLocalDevelopment(command, env = process.env) {
883
- const reason = getProductionReason(env);
884
- if (!reason) return;
885
- throw new Error(`Localghost only runs in local development. Refusing \`${command}\` because ${reason}.`);
1499
+
1500
+ // src/caddy.ts
1501
+ import { dirname as dirname3, join as join3 } from "path";
1502
+ import { execa } from "execa";
1503
+
1504
+ // src/fs.ts
1505
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1506
+ import { dirname as dirname2 } from "path";
1507
+ function readTextFile(path) {
1508
+ return readFileSync3(path, "utf8");
886
1509
  }
887
- function getProductionEnvKeys() {
888
- return PRODUCTION_ENV_KEYS;
1510
+ function writeTextFile(path, value) {
1511
+ mkdirSync2(dirname2(path), { recursive: true });
1512
+ writeFileSync2(path, value, "utf8");
1513
+ return path;
889
1514
  }
890
1515
 
891
- // src/hosts-file.ts
892
- import { writeFileSync as writeFileSync3 } from "fs";
893
- import { tmpdir } from "os";
894
- import { join as join5 } from "path";
895
- import { execa as execa3 } from "execa";
896
- function escapeRegExp(value) {
897
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1516
+ // src/caddy.ts
1517
+ function shouldShowCaddyLogs() {
1518
+ return ["1", "true", "yes", "on"].includes((process.env.LOCALGHOST_CADDY_VERBOSE ?? "").toLowerCase());
898
1519
  }
899
- function getManagedBlockPattern(projectName) {
900
- const sanitizedProjectName = sanitizeProjectName(projectName);
901
- const start = `# localghost:start ${sanitizedProjectName}`;
902
- const end = `# localghost:end ${sanitizedProjectName}`;
903
- return new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "m");
1520
+ function caddyStdio() {
1521
+ return shouldShowCaddyLogs() ? "inherit" : "pipe";
904
1522
  }
905
- function getSystemHostsPath() {
906
- return process.platform === "win32" ? "C:\\Windows\\System32\\drivers\\etc\\hosts" : "/etc/hosts";
1523
+ function groupByPort(entries) {
1524
+ const groups = /* @__PURE__ */ new Map();
1525
+ for (const entry of entries) {
1526
+ const group = groups.get(entry.port) ?? [];
1527
+ group.push(entry);
1528
+ groups.set(entry.port, group);
1529
+ }
1530
+ return groups;
907
1531
  }
908
- function renderHostsBlock(projectName, entries) {
909
- const sanitizedProjectName = sanitizeProjectName(projectName);
910
- const hosts = [...new Set(entries.map((entry) => entry.host))].sort();
911
- return [
912
- `# localghost:start ${sanitizedProjectName}`,
913
- ...hosts.map((host) => `127.0.0.1 ${host}`),
914
- `# localghost:end ${sanitizedProjectName}`,
915
- ""
916
- ].join("\n");
1532
+ function getCaddyfilePath(cwd = process.cwd()) {
1533
+ return join3(cwd, "ops/local/Caddyfile");
1534
+ }
1535
+ function renderCaddyfile(entries, options = {}) {
1536
+ const groups = groupByPort(entries);
1537
+ const https = options.https === true;
1538
+ const blocks = [...groups.entries()].sort(([leftPort], [rightPort]) => leftPort - rightPort).map(([port, group]) => {
1539
+ const hosts = group.map((entry) => https ? entry.host : `http://${entry.host}`).sort().join(", ");
1540
+ return `${hosts} {
1541
+ reverse_proxy 127.0.0.1:${port}
1542
+ }`;
1543
+ });
1544
+ const globalOptions = https ? `{
1545
+ local_certs
917
1546
  }
918
- function upsertManagedBlock(existing, projectName, block) {
919
- const pattern = getManagedBlockPattern(projectName);
920
- if (pattern.test(existing)) {
921
- return existing.replace(pattern, block);
922
- }
923
- return `${existing.trimEnd()}
924
1547
 
925
- ${block}`;
1548
+ ` : "";
1549
+ return `${globalOptions}${blocks.join("\n\n")}
1550
+ `;
926
1551
  }
927
- function removeManagedBlock(existing, projectName) {
928
- const pattern = getManagedBlockPattern(projectName);
929
- if (!pattern.test(existing)) {
930
- return existing;
931
- }
932
- return existing.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
1552
+ async function writeCaddyfile(entries, cwd = process.cwd(), options = {}) {
1553
+ const path = getCaddyfilePath(cwd);
1554
+ writeTextFile(path, renderCaddyfile(entries, options));
1555
+ return path;
933
1556
  }
934
- async function writeSystemHostsFile(hostsPath, next, projectName) {
935
- const sanitizedProjectName = sanitizeProjectName(projectName);
936
- const tempPath = join5(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
937
- writeFileSync3(tempPath, next, "utf8");
938
- if (process.platform === "win32") {
939
- throw new Error(`Windows support: run as administrator and copy ${tempPath} to ${hostsPath}.`);
940
- }
941
- await execa3("sudo", ["cp", tempPath, hostsPath], { stdio: "inherit" });
942
- return tempPath;
1557
+ async function validateCaddyfile(path) {
1558
+ await execa("caddy", ["validate", "--config", path], {
1559
+ cwd: dirname3(path),
1560
+ stdio: caddyStdio()
1561
+ });
943
1562
  }
944
- async function updateSystemHosts(projectName, entries) {
945
- const sanitizedProjectName = sanitizeProjectName(projectName);
946
- const hostsPath = getSystemHostsPath();
947
- const existing = readTextFile(hostsPath);
948
- const block = renderHostsBlock(sanitizedProjectName, entries);
949
- const next = upsertManagedBlock(existing, sanitizedProjectName, block);
950
- if (next === existing) {
951
- return { changed: false, hostsPath };
952
- }
953
- const tempPath = await writeSystemHostsFile(hostsPath, next, sanitizedProjectName);
954
- return { changed: true, hostsPath, tempPath };
1563
+ async function runCaddy(path) {
1564
+ await execa("caddy", ["run", "--config", path], {
1565
+ cwd: dirname3(path),
1566
+ stdio: caddyStdio()
1567
+ });
1568
+ }
1569
+ function startCaddy(path) {
1570
+ return execa("caddy", ["run", "--config", path], {
1571
+ cwd: dirname3(path),
1572
+ stdio: caddyStdio()
1573
+ });
1574
+ }
1575
+ async function trustCaddy(path) {
1576
+ await execa("caddy", ["trust", "--config", path], {
1577
+ cwd: dirname3(path),
1578
+ stdio: "inherit"
1579
+ });
1580
+ }
1581
+
1582
+ // src/context.ts
1583
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
1584
+ import { join as join4 } from "path";
1585
+ import { pathToFileURL } from "url";
1586
+
1587
+ // src/port.ts
1588
+ import { createServer } from "net";
1589
+ async function isPortAvailable(port, host = "127.0.0.1") {
1590
+ return new Promise((resolve3) => {
1591
+ const server = createServer();
1592
+ server.once("error", () => {
1593
+ resolve3(false);
1594
+ });
1595
+ server.once("listening", () => {
1596
+ server.close(() => resolve3(true));
1597
+ });
1598
+ server.listen(port, host);
1599
+ });
955
1600
  }
956
- async function removeSystemHosts(projectName) {
957
- const sanitizedProjectName = sanitizeProjectName(projectName);
958
- const hostsPath = getSystemHostsPath();
959
- const existing = readTextFile(hostsPath);
960
- const next = removeManagedBlock(existing, sanitizedProjectName);
961
- if (next === existing) {
962
- return { changed: false, removed: false, hostsPath };
1601
+ async function findAvailablePort(startPort, options = {}) {
1602
+ const host = options.host ?? "127.0.0.1";
1603
+ const maxAttempts = options.maxAttempts ?? 50;
1604
+ for (let offset = 0; offset < maxAttempts; offset += 1) {
1605
+ const port = startPort + offset;
1606
+ if (await isPortAvailable(port, host)) {
1607
+ return port;
1608
+ }
963
1609
  }
964
- const tempPath = await writeSystemHostsFile(hostsPath, next, sanitizedProjectName);
965
- return { changed: true, removed: true, hostsPath, tempPath };
1610
+ throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
966
1611
  }
967
1612
 
968
- // src/init.ts
969
- import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
970
- import { join as join6 } from "path";
971
- function detectPackageManager(cwd = process.cwd()) {
972
- if (existsSync4(join6(cwd, "pnpm-lock.yaml"))) return "pnpm";
973
- if (existsSync4(join6(cwd, "yarn.lock"))) return "yarn";
974
- return "npm";
1613
+ // src/context.ts
1614
+ var LOCALGHOST_PROJECT_CONFIG_FILES = [
1615
+ "localghost.config.mjs",
1616
+ "localghost.config.js",
1617
+ "localghost.config.cjs"
1618
+ ];
1619
+ function parsePort(value) {
1620
+ if (!value) return void 0;
1621
+ const port = Number.parseInt(value, 10);
1622
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : void 0;
975
1623
  }
976
- function packageRunCommand(packageManager, script) {
977
- if (packageManager === "yarn") return `yarn ${script}`;
978
- if (packageManager === "pnpm") return `pnpm ${script}`;
979
- return `npm run ${script}`;
1624
+ function envPort() {
1625
+ return parsePort(process.env.LOCALGHOST_PORT) ?? parsePort(process.env.VITE_PORT);
980
1626
  }
981
- function packageAddCommand(packageManager, packageName = "@hamedb89/localghost") {
982
- if (packageManager === "yarn") return `yarn add -D ${packageName}`;
983
- if (packageManager === "pnpm") return `pnpm add -D ${packageName}`;
984
- return `npm install -D ${packageName}`;
1627
+ function envDynamicPort() {
1628
+ const value = process.env.LOCALGHOST_DYNAMIC_PORT;
1629
+ if (!value) return void 0;
1630
+ return ["1", "true", "yes", "on"].includes(value.toLowerCase());
985
1631
  }
986
- function renderConfig(options) {
987
- return [
988
- "# Buh. Friendly names for local services.",
989
- "# Format: <host> <port>",
990
- `${options.host} ${options.port}`,
991
- `www.${options.host} ${options.port}`,
992
- `${options.apiHost} ${options.apiPort}`,
993
- ""
994
- ].join("\n");
1632
+ function envHttps() {
1633
+ const value = process.env.LOCALGHOST_HTTPS;
1634
+ if (!value) return void 0;
1635
+ return ["1", "true", "yes", "on"].includes(value.toLowerCase());
995
1636
  }
996
- function readPackageJson(path) {
1637
+ function getPackageName(cwd) {
997
1638
  try {
998
- return JSON.parse(readFileSync5(path, "utf8"));
1639
+ const pkg = JSON.parse(readFileSync4(join4(cwd, "package.json"), "utf8"));
1640
+ return typeof pkg.name === "string" ? pkg.name : void 0;
999
1641
  } catch {
1000
- return null;
1642
+ return void 0;
1001
1643
  }
1002
1644
  }
1003
- function shellQuote(value) {
1004
- if (/^[A-Za-z0-9_./:-]+$/.test(value)) return value;
1005
- return `'${value.replace(/'/g, `'"'"'`)}'`;
1645
+ function getPackageOwner(cwd) {
1646
+ const packageName = getPackageName(cwd);
1647
+ if (!packageName?.startsWith("@")) return void 0;
1648
+ return packageName.slice(1).split("/")[0];
1006
1649
  }
1007
- function getConfigFlag(configFile) {
1008
- return configFile === LOCALGHOST_CONFIG_FILE ? "" : ` --config ${shellQuote(configFile)}`;
1650
+ function getLocalOwner(cwd) {
1651
+ return sanitizeProjectName(process.env.LOCALGHOST_OWNER ?? getPackageOwner(cwd) ?? process.env.USER ?? process.env.USERNAME ?? "local");
1009
1652
  }
1010
- function updatePackageScripts(packageJsonPath, configFile) {
1011
- const pkg = readPackageJson(packageJsonPath);
1012
- if (!pkg) return false;
1013
- const scripts = typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
1014
- const configFlag = getConfigFlag(configFile);
1015
- const nextScripts = {
1016
- ...scripts,
1017
- "localghost:setup": scripts["localghost:setup"] ?? `localghost setup${configFlag}`,
1018
- "localghost:proxy": scripts["localghost:proxy"] ?? `localghost dev${configFlag}`,
1019
- "localghost:proxy:https": scripts["localghost:proxy:https"] ?? `localghost dev${configFlag} --https`,
1020
- "localghost:run": scripts["localghost:run"] ?? `localghost run${configFlag} --`,
1021
- "localghost:ready": scripts["localghost:ready"] ?? `localghost status${configFlag} --ready`,
1022
- "localghost:trust": scripts["localghost:trust"] ?? `localghost trust${configFlag}`,
1023
- "localghost:ps": scripts["localghost:ps"] ?? "localghost ps",
1024
- "localghost:print": scripts["localghost:print"] ?? `localghost print${configFlag}`,
1025
- "localghost:routes": scripts["localghost:routes"] ?? `localghost routes${configFlag}`,
1026
- "localghost:status": scripts["localghost:status"] ?? "localghost status",
1027
- "localghost:reset": scripts["localghost:reset"] ?? "localghost reset",
1028
- "localghost:teardown": scripts["localghost:teardown"] ?? "localghost teardown",
1029
- "localghost:doctor": scripts["localghost:doctor"] ?? "localghost doctor",
1030
- "localghost:update": scripts["localghost:update"] ?? "localghost update",
1031
- "caddy:setup": scripts["caddy:setup"] ?? `localghost setup${configFlag}`,
1032
- "caddy:dev": scripts["caddy:dev"] ?? `localghost dev${configFlag}`
1033
- };
1034
- const changed = JSON.stringify(scripts) !== JSON.stringify(nextScripts);
1035
- if (!changed) return false;
1036
- pkg.scripts = nextScripts;
1037
- writeFileSync4(packageJsonPath, `${JSON.stringify(pkg, null, 2)}
1038
- `, "utf8");
1039
- return true;
1653
+ function getRouteName(primaryHost, fallback) {
1654
+ return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
1040
1655
  }
1041
- function initLocalghost(options = {}) {
1042
- const cwd = options.cwd ?? process.cwd();
1043
- const projectName = sanitizeProjectName(getProjectName(cwd).split("/").pop() ?? "app");
1044
- const host = options.host ?? `${projectName}.localhost`;
1045
- const port = options.port ?? 5173;
1046
- const apiHost = options.apiHost ?? `api.${host}`;
1047
- const apiPort = options.apiPort ?? 8787;
1048
- const packageManager = options.packageManager ?? detectPackageManager(cwd);
1049
- const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
1050
- const configPath = join6(cwd, configFile);
1051
- const configExists = existsSync4(configPath);
1052
- if (configExists && !options.force) {
1053
- return {
1054
- configPath,
1055
- configCreated: false,
1056
- packageJsonChanged: false,
1057
- packageManager,
1058
- nextSteps: [
1059
- packageRunCommand(packageManager, "localghost:doctor"),
1060
- packageRunCommand(packageManager, "localghost:setup"),
1061
- packageRunCommand(packageManager, "localghost:ready"),
1062
- packageRunCommand(packageManager, "localghost:proxy")
1063
- ]
1064
- };
1065
- }
1066
- writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
1067
- const packageJsonPath = join6(cwd, "package.json");
1068
- const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
1656
+ function readOptionsFromContext(options) {
1069
1657
  return {
1070
- configPath,
1071
- configCreated: true,
1072
- ...existsSync4(packageJsonPath) ? { packageJsonPath } : {},
1073
- packageJsonChanged,
1074
- packageManager,
1075
- nextSteps: [
1076
- packageRunCommand(packageManager, "localghost:doctor"),
1077
- packageRunCommand(packageManager, "localghost:setup"),
1078
- packageRunCommand(packageManager, "localghost:ready"),
1079
- packageRunCommand(packageManager, "localghost:proxy")
1080
- ]
1658
+ cwd: options.cwd ?? process.cwd(),
1659
+ ...options.fileName ? { fileName: options.fileName } : {},
1660
+ ...options.configFiles ? { configFiles: options.configFiles } : {},
1661
+ ...options.configPattern ? { configPattern: options.configPattern } : {}
1081
1662
  };
1082
1663
  }
1083
-
1084
- // src/relay.ts
1085
- import { createHmac, timingSafeEqual } from "crypto";
1086
- import { domainToASCII as domainToASCII2 } from "url";
1087
- var DEFAULT_RELAY_ALLOWED_TARGET_HOSTS = ["localhost", "127.0.0.1", "::1"];
1088
- var DEFAULT_RELAY_BLOCKED_PORTS = [22, 2375, 2376, 5432, 6379, 9200, 9229, 27017];
1089
- var DEFAULT_RELAY_LIMITS = {
1090
- requestBodyBytes: 5 * 1024 * 1024,
1091
- responseBytes: 25 * 1024 * 1024,
1092
- timeoutMs: 3e4,
1093
- maxConcurrentRequests: 20,
1094
- perRouteRequestsPerMinute: 120,
1095
- perIpRequestsPerMinute: 60
1096
- };
1097
- var DEFAULT_RELAY_TARGET_POLICY = {
1098
- allowedHosts: [...DEFAULT_RELAY_ALLOWED_TARGET_HOSTS],
1099
- blockedPorts: [...DEFAULT_RELAY_BLOCKED_PORTS],
1100
- allowPrivateNetworkTargets: false
1101
- };
1102
- var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
1103
- "connection",
1104
- "keep-alive",
1105
- "proxy-authenticate",
1106
- "proxy-authorization",
1107
- "te",
1108
- "trailer",
1109
- "transfer-encoding",
1110
- "upgrade"
1111
- ]);
1112
- var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "set-cookie"]);
1113
- var TOKEN_QUERY_PATTERN = /(token|secret|key|password|session|jwt|auth)/i;
1114
- var HOST_PATTERN2 = /^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*$/i;
1115
- var IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/;
1116
- function base64UrlEncode(value) {
1117
- return Buffer.from(value).toString("base64url");
1664
+ function withRuntimePort(entries, requestedPort, port) {
1665
+ if (requestedPort === port) return entries;
1666
+ const hasRequestedPort = entries.some((entry) => entry.port === requestedPort);
1667
+ if (!hasRequestedPort) return entries;
1668
+ return entries.map((entry) => entry.port === requestedPort ? { ...entry, port, target: `127.0.0.1:${port}` } : entry);
1118
1669
  }
1119
- function base64UrlDecode(value) {
1120
- return Buffer.from(value, "base64url").toString("utf8");
1670
+ function uniqueHosts(entries) {
1671
+ return [...new Set(entries.map((entry) => entry.host))];
1121
1672
  }
1122
- function signPayload(payload, secret) {
1123
- return createHmac("sha256", secret).update(payload).digest("base64url");
1673
+ function isAliasableHost(host) {
1674
+ return host.includes(".") && !host.startsWith("www.") && !host.includes(":");
1124
1675
  }
1125
- function secureEqual(left, right) {
1126
- const leftBuffer = Buffer.from(left);
1127
- const rightBuffer = Buffer.from(right);
1128
- return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
1676
+ function getDefaultWwwAlias(host) {
1677
+ return isAliasableHost(host) ? `www.${host}` : null;
1129
1678
  }
1130
- function normalizeHost(host) {
1131
- const trimmed = host.trim().toLowerCase().replace(/\.$/, "");
1132
- if (!trimmed || trimmed.includes("*") || trimmed.includes("/") || trimmed.includes(":")) return null;
1133
- const ascii = domainToASCII2(trimmed);
1134
- if (!ascii || ascii.includes("..")) return null;
1135
- return HOST_PATTERN2.test(ascii) ? ascii : null;
1679
+ function addDefaultWwwAliases(entries) {
1680
+ const seen = new Set(entries.map((entry) => entry.host));
1681
+ const aliases = [];
1682
+ for (const entry of entries) {
1683
+ const alias = getDefaultWwwAlias(entry.host);
1684
+ if (alias && !seen.has(alias)) {
1685
+ aliases.push({ host: alias, port: entry.port, target: `127.0.0.1:${entry.port}` });
1686
+ seen.add(alias);
1687
+ }
1688
+ }
1689
+ return [...entries, ...aliases];
1136
1690
  }
1137
- function normalizeTargetHost(host) {
1138
- const trimmed = host.trim().toLowerCase();
1139
- if (trimmed === "::1" || trimmed === "[::1]") return "::1";
1140
- if (trimmed.includes("/") || trimmed.includes("*")) return null;
1141
- if (IPV4_PATTERN.test(trimmed)) return isValidIpv4(trimmed) ? trimmed : null;
1142
- return normalizeHost(trimmed);
1691
+ function defined(input) {
1692
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => typeof value !== "undefined"));
1143
1693
  }
1144
- function isValidIpv4(value) {
1145
- return value.split(".").every((part) => {
1146
- const octet = Number(part);
1147
- return Number.isInteger(octet) && octet >= 0 && octet <= 255 && String(octet) === part;
1694
+ async function readLocalghostProjectConfig(options = {}) {
1695
+ const cwd = options.cwd ?? process.cwd();
1696
+ if (options.configFile === false) return { config: {} };
1697
+ const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
1698
+ const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync3(candidate));
1699
+ if (!path) return { config: {} };
1700
+ const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
1701
+ const config = imported.default ?? imported;
1702
+ return { config, path };
1703
+ }
1704
+ function defineLocalghostConfig(config) {
1705
+ return config;
1706
+ }
1707
+ async function resolveLocalghostContext(options = {}) {
1708
+ const cwd = options.cwd ?? process.cwd();
1709
+ const projectConfig = await readLocalghostProjectConfig({
1710
+ cwd,
1711
+ ...typeof options.localghostConfig !== "undefined" ? { configFile: options.localghostConfig } : {}
1712
+ });
1713
+ const merged = {
1714
+ ...projectConfig.config,
1715
+ ...defined(options)
1716
+ };
1717
+ const readOptions = readOptionsFromContext({ ...merged, cwd });
1718
+ const resolvedPath = resolveDevHostsPath(readOptions);
1719
+ const configEntries = readDevHosts(readOptions);
1720
+ const requestedPort = merged.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
1721
+ const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? true;
1722
+ const autoRepair = merged.autoRepair ?? true;
1723
+ const bindHost = merged.bindHost ?? "127.0.0.1";
1724
+ const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
1725
+ const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
1726
+ const wwwAlias = merged.wwwAlias ?? true;
1727
+ const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
1728
+ const hosts = uniqueHosts(entries);
1729
+ const primaryHost = merged.primaryHost ?? entries.find((entry) => entry.port === port)?.host ?? hosts[0] ?? `${sanitizeProjectName(getProjectName(cwd))}.localhost`;
1730
+ const projectName = sanitizeProjectName(merged.project ?? getProjectName(cwd));
1731
+ const ghostTunnel = resolveGhostTunnelConfig(merged.ghostTunnel, {
1732
+ route: getRouteName(primaryHost, projectName),
1733
+ project: projectName,
1734
+ owner: getLocalOwner(cwd)
1148
1735
  });
1736
+ return {
1737
+ cwd,
1738
+ projectName,
1739
+ readOptions,
1740
+ configPath: resolvedPath.path,
1741
+ configFileName: resolvedPath.fileName,
1742
+ configEntries,
1743
+ entries,
1744
+ hosts,
1745
+ requestedPort,
1746
+ port,
1747
+ dynamicPort,
1748
+ autoRepair,
1749
+ bindHost,
1750
+ primaryHost,
1751
+ https: merged.https ?? envHttps() ?? false,
1752
+ wwwAlias,
1753
+ ghostTunnel,
1754
+ ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
1755
+ };
1149
1756
  }
1150
- function isPrivateIpv4(value) {
1151
- if (!isValidIpv4(value)) return false;
1152
- const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
1153
- return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
1154
- }
1155
- function isLocalTargetHost(host) {
1156
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
1157
- }
1158
- function mergeTargetPolicy(policy) {
1757
+
1758
+ // src/ghost-request.ts
1759
+ function getGhostTunnelReadOptions(input) {
1159
1760
  return {
1160
- allowedHosts: policy?.allowedHosts ?? DEFAULT_RELAY_TARGET_POLICY.allowedHosts,
1161
- blockedPorts: policy?.blockedPorts ?? DEFAULT_RELAY_TARGET_POLICY.blockedPorts,
1162
- allowPrivateNetworkTargets: policy?.allowPrivateNetworkTargets ?? DEFAULT_RELAY_TARGET_POLICY.allowPrivateNetworkTargets
1761
+ ...input.cwd ? { cwd: input.cwd } : {},
1762
+ ...input.ghostTunnelFile ? { fileName: input.ghostTunnelFile } : {}
1163
1763
  };
1164
1764
  }
1165
- function mergeLimits(limits) {
1166
- const merged = {
1167
- ...DEFAULT_RELAY_LIMITS,
1168
- ...limits ?? {}
1765
+ async function resolveGhostTunnelRequest(input) {
1766
+ const projectConfig = await readLocalghostProjectConfig({
1767
+ ...input.cwd ? { cwd: input.cwd } : {},
1768
+ ...typeof input.localghostConfig !== "undefined" ? { configFile: input.localghostConfig } : {}
1769
+ });
1770
+ const ghostTunnel = resolveGhostTunnelConfig(projectConfig.config.ghostTunnel, {
1771
+ domain: input.domain
1772
+ });
1773
+ const route = assertSecureGhostTunnelRequest({
1774
+ host: input.host,
1775
+ domain: input.domain,
1776
+ protocol: input.protocol,
1777
+ ghostTunnel,
1778
+ ...typeof input.authenticated === "boolean" ? { authenticated: input.authenticated } : {}
1779
+ });
1780
+ const ghostTunnelPath = resolveGhostTunnelPath(getGhostTunnelReadOptions(input));
1781
+ const entry = findGhostTunnelEntry(route.host, getGhostTunnelReadOptions(input));
1782
+ const target = entry ? assertRelayLocalTarget({ host: "127.0.0.1", port: entry.port }) : void 0;
1783
+ return {
1784
+ route,
1785
+ ghostTunnel,
1786
+ ...entry ? { entry } : {},
1787
+ ...target ? { target } : {},
1788
+ ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {},
1789
+ ...ghostTunnelPath.exists ? { ghostTunnelPath: ghostTunnelPath.path } : {}
1169
1790
  };
1170
- for (const [key, value] of Object.entries(merged)) {
1171
- if (!Number.isInteger(value) || value < 1) {
1172
- throw new Error(`Invalid relay limit ${key}: ${value}`);
1173
- }
1174
- }
1175
- return merged;
1176
1791
  }
1177
- function assertExactRelayHost(host) {
1178
- const normalized = normalizeHost(host);
1179
- if (!normalized) {
1180
- throw new Error(`Relay route claims must use an exact hostname: ${host}`);
1181
- }
1182
- return normalized;
1792
+ function renderGhostTunnelRouteMissingResponse(resolved) {
1793
+ return {
1794
+ status: 404,
1795
+ headers: {
1796
+ "content-type": "text/html; charset=utf-8",
1797
+ "cache-control": "no-store",
1798
+ "x-localghost-relay": "missing",
1799
+ "x-localghost-route": resolved.route.slug
1800
+ },
1801
+ body: [
1802
+ "<!doctype html>",
1803
+ "<html>",
1804
+ '<head><meta charset="utf-8"><title>Ghost Tunnel route not configured</title></head>',
1805
+ "<body>",
1806
+ "<h1>Ghost Tunnel route not configured</h1>",
1807
+ `<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>`,
1808
+ "</body>",
1809
+ "</html>"
1810
+ ].join("")
1811
+ };
1183
1812
  }
1184
- function assertRelayLocalTarget(target, policyInput) {
1185
- if (!target || typeof target !== "object") {
1186
- throw new Error("Relay target must be an explicit local target object.");
1187
- }
1188
- const policy = mergeTargetPolicy(policyInput);
1189
- const host = normalizeTargetHost(target.host);
1190
- if (!host) {
1191
- throw new Error(`Invalid relay target host: ${target.host}`);
1192
- }
1193
- if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
1194
- throw new Error(`Invalid relay target port: ${target.port}`);
1195
- }
1196
- const protocol = target.protocol ?? "http";
1197
- if (protocol !== "http" && protocol !== "https") {
1198
- throw new Error(`Invalid relay target protocol: ${String(protocol)}`);
1199
- }
1200
- if (policy.blockedPorts.includes(target.port)) {
1201
- throw new Error(`Relay target port is blocked: ${target.port}`);
1202
- }
1203
- const allowedHosts = new Set(policy.allowedHosts.map((allowedHost) => normalizeTargetHost(allowedHost)).filter((value) => Boolean(value)));
1204
- if (!allowedHosts.has(host)) {
1205
- throw new Error(`Relay target host is not explicitly allowed: ${host}`);
1206
- }
1207
- if (!policy.allowPrivateNetworkTargets && !isLocalTargetHost(host) && (host === "::1" || isPrivateIpv4(host))) {
1208
- throw new Error(`Private-network relay target requires explicit opt-in: ${host}`);
1209
- }
1813
+ function renderGhostTunnelRelayOfflineResponse(resolved) {
1814
+ const response = renderRelayOfflineResponse();
1210
1815
  return {
1211
- protocol,
1212
- host,
1213
- port: target.port
1816
+ ...response,
1817
+ headers: {
1818
+ ...response.headers,
1819
+ "x-localghost-relay": "offline",
1820
+ "x-localghost-route": resolved.route.slug,
1821
+ "x-localghost-entry": resolved.entry ? "configured" : "missing"
1822
+ },
1823
+ body: [
1824
+ "<!doctype html>",
1825
+ "<html>",
1826
+ '<head><meta charset="utf-8"><title>Ghost Tunnel offline</title></head>',
1827
+ "<body>",
1828
+ "<h1>Ghost Tunnel offline</h1>",
1829
+ `<p>The wildcard host <code>${resolved.route.host}</code> reached the deployed Ghost Tunnel handler.</p>`,
1830
+ 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>",
1831
+ "</body>",
1832
+ "</html>"
1833
+ ].join("")
1214
1834
  };
1215
1835
  }
1216
- function authenticateRelayAgentToken(input) {
1217
- const expected = `Bearer ${input.agentToken}`;
1218
- return typeof input.authorizationHeader === "string" && secureEqual(input.authorizationHeader, expected);
1836
+
1837
+ // src/doctor.ts
1838
+ import { execa as execa2 } from "execa";
1839
+ async function checkCaddy() {
1840
+ try {
1841
+ const result = await execa2("caddy", ["version"], { reject: false });
1842
+ const version = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
1843
+ return {
1844
+ found: result.exitCode === 0,
1845
+ ...version ? { version } : {},
1846
+ installHint: "brew install caddy"
1847
+ };
1848
+ } catch {
1849
+ return {
1850
+ found: false,
1851
+ installHint: "brew install caddy"
1852
+ };
1853
+ }
1219
1854
  }
1220
- function signRelayRouteClaim(claim, signingSecret) {
1221
- const payload = {
1222
- ...claim,
1223
- host: assertExactRelayHost(claim.host)
1224
- };
1225
- if (!payload.scope) throw new Error("Relay route claim requires a scope.");
1226
- if (!payload.agentId) throw new Error("Relay route claim requires an agentId.");
1227
- if (Number.isNaN(Date.parse(payload.expiresAt))) throw new Error("Relay route claim requires a valid expiresAt.");
1228
- const encodedPayload = base64UrlEncode(JSON.stringify(payload));
1229
- const signature = signPayload(encodedPayload, signingSecret);
1855
+ async function runDoctor() {
1856
+ const caddy = await checkCaddy();
1230
1857
  return {
1231
- payload,
1232
- token: `${encodedPayload}.${signature}`
1858
+ ok: caddy.found,
1859
+ caddy
1233
1860
  };
1234
1861
  }
1235
- function verifyRelayRouteClaim(token, signingSecret, options) {
1236
- const [encodedPayload, signature] = token.split(".");
1237
- if (!encodedPayload || !signature || token.split(".").length !== 2) {
1238
- throw new Error("Invalid relay route claim token.");
1862
+
1863
+ // src/command.ts
1864
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
1865
+ import { isAbsolute, join as join5, relative, resolve as resolve2 } from "path";
1866
+ function readPackageJson(cwd) {
1867
+ const path = join5(cwd, "package.json");
1868
+ if (!existsSync4(path)) {
1869
+ throw new Error(`No package.json found in ${cwd}. Pass an explicit command with \`localghost run -- <command>\`.`);
1239
1870
  }
1240
- const expectedSignature = signPayload(encodedPayload, signingSecret);
1241
- if (!secureEqual(signature, expectedSignature)) {
1242
- throw new Error("Invalid relay route claim signature.");
1871
+ try {
1872
+ return JSON.parse(readFileSync5(path, "utf8"));
1873
+ } catch {
1874
+ throw new Error(`Could not parse ${path}.`);
1243
1875
  }
1244
- const parsed = JSON.parse(base64UrlDecode(encodedPayload));
1245
- const host = assertExactRelayHost(parsed.host);
1246
- if (parsed.scope !== options.expectedScope) {
1247
- throw new Error("Relay route claim scope mismatch.");
1876
+ }
1877
+ function detectDevPackageManager(cwd, packageManager) {
1878
+ if (typeof packageManager === "string") {
1879
+ const name = packageManager.split("@")[0];
1880
+ if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") return name;
1248
1881
  }
1249
- const now = options.now ?? /* @__PURE__ */ new Date();
1250
- if (Date.parse(parsed.expiresAt) <= now.getTime()) {
1251
- throw new Error("Relay route claim has expired.");
1882
+ if (existsSync4(join5(cwd, "pnpm-lock.yaml"))) return "pnpm";
1883
+ if (existsSync4(join5(cwd, "yarn.lock"))) return "yarn";
1884
+ if (existsSync4(join5(cwd, "bun.lock")) || existsSync4(join5(cwd, "bun.lockb"))) return "bun";
1885
+ return "npm";
1886
+ }
1887
+ function scriptCommand(packageManager, script) {
1888
+ if (packageManager === "yarn") return ["yarn", script];
1889
+ return [packageManager, "run", script];
1890
+ }
1891
+ function invokesLocalghost(script) {
1892
+ return /(^|[\s;&|])(?:npm\s+exec\s+|pnpm\s+exec\s+|bunx\s+|npx\s+)?localghost(?:\s|$)/.test(script);
1893
+ }
1894
+ function detectDevCommand(options = {}) {
1895
+ const cwd = options.cwd ?? process.cwd();
1896
+ if (options.command) {
1897
+ if (options.command.length === 0 || options.command.some((part) => typeof part !== "string" || part.length === 0)) {
1898
+ throw new Error("localghost.config.mjs command must be a non-empty array of strings.");
1899
+ }
1900
+ if (invokesLocalghost(options.command.join(" "))) {
1901
+ throw new Error("localghost.config.mjs command cannot invoke Localghost recursively.");
1902
+ }
1903
+ return { command: [...options.command], source: "config" };
1252
1904
  }
1253
- if (!parsed.agentId) {
1254
- throw new Error("Relay route claim requires an agentId.");
1905
+ const pkg = readPackageJson(cwd);
1906
+ const scripts = typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
1907
+ const packageManager = detectDevPackageManager(cwd, pkg.packageManager);
1908
+ for (const script of ["dev:raw", "dev"]) {
1909
+ const value = scripts[script];
1910
+ if (typeof value !== "string" || invokesLocalghost(value)) continue;
1911
+ return {
1912
+ command: scriptCommand(packageManager, script),
1913
+ source: "script",
1914
+ packageManager,
1915
+ script
1916
+ };
1255
1917
  }
1256
- return { ...parsed, host };
1918
+ throw new Error([
1919
+ `Could not detect a safe development command in ${join5(cwd, "package.json")}.`,
1920
+ "Add a non-recursive dev or dev:raw script, configure command in localghost.config.mjs,",
1921
+ "or pass an explicit command with `localghost run -- <command>`."
1922
+ ].join(" "));
1923
+ }
1924
+ function formatDetectedDevCommand(detected) {
1925
+ const command = detected.command.map((part) => /^[A-Za-z0-9_./:@=-]+$/.test(part) ? part : JSON.stringify(part)).join(" ");
1926
+ const source = detected.source === "config" ? "localghost.config.mjs" : `package.json#scripts.${detected.script}`;
1927
+ return `${command} (${source})`;
1928
+ }
1929
+ function assertServicePath(root, serviceCwd, name) {
1930
+ const cwd = resolve2(root, serviceCwd);
1931
+ const relativeCwd = relative(root, cwd);
1932
+ if (isAbsolute(relativeCwd) || relativeCwd === ".." || relativeCwd.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
1933
+ throw new Error(`Service ${name} cwd must stay inside the project root.`);
1934
+ }
1935
+ return { cwd, relativeCwd: relativeCwd || "." };
1936
+ }
1937
+ function detectDevServices(options) {
1938
+ const root = options.cwd ?? process.cwd();
1939
+ if (options.services.length === 0) throw new Error("services must contain at least one service.");
1940
+ const names = /* @__PURE__ */ new Set();
1941
+ const hosts = /* @__PURE__ */ new Set();
1942
+ return options.services.map((service, index) => {
1943
+ if (!service || typeof service !== "object") throw new Error(`Service at index ${index} must be an object.`);
1944
+ if (!service.name || names.has(service.name)) throw new Error(`Service name must be unique: ${service.name || `<index ${index}>`}.`);
1945
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(service.name)) throw new Error(`Invalid service name: ${service.name}.`);
1946
+ if (!service.host || hosts.has(service.host)) throw new Error(`Service host must be unique: ${service.host || `<index ${index}>`}.`);
1947
+ if (!Number.isInteger(service.port) || service.port < 1 || service.port > 65535) {
1948
+ throw new Error(`Invalid port for service ${service.name}: ${service.port}.`);
1949
+ }
1950
+ names.add(service.name);
1951
+ hosts.add(service.host);
1952
+ const path = assertServicePath(root, service.cwd, service.name);
1953
+ const detected = detectDevCommand({
1954
+ cwd: path.cwd,
1955
+ ...service.command ? { command: service.command } : {}
1956
+ });
1957
+ return {
1958
+ name: service.name,
1959
+ ...path,
1960
+ host: service.host,
1961
+ requestedPort: service.port,
1962
+ command: detected.command,
1963
+ commandSource: detected.source
1964
+ };
1965
+ });
1257
1966
  }
1258
- function createRelayRouteRegistration(input) {
1259
- if (!authenticateRelayAgentToken({
1260
- agentToken: input.agentToken,
1261
- ...typeof input.authorizationHeader !== "undefined" ? { authorizationHeader: input.authorizationHeader } : {}
1262
- })) {
1263
- throw new Error("Relay route registration requires an authenticated local agent.");
1967
+ function formatDetectedDevServices(services) {
1968
+ return [
1969
+ `Localghost detected ${services.length} services:`,
1970
+ ...services.map((service) => `${service.name}: ${service.command.map((part) => /^[A-Za-z0-9_./:@=-]+$/.test(part) ? part : JSON.stringify(part)).join(" ")} (${service.relativeCwd}, ${service.host} -> ${service.requestedPort})`)
1971
+ ].join("\n");
1972
+ }
1973
+
1974
+ // src/env.ts
1975
+ var PRODUCTION_ENV_KEYS = ["NODE_ENV", "VERCEL_ENV", "NETLIFY", "CF_PAGES_BRANCH", "LOCALGHOST_ENV"];
1976
+ function getProductionReason(env = process.env) {
1977
+ if (env.LOCALGHOST_ENV === "production") return "LOCALGHOST_ENV=production";
1978
+ if (env.NODE_ENV === "production") return "NODE_ENV=production";
1979
+ if (env.VERCEL_ENV === "production") return "VERCEL_ENV=production";
1980
+ if (env.NETLIFY === "true" && env.CONTEXT === "production") return "NETLIFY=true and CONTEXT=production";
1981
+ if (env.CF_PAGES_BRANCH && env.CF_PAGES_BRANCH === env.CF_PAGES_PRODUCTION_BRANCH) {
1982
+ return "CF_PAGES_BRANCH matches CF_PAGES_PRODUCTION_BRANCH";
1264
1983
  }
1265
- const claim = verifyRelayRouteClaim(input.claimToken, input.signingSecret, {
1266
- expectedScope: input.expectedScope,
1267
- ...input.now ? { now: input.now } : {}
1268
- });
1269
- const target = assertRelayLocalTarget(input.target, input.targetPolicy);
1270
- const access = input.publicMode === true ? "public" : input.access ?? "private";
1271
- const passwordProtected = input.passwordProtected ?? false;
1272
- const authRequired = input.authRequired ?? false;
1273
- if (access === "public" && input.publicMode !== true) {
1274
- throw new Error("Relay public mode must be explicitly enabled.");
1984
+ return null;
1985
+ }
1986
+ function isProductionLike(env = process.env) {
1987
+ return getProductionReason(env) !== null;
1988
+ }
1989
+ function assertLocalDevelopment(command, env = process.env) {
1990
+ const reason = getProductionReason(env);
1991
+ if (!reason) return;
1992
+ throw new Error(`Localghost only runs in local development. Refusing \`${command}\` because ${reason}.`);
1993
+ }
1994
+ function getProductionEnvKeys() {
1995
+ return PRODUCTION_ENV_KEYS;
1996
+ }
1997
+
1998
+ // src/hosts-file.ts
1999
+ import { writeFileSync as writeFileSync3 } from "fs";
2000
+ import { tmpdir } from "os";
2001
+ import { join as join6 } from "path";
2002
+ import { execa as execa3 } from "execa";
2003
+ function escapeRegExp(value) {
2004
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2005
+ }
2006
+ function getManagedBlockPattern(projectName) {
2007
+ const sanitizedProjectName = sanitizeProjectName(projectName);
2008
+ const start = `# localghost:start ${sanitizedProjectName}`;
2009
+ const end = `# localghost:end ${sanitizedProjectName}`;
2010
+ return new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "m");
2011
+ }
2012
+ function getSystemHostsPath(env = process.env) {
2013
+ if (env.LOCALGHOST_HOSTS_PATH) return env.LOCALGHOST_HOSTS_PATH;
2014
+ return process.platform === "win32" ? "C:\\Windows\\System32\\drivers\\etc\\hosts" : "/etc/hosts";
2015
+ }
2016
+ function renderHostsBlock(projectName, entries) {
2017
+ const sanitizedProjectName = sanitizeProjectName(projectName);
2018
+ const hosts = [...new Set(entries.map((entry) => entry.host))].sort();
2019
+ return [
2020
+ `# localghost:start ${sanitizedProjectName}`,
2021
+ ...hosts.map((host) => `127.0.0.1 ${host}`),
2022
+ `# localghost:end ${sanitizedProjectName}`,
2023
+ ""
2024
+ ].join("\n");
2025
+ }
2026
+ function upsertManagedBlock(existing, projectName, block) {
2027
+ const pattern = getManagedBlockPattern(projectName);
2028
+ if (pattern.test(existing)) {
2029
+ return existing.replace(pattern, block);
1275
2030
  }
1276
- if (access === "private" && !passwordProtected && !authRequired) {
1277
- throw new Error("Private relay previews require password or auth.");
2031
+ return `${existing.trimEnd()}
2032
+
2033
+ ${block}`;
2034
+ }
2035
+ function removeManagedBlock(existing, projectName) {
2036
+ const pattern = getManagedBlockPattern(projectName);
2037
+ if (!pattern.test(existing)) {
2038
+ return existing;
1278
2039
  }
1279
- return {
1280
- host: claim.host,
1281
- scope: claim.scope,
1282
- agentId: claim.agentId,
1283
- expiresAt: claim.expiresAt,
1284
- target,
1285
- access,
1286
- passwordProtected,
1287
- authRequired,
1288
- limits: mergeLimits(input.limits)
1289
- };
2040
+ return existing.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
1290
2041
  }
1291
- function isRelayRouteActive(route, options) {
1292
- if (!options.agentConnected) return false;
1293
- return Date.parse(route.expiresAt) > (options.now ?? /* @__PURE__ */ new Date()).getTime();
2042
+ async function writeSystemHostsFile(hostsPath, next, projectName) {
2043
+ const sanitizedProjectName = sanitizeProjectName(projectName);
2044
+ const tempPath = join6(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
2045
+ writeFileSync3(tempPath, next, "utf8");
2046
+ if (process.env.LOCALGHOST_HOSTS_PATH) {
2047
+ writeFileSync3(hostsPath, next, "utf8");
2048
+ return tempPath;
2049
+ }
2050
+ if (process.platform === "win32") {
2051
+ throw new Error(`Windows support: run as administrator and copy ${tempPath} to ${hostsPath}.`);
2052
+ }
2053
+ await execa3("sudo", ["cp", tempPath, hostsPath], { stdio: "inherit" });
2054
+ return tempPath;
1294
2055
  }
1295
- function stripRelayForwardHeaders(headers) {
1296
- const stripped = {};
1297
- for (const [name, value] of Object.entries(headers)) {
1298
- if (typeof value === "undefined") continue;
1299
- const lowerName = name.toLowerCase();
1300
- if (HOP_BY_HOP_HEADERS.has(lowerName)) continue;
1301
- if (lowerName.startsWith("x-localghost-")) continue;
1302
- stripped[name] = value;
2056
+ async function updateSystemHosts(projectName, entries) {
2057
+ const sanitizedProjectName = sanitizeProjectName(projectName);
2058
+ const hostsPath = getSystemHostsPath();
2059
+ const existing = readTextFile(hostsPath);
2060
+ const block = renderHostsBlock(sanitizedProjectName, entries);
2061
+ const next = upsertManagedBlock(existing, sanitizedProjectName, block);
2062
+ if (next === existing) {
2063
+ return { changed: false, hostsPath };
1303
2064
  }
1304
- return stripped;
2065
+ const tempPath = await writeSystemHostsFile(hostsPath, next, sanitizedProjectName);
2066
+ return { changed: true, hostsPath, tempPath };
1305
2067
  }
1306
- function redactRelayHeaders(headers) {
1307
- const redacted = {};
1308
- for (const [name, value] of Object.entries(headers)) {
1309
- if (typeof value === "undefined") continue;
1310
- redacted[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? "[redacted]" : value;
2068
+ async function removeSystemHosts(projectName) {
2069
+ const sanitizedProjectName = sanitizeProjectName(projectName);
2070
+ const hostsPath = getSystemHostsPath();
2071
+ const existing = readTextFile(hostsPath);
2072
+ const next = removeManagedBlock(existing, sanitizedProjectName);
2073
+ if (next === existing) {
2074
+ return { changed: false, removed: false, hostsPath };
1311
2075
  }
1312
- return redacted;
2076
+ const tempPath = await writeSystemHostsFile(hostsPath, next, sanitizedProjectName);
2077
+ return { changed: true, removed: true, hostsPath, tempPath };
1313
2078
  }
1314
- function redactRelayLogUrl(input) {
1315
- const url = new URL(input, "http://localghost.invalid");
1316
- for (const key of [...url.searchParams.keys()]) {
1317
- if (TOKEN_QUERY_PATTERN.test(key)) {
1318
- url.searchParams.set(key, "[redacted]");
1319
- }
2079
+
2080
+ // src/init.ts
2081
+ import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
2082
+ import { join as join7 } from "path";
2083
+ function detectPackageManager(cwd = process.cwd()) {
2084
+ if (existsSync5(join7(cwd, "pnpm-lock.yaml"))) return "pnpm";
2085
+ if (existsSync5(join7(cwd, "yarn.lock"))) return "yarn";
2086
+ if (existsSync5(join7(cwd, "bun.lock")) || existsSync5(join7(cwd, "bun.lockb"))) return "bun";
2087
+ return "npm";
2088
+ }
2089
+ function packageRunCommand(packageManager, script) {
2090
+ if (packageManager === "yarn") return `yarn ${script}`;
2091
+ if (packageManager === "pnpm") return `pnpm ${script}`;
2092
+ if (packageManager === "bun") return `bun run ${script}`;
2093
+ return `npm run ${script}`;
2094
+ }
2095
+ function packageAddCommand(packageManager, packageName = "@hamedb89/localghost") {
2096
+ if (packageManager === "yarn") return `yarn add -D ${packageName}`;
2097
+ if (packageManager === "pnpm") return `pnpm add -D ${packageName}`;
2098
+ if (packageManager === "bun") return `bun add -d ${packageName}`;
2099
+ return `npm install -D ${packageName}`;
2100
+ }
2101
+ function renderConfig(options) {
2102
+ return [
2103
+ "# Buh. Friendly names for local services.",
2104
+ "# Format: <host> <port>",
2105
+ `${options.host} ${options.port}`,
2106
+ `www.${options.host} ${options.port}`,
2107
+ `${options.apiHost} ${options.apiPort}`,
2108
+ ""
2109
+ ].join("\n");
2110
+ }
2111
+ function readPackageJson2(path) {
2112
+ try {
2113
+ return JSON.parse(readFileSync6(path, "utf8"));
2114
+ } catch {
2115
+ return null;
1320
2116
  }
1321
- return input.startsWith("http://") || input.startsWith("https://") ? url.toString() : `${url.pathname}${url.search}`;
1322
2117
  }
1323
- function renderRelayOfflineResponse() {
2118
+ function shellQuote(value) {
2119
+ if (/^[A-Za-z0-9_./:-]+$/.test(value)) return value;
2120
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
2121
+ }
2122
+ function getConfigFlag(configFile) {
2123
+ return configFile === LOCALGHOST_CONFIG_FILE ? "" : ` --config ${shellQuote(configFile)}`;
2124
+ }
2125
+ function updatePackageScripts(packageJsonPath, configFile) {
2126
+ const pkg = readPackageJson2(packageJsonPath);
2127
+ if (!pkg) return false;
2128
+ const scripts = typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
2129
+ const configFlag = getConfigFlag(configFile);
2130
+ const nextScripts = {
2131
+ ...scripts,
2132
+ "localghost:setup": scripts["localghost:setup"] ?? `localghost setup${configFlag}`,
2133
+ "localghost:proxy": scripts["localghost:proxy"] ?? `localghost dev${configFlag}`,
2134
+ "localghost:proxy:https": scripts["localghost:proxy:https"] ?? `localghost dev${configFlag} --https`,
2135
+ "localghost:run": scripts["localghost:run"] ?? `localghost run${configFlag} --`,
2136
+ "localghost:ready": scripts["localghost:ready"] ?? `localghost status${configFlag} --ready`,
2137
+ "localghost:repair": scripts["localghost:repair"] ?? `localghost repair${configFlag}`,
2138
+ "localghost:trust": scripts["localghost:trust"] ?? `localghost trust${configFlag}`,
2139
+ "localghost:ps": scripts["localghost:ps"] ?? "localghost ps",
2140
+ "localghost:print": scripts["localghost:print"] ?? `localghost print${configFlag}`,
2141
+ "localghost:routes": scripts["localghost:routes"] ?? `localghost routes${configFlag}`,
2142
+ "localghost:status": scripts["localghost:status"] ?? "localghost status",
2143
+ "localghost:reset": scripts["localghost:reset"] ?? "localghost reset",
2144
+ "localghost:teardown": scripts["localghost:teardown"] ?? "localghost teardown",
2145
+ "localghost:doctor": scripts["localghost:doctor"] ?? "localghost doctor",
2146
+ "localghost:update": scripts["localghost:update"] ?? "localghost update",
2147
+ "caddy:setup": scripts["caddy:setup"] ?? `localghost setup${configFlag}`,
2148
+ "caddy:dev": scripts["caddy:dev"] ?? `localghost dev${configFlag}`
2149
+ };
2150
+ const changed = JSON.stringify(scripts) !== JSON.stringify(nextScripts);
2151
+ if (!changed) return false;
2152
+ pkg.scripts = nextScripts;
2153
+ writeFileSync4(packageJsonPath, `${JSON.stringify(pkg, null, 2)}
2154
+ `, "utf8");
2155
+ return true;
2156
+ }
2157
+ function initLocalghost(options = {}) {
2158
+ const cwd = options.cwd ?? process.cwd();
2159
+ const projectName = sanitizeProjectName(getProjectName(cwd).split("/").pop() ?? "app");
2160
+ const host = options.host ?? `${projectName}.localhost`;
2161
+ const port = options.port ?? 5173;
2162
+ const apiHost = options.apiHost ?? `api.${host}`;
2163
+ const apiPort = options.apiPort ?? 8787;
2164
+ const packageManager = options.packageManager ?? detectPackageManager(cwd);
2165
+ const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
2166
+ const configPath = join7(cwd, configFile);
2167
+ const configExists = existsSync5(configPath);
2168
+ if (configExists && !options.force) {
2169
+ return {
2170
+ configPath,
2171
+ configCreated: false,
2172
+ packageJsonChanged: false,
2173
+ packageManager,
2174
+ nextSteps: [
2175
+ packageRunCommand(packageManager, "localghost:doctor"),
2176
+ packageRunCommand(packageManager, "localghost:setup"),
2177
+ packageRunCommand(packageManager, "localghost:ready"),
2178
+ packageRunCommand(packageManager, "localghost:proxy")
2179
+ ]
2180
+ };
2181
+ }
2182
+ writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
2183
+ const packageJsonPath = join7(cwd, "package.json");
2184
+ const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
1324
2185
  return {
1325
- status: 503,
1326
- headers: {
1327
- "content-type": "text/html; charset=utf-8",
1328
- "cache-control": "no-store"
1329
- },
1330
- body: [
1331
- "<!doctype html>",
1332
- "<html>",
1333
- '<head><meta charset="utf-8"><title>Preview offline</title></head>',
1334
- "<body><h1>Preview offline</h1><p>The local agent is not connected. Try again later.</p></body>",
1335
- "</html>"
1336
- ].join("")
2186
+ configPath,
2187
+ configCreated: true,
2188
+ ...existsSync5(packageJsonPath) ? { packageJsonPath } : {},
2189
+ packageJsonChanged,
2190
+ packageManager,
2191
+ nextSteps: [
2192
+ packageRunCommand(packageManager, "localghost:doctor"),
2193
+ packageRunCommand(packageManager, "localghost:setup"),
2194
+ packageRunCommand(packageManager, "localghost:ready"),
2195
+ packageRunCommand(packageManager, "localghost:proxy")
2196
+ ]
1337
2197
  };
1338
2198
  }
1339
2199
 
@@ -1394,21 +2254,22 @@ function formatGhostTunnel(config, options = {}) {
1394
2254
  if (options.verbose) {
1395
2255
  lines.push(` domains: ${config.domains.length > 0 ? config.domains.join(", ") : "*"}`);
1396
2256
  lines.push(` access: ${config.requireAuth ? "auth required" : "app decides"}`);
1397
- lines.push(` transport: ${config.requireHttps ? "https required" : "http allowed"}`);
2257
+ lines.push(` protocol: ${config.requireHttps ? "https required" : "http allowed"}`);
2258
+ lines.push(` transport: ${config.transport.kind}`);
1398
2259
  }
1399
2260
  return lines.join("\n");
1400
2261
  }
1401
2262
 
1402
2263
  // src/state.ts
1403
- import { existsSync as existsSync5 } from "fs";
1404
- import { join as join7 } from "path";
2264
+ import { existsSync as existsSync6 } from "fs";
2265
+ import { join as join8 } from "path";
1405
2266
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
1406
2267
  function getLocalghostStatePath(cwd = process.cwd()) {
1407
- return join7(cwd, LOCALGHOST_STATE_FILE);
2268
+ return join8(cwd, LOCALGHOST_STATE_FILE);
1408
2269
  }
1409
2270
  function readLocalghostState(cwd = process.cwd()) {
1410
2271
  const path = getLocalghostStatePath(cwd);
1411
- if (!existsSync5(path)) return null;
2272
+ if (!existsSync6(path)) return null;
1412
2273
  return JSON.parse(readTextFile(path));
1413
2274
  }
1414
2275
  function writeLocalghostState(cwd, state) {
@@ -1423,12 +2284,239 @@ function patchLocalghostState(cwd, patch) {
1423
2284
  return writeLocalghostState(cwd, { ...current, ...patch });
1424
2285
  }
1425
2286
 
2287
+ // src/vercel.ts
2288
+ function getHeaderValue(value) {
2289
+ if (Array.isArray(value)) return value[0] ?? "";
2290
+ return value ?? "";
2291
+ }
2292
+ function getTrustedProtocol(request) {
2293
+ const forwarded = getHeaderValue(request.headers["x-forwarded-proto"]).split(",")[0]?.trim().toLowerCase();
2294
+ return forwarded === "http" ? "http" : "https";
2295
+ }
2296
+ function getTrustedRequestUrl(request, host, protocol) {
2297
+ return new URL(request.url ?? "/", `${protocol}://${host}`).toString();
2298
+ }
2299
+ function getTunnelRequestPath(request) {
2300
+ const url = new URL(request.url ?? "/", "http://localghost.invalid");
2301
+ return `${url.pathname}${url.search}`;
2302
+ }
2303
+ function normalizeRequestHeaders(headers) {
2304
+ const stripped = stripRelayForwardHeaders(headers);
2305
+ return Object.fromEntries(Object.entries(stripped).map(([name, value]) => [
2306
+ name,
2307
+ Array.isArray(value) ? value.join(", ") : value
2308
+ ]));
2309
+ }
2310
+ function getTunnelStore(options, namespace) {
2311
+ return options.tunnelStore ?? createRedisGhostTunnelStoreFromEnv({
2312
+ ...options.tunnelEnv ? { env: options.tunnelEnv } : {},
2313
+ namespace
2314
+ });
2315
+ }
2316
+ async function readRequestBody(request, maxBytes) {
2317
+ if (!request[Symbol.asyncIterator]) return void 0;
2318
+ const chunks = [];
2319
+ let size = 0;
2320
+ for await (const chunk of request) {
2321
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2322
+ size += buffer.byteLength;
2323
+ if (size > maxBytes) {
2324
+ throw new Error(`Ghost Tunnel request exceeded ${maxBytes} bytes.`);
2325
+ }
2326
+ chunks.push(buffer);
2327
+ }
2328
+ return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
2329
+ }
2330
+ function writeResponse(response, payload) {
2331
+ response.statusCode = payload.status;
2332
+ for (const [name, value] of Object.entries(payload.headers)) {
2333
+ response.setHeader(name, value);
2334
+ }
2335
+ response.end(payload.body);
2336
+ }
2337
+ function renderGhostTunnelIpRedirectResponse(url) {
2338
+ return {
2339
+ status: 307,
2340
+ headers: {
2341
+ location: url,
2342
+ "cache-control": "no-store",
2343
+ "content-type": "text/html; charset=utf-8",
2344
+ "x-localghost-relay": "ip"
2345
+ },
2346
+ body: [
2347
+ "<!doctype html>",
2348
+ "<html>",
2349
+ '<head><meta charset="utf-8"><title>Redirecting to local preview</title></head>',
2350
+ "<body>",
2351
+ `<p>Redirecting to <a href="${url}">${url}</a>.</p>`,
2352
+ "</body>",
2353
+ "</html>"
2354
+ ].join("")
2355
+ };
2356
+ }
2357
+ function renderGhostTunnelTransportRejectedResponse(message, status = 400) {
2358
+ return {
2359
+ status,
2360
+ headers: {
2361
+ "content-type": "text/html; charset=utf-8",
2362
+ "cache-control": "no-store",
2363
+ "x-localghost-relay": "rejected"
2364
+ },
2365
+ body: [
2366
+ "<!doctype html>",
2367
+ "<html>",
2368
+ '<head><meta charset="utf-8"><title>Ghost Tunnel transport rejected</title></head>',
2369
+ "<body>",
2370
+ "<h1>Ghost Tunnel transport rejected</h1>",
2371
+ `<p>${message}</p>`,
2372
+ "</body>",
2373
+ "</html>"
2374
+ ].join("")
2375
+ };
2376
+ }
2377
+ function renderGhostTunnelTunnelTimeoutResponse() {
2378
+ return {
2379
+ status: 504,
2380
+ headers: {
2381
+ "content-type": "text/html; charset=utf-8",
2382
+ "cache-control": "no-store",
2383
+ "x-localghost-relay": "timeout"
2384
+ },
2385
+ body: [
2386
+ "<!doctype html>",
2387
+ "<html>",
2388
+ '<head><meta charset="utf-8"><title>Ghost Tunnel timed out</title></head>',
2389
+ "<body>",
2390
+ "<h1>Ghost Tunnel timed out</h1>",
2391
+ "<p>The deployed handler did not receive a local response before the request window closed.</p>",
2392
+ "</body>",
2393
+ "</html>"
2394
+ ].join("")
2395
+ };
2396
+ }
2397
+ function renderGhostTunnelQueuedResponse(response) {
2398
+ const body = decodeGhostTunnelBody(response.bodyBase64)?.toString() ?? "";
2399
+ return {
2400
+ status: response.status,
2401
+ headers: {
2402
+ ...response.headers,
2403
+ "x-localghost-relay": response.error ? "target-error" : "tunnel"
2404
+ },
2405
+ body
2406
+ };
2407
+ }
2408
+ async function waitForTunnelResponse(input) {
2409
+ const startedAt = Date.now();
2410
+ while (Date.now() - startedAt < input.waitMs) {
2411
+ const response = await input.store.readResponse(input.requestId);
2412
+ if (response) {
2413
+ await input.store.cleanup(input.requestId);
2414
+ return response;
2415
+ }
2416
+ await new Promise((resolve3) => setTimeout(resolve3, input.pollIntervalMs));
2417
+ }
2418
+ return null;
2419
+ }
2420
+ async function resolveAuthenticatedState(input, request) {
2421
+ if (typeof input === "function") {
2422
+ return await input(request);
2423
+ }
2424
+ return input;
2425
+ }
2426
+ function createVercelGhostTunnelHandler(options) {
2427
+ return async function handler(request, response) {
2428
+ const host = getHeaderValue(request.headers.host);
2429
+ const protocol = getTrustedProtocol(request);
2430
+ try {
2431
+ const authenticated = typeof options.authenticated !== "undefined" ? await resolveAuthenticatedState(options.authenticated, request) : void 0;
2432
+ const resolved = await resolveGhostTunnelRequest({
2433
+ ...options.cwd ? { cwd: options.cwd } : {},
2434
+ ...typeof options.localghostConfig !== "undefined" ? { localghostConfig: options.localghostConfig } : {},
2435
+ ...options.ghostTunnelFile ? { ghostTunnelFile: options.ghostTunnelFile } : {},
2436
+ host,
2437
+ domain: options.domain,
2438
+ protocol,
2439
+ ...typeof authenticated === "boolean" ? { authenticated } : {}
2440
+ });
2441
+ if (!resolved.entry) {
2442
+ writeResponse(response, renderGhostTunnelRouteMissingResponse(resolved));
2443
+ return;
2444
+ }
2445
+ if (resolved.ghostTunnel.transport.kind === "ip") {
2446
+ if (!options.ipSigningSecret) {
2447
+ writeResponse(response, renderGhostTunnelTransportRejectedResponse("Ghost Tunnel IP transport requires ipSigningSecret in the deployed handler.", 500));
2448
+ return;
2449
+ }
2450
+ try {
2451
+ const redirect = resolveGhostTunnelIpRedirect({
2452
+ requestUrl: getTrustedRequestUrl(request, host, protocol),
2453
+ host: resolved.route.host,
2454
+ entryPort: resolved.entry.port,
2455
+ signingSecret: options.ipSigningSecret,
2456
+ transport: resolved.ghostTunnel.transport
2457
+ });
2458
+ writeResponse(response, renderGhostTunnelIpRedirectResponse(redirect.url));
2459
+ } catch (error) {
2460
+ const message = error instanceof Error ? error.message : String(error);
2461
+ const status = /expired/i.test(message) ? 410 : 400;
2462
+ writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, status));
2463
+ }
2464
+ return;
2465
+ }
2466
+ if (resolved.ghostTunnel.transport.kind === "tunnel") {
2467
+ const transport = resolved.ghostTunnel.transport;
2468
+ const store = getTunnelStore(options, transport.store.namespace);
2469
+ const route = await store.getRoute(resolved.route.host);
2470
+ if (!route) {
2471
+ writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
2472
+ return;
2473
+ }
2474
+ try {
2475
+ const requestBody = await readRequestBody(request, transport.maxRequestBodyBytes);
2476
+ const queuedRequest = createGhostTunnelQueuedRequest({
2477
+ host: resolved.route.host,
2478
+ method: request.method ?? "GET",
2479
+ path: getTunnelRequestPath(request),
2480
+ headers: normalizeRequestHeaders(request.headers),
2481
+ ...requestBody ? { body: requestBody } : {},
2482
+ ttlSeconds: transport.requestTtlSeconds
2483
+ });
2484
+ await store.enqueueRequest(queuedRequest, transport.requestTtlSeconds);
2485
+ const tunnelResponse = await waitForTunnelResponse({
2486
+ store,
2487
+ requestId: queuedRequest.id,
2488
+ waitMs: transport.waitMs,
2489
+ pollIntervalMs: transport.pollIntervalMs
2490
+ });
2491
+ writeResponse(response, tunnelResponse ? renderGhostTunnelQueuedResponse(tunnelResponse) : renderGhostTunnelTunnelTimeoutResponse());
2492
+ } catch (error) {
2493
+ const message = error instanceof Error ? error.message : String(error);
2494
+ writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, 400));
2495
+ }
2496
+ return;
2497
+ }
2498
+ writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
2499
+ } catch (error) {
2500
+ const message = error instanceof Error ? error.message : String(error);
2501
+ writeResponse(response, {
2502
+ status: /authenticated/i.test(message) ? 401 : 404,
2503
+ headers: {
2504
+ "content-type": "text/plain; charset=utf-8",
2505
+ "cache-control": "no-store",
2506
+ "x-localghost-relay": "rejected"
2507
+ },
2508
+ body: message
2509
+ });
2510
+ }
2511
+ };
2512
+ }
2513
+
1426
2514
  // src/update-check.ts
1427
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2515
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
1428
2516
  import { homedir as homedir2 } from "os";
1429
- import { dirname as dirname4, join as join8 } from "path";
2517
+ import { dirname as dirname4, join as join9 } from "path";
1430
2518
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
1431
- var LOCALGHOST_VERSION = "0.1.10";
2519
+ var LOCALGHOST_VERSION = "0.1.13";
1432
2520
  var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
1433
2521
  var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
1434
2522
  var UPDATE_CHECK_TIMEOUT_MS = 900;
@@ -1440,13 +2528,13 @@ function isUpdateCheckDisabled(env = process.env) {
1440
2528
  }
1441
2529
  function getUpdateCheckCachePath(env = process.env) {
1442
2530
  if (env.LOCALGHOST_UPDATE_CHECK_CACHE) return env.LOCALGHOST_UPDATE_CHECK_CACHE;
1443
- const cacheRoot = env.XDG_CACHE_HOME || join8(homedir2(), ".cache");
1444
- return join8(cacheRoot, "localghost", "update-check.json");
2531
+ const cacheRoot = env.XDG_CACHE_HOME || join9(homedir2(), ".cache");
2532
+ return join9(cacheRoot, "localghost", "update-check.json");
1445
2533
  }
1446
2534
  function readCache(path = getUpdateCheckCachePath()) {
1447
- if (!existsSync6(path)) return null;
2535
+ if (!existsSync7(path)) return null;
1448
2536
  try {
1449
- return JSON.parse(readFileSync6(path, "utf8"));
2537
+ return JSON.parse(readFileSync7(path, "utf8"));
1450
2538
  } catch {
1451
2539
  return null;
1452
2540
  }
@@ -1590,12 +2678,16 @@ ${message}`);
1590
2678
  markUpdateNotified(result, cachePath);
1591
2679
  }
1592
2680
  export {
2681
+ DEFAULT_GHOST_TUNNEL_IP_TRANSPORT_TTL_SECONDS,
2682
+ DEFAULT_GHOST_TUNNEL_RESPONSE_TTL_SECONDS,
2683
+ DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM,
1593
2684
  DEFAULT_RELAY_ALLOWED_TARGET_HOSTS,
1594
2685
  DEFAULT_RELAY_BLOCKED_PORTS,
1595
2686
  DEFAULT_RELAY_LIMITS,
1596
2687
  DEFAULT_RELAY_TARGET_POLICY,
1597
2688
  LOCALGHOST_ACTIVITY_VERSION,
1598
2689
  LOCALGHOST_CONFIG_FILE,
2690
+ LOCALGHOST_GHOST_TUNNEL_FILE,
1599
2691
  LOCALGHOST_PACKAGE_NAME,
1600
2692
  LOCALGHOST_STATE_FILE,
1601
2693
  LOCALGHOST_VERSION,
@@ -1611,13 +2703,28 @@ export {
1611
2703
  checkForUpdate,
1612
2704
  compareVersions,
1613
2705
  constructGhostTunnelHost,
2706
+ constructGhostTunnelIpUrl,
1614
2707
  constructGhostTunnelURL,
1615
2708
  constructGhostTunnelUrl,
2709
+ createGhostTunnelQueuedRequest,
2710
+ createGhostTunnelRouteHeartbeat,
2711
+ createMemoryGhostTunnelStore,
2712
+ createRedisGhostTunnelStore,
2713
+ createRedisGhostTunnelStoreFromEnv,
1616
2714
  createRelayRouteRegistration,
2715
+ createVercelGhostTunnelHandler,
2716
+ decodeGhostTunnelBody,
1617
2717
  defineLocalghostConfig,
2718
+ detectDevCommand,
2719
+ detectDevPackageManager,
2720
+ detectDevServices,
1618
2721
  detectPackageManager,
2722
+ encodeGhostTunnelBody,
1619
2723
  findAvailablePort,
2724
+ findGhostTunnelEntry,
1620
2725
  findLocalMdnsHosts,
2726
+ formatDetectedDevCommand,
2727
+ formatDetectedDevServices,
1621
2728
  formatDomainRoutes,
1622
2729
  formatGhostTunnel,
1623
2730
  formatUpdateMessage,
@@ -1629,6 +2736,7 @@ export {
1629
2736
  getGhostTunnelDisplayUrl,
1630
2737
  getGhostTunnelDisplayUrls,
1631
2738
  getGhostTunnelEntryHost,
2739
+ getGhostTunnelPath,
1632
2740
  getGhostTunnelPreviewUrl,
1633
2741
  getGhostTunnelWildcardHost,
1634
2742
  getLocalghostActivityPath,
@@ -1645,6 +2753,7 @@ export {
1645
2753
  isProductionLike,
1646
2754
  isRelayRouteActive,
1647
2755
  isUpdateCheckDisabled,
2756
+ listGhostTunnelEntries,
1648
2757
  listLocalghostRuns,
1649
2758
  listLocalghostSetups,
1650
2759
  markUpdateNotified,
@@ -1656,6 +2765,7 @@ export {
1656
2765
  patchLocalghostState,
1657
2766
  pruneLocalghostActivity,
1658
2767
  readDevHosts,
2768
+ readGhostTunnelEntries,
1659
2769
  readLocalghostActivity,
1660
2770
  readLocalghostProjectConfig,
1661
2771
  readLocalghostState,
@@ -1666,17 +2776,28 @@ export {
1666
2776
  removeManagedBlock,
1667
2777
  removeSystemHosts,
1668
2778
  renderCaddyfile,
2779
+ renderCompactLocalghostBanner,
2780
+ renderGhostTunnelRelayOfflineResponse,
2781
+ renderGhostTunnelRouteMissingResponse,
1669
2782
  renderHostsBlock,
2783
+ renderLocalghostBanner,
1670
2784
  renderRelayOfflineResponse,
1671
2785
  resolveDevHostsPath,
1672
2786
  resolveGhostTunnelConfig,
2787
+ resolveGhostTunnelIpRedirect,
2788
+ resolveGhostTunnelPath,
2789
+ resolveGhostTunnelRequest,
1673
2790
  resolveLocalghostContext,
2791
+ resolveRedisGhostTunnelEnv,
1674
2792
  runCaddy,
1675
2793
  runDoctor,
1676
2794
  sanitizeProjectName,
2795
+ serveGhostTunnelLocalRequest,
1677
2796
  shouldNotifyAboutUpdate,
2797
+ signGhostTunnelIpTransportClaim,
1678
2798
  signRelayRouteClaim,
1679
2799
  startCaddy,
2800
+ startGhostTunnelAgent,
1680
2801
  stripRelayForwardHeaders,
1681
2802
  trustCaddy,
1682
2803
  unregisterLocalghostRun,
@@ -1684,6 +2805,7 @@ export {
1684
2805
  updateSystemHosts,
1685
2806
  upsertManagedBlock,
1686
2807
  validateCaddyfile,
2808
+ verifyGhostTunnelIpTransportClaim,
1687
2809
  verifyRelayRouteClaim,
1688
2810
  writeCaddyfile,
1689
2811
  writeLocalghostActivity,