diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cd20211bfef..b23adccd6b8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -17,6 +17,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", + "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 new file mode 100644 index 00000000000..965b39033c0 --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc.test.ts @@ -0,0 +1,245 @@ +import { Effect, Stream } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import { describe, expect, it } from "vitest"; + +import { + DESKTOP_IPC_POC_METHODS, + DesktopIpcPocRpcGroup, +} from "@t3tools/contracts/effectElectronIpcPoc"; +import { runDesktopIpcPocRpcServer } from "./effectRpcIpcPoc/example/rpc-server.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, + EffectElectronIpcMainSource, + EffectElectronIpcRendererBridge, + 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(); + + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* runDesktopIpcPocRpcServer({ + port: ipc.mainPort, + appVersion: "1.2.3", + platform: "test-os", + now: () => new Date("2026-05-06T12:00:00.000Z"), + }); + + return yield* withEffectElectronIpcRendererBridge( + ipc.rendererPort, + 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, + }; + }), + ); + }), + ), + ); + + expect(result).toEqual({ + runtimeInfo: { + appVersion: "1.2.3", + platform: "test-os", + ipcTransport: "electron-ipc", + }, + echo: { + text: "hello from the renderer", + echoedAt: "2026-05-06T12:00:00.000Z", + }, + ticks: [ + { sequence: 1, label: "tick:1" }, + { sequence: 2, label: "tick:2" }, + { sequence: 3, label: "tick:3" }, + ], + }); + }); + + it("lets browser code consume the generated Effect RPC client directly", async () => { + const ipc = new InMemoryEffectElectronIpc(); + + const ticks = 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* makeTestDesktopIpcPocClient; + + 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" }, + ]); + }); + + 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* makeTestDesktopIpcPocClient; + + 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 { + private readonly mainListeners = new Set< + (source: EffectElectronIpcMainSource, frame: EffectElectronIpcRendererFrame) => void + >(); + private readonly rendererListeners = new Set<(frame: EffectElectronIpcMainFrame) => void>(); + private readonly closeListeners = new Set<() => void>(); + private closed = false; + + readonly source: EffectElectronIpcMainSource = { + 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: EffectElectronIpcMainSource, + frame: EffectElectronIpcRendererFrame, + ) => void, + ) => { + this.mainListeners.add(listener); + return () => { + this.mainListeners.delete(listener); + }; + }, + }; + + readonly rendererPort = { + send: (frame: EffectElectronIpcRendererFrame) => { + queueMicrotask(() => { + for (const listener of this.mainListeners) { + listener(this.source, frame); + } + }); + }, + subscribe: (listener: (frame: EffectElectronIpcMainFrame) => void) => { + this.rendererListeners.add(listener); + return () => { + this.rendererListeners.delete(listener); + }; + }, + }; + + close(): void { + this.closed = true; + for (const listener of this.closeListeners) { + listener(); + } + } +} + +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), + ); + +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/main.ts b/apps/desktop/src/effectRpcIpcPoc/example/main.ts new file mode 100644 index 00000000000..ecf39fade31 --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/example/main.ts @@ -0,0 +1,96 @@ +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 "effect-electron-ipc/main"; +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 mount apps/web/src/examples/effectElectronIpcPoc.tsx here.
+ + + `)}`, + ); + + 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); + } + }; + + 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/preload.ts b/apps/desktop/src/effectRpcIpcPoc/example/preload.ts new file mode 100644 index 00000000000..6125847dc5c --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/example/preload.ts @@ -0,0 +1,8 @@ +import { contextBridge, ipcRenderer } from "electron"; + +import { exposeEffectElectronIpcPreloadBridge } from "effect-electron-ipc/preload"; + +exposeEffectElectronIpcPreloadBridge({ + contextBridge, + ipcRenderer, +}); 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..648cde1bef6 --- /dev/null +++ b/apps/desktop/src/effectRpcIpcPoc/example/rpc-server.ts @@ -0,0 +1,62 @@ +import { Effect, Stream } from "effect"; +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, + DesktopIpcPocEchoError, + DesktopIpcPocRpcGroup, +} from "@t3tools/contracts/effectElectronIpcPoc"; + +export interface DesktopIpcPocMainOptions { + readonly port: EffectElectronIpcMainPort; + 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) => + 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) => ({ + sequence: index + 1, + label: `tick:${index + 1}`, + })), + ), + }), + ); +}; + +export const runDesktopIpcPocRpcServer = (options: DesktopIpcPocMainOptions) => + Effect.gen(function* () { + const mainProtocol = yield* makeEffectElectronIpcMainProtocol(options.port); + + yield* RpcServer.make(DesktopIpcPocRpcGroup).pipe( + Effect.provideService(RpcServer.Protocol, mainProtocol), + Effect.provide(makeDesktopIpcPocHandlersLayer(options)), + Effect.forkScoped, + ); + }); 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..3adf8040911 --- /dev/null +++ b/apps/web/src/examples/effectElectronIpcPoc.tsx @@ -0,0 +1,339 @@ +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 type { Cause } from "effect"; +import { AsyncResult, Atom, AtomRpc } from "effect/unstable/reactivity"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { + getEffectElectronIpcRendererBridge, + layerEffectElectronIpcRendererProtocol, + makeEffectElectronIpcRendererPort, +} from "effect-electron-ipc/client"; +import { createRoot } from "react-dom/client"; +import type { ReactElement, ReactNode } 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 protocol layer +// -> AtomRpc service +// -> query / mutation atoms with typed success and error values + +export interface DesktopIpcPocSnapshot { + readonly runtimeInfo: DesktopIpcPocRuntimeInfo; + readonly echo: DesktopIpcPocEchoResult; + readonly ticks: ReadonlyArray; +} + +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 +// ----------------------------------------------------------------------------- + +const DESKTOP_IPC_POC_SNAPSHOT_STALE_TIME_MS = 5_000; +const DESKTOP_IPC_POC_SNAPSHOT_IDLE_TTL_MS = 60_000; + +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 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: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 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.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 +// ----------------------------------------------------------------------------- + +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}`; + } +} + +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 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 { + 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(desktopIpcPocEchoMutationAtom); + const sendEcho = useAtomSet(desktopIpcPocEchoMutationAtom); + + return ( +
+ + + } + 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`} +

+ )} + /> +
+ ); +} + +export function DesktopIpcPocPanel(): ReactElement { + const snapshotResult = useAtomValue(desktopIpcPocSnapshotAtom); + const refreshSnapshot = useAtomRefresh(desktopIpcPocSnapshotAtom); + + return ( +
+
+ +
+ + } + renderSuccess={(snapshot) => ( +
+ + + + + +
+ )} + /> +
+ ); +} + +// ----------------------------------------------------------------------------- +// example/renderer.tsx +// ----------------------------------------------------------------------------- + +export function mountDesktopIpcPocReactExample(container: Element): void { + createRoot(container).render( + + + , + ); +} diff --git a/bun.lock b/bun.lock index a87ac77094b..31ec01bf6c7 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-ipc": "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", @@ -104,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", @@ -148,7 +150,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "effect": "catalog:", }, @@ -190,6 +192,18 @@ "vitest": "catalog:", }, }, + "packages/effect-electron-ipc": { + "name": "effect-electron-ipc", + "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 +1164,8 @@ "effect-codex-app-server": ["effect-codex-app-server@workspace:packages/effect-codex-app-server"], + "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=="], "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], 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..c64db1f836f --- /dev/null +++ b/packages/contracts/src/effectElectronIpcPoc.ts @@ -0,0 +1,69 @@ +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 class DesktopIpcPocEchoError extends Schema.TaggedErrorClass()( + "DesktopIpcPocEchoError", + { + reason: Schema.Literal("empty-text"), + message: Schema.String, + }, +) {} + +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, + error: DesktopIpcPocEchoError, +}); + +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"; diff --git a/packages/effect-electron-ipc/package.json b/packages/effect-electron-ipc/package.json new file mode 100644 index 00000000000..8de31813dfd --- /dev/null +++ b/packages/effect-electron-ipc/package.json @@ -0,0 +1,38 @@ +{ + "name": "effect-electron-ipc", + "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-ipc/src/client.ts b/packages/effect-electron-ipc/src/client.ts new file mode 100644 index 00000000000..a3504c33124 --- /dev/null +++ b/packages/effect-electron-ipc/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_IPC_RENDERER_BRIDGE_KEY, + type EffectElectronIpcMainFrame, + type EffectElectronIpcRendererBridge, + type EffectElectronIpcRendererPort, +} from "./ipc.ts"; + +export interface EffectElectronIpcBrowserGlobal { + readonly [EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY]?: EffectElectronIpcRendererBridge; +} + +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_IPC_RENDERER_BRIDGE_KEY}`); + } + return bridge; +} + +export const makeEffectElectronIpcRendererPort = ( + bridge: EffectElectronIpcRendererBridge, +): EffectElectronIpcRendererPort => bridge; + +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 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 layerEffectElectronIpcRendererProtocol = (port: EffectElectronIpcRendererPort) => + Layer.effect(RpcClient.Protocol, makeEffectElectronIpcRendererProtocol(port)); diff --git a/packages/effect-electron-ipc/src/ipc.ts b/packages/effect-electron-ipc/src/ipc.ts new file mode 100644 index 00000000000..011acc3bb98 --- /dev/null +++ b/packages/effect-electron-ipc/src/ipc.ts @@ -0,0 +1,72 @@ +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_ELECTRON_IPC_CHANNELS = { + rendererToMain: "effect-electron-ipc:renderer-to-main", + mainToRenderer: "effect-electron-ipc:main-to-renderer", +} as const; + +export const EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY = "effectElectronIpc" as const; + +export interface EffectElectronIpcRendererFrame { + readonly version: 1; + readonly rendererClientId: number; + readonly message: FromClientEncoded; +} + +export interface EffectElectronIpcMainFrame { + readonly version: 1; + readonly rendererClientId: number; + readonly message: FromServerEncoded; +} + +export interface EffectElectronIpcRendererPort { + readonly send: (frame: EffectElectronIpcRendererFrame) => void; + readonly subscribe: (listener: (frame: EffectElectronIpcMainFrame) => void) => () => void; +} + +export type EffectElectronIpcRendererBridge = EffectElectronIpcRendererPort; + +export interface EffectElectronIpcMainSource { + readonly id: number; + readonly send: (frame: EffectElectronIpcMainFrame) => void; + readonly isClosed?: () => boolean; + readonly onClose?: (listener: () => void) => () => void; +} + +export interface EffectElectronIpcMainPort { + readonly subscribe: ( + listener: (source: EffectElectronIpcMainSource, frame: EffectElectronIpcRendererFrame) => void, + ) => () => void; +} + +export function isEffectElectronIpcRendererFrame( + value: unknown, +): value is EffectElectronIpcRendererFrame { + return ( + isRecord(value) && + value.version === 1 && + typeof value.rendererClientId === "number" && + isRecord(value.message) + ); +} + +export function isEffectElectronIpcMainFrame(value: unknown): value is EffectElectronIpcMainFrame { + 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/packages/effect-electron-ipc/src/main.ts b/packages/effect-electron-ipc/src/main.ts new file mode 100644 index 00000000000..093b9f6adf8 --- /dev/null +++ b/packages/effect-electron-ipc/src/main.ts @@ -0,0 +1,263 @@ +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_ELECTRON_IPC_CHANNELS, + type EffectElectronIpcMainFrame, + type EffectElectronIpcMainPort, + type EffectElectronIpcMainSource, + isEffectElectronIpcRendererFrame, +} from "./ipc.ts"; + +export interface ElectronLikeWebContents { + readonly id: number; + readonly send: (channel: string, frame: EffectElectronIpcMainFrame) => 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_ELECTRON_IPC_CHANNELS, +): EffectElectronIpcMainPort { + return { + subscribe: (listener) => { + const wrapped = (event: ElectronLikeIpcMainEvent, frame: unknown) => { + if (!isEffectElectronIpcRendererFrame(frame)) { + return; + } + + const source: EffectElectronIpcMainSource = { + 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 makeEffectElectronIpcMainProtocol = ( + port: EffectElectronIpcMainPort, +): 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: EffectElectronIpcMainSource, + 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 layerEffectElectronIpcMainProtocol = (port: EffectElectronIpcMainPort) => + Layer.effect(RpcServer.Protocol, makeEffectElectronIpcMainProtocol(port)); + +interface MainClientRecord { + readonly key: string; + readonly rendererClientId: number; + readonly source: EffectElectronIpcMainSource; +} + +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/packages/effect-electron-ipc/src/preload.ts b/packages/effect-electron-ipc/src/preload.ts new file mode 100644 index 00000000000..cbc761a2529 --- /dev/null +++ b/packages/effect-electron-ipc/src/preload.ts @@ -0,0 +1,81 @@ +import { + 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: EffectElectronIpcRendererFrame) => void; + readonly on: ( + channel: string, + listener: (event: unknown, frame: unknown) => void, + ) => ElectronLikeIpcRenderer; + readonly off?: ( + channel: string, + listener: (event: unknown, frame: unknown) => void, + ) => ElectronLikeIpcRenderer; + readonly removeListener?: ( + channel: string, + listener: (event: unknown, frame: unknown) => void, + ) => ElectronLikeIpcRenderer; +} + +export interface ElectronLikeContextBridge { + readonly exposeInMainWorld: (apiKey: string, api: EffectElectronIpcRendererBridge) => void; +} + +export function makeEffectElectronIpcPreloadBridge( + electronIpcRenderer: ElectronLikeIpcRenderer, + channels = EFFECT_ELECTRON_IPC_CHANNELS, +): EffectElectronIpcRendererBridge { + return { + send: (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 (isEffectElectronIpcMainFrame(frame)) { + listener(frame); + } + }; + + electronIpcRenderer.on(channels.mainToRenderer, wrapped); + return () => { + removeIpcListener(electronIpcRenderer, channels.mainToRenderer, wrapped); + }; + }, + }; +} + +export function exposeEffectElectronIpcPreloadBridge(options: { + readonly contextBridge: ElectronLikeContextBridge; + readonly ipcRenderer: ElectronLikeIpcRenderer; + readonly globalKey?: string; + readonly channels?: typeof EFFECT_ELECTRON_IPC_CHANNELS; +}): void { + options.contextBridge.exposeInMainWorld( + options.globalKey ?? EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY, + makeEffectElectronIpcPreloadBridge(options.ipcRenderer, options.channels), + ); +} + +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/packages/effect-electron-ipc/src/transport.test.ts b/packages/effect-electron-ipc/src/transport.test.ts new file mode 100644 index 00000000000..b113f14f650 --- /dev/null +++ b/packages/effect-electron-ipc/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 { + EffectElectronIpcMainFrame, + EffectElectronIpcMainSource, + EffectElectronIpcRendererFrame, +} from "./ipc.ts"; +import { + makeEffectElectronIpcRendererPort, + makeEffectElectronIpcRendererProtocol, +} from "./client.ts"; +import { makeEffectElectronIpcMainProtocol } from "./main.ts"; + +const TEST_METHODS = { + echo: "effect-electron-ipc.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 InMemoryEffectElectronIpc(); + + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const mainProtocol = yield* makeEffectElectronIpcMainProtocol(transport.mainPort); + + yield* RpcServer.make(TestRpcGroup).pipe( + Effect.provideService(RpcServer.Protocol, mainProtocol), + Effect.provide(TestHandlersLive), + Effect.forkScoped, + ); + + const rendererProtocol = yield* makeEffectElectronIpcRendererProtocol( + makeEffectElectronIpcRendererPort(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 InMemoryEffectElectronIpc { + private readonly mainListeners = new Set< + (source: EffectElectronIpcMainSource, frame: EffectElectronIpcRendererFrame) => void + >(); + private readonly rendererListeners = new Set<(frame: EffectElectronIpcMainFrame) => void>(); + + readonly source: EffectElectronIpcMainSource = { + id: 1, + send: (frame) => { + queueMicrotask(() => { + for (const listener of this.rendererListeners) { + listener(frame); + } + }); + }, + }; + + readonly mainPort = { + subscribe: ( + listener: ( + source: EffectElectronIpcMainSource, + frame: EffectElectronIpcRendererFrame, + ) => void, + ) => { + this.mainListeners.add(listener); + return () => { + this.mainListeners.delete(listener); + }; + }, + }; + + readonly rendererPort = { + send: (frame: EffectElectronIpcRendererFrame) => { + queueMicrotask(() => { + for (const listener of this.mainListeners) { + listener(this.source, frame); + } + }); + }, + subscribe: (listener: (frame: EffectElectronIpcMainFrame) => void) => { + this.rendererListeners.add(listener); + return () => { + this.rendererListeners.delete(listener); + }; + }, + }; +} diff --git a/packages/effect-electron-ipc/tsconfig.json b/packages/effect-electron-ipc/tsconfig.json new file mode 100644 index 00000000000..2f8e6d4df6d --- /dev/null +++ b/packages/effect-electron-ipc/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"] +}