vite-plugin-caddy-multiple-tls 1.6.1 → 1.7.1

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.
Files changed (3) hide show
  1. package/README.md +12 -0
  2. package/dist/index.js +433 -39
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -112,6 +112,10 @@ export default config;
112
112
 
113
113
  This derives a host like `<repo>.<branch>.web-1.localhost`.
114
114
 
115
+ The plugin now treats hostname ownership as explicit. If another live Vite server already owns the resolved domain, it will refuse takeover instead of deleting the other server's route. Use `instanceLabel`, `domain`, or stop the other server first.
116
+
117
+ If a previous Vite process crashed and left stale ownership behind, the plugin will reclaim it automatically and clean up the stale Caddy route before continuing.
118
+
115
119
  For a zero-config experience, use `baseDomain: 'localhost'` (the default) so the derived domain works without editing `/etc/hosts`.
116
120
 
117
121
  `internalTls` defaults to `true` when you pass `baseDomain` or `domain`. You can override it if needed.
@@ -157,6 +161,14 @@ export default config;
157
161
 
158
162
  ## Troubleshooting
159
163
 
164
+ ### `Cannot claim ... another Vite server already owns this domain`
165
+
166
+ This means another live dev server is already using the resolved hostname.
167
+
168
+ - Stop the other server if you want this one to use the same host.
169
+ - Add `instanceLabel` if both servers should run at the same time.
170
+ - Pass an explicit `domain` if you want total control over the hostname.
171
+
160
172
  ### `client is not allowed to access from origin ''`
161
173
 
162
174
  This error comes from Caddy Admin API origin enforcement, not from Caddy being down.
package/dist/index.js CHANGED
@@ -1,17 +1,23 @@
1
1
  // src/index.ts
2
2
  import { execSync as execSync2 } from "child_process";
3
- import { createHash as createHash2 } from "crypto";
3
+ import { randomUUID } from "crypto";
4
4
  import path2 from "path";
5
5
 
6
6
  // src/utils.ts
7
7
  import { execSync } from "child_process";
8
8
  import { createHash } from "crypto";
9
- import { open, unlink } from "fs/promises";
9
+ import { mkdir, open, readFile, readdir, rename, unlink, writeFile } from "fs/promises";
10
10
  import os from "os";
11
11
  import path from "path";
12
12
  var DEFAULT_SERVER_NAME = "srv0";
13
13
  var DEFAULT_CADDY_API_URL = "http://localhost:2019";
14
14
  var CADDY_ADMIN_ORIGIN_POLICY_ERROR_MESSAGE = "Caddy Admin API rejected request due to origin policy. Check caddyApiUrl and admin origin settings.";
15
+ var ROUTE_ID_PREFIX = "vite-proxy-";
16
+ var LOCK_TIMEOUT_MS = 5e3;
17
+ var LOCK_RETRY_MS = 50;
18
+ var ROUTE_OWNERSHIP_VERSION = 1;
19
+ var ROUTE_OWNERSHIP_STALE_AFTER_MS = 3e4;
20
+ var ROUTE_OWNERSHIP_HEARTBEAT_INTERVAL_MS = 1e4;
15
21
  var CONNECTIVITY_ERROR_CODES = /* @__PURE__ */ new Set([
16
22
  "ECONNREFUSED",
17
23
  "ECONNRESET",
@@ -26,6 +32,57 @@ var CONNECTIVITY_ERROR_CODES = /* @__PURE__ */ new Set([
26
32
  function isRecord(value) {
27
33
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
28
34
  }
35
+ function normalizeRouteOwnershipDomains(domains) {
36
+ return Array.from(new Set(domains)).sort();
37
+ }
38
+ function getRouteOwnershipDirectory() {
39
+ return path.join(os.tmpdir(), "vite-plugin-caddy-multiple-tls", "owners");
40
+ }
41
+ function getRouteOwnershipPaths(scope) {
42
+ const key = createHash("sha1").update(
43
+ JSON.stringify({
44
+ domains: normalizeRouteOwnershipDomains(scope.domains),
45
+ serverName: scope.serverName,
46
+ caddyApiUrl: scope.caddyApiUrl
47
+ })
48
+ ).digest("hex").slice(0, 20);
49
+ const scopeLockKey = createHash("sha1").update(
50
+ JSON.stringify({
51
+ serverName: scope.serverName,
52
+ caddyApiUrl: scope.caddyApiUrl
53
+ })
54
+ ).digest("hex").slice(0, 20);
55
+ const directory = getRouteOwnershipDirectory();
56
+ return {
57
+ directory,
58
+ recordPath: path.join(directory, `${key}.json`),
59
+ lockPath: path.join(directory, `scope-${scopeLockKey}.lock`)
60
+ };
61
+ }
62
+ function isRouteOwnershipRecord(value) {
63
+ if (!isRecord(value)) return false;
64
+ if (value.version !== ROUTE_OWNERSHIP_VERSION) return false;
65
+ if (typeof value.ownerId !== "string" || !value.ownerId) return false;
66
+ if (typeof value.pid !== "number" || !Number.isFinite(value.pid)) return false;
67
+ if (typeof value.cwd !== "string") return false;
68
+ if (value.configRoot !== null && typeof value.configRoot !== "string") return false;
69
+ if (!Array.isArray(value.domains) || value.domains.some((domain) => typeof domain !== "string")) {
70
+ return false;
71
+ }
72
+ if (typeof value.routeId !== "string" || !value.routeId) return false;
73
+ if (value.tlsPolicyId !== null && typeof value.tlsPolicyId !== "string") return false;
74
+ if (typeof value.serverName !== "string" || !value.serverName) return false;
75
+ if (typeof value.caddyApiUrl !== "string" || !value.caddyApiUrl) return false;
76
+ if (typeof value.startedAt !== "number" || !Number.isFinite(value.startedAt)) return false;
77
+ if (typeof value.lastSeenAt !== "number" || !Number.isFinite(value.lastSeenAt)) return false;
78
+ return true;
79
+ }
80
+ function normalizeRouteOwnershipRecord(record) {
81
+ return {
82
+ ...record,
83
+ domains: normalizeRouteOwnershipDomains(record.domains)
84
+ };
85
+ }
29
86
  function parseConfig(text) {
30
87
  if (!text.trim()) return {};
31
88
  try {
@@ -59,6 +116,9 @@ function buildCaddyRequestError(message, status, text) {
59
116
  }
60
117
  return new Error(`${message}: ${normalizedText}`);
61
118
  }
119
+ function isNodeError(error) {
120
+ return Boolean(error) && typeof error === "object" && "code" in error;
121
+ }
62
122
  function getErrorCode(error) {
63
123
  if (!error || typeof error !== "object") return void 0;
64
124
  if ("code" in error && typeof error.code === "string") {
@@ -131,10 +191,9 @@ function getLockPath(apiUrl) {
131
191
  function sleep(ms) {
132
192
  return new Promise((resolve) => setTimeout(resolve, ms));
133
193
  }
134
- async function withApiLock(apiUrl, fn) {
135
- const lockPath = getLockPath(apiUrl);
194
+ async function withFileLock(lockPath, fn) {
195
+ await mkdir(path.dirname(lockPath), { recursive: true });
136
196
  const startedAt = Date.now();
137
- const timeoutMs = 5e3;
138
197
  while (true) {
139
198
  try {
140
199
  const handle = await open(lockPath, "wx");
@@ -146,16 +205,159 @@ async function withApiLock(apiUrl, fn) {
146
205
  }
147
206
  return;
148
207
  } catch (e) {
149
- if (e.code !== "EEXIST") {
208
+ if (!isNodeError(e) || e.code !== "EEXIST") {
150
209
  throw e;
151
210
  }
152
- if (Date.now() - startedAt >= timeoutMs) {
211
+ if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
153
212
  await fn();
154
213
  return;
155
214
  }
156
- await sleep(50);
215
+ await sleep(LOCK_RETRY_MS);
216
+ }
217
+ }
218
+ }
219
+ async function withApiLock(apiUrl, fn) {
220
+ await withFileLock(getLockPath(apiUrl), fn);
221
+ }
222
+ async function readRouteOwnershipByPath(recordPath) {
223
+ try {
224
+ const text = await readFile(recordPath, "utf8");
225
+ const parsed = parseConfig(text);
226
+ if (!isRouteOwnershipRecord(parsed)) return null;
227
+ return normalizeRouteOwnershipRecord(parsed);
228
+ } catch (e) {
229
+ if (isNodeError(e) && e.code === "ENOENT") {
230
+ return null;
231
+ }
232
+ throw e;
233
+ }
234
+ }
235
+ async function writeRouteOwnership(record) {
236
+ const normalizedRecord = normalizeRouteOwnershipRecord(record);
237
+ const { directory, recordPath } = getRouteOwnershipPaths(normalizedRecord);
238
+ const tempPath = path.join(
239
+ directory,
240
+ `${path.basename(recordPath)}.${process.pid}.${Date.now()}.tmp`
241
+ );
242
+ await mkdir(directory, { recursive: true });
243
+ await writeFile(tempPath, JSON.stringify(normalizedRecord), "utf8");
244
+ await rename(tempPath, recordPath);
245
+ }
246
+ async function listRouteOwnershipRecords(scope) {
247
+ const directory = getRouteOwnershipDirectory();
248
+ let entries;
249
+ try {
250
+ entries = await readdir(directory);
251
+ } catch (e) {
252
+ if (isNodeError(e) && e.code === "ENOENT") {
253
+ return [];
254
+ }
255
+ throw e;
256
+ }
257
+ const records = await Promise.all(
258
+ entries.filter((entry) => entry.endsWith(".json")).map((entry) => readRouteOwnershipByPath(path.join(directory, entry)))
259
+ );
260
+ return records.filter((record) => {
261
+ return Boolean(
262
+ record && record.serverName === scope.serverName && record.caddyApiUrl === scope.caddyApiUrl
263
+ );
264
+ });
265
+ }
266
+ function isProcessAlive(pid) {
267
+ try {
268
+ process.kill(pid, 0);
269
+ return true;
270
+ } catch (e) {
271
+ return isNodeError(e) && e.code === "EPERM";
272
+ }
273
+ }
274
+ function isRouteOwnershipActive(record, now = Date.now()) {
275
+ if (isProcessAlive(record.pid)) {
276
+ return true;
277
+ }
278
+ return record.pid <= 0 && now - record.lastSeenAt <= ROUTE_OWNERSHIP_STALE_AFTER_MS;
279
+ }
280
+ async function claimRouteOwnership(record) {
281
+ const normalizedRecord = normalizeRouteOwnershipRecord(record);
282
+ const { lockPath, recordPath } = getRouteOwnershipPaths(normalizedRecord);
283
+ let claimResult = null;
284
+ await withFileLock(lockPath, async () => {
285
+ const existingRecord = await readRouteOwnershipByPath(recordPath);
286
+ if (existingRecord?.ownerId === normalizedRecord.ownerId) {
287
+ await writeRouteOwnership(normalizedRecord);
288
+ claimResult = {
289
+ status: "claimed",
290
+ currentRecord: normalizedRecord
291
+ };
292
+ return;
293
+ }
294
+ const overlappingRecords = (await listRouteOwnershipRecords(normalizedRecord)).filter(
295
+ (candidate) => {
296
+ return candidate.ownerId !== normalizedRecord.ownerId && intersectsDomains(candidate.domains, normalizedRecord.domains);
297
+ }
298
+ );
299
+ const activeConflict = overlappingRecords.find((candidate) => {
300
+ return isRouteOwnershipActive(candidate);
301
+ });
302
+ if (activeConflict) {
303
+ claimResult = {
304
+ status: "active-conflict",
305
+ currentRecord: normalizedRecord,
306
+ existingRecord: activeConflict
307
+ };
308
+ return;
157
309
  }
310
+ await writeRouteOwnership(normalizedRecord);
311
+ if (overlappingRecords.length > 0) {
312
+ claimResult = {
313
+ status: "reclaimed",
314
+ currentRecord: normalizedRecord,
315
+ previousRecords: overlappingRecords
316
+ };
317
+ return;
318
+ }
319
+ claimResult = {
320
+ status: "claimed",
321
+ currentRecord: normalizedRecord
322
+ };
323
+ });
324
+ if (!claimResult) {
325
+ throw new Error("Failed to claim route ownership.");
158
326
  }
327
+ return claimResult;
328
+ }
329
+ async function touchRouteOwnership(reference) {
330
+ const { lockPath, recordPath } = getRouteOwnershipPaths(reference);
331
+ let touched = false;
332
+ await withFileLock(lockPath, async () => {
333
+ const existingRecord = await readRouteOwnershipByPath(recordPath);
334
+ if (!existingRecord || existingRecord.ownerId !== reference.ownerId) {
335
+ return;
336
+ }
337
+ await writeRouteOwnership({
338
+ ...existingRecord,
339
+ lastSeenAt: Date.now()
340
+ });
341
+ touched = true;
342
+ });
343
+ return touched;
344
+ }
345
+ async function releaseRouteOwnership(reference) {
346
+ const { lockPath, recordPath } = getRouteOwnershipPaths(reference);
347
+ let released = false;
348
+ await withFileLock(lockPath, async () => {
349
+ const existingRecord = await readRouteOwnershipByPath(recordPath);
350
+ if (!existingRecord || existingRecord.ownerId !== reference.ownerId) {
351
+ return;
352
+ }
353
+ await unlink(recordPath).catch((error) => {
354
+ if (!isNodeError(error) || error.code !== "ENOENT") {
355
+ throw error;
356
+ }
357
+ });
358
+ released = true;
359
+ });
360
+ return released;
159
361
  }
160
362
  function validateCaddyIsInstalled() {
161
363
  try {
@@ -403,33 +605,68 @@ function extractMatchedHosts(route) {
403
605
  }
404
606
  return hosts;
405
607
  }
608
+ function extractMatchedSubjects(policy) {
609
+ if (!isRecord(policy) || !Array.isArray(policy.subjects)) return [];
610
+ const subjects = [];
611
+ for (const subject of policy.subjects) {
612
+ if (typeof subject === "string") {
613
+ subjects.push(subject);
614
+ }
615
+ }
616
+ return subjects;
617
+ }
406
618
  function intersectsDomains(targetDomains, routeDomains) {
407
619
  if (targetDomains.length === 0 || routeDomains.length === 0) return false;
408
620
  const targetSet = new Set(targetDomains);
409
621
  return routeDomains.some((domain) => targetSet.has(domain));
410
622
  }
411
- async function cleanupStaleRoutesForDomains(domains, currentRouteId, serverName = DEFAULT_SERVER_NAME, apiUrl, adminOrigin) {
412
- if (domains.length === 0) return;
623
+ async function findManagedRoutesForDomains(domains, serverName = DEFAULT_SERVER_NAME, apiUrl, adminOrigin) {
624
+ if (domains.length === 0) return [];
413
625
  const res = await caddyFetch(
414
626
  `${getApiUrl(apiUrl)}/config/apps/http/servers/${serverName}/routes`,
415
627
  void 0,
416
628
  apiUrl,
417
629
  adminOrigin
418
630
  );
419
- if (!res.ok) return;
631
+ if (!res.ok) return [];
420
632
  const text = await res.text();
421
633
  const parsed = parseConfig(text);
422
- if (!Array.isArray(parsed)) return;
634
+ if (!Array.isArray(parsed)) return [];
635
+ const routeIds = [];
423
636
  for (const route of parsed) {
424
637
  if (!isRecord(route)) continue;
425
638
  const id = route["@id"];
426
639
  if (typeof id !== "string") continue;
427
- if (!id.startsWith("vite-proxy-")) continue;
428
- if (id === currentRouteId) continue;
640
+ if (!id.startsWith(ROUTE_ID_PREFIX)) continue;
429
641
  const routeDomains = extractMatchedHosts(route);
430
642
  if (!intersectsDomains(domains, routeDomains)) continue;
431
- await removeRoute(id, apiUrl, adminOrigin);
643
+ routeIds.push(id);
432
644
  }
645
+ return routeIds;
646
+ }
647
+ async function findManagedTlsPoliciesForDomains(domains, apiUrl, adminOrigin) {
648
+ if (domains.length === 0) return [];
649
+ const res = await caddyFetch(
650
+ `${getApiUrl(apiUrl)}/config/apps/tls/automation/policies`,
651
+ void 0,
652
+ apiUrl,
653
+ adminOrigin
654
+ );
655
+ if (!res.ok) return [];
656
+ const text = await res.text();
657
+ const parsed = parseConfig(text);
658
+ if (!Array.isArray(parsed)) return [];
659
+ const policyIds = [];
660
+ for (const policy of parsed) {
661
+ if (!isRecord(policy)) continue;
662
+ const id = policy["@id"];
663
+ if (typeof id !== "string") continue;
664
+ if (!id.startsWith(ROUTE_ID_PREFIX)) continue;
665
+ const policyDomains = extractMatchedSubjects(policy);
666
+ if (!intersectsDomains(domains, policyDomains)) continue;
667
+ policyIds.push(id);
668
+ }
669
+ return policyIds;
433
670
  }
434
671
  async function addRoute(id, domains, port, cors, serverName = DEFAULT_SERVER_NAME, upstreamHost = "127.0.0.1", upstreamHostHeader, apiUrl, adminOrigin) {
435
672
  const handlers = [];
@@ -718,13 +955,82 @@ function viteCaddyTlsPlugin({
718
955
  `caddyAdminOrigin is invalid. Falling back to ${pluginCaddyApiUrl}.`
719
956
  );
720
957
  }
721
- function getInstanceKey(domains, configRoot) {
722
- const keyMaterial = JSON.stringify({
723
- domains: [...domains].sort(),
958
+ function createOwnerId() {
959
+ return `${process.pid}-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
960
+ }
961
+ function createRouteOwnershipRecord(ownerId, domains, configRoot) {
962
+ const routeId = `vite-proxy-${ownerId}`;
963
+ const shouldUseInternalTls = internalTls ?? (baseDomain !== void 0 || loopbackDomain !== void 0 || domain !== void 0);
964
+ const now = Date.now();
965
+ return {
966
+ version: 1,
967
+ ownerId,
968
+ pid: process.pid,
724
969
  cwd: process.cwd(),
725
- root: configRoot ?? null
726
- });
727
- return createHash2("sha1").update(keyMaterial).digest("hex").slice(0, 12);
970
+ configRoot: configRoot ?? null,
971
+ domains: [...domains],
972
+ routeId,
973
+ tlsPolicyId: shouldUseInternalTls ? `${routeId}-tls` : null,
974
+ serverName: serverName ?? "srv0",
975
+ caddyApiUrl: pluginCaddyApiUrl,
976
+ startedAt: now,
977
+ lastSeenAt: now
978
+ };
979
+ }
980
+ function buildOwnershipConflictMessage(domains, existingRecord) {
981
+ const ownerLocation = existingRecord.configRoot ?? existingRecord.cwd;
982
+ const domainLabel = domains.join(", ");
983
+ return [
984
+ `Cannot claim ${domainLabel}: another Vite server already owns ${domains.length > 1 ? "these domains" : "this domain"}.`,
985
+ `Existing owner pid ${existingRecord.pid} from ${ownerLocation}.`,
986
+ "Stop the other server first, or use `instanceLabel` or `domain` to make the hostname unique."
987
+ ].join(" ");
988
+ }
989
+ async function releaseOwnershipRecord(record) {
990
+ if (!record) return;
991
+ await releaseRouteOwnership(record);
992
+ }
993
+ async function releaseOwnershipRecords(records) {
994
+ await Promise.all(records.map((record) => releaseOwnershipRecord(record)));
995
+ }
996
+ async function cleanupClaimedResources(record, removeWithRetry) {
997
+ let cleaned = true;
998
+ if (record.tlsPolicyId) {
999
+ const tlsPolicyId = record.tlsPolicyId;
1000
+ cleaned = await removeWithRetry(
1001
+ () => removeTlsPolicy(
1002
+ tlsPolicyId,
1003
+ pluginCaddyApiUrl,
1004
+ pluginCaddyAdminOrigin
1005
+ ),
1006
+ "TLS policy"
1007
+ ) && cleaned;
1008
+ }
1009
+ cleaned = await removeWithRetry(
1010
+ () => removeRoute(record.routeId, pluginCaddyApiUrl, pluginCaddyAdminOrigin),
1011
+ "route"
1012
+ ) && cleaned;
1013
+ return cleaned;
1014
+ }
1015
+ async function cleanupManagedResources(routeIds, tlsPolicyIds, removeWithRetry) {
1016
+ let cleaned = true;
1017
+ for (const managedTlsPolicyId of tlsPolicyIds) {
1018
+ cleaned = await removeWithRetry(
1019
+ () => removeTlsPolicy(
1020
+ managedTlsPolicyId,
1021
+ pluginCaddyApiUrl,
1022
+ pluginCaddyAdminOrigin
1023
+ ),
1024
+ `managed TLS policy ${managedTlsPolicyId}`
1025
+ ) && cleaned;
1026
+ }
1027
+ for (const managedRouteId of routeIds) {
1028
+ cleaned = await removeWithRetry(
1029
+ () => removeRoute(managedRouteId, pluginCaddyApiUrl, pluginCaddyAdminOrigin),
1030
+ `managed route ${managedRouteId}`
1031
+ ) && cleaned;
1032
+ }
1033
+ return cleaned;
728
1034
  }
729
1035
  function isPreviewServer(server) {
730
1036
  return server.config.isProduction;
@@ -757,13 +1063,16 @@ function viteCaddyTlsPlugin({
757
1063
  instanceLabel
758
1064
  });
759
1065
  const domainArray = resolvedDomains ?? [];
760
- const routeId = `vite-proxy-${getInstanceKey(domainArray, config.root)}`;
761
- const shouldUseInternalTls = internalTls ?? (baseDomain !== void 0 || loopbackDomain !== void 0 || domain !== void 0);
762
- const tlsPolicyId = shouldUseInternalTls ? `${routeId}-tls` : null;
1066
+ const ownerId = createOwnerId();
1067
+ const ownershipRecord = createRouteOwnershipRecord(ownerId, domainArray, config.root);
1068
+ const routeId = ownershipRecord.routeId;
1069
+ const tlsPolicyId = ownershipRecord.tlsPolicyId;
763
1070
  let cleanupStarted = false;
764
1071
  let resolvedPort = null;
765
1072
  let resolvedHost = null;
766
1073
  let setupStarted = false;
1074
+ let activeOwnershipRecord = null;
1075
+ let ownershipHeartbeat = null;
767
1076
  function buildDomainResolutionMessage() {
768
1077
  const issues = [];
769
1078
  if (domain !== void 0 && !normalizeDomains(domain)) {
@@ -869,25 +1178,30 @@ function viteCaddyTlsPlugin({
869
1178
  async function cleanupRoute() {
870
1179
  if (cleanupStarted) return;
871
1180
  cleanupStarted = true;
872
- if (tlsPolicyId) {
873
- await removeWithRetry(
874
- () => removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl, pluginCaddyAdminOrigin),
875
- "TLS policy"
876
- );
1181
+ if (ownershipHeartbeat) {
1182
+ clearInterval(ownershipHeartbeat);
1183
+ ownershipHeartbeat = null;
1184
+ }
1185
+ if (!activeOwnershipRecord) return;
1186
+ const cleaned = await cleanupClaimedResources(activeOwnershipRecord, removeWithRetry);
1187
+ if (cleaned) {
1188
+ await releaseOwnershipRecord(activeOwnershipRecord);
1189
+ activeOwnershipRecord = null;
877
1190
  }
878
- await removeWithRetry(
879
- () => removeRoute(routeId, pluginCaddyApiUrl, pluginCaddyAdminOrigin),
880
- "route"
881
- );
882
1191
  }
883
1192
  function onServerClose() {
884
1193
  void cleanupRoute();
885
1194
  }
1195
+ function getSignalExitCode(signal) {
1196
+ if (signal === "SIGINT") return 130;
1197
+ if (signal === "SIGTERM") return 143;
1198
+ return 1;
1199
+ }
886
1200
  function handleSignal(signal) {
887
1201
  process.off("SIGINT", onSigint);
888
1202
  process.off("SIGTERM", onSigterm);
889
1203
  void cleanupRoute().finally(() => {
890
- process.kill(process.pid, signal);
1204
+ process.exit(getSignalExitCode(signal));
891
1205
  });
892
1206
  }
893
1207
  function onSigint() {
@@ -918,6 +1232,25 @@ function viteCaddyTlsPlugin({
918
1232
  console.error(`Failed to remove ${label} after ${maxAttempts} attempts.`);
919
1233
  return false;
920
1234
  }
1235
+ function startOwnershipHeartbeat(record) {
1236
+ ownershipHeartbeat = setInterval(() => {
1237
+ void touchRouteOwnership(record).then((touched) => {
1238
+ if (touched || !ownershipHeartbeat) {
1239
+ return;
1240
+ }
1241
+ console.error(
1242
+ `Lost route ownership for ${domainArray.join(", ")}. Cleaning up managed Caddy resources.`
1243
+ );
1244
+ void cleanupRoute();
1245
+ }).catch((error) => {
1246
+ console.error(
1247
+ `Failed to refresh route ownership for ${domainArray.join(", ")}.`,
1248
+ error
1249
+ );
1250
+ });
1251
+ }, ROUTE_OWNERSHIP_HEARTBEAT_INTERVAL_MS);
1252
+ ownershipHeartbeat.unref?.();
1253
+ }
921
1254
  async function setupRoute() {
922
1255
  if (!validateCaddyIsInstalled()) {
923
1256
  return;
@@ -933,16 +1266,70 @@ function viteCaddyTlsPlugin({
933
1266
  }
934
1267
  const port = getServerPort();
935
1268
  const upstreamHost = getUpstreamHost();
936
- await cleanupStaleRoutesForDomains(
1269
+ let claimResult;
1270
+ try {
1271
+ claimResult = await claimRouteOwnership(ownershipRecord);
1272
+ } catch (e) {
1273
+ console.error("Failed to claim route ownership for the resolved domains.", e);
1274
+ return;
1275
+ }
1276
+ if (claimResult.status === "active-conflict") {
1277
+ console.error(
1278
+ buildOwnershipConflictMessage(domainArray, claimResult.existingRecord)
1279
+ );
1280
+ return;
1281
+ }
1282
+ activeOwnershipRecord = claimResult.currentRecord;
1283
+ if (claimResult.status === "reclaimed") {
1284
+ let reclaimed = true;
1285
+ for (const previousRecord of claimResult.previousRecords) {
1286
+ reclaimed = await cleanupClaimedResources(previousRecord, removeWithRetry) && reclaimed;
1287
+ }
1288
+ if (!reclaimed) {
1289
+ console.error(
1290
+ `Failed to reclaim stale ownership for ${domainArray.join(", ")}. Try stopping the other server or removing stale Caddy state manually.`
1291
+ );
1292
+ await releaseOwnershipRecord(activeOwnershipRecord);
1293
+ activeOwnershipRecord = null;
1294
+ return;
1295
+ }
1296
+ await releaseOwnershipRecords(claimResult.previousRecords);
1297
+ }
1298
+ const conflictingRouteIds = (await findManagedRoutesForDomains(
937
1299
  domainArray,
938
- routeId,
939
1300
  serverName,
940
1301
  pluginCaddyApiUrl,
941
1302
  pluginCaddyAdminOrigin
942
- );
943
- await removeRoute(routeId, pluginCaddyApiUrl, pluginCaddyAdminOrigin);
1303
+ )).filter((existingRouteId) => {
1304
+ return existingRouteId !== routeId;
1305
+ });
1306
+ const conflictingTlsPolicyIds = (await findManagedTlsPoliciesForDomains(
1307
+ domainArray,
1308
+ pluginCaddyApiUrl,
1309
+ pluginCaddyAdminOrigin
1310
+ )).filter((existingTlsPolicyId) => {
1311
+ return existingTlsPolicyId !== tlsPolicyId;
1312
+ });
1313
+ if (conflictingRouteIds.length > 0 || conflictingTlsPolicyIds.length > 0) {
1314
+ const reclaimedOrphans = await cleanupManagedResources(
1315
+ conflictingRouteIds,
1316
+ conflictingTlsPolicyIds,
1317
+ removeWithRetry
1318
+ );
1319
+ if (reclaimedOrphans) {
1320
+ console.warn(
1321
+ `Reclaimed orphaned managed Caddy resources for ${domainArray.join(", ")}.`
1322
+ );
1323
+ } else {
1324
+ console.error(
1325
+ `Cannot claim ${domainArray.join(", ")} because Caddy still has orphaned managed resources. Remove the stale Caddy state or use \`instanceLabel\` or \`domain\` to make the hostname unique.`
1326
+ );
1327
+ await releaseOwnershipRecord(activeOwnershipRecord);
1328
+ activeOwnershipRecord = null;
1329
+ return;
1330
+ }
1331
+ }
944
1332
  if (tlsPolicyId) {
945
- await removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl, pluginCaddyAdminOrigin);
946
1333
  try {
947
1334
  await addTlsPolicy(
948
1335
  tlsPolicyId,
@@ -956,6 +1343,8 @@ function viteCaddyTlsPlugin({
956
1343
  `Failed to add TLS policy to Caddy. Is the Caddy Admin API reachable at ${pluginCaddyApiUrl}?`,
957
1344
  e
958
1345
  );
1346
+ await releaseOwnershipRecord(activeOwnershipRecord);
1347
+ activeOwnershipRecord = null;
959
1348
  return;
960
1349
  }
961
1350
  }
@@ -975,12 +1364,17 @@ function viteCaddyTlsPlugin({
975
1364
  if (tlsPolicyAdded && tlsPolicyId) {
976
1365
  await removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl, pluginCaddyAdminOrigin);
977
1366
  }
1367
+ await releaseOwnershipRecord(activeOwnershipRecord);
1368
+ activeOwnershipRecord = null;
978
1369
  console.error(
979
1370
  `Failed to add route to Caddy. Is the Caddy Admin API reachable at ${pluginCaddyApiUrl}?`,
980
1371
  e
981
1372
  );
982
1373
  return;
983
1374
  }
1375
+ if (activeOwnershipRecord) {
1376
+ startOwnershipHeartbeat(activeOwnershipRecord);
1377
+ }
984
1378
  console.log("\n\u{1F512} Caddy is proxying your traffic on https");
985
1379
  console.log(`
986
1380
  \u27A1\uFE0F Upstream target: http://${formatUpstreamTarget(upstreamHost, port)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-caddy-multiple-tls",
3
- "version": "1.6.1",
3
+ "version": "1.7.1",
4
4
  "description": "Vite plugin that uses Caddy to provide local HTTPS with derived domains.",
5
5
  "keywords": [
6
6
  "vite",
@@ -47,14 +47,14 @@
47
47
  },
48
48
  "homepage": "https://github.com/vampaz/vite-plugin-caddy-multiple-tls/#readme",
49
49
  "peerDependencies": {
50
- "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
50
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/fs-extra": "^11.0.4",
54
54
  "@types/node": "^25.0.3",
55
55
  "fs-extra": "^11.3.3",
56
56
  "tsup": "^8.5.1",
57
- "vite": "^7.3.0",
57
+ "vite": "^8.0.0",
58
58
  "vitest": "^4.0.16"
59
59
  }
60
60
  }