From e54fde6473f2b370f0d9aeac713697fe01364ef6 Mon Sep 17 00:00:00 2001 From: erkamyaman Date: Thu, 1 Oct 2026 15:30:00 +0300 Subject: [PATCH 1/7] fix(vite): share an https dev server for the hub WebSocket --- packages/vite/src/hub.ts | 15 ++++--- packages/vite/test/hub.test.ts | 78 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 packages/vite/test/hub.test.ts diff --git a/packages/vite/src/hub.ts b/packages/vite/src/hub.ts index f1b24119..c57852ef 100644 --- a/packages/vite/src/hub.ts +++ b/packages/vite/src/hub.ts @@ -2,8 +2,8 @@ import type { DevframeHubUi, DockRendererRegistration, HubDevframeEntry, HubInst import type { DevframeHubContext } from '@devframes/hub/node' import type { ClientScriptEntry } from '@devframes/hub/types' import type { DevframeDefinition } from 'devframe' +import type { Server as NodeHttpServer } from 'node:http' import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite' -import { Server as NodeHttpServer } from 'node:http' import process from 'node:process' import { DEVFRAMES_HUB_BASE, normalizeHubBase } from '@devframes/hub/constants' import { initHub } from '@devframes/hub/initiate' @@ -180,7 +180,10 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { // the hub client runtime imports it into the host page. const devframes = attachClientScripts(options.devframes, options.clientScripts) - const httpServer = server.httpServer instanceof NodeHttpServer ? server.httpServer : undefined + // Vite's https dev server is an `Http2SecureServer` (or an + // `https.Server` with `server.proxy`). Both emit `upgrade` for HTTP/1.1 + // requests, as Vite's own HMR socket relies on, so share it too. + const httpServer = (server.httpServer ?? undefined) as NodeHttpServer | undefined const hub = initHub({ base, @@ -198,8 +201,8 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { auth: options.auth, /** * Share Vite's own HTTP server for the WS upgrade at `__ws`, with no - * side-car port to discover. A pinned `port` uses a side-car instead; - * an https/http2 dev server (non-`node:http`) asks for an auto-port + * side-car port to discover. A pinned `port` uses a side-car instead, + * and a middleware-mode Vite (no `httpServer`) asks for an auto-port * side-car. Clients discover either via `__connection.json`. */ server: httpServer, @@ -267,8 +270,8 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { /** * Share Vite's own HTTP server for the WS upgrade unless a `port` pins a - * side-car, or the dev server isn't a plain `node:http` server (https/http2), - * which needs an auto-port side-car. + * side-car, or there is no server to share (middleware mode), which needs an + * auto-port side-car. */ function resolveWsBinding(port: number | undefined, httpServer: NodeHttpServer | undefined): { ws?: { port: number } | { sidecar: true } } { if (port != null) diff --git a/packages/vite/test/hub.test.ts b/packages/vite/test/hub.test.ts new file mode 100644 index 00000000..ef2317d1 --- /dev/null +++ b/packages/vite/test/hub.test.ts @@ -0,0 +1,78 @@ +import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' +import type { Http2SecureServer } from 'node:http2' +import type { ViteDevServer } from 'vite' +import { mkdtempSync } from 'node:fs' +import { createServer } from 'node:http' +import { createSecureServer } from 'node:http2' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getPort } from 'get-port-please' +import { afterEach, describe, expect, it } from 'vitest' +import { viteDevframeHub } from '../src/hub' + +type ConnectMiddleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void + +/** + * Vite on `server.https` hands plugins an `Http2SecureServer` (with + * `allowHTTP1`), which is not a `node:http` `Server`. Requests are served + * here over a plain HTTP server running the same middleware stack, so the + * test needs no certificate. + */ +function fakeHttpsViteServer() { + const stack: ConnectMiddleware[] = [] + const requestServer: NodeHttpServer = createServer((req, res) => { + let i = 0 + const next = (): void => { + const handler = stack[i++] + if (!handler) { + res.statusCode = 404 + res.end() + return + } + handler(req, res, next) + } + next() + }) + const httpServer: Http2SecureServer = createSecureServer({ allowHTTP1: true }) + const server = { + httpServer, + resolvedUrls: null, + middlewares: { use: (handler: ConnectMiddleware) => stack.push(handler) }, + } + return { server, httpServer, requestServer } +} + +describe('viteDevframeHub', () => { + let cleanup: (() => Promise) | undefined + + afterEach(async () => { + await cleanup?.() + cleanup = undefined + }) + + it('shares an https (http2) dev server for the WebSocket upgrade', async () => { + const host = '127.0.0.1' + const port = await getPort({ port: 19800, host }) + const { server, httpServer, requestServer } = fakeHttpsViteServer() + await new Promise(resolve => requestServer.listen(port, host, resolve)) + + const plugin = viteDevframeHub({ + ui: false, + auth: false, + quiet: true, + cwd: mkdtempSync(join(tmpdir(), 'devframe-vite-hub-')), + }) + cleanup = async () => { + httpServer.emit('close') + await (plugin.closeBundle as () => Promise)() + requestServer.close() + requestServer.closeAllConnections() + } + await (plugin.configureServer as (s: ViteDevServer) => Promise)(server as any) + + const res = await fetch(`http://${host}:${port}/__devframes/__connection.json`) + const meta = await res.json() as { websocket?: unknown } + expect(meta.websocket).toEqual({ path: '/__devframes/__ws' }) + expect(httpServer.listenerCount('upgrade')).toBe(1) + }) +}) From c4b6aef643a9b7d1d5b30dc605e1d57957a36fd2 Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 1 Oct 2026 16:00:01 +0300 Subject: [PATCH 2/7] fix(devframe): advertise wss for a shared TLS server --- .../src/adapters/__tests__/initiate.test.ts | 26 +++++++++++++++++++ packages/devframe/src/node/instance-shell.ts | 8 ++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index 56bac6b2..4f1dde66 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -1,6 +1,7 @@ import type { DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../../types' import { mkdtempSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' +import { createSecureServer } from 'node:http2' import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineDevframe } from 'devframe' @@ -10,6 +11,7 @@ import { getPort } from 'get-port-please' import { describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' import { getTempAuthCode } from '../../node/auth/state' +import { getInternalContext } from '../../node/hub-internals/context' import { initDevframe } from '../initiate' const HANDSHAKE = { authToken: '', ua: 'test', origin: 'http://localhost' } @@ -206,6 +208,30 @@ describe('adapters/handler', () => { } }) + it('shared-server tier: a TLS host server advertises wss://', async () => { + const host = '127.0.0.1' + const server = createSecureServer({ allowHTTP1: true }) + await new Promise(resolve => server.listen(0, host, resolve)) + const { port } = server.address() as { port: number } + const devtools = initDevframe(defineTestDef('handler-tls'), { + base: '/__handler-tls/', + auth: false, + host, + server: server as any, + }) + + try { + await devtools.ready + expect(getInternalContext(await devtools.context).wsEndpoint).toEqual({ + url: `wss://localhost:${port}/__handler-tls/__ws`, + }) + } + finally { + await devtools.close() + await new Promise(resolve => server.close(() => resolve())) + } + }) + it('ws.url tier: advertises the external endpoint verbatim, owns no transport', async () => { const devtools = initDevframe(defineTestDef('handler-remote'), { base: '/__handler-remote/', ws: { url: 'wss://devtools.example.com/relay/__ws' } }) diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 36bd396d..0103eb43 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -14,6 +14,7 @@ import type { DevframeInstanceRecord, DevframeInstanceRegistration } from './ins import type { ContextRpcServer } from './rpc-core' import { createServer } from 'node:http' import process from 'node:process' +import { Server as TlsServer } from 'node:tls' import { validateOriginCandidate } from 'devframe/utils/origin' import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'devframe/utils/url' import { defineHandler, H3 as H3App, toNodeHandler } from 'h3' @@ -143,9 +144,12 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise Date: Thu, 1 Oct 2026 16:00:03 +0300 Subject: [PATCH 3/7] test(vite): open a real wss handshake on the https hub server --- packages/vite/test/hub.test.ts | 117 ++++++++++++++++++++++++++------- 1 file changed, 93 insertions(+), 24 deletions(-) diff --git a/packages/vite/test/hub.test.ts b/packages/vite/test/hub.test.ts index ef2317d1..a802fc99 100644 --- a/packages/vite/test/hub.test.ts +++ b/packages/vite/test/hub.test.ts @@ -1,26 +1,42 @@ -import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' +import type { IncomingMessage, ServerResponse } from 'node:http' import type { Http2SecureServer } from 'node:http2' +import type { Socket } from 'node:net' import type { ViteDevServer } from 'vite' -import { mkdtempSync } from 'node:fs' -import { createServer } from 'node:http' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync } from 'node:fs' import { createSecureServer } from 'node:http2' +import { request } from 'node:https' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { getPort } from 'get-port-please' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { WebSocket } from 'ws' import { viteDevframeHub } from '../src/hub' type ConnectMiddleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void +function hasOpenssl(): boolean { + try { + execFileSync('openssl', ['version'], { stdio: 'ignore' }) + return true + } + catch { + return false + } +} + +const opensslAvailable = hasOpenssl() +if (!opensslAvailable) + console.warn('[vite hub test] openssl not found, skipping the https dev server test') + /** - * Vite on `server.https` hands plugins an `Http2SecureServer` (with - * `allowHTTP1`), which is not a `node:http` `Server`. Requests are served - * here over a plain HTTP server running the same middleware stack, so the - * test needs no certificate. + * Vite on `server.https` hands plugins an `Http2SecureServer` with + * `allowHTTP1`, which is not a `node:http` `Server`. This one runs the + * plugin's connect middlewares like Vite does. */ -function fakeHttpsViteServer() { +function fakeHttpsViteServer(tls: { key: string, cert: string }) { const stack: ConnectMiddleware[] = [] - const requestServer: NodeHttpServer = createServer((req, res) => { + const httpServer: Http2SecureServer = createSecureServer({ ...tls, allowHTTP1: true }) + httpServer.on('request', (req: IncomingMessage, res: ServerResponse) => { let i = 0 const next = (): void => { const handler = stack[i++] @@ -33,18 +49,69 @@ function fakeHttpsViteServer() { } next() }) - const httpServer: Http2SecureServer = createSecureServer({ allowHTTP1: true }) + const sockets = new Set() + httpServer.on('secureConnection', (socket: Socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + }) const server = { httpServer, resolvedUrls: null, middlewares: { use: (handler: ConnectMiddleware) => stack.push(handler) }, } - return { server, httpServer, requestServer } + const close = async (): Promise => { + for (const socket of sockets) + socket.destroy() + await new Promise(resolve => httpServer.close(() => resolve())) + } + return { server, httpServer, close } +} + +function getInsecure(url: string): Promise<{ status: number, body: string }> { + return new Promise((resolve, reject) => { + request(url, { rejectUnauthorized: false }, (res) => { + let body = '' + res.setEncoding('utf8') + res.on('data', (chunk: string) => body += chunk) + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })) + }).on('error', reject).end() + }) +} + +function openWs(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, { rejectUnauthorized: false }) + ws.once('open', () => resolve(ws)) + ws.once('error', reject) + }) } -describe('viteDevframeHub', () => { +describe.skipIf(!opensslAvailable)('viteDevframeHub', () => { + let tls: { key: string, cert: string } let cleanup: (() => Promise) | undefined + beforeAll(() => { + const dir = mkdtempSync(join(tmpdir(), 'devframe-vite-hub-tls-')) + const keyPath = join(dir, 'key.pem') + const certPath = join(dir, 'cert.pem') + execFileSync('openssl', [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + keyPath, + '-out', + certPath, + '-days', + '1', + '-subj', + '/CN=localhost', + ], { stdio: 'ignore' }) + tls = { key: readFileSync(keyPath, 'utf8'), cert: readFileSync(certPath, 'utf8') } + }) + afterEach(async () => { await cleanup?.() cleanup = undefined @@ -52,9 +119,9 @@ describe('viteDevframeHub', () => { it('shares an https (http2) dev server for the WebSocket upgrade', async () => { const host = '127.0.0.1' - const port = await getPort({ port: 19800, host }) - const { server, httpServer, requestServer } = fakeHttpsViteServer() - await new Promise(resolve => requestServer.listen(port, host, resolve)) + const { server, httpServer, close } = fakeHttpsViteServer(tls) + await new Promise(resolve => httpServer.listen(0, host, resolve)) + const { port } = httpServer.address() as { port: number } const plugin = viteDevframeHub({ ui: false, @@ -62,17 +129,19 @@ describe('viteDevframeHub', () => { quiet: true, cwd: mkdtempSync(join(tmpdir(), 'devframe-vite-hub-')), }) + let ws: WebSocket | undefined cleanup = async () => { - httpServer.emit('close') + ws?.terminate() await (plugin.closeBundle as () => Promise)() - requestServer.close() - requestServer.closeAllConnections() + await close() } await (plugin.configureServer as (s: ViteDevServer) => Promise)(server as any) - const res = await fetch(`http://${host}:${port}/__devframes/__connection.json`) - const meta = await res.json() as { websocket?: unknown } - expect(meta.websocket).toEqual({ path: '/__devframes/__ws' }) - expect(httpServer.listenerCount('upgrade')).toBe(1) + const res = await getInsecure(`https://${host}:${port}/__devframes/__connection.json`) + expect(res.status).toBe(200) + expect((JSON.parse(res.body) as { websocket?: unknown }).websocket).toEqual({ path: '/__devframes/__ws' }) + + ws = await openWs(`wss://${host}:${port}/__devframes/__ws`) + expect(ws.readyState).toBe(WebSocket.OPEN) }) }) From 7222527499a87848def9ba0d2ffeecb1ff27c22b Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 1 Oct 2026 16:11:08 +0300 Subject: [PATCH 4/7] chore: let knip accept the openssl binary in the vite hub test --- knip.jsonc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/knip.jsonc b/knip.jsonc index db19dee2..99a841cd 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -157,6 +157,9 @@ // the same shape as a devframe's `clientScript`/`clientScripts` entry. "entry": ["playground/client-scripts/*.ts"] }, + "packages/vite": { + "ignoreBinaries": ["openssl"] + }, "packages/json-render": { // `src/node/index.ts` is already picked up via `tsdown.config.ts` // (its literal `entry` object parses cleanly); only `core.ts`/`hub.ts` From e324dd3d4698ce17bc550988fb108fe338ab09c2 Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 1 Oct 2026 16:18:05 +0300 Subject: [PATCH 5/7] fix(devframe): publish a shared server's WS endpoint once it listens --- .../src/adapters/__tests__/initiate.test.ts | 27 ++++++++++ packages/devframe/src/node/instance-shell.ts | 49 ++++++++++++------- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index 4f1dde66..28723151 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -232,6 +232,33 @@ describe('adapters/handler', () => { } }) + it('shared-server tier: publishes the endpoint once a not-yet-listening TLS server listens', async () => { + const host = '127.0.0.1' + const server = createSecureServer({ allowHTTP1: true }) + const devtools = initDevframe(defineTestDef('handler-tls-late'), { + base: '/__handler-tls-late/', + auth: false, + host, + server: server as any, + }) + + try { + await devtools.ready + const internal = getInternalContext(await devtools.context) + expect(internal.wsEndpoint).toBeUndefined() + + await new Promise(resolve => server.listen(0, host, resolve)) + const { port } = server.address() as { port: number } + expect(internal.wsEndpoint).toEqual({ + url: `wss://localhost:${port}/__handler-tls-late/__ws`, + }) + } + finally { + await devtools.close() + await new Promise(resolve => server.close(() => resolve())) + } + }) + it('ws.url tier: advertises the external endpoint verbatim, owns no transport', async () => { const devtools = initDevframe(defineTestDef('handler-remote'), { base: '/__handler-remote/', ws: { url: 'wss://devtools.example.com/relay/__ws' } }) diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 0103eb43..f22d682b 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -5,6 +5,7 @@ import type { DevframeRpcConnection, WsOriginRegistry, WsRpcTransport } from 'de import type { H3, H3Event } from 'h3' import type { Buffer } from 'node:buffer' import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' import type { Duplex } from 'node:stream' import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../types' import type { DevframeSseOptions, DevframeWsOptions } from '../types/devframe' @@ -144,14 +145,16 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise { + wsUrl = `${wsScheme(httpServer)}://${formatHostForUrl(bindHost)}:${boundPort}${options.path ?? ''}` + getInternalContext(context).setWsEndpoint({ url: wsUrl }) + }) + : () => {} return { origin, @@ -161,15 +164,34 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise websocketConnectionMeta(rpcHost, options.path), async close() { + cancelPublish() await closeWs() if (ownsHttpServer) await new Promise(r => httpServer.close(() => r())) - if (websocket && getInternalContext(context).wsEndpoint?.url === wsUrl) + if (wsUrl && getInternalContext(context).wsEndpoint?.url === wsUrl) getInternalContext(context).setWsEndpoint(undefined) }, } } +function wsScheme(server: NodeHttpServer): 'ws' | 'wss' { + return server instanceof TlsServer ? 'wss' : 'ws' +} + +function whenListening(server: NodeHttpServer, fn: (port: number, address: AddressInfo) => void): () => void { + const run = (): void => { + const address = server.address() + if (typeof address === 'object' && address) + fn(address.port, address) + } + if (server.listening) { + run() + return () => {} + } + server.once('listening', run) + return () => server.off('listening', run) +} + /** * Structural view of `Bun.serve`, typed loosely so devframe carries no * dependency on Bun's own types (`tsc` runs with Node lib only). @@ -943,19 +965,12 @@ export function createInstanceShell( * {@link bindHttpAndWs} does the same for the tiers it owns. */ function publishWsEndpoint(server: NodeHttpServer): void { - const record = (): void => { - const address = server.address() - if (typeof address !== 'object' || !address) - return + whenListening(server, (boundPort, address) => { const host = options.host ?? (address.address === '::' || address.address === '0.0.0.0' ? 'localhost' : address.address) getInternalContext(ctx).setWsEndpoint({ - url: `ws://${formatHostForUrl(host)}:${address.port}${routePath}`, + url: `${wsScheme(server)}://${formatHostForUrl(host)}:${boundPort}${routePath}`, }) - } - if (server.listening) - record() - else - server.once('listening', record) + }) } function handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { From 8c1067770abfd0fd44781ee4c8e327fd852e53f3 Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 1 Oct 2026 16:18:07 +0300 Subject: [PATCH 6/7] test(vite): configure the hub before the https server listens --- packages/vite/test/hub.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/vite/test/hub.test.ts b/packages/vite/test/hub.test.ts index a802fc99..3bea039d 100644 --- a/packages/vite/test/hub.test.ts +++ b/packages/vite/test/hub.test.ts @@ -1,3 +1,4 @@ +import type { DevframeHubContext } from '@devframes/hub/node' import type { IncomingMessage, ServerResponse } from 'node:http' import type { Http2SecureServer } from 'node:http2' import type { Socket } from 'node:net' @@ -8,7 +9,8 @@ import { createSecureServer } from 'node:http2' import { request } from 'node:https' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { getInternalContext } from 'devframe/node/hub-internals' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' import { viteDevframeHub } from '../src/hub' @@ -120,14 +122,16 @@ describe.skipIf(!opensslAvailable)('viteDevframeHub', () => { it('shares an https (http2) dev server for the WebSocket upgrade', async () => { const host = '127.0.0.1' const { server, httpServer, close } = fakeHttpsViteServer(tls) - await new Promise(resolve => httpServer.listen(0, host, resolve)) - const { port } = httpServer.address() as { port: number } + let context: DevframeHubContext | undefined const plugin = viteDevframeHub({ ui: false, auth: false, quiet: true, cwd: mkdtempSync(join(tmpdir(), 'devframe-vite-hub-')), + configure: (ctx) => { + context = ctx + }, }) let ws: WebSocket | undefined cleanup = async () => { @@ -136,6 +140,9 @@ describe.skipIf(!opensslAvailable)('viteDevframeHub', () => { await close() } await (plugin.configureServer as (s: ViteDevServer) => Promise)(server as any) + await vi.waitFor(() => expect(httpServer.listenerCount('upgrade')).toBeGreaterThan(0)) + await new Promise(resolve => httpServer.listen(0, host, resolve)) + const { port } = httpServer.address() as { port: number } const res = await getInsecure(`https://${host}:${port}/__devframes/__connection.json`) expect(res.status).toBe(200) @@ -143,5 +150,8 @@ describe.skipIf(!opensslAvailable)('viteDevframeHub', () => { ws = await openWs(`wss://${host}:${port}/__devframes/__ws`) expect(ws.readyState).toBe(WebSocket.OPEN) + + expect(context).toBeDefined() + expect(getInternalContext(context!).wsEndpoint).toEqual({ url: `wss://localhost:${port}/__devframes/__ws` }) }) }) From 3940156ad41735ff88fb87ea887e580238be0921 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 2 Oct 2026 01:22:41 +0000 Subject: [PATCH 7/7] refactor(devframe): trim the shared-server wss endpoint change --- packages/devframe/src/node/instance-shell.ts | 59 +++++++++----------- packages/vite/src/hub.ts | 5 +- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index f22d682b..212a65da 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -5,7 +5,6 @@ import type { DevframeRpcConnection, WsOriginRegistry, WsRpcTransport } from 'de import type { H3, H3Event } from 'h3' import type { Buffer } from 'node:buffer' import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' -import type { AddressInfo } from 'node:net' import type { Duplex } from 'node:stream' import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../types' import type { DevframeSseOptions, DevframeWsOptions } from '../types/devframe' @@ -145,16 +144,23 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise { - wsUrl = `${wsScheme(httpServer)}://${formatHostForUrl(bindHost)}:${boundPort}${options.path ?? ''}` - getInternalContext(context).setWsEndpoint({ url: wsUrl }) - }) - : () => {} + const publishWsEndpoint = (): void => { + const bound = httpServer.address() + if (!bound || typeof bound === 'string') + return + const scheme = httpServer instanceof TlsServer ? 'wss' : 'ws' + wsUrl = `${scheme}://${formatHostForUrl(bindHost)}:${bound.port}${options.path ?? ''}` + getInternalContext(context).setWsEndpoint({ url: wsUrl }) + } + if (websocket) { + if (httpServer.listening) + publishWsEndpoint() + else + httpServer.once('listening', publishWsEndpoint) + } return { origin, @@ -164,7 +170,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise websocketConnectionMeta(rpcHost, options.path), async close() { - cancelPublish() + httpServer.off('listening', publishWsEndpoint) await closeWs() if (ownsHttpServer) await new Promise(r => httpServer.close(() => r())) @@ -174,24 +180,6 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise void): () => void { - const run = (): void => { - const address = server.address() - if (typeof address === 'object' && address) - fn(address.port, address) - } - if (server.listening) { - run() - return () => {} - } - server.once('listening', run) - return () => server.off('listening', run) -} - /** * Structural view of `Bun.serve`, typed loosely so devframe carries no * dependency on Bun's own types (`tsc` runs with Node lib only). @@ -965,12 +953,19 @@ export function createInstanceShell( * {@link bindHttpAndWs} does the same for the tiers it owns. */ function publishWsEndpoint(server: NodeHttpServer): void { - whenListening(server, (boundPort, address) => { + const record = (): void => { + const address = server.address() + if (typeof address !== 'object' || !address) + return const host = options.host ?? (address.address === '::' || address.address === '0.0.0.0' ? 'localhost' : address.address) getInternalContext(ctx).setWsEndpoint({ - url: `${wsScheme(server)}://${formatHostForUrl(host)}:${boundPort}${routePath}`, + url: `ws://${formatHostForUrl(host)}:${address.port}${routePath}`, }) - }) + } + if (server.listening) + record() + else + server.once('listening', record) } function handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { diff --git a/packages/vite/src/hub.ts b/packages/vite/src/hub.ts index c57852ef..776443f4 100644 --- a/packages/vite/src/hub.ts +++ b/packages/vite/src/hub.ts @@ -180,9 +180,8 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { // the hub client runtime imports it into the host page. const devframes = attachClientScripts(options.devframes, options.clientScripts) - // Vite's https dev server is an `Http2SecureServer` (or an - // `https.Server` with `server.proxy`). Both emit `upgrade` for HTTP/1.1 - // requests, as Vite's own HMR socket relies on, so share it too. + // `server.https` makes Vite use an `Http2SecureServer`, not a `node:http` + // `Server`. It still emits `upgrade` for HTTP/1.1, so the cast is safe. const httpServer = (server.httpServer ?? undefined) as NodeHttpServer | undefined const hub = initHub({