@methodacting/actor-kit 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +7 -0
- package/README.md +2042 -0
- package/dist/browser.d.ts +384 -0
- package/dist/browser.js +2 -0
- package/dist/browser.js.map +1 -0
- package/dist/index.d.ts +644 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/react.d.ts +416 -0
- package/dist/react.js +2 -0
- package/dist/react.js.map +1 -0
- package/dist/src/alarms.d.ts +47 -0
- package/dist/src/alarms.d.ts.map +1 -0
- package/dist/src/browser.d.ts +2 -0
- package/dist/src/browser.d.ts.map +1 -0
- package/dist/src/constants.d.ts +12 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/createAccessToken.d.ts +9 -0
- package/dist/src/createAccessToken.d.ts.map +1 -0
- package/dist/src/createActorFetch.d.ts +18 -0
- package/dist/src/createActorFetch.d.ts.map +1 -0
- package/dist/src/createActorKitClient.d.ts +13 -0
- package/dist/src/createActorKitClient.d.ts.map +1 -0
- package/dist/src/createActorKitContext.d.ts +29 -0
- package/dist/src/createActorKitContext.d.ts.map +1 -0
- package/dist/src/createActorKitMockClient.d.ts +11 -0
- package/dist/src/createActorKitMockClient.d.ts.map +1 -0
- package/dist/src/createActorKitRouter.d.ts +4 -0
- package/dist/src/createActorKitRouter.d.ts.map +1 -0
- package/dist/src/createMachineServer.d.ts +20 -0
- package/dist/src/createMachineServer.d.ts.map +1 -0
- package/dist/src/durable-object-system.d.ts +36 -0
- package/dist/src/durable-object-system.d.ts.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/react.d.ts +2 -0
- package/dist/src/react.d.ts.map +1 -0
- package/dist/src/schemas.d.ts +312 -0
- package/dist/src/schemas.d.ts.map +1 -0
- package/dist/src/server.d.ts +3 -0
- package/dist/src/server.d.ts.map +1 -0
- package/dist/src/storage.d.ts +64 -0
- package/dist/src/storage.d.ts.map +1 -0
- package/dist/src/storybook.d.ts +13 -0
- package/dist/src/storybook.d.ts.map +1 -0
- package/dist/src/test.d.ts +2 -0
- package/dist/src/test.d.ts.map +1 -0
- package/dist/src/types.d.ts +181 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/utils.d.ts +30 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/withActorKit.d.ts +9 -0
- package/dist/src/withActorKit.d.ts.map +1 -0
- package/dist/src/worker.d.ts +3 -0
- package/dist/src/worker.d.ts.map +1 -0
- package/package.json +87 -0
- package/src/alarms.ts +237 -0
- package/src/browser.ts +1 -0
- package/src/constants.ts +31 -0
- package/src/createAccessToken.ts +29 -0
- package/src/createActorFetch.ts +111 -0
- package/src/createActorKitClient.ts +224 -0
- package/src/createActorKitContext.tsx +228 -0
- package/src/createActorKitMockClient.ts +138 -0
- package/src/createActorKitRouter.ts +149 -0
- package/src/createMachineServer.ts +844 -0
- package/src/durable-object-system.ts +212 -0
- package/src/global.d.ts +7 -0
- package/src/index.ts +6 -0
- package/src/react.ts +1 -0
- package/src/schemas.ts +95 -0
- package/src/server.ts +3 -0
- package/src/storage.ts +404 -0
- package/src/storybook.ts +42 -0
- package/src/test.ts +1 -0
- package/src/types.ts +334 -0
- package/src/utils.ts +171 -0
- package/src/withActorKit.tsx +103 -0
- package/src/worker.ts +2 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
// Import necessary dependencies and types
|
|
2
|
+
import { DurableObject } from "cloudflare:workers";
|
|
3
|
+
import { compare } from "fast-json-patch";
|
|
4
|
+
import {
|
|
5
|
+
Actor,
|
|
6
|
+
AnyEventObject,
|
|
7
|
+
createActor,
|
|
8
|
+
InputFrom,
|
|
9
|
+
matchesState,
|
|
10
|
+
SnapshotFrom,
|
|
11
|
+
StateValueFrom,
|
|
12
|
+
Subscription,
|
|
13
|
+
} from "xstate";
|
|
14
|
+
import { xstateMigrate } from "xstate-migrate";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { AlarmTypes, PERSISTED_SNAPSHOT_KEY } from "./constants";
|
|
17
|
+
import { CallerSchema } from "./schemas";
|
|
18
|
+
import {
|
|
19
|
+
ActorKitInputProps,
|
|
20
|
+
ActorKitStateMachine,
|
|
21
|
+
ActorKitSystemEvent,
|
|
22
|
+
ActorServer,
|
|
23
|
+
Caller,
|
|
24
|
+
CallerSnapshotFrom,
|
|
25
|
+
ClientEventFrom,
|
|
26
|
+
EnvFromMachine,
|
|
27
|
+
MachineServerOptions,
|
|
28
|
+
ServiceEventFrom,
|
|
29
|
+
WithActorKitContext,
|
|
30
|
+
WithActorKitEvent,
|
|
31
|
+
} from "./types";
|
|
32
|
+
import { ActorKitStorage } from "./storage";
|
|
33
|
+
import { AlarmManager, generateAlarmId } from "./alarms";
|
|
34
|
+
import {
|
|
35
|
+
createAlarmScheduler,
|
|
36
|
+
handleXStateAlarm,
|
|
37
|
+
restoreScheduledEvents,
|
|
38
|
+
} from "./durable-object-system";
|
|
39
|
+
import { assert, getCallerFromRequest } from "./utils";
|
|
40
|
+
|
|
41
|
+
// Define schemas for storage and WebSocket attachments
|
|
42
|
+
const StorageSchema = z.object({
|
|
43
|
+
actorType: z.string(),
|
|
44
|
+
actorId: z.string(),
|
|
45
|
+
initialCaller: CallerSchema,
|
|
46
|
+
input: z.record(z.unknown()),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const WebSocketAttachmentSchema = z.object({
|
|
50
|
+
caller: CallerSchema,
|
|
51
|
+
lastSentChecksum: z.string().optional(),
|
|
52
|
+
});
|
|
53
|
+
type WebSocketAttachment = z.infer<typeof WebSocketAttachmentSchema>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates a MachineServer class that extends DurableObject and implements ActorServer.
|
|
57
|
+
* This function is the main entry point for creating a machine server.
|
|
58
|
+
*/
|
|
59
|
+
export const createMachineServer = <
|
|
60
|
+
TClientEvent extends AnyEventObject,
|
|
61
|
+
TServiceEvent extends AnyEventObject,
|
|
62
|
+
TInputSchema extends z.ZodObject<z.ZodRawShape>,
|
|
63
|
+
TMachine extends ActorKitStateMachine<
|
|
64
|
+
(
|
|
65
|
+
| WithActorKitEvent<TClientEvent, "client">
|
|
66
|
+
| WithActorKitEvent<TServiceEvent, "service">
|
|
67
|
+
| ActorKitSystemEvent
|
|
68
|
+
) & {
|
|
69
|
+
storage: DurableObjectStorage;
|
|
70
|
+
env: EnvFromMachine<TMachine>;
|
|
71
|
+
},
|
|
72
|
+
z.infer<TInputSchema> & {
|
|
73
|
+
id: string;
|
|
74
|
+
caller: Caller;
|
|
75
|
+
storage: DurableObjectStorage;
|
|
76
|
+
},
|
|
77
|
+
WithActorKitContext<any, any, any>
|
|
78
|
+
>
|
|
79
|
+
>({
|
|
80
|
+
machine,
|
|
81
|
+
schemas,
|
|
82
|
+
options,
|
|
83
|
+
}: {
|
|
84
|
+
machine: TMachine;
|
|
85
|
+
schemas: {
|
|
86
|
+
clientEvent: z.ZodSchema<TClientEvent>;
|
|
87
|
+
serviceEvent: z.ZodSchema<TServiceEvent>;
|
|
88
|
+
inputProps: TInputSchema;
|
|
89
|
+
};
|
|
90
|
+
options?: MachineServerOptions;
|
|
91
|
+
}): new (
|
|
92
|
+
state: DurableObjectState,
|
|
93
|
+
env: EnvFromMachine<TMachine>,
|
|
94
|
+
ctx: ExecutionContext
|
|
95
|
+
) => ActorServer<TMachine> =>
|
|
96
|
+
class MachineServerImpl
|
|
97
|
+
extends DurableObject
|
|
98
|
+
implements ActorServer<TMachine>
|
|
99
|
+
{
|
|
100
|
+
// Class properties
|
|
101
|
+
actor: Actor<TMachine> | undefined;
|
|
102
|
+
actorType: string | undefined;
|
|
103
|
+
actorId: string | undefined;
|
|
104
|
+
input: Record<string, unknown> | undefined;
|
|
105
|
+
initialCaller: Caller | undefined;
|
|
106
|
+
lastPersistedSnapshot: SnapshotFrom<TMachine> | null = null;
|
|
107
|
+
lastSnapshotChecksum: string | null = null;
|
|
108
|
+
snapshotCache: Map<
|
|
109
|
+
string,
|
|
110
|
+
{ snapshot: SnapshotFrom<TMachine>; timestamp: number }
|
|
111
|
+
> = new Map();
|
|
112
|
+
state: DurableObjectState;
|
|
113
|
+
storage: ActorKitStorage; // Now uses SQLite storage wrapper
|
|
114
|
+
kvStorage: DurableObjectStorage; // Keep reference for backward compatibility
|
|
115
|
+
attachments: Map<WebSocket, WebSocketAttachment>;
|
|
116
|
+
subscriptions: Map<WebSocket, Subscription>;
|
|
117
|
+
env: EnvFromMachine<TMachine>;
|
|
118
|
+
currentChecksum: string | null = null;
|
|
119
|
+
alarmManager: AlarmManager | null = null; // Alarm manager for delayed events
|
|
120
|
+
private enableAlarms: boolean;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Constructor for the MachineServerImpl class.
|
|
124
|
+
* Initializes the server and sets up WebSocket connections.
|
|
125
|
+
*/
|
|
126
|
+
constructor(
|
|
127
|
+
state: DurableObjectState,
|
|
128
|
+
env: EnvFromMachine<TMachine>,
|
|
129
|
+
ctx: ExecutionContext
|
|
130
|
+
) {
|
|
131
|
+
super(state, env);
|
|
132
|
+
this.state = state;
|
|
133
|
+
this.kvStorage = state.storage;
|
|
134
|
+
this.storage = new ActorKitStorage(state.storage);
|
|
135
|
+
this.env = env;
|
|
136
|
+
this.attachments = new Map();
|
|
137
|
+
this.subscriptions = new Map();
|
|
138
|
+
this.enableAlarms = options?.enableAlarms !== false;
|
|
139
|
+
|
|
140
|
+
// Initialize alarm manager if alarms are enabled
|
|
141
|
+
if (this.enableAlarms) {
|
|
142
|
+
this.alarmManager = new AlarmManager(this.storage, state);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Initialize actor data from storage
|
|
146
|
+
this.state.blockConcurrencyWhile(async () => {
|
|
147
|
+
// Try to load from SQLite first (new format)
|
|
148
|
+
const actorMeta = await this.storage.getActorMeta();
|
|
149
|
+
|
|
150
|
+
if (actorMeta) {
|
|
151
|
+
// Loaded from SQLite
|
|
152
|
+
this.actorType = actorMeta.actorType;
|
|
153
|
+
this.actorId = actorMeta.actorId;
|
|
154
|
+
this.initialCaller = actorMeta.initialCaller;
|
|
155
|
+
this.input = actorMeta.input;
|
|
156
|
+
} else {
|
|
157
|
+
// Try legacy KV format for migration
|
|
158
|
+
const [actorType, actorId, initialCallerString, inputString] =
|
|
159
|
+
await Promise.all([
|
|
160
|
+
this.kvStorage.get("actorType"),
|
|
161
|
+
this.kvStorage.get("actorId"),
|
|
162
|
+
this.kvStorage.get("initialCaller"),
|
|
163
|
+
this.kvStorage.get("input"),
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
if (actorType && actorId && initialCallerString && inputString) {
|
|
167
|
+
try {
|
|
168
|
+
const parsedData = StorageSchema.parse({
|
|
169
|
+
actorType,
|
|
170
|
+
actorId,
|
|
171
|
+
initialCaller: JSON.parse(
|
|
172
|
+
initialCallerString as string
|
|
173
|
+
) as Caller,
|
|
174
|
+
input: JSON.parse(inputString as string),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
this.actorType = parsedData.actorType;
|
|
178
|
+
this.actorId = parsedData.actorId;
|
|
179
|
+
this.initialCaller = parsedData.initialCaller;
|
|
180
|
+
this.input = parsedData.input;
|
|
181
|
+
|
|
182
|
+
// Migrate to SQLite
|
|
183
|
+
await this.storage.setActorMeta({
|
|
184
|
+
actorId: this.actorId,
|
|
185
|
+
actorType: this.actorType,
|
|
186
|
+
initialCaller: this.initialCaller,
|
|
187
|
+
input: this.input,
|
|
188
|
+
});
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error("Failed to parse stored data:", error);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (this.actorId) {
|
|
196
|
+
console.debug(
|
|
197
|
+
`[${this.actorId}] Attempting to load actor data from storage`
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
if (options?.persisted) {
|
|
201
|
+
// Try SQLite first, then fall back to KV
|
|
202
|
+
let persistedSnapshot = await this.loadPersistedSnapshotFromSQLite();
|
|
203
|
+
if (!persistedSnapshot) {
|
|
204
|
+
persistedSnapshot = await this.loadPersistedSnapshotFromKV();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (persistedSnapshot) {
|
|
208
|
+
await this.restorePersistedActor(persistedSnapshot);
|
|
209
|
+
} else {
|
|
210
|
+
this.#ensureActorRunning();
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
this.#ensureActorRunning();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Resume all existing WebSockets
|
|
218
|
+
this.state.getWebSockets().forEach((ws) => {
|
|
219
|
+
this.#subscribeSocketToActor(ws);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Schedule cache cleanup alarm if alarms are enabled
|
|
223
|
+
if (this.alarmManager) {
|
|
224
|
+
await this.#scheduleCacheCleanupAlarm();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Ensures that the actor is running. If not, it creates and initializes the actor.
|
|
231
|
+
* @private
|
|
232
|
+
*/
|
|
233
|
+
#ensureActorRunning() {
|
|
234
|
+
assert(this.actorId, "actorId is not set");
|
|
235
|
+
assert(this.actorType, "actorType is not set");
|
|
236
|
+
assert(this.input, "input is not set");
|
|
237
|
+
assert(this.initialCaller, "initialCaller is not set");
|
|
238
|
+
|
|
239
|
+
if (!this.actor) {
|
|
240
|
+
console.debug(`[${this.actorId}] Creating new actor`);
|
|
241
|
+
const input = {
|
|
242
|
+
id: this.actorId,
|
|
243
|
+
caller: this.initialCaller,
|
|
244
|
+
env: this.env,
|
|
245
|
+
storage: this.kvStorage, // Use KV storage for machine compatibility
|
|
246
|
+
...this.input,
|
|
247
|
+
} satisfies ActorKitInputProps;
|
|
248
|
+
|
|
249
|
+
// Create actor with durable object system if alarms are enabled
|
|
250
|
+
const actorOptions: any = { input };
|
|
251
|
+
|
|
252
|
+
this.actor = createActor(machine, actorOptions);
|
|
253
|
+
|
|
254
|
+
// Monkey patch the scheduler to use alarms if alarm manager is available
|
|
255
|
+
if (this.alarmManager && this.actor.system) {
|
|
256
|
+
console.debug(`[${this.actorId}] Replacing scheduler with alarm-based scheduler`);
|
|
257
|
+
this.actor.system.scheduler = createAlarmScheduler(
|
|
258
|
+
this.alarmManager,
|
|
259
|
+
this.actor.system
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (options?.persisted) {
|
|
264
|
+
console.debug(
|
|
265
|
+
`[${this.actorId}] Setting up persistence for new actor`
|
|
266
|
+
);
|
|
267
|
+
this.#setupStatePersistence(this.actor);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
this.actor.start();
|
|
271
|
+
console.debug(`[${this.actorId}] New actor started`);
|
|
272
|
+
}
|
|
273
|
+
return this.actor;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#subscribeSocketToActor(ws: WebSocket) {
|
|
277
|
+
try {
|
|
278
|
+
const attachment = WebSocketAttachmentSchema.parse(
|
|
279
|
+
ws.deserializeAttachment()
|
|
280
|
+
);
|
|
281
|
+
this.attachments.set(ws, attachment);
|
|
282
|
+
|
|
283
|
+
// Send initial state update
|
|
284
|
+
this.#sendStateUpdate(ws);
|
|
285
|
+
|
|
286
|
+
// Set up subscription for this WebSocket
|
|
287
|
+
const sub = this.actor!.subscribe((snapshot) => {
|
|
288
|
+
this.#sendStateUpdate(ws);
|
|
289
|
+
});
|
|
290
|
+
this.subscriptions.set(ws, sub);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error("Failed to subscribe WebSocket to actor:", error);
|
|
293
|
+
// Optionally, handle the error (e.g., close the WebSocket)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#sendStateUpdate(ws: WebSocket) {
|
|
298
|
+
assert(this.actor, "actor is not running");
|
|
299
|
+
const attachment = this.attachments.get(ws);
|
|
300
|
+
assert(attachment, "Attachment missing for WebSocket");
|
|
301
|
+
|
|
302
|
+
const fullSnapshot = this.actor.getSnapshot();
|
|
303
|
+
const currentChecksum = this.#calculateChecksum(fullSnapshot);
|
|
304
|
+
|
|
305
|
+
// Store snapshot in cache with timestamp
|
|
306
|
+
this.snapshotCache.set(currentChecksum, {
|
|
307
|
+
snapshot: fullSnapshot,
|
|
308
|
+
timestamp: Date.now(),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Update current checksum
|
|
312
|
+
this.currentChecksum = currentChecksum;
|
|
313
|
+
|
|
314
|
+
// Only send updates if the checksum has changed
|
|
315
|
+
if (attachment.lastSentChecksum !== currentChecksum) {
|
|
316
|
+
const nextSnapshot = this.#createCallerSnapshot(
|
|
317
|
+
fullSnapshot,
|
|
318
|
+
attachment.caller.id
|
|
319
|
+
);
|
|
320
|
+
let lastSnapshot = {};
|
|
321
|
+
if (attachment.lastSentChecksum) {
|
|
322
|
+
const cachedData = this.snapshotCache.get(
|
|
323
|
+
attachment.lastSentChecksum
|
|
324
|
+
);
|
|
325
|
+
if (cachedData) {
|
|
326
|
+
lastSnapshot = this.#createCallerSnapshot(
|
|
327
|
+
cachedData.snapshot,
|
|
328
|
+
attachment.caller.id
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const operations = compare(lastSnapshot, nextSnapshot);
|
|
334
|
+
|
|
335
|
+
if (operations.length) {
|
|
336
|
+
ws.send(JSON.stringify({ operations, checksum: currentChecksum }));
|
|
337
|
+
attachment.lastSentChecksum = currentChecksum;
|
|
338
|
+
ws.serializeAttachment(attachment);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Sets up state persistence for the actor if the persisted option is enabled.
|
|
345
|
+
* @private
|
|
346
|
+
*/
|
|
347
|
+
#setupStatePersistence(actor: Actor<TMachine>) {
|
|
348
|
+
console.debug(`[${this.actorId}] Setting up state persistence`);
|
|
349
|
+
actor.subscribe((state) => {
|
|
350
|
+
const fullSnapshot = actor.getSnapshot();
|
|
351
|
+
if (fullSnapshot) {
|
|
352
|
+
this.#persistSnapshot(fullSnapshot);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Persists the given snapshot if it's different from the last persisted snapshot.
|
|
359
|
+
* @private
|
|
360
|
+
*/
|
|
361
|
+
async #persistSnapshot(snapshot: SnapshotFrom<TMachine>) {
|
|
362
|
+
try {
|
|
363
|
+
if (
|
|
364
|
+
!this.lastPersistedSnapshot ||
|
|
365
|
+
compare(this.lastPersistedSnapshot, snapshot).length > 0
|
|
366
|
+
) {
|
|
367
|
+
console.debug(`[${this.actorId}] Persisting new snapshot`);
|
|
368
|
+
|
|
369
|
+
// Calculate checksum
|
|
370
|
+
const checksum = this.#calculateChecksum(snapshot);
|
|
371
|
+
|
|
372
|
+
// Save to SQLite (new format)
|
|
373
|
+
if (this.actorId) {
|
|
374
|
+
await this.storage.setSnapshot(this.actorId, snapshot, checksum);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Also save to KV for backward compatibility during migration
|
|
378
|
+
await this.kvStorage.put(
|
|
379
|
+
PERSISTED_SNAPSHOT_KEY,
|
|
380
|
+
JSON.stringify(snapshot)
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
this.lastPersistedSnapshot = snapshot;
|
|
384
|
+
} else {
|
|
385
|
+
console.debug(
|
|
386
|
+
`[${this.actorId}] No changes in snapshot, skipping persistence`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error(`[${this.actorId}] Error persisting snapshot:`, error);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Handles incoming HTTP requests and sets up WebSocket connections.
|
|
396
|
+
*/
|
|
397
|
+
async fetch(request: Request): Promise<Response> {
|
|
398
|
+
const actor = this.#ensureActorRunning();
|
|
399
|
+
assert(this.actorType, "actorType is not set");
|
|
400
|
+
assert(this.actorId, "actorId is not set");
|
|
401
|
+
|
|
402
|
+
const webSocketPair = new WebSocketPair();
|
|
403
|
+
const [client, server] = Object.values(webSocketPair);
|
|
404
|
+
|
|
405
|
+
let caller: Caller | undefined;
|
|
406
|
+
try {
|
|
407
|
+
caller = await getCallerFromRequest(
|
|
408
|
+
request,
|
|
409
|
+
this.actorType,
|
|
410
|
+
this.actorId,
|
|
411
|
+
this.env.ACTOR_KIT_SECRET
|
|
412
|
+
);
|
|
413
|
+
} catch (error: any) {
|
|
414
|
+
return new Response(`Error: ${error.message}`, { status: 401 });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!caller) {
|
|
418
|
+
return new Response("Unauthorized", { status: 401 });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Parse the checksum from the request, if provided
|
|
422
|
+
const url = new URL(request.url);
|
|
423
|
+
const clientChecksum = url.searchParams.get("checksum");
|
|
424
|
+
|
|
425
|
+
this.state.acceptWebSocket(server);
|
|
426
|
+
const initialAttachment = {
|
|
427
|
+
caller,
|
|
428
|
+
lastSentChecksum: clientChecksum ?? undefined,
|
|
429
|
+
};
|
|
430
|
+
server.serializeAttachment(initialAttachment);
|
|
431
|
+
|
|
432
|
+
// Subscribe the new WebSocket to the actor
|
|
433
|
+
this.#subscribeSocketToActor(server);
|
|
434
|
+
|
|
435
|
+
return new Response(null, {
|
|
436
|
+
status: 101,
|
|
437
|
+
webSocket: client,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Handles incoming WebSocket messages.
|
|
443
|
+
*/
|
|
444
|
+
async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
|
|
445
|
+
const attachment = this.attachments.get(ws);
|
|
446
|
+
assert(attachment, "Attachment missing for WebSocket");
|
|
447
|
+
|
|
448
|
+
let event: ClientEventFrom<TMachine> | ServiceEventFrom<TMachine>;
|
|
449
|
+
|
|
450
|
+
const { caller } = attachment;
|
|
451
|
+
if (caller.type === "client") {
|
|
452
|
+
const clientEvent = schemas.clientEvent.parse(
|
|
453
|
+
JSON.parse(message as string)
|
|
454
|
+
);
|
|
455
|
+
event = {
|
|
456
|
+
...clientEvent,
|
|
457
|
+
caller,
|
|
458
|
+
} as ClientEventFrom<TMachine>;
|
|
459
|
+
} else if (caller.type === "service") {
|
|
460
|
+
const serviceEvent = schemas.serviceEvent.parse(
|
|
461
|
+
JSON.parse(message as string)
|
|
462
|
+
);
|
|
463
|
+
event = {
|
|
464
|
+
...serviceEvent,
|
|
465
|
+
caller,
|
|
466
|
+
} as ServiceEventFrom<TMachine>;
|
|
467
|
+
} else {
|
|
468
|
+
throw new Error(`Unknown caller type: ${caller.type}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this.send(event);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Handles WebSocket errors.
|
|
476
|
+
*/
|
|
477
|
+
async webSocketError(ws: WebSocket, error: Error) {
|
|
478
|
+
console.error(
|
|
479
|
+
"[MachineServerImpl] WebSocket error:",
|
|
480
|
+
error.message,
|
|
481
|
+
error.stack
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Handles WebSocket closure.
|
|
487
|
+
*/
|
|
488
|
+
async webSocketClose(
|
|
489
|
+
ws: WebSocket,
|
|
490
|
+
code: number,
|
|
491
|
+
reason: string,
|
|
492
|
+
wasClean: boolean
|
|
493
|
+
) {
|
|
494
|
+
ws.close(code, "Durable Object is closing WebSocket");
|
|
495
|
+
// Remove the subscription for the socket
|
|
496
|
+
const subscription = this.subscriptions.get(ws);
|
|
497
|
+
if (subscription) {
|
|
498
|
+
subscription.unsubscribe();
|
|
499
|
+
this.subscriptions.delete(ws);
|
|
500
|
+
}
|
|
501
|
+
// Remove the attachment for the socket
|
|
502
|
+
this.attachments.delete(ws);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Sends an event to the actor.
|
|
507
|
+
*/
|
|
508
|
+
send(event: ClientEventFrom<TMachine> | ServiceEventFrom<TMachine>): void {
|
|
509
|
+
assert(this.actor, "Actor is not running");
|
|
510
|
+
this.actor.send({
|
|
511
|
+
...event,
|
|
512
|
+
env: this.env,
|
|
513
|
+
storage: this.storage,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Retrieves a snapshot of the actor's state for a specific caller.
|
|
519
|
+
* @param caller The caller requesting the snapshot.
|
|
520
|
+
* @returns An object containing the caller-specific snapshot and a checksum for the full snapshot.
|
|
521
|
+
*/
|
|
522
|
+
async getSnapshot(
|
|
523
|
+
caller: Caller,
|
|
524
|
+
options?: {
|
|
525
|
+
waitForEvent?: ClientEventFrom<TMachine>;
|
|
526
|
+
waitForState?: StateValueFrom<TMachine>;
|
|
527
|
+
timeout?: number;
|
|
528
|
+
errorOnWaitTimeout?: boolean;
|
|
529
|
+
}
|
|
530
|
+
): Promise<{
|
|
531
|
+
checksum: string;
|
|
532
|
+
snapshot: CallerSnapshotFrom<TMachine>;
|
|
533
|
+
}> {
|
|
534
|
+
this.#ensureActorRunning();
|
|
535
|
+
|
|
536
|
+
if (options?.waitForEvent || options?.waitForState) {
|
|
537
|
+
const timeoutPromise = new Promise((resolve, reject) => {
|
|
538
|
+
setTimeout(() => {
|
|
539
|
+
if (options.errorOnWaitTimeout !== false) {
|
|
540
|
+
reject(new Error("Timeout waiting for event or state"));
|
|
541
|
+
} else {
|
|
542
|
+
resolve(this.#getCurrentSnapshot(caller));
|
|
543
|
+
}
|
|
544
|
+
}, options.timeout || 5000);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const waitPromise: Promise<{
|
|
548
|
+
checksum: string;
|
|
549
|
+
snapshot: CallerSnapshotFrom<TMachine>;
|
|
550
|
+
}> = new Promise((resolve) => {
|
|
551
|
+
const sub = this.actor!.subscribe((state) => {
|
|
552
|
+
if (
|
|
553
|
+
(options.waitForEvent &&
|
|
554
|
+
this.#matchesEvent(state, options.waitForEvent)) ||
|
|
555
|
+
(options.waitForState &&
|
|
556
|
+
this.#matchesState(state, options.waitForState))
|
|
557
|
+
) {
|
|
558
|
+
sub && sub.unsubscribe();
|
|
559
|
+
resolve(this.#getCurrentSnapshot(caller));
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
return Promise.race([waitPromise, timeoutPromise]) as Promise<{
|
|
565
|
+
checksum: string;
|
|
566
|
+
snapshot: CallerSnapshotFrom<TMachine>;
|
|
567
|
+
}>;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// const checksum =
|
|
571
|
+
return this.#getCurrentSnapshot(caller);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
#getCurrentSnapshot(caller: Caller) {
|
|
575
|
+
const fullSnapshot = this.actor!.getSnapshot();
|
|
576
|
+
const callerSnapshot = this.#createCallerSnapshot(
|
|
577
|
+
fullSnapshot,
|
|
578
|
+
caller.id
|
|
579
|
+
);
|
|
580
|
+
const checksum = this.#calculateChecksum(fullSnapshot);
|
|
581
|
+
return { snapshot: callerSnapshot, checksum };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
#matchesEvent(
|
|
585
|
+
snapshot: SnapshotFrom<TMachine>,
|
|
586
|
+
event: ClientEventFrom<TMachine>
|
|
587
|
+
): boolean {
|
|
588
|
+
// todo implement later
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
#matchesState(
|
|
593
|
+
snapshot: SnapshotFrom<TMachine>,
|
|
594
|
+
stateValue: StateValueFrom<TMachine>
|
|
595
|
+
): boolean {
|
|
596
|
+
return matchesState(stateValue, snapshot);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Calculates a checksum for the given snapshot.
|
|
601
|
+
* @private
|
|
602
|
+
*/
|
|
603
|
+
#calculateChecksum(snapshot: SnapshotFrom<TMachine>): string {
|
|
604
|
+
const snapshotString = JSON.stringify(snapshot);
|
|
605
|
+
return this.#hashString(snapshotString);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Generates a simple hash for a given string.
|
|
610
|
+
* @private
|
|
611
|
+
*/
|
|
612
|
+
#hashString(str: string): string {
|
|
613
|
+
let hash = 0;
|
|
614
|
+
for (let i = 0; i < str.length; i++) {
|
|
615
|
+
const char = str.charCodeAt(i);
|
|
616
|
+
hash = (hash << 5) - hash + char;
|
|
617
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
618
|
+
}
|
|
619
|
+
return hash.toString(16); // Convert to hexadecimal
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Creates a caller-specific snapshot from the full snapshot.
|
|
624
|
+
* @private
|
|
625
|
+
*/
|
|
626
|
+
#createCallerSnapshot(
|
|
627
|
+
fullSnapshot: SnapshotFrom<TMachine>,
|
|
628
|
+
callerId: string
|
|
629
|
+
): CallerSnapshotFrom<TMachine> {
|
|
630
|
+
const snap = fullSnapshot as any;
|
|
631
|
+
assert(snap.value, "expected value");
|
|
632
|
+
assert(snap.context.public, "expected public key in context");
|
|
633
|
+
assert(snap.context.private, "expected private key in context");
|
|
634
|
+
return {
|
|
635
|
+
public: snap.context.public,
|
|
636
|
+
private: snap.context.private[callerId] || {},
|
|
637
|
+
value: snap.value,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Spawns a new actor with the given properties.
|
|
643
|
+
*/
|
|
644
|
+
async spawn(props: {
|
|
645
|
+
actorType: string;
|
|
646
|
+
actorId: string;
|
|
647
|
+
caller: Caller;
|
|
648
|
+
input: Record<string, unknown>;
|
|
649
|
+
}) {
|
|
650
|
+
if (!this.actorType && !this.actorId && !this.initialCaller) {
|
|
651
|
+
// Store actor data in SQLite storage
|
|
652
|
+
await this.storage.setActorMeta({
|
|
653
|
+
actorId: props.actorId,
|
|
654
|
+
actorType: props.actorType,
|
|
655
|
+
initialCaller: props.caller,
|
|
656
|
+
input: props.input,
|
|
657
|
+
}).catch((error) => {
|
|
658
|
+
console.error("Error storing actor data:", error);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// Update the instance properties
|
|
662
|
+
this.actorType = props.actorType;
|
|
663
|
+
this.actorId = props.actorId;
|
|
664
|
+
this.initialCaller = props.caller;
|
|
665
|
+
this.input = props.input;
|
|
666
|
+
|
|
667
|
+
this.#ensureActorRunning();
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Schedule the cache cleanup alarm
|
|
673
|
+
* @private
|
|
674
|
+
*/
|
|
675
|
+
async #scheduleCacheCleanupAlarm() {
|
|
676
|
+
if (!this.alarmManager) return;
|
|
677
|
+
|
|
678
|
+
const CLEANUP_INTERVAL = 300000; // 5 minutes
|
|
679
|
+
const alarmId = generateAlarmId();
|
|
680
|
+
|
|
681
|
+
await this.alarmManager.schedule({
|
|
682
|
+
id: alarmId,
|
|
683
|
+
type: AlarmTypes["cache-cleanup"],
|
|
684
|
+
scheduledAt: Date.now() + CLEANUP_INTERVAL,
|
|
685
|
+
repeatInterval: CLEANUP_INTERVAL, // Recurring
|
|
686
|
+
payload: {},
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Handle cache cleanup alarm
|
|
692
|
+
* @private
|
|
693
|
+
*/
|
|
694
|
+
async #handleCacheCleanupAlarm() {
|
|
695
|
+
const now = Date.now();
|
|
696
|
+
const CLEANUP_AGE = 300000; // 5 minutes
|
|
697
|
+
|
|
698
|
+
for (const [checksum, { timestamp }] of this.snapshotCache.entries()) {
|
|
699
|
+
if (now - timestamp > CLEANUP_AGE) {
|
|
700
|
+
this.snapshotCache.delete(checksum);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Load persisted snapshot from SQLite storage
|
|
707
|
+
* @private
|
|
708
|
+
*/
|
|
709
|
+
async loadPersistedSnapshotFromSQLite(): Promise<SnapshotFrom<TMachine> | null> {
|
|
710
|
+
if (!this.actorId) return null;
|
|
711
|
+
|
|
712
|
+
const snapshotData = await this.storage.getSnapshot(this.actorId);
|
|
713
|
+
if (snapshotData) {
|
|
714
|
+
console.debug(`[${this.actorId}] Loaded persisted snapshot from SQLite`);
|
|
715
|
+
return snapshotData.snapshot as SnapshotFrom<TMachine>;
|
|
716
|
+
}
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Load persisted snapshot from KV storage (legacy)
|
|
722
|
+
* @private
|
|
723
|
+
*/
|
|
724
|
+
async loadPersistedSnapshotFromKV(): Promise<SnapshotFrom<TMachine> | null> {
|
|
725
|
+
const snapshotString = await this.kvStorage.get(PERSISTED_SNAPSHOT_KEY);
|
|
726
|
+
if (snapshotString) {
|
|
727
|
+
console.debug(`[${this.actorId}] Loaded persisted snapshot from KV`);
|
|
728
|
+
return JSON.parse(snapshotString as string);
|
|
729
|
+
}
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Add this method to restore the persisted actor
|
|
734
|
+
async restorePersistedActor(persistedSnapshot: SnapshotFrom<TMachine>) {
|
|
735
|
+
console.debug(
|
|
736
|
+
`[${this.actorId}] Restoring persisted actor from `,
|
|
737
|
+
persistedSnapshot
|
|
738
|
+
);
|
|
739
|
+
assert(this.actorId, "actorId is not set");
|
|
740
|
+
assert(this.actorType, "actorType is not set");
|
|
741
|
+
assert(this.initialCaller, "initialCaller is not set");
|
|
742
|
+
assert(this.input, "input is not set");
|
|
743
|
+
|
|
744
|
+
const input = {
|
|
745
|
+
id: this.actorId,
|
|
746
|
+
caller: this.initialCaller,
|
|
747
|
+
storage: this.kvStorage, // Use KV storage for machine compatibility
|
|
748
|
+
env: this.env,
|
|
749
|
+
...this.input,
|
|
750
|
+
} as InputFrom<TMachine>;
|
|
751
|
+
|
|
752
|
+
const migrations = xstateMigrate.generateMigrations(
|
|
753
|
+
machine,
|
|
754
|
+
persistedSnapshot,
|
|
755
|
+
input
|
|
756
|
+
);
|
|
757
|
+
const restoredSnapshot = xstateMigrate.applyMigrations(
|
|
758
|
+
persistedSnapshot,
|
|
759
|
+
migrations
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
// Create actor options with system provider if alarms are enabled
|
|
763
|
+
const actorOptions: any = {
|
|
764
|
+
snapshot: restoredSnapshot,
|
|
765
|
+
input,
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
this.actor = createActor(machine, actorOptions);
|
|
769
|
+
|
|
770
|
+
// Monkey patch the scheduler to use alarms if alarm manager is available
|
|
771
|
+
if (this.alarmManager && this.actor.system) {
|
|
772
|
+
console.debug(`[${this.actorId}] Replacing scheduler with alarm-based scheduler (restored)`);
|
|
773
|
+
this.actor.system.scheduler = createAlarmScheduler(
|
|
774
|
+
this.alarmManager,
|
|
775
|
+
this.actor.system
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
// Restore any scheduled events from alarm storage
|
|
779
|
+
const pendingAlarms = await this.alarmManager.getPendingAlarms();
|
|
780
|
+
const xstateAlarms = pendingAlarms.filter((a) => a.type === "xstate-delay");
|
|
781
|
+
if (xstateAlarms.length > 0) {
|
|
782
|
+
restoreScheduledEvents(xstateAlarms.map((a) => ({
|
|
783
|
+
payload: a.payload as any,
|
|
784
|
+
scheduledAt: a.scheduledAt,
|
|
785
|
+
})));
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (options?.persisted) {
|
|
790
|
+
console.debug(
|
|
791
|
+
`[${this.actorId}] Setting up persistence for restored actor`
|
|
792
|
+
);
|
|
793
|
+
this.#setupStatePersistence(this.actor);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
this.actor.start();
|
|
797
|
+
console.debug(`[${this.actorId}] Restored actor started`);
|
|
798
|
+
|
|
799
|
+
this.actor.send({
|
|
800
|
+
type: "RESUME",
|
|
801
|
+
caller: { id: this.actorId, type: "system" },
|
|
802
|
+
env: this.env,
|
|
803
|
+
storage: this.kvStorage,
|
|
804
|
+
} as any);
|
|
805
|
+
console.debug(`[${this.actorId}] Sent RESUME event to restored actor`);
|
|
806
|
+
|
|
807
|
+
this.lastPersistedSnapshot = restoredSnapshot as any;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Durable Object alarm handler
|
|
812
|
+
* Called when a scheduled alarm fires
|
|
813
|
+
*/
|
|
814
|
+
async alarm(): Promise<void> {
|
|
815
|
+
if (!this.alarmManager) {
|
|
816
|
+
console.warn("Alarm fired but alarmManager is not initialized");
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
console.debug(`[${this.actorId}] Alarm fired, processing due alarms`);
|
|
821
|
+
|
|
822
|
+
await this.alarmManager.handleDueAlarms(async (alarm) => {
|
|
823
|
+
switch (alarm.type) {
|
|
824
|
+
case AlarmTypes["cache-cleanup"]:
|
|
825
|
+
await this.#handleCacheCleanupAlarm();
|
|
826
|
+
return true;
|
|
827
|
+
case AlarmTypes["xstate-delay"]:
|
|
828
|
+
// Handle XState delayed events
|
|
829
|
+
if (this.actor) {
|
|
830
|
+
const eventData = alarm.payload as any;
|
|
831
|
+
console.debug(
|
|
832
|
+
`[${this.actorId}] Processing XState delayed event: ${eventData.scheduledEventId}`
|
|
833
|
+
);
|
|
834
|
+
// Use the handleXStateAlarm function to deliver the event
|
|
835
|
+
await handleXStateAlarm(eventData, this.actor);
|
|
836
|
+
}
|
|
837
|
+
return true;
|
|
838
|
+
default:
|
|
839
|
+
console.warn(`[${this.actorId}] Unknown alarm type: ${alarm.type}`);
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
};
|