Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 266 additions & 0 deletions src/hmr-sse-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import type { Buffer } from 'node:buffer'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ViteDevServer } from 'vite'

const SSE_PATH = '/__hmr_sse'
const SEND_PATH = '/__hmr_send'
const MAX_POST_BODY = 64 * 1024 // 64KB limit for client messages

export function setupHmrSseBridge(server: ViteDevServer): void {
const sseClients = new Set<ServerResponse>()

// Monkey-patch server.ws.send to also push to SSE clients
const originalSend = server.ws.send.bind(server.ws)
server.ws.send = (...args: any[]) => {
// Call original WebSocket send
;(originalSend as any)(...args)

// Build the payload string same as Vite does internally
let payload: string
if (typeof args[0] === 'string') {
// server.ws.send(event, data) — custom event, wrap it
payload = JSON.stringify({ type: 'custom', event: args[0], data: args[1] })
}
else {
// server.ws.send(payloadObject)
payload = JSON.stringify(args[0])
}

// Push to all SSE clients (skip errored ones)
for (const res of sseClients) {
try {
res.write(`data: ${payload}\n\n`)
}
catch {
sseClients.delete(res)
}
}
Comment on lines +30 to +37
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`)
}
}

}

// Add middleware for SSE and POST endpoints
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
const url = req.url?.split('?')[0]

if (url === SSE_PATH && req.method === 'GET') {
handleSseConnection(req, res, sseClients)
return
}

if (url === SEND_PATH && req.method === 'POST') {
handleClientMessage(req, res, server)
return
}

next()
})
}

function handleSseConnection(
_req: IncomingMessage,
res: ServerResponse,
sseClients: Set<ServerResponse>,
): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})

// Send initial connected message (same as Vite's WebSocket)
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)

sseClients.add(res)

// Keep-alive ping every 30s
const keepAlive = setInterval(() => {
try {
res.write(': ping\n\n')
}
catch { cleanup() }
}, 30000)

function cleanup(): void {
clearInterval(keepAlive)
sseClients.delete(res)
}

res.on('close', cleanup)
res.on('error', cleanup)
}

function handleClientMessage(
req: IncomingMessage,
res: ServerResponse,
server: ViteDevServer,
): void {
let body = ''
let overflow = false
req.on('data', (chunk: Buffer) => {
body += chunk.toString()
if (body.length > MAX_POST_BODY) {
overflow = true
req.destroy()
}
})
Comment on lines +96 to +104

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()
    }
  })

req.on('end', () => {
if (overflow) {
res.writeHead(413)
res.end('{"ok":false}')
return
}
try {
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)
}
Comment on lines +112 to +124

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.

res.writeHead(200, {
'Content-Type': 'application/json',
})
res.end('{"ok":true}')
}
catch {
res.writeHead(400)
res.end('{"ok":false}')
}
Comment on lines +130 to +133

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}')
}

})
}

/**
* Client-side script that patches WebSocket to use SSE+fetch on 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;

var OriginalWebSocket = window.WebSocket;

function SseWebSocket(originalUrl) {
var self = this;
// WebSocket readyState constants on instance
self.CONNECTING = 0;
self.OPEN = 1;
self.CLOSING = 2;
self.CLOSED = 3;
self.readyState = 0;
self.onopen = null;
self.onmessage = null;
self.onclose = null;
self.onerror = null;
self.bufferedAmount = 0;
self.extensions = '';
self.binaryType = 'blob';
self.protocol = 'vite-hmr';
self.url = originalUrl;
self._listeners = {};
self._opened = false;

var sseUrl = location.origin + '${SSE_PATH}';
var sendUrl = location.origin + '${SEND_PATH}';
self._sendUrl = sendUrl;

var es = new EventSource(sseUrl);
self._es = es;

es.onopen = function() {
if (self._opened) return;
self._opened = true;
self.readyState = 1;
var evt = new Event('open');
if (self.onopen) self.onopen(evt);
self._emit('open', evt);
};

es.onmessage = function(e) {
var evt = new MessageEvent('message', { data: e.data });
if (self.onmessage) self.onmessage(evt);
self._emit('message', evt);
};

es.onerror = function() {
// EventSource auto-reconnects on error; don't treat as fatal close
// Only emit error event, never close event (SSE handles reconnect internally)
if (self.readyState >= 2) return;
// EventSource auto-reconnects; no action needed
};
}

SseWebSocket.prototype.send = function(data) {
if (this.readyState !== 1) return;
fetch(this._sendUrl, {
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); });

};

SseWebSocket.prototype.close = function() {
if (this.readyState >= 2) return;
this.readyState = 3;
if (this._es) this._es.close();
var evt = new CloseEvent('close', { code: 1000, reason: '', wasClean: true });
if (this.onclose) this.onclose(evt);
this._emit('close', evt);
};

SseWebSocket.prototype.addEventListener = function(type, fn, options) {
if (!this._listeners[type]) this._listeners[type] = [];
var entry = { fn: fn, once: !!(options && options.once) };
this._listeners[type].push(entry);
};

SseWebSocket.prototype.removeEventListener = function(type, fn) {
var list = this._listeners[type];
if (!list) return;
this._listeners[type] = list.filter(function(e) { return e.fn !== fn; });
};

SseWebSocket.prototype.dispatchEvent = function(evt) {
this._emit(evt.type, evt);
return true;
};

SseWebSocket.prototype._emit = function(type, evt) {
var list = this._listeners[type];
if (!list) return;
var remaining = [];
list.forEach(function(entry) {
entry.fn(evt);
if (!entry.once) remaining.push(entry);
});
this._listeners[type] = remaining;
};

function PatchedWebSocket(url, protocols) {
// Only intercept Vite HMR connections (protocol 'vite-hmr')
var isViteHmr = protocols === 'vite-hmr'
|| (Array.isArray(protocols) && protocols.indexOf('vite-hmr') !== -1);
if (!isViteHmr) {
if (protocols !== undefined) {
return new OriginalWebSocket(url, protocols);
}
return new OriginalWebSocket(url);
}
return new SseWebSocket(url);
}

PatchedWebSocket.CONNECTING = 0;
PatchedWebSocket.OPEN = 1;
PatchedWebSocket.CLOSING = 2;
PatchedWebSocket.CLOSED = 3;
PatchedWebSocket.prototype = Object.create(OriginalWebSocket.prototype);
PatchedWebSocket.prototype.constructor = PatchedWebSocket;

window.WebSocket = PatchedWebSocket;
})();
`
20 changes: 20 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import { Table } from 'console-table-printer'
import c from 'picocolors'
import { createUnplugin } from 'unplugin'
import { CaddyInstant } from './caddy'
import { hmrSseBridgeClientScript, setupHmrSseBridge } from './hmr-sse-bridge'
import { consola, generateQrcode, getIpv4List, isAdmin, once } from './utils'

let config: ResolvedConfig

let caddy: CaddyInstant

let hmrSseBridgeActive = false

const cwd = process.cwd()

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

server.printUrls = () => vitePrintUrls(options, source, target, base, _printUrls)

// Setup SSE bridge for iOS Safari WSS workaround
if (options.https) {
setupHmrSseBridge(server)
hmrSseBridgeActive = true
consola.info('HMR SSE bridge enabled for iOS Safari WSS workaround')
}
},
transformIndexHtml() {
if (!hmrSseBridgeActive)
return []
// Inject client-side script to patch WebSocket on iOS Safari
return [{
tag: 'script',
children: hmrSseBridgeClientScript,
injectTo: 'head-prepend',
}]
},
Comment on lines +133 to 142
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' }]
},

},
webpack(compiler) {
Expand Down