@livestore/common-cf 0.5.0-dev.0 → 0.5.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.
Files changed (39) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/declare/mod.js +1 -1
  3. package/dist/do-rpc/client.d.ts +8 -0
  4. package/dist/do-rpc/client.d.ts.map +1 -1
  5. package/dist/do-rpc/client.js +67 -37
  6. package/dist/do-rpc/client.js.map +1 -1
  7. package/dist/do-rpc/client.test.js +97 -7
  8. package/dist/do-rpc/client.test.js.map +1 -1
  9. package/dist/do-rpc/mod.d.ts +1 -1
  10. package/dist/do-rpc/mod.d.ts.map +1 -1
  11. package/dist/do-rpc/mod.js +2 -2
  12. package/dist/do-rpc/mod.js.map +1 -1
  13. package/dist/do-rpc/rpc.test.js +2 -2
  14. package/dist/do-rpc/rpc.test.js.map +1 -1
  15. package/dist/do-rpc/server.d.ts +4 -3
  16. package/dist/do-rpc/server.d.ts.map +1 -1
  17. package/dist/do-rpc/server.js +55 -63
  18. package/dist/do-rpc/server.js.map +1 -1
  19. package/dist/do-rpc/test-fixtures/rpc-schema.d.ts +16 -16
  20. package/dist/do-rpc/test-fixtures/worker.d.ts.map +1 -1
  21. package/dist/do-rpc/test-fixtures/worker.js +1 -1
  22. package/dist/mod.js +3 -3
  23. package/dist/ws-rpc/test-fixtures/rpc-schema.d.ts +16 -16
  24. package/dist/ws-rpc/test-fixtures/worker.d.ts.map +1 -1
  25. package/dist/ws-rpc/test-fixtures/worker.js +2 -2
  26. package/dist/ws-rpc/ws-rpc-server.d.ts +8 -2
  27. package/dist/ws-rpc/ws-rpc-server.d.ts.map +1 -1
  28. package/dist/ws-rpc/ws-rpc-server.js +45 -13
  29. package/dist/ws-rpc/ws-rpc-server.js.map +1 -1
  30. package/dist/ws-rpc/ws-rpc.test.js +1 -1
  31. package/dist/ws-rpc/ws-rpc.test.js.map +1 -1
  32. package/package.json +28 -28
  33. package/src/do-rpc/README.md +2 -2
  34. package/src/do-rpc/client.test.ts +148 -7
  35. package/src/do-rpc/client.ts +94 -53
  36. package/src/do-rpc/mod.ts +1 -1
  37. package/src/do-rpc/rpc.test.ts +1 -1
  38. package/src/do-rpc/server.ts +84 -67
  39. package/src/ws-rpc/ws-rpc-server.ts +78 -12
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Effect,
3
3
  Exit,
4
+ Fiber,
4
5
  Headers,
5
6
  type Layer,
6
7
  Option,
@@ -19,17 +20,29 @@ import {
19
20
 
20
21
  import type * as CfTypes from '../cf-types.ts'
21
22
 
22
- /**
23
- * The erased dynamic RPC schemas (`Rpc.exitSchema(...) as Schema.Top`, `rpc.payloadSchema`) are
24
- * plain data — they need NO decoding/encoding services. `Schema.Top` types those service channels
25
- * as `unknown`, which surfaces as `unknown` in the requirements channel of `encode/decodeUnknownEffect`
26
- * (anyUnknownInErrorContext / TS377030). Assert the true `never` services in ONE place.
27
- *
28
- * TODO(effect): revisit if a cleaner/idiomatic API lands — https://github.com/Effect-TS/effect/issues/6489
29
- */
30
- const erasedJsonCodec = (schema: Schema.Top) =>
23
+ const isEncodedRpcMessage = Schema.is(RpcMessage.EncodedSchema)
24
+
25
+ const isRequestEncoded = (message: unknown): message is RpcMessage.RequestEncoded =>
26
+ isEncodedRpcMessage(message) === true && message._tag === 'Request'
27
+
28
+ const erasedCodec = (codecFor: RpcSerialization.CodecFor, schema: Schema.Top) =>
31
29
  // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- erased RPC data schemas require no codec services
32
- Schema.toCodecJson(schema as Schema.Codec<unknown, unknown, never, never>)
30
+ codecFor(schema as Schema.Codec<unknown, unknown, never, never>)
31
+
32
+ const schemaBinarySerialization = RpcSerialization.RpcSerialization.pipe(
33
+ Effect.provide(RpcSerialization.layerSchemaBinary()),
34
+ )
35
+
36
+ const encodeBytes = (parser: RpcSerialization.Parser, value: unknown): Uint8Array<ArrayBuffer> => {
37
+ const encoded = parser.encode(value)
38
+ if (encoded instanceof Uint8Array === false) {
39
+ throw new TypeError('SchemaBinary RPC serialization did not produce bytes')
40
+ }
41
+ if (encoded.buffer instanceof ArrayBuffer === false) {
42
+ throw new TypeError('SchemaBinary RPC serialization produced shared bytes')
43
+ }
44
+ return new Uint8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength)
45
+ }
33
46
 
34
47
  export interface ClientDoWithRpcCallback {
35
48
  __DURABLE_OBJECT_BRAND: never
@@ -58,22 +71,10 @@ export const toDurableObjectHandler =
58
71
  ) => Effect.Effect<Uint8Array<ArrayBuffer> | CfTypes.ReadableStream>) =>
59
72
  (serializedPayload) =>
60
73
  Effect.gen(function* () {
61
- const parser = RpcSerialization.msgPack.makeUnsafe()
62
-
63
- // Decode incoming requests - client sends array of requests
64
- const decoded = parser.decode(serializedPayload)
65
-
66
- // Handle potential nested array from client serialization
67
- let requests: RpcMessage.FromClientEncoded[]
68
- if (Array.isArray(decoded) === true && decoded.length === 1 && Array.isArray(decoded[0]) === true) {
69
- // Double-wrapped array [[{...}]] -> [{...}]
70
- requests = decoded[0]
71
- } else if (Array.isArray(decoded) === true) {
72
- // Single array [{...}]
73
- requests = decoded
74
- } else {
75
- requests = []
76
- }
74
+ const serialization = yield* schemaBinarySerialization
75
+ const parser = serialization.makeUnsafe()
76
+
77
+ const requests = parser.decode(serializedPayload).filter(isRequestEncoded)
77
78
 
78
79
  // Get the context with handlers
79
80
  const context = yield* Effect.context<Rpc.ToHandler<Rpcs> | Rpc.Middleware<Rpcs>>()
@@ -82,9 +83,6 @@ export const toDurableObjectHandler =
82
83
  const responses: any[] = []
83
84
 
84
85
  for (const request of requests) {
85
- if (request._tag !== 'Request') {
86
- continue
87
- }
88
86
  const requestId = RpcMessage.RequestId(request.id)
89
87
 
90
88
  // Find the RPC handler
@@ -102,19 +100,17 @@ export const toDurableObjectHandler =
102
100
  continue
103
101
  }
104
102
 
105
- const payloadResult = yield* Schema.decodeUnknownEffect(erasedJsonCodec(rpc.payloadSchema))(
103
+ const payloadResult = yield* Schema.decodeUnknownEffect(erasedCodec(serialization.codecFor, rpc.payloadSchema))(
106
104
  request.payload,
107
105
  ).pipe(Effect.provideContext(entry.context), Effect.result)
108
106
 
109
107
  if (Result.isFailure(payloadResult) === true) {
110
- // Request payloads are encoded with the JSON codec by Effect's RPC client. Decode them
111
- // before dispatch so JSON-only representations such as `null` become their schema values.
112
108
  // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Rpc.exitSchema requires AnyWithProps; type narrowing already done above
113
109
  const exitSchema = Rpc.exitSchema(rpc as any) as Schema.Top
114
110
  const rawExit = Exit.die(SchemaIssue.makeFormatterDefault()(payloadResult.failure.issue))
115
- const encodedExit = yield* Schema.encodeUnknownEffect(erasedJsonCodec(exitSchema))(rawExit).pipe(
116
- Effect.provideContext(entry.context),
117
- )
111
+ const encodedExit = yield* Schema.encodeUnknownEffect(erasedCodec(serialization.codecFor, exitSchema))(
112
+ rawExit,
113
+ ).pipe(Effect.provideContext(entry.context))
118
114
  responses.push({
119
115
  _tag: 'Exit',
120
116
  requestId,
@@ -130,7 +126,15 @@ export const toDurableObjectHandler =
130
126
 
131
127
  // For streaming RPCs with only one request, return ReadableStream directly
132
128
  if (isStream === true && requests.length === 1) {
133
- return yield* createStreamingResponse(rpc, entry, requestId, payload, parser, options.layer)
129
+ return yield* createStreamingResponse(
130
+ rpc,
131
+ entry,
132
+ requestId,
133
+ payload,
134
+ parser,
135
+ serialization.codecFor,
136
+ options.layer,
137
+ )
134
138
  }
135
139
 
136
140
  // Execute the handler
@@ -143,7 +147,8 @@ export const toDurableObjectHandler =
143
147
  }),
144
148
  rpc,
145
149
  })
146
- const effectOrStream = Rpc.isWrapper(handlerResult) === true ? handlerResult.value : handlerResult
150
+ const effectOrStream: Effect.Effect<unknown, unknown> | Stream.Stream<unknown, unknown> =
151
+ Rpc.isWrapper(handlerResult) === true ? handlerResult.value : handlerResult
147
152
 
148
153
  let value: any
149
154
  if (Effect.isEffect(effectOrStream) === true) {
@@ -161,7 +166,7 @@ export const toDurableObjectHandler =
161
166
  if (exitSchema !== undefined) {
162
167
  // Use schema encoding for proper serialization
163
168
  const rawExit = Exit.succeed(value)
164
- encodedExit = yield* Schema.encodeUnknownEffect(erasedJsonCodec(exitSchema))(rawExit)
169
+ encodedExit = yield* Schema.encodeUnknownEffect(erasedCodec(serialization.codecFor, exitSchema))(rawExit)
165
170
  } else {
166
171
  // Fallback to direct exit
167
172
  encodedExit = Exit.succeed(value)
@@ -183,7 +188,9 @@ export const toDurableObjectHandler =
183
188
  if (exitSchema !== undefined) {
184
189
  // Use schema encoding for proper serialization
185
190
  const rawExit = Exit.failCause(cause)
186
- encodedExit = yield* Schema.encodeUnknownEffect(erasedJsonCodec(exitSchema))(rawExit)
191
+ encodedExit = yield* Schema.encodeUnknownEffect(erasedCodec(serialization.codecFor, exitSchema))(
192
+ rawExit,
193
+ )
187
194
  } else {
188
195
  // Fallback to direct exit
189
196
  encodedExit = Exit.failCause(cause)
@@ -201,9 +208,7 @@ export const toDurableObjectHandler =
201
208
  responses.push(result)
202
209
  }
203
210
 
204
- // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- msgPack parser.encode returns unknown; cast to expected wire format
205
- const encoded = parser.encode(responses) as Uint8Array<ArrayBuffer>
206
- return encoded
211
+ return encodeBytes(parser, responses)
207
212
  }).pipe(Effect.provide(options.layer), Effect.scoped, Effect.orDie) as Effect.Effect<
208
213
  Uint8Array<ArrayBuffer> | CfTypes.ReadableStream
209
214
  >
@@ -213,6 +218,7 @@ export const emitStreamResponse = Effect.fn('do-rpc/emitStreamResponse')(functio
213
218
  callerContext,
214
219
  env,
215
220
  requestId,
221
+ schema,
216
222
  storeId,
217
223
  values,
218
224
  }: {
@@ -220,7 +226,8 @@ export const emitStreamResponse = Effect.fn('do-rpc/emitStreamResponse')(functio
220
226
  callerContext: { bindingName: string; durableObjectId: string }
221
227
  requestId: string
222
228
  storeId: string
223
- values: ReadonlyArray.NonEmptyReadonlyArray<any>
229
+ schema: Schema.Top
230
+ values: ReadonlyArray.NonEmptyReadonlyArray<unknown>
224
231
  }) {
225
232
  // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- CF worker env bindings are typed as Record<string, any>; narrowing to known DO namespace
226
233
  const clientDoNamespace = env[callerContext.bindingName] as
@@ -233,12 +240,15 @@ export const emitStreamResponse = Effect.fn('do-rpc/emitStreamResponse')(functio
233
240
 
234
241
  const clientDo = clientDoNamespace.get(clientDoNamespace.idFromString(callerContext.durableObjectId))
235
242
 
236
- const res: RpcMessage.ResponseChunkEncoded = { _tag: 'Chunk', requestId, values }
237
- const parser = RpcSerialization.msgPack.makeUnsafe()
243
+ const serialization = yield* schemaBinarySerialization
244
+ const parser = serialization.makeUnsafe()
245
+ const encodedValues = yield* Schema.encodeUnknownEffect(
246
+ erasedCodec(serialization.codecFor, Schema.NonEmptyArray(schema)),
247
+ )(values)
248
+ const res = { _tag: 'Chunk', requestId, values: encodedValues }
238
249
  // Native Cloudflare RPC rejects schema values with custom prototypes. Keep the callback
239
250
  // boundary clone-safe by sending the already-encoded Effect RPC message as bytes.
240
- // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- msgPack parser.encode returns unknown; the encoded result is a byte payload
241
- const serializedRes = parser.encode(res) as Uint8Array<ArrayBuffer>
251
+ const serializedRes = encodeBytes(parser, res)
242
252
 
243
253
  yield* Effect.tryPromise(() => clientDo.syncUpdateRpc(serializedRes, storeId))
244
254
  })
@@ -252,7 +262,8 @@ const createStreamingResponse = <Rpcs extends Rpc.Any, LE>(
252
262
  entry: Rpc.Handler<Rpcs['_tag']>,
253
263
  requestId: RpcMessage.RequestId,
254
264
  payload: unknown,
255
- parser: ReturnType<typeof RpcSerialization.msgPack.makeUnsafe>,
265
+ parser: RpcSerialization.Parser,
266
+ codecFor: RpcSerialization.CodecFor,
256
267
  layer: Layer.Layer<Rpc.ToHandler<Rpcs> | Rpc.Middleware<Rpcs>, LE>,
257
268
  ): Effect.Effect<CfTypes.ReadableStream, never, Scope.Scope> =>
258
269
  Effect.gen(function* () {
@@ -265,11 +276,15 @@ const createStreamingResponse = <Rpcs extends Rpc.Any, LE>(
265
276
  }),
266
277
  rpc,
267
278
  })
268
- const effectOrStream = Rpc.isWrapper(handlerResult) === true ? handlerResult.value : handlerResult
279
+ const effectOrStream: Effect.Effect<unknown, unknown> | Stream.Stream<unknown, unknown> =
280
+ Rpc.isWrapper(handlerResult) === true ? handlerResult.value : handlerResult
269
281
 
270
- const stream: Stream.Stream<any, any> =
271
- // @effect-diagnostics-next-line anyUnknownInErrorContext:off -- `Rpc.Handler.handler` returns `Effect<any, any>` due to dynamic dispatch; orDie converts the error to a defect handled by the downstream catchCause
272
- Effect.isEffect(effectOrStream) === true ? yield* Effect.orDie(effectOrStream) : effectOrStream
282
+ // @effect-diagnostics-next-line anyUnknownInErrorContext:off -- dynamically dispatched handlers have an unknown error; orDie converts it to a defect handled by the downstream catchCause
283
+ const result = Effect.isEffect(effectOrStream) === true ? yield* Effect.orDie(effectOrStream) : effectOrStream
284
+ if (Stream.isStream(result) === false) {
285
+ return yield* Effect.die(`Streaming RPC ${rpc._tag} did not produce a Stream`)
286
+ }
287
+ const stream = result
273
288
 
274
289
  // Get the stream schemas for proper chunk-level encoding
275
290
  // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Rpc.Handler doesn't expose successSchema publicly; see https://github.com/Effect-TS/effect/issues/6064
@@ -282,10 +297,12 @@ const createStreamingResponse = <Rpcs extends Rpc.Any, LE>(
282
297
  : Option.none()
283
298
  const arrayEncoder =
284
299
  Option.isSome(streamSchemas) === true
285
- ? Schema.encodeUnknownEffect(Schema.toCodecJson(Schema.Array(streamSchemas.value.success)))
286
- : Schema.encodeUnknownEffect(Schema.toCodecJson(Schema.Array(Schema.Any)))
300
+ ? Schema.encodeUnknownEffect(erasedCodec(codecFor, Schema.NonEmptyArray(streamSchemas.value.success)))
301
+ : Schema.encodeUnknownEffect(erasedCodec(codecFor, Schema.NonEmptyArray(Schema.Any)))
287
302
 
288
303
  // Convert stream to ReadableStream
304
+ let producer: Fiber.Fiber<void> | undefined
305
+ let cancelled = false
289
306
  const readableStream = new ReadableStream({
290
307
  start(controller) {
291
308
  // Run the stream and send chunks + final exit
@@ -304,9 +321,7 @@ const createStreamingResponse = <Rpcs extends Rpc.Any, LE>(
304
321
  values: encodedValues,
305
322
  }
306
323
 
307
- // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- msgPack parser.encode returns unknown; cast to expected wire format
308
- const serialized = parser.encode([chunkMessage]) as Uint8Array<ArrayBuffer>
309
- controller.enqueue(serialized)
324
+ controller.enqueue(encodeBytes(parser, [chunkMessage]))
310
325
  }),
311
326
  )
312
327
 
@@ -314,7 +329,7 @@ const createStreamingResponse = <Rpcs extends Rpc.Any, LE>(
314
329
  const rawExit = Exit.void
315
330
  // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Rpc.exitSchema requires AnyWithProps; type narrowing already done above
316
331
  const exitSchema = Rpc.exitSchema(rpc as any) as Schema.Top
317
- const encodedExit = yield* Schema.encodeUnknownEffect(erasedJsonCodec(exitSchema))(rawExit)
332
+ const encodedExit = yield* Schema.encodeUnknownEffect(erasedCodec(codecFor, exitSchema))(rawExit)
318
333
 
319
334
  const exitMessage = {
320
335
  _tag: 'Exit' as const,
@@ -322,18 +337,17 @@ const createStreamingResponse = <Rpcs extends Rpc.Any, LE>(
322
337
  exit: encodedExit,
323
338
  }
324
339
 
325
- // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- msgPack parser.encode returns unknown; cast to expected wire format
326
- const exitSerialized = parser.encode([exitMessage]) as Uint8Array<ArrayBuffer>
327
- controller.enqueue(exitSerialized)
340
+ controller.enqueue(encodeBytes(parser, [exitMessage]))
328
341
  controller.close()
329
342
  }).pipe(
330
343
  Effect.catchCause((cause) =>
331
344
  Effect.gen(function* () {
345
+ if (cancelled === true) return
332
346
  // Send error exit with proper schema encoding
333
347
  const rawExit = Exit.failCause(cause)
334
348
  // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Rpc.exitSchema requires AnyWithProps; type narrowing already done above
335
349
  const exitSchema = Rpc.exitSchema(rpc as any) as Schema.Top
336
- const encodedExit = yield* Schema.encodeUnknownEffect(erasedJsonCodec(exitSchema))(rawExit)
350
+ const encodedExit = yield* Schema.encodeUnknownEffect(erasedCodec(codecFor, exitSchema))(rawExit)
337
351
 
338
352
  const exitMessage = {
339
353
  _tag: 'Exit' as const,
@@ -341,23 +355,26 @@ const createStreamingResponse = <Rpcs extends Rpc.Any, LE>(
341
355
  exit: encodedExit,
342
356
  }
343
357
 
344
- // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- msgPack parser.encode returns unknown; cast to expected wire format
345
- const exitSerialized = parser.encode([exitMessage]) as Uint8Array<ArrayBuffer>
346
- controller.enqueue(exitSerialized)
358
+ controller.enqueue(encodeBytes(parser, [exitMessage]))
347
359
  controller.close()
348
360
  }),
349
361
  ),
350
362
  )
351
363
 
352
364
  // Run the stream processing
353
- runStream.pipe(
365
+ producer = runStream.pipe(
354
366
  Effect.provide(layer),
355
367
  Effect.scoped,
356
368
  Effect.tapCauseLogPretty,
357
369
  (_) => _ as Effect.Effect<void>,
358
- Effect.runPromise,
370
+ Effect.runFork,
359
371
  )
360
372
  },
373
+ cancel() {
374
+ cancelled = true
375
+ // @effect-diagnostics-next-line runEffectInsideEffect:off -- native stream callback runs outside Effect; interruption needs no services
376
+ return producer === undefined ? undefined : Effect.runPromise(Fiber.interrupt(producer))
377
+ },
361
378
  // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- bridging standard Web API ReadableStream to Cloudflare Worker ReadableStream type
362
379
  }) as any as CfTypes.ReadableStream
363
380
 
@@ -28,6 +28,7 @@ import {
28
28
  RpcMessage,
29
29
  RpcSerialization,
30
30
  RpcServer,
31
+ Schema,
31
32
  Scope,
32
33
  Stream,
33
34
  } from '@livestore/utils/effect'
@@ -71,6 +72,10 @@ export interface DurableObjectWebSocketRpcConfig {
71
72
  rpcLayer: Layer.Layer<never, never, RpcServer.Protocol | WsContext>
72
73
  /** Function to get access to incoming requests */
73
74
  onMessage?: (msg: RpcMessage.FromClientEncoded, ws: CfTypes.WebSocket) => void
75
+ /** Called after a terminal RPC response has been accepted by the serializer. */
76
+ onRequestExit?: (requestId: string | number, ws: CfTypes.WebSocket) => void
77
+ /** Called after a connection-level RPC defect has been accepted by the serializer. */
78
+ onProtocolDefect?: (ws: CfTypes.WebSocket) => void
74
79
  mainLayer?: Layer.Layer<never>
75
80
  }
76
81
 
@@ -139,6 +144,8 @@ export const setupDurableObjectWebSocketRpc = ({
139
144
  rpcLayer,
140
145
  webSocketMode,
141
146
  onMessage,
147
+ onRequestExit,
148
+ onProtocolDefect,
142
149
  mainLayer,
143
150
  }: DurableObjectWebSocketRpcConfig) => {
144
151
  if (webSocketMode === 'accept') {
@@ -171,7 +178,7 @@ export const setupDurableObjectWebSocketRpc = ({
171
178
  ws,
172
179
  scope,
173
180
  incomingQueue,
174
- ...omitUndefineds({ onMessage }),
181
+ ...omitUndefineds({ onMessage, onRequestExit, onProtocolDefect }),
175
182
  }).pipe(Layer.provide(RpcSerialization.layerJson))
176
183
 
177
184
  const ServerLive = rpcLayer.pipe(Layer.provide(ProtocolLive))
@@ -239,6 +246,8 @@ export interface WsRpcServerArgs {
239
246
  ws: CfTypes.WebSocket
240
247
  scope: Scope.Scope
241
248
  onMessage?: (message: RpcMessage.FromClientEncoded, ws: CfTypes.WebSocket) => void
249
+ onRequestExit?: (requestId: string | number, ws: CfTypes.WebSocket) => void
250
+ onProtocolDefect?: (ws: CfTypes.WebSocket) => void
242
251
  /** Queue for receiving incoming messages from the WebSocket */
243
252
  incomingQueue: Queue.Queue<Uint8Array | string>
244
253
  }
@@ -274,30 +283,79 @@ export const layerRpcServerWebsocket = (args: WsRpcServerArgs) =>
274
283
  *
275
284
  * @internal Used internally by `layerRpcServerWebsocket`
276
285
  */
277
- const makeSocketProtocol = ({ incomingQueue, scope, ws, onMessage }: WsRpcServerArgs) =>
286
+ const makeSocketProtocol = ({
287
+ incomingQueue,
288
+ scope,
289
+ ws,
290
+ onMessage,
291
+ onRequestExit,
292
+ onProtocolDefect,
293
+ }: WsRpcServerArgs) =>
278
294
  Effect.gen(function* () {
279
295
  const serialization = yield* RpcSerialization.RpcSerialization
280
296
  const disconnects = yield* Queue.unbounded<number>()
297
+ const requestIdsWithSchemas = new Set<string | number>()
298
+ const interruptedExitEncoded = yield* Schema.encodeUnknownEffect(
299
+ serialization.codecFor(Schema.Exit(Schema.Void, Schema.Never, Schema.Never)),
300
+ )(Exit.interrupt()).pipe(Effect.orDie)
281
301
 
282
- const writeRaw = (msg: Uint8Array | string) => Effect.succeed(ws.send(msg))
302
+ const writeRaw = (msg: Uint8Array | string) => Effect.sync(() => ws.send(msg))
283
303
 
284
304
  let writeRequest!: (clientId: number, message: RpcMessage.FromClientEncoded) => Effect.Effect<void>
285
305
 
286
306
  const parser = serialization.makeUnsafe()
287
307
  const id = 0
288
308
 
289
- const write = (response: RpcMessage.FromServerEncoded) => {
309
+ const runLifecycleHook = (name: string, hook: () => void) =>
310
+ Effect.sync(hook).pipe(
311
+ Effect.catchCause((cause) => Effect.logError(`WebSocket RPC ${name} hook failed`, { cause })),
312
+ )
313
+
314
+ const writeDefect = (defect: unknown) => {
315
+ try {
316
+ const encoded = parser.encode(RpcMessage.ResponseDefectEncoded(defect))
317
+ const responseEffect = encoded === undefined ? Effect.void : Effect.orDie(writeRaw(encoded))
318
+ const onAccepted = Effect.sync(() => requestIdsWithSchemas.clear()).pipe(
319
+ Effect.andThen(
320
+ onProtocolDefect !== undefined
321
+ ? runLifecycleHook('onProtocolDefect', () => onProtocolDefect(ws))
322
+ : Effect.void,
323
+ ),
324
+ )
325
+ return Effect.ensuring(responseEffect, onAccepted)
326
+ } catch (cause) {
327
+ return Effect.die(cause)
328
+ }
329
+ }
330
+
331
+ const writeResponse = (response: unknown, onAccepted?: Effect.Effect<void>) => {
290
332
  try {
291
333
  const encoded = parser.encode(response)
292
- if (encoded === undefined) {
293
- return Effect.void
294
- }
295
- return Effect.orDie(writeRaw(encoded))
334
+ const responseEffect = encoded === undefined ? Effect.void : Effect.orDie(writeRaw(encoded))
335
+ return onAccepted === undefined ? responseEffect : Effect.ensuring(responseEffect, onAccepted)
296
336
  } catch (cause) {
297
- return Effect.orDie(writeRaw(parser.encode(RpcMessage.ResponseDefectEncoded(cause))!))
337
+ return writeDefect(cause)
298
338
  }
299
339
  }
300
340
 
341
+ const writeExit = (requestId: string | number, exit: unknown) =>
342
+ writeResponse(
343
+ { _tag: 'Exit', requestId, exit },
344
+ Effect.sync(() => requestIdsWithSchemas.delete(requestId)).pipe(
345
+ Effect.andThen(
346
+ onRequestExit !== undefined
347
+ ? runLifecycleHook('onRequestExit', () => onRequestExit(requestId, ws))
348
+ : Effect.void,
349
+ ),
350
+ ),
351
+ )
352
+
353
+ const write = (response: RpcMessage.FromServerEncoded) => {
354
+ if (response._tag === 'Exit') return writeExit(response.requestId, response.exit)
355
+ if (response._tag === 'Defect') return writeDefect(response.defect)
356
+ return writeResponse(response)
357
+ }
358
+
301
359
  const protocol = yield* RpcServer.Protocol.make((writeRequest_) => {
302
360
  writeRequest = writeRequest_
303
361
 
@@ -312,15 +370,22 @@ const makeSocketProtocol = ({ incomingQueue, scope, ws, onMessage }: WsRpcServer
312
370
  while: () => i < decoded.length,
313
371
  body: () => {
314
372
  const request = decoded[i++]!
315
- if (onMessage !== undefined) {
316
- onMessage(request, ws)
373
+ if (onMessage !== undefined) onMessage(request, ws)
374
+
375
+ if (request._tag === 'Request') {
376
+ requestIdsWithSchemas.add(request.id)
377
+ } else if (request._tag === 'Interrupt' && requestIdsWithSchemas.has(request.requestId) === false) {
378
+ // Effect creates this Exit for an unknown request, but its encoder drops it when hibernation has
379
+ // discarded the request schema.
380
+ return writeExit(request.requestId, interruptedExitEncoded)
317
381
  }
382
+
318
383
  return writeRequest(id, request)
319
384
  },
320
385
  step: Function.constVoid,
321
386
  })
322
387
  } catch (cause) {
323
- return Effect.orDie(writeRaw(parser.encode(RpcMessage.ResponseDefectEncoded(cause))!))
388
+ return writeDefect(cause)
324
389
  }
325
390
  }),
326
391
  Stream.runDrain,
@@ -342,6 +407,7 @@ const makeSocketProtocol = ({ incomingQueue, scope, ws, onMessage }: WsRpcServer
342
407
  supportsTransferables: false,
343
408
  supportsSpanPropagation: true,
344
409
  supportsNotifications: true,
410
+ codecFor: serialization.codecFor,
345
411
  }))
346
412
  })
347
413