|
| 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