v3.9.2
release: v3.9.2 - make class-instance wrapping idempotent to stop async… (#132)
Slothlet v3.9.2 Changelog
Release Date: May 2026
Release Type: Patch
Branch: release/3.9.2
Overview
Version 3.9.2 makes browser mode actually loadable and fixes three independent bugs. v3.9.0/v3.9.1 shipped the browser / worker target, but a consumer still had to hand-roll an importmap to get slothlet's own modules to resolve — so the headline of this release is generateBrowserAssets, which produces that importmap (alongside the API manifest) from slothlet's real module graph. It also fixes a critical async-runtime blow-up on chainable class instances (#124), wires up the previously-dead global hook pattern filter (#125), and repairs npm run docs:build (#121).
Compatibility. No API breaking changes. generateManifest is unchanged; the browser importmap is delivered through a new generateBrowserAssets helper.
🐛 Bug Fixes
Browser mode could not actually be loaded by a browser (#123)
Slothlet's own internal imports (@cldmv/slothlet, @cldmv/slothlet/helpers/*, …) are static imports the browser resolves before slothlet runs, so they cannot route through resolveModuleSpecifier (which only governs API-leaf loads) — they must be declared in the page's <script type="importmap">. Previously there was no way to obtain that importmap, so every consumer had to hand-roll a scan of slothlet's source. The new generateBrowserAssets helper generates it from slothlet's real module graph, and the Playwright smoke test now uses it to load slothlet end-to-end in a real headless browser.
runtime: "async" double-wrapped chainable class instances → exponential blow-up (#124)
In async runtime, a class-instance result was wrapped twice per method call — once inside AsyncContextManager.runInContext (so later calls re-enter the ALS store) and again in the method Proxy's context-preserving wrapper (needed for live mode). Proxies are transparent, so the outer wrap re-wrapped the already-wrapped value, doubling the wrap count on every step of a fluent chain (2^(N+4) − 1 wraps for an N-step chain — an 8-step chain took minutes). Wrapped Proxies are now marked with a sentinel and the wrap is idempotent across both call sites, so chains stay flat (one Proxy layer per step) and resolve in linear time. Live mode still wraps exactly once.
The global hook.pattern filter was a silent no-op (#125)
hook.pattern (and the string/boolean hook forms that derive it) was documented as a global filter for which API paths hooks apply to, but the value was stored on the HookManager and never read — so it filtered nothing. getHooksForPath now consults a global path filter (a hook fires only if the called path matches an enabled pattern), seeded from hook.pattern; the catch-all "**" leaves it inactive (backward compatible). The root cause was broader: the HookManager is constructed before config normalization runs, so it read the raw config.hook — only the object form happened to have .enabled/.pattern, which meant the documented string ("database.*") and boolean (true) forms never enabled hooks at all. Hook-config normalization is now a shared normalizeHookConfig() used by both the config transformer and the HookManager.
npm run docs:build failed on TypeScript-style JSDoc (#121)
The doc generator (jsdoc-to-markdown / catharsis) cannot parse the TypeScript-style JSDoc the source uses for accurate tsc declarations — import("./x.mjs").T, arrow types, tuples, optional record props, intersections — and aborted the whole build (36 errors across 9 files). A jsdoc plugin now rewrites only the {…} type portion of type-bearing tags into meaning-preserving Closure equivalents before catharsis parses them; example code and prose are untouched, and the tsc-generated declarations are unaffected.
🚀 New / Changed APIs
generateBrowserAssets(apiDir, { slothletBase })
The recommended one-call entry point for browser setup. Returns { manifest, importmap }: the API-directory manifest and the importmap for slothlet's own modules. slothletBase (default /node_modules/@cldmv/slothlet/) is the URL/path prefix where the package is served in the browser — override for a CDN, an Electron protocol, or "/" when the package is served at the web root. Also exported: generateImportMap(slothletBase) for the importmap alone. See docs/BROWSER.md.
Runtime hook pattern-filter methods
api.slothlet.hook.enablePattern(pattern) / disablePattern(pattern) / resetPatternFilter() manage the global path filter at runtime (the runtime counterpart of the hook.pattern config) — distinct from enable/disable, which toggle individual registered hooks. See docs/HOOKS.md.
🧪 Tests
- #124 — deterministic idempotency unit test for
runtime_wrapClassInstanceplus an async-runtime integration suite proving a deep chain stays correct and context-preserving. - #125 — a hook pattern-filter suite asserting matching/non-matching paths for the config form (object/string), the
"**"no-op, and the runtimeenablePattern/disablePattern/resetPatternFiltermethods across the hook-enabled matrix. - #123 — the Playwright smoke test now builds its assets with
generateBrowserAssets, plus unit coverage for the generator (default/override base, locale enumeration, the string guard). - Full coverage gate green: 100% statements / branches / functions / lines.
📚 Documentation
- NEW: docs/BROWSER.md — complete browser / worker mode guide: the manifest-vs-importmap split,
generateBrowserAssets, commonslothletBaseconfigurations (default, CDN, web root, Electron main/renderer), the lower-level helpers, and the in-browser runtime boundary. - docs/HOOKS.md — documents the global path filter and the new runtime pattern methods.
- README — new Browser / Worker Mode feature section.
Upgrade notes
- No API or config changes are required. Browser-mode consumers can replace a hand-rolled importmap with a single
generateBrowserAssets(apiDir, { slothletBase })call. - Async-runtime hosts using chainable class instances (query builders, fluent SDK clients) get the #124 fix automatically.
- Anyone relying on
hook: "pattern"/hook: trueshould note these forms now genuinely enable hooks and apply the global path filter (previously they were inert).