@hamedb89/localghost 0.1.9 → 0.1.12

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,1085 +269,1928 @@ 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 createDisplayUrl(config, defaults, domain) {
502
- const input = getPreviewDefaults(config.preview, defaults);
503
- const protocol = input.protocol ?? "https";
504
- const slug = createNamespaceDisplaySlug(config.namespace, getDisplayValues(input));
505
- const entryHost = domain ? getGhostTunnelEntryHost(domain, config) : input.domain ? getGhostTunnelEntryHost(input.domain, config) : `${config.subdomain}.*`;
506
- const url = `${protocol}://${slug}.${entryHost}/`;
507
- if (!input.path) return url;
508
- 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
+ };
509
597
  }
510
- function createDisplayUrls(config, defaults) {
511
- const domains = config.domains.length > 0 ? config.domains : defaults?.domain ? [defaults.domain] : [];
512
- const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, defaults, domain)) : [createDisplayUrl(config, defaults)];
513
- 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();
514
601
  }
515
- function maybeConstructPreviewUrl(config, defaults) {
516
- if (!config.preview) return void 0;
517
- const input = getPreviewDefaults(config.preview, defaults);
518
- if (!input.domain || !input.route || !input.project || !input.owner) return void 0;
519
- return constructGhostTunnelUrl({
520
- domain: input.domain,
521
- route: input.route,
522
- project: input.project,
523
- owner: input.owner,
524
- values: input.values,
525
- ...input.path ? { path: input.path } : {},
526
- ...input.protocol ? { protocol: input.protocol } : {},
527
- ghostTunnel: config
528
- });
602
+ function serializeJson(value) {
603
+ return JSON.stringify(value);
529
604
  }
530
- function parseNamespaceSlug(slug, config) {
531
- const parts = slug.split(config.separator);
532
- if (parts.length < config.tags.length) return null;
533
- if (parts.length !== config.tags.length && !config.spreadTag) return null;
534
- const namespace = {};
535
- const spreadIndex = config.spreadTag ? config.tags.indexOf(config.spreadTag) : -1;
536
- const spreadWidth = spreadIndex >= 0 ? parts.length - config.tags.length + 1 : 1;
537
- let partIndex = 0;
538
- for (const [tagIndex, tag] of config.tags.entries()) {
539
- const value = tagIndex === spreadIndex ? parts.slice(partIndex, partIndex + spreadWidth).join(config.separator) : parts[partIndex];
540
- if (!value || !isValidHostLabel(value)) return null;
541
- if (tagIndex !== spreadIndex && value.includes(config.separator)) return null;
542
- namespace[tag] = value;
543
- 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;
544
611
  }
545
- return namespace;
546
612
  }
547
- function resolveGhostTunnelConfig(options, defaults) {
548
- if (options === false || typeof options === "undefined") {
549
- return {
550
- enabled: false,
551
- mode: DEFAULT_GHOST_TUNNEL_MODE,
552
- domains: [],
553
- subdomain: DEFAULT_GHOST_TUNNEL_SUBDOMAIN,
554
- namespace: resolveNamespaceConfig(void 0),
555
- displayUrls: [],
556
- requireHttps: true,
557
- requireAuth: true
558
- };
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);
559
622
  }
560
- const config = typeof options === "string" ? { mode: options } : options;
561
- const subdomain = config.subdomain ?? DEFAULT_GHOST_TUNNEL_SUBDOMAIN;
562
- assertValidSubdomain(subdomain);
563
- const domains = normalizeDomains(config.domains);
564
- const enabled = config.enabled ?? true;
565
- const resolved = {
566
- enabled,
567
- mode: parseGhostTunnelMode(config.mode),
568
- domains,
569
- subdomain,
570
- namespace: resolveNamespaceConfig(config.namespace),
571
- ...config.preview ? { preview: config.preview } : {},
572
- displayUrls: [],
573
- requireHttps: config.requireHttps ?? true,
574
- requireAuth: config.requireAuth ?? true
575
- };
576
- if (!enabled) {
577
- 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;
578
629
  }
579
- const previewUrl = maybeConstructPreviewUrl(resolved, defaults);
580
- const displayUrls = previewUrl ? [previewUrl] : createDisplayUrls(resolved, defaults);
581
- return {
582
- ...resolved,
583
- displayUrls,
584
- ...displayUrls[0] ? { displayUrl: displayUrls[0] } : {},
585
- ...previewUrl ? { previewUrl } : {}
586
- };
587
- }
588
- function getGhostTunnelEntryHost(domain, options = {}) {
589
- const config = toGhostTunnelConfig(options);
590
- const normalizedDomain = normalizeDomain(domain);
591
- if (!normalizedDomain) {
592
- 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;
593
644
  }
594
- return `${config.subdomain}.${normalizedDomain}`;
595
- }
596
- function getGhostTunnelWildcardHost(domain, options = {}) {
597
- return `*.${getGhostTunnelEntryHost(domain, options)}`;
598
- }
599
- function constructGhostTunnelHost(input) {
600
- const config = toGhostTunnelConfig(input.ghostTunnel ?? {});
601
- if (!config.enabled) {
602
- 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
+ });
603
650
  }
604
- const namespaceValues = {
605
- route: input.route,
606
- project: input.project,
607
- owner: input.owner,
608
- ...input.values ?? {}
609
- };
610
- const slug = createNamespaceSlug(config.namespace, namespaceValues);
611
- return `${slug}.${getGhostTunnelEntryHost(input.domain, config)}`;
612
- }
613
- function constructGhostTunnelUrl(input) {
614
- const protocol = input.protocol ?? "https";
615
- const host = constructGhostTunnelHost(input);
616
- const url = new URL(`${protocol}://${host}/`);
617
- if (input.path) {
618
- 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;
619
657
  }
620
- if (input.searchParams instanceof URLSearchParams) {
621
- url.search = input.searchParams.toString();
622
- } else if (input.searchParams) {
623
- for (const [key, value] of Object.entries(input.searchParams)) {
624
- if (typeof value !== "undefined" && value !== null) {
625
- url.searchParams.set(key, String(value));
626
- }
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;
627
715
  }
628
716
  }
629
- 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
+ });
630
751
  }
631
- var constructGhostTunnelURL = constructGhostTunnelUrl;
632
- function getGhostTunnelDefaultDisplayUrl(options = {}, defaults) {
633
- const config = toGhostTunnelConfig(options);
634
- if (!config.enabled) return null;
635
- 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
+ });
636
768
  }
637
- function getGhostTunnelDisplayUrl(options, defaults) {
638
- const config = toGhostTunnelConfig(options);
639
- if (!config.enabled) return null;
640
- 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;
641
775
  }
642
- function getGhostTunnelDisplayUrls(options, defaults) {
643
- const config = toGhostTunnelConfig(options);
644
- if (!config.enabled) return [];
645
- if (config.displayUrls.length > 0) return config.displayUrls;
646
- const displayUrl = getGhostTunnelDisplayUrl(config, defaults);
647
- return displayUrl ? [displayUrl] : [];
776
+ function hasRequestBody(method) {
777
+ return method !== "GET" && method !== "HEAD";
648
778
  }
649
- function getGhostTunnelPreviewUrl(options) {
650
- const config = toGhostTunnelConfig(options);
651
- if (!config.enabled) return null;
652
- 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
+ }
653
820
  }
654
- function parseGhostTunnelHost(host, domain, options = {}) {
655
- const config = toGhostTunnelConfig(options);
656
- if (!config.enabled) return null;
657
- const normalizedHost = normalizeDomain(host);
658
- const normalizedDomain = normalizeDomain(domain);
659
- if (!normalizedHost || !normalizedDomain) return null;
660
- const entryHost = getGhostTunnelEntryHost(normalizedDomain, config);
661
- const suffix = `.${entryHost}`;
662
- if (!normalizedHost.endsWith(suffix)) return null;
663
- const slug = normalizedHost.slice(0, -suffix.length);
664
- if (!isValidHostLabel(slug)) return null;
665
- const namespace = parseNamespaceSlug(slug, config.namespace);
666
- 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
+ })();
667
892
  return {
668
- host: normalizedHost,
669
- slug,
670
- namespace,
671
- entryHost,
672
- wildcardHost: `*.${entryHost}`,
673
- domain: normalizedDomain
893
+ agentId,
894
+ stop() {
895
+ controller.abort();
896
+ },
897
+ done
674
898
  };
675
899
  }
676
- function assertSecureGhostTunnelRequest(input) {
677
- const config = toGhostTunnelConfig(input.ghostTunnel);
678
- if (!config.enabled) {
679
- throw new Error("Ghost tunnel is not enabled.");
680
- }
681
- if (config.requireHttps && input.protocol !== "https") {
682
- throw new Error("Ghost tunnel requests must use HTTPS.");
683
- }
684
- if (config.requireAuth && input.authenticated !== true) {
685
- throw new Error("Ghost tunnel requests must be authenticated.");
686
- }
687
- const route = parseGhostTunnelHost(input.host, input.domain, config);
688
- if (!route) {
689
- throw new Error(`Host is not a valid ghost tunnel host for ${input.domain}.`);
690
- }
691
- return route;
692
- }
693
900
 
694
- // src/context.ts
695
- var LOCALGHOST_PROJECT_CONFIG_FILES = [
696
- "localghost.config.mjs",
697
- "localghost.config.js",
698
- "localghost.config.cjs"
699
- ];
700
- function parsePort(value) {
701
- if (!value) return void 0;
702
- const port = Number.parseInt(value, 10);
703
- 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;
704
924
  }
705
- function envPort() {
706
- return parsePort(process.env.LOCALGHOST_PORT) ?? parsePort(process.env.VITE_PORT);
925
+ function toGhostTunnelConfig(options) {
926
+ return isResolvedGhostTunnelConfig(options) ? options : resolveGhostTunnelConfig(options);
707
927
  }
708
- function envDynamicPort() {
709
- const value = process.env.LOCALGHOST_DYNAMIC_PORT;
710
- if (!value) return void 0;
711
- 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;
712
935
  }
713
- function envHttps() {
714
- const value = process.env.LOCALGHOST_HTTPS;
715
- if (!value) return void 0;
716
- 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;
717
944
  }
718
- function getPackageName(cwd) {
719
- try {
720
- const pkg = JSON.parse(readFileSync4(join4(cwd, "package.json"), "utf8"));
721
- return typeof pkg.name === "string" ? pkg.name : void 0;
722
- } catch {
723
- 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}`);
724
957
  }
725
958
  }
726
- function getPackageOwner(cwd) {
727
- const packageName = getPackageName(cwd);
728
- if (!packageName?.startsWith("@")) return void 0;
729
- 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)];
730
967
  }
731
- function getLocalOwner(cwd) {
732
- 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;
733
970
  }
734
- function getRouteName(primaryHost, fallback) {
735
- 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)}`);
736
975
  }
737
- function readOptionsFromContext(options) {
738
- return {
739
- cwd: options.cwd ?? process.cwd(),
740
- ...options.fileName ? { fileName: options.fileName } : {},
741
- ...options.configFiles ? { configFiles: options.configFiles } : {},
742
- ...options.configPattern ? { configPattern: options.configPattern } : {}
743
- };
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)}`);
744
980
  }
745
- function withRuntimePort(entries, requestedPort, port) {
746
- if (requestedPort === port) return entries;
747
- const hasRequestedPort = entries.some((entry) => entry.port === requestedPort);
748
- if (!hasRequestedPort) return entries;
749
- 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;
750
987
  }
751
- function uniqueHosts(entries) {
752
- 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)}`);
753
992
  }
754
- function isAliasableHost(host) {
755
- 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)}`);
756
997
  }
757
- function getDefaultWwwAlias(host) {
758
- 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;
759
1004
  }
760
- function addDefaultWwwAliases(entries) {
761
- const seen = new Set(entries.map((entry) => entry.host));
762
- const aliases = [];
763
- for (const entry of entries) {
764
- const alias = getDefaultWwwAlias(entry.host);
765
- if (alias && !seen.has(alias)) {
766
- aliases.push({ host: alias, port: entry.port, target: `127.0.0.1:${entry.port}` });
767
- seen.add(alias);
768
- }
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)}`);
769
1010
  }
770
- return [...entries, ...aliases];
1011
+ return {
1012
+ provider,
1013
+ strategy: typeof input === "string" ? DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY : parseGhostTunnelAdapterStrategy(input.strategy)
1014
+ };
771
1015
  }
772
- function defined(input) {
773
- 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;
774
1019
  }
775
- async function readLocalghostProjectConfig(options = {}) {
776
- const cwd = options.cwd ?? process.cwd();
777
- if (options.configFile === false) return { config: {} };
778
- const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
779
- const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync3(candidate));
780
- if (!path) return { config: {} };
781
- const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
782
- const config = imported.default ?? imported;
783
- 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" };
784
1050
  }
785
- function defineLocalghostConfig(config) {
786
- 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
+ };
787
1078
  }
788
- async function resolveLocalghostContext(options = {}) {
789
- const cwd = options.cwd ?? process.cwd();
790
- const projectConfig = await readLocalghostProjectConfig({
791
- cwd,
792
- ...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 });
793
1096
  });
794
- const merged = {
795
- ...projectConfig.config,
796
- ...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
797
1126
  };
798
- const readOptions = readOptionsFromContext({ ...merged, cwd });
799
- const resolvedPath = resolveDevHostsPath(readOptions);
800
- const configEntries = readDevHosts(readOptions);
801
- const requestedPort = merged.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
802
- const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? true;
803
- const bindHost = merged.bindHost ?? "127.0.0.1";
804
- const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
805
- const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
806
- const wwwAlias = merged.wwwAlias ?? true;
807
- const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
808
- const hosts = uniqueHosts(entries);
809
- const primaryHost = merged.primaryHost ?? entries.find((entry) => entry.port === port)?.host ?? hosts[0] ?? `${sanitizeProjectName(getProjectName(cwd))}.localhost`;
810
- const projectName = sanitizeProjectName(merged.project ?? getProjectName(cwd));
811
- const ghostTunnel = resolveGhostTunnelConfig(merged.ghostTunnel, {
812
- route: getRouteName(primaryHost, projectName),
813
- project: projectName,
814
- 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
815
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;
816
1311
  return {
817
- cwd,
818
- projectName,
819
- readOptions,
820
- configPath: resolvedPath.path,
821
- configFileName: resolvedPath.fileName,
822
- configEntries,
823
- entries,
824
- hosts,
825
- requestedPort,
826
- port,
827
- dynamicPort,
828
- bindHost,
829
- primaryHost,
830
- https: merged.https ?? envHttps() ?? false,
831
- wwwAlias,
832
- ghostTunnel,
833
- ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
1312
+ host: normalizedHost,
1313
+ slug,
1314
+ namespace,
1315
+ entryHost,
1316
+ wildcardHost: `*.${entryHost}`,
1317
+ domain: normalizedDomain
834
1318
  };
835
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
+ }
836
1337
 
837
- // src/doctor.ts
838
- import { execa as execa2 } from "execa";
839
- async function checkCaddy() {
840
- try {
841
- const result = await execa2("caddy", ["version"], { reject: false });
842
- const version = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
843
- return {
844
- found: result.exitCode === 0,
845
- ...version ? { version } : {},
846
- installHint: "brew install caddy"
847
- };
848
- } catch {
849
- return {
850
- found: false,
851
- installHint: "brew install caddy"
852
- };
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}`);
853
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;
854
1372
  }
855
- async function runDoctor() {
856
- 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);
857
1415
  return {
858
- ok: caddy.found,
859
- caddy
1416
+ payload,
1417
+ token: `${encodedPayload}.${signature}`
860
1418
  };
861
1419
  }
862
-
863
- // src/env.ts
864
- var PRODUCTION_ENV_KEYS = ["NODE_ENV", "VERCEL_ENV", "NETLIFY", "CF_PAGES_BRANCH", "LOCALGHOST_ENV"];
865
- function getProductionReason(env = process.env) {
866
- if (env.LOCALGHOST_ENV === "production") return "LOCALGHOST_ENV=production";
867
- if (env.NODE_ENV === "production") return "NODE_ENV=production";
868
- if (env.VERCEL_ENV === "production") return "VERCEL_ENV=production";
869
- if (env.NETLIFY === "true" && env.CONTEXT === "production") return "NETLIFY=true and CONTEXT=production";
870
- if (env.CF_PAGES_BRANCH && env.CF_PAGES_BRANCH === env.CF_PAGES_PRODUCTION_BRANCH) {
871
- 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.");
872
1424
  }
873
- 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
+ };
874
1446
  }
875
- function isProductionLike(env = process.env) {
876
- 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
+ };
877
1498
  }
878
- function assertLocalDevelopment(command, env = process.env) {
879
- const reason = getProductionReason(env);
880
- if (!reason) return;
881
- 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");
882
1509
  }
883
- function getProductionEnvKeys() {
884
- return PRODUCTION_ENV_KEYS;
1510
+ function writeTextFile(path, value) {
1511
+ mkdirSync2(dirname2(path), { recursive: true });
1512
+ writeFileSync2(path, value, "utf8");
1513
+ return path;
885
1514
  }
886
1515
 
887
- // src/hosts-file.ts
888
- import { writeFileSync as writeFileSync3 } from "fs";
889
- import { tmpdir } from "os";
890
- import { join as join5 } from "path";
891
- import { execa as execa3 } from "execa";
892
- function escapeRegExp(value) {
893
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1516
+ // src/caddy.ts
1517
+ function shouldShowCaddyLogs() {
1518
+ return ["1", "true", "yes", "on"].includes((process.env.LOCALGHOST_CADDY_VERBOSE ?? "").toLowerCase());
894
1519
  }
895
- function getManagedBlockPattern(projectName) {
896
- const sanitizedProjectName = sanitizeProjectName(projectName);
897
- const start = `# localghost:start ${sanitizedProjectName}`;
898
- const end = `# localghost:end ${sanitizedProjectName}`;
899
- return new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "m");
1520
+ function caddyStdio() {
1521
+ return shouldShowCaddyLogs() ? "inherit" : "pipe";
900
1522
  }
901
- function getSystemHostsPath() {
902
- 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;
903
1531
  }
904
- function renderHostsBlock(projectName, entries) {
905
- const sanitizedProjectName = sanitizeProjectName(projectName);
906
- const hosts = [...new Set(entries.map((entry) => entry.host))].sort();
907
- return [
908
- `# localghost:start ${sanitizedProjectName}`,
909
- ...hosts.map((host) => `127.0.0.1 ${host}`),
910
- `# localghost:end ${sanitizedProjectName}`,
911
- ""
912
- ].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
913
1546
  }
914
- function upsertManagedBlock(existing, projectName, block) {
915
- const pattern = getManagedBlockPattern(projectName);
916
- if (pattern.test(existing)) {
917
- return existing.replace(pattern, block);
918
- }
919
- return `${existing.trimEnd()}
920
1547
 
921
- ${block}`;
1548
+ ` : "";
1549
+ return `${globalOptions}${blocks.join("\n\n")}
1550
+ `;
922
1551
  }
923
- function removeManagedBlock(existing, projectName) {
924
- const pattern = getManagedBlockPattern(projectName);
925
- if (!pattern.test(existing)) {
926
- return existing;
927
- }
928
- 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;
929
1556
  }
930
- async function writeSystemHostsFile(hostsPath, next, projectName) {
931
- const sanitizedProjectName = sanitizeProjectName(projectName);
932
- const tempPath = join5(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
933
- writeFileSync3(tempPath, next, "utf8");
934
- if (process.platform === "win32") {
935
- throw new Error(`Windows support: run as administrator and copy ${tempPath} to ${hostsPath}.`);
936
- }
937
- await execa3("sudo", ["cp", tempPath, hostsPath], { stdio: "inherit" });
938
- return tempPath;
1557
+ async function validateCaddyfile(path) {
1558
+ await execa("caddy", ["validate", "--config", path], {
1559
+ cwd: dirname3(path),
1560
+ stdio: caddyStdio()
1561
+ });
939
1562
  }
940
- async function updateSystemHosts(projectName, entries) {
941
- const sanitizedProjectName = sanitizeProjectName(projectName);
942
- const hostsPath = getSystemHostsPath();
943
- const existing = readTextFile(hostsPath);
944
- const block = renderHostsBlock(sanitizedProjectName, entries);
945
- const next = upsertManagedBlock(existing, sanitizedProjectName, block);
946
- if (next === existing) {
947
- return { changed: false, hostsPath };
948
- }
949
- const tempPath = await writeSystemHostsFile(hostsPath, next, sanitizedProjectName);
950
- 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
+ });
951
1600
  }
952
- async function removeSystemHosts(projectName) {
953
- const sanitizedProjectName = sanitizeProjectName(projectName);
954
- const hostsPath = getSystemHostsPath();
955
- const existing = readTextFile(hostsPath);
956
- const next = removeManagedBlock(existing, sanitizedProjectName);
957
- if (next === existing) {
958
- 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
+ }
959
1609
  }
960
- const tempPath = await writeSystemHostsFile(hostsPath, next, sanitizedProjectName);
961
- return { changed: true, removed: true, hostsPath, tempPath };
1610
+ throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
962
1611
  }
963
1612
 
964
- // src/init.ts
965
- import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
966
- import { join as join6 } from "path";
967
- function detectPackageManager(cwd = process.cwd()) {
968
- if (existsSync4(join6(cwd, "pnpm-lock.yaml"))) return "pnpm";
969
- if (existsSync4(join6(cwd, "yarn.lock"))) return "yarn";
970
- 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;
971
1623
  }
972
- function packageRunCommand(packageManager, script) {
973
- if (packageManager === "yarn") return `yarn ${script}`;
974
- if (packageManager === "pnpm") return `pnpm ${script}`;
975
- return `npm run ${script}`;
1624
+ function envPort() {
1625
+ return parsePort(process.env.LOCALGHOST_PORT) ?? parsePort(process.env.VITE_PORT);
976
1626
  }
977
- function packageAddCommand(packageManager, packageName = "@hamedb89/localghost") {
978
- if (packageManager === "yarn") return `yarn add -D ${packageName}`;
979
- if (packageManager === "pnpm") return `pnpm add -D ${packageName}`;
980
- 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());
981
1631
  }
982
- function renderConfig(options) {
983
- return [
984
- "# Buh. Friendly names for local services.",
985
- "# Format: <host> <port>",
986
- `${options.host} ${options.port}`,
987
- `www.${options.host} ${options.port}`,
988
- `${options.apiHost} ${options.apiPort}`,
989
- ""
990
- ].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());
991
1636
  }
992
- function readPackageJson(path) {
1637
+ function getPackageName(cwd) {
993
1638
  try {
994
- 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;
995
1641
  } catch {
996
- return null;
1642
+ return void 0;
997
1643
  }
998
1644
  }
999
- function shellQuote(value) {
1000
- if (/^[A-Za-z0-9_./:-]+$/.test(value)) return value;
1001
- 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];
1002
1649
  }
1003
- function getConfigFlag(configFile) {
1004
- 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");
1005
1652
  }
1006
- function updatePackageScripts(packageJsonPath, configFile) {
1007
- const pkg = readPackageJson(packageJsonPath);
1008
- if (!pkg) return false;
1009
- const scripts = typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
1010
- const configFlag = getConfigFlag(configFile);
1011
- const nextScripts = {
1012
- ...scripts,
1013
- "localghost:setup": scripts["localghost:setup"] ?? `localghost setup${configFlag}`,
1014
- "localghost:proxy": scripts["localghost:proxy"] ?? `localghost dev${configFlag}`,
1015
- "localghost:proxy:https": scripts["localghost:proxy:https"] ?? `localghost dev${configFlag} --https`,
1016
- "localghost:run": scripts["localghost:run"] ?? `localghost run${configFlag} --`,
1017
- "localghost:ready": scripts["localghost:ready"] ?? `localghost status${configFlag} --ready`,
1018
- "localghost:trust": scripts["localghost:trust"] ?? `localghost trust${configFlag}`,
1019
- "localghost:ps": scripts["localghost:ps"] ?? "localghost ps",
1020
- "localghost:print": scripts["localghost:print"] ?? `localghost print${configFlag}`,
1021
- "localghost:routes": scripts["localghost:routes"] ?? `localghost routes${configFlag}`,
1022
- "localghost:status": scripts["localghost:status"] ?? "localghost status",
1023
- "localghost:reset": scripts["localghost:reset"] ?? "localghost reset",
1024
- "localghost:teardown": scripts["localghost:teardown"] ?? "localghost teardown",
1025
- "localghost:doctor": scripts["localghost:doctor"] ?? "localghost doctor",
1026
- "localghost:update": scripts["localghost:update"] ?? "localghost update",
1027
- "caddy:setup": scripts["caddy:setup"] ?? `localghost setup${configFlag}`,
1028
- "caddy:dev": scripts["caddy:dev"] ?? `localghost dev${configFlag}`
1029
- };
1030
- const changed = JSON.stringify(scripts) !== JSON.stringify(nextScripts);
1031
- if (!changed) return false;
1032
- pkg.scripts = nextScripts;
1033
- writeFileSync4(packageJsonPath, `${JSON.stringify(pkg, null, 2)}
1034
- `, "utf8");
1035
- return true;
1653
+ function getRouteName(primaryHost, fallback) {
1654
+ return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
1036
1655
  }
1037
- function initLocalghost(options = {}) {
1038
- const cwd = options.cwd ?? process.cwd();
1039
- const projectName = sanitizeProjectName(getProjectName(cwd).split("/").pop() ?? "app");
1040
- const host = options.host ?? `${projectName}.localhost`;
1041
- const port = options.port ?? 5173;
1042
- const apiHost = options.apiHost ?? `api.${host}`;
1043
- const apiPort = options.apiPort ?? 8787;
1044
- const packageManager = options.packageManager ?? detectPackageManager(cwd);
1045
- const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
1046
- const configPath = join6(cwd, configFile);
1047
- const configExists = existsSync4(configPath);
1048
- if (configExists && !options.force) {
1049
- return {
1050
- configPath,
1051
- configCreated: false,
1052
- packageJsonChanged: false,
1053
- packageManager,
1054
- nextSteps: [
1055
- packageRunCommand(packageManager, "localghost:doctor"),
1056
- packageRunCommand(packageManager, "localghost:setup"),
1057
- packageRunCommand(packageManager, "localghost:ready"),
1058
- packageRunCommand(packageManager, "localghost:proxy")
1059
- ]
1060
- };
1061
- }
1062
- writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
1063
- const packageJsonPath = join6(cwd, "package.json");
1064
- const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
1656
+ function readOptionsFromContext(options) {
1065
1657
  return {
1066
- configPath,
1067
- configCreated: true,
1068
- ...existsSync4(packageJsonPath) ? { packageJsonPath } : {},
1069
- packageJsonChanged,
1070
- packageManager,
1071
- nextSteps: [
1072
- packageRunCommand(packageManager, "localghost:doctor"),
1073
- packageRunCommand(packageManager, "localghost:setup"),
1074
- packageRunCommand(packageManager, "localghost:ready"),
1075
- packageRunCommand(packageManager, "localghost:proxy")
1076
- ]
1658
+ cwd: options.cwd ?? process.cwd(),
1659
+ ...options.fileName ? { fileName: options.fileName } : {},
1660
+ ...options.configFiles ? { configFiles: options.configFiles } : {},
1661
+ ...options.configPattern ? { configPattern: options.configPattern } : {}
1077
1662
  };
1078
1663
  }
1079
-
1080
- // src/relay.ts
1081
- import { createHmac, timingSafeEqual } from "crypto";
1082
- import { domainToASCII as domainToASCII2 } from "url";
1083
- var DEFAULT_RELAY_ALLOWED_TARGET_HOSTS = ["localhost", "127.0.0.1", "::1"];
1084
- var DEFAULT_RELAY_BLOCKED_PORTS = [22, 2375, 2376, 5432, 6379, 9200, 9229, 27017];
1085
- var DEFAULT_RELAY_LIMITS = {
1086
- requestBodyBytes: 5 * 1024 * 1024,
1087
- responseBytes: 25 * 1024 * 1024,
1088
- timeoutMs: 3e4,
1089
- maxConcurrentRequests: 20,
1090
- perRouteRequestsPerMinute: 120,
1091
- perIpRequestsPerMinute: 60
1092
- };
1093
- var DEFAULT_RELAY_TARGET_POLICY = {
1094
- allowedHosts: [...DEFAULT_RELAY_ALLOWED_TARGET_HOSTS],
1095
- blockedPorts: [...DEFAULT_RELAY_BLOCKED_PORTS],
1096
- allowPrivateNetworkTargets: false
1097
- };
1098
- var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
1099
- "connection",
1100
- "keep-alive",
1101
- "proxy-authenticate",
1102
- "proxy-authorization",
1103
- "te",
1104
- "trailer",
1105
- "transfer-encoding",
1106
- "upgrade"
1107
- ]);
1108
- var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "set-cookie"]);
1109
- var TOKEN_QUERY_PATTERN = /(token|secret|key|password|session|jwt|auth)/i;
1110
- var HOST_PATTERN2 = /^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*$/i;
1111
- var IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/;
1112
- function base64UrlEncode(value) {
1113
- 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);
1114
1669
  }
1115
- function base64UrlDecode(value) {
1116
- return Buffer.from(value, "base64url").toString("utf8");
1670
+ function uniqueHosts(entries) {
1671
+ return [...new Set(entries.map((entry) => entry.host))];
1117
1672
  }
1118
- function signPayload(payload, secret) {
1119
- return createHmac("sha256", secret).update(payload).digest("base64url");
1673
+ function isAliasableHost(host) {
1674
+ return host.includes(".") && !host.startsWith("www.") && !host.includes(":");
1120
1675
  }
1121
- function secureEqual(left, right) {
1122
- const leftBuffer = Buffer.from(left);
1123
- const rightBuffer = Buffer.from(right);
1124
- return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
1676
+ function getDefaultWwwAlias(host) {
1677
+ return isAliasableHost(host) ? `www.${host}` : null;
1125
1678
  }
1126
- function normalizeHost(host) {
1127
- const trimmed = host.trim().toLowerCase().replace(/\.$/, "");
1128
- if (!trimmed || trimmed.includes("*") || trimmed.includes("/") || trimmed.includes(":")) return null;
1129
- const ascii = domainToASCII2(trimmed);
1130
- if (!ascii || ascii.includes("..")) return null;
1131
- 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];
1132
1690
  }
1133
- function normalizeTargetHost(host) {
1134
- const trimmed = host.trim().toLowerCase();
1135
- if (trimmed === "::1" || trimmed === "[::1]") return "::1";
1136
- if (trimmed.includes("/") || trimmed.includes("*")) return null;
1137
- if (IPV4_PATTERN.test(trimmed)) return isValidIpv4(trimmed) ? trimmed : null;
1138
- return normalizeHost(trimmed);
1691
+ function defined(input) {
1692
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => typeof value !== "undefined"));
1139
1693
  }
1140
- function isValidIpv4(value) {
1141
- return value.split(".").every((part) => {
1142
- const octet = Number(part);
1143
- 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)
1144
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
+ };
1145
1756
  }
1146
- function isPrivateIpv4(value) {
1147
- if (!isValidIpv4(value)) return false;
1148
- const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
1149
- return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
1150
- }
1151
- function isLocalTargetHost(host) {
1152
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
1153
- }
1154
- function mergeTargetPolicy(policy) {
1757
+
1758
+ // src/ghost-request.ts
1759
+ function getGhostTunnelReadOptions(input) {
1155
1760
  return {
1156
- allowedHosts: policy?.allowedHosts ?? DEFAULT_RELAY_TARGET_POLICY.allowedHosts,
1157
- blockedPorts: policy?.blockedPorts ?? DEFAULT_RELAY_TARGET_POLICY.blockedPorts,
1158
- allowPrivateNetworkTargets: policy?.allowPrivateNetworkTargets ?? DEFAULT_RELAY_TARGET_POLICY.allowPrivateNetworkTargets
1761
+ ...input.cwd ? { cwd: input.cwd } : {},
1762
+ ...input.ghostTunnelFile ? { fileName: input.ghostTunnelFile } : {}
1159
1763
  };
1160
1764
  }
1161
- function mergeLimits(limits) {
1162
- const merged = {
1163
- ...DEFAULT_RELAY_LIMITS,
1164
- ...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 } : {}
1165
1790
  };
1166
- for (const [key, value] of Object.entries(merged)) {
1167
- if (!Number.isInteger(value) || value < 1) {
1168
- throw new Error(`Invalid relay limit ${key}: ${value}`);
1169
- }
1170
- }
1171
- return merged;
1172
1791
  }
1173
- function assertExactRelayHost(host) {
1174
- const normalized = normalizeHost(host);
1175
- if (!normalized) {
1176
- throw new Error(`Relay route claims must use an exact hostname: ${host}`);
1177
- }
1178
- 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
+ };
1179
1812
  }
1180
- function assertRelayLocalTarget(target, policyInput) {
1181
- if (!target || typeof target !== "object") {
1182
- throw new Error("Relay target must be an explicit local target object.");
1183
- }
1184
- const policy = mergeTargetPolicy(policyInput);
1185
- const host = normalizeTargetHost(target.host);
1186
- if (!host) {
1187
- throw new Error(`Invalid relay target host: ${target.host}`);
1188
- }
1189
- if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
1190
- throw new Error(`Invalid relay target port: ${target.port}`);
1191
- }
1192
- const protocol = target.protocol ?? "http";
1193
- if (protocol !== "http" && protocol !== "https") {
1194
- throw new Error(`Invalid relay target protocol: ${String(protocol)}`);
1195
- }
1196
- if (policy.blockedPorts.includes(target.port)) {
1197
- throw new Error(`Relay target port is blocked: ${target.port}`);
1198
- }
1199
- const allowedHosts = new Set(policy.allowedHosts.map((allowedHost) => normalizeTargetHost(allowedHost)).filter((value) => Boolean(value)));
1200
- if (!allowedHosts.has(host)) {
1201
- throw new Error(`Relay target host is not explicitly allowed: ${host}`);
1202
- }
1203
- if (!policy.allowPrivateNetworkTargets && !isLocalTargetHost(host) && (host === "::1" || isPrivateIpv4(host))) {
1204
- throw new Error(`Private-network relay target requires explicit opt-in: ${host}`);
1205
- }
1813
+ function renderGhostTunnelRelayOfflineResponse(resolved) {
1814
+ const response = renderRelayOfflineResponse();
1206
1815
  return {
1207
- protocol,
1208
- host,
1209
- 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("")
1210
1834
  };
1211
1835
  }
1212
- function authenticateRelayAgentToken(input) {
1213
- const expected = `Bearer ${input.agentToken}`;
1214
- 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
+ }
1215
1854
  }
1216
- function signRelayRouteClaim(claim, signingSecret) {
1217
- const payload = {
1218
- ...claim,
1219
- host: assertExactRelayHost(claim.host)
1220
- };
1221
- if (!payload.scope) throw new Error("Relay route claim requires a scope.");
1222
- if (!payload.agentId) throw new Error("Relay route claim requires an agentId.");
1223
- if (Number.isNaN(Date.parse(payload.expiresAt))) throw new Error("Relay route claim requires a valid expiresAt.");
1224
- const encodedPayload = base64UrlEncode(JSON.stringify(payload));
1225
- const signature = signPayload(encodedPayload, signingSecret);
1855
+ async function runDoctor() {
1856
+ const caddy = await checkCaddy();
1226
1857
  return {
1227
- payload,
1228
- token: `${encodedPayload}.${signature}`
1858
+ ok: caddy.found,
1859
+ caddy
1229
1860
  };
1230
1861
  }
1231
- function verifyRelayRouteClaim(token, signingSecret, options) {
1232
- const [encodedPayload, signature] = token.split(".");
1233
- if (!encodedPayload || !signature || token.split(".").length !== 2) {
1234
- 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>\`.`);
1235
1870
  }
1236
- const expectedSignature = signPayload(encodedPayload, signingSecret);
1237
- if (!secureEqual(signature, expectedSignature)) {
1238
- 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}.`);
1239
1875
  }
1240
- const parsed = JSON.parse(base64UrlDecode(encodedPayload));
1241
- const host = assertExactRelayHost(parsed.host);
1242
- if (parsed.scope !== options.expectedScope) {
1243
- 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;
1244
1881
  }
1245
- const now = options.now ?? /* @__PURE__ */ new Date();
1246
- if (Date.parse(parsed.expiresAt) <= now.getTime()) {
1247
- 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" };
1248
1904
  }
1249
- if (!parsed.agentId) {
1250
- 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
+ };
1251
1917
  }
1252
- 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
+ });
1253
1966
  }
1254
- function createRelayRouteRegistration(input) {
1255
- if (!authenticateRelayAgentToken({
1256
- agentToken: input.agentToken,
1257
- ...typeof input.authorizationHeader !== "undefined" ? { authorizationHeader: input.authorizationHeader } : {}
1258
- })) {
1259
- 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";
1260
1983
  }
1261
- const claim = verifyRelayRouteClaim(input.claimToken, input.signingSecret, {
1262
- expectedScope: input.expectedScope,
1263
- ...input.now ? { now: input.now } : {}
1264
- });
1265
- const target = assertRelayLocalTarget(input.target, input.targetPolicy);
1266
- const access = input.publicMode === true ? "public" : input.access ?? "private";
1267
- const passwordProtected = input.passwordProtected ?? false;
1268
- const authRequired = input.authRequired ?? false;
1269
- if (access === "public" && input.publicMode !== true) {
1270
- 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);
1271
2030
  }
1272
- if (access === "private" && !passwordProtected && !authRequired) {
1273
- 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;
1274
2039
  }
1275
- return {
1276
- host: claim.host,
1277
- scope: claim.scope,
1278
- agentId: claim.agentId,
1279
- expiresAt: claim.expiresAt,
1280
- target,
1281
- access,
1282
- passwordProtected,
1283
- authRequired,
1284
- limits: mergeLimits(input.limits)
1285
- };
2040
+ return existing.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
1286
2041
  }
1287
- function isRelayRouteActive(route, options) {
1288
- if (!options.agentConnected) return false;
1289
- 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;
1290
2055
  }
1291
- function stripRelayForwardHeaders(headers) {
1292
- const stripped = {};
1293
- for (const [name, value] of Object.entries(headers)) {
1294
- if (typeof value === "undefined") continue;
1295
- const lowerName = name.toLowerCase();
1296
- if (HOP_BY_HOP_HEADERS.has(lowerName)) continue;
1297
- if (lowerName.startsWith("x-localghost-")) continue;
1298
- 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 };
1299
2064
  }
1300
- return stripped;
2065
+ const tempPath = await writeSystemHostsFile(hostsPath, next, sanitizedProjectName);
2066
+ return { changed: true, hostsPath, tempPath };
1301
2067
  }
1302
- function redactRelayHeaders(headers) {
1303
- const redacted = {};
1304
- for (const [name, value] of Object.entries(headers)) {
1305
- if (typeof value === "undefined") continue;
1306
- 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 };
1307
2075
  }
1308
- return redacted;
2076
+ const tempPath = await writeSystemHostsFile(hostsPath, next, sanitizedProjectName);
2077
+ return { changed: true, removed: true, hostsPath, tempPath };
1309
2078
  }
1310
- function redactRelayLogUrl(input) {
1311
- const url = new URL(input, "http://localghost.invalid");
1312
- for (const key of [...url.searchParams.keys()]) {
1313
- if (TOKEN_QUERY_PATTERN.test(key)) {
1314
- url.searchParams.set(key, "[redacted]");
1315
- }
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
+ return "npm";
2087
+ }
2088
+ function packageRunCommand(packageManager, script) {
2089
+ if (packageManager === "yarn") return `yarn ${script}`;
2090
+ if (packageManager === "pnpm") return `pnpm ${script}`;
2091
+ return `npm run ${script}`;
2092
+ }
2093
+ function packageAddCommand(packageManager, packageName = "@hamedb89/localghost") {
2094
+ if (packageManager === "yarn") return `yarn add -D ${packageName}`;
2095
+ if (packageManager === "pnpm") return `pnpm add -D ${packageName}`;
2096
+ return `npm install -D ${packageName}`;
2097
+ }
2098
+ function renderConfig(options) {
2099
+ return [
2100
+ "# Buh. Friendly names for local services.",
2101
+ "# Format: <host> <port>",
2102
+ `${options.host} ${options.port}`,
2103
+ `www.${options.host} ${options.port}`,
2104
+ `${options.apiHost} ${options.apiPort}`,
2105
+ ""
2106
+ ].join("\n");
2107
+ }
2108
+ function readPackageJson2(path) {
2109
+ try {
2110
+ return JSON.parse(readFileSync6(path, "utf8"));
2111
+ } catch {
2112
+ return null;
1316
2113
  }
1317
- return input.startsWith("http://") || input.startsWith("https://") ? url.toString() : `${url.pathname}${url.search}`;
1318
2114
  }
1319
- function renderRelayOfflineResponse() {
2115
+ function shellQuote(value) {
2116
+ if (/^[A-Za-z0-9_./:-]+$/.test(value)) return value;
2117
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
2118
+ }
2119
+ function getConfigFlag(configFile) {
2120
+ return configFile === LOCALGHOST_CONFIG_FILE ? "" : ` --config ${shellQuote(configFile)}`;
2121
+ }
2122
+ function updatePackageScripts(packageJsonPath, configFile) {
2123
+ const pkg = readPackageJson2(packageJsonPath);
2124
+ if (!pkg) return false;
2125
+ const scripts = typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
2126
+ const configFlag = getConfigFlag(configFile);
2127
+ const nextScripts = {
2128
+ ...scripts,
2129
+ "localghost:setup": scripts["localghost:setup"] ?? `localghost setup${configFlag}`,
2130
+ "localghost:proxy": scripts["localghost:proxy"] ?? `localghost dev${configFlag}`,
2131
+ "localghost:proxy:https": scripts["localghost:proxy:https"] ?? `localghost dev${configFlag} --https`,
2132
+ "localghost:run": scripts["localghost:run"] ?? `localghost run${configFlag} --`,
2133
+ "localghost:ready": scripts["localghost:ready"] ?? `localghost status${configFlag} --ready`,
2134
+ "localghost:repair": scripts["localghost:repair"] ?? `localghost repair${configFlag}`,
2135
+ "localghost:trust": scripts["localghost:trust"] ?? `localghost trust${configFlag}`,
2136
+ "localghost:ps": scripts["localghost:ps"] ?? "localghost ps",
2137
+ "localghost:print": scripts["localghost:print"] ?? `localghost print${configFlag}`,
2138
+ "localghost:routes": scripts["localghost:routes"] ?? `localghost routes${configFlag}`,
2139
+ "localghost:status": scripts["localghost:status"] ?? "localghost status",
2140
+ "localghost:reset": scripts["localghost:reset"] ?? "localghost reset",
2141
+ "localghost:teardown": scripts["localghost:teardown"] ?? "localghost teardown",
2142
+ "localghost:doctor": scripts["localghost:doctor"] ?? "localghost doctor",
2143
+ "localghost:update": scripts["localghost:update"] ?? "localghost update",
2144
+ "caddy:setup": scripts["caddy:setup"] ?? `localghost setup${configFlag}`,
2145
+ "caddy:dev": scripts["caddy:dev"] ?? `localghost dev${configFlag}`
2146
+ };
2147
+ const changed = JSON.stringify(scripts) !== JSON.stringify(nextScripts);
2148
+ if (!changed) return false;
2149
+ pkg.scripts = nextScripts;
2150
+ writeFileSync4(packageJsonPath, `${JSON.stringify(pkg, null, 2)}
2151
+ `, "utf8");
2152
+ return true;
2153
+ }
2154
+ function initLocalghost(options = {}) {
2155
+ const cwd = options.cwd ?? process.cwd();
2156
+ const projectName = sanitizeProjectName(getProjectName(cwd).split("/").pop() ?? "app");
2157
+ const host = options.host ?? `${projectName}.localhost`;
2158
+ const port = options.port ?? 5173;
2159
+ const apiHost = options.apiHost ?? `api.${host}`;
2160
+ const apiPort = options.apiPort ?? 8787;
2161
+ const packageManager = options.packageManager ?? detectPackageManager(cwd);
2162
+ const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
2163
+ const configPath = join7(cwd, configFile);
2164
+ const configExists = existsSync5(configPath);
2165
+ if (configExists && !options.force) {
2166
+ return {
2167
+ configPath,
2168
+ configCreated: false,
2169
+ packageJsonChanged: false,
2170
+ packageManager,
2171
+ nextSteps: [
2172
+ packageRunCommand(packageManager, "localghost:doctor"),
2173
+ packageRunCommand(packageManager, "localghost:setup"),
2174
+ packageRunCommand(packageManager, "localghost:ready"),
2175
+ packageRunCommand(packageManager, "localghost:proxy")
2176
+ ]
2177
+ };
2178
+ }
2179
+ writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
2180
+ const packageJsonPath = join7(cwd, "package.json");
2181
+ const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
1320
2182
  return {
1321
- status: 503,
1322
- headers: {
1323
- "content-type": "text/html; charset=utf-8",
1324
- "cache-control": "no-store"
1325
- },
1326
- body: [
1327
- "<!doctype html>",
1328
- "<html>",
1329
- '<head><meta charset="utf-8"><title>Preview offline</title></head>',
1330
- "<body><h1>Preview offline</h1><p>The local agent is not connected. Try again later.</p></body>",
1331
- "</html>"
1332
- ].join("")
2183
+ configPath,
2184
+ configCreated: true,
2185
+ ...existsSync5(packageJsonPath) ? { packageJsonPath } : {},
2186
+ packageJsonChanged,
2187
+ packageManager,
2188
+ nextSteps: [
2189
+ packageRunCommand(packageManager, "localghost:doctor"),
2190
+ packageRunCommand(packageManager, "localghost:setup"),
2191
+ packageRunCommand(packageManager, "localghost:ready"),
2192
+ packageRunCommand(packageManager, "localghost:proxy")
2193
+ ]
1333
2194
  };
1334
2195
  }
1335
2196
 
@@ -1390,21 +2251,22 @@ function formatGhostTunnel(config, options = {}) {
1390
2251
  if (options.verbose) {
1391
2252
  lines.push(` domains: ${config.domains.length > 0 ? config.domains.join(", ") : "*"}`);
1392
2253
  lines.push(` access: ${config.requireAuth ? "auth required" : "app decides"}`);
1393
- lines.push(` transport: ${config.requireHttps ? "https required" : "http allowed"}`);
2254
+ lines.push(` protocol: ${config.requireHttps ? "https required" : "http allowed"}`);
2255
+ lines.push(` transport: ${config.transport.kind}`);
1394
2256
  }
1395
2257
  return lines.join("\n");
1396
2258
  }
1397
2259
 
1398
2260
  // src/state.ts
1399
- import { existsSync as existsSync5 } from "fs";
1400
- import { join as join7 } from "path";
2261
+ import { existsSync as existsSync6 } from "fs";
2262
+ import { join as join8 } from "path";
1401
2263
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
1402
2264
  function getLocalghostStatePath(cwd = process.cwd()) {
1403
- return join7(cwd, LOCALGHOST_STATE_FILE);
2265
+ return join8(cwd, LOCALGHOST_STATE_FILE);
1404
2266
  }
1405
2267
  function readLocalghostState(cwd = process.cwd()) {
1406
2268
  const path = getLocalghostStatePath(cwd);
1407
- if (!existsSync5(path)) return null;
2269
+ if (!existsSync6(path)) return null;
1408
2270
  return JSON.parse(readTextFile(path));
1409
2271
  }
1410
2272
  function writeLocalghostState(cwd, state) {
@@ -1419,12 +2281,239 @@ function patchLocalghostState(cwd, patch) {
1419
2281
  return writeLocalghostState(cwd, { ...current, ...patch });
1420
2282
  }
1421
2283
 
2284
+ // src/vercel.ts
2285
+ function getHeaderValue(value) {
2286
+ if (Array.isArray(value)) return value[0] ?? "";
2287
+ return value ?? "";
2288
+ }
2289
+ function getTrustedProtocol(request) {
2290
+ const forwarded = getHeaderValue(request.headers["x-forwarded-proto"]).split(",")[0]?.trim().toLowerCase();
2291
+ return forwarded === "http" ? "http" : "https";
2292
+ }
2293
+ function getTrustedRequestUrl(request, host, protocol) {
2294
+ return new URL(request.url ?? "/", `${protocol}://${host}`).toString();
2295
+ }
2296
+ function getTunnelRequestPath(request) {
2297
+ const url = new URL(request.url ?? "/", "http://localghost.invalid");
2298
+ return `${url.pathname}${url.search}`;
2299
+ }
2300
+ function normalizeRequestHeaders(headers) {
2301
+ const stripped = stripRelayForwardHeaders(headers);
2302
+ return Object.fromEntries(Object.entries(stripped).map(([name, value]) => [
2303
+ name,
2304
+ Array.isArray(value) ? value.join(", ") : value
2305
+ ]));
2306
+ }
2307
+ function getTunnelStore(options, namespace) {
2308
+ return options.tunnelStore ?? createRedisGhostTunnelStoreFromEnv({
2309
+ ...options.tunnelEnv ? { env: options.tunnelEnv } : {},
2310
+ namespace
2311
+ });
2312
+ }
2313
+ async function readRequestBody(request, maxBytes) {
2314
+ if (!request[Symbol.asyncIterator]) return void 0;
2315
+ const chunks = [];
2316
+ let size = 0;
2317
+ for await (const chunk of request) {
2318
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2319
+ size += buffer.byteLength;
2320
+ if (size > maxBytes) {
2321
+ throw new Error(`Ghost Tunnel request exceeded ${maxBytes} bytes.`);
2322
+ }
2323
+ chunks.push(buffer);
2324
+ }
2325
+ return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
2326
+ }
2327
+ function writeResponse(response, payload) {
2328
+ response.statusCode = payload.status;
2329
+ for (const [name, value] of Object.entries(payload.headers)) {
2330
+ response.setHeader(name, value);
2331
+ }
2332
+ response.end(payload.body);
2333
+ }
2334
+ function renderGhostTunnelIpRedirectResponse(url) {
2335
+ return {
2336
+ status: 307,
2337
+ headers: {
2338
+ location: url,
2339
+ "cache-control": "no-store",
2340
+ "content-type": "text/html; charset=utf-8",
2341
+ "x-localghost-relay": "ip"
2342
+ },
2343
+ body: [
2344
+ "<!doctype html>",
2345
+ "<html>",
2346
+ '<head><meta charset="utf-8"><title>Redirecting to local preview</title></head>',
2347
+ "<body>",
2348
+ `<p>Redirecting to <a href="${url}">${url}</a>.</p>`,
2349
+ "</body>",
2350
+ "</html>"
2351
+ ].join("")
2352
+ };
2353
+ }
2354
+ function renderGhostTunnelTransportRejectedResponse(message, status = 400) {
2355
+ return {
2356
+ status,
2357
+ headers: {
2358
+ "content-type": "text/html; charset=utf-8",
2359
+ "cache-control": "no-store",
2360
+ "x-localghost-relay": "rejected"
2361
+ },
2362
+ body: [
2363
+ "<!doctype html>",
2364
+ "<html>",
2365
+ '<head><meta charset="utf-8"><title>Ghost Tunnel transport rejected</title></head>',
2366
+ "<body>",
2367
+ "<h1>Ghost Tunnel transport rejected</h1>",
2368
+ `<p>${message}</p>`,
2369
+ "</body>",
2370
+ "</html>"
2371
+ ].join("")
2372
+ };
2373
+ }
2374
+ function renderGhostTunnelTunnelTimeoutResponse() {
2375
+ return {
2376
+ status: 504,
2377
+ headers: {
2378
+ "content-type": "text/html; charset=utf-8",
2379
+ "cache-control": "no-store",
2380
+ "x-localghost-relay": "timeout"
2381
+ },
2382
+ body: [
2383
+ "<!doctype html>",
2384
+ "<html>",
2385
+ '<head><meta charset="utf-8"><title>Ghost Tunnel timed out</title></head>',
2386
+ "<body>",
2387
+ "<h1>Ghost Tunnel timed out</h1>",
2388
+ "<p>The deployed handler did not receive a local response before the request window closed.</p>",
2389
+ "</body>",
2390
+ "</html>"
2391
+ ].join("")
2392
+ };
2393
+ }
2394
+ function renderGhostTunnelQueuedResponse(response) {
2395
+ const body = decodeGhostTunnelBody(response.bodyBase64)?.toString() ?? "";
2396
+ return {
2397
+ status: response.status,
2398
+ headers: {
2399
+ ...response.headers,
2400
+ "x-localghost-relay": response.error ? "target-error" : "tunnel"
2401
+ },
2402
+ body
2403
+ };
2404
+ }
2405
+ async function waitForTunnelResponse(input) {
2406
+ const startedAt = Date.now();
2407
+ while (Date.now() - startedAt < input.waitMs) {
2408
+ const response = await input.store.readResponse(input.requestId);
2409
+ if (response) {
2410
+ await input.store.cleanup(input.requestId);
2411
+ return response;
2412
+ }
2413
+ await new Promise((resolve3) => setTimeout(resolve3, input.pollIntervalMs));
2414
+ }
2415
+ return null;
2416
+ }
2417
+ async function resolveAuthenticatedState(input, request) {
2418
+ if (typeof input === "function") {
2419
+ return await input(request);
2420
+ }
2421
+ return input;
2422
+ }
2423
+ function createVercelGhostTunnelHandler(options) {
2424
+ return async function handler(request, response) {
2425
+ const host = getHeaderValue(request.headers.host);
2426
+ const protocol = getTrustedProtocol(request);
2427
+ try {
2428
+ const authenticated = typeof options.authenticated !== "undefined" ? await resolveAuthenticatedState(options.authenticated, request) : void 0;
2429
+ const resolved = await resolveGhostTunnelRequest({
2430
+ ...options.cwd ? { cwd: options.cwd } : {},
2431
+ ...typeof options.localghostConfig !== "undefined" ? { localghostConfig: options.localghostConfig } : {},
2432
+ ...options.ghostTunnelFile ? { ghostTunnelFile: options.ghostTunnelFile } : {},
2433
+ host,
2434
+ domain: options.domain,
2435
+ protocol,
2436
+ ...typeof authenticated === "boolean" ? { authenticated } : {}
2437
+ });
2438
+ if (!resolved.entry) {
2439
+ writeResponse(response, renderGhostTunnelRouteMissingResponse(resolved));
2440
+ return;
2441
+ }
2442
+ if (resolved.ghostTunnel.transport.kind === "ip") {
2443
+ if (!options.ipSigningSecret) {
2444
+ writeResponse(response, renderGhostTunnelTransportRejectedResponse("Ghost Tunnel IP transport requires ipSigningSecret in the deployed handler.", 500));
2445
+ return;
2446
+ }
2447
+ try {
2448
+ const redirect = resolveGhostTunnelIpRedirect({
2449
+ requestUrl: getTrustedRequestUrl(request, host, protocol),
2450
+ host: resolved.route.host,
2451
+ entryPort: resolved.entry.port,
2452
+ signingSecret: options.ipSigningSecret,
2453
+ transport: resolved.ghostTunnel.transport
2454
+ });
2455
+ writeResponse(response, renderGhostTunnelIpRedirectResponse(redirect.url));
2456
+ } catch (error) {
2457
+ const message = error instanceof Error ? error.message : String(error);
2458
+ const status = /expired/i.test(message) ? 410 : 400;
2459
+ writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, status));
2460
+ }
2461
+ return;
2462
+ }
2463
+ if (resolved.ghostTunnel.transport.kind === "tunnel") {
2464
+ const transport = resolved.ghostTunnel.transport;
2465
+ const store = getTunnelStore(options, transport.store.namespace);
2466
+ const route = await store.getRoute(resolved.route.host);
2467
+ if (!route) {
2468
+ writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
2469
+ return;
2470
+ }
2471
+ try {
2472
+ const requestBody = await readRequestBody(request, transport.maxRequestBodyBytes);
2473
+ const queuedRequest = createGhostTunnelQueuedRequest({
2474
+ host: resolved.route.host,
2475
+ method: request.method ?? "GET",
2476
+ path: getTunnelRequestPath(request),
2477
+ headers: normalizeRequestHeaders(request.headers),
2478
+ ...requestBody ? { body: requestBody } : {},
2479
+ ttlSeconds: transport.requestTtlSeconds
2480
+ });
2481
+ await store.enqueueRequest(queuedRequest, transport.requestTtlSeconds);
2482
+ const tunnelResponse = await waitForTunnelResponse({
2483
+ store,
2484
+ requestId: queuedRequest.id,
2485
+ waitMs: transport.waitMs,
2486
+ pollIntervalMs: transport.pollIntervalMs
2487
+ });
2488
+ writeResponse(response, tunnelResponse ? renderGhostTunnelQueuedResponse(tunnelResponse) : renderGhostTunnelTunnelTimeoutResponse());
2489
+ } catch (error) {
2490
+ const message = error instanceof Error ? error.message : String(error);
2491
+ writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, 400));
2492
+ }
2493
+ return;
2494
+ }
2495
+ writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
2496
+ } catch (error) {
2497
+ const message = error instanceof Error ? error.message : String(error);
2498
+ writeResponse(response, {
2499
+ status: /authenticated/i.test(message) ? 401 : 404,
2500
+ headers: {
2501
+ "content-type": "text/plain; charset=utf-8",
2502
+ "cache-control": "no-store",
2503
+ "x-localghost-relay": "rejected"
2504
+ },
2505
+ body: message
2506
+ });
2507
+ }
2508
+ };
2509
+ }
2510
+
1422
2511
  // src/update-check.ts
1423
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2512
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
1424
2513
  import { homedir as homedir2 } from "os";
1425
- import { dirname as dirname4, join as join8 } from "path";
2514
+ import { dirname as dirname4, join as join9 } from "path";
1426
2515
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
1427
- var LOCALGHOST_VERSION = "0.1.9";
2516
+ var LOCALGHOST_VERSION = "0.1.12";
1428
2517
  var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
1429
2518
  var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
1430
2519
  var UPDATE_CHECK_TIMEOUT_MS = 900;
@@ -1436,13 +2525,13 @@ function isUpdateCheckDisabled(env = process.env) {
1436
2525
  }
1437
2526
  function getUpdateCheckCachePath(env = process.env) {
1438
2527
  if (env.LOCALGHOST_UPDATE_CHECK_CACHE) return env.LOCALGHOST_UPDATE_CHECK_CACHE;
1439
- const cacheRoot = env.XDG_CACHE_HOME || join8(homedir2(), ".cache");
1440
- return join8(cacheRoot, "localghost", "update-check.json");
2528
+ const cacheRoot = env.XDG_CACHE_HOME || join9(homedir2(), ".cache");
2529
+ return join9(cacheRoot, "localghost", "update-check.json");
1441
2530
  }
1442
2531
  function readCache(path = getUpdateCheckCachePath()) {
1443
- if (!existsSync6(path)) return null;
2532
+ if (!existsSync7(path)) return null;
1444
2533
  try {
1445
- return JSON.parse(readFileSync6(path, "utf8"));
2534
+ return JSON.parse(readFileSync7(path, "utf8"));
1446
2535
  } catch {
1447
2536
  return null;
1448
2537
  }
@@ -1586,12 +2675,16 @@ ${message}`);
1586
2675
  markUpdateNotified(result, cachePath);
1587
2676
  }
1588
2677
  export {
2678
+ DEFAULT_GHOST_TUNNEL_IP_TRANSPORT_TTL_SECONDS,
2679
+ DEFAULT_GHOST_TUNNEL_RESPONSE_TTL_SECONDS,
2680
+ DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM,
1589
2681
  DEFAULT_RELAY_ALLOWED_TARGET_HOSTS,
1590
2682
  DEFAULT_RELAY_BLOCKED_PORTS,
1591
2683
  DEFAULT_RELAY_LIMITS,
1592
2684
  DEFAULT_RELAY_TARGET_POLICY,
1593
2685
  LOCALGHOST_ACTIVITY_VERSION,
1594
2686
  LOCALGHOST_CONFIG_FILE,
2687
+ LOCALGHOST_GHOST_TUNNEL_FILE,
1595
2688
  LOCALGHOST_PACKAGE_NAME,
1596
2689
  LOCALGHOST_STATE_FILE,
1597
2690
  LOCALGHOST_VERSION,
@@ -1607,13 +2700,28 @@ export {
1607
2700
  checkForUpdate,
1608
2701
  compareVersions,
1609
2702
  constructGhostTunnelHost,
2703
+ constructGhostTunnelIpUrl,
1610
2704
  constructGhostTunnelURL,
1611
2705
  constructGhostTunnelUrl,
2706
+ createGhostTunnelQueuedRequest,
2707
+ createGhostTunnelRouteHeartbeat,
2708
+ createMemoryGhostTunnelStore,
2709
+ createRedisGhostTunnelStore,
2710
+ createRedisGhostTunnelStoreFromEnv,
1612
2711
  createRelayRouteRegistration,
2712
+ createVercelGhostTunnelHandler,
2713
+ decodeGhostTunnelBody,
1613
2714
  defineLocalghostConfig,
2715
+ detectDevCommand,
2716
+ detectDevPackageManager,
2717
+ detectDevServices,
1614
2718
  detectPackageManager,
2719
+ encodeGhostTunnelBody,
1615
2720
  findAvailablePort,
2721
+ findGhostTunnelEntry,
1616
2722
  findLocalMdnsHosts,
2723
+ formatDetectedDevCommand,
2724
+ formatDetectedDevServices,
1617
2725
  formatDomainRoutes,
1618
2726
  formatGhostTunnel,
1619
2727
  formatUpdateMessage,
@@ -1625,6 +2733,7 @@ export {
1625
2733
  getGhostTunnelDisplayUrl,
1626
2734
  getGhostTunnelDisplayUrls,
1627
2735
  getGhostTunnelEntryHost,
2736
+ getGhostTunnelPath,
1628
2737
  getGhostTunnelPreviewUrl,
1629
2738
  getGhostTunnelWildcardHost,
1630
2739
  getLocalghostActivityPath,
@@ -1641,6 +2750,7 @@ export {
1641
2750
  isProductionLike,
1642
2751
  isRelayRouteActive,
1643
2752
  isUpdateCheckDisabled,
2753
+ listGhostTunnelEntries,
1644
2754
  listLocalghostRuns,
1645
2755
  listLocalghostSetups,
1646
2756
  markUpdateNotified,
@@ -1652,6 +2762,7 @@ export {
1652
2762
  patchLocalghostState,
1653
2763
  pruneLocalghostActivity,
1654
2764
  readDevHosts,
2765
+ readGhostTunnelEntries,
1655
2766
  readLocalghostActivity,
1656
2767
  readLocalghostProjectConfig,
1657
2768
  readLocalghostState,
@@ -1662,17 +2773,28 @@ export {
1662
2773
  removeManagedBlock,
1663
2774
  removeSystemHosts,
1664
2775
  renderCaddyfile,
2776
+ renderCompactLocalghostBanner,
2777
+ renderGhostTunnelRelayOfflineResponse,
2778
+ renderGhostTunnelRouteMissingResponse,
1665
2779
  renderHostsBlock,
2780
+ renderLocalghostBanner,
1666
2781
  renderRelayOfflineResponse,
1667
2782
  resolveDevHostsPath,
1668
2783
  resolveGhostTunnelConfig,
2784
+ resolveGhostTunnelIpRedirect,
2785
+ resolveGhostTunnelPath,
2786
+ resolveGhostTunnelRequest,
1669
2787
  resolveLocalghostContext,
2788
+ resolveRedisGhostTunnelEnv,
1670
2789
  runCaddy,
1671
2790
  runDoctor,
1672
2791
  sanitizeProjectName,
2792
+ serveGhostTunnelLocalRequest,
1673
2793
  shouldNotifyAboutUpdate,
2794
+ signGhostTunnelIpTransportClaim,
1674
2795
  signRelayRouteClaim,
1675
2796
  startCaddy,
2797
+ startGhostTunnelAgent,
1676
2798
  stripRelayForwardHeaders,
1677
2799
  trustCaddy,
1678
2800
  unregisterLocalghostRun,
@@ -1680,6 +2802,7 @@ export {
1680
2802
  updateSystemHosts,
1681
2803
  upsertManagedBlock,
1682
2804
  validateCaddyfile,
2805
+ verifyGhostTunnelIpTransportClaim,
1683
2806
  verifyRelayRouteClaim,
1684
2807
  writeCaddyfile,
1685
2808
  writeLocalghostActivity,