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.
- package/README.md +12 -0
- package/dist/index.js +424 -38
- 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 {
|
|
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
|
|
135
|
-
|
|
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 >=
|
|
211
|
+
if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
|
|
153
212
|
await fn();
|
|
154
213
|
return;
|
|
155
214
|
}
|
|
156
|
-
await sleep(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
|
761
|
-
const
|
|
762
|
-
const
|
|
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 (
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
57
|
+
"vite": "^8.0.0",
|
|
58
58
|
"vitest": "^4.0.16"
|
|
59
59
|
}
|
|
60
60
|
}
|