From 1a3dec46ba6c614441890b0326942bff2872deaf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 10:20:59 -0700 Subject: [PATCH 1/9] Add Electron IPC Effect RPC proof of concept - Wire Effect RPC client/server over Electron preload and main IPC - Add in-memory tests for unary and streaming round-trips - Define shared IPC envelope and desktop POC protocol --- apps/desktop/src/effectRpcIpcPoc.test.ts | 173 +++++++++++++ apps/desktop/src/effectRpcIpcPoc/client.ts | 67 +++++ apps/desktop/src/effectRpcIpcPoc/ipc.ts | 70 +++++ apps/desktop/src/effectRpcIpcPoc/main.ts | 256 +++++++++++++++++++ apps/desktop/src/effectRpcIpcPoc/preload.ts | 72 ++++++ apps/desktop/src/effectRpcIpcPoc/protocol.ts | 97 +++++++ 6 files changed, 735 insertions(+) create mode 100644 apps/desktop/src/effectRpcIpcPoc.test.ts create mode 100644 apps/desktop/src/effectRpcIpcPoc/client.ts create mode 100644 apps/desktop/src/effectRpcIpcPoc/ipc.ts create mode 100644 apps/desktop/src/effectRpcIpcPoc/main.ts create mode 100644 apps/desktop/src/effectRpcIpcPoc/preload.ts create mode 100644 apps/desktop/src/effectRpcIpcPoc/protocol.ts diff --git a/apps/desktop/src/effectRpcIpcPoc.test.ts b/apps/desktop/src/effectRpcIpcPoc.test.ts new file mode 100644 index 00000000000..3ab1c208c9a --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc.test.ts @@ -0,0 +1,173 @@ +import { Effect, Stream } from "effect"; +import { RpcClient, RpcServer } from "effect/unstable/rpc"; +import { describe, expect, it } from "vitest"; + +import { + DESKTOP_IPC_POC_METHODS, + DesktopIpcPocRpcGroup, + makeDesktopIpcPocClient, + makeDesktopIpcPocHandlersLayer, +} from "./effectRpcIpcPoc/protocol.ts"; +import { + getEffectRpcIpcRendererBridge, + makeEffectRpcIpcRendererPort, + makeEffectRpcIpcRendererProtocol, +} from "./effectRpcIpcPoc/client.ts"; +import { makeEffectRpcIpcMainProtocol } from "./effectRpcIpcPoc/main.ts"; +import { EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY } from "./effectRpcIpcPoc/ipc.ts"; +import type { + EffectRpcIpcMainFrame, + EffectRpcIpcMainSource, + EffectRpcIpcRendererFrame, +} from "./effectRpcIpcPoc/ipc.ts"; + +describe("effect RPC over Electron IPC proof of concept", () => { + it("round-trips unary requests through the renderer/main protocol pair", async () => { + const ipc = new InMemoryEffectRpcIpc(); + + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const mainProtocol = yield* makeEffectRpcIpcMainProtocol(ipc.mainPort); + + yield* RpcServer.make(DesktopIpcPocRpcGroup).pipe( + Effect.provideService(RpcServer.Protocol, mainProtocol), + Effect.provide( + makeDesktopIpcPocHandlersLayer({ + appVersion: "1.2.3", + platform: "test-os", + now: () => new Date("2026-05-06T12:00:00.000Z"), + }), + ), + Effect.forkScoped, + ); + + const rendererProtocol = yield* makeEffectRpcIpcRendererProtocol( + makeEffectRpcIpcRendererPort(getEffectRpcIpcRendererBridge(ipc.rendererGlobal)), + ); + const client = yield* makeDesktopIpcPocClient.pipe( + Effect.provideService(RpcClient.Protocol, rendererProtocol), + ); + + const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); + const echo = yield* client[DESKTOP_IPC_POC_METHODS.echo]({ text: "hello ipc" }); + + return { runtimeInfo, echo }; + }), + ), + ); + + expect(result).toEqual({ + runtimeInfo: { + appVersion: "1.2.3", + platform: "test-os", + ipcTransport: "electron-ipc", + }, + echo: { + text: "hello ipc", + echoedAt: "2026-05-06T12:00:00.000Z", + }, + }); + }); + + it("keeps Effect RPC streaming semantics over the same IPC envelope", async () => { + const ipc = new InMemoryEffectRpcIpc(); + + const ticks = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const mainProtocol = yield* makeEffectRpcIpcMainProtocol(ipc.mainPort); + + yield* RpcServer.make(DesktopIpcPocRpcGroup).pipe( + Effect.provideService(RpcServer.Protocol, mainProtocol), + Effect.provide(makeDesktopIpcPocHandlersLayer()), + Effect.forkScoped, + ); + + const rendererProtocol = yield* makeEffectRpcIpcRendererProtocol( + makeEffectRpcIpcRendererPort(getEffectRpcIpcRendererBridge(ipc.rendererGlobal)), + ); + const client = yield* makeDesktopIpcPocClient.pipe( + Effect.provideService(RpcClient.Protocol, rendererProtocol), + ); + + return yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ take: 3 }).pipe( + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); + }), + ), + ); + + expect(ticks).toEqual([ + { sequence: 1, label: "tick:1" }, + { sequence: 2, label: "tick:2" }, + { sequence: 3, label: "tick:3" }, + ]); + }); +}); + +class InMemoryEffectRpcIpc { + private readonly mainListeners = new Set< + (source: EffectRpcIpcMainSource, frame: EffectRpcIpcRendererFrame) => void + >(); + private readonly rendererListeners = new Set<(frame: EffectRpcIpcMainFrame) => void>(); + private readonly closeListeners = new Set<() => void>(); + private closed = false; + + readonly source: EffectRpcIpcMainSource = { + id: 1, + send: (frame) => { + queueMicrotask(() => { + for (const listener of this.rendererListeners) { + listener(frame); + } + }); + }, + isClosed: () => this.closed, + onClose: (listener) => { + this.closeListeners.add(listener); + return () => { + this.closeListeners.delete(listener); + }; + }, + }; + + readonly mainPort = { + subscribe: ( + listener: (source: EffectRpcIpcMainSource, frame: EffectRpcIpcRendererFrame) => void, + ) => { + this.mainListeners.add(listener); + return () => { + this.mainListeners.delete(listener); + }; + }, + }; + + readonly rendererPort = { + send: (frame: EffectRpcIpcRendererFrame) => { + queueMicrotask(() => { + for (const listener of this.mainListeners) { + listener(this.source, frame); + } + }); + }, + subscribe: (listener: (frame: EffectRpcIpcMainFrame) => void) => { + this.rendererListeners.add(listener); + return () => { + this.rendererListeners.delete(listener); + }; + }, + }; + + readonly rendererGlobal = { + [EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY]: this.rendererPort, + }; + + close(): void { + this.closed = true; + for (const listener of this.closeListeners) { + listener(); + } + } +} diff --git a/apps/desktop/src/effectRpcIpcPoc/client.ts b/apps/desktop/src/effectRpcIpcPoc/client.ts new file mode 100644 index 00000000000..412723890d4 --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/client.ts @@ -0,0 +1,67 @@ +import { Effect, Layer, Queue, Scope } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; + +import { + EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY, + type EffectRpcIpcMainFrame, + type EffectRpcIpcRendererBridge, + type EffectRpcIpcRendererPort, +} from "./ipc.ts"; + +export interface EffectRpcIpcBrowserGlobal { + readonly [EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY]?: EffectRpcIpcRendererBridge; +} + +export function getEffectRpcIpcRendererBridge( + globalObject: EffectRpcIpcBrowserGlobal = globalThis as EffectRpcIpcBrowserGlobal, +): EffectRpcIpcRendererBridge { + const bridge = globalObject[EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY]; + if (!bridge) { + throw new Error(`Missing preload bridge: window.${EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY}`); + } + return bridge; +} + +export const makeEffectRpcIpcRendererPort = ( + bridge: EffectRpcIpcRendererBridge, +): EffectRpcIpcRendererPort => bridge; + +export const makeEffectRpcIpcRendererProtocol = ( + port: EffectRpcIpcRendererPort, +): Effect.Effect => + RpcClient.Protocol.make((writeResponse) => + Effect.gen(function* () { + const scope = yield* Effect.scope; + const responses = yield* Queue.make(); + const unsubscribe = port.subscribe((frame) => { + Queue.offerUnsafe(responses, frame); + }); + + yield* Queue.take(responses).pipe( + Effect.flatMap((frame) => writeResponse(frame.rendererClientId, frame.message)), + Effect.forever, + Effect.forkScoped, + ); + + yield* Scope.addFinalizer( + scope, + Effect.sync(unsubscribe).pipe(Effect.andThen(Queue.shutdown(responses))), + ); + + return { + send: (rendererClientId, message) => + Effect.sync(() => { + port.send({ + version: 1, + rendererClientId, + message, + }); + }), + supportsAck: true, + supportsTransferables: false, + }; + }), + ); + +export const layerEffectRpcIpcRendererProtocol = (port: EffectRpcIpcRendererPort) => + Layer.effect(RpcClient.Protocol, makeEffectRpcIpcRendererProtocol(port)); diff --git a/apps/desktop/src/effectRpcIpcPoc/ipc.ts b/apps/desktop/src/effectRpcIpcPoc/ipc.ts new file mode 100644 index 00000000000..c60c04e936c --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/ipc.ts @@ -0,0 +1,70 @@ +import type { FromClientEncoded, FromServerEncoded } from "effect/unstable/rpc/RpcMessage"; + +/** + * Shared IPC envelope for the Electron transport. + * + * Electron IPC already gives us framing and structured clone, so the transport + * can pass Effect RPC's encoded message objects directly instead of wrapping + * them in JSON-RPC text. + */ + +export const EFFECT_RPC_IPC_CHANNELS = { + rendererToMain: "effect-rpc-ipc:poc:renderer-to-main", + mainToRenderer: "effect-rpc-ipc:poc:main-to-renderer", +} as const; + +export const EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY = "effectRpcIpcPoc" as const; + +export interface EffectRpcIpcRendererFrame { + readonly version: 1; + readonly rendererClientId: number; + readonly message: FromClientEncoded; +} + +export interface EffectRpcIpcMainFrame { + readonly version: 1; + readonly rendererClientId: number; + readonly message: FromServerEncoded; +} + +export interface EffectRpcIpcRendererPort { + readonly send: (frame: EffectRpcIpcRendererFrame) => void; + readonly subscribe: (listener: (frame: EffectRpcIpcMainFrame) => void) => () => void; +} + +export type EffectRpcIpcRendererBridge = EffectRpcIpcRendererPort; + +export interface EffectRpcIpcMainSource { + readonly id: number; + readonly send: (frame: EffectRpcIpcMainFrame) => void; + readonly isClosed?: () => boolean; + readonly onClose?: (listener: () => void) => () => void; +} + +export interface EffectRpcIpcMainPort { + readonly subscribe: ( + listener: (source: EffectRpcIpcMainSource, frame: EffectRpcIpcRendererFrame) => void, + ) => () => void; +} + +export function isEffectRpcIpcRendererFrame(value: unknown): value is EffectRpcIpcRendererFrame { + return ( + isRecord(value) && + value.version === 1 && + typeof value.rendererClientId === "number" && + isRecord(value.message) + ); +} + +export function isEffectRpcIpcMainFrame(value: unknown): value is EffectRpcIpcMainFrame { + return ( + isRecord(value) && + value.version === 1 && + typeof value.rendererClientId === "number" && + isRecord(value.message) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/apps/desktop/src/effectRpcIpcPoc/main.ts b/apps/desktop/src/effectRpcIpcPoc/main.ts new file mode 100644 index 00000000000..d47054fd897 --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/main.ts @@ -0,0 +1,256 @@ +import { Effect, Layer, Option, Queue, Scope } from "effect"; +import { RpcServer } from "effect/unstable/rpc"; +import type { FromClientEncoded } from "effect/unstable/rpc/RpcMessage"; + +import { + EFFECT_RPC_IPC_CHANNELS, + type EffectRpcIpcMainFrame, + type EffectRpcIpcMainPort, + type EffectRpcIpcMainSource, + isEffectRpcIpcRendererFrame, +} from "./ipc.ts"; + +export interface ElectronLikeWebContents { + readonly id: number; + readonly send: (channel: string, frame: EffectRpcIpcMainFrame) => void; + readonly isDestroyed?: () => boolean; + readonly once?: (event: "destroyed", listener: () => void) => ElectronLikeWebContents; + readonly off?: (event: "destroyed", listener: () => void) => ElectronLikeWebContents; + readonly removeListener?: (event: "destroyed", listener: () => void) => ElectronLikeWebContents; +} + +export interface ElectronLikeIpcMainEvent { + readonly sender: ElectronLikeWebContents; +} + +export interface ElectronLikeIpcMain { + readonly on: ( + channel: string, + listener: (event: ElectronLikeIpcMainEvent, frame: unknown) => void, + ) => ElectronLikeIpcMain; + readonly off?: ( + channel: string, + listener: (event: ElectronLikeIpcMainEvent, frame: unknown) => void, + ) => ElectronLikeIpcMain; + readonly removeListener?: ( + channel: string, + listener: (event: ElectronLikeIpcMainEvent, frame: unknown) => void, + ) => ElectronLikeIpcMain; +} + +export function makeElectronIpcMainPort( + ipcMain: ElectronLikeIpcMain, + channels = EFFECT_RPC_IPC_CHANNELS, +): EffectRpcIpcMainPort { + return { + subscribe: (listener) => { + const wrapped = (event: ElectronLikeIpcMainEvent, frame: unknown) => { + if (!isEffectRpcIpcRendererFrame(frame)) { + return; + } + + const source: EffectRpcIpcMainSource = { + id: event.sender.id, + send: (responseFrame) => { + event.sender.send(channels.mainToRenderer, responseFrame); + }, + isClosed: () => event.sender.isDestroyed?.() === true, + ...(event.sender.once + ? { + onClose: (closeListener) => { + event.sender.once?.("destroyed", closeListener); + return () => { + removeDestroyedListener(event.sender, closeListener); + }; + }, + } + : {}), + }; + + listener(source, frame); + }; + + ipcMain.on(channels.rendererToMain, wrapped); + return () => { + removeIpcListener(ipcMain, channels.rendererToMain, wrapped); + }; + }, + }; +} + +export const makeEffectRpcIpcMainProtocol = ( + port: EffectRpcIpcMainPort, +): Effect.Effect => + Effect.gen(function* () { + const scope = yield* Effect.scope; + const requests = yield* Queue.make(); + const disconnects = yield* Queue.make(); + let nextMainClientId = 1; + const mainClientIds = new Set(); + const clients = new Map(); + const mainClientIdByRendererKey = new Map(); + const closeUnsubscribers = new Map void>(); + + const disconnectClient = (mainClientId: number) => + Effect.sync(() => { + const client = clients.get(mainClientId); + if (!client) { + return; + } + + clients.delete(mainClientId); + mainClientIds.delete(mainClientId); + mainClientIdByRendererKey.delete(client.key); + Queue.offerUnsafe(disconnects, mainClientId); + }); + + const disconnectSource = (sourceId: number) => { + for (const [mainClientId, client] of clients.entries()) { + if (client.source.id === sourceId) { + Queue.offerUnsafe(requests, { + _tag: "disconnect", + mainClientId, + }); + } + } + }; + + const registerClient = (source: EffectRpcIpcMainSource, rendererClientId: number): number => { + const key = `${source.id}:${rendererClientId}`; + const existingMainClientId = mainClientIdByRendererKey.get(key); + if (existingMainClientId !== undefined) { + return existingMainClientId; + } + + const mainClientId = nextMainClientId; + nextMainClientId += 1; + mainClientIds.add(mainClientId); + mainClientIdByRendererKey.set(key, mainClientId); + clients.set(mainClientId, { + key, + rendererClientId, + source, + }); + + if (!closeUnsubscribers.has(source.id) && source.onClose) { + const unsubscribe = source.onClose(() => { + disconnectSource(source.id); + }); + closeUnsubscribers.set(source.id, unsubscribe); + } + + return mainClientId; + }; + + const unsubscribe = port.subscribe((source, frame) => { + if (source.isClosed?.() === true) { + return; + } + + Queue.offerUnsafe(requests, { + _tag: "request", + mainClientId: registerClient(source, frame.rendererClientId), + message: frame.message, + }); + }); + + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + unsubscribe(); + for (const closeUnsubscribe of closeUnsubscribers.values()) { + closeUnsubscribe(); + } + closeUnsubscribers.clear(); + clients.clear(); + mainClientIds.clear(); + mainClientIdByRendererKey.clear(); + }).pipe( + Effect.andThen(Queue.shutdown(requests)), + Effect.andThen(Queue.shutdown(disconnects)), + ), + ); + + return RpcServer.Protocol.of({ + run: (writeRequest) => + Queue.take(requests).pipe( + Effect.flatMap((request) => { + switch (request._tag) { + case "request": + return writeRequest(request.mainClientId, request.message); + case "disconnect": + return disconnectClient(request.mainClientId); + } + }), + Effect.forever, + ), + disconnects, + send: (mainClientId, message) => + Effect.gen(function* () { + const client = clients.get(mainClientId); + if (!client) { + return; + } + + if (client.source.isClosed?.() === true) { + yield* disconnectClient(mainClientId); + return; + } + + client.source.send({ + version: 1, + rendererClientId: client.rendererClientId, + message, + }); + }), + end: disconnectClient, + clientIds: Effect.sync(() => new Set(mainClientIds)), + initialMessage: Effect.succeed(Option.none()), + supportsAck: true, + supportsTransferables: false, + supportsSpanPropagation: true, + }); + }); + +export const layerEffectRpcIpcMainProtocol = (port: EffectRpcIpcMainPort) => + Layer.effect(RpcServer.Protocol, makeEffectRpcIpcMainProtocol(port)); + +interface MainClientRecord { + readonly key: string; + readonly rendererClientId: number; + readonly source: EffectRpcIpcMainSource; +} + +type MainProtocolRequest = + | { + readonly _tag: "request"; + readonly mainClientId: number; + readonly message: FromClientEncoded; + } + | { + readonly _tag: "disconnect"; + readonly mainClientId: number; + }; + +function removeIpcListener( + target: { + readonly off?: (channel: string, listener: TListener) => unknown; + readonly removeListener?: (channel: string, listener: TListener) => unknown; + }, + channel: string, + listener: TListener, +): void { + if (target.off) { + target.off(channel, listener); + return; + } + target.removeListener?.(channel, listener); +} + +function removeDestroyedListener(webContents: ElectronLikeWebContents, listener: () => void): void { + if (webContents.off) { + webContents.off("destroyed", listener); + return; + } + webContents.removeListener?.("destroyed", listener); +} diff --git a/apps/desktop/src/effectRpcIpcPoc/preload.ts b/apps/desktop/src/effectRpcIpcPoc/preload.ts new file mode 100644 index 00000000000..3c0d7e58c78 --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/preload.ts @@ -0,0 +1,72 @@ +import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; + +import { + EFFECT_RPC_IPC_CHANNELS, + EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY, + type EffectRpcIpcRendererBridge, + type EffectRpcIpcRendererFrame, + isEffectRpcIpcMainFrame, + isEffectRpcIpcRendererFrame, +} from "./ipc.ts"; + +export interface ElectronLikeIpcRenderer { + readonly send: (channel: string, frame: EffectRpcIpcRendererFrame) => void; + readonly on: ( + channel: string, + listener: (event: IpcRendererEvent, frame: unknown) => void, + ) => ElectronLikeIpcRenderer; + readonly off?: ( + channel: string, + listener: (event: IpcRendererEvent, frame: unknown) => void, + ) => ElectronLikeIpcRenderer; + readonly removeListener?: ( + channel: string, + listener: (event: IpcRendererEvent, frame: unknown) => void, + ) => ElectronLikeIpcRenderer; +} + +export function makeEffectRpcIpcPreloadBridge( + electronIpcRenderer: ElectronLikeIpcRenderer, + channels = EFFECT_RPC_IPC_CHANNELS, +): EffectRpcIpcRendererBridge { + return { + send: (frame) => { + if (!isEffectRpcIpcRendererFrame(frame)) { + throw new TypeError("Invalid Effect RPC renderer frame"); + } + electronIpcRenderer.send(channels.rendererToMain, frame); + }, + subscribe: (listener) => { + const wrapped = (_event: IpcRendererEvent, frame: unknown) => { + if (isEffectRpcIpcMainFrame(frame)) { + listener(frame); + } + }; + + electronIpcRenderer.on(channels.mainToRenderer, wrapped); + return () => { + removeIpcListener(electronIpcRenderer, channels.mainToRenderer, wrapped); + }; + }, + }; +} + +contextBridge.exposeInMainWorld( + EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY, + makeEffectRpcIpcPreloadBridge(ipcRenderer), +); + +function removeIpcListener( + target: { + readonly off?: (channel: string, listener: TListener) => unknown; + readonly removeListener?: (channel: string, listener: TListener) => unknown; + }, + channel: string, + listener: TListener, +): void { + if (target.off) { + target.off(channel, listener); + return; + } + target.removeListener?.(channel, listener); +} diff --git a/apps/desktop/src/effectRpcIpcPoc/protocol.ts b/apps/desktop/src/effectRpcIpcPoc/protocol.ts new file mode 100644 index 00000000000..77d566e7e2a --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/protocol.ts @@ -0,0 +1,97 @@ +import { Effect, Schema, Stream } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; + +export const DESKTOP_IPC_POC_METHODS = { + getRuntimeInfo: "desktop.poc.getRuntimeInfo", + echo: "desktop.poc.echo", + subscribeTicks: "desktop.poc.subscribeTicks", +} as const; + +export const DesktopIpcPocRuntimeInfo = Schema.Struct({ + appVersion: Schema.String, + platform: Schema.String, + ipcTransport: Schema.Literal("electron-ipc"), +}); +export type DesktopIpcPocRuntimeInfo = typeof DesktopIpcPocRuntimeInfo.Type; + +export const DesktopIpcPocEchoInput = Schema.Struct({ + text: Schema.String, +}); +export type DesktopIpcPocEchoInput = typeof DesktopIpcPocEchoInput.Type; + +export const DesktopIpcPocEchoResult = Schema.Struct({ + text: Schema.String, + echoedAt: Schema.String, +}); +export type DesktopIpcPocEchoResult = typeof DesktopIpcPocEchoResult.Type; + +export const DesktopIpcPocSubscribeTicksInput = Schema.Struct({ + take: Schema.Number, +}); +export type DesktopIpcPocSubscribeTicksInput = typeof DesktopIpcPocSubscribeTicksInput.Type; + +export const DesktopIpcPocTick = Schema.Struct({ + sequence: Schema.Number, + label: Schema.String, +}); +export type DesktopIpcPocTick = typeof DesktopIpcPocTick.Type; + +export const DesktopIpcPocGetRuntimeInfoRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.getRuntimeInfo, { + payload: Schema.Struct({}), + success: DesktopIpcPocRuntimeInfo, +}); + +export const DesktopIpcPocEchoRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.echo, { + payload: DesktopIpcPocEchoInput, + success: DesktopIpcPocEchoResult, +}); + +export const DesktopIpcPocSubscribeTicksRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.subscribeTicks, { + payload: DesktopIpcPocSubscribeTicksInput, + success: DesktopIpcPocTick, + stream: true, +}); + +export const DesktopIpcPocRpcGroup = RpcGroup.make( + DesktopIpcPocGetRuntimeInfoRpc, + DesktopIpcPocEchoRpc, + DesktopIpcPocSubscribeTicksRpc, +); + +export const makeDesktopIpcPocHandlersLayer = (options?: { + readonly appVersion?: string; + readonly platform?: string; + readonly now?: () => Date; +}) => { + const now = options?.now ?? (() => new Date()); + + return DesktopIpcPocRpcGroup.toLayer( + DesktopIpcPocRpcGroup.of({ + [DESKTOP_IPC_POC_METHODS.getRuntimeInfo]: () => + Effect.succeed({ + appVersion: options?.appVersion ?? "0.0.0-poc", + platform: options?.platform ?? process.platform, + ipcTransport: "electron-ipc" as const, + }), + [DESKTOP_IPC_POC_METHODS.echo]: (input) => + Effect.sync(() => ({ + text: input.text, + echoedAt: now().toISOString(), + })), + [DESKTOP_IPC_POC_METHODS.subscribeTicks]: (input) => + Stream.fromIterable( + Array.from({ length: Math.max(0, Math.floor(input.take)) }, (_, index) => ({ + sequence: index + 1, + label: `tick:${index + 1}`, + })), + ), + }), + ); +}; + +export const makeDesktopIpcPocClient = RpcClient.make(DesktopIpcPocRpcGroup); +type DesktopIpcPocClientFactory = typeof makeDesktopIpcPocClient; +export type DesktopIpcPocClient = + DesktopIpcPocClientFactory extends Effect.Effect ? Client : never; From 439836f6b9802ffe9ad8dedaf1553c8565bfae6e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 10:30:39 -0700 Subject: [PATCH 2/9] Refactor IPC PoC into reusable example modules - Move the core IPC protocol and transport helpers into a library layout - Add Electron and browser example entrypoints for the RPC PoC - Update the proof-of-concept test to exercise the end-to-end example --- apps/desktop/src/effectRpcIpcPoc.test.ts | 90 ++++++++----------- .../effectRpcIpcPoc/example/browser-client.ts | 64 +++++++++++++ .../effectRpcIpcPoc/example/electron-main.ts | 22 +++++ .../example/electron-preload.ts | 8 ++ .../src/effectRpcIpcPoc/example/main.ts | 51 +++++++++++ .../effectRpcIpcPoc/{ => example}/protocol.ts | 33 +------ .../effectRpcIpcPoc/{ => library}/client.ts | 0 .../src/effectRpcIpcPoc/{ => library}/ipc.ts | 0 .../src/effectRpcIpcPoc/{ => library}/main.ts | 0 .../effectRpcIpcPoc/{ => library}/preload.ts | 29 +++--- 10 files changed, 200 insertions(+), 97 deletions(-) create mode 100644 apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts create mode 100644 apps/desktop/src/effectRpcIpcPoc/example/electron-main.ts create mode 100644 apps/desktop/src/effectRpcIpcPoc/example/electron-preload.ts create mode 100644 apps/desktop/src/effectRpcIpcPoc/example/main.ts rename apps/desktop/src/effectRpcIpcPoc/{ => example}/protocol.ts (67%) rename apps/desktop/src/effectRpcIpcPoc/{ => library}/client.ts (100%) rename apps/desktop/src/effectRpcIpcPoc/{ => library}/ipc.ts (100%) rename apps/desktop/src/effectRpcIpcPoc/{ => library}/main.ts (100%) rename apps/desktop/src/effectRpcIpcPoc/{ => library}/preload.ts (67%) diff --git a/apps/desktop/src/effectRpcIpcPoc.test.ts b/apps/desktop/src/effectRpcIpcPoc.test.ts index 3ab1c208c9a..54a7d5824da 100644 --- a/apps/desktop/src/effectRpcIpcPoc.test.ts +++ b/apps/desktop/src/effectRpcIpcPoc.test.ts @@ -1,58 +1,38 @@ import { Effect, Stream } from "effect"; -import { RpcClient, RpcServer } from "effect/unstable/rpc"; import { describe, expect, it } from "vitest"; import { - DESKTOP_IPC_POC_METHODS, - DesktopIpcPocRpcGroup, - makeDesktopIpcPocClient, - makeDesktopIpcPocHandlersLayer, -} from "./effectRpcIpcPoc/protocol.ts"; -import { - getEffectRpcIpcRendererBridge, - makeEffectRpcIpcRendererPort, - makeEffectRpcIpcRendererProtocol, -} from "./effectRpcIpcPoc/client.ts"; -import { makeEffectRpcIpcMainProtocol } from "./effectRpcIpcPoc/main.ts"; -import { EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY } from "./effectRpcIpcPoc/ipc.ts"; + loadDesktopIpcPocSnapshot, + makeDesktopIpcPocBrowserClient, +} from "./effectRpcIpcPoc/example/browser-client.ts"; +import { runDesktopIpcPocRpcServer } from "./effectRpcIpcPoc/example/main.ts"; +import { DESKTOP_IPC_POC_METHODS } from "./effectRpcIpcPoc/example/protocol.ts"; +import { EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY } from "./effectRpcIpcPoc/library/ipc.ts"; import type { EffectRpcIpcMainFrame, EffectRpcIpcMainSource, EffectRpcIpcRendererFrame, -} from "./effectRpcIpcPoc/ipc.ts"; +} from "./effectRpcIpcPoc/library/ipc.ts"; describe("effect RPC over Electron IPC proof of concept", () => { - it("round-trips unary requests through the renderer/main protocol pair", async () => { + it("runs the end-to-end consumer example over the Electron IPC transport", async () => { const ipc = new InMemoryEffectRpcIpc(); const result = await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const mainProtocol = yield* makeEffectRpcIpcMainProtocol(ipc.mainPort); - - yield* RpcServer.make(DesktopIpcPocRpcGroup).pipe( - Effect.provideService(RpcServer.Protocol, mainProtocol), - Effect.provide( - makeDesktopIpcPocHandlersLayer({ - appVersion: "1.2.3", - platform: "test-os", - now: () => new Date("2026-05-06T12:00:00.000Z"), - }), - ), - Effect.forkScoped, - ); - - const rendererProtocol = yield* makeEffectRpcIpcRendererProtocol( - makeEffectRpcIpcRendererPort(getEffectRpcIpcRendererBridge(ipc.rendererGlobal)), - ); - const client = yield* makeDesktopIpcPocClient.pipe( - Effect.provideService(RpcClient.Protocol, rendererProtocol), - ); - - const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); - const echo = yield* client[DESKTOP_IPC_POC_METHODS.echo]({ text: "hello ipc" }); - - return { runtimeInfo, echo }; + yield* runDesktopIpcPocRpcServer({ + port: ipc.mainPort, + appVersion: "1.2.3", + platform: "test-os", + now: () => new Date("2026-05-06T12:00:00.000Z"), + }); + + return yield* loadDesktopIpcPocSnapshot({ + globalObject: ipc.rendererGlobal, + echoText: "hello ipc", + ticks: 3, + }); }), ), ); @@ -67,29 +47,29 @@ describe("effect RPC over Electron IPC proof of concept", () => { text: "hello ipc", echoedAt: "2026-05-06T12:00:00.000Z", }, + ticks: [ + { sequence: 1, label: "tick:1" }, + { sequence: 2, label: "tick:2" }, + { sequence: 3, label: "tick:3" }, + ], }); }); - it("keeps Effect RPC streaming semantics over the same IPC envelope", async () => { + it("lets browser code consume the generated Effect RPC client directly", async () => { const ipc = new InMemoryEffectRpcIpc(); const ticks = await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const mainProtocol = yield* makeEffectRpcIpcMainProtocol(ipc.mainPort); - - yield* RpcServer.make(DesktopIpcPocRpcGroup).pipe( - Effect.provideService(RpcServer.Protocol, mainProtocol), - Effect.provide(makeDesktopIpcPocHandlersLayer()), - Effect.forkScoped, - ); - - const rendererProtocol = yield* makeEffectRpcIpcRendererProtocol( - makeEffectRpcIpcRendererPort(getEffectRpcIpcRendererBridge(ipc.rendererGlobal)), - ); - const client = yield* makeDesktopIpcPocClient.pipe( - Effect.provideService(RpcClient.Protocol, rendererProtocol), - ); + yield* runDesktopIpcPocRpcServer({ + port: ipc.mainPort, + appVersion: "0.0.0-test", + platform: "test-os", + }); + + const client = yield* makeDesktopIpcPocBrowserClient({ + globalObject: ipc.rendererGlobal, + }); return yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ take: 3 }).pipe( Stream.runCollect, diff --git a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts new file mode 100644 index 00000000000..b8a62052ed0 --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts @@ -0,0 +1,64 @@ +import { Effect, Scope, Stream } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; + +import { + getEffectRpcIpcRendererBridge, + makeEffectRpcIpcRendererPort, + makeEffectRpcIpcRendererProtocol, + type EffectRpcIpcBrowserGlobal, +} from "../library/client.ts"; +import type { EffectRpcIpcRendererBridge } from "../library/ipc.ts"; +import { + DESKTOP_IPC_POC_METHODS, + makeDesktopIpcPocClient, + type DesktopIpcPocClient, +} from "./protocol.ts"; + +export interface DesktopIpcPocBrowserClientOptions { + readonly bridge?: EffectRpcIpcRendererBridge; + readonly globalObject?: EffectRpcIpcBrowserGlobal; +} + +export interface DesktopIpcPocSnapshotOptions extends DesktopIpcPocBrowserClientOptions { + readonly echoText?: string; + readonly ticks?: number; +} + +export const makeDesktopIpcPocBrowserClient = ( + options: DesktopIpcPocBrowserClientOptions = {}, +): Effect.Effect => + Effect.gen(function* () { + const bridge = options.bridge ?? getEffectRpcIpcRendererBridge(options.globalObject); + const rendererProtocol = yield* makeEffectRpcIpcRendererProtocol( + makeEffectRpcIpcRendererPort(bridge), + ); + + return yield* makeDesktopIpcPocClient.pipe( + Effect.provideService(RpcClient.Protocol, rendererProtocol), + ); + }); + +export const loadDesktopIpcPocSnapshot = (options: DesktopIpcPocSnapshotOptions = {}) => + Effect.gen(function* () { + const client = yield* makeDesktopIpcPocBrowserClient(options); + const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); + const echo = yield* client[DESKTOP_IPC_POC_METHODS.echo]({ + text: options.echoText ?? "hello from the renderer", + }); + const ticks = yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ + take: options.ticks ?? 3, + }).pipe( + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); + + return { + runtimeInfo, + echo, + ticks, + }; + }); + +export const loadDesktopIpcPocSnapshotFromBrowser = ( + options: Omit = {}, +) => Effect.runPromise(Effect.scoped(loadDesktopIpcPocSnapshot(options))); diff --git a/apps/desktop/src/effectRpcIpcPoc/example/electron-main.ts b/apps/desktop/src/effectRpcIpcPoc/example/electron-main.ts new file mode 100644 index 00000000000..26d86ad29d4 --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/example/electron-main.ts @@ -0,0 +1,22 @@ +import { Effect, Fiber } from "effect"; +import { app, ipcMain } from "electron"; + +import { makeElectronIpcMainPort } from "../library/main.ts"; +import { runDesktopIpcPocRpcServer } from "./main.ts"; + +export const runDesktopIpcPocElectronMainRpcServer = () => + runDesktopIpcPocRpcServer({ + port: makeElectronIpcMainPort(ipcMain), + appVersion: app.getVersion(), + platform: process.platform, + }); + +export const startDesktopIpcPocElectronMainRpcServer = () => { + const fiber = Effect.runFork(Effect.scoped(runDesktopIpcPocElectronMainRpcServer())); + + app.once("before-quit", () => { + Effect.runFork(Fiber.interrupt(fiber)); + }); + + return fiber; +}; diff --git a/apps/desktop/src/effectRpcIpcPoc/example/electron-preload.ts b/apps/desktop/src/effectRpcIpcPoc/example/electron-preload.ts new file mode 100644 index 00000000000..9573abdc8b2 --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/example/electron-preload.ts @@ -0,0 +1,8 @@ +import { contextBridge, ipcRenderer } from "electron"; + +import { exposeEffectRpcIpcPreloadBridge } from "../library/preload.ts"; + +exposeEffectRpcIpcPreloadBridge({ + contextBridge, + ipcRenderer, +}); diff --git a/apps/desktop/src/effectRpcIpcPoc/example/main.ts b/apps/desktop/src/effectRpcIpcPoc/example/main.ts new file mode 100644 index 00000000000..99c0c48fb6a --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/example/main.ts @@ -0,0 +1,51 @@ +import { Effect, Stream } from "effect"; +import { RpcServer } from "effect/unstable/rpc"; + +import { makeEffectRpcIpcMainProtocol } from "../library/main.ts"; +import type { EffectRpcIpcMainPort } from "../library/ipc.ts"; +import { DESKTOP_IPC_POC_METHODS, DesktopIpcPocRpcGroup } from "./protocol.ts"; + +export interface DesktopIpcPocMainOptions { + readonly port: EffectRpcIpcMainPort; + readonly appVersion?: string; + readonly platform?: string; + readonly now?: () => Date; +} + +export const makeDesktopIpcPocHandlersLayer = (options: DesktopIpcPocMainOptions) => { + const now = options.now ?? (() => new Date()); + + return DesktopIpcPocRpcGroup.toLayer( + DesktopIpcPocRpcGroup.of({ + [DESKTOP_IPC_POC_METHODS.getRuntimeInfo]: () => + Effect.succeed({ + appVersion: options.appVersion ?? "0.0.0-poc", + platform: options.platform ?? process.platform, + ipcTransport: "electron-ipc" as const, + }), + [DESKTOP_IPC_POC_METHODS.echo]: (input) => + Effect.sync(() => ({ + text: input.text, + echoedAt: now().toISOString(), + })), + [DESKTOP_IPC_POC_METHODS.subscribeTicks]: (input) => + Stream.fromIterable( + Array.from({ length: Math.max(0, Math.floor(input.take)) }, (_, index) => ({ + sequence: index + 1, + label: `tick:${index + 1}`, + })), + ), + }), + ); +}; + +export const runDesktopIpcPocRpcServer = (options: DesktopIpcPocMainOptions) => + Effect.gen(function* () { + const mainProtocol = yield* makeEffectRpcIpcMainProtocol(options.port); + + yield* RpcServer.make(DesktopIpcPocRpcGroup).pipe( + Effect.provideService(RpcServer.Protocol, mainProtocol), + Effect.provide(makeDesktopIpcPocHandlersLayer(options)), + Effect.forkScoped, + ); + }); diff --git a/apps/desktop/src/effectRpcIpcPoc/protocol.ts b/apps/desktop/src/effectRpcIpcPoc/example/protocol.ts similarity index 67% rename from apps/desktop/src/effectRpcIpcPoc/protocol.ts rename to apps/desktop/src/effectRpcIpcPoc/example/protocol.ts index 77d566e7e2a..35dd35aff1c 100644 --- a/apps/desktop/src/effectRpcIpcPoc/protocol.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/protocol.ts @@ -1,4 +1,4 @@ -import { Effect, Schema, Stream } from "effect"; +import { Effect, Schema } from "effect"; import { RpcClient } from "effect/unstable/rpc"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; @@ -60,37 +60,6 @@ export const DesktopIpcPocRpcGroup = RpcGroup.make( DesktopIpcPocSubscribeTicksRpc, ); -export const makeDesktopIpcPocHandlersLayer = (options?: { - readonly appVersion?: string; - readonly platform?: string; - readonly now?: () => Date; -}) => { - const now = options?.now ?? (() => new Date()); - - return DesktopIpcPocRpcGroup.toLayer( - DesktopIpcPocRpcGroup.of({ - [DESKTOP_IPC_POC_METHODS.getRuntimeInfo]: () => - Effect.succeed({ - appVersion: options?.appVersion ?? "0.0.0-poc", - platform: options?.platform ?? process.platform, - ipcTransport: "electron-ipc" as const, - }), - [DESKTOP_IPC_POC_METHODS.echo]: (input) => - Effect.sync(() => ({ - text: input.text, - echoedAt: now().toISOString(), - })), - [DESKTOP_IPC_POC_METHODS.subscribeTicks]: (input) => - Stream.fromIterable( - Array.from({ length: Math.max(0, Math.floor(input.take)) }, (_, index) => ({ - sequence: index + 1, - label: `tick:${index + 1}`, - })), - ), - }), - ); -}; - export const makeDesktopIpcPocClient = RpcClient.make(DesktopIpcPocRpcGroup); type DesktopIpcPocClientFactory = typeof makeDesktopIpcPocClient; export type DesktopIpcPocClient = diff --git a/apps/desktop/src/effectRpcIpcPoc/client.ts b/apps/desktop/src/effectRpcIpcPoc/library/client.ts similarity index 100% rename from apps/desktop/src/effectRpcIpcPoc/client.ts rename to apps/desktop/src/effectRpcIpcPoc/library/client.ts diff --git a/apps/desktop/src/effectRpcIpcPoc/ipc.ts b/apps/desktop/src/effectRpcIpcPoc/library/ipc.ts similarity index 100% rename from apps/desktop/src/effectRpcIpcPoc/ipc.ts rename to apps/desktop/src/effectRpcIpcPoc/library/ipc.ts diff --git a/apps/desktop/src/effectRpcIpcPoc/main.ts b/apps/desktop/src/effectRpcIpcPoc/library/main.ts similarity index 100% rename from apps/desktop/src/effectRpcIpcPoc/main.ts rename to apps/desktop/src/effectRpcIpcPoc/library/main.ts diff --git a/apps/desktop/src/effectRpcIpcPoc/preload.ts b/apps/desktop/src/effectRpcIpcPoc/library/preload.ts similarity index 67% rename from apps/desktop/src/effectRpcIpcPoc/preload.ts rename to apps/desktop/src/effectRpcIpcPoc/library/preload.ts index 3c0d7e58c78..9d03a489b2b 100644 --- a/apps/desktop/src/effectRpcIpcPoc/preload.ts +++ b/apps/desktop/src/effectRpcIpcPoc/library/preload.ts @@ -1,5 +1,3 @@ -import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; - import { EFFECT_RPC_IPC_CHANNELS, EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY, @@ -13,18 +11,22 @@ export interface ElectronLikeIpcRenderer { readonly send: (channel: string, frame: EffectRpcIpcRendererFrame) => void; readonly on: ( channel: string, - listener: (event: IpcRendererEvent, frame: unknown) => void, + listener: (event: unknown, frame: unknown) => void, ) => ElectronLikeIpcRenderer; readonly off?: ( channel: string, - listener: (event: IpcRendererEvent, frame: unknown) => void, + listener: (event: unknown, frame: unknown) => void, ) => ElectronLikeIpcRenderer; readonly removeListener?: ( channel: string, - listener: (event: IpcRendererEvent, frame: unknown) => void, + listener: (event: unknown, frame: unknown) => void, ) => ElectronLikeIpcRenderer; } +export interface ElectronLikeContextBridge { + readonly exposeInMainWorld: (apiKey: string, api: EffectRpcIpcRendererBridge) => void; +} + export function makeEffectRpcIpcPreloadBridge( electronIpcRenderer: ElectronLikeIpcRenderer, channels = EFFECT_RPC_IPC_CHANNELS, @@ -37,7 +39,7 @@ export function makeEffectRpcIpcPreloadBridge( electronIpcRenderer.send(channels.rendererToMain, frame); }, subscribe: (listener) => { - const wrapped = (_event: IpcRendererEvent, frame: unknown) => { + const wrapped = (_event: unknown, frame: unknown) => { if (isEffectRpcIpcMainFrame(frame)) { listener(frame); } @@ -51,10 +53,17 @@ export function makeEffectRpcIpcPreloadBridge( }; } -contextBridge.exposeInMainWorld( - EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY, - makeEffectRpcIpcPreloadBridge(ipcRenderer), -); +export function exposeEffectRpcIpcPreloadBridge(options: { + readonly contextBridge: ElectronLikeContextBridge; + readonly ipcRenderer: ElectronLikeIpcRenderer; + readonly globalKey?: string; + readonly channels?: typeof EFFECT_RPC_IPC_CHANNELS; +}): void { + options.contextBridge.exposeInMainWorld( + options.globalKey ?? EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY, + makeEffectRpcIpcPreloadBridge(options.ipcRenderer, options.channels), + ); +} function removeIpcListener( target: { From d15de553c2f65b4368529770cec901ded6bd8033 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 10:36:43 -0700 Subject: [PATCH 3/9] Refactor Electron IPC POC into main, preload, and renderer - Split the Electron example into dedicated main, preload, renderer, and RPC server modules - Update the POC to create a real BrowserWindow and render the RPC snapshot --- apps/desktop/src/effectRpcIpcPoc.test.ts | 2 +- .../effectRpcIpcPoc/example/electron-main.ts | 22 --- .../src/effectRpcIpcPoc/example/main.ts | 139 ++++++++++++------ .../{electron-preload.ts => preload.ts} | 0 .../src/effectRpcIpcPoc/example/renderer.ts | 22 +++ .../src/effectRpcIpcPoc/example/rpc-server.ts | 51 +++++++ 6 files changed, 166 insertions(+), 70 deletions(-) delete mode 100644 apps/desktop/src/effectRpcIpcPoc/example/electron-main.ts rename apps/desktop/src/effectRpcIpcPoc/example/{electron-preload.ts => preload.ts} (100%) create mode 100644 apps/desktop/src/effectRpcIpcPoc/example/renderer.ts create mode 100644 apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts diff --git a/apps/desktop/src/effectRpcIpcPoc.test.ts b/apps/desktop/src/effectRpcIpcPoc.test.ts index 54a7d5824da..38aa2b421a6 100644 --- a/apps/desktop/src/effectRpcIpcPoc.test.ts +++ b/apps/desktop/src/effectRpcIpcPoc.test.ts @@ -5,7 +5,7 @@ import { loadDesktopIpcPocSnapshot, makeDesktopIpcPocBrowserClient, } from "./effectRpcIpcPoc/example/browser-client.ts"; -import { runDesktopIpcPocRpcServer } from "./effectRpcIpcPoc/example/main.ts"; +import { runDesktopIpcPocRpcServer } from "./effectRpcIpcPoc/example/rpc-server.ts"; import { DESKTOP_IPC_POC_METHODS } from "./effectRpcIpcPoc/example/protocol.ts"; import { EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY } from "./effectRpcIpcPoc/library/ipc.ts"; import type { diff --git a/apps/desktop/src/effectRpcIpcPoc/example/electron-main.ts b/apps/desktop/src/effectRpcIpcPoc/example/electron-main.ts deleted file mode 100644 index 26d86ad29d4..00000000000 --- a/apps/desktop/src/effectRpcIpcPoc/example/electron-main.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Effect, Fiber } from "effect"; -import { app, ipcMain } from "electron"; - -import { makeElectronIpcMainPort } from "../library/main.ts"; -import { runDesktopIpcPocRpcServer } from "./main.ts"; - -export const runDesktopIpcPocElectronMainRpcServer = () => - runDesktopIpcPocRpcServer({ - port: makeElectronIpcMainPort(ipcMain), - appVersion: app.getVersion(), - platform: process.platform, - }); - -export const startDesktopIpcPocElectronMainRpcServer = () => { - const fiber = Effect.runFork(Effect.scoped(runDesktopIpcPocElectronMainRpcServer())); - - app.once("before-quit", () => { - Effect.runFork(Fiber.interrupt(fiber)); - }); - - return fiber; -}; diff --git a/apps/desktop/src/effectRpcIpcPoc/example/main.ts b/apps/desktop/src/effectRpcIpcPoc/example/main.ts index 99c0c48fb6a..d130da0f025 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/main.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/main.ts @@ -1,51 +1,96 @@ -import { Effect, Stream } from "effect"; -import { RpcServer } from "effect/unstable/rpc"; - -import { makeEffectRpcIpcMainProtocol } from "../library/main.ts"; -import type { EffectRpcIpcMainPort } from "../library/ipc.ts"; -import { DESKTOP_IPC_POC_METHODS, DesktopIpcPocRpcGroup } from "./protocol.ts"; - -export interface DesktopIpcPocMainOptions { - readonly port: EffectRpcIpcMainPort; - readonly appVersion?: string; - readonly platform?: string; - readonly now?: () => Date; -} - -export const makeDesktopIpcPocHandlersLayer = (options: DesktopIpcPocMainOptions) => { - const now = options.now ?? (() => new Date()); - - return DesktopIpcPocRpcGroup.toLayer( - DesktopIpcPocRpcGroup.of({ - [DESKTOP_IPC_POC_METHODS.getRuntimeInfo]: () => - Effect.succeed({ - appVersion: options.appVersion ?? "0.0.0-poc", - platform: options.platform ?? process.platform, - ipcTransport: "electron-ipc" as const, - }), - [DESKTOP_IPC_POC_METHODS.echo]: (input) => - Effect.sync(() => ({ - text: input.text, - echoedAt: now().toISOString(), - })), - [DESKTOP_IPC_POC_METHODS.subscribeTicks]: (input) => - Stream.fromIterable( - Array.from({ length: Math.max(0, Math.floor(input.take)) }, (_, index) => ({ - sequence: index + 1, - label: `tick:${index + 1}`, - })), - ), - }), +import * as Path from "node:path"; + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import { Effect } from "effect"; +import { app, BrowserWindow, ipcMain } from "electron"; + +import { makeElectronIpcMainPort } from "../library/main.ts"; +import { runDesktopIpcPocRpcServer } from "./rpc-server.ts"; + +const isMac = process.platform === "darwin"; + +export const makeMainWindow = Effect.sync(() => { + const window = new BrowserWindow({ + width: 900, + height: 650, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + preload: Path.join(__dirname, "preload.cjs"), + }, + }); + + void window.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(` + + + + + Effect RPC Electron IPC POC + + +
Renderer bundle would call example/renderer.ts here.
+ + + `)}`, ); -}; -export const runDesktopIpcPocRpcServer = (options: DesktopIpcPocMainOptions) => - Effect.gen(function* () { - const mainProtocol = yield* makeEffectRpcIpcMainProtocol(options.port); + return window; +}); + +export const installElectronLifecycleHandlers = Effect.acquireRelease( + Effect.sync(() => { + const onWindowAllClosed = () => { + if (!isMac) { + app.quit(); + } + }; + + const onActivate = () => { + if (BrowserWindow.getAllWindows().length === 0) { + Effect.runFork(makeMainWindow); + } + }; - yield* RpcServer.make(DesktopIpcPocRpcGroup).pipe( - Effect.provideService(RpcServer.Protocol, mainProtocol), - Effect.provide(makeDesktopIpcPocHandlersLayer(options)), - Effect.forkScoped, - ); + app.on("window-all-closed", onWindowAllClosed); + app.on("activate", onActivate); + + return { + onActivate, + onWindowAllClosed, + }; + }), + ({ onActivate, onWindowAllClosed }) => + Effect.sync(() => { + app.off("activate", onActivate); + app.off("window-all-closed", onWindowAllClosed); + }), +); + +export const waitForElectronAppReady = Effect.promise(() => app.whenReady()); + +export const waitForElectronAppQuit = Effect.callback((resume) => { + const onBeforeQuit = () => { + resume(Effect.void); + }; + + app.once("before-quit", onBeforeQuit); + + return Effect.sync(() => { + app.off("before-quit", onBeforeQuit); + }); +}); + +export const main = Effect.gen(function* () { + yield* waitForElectronAppReady; + yield* installElectronLifecycleHandlers; + yield* runDesktopIpcPocRpcServer({ + port: makeElectronIpcMainPort(ipcMain), + appVersion: app.getVersion(), + platform: process.platform, }); + yield* makeMainWindow; + yield* waitForElectronAppQuit; +}).pipe(Effect.scoped); + +NodeRuntime.runMain(main); diff --git a/apps/desktop/src/effectRpcIpcPoc/example/electron-preload.ts b/apps/desktop/src/effectRpcIpcPoc/example/preload.ts similarity index 100% rename from apps/desktop/src/effectRpcIpcPoc/example/electron-preload.ts rename to apps/desktop/src/effectRpcIpcPoc/example/preload.ts diff --git a/apps/desktop/src/effectRpcIpcPoc/example/renderer.ts b/apps/desktop/src/effectRpcIpcPoc/example/renderer.ts new file mode 100644 index 00000000000..10b19be2099 --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/example/renderer.ts @@ -0,0 +1,22 @@ +import { Effect } from "effect"; + +import { loadDesktopIpcPocSnapshot } from "./browser-client.ts"; + +const program = Effect.gen(function* () { + const snapshot = yield* loadDesktopIpcPocSnapshot({ + echoText: "hello from the renderer", + ticks: 5, + }); + + const root = document.querySelector("#root"); + if (root) { + root.textContent = JSON.stringify(snapshot, null, 2); + } +}).pipe(Effect.scoped); + +Effect.runPromise(program).catch((error: unknown) => { + const root = document.querySelector("#root"); + if (root) { + root.textContent = error instanceof Error ? error.message : String(error); + } +}); diff --git a/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts b/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts new file mode 100644 index 00000000000..99c0c48fb6a --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts @@ -0,0 +1,51 @@ +import { Effect, Stream } from "effect"; +import { RpcServer } from "effect/unstable/rpc"; + +import { makeEffectRpcIpcMainProtocol } from "../library/main.ts"; +import type { EffectRpcIpcMainPort } from "../library/ipc.ts"; +import { DESKTOP_IPC_POC_METHODS, DesktopIpcPocRpcGroup } from "./protocol.ts"; + +export interface DesktopIpcPocMainOptions { + readonly port: EffectRpcIpcMainPort; + readonly appVersion?: string; + readonly platform?: string; + readonly now?: () => Date; +} + +export const makeDesktopIpcPocHandlersLayer = (options: DesktopIpcPocMainOptions) => { + const now = options.now ?? (() => new Date()); + + return DesktopIpcPocRpcGroup.toLayer( + DesktopIpcPocRpcGroup.of({ + [DESKTOP_IPC_POC_METHODS.getRuntimeInfo]: () => + Effect.succeed({ + appVersion: options.appVersion ?? "0.0.0-poc", + platform: options.platform ?? process.platform, + ipcTransport: "electron-ipc" as const, + }), + [DESKTOP_IPC_POC_METHODS.echo]: (input) => + Effect.sync(() => ({ + text: input.text, + echoedAt: now().toISOString(), + })), + [DESKTOP_IPC_POC_METHODS.subscribeTicks]: (input) => + Stream.fromIterable( + Array.from({ length: Math.max(0, Math.floor(input.take)) }, (_, index) => ({ + sequence: index + 1, + label: `tick:${index + 1}`, + })), + ), + }), + ); +}; + +export const runDesktopIpcPocRpcServer = (options: DesktopIpcPocMainOptions) => + Effect.gen(function* () { + const mainProtocol = yield* makeEffectRpcIpcMainProtocol(options.port); + + yield* RpcServer.make(DesktopIpcPocRpcGroup).pipe( + Effect.provideService(RpcServer.Protocol, mainProtocol), + Effect.provide(makeDesktopIpcPocHandlersLayer(options)), + Effect.forkScoped, + ); + }); From a7164ccb69429f28291ae112607f620d7bc239e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 10:48:25 -0700 Subject: [PATCH 4/9] Extract Electron RPC transport into shared package - Move the Electron IPC bridge and protocol helpers into `effect-electron-rpc` - Update the desktop POC to consume the new shared package - Add transport coverage for renderer/main round-tripping --- apps/desktop/package.json | 1 + apps/desktop/src/effectRpcIpcPoc.test.ts | 33 +++-- .../effectRpcIpcPoc/example/browser-client.ts | 22 +-- .../src/effectRpcIpcPoc/example/main.ts | 2 +- .../src/effectRpcIpcPoc/example/preload.ts | 4 +- .../src/effectRpcIpcPoc/example/rpc-server.ts | 8 +- .../src/effectRpcIpcPoc/library/client.ts | 67 --------- bun.lock | 23 +++- packages/effect-electron-rpc/package.json | 38 +++++ packages/effect-electron-rpc/src/client.ts | 70 ++++++++++ .../effect-electron-rpc/src}/ipc.ts | 34 ++--- .../effect-electron-rpc/src}/main.ts | 43 +++--- .../effect-electron-rpc/src}/preload.ts | 34 ++--- .../effect-electron-rpc/src/transport.test.ts | 130 ++++++++++++++++++ packages/effect-electron-rpc/tsconfig.json | 17 +++ 15 files changed, 371 insertions(+), 155 deletions(-) delete mode 100644 apps/desktop/src/effectRpcIpcPoc/library/client.ts create mode 100644 packages/effect-electron-rpc/package.json create mode 100644 packages/effect-electron-rpc/src/client.ts rename {apps/desktop/src/effectRpcIpcPoc/library => packages/effect-electron-rpc/src}/ipc.ts (51%) rename {apps/desktop/src/effectRpcIpcPoc/library => packages/effect-electron-rpc/src}/main.ts (86%) rename {apps/desktop/src/effectRpcIpcPoc/library => packages/effect-electron-rpc/src}/preload.ts (64%) create mode 100644 packages/effect-electron-rpc/src/transport.test.ts create mode 100644 packages/effect-electron-rpc/tsconfig.json diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cd20211bfef..3b3da8ffb55 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -17,6 +17,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", + "effect-electron-rpc": "workspace:*", "electron": "40.9.3", "electron-updater": "^6.6.2" }, diff --git a/apps/desktop/src/effectRpcIpcPoc.test.ts b/apps/desktop/src/effectRpcIpcPoc.test.ts index 38aa2b421a6..8360e03912d 100644 --- a/apps/desktop/src/effectRpcIpcPoc.test.ts +++ b/apps/desktop/src/effectRpcIpcPoc.test.ts @@ -7,16 +7,16 @@ import { } from "./effectRpcIpcPoc/example/browser-client.ts"; import { runDesktopIpcPocRpcServer } from "./effectRpcIpcPoc/example/rpc-server.ts"; import { DESKTOP_IPC_POC_METHODS } from "./effectRpcIpcPoc/example/protocol.ts"; -import { EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY } from "./effectRpcIpcPoc/library/ipc.ts"; +import { EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY } from "effect-electron-rpc/ipc"; import type { - EffectRpcIpcMainFrame, - EffectRpcIpcMainSource, - EffectRpcIpcRendererFrame, -} from "./effectRpcIpcPoc/library/ipc.ts"; + EffectElectronRpcMainFrame, + EffectElectronRpcMainSource, + EffectElectronRpcRendererFrame, +} from "effect-electron-rpc/ipc"; describe("effect RPC over Electron IPC proof of concept", () => { it("runs the end-to-end consumer example over the Electron IPC transport", async () => { - const ipc = new InMemoryEffectRpcIpc(); + const ipc = new InMemoryEffectElectronRpc(); const result = await Effect.runPromise( Effect.scoped( @@ -56,7 +56,7 @@ describe("effect RPC over Electron IPC proof of concept", () => { }); it("lets browser code consume the generated Effect RPC client directly", async () => { - const ipc = new InMemoryEffectRpcIpc(); + const ipc = new InMemoryEffectElectronRpc(); const ticks = await Effect.runPromise( Effect.scoped( @@ -87,15 +87,15 @@ describe("effect RPC over Electron IPC proof of concept", () => { }); }); -class InMemoryEffectRpcIpc { +class InMemoryEffectElectronRpc { private readonly mainListeners = new Set< - (source: EffectRpcIpcMainSource, frame: EffectRpcIpcRendererFrame) => void + (source: EffectElectronRpcMainSource, frame: EffectElectronRpcRendererFrame) => void >(); - private readonly rendererListeners = new Set<(frame: EffectRpcIpcMainFrame) => void>(); + private readonly rendererListeners = new Set<(frame: EffectElectronRpcMainFrame) => void>(); private readonly closeListeners = new Set<() => void>(); private closed = false; - readonly source: EffectRpcIpcMainSource = { + readonly source: EffectElectronRpcMainSource = { id: 1, send: (frame) => { queueMicrotask(() => { @@ -115,7 +115,10 @@ class InMemoryEffectRpcIpc { readonly mainPort = { subscribe: ( - listener: (source: EffectRpcIpcMainSource, frame: EffectRpcIpcRendererFrame) => void, + listener: ( + source: EffectElectronRpcMainSource, + frame: EffectElectronRpcRendererFrame, + ) => void, ) => { this.mainListeners.add(listener); return () => { @@ -125,14 +128,14 @@ class InMemoryEffectRpcIpc { }; readonly rendererPort = { - send: (frame: EffectRpcIpcRendererFrame) => { + send: (frame: EffectElectronRpcRendererFrame) => { queueMicrotask(() => { for (const listener of this.mainListeners) { listener(this.source, frame); } }); }, - subscribe: (listener: (frame: EffectRpcIpcMainFrame) => void) => { + subscribe: (listener: (frame: EffectElectronRpcMainFrame) => void) => { this.rendererListeners.add(listener); return () => { this.rendererListeners.delete(listener); @@ -141,7 +144,7 @@ class InMemoryEffectRpcIpc { }; readonly rendererGlobal = { - [EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY]: this.rendererPort, + [EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY]: this.rendererPort, }; close(): void { diff --git a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts index b8a62052ed0..6085e2e2cff 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts @@ -2,12 +2,12 @@ import { Effect, Scope, Stream } from "effect"; import { RpcClient } from "effect/unstable/rpc"; import { - getEffectRpcIpcRendererBridge, - makeEffectRpcIpcRendererPort, - makeEffectRpcIpcRendererProtocol, - type EffectRpcIpcBrowserGlobal, -} from "../library/client.ts"; -import type { EffectRpcIpcRendererBridge } from "../library/ipc.ts"; + getEffectElectronRpcRendererBridge, + makeEffectElectronRpcRendererPort, + makeEffectElectronRpcRendererProtocol, + type EffectElectronRpcBrowserGlobal, +} from "effect-electron-rpc/client"; +import type { EffectElectronRpcRendererBridge } from "effect-electron-rpc/ipc"; import { DESKTOP_IPC_POC_METHODS, makeDesktopIpcPocClient, @@ -15,8 +15,8 @@ import { } from "./protocol.ts"; export interface DesktopIpcPocBrowserClientOptions { - readonly bridge?: EffectRpcIpcRendererBridge; - readonly globalObject?: EffectRpcIpcBrowserGlobal; + readonly bridge?: EffectElectronRpcRendererBridge; + readonly globalObject?: EffectElectronRpcBrowserGlobal; } export interface DesktopIpcPocSnapshotOptions extends DesktopIpcPocBrowserClientOptions { @@ -28,9 +28,9 @@ export const makeDesktopIpcPocBrowserClient = ( options: DesktopIpcPocBrowserClientOptions = {}, ): Effect.Effect => Effect.gen(function* () { - const bridge = options.bridge ?? getEffectRpcIpcRendererBridge(options.globalObject); - const rendererProtocol = yield* makeEffectRpcIpcRendererProtocol( - makeEffectRpcIpcRendererPort(bridge), + const bridge = options.bridge ?? getEffectElectronRpcRendererBridge(options.globalObject); + const rendererProtocol = yield* makeEffectElectronRpcRendererProtocol( + makeEffectElectronRpcRendererPort(bridge), ); return yield* makeDesktopIpcPocClient.pipe( diff --git a/apps/desktop/src/effectRpcIpcPoc/example/main.ts b/apps/desktop/src/effectRpcIpcPoc/example/main.ts index d130da0f025..e9d4858d1be 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/main.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/main.ts @@ -4,7 +4,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import { Effect } from "effect"; import { app, BrowserWindow, ipcMain } from "electron"; -import { makeElectronIpcMainPort } from "../library/main.ts"; +import { makeElectronIpcMainPort } from "effect-electron-rpc/main"; import { runDesktopIpcPocRpcServer } from "./rpc-server.ts"; const isMac = process.platform === "darwin"; diff --git a/apps/desktop/src/effectRpcIpcPoc/example/preload.ts b/apps/desktop/src/effectRpcIpcPoc/example/preload.ts index 9573abdc8b2..feaa777fb6d 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/preload.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/preload.ts @@ -1,8 +1,8 @@ import { contextBridge, ipcRenderer } from "electron"; -import { exposeEffectRpcIpcPreloadBridge } from "../library/preload.ts"; +import { exposeEffectElectronRpcPreloadBridge } from "effect-electron-rpc/preload"; -exposeEffectRpcIpcPreloadBridge({ +exposeEffectElectronRpcPreloadBridge({ contextBridge, ipcRenderer, }); diff --git a/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts b/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts index 99c0c48fb6a..cc2b764e929 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts @@ -1,12 +1,12 @@ import { Effect, Stream } from "effect"; import { RpcServer } from "effect/unstable/rpc"; -import { makeEffectRpcIpcMainProtocol } from "../library/main.ts"; -import type { EffectRpcIpcMainPort } from "../library/ipc.ts"; +import type { EffectElectronRpcMainPort } from "effect-electron-rpc/ipc"; +import { makeEffectElectronRpcMainProtocol } from "effect-electron-rpc/main"; import { DESKTOP_IPC_POC_METHODS, DesktopIpcPocRpcGroup } from "./protocol.ts"; export interface DesktopIpcPocMainOptions { - readonly port: EffectRpcIpcMainPort; + readonly port: EffectElectronRpcMainPort; readonly appVersion?: string; readonly platform?: string; readonly now?: () => Date; @@ -41,7 +41,7 @@ export const makeDesktopIpcPocHandlersLayer = (options: DesktopIpcPocMainOptions export const runDesktopIpcPocRpcServer = (options: DesktopIpcPocMainOptions) => Effect.gen(function* () { - const mainProtocol = yield* makeEffectRpcIpcMainProtocol(options.port); + const mainProtocol = yield* makeEffectElectronRpcMainProtocol(options.port); yield* RpcServer.make(DesktopIpcPocRpcGroup).pipe( Effect.provideService(RpcServer.Protocol, mainProtocol), diff --git a/apps/desktop/src/effectRpcIpcPoc/library/client.ts b/apps/desktop/src/effectRpcIpcPoc/library/client.ts deleted file mode 100644 index 412723890d4..00000000000 --- a/apps/desktop/src/effectRpcIpcPoc/library/client.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Effect, Layer, Queue, Scope } from "effect"; -import { RpcClient } from "effect/unstable/rpc"; - -import { - EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY, - type EffectRpcIpcMainFrame, - type EffectRpcIpcRendererBridge, - type EffectRpcIpcRendererPort, -} from "./ipc.ts"; - -export interface EffectRpcIpcBrowserGlobal { - readonly [EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY]?: EffectRpcIpcRendererBridge; -} - -export function getEffectRpcIpcRendererBridge( - globalObject: EffectRpcIpcBrowserGlobal = globalThis as EffectRpcIpcBrowserGlobal, -): EffectRpcIpcRendererBridge { - const bridge = globalObject[EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY]; - if (!bridge) { - throw new Error(`Missing preload bridge: window.${EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY}`); - } - return bridge; -} - -export const makeEffectRpcIpcRendererPort = ( - bridge: EffectRpcIpcRendererBridge, -): EffectRpcIpcRendererPort => bridge; - -export const makeEffectRpcIpcRendererProtocol = ( - port: EffectRpcIpcRendererPort, -): Effect.Effect => - RpcClient.Protocol.make((writeResponse) => - Effect.gen(function* () { - const scope = yield* Effect.scope; - const responses = yield* Queue.make(); - const unsubscribe = port.subscribe((frame) => { - Queue.offerUnsafe(responses, frame); - }); - - yield* Queue.take(responses).pipe( - Effect.flatMap((frame) => writeResponse(frame.rendererClientId, frame.message)), - Effect.forever, - Effect.forkScoped, - ); - - yield* Scope.addFinalizer( - scope, - Effect.sync(unsubscribe).pipe(Effect.andThen(Queue.shutdown(responses))), - ); - - return { - send: (rendererClientId, message) => - Effect.sync(() => { - port.send({ - version: 1, - rendererClientId, - message, - }); - }), - supportsAck: true, - supportsTransferables: false, - }; - }), - ); - -export const layerEffectRpcIpcRendererProtocol = (port: EffectRpcIpcRendererPort) => - Layer.effect(RpcClient.Protocol, makeEffectRpcIpcRendererProtocol(port)); diff --git a/bun.lock b/bun.lock index a87ac77094b..fabebb1b8a9 100644 --- a/bun.lock +++ b/bun.lock @@ -16,10 +16,11 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", + "effect-electron-rpc": "workspace:*", "electron": "40.9.3", "electron-updater": "^6.6.2", }, @@ -49,7 +50,7 @@ }, "apps/server": { "name": "t3", - "version": "0.0.21", + "version": "0.0.22", "bin": { "t3": "./dist/bin.mjs", }, @@ -82,7 +83,7 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", @@ -148,7 +149,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "effect": "catalog:", }, @@ -190,6 +191,18 @@ "vitest": "catalog:", }, }, + "packages/effect-electron-rpc": { + "name": "effect-electron-rpc", + "dependencies": { + "effect": "catalog:", + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, "packages/shared": { "name": "@t3tools/shared", "version": "0.0.0-alpha.1", @@ -1150,6 +1163,8 @@ "effect-codex-app-server": ["effect-codex-app-server@workspace:packages/effect-codex-app-server"], + "effect-electron-rpc": ["effect-electron-rpc@workspace:packages/effect-electron-rpc"], + "electron": ["electron@40.9.3", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-rDcJOT6BBE689Ada+4jD3rVr05pMv9MZOgT0x/rIMVDF9c4ttx4RTb6lVARTyxZC7uqpirttCtcli1eg1DX5qg=="], "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], diff --git a/packages/effect-electron-rpc/package.json b/packages/effect-electron-rpc/package.json new file mode 100644 index 00000000000..c58dbbd19da --- /dev/null +++ b/packages/effect-electron-rpc/package.json @@ -0,0 +1,38 @@ +{ + "name": "effect-electron-rpc", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./ipc": { + "types": "./src/ipc.ts", + "import": "./src/ipc.ts" + }, + "./main": { + "types": "./src/main.ts", + "import": "./src/main.ts" + }, + "./preload": { + "types": "./src/preload.ts", + "import": "./src/preload.ts" + } + }, + "scripts": { + "dev": "tsdown src/client.ts src/ipc.ts src/main.ts src/preload.ts --format esm,cjs --dts --watch --clean", + "build": "tsdown src/client.ts src/ipc.ts src/main.ts src/preload.ts --format esm,cjs --dts --clean", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/effect-electron-rpc/src/client.ts b/packages/effect-electron-rpc/src/client.ts new file mode 100644 index 00000000000..5e759ae169c --- /dev/null +++ b/packages/effect-electron-rpc/src/client.ts @@ -0,0 +1,70 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; + +import { + EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY, + type EffectElectronRpcMainFrame, + type EffectElectronRpcRendererBridge, + type EffectElectronRpcRendererPort, +} from "./ipc.ts"; + +export interface EffectElectronRpcBrowserGlobal { + readonly [EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY]?: EffectElectronRpcRendererBridge; +} + +export function getEffectElectronRpcRendererBridge( + globalObject: EffectElectronRpcBrowserGlobal = globalThis as EffectElectronRpcBrowserGlobal, +): EffectElectronRpcRendererBridge { + const bridge = globalObject[EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY]; + if (!bridge) { + throw new Error(`Missing preload bridge: window.${EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY}`); + } + return bridge; +} + +export const makeEffectElectronRpcRendererPort = ( + bridge: EffectElectronRpcRendererBridge, +): EffectElectronRpcRendererPort => bridge; + +export const makeEffectElectronRpcRendererProtocol = ( + port: EffectElectronRpcRendererPort, +): Effect.Effect => + RpcClient.Protocol.make((writeResponse) => + Effect.gen(function* () { + const scope = yield* Effect.scope; + const responses = yield* Queue.make(); + const unsubscribe = port.subscribe((frame) => { + Queue.offerUnsafe(responses, frame); + }); + + yield* Queue.take(responses).pipe( + Effect.flatMap((frame) => writeResponse(frame.rendererClientId, frame.message)), + Effect.forever, + Effect.forkScoped, + ); + + yield* Scope.addFinalizer( + scope, + Effect.sync(unsubscribe).pipe(Effect.andThen(Queue.shutdown(responses))), + ); + + return { + send: (rendererClientId, message) => + Effect.sync(() => { + port.send({ + version: 1, + rendererClientId, + message, + }); + }), + supportsAck: true, + supportsTransferables: false, + }; + }), + ); + +export const layerEffectElectronRpcRendererProtocol = (port: EffectElectronRpcRendererPort) => + Layer.effect(RpcClient.Protocol, makeEffectElectronRpcRendererProtocol(port)); diff --git a/apps/desktop/src/effectRpcIpcPoc/library/ipc.ts b/packages/effect-electron-rpc/src/ipc.ts similarity index 51% rename from apps/desktop/src/effectRpcIpcPoc/library/ipc.ts rename to packages/effect-electron-rpc/src/ipc.ts index c60c04e936c..22bd67e4459 100644 --- a/apps/desktop/src/effectRpcIpcPoc/library/ipc.ts +++ b/packages/effect-electron-rpc/src/ipc.ts @@ -8,46 +8,48 @@ import type { FromClientEncoded, FromServerEncoded } from "effect/unstable/rpc/R * them in JSON-RPC text. */ -export const EFFECT_RPC_IPC_CHANNELS = { - rendererToMain: "effect-rpc-ipc:poc:renderer-to-main", - mainToRenderer: "effect-rpc-ipc:poc:main-to-renderer", +export const EFFECT_ELECTRON_RPC_CHANNELS = { + rendererToMain: "effect-electron-rpc:renderer-to-main", + mainToRenderer: "effect-electron-rpc:main-to-renderer", } as const; -export const EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY = "effectRpcIpcPoc" as const; +export const EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY = "effectElectronRpc" as const; -export interface EffectRpcIpcRendererFrame { +export interface EffectElectronRpcRendererFrame { readonly version: 1; readonly rendererClientId: number; readonly message: FromClientEncoded; } -export interface EffectRpcIpcMainFrame { +export interface EffectElectronRpcMainFrame { readonly version: 1; readonly rendererClientId: number; readonly message: FromServerEncoded; } -export interface EffectRpcIpcRendererPort { - readonly send: (frame: EffectRpcIpcRendererFrame) => void; - readonly subscribe: (listener: (frame: EffectRpcIpcMainFrame) => void) => () => void; +export interface EffectElectronRpcRendererPort { + readonly send: (frame: EffectElectronRpcRendererFrame) => void; + readonly subscribe: (listener: (frame: EffectElectronRpcMainFrame) => void) => () => void; } -export type EffectRpcIpcRendererBridge = EffectRpcIpcRendererPort; +export type EffectElectronRpcRendererBridge = EffectElectronRpcRendererPort; -export interface EffectRpcIpcMainSource { +export interface EffectElectronRpcMainSource { readonly id: number; - readonly send: (frame: EffectRpcIpcMainFrame) => void; + readonly send: (frame: EffectElectronRpcMainFrame) => void; readonly isClosed?: () => boolean; readonly onClose?: (listener: () => void) => () => void; } -export interface EffectRpcIpcMainPort { +export interface EffectElectronRpcMainPort { readonly subscribe: ( - listener: (source: EffectRpcIpcMainSource, frame: EffectRpcIpcRendererFrame) => void, + listener: (source: EffectElectronRpcMainSource, frame: EffectElectronRpcRendererFrame) => void, ) => () => void; } -export function isEffectRpcIpcRendererFrame(value: unknown): value is EffectRpcIpcRendererFrame { +export function isEffectElectronRpcRendererFrame( + value: unknown, +): value is EffectElectronRpcRendererFrame { return ( isRecord(value) && value.version === 1 && @@ -56,7 +58,7 @@ export function isEffectRpcIpcRendererFrame(value: unknown): value is EffectRpcI ); } -export function isEffectRpcIpcMainFrame(value: unknown): value is EffectRpcIpcMainFrame { +export function isEffectElectronRpcMainFrame(value: unknown): value is EffectElectronRpcMainFrame { return ( isRecord(value) && value.version === 1 && diff --git a/apps/desktop/src/effectRpcIpcPoc/library/main.ts b/packages/effect-electron-rpc/src/main.ts similarity index 86% rename from apps/desktop/src/effectRpcIpcPoc/library/main.ts rename to packages/effect-electron-rpc/src/main.ts index d47054fd897..d9a073349a7 100644 --- a/apps/desktop/src/effectRpcIpcPoc/library/main.ts +++ b/packages/effect-electron-rpc/src/main.ts @@ -1,18 +1,22 @@ -import { Effect, Layer, Option, Queue, Scope } from "effect"; -import { RpcServer } from "effect/unstable/rpc"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as RpcServer from "effect/unstable/rpc/RpcServer"; import type { FromClientEncoded } from "effect/unstable/rpc/RpcMessage"; import { - EFFECT_RPC_IPC_CHANNELS, - type EffectRpcIpcMainFrame, - type EffectRpcIpcMainPort, - type EffectRpcIpcMainSource, - isEffectRpcIpcRendererFrame, + EFFECT_ELECTRON_RPC_CHANNELS, + type EffectElectronRpcMainFrame, + type EffectElectronRpcMainPort, + type EffectElectronRpcMainSource, + isEffectElectronRpcRendererFrame, } from "./ipc.ts"; export interface ElectronLikeWebContents { readonly id: number; - readonly send: (channel: string, frame: EffectRpcIpcMainFrame) => void; + readonly send: (channel: string, frame: EffectElectronRpcMainFrame) => void; readonly isDestroyed?: () => boolean; readonly once?: (event: "destroyed", listener: () => void) => ElectronLikeWebContents; readonly off?: (event: "destroyed", listener: () => void) => ElectronLikeWebContents; @@ -40,16 +44,16 @@ export interface ElectronLikeIpcMain { export function makeElectronIpcMainPort( ipcMain: ElectronLikeIpcMain, - channels = EFFECT_RPC_IPC_CHANNELS, -): EffectRpcIpcMainPort { + channels = EFFECT_ELECTRON_RPC_CHANNELS, +): EffectElectronRpcMainPort { return { subscribe: (listener) => { const wrapped = (event: ElectronLikeIpcMainEvent, frame: unknown) => { - if (!isEffectRpcIpcRendererFrame(frame)) { + if (!isEffectElectronRpcRendererFrame(frame)) { return; } - const source: EffectRpcIpcMainSource = { + const source: EffectElectronRpcMainSource = { id: event.sender.id, send: (responseFrame) => { event.sender.send(channels.mainToRenderer, responseFrame); @@ -78,8 +82,8 @@ export function makeElectronIpcMainPort( }; } -export const makeEffectRpcIpcMainProtocol = ( - port: EffectRpcIpcMainPort, +export const makeEffectElectronRpcMainProtocol = ( + port: EffectElectronRpcMainPort, ): Effect.Effect => Effect.gen(function* () { const scope = yield* Effect.scope; @@ -115,7 +119,10 @@ export const makeEffectRpcIpcMainProtocol = ( } }; - const registerClient = (source: EffectRpcIpcMainSource, rendererClientId: number): number => { + const registerClient = ( + source: EffectElectronRpcMainSource, + rendererClientId: number, + ): number => { const key = `${source.id}:${rendererClientId}`; const existingMainClientId = mainClientIdByRendererKey.get(key); if (existingMainClientId !== undefined) { @@ -212,13 +219,13 @@ export const makeEffectRpcIpcMainProtocol = ( }); }); -export const layerEffectRpcIpcMainProtocol = (port: EffectRpcIpcMainPort) => - Layer.effect(RpcServer.Protocol, makeEffectRpcIpcMainProtocol(port)); +export const layerEffectElectronRpcMainProtocol = (port: EffectElectronRpcMainPort) => + Layer.effect(RpcServer.Protocol, makeEffectElectronRpcMainProtocol(port)); interface MainClientRecord { readonly key: string; readonly rendererClientId: number; - readonly source: EffectRpcIpcMainSource; + readonly source: EffectElectronRpcMainSource; } type MainProtocolRequest = diff --git a/apps/desktop/src/effectRpcIpcPoc/library/preload.ts b/packages/effect-electron-rpc/src/preload.ts similarity index 64% rename from apps/desktop/src/effectRpcIpcPoc/library/preload.ts rename to packages/effect-electron-rpc/src/preload.ts index 9d03a489b2b..564da2778a0 100644 --- a/apps/desktop/src/effectRpcIpcPoc/library/preload.ts +++ b/packages/effect-electron-rpc/src/preload.ts @@ -1,14 +1,14 @@ import { - EFFECT_RPC_IPC_CHANNELS, - EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY, - type EffectRpcIpcRendererBridge, - type EffectRpcIpcRendererFrame, - isEffectRpcIpcMainFrame, - isEffectRpcIpcRendererFrame, + EFFECT_ELECTRON_RPC_CHANNELS, + EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY, + type EffectElectronRpcRendererBridge, + type EffectElectronRpcRendererFrame, + isEffectElectronRpcMainFrame, + isEffectElectronRpcRendererFrame, } from "./ipc.ts"; export interface ElectronLikeIpcRenderer { - readonly send: (channel: string, frame: EffectRpcIpcRendererFrame) => void; + readonly send: (channel: string, frame: EffectElectronRpcRendererFrame) => void; readonly on: ( channel: string, listener: (event: unknown, frame: unknown) => void, @@ -24,23 +24,23 @@ export interface ElectronLikeIpcRenderer { } export interface ElectronLikeContextBridge { - readonly exposeInMainWorld: (apiKey: string, api: EffectRpcIpcRendererBridge) => void; + readonly exposeInMainWorld: (apiKey: string, api: EffectElectronRpcRendererBridge) => void; } -export function makeEffectRpcIpcPreloadBridge( +export function makeEffectElectronRpcPreloadBridge( electronIpcRenderer: ElectronLikeIpcRenderer, - channels = EFFECT_RPC_IPC_CHANNELS, -): EffectRpcIpcRendererBridge { + channels = EFFECT_ELECTRON_RPC_CHANNELS, +): EffectElectronRpcRendererBridge { return { send: (frame) => { - if (!isEffectRpcIpcRendererFrame(frame)) { + if (!isEffectElectronRpcRendererFrame(frame)) { throw new TypeError("Invalid Effect RPC renderer frame"); } electronIpcRenderer.send(channels.rendererToMain, frame); }, subscribe: (listener) => { const wrapped = (_event: unknown, frame: unknown) => { - if (isEffectRpcIpcMainFrame(frame)) { + if (isEffectElectronRpcMainFrame(frame)) { listener(frame); } }; @@ -53,15 +53,15 @@ export function makeEffectRpcIpcPreloadBridge( }; } -export function exposeEffectRpcIpcPreloadBridge(options: { +export function exposeEffectElectronRpcPreloadBridge(options: { readonly contextBridge: ElectronLikeContextBridge; readonly ipcRenderer: ElectronLikeIpcRenderer; readonly globalKey?: string; - readonly channels?: typeof EFFECT_RPC_IPC_CHANNELS; + readonly channels?: typeof EFFECT_ELECTRON_RPC_CHANNELS; }): void { options.contextBridge.exposeInMainWorld( - options.globalKey ?? EFFECT_RPC_IPC_RENDERER_BRIDGE_KEY, - makeEffectRpcIpcPreloadBridge(options.ipcRenderer, options.channels), + options.globalKey ?? EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY, + makeEffectElectronRpcPreloadBridge(options.ipcRenderer, options.channels), ); } diff --git a/packages/effect-electron-rpc/src/transport.test.ts b/packages/effect-electron-rpc/src/transport.test.ts new file mode 100644 index 00000000000..66a0af6f746 --- /dev/null +++ b/packages/effect-electron-rpc/src/transport.test.ts @@ -0,0 +1,130 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import * as RpcServer from "effect/unstable/rpc/RpcServer"; +import { describe, expect, it } from "vitest"; + +import type { + EffectElectronRpcMainFrame, + EffectElectronRpcMainSource, + EffectElectronRpcRendererFrame, +} from "./ipc.ts"; +import { + makeEffectElectronRpcRendererPort, + makeEffectElectronRpcRendererProtocol, +} from "./client.ts"; +import { makeEffectElectronRpcMainProtocol } from "./main.ts"; + +const TEST_METHODS = { + echo: "effect-electron-rpc.test.echo", +} as const; + +const EchoInput = Schema.Struct({ + text: Schema.String, +}); + +const EchoResult = Schema.Struct({ + text: Schema.String, +}); + +const EchoRpc = Rpc.make(TEST_METHODS.echo, { + payload: EchoInput, + success: EchoResult, +}); + +const TestRpcGroup = RpcGroup.make(EchoRpc); +const makeTestClient = RpcClient.make(TestRpcGroup); + +const TestHandlersLive = TestRpcGroup.toLayer( + TestRpcGroup.of({ + [TEST_METHODS.echo]: (input) => + Effect.succeed({ + text: input.text, + }), + }), +); + +describe("Effect Electron RPC transport", () => { + it("round-trips an Effect RPC through renderer and main ports", async () => { + const transport = new InMemoryEffectElectronRpc(); + + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const mainProtocol = yield* makeEffectElectronRpcMainProtocol(transport.mainPort); + + yield* RpcServer.make(TestRpcGroup).pipe( + Effect.provideService(RpcServer.Protocol, mainProtocol), + Effect.provide(TestHandlersLive), + Effect.forkScoped, + ); + + const rendererProtocol = yield* makeEffectElectronRpcRendererProtocol( + makeEffectElectronRpcRendererPort(transport.rendererPort), + ); + const client = yield* makeTestClient.pipe( + Effect.provideService(RpcClient.Protocol, rendererProtocol), + ); + + return yield* client[TEST_METHODS.echo]({ + text: "hello from renderer", + }); + }), + ), + ); + + expect(result).toEqual({ + text: "hello from renderer", + }); + }); +}); + +class InMemoryEffectElectronRpc { + private readonly mainListeners = new Set< + (source: EffectElectronRpcMainSource, frame: EffectElectronRpcRendererFrame) => void + >(); + private readonly rendererListeners = new Set<(frame: EffectElectronRpcMainFrame) => void>(); + + readonly source: EffectElectronRpcMainSource = { + id: 1, + send: (frame) => { + queueMicrotask(() => { + for (const listener of this.rendererListeners) { + listener(frame); + } + }); + }, + }; + + readonly mainPort = { + subscribe: ( + listener: ( + source: EffectElectronRpcMainSource, + frame: EffectElectronRpcRendererFrame, + ) => void, + ) => { + this.mainListeners.add(listener); + return () => { + this.mainListeners.delete(listener); + }; + }, + }; + + readonly rendererPort = { + send: (frame: EffectElectronRpcRendererFrame) => { + queueMicrotask(() => { + for (const listener of this.mainListeners) { + listener(this.source, frame); + } + }); + }, + subscribe: (listener: (frame: EffectElectronRpcMainFrame) => void) => { + this.rendererListeners.add(listener); + return () => { + this.rendererListeners.delete(listener); + }; + }, + }; +} diff --git a/packages/effect-electron-rpc/tsconfig.json b/packages/effect-electron-rpc/tsconfig.json new file mode 100644 index 00000000000..2f8e6d4df6d --- /dev/null +++ b/packages/effect-electron-rpc/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service", + "diagnosticSeverity": { + "importFromBarrel": "error", + "anyUnknownInErrorContext": "warning", + "instanceOfSchema": "warning", + "deterministicKeys": "warning" + } + } + ] + }, + "include": ["src"] +} From 0eec82b65542e59fd6c27cb11a80d0ed47a44f2c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 10:56:53 -0700 Subject: [PATCH 5/9] Rename Effect Electron RPC package to IPC - Rename the workspace package and update all imports - Update the desktop IPC proof-of-concept to use the new transport name --- apps/desktop/package.json | 2 +- apps/desktop/src/effectRpcIpcPoc.test.ts | 32 +-- .../effectRpcIpcPoc/example/browser-client.ts | 234 ++++++++++++++++-- .../src/effectRpcIpcPoc/example/main.ts | 2 +- .../src/effectRpcIpcPoc/example/preload.ts | 4 +- .../src/effectRpcIpcPoc/example/rpc-server.ts | 8 +- bun.lock | 8 +- .../package.json | 2 +- .../src/client.ts | 38 +-- .../src/ipc.ts | 34 +-- .../src/main.ts | 32 +-- .../src/preload.ts | 34 +-- .../src/transport.test.ts | 38 +-- .../tsconfig.json | 0 14 files changed, 337 insertions(+), 131 deletions(-) rename packages/{effect-electron-rpc => effect-electron-ipc}/package.json (96%) rename packages/{effect-electron-rpc => effect-electron-ipc}/src/client.ts (56%) rename packages/{effect-electron-rpc => effect-electron-ipc}/src/ipc.ts (53%) rename packages/{effect-electron-rpc => effect-electron-ipc}/src/main.ts (90%) rename packages/{effect-electron-rpc => effect-electron-ipc}/src/preload.ts (68%) rename packages/{effect-electron-rpc => effect-electron-ipc}/src/transport.test.ts (74%) rename packages/{effect-electron-rpc => effect-electron-ipc}/tsconfig.json (100%) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3b3da8ffb55..b23adccd6b8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -17,7 +17,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", - "effect-electron-rpc": "workspace:*", + "effect-electron-ipc": "workspace:*", "electron": "40.9.3", "electron-updater": "^6.6.2" }, diff --git a/apps/desktop/src/effectRpcIpcPoc.test.ts b/apps/desktop/src/effectRpcIpcPoc.test.ts index 8360e03912d..c69ed8d55cb 100644 --- a/apps/desktop/src/effectRpcIpcPoc.test.ts +++ b/apps/desktop/src/effectRpcIpcPoc.test.ts @@ -7,16 +7,16 @@ import { } from "./effectRpcIpcPoc/example/browser-client.ts"; import { runDesktopIpcPocRpcServer } from "./effectRpcIpcPoc/example/rpc-server.ts"; import { DESKTOP_IPC_POC_METHODS } from "./effectRpcIpcPoc/example/protocol.ts"; -import { EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY } from "effect-electron-rpc/ipc"; +import { EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY } from "effect-electron-ipc/ipc"; import type { - EffectElectronRpcMainFrame, - EffectElectronRpcMainSource, - EffectElectronRpcRendererFrame, -} from "effect-electron-rpc/ipc"; + EffectElectronIpcMainFrame, + EffectElectronIpcMainSource, + EffectElectronIpcRendererFrame, +} from "effect-electron-ipc/ipc"; describe("effect RPC over Electron IPC proof of concept", () => { it("runs the end-to-end consumer example over the Electron IPC transport", async () => { - const ipc = new InMemoryEffectElectronRpc(); + const ipc = new InMemoryEffectElectronIpc(); const result = await Effect.runPromise( Effect.scoped( @@ -56,7 +56,7 @@ describe("effect RPC over Electron IPC proof of concept", () => { }); it("lets browser code consume the generated Effect RPC client directly", async () => { - const ipc = new InMemoryEffectElectronRpc(); + const ipc = new InMemoryEffectElectronIpc(); const ticks = await Effect.runPromise( Effect.scoped( @@ -87,15 +87,15 @@ describe("effect RPC over Electron IPC proof of concept", () => { }); }); -class InMemoryEffectElectronRpc { +class InMemoryEffectElectronIpc { private readonly mainListeners = new Set< - (source: EffectElectronRpcMainSource, frame: EffectElectronRpcRendererFrame) => void + (source: EffectElectronIpcMainSource, frame: EffectElectronIpcRendererFrame) => void >(); - private readonly rendererListeners = new Set<(frame: EffectElectronRpcMainFrame) => void>(); + private readonly rendererListeners = new Set<(frame: EffectElectronIpcMainFrame) => void>(); private readonly closeListeners = new Set<() => void>(); private closed = false; - readonly source: EffectElectronRpcMainSource = { + readonly source: EffectElectronIpcMainSource = { id: 1, send: (frame) => { queueMicrotask(() => { @@ -116,8 +116,8 @@ class InMemoryEffectElectronRpc { readonly mainPort = { subscribe: ( listener: ( - source: EffectElectronRpcMainSource, - frame: EffectElectronRpcRendererFrame, + source: EffectElectronIpcMainSource, + frame: EffectElectronIpcRendererFrame, ) => void, ) => { this.mainListeners.add(listener); @@ -128,14 +128,14 @@ class InMemoryEffectElectronRpc { }; readonly rendererPort = { - send: (frame: EffectElectronRpcRendererFrame) => { + send: (frame: EffectElectronIpcRendererFrame) => { queueMicrotask(() => { for (const listener of this.mainListeners) { listener(this.source, frame); } }); }, - subscribe: (listener: (frame: EffectElectronRpcMainFrame) => void) => { + subscribe: (listener: (frame: EffectElectronIpcMainFrame) => void) => { this.rendererListeners.add(listener); return () => { this.rendererListeners.delete(listener); @@ -144,7 +144,7 @@ class InMemoryEffectElectronRpc { }; readonly rendererGlobal = { - [EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY]: this.rendererPort, + [EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY]: this.rendererPort, }; close(): void { diff --git a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts index 6085e2e2cff..55fc503e403 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts @@ -1,22 +1,88 @@ -import { Effect, Scope, Stream } from "effect"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { Cause, Effect, Option, Scope, Stream } from "effect"; import { RpcClient } from "effect/unstable/rpc"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; import { - getEffectElectronRpcRendererBridge, - makeEffectElectronRpcRendererPort, - makeEffectElectronRpcRendererProtocol, - type EffectElectronRpcBrowserGlobal, -} from "effect-electron-rpc/client"; -import type { EffectElectronRpcRendererBridge } from "effect-electron-rpc/ipc"; + getEffectElectronIpcRendererBridge, + makeEffectElectronIpcRendererPort, + makeEffectElectronIpcRendererProtocol, + type EffectElectronIpcBrowserGlobal, +} from "effect-electron-ipc/client"; +import type { EffectElectronIpcRendererBridge } from "effect-electron-ipc/ipc"; import { DESKTOP_IPC_POC_METHODS, makeDesktopIpcPocClient, type DesktopIpcPocClient, + type DesktopIpcPocEchoResult, + type DesktopIpcPocRuntimeInfo, + type DesktopIpcPocTick, } from "./protocol.ts"; +// ----------------------------------------------------------------------------- +// example/preload.ts +// ----------------------------------------------------------------------------- +// The real preload file is intentionally tiny: +// +// import { contextBridge, ipcRenderer } from "electron"; +// import { exposeEffectElectronIpcPreloadBridge } from "effect-electron-ipc/preload"; +// +// exposeEffectElectronIpcPreloadBridge({ contextBridge, ipcRenderer }); +// +// That installs `window.effectElectronIpc`, which this browser module reads below. + +// ----------------------------------------------------------------------------- +// example/browser-react-runtime.ts +// ----------------------------------------------------------------------------- +// These declarations stand in for the actual React and @effect/atom-react imports +// a renderer bundle would use. The transport and Effect RPC client code below is +// real; only the UI runtime host is declared to keep this proof of concept small. + +type ReactElement = unknown; +type ReactNode = unknown; +type ReactComponent

> = (props: P) => ReactElement; +type ReactElementType

> = string | ReactComponent

; + +declare const React: { + readonly createElement:

( + type: ReactElementType

, + props?: P | null, + ...children: ReactNode[] + ) => ReactElement; +}; + +declare const createRoot: (container: Element) => { + readonly render: (element: ReactElement) => void; +}; + +declare const useAtomRefresh: (atom: Atom.Atom) => () => void; +declare const useAtomValue: (atom: Atom.Atom) => A; + +// ----------------------------------------------------------------------------- +// example/protocol.ts +// ----------------------------------------------------------------------------- +// The shared RPC contract lives in protocol.ts. Both the Electron main process +// and browser renderer import this contract, so the renderer gets a typed client +// without depending on Electron-specific implementation code. + +export interface DesktopIpcPocSnapshot { + readonly runtimeInfo: DesktopIpcPocRuntimeInfo; + readonly echo: DesktopIpcPocEchoResult; + readonly ticks: ReadonlyArray; +} + +// ----------------------------------------------------------------------------- +// example/browser-client.ts +// ----------------------------------------------------------------------------- +// This is the important browser-side transport step: +// +// preload bridge -> Effect Electron IPC renderer port +// -> Effect RPC RpcClient.Protocol +// -> typed DesktopIpcPocClient + export interface DesktopIpcPocBrowserClientOptions { - readonly bridge?: EffectElectronRpcRendererBridge; - readonly globalObject?: EffectElectronRpcBrowserGlobal; + readonly bridge?: EffectElectronIpcRendererBridge; + readonly globalObject?: EffectElectronIpcBrowserGlobal; } export interface DesktopIpcPocSnapshotOptions extends DesktopIpcPocBrowserClientOptions { @@ -28,17 +94,18 @@ export const makeDesktopIpcPocBrowserClient = ( options: DesktopIpcPocBrowserClientOptions = {}, ): Effect.Effect => Effect.gen(function* () { - const bridge = options.bridge ?? getEffectElectronRpcRendererBridge(options.globalObject); - const rendererProtocol = yield* makeEffectElectronRpcRendererProtocol( - makeEffectElectronRpcRendererPort(bridge), - ); + const bridge = options.bridge ?? getEffectElectronIpcRendererBridge(options.globalObject); + const rendererPort = makeEffectElectronIpcRendererPort(bridge); + const rendererProtocol = yield* makeEffectElectronIpcRendererProtocol(rendererPort); return yield* makeDesktopIpcPocClient.pipe( Effect.provideService(RpcClient.Protocol, rendererProtocol), ); }); -export const loadDesktopIpcPocSnapshot = (options: DesktopIpcPocSnapshotOptions = {}) => +export const loadDesktopIpcPocSnapshot = ( + options: DesktopIpcPocSnapshotOptions = {}, +): Effect.Effect => Effect.gen(function* () { const client = yield* makeDesktopIpcPocBrowserClient(options); const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); @@ -62,3 +129,142 @@ export const loadDesktopIpcPocSnapshot = (options: DesktopIpcPocSnapshotOptions export const loadDesktopIpcPocSnapshotFromBrowser = ( options: Omit = {}, ) => Effect.runPromise(Effect.scoped(loadDesktopIpcPocSnapshot(options))); + +// ----------------------------------------------------------------------------- +// example/browser-atoms.ts +// ----------------------------------------------------------------------------- +// These are the Effect Atom values the React layer consumes. The labels and SWR +// annotations are the important bit for app ergonomics: the RPC client and RPC +// query have stable, inspectable identities and refresh behavior. + +const DESKTOP_IPC_POC_SNAPSHOT_STALE_TIME_MS = 5_000; +const DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS = 60_000; + +export const desktopIpcPocClientAtom = Atom.make(makeDesktopIpcPocBrowserClient()).pipe( + Atom.keepAlive, + Atom.withLabel("desktop-ipc-poc:effect-rpc-client"), +); + +export const desktopIpcPocSnapshotAtom = Atom.make( + loadDesktopIpcPocSnapshot({ + echoText: "hello from an Effect Atom", + ticks: 5, + }), +).pipe( + Atom.swr({ + staleTime: DESKTOP_IPC_POC_SNAPSHOT_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS), + Atom.withLabel("desktop-ipc-poc:snapshot"), +); + +export const desktopIpcPocManualEchoAtom = Atom.make( + Effect.gen(function* () { + const client = yield* makeDesktopIpcPocBrowserClient(); + return yield* client[DESKTOP_IPC_POC_METHODS.echo]({ + text: "manual echo from an Atom-backed action", + }); + }), +).pipe(Atom.withLabel("desktop-ipc-poc:manual-echo")); + +// ----------------------------------------------------------------------------- +// example/components/DesktopIpcPocPanel.tsx +// ----------------------------------------------------------------------------- +// The React layer does not know about Electron IPC or RpcClient.Protocol. It only +// reads Atom values, renders AsyncResult states, and calls Atom refresh handlers. + +function formatAsyncResultError(result: AsyncResult.AsyncResult): string | null { + if (result._tag !== "Failure") { + return null; + } + const error = Cause.squash(result.cause); + return error instanceof Error ? error.message : String(error); +} + +function DesktopIpcPocClientStatus(): ReactElement { + const clientResult = useAtomValue(desktopIpcPocClientAtom); + const isReady = clientResult._tag === "Success"; + const label = isReady + ? "Effect RPC client ready" + : clientResult.waiting + ? "Connecting RPC client" + : "RPC client failed"; + + return React.createElement( + "span", + { + "data-state": isReady ? "ready" : clientResult._tag.toLowerCase(), + }, + label, + ); +} + +function RuntimeInfoView(props: { readonly runtimeInfo: DesktopIpcPocRuntimeInfo }): ReactElement { + return React.createElement( + "dl", + { "aria-label": "Runtime info" }, + React.createElement("dt", null, "App version"), + React.createElement("dd", null, props.runtimeInfo.appVersion), + React.createElement("dt", null, "Platform"), + React.createElement("dd", null, props.runtimeInfo.platform), + React.createElement("dt", null, "Transport"), + React.createElement("dd", null, props.runtimeInfo.ipcTransport), + ); +} + +function EchoView(props: { readonly echo: DesktopIpcPocEchoResult }): ReactElement { + return React.createElement("p", null, `Echoed "${props.echo.text}" at ${props.echo.echoedAt}`); +} + +function TickList(props: { readonly ticks: ReadonlyArray }): ReactElement { + return React.createElement( + "ol", + { "aria-label": "Streamed ticks" }, + ...props.ticks.map((tick) => + React.createElement("li", { key: tick.sequence }, `${tick.sequence}: ${tick.label}`), + ), + ); +} + +export function DesktopIpcPocPanel(): ReactElement { + const snapshotResult = useAtomValue(desktopIpcPocSnapshotAtom); + const refreshSnapshot = useAtomRefresh(desktopIpcPocSnapshotAtom); + const snapshot = Option.getOrNull(AsyncResult.value(snapshotResult)); + const error = formatAsyncResultError(snapshotResult); + + return React.createElement( + "section", + { "aria-label": "Effect Electron IPC proof of concept" }, + React.createElement("header", null, React.createElement(DesktopIpcPocClientStatus, null)), + React.createElement( + "button", + { + disabled: snapshotResult.waiting, + onClick: refreshSnapshot, + type: "button", + }, + snapshotResult.waiting ? "Refreshing" : "Refresh", + ), + error ? React.createElement("p", { role: "alert" }, error) : null, + snapshot + ? React.createElement( + "div", + null, + React.createElement(RuntimeInfoView, { runtimeInfo: snapshot.runtimeInfo }), + React.createElement(EchoView, { echo: snapshot.echo }), + React.createElement(TickList, { ticks: snapshot.ticks }), + ) + : React.createElement("p", null, "Loading desktop RPC data"), + ); +} + +// ----------------------------------------------------------------------------- +// example/renderer.tsx +// ----------------------------------------------------------------------------- +// The renderer entrypoint just mounts the React tree. The preload has already +// installed the bridge, and the Atom graph lazily creates the Effect RPC client. + +export function mountDesktopIpcPocReactExample(container: Element): void { + createRoot(container).render(React.createElement(DesktopIpcPocPanel, null)); +} diff --git a/apps/desktop/src/effectRpcIpcPoc/example/main.ts b/apps/desktop/src/effectRpcIpcPoc/example/main.ts index e9d4858d1be..bf3d7f94f6a 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/main.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/main.ts @@ -4,7 +4,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import { Effect } from "effect"; import { app, BrowserWindow, ipcMain } from "electron"; -import { makeElectronIpcMainPort } from "effect-electron-rpc/main"; +import { makeElectronIpcMainPort } from "effect-electron-ipc/main"; import { runDesktopIpcPocRpcServer } from "./rpc-server.ts"; const isMac = process.platform === "darwin"; diff --git a/apps/desktop/src/effectRpcIpcPoc/example/preload.ts b/apps/desktop/src/effectRpcIpcPoc/example/preload.ts index feaa777fb6d..6125847dc5c 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/preload.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/preload.ts @@ -1,8 +1,8 @@ import { contextBridge, ipcRenderer } from "electron"; -import { exposeEffectElectronRpcPreloadBridge } from "effect-electron-rpc/preload"; +import { exposeEffectElectronIpcPreloadBridge } from "effect-electron-ipc/preload"; -exposeEffectElectronRpcPreloadBridge({ +exposeEffectElectronIpcPreloadBridge({ contextBridge, ipcRenderer, }); diff --git a/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts b/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts index cc2b764e929..b6138f1729f 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts @@ -1,12 +1,12 @@ import { Effect, Stream } from "effect"; import { RpcServer } from "effect/unstable/rpc"; -import type { EffectElectronRpcMainPort } from "effect-electron-rpc/ipc"; -import { makeEffectElectronRpcMainProtocol } from "effect-electron-rpc/main"; +import type { EffectElectronIpcMainPort } from "effect-electron-ipc/ipc"; +import { makeEffectElectronIpcMainProtocol } from "effect-electron-ipc/main"; import { DESKTOP_IPC_POC_METHODS, DesktopIpcPocRpcGroup } from "./protocol.ts"; export interface DesktopIpcPocMainOptions { - readonly port: EffectElectronRpcMainPort; + readonly port: EffectElectronIpcMainPort; readonly appVersion?: string; readonly platform?: string; readonly now?: () => Date; @@ -41,7 +41,7 @@ export const makeDesktopIpcPocHandlersLayer = (options: DesktopIpcPocMainOptions export const runDesktopIpcPocRpcServer = (options: DesktopIpcPocMainOptions) => Effect.gen(function* () { - const mainProtocol = yield* makeEffectElectronRpcMainProtocol(options.port); + const mainProtocol = yield* makeEffectElectronIpcMainProtocol(options.port); yield* RpcServer.make(DesktopIpcPocRpcGroup).pipe( Effect.provideService(RpcServer.Protocol, mainProtocol), diff --git a/bun.lock b/bun.lock index fabebb1b8a9..77decb032d5 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", - "effect-electron-rpc": "workspace:*", + "effect-electron-ipc": "workspace:*", "electron": "40.9.3", "electron-updater": "^6.6.2", }, @@ -191,8 +191,8 @@ "vitest": "catalog:", }, }, - "packages/effect-electron-rpc": { - "name": "effect-electron-rpc", + "packages/effect-electron-ipc": { + "name": "effect-electron-ipc", "dependencies": { "effect": "catalog:", }, @@ -1163,7 +1163,7 @@ "effect-codex-app-server": ["effect-codex-app-server@workspace:packages/effect-codex-app-server"], - "effect-electron-rpc": ["effect-electron-rpc@workspace:packages/effect-electron-rpc"], + "effect-electron-ipc": ["effect-electron-ipc@workspace:packages/effect-electron-ipc"], "electron": ["electron@40.9.3", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-rDcJOT6BBE689Ada+4jD3rVr05pMv9MZOgT0x/rIMVDF9c4ttx4RTb6lVARTyxZC7uqpirttCtcli1eg1DX5qg=="], diff --git a/packages/effect-electron-rpc/package.json b/packages/effect-electron-ipc/package.json similarity index 96% rename from packages/effect-electron-rpc/package.json rename to packages/effect-electron-ipc/package.json index c58dbbd19da..8de31813dfd 100644 --- a/packages/effect-electron-rpc/package.json +++ b/packages/effect-electron-ipc/package.json @@ -1,5 +1,5 @@ { - "name": "effect-electron-rpc", + "name": "effect-electron-ipc", "private": true, "type": "module", "exports": { diff --git a/packages/effect-electron-rpc/src/client.ts b/packages/effect-electron-ipc/src/client.ts similarity index 56% rename from packages/effect-electron-rpc/src/client.ts rename to packages/effect-electron-ipc/src/client.ts index 5e759ae169c..a3504c33124 100644 --- a/packages/effect-electron-rpc/src/client.ts +++ b/packages/effect-electron-ipc/src/client.ts @@ -5,37 +5,37 @@ import * as Scope from "effect/Scope"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; import { - EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY, - type EffectElectronRpcMainFrame, - type EffectElectronRpcRendererBridge, - type EffectElectronRpcRendererPort, + EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY, + type EffectElectronIpcMainFrame, + type EffectElectronIpcRendererBridge, + type EffectElectronIpcRendererPort, } from "./ipc.ts"; -export interface EffectElectronRpcBrowserGlobal { - readonly [EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY]?: EffectElectronRpcRendererBridge; +export interface EffectElectronIpcBrowserGlobal { + readonly [EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY]?: EffectElectronIpcRendererBridge; } -export function getEffectElectronRpcRendererBridge( - globalObject: EffectElectronRpcBrowserGlobal = globalThis as EffectElectronRpcBrowserGlobal, -): EffectElectronRpcRendererBridge { - const bridge = globalObject[EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY]; +export function getEffectElectronIpcRendererBridge( + globalObject: EffectElectronIpcBrowserGlobal = globalThis as EffectElectronIpcBrowserGlobal, +): EffectElectronIpcRendererBridge { + const bridge = globalObject[EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY]; if (!bridge) { - throw new Error(`Missing preload bridge: window.${EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY}`); + throw new Error(`Missing preload bridge: window.${EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY}`); } return bridge; } -export const makeEffectElectronRpcRendererPort = ( - bridge: EffectElectronRpcRendererBridge, -): EffectElectronRpcRendererPort => bridge; +export const makeEffectElectronIpcRendererPort = ( + bridge: EffectElectronIpcRendererBridge, +): EffectElectronIpcRendererPort => bridge; -export const makeEffectElectronRpcRendererProtocol = ( - port: EffectElectronRpcRendererPort, +export const makeEffectElectronIpcRendererProtocol = ( + port: EffectElectronIpcRendererPort, ): Effect.Effect => RpcClient.Protocol.make((writeResponse) => Effect.gen(function* () { const scope = yield* Effect.scope; - const responses = yield* Queue.make(); + const responses = yield* Queue.make(); const unsubscribe = port.subscribe((frame) => { Queue.offerUnsafe(responses, frame); }); @@ -66,5 +66,5 @@ export const makeEffectElectronRpcRendererProtocol = ( }), ); -export const layerEffectElectronRpcRendererProtocol = (port: EffectElectronRpcRendererPort) => - Layer.effect(RpcClient.Protocol, makeEffectElectronRpcRendererProtocol(port)); +export const layerEffectElectronIpcRendererProtocol = (port: EffectElectronIpcRendererPort) => + Layer.effect(RpcClient.Protocol, makeEffectElectronIpcRendererProtocol(port)); diff --git a/packages/effect-electron-rpc/src/ipc.ts b/packages/effect-electron-ipc/src/ipc.ts similarity index 53% rename from packages/effect-electron-rpc/src/ipc.ts rename to packages/effect-electron-ipc/src/ipc.ts index 22bd67e4459..011acc3bb98 100644 --- a/packages/effect-electron-rpc/src/ipc.ts +++ b/packages/effect-electron-ipc/src/ipc.ts @@ -8,48 +8,48 @@ import type { FromClientEncoded, FromServerEncoded } from "effect/unstable/rpc/R * them in JSON-RPC text. */ -export const EFFECT_ELECTRON_RPC_CHANNELS = { - rendererToMain: "effect-electron-rpc:renderer-to-main", - mainToRenderer: "effect-electron-rpc:main-to-renderer", +export const EFFECT_ELECTRON_IPC_CHANNELS = { + rendererToMain: "effect-electron-ipc:renderer-to-main", + mainToRenderer: "effect-electron-ipc:main-to-renderer", } as const; -export const EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY = "effectElectronRpc" as const; +export const EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY = "effectElectronIpc" as const; -export interface EffectElectronRpcRendererFrame { +export interface EffectElectronIpcRendererFrame { readonly version: 1; readonly rendererClientId: number; readonly message: FromClientEncoded; } -export interface EffectElectronRpcMainFrame { +export interface EffectElectronIpcMainFrame { readonly version: 1; readonly rendererClientId: number; readonly message: FromServerEncoded; } -export interface EffectElectronRpcRendererPort { - readonly send: (frame: EffectElectronRpcRendererFrame) => void; - readonly subscribe: (listener: (frame: EffectElectronRpcMainFrame) => void) => () => void; +export interface EffectElectronIpcRendererPort { + readonly send: (frame: EffectElectronIpcRendererFrame) => void; + readonly subscribe: (listener: (frame: EffectElectronIpcMainFrame) => void) => () => void; } -export type EffectElectronRpcRendererBridge = EffectElectronRpcRendererPort; +export type EffectElectronIpcRendererBridge = EffectElectronIpcRendererPort; -export interface EffectElectronRpcMainSource { +export interface EffectElectronIpcMainSource { readonly id: number; - readonly send: (frame: EffectElectronRpcMainFrame) => void; + readonly send: (frame: EffectElectronIpcMainFrame) => void; readonly isClosed?: () => boolean; readonly onClose?: (listener: () => void) => () => void; } -export interface EffectElectronRpcMainPort { +export interface EffectElectronIpcMainPort { readonly subscribe: ( - listener: (source: EffectElectronRpcMainSource, frame: EffectElectronRpcRendererFrame) => void, + listener: (source: EffectElectronIpcMainSource, frame: EffectElectronIpcRendererFrame) => void, ) => () => void; } -export function isEffectElectronRpcRendererFrame( +export function isEffectElectronIpcRendererFrame( value: unknown, -): value is EffectElectronRpcRendererFrame { +): value is EffectElectronIpcRendererFrame { return ( isRecord(value) && value.version === 1 && @@ -58,7 +58,7 @@ export function isEffectElectronRpcRendererFrame( ); } -export function isEffectElectronRpcMainFrame(value: unknown): value is EffectElectronRpcMainFrame { +export function isEffectElectronIpcMainFrame(value: unknown): value is EffectElectronIpcMainFrame { return ( isRecord(value) && value.version === 1 && diff --git a/packages/effect-electron-rpc/src/main.ts b/packages/effect-electron-ipc/src/main.ts similarity index 90% rename from packages/effect-electron-rpc/src/main.ts rename to packages/effect-electron-ipc/src/main.ts index d9a073349a7..093b9f6adf8 100644 --- a/packages/effect-electron-rpc/src/main.ts +++ b/packages/effect-electron-ipc/src/main.ts @@ -7,16 +7,16 @@ import * as RpcServer from "effect/unstable/rpc/RpcServer"; import type { FromClientEncoded } from "effect/unstable/rpc/RpcMessage"; import { - EFFECT_ELECTRON_RPC_CHANNELS, - type EffectElectronRpcMainFrame, - type EffectElectronRpcMainPort, - type EffectElectronRpcMainSource, - isEffectElectronRpcRendererFrame, + EFFECT_ELECTRON_IPC_CHANNELS, + type EffectElectronIpcMainFrame, + type EffectElectronIpcMainPort, + type EffectElectronIpcMainSource, + isEffectElectronIpcRendererFrame, } from "./ipc.ts"; export interface ElectronLikeWebContents { readonly id: number; - readonly send: (channel: string, frame: EffectElectronRpcMainFrame) => void; + readonly send: (channel: string, frame: EffectElectronIpcMainFrame) => void; readonly isDestroyed?: () => boolean; readonly once?: (event: "destroyed", listener: () => void) => ElectronLikeWebContents; readonly off?: (event: "destroyed", listener: () => void) => ElectronLikeWebContents; @@ -44,16 +44,16 @@ export interface ElectronLikeIpcMain { export function makeElectronIpcMainPort( ipcMain: ElectronLikeIpcMain, - channels = EFFECT_ELECTRON_RPC_CHANNELS, -): EffectElectronRpcMainPort { + channels = EFFECT_ELECTRON_IPC_CHANNELS, +): EffectElectronIpcMainPort { return { subscribe: (listener) => { const wrapped = (event: ElectronLikeIpcMainEvent, frame: unknown) => { - if (!isEffectElectronRpcRendererFrame(frame)) { + if (!isEffectElectronIpcRendererFrame(frame)) { return; } - const source: EffectElectronRpcMainSource = { + const source: EffectElectronIpcMainSource = { id: event.sender.id, send: (responseFrame) => { event.sender.send(channels.mainToRenderer, responseFrame); @@ -82,8 +82,8 @@ export function makeElectronIpcMainPort( }; } -export const makeEffectElectronRpcMainProtocol = ( - port: EffectElectronRpcMainPort, +export const makeEffectElectronIpcMainProtocol = ( + port: EffectElectronIpcMainPort, ): Effect.Effect => Effect.gen(function* () { const scope = yield* Effect.scope; @@ -120,7 +120,7 @@ export const makeEffectElectronRpcMainProtocol = ( }; const registerClient = ( - source: EffectElectronRpcMainSource, + source: EffectElectronIpcMainSource, rendererClientId: number, ): number => { const key = `${source.id}:${rendererClientId}`; @@ -219,13 +219,13 @@ export const makeEffectElectronRpcMainProtocol = ( }); }); -export const layerEffectElectronRpcMainProtocol = (port: EffectElectronRpcMainPort) => - Layer.effect(RpcServer.Protocol, makeEffectElectronRpcMainProtocol(port)); +export const layerEffectElectronIpcMainProtocol = (port: EffectElectronIpcMainPort) => + Layer.effect(RpcServer.Protocol, makeEffectElectronIpcMainProtocol(port)); interface MainClientRecord { readonly key: string; readonly rendererClientId: number; - readonly source: EffectElectronRpcMainSource; + readonly source: EffectElectronIpcMainSource; } type MainProtocolRequest = diff --git a/packages/effect-electron-rpc/src/preload.ts b/packages/effect-electron-ipc/src/preload.ts similarity index 68% rename from packages/effect-electron-rpc/src/preload.ts rename to packages/effect-electron-ipc/src/preload.ts index 564da2778a0..cbc761a2529 100644 --- a/packages/effect-electron-rpc/src/preload.ts +++ b/packages/effect-electron-ipc/src/preload.ts @@ -1,14 +1,14 @@ import { - EFFECT_ELECTRON_RPC_CHANNELS, - EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY, - type EffectElectronRpcRendererBridge, - type EffectElectronRpcRendererFrame, - isEffectElectronRpcMainFrame, - isEffectElectronRpcRendererFrame, + EFFECT_ELECTRON_IPC_CHANNELS, + EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY, + type EffectElectronIpcRendererBridge, + type EffectElectronIpcRendererFrame, + isEffectElectronIpcMainFrame, + isEffectElectronIpcRendererFrame, } from "./ipc.ts"; export interface ElectronLikeIpcRenderer { - readonly send: (channel: string, frame: EffectElectronRpcRendererFrame) => void; + readonly send: (channel: string, frame: EffectElectronIpcRendererFrame) => void; readonly on: ( channel: string, listener: (event: unknown, frame: unknown) => void, @@ -24,23 +24,23 @@ export interface ElectronLikeIpcRenderer { } export interface ElectronLikeContextBridge { - readonly exposeInMainWorld: (apiKey: string, api: EffectElectronRpcRendererBridge) => void; + readonly exposeInMainWorld: (apiKey: string, api: EffectElectronIpcRendererBridge) => void; } -export function makeEffectElectronRpcPreloadBridge( +export function makeEffectElectronIpcPreloadBridge( electronIpcRenderer: ElectronLikeIpcRenderer, - channels = EFFECT_ELECTRON_RPC_CHANNELS, -): EffectElectronRpcRendererBridge { + channels = EFFECT_ELECTRON_IPC_CHANNELS, +): EffectElectronIpcRendererBridge { return { send: (frame) => { - if (!isEffectElectronRpcRendererFrame(frame)) { + if (!isEffectElectronIpcRendererFrame(frame)) { throw new TypeError("Invalid Effect RPC renderer frame"); } electronIpcRenderer.send(channels.rendererToMain, frame); }, subscribe: (listener) => { const wrapped = (_event: unknown, frame: unknown) => { - if (isEffectElectronRpcMainFrame(frame)) { + if (isEffectElectronIpcMainFrame(frame)) { listener(frame); } }; @@ -53,15 +53,15 @@ export function makeEffectElectronRpcPreloadBridge( }; } -export function exposeEffectElectronRpcPreloadBridge(options: { +export function exposeEffectElectronIpcPreloadBridge(options: { readonly contextBridge: ElectronLikeContextBridge; readonly ipcRenderer: ElectronLikeIpcRenderer; readonly globalKey?: string; - readonly channels?: typeof EFFECT_ELECTRON_RPC_CHANNELS; + readonly channels?: typeof EFFECT_ELECTRON_IPC_CHANNELS; }): void { options.contextBridge.exposeInMainWorld( - options.globalKey ?? EFFECT_ELECTRON_RPC_RENDERER_BRIDGE_KEY, - makeEffectElectronRpcPreloadBridge(options.ipcRenderer, options.channels), + options.globalKey ?? EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY, + makeEffectElectronIpcPreloadBridge(options.ipcRenderer, options.channels), ); } diff --git a/packages/effect-electron-rpc/src/transport.test.ts b/packages/effect-electron-ipc/src/transport.test.ts similarity index 74% rename from packages/effect-electron-rpc/src/transport.test.ts rename to packages/effect-electron-ipc/src/transport.test.ts index 66a0af6f746..b113f14f650 100644 --- a/packages/effect-electron-rpc/src/transport.test.ts +++ b/packages/effect-electron-ipc/src/transport.test.ts @@ -7,18 +7,18 @@ import * as RpcServer from "effect/unstable/rpc/RpcServer"; import { describe, expect, it } from "vitest"; import type { - EffectElectronRpcMainFrame, - EffectElectronRpcMainSource, - EffectElectronRpcRendererFrame, + EffectElectronIpcMainFrame, + EffectElectronIpcMainSource, + EffectElectronIpcRendererFrame, } from "./ipc.ts"; import { - makeEffectElectronRpcRendererPort, - makeEffectElectronRpcRendererProtocol, + makeEffectElectronIpcRendererPort, + makeEffectElectronIpcRendererProtocol, } from "./client.ts"; -import { makeEffectElectronRpcMainProtocol } from "./main.ts"; +import { makeEffectElectronIpcMainProtocol } from "./main.ts"; const TEST_METHODS = { - echo: "effect-electron-rpc.test.echo", + echo: "effect-electron-ipc.test.echo", } as const; const EchoInput = Schema.Struct({ @@ -48,12 +48,12 @@ const TestHandlersLive = TestRpcGroup.toLayer( describe("Effect Electron RPC transport", () => { it("round-trips an Effect RPC through renderer and main ports", async () => { - const transport = new InMemoryEffectElectronRpc(); + const transport = new InMemoryEffectElectronIpc(); const result = await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const mainProtocol = yield* makeEffectElectronRpcMainProtocol(transport.mainPort); + const mainProtocol = yield* makeEffectElectronIpcMainProtocol(transport.mainPort); yield* RpcServer.make(TestRpcGroup).pipe( Effect.provideService(RpcServer.Protocol, mainProtocol), @@ -61,8 +61,8 @@ describe("Effect Electron RPC transport", () => { Effect.forkScoped, ); - const rendererProtocol = yield* makeEffectElectronRpcRendererProtocol( - makeEffectElectronRpcRendererPort(transport.rendererPort), + const rendererProtocol = yield* makeEffectElectronIpcRendererProtocol( + makeEffectElectronIpcRendererPort(transport.rendererPort), ); const client = yield* makeTestClient.pipe( Effect.provideService(RpcClient.Protocol, rendererProtocol), @@ -81,13 +81,13 @@ describe("Effect Electron RPC transport", () => { }); }); -class InMemoryEffectElectronRpc { +class InMemoryEffectElectronIpc { private readonly mainListeners = new Set< - (source: EffectElectronRpcMainSource, frame: EffectElectronRpcRendererFrame) => void + (source: EffectElectronIpcMainSource, frame: EffectElectronIpcRendererFrame) => void >(); - private readonly rendererListeners = new Set<(frame: EffectElectronRpcMainFrame) => void>(); + private readonly rendererListeners = new Set<(frame: EffectElectronIpcMainFrame) => void>(); - readonly source: EffectElectronRpcMainSource = { + readonly source: EffectElectronIpcMainSource = { id: 1, send: (frame) => { queueMicrotask(() => { @@ -101,8 +101,8 @@ class InMemoryEffectElectronRpc { readonly mainPort = { subscribe: ( listener: ( - source: EffectElectronRpcMainSource, - frame: EffectElectronRpcRendererFrame, + source: EffectElectronIpcMainSource, + frame: EffectElectronIpcRendererFrame, ) => void, ) => { this.mainListeners.add(listener); @@ -113,14 +113,14 @@ class InMemoryEffectElectronRpc { }; readonly rendererPort = { - send: (frame: EffectElectronRpcRendererFrame) => { + send: (frame: EffectElectronIpcRendererFrame) => { queueMicrotask(() => { for (const listener of this.mainListeners) { listener(this.source, frame); } }); }, - subscribe: (listener: (frame: EffectElectronRpcMainFrame) => void) => { + subscribe: (listener: (frame: EffectElectronIpcMainFrame) => void) => { this.rendererListeners.add(listener); return () => { this.rendererListeners.delete(listener); diff --git a/packages/effect-electron-rpc/tsconfig.json b/packages/effect-electron-ipc/tsconfig.json similarity index 100% rename from packages/effect-electron-rpc/tsconfig.json rename to packages/effect-electron-ipc/tsconfig.json From 0a64216c6830462b5a57c1443224e1adb40e872e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 11:06:31 -0700 Subject: [PATCH 6/9] Move Electron IPC PoC contracts into shared package - Extract the effect-electron IPC PoC RPC schemas and group into `packages/contracts` - Update the desktop example to import shared contracts and add the web renderer example - Wire in the `effect-electron-ipc` dependency for the web app --- .../effectRpcIpcPoc/example/browser-client.ts | 197 +------------ .../src/effectRpcIpcPoc/example/protocol.ts | 66 +---- apps/web/package.json | 1 + .../web/src/examples/effectElectronIpcPoc.tsx | 265 ++++++++++++++++++ bun.lock | 1 + packages/contracts/package.json | 5 + .../contracts/src/effectElectronIpcPoc.ts | 60 ++++ packages/contracts/src/index.ts | 1 + 8 files changed, 340 insertions(+), 256 deletions(-) create mode 100644 apps/web/src/examples/effectElectronIpcPoc.tsx create mode 100644 packages/contracts/src/effectElectronIpcPoc.ts diff --git a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts index 55fc503e403..ac85b7bc320 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts @@ -1,5 +1,4 @@ -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { Cause, Effect, Option, Scope, Stream } from "effect"; +import { Effect, Scope, Stream } from "effect"; import { RpcClient } from "effect/unstable/rpc"; import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; @@ -19,67 +18,12 @@ import { type DesktopIpcPocTick, } from "./protocol.ts"; -// ----------------------------------------------------------------------------- -// example/preload.ts -// ----------------------------------------------------------------------------- -// The real preload file is intentionally tiny: -// -// import { contextBridge, ipcRenderer } from "electron"; -// import { exposeEffectElectronIpcPreloadBridge } from "effect-electron-ipc/preload"; -// -// exposeEffectElectronIpcPreloadBridge({ contextBridge, ipcRenderer }); -// -// That installs `window.effectElectronIpc`, which this browser module reads below. - -// ----------------------------------------------------------------------------- -// example/browser-react-runtime.ts -// ----------------------------------------------------------------------------- -// These declarations stand in for the actual React and @effect/atom-react imports -// a renderer bundle would use. The transport and Effect RPC client code below is -// real; only the UI runtime host is declared to keep this proof of concept small. - -type ReactElement = unknown; -type ReactNode = unknown; -type ReactComponent

> = (props: P) => ReactElement; -type ReactElementType

> = string | ReactComponent

; - -declare const React: { - readonly createElement:

( - type: ReactElementType

, - props?: P | null, - ...children: ReactNode[] - ) => ReactElement; -}; - -declare const createRoot: (container: Element) => { - readonly render: (element: ReactElement) => void; -}; - -declare const useAtomRefresh: (atom: Atom.Atom) => () => void; -declare const useAtomValue: (atom: Atom.Atom) => A; - -// ----------------------------------------------------------------------------- -// example/protocol.ts -// ----------------------------------------------------------------------------- -// The shared RPC contract lives in protocol.ts. Both the Electron main process -// and browser renderer import this contract, so the renderer gets a typed client -// without depending on Electron-specific implementation code. - export interface DesktopIpcPocSnapshot { readonly runtimeInfo: DesktopIpcPocRuntimeInfo; readonly echo: DesktopIpcPocEchoResult; readonly ticks: ReadonlyArray; } -// ----------------------------------------------------------------------------- -// example/browser-client.ts -// ----------------------------------------------------------------------------- -// This is the important browser-side transport step: -// -// preload bridge -> Effect Electron IPC renderer port -// -> Effect RPC RpcClient.Protocol -// -> typed DesktopIpcPocClient - export interface DesktopIpcPocBrowserClientOptions { readonly bridge?: EffectElectronIpcRendererBridge; readonly globalObject?: EffectElectronIpcBrowserGlobal; @@ -129,142 +73,3 @@ export const loadDesktopIpcPocSnapshot = ( export const loadDesktopIpcPocSnapshotFromBrowser = ( options: Omit = {}, ) => Effect.runPromise(Effect.scoped(loadDesktopIpcPocSnapshot(options))); - -// ----------------------------------------------------------------------------- -// example/browser-atoms.ts -// ----------------------------------------------------------------------------- -// These are the Effect Atom values the React layer consumes. The labels and SWR -// annotations are the important bit for app ergonomics: the RPC client and RPC -// query have stable, inspectable identities and refresh behavior. - -const DESKTOP_IPC_POC_SNAPSHOT_STALE_TIME_MS = 5_000; -const DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS = 60_000; - -export const desktopIpcPocClientAtom = Atom.make(makeDesktopIpcPocBrowserClient()).pipe( - Atom.keepAlive, - Atom.withLabel("desktop-ipc-poc:effect-rpc-client"), -); - -export const desktopIpcPocSnapshotAtom = Atom.make( - loadDesktopIpcPocSnapshot({ - echoText: "hello from an Effect Atom", - ticks: 5, - }), -).pipe( - Atom.swr({ - staleTime: DESKTOP_IPC_POC_SNAPSHOT_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS), - Atom.withLabel("desktop-ipc-poc:snapshot"), -); - -export const desktopIpcPocManualEchoAtom = Atom.make( - Effect.gen(function* () { - const client = yield* makeDesktopIpcPocBrowserClient(); - return yield* client[DESKTOP_IPC_POC_METHODS.echo]({ - text: "manual echo from an Atom-backed action", - }); - }), -).pipe(Atom.withLabel("desktop-ipc-poc:manual-echo")); - -// ----------------------------------------------------------------------------- -// example/components/DesktopIpcPocPanel.tsx -// ----------------------------------------------------------------------------- -// The React layer does not know about Electron IPC or RpcClient.Protocol. It only -// reads Atom values, renders AsyncResult states, and calls Atom refresh handlers. - -function formatAsyncResultError(result: AsyncResult.AsyncResult): string | null { - if (result._tag !== "Failure") { - return null; - } - const error = Cause.squash(result.cause); - return error instanceof Error ? error.message : String(error); -} - -function DesktopIpcPocClientStatus(): ReactElement { - const clientResult = useAtomValue(desktopIpcPocClientAtom); - const isReady = clientResult._tag === "Success"; - const label = isReady - ? "Effect RPC client ready" - : clientResult.waiting - ? "Connecting RPC client" - : "RPC client failed"; - - return React.createElement( - "span", - { - "data-state": isReady ? "ready" : clientResult._tag.toLowerCase(), - }, - label, - ); -} - -function RuntimeInfoView(props: { readonly runtimeInfo: DesktopIpcPocRuntimeInfo }): ReactElement { - return React.createElement( - "dl", - { "aria-label": "Runtime info" }, - React.createElement("dt", null, "App version"), - React.createElement("dd", null, props.runtimeInfo.appVersion), - React.createElement("dt", null, "Platform"), - React.createElement("dd", null, props.runtimeInfo.platform), - React.createElement("dt", null, "Transport"), - React.createElement("dd", null, props.runtimeInfo.ipcTransport), - ); -} - -function EchoView(props: { readonly echo: DesktopIpcPocEchoResult }): ReactElement { - return React.createElement("p", null, `Echoed "${props.echo.text}" at ${props.echo.echoedAt}`); -} - -function TickList(props: { readonly ticks: ReadonlyArray }): ReactElement { - return React.createElement( - "ol", - { "aria-label": "Streamed ticks" }, - ...props.ticks.map((tick) => - React.createElement("li", { key: tick.sequence }, `${tick.sequence}: ${tick.label}`), - ), - ); -} - -export function DesktopIpcPocPanel(): ReactElement { - const snapshotResult = useAtomValue(desktopIpcPocSnapshotAtom); - const refreshSnapshot = useAtomRefresh(desktopIpcPocSnapshotAtom); - const snapshot = Option.getOrNull(AsyncResult.value(snapshotResult)); - const error = formatAsyncResultError(snapshotResult); - - return React.createElement( - "section", - { "aria-label": "Effect Electron IPC proof of concept" }, - React.createElement("header", null, React.createElement(DesktopIpcPocClientStatus, null)), - React.createElement( - "button", - { - disabled: snapshotResult.waiting, - onClick: refreshSnapshot, - type: "button", - }, - snapshotResult.waiting ? "Refreshing" : "Refresh", - ), - error ? React.createElement("p", { role: "alert" }, error) : null, - snapshot - ? React.createElement( - "div", - null, - React.createElement(RuntimeInfoView, { runtimeInfo: snapshot.runtimeInfo }), - React.createElement(EchoView, { echo: snapshot.echo }), - React.createElement(TickList, { ticks: snapshot.ticks }), - ) - : React.createElement("p", null, "Loading desktop RPC data"), - ); -} - -// ----------------------------------------------------------------------------- -// example/renderer.tsx -// ----------------------------------------------------------------------------- -// The renderer entrypoint just mounts the React tree. The preload has already -// installed the bridge, and the Atom graph lazily creates the Effect RPC client. - -export function mountDesktopIpcPocReactExample(container: Element): void { - createRoot(container).render(React.createElement(DesktopIpcPocPanel, null)); -} diff --git a/apps/desktop/src/effectRpcIpcPoc/example/protocol.ts b/apps/desktop/src/effectRpcIpcPoc/example/protocol.ts index 35dd35aff1c..3a5136bfe71 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/protocol.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/protocol.ts @@ -1,66 +1,12 @@ -import { Effect, Schema } from "effect"; +import { DesktopIpcPocRpcGroup } from "@t3tools/contracts/effectElectronIpcPoc"; +import { Effect } from "effect"; import { RpcClient } from "effect/unstable/rpc"; -import * as Rpc from "effect/unstable/rpc/Rpc"; -import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -export const DESKTOP_IPC_POC_METHODS = { - getRuntimeInfo: "desktop.poc.getRuntimeInfo", - echo: "desktop.poc.echo", - subscribeTicks: "desktop.poc.subscribeTicks", -} as const; - -export const DesktopIpcPocRuntimeInfo = Schema.Struct({ - appVersion: Schema.String, - platform: Schema.String, - ipcTransport: Schema.Literal("electron-ipc"), -}); -export type DesktopIpcPocRuntimeInfo = typeof DesktopIpcPocRuntimeInfo.Type; - -export const DesktopIpcPocEchoInput = Schema.Struct({ - text: Schema.String, -}); -export type DesktopIpcPocEchoInput = typeof DesktopIpcPocEchoInput.Type; - -export const DesktopIpcPocEchoResult = Schema.Struct({ - text: Schema.String, - echoedAt: Schema.String, -}); -export type DesktopIpcPocEchoResult = typeof DesktopIpcPocEchoResult.Type; - -export const DesktopIpcPocSubscribeTicksInput = Schema.Struct({ - take: Schema.Number, -}); -export type DesktopIpcPocSubscribeTicksInput = typeof DesktopIpcPocSubscribeTicksInput.Type; - -export const DesktopIpcPocTick = Schema.Struct({ - sequence: Schema.Number, - label: Schema.String, -}); -export type DesktopIpcPocTick = typeof DesktopIpcPocTick.Type; - -export const DesktopIpcPocGetRuntimeInfoRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.getRuntimeInfo, { - payload: Schema.Struct({}), - success: DesktopIpcPocRuntimeInfo, -}); - -export const DesktopIpcPocEchoRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.echo, { - payload: DesktopIpcPocEchoInput, - success: DesktopIpcPocEchoResult, -}); - -export const DesktopIpcPocSubscribeTicksRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.subscribeTicks, { - payload: DesktopIpcPocSubscribeTicksInput, - success: DesktopIpcPocTick, - stream: true, -}); - -export const DesktopIpcPocRpcGroup = RpcGroup.make( - DesktopIpcPocGetRuntimeInfoRpc, - DesktopIpcPocEchoRpc, - DesktopIpcPocSubscribeTicksRpc, -); +export * from "@t3tools/contracts/effectElectronIpcPoc"; export const makeDesktopIpcPocClient = RpcClient.make(DesktopIpcPocRpcGroup); type DesktopIpcPocClientFactory = typeof makeDesktopIpcPocClient; export type DesktopIpcPocClient = - DesktopIpcPocClientFactory extends Effect.Effect ? Client : never; + DesktopIpcPocClientFactory extends Effect.Effect + ? Client + : never; diff --git a/apps/web/package.json b/apps/web/package.json index 7fa8818109b..7adb7838dd8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", + "effect-electron-ipc": "workspace:*", "lexical": "^0.41.0", "lucide-react": "^0.564.0", "react": "^19.0.0", diff --git a/apps/web/src/examples/effectElectronIpcPoc.tsx b/apps/web/src/examples/effectElectronIpcPoc.tsx new file mode 100644 index 00000000000..6d58be64c7f --- /dev/null +++ b/apps/web/src/examples/effectElectronIpcPoc.tsx @@ -0,0 +1,265 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { + DESKTOP_IPC_POC_METHODS, + DesktopIpcPocRpcGroup, + type DesktopIpcPocEchoResult, + type DesktopIpcPocRuntimeInfo, + type DesktopIpcPocTick, +} from "@t3tools/contracts/effectElectronIpcPoc"; +import { Cause, Effect, Option, Scope, Stream } from "effect"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { RpcClient } from "effect/unstable/rpc"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { + getEffectElectronIpcRendererBridge, + makeEffectElectronIpcRendererPort, + makeEffectElectronIpcRendererProtocol, + type EffectElectronIpcBrowserGlobal, +} from "effect-electron-ipc/client"; +import type { EffectElectronIpcRendererBridge } from "effect-electron-ipc/ipc"; +import { createRoot } from "react-dom/client"; +import type { ReactElement } from "react"; + +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; + +// ----------------------------------------------------------------------------- +// example/preload.ts +// ----------------------------------------------------------------------------- +// import { contextBridge, ipcRenderer } from "electron"; +// import { exposeEffectElectronIpcPreloadBridge } from "effect-electron-ipc/preload"; +// +// exposeEffectElectronIpcPreloadBridge({ contextBridge, ipcRenderer }); + +// ----------------------------------------------------------------------------- +// packages/contracts/src/effectElectronIpcPoc.ts +// ----------------------------------------------------------------------------- +// The shared contract owns only app-level RPC method names and schemas: +// +// DESKTOP_IPC_POC_METHODS +// DesktopIpcPocRuntimeInfo +// DesktopIpcPocEchoInput +// DesktopIpcPocEchoResult +// DesktopIpcPocSubscribeTicksInput +// DesktopIpcPocTick +// DesktopIpcPocRpcGroup +// +// The generic Electron transport package does not import these contracts. + +// ----------------------------------------------------------------------------- +// example/browser-client.ts +// ----------------------------------------------------------------------------- +// preload bridge -> Effect Electron IPC renderer port +// -> Effect RPC RpcClient.Protocol +// -> generated typed DesktopIpcPoc client + +const makeDesktopIpcPocClient = RpcClient.make(DesktopIpcPocRpcGroup); +type DesktopIpcPocClient = + typeof makeDesktopIpcPocClient extends Effect.Effect + ? Client + : never; + +export interface DesktopIpcPocSnapshot { + readonly runtimeInfo: DesktopIpcPocRuntimeInfo; + readonly echo: DesktopIpcPocEchoResult; + readonly ticks: ReadonlyArray; +} + +export interface DesktopIpcPocBrowserClientOptions { + readonly bridge?: EffectElectronIpcRendererBridge; + readonly globalObject?: EffectElectronIpcBrowserGlobal; +} + +export interface DesktopIpcPocSnapshotOptions extends DesktopIpcPocBrowserClientOptions { + readonly echoText?: string; + readonly ticks?: number; +} + +export const makeDesktopIpcPocBrowserClient = ( + options: DesktopIpcPocBrowserClientOptions = {}, +): Effect.Effect => + Effect.gen(function* () { + const bridge = options.bridge ?? getEffectElectronIpcRendererBridge(options.globalObject); + const rendererPort = makeEffectElectronIpcRendererPort(bridge); + const rendererProtocol = yield* makeEffectElectronIpcRendererProtocol(rendererPort); + + return yield* makeDesktopIpcPocClient.pipe( + Effect.provideService(RpcClient.Protocol, rendererProtocol), + ); + }); + +export const loadDesktopIpcPocSnapshot = ( + options: DesktopIpcPocSnapshotOptions = {}, +): Effect.Effect => + Effect.gen(function* () { + const client = yield* makeDesktopIpcPocBrowserClient(options); + const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); + const echo = yield* client[DESKTOP_IPC_POC_METHODS.echo]({ + text: options.echoText ?? "hello from the renderer", + }); + const ticks = yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ + take: options.ticks ?? 3, + }).pipe( + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); + + return { + runtimeInfo, + echo, + ticks, + }; + }); + +export const loadDesktopIpcPocSnapshotFromBrowser = ( + options: Omit = {}, +) => Effect.runPromise(Effect.scoped(loadDesktopIpcPocSnapshot(options))); + +// ----------------------------------------------------------------------------- +// example/browser-atoms.ts +// ----------------------------------------------------------------------------- + +const DESKTOP_IPC_POC_SNAPSHOT_STALE_TIME_MS = 5_000; +const DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS = 60_000; + +export const desktopIpcPocClientAtom = Atom.make(makeDesktopIpcPocBrowserClient()).pipe( + Atom.keepAlive, + Atom.withLabel("desktop-ipc-poc:effect-rpc-client"), +); + +export const desktopIpcPocSnapshotAtom = Atom.make( + loadDesktopIpcPocSnapshot({ + echoText: "hello from an Effect Atom", + ticks: 5, + }), +).pipe( + Atom.swr({ + staleTime: DESKTOP_IPC_POC_SNAPSHOT_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS), + Atom.withLabel("desktop-ipc-poc:snapshot"), +); + +export const desktopIpcPocManualEchoAtom = Atom.make( + Effect.gen(function* () { + const client = yield* makeDesktopIpcPocBrowserClient(); + return yield* client[DESKTOP_IPC_POC_METHODS.echo]({ + text: "manual echo from an Atom-backed action", + }); + }), +).pipe(Atom.withLabel("desktop-ipc-poc:manual-echo")); + +// ----------------------------------------------------------------------------- +// example/components/DesktopIpcPocPanel.tsx +// ----------------------------------------------------------------------------- + +function formatAsyncResultError(result: AsyncResult.AsyncResult): string | null { + if (result._tag !== "Failure") { + return null; + } + const error = Cause.squash(result.cause); + return error instanceof Error ? error.message : String(error); +} + +function DesktopIpcPocClientStatus(): ReactElement { + const clientResult = useAtomValue(desktopIpcPocClientAtom); + const isReady = clientResult._tag === "Success"; + const label = isReady + ? "Effect RPC client ready" + : clientResult.waiting + ? "Connecting RPC client" + : "RPC client failed"; + + return {label}; +} + +function RuntimeInfoView(props: { readonly runtimeInfo: DesktopIpcPocRuntimeInfo }): ReactElement { + return ( +

+
App version
+
{props.runtimeInfo.appVersion}
+
Platform
+
{props.runtimeInfo.platform}
+
Transport
+
{props.runtimeInfo.ipcTransport}
+
+ ); +} + +function EchoView(props: { readonly echo: DesktopIpcPocEchoResult }): ReactElement { + return ( +

+ Echoed "{props.echo.text}" at {props.echo.echoedAt} +

+ ); +} + +function TickList(props: { readonly ticks: ReadonlyArray }): ReactElement { + return ( +
    + {props.ticks.map((tick) => ( +
  1. + {tick.sequence}: {tick.label} +
  2. + ))} +
+ ); +} + +function ManualEchoButton(): ReactElement { + const echoResult = useAtomValue(desktopIpcPocManualEchoAtom); + const runEcho = useAtomRefresh(desktopIpcPocManualEchoAtom); + const echo = Option.getOrNull(AsyncResult.value(echoResult)); + const error = formatAsyncResultError(echoResult); + + return ( +
+ + {echo ? : null} + {error ?

{error}

: null} +
+ ); +} + +export function DesktopIpcPocPanel(): ReactElement { + const snapshotResult = useAtomValue(desktopIpcPocSnapshotAtom); + const refreshSnapshot = useAtomRefresh(desktopIpcPocSnapshotAtom); + const snapshot = Option.getOrNull(AsyncResult.value(snapshotResult)); + const error = formatAsyncResultError(snapshotResult); + + return ( +
+
+ +
+ + {error ?

{error}

: null} + {snapshot ? ( +
+ + + + +
+ ) : ( +

Loading desktop RPC data

+ )} +
+ ); +} + +// ----------------------------------------------------------------------------- +// example/renderer.tsx +// ----------------------------------------------------------------------------- + +export function mountDesktopIpcPocReactExample(container: Element): void { + createRoot(container).render( + + + , + ); +} diff --git a/bun.lock b/bun.lock index 77decb032d5..31ec01bf6c7 100644 --- a/bun.lock +++ b/bun.lock @@ -105,6 +105,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", + "effect-electron-ipc": "workspace:*", "lexical": "^0.41.0", "lucide-react": "^0.564.0", "react": "^19.0.0", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 41ce0dcd7b2..aa2c8c2e5ee 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,6 +10,11 @@ "module": "./dist/index.mjs", "types": "./src/index.ts", "exports": { + "./effectElectronIpcPoc": { + "types": "./src/effectElectronIpcPoc.ts", + "import": "./src/effectElectronIpcPoc.ts", + "require": "./src/effectElectronIpcPoc.ts" + }, "./settings": { "types": "./src/settings.ts", "import": "./src/settings.ts", diff --git a/packages/contracts/src/effectElectronIpcPoc.ts b/packages/contracts/src/effectElectronIpcPoc.ts new file mode 100644 index 00000000000..cda3b474b47 --- /dev/null +++ b/packages/contracts/src/effectElectronIpcPoc.ts @@ -0,0 +1,60 @@ +import { Schema } from "effect"; +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; + +export const DESKTOP_IPC_POC_METHODS = { + getRuntimeInfo: "desktop.poc.getRuntimeInfo", + echo: "desktop.poc.echo", + subscribeTicks: "desktop.poc.subscribeTicks", +} as const; + +export const DesktopIpcPocRuntimeInfo = Schema.Struct({ + appVersion: Schema.String, + platform: Schema.String, + ipcTransport: Schema.Literal("electron-ipc"), +}); +export type DesktopIpcPocRuntimeInfo = typeof DesktopIpcPocRuntimeInfo.Type; + +export const DesktopIpcPocEchoInput = Schema.Struct({ + text: Schema.String, +}); +export type DesktopIpcPocEchoInput = typeof DesktopIpcPocEchoInput.Type; + +export const DesktopIpcPocEchoResult = Schema.Struct({ + text: Schema.String, + echoedAt: Schema.String, +}); +export type DesktopIpcPocEchoResult = typeof DesktopIpcPocEchoResult.Type; + +export const DesktopIpcPocSubscribeTicksInput = Schema.Struct({ + take: Schema.Number, +}); +export type DesktopIpcPocSubscribeTicksInput = typeof DesktopIpcPocSubscribeTicksInput.Type; + +export const DesktopIpcPocTick = Schema.Struct({ + sequence: Schema.Number, + label: Schema.String, +}); +export type DesktopIpcPocTick = typeof DesktopIpcPocTick.Type; + +export const DesktopIpcPocGetRuntimeInfoRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.getRuntimeInfo, { + payload: Schema.Struct({}), + success: DesktopIpcPocRuntimeInfo, +}); + +export const DesktopIpcPocEchoRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.echo, { + payload: DesktopIpcPocEchoInput, + success: DesktopIpcPocEchoResult, +}); + +export const DesktopIpcPocSubscribeTicksRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.subscribeTicks, { + payload: DesktopIpcPocSubscribeTicksInput, + success: DesktopIpcPocTick, + stream: true, +}); + +export const DesktopIpcPocRpcGroup = RpcGroup.make( + DesktopIpcPocGetRuntimeInfoRpc, + DesktopIpcPocEchoRpc, + DesktopIpcPocSubscribeTicksRpc, +); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1a3647eb314..a1ae659d4cf 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -19,3 +19,4 @@ export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; export * from "./rpc.ts"; +export * from "./effectElectronIpcPoc.ts"; From 28c62e101b9804fb70c515d90ad91a54307da7b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 11:14:11 -0700 Subject: [PATCH 7/9] Refactor Electron IPC POC to use global bridge - Load the renderer bridge from `globalThis` instead of passing it through helpers - Simplify the snapshot/client APIs and update the test harness accordingly - Align the web example atoms with the new effect-based client wiring --- apps/desktop/src/effectRpcIpcPoc.test.ts | 55 +++++++---- .../effectRpcIpcPoc/example/browser-client.ts | 86 +++++++---------- .../src/effectRpcIpcPoc/example/renderer.ts | 5 +- .../web/src/examples/effectElectronIpcPoc.tsx | 95 ++++++++----------- 4 files changed, 114 insertions(+), 127 deletions(-) diff --git a/apps/desktop/src/effectRpcIpcPoc.test.ts b/apps/desktop/src/effectRpcIpcPoc.test.ts index c69ed8d55cb..4e3ecbda358 100644 --- a/apps/desktop/src/effectRpcIpcPoc.test.ts +++ b/apps/desktop/src/effectRpcIpcPoc.test.ts @@ -11,6 +11,7 @@ import { EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY } from "effect-electron-ipc/ipc import type { EffectElectronIpcMainFrame, EffectElectronIpcMainSource, + EffectElectronIpcRendererBridge, EffectElectronIpcRendererFrame, } from "effect-electron-ipc/ipc"; @@ -28,11 +29,10 @@ describe("effect RPC over Electron IPC proof of concept", () => { now: () => new Date("2026-05-06T12:00:00.000Z"), }); - return yield* loadDesktopIpcPocSnapshot({ - globalObject: ipc.rendererGlobal, - echoText: "hello ipc", - ticks: 3, - }); + return yield* withEffectElectronIpcRendererBridge( + ipc.rendererPort, + loadDesktopIpcPocSnapshot, + ); }), ), ); @@ -44,7 +44,7 @@ describe("effect RPC over Electron IPC proof of concept", () => { ipcTransport: "electron-ipc", }, echo: { - text: "hello ipc", + text: "hello from the renderer", echoedAt: "2026-05-06T12:00:00.000Z", }, ticks: [ @@ -67,13 +67,16 @@ describe("effect RPC over Electron IPC proof of concept", () => { platform: "test-os", }); - const client = yield* makeDesktopIpcPocBrowserClient({ - globalObject: ipc.rendererGlobal, - }); + return yield* withEffectElectronIpcRendererBridge( + ipc.rendererPort, + Effect.gen(function* () { + const client = yield* makeDesktopIpcPocBrowserClient; - return yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ take: 3 }).pipe( - Stream.runCollect, - Effect.map((chunk) => Array.from(chunk)), + return yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ take: 3 }).pipe( + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); + }), ); }), ), @@ -143,10 +146,6 @@ class InMemoryEffectElectronIpc { }, }; - readonly rendererGlobal = { - [EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY]: this.rendererPort, - }; - close(): void { this.closed = true; for (const listener of this.closeListeners) { @@ -154,3 +153,27 @@ class InMemoryEffectElectronIpc { } } } + +const withEffectElectronIpcRendererBridge = ( + bridge: EffectElectronIpcRendererBridge, + effect: Effect.Effect, +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + const globalObject = globalThis as Partial< + Record + >; + const previousBridge = globalObject[EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY]; + globalObject[EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY] = bridge; + + return () => { + if (previousBridge !== undefined) { + globalObject[EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY] = previousBridge; + } else { + delete globalObject[EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY]; + } + }; + }), + () => effect, + (restore) => Effect.sync(restore), + ); diff --git a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts index ac85b7bc320..0b96c6663c7 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts @@ -6,9 +6,7 @@ import { getEffectElectronIpcRendererBridge, makeEffectElectronIpcRendererPort, makeEffectElectronIpcRendererProtocol, - type EffectElectronIpcBrowserGlobal, } from "effect-electron-ipc/client"; -import type { EffectElectronIpcRendererBridge } from "effect-electron-ipc/ipc"; import { DESKTOP_IPC_POC_METHODS, makeDesktopIpcPocClient, @@ -24,52 +22,40 @@ export interface DesktopIpcPocSnapshot { readonly ticks: ReadonlyArray; } -export interface DesktopIpcPocBrowserClientOptions { - readonly bridge?: EffectElectronIpcRendererBridge; - readonly globalObject?: EffectElectronIpcBrowserGlobal; -} - -export interface DesktopIpcPocSnapshotOptions extends DesktopIpcPocBrowserClientOptions { - readonly echoText?: string; - readonly ticks?: number; -} - -export const makeDesktopIpcPocBrowserClient = ( - options: DesktopIpcPocBrowserClientOptions = {}, -): Effect.Effect => - Effect.gen(function* () { - const bridge = options.bridge ?? getEffectElectronIpcRendererBridge(options.globalObject); - const rendererPort = makeEffectElectronIpcRendererPort(bridge); - const rendererProtocol = yield* makeEffectElectronIpcRendererProtocol(rendererPort); - - return yield* makeDesktopIpcPocClient.pipe( - Effect.provideService(RpcClient.Protocol, rendererProtocol), - ); +export const makeDesktopIpcPocBrowserClient: Effect.Effect< + DesktopIpcPocClient, + never, + Scope.Scope +> = Effect.gen(function* () { + const bridge = yield* Effect.sync(() => getEffectElectronIpcRendererBridge()); + const rendererPort = makeEffectElectronIpcRendererPort(bridge); + const rendererProtocol = yield* makeEffectElectronIpcRendererProtocol(rendererPort); + + return yield* makeDesktopIpcPocClient.pipe( + Effect.provideService(RpcClient.Protocol, rendererProtocol), + ); +}); + +export const loadDesktopIpcPocSnapshot: Effect.Effect< + DesktopIpcPocSnapshot, + RpcClientError, + Scope.Scope +> = Effect.gen(function* () { + const client = yield* makeDesktopIpcPocBrowserClient; + const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); + const echo = yield* client[DESKTOP_IPC_POC_METHODS.echo]({ + text: "hello from the renderer", }); - -export const loadDesktopIpcPocSnapshot = ( - options: DesktopIpcPocSnapshotOptions = {}, -): Effect.Effect => - Effect.gen(function* () { - const client = yield* makeDesktopIpcPocBrowserClient(options); - const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); - const echo = yield* client[DESKTOP_IPC_POC_METHODS.echo]({ - text: options.echoText ?? "hello from the renderer", - }); - const ticks = yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ - take: options.ticks ?? 3, - }).pipe( - Stream.runCollect, - Effect.map((chunk) => Array.from(chunk)), - ); - - return { - runtimeInfo, - echo, - ticks, - }; - }); - -export const loadDesktopIpcPocSnapshotFromBrowser = ( - options: Omit = {}, -) => Effect.runPromise(Effect.scoped(loadDesktopIpcPocSnapshot(options))); + const ticks = yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ + take: 3, + }).pipe( + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); + + return { + runtimeInfo, + echo, + ticks, + }; +}); diff --git a/apps/desktop/src/effectRpcIpcPoc/example/renderer.ts b/apps/desktop/src/effectRpcIpcPoc/example/renderer.ts index 10b19be2099..892f1fb0419 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/renderer.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/renderer.ts @@ -3,10 +3,7 @@ import { Effect } from "effect"; import { loadDesktopIpcPocSnapshot } from "./browser-client.ts"; const program = Effect.gen(function* () { - const snapshot = yield* loadDesktopIpcPocSnapshot({ - echoText: "hello from the renderer", - ticks: 5, - }); + const snapshot = yield* loadDesktopIpcPocSnapshot; const root = document.querySelector("#root"); if (root) { diff --git a/apps/web/src/examples/effectElectronIpcPoc.tsx b/apps/web/src/examples/effectElectronIpcPoc.tsx index 6d58be64c7f..55d8a511259 100644 --- a/apps/web/src/examples/effectElectronIpcPoc.tsx +++ b/apps/web/src/examples/effectElectronIpcPoc.tsx @@ -14,9 +14,7 @@ import { getEffectElectronIpcRendererBridge, makeEffectElectronIpcRendererPort, makeEffectElectronIpcRendererProtocol, - type EffectElectronIpcBrowserGlobal, } from "effect-electron-ipc/client"; -import type { EffectElectronIpcRendererBridge } from "effect-electron-ipc/ipc"; import { createRoot } from "react-dom/client"; import type { ReactElement } from "react"; @@ -64,55 +62,43 @@ export interface DesktopIpcPocSnapshot { readonly ticks: ReadonlyArray; } -export interface DesktopIpcPocBrowserClientOptions { - readonly bridge?: EffectElectronIpcRendererBridge; - readonly globalObject?: EffectElectronIpcBrowserGlobal; -} - -export interface DesktopIpcPocSnapshotOptions extends DesktopIpcPocBrowserClientOptions { - readonly echoText?: string; - readonly ticks?: number; -} - -export const makeDesktopIpcPocBrowserClient = ( - options: DesktopIpcPocBrowserClientOptions = {}, -): Effect.Effect => - Effect.gen(function* () { - const bridge = options.bridge ?? getEffectElectronIpcRendererBridge(options.globalObject); - const rendererPort = makeEffectElectronIpcRendererPort(bridge); - const rendererProtocol = yield* makeEffectElectronIpcRendererProtocol(rendererPort); - - return yield* makeDesktopIpcPocClient.pipe( - Effect.provideService(RpcClient.Protocol, rendererProtocol), - ); - }); - -export const loadDesktopIpcPocSnapshot = ( - options: DesktopIpcPocSnapshotOptions = {}, -): Effect.Effect => - Effect.gen(function* () { - const client = yield* makeDesktopIpcPocBrowserClient(options); - const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); - const echo = yield* client[DESKTOP_IPC_POC_METHODS.echo]({ - text: options.echoText ?? "hello from the renderer", - }); - const ticks = yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ - take: options.ticks ?? 3, - }).pipe( - Stream.runCollect, - Effect.map((chunk) => Array.from(chunk)), - ); - - return { - runtimeInfo, - echo, - ticks, - }; +export const makeDesktopIpcPocBrowserClient: Effect.Effect< + DesktopIpcPocClient, + never, + Scope.Scope +> = Effect.gen(function* () { + const bridge = yield* Effect.sync(() => getEffectElectronIpcRendererBridge()); + const rendererPort = makeEffectElectronIpcRendererPort(bridge); + const rendererProtocol = yield* makeEffectElectronIpcRendererProtocol(rendererPort); + + return yield* makeDesktopIpcPocClient.pipe( + Effect.provideService(RpcClient.Protocol, rendererProtocol), + ); +}); + +export const loadDesktopIpcPocSnapshot: Effect.Effect< + DesktopIpcPocSnapshot, + RpcClientError, + Scope.Scope +> = Effect.gen(function* () { + const client = yield* makeDesktopIpcPocBrowserClient; + const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); + const echo = yield* client[DESKTOP_IPC_POC_METHODS.echo]({ + text: "hello from the renderer", }); + const ticks = yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ + take: 3, + }).pipe( + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); -export const loadDesktopIpcPocSnapshotFromBrowser = ( - options: Omit = {}, -) => Effect.runPromise(Effect.scoped(loadDesktopIpcPocSnapshot(options))); + return { + runtimeInfo, + echo, + ticks, + }; +}); // ----------------------------------------------------------------------------- // example/browser-atoms.ts @@ -121,17 +107,12 @@ export const loadDesktopIpcPocSnapshotFromBrowser = ( const DESKTOP_IPC_POC_SNAPSHOT_STALE_TIME_MS = 5_000; const DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS = 60_000; -export const desktopIpcPocClientAtom = Atom.make(makeDesktopIpcPocBrowserClient()).pipe( +export const desktopIpcPocClientAtom = Atom.make(makeDesktopIpcPocBrowserClient).pipe( Atom.keepAlive, Atom.withLabel("desktop-ipc-poc:effect-rpc-client"), ); -export const desktopIpcPocSnapshotAtom = Atom.make( - loadDesktopIpcPocSnapshot({ - echoText: "hello from an Effect Atom", - ticks: 5, - }), -).pipe( +export const desktopIpcPocSnapshotAtom = Atom.make(loadDesktopIpcPocSnapshot).pipe( Atom.swr({ staleTime: DESKTOP_IPC_POC_SNAPSHOT_STALE_TIME_MS, revalidateOnMount: true, @@ -142,7 +123,7 @@ export const desktopIpcPocSnapshotAtom = Atom.make( export const desktopIpcPocManualEchoAtom = Atom.make( Effect.gen(function* () { - const client = yield* makeDesktopIpcPocBrowserClient(); + const client = yield* makeDesktopIpcPocBrowserClient; return yield* client[DESKTOP_IPC_POC_METHODS.echo]({ text: "manual echo from an Atom-backed action", }); From d16cdd00d84cb1164d1a96602c5c763606d92378 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 11:25:52 -0700 Subject: [PATCH 8/9] Add typed Electron IPC RPC errors and AtomRpc client - Add a typed echo error to the IPC contract and server - Migrate the desktop/web POC to AtomRpc-backed queries and mutations - Add coverage for round-tripping app-level RPC failures --- apps/desktop/src/effectRpcIpcPoc.test.ts | 31 ++ .../effectRpcIpcPoc/example/browser-client.ts | 3 +- .../src/effectRpcIpcPoc/example/rpc-server.ts | 21 +- .../web/src/examples/effectElectronIpcPoc.tsx | 291 ++++++++++++------ .../contracts/src/effectElectronIpcPoc.ts | 9 + 5 files changed, 250 insertions(+), 105 deletions(-) diff --git a/apps/desktop/src/effectRpcIpcPoc.test.ts b/apps/desktop/src/effectRpcIpcPoc.test.ts index 4e3ecbda358..74039511db1 100644 --- a/apps/desktop/src/effectRpcIpcPoc.test.ts +++ b/apps/desktop/src/effectRpcIpcPoc.test.ts @@ -88,6 +88,37 @@ describe("effect RPC over Electron IPC proof of concept", () => { { sequence: 3, label: "tick:3" }, ]); }); + + it("round-trips typed app-level RPC errors", async () => { + const ipc = new InMemoryEffectElectronIpc(); + + const error = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* runDesktopIpcPocRpcServer({ + port: ipc.mainPort, + appVersion: "0.0.0-test", + platform: "test-os", + }); + + return yield* withEffectElectronIpcRendererBridge( + ipc.rendererPort, + Effect.gen(function* () { + const client = yield* makeDesktopIpcPocBrowserClient; + + return yield* client[DESKTOP_IPC_POC_METHODS.echo]({ text: "" }).pipe(Effect.flip); + }), + ); + }), + ), + ); + + expect(error).toMatchObject({ + _tag: "DesktopIpcPocEchoError", + reason: "empty-text", + message: "Echo text cannot be empty.", + }); + }); }); class InMemoryEffectElectronIpc { diff --git a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts index 0b96c6663c7..86038b8484b 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts @@ -11,6 +11,7 @@ import { DESKTOP_IPC_POC_METHODS, makeDesktopIpcPocClient, type DesktopIpcPocClient, + type DesktopIpcPocEchoError, type DesktopIpcPocEchoResult, type DesktopIpcPocRuntimeInfo, type DesktopIpcPocTick, @@ -38,7 +39,7 @@ export const makeDesktopIpcPocBrowserClient: Effect.Effect< export const loadDesktopIpcPocSnapshot: Effect.Effect< DesktopIpcPocSnapshot, - RpcClientError, + DesktopIpcPocEchoError | RpcClientError, Scope.Scope > = Effect.gen(function* () { const client = yield* makeDesktopIpcPocBrowserClient; diff --git a/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts b/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts index b6138f1729f..b3078c8e321 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts @@ -3,7 +3,11 @@ import { RpcServer } from "effect/unstable/rpc"; import type { EffectElectronIpcMainPort } from "effect-electron-ipc/ipc"; import { makeEffectElectronIpcMainProtocol } from "effect-electron-ipc/main"; -import { DESKTOP_IPC_POC_METHODS, DesktopIpcPocRpcGroup } from "./protocol.ts"; +import { + DESKTOP_IPC_POC_METHODS, + DesktopIpcPocEchoError, + DesktopIpcPocRpcGroup, +} from "./protocol.ts"; export interface DesktopIpcPocMainOptions { readonly port: EffectElectronIpcMainPort; @@ -24,10 +28,17 @@ export const makeDesktopIpcPocHandlersLayer = (options: DesktopIpcPocMainOptions ipcTransport: "electron-ipc" as const, }), [DESKTOP_IPC_POC_METHODS.echo]: (input) => - Effect.sync(() => ({ - text: input.text, - echoedAt: now().toISOString(), - })), + input.text.trim().length === 0 + ? Effect.fail( + new DesktopIpcPocEchoError({ + reason: "empty-text", + message: "Echo text cannot be empty.", + }), + ) + : Effect.sync(() => ({ + text: input.text, + echoedAt: now().toISOString(), + })), [DESKTOP_IPC_POC_METHODS.subscribeTicks]: (input) => Stream.fromIterable( Array.from({ length: Math.max(0, Math.floor(input.take)) }, (_, index) => ({ diff --git a/apps/web/src/examples/effectElectronIpcPoc.tsx b/apps/web/src/examples/effectElectronIpcPoc.tsx index 55d8a511259..3adf8040911 100644 --- a/apps/web/src/examples/effectElectronIpcPoc.tsx +++ b/apps/web/src/examples/effectElectronIpcPoc.tsx @@ -1,22 +1,22 @@ -import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { DESKTOP_IPC_POC_METHODS, DesktopIpcPocRpcGroup, + type DesktopIpcPocEchoError, type DesktopIpcPocEchoResult, type DesktopIpcPocRuntimeInfo, type DesktopIpcPocTick, } from "@t3tools/contracts/effectElectronIpcPoc"; -import { Cause, Effect, Option, Scope, Stream } from "effect"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { RpcClient } from "effect/unstable/rpc"; +import type { Cause } from "effect"; +import { AsyncResult, Atom, AtomRpc } from "effect/unstable/reactivity"; import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; import { getEffectElectronIpcRendererBridge, + layerEffectElectronIpcRendererProtocol, makeEffectElectronIpcRendererPort, - makeEffectElectronIpcRendererProtocol, } from "effect-electron-ipc/client"; import { createRoot } from "react-dom/client"; -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; @@ -46,15 +46,9 @@ import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; // ----------------------------------------------------------------------------- // example/browser-client.ts // ----------------------------------------------------------------------------- -// preload bridge -> Effect Electron IPC renderer port -// -> Effect RPC RpcClient.Protocol -// -> generated typed DesktopIpcPoc client - -const makeDesktopIpcPocClient = RpcClient.make(DesktopIpcPocRpcGroup); -type DesktopIpcPocClient = - typeof makeDesktopIpcPocClient extends Effect.Effect - ? Client - : never; +// preload bridge -> Effect Electron IPC renderer protocol layer +// -> AtomRpc service +// -> query / mutation atoms with typed success and error values export interface DesktopIpcPocSnapshot { readonly runtimeInfo: DesktopIpcPocRuntimeInfo; @@ -62,43 +56,17 @@ export interface DesktopIpcPocSnapshot { readonly ticks: ReadonlyArray; } -export const makeDesktopIpcPocBrowserClient: Effect.Effect< - DesktopIpcPocClient, - never, - Scope.Scope -> = Effect.gen(function* () { - const bridge = yield* Effect.sync(() => getEffectElectronIpcRendererBridge()); - const rendererPort = makeEffectElectronIpcRendererPort(bridge); - const rendererProtocol = yield* makeEffectElectronIpcRendererProtocol(rendererPort); - - return yield* makeDesktopIpcPocClient.pipe( - Effect.provideService(RpcClient.Protocol, rendererProtocol), - ); -}); - -export const loadDesktopIpcPocSnapshot: Effect.Effect< - DesktopIpcPocSnapshot, - RpcClientError, - Scope.Scope -> = Effect.gen(function* () { - const client = yield* makeDesktopIpcPocBrowserClient; - const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); - const echo = yield* client[DESKTOP_IPC_POC_METHODS.echo]({ - text: "hello from the renderer", - }); - const ticks = yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ - take: 3, - }).pipe( - Stream.runCollect, - Effect.map((chunk) => Array.from(chunk)), - ); - - return { - runtimeInfo, - echo, - ticks, - }; -}); +export class DesktopIpcPocRpcClient extends AtomRpc.Service()( + "desktop-ipc-poc:rpc-client", + { + group: DesktopIpcPocRpcGroup, + protocol: () => { + const bridge = getEffectElectronIpcRendererBridge(); + const rendererPort = makeEffectElectronIpcRendererPort(bridge); + return layerEffectElectronIpcRendererProtocol(rendererPort); + }, + }, +) {} // ----------------------------------------------------------------------------- // example/browser-atoms.ts @@ -107,51 +75,123 @@ export const loadDesktopIpcPocSnapshot: Effect.Effect< const DESKTOP_IPC_POC_SNAPSHOT_STALE_TIME_MS = 5_000; const DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS = 60_000; -export const desktopIpcPocClientAtom = Atom.make(makeDesktopIpcPocBrowserClient).pipe( - Atom.keepAlive, - Atom.withLabel("desktop-ipc-poc:effect-rpc-client"), -); +export const desktopIpcPocRuntimeInfoAtom = DesktopIpcPocRpcClient.query( + DESKTOP_IPC_POC_METHODS.getRuntimeInfo, + {}, + { timeToLive: DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS }, +).pipe(Atom.keepAlive, Atom.withLabel("desktop-ipc-poc:runtime-info")); -export const desktopIpcPocSnapshotAtom = Atom.make(loadDesktopIpcPocSnapshot).pipe( +export const desktopIpcPocEchoAtom = DesktopIpcPocRpcClient.query( + DESKTOP_IPC_POC_METHODS.echo, + { text: "hello from the renderer" }, + { timeToLive: DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS }, +).pipe( Atom.swr({ staleTime: DESKTOP_IPC_POC_SNAPSHOT_STALE_TIME_MS, revalidateOnMount: true, }), Atom.setIdleTTL(DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS), - Atom.withLabel("desktop-ipc-poc:snapshot"), + Atom.withLabel("desktop-ipc-poc:echo-query"), +); + +export const desktopIpcPocTickPullAtom = DesktopIpcPocRpcClient.query( + DESKTOP_IPC_POC_METHODS.subscribeTicks, + { take: 3 }, + { timeToLive: DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS }, +).pipe( + Atom.setIdleTTL(DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS), + Atom.withLabel("desktop-ipc-poc:tick-pull"), ); -export const desktopIpcPocManualEchoAtom = Atom.make( - Effect.gen(function* () { - const client = yield* makeDesktopIpcPocBrowserClient; - return yield* client[DESKTOP_IPC_POC_METHODS.echo]({ - text: "manual echo from an Atom-backed action", - }); +export const desktopIpcPocTicksAtom = Atom.mapResult( + desktopIpcPocTickPullAtom, + (pullResult) => pullResult.items, +).pipe(Atom.withLabel("desktop-ipc-poc:ticks")); + +export const desktopIpcPocSnapshotAtom = Atom.readable((get) => + AsyncResult.all({ + runtimeInfo: get(desktopIpcPocRuntimeInfoAtom), + echo: get(desktopIpcPocEchoAtom), + ticks: get(desktopIpcPocTicksAtom), }), -).pipe(Atom.withLabel("desktop-ipc-poc:manual-echo")); +).pipe( + Atom.setIdleTTL(DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS), + Atom.withLabel("desktop-ipc-poc:snapshot"), +); + +export const desktopIpcPocEchoMutationAtom = DesktopIpcPocRpcClient.mutation( + DESKTOP_IPC_POC_METHODS.echo, +).pipe(Atom.withLabel("desktop-ipc-poc:echo-mutation")); // ----------------------------------------------------------------------------- // example/components/DesktopIpcPocPanel.tsx // ----------------------------------------------------------------------------- -function formatAsyncResultError(result: AsyncResult.AsyncResult): string | null { - if (result._tag !== "Failure") { - return null; +type DesktopIpcPocExpectedError = + | Cause.NoSuchElementError + | DesktopIpcPocEchoError + | RpcClientError; + +function formatDesktopIpcPocError(error: DesktopIpcPocExpectedError): string { + switch (error._tag) { + case "DesktopIpcPocEchoError": + return error.message; + case "NoSuchElementError": + return "The tick stream did not return any items."; + case "RpcClientError": + return `Transport error: ${error.message}`; } - const error = Cause.squash(result.cause); - return error instanceof Error ? error.message : String(error); +} + +function formatDefect(defect: unknown): string { + return defect instanceof Error ? defect.message : String(defect); +} + +function DesktopIpcPocErrorAlert(props: { + readonly error: DesktopIpcPocExpectedError; +}): ReactElement { + return

{formatDesktopIpcPocError(props.error)}

; +} + +function DesktopIpcPocDefectAlert(props: { readonly defect: unknown }): ReactElement { + return

Unexpected defect: {formatDefect(props.defect)}

; +} + +function AsyncResultView(props: { + readonly result: AsyncResult.AsyncResult; + readonly renderSuccess: (value: A) => ReactNode; + readonly renderError: (error: E) => ReactNode; + readonly emptyLabel: string; + readonly waitingLabel: string; +}): ReactElement { + return ( + <> + {AsyncResult.matchWithError(props.result, { + onInitial: (initial) =>

{initial.waiting ? props.waitingLabel : props.emptyLabel}

, + onError: (error) => props.renderError(error), + onDefect: (defect) => , + onSuccess: (success) => props.renderSuccess(success.value), + })} + {props.result.waiting && props.result._tag !== "Initial" ? ( +

Refreshing

+ ) : null} + + ); } function DesktopIpcPocClientStatus(): ReactElement { - const clientResult = useAtomValue(desktopIpcPocClientAtom); - const isReady = clientResult._tag === "Success"; - const label = isReady - ? "Effect RPC client ready" - : clientResult.waiting - ? "Connecting RPC client" - : "RPC client failed"; - - return {label}; + const runtimeResult = useAtomValue(DesktopIpcPocRpcClient.runtime); + + return AsyncResult.matchWithError(runtimeResult, { + onInitial: (initial) => ( + + {initial.waiting ? "Connecting RPC client" : "RPC client idle"} + + ), + onError: () => RPC client failed, + onDefect: (defect) => {formatDefect(defect)}, + onSuccess: () => Effect RPC client ready, + }); } function RuntimeInfoView(props: { readonly runtimeInfo: DesktopIpcPocRuntimeInfo }): ReactElement { @@ -188,18 +228,69 @@ function TickList(props: { readonly ticks: ReadonlyArray }): } function ManualEchoButton(): ReactElement { - const echoResult = useAtomValue(desktopIpcPocManualEchoAtom); - const runEcho = useAtomRefresh(desktopIpcPocManualEchoAtom); - const echo = Option.getOrNull(AsyncResult.value(echoResult)); - const error = formatAsyncResultError(echoResult); + const echoResult = useAtomValue(desktopIpcPocEchoMutationAtom); + const sendEcho = useAtomSet(desktopIpcPocEchoMutationAtom); return (
- - {echo ? : null} - {error ?

{error}

: null} + + } + renderSuccess={(echo) => } + /> +
+ ); +} + +function TickPullButton(): ReactElement { + const tickResult = useAtomValue(desktopIpcPocTickPullAtom); + const pullTicks = useAtomSet(desktopIpcPocTickPullAtom); + const isDone = AsyncResult.isSuccess(tickResult) && tickResult.value.done; + + return ( +
+ + } + renderSuccess={(pullResult) => ( +

+ {pullResult.done ? "Stream completed" : `${pullResult.items.length} ticks received`} +

+ )} + />
); } @@ -207,8 +298,6 @@ function ManualEchoButton(): ReactElement { export function DesktopIpcPocPanel(): ReactElement { const snapshotResult = useAtomValue(desktopIpcPocSnapshotAtom); const refreshSnapshot = useAtomRefresh(desktopIpcPocSnapshotAtom); - const snapshot = Option.getOrNull(AsyncResult.value(snapshotResult)); - const error = formatAsyncResultError(snapshotResult); return (
@@ -218,17 +307,21 @@ export function DesktopIpcPocPanel(): ReactElement { - {error ?

{error}

: null} - {snapshot ? ( -
- - - - -
- ) : ( -

Loading desktop RPC data

- )} + } + renderSuccess={(snapshot) => ( +
+ + + + + +
+ )} + />
); } diff --git a/packages/contracts/src/effectElectronIpcPoc.ts b/packages/contracts/src/effectElectronIpcPoc.ts index cda3b474b47..c64db1f836f 100644 --- a/packages/contracts/src/effectElectronIpcPoc.ts +++ b/packages/contracts/src/effectElectronIpcPoc.ts @@ -26,6 +26,14 @@ export const DesktopIpcPocEchoResult = Schema.Struct({ }); export type DesktopIpcPocEchoResult = typeof DesktopIpcPocEchoResult.Type; +export class DesktopIpcPocEchoError extends Schema.TaggedErrorClass()( + "DesktopIpcPocEchoError", + { + reason: Schema.Literal("empty-text"), + message: Schema.String, + }, +) {} + export const DesktopIpcPocSubscribeTicksInput = Schema.Struct({ take: Schema.Number, }); @@ -45,6 +53,7 @@ export const DesktopIpcPocGetRuntimeInfoRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.g export const DesktopIpcPocEchoRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.echo, { payload: DesktopIpcPocEchoInput, success: DesktopIpcPocEchoResult, + error: DesktopIpcPocEchoError, }); export const DesktopIpcPocSubscribeTicksRpc = Rpc.make(DESKTOP_IPC_POC_METHODS.subscribeTicks, { From e11dba8fe407cc53708ee9db141fad3f2bf401e0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 11:47:45 -0700 Subject: [PATCH 9/9] Consolidate Electron IPC POC protocol in shared contracts - Move the POC RPC group and methods to `@t3tools/contracts` - Inline the renderer client setup in the IPC test and remove obsolete example files - Update the main window placeholder copy --- apps/desktop/src/effectRpcIpcPoc.test.ts | 49 ++++++++++++--- .../effectRpcIpcPoc/example/browser-client.ts | 62 ------------------- .../src/effectRpcIpcPoc/example/main.ts | 2 +- .../src/effectRpcIpcPoc/example/protocol.ts | 12 ---- .../src/effectRpcIpcPoc/example/renderer.ts | 19 ------ .../src/effectRpcIpcPoc/example/rpc-server.ts | 2 +- 6 files changed, 44 insertions(+), 102 deletions(-) delete mode 100644 apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts delete mode 100644 apps/desktop/src/effectRpcIpcPoc/example/protocol.ts delete mode 100644 apps/desktop/src/effectRpcIpcPoc/example/renderer.ts diff --git a/apps/desktop/src/effectRpcIpcPoc.test.ts b/apps/desktop/src/effectRpcIpcPoc.test.ts index 74039511db1..965b39033c0 100644 --- a/apps/desktop/src/effectRpcIpcPoc.test.ts +++ b/apps/desktop/src/effectRpcIpcPoc.test.ts @@ -1,12 +1,17 @@ import { Effect, Stream } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; import { describe, expect, it } from "vitest"; import { - loadDesktopIpcPocSnapshot, - makeDesktopIpcPocBrowserClient, -} from "./effectRpcIpcPoc/example/browser-client.ts"; + DESKTOP_IPC_POC_METHODS, + DesktopIpcPocRpcGroup, +} from "@t3tools/contracts/effectElectronIpcPoc"; import { runDesktopIpcPocRpcServer } from "./effectRpcIpcPoc/example/rpc-server.ts"; -import { DESKTOP_IPC_POC_METHODS } from "./effectRpcIpcPoc/example/protocol.ts"; +import { + getEffectElectronIpcRendererBridge, + makeEffectElectronIpcRendererPort, + makeEffectElectronIpcRendererProtocol, +} from "effect-electron-ipc/client"; import { EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY } from "effect-electron-ipc/ipc"; import type { EffectElectronIpcMainFrame, @@ -15,6 +20,8 @@ import type { EffectElectronIpcRendererFrame, } from "effect-electron-ipc/ipc"; +const makeDesktopIpcPocClient = RpcClient.make(DesktopIpcPocRpcGroup); + describe("effect RPC over Electron IPC proof of concept", () => { it("runs the end-to-end consumer example over the Electron IPC transport", async () => { const ipc = new InMemoryEffectElectronIpc(); @@ -31,7 +38,25 @@ describe("effect RPC over Electron IPC proof of concept", () => { return yield* withEffectElectronIpcRendererBridge( ipc.rendererPort, - loadDesktopIpcPocSnapshot, + Effect.gen(function* () { + const client = yield* makeTestDesktopIpcPocClient; + const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); + const echo = yield* client[DESKTOP_IPC_POC_METHODS.echo]({ + text: "hello from the renderer", + }); + const ticks = yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ + take: 3, + }).pipe( + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); + + return { + runtimeInfo, + echo, + ticks, + }; + }), ); }), ), @@ -70,7 +95,7 @@ describe("effect RPC over Electron IPC proof of concept", () => { return yield* withEffectElectronIpcRendererBridge( ipc.rendererPort, Effect.gen(function* () { - const client = yield* makeDesktopIpcPocBrowserClient; + const client = yield* makeTestDesktopIpcPocClient; return yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ take: 3 }).pipe( Stream.runCollect, @@ -104,7 +129,7 @@ describe("effect RPC over Electron IPC proof of concept", () => { return yield* withEffectElectronIpcRendererBridge( ipc.rendererPort, Effect.gen(function* () { - const client = yield* makeDesktopIpcPocBrowserClient; + const client = yield* makeTestDesktopIpcPocClient; return yield* client[DESKTOP_IPC_POC_METHODS.echo]({ text: "" }).pipe(Effect.flip); }), @@ -208,3 +233,13 @@ const withEffectElectronIpcRendererBridge = ( () => effect, (restore) => Effect.sync(restore), ); + +const makeTestDesktopIpcPocClient = Effect.gen(function* () { + const bridge = yield* Effect.sync(() => getEffectElectronIpcRendererBridge()); + const rendererPort = makeEffectElectronIpcRendererPort(bridge); + const rendererProtocol = yield* makeEffectElectronIpcRendererProtocol(rendererPort); + + return yield* makeDesktopIpcPocClient.pipe( + Effect.provideService(RpcClient.Protocol, rendererProtocol), + ); +}); diff --git a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts b/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts deleted file mode 100644 index 86038b8484b..00000000000 --- a/apps/desktop/src/effectRpcIpcPoc/example/browser-client.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Effect, Scope, Stream } from "effect"; -import { RpcClient } from "effect/unstable/rpc"; -import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; - -import { - getEffectElectronIpcRendererBridge, - makeEffectElectronIpcRendererPort, - makeEffectElectronIpcRendererProtocol, -} from "effect-electron-ipc/client"; -import { - DESKTOP_IPC_POC_METHODS, - makeDesktopIpcPocClient, - type DesktopIpcPocClient, - type DesktopIpcPocEchoError, - type DesktopIpcPocEchoResult, - type DesktopIpcPocRuntimeInfo, - type DesktopIpcPocTick, -} from "./protocol.ts"; - -export interface DesktopIpcPocSnapshot { - readonly runtimeInfo: DesktopIpcPocRuntimeInfo; - readonly echo: DesktopIpcPocEchoResult; - readonly ticks: ReadonlyArray; -} - -export const makeDesktopIpcPocBrowserClient: Effect.Effect< - DesktopIpcPocClient, - never, - Scope.Scope -> = Effect.gen(function* () { - const bridge = yield* Effect.sync(() => getEffectElectronIpcRendererBridge()); - const rendererPort = makeEffectElectronIpcRendererPort(bridge); - const rendererProtocol = yield* makeEffectElectronIpcRendererProtocol(rendererPort); - - return yield* makeDesktopIpcPocClient.pipe( - Effect.provideService(RpcClient.Protocol, rendererProtocol), - ); -}); - -export const loadDesktopIpcPocSnapshot: Effect.Effect< - DesktopIpcPocSnapshot, - DesktopIpcPocEchoError | RpcClientError, - Scope.Scope -> = Effect.gen(function* () { - const client = yield* makeDesktopIpcPocBrowserClient; - const runtimeInfo = yield* client[DESKTOP_IPC_POC_METHODS.getRuntimeInfo]({}); - const echo = yield* client[DESKTOP_IPC_POC_METHODS.echo]({ - text: "hello from the renderer", - }); - const ticks = yield* client[DESKTOP_IPC_POC_METHODS.subscribeTicks]({ - take: 3, - }).pipe( - Stream.runCollect, - Effect.map((chunk) => Array.from(chunk)), - ); - - return { - runtimeInfo, - echo, - ticks, - }; -}); diff --git a/apps/desktop/src/effectRpcIpcPoc/example/main.ts b/apps/desktop/src/effectRpcIpcPoc/example/main.ts index bf3d7f94f6a..ecf39fade31 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/main.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/main.ts @@ -29,7 +29,7 @@ export const makeMainWindow = Effect.sync(() => { Effect RPC Electron IPC POC -
Renderer bundle would call example/renderer.ts here.
+
Renderer bundle would mount apps/web/src/examples/effectElectronIpcPoc.tsx here.
`)}`, diff --git a/apps/desktop/src/effectRpcIpcPoc/example/protocol.ts b/apps/desktop/src/effectRpcIpcPoc/example/protocol.ts deleted file mode 100644 index 3a5136bfe71..00000000000 --- a/apps/desktop/src/effectRpcIpcPoc/example/protocol.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { DesktopIpcPocRpcGroup } from "@t3tools/contracts/effectElectronIpcPoc"; -import { Effect } from "effect"; -import { RpcClient } from "effect/unstable/rpc"; - -export * from "@t3tools/contracts/effectElectronIpcPoc"; - -export const makeDesktopIpcPocClient = RpcClient.make(DesktopIpcPocRpcGroup); -type DesktopIpcPocClientFactory = typeof makeDesktopIpcPocClient; -export type DesktopIpcPocClient = - DesktopIpcPocClientFactory extends Effect.Effect - ? Client - : never; diff --git a/apps/desktop/src/effectRpcIpcPoc/example/renderer.ts b/apps/desktop/src/effectRpcIpcPoc/example/renderer.ts deleted file mode 100644 index 892f1fb0419..00000000000 --- a/apps/desktop/src/effectRpcIpcPoc/example/renderer.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Effect } from "effect"; - -import { loadDesktopIpcPocSnapshot } from "./browser-client.ts"; - -const program = Effect.gen(function* () { - const snapshot = yield* loadDesktopIpcPocSnapshot; - - const root = document.querySelector("#root"); - if (root) { - root.textContent = JSON.stringify(snapshot, null, 2); - } -}).pipe(Effect.scoped); - -Effect.runPromise(program).catch((error: unknown) => { - const root = document.querySelector("#root"); - if (root) { - root.textContent = error instanceof Error ? error.message : String(error); - } -}); diff --git a/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts b/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts index b3078c8e321..648cde1bef6 100644 --- a/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts +++ b/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts @@ -7,7 +7,7 @@ import { DESKTOP_IPC_POC_METHODS, DesktopIpcPocEchoError, DesktopIpcPocRpcGroup, -} from "./protocol.ts"; +} from "@t3tools/contracts/effectElectronIpcPoc"; export interface DesktopIpcPocMainOptions { readonly port: EffectElectronIpcMainPort;