effect-cf 0.31.0 → 0.32.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.
@@ -1,13 +1,80 @@
1
- import { Context, Effect, Layer, Predicate, Queue } from "effect";
1
+ import { Context, Effect, Layer, Option, Predicate, Queue, Schema } from "effect";
2
2
  import * as RpcMessage from "effect/unstable/rpc/RpcMessage";
3
3
  import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
4
4
  import * as RpcServer from "effect/unstable/rpc/RpcServer";
5
5
 
6
6
  import { DurableObjectState } from "./DurableObjectState";
7
- import type { DurableWebSocket } from "./DurableObjectWebSocket";
7
+ import {
8
+ type AcceptedUpgrade,
9
+ type AcceptUpgradeOptions,
10
+ type DurableWebSocket,
11
+ DurableWebSocketAttachmentError,
12
+ fromWebSocket,
13
+ } from "./DurableObjectWebSocket";
8
14
 
9
15
  const defaultAttachmentKey = "effectCloudflareRpcClientId";
10
16
  const defaultTag = "effect-cf-rpc";
17
+ const legacyAttachmentVersion = 1;
18
+ const resumableAttachmentVersion = 2;
19
+ const serviceRestartCode = 1012;
20
+ const serviceRestartReason = "Durable Object RPC activation reset";
21
+
22
+ /** Heartbeat handling for Effect's application-level RPC Ping messages. */
23
+ export type HeartbeatPolicy = "auto-response" | "passthrough";
24
+
25
+ /** Object-shaped application attachment fields that can coexist with adapter metadata. */
26
+ export type RpcWebSocketAttachment = object;
27
+
28
+ /** A resumable RPC stream declaration understood by the websocket transport. */
29
+ export interface ResumableStreamDeclaration<ResumeDescriptor = unknown, Checkpoint = unknown> {
30
+ /** Stable declaration identifier. Change it when the persisted representation changes. */
31
+ readonly id: string;
32
+ /** RPC tag matched against encoded client requests. */
33
+ readonly rpcTag: string;
34
+ /** Validates a resume descriptor restored from a websocket attachment. */
35
+ readonly resumeDescriptorSchema: Schema.Decoder<ResumeDescriptor>;
36
+ /** Validates an acknowledged checkpoint restored from a websocket attachment. */
37
+ readonly checkpointSchema: Schema.Decoder<Checkpoint>;
38
+ /**
39
+ * Extracts compact persisted state from an initial encoded request. Do not
40
+ * recycle a subscription key for an unrelated logical subscription while a
41
+ * delayed checkpoint for the previous subscription can still arrive.
42
+ */
43
+ readonly identify: (request: RpcMessage.RequestEncoded) => Option.Option<{
44
+ readonly subscriptionKey: string;
45
+ readonly resumeDescriptor: ResumeDescriptor;
46
+ readonly acknowledgedCheckpoint: Checkpoint;
47
+ }>;
48
+ /** Rebuilds the encoded request fields used to restart a handler. */
49
+ readonly rebuild: (options: {
50
+ readonly subscriptionKey: string;
51
+ readonly resumeDescriptor: ResumeDescriptor;
52
+ readonly acknowledgedCheckpoint: Checkpoint;
53
+ }) => {
54
+ readonly payload: unknown;
55
+ readonly headers?: ReadonlyArray<readonly [string, string]> | undefined;
56
+ readonly traceId?: string | undefined;
57
+ readonly spanId?: string | undefined;
58
+ readonly sampled?: boolean | undefined;
59
+ };
60
+ /** Extracts the durable checkpoint represented by one encoded stream value. */
61
+ readonly checkpointFromValue: (
62
+ value: RpcMessage.ResponseChunkEncoded["values"][number],
63
+ ) => Option.Option<Checkpoint>;
64
+ /**
65
+ * Produces the checkpoint wire token. A logical event must keep the same
66
+ * token when replayed, and unrelated events must never reuse it during the
67
+ * lifetime of a subscription key.
68
+ */
69
+ readonly checkpointToken: (checkpoint: Checkpoint) => string;
70
+ }
71
+
72
+ type AnyResumableStreamDeclaration = ResumableStreamDeclaration<any, any>;
73
+
74
+ /** Declares an explicitly resumable RPC stream. */
75
+ export const resumableStream = <ResumeDescriptor, Checkpoint>(
76
+ declaration: ResumableStreamDeclaration<ResumeDescriptor, Checkpoint>,
77
+ ): ResumableStreamDeclaration<ResumeDescriptor, Checkpoint> => declaration;
11
78
 
12
79
  /**
13
80
  * Configuration for {@link layer}.
@@ -15,8 +82,21 @@ const defaultTag = "effect-cf-rpc";
15
82
  export interface LayerOptions {
16
83
  /** Tag used to select hibernated sockets. */
17
84
  readonly tag?: string | undefined;
18
- /** Socket attachment key used to persist client ids across hibernation. */
85
+ /** Socket attachment namespace used to persist adapter metadata across hibernation. */
19
86
  readonly attachmentKey?: string | undefined;
87
+ /**
88
+ * Heartbeat behavior. `"auto-response"` (the default) installs Cloudflare's
89
+ * hibernation-safe text Ping/Pong response while an RPC socket exists and no
90
+ * non-resumable RPC operation is active, then removes it while one is pending.
91
+ * `"passthrough"` leaves the Durable Object's existing auto-response
92
+ * configuration unchanged and makes the application responsible for waking a
93
+ * lost pending operation.
94
+ * Cloudflare's pair is global to the Durable Object, so use `"passthrough"`
95
+ * when RPC and unrelated websocket protocols share one object.
96
+ */
97
+ readonly heartbeat?: HeartbeatPolicy | undefined;
98
+ /** Explicit stream declarations that may be reconstructed after activation loss. */
99
+ readonly resumableStreams?: ReadonlyArray<AnyResumableStreamDeclaration> | undefined;
20
100
  }
21
101
 
22
102
  /** Native websocket event payload accepted by Cloudflare Durable Objects. */
@@ -26,21 +106,111 @@ export type NativeWebSocketMessage = string | ArrayBuffer;
26
106
  * Service API used to wire websocket lifecycle events to Effect RPC server protocol.
27
107
  */
28
108
  export interface DurableObjectRpcWebSocketService {
29
- readonly accept: (socket: DurableWebSocket) => Effect.Effect<void>;
109
+ /** Accepts a new, not-yet-accepted socket with the RPC tag and any application tags. */
110
+ readonly accept: (socket: DurableWebSocket, tags?: ReadonlyArray<string>) => Effect.Effect<void>;
111
+ /**
112
+ * Creates and accepts a websocket upgrade without double-accepting the server
113
+ * socket. Application attachment fields are shallow-merged with the adapter's
114
+ * metadata namespace.
115
+ */
116
+ readonly acceptUpgrade: <Attachment extends RpcWebSocketAttachment = RpcWebSocketAttachment>(
117
+ options?: AcceptUpgradeOptions<Attachment>,
118
+ ) => Effect.Effect<AcceptedUpgrade<Attachment>, DurableWebSocketAttachmentError>;
30
119
  readonly message: (
31
120
  socket: DurableWebSocket,
32
121
  message: NativeWebSocketMessage,
33
122
  ) => Effect.Effect<void>;
34
123
  readonly close: (socket: DurableWebSocket) => Effect.Effect<void>;
35
124
  readonly error: (socket: DurableWebSocket, cause: unknown) => Effect.Effect<void>;
125
+ /**
126
+ * Persists forward checkpoint progress for a resumable stream. Returns `true`
127
+ * only when the checkpoint advances the current pending chunk batch. A failed
128
+ * websocket attachment write resets the connection and fails with
129
+ * `DurableWebSocketAttachmentError`.
130
+ */
131
+ readonly checkpoint: <ResumeDescriptor, Checkpoint>(
132
+ declaration: ResumableStreamDeclaration<ResumeDescriptor, Checkpoint>,
133
+ options: {
134
+ readonly clientId: number;
135
+ readonly subscriptionKey: string;
136
+ readonly checkpoint: Checkpoint;
137
+ },
138
+ ) => Effect.Effect<boolean, DurableWebSocketAttachmentError>;
36
139
  }
37
140
 
38
141
  interface RpcConnection {
39
142
  readonly id: number;
40
143
  readonly socket: DurableWebSocket;
41
144
  readonly parser: RpcSerialization.Parser;
145
+ readonly requestIds: Set<string | number>;
146
+ readonly nonResumableRequestIds: Set<string | number>;
147
+ readonly subscriptionsByRequestId: Map<string | number, ActiveSubscription>;
148
+ readonly subscriptionsByDefinition: Map<string, Map<string, ActiveSubscription>>;
149
+ readonly suppressedAckRequestIds: Set<string | number>;
150
+ readonly currentPendingRequestIds: Set<string | number>;
151
+ metadataVersion: 1 | 2;
152
+ }
153
+
154
+ interface LegacyAttachmentMetadata {
155
+ readonly version: 1;
156
+ readonly clientId: number;
157
+ readonly hasPendingRequests: boolean;
158
+ }
159
+
160
+ interface PersistedPendingBatch {
161
+ readonly checkpointTokens: ReadonlyArray<string>;
162
+ }
163
+
164
+ interface PersistedSubscription {
165
+ readonly definitionId: string;
166
+ readonly subscriptionKey: string;
167
+ readonly requestId: string | number;
168
+ readonly rpcTag: string;
169
+ readonly resumeDescriptor: unknown;
170
+ readonly acknowledgedCheckpoint: unknown;
171
+ readonly pending?: PersistedPendingBatch | undefined;
172
+ }
173
+
174
+ interface ResumableAttachmentMetadata {
175
+ readonly version: 2;
176
+ readonly clientId: number;
177
+ readonly hasPendingRequests: boolean;
178
+ readonly hasNonResumableRequests: boolean;
179
+ readonly subscriptions: ReadonlyArray<PersistedSubscription>;
180
+ }
181
+
182
+ type AttachmentMetadata = LegacyAttachmentMetadata | ResumableAttachmentMetadata;
183
+
184
+ interface ActiveSubscription extends PersistedSubscription {
185
+ readonly declaration: AnyResumableStreamDeclaration;
186
+ acknowledgedCheckpoint: unknown;
187
+ pending?: PersistedPendingBatch | undefined;
42
188
  }
43
189
 
190
+ const PersistedPendingBatchSchema = Schema.Struct({
191
+ checkpointTokens: Schema.Array(Schema.String),
192
+ });
193
+
194
+ const PersistedSubscriptionSchema = Schema.Struct({
195
+ definitionId: Schema.String,
196
+ subscriptionKey: Schema.String,
197
+ requestId: Schema.Union([Schema.String, Schema.Finite]),
198
+ rpcTag: Schema.String,
199
+ resumeDescriptor: Schema.Unknown,
200
+ acknowledgedCheckpoint: Schema.Unknown,
201
+ pending: Schema.optionalKey(PersistedPendingBatchSchema),
202
+ });
203
+
204
+ const ResumableAttachmentMetadataSchema = Schema.Struct({
205
+ version: Schema.Literal(resumableAttachmentVersion),
206
+ clientId: Schema.Finite,
207
+ hasPendingRequests: Schema.Boolean,
208
+ hasNonResumableRequests: Schema.Boolean,
209
+ subscriptions: Schema.Array(PersistedSubscriptionSchema),
210
+ });
211
+
212
+ const decodeResumableAttachment = Schema.decodeUnknownOption(ResumableAttachmentMetadataSchema);
213
+
44
214
  /**
45
215
  * Context tag for the Durable Object RPC websocket service.
46
216
  */
@@ -50,7 +220,13 @@ export class DurableObjectRpcWebSocket extends Context.Service<
50
220
  >()("effect-cf/DurableObjectRpcWebSocket") {}
51
221
 
52
222
  /**
53
- * Builds a layer that bridges Durable Object websocket events to `RpcServer.Protocol`.
223
+ * Builds a hibernation-aware transport that bridges Durable Object websocket
224
+ * events to `RpcServer.Protocol`.
225
+ *
226
+ * The layer restores idle tagged sockets and explicitly declared resumable
227
+ * streams after constructor recreation. A socket that lost any other in-flight
228
+ * request is closed with code 1012, which the stock Effect socket protocol
229
+ * reports as an `RpcClientError` containing `SocketCloseError`.
54
230
  */
55
231
  export const layer = (
56
232
  options: LayerOptions = {},
@@ -63,85 +239,611 @@ export const layer = (
63
239
  Effect.gen(function* () {
64
240
  const tag = options.tag ?? defaultTag;
65
241
  const attachmentKey = options.attachmentKey ?? defaultAttachmentKey;
242
+ const heartbeatPolicy = options.heartbeat ?? "auto-response";
243
+ const declarations = options.resumableStreams ?? [];
66
244
  const durableObjectState = yield* DurableObjectState;
67
245
  const serialization = yield* RpcSerialization.RpcSerialization;
68
246
  const disconnects = yield* Queue.make<number>();
69
247
  const connectionsBySocket = new Map<WebSocket, RpcConnection>();
70
248
  const connectionsById = new Map<number, RpcConnection>();
71
249
  const clientIds = new Set<number>();
250
+ const resetSockets = new WeakSet<WebSocket>();
251
+ const declarationsById = new Map<string, AnyResumableStreamDeclaration>();
252
+ const declarationsByTag = new Map<string, AnyResumableStreamDeclaration>();
253
+ const replayRequests: Array<{
254
+ readonly clientId: number;
255
+ readonly request: RpcMessage.RequestEncoded;
256
+ }> = [];
72
257
  let nextClientId = 0;
258
+ let activeNonResumableRequestCount = 0;
73
259
  let writeRequest:
74
260
  | ((clientId: number, data: RpcMessage.FromClientEncoded) => Effect.Effect<void>)
75
261
  | undefined;
76
262
 
77
- const reserveClientId = (socket: DurableWebSocket) => {
263
+ for (const declaration of declarations) {
264
+ if (declaration.id.length === 0 || declaration.rpcTag.length === 0) {
265
+ return yield* Effect.die(
266
+ new Error("Resumable RPC stream declaration ids and tags must be non-empty"),
267
+ );
268
+ }
269
+
270
+ if (declarationsById.has(declaration.id)) {
271
+ return yield* Effect.die(
272
+ new Error(`Duplicate resumable RPC stream declaration id: ${declaration.id}`),
273
+ );
274
+ }
275
+
276
+ if (declarationsByTag.has(declaration.rpcTag)) {
277
+ return yield* Effect.die(
278
+ new Error(`Duplicate resumable RPC stream tag: ${declaration.rpcTag}`),
279
+ );
280
+ }
281
+
282
+ declarationsById.set(declaration.id, declaration);
283
+ declarationsByTag.set(declaration.rpcTag, declaration);
284
+ }
285
+
286
+ const reserveClientId = (socket: DurableWebSocket): AttachmentMetadata => {
78
287
  const attachment = readAttachment(socket.raw, attachmentKey);
79
288
 
80
- if (attachment !== undefined) {
81
- nextClientId = Math.max(nextClientId, attachment + 1);
289
+ if (attachment._tag === "Invalid") {
290
+ throw new Error("Invalid Durable Object RPC websocket attachment metadata");
291
+ }
292
+
293
+ if (attachment._tag === "Valid") {
294
+ const metadata = attachment.metadata;
82
295
 
83
- return attachment;
296
+ nextClientId = Math.max(nextClientId, metadata.clientId + 1);
297
+ writeAttachment(socket.raw, attachmentKey, metadata);
298
+
299
+ return metadata;
84
300
  }
85
301
 
86
302
  const id = nextClientId++;
303
+ const created: LegacyAttachmentMetadata = {
304
+ version: legacyAttachmentVersion,
305
+ clientId: id,
306
+ hasPendingRequests: false,
307
+ };
87
308
 
88
- writeAttachment(socket.raw, attachmentKey, id);
309
+ writeAttachment(socket.raw, attachmentKey, created);
89
310
 
90
- return id;
311
+ return created;
91
312
  };
92
313
 
93
- const register = (socket: DurableWebSocket) => {
314
+ const addSubscription = (
315
+ connection: RpcConnection,
316
+ subscription: ActiveSubscription,
317
+ ): boolean => {
318
+ if (connection.subscriptionsByRequestId.has(subscription.requestId)) {
319
+ return false;
320
+ }
321
+
322
+ let byKey = connection.subscriptionsByDefinition.get(subscription.definitionId);
323
+
324
+ if (byKey?.has(subscription.subscriptionKey) === true) {
325
+ return false;
326
+ }
327
+
328
+ if (byKey === undefined) {
329
+ byKey = new Map();
330
+ connection.subscriptionsByDefinition.set(subscription.definitionId, byKey);
331
+ }
332
+
333
+ byKey.set(subscription.subscriptionKey, subscription);
334
+ connection.subscriptionsByRequestId.set(subscription.requestId, subscription);
335
+ connection.requestIds.add(subscription.requestId);
336
+ connection.metadataVersion = resumableAttachmentVersion;
337
+
338
+ return true;
339
+ };
340
+
341
+ const removeSubscription = (connection: RpcConnection, requestId: string | number) => {
342
+ const subscription = connection.subscriptionsByRequestId.get(requestId);
343
+
344
+ if (subscription === undefined) {
345
+ return undefined;
346
+ }
347
+
348
+ connection.subscriptionsByRequestId.delete(requestId);
349
+ const byKey = connection.subscriptionsByDefinition.get(subscription.definitionId);
350
+
351
+ byKey?.delete(subscription.subscriptionKey);
352
+ if (byKey?.size === 0) {
353
+ connection.subscriptionsByDefinition.delete(subscription.definitionId);
354
+ }
355
+
356
+ return subscription;
357
+ };
358
+
359
+ const register = (
360
+ socket: DurableWebSocket,
361
+ restoredMetadata?: AttachmentMetadata,
362
+ restoredSubscriptions: ReadonlyArray<ActiveSubscription> = [],
363
+ ) => {
94
364
  const existing = connectionsBySocket.get(socket.raw);
95
365
 
96
366
  if (existing !== undefined) {
97
367
  return existing;
98
368
  }
99
369
 
370
+ const metadata = restoredMetadata ?? reserveClientId(socket);
100
371
  const connection = {
101
- id: reserveClientId(socket),
372
+ id: metadata.clientId,
102
373
  socket,
103
374
  parser: serialization.makeUnsafe(),
375
+ requestIds: new Set<string | number>(),
376
+ nonResumableRequestIds: new Set<string | number>(),
377
+ subscriptionsByRequestId: new Map<string | number, ActiveSubscription>(),
378
+ subscriptionsByDefinition: new Map<string, Map<string, ActiveSubscription>>(),
379
+ suppressedAckRequestIds: new Set<string | number>(),
380
+ currentPendingRequestIds: new Set<string | number>(),
381
+ metadataVersion: metadata.version,
104
382
  } satisfies RpcConnection;
105
383
 
384
+ if (connectionsById.has(connection.id)) {
385
+ throw new Error(`Duplicate Durable Object RPC websocket client id: ${connection.id}`);
386
+ }
387
+
388
+ for (const subscription of restoredSubscriptions) {
389
+ if (!addSubscription(connection, subscription)) {
390
+ throw new Error("Duplicate restored resumable RPC stream subscription identity");
391
+ }
392
+ }
393
+
106
394
  connectionsBySocket.set(socket.raw, connection);
107
395
  connectionsById.set(connection.id, connection);
108
396
  clientIds.add(connection.id);
397
+ nextClientId = Math.max(nextClientId, connection.id + 1);
109
398
 
110
399
  return connection;
111
400
  };
112
401
 
113
- const unregister = (socket: DurableWebSocket) =>
114
- Effect.sync(() => {
115
- const connection = connectionsBySocket.get(socket.raw);
402
+ const heartbeat = yield* makeHeartbeatController(
403
+ durableObjectState,
404
+ serialization,
405
+ heartbeatPolicy,
406
+ );
407
+
408
+ const syncHeartbeat = () =>
409
+ heartbeat.setEnabled(connectionsBySocket.size > 0 && activeNonResumableRequestCount === 0);
116
410
 
117
- if (connection === undefined) {
411
+ const persistConnection = Effect.fn("DurableObjectRpcWebSocket.persistConnection")(function* (
412
+ connection: RpcConnection,
413
+ ): Effect.fn.Return<void, DurableWebSocketAttachmentError> {
414
+ return yield* Effect.try({
415
+ try: () => {
416
+ if (connection.metadataVersion === legacyAttachmentVersion) {
417
+ writeAttachment(connection.socket.raw, attachmentKey, {
418
+ version: legacyAttachmentVersion,
419
+ clientId: connection.id,
420
+ hasPendingRequests: connection.requestIds.size > 0,
421
+ });
422
+
423
+ return;
424
+ }
425
+
426
+ const subscriptions = Array.from(connection.subscriptionsByRequestId.values()).map(
427
+ ({ declaration: _, ...subscription }): PersistedSubscription => subscription,
428
+ );
429
+
430
+ writeAttachment(connection.socket.raw, attachmentKey, {
431
+ version: resumableAttachmentVersion,
432
+ clientId: connection.id,
433
+ hasPendingRequests: connection.requestIds.size > 0,
434
+ hasNonResumableRequests: connection.nonResumableRequestIds.size > 0,
435
+ subscriptions,
436
+ });
437
+ },
438
+ catch: (cause) => new DurableWebSocketAttachmentError({ operation: "serialize", cause }),
439
+ });
440
+ });
441
+
442
+ const unregister = Effect.fn("DurableObjectRpcWebSocket.unregister")(function* (
443
+ socket: DurableWebSocket,
444
+ ) {
445
+ const connection = connectionsBySocket.get(socket.raw);
446
+
447
+ if (connection === undefined) {
448
+ return;
449
+ }
450
+
451
+ connectionsBySocket.delete(socket.raw);
452
+ connectionsById.delete(connection.id);
453
+ clientIds.delete(connection.id);
454
+ activeNonResumableRequestCount -= connection.nonResumableRequestIds.size;
455
+ connection.requestIds.clear();
456
+ connection.nonResumableRequestIds.clear();
457
+ connection.subscriptionsByRequestId.clear();
458
+ connection.subscriptionsByDefinition.clear();
459
+ connection.suppressedAckRequestIds.clear();
460
+ connection.currentPendingRequestIds.clear();
461
+ Queue.offerUnsafe(disconnects, connection.id);
462
+
463
+ yield* syncHeartbeat();
464
+ });
465
+
466
+ const resetSocket = Effect.fn("DurableObjectRpcWebSocket.resetSocket")(function* (
467
+ socket: DurableWebSocket,
468
+ cause?: unknown,
469
+ ) {
470
+ resetSockets.add(socket.raw);
471
+ yield* unregister(socket).pipe(
472
+ Effect.catchCause((unregisterCause) =>
473
+ Effect.logDebug("Failed to unregister Durable Object RPC websocket", unregisterCause),
474
+ ),
475
+ );
476
+
477
+ if (cause !== undefined) {
478
+ yield* Effect.logDebug("Resetting Durable Object RPC websocket", cause);
479
+ }
480
+
481
+ yield* socket
482
+ .close(serviceRestartCode, serviceRestartReason)
483
+ .pipe(
484
+ Effect.catch((closeError) =>
485
+ Effect.logDebug("Failed to close Durable Object RPC websocket", closeError),
486
+ ),
487
+ );
488
+ });
489
+
490
+ const persistOrReset = Effect.fn("DurableObjectRpcWebSocket.persistOrReset")(function* (
491
+ connection: RpcConnection,
492
+ ) {
493
+ return yield* persistConnection(connection).pipe(
494
+ Effect.as(true),
495
+ Effect.catch((cause) => resetSocket(connection.socket, cause).pipe(Effect.as(false))),
496
+ );
497
+ });
498
+
499
+ const trackRequest = Effect.fn("DurableObjectRpcWebSocket.trackRequest")(function* (
500
+ connection: RpcConnection,
501
+ request: RpcMessage.RequestEncoded,
502
+ ) {
503
+ if (connection.requestIds.has(request.id)) {
504
+ return true;
505
+ }
506
+
507
+ const declaration = declarationsByTag.get(request.tag);
508
+ const identified =
509
+ declaration === undefined
510
+ ? Option.none()
511
+ : yield* Effect.sync(() => declaration.identify(request));
512
+
513
+ if (declaration !== undefined && Option.isSome(identified)) {
514
+ if (
515
+ !addSubscription(connection, {
516
+ declaration,
517
+ definitionId: declaration.id,
518
+ subscriptionKey: identified.value.subscriptionKey,
519
+ requestId: request.id,
520
+ rpcTag: declaration.rpcTag,
521
+ resumeDescriptor: identified.value.resumeDescriptor,
522
+ acknowledgedCheckpoint: identified.value.acknowledgedCheckpoint,
523
+ })
524
+ ) {
525
+ yield* resetSocket(
526
+ connection.socket,
527
+ new Error(`Duplicate resumable RPC stream subscription: ${declaration.id}`),
528
+ );
529
+
530
+ return false;
531
+ }
532
+ } else {
533
+ connection.requestIds.add(request.id);
534
+ connection.nonResumableRequestIds.add(request.id);
535
+ activeNonResumableRequestCount++;
536
+ }
537
+
538
+ if (!(yield* persistOrReset(connection))) {
539
+ return false;
540
+ }
541
+
542
+ yield* syncHeartbeat();
543
+
544
+ return true;
545
+ });
546
+
547
+ const completeRequest = Effect.fn("DurableObjectRpcWebSocket.completeRequest")(function* (
548
+ connection: RpcConnection,
549
+ requestId: string | number,
550
+ preserveAckFilter = false,
551
+ ) {
552
+ if (connection.requestIds.delete(requestId)) {
553
+ if (connection.nonResumableRequestIds.delete(requestId)) {
554
+ activeNonResumableRequestCount--;
555
+ }
556
+
557
+ const subscription = removeSubscription(connection, requestId);
558
+
559
+ connection.currentPendingRequestIds.delete(requestId);
560
+
561
+ if (preserveAckFilter && subscription !== undefined) {
562
+ connection.suppressedAckRequestIds.add(requestId);
563
+ }
564
+
565
+ if (!(yield* persistOrReset(connection))) {
566
+ return;
567
+ }
568
+ }
569
+
570
+ if (!preserveAckFilter) {
571
+ connection.suppressedAckRequestIds.delete(requestId);
572
+ }
573
+
574
+ yield* syncHeartbeat();
575
+ });
576
+
577
+ const completeAllRequests = Effect.fn("DurableObjectRpcWebSocket.completeAllRequests")(
578
+ function* (connection: RpcConnection) {
579
+ const hadRequests = connection.requestIds.size > 0;
580
+
581
+ if (connection.requestIds.size > 0) {
582
+ activeNonResumableRequestCount -= connection.nonResumableRequestIds.size;
583
+ }
584
+
585
+ connection.requestIds.clear();
586
+ connection.nonResumableRequestIds.clear();
587
+ connection.subscriptionsByRequestId.clear();
588
+ connection.subscriptionsByDefinition.clear();
589
+ connection.suppressedAckRequestIds.clear();
590
+ connection.currentPendingRequestIds.clear();
591
+
592
+ if (hadRequests && !(yield* persistOrReset(connection))) {
118
593
  return;
119
594
  }
120
595
 
121
- connectionsBySocket.delete(socket.raw);
122
- connectionsById.delete(connection.id);
123
- clientIds.delete(connection.id);
124
- Queue.offerUnsafe(disconnects, connection.id);
596
+ yield* syncHeartbeat();
597
+ },
598
+ );
599
+
600
+ const restored = yield* durableObjectState.getWebSockets(tag);
601
+ const restoredAttachments = restored.map((socket) => ({
602
+ socket,
603
+ attachment: readAttachment(socket.raw, attachmentKey),
604
+ }));
605
+
606
+ for (const { attachment, socket } of restoredAttachments) {
607
+ if (attachment._tag === "Invalid") {
608
+ yield* resetSocket(socket);
609
+ continue;
610
+ }
611
+
612
+ if (attachment._tag === "Missing") {
613
+ yield* resetSocket(socket);
614
+ continue;
615
+ }
616
+
617
+ const metadata = attachment.metadata;
618
+
619
+ if (metadata.version === legacyAttachmentVersion) {
620
+ if (metadata.hasPendingRequests) {
621
+ yield* resetSocket(socket);
622
+ } else if (connectionsById.has(metadata.clientId)) {
623
+ yield* resetSocket(socket);
624
+ } else {
625
+ const connection = register(socket, metadata);
626
+
627
+ yield* persistOrReset(connection);
628
+ }
629
+
630
+ continue;
631
+ }
632
+
633
+ const prepared = yield* Effect.sync(() => {
634
+ try {
635
+ if (
636
+ metadata.hasNonResumableRequests ||
637
+ metadata.hasPendingRequests !==
638
+ (metadata.hasNonResumableRequests || metadata.subscriptions.length > 0)
639
+ ) {
640
+ throw new Error("Dirty resumable RPC websocket attachment metadata");
641
+ }
642
+
643
+ const requestIds = new Set<string | number>();
644
+ const subscriptionKeys = new Map<string, Set<string>>();
645
+ const subscriptions: Array<ActiveSubscription> = [];
646
+ const requests: Array<RpcMessage.RequestEncoded> = [];
647
+
648
+ for (const persisted of metadata.subscriptions) {
649
+ const declaration = declarationsById.get(persisted.definitionId);
650
+
651
+ if (declaration === undefined || declaration.rpcTag !== persisted.rpcTag) {
652
+ throw new Error("Missing resumable RPC stream declaration");
653
+ }
654
+
655
+ let keys = subscriptionKeys.get(persisted.definitionId);
656
+
657
+ if (keys === undefined) {
658
+ keys = new Set();
659
+ subscriptionKeys.set(persisted.definitionId, keys);
660
+ }
661
+
662
+ if (requestIds.has(persisted.requestId) || keys.has(persisted.subscriptionKey)) {
663
+ throw new Error("Duplicate persisted resumable RPC stream subscription");
664
+ }
665
+
666
+ requestIds.add(persisted.requestId);
667
+ keys.add(persisted.subscriptionKey);
668
+
669
+ if (persisted.pending !== undefined) {
670
+ const { checkpointTokens } = persisted.pending;
671
+
672
+ if (
673
+ checkpointTokens.length === 0 ||
674
+ checkpointTokens.some((token) => token.length === 0) ||
675
+ new Set(checkpointTokens).size !== checkpointTokens.length
676
+ ) {
677
+ throw new Error("Invalid persisted resumable RPC stream pending batch");
678
+ }
679
+ }
680
+
681
+ const resumeDescriptor = Schema.decodeUnknownOption(
682
+ declaration.resumeDescriptorSchema,
683
+ )(persisted.resumeDescriptor);
684
+ const acknowledgedCheckpoint = Schema.decodeUnknownOption(
685
+ declaration.checkpointSchema,
686
+ )(persisted.acknowledgedCheckpoint);
687
+
688
+ if (Option.isNone(resumeDescriptor) || Option.isNone(acknowledgedCheckpoint)) {
689
+ throw new Error("Invalid persisted resumable RPC stream state");
690
+ }
691
+
692
+ const rebuilt = declaration.rebuild({
693
+ subscriptionKey: persisted.subscriptionKey,
694
+ resumeDescriptor: resumeDescriptor.value,
695
+ acknowledgedCheckpoint: acknowledgedCheckpoint.value,
696
+ });
697
+ const request = {
698
+ _tag: "Request" as const,
699
+ id: persisted.requestId,
700
+ tag: declaration.rpcTag,
701
+ payload: rebuilt.payload,
702
+ headers:
703
+ rebuilt.headers?.map(([name, value]): [string, string] => [name, value]) ?? [],
704
+ };
705
+
706
+ if (rebuilt.traceId !== undefined) {
707
+ Object.assign(request, { traceId: rebuilt.traceId });
708
+ }
709
+
710
+ if (rebuilt.spanId !== undefined) {
711
+ Object.assign(request, { spanId: rebuilt.spanId });
712
+ }
713
+
714
+ if (rebuilt.sampled !== undefined) {
715
+ Object.assign(request, { sampled: rebuilt.sampled });
716
+ }
717
+
718
+ subscriptions.push({
719
+ ...persisted,
720
+ resumeDescriptor: resumeDescriptor.value,
721
+ acknowledgedCheckpoint: acknowledgedCheckpoint.value,
722
+ declaration,
723
+ });
724
+ requests.push(request);
725
+ }
726
+
727
+ return { _tag: "Success" as const, requests, subscriptions };
728
+ } catch (cause) {
729
+ return { _tag: "Failure" as const, cause };
730
+ }
125
731
  });
126
732
 
127
- for (const socket of yield* durableObjectState.getWebSockets(tag)) {
128
- register(socket);
733
+ if (prepared._tag === "Failure") {
734
+ yield* Effect.logDebug("Invalid resumable RPC websocket attachment", prepared.cause);
735
+ yield* resetSocket(socket);
736
+ continue;
737
+ }
738
+
739
+ if (connectionsById.has(metadata.clientId)) {
740
+ yield* resetSocket(socket);
741
+ continue;
742
+ }
743
+
744
+ const connection = register(socket, metadata, prepared.subscriptions);
745
+
746
+ for (const request of prepared.requests) {
747
+ replayRequests.push({ clientId: connection.id, request });
748
+ }
129
749
  }
130
750
 
751
+ yield* syncHeartbeat();
752
+
131
753
  const send = (connection: RpcConnection, response: RpcMessage.FromServerEncoded) =>
132
- Effect.sync(() => {
133
- try {
134
- const encoded = connection.parser.encode(response);
754
+ Effect.gen(function* () {
755
+ let delivered = response;
135
756
 
136
- if (encoded !== undefined) {
137
- connection.socket.raw.send(encoded);
757
+ if (response._tag === "Chunk") {
758
+ const subscription = connection.subscriptionsByRequestId.get(response.requestId);
759
+
760
+ if (subscription !== undefined) {
761
+ const checkpointTokens = yield* Effect.sync(() => {
762
+ const tokens: Array<string> = [];
763
+
764
+ for (const value of response.values) {
765
+ const checkpoint = subscription.declaration.checkpointFromValue(value);
766
+
767
+ if (Option.isNone(checkpoint)) {
768
+ throw new Error(
769
+ `Resumable RPC stream ${subscription.definitionId} did not identify a checkpoint`,
770
+ );
771
+ }
772
+
773
+ const token = subscription.declaration.checkpointToken(checkpoint.value);
774
+
775
+ if (token.length === 0 || tokens.includes(token)) {
776
+ throw new Error(
777
+ `Resumable RPC stream ${subscription.definitionId} produced invalid checkpoint tokens`,
778
+ );
779
+ }
780
+
781
+ tokens.push(token);
782
+ }
783
+
784
+ if (tokens.length === 0) {
785
+ throw new Error("Resumable RPC stream chunks must contain checkpointed values");
786
+ }
787
+
788
+ return tokens;
789
+ });
790
+
791
+ subscription.pending = {
792
+ checkpointTokens,
793
+ };
794
+ connection.currentPendingRequestIds.add(subscription.requestId);
795
+
796
+ if (!(yield* persistOrReset(connection))) {
797
+ return;
798
+ }
138
799
  }
139
- } catch (cause) {
140
- const encoded = connection.parser.encode(RpcMessage.ResponseDefectEncoded(cause));
800
+ }
801
+
802
+ const enqueued = yield* Effect.sync(() => {
803
+ try {
804
+ const encoded = connection.parser.encode(response);
805
+
806
+ if (encoded === undefined) {
807
+ return false;
808
+ }
809
+
810
+ connection.socket.raw.send(encoded);
811
+
812
+ return true;
813
+ } catch (cause) {
814
+ delivered = RpcMessage.ResponseDefectEncoded(cause);
815
+ const encoded = connection.parser.encode(delivered);
816
+
817
+ if (encoded === undefined) {
818
+ return false;
819
+ }
141
820
 
142
- if (encoded !== undefined) {
143
821
  connection.socket.raw.send(encoded);
822
+
823
+ return true;
144
824
  }
825
+ }).pipe(
826
+ Effect.catchCause((cause) =>
827
+ Effect.logDebug("Durable Object RPC websocket send failed", cause).pipe(
828
+ Effect.as(false),
829
+ ),
830
+ ),
831
+ );
832
+
833
+ if (!enqueued) {
834
+ resetSockets.add(connection.socket.raw);
835
+ yield* connection.socket
836
+ .close(serviceRestartCode, serviceRestartReason)
837
+ .pipe(Effect.ignore);
838
+ yield* unregister(connection.socket);
839
+
840
+ return;
841
+ }
842
+
843
+ if (delivered._tag === "Exit") {
844
+ yield* completeRequest(connection, delivered.requestId);
845
+ } else if (delivered._tag === "Defect" || delivered._tag === "ClientProtocolError") {
846
+ yield* completeAllRequests(connection);
145
847
  }
146
848
  });
147
849
 
@@ -169,14 +871,136 @@ export const layer = (
169
871
  });
170
872
  });
171
873
 
874
+ const replay = writeRequest;
875
+
876
+ if (replay === undefined) {
877
+ return yield* Effect.die(
878
+ new Error("RPC server protocol did not install its request writer"),
879
+ );
880
+ }
881
+
882
+ for (const restoredRequest of replayRequests) {
883
+ yield* replay(restoredRequest.clientId, restoredRequest.request);
884
+ }
885
+
886
+ const checkpoint = Effect.fn("DurableObjectRpcWebSocket.checkpoint")(function* <
887
+ ResumeDescriptor,
888
+ Checkpoint,
889
+ >(
890
+ declaration: ResumableStreamDeclaration<ResumeDescriptor, Checkpoint>,
891
+ checkpointOptions: {
892
+ readonly clientId: number;
893
+ readonly subscriptionKey: string;
894
+ readonly checkpoint: Checkpoint;
895
+ },
896
+ ): Effect.fn.Return<boolean, DurableWebSocketAttachmentError> {
897
+ const connection = connectionsById.get(checkpointOptions.clientId);
898
+ const configured = declarationsById.get(declaration.id);
899
+ const subscription = connection?.subscriptionsByDefinition
900
+ .get(declaration.id)
901
+ ?.get(checkpointOptions.subscriptionKey);
902
+
903
+ if (
904
+ connection === undefined ||
905
+ configured === undefined ||
906
+ configured.rpcTag !== declaration.rpcTag ||
907
+ subscription === undefined ||
908
+ subscription.pending === undefined
909
+ ) {
910
+ return false;
911
+ }
912
+
913
+ const token = configured.checkpointToken(checkpointOptions.checkpoint);
914
+ const checkpointIndex = subscription.pending.checkpointTokens.indexOf(token);
915
+
916
+ if (token.length === 0 || checkpointIndex < 0) {
917
+ return false;
918
+ }
919
+
920
+ const pending = subscription.pending;
921
+ const isFinal = checkpointIndex === pending.checkpointTokens.length - 1;
922
+ const shouldAck =
923
+ isFinal && connection.currentPendingRequestIds.has(subscription.requestId);
924
+
925
+ return yield* Effect.uninterruptible(
926
+ Effect.gen(function* () {
927
+ subscription.acknowledgedCheckpoint = checkpointOptions.checkpoint;
928
+
929
+ if (isFinal) {
930
+ connection.currentPendingRequestIds.delete(subscription.requestId);
931
+ delete subscription.pending;
932
+ } else {
933
+ subscription.pending = {
934
+ checkpointTokens: pending.checkpointTokens.slice(checkpointIndex + 1),
935
+ };
936
+ }
937
+
938
+ yield* persistConnection(connection).pipe(
939
+ Effect.catch((cause) =>
940
+ Effect.gen(function* () {
941
+ yield* resetSocket(connection.socket, cause);
942
+
943
+ return yield* cause;
944
+ }),
945
+ ),
946
+ );
947
+
948
+ if (shouldAck) {
949
+ const run = writeRequest;
950
+
951
+ if (run === undefined) {
952
+ return yield* Effect.die(
953
+ new Error("RPC server is not ready to accept a checkpoint Ack"),
954
+ );
955
+ }
956
+
957
+ yield* run(connection.id, {
958
+ _tag: "Ack",
959
+ requestId: subscription.requestId,
960
+ });
961
+ }
962
+
963
+ return true;
964
+ }),
965
+ );
966
+ });
967
+
172
968
  const service = DurableObjectRpcWebSocket.of({
173
- accept: (socket) =>
969
+ accept: (socket, tags = []) =>
174
970
  Effect.gen(function* () {
175
971
  register(socket);
176
- yield* durableObjectState.acceptWebSocket(socket, [tag]);
972
+ yield* durableObjectState.acceptWebSocket(socket, Array.from(new Set([tag, ...tags])));
973
+ yield* syncHeartbeat();
974
+ }),
975
+ acceptUpgrade: <Attachment extends RpcWebSocketAttachment>(
976
+ upgradeOptions: AcceptUpgradeOptions<Attachment> = {},
977
+ ) =>
978
+ Effect.gen(function* () {
979
+ const pair = new WebSocketPair();
980
+ const client = pair[0];
981
+ const server = fromWebSocket<Attachment>(pair[1]);
982
+
983
+ if (upgradeOptions.attachment !== undefined) {
984
+ yield* server.serializeAttachment(upgradeOptions.attachment);
985
+ }
986
+
987
+ yield* service.accept(server, upgradeOptions.tags);
988
+
989
+ return {
990
+ client,
991
+ server,
992
+ response: new Response(null, {
993
+ status: 101,
994
+ webSocket: client,
995
+ }),
996
+ };
177
997
  }),
178
998
  message: (socket, message) =>
179
999
  Effect.gen(function* () {
1000
+ if (resetSockets.has(socket.raw)) {
1001
+ return;
1002
+ }
1003
+
180
1004
  const connection = register(socket);
181
1005
  const decoded = yield* Effect.try({
182
1006
  try: () => connection.parser.decode(normalizeMessage(message)),
@@ -193,7 +1017,37 @@ export const layer = (
193
1017
 
194
1018
  for (const current of decoded) {
195
1019
  // SAFETY: this parser was created by the configured RPC serialization service.
196
- yield* run(connection.id, current as RpcMessage.FromClientEncoded);
1020
+ const request = current as RpcMessage.FromClientEncoded;
1021
+
1022
+ if (request._tag === "Request" && request.isNotification !== true) {
1023
+ if (!(yield* trackRequest(connection, request))) {
1024
+ return;
1025
+ }
1026
+ }
1027
+
1028
+ if (
1029
+ request._tag === "Ack" &&
1030
+ (connection.subscriptionsByRequestId.has(request.requestId) ||
1031
+ connection.suppressedAckRequestIds.has(request.requestId))
1032
+ ) {
1033
+ continue;
1034
+ }
1035
+
1036
+ if (
1037
+ request._tag === "Interrupt" &&
1038
+ connection.subscriptionsByRequestId.has(request.requestId)
1039
+ ) {
1040
+ yield* completeRequest(connection, request.requestId, true);
1041
+ }
1042
+
1043
+ yield* run(connection.id, request);
1044
+
1045
+ if (
1046
+ request._tag === "Interrupt" &&
1047
+ !connection.suppressedAckRequestIds.has(request.requestId)
1048
+ ) {
1049
+ yield* completeRequest(connection, request.requestId);
1050
+ }
197
1051
  }
198
1052
  }).pipe(
199
1053
  Effect.catch((cause) => {
@@ -210,6 +1064,7 @@ export const layer = (
210
1064
  yield* Effect.logDebug("Durable Object RPC websocket error", cause);
211
1065
  yield* unregister(socket);
212
1066
  }),
1067
+ checkpoint,
213
1068
  });
214
1069
 
215
1070
  return Context.mergeAll(
@@ -222,26 +1077,159 @@ export const layer = (
222
1077
  const normalizeMessage = (message: NativeWebSocketMessage) =>
223
1078
  Predicate.isString(message) ? message : new Uint8Array(message);
224
1079
 
225
- const readAttachment = (socket: WebSocket, key: string): number | undefined => {
1080
+ type AttachmentRead =
1081
+ | { readonly _tag: "Missing" }
1082
+ | { readonly _tag: "Invalid" }
1083
+ | { readonly _tag: "Valid"; readonly metadata: AttachmentMetadata };
1084
+
1085
+ const readAttachment = (socket: WebSocket, key: string): AttachmentRead => {
226
1086
  const value = socket.deserializeAttachment();
227
1087
 
1088
+ if (value === null || value === undefined) {
1089
+ return { _tag: "Missing" };
1090
+ }
1091
+
1092
+ if (!Predicate.isObject(value)) {
1093
+ return { _tag: "Invalid" };
1094
+ }
1095
+
1096
+ if (!Predicate.hasProperty(value, key)) {
1097
+ return { _tag: "Missing" };
1098
+ }
1099
+
1100
+ const metadata = value[key];
1101
+
1102
+ if (Predicate.isNumber(metadata) && isClientId(metadata)) {
1103
+ return {
1104
+ _tag: "Valid",
1105
+ metadata: {
1106
+ version: legacyAttachmentVersion,
1107
+ clientId: metadata,
1108
+ hasPendingRequests: false,
1109
+ },
1110
+ };
1111
+ }
1112
+
1113
+ if (!Predicate.isObject(metadata)) {
1114
+ return { _tag: "Invalid" };
1115
+ }
1116
+
1117
+ if (
1118
+ Predicate.hasProperty(metadata, "version") &&
1119
+ metadata.version === resumableAttachmentVersion
1120
+ ) {
1121
+ const decoded = decodeResumableAttachment(metadata);
1122
+
1123
+ return Option.isSome(decoded) && isClientId(decoded.value.clientId)
1124
+ ? { _tag: "Valid", metadata: decoded.value }
1125
+ : { _tag: "Invalid" };
1126
+ }
1127
+
1128
+ const hasPendingRequests = Predicate.hasProperty(metadata, "hasPendingRequests")
1129
+ ? metadata.hasPendingRequests
1130
+ : false;
1131
+
228
1132
  if (
229
- Predicate.isObjectOrArray(value) &&
230
- Predicate.hasProperty(value, key) &&
231
- Predicate.isNumber(value[key])
1133
+ (!Predicate.hasProperty(metadata, "version") || metadata.version === legacyAttachmentVersion) &&
1134
+ Predicate.hasProperty(metadata, "clientId") &&
1135
+ Predicate.isNumber(metadata.clientId) &&
1136
+ isClientId(metadata.clientId) &&
1137
+ Predicate.isBoolean(hasPendingRequests)
232
1138
  ) {
233
- return value[key];
1139
+ return {
1140
+ _tag: "Valid",
1141
+ metadata: {
1142
+ version: legacyAttachmentVersion,
1143
+ clientId: metadata.clientId,
1144
+ hasPendingRequests,
1145
+ },
1146
+ };
234
1147
  }
235
1148
 
236
- return undefined;
1149
+ return { _tag: "Invalid" };
237
1150
  };
238
1151
 
239
- const writeAttachment = (socket: WebSocket, key: string, clientId: number) => {
1152
+ const isClientId = (value: number): boolean => Number.isSafeInteger(value) && value >= 0;
1153
+
1154
+ const writeAttachment = (socket: WebSocket, key: string, metadata: AttachmentMetadata) => {
240
1155
  const current = socket.deserializeAttachment();
1156
+
1157
+ if (current !== null && current !== undefined && !Predicate.isObject(current)) {
1158
+ throw new Error(
1159
+ "DurableObjectRpcWebSocket requires object-shaped websocket attachments so adapter metadata can preserve application fields",
1160
+ );
1161
+ }
1162
+
241
1163
  const attachment = Predicate.isObject(current) ? current : {};
1164
+ const currentMetadata =
1165
+ Predicate.hasProperty(attachment, key) && Predicate.isObject(attachment[key])
1166
+ ? attachment[key]
1167
+ : {};
242
1168
 
243
1169
  socket.serializeAttachment({
244
1170
  ...attachment,
245
- [key]: clientId,
1171
+ [key]: {
1172
+ ...currentMetadata,
1173
+ ...metadata,
1174
+ },
246
1175
  });
247
1176
  };
1177
+
1178
+ interface HeartbeatController {
1179
+ readonly setEnabled: (enabled: boolean) => Effect.Effect<void>;
1180
+ }
1181
+
1182
+ const makeHeartbeatController = (
1183
+ state: DurableObjectState["Service"],
1184
+ serialization: RpcSerialization.RpcSerialization["Service"],
1185
+ policy: HeartbeatPolicy,
1186
+ ): Effect.Effect<HeartbeatController> =>
1187
+ Effect.gen(function* () {
1188
+ if (policy === "passthrough") {
1189
+ return { setEnabled: () => Effect.void };
1190
+ }
1191
+
1192
+ const parser = serialization.makeUnsafe();
1193
+ const request = parser.encode(RpcMessage.constPing);
1194
+ const response = parser.encode(RpcMessage.constPong);
1195
+
1196
+ if (!Predicate.isString(request) || !Predicate.isString(response)) {
1197
+ return yield* Effect.die(
1198
+ new Error(
1199
+ 'DurableObjectRpcWebSocket heartbeat auto-response requires a text RPC serialization; use heartbeat: "passthrough" to manage heartbeats externally',
1200
+ ),
1201
+ );
1202
+ }
1203
+
1204
+ const configured = yield* state.getWebSocketAutoResponse;
1205
+
1206
+ if (
1207
+ configured !== null &&
1208
+ (configured.request !== request || configured.response !== response)
1209
+ ) {
1210
+ return yield* Effect.die(
1211
+ new Error(
1212
+ 'DurableObjectRpcWebSocket cannot replace the application WebSocket auto-response; use heartbeat: "passthrough" to preserve it',
1213
+ ),
1214
+ );
1215
+ }
1216
+
1217
+ const pair = new WebSocketRequestResponsePair(request, response);
1218
+ let enabled = configured !== null;
1219
+
1220
+ return {
1221
+ setEnabled: (nextEnabled: boolean) => {
1222
+ if (enabled === nextEnabled) {
1223
+ return Effect.void;
1224
+ }
1225
+
1226
+ return state.setWebSocketAutoResponse(nextEnabled ? pair : undefined).pipe(
1227
+ Effect.tap(() =>
1228
+ Effect.sync(() => {
1229
+ enabled = nextEnabled;
1230
+ }),
1231
+ ),
1232
+ );
1233
+ },
1234
+ };
1235
+ });