fix: ios safari wss connect fail (vite hmr)#6
Conversation
Summary of ChangesHello, 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
Changelog
Activity
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
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.
| 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) | ||
| } |
There was a problem hiding this comment.
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.
| let body = '' | ||
| req.on('data', (chunk: Buffer) => { | ||
| body += chunk.toString() | ||
| }) |
There was a problem hiding this comment.
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()
}
})
src/hmr-sse-bridge.ts
Outdated
| 'Content-Type': 'text/event-stream', | ||
| 'Cache-Control': 'no-cache', | ||
| 'Connection': 'keep-alive', | ||
| 'Access-Control-Allow-Origin': '*', |
There was a problem hiding this comment.
src/hmr-sse-bridge.ts
Outdated
| } | ||
| res.writeHead(200, { | ||
| 'Content-Type': 'application/json', | ||
| 'Access-Control-Allow-Origin': '*', |
| catch { | ||
| res.writeHead(400) | ||
| res.end('{"ok":false}') | ||
| } |
There was a problem hiding this comment.
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() {}); |
There was a problem hiding this comment.
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 SummaryThis 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 Key changes:
Issues found:
Confidence Score: 2/5
Sequence DiagramsequenceDiagram
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
Last reviewed commit: f0ecc7b |
| for (const res of sseClients) { | ||
| res.write(`data: ${payload}\n\n`) | ||
| } |
There was a problem hiding this comment.
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:
| 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:
| 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`) | |
| } | |
| } |
| 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', | ||
| }] | ||
| }, |
There was a problem hiding this comment.
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:
| 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' }] | |
| }, |
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.The error manifests as:
Root Cause Analysis
r. 25491679)In Vite 6+, when the WSS connection fails,
wsstaysundefinedinsidecreateWebSocketModuleRunnerTransport, but the error is silently swallowed. Any subsequent call tows!.send()causesTypeError: 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
Implementation
Two parts:
Server-side (
src/hmr-sse-bridge.ts):GET /__hmr_sse— SSE endpoint that streams HMR payloads to the browserPOST /__hmr_send— Receives client-to-server HMR messagesserver.ws.send()to simultaneously push payloads to all SSE clientsClient-side (injected via
transformIndexHtml):location.protocol !== 'https:')window.WebSocketconstructor'vite-hmr'protocol)SseWebSocket— a WebSocket-compatible shim backed byEventSource+fetchWebSocketuntouchedKey Design Decisions
onerrorevents are intentionally not forwarded as WebSocketcloseevents, becauseEventSourceauto-reconnects — this prevents Vite'sconnect()from rejecting and leavingwsasundefined{ once: true }support: Vite 6'screateWebSocketModuleRunnerTransportusesaddEventListener('open', ..., { once: true }), which theSseWebSocketshim fully supportsSseWebSocketexposesCONNECTING/OPEN/CLOSING/CLOSEDas instance properties, matching Vite'ssocket.readyState === socket.OPENpatternScope
Files Changed
src/hmr-sse-bridge.tssrc/index.tssetupHmrSseBridge()inconfigureServer, addtransformIndexHtmlhookfix: iOS Safari WSS 连接失败 (Vite HMR) — SSE 桥接方案
关联 #5
问题
iOS Safari 存在一个已确认的 WebKit bug(WebKit #145819):WebSocket Secure (WSS) 连接会拒绝自签名证书,即使用户已经在设备上完全信任了该证书。同一证书的 HTTPS 请求完全正常——只有 WSS 受影响。
这导致通过 Caddy HTTPS 反向代理访问 Vite 开发服务器时,HMR(热模块替换)在 iOS Safari 上完全失败,因为 Vite 的 HMR 客户端使用
new WebSocket(url, 'vite-hmr')建立连接。错误表现为:
根因分析
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。
架构
实现
分为两部分:
服务端(
src/hmr-sse-bridge.ts):GET /__hmr_sse— SSE 端点,将 HMR 数据以流的形式推送给浏览器POST /__hmr_send— 接收客户端到服务端的 HMR 消息server.ws.send(),在 WebSocket 广播的同时将数据推送给所有 SSE 客户端客户端(通过
transformIndexHtml注入):location.protocol !== 'https:'时跳过)window.WebSocket构造函数'vite-hmr'协议识别)SseWebSocket—— 一个基于EventSource+fetch的 WebSocket 兼容垫片WebSocket关键设计决策
onerror事件不会被转发为 WebSocket 的close事件,因为EventSource会自动重连——这避免了 Vite 的connect()reject 导致ws保持undefined{ once: true }:Vite 6 的createWebSocketModuleRunnerTransport使用addEventListener('open', ..., { once: true }),SseWebSocket垫片完整支持SseWebSocket在实例上暴露CONNECTING/OPEN/CLOSING/CLOSED属性,匹配 Vite 的socket.readyState === socket.OPEN检查模式适用范围
变更文件
src/hmr-sse-bridge.tssrc/index.tsconfigureServer中调用setupHmrSseBridge(),添加transformIndexHtml钩子