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/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;
@@ -1 +1 @@
1
- {"version":3,"file":"preload.d.ts","sourceRoot":"","sources":["../src/preload.ts"],"names":[],"mappings":"AAEA,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;CACpC,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;AAwCF,wBAAgB,oBAAoB,CAClC,OAAO,CAAC,EAAE,qBAAqB,GAC9B,cAAc,CAuBhB;AAED,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAgBrE;AAED,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,wBAAwB,GAAG,IAAI,CAYxE"}
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 moduleDefault = electronModule.default;
5
- const source = moduleDefault && typeof moduleDefault === "object"
6
- ? moduleDefault
7
- : electronModule;
8
- const contextBridge = source.contextBridge;
9
- const ipcRenderer = source.ipcRenderer;
10
- if (!contextBridge || !ipcRenderer) {
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
  }
@@ -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
@@ -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,GAC3B,kBAAkB,GAClB,kBAAkB,GAClB,iBAAiB,CAAC;AAUtB,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEpD;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAUtD;AAED,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,OAAO,EACd,MAAM,CAAC,EAAE,MAAM,GACd,iBAAiB,CAOnB;AAED,wBAAgB,UAAU,CAAC,CAAC,EAC1B,QAAQ,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,KAAK,IAAI,CAAC,GAAG,SAAS,EAC5C,OAAO,EAAE,CAAC,GACT,IAAI,CAUN;AAED,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,GACb,mBAAmB,GAAG,IAAI,CA+C5B"}
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;
@@ -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>>(contract: RpcContract<Methods, Events>, options: RpcClientOptions): RpcClient<RpcContract<Methods, Events>>;
4
- export declare function createEventSubscriber<const Methods extends ReadonlyArray<AnyMethod>, const Events extends ReadonlyArray<AnyEvent>>(contract: RpcContract<Methods, Events>, options: EventSubscriberOptions): EventSubscriber<RpcContract<Methods, Events>>;
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
@@ -1 +1 @@
1
- {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,WAAW,EAIjB,MAAM,eAAe,CAAC;AAOvB,OAAO,EAEL,KAAK,QAAQ,EACb,KAAK,SAAS,EAGd,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAE3B,KAAK,SAAS,EACd,KAAK,gBAAgB,EAGtB,MAAM,YAAY,CAAC;AAuEpB,wBAAgB,eAAe,CAC7B,KAAK,CAAC,OAAO,SAAS,aAAa,CAAC,SAAS,CAAC,EAC9C,KAAK,CAAC,MAAM,SAAS,aAAa,CAAC,QAAQ,CAAC,EAE5C,QAAQ,EAAE,WAAW,CAAC,OAAO,EAAE,MAAM,CAAC,EACtC,OAAO,EAAE,gBAAgB,GACxB,SAAS,CAAC,WAAW,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CA0KzC;AAqBD,wBAAgB,qBAAqB,CACnC,KAAK,CAAC,OAAO,SAAS,aAAa,CAAC,SAAS,CAAC,EAC9C,KAAK,CAAC,MAAM,SAAS,aAAa,CAAC,QAAQ,CAAC,EAE5C,QAAQ,EAAE,WAAW,CAAC,OAAO,EAAE,MAAM,CAAC,EACtC,OAAO,EAAE,sBAAsB,GAC9B,eAAe,CAAC,WAAW,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAyF/C;AAED,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC"}
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: () => S.decodeUnknownSync(errorSchema)(envelope.error.data),
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";