Skip to content
8 min read · 1,511 words

@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 awaited resolve() 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 — a Proxy<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 the export let Foo: typeof AdkModule.Foo holder pattern the docs app hand-wrote per symbol — one proxy object stands in for the whole bundle's worth of holders, and destructuring const { Foo } = shim.proxy reads 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:

  • resolved reports false.
  • get() and proxy property 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):

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);
});

Same-graph passthrough (no deferral at all — useful as a drop-in when the caller doesn't need one, e.g. tests):

ts
const shim = createAdkShim(() => import("@nhtio/adk"));

Worker / plugin injection sketch (the resolver awaits a handshake instead of an import):

ts
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:

ts
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

InterfaceDescription
AdkShimA 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 AliasDescription
AdkResolverFnThe 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

VariableDescription
adkThe 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_RESOLVEDThrown 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_FAILEDThrown 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_RESOLVEDThrown by registerAdkResolver when it is called again after the ambient adk shim has already resolved once successfully.

Functions

FunctionDescription
createAdkShimConstruct a new AdkShim around a resolver.
registerAdkResolverRegister (or replace) the resolver the ambient adk shim delegates to.