Ship an HTTP Service Endpoint
Not every service speaks gRPC. An OAuth callback, a webhook
receiver, or an SDK transport needs plain HTTP. The transport
scaffolding is the same every time: security headers, a
/health endpoint, a request-size limit, a consistent
error response, port binding, and a clean shutdown.
@forwardimpact/libhttp owns that scaffolding so you
write only your routes.
createHttpService is the HTTP counterpart to the gRPC
Server covered in
Ship a Service Endpoint. It wraps Hono and
@hono/node-server: you mount routes through a
configure callback, then call start().
Prerequisites
- Node.js 22+
@forwardimpact/libhttpinstalled:
npm install @forwardimpact/libhttp
Mount routes and start
A service is one call. Pass a name, a bind config, a
logger, and a configure callback that mounts your
routes on the Hono app:
import { createHttpService } from "@forwardimpact/libhttp";
const service = createHttpService({
name: "greeter",
config: { host: "127.0.0.1", port: 8080 },
logger,
configure(app, { logger }) {
app.get("/greet/:name", (c) => {
const name = c.req.param("name");
logger.info("greeter.greet", name);
return c.json({ message: `Hello, ${name}!` });
});
},
});
await service.start();
// greeter.server listening { host: "127.0.0.1", port: 8080 }
The configure callback runs after the standard
middleware, so every route you mount inherits the security headers
and body limit automatically. The second argument carries the
injected logger and (when supplied)
tracer, so handlers can log and open spans without
reaching for module-level globals.
The returned service object has four members:
| Member | Purpose |
|---|---|
app |
The underlying Hono instance, for tests or extra wiring |
start() |
Binds the socket and resolves once the server is listening |
stop() |
Graceful shutdown — runs onStop, then closes the
socket
|
address() |
The bound { port }, or null before
start()
|
Pass port: 0 to let the OS pick a free port, then read
it back with address(). This is the usual pattern in
tests:
await service.start();
const { port } = service.address();
const res = await fetch(`http://127.0.0.1:${port}/health`);
What you get for free
The standard middleware runs before your routes, so the following
hold for every request without any code in configure:
| Concern | Behaviour |
|---|---|
| Security headers |
X-Content-Type-Options: nosniff,
X-Frame-Options: DENY,
Cache-Control: no-store
|
| Health check |
GET /health returns
{ "status": "ok" }
|
| Body limit |
Requests over the limit are rejected with 413
|
| Error envelope |
An uncaught handler error becomes
{ "error": "server_error" }
with status 500
|
| Graceful shutdown |
stop() runs your onStop, then closes
the socket
|
Health check
GET /health is mounted before your routes, so it
resolves even if a route in configure registers a
catch-all. A load balancer or orchestrator can poll it with no extra
code:
curl -s http://127.0.0.1:8080/health
{ "status": "ok" }
Body limit
The default request-body limit is 1 MB — generous for JSON. Override
it with bodyLimit (in bytes). A request whose body
exceeds the limit is rejected with 413 before it
reaches your handler:
const service = createHttpService({
name: "echo",
config,
logger,
bodyLimit: 64 * 1024, // 64 KB
configure(app) {
app.post("/echo", async (c) => c.json(await c.req.json()));
},
});
Set bodyLimit: 0 to disable the limit. Do this only
when a handler reads the raw request stream itself — for example an
SDK transport that consumes the body directly — since the body-limit
middleware would otherwise drain it.
Error envelope
Any error a handler throws is caught and returned as a 500 with a
stable shape, and the error message is logged under the
{name}.error tag:
{ "error": "server_error" }
To return a specific status instead, throw an
HTTPException from Hono — it carries its own status and
response, which the envelope renders directly. The
413 from the body limit works the same way.
Shut down cleanly
Signal handling lives at the entry point, not in the library —
process-exit decisions belong at the composition root. Wire
SIGINT and SIGTERM to
stop() in your server.js:
for (const sig of ["SIGINT", "SIGTERM"]) {
process.on(sig, () => service.stop());
}
stop() runs the optional onStop callback
first, then closes the listening socket. Use onStop to
release resources your routes acquired — close database sessions,
clear timers, flush buffers:
const service = createHttpService({
name: "sessions",
config,
logger,
configure(app) {
/* routes that open sessions */
},
async onStop() {
await sessionStore.closeAll();
},
});
A complete, runnable example
This service mounts one route, starts on an OS-assigned port,
exercises the free /health endpoint and security
headers, then shuts down:
import { createHttpService } from "@forwardimpact/libhttp";
const logger = {
info: (tag, msg, meta) => console.log(`[info] ${tag} ${msg}`, meta ?? ""),
error: (tag, msg) => console.error(`[error] ${tag} ${msg}`),
};
const service = createHttpService({
name: "greeter",
config: { host: "127.0.0.1", port: 0 }, // 0 = pick a free port
logger,
bodyLimit: 64 * 1024,
configure(app, { logger }) {
app.get("/greet/:name", (c) => {
logger.info("greeter.greet", c.req.param("name"));
return c.json({ message: `Hello, ${c.req.param("name")}!` });
});
},
async onStop() {
logger.info("greeter.stop", "cleaning up");
},
});
await service.start();
const { port } = service.address();
const health = await fetch(`http://127.0.0.1:${port}/health`);
console.log("GET /health ->", health.status, await health.json());
console.log(" X-Content-Type-Options:", health.headers.get("x-content-type-options"));
const greet = await fetch(`http://127.0.0.1:${port}/greet/Ada`);
console.log("GET /greet/Ada ->", greet.status, await greet.json());
await service.stop();
Running it prints:
[info] greeter.server listening { host: '127.0.0.1', port: 51949 }
GET /health -> 200 { status: 'ok' }
X-Content-Type-Options: nosniff
GET /greet/Ada -> 200 { message: 'Hello, Ada!' }
[info] greeter.stop cleaning up
stopped cleanly
Verify
You have reached the outcome of this guide when:
-
start()logslisteningandaddress()returns the bound port. -
GET /healthreturns200with{ "status": "ok" }. -
Every response carries
X-Content-Type-Options,X-Frame-Options, andCache-Controlheaders. -
A request body over
bodyLimitis rejected with413. -
stop()runsonStopand the process exits without a hanging socket.
What's next
Keep Types Synced with Proto Definitions
Proto changes flow through to JavaScript types, MCP tools, and service endpoints automatically — one source of truth from definition to runtime.
Ship a Service Endpoint
Ship a gRPC service with typed contracts, authentication, retries, and health checks — without reimplementing transport.