Skip to content

Plugin Recipes

Copy-paste plugins for common cross-cutting concerns. Each recipe is a self-contained BlacPlugin that you can drop into your app and tweak. All recipes use only @blac/core imports so they work without any extra dependencies unless noted.

For the plugin authoring API that every recipe is built on, see Plugin Authoring. For the official first-party plugins see Plugin Overview.

Plugins execute in install order. Keep these rules in mind when you compose several recipes together:

  • Persistence first. If you layer a logging plugin on top of a persistence plugin, the logger will see the post-hydration state. That is usually what you want — logging will then record the initial restored value rather than the constructor default.
  • Sinks last. Send-to-server plugins (Sentry, audit log) should be installed after any transforming or filtering plugins so they see the final view of the state.
  • Hook throws are not caught. If onStateChange throws, the exception propagates synchronously out of the flush and the remaining plugins in the chain are skipped. Guard any I/O inside a try/catch in your hooks rather than letting errors bubble.

A minimal persistence plugin backed by localStorage instead of IndexedDB. Good for small string-serializable state where IndexedDB is unnecessary.

import {
function getPluginManager(): PluginManager

Get the global plugin manager

getPluginManager
,
type
(alias) interface BlacPlugin
import BlacPlugin

BlaC plugin hook surface.

Per C2 (Decision 6), state-change events carry the PathSet of paths that changed in the flush. Lifecycle events (onCreated, onDestroyed) are not tied to a state change and so do not carry paths.

Hook firing model:

  • onCreated fires synchronously when a container is first acquired.
  • onStateChange fires once per channel flush (microtask-coalesced). prev is the state before the first emit of the flush; next is the state at flush time; paths is the set of paths marked during the flush.
  • onDestroyed fires synchronously when a container is disposed (after its system 'dispose' event).

Legacy ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are retained for devtools-connect compatibility — they are orthogonal to the C2 event payload contract.

BlacPlugin
,
type
type PathSet = Set<number> | typeof ALL_PATHS
PathSet
,
const ALL_PATHS: typeof ALL_PATHS
ALL_PATHS
,
type
type StateContainerConstructor<S extends object = any> = new (...args: any[]) => StateContainer<S, any, any>

Constructor type for StateContainer classes

@templateS - State type managed by the container

StateContainerConstructor
,
} from '@blac/core';
// --- Recipe: localStorage persistence ---
interface
interface LocalStorageOptions<S extends object>
LocalStorageOptions
<
function (type parameter) S in LocalStorageOptions<S extends object>
S
extends object> {
/**
* Classes to persist. State must be JSON-serializable.
* e.g. [CartCubit, ThemeCubit]
*/
LocalStorageOptions<S extends object>.targets: StateContainerConstructor<S>[]

Classes to persist. State must be JSON-serializable. e.g. [CartCubit, ThemeCubit]

targets
:
type StateContainerConstructor<S extends object = any> = new (...args: any[]) => StateContainer<S, any, any>

Constructor type for StateContainer classes

@templateS - State type managed by the container

StateContainerConstructor
<
function (type parameter) S in LocalStorageOptions<S extends object>
S
>[];
/** Storage key prefix. Defaults to "blac". */
LocalStorageOptions<S extends object>.prefix?: string | undefined

Storage key prefix. Defaults to "blac".

prefix
?: string;
}
function
function createLocalStoragePlugin<S extends object>(opts: LocalStorageOptions<S>): BlacPlugin
createLocalStoragePlugin
<
function (type parameter) S in createLocalStoragePlugin<S extends object>(opts: LocalStorageOptions<S>): BlacPlugin
S
extends object>(
opts: LocalStorageOptions<S>
opts
:
interface LocalStorageOptions<S extends object>
LocalStorageOptions
<
function (type parameter) S in createLocalStoragePlugin<S extends object>(opts: LocalStorageOptions<S>): BlacPlugin
S
>,
):
(alias) interface BlacPlugin
import BlacPlugin

BlaC plugin hook surface.

Per C2 (Decision 6), state-change events carry the PathSet of paths that changed in the flush. Lifecycle events (onCreated, onDestroyed) are not tied to a state change and so do not carry paths.

Hook firing model:

  • onCreated fires synchronously when a container is first acquired.
  • onStateChange fires once per channel flush (microtask-coalesced). prev is the state before the first emit of the flush; next is the state at flush time; paths is the set of paths marked during the flush.
  • onDestroyed fires synchronously when a container is disposed (after its system 'dispose' event).

Legacy ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are retained for devtools-connect compatibility — they are orthogonal to the C2 event payload contract.

BlacPlugin
{
const
const prefix: string
prefix
=
opts: LocalStorageOptions<S>
opts
.
LocalStorageOptions<S extends object>.prefix?: string | undefined

Storage key prefix. Defaults to "blac".

prefix
?? 'blac';
const
const targetNames: Set<string>
targetNames
= new
var Set: SetConstructor
new <string>(iterable?: Iterable<string> | null | undefined) => Set<string> (+1 overload)
Set
(
opts: LocalStorageOptions<S>
opts
.
LocalStorageOptions<S>.targets: StateContainerConstructor<S>[]

Classes to persist. State must be JSON-serializable. e.g. [CartCubit, ThemeCubit]

targets
.
Array<StateContainerConstructor<S>>.map<string>(callbackfn: (value: StateContainerConstructor<S>, index: number, array: StateContainerConstructor<S>[]) => string, thisArg?: any): string[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

map
((
type T: StateContainerConstructor<S>
T
) =>
type T: StateContainerConstructor<S>
T
.
Function.name: string

Returns the name of the function. Function names are read-only and can not be changed.

name
));
return {
BlacPlugin.name: string
name
: 'local-storage-persist',
BlacPlugin.version: string
version
: '1.0.0',
BlacPlugin.onCreated?(ctx: PluginContext): void

Fires when a container is first created and acquired. ctx.container is the new container.

onCreated
(
ctx: PluginContext
ctx
) {
const
const c: StateContainer<any, any, any> | undefined
c
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (!
const c: StateContainer<any, any, any> | undefined
c
|| !
const targetNames: Set<string>
targetNames
.
Set<string>.has(value: string): boolean

@returnsa boolean indicating whether an element with the specified value exists in the Set or not.

has
(
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.name: string
name
)) return;
const
const key: string
key
= `${
const prefix: string
prefix
}:${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.name: string
name
}:${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.id: string

Was instanceId.

id
}`;
try {
const
const raw: string | null
raw
=
var localStorage: Storage
localStorage
.
Storage.getItem(key: string): string | null

The getItem() method of the Storage interface, when passed a key name, will return that key's value, or null if the key does not exist, in the given Storage object.

MDN Reference

getItem
(
const key: string
key
);
if (
const raw: string | null
raw
!== null) {
const
const saved: S extends object
saved
=
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.
JSON.parse(text: string, reviver?: (this: any, key: string, value: any) => any): any

Converts a JavaScript Object Notation (JSON) string into an object.

@paramtext A valid JSON string.

@paramreviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is.

@throws{SyntaxError} If text is not valid JSON.

parse
(
const raw: string
raw
) as
function (type parameter) S in createLocalStoragePlugin<S extends object>(opts: LocalStorageOptions<S>): BlacPlugin
S
;
ctx: PluginContext
ctx
.
PluginContext.startHydration(instance: StateContainer<any, any, any>): void
startHydration
(
const c: StateContainer<any, any, any>
c
);
ctx: PluginContext
ctx
.
PluginContext.applyHydratedState<any>(instance: StateContainer<any, void, Record<string, never>>, state: any): boolean
applyHydratedState
(
const c: StateContainer<any, any, any>
c
,
const saved: S extends object
saved
);
ctx: PluginContext
ctx
.
PluginContext.finishHydration(instance: StateContainer<any, any, any>): void
finishHydration
(
const c: StateContainer<any, any, any>
c
);
}
} catch {
ctx: PluginContext
ctx
.
PluginContext.failHydration(instance: StateContainer<any, any, any>, error: Error): void
failHydration
(
const c: StateContainer<any, any, any>
c
, new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
('localStorage read failed'));
}
},
BlacPlugin.onStateChange?<S extends object = any>(ctx: PluginContext, prev: S, next: S, paths: PathSet): void

Fires once per channel flush with the changed PathSet. paths may be ALL_PATHS when the change spans every path. prev/next are the coalesced before/after states for the flush.

onStateChange
(
ctx: PluginContext
ctx
,
_prev: S extends object = any
_prev
,
next: S extends object = any
next
,
_paths: PathSet
_paths
:
type PathSet = Set<number> | typeof ALL_PATHS
PathSet
) {
const
const c: StateContainer<any, any, any> | undefined
c
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (!
const c: StateContainer<any, any, any> | undefined
c
|| !
const targetNames: Set<string>
targetNames
.
Set<string>.has(value: string): boolean

@returnsa boolean indicating whether an element with the specified value exists in the Set or not.

has
(
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.name: string
name
)) return;
const
const key: string
key
= `${
const prefix: string
prefix
}:${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.name: string
name
}:${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.id: string

Was instanceId.

id
}`;
try {
var localStorage: Storage
localStorage
.
Storage.setItem(key: string, value: string): void

The setItem() method of the Storage interface, when passed a key name and value, will add that key to the given Storage object, or update that key's value if it already exists.

MDN Reference

setItem
(
const key: string
key
,
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.
JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)

Converts a JavaScript value to a JavaScript Object Notation (JSON) string.

@paramvalue A JavaScript value, usually an object or array, to be converted.

@paramreplacer A function that transforms the results.

@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.

@throws{TypeError} If a circular reference or a BigInt value is found.

stringify
(
next: S extends object = any
next
));
} catch {
// Storage full or private-browsing restriction — fail silently.
}
},
};
}
// Install once at app startup.
// getPluginManager().install(createLocalStoragePlugin({ targets: [] }));

Wraps any synchronous write with a debounce so rapid state changes only trigger one write per quiet window. Useful when state changes on every keystroke and the save target (IndexedDB, a remote API) is slow.

import {
function getPluginManager(): PluginManager

Get the global plugin manager

getPluginManager
,
type
(alias) interface BlacPlugin
import BlacPlugin

BlaC plugin hook surface.

Per C2 (Decision 6), state-change events carry the PathSet of paths that changed in the flush. Lifecycle events (onCreated, onDestroyed) are not tied to a state change and so do not carry paths.

Hook firing model:

  • onCreated fires synchronously when a container is first acquired.
  • onStateChange fires once per channel flush (microtask-coalesced). prev is the state before the first emit of the flush; next is the state at flush time; paths is the set of paths marked during the flush.
  • onDestroyed fires synchronously when a container is disposed (after its system 'dispose' event).

Legacy ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are retained for devtools-connect compatibility — they are orthogonal to the C2 event payload contract.

BlacPlugin
,
type
type PathSet = Set<number> | typeof ALL_PATHS
PathSet
,
const ALL_PATHS: typeof ALL_PATHS
ALL_PATHS
,
} from '@blac/core';
// --- Recipe: debounced-save wrapper ---
type
type SaveFn = (key: string, state: unknown) => void
SaveFn
= (
key: string
key
: string,
state: unknown
state
: unknown) => void;
interface
interface DebouncedSaveOptions
DebouncedSaveOptions
{
/**
* Synchronous or async function that does the actual write.
* Called once per quiet window per instance.
*/
DebouncedSaveOptions.save: SaveFn

Synchronous or async function that does the actual write. Called once per quiet window per instance.

save
:
type SaveFn = (key: string, state: unknown) => void
SaveFn
;
/** Milliseconds of inactivity before flushing. Default: 300. */
DebouncedSaveOptions.debounceMs?: number | undefined

Milliseconds of inactivity before flushing. Default: 300.

debounceMs
?: number;
/** Key prefix. Default: "blac". */
DebouncedSaveOptions.prefix?: string | undefined

Key prefix. Default: "blac".

prefix
?: string;
/** Optional set of class names to include. Omit to include all. */
DebouncedSaveOptions.include?: string[] | undefined

Optional set of class names to include. Omit to include all.

include
?: string[];
}
function
function createDebouncedSavePlugin(opts: DebouncedSaveOptions): BlacPlugin
createDebouncedSavePlugin
(
opts: DebouncedSaveOptions
opts
:
interface DebouncedSaveOptions
DebouncedSaveOptions
):
(alias) interface BlacPlugin
import BlacPlugin

BlaC plugin hook surface.

Per C2 (Decision 6), state-change events carry the PathSet of paths that changed in the flush. Lifecycle events (onCreated, onDestroyed) are not tied to a state change and so do not carry paths.

Hook firing model:

  • onCreated fires synchronously when a container is first acquired.
  • onStateChange fires once per channel flush (microtask-coalesced). prev is the state before the first emit of the flush; next is the state at flush time; paths is the set of paths marked during the flush.
  • onDestroyed fires synchronously when a container is disposed (after its system 'dispose' event).

Legacy ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are retained for devtools-connect compatibility — they are orthogonal to the C2 event payload contract.

BlacPlugin
{
const
const debounceMs: number
debounceMs
=
opts: DebouncedSaveOptions
opts
.
DebouncedSaveOptions.debounceMs?: number | undefined

Milliseconds of inactivity before flushing. Default: 300.

debounceMs
?? 300;
const
const prefix: string
prefix
=
opts: DebouncedSaveOptions
opts
.
DebouncedSaveOptions.prefix?: string | undefined

Key prefix. Default: "blac".

prefix
?? 'blac';
const
const includeSet: Set<string> | null
includeSet
=
opts: DebouncedSaveOptions
opts
.
DebouncedSaveOptions.include?: string[] | undefined

Optional set of class names to include. Omit to include all.

include
? new
var Set: SetConstructor
new <string>(iterable?: Iterable<string> | null | undefined) => Set<string> (+1 overload)
Set
(
opts: DebouncedSaveOptions
opts
.
DebouncedSaveOptions.include?: string[]

Optional set of class names to include. Omit to include all.

include
) : null;
const
const timers: Map<string, number>
timers
= new
var Map: MapConstructor
new <string, number>(iterable?: Iterable<readonly [string, number]> | null | undefined) => Map<string, number> (+3 overloads)
Map
<string,
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any

Obtain the return type of a function type

ReturnType
<typeof
function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number
setTimeout
>>();
return {
BlacPlugin.name: string
name
: 'debounced-save',
BlacPlugin.version: string
version
: '1.0.0',
BlacPlugin.onStateChange?<S extends object = any>(ctx: PluginContext, prev: S, next: S, paths: PathSet): void

Fires once per channel flush with the changed PathSet. paths may be ALL_PATHS when the change spans every path. prev/next are the coalesced before/after states for the flush.

onStateChange
(
ctx: PluginContext
ctx
,
_prev: S extends object = any
_prev
,
next: S extends object = any
next
,
_paths: PathSet
_paths
:
type PathSet = Set<number> | typeof ALL_PATHS
PathSet
) {
const
const c: StateContainer<any, any, any> | undefined
c
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (!
const c: StateContainer<any, any, any> | undefined
c
) return;
if (
const includeSet: Set<string> | null
includeSet
&& !
const includeSet: Set<string>
includeSet
.
Set<string>.has(value: string): boolean

@returnsa boolean indicating whether an element with the specified value exists in the Set or not.

has
(
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
)) return;
const
const key: string
key
= `${
const prefix: string
prefix
}:${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
}:${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.id: string

Was instanceId.

id
}`;
const
const existing: number | undefined
existing
=
const timers: Map<string, number>
timers
.
Map<string, number>.get(key: string): number | undefined

Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.

@returnsReturns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.

get
(
const key: string
key
);
if (
const existing: number | undefined
existing
!==
var undefined
undefined
)
function clearTimeout(id: number | undefined): void
clearTimeout
(
const existing: number
existing
);
const timers: Map<string, number>
timers
.
Map<string, number>.set(key: string, value: number): Map<string, number>

Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated.

set
(
const key: string
key
,
function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number
setTimeout
(() => {
const timers: Map<string, number>
timers
.
Map<string, number>.delete(key: string): boolean

@returnstrue if an element in the Map existed and has been removed, or false if the element does not exist.

delete
(
const key: string
key
);
opts: DebouncedSaveOptions
opts
.
DebouncedSaveOptions.save: (key: string, state: unknown) => void

Synchronous or async function that does the actual write. Called once per quiet window per instance.

save
(
const key: string
key
,
next: S extends object = any
next
);
},
const debounceMs: number
debounceMs
),
);
},
BlacPlugin.onDestroyed?(ctx: PluginContext): void

Fires when a container is disposed. ctx.container is the disposed container (still queryable, but its isDisposed is true by the time this fires).

onDestroyed
(
ctx: PluginContext
ctx
) {
const
const c: StateContainer<any, any, any> | undefined
c
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (!
const c: StateContainer<any, any, any> | undefined
c
) return;
const
const key: string
key
= `${
const prefix: string
prefix
}:${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
}:${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.id: string

Was instanceId.

id
}`;
const
const existing: number | undefined
existing
=
const timers: Map<string, number>
timers
.
Map<string, number>.get(key: string): number | undefined

Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.

@returnsReturns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.

get
(
const key: string
key
);
if (
const existing: number | undefined
existing
!==
var undefined
undefined
) {
// Flush immediately on disposal — don't lose the last state.
function clearTimeout(id: number | undefined): void
clearTimeout
(
const existing: number
existing
);
const timers: Map<string, number>
timers
.
Map<string, number>.delete(key: string): boolean

@returnstrue if an element in the Map existed and has been removed, or false if the element does not exist.

delete
(
const key: string
key
);
opts: DebouncedSaveOptions
opts
.
DebouncedSaveOptions.save: (key: string, state: unknown) => void

Synchronous or async function that does the actual write. Called once per quiet window per instance.

save
(
const key: string
key
,
const c: StateContainer<any, any, any>
c
.
StructuralContainer<any>.state: any
state
);
}
},
};
}
// Example: write to localStorage with a 500 ms debounce.
const
const debouncedPlugin: BlacPlugin
debouncedPlugin
=
function createDebouncedSavePlugin(opts: DebouncedSaveOptions): BlacPlugin
createDebouncedSavePlugin
({
DebouncedSaveOptions.save: SaveFn

Synchronous or async function that does the actual write. Called once per quiet window per instance.

save
: (
key: string
key
,
state: unknown
state
) =>
var localStorage: Storage
localStorage
.
Storage.setItem(key: string, value: string): void

The setItem() method of the Storage interface, when passed a key name and value, will add that key to the given Storage object, or update that key's value if it already exists.

MDN Reference

setItem
(
key: string
key
,
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.
JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)

Converts a JavaScript value to a JavaScript Object Notation (JSON) string.

@paramvalue A JavaScript value, usually an object or array, to be converted.

@paramreplacer A function that transforms the results.

@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.

@throws{TypeError} If a circular reference or a BigInt value is found.

stringify
(
state: unknown
state
)),
DebouncedSaveOptions.debounceMs?: number | undefined

Milliseconds of inactivity before flushing. Default: 300.

debounceMs
: 500,
DebouncedSaveOptions.include?: string[] | undefined

Optional set of class names to include. Omit to include all.

include
: ['CartCubit', 'DraftCubit'],
});
// Install once at app startup.
// getPluginManager().install(debouncedPlugin);

Broadcasts state changes to other browser tabs via BroadcastChannel and applies incoming updates through the hydration API. Use this when multiple tabs show the same app and you want them to stay in sync (e.g. a shopping cart or an auth session).

import {
function getPluginManager(): PluginManager

Get the global plugin manager

getPluginManager
,
type
(alias) interface BlacPlugin
import BlacPlugin

BlaC plugin hook surface.

Per C2 (Decision 6), state-change events carry the PathSet of paths that changed in the flush. Lifecycle events (onCreated, onDestroyed) are not tied to a state change and so do not carry paths.

Hook firing model:

  • onCreated fires synchronously when a container is first acquired.
  • onStateChange fires once per channel flush (microtask-coalesced). prev is the state before the first emit of the flush; next is the state at flush time; paths is the set of paths marked during the flush.
  • onDestroyed fires synchronously when a container is disposed (after its system 'dispose' event).

Legacy ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are retained for devtools-connect compatibility — they are orthogonal to the C2 event payload contract.

BlacPlugin
,
type
type PathSet = Set<number> | typeof ALL_PATHS
PathSet
,
const ALL_PATHS: typeof ALL_PATHS
ALL_PATHS
,
type
type StateContainerConstructor<S extends object = any> = new (...args: any[]) => StateContainer<S, any, any>

Constructor type for StateContainer classes

@templateS - State type managed by the container

StateContainerConstructor
,
} from '@blac/core';
// --- Recipe: cross-tab sync via BroadcastChannel ---
interface
interface CrossTabSyncOptions<S extends object>
CrossTabSyncOptions
<
function (type parameter) S in CrossTabSyncOptions<S extends object>
S
extends object> {
/** Classes to sync across tabs. */
CrossTabSyncOptions<S extends object>.targets: StateContainerConstructor<S>[]

Classes to sync across tabs.

targets
:
type StateContainerConstructor<S extends object = any> = new (...args: any[]) => StateContainer<S, any, any>

Constructor type for StateContainer classes

@templateS - State type managed by the container

StateContainerConstructor
<
function (type parameter) S in CrossTabSyncOptions<S extends object>
S
>[];
/** BroadcastChannel name. Default: "blac-sync". */
CrossTabSyncOptions<S extends object>.channelName?: string | undefined

BroadcastChannel name. Default: "blac-sync".

channelName
?: string;
/**
* Optional transform applied before broadcasting.
* Use this to strip PII or tokens before the message leaves the tab.
*/
CrossTabSyncOptions<S extends object>.serialize?: ((state: S) => unknown) | undefined

Optional transform applied before broadcasting. Use this to strip PII or tokens before the message leaves the tab.

serialize
?: (
state: S extends object
state
:
function (type parameter) S in CrossTabSyncOptions<S extends object>
S
) => unknown;
/** Optional transform applied when receiving a message. */
CrossTabSyncOptions<S extends object>.deserialize?: ((payload: unknown) => S) | undefined

Optional transform applied when receiving a message.

deserialize
?: (
payload: unknown
payload
: unknown) =>
function (type parameter) S in CrossTabSyncOptions<S extends object>
S
;
}
interface
interface SyncMessage
SyncMessage
{
SyncMessage.type: "state-update"
type
: 'state-update';
SyncMessage.className: string
className
: string;
SyncMessage.instanceId: string
instanceId
: string;
SyncMessage.state: unknown
state
: unknown;
}
function
function createCrossTabSyncPlugin<S extends object>(opts: CrossTabSyncOptions<S>): BlacPlugin
createCrossTabSyncPlugin
<
function (type parameter) S in createCrossTabSyncPlugin<S extends object>(opts: CrossTabSyncOptions<S>): BlacPlugin
S
extends object>(
opts: CrossTabSyncOptions<S>
opts
:
interface CrossTabSyncOptions<S extends object>
CrossTabSyncOptions
<
function (type parameter) S in createCrossTabSyncPlugin<S extends object>(opts: CrossTabSyncOptions<S>): BlacPlugin
S
>,
):
(alias) interface BlacPlugin
import BlacPlugin

BlaC plugin hook surface.

Per C2 (Decision 6), state-change events carry the PathSet of paths that changed in the flush. Lifecycle events (onCreated, onDestroyed) are not tied to a state change and so do not carry paths.

Hook firing model:

  • onCreated fires synchronously when a container is first acquired.
  • onStateChange fires once per channel flush (microtask-coalesced). prev is the state before the first emit of the flush; next is the state at flush time; paths is the set of paths marked during the flush.
  • onDestroyed fires synchronously when a container is disposed (after its system 'dispose' event).

Legacy ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are retained for devtools-connect compatibility — they are orthogonal to the C2 event payload contract.

BlacPlugin
{
const
const channelName: string
channelName
=
opts: CrossTabSyncOptions<S>
opts
.
CrossTabSyncOptions<S extends object>.channelName?: string | undefined

BroadcastChannel name. Default: "blac-sync".

channelName
?? 'blac-sync';
const
const targetNames: Set<string>
targetNames
= new
var Set: SetConstructor
new <string>(iterable?: Iterable<string> | null | undefined) => Set<string> (+1 overload)
Set
(
opts: CrossTabSyncOptions<S>
opts
.
CrossTabSyncOptions<S>.targets: StateContainerConstructor<S>[]

Classes to sync across tabs.

targets
.
Array<StateContainerConstructor<S>>.map<string>(callbackfn: (value: StateContainerConstructor<S>, index: number, array: StateContainerConstructor<S>[]) => string, thisArg?: any): string[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

map
((
type T: StateContainerConstructor<S>
T
) =>
type T: StateContainerConstructor<S>
T
.
Function.name: string

Returns the name of the function. Function names are read-only and can not be changed.

name
));
let
let channel: BroadcastChannel | null
channel
:
interface BroadcastChannel

The BroadcastChannel interface represents a named channel that any browsing context of a given origin can subscribe to. It allows communication between different documents (in different windows, tabs, frames or iframes) of the same origin. Messages are broadcasted via a message event fired at all BroadcastChannel objects listening to the channel, except the object that sent the message.

MDN Reference

BroadcastChannel
| null = null;
// Track which instance keys are receiving a remote update so we don't
// echo the incoming state back out to other tabs.
const
const receiving: Set<string>
receiving
= new
var Set: SetConstructor
new <string>(iterable?: Iterable<string> | null | undefined) => Set<string> (+1 overload)
Set
<string>();
return {
BlacPlugin.name: string
name
: 'cross-tab-sync',
BlacPlugin.version: string
version
: '1.0.0',
BlacPlugin.onInstall?(ctx: PluginContext): void

Fires once when the plugin is installed. ctx.container is undefined here — onInstall is global to the plugin, not per-container.

onInstall
(
ctx: PluginContext
ctx
) {
if (typeof
var BroadcastChannel: {
new (name: string): BroadcastChannel;
prototype: BroadcastChannel;
}

The BroadcastChannel interface represents a named channel that any browsing context of a given origin can subscribe to. It allows communication between different documents (in different windows, tabs, frames or iframes) of the same origin. Messages are broadcasted via a message event fired at all BroadcastChannel objects listening to the channel, except the object that sent the message.

MDN Reference

BroadcastChannel
=== 'undefined') return;
let channel: BroadcastChannel | null
channel
= new
var BroadcastChannel: new (name: string) => BroadcastChannel

The BroadcastChannel interface represents a named channel that any browsing context of a given origin can subscribe to. It allows communication between different documents (in different windows, tabs, frames or iframes) of the same origin. Messages are broadcasted via a message event fired at all BroadcastChannel objects listening to the channel, except the object that sent the message.

MDN Reference

BroadcastChannel
(
const channelName: string
channelName
);
let channel: BroadcastChannel
channel
.
BroadcastChannel.onmessage: ((this: BroadcastChannel, ev: MessageEvent) => any) | null
onmessage
= (
event: MessageEvent<SyncMessage>
event
:
interface MessageEvent<T = any>

The MessageEvent interface represents a message received by a target object.

MDN Reference

MessageEvent
<
interface SyncMessage
SyncMessage
>) => {
const
const msg: SyncMessage
msg
=
event: MessageEvent<SyncMessage>
event
.
MessageEvent<SyncMessage>.data: SyncMessage

The data read-only property of the MessageEvent interface represents the data sent by the message emitter.

MDN Reference

data
;
if (
const msg: SyncMessage
msg
?.
SyncMessage.type: "state-update"
type
!== 'state-update') return;
if (!
const targetNames: Set<string>
targetNames
.
Set<string>.has(value: string): boolean

@returnsa boolean indicating whether an element with the specified value exists in the Set or not.

has
(
const msg: SyncMessage
msg
.
SyncMessage.className: string
className
)) return;
const
const instances: StateContainer<S, any, any>[]
instances
=
ctx: PluginContext
ctx
.
PluginContext.queryInstances<StateContainer<S, any, any>>(typeClass: new (...args: any[]) => StateContainer<S, any, any>): StateContainer<S, any, any>[]
queryInstances
(
// Use the metadata helper to match by name since we only have strings.
// queryInstances needs the constructor — skip if class not in registry.
opts: CrossTabSyncOptions<S>
opts
.
CrossTabSyncOptions<S>.targets: StateContainerConstructor<S>[]

Classes to sync across tabs.

targets
.
Array<StateContainerConstructor<S>>.find(predicate: (value: StateContainerConstructor<S>, index: number, obj: StateContainerConstructor<S>[]) => unknown, thisArg?: any): StateContainerConstructor<S> | undefined (+1 overload)

Returns the value of the first element in the array where predicate is true, and undefined otherwise.

@parampredicate find calls predicate once for each element of the array, in ascending order, until it finds one where predicate returns true. If such an element is found, find immediately returns that element value. Otherwise, find returns undefined.

@paramthisArg If provided, it will be used as the this value for each invocation of predicate. If it is not provided, undefined is used instead.

find
(
(
type T: StateContainerConstructor<S>
T
) =>
type T: StateContainerConstructor<S>
T
.
Function.name: string

Returns the name of the function. Function names are read-only and can not be changed.

name
===
const msg: SyncMessage
msg
.
SyncMessage.className: string
className
,
) as
type StateContainerConstructor<S extends object = any> = new (...args: any[]) => StateContainer<S, any, any>

Constructor type for StateContainer classes

@templateS - State type managed by the container

StateContainerConstructor
<
function (type parameter) S in createCrossTabSyncPlugin<S extends object>(opts: CrossTabSyncOptions<S>): BlacPlugin
S
>,
);
for (const
const inst: StateContainer<S, any, any>
inst
of
const instances: StateContainer<S, any, any>[]
instances
) {
if (
const inst: StateContainer<S, any, any>
inst
.
StateContainer<S, any, any>.$blac: BlacMeta<S>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.id: string

Was instanceId.

id
!==
const msg: SyncMessage
msg
.
SyncMessage.instanceId: string
instanceId
) continue;
const
const key: string
key
= `${
const msg: SyncMessage
msg
.
SyncMessage.className: string
className
}:${
const msg: SyncMessage
msg
.
SyncMessage.instanceId: string
instanceId
}`;
const receiving: Set<string>
receiving
.
Set<string>.add(value: string): Set<string>

Appends a new element with a specified value to the end of the Set.

add
(
const key: string
key
);
const
const next: S extends object
next
=
opts: CrossTabSyncOptions<S>
opts
.
CrossTabSyncOptions<S>.deserialize?: ((payload: unknown) => S) | undefined

Optional transform applied when receiving a message.

deserialize
?
opts: CrossTabSyncOptions<S>
opts
.
CrossTabSyncOptions<S>.deserialize?: (payload: unknown) => S

Optional transform applied when receiving a message.

deserialize
(
const msg: SyncMessage
msg
.
SyncMessage.state: unknown
state
)
: (
const msg: SyncMessage
msg
.
SyncMessage.state: unknown
state
as
function (type parameter) S in createCrossTabSyncPlugin<S extends object>(opts: CrossTabSyncOptions<S>): BlacPlugin
S
);
ctx: PluginContext
ctx
.
PluginContext.startHydration(instance: StateContainer<any, any, any>): void
startHydration
(
const inst: StateContainer<S, any, any>
inst
);
ctx: PluginContext
ctx
.
PluginContext.applyHydratedState<S>(instance: StateContainer<S, void, Record<string, never>>, state: S): boolean
applyHydratedState
(
const inst: StateContainer<S, any, any>
inst
,
const next: S extends object
next
);
ctx: PluginContext
ctx
.
PluginContext.finishHydration(instance: StateContainer<any, any, any>): void
finishHydration
(
const inst: StateContainer<S, any, any>
inst
);
// Remove after the microtask so onStateChange fires first.
var Promise: PromiseConstructor

Represents the completion of an asynchronous operation

Promise
.
PromiseConstructor.resolve(): Promise<void> (+2 overloads)

Creates a new resolved promise.

@returnsA resolved promise.

resolve
().
Promise<void>.then<boolean, never>(onfulfilled?: ((value: void) => boolean | PromiseLike<boolean>) | null | undefined, onrejected?: ((reason: any) => PromiseLike<never>) | null | undefined): Promise<boolean>

Attaches callbacks for the resolution and/or rejection of the Promise.

@paramonfulfilled The callback to execute when the Promise is resolved.

@paramonrejected The callback to execute when the Promise is rejected.

@returnsA Promise for the completion of which ever callback is executed.

then
(() =>
const receiving: Set<string>
receiving
.
Set<string>.delete(value: string): boolean

Removes a specified value from the Set.

@returnsReturns true if an element in the Set existed and has been removed, or false if the element does not exist.

delete
(
const key: string
key
));
}
};
},
BlacPlugin.onUninstall?(): void
onUninstall
() {
let channel: BroadcastChannel | null
channel
?.
BroadcastChannel.close(): void

The close() method of the BroadcastChannel interface terminates the connection to the underlying channel, allowing the object to be garbage collected. This is a necessary step to perform as there is no other way for a browser to know that this channel is not needed anymore.

MDN Reference

close
();
let channel: BroadcastChannel | null
channel
= null;
},
BlacPlugin.onStateChange?<S extends object = any>(ctx: PluginContext, prev: S, next: S, paths: PathSet): void

Fires once per channel flush with the changed PathSet. paths may be ALL_PATHS when the change spans every path. prev/next are the coalesced before/after states for the flush.

onStateChange
(
ctx: PluginContext
ctx
,
_prev: S extends object = any
_prev
,
next: S extends object = any
next
,
_paths: PathSet
_paths
:
type PathSet = Set<number> | typeof ALL_PATHS
PathSet
) {
const
const c: StateContainer<any, any, any> | undefined
c
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (!
const c: StateContainer<any, any, any> | undefined
c
|| !
let channel: BroadcastChannel | null
channel
) return;
if (!
const targetNames: Set<string>
targetNames
.
Set<string>.has(value: string): boolean

@returnsa boolean indicating whether an element with the specified value exists in the Set or not.

has
(
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.name: string
name
)) return;
const
const key: string
key
= `${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.name: string
name
}:${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.id: string

Was instanceId.

id
}`;
if (
const receiving: Set<string>
receiving
.
Set<string>.has(value: string): boolean

@returnsa boolean indicating whether an element with the specified value exists in the Set or not.

has
(
const key: string
key
)) return; // avoid echo
const
const payload: unknown
payload
=
opts: CrossTabSyncOptions<S>
opts
.
CrossTabSyncOptions<S>.serialize?: ((state: S) => unknown) | undefined

Optional transform applied before broadcasting. Use this to strip PII or tokens before the message leaves the tab.

serialize
?
opts: CrossTabSyncOptions<S>
opts
.
CrossTabSyncOptions<S>.serialize?: (state: S) => unknown

Optional transform applied before broadcasting. Use this to strip PII or tokens before the message leaves the tab.

serialize
(
next: S extends object = any
next
as unknown as
function (type parameter) S in createCrossTabSyncPlugin<S extends object>(opts: CrossTabSyncOptions<S>): BlacPlugin
S
)
:
next: S extends object = any
next
;
const
const msg: SyncMessage
msg
:
interface SyncMessage
SyncMessage
= {
SyncMessage.type: "state-update"
type
: 'state-update',
SyncMessage.className: string
className
:
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.name: string
name
,
SyncMessage.instanceId: string
instanceId
:
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<S extends object = any>.id: string

Was instanceId.

id
,
SyncMessage.state: unknown
state
:
const payload: unknown
payload
,
};
let channel: BroadcastChannel
channel
.
BroadcastChannel.postMessage(message: any): void

The postMessage() method of the BroadcastChannel interface sends a message, which can be of any kind of Object, to each listener in any browsing context with the same origin. The message is transmitted as a message event targeted at each BroadcastChannel bound to the channel.

MDN Reference

postMessage
(
const msg: SyncMessage
msg
);
},
};
}
// Install once at app startup.
// getPluginManager().install(createCrossTabSyncPlugin({ targets: [] }));

Adds a Sentry breadcrumb for each state change. Because breadcrumbs are sent with every event, this gives you a state-change timeline attached to error reports — similar to Redux DevTools’ action history but visible in Sentry.

import { getPluginManager } from '@blac/core';
import type { BlacPlugin, PathSet } from '@blac/core';
import * as Sentry from '@sentry/browser';
// --- Recipe: Sentry breadcrumb sink ---
interface SentryPluginOptions {
/** Classes to include. Omit to include all blocs. */
include?: string[];
/** Classes to exclude. */
exclude?: string[];
/**
* Strip sensitive fields from state before attaching to the breadcrumb.
* Called with the full next state; return the shape you want Sentry to see.
* REQUIRED if your state contains PII, tokens, or health data.
*/
sanitize?: (state: unknown, className: string) => unknown;
}
function createSentryPlugin(opts: SentryPluginOptions = {}): BlacPlugin {
const includeSet = opts.include ? new Set(opts.include) : null;
const excludeSet = opts.exclude ? new Set(opts.exclude) : null;
return {
name: 'sentry-breadcrumbs',
version: '1.0.0',
onStateChange(ctx, _prev, next, _paths: PathSet) {
const c = ctx.container;
if (!c) return;
if (includeSet && !includeSet.has(c.$blac.name)) return;
if (excludeSet && excludeSet.has(c.$blac.name)) return;
const data = opts.sanitize ? opts.sanitize(next, c.$blac.name) : next;
Sentry.addBreadcrumb({
category: 'blac.state',
message: `${c.$blac.name} changed`,
level: 'info',
data: data as Record<string, unknown>,
});
},
};
}
// Install once at app startup. Gate to production so dev overhead stays low.
// getPluginManager().install(createSentryPlugin({
// sanitize: (state, name) => {
// if (name === 'AuthCubit') {
// const s = state as { user: unknown; token: unknown };
// return { user: s.user, token: '[redacted]' };
// }
// return state;
// },
// }), { environment: 'production' });

Writes a structured log entry for every state change, creation, and disposal. Useful for compliance or debugging — you can pipe entries to your existing logging infrastructure, a remote endpoint, or a circular buffer.

import {
function getPluginManager(): PluginManager

Get the global plugin manager

getPluginManager
,
type
(alias) interface BlacPlugin
import BlacPlugin

BlaC plugin hook surface.

Per C2 (Decision 6), state-change events carry the PathSet of paths that changed in the flush. Lifecycle events (onCreated, onDestroyed) are not tied to a state change and so do not carry paths.

Hook firing model:

  • onCreated fires synchronously when a container is first acquired.
  • onStateChange fires once per channel flush (microtask-coalesced). prev is the state before the first emit of the flush; next is the state at flush time; paths is the set of paths marked during the flush.
  • onDestroyed fires synchronously when a container is disposed (after its system 'dispose' event).

Legacy ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are retained for devtools-connect compatibility — they are orthogonal to the C2 event payload contract.

BlacPlugin
,
type
type PathSet = Set<number> | typeof ALL_PATHS
PathSet
,
const ALL_PATHS: typeof ALL_PATHS
ALL_PATHS
,
} from '@blac/core';
// --- Recipe: structured audit log ---
type
type AuditEventKind = "created" | "state-changed" | "disposed"
AuditEventKind
= 'created' | 'state-changed' | 'disposed';
interface
interface AuditEntry
AuditEntry
{
AuditEntry.kind: AuditEventKind
kind
:
type AuditEventKind = "created" | "state-changed" | "disposed"
AuditEventKind
;
AuditEntry.className: string
className
: string;
AuditEntry.instanceId: string
instanceId
: string;
AuditEntry.timestamp: number
timestamp
: number;
AuditEntry.prev?: unknown
prev
?: unknown;
AuditEntry.next?: unknown
next
?: unknown;
/** Human-readable changed paths, or "(all)" for a full-replace. */
AuditEntry.paths?: string[] | undefined

Human-readable changed paths, or "(all)" for a full-replace.

paths
?: string[];
}
interface
interface AuditLogOptions
AuditLogOptions
{
/**
* Called with each new entry. Wire to console.log, a remote endpoint,
* or push into a circular buffer.
*/
AuditLogOptions.onEntry: (entry: AuditEntry) => void

Called with each new entry. Wire to console.log, a remote endpoint, or push into a circular buffer.

onEntry
: (
entry: AuditEntry
entry
:
interface AuditEntry
AuditEntry
) => void;
/** Optional per-entry sanitizer. Strip PII before logging externally. */
AuditLogOptions.sanitize?: ((state: unknown, className: string) => unknown) | undefined

Optional per-entry sanitizer. Strip PII before logging externally.

sanitize
?: (
state: unknown
state
: unknown,
className: string
className
: string) => unknown;
/** Optional allowlist of class names to audit. */
AuditLogOptions.include?: string[] | undefined

Optional allowlist of class names to audit.

include
?: string[];
/** Optional denylist of class names to skip. */
AuditLogOptions.exclude?: string[] | undefined

Optional denylist of class names to skip.

exclude
?: string[];
}
function
function createAuditLogPlugin(opts: AuditLogOptions): BlacPlugin
createAuditLogPlugin
(
opts: AuditLogOptions
opts
:
interface AuditLogOptions
AuditLogOptions
):
(alias) interface BlacPlugin
import BlacPlugin

BlaC plugin hook surface.

Per C2 (Decision 6), state-change events carry the PathSet of paths that changed in the flush. Lifecycle events (onCreated, onDestroyed) are not tied to a state change and so do not carry paths.

Hook firing model:

  • onCreated fires synchronously when a container is first acquired.
  • onStateChange fires once per channel flush (microtask-coalesced). prev is the state before the first emit of the flush; next is the state at flush time; paths is the set of paths marked during the flush.
  • onDestroyed fires synchronously when a container is disposed (after its system 'dispose' event).

Legacy ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are retained for devtools-connect compatibility — they are orthogonal to the C2 event payload contract.

BlacPlugin
{
const
const includeSet: Set<string> | null
includeSet
=
opts: AuditLogOptions
opts
.
AuditLogOptions.include?: string[] | undefined

Optional allowlist of class names to audit.

include
? new
var Set: SetConstructor
new <string>(iterable?: Iterable<string> | null | undefined) => Set<string> (+1 overload)
Set
(
opts: AuditLogOptions
opts
.
AuditLogOptions.include?: string[]

Optional allowlist of class names to audit.

include
) : null;
const
const excludeSet: Set<string> | null
excludeSet
=
opts: AuditLogOptions
opts
.
AuditLogOptions.exclude?: string[] | undefined

Optional denylist of class names to skip.

exclude
? new
var Set: SetConstructor
new <string>(iterable?: Iterable<string> | null | undefined) => Set<string> (+1 overload)
Set
(
opts: AuditLogOptions
opts
.
AuditLogOptions.exclude?: string[]

Optional denylist of class names to skip.

exclude
) : null;
const
const allowed: (name: string) => boolean
allowed
= (
name: string
name
: string) => {
if (
const includeSet: Set<string> | null
includeSet
&& !
const includeSet: Set<string>
includeSet
.
Set<string>.has(value: string): boolean

@returnsa boolean indicating whether an element with the specified value exists in the Set or not.

has
(
name: string
name
)) return false;
if (
const excludeSet: Set<string> | null
excludeSet
&&
const excludeSet: Set<string>
excludeSet
.
Set<string>.has(value: string): boolean

@returnsa boolean indicating whether an element with the specified value exists in the Set or not.

has
(
name: string
name
)) return false;
return true;
};
const
const san: (state: unknown, name: string) => unknown
san
= (
state: unknown
state
: unknown,
name: string
name
: string) =>
opts: AuditLogOptions
opts
.
AuditLogOptions.sanitize?: ((state: unknown, className: string) => unknown) | undefined

Optional per-entry sanitizer. Strip PII before logging externally.

sanitize
?
opts: AuditLogOptions
opts
.
AuditLogOptions.sanitize?: (state: unknown, className: string) => unknown

Optional per-entry sanitizer. Strip PII before logging externally.

sanitize
(
state: unknown
state
,
name: string
name
) :
state: unknown
state
;
return {
BlacPlugin.name: string
name
: 'audit-log',
BlacPlugin.version: string
version
: '1.0.0',
BlacPlugin.onCreated?(ctx: PluginContext): void

Fires when a container is first created and acquired. ctx.container is the new container.

onCreated
(
ctx: PluginContext
ctx
) {
const
const c: StateContainer<any, any, any> | undefined
c
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (!
const c: StateContainer<any, any, any> | undefined
c
|| !
const allowed: (name: string) => boolean
allowed
(
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
)) return;
opts: AuditLogOptions
opts
.
AuditLogOptions.onEntry: (entry: AuditEntry) => void

Called with each new entry. Wire to console.log, a remote endpoint, or push into a circular buffer.

onEntry
({
AuditEntry.kind: AuditEventKind
kind
: 'created',
AuditEntry.className: string
className
:
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
,
AuditEntry.instanceId: string
instanceId
:
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.id: string

Was instanceId.

id
,
AuditEntry.timestamp: number
timestamp
:
var Date: DateConstructor

Enables basic storage and retrieval of dates and times.

Date
.
DateConstructor.now(): number

Returns the number of milliseconds elapsed since midnight, January 1, 1970 Universal Coordinated Time (UTC).

now
(),
AuditEntry.next?: unknown
next
:
const san: (state: unknown, name: string) => unknown
san
(
const c: StateContainer<any, any, any>
c
.
StructuralContainer<any>.state: any
state
,
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
),
});
},
BlacPlugin.onStateChange?<S extends object = any>(ctx: PluginContext, prev: S, next: S, paths: PathSet): void

Fires once per channel flush with the changed PathSet. paths may be ALL_PATHS when the change spans every path. prev/next are the coalesced before/after states for the flush.

onStateChange
(
ctx: PluginContext
ctx
,
prev: S extends object = any
prev
,
next: S extends object = any
next
,
paths: PathSet
paths
:
type PathSet = Set<number> | typeof ALL_PATHS
PathSet
) {
const
const c: StateContainer<any, any, any> | undefined
c
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (!
const c: StateContainer<any, any, any> | undefined
c
|| !
const allowed: (name: string) => boolean
allowed
(
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
)) return;
const
const changedPaths: string[]
changedPaths
=
paths: PathSet
paths
===
const ALL_PATHS: typeof ALL_PATHS
ALL_PATHS
? ['(all)']
: [...
paths: Set<number>
paths
].
Array<number>.map<string>(callbackfn: (value: number, index: number, array: number[]) => string, thisArg?: any): string[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

map
((
id: number
id
) =>
const c: StateContainer<any, any, any>
c
.
StructuralContainer<any>.interner: PathInterner
interner
.
PathInterner.lookup(id: PathId): string
lookup
(
id: number
id
));
opts: AuditLogOptions
opts
.
AuditLogOptions.onEntry: (entry: AuditEntry) => void

Called with each new entry. Wire to console.log, a remote endpoint, or push into a circular buffer.

onEntry
({
AuditEntry.kind: AuditEventKind
kind
: 'state-changed',
AuditEntry.className: string
className
:
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
,
AuditEntry.instanceId: string
instanceId
:
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.id: string

Was instanceId.

id
,
AuditEntry.timestamp: number
timestamp
:
var Date: DateConstructor

Enables basic storage and retrieval of dates and times.

Date
.
DateConstructor.now(): number

Returns the number of milliseconds elapsed since midnight, January 1, 1970 Universal Coordinated Time (UTC).

now
(),
AuditEntry.prev?: unknown
prev
:
const san: (state: unknown, name: string) => unknown
san
(
prev: S extends object = any
prev
,
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
),
AuditEntry.next?: unknown
next
:
const san: (state: unknown, name: string) => unknown
san
(
next: S extends object = any
next
,
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
),
AuditEntry.paths?: string[] | undefined

Human-readable changed paths, or "(all)" for a full-replace.

paths
:
const changedPaths: string[]
changedPaths
,
});
},
BlacPlugin.onDestroyed?(ctx: PluginContext): void

Fires when a container is disposed. ctx.container is the disposed container (still queryable, but its isDisposed is true by the time this fires).

onDestroyed
(
ctx: PluginContext
ctx
) {
const
const c: StateContainer<any, any, any> | undefined
c
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (!
const c: StateContainer<any, any, any> | undefined
c
|| !
const allowed: (name: string) => boolean
allowed
(
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
)) return;
opts: AuditLogOptions
opts
.
AuditLogOptions.onEntry: (entry: AuditEntry) => void

Called with each new entry. Wire to console.log, a remote endpoint, or push into a circular buffer.

onEntry
({
AuditEntry.kind: AuditEventKind
kind
: 'disposed',
AuditEntry.className: string
className
:
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
,
AuditEntry.instanceId: string
instanceId
:
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.id: string

Was instanceId.

id
,
AuditEntry.timestamp: number
timestamp
:
var Date: DateConstructor

Enables basic storage and retrieval of dates and times.

Date
.
DateConstructor.now(): number

Returns the number of milliseconds elapsed since midnight, January 1, 1970 Universal Coordinated Time (UTC).

now
(),
AuditEntry.prev?: unknown
prev
:
const san: (state: unknown, name: string) => unknown
san
(
const c: StateContainer<any, any, any>
c
.
StructuralContainer<any>.state: any
state
,
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

Reserved meta namespace: identity (name/id/debug/createdAt), lifecycle (disposed/dependencies), and the hydration sub-surface. Own, frozen, branded data property — buildTrackedProxy only intercepts prototype getters, so this own property and its closure-based getters are proxy-safe. See

createMeta

.

$blac
.
BlacMeta<any>.name: string
name
),
});
},
};
}
// Example: log to console with a circular buffer of the last 100 entries.
const
const auditEntries: AuditEntry[]
auditEntries
:
interface AuditEntry
AuditEntry
[] = [];
function getPluginManager(): PluginManager

Get the global plugin manager

getPluginManager
().
PluginManager.install(plugin: BlacPlugin, config?: PluginConfig): void

Install a plugin with optional configuration

@paramplugin - The plugin to install

@paramconfig - Optional plugin configuration

@throwsError if plugin is already installed

install
(
function createAuditLogPlugin(opts: AuditLogOptions): BlacPlugin
createAuditLogPlugin
({
AuditLogOptions.onEntry: (entry: AuditEntry) => void

Called with each new entry. Wire to console.log, a remote endpoint, or push into a circular buffer.

onEntry
(
entry: AuditEntry
entry
) {
if (
const auditEntries: AuditEntry[]
auditEntries
.
Array<T>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
>= 100)
const auditEntries: AuditEntry[]
auditEntries
.
Array<AuditEntry>.shift(): AuditEntry | undefined

Removes the first element from an array and returns it. If the array is empty, undefined is returned and the array is not modified.

shift
();
const auditEntries: AuditEntry[]
auditEntries
.
Array<AuditEntry>.push(...items: AuditEntry[]): number

Appends new elements to the end of an array, and returns the new length of the array.

@paramitems New elements to add to the array.

push
(
entry: AuditEntry
entry
);
var console: Console
console
.
Console.debug(...data: any[]): void

The console.debug() static method outputs a message to the console at the "debug" log level. The message is only displayed to the user if the console is configured to display debug output. In most cases, the log level is configured within the console UI. This log level might correspond to the Debug or Verbose log level.

MDN Reference

debug
('[audit]',
entry: AuditEntry
entry
.
AuditEntry.kind: AuditEventKind
kind
,
entry: AuditEntry
entry
.
AuditEntry.className: string
className
,
entry: AuditEntry
entry
.
AuditEntry.paths?: string[] | undefined

Human-readable changed paths, or "(all)" for a full-replace.

paths
);
},
AuditLogOptions.sanitize?: ((state: unknown, className: string) => unknown) | undefined

Optional per-entry sanitizer. Strip PII before logging externally.

sanitize
(
state: unknown
state
,
name: string
name
) {
if (
name: string
name
=== 'AuthCubit') {
const
const s: {
user: unknown;
token?: string;
}
s
=
state: unknown
state
as {
user: unknown
user
: unknown;
token?: string | undefined
token
?: string };
return { ...
const s: {
user: unknown;
token?: string;
}
s
,
token: string
token
: '[redacted]' };
}
return
state: unknown
state
;
},
}),
{
PluginConfig.environment?: "development" | "production" | "test" | "all" | undefined
environment
: 'development' },
);

  • Plugin Authoring — the BlacPlugin interface and hook reference
  • Plugin Overview — first-party plugin catalog
  • Persistence — official IndexedDB persistence, including a custom adapter API
  • Logging — official structured console logging plugin