npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

saga-custom-events

v0.1.0

Published

redux-saga inspired orchestration for CustomEvent

Readme

saga-custom-events

TypeScript orchestration inspired by redux-saga, using native EventTarget and CustomEvent instead of a Redux store.

The library is ESM-only and works in modern browsers and Node.js 18+.

Install

npm install saga-custom-events

Quick start

import createSagaRuntime, {
  call,
  cancel,
  put,
  takeLatest,
  type SagaEvent,
  type SagaIterator,
} from "saga-custom-events";

type LoadUser = { id: string };

function* loadUser(event: SagaEvent<LoadUser>): SagaIterator<void> {
  const response: Response = yield call(fetch, `/api/users/${event.payload.id}`);
  const user: unknown = yield call([response, "json"]);
  yield put("USER_LOADED", user);
}

function* rootSaga(): SagaIterator<void> {
  yield takeLatest("LOAD_USER", loadUser);
}

const runtime = createSagaRuntime();
const rootTask = runtime.run(rootSaga);
const offUserLoaded = runtime.bus.on("USER_LOADED", (event) => {
  console.log(event.detail);
});

runtime.emit("LOAD_USER", { id: "42" });

// Later:
rootTask.cancel();
await rootTask.promise;
offUserLoaded();
runtime.close();

A saga receives an envelope:

interface SagaEvent<T> {
  type: string;
  payload: T;
  event: CustomEvent<T>;
}

Both convenience dispatch and native dispatch are supported:

runtime.emit("SAVE", { id: 1 });
runtime.bus.dispatchEvent(new CustomEvent("SAVE", {
  detail: { id: 2 },
}));

Native EventTarget listeners run synchronously. Delivery to sagas is queued in order so a watcher can re-arm between events in a synchronous burst. The first queued event is delivered in a microtask; later events are separated by task turns so the watcher has time to install its next take.

The saga queue is bounded by default:

const runtime = createSagaRuntime({
  busOptions: {
    capacity: 4096,
    overflow: "throw", // "drop-newest" | "drop-oldest"
  },
});

The defaults are exported as DEFAULT_EVENT_QUEUE_CAPACITY, DEFAULT_CHANNEL_CAPACITY and DEFAULT_ACTION_CHANNEL_CAPACITY. throw is the default and fails the dispatch before native listeners see an event that cannot be queued. The dropping policies keep dispatch non-throwing and expose the number of discarded events through diagnostics. If bus and busOptions are both supplied, the already-created bus takes precedence.

Effects

Import effects either from the package root or from saga-custom-events/effects.

  • Events: take, takeMaybe, put, putResolve, actionChannel, flush
  • Functions: call, apply, cps
  • Tasks: fork, spawn, detach, join, cancel, cancelled
  • Composition: all, race, delay
  • State and context: select, getContext, setContext
  • Watchers: takeEvery, takeLatest, takeLeading, throttle, debounce
  • Resilience: retry

The watcher helpers return fork effects, so standard saga syntax works:

function* rootSaga() {
  yield takeEvery("AUDIT", auditWorker);
  yield takeLatest("SEARCH", searchWorker);
}

Generator, async, Promise-returning and synchronous workers are supported. Async generators are rejected explicitly.

put also accepts a channel:

const jobs = channel<number>();

function* example() {
  yield put(jobs, 10);
  const value: number = yield take(jobs);
}

Because native EventTarget.dispatchEvent is synchronous, putResolve is an API-compatible alias of put for bus events.

Channels and buffers

Channels:

  • channel
  • eventChannel
  • multicastChannel
  • stdChannel

Buffers:

  • buffers.none()
  • buffers.fixed(size)
  • buffers.dropping(size)
  • buffers.sliding(size)
  • buffers.expanding(initialSize)

channel() uses a fixed buffer of 1024 items by default. Overflow throws rather than growing memory without a bound. Pass a buffer explicitly to choose another policy. This intentionally differs from redux-saga's expanding default:

const latestJobs = channel(buffers.sliding(100));
const losslessButUnbounded = channel(buffers.expanding());

buffers.expanding() is intentionally unbounded. Use it only when producers are controlled or when an external backpressure mechanism exists.

Closing a channel wakes pending takers with END. A regular take terminates its saga and executes finally; takeMaybe returns END to the saga. Closing does not discard buffered payloads: they remain available to take/flush until drained, so a retained closed channel can still retain those payloads.

const stream = eventChannel<number>((emit) => {
  const timer = setInterval(() => emit(Date.now()), 1000);
  return () => clearInterval(timer);
});

stream.close(); // subscription cleanup runs exactly once

Cleanup functions returned by channel.take, multicastChannel.take and runtime.bus.on are idempotent and detach their internal references after use. eventChannel.close() also drops its subscriber and emitter references.

actionChannel ownership and overflow

An actionChannel uses buffers.sliding(1024) by default. This bounds memory under a sustained producer burst and keeps the newest events; old buffered events can therefore be dropped. This is an overflow policy, not producer backpressure. For job queues where loss is unacceptable, choose the behavior explicitly:

function* worker() {
  const jobs = yield actionChannel("JOB", buffers.fixed(500));
  // Or opt into the redux-saga-style unbounded behavior:
  const allJobs = yield actionChannel("ALL_JOBS", buffers.expanding());
}

With buffers.fixed, overflow throws while the bus delivers the event; the runtime closes that actionChannel and reports the error through onError. It does not pause the producer.

Each created actionChannel belongs to the task that created it. It remains open while the generator and its finally blocks run, then closes before the task waits for attached children. Manual close() releases ownership immediately. A channel directly returned by a blocking call/raw nested generator is transferred to the waiting parent; forked and detached ownership is not transferred.

For a channel deliberately handed to a detached task, disable task ownership and close the channel manually:

const runtime = createSagaRuntime({
  autoCloseActionChannels: false,
  actionChannelBuffer: () => buffers.sliding(256),
});

actionChannelBuffer is a factory and is called for every channel. A buffer passed directly to actionChannel(pattern, buffer) has priority over it.

Cancellation

Cancellation propagates through attached forks, blocking calls, all, race and joins. Generator finally blocks are fully drained.

function* worker() {
  try {
    yield delay(60_000);
  } finally {
    if (yield cancelled()) {
      yield call(releaseResources);
    }
  }
}

Promises can expose the public CANCEL hook:

import { CANCEL, type CancelablePromise } from "saga-custom-events";

const controller = new AbortController();
const request = fetch("/api/data", {
  signal: controller.signal,
}) as CancelablePromise<Response>;
request[CANCEL] = () => controller.abort();

Tasks

runtime.run, fork, spawn and watcher helpers create tasks with:

  • cancel()
  • isRunning()
  • isCancelled()
  • result()
  • error()
  • setContext(object)
  • toPromise()
  • promise

Cancellation resolves promise/toPromise() and result() with the exported TASK_CANCEL symbol, so their TypeScript result is T | typeof TASK_CANCEL.

spawn creates a detached task. Its failure does not abort the parent and is reported through onError. Failure of an attached fork aborts the parent and its sibling tasks.

Runtime options

const runtime = createSagaRuntime({
  busOptions: {
    capacity: 4096,
    overflow: "throw",
  },
  actionChannelBuffer: () => buffers.sliding(1024),
  autoCloseActionChannels: true,
  context: { api },
  getState: () => applicationState,
  onError: (error, { sagaStack }) => {
    console.error(sagaStack, error);
  },
  sagaMonitor,
});

runtime.setContext({ api: replacementApi });

select is optional and reads from getState; Redux itself is not required. For standalone runSaga, pass an explicit bus when callers need to emit or inspect events; createSagaRuntime is the convenient owner of a newly created bus.

runtime.close() stops saga-side event acceptance, drains already queued events, then sends END to bus takers. It returns immediately. It does not cancel tasks blocked on delays, Promises or custom channels, does not cancel detached spawn tasks, and does not remove native EventTarget listeners. Cancel owned root tasks and run listener cleanup functions as part of application shutdown. Events emitted after close still reach native listeners, but are not queued for sagas.

Diagnostics and memory lifecycle

Diagnostics return plain snapshot records and arrays without live tasks, channels, callbacks or contexts:

const diagnostics = runtime.getDiagnostics();

console.log(diagnostics.activeTasks);
console.log(diagnostics.activeActionChannels);
console.log(diagnostics.ownedActionChannels);
console.log(diagnostics.bus.pendingEvents);
console.log(diagnostics.bus.droppedEvents);
console.log(diagnostics.bus.activeTakers);

tasks contains active tasks only. Task totals and dropped-item counters are cumulative. activeActionChannels includes manually managed channels, while ownedActionChannels includes only task-owned channels. bus.activeTakers counts saga-side bus takers, not native EventTarget listeners.

Built-in buffers and channels return InspectableBuffer and InspectableChannel, whose getDiagnostics() method is required. The method remains optional on the base Buffer and Channel interfaces so custom implementations stay structurally compatible. Buffer capacity: null means the buffer is unbounded.

Cancellation detaches the continuation from unresolved Promise/thenable and CPS callbacks before invoking their cleanup hook. A Promise can still expose CANCEL to stop the underlying operation. Completed tasks release their runtime, parent and context references; task results and errors remain available through the public task API.

The bounded defaults prevent accidental queue growth, but deliberately unbounded resources are still possible: buffers.expanding(), an actionChannel configured with it, an infinite spawn, or native addEventListener calls that are never removed. Those resources must have an application-level lifecycle. Diagnostics expose live counts and queue state; the separate GC regression suite verifies reference detachment.

Development

npm ci
npm run typecheck
npm test
npm run test:memory
npm pack --dry-run

The test suite covers events, channels, buffers, helpers, cancellation, error propagation, task lifecycle, bounded bursts, END, packaging types and concurrent effects. test:memory runs separate WeakRef regressions with --expose-gc for externally retained Promise, CPS and unsubscribe callbacks.