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.
@@ -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._stopEffects.push(
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
- this.caddyfile = await genCaddyfile(ctx)
252
- await this.writeCaddyfile()
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
- return resolve()
425
+ void resolveWhenReady()
364
426
  }
365
427
  })
366
428
 
367
429
  this._caddyChild!.stdout?.on('data', (_data) => {
368
430
  consola.info(_data.toString())
369
- resolve()
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
- setupCleanup()
378
-
379
- return resolve()
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._caddyChild?.kill()
471
+ await this.stopCaddy()
402
472
  try {
403
473
  await lockfile.unlock(caddyLockFilePath).catch(() => { })
404
474
  await unlink(caddyFilePath).catch(() => { })
@@ -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
+ }
@@ -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
- // Activate when served over HTTPS to bypass WSS self-signed cert issues
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,