Skip to content

v3.5.1

Choose a tag to compare

@cldmv-bot cldmv-bot released this 19 May 04:40
· 7 commits to master since this release
v3.5.1
9ecb887

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:

  1. Binary buffers crossing the self boundary — a Buffer (or any TypedArray view / DataView) returned from one module and consumed through self was proxy-wrapped as if it were an ordinary class instance, which breaks intrinsic accessors such as .length and .byteLength. Such values now cross self unwrapped.

  2. Relative imports in TypeScript modules.ts / .mts modules can now use relative import / export specifiers — both to plain .mjs / .cjs / .js files and to other .ts / .mts modules. v3.5.0 fixed bare-specifier resolution but left relative specifiers resolving against the on-disk transform cache, where they failed with Cannot 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 viewsBuffer 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 module

The 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 / .js file becomes an absolute file:// URL at that location. It covers static import / export … from declarations (including multi-line binding lists and export *), bare side-effect imports, and dynamic import("…") with a static string literal. Comments between the tokens of an import — including between from/import and 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 / .mts imports: 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.mjs or ./x while the file on disk is x.mts). Each cache file is named by a content hash over its whole relative-.ts/.mts closure, 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.mts lost its named slothlet export. index.mjs exports slothlet both as the default and as a named alias, but the generated declaration carried only the default — so import { slothlet } from "@cldmv/slothlet" failed type-checking despite working at runtime. The entry point is declared as a const (rather than export default function), so tsc emits a named export function slothlet(…) alongside the default. The declaration post-processor build-exports.mjs was corrected as part of this: it previously appended an export { slothlet } statement whenever that literal text was missing, which — now that tsc emits the named export as a function declaration — produced a second named export and a TS2484 declaration conflict that broke build:dev. It now appends the statement only when no named slothlet export already exists in any form.

  • UnifiedWrapper carried a bogus numeric index signature. The util.inspect.custom handler 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 the util.inspect.custom symbol with Object.defineProperty on 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 names ArrayBuffer, 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 / .js files and to other .ts / .mts modules.
  • docs/TYPESCRIPT.md — the Runtime Imports section and the on-disk cache entry under Limitations describe relative-specifier anchoring and recursive .ts/.mts linking.
  • README.md — promoted v3.5.1 to "Latest".

🧪 Test Coverage

  • tests/vitests/suites/helpers/class-instance-wrapper-proxy.test.vitest.mjs — extended with runtime_isClassInstance cases for Buffer, the typed-array family, DataView, and ArrayBuffer, plus a regression check that an unwrapped Buffer keeps its .length.
  • api_tests/api_test_typescript_relative/ — fixture: .mts and .ts API modules under api/ that relatively import plain .mjs/.cjs helpers, other .ts/.mts modules (including a transitive .ts.mts chain), and a circular .mts.mts pair — all from a sibling external/ directory kept outside the API directory.
  • tests/vitests/suites/typescript/typescript-relative-imports.test.vitest.mjs — unit coverage for rewriteRelativeSpecifiers, resolveModuleFile, and maskStringsAndComments (every specifier form, comment-separated specifiers, the TypeScript source-extension remap, ?query/#hash preservation, 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.