vite-plugin-caddy-multiple-tls 1.6.1 → 1.7.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.
Files changed (3) hide show
  1. package/README.md +12 -0
  2. package/dist/index.js +424 -38
  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,156 @@ 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
+ return isProcessAlive(record.pid) || now - record.lastSeenAt <= ROUTE_OWNERSHIP_STALE_AFTER_MS;
276
+ }
277
+ async function claimRouteOwnership(record) {
278
+ const normalizedRecord = normalizeRouteOwnershipRecord(record);
279
+ const { lockPath, recordPath } = getRouteOwnershipPaths(normalizedRecord);
280
+ let claimResult = null;
281
+ await withFileLock(lockPath, async () => {
282
+ const existingRecord = await readRouteOwnershipByPath(recordPath);
283
+ if (existingRecord?.ownerId === normalizedRecord.ownerId) {
284
+ await writeRouteOwnership(normalizedRecord);
285
+ claimResult = {
286
+ status: "claimed",
287
+ currentRecord: normalizedRecord
288
+ };
289
+ return;
290
+ }
291
+ const overlappingRecords = (await listRouteOwnershipRecords(normalizedRecord)).filter(
292
+ (candidate) => {
293
+ return candidate.ownerId !== normalizedRecord.ownerId && intersectsDomains(candidate.domains, normalizedRecord.domains);
294
+ }
295
+ );
296
+ const activeConflict = overlappingRecords.find((candidate) => {
297
+ return isRouteOwnershipActive(candidate);
298
+ });
299
+ if (activeConflict) {
300
+ claimResult = {
301
+ status: "active-conflict",
302
+ currentRecord: normalizedRecord,
303
+ existingRecord: activeConflict
304
+ };
305
+ return;
306
+ }
307
+ await writeRouteOwnership(normalizedRecord);
308
+ if (overlappingRecords.length > 0) {
309
+ claimResult = {
310
+ status: "reclaimed",
311
+ currentRecord: normalizedRecord,
312
+ previousRecords: overlappingRecords
313
+ };
314
+ return;
157
315
  }
316
+ claimResult = {
317
+ status: "claimed",
318
+ currentRecord: normalizedRecord
319
+ };
320
+ });
321
+ if (!claimResult) {
322
+ throw new Error("Failed to claim route ownership.");
158
323
  }
324
+ return claimResult;
325
+ }
326
+ async function touchRouteOwnership(reference) {
327
+ const { lockPath, recordPath } = getRouteOwnershipPaths(reference);
328
+ let touched = false;
329
+ await withFileLock(lockPath, async () => {
330
+ const existingRecord = await readRouteOwnershipByPath(recordPath);
331
+ if (!existingRecord || existingRecord.ownerId !== reference.ownerId) {
332
+ return;
333
+ }
334
+ await writeRouteOwnership({
335
+ ...existingRecord,
336
+ lastSeenAt: Date.now()
337
+ });
338
+ touched = true;
339
+ });
340
+ return touched;
341
+ }
342
+ async function releaseRouteOwnership(reference) {
343
+ const { lockPath, recordPath } = getRouteOwnershipPaths(reference);
344
+ let released = false;
345
+ await withFileLock(lockPath, async () => {
346
+ const existingRecord = await readRouteOwnershipByPath(recordPath);
347
+ if (!existingRecord || existingRecord.ownerId !== reference.ownerId) {
348
+ return;
349
+ }
350
+ await unlink(recordPath).catch((error) => {
351
+ if (!isNodeError(error) || error.code !== "ENOENT") {
352
+ throw error;
353
+ }
354
+ });
355
+ released = true;
356
+ });
357
+ return released;
159
358
  }
160
359
  function validateCaddyIsInstalled() {
161
360
  try {
@@ -403,33 +602,68 @@ function extractMatchedHosts(route) {
403
602
  }
404
603
  return hosts;
405
604
  }
605
+ function extractMatchedSubjects(policy) {
606
+ if (!isRecord(policy) || !Array.isArray(policy.subjects)) return [];
607
+ const subjects = [];
608
+ for (const subject of policy.subjects) {
609
+ if (typeof subject === "string") {
610
+ subjects.push(subject);
611
+ }
612
+ }
613
+ return subjects;
614
+ }
406
615
  function intersectsDomains(targetDomains, routeDomains) {
407
616
  if (targetDomains.length === 0 || routeDomains.length === 0) return false;
408
617
  const targetSet = new Set(targetDomains);
409
618
  return routeDomains.some((domain) => targetSet.has(domain));
410
619
  }
411
- async function cleanupStaleRoutesForDomains(domains, currentRouteId, serverName = DEFAULT_SERVER_NAME, apiUrl, adminOrigin) {
412
- if (domains.length === 0) return;
620
+ async function findManagedRoutesForDomains(domains, serverName = DEFAULT_SERVER_NAME, apiUrl, adminOrigin) {
621
+ if (domains.length === 0) return [];
413
622
  const res = await caddyFetch(
414
623
  `${getApiUrl(apiUrl)}/config/apps/http/servers/${serverName}/routes`,
415
624
  void 0,
416
625
  apiUrl,
417
626
  adminOrigin
418
627
  );
419
- if (!res.ok) return;
628
+ if (!res.ok) return [];
420
629
  const text = await res.text();
421
630
  const parsed = parseConfig(text);
422
- if (!Array.isArray(parsed)) return;
631
+ if (!Array.isArray(parsed)) return [];
632
+ const routeIds = [];
423
633
  for (const route of parsed) {
424
634
  if (!isRecord(route)) continue;
425
635
  const id = route["@id"];
426
636
  if (typeof id !== "string") continue;
427
- if (!id.startsWith("vite-proxy-")) continue;
428
- if (id === currentRouteId) continue;
637
+ if (!id.startsWith(ROUTE_ID_PREFIX)) continue;
429
638
  const routeDomains = extractMatchedHosts(route);
430
639
  if (!intersectsDomains(domains, routeDomains)) continue;
431
- await removeRoute(id, apiUrl, adminOrigin);
640
+ routeIds.push(id);
432
641
  }
642
+ return routeIds;
643
+ }
644
+ async function findManagedTlsPoliciesForDomains(domains, apiUrl, adminOrigin) {
645
+ if (domains.length === 0) return [];
646
+ const res = await caddyFetch(
647
+ `${getApiUrl(apiUrl)}/config/apps/tls/automation/policies`,
648
+ void 0,
649
+ apiUrl,
650
+ adminOrigin
651
+ );
652
+ if (!res.ok) return [];
653
+ const text = await res.text();
654
+ const parsed = parseConfig(text);
655
+ if (!Array.isArray(parsed)) return [];
656
+ const policyIds = [];
657
+ for (const policy of parsed) {
658
+ if (!isRecord(policy)) continue;
659
+ const id = policy["@id"];
660
+ if (typeof id !== "string") continue;
661
+ if (!id.startsWith(ROUTE_ID_PREFIX)) continue;
662
+ const policyDomains = extractMatchedSubjects(policy);
663
+ if (!intersectsDomains(domains, policyDomains)) continue;
664
+ policyIds.push(id);
665
+ }
666
+ return policyIds;
433
667
  }
434
668
  async function addRoute(id, domains, port, cors, serverName = DEFAULT_SERVER_NAME, upstreamHost = "127.0.0.1", upstreamHostHeader, apiUrl, adminOrigin) {
435
669
  const handlers = [];
@@ -718,13 +952,82 @@ function viteCaddyTlsPlugin({
718
952
  `caddyAdminOrigin is invalid. Falling back to ${pluginCaddyApiUrl}.`
719
953
  );
720
954
  }
721
- function getInstanceKey(domains, configRoot) {
722
- const keyMaterial = JSON.stringify({
723
- domains: [...domains].sort(),
955
+ function createOwnerId() {
956
+ return `${process.pid}-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
957
+ }
958
+ function createRouteOwnershipRecord(ownerId, domains, configRoot) {
959
+ const routeId = `vite-proxy-${ownerId}`;
960
+ const shouldUseInternalTls = internalTls ?? (baseDomain !== void 0 || loopbackDomain !== void 0 || domain !== void 0);
961
+ const now = Date.now();
962
+ return {
963
+ version: 1,
964
+ ownerId,
965
+ pid: process.pid,
724
966
  cwd: process.cwd(),
725
- root: configRoot ?? null
726
- });
727
- return createHash2("sha1").update(keyMaterial).digest("hex").slice(0, 12);
967
+ configRoot: configRoot ?? null,
968
+ domains: [...domains],
969
+ routeId,
970
+ tlsPolicyId: shouldUseInternalTls ? `${routeId}-tls` : null,
971
+ serverName: serverName ?? "srv0",
972
+ caddyApiUrl: pluginCaddyApiUrl,
973
+ startedAt: now,
974
+ lastSeenAt: now
975
+ };
976
+ }
977
+ function buildOwnershipConflictMessage(domains, existingRecord) {
978
+ const ownerLocation = existingRecord.configRoot ?? existingRecord.cwd;
979
+ const domainLabel = domains.join(", ");
980
+ return [
981
+ `Cannot claim ${domainLabel}: another Vite server already owns ${domains.length > 1 ? "these domains" : "this domain"}.`,
982
+ `Existing owner pid ${existingRecord.pid} from ${ownerLocation}.`,
983
+ "Stop the other server first, or use `instanceLabel` or `domain` to make the hostname unique."
984
+ ].join(" ");
985
+ }
986
+ async function releaseOwnershipRecord(record) {
987
+ if (!record) return;
988
+ await releaseRouteOwnership(record);
989
+ }
990
+ async function releaseOwnershipRecords(records) {
991
+ await Promise.all(records.map((record) => releaseOwnershipRecord(record)));
992
+ }
993
+ async function cleanupClaimedResources(record, removeWithRetry) {
994
+ let cleaned = true;
995
+ if (record.tlsPolicyId) {
996
+ const tlsPolicyId = record.tlsPolicyId;
997
+ cleaned = await removeWithRetry(
998
+ () => removeTlsPolicy(
999
+ tlsPolicyId,
1000
+ pluginCaddyApiUrl,
1001
+ pluginCaddyAdminOrigin
1002
+ ),
1003
+ "TLS policy"
1004
+ ) && cleaned;
1005
+ }
1006
+ cleaned = await removeWithRetry(
1007
+ () => removeRoute(record.routeId, pluginCaddyApiUrl, pluginCaddyAdminOrigin),
1008
+ "route"
1009
+ ) && cleaned;
1010
+ return cleaned;
1011
+ }
1012
+ async function cleanupManagedResources(routeIds, tlsPolicyIds, removeWithRetry) {
1013
+ let cleaned = true;
1014
+ for (const managedTlsPolicyId of tlsPolicyIds) {
1015
+ cleaned = await removeWithRetry(
1016
+ () => removeTlsPolicy(
1017
+ managedTlsPolicyId,
1018
+ pluginCaddyApiUrl,
1019
+ pluginCaddyAdminOrigin
1020
+ ),
1021
+ `managed TLS policy ${managedTlsPolicyId}`
1022
+ ) && cleaned;
1023
+ }
1024
+ for (const managedRouteId of routeIds) {
1025
+ cleaned = await removeWithRetry(
1026
+ () => removeRoute(managedRouteId, pluginCaddyApiUrl, pluginCaddyAdminOrigin),
1027
+ `managed route ${managedRouteId}`
1028
+ ) && cleaned;
1029
+ }
1030
+ return cleaned;
728
1031
  }
729
1032
  function isPreviewServer(server) {
730
1033
  return server.config.isProduction;
@@ -757,13 +1060,16 @@ function viteCaddyTlsPlugin({
757
1060
  instanceLabel
758
1061
  });
759
1062
  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;
1063
+ const ownerId = createOwnerId();
1064
+ const ownershipRecord = createRouteOwnershipRecord(ownerId, domainArray, config.root);
1065
+ const routeId = ownershipRecord.routeId;
1066
+ const tlsPolicyId = ownershipRecord.tlsPolicyId;
763
1067
  let cleanupStarted = false;
764
1068
  let resolvedPort = null;
765
1069
  let resolvedHost = null;
766
1070
  let setupStarted = false;
1071
+ let activeOwnershipRecord = null;
1072
+ let ownershipHeartbeat = null;
767
1073
  function buildDomainResolutionMessage() {
768
1074
  const issues = [];
769
1075
  if (domain !== void 0 && !normalizeDomains(domain)) {
@@ -869,16 +1175,16 @@ function viteCaddyTlsPlugin({
869
1175
  async function cleanupRoute() {
870
1176
  if (cleanupStarted) return;
871
1177
  cleanupStarted = true;
872
- if (tlsPolicyId) {
873
- await removeWithRetry(
874
- () => removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl, pluginCaddyAdminOrigin),
875
- "TLS policy"
876
- );
1178
+ if (ownershipHeartbeat) {
1179
+ clearInterval(ownershipHeartbeat);
1180
+ ownershipHeartbeat = null;
1181
+ }
1182
+ if (!activeOwnershipRecord) return;
1183
+ const cleaned = await cleanupClaimedResources(activeOwnershipRecord, removeWithRetry);
1184
+ if (cleaned) {
1185
+ await releaseOwnershipRecord(activeOwnershipRecord);
1186
+ activeOwnershipRecord = null;
877
1187
  }
878
- await removeWithRetry(
879
- () => removeRoute(routeId, pluginCaddyApiUrl, pluginCaddyAdminOrigin),
880
- "route"
881
- );
882
1188
  }
883
1189
  function onServerClose() {
884
1190
  void cleanupRoute();
@@ -918,6 +1224,25 @@ function viteCaddyTlsPlugin({
918
1224
  console.error(`Failed to remove ${label} after ${maxAttempts} attempts.`);
919
1225
  return false;
920
1226
  }
1227
+ function startOwnershipHeartbeat(record) {
1228
+ ownershipHeartbeat = setInterval(() => {
1229
+ void touchRouteOwnership(record).then((touched) => {
1230
+ if (touched || !ownershipHeartbeat) {
1231
+ return;
1232
+ }
1233
+ console.error(
1234
+ `Lost route ownership for ${domainArray.join(", ")}. Cleaning up managed Caddy resources.`
1235
+ );
1236
+ void cleanupRoute();
1237
+ }).catch((error) => {
1238
+ console.error(
1239
+ `Failed to refresh route ownership for ${domainArray.join(", ")}.`,
1240
+ error
1241
+ );
1242
+ });
1243
+ }, ROUTE_OWNERSHIP_HEARTBEAT_INTERVAL_MS);
1244
+ ownershipHeartbeat.unref?.();
1245
+ }
921
1246
  async function setupRoute() {
922
1247
  if (!validateCaddyIsInstalled()) {
923
1248
  return;
@@ -933,16 +1258,70 @@ function viteCaddyTlsPlugin({
933
1258
  }
934
1259
  const port = getServerPort();
935
1260
  const upstreamHost = getUpstreamHost();
936
- await cleanupStaleRoutesForDomains(
1261
+ let claimResult;
1262
+ try {
1263
+ claimResult = await claimRouteOwnership(ownershipRecord);
1264
+ } catch (e) {
1265
+ console.error("Failed to claim route ownership for the resolved domains.", e);
1266
+ return;
1267
+ }
1268
+ if (claimResult.status === "active-conflict") {
1269
+ console.error(
1270
+ buildOwnershipConflictMessage(domainArray, claimResult.existingRecord)
1271
+ );
1272
+ return;
1273
+ }
1274
+ activeOwnershipRecord = claimResult.currentRecord;
1275
+ if (claimResult.status === "reclaimed") {
1276
+ let reclaimed = true;
1277
+ for (const previousRecord of claimResult.previousRecords) {
1278
+ reclaimed = await cleanupClaimedResources(previousRecord, removeWithRetry) && reclaimed;
1279
+ }
1280
+ if (!reclaimed) {
1281
+ console.error(
1282
+ `Failed to reclaim stale ownership for ${domainArray.join(", ")}. Try stopping the other server or removing stale Caddy state manually.`
1283
+ );
1284
+ await releaseOwnershipRecord(activeOwnershipRecord);
1285
+ activeOwnershipRecord = null;
1286
+ return;
1287
+ }
1288
+ await releaseOwnershipRecords(claimResult.previousRecords);
1289
+ }
1290
+ const conflictingRouteIds = (await findManagedRoutesForDomains(
937
1291
  domainArray,
938
- routeId,
939
1292
  serverName,
940
1293
  pluginCaddyApiUrl,
941
1294
  pluginCaddyAdminOrigin
942
- );
943
- await removeRoute(routeId, pluginCaddyApiUrl, pluginCaddyAdminOrigin);
1295
+ )).filter((existingRouteId) => {
1296
+ return existingRouteId !== routeId;
1297
+ });
1298
+ const conflictingTlsPolicyIds = (await findManagedTlsPoliciesForDomains(
1299
+ domainArray,
1300
+ pluginCaddyApiUrl,
1301
+ pluginCaddyAdminOrigin
1302
+ )).filter((existingTlsPolicyId) => {
1303
+ return existingTlsPolicyId !== tlsPolicyId;
1304
+ });
1305
+ if (conflictingRouteIds.length > 0 || conflictingTlsPolicyIds.length > 0) {
1306
+ const reclaimedOrphans = await cleanupManagedResources(
1307
+ conflictingRouteIds,
1308
+ conflictingTlsPolicyIds,
1309
+ removeWithRetry
1310
+ );
1311
+ if (reclaimedOrphans) {
1312
+ console.warn(
1313
+ `Reclaimed orphaned managed Caddy resources for ${domainArray.join(", ")}.`
1314
+ );
1315
+ } else {
1316
+ console.error(
1317
+ `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.`
1318
+ );
1319
+ await releaseOwnershipRecord(activeOwnershipRecord);
1320
+ activeOwnershipRecord = null;
1321
+ return;
1322
+ }
1323
+ }
944
1324
  if (tlsPolicyId) {
945
- await removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl, pluginCaddyAdminOrigin);
946
1325
  try {
947
1326
  await addTlsPolicy(
948
1327
  tlsPolicyId,
@@ -956,6 +1335,8 @@ function viteCaddyTlsPlugin({
956
1335
  `Failed to add TLS policy to Caddy. Is the Caddy Admin API reachable at ${pluginCaddyApiUrl}?`,
957
1336
  e
958
1337
  );
1338
+ await releaseOwnershipRecord(activeOwnershipRecord);
1339
+ activeOwnershipRecord = null;
959
1340
  return;
960
1341
  }
961
1342
  }
@@ -975,12 +1356,17 @@ function viteCaddyTlsPlugin({
975
1356
  if (tlsPolicyAdded && tlsPolicyId) {
976
1357
  await removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl, pluginCaddyAdminOrigin);
977
1358
  }
1359
+ await releaseOwnershipRecord(activeOwnershipRecord);
1360
+ activeOwnershipRecord = null;
978
1361
  console.error(
979
1362
  `Failed to add route to Caddy. Is the Caddy Admin API reachable at ${pluginCaddyApiUrl}?`,
980
1363
  e
981
1364
  );
982
1365
  return;
983
1366
  }
1367
+ if (activeOwnershipRecord) {
1368
+ startOwnershipHeartbeat(activeOwnershipRecord);
1369
+ }
984
1370
  console.log("\n\u{1F512} Caddy is proxying your traffic on https");
985
1371
  console.log(`
986
1372
  \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.0",
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
  }