Skip to content
mycl is in early development. The API may change before a stable release; it is not yet recommended for production use.

Snapshots & Async

A scope lives on the synchronous call stack. It is active while the scoped function runs and gone the instant control returns. So the code after an await, inside a .then(), or in any callback that outlives the scope body runs with no scope on the stack, and a capability called there throws the loud “called outside any registry scope” error.

snapshot(fn) is the fix. Called while a scope is active, it captures that scope and returns a function that replays it on every later call, no matter when or where the call happens.

Wrap it where the boundary is: the callback you hand to setTimeout, the function inside a .then(), the event handler you register, the method your mycl() builder returns for later use. Capture at the boundary; call whenever.

snapshot captures whatever is active, including no scope

Section titled “snapshot captures whatever is active, including no scope”

snapshot pins the scope active at call time, and “no scope” is a valid thing to pin. Call snapshot with no scope on the stack and the returned function replays scopelessness: a capability inside it throws, rather than silently adopting whatever scope happens to be active when the function later runs. That is deliberate: a missed boundary fails loud at the call site instead of dispatching through a stranger’s scope. To pin the defaults on purpose, take the snapshot inside a scope(fn) body with no registry.

This is the counterpart to the escaping-function rule: scope() establishes a scope up front, snapshot() carries an already-active one across a boundary.

Node.js: alsContext for automatic propagation

Section titled “Node.js: alsContext for automatic propagation”

On Node you can retire the manual snapshots entirely. Swap your channel’s context for alsContext(), an AsyncLocalStorage-backed ScopeContext shipped from @mycl/core/context, and the scope propagates across await, setTimeout, and microtask boundaries on its own.

This snippet needs Node built-ins the Playground can’t run, so it is shown as static configuration. Wire it once at startup, wherever you minted the channel:

import { setChannelContext } from '@mycl/core';
import { alsContext } from '@mycl/core/context';
import { channel } from './kernel'; // your createFnChannel('my-app-or-library') module
setChannelContext(channel, alsContext());

After this, every scope() and mycl() call for that channel uses AsyncLocalStorage as its backing store, and capabilities called after an await dispatch through the right scope automatically. snapshot still works. It just becomes optional.

alsContext resolves node:async_hooks lazily, through process.getBuiltinModule, the first time it is called, not through a static import: the entry stays browser-safe to import even for consumers that never call it. That lazy lookup needs Node 20.16 or newer (or another runtime that provides process.getBuiltinModule); calling alsContext() where it is missing throws error 14. A ScopeContext is just { get, run }, so if you need something other than the stack or AsyncLocalStorage, for example a test harness or a different async-tracking primitive, writing your own stays a few lines: see Building a Kernel for the { get, run } contract and a hand-rolled example.

Browsers have no AsyncLocalStorage (and the zone.js-style shims that emulate it are not part of mycl). In the browser, snapshot is the mechanism: capture at every async boundary. The mycl() habit of wrapping each builder method in snapshot covers the common cases: the method carries its scope wherever it is later called.

  • Scopes: the synchronous scope model these snapshots carry.
  • Factories: why mycl() builder methods are wrapped in snapshot.
  • Augmentation: the async caveat behind after and pipe.