@matthewp/beepboop
Version:
A framework built on Finite State Machines
507 lines (457 loc) • 13.9 kB
JavaScript
import {
createMachine,
state as createState,
action,
guard,
transition,
immediate,
interpret,
reduce,
invoke,
} from 'robot3';
import { createElement, render, Component as PreactComponent } from 'preact';
// Constants
const BEEPBOOP_INITIAL_STATE = 'beepboop.initial';
// Util
let valueEnumerable = (value) => ({ enumerable: true, value });
let valueEnumerableWritable = (value) => ({
enumerable: true,
value,
writable: true,
});
let create = (a, b) => Object.freeze(Object.create(a, b));
let mergeArray = (a, b) => (Array.isArray(a) ? a.concat([b]) : [b]);
let h = (fn) => (_, ev) => fn(ev);
// Props validation function
function validateProps(props, schema) {
if (!schema) {
return props; // No validation if no schema defined
}
const result = schema['~standard'].validate(props);
if ('issues' in result) {
// Throw error with validation issues
const messages = result.issues.map(issue =>
`${issue.path.length > 0 ? issue.path.join('.') + ': ' : ''}${issue.message}`
).join(', ');
throw new Error(`Props validation failed: ${messages}`);
}
return result.value;
}
let effect = fn => (_r, _v, ev) => fn(ev);
let viewEffect = () => (root, model, ev) => {
// Re-render the view with updated model
root.draw(model);
};
let mutateAction = (fn) =>
action((ctx, ev) => {
fn(ctx.root, ctx.model, ev);
});
class EventDetails {
constructor(type, domEvent, data, actor) {
this.type = type;
this.domEvent = domEvent;
this.actor = actor;
this.data = data;
this._service = actor.service;
}
send = (...args) => {
return this.actor.send(...args);
}
sendEvent(...args) {
return this.actor.sendEvent(...args);
}
get root() {
return this.actor.component;
}
get service() {
return this._service ?? this.actor.service;
}
set service(val) {
this._service = val;
}
get model() {
return this.service.context.model;
}
get state() {
return this.service.machine.current;
}
}
class Component extends PreactComponent {
constructor(props) {
super(props);
// Create a proper actor from the built machine
this.actor = Object.create(Actor, {
machine: valueEnumerable(props.machine),
viewFn: valueEnumerable(props.viewFn),
propsSchema: valueEnumerable(props.propsSchema),
service: valueEnumerableWritable(undefined),
});
this.actor.send = this.actor.send.bind(this.actor);
this.actor.sendEvent = this.actor.sendEvent.bind(this.actor);
this.actor.init(this);
// Validate and send initial props event
const validatedProps = validateProps(props.props, this.actor.propsSchema);
this.actor.send('props', validatedProps);
this.state = {
view: this.callView()
};
}
deliver = (name) => {
return event => this.actor.sendEvent(name, event);
};
callView(model) {
return this.actor.viewFn({
deliver: this.deliver,
model: model ?? this.actor.service.context.model,
send: this.actor.send
});
}
draw(model) {
this.setState({
view: this.callView(model)
});
}
componentDidUpdate(prevProps) {
// Validate and send props event on updates
const validatedProps = validateProps(this.props.props, this.actor.propsSchema);
this.actor.send('props', validatedProps);
}
render() {
return this.state.view;
}
}
let Actor = {
mount(selector) {
let el =
typeof selector === 'string'
? document.querySelector(selector)
: selector;
if (!this.viewFn) {
throw new Error('Cannot mount actor without a view function. Use bb.view(machine) to create a mountable component.');
}
// Create component directly with machine data
const component = createElement(Component, {
machine: this.machine,
viewFn: this.viewFn,
propsSchema: this.propsSchema,
props: {}
});
render(component, el);
},
init(component) {
this.component = component;
this.interpret();
},
interpret() {
this.service = interpret(
this.machine,
() => {
// TODO state change effects
},
this.component,
new EventDetails(null, null, null, this)
);
return this;
},
send(eventType, data) {
let domEvent = null;
if (typeof eventType === 'object' && ('type' in eventType)) {
domEvent = eventType;
eventType = domEvent.type;
}
this.service?.send(new EventDetails(eventType, domEvent, data, this));
},
sendEvent(eventType, domEvent) {
this.service?.send(new EventDetails(eventType, domEvent, null, this));
}
};
function build(builder) {
let effects = builder.effects;
// Process always transitions - apply to all states
let states = { ...builder.states };
for (let { event, args } of (builder.alwaysTransitions || [])) {
for (let stateName in states) {
let state = states[stateName];
state.events[event] = mergeArray(state.events[event], [stateName, args]);
}
}
let machineDefn = {};
for (let name in states) {
let state = states[name];
let stateArgs = [];
for (let evName in state.events) {
let eventDetails = state.events[evName];
for (let [dest, extras] of eventDetails) {
let args = [];
let needsViewAction = false;
for (let type of extras) {
if (AssignType.isPrototypeOf(type)) {
let key = type.key;
args.push(reduce(type.reducer.bind(type)));
needsViewAction = true;
} else {
args.push(type);
}
}
// Add view effect once at the end if needed
if (needsViewAction && builder.viewFn) {
args.push(mutateAction(viewEffect()));
}
stateArgs.push(transition(evName, dest, ...args));
}
}
for (let dest in state.immediates) {
for (let extras of state.immediates[dest]) {
let args = [];
let needsViewAction = false;
if (name === builder.initial) {
args.push(reduce(function(ctx, ev) {
ev.service = this;
return ctx;
}));
}
for (let type of extras) {
if (AssignType.isPrototypeOf(type)) {
let key = type.key;
args.push(reduce(type.reducer.bind(type)));
needsViewAction = true;
} else {
args.push(type);
}
}
// Add view effect once at the end if needed
if (needsViewAction && builder.viewFn) {
args.push(mutateAction(viewEffect()));
}
stateArgs.push(immediate(dest, ...args));
}
}
// Check if this state has an invoke function
if (state.invoke) {
machineDefn[name] = invoke(h(state.invoke), ...stateArgs);
} else {
machineDefn[name] = createState(...stateArgs);
}
}
// Start with empty model - users set initial values via transitions
let modelFn = () => ({});
// Add the initial state that immediately transitions to the builder's initial state
let initialArgs = [];
// Add init effects if any were defined
if (states[BEEPBOOP_INITIAL_STATE]) {
let needsViewAction = false;
for (let dest in states[BEEPBOOP_INITIAL_STATE].immediates) {
for (let extras of states[BEEPBOOP_INITIAL_STATE].immediates[dest]) {
for (let type of extras) {
if (AssignType.isPrototypeOf(type)) {
initialArgs.push(reduce(type.reducer.bind(type)));
needsViewAction = true;
} else {
initialArgs.push(type);
}
}
}
}
// Add view effect once at the end if needed
if (needsViewAction && builder.viewFn) {
initialArgs.push(mutateAction(viewEffect()));
}
}
machineDefn[BEEPBOOP_INITIAL_STATE] = createState(immediate(builder.initial, ...initialArgs));
let machine = createMachine(BEEPBOOP_INITIAL_STATE, machineDefn, (root) => {
return {
model: modelFn(),
root,
};
});
return machine;
}
function extendState(bb, state) {
let desc = Object.getOwnPropertyDescriptor(bb.states, state);
return desc.value;
}
function createBuilder(initial, model, states, effects, viewFn, alwaysTransitions, propsSchema) {
return Object.create(Builder, {
initial: valueEnumerable(initial),
model: valueEnumerable(model),
states: valueEnumerable(states),
effects: valueEnumerable(effects ?? {}),
viewFn: valueEnumerable(viewFn ?? null),
alwaysTransitions: valueEnumerable(alwaysTransitions ?? []),
propsSchema: valueEnumerable(propsSchema ?? null)
});
}
function appendStates(builder, states) {
return createBuilder(
builder.initial,
builder.model,
states,
builder.effects,
builder.viewFn,
builder.alwaysTransitions,
builder.propsSchema
);
}
let GuardType = Object.getPrototypeOf(guard(() => {}));
let AssignType = {
reducer(ctx, ev) {
let newValue = this.fn(ev);
if (this.key.includes('.')) {
// Handle nested path like 'user.name'
const keys = this.key.split('.');
let obj = ctx.model;
// Navigate to parent object
for (let i = 0; i < keys.length - 1; i++) {
obj = obj[keys[i]];
}
// Set final property
obj[keys[keys.length - 1]] = newValue;
} else {
// Current behavior for flat keys
ctx.model[this.key] = newValue;
}
return ctx;
},
};
let Builder = {
template() {
throw new Error(`Not yet implemented.`);
},
model(schema) {
return createBuilder(this.initial, schema, this.states, this.effects, this.viewFn, this.alwaysTransitions, this.propsSchema);
},
props(schema) {
return createBuilder(this.initial, this.model, this.states, this.effects, this.viewFn, this.alwaysTransitions, schema);
},
states(names) {
let desc = {};
for (let name of names) {
desc[name] = {
events: {},
immediates: {},
invoke: null,
};
}
return createBuilder(names[0], this.model, desc, this.effects, this.viewFn, this.alwaysTransitions, this.propsSchema);
},
events(state, events) {
// TODO validate state exists
let desc = extendState(this, state);
for (let name of events) {
desc.events[name] = {};
}
return appendStates(this, {
...this.states,
[state]: desc,
});
},
immediate(state, dest, ...args) {
let desc = extendState(this, state);
desc.immediates[dest] = mergeArray(desc.immediates[dest], args);
return appendStates(this, {
...this.states,
[state]: desc,
});
},
transition(state, event, dest, ...args) {
let desc = extendState(this, state);
//desc.immediates[dest] = args;
desc.events[event] = mergeArray(desc.events[event], [dest, args]);
return appendStates(this, {
...this.states,
[state]: desc,
});
},
guard(fn) {
return guard(h(fn));
},
assign(key, fn) {
return create(AssignType, {
key: valueEnumerable(key),
fn: valueEnumerable(fn),
});
},
action(fn) {
return action(h(fn));
},
effect(keyOrFn, fn) {
if (typeof keyOrFn === 'function') {
// Lifecycle effect - only run in browser
if (typeof window !== 'undefined') {
return this.init(this.action(keyOrFn));
}
return this;
} else {
// Model effect
return createBuilder(this.initial, this.model, this.states, {
...this.effects,
[keyOrFn]: mergeArray(this.effects[keyOrFn], effect(fn)),
}, this.viewFn, this.alwaysTransitions, this.propsSchema);
}
},
always(event, ...args) {
const newAlwaysTransitions = mergeArray(this.alwaysTransitions, { event, args });
return createBuilder(
this.initial,
this.model,
this.states,
this.effects,
this.viewFn,
newAlwaysTransitions,
this.propsSchema
);
},
init(...args) {
const filteredArgs = args.filter(arg => !GuardType.isPrototypeOf(arg));
let builder = this;
if (!(BEEPBOOP_INITIAL_STATE in this.states)) {
builder = appendStates(this, {
...this.states,
[BEEPBOOP_INITIAL_STATE]: { events: {}, immediates: {}, invoke: null }
});
}
return builder.immediate(BEEPBOOP_INITIAL_STATE, this.initial, ...filteredArgs);
},
invoke(state, fn) {
let desc = extendState(this, state);
desc.invoke = fn;
return appendStates(this, {
...this.states,
[state]: desc,
});
},
view(fnOrMachine) {
// If it's a function, it's the old .view(fn) for setting the view function
if (typeof fnOrMachine === 'function') {
return createBuilder(this.initial, this.model, this.states, this.effects, fnOrMachine ?? null, this.alwaysTransitions, this.propsSchema);
}
// If it's a Builder object (machine), it's the new bb.view(machine) for creating a component
if (Builder.isPrototypeOf(fnOrMachine)) {
if (!fnOrMachine.viewFn) {
throw new Error('bb.view(machine) requires a machine with a view function. Use .view() on your machine builder first.');
}
const builtMachine = build(fnOrMachine);
return (props) => createElement(Component, {
machine: builtMachine,
viewFn: fnOrMachine.viewFn,
propsSchema: fnOrMachine.propsSchema,
props
});
}
// Invalid argument
throw new Error('bb.view() expects either a view function or a machine builder.');
},
actor(builder) {
if (builder) {
return Object.create(Actor, {
machine: valueEnumerable(build(builder)),
viewFn: valueEnumerable(builder.viewFn),
propsSchema: valueEnumerable(builder.propsSchema),
service: valueEnumerableWritable(undefined),
});
} else {
return create(Actor);
}
}
};
export let bb = create(Builder);