Skip to content

Instantly share code, notes, and snippets.

@sibbng
Created April 14, 2025 04:06
Show Gist options
  • Save sibbng/8e0954233f32d9e74a8bf26aa38aa760 to your computer and use it in GitHub Desktop.
Save sibbng/8e0954233f32d9e74a8bf26aa38aa760 to your computer and use it in GitHub Desktop.
import { render, Switch, h, useEffect, ObservableReadonly, Observable, If } from 'voby';
import { $, useMemo, useReadonly, store } from 'voby';
// --- Event & Context Types ---
type EventObject = { type: string; payload?: any };
type GuardCondition<TContext, TEvent extends EventObject = EventObject> =
(context: TContext, event: TEvent) => boolean;
// --- Action Types ---
interface ActionContext<TContext extends object, TEvent extends { type: string }> {
context: TContext; // The reactive machine context
event: TEvent; // The event that triggered the action (or caused entry/exit)
}
type ActionParamsMap = Record<string, any>;
type ActionImplementationV2<TContext extends object, TEvent extends { type: string }, TParams = any> =
(actionContext: ActionContext<TContext, TEvent>, params?: TParams) => void;
type ActionDefinitionV2<
TContext extends object,
TEvent extends { type: string },
TActions extends ActionParamsMap
> =
| {
[K in keyof TActions]: undefined extends TActions[K]
? K | { type: K; params?: TActions[K] }
: { type: K; params: TActions[K] }
}[keyof TActions]
| ((actionContext: ActionContext<TContext, TEvent>, params?: any) => void);
// --- Machine Definition Types ---
type TransitionDefinitionV2<TState extends string, TContext extends object, TEvent extends { type: string; payload?: any }, TActions extends ActionParamsMap> =
| TState
| {
target?: TState;
guard?: GuardCondition<TContext, TEvent>;
actions?: ActionDefinitionV2<TContext, TEvent, TActions>[];
};
type GuardedTransitionsV2<TState extends string, TContext extends object, TEvent extends { type: string; payload?: any }, TActions extends ActionParamsMap> =
TransitionDefinitionV2<TState, TContext, TEvent, TActions>[]
| TransitionDefinitionV2<TState, TContext, TEvent, TActions>;
interface StateNodeV2<TState extends string, TContext extends object, TEvent extends { type: string; payload?: any }, TActions extends ActionParamsMap> {
on?: Record<string, GuardedTransitionsV2<TState, TContext, TEvent, TActions>>;
entry?: ActionDefinitionV2<TContext, TEvent, TActions>[];
exit?: ActionDefinitionV2<TContext, TEvent, TActions>[];
after?: Record<number | string, GuardedTransitionsV2<TState, TContext, TEvent, TActions>>;
}
interface MachineDefinitionV2<
TState extends string,
TContext extends object,
TEvent extends { type: string; payload?: any },
TActions extends ActionParamsMap,
TInput = void
> {
id: string;
initial: TState;
context?: TContext | ((opts: { input: TInput }) => TContext);
states: Record<TState, StateNodeV2<TState, TContext, TEvent, TActions>>;
actions: {
[K in keyof TActions]: ActionImplementationV2<TContext, TEvent, TActions[K]>;
};
delays?: Record<string, number | ((opts: { context: TContext }) => number)>;
}
// --- Machine Instance Type ---
interface Machine<TState extends string, TContext extends object, TEvent extends { type: string; payload?: any }> {
state: ObservableReadonly<TState>;
context: TContext;
send: (event: TEvent) => void;
matches: (...states: TState[]) => ObservableReadonly<boolean>;
}
// --- Helper Functions ---
/**
* Helper function to execute an array of action definitions.
*/
function executeActions<
TContext extends object,
TEvent extends { type: string; payload?: any; params?: any },
TActions extends ActionParamsMap
>(
actionDefs: ActionDefinitionV2<TContext, TEvent, TActions>[] | undefined,
actionsMap: { [K in keyof TActions]: ActionImplementationV2<TContext, TEvent, TActions[K]> } | undefined,
actionContext: ActionContext<TContext, TEvent>,
machineId: string,
actionType: 'entry' | 'exit' | 'transition'
): void {
if (!actionDefs || !actionsMap) {
return;
}
actionDefs.forEach(actionDef => {
// Case 1: Action is a function
if (typeof actionDef === "function") {
try {
console.log(`[${machineId}] Executing ${actionType} inline action (function)`);
actionDef(actionContext);
} catch (error) {
console.error(`[${machineId}] Error executing ${actionType} inline action (function):`, error);
}
return;
}
let actionName: keyof TActions | undefined = undefined;
let params: any | undefined;
// Case 2: Action is a string
if (typeof actionDef === 'string') {
actionName = actionDef as keyof TActions;
}
// Case 3: Action is an object with type and optional params
else if (typeof actionDef === 'object' && actionDef !== null) {
actionName = actionDef.type;
params = actionDef.params;
}
if (actionName === undefined) {
console.warn(`[${machineId}] Skipping ${actionType} action: could not determine action name`, actionDef);
return;
}
const actionFn = actionsMap[actionName];
if (actionFn) {
try {
if (params !== undefined) {
console.log(`[${machineId}] Executing ${actionType} action: ${String(actionName)} with params:`, params);
} else {
console.log(`[${machineId}] Executing ${actionType} action: ${String(actionName)}`);
}
actionFn(actionContext, params);
} catch (error) {
console.error(`[${machineId}] Error executing ${actionType} action '${String(actionName)}':`, error);
}
} else {
console.warn(`[${machineId}] Attempted to execute unknown ${actionType} action: ${String(actionName)}`);
}
});
}
/**
* Creates a reactive state machine instance from a definition,
* supporting context, guards, and entry/exit/transition actions.
* Optionally accepts input for context initialization.
*/
function createMachine<
TState extends string,
TContext extends object,
TEvent extends { type: string; payload?: any },
TActions extends ActionParamsMap,
TInput = void
>(
definition: MachineDefinitionV2<TState, TContext, TEvent, TActions, TInput>,
input?: TInput
): Machine<TState, TContext, TEvent> {
const currentState: Observable<TState> = $(definition.initial);
// Type-safe context initialization
let initialContext: TContext;
if (typeof definition.context === "function") {
initialContext = (definition.context as (opts: { input: TInput }) => TContext)({ input: input as TInput });
} else if (typeof definition.context === "object" && definition.context !== null) {
initialContext = definition.context as TContext;
} else {
initialContext = {} as TContext;
}
const machineContext = store<TContext>(initialContext);
const actionsMap = definition.actions;
// --- Delayed (after) transitions support ---
let afterTimers: Record<string, number> = {};
function clearAfterTimers() {
if (!Object.keys(afterTimers).length) return;
console.log(`[${definition.id}] Clearing timers.`);
Object.values(afterTimers).forEach(timerId => clearTimeout(timerId));
afterTimers = {};
}
function scheduleAfterTransitions(stateValue: TState) {
const stateNode = definition.states[stateValue];
if (!stateNode?.after) return;
Object.entries(stateNode.after).forEach(([delayKey, transitionDefRaw]) => {
console.log(`[${definition.id}] Scheduling after timer: key=${delayKey}, value=`, transitionDefRaw);
});
Object.entries(stateNode.after).forEach(([delayKey, transitionDefRaw]) => {
let delayMs: number | undefined;
if (!Number.isNaN(Number(delayKey))) {
delayMs = Number(delayKey);
} else if (definition.delays && typeof delayKey === "string" && delayKey in definition.delays) {
const delayVal = definition.delays[delayKey];
if (typeof delayVal === "function") {
delayMs = delayVal({ context: machineContext });
} else {
delayMs = delayVal;
}
}
if (typeof delayMs !== "number" || Number.isNaN(delayMs)) {
console.warn(`[${definition.id}] Invalid after delay:`, delayKey);
return;
}
const transitionDefs = Array.isArray(transitionDefRaw) ? transitionDefRaw : [transitionDefRaw];
const timerId = setTimeout(() => {
if (currentState() !== stateValue) return;
let selectedTransition: TransitionDefinitionV2<TState, TContext, TEvent, TActions> | undefined;
for (const t of transitionDefs) {
if (typeof t === 'string' || !t.guard) {
selectedTransition = t;
break;
}
try {
if (t.guard(machineContext, { type: 'after', payload: undefined } as unknown as TEvent)) {
selectedTransition = t;
break;
}
} catch (error) {
console.error(`[${definition.id}] Error executing guard for after transition in state '${stateValue}':`, error);
}
}
if (!selectedTransition) return;
let targetState: TState | undefined = undefined;
let transitionActions: ActionDefinitionV2<TContext, TEvent, TActions>[] | undefined = undefined;
if (typeof selectedTransition === 'string') {
targetState = selectedTransition;
} else {
targetState = selectedTransition.target;
transitionActions = selectedTransition.actions;
}
const actionCtx: ActionContext<TContext, TEvent> = { context: machineContext, event: { type: 'after', payload: undefined } as TEvent };
if (targetState) {
const targetStateNode = definition.states[targetState];
if (targetStateNode) {
console.log(`[${definition.id}] After transition: ${stateValue} --(after ${delayKey})--> ${targetState}`);
executeActions(stateNode.exit, actionsMap, actionCtx, definition.id, 'exit');
executeActions(transitionActions, actionsMap, actionCtx, definition.id, 'transition');
currentState(targetState);
executeActions(targetStateNode.entry, actionsMap, actionCtx, definition.id, 'entry');
if (targetState === stateValue) {
clearAfterTimers();
scheduleAfterTransitions(targetState);
}
}
} else if (transitionActions && transitionActions.length > 0) {
console.log(`[${definition.id}] After timer: executing actions only (no transition) for key=${delayKey}`);
executeActions(transitionActions, actionsMap, actionCtx, definition.id, 'transition');
}
}, delayMs);
afterTimers[delayKey] = timerId as unknown as number;
});
}
useEffect(() => {
clearAfterTimers();
scheduleAfterTransitions(currentState());
return () => {
clearAfterTimers();
};
});
const send = (event: TEvent): void => {
const sourceStateValue = currentState();
const sourceStateNode = definition.states[sourceStateValue];
if (!sourceStateNode?.on) {
return;
}
const transitionDefRaw = sourceStateNode.on[event.type];
if (!transitionDefRaw) {
return;
}
const transitionDefs = Array.isArray(transitionDefRaw) ? transitionDefRaw : [transitionDefRaw];
let selectedTransition: TransitionDefinitionV2<TState, TContext, TEvent, TActions> | undefined;
for (const t of transitionDefs) {
if (typeof t === 'string' || !t.guard) {
selectedTransition = t;
break;
}
try {
if (t.guard(machineContext, event)) {
selectedTransition = t;
break;
}
} catch (error) {
console.error(`[${definition.id}] Error executing guard for event '${event.type}' in state '${sourceStateValue}':`, error);
}
}
if (!selectedTransition) {
return;
}
let targetState: TState | undefined = undefined;
let transitionActions: ActionDefinitionV2<TContext, TEvent, TActions>[] | undefined = undefined;
if (typeof selectedTransition === 'string') {
targetState = selectedTransition;
} else {
targetState = selectedTransition.target;
transitionActions = selectedTransition.actions;
}
if (targetState) {
const targetStateNode = definition.states[targetState];
if (targetStateNode) {
console.log(`[${definition.id}] Transitioning: ${sourceStateValue} --(${event.type})--> ${targetState}`);
const actionCtx: ActionContext<TContext, TEvent> = { context: machineContext, event };
executeActions(sourceStateNode.exit, actionsMap, actionCtx, definition.id, 'exit');
executeActions(transitionActions, actionsMap, actionCtx, definition.id, 'transition');
currentState(targetState);
executeActions(targetStateNode.entry, actionsMap, actionCtx, definition.id, 'entry');
if (targetState === sourceStateValue) {
clearAfterTimers();
scheduleAfterTransitions(targetState);
}
} else {
console.warn(`[${definition.id}] Invalid target state specified for event '${event.type}' in state '${sourceStateValue}': ${targetState}`);
}
}
};
const matches = (...statesToMatch: TState[]): ObservableReadonly<boolean> => {
return useMemo(() => statesToMatch.includes(currentState()));
};
return {
state: useReadonly(currentState),
context: machineContext,
send,
matches,
};
}
/**
* Creates an actor (machine instance) with support for input.
*/
function createActor<
TState extends string,
TContext extends object,
TEvent extends { type: string; payload?: any },
TActions extends ActionParamsMap,
TInput = void
>(
definition: MachineDefinitionV2<TState, TContext, TEvent, TActions, TInput>,
options?: { input: TInput }
): Machine<TState, TContext, TEvent> {
return createMachine(definition, options?.input);
}
// --- Money Machine Definition ---
type MoneyState = "idle" | "rich" | "super-rich";
type MoneyEvent = { type: "PAY"; payload?: any };
interface MoneyContext {
actualMoney: number;
}
type MoneyActionParams = {
setMoney: { value?: number };
setRich: undefined;
setSuperRich: undefined;
}
const moneyMachineDefinition: MachineDefinitionV2<
MoneyState,
MoneyContext,
MoneyEvent,
MoneyActionParams,
{ money: number }
> = {
id: "money",
initial: "idle",
context: ({ input }: { input: { money: number } }): MoneyContext => ({
actualMoney: Math.min(input.money, 42),
}),
actions: {
setRich: ({ context }: ActionContext<MoneyContext, MoneyEvent>) => {
context.actualMoney = 1000000;
},
setSuperRich: ({ context }: ActionContext<MoneyContext, MoneyEvent>) => {
context.actualMoney = 1000000000;
},
setMoney: (
{ context, event }: ActionContext<MoneyContext, MoneyEvent>,
params: { value?: number } | undefined
) => {
let value = params?.value;
if (value === undefined && event.payload && typeof event.payload.value === "number") {
value = event.payload.value;
}
if (typeof value === "number") {
context.actualMoney += value;
} else {
context.actualMoney += 1;
}
}
},
states: {
idle: {
entry: [
{ type: "setMoney", params: { value: 1 } }
],
on: {
PAY: [
{
guard: (ctx: MoneyContext) => ctx.actualMoney >= 1000000,
target: "super-rich",
actions: ["setSuperRich"]
},
{
guard: (ctx: MoneyContext) => ctx.actualMoney >= 42,
target: "rich",
actions: ["setRich"]
},
{
target: "idle",
actions: [{ type: "setMoney", params: { value: 2 } }]
}
]
},
},
rich: {
on: {
PAY: [
{
guard: (ctx: MoneyContext) => ctx.actualMoney >= 1000000,
target: "super-rich",
actions: ["setSuperRich"]
},
{
target: "rich",
actions: ["setRich"]
}
]
}
},
"super-rich": {}
},
};
const moneyActor = createActor(moneyMachineDefinition, { input: { money: 1000 } });
console.log("Money actor context (should be 42):", moneyActor.context.actualMoney);
// --- Feedback Machine Definition ---
type FeedbackState = 'idle' | 'question' | 'thanks' | 'error';
type FeedbackEvent =
| { type: 'START' }
| { type: 'feedback.good'; response: string }
| { type: 'feedback.bad'; response: string }
| { type: 'CLOSE' }
| { type: 'SUBMIT' }
| { type: 'error'; message: string };
interface FeedbackContext {
feedbackGiven?: 'good' | 'bad';
error?: string;
}
const trackResponse = (response: string) => {
console.log(`📊 TRACKING: Response = ${response}`);
};
const showConfettiEffect = () => {
console.log('🎉 CONFEtti! 🎉');
};
type FeedbackActions = {
logStart: undefined;
trackResponse: { logResponse?: boolean } | undefined;
showConfetti: undefined;
showError: { message?: string };
logExitQuestion: undefined;
logEntryThanks: undefined;
blockSubmitWithoutAnswer: undefined;
clearError: undefined;
}
const feedbackMachineDefinition: MachineDefinitionV2<
FeedbackState,
FeedbackContext,
FeedbackEvent,
FeedbackActions
> = {
id: 'feedback',
initial: 'idle',
context: {},
actions: {
logStart: ({ event }) => console.log(`Action: logStart triggered by ${event.type}`),
trackResponse: (
{ context, event },
params
) => {
const response = event.type === "feedback.good" || event.type === "feedback.bad" ? event.response : undefined;
if (response) {
trackResponse(response);
context.feedbackGiven = response as 'good' | 'bad';
if (params?.logResponse) {
console.log(`Logged response: ${response}`);
}
} else {
console.warn('Action: trackResponse called without response in event');
}
},
showConfetti: () => {
showConfettiEffect();
},
showError: (
{ context, event },
params: { message?: string } = {}
) => {
let errorMessage = params.message;
if (event.type === "error" && event.message) {
errorMessage = event.message;
}
if (!errorMessage) {
errorMessage = 'An unknown error occurred.';
}
console.error(`Action: showError - ${errorMessage}`);
context.error = errorMessage;
},
logExitQuestion: () => console.log('Action: Exiting question state...'),
logEntryThanks: () => console.log('Action: Entering thanks state...'),
blockSubmitWithoutAnswer: ({ context }) => {
console.warn('Tried to submit before answer. Please select Good/Bad before submitting.');
context.error = 'Please answer the question before submitting!';
},
clearError: ({ context }) => {
context.error = undefined;
},
},
states: {
idle: {
entry: ['clearError'],
on: { START: { target: 'question', actions: ['logStart'] } }
},
question: {
exit: ['logExitQuestion'],
on: {
'feedback.good': {
target: 'thanks',
actions: [{ type: 'trackResponse', params: { logResponse: true } }]
},
'feedback.bad': {
target: 'thanks',
actions: ['trackResponse']
},
CLOSE: 'idle',
SUBMIT: [
{
guard: (context) => !context.feedbackGiven,
target: 'error',
actions: [{ type: 'showError', params: { message: 'Submission failed!' } }]
},
{
actions: ['blockSubmitWithoutAnswer']
}
]
}
},
thanks: {
entry: ['showConfetti', 'logEntryThanks'],
on: { CLOSE: 'idle' }
},
error: {
entry: [{ type: 'showError', params: { message: 'Entered error state.' } }],
on: { CLOSE: 'idle' }
}
}
};
const feedbackMachine = createMachine(feedbackMachineDefinition);
// --- Feedback Widget ---
const FeedbackWidget = (): JSX.Element => {
const { state, context, send } = feedbackMachine;
return h('div', {
style: {
border: '2px solid blue',
padding: '15px',
margin: '10px',
fontFamily: 'sans-serif'
}
},
<>
<h3>Feedback Widget</h3>
<p>
State: <strong>{state}</strong>
</p>
<p>
Context: <pre>{() => JSON.stringify(context, null, 2)}</pre>
</p>
<Switch when={state}>
<Switch.Case when="idle">
<button onClick={() => send({ type: 'START' })}>Provide Feedback</button>
</Switch.Case>
<Switch.Case when="question">
<h4>How was your experience?</h4>
<button onClick={() => send({ type: 'feedback.good', response: 'good' })}>👍 Good</button>
<button onClick={() => send({ type: 'feedback.bad', response: 'bad' })}>👎 Bad</button>
<hr />
<button onClick={() => send({ type: 'SUBMIT' })}>Simulate Submit Error</button>
<button onClick={() => send({ type: 'CLOSE' })}>Close</button>
{() => context.error && <p style={{ color: 'red' }}>{context.error}</p>}
</Switch.Case>
<Switch.Case when="thanks">
<h4>Thank you for your feedback!</h4>
<p>
You rated: {() => context.feedbackGiven}
</p>
<button onClick={() => send({ type: 'CLOSE' })}>Close</button>
</Switch.Case>
<Switch.Case when="error">
<h4 style={{ color: 'red' }}>Error!</h4>
<p>{context.error}</p>
<button onClick={() => send({ type: 'CLOSE' })}>Close</button>
</Switch.Case>
<Switch.Default>
<p>Unknown state: {state}</p>
</Switch.Default>
</Switch>
</>
);
};
// --- Money Widget ---
const MoneyWidget = (): JSX.Element => {
const { state, context, send } = moneyActor;
return h('div', {
style: {
border: '2px solid green',
padding: '15px',
margin: '10px',
fontFamily: 'sans-serif',
minWidth: '220px'
}
},
<>
<h3>Money Machine Demo</h3>
<p>State: <strong>{state}</strong></p>
<p>actualMoney: <strong>{() => context.actualMoney}</strong></p>
<button onClick={() => send({ type: 'PAY' })}>PAY</button>
</>
);
};
// --- Timer Machine Definition ---
type TimerState = "waitingForButtonPush" | "success" | "timedOut";
type TimerEvent =
| { type: "PUSH_BUTTON" }
| { type: "RESET" };
interface TimerContext {
startedAt: number;
timedOutAt?: number;
successAt?: number;
attempts: number;
}
const TIMER_DURATION = 1000;
type TimerActions = {
logSuccess: undefined;
logThatYouGotTimedOut: undefined;
logReset: undefined;
reset: undefined;
}
const timerMachineDefinition: MachineDefinitionV2<
TimerState,
TimerContext,
TimerEvent,
TimerActions
> = {
id: "timer",
initial: "waitingForButtonPush",
context: () => ({
startedAt: Date.now(),
attempts: 0,
}),
actions: {
logSuccess: ({ context }) => {
context.successAt = Date.now();
console.log("[timer] Button pushed in time!");
},
logThatYouGotTimedOut: ({ context }) => {
context.timedOutAt = Date.now();
console.log("[timer] Timed out!");
},
logReset: ({ context }) => {
context.startedAt = Date.now();
context.timedOutAt = undefined;
context.successAt = undefined;
console.log("[timer] Reset timer.");
},
reset: ({ context }) => {
context.startedAt = Date.now();
context.timedOutAt = undefined;
context.successAt = undefined;
console.log("[timer] Reset action triggered.");
}
},
delays: {
timeout: ({ context }) => TIMER_DURATION,
},
states: {
waitingForButtonPush: {
entry: ["logReset"],
after: {
[TIMER_DURATION - 1000]: {
actions: [() => console.log("[timer] Almost timed out!")],
},
timeout: [
{
guard: (context) => context.attempts < 3,
actions: [({ context }) => { context.attempts += 1; console.log(`[timer] Attempt #${context.attempts}`); }],
target: "waitingForButtonPush",
},
{
target: "timedOut",
actions: ["logThatYouGotTimedOut"],
}
]
},
on: {
PUSH_BUTTON: {
actions: ["logSuccess"],
target: "success",
},
RESET: {
actions: ["reset"],
target: "waitingForButtonPush",
}
},
},
success: {
entry: [],
on: {
RESET: {
actions: ["reset"],
target: "waitingForButtonPush",
}
}
},
timedOut: {
entry: [],
on: {
RESET: {
actions: ["reset"],
target: "waitingForButtonPush",
}
}
}
}
};
const timerMachine = createMachine(timerMachineDefinition);
// --- Timer Widget ---
const TimerWidget = (): JSX.Element => {
const { state, context, send } = timerMachine;
return (
<div
style={{
border: '2px solid orange',
padding: '15px',
margin: '10px',
fontFamily: 'sans-serif',
minWidth: '220px'
}}
>
<h3>Timer Machine Demo</h3>
<p>State: <strong>{state}</strong></p>
<button disabled={state() !== "waitingForButtonPush"} onClick={() => send({ type: "PUSH_BUTTON" })}>
Push the Button!
</button>
<button style={{ marginLeft: "8px" }} onClick={() => send({ type: "RESET" })}>
Reset
</button>
<p>Started at: {() => new Date(context.startedAt).toLocaleTimeString()}</p>
<If when={context.successAt}>
<p style={{ color: "green" }}>
Success at: {new Date(context.successAt ?? 0).toLocaleTimeString()}
</p>
</If>
<If when={context.timedOutAt}>
<p style={{ color: "red" }}>
Timed out at: {new Date(context.timedOutAt ?? 0).toLocaleTimeString()}
</p>
</If>
</div>
);
};
// --- Render All Widgets ---
render(
<div style={{ display: "flex", flexDirection: "row", gap: "4px", alignItems: "flex-start" }}>
<FeedbackWidget />
<MoneyWidget />
<TimerWidget />
</div>,
document.getElementById("app")
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment