unplugin-https-reverse-proxy 4.0.2 → 4.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-JA3D5AWS.js → chunk-5R33PUJP.js} +168 -18
- package/dist/{chunk-RSBXFJ3Z.js → chunk-C5A7XLKL.js} +9 -2
- package/dist/esbuild.cjs +175 -19
- package/dist/esbuild.js +2 -2
- package/dist/hmr-sse-bridge.cjs +12 -4
- package/dist/hmr-sse-bridge.d.cts +4 -3
- package/dist/hmr-sse-bridge.d.ts +4 -3
- package/dist/hmr-sse-bridge.js +5 -3
- package/dist/index.cjs +175 -19
- package/dist/index.js +2 -2
- package/dist/nuxt.cjs +174 -18
- package/dist/nuxt.js +2 -2
- package/dist/rollup.cjs +175 -19
- package/dist/rollup.js +2 -2
- package/dist/rspack.cjs +175 -19
- package/dist/rspack.js +2 -2
- package/dist/vite.cjs +175 -19
- package/dist/vite.js +2 -2
- package/dist/webpack.cjs +175 -19
- package/dist/webpack.js +2 -2
- package/package.json +1 -1
- package/src/caddy/caddyfile.ts +1 -1
- package/src/caddy/index.ts +86 -16
- package/src/caddy/log.ts +16 -0
- package/src/caddy/tls-health.ts +142 -0
- package/src/hmr-sse-bridge.ts +14 -3
- package/src/index.ts +3 -3
package/src/caddy/index.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { chmodRecursive, consola, once } from '../utils'
|
|
|
14
14
|
import { genCaddyfile, validateTemplateContext } from './caddyfile'
|
|
15
15
|
import { useCaddyConfig } from './config'
|
|
16
16
|
import { caddyFilePath, caddyLockFilePath, caddyPath, supportList, TEMP_DIR } from './constants'
|
|
17
|
+
import { isCaddyReloadCancellation } from './log'
|
|
18
|
+
import { formatTlsHealthFailure, waitForLocalTls } from './tls-health'
|
|
17
19
|
import { kill, logProgress, logProgressOver, tryPort } from './utils'
|
|
18
20
|
|
|
19
21
|
export async function download(): Promise<string> {
|
|
@@ -187,6 +189,43 @@ export class CaddyInstant {
|
|
|
187
189
|
this.inited = true
|
|
188
190
|
}
|
|
189
191
|
|
|
192
|
+
private async ensureTlsReady(targetHost: string): Promise<void> {
|
|
193
|
+
const health = await waitForLocalTls(targetHost)
|
|
194
|
+
if (!health.healthy)
|
|
195
|
+
throw new Error(formatTlsHealthFailure(targetHost, health))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async updateCaddyfile(): Promise<void> {
|
|
199
|
+
const { configJsonRef } = getCaddyConfig()
|
|
200
|
+
const proxies = Object.values(configJsonRef() || {})
|
|
201
|
+
const ctx = validateTemplateContext({
|
|
202
|
+
caddy_root: this.caddyRoot,
|
|
203
|
+
proxies,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
this.caddyfile = await genCaddyfile(ctx)
|
|
207
|
+
await this.writeCaddyfile()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async stopCaddy(): Promise<void> {
|
|
211
|
+
try {
|
|
212
|
+
await got.post('http://127.0.0.1:2019/stop', {
|
|
213
|
+
headers: {
|
|
214
|
+
Origin: 'http://localhost:2019',
|
|
215
|
+
},
|
|
216
|
+
retry: {
|
|
217
|
+
limit: 0,
|
|
218
|
+
},
|
|
219
|
+
timeout: {
|
|
220
|
+
request: 2000,
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
catch {}
|
|
225
|
+
|
|
226
|
+
this._caddyChild?.kill()
|
|
227
|
+
}
|
|
228
|
+
|
|
190
229
|
/**
|
|
191
230
|
* @param source like `127.0.0.1:8080`
|
|
192
231
|
* @param target like `test.abc.com`. And if you provide a port ,it will be ignore.
|
|
@@ -239,17 +278,17 @@ export class CaddyInstant {
|
|
|
239
278
|
const { writeCaddyConfig } = getCaddyConfig()
|
|
240
279
|
writeCaddyConfig(proxies)
|
|
241
280
|
|
|
242
|
-
this.
|
|
243
|
-
effect(async () => {
|
|
244
|
-
const { configJsonRef } = getCaddyConfig()
|
|
245
|
-
const proxies = Object.values(configJsonRef() || {})
|
|
246
|
-
const ctx = validateTemplateContext({
|
|
247
|
-
caddy_root: this.caddyRoot,
|
|
248
|
-
proxies,
|
|
249
|
-
})
|
|
281
|
+
await this.updateCaddyfile()
|
|
250
282
|
|
|
251
|
-
|
|
252
|
-
|
|
283
|
+
let skipInitialCaddyfileEffect = true
|
|
284
|
+
this._stopEffects.push(
|
|
285
|
+
effect(() => {
|
|
286
|
+
getCaddyConfig().configJsonRef()
|
|
287
|
+
if (skipInitialCaddyfileEffect) {
|
|
288
|
+
skipInitialCaddyfileEffect = false
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
void this.updateCaddyfile()
|
|
253
292
|
}),
|
|
254
293
|
)
|
|
255
294
|
|
|
@@ -264,6 +303,25 @@ export class CaddyInstant {
|
|
|
264
303
|
}
|
|
265
304
|
|
|
266
305
|
return new Promise<void>((resolve, reject) => {
|
|
306
|
+
let startupCheckStarted = false
|
|
307
|
+
|
|
308
|
+
const resolveWhenReady = async (): Promise<void> => {
|
|
309
|
+
if (startupCheckStarted)
|
|
310
|
+
return
|
|
311
|
+
startupCheckStarted = true
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
if (https)
|
|
315
|
+
await this.ensureTlsReady(targetHost)
|
|
316
|
+
resolve()
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (!this.locked)
|
|
320
|
+
await this.baseCleanup(restore)
|
|
321
|
+
reject(error)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
267
325
|
const setupCleanup = (): void => {
|
|
268
326
|
process.on('SIGINT', async () => {
|
|
269
327
|
if (this.stoped)
|
|
@@ -340,6 +398,10 @@ export class CaddyInstant {
|
|
|
340
398
|
// caddy log
|
|
341
399
|
// eslint-disable-next-line no-console
|
|
342
400
|
showCaddyLog && line && console.info(line)
|
|
401
|
+
if (isCaddyReloadCancellation(line)) {
|
|
402
|
+
consola.info('Caddy restarted before TLS issuance completed; retrying.\n')
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
343
405
|
try {
|
|
344
406
|
if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) {
|
|
345
407
|
if (line.includes('parsing caddyfile tokens')) {
|
|
@@ -360,13 +422,13 @@ export class CaddyInstant {
|
|
|
360
422
|
consola.success('Detect added a new reverse proxy config, restarted.\n')
|
|
361
423
|
|
|
362
424
|
if (line.includes('caddy proxying') || line.includes('serving initial'))
|
|
363
|
-
|
|
425
|
+
void resolveWhenReady()
|
|
364
426
|
}
|
|
365
427
|
})
|
|
366
428
|
|
|
367
429
|
this._caddyChild!.stdout?.on('data', (_data) => {
|
|
368
430
|
consola.info(_data.toString())
|
|
369
|
-
|
|
431
|
+
void resolveWhenReady()
|
|
370
432
|
})
|
|
371
433
|
|
|
372
434
|
setupCleanup()
|
|
@@ -374,9 +436,17 @@ export class CaddyInstant {
|
|
|
374
436
|
else {
|
|
375
437
|
consola.info('Caddy is running in another process.')
|
|
376
438
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
439
|
+
void (async () => {
|
|
440
|
+
try {
|
|
441
|
+
if (https)
|
|
442
|
+
await this.ensureTlsReady(targetHost)
|
|
443
|
+
setupCleanup()
|
|
444
|
+
resolve()
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
reject(error)
|
|
448
|
+
}
|
|
449
|
+
})()
|
|
380
450
|
}
|
|
381
451
|
})
|
|
382
452
|
}
|
|
@@ -398,7 +468,7 @@ export class CaddyInstant {
|
|
|
398
468
|
consola.fail('Restore host failed.\n')
|
|
399
469
|
}
|
|
400
470
|
if (!this.locked && !this.stoped) {
|
|
401
|
-
this.
|
|
471
|
+
await this.stopCaddy()
|
|
402
472
|
try {
|
|
403
473
|
await lockfile.unlock(caddyLockFilePath).catch(() => { })
|
|
404
474
|
await unlink(caddyFilePath).catch(() => { })
|
package/src/caddy/log.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface CaddyLogEntry {
|
|
2
|
+
error?: unknown
|
|
3
|
+
logger?: unknown
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isCaddyReloadCancellation(line: string): boolean {
|
|
7
|
+
try {
|
|
8
|
+
const entry = JSON.parse(line) as CaddyLogEntry
|
|
9
|
+
return entry.logger === 'tls'
|
|
10
|
+
&& typeof entry.error === 'string'
|
|
11
|
+
&& entry.error.endsWith('context canceled')
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { ConnectionOptions, PeerCertificate, TLSSocket } from 'node:tls'
|
|
2
|
+
import { checkServerIdentity, connect } from 'node:tls'
|
|
3
|
+
|
|
4
|
+
export type TlsHealthReason =
|
|
5
|
+
| 'certificate_expired'
|
|
6
|
+
| 'certificate_invalid'
|
|
7
|
+
| 'certificate_missing'
|
|
8
|
+
| 'connection_failed'
|
|
9
|
+
| 'hostname_mismatch'
|
|
10
|
+
|
|
11
|
+
export type TlsHealthResult =
|
|
12
|
+
| {
|
|
13
|
+
healthy: true
|
|
14
|
+
expiresAt: Date
|
|
15
|
+
}
|
|
16
|
+
| {
|
|
17
|
+
healthy: false
|
|
18
|
+
reason: TlsHealthReason
|
|
19
|
+
expiresAt?: Date
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type HostnameVerifier = (hostname: string, certificate: PeerCertificate) => Error | undefined
|
|
23
|
+
|
|
24
|
+
type TlsConnector = (options: ConnectionOptions) => TLSSocket
|
|
25
|
+
type TlsProbe = (hostname: string, options: ProbeLocalTlsOptions) => Promise<TlsHealthResult>
|
|
26
|
+
type Sleep = (ms: number) => Promise<void>
|
|
27
|
+
|
|
28
|
+
export function assessTlsCertificate(
|
|
29
|
+
certificate: PeerCertificate,
|
|
30
|
+
hostname: string,
|
|
31
|
+
now = Date.now(),
|
|
32
|
+
verifyHostname: HostnameVerifier = checkServerIdentity,
|
|
33
|
+
): TlsHealthResult {
|
|
34
|
+
if (!certificate.valid_to)
|
|
35
|
+
return { healthy: false, reason: 'certificate_missing' }
|
|
36
|
+
|
|
37
|
+
const expiresAt = new Date(certificate.valid_to)
|
|
38
|
+
if (Number.isNaN(expiresAt.getTime()))
|
|
39
|
+
return { healthy: false, reason: 'certificate_invalid' }
|
|
40
|
+
|
|
41
|
+
if (expiresAt.getTime() <= now)
|
|
42
|
+
return { healthy: false, reason: 'certificate_expired', expiresAt }
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
if (verifyHostname(hostname, certificate))
|
|
46
|
+
return { healthy: false, reason: 'hostname_mismatch' }
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { healthy: false, reason: 'certificate_invalid' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { healthy: true, expiresAt }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ProbeLocalTlsOptions {
|
|
56
|
+
connect?: TlsConnector
|
|
57
|
+
port?: number
|
|
58
|
+
timeoutMs?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function probeLocalTls(hostname: string, options: ProbeLocalTlsOptions = {}): Promise<TlsHealthResult> {
|
|
62
|
+
const {
|
|
63
|
+
connect: openTlsConnection = connect,
|
|
64
|
+
port = 443,
|
|
65
|
+
timeoutMs = 1000,
|
|
66
|
+
} = options
|
|
67
|
+
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
let settled = false
|
|
70
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
71
|
+
const socket = openTlsConnection({
|
|
72
|
+
host: '127.0.0.1',
|
|
73
|
+
port,
|
|
74
|
+
servername: hostname,
|
|
75
|
+
rejectUnauthorized: false,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const finish = (result: TlsHealthResult): void => {
|
|
79
|
+
if (settled)
|
|
80
|
+
return
|
|
81
|
+
settled = true
|
|
82
|
+
if (timeout)
|
|
83
|
+
clearTimeout(timeout)
|
|
84
|
+
socket.destroy()
|
|
85
|
+
resolve(result)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
timeout = setTimeout(() => {
|
|
89
|
+
finish({ healthy: false, reason: 'connection_failed' })
|
|
90
|
+
}, timeoutMs)
|
|
91
|
+
|
|
92
|
+
socket.once('secureConnect', () => {
|
|
93
|
+
finish(assessTlsCertificate(socket.getPeerCertificate(), hostname))
|
|
94
|
+
})
|
|
95
|
+
socket.once('error', () => {
|
|
96
|
+
finish({ healthy: false, reason: 'connection_failed' })
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface WaitForLocalTlsOptions extends ProbeLocalTlsOptions {
|
|
102
|
+
probe?: TlsProbe
|
|
103
|
+
retryIntervalMs?: number
|
|
104
|
+
retryWindowMs?: number
|
|
105
|
+
sleep?: Sleep
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sleep(ms: number): Promise<void> {
|
|
109
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function waitForLocalTls(hostname: string, options: WaitForLocalTlsOptions = {}): Promise<TlsHealthResult> {
|
|
113
|
+
const {
|
|
114
|
+
probe: checkTls = probeLocalTls,
|
|
115
|
+
retryIntervalMs = 250,
|
|
116
|
+
retryWindowMs = 30_000,
|
|
117
|
+
sleep: wait = sleep,
|
|
118
|
+
...probeOptions
|
|
119
|
+
} = options
|
|
120
|
+
const deadline = Date.now() + retryWindowMs
|
|
121
|
+
let result: TlsHealthResult = { healthy: false, reason: 'connection_failed' }
|
|
122
|
+
|
|
123
|
+
do {
|
|
124
|
+
result = await checkTls(hostname, probeOptions)
|
|
125
|
+
if (result.healthy)
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
const remaining = deadline - Date.now()
|
|
129
|
+
if (remaining > 0)
|
|
130
|
+
await wait(Math.min(retryIntervalMs, remaining))
|
|
131
|
+
} while (Date.now() < deadline)
|
|
132
|
+
|
|
133
|
+
return result
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function formatTlsHealthFailure(hostname: string, result: Exclude<TlsHealthResult, { healthy: true }>): string {
|
|
137
|
+
const reason = result.reason === 'certificate_expired' && result.expiresAt
|
|
138
|
+
? `the certificate expired at ${result.expiresAt.toISOString()}`
|
|
139
|
+
: result.reason.replaceAll('_', ' ')
|
|
140
|
+
|
|
141
|
+
return `[unplugin-https-reverse-proxy] Caddy cannot serve valid HTTPS for ${hostname}: ${reason}. Restart Caddy to retry its automatic certificate renewal. Do not remove Caddy PKI data automatically because that replaces the trusted local root certificate.`
|
|
142
|
+
}
|
package/src/hmr-sse-bridge.ts
CHANGED
|
@@ -134,14 +134,25 @@ function handleClientMessage(
|
|
|
134
134
|
})
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
export function shouldUseHmrSseBridge(protocol: string, userAgent: string): boolean {
|
|
138
|
+
if (protocol !== 'https:')
|
|
139
|
+
return false
|
|
140
|
+
|
|
141
|
+
const isApplePlatform = /Macintosh|iPhone|iPad|iPod/.test(userAgent)
|
|
142
|
+
const isSafari = /Version\//.test(userAgent)
|
|
143
|
+
&& /Safari\//.test(userAgent)
|
|
144
|
+
&& !/(?:Chrome|Chromium|CriOS|Edg|EdgiOS|OPR|OPiOS|Firefox|FxiOS)\//.test(userAgent)
|
|
145
|
+
|
|
146
|
+
return isApplePlatform && isSafari
|
|
147
|
+
}
|
|
148
|
+
|
|
137
149
|
/**
|
|
138
|
-
* Client-side script that patches WebSocket to use SSE+fetch on iOS Safari.
|
|
150
|
+
* Client-side script that patches WebSocket to use SSE+fetch on macOS/iOS Safari.
|
|
139
151
|
* This script is injected via Vite's transformIndexHtml hook.
|
|
140
152
|
*/
|
|
141
153
|
export const hmrSseBridgeClientScript = `
|
|
142
154
|
(function() {
|
|
143
|
-
|
|
144
|
-
if (location.protocol !== 'https:') return;
|
|
155
|
+
if (!(${shouldUseHmrSseBridge.toString()})(location.protocol, navigator.userAgent)) return;
|
|
145
156
|
|
|
146
157
|
var OriginalWebSocket = window.WebSocket;
|
|
147
158
|
|
package/src/index.ts
CHANGED
|
@@ -123,17 +123,17 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
|
|
|
123
123
|
|
|
124
124
|
server.printUrls = () => vitePrintUrls(options, source, target, base, _printUrls)
|
|
125
125
|
|
|
126
|
-
// Setup SSE bridge for iOS Safari WSS workaround
|
|
126
|
+
// Setup SSE bridge for macOS/iOS Safari WSS workaround
|
|
127
127
|
if (options.https) {
|
|
128
128
|
setupHmrSseBridge(server)
|
|
129
129
|
hmrSseBridgeActive = true
|
|
130
|
-
consola.info('HMR SSE bridge enabled for iOS Safari WSS workaround')
|
|
130
|
+
consola.info('HMR SSE bridge enabled for macOS/iOS Safari WSS workaround')
|
|
131
131
|
}
|
|
132
132
|
},
|
|
133
133
|
transformIndexHtml() {
|
|
134
134
|
if (!hmrSseBridgeActive)
|
|
135
135
|
return []
|
|
136
|
-
// Inject client-side script to patch WebSocket on iOS Safari
|
|
136
|
+
// Inject client-side script to patch WebSocket on macOS/iOS Safari
|
|
137
137
|
return [{
|
|
138
138
|
tag: 'script',
|
|
139
139
|
children: hmrSseBridgeClientScript,
|