effect-cf 0.31.0 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -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 -31
- package/package.json +1 -1
- package/src/DurableObjectRpcWebSocket.ts +1031 -47
|
@@ -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);
|
|
410
|
+
|
|
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
|
+
}
|
|
116
450
|
|
|
117
|
-
|
|
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) => {
|
|
@@ -205,11 +1059,8 @@ export const layer = (
|
|
|
205
1059
|
}),
|
|
206
1060
|
),
|
|
207
1061
|
close: unregister,
|
|
208
|
-
error:
|
|
209
|
-
|
|
210
|
-
yield* Effect.logDebug("Durable Object RPC websocket error", cause);
|
|
211
|
-
yield* unregister(socket);
|
|
212
|
-
}),
|
|
1062
|
+
error: resetSocket,
|
|
1063
|
+
checkpoint,
|
|
213
1064
|
});
|
|
214
1065
|
|
|
215
1066
|
return Context.mergeAll(
|
|
@@ -222,26 +1073,159 @@ export const layer = (
|
|
|
222
1073
|
const normalizeMessage = (message: NativeWebSocketMessage) =>
|
|
223
1074
|
Predicate.isString(message) ? message : new Uint8Array(message);
|
|
224
1075
|
|
|
225
|
-
|
|
1076
|
+
type AttachmentRead =
|
|
1077
|
+
| { readonly _tag: "Missing" }
|
|
1078
|
+
| { readonly _tag: "Invalid" }
|
|
1079
|
+
| { readonly _tag: "Valid"; readonly metadata: AttachmentMetadata };
|
|
1080
|
+
|
|
1081
|
+
const readAttachment = (socket: WebSocket, key: string): AttachmentRead => {
|
|
226
1082
|
const value = socket.deserializeAttachment();
|
|
227
1083
|
|
|
1084
|
+
if (value === null || value === undefined) {
|
|
1085
|
+
return { _tag: "Missing" };
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
if (!Predicate.isObject(value)) {
|
|
1089
|
+
return { _tag: "Invalid" };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (!Predicate.hasProperty(value, key)) {
|
|
1093
|
+
return { _tag: "Missing" };
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const metadata = value[key];
|
|
1097
|
+
|
|
1098
|
+
if (Predicate.isNumber(metadata) && isClientId(metadata)) {
|
|
1099
|
+
return {
|
|
1100
|
+
_tag: "Valid",
|
|
1101
|
+
metadata: {
|
|
1102
|
+
version: legacyAttachmentVersion,
|
|
1103
|
+
clientId: metadata,
|
|
1104
|
+
hasPendingRequests: false,
|
|
1105
|
+
},
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (!Predicate.isObject(metadata)) {
|
|
1110
|
+
return { _tag: "Invalid" };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (
|
|
1114
|
+
Predicate.hasProperty(metadata, "version") &&
|
|
1115
|
+
metadata.version === resumableAttachmentVersion
|
|
1116
|
+
) {
|
|
1117
|
+
const decoded = decodeResumableAttachment(metadata);
|
|
1118
|
+
|
|
1119
|
+
return Option.isSome(decoded) && isClientId(decoded.value.clientId)
|
|
1120
|
+
? { _tag: "Valid", metadata: decoded.value }
|
|
1121
|
+
: { _tag: "Invalid" };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const hasPendingRequests = Predicate.hasProperty(metadata, "hasPendingRequests")
|
|
1125
|
+
? metadata.hasPendingRequests
|
|
1126
|
+
: false;
|
|
1127
|
+
|
|
228
1128
|
if (
|
|
229
|
-
Predicate.
|
|
230
|
-
Predicate.hasProperty(
|
|
231
|
-
Predicate.isNumber(
|
|
1129
|
+
(!Predicate.hasProperty(metadata, "version") || metadata.version === legacyAttachmentVersion) &&
|
|
1130
|
+
Predicate.hasProperty(metadata, "clientId") &&
|
|
1131
|
+
Predicate.isNumber(metadata.clientId) &&
|
|
1132
|
+
isClientId(metadata.clientId) &&
|
|
1133
|
+
Predicate.isBoolean(hasPendingRequests)
|
|
232
1134
|
) {
|
|
233
|
-
return
|
|
1135
|
+
return {
|
|
1136
|
+
_tag: "Valid",
|
|
1137
|
+
metadata: {
|
|
1138
|
+
version: legacyAttachmentVersion,
|
|
1139
|
+
clientId: metadata.clientId,
|
|
1140
|
+
hasPendingRequests,
|
|
1141
|
+
},
|
|
1142
|
+
};
|
|
234
1143
|
}
|
|
235
1144
|
|
|
236
|
-
return
|
|
1145
|
+
return { _tag: "Invalid" };
|
|
237
1146
|
};
|
|
238
1147
|
|
|
239
|
-
const
|
|
1148
|
+
const isClientId = (value: number): boolean => Number.isSafeInteger(value) && value >= 0;
|
|
1149
|
+
|
|
1150
|
+
const writeAttachment = (socket: WebSocket, key: string, metadata: AttachmentMetadata) => {
|
|
240
1151
|
const current = socket.deserializeAttachment();
|
|
1152
|
+
|
|
1153
|
+
if (current !== null && current !== undefined && !Predicate.isObject(current)) {
|
|
1154
|
+
throw new Error(
|
|
1155
|
+
"DurableObjectRpcWebSocket requires object-shaped websocket attachments so adapter metadata can preserve application fields",
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
241
1159
|
const attachment = Predicate.isObject(current) ? current : {};
|
|
1160
|
+
const currentMetadata =
|
|
1161
|
+
Predicate.hasProperty(attachment, key) && Predicate.isObject(attachment[key])
|
|
1162
|
+
? attachment[key]
|
|
1163
|
+
: {};
|
|
242
1164
|
|
|
243
1165
|
socket.serializeAttachment({
|
|
244
1166
|
...attachment,
|
|
245
|
-
[key]:
|
|
1167
|
+
[key]: {
|
|
1168
|
+
...currentMetadata,
|
|
1169
|
+
...metadata,
|
|
1170
|
+
},
|
|
246
1171
|
});
|
|
247
1172
|
};
|
|
1173
|
+
|
|
1174
|
+
interface HeartbeatController {
|
|
1175
|
+
readonly setEnabled: (enabled: boolean) => Effect.Effect<void>;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const makeHeartbeatController = (
|
|
1179
|
+
state: DurableObjectState["Service"],
|
|
1180
|
+
serialization: RpcSerialization.RpcSerialization["Service"],
|
|
1181
|
+
policy: HeartbeatPolicy,
|
|
1182
|
+
): Effect.Effect<HeartbeatController> =>
|
|
1183
|
+
Effect.gen(function* () {
|
|
1184
|
+
if (policy === "passthrough") {
|
|
1185
|
+
return { setEnabled: () => Effect.void };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const parser = serialization.makeUnsafe();
|
|
1189
|
+
const request = parser.encode(RpcMessage.constPing);
|
|
1190
|
+
const response = parser.encode(RpcMessage.constPong);
|
|
1191
|
+
|
|
1192
|
+
if (!Predicate.isString(request) || !Predicate.isString(response)) {
|
|
1193
|
+
return yield* Effect.die(
|
|
1194
|
+
new Error(
|
|
1195
|
+
'DurableObjectRpcWebSocket heartbeat auto-response requires a text RPC serialization; use heartbeat: "passthrough" to manage heartbeats externally',
|
|
1196
|
+
),
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const configured = yield* state.getWebSocketAutoResponse;
|
|
1201
|
+
|
|
1202
|
+
if (
|
|
1203
|
+
configured !== null &&
|
|
1204
|
+
(configured.request !== request || configured.response !== response)
|
|
1205
|
+
) {
|
|
1206
|
+
return yield* Effect.die(
|
|
1207
|
+
new Error(
|
|
1208
|
+
'DurableObjectRpcWebSocket cannot replace the application WebSocket auto-response; use heartbeat: "passthrough" to preserve it',
|
|
1209
|
+
),
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const pair = new WebSocketRequestResponsePair(request, response);
|
|
1214
|
+
let enabled = configured !== null;
|
|
1215
|
+
|
|
1216
|
+
return {
|
|
1217
|
+
setEnabled: (nextEnabled: boolean) => {
|
|
1218
|
+
if (enabled === nextEnabled) {
|
|
1219
|
+
return Effect.void;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
return state.setWebSocketAutoResponse(nextEnabled ? pair : undefined).pipe(
|
|
1223
|
+
Effect.tap(() =>
|
|
1224
|
+
Effect.sync(() => {
|
|
1225
|
+
enabled = nextEnabled;
|
|
1226
|
+
}),
|
|
1227
|
+
),
|
|
1228
|
+
);
|
|
1229
|
+
},
|
|
1230
|
+
};
|
|
1231
|
+
});
|