@nhtio/adk/shims
Runtime-binding shims — use @nhtio/adk (or any bundle) WITHOUT importing it into your module graph.
Remarks
Why this exists
Most consumers import the ADK directly and let the bundler wire it in. Some cannot: importing ADK source eagerly evaluates its entire module graph, and on constrained runtimes that eager evaluation is itself a problem — the motivating case is this repository's own docs site, which hand-rolled this exact pattern four times (~590 lines, one drifting hand-maintained manifest type per copy) after discovering that importing @nhtio/adk into the VitePress app's graph overflowed the JS call stack on iOS WebKit ("Maximum call stack size exceeded") — see docs/.vitepress/repl/index.ts for the full account of that failure and the compiled-bundle workaround it settled on. The same shape recurs anywhere a module must reference ADK types and values without eagerly linking to an ADK build at import time: CDN / no-bundler pages, Worker threads, plugin systems that inject a host-provided implementation, code-split lazy chunks, and version hot-swap (retiring one loaded bundle for another without reloading every importer).
This module ships the mechanism those call sites all rediscover independently — a resolver seam plus a memoizing handle — and nothing else. It has no opinion on how you load a bundle (fetch + dynamic import(), a bundler-native import(), a Worker postMessage handshake, a host-injected global); that policy is entirely yours, supplied as a single function.
The pattern
You hand createAdkShim an AdkResolverFn — a zero-argument function that produces your bundle, synchronously or asynchronously. The returned AdkShim gives you four ways to consume it:
await shim.resolve()— the async, always-correct path. Single-flight: concurrent callers during an in-flight resolution share one resolver invocation, not one each.shim.get()— the sync path, for code that has already awaitedresolve()at least once and now wants to read the bundle without re-awaiting. Throws E_SHIM_NOT_RESOLVED if nothing is resolved yet.shim.proxy— aProxy<TBundle>that transparently delegates every property read to the resolved bundle once one exists, and throws E_SHIM_NOT_RESOLVED (naming the exact property that was touched) if read too early. This directly replaces theexport let Foo: typeof AdkModule.Fooholder pattern the docs app hand-wrote per symbol — oneproxyobject stands in for the whole bundle's worth of holders, and destructuringconst { Foo } = shim.proxyreads through it exactly the same way a real module namespace would.shim.resolved— a live boolean for "is there currently a dereferenceable bundle", for call sites that want to branch without risking a throw.
API CONTRACT: importing this module evaluates essentially nothing
@nhtio/adk/shims is a leaf: aside from createException (used only to mint the three exception classes below, and itself graph-free — see the note at the bottom of this remarks block), it has zero runtime imports from the rest of the ADK. Every ADK type used here — AdkNamespace — is an erased typeof import(...) type query, which the compiler discards entirely; it costs nothing at runtime and pulls in no value bindings. import * as shims from '@nhtio/adk/shims' therefore does not construct a single ADK class, does not touch a single battery, and cannot itself be the thing that overflows a call stack. That is the entire point: this module is safe to import eagerly from anywhere — including the eager, top-of-file position a resolve()-calling module needs — precisely because it never touches the graph it is a seam for.
Why shims is NOT re-exported from the root @nhtio/adk barrel
Root-barrel re-export would defeat the entire purpose. If @nhtio/adk/shims were exported from @nhtio/adk itself, then import { createAdkShim } from '@nhtio/adk' would drag in the very module graph this subpath exists to let you avoid — you cannot get "a seam for deferring ADK's import" without importing ADK. Keeping shims a sibling subpath, never re-exported upward, is what lets a leaf-conscious consumer write import { createAdkShim } from '@nhtio/adk/shims' and mean it.
GC-safe memoization
A resolved bundle is held via WeakRef (constraining TBundle extends object, since only objects are valid WeakRef targets) — never strongly retained by the shim itself, so a shim you hold onto cannot alone keep an entire ADK bundle (and everything it 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 a resolver like the canonical dynamic-import one, was never going to be collected in practice — 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:
resolvedreportsfalse.get()andproxyproperty reads throw E_SHIM_NOT_RESOLVED.resolve()transparently re-invokes the resolver (idempotent for the dynamic-import case — re-import()-ing an already-loaded module resolves instantly from the registry cache — and a correctness requirement for any other resolver you supply, since this path can legitimately run twice).
Long-lived synchronous consumers — anything that reads get() / proxy on a timer or from an event handler without ever await-ing resolve() again — should either hold their own strong reference to the value they read out of the bundle, or re-await resolve() before each sync read, rather than assuming a resolved bundle stays resolved indefinitely.
Environments without WeakRef (checked once, with typeof WeakRef === 'function', at createAdkShim construction time) fall back to an ordinary strong reference for that shim's lifetime — documented behavior, not a silent downgrade: such a shim's bundle lives as long as the shim does.
Example resolvers (illustrative only — this module ships no loading policy)
URL dynamic-import, guarded against SSR (mirrors the docs app's real loader):
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);
});Same-graph passthrough (no deferral at all — useful as a drop-in when the caller doesn't need one, e.g. tests):
const shim = createAdkShim(() => import("@nhtio/adk"));Worker / plugin injection sketch (the resolver awaits a handshake instead of an import):
const shim = createAdkShim(
() =>
new Promise((resolvePromise) => {
worker.postMessage({ type: "request-adk-bundle" });
worker.addEventListener("message", function onMsg(e) {
if (e.data?.type !== "adk-bundle") return;
worker.removeEventListener("message", onMsg);
resolvePromise(e.data.bundle);
});
}),
);TBundle is a compile-time contract, not a runtime guarantee
The generic TBundle you supply to createAdkShim (or, at the ambient registerAdkResolver / adk 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 this 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, e.g. a battery bundled alongside core:
type MyBundle = AdkNamespace &
typeof import("@nhtio/adk/batteries/context/thrift");
const shim = createAdkShim<MyBundle>(() => loadMyPrecompiledBundle());A note on createException's own leaf-ness
createException's import closure was verified (not assumed) to be graph-free before this module was written: ../lib/utils/exceptions imports only ./validation (which imports only ../classes/base_exception + the external @nhtio/validation package), the external @nhtio/validation package itself, the external fast-printf package, and ../classes/base_exception directly (which imports nothing at all). None of those files reach into lib/contracts, lib/classes beyond base_exception, batteries, or any other part of the ADK's runtime graph — so importing it here does not compromise this module's leaf guarantee.
Interfaces
| Interface | Description |
|---|---|
| AdkShim | A memoizing handle over a lazily-resolved bundle, returned by createAdkShim. See the module-level remarks for the full GC-safety contract and the rationale for each accessor. |
Type Aliases
| Type Alias | Description |
|---|---|
| AdkResolverFn | The seam: a zero-argument, consumer-supplied function that produces the bundle a shim wraps, synchronously or asynchronously. All environment-specific loading knowledge — where the bundle lives, how it's fetched, whether it's cached upstream — lives inside this one function; the shim itself is entirely agnostic to how TBundle gets produced. |
Variables
| Variable | Description |
|---|---|
| adk | The ambient, module-scope AdkShim instance. Delegates to whatever resolver was last passed to registerAdkResolver — resolving before any registration raises E_SHIM_RESOLUTION_FAILED with a cause explaining that no resolver has been registered yet. |
| E_SHIM_NOT_RESOLVED | Thrown when AdkShim.get or a AdkShim.proxy property read is attempted before the shim has a dereferenceable bundle — either because AdkShim.resolve has never been awaited to completion, or because a previously resolved bundle's WeakRef has since been garbage collected. The message names the exact accessor that triggered the throw ("get()" for the get() method itself, or the property name for a proxy read) so the failure points straight at the offending call site. |
| E_SHIM_RESOLUTION_FAILED | Thrown when an AdkResolverFn rejects (or throws synchronously). The original failure is preserved on .cause so callers can inspect the underlying reason (a failed fetch, a malformed bundle, a Worker handshake that never completed, …); the message additionally embeds its .message for log lines that only surface the top-level error. |
| E_SHIM_RESOLVER_ALREADY_RESOLVED | Thrown by registerAdkResolver when it is called again after the ambient adk shim has already resolved once successfully. |
Functions
| Function | Description |
|---|---|
| createAdkShim | Construct a new AdkShim around a resolver. |
| registerAdkResolver | Register (or replace) the resolver the ambient adk shim delegates to. |