@event-kit/core
v0.1.0
Published
Isomorphic, zero-dependency event engine providing structured types, i18n formatting, deduplication, and client sanitization.
Readme
@event-kit/core
Isomorphic, zero-dependency eventging primitives, structured types, and a lightweight eventger engine.
Designed for high-throughput Node.js, browser, edge runtime, and serverless environments.
@event-kit/core provides strongly-typed eventging contracts with native i18n support, runtime
deduplication, and audience-based filtering, with zero runtime dependencies.
Features
- Isomorphic & zero-dependency: runs identically in Node.js, browsers, Bun, Deno, and edge environments.
- Strongly typed contracts: structured interfaces (
event,eventClient,SerializedError) and string-union constants (eventType,eventErrorType,eventAudience). - Rust
serdeparity: constants and properties uselowerCamelCase, matchingserde(rename_all = "camelCase")mapping. - Context-aware
eventger: child scopes, level filtering, time-window deduplication, and pluggable transports. - Environment-aware console output:
minLevelalone decides whether a event is emitted; the default transport separately uses a resolvedisDevflag to decide how much detail to print for events that pass, keeping raw payloads out of production consoles by default without silently overriding your level configuration. Bundler-specific dev flags (e.g. Vite/SvelteKit'simport.meta.env.DEV) can be passed in vianew eventger({ isDev }). - Built-in utilities: structured error serialization with sensitive-key redaction (including Axios-style
.responseerrors), deduplication, audience sanitization/filtering, multi-event aggregation, and type guards — every utility is a standalone function, independently importable from@event-kit/core/utils.
Installation
npm install @event-kit/core
# or
pnpm add @event-kit/core
# or
yarn add @event-kit/core
# or
bun add @event-kit/coreQuick start
1. eventger
import { eventger, eventType, eventErrorType } from "@event-kit/core";
const eventger = new eventger({
namespace: "api-gateway",
minLevel: eventType.debug,
dedupeWindowMs: 1000 // suppress an identical event re-emitted within 1s
});
eventger.info({
code: "USER_AUTH_SUCCESS",
message: "User usr_1024 eventged in from 192.168.1.1"
});
// Scoped child eventger — namespaces are joined with ":"
const autheventger = eventger.child({ group: "oauth-flow" });
autheventger.warn({
code: "ERR_RATE_LIMIT",
message: "IP exceeded maximum auth attempts",
errorType: eventErrorType.rateLimit,
status: 429
});Or use the createeventger factory if you'd rather not use new:
import { createeventger } from "@event-kit/core";
const eventger = createeventger({ namespace: "api-gateway" });Each level method (trace, debug, info, event, warn, error, fatal) returns the
normalized event object it emitted, or null when the entry was filtered out by minLevel
or suppressed as a duplicate.
Note: templating/interpolation (
{key}-style placeholders) is intentionally out of scope for this package — build the finalmessagestring yourself, or reach for a dedicated templating package.
2. Custom transports
By default events are written to the console. minLevel is the only thing that decides whether a
event is emitted — the default console transport never re-filters by severity. It does use
isDev for one thing: outside production, it hides event.details (arbitrary, unredacted,
caller-supplied context) from the console, since — unlike event.error, which passes through
serializeError's redaction — nothing in this package sanitizes details. event.error is
always shown for error/fatal events, since that's the point of eventging an error. Provide
transports to ship events anywhere else (a file, an HTTP sink, an observability platform) —
transport errors are caught internally so one broken transport can't take down the others or the
calling app. An empty transports: [] is treated the same as omitting it (falls back to the
console transport) — pass a no-op transport ([() => {}]) if you want to silence output entirely.
import { eventger, type eventTransportHandler } from "@event-kit/core";
const shipToDatadog: eventTransportHandler = (event) => {
fetch("https://http-intake.events.datadoghq.com/v1/input", {
method: "POST",
body: JSON.stringify(event)
});
};
const eventger = new eventger({ transports: [shipToDatadog] });isDev (whether event.details gets shown) defaults to this package's own isDev() utility,
which only checks process.env.NODE_ENV. That's the standard convention in Node and in
bundlers that replace it (webpack, esbuild, Next.js), but Vite and SvelteKit expose their dev
flag as import.meta.env.DEV instead — isDev()'s process check runs before any
bundler-side text replacement could apply, so it can't see that flag on its own. Pass it in
explicitly:
import { eventger, isDev } from "@event-kit/core";
// In a Vite/SvelteKit app:
const eventger = new eventger({ isDev: import.meta.env.DEV });
// Or, to keep a graceful fallback for environments without `import.meta.env`
// (e.g. this same code also running under plain Node/Jest):
const eventger = new eventger({ isDev: isDev(import.meta.env.DEV) });isDev is resolved once per eventger instance at construction time (not re-checked on every
event), and child() inherits the parent's resolved value unless a child explicitly overrides it.
3. Sanitizing events for public APIs (toClientevent)
Strip internal stack traces, system namespaces, and non-client-visible fields before returning event payloads to frontend clients:
import { toClientevent, type event } from "@event-kit/core";
const internalevent: event = {
code: "ERR_DB_TIMEOUT",
message: "Database query timed out for query ID q_88",
namespaces: "internal-db-cluster",
stack: "Error: Query failed at Pool.query...",
details: { internalHost: "10.0.0.5" }
};
const publicevent = toClientevent(internalevent);
// { code: "ERR_DB_TIMEOUT", message: "..." }4. Serializing caught errors (serializeError)
Handles plain Errors, primitive throws, and structured HTTP-client errors (e.g. Axios) that
carry a .response. Sensitive-looking keys (password, token, authorization, cookie, ...)
are redacted wherever this function touches external data — the error's own properties,
response.headers, and response.data — as a best-effort safety net against accidentally
eventging credentials. Redaction recurses into nested objects/arrays (default 3 levels deep, with
circular-reference protection), so a nested response.data.user.password is caught too, not
just a top-level field:
import { serializeError } from "@event-kit/core";
try {
await axios.get("/users/1");
} catch (err) {
const serialized = serializeError(err);
// { name: "AxiosError", message: "...", code: "ERR_BAD_REQUEST",
// response: { status: 404, statusText: "Not Found",
// data: { user: { password: "[REDACTED]" } },
// headers: { authorization: "[REDACTED]", "content-type": "application/json" } } }
}5. Redacting sensitive keys directly (redactObject / redactValue)
The same redaction serializeError uses internally is available standalone, for scrubbing any
plain object (e.g. request headers) or arbitrary value (object, array, or primitive) before you
hand it to a transport or a details payload:
import { redactObject, redactValue } from "@event-kit/core";
redactObject({ userId: "u1", password: "hunter2" });
// { userId: "u1", password: "[REDACTED]" }
redactObject({ user: { profile: { password: "hunter2" } } });
// { user: { profile: { password: "[REDACTED]" } } } — recurses by default
// redactValue also handles arrays and primitives, for when you don't know the shape ahead of time
redactValue([{ token: "a" }, { token: "b" }]);
// [{ token: "[REDACTED]" }, { token: "[REDACTED]" }]6. Building event objects without a eventger instance (createevent)
The same normalization eventger uses internally — building a complete event from a level and
payload, serializing payload.error — is exported standalone so you can compose it into your
own pipeline:
import { createevent, eventType } from "@event-kit/core";
try {
await db.query(sql);
} catch (error) {
const event = createevent(eventType.error, { code: "ERR_DB_QUERY", error });
// event.error -> SerializedError, event.stack derived automatically
}7. Aggregating multiple events (toevent)
Collapse a batch of related events (e.g. several field-validation failures) into a single, localizable, displayable event:
import { toevent } from "@event-kit/core";
const summary = toevent([
{ code: "ERR_EMAIL_INVALID", type: "warn", errorType: "validation", message: "Invalid email" },
{ code: "ERR_AGE_INVALID", type: "error", errorType: "validation", message: "Invalid age" }
]);
// summary.type === "error", summary.code === "error.validation.error"summary.audience is only set when you pass config.audiences (to the first requested
audience); otherwise it's left undefined, same as any other event with no explicit audience —
it does not silently default to eventAudience.admin.
8. Audience-based filtering (filterByAudience)
import { filterByAudience, eventAudience } from "@event-kit/core";
const clientVisibleevents = filterByAudience(allevents, eventAudience.client);9. Checking for errors in a batch (hasError)
import { hasError, eventType } from "@event-kit/core";
if (hasError(aggregatedevents)) {
// at least one event is `error` or `fatal` (the ERROR_TYPES default)
}
hasError(aggregatedevents, [eventType.warn, eventType.error, eventType.fatal]); // custom thresholdArchitecture
No event class
event is a plain data shape (see Type contracts), not a class. A class
would only add value here if event needed private state, inheritance, or behavior tightly
bound to a single instance — it doesn't. events are plain objects created, passed around,
serialized, and compared by value across process/network boundaries (they need to survive a
JSON.stringify round-trip), which is exactly what plain objects are for. createevent() is the
functional equivalent of a constructor: it takes a level and a payload and returns a fully
normalized event, with no hidden state and no prototype.
eventger is a class, and that's the right call here
Unlike event, the eventger engine genuinely owns per-instance state: its resolved config, its
transports, and a one-slot dedupe cache that has to persist across calls. That's real
encapsulated, mutable state tied to one instance's lifetime — exactly what a class is for, and
child() needs prototype-free, straightforward instantiation (new eventger({...})) to produce
scoped instances cleanly. createeventger() is provided alongside it for callers who'd rather
not use new.
Every method on eventger is deliberately thin — dispatch() reads like a short pipeline of
calls into ./utils (meetsMinLevel, createevent, isDuplicate, mergeNamespace, isDev)
rather than reimplementing that eventic inline. The class owns state and orchestration; the
actual eventic lives in standalone, independently testable functions that other packages can
also import without touching eventger at all.
Utilities reference
All utilities live under src/utils/ (one file per concern) and are re-exported from both
@event-kit/core and @event-kit/core/utils.
| Function | Description |
| --- | --- |
| createevent(type, payload) | Builds a normalized event, serializing payload.error when present. |
| serializeError(error) | Converts an unknown thrown value into a SerializedError, including Axios-style .response errors. Never throws. |
| isDuplicate(eventA, eventB, options?) | Structural equivalence check between two events. |
| toClientevent(event) | Strips server-only fields for public API responses. |
| filterByAudience(events, targetAudience) | Filters events by visibility scope. |
| toevent(events, config?) | Reduces multiple events into one representative event. |
| pickeventType(types) | Picks the most severe eventType from a list. |
| pickErrorType(errorTypes) | Picks the most specific eventErrorType from a list. |
| summarizeMessages(messages, type?) | Joins multiple messages into one summary string. |
| hasError(events, types?) | Checks whether any event's type is in types (defaults to ERROR_TYPES: fatal, error). |
| meetsMinLevel(type, minLevel?) | Checks whether a severity level meets a minimum threshold. |
| mergeNamespace(parent?, child?) | Joins a parent/child namespace pair the same way eventger.child does. |
| isDev(fallback?) | Detects whether the current environment is non-production, via process.env.NODE_ENV (works in Node and in any bundler that statically replaces it — webpack, esbuild, Vite, Next.js, ...). |
| redactObject(obj, depth?) | Recursively redacts sensitive-looking keys (password, token, ...) on a plain object, up to depth levels (default 3), with circular-reference protection. |
| redactValue(value, depth?) | Same as redactObject, but for a value of unknown shape — object, array, or primitive. |
| isSensitiveKey(key) | Checks whether a key name looks like it holds a secret. |
| iseventType, iseventErrorType, iseventAudience | Runtime type guards for the string-union constants. |
Type contracts
All interfaces and type aliases live in src/types/types.d.ts.
export interface event extends eventClient, eventErrorPayload, eventContext {
details?: Record<string, unknown>;
}
export interface eventClient extends eventLocalization {
type?: eventType;
status?: number;
events?: event[];
}
export interface SerializedError {
name: string;
message: string;
stack?: string;
code?: string | number;
response?: SerializedErrorResponse;
raw?: unknown;
[key: string]: unknown;
}
export interface eventgerOptions {
namespace?: string;
group?: string;
defaultAudience?: eventAudience;
minLevel?: eventType;
dedupeWindowMs?: number;
transports?: eventTransportHandler[];
isDev?: boolean;
}eventType, eventErrorType, and eventAudience are as const string-union objects (not TypeScript
enums), so they compile away to plain string literals with no runtime enum object overhead
beyond the small lookup table itself, and compare correctly across module/bundle boundaries.
