electron-effect-rpc 0.5.0 → 0.6.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/README.md +53 -19
- package/dist/contract.d.ts +24 -2
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +35 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/kit.d.ts +11 -7
- package/dist/kit.d.ts.map +1 -1
- package/dist/kit.js +19 -1
- package/dist/main.d.ts +3 -3
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +197 -5
- package/dist/preload.d.ts +1 -0
- package/dist/preload.d.ts.map +1 -1
- package/dist/preload.js +29 -7
- package/dist/protocol.d.ts +26 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +43 -1
- package/dist/renderer.d.ts +5 -4
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +161 -9
- package/dist/types.d.ts +36 -14
- package/dist/types.d.ts.map +1 -1
- package/package.json +29 -23
package/dist/main.js
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import * as S from "@effect/schema/Schema";
|
|
2
|
-
import { Cause, Effect, Exit } from "effect";
|
|
2
|
+
import { Cause, Effect, Exit, FiberId, Stream } from "effect";
|
|
3
3
|
import * as Runtime from "effect/Runtime";
|
|
4
4
|
import { isNoErrorSchema } from "./contract.js";
|
|
5
|
-
import { extractErrorTag, safelyCall, toDefectEnvelope, } from "./protocol.js";
|
|
5
|
+
import { extractErrorTag, formatUnknown, isRecord, safelyCall, toDefectEnvelope, } from "./protocol.js";
|
|
6
6
|
import { defaultChannelPrefix, } from "./types.js";
|
|
7
7
|
function resolveChannelPrefix(prefix) {
|
|
8
8
|
return prefix ?? defaultChannelPrefix;
|
|
9
9
|
}
|
|
10
|
+
function isWebContentsLike(value) {
|
|
11
|
+
if (!isRecord(value))
|
|
12
|
+
return false;
|
|
13
|
+
return (typeof value.id === "number" &&
|
|
14
|
+
typeof value.isDestroyed === "function" &&
|
|
15
|
+
typeof value.send === "function");
|
|
16
|
+
}
|
|
17
|
+
function extractSender(event) {
|
|
18
|
+
if (!isRecord(event))
|
|
19
|
+
return null;
|
|
20
|
+
return isWebContentsLike(event.sender) ? event.sender : null;
|
|
21
|
+
}
|
|
10
22
|
function isImplementation(value) {
|
|
11
23
|
return typeof value === "function";
|
|
12
24
|
}
|
|
25
|
+
function isStreamImplementation(value) {
|
|
26
|
+
return typeof value === "function";
|
|
27
|
+
}
|
|
13
28
|
export function createRpcEndpoint(contract, ipc, implementations, options) {
|
|
14
29
|
const channelPrefix = resolveChannelPrefix(options.channelPrefix);
|
|
15
30
|
const diagnostics = options.diagnostics;
|
|
@@ -36,9 +51,7 @@ export function createRpcEndpoint(contract, ipc, implementations, options) {
|
|
|
36
51
|
}
|
|
37
52
|
const decodeInput = S.decodeUnknownSync(method.req);
|
|
38
53
|
const encodeSuccess = S.encodeSync(method.res);
|
|
39
|
-
const encodeFailure = isNoErrorSchema(method.err)
|
|
40
|
-
? null
|
|
41
|
-
: S.encodeSync(method.err);
|
|
54
|
+
const encodeFailure = isNoErrorSchema(method.err) ? null : S.encodeSync(method.err);
|
|
42
55
|
const channel = `${channelPrefix.rpc}${method.name}`;
|
|
43
56
|
listeners.set(channel, async function handleRpcRequest(_event, rawPayload) {
|
|
44
57
|
let input;
|
|
@@ -100,6 +113,133 @@ export function createRpcEndpoint(contract, ipc, implementations, options) {
|
|
|
100
113
|
return toDefectEnvelope(exit.cause, `RPC ${method.name} interrupted`);
|
|
101
114
|
});
|
|
102
115
|
}
|
|
116
|
+
const activeStreams = new Map();
|
|
117
|
+
const streamListeners = new Map();
|
|
118
|
+
const streamMethods = contract.streamMethods ?? [];
|
|
119
|
+
const streamHandlerImpls = options.streamHandlers;
|
|
120
|
+
const cancelChannel = `${channelPrefix.rpc}stream-cancel`;
|
|
121
|
+
if (streamMethods.length > 0 && !streamHandlerImpls) {
|
|
122
|
+
throw new Error("Contract defines stream methods but no streamHandlers were provided.");
|
|
123
|
+
}
|
|
124
|
+
if (streamMethods.length > 0 && streamHandlerImpls) {
|
|
125
|
+
const streamMethodNames = new Set(streamMethods.map((m) => m.name));
|
|
126
|
+
for (const name in streamHandlerImpls) {
|
|
127
|
+
if (!streamMethodNames.has(name)) {
|
|
128
|
+
throw new Error(`Stream implementation provided for unknown stream method: ${name}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const runFork = Runtime.runFork(options.runtime);
|
|
132
|
+
for (const method of streamMethods) {
|
|
133
|
+
const impl = streamHandlerImpls[method.name];
|
|
134
|
+
if (!isStreamImplementation(impl)) {
|
|
135
|
+
throw new Error(`Missing implementation for stream method: ${method.name}`);
|
|
136
|
+
}
|
|
137
|
+
const decodeInput = S.decodeUnknownSync(method.req);
|
|
138
|
+
const encodeChunk = S.encodeSync(method.chunk);
|
|
139
|
+
const encodeFailure = isNoErrorSchema(method.err) ? null : S.encodeSync(method.err);
|
|
140
|
+
const channel = `${channelPrefix.rpc}stream/${method.name}`;
|
|
141
|
+
streamListeners.set(channel, async function handleStreamRequest(event, rawPayload) {
|
|
142
|
+
if (!isRecord(rawPayload)) {
|
|
143
|
+
return toDefectEnvelope("Stream request payload must be an object");
|
|
144
|
+
}
|
|
145
|
+
const streamId = rawPayload.streamId;
|
|
146
|
+
const rawData = rawPayload.data;
|
|
147
|
+
if (typeof streamId !== "string" || streamId.length === 0) {
|
|
148
|
+
return toDefectEnvelope("Invalid streamId");
|
|
149
|
+
}
|
|
150
|
+
if (activeStreams.has(streamId)) {
|
|
151
|
+
return toDefectEnvelope("Duplicate streamId");
|
|
152
|
+
}
|
|
153
|
+
const sender = extractSender(event);
|
|
154
|
+
if (!sender) {
|
|
155
|
+
return toDefectEnvelope("Stream handler requires sender with webContents");
|
|
156
|
+
}
|
|
157
|
+
let input;
|
|
158
|
+
try {
|
|
159
|
+
input = decodeInput(rawData);
|
|
160
|
+
}
|
|
161
|
+
catch (cause) {
|
|
162
|
+
const decodeCtx = {
|
|
163
|
+
scope: "stream-request",
|
|
164
|
+
name: method.name,
|
|
165
|
+
payload: rawData,
|
|
166
|
+
cause,
|
|
167
|
+
};
|
|
168
|
+
safelyCall(diagnostics?.onDecodeFailure, decodeCtx);
|
|
169
|
+
return toDefectEnvelope(cause, `Stream ${method.name} request decode failed`);
|
|
170
|
+
}
|
|
171
|
+
const sfChannel = `${channelPrefix.rpc}sf`;
|
|
172
|
+
const trySend = (frame) => Effect.try({
|
|
173
|
+
try: () => {
|
|
174
|
+
if (!sender.isDestroyed()) {
|
|
175
|
+
sender.send(sfChannel, frame);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
catch: () => undefined,
|
|
179
|
+
}).pipe(Effect.ignore);
|
|
180
|
+
let handlerStream;
|
|
181
|
+
try {
|
|
182
|
+
handlerStream = impl(input);
|
|
183
|
+
}
|
|
184
|
+
catch (cause) {
|
|
185
|
+
return toDefectEnvelope(cause, `Stream ${method.name} implementation threw`);
|
|
186
|
+
}
|
|
187
|
+
const buildTerminalFrame = (cause) => {
|
|
188
|
+
const failure = Cause.failureOption(cause);
|
|
189
|
+
if (failure._tag === "Some") {
|
|
190
|
+
if (encodeFailure) {
|
|
191
|
+
try {
|
|
192
|
+
return {
|
|
193
|
+
type: "error",
|
|
194
|
+
streamId,
|
|
195
|
+
error: {
|
|
196
|
+
tag: extractErrorTag(failure.value),
|
|
197
|
+
data: encodeFailure(failure.value),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// encoding failed, fall through to defect
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { type: "defect", streamId, message: formatUnknown(failure.value) };
|
|
206
|
+
}
|
|
207
|
+
if (Cause.isInterruptedOnly(cause)) {
|
|
208
|
+
return { type: "end", streamId };
|
|
209
|
+
}
|
|
210
|
+
const defect = Cause.dieOption(cause);
|
|
211
|
+
return {
|
|
212
|
+
type: "defect",
|
|
213
|
+
streamId,
|
|
214
|
+
message: defect._tag === "Some" ? formatUnknown(defect.value) : "Stream failed unexpectedly",
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
const streamEffect = handlerStream.pipe(Stream.mapEffect((chunk) => Effect.try({
|
|
218
|
+
try: () => {
|
|
219
|
+
if (sender.isDestroyed())
|
|
220
|
+
return;
|
|
221
|
+
sender.send(sfChannel, { type: "data", streamId, payload: encodeChunk(chunk) });
|
|
222
|
+
},
|
|
223
|
+
catch: () => undefined,
|
|
224
|
+
}).pipe(Effect.ignore)), Stream.runDrain, Effect.andThen(() => trySend({ type: "end", streamId })), Effect.catchAllCause((cause) => sender.isDestroyed() ? Effect.void : trySend(buildTerminalFrame(cause))), Effect.ensuring(Effect.sync(() => {
|
|
225
|
+
activeStreams.delete(streamId);
|
|
226
|
+
})));
|
|
227
|
+
// Reserve entry BEFORE forking
|
|
228
|
+
const entry = {
|
|
229
|
+
fiber: null,
|
|
230
|
+
senderId: sender.id,
|
|
231
|
+
};
|
|
232
|
+
activeStreams.set(streamId, entry);
|
|
233
|
+
const fiber = runFork(streamEffect);
|
|
234
|
+
entry.fiber = fiber;
|
|
235
|
+
const response = {
|
|
236
|
+
type: "success",
|
|
237
|
+
data: { type: "stream_started" },
|
|
238
|
+
};
|
|
239
|
+
return response;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
103
243
|
let running = false;
|
|
104
244
|
let disposed = false;
|
|
105
245
|
function start() {
|
|
@@ -115,6 +255,35 @@ export function createRpcEndpoint(contract, ipc, implementations, options) {
|
|
|
115
255
|
ipc.handle(channel, listener);
|
|
116
256
|
registeredChannels.push(channel);
|
|
117
257
|
}
|
|
258
|
+
// Register stream handlers
|
|
259
|
+
for (const [channel, listener] of streamListeners) {
|
|
260
|
+
ipc.handle(channel, listener);
|
|
261
|
+
registeredChannels.push(channel);
|
|
262
|
+
}
|
|
263
|
+
// Register cancel handler if we have stream methods
|
|
264
|
+
if (streamMethods.length > 0) {
|
|
265
|
+
ipc.handle(cancelChannel, (event, rawPayload) => {
|
|
266
|
+
if (!isRecord(rawPayload)) {
|
|
267
|
+
return { cancelled: false };
|
|
268
|
+
}
|
|
269
|
+
const streamId = rawPayload.streamId;
|
|
270
|
+
if (typeof streamId !== "string")
|
|
271
|
+
return { cancelled: false };
|
|
272
|
+
const entry = activeStreams.get(streamId);
|
|
273
|
+
if (!entry)
|
|
274
|
+
return { cancelled: false };
|
|
275
|
+
// Validate sender identity
|
|
276
|
+
const sender = extractSender(event);
|
|
277
|
+
if (!sender || sender.id !== entry.senderId)
|
|
278
|
+
return { cancelled: false };
|
|
279
|
+
// Interrupt the fiber
|
|
280
|
+
if (entry.fiber) {
|
|
281
|
+
entry.fiber.unsafeInterruptAsFork(FiberId.none);
|
|
282
|
+
}
|
|
283
|
+
return { cancelled: true };
|
|
284
|
+
});
|
|
285
|
+
registeredChannels.push(cancelChannel);
|
|
286
|
+
}
|
|
118
287
|
}
|
|
119
288
|
catch (cause) {
|
|
120
289
|
for (const channel of registeredChannels) {
|
|
@@ -133,6 +302,13 @@ export function createRpcEndpoint(contract, ipc, implementations, options) {
|
|
|
133
302
|
if (!running) {
|
|
134
303
|
return;
|
|
135
304
|
}
|
|
305
|
+
// Interrupt all active stream fibers first
|
|
306
|
+
for (const entry of activeStreams.values()) {
|
|
307
|
+
if (entry.fiber) {
|
|
308
|
+
entry.fiber.unsafeInterruptAsFork(FiberId.none);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
activeStreams.clear();
|
|
136
312
|
let firstError;
|
|
137
313
|
for (const channel of listeners.keys()) {
|
|
138
314
|
try {
|
|
@@ -142,6 +318,22 @@ export function createRpcEndpoint(contract, ipc, implementations, options) {
|
|
|
142
318
|
firstError ??= cause;
|
|
143
319
|
}
|
|
144
320
|
}
|
|
321
|
+
for (const channel of streamListeners.keys()) {
|
|
322
|
+
try {
|
|
323
|
+
ipc.removeHandler(channel);
|
|
324
|
+
}
|
|
325
|
+
catch (cause) {
|
|
326
|
+
firstError ??= cause;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (streamMethods.length > 0) {
|
|
330
|
+
try {
|
|
331
|
+
ipc.removeHandler(cancelChannel);
|
|
332
|
+
}
|
|
333
|
+
catch (cause) {
|
|
334
|
+
firstError ??= cause;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
145
337
|
running = false;
|
|
146
338
|
if (firstError !== undefined) {
|
|
147
339
|
throw firstError;
|
package/dist/preload.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type ChannelPrefix, type EventSubscribe, type RpcInvoke } from "./types
|
|
|
2
2
|
export type BridgeAdapters = {
|
|
3
3
|
readonly invoke: RpcInvoke;
|
|
4
4
|
readonly subscribe: EventSubscribe;
|
|
5
|
+
readonly onStreamFrame: (listener: (frame: unknown) => void) => () => void;
|
|
5
6
|
};
|
|
6
7
|
export type BridgeAdaptersOptions = {
|
|
7
8
|
readonly channelPrefix?: ChannelPrefix;
|
package/dist/preload.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"preload.d.ts","sourceRoot":"","sources":["../src/preload.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"preload.d.ts","sourceRoot":"","sources":["../src/preload.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,SAAS,EACf,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAC;IACnC,QAAQ,CAAC,aAAa,EAAE,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;CAC5E,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG,qBAAqB,GAAG;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,qBAAqB,GAAG;IAC7D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAoDF,wBAAgB,oBAAoB,CAAC,OAAO,CAAC,EAAE,qBAAqB,GAAG,cAAc,CAgCpF;AAED,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAiBrE;AAED,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,wBAAwB,GAAG,IAAI,CAaxE"}
|
package/dist/preload.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import * as electronModule from "electron";
|
|
2
|
+
import { isRecord } from "./protocol.js";
|
|
2
3
|
import { defaultChannelPrefix, } from "./types.js";
|
|
4
|
+
function isContextBridgeLike(value) {
|
|
5
|
+
return isRecord(value) && typeof value.exposeInMainWorld === "function";
|
|
6
|
+
}
|
|
7
|
+
function isIpcRendererLike(value) {
|
|
8
|
+
return (isRecord(value) &&
|
|
9
|
+
typeof value.invoke === "function" &&
|
|
10
|
+
typeof value.on === "function" &&
|
|
11
|
+
typeof value.removeListener === "function");
|
|
12
|
+
}
|
|
3
13
|
function resolveElectronRendererBindings() {
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
const mod = electronModule;
|
|
15
|
+
const moduleDefault = isRecord(mod) ? mod.default : undefined;
|
|
16
|
+
const source = isRecord(moduleDefault) ? moduleDefault : isRecord(mod) ? mod : undefined;
|
|
17
|
+
if (!source) {
|
|
18
|
+
throw new Error("electron-effect-rpc/preload requires Electron preload runtime bindings.");
|
|
19
|
+
}
|
|
20
|
+
const { contextBridge, ipcRenderer } = source;
|
|
21
|
+
if (!isContextBridgeLike(contextBridge) || !isIpcRendererLike(ipcRenderer)) {
|
|
11
22
|
throw new Error("electron-effect-rpc/preload requires Electron preload runtime bindings.");
|
|
12
23
|
}
|
|
13
24
|
return { contextBridge, ipcRenderer };
|
|
@@ -24,9 +35,18 @@ export function createBridgeAdapters(options) {
|
|
|
24
35
|
ipcRenderer.removeListener(channel, wrapped);
|
|
25
36
|
};
|
|
26
37
|
};
|
|
38
|
+
const onStreamFrame = (listener) => {
|
|
39
|
+
const channel = `${channelPrefix.rpc}sf`;
|
|
40
|
+
const wrapped = (_event, frame) => listener(frame);
|
|
41
|
+
ipcRenderer.on(channel, wrapped);
|
|
42
|
+
return () => {
|
|
43
|
+
ipcRenderer.removeListener(channel, wrapped);
|
|
44
|
+
};
|
|
45
|
+
};
|
|
27
46
|
return {
|
|
28
47
|
invoke,
|
|
29
48
|
subscribe,
|
|
49
|
+
onStreamFrame,
|
|
30
50
|
};
|
|
31
51
|
}
|
|
32
52
|
export function exposeRpcBridge(options) {
|
|
@@ -38,6 +58,7 @@ export function exposeRpcBridge(options) {
|
|
|
38
58
|
});
|
|
39
59
|
contextBridge.exposeInMainWorld(rpcGlobal, {
|
|
40
60
|
invoke: adapters.invoke,
|
|
61
|
+
onStreamFrame: adapters.onStreamFrame,
|
|
41
62
|
});
|
|
42
63
|
contextBridge.exposeInMainWorld(eventsGlobal, {
|
|
43
64
|
subscribe: adapters.subscribe,
|
|
@@ -52,5 +73,6 @@ export function exposeIpcBridge(options) {
|
|
|
52
73
|
contextBridge.exposeInMainWorld(global, {
|
|
53
74
|
invoke: adapters.invoke,
|
|
54
75
|
subscribe: adapters.subscribe,
|
|
76
|
+
onStreamFrame: adapters.onStreamFrame,
|
|
55
77
|
});
|
|
56
78
|
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -15,9 +15,35 @@ export type RpcDefectEnvelope = {
|
|
|
15
15
|
readonly cause?: unknown;
|
|
16
16
|
};
|
|
17
17
|
export type RpcResponseEnvelope = RpcSuccessEnvelope | RpcFailureEnvelope | RpcDefectEnvelope;
|
|
18
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
18
19
|
export declare function formatUnknown(value: unknown): string;
|
|
19
20
|
export declare function extractErrorTag(error: unknown): string;
|
|
20
21
|
export declare function toDefectEnvelope(cause: unknown, prefix?: string): RpcDefectEnvelope;
|
|
21
22
|
export declare function safelyCall<T>(callback: ((context: T) => void) | undefined, context: T): void;
|
|
23
|
+
export type StreamDataFrame = {
|
|
24
|
+
readonly type: "data";
|
|
25
|
+
readonly streamId: string;
|
|
26
|
+
readonly payload: unknown;
|
|
27
|
+
};
|
|
28
|
+
export type StreamEndFrame = {
|
|
29
|
+
readonly type: "end";
|
|
30
|
+
readonly streamId: string;
|
|
31
|
+
};
|
|
32
|
+
export type StreamErrorFrame = {
|
|
33
|
+
readonly type: "error";
|
|
34
|
+
readonly streamId: string;
|
|
35
|
+
readonly error: {
|
|
36
|
+
readonly tag: string;
|
|
37
|
+
readonly data: unknown;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
export type StreamDefectFrame = {
|
|
41
|
+
readonly type: "defect";
|
|
42
|
+
readonly streamId: string;
|
|
43
|
+
readonly message: string;
|
|
44
|
+
};
|
|
45
|
+
export type StreamFrame = StreamDataFrame | StreamEndFrame | StreamErrorFrame | StreamDefectFrame;
|
|
46
|
+
export declare function extractStreamIdFromRaw(value: unknown): string | null;
|
|
47
|
+
export declare function parseStreamFrame(value: unknown): StreamFrame | null;
|
|
22
48
|
export declare function parseRpcResponseEnvelope(value: unknown): RpcResponseEnvelope | null;
|
|
23
49
|
//# sourceMappingURL=protocol.d.ts.map
|
package/dist/protocol.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;KACxB,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,mBAAmB,
|
|
1
|
+
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;KACxB,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,kBAAkB,GAAG,kBAAkB,GAAG,iBAAiB,CAAC;AAM9F,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEpD;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAUtD;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,iBAAiB,CAOnF;AAED,wBAAgB,UAAU,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,KAAK,IAAI,CAAC,GAAG,SAAS,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,CAU5F;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;KACxB,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,eAAe,GAAG,cAAc,GAAG,gBAAgB,GAAG,iBAAiB,CAAC;AAElG,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAKpE;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CA4CnE;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,GAAG,mBAAmB,GAAG,IAAI,CA+CnF"}
|
package/dist/protocol.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
function hasOwn(record, key) {
|
|
2
2
|
return Object.prototype.hasOwnProperty.call(record, key);
|
|
3
3
|
}
|
|
4
|
-
function isRecord(value) {
|
|
4
|
+
export function isRecord(value) {
|
|
5
5
|
return typeof value === "object" && value !== null;
|
|
6
6
|
}
|
|
7
7
|
export function formatUnknown(value) {
|
|
@@ -35,6 +35,48 @@ export function safelyCall(callback, context) {
|
|
|
35
35
|
// Diagnostics hooks must never crash transport internals.
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
export function extractStreamIdFromRaw(value) {
|
|
39
|
+
if (!isRecord(value) || typeof value.streamId !== "string") {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return value.streamId;
|
|
43
|
+
}
|
|
44
|
+
export function parseStreamFrame(value) {
|
|
45
|
+
if (!isRecord(value) || typeof value.type !== "string") {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (typeof value.streamId !== "string") {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const streamId = value.streamId;
|
|
52
|
+
switch (value.type) {
|
|
53
|
+
case "data":
|
|
54
|
+
if (!hasOwn(value, "payload")) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return { type: "data", streamId, payload: value.payload };
|
|
58
|
+
case "end":
|
|
59
|
+
return { type: "end", streamId };
|
|
60
|
+
case "error":
|
|
61
|
+
if (!isRecord(value.error) ||
|
|
62
|
+
typeof value.error.tag !== "string" ||
|
|
63
|
+
!hasOwn(value.error, "data")) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
type: "error",
|
|
68
|
+
streamId,
|
|
69
|
+
error: { tag: value.error.tag, data: value.error.data },
|
|
70
|
+
};
|
|
71
|
+
case "defect":
|
|
72
|
+
if (typeof value.message !== "string") {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return { type: "defect", streamId, message: value.message };
|
|
76
|
+
default:
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
38
80
|
export function parseRpcResponseEnvelope(value) {
|
|
39
81
|
if (!isRecord(value) || typeof value.type !== "string") {
|
|
40
82
|
return null;
|
package/dist/renderer.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type RpcContract } from "./contract.ts";
|
|
2
|
-
import { type AnyEvent, type AnyMethod, type EventSubscriber, type EventSubscriberOptions, type RpcClient, type RpcClientOptions } from "./types.ts";
|
|
3
|
-
export declare function createRpcClient<const Methods extends ReadonlyArray<AnyMethod>, const Events extends ReadonlyArray<AnyEvent
|
|
4
|
-
export declare function createEventSubscriber<const Methods extends ReadonlyArray<AnyMethod>, const Events extends ReadonlyArray<AnyEvent
|
|
1
|
+
import { type AnyStreamMethod, type RpcContract } from "./contract.ts";
|
|
2
|
+
import { type AnyEvent, type AnyMethod, type EventSubscriber, type EventSubscriberOptions, type RpcClient, type RpcClientOptions, type StreamRpcClientHandle, type StreamRpcClientOptions } from "./types.ts";
|
|
3
|
+
export declare function createRpcClient<const Methods extends ReadonlyArray<AnyMethod>, const Events extends ReadonlyArray<AnyEvent>, const StreamMethods extends ReadonlyArray<AnyStreamMethod> = readonly []>(contract: RpcContract<Methods, Events, StreamMethods>, options: RpcClientOptions): RpcClient<RpcContract<Methods, Events, StreamMethods>>;
|
|
4
|
+
export declare function createEventSubscriber<const Methods extends ReadonlyArray<AnyMethod>, const Events extends ReadonlyArray<AnyEvent>, const StreamMethods extends ReadonlyArray<AnyStreamMethod> = readonly []>(contract: RpcContract<Methods, Events, StreamMethods>, options: EventSubscriberOptions): EventSubscriber<RpcContract<Methods, Events, StreamMethods>>;
|
|
5
|
+
export declare function createStreamRpcClient<const Methods extends ReadonlyArray<AnyMethod>, const Events extends ReadonlyArray<AnyEvent>, const StreamMethods extends ReadonlyArray<AnyStreamMethod>>(contract: RpcContract<Methods, Events, StreamMethods>, options: StreamRpcClientOptions): StreamRpcClientHandle<RpcContract<Methods, Events, StreamMethods>>;
|
|
5
6
|
export { RpcDefectError } from "./types.ts";
|
|
6
7
|
//# sourceMappingURL=renderer.d.ts.map
|
package/dist/renderer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,WAAW,
|
|
1
|
+
{"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,WAAW,EAMjB,MAAM,eAAe,CAAC;AAUvB,OAAO,EAEL,KAAK,QAAQ,EACb,KAAK,SAAS,EAGd,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAE3B,KAAK,SAAS,EACd,KAAK,gBAAgB,EAMrB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC5B,MAAM,YAAY,CAAC;AAsEpB,wBAAgB,eAAe,CAC7B,KAAK,CAAC,OAAO,SAAS,aAAa,CAAC,SAAS,CAAC,EAC9C,KAAK,CAAC,MAAM,SAAS,aAAa,CAAC,QAAQ,CAAC,EAC5C,KAAK,CAAC,aAAa,SAAS,aAAa,CAAC,eAAe,CAAC,GAAG,SAAS,EAAE,EAExE,QAAQ,EAAE,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,CAAC,EACrD,OAAO,EAAE,gBAAgB,GACxB,SAAS,CAAC,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,CAAC,CAAC,CA0JxD;AAqBD,wBAAgB,qBAAqB,CACnC,KAAK,CAAC,OAAO,SAAS,aAAa,CAAC,SAAS,CAAC,EAC9C,KAAK,CAAC,MAAM,SAAS,aAAa,CAAC,QAAQ,CAAC,EAC5C,KAAK,CAAC,aAAa,SAAS,aAAa,CAAC,eAAe,CAAC,GAAG,SAAS,EAAE,EAExE,QAAQ,EAAE,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,CAAC,EACrD,OAAO,EAAE,sBAAsB,GAC9B,eAAe,CAAC,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,CAAC,CAAC,CAsF9D;AAUD,wBAAgB,qBAAqB,CACnC,KAAK,CAAC,OAAO,SAAS,aAAa,CAAC,SAAS,CAAC,EAC9C,KAAK,CAAC,MAAM,SAAS,aAAa,CAAC,QAAQ,CAAC,EAC5C,KAAK,CAAC,aAAa,SAAS,aAAa,CAAC,eAAe,CAAC,EAE1D,QAAQ,EAAE,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,CAAC,EACrD,OAAO,EAAE,sBAAsB,GAC9B,qBAAqB,CAAC,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,CAAC,CAAC,CA2NpE;AAED,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/renderer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as S from "@effect/schema/Schema";
|
|
2
|
-
import { Cause, Effect, Exit } from "effect";
|
|
2
|
+
import { Cause, Effect, Exit, Stream } from "effect";
|
|
3
3
|
import { exitSchemaFor, isNoErrorSchema, } from "./contract.js";
|
|
4
|
-
import { formatUnknown, parseRpcResponseEnvelope, safelyCall, } from "./protocol.js";
|
|
4
|
+
import { extractStreamIdFromRaw, formatUnknown, isRecord, parseRpcResponseEnvelope, parseStreamFrame, safelyCall, } from "./protocol.js";
|
|
5
5
|
import { RpcDefectError, } from "./types.js";
|
|
6
6
|
function requireInvoke(options) {
|
|
7
7
|
if (!options?.invoke) {
|
|
@@ -18,6 +18,12 @@ function requireSubscribe(options) {
|
|
|
18
18
|
function rpcDefect(code, message, cause) {
|
|
19
19
|
return new RpcDefectError(code, message, cause);
|
|
20
20
|
}
|
|
21
|
+
function decodeNonEmptyError(schema, data) {
|
|
22
|
+
if (isNoErrorSchema(schema)) {
|
|
23
|
+
throw new Error("unreachable: caller must check isNoErrorSchema first");
|
|
24
|
+
}
|
|
25
|
+
return S.decodeUnknownSync(schema)(data);
|
|
26
|
+
}
|
|
21
27
|
function decodeLegacyExit(method, raw) {
|
|
22
28
|
return Effect.try({
|
|
23
29
|
try: () => S.decodeUnknownSync(exitSchemaFor(method))(raw),
|
|
@@ -62,9 +68,8 @@ export function createRpcClient(contract, options) {
|
|
|
62
68
|
if (isNoErrorSchema(method.err)) {
|
|
63
69
|
return Effect.fail(rpcDefect("noerror_contract_violation", `RPC ${method.name} received a failure for a method that declares NoError`, envelope.error));
|
|
64
70
|
}
|
|
65
|
-
const errorSchema = method.err;
|
|
66
71
|
return Effect.try({
|
|
67
|
-
try: () =>
|
|
72
|
+
try: () => decodeNonEmptyError(method.err, envelope.error.data),
|
|
68
73
|
catch: (cause) => {
|
|
69
74
|
safelyCall(diagnostics?.onDecodeFailure, {
|
|
70
75
|
scope: "rpc-response",
|
|
@@ -107,8 +112,7 @@ export function createRpcClient(contract, options) {
|
|
|
107
112
|
}
|
|
108
113
|
if (decodeMode === "dual") {
|
|
109
114
|
return decodeLegacyExit(method, raw).pipe(Effect.tapError((cause) => {
|
|
110
|
-
if (cause instanceof RpcDefectError &&
|
|
111
|
-
cause.code === "legacy_decode_failed") {
|
|
115
|
+
if (cause instanceof RpcDefectError && cause.code === "legacy_decode_failed") {
|
|
112
116
|
return Effect.sync(() => safelyCall(diagnostics?.onProtocolError, {
|
|
113
117
|
method: method.name,
|
|
114
118
|
response: raw,
|
|
@@ -130,9 +134,7 @@ export function createRpcClient(contract, options) {
|
|
|
130
134
|
const clientRecord = client;
|
|
131
135
|
for (const method of contract.methods) {
|
|
132
136
|
const caller = (...args) => {
|
|
133
|
-
const payload = args.length === 0
|
|
134
|
-
? {}
|
|
135
|
-
: args[0];
|
|
137
|
+
const payload = args.length === 0 ? {} : args[0];
|
|
136
138
|
return call(method, payload);
|
|
137
139
|
};
|
|
138
140
|
clientRecord[method.name] = caller;
|
|
@@ -221,4 +223,154 @@ export function createEventSubscriber(contract, options) {
|
|
|
221
223
|
dispose,
|
|
222
224
|
};
|
|
223
225
|
}
|
|
226
|
+
function isStreamStartedResponse(value) {
|
|
227
|
+
if (!isRecord(value))
|
|
228
|
+
return false;
|
|
229
|
+
if (value.type === "success" && isRecord(value.data)) {
|
|
230
|
+
return value.data.type === "stream_started";
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
export function createStreamRpcClient(contract, options) {
|
|
235
|
+
const invoke = options.invoke;
|
|
236
|
+
const onStreamFrame = options.onStreamFrame;
|
|
237
|
+
const diagnostics = options.diagnostics;
|
|
238
|
+
const frameDispatcher = new Map();
|
|
239
|
+
// Set up central frame listener
|
|
240
|
+
const centralCleanup = onStreamFrame((raw) => {
|
|
241
|
+
const frame = parseStreamFrame(raw);
|
|
242
|
+
if (!frame) {
|
|
243
|
+
// Best-effort: extract streamId from malformed frame to fail the active stream
|
|
244
|
+
const rawStreamId = extractStreamIdFromRaw(raw);
|
|
245
|
+
if (rawStreamId) {
|
|
246
|
+
const handler = frameDispatcher.get(rawStreamId);
|
|
247
|
+
if (handler) {
|
|
248
|
+
handler.defect("Malformed stream frame received");
|
|
249
|
+
frameDispatcher.delete(rawStreamId);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
safelyCall(diagnostics?.onProtocolError, {
|
|
253
|
+
method: "stream-frame",
|
|
254
|
+
response: raw,
|
|
255
|
+
cause: null,
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const handler = frameDispatcher.get(frame.streamId);
|
|
260
|
+
if (!handler)
|
|
261
|
+
return; // stale frame for completed/cancelled stream
|
|
262
|
+
switch (frame.type) {
|
|
263
|
+
case "data":
|
|
264
|
+
handler.data(frame.payload);
|
|
265
|
+
break;
|
|
266
|
+
case "end":
|
|
267
|
+
handler.end();
|
|
268
|
+
frameDispatcher.delete(frame.streamId);
|
|
269
|
+
break;
|
|
270
|
+
case "error":
|
|
271
|
+
handler.error(frame.error);
|
|
272
|
+
frameDispatcher.delete(frame.streamId);
|
|
273
|
+
break;
|
|
274
|
+
case "defect":
|
|
275
|
+
handler.defect(frame.message);
|
|
276
|
+
frameDispatcher.delete(frame.streamId);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
const streamMethods = contract.streamMethods ?? [];
|
|
281
|
+
const client = Object.create(null);
|
|
282
|
+
const clientRecord = client;
|
|
283
|
+
for (const method of streamMethods) {
|
|
284
|
+
const decodeChunk = S.decodeUnknownSync(method.chunk);
|
|
285
|
+
const encodeInput = S.encodeSync(method.req);
|
|
286
|
+
const decodeTypedError = isNoErrorSchema(method.err) ? null : S.decodeUnknownSync(method.err);
|
|
287
|
+
const caller = (...args) => {
|
|
288
|
+
const payload = args.length === 0 ? {} : args[0];
|
|
289
|
+
return Stream.asyncPush((emit) => Effect.gen(function* () {
|
|
290
|
+
const streamId = crypto.randomUUID();
|
|
291
|
+
// 1. Register in dispatch map BEFORE calling invoke
|
|
292
|
+
frameDispatcher.set(streamId, {
|
|
293
|
+
data: (rawPayload) => {
|
|
294
|
+
let decoded;
|
|
295
|
+
try {
|
|
296
|
+
decoded = decodeChunk(rawPayload);
|
|
297
|
+
}
|
|
298
|
+
catch (cause) {
|
|
299
|
+
const context = {
|
|
300
|
+
scope: "stream-chunk",
|
|
301
|
+
name: method.name,
|
|
302
|
+
payload: rawPayload,
|
|
303
|
+
cause,
|
|
304
|
+
};
|
|
305
|
+
safelyCall(diagnostics?.onDecodeFailure, context);
|
|
306
|
+
emit.fail(rpcDefect("stream_chunk_decode_failed", `Stream ${method.name} chunk decode failed: ${formatUnknown(cause)}`, cause));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
emit.single(decoded);
|
|
310
|
+
},
|
|
311
|
+
end: () => emit.end(),
|
|
312
|
+
error: (err) => {
|
|
313
|
+
if (!decodeTypedError) {
|
|
314
|
+
emit.fail(rpcDefect("stream_error_decode_failed", `Stream ${method.name} received typed error but declares NoError`, err));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const decoded = decodeNonEmptyError(method.err, err.data);
|
|
319
|
+
emit.fail(decoded);
|
|
320
|
+
}
|
|
321
|
+
catch (cause) {
|
|
322
|
+
const errContext = {
|
|
323
|
+
scope: "stream-error",
|
|
324
|
+
name: method.name,
|
|
325
|
+
payload: err,
|
|
326
|
+
cause,
|
|
327
|
+
};
|
|
328
|
+
safelyCall(diagnostics?.onDecodeFailure, errContext);
|
|
329
|
+
emit.fail(rpcDefect("stream_error_decode_failed", `Stream ${method.name} error decode failed: ${formatUnknown(cause)}`, cause));
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
defect: (message) => emit.fail(rpcDefect("remote_defect", message, undefined)),
|
|
333
|
+
});
|
|
334
|
+
// 2. Register cleanup finalizer
|
|
335
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => {
|
|
336
|
+
frameDispatcher.delete(streamId);
|
|
337
|
+
}).pipe(Effect.andThen(Effect.tryPromise(() => invoke(`stream-cancel`, { streamId })).pipe(Effect.ignore))));
|
|
338
|
+
// 3. Encode input
|
|
339
|
+
const encodedInput = yield* Effect.try({
|
|
340
|
+
try: () => encodeInput(payload),
|
|
341
|
+
catch: (cause) => rpcDefect("request_encoding_failed", `Stream ${method.name} request encoding failed: ${formatUnknown(cause)}`, cause),
|
|
342
|
+
});
|
|
343
|
+
// 4. Initiate the stream on main
|
|
344
|
+
const response = yield* Effect.tryPromise({
|
|
345
|
+
try: () => invoke(`stream/${method.name}`, {
|
|
346
|
+
data: encodedInput,
|
|
347
|
+
streamId,
|
|
348
|
+
}),
|
|
349
|
+
catch: (cause) => rpcDefect("stream_invoke_failed", `Stream ${method.name} invoke failed: ${formatUnknown(cause)}`, cause),
|
|
350
|
+
});
|
|
351
|
+
// 5. Validate handshake response
|
|
352
|
+
const envelope = parseRpcResponseEnvelope(response);
|
|
353
|
+
if (envelope?.type === "defect") {
|
|
354
|
+
return yield* Effect.fail(rpcDefect("remote_defect", envelope.message, envelope.cause));
|
|
355
|
+
}
|
|
356
|
+
if (!isStreamStartedResponse(response)) {
|
|
357
|
+
return yield* Effect.fail(rpcDefect("stream_handshake_invalid", `Stream ${method.name} unexpected handshake response`, response));
|
|
358
|
+
}
|
|
359
|
+
}), { bufferSize: 16, strategy: "dropping" });
|
|
360
|
+
};
|
|
361
|
+
clientRecord[method.name] = caller;
|
|
362
|
+
}
|
|
363
|
+
function dispose() {
|
|
364
|
+
// Fail all active streams so consumers don't hang
|
|
365
|
+
for (const [streamId, handler] of frameDispatcher) {
|
|
366
|
+
handler.defect("Stream client disposed");
|
|
367
|
+
frameDispatcher.delete(streamId);
|
|
368
|
+
}
|
|
369
|
+
centralCleanup();
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
client,
|
|
373
|
+
dispose,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
224
376
|
export { RpcDefectError } from "./types.js";
|