Skip to content
12 min read · 2,452 words

Runtime Loading

You import '@nhtio/adk' the same way you'd import any package. On desktop Chrome it's fine. On an iOS user's phone, their tab dies with "Maximum call stack size exceeded" — a stack overflow — before a single line of your own code has run. This page is why, and what to do about it.

Most consumers import ADK directly and let the bundler wire it in — that's the right default, and if it works for you, stop reading. This page is for the cases where it doesn't: where importing ADK's source into your module graph — the tree of every module your code transitively imports, that your bundler resolves and loads together — is itself the problem, not a step on the way to a working page.

The constraint

Importing @nhtio/adk's root barrel — the . export — eagerly evaluates the core module graph: every primitive class, TurnRunner, DispatchRunner, every transitive dependency core needs, gets constructed and wired up the moment the import statement executes, whether or not you use any of it yet (the opposite would be lazy evaluation — deferred until first use). Batteries are not part of that graph. Each battery ships as its own package subpath export (@nhtio/adk/batteries/llm/litert_lm, and so on, generated from an @module tag on the battery's own entry file) and is only evaluated when that specific subpath is imported — importing the root package pulls in none of them. On most runtimes even the core-only evaluation is simply "the app starts a little slower." On constrained runtimes it can be a hard failure.

The motivating case, in full, is this documentation site itself — the same story from the top of this page. The docs app is a VitePress single-page application; at one point it imported @nhtio/adk source directly, the same way any consumer would. On iOS WebKit, the failure manifested during that eager graph evaluation — the JavaScript call stack overflowed ("Maximum call stack size exceeded") on every single page, before the page had finished hydrating, and moving ADK out of the eagerly-evaluated graph is what fixed it. The same underlying shape shows up anywhere a module needs to reference ADK types and values without eagerly linking to a concrete ADK build at import time:

  • CDN / no-bundler pages — a plain <script type="module"> page with no build step has no bundler to defer the import for you; a bare top-level import runs immediately, full stop.
  • Worker threads — a worker's module graph is separate from the main thread's; you often want the main thread to reference ADK's types without ever constructing the library there, while the actual instance lives in the worker.
  • Plugin systems — a host application wants to inject its own ADK build (a specific version, a specific set of batteries) into a plugin's code without the plugin's own bundle carrying a second, possibly conflicting copy.
  • Version hot-swap — retiring one loaded bundle for another without reloading every module that referenced it.

Before this seam existed, the docs app's fix was to hand-roll it: four separate export let Foo: typeof AdkModule.Foo holder modules, one per consumer, about 590 lines total, each independently drifting from the others as the set of symbols each consumer needed grew. @nhtio/adk/shims is that pattern, extracted once, fixed.

The seam: createAdkShim

createAdkShim(resolver) takes one thing from you: an AdkResolverFn — a zero-argument function that produces your bundle, synchronously or asynchronously. Everything environment-specific — where the bundle lives, how it's fetched, whether it's cached upstream — lives inside that one function. The shim itself doesn't know or care how TBundle gets produced; it owns exactly one job, memoization, and does it the same way regardless of what your resolver does.

ts
import { createAdkShim } from '@nhtio/adk/shims'
import type * as AdkModule from '@nhtio/adk'

const shim = createAdkShim<typeof AdkModule>(() => import('@nhtio/adk'))

That's the entire same-graph passthrough case — useful as a drop-in when a call site doesn't actually need deferral (tests, for instance), while still giving every consumer the identical AdkShim interface regardless of which resolver backs it.

Four ways to read it

  • await shim.resolve() — the async, always-correct path. Concurrent callers during an in-flight resolution share that one in-flight promise rather than each triggering a fresh resolver invocation (single-flight: one resolution in flight at a time, everyone else waits on it instead of starting their own). Already-resolved calls return the cached bundle without touching the resolver at all.
  • shim.get() — the sync path, for code that has already awaited resolve() at least once and now wants to read without paying for another await. Throws E_SHIM_NOT_RESOLVED if nothing is resolved yet — there is no synchronous equivalent of running an async resolver, so this never blocks and never returns a stale value.
  • shim.proxy — a Proxy<TBundle> (TBundle is the type parameter for whatever your resolver produces — typeof import('@nhtio/adk'), say) that delegates every property read through to the resolved bundle once one exists, and throws E_SHIM_NOT_RESOLVED naming the exact property that was touched too early. This directly replaces the hand-rolled export let Foo: typeof AdkModule.Foo holder pattern: one proxy stands in for an entire bundle's worth of individually-declared holders. Reading a method off proxy returns it pre-bound to the resolved bundle, so destructuring — const { Message } = shim.proxy — behaves the same as shim.get ().Message and doesn't lose this.
  • shim.resolved — a live boolean, re-evaluated on every read, for call sites that want to branch without risking a throw.

GC-safe memoization

A resolved bundle is held via a WeakRef — a reference that lets the garbage collector reclaim the object it points to, instead of keeping it alive forever — never strongly retained by the shim itself, which is why TBundle must extend object (only objects are valid WeakRef targets). A shim you hold onto for the lifetime of your app cannot, on its own, keep an entire ADK bundle — and everything that bundle closes over — alive forever. Only the in-flight resolution promise is held strongly, and only for the duration of that one flight.

If the bundle is collected — or, for the dynamic-import resolver, was never actually going to be collected in practice, since the JS module registry itself caches an imported module strongly for the life of the realm; the WeakRef here only releases the shim's own handle to it — the shim degrades predictably rather than silently: resolved reports false, get() and proxy reads throw E_SHIM_NOT_RESOLVED, and the next resolve() call transparently re-invokes the resolver. That re-invocation is idempotent for a dynamic import (re-importing an already-loaded module resolves instantly from the registry cache) and is a correctness requirement for any other resolver you supply, since this path can legitimately run more than once over a shim's lifetime.

Long-lived synchronous consumers — a timer callback, an event handler — that read get() / proxy without ever await-ing resolve() again should either hold their own strong reference to the specific value they pulled out of the bundle, or re-await resolve() before each synchronous read, rather than assuming a resolved bundle stays resolved indefinitely.

Environments without WeakRef (checked once, at construction time) fall back to an ordinary strong reference for that shim's lifetime. That is documented, intentional behavior for those environments — not a silent downgrade you have to discover the hard way.

Typed failures

Three exception codes cover the seam's failure surface:

  • E_SHIM_NOT_RESOLVED — thrown by get() or a proxy property read attempted before anything has resolved. The message names the exact accessor ("get()", or the touched property name), so the failure points at the offending call site instead of surfacing as a generic "cannot read property of undefined" a few frames downstream. Recoverable: await shim.resolve(), then retry the sync read.
  • E_SHIM_RESOLUTION_FAILED — thrown when the resolver rejects or throws. The original failure is preserved on .cause — a failed fetch, a malformed bundle, a Worker handshake that never completed — and the memo is cleared on this path, so the very next resolve() call re-invokes the resolver and can still succeed. Treated as non-fatal by design: a resolver failure is an environmental condition, not a programming error.
  • E_SHIM_RESOLVER_ALREADY_RESOLVED — ambient-shim-only (see below): thrown when registerAdkResolver is called again after the ambient shim has already resolved once.

Example resolvers

These are illustrative — the module ships the mechanism, not a loading policy.

ts
const shim = createAdkShim(() => {
  if (typeof window === 'undefined') {
    throw new Error('this bundle is client-only (no SSR)')
  }
  const url = new URL('/repl/adk-repl.es.js', window.location.origin).href
  return import(/* @vite-ignore */ url)
})
ts
// No deferral at all — a drop-in for call sites that don't need one, e.g. tests.
const shim = createAdkShim(() => import('@nhtio/adk'))
ts
// The bundle LIVES in the worker; it cannot be postMessage'd back — a module
// namespace of classes and methods doesn't survive structured cloning. So the
// resolver waits for the worker to signal readiness, then resolves to a client
// -side RPC FAÇADE whose methods forward calls across the boundary. TBundle is
// the façade's shape here, not `typeof import('@nhtio/adk')`.
const shim = createAdkShim<AdkClientFacade>(
  () => new Promise((resolvePromise) => {
    worker.addEventListener('message', function onReady(e) {
      if (e.data?.type !== 'adk-ready') return
      worker.removeEventListener('message', onReady)
      resolvePromise(makeAdkFacade(worker)) // methods postMessage into the worker
    })
    worker.postMessage({ type: 'init-adk' }) // worker imports ADK on its own side
  })
)

The worker case is the sharpest illustration of the seam staying out of your loading policy: the resolver's job is only to hand back a TBundle-shaped value once one exists. Whether that value is a real ADK namespace (same-graph, URL import) or a façade standing in for an instance that lives somewhere unclonable (a worker, a different realm) is entirely yours to decide — the shim memoizes it either way and never inspects what it is.

The ambient variant: registerAdkResolver + adk

createAdkShim gives you an independent, scoped binding — construct one per plugin version, per Worker, per whatever unit needs its own swappable resolver. Most apps don't need that granularity; they need one shared binding that every file in the module graph imports and reads the same way. That's what the ambient registry is for:

ts
import { registerAdkResolver, adk } from '@nhtio/adk/shims'

// Once, early, before anything reads `adk`:
registerAdkResolver(() => import('@nhtio/adk'))

// Anywhere else in the graph, after a resolve has completed at least once:
await adk.resolve()
const { Message } = adk.proxy

Call registerAdkResolver once, early, before anything reads adk. Re-registering before the ambient shim's first successful resolution silently overwrites the previous registration — last writer wins, no throw — which is the expected shape during application bootstrap, where a resolver might be registered speculatively and then superseded before anything actually resolves. Re-registering after the first successful resolution throws E_SHIM_RESOLVER_ALREADY_RESOLVED: swapping the resolver underneath already-resolved consumers risks two different call sites silently observing two different ADK builds through the same shared handle, which is a far harder bug to diagnose than a loud, immediate throw at the mis-timed registration site. If you need a second, independently swappable binding, construct a fresh createAdkShim instance instead of fighting the ambient one.

Prefer createAdkShim directly when you want a scoped, non-ambient binding; reach for registerAdkResolver / adk for the "one shared binding used across many files" ergonomics — which is exactly what this docs site's own flagship agent does (see below).

TBundle is a compile-time contract, not a runtime guarantee

Here's the failure this section exists to prevent: you type your shim's bundle as carrying the thrift battery (typeof import('@nhtio/adk/batteries/context/thrift'), composed in below), call subtractToFit off it, and ship. Your build's entry point never actually imported that battery, so the compiled artifact doesn't contain it. TypeScript is satisfied — the type says the function exists — and the build succeeds. The runtime isn't: shim.resolve() returns a bundle missing the function, and the call throws the first time a real user hits it.

The generic TBundle you supply to createAdkShim (or, at the ambient call sites, the default AdkNamespace) tells the compiler what shape to expect back from your resolver. It performs no runtime validation of the value your resolver actually produces — getting the contract right is on you, exactly as it would be for a real import statement whose resolved module happens not to match its .d.ts. Compose it with & when your resolver's bundle carries more than the root namespace — a battery bundled alongside core, say:

ts
type MyBundle = typeof import('@nhtio/adk') & typeof import('@nhtio/adk/batteries/context/thrift')
const shim = createAdkShim<MyBundle>(() => loadMyPrecompiledBundle())
const { subtractToFit } = await shim.resolve()

The bundle-build recipe

createAdkShim and registerAdkResolver are the consumption seam — they say nothing about how the bundle itself gets built. For a URL dynamic-import resolver, you need an actual compiled artifact to import, and the way to produce one is a standalone bundler config that compiles @nhtio/adk (plus whichever battery subpaths you use) into a single self-contained module, independently of your host app's own bundler pass.

This repository's own docs playground does exactly that. A trimmed version of the real config (repl.vite.config.mts):

ts
import { resolve } from 'path'
import { defineConfig } from 'vite'

const SRC_DIR = resolve(__dirname, 'src')

export default defineConfig({
  resolve: {
    alias: {
      // Resolve @nhtio/adk to LOCAL source so this build compiles the library itself,
      // not a previously published package — cycles resolved by Rollup, exactly as a
      // real published build would be.
      '@nhtio/adk/': `${SRC_DIR}/`,
      '@nhtio/adk': resolve(SRC_DIR, 'index.ts'),
    },
  },
  build: {
    outDir: resolve(__dirname, 'docs', 'public', 'repl'),
    sourcemap: true,
    // Do NOT minify: a class-name-keyed encode/decode round-trip breaks under esbuild's
    // lib-mode minifier mangling constructor names. This asset is loaded by URL, so
    // correctness beats the size win.
    minify: false,
    lib: {
      entry: { 'adk-repl': resolve(__dirname, 'entry.ts') },
      formats: ['es'],
      fileName: (_format, entry) => `${entry}.es.js`,
    },
    rollupOptions: {
      // INVERTED externals vs. a normal app build: bundle @nhtio/adk IN (that's the whole
      // point). Externalize only what the host page already provides.
      external: ['vue'],
    },
  },
})

The shape that matters, independent of which bundler you use:

  • Alias @nhtio/adk to your library's real entry point (source or a published package) so the compiled output is self-contained — resolving it once here, not deferred to whatever resolves imports at runtime, is the entire reason this is a separate build rather than a flag on your host app's own config.
  • Emit a single ESM file (build.lib with formats: ['es']) so a plain import() by URL, with no import map and no host bundler involvement, can load it.
  • Disable minification if your persistence layer depends on constructor names surviving intact (this repo's encoder does — decoding a custom:<ClassName> tag depends on constructor.name, and minification mangles it). If nothing in your bundle keys off runtime names, you can drop this constraint.
  • Externalize only what the loading page unconditionally already provides. Anything the bundle needs that the page can't guarantee gets bundled in, not externalized — an externalized dependency the page doesn't actually have becomes an unresolvable import at runtime, since there's no bundler present to resolve it for you the way there would be inside a normal app build.

The output is a static asset your host app serves (this repo serves it from docs/public/repl/) and loads by URL — which is precisely the shape the "URL dynamic import" resolver example above expects.

TBundle and the build are two halves of one contract

Treat the bundle-build config and the TBundle type you hand to createAdkShim as one contract split across two files: the build config decides which symbols actually end up in the compiled artifact, and TBundle is your compile-time promise about what a successful resolution will contain. Neither half validates the other at runtime. If you add a battery import to the build's entry point, update TBundle to match; if you widen TBundle, make sure the entry point actually exports the new symbol. The compiler will happily let the two drift apart — it's checking your code against the type, not against what your bundler actually emitted.

This site runs on its own advice

The flagship agent embedded in this documentation site, and the interactive code playground next to it, both consume ADK through exactly this seam — createAdkShim wrapping a URL dynamic-import resolver against the compiled adk-repl.es.js asset described above, accessed through the ambient registerAdkResolver / adk pattern so every agent module shares one resolved binding. This isn't a hypothetical recipe; it's the mechanism keeping this very page's interactive demos from re-triggering the iOS WebKit crash that motivated writing @nhtio/adk/shims in the first place.

Where to go next

  • Bring your own tools — the next assembly concern once ADK is loaded: defining the Tool instances your runtime-loaded bundle will execute.
  • Assembly — the full map of where your stack plugs into ADK.
  • Token Thrift and Behavioral Rails — the context and gate discipline the flagship agent (loaded through this exact seam) runs on top of.