@open-draft/test-server
Version:
HTTP/HTTPS testing server for your tests.
260 lines • 8.87 kB
JavaScript
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