JSPM

@kellanjs/eventcraft

0.1.0
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 17
  • Score
    100M100P100Q58079F
  • License MIT

A tiny, type-safe event library with inferred payloads, selective subscriptions, and optional React hooks.

Package Exports

  • @kellanjs/eventcraft
  • @kellanjs/eventcraft/package.json
  • @kellanjs/eventcraft/react

Readme

Eventcraft

A tiny TypeScript event helper for components that need to emit events and subscribe to exactly the events they care about.

Eventcraft gives you a typed event tree. Emit from leaf events, then subscribe to one event, a whole branch, the root, a named group, or a one-off selection.

import { event, eventcraft, group } from "@kellanjs/eventcraft";

const events = eventcraft()
  .events({
    notes: {
      created: event<{ id: string; title: string }>(),
      updated: event<{ id: string; title: string }>(),
      deleted: event<{ id: string }>(),
    },
    saved: event(),
  })
  .groups((events) => ({
    searchInvalidating: group(events.notes),
  }))
  .build();

events.searchInvalidating.$subscribe((event) => {
  console.log("Refresh search because:", event.name);
});

await events.notes.updated({
  id: "note-1",
  title: "Updated title",
});

Install

npm install @kellanjs/eventcraft

Why Use It?

  • Typed payloads: event<T>() controls what can be emitted.
  • Tree-shaped subscriptions: subscribe to events.notes instead of string wildcards like "notes.*".
  • Small runtime API: events are callable functions with $emit, $subscribe, $once, and metadata.
  • Reusable groups: name important event sets once and use them across the app.
  • React-friendly: optional useEvent hook handles component cleanup.
  • No required React dependency: the core package is framework-agnostic.

Define Events

Create a registry with eventcraft().events(...).build().

import { event, eventcraft } from "@kellanjs/eventcraft";

const events = eventcraft()
  .events({
    notes: {
      created: event<{ id: string; title: string }>(),
      updated: event<{ id: string; title: string }>(),
      deleted: event<{ id: string }>(),
    },
    saved: event(),
  })
  .build();

Use event<T>() for payloadful events. Use event() for payloadless events.

Emit Events

Events are callable.

await events.notes.created({
  id: "note-1",
  title: "First note",
});

await events.saved();

You can also use $emit.

await events.notes.deleted.$emit({ id: "note-1" });
await events.saved.$emit();

Subscribe To One Event

Leaf event listeners receive (payload, event).

const unsubscribe = events.notes.updated.$subscribe((payload, event) => {
  console.log(payload.title);
  console.log(event.name); // "notes.updated"
});

unsubscribe();

Every emitted event includes:

type EventEnvelope = {
  id: string;
  name: string;
  payload: unknown;
  timestamp: number;
};

By default, ids come from crypto.randomUUID() and timestamps come from Date.now(). You can override both for tests or custom tracing.

const events = eventcraft({
  createId: () => "event-1",
  now: () => 123,
})
  .events({
    saved: event(),
  })
  .build();

Subscribe To Event Sources

Branches, the root registry, named groups, and selections are all event sources. They receive the full event object.

Branch

events.notes.$subscribe((event) => {
  console.log(event.name); // notes.created | notes.updated | notes.deleted
});

Root

events.$subscribe((event) => {
  console.log("Something happened:", event.name);
});

Named Group

import { group } from "@kellanjs/eventcraft";

const events = eventcraft()
  .events({
    notes: {
      created: event<{ id: string }>(),
      updated: event<{ id: string }>(),
      deleted: event<{ id: string }>(),
    },
    tags: {
      updated: event<{ id: string }>(),
    },
  })
  .groups((events) => ({
    searchInvalidating: group(events.notes, events.tags.updated),
  }))
  .build();

events.searchInvalidating.$subscribe((event) => {
  console.log(event.name);
});

One-Off Selection

events
  .$select([events.notes, events.tags.updated])
  .$subscribe((event) => {
    console.log(event.name);
  });

You can also create a selection without the root registry.

import { select } from "@kellanjs/eventcraft";

select([events.notes.updated, events.tags.updated]).$subscribe((event) => {
  console.log(event.name);
});

Overlapping sources are deduplicated by event node identity. If you select both a branch and one of its child events, the listener runs once for that child event. Separate registries can still contain distinct events with the same $name.

Cleanup

$subscribe returns an unsubscribe function.

const unsubscribe = events.notes.updated.$subscribe(listener);

unsubscribe();

You can bind subscriptions to an AbortSignal.

const controller = new AbortController();

events.notes.updated.$subscribe(listener, {
  signal: controller.signal,
});

controller.abort();

Use $once when you only want the next matching event.

events.saved.$once((payload, event) => {
  console.log(event.id);
});

events.notes.$once((event) => {
  console.log(event.name);
});

React

The React hook is available from the React subpath.

import { useEvent } from "@kellanjs/eventcraft/react";

type AppEvents = typeof events;

function NoteListener({ events }: { events: AppEvents }) {
  useEvent(events.notes.updated, (payload) => {
    console.log("Note updated:", payload.id);
  });

  return null;
}

useEvent accepts the same event sources as the core API.

useEvent(events.notes, (event) => {
  console.log(event.name);
});

useEvent(events.searchInvalidating, (event) => {
  console.log(event.name);
});

useEvent(events.$select([events.notes, events.tags.updated]), (event) => {
  console.log(event.name);
});

useEvent(events, (event) => {
  console.log(event.name);
});

useEvent([events.notes.created, events.tags.updated], (event) => {
  console.log(event.name);
});

The hook unsubscribes on unmount. If the source changes, it cleans up the old subscription before subscribing to the new source. The listener can change every render without resubscribing, and inline arrays are compared by member identity.

Async Iteration

Every event source is an async iterable.

for await (const event of events.notes) {
  console.log(event.name);
}

Use break, return, or throw to stop the iterator and clean up its internal subscription.

Error Handling

Listeners can be synchronous or asynchronous. Eventcraft runs every listener for an emit and waits for all of them to settle. One failing listener does not stop the others.

events.notes.created.$subscribe(async (payload) => {
  await saveToDatabase(payload);
});

After listeners settle:

  • If one handler failed, the emit promise rejects with that error.
  • If multiple handlers failed, the emit promise rejects with an AggregateError.

Use onEmit and onListenerError for observability.

const events = eventcraft({
  onEmit(event) {
    console.log("Emitted:", event.name);
  },
  onListenerError(error, event) {
    console.error("Listener failed:", event.name, error);
  },
})
  .events({
    saved: event(),
  })
  .build();

If onEmit throws or rejects, Eventcraft still delivers the event to listeners, then rejects the emit promise after listener delivery settles. onListenerError runs for listener failures and preserves the normal emit rejection behavior.

Naming Rules

Eventcraft uses $ properties internally, such as $emit, $subscribe, $once, $name, $members, and $select.

Event and group keys cannot start with $ or include .. Dots are reserved for nested event names like notes.updated.

eventcraft()
  .events({
    $saved: event(),
  })
  .build();

That throws at build() time.

Groups also cannot shadow existing events or branches.

eventcraft()
  .events({
    notes: {
      updated: event<{ id: string }>(),
    },
  })
  .groups((events) => ({
    notes: group(events.notes.updated),
  }))
  .build();

That throws because notes is already a branch.

Public API

Core:

import { event, eventcraft, group, select } from "@kellanjs/eventcraft";

React:

import { useEvent } from "@kellanjs/eventcraft/react";

Types:

import type {
  AnyEventSource,
  BranchNode,
  EventDef,
  EventEnvelope,
  EventListener,
  EventNode,
  EventcraftOptions,
  EventRegistry,
  GroupNode,
  RootNode,
  SubscribeOptions,
} from "@kellanjs/eventcraft";

Thanks

If you made it this far, thanks for checking out the library, and I hope you find it useful in your projects!

License

Keycraft is open source under the terms of the MIT license.