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
3 changes: 3 additions & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
53 changes: 53 additions & 0 deletions packages/devframe/src/adapters/__tests__/initiate.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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' }
Expand Down Expand Up @@ -206,6 +208,57 @@ 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<void>(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<void>(resolve => server.close(() => resolve()))
}
})

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

Expand Down
24 changes: 19 additions & 5 deletions packages/devframe/src/node/instance-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -144,10 +145,22 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
const address = httpServer.address()
const resolvedPort = typeof address === 'object' && address ? address.port : port
const origin = normalizeHttpServerUrl(bindHost, resolvedPort)
const internal = getInternalContext(context)
const wsUrl = `ws://${formatHostForUrl(bindHost)}:${resolvedPort}${options.path ?? ''}`
if (websocket)
internal.setWsEndpoint({ url: wsUrl })
// A shared server may not be listening yet (Vite listens after plugins configure).
let wsUrl: string | undefined
const publishWsEndpoint = (): void => {
const bound = httpServer.address()
if (!bound || typeof bound === 'string')
return
const scheme = httpServer instanceof TlsServer ? 'wss' : 'ws'
Comment thread
erkamyaman marked this conversation as resolved.
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,
Expand All @@ -157,10 +170,11 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
rpcGroup: core.rpcGroup,
connectionMeta: () => websocketConnectionMeta(rpcHost, options.path),
async close() {
httpServer.off('listening', publishWsEndpoint)
await closeWs()
if (ownsHttpServer)
await new Promise<void>(r => httpServer.close(() => r()))
if (websocket && getInternalContext(context).wsEndpoint?.url === wsUrl)
if (wsUrl && getInternalContext(context).wsEndpoint?.url === wsUrl)
getInternalContext(context).setWsEndpoint(undefined)
},
}
Expand Down
14 changes: 8 additions & 6 deletions packages/vite/src/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -180,7 +180,9 @@ 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
// `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
Comment thread
erkamyaman marked this conversation as resolved.

const hub = initHub({
base,
Expand All @@ -198,8 +200,8 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin {
auth: options.auth,
/**
* Share Vite's own HTTP server for the WS upgrade at `<base>__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
Comment thread
erkamyaman marked this conversation as resolved.
* side-car. Clients discover either via `__connection.json`.
*/
server: httpServer,
Expand Down Expand Up @@ -267,8 +269,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)
Expand Down
157 changes: 157 additions & 0 deletions packages/vite/test/hub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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'
import type { ViteDevServer } from 'vite'
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 { getInternalContext } from 'devframe/node/hub-internals'
import { afterEach, beforeAll, describe, expect, it, vi } 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`. This one runs the
* plugin's connect middlewares like Vite does.
*/
function fakeHttpsViteServer(tls: { key: string, cert: string }) {
const stack: ConnectMiddleware[] = []
const httpServer: Http2SecureServer = createSecureServer({ ...tls, allowHTTP1: true })
httpServer.on('request', (req: IncomingMessage, res: ServerResponse) => {
let i = 0
const next = (): void => {
const handler = stack[i++]
if (!handler) {
res.statusCode = 404
res.end()
return
}
handler(req, res, next)
}
next()
})
const sockets = new Set<Socket>()
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) },
}
const close = async (): Promise<void> => {
for (const socket of sockets)
socket.destroy()
await new Promise<void>(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<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, { rejectUnauthorized: false })
ws.once('open', () => resolve(ws))
ws.once('error', reject)
})
}

describe.skipIf(!opensslAvailable)('viteDevframeHub', () => {
let tls: { key: string, cert: string }
let cleanup: (() => Promise<void>) | 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
})

it('shares an https (http2) dev server for the WebSocket upgrade', async () => {
const host = '127.0.0.1'
const { server, httpServer, close } = fakeHttpsViteServer(tls)
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 () => {
ws?.terminate()
await (plugin.closeBundle as () => Promise<void>)()
await close()
}
await (plugin.configureServer as (s: ViteDevServer) => Promise<void>)(server as any)
await vi.waitFor(() => expect(httpServer.listenerCount('upgrade')).toBeGreaterThan(0))
await new Promise<void>(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)
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)

expect(context).toBeDefined()
expect(getInternalContext(context!).wsEndpoint).toEqual({ url: `wss://localhost:${port}/__devframes/__ws` })
})
})
Loading