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.
- package/CHANGELOG.md +6 -0
- package/README.md +94 -1
- package/dist/ComputerWorkspace.d.mts +1 -1
- package/dist/ComputerWorkspace.mjs +1 -1
- package/dist/{DurableObjectState-DVIgf0sA.d.mts → DurableObjectState-B0A0Y1ef.d.mts} +1 -1
- package/dist/{DurableObjectState-CxS5Y2hf.mjs → DurableObjectState-D1Ibr6k0.mjs} +1 -1
- package/dist/Vitest.d.mts +2 -2
- package/dist/Vitest.mjs +1 -1
- package/dist/{WorkflowBinding-C9KRmfDU.d.mts → WorkflowBinding-B9_S8tX2.d.mts} +1 -1
- package/dist/index.d.mts +92 -6
- package/dist/index.mjs +452 -28
- package/package.json +1 -1
- package/src/DurableObjectRpcWebSocket.ts +1030 -42
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
81
|
-
|
|
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
|
-
|
|
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,
|
|
309
|
+
writeAttachment(socket.raw, attachmentKey, created);
|
|
89
310
|
|
|
90
|
-
return
|
|
311
|
+
return created;
|
|
91
312
|
};
|
|
92
313
|
|
|
93
|
-
const
|
|
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:
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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.
|
|
133
|
-
|
|
134
|
-
const encoded = connection.parser.encode(response);
|
|
754
|
+
Effect.gen(function* () {
|
|
755
|
+
let delivered = response;
|
|
135
756
|
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
}
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
230
|
-
Predicate.hasProperty(
|
|
231
|
-
Predicate.isNumber(
|
|
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
|
|
1139
|
+
return {
|
|
1140
|
+
_tag: "Valid",
|
|
1141
|
+
metadata: {
|
|
1142
|
+
version: legacyAttachmentVersion,
|
|
1143
|
+
clientId: metadata.clientId,
|
|
1144
|
+
hasPendingRequests,
|
|
1145
|
+
},
|
|
1146
|
+
};
|
|
234
1147
|
}
|
|
235
1148
|
|
|
236
|
-
return
|
|
1149
|
+
return { _tag: "Invalid" };
|
|
237
1150
|
};
|
|
238
1151
|
|
|
239
|
-
const
|
|
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]:
|
|
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
|
+
});
|