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

@cyrenex/pondi

v0.2.0

Published

Type-safe, string-keyed dependency injection with async factories and scoped disposal.

Readme

[!NOTE] No decorators, no reflect-metadata, no runtime dependencies. Just keys, factories, and types.

Most DI containers in TypeScript lean on decorators and emitted metadata, and you pay for that with build configuration and a class-only style. pondi goes the other way: a dependency is a string key with a type attached. You declare what you need with a pebble, describe how to build it with a ripple, and let a Pond wire everything together.

Think of it as dropping a pebble into a pond: the pebble names what you need, the ripple is what it becomes.

What's Special

  • Inferred types everywhere: factory parameters are inferred from the dependency list, and wrong orders or shapes fail at compile time.
  • Async all the way: async factories, async init hooks, and concurrent resolves that run each factory only once.
  • Hierarchical ponds: fork() a child to override providers for tests, requests, or tenants; only the affected singletons are rebuilt.
  • Deterministic disposal: works with await using; children first, then instances in reverse creation order.
  • Helpful errors: every PondError carries a code and the full dependency path.

Installation

pnpm add @cyrenex/pondi

Quick Start

import { pebble, Pond, ripple } from "@cyrenex/pondi";

interface AgentOptions {
  url: string;
}

// A contract: a type, no implementation
const AgentConfig = pebble<AgentOptions>("agent:config");

// A plugin: provides an implementation for the contract
const agentConfigPlugin = (options: AgentOptions) => ripple(AgentConfig, () => options);

// A token with a default implementation
const Message = ripple("message", () => "Hello, Pond!");

// Declare dependencies; factory parameter types are inferred
const Agent = ripple("agent", [Message, AgentConfig], async (message, config) => {
  const connection = await connect(config.url);
  return {
    send: () => connection.send(message),
    async [Symbol.asyncDispose]() {
      await connection.close();
    },
  };
});

await using app = new Pond().use(agentConfigPlugin({ url: "ws://localhost" }));
const agent = await app.resolve(Agent); // { send: () => void }

Core Concepts

| Concept | Description | | ----------- | ------------------------------------------------------------------------------ | | pebble<T> | A contract: a typed string key with no implementation | | ripple | An implementation: key + dependencies + factory + mode; also usable as a token | | Pond | The container: registers implementations, resolves instances, owns lifecycles |

The key is the identity. ripple("message", ...), pebble("message"), and the string "message" all refer to the same token, so app.resolve(Message) and app.resolve("message") return the same instance. Namespace your keys to avoid collisions, e.g. "agent:config".

Declaring Ripples

ripple(key, factory, options?)
ripple(key, deps, factory, options?)
ripple(pebble, factory, options?)        // implements a pebble; the return type must match the contract
ripple(pebble, deps, factory, options?)
  • deps may mix pebbles, ripples, and strings. The factory receives the resolved values in order.
  • Factories may be async. The container awaits them, so dependents always receive values, never promises.
  • options.mode defaults to "singleton". See Lifecycle Modes.
  • ripple.scoped(...) and ripple.transient(...) are shorthands for ripple(..., { mode: "scoped" }) and ripple(..., { mode: "transient" }). They take the same arguments as ripple, minus options. Class variants are ripple.scoped.class(...) and ripple.transient.class(...).

ripple.class

Same signatures as ripple, but takes a class instead of a factory. Dependencies are passed to the constructor in order:

class Agent {
  constructor(
    readonly message: string,
    readonly config: AgentOptions,
  ) {}
}

const AgentRipple = ripple.class("agent", [Message, AgentConfig], Agent);
const Logger = ripple.class(LoggerPebble, ConsoleLogger);

Constructor parameters are type-checked against the dependency list at compile time, so listing dependencies in the wrong order is an error.

Resolving and Types

await app.resolve(Agent); // typed by the ripple
await app.resolve(AgentConfig); // typed by the pebble
await app.resolve("message"); // typed by Registry, or unknown if not declared

To give plain string keys a type, augment Registry. Augmented keys get autocompletion, and ripple checks its return type against the declaration:

declare module "@cyrenex/pondi" {
  interface Registry {
    message: string;
    "agent:config": AgentOptions;
  }
}

Resolution Rules

To resolve a token, the container looks for an implementation in this order:

  1. Implementations registered with use in the current container or any ancestor; the nearest one wins.
  2. If the token itself is a ripple, its own default implementation.
  3. Otherwise it throws MISSING_PROVIDER, with the full dependency path, e.g. agent -> agent:config.

So a ripple can be resolved without calling use first. Pebbles and plain strings need some container to provide them.

Registering and Overriding

use

app.use(Message, agentConfigPlugin(opts), [PluginA, PluginB]); // arrays are accepted, so plugins can return several ripples
  • Registering the same ripple object again has no effect.
  • Registering a different implementation for an existing key throws DUPLICATE_PROVIDER.
  • Once a key has been resolved, it can no longer be registered in that container or its ancestors (PROVIDER_LOCKED). This prevents old and new instances from coexisting.

fork

There is one way to override an implementation: create a child container and register the same key in it.

await using test = app.fork().use(ripple("message", () => "mock"));

await test.resolve(Agent); // uses the mocked message
await app.resolve(Agent); // the parent is unaffected

In the child, singletons affected by an override are rebuilt, while unaffected singletons stay shared with the parent. Precisely: a singleton is cached in the deepest container that provides it or any of its transitive dependencies.

Use cases: test mocks, request-scoped context, multi-tenancy, replacing part of a plugin.

Lifecycle Modes

| mode | Where instances are cached | Disposed by | | --------------------- | ------------------------------------------ | ------------------------ | | singleton (default) | The container chosen by the rule above | The container caching it | | scoped | The resolving container, one per container | That container | | transient | Not cached; a new instance per resolve | The resolving container |

  • A singleton that depends on a scoped provider, directly or transitively, throws CAPTIVE_DEPENDENCY.
  • Concurrent resolves of the same singleton run the factory only once.
  • Failed factories or initializations are not cached; the next resolve retries.

Async Initialization and Disposal

import { asyncDispose, dispose, init } from "@cyrenex/pondi";

class Db {
  #conn!: Connection;
  constructor(readonly config: DbConfig) {}

  async [init]() {
    this.#conn = await connect(this.config.url);
  }

  async [asyncDispose]() {
    await this.#conn.close();
  }
}
  • init: after creating an instance, the container awaits value[init]() before handing it to dependents or caching it. Use it for async setup a constructor can't do. If init throws, the container disposes the instance and rethrows the original error.
  • asyncDispose / dispose: the standard Symbol.asyncDispose / Symbol.dispose, falling back to Symbol.for(...) on runtimes that lack them. They work with await using / using.
  • Both apply to objects returned by ripple factories and to instances created by ripple.class.

Dispose a container with await using or await app.dispose(). Disposal order:

  1. Child containers first.
  2. Then instances owned by this container, in reverse creation order, so dependents are disposed before their dependencies.
  3. Multiple disposal errors are combined into one AggregateError.

After disposal, resolve, use, and fork throw DISPOSED.

Errors

All container errors are PondError instances with a code and a dependency path:

| code | Thrown when | | --------------------- | ------------------------------------------------------------------- | | MISSING_PROVIDER | No implementation exists for a key | | DUPLICATE_PROVIDER | A different implementation is registered for a key in one container | | PROVIDER_LOCKED | Registering an implementation for a key that was already resolved | | CIRCULAR_DEPENDENCY | Dependencies form a cycle | | CAPTIVE_DEPENDENCY | A singleton depends on a scoped provider, directly or transitively | | DISPOSED | The container has been disposed |

try {
  await app.resolve(Agent);
} catch (error) {
  if (error instanceof PondError && error.code === "MISSING_PROVIDER") {
    console.error(error.path); // ["agent", "agent:config"]
  }
}

Development

vp install # install dependencies
vp test    # run tests
vp check   # format, lint, and type check
vp pack    # build
vp run changeset # describe a change for the next release

License

MIT

Special Thanks

Thanks to all contributors for their contributions to pondi ❤️

Star History