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.
- 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.
pnpm add hermes-handlerimport { 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"
}initialHandlers
Record<string, HermesHandlerFn>
options
timeoutMs?: numberonUnknown?: (msg, ctx) => HermesResponseonError?: (err, msg, ctx) => HermesResponseignoreUnknown?: booleanshouldHandle?: (msg, sender) => booleanlogger?: HermesLogger | null
Register or overwrite a handler.
Register multiple handlers at once.
Remove a handler.
Check if a handler exists.
Returns a runtime-compatible message listener.
Dispatch a message manually (useful for testing or non-extension environments).
List registered message types (registration order).
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.
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).
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
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.
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.
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.
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.
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.
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.
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.