├── .gitignore ├── README.md ├── examples ├── broadcast-channel │ ├── index.html │ └── main.ts ├── event-bus │ ├── index.html │ └── main.ts ├── web-socket │ ├── index.html │ └── main.ts └── web-worker │ ├── index.html │ ├── main.ts │ ├── transferable │ ├── index.html │ ├── main.ts │ └── worker.ts │ └── worker.ts ├── package.json ├── src ├── async │ └── from-dynamic-import.ts ├── event-bus │ ├── from-broadcast-channel.ts │ ├── from-event-bus.ts │ └── from-event-listener.ts ├── index.ts ├── real-time │ ├── from-event-source.ts │ ├── from-web-socket.ts │ └── from-webrtc.ts └── workers │ ├── from-audio-worklet.ts │ ├── from-service-worker.ts │ ├── from-shared-worker.ts │ └── from-web-worker.ts ├── tsconfig.json ├── vite-env.d.ts ├── vite.config.ts └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xstate-behaviors 2 | 3 | > Making web workers, web sockets, WebRTC, and other web-based APIs first-class actors in `xstate`. 4 | 5 | _This package is not yet release as it is in a very early alpha state. If you would like to use any of these helpers now its recommended to copy and paste them into your codebase for the time being!_ 6 | 7 | If you have any ideas for other Web APIs to support please file an issue! 8 | 9 | ## Helper functions 10 | 11 | Any of the helper functions exported from this library can be used to invoke or spawn an actor that wraps around a certain Web API. 12 | 13 | ### Async 14 | 15 | - `fromDynamicImport` 16 | - Invoke/spawn a machine that is dynamically imported. Events sent to the machine while the dynamic import are loading are deferred until the machine is started. 17 | 18 | ### Workers 19 | 20 | _It is assumed that events sent to/from the worker are of the type `EventObject`, otherwise they are ignored._ 21 | 22 | - `fromWebWorker` 23 | - Invoke/spawn a web worker. 24 | - `interpretInWebWorker` 25 | - Interpret a machine inside a web worker whose _parent_ is on the main thread. This means that you can use `sendParent` in your machine to send events out of the web worker. 26 | - `fromSharedWorker` 27 | - Invoke/spawn a shared worker. 28 | - `interpretInSharedWorker` 29 | - Interpret a machine inside a shared worker whose _parent_ is on a main thread. This means that you can use `sendParent` in your machine to send events out of the web worker. 30 | - `fromServiceWorker` 31 | - Invoke/spawn an actor that lets you communicate with a service worker. It is assumed that events sent from the worker are of the type `EventObject`. 32 | - `fromAudioWorklet` 33 | - Invoke/spawn a `AudioWorklet`. 34 | 35 | ### Real-time 36 | 37 | _It is assumed that events sent over the network are of the type `EventObject`, otherwise they are ignored._ 38 | 39 | - `fromWebSocket` 40 | - Invoke/spawn a web socket. 41 | - `fromEventSource` 42 | - Invoke/spawn a event source for server side events. 43 | - `fromWebRTC` (TODO) 44 | - Still figuring this out let me know if you have any idea! 45 | 46 | ### Events 47 | 48 | - `fromEventBus` 49 | - Invoke/spawn an event bus so non-hierarchical actors can communicate 50 | - `EventBus` is a class that encapsulates an event bus. This is not a web API. 51 | - `fromBroadCastChannel` 52 | - Invoke/spawn a `BroadcastChannel` to communicate with anyone else listening 53 | - `fromEventListener` 54 | - Invoke/spawn an event from 55 | - `fromPostMessage` (TODO) 56 | - `fromMessageChannel` (TODO) 57 | 58 | ## TODO 59 | 60 | - [ ] Finish building out planned helpers. 61 | - [ ] Add ability to filter out unwanted events. 62 | - [ ] Add more tests/examples. 63 | - [ ] Build out documentation. 64 | -------------------------------------------------------------------------------- /examples/broadcast-channel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/broadcast-channel/main.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, interpret, send } from 'xstate'; 2 | import { log } from 'xstate/lib/actions'; 3 | import { fromBroadcastChannel } from '@src'; 4 | 5 | const BROADCAST_CHANNEL_NAME = 'BC'; 6 | 7 | const pingMachine = createMachine({ 8 | id: 'ping', 9 | invoke: { 10 | id: 'event bus', 11 | src: fromBroadcastChannel(() => new BroadcastChannel(BROADCAST_CHANNEL_NAME)), 12 | }, 13 | on: { 14 | PONG: { 15 | actions: [log('PONG'), send({ type: 'PING' }, { to: 'event bus', delay: 1000 })], 16 | }, 17 | }, 18 | }); 19 | 20 | const pongMachine = createMachine({ 21 | id: 'pong', 22 | invoke: { 23 | id: 'event bus', 24 | src: fromBroadcastChannel(() => new BroadcastChannel(BROADCAST_CHANNEL_NAME)), 25 | }, 26 | on: { 27 | PING: { 28 | actions: [log('PING'), send({ type: 'PONG' }, { to: 'event bus', delay: 1000 })], 29 | }, 30 | }, 31 | }); 32 | 33 | const parentMachine = createMachine({ 34 | id: 'parent', 35 | invoke: [ 36 | { id: 'ping', src: pingMachine }, 37 | { id: 'pong', src: pongMachine }, 38 | ], 39 | entry: send({ type: 'PING' }, { to: 'pong' }), 40 | }); 41 | 42 | const service = interpret(parentMachine).start(); 43 | -------------------------------------------------------------------------------- /examples/event-bus/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/event-bus/main.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, interpret, send } from 'xstate'; 2 | import { log } from 'xstate/lib/actions'; 3 | import { EventBus, fromEventBus } from '@src'; 4 | 5 | type Context = { 6 | eventBus: EventBus; 7 | }; 8 | 9 | const pingMachine = createMachine({ 10 | id: 'ping', 11 | invoke: { 12 | id: 'event bus', 13 | src: fromEventBus((context) => context.eventBus), 14 | }, 15 | on: { 16 | PONG: { 17 | actions: [log('PONG'), send({ type: 'PING' }, { to: 'event bus', delay: 1000 })], 18 | }, 19 | }, 20 | }); 21 | 22 | const pongMachine = createMachine({ 23 | id: 'pong', 24 | invoke: { 25 | id: 'event bus', 26 | src: fromEventBus((context) => context.eventBus), 27 | }, 28 | on: { 29 | PING: { 30 | actions: [log('PING'), send({ type: 'PONG' }, { to: 'event bus', delay: 1000 })], 31 | }, 32 | }, 33 | }); 34 | 35 | const parentMachine = createMachine({ 36 | id: 'parent', 37 | context: { 38 | eventBus: new EventBus('EV'), 39 | }, 40 | invoke: [ 41 | { id: 'ping', src: pingMachine, data: (context) => context }, 42 | { id: 'pong', src: pongMachine, data: (context) => context }, 43 | ], 44 | entry: send({ type: 'PING' }, { to: 'pong' }), 45 | }); 46 | 47 | const service = interpret(parentMachine).start(); 48 | -------------------------------------------------------------------------------- /examples/web-socket/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/web-socket/main.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, interpret, send } from 'xstate'; 2 | import { fromWebSocket } from '@src'; 3 | 4 | const pingMachine = createMachine({ 5 | id: 'ping', 6 | invoke: { 7 | id: 'pong', 8 | src: fromWebSocket(() => new WebSocket('')), 9 | }, 10 | entry: send({ type: 'PING' }, { to: 'pong' }), 11 | initial: 'active', 12 | states: { 13 | active: { 14 | on: { 15 | PONG: { 16 | actions: [send({ type: 'PING' }, { to: 'pong', delay: 1000 }), () => console.log('PONG')], 17 | }, 18 | STOP: 'complete', 19 | }, 20 | }, 21 | complete: {}, 22 | }, 23 | }); 24 | 25 | const service = interpret(pingMachine).start(); 26 | -------------------------------------------------------------------------------- /examples/web-worker/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/web-worker/main.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, interpret, send, actions } from 'xstate'; 2 | import { fromWebWorker } from '@src'; 3 | 4 | const { log } = actions; 5 | 6 | const pingMachine = createMachine({ 7 | id: 'ping', 8 | invoke: { 9 | id: 'pong', 10 | src: fromWebWorker(() => new Worker(new URL('./worker', import.meta.url), { type: 'module' })), 11 | }, 12 | entry: send({ type: 'PING' }, { to: 'pong' }), 13 | initial: 'active', 14 | states: { 15 | active: { 16 | on: { 17 | PONG: { 18 | actions: [log('PONG'), send({ type: 'PING' }, { to: 'pong', delay: 1000 })], 19 | }, 20 | STOP: 'complete', 21 | }, 22 | }, 23 | complete: {}, 24 | }, 25 | }); 26 | 27 | const service = interpret(pingMachine).start(); 28 | -------------------------------------------------------------------------------- /examples/web-worker/transferable/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/web-worker/transferable/main.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, interpret, send, actions } from 'xstate'; 2 | import { fromWebWorker } from '@src'; 3 | 4 | const { log } = actions; 5 | 6 | type Context = { 7 | buffer: ArrayBuffer; 8 | }; 9 | 10 | const pingMachine = createMachine({ 11 | id: 'ping', 12 | context: { 13 | buffer: new ArrayBuffer(8), 14 | }, 15 | invoke: { 16 | id: 'pong', 17 | src: fromWebWorker(() => new Worker(new URL('./worker', import.meta.url), { type: 'module' })), 18 | }, 19 | entry: [ 20 | log((ctx) => ctx.buffer.byteLength, 'Before transfer from main:'), 21 | send(({ buffer }) => ({ type: 'PING', buffer, _transfer: [buffer] }), { to: 'pong' }), 22 | // For some reason logging the buffer length will print 8, but the reference to the buffer shows that it was transferred 23 | log((ctx) => ctx.buffer, 'After transfer from main:'), 24 | ], 25 | initial: 'active', 26 | states: { 27 | active: { 28 | on: { 29 | PONG: { actions: log((_, e) => e.buffer.byteLength, 'After transfer from worker: ') }, 30 | }, 31 | }, 32 | }, 33 | }); 34 | 35 | const service = interpret(pingMachine).start(); 36 | -------------------------------------------------------------------------------- /examples/web-worker/transferable/worker.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, sendParent, actions } from 'xstate'; 2 | import { interpretInWebWorker } from '@src'; 3 | 4 | const { log } = actions; 5 | 6 | const pongMachine = createMachine({ 7 | id: 'pong', 8 | on: { 9 | PING: { 10 | actions: [ 11 | log((_, e) => e.buffer.byteLength, 'Before transfer from worker:'), 12 | sendParent((_, e) => ({ type: 'PONG', buffer: e.buffer, _transfer: [e.buffer] })), 13 | // For some reason logging the buffer length will print 8, but the reference to the buffer shows that it was transferred 14 | log((_, e) => e.buffer, 'After transfer from worker:'), 15 | ], 16 | }, 17 | }, 18 | }); 19 | 20 | const service = interpretInWebWorker(pongMachine); 21 | service.start(); 22 | -------------------------------------------------------------------------------- /examples/web-worker/worker.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, sendParent, actions } from 'xstate'; 2 | import { interpretInWebWorker } from '@src'; 3 | 4 | const { log } = actions; 5 | 6 | const pongMachine = createMachine({ 7 | id: 'pong', 8 | on: { 9 | PING: { 10 | actions: [log('PING'), sendParent('PONG', { delay: 1000 })], 11 | }, 12 | }, 13 | }); 14 | 15 | const service = interpretInWebWorker(pongMachine); 16 | service.start(); 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.0", 3 | "scripts": { 4 | "dev": "vite", 5 | "build": "tsc && vite build", 6 | "serve": "vite preview" 7 | }, 8 | "devDependencies": { 9 | "@types/dom-mediacapture-record": "^1.0.7", 10 | "@types/node": "^16.7.6", 11 | "@xstate/inspect": "^0.4.1", 12 | "typescript": "^4.2.3", 13 | "vite": "^2.3.5" 14 | }, 15 | "dependencies": { 16 | "@types/three": "^0.129.0", 17 | "d3-random": "^2.2.2", 18 | "three": "^0.129.0", 19 | "xstate": "^4.23.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/async/from-dynamic-import.ts: -------------------------------------------------------------------------------- 1 | import { 2 | interpret, 3 | AnyInterpreter, 4 | StateMachine, 5 | InterpreterFrom, 6 | AnyEventObject, 7 | EventObject, 8 | StateSchema, 9 | InvokeCreator, 10 | } from 'xstate'; 11 | 12 | /** 13 | * Create an invoked machine that is dynamically imported. 14 | * @param loadMachine Dynamically import a machine 15 | * @returns an invoke creator 16 | */ 17 | export function fromDynamicImport< 18 | TContext, 19 | TEvent extends EventObject = AnyEventObject, 20 | Machine extends StateMachine = StateMachine< 21 | TContext, 22 | StateSchema, 23 | TEvent, 24 | any 25 | > 26 | >( 27 | loadMachine: (context: TContext, event: TEvent) => Promise 28 | ): InvokeCreator { 29 | return (context, event) => (sendBack, receive) => { 30 | let service: InterpreterFrom | null = null; 31 | let status: 'pending' | 'resolved' | 'rejected' | 'stopped' = 'pending'; 32 | const pendingMessages: AnyEventObject[] = []; 33 | 34 | loadMachine(context, event) 35 | .then((machine) => { 36 | if (status === 'stopped') return; 37 | 38 | status = 'resolved'; 39 | service = interpret(machine, { 40 | parent: { send: sendBack } as AnyInterpreter, 41 | }) as InterpreterFrom; 42 | service?.send(pendingMessages); 43 | }) 44 | .catch(() => (status = 'rejected')); 45 | 46 | receive((event) => { 47 | if (status === 'pending') { 48 | pendingMessages.push(event); 49 | } else if (status === 'resolved' && service) { 50 | service?.send(event); 51 | } 52 | }); 53 | 54 | return () => { 55 | status = 'stopped'; 56 | service?.stop(); 57 | }; 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/event-bus/from-broadcast-channel.ts: -------------------------------------------------------------------------------- 1 | import { EventObject, AnyEventObject, InvokeCreator } from 'xstate'; 2 | import { getEventType } from 'xstate/lib/utils'; 3 | 4 | /** 5 | * Create an invoked service for a BroadcastChannel. 6 | * @param createBroadcastChannel Create a BroadcastChannel 7 | * @returns an invoke creator 8 | */ 9 | export function fromBroadcastChannel( 10 | createBroadcastChannel: (context: TContext, event: TEvent) => BroadcastChannel 11 | ): InvokeCreator { 12 | return (context, event) => (sendBack, receive) => { 13 | const channel = createBroadcastChannel(context, event); 14 | 15 | const handler = (event: MessageEvent) => { 16 | try { 17 | // Will error out if the data is not a valid event 18 | getEventType(event.data); 19 | sendBack(event.data); 20 | } catch {} 21 | }; 22 | 23 | channel.addEventListener('message', handler); 24 | 25 | receive((event) => { 26 | channel.postMessage(event); 27 | }); 28 | 29 | return () => { 30 | channel.removeEventListener('message', handler); 31 | channel.close(); 32 | }; 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/event-bus/from-event-bus.ts: -------------------------------------------------------------------------------- 1 | import { Event, EventObject, AnyEventObject, InvokeCreator, Subscription } from 'xstate'; 2 | 3 | type Listener = (event: Event) => void; 4 | 5 | export class EventBus { 6 | state: 'running' | 'stopped' = 'running'; 7 | listeners: Set> = new Set(); 8 | 9 | constructor(readonly id: string) {} 10 | 11 | protected get isStopped() { 12 | return this.state === 'stopped'; 13 | } 14 | 15 | subscribe(listener: Listener): Subscription { 16 | if (this.isStopped) return { unsubscribe: () => {} }; 17 | 18 | this.listeners.add(listener); 19 | 20 | return { 21 | unsubscribe: () => this.listeners.delete(listener), 22 | }; 23 | } 24 | 25 | send(event: Event, listenerToIgnore?: Listener) { 26 | if (this.isStopped) return; 27 | 28 | for (const listener of this.listeners) { 29 | if (listener !== listenerToIgnore) listener(event); 30 | } 31 | } 32 | 33 | stop() { 34 | if (this.isStopped) return; 35 | 36 | this.state = 'stopped'; 37 | this.listeners.clear(); 38 | } 39 | } 40 | 41 | /** 42 | * Create an invoked service for a event bus. 43 | * @param createEventBus Create a EventBus 44 | * @returns an invoke creator 45 | */ 46 | export function fromEventBus( 47 | createEventBus: (context: TContext, event: TEvent) => EventBus 48 | ): InvokeCreator { 49 | return (context, event) => (sendBack, receive) => { 50 | const bus = createEventBus(context, event); 51 | 52 | const listener: Listener = (event) => { 53 | sendBack(event); 54 | }; 55 | 56 | const subscription = bus.subscribe(listener); 57 | 58 | receive((event) => { 59 | bus.send(event, listener); 60 | }); 61 | 62 | return () => { 63 | subscription.unsubscribe(); 64 | }; 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/event-bus/from-event-listener.ts: -------------------------------------------------------------------------------- 1 | import { InvokeCallback } from 'xstate'; 2 | 3 | export function fromEventListener( 4 | eventName: E 5 | ): () => InvokeCallback { 6 | return () => (sendBack) => { 7 | addEventListener(eventName, (e) => sendBack(e)); 8 | return () => { 9 | removeEventListener(eventName, (e) => sendBack(e)); 10 | }; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Async 2 | export { fromDynamicImport } from './async/from-dynamic-import'; 3 | 4 | // Workers 5 | export { fromWebWorker, interpretInWebWorker } from './workers/from-web-worker'; 6 | export { fromSharedWorker, interpretInSharedWorker } from './workers/from-shared-worker'; 7 | export { fromServiceWorker } from './workers/from-service-worker'; 8 | export { fromAudioWorklet } from './workers/from-audio-worklet'; 9 | 10 | // Realtime 11 | export { fromWebSocket } from './real-time/from-web-socket'; 12 | export { fromWebRTC } from './real-time/from-webrtc'; 13 | export { fromEventSource } from './real-time/from-event-source'; 14 | 15 | // Event Bus 16 | export { EventBus, fromEventBus } from './event-bus/from-event-bus'; 17 | export { fromBroadcastChannel } from './event-bus/from-broadcast-channel'; 18 | export { fromEventListener } from './event-bus/from-event-listener'; 19 | -------------------------------------------------------------------------------- /src/real-time/from-event-source.ts: -------------------------------------------------------------------------------- 1 | import { AnyEventObject, EventObject, InvokeCreator } from 'xstate'; 2 | import { getEventType } from 'xstate/lib/utils'; 3 | 4 | export function fromEventSource( 5 | createEventSource: (context: TContext, event: TEvent) => EventSource 6 | ): InvokeCreator { 7 | return (context, event) => (sendBack) => { 8 | const eventSource = createEventSource(context, event); 9 | 10 | eventSource.addEventListener('message', (event: MessageEvent) => { 11 | try { 12 | // Will error out if the data is not a valid event 13 | getEventType(event.data); 14 | sendBack(event.data); 15 | } catch {} 16 | }); 17 | 18 | // TODO 19 | // eventSource.addEventListener('error', (event) => { 20 | // sendBack({ type: 'error', data: event }); 21 | // }); 22 | 23 | return () => { 24 | eventSource.close(); 25 | }; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/real-time/from-web-socket.ts: -------------------------------------------------------------------------------- 1 | import { AnyEventObject, EventObject, InvokeCreator } from 'xstate'; 2 | import { getEventType } from 'xstate/lib/utils'; 3 | 4 | export function fromWebSocket( 5 | createSocket: (context: TContext, event: TEvent) => WebSocket 6 | ): InvokeCreator { 7 | return (context, event) => (sendBack, receive) => { 8 | const socket = createSocket(context, event); 9 | 10 | socket.addEventListener('message', (event: MessageEvent) => { 11 | try { 12 | // Will error out if the data is not a valid event 13 | const data = JSON.parse(event.data); 14 | getEventType(data); 15 | sendBack(data); 16 | } catch {} 17 | }); 18 | 19 | // TODO 20 | // socket.addEventListener('error', (event) => { 21 | // sendBack({ type: 'error', data: event }); 22 | // }); 23 | 24 | receive((event) => { 25 | socket.send(JSON.stringify(event)); 26 | }); 27 | 28 | return () => { 29 | socket.close(); 30 | }; 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/real-time/from-webrtc.ts: -------------------------------------------------------------------------------- 1 | import { EventObject, AnyEventObject, InvokeCreator } from 'xstate'; 2 | import { getEventType } from 'xstate/lib/utils'; 3 | 4 | // TODO: figure out how webRTC works 5 | export function fromWebRTC( 6 | createWebRTC: (context: TContext, event: TEvent) => any 7 | ): InvokeCreator { 8 | return (context, event) => (sendBack, receive) => {}; 9 | } 10 | -------------------------------------------------------------------------------- /src/workers/from-audio-worklet.ts: -------------------------------------------------------------------------------- 1 | import { EventObject, AnyEventObject, InvokeCreator } from 'xstate'; 2 | import { getEventType } from 'xstate/lib/utils'; 3 | 4 | export function fromAudioWorklet( 5 | createAudioWorkletNode: (context: TContext, event: TEvent) => AudioWorkletNode 6 | ): InvokeCreator { 7 | return (context, event) => (sendBack, receive) => { 8 | const node = createAudioWorkletNode(context, event); 9 | 10 | const handler = (event: MessageEvent) => { 11 | try { 12 | // Will error out if the data is not a valid event 13 | getEventType(event.data); 14 | sendBack(event.data); 15 | } catch {} 16 | }; 17 | 18 | node.port.addEventListener('message', handler); 19 | 20 | receive(({ _transfer, ...event }) => { 21 | node.port.postMessage(event, { transfer: _transfer }); 22 | }); 23 | 24 | return () => { 25 | node.port.removeEventListener('message', handler); 26 | node.port.close(); 27 | }; 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/workers/from-service-worker.ts: -------------------------------------------------------------------------------- 1 | import { AnyEventObject, EventObject, InvokeCreator } from 'xstate'; 2 | import { getEventType } from 'xstate/lib/utils'; 3 | 4 | export function fromServiceWorker< 5 | TContext, 6 | TEvent extends EventObject = AnyEventObject 7 | >(): InvokeCreator { 8 | return () => (sendBack, receive) => { 9 | const handler = (event: MessageEvent) => { 10 | try { 11 | // Will error out if the data is not a valid event 12 | getEventType(event.data); 13 | sendBack(event.data); 14 | } catch {} 15 | }; 16 | navigator.serviceWorker.addEventListener('message', handler); 17 | 18 | receive(({ _transfer, ...event }) => { 19 | navigator.serviceWorker.controller?.postMessage(event, _transfer); 20 | }); 21 | 22 | return () => { 23 | navigator.serviceWorker.removeEventListener('message', handler); 24 | }; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/workers/from-shared-worker.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnyEventObject, 3 | AnyInterpreter, 4 | DefaultContext, 5 | EventObject, 6 | interpret, 7 | InterpreterOptions, 8 | InvokeCreator, 9 | StateMachine, 10 | StateSchema, 11 | Typestate, 12 | } from 'xstate'; 13 | import { getEventType } from 'xstate/lib/utils'; 14 | 15 | export function fromSharedWorker( 16 | createWorker: (context: TContext, event: TEvent) => SharedWorker 17 | ): InvokeCreator { 18 | return (context, event) => (sendBack, receive) => { 19 | const worker = createWorker(context, event); 20 | 21 | worker.port.start(); 22 | 23 | const handler = (event: MessageEvent) => { 24 | try { 25 | // Will error out if the data is not a valid event 26 | getEventType(event.data); 27 | sendBack(event.data); 28 | } catch {} 29 | }; 30 | 31 | worker.port.addEventListener('message', handler); 32 | 33 | receive(({ _transfer, ...event }) => { 34 | worker.port.postMessage(event, _transfer); 35 | }); 36 | 37 | return () => { 38 | worker.port.removeEventListener('message', handler); 39 | worker.port.close(); 40 | }; 41 | }; 42 | } 43 | 44 | export function interpretInSharedWorker< 45 | TContext = DefaultContext, 46 | TStateSchema extends StateSchema = any, 47 | TEvent extends EventObject = EventObject, 48 | TTypestate extends Typestate = { value: any; context: TContext } 49 | >( 50 | machine: StateMachine, 51 | options?: Partial 52 | ) { 53 | // TODO type as WorkerGlobalScope 54 | const _self = self as any; 55 | 56 | const service = interpret(machine, { 57 | ...options, 58 | deferEvents: true, 59 | parent: { 60 | send: ({ _transfer, ...event }) => { 61 | _self.postMessage(event, _transfer); 62 | }, 63 | } as AnyInterpreter, // should probably be a different type 64 | }); 65 | 66 | _self.addEventListener('connect' as any, (event: MessageEvent) => { 67 | const [port] = event.ports; 68 | 69 | port.addEventListener('message', (event: MessageEvent) => { 70 | try { 71 | // Will error out if the data is not a valid event 72 | getEventType(event.data); 73 | service.send(event.data); 74 | } catch {} 75 | }); 76 | }); 77 | 78 | return service; 79 | } 80 | -------------------------------------------------------------------------------- /src/workers/from-web-worker.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnyEventObject, 3 | AnyInterpreter, 4 | DefaultContext, 5 | EventObject, 6 | interpret, 7 | InterpreterOptions, 8 | InvokeCreator, 9 | StateMachine, 10 | StateSchema, 11 | Typestate, 12 | } from 'xstate'; 13 | import { getEventType } from 'xstate/lib/utils'; 14 | 15 | export function fromWebWorker( 16 | createWorker: (context: TContext, event: TEvent) => Worker 17 | ): InvokeCreator { 18 | return (context, event) => (sendBack, receive) => { 19 | const worker = createWorker(context, event); 20 | const handler = (event: MessageEvent) => { 21 | try { 22 | // Will error out if the data is not a valid event 23 | getEventType(event.data); 24 | sendBack(event.data); 25 | } catch {} 26 | }; 27 | worker.addEventListener('message', handler); 28 | 29 | receive(({ _transfer, ...event }) => { 30 | worker.postMessage(event, _transfer); 31 | }); 32 | 33 | return () => { 34 | worker.removeEventListener('message', handler); 35 | worker.terminate(); 36 | }; 37 | }; 38 | } 39 | 40 | export function interpretInWebWorker< 41 | TContext = DefaultContext, 42 | TStateSchema extends StateSchema = any, 43 | TEvent extends EventObject = EventObject, 44 | TTypestate extends Typestate = { value: any; context: TContext } 45 | >( 46 | machine: StateMachine, 47 | options?: Partial 48 | ) { 49 | // TODO type as WorkerGlobalScope 50 | const _self = self as any; 51 | 52 | const service = interpret(machine, { 53 | ...options, 54 | deferEvents: true, 55 | parent: { 56 | send: ({ _transfer, ...event }) => { 57 | _self.postMessage(event, _transfer); 58 | }, 59 | } as AnyInterpreter, // should probably be a different type 60 | }); 61 | 62 | _self.addEventListener('message', (event: MessageEvent) => { 63 | try { 64 | // Will error out if the data is not a valid event 65 | getEventType(event.data); 66 | service.send(event.data); 67 | } catch {} 68 | }); 69 | 70 | return service; 71 | } 72 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "noEmit": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "paths": { 17 | "@src": ["./src"] 18 | } 19 | }, 20 | "include": ["src", "examples", "vite-env.d.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import path from 'path'; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | '@src': path.resolve(__dirname, '/src'), 8 | }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/dom-mediacapture-record@^1.0.7": 6 | version "1.0.7" 7 | resolved "https://registry.yarnpkg.com/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.7.tgz#08bacca4296ef521d59049f43e65cf971bbf6be1" 8 | integrity sha512-ddDIRTO1ajtbxaNo2o7fPJggpN54PZf1ZUJKOjto2ENMJE/9GKUvaw3ZRuQzlS/p0E+PnIcssxfoqYJ4yiXSBw== 9 | 10 | "@types/node@^16.7.6": 11 | version "16.7.6" 12 | resolved "https://registry.yarnpkg.com/@types/node/-/node-16.7.6.tgz#8666478db8095aa66e25b7e469f3e7b53ea2855e" 13 | integrity sha512-VESVNFoa/ahYA62xnLBjo5ur6gPsgEE5cNRy8SrdnkZ2nwJSW0kJ4ufbFr2zuU9ALtHM8juY53VcRoTA7htXSg== 14 | 15 | "@types/three@^0.129.0": 16 | version "0.129.1" 17 | resolved "https://registry.yarnpkg.com/@types/three/-/three-0.129.1.tgz#e488e686132256dfd61cffd4d97ea6cf176a4a8d" 18 | integrity sha512-31VTcjAQNggIrCH9NVotTYsr5Ws/QMGGTaMK6RP3EgyzW2WEuZdm25TNidd6PJ3e4a6/hbswNacnTsjwc7ksbw== 19 | 20 | "@xstate/inspect@^0.4.1": 21 | version "0.4.1" 22 | resolved "https://registry.yarnpkg.com/@xstate/inspect/-/inspect-0.4.1.tgz#6b00b945c03280a333de8ddf10e91830c361052d" 23 | integrity sha512-tcGBUCVymP8ij0K2fXZckrwrQkmNJL1DgQAM0eSILQKb8iBDN3GeqhYkNCS4HP29EzF2TZL0+k74FPWKaU9Fhw== 24 | dependencies: 25 | fast-safe-stringify "^2.0.7" 26 | 27 | colorette@^1.2.2: 28 | version "1.2.2" 29 | resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" 30 | integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== 31 | 32 | d3-random@^2.2.2: 33 | version "2.2.2" 34 | resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-2.2.2.tgz#5eebd209ef4e45a2b362b019c1fb21c2c98cbb6e" 35 | integrity sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw== 36 | 37 | esbuild@^0.12.5: 38 | version "0.12.7" 39 | resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.12.7.tgz#0243ff94b241d9c815720083633731bc5b601b24" 40 | integrity sha512-vxENKWBo928ErLiT/uUv8Sl2EoC5cF3cZzCTc8hDEh9ZAZ75xblJCr72NeJo74lxWaGopIePZPIWq1qDpLUHQQ== 41 | 42 | fast-safe-stringify@^2.0.7: 43 | version "2.0.7" 44 | resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" 45 | integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA== 46 | 47 | fsevents@~2.3.1: 48 | version "2.3.2" 49 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" 50 | integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== 51 | 52 | function-bind@^1.1.1: 53 | version "1.1.1" 54 | resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" 55 | integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== 56 | 57 | has@^1.0.3: 58 | version "1.0.3" 59 | resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" 60 | integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== 61 | dependencies: 62 | function-bind "^1.1.1" 63 | 64 | is-core-module@^2.2.0: 65 | version "2.4.0" 66 | resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.4.0.tgz#8e9fc8e15027b011418026e98f0e6f4d86305cc1" 67 | integrity sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A== 68 | dependencies: 69 | has "^1.0.3" 70 | 71 | nanoid@^3.1.23: 72 | version "3.1.23" 73 | resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81" 74 | integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw== 75 | 76 | path-parse@^1.0.6: 77 | version "1.0.7" 78 | resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" 79 | integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== 80 | 81 | postcss@^8.3.0: 82 | version "8.3.0" 83 | resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.0.tgz#b1a713f6172ca427e3f05ef1303de8b65683325f" 84 | integrity sha512-+ogXpdAjWGa+fdYY5BQ96V/6tAo+TdSSIMP5huJBIygdWwKtVoB5JWZ7yUd4xZ8r+8Kvvx4nyg/PQ071H4UtcQ== 85 | dependencies: 86 | colorette "^1.2.2" 87 | nanoid "^3.1.23" 88 | source-map-js "^0.6.2" 89 | 90 | resolve@^1.19.0: 91 | version "1.20.0" 92 | resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" 93 | integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== 94 | dependencies: 95 | is-core-module "^2.2.0" 96 | path-parse "^1.0.6" 97 | 98 | rollup@^2.38.5: 99 | version "2.51.1" 100 | resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.51.1.tgz#87bcd4095fe79b14c9bec0edc7ffa44e4827f793" 101 | integrity sha512-8xfDbAtBleXotb6qKEHWuo/jkn94a9dVqGc7Rwl3sqspCVlnCfbRek7ldhCARSi7h32H0xR4QThm1t9zHN+3uw== 102 | optionalDependencies: 103 | fsevents "~2.3.1" 104 | 105 | source-map-js@^0.6.2: 106 | version "0.6.2" 107 | resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" 108 | integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== 109 | 110 | three@^0.129.0: 111 | version "0.129.0" 112 | resolved "https://registry.yarnpkg.com/three/-/three-0.129.0.tgz#f5e530bbc96eac5d5b4749cb5da886ef0d42f554" 113 | integrity sha512-wiWio1yVRg2Oj6WEWsTHQo5eSzYpEwSBtPSi3OofNpvFbf26HFfb9kw4FZJNjII4qxzp0b1xLB11+tKkBGB1ZA== 114 | 115 | typescript@^4.2.3: 116 | version "4.3.2" 117 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805" 118 | integrity sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw== 119 | 120 | vite@^2.3.5: 121 | version "2.3.7" 122 | resolved "https://registry.yarnpkg.com/vite/-/vite-2.3.7.tgz#3023892419367465e1af1739578f8663d04243b2" 123 | integrity sha512-Y0xRz11MPYu/EAvzN94+FsOZHbSvO6FUvHv127CyG7mV6oDoay2bw+g5y9wW3Blf8OY3chaz3nc/DcRe1IQ3Nw== 124 | dependencies: 125 | esbuild "^0.12.5" 126 | postcss "^8.3.0" 127 | resolve "^1.19.0" 128 | rollup "^2.38.5" 129 | optionalDependencies: 130 | fsevents "~2.3.1" 131 | 132 | xstate@^4.23.0: 133 | version "4.23.1" 134 | resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.23.1.tgz#a85ce0ca4ad9737f1497e2da7a3abc6a5697fbcd" 135 | integrity sha512-8ZoCe8d6wDSPfkep+GBgi+fKAdMyXcaizoNf5FKceEhlso4+9n1TeK6oviaDsXZ3Z5O8xKkJOxXPNuD4cA9LCw== 136 | --------------------------------------------------------------------------------