hermes-handler
v1.0.3
Published
HermesHandler is a lightweight, framework-agnostic message router for browser extensions and event-driven systems, with strict {ok,result,error} envelopes, timeouts, cooperative cancellation, and safe normalization. Ideal for reliable LLM agent backbones.
Maintainers
Readme
hermes-handler
Lightweight, framework-agnostic message router for browser extensions and event-driven systems. Strict response envelope, built-in timeouts, cooperative cancellation, and a tiny client subpath for size-sensitive bundles.
Features
- Deterministic message routing via
type - Strict response envelope:
{ ok:true, result?, info? } | { ok:false, error, info? } - Built-in timeout handling
- Cooperative cancellation via
AbortSignal - Immutable (shallow-frozen) responses
- LLM-friendly deterministic contract
- Framework-agnostic (no runtime dependencies)
- Type-safe via generated
.d.ts - Tiny client-side helper via
hermes-handler/clientsubpath (≈ 1 KB minified). Speaks the same wire envelope without bringing the router class into size-sensitive bundles.
Install
pnpm add hermes-handlerQuick start
import { HermesHandler } from "hermes-handler";
const handlers = {
ping: () => ({ ok: true, result: "pong" }),
greet: (msg) => {
return { ok: true, result: `Hello ${msg.payload.name}` };
}
};
const hermes = new HermesHandler(handlers);
const res = await hermes.dispatch({ type: "ping" });
if (res.ok) {
console.log(res.result); // "pong"
}API
new HermesHandler(initialHandlers?, options?)
initialHandlers
Record<string, HermesHandlerFn>
options
timeoutMs?: numberonUnknown?: (msg, ctx) => HermesResponseonError?: (err, msg, ctx) => HermesResponseignoreUnknown?: booleanshouldHandle?: (msg, sender) => booleanlogger?: HermesLogger | null
.register(type, fn)
Register or overwrite a handler.
.registerMany(map)
Register multiple handlers at once.
.unregister(type)
Remove a handler.
.has(type)
Check if a handler exists.
.getListener()
Returns a runtime-compatible message listener.
.dispatch(msg, sender?)
Dispatch a message manually (useful for testing or non-extension environments).
.types()
List registered message types (registration order).
Response envelope
All wire responses follow a strict envelope. Handler returns may use ergonomic shorthand, but Hermes normalizes every settled response before it leaves the router. Payload belongs under result, diagnostics belong under info, failures belong under error, and requestId is echoed from the request unless the handler provides one.
Success
{ ok: true, result: any, info?: any }Error
{ ok: false, error: string, info?: any }Primitive and non-envelope return values are automatically normalized:
return "hello";
// -> { ok: true, result: "hello" }
return { sourceCandidates };
// -> { ok: true, result: { sourceCandidates } }Success envelopes with an explicit result keep that result as the primary payload. Extra top-level fields are treated as diagnostics and moved into info:
return { ok: true, result: { sourceCandidates }, diagnostics, warnings };
// -> { ok: true, result: { sourceCandidates }, info: { diagnostics, warnings } }Success envelopes without an explicit result treat non-canonical top-level fields as the primary payload:
return { ok: true, sourceCandidates };
// -> { ok: true, result: { sourceCandidates } }
return { ok: true, sourceCandidates, info: { timingMs: 12 } };
// -> { ok: true, result: { sourceCandidates }, info: { timingMs: 12 } }
return { ok: true, error: "not actually failed" };
// -> { ok: true, info: { error: "not actually failed" } }Fields reserved for the opposite branch, such as error on ok:true, are treated as diagnostics instead of success payload.
Error envelopes stay strict: ok:false must include a string error. Canonical info is preserved as info. Extra fields on errors are moved into info; if the handler also provided info, Hermes preserves it under info.handlerInfo when combining it with those extras. Malformed envelopes are coerced into valid error responses.
Handler return contract
Every handler MUST settle the response by one of:
- Return a value. Primitives →
{ ok: true, result: value }. Full envelopes ({ ok, result?, error? }) are normalized into canonical wire envelopes. Promises are awaited. - Call
ctx.send(payload). Sync or async, before the handler's returned Promise settles. Subsequentctx.sendcalls are ignored (idempotent).
Returning undefined WITHOUT calling ctx.send settles the dispatch with { ok: false, error: "Handler ${type} returned no response" } — this is treated as a handler bug, not a valid envelope.
// ✅ return-value style
{ ping: () => 'pong' }
// ✅ full-envelope return
{ ping: () => ({ ok: true, result: 'pong' }) }
// ✅ ctx.send style (sync)
{ ping: (_msg, ctx) => { ctx.send('pong'); } }
// ✅ ctx.send style (async)
{ slow: async (_msg, ctx) => {
const data = await fetchSomething();
ctx.send({ ok: true, result: data });
} }
// ❌ returns undefined, never sends — settles with "returned no response" error
{ bad: () => { doSideEffect(); } }If both ctx.send and a return value are present, ctx.send wins (it settles first).
Notes
Browser extension usage
Attach hermes-handler to a runtime listener:
browser.runtime.onMessage.addListener(
hermes.getListener()
);Both styles are supported:
- Promise-returning listeners (MV3 / Firefox / polyfill)
- Callback-style
sendResponse + return true
Ignoring messages owned by other listeners
Browser extensions can have multiple runtime.onMessage listeners alive at the same time. By default, hermes-handler preserves its original behavior and responds to unknown message types with an error envelope.
If a listener should only claim messages it knows how to handle, enable ignoreUnknown:
const hermes = new HermesHandler(handlers, {
ignoreUnknown: true
});When ignoreUnknown is enabled, getListener() returns false for runtime messages whose type is missing or not registered. That lets another listener handle the message instead of racing it with an unknown-message response.
For scoped extension pages or richer ownership rules, provide shouldHandle:
const hermes = new HermesHandler(handlers, {
shouldHandle: (msg) => msg?.scope === "popup" || hermes.has(msg?.type)
});When shouldHandle is provided, it is the runtime listener ownership predicate. If it returns true, hermes-handler uses normal dispatch behavior. If it returns false, the listener returns false without sending a response.
Half-and-half: server router + tiny client
hermes-handler has two natural sides:
- Server side. Runs the handlers. You want the full
HermesHandlerclass here (routing, normalization, per-handler timeout, AbortSignal plumbing). - Client side. Sends a request and parses the envelope. You don't need the router; you need ~1 KB of wire-correlation glue.
For size-sensitive contexts (page-world bundles, popups, child processes), import the client subpath instead of the class:
// page-world / popup / inline-injected bundle
import { createHermesClient } from "hermes-handler/client";
const dispatch = createHermesClient({
send: (msg) => window.parent.postMessage(msg, "*"),
subscribe: (handler) => {
const listener = (e) => handler(e.data);
window.addEventListener("message", listener);
return () => window.removeEventListener("message", listener);
},
defaultTimeoutMs: 8000,
});
const res = await dispatch({ type: "code-source.fetch", payload: { url } });
if (res.ok) console.log(res.result);
else console.warn(res.error, res.info);createHermesClient handles requestId correlation, per-call timeout, AbortSignal, and envelope normalization. The wire shape is the contract; the client is one implementation of it. Half-and-half is fine and often correct.
Timeouts
Handlers can be time-limited:
const hermes = new HermesHandler(handlers, {
timeoutMs: 7000
});If exceeded, hermes-handler returns:
{ ok: false, error: "Handler <type> timed out (7000 ms)" }Pick a timeout longer than the longest legitimate handler. If any handler awaits a fetch() or other network call, match or exceed that call's own timeout. Use timeoutMs: 0 to opt out entirely when the caller doesn't care about the reply.
Cooperative cancellation
Each handler receives an AbortSignal:
async function longTask(msg, ctx) {
if (ctx.signal?.aborted) {
return { ok: false, error: "Cancelled" };
}
ctx.signal?.addEventListener("abort", () => {
console.log("Cancelled externally");
});
}hermes-handler aborts the signal once a request lifecycle completes.
Logging
hermes-handler emits warnings and errors through a configurable logger. By default, it uses the global console. You can disable logging entirely or provide a custom logger implementation.
Disable logging
const hermes = new HermesHandler(handlers, {
logger: null
});Custom logger
const hermes = new HermesHandler(handlers, {
logger: {
warn: (...args) => myLogger.warn(...args),
error: (...args) => myLogger.error(...args)
}
});HermesLogger shape
interface HermesLogger {
debug?(message?: any, ...optionalParams: any[]): void;
info?(message?: any, ...optionalParams: any[]): void;
warn?(message?: any, ...optionalParams: any[]): void;
error?(message?: any, ...optionalParams: any[]): void;
}If logger is null, hermes-handler will not emit any console output.
Design goals
hermes-handler enforces a predictable and deterministic runtime contract. By standardizing request/response handling and isolating message dispatch logic, it simplifies reasoning about complex systems, particularly those involving automation, background scripts, or LLM-driven tool execution. The core remains intentionally minimal, dependency-free, and portable.
Project docs
License
Licensed under AGPL-3.0 with WATT3D Additional Terms. See LICENSE and ADDITIONAL_TERMS.md. Commercial AI/model-training use requires compliance with those terms or a separate WATT3D license. © WATT3D.
