Skip to content

fix: ios safari wss connect fail (vite hmr)#6

Merged
zcf0508 merged 2 commits intomainfrom
fix-ios-safari-wss-fail
Mar 6, 2026
Merged

fix: ios safari wss connect fail (vite hmr)#6
zcf0508 merged 2 commits intomainfrom
fix-ios-safari-wss-fail

Conversation

@zcf0508
Copy link
Owner

@zcf0508 zcf0508 commented Mar 6, 2026

fix: iOS Safari WSS connect fail (Vite HMR) — SSE Bridge

Closes #5

Problem

iOS Safari has a confirmed WebKit bug (WebKit #145819) where WebSocket Secure (WSS) connections reject self-signed certificates, even when the certificate has been fully trusted by the user on the device. HTTPS requests work normally with the same certificate — only WSS is affected.

This causes Vite's HMR (Hot Module Replacement) to fail completely on iOS Safari when accessing the dev server through the Caddy HTTPS reverse proxy, because Vite's HMR client uses new WebSocket(url, 'vite-hmr') to establish the connection.

Browser --HTTPS--> Caddy (self-signed TLS) --> Vite   ✅ Works
Browser --WSS----> Caddy (self-signed TLS) --> Vite   ❌ iOS Safari rejects

The error manifests as:

WebSocket network error: OSStatus Error -9807: Invalid certificate chain

Root Cause Analysis

  1. iOS Safari/WebKit uses a separate certificate validation path for WebSocket connections compared to regular HTTPS requests
  2. Even with the root CA certificate installed and fully trusted (Settings → General → About → Certificate Trust Settings), WSS connections are still rejected
  3. This bug has existed since at least 2015 and remains unfixed as of 2026
  4. Apple engineers have acknowledged this as a bug internally (Radar r. 25491679)

In Vite 6+, when the WSS connection fails, ws stays undefined inside createWebSocketModuleRunnerTransport, but the error is silently swallowed. Any subsequent call to ws!.send() causes TypeError: undefined is not an object.

Solution: SSE Bridge

Since iOS Safari trusts the self-signed certificate for regular HTTPS but not WSS, we bypass WebSocket entirely by bridging HMR through Server-Sent Events (SSE) + fetch POST — both of which use standard HTTPS.

Architecture

                        HTTPS (trusted ✅)
iOS Safari  ──── GET  /__hmr_sse ────→  Caddy  ──→  Vite (SSE endpoint)
            ←─── SSE stream ────────────────────────  Server pushes HMR payloads

            ──── POST /__hmr_send ───→  Caddy  ──→  Vite (POST endpoint)
            ←─── JSON response ─────────────────────  Client sends HMR messages

Implementation

Two parts:

Server-side (src/hmr-sse-bridge.ts):

  • GET /__hmr_sse — SSE endpoint that streams HMR payloads to the browser
  • POST /__hmr_send — Receives client-to-server HMR messages
  • Monkey-patches server.ws.send() to simultaneously push payloads to all SSE clients

Client-side (injected via transformIndexHtml):

  • Only activates on HTTPS pages (location.protocol !== 'https:')
  • Patches window.WebSocket constructor
  • Intercepts Vite HMR connections (identified by 'vite-hmr' protocol)
  • Replaces them with SseWebSocket — a WebSocket-compatible shim backed by EventSource + fetch
  • Non-HMR WebSocket connections are passed through to the original WebSocket untouched

Key Design Decisions

  1. Protocol detection over UA sniffing: Activates on all HTTPS pages instead of iOS Safari detection only, because the WSS+self-signed-cert issue also affects macOS Safari and other WebKit browsers
  2. EventSource error resilience: SSE onerror events are intentionally not forwarded as WebSocket close events, because EventSource auto-reconnects — this prevents Vite's connect() from rejecting and leaving ws as undefined
  3. { once: true } support: Vite 6's createWebSocketModuleRunnerTransport uses addEventListener('open', ..., { once: true }), which the SseWebSocket shim fully supports
  4. Instance-level constants: SseWebSocket exposes CONNECTING/OPEN/CLOSING/CLOSED as instance properties, matching Vite's socket.readyState === socket.OPEN pattern

Scope

This fix currently only applies to Vite. Webpack and Rspack are not affected by this change — their HMR mechanisms are different and require separate investigation.

Files Changed

File Change
src/hmr-sse-bridge.ts New file — SSE bridge server + client shim
src/index.ts Import bridge, call setupHmrSseBridge() in configureServer, add transformIndexHtml hook

fix: iOS Safari WSS 连接失败 (Vite HMR) — SSE 桥接方案

关联 #5

问题

iOS Safari 存在一个已确认的 WebKit bugWebKit #145819):WebSocket Secure (WSS) 连接会拒绝自签名证书,即使用户已经在设备上完全信任了该证书。同一证书的 HTTPS 请求完全正常——只有 WSS 受影响。

这导致通过 Caddy HTTPS 反向代理访问 Vite 开发服务器时,HMR(热模块替换)在 iOS Safari 上完全失败,因为 Vite 的 HMR 客户端使用 new WebSocket(url, 'vite-hmr') 建立连接。

浏览器 --HTTPS--> Caddy(自签名 TLS)--> Vite   ✅ 正常
浏览器 --WSS----> Caddy(自签名 TLS)--> Vite   ❌ iOS Safari 拒绝

错误表现为:

WebSocket network error: OSStatus Error -9807: Invalid certificate chain

根因分析

  1. iOS Safari/WebKit 对 WebSocket 连接使用了与普通 HTTPS 请求不同的证书校验路径
  2. 即使在"设置 → 通用 → 关于本机 → 证书信任设置"中完全信任了根 CA 证书,WSS 连接仍然被拒绝
  3. 此 bug 从 2015 年至今未修复(2026 年仍然存在)
  4. Apple 工程师在内部已确认这是一个 bug(Radar r. 25491679

在 Vite 6+ 中,WSS 连接失败后,createWebSocketModuleRunnerTransport 内部的 ws 变量保持 undefined,但错误被静默吞掉。后续任何 ws!.send() 调用都会导致 TypeError: undefined is not an object

解决方案:SSE 桥接

既然 iOS Safari 信任自签名证书的 HTTPS 但不信任 WSS,我们完全绕过 WebSocket,通过 SSE(Server-Sent Events)+ fetch POST 来桥接 HMR——两者都使用标准 HTTPS。

架构

                        HTTPS(受信任 ✅)
iOS Safari  ──── GET  /__hmr_sse ────→  Caddy  ──→  Vite(SSE 端点)
            ←─── SSE 流 ────────────────────────────  服务器推送 HMR 数据

            ──── POST /__hmr_send ───→  Caddy  ──→  Vite(POST 端点)
            ←─── JSON 响应 ─────────────────────────  客户端发送 HMR 消息

实现

分为两部分:

服务端src/hmr-sse-bridge.ts):

  • GET /__hmr_sse — SSE 端点,将 HMR 数据以流的形式推送给浏览器
  • POST /__hmr_send — 接收客户端到服务端的 HMR 消息
  • Monkey-patch server.ws.send(),在 WebSocket 广播的同时将数据推送给所有 SSE 客户端

客户端(通过 transformIndexHtml 注入):

  • 仅在 HTTPS 页面激活(location.protocol !== 'https:' 时跳过)
  • Patch window.WebSocket 构造函数
  • 拦截 Vite HMR 连接(通过 'vite-hmr' 协议识别)
  • 替换为 SseWebSocket —— 一个基于 EventSource + fetch 的 WebSocket 兼容垫片
  • 非 HMR 的 WebSocket 连接不受影响,透传给原始 WebSocket

关键设计决策

  1. 协议检测而非 UA 嗅探:在所有 HTTPS 页面上激活,而不仅限于 iOS Safari,因为 WSS + 自签名证书的问题同样影响 macOS Safari 和其他 WebKit 浏览器
  2. EventSource 错误容错:SSE 的 onerror 事件不会被转发为 WebSocket 的 close 事件,因为 EventSource 会自动重连——这避免了 Vite 的 connect() reject 导致 ws 保持 undefined
  3. 支持 { once: true }:Vite 6 的 createWebSocketModuleRunnerTransport 使用 addEventListener('open', ..., { once: true })SseWebSocket 垫片完整支持
  4. 实例级常量SseWebSocket 在实例上暴露 CONNECTING/OPEN/CLOSING/CLOSED 属性,匹配 Vite 的 socket.readyState === socket.OPEN 检查模式

适用范围

此修复目前仅适用于 Vite。 Webpack 和 Rspack 不受此变更影响——它们的 HMR 机制不同,需要单独调研。

变更文件

文件 变更
src/hmr-sse-bridge.ts 新文件 — SSE 桥接服务端 + 客户端垫片
src/index.ts 导入桥接模块,在 configureServer 中调用 setupHmrSseBridge(),添加 transformIndexHtml 钩子

@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request provides a critical fix for a long-standing WebKit bug affecting iOS Safari, where secure WebSocket (WSS) connections fail with self-signed certificates, disrupting Vite's Hot Module Replacement (HMR). The solution implements an SSE (Server-Sent Events) bridge, effectively bypassing the problematic WSS protocol by routing HMR traffic over standard HTTPS, ensuring a reliable development experience on affected Apple devices.

Highlights

  • Problem Resolution: Addressed a long-standing WebKit bug in iOS Safari that prevents WebSocket Secure (WSS) connections from working with self-signed certificates, specifically impacting Vite's Hot Module Replacement (HMR) functionality.
  • SSE Bridge Implementation: Introduced a Server-Sent Events (SSE) bridge as a robust workaround, leveraging standard HTTPS for HMR communication instead of the problematic WSS protocol on affected browsers.
  • Server-Side Integration: Implemented a new server-side component (src/hmr-sse-bridge.ts) that handles SSE streams, receives client-to-server HMR messages via POST requests, and monkey-patches Vite's server.ws.send() to broadcast HMR payloads to both WebSocket and SSE clients.
  • Client-Side Patching: Developed a client-side script (injected via Vite's transformIndexHtml hook) that patches window.WebSocket to intercept Vite HMR connections and replace them with an SseWebSocket shim, which uses EventSource for receiving and fetch for sending HMR messages.
  • Broad Compatibility: Designed the solution to activate on all HTTPS pages, rather than relying on user-agent sniffing, to ensure compatibility and address similar WSS issues that may affect other WebKit browsers like macOS Safari.
Changelog
  • src/hmr-sse-bridge.ts
    • Implemented the server-side logic for handling SSE connections and client-to-server HMR messages.
    • Included the client-side JavaScript for patching window.WebSocket to use SSE and fetch for HMR.
    • Monkey-patched server.ws.send to broadcast HMR payloads to both WebSocket and SSE clients.
  • src/index.ts
    • Imported the hmrSseBridgeClientScript and setupHmrSseBridge functions.
    • Called setupHmrSseBridge within the configureServer hook to initialize the SSE bridge when HTTPS is enabled.
    • Added a transformIndexHtml hook to inject the client-side SSE bridge script into the HTML.
Activity
  • No human activity has been recorded on this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request provides a clever solution to a long-standing WebKit bug affecting WebSocket connections with self-signed certificates by implementing an SSE bridge for Vite's HMR. However, the solution introduces several security vulnerabilities in the development server. The new endpoints lack authentication and use a permissive CORS policy ("Access-Control-Allow-Origin: '*'"), allowing any website to interact with the internal HMR system. The POST endpoint is also vulnerable to memory exhaustion due to unbounded request body handling. Additionally, the implementation has a few robustness and debuggability concerns, such as silently swallowed errors and the use of a fragile internal Vite API.

Comment on lines +90 to +102
const data = JSON.parse(body)
// Forward custom events to Vite's HMR server
// Vite client sends: { type: 'custom', event: string, data: any }
if (data.type === 'custom' && data.event) {
// Trigger listeners registered via server.ws.on(event, handler)
// by simulating the internal dispatch with a dummy client
const dummyClient = {
send: (...args: any[]) => { server.ws.send(...(args as [any])) },
}
// Access internal customListeners via the 'on' registered handlers
// We re-emit by calling the server's hot channel listener mechanism
;(server as any).environments?.client?.hot?.api?.emit?.(data.event, data.data, dummyClient)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The handleClientMessage function directly emits events to the Vite internal HMR system using server.environments.client.hot.api.emit. This endpoint is unauthenticated and has a permissive CORS policy, allowing any website to trigger internal events on the dev server, which could lead to disruption or remote code execution. Additionally, this direct access to Vite's internal API is fragile and may break with future updates; consider adding a comment to explain its necessity and acknowledge the risk.

Comment on lines +84 to +87
let body = ''
req.on('data', (chunk: Buffer) => {
body += chunk.toString()
})

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The handleClientMessage function reads the entire request body into memory without any size limits. An attacker can send an extremely large POST request to /__hmr_send to consume all available memory on the development machine, leading to a Denial of Service (DoS).

  let body = ''
  req.on('data', (chunk: Buffer) => {
    body += chunk.toString()
    if (body.length > 1e6) { // 1MB limit
      req.destroy()
    }
  })

'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The SSE and POST endpoints use Access-Control-Allow-Origin: '*', which allows any website to interact with the HMR bridge. This can lead to data leakage (reading HMR payloads) and unauthorized event emission if a developer visits a malicious website while the dev server is running.

}
res.writeHead(200, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The POST endpoint for HMR messages uses a wildcard CORS policy, allowing any origin to send messages to the dev server. This should be restricted to the expected development origin.

Comment on lines +109 to +112
catch {
res.writeHead(400)
res.end('{"ok":false}')
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This catch block currently swallows the error from JSON.parse, which can make debugging issues with client messages difficult. For better debuggability, you should capture the error object and log it to the server console.

For example:

catch (e) {
  server.config.logger.error(`[HMR SSE Bridge] Failed to parse client message: ${e}`)
  res.writeHead(400)
  res.end('{"ok":false}')
}

method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: data
}).catch(function() {});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The empty .catch(function() {}) on the fetch call silently swallows any errors that occur when sending messages from the client to the server. This can make debugging HMR issues very difficult. It's better to log these errors to the browser console.

    }).catch(function(err) { console.error('[HMR SSE Bridge] Failed to send message:', err); });

@greptile-apps
Copy link

greptile-apps bot commented Mar 6, 2026

Greptile Summary

This PR introduces an SSE+fetch bridge to work around the well-documented iOS/macOS Safari WebKit bug that rejects WSS connections with self-signed certificates, even after the root CA has been manually trusted. The fix is well-motivated and the overall architecture (server-side SSE endpoint + POST endpoint, client-side WebSocket shim injected via transformIndexHtml) is sound.

Key changes:

  • src/hmr-sse-bridge.ts — new file that monkey-patches server.ws.send, registers GET /__hmr_sse and POST /__hmr_send middleware on Vite's connect stack, and exports the client-side SseWebSocket shim as an inline script string.
  • src/index.ts — calls setupHmrSseBridge(server) inside configureServer when options.https is set, and adds a transformIndexHtml hook to inject the shim into every served HTML page.

Issues found:

  • Crash risk: Writing to SSE response objects has no error handler. Node.js writable streams emit an error event when written after the peer disconnects. Without a handler on the ServerResponse, this becomes an unhandled error event and crashes the dev server process.
  • Logic mismatch: transformIndexHtml injects the client script whenever options.https is truthy, but configureServer has additional early-return guards (enable, isAdmin(), target, target format). If any of those prevent the bridge from being set up, the client shim is still injected and EventSource will repeatedly request the non-existent /__hmr_sse endpoint.
  • Unbounded POST body: The request body in handleClientMessage is accumulated in memory without any upper bound, risking memory exhaustion from a malicious or malfunctioning client.
  • Prototype aliasing: Direct assignment PatchedWebSocket.prototype = OriginalWebSocket.prototype aliases the prototype objects, and SseWebSocket instances will fail instanceof window.WebSocket checks.

Confidence Score: 2/5

  • This PR contains one confirmed crash-risk bug (unhandled Node.js error event on SSE writes) and one logic bug (client script injected unconditionally while corresponding server routes may not exist) that should be addressed before merging.
  • Score 2 reflects one confirmed crash-risk (unhandled stream error event can terminate the dev server process) and one logic bug (client script injected unconditionally while the corresponding server routes may not exist). The overall approach is solid and the fix is well-designed, but these two issues need to be resolved first. Additionally, an unbounded POST body and a prototype aliasing concern are noted.
  • src/hmr-sse-bridge.ts (error handling on SSE writes and POST body size limit) and src/index.ts (guard conditions in transformIndexHtml) need the most attention.

Sequence Diagram

sequenceDiagram
    participant Browser as iOS Safari
    participant Caddy as Caddy (TLS proxy)
    participant Vite as Vite Dev Server
    participant Bridge as SSE Bridge Middleware

    Note over Browser,Vite: Page Load
    Browser->>Caddy: GET /index.html (HTTPS ✅)
    Caddy->>Vite: forward request
    Vite-->>Browser: HTML + injected SseWebSocket shim script

    Note over Browser,Vite: HMR Connection (replaces WSS)
    Browser->>Caddy: GET /__hmr_sse (HTTPS EventSource ✅)
    Caddy->>Bridge: forward request
    Bridge-->>Browser: SSE stream opened, sends {type:"connected"}

    Note over Browser,Vite: Server triggers HMR update
    Vite->>Bridge: server.ws.send(payload) [monkey-patched]
    Bridge->>Browser: SSE data event → SseWebSocket.onmessage fires

    Note over Browser,Vite: Client sends message to server
    Browser->>Caddy: POST /__hmr_send (fetch, HTTPS ✅)
    Caddy->>Bridge: forward POST
    Bridge->>Vite: server.environments.client.hot.api.emit(event, data)
    Bridge-->>Browser: {"ok":true}

    Note over Browser,Vite: Keep-alive
    loop every 30s
        Bridge-->>Browser: SSE comment ": ping"
    end
Loading

Last reviewed commit: f0ecc7b

Comment on lines +28 to +30
for (const res of sseClients) {
res.write(`data: ${payload}\n\n`)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled write errors can crash the dev server

When a client disconnects, there is a race window before the close event fires and removes it from sseClients. If res.write() is called during this window, Node.js emits an error event on the writable stream. Without an error handler on res, this unhandled error event will crash the Vite dev server process.

An error listener should be added to res inside handleSseConnection:

Suggested change
for (const res of sseClients) {
res.write(`data: ${payload}\n\n`)
}
res.on('error', () => {
clearInterval(keepAlive)
sseClients.delete(res)
})

Additionally, the write loop should guard against already-ended responses:

Suggested change
for (const res of sseClients) {
res.write(`data: ${payload}\n\n`)
}
for (const res of sseClients) {
if (!res.writableEnded) {
res.write(`data: ${payload}\n\n`)
}
}

Comment on lines +130 to 139
transformIndexHtml() {
if (config.command !== 'serve' || !options.https)
return []
// Inject client-side script to patch WebSocket on iOS Safari
return [{
tag: 'script',
children: hmrSseBridgeClientScript,
injectTo: 'head-prepend',
}]
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client script injected even when the SSE bridge server was never set up

transformIndexHtml only guards on config.command !== 'serve' and !options.https, but configureServer has several additional early-return conditions that prevent setupHmrSseBridge from being called:

  • !enable (line 94)
  • !isAdmin() (line 97)
  • !target (line 101)
  • target.match(/^(?:https?)?:\/\//) (line 106)

When any of these conditions applies, the server-side /__hmr_sse and /__hmr_send endpoints are never registered, but the client shim is still injected. The EventSource inside SseWebSocket will then continuously retry failed requests to these non-existent endpoints, producing noisy network errors in the browser's DevTools for every page load.

The hook needs the same set of guards. A simple approach is to use a module-level flag:

Suggested change
transformIndexHtml() {
if (config.command !== 'serve' || !options.https)
return []
// Inject client-side script to patch WebSocket on iOS Safari
return [{
tag: 'script',
children: hmrSseBridgeClientScript,
injectTo: 'head-prepend',
}]
},
let hmrSseBridgeActive = false
// inside configureServer, after setupHmrSseBridge(server):
hmrSseBridgeActive = true
// inside transformIndexHtml:
transformIndexHtml() {
if (!hmrSseBridgeActive)
return []
return [{ tag: 'script', children: hmrSseBridgeClientScript, injectTo: 'head-prepend' }]
},

@zcf0508 zcf0508 merged commit 36eeb16 into main Mar 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wss not works on ios

1 participant