@zeroxsolutions/server
v0.3.3
Published
Every Hono handler the ZeroXSolutions house ships: the environment gate bound to a worker's own bindings schema, the central onError, the OpenAPIHono defaultHook and the JSON:API content-negotiation middleware - kept out of the packages that decide what t
Readme
@zeroxsolutions/server
Every Hono handler the house ships: the middlewares, the onError and the defaultHook a worker wires
before its routes. A concern package holds the DECISION as a plain function over values;
this one holds the handler that calls it. That split is what leaves the concern packages framework-free.
| Holds | Wires the decisions in |
| --- | --- |
| createEnvironment | the worker's own bindingsSchema - no package owns a worker's bindings |
| createErrorHandler, createValidationHook, jsonApiContentNegotiation | @zeroxsolutions/response, @zeroxsolutions/jsonapi |
What the split bought
| Package | Peers before | Peers now |
| --- | --- | --- |
| @zeroxsolutions/jsonapi | hono | none - plain functions over strings and documents, which is what an SPA loads |
| @zeroxsolutions/response | @hono/zod-openapi, @logtape/logtape, hono, zod | zod - hono left with the plain spelling |
That is the test for anything else moving here: it moves when the move DROPS a framework peer from the
package it left. auth() and permission() do not - the provider's own surface decides them, and
hasPermission is a call rather than a shape. Neither does dependencies(), which registers one
context's own tokens and so has nothing to share.
Install
pnpm add @zeroxsolutions/serverhono, zod and @logtape/logtape are peers - the worker already declares them.
@zeroxsolutions/jsonapi and @zeroxsolutions/response come with it, as dependencies: this package
CALLS them rather than reading a host's instance.
Quickstart
createEnvironment builds the gate that refuses a request whose bindings do not match the schema the
transport declares its Bindings from:
// packages/<context>/src/entrypoints/http/middlewares/environment.ts
import { createEnvironment } from '@zeroxsolutions/server';
import { LOG_CATEGORY } from '../../../lib/constants/index.js';
import { bindingsSchema, type AppEnvironment } from '../env.js';
/** Refuses a request whose environment does not match `bindingsSchema`, before any other middleware reads a field. */
export const environment = createEnvironment<AppEnvironment>({ schema: bindingsSchema, category: LOG_CATEGORY });The mount site is unchanged, because the binder returns a factory rather than the middleware:
// entrypoints/http/app.ts - first, so a missing binding is named once rather than at first use
app.use('*', environment());A rejected parse logs app.env.invalid with the failing PATHS and the request id, then throws an error
carrying no field name - which input an operator failed to set is not the caller's business, and the
transport's onError renders it as a bare 500.
jsonApiContentNegotiation binds to nothing of the product's - the media type and the parameter rule
are the standard's, identical on every surface answering it. It takes the surface's namespace for the
same reason the other two do: a refusal it mints is an error object like any other, and a client reading
not_acceptable beside api.place.not_found cannot tell which surface spoke.
// entrypoints/http/app.ts - one middleware, before the routes, never a per-handler check
import { jsonApiContentNegotiation } from '@zeroxsolutions/server';
app.use('*', jsonApiContentNegotiation({ namespace: 'api' }));It refuses with JsonApiError, so the refusal renders through the same central onError as every other
failure. @zeroxsolutions/jsonapi's README carries the two decisions the headers alone do not settle -
why Content-Type is read on a write only, and why an empty Accept is refused.
One error seat
createErrorHandler is the app's onError, and every failure renders through it as one
application/vnd.api+json error document carrying jsonapi: { version: '1.1' }. It asks, in order:
resolve(err, c)- where a surface's domain error map answers, through@zeroxsolutions/response'screateDomainErrorResolver;- a request the validation hook rejected, one error object per issue;
- a
JsonApiError, rendered as it was thrown; - otherwise the status alone - a hono
HTTPExceptionat its own, anything else as a 500.
Whichever step answers, the handler writes one http.error.answered line under category before the
document goes out, at the level the answered status class decides: a 5xx at error, anything else at
warn. The line carries the request id, the method, the path, the answered status, the step that claimed
the failure (resolve, validation, jsonapi, status) and the error's type and message. Without it
a mapped 500 or a guard's 403 reaches the client with nothing in the log for an operator to join it to.
The requestId option's value becomes the id of each error object that carries none, so a client
quotes the id the request's log lines carry.
createValidationHook throws its rejection rather than answering, which is what puts a 400 or 422 on
that same path. It is still passed to the OpenAPIHono constructor, because without a hook
@hono/zod-validator (0.9.0) answers a rejected parse with its own raw 400. And it needs
createErrorHandler on the same app: hono answers an error no onError catches with a text 500.
// entrypoints/http/app.ts
const app = new OpenAPIHono<AppEnvironment>({ defaultHook: createValidationHook({ namespace: 'api' }) });
app.onError(
createErrorHandler({
namespace: 'api',
category: LOG_CATEGORY,
resolve: createDomainErrorResolver(apiErrors),
requestId: (c) => c.var.requestId,
}),
);Why a binder rather than a ready middleware
The schema and the log category are the package's, not the library's: the schema is what the transport
declares its Bindings from, and the category names that worker's isolate. A ready middleware would
have to read both from somewhere global. Binding them once, at the module that owns them, keeps the
type flowing (createEnvironment<AppEnvironment>) and leaves the mount site the shape a middleware
module already has - a factory named for its file, called where it is mounted.
API reference
The version-accurate API is the shipped types - every export carries TSDoc compiled into
dist/**/*.d.ts. Read it in your editor or at node_modules/@zeroxsolutions/server/dist/. This README
does not restate the API: the types are the source of truth and never drift from the version.
Building & testing
pnpm nx build @zeroxsolutions/server
pnpm nx test @zeroxsolutions/server