├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .npmrc ├── .gitignore ├── RELEASE.md ├── assets ├── trpc-chrome-graph.png ├── trpc-chrome-readme.png └── trpc-chrome.svg ├── .npmignore ├── examples └── with-plasmo │ ├── assets │ └── icon.png │ ├── README.md │ ├── tsconfig.json │ ├── src │ ├── background.ts │ └── popup.tsx │ ├── .gitignore │ └── package.json ├── tsconfig.eslint.json ├── .vscode ├── extensions.json └── settings.json ├── tsconfig.build.json ├── jest.config.js ├── prettier.config.js ├── src ├── types │ └── index.ts ├── adapter │ ├── errors.ts │ └── index.ts └── link │ └── index.ts ├── tsconfig.json ├── LICENSE ├── .eslintrc.js ├── package.json ├── test ├── __setup.ts └── webext.test.ts └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jlalmes 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | sign-git-tag=false 2 | message="trpc-chrome v%s" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | adapter/**/* 5 | link/**/* 6 | types/**/* -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | npm run test 2 | npm version {{version}} 3 | npm run build 4 | npm publish {{--tag alpha}} 5 | -------------------------------------------------------------------------------- /assets/trpc-chrome-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlalmes/trpc-chrome/HEAD/assets/trpc-chrome-graph.png -------------------------------------------------------------------------------- /assets/trpc-chrome-readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlalmes/trpc-chrome/HEAD/assets/trpc-chrome-readme.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !adapter/**/* 3 | !link/**/* 4 | !types/**/* 5 | !package.json 6 | !package-lock.json 7 | !LICENSE -------------------------------------------------------------------------------- /examples/with-plasmo/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlalmes/trpc-chrome/HEAD/examples/with-plasmo/assets/icon.png -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*", ".eslintrc.js"], 4 | "exclude": ["node_modules", "adapter", "link", "types"] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "yzhang.markdown-all-in-one", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "outDir": "dist" 6 | }, 7 | "include": ["src"] 8 | } 9 | -------------------------------------------------------------------------------- /examples/with-plasmo/README.md: -------------------------------------------------------------------------------- 1 | # [**`trpc-chrome`**](../../README.md) (with-plasmo) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-chrome` root. 6 | 7 | ```bash 8 | npm install 9 | npm run build 10 | npm run dev -w with-plasmo 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/with-plasmo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "plasmo/templates/tsconfig.base", 3 | "exclude": ["node_modules"], 4 | "include": [".plasmo/**/*", "./**/*.ts", "./**/*.tsx"], 5 | "compilerOptions": { 6 | "strict": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "~*": ["./src/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */ 4 | module.exports = { 5 | preset: 'ts-jest', 6 | testEnvironment: 'node', 7 | rootDir: './test', 8 | setupFiles: ['./__setup.ts'], 9 | snapshotFormat: { 10 | escapeString: true, 11 | printBasicPrototype: true, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type import("prettier").Options */ 4 | module.exports = { 5 | printWidth: 100, 6 | tabWidth: 2, 7 | useTabs: false, 8 | semi: true, 9 | singleQuote: true, 10 | trailingComma: 'all', 11 | endOfLine: 'lf', 12 | importOrder: ['__', '', '^[./]'], 13 | importOrderSeparation: true, 14 | importOrderSortSpecifiers: true, 15 | }; 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout repo 8 | uses: actions/checkout@v2 9 | 10 | - name: Setup node 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: 16 14 | 15 | - name: Install dependencies 16 | run: npm ci 17 | 18 | - name: Run tests 19 | run: npm test 20 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TRPCClientOutgoingMessage, 3 | TRPCErrorResponse, 4 | TRPCRequest, 5 | TRPCResultMessage, 6 | } from '@trpc/server/rpc'; 7 | 8 | export type TRPCChromeRequest = { 9 | trpc: TRPCRequest | TRPCClientOutgoingMessage; 10 | }; 11 | 12 | export type TRPCChromeSuccessResponse = { 13 | trpc: TRPCResultMessage; 14 | }; 15 | 16 | export type TRPCChromeErrorResponse = { 17 | trpc: TRPCErrorResponse; 18 | }; 19 | 20 | export type TRPCChromeResponse = TRPCChromeSuccessResponse | TRPCChromeErrorResponse; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "target": "ES2017", 5 | "module": "commonjs", 6 | "strict": true, 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "skipLibCheck": true, 11 | "allowJs": true, 12 | "checkJs": false, 13 | "moduleResolution": "node", 14 | "esModuleInterop": true, 15 | "removeComments": false, 16 | "noUncheckedIndexedAccess": true, 17 | "importsNotUsedAsValues": "error" 18 | }, 19 | "include": ["src", "test"] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.tabSize": 2, 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": true 7 | }, 8 | "workbench.colorCustomizations": { 9 | "titleBar.activeBackground": "#1a73e8", 10 | "titleBar.inactiveBackground": "#5a98e9" 11 | }, 12 | "[javascript]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "[jsonc]": { 16 | "editor.defaultFormatter": "esbenp.prettier-vscode" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/with-plasmo/src/background.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from '@trpc/server'; 2 | import { createChromeHandler } from 'trpc-chrome/adapter'; 3 | import { z } from 'zod'; 4 | 5 | const t = initTRPC.create({ 6 | isServer: false, 7 | allowOutsideOfServer: true, 8 | }); 9 | 10 | const appRouter = t.router({ 11 | openNewTab: t.procedure.input(z.object({ url: z.string().url() })).mutation(async ({ input }) => { 12 | await chrome.tabs.create({ url: input.url, active: true }); 13 | }), 14 | }); 15 | 16 | export type AppRouter = typeof appRouter; 17 | 18 | createChromeHandler({ router: appRouter }); 19 | -------------------------------------------------------------------------------- /examples/with-plasmo/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | #cache 13 | .turbo 14 | .next 15 | .vercel 16 | 17 | # misc 18 | .DS_Store 19 | *.pem 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | .pnpm-debug.log* 26 | 27 | 28 | # local env files 29 | .env* 30 | 31 | out/ 32 | build/ 33 | dist/ 34 | 35 | # plasmo - https://www.plasmo.com 36 | .plasmo 37 | 38 | # bpp - http://bpp.browser.market/ 39 | keys.json 40 | 41 | # typescript 42 | .tsbuildinfo 43 | -------------------------------------------------------------------------------- /src/adapter/errors.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | 3 | export function getErrorFromUnknown(cause: unknown): TRPCError { 4 | if (cause instanceof Error && cause.name === 'TRPCError') { 5 | return cause as TRPCError; 6 | } 7 | 8 | let errorCause: Error | undefined = undefined; 9 | let stack: string | undefined = undefined; 10 | 11 | if (cause instanceof Error) { 12 | errorCause = cause; 13 | stack = cause.stack; 14 | } 15 | 16 | const error = new TRPCError({ 17 | message: 'Internal server error', 18 | code: 'INTERNAL_SERVER_ERROR', 19 | cause: errorCause, 20 | }); 21 | 22 | if (stack) { 23 | error.stack = stack; 24 | } 25 | 26 | return error; 27 | } 28 | -------------------------------------------------------------------------------- /examples/with-plasmo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-plasmo", 3 | "displayName": "tRPC with Plasmo", 4 | "version": "1.0.0", 5 | "private": true, 6 | "scripts": { 7 | "dev": "plasmo dev", 8 | "build": "plasmo build" 9 | }, 10 | "dependencies": { 11 | "@trpc/client": "^10.3.0", 12 | "@trpc/server": "^10.3.0", 13 | "plasmo": "^0.59.1", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0", 16 | "zod": "^3.19.1" 17 | }, 18 | "devDependencies": { 19 | "@types/chrome": "^0.0.203", 20 | "@types/node": "^18.11.9", 21 | "@types/react": "^18.0.25", 22 | "@types/react-dom": "^18.0.9", 23 | "typescript": "^4.9.3" 24 | }, 25 | "manifest": { 26 | "host_permissions": [ 27 | "http://*/*", 28 | "https://*/*" 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 James Berry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('eslint').Linter.Config} */ 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:import/recommended', 9 | 'plugin:import/typescript', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 12 | 'plugin:promise/recommended', 13 | 'plugin:prettier/recommended', 14 | ], 15 | parser: '@typescript-eslint/parser', 16 | parserOptions: { 17 | ecmaFeatures: { jsx: false }, 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: './tsconfig.eslint.json', 21 | tsconfigRootDir: __dirname, 22 | }, 23 | env: { 24 | node: true, 25 | }, 26 | rules: { 27 | '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }], 28 | '@typescript-eslint/no-unsafe-argument': 'warn', 29 | '@typescript-eslint/no-unsafe-assignment': 'warn', 30 | '@typescript-eslint/no-unsafe-call': 'warn', 31 | '@typescript-eslint/no-unsafe-member-access': 'warn', 32 | '@typescript-eslint/no-unsafe-return': 'warn', 33 | '@typescript-eslint/unbound-method': 'off', 34 | }, 35 | overrides: [ 36 | { 37 | files: ['*.js', '*.jsx', '*.mjs', '*.cjs'], 38 | rules: {}, 39 | }, 40 | ], 41 | }; 42 | -------------------------------------------------------------------------------- /examples/with-plasmo/src/popup.tsx: -------------------------------------------------------------------------------- 1 | import { createTRPCProxyClient } from '@trpc/client'; 2 | import { useRef, useState } from 'react'; 3 | import { chromeLink } from 'trpc-chrome/link'; 4 | 5 | import type { AppRouter } from './background'; 6 | 7 | const port = chrome.runtime.connect(); 8 | const trpc = createTRPCProxyClient({ 9 | links: [chromeLink({ port })], 10 | }); 11 | 12 | function Popup() { 13 | const inputRef = useRef(null); 14 | const [errorMessage, setErrorMessage] = useState(null); 15 | 16 | const onOpenNewTab = async () => { 17 | setErrorMessage(null); 18 | const url = inputRef.current!.value; 19 | try { 20 | await trpc.openNewTab.mutate({ url }); 21 | } catch (error) { 22 | setErrorMessage(error instanceof Error ? error.message : 'Something went wrong'); 23 | } 24 | }; 25 | 26 | return ( 27 |
35 |

Extension using tRPC & Plasmo

36 | 37 | {errorMessage &&

{errorMessage}

} 38 | 39 |
40 | ); 41 | } 42 | 43 | export default Popup; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trpc-chrome", 3 | "version": "1.0.0", 4 | "description": "tRPC adapter for Web Extensions", 5 | "author": "James Berry ", 6 | "private": false, 7 | "license": "MIT", 8 | "keywords": [ 9 | "trpc", 10 | "chrome", 11 | "extension", 12 | "webext", 13 | "webextension" 14 | ], 15 | "homepage": "https://github.com/jlalmes/trpc-chrome", 16 | "repository": "github:jlalmes/trpc-chrome", 17 | "bugs": "https://github.com/jlalmes/trpc-chrome/issues", 18 | "workspaces": [ 19 | ".", 20 | "examples/with-plasmo" 21 | ], 22 | "scripts": { 23 | "test": "tsc --noEmit && jest --verbose", 24 | "build": "rimraf dist && rimraf adapter && rimraf link && rimraf types && tsc -p tsconfig.build.json && mv dist/* . && rimraf dist" 25 | }, 26 | "peerDependencies": { 27 | "@trpc/client": "^10.0.0", 28 | "@trpc/server": "^10.0.0" 29 | }, 30 | "devDependencies": { 31 | "@trivago/prettier-plugin-sort-imports": "^4.0.0", 32 | "@types/chrome": "^0.0.203", 33 | "@types/jest": "^29.2.3", 34 | "@types/node": "^18.11.9", 35 | "@typescript-eslint/eslint-plugin": "^5.44.0", 36 | "@typescript-eslint/parser": "^5.44.0", 37 | "eslint": "^8.28.0", 38 | "eslint-config-prettier": "^8.5.0", 39 | "eslint-plugin-import": "^2.26.0", 40 | "eslint-plugin-prettier": "^4.2.1", 41 | "eslint-plugin-promise": "^6.1.1", 42 | "jest": "^29.3.1", 43 | "jest-environment-jsdom": "^29.3.1", 44 | "prettier": "^2.8.0", 45 | "rimraf": "^3.0.2", 46 | "superjson": "^1.11.0", 47 | "ts-jest": "^29.0.3", 48 | "ts-node": "^10.9.1", 49 | "tslib": "^2.4.1", 50 | "typescript": "^4.9.3", 51 | "zod": "^3.19.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/__setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 4 | 5 | type OnMessageListener = (message: any) => void; 6 | type OnConnectListener = (port: any) => void; 7 | 8 | const getMockChrome = jest.fn(() => { 9 | const linkPortOnMessageListeners: OnMessageListener[] = []; 10 | const handlerPortOnMessageListeners: OnMessageListener[] = []; 11 | const handlerPortOnConnectListeners: OnConnectListener[] = []; 12 | 13 | return { 14 | runtime: { 15 | connect: jest.fn(() => { 16 | const handlerPort = { 17 | postMessage: jest.fn((message) => { 18 | linkPortOnMessageListeners.forEach((listener) => listener(message)); 19 | }), 20 | onMessage: { 21 | addListener: jest.fn((listener) => { 22 | handlerPortOnMessageListeners.push(listener); 23 | }), 24 | removeListener: jest.fn(), 25 | }, 26 | onDisconnect: { 27 | addListener: jest.fn(), 28 | removeListener: jest.fn(), 29 | }, 30 | }; 31 | 32 | const linkPort = { 33 | postMessage: jest.fn((message) => { 34 | handlerPortOnMessageListeners.forEach((listener) => listener(message)); 35 | }), 36 | onMessage: { 37 | addListener: jest.fn((listener) => { 38 | linkPortOnMessageListeners.push(listener); 39 | }), 40 | removeListener: jest.fn(), 41 | }, 42 | onDisconnect: { 43 | addListener: jest.fn(), 44 | removeListener: jest.fn(), 45 | }, 46 | }; 47 | 48 | handlerPortOnConnectListeners.forEach((listener) => listener(handlerPort)); 49 | 50 | return linkPort; 51 | }), 52 | onConnect: { 53 | addListener: jest.fn((listener) => { 54 | handlerPortOnConnectListeners.push(listener); 55 | }), 56 | }, 57 | }, 58 | }; 59 | }); 60 | 61 | export const resetMocks = () => { 62 | // @ts-expect-error mocking chrome 63 | global.chrome = getMockChrome(); 64 | }; 65 | 66 | resetMocks(); 67 | -------------------------------------------------------------------------------- /src/link/index.ts: -------------------------------------------------------------------------------- 1 | import { TRPCClientError, TRPCLink } from '@trpc/client'; 2 | import type { AnyRouter } from '@trpc/server'; 3 | import { observable } from '@trpc/server/observable'; 4 | 5 | import type { TRPCChromeRequest, TRPCChromeResponse } from '../types'; 6 | 7 | export type ChromeLinkOptions = { 8 | port: chrome.runtime.Port; 9 | }; 10 | 11 | export const chromeLink = ( 12 | opts: ChromeLinkOptions, 13 | ): TRPCLink => { 14 | return (runtime) => { 15 | const { port } = opts; 16 | return ({ op }) => { 17 | return observable((observer) => { 18 | const listeners: (() => void)[] = []; 19 | 20 | const { id, type, path } = op; 21 | 22 | try { 23 | const input = runtime.transformer.serialize(op.input); 24 | 25 | const onDisconnect = () => { 26 | observer.error(new TRPCClientError('Port disconnected prematurely')); 27 | }; 28 | 29 | port.onDisconnect.addListener(onDisconnect); 30 | listeners.push(() => port.onDisconnect.removeListener(onDisconnect)); 31 | 32 | const onMessage = (message: TRPCChromeResponse) => { 33 | if (!('trpc' in message)) return; 34 | const { trpc } = message; 35 | if (!trpc) return; 36 | if (!('id' in trpc) || trpc.id === null || trpc.id === undefined) return; 37 | if (id !== trpc.id) return; 38 | 39 | if ('error' in trpc) { 40 | const error = runtime.transformer.deserialize(trpc.error); 41 | observer.error(TRPCClientError.from({ ...trpc, error })); 42 | return; 43 | } 44 | 45 | observer.next({ 46 | result: { 47 | ...trpc.result, 48 | ...((!trpc.result.type || trpc.result.type === 'data') && { 49 | type: 'data', 50 | data: runtime.transformer.deserialize(trpc.result.data), 51 | }), 52 | } as any, 53 | }); 54 | 55 | if (type !== 'subscription' || trpc.result.type === 'stopped') { 56 | observer.complete(); 57 | } 58 | }; 59 | 60 | port.onMessage.addListener(onMessage); 61 | listeners.push(() => port.onMessage.removeListener(onMessage)); 62 | 63 | port.postMessage({ 64 | trpc: { 65 | id, 66 | jsonrpc: undefined, 67 | method: type, 68 | params: { path, input }, 69 | }, 70 | } as TRPCChromeRequest); 71 | } catch (cause) { 72 | observer.error( 73 | new TRPCClientError(cause instanceof Error ? cause.message : 'Unknown error'), 74 | ); 75 | } 76 | 77 | return () => { 78 | listeners.forEach((unsub) => unsub()); 79 | if (type === 'subscription') { 80 | port.postMessage({ 81 | trpc: { 82 | id, 83 | jsonrpc: undefined, 84 | method: 'subscription.stop', 85 | }, 86 | } as TRPCChromeRequest); 87 | } 88 | }; 89 | }); 90 | }; 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![trpc-chrome](assets/trpc-chrome-readme.png) 2 | 3 |
4 |

trpc-chrome

5 | 6 | 7 | 8 |
9 |
10 |
11 | 12 | ## **[Chrome extension](https://developer.chrome.com/docs/extensions/mv3/) support for [tRPC](https://trpc.io/)** 🧩 13 | 14 | - Easy communication for web extensions. 15 | - Typesafe messaging between content & background scripts. 16 | - Ready for Manifest V3. 17 | 18 | ## Usage 19 | 20 | **1. Install `trpc-chrome`.** 21 | 22 | ```bash 23 | # npm 24 | npm install trpc-chrome 25 | # yarn 26 | yarn add trpc-chrome 27 | ``` 28 | 29 | **2. Add `createChromeHandler` in your background script.** 30 | 31 | ```typescript 32 | // background.ts 33 | import { initTRPC } from '@trpc/server'; 34 | import { createChromeHandler } from 'trpc-chrome/adapter'; 35 | 36 | const t = initTRPC.create({ 37 | isServer: false, 38 | allowOutsideOfServer: true, 39 | }); 40 | 41 | const appRouter = t.router({ 42 | // ...procedures 43 | }); 44 | 45 | export type AppRouter = typeof appRouter; 46 | 47 | createChromeHandler({ 48 | router: appRouter /* 👈 */, 49 | }); 50 | ``` 51 | 52 | **3. Add a `chromeLink` to the client in your content script.** 53 | 54 | ```typescript 55 | // content.ts 56 | import { createTRPCClient } from '@trpc/client'; 57 | import { chromeLink } from 'trpc-chrome/link'; 58 | 59 | import type { AppRouter } from './background'; 60 | 61 | const port = chrome.runtime.connect(); 62 | export const chromeClient = createTRPCClient({ 63 | links: [/* 👉 */ chromeLink({ port })], 64 | }); 65 | ``` 66 | 67 | ## Requirements 68 | 69 | Peer dependencies: 70 | 71 | - [`tRPC`](https://github.com/trpc/trpc) Server v10 (`@trpc/server`) must be installed. 72 | - [`tRPC`](https://github.com/trpc/trpc) Client v10 (`@trpc/client`) must be installed. 73 | 74 | ## Example 75 | 76 | Please see [full example here](examples/with-plasmo). 77 | 78 | _For advanced use-cases, please find examples in our [complete test suite](test)._ 79 | 80 | ## Types 81 | 82 | #### ChromeLinkOptions 83 | 84 | Please see [full typings here](src/link/index.ts). 85 | 86 | | Property | Type | Description | Required | 87 | | -------- | --------------------- | ---------------------------------------------------------------- | -------- | 88 | | `port` | `chrome.runtime.Port` | An open web extension port between content & background scripts. | `true` | 89 | 90 | #### CreateChromeHandlerOptions 91 | 92 | Please see [full typings here](src/adapter/index.ts). 93 | 94 | | Property | Type | Description | Required | 95 | | --------------- | ---------- | ------------------------------------------------------ | -------- | 96 | | `router` | `Router` | Your application tRPC router. | `true` | 97 | | `createContext` | `Function` | Passes contextual (`ctx`) data to procedure resolvers. | `false` | 98 | | `onError` | `Function` | Called if error occurs inside handler. | `false` | 99 | 100 | --- 101 | 102 | ## License 103 | 104 | Distributed under the MIT License. See LICENSE for more information. 105 | 106 | ## Contact 107 | 108 | James Berry - Follow me on Twitter [@jlalmes](https://twitter.com/jlalmes) 💙 109 | -------------------------------------------------------------------------------- /test/webext.test.ts: -------------------------------------------------------------------------------- 1 | import { resetMocks } from './__setup'; 2 | 3 | import { createTRPCProxyClient } from '@trpc/client'; 4 | import { initTRPC } from '@trpc/server'; 5 | import { Unsubscribable, observable } from '@trpc/server/observable'; 6 | import { z } from 'zod'; 7 | 8 | import { createChromeHandler } from '../src/adapter'; 9 | import { chromeLink } from '../src/link'; 10 | 11 | afterEach(() => { 12 | resetMocks(); 13 | }); 14 | 15 | const t = initTRPC.create(); 16 | 17 | const appRouter = t.router({ 18 | echoQuery: t.procedure.input(z.object({ payload: z.string() })).query(({ input }) => input), 19 | echoMutation: t.procedure.input(z.object({ payload: z.string() })).mutation(({ input }) => input), 20 | echoSubscription: t.procedure.input(z.object({ payload: z.string() })).subscription(({ input }) => 21 | observable((emit) => { 22 | emit.next(input); 23 | }), 24 | ), 25 | nestedRouter: t.router({ 26 | echoQuery: t.procedure.input(z.object({ payload: z.string() })).query(({ input }) => input), 27 | echoMutation: t.procedure 28 | .input(z.object({ payload: z.string() })) 29 | .mutation(({ input }) => input), 30 | echoSubscription: t.procedure 31 | .input(z.object({ payload: z.string() })) 32 | .subscription(({ input }) => 33 | observable((emit) => { 34 | emit.next(input); 35 | }), 36 | ), 37 | }), 38 | }); 39 | 40 | test('with query', async () => { 41 | // background 42 | createChromeHandler({ router: appRouter }); 43 | expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledTimes(1); 44 | 45 | // content 46 | const port = chrome.runtime.connect(); 47 | const trpc = createTRPCProxyClient({ 48 | links: [chromeLink({ port })], 49 | }); 50 | 51 | const data1 = await trpc.echoQuery.query({ payload: 'query1' }); 52 | expect(data1).toEqual({ payload: 'query1' }); 53 | 54 | const data2 = await trpc.nestedRouter.echoQuery.query({ payload: 'query2' }); 55 | expect(data2).toEqual({ payload: 'query2' }); 56 | 57 | const [data3, data4] = await Promise.all([ 58 | trpc.echoQuery.query({ payload: 'query3' }), 59 | trpc.echoQuery.query({ payload: 'query4' }), 60 | ]); 61 | expect(data3).toEqual({ payload: 'query3' }); 62 | expect(data4).toEqual({ payload: 'query4' }); 63 | }); 64 | 65 | test('with mutation', async () => { 66 | // background 67 | createChromeHandler({ router: appRouter }); 68 | expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledTimes(1); 69 | 70 | // content 71 | const port = chrome.runtime.connect(); 72 | const trpc = createTRPCProxyClient({ 73 | links: [chromeLink({ port })], 74 | }); 75 | 76 | const data1 = await trpc.echoMutation.mutate({ payload: 'mutation1' }); 77 | expect(data1).toEqual({ payload: 'mutation1' }); 78 | 79 | const data2 = await trpc.nestedRouter.echoMutation.mutate({ payload: 'mutation2' }); 80 | expect(data2).toEqual({ payload: 'mutation2' }); 81 | 82 | const [data3, data4] = await Promise.all([ 83 | trpc.echoMutation.mutate({ payload: 'mutation3' }), 84 | trpc.echoMutation.mutate({ payload: 'mutation4' }), 85 | ]); 86 | expect(data3).toEqual({ payload: 'mutation3' }); 87 | expect(data4).toEqual({ payload: 'mutation4' }); 88 | }); 89 | 90 | test('with subscription', async () => { 91 | // background 92 | createChromeHandler({ router: appRouter }); 93 | expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledTimes(1); 94 | 95 | // content 96 | const port = chrome.runtime.connect(); 97 | const trpc = createTRPCProxyClient({ 98 | links: [chromeLink({ port })], 99 | }); 100 | 101 | const onDataMock = jest.fn(); 102 | const onCompleteMock = jest.fn(); 103 | const onErrorMock = jest.fn(); 104 | const onStartedMock = jest.fn(); 105 | const onStoppedMock = jest.fn(); 106 | const subscription = await new Promise((resolve) => { 107 | const subscription = trpc.echoSubscription.subscribe( 108 | { payload: 'subscription1' }, 109 | { 110 | onData: (data) => { 111 | onDataMock(data); 112 | resolve(subscription); 113 | }, 114 | onComplete: onCompleteMock, 115 | onError: onErrorMock, 116 | onStarted: onStartedMock, 117 | onStopped: onStoppedMock, 118 | }, 119 | ); 120 | }); 121 | expect(onDataMock).toHaveBeenCalledTimes(1); 122 | expect(onDataMock).toHaveBeenNthCalledWith(1, { payload: 'subscription1' }); 123 | expect(onCompleteMock).toHaveBeenCalledTimes(0); 124 | expect(onErrorMock).toHaveBeenCalledTimes(0); 125 | expect(onStartedMock).toHaveBeenCalledTimes(1); 126 | expect(onStoppedMock).toHaveBeenCalledTimes(0); 127 | subscription.unsubscribe(); 128 | expect(onDataMock).toHaveBeenCalledTimes(1); 129 | expect(onCompleteMock).toHaveBeenCalledTimes(1); 130 | expect(onErrorMock).toHaveBeenCalledTimes(0); 131 | expect(onStartedMock).toHaveBeenCalledTimes(1); 132 | expect(onStoppedMock).toHaveBeenCalledTimes(1); 133 | }); 134 | 135 | // with subscription 136 | // with error 137 | // with createcontext 138 | // with output 139 | // with multiport 140 | -------------------------------------------------------------------------------- /src/adapter/index.ts: -------------------------------------------------------------------------------- 1 | import { AnyProcedure, AnyRouter, ProcedureType, TRPCError } from '@trpc/server'; 2 | // eslint-disable-next-line import/no-unresolved 3 | import type { NodeHTTPCreateContextOption } from '@trpc/server/dist/adapters/node-http/types'; 4 | // eslint-disable-next-line import/no-unresolved 5 | import type { BaseHandlerOptions } from '@trpc/server/dist/internals/types'; 6 | import { Unsubscribable, isObservable } from '@trpc/server/observable'; 7 | 8 | import type { TRPCChromeRequest, TRPCChromeResponse } from '../types'; 9 | import { getErrorFromUnknown } from './errors'; 10 | 11 | export type CreateChromeContextOptions = { 12 | req: chrome.runtime.Port; 13 | res: undefined; 14 | }; 15 | 16 | export type CreateChromeHandlerOptions = Pick< 17 | BaseHandlerOptions & 18 | NodeHTTPCreateContextOption< 19 | TRouter, 20 | CreateChromeContextOptions['req'], 21 | CreateChromeContextOptions['res'] 22 | >, 23 | 'router' | 'createContext' | 'onError' 24 | >; 25 | 26 | export const createChromeHandler = ( 27 | opts: CreateChromeHandlerOptions, 28 | ) => { 29 | const { router, createContext, onError } = opts; 30 | const { transformer } = router._def._config; 31 | 32 | chrome.runtime.onConnect.addListener((port) => { 33 | const subscriptions = new Map(); 34 | const listeners: (() => void)[] = []; 35 | 36 | const onDisconnect = () => { 37 | listeners.forEach((unsub) => unsub()); 38 | }; 39 | 40 | port.onDisconnect.addListener(onDisconnect); 41 | listeners.push(() => port.onDisconnect.removeListener(onDisconnect)); 42 | 43 | const onMessage = async (message: TRPCChromeRequest) => { 44 | if (!('trpc' in message)) return; 45 | const { trpc } = message; 46 | if (!('id' in trpc) || trpc.id === null || trpc.id === undefined) return; 47 | if (!trpc) return; 48 | 49 | const { id, jsonrpc, method } = trpc; 50 | 51 | const sendResponse = (response: TRPCChromeResponse['trpc']) => { 52 | port.postMessage({ 53 | trpc: { id, jsonrpc, ...response }, 54 | } as TRPCChromeResponse); 55 | }; 56 | 57 | let params: { path: string; input: unknown } | undefined; 58 | let input: any; 59 | let ctx: any; 60 | 61 | try { 62 | if (method === 'subscription.stop') { 63 | const subscription = subscriptions.get(id); 64 | if (subscription) { 65 | subscription.unsubscribe(); 66 | sendResponse({ 67 | result: { 68 | type: 'stopped', 69 | }, 70 | }); 71 | } 72 | subscriptions.delete(id); 73 | return; 74 | } 75 | 76 | params = trpc.params; 77 | 78 | input = transformer.input.deserialize(params.input); 79 | 80 | ctx = await createContext?.({ req: port, res: undefined }); 81 | const caller = router.createCaller(ctx); 82 | 83 | const segments = params.path.split('.'); 84 | const procedureFn = segments.reduce( 85 | (acc, segment) => acc[segment], 86 | caller as any, 87 | ) as AnyProcedure; 88 | 89 | const result = await procedureFn(input); 90 | 91 | if (method !== 'subscription') { 92 | const data = transformer.output.serialize(result); 93 | sendResponse({ 94 | result: { 95 | type: 'data', 96 | data, 97 | }, 98 | }); 99 | return; 100 | } 101 | 102 | if (!isObservable(result)) { 103 | throw new TRPCError({ 104 | message: 'Subscription ${params.path} did not return an observable', 105 | code: 'INTERNAL_SERVER_ERROR', 106 | }); 107 | } 108 | 109 | const subscription = result.subscribe({ 110 | next: (data) => { 111 | sendResponse({ 112 | result: { 113 | type: 'data', 114 | data, 115 | }, 116 | }); 117 | }, 118 | error: (cause) => { 119 | const error = getErrorFromUnknown(cause); 120 | 121 | onError?.({ 122 | error, 123 | type: method, 124 | path: params?.path, 125 | input, 126 | ctx, 127 | req: port, 128 | }); 129 | 130 | sendResponse({ 131 | error: router.getErrorShape({ 132 | error, 133 | type: method, 134 | path: params?.path, 135 | input, 136 | ctx, 137 | }), 138 | }); 139 | }, 140 | complete: () => { 141 | sendResponse({ 142 | result: { 143 | type: 'stopped', 144 | }, 145 | }); 146 | }, 147 | }); 148 | 149 | if (subscriptions.has(id)) { 150 | subscription.unsubscribe(); 151 | sendResponse({ 152 | result: { 153 | type: 'stopped', 154 | }, 155 | }); 156 | throw new TRPCError({ 157 | message: `Duplicate id ${id}`, 158 | code: 'BAD_REQUEST', 159 | }); 160 | } 161 | listeners.push(() => subscription.unsubscribe()); 162 | 163 | subscriptions.set(id, subscription); 164 | 165 | sendResponse({ 166 | result: { 167 | type: 'started', 168 | }, 169 | }); 170 | return; 171 | } catch (cause) { 172 | const error = getErrorFromUnknown(cause); 173 | 174 | onError?.({ 175 | error, 176 | type: method as ProcedureType, 177 | path: params?.path, 178 | input, 179 | ctx, 180 | req: port, 181 | }); 182 | 183 | sendResponse({ 184 | error: router.getErrorShape({ 185 | error, 186 | type: method as ProcedureType, 187 | path: params?.path, 188 | input, 189 | ctx, 190 | }), 191 | }); 192 | } 193 | }; 194 | 195 | port.onMessage.addListener(onMessage); 196 | listeners.push(() => port.onMessage.removeListener(onMessage)); 197 | }); 198 | }; 199 | -------------------------------------------------------------------------------- /assets/trpc-chrome.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | --------------------------------------------------------------------------------