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 via beforeAll/afterAll.

Why a server harness

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.

The two-role model

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 per withServer call. 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.

withServer()

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.

startServer()

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.

setupServer()

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.

Options

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.

Composing with hooks

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.

⚠️ **GitHub.com Fallback** ⚠️