Skip to content

Commit 36eeb16

Browse files
authored
Merge pull request #6 from zcf0508/fix-ios-safari-wss-fail
fix: ios safari wss connect fail (vite hmr)
2 parents da6c261 + f575b5b commit 36eeb16

File tree

2 files changed

+286
-0
lines changed

2 files changed

+286
-0
lines changed

src/hmr-sse-bridge.ts

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import type { Buffer } from 'node:buffer'
2+
import type { IncomingMessage, ServerResponse } from 'node:http'
3+
import type { ViteDevServer } from 'vite'
4+
5+
const SSE_PATH = '/__hmr_sse'
6+
const SEND_PATH = '/__hmr_send'
7+
const MAX_POST_BODY = 64 * 1024 // 64KB limit for client messages
8+
9+
export function setupHmrSseBridge(server: ViteDevServer): void {
10+
const sseClients = new Set<ServerResponse>()
11+
12+
// Monkey-patch server.ws.send to also push to SSE clients
13+
const originalSend = server.ws.send.bind(server.ws)
14+
server.ws.send = (...args: any[]) => {
15+
// Call original WebSocket send
16+
;(originalSend as any)(...args)
17+
18+
// Build the payload string same as Vite does internally
19+
let payload: string
20+
if (typeof args[0] === 'string') {
21+
// server.ws.send(event, data) — custom event, wrap it
22+
payload = JSON.stringify({ type: 'custom', event: args[0], data: args[1] })
23+
}
24+
else {
25+
// server.ws.send(payloadObject)
26+
payload = JSON.stringify(args[0])
27+
}
28+
29+
// Push to all SSE clients (skip errored ones)
30+
for (const res of sseClients) {
31+
try {
32+
res.write(`data: ${payload}\n\n`)
33+
}
34+
catch {
35+
sseClients.delete(res)
36+
}
37+
}
38+
}
39+
40+
// Add middleware for SSE and POST endpoints
41+
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
42+
const url = req.url?.split('?')[0]
43+
44+
if (url === SSE_PATH && req.method === 'GET') {
45+
handleSseConnection(req, res, sseClients)
46+
return
47+
}
48+
49+
if (url === SEND_PATH && req.method === 'POST') {
50+
handleClientMessage(req, res, server)
51+
return
52+
}
53+
54+
next()
55+
})
56+
}
57+
58+
function handleSseConnection(
59+
_req: IncomingMessage,
60+
res: ServerResponse,
61+
sseClients: Set<ServerResponse>,
62+
): void {
63+
res.writeHead(200, {
64+
'Content-Type': 'text/event-stream',
65+
'Cache-Control': 'no-cache',
66+
'Connection': 'keep-alive',
67+
})
68+
69+
// Send initial connected message (same as Vite's WebSocket)
70+
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
71+
72+
sseClients.add(res)
73+
74+
// Keep-alive ping every 30s
75+
const keepAlive = setInterval(() => {
76+
try {
77+
res.write(': ping\n\n')
78+
}
79+
catch { cleanup() }
80+
}, 30000)
81+
82+
function cleanup(): void {
83+
clearInterval(keepAlive)
84+
sseClients.delete(res)
85+
}
86+
87+
res.on('close', cleanup)
88+
res.on('error', cleanup)
89+
}
90+
91+
function handleClientMessage(
92+
req: IncomingMessage,
93+
res: ServerResponse,
94+
server: ViteDevServer,
95+
): void {
96+
let body = ''
97+
let overflow = false
98+
req.on('data', (chunk: Buffer) => {
99+
body += chunk.toString()
100+
if (body.length > MAX_POST_BODY) {
101+
overflow = true
102+
req.destroy()
103+
}
104+
})
105+
req.on('end', () => {
106+
if (overflow) {
107+
res.writeHead(413)
108+
res.end('{"ok":false}')
109+
return
110+
}
111+
try {
112+
const data = JSON.parse(body)
113+
// Forward custom events to Vite's HMR server
114+
// Vite client sends: { type: 'custom', event: string, data: any }
115+
if (data.type === 'custom' && data.event) {
116+
// Trigger listeners registered via server.ws.on(event, handler)
117+
// by simulating the internal dispatch with a dummy client
118+
const dummyClient = {
119+
send: (...args: any[]) => { server.ws.send(...(args as [any])) },
120+
}
121+
// Access internal customListeners via the 'on' registered handlers
122+
// We re-emit by calling the server's hot channel listener mechanism
123+
;(server as any).environments?.client?.hot?.api?.emit?.(data.event, data.data, dummyClient)
124+
}
125+
res.writeHead(200, {
126+
'Content-Type': 'application/json',
127+
})
128+
res.end('{"ok":true}')
129+
}
130+
catch {
131+
res.writeHead(400)
132+
res.end('{"ok":false}')
133+
}
134+
})
135+
}
136+
137+
/**
138+
* Client-side script that patches WebSocket to use SSE+fetch on iOS Safari.
139+
* This script is injected via Vite's transformIndexHtml hook.
140+
*/
141+
export const hmrSseBridgeClientScript = `
142+
(function() {
143+
// Activate when served over HTTPS to bypass WSS self-signed cert issues
144+
if (location.protocol !== 'https:') return;
145+
146+
var OriginalWebSocket = window.WebSocket;
147+
148+
function SseWebSocket(originalUrl) {
149+
var self = this;
150+
// WebSocket readyState constants on instance
151+
self.CONNECTING = 0;
152+
self.OPEN = 1;
153+
self.CLOSING = 2;
154+
self.CLOSED = 3;
155+
self.readyState = 0;
156+
self.onopen = null;
157+
self.onmessage = null;
158+
self.onclose = null;
159+
self.onerror = null;
160+
self.bufferedAmount = 0;
161+
self.extensions = '';
162+
self.binaryType = 'blob';
163+
self.protocol = 'vite-hmr';
164+
self.url = originalUrl;
165+
self._listeners = {};
166+
self._opened = false;
167+
168+
var sseUrl = location.origin + '${SSE_PATH}';
169+
var sendUrl = location.origin + '${SEND_PATH}';
170+
self._sendUrl = sendUrl;
171+
172+
var es = new EventSource(sseUrl);
173+
self._es = es;
174+
175+
es.onopen = function() {
176+
if (self._opened) return;
177+
self._opened = true;
178+
self.readyState = 1;
179+
var evt = new Event('open');
180+
if (self.onopen) self.onopen(evt);
181+
self._emit('open', evt);
182+
};
183+
184+
es.onmessage = function(e) {
185+
var evt = new MessageEvent('message', { data: e.data });
186+
if (self.onmessage) self.onmessage(evt);
187+
self._emit('message', evt);
188+
};
189+
190+
es.onerror = function() {
191+
// EventSource auto-reconnects on error; don't treat as fatal close
192+
// Only emit error event, never close event (SSE handles reconnect internally)
193+
if (self.readyState >= 2) return;
194+
// EventSource auto-reconnects; no action needed
195+
};
196+
}
197+
198+
SseWebSocket.prototype.send = function(data) {
199+
if (this.readyState !== 1) return;
200+
fetch(this._sendUrl, {
201+
method: 'POST',
202+
headers: { 'Content-Type': 'application/json' },
203+
body: data
204+
}).catch(function() {});
205+
};
206+
207+
SseWebSocket.prototype.close = function() {
208+
if (this.readyState >= 2) return;
209+
this.readyState = 3;
210+
if (this._es) this._es.close();
211+
var evt = new CloseEvent('close', { code: 1000, reason: '', wasClean: true });
212+
if (this.onclose) this.onclose(evt);
213+
this._emit('close', evt);
214+
};
215+
216+
SseWebSocket.prototype.addEventListener = function(type, fn, options) {
217+
if (!this._listeners[type]) this._listeners[type] = [];
218+
var entry = { fn: fn, once: !!(options && options.once) };
219+
this._listeners[type].push(entry);
220+
};
221+
222+
SseWebSocket.prototype.removeEventListener = function(type, fn) {
223+
var list = this._listeners[type];
224+
if (!list) return;
225+
this._listeners[type] = list.filter(function(e) { return e.fn !== fn; });
226+
};
227+
228+
SseWebSocket.prototype.dispatchEvent = function(evt) {
229+
this._emit(evt.type, evt);
230+
return true;
231+
};
232+
233+
SseWebSocket.prototype._emit = function(type, evt) {
234+
var list = this._listeners[type];
235+
if (!list) return;
236+
var remaining = [];
237+
list.forEach(function(entry) {
238+
entry.fn(evt);
239+
if (!entry.once) remaining.push(entry);
240+
});
241+
this._listeners[type] = remaining;
242+
};
243+
244+
function PatchedWebSocket(url, protocols) {
245+
// Only intercept Vite HMR connections (protocol 'vite-hmr')
246+
var isViteHmr = protocols === 'vite-hmr'
247+
|| (Array.isArray(protocols) && protocols.indexOf('vite-hmr') !== -1);
248+
if (!isViteHmr) {
249+
if (protocols !== undefined) {
250+
return new OriginalWebSocket(url, protocols);
251+
}
252+
return new OriginalWebSocket(url);
253+
}
254+
return new SseWebSocket(url);
255+
}
256+
257+
PatchedWebSocket.CONNECTING = 0;
258+
PatchedWebSocket.OPEN = 1;
259+
PatchedWebSocket.CLOSING = 2;
260+
PatchedWebSocket.CLOSED = 3;
261+
PatchedWebSocket.prototype = Object.create(OriginalWebSocket.prototype);
262+
PatchedWebSocket.prototype.constructor = PatchedWebSocket;
263+
264+
window.WebSocket = PatchedWebSocket;
265+
})();
266+
`

src/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import { Table } from 'console-table-printer'
66
import c from 'picocolors'
77
import { createUnplugin } from 'unplugin'
88
import { CaddyInstant } from './caddy'
9+
import { hmrSseBridgeClientScript, setupHmrSseBridge } from './hmr-sse-bridge'
910
import { consola, generateQrcode, getIpv4List, isAdmin, once } from './utils'
1011

1112
let config: ResolvedConfig
1213

1314
let caddy: CaddyInstant
1415

16+
let hmrSseBridgeActive = false
17+
1518
const cwd = process.cwd()
1619

1720
function colorUrl(url: string): string {
@@ -119,6 +122,23 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
119122
const base = server.config.base || '/'
120123

121124
server.printUrls = () => vitePrintUrls(options, source, target, base, _printUrls)
125+
126+
// Setup SSE bridge for iOS Safari WSS workaround
127+
if (options.https) {
128+
setupHmrSseBridge(server)
129+
hmrSseBridgeActive = true
130+
consola.info('HMR SSE bridge enabled for iOS Safari WSS workaround')
131+
}
132+
},
133+
transformIndexHtml() {
134+
if (!hmrSseBridgeActive)
135+
return []
136+
// Inject client-side script to patch WebSocket on iOS Safari
137+
return [{
138+
tag: 'script',
139+
children: hmrSseBridgeClientScript,
140+
injectTo: 'head-prepend',
141+
}]
122142
},
123143
},
124144
webpack(compiler) {

0 commit comments

Comments
 (0)