diff --git a/apps/content/.vitepress/config.ts b/apps/content/.vitepress/config.ts index c75102dba..95136019b 100644 --- a/apps/content/.vitepress/config.ts +++ b/apps/content/.vitepress/config.ts @@ -87,6 +87,7 @@ export default defineConfig({ items: [ { text: 'HTTP', link: '/docs/adapters/http' }, { text: 'Websocket', link: '/docs/adapters/websocket' }, + { text: 'Message Port', link: '/docs/adapters/message-port' }, ], }, { @@ -105,6 +106,9 @@ 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', link: '/docs/integrations/electron' }, + { text: 'Browser Extension', link: '/docs/integrations/browser-extension' }, + { text: 'Worker Threads', link: '/docs/integrations/worker-threads' }, ], }, { diff --git a/apps/content/docs/adapters/message-port.md b/apps/content/docs/adapters/message-port.md new file mode 100644 index 000000000..afacd73d0 --- /dev/null +++ b/apps/content/docs/adapters/message-port.md @@ -0,0 +1,46 @@ +--- +title: Message Port +description: Using oRPC with Message Ports +--- + +# Message Port + +oRPC offers built-in support for common Message Port implementations, enabling easy internal communication between different processes. + +| Environment | Documentation | +| --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| [Electron Message Port](https://www.electronjs.org/docs/latest/tutorial/message-ports) | [Integration Guide](/docs/integrations/electron) | +| [Browser Extension Long-lived Connections](https://developer.chrome.com/docs/extensions/develop/concepts/messaging#connect) | [Integration Guide](/docs/integrations/browser-extension) | +| [Node.js Worker Threads Port](https://nodejs.org/api/worker_threads.html#workerparentport) | [Integration Guide](/docs/integrations/worker-threads) | + +## Basic Usage + +Message Ports work by establishing two endpoints that can communicate with each other: + +```ts [bridge] +const channel = new MessageChannel() +const serverPort = channel.port1 +const clientPort = channel.port2 +``` + +```ts [server] +import { experimental_RPCHandler as RPCHandler } from '@orpc/server/message-port' + +const handler = new RPCHandler(router) + +handler.upgrade(serverPort, { + context: {}, // Optionally provide an initial context +}) +``` + +```ts [client] +import { experimental_RPCLink as RPCLink } from '@orpc/client/message-port' + +const link = new RPCLink({ + port: clientPort, +}) +``` + +:::info +This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). +::: diff --git a/apps/content/docs/integrations/browser-extension.md b/apps/content/docs/integrations/browser-extension.md new file mode 100644 index 000000000..1ace574ed --- /dev/null +++ b/apps/content/docs/integrations/browser-extension.md @@ -0,0 +1,51 @@ +--- +title: Browser Extension Integration +description: Integrate oRPC with Browser Extensions +--- + +# Browser Extension Integration + +Easily set up type-safe communication between scripts in your browser extension using [Message Port Adapter](/docs/adapters/message-port). Before you begin, it’s recommended to read the [Message Passing Docs](https://developer.chrome.com/docs/extensions/develop/concepts/messaging#connect) + +::: warning +The browser [Message Passing API](https://developer.chrome.com/docs/extensions/develop/concepts/messaging) does not support transferring binary data, which means oRPC features like `File` and `Blob` cannot be used natively. However, you can temporarily work around this limitation by extending the [RPC JSON Serializer](/docs/advanced/rpc-json-serializer#extending-native-data-types) to encode `File` and `Blob` as Base64. +::: + +## Server + +To listen for connections on a port and upgrade the handler: + +```ts +import { experimental_RPCHandler as RPCHandler } from '@orpc/server/message-port' +import { router } from './router' + +const handler = new RPCHandler(router) + +browser.runtime.onConnect.addListener((port) => { + handler.upgrade(port, { + context: {}, // provide initial context if needed + }) +}) +``` + +:::info +Both `browser` and `chrome` namespaces work similarly in this case. You can use whichever one you prefer. +::: + +## Client + +To connect to the port and create an oRPC link on the client side: + +```ts +import { experimental_RPCLink as RPCLink } from '@orpc/client/message-port' + +const port = browser.runtime.connect() + +const link = new RPCLink({ + port, +}) +``` + +:::info +This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). +::: diff --git a/apps/content/docs/integrations/electron.md b/apps/content/docs/integrations/electron.md new file mode 100644 index 000000000..48fa77bfb --- /dev/null +++ b/apps/content/docs/integrations/electron.md @@ -0,0 +1,65 @@ +--- +title: Electron Integration +description: Integrate oRPC with Electron +--- + +# Electron Integration + +Establish type-safe communication between processes in [Electron](https://www.electronjs.org/) using the [Message Port Adapter](/docs/adapters/message-port). Before you start, we recommend reading the [MessagePorts in Electron](https://www.electronjs.org/docs/latest/tutorial/message-ports) guide. + +## Main Process + +Listen for a port sent from the renderer, then upgrade it: + +```ts +import { experimental_RPCHandler as RPCHandler } from '@orpc/server/message-port' +import { router } from './router' + +const handler = new RPCHandler(router) + +app.whenReady().then(() => { + ipcMain.on('start-orpc-server', async (event) => { + const [serverPort] = event.ports + handler.upgrade(serverPort) + serverPort.start() + }) +}) +``` + +:::info +Channel `start-orpc-server` is arbitrary. you can use any name that fits your needs. +::: + +## Preload Process + +Receive the port from the renderer and forward it to the main process: + +```ts +window.addEventListener('message', (event) => { + if (event.data === 'start-orpc-client') { + const [serverPort] = event.ports + + ipcRenderer.postMessage('start-orpc-server', null, [serverPort]) + } +}) +``` + +## Renderer Process + +Create a `MessageChannel`, send one port to the preload script, and use the other to initialize the client link: + +```ts +const { port1: clientPort, port2: serverPort } = new MessageChannel() + +window.postMessage('start-orpc-client', '*', [serverPort]) + +const link = new RPCLink({ + port: clientPort, +}) + +clientPort.start() +``` + +:::info +This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). +::: diff --git a/apps/content/docs/integrations/worker-threads.md b/apps/content/docs/integrations/worker-threads.md new file mode 100644 index 000000000..7af079602 --- /dev/null +++ b/apps/content/docs/integrations/worker-threads.md @@ -0,0 +1,48 @@ +--- +title: Worker Threads Integration +description: Enable type-safe communication between Node.js Worker Threads using oRPC. +--- + +# Worker Threads Integration + +Use [Node.js Worker Threads](https://nodejs.org/api/worker_threads.html) with oRPC for type-safe inter-thread communication via the [Message Port Adapter](/docs/adapters/message-port). Before proceeding, we recommend reviewing the [Node.js Worker Thread API](https://nodejs.org/api/worker_threads.html). + +## Worker Thread + +Listen for a `MessagePort` sent from the main thread and upgrade it: + +```ts +import { parentPort } from 'node:worker_threads' +import { experimental_RPCHandler as RPCHandler } from '@orpc/server/message-port' + +const handler = new RPCHandler(router) + +parentPort.on('message', (message) => { + if (message instanceof MessagePort) { + handler.upgrade(message) + } +}) +``` + +## Main Thread + +Create a `MessageChannel`, send one port to the thread worker, and use the other to initialize the client link: + +```ts +import { MessageChannel, Worker } from 'node:worker_threads' +import { experimental_RPCLink as RPCLink } from '@orpc/client/message-port' + +const { port1: clientPort, port2: serverPort } = new MessageChannel() + +const worker = new Worker('some-worker.js') + +worker.postMessage(serverPort, [serverPort]) + +const link = new RPCLink({ + port: clientPort +}) +``` + +:::info +This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). +::: diff --git a/apps/content/docs/playgrounds.md b/apps/content/docs/playgrounds.md index 0e84551ce..9bde359c6 100644 --- a/apps/content/docs/playgrounds.md +++ b/apps/content/docs/playgrounds.md @@ -10,16 +10,18 @@ featuring pre-configured examples accessible instantly via StackBlitz or local s ## Available Playgrounds -| Environment | StackBlitz | GitHub Source | -| ------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| Next.js Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/next) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/next) | -| TanStack Start Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/tanstack-start) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/tanstack-start) | -| Nuxt.js Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/nuxt) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/nuxt) | -| Solid Start Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/solid-start) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/solid-start) | -| Svelte Kit Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/svelte-kit) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/svelte-kit) | -| 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) | +| Environment | StackBlitz | GitHub Source | +| ---------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Next.js Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/next) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/next) | +| TanStack Start Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/tanstack-start) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/tanstack-start) | +| Nuxt.js Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/nuxt) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/nuxt) | +| Solid Start Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/solid-start) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/solid-start) | +| Svelte Kit Playground | [Open in StackBlitz](https://stackblitz.com/github/unnoq/orpc/tree/main/playgrounds/svelte-kit) | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/svelte-kit) | +| 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) | +| Browser Extension Playground | | [View Source](https://github.com/unnoq/orpc/tree/main/playgrounds/browser-extension) | :::warning StackBlitz has own limitations, so some features may not work as expected. @@ -38,6 +40,8 @@ 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 +npx degit unnoq/orpc/playgrounds/browser-extension orpc-browser-extension-playground ``` For each project, set up the development environment: diff --git a/eslint.config.js b/eslint.config.js index b3fa1d5ea..d61b665c3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -58,11 +58,11 @@ export default antfu({ rules: { 'no-alert': 'off', 'eslint-comments/no-unlimited-disable': 'off', + 'node/prefer-global/process': 'off', }, }, { files: ['playgrounds/nest/**'], rules: { - 'node/prefer-global/process': 'off', '@typescript-eslint/consistent-type-imports': 'off', }, }) diff --git a/package.json b/package.json index 8c0a680de..c9c982afa 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/packages/client/package.json b/packages/client/package.json index b7846f954..c289d8177 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -39,6 +39,11 @@ "types": "./dist/adapters/websocket/index.d.mts", "import": "./dist/adapters/websocket/index.mjs", "default": "./dist/adapters/websocket/index.mjs" + }, + "./message-port": { + "types": "./dist/adapters/message-port/index.d.mts", + "import": "./dist/adapters/message-port/index.mjs", + "default": "./dist/adapters/message-port/index.mjs" } } }, @@ -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", + "./message-port": "./src/adapters/message-port/index.ts" }, "files": [ "dist" diff --git a/packages/client/src/adapters/message-port/index.ts b/packages/client/src/adapters/message-port/index.ts new file mode 100644 index 000000000..ac48244b8 --- /dev/null +++ b/packages/client/src/adapters/message-port/index.ts @@ -0,0 +1,3 @@ +export * from './link-client' +export * from './message-port' +export * from './rpc-link' diff --git a/packages/client/src/adapters/message-port/link-client.ts b/packages/client/src/adapters/message-port/link-client.ts new file mode 100644 index 000000000..66980215c --- /dev/null +++ b/packages/client/src/adapters/message-port/link-client.ts @@ -0,0 +1,33 @@ +import type { StandardLazyResponse, StandardRequest } from '@orpc/standard-server' +import type { ClientContext, ClientOptions } from '../../types' +import type { StandardLinkClient } from '../standard' +import type { SupportedMessagePort } from './message-port' +import { ClientPeer } from '@orpc/standard-server-peer' +import { onMessagePortClose, onMessagePortMessage, postMessagePortMessage } from './message-port' + +export interface experimental_LinkMessagePortClientOptions { + port: SupportedMessagePort +} + +export class experimental_LinkMessagePortClient implements StandardLinkClient { + private readonly peer: ClientPeer + + constructor(options: experimental_LinkMessagePortClientOptions) { + this.peer = new ClientPeer(async (message) => { + postMessagePortMessage(options.port, message instanceof Blob ? await message.arrayBuffer() : message) + }) + + onMessagePortMessage(options.port, async (message) => { + await this.peer.message(message) + }) + + onMessagePortClose(options.port, () => { + this.peer.close() + }) + } + + async call(request: StandardRequest, _options: ClientOptions, _path: readonly string[], _input: unknown): Promise { + const response = await this.peer.request(request) + return { ...response, body: () => Promise.resolve(response.body) } + } +} diff --git a/packages/client/src/adapters/message-port/message-port.test.ts b/packages/client/src/adapters/message-port/message-port.test.ts new file mode 100644 index 000000000..ba3e170d5 --- /dev/null +++ b/packages/client/src/adapters/message-port/message-port.test.ts @@ -0,0 +1,174 @@ +import { onMessagePortClose, onMessagePortMessage, postMessagePortMessage } from './message-port' + +describe('postMessagePortMessage', () => { + it('calls postMessage on the port', () => { + const mockPort = { + addEventListener: vi.fn(), + postMessage: vi.fn(), + } + const data = 'hello' + postMessagePortMessage(mockPort, data) + expect(mockPort.postMessage).toBeCalledTimes(1) + expect(mockPort.postMessage).toHaveBeenCalledWith(data) + }) +}) + +describe('onMessagePortMessage', () => { + it('uses addEventListener if available', () => { + const port = { + addEventListener: vi.fn(), + postMessage: vi.fn(), + } + + const callback = vi.fn() + onMessagePortMessage(port, callback) + + expect(port.addEventListener).toBeCalledTimes(1) + const [event, handler] = port.addEventListener.mock.calls[0]! + expect(event).toBe('message') + + handler({ data: 'test-data' }) + expect(callback).toHaveBeenCalledWith('test-data') + }) + + it('uses on if available', () => { + const port = { + on: vi.fn(), + postMessage: vi.fn(), + } + + const callback = vi.fn() + onMessagePortMessage(port, callback) + + expect(port.on).toBeCalledTimes(1) + const [event, handler] = port.on.mock.calls[0]! + expect(event).toBe('message') + + handler({ data: 'test-data' }) + expect(callback).toHaveBeenCalledWith('test-data') + }) + + it('uses onMessage if available', () => { + const port = { + onMessage: { + addListener: vi.fn(), + }, + onDisconnect: { + addListener: vi.fn(), + }, + postMessage: vi.fn(), + } + + const callback = vi.fn() + onMessagePortMessage(port, callback) + + expect(port.onMessage.addListener).toBeCalledTimes(1) + const listener = port.onMessage.addListener.mock.calls[0]![0] + + listener('test-data') + expect(callback).toHaveBeenCalledWith('test-data') + }) + + it('prefer addEventListener over on', () => { + const port = { + on: vi.fn(), + addEventListener: vi.fn(), + postMessage: vi.fn(), + } + + const callback = vi.fn() + onMessagePortMessage(port, callback) + + expect(port.on).toBeCalledTimes(0) + expect(port.addEventListener).toBeCalledTimes(1) + const [event, handler] = port.addEventListener.mock.calls[0]! + expect(event).toBe('message') + + handler({ data: 'test-data' }) + expect(callback).toHaveBeenCalledWith('test-data') + }) + + it('throws if invalid port', () => { + expect(() => onMessagePortMessage({} as any, () => {})).toThrowError() + }) +}) + +describe('onMessagePortClose', () => { + it('uses addEventListener if available', () => { + const port = { + addEventListener: vi.fn(), + postMessage: vi.fn(), + } + + const callback = vi.fn() + onMessagePortClose(port, callback) + + expect(port.addEventListener).toBeCalledTimes(1) + const [event, handler] = port.addEventListener.mock.calls[0]! + expect(event).toBe('close') + + handler() + expect(callback).toHaveBeenCalled() + }) + + it('uses on if available', () => { + const port = { + on: vi.fn(), + postMessage: vi.fn(), + } + + const callback = vi.fn() + onMessagePortClose(port, callback) + + expect(port.on).toBeCalledTimes(1) + const [event, handler] = port.on.mock.calls[0]! + expect(event).toBe('close') + + handler({}) + expect(callback).toHaveBeenCalled() + }) + + it('uses onDisconnect if available', () => { + const port = { + onMessage: { + addListener: vi.fn(), + }, + onDisconnect: { + addListener: vi.fn(), + }, + postMessage: vi.fn(), + } + + const callback = vi.fn() + onMessagePortClose(port, callback) + + expect(port.onDisconnect.addListener).toBeCalledTimes(1) + const listener = port.onDisconnect.addListener.mock.calls[0]![0] + + listener() + expect(callback).toHaveBeenCalled() + }) + + it('prefer addEventListener over on', () => { + const port = { + on: vi.fn(), + addEventListener: vi.fn(), + postMessage: vi.fn(), + } + + const callback = vi.fn() + onMessagePortClose(port, callback) + + expect(port.on).toBeCalledTimes(0) + expect(port.addEventListener).toBeCalledTimes(1) + const [event, handler] = port.addEventListener.mock.calls[0]! + expect(event).toBe('close') + + handler({}) + expect(callback).toHaveBeenCalled() + }) + + it('throws if invalid port', () => { + expect(() => onMessagePortClose({} as any, () => {})).toThrowError() + }) +}) diff --git a/packages/client/src/adapters/message-port/message-port.ts b/packages/client/src/adapters/message-port/message-port.ts new file mode 100644 index 000000000..18cd763aa --- /dev/null +++ b/packages/client/src/adapters/message-port/message-port.ts @@ -0,0 +1,81 @@ +/** + * The message port used by electron in main process + */ +export interface MessagePortMainLike { + on: (event: T, callback: (event?: { data: any }) => void) => void + postMessage: (data: any) => void +} + +/** + * The message port used by browser extension + */ +export interface BrowserPortLike { + onMessage: { + addListener: (callback: (data: any) => void) => void + } + onDisconnect: { + addListener: (callback: () => void) => void + } + postMessage: (data: any) => void +} + +export type SupportedMessagePort = Pick | MessagePortMainLike | BrowserPortLike + +/** + * Message port can support [The structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) + */ +export type SupportedMessagePortData = string | ArrayBufferLike + +export function postMessagePortMessage(port: SupportedMessagePort, data: SupportedMessagePortData): void { + port.postMessage(data) +} + +export function onMessagePortMessage(port: SupportedMessagePort, callback: (data: SupportedMessagePortData) => void): void { + /** + * MessagePort in node.js might have "on" method but it pass different parameters vs MessagePortMainLike + * So we need check "addEventListener" before "on" + */ + if ('addEventListener' in port) { + port.addEventListener('message', (event) => { + callback(event.data) + }) + } + else if ('on' in port) { + port.on('message', (event) => { + callback(event?.data) + }) + } + else if ('onMessage' in port) { + port.onMessage.addListener((data) => { + callback(data) + }) + } + else { + throw new Error('Cannot find a addEventListener/on/onMessage method on the port') + } +} + +export function onMessagePortClose(port: SupportedMessagePort, callback: () => void): void { + /** + * MessagePort in node.js might have "on" method but it pass different parameters vs MessagePortMainLike + * So we need check "addEventListener" before "on" + */ + if ('addEventListener' in port) { + port.addEventListener('close', async () => { + callback() + }) + } + else if ('on' in port) { + port.on('close', async () => { + callback() + }) + } + else if ('onDisconnect' in port) { + port.onDisconnect.addListener(() => { + callback() + }) + } + else { + throw new Error('Cannot find a addEventListener/on/onDisconnect method on the port') + } +} diff --git a/packages/client/src/adapters/message-port/rpc-link.test.ts b/packages/client/src/adapters/message-port/rpc-link.test.ts new file mode 100644 index 000000000..57202f80c --- /dev/null +++ b/packages/client/src/adapters/message-port/rpc-link.test.ts @@ -0,0 +1,77 @@ +import { MessageChannel } from 'node:worker_threads' +import { decodeRequestMessage, encodeResponseMessage, MessageType } from '@orpc/standard-server-peer' +import { createORPCClient } from '../../client' +import { experimental_RPCLink as RPCLink } from './rpc-link' + +describe('rpcLink', () => { + let orpc: any + let sentMessages: any[] + let clientPort: any + let serverPort: any + + beforeEach(() => { + const channel = new MessageChannel() + clientPort = channel.port1 + serverPort = channel.port2 + + clientPort.start() + serverPort.start() + + sentMessages = [] + serverPort.addEventListener('message', (event: any) => { + sentMessages.push(event.data) + }) + + orpc = createORPCClient(new RPCLink({ + port: clientPort, + })) + }) + + it('on success', async () => { + expect(orpc.ping('input')).resolves.toEqual('pong') + + await new Promise(resolve => setTimeout(resolve, 100)) + + const [id, , payload] = (await decodeRequestMessage(sentMessages[0])) + + expect(id).toBeTypeOf('number') + expect(payload).toEqual({ + url: new URL('orpc:/ping'), + body: { json: 'input' }, + headers: {}, + method: 'POST', + }) + + serverPort.postMessage( + await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }), + ) + }) + + it('on success with blob', async () => { + expect(orpc.ping(new Blob(['input']))).resolves.toEqual('pong') + + await new Promise(resolve => setTimeout(resolve, 100)) + + const [id, , payload] = (await decodeRequestMessage(sentMessages[0])) + + expect(id).toBeTypeOf('number') + expect(payload).toEqual({ + url: new URL('orpc:/ping'), + body: expect.any(FormData), + headers: expect.any(Object), + method: 'POST', + }) + + serverPort.postMessage( + await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }), + ) + }) + + it('on close', async () => { + expect(orpc.ping('input')).rejects.toThrow(/aborted/) + + await new Promise(resolve => setTimeout(resolve, 0)) + + serverPort.close() + }) +}) diff --git a/packages/client/src/adapters/message-port/rpc-link.ts b/packages/client/src/adapters/message-port/rpc-link.ts new file mode 100644 index 000000000..69dc204b2 --- /dev/null +++ b/packages/client/src/adapters/message-port/rpc-link.ts @@ -0,0 +1,21 @@ +import type { ClientContext } from '../../types' +import type { StandardRPCLinkOptions } from '../standard' +import type { experimental_LinkMessagePortClientOptions } from './link-client' +import { StandardRPCLink } from '../standard' +import { experimental_LinkMessagePortClient as LinkMessagePortClient } from './link-client' + +export interface experimental_RPCLinkOptions + extends Omit, 'url' | 'headers' | 'method' | 'fallbackMethod' | 'maxUrlLength'>, experimental_LinkMessagePortClientOptions {} + +/** + * The RPC Link for common message port implementations. + * + * @see {@link https://orpc.unnoq.com/docs/client/rpc-link RPC Link Docs} + * @see {@link https://orpc.unnoq.com/docs/adapters/message-port Message Port Adapter Docs} + */ +export class experimental_RPCLink extends StandardRPCLink { + constructor(options: experimental_RPCLinkOptions) { + const linkClient = new LinkMessagePortClient(options) + super(linkClient, { ...options, url: 'orpc:/' }) + } +} diff --git a/packages/server/package.json b/packages/server/package.json index 619e9c3de..e2982f1d5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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" + }, + "./message-port": { + "types": "./dist/adapters/message-port/index.d.mts", + "import": "./dist/adapters/message-port/index.mjs", + "default": "./dist/adapters/message-port/index.mjs" } } }, @@ -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", + "./message-port": "./src/adapters/message-port/index.ts" }, "files": [ "dist" diff --git a/packages/server/src/adapters/message-port/handler.ts b/packages/server/src/adapters/message-port/handler.ts new file mode 100644 index 000000000..543a93aa4 --- /dev/null +++ b/packages/server/src/adapters/message-port/handler.ts @@ -0,0 +1,40 @@ +import type { SupportedMessagePort } from '@orpc/client/message-port' +import type { MaybeOptionalOptions } from '@orpc/shared' +import type { Context } from '../../context' +import type { StandardHandler } from '../standard' +import type { FriendlyStandardHandleOptions } from '../standard/utils' +import { onMessagePortClose, onMessagePortMessage } from '@orpc/client/message-port' +import { resolveMaybeOptionalOptions } from '@orpc/shared' +import { ServerPeer } from '@orpc/standard-server-peer' +import { resolveFriendlyStandardHandleOptions } from '../standard/utils' + +export class experimental_MessagePortHandler { + constructor( + private readonly standardHandler: StandardHandler, + ) { + } + + upgrade(port: SupportedMessagePort, ...rest: MaybeOptionalOptions, 'prefix'>>): void { + const peer = new ServerPeer(async (message) => { + port.postMessage(message instanceof Blob ? await message.arrayBuffer() : message) + }) + + onMessagePortMessage(port, async (message) => { + const [id, request] = await peer.message(message) + + if (!request) { + return + } + + const options = resolveFriendlyStandardHandleOptions(resolveMaybeOptionalOptions(rest)) + + const { response } = await this.standardHandler.handle({ ...request, body: () => Promise.resolve(request.body) }, options) + + await peer.response(id, response ?? { status: 404, headers: {}, body: 'No procedure matched' }) + }) + + onMessagePortClose(port, () => { + peer.close() + }) + } +} diff --git a/packages/server/src/adapters/message-port/index.ts b/packages/server/src/adapters/message-port/index.ts new file mode 100644 index 000000000..97d3dd64c --- /dev/null +++ b/packages/server/src/adapters/message-port/index.ts @@ -0,0 +1,2 @@ +export * from './handler' +export * from './rpc-handler' diff --git a/packages/server/src/adapters/message-port/rpc-handler.test.ts b/packages/server/src/adapters/message-port/rpc-handler.test.ts new file mode 100644 index 000000000..9a6187afd --- /dev/null +++ b/packages/server/src/adapters/message-port/rpc-handler.test.ts @@ -0,0 +1,145 @@ +import { decodeResponseMessage, encodeRequestMessage, MessageType } from '@orpc/standard-server-peer' +import { os } from '../../builder' +import { experimental_RPCHandler as RPCHandler } from './rpc-handler' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('rpcHandler', async () => { + let signal: AbortSignal | undefined + let serverPort: any + let clientPort: any + let sentMessages: any[] + + beforeEach(() => { + signal = undefined + const channel = new MessageChannel() + clientPort = channel.port1 + serverPort = channel.port2 + + sentMessages = [] + clientPort.addEventListener('message', (event: any) => { + sentMessages.push(event.data) + }) + + const handler = new RPCHandler({ + ping: os.handler(async ({ signal: _signal }) => { + signal = _signal! + await new Promise(resolve => setTimeout(resolve, 10)) + return 'pong' + }), + + file: os.handler(async ({ signal: _signal }) => { + signal = _signal! + await new Promise(resolve => setTimeout(resolve, 10)) + return new Blob(['pong']) + }), + }) + + handler.upgrade(serverPort) + }) + + const ping_request_message = await encodeRequestMessage(19, MessageType.REQUEST, { + url: new URL('orpc:/ping'), + body: { json: 'input' }, + headers: {}, + method: 'POST', + }) + + const file_request_message = new TextEncoder().encode(await encodeRequestMessage(19, MessageType.REQUEST, { + url: new URL('orpc:/file'), + body: { json: 'input' }, + headers: {}, + method: 'POST', + }) as string) + + const not_found_request_message = await encodeRequestMessage(19, MessageType.REQUEST, { + url: new URL('orpc:/not-found'), + body: { json: 'input' }, + headers: {}, + method: 'POST', + }) + + const abort_message = await encodeRequestMessage(19, MessageType.ABORT_SIGNAL, undefined) + + it('on success', async () => { + clientPort.postMessage(ping_request_message) + + await new Promise(resolve => setTimeout(resolve, 20)) + + const [id,, payload] = (await decodeResponseMessage(sentMessages[0])) + + expect(id).toBeTypeOf('number') + expect(payload).toEqual({ + status: 200, + headers: {}, + body: { json: 'pong' }, + }) + }) + + it('on success with buffer data', async () => { + clientPort.postMessage(file_request_message) + + await new Promise(resolve => setTimeout(resolve, 100)) + + const [id, , payload] = (await decodeResponseMessage(sentMessages[0])) + + expect(id).toBeTypeOf('number') + expect(payload).toEqual({ + status: 200, + headers: { + 'content-type': expect.any(String), + }, + body: expect.any(FormData), + }) + + expect(await (payload as any).body.get('0').text()).toBe('pong') + }) + + it('on abort signal', async () => { + clientPort.postMessage(ping_request_message) + + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(signal?.aborted).toBe(false) + expect(sentMessages).toHaveLength(0) + + clientPort.postMessage(abort_message) + + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(signal?.aborted).toBe(true) + expect(sentMessages).toHaveLength(0) + }) + + it('on close', async () => { + clientPort.postMessage(ping_request_message) + + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(signal?.aborted).toBe(false) + expect(sentMessages).toHaveLength(0) + + clientPort.close() + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(signal?.aborted).toBe(true) + expect(sentMessages).toHaveLength(0) + }) + + it('on no procedure matched', async () => { + clientPort.postMessage(not_found_request_message) + + await new Promise(resolve => setTimeout(resolve, 100)) + + const [id,, payload] = (await decodeResponseMessage(sentMessages[0])) + + expect(id).toBeTypeOf('number') + expect(payload).toEqual({ + status: 404, + headers: {}, + body: 'No procedure matched', + }) + }) +}) diff --git a/packages/server/src/adapters/message-port/rpc-handler.ts b/packages/server/src/adapters/message-port/rpc-handler.ts new file mode 100644 index 000000000..d64b68622 --- /dev/null +++ b/packages/server/src/adapters/message-port/rpc-handler.ts @@ -0,0 +1,17 @@ +import type { Context } from '../../context' +import type { Router } from '../../router' +import type { StandardRPCHandlerOptions } from '../standard' +import { StandardRPCHandler } from '../standard' +import { experimental_MessagePortHandler as MessagePortHandler } from './handler' + +/** + * RPC Handler for common message port implementations. + * + * @see {@link https://orpc.unnoq.com/docs/rpc-handler RPC Handler Docs} + * @see {@link https://orpc.unnoq.com/docs/adapters/message-port Message Port Adapter Docs} + */ +export class experimental_RPCHandler extends MessagePortHandler { + constructor(router: Router, options: NoInfer> = {}) { + super(new StandardRPCHandler(router, options)) + } +} diff --git a/packages/server/tests/message-port.test.ts b/packages/server/tests/message-port.test.ts new file mode 100644 index 000000000..bea0e040c --- /dev/null +++ b/packages/server/tests/message-port.test.ts @@ -0,0 +1,33 @@ +import type { RouterClient } from '../src' +import { createORPCClient } from '@orpc/client' +import { experimental_RPCLink as RPCLink } from '@orpc/client/message-port' +import { supportedDataTypes } from '../../client/tests/shared' +import { os } from '../src' +import { experimental_RPCHandler as RPCHandler } from '../src/adapters/message-port' + +describe('message port adapter', () => { + const { port1, port2 } = new MessageChannel() + + const procedure = os.handler(({ input }) => input) + + const handler = new RPCHandler(procedure) + handler.upgrade(port1) + + const link = new RPCLink({ + port: port2, + }) + + const client: RouterClient = createORPCClient(link) + + it.each(supportedDataTypes)('supports $name', async ({ value, expected }) => { + expect(await client(value)).toEqual(expected) + }) + + it.each(supportedDataTypes)('supports $name on complex object', async ({ value, expected }) => { + expect(await client({ + value: [value], + })).toEqual({ + value: [expected], + }) + }) +}) diff --git a/playgrounds/browser-extension/.gitignore b/playgrounds/browser-extension/.gitignore new file mode 100644 index 000000000..2e7eca957 --- /dev/null +++ b/playgrounds/browser-extension/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.output +stats.html +stats-*.json +.wxt +web-ext.config.ts + +# Editor directories and files +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/playgrounds/browser-extension/.vscode/settings.json b/playgrounds/browser-extension/.vscode/settings.json new file mode 100644 index 000000000..ad92582bd --- /dev/null +++ b/playgrounds/browser-extension/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} diff --git a/playgrounds/browser-extension/README.md b/playgrounds/browser-extension/README.md new file mode 100644 index 000000000..b5afcf116 --- /dev/null +++ b/playgrounds/browser-extension/README.md @@ -0,0 +1,19 @@ +# ORPC Playground + +This is a playground for [oRPC](https://orpc.unnoq.com) and [WXT](https://wxt.dev/) + +## Getting Started + +First, run the development server: + +```bash +npm run dev +``` + +## Sponsors + +

+ + + +

diff --git a/playgrounds/browser-extension/entrypoints/background/index.ts b/playgrounds/browser-extension/entrypoints/background/index.ts new file mode 100644 index 000000000..b7652b0f2 --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/index.ts @@ -0,0 +1,10 @@ +import { experimental_RPCHandler as RPCHandler } from '@orpc/server/message-port' +import { router } from './router' + +const handler = new RPCHandler(router) + +export default defineBackground(() => { + browser.runtime.onConnect.addListener((port) => { + handler.upgrade(port) + }) +}) diff --git a/playgrounds/browser-extension/entrypoints/background/middlewares/auth.ts b/playgrounds/browser-extension/entrypoints/background/middlewares/auth.ts new file mode 100644 index 000000000..a392694e6 --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/middlewares/auth.ts @@ -0,0 +1,25 @@ +import type { User } from '../schemas/user' +import { os } from '@orpc/server' + +export const requiredAuthMiddleware = os + .$context<{ session?: { user?: User } }>() + .middleware(async ({ context, next }) => { + /** + * Why we should ?? here? + * Because it can avoid `getSession` being called when unnecessary. + * {@link https://orpc.unnoq.com/docs/best-practices/dedupe-middleware} + */ + const session = context.session ?? await getSession() + + if (!session.user) { + throw new Error('UNAUTHORIZED') + } + + return next({ + context: { user: session.user }, + }) + }) + +async function getSession() { + return { user: { id: 'unique', name: 'unnoq', email: 'contact@unnoq.com' } } +} diff --git a/playgrounds/browser-extension/entrypoints/background/middlewares/db.ts b/playgrounds/browser-extension/entrypoints/background/middlewares/db.ts new file mode 100644 index 000000000..9fefd385b --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/middlewares/db.ts @@ -0,0 +1,109 @@ +import type { NewPlanet, Planet, UpdatePlanet } from '../schemas/planet' +import type { User } from '../schemas/user' +import { os } from '@orpc/server' + +export interface DB { + planets: { + find: (id: number) => Promise + list: (limit: number, cursor: number) => Promise + create: (newPlanet: NewPlanet, creator: User) => Promise + update: (updatePlanet: UpdatePlanet) => Promise + } +} + +export const dbProviderMiddleware = os + .$context<{ db?: DB }>() + .middleware(async ({ context, next }) => { + /** + * Why we should ?? here? + * Because it can avoid `createFakeDB` being called when unnecessary. + * {@link https://orpc.unnoq.com/docs/best-practices/dedupe-middleware} + */ + const db: DB = context.db ?? createFakeDB() + + return next({ + context: { + db, + }, + }) + }) + +const planets: Planet[] = [ + { + id: 1, + name: 'Earth', + description: 'The planet Earth', + imageUrl: 'https://picsum.photos/200/300', + creator: { + id: '1', + name: 'John Doe', + email: 'john@doe.com', + }, + }, + { + id: 2, + name: 'Mars', + description: 'The planet Mars', + imageUrl: 'https://picsum.photos/200/300', + creator: { + id: '1', + name: 'John Doe', + email: 'john@doe.com', + }, + }, + { + id: 3, + name: 'Jupiter', + description: 'The planet Jupiter', + imageUrl: 'https://picsum.photos/200/300', + creator: { + id: '1', + name: 'John Doe', + email: 'john@doe.com', + }, + }, +] + +export function createFakeDB(): DB { + return { + planets: { + find: async (id) => { + return planets.find(planet => planet.id === id) + }, + list: async (limit: number, cursor: number) => { + return planets.slice(cursor, cursor + limit) + }, + create: async (newPlanet, creator) => { + const id = planets.length + 1 + const imageUrl = newPlanet.image ? `https://example.com/cdn/${newPlanet.image.name}` : undefined + + const planet: Planet = { + creator, + id, + name: newPlanet.name, + description: newPlanet.description, + imageUrl, + } + + planets.push(planet) + + return planet + }, + update: async (planet) => { + const index = planets.findIndex(p => p.id === planet.id) + + if (index === -1) { + throw new Error('Planet not found') + } + + planets[index] = { + ...planets[index], + ...planet, + imageUrl: planet.image ? `https://example.com/cdn/${planet.image.name}` : planets[index].imageUrl, + } + + return planets[index] + }, + }, + } +} diff --git a/playgrounds/browser-extension/entrypoints/background/middlewares/retry.ts b/playgrounds/browser-extension/entrypoints/background/middlewares/retry.ts new file mode 100644 index 000000000..2b55f01e4 --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/middlewares/retry.ts @@ -0,0 +1,35 @@ +import { os } from '@orpc/server' + +export function retry(options: { times: number }) { + /** + * Best practices for dedupe-middlewares + * {@link https://orpc.unnoq.com/docs/best-practices/dedupe-middleware} + */ + return os + .$context<{ canRetry?: boolean }>() + .middleware(({ context, next }) => { + const canRetry = context.canRetry ?? true + + if (!canRetry) { + return next() + } + + let times = 0 + while (true) { + try { + return next({ + context: { + canRetry: false, + }, + }) + } + catch (e) { + if (times >= options.times) { + throw e + } + + times++ + } + } + }) +} diff --git a/playgrounds/browser-extension/entrypoints/background/orpc.ts b/playgrounds/browser-extension/entrypoints/background/orpc.ts new file mode 100644 index 000000000..82697cbbe --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/orpc.ts @@ -0,0 +1,9 @@ +import { os } from '@orpc/server' +import { dbProviderMiddleware } from './middlewares/db' +import { requiredAuthMiddleware } from './middlewares/auth' + +export const pub = os + .use(dbProviderMiddleware) + +export const authed = pub + .use(requiredAuthMiddleware) diff --git a/playgrounds/browser-extension/entrypoints/background/router/auth.ts b/playgrounds/browser-extension/entrypoints/background/router/auth.ts new file mode 100644 index 000000000..dd2700da9 --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/router/auth.ts @@ -0,0 +1,45 @@ +import { authed, pub } from '../orpc' +import { CredentialSchema, TokenSchema } from '../schemas/auth' +import { NewUserSchema, UserSchema } from '../schemas/user' + +export const signup = pub + .route({ + method: 'POST', + path: '/auth/signup', + summary: 'Sign up a new user', + tags: ['Authentication'], + }) + .input(NewUserSchema) + .output(UserSchema) + .handler(async ({ input, context }) => { + return { + id: '28aa6286-48e9-4f23-adea-3486c86acd55', + email: input.email, + name: input.name, + } + }) + +export const signin = pub + .route({ + method: 'POST', + path: '/auth/signin', + summary: 'Sign in a user', + tags: ['Authentication'], + }) + .input(CredentialSchema) + .output(TokenSchema) + .handler(async ({ input, context }) => { + return { token: 'token' } + }) + +export const me = authed + .route({ + method: 'GET', + path: '/auth/me', + summary: 'Get the current user', + tags: ['Authentication'], + }) + .output(UserSchema) + .handler(async ({ input, context }) => { + return context.user + }) diff --git a/playgrounds/browser-extension/entrypoints/background/router/index.ts b/playgrounds/browser-extension/entrypoints/background/router/index.ts new file mode 100644 index 000000000..76109fce4 --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/router/index.ts @@ -0,0 +1,20 @@ +import { me, signin, signup } from './auth' +import { createPlanet, findPlanet, listPlanets, updatePlanet } from './planet' +import { sse } from './sse' + +export const router = { + auth: { + signup, + signin, + me, + }, + + planet: { + list: listPlanets, + create: createPlanet, + find: findPlanet, + update: updatePlanet, + }, + + sse, +} diff --git a/playgrounds/browser-extension/entrypoints/background/router/planet.ts b/playgrounds/browser-extension/entrypoints/background/router/planet.ts new file mode 100644 index 000000000..9304b3234 --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/router/planet.ts @@ -0,0 +1,98 @@ +import { ORPCError } from '@orpc/server' +import { z } from 'zod' +import { authed, pub } from '../orpc' +import { NewPlanetSchema, PlanetSchema, UpdatePlanetSchema } from '../schemas/planet' +import { retry } from '../middlewares/retry' + +export const listPlanets = pub + .use(retry({ times: 3 })) + .route({ + method: 'GET', + path: '/planets', + summary: 'List all planets', + tags: ['Planets'], + }) + .input( + z.object({ + limit: z.number().int().min(1).max(100).default(10), + cursor: z.number().int().min(0).default(0), + }), + ) + .output(z.array(PlanetSchema)) + .handler(async ({ input, context }) => { + return context.db.planets.list(input.limit, input.cursor) + }) + +export const createPlanet = authed + .route({ + method: 'POST', + path: '/planets', + summary: 'Create a planet', + tags: ['Planets'], + }) + .input(NewPlanetSchema) + .output(PlanetSchema) + .handler(async ({ input, context }) => { + return context.db.planets.create(input, context.user) + }) + +export const findPlanet = pub + .use(retry({ times: 3 })) + .route({ + method: 'GET', + path: '/planets/{id}', + summary: 'Find a planet', + tags: ['Planets'], + }) + .input( + z.object({ + id: z.number().int().min(1), + }), + ) + .output(PlanetSchema) + .handler(async ({ input, context }) => { + const planet = await context.db.planets.find(input.id) + + if (!planet) { + throw new ORPCError('NOT_FOUND', { message: 'Planet not found' }) + } + + return planet + }) + +export const updatePlanet = authed + .route({ + method: 'PUT', + path: '/planets/{id}', + summary: 'Update a planet', + tags: ['Planets'], + }) + .errors({ + NOT_FOUND: { + message: 'Planet not found', + data: z.object({ id: UpdatePlanetSchema.shape.id }), + }, + }) + .input(UpdatePlanetSchema) + .output(PlanetSchema) + .handler(async ({ input, context, errors }) => { + const planet = await context.db.planets.find(input.id) + + if (!planet) { + /** + * 1. Type-Safe Error Handling + * + * {@link https://orpc.unnoq.com/docs/error-handling#type%E2%80%90safe-error-handling} + */ + throw errors.NOT_FOUND({ data: { id: input.id } }) + + /** + * 2. Normal Approach + * + * {@link https://orpc.unnoq.com/docs/error-handling#normal-approach} + */ + // throw new ORPCError('NOT_FOUND', { message: 'Planet not found' }) + } + + return context.db.planets.update(input) + }) diff --git a/playgrounds/browser-extension/entrypoints/background/router/sse.ts b/playgrounds/browser-extension/entrypoints/background/router/sse.ts new file mode 100644 index 000000000..06f0e15e9 --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/router/sse.ts @@ -0,0 +1,22 @@ +import { eventIterator, os } from '@orpc/server' +import { z } from 'zod' + +const MAX_EVENTS = 5 + +export const sse = os + .route({ + method: 'GET', + path: '/sse', + tags: ['SSE'], + summary: 'Server-Sent Events', + }) + .output(eventIterator(z.object({ time: z.date() }))) + .handler(async function* () { + let count = 0 + + while (count < MAX_EVENTS) { + count++ + yield { time: new Date() } + await new Promise(resolve => setTimeout(resolve, 1000)) + } + }) diff --git a/playgrounds/browser-extension/entrypoints/background/schemas/auth.ts b/playgrounds/browser-extension/entrypoints/background/schemas/auth.ts new file mode 100644 index 000000000..5a70d7206 --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/schemas/auth.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const CredentialSchema = z.object({ + email: z.string().email(), + password: z.string(), +}) + +export const TokenSchema = z.object({ + token: z.string(), +}) diff --git a/playgrounds/browser-extension/entrypoints/background/schemas/planet.ts b/playgrounds/browser-extension/entrypoints/background/schemas/planet.ts new file mode 100644 index 000000000..59a86e124 --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/schemas/planet.ts @@ -0,0 +1,28 @@ +import { oz } from '@orpc/zod' +import { z } from 'zod' +import { UserSchema } from './user' + +export type NewPlanet = z.infer +export type UpdatePlanet = z.infer +export type Planet = z.infer + +export const NewPlanetSchema = z.object({ + name: z.string(), + description: z.string().optional(), + image: oz.file().type('image/*').optional(), +}) + +export const UpdatePlanetSchema = z.object({ + id: z.number().int().min(1), + name: z.string(), + description: z.string().optional(), + image: oz.file().type('image/*').optional(), +}) + +export const PlanetSchema = z.object({ + id: z.number().int().min(1), + name: z.string(), + description: z.string().optional(), + imageUrl: z.string().url().optional(), + creator: UserSchema, +}) diff --git a/playgrounds/browser-extension/entrypoints/background/schemas/user.ts b/playgrounds/browser-extension/entrypoints/background/schemas/user.ts new file mode 100644 index 000000000..875534c3c --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/background/schemas/user.ts @@ -0,0 +1,39 @@ +import { oz } from '@orpc/zod' +import { z } from 'zod' + +export type NewUser = z.infer +export type User = z.infer + +export const NewUserSchema = oz.openapi( + z.object({ + name: z.string(), + email: z.string().email(), + password: z.string(), + }), + { + examples: [ + { + name: 'John Doe', + email: 'john@doe.com', + password: '123456', + }, + ], + }, +) + +export const UserSchema = oz.openapi( + z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + }), + { + examples: [ + { + id: '1', + name: 'John Doe', + email: 'john@doe.com', + }, + ], + }, +) diff --git a/playgrounds/browser-extension/entrypoints/content.ts b/playgrounds/browser-extension/entrypoints/content.ts new file mode 100644 index 000000000..d00cedbad --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/content.ts @@ -0,0 +1,6 @@ +export default defineContentScript({ + matches: ['*://*.google.com/*'], + main() { + console.log('Hello content.') + }, +}) diff --git a/playgrounds/browser-extension/entrypoints/popup/App.tsx b/playgrounds/browser-extension/entrypoints/popup/App.tsx new file mode 100644 index 000000000..79119f75d --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/popup/App.tsx @@ -0,0 +1,14 @@ +import { CreatePlanetMutationForm } from './components/orpc-mutation' +import { ListPlanetsQuery } from './components/orpc-query' + +export default function App() { + return ( +
+

ORPC Playground

+
+ +
+ +
+ ) +} diff --git a/playgrounds/browser-extension/entrypoints/popup/components/orpc-mutation.tsx b/playgrounds/browser-extension/entrypoints/popup/components/orpc-mutation.tsx new file mode 100644 index 000000000..11056ec9b --- /dev/null +++ b/playgrounds/browser-extension/entrypoints/popup/components/orpc-mutation.tsx @@ -0,0 +1,58 @@ +import { orpc } from '../lib/orpc' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +export function CreatePlanetMutationForm() { + const queryClient = useQueryClient() + + const { mutate } = useMutation( + orpc.planet.create.mutationOptions({ + onSuccess() { + queryClient.invalidateQueries({ + queryKey: orpc.planet.key(), + }) + }, + onError(error) { + console.error(error) + alert(error.message) + }, + }), + ) + + return ( +
+

oRPC and Tanstack Query | Create Planet example

+ +
{ + e.preventDefault() + const form = new FormData(e.target as HTMLFormElement) + + const name = form.get('name') as string + const description + = (form.get('description') as string | null) ?? undefined + const image = form.get('image') as File | undefined + + mutate({ + name, + description, + image: image && image.size > 0 ? image : undefined, + }) + }} + > + +