v3.5.1
release: v3.5.1 (#89)
Slothlet v3.5.1 Changelog
Release Date: May 2026
Release Type: Patch
Branch: release/3.5.1
Overview
Version 3.5.1 is a bug-fix patch release. Two fixes:
-
Binary buffers crossing the
selfboundary — aBuffer(or anyTypedArrayview /DataView) returned from one module and consumed throughselfwas proxy-wrapped as if it were an ordinary class instance, which breaks intrinsic accessors such as.lengthand.byteLength. Such values now crossselfunwrapped. -
Relative imports in TypeScript modules —
.ts/.mtsmodules can now use relativeimport/exportspecifiers — both to plain.mjs/.cjs/.jsfiles and to other.ts/.mtsmodules. v3.5.0 fixed bare-specifier resolution but left relative specifiers resolving against the on-disk transform cache, where they failed withCannot find module.
No breaking changes. All v3.5.0 configuration and API usage is fully compatible.
🐛 Bug Fixes
Buffer / TypedArray / DataView are no longer proxy-wrapped across self
Before: When a module returned a Buffer (or a typed-array view like Uint8Array, or a DataView) and the value crossed the self boundary, Slothlet classified it as a wrappable class instance and returned a context-preserving Proxy in its place. A proxy breaks typed-array intrinsics — .length and .byteLength are read directly from the value's internal slots on the receiver, which a wrapper proxy does not carry — so downstream code saw a broken length and corrupt binary handling.
After: Binary buffers cross self untouched. A returned Buffer, any TypedArray view, or a DataView is passed through as-is, with its intrinsic accessors intact.
// api/encoder.mjs
export function encode(text) {
return Buffer.from(text, "utf8"); // returned across `self`
}// api/consumer.mjs
import { self } from "@cldmv/slothlet/runtime";
export function size(text) {
const buf = self.encoder.encode(text);
return buf.length; // ✓ correct length — buf is a real Buffer, not a proxy
}Root cause
runtime_isClassInstance() in src/lib/helpers/class-instance-wrapper.mjs decides whether a returned value should receive a context-preserving wrapper. A Buffer has constructor Buffer (not in EXCLUDED_CONSTRUCTORS) and matched no entry in EXCLUDED_INSTANCEOF_CLASSES, which was:
[ArrayBuffer, Map, Set, WeakMap, WeakSet, EventEmitter]ArrayBuffer (the raw backing store) was excluded, but the views — Buffer and the typed-array family — are what actually cross self, and they were not.
What changed
EXCLUDED_INSTANCEOF_CLASSES now also excludes the abstract %TypedArray% base constructor and DataView:
[ArrayBuffer, TypedArray, DataView, Map, Set, WeakMap, WeakSet, EventEmitter]The %TypedArray% base is not exposed as a global, so it is derived once via Object.getPrototypeOf(Uint8Array). An instanceof check against it matches Buffer and every numeric view (Uint8Array, Int16Array, Float64Array, BigInt64Array, …) in a single entry; DataView is listed separately because it is not a typed array but relies on the same internal-slot accessors.
Relative imports now resolve from .ts / .mts modules
Before: A relative import / export specifier inside a TypeScript module failed at load time:
Cannot find module '<project>/.slothlet-cache/helper.mjs'
imported from <project>/.slothlet-cache/<pid>-<id>/<hash>.mjs
// api/alpha/alpha.mts
import { helperPing } from "../helper.mjs"; // ❌ Cannot find moduleThe same relative imports worked from .mjs modules — only .ts / .mts were affected.
After: .ts and .mts modules can use relative specifiers the same way .mjs modules can — to plain .mjs / .cjs / .js files (including deeper ../../… paths) and to other .ts / .mts modules, which are transpiled and linked automatically.
// api/alpha/alpha.mts
import { helperPing } from "../helper.mjs"; // ✓ plain ESM
import { utilTag } from "../../shared/util.cjs"; // ✓ CommonJS
import { sharedTag } from "./ts-shared.mts"; // ✓ another TypeScript module
export function describe(): string {
return `${helperPing()}:${utilTag()}:${sharedTag()}`;
}Root cause
Transformed .ts / .mts output is written to a content-hashed cache file at <project>/.slothlet-cache/<pid>-<instanceID>/<hash>.mjs and imported from there. esbuild (fast mode) and tsc (strict mode) transform the code but never rewrite import specifiers. A relative specifier such as ../helper.mjs therefore resolved against the cache directory instead of the original source directory, pointing at a file that does not exist.
v3.5.0 moved TS modules off data: URLs onto real cache files, which fixed bare-specifier resolution (@cldmv/slothlet/runtime, npm packages — those resolve by walking up to node_modules, and the cache lives inside the project tree). Relative specifiers were not addressed by that change.
What changed
Two helpers in src/lib/processors/typescript.mjs run on the transformed output before it is cached:
-
rewriteRelativeSpecifiers()resolves every./- or../-prefixed specifier against the original source directory. A specifier targeting a plain.mjs/.cjs/.jsfile becomes an absolutefile://URL at that location. It covers staticimport/export … fromdeclarations (including multi-line binding lists andexport *), bare side-effect imports, and dynamicimport("…")with a static string literal. Comments between the tokens of an import — including betweenfrom/importand the module string (import(/* webpackIgnore */ "./x.mjs")) — are tolerated and do not defeat the rewrite. Bare specifiers and already-absolute URLs are left untouched, and matches inside string literals, comments, or regular-expression literals are skipped — import-shaped text in string data or a regex body is never mutated. -
writeTransformedToCache()follows the transitive graph of relative.ts/.mtsimports: each imported TypeScript module is transpiled and cached too, and the importing specifier is rewritten to the dependency's cache file. Import cycles are handled, and the TypeScript source-extension convention is respected (a specifier may name./x.mjsor./xwhile the file on disk isx.mts). Each cache file is named by a content hash over its whole relative-.ts/.mtsclosure, so editing any module in the graph produces fresh URLs for every importer — a reload never serves stale linked output.
Node has no built-in loader for .ts / .mts, which is why a relative TypeScript import must be transpiled and pointed at its cache file rather than its raw source.
📦 Type Declarations
The typescript.mjs processor now exports rewriteRelativeSpecifiers, resolveModuleFile, and maskStringsAndComments, and writeTransformedToCache gained an optional transform parameter (the transpiler used to follow relative .ts/.mts imports). The published types/ declarations were regenerated so all of these appear in typescript.d.mts.
Two pre-existing declaration-emit defects were also corrected:
-
index.d.mtslost its namedslothletexport.index.mjsexportsslothletboth as the default and as a named alias, but the generated declaration carried only the default — soimport { slothlet } from "@cldmv/slothlet"failed type-checking despite working at runtime. The entry point is declared as aconst(rather thanexport default function), so tsc emits a namedexport function slothlet(…)alongside the default. The declaration post-processorbuild-exports.mjswas corrected as part of this: it previously appended anexport { slothlet }statement whenever that literal text was missing, which — now that tsc emits the named export as afunctiondeclaration — produced a second named export and aTS2484declaration conflict that brokebuild:dev. It now appends the statement only when no namedslothletexport already exists in any form. -
UnifiedWrappercarried a bogus numeric index signature. Theutil.inspect.customhandler was a computed-key class member, which tsc emits as a spurious[x: number]: …index signature that makes the type look array-like. The handler is now an ordinary named method wired to theutil.inspect.customsymbol withObject.definePropertyon the prototype, so the generated class declaration is clean.Object.defineProperty(rather than a plain assignment) keeps the property non-enumerable — the descriptor a computed class-body method would have produced.
📚 Documentation Updates
docs/CONTEXT-PROPAGATION.md— the class-instance-wrapping built-in exclusion list now explicitly namesArrayBuffer,DataView,Buffer, and typed-array views, and notes that wrapping a binary buffer would break its intrinsic accessors.docs/MODULE-STRUCTURE.md— the "Runtime Imports from.ts/.mts" subsection now states that relative imports resolve to plain.mjs/.cjs/.jsfiles and to other.ts/.mtsmodules.docs/TYPESCRIPT.md— the Runtime Imports section and the on-disk cache entry under Limitations describe relative-specifier anchoring and recursive.ts/.mtslinking.README.md— promoted v3.5.1 to "Latest".
🧪 Test Coverage
tests/vitests/suites/helpers/class-instance-wrapper-proxy.test.vitest.mjs— extended withruntime_isClassInstancecases forBuffer, the typed-array family,DataView, andArrayBuffer, plus a regression check that an unwrappedBufferkeeps its.length.api_tests/api_test_typescript_relative/— fixture:.mtsand.tsAPI modules underapi/that relatively import plain.mjs/.cjshelpers, other.ts/.mtsmodules (including a transitive.ts→.mtschain), and a circular.mts⇄.mtspair — all from a siblingexternal/directory kept outside the API directory.tests/vitests/suites/typescript/typescript-relative-imports.test.vitest.mjs— unit coverage forrewriteRelativeSpecifiers,resolveModuleFile, andmaskStringsAndComments(every specifier form, comment-separated specifiers, the TypeScript source-extension remap,?query/#hashpreservation, string/comment/regex-literal skipping, and false-positive guards) plus end-to-end loading of every relative-import form in fast and eager modes.
Coverage remains at 100% across statements, branches, functions, and lines.