Skip to content
Closed
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
2 changes: 2 additions & 0 deletions apps/content/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export default defineConfig({
items: [
{ text: 'HTTP', link: '/docs/adapters/http' },
{ text: 'Websocket', link: '/docs/adapters/websocket' },
{ text: 'IPC', link: '/docs/adapters/ipc' },
],
},
{
Expand All @@ -105,6 +106,7 @@ export default defineConfig({
{ text: 'SolidStart', link: '/docs/integrations/solid-start' },
{ text: 'Astro', link: '/docs/integrations/astro' },
{ text: 'React Native', link: '/docs/integrations/react-native' },
{ text: 'Electron IPC', link: '/docs/integrations/electron-ipc' },
],
},
{
Expand Down
16 changes: 16 additions & 0 deletions apps/content/docs/adapters/ipc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: IPC
description: How to use oRPC over IPC?
---

# IPC

oRPC includes built-in support for common IPC protocols, making it easy to expose RPC endpoints in any environment that supports IPC:

| Adapter | Target | |
| -------------- | ------------------------------------------------------------------- | --------------------------------------- |
| `electron-ipc` | [Electron IPC](https://www.electronjs.org/docs/latest/tutorial/ipc) | [docs](/docs/integrations/electron-ipc) |

::: info
Each IPC protocol has its own setup requirements. Refer to the corresponding documentation for detailed instructions.
:::
45 changes: 45 additions & 0 deletions apps/content/docs/integrations/electron-ipc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
title: Electron IPC Integration
description: Integrate oRPC with Electron IPC
---

# Electron IPC Integration

[Electron IPC](https://www.electronjs.org/docs/latest/tutorial/ipc) is a built-in IPC mechanism in Electron that allows you to communicate between the main process and renderer processes. For additional context, refer to the [IPC Adapter](/docs/adapters/ipc) guide.

## Basic

1. **main script**: Create and upgrade an oRPC handler.

```ts
import { app } from 'electron'
import { experimental_RPCHandler as RPCHandler } from '@orpc/server/electron-ipc'

const handler = new RPCHandler(router)

app.whenReady().then(() => {
handler.upgrade({
context: {}, // Provide initial context if needed
})
})
```

2. **preload script**: Expose the oRPC handler channel to the renderer process.

```ts
import { experimental_exposeORPCHandlerChannel as exposeORPCHandlerChannel } from '@orpc/server/electron-ipc'

exposeORPCHandlerChannel()
```

3. **renderer script**: Create an oRPC link and use it to initialize the client.

```ts
import { experimental_RPCLink as RPCLink } from '@orpc/client/electron-ipc'

const link = new RPCLink()
```

:::info
This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side).
:::
2 changes: 2 additions & 0 deletions apps/content/docs/playgrounds.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ featuring pre-configured examples accessible instantly via StackBlitz or local s
| Astro Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/astro) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/astro) |
| Contract-First Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/contract-first) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/contract-first) |
| NestJS Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/nest) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/nest) |
| Electron Playground | | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/electron) |

:::warning
StackBlitz has own limitations, so some features may not work as expected.
Expand All @@ -38,6 +39,7 @@ npx degit unnoq/orpc/playgrounds/svelte-kit orpc-svelte-kit-playground
npx degit unnoq/orpc/playgrounds/astro orpc-astro-playground
npx degit unnoq/orpc/playgrounds/contract-first orpc-contract-first-playground
npx degit unnoq/orpc/playgrounds/nest orpc-nest-playground
npx degit unnoq/orpc/playgrounds/electron orpc-electron-playground
```

For each project, set up the development environment:
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,13 @@ export default antfu({
}, {
files: ['playgrounds/**'],
rules: {
'node/prefer-global/process': 'off',
'no-alert': 'off',
'eslint-comments/no-unlimited-disable': 'off',
},
}, {
files: ['playgrounds/nest/**'],
rules: {
'node/prefer-global/process': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
},
})
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@
"vite-plugin-solid": "^2.11.6",
"vitest": "^3.0.4"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"esbuild"
]
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
},
Expand Down
17 changes: 16 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"types": "./dist/adapters/websocket/index.d.mts",
"import": "./dist/adapters/websocket/index.mjs",
"default": "./dist/adapters/websocket/index.mjs"
},
"./electron-ipc": {
"types": "./dist/adapters/electron-ipc/index.d.mts",
"import": "./dist/adapters/electron-ipc/index.mjs",
"default": "./dist/adapters/electron-ipc/index.mjs"
}
}
},
Expand All @@ -47,7 +52,8 @@
"./plugins": "./src/plugins/index.ts",
"./standard": "./src/adapters/standard/index.ts",
"./fetch": "./src/adapters/fetch/index.ts",
"./websocket": "./src/adapters/websocket/index.ts"
"./websocket": "./src/adapters/websocket/index.ts",
"./electron-ipc": "./src/adapters/electron-ipc/index.ts"
},
"files": [
"dist"
Expand All @@ -57,13 +63,22 @@
"build:watch": "pnpm run build --watch",
"type:check": "tsc -b"
},
"peerDependencies": {
"electron": ">=36.2.0"
},
"peerDependenciesMeta": {
"electron": {
"optional": true
}
},
Comment thread
dinwwwh marked this conversation as resolved.
"dependencies": {
"@orpc/shared": "workspace:*",
"@orpc/standard-server": "workspace:*",
"@orpc/standard-server-fetch": "workspace:*",
"@orpc/standard-server-peer": "workspace:*"
},
"devDependencies": {
"electron": "^36.2.0",
"zod": "^3.25.7"
}
}
1 change: 1 addition & 0 deletions packages/client/src/adapters/electron-ipc/consts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DEFAULT_ORPC_HANDLER_CHANNEL = 'orpc:default'
4 changes: 4 additions & 0 deletions packages/client/src/adapters/electron-ipc/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './consts'
export * from './link-client'
export * from './rpc-link'
export * from './types'
42 changes: 42 additions & 0 deletions packages/client/src/adapters/electron-ipc/link-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { StandardLazyResponse, StandardRequest } from '@orpc/standard-server'
import type { ClientContext, ClientOptions } from '../../types'
import type { StandardLinkClient } from '../standard'
import type { experimental_ExposedORPCHandlerChannel as ExposedORPCHandlerChannel } from './types'
import { ClientPeer } from '@orpc/standard-server-peer'
import { DEFAULT_ORPC_HANDLER_CHANNEL } from './consts'

export interface experimental_LinkElectronIPCClientOptions {
/**
* The channel name exposed by the Electron IPC handler.
*
* @default 'orpc:default'
*/
channel?: string
}

export class experimental_LinkElectronIPCClient<T extends ClientContext> implements StandardLinkClient<T> {
private readonly peer: ClientPeer

constructor(options: experimental_LinkElectronIPCClientOptions = {}) {
const channel = options.channel ?? DEFAULT_ORPC_HANDLER_CHANNEL

const exposed: ExposedORPCHandlerChannel | undefined = (globalThis as any)[channel]

if (!exposed) {
throw new Error(`Cannot find exposed ORPC handler channel at globalThis['${channel}']`)
}

this.peer = new ClientPeer(async (message) => {
exposed.send(message instanceof Blob ? await message.arrayBuffer() : message)
})
Comment thread
dinwwwh marked this conversation as resolved.

exposed.receive((message) => {
this.peer.message(message)
})
}

async call(request: StandardRequest, _options: ClientOptions<T>, _path: readonly string[], _input: unknown): Promise<StandardLazyResponse> {
const response = await this.peer.request(request)
return { ...response, body: () => Promise.resolve(response.body) }
}
}
72 changes: 72 additions & 0 deletions packages/client/src/adapters/electron-ipc/rpc-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { decodeRequestMessage, encodeResponseMessage, MessageType } from '@orpc/standard-server-peer'
import { createORPCClient } from '../../client'
import { experimental_RPCLink as RPCLink } from './rpc-link'

beforeEach(() => {
delete (globalThis as any)['orpc:default']
vi.clearAllMocks()
})

describe('rpcLink', () => {
it('it throw if not find exposed channel', () => {
expect(() => new RPCLink()).toThrow()
})

describe('on success', () => {
const send = vi.fn()
const receive = vi.fn()
; (globalThis as any)['orpc:default'] = {
send,
receive,
}
const link = new RPCLink()
const orpc = createORPCClient(link) as any

const onMessage = receive.mock.calls[0]![0]

it('with text', async () => {
expect(orpc.ping('input')).resolves.toEqual('pong')

await new Promise(resolve => setTimeout(resolve, 0))

const [id, , payload] = (await decodeRequestMessage(send.mock.calls[0]![0]))

expect(id).toBeTypeOf('number')
expect(payload).toEqual({
url: new URL('orpc:/ping'),
body: { json: 'input' },
headers: {},
method: 'POST',
})

onMessage(await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }))
})

it('with blob', async () => {
const form = new FormData()
form.append('file', new Blob(['hello'], { type: 'text/plain' }))
form.append('text', 'world')

expect(orpc.ping({
file: new Blob(['hello'], { type: 'text/plain' }),
text: 'world',
})).resolves.toEqual('pong')

await new Promise(resolve => setTimeout(resolve, 0))

const [id, , payload] = (await decodeRequestMessage(send.mock.calls[0]![0]))

expect(id).toBeTypeOf('number')
expect(payload).toEqual({
url: new URL('orpc:/ping'),
body: expect.toSatisfy(v => v instanceof FormData && v.get('0') instanceof Blob),
headers: {
'content-type': expect.stringContaining('multipart/form-data'),
},
method: 'POST',
})

onMessage(await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }))
})
})
})
21 changes: 21 additions & 0 deletions packages/client/src/adapters/electron-ipc/rpc-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ClientContext } from '../../types'
import type { StandardRPCLinkOptions } from '../standard'
import type { experimental_LinkElectronIPCClientOptions as LinkElectronIPCClientOptions } from './link-client'
import { StandardRPCLink } from '../standard'
import { experimental_LinkElectronIPCClient as LinkElectronIPCClient } from './link-client'

export interface experimental_RPCLinkOptions<T extends ClientContext>
extends Omit<StandardRPCLinkOptions<T>, 'url' | 'headers' | 'method' | 'fallbackMethod' | 'maxUrlLength'>, LinkElectronIPCClientOptions {}

/**
* The RPC Link communicates with the server using the RPC protocol over Electron IPC.
*
* @see {@link https://orpc.unnoq.com/docs/client/rpc-link RPC Link Docs}
* @see {@link https://orpc.unnoq.com/docs/integrations/electron-ipc Electron IPC Integration Docs}
*/
export class experimental_RPCLink<T extends ClientContext> extends StandardRPCLink<T> {
constructor(options: experimental_RPCLinkOptions<T> = {}) {
const linkClient = new LinkElectronIPCClient(options)
super(linkClient, { ...options, url: 'orpc:/' })
}
}
4 changes: 4 additions & 0 deletions packages/client/src/adapters/electron-ipc/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface experimental_ExposedORPCHandlerChannel {
send(message: string | ArrayBufferLike): void
receive(callback: (message: string | ArrayBufferLike) => void): void
}
Comment thread
dinwwwh marked this conversation as resolved.
13 changes: 12 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@
"types": "./dist/adapters/bun-ws/index.d.mts",
"import": "./dist/adapters/bun-ws/index.mjs",
"default": "./dist/adapters/bun-ws/index.mjs"
},
"./electron-ipc": {
"types": "./dist/adapters/electron-ipc/index.d.mts",
"import": "./dist/adapters/electron-ipc/index.mjs",
"default": "./dist/adapters/electron-ipc/index.mjs"
}
}
},
Expand All @@ -71,7 +76,8 @@
"./websocket": "./src/adapters/websocket/index.ts",
"./crossws": "./src/adapters/crossws/index.ts",
"./ws": "./src/adapters/ws/index.ts",
"./bun-ws": "./src/adapters/bun-ws/index.ts"
"./bun-ws": "./src/adapters/bun-ws/index.ts",
"./electron-ipc": "./src/adapters/electron-ipc/index.ts"
},
"files": [
"dist"
Expand All @@ -83,12 +89,16 @@
},
"peerDependencies": {
"crossws": ">=0.3.4",
"electron": ">=36.2.0",
Comment thread
dinwwwh marked this conversation as resolved.
"ws": ">=8.18.1"
},
"peerDependenciesMeta": {
"crossws": {
"optional": true
},
"electron": {
"optional": true
},
"ws": {
"optional": true
}
Expand All @@ -105,6 +115,7 @@
"devDependencies": {
"@types/ws": "^8.18.1",
"crossws": "^0.3.4",
"electron": "^36.2.0",
"next": "^15.3.0",
"supertest": "^7.1.0",
"ws": "^8.18.1"
Expand Down
33 changes: 33 additions & 0 deletions packages/server/src/adapters/electron-ipc/expose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { contextBridge, ipcRenderer } from 'electron'
import { experimental_exposeORPCHandlerChannel as exposeORPCHandlerChannel } from './expose'

vi.mock('electron', () => ({
contextBridge: {
exposeInMainWorld: vi.fn(),
},
ipcRenderer: {
on: vi.fn(),
send: vi.fn(),
},
}))

beforeEach(() => {
delete (globalThis as any)['orpc:default']
})
Comment thread
dinwwwh marked this conversation as resolved.

it('exposeORPCHandlerChannel', () => {
exposeORPCHandlerChannel()

expect(contextBridge.exposeInMainWorld).toHaveBeenCalledWith('orpc:default', {
send: expect.any(Function),
receive: expect.any(Function),
})

vi.mocked(contextBridge.exposeInMainWorld).mock.calls[0]![1].send('hello')
expect(ipcRenderer.send).toHaveBeenCalledWith('orpc:default', 'hello')

const messages: string[] = []
vi.mocked(contextBridge.exposeInMainWorld).mock.calls[0]![1].receive((message: any) => messages.push(message))
vi.mocked(ipcRenderer.on).mock.calls[0]![1]({} as any, 'hello')
expect(messages).toEqual(['hello'])
})
Loading