@rivetkit/cloudflare-workers 2.0.2 → 2.0.4-rc.1

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.
@@ -1,42 +1,31 @@
1
- import type { Encoding } from "@rivetkit/core";
1
+ import type { Hono, Context as HonoContext } from "hono";
2
+ import type { Encoding, RegistryConfig, UniversalWebSocket } from "rivetkit";
2
3
  import {
3
4
  type ActorOutput,
4
5
  type CreateInput,
5
6
  type GetForIdInput,
6
7
  type GetOrCreateWithKeyInput,
7
8
  type GetWithKeyInput,
8
- HEADER_AUTH_DATA,
9
- HEADER_CONN_PARAMS,
10
- HEADER_ENCODING,
11
- HEADER_EXPOSE_INTERNAL_ERROR,
9
+ type ListActorsInput,
10
+ type ManagerDisplayInformation,
12
11
  type ManagerDriver,
13
- } from "@rivetkit/core/driver-helpers";
14
- import { ActorAlreadyExists, InternalError } from "@rivetkit/core/errors";
15
- import type { Context as HonoContext } from "hono";
12
+ WS_PROTOCOL_ACTOR,
13
+ WS_PROTOCOL_CONN_PARAMS,
14
+ WS_PROTOCOL_ENCODING,
15
+ WS_PROTOCOL_STANDARD,
16
+ WS_PROTOCOL_TARGET,
17
+ } from "rivetkit/driver-helpers";
18
+ import {
19
+ ActorDuplicateKey,
20
+ ActorNotFound,
21
+ InternalError,
22
+ } from "rivetkit/errors";
23
+ import { assertUnreachable } from "rivetkit/utils";
24
+ import { parseActorId } from "./actor-id";
16
25
  import { getCloudflareAmbientEnv } from "./handler";
17
26
  import { logger } from "./log";
18
27
  import type { Bindings } from "./mod";
19
- import { serializeKey, serializeNameAndKey } from "./util";
20
-
21
- // Actor metadata structure
22
- interface ActorData {
23
- name: string;
24
- key: string[];
25
- }
26
-
27
- // Key constants similar to Redis implementation
28
- const KEYS = {
29
- ACTOR: {
30
- // Combined key for actor metadata (name and key)
31
- metadata: (actorId: string) => `actor:${actorId}:metadata`,
32
-
33
- // Key index function for actor lookup
34
- keyIndex: (name: string, key: string[] = []) => {
35
- // Use serializeKey for consistent handling of all keys
36
- return `actor_key:${serializeKey(key)}`;
37
- },
38
- },
39
- };
28
+ import { serializeNameAndKey } from "./util";
40
29
 
41
30
  const STANDARD_WEBSOCKET_HEADERS = [
42
31
  "connection",
@@ -48,16 +37,24 @@ const STANDARD_WEBSOCKET_HEADERS = [
48
37
  ];
49
38
 
50
39
  export class CloudflareActorsManagerDriver implements ManagerDriver {
51
- async sendRequest(actorId: string, actorRequest: Request): Promise<Response> {
40
+ async sendRequest(
41
+ actorId: string,
42
+ actorRequest: Request,
43
+ ): Promise<Response> {
52
44
  const env = getCloudflareAmbientEnv();
53
45
 
54
- logger().debug("sending request to durable object", {
46
+ // Parse actor ID to get DO ID
47
+ const [doId] = parseActorId(actorId);
48
+
49
+ logger().debug({
50
+ msg: "sending request to durable object",
55
51
  actorId,
52
+ doId,
56
53
  method: actorRequest.method,
57
54
  url: actorRequest.url,
58
55
  });
59
56
 
60
- const id = env.ACTOR_DO.idFromString(actorId);
57
+ const id = env.ACTOR_DO.idFromString(doId);
61
58
  const stub = env.ACTOR_DO.get(id);
62
59
 
63
60
  return await stub.fetch(actorRequest);
@@ -68,34 +65,45 @@ export class CloudflareActorsManagerDriver implements ManagerDriver {
68
65
  actorId: string,
69
66
  encoding: Encoding,
70
67
  params: unknown,
71
- ): Promise<WebSocket> {
68
+ ): Promise<UniversalWebSocket> {
72
69
  const env = getCloudflareAmbientEnv();
73
70
 
74
- logger().debug("opening websocket to durable object", { actorId, path });
71
+ // Parse actor ID to get DO ID
72
+ const [doId] = parseActorId(actorId);
73
+
74
+ logger().debug({
75
+ msg: "opening websocket to durable object",
76
+ actorId,
77
+ doId,
78
+ path,
79
+ });
75
80
 
76
81
  // Make a fetch request to the Durable Object with WebSocket upgrade
77
- const id = env.ACTOR_DO.idFromString(actorId);
82
+ const id = env.ACTOR_DO.idFromString(doId);
78
83
  const stub = env.ACTOR_DO.get(id);
79
84
 
85
+ const protocols: string[] = [];
86
+ protocols.push(WS_PROTOCOL_STANDARD);
87
+ protocols.push(`${WS_PROTOCOL_TARGET}actor`);
88
+ protocols.push(`${WS_PROTOCOL_ACTOR}${encodeURIComponent(actorId)}`);
89
+ protocols.push(`${WS_PROTOCOL_ENCODING}${encoding}`);
90
+ if (params) {
91
+ protocols.push(
92
+ `${WS_PROTOCOL_CONN_PARAMS}${encodeURIComponent(JSON.stringify(params))}`,
93
+ );
94
+ }
95
+
80
96
  const headers: Record<string, string> = {
81
97
  Upgrade: "websocket",
82
98
  Connection: "Upgrade",
83
- [HEADER_EXPOSE_INTERNAL_ERROR]: "true",
84
- [HEADER_ENCODING]: encoding,
99
+ "sec-websocket-protocol": protocols.join(", "),
85
100
  };
86
- if (params) {
87
- headers[HEADER_CONN_PARAMS] = JSON.stringify(params);
88
- }
89
- // HACK: See packages/drivers/cloudflare-workers/src/websocket.ts
90
- headers["sec-websocket-protocol"] = "rivetkit";
91
101
 
92
102
  // Use the path parameter to determine the URL
93
- const url = `http://actor${path}`;
103
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
104
+ const url = `http://actor${normalizedPath}`;
94
105
 
95
- logger().debug("rewriting websocket url", {
96
- from: path,
97
- to: url,
98
- });
106
+ logger().debug({ msg: "rewriting websocket url", from: path, to: url });
99
107
 
100
108
  const response = await stub.fetch(url, {
101
109
  headers,
@@ -104,11 +112,12 @@ export class CloudflareActorsManagerDriver implements ManagerDriver {
104
112
 
105
113
  if (!webSocket) {
106
114
  throw new InternalError(
107
- "missing websocket connection in response from DO",
115
+ `missing websocket connection in response from DO\n\nStatus: ${response.status}\nResponse: ${await response.text()}`,
108
116
  );
109
117
  }
110
118
 
111
- logger().debug("durable object websocket connection open", {
119
+ logger().debug({
120
+ msg: "durable object websocket connection open",
112
121
  actorId,
113
122
  });
114
123
 
@@ -123,7 +132,11 @@ export class CloudflareActorsManagerDriver implements ManagerDriver {
123
132
  (webSocket as any).dispatchEvent(event);
124
133
  }, 0);
125
134
 
126
- return webSocket as unknown as WebSocket;
135
+ return webSocket as unknown as UniversalWebSocket;
136
+ }
137
+
138
+ async buildGatewayUrl(actorId: string): Promise<string> {
139
+ return `http://actor/gateway/${encodeURIComponent(actorId)}`;
127
140
  }
128
141
 
129
142
  async proxyRequest(
@@ -131,14 +144,21 @@ export class CloudflareActorsManagerDriver implements ManagerDriver {
131
144
  actorRequest: Request,
132
145
  actorId: string,
133
146
  ): Promise<Response> {
134
- logger().debug("forwarding request to durable object", {
147
+ const env = getCloudflareAmbientEnv();
148
+
149
+ // Parse actor ID to get DO ID
150
+ const [doId] = parseActorId(actorId);
151
+
152
+ logger().debug({
153
+ msg: "forwarding request to durable object",
135
154
  actorId,
155
+ doId,
136
156
  method: actorRequest.method,
137
157
  url: actorRequest.url,
138
158
  });
139
159
 
140
- const id = c.env.ACTOR_DO.idFromString(actorId);
141
- const stub = c.env.ACTOR_DO.get(id);
160
+ const id = env.ACTOR_DO.idFromString(doId);
161
+ const stub = env.ACTOR_DO.get(id);
142
162
 
143
163
  return await stub.fetch(actorRequest);
144
164
  }
@@ -149,9 +169,9 @@ export class CloudflareActorsManagerDriver implements ManagerDriver {
149
169
  actorId: string,
150
170
  encoding: Encoding,
151
171
  params: unknown,
152
- authData: unknown,
153
172
  ): Promise<Response> {
154
- logger().debug("forwarding websocket to durable object", {
173
+ logger().debug({
174
+ msg: "forwarding websocket to durable object",
155
175
  actorId,
156
176
  path,
157
177
  });
@@ -167,7 +187,8 @@ export class CloudflareActorsManagerDriver implements ManagerDriver {
167
187
  const newUrl = new URL(`http://actor${path}`);
168
188
  const actorRequest = new Request(newUrl, c.req.raw);
169
189
 
170
- logger().debug("rewriting websocket url", {
190
+ logger().debug({
191
+ msg: "rewriting websocket url",
171
192
  from: c.req.url,
172
193
  to: actorRequest.url,
173
194
  });
@@ -176,49 +197,85 @@ export class CloudflareActorsManagerDriver implements ManagerDriver {
176
197
  // HACK: Since we can't build a new request, we need to remove
177
198
  // non-standard headers manually
178
199
  const headerKeys: string[] = [];
179
- actorRequest.headers.forEach((v, k) => headerKeys.push(k));
200
+ actorRequest.headers.forEach((v, k) => {
201
+ headerKeys.push(k);
202
+ });
180
203
  for (const k of headerKeys) {
181
204
  if (!STANDARD_WEBSOCKET_HEADERS.includes(k)) {
182
205
  actorRequest.headers.delete(k);
183
206
  }
184
207
  }
185
208
 
186
- // Add RivetKit headers
187
- actorRequest.headers.set(HEADER_EXPOSE_INTERNAL_ERROR, "true");
188
- actorRequest.headers.set(HEADER_ENCODING, encoding);
209
+ // Build protocols for WebSocket connection
210
+ const protocols: string[] = [];
211
+ protocols.push(WS_PROTOCOL_STANDARD);
212
+ protocols.push(`${WS_PROTOCOL_TARGET}actor`);
213
+ protocols.push(`${WS_PROTOCOL_ACTOR}${encodeURIComponent(actorId)}`);
214
+ protocols.push(`${WS_PROTOCOL_ENCODING}${encoding}`);
189
215
  if (params) {
190
- actorRequest.headers.set(HEADER_CONN_PARAMS, JSON.stringify(params));
191
- }
192
- if (authData) {
193
- actorRequest.headers.set(HEADER_AUTH_DATA, JSON.stringify(authData));
216
+ protocols.push(
217
+ `${WS_PROTOCOL_CONN_PARAMS}${encodeURIComponent(JSON.stringify(params))}`,
218
+ );
194
219
  }
220
+ actorRequest.headers.set(
221
+ "sec-websocket-protocol",
222
+ protocols.join(", "),
223
+ );
195
224
 
196
- const id = c.env.ACTOR_DO.idFromString(actorId);
197
- const stub = c.env.ACTOR_DO.get(id);
225
+ // Parse actor ID to get DO ID
226
+ const env = getCloudflareAmbientEnv();
227
+ const [doId] = parseActorId(actorId);
228
+ const id = env.ACTOR_DO.idFromString(doId);
229
+ const stub = env.ACTOR_DO.get(id);
198
230
 
199
231
  return await stub.fetch(actorRequest);
200
232
  }
201
233
 
202
234
  async getForId({
203
235
  c,
236
+ name,
204
237
  actorId,
205
- }: GetForIdInput<{ Bindings: Bindings }>): Promise<ActorOutput | undefined> {
238
+ }: GetForIdInput<{ Bindings: Bindings }>): Promise<
239
+ ActorOutput | undefined
240
+ > {
206
241
  const env = getCloudflareAmbientEnv();
207
242
 
208
- // Get actor metadata from KV (combined name and key)
209
- const actorData = (await env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), {
210
- type: "json",
211
- })) as ActorData | null;
243
+ // Parse actor ID to get DO ID and expected generation
244
+ const [doId, expectedGeneration] = parseActorId(actorId);
245
+
246
+ // Get the Durable Object stub
247
+ const id = env.ACTOR_DO.idFromString(doId);
248
+ const stub = env.ACTOR_DO.get(id);
249
+
250
+ // Call the DO's getMetadata method
251
+ const result = await stub.getMetadata();
252
+
253
+ if (!result) {
254
+ logger().debug({
255
+ msg: "getForId: actor not found",
256
+ actorId,
257
+ });
258
+ return undefined;
259
+ }
212
260
 
213
- // If the actor doesn't exist, return undefined
214
- if (!actorData) {
261
+ // Check if the actor IDs match in order to check if the generation matches
262
+ if (result.actorId !== actorId) {
263
+ logger().debug({
264
+ msg: "getForId: generation mismatch",
265
+ requestedActorId: actorId,
266
+ actualActorId: result.actorId,
267
+ });
215
268
  return undefined;
216
269
  }
217
270
 
271
+ if (result.destroying) {
272
+ throw new ActorNotFound(actorId);
273
+ }
274
+
218
275
  return {
219
- actorId,
220
- name: actorData.name,
221
- key: actorData.key,
276
+ actorId: result.actorId,
277
+ name: result.name,
278
+ key: result.key,
222
279
  };
223
280
  }
224
281
 
@@ -231,44 +288,82 @@ export class CloudflareActorsManagerDriver implements ManagerDriver {
231
288
  > {
232
289
  const env = getCloudflareAmbientEnv();
233
290
 
234
- logger().debug("getWithKey: searching for actor", { name, key });
291
+ logger().debug({ msg: "getWithKey: searching for actor", name, key });
235
292
 
236
293
  // Generate deterministic ID from the name and key
237
- // This is aligned with how createActor generates IDs
238
294
  const nameKeyString = serializeNameAndKey(name, key);
239
- const actorId = env.ACTOR_DO.idFromName(nameKeyString).toString();
295
+ const doId = env.ACTOR_DO.idFromName(nameKeyString).toString();
240
296
 
241
- // Check if the actor metadata exists
242
- const actorData = await env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), {
243
- type: "json",
244
- });
297
+ // Try to get the Durable Object to see if it exists
298
+ const id = env.ACTOR_DO.idFromString(doId);
299
+ const stub = env.ACTOR_DO.get(id);
245
300
 
246
- if (!actorData) {
247
- logger().debug("getWithKey: no actor found with matching name and key", {
301
+ // Check if actor exists without creating it
302
+ const result = await stub.getMetadata();
303
+
304
+ if (result) {
305
+ logger().debug({
306
+ msg: "getWithKey: found actor with matching name and key",
307
+ actorId: result.actorId,
308
+ name: result.name,
309
+ key: result.key,
310
+ });
311
+ return {
312
+ actorId: result.actorId,
313
+ name: result.name,
314
+ key: result.key,
315
+ };
316
+ } else {
317
+ logger().debug({
318
+ msg: "getWithKey: no actor found with matching name and key",
248
319
  name,
249
320
  key,
250
- actorId,
321
+ doId,
251
322
  });
252
323
  return undefined;
253
324
  }
325
+ }
254
326
 
255
- logger().debug("getWithKey: found actor with matching name and key", {
256
- actorId,
327
+ async getOrCreateWithKey({
328
+ c,
329
+ name,
330
+ key,
331
+ input,
332
+ }: GetOrCreateWithKeyInput<{ Bindings: Bindings }>): Promise<ActorOutput> {
333
+ const env = getCloudflareAmbientEnv();
334
+
335
+ // Create a deterministic ID from the actor name and key
336
+ // This ensures that actors with the same name and key will have the same ID
337
+ const nameKeyString = serializeNameAndKey(name, key);
338
+ const doId = env.ACTOR_DO.idFromName(nameKeyString);
339
+
340
+ // Get or create actor using the Durable Object's method
341
+ const actor = env.ACTOR_DO.get(doId);
342
+ const result = await actor.create({
257
343
  name,
258
344
  key,
345
+ input,
346
+ allowExisting: true,
259
347
  });
260
- return this.#buildActorOutput(c, actorId);
261
- }
348
+ if ("success" in result) {
349
+ const { actorId, created } = result.success;
350
+ logger().debug({
351
+ msg: "getOrCreateWithKey result",
352
+ actorId,
353
+ name,
354
+ key,
355
+ created,
356
+ });
262
357
 
263
- async getOrCreateWithKey(
264
- input: GetOrCreateWithKeyInput,
265
- ): Promise<ActorOutput> {
266
- // TODO: Prevent race condition here
267
- const getOutput = await this.getWithKey(input);
268
- if (getOutput) {
269
- return getOutput;
358
+ return {
359
+ actorId,
360
+ name,
361
+ key,
362
+ };
363
+ } else if ("error" in result) {
364
+ throw new Error(`Error: ${JSON.stringify(result.error)}`);
270
365
  } else {
271
- return await this.createActor(input);
366
+ assertUnreachable(result);
272
367
  }
273
368
  }
274
369
 
@@ -280,62 +375,70 @@ export class CloudflareActorsManagerDriver implements ManagerDriver {
280
375
  }: CreateInput<{ Bindings: Bindings }>): Promise<ActorOutput> {
281
376
  const env = getCloudflareAmbientEnv();
282
377
 
283
- // Check if actor with the same name and key already exists
284
- const existingActor = await this.getWithKey({ c, name, key });
285
- if (existingActor) {
286
- throw new ActorAlreadyExists(name, key);
287
- }
288
-
289
378
  // Create a deterministic ID from the actor name and key
290
379
  // This ensures that actors with the same name and key will have the same ID
291
380
  const nameKeyString = serializeNameAndKey(name, key);
292
381
  const doId = env.ACTOR_DO.idFromName(nameKeyString);
293
- const actorId = doId.toString();
294
382
 
295
- // Init actor
383
+ // Create actor - this will fail if it already exists
296
384
  const actor = env.ACTOR_DO.get(doId);
297
- await actor.initialize({
385
+ const result = await actor.create({
298
386
  name,
299
387
  key,
300
388
  input,
389
+ allowExisting: false,
301
390
  });
302
391
 
303
- // Store combined actor metadata (name and key)
304
- const actorData: ActorData = { name, key };
305
- await env.ACTOR_KV.put(
306
- KEYS.ACTOR.metadata(actorId),
307
- JSON.stringify(actorData),
308
- );
392
+ if ("success" in result) {
393
+ const { actorId } = result.success;
394
+ return {
395
+ actorId,
396
+ name,
397
+ key,
398
+ };
399
+ } else if ("error" in result) {
400
+ if (result.error.actorAlreadyExists) {
401
+ throw new ActorDuplicateKey(name, key);
402
+ }
309
403
 
310
- // Add to key index for lookups by name and key
311
- await env.ACTOR_KV.put(KEYS.ACTOR.keyIndex(name, key), actorId);
404
+ throw new InternalError(
405
+ `Unknown error creating actor: ${JSON.stringify(result.error)}`,
406
+ );
407
+ } else {
408
+ assertUnreachable(result);
409
+ }
410
+ }
312
411
 
313
- return {
314
- actorId,
412
+ async listActors({ c, name }: ListActorsInput): Promise<ActorOutput[]> {
413
+ logger().warn({
414
+ msg: "listActors not fully implemented for Cloudflare Workers",
315
415
  name,
316
- key,
416
+ });
417
+ return [];
418
+ }
419
+
420
+ displayInformation(): ManagerDisplayInformation {
421
+ return {
422
+ properties: {
423
+ Driver: "Cloudflare Workers",
424
+ },
317
425
  };
318
426
  }
319
427
 
320
- // Helper method to build actor output from an ID
321
- async #buildActorOutput(
322
- c: any,
323
- actorId: string,
324
- ): Promise<ActorOutput | undefined> {
428
+ setGetUpgradeWebSocket(): void {
429
+ // No-op for Cloudflare Workers - WebSocket upgrades are handled by the DO
430
+ }
431
+
432
+ async kvGet(actorId: string, key: Uint8Array): Promise<string | null> {
325
433
  const env = getCloudflareAmbientEnv();
326
434
 
327
- const actorData = (await env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), {
328
- type: "json",
329
- })) as ActorData | null;
435
+ // Parse actor ID to get DO ID
436
+ const [doId] = parseActorId(actorId);
330
437
 
331
- if (!actorData) {
332
- return undefined;
333
- }
438
+ const id = env.ACTOR_DO.idFromString(doId);
439
+ const stub = env.ACTOR_DO.get(id);
334
440
 
335
- return {
336
- actorId,
337
- name: actorData.name,
338
- key: actorData.key,
339
- };
441
+ const value = await stub.managerKvGet(key);
442
+ return value !== null ? new TextDecoder().decode(value) : null;
340
443
  }
341
444
  }
package/src/mod.ts CHANGED
@@ -1,3 +1,11 @@
1
+ export type { Client } from "rivetkit";
1
2
  export type { DriverContext } from "./actor-driver";
3
+ export { createActorDurableObject } from "./actor-handler-do";
2
4
  export type { InputConfig as Config } from "./config";
3
- export { type Bindings, createServer, createServerHandler } from "./handler";
5
+ export {
6
+ type Bindings,
7
+ createHandler,
8
+ createInlineClient,
9
+ HandlerOutput,
10
+ InlineOutput,
11
+ } from "./handler";
package/src/websocket.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { UpgradeWebSocket, WSEvents, WSReadyState } from "hono/ws";
6
6
  import { defineWebSocketHelper, WSContext } from "hono/ws";
7
+ import { WS_PROTOCOL_STANDARD } from "rivetkit/driver-helpers";
7
8
 
8
9
  // Based on https://github.com/honojs/hono/issues/1153#issuecomment-1767321332
9
10
  export const upgradeWebSocket: UpgradeWebSocket<
@@ -57,14 +58,24 @@ export const upgradeWebSocket: UpgradeWebSocket<
57
58
  // we have to do this after `server.accept() is called`
58
59
  events.onOpen?.(new Event("open"), wsContext);
59
60
 
61
+ // Build response headers
62
+ const headers: Record<string, string> = {};
63
+
64
+ // Set Sec-WebSocket-Protocol if does not exist
65
+ const protocols = c.req.header("Sec-WebSocket-Protocol");
66
+ if (
67
+ typeof protocols === "string" &&
68
+ protocols
69
+ .split(",")
70
+ .map((x) => x.trim())
71
+ .includes(WS_PROTOCOL_STANDARD)
72
+ ) {
73
+ headers["Sec-WebSocket-Protocol"] = WS_PROTOCOL_STANDARD;
74
+ }
75
+
60
76
  return new Response(null, {
61
77
  status: 101,
62
- headers: {
63
- // HACK: Required in order for Cloudflare to not error with "Network connection lost"
64
- //
65
- // This bug undocumented. Cannot easily reproduce outside of RivetKit.
66
- "Sec-WebSocket-Protocol": "rivetkit",
67
- },
78
+ headers,
68
79
  webSocket: client,
69
80
  });
70
81
  });