Skip to content

Commit 495fc4e

Browse files
erkamyamanantfubot
andauthored
fix(vite): share an https dev server for the hub WebSocket (#419)
Co-authored-by: Anthony Fu (via agent) <reg-github-bot@antfu.me>
1 parent e6c5c73 commit 495fc4e

5 files changed

Lines changed: 240 additions & 11 deletions

File tree

‎knip.jsonc‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@
157157
// the same shape as a devframe's `clientScript`/`clientScripts` entry.
158158
"entry": ["playground/client-scripts/*.ts"]
159159
},
160+
"packages/vite": {
161+
"ignoreBinaries": ["openssl"]
162+
},
160163
"packages/json-render": {
161164
// `src/node/index.ts` is already picked up via `tsdown.config.ts`
162165
// (its literal `entry` object parses cleanly); only `core.ts`/`hub.ts`

‎packages/devframe/src/adapters/__tests__/initiate.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../../types'
22
import { mkdtempSync, writeFileSync } from 'node:fs'
33
import { createServer } from 'node:http'
4+
import { createSecureServer } from 'node:http2'
45
import { tmpdir } from 'node:os'
56
import { join } from 'node:path'
67
import { defineDevframe } from 'devframe'
@@ -10,6 +11,7 @@ import { getPort } from 'get-port-please'
1011
import { describe, expect, it, vi } from 'vitest'
1112
import { WebSocket } from 'ws'
1213
import { getTempAuthCode } from '../../node/auth/state'
14+
import { getInternalContext } from '../../node/hub-internals/context'
1315
import { initDevframe } from '../initiate'
1416

1517
const HANDSHAKE = { authToken: '', ua: 'test', origin: 'http://localhost' }
@@ -206,6 +208,57 @@ describe('adapters/handler', () => {
206208
}
207209
})
208210

211+
it('shared-server tier: a TLS host server advertises wss://', async () => {
212+
const host = '127.0.0.1'
213+
const server = createSecureServer({ allowHTTP1: true })
214+
await new Promise<void>(resolve => server.listen(0, host, resolve))
215+
const { port } = server.address() as { port: number }
216+
const devtools = initDevframe(defineTestDef('handler-tls'), {
217+
base: '/__handler-tls/',
218+
auth: false,
219+
host,
220+
server: server as any,
221+
})
222+
223+
try {
224+
await devtools.ready
225+
expect(getInternalContext(await devtools.context).wsEndpoint).toEqual({
226+
url: `wss://localhost:${port}/__handler-tls/__ws`,
227+
})
228+
}
229+
finally {
230+
await devtools.close()
231+
await new Promise<void>(resolve => server.close(() => resolve()))
232+
}
233+
})
234+
235+
it('shared-server tier: publishes the endpoint once a not-yet-listening TLS server listens', async () => {
236+
const host = '127.0.0.1'
237+
const server = createSecureServer({ allowHTTP1: true })
238+
const devtools = initDevframe(defineTestDef('handler-tls-late'), {
239+
base: '/__handler-tls-late/',
240+
auth: false,
241+
host,
242+
server: server as any,
243+
})
244+
245+
try {
246+
await devtools.ready
247+
const internal = getInternalContext(await devtools.context)
248+
expect(internal.wsEndpoint).toBeUndefined()
249+
250+
await new Promise<void>(resolve => server.listen(0, host, resolve))
251+
const { port } = server.address() as { port: number }
252+
expect(internal.wsEndpoint).toEqual({
253+
url: `wss://localhost:${port}/__handler-tls-late/__ws`,
254+
})
255+
}
256+
finally {
257+
await devtools.close()
258+
await new Promise<void>(resolve => server.close(() => resolve()))
259+
}
260+
})
261+
209262
it('ws.url tier: advertises the external endpoint verbatim, owns no transport', async () => {
210263
const devtools = initDevframe(defineTestDef('handler-remote'), { base: '/__handler-remote/', ws: { url: 'wss://devtools.example.com/relay/__ws' } })
211264

‎packages/devframe/src/node/instance-shell.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { DevframeInstanceRecord, DevframeInstanceRegistration } from './ins
1414
import type { ContextRpcServer } from './rpc-core'
1515
import { createServer } from 'node:http'
1616
import process from 'node:process'
17+
import { Server as TlsServer } from 'node:tls'
1718
import { validateOriginCandidate } from 'devframe/utils/origin'
1819
import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'devframe/utils/url'
1920
import { defineHandler, H3 as H3App, toNodeHandler } from 'h3'
@@ -144,10 +145,22 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
144145
const address = httpServer.address()
145146
const resolvedPort = typeof address === 'object' && address ? address.port : port
146147
const origin = normalizeHttpServerUrl(bindHost, resolvedPort)
147-
const internal = getInternalContext(context)
148-
const wsUrl = `ws://${formatHostForUrl(bindHost)}:${resolvedPort}${options.path ?? ''}`
149-
if (websocket)
150-
internal.setWsEndpoint({ url: wsUrl })
148+
// A shared server may not be listening yet (Vite listens after plugins configure).
149+
let wsUrl: string | undefined
150+
const publishWsEndpoint = (): void => {
151+
const bound = httpServer.address()
152+
if (!bound || typeof bound === 'string')
153+
return
154+
const scheme = httpServer instanceof TlsServer ? 'wss' : 'ws'
155+
wsUrl = `${scheme}://${formatHostForUrl(bindHost)}:${bound.port}${options.path ?? ''}`
156+
getInternalContext(context).setWsEndpoint({ url: wsUrl })
157+
}
158+
if (websocket) {
159+
if (httpServer.listening)
160+
publishWsEndpoint()
161+
else
162+
httpServer.once('listening', publishWsEndpoint)
163+
}
151164

152165
return {
153166
origin,
@@ -157,10 +170,11 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
157170
rpcGroup: core.rpcGroup,
158171
connectionMeta: () => websocketConnectionMeta(rpcHost, options.path),
159172
async close() {
173+
httpServer.off('listening', publishWsEndpoint)
160174
await closeWs()
161175
if (ownsHttpServer)
162176
await new Promise<void>(r => httpServer.close(() => r()))
163-
if (websocket && getInternalContext(context).wsEndpoint?.url === wsUrl)
177+
if (wsUrl && getInternalContext(context).wsEndpoint?.url === wsUrl)
164178
getInternalContext(context).setWsEndpoint(undefined)
165179
},
166180
}

‎packages/vite/src/hub.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type { DevframeHubUi, DockRendererRegistration, HubDevframeEntry, HubInst
22
import type { DevframeHubContext } from '@devframes/hub/node'
33
import type { ClientScriptEntry } from '@devframes/hub/types'
44
import type { DevframeDefinition } from 'devframe'
5+
import type { Server as NodeHttpServer } from 'node:http'
56
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
6-
import { Server as NodeHttpServer } from 'node:http'
77
import process from 'node:process'
88
import { DEVFRAMES_HUB_BASE, normalizeHubBase } from '@devframes/hub/constants'
99
import { initHub } from '@devframes/hub/initiate'
@@ -180,7 +180,9 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin {
180180
// the hub client runtime imports it into the host page.
181181
const devframes = attachClientScripts(options.devframes, options.clientScripts)
182182

183-
const httpServer = server.httpServer instanceof NodeHttpServer ? server.httpServer : undefined
183+
// `server.https` makes Vite use an `Http2SecureServer`, not a `node:http`
184+
// `Server`. It still emits `upgrade` for HTTP/1.1, so the cast is safe.
185+
const httpServer = (server.httpServer ?? undefined) as NodeHttpServer | undefined
184186

185187
const hub = initHub({
186188
base,
@@ -198,8 +200,8 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin {
198200
auth: options.auth,
199201
/**
200202
* Share Vite's own HTTP server for the WS upgrade at `<base>__ws`, with no
201-
* side-car port to discover. A pinned `port` uses a side-car instead;
202-
* an https/http2 dev server (non-`node:http`) asks for an auto-port
203+
* side-car port to discover. A pinned `port` uses a side-car instead,
204+
* and a middleware-mode Vite (no `httpServer`) asks for an auto-port
203205
* side-car. Clients discover either via `__connection.json`.
204206
*/
205207
server: httpServer,
@@ -267,8 +269,8 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin {
267269

268270
/**
269271
* Share Vite's own HTTP server for the WS upgrade unless a `port` pins a
270-
* side-car, or the dev server isn't a plain `node:http` server (https/http2),
271-
* which needs an auto-port side-car.
272+
* side-car, or there is no server to share (middleware mode), which needs an
273+
* auto-port side-car.
272274
*/
273275
function resolveWsBinding(port: number | undefined, httpServer: NodeHttpServer | undefined): { ws?: { port: number } | { sidecar: true } } {
274276
if (port != null)

‎packages/vite/test/hub.test.ts‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import type { DevframeHubContext } from '@devframes/hub/node'
2+
import type { IncomingMessage, ServerResponse } from 'node:http'
3+
import type { Http2SecureServer } from 'node:http2'
4+
import type { Socket } from 'node:net'
5+
import type { ViteDevServer } from 'vite'
6+
import { execFileSync } from 'node:child_process'
7+
import { mkdtempSync, readFileSync } from 'node:fs'
8+
import { createSecureServer } from 'node:http2'
9+
import { request } from 'node:https'
10+
import { tmpdir } from 'node:os'
11+
import { join } from 'node:path'
12+
import { getInternalContext } from 'devframe/node/hub-internals'
13+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
14+
import { WebSocket } from 'ws'
15+
import { viteDevframeHub } from '../src/hub'
16+
17+
type ConnectMiddleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void
18+
19+
function hasOpenssl(): boolean {
20+
try {
21+
execFileSync('openssl', ['version'], { stdio: 'ignore' })
22+
return true
23+
}
24+
catch {
25+
return false
26+
}
27+
}
28+
29+
const opensslAvailable = hasOpenssl()
30+
if (!opensslAvailable)
31+
console.warn('[vite hub test] openssl not found, skipping the https dev server test')
32+
33+
/**
34+
* Vite on `server.https` hands plugins an `Http2SecureServer` with
35+
* `allowHTTP1`, which is not a `node:http` `Server`. This one runs the
36+
* plugin's connect middlewares like Vite does.
37+
*/
38+
function fakeHttpsViteServer(tls: { key: string, cert: string }) {
39+
const stack: ConnectMiddleware[] = []
40+
const httpServer: Http2SecureServer = createSecureServer({ ...tls, allowHTTP1: true })
41+
httpServer.on('request', (req: IncomingMessage, res: ServerResponse) => {
42+
let i = 0
43+
const next = (): void => {
44+
const handler = stack[i++]
45+
if (!handler) {
46+
res.statusCode = 404
47+
res.end()
48+
return
49+
}
50+
handler(req, res, next)
51+
}
52+
next()
53+
})
54+
const sockets = new Set<Socket>()
55+
httpServer.on('secureConnection', (socket: Socket) => {
56+
sockets.add(socket)
57+
socket.once('close', () => sockets.delete(socket))
58+
})
59+
const server = {
60+
httpServer,
61+
resolvedUrls: null,
62+
middlewares: { use: (handler: ConnectMiddleware) => stack.push(handler) },
63+
}
64+
const close = async (): Promise<void> => {
65+
for (const socket of sockets)
66+
socket.destroy()
67+
await new Promise<void>(resolve => httpServer.close(() => resolve()))
68+
}
69+
return { server, httpServer, close }
70+
}
71+
72+
function getInsecure(url: string): Promise<{ status: number, body: string }> {
73+
return new Promise((resolve, reject) => {
74+
request(url, { rejectUnauthorized: false }, (res) => {
75+
let body = ''
76+
res.setEncoding('utf8')
77+
res.on('data', (chunk: string) => body += chunk)
78+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }))
79+
}).on('error', reject).end()
80+
})
81+
}
82+
83+
function openWs(url: string): Promise<WebSocket> {
84+
return new Promise((resolve, reject) => {
85+
const ws = new WebSocket(url, { rejectUnauthorized: false })
86+
ws.once('open', () => resolve(ws))
87+
ws.once('error', reject)
88+
})
89+
}
90+
91+
describe.skipIf(!opensslAvailable)('viteDevframeHub', () => {
92+
let tls: { key: string, cert: string }
93+
let cleanup: (() => Promise<void>) | undefined
94+
95+
beforeAll(() => {
96+
const dir = mkdtempSync(join(tmpdir(), 'devframe-vite-hub-tls-'))
97+
const keyPath = join(dir, 'key.pem')
98+
const certPath = join(dir, 'cert.pem')
99+
execFileSync('openssl', [
100+
'req',
101+
'-x509',
102+
'-newkey',
103+
'rsa:2048',
104+
'-nodes',
105+
'-keyout',
106+
keyPath,
107+
'-out',
108+
certPath,
109+
'-days',
110+
'1',
111+
'-subj',
112+
'/CN=localhost',
113+
], { stdio: 'ignore' })
114+
tls = { key: readFileSync(keyPath, 'utf8'), cert: readFileSync(certPath, 'utf8') }
115+
})
116+
117+
afterEach(async () => {
118+
await cleanup?.()
119+
cleanup = undefined
120+
})
121+
122+
it('shares an https (http2) dev server for the WebSocket upgrade', async () => {
123+
const host = '127.0.0.1'
124+
const { server, httpServer, close } = fakeHttpsViteServer(tls)
125+
let context: DevframeHubContext | undefined
126+
127+
const plugin = viteDevframeHub({
128+
ui: false,
129+
auth: false,
130+
quiet: true,
131+
cwd: mkdtempSync(join(tmpdir(), 'devframe-vite-hub-')),
132+
configure: (ctx) => {
133+
context = ctx
134+
},
135+
})
136+
let ws: WebSocket | undefined
137+
cleanup = async () => {
138+
ws?.terminate()
139+
await (plugin.closeBundle as () => Promise<void>)()
140+
await close()
141+
}
142+
await (plugin.configureServer as (s: ViteDevServer) => Promise<void>)(server as any)
143+
await vi.waitFor(() => expect(httpServer.listenerCount('upgrade')).toBeGreaterThan(0))
144+
await new Promise<void>(resolve => httpServer.listen(0, host, resolve))
145+
const { port } = httpServer.address() as { port: number }
146+
147+
const res = await getInsecure(`https://${host}:${port}/__devframes/__connection.json`)
148+
expect(res.status).toBe(200)
149+
expect((JSON.parse(res.body) as { websocket?: unknown }).websocket).toEqual({ path: '/__devframes/__ws' })
150+
151+
ws = await openWs(`wss://${host}:${port}/__devframes/__ws`)
152+
expect(ws.readyState).toBe(WebSocket.OPEN)
153+
154+
expect(context).toBeDefined()
155+
expect(getInternalContext(context!).wsEndpoint).toEqual({ url: `wss://localhost:${port}/__devframes/__ws` })
156+
})
157+
})

0 commit comments

Comments
 (0)