-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathworker.js
165 lines (158 loc) · 6.94 KB
/
worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import { createMachine, interpret } from 'xstate'
export default {
fetch: (req, env) => {
if (req.method == 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': req.headers.get('Origin') || req.headers.get('host'),
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Cookie, X-Forwarded-Proto, X-Forwarded-For',
'Access-Control-Max-Age': '86400',
},
})
}
const { hostname, pathname } = new URL(req.url)
const instance = pathname.split('/')[1]
const id = env.STATE.idFromName(hostname + instance)
const stub = env.STATE.get(id)
return stub.fetch(req)
},
}
export class State {
state
env
/** @type {import('xstate').MachineConfig} */
machineDefinition
/** @type {import('xstate').StateValue} */
machineState
/** @type {import('xstate').StateMachine} */
machine
/** @type {import('xstate').Interpreter} */
service
/** @type {import('xstate').State} */
serviceState
constructor(state, env) {
this.state = state
this.env = env
state.blockConcurrencyWhile(async () => {
;[this.machineDefinition, this.machineState] = await Promise.all([this.state.storage.get('machineDefinition'), this.state.storage.get('machineState')])
if (this.machineDefinition) {
this.startMachine(this.machineState)
}
})
}
/**
* @param {import('xstate').StateValue|undefined} state
*/
startMachine(state) {
this.machine = createMachine(this.machineDefinition)
this.service = interpret(this.machine)
this.service.onTransition(async (state) => {
this.serviceState = state
if (this.machineState === state.value) return
await this.state.storage.put('machineState', (this.machineState = state.value))
const meta = Object.values(state.meta)[0]
const callback = meta?.callback || state.configuration.flatMap((c) => c.config).reduce((acc, c) => ({ ...acc, ...c }), {}).callback
if (callback) {
const callbacks = Array.isArray(callback) ? callback : [callback]
for (let i = 0; i < callbacks.length; i++) {
const url = typeof callbacks[i] === 'string' || callbacks[i] instanceof String ? callbacks[i] : (callbacks[i].url || callbacks[i].callback)
const init = callbacks[i].init || meta?.init || {}
init.headers = callbacks[i].headers || meta?.headers || init.headers || {}
// Check if the callback has a body (cascade: callback > meta > init > event)
const body = callbacks[i].body || meta?.body
// If the callback has a body, set it and set the method to POST
if (body) init.body = JSON.stringify(body)
init.method = callbacks[i].method || meta?.method || init.method || init.body ? 'POST' : 'GET'
// If a method requests abody but doesn't have one, stringify the event and set the content-type to application/json
if (!init.body && ['POST', 'PUT', 'PATCH'].includes(init.method)) init.body = JSON.stringify(state.event)
if (init.body && !init.headers['content-type']) init.headers['content-type'] = 'application/json'
console.log({ url, init, state })
const data = await fetch(url, init)
// Escape special regex characters and replace x with \d to check if the callback status code matches an event (e.g. 2xx)
const event = state?.nextEvents.find((e) => data.status.toString().match(new RegExp(e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/x/gi, '\\d'))))
this.service.send(event || data.status.toString(), await data.json())
}
}
})
try {
this.service.start(state)
} catch (error) {
// Machines with new definitions that have incompatible states can't recycle the old state
this.reset()
}
}
async reset() {
// Stop the service and reset the state before restarting it
this.service?.stop()
this.service = undefined
this.serviceState = undefined
if (this.machineState) {
this.machineState = undefined
await this.state.storage.delete('machineState')
}
// Restart the service
if (this.machineDefinition) this.startMachine()
}
/**
* @param {import('xstate').MachineConfig} machineDefinition
*/
async update(machineDefinition) {
// Don't update if the new definition is empty or hasn't changed
if (!machineDefinition || machineDefinition === this.machineDefinition) return
this.service?.stop()
await this.state.storage.put('machineDefinition', (this.machineDefinition = machineDefinition))
this.startMachine(this.machineState)
}
/**
* @param {Request} req
*/
async fetch(req) {
let { user, redirect, method, origin, pathSegments, search, json } = await this.env.CTX.fetch(req).then((res) => res.json())
if (redirect) return Response.redirect(redirect)
const [instance, stateEvent] = pathSegments
const update = '?update='
const isSearchBasedUpdate = search.startsWith(update)
const retval = {
api: {
icon: '●→',
name: 'state.do',
description: 'Finite State Machine implementation with Durable Objects based on xstate',
url: 'https://state.do/',
type: 'https://apis.do/state',
endpoints: {
create: origin + '/:key?{state_machine}',
reset: origin + '/:key?reset',
update: origin + '/:key?update={state_machine}',
read: origin + '/:key',
event: origin + '/:key/:event',
},
site: 'https://state.do',
repo: 'https://github.com/drivly/state.do',
},
instance,
}
if (search === '?reset') {
await this.reset()
} else if (search.startsWith('?import=')) {
const machine = await fetch(decodeURIComponent(search.substring('?import='.length))).then((res) => res.json())
await this.update(machine)
} else if (search === '?machine') {
if (this.machineDefinition) retval.machine = this.machineDefinition
retval.user = user
return new Response(JSON.stringify(retval, null, 2), { headers: { 'content-type': 'application/json; charset=utf-8' } })
} else if ((search && (!this.machineDefinition || isSearchBasedUpdate)) || (method === 'POST' && json?.states)) {
await this.update((search && JSON.parse(decodeURIComponent(search.substring(isSearchBasedUpdate ? update.length : 1)))) || json)
} else {
if (json) console.log(json)
if (stateEvent) this.service?.send(stateEvent, json)
else if (json) this.service?.send(json)
}
retval.state = this.machineState
if (this.serviceState?.nextEvents && this.serviceState.nextEvents.length)
retval.events = this.serviceState.nextEvents.map((e) => `${origin}/${instance}/${encodeURIComponent(e)}`)
retval.user = user
return new Response(JSON.stringify(retval, null, 2), { headers: { 'content-type': 'application/json; charset=utf-8' } })
}
}