Middleware
Every message — outgoing and incoming — can flow through a middleware chain. Middleware can observe, transform, delay, retry, or block messages.
The onion model
A middleware receives the message, a context, and next. Calling next(message) passes control inward; code after await next() runs on the way back out — like layers of an onion:
import type { Middleware } from "nbridge";
const timing: Middleware = async (message, context, next) => {
const start = performance.now(); // ── entering the onion
await next(message); // inner layers + delivery/dispatch
const ms = performance.now() - start; // ── unwinding
console.log(`${context.direction} ${message.type} took ${ms.toFixed(1)}ms`);
};
bridge.use(timing);interface MiddlewareContext {
direction: "outgoing" | "incoming";
timestamp: number; // when the chain started
bridge?: unknown; // the BridgeManager instance
}Rules of the game:
- Register order = execution order.
bridge.use(a); bridge.use(b)runsathenbon the way in,bthenaon the way back. - Transform by passing a modified message to
next({...message, payload: ... }). - Block by not calling
next()— the message is silently dropped. - Fail by throwing — an outgoing throw rejects the
send()promise (and falls into the retry queue when enabled). - Both directions. The same chain runs for outgoing and incoming messages; branch on
context.directionwhen behavior should differ. - Protocol messages skip it. Handshake messages are handled before middleware; queued messages being replayed also skip the chain so stamping middleware does not run twice.
Middleware is on by default; disable the whole system with middleware: { enabled: false }.
The ten built-ins
All are factory functions importable from nbridge:
import {
loggingMiddleware,
timingMiddleware,
validationMiddleware,
transformMiddleware,
filterMiddleware,
retryMiddleware,
throttleMiddleware,
encryptionMiddleware,
metadataMiddleware,
debugMiddleware,
} from "nbridge";loggingMiddleware(prefix = "Bridge")
Logs every message with its direction to the console.
bridge.use(loggingMiddleware("MyApp"));
// [MyApp] OUTGOING - getUser {...}timingMiddleware(onTiming?)
Measures how long the rest of the chain (including delivery/dispatch) takes.
bridge.use(
timingMiddleware((type, duration, direction) => {
analytics.track("bridge_timing", { type, duration, direction });
}),
);validationMiddleware(validator)
Structural gate for every message. Return false (generic error), an error string, or true to pass.
bridge.use(
validationMiddleware((message) => {
if (message.type.length > 64) return "Message type too long";
return true;
}),
);transformMiddleware(transform)
Rewrite messages in flight; may be async.
bridge.use(
transformMiddleware((message, direction) =>
direction === "outgoing"
? { ...message, payload: { ...(message.payload as object), appVersion: "1.4.2" } }
: message,
),
);filterMiddleware(filter)
Drop messages that fail the predicate — no error, no delivery.
// Never forward debug chatter to production hosts
bridge.use(filterMiddleware((message) => !message.type.startsWith("debug:")));retryMiddleware(maxRetries = 3, delayMs = 1000)
Re-runs the inner chain when it throws, with linear backoff (delayMs * attempt). Rethrows after the last attempt.
bridge.use(retryMiddleware(3, 500));throttleMiddleware(messagesPerSecond)
Rate-limits the chain by delaying messages that arrive too quickly.
bridge.use(throttleMiddleware(20)); // at most ~20 messages/secondencryptionMiddleware(encrypt, decrypt)
Encrypts outgoing payloads (wrapped as { encrypted: string }) and decrypts incoming ones. You supply both functions; they may be async.
bridge.use(
encryptionMiddleware(
async (data) => aesEncrypt(JSON.stringify(data)),
async (blob) => JSON.parse(await aesDecrypt(blob)),
),
);WARNING
The native side must apply the mirror-image transformation, and unencrypted incoming payloads pass through untouched. This is a convenience hook, not a security review.
metadataMiddleware(metadata)
Stamps __metadata onto every plain-object payload. Accepts a static object or a factory for per-message values.
bridge.use(
metadataMiddleware(() => ({
sessionId,
sentAt: Date.now(),
})),
);Non-object payloads (strings, arrays, numbers) are left untouched.
debugMiddleware(enabled = true)
console.group per message with the full message, context, timestamp, execution time, and any error. Noisy by design — development only.
bridge.use(debugMiddleware(import.meta.env.DEV));Composing
Order matters. A sensible production stack:
bridge.use(filterMiddleware((m) => allowedTypes.has(m.type))); // gate first
bridge.use(metadataMiddleware(() => ({ sessionId }))); // then stamp
bridge.use(retryMiddleware(2, 300)); // retry around delivery
bridge.use(timingMiddleware(reportTiming)); // measure everything inside