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

@machine202502/di

v1.0.0

Published

Type-driven dependency injection: abstract containers, replaceable implementations and disposable resources

Readme

@machine202502/di

Dependency injection driven by types. Containers declare what they need, the library builds them in dependency order, hands every factory its dependencies already built, and releases everything in reverse when you are done.

No decorators, no reflection, no global registry, no string keys: a container is a value you import, and TypeScript infers the data it produces.

Install

npm install @machine202502/di

ESM only, Node 20 or newer.

Public imports:

import {
  abstract,
  container,
  resolve,
  OnDispose,
  OnEvent,
} from "@machine202502/di";
import { openScope, scoped, value } from "@machine202502/di/scope";

OnDispose is the release protocol key; OnEvent is the broadcast key for send. Everything else from the package root or ./scope is internal and not part of the supported surface.

The typings mention the standard Symbol.asyncDispose, so esnext.disposable in lib is needed to write await using — and also if you compile with skipLibCheck: false, which is not the default tsc --init gives you. Plain dispose() asks for nothing extra.

Quick start

import { container, resolve, OnDispose } from "@machine202502/di";

const config = container({
  name: "config",
  dependencies: {},
  factory: () => ({ url: "postgres://localhost/shop" }),
});

const database = container({
  name: "database",
  dependencies: { config },
  factory: async ({ config }) => ({
    query: (sql: string) => [sql],
    [OnDispose]: () => close(config.url),
  }),
});

const resolved = await resolve(database);

resolved.data.query("select 1");

await resolved.dispose();

API

container({ dependencies, factory, name? })

Declares a concrete container and returns its description. dependencies is a record of other containers; factory receives their data under the same keys and returns the data of this container, synchronously or as a promise. name is used in error messages only.

The returned value is the identity of the container: two calls with identical arguments are two different containers.

abstract<Data>(name?)

Declares a container that has a data type but no factory. Nothing resolves until an implementation is supplied, which lets a graph depend on a contract and pick the implementation later.

abstractContainer.implement(container)

Binds a concrete container to an abstract one and returns an implementation.

description.replace(container)

Same thing for a concrete container: whoever asks for description gets container instead. Useful for swapping a real dependency for a fake one without touching the graph.

An implementation may itself be replaced; chains are collapsed before the build, so a.replace(b) together with b.replace(c) resolves a to c.

resolve(container, { implementations?, disposeTimeout? })

Builds the graph and returns a ResolvedContainer. Implementations can be passed here, or used inline as a dependency; a passed one wins over an inline one.

disposeTimeout is how many milliseconds one release may take before this resolve stops waiting for it. Unset means waiting forever, which is what a stuck handler does to a shutdown, so a server should set it. Giving up only stops the waiting: the handler keeps running, the container is reported as failed with di:dispose:to, and everything underneath it is released as usual.

Every resolve is independent: it builds its own instances and owns them.

ResolvedContainer

  • data — the data of the container you resolved.
  • getNode(container) — the data of any container in this resolve, addressed by the container itself. Ask for a concrete container, not an abstract one.
  • send(name, ...args) — broadcasts to every owned node that carries OnEvent. Application-defined steps that must not follow dispose order (stop listening before scopes drain, and so on). Borrowed parent nodes are not notified.
  • dispose() — waits for nested scopes opened against this resolve (refusing new openScope meanwhile), then releases every owned container. Idempotent. Nodes borrowed by a scope from a parent resolve are not released by the scope.
  • [Symbol.asyncDispose]() — the same thing under the language protocol, so a resolve can be scoped:
await using resolved = await resolve(server);

OnDispose

A symbol a factory can put on its data to say what to release:

factory: () => ({
  query,
  [OnDispose]: async () => pool.end(),
});

The handler is awaited, and whatever it returns is ignored. Data without this key is left alone, and the Releasable interface describes data with it if you need to type that explicitly.

The standard Symbol.asyncDispose and Symbol.dispose are honoured as well, so anything that already follows the language protocol is released without a wrapper. When data carries several of these keys, OnDispose wins, then the async standard one, then the sync one. That also applies to data passed through value(...): if the value itself is disposable (a Node stream or IncomingMessage, for example), disposing the resolve will dispose it.

OnEvent

A symbol a factory can put on its data to react to send:

factory: ({ socket }) => ({
  url,
  [OnEvent]: (name) => {
    if (name === "stop-listening") {
      return closeListen(socket);
    }
  },
  [OnDispose]: () => pool.end(),
});

Names and arguments are defined by the application. Handlers of every owned node run at once; failures are reported together as an AggregateError. Use this when shutdown must not be a single reverse-graph walk — for example stop accepting connections first, let request scopes finish, then dispose() the process resolve so the database closes last.

Scopes

Import from @machine202502/di/scope. A scope is a nested resolve: process services live on a parent resolve, request-only nodes are declared with scoped() and built by openScope.

  • scoped({ dependencies, factory, name? }) — like container, but a plain resolve that reaches it fails with di:resolve:sc.
  • value(data, name?) — a container that yields a ready value; used to bind request-time input to an abstract. Pass a plain object, not a Node IncomingMessage / stream: those carry Symbol.asyncDispose, and a failed or finished scope would close the socket while aborting or disposing.
  • openScope(root, { parent, implementations?, disposeTimeout? }) — builds the scoped graph against parent. Nodes already present on parent are borrowed and not owned; only what this call builds is released by its dispose().
import { abstract, container, resolve } from "@machine202502/di";
import { openScope, scoped, value } from "@machine202502/di/scope";

const database = container({
  name: "database",
  dependencies: {},
  factory: () => createDb(),
});

// Bag, not the raw IncomingMessage — see value() above.
const httpRequest = abstract<{ request: Request }>("http-request");

const handler = scoped({
  name: "handler",
  dependencies: { database, httpRequest },
  factory: ({ database, httpRequest }) =>
    makeHandler(database, httpRequest.request),
});

const app = await resolve(database);

await using scope = await openScope(handler, {
  parent: app,
  implementations: [httpRequest.implement(value({ request: currentRequest }))],
});

Shutdown

Nothing is hooked up for you, because when to stop is the application's call. A service usually needs three phases that dispose() alone cannot invent: stop accepting work, finish in-flight scopes, then release process resources.

const resolved = await resolve(server, { disposeTimeout: 10_000 });

for (const signal of ["SIGTERM", "SIGINT"] as const) {
  process.once(signal, async () => {
    try {
      // 1. Application-defined: close the listen socket (OnEvent on the server).
      await resolved.send("stop-listening");
      // 2–3. Wait for open scopes, then release the graph (database last).
      await resolved.dispose();
    } catch (error: unknown) {
      console.error(error);
    }
    process.exit(0);
  });
}

dispose() refuses new openScope calls while it waits for nested scopes that are already open (disposeTimeout bounds that wait as well as each release). Once dispose has started, openScope stays refused even if waiting for scopes timed out (di:dispose:ct) — retry dispose() after the scopes finish, do not open new ones. Stopping the HTTP listener so new requests never arrive is still your job — typically via send, not via graph order.

Behaviour

  • One instance per resolve. A container reached through several paths is built once, and everyone gets the same data.
  • Build order. A factory runs only once all of its dependencies are built, so it always sees complete data. Independent branches of the graph are built at the same time, so startup costs the longest path rather than the sum.
  • Release order. dispose() goes the other way: nobody is released before those who depend on it, and independent branches again go at once. Every container is released even if another handler threw, and the failures are reported together as an AggregateError. Nested scopes are waited for before any owned node is released; new scopes are refused once dispose has started.
  • Dependencies are frozen when a container is created, so the graph cannot be rewritten afterward.
  • Failed builds do not leak. If a factory throws, resolve waits for the factories still in flight, releases everything that got built, and only then rejects — the caller never receives a handle it could clean up itself. If several factories failed, they are reported together.
  • Unused implementations are an error, not a silent no-op: passing an implementation the graph never asks for is a wiring mistake.
  • Two copies of this package coexist. Containers are recognized by a globally registered key rather than by instanceof, so a container built by one copy works in another.
  • Scoped containers stay out of process resolves. A description created by scoped() fails under plain resolve (di:resolve:sc); openScope builds it and borrows matching nodes from its parent without taking ownership. A plain container() missing from the parent fails with di:resolve:np instead of silently building a second copy.

Errors

Every error carries a stable code, so messages can change without breaking your handling.

| Code | Meaning | | ---------------------- | ------------------------------------------------------------------- | | di:container:if | factory is not a function | | di:container:id | dependencies is not an object | | di:container:ic | a dependency is not a container | | di:implementation:ic | an implementation is not built by container() | | di:resolve:ic | a value passed where a container was expected | | di:resolve:iae | two implementations for the same container | | di:resolve:icc | implementations replace each other in a cycle | | di:resolve:ccnf | an abstract container has no implementation | | di:resolve:dc | dependency cycle, with the path in the message | | di:resolve:ui | a passed implementation is never used | | di:resolve:nnf | getNode asked for a container outside this resolve | | di:resolve:ad | a resolved container is used after dispose() | | di:resolve:sd | openScope after the parent has started disposing | | di:resolve:sc | a scoped container was reached by a plain resolve | | di:resolve:np | a nested resolve needs a process container the parent does not have | | di:resolve:mf | several factories failed in one build | | di:resolve:rf | a build failed and releasing what it built failed too | | di:dispose:df | one or more containers failed to release | | di:dispose:to | a release did not finish within disposeTimeout | | di:dispose:ct | nested scopes did not finish within disposeTimeout | | di:event:ef | one or more OnEvent handlers failed during send |

Examples

examples/main.ts is a working application: an Express API over SQLite. Resolving it opens the database, applies the schema, wires the routes and starts listening; one dispose() closes the socket and then the database. The second half resolves the same graph with a frozen clock and no HTTP layer at all, which is what a test would do.

examples/scope.ts is the same stack with per-request scopes: process services stay open, each HTTP hit opens a scope (user, connection, transaction, notes), and handlers call notes.list() / notes.add(text) with no user id threaded through. Parallel alice/bob requests and a rolled-back failure are exercised over the live server.

npm run example:main
npm run example:scope

License

0BSD