unplugin-https-reverse-proxy 3.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ `
package/src/index.ts CHANGED
@@ -6,12 +6,15 @@ import { Table } from 'console-table-printer'
6
6
  import c from 'picocolors'
7
7
  import { createUnplugin } from 'unplugin'
8
8
  import { CaddyInstant } from './caddy'
9
+ import { hmrSseBridgeClientScript, setupHmrSseBridge } from './hmr-sse-bridge'
9
10
  import { consola, generateQrcode, getIpv4List, isAdmin, once } from './utils'
10
11
 
11
12
  let config: ResolvedConfig
12
13
 
13
14
  let caddy: CaddyInstant
14
15
 
16
+ let hmrSseBridgeActive = false
17
+
15
18
  const cwd = process.cwd()
16
19
 
17
20
  function colorUrl(url: string): string {
@@ -119,6 +122,23 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
119
122
  const base = server.config.base || '/'
120
123
 
121
124
  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
+ }]
122
142
  },
123
143
  },
124
144
  webpack(compiler) {
@@ -160,6 +180,16 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
160
180
  if (caddy)
161
181
  return
162
182
 
183
+ let base = '/'
184
+ const publicPath = compiler.options.output?.publicPath as unknown
185
+ if (typeof publicPath === 'string' && publicPath) {
186
+ base = publicPath
187
+ }
188
+ if (!base.startsWith('/'))
189
+ base = `/${base}`
190
+ if (base !== '/' && !base.endsWith('/'))
191
+ base = `${base}/`
192
+
163
193
  // https://github.com/webpack/webpack-dev-server/blob/master/lib/Server.js#L1913
164
194
  const _close = compiler.close?.bind(compiler)
165
195
  if (_close) {
@@ -177,12 +207,13 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
177
207
  caddy = new CaddyInstant()
178
208
 
179
209
  caddy.run(source, target, {
210
+ base,
180
211
  ...options,
181
212
  }).then(() => {
182
213
  compiler.hooks.done.tap('unplugin-https-reverse-proxy', async () => {
183
214
  await printForwardProxy()
184
215
 
185
- consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}`)}`)
216
+ consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
186
217
  })
187
218
  }).catch((e) => {
188
219
  throw e
@@ -231,12 +262,23 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
231
262
 
232
263
  caddy = new CaddyInstant()
233
264
 
265
+ let base = '/'
266
+ const publicPath = compiler.options.output?.publicPath as unknown
267
+ if (typeof publicPath === 'string' && publicPath) {
268
+ base = publicPath
269
+ }
270
+ if (!base.startsWith('/'))
271
+ base = `/${base}`
272
+ if (base !== '/' && !base.endsWith('/'))
273
+ base = `${base}/`
274
+
234
275
  caddy.run(source, target, {
276
+ base,
235
277
  ...options,
236
278
  }).then(async () => {
237
279
  await printForwardProxy()
238
280
 
239
- consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}`)}`)
281
+ consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
240
282
  }).catch((e) => {
241
283
  throw e
242
284
  })