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, a bound port, and a clean shutdown.
@forwardimpact/libhttp owns that scaffolding so you
write only your routes.
createHttpService is the HTTP counterpart to the gRPC
Server that
Ship a Service Endpoint
covers. It wraps Hono and
@hono/node-server. You mount routes through a
configure callback. Then you 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 the tracer (when you
supply one). Handlers can then log and open spans without
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 after the server listens |
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 |
The service rejects requests over the limit 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
createHttpService mounts
GET /health before your routes. The route resolves even
if a route in configure registers a catch-all. A load
balancer or an 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. That is generous for JSON.
Override it with bodyLimit (in bytes). The service
rejects a request whose body exceeds the limit. It returns
413 before the request 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. An SDK transport
that consumes the body directly is one example. The body-limit
middleware would otherwise drain the stream.
Error envelope
The service catches any error a handler throws. It returns a 500
with a stable shape. It logs the error message 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. The envelope renders them directly. The
413 from the body limit works the same way.
Shut down cleanly
Signal handlers live at the entry point. They do not live 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 it closes the socket it listens on. Use
onStop to release the resources your routes acquired.
Close database sessions, clear timers, and 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. It starts on an OS-assigned port. It
exercises the free /health endpoint and the security
headers. Then it 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();
When you run it, the script 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 reach the outcome of this guide when:
-
start()logslistening.address()returns the bound port. -
GET /healthreturns200with{ "status": "ok" }. -
Every response carries
X-Content-Type-Options,X-Frame-Options, andCache-Controlheaders. -
The service rejects a request body over
bodyLimitwith413. -
stop()runsonStop. The process exits and leaves no open 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 runs from definition to runtime.
Ship a Service Endpoint
Ship a gRPC service with typed contracts, authentication, retries, and health checks. You do not reimplement the transport.