vite-plugin-caddy-multiple-tls 1.6.0 → 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.
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.
@@ -136,6 +140,48 @@ const config = defineConfig({
136
140
  export default config;
137
141
  ```
138
142
 
143
+ If your Caddy Admin API enforces a specific allowed origin that differs from `caddyApiUrl`, set `caddyAdminOrigin`.
144
+
145
+ ```js
146
+ // vite.config.js
147
+ import { defineConfig } from 'vite';
148
+ import caddyTls from 'vite-plugin-caddy-multiple-tls';
149
+
150
+ const config = defineConfig({
151
+ plugins: [
152
+ caddyTls({
153
+ caddyApiUrl: 'http://127.0.0.1:2019',
154
+ caddyAdminOrigin: 'http://localhost:2019',
155
+ })
156
+ ]
157
+ });
158
+
159
+ export default config;
160
+ ```
161
+
162
+ ## Troubleshooting
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
+
172
+ ### `client is not allowed to access from origin ''`
173
+
174
+ This error comes from Caddy Admin API origin enforcement, not from Caddy being down.
175
+
176
+ - Check `caddyApiUrl` points to the correct Admin API endpoint.
177
+ - If Admin API expects a different origin than the API URL host, set `caddyAdminOrigin`.
178
+ - Verify behavior directly:
179
+
180
+ ```bash
181
+ curl -i http://127.0.0.1:2019/config/
182
+ curl -i -H 'Origin: http://127.0.0.1:2019' http://127.0.0.1:2019/config/
183
+ ```
184
+
139
185
  > [!IMPORTANT]
140
186
  > **Hosts file limitation:** If you use a custom domain, you must **manually** add each generated subdomain to your `/etc/hosts` file (e.g., `127.0.0.1 repo.branch.local.example.test`). System hosts files **do not support wildcards** (e.g., `*.local.example.test`), so you lose the benefit of automatic domain resolution that `localhost` provides.
141
187
 
package/dist/index.d.ts CHANGED
@@ -18,6 +18,8 @@ interface ViteCaddyTlsPluginOptions {
18
18
  serverName?: string;
19
19
  /** Override the Caddy Admin API base URL (default: http://localhost:2019) */
20
20
  caddyApiUrl?: string;
21
+ /** Override the Origin header used for Caddy Admin API requests (defaults to caddyApiUrl origin) */
22
+ caddyAdminOrigin?: string;
21
23
  /** Use Caddy's internal CA for the provided domains (defaults to true when baseDomain or domain is set) */
22
24
  internalTls?: boolean;
23
25
  /**
@@ -39,6 +41,6 @@ type LoopbackDomain = 'localtest.me' | 'lvh.me' | 'nip.io';
39
41
  * ```
40
42
  * @returns {Plugin} - a Vite plugin
41
43
  */
42
- declare function viteCaddyTlsPlugin({ domain, baseDomain, loopbackDomain, repo, branch, instanceLabel, cors, serverName, caddyApiUrl, internalTls, upstreamHostHeader, }?: ViteCaddyTlsPluginOptions): PluginOption;
44
+ declare function viteCaddyTlsPlugin({ domain, baseDomain, loopbackDomain, repo, branch, instanceLabel, cors, serverName, caddyApiUrl, caddyAdminOrigin, internalTls, upstreamHostHeader, }?: ViteCaddyTlsPluginOptions): PluginOption;
43
45
 
44
46
  export { type ViteCaddyTlsPluginOptions, viteCaddyTlsPlugin as default };
package/dist/index.js CHANGED
@@ -1,19 +1,88 @@
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
+ 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;
21
+ var CONNECTIVITY_ERROR_CODES = /* @__PURE__ */ new Set([
22
+ "ECONNREFUSED",
23
+ "ECONNRESET",
24
+ "EHOSTUNREACH",
25
+ "ENETUNREACH",
26
+ "ENOTFOUND",
27
+ "ETIMEDOUT",
28
+ "UND_ERR_CONNECT_TIMEOUT",
29
+ "UND_ERR_HEADERS_TIMEOUT",
30
+ "UND_ERR_SOCKET"
31
+ ]);
14
32
  function isRecord(value) {
15
33
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
16
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
+ }
17
86
  function parseConfig(text) {
18
87
  if (!text.trim()) return {};
19
88
  try {
@@ -28,6 +97,93 @@ function isTlsPolicyOverlapError(text) {
28
97
  function getApiUrl(apiUrl) {
29
98
  return apiUrl ?? DEFAULT_CADDY_API_URL;
30
99
  }
100
+ function toError(error) {
101
+ if (error instanceof Error) return error;
102
+ return new Error(String(error));
103
+ }
104
+ function isOriginPolicyError(status, text) {
105
+ if (status !== 403) return false;
106
+ const normalizedText = text.toLowerCase();
107
+ return normalizedText.includes("origin") && normalizedText.includes("not allowed");
108
+ }
109
+ function buildCaddyRequestError(message, status, text) {
110
+ if (isOriginPolicyError(status, text)) {
111
+ return new Error(CADDY_ADMIN_ORIGIN_POLICY_ERROR_MESSAGE);
112
+ }
113
+ const normalizedText = text.trim();
114
+ if (!normalizedText) {
115
+ return new Error(`${message}: HTTP ${status}`);
116
+ }
117
+ return new Error(`${message}: ${normalizedText}`);
118
+ }
119
+ function isNodeError(error) {
120
+ return Boolean(error) && typeof error === "object" && "code" in error;
121
+ }
122
+ function getErrorCode(error) {
123
+ if (!error || typeof error !== "object") return void 0;
124
+ if ("code" in error && typeof error.code === "string") {
125
+ return error.code;
126
+ }
127
+ if ("cause" in error) {
128
+ const cause = error.cause;
129
+ if (cause && typeof cause === "object") {
130
+ if ("code" in cause && typeof cause.code === "string") {
131
+ return cause.code;
132
+ }
133
+ }
134
+ }
135
+ return void 0;
136
+ }
137
+ function isConnectivityError(error) {
138
+ const code = getErrorCode(error);
139
+ return Boolean(code) && CONNECTIVITY_ERROR_CODES.has(code);
140
+ }
141
+ function getAdminOrigin(apiUrl, adminOrigin) {
142
+ const originSource = adminOrigin ?? getApiUrl(apiUrl);
143
+ try {
144
+ return new URL(originSource).origin;
145
+ } catch (e) {
146
+ return new URL(getApiUrl(apiUrl)).origin;
147
+ }
148
+ }
149
+ async function caddyFetch(input, init, apiUrl, adminOrigin) {
150
+ const headers = new Headers(init?.headers);
151
+ headers.set("Origin", getAdminOrigin(apiUrl, adminOrigin));
152
+ return fetch(input, {
153
+ ...init,
154
+ headers
155
+ });
156
+ }
157
+ async function checkCaddyAdminStatus(apiUrl, adminOrigin) {
158
+ try {
159
+ const res = await caddyFetch(`${getApiUrl(apiUrl)}/config/`, void 0, apiUrl, adminOrigin);
160
+ if (res.ok) {
161
+ return { status: "running" };
162
+ }
163
+ const text = await res.text();
164
+ return {
165
+ status: "api-error",
166
+ error: buildCaddyRequestError("Failed to read Caddy config", res.status, text)
167
+ };
168
+ } catch (e) {
169
+ const error = toError(e);
170
+ if (isConnectivityError(error)) {
171
+ return {
172
+ status: "connectivity-error",
173
+ error
174
+ };
175
+ }
176
+ return {
177
+ status: "api-error",
178
+ error
179
+ };
180
+ }
181
+ }
182
+ async function assertCaddyResponse(res, message) {
183
+ if (res.ok) return;
184
+ const text = await res.text();
185
+ throw buildCaddyRequestError(message, res.status, text);
186
+ }
31
187
  function getLockPath(apiUrl) {
32
188
  const key = createHash("sha1").update(getApiUrl(apiUrl)).digest("hex").slice(0, 12);
33
189
  return path.join(os.tmpdir(), `vite-plugin-caddy-multiple-tls-${key}.lock`);
@@ -35,10 +191,9 @@ function getLockPath(apiUrl) {
35
191
  function sleep(ms) {
36
192
  return new Promise((resolve) => setTimeout(resolve, ms));
37
193
  }
38
- async function withApiLock(apiUrl, fn) {
39
- const lockPath = getLockPath(apiUrl);
194
+ async function withFileLock(lockPath, fn) {
195
+ await mkdir(path.dirname(lockPath), { recursive: true });
40
196
  const startedAt = Date.now();
41
- const timeoutMs = 5e3;
42
197
  while (true) {
43
198
  try {
44
199
  const handle = await open(lockPath, "wx");
@@ -50,50 +205,196 @@ async function withApiLock(apiUrl, fn) {
50
205
  }
51
206
  return;
52
207
  } catch (e) {
53
- if (e.code !== "EEXIST") {
208
+ if (!isNodeError(e) || e.code !== "EEXIST") {
54
209
  throw e;
55
210
  }
56
- if (Date.now() - startedAt >= timeoutMs) {
211
+ if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
57
212
  await fn();
58
213
  return;
59
214
  }
60
- await sleep(50);
215
+ await sleep(LOCK_RETRY_MS);
61
216
  }
62
217
  }
63
218
  }
64
- function validateCaddyIsInstalled() {
219
+ async function withApiLock(apiUrl, fn) {
220
+ await withFileLock(getLockPath(apiUrl), fn);
221
+ }
222
+ async function readRouteOwnershipByPath(recordPath) {
65
223
  try {
66
- execSync("caddy version");
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);
67
269
  return true;
68
270
  } catch (e) {
69
- console.error("caddy cli is not installed");
70
- return false;
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;
315
+ }
316
+ claimResult = {
317
+ status: "claimed",
318
+ currentRecord: normalizedRecord
319
+ };
320
+ });
321
+ if (!claimResult) {
322
+ throw new Error("Failed to claim route ownership.");
71
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;
72
358
  }
73
- async function isCaddyRunning(apiUrl) {
359
+ function validateCaddyIsInstalled() {
74
360
  try {
75
- const res = await fetch(`${getApiUrl(apiUrl)}/config/`);
76
- return res.ok;
361
+ execSync("caddy version");
362
+ return true;
77
363
  } catch (e) {
364
+ console.error("caddy cli is not installed");
78
365
  return false;
79
366
  }
80
367
  }
81
- async function startCaddy(apiUrl) {
368
+ async function startCaddy(apiUrl, adminOrigin) {
82
369
  try {
83
370
  execSync("caddy start", { stdio: "ignore" });
84
371
  } catch (e) {
85
372
  }
86
373
  for (let i = 0; i < 10; i++) {
87
- if (await isCaddyRunning(apiUrl)) return true;
374
+ const status = await checkCaddyAdminStatus(apiUrl, adminOrigin);
375
+ if (status.status === "running") return true;
376
+ if (status.status === "api-error") {
377
+ throw status.error;
378
+ }
88
379
  await sleep(500);
89
380
  }
90
381
  return false;
91
382
  }
92
- async function ensureBaseConfig(serverName = DEFAULT_SERVER_NAME, apiUrl) {
383
+ async function ensureBaseConfig(serverName = DEFAULT_SERVER_NAME, apiUrl, adminOrigin) {
93
384
  const resolvedApiUrl = getApiUrl(apiUrl);
94
385
  const serverUrl = `${resolvedApiUrl}/config/apps/http/servers/${serverName}`;
95
- const res = await fetch(serverUrl);
386
+ const res = await caddyFetch(serverUrl, void 0, apiUrl, adminOrigin);
96
387
  if (res.ok) return;
388
+ if (res.status === 403) {
389
+ const text = await res.text();
390
+ if (isOriginPolicyError(res.status, text)) {
391
+ throw buildCaddyRequestError(
392
+ "Failed to initialize Caddy base configuration",
393
+ res.status,
394
+ text
395
+ );
396
+ }
397
+ }
97
398
  const baseConfig = {
98
399
  listen: [":443"],
99
400
  routes: []
@@ -103,11 +404,13 @@ async function ensureBaseConfig(serverName = DEFAULT_SERVER_NAME, apiUrl) {
103
404
  [serverName]: baseConfig
104
405
  }
105
406
  };
106
- const configRes = await fetch(`${resolvedApiUrl}/config/`);
107
- if (!configRes.ok) {
108
- const text = await configRes.text();
109
- throw new Error(`Failed to read Caddy config: ${text}`);
110
- }
407
+ const configRes = await caddyFetch(
408
+ `${resolvedApiUrl}/config/`,
409
+ void 0,
410
+ apiUrl,
411
+ adminOrigin
412
+ );
413
+ await assertCaddyResponse(configRes, "Failed to read Caddy config");
111
414
  const configText = await configRes.text();
112
415
  const config = parseConfig(configText);
113
416
  if (config === void 0) {
@@ -115,18 +418,27 @@ async function ensureBaseConfig(serverName = DEFAULT_SERVER_NAME, apiUrl) {
115
418
  }
116
419
  const isEmptyConfig = configText.trim() === "" || config === null || isRecord(config) && Object.keys(config).length === 0;
117
420
  if (isEmptyConfig) {
118
- const loadRes = await fetch(`${resolvedApiUrl}/load`, {
119
- method: "POST",
120
- headers: { "Content-Type": "application/json" },
121
- body: JSON.stringify({
122
- apps: {
123
- http: httpAppConfig
124
- }
125
- })
126
- });
421
+ const loadRes = await caddyFetch(
422
+ `${resolvedApiUrl}/load`,
423
+ {
424
+ method: "POST",
425
+ headers: { "Content-Type": "application/json" },
426
+ body: JSON.stringify({
427
+ apps: {
428
+ http: httpAppConfig
429
+ }
430
+ })
431
+ },
432
+ apiUrl,
433
+ adminOrigin
434
+ );
127
435
  if (!loadRes.ok) {
128
436
  const text = await loadRes.text();
129
- throw new Error(`Failed to initialize Caddy base configuration: ${text}`);
437
+ throw buildCaddyRequestError(
438
+ "Failed to initialize Caddy base configuration",
439
+ loadRes.status,
440
+ text
441
+ );
130
442
  }
131
443
  return;
132
444
  }
@@ -137,82 +449,136 @@ async function ensureBaseConfig(serverName = DEFAULT_SERVER_NAME, apiUrl) {
137
449
  let hasHttp = isRecord(http);
138
450
  let hasServers = isRecord(servers);
139
451
  if (!hasApps) {
140
- const createAppsRes = await fetch(`${resolvedApiUrl}/config/apps`, {
141
- method: "PUT",
142
- headers: { "Content-Type": "application/json" },
143
- body: JSON.stringify({})
144
- });
452
+ const createAppsRes = await caddyFetch(
453
+ `${resolvedApiUrl}/config/apps`,
454
+ {
455
+ method: "PUT",
456
+ headers: { "Content-Type": "application/json" },
457
+ body: JSON.stringify({})
458
+ },
459
+ apiUrl,
460
+ adminOrigin
461
+ );
145
462
  if (!createAppsRes.ok && createAppsRes.status !== 409) {
146
463
  const text = await createAppsRes.text();
147
- throw new Error(`Failed to initialize Caddy base configuration: ${text}`);
464
+ throw buildCaddyRequestError(
465
+ "Failed to initialize Caddy base configuration",
466
+ createAppsRes.status,
467
+ text
468
+ );
148
469
  }
149
470
  hasApps = true;
150
471
  }
151
472
  if (!hasHttp) {
152
- const createHttpRes = await fetch(`${resolvedApiUrl}/config/apps/http`, {
153
- method: "PUT",
154
- headers: { "Content-Type": "application/json" },
155
- body: JSON.stringify({ servers: {} })
156
- });
473
+ const createHttpRes = await caddyFetch(
474
+ `${resolvedApiUrl}/config/apps/http`,
475
+ {
476
+ method: "PUT",
477
+ headers: { "Content-Type": "application/json" },
478
+ body: JSON.stringify({ servers: {} })
479
+ },
480
+ apiUrl,
481
+ adminOrigin
482
+ );
157
483
  if (!createHttpRes.ok && createHttpRes.status !== 409) {
158
484
  const text = await createHttpRes.text();
159
- throw new Error(`Failed to initialize Caddy base configuration: ${text}`);
485
+ throw buildCaddyRequestError(
486
+ "Failed to initialize Caddy base configuration",
487
+ createHttpRes.status,
488
+ text
489
+ );
160
490
  }
161
491
  hasHttp = true;
162
492
  hasServers = true;
163
493
  }
164
494
  if (!hasServers) {
165
- const createServersRes = await fetch(`${resolvedApiUrl}/config/apps/http/servers`, {
166
- method: "PUT",
167
- headers: { "Content-Type": "application/json" },
168
- body: JSON.stringify({})
169
- });
495
+ const createServersRes = await caddyFetch(
496
+ `${resolvedApiUrl}/config/apps/http/servers`,
497
+ {
498
+ method: "PUT",
499
+ headers: { "Content-Type": "application/json" },
500
+ body: JSON.stringify({})
501
+ },
502
+ apiUrl,
503
+ adminOrigin
504
+ );
170
505
  if (!createServersRes.ok && createServersRes.status !== 409) {
171
506
  const text = await createServersRes.text();
172
- throw new Error(`Failed to initialize Caddy base configuration: ${text}`);
507
+ throw buildCaddyRequestError(
508
+ "Failed to initialize Caddy base configuration",
509
+ createServersRes.status,
510
+ text
511
+ );
173
512
  }
174
513
  }
175
- const createServerRes = await fetch(serverUrl, {
176
- method: "PUT",
177
- headers: { "Content-Type": "application/json" },
178
- body: JSON.stringify(baseConfig)
179
- });
514
+ const createServerRes = await caddyFetch(
515
+ serverUrl,
516
+ {
517
+ method: "PUT",
518
+ headers: { "Content-Type": "application/json" },
519
+ body: JSON.stringify(baseConfig)
520
+ },
521
+ apiUrl,
522
+ adminOrigin
523
+ );
180
524
  if (!createServerRes.ok && createServerRes.status !== 409) {
181
525
  const text = await createServerRes.text();
182
- throw new Error(`Failed to initialize Caddy base configuration: ${text}`);
526
+ throw buildCaddyRequestError(
527
+ "Failed to initialize Caddy base configuration",
528
+ createServerRes.status,
529
+ text
530
+ );
183
531
  }
184
532
  }
185
- async function ensureTlsAutomation(apiUrl) {
533
+ async function ensureTlsAutomation(apiUrl, adminOrigin) {
186
534
  const resolvedApiUrl = getApiUrl(apiUrl);
187
535
  const policiesUrl = `${resolvedApiUrl}/config/apps/tls/automation/policies`;
188
- const policiesRes = await fetch(policiesUrl);
536
+ const policiesRes = await caddyFetch(policiesUrl, void 0, apiUrl, adminOrigin);
189
537
  if (policiesRes.ok) return;
190
538
  const policiesText = await policiesRes.text();
191
539
  if (policiesRes.status !== 404 && !policiesText.includes("invalid traversal path")) {
192
- throw new Error(
193
- `Failed to initialize Caddy TLS automation: ${policiesText}`
540
+ throw buildCaddyRequestError(
541
+ "Failed to initialize Caddy TLS automation",
542
+ policiesRes.status,
543
+ policiesText
194
544
  );
195
545
  }
196
- const automationRes = await fetch(`${resolvedApiUrl}/config/apps/tls/automation`, {
197
- method: "PUT",
198
- headers: { "Content-Type": "application/json" },
199
- body: JSON.stringify({ policies: [] })
200
- });
546
+ const automationRes = await caddyFetch(
547
+ `${resolvedApiUrl}/config/apps/tls/automation`,
548
+ {
549
+ method: "PUT",
550
+ headers: { "Content-Type": "application/json" },
551
+ body: JSON.stringify({ policies: [] })
552
+ },
553
+ apiUrl,
554
+ adminOrigin
555
+ );
201
556
  if (automationRes.ok || automationRes.status === 409) return;
202
557
  const automationText = await automationRes.text();
203
558
  if (!automationText.includes("invalid traversal path")) {
204
- throw new Error(
205
- `Failed to initialize Caddy TLS automation: ${automationText}`
559
+ throw buildCaddyRequestError(
560
+ "Failed to initialize Caddy TLS automation",
561
+ automationRes.status,
562
+ automationText
206
563
  );
207
564
  }
208
- const tlsRes = await fetch(`${resolvedApiUrl}/config/apps/tls`, {
209
- method: "PUT",
210
- headers: { "Content-Type": "application/json" },
211
- body: JSON.stringify({ automation: { policies: [] } })
212
- });
565
+ const tlsRes = await caddyFetch(
566
+ `${resolvedApiUrl}/config/apps/tls`,
567
+ {
568
+ method: "PUT",
569
+ headers: { "Content-Type": "application/json" },
570
+ body: JSON.stringify({ automation: { policies: [] } })
571
+ },
572
+ apiUrl,
573
+ adminOrigin
574
+ );
213
575
  if (!tlsRes.ok && tlsRes.status !== 409) {
214
576
  const text = await tlsRes.text();
215
- throw new Error(`Failed to initialize Caddy TLS automation: ${text}`);
577
+ throw buildCaddyRequestError(
578
+ "Failed to initialize Caddy TLS automation",
579
+ tlsRes.status,
580
+ text
581
+ );
216
582
  }
217
583
  }
218
584
  function formatDialAddress(host, port) {
@@ -236,32 +602,70 @@ function extractMatchedHosts(route) {
236
602
  }
237
603
  return hosts;
238
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
+ }
239
615
  function intersectsDomains(targetDomains, routeDomains) {
240
616
  if (targetDomains.length === 0 || routeDomains.length === 0) return false;
241
617
  const targetSet = new Set(targetDomains);
242
618
  return routeDomains.some((domain) => targetSet.has(domain));
243
619
  }
244
- async function cleanupStaleRoutesForDomains(domains, currentRouteId, serverName = DEFAULT_SERVER_NAME, apiUrl) {
245
- if (domains.length === 0) return;
246
- const res = await fetch(
247
- `${getApiUrl(apiUrl)}/config/apps/http/servers/${serverName}/routes`
620
+ async function findManagedRoutesForDomains(domains, serverName = DEFAULT_SERVER_NAME, apiUrl, adminOrigin) {
621
+ if (domains.length === 0) return [];
622
+ const res = await caddyFetch(
623
+ `${getApiUrl(apiUrl)}/config/apps/http/servers/${serverName}/routes`,
624
+ void 0,
625
+ apiUrl,
626
+ adminOrigin
248
627
  );
249
- if (!res.ok) return;
628
+ if (!res.ok) return [];
250
629
  const text = await res.text();
251
630
  const parsed = parseConfig(text);
252
- if (!Array.isArray(parsed)) return;
631
+ if (!Array.isArray(parsed)) return [];
632
+ const routeIds = [];
253
633
  for (const route of parsed) {
254
634
  if (!isRecord(route)) continue;
255
635
  const id = route["@id"];
256
636
  if (typeof id !== "string") continue;
257
- if (!id.startsWith("vite-proxy-")) continue;
258
- if (id === currentRouteId) continue;
637
+ if (!id.startsWith(ROUTE_ID_PREFIX)) continue;
259
638
  const routeDomains = extractMatchedHosts(route);
260
639
  if (!intersectsDomains(domains, routeDomains)) continue;
261
- await removeRoute(id, apiUrl);
640
+ routeIds.push(id);
262
641
  }
642
+ return routeIds;
263
643
  }
264
- async function addRoute(id, domains, port, cors, serverName = DEFAULT_SERVER_NAME, upstreamHost = "127.0.0.1", upstreamHostHeader, apiUrl) {
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;
667
+ }
668
+ async function addRoute(id, domains, port, cors, serverName = DEFAULT_SERVER_NAME, upstreamHost = "127.0.0.1", upstreamHostHeader, apiUrl, adminOrigin) {
265
669
  const handlers = [];
266
670
  if (cors) {
267
671
  handlers.push({
@@ -311,22 +715,24 @@ async function addRoute(id, domains, port, cors, serverName = DEFAULT_SERVER_NAM
311
715
  ],
312
716
  terminal: true
313
717
  };
314
- const res = await fetch(
718
+ const res = await caddyFetch(
315
719
  `${getApiUrl(apiUrl)}/config/apps/http/servers/${serverName}/routes`,
316
720
  {
317
721
  method: "POST",
318
722
  // Append to routes list
319
723
  headers: { "Content-Type": "application/json" },
320
724
  body: JSON.stringify(route)
321
- }
725
+ },
726
+ apiUrl,
727
+ adminOrigin
322
728
  );
323
729
  if (!res.ok) {
324
730
  const text = await res.text();
325
- throw new Error(`Failed to add route: ${text}`);
731
+ throw buildCaddyRequestError("Failed to add route", res.status, text);
326
732
  }
327
733
  }
328
- async function addTlsPolicy(id, domains, apiUrl) {
329
- await ensureTlsAutomation(apiUrl);
734
+ async function addTlsPolicy(id, domains, apiUrl, adminOrigin) {
735
+ await ensureTlsAutomation(apiUrl, adminOrigin);
330
736
  const policy = {
331
737
  "@id": id,
332
738
  subjects: domains,
@@ -336,49 +742,76 @@ async function addTlsPolicy(id, domains, apiUrl) {
336
742
  }
337
743
  ]
338
744
  };
339
- const res = await fetch(`${getApiUrl(apiUrl)}/config/apps/tls/automation/policies`, {
340
- method: "POST",
341
- headers: { "Content-Type": "application/json" },
342
- body: JSON.stringify(policy)
343
- });
745
+ const res = await caddyFetch(
746
+ `${getApiUrl(apiUrl)}/config/apps/tls/automation/policies`,
747
+ {
748
+ method: "POST",
749
+ headers: { "Content-Type": "application/json" },
750
+ body: JSON.stringify(policy)
751
+ },
752
+ apiUrl,
753
+ adminOrigin
754
+ );
344
755
  if (!res.ok) {
345
756
  const text = await res.text();
346
757
  if (isTlsPolicyOverlapError(text)) {
347
758
  return;
348
759
  }
349
- throw new Error(`Failed to add TLS policy: ${text}`);
760
+ throw buildCaddyRequestError("Failed to add TLS policy", res.status, text);
350
761
  }
351
762
  }
352
- async function removeRoute(id, apiUrl) {
353
- const res = await fetch(`${getApiUrl(apiUrl)}/id/${id}`, {
354
- method: "DELETE"
355
- });
763
+ async function removeRoute(id, apiUrl, adminOrigin) {
764
+ const res = await caddyFetch(
765
+ `${getApiUrl(apiUrl)}/id/${id}`,
766
+ {
767
+ method: "DELETE"
768
+ },
769
+ apiUrl,
770
+ adminOrigin
771
+ );
356
772
  if (!res.ok && res.status !== 404) {
357
- console.error(`Failed to remove route ${id}`);
773
+ const text = await res.text();
774
+ const error = buildCaddyRequestError(`Failed to remove route ${id}`, res.status, text);
775
+ console.error(error.message);
358
776
  return false;
359
777
  }
360
778
  return true;
361
779
  }
362
- async function removeTlsPolicy(id, apiUrl) {
363
- const res = await fetch(`${getApiUrl(apiUrl)}/id/${id}`, {
364
- method: "DELETE"
365
- });
780
+ async function removeTlsPolicy(id, apiUrl, adminOrigin) {
781
+ const res = await caddyFetch(
782
+ `${getApiUrl(apiUrl)}/id/${id}`,
783
+ {
784
+ method: "DELETE"
785
+ },
786
+ apiUrl,
787
+ adminOrigin
788
+ );
366
789
  if (!res.ok && res.status !== 404) {
367
- console.error(`Failed to remove TLS policy ${id}`);
790
+ const text = await res.text();
791
+ const error = buildCaddyRequestError(
792
+ `Failed to remove TLS policy ${id}`,
793
+ res.status,
794
+ text
795
+ );
796
+ console.error(error.message);
368
797
  return false;
369
798
  }
370
799
  return true;
371
800
  }
372
- async function ensureCaddyReady(serverName = DEFAULT_SERVER_NAME, apiUrl) {
801
+ async function ensureCaddyReady(serverName = DEFAULT_SERVER_NAME, apiUrl, adminOrigin) {
373
802
  await withApiLock(apiUrl, async () => {
374
- let running = await isCaddyRunning(apiUrl);
375
- if (!running) {
376
- running = await startCaddy(apiUrl);
803
+ const status = await checkCaddyAdminStatus(apiUrl, adminOrigin);
804
+ if (status.status === "api-error") {
805
+ throw status.error;
806
+ }
807
+ let running = status.status === "running";
808
+ if (status.status === "connectivity-error") {
809
+ running = await startCaddy(apiUrl, adminOrigin);
377
810
  }
378
811
  if (!running) {
379
812
  throw new Error("Failed to start Caddy server.");
380
813
  }
381
- await ensureBaseConfig(serverName, apiUrl);
814
+ await ensureBaseConfig(serverName, apiUrl, adminOrigin);
382
815
  });
383
816
  }
384
817
 
@@ -474,6 +907,15 @@ function normalizeCaddyApiUrl(url) {
474
907
  if (!trimmed) return null;
475
908
  return trimmed.replace(/\/+$/g, "");
476
909
  }
910
+ function normalizeCaddyAdminOrigin(origin) {
911
+ const trimmed = origin.trim();
912
+ if (!trimmed) return null;
913
+ try {
914
+ return new URL(trimmed).origin;
915
+ } catch (e) {
916
+ return null;
917
+ }
918
+ }
477
919
  function resolveDomains(options) {
478
920
  if (options.domain) {
479
921
  return normalizeDomains(options.domain);
@@ -492,23 +934,100 @@ function viteCaddyTlsPlugin({
492
934
  cors,
493
935
  serverName,
494
936
  caddyApiUrl,
937
+ caddyAdminOrigin,
495
938
  internalTls,
496
939
  upstreamHostHeader
497
940
  } = {}) {
498
941
  const normalizedApiUrl = caddyApiUrl ? normalizeCaddyApiUrl(caddyApiUrl) : null;
499
942
  const pluginCaddyApiUrl = normalizedApiUrl ?? DEFAULT_CADDY_API_URL;
943
+ const normalizedAdminOrigin = caddyAdminOrigin ? normalizeCaddyAdminOrigin(caddyAdminOrigin) : null;
944
+ const pluginCaddyAdminOrigin = normalizedAdminOrigin ?? pluginCaddyApiUrl;
500
945
  if (caddyApiUrl !== void 0 && !normalizedApiUrl) {
501
946
  console.warn(
502
947
  `caddyApiUrl is empty after trimming. Falling back to ${DEFAULT_CADDY_API_URL}.`
503
948
  );
504
949
  }
505
- function getInstanceKey(domains, configRoot) {
506
- const keyMaterial = JSON.stringify({
507
- domains: [...domains].sort(),
950
+ if (caddyAdminOrigin !== void 0 && !normalizedAdminOrigin) {
951
+ console.warn(
952
+ `caddyAdminOrigin is invalid. Falling back to ${pluginCaddyApiUrl}.`
953
+ );
954
+ }
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,
508
966
  cwd: process.cwd(),
509
- root: configRoot ?? null
510
- });
511
- 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;
512
1031
  }
513
1032
  function isPreviewServer(server) {
514
1033
  return server.config.isProduction;
@@ -541,13 +1060,16 @@ function viteCaddyTlsPlugin({
541
1060
  instanceLabel
542
1061
  });
543
1062
  const domainArray = resolvedDomains ?? [];
544
- const routeId = `vite-proxy-${getInstanceKey(domainArray, config.root)}`;
545
- const shouldUseInternalTls = internalTls ?? (baseDomain !== void 0 || loopbackDomain !== void 0 || domain !== void 0);
546
- 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;
547
1067
  let cleanupStarted = false;
548
1068
  let resolvedPort = null;
549
1069
  let resolvedHost = null;
550
1070
  let setupStarted = false;
1071
+ let activeOwnershipRecord = null;
1072
+ let ownershipHeartbeat = null;
551
1073
  function buildDomainResolutionMessage() {
552
1074
  const issues = [];
553
1075
  if (domain !== void 0 && !normalizeDomains(domain)) {
@@ -653,13 +1175,16 @@ function viteCaddyTlsPlugin({
653
1175
  async function cleanupRoute() {
654
1176
  if (cleanupStarted) return;
655
1177
  cleanupStarted = true;
656
- if (tlsPolicyId) {
657
- await removeWithRetry(
658
- () => removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl),
659
- "TLS policy"
660
- );
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;
661
1187
  }
662
- await removeWithRetry(() => removeRoute(routeId, pluginCaddyApiUrl), "route");
663
1188
  }
664
1189
  function onServerClose() {
665
1190
  void cleanupRoute();
@@ -699,12 +1224,31 @@ function viteCaddyTlsPlugin({
699
1224
  console.error(`Failed to remove ${label} after ${maxAttempts} attempts.`);
700
1225
  return false;
701
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
+ }
702
1246
  async function setupRoute() {
703
1247
  if (!validateCaddyIsInstalled()) {
704
1248
  return;
705
1249
  }
706
1250
  try {
707
- await ensureCaddyReady(serverName, pluginCaddyApiUrl);
1251
+ await ensureCaddyReady(serverName, pluginCaddyApiUrl, pluginCaddyAdminOrigin);
708
1252
  } catch (e) {
709
1253
  console.error(
710
1254
  `Failed to configure Caddy base settings. Is the Caddy Admin API reachable at ${pluginCaddyApiUrl}?`,
@@ -714,23 +1258,85 @@ function viteCaddyTlsPlugin({
714
1258
  }
715
1259
  const port = getServerPort();
716
1260
  const upstreamHost = getUpstreamHost();
717
- 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(
718
1291
  domainArray,
719
- routeId,
720
1292
  serverName,
721
- pluginCaddyApiUrl
722
- );
723
- await removeRoute(routeId, pluginCaddyApiUrl);
1293
+ pluginCaddyApiUrl,
1294
+ 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
+ }
724
1324
  if (tlsPolicyId) {
725
- await removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl);
726
1325
  try {
727
- await addTlsPolicy(tlsPolicyId, domainArray, pluginCaddyApiUrl);
1326
+ await addTlsPolicy(
1327
+ tlsPolicyId,
1328
+ domainArray,
1329
+ pluginCaddyApiUrl,
1330
+ pluginCaddyAdminOrigin
1331
+ );
728
1332
  tlsPolicyAdded = true;
729
1333
  } catch (e) {
730
1334
  console.error(
731
1335
  `Failed to add TLS policy to Caddy. Is the Caddy Admin API reachable at ${pluginCaddyApiUrl}?`,
732
1336
  e
733
1337
  );
1338
+ await releaseOwnershipRecord(activeOwnershipRecord);
1339
+ activeOwnershipRecord = null;
734
1340
  return;
735
1341
  }
736
1342
  }
@@ -743,18 +1349,24 @@ function viteCaddyTlsPlugin({
743
1349
  serverName,
744
1350
  upstreamHost,
745
1351
  upstreamHostHeader,
746
- pluginCaddyApiUrl
1352
+ pluginCaddyApiUrl,
1353
+ pluginCaddyAdminOrigin
747
1354
  );
748
1355
  } catch (e) {
749
1356
  if (tlsPolicyAdded && tlsPolicyId) {
750
- await removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl);
1357
+ await removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl, pluginCaddyAdminOrigin);
751
1358
  }
1359
+ await releaseOwnershipRecord(activeOwnershipRecord);
1360
+ activeOwnershipRecord = null;
752
1361
  console.error(
753
1362
  `Failed to add route to Caddy. Is the Caddy Admin API reachable at ${pluginCaddyApiUrl}?`,
754
1363
  e
755
1364
  );
756
1365
  return;
757
1366
  }
1367
+ if (activeOwnershipRecord) {
1368
+ startOwnershipHeartbeat(activeOwnershipRecord);
1369
+ }
758
1370
  console.log("\n\u{1F512} Caddy is proxying your traffic on https");
759
1371
  console.log(`
760
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.0",
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
  }