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.
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
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 aPromise<void>thatstop()awaits. Returning anything else is a type error. Discard a return value with braces:onDispose(() => { server.close() }). onDisposeis callable only while its own factory is running, and only beforestop(). 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, orCannot register cleanup after the app has stoppedif 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 anAggregateError. - 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