├── .changeset ├── README.md └── config.json ├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ ├── CI.yml │ └── publish.yml ├── .gitignore ├── .npmignore ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASE.md ├── assets ├── trpc-chrome-graph.png ├── trpc-chrome-readme.png └── trpc-chrome.svg ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── prettier.config.js ├── src ├── adapter │ ├── errors.ts │ └── index.ts ├── link │ └── index.ts └── types │ └── index.ts ├── test ├── __setup.ts └── postmessage.test.ts ├── tsconfig.json └── tsup.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.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 | tsconfigRootDir: __dirname, 21 | extends: "./tsconfig.json", 22 | include: ["**/*", ".eslintrc.js"], 23 | exclude: ["node_modules", "adapter", "link", "types"], 24 | }, 25 | env: { 26 | node: true, 27 | }, 28 | rules: { 29 | "@typescript-eslint/no-misused-promises": [ 30 | "error", 31 | { checksVoidReturn: false }, 32 | ], 33 | "@typescript-eslint/no-unsafe-argument": "warn", 34 | "@typescript-eslint/no-unsafe-assignment": "warn", 35 | "@typescript-eslint/no-unsafe-call": "warn", 36 | "@typescript-eslint/no-unsafe-member-access": "warn", 37 | "@typescript-eslint/no-unsafe-return": "warn", 38 | "@typescript-eslint/unbound-method": "off", 39 | }, 40 | overrides: [ 41 | { 42 | files: ["*.js", "*.jsx", "*.mjs", "*.cjs"], 43 | rules: {}, 44 | }, 45 | ], 46 | }; 47 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ElasticBottle 2 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: checkout branch 12 | uses: actions/checkout@v3 13 | 14 | - name: install pnpm package manager 15 | uses: pnpm/action-setup@v2 16 | with: 17 | version: 7 18 | 19 | - name: set-up with node 18 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 18.x 23 | cache: "pnpm" 24 | 25 | - name: install deps 26 | run: pnpm install --frozen-lockfile 27 | 28 | - name: lint 29 | run: pnpm run lint 30 | 31 | - name: test 32 | run: pnpm run test 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | 7 | concurrency: ${{ github.workflow }}-${{ github.ref }} 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout branch 14 | uses: actions/checkout@v3 15 | 16 | - name: install pnpm package manager 17 | uses: pnpm/action-setup@v2 18 | with: 19 | version: 7 20 | 21 | - name: set-up with node 18 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 18.x 25 | cache: "pnpm" 26 | 27 | - name: install deps 28 | run: pnpm install --frozen-lockfile 29 | 30 | - name: Create Release Pull Request or Publish 31 | id: changesets 32 | uses: changesets/action@v1 33 | with: 34 | publish: pnpm run release 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | adapter/**/* 5 | link/**/* 6 | types/**/* -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !adapter/**/* 3 | !link/**/* 4 | !types/**/* 5 | !package.json 6 | !package-lock.json 7 | !LICENSE -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.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 | "[javascript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[jsonc]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @elasticbottle/trpc-post-message 2 | 3 | ## 0.0.4 4 | 5 | ### Patch Changes 6 | 7 | - 1d8eb22: fix: event listeners not being removed properly 8 | 9 | ## 0.0.3 10 | 11 | ### Patch Changes 12 | 13 | - 3c00c85: BREAKING: rename `PostMessageLink` to `postMessageLink` 14 | 15 | ## 0.0.2 16 | 17 | ### Patch Changes 18 | 19 | - baa1d49: fix: add npm published files 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Winston Yeo 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

trpc-post-message

3 | 4 | 5 | 6 |
7 |
8 |
9 | 10 | ⭐ **Help this repo out, STAR it!** ⭐ 11 | 12 | ## **[Post Message](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage/) support for [tRPC](https://trpc.io/)** 📨 13 | 14 | - Easy communication between iframes. 15 | - Typesafe messaging between parent and child windows 16 | - soon to be compatible with web workers 17 | 18 | ## Usage 19 | 20 | **1. Install `trpc-post-message`.** 21 | 22 | ```bash 23 | # npm 24 | npm install @elasticbottle/trpc-post-message 25 | # yarn 26 | yarn add @elasticbottle/trpc-post-message 27 | # pnpm 28 | pnpm add @elasticbottle/trpc-post-message 29 | ``` 30 | 31 | **2. Add `createPostMessageHandler` in your background script.** 32 | 33 | ```typescript 34 | // background.ts 35 | import { initTRPC } from "@trpc/server"; 36 | import { createPostMessageHandler } from "@elasticbottle/trpc-post-message/adapter"; 37 | 38 | const t = initTRPC.create({ 39 | isServer: false, 40 | allowOutsideOfServer: true, 41 | }); 42 | 43 | const appRouter = t.router({ 44 | // ...procedures 45 | }); 46 | 47 | export type AppRouter = typeof appRouter; 48 | 49 | createPostMessageHandler({ 50 | router: appRouter, 51 | postMessage: ({ message }) => window.postMessage(message, "your_targeted_url"), 52 | addEventListener: (listener) => 53 | window.addEventListener("message", (e) => { 54 | if (e.origin !== 'your_whitelisted_domain') { 55 | return; 56 | } 57 | listener(e); 58 | }), 59 | }); /* 👈 */, 60 | ``` 61 | 62 | **3. Add a `PostMessageLink` to the client in your content script.** 63 | 64 | ```typescript 65 | // content.ts 66 | import { createTRPCClient } from "@trpc/client"; 67 | import { PostMessageLink } from "@elasticbottle/trpc-post-message/link"; 68 | 69 | import type { AppRouter } from "./background"; 70 | 71 | export const PostMessageClient = createTRPCClient({ 72 | links: [ 73 | PostMessageLink({ 74 | postMessage: ({ message }) => window.postMessage(message, "your_targeted_url"), 75 | addEventListener: (listener) => { 76 | const customerListener = (e) => { 77 | if (e.origin !== 'your_whitelisted_domain') { 78 | return; 79 | } 80 | listener(e); 81 | } 82 | window.addEventListener("message", customerListener) 83 | // if you don't return anything it is assumed that the default listener was used 84 | return customerListener; 85 | }, 86 | removeEventListener: (listener) => 87 | window.removeEventListener("message", listener), 88 | }), 89 | ], /* 👈 */, 90 | }); 91 | ``` 92 | 93 | ## Requirements 94 | 95 | Peer dependencies: 96 | 97 | - [`tRPC`](https://github.com/trpc/trpc) Server v10 (`@trpc/server`) must be installed. 98 | - [`tRPC`](https://github.com/trpc/trpc) Client v10 (`@trpc/client`) must be installed. 99 | 100 | ## Types 101 | 102 | ### PostMessageLinkOption 103 | 104 | Please see [full typings here](src/link/index.ts). 105 | 106 | | Property | Type | Description | Required | 107 | | ------------------ | ---------- | ---------------------------------------------------------------------------- | -------- | 108 | | `postMessage` | `Function` | Called to send data to the "server". You must send the `message` param as is | `true` | 109 | | `addEventListener` | `Function` | Called to add listener to receive request from the "server". | `true` | 110 | 111 | ### CreatePostMessageHandlerOptions 112 | 113 | Please see [full typings here](src/adapter/index.ts). 114 | 115 | | Property | Type | Description | Required | 116 | | ------------------ | ---------- | ---------------------------------------------------------------------------- | -------- | 117 | | `router` | `Router` | Your application tRPC router. | `true` | 118 | | `postMessage` | `Function` | Called to send data to the "client". You must send the `message` param as is | `true` | 119 | | `addEventListener` | `Function` | Called to add listener to receive request from the "client". | `true` | 120 | | `createContext` | `Function` | Passes contextual (`ctx`) data to procedure resolvers. | `false` | 121 | | `onError` | `Function` | Called if error occurs inside handler. | `false` | 122 | 123 | --- 124 | 125 | ## License 126 | 127 | Distributed under the MIT License. See LICENSE for more information. 128 | 129 | ## Contact 130 | 131 | Winston Yeo - Follow me on Twitter [@winston_yeo](https://twitter.com/winston_yeo) 💖 132 | 133 | ## Acknowledgements 134 | 135 | Ths project would not have been possible without [@jlalmes](https://twitter.com/jlalmes) and his well-documented [trpc-chrome](https://github.com/jlalmes/trpc-chrome) package for which this code base was heavily built upon. 136 | -------------------------------------------------------------------------------- /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/ElasticBottle/trpc-post-message/9260586a5693ecc6ac290c6cbfd734d4bf77d1cd/assets/trpc-chrome-graph.png -------------------------------------------------------------------------------- /assets/trpc-chrome-readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElasticBottle/trpc-post-message/9260586a5693ecc6ac290c6cbfd734d4bf77d1cd/assets/trpc-chrome-readme.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */ 4 | module.exports = { 5 | preset: "ts-jest", 6 | testEnvironment: "jsdom", 7 | rootDir: "./test", 8 | setupFiles: ["./__setup.ts"], 9 | snapshotFormat: { 10 | escapeString: true, 11 | printBasicPrototype: true, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elasticbottle/trpc-post-message", 3 | "version": "0.0.4", 4 | "description": "tRPC adapter for post messages 📨", 5 | "author": "Winston Yeo ", 6 | "private": false, 7 | "license": "MIT", 8 | "keywords": [ 9 | "trpc", 10 | "window", 11 | "postMessage", 12 | "extension", 13 | "message passing", 14 | "web workers", 15 | "service workers" 16 | ], 17 | "homepage": "https://github.com/ElasticBottle/trpc-post-message", 18 | "repository": "github:ElasticBottle/trpc-post-message", 19 | "bugs": "https://github.com/ElasticBottle/trpc-post-message/issues", 20 | "scripts": { 21 | "test": "pnpm lint && jest --verbose", 22 | "clean": "rimraf dist && rimraf adapter && rimraf link && rimraf types", 23 | "build": "tsup && mv dist/* . && rimraf dist", 24 | "release": "pnpm build && pnpm changeset publish", 25 | "dev": "tsup --watch", 26 | "lint": "tsc" 27 | }, 28 | "peerDependencies": { 29 | "@trpc/client": "^10.0.0", 30 | "@trpc/server": "^10.0.0" 31 | }, 32 | "devDependencies": { 33 | "@changesets/cli": "^2.26.0", 34 | "@trivago/prettier-plugin-sort-imports": "^4.0.0", 35 | "@trpc/client": "^10.15.0", 36 | "@trpc/server": "^10.15.0", 37 | "@types/jest": "^29.2.3", 38 | "@types/node": "^18.11.9", 39 | "@typescript-eslint/eslint-plugin": "^5.44.0", 40 | "@typescript-eslint/parser": "^5.44.0", 41 | "eslint": "^8.28.0", 42 | "eslint-config-prettier": "^8.5.0", 43 | "eslint-plugin-import": "^2.26.0", 44 | "eslint-plugin-prettier": "^4.2.1", 45 | "eslint-plugin-promise": "^6.1.1", 46 | "jest": "^29.3.1", 47 | "jest-environment-jsdom": "^29.3.1", 48 | "prettier": "^2.8.0", 49 | "rimraf": "^4.4.0", 50 | "superjson": "^1.11.0", 51 | "ts-jest": "^29.0.3", 52 | "ts-node": "^10.9.1", 53 | "tslib": "^2.4.1", 54 | "tsup": "^6.6.3", 55 | "typescript": "^4.9.3", 56 | "zod": "^3.19.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type import("prettier").Options */ 4 | module.exports = { 5 | printWidth: 80, 6 | tabWidth: 2, 7 | useTabs: false, 8 | semi: true, 9 | singleQuote: false, 10 | trailingComma: "all", 11 | importOrder: ["__", "", "^[./]"], 12 | importOrderSeparation: true, 13 | importOrderSortSpecifiers: true, 14 | }; 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/adapter/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnyProcedure, 3 | AnyRouter, 4 | ProcedureType, 5 | TRPCError, 6 | } from "@trpc/server"; 7 | import type { NodeHTTPCreateContextOption } from "@trpc/server/dist/adapters/node-http/types"; 8 | import type { BaseHandlerOptions } from "@trpc/server/dist/internals/types"; 9 | import { isObservable, Unsubscribable } from "@trpc/server/observable"; 10 | 11 | import type { 12 | PostMessageEventListener, 13 | TRPCPostMessageResponse, 14 | } from "../types"; 15 | import { getErrorFromUnknown } from "./errors"; 16 | 17 | export type CreatePostMessageContextOptions = { 18 | req: MessageEvent; 19 | res: undefined; 20 | }; 21 | 22 | export type CreatePostMessageHandlerOptions = Pick< 23 | BaseHandlerOptions & 24 | NodeHTTPCreateContextOption< 25 | TRouter, 26 | CreatePostMessageContextOptions["req"], 27 | CreatePostMessageContextOptions["res"] 28 | >, 29 | "router" | "createContext" | "onError" 30 | > & { 31 | postMessage: (args: { 32 | message: TRPCPostMessageResponse; 33 | opts: { event: MessageEvent }; 34 | }) => void; 35 | addEventListener: PostMessageEventListener; 36 | }; 37 | 38 | export const createPostMessageHandler = ( 39 | opts: CreatePostMessageHandlerOptions, 40 | ) => { 41 | const { router, createContext, onError, addEventListener, postMessage } = 42 | opts; 43 | const { transformer } = router._def._config; 44 | 45 | const subscriptions = new Map(); 46 | 47 | const onMessage: Parameters[0] = async (event) => { 48 | const { data } = event; 49 | 50 | if (!("trpc" in data)) { 51 | return; 52 | } 53 | const { trpc } = data; 54 | if (!trpc) { 55 | return; 56 | } 57 | if ( 58 | !("id" in trpc) || 59 | (typeof trpc.id !== "number" && typeof trpc.id !== "string") 60 | ) { 61 | return; 62 | } 63 | if ( 64 | "jsonrpc" in trpc && 65 | trpc.jsonrpc !== "2.0" && 66 | trpc.jsonrpc !== undefined 67 | ) { 68 | return; 69 | } 70 | if ( 71 | !("method" in trpc) || 72 | (trpc.method !== "query" && 73 | trpc.method !== "mutation" && 74 | trpc.method !== "subscription" && 75 | trpc.method !== "subscription.stop") 76 | ) { 77 | return; 78 | } 79 | 80 | const { 81 | id, 82 | jsonrpc, 83 | method, 84 | }: { 85 | id: string | number; 86 | jsonrpc: "2.0" | undefined; 87 | method: string; 88 | } = trpc; 89 | 90 | const sendResponse = (response: TRPCPostMessageResponse["trpc"]) => { 91 | postMessage({ 92 | message: { trpc: { id, jsonrpc, ...response } }, 93 | opts: { 94 | event, 95 | }, 96 | }); 97 | }; 98 | 99 | let params: { path: string; input: unknown } | undefined; 100 | let input: any; 101 | let ctx: any; 102 | try { 103 | if (method === "subscription.stop") { 104 | const subscription = subscriptions.get(id); 105 | if (subscription) { 106 | subscription.unsubscribe(); 107 | sendResponse({ 108 | result: { 109 | type: "stopped", 110 | }, 111 | }); 112 | subscriptions.delete(id); 113 | } 114 | return; 115 | } 116 | 117 | // params should always be present for 'query', 'subscription' and 'mutation' {@param method} 118 | ({ params } = trpc); 119 | if (!params) { 120 | return; 121 | } 122 | 123 | input = transformer.input.deserialize(params.input); 124 | 125 | ctx = await createContext?.({ req: event, res: undefined }); 126 | const caller = router.createCaller(ctx); 127 | 128 | const segments = params.path.split("."); 129 | const procedureFn = segments.reduce( 130 | (acc, segment) => acc[segment], 131 | caller as any, 132 | ) as AnyProcedure; 133 | 134 | const result = await procedureFn(input); 135 | 136 | if (method !== "subscription") { 137 | const data = transformer.output.serialize(result); 138 | sendResponse({ 139 | result: { 140 | type: "data", 141 | data, 142 | }, 143 | }); 144 | return; 145 | } 146 | 147 | if (!isObservable(result)) { 148 | throw new TRPCError({ 149 | message: `Subscription ${params.path} did not return an observable`, 150 | code: "INTERNAL_SERVER_ERROR", 151 | }); 152 | } 153 | 154 | if (subscriptions.has(id)) { 155 | subscriptions.get(id)?.unsubscribe(); 156 | sendResponse({ 157 | result: { 158 | type: "stopped", 159 | }, 160 | }); 161 | subscriptions.delete(id); 162 | throw new TRPCError({ 163 | message: `Duplicate id ${id}`, 164 | code: "BAD_REQUEST", 165 | }); 166 | } 167 | 168 | const subscription = result.subscribe({ 169 | next: (data) => { 170 | sendResponse({ 171 | result: { 172 | type: "data", 173 | data, 174 | }, 175 | }); 176 | }, 177 | error: (cause) => { 178 | const error = getErrorFromUnknown(cause); 179 | 180 | onError?.({ 181 | error, 182 | type: method, 183 | path: params?.path, 184 | input, 185 | ctx, 186 | req: event, 187 | }); 188 | 189 | sendResponse({ 190 | error: router.getErrorShape({ 191 | error, 192 | type: method, 193 | path: params?.path, 194 | input, 195 | ctx, 196 | }), 197 | }); 198 | }, 199 | complete: () => { 200 | sendResponse({ 201 | result: { 202 | type: "stopped", 203 | }, 204 | }); 205 | }, 206 | }); 207 | subscriptions.set(id, subscription); 208 | 209 | sendResponse({ 210 | result: { 211 | type: "started", 212 | }, 213 | }); 214 | return; 215 | } catch (cause) { 216 | const error = getErrorFromUnknown(cause); 217 | 218 | onError?.({ 219 | error, 220 | type: method as ProcedureType, 221 | path: params?.path, 222 | input, 223 | ctx, 224 | req: event, 225 | }); 226 | 227 | sendResponse({ 228 | error: router.getErrorShape({ 229 | error, 230 | type: method as ProcedureType, 231 | path: params?.path, 232 | input, 233 | ctx, 234 | }), 235 | }); 236 | } 237 | }; 238 | 239 | addEventListener(onMessage); 240 | }; 241 | -------------------------------------------------------------------------------- /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 { 6 | PostMessageEventListener, 7 | TRPCPostMessageRequest, 8 | } from "../types"; 9 | 10 | export type PostMessageLinkOption = { 11 | postMessage: (args: { message: TRPCPostMessageRequest }) => void; 12 | addEventListener: PostMessageEventListener; 13 | removeEventListener: PostMessageEventListener; 14 | }; 15 | 16 | export const postMessageLink = ( 17 | opts: PostMessageLinkOption, 18 | ): TRPCLink => { 19 | return (runtime) => { 20 | // here we just got initialized in the app - this happens once per app 21 | // useful for storing cache for instance 22 | const { addEventListener, postMessage, removeEventListener } = opts; 23 | 24 | return ({ op }) => { 25 | return observable((observer) => { 26 | // why do we observe.complete immediately? 27 | const listeners: (() => void)[] = []; 28 | const { id, type, path } = op; 29 | try { 30 | const input = runtime.transformer.serialize(op.input); 31 | 32 | const onMessage: Parameters[0] = ( 33 | event, 34 | ) => { 35 | const { data } = event; 36 | 37 | if (!("trpc" in data)) { 38 | return; 39 | } 40 | const { trpc } = data; 41 | if (!trpc) { 42 | return; 43 | } 44 | if ( 45 | !("id" in trpc) || 46 | (typeof trpc.id !== "number" && typeof trpc.id !== "string") 47 | ) { 48 | return; 49 | } 50 | if ( 51 | "jsonrpc" in trpc && 52 | trpc.jsonrpc !== "2.0" && 53 | trpc.jsonrpc !== undefined 54 | ) { 55 | return; 56 | } 57 | if (id !== trpc.id) { 58 | return; 59 | } 60 | 61 | if ("error" in trpc) { 62 | const error = runtime.transformer.deserialize(trpc.error); 63 | observer.error(TRPCClientError.from({ ...trpc, error })); 64 | return; 65 | } 66 | 67 | if ("result" in trpc) { 68 | observer.next({ 69 | result: { 70 | ...trpc.result, 71 | // Questionable if we need !trpc.result.type 72 | ...((!trpc.result.type || trpc.result.type === "data") && { 73 | type: "data", 74 | data: runtime.transformer.deserialize(trpc.result.data), 75 | }), 76 | }, 77 | }); 78 | 79 | if (type !== "subscription" || trpc.result.type === "stopped") { 80 | observer.complete(); 81 | } 82 | } 83 | }; 84 | 85 | const maybeNewListener = addEventListener(onMessage); 86 | listeners.push( 87 | maybeNewListener 88 | ? () => removeEventListener(maybeNewListener) 89 | : () => removeEventListener(onMessage), 90 | ); 91 | 92 | postMessage({ 93 | message: { 94 | trpc: { 95 | id, 96 | jsonrpc: undefined, 97 | method: type, 98 | params: { path, input }, 99 | }, 100 | }, 101 | }); 102 | } catch (cause) { 103 | observer.error( 104 | new TRPCClientError( 105 | cause instanceof Error ? cause.message : "Unknown error", 106 | ), 107 | ); 108 | } 109 | 110 | return () => { 111 | if (type === "subscription") { 112 | postMessage({ 113 | message: { 114 | trpc: { 115 | id, 116 | jsonrpc: undefined, 117 | method: "subscription.stop", 118 | }, 119 | }, 120 | }); 121 | } 122 | listeners.forEach((unsub) => unsub()); 123 | }; 124 | }); 125 | }; 126 | }; 127 | }; 128 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TRPCClientOutgoingMessage, 3 | TRPCErrorResponse, 4 | TRPCResultMessage, 5 | } from "@trpc/server/rpc"; 6 | 7 | export type EventListener = (e: MessageEvent) => any; 8 | export type PostMessageEventListener = ( 9 | listener: EventListener, 10 | ) => EventListener | void; 11 | 12 | export type TRPCPostMessageRequest = { 13 | trpc: TRPCClientOutgoingMessage; 14 | }; 15 | 16 | export type TRPCPostMessageSuccessResponse = TRPCResultMessage; 17 | 18 | export type TRPCPostMessageErrorResponse = TRPCErrorResponse; 19 | 20 | export type TRPCPostMessageResponse = { 21 | trpc: TRPCPostMessageSuccessResponse | TRPCPostMessageErrorResponse; 22 | }; 23 | -------------------------------------------------------------------------------- /test/__setup.ts: -------------------------------------------------------------------------------- 1 | type OnMessageEventListener = (event: MessageEvent) => void; 2 | 3 | const getMockWindow = jest.fn(() => { 4 | const onEventListeners: Record = {}; 5 | 6 | jest 7 | .spyOn(window, "addEventListener") 8 | .mockImplementation((event, handler, options) => { 9 | const currentListeners = onEventListeners[event] || []; 10 | if ("handleEvent" in handler) { 11 | currentListeners.push(handler.handleEvent); 12 | } else { 13 | currentListeners.push(handler); 14 | } 15 | onEventListeners[event] = currentListeners; 16 | }); 17 | jest 18 | .spyOn(window, "removeEventListener") 19 | .mockImplementation((event, handler, options) => { 20 | onEventListeners[event] = 21 | onEventListeners[event]?.filter((listener) => handler !== listener) || 22 | []; 23 | }); 24 | jest.spyOn(window, "postMessage").mockImplementation((message, options) => { 25 | const event = new MessageEvent("", { 26 | data: message, 27 | source: this, 28 | origin: window.location.href, 29 | }); 30 | 31 | onEventListeners["message"]?.forEach((listener) => { 32 | listener(event); 33 | }); 34 | }); 35 | }); 36 | 37 | export const resetMocks = () => { 38 | getMockWindow(); 39 | }; 40 | 41 | resetMocks(); 42 | -------------------------------------------------------------------------------- /test/postmessage.test.ts: -------------------------------------------------------------------------------- 1 | import { resetMocks } from "./__setup"; 2 | 3 | import { createTRPCProxyClient } from "@trpc/client"; 4 | import { initTRPC } from "@trpc/server"; 5 | import { observable, Unsubscribable } from "@trpc/server/observable"; 6 | import { z } from "zod"; 7 | 8 | import { createPostMessageHandler } from "../src/adapter"; 9 | import { postMessageLink } from "../src/link"; 10 | 11 | afterEach(() => { 12 | jest.clearAllMocks(); 13 | resetMocks(); 14 | }); 15 | 16 | const t = initTRPC.create(); 17 | 18 | const appRouter = t.router({ 19 | echoQuery: t.procedure 20 | .input(z.object({ payload: z.string() })) 21 | .query(({ input }) => input), 22 | echoMutation: t.procedure 23 | .input(z.object({ payload: z.string() })) 24 | .mutation(({ input }) => input), 25 | echoSubscription: t.procedure 26 | .input(z.object({ payload: z.string() })) 27 | .subscription(({ input }) => 28 | observable((emit) => { 29 | emit.next(input); 30 | }), 31 | ), 32 | nestedRouter: t.router({ 33 | echoQuery: t.procedure 34 | .input(z.object({ payload: z.string() })) 35 | .query(({ input }) => input), 36 | echoMutation: t.procedure 37 | .input(z.object({ payload: z.string() })) 38 | .mutation(({ input }) => input), 39 | echoSubscription: t.procedure 40 | .input(z.object({ payload: z.string() })) 41 | .subscription(({ input }) => 42 | observable((emit) => { 43 | emit.next(input); 44 | }), 45 | ), 46 | }), 47 | }); 48 | 49 | test("with query", async () => { 50 | // background 51 | createPostMessageHandler({ 52 | router: appRouter, 53 | addEventListener(listener) { 54 | window.addEventListener("message", (event) => { 55 | listener(event); 56 | }); 57 | }, 58 | postMessage({ message }) { 59 | window.postMessage(message, "*"); 60 | }, 61 | }); 62 | expect(window.addEventListener).toHaveBeenCalledTimes(1); 63 | 64 | // content 65 | const trpc = createTRPCProxyClient({ 66 | links: [ 67 | postMessageLink({ 68 | addEventListener(listener) { 69 | window.addEventListener("message", listener); 70 | }, 71 | postMessage({ message }) { 72 | window.postMessage(message, "*"); 73 | }, 74 | removeEventListener(listener) { 75 | window.removeEventListener("message", listener); 76 | }, 77 | }), 78 | ], 79 | }); 80 | 81 | const data1 = await trpc.echoQuery.query({ payload: "query1" }); 82 | expect(data1).toEqual({ payload: "query1" }); 83 | expect(window.removeEventListener).toHaveBeenCalledTimes(1); 84 | 85 | const data2 = await trpc.nestedRouter.echoQuery.query({ payload: "query2" }); 86 | expect(data2).toEqual({ payload: "query2" }); 87 | expect(window.removeEventListener).toHaveBeenCalledTimes(2); 88 | 89 | const [data3, data4] = await Promise.all([ 90 | trpc.echoQuery.query({ payload: "query3" }), 91 | trpc.echoQuery.query({ payload: "query4" }), 92 | ]); 93 | expect(data3).toEqual({ payload: "query3" }); 94 | expect(data4).toEqual({ payload: "query4" }); 95 | expect(window.removeEventListener).toHaveBeenCalledTimes(4); 96 | }); 97 | 98 | test("with mutation", async () => { 99 | // background 100 | createPostMessageHandler({ 101 | router: appRouter, 102 | addEventListener(listener) { 103 | window.addEventListener("message", listener); 104 | }, 105 | postMessage({ message }) { 106 | window.postMessage(message, "*"); 107 | }, 108 | }); 109 | expect(window.addEventListener).toHaveBeenCalledTimes(1); 110 | 111 | // content 112 | const trpc = createTRPCProxyClient({ 113 | links: [ 114 | postMessageLink({ 115 | addEventListener(listener) { 116 | const customEventListener = (event: MessageEvent) => { 117 | listener(event); 118 | }; 119 | window.addEventListener("message", customEventListener); 120 | return customEventListener; 121 | }, 122 | postMessage({ message }) { 123 | window.postMessage(message, "*"); 124 | }, 125 | removeEventListener(listener) { 126 | // ^ This will be a reference to the customEventListener above 127 | window.removeEventListener("message", listener); 128 | }, 129 | }), 130 | ], 131 | }); 132 | 133 | const data1 = await trpc.echoMutation.mutate({ payload: "mutation1" }); 134 | expect(data1).toEqual({ payload: "mutation1" }); 135 | 136 | const data2 = await trpc.nestedRouter.echoMutation.mutate({ 137 | payload: "mutation2", 138 | }); 139 | expect(data2).toEqual({ payload: "mutation2" }); 140 | 141 | const [data3, data4] = await Promise.all([ 142 | trpc.echoMutation.mutate({ payload: "mutation3" }), 143 | trpc.echoMutation.mutate({ payload: "mutation4" }), 144 | ]); 145 | expect(data3).toEqual({ payload: "mutation3" }); 146 | expect(data4).toEqual({ payload: "mutation4" }); 147 | }); 148 | 149 | test("with subscription", async () => { 150 | // background 151 | createPostMessageHandler({ 152 | router: appRouter, 153 | addEventListener(listener) { 154 | window.addEventListener("message", listener); 155 | }, 156 | postMessage({ message }) { 157 | window.postMessage(message, "*"); 158 | }, 159 | }); 160 | expect(window.addEventListener).toHaveBeenCalledTimes(1); 161 | 162 | // content 163 | const trpc = createTRPCProxyClient({ 164 | links: [ 165 | postMessageLink({ 166 | addEventListener(listener) { 167 | window.addEventListener("message", listener); 168 | }, 169 | postMessage({ message }) { 170 | window.postMessage(message, "*"); 171 | }, 172 | removeEventListener(listener) { 173 | window.removeEventListener("message", listener); 174 | }, 175 | }), 176 | ], 177 | }); 178 | 179 | const onDataMock = jest.fn(); 180 | const onCompleteMock = jest.fn(); 181 | const onErrorMock = jest.fn(); 182 | const onStartedMock = jest.fn(); 183 | const onStoppedMock = jest.fn(); 184 | const subscription = await new Promise((resolve) => { 185 | const subscription = trpc.echoSubscription.subscribe( 186 | { payload: "subscription1" }, 187 | { 188 | onData: (data) => { 189 | onDataMock(data); 190 | resolve(subscription); 191 | }, 192 | onComplete: () => { 193 | onCompleteMock(); 194 | }, 195 | onError: onErrorMock, 196 | onStarted: onStartedMock, 197 | onStopped: onStoppedMock, 198 | }, 199 | ); 200 | }); 201 | expect(onDataMock).toHaveBeenCalledTimes(1); 202 | expect(onDataMock).toHaveBeenNthCalledWith(1, { payload: "subscription1" }); 203 | expect(onCompleteMock).toHaveBeenCalledTimes(0); 204 | expect(onErrorMock).toHaveBeenCalledTimes(0); 205 | expect(onStartedMock).toHaveBeenCalledTimes(1); 206 | expect(onStoppedMock).toHaveBeenCalledTimes(0); 207 | subscription.unsubscribe(); 208 | expect(onDataMock).toHaveBeenCalledTimes(1); 209 | expect(onCompleteMock).toHaveBeenCalledTimes(1); 210 | expect(onErrorMock).toHaveBeenCalledTimes(0); 211 | expect(onStartedMock).toHaveBeenCalledTimes(1); 212 | expect(onStoppedMock).toHaveBeenCalledTimes(1); 213 | }); 214 | 215 | // with error 216 | // with createcontext 217 | // with output 218 | // with MessageChannel 219 | // with Workers 220 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "target": "ES2017", 5 | "module": "commonjs", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "moduleResolution": "node", 10 | "esModuleInterop": true, 11 | "removeComments": false, 12 | "noUncheckedIndexedAccess": true, 13 | "importsNotUsedAsValues": "error" 14 | }, 15 | "include": ["src", "test"] 16 | } 17 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig((options) => { 4 | return { 5 | entry: ["src/link/index.ts", "src/adapter/index.ts", "src/types/index.ts"], 6 | splitting: false, 7 | sourcemap: true, 8 | clean: true, 9 | dts: true, 10 | format: ["cjs", "esm"], 11 | minify: !options.watch, 12 | }; 13 | }); 14 | --------------------------------------------------------------------------------