@livestore/common-cf 0.4.0-dev.9 → 0.5.0-dev.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/dist/.tsbuildinfo +1 -1
- package/dist/cf-types.js +2 -0
- package/dist/cf-types.js.map +1 -1
- package/dist/declare/cf-declare.d.ts +32 -5
- package/dist/declare/cf-declare.d.ts.map +1 -1
- package/dist/declare/cf-declare.js +9 -10
- package/dist/declare/cf-declare.js.map +1 -1
- package/dist/do-rpc/client.d.ts +1 -1
- package/dist/do-rpc/client.d.ts.map +1 -1
- package/dist/do-rpc/client.js +23 -46
- package/dist/do-rpc/client.js.map +1 -1
- package/dist/do-rpc/client.test.d.ts +2 -0
- package/dist/do-rpc/client.test.d.ts.map +1 -0
- package/dist/do-rpc/client.test.js +99 -0
- package/dist/do-rpc/client.test.js.map +1 -0
- package/dist/do-rpc/rpc.test.js +27 -59
- package/dist/do-rpc/rpc.test.js.map +1 -1
- package/dist/do-rpc/server.d.ts +10 -5
- package/dist/do-rpc/server.d.ts.map +1 -1
- package/dist/do-rpc/server.js +104 -54
- package/dist/do-rpc/server.js.map +1 -1
- package/dist/do-rpc/test-fixtures/rpc-schema.d.ts +27 -27
- package/dist/do-rpc/test-fixtures/rpc-schema.js +11 -11
- package/dist/do-rpc/test-fixtures/worker.d.ts.map +1 -1
- package/dist/do-rpc/test-fixtures/worker.js +10 -10
- package/dist/do-rpc/test-fixtures/worker.js.map +1 -1
- package/dist/ws-rpc/test-fixtures/rpc-schema.d.ts +27 -27
- package/dist/ws-rpc/test-fixtures/rpc-schema.js +9 -9
- package/dist/ws-rpc/test-fixtures/worker.d.ts.map +1 -1
- package/dist/ws-rpc/test-fixtures/worker.js +2 -2
- package/dist/ws-rpc/test-fixtures/worker.js.map +1 -1
- package/dist/ws-rpc/ws-rpc-server.d.ts +47 -6
- package/dist/ws-rpc/ws-rpc-server.d.ts.map +1 -1
- package/dist/ws-rpc/ws-rpc-server.js +30 -20
- package/dist/ws-rpc/ws-rpc-server.js.map +1 -1
- package/dist/ws-rpc/ws-rpc.test.js +29 -61
- package/dist/ws-rpc/ws-rpc.test.js.map +1 -1
- package/package.json +40 -16
- package/src/cf-types.ts +2 -0
- package/src/declare/cf-declare.ts +18 -10
- package/src/do-rpc/README.md +1 -2
- package/src/do-rpc/client.test.ts +122 -0
- package/src/do-rpc/client.ts +30 -50
- package/src/do-rpc/rpc.test.ts +33 -61
- package/src/do-rpc/server.ts +157 -80
- package/src/do-rpc/test-fixtures/rpc-schema.ts +11 -11
- package/src/do-rpc/test-fixtures/worker.ts +13 -14
- package/src/ws-rpc/README.md +1 -1
- package/src/ws-rpc/test-fixtures/rpc-schema.ts +9 -9
- package/src/ws-rpc/test-fixtures/worker.ts +6 -4
- package/src/ws-rpc/ws-rpc-server.ts +69 -34
- package/src/ws-rpc/ws-rpc.test.ts +35 -63
- package/src/do-rpc/test-fixtures/.wrangler/state/v3/do/test-durable-object-rpc-TestRpcDurableObject/9ad3dfcb5436dfc3f2e14f5b554a0fd6d8b68206ad64fdde7320232d04e16dfe.sqlite +0 -0
- package/src/do-rpc/test-fixtures/.wrangler/state/v3/do/test-durable-object-rpc-TestRpcDurableObject/9ad3dfcb5436dfc3f2e14f5b554a0fd6d8b68206ad64fdde7320232d04e16dfe.sqlite-shm +0 -0
- package/src/do-rpc/test-fixtures/.wrangler/state/v3/do/test-durable-object-rpc-TestRpcDurableObject/9ad3dfcb5436dfc3f2e14f5b554a0fd6d8b68206ad64fdde7320232d04e16dfe.sqlite-wal +0 -0
- package/src/ws-rpc/test-fixtures/.wrangler/state/v3/do/test-durable-object-rpc-TestRpcDurableObject/9ad3dfcb5436dfc3f2e14f5b554a0fd6d8b68206ad64fdde7320232d04e16dfe.sqlite +0 -0
- package/src/ws-rpc/test-fixtures/.wrangler/state/v3/do/test-durable-object-rpc-TestRpcDurableObject/9ad3dfcb5436dfc3f2e14f5b554a0fd6d8b68206ad64fdde7320232d04e16dfe.sqlite-shm +0 -0
- package/src/ws-rpc/test-fixtures/.wrangler/state/v3/do/test-durable-object-rpc-TestRpcDurableObject/9ad3dfcb5436dfc3f2e14f5b554a0fd6d8b68206ad64fdde7320232d04e16dfe.sqlite-wal +0 -0
package/src/do-rpc/server.ts
CHANGED
|
@@ -1,25 +1,43 @@
|
|
|
1
1
|
import {
|
|
2
|
-
Chunk,
|
|
3
2
|
Effect,
|
|
4
3
|
Exit,
|
|
5
4
|
Headers,
|
|
6
5
|
type Layer,
|
|
7
|
-
type NonEmptyArray,
|
|
8
6
|
Option,
|
|
7
|
+
type ReadonlyArray,
|
|
9
8
|
Rpc,
|
|
10
9
|
type RpcGroup,
|
|
11
|
-
|
|
10
|
+
RpcMessage,
|
|
12
11
|
RpcSchema,
|
|
13
12
|
RpcSerialization,
|
|
13
|
+
Result,
|
|
14
14
|
Schema,
|
|
15
|
+
SchemaIssue,
|
|
15
16
|
type Scope,
|
|
16
17
|
Stream,
|
|
17
18
|
} from '@livestore/utils/effect'
|
|
19
|
+
|
|
18
20
|
import type * as CfTypes from '../cf-types.ts'
|
|
19
21
|
|
|
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) =>
|
|
31
|
+
// 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>)
|
|
33
|
+
|
|
20
34
|
export interface ClientDoWithRpcCallback {
|
|
21
35
|
__DURABLE_OBJECT_BRAND: never
|
|
22
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The sync backend calls this to deliver a live update; `storeId` lets a rebuilt DO reload its
|
|
38
|
+
* store before delivering. See the Cloudflare Durable Object adapter docs for the recovery options.
|
|
39
|
+
*/
|
|
40
|
+
syncUpdateRpc: (payload: Uint8Array<ArrayBuffer>, storeId: string) => Promise<void>
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
/**
|
|
@@ -40,17 +58,17 @@ export const toDurableObjectHandler =
|
|
|
40
58
|
) => Effect.Effect<Uint8Array<ArrayBuffer> | CfTypes.ReadableStream>) =>
|
|
41
59
|
(serializedPayload) =>
|
|
42
60
|
Effect.gen(function* () {
|
|
43
|
-
const parser = RpcSerialization.msgPack.
|
|
61
|
+
const parser = RpcSerialization.msgPack.makeUnsafe()
|
|
44
62
|
|
|
45
63
|
// Decode incoming requests - client sends array of requests
|
|
46
64
|
const decoded = parser.decode(serializedPayload)
|
|
47
65
|
|
|
48
66
|
// Handle potential nested array from client serialization
|
|
49
|
-
let requests: RpcMessage.
|
|
50
|
-
if (Array.isArray(decoded) && decoded.length === 1 && Array.isArray(decoded[0])) {
|
|
67
|
+
let requests: RpcMessage.FromClientEncoded[]
|
|
68
|
+
if (Array.isArray(decoded) === true && decoded.length === 1 && Array.isArray(decoded[0]) === true) {
|
|
51
69
|
// Double-wrapped array [[{...}]] -> [{...}]
|
|
52
70
|
requests = decoded[0]
|
|
53
|
-
} else if (Array.isArray(decoded)) {
|
|
71
|
+
} else if (Array.isArray(decoded) === true) {
|
|
54
72
|
// Single array [{...}]
|
|
55
73
|
requests = decoded
|
|
56
74
|
} else {
|
|
@@ -67,52 +85,83 @@ export const toDurableObjectHandler =
|
|
|
67
85
|
if (request._tag !== 'Request') {
|
|
68
86
|
continue
|
|
69
87
|
}
|
|
88
|
+
const requestId = RpcMessage.RequestId(request.id)
|
|
70
89
|
|
|
71
90
|
// Find the RPC handler
|
|
91
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- RpcGroup.requests map returns Rpc.Any; narrowing to AnyWithProps for property access
|
|
72
92
|
const rpc = group.requests.get(request.tag)! as unknown as Rpc.AnyWithProps
|
|
73
|
-
|
|
93
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- context.mapUnsafe dynamic lookup; type safety ensured by RpcGroup registration
|
|
94
|
+
const entry = context.mapUnsafe.get(rpc.key) as Rpc.Handler<Rpcs['_tag']>
|
|
74
95
|
|
|
75
|
-
if (
|
|
96
|
+
if (rpc == null || entry == null) {
|
|
76
97
|
responses.push({
|
|
77
98
|
_tag: 'Exit',
|
|
78
|
-
requestId
|
|
99
|
+
requestId,
|
|
79
100
|
exit: Exit.die(`Unknown request tag: ${request.tag}`),
|
|
80
101
|
})
|
|
81
102
|
continue
|
|
82
103
|
}
|
|
83
104
|
|
|
105
|
+
const payloadResult = yield* Schema.decodeUnknownEffect(erasedJsonCodec(rpc.payloadSchema))(
|
|
106
|
+
request.payload,
|
|
107
|
+
).pipe(Effect.provideContext(entry.context), Effect.result)
|
|
108
|
+
|
|
109
|
+
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
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Rpc.exitSchema requires AnyWithProps; type narrowing already done above
|
|
113
|
+
const exitSchema = Rpc.exitSchema(rpc as any) as Schema.Top
|
|
114
|
+
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
|
+
)
|
|
118
|
+
responses.push({
|
|
119
|
+
_tag: 'Exit',
|
|
120
|
+
requestId,
|
|
121
|
+
exit: encodedExit,
|
|
122
|
+
})
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const payload = payloadResult.success
|
|
127
|
+
|
|
84
128
|
// Check if this is a streaming RPC
|
|
85
|
-
const isStream = RpcSchema.isStreamSchema(
|
|
129
|
+
const isStream = RpcSchema.isStreamSchema(rpc.successSchema)
|
|
86
130
|
|
|
87
131
|
// For streaming RPCs with only one request, return ReadableStream directly
|
|
88
|
-
if (isStream && requests.length === 1) {
|
|
89
|
-
return yield* createStreamingResponse(rpc, entry,
|
|
132
|
+
if (isStream === true && requests.length === 1) {
|
|
133
|
+
return yield* createStreamingResponse(rpc, entry, requestId, payload, parser, options.layer)
|
|
90
134
|
}
|
|
91
135
|
|
|
92
136
|
// Execute the handler
|
|
93
137
|
const result = yield* Effect.gen(function* () {
|
|
94
|
-
const handlerResult = entry.handler(
|
|
95
|
-
|
|
138
|
+
const handlerResult = entry.handler(payload, {
|
|
139
|
+
client: new Rpc.ServerClient(0), // TODO: add proper clientId if needed
|
|
140
|
+
requestId,
|
|
96
141
|
headers: Headers.fromInput({
|
|
97
|
-
'x-rpc-request-id':
|
|
142
|
+
'x-rpc-request-id': requestId.toString(),
|
|
98
143
|
}),
|
|
144
|
+
rpc,
|
|
99
145
|
})
|
|
146
|
+
const effectOrStream = Rpc.isWrapper(handlerResult) === true ? handlerResult.value : handlerResult
|
|
100
147
|
|
|
101
148
|
let value: any
|
|
102
|
-
if (Effect.isEffect(
|
|
103
|
-
|
|
149
|
+
if (Effect.isEffect(effectOrStream) === true) {
|
|
150
|
+
// @effect-diagnostics-next-line anyUnknownInErrorContext:off -- `Rpc.Handler.handler` returns `Effect<any, any>` due to dynamic dispatch
|
|
151
|
+
value = yield* effectOrStream
|
|
104
152
|
} else {
|
|
105
|
-
value =
|
|
153
|
+
value = effectOrStream
|
|
106
154
|
}
|
|
107
155
|
|
|
108
156
|
// Get the exit schema for this RPC
|
|
109
|
-
|
|
157
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Rpc.exitSchema requires AnyWithProps; type narrowing already done above
|
|
158
|
+
const exitSchema = Rpc.exitSchema(rpc as any) as Schema.Top
|
|
110
159
|
|
|
111
160
|
let encodedExit: any
|
|
112
|
-
if (exitSchema) {
|
|
161
|
+
if (exitSchema !== undefined) {
|
|
113
162
|
// Use schema encoding for proper serialization
|
|
114
163
|
const rawExit = Exit.succeed(value)
|
|
115
|
-
encodedExit = yield* Schema.
|
|
164
|
+
encodedExit = yield* Schema.encodeUnknownEffect(erasedJsonCodec(exitSchema))(rawExit)
|
|
116
165
|
} else {
|
|
117
166
|
// Fallback to direct exit
|
|
118
167
|
encodedExit = Exit.succeed(value)
|
|
@@ -120,20 +169,21 @@ export const toDurableObjectHandler =
|
|
|
120
169
|
|
|
121
170
|
return {
|
|
122
171
|
_tag: 'Exit' as const,
|
|
123
|
-
requestId
|
|
172
|
+
requestId,
|
|
124
173
|
exit: encodedExit,
|
|
125
174
|
}
|
|
126
175
|
}).pipe(
|
|
127
|
-
Effect.
|
|
176
|
+
Effect.catchCause((cause) => {
|
|
128
177
|
// Get the exit schema for this RPC
|
|
129
|
-
|
|
178
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Rpc.exitSchema requires AnyWithProps; type narrowing already done above
|
|
179
|
+
const exitSchema = Rpc.exitSchema(rpc as any) as Schema.Top
|
|
130
180
|
|
|
131
181
|
return Effect.gen(function* () {
|
|
132
182
|
let encodedExit: any
|
|
133
|
-
if (exitSchema) {
|
|
183
|
+
if (exitSchema !== undefined) {
|
|
134
184
|
// Use schema encoding for proper serialization
|
|
135
185
|
const rawExit = Exit.failCause(cause)
|
|
136
|
-
encodedExit = yield* Schema.
|
|
186
|
+
encodedExit = yield* Schema.encodeUnknownEffect(erasedJsonCodec(exitSchema))(rawExit)
|
|
137
187
|
} else {
|
|
138
188
|
// Fallback to direct exit
|
|
139
189
|
encodedExit = Exit.failCause(cause)
|
|
@@ -141,7 +191,7 @@ export const toDurableObjectHandler =
|
|
|
141
191
|
|
|
142
192
|
return {
|
|
143
193
|
_tag: 'Exit' as const,
|
|
144
|
-
requestId
|
|
194
|
+
requestId,
|
|
145
195
|
exit: encodedExit,
|
|
146
196
|
}
|
|
147
197
|
})
|
|
@@ -151,37 +201,47 @@ export const toDurableObjectHandler =
|
|
|
151
201
|
responses.push(result)
|
|
152
202
|
}
|
|
153
203
|
|
|
204
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- msgPack parser.encode returns unknown; cast to expected wire format
|
|
154
205
|
const encoded = parser.encode(responses) as Uint8Array<ArrayBuffer>
|
|
155
206
|
return encoded
|
|
156
|
-
}).pipe(Effect.provide(options.layer), Effect.scoped, Effect.orDie)
|
|
207
|
+
}).pipe(Effect.provide(options.layer), Effect.scoped, Effect.orDie) as Effect.Effect<
|
|
208
|
+
Uint8Array<ArrayBuffer> | CfTypes.ReadableStream
|
|
209
|
+
>
|
|
157
210
|
|
|
158
211
|
/** Out-of-band RPC stream response emission back to the caller DO */
|
|
159
|
-
export const emitStreamResponse = ({
|
|
212
|
+
export const emitStreamResponse = Effect.fn('do-rpc/emitStreamResponse')(function* ({
|
|
160
213
|
callerContext,
|
|
161
214
|
env,
|
|
162
215
|
requestId,
|
|
216
|
+
storeId,
|
|
163
217
|
values,
|
|
164
218
|
}: {
|
|
165
219
|
env: Record<string, any>
|
|
166
220
|
callerContext: { bindingName: string; durableObjectId: string }
|
|
167
221
|
requestId: string
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
222
|
+
storeId: string
|
|
223
|
+
values: ReadonlyArray.NonEmptyReadonlyArray<any>
|
|
224
|
+
}) {
|
|
225
|
+
// 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
|
+
const clientDoNamespace = env[callerContext.bindingName] as
|
|
227
|
+
| CfTypes.DurableObjectNamespace<ClientDoWithRpcCallback>
|
|
228
|
+
| undefined
|
|
229
|
+
|
|
230
|
+
if (clientDoNamespace === undefined) {
|
|
231
|
+
throw new Error(`Client DO namespace not found: ${callerContext.bindingName}`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const clientDo = clientDoNamespace.get(clientDoNamespace.idFromString(callerContext.durableObjectId))
|
|
235
|
+
|
|
236
|
+
const res: RpcMessage.ResponseChunkEncoded = { _tag: 'Chunk', requestId, values }
|
|
237
|
+
const parser = RpcSerialization.msgPack.makeUnsafe()
|
|
238
|
+
// Native Cloudflare RPC rejects schema values with custom prototypes. Keep the callback
|
|
239
|
+
// 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>
|
|
242
|
+
|
|
243
|
+
yield* Effect.tryPromise(() => clientDo.syncUpdateRpc(serializedRes, storeId))
|
|
244
|
+
})
|
|
185
245
|
|
|
186
246
|
/**
|
|
187
247
|
* Creates a ReadableStream response for streaming RPCs.
|
|
@@ -190,33 +250,40 @@ export const emitStreamResponse = ({
|
|
|
190
250
|
const createStreamingResponse = <Rpcs extends Rpc.Any, LE>(
|
|
191
251
|
rpc: Rpc.AnyWithProps,
|
|
192
252
|
entry: Rpc.Handler<Rpcs['_tag']>,
|
|
193
|
-
|
|
194
|
-
|
|
253
|
+
requestId: RpcMessage.RequestId,
|
|
254
|
+
payload: unknown,
|
|
255
|
+
parser: ReturnType<typeof RpcSerialization.msgPack.makeUnsafe>,
|
|
195
256
|
layer: Layer.Layer<Rpc.ToHandler<Rpcs> | Rpc.Middleware<Rpcs>, LE>,
|
|
196
|
-
): Effect.Effect<CfTypes.ReadableStream,
|
|
257
|
+
): Effect.Effect<CfTypes.ReadableStream, never, Scope.Scope> =>
|
|
197
258
|
Effect.gen(function* () {
|
|
198
259
|
// Execute the handler to get the stream
|
|
199
|
-
const handlerResult = entry.handler(
|
|
200
|
-
|
|
260
|
+
const handlerResult = entry.handler(payload, {
|
|
261
|
+
client: new Rpc.ServerClient(0), // TODO: add proper clientId if needed
|
|
262
|
+
requestId,
|
|
201
263
|
headers: Headers.fromInput({
|
|
202
|
-
'x-rpc-request-id':
|
|
264
|
+
'x-rpc-request-id': requestId.toString(),
|
|
203
265
|
}),
|
|
266
|
+
rpc,
|
|
204
267
|
})
|
|
268
|
+
const effectOrStream = Rpc.isWrapper(handlerResult) === true ? handlerResult.value : handlerResult
|
|
205
269
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
stream = yield* handlerResult
|
|
210
|
-
} else {
|
|
211
|
-
// Direct stream
|
|
212
|
-
stream = handlerResult
|
|
213
|
-
}
|
|
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
|
|
214
273
|
|
|
215
274
|
// Get the stream schemas for proper chunk-level encoding
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
275
|
+
// 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
|
|
276
|
+
const streamSchemas =
|
|
277
|
+
RpcSchema.isStreamSchema(rpc.successSchema) === true
|
|
278
|
+
? Option.some({
|
|
279
|
+
success: rpc.successSchema.success,
|
|
280
|
+
error: rpc.successSchema.error,
|
|
281
|
+
})
|
|
282
|
+
: Option.none()
|
|
283
|
+
const arrayEncoder =
|
|
284
|
+
Option.isSome(streamSchemas) === true
|
|
285
|
+
? Schema.encodeUnknownEffect(Schema.toCodecJson(Schema.Array(streamSchemas.value.success)))
|
|
286
|
+
: Schema.encodeUnknownEffect(Schema.toCodecJson(Schema.Array(Schema.Any)))
|
|
220
287
|
|
|
221
288
|
// Convert stream to ReadableStream
|
|
222
289
|
const readableStream = new ReadableStream({
|
|
@@ -224,20 +291,20 @@ const createStreamingResponse = <Rpcs extends Rpc.Any, LE>(
|
|
|
224
291
|
// Run the stream and send chunks + final exit
|
|
225
292
|
const runStream = Effect.gen(function* () {
|
|
226
293
|
// Process stream chunks - let chunk encoder handle Effect objects properly
|
|
227
|
-
yield* Stream.
|
|
294
|
+
yield* Stream.runForEachArray(stream, (array) =>
|
|
228
295
|
Effect.gen(function* () {
|
|
229
|
-
|
|
230
|
-
if (chunkArray.length === 0) return
|
|
296
|
+
if (array.length === 0) return
|
|
231
297
|
|
|
232
298
|
// Encode the chunk using the proper chunk encoder (like official RPC)
|
|
233
|
-
const encodedValues = yield*
|
|
299
|
+
const encodedValues = yield* arrayEncoder(array)
|
|
234
300
|
|
|
235
301
|
const chunkMessage = {
|
|
236
302
|
_tag: 'Chunk' as const,
|
|
237
|
-
requestId
|
|
303
|
+
requestId,
|
|
238
304
|
values: encodedValues,
|
|
239
305
|
}
|
|
240
306
|
|
|
307
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- msgPack parser.encode returns unknown; cast to expected wire format
|
|
241
308
|
const serialized = parser.encode([chunkMessage]) as Uint8Array<ArrayBuffer>
|
|
242
309
|
controller.enqueue(serialized)
|
|
243
310
|
}),
|
|
@@ -245,32 +312,36 @@ const createStreamingResponse = <Rpcs extends Rpc.Any, LE>(
|
|
|
245
312
|
|
|
246
313
|
// Send final exit message with proper schema encoding
|
|
247
314
|
const rawExit = Exit.void
|
|
248
|
-
|
|
249
|
-
const
|
|
315
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Rpc.exitSchema requires AnyWithProps; type narrowing already done above
|
|
316
|
+
const exitSchema = Rpc.exitSchema(rpc as any) as Schema.Top
|
|
317
|
+
const encodedExit = yield* Schema.encodeUnknownEffect(erasedJsonCodec(exitSchema))(rawExit)
|
|
250
318
|
|
|
251
319
|
const exitMessage = {
|
|
252
320
|
_tag: 'Exit' as const,
|
|
253
|
-
requestId
|
|
321
|
+
requestId,
|
|
254
322
|
exit: encodedExit,
|
|
255
323
|
}
|
|
256
324
|
|
|
325
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- msgPack parser.encode returns unknown; cast to expected wire format
|
|
257
326
|
const exitSerialized = parser.encode([exitMessage]) as Uint8Array<ArrayBuffer>
|
|
258
327
|
controller.enqueue(exitSerialized)
|
|
259
328
|
controller.close()
|
|
260
329
|
}).pipe(
|
|
261
|
-
Effect.
|
|
330
|
+
Effect.catchCause((cause) =>
|
|
262
331
|
Effect.gen(function* () {
|
|
263
332
|
// Send error exit with proper schema encoding
|
|
264
333
|
const rawExit = Exit.failCause(cause)
|
|
265
|
-
|
|
266
|
-
const
|
|
334
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Rpc.exitSchema requires AnyWithProps; type narrowing already done above
|
|
335
|
+
const exitSchema = Rpc.exitSchema(rpc as any) as Schema.Top
|
|
336
|
+
const encodedExit = yield* Schema.encodeUnknownEffect(erasedJsonCodec(exitSchema))(rawExit)
|
|
267
337
|
|
|
268
338
|
const exitMessage = {
|
|
269
339
|
_tag: 'Exit' as const,
|
|
270
|
-
requestId
|
|
340
|
+
requestId,
|
|
271
341
|
exit: encodedExit,
|
|
272
342
|
}
|
|
273
343
|
|
|
344
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- msgPack parser.encode returns unknown; cast to expected wire format
|
|
274
345
|
const exitSerialized = parser.encode([exitMessage]) as Uint8Array<ArrayBuffer>
|
|
275
346
|
controller.enqueue(exitSerialized)
|
|
276
347
|
controller.close()
|
|
@@ -279,9 +350,15 @@ const createStreamingResponse = <Rpcs extends Rpc.Any, LE>(
|
|
|
279
350
|
)
|
|
280
351
|
|
|
281
352
|
// Run the stream processing
|
|
282
|
-
|
|
283
|
-
|
|
353
|
+
runStream.pipe(
|
|
354
|
+
Effect.provide(layer),
|
|
355
|
+
Effect.scoped,
|
|
356
|
+
Effect.tapCauseLogPretty,
|
|
357
|
+
(_) => _ as Effect.Effect<void>,
|
|
358
|
+
Effect.runPromise,
|
|
359
|
+
)
|
|
284
360
|
},
|
|
361
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- bridging standard Web API ReadableStream to Cloudflare Worker ReadableStream type
|
|
285
362
|
}) as any as CfTypes.ReadableStream
|
|
286
363
|
|
|
287
364
|
// yield* Effect.addFinalizer(() => Effect.promise(() => readableStream.cancel()))
|
|
@@ -10,8 +10,8 @@ export class TestRpcs extends RpcGroup.make(
|
|
|
10
10
|
success: Schema.Struct({ echo: Schema.String }),
|
|
11
11
|
}),
|
|
12
12
|
Rpc.make('Add', {
|
|
13
|
-
payload: Schema.Struct({ a: Schema.
|
|
14
|
-
success: Schema.Struct({ result: Schema.
|
|
13
|
+
payload: Schema.Struct({ a: Schema.Finite, b: Schema.Finite }),
|
|
14
|
+
success: Schema.Struct({ result: Schema.Finite }),
|
|
15
15
|
}),
|
|
16
16
|
Rpc.make('Defect', {
|
|
17
17
|
payload: Schema.Struct({ message: Schema.String }),
|
|
@@ -25,34 +25,34 @@ export class TestRpcs extends RpcGroup.make(
|
|
|
25
25
|
Rpc.make('Stream', {
|
|
26
26
|
payload: Schema.Struct({}),
|
|
27
27
|
success: Schema.Struct({
|
|
28
|
-
maybeNumber: Schema.Option(Schema.
|
|
28
|
+
maybeNumber: Schema.Option(Schema.Finite),
|
|
29
29
|
}),
|
|
30
30
|
stream: true,
|
|
31
31
|
}),
|
|
32
32
|
Rpc.make('StreamError', {
|
|
33
|
-
payload: Schema.Struct({ count: Schema.
|
|
34
|
-
success: Schema.
|
|
33
|
+
payload: Schema.Struct({ count: Schema.Finite, errorAfter: Schema.Finite }),
|
|
34
|
+
success: Schema.Finite,
|
|
35
35
|
error: Schema.String,
|
|
36
36
|
stream: true,
|
|
37
37
|
}),
|
|
38
38
|
Rpc.make('StreamDefect', {
|
|
39
|
-
payload: Schema.Struct({ count: Schema.
|
|
40
|
-
success: Schema.
|
|
39
|
+
payload: Schema.Struct({ count: Schema.Finite, defectAfter: Schema.Finite }),
|
|
40
|
+
success: Schema.Finite,
|
|
41
41
|
stream: true,
|
|
42
42
|
}),
|
|
43
43
|
Rpc.make('StreamInterruptible', {
|
|
44
|
-
payload: Schema.Struct({ delay: Schema.
|
|
45
|
-
success: Schema.
|
|
44
|
+
payload: Schema.Struct({ delay: Schema.Finite, interruptAfterCount: Schema.Finite }),
|
|
45
|
+
success: Schema.Finite,
|
|
46
46
|
stream: true,
|
|
47
47
|
}),
|
|
48
48
|
Rpc.make('StreamBugScenarioDoServer', {
|
|
49
49
|
payload: Schema.Struct({}),
|
|
50
|
-
success: Schema.
|
|
50
|
+
success: Schema.Finite,
|
|
51
51
|
stream: true,
|
|
52
52
|
}),
|
|
53
53
|
Rpc.make('StreamBugScenarioDoClient', {
|
|
54
54
|
payload: Schema.Struct({}),
|
|
55
|
-
success: Schema.
|
|
55
|
+
success: Schema.Finite,
|
|
56
56
|
}),
|
|
57
57
|
) {}
|
|
58
58
|
export type TestRpcsI = RpcGroup.Rpcs<typeof TestRpcs>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
2
|
|
|
3
3
|
import { DurableObject } from 'cloudflare:workers'
|
|
4
|
+
|
|
4
5
|
import { layerProtocolDurableObject, toDurableObjectHandler } from '@livestore/common-cf'
|
|
5
6
|
import {
|
|
6
7
|
Effect,
|
|
7
|
-
|
|
8
|
+
HttpEffect,
|
|
8
9
|
Layer,
|
|
9
10
|
Option,
|
|
10
11
|
RpcClient,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
Schedule,
|
|
14
15
|
Stream,
|
|
15
16
|
} from '@livestore/utils/effect'
|
|
17
|
+
|
|
16
18
|
import { TestRpcs } from './rpc-schema.ts'
|
|
17
19
|
|
|
18
20
|
export interface Env {
|
|
@@ -20,7 +22,7 @@ export interface Env {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export class TestRpcDurableObject extends DurableObject {
|
|
23
|
-
__DURABLE_OBJECT_BRAND = 'TestRpcDurableObject' as never
|
|
25
|
+
override __DURABLE_OBJECT_BRAND = 'TestRpcDurableObject' as never
|
|
24
26
|
|
|
25
27
|
async rpc(payload: unknown): Promise<unknown> {
|
|
26
28
|
const TestRpcsLive = TestRpcs.toLayer({
|
|
@@ -74,7 +76,7 @@ export default {
|
|
|
74
76
|
const url = new URL(request.url)
|
|
75
77
|
|
|
76
78
|
// Handle HTTP RPC endpoint
|
|
77
|
-
if (url.pathname === '/rpc') {
|
|
79
|
+
if (url.pathname === '/rpc/') {
|
|
78
80
|
// Get the test server DO instance
|
|
79
81
|
const doId = env.TEST_RPC_DO.idFromName('test-server')
|
|
80
82
|
const serverDO = env.TEST_RPC_DO.get(doId)
|
|
@@ -104,26 +106,23 @@ export default {
|
|
|
104
106
|
doRpcClient.StreamBugScenarioDoServer(msg).pipe(
|
|
105
107
|
Stream.tap(() => Effect.fail('doh')),
|
|
106
108
|
// observed behaviour: `log1` is still logged
|
|
107
|
-
Stream.
|
|
109
|
+
Stream.tapCause((cause) => Effect.log('log1', cause)),
|
|
108
110
|
Stream.mapError((cause) => cause.toString()),
|
|
109
111
|
// observed behaviour: after this error mapping `log2` is never logged
|
|
110
|
-
Stream.
|
|
112
|
+
Stream.tapCause((cause) => Effect.log('log2', cause)),
|
|
111
113
|
Stream.tapLogWithLabel('stream'),
|
|
112
114
|
Stream.runCount,
|
|
113
115
|
Effect.orDie,
|
|
114
116
|
// observed behaviour: `log3` is also never logged
|
|
115
|
-
Effect.
|
|
117
|
+
Effect.tapCause((cause) => Effect.log('log3', cause)),
|
|
116
118
|
),
|
|
117
|
-
}).pipe(
|
|
118
|
-
Layer.provideMerge(RpcServer.layerProtocolHttp({ path: '/rpc' })),
|
|
119
|
-
Layer.provideMerge(RpcSerialization.layerJson),
|
|
120
|
-
)
|
|
119
|
+
}).pipe(Layer.provideMerge(RpcSerialization.layerJson))
|
|
121
120
|
|
|
122
|
-
// Create the HTTP RPC
|
|
123
|
-
const
|
|
121
|
+
// Create the HTTP RPC effect
|
|
122
|
+
const httpEffect = yield* RpcServer.toHttpEffect(TestRpcs).pipe(Effect.provide(handlersLayer))
|
|
124
123
|
|
|
125
124
|
// Run the app and convert to web handler
|
|
126
|
-
const webHandler =
|
|
125
|
+
const webHandler = HttpEffect.toWebHandler(httpEffect)
|
|
127
126
|
|
|
128
127
|
return yield* Effect.promise(() => webHandler(request))
|
|
129
128
|
}).pipe(
|
|
@@ -139,7 +138,7 @@ export default {
|
|
|
139
138
|
headers: { 'Content-Type': 'text/plain' },
|
|
140
139
|
})
|
|
141
140
|
} catch (error) {
|
|
142
|
-
return new Response(`Error: ${error}`, { status: 500 })
|
|
141
|
+
return new Response(`Error: ${String(error)}`, { status: 500 })
|
|
143
142
|
}
|
|
144
143
|
},
|
|
145
144
|
}
|
package/src/ws-rpc/README.md
CHANGED
|
@@ -10,8 +10,8 @@ export class TestRpcs extends RpcGroup.make(
|
|
|
10
10
|
success: Schema.Struct({ echo: Schema.String }),
|
|
11
11
|
}),
|
|
12
12
|
Rpc.make('Add', {
|
|
13
|
-
payload: Schema.Struct({ a: Schema.
|
|
14
|
-
success: Schema.Struct({ result: Schema.
|
|
13
|
+
payload: Schema.Struct({ a: Schema.Finite, b: Schema.Finite }),
|
|
14
|
+
success: Schema.Struct({ result: Schema.Finite }),
|
|
15
15
|
}),
|
|
16
16
|
Rpc.make('Defect', {
|
|
17
17
|
payload: Schema.Struct({ message: Schema.String }),
|
|
@@ -25,24 +25,24 @@ export class TestRpcs extends RpcGroup.make(
|
|
|
25
25
|
Rpc.make('Stream', {
|
|
26
26
|
payload: Schema.Struct({}),
|
|
27
27
|
success: Schema.Struct({
|
|
28
|
-
maybeNumber: Schema.Option(Schema.
|
|
28
|
+
maybeNumber: Schema.Option(Schema.Finite),
|
|
29
29
|
}),
|
|
30
30
|
stream: true,
|
|
31
31
|
}),
|
|
32
32
|
Rpc.make('StreamError', {
|
|
33
|
-
payload: Schema.Struct({ count: Schema.
|
|
34
|
-
success: Schema.
|
|
33
|
+
payload: Schema.Struct({ count: Schema.Finite, errorAfter: Schema.Finite }),
|
|
34
|
+
success: Schema.Finite,
|
|
35
35
|
error: Schema.String,
|
|
36
36
|
stream: true,
|
|
37
37
|
}),
|
|
38
38
|
Rpc.make('StreamDefect', {
|
|
39
|
-
payload: Schema.Struct({ count: Schema.
|
|
40
|
-
success: Schema.
|
|
39
|
+
payload: Schema.Struct({ count: Schema.Finite, defectAfter: Schema.Finite }),
|
|
40
|
+
success: Schema.Finite,
|
|
41
41
|
stream: true,
|
|
42
42
|
}),
|
|
43
43
|
Rpc.make('StreamInterruptible', {
|
|
44
|
-
payload: Schema.Struct({ delay: Schema.
|
|
45
|
-
success: Schema.
|
|
44
|
+
payload: Schema.Struct({ delay: Schema.Finite, interruptAfterCount: Schema.Finite }),
|
|
45
|
+
success: Schema.Finite,
|
|
46
46
|
stream: true,
|
|
47
47
|
}),
|
|
48
48
|
) {}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
2
|
|
|
3
3
|
import { DurableObject } from 'cloudflare:workers'
|
|
4
|
+
|
|
4
5
|
import { Effect, Layer, Option, RpcServer, Schedule, Stream } from '@livestore/utils/effect'
|
|
6
|
+
|
|
5
7
|
import type * as CfTypes from '../../cf-types.ts'
|
|
6
8
|
import { setupDurableObjectWebSocketRpc } from '../ws-rpc-server.ts'
|
|
7
9
|
import { TestRpcs } from './rpc-schema.ts'
|
|
@@ -11,7 +13,7 @@ export interface Env {
|
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export class TestRpcDurableObject extends DurableObject<Env, unknown> {
|
|
14
|
-
__DURABLE_OBJECT_BRAND = 'TestRpcDurableObject' as never
|
|
16
|
+
override __DURABLE_OBJECT_BRAND = 'TestRpcDurableObject' as never
|
|
15
17
|
|
|
16
18
|
constructor(state: DurableObjectState, env: Env) {
|
|
17
19
|
super(state, env)
|
|
@@ -59,7 +61,7 @@ export class TestRpcDurableObject extends DurableObject<Env, unknown> {
|
|
|
59
61
|
})
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
async fetch(request: Request): Promise<Response> {
|
|
64
|
+
override async fetch(request: Request): Promise<Response> {
|
|
63
65
|
const upgradeHeader = request.headers.get('Upgrade')
|
|
64
66
|
if (upgradeHeader === undefined || upgradeHeader !== 'websocket') {
|
|
65
67
|
return new Response('Durable Object expected Upgrade: websocket', { status: 426 })
|
|
@@ -81,7 +83,7 @@ export default {
|
|
|
81
83
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
82
84
|
try {
|
|
83
85
|
const upgradeHeader = request.headers.get('Upgrade')
|
|
84
|
-
if (
|
|
86
|
+
if (upgradeHeader == null || upgradeHeader !== 'websocket') {
|
|
85
87
|
return new Response('Durable Object expected Upgrade: websocket', { status: 426 })
|
|
86
88
|
}
|
|
87
89
|
|
|
@@ -89,7 +91,7 @@ export default {
|
|
|
89
91
|
|
|
90
92
|
return serverDO.fetch(request)
|
|
91
93
|
} catch (error) {
|
|
92
|
-
return new Response(`Error: ${error}`, { status: 500 })
|
|
94
|
+
return new Response(`Error: ${String(error)}`, { status: 500 })
|
|
93
95
|
}
|
|
94
96
|
},
|
|
95
97
|
}
|