Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
"effect-electron-ipc": "workspace:*",
"electron": "40.9.3",
"electron-updater": "^6.6.2"
},
Expand Down
245 changes: 245 additions & 0 deletions apps/desktop/src/effectRpcIpcPoc.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <A, E, R>(
bridge: EffectElectronIpcRendererBridge,
effect: Effect.Effect<A, E, R>,
): Effect.Effect<A, E, R> =>
Effect.acquireUseRelease(
Effect.sync(() => {
const globalObject = globalThis as Partial<
Record<typeof EFFECT_ELECTRON_IPC_RENDERER_BRIDGE_KEY, EffectElectronIpcRendererBridge>
>;
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),
);
});
96 changes: 96 additions & 0 deletions apps/desktop/src/effectRpcIpcPoc/example/main.ts
Original file line number Diff line number Diff line change
@@ -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(`
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Effect RPC Electron IPC POC</title>
</head>
<body>
<main id="root">Renderer bundle would mount apps/web/src/examples/effectElectronIpcPoc.tsx here.</main>
</body>
</html>
`)}`,
);

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<void>((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);
8 changes: 8 additions & 0 deletions apps/desktop/src/effectRpcIpcPoc/example/preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { contextBridge, ipcRenderer } from "electron";

import { exposeEffectElectronIpcPreloadBridge } from "effect-electron-ipc/preload";

exposeEffectElectronIpcPreloadBridge({
contextBridge,
ipcRenderer,
});
Loading
Loading