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

@circulo-ai/di

v3.1.0

Published

A lightweight dependency injection toolkit with singleton, scoped, global-singleton, and transient lifetimes plus Hono helpers. No decorators, no reflect metadata—just factories and tokens (sync or async).

Readme

@circulo-ai/di

A lightweight dependency injection toolkit with singleton, scoped, global-singleton, and transient lifetimes plus Hono helpers. No decorators, no reflect metadata—just factories and tokens (sync or async).

What's Inside

  • ServiceCollection: Register services with lifetimes (Singleton, GlobalSingleton, Scoped, Transient), defaults (allowOverwrite/defaultMultiple), metadata (registeredAt/source), and dispose priorities.
  • Binding DSL: services.bind(Token).toValue/toFunction/toFactory/toClass/toHigherOrderFunction with array/object dependencies and scope aliases for lifetimes.
  • ServiceProvider: Root container with singleton/global caches, async-aware resolution, scopes, disposal hooks, tracing, and withScope.
  • ServiceScope: Per-request/per-operation scoped instances with disposal ordering and async caching.
  • Hono Helpers: bindToHono for one-liner setup; decorateContext for “put it on c.var”; strict/memoized proxies.
  • Service Locator: createServiceLocator for typed, lazily-resolved proxies from nested token trees.
  • Tokens: createToken, optional(token) for optional resolution; keyed/multi registrations; resolveMap for keyed lookups; factory/lazy helpers.
  • Diagnostics: validateGraph, runtime circular detection, structured errors with path/token.
  • Conditional registration: ifProd, ifDev, ifTruthy.

Install

bun add @circulo-ai/di

Quickstart

import { ServiceCollection, ServiceLifetime } from "@circulo-ai/di";

const services = new ServiceCollection();

// Singleton
services.addSingleton("Config", { port: 3000 });

// Scoped (e.g., per request)
services.addScoped("RequestId", () => crypto.randomUUID());

// Transient
services.addTransient("Now", () => () => new Date());

// Multiple/Keyed registrations
services.addSingleton("Cache", () => primaryCache, {
  key: "primary",
  multiple: true,
});
services.addSingleton("Cache", () => secondaryCache, {
  key: "secondary",
  multiple: true,
});

const provider = services.build();
const scope = provider.createScope();

const config = scope.resolve<{ port: number }>("Config");
const requestId = scope.resolve<string>("RequestId");
const primary = scope.resolve("Cache", "primary");
const caches = scope.resolveAll("Cache"); // [secondary, primary] (last wins unless keyed)
const byKey = scope.resolveMap("Cache"); // { primary: primaryCache, secondary: secondaryCache }

// Optional resolution
const maybeMissing = scope.tryResolve("Missing"); // undefined instead of throw
const maybeMissing2 = scope.resolve(optional("Missing")); // undefined

// Async factories
services.addSingleton("AsyncDb", async () => connectDb());
const db = await provider.resolveAsync("AsyncDb");
// provider.resolve("AsyncDb") will throw while the async factory is in-flight

// Binding DSL with array/object deps and scope aliases
services
  .bind("Settings")
  .toHigherOrderFunction(
    (db, logger) => ({ db, logger }),
    ["AsyncDb", TYPES.Logger],
    { scope: "scoped", async: true },
  );
services.bind("Static").toValue("hi");
services.bind(TYPES.Logger).toClass(Logger);

// Factory/lazy helpers
services.addTransient("DbFactory", factory("AsyncDb"));
services.addScoped("LazyConfig", lazy("Config"));

Service locator helper

import {
  ServiceCollection,
  createServiceLocator,
  createToken,
  optional,
} from "@circulo-ai/di";

const TYPES = {
  Config: createToken<{ port: number }>("Config"),
  Db: createToken<{ query: (sql: string) => Promise<unknown> }>("Db"),
} as const;

const services = new ServiceCollection()
  .addSingleton(TYPES.Config, { port: 3000 })
  .addSingleton(TYPES.Db, () => ({ query: async (_sql: string) => [] }));

const provider = services.build();
const scope = provider.createScope();
const locator = createServiceLocator(
  scope,
  {
    config: TYPES.Config,
    db: { primary: TYPES.Db, cache: optional("Cache") },
  },
  { cache: false, strict: true },
);

const config = locator.config;
const db = locator.db.primary;
const maybeCache = locator.db.cache;

Fundamentals & best practices

  • Pick the right lifetime: GlobalSingleton for expensive process-wide things (DB pools); Singleton for app-level caches; Scoped per request/task; Transient for pure, cheap objects. Avoid scoped resolution from the root—always resolve through a scope/middleware.
  • Prefer tokens over strings: createToken<T>("Name") keeps types tight and avoids collision. Use optional(token) for soft dependencies.
  • Binder DSL for ergonomic wiring: bind(Token).toValue|toFactory|toClass|toHigherOrderFunction with array/object deps; use scope for lifetimes and { async: true } when dep factories are async.
  • Keyed multi-bindings: set { multiple: true, key: "primary" } and use resolveMap for clarity; avoid mixing keyed/unkeyed for the same token.
  • Async factories: always resolve with resolveAsync; sync resolve will throw while in flight. Use factory(token) to inject lazy calls and lazy(token) to memoize per scope.
  • Dispose eagerly: wrap work in provider.withScope or withRequestScope (Next) and call provider.dispose() on shutdown. Add disposePriority for ordered teardown.
  • Modules for features: group registrations with createModule().bind(...).to... and services.addModule(module) to keep domains isolated.
  • Environment guards: wrap optional services with ifProd/ifDev/ifTruthy to keep registration clean.
  • Validate and trace: run provider.validateGraph({ throwOnError: true }) locally to catch duplicates/missing tokens; pass trace to ServiceCollection to log resolution paths during debugging.
  • Testing overrides: set allowOverwrite: true in tests, re-register tokens with fakes, or compose a new ServiceCollection per test. Use useExisting to alias mocks without changing consumers.
  • Hot-reload safety: prefer GlobalSingleton or getGlobalProvider in dev servers/Next.js to avoid duplicate pools; keep disposers on value providers for clean reloads.
  • Edge vs Node: on Edge runtimes, avoid globalThis if not needed; prefer scoped lifetimes and per-request factories for lightweight objects.
  • Avoid hidden singletons: keep most services scoped/transient and only elevate to singleton/global when necessary; use trace to spot unintended sharing.
// Lifetime + binder examples
services
  .bind(createToken<Pool>("Db"))
  .toHigherOrderFunction(() => createPool(), [], { scope: "global" });
services
  .bind(createToken<RequestLogger>("Logger"))
  .toFactory((r) => makeRequestLogger(r.resolve("RequestId")), { scope: "scoped" });
services
  .bind(createToken<Feature>("Feature"))
  .toHigherOrderFunction((deps) => new Feature(deps), { config: "Config" });

provider.validateGraph({ throwOnError: true });

Hono Integration

import { bindToHono, createToken, decorateContext } from "@circulo-ai/di";
import { Hono } from "hono";

const TYPES = { RequestId: createToken<string>("requestId") } as const;
const provider = services.build();
const app = new Hono();

bindToHono(app as any, provider, TYPES, { cache: true, strict: true });
app.use("*", decorateContext(TYPES, { targetVar: "svc" }) as any);

app.get("/ping", (c) => {
  return c.json({
    ok: true,
    requestId: (c as any).di.RequestId,
    viaVar: (c.var as any).svc.RequestId,
  });
});

Real-world examples

Next.js App Route (Node/Edge)

// app/api/users/route.ts
import {
  getGlobalProvider,
  withRequestScope,
  ServiceCollection,
} from "@circulo-ai/di";
import { NextRequest } from "next/server";

const TYPES = { Db: "Db", Logger: "Logger" } as const;

// Reuse across hot reloads and edge invocations
const provider = getGlobalProvider(() => {
  const services = new ServiceCollection();
  services
    .bind(TYPES.Db)
    .toHigherOrderFunction(() => createPool(), [], { scope: "global" });
  services
    .bind(TYPES.Logger)
    .toFactory(() => createRequestLogger(), { scope: "scoped" });
  return services.build();
});

export const GET = withRequestScope(
  provider,
  async (_req: NextRequest, ctx) => {
    const db = await ctx.container.resolveAsync(TYPES.Db);
    const logger = ctx.container.resolve(TYPES.Logger);
    const rows = await db.query("select * from users");
    logger.info("users fetched", { count: rows.length });
    return Response.json({ users: rows });
  },
);

Modular feature wiring

// user.module.ts
import { createModule } from "@circulo-ai/di";
export const TYPES = { UserRepo: "UserRepo", GetUser: "GetUser" } as const;
export const userModule = createModule()
  .bind(TYPES.UserRepo)
  .toClass(UserRepository, { db: "Db" })
  .bind(TYPES.GetUser)
  .toHigherOrderFunction(
    (repo) => (id: string) => repo.findById(id),
    [TYPES.UserRepo],
  );

// app container
import { ServiceCollection } from "@circulo-ai/di";
import { userModule, TYPES as USER } from "./user.module";

const services = new ServiceCollection()
  .addGlobalSingleton("Db", () => createPool(), { disposePriority: 10 })
  .addModule(userModule);

const provider = services.build();
const scope = provider.createScope();
await scope.resolveAsync(USER.GetUser)("123");

Background job scope with disposals

import { ServiceCollection } from "@circulo-ai/di";

const TYPES = { Queue: "Queue", JobLogger: "JobLogger" } as const;
const services = new ServiceCollection()
  .addGlobalSingleton(TYPES.Queue, () => connectQueue(), { disposePriority: 5 })
  .bind(TYPES.JobLogger)
  .toFactory(() => createJobLogger(), { scope: "scoped" });

const provider = services.build();

export async function handleJob(payload: any) {
  return provider.withScope(async (scope) => {
    const queue = scope.resolve(TYPES.Queue);
    const log = scope.resolve(TYPES.JobLogger);
    log.info("processing job", payload);
    await queue.ack(payload.id);
  });
}

Lifetimes

  • Singleton: One instance for the app lifetime (per provider).
  • GlobalSingleton: One instance per process (hot-reload safe via globalThis).
  • Scoped: One instance per ServiceScope (commonly per request).
  • Transient: New instance every resolution.

Disposal

If a resolved instance exposes dispose, close, destroy, Symbol.dispose, or Symbol.asyncDispose, scopes and providers will call them when disposed. You can also register manual hooks with scope.onDispose / provider.onDispose, or run work in provider.withScope(fn) to auto-dispose.

  • Scoped instances dispose in reverse resolve order; use disposePriority to override (higher runs first). Singletons honor the same priority and order.
  • Custom disposers on value providers: addSingleton(token, { value, dispose }).

Recipes

  • Connection pool (global)
    addGlobalSingleton(CacheToken, () => createPool(), { disposePriority: 5 })
  • Per-request transaction
    addScoped(TxToken, (r) => startTx(r.resolve(DbToken)), { disposePriority: 10 })
  • Background job scope
    provider.withScope(async (scope) => { const job = scope.resolve(Job); await job.run(); })
  • Testing overrides
    Build a fresh ServiceCollection in tests and register fakes; set allowOverwrite: false in prod to catch duplicate registrations; use useExisting to alias/mirror tokens for mocks.
  • Keyed multi-binding
    resolveMap(Cache) to pick keyed implementations; validateGraph warns about mixed keyed/unkeyed.
  • Async factory pattern
    Use resolveAsync for async factories; sync resolve throws AsyncFactoryError while the promise is in flight.
const scope = provider.createScope();
// ...use services
await scope.dispose(); // cleans up scoped disposables
await provider.dispose(); // cleans up singletons

Developing

bun --cwd packages/di run typecheck
bun --cwd packages/di run build

Publishing

bun -cwd packages/di run release

The release script builds and publishes with --access public. prepack also runs the build automatically if you publish manually.