The /lifecycle subpath groups subscriptions and temporary resources into an
explicitly managed scope. It does not require a host driver or change legacy APIs.
import { on } from "@urth/metatell-sdk/events";
import { createPluginScope } from "@urth/metatell-sdk/lifecycle";
const scope = createPluginScope();
scope.addCleanup(on("chat:message", (message) => renderMessage(message.text)));
const timer = window.setInterval(refresh, 1000);
const releaseTimer = scope.addCleanup(() => window.clearInterval(timer));
releaseTimer(); // Release just this registration once, before scope disposal.
scope.dispose(); // Abort immediately, then begin all remaining cleanup.
await scope.disposeAsync(); // Wait for asynchronous cleanup to settle.
const cleanupErrors = scope.getCleanupErrors();
renderMessage and refresh above are application callbacks.
signal aborts synchronously before registered cleanup starts. New resources
registered after disposal begins are immediately released.addCleanup accepts () => void | Promise<void> and returns a release function.
Each registration runs once, even if released early, recursively, or repeatedly.dispose starts remaining cleanup in reverse registration order. It does not
wait for asynchronous cleanup before starting the next callback.getCleanupErrors returns a snapshot
in observation order; asynchronous failures appear after settling.disposeAsync starts disposal if needed and waits for pending cleanup, including
early-release cleanup and cleanup registered while it waits. It resolves even
when cleanup fails; inspect the error snapshot afterward. Cleanup registered
after it finishes is released immediately but is outside that completed wait.
Another call waits for any new pending cleanup.disposeAsync on its own scope: doing so creates a
circular wait. Cleanup that never settles also prevents disposal from settling.createPluginScope remains host-independent: its owner must dispose it. A host
integrating the runtime manager can instead supply a PluginRuntimeContext with
an instance scope, getRoomScope(), and onRoomChanged(listener). The context
contains host-assigned plugin/version/instance identity; plugins must not obtain
another plugin's context from an internal registry.
The instance survives UI remounts. Removal, version replacement, or runtime exit
aborts it. A room/scene change aborts the old room scope before the replacement
is announced; the instance can remain active. onRoomChanged immediately sends
the current room scope (or null) and returns an unsubscribe function.
getRoomScope() throws NOT_READY without a room and ABORTED after disposal.
import type { PluginRuntimeContext } from "@urth/metatell-sdk/lifecycle";
async function loadRoomText(sdk: PluginRuntimeContext, url: string) {
const room = sdk.getRoomScope();
return room.run(async (signal) => {
const response = await fetch(url, { signal });
return response.text();
});
}
room.run runs once, rejects promptly with ABORTED on room invalidation, and
consumes a late completion or rejection. It cannot roll back an accepted write
or prevent side effects inside an operation that ignores the signal. Check the
scope again before applying a value in a later promise continuation.
createPluginRoomScope(room) is also exported for host integrators and tests. It
validates a PluginRoomIdentity and returns an independent room scope; it does
not advertise host support or acquire resources.
See runtime capabilities for readiness and cancellation contracts.
Keep scopes separate for resources with different lifetimes. A UI subscription scope may end when its view closes, while the room-level owner of a background may persist until the plugin stops or the room changes.
For asynchronous operations, pass scope.signal to APIs that support cancellation
and guard any late result before applying it. Aborting does not cancel an already
accepted server write or forcibly stop arbitrary JavaScript. A scope also cannot
detect that a host room changed without its owner disposing it.
async function loadText(url: string) {
const response = await fetch(url, { signal: scope.signal });
const value = await response.text();
if (scope.signal.aborted) return;
renderText(value);
}
Handle network errors and aborts at the application's call site. Exceptions from application-owned AbortSignal listeners follow platform event-dispatch behavior and are outside the cleanup-error collection contract.