diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2fdad7..c419835 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-node@v5 with: - node-version: lts/* + node-version: 22.x registry-url: 'https://registry.npmjs.org' - run: npm i -g npm@latest @@ -30,10 +30,10 @@ jobs: run: npm i -g pnpm@9 - name: Install Dependencies - run: pnpm i - - - name: PNPM build - run: pnpm run build + run: pnpm i --frozen-lockfile - name: Publish to NPM - run: pnpm publish --access public --no-git-checks + run: | + pnpm run build + TARBALL=$(pnpm pack --pack-destination "$RUNNER_TEMP" | tail -n 1) + npm publish "$TARBALL" --access public diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..4128961 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +pnpm = "9" +node = "18" diff --git a/package.json b/package.json index 4cac7dd..8670d4d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.0.0", + "version": "4.0.4", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", diff --git a/src/caddy/caddyfile.ts b/src/caddy/caddyfile.ts index e0ea76e..7693edf 100644 --- a/src/caddy/caddyfile.ts +++ b/src/caddy/caddyfile.ts @@ -25,7 +25,7 @@ export function validateTemplateContext(ctx: unknown): typeof CaddyContext.infer export const caddyTemplate = `{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { diff --git a/src/caddy/index.ts b/src/caddy/index.ts index ba4280c..8e7eb9c 100644 --- a/src/caddy/index.ts +++ b/src/caddy/index.ts @@ -14,6 +14,8 @@ import { chmodRecursive, consola, once } from '../utils' import { genCaddyfile, validateTemplateContext } from './caddyfile' import { useCaddyConfig } from './config' import { caddyFilePath, caddyLockFilePath, caddyPath, supportList, TEMP_DIR } from './constants' +import { isCaddyReloadCancellation } from './log' +import { formatTlsHealthFailure, waitForLocalTls } from './tls-health' import { kill, logProgress, logProgressOver, tryPort } from './utils' export async function download(): Promise { @@ -114,6 +116,19 @@ function getCaddyConfig(): ReturnType { return caddyConfig } +/** Split `host:port` (IPv4 or hostname); supports `[::1]:port`. */ +function splitHostPort(addr: string): { host: string, port: string } { + const lastColon = addr.lastIndexOf(':') + if (lastColon === -1) + return { host: addr, port: '' } + if (addr.startsWith('[')) { + const end = addr.indexOf(']') + if (end !== -1 && addr[end + 1] === ':') + return { host: addr.slice(1, end), port: addr.slice(end + 2) } + } + return { host: addr.slice(0, lastColon), port: addr.slice(lastColon + 1) } +} + export class CaddyInstant { private inited = false private stoped = false @@ -174,12 +189,52 @@ export class CaddyInstant { this.inited = true } + private async ensureTlsReady(targetHost: string): Promise { + const health = await waitForLocalTls(targetHost) + if (!health.healthy) + throw new Error(formatTlsHealthFailure(targetHost, health)) + } + + private async updateCaddyfile(): Promise { + const { configJsonRef } = getCaddyConfig() + const proxies = Object.values(configJsonRef() || {}) + const ctx = validateTemplateContext({ + caddy_root: this.caddyRoot, + proxies, + }) + + this.caddyfile = await genCaddyfile(ctx) + await this.writeCaddyfile() + } + + private async stopCaddy(): Promise { + try { + await got.post('http://127.0.0.1:2019/stop', { + headers: { + Origin: 'http://localhost:2019', + }, + retry: { + limit: 0, + }, + timeout: { + request: 2000, + }, + }) + } + catch {} + + this._caddyChild?.kill() + } + /** * @param source like `127.0.0.1:8080` * @param target like `test.abc.com`. And if you provide a port ,it will be ignore. */ async run(source: string, target: string, options?: Partial): Promise { source = source.replace('0.0.0.0', '127.0.0.1') + // Caddy dials the upstream host via DNS; `localhost` can hit lookup timeouts (VPN/DNS). + // Use loopback IP so the proxy never depends on resolving "localhost". + source = source.replace(/^localhost(?=:)/i, '127.0.0.1') const { restore = true, base = '/', @@ -194,11 +249,11 @@ export class CaddyInstant { if (!this.inited) await this.init() - if (!await addHost(source.split(':')[0], target.split(':')[0])) + const sourceHost = splitHostPort(source).host + const targetHost = splitHostPort(target).host + if (!await addHost(sourceHost, targetHost)) throw new Error('update host failed') consola.success('update host success\n') - - const targetHost = target.split(':')[0] const normalizeBase = (b: string): string => { let out = b || '/' if (!out.startsWith('/')) @@ -223,17 +278,17 @@ export class CaddyInstant { const { writeCaddyConfig } = getCaddyConfig() writeCaddyConfig(proxies) - this._stopEffects.push( - effect(async () => { - const { configJsonRef } = getCaddyConfig() - const proxies = Object.values(configJsonRef() || {}) - const ctx = validateTemplateContext({ - caddy_root: this.caddyRoot, - proxies, - }) + await this.updateCaddyfile() - this.caddyfile = await genCaddyfile(ctx) - await this.writeCaddyfile() + let skipInitialCaddyfileEffect = true + this._stopEffects.push( + effect(() => { + getCaddyConfig().configJsonRef() + if (skipInitialCaddyfileEffect) { + skipInitialCaddyfileEffect = false + return + } + void this.updateCaddyfile() }), ) @@ -248,6 +303,25 @@ export class CaddyInstant { } return new Promise((resolve, reject) => { + let startupCheckStarted = false + + const resolveWhenReady = async (): Promise => { + if (startupCheckStarted) + return + startupCheckStarted = true + + try { + if (https) + await this.ensureTlsReady(targetHost) + resolve() + } + catch (error) { + if (!this.locked) + await this.baseCleanup(restore) + reject(error) + } + } + const setupCleanup = (): void => { process.on('SIGINT', async () => { if (this.stoped) @@ -270,7 +344,7 @@ export class CaddyInstant { // @ts-expect-error override process.exit = async (code?: number) => { - const port = Number(source.split(':')[1]) + const port = Number(splitHostPort(source).port) if (!Number.isNaN(port) && await tryPort(port)) await kill(port) @@ -279,6 +353,9 @@ export class CaddyInstant { const viteCacheDir = `${pwd}/node_modules/.vite` if (existsSync(viteCacheDir)) await chmodRecursive(viteCacheDir, 0o777).catch(() => { }) + const viteTempCacheDir = `${pwd}/node_modules/.vite-temp` + if (existsSync(viteTempCacheDir)) + await chmodRecursive(viteTempCacheDir, 0o777).catch(() => { }) const nuxtCacheDir = `${pwd}/.nuxt` if (existsSync(nuxtCacheDir)) await chmodRecursive(nuxtCacheDir, 0o777).catch(() => { }) @@ -321,6 +398,10 @@ export class CaddyInstant { // caddy log // eslint-disable-next-line no-console showCaddyLog && line && console.info(line) + if (isCaddyReloadCancellation(line)) { + consola.info('Caddy restarted before TLS issuance completed; retrying.\n') + continue + } try { if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) { if (line.includes('parsing caddyfile tokens')) { @@ -341,13 +422,13 @@ export class CaddyInstant { consola.success('Detect added a new reverse proxy config, restarted.\n') if (line.includes('caddy proxying') || line.includes('serving initial')) - return resolve() + void resolveWhenReady() } }) this._caddyChild!.stdout?.on('data', (_data) => { consola.info(_data.toString()) - resolve() + void resolveWhenReady() }) setupCleanup() @@ -355,9 +436,17 @@ export class CaddyInstant { else { consola.info('Caddy is running in another process.') - setupCleanup() - - return resolve() + void (async () => { + try { + if (https) + await this.ensureTlsReady(targetHost) + setupCleanup() + resolve() + } + catch (error) { + reject(error) + } + })() } }) } @@ -367,7 +456,7 @@ export class CaddyInstant { try { if (restore && this._source && this._target) { - if (await removeHost(this._source.split(':')[0], this._target.split(':')[0])) + if (await removeHost(splitHostPort(this._source).host, splitHostPort(this._target).host)) consola.success('Restore host success.\n') else @@ -379,7 +468,7 @@ export class CaddyInstant { consola.fail('Restore host failed.\n') } if (!this.locked && !this.stoped) { - this._caddyChild?.kill() + await this.stopCaddy() try { await lockfile.unlock(caddyLockFilePath).catch(() => { }) await unlink(caddyFilePath).catch(() => { }) diff --git a/src/caddy/log.ts b/src/caddy/log.ts new file mode 100644 index 0000000..a1184ab --- /dev/null +++ b/src/caddy/log.ts @@ -0,0 +1,16 @@ +interface CaddyLogEntry { + error?: unknown + logger?: unknown +} + +export function isCaddyReloadCancellation(line: string): boolean { + try { + const entry = JSON.parse(line) as CaddyLogEntry + return entry.logger === 'tls' + && typeof entry.error === 'string' + && entry.error.endsWith('context canceled') + } + catch { + return false + } +} diff --git a/src/caddy/tls-health.ts b/src/caddy/tls-health.ts new file mode 100644 index 0000000..36cf4c6 --- /dev/null +++ b/src/caddy/tls-health.ts @@ -0,0 +1,142 @@ +import type { ConnectionOptions, PeerCertificate, TLSSocket } from 'node:tls' +import { checkServerIdentity, connect } from 'node:tls' + +export type TlsHealthReason = + | 'certificate_expired' + | 'certificate_invalid' + | 'certificate_missing' + | 'connection_failed' + | 'hostname_mismatch' + +export type TlsHealthResult = + | { + healthy: true + expiresAt: Date + } + | { + healthy: false + reason: TlsHealthReason + expiresAt?: Date + } + +export type HostnameVerifier = (hostname: string, certificate: PeerCertificate) => Error | undefined + +type TlsConnector = (options: ConnectionOptions) => TLSSocket +type TlsProbe = (hostname: string, options: ProbeLocalTlsOptions) => Promise +type Sleep = (ms: number) => Promise + +export function assessTlsCertificate( + certificate: PeerCertificate, + hostname: string, + now = Date.now(), + verifyHostname: HostnameVerifier = checkServerIdentity, +): TlsHealthResult { + if (!certificate.valid_to) + return { healthy: false, reason: 'certificate_missing' } + + const expiresAt = new Date(certificate.valid_to) + if (Number.isNaN(expiresAt.getTime())) + return { healthy: false, reason: 'certificate_invalid' } + + if (expiresAt.getTime() <= now) + return { healthy: false, reason: 'certificate_expired', expiresAt } + + try { + if (verifyHostname(hostname, certificate)) + return { healthy: false, reason: 'hostname_mismatch' } + } + catch { + return { healthy: false, reason: 'certificate_invalid' } + } + + return { healthy: true, expiresAt } +} + +interface ProbeLocalTlsOptions { + connect?: TlsConnector + port?: number + timeoutMs?: number +} + +export function probeLocalTls(hostname: string, options: ProbeLocalTlsOptions = {}): Promise { + const { + connect: openTlsConnection = connect, + port = 443, + timeoutMs = 1000, + } = options + + return new Promise((resolve) => { + let settled = false + let timeout: ReturnType | undefined + const socket = openTlsConnection({ + host: '127.0.0.1', + port, + servername: hostname, + rejectUnauthorized: false, + }) + + const finish = (result: TlsHealthResult): void => { + if (settled) + return + settled = true + if (timeout) + clearTimeout(timeout) + socket.destroy() + resolve(result) + } + + timeout = setTimeout(() => { + finish({ healthy: false, reason: 'connection_failed' }) + }, timeoutMs) + + socket.once('secureConnect', () => { + finish(assessTlsCertificate(socket.getPeerCertificate(), hostname)) + }) + socket.once('error', () => { + finish({ healthy: false, reason: 'connection_failed' }) + }) + }) +} + +interface WaitForLocalTlsOptions extends ProbeLocalTlsOptions { + probe?: TlsProbe + retryIntervalMs?: number + retryWindowMs?: number + sleep?: Sleep +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export async function waitForLocalTls(hostname: string, options: WaitForLocalTlsOptions = {}): Promise { + const { + probe: checkTls = probeLocalTls, + retryIntervalMs = 250, + retryWindowMs = 30_000, + sleep: wait = sleep, + ...probeOptions + } = options + const deadline = Date.now() + retryWindowMs + let result: TlsHealthResult = { healthy: false, reason: 'connection_failed' } + + do { + result = await checkTls(hostname, probeOptions) + if (result.healthy) + return result + + const remaining = deadline - Date.now() + if (remaining > 0) + await wait(Math.min(retryIntervalMs, remaining)) + } while (Date.now() < deadline) + + return result +} + +export function formatTlsHealthFailure(hostname: string, result: Exclude): string { + const reason = result.reason === 'certificate_expired' && result.expiresAt + ? `the certificate expired at ${result.expiresAt.toISOString()}` + : result.reason.replaceAll('_', ' ') + + 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.` +} diff --git a/src/hmr-sse-bridge.ts b/src/hmr-sse-bridge.ts index 1055bfc..742ba61 100644 --- a/src/hmr-sse-bridge.ts +++ b/src/hmr-sse-bridge.ts @@ -134,14 +134,25 @@ function handleClientMessage( }) } +export function shouldUseHmrSseBridge(protocol: string, userAgent: string): boolean { + if (protocol !== 'https:') + return false + + const isApplePlatform = /Macintosh|iPhone|iPad|iPod/.test(userAgent) + const isSafari = /Version\//.test(userAgent) + && /Safari\//.test(userAgent) + && !/(?:Chrome|Chromium|CriOS|Edg|EdgiOS|OPR|OPiOS|Firefox|FxiOS)\//.test(userAgent) + + return isApplePlatform && isSafari +} + /** - * Client-side script that patches WebSocket to use SSE+fetch on iOS Safari. + * Client-side script that patches WebSocket to use SSE+fetch on macOS/iOS Safari. * This script is injected via Vite's transformIndexHtml hook. */ export const hmrSseBridgeClientScript = ` (function() { - // Activate when served over HTTPS to bypass WSS self-signed cert issues - if (location.protocol !== 'https:') return; + if (!(${shouldUseHmrSseBridge.toString()})(location.protocol, navigator.userAgent)) return; var OriginalWebSocket = window.WebSocket; diff --git a/src/index.ts b/src/index.ts index 554dcad..47c250f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -123,17 +123,17 @@ export const unpluginFactory: UnpluginFactory = options => ({ server.printUrls = () => vitePrintUrls(options, source, target, base, _printUrls) - // Setup SSE bridge for iOS Safari WSS workaround + // Setup SSE bridge for macOS/iOS Safari WSS workaround if (options.https) { setupHmrSseBridge(server) hmrSseBridgeActive = true - consola.info('HMR SSE bridge enabled for iOS Safari WSS workaround') + consola.info('HMR SSE bridge enabled for macOS/iOS Safari WSS workaround') } }, transformIndexHtml() { if (!hmrSseBridgeActive) return [] - // Inject client-side script to patch WebSocket on iOS Safari + // Inject client-side script to patch WebSocket on macOS/iOS Safari return [{ tag: 'script', children: hmrSseBridgeClientScript, diff --git a/test/caddy/index.test.ts b/test/caddy/index.test.ts index 54ed0ae..2fa3062 100644 --- a/test/caddy/index.test.ts +++ b/test/caddy/index.test.ts @@ -45,7 +45,7 @@ describe('caddy', () => { expect(caddyfile).toMatchInlineSnapshot(` "{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { @@ -117,7 +117,7 @@ describe('caddy', () => { expect(caddyfile).toMatchInlineSnapshot(` "{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { @@ -186,7 +186,7 @@ describe('caddy', () => { expect(caddyfile).toMatchInlineSnapshot(` "{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { @@ -256,7 +256,7 @@ describe('caddy', () => { expect(caddyfile).toMatchInlineSnapshot(` "{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { @@ -340,7 +340,7 @@ describe('caddy', () => { expect(caddyfile).toMatchInlineSnapshot(` "{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { diff --git a/test/caddy/log.test.ts b/test/caddy/log.test.ts new file mode 100644 index 0000000..b55753c --- /dev/null +++ b/test/caddy/log.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { isCaddyReloadCancellation } from '../../src/caddy/log' + +describe('isCaddyReloadCancellation', () => { + it('ignores TLS issuance canceled by a Caddy config reload', () => { + expect(isCaddyReloadCancellation('{"level":"error","logger":"tls","msg":"job failed","error":"local.doc.suanshubang.com: obtaining certificate: unable to acquire lock \'issue_cert_local.doc.suanshubang.com\': context canceled"}')).toBe(true) + }) + + it('keeps other TLS errors actionable', () => { + expect(isCaddyReloadCancellation('{"level":"error","logger":"tls","msg":"job failed","error":"dial tcp: connection refused"}')).toBe(false) + }) + + it('does not ignore non-JSON log lines', () => { + expect(isCaddyReloadCancellation('Error: parsing caddyfile tokens')).toBe(false) + }) +}) diff --git a/test/caddy/tls-health.test.ts b/test/caddy/tls-health.test.ts new file mode 100644 index 0000000..2e6a8c6 --- /dev/null +++ b/test/caddy/tls-health.test.ts @@ -0,0 +1,86 @@ +import type { PeerCertificate } from 'node:tls' +import type { TlsHealthResult } from '../../src/caddy/tls-health' +import { describe, expect, it } from 'vitest' +import { assessTlsCertificate, waitForLocalTls } from '../../src/caddy/tls-health' + +function certificate(validTo: string): PeerCertificate { + return { + valid_to: validTo, + } as PeerCertificate +} + +describe('assessTlsCertificate', () => { + const now = Date.UTC(2026, 8, 4, 14, 0, 0) + + it('accepts a certificate that is valid for the requested hostname', () => { + const result = assessTlsCertificate(certificate('Sep 5 14:00:00 2026 GMT'), 'local.doc.suanshubang.com', now, () => undefined) + + expect(result).toMatchInlineSnapshot(` + { + "expiresAt": 2026-09-05T14:00:00.000Z, + "healthy": true, + } + `) + }) + + it('reports an expired certificate instead of treating the proxy as ready', () => { + const result = assessTlsCertificate(certificate('Aug 14 10:49:33 2026 GMT'), 'local.doc.suanshubang.com', now, () => undefined) + + expect(result).toMatchInlineSnapshot(` + { + "expiresAt": 2026-08-14T10:49:33.000Z, + "healthy": false, + "reason": "certificate_expired", + } + `) + }) + + it('reports a hostname mismatch', () => { + const result = assessTlsCertificate(certificate('Sep 5 14:00:00 2026 GMT'), 'local.doc.suanshubang.com', now, () => new Error('hostname mismatch')) + + expect(result).toMatchInlineSnapshot(` + { + "healthy": false, + "reason": "hostname_mismatch", + } + `) + }) + + it('reports certificates with an invalid validity date', () => { + const result = assessTlsCertificate(certificate('not-a-date'), 'local.doc.suanshubang.com', now, () => undefined) + + expect(result).toMatchInlineSnapshot(` + { + "healthy": false, + "reason": "certificate_invalid", + } + `) + }) +}) + +describe('waitForLocalTls', () => { + it('waits for Caddy to replace an expired certificate', async () => { + const results: TlsHealthResult[] = [ + { healthy: false, reason: 'certificate_expired', expiresAt: new Date('2026-08-14T10:49:33.000Z') }, + { healthy: true, expiresAt: new Date('2026-09-05T14:00:00.000Z') }, + ] + let attempts = 0 + + const result = await waitForLocalTls('local.doc.suanshubang.com', { + retryIntervalMs: 1, + retryWindowMs: 30_000, + probe: async () => results[attempts++]!, + sleep: async () => {}, + }) + + expect({ attempts, result }).toMatchInlineSnapshot(` + { + "attempts": 2, + "result": { + "expiresAt": 2026-09-05T14:00:00.000Z, + "healthy": true, + }, + } + `) + }) +}) diff --git a/test/caddyfile.test.ts b/test/caddyfile.test.ts index bc4ac29..9da4e5a 100644 --- a/test/caddyfile.test.ts +++ b/test/caddyfile.test.ts @@ -34,6 +34,7 @@ describe('caddyfile host grouping and route ordering', async () => { }) const caddyfile = await genCaddyfile(ctx) + expect(caddyfile).toContain('auto_https disable_redirects ignore_loaded_certs') expect(caddyfile).toContain('example.com:80 {') expect(caddyfile).toContain('another.com:80 {') diff --git a/test/hmr-sse-bridge.test.ts b/test/hmr-sse-bridge.test.ts new file mode 100644 index 0000000..04e336f --- /dev/null +++ b/test/hmr-sse-bridge.test.ts @@ -0,0 +1,65 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { hmrSseBridgeClientScript, shouldUseHmrSseBridge } from '../src/hmr-sse-bridge' + +const userAgents = { + macosSafari: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15', + iphoneSafari: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1', + ipadSafari: 'Mozilla/5.0 (iPad; CPU OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1', + macosChrome: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + iphoneChrome: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1', + macosFirefox: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0', + windowsSafari: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/534.57.2 (KHTML, like Gecko) Version/5.1.7 Safari/534.57.2', +} + +describe('shouldUseHmrSseBridge', () => { + it('only enables the bridge for Safari on macOS and iOS over HTTPS', () => { + const result = Object.fromEntries( + Object.entries(userAgents).map(([browser, userAgent]) => [ + browser, + shouldUseHmrSseBridge('https:', userAgent), + ]), + ) + + expect(result).toMatchInlineSnapshot(` + { + "ipadSafari": true, + "iphoneChrome": false, + "iphoneSafari": true, + "macosChrome": false, + "macosFirefox": false, + "macosSafari": true, + "windowsSafari": false, + } + `) + }) + + it('keeps native WebSocket on HTTP', () => { + expect(shouldUseHmrSseBridge('http:', userAgents.macosSafari)).toBe(false) + }) + + it('uses the same guard in the injected client script', () => { + function isWebSocketPatched(userAgent: string): boolean { + const OriginalWebSocket = function WebSocket() {} + const context = { + location: { protocol: 'https:' }, + navigator: { userAgent }, + window: { WebSocket: OriginalWebSocket }, + } + + runInNewContext(hmrSseBridgeClientScript, context) + + return context.window.WebSocket !== OriginalWebSocket + } + + expect({ + macosChrome: isWebSocketPatched(userAgents.macosChrome), + macosSafari: isWebSocketPatched(userAgents.macosSafari), + }).toMatchInlineSnapshot(` + { + "macosChrome": false, + "macosSafari": true, + } + `) + }) +})