dn-ioc

A provider is a factory that creates a value. There is no container.

Plain-text copy: llms.txt.

Install

Pick one package manager. The package is ESM and has no dependencies.

npm install dn-ioc
pnpm add dn-ioc
yarn add dn-ioc
bun add dn-ioc
deno add npm:dn-ioc

Deno can skip the install:

import { bootstrapApp } from 'npm:dn-ioc'

Browser, no bundler

A page can import the package from a CDN.

<!doctype html>
<html lang="en">
  <body>
    <script type="module">
      import { bootstrapApp, provide, provideFor, token } from 'https://cdn.jsdelivr.net/npm/dn-ioc'

      const greetingToken = token('Greeting')

      const greeterRef = provide(({ inject }) => {
        const greeting = inject(greetingToken)
        return { greet: name => `${greeting}, ${name}` }
      })

      const app = bootstrapApp(
        ({ inject }) => {
          document.body.textContent = inject(greeterRef).greet('world') // hello, world
        },
        { providers: [provideFor(greetingToken, () => 'hello')] },
      )

      await app.start()
    </script>
  </body>
</html>
CDN URL Serves
jsDelivr https://cdn.jsdelivr.net/npm/dn-ioc index.min.js
unpkg https://unpkg.com/dn-ioc index.min.js — same file
esm.sh https://esm.sh/dn-ioc esm.sh's own transpiled build

An import map keeps the specifier bare, so the same source runs with or without a bundler:

<script type="importmap">
  { "imports": { "dn-ioc": "https://cdn.jsdelivr.net/npm/dn-ioc" } }
</script>
<script type="module">
  import { bootstrapApp } from 'dn-ioc'
</script>

The package is ESM-only. Use import. TypeScript consumers should set moduleResolution: "bundler". NodeNext and Node16 declaration resolution are not supported.

The kernel uses no platform APIs — no process, no DOM, no timers — so it runs unchanged on Node.js, Bun, Deno, browsers, workers, and edge runtimes. Only shutdown differs. See stop().

start() runs the root factory once

bootstrapApp() builds the graph and returns a handle. It runs nothing. app.start() runs the root factory and rejects if it fails. The root factory returns void: it wires the graph and does the work. It is not an expression that produces a value.

import { bootstrapApp, provide } from 'dn-ioc'

const configRef = provide(() => ({ greeting: 'hello' }))
const greeterRef = provide(({ inject }) => {
  const config = inject(configRef)
  return { greet: (name: string) => `${config.greeting}, ${name}` }
})

const app = bootstrapApp(({ inject }) => {
  console.log(inject(greeterRef).greet('world')) // hello, world
})

await app.start()

This graph holds no resources, so it never needs stop().

For an async provider, await inject() inside the root factory so startup includes it:

import { bootstrapApp, provide } from 'dn-ioc'

const settingsRef = provide(async () => ({ greeting: 'hello' }))
const app = bootstrapApp(async ({ inject }) => {
  const settings = await inject(settingsRef)
  console.log(`${settings.greeting}, world`) // hello, world
})

await app.start()

Register shutdown handling before start(). bootstrapApp() runs no provider, so the handle exists before any factory does. start() is idempotent: repeated calls return the same Promise and run the root factory once.

token() makes a key with no default

A Token<T> must be bound with provideFor() before anything injects it. The description is the name in the error. A ref with no description is reported as <anonymous>.

import { bootstrapApp, provide, provideFor, token } from 'dn-ioc'

const prefixToken = token<string>('Prefix')
const formatterRef = provide(({ inject }) => {
  const prefix = inject(prefixToken)
  return { format: (value: string) => `${prefix}${value}` }
})

const app = bootstrapApp(
  ({ inject }) => {
    console.log(inject(formatterRef).format('demo')) // [app] demo
  },
  { providers: [provideFor(prefixToken, () => '[app] '), formatterRef] },
)

await app.start()
await app.stop()

Leave the token unbound and inject(prefixToken) throws No provider for token: Prefix.

provide() makes a Ref that self-installs

A Ref<T> has a default factory. The first inject() in a scope installs it there and caches the instance. It is not a global singleton. Install a shared ref at bootstrap — pass it in providers, as formatterRef is above — so sibling subtrees reuse the same instance.

import { bootstrapApp, provide } from 'dn-ioc'

let created = 0
const configRef = provide(() => ({ id: ++created }))

const app = bootstrapApp(({ inject }) => {
  const first = inject(configRef)
  const second = inject(configRef)
  console.log(first === second, created) // true 1
})

await app.start()

provide() and provideFor() both accept { providers }. That array is a private subtree, visible to that provider and its descendants. The next section is the rule that subtree does not break.

inject() searches this scope, then parents

A solid trace is a binding resolved in that scope. A dashed trace is a binding that does not rewrite a consumer already installed above it.

resolved here does not rewrite a parent consumer

app scope

labelToken → "app"

private scope of localDemoRef

labelToken → "local"

rendererRef → "local"

localDemoRef → "local"

reads local local

app scope

configRef → "root config"

serviceRef → "root config"

private scope of localServiceRef

configRef → "local config"

serviceRef stays "root config"

reads root config

A private binding is visible to that provider and its descendants. Sibling subtrees stay isolated. A consumer already installed in a parent scope keeps resolving there.

The left frame is a private subtree. The local binding is the one rendererRef actually injects:

import { bootstrapApp, provide, provideFor, token } from 'dn-ioc'

const labelToken = token<string>('Label')
const rendererRef = provide(({ inject }) => ({ label: inject(labelToken) }))
const localDemoRef = provide(
  ({ inject }) => ({
    label: inject(labelToken),
    renderer: inject(rendererRef),
  }),
  { providers: [provideFor(labelToken, () => 'local')] },
)

const app = bootstrapApp(
  ({ inject }) => {
    const demo = inject(localDemoRef)
    console.log(demo.label, demo.renderer.label) // local local
  },
  { providers: [provideFor(labelToken, () => 'app')] },
)

await app.start()
await app.stop()

The right frame is the trap. serviceRef is already installed in the app scope, so a child binding of configRef does not change it:

const configRef = provide(() => 'root config')
const serviceRef = provide(({ inject }) => inject(configRef))
const localServiceRef = provide(({ inject }) => inject(serviceRef), {
  providers: [provideFor(configRef, () => 'local config')],
})

const app = bootstrapApp(({ inject }) => {
  console.log(inject(serviceRef)) // root config
  console.log(inject(localServiceRef)) // root config
})

Rebind the consumer and its dependency together in the child scope when both need to change.

const localServiceRef = provide(({ inject }) => inject(serviceRef), {
  providers: [
    provideFor(configRef, () => 'local config'),
    provideFor(serviceRef, ({ inject }) => inject(configRef)),
  ],
})
// inject(localServiceRef) === 'local config'
// inject(serviceRef) in the parent stays 'root config'

Installation

Snapshotting
Provider arrays and bundles are copied when their owning ref, binding, or bundle is created. Later edits to those arrays do not change that definition.
Order
Provider inputs are flattened in source order, nested arrays and bundles included.
Last-write-wins
If the same key is installed twice in one scope, the later binding silently replaces the earlier one, including its nested providers.

A bundle is an ordinary provider input. Use it to ship a token and the factory that reads it as one value:

import { bootstrapApp, bundleProviders, provide, provideFor, token } from 'dn-ioc'

const themeToken = token<{ palette: string }>('Theme')
const optionsToken = token<{ palette: string }>('ThemeOptions')

function provideDemoTheme(options: { palette: string }) {
  return bundleProviders(
    provideFor(optionsToken, () => options),
    provideFor(themeToken, ({ inject }) => ({ palette: inject(optionsToken).palette })),
  )
}

const previewRef = provide(({ inject }) => inject(themeToken))
const app = bootstrapApp(
  ({ inject }) => {
    console.log(inject(previewRef).palette) // ocean
  },
  { providers: [provideDemoTheme({ palette: 'ocean' })] },
)

await app.start()
await app.stop()

stop() closes injection, then runs cleanup newest-first

A successful start() never stops the app. The caller decides when to call stop(). Not every app needs it.

  • Call it for a long-running process holding sockets, timers, or file handles.
  • Call it in tests, one app per test, so timers and connections do not leak between cases.
  • Call it for a sub-app, widget, or route-scoped graph that unmounts while the page lives on.

A short-lived script, or a page-wide graph that ends when the tab closes, does not need it. The runtime reclaims everything. The quick start above is in that category.

stop() synchronously closes inject() across the whole app, then runs registered hooks newest-first. Later injection throws Cannot inject after the app has stopped, including cached instances and private subtrees. Capture a dependency before registering the hook:

import { bootstrapApp, provide } from 'dn-ioc'

const storeRef = provide(() => ({ closed: false }))
const serviceRef = provide(({ inject, onDispose }) => {
  const store = inject(storeRef)
  onDispose(async () => {
    store.closed = true
  })
  return { read: () => inject(storeRef).closed }
})

let service!: { read: () => boolean }
const app = bootstrapApp(({ inject }) => {
  service = inject(serviceRef)
})

await app.start()
service.read() // false — deferred injection is allowed while the app is running
await app.stop()
service.read() // throws Cannot inject after the app has stopped

Hook rules

  • A hook may be sync or async. It must resolve to nothing: void, or a Promise<void> that stop() awaits. Returning anything else is a type error. Discard a return value with braces: onDispose(() => { server.close() }).
  • onDispose is callable only while its own factory is running, and only before stop(). A sync factory closes that window when it returns or throws. An async factory closes it when its result settles.
  • Registering later throws onDispose can only be called while the factory is running, or Cannot register cleanup after the app has stopped if the app is already stopping.
  • Concurrent and repeated stop() calls return the same Promise and run each hook once. If a hook fails, the remaining hooks still run. One failure is rethrown unchanged. Several produce an AggregateError.
  • A hook must not await app.stop(). That is its own completion and would deadlock.

If start() fails, it stops the app, then rejects. A lone failure is rethrown unchanged. A startup failure plus cleanup failures produce an AggregateError. A successful start() never stops the app, and neither do runtime failures afterwards.

stop() does not wait for unfinished factories, cancel their I/O, or revoke objects you already hold. A detached factory that started before shutdown keeps running and reports through the Promise inject() returned. Request admission, task cancellation, and shutdown ordering stay with the application.

Wire stop() to the runtime

The kernel installs no listeners. Shutdown signals have no common subset across runtimes.

Runtime Trigger Async cleanup
Node.js, Bun process.on('SIGINT' | 'SIGTERM', handler) Awaited, if the handler keeps the process alive
Deno Deno.addSignalListener('SIGINT', handler) Awaited
Browser page addEventListener('pagehide', handler) Not reliable
Browser sub-app Your own unmount call Awaited
Web Worker A message from the host before worker.terminate() Only if the host waits before terminating
Serverless, edge End of the request or invocation you scoped the app to Depends on the platform's keep-alive API

A server on Node or Bun. The listener is registered before start(), because bootstrapApp() runs no provider yet:

import { createServer } from 'node:http'
import { bootstrapApp, provide } from 'dn-ioc'

const serverRef = provide(({ onDispose }) => {
  const server = createServer().listen(3000)
  onDispose(() => new Promise<void>(resolve => server.close(() => resolve())))
  return server
})

const app = bootstrapApp(({ inject }) => {
  inject(serverRef)
})

for (const signal of ['SIGINT', 'SIGTERM'] as const) {
  process.on(signal, () => void app.stop())
}

try {
  await app.start()
} catch (error) {
  console.error(error)
  await app.stop()
}

The same graph on Deno only changes the listener: Deno.addSignalListener('SIGINT', () => void app.stop()).

In a browser, scope the app to something you unmount yourself, so cleanup runs while the page is still alive. Call stop() from a React effect cleanup, Vue's onScopeDispose, a custom element's disconnectedCallback, or a router leave hook.

import { bootstrapApp, provide } from 'dn-ioc'

const socketRef = provide(({ onDispose }) => {
  const socket = new WebSocket('wss://example.com')
  onDispose(() => socket.close())
  return socket
})

const app = bootstrapApp(({ inject }) => {
  inject(socketRef)
})
await app.start()

async function unmount() {
  await app.stop()
}

pagehide is the least-bad unload event, and the browser may still discard the page before an async hook resolves. beforeunload is worse: the back/forward cache can skip it. If a resource must be released on unload, keep that hook synchronous.

const sessionRef = provide(({ onDispose }) => {
  const id = crypto.randomUUID()
  onDispose(() => {
    navigator.sendBeacon('/session/end', id)
  })
  return id
})

const app = bootstrapApp(({ inject }) => {
  inject(sessionRef)
})
await app.start()

addEventListener('pagehide', () => void app.stop())

await using

App implements AsyncDisposable, so a scoped app can drop the explicit stop(). This needs TypeScript 5.2+, or a runtime that supports the syntax. When TypeScript downlevels it, the lookup falls back to Symbol.for('Symbol.asyncDispose'). dn-ioc implements both keys.

{
  await using app = bootstrapApp(({ inject }) => {
    inject(serverRef)
  })
  await app.start()
} // app.stop() runs when this scope exits, including on throw

Async factories share one Promise

inject() returns a synchronous value as a value, and a shared Promise for an async provider. Repeated injection in the same resolution scope reuses that Promise. A rejected provider is removed from the cache, so a later injection can retry while the app is running.

start() follows the root factory and the Promises it actually awaits or returns. Await what startup requires before returning. For work started without awaiting it, keep the Promise somewhere the caller can still see it. That Promise reports its own provider. It does not wait for unrelated providers, even after stop() has settled. If the factory never finishes, its Promise never reports an outcome, and stop() can still finish.

import { bootstrapApp, provide } from 'dn-ioc'

const backgroundRef = provide(async () => 'done')

let background!: Promise<string>
const app = bootstrapApp(({ inject }) => {
  background = inject(backgroundRef)
})

await app.start()
console.log(await background) // done
await app.stop()

For an async token, put the Promise in the token type:

import { bootstrapApp, provideFor, token } from 'dn-ioc'

const settingsToken = token<Promise<{ ready: boolean }>>('Settings')
const app = bootstrapApp(
  async ({ inject }) => {
    const settings = await inject(settingsToken)
    console.log(settings.ready) // true
  },
  { providers: [provideFor(settingsToken, async () => ({ ready: true }))] },
)

await app.start()
await app.stop()

Errors are exact strings

Match these messages. Do not paraphrase them. Nested provider inputs are validated when their subtree is first resolved. Invalid nested input throws from that inject() call; if the root does not handle it, start() rejects.

Situation Result
Unbound Token No provider for token: Name
Construction-time or concurrent async cycle Circular dependency detected: A -> B -> A
Invalid inject key Invalid inject key received
Invalid root provider input Invalid provider input received, thrown by bootstrapApp() before an App exists
inject after stop() Cannot inject after the app has stopped, before cache lookup or key validation
onDispose after factory completion onDispose can only be called while the factory is running
onDispose after stop() Cannot register cleanup after the app has stopped
start() on a stopped app Cannot start an app that has been stopped
Synchronous provider failure inject() throws; the caller may catch it
Async provider failure Its inject() Promise rejects; the caller may catch it
Root factory failure start() rejects after registered cleanup runs
Root factory returns a value Type error: the root factory must return void
Cleanup failure Rejects stop(), and start() too when it triggered the stop

Public API

Signatures below omit the opaque brands on handles. Create handles with the exported functions. isProvideRef is true only for a Ref from provide(), not for a token, a binding, a bundle, or a copy.

Handles returned by token, provide, provideFor, and bundleProviders are frozen opaque objects. A copied object is rejected. This is an in-process kernel for trusted application code, not a JavaScript sandbox.

interface Token<T> {}
interface Ref<T> extends Token<T> {}
interface ProviderDef<T> {}
interface ProviderBundle {}

type RefType<T> = T extends Ref<infer U> ? U : never
type InjectKey<T> = Token<T> | Ref<T>
type ProviderInput = Ref<unknown> | ProviderDef<unknown> | ProviderBundle | readonly ProviderInput[]
type ProviderOptions = { providers?: readonly ProviderInput[] }
type InjectFn = <T>(key: InjectKey<T>) => T
type OnDisposeFn = (fn: () => void | Promise<void>) => void

interface Context {
  inject: InjectFn
  onDispose: OnDisposeFn
}

type Factory<T> = (ctx: Context) => T
type BootstrapAppFn = (ctx: Context) => void | Promise<void>
interface BootstrapAppOptions { providers?: readonly ProviderInput[] }

interface App extends AsyncDisposable {
  start(): Promise<void>                 // idempotent; rejects once the app is stopped
  stop(): Promise<void>                  // idempotent
  [Symbol.asyncDispose](): Promise<void> // same operation as stop()
}

function token<T>(description?: string): Token<T>
function provide<T>(factory: Factory<T>, options?: ProviderOptions): Ref<T>
function provideFor<T>(key: InjectKey<T>, factory: Factory<T>, options?: ProviderOptions): ProviderDef<T>
function bundleProviders(...inputs: ProviderInput[]): ProviderBundle
function isProvideRef(value: unknown): value is Ref<unknown>
function bootstrapApp(fn: BootstrapAppFn, options?: BootstrapAppOptions): App