@xmachines/core
API / @xmachines/core
The base of the XMachines packages: the error base class, the release protocol, and a cache.
Code that more than one @xmachines/* package can use, and that belongs to no layer of the Play Architecture. The package reads no workspace package. @xmachines/play reads it, and so can every package above the protocol layer.
What this package is
The package has no root entry. It has three subpaths:
@xmachines/core/errors—BaseError, the base class of every typed error of the workspace, andDisposeConflictError.@xmachines/core/utils— the release protocol, and small helpers for values and errors.@xmachines/core/cache— a key-value cache contract, and an LRU cache.
A subscription of the XMachines packages hands back a plain release: a function, or an object with a release method. A caller that wants a using scope publishes the release with asDisposable. A caller that does not pays nothing.
asDisposable(release)— makes a release function aDisposable. The function is its own release.asDisposable(target, release)— makes an object aDisposable, with the release that the caller names.DISPOSE— the symbol key that a release sits under.DisposeKeyandDisposeMember— the types of that key and of the member.
The package keeps no module state, so DISPOSE resolves to the same symbol in each copy. It must still exist one time in an install, because err instanceof BaseError answers false for an error of a second copy. Each package that reads it therefore names it as a peer.
Installation
pnpm add @xmachines/coreErrors
BaseError gives each error two stable fields: scope, the class or the module that threw it, and code, a machine-readable identifier. PlayError of @xmachines/play extends it, so err instanceof BaseError matches every typed error of the workspace. Branch on the class or on code, and never on message.
import { BaseError } from "@xmachines/core/errors";
declare function start(): void; // your code, which can throw
try { start();} catch (err) { if (err instanceof BaseError) { console.error(`[${err.scope}:${err.code}] ${err.message}`); } else { throw err; }}A package below the Play protocol extends BaseError directly. This package throws one such error: DisposeConflictError, with the code CORE_DISPOSE_CONFLICT.
Usage
A release function
asDisposable gives the SAME function back with the dispose key added. Nothing wraps it, so a caller that runs the release keeps that code:
import { createAtom, watchAtom } from "@xmachines/play-atom";import { asDisposable } from "@xmachines/core/utils";
const count = createAtom(0);const render = (value: number) => console.log(value);
// The explicit form.const stop = watchAtom(count, render);stop();
// The scoped form. The scope releases it, and an exception releases it too.{ using stop = asDisposable(watchAtom(count, render)); render(count.get());}An object
An object names its release. The object comes back as itself, and it keeps every member that it had:
import { createBrowserHistory } from "@xmachines/play-dom-router";import { asDisposable } from "@xmachines/core/utils";
{ const plain = createBrowserHistory({ window }); using history = asDisposable(plain, () => plain.destroy()); history.push("/first");} // destroy() runs hereA value that is a Disposable already
asDisposable adds the key only where it is absent. asDisposable(release) gives a value that carries a dispose member already, own or inherited, back unchanged, and a second call changes nothing. A Vitest mock is such a value: its dispose restores the mock.
A named release does not replace that member. asDisposable(target, release) throws a DisposeConflictError when the target has a dispose member already, because the scope would run that member and never the release. A second call with the same release is not a conflict, and it returns the target. Each form fills a well-known key that a polyfill installed after the first call.
The rules of a release
- Idempotent. A caller that runs the release AND lets the scope end runs it two times.
- Synchronous. The type rejects a release that returns a value, a promise above all. A
Disposableservesusingandawait using, while anAsyncDisposableraises a TypeError under a plainusing.
The key is not enumerable, so a spread of the value does not copy it.
Use DISPOSE and not a bare Symbol.dispose when a class implements the protocol itself. A bundler that downlevels using for a target below Chrome 134 emits a helper that reads object[Symbol.dispose || Symbol.for("Symbol.dispose")], and DISPOSE is that same pair:
import { DISPOSE } from "@xmachines/core/utils";
class Session { close(): void {} [DISPOSE](): void { this.close(); }}The
usingform needs"lib": ["ESNext"]. A consumer on a lowerlibreadsDisposeMemberas an empty type, and every call site keeps compiling.
releaseQuietly(release, onFailure)
Runs one release, and gives a throw of it to onFailure. A teardown that runs several releases in a row runs each one through it, so a throw of the first release skips none of the others. null and undefined do nothing.
Value helpers
isError(value)
Tells if a value is an Error of any realm. The function tries instanceof first, so an object that inherits from Error.prototype answers true. instanceof misses an error from an iframe or from node:vm, so for such a value the function uses Error.isError where the runtime has it, and it tests the brand elsewhere. It can throw for a revoked Proxy, so wrap it in try inside an error path.
safeString(value) and describeValue(value)
Two ways to put an unknown value in the message of an error, and neither one throws. safeString gives the String() of the value, and it falls back to the brand for an object with a null prototype or a conversion that throws. describeValue gives the JSON text, and it names the type for a value that JSON drops or cannot write, such as [function].
isThenable(value)
Tells if a value has a callable then, which is the test that await applies. Use it where a declared void | Promise<void> says nothing at run time.
createNullRecord()
Builds an empty record with no prototype. A record that collects keys from outside the program — the params of a URL, the query of a location — takes any key that the outside sends. In an ordinary {}, a key such as __proto__ writes the prototype and not an entry.
rethrowLater(error)
Throws an error again from a microtask of its own, and returns at once. The throw reaches the global handler of the runtime, and it touches the current stack not. A host escalates a failure this way from the onError handler of a renderer: each renderer contains a handler that throws, so a plain throw there reaches no caller.
createWarnOnce()
Builds a function of (anchor, message) that writes one console.warn for each anchor object. A diagnostic that a hot path finds — a stale field of a static config, read on every event — reports one time for that config, and not at the rate of the path. Each function keeps a memory of its own, so the package keeps no module state.
shallowEqualExcept(a, b, except?)
Compares the own keys of two objects with Object.is, and it can ignore one key on both sides. A key that one side carries and the other lacks makes the two unequal. The emission dedup of the Play packages reads it.
The cache, on @xmachines/core/cache
The cache has a subpath of its own, because it is cache code alone.
KeyValueCache<V>— a read and a write by a string key. AMapsatisfies it, and so does an LRU of any library. A cache may forget an entry at any time.createLruCache<V>(maxSize = 500)— a cache that forgets its least recently used entries. It holds two generations, so it holds2 * maxSize + 1entries at most. A size below1reads as1, a fraction rounds down, and a size that is not a finite number reads as the default.
import { createLruCache } from "@xmachines/core/cache";
const pathCache = createLruCache<string | null>(5000);The route map of @xmachines/play-router and the parse of @xmachines/play-pattern take such a cache, and those packages re-export both names.
API Summary
Exported from @xmachines/core/errors
| Export | Kind | Description |
|---|---|---|
BaseError | class | The base of every typed error: a scope and a code |
DisposeConflictError | class | asDisposable refuses a named release that the scope would not run |
Exported from @xmachines/core/utils
| Export | Kind | Description |
|---|---|---|
asDisposable | function | Makes one function or object a Disposable. It changes the value itself |
DISPOSE | const | The symbol key that a release sits under |
DisposeKey | type | The type of that key, and never where the consumer omits the lib |
DisposeMember | type | The dispose member of a Disposable, and none where the lib is absent |
releaseQuietly | function | Runs one release, and gives a throw of it to onFailure |
rethrowLater | function | Throws an error again from a microtask of its own |
createWarnOnce | function | Builds a function that warns one time for each anchor object |
isError | function | Tells if a value is an Error of any realm |
safeString | function | The String() of a value, and it throws never |
describeValue | function | The JSON text of a value, or its type in brackets |
isThenable | function | Tells if a value has a callable then |
createNullRecord | function | An empty record with no prototype |
shallowEqualExcept | function | The shallow equality of the own keys, and it can ignore one key |
Exported from @xmachines/core/cache
| Export | Kind | Description |
|---|---|---|
KeyValueCache<V> | interface | A read and a write by a string key |
createLruCache | function | A cache that forgets its least recently used entries |
License
MIT