Workers, policies, and reconnect
Configure Worker and SharedWorker clients, restrict authority actions, and handle epoch changes safely.
Dedicated Worker
A dedicated Worker gives one page its own authority lifetime:
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module'
});
const store = create(source, { worker });Destroy the Coaction store when the client no longer owns the connection. Terminate the Worker according to your application's ownership model.
SharedWorker across tabs
A reusable module can create a SharedWorker in page runtimes and become the authority inside the worker runtime:
import { create } from 'coaction/shared';
let worker: SharedWorker | undefined;
if (typeof SharedWorker !== 'undefined') {
try {
worker = new SharedWorker(new URL('./store.ts', import.meta.url), {
type: 'module'
});
} catch (error) {
console.error('SharedWorker construction failed', error);
// Keep `undefined` only when independent local state is acceptable.
}
}
export const store = create(source, { worker });Each tab gets a client mirror while the SharedWorker owns one state instance. Passing the explicit
worker key with an undefined value degrades to a local authority. Its getState() actions still
return promises and its state, action arguments, and action results obey the shared JSON contract,
so awaited call sites remain portable between modes.
Fallback is not a client mirror
Degraded tabs run independent local state. If every tab must see the same state, treat a missing
SharedWorker as a hard error instead of relying on the fallback. The degraded store has
share === false and owns its state locally, so its low-level setState() and apply() methods
remain available. Always invoke actions through getState() and await them before reading their
effects.
Coaction cannot catch an exception thrown while your code evaluates new SharedWorker(...) before
create() is called. Catch that constructor failure yourself only when local fallback is an
acceptable product decision. A worker or transport that exists but fails during setup is surfaced
as an error rather than silently changing the authority model.
Restrict the authority surface
The authority only exposes method paths discovered from its store. transportPolicy can narrow
that surface further:
const authority = create(source, {
transport,
transportPolicy: {
allowedActions: [['counter', 'increment']],
authorize(request) {
return request.type === 'fullSync' || hasAccess(request);
},
mapError(error) {
return error instanceof DomainError ? error.publicMessage : undefined;
}
}
});allowedActionsis an allowlist of method paths.authorizereceives a decoded JSON execute or full-sync request.mapErrormay return a deliberately client-visible message.
Unexpected action errors are redacted to Remote action failed. The original error remains on
the authority. Treat policy code as trusted application code and keep it side-effect free where
possible.
Epochs and sequence recovery
Each authority lifetime owns a unique epoch and a sequence that begins at zero. A client:
- fetches an atomic
{ epoch, sequence, state }snapshot; - applies only the next sequence for that epoch;
- ignores duplicate or older updates;
- requests a full snapshot after a sequence gap or epoch change.
Snapshot replacement is atomic. A failed validation or apply step leaves the client's previous epoch, sequence, and state intact.
Awaiting action visibility
An action response includes the authority epoch and resulting sequence. The client waits for its
mirror to catch up before resolving the action promise. If the incremental update does not arrive
within executeSyncTimeoutMs (default 1500 ms), the client falls back to full sync.
const store = create(source, {
worker,
executeSyncTimeoutMs: 3000
});Increase the timeout only when legitimate worker execution and message scheduling routinely exceed the default. It is not a replacement for fixing a broken transport.
Authority changes during an action
If the authority changes while an action is in flight, a response from the old epoch rejects with
ActionAuthorityChangedError.
Its outcome is unknown: the old authority may already have performed the action. Retry only
when the action is idempotent or protected by an application-level idempotency key.
import { ActionAuthorityChangedError } from 'coaction/shared';
try {
await store.getState().submitOrder({ idempotencyKey });
} catch (error) {
if (error instanceof ActionAuthorityChangedError) {
// Reconcile by key; do not blindly repeat a non-idempotent action.
}
}Reconnect callbacks are generation-scoped, so a late callback from an older connection cannot overwrite state established by a newer one.