@hamedb89/localghost 0.1.13 → 0.1.15

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
 
@@ -229,25 +229,30 @@ function getProjectName(cwd = process.cwd()) {
229
229
  }
230
230
  }
231
231
  function sanitizeProjectName(value) {
232
- const projectName = value.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "");
232
+ const sanitized = value.replace(/[^\w.-]+/g, "-");
233
+ let start = 0;
234
+ let end = sanitized.length;
235
+ while (start < end && sanitized.charCodeAt(start) === 45) start += 1;
236
+ while (end > start && sanitized.charCodeAt(end - 1) === 45) end -= 1;
237
+ const projectName = sanitized.slice(start, end);
233
238
  return projectName || "app";
234
239
  }
235
240
 
236
241
  // src/context.ts
237
242
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
238
- import { join as join3 } from "path";
243
+ import { join as join4 } from "path";
239
244
  import { pathToFileURL } from "url";
240
245
 
241
246
  // src/port.ts
242
247
  import { createServer } from "net";
243
248
  async function isPortAvailable(port, host = "127.0.0.1") {
244
- return new Promise((resolve3) => {
249
+ return new Promise((resolve4) => {
245
250
  const server = createServer();
246
251
  server.once("error", () => {
247
- resolve3(false);
252
+ resolve4(false);
248
253
  });
249
254
  server.once("listening", () => {
250
- server.close(() => resolve3(true));
255
+ server.close(() => resolve4(true));
251
256
  });
252
257
  server.listen(port, host);
253
258
  });
@@ -264,6 +269,176 @@ async function findAvailablePort(startPort, options = {}) {
264
269
  throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
265
270
  }
266
271
 
272
+ // src/registry.ts
273
+ import { randomUUID } from "crypto";
274
+ import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from "fs/promises";
275
+ import { homedir as homedir2 } from "os";
276
+ import { join as join3, normalize, resolve as resolve2 } from "path";
277
+ var LOCALGHOST_REGISTRY_FILE = "registry.json";
278
+ var LOCALGHOST_REGISTRY_LOCK_FILE = "registry.lock";
279
+ function defaultProcessRunning(pid) {
280
+ if (pid <= 0) return false;
281
+ try {
282
+ process.kill(pid, 0);
283
+ return true;
284
+ } catch (error) {
285
+ return error.code === "EPERM";
286
+ }
287
+ }
288
+ function getLocalghostRegistryRoot(env = process.env) {
289
+ return resolve2(env.LOCALGHOST_HOME || join3(homedir2(), ".localghost"));
290
+ }
291
+ function canonicalizeLocalghostProjectCwd(cwd = process.cwd()) {
292
+ return normalize(resolve2(cwd));
293
+ }
294
+ function emptyRegistry() {
295
+ return { version: 1, allocations: [], leases: [] };
296
+ }
297
+ function leaseKey(projectCwd, instanceKey) {
298
+ return `${projectCwd}\0${instanceKey}`;
299
+ }
300
+ function validRegistry(value) {
301
+ if (!value || typeof value !== "object") return false;
302
+ const candidate = value;
303
+ return candidate.version === 1 && Array.isArray(candidate.allocations) && Array.isArray(candidate.leases);
304
+ }
305
+ function pruneRegistry(registry, now, isRunning) {
306
+ registry.leases = registry.leases.filter((lease) => lease.expiresAt > now && isRunning(lease.pid));
307
+ }
308
+ async function readJson(path) {
309
+ try {
310
+ return JSON.parse(await readFile(path, "utf8"));
311
+ } catch (error) {
312
+ if (error.code === "ENOENT") return void 0;
313
+ return void 0;
314
+ }
315
+ }
316
+ function createLocalghostRegistry(options = {}) {
317
+ const root = resolve2(options.stateRoot ?? getLocalghostRegistryRoot());
318
+ const registryPath = join3(root, LOCALGHOST_REGISTRY_FILE);
319
+ const lockPath = join3(root, LOCALGHOST_REGISTRY_LOCK_FILE);
320
+ const cwd = canonicalizeLocalghostProjectCwd(options.cwd);
321
+ const now = options.now ?? Date.now;
322
+ const pid = options.pid ?? process.pid;
323
+ const ownerToken = options.ownerToken ?? randomUUID();
324
+ const isRunning = options.isProcessRunning ?? defaultProcessRunning;
325
+ const availabilityCheck = options.availabilityCheck ?? isPortAvailable;
326
+ const lockTimeoutMs = options.lockTimeoutMs ?? 5e3;
327
+ const lockRetryMs = options.lockRetryMs ?? 25;
328
+ const lockStaleMs = options.lockStaleMs ?? 3e4;
329
+ async function readRegistry() {
330
+ const value = await readJson(registryPath);
331
+ return validRegistry(value) ? value : emptyRegistry();
332
+ }
333
+ async function writeRegistry(registry) {
334
+ await mkdir(root, { recursive: true });
335
+ const temporaryPath = join3(root, `.registry.${process.pid}.${randomUUID()}.tmp`);
336
+ await writeFile(temporaryPath, `${JSON.stringify(registry, null, 2)}
337
+ `, { mode: 384 });
338
+ await rename(temporaryPath, registryPath);
339
+ }
340
+ async function lock() {
341
+ await mkdir(root, { recursive: true });
342
+ const deadline = now() + lockTimeoutMs;
343
+ const token = randomUUID();
344
+ while (true) {
345
+ try {
346
+ const handle = await open(lockPath, "wx", 384);
347
+ await handle.writeFile(`${JSON.stringify({ pid, createdAt: now(), token })}
348
+ `);
349
+ await handle.close();
350
+ return async () => {
351
+ const current = await readJson(lockPath);
352
+ if (current?.token === token) await unlink(lockPath).catch(() => void 0);
353
+ };
354
+ } catch (error) {
355
+ if (error.code !== "EEXIST") throw error;
356
+ const lockInfo = await readJson(lockPath);
357
+ let stale = false;
358
+ if (lockInfo && typeof lockInfo.pid === "number") {
359
+ stale = !isRunning(lockInfo.pid) && now() - lockInfo.createdAt >= 0;
360
+ } else {
361
+ try {
362
+ stale = now() - (await stat(lockPath)).mtimeMs > lockStaleMs;
363
+ } catch {
364
+ continue;
365
+ }
366
+ }
367
+ if (stale) {
368
+ await rm(lockPath, { force: true }).catch(() => void 0);
369
+ continue;
370
+ }
371
+ if (now() >= deadline) throw new Error(`Timed out waiting for Localghost registry lock: ${lockPath}`);
372
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, lockRetryMs));
373
+ }
374
+ }
375
+ }
376
+ async function withLock(operation) {
377
+ const releaseLock = await lock();
378
+ try {
379
+ const registry = await readRegistry();
380
+ pruneRegistry(registry, now(), isRunning);
381
+ return await operation(registry);
382
+ } finally {
383
+ await releaseLock();
384
+ }
385
+ }
386
+ return {
387
+ root,
388
+ registryPath,
389
+ lockPath,
390
+ ownerToken,
391
+ read: readRegistry,
392
+ async acquirePort(acquireOptions) {
393
+ const projectCwd = canonicalizeLocalghostProjectCwd(acquireOptions.projectCwd ?? cwd);
394
+ if (!acquireOptions.instanceKey) throw new Error("instanceKey is required");
395
+ return withLock(async (registry) => {
396
+ const key = leaseKey(projectCwd, acquireOptions.instanceKey);
397
+ const existing = registry.allocations.find((entry2) => leaseKey(entry2.projectCwd, entry2.instanceKey) === key);
398
+ const reserved = new Set(acquireOptions.reservedPorts ?? []);
399
+ const activePorts = new Set(registry.leases.map((lease2) => lease2.port));
400
+ const port = existing?.port;
401
+ const ownsActiveLease = registry.leases.some((lease2) => lease2.port === port && leaseKey(lease2.projectCwd, lease2.instanceKey) === key && lease2.ownerToken === ownerToken);
402
+ const reusable = port !== void 0 && !reserved.has(port) && (!activePorts.has(port) || ownsActiveLease) && (ownsActiveLease || await availabilityCheck(port, acquireOptions.host));
403
+ let selectedPort = reusable ? port : void 0;
404
+ if (selectedPort === void 0) {
405
+ const startPort = acquireOptions.startPort ?? 3e3;
406
+ const maxAttempts = acquireOptions.maxAttempts ?? 50;
407
+ for (let offset = 0; offset < maxAttempts; offset += 1) {
408
+ const candidate = startPort + offset;
409
+ if (reserved.has(candidate) || activePorts.has(candidate)) continue;
410
+ if (await availabilityCheck(candidate, acquireOptions.host)) {
411
+ selectedPort = candidate;
412
+ break;
413
+ }
414
+ }
415
+ if (selectedPort === void 0) throw new Error(`No available registry port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
416
+ }
417
+ const timestamp = now();
418
+ const entry = existing ?? { projectCwd, instanceKey: acquireOptions.instanceKey, port: selectedPort, updatedAt: timestamp };
419
+ entry.port = selectedPort;
420
+ entry.updatedAt = timestamp;
421
+ if (!existing) registry.allocations.push(entry);
422
+ registry.leases = registry.leases.filter((lease2) => leaseKey(lease2.projectCwd, lease2.instanceKey) !== key);
423
+ const lease = { projectCwd, instanceKey: acquireOptions.instanceKey, port: selectedPort, pid, acquiredAt: timestamp, expiresAt: timestamp + (acquireOptions.leaseTtlMs ?? 30 * 60 * 1e3), ownerToken };
424
+ registry.leases.push(lease);
425
+ await writeRegistry(registry);
426
+ return lease;
427
+ });
428
+ },
429
+ async releasePort(releaseOptions) {
430
+ const projectCwd = canonicalizeLocalghostProjectCwd(releaseOptions.projectCwd ?? cwd);
431
+ return withLock(async (registry) => {
432
+ const key = leaseKey(projectCwd, releaseOptions.instanceKey);
433
+ const before = registry.leases.length;
434
+ registry.leases = registry.leases.filter((lease) => leaseKey(lease.projectCwd, lease.instanceKey) !== key || lease.ownerToken !== ownerToken);
435
+ if (registry.leases.length !== before) await writeRegistry(registry);
436
+ return registry.leases.length !== before;
437
+ });
438
+ }
439
+ };
440
+ }
441
+
267
442
  // src/tunnel.ts
268
443
  import { domainToASCII } from "url";
269
444
  var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
@@ -641,7 +816,7 @@ function envHttps() {
641
816
  }
642
817
  function getPackageName(cwd) {
643
818
  try {
644
- const pkg = JSON.parse(readFileSync3(join3(cwd, "package.json"), "utf8"));
819
+ const pkg = JSON.parse(readFileSync3(join4(cwd, "package.json"), "utf8"));
645
820
  return typeof pkg.name === "string" ? pkg.name : void 0;
646
821
  } catch {
647
822
  return void 0;
@@ -724,7 +899,24 @@ async function resolveLocalghostContext(options = {}) {
724
899
  const autoRepair = merged.autoRepair ?? true;
725
900
  const bindHost = merged.bindHost ?? "127.0.0.1";
726
901
  const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
727
- const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
902
+ let port = requestedPort;
903
+ let releasePort;
904
+ const reservePort = merged.reservePort ?? false;
905
+ const instanceKey = merged.instanceKey ?? "run";
906
+ if (reservePort && dynamicPort) {
907
+ const registry = createLocalghostRegistry({ cwd, ...merged.registryOwnerToken ? { ownerToken: merged.registryOwnerToken } : {} });
908
+ const lease = await registry.acquirePort({
909
+ projectCwd: cwd,
910
+ instanceKey,
911
+ startPort: requestedPort,
912
+ host: probeHost,
913
+ ...options.reservedPorts ? { reservedPorts: options.reservedPorts } : {}
914
+ });
915
+ port = lease.port;
916
+ releasePort = () => registry.releasePort({ projectCwd: cwd, instanceKey });
917
+ } else if (dynamicPort) {
918
+ port = await findAvailablePort(requestedPort, { host: probeHost });
919
+ }
728
920
  const wwwAlias = merged.wwwAlias ?? true;
729
921
  const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
730
922
  const hosts = uniqueHosts(entries);
@@ -753,7 +945,8 @@ async function resolveLocalghostContext(options = {}) {
753
945
  https: merged.https ?? envHttps() ?? false,
754
946
  wwwAlias,
755
947
  ghostTunnel,
756
- ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
948
+ ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {},
949
+ ...releasePort ? { releasePort } : {}
757
950
  };
758
951
  }
759
952
 
@@ -806,7 +999,7 @@ function writeTextFile(path, value) {
806
999
  // src/hosts-file.ts
807
1000
  import { writeFileSync as writeFileSync3 } from "fs";
808
1001
  import { tmpdir } from "os";
809
- import { join as join4 } from "path";
1002
+ import { join as join5 } from "path";
810
1003
  import { execa as execa2 } from "execa";
811
1004
  function escapeRegExp(value) {
812
1005
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -842,7 +1035,7 @@ ${block}`;
842
1035
  }
843
1036
  async function writeSystemHostsFile(hostsPath, next, projectName) {
844
1037
  const sanitizedProjectName = sanitizeProjectName(projectName);
845
- const tempPath = join4(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
1038
+ const tempPath = join5(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
846
1039
  writeFileSync3(tempPath, next, "utf8");
847
1040
  if (process.env.LOCALGHOST_HOSTS_PATH) {
848
1041
  writeFileSync3(hostsPath, next, "utf8");
@@ -899,10 +1092,10 @@ async function ask(question, defaultValue) {
899
1092
 
900
1093
  // src/state.ts
901
1094
  import { existsSync as existsSync4 } from "fs";
902
- import { join as join5 } from "path";
1095
+ import { join as join6 } from "path";
903
1096
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
904
1097
  function getLocalghostStatePath(cwd = process.cwd()) {
905
- return join5(cwd, LOCALGHOST_STATE_FILE);
1098
+ return join6(cwd, LOCALGHOST_STATE_FILE);
906
1099
  }
907
1100
  function readLocalghostState(cwd = process.cwd()) {
908
1101
  const path = getLocalghostStatePath(cwd);
@@ -917,7 +1110,7 @@ function writeLocalghostState(cwd, state) {
917
1110
  }
918
1111
 
919
1112
  // src/caddy.ts
920
- import { dirname as dirname3, join as join6 } from "path";
1113
+ import { dirname as dirname3, join as join7 } from "path";
921
1114
  import { execa as execa3 } from "execa";
922
1115
  function shouldShowCaddyLogs() {
923
1116
  return ["1", "true", "yes", "on"].includes((process.env.LOCALGHOST_CADDY_VERBOSE ?? "").toLowerCase());
@@ -935,7 +1128,7 @@ function groupByPort(entries) {
935
1128
  return groups;
936
1129
  }
937
1130
  function getCaddyfilePath(cwd = process.cwd()) {
938
- return join6(cwd, "ops/local/Caddyfile");
1131
+ return join7(cwd, "ops/local/Caddyfile");
939
1132
  }
940
1133
  function renderCaddyfile(entries, options = {}) {
941
1134
  const groups = groupByPort(entries);
@@ -1073,12 +1266,12 @@ function getConfigWatchFiles(options) {
1073
1266
  const readOptions = readOptionsFromPlugin(options);
1074
1267
  const cwd = readOptions.cwd ?? process.cwd();
1075
1268
  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));
1269
+ const candidatePaths = getConfigFileCandidates(readOptions).map((fileName) => resolve3(cwd, fileName));
1270
+ 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
1271
  return [.../* @__PURE__ */ new Set([...candidatePaths, resolvedPath.path, ...projectConfigPaths])];
1079
1272
  }
1080
1273
  function normalizeWatchPath(filePath) {
1081
- return normalize(resolve2(filePath));
1274
+ return normalize2(resolve3(filePath));
1082
1275
  }
1083
1276
  function renderConfig(hosts, port) {
1084
1277
  return [
@@ -1094,7 +1287,7 @@ function defaultHost(cwd) {
1094
1287
  }
1095
1288
  function getPackageOwner2(cwd) {
1096
1289
  try {
1097
- const pkg = JSON.parse(readFileSync5(resolve2(cwd, "package.json"), "utf8"));
1290
+ const pkg = JSON.parse(readFileSync5(resolve3(cwd, "package.json"), "utf8"));
1098
1291
  if (typeof pkg.name === "string" && pkg.name.startsWith("@")) {
1099
1292
  return pkg.name.slice(1).split("/")[0];
1100
1293
  }
@@ -1219,10 +1412,15 @@ async function ensureLocalghostContext(options, vitePort, https) {
1219
1412
  writeTextFile(resolved.path, renderConfig(hosts, vitePort));
1220
1413
  console.log(`Created ${resolved.path}`);
1221
1414
  }
1415
+ const wrapperManagedPort = Boolean(process.env.LOCALGHOST_PORT);
1222
1416
  const context = await resolveLocalghostContext({
1223
1417
  ...options,
1224
1418
  cwd,
1225
1419
  port: vitePort,
1420
+ reservePort: !wrapperManagedPort,
1421
+ instanceKey: "vite",
1422
+ registryOwnerToken: options.registryOwnerToken ?? `${process.pid}:vite:${cwd}`,
1423
+ ...wrapperManagedPort ? { dynamicPort: false } : {},
1226
1424
  ...typeof https === "boolean" ? { https } : {}
1227
1425
  });
1228
1426
  if (!hasReadySetup(cwd, context.entries, resolved.path, context.https)) {
@@ -1420,6 +1618,7 @@ function localGhostPlugin(options = {}) {
1420
1618
  const cleanup = () => {
1421
1619
  cleanupActivity();
1422
1620
  cleanupGhostMenu?.();
1621
+ void resolvedContext?.releasePort?.();
1423
1622
  };
1424
1623
  server.httpServer?.once("close", cleanup);
1425
1624
  process.once("exit", cleanup);