@hamedb89/localghost 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vite.d.ts CHANGED
@@ -17,6 +17,7 @@ type LocalGhostPluginOptions = {
17
17
  localghostConfig?: string | false;
18
18
  wwwAlias?: boolean;
19
19
  ghostTunnel?: GhostTunnelOptions;
20
+ registryOwnerToken?: string;
20
21
  verbose?: boolean;
21
22
  };
22
23
  declare function localGhostPlugin(options?: LocalGhostPluginOptions): Plugin;
package/dist/vite.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/vite.ts
2
2
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
3
- import { normalize, resolve as resolve2 } from "path";
3
+ import { normalize as normalize2, resolve as resolve3 } from "path";
4
4
  import { spawn } from "child_process";
5
5
  import { emitKeypressEvents } from "readline";
6
6
 
@@ -80,6 +80,7 @@ function registerLocalghostRun(input2, path = getLocalghostActivityPath()) {
80
80
  ...input2.configPath ? { configPath: input2.configPath } : {},
81
81
  ...input2.caddyfilePath ? { caddyfilePath: input2.caddyfilePath } : {},
82
82
  ...input2.caddyPid ? { caddyPid: input2.caddyPid } : {},
83
+ ...input2.caddyPgid ? { caddyPgid: input2.caddyPgid } : {},
83
84
  ...input2.childPid ? { childPid: input2.childPid } : {},
84
85
  ...input2.childCommand ? { childCommand: input2.childCommand } : {},
85
86
  ...typeof input2.https === "boolean" ? { https: input2.https } : {},
@@ -229,25 +230,30 @@ function getProjectName(cwd = process.cwd()) {
229
230
  }
230
231
  }
231
232
  function sanitizeProjectName(value) {
232
- const projectName = value.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "");
233
+ const sanitized = value.replace(/[^\w.-]+/g, "-");
234
+ let start = 0;
235
+ let end = sanitized.length;
236
+ while (start < end && sanitized.charCodeAt(start) === 45) start += 1;
237
+ while (end > start && sanitized.charCodeAt(end - 1) === 45) end -= 1;
238
+ const projectName = sanitized.slice(start, end);
233
239
  return projectName || "app";
234
240
  }
235
241
 
236
242
  // src/context.ts
237
243
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
238
- import { join as join3 } from "path";
244
+ import { join as join4 } from "path";
239
245
  import { pathToFileURL } from "url";
240
246
 
241
247
  // src/port.ts
242
248
  import { createServer } from "net";
243
249
  async function isPortAvailable(port, host = "127.0.0.1") {
244
- return new Promise((resolve3) => {
250
+ return new Promise((resolve4) => {
245
251
  const server = createServer();
246
252
  server.once("error", () => {
247
- resolve3(false);
253
+ resolve4(false);
248
254
  });
249
255
  server.once("listening", () => {
250
- server.close(() => resolve3(true));
256
+ server.close(() => resolve4(true));
251
257
  });
252
258
  server.listen(port, host);
253
259
  });
@@ -264,6 +270,188 @@ async function findAvailablePort(startPort, options = {}) {
264
270
  throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
265
271
  }
266
272
 
273
+ // src/registry.ts
274
+ import { randomUUID } from "crypto";
275
+ import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from "fs/promises";
276
+ import { homedir as homedir2 } from "os";
277
+ import { join as join3, normalize, resolve as resolve2 } from "path";
278
+ var LOCALGHOST_REGISTRY_FILE = "registry.json";
279
+ var LOCALGHOST_REGISTRY_LOCK_FILE = "registry.lock";
280
+ function defaultProcessRunning(pid) {
281
+ if (pid <= 0) return false;
282
+ try {
283
+ process.kill(pid, 0);
284
+ return true;
285
+ } catch (error) {
286
+ return error.code === "EPERM";
287
+ }
288
+ }
289
+ function getLocalghostRegistryRoot(env = process.env) {
290
+ return resolve2(env.LOCALGHOST_HOME || join3(homedir2(), ".localghost"));
291
+ }
292
+ function canonicalizeLocalghostProjectCwd(cwd = process.cwd()) {
293
+ return normalize(resolve2(cwd));
294
+ }
295
+ function emptyRegistry() {
296
+ return { version: 1, allocations: [], leases: [] };
297
+ }
298
+ function leaseKey(projectCwd, instanceKey) {
299
+ return `${projectCwd}\0${instanceKey}`;
300
+ }
301
+ function validRegistry(value) {
302
+ if (!value || typeof value !== "object") return false;
303
+ const candidate = value;
304
+ return candidate.version === 1 && Array.isArray(candidate.allocations) && Array.isArray(candidate.leases);
305
+ }
306
+ function pruneRegistry(registry, now, isRunning) {
307
+ registry.leases = registry.leases.filter((lease) => lease.expiresAt > now && isRunning(lease.pid));
308
+ }
309
+ async function readJson(path) {
310
+ try {
311
+ return JSON.parse(await readFile(path, "utf8"));
312
+ } catch (error) {
313
+ if (error.code === "ENOENT") return void 0;
314
+ return void 0;
315
+ }
316
+ }
317
+ function createLocalghostRegistry(options = {}) {
318
+ const root = resolve2(options.stateRoot ?? getLocalghostRegistryRoot());
319
+ const registryPath = join3(root, LOCALGHOST_REGISTRY_FILE);
320
+ const lockPath = join3(root, LOCALGHOST_REGISTRY_LOCK_FILE);
321
+ const cwd = canonicalizeLocalghostProjectCwd(options.cwd);
322
+ const now = options.now ?? Date.now;
323
+ const pid = options.pid ?? process.pid;
324
+ const ownerToken = options.ownerToken ?? randomUUID();
325
+ const isRunning = options.isProcessRunning ?? defaultProcessRunning;
326
+ const availabilityCheck = options.availabilityCheck ?? isPortAvailable;
327
+ const lockTimeoutMs = options.lockTimeoutMs ?? 5e3;
328
+ const lockRetryMs = options.lockRetryMs ?? 25;
329
+ const lockStaleMs = options.lockStaleMs ?? 3e4;
330
+ async function readRegistry() {
331
+ const value = await readJson(registryPath);
332
+ return validRegistry(value) ? value : emptyRegistry();
333
+ }
334
+ async function writeRegistry(registry) {
335
+ await mkdir(root, { recursive: true });
336
+ const temporaryPath = join3(root, `.registry.${process.pid}.${randomUUID()}.tmp`);
337
+ await writeFile(temporaryPath, `${JSON.stringify(registry, null, 2)}
338
+ `, { mode: 384 });
339
+ await rename(temporaryPath, registryPath);
340
+ }
341
+ async function lock() {
342
+ await mkdir(root, { recursive: true });
343
+ const deadline = now() + lockTimeoutMs;
344
+ const token = randomUUID();
345
+ while (true) {
346
+ try {
347
+ const handle = await open(lockPath, "wx", 384);
348
+ await handle.writeFile(`${JSON.stringify({ pid, createdAt: now(), token })}
349
+ `);
350
+ await handle.close();
351
+ return async () => {
352
+ const current = await readJson(lockPath);
353
+ if (current?.token === token) await unlink(lockPath).catch(() => void 0);
354
+ };
355
+ } catch (error) {
356
+ if (error.code !== "EEXIST") throw error;
357
+ const lockInfo = await readJson(lockPath);
358
+ let stale = false;
359
+ if (lockInfo && typeof lockInfo.pid === "number") {
360
+ stale = !isRunning(lockInfo.pid) && now() - lockInfo.createdAt >= 0;
361
+ } else {
362
+ try {
363
+ stale = now() - (await stat(lockPath)).mtimeMs > lockStaleMs;
364
+ } catch {
365
+ continue;
366
+ }
367
+ }
368
+ if (stale) {
369
+ await rm(lockPath, { force: true }).catch(() => void 0);
370
+ continue;
371
+ }
372
+ if (now() >= deadline) throw new Error(`Timed out waiting for Localghost registry lock: ${lockPath}`);
373
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, lockRetryMs));
374
+ }
375
+ }
376
+ }
377
+ async function withLock(operation) {
378
+ const releaseLock = await lock();
379
+ try {
380
+ const registry = await readRegistry();
381
+ pruneRegistry(registry, now(), isRunning);
382
+ return await operation(registry);
383
+ } finally {
384
+ await releaseLock();
385
+ }
386
+ }
387
+ return {
388
+ root,
389
+ registryPath,
390
+ lockPath,
391
+ ownerToken,
392
+ read: readRegistry,
393
+ async prune() {
394
+ const releaseLock = await lock();
395
+ try {
396
+ const registry = await readRegistry();
397
+ const before = registry.leases.length;
398
+ pruneRegistry(registry, now(), isRunning);
399
+ await writeRegistry(registry);
400
+ return { removedLeases: before - registry.leases.length };
401
+ } finally {
402
+ await releaseLock();
403
+ }
404
+ },
405
+ async acquirePort(acquireOptions) {
406
+ const projectCwd = canonicalizeLocalghostProjectCwd(acquireOptions.projectCwd ?? cwd);
407
+ if (!acquireOptions.instanceKey) throw new Error("instanceKey is required");
408
+ return withLock(async (registry) => {
409
+ const key = leaseKey(projectCwd, acquireOptions.instanceKey);
410
+ const existing = registry.allocations.find((entry2) => leaseKey(entry2.projectCwd, entry2.instanceKey) === key);
411
+ const reserved = new Set(acquireOptions.reservedPorts ?? []);
412
+ const activePorts = new Set(registry.leases.map((lease2) => lease2.port));
413
+ const port = existing?.port;
414
+ const ownsActiveLease = registry.leases.some((lease2) => lease2.port === port && leaseKey(lease2.projectCwd, lease2.instanceKey) === key && lease2.ownerToken === ownerToken);
415
+ const reusable = port !== void 0 && !reserved.has(port) && (!activePorts.has(port) || ownsActiveLease) && (ownsActiveLease || await availabilityCheck(port, acquireOptions.host));
416
+ let selectedPort = reusable ? port : void 0;
417
+ if (selectedPort === void 0) {
418
+ const startPort = acquireOptions.startPort ?? 3e3;
419
+ const maxAttempts = acquireOptions.maxAttempts ?? 50;
420
+ for (let offset = 0; offset < maxAttempts; offset += 1) {
421
+ const candidate = startPort + offset;
422
+ if (reserved.has(candidate) || activePorts.has(candidate)) continue;
423
+ if (await availabilityCheck(candidate, acquireOptions.host)) {
424
+ selectedPort = candidate;
425
+ break;
426
+ }
427
+ }
428
+ if (selectedPort === void 0) throw new Error(`No available registry port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
429
+ }
430
+ const timestamp = now();
431
+ const entry = existing ?? { projectCwd, instanceKey: acquireOptions.instanceKey, port: selectedPort, updatedAt: timestamp };
432
+ entry.port = selectedPort;
433
+ entry.updatedAt = timestamp;
434
+ if (!existing) registry.allocations.push(entry);
435
+ registry.leases = registry.leases.filter((lease2) => leaseKey(lease2.projectCwd, lease2.instanceKey) !== key);
436
+ const lease = { projectCwd, instanceKey: acquireOptions.instanceKey, port: selectedPort, pid, acquiredAt: timestamp, expiresAt: timestamp + (acquireOptions.leaseTtlMs ?? 30 * 60 * 1e3), ownerToken };
437
+ registry.leases.push(lease);
438
+ await writeRegistry(registry);
439
+ return lease;
440
+ });
441
+ },
442
+ async releasePort(releaseOptions) {
443
+ const projectCwd = canonicalizeLocalghostProjectCwd(releaseOptions.projectCwd ?? cwd);
444
+ return withLock(async (registry) => {
445
+ const key = leaseKey(projectCwd, releaseOptions.instanceKey);
446
+ const before = registry.leases.length;
447
+ registry.leases = registry.leases.filter((lease) => leaseKey(lease.projectCwd, lease.instanceKey) !== key || lease.ownerToken !== ownerToken);
448
+ if (registry.leases.length !== before) await writeRegistry(registry);
449
+ return registry.leases.length !== before;
450
+ });
451
+ }
452
+ };
453
+ }
454
+
267
455
  // src/tunnel.ts
268
456
  import { domainToASCII } from "url";
269
457
  var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
@@ -641,7 +829,7 @@ function envHttps() {
641
829
  }
642
830
  function getPackageName(cwd) {
643
831
  try {
644
- const pkg = JSON.parse(readFileSync3(join3(cwd, "package.json"), "utf8"));
832
+ const pkg = JSON.parse(readFileSync3(join4(cwd, "package.json"), "utf8"));
645
833
  return typeof pkg.name === "string" ? pkg.name : void 0;
646
834
  } catch {
647
835
  return void 0;
@@ -724,7 +912,24 @@ async function resolveLocalghostContext(options = {}) {
724
912
  const autoRepair = merged.autoRepair ?? true;
725
913
  const bindHost = merged.bindHost ?? "127.0.0.1";
726
914
  const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
727
- const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
915
+ let port = requestedPort;
916
+ let releasePort;
917
+ const reservePort = merged.reservePort ?? false;
918
+ const instanceKey = merged.instanceKey ?? "run";
919
+ if (reservePort && dynamicPort) {
920
+ const registry = createLocalghostRegistry({ cwd, ...merged.registryOwnerToken ? { ownerToken: merged.registryOwnerToken } : {} });
921
+ const lease = await registry.acquirePort({
922
+ projectCwd: cwd,
923
+ instanceKey,
924
+ startPort: requestedPort,
925
+ host: probeHost,
926
+ ...options.reservedPorts ? { reservedPorts: options.reservedPorts } : {}
927
+ });
928
+ port = lease.port;
929
+ releasePort = () => registry.releasePort({ projectCwd: cwd, instanceKey });
930
+ } else if (dynamicPort) {
931
+ port = await findAvailablePort(requestedPort, { host: probeHost });
932
+ }
728
933
  const wwwAlias = merged.wwwAlias ?? true;
729
934
  const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
730
935
  const hosts = uniqueHosts(entries);
@@ -753,7 +958,8 @@ async function resolveLocalghostContext(options = {}) {
753
958
  https: merged.https ?? envHttps() ?? false,
754
959
  wwwAlias,
755
960
  ghostTunnel,
756
- ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
961
+ ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {},
962
+ ...releasePort ? { releasePort } : {}
757
963
  };
758
964
  }
759
965
 
@@ -806,7 +1012,7 @@ function writeTextFile(path, value) {
806
1012
  // src/hosts-file.ts
807
1013
  import { writeFileSync as writeFileSync3 } from "fs";
808
1014
  import { tmpdir } from "os";
809
- import { join as join4 } from "path";
1015
+ import { join as join5 } from "path";
810
1016
  import { execa as execa2 } from "execa";
811
1017
  function escapeRegExp(value) {
812
1018
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -842,7 +1048,7 @@ ${block}`;
842
1048
  }
843
1049
  async function writeSystemHostsFile(hostsPath, next, projectName) {
844
1050
  const sanitizedProjectName = sanitizeProjectName(projectName);
845
- const tempPath = join4(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
1051
+ const tempPath = join5(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
846
1052
  writeFileSync3(tempPath, next, "utf8");
847
1053
  if (process.env.LOCALGHOST_HOSTS_PATH) {
848
1054
  writeFileSync3(hostsPath, next, "utf8");
@@ -899,10 +1105,10 @@ async function ask(question, defaultValue) {
899
1105
 
900
1106
  // src/state.ts
901
1107
  import { existsSync as existsSync4 } from "fs";
902
- import { join as join5 } from "path";
1108
+ import { join as join6 } from "path";
903
1109
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
904
1110
  function getLocalghostStatePath(cwd = process.cwd()) {
905
- return join5(cwd, LOCALGHOST_STATE_FILE);
1111
+ return join6(cwd, LOCALGHOST_STATE_FILE);
906
1112
  }
907
1113
  function readLocalghostState(cwd = process.cwd()) {
908
1114
  const path = getLocalghostStatePath(cwd);
@@ -917,7 +1123,7 @@ function writeLocalghostState(cwd, state) {
917
1123
  }
918
1124
 
919
1125
  // src/caddy.ts
920
- import { dirname as dirname3, join as join6 } from "path";
1126
+ import { dirname as dirname3, join as join7 } from "path";
921
1127
  import { execa as execa3 } from "execa";
922
1128
  function shouldShowCaddyLogs() {
923
1129
  return ["1", "true", "yes", "on"].includes((process.env.LOCALGHOST_CADDY_VERBOSE ?? "").toLowerCase());
@@ -935,7 +1141,7 @@ function groupByPort(entries) {
935
1141
  return groups;
936
1142
  }
937
1143
  function getCaddyfilePath(cwd = process.cwd()) {
938
- return join6(cwd, "ops/local/Caddyfile");
1144
+ return join7(cwd, "ops/local/Caddyfile");
939
1145
  }
940
1146
  function renderCaddyfile(entries, options = {}) {
941
1147
  const groups = groupByPort(entries);
@@ -1073,12 +1279,12 @@ function getConfigWatchFiles(options) {
1073
1279
  const readOptions = readOptionsFromPlugin(options);
1074
1280
  const cwd = readOptions.cwd ?? process.cwd();
1075
1281
  const resolvedPath = resolveDevHostsPath(readOptions);
1076
- const candidatePaths = getConfigFileCandidates(readOptions).map((fileName) => resolve2(cwd, fileName));
1077
- const projectConfigPaths = options.localghostConfig === false ? [] : options.localghostConfig ? [resolve2(cwd, options.localghostConfig)] : ["localghost.config.mjs", "localghost.config.js", "localghost.config.cjs"].map((fileName) => resolve2(cwd, fileName));
1282
+ const candidatePaths = getConfigFileCandidates(readOptions).map((fileName) => resolve3(cwd, fileName));
1283
+ const projectConfigPaths = options.localghostConfig === false ? [] : options.localghostConfig ? [resolve3(cwd, options.localghostConfig)] : ["localghost.config.mjs", "localghost.config.js", "localghost.config.cjs"].map((fileName) => resolve3(cwd, fileName));
1078
1284
  return [.../* @__PURE__ */ new Set([...candidatePaths, resolvedPath.path, ...projectConfigPaths])];
1079
1285
  }
1080
1286
  function normalizeWatchPath(filePath) {
1081
- return normalize(resolve2(filePath));
1287
+ return normalize2(resolve3(filePath));
1082
1288
  }
1083
1289
  function renderConfig(hosts, port) {
1084
1290
  return [
@@ -1094,7 +1300,7 @@ function defaultHost(cwd) {
1094
1300
  }
1095
1301
  function getPackageOwner2(cwd) {
1096
1302
  try {
1097
- const pkg = JSON.parse(readFileSync5(resolve2(cwd, "package.json"), "utf8"));
1303
+ const pkg = JSON.parse(readFileSync5(resolve3(cwd, "package.json"), "utf8"));
1098
1304
  if (typeof pkg.name === "string" && pkg.name.startsWith("@")) {
1099
1305
  return pkg.name.slice(1).split("/")[0];
1100
1306
  }
@@ -1219,10 +1425,15 @@ async function ensureLocalghostContext(options, vitePort, https) {
1219
1425
  writeTextFile(resolved.path, renderConfig(hosts, vitePort));
1220
1426
  console.log(`Created ${resolved.path}`);
1221
1427
  }
1428
+ const wrapperManagedPort = Boolean(process.env.LOCALGHOST_PORT);
1222
1429
  const context = await resolveLocalghostContext({
1223
1430
  ...options,
1224
1431
  cwd,
1225
1432
  port: vitePort,
1433
+ reservePort: !wrapperManagedPort,
1434
+ instanceKey: "vite",
1435
+ registryOwnerToken: options.registryOwnerToken ?? `${process.pid}:vite:${cwd}`,
1436
+ ...wrapperManagedPort ? { dynamicPort: false } : {},
1226
1437
  ...typeof https === "boolean" ? { https } : {}
1227
1438
  });
1228
1439
  if (!hasReadySetup(cwd, context.entries, resolved.path, context.https)) {
@@ -1420,6 +1631,7 @@ function localGhostPlugin(options = {}) {
1420
1631
  const cleanup = () => {
1421
1632
  cleanupActivity();
1422
1633
  cleanupGhostMenu?.();
1634
+ void resolvedContext?.releasePort?.();
1423
1635
  };
1424
1636
  server.httpServer?.once("close", cleanup);
1425
1637
  process.once("exit", cleanup);