UNPKG

@open-draft/test-server

Version:

HTTP/HTTPS testing server for your tests.

260 lines 8.87 kB
import fs from 'node:fs'; import https from 'node:https'; import { randomUUID } from 'node:crypto'; import EventEmitter from 'node:events'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { serve } from '@hono/node-server'; import { DeferredPromise } from '@open-draft/deferred-promise'; export const DEFAULT_PROTOCOLS = [ 'http', 'https', ]; /** * @note Store the current file's URL in a separate variable * so that `URL` instances don't get resolves against the "http://" * scheme (as Vite does normally in the browser) in JSDOM. * This will force urls to use the "file:// scheme. */ const BASE_URL = import.meta.url; const SSL_CERT_PATH = new URL('../cert.pem', BASE_URL); const SSL_KEY_PATH = new URL('../key.pem', BASE_URL); export const kApp = Symbol('kApp'); export const kServer = Symbol('kServer'); export const kServers = Symbol('kServers'); export const kEmitter = Symbol('kEmitter'); export function createTestHttpServer(options) { const protocols = Array.from(new Set(typeof options === 'object' && Array.isArray(options.protocols) ? options.protocols : DEFAULT_PROTOCOLS)); const sockets = new Set(); const emitter = new EventEmitter(); const rootRouter = new Hono(); rootRouter.get('/', () => { return new Response('Test server is listening'); }); if (typeof options === 'object' && typeof options.defineRoutes === 'function') { options.defineRoutes(rootRouter); } /** * @note Apply the default CORS middleware last so `defineRoutes` * could override it. Request handlers are sensitive to order. */ rootRouter.use(cors()); const app = new Hono(rootRouter); const serveOptions = { fetch: app.fetch, hostname: '127.0.0.1', port: 0, }; const servers = new Map(); const serverInits = new Map(protocols.map((protocol) => { switch (protocol) { case 'http': { return ['http', serveOptions]; } case 'https': { return [ 'https', { ...serveOptions, createServer: https.createServer, serverOptions: { key: fs.readFileSync(SSL_KEY_PATH), cert: fs.readFileSync(SSL_CERT_PATH), }, }, ]; } default: { throw new Error(`Unsupported server protocol "${protocol}"`); } } })); const abortAllConnections = async () => { const pendingAborts = []; for (const socket of sockets) { pendingAborts.push(abortConnection(socket)); } return Promise.all(pendingAborts); }; const api = { [Symbol.asyncDispose]() { return this.close(); }, async listen(options = {}) { const pendingListens = []; for (const [protocol, serveOptions] of serverInits) { pendingListens.push(startHttpServer({ ...serveOptions, ...options }).then((server) => { subscribeToConnections(server, sockets); servers.set(protocol, server); emitter.emit('listen', server); })); } await Promise.all(pendingListens); }, async close() { await abortAllConnections(); const pendingClosures = []; for (const [, server] of servers) { pendingClosures.push(closeHttpServer(server)); } await Promise.all(pendingClosures); servers.clear(); }, get http() { const server = servers.get('http'); if (server == null) { throw new Error('HTTP server is not defined. Did you forget to include "http" in the "protocols" option?'); } return buildServerApi('http', server, app); }, get https() { const server = servers.get('https'); if (server == null) { throw new Error('HTTPS server is not defined. Did you forget to include "https" in the "protocols" option?'); } return buildServerApi('https', server, app); }, }; Object.defineProperty(api, kEmitter, { value: emitter }); Object.defineProperty(api, kApp, { value: app }); Object.defineProperty(api, kServers, { value: servers }); return api; } async function startHttpServer(options) { const listenPromise = new DeferredPromise(); const server = serve(options, () => { listenPromise.resolve(server); }); server.once('error', (error) => { console.error(error); listenPromise.reject(error); }); return listenPromise; } async function closeHttpServer(server) { if (!server.listening) { return Promise.resolve(); } const closePromise = new DeferredPromise(); server.close((error) => { if (error) { closePromise.reject(error); } closePromise.resolve(); }); return closePromise.then(() => { server.unref(); }); } function subscribeToConnections(server, sockets) { server.on('connection', (socket) => { sockets.add(socket); socket.once('close', () => { sockets.delete(socket); }); }); } async function abortConnection(socket) { if (socket.destroyed) { return Promise.resolve(); } const abortPromise = new DeferredPromise(); socket.destroy(); socket .on('close', () => abortPromise.resolve()) .once('error', (error) => abortPromise.reject(error)); return abortPromise; } export function createUrlBuilder(baseUrl, forceRelativePathname) { return (pathname = '/') => { return new URL(forceRelativePathname ? toRelativePathname(pathname) : pathname, baseUrl); }; } export function toRelativePathname(pathname) { return !pathname.startsWith('.') ? '.' + pathname : pathname; } export function getServerUrl(protocol, server) { let url; const address = server.address(); if (address == null) { throw new Error('Failed to get server URL: server.address() returned null'); } if (typeof address === 'string') { url = new URL(address); } else { const hostname = address.address.includes(':') && !address.address.startsWith('[') && !address.address.endsWith(']') ? `[${address.address}]` : address.address; url = new URL(`http://${hostname}`); url.port = address.port.toString() ?? ''; if (protocol === 'https') { url.protocol = 'https:'; } } return url; } function buildServerApi(protocol, server, app) { const baseUrl = getServerUrl(protocol, server); const api = { url: createUrlBuilder(baseUrl), createRoom(options) { return new Room({ roomOptions: options, app, baseUrl, }); }, }; Object.defineProperty(api, kServer, { value: server }); return api; } class Room { options; id; pathname; router; url; constructor(options) { this.options = options; this.id = randomUUID(); this.pathname = `/${this.id}/`; this.router = new Hono(); if (typeof options.roomOptions === 'object' && typeof options.roomOptions.defineRoutes === 'function') { options.roomOptions.defineRoutes(this.router); } // Attach the room router to the main app under a unique path. options.app.route(this.pathname, this.router); /** * @note Force `room.url()` to always result in the pathname relative * to the room URL, never the server URL. */ this.url = createUrlBuilder(new URL(this.pathname, options.baseUrl), true); } /** * Close the room and remove its route handlers from the server. */ close() { this.options.app.routes = this.options.app.routes.filter((route) => { return !route.path.startsWith(this.pathname); }); /** * Forcefully keep the `routes` as a truthy value. * This prevents the "Can not add a route since the matcher is already built" * caused by Hono exhausing routes and setting `this.routes = void 0` in SmartRouter. * @note THIS MAY CAUSE ISSUES. * @see https://github.com/honojs/hono/issues/3026 */ Reflect.set(this.options.app.router, 'routes', []); } [Symbol.dispose]() { this.close(); } } //# sourceMappingURL=createTestHttpServer.js.map