Module ‐ server - uhop/tape-six GitHub Wiki
The tape-six/server module provides helpers for tests that need an ephemeral HTTP server: spinning one up before a test, tearing it down after, and exposing the bound base URL to the test body. It wraps node:http so it works on Node, Bun, and Deno from a single implementation. It is not intended for browser-side tests — running an HTTP server inside a webpage isn't a use case.
import {withServer, startServer, setupServer} from 'tape-six/server';Three exports cover three lifetimes:
-
withServer()— server lives for one test (the 95% case). -
startServer()— procedural primitive, caller controls lifetime. -
setupServer()— server lives for an entire suite, registered viabeforeAll/afterAll.
The same 10-line lifecycle was reimplemented across half a dozen projects in the toolkit ecosystem (dynamodb-toolkit, dynamodb-toolkit-{koa,express,fetch,lambda}, install-artifact-from-github). Six copies, each with slight drift — some race 'error' against 'listening', some don't; some default-bind to 127.0.0.1, some to localhost; close-on-keep-alive timing differs. Drift is bug-shaped: tests on a port-busy CI machine hang because no one listens for 'error'. This module centralizes the lifecycle so it gets built once and tested once.
A test using this module pairs two roles:
-
serverHandler— the function answering HTTP requests. Per-request callback: Node invokes it once for each incoming request. May be the SUT (a REST handler tested directly) or a mock impersonating an external service. -
clientHandler— the function running on the client side of the interaction while the server is up. Per-scope callback: invoked once perwithServercall. May drive requests itself (fetch(base)-style), or set up a separate SUT that does (e.g., a spawned CLI given${base}as its endpoint env var), or do neither (multi-phase choreography).
Naming reflects role on the wire (server side / client side), not which side is being tested. Either side may be the SUT.
Scoped resource: spin up a server, run a test body with the base URL, tear down in finally.
function withServer<T>(
serverHandler: RequestListener,
clientHandler: (base: string, lifecycle: ServerLifecycle) => Promise<T> | T,
opts?: ServerOptions
): Promise<T>;import test from 'tape-six';
import {withServer} from 'tape-six/server';
import {asJson} from 'tape-six/response';
test('GET /users returns the list', t =>
withServer(myHandler, async base => {
const res = await fetch(`${base}/users`);
t.equal(res.status, 200);
const body = await asJson(res);
t.equal(body.length, 3);
}));Cleanup runs whether clientHandler resolves, rejects, or throws synchronously. The return value of clientHandler is the return value of withServer.
Procedural primitive. Use when withServer's scope doesn't fit — multi-phase tests with intervening assertions, servers shared across nested test blocks, or non-test code (this is what bin/tape6-server uses).
function startServer(server: Server, opts?: ServerOptions): Promise<ServerLifecycle>;Accepts a fully-constructed http.Server so the caller can attach 'clientError' listeners, configure TLS, or set custom server options before listen.
import http from 'node:http';
import {startServer} from 'tape-six/server';
const lc = await startServer(http.createServer(handler), {host: '127.0.0.1', port: 0});
try {
// ... use lc.base ...
} finally {
await lc.close();
}The returned ServerLifecycle:
| Field | Type | Description |
|---|---|---|
server |
http.Server |
The underlying server instance. |
base |
string |
Bound base URL, e.g. "http://127.0.0.1:54321". |
port |
number |
Actual bound port (OS-assigned when port: 0 was requested). |
host |
string |
Bound host. |
close() |
() => Promise<void> |
Idempotent. Calls server.closeAllConnections() (when available) so keep-alive sockets don't delay teardown. |
startServer races 'listening' against 'error': if listen fails (EACCES, EADDRINUSE), the promise rejects with the original error rather than hanging.
Hook helper for suite-shared servers. Registers beforeAll to start and afterAll to close, and returns a frozen handle whose properties are live getters.
function setupServer(
serverHandler: RequestListener,
opts?: ServerOptions
): Readonly<{
readonly server: Server | undefined;
readonly base: string | undefined;
readonly port: number | undefined;
readonly host: string | undefined;
}>;import test from 'tape-six';
import {setupServer} from 'tape-six/server';
const server = setupServer(handler);
test('first test', async t => {
const res = await fetch(`${server.base}/foo`);
t.equal(res.status, 200);
});
test('second test', async t => {
const res = await fetch(`${server.base}/bar`);
t.equal(res.status, 200);
});Don't destructure the returned object at module load (const {base} = setupServer(...)) — base is undefined until beforeAll runs. Property access at test time always sees the live state.
State reset stays user-side. For mock-server scenarios that record requests and need a fresh recorder per test, compose your own beforeEach:
import test, {beforeEach} from 'tape-six';
import {setupServer} from 'tape-six/server';
let recorded;
const server = setupServer((req, res) => {
recorded.push({method: req.method, url: req.url});
res.writeHead(204).end();
});
beforeEach(() => {
recorded = [];
});
test('records exactly one request per fetch', async t => {
await fetch(`${server.base}/foo`);
t.equal(recorded.length, 1);
});setupServer owns the suite lifecycle; the caller owns suite state.
interface ServerOptions {
host?: string; // default '127.0.0.1' (explicit IPv4)
port?: number; // default 0 (OS-assigned)
}The default host is explicit IPv4 ('127.0.0.1'), not 'localhost'. On dual-stack systems localhost may resolve to ::1 first, and a server bound to 127.0.0.1 is then unreachable from a fetch to localhost. The IPv4-explicit default avoids that. Override via opts.host if needed.
| Lifetime | What to use | Hooks |
|---|---|---|
| Per test (server lives for one test only) | withServer |
none — the helper owns it |
| Suite-shared (one server across many tests) | setupServer |
beforeAll/afterAll registered automatically |
| Suite-shared with per-test state reset |
setupServer + your beforeEach
|
beforeEach for state, setupServer for lifecycle |
| Multi-phase / non-test code | startServer |
manual beforeAll/afterAll if needed, or no hooks at all |
Do not use beforeEach/afterEach to start/close a server per test — that's withServer written across three function bodies. Reach for withServer instead.
Pairs naturally with tape-six/response
import {withServer} from 'tape-six/server';
import {asJson, header} from 'tape-six/response';
test('GET / returns JSON', t =>
withServer(handler, async base => {
const res = await fetch(`${base}/`);
t.equal(res.status, 200);
t.match(header(res, 'content-type'), /^application\/json/);
const body = await asJson(res);
t.deepEqual(body, {ok: true});
}));tape-six/server gives the test setup; tape-six/response gives the assertion-side helpers that work uniformly across Response (fetch results) and http.IncomingMessage.