diff --git a/.gitignore b/.gitignore index 3a98acc..7f2019c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules .DS_Store dist -adapter/**/* -link/**/* -types/**/* \ No newline at end of file +/adapter +/link +/types +/relay +/shared diff --git a/README.md b/README.md index bd7f240..060b7cf 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ## **[Chrome extension](https://developer.chrome.com/docs/extensions/mv3/) support for [tRPC](https://trpc.io/)** 🧩 - Easy communication for web extensions. -- Typesafe messaging between content & background scripts. +- Typesafe messaging between window, content & background scripts. - Ready for Manifest V3. ## Usage @@ -59,11 +59,33 @@ import { chromeLink } from 'trpc-chrome/link'; import type { AppRouter } from './background'; const port = chrome.runtime.connect(); -export const chromeClient = createTRPCClient({ +export const chromeClient = createTRPCProxyClient({ links: [/* 👉 */ chromeLink({ port })], }); ``` +**4. `(extra)` If you have an injected window script, hook it up too!.** + +```typescript +// inpage.ts +import { createTRPCClient } from '@trpc/client'; +import { windowLink } from 'trpc-chrome/link'; + +import type { AppRouter } from './background'; + +export const windowClient = createTRPCProxyClient({ + links: [/* 👉 */ windowLink({ window })], +}); +``` + +```typescript +// content.ts +import { relay } from 'trpc-chrome/relay'; + +const port = chrome.runtime.connect(); +relay(port, window); +``` + ## Requirements Peer dependencies: @@ -81,12 +103,20 @@ _For advanced use-cases, please find examples in our [complete test suite](test) #### ChromeLinkOptions -Please see [full typings here](src/link/index.ts). +Please see [full typings here](src/link/chrome.ts). | Property | Type | Description | Required | | -------- | --------------------- | ---------------------------------------------------------------- | -------- | | `port` | `chrome.runtime.Port` | An open web extension port between content & background scripts. | `true` | +### WindowLinkOptions + +Please see [full typings here](src/link/window.ts). + +| Property | Type | Description | Required | +| -------- | -------- | ----------------------------------------------- | -------- | +| `window` | `Window` | A window object which is listened to by a relay | `true` | + #### CreateChromeHandlerOptions Please see [full typings here](src/adapter/index.ts). diff --git a/package-lock.json b/package-lock.json index c29990a..7946629 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7713,9 +7713,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", - "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" }, "node_modules/http-proxy-agent": { "version": "5.0.0", @@ -10155,9 +10155,9 @@ "dev": true }, "node_modules/json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": { "json5": "lib/cli.js" }, @@ -13189,9 +13189,9 @@ } }, "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", - "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "dependencies": { "minimist": "^1.2.0" @@ -19144,9 +19144,9 @@ } }, "http-cache-semantics": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", - "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" }, "http-proxy-agent": { "version": "5.0.0", @@ -20904,9 +20904,9 @@ "dev": true }, "json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==" + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" }, "keyv": { "version": "4.5.0", @@ -28297,9 +28297,9 @@ } }, "http-cache-semantics": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", - "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" }, "http-proxy-agent": { "version": "5.0.0", @@ -30057,9 +30057,9 @@ "dev": true }, "json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==" + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" }, "keyv": { "version": "4.5.0", @@ -32187,9 +32187,9 @@ }, "dependencies": { "json5": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", - "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "requires": { "minimist": "^1.2.0" @@ -32741,9 +32741,9 @@ }, "dependencies": { "json5": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", - "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "requires": { "minimist": "^1.2.0" diff --git a/package.json b/package.json index 3715231..16e5984 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,13 @@ "author": "James Berry ", "private": false, "license": "MIT", + "files": [ + "types", + "adapter", + "link", + "relay", + "shared" + ], "keywords": [ "trpc", "chrome", @@ -21,7 +28,7 @@ ], "scripts": { "test": "tsc --noEmit && jest --verbose", - "build": "rimraf dist && rimraf adapter && rimraf link && rimraf types && tsc -p tsconfig.build.json && mv dist/* . && rimraf dist" + "build": "rimraf dist && rimraf adapter && rimraf link && rimraf types && rimraf relay && rimraf shared && tsc -p tsconfig.build.json && mv dist/* . && rimraf dist" }, "peerDependencies": { "@trpc/client": "^10.0.0", diff --git a/src/adapter/index.ts b/src/adapter/index.ts index 0754bf0..79eaa23 100644 --- a/src/adapter/index.ts +++ b/src/adapter/index.ts @@ -5,6 +5,7 @@ import type { NodeHTTPCreateContextOption } from '@trpc/server/dist/adapters/nod import type { BaseHandlerOptions } from '@trpc/server/dist/internals/types'; import { Unsubscribable, isObservable } from '@trpc/server/observable'; +import { isTRPCRequest, isTRPCRequestWithId } from '../shared/trpcMessage'; import type { TRPCChromeRequest, TRPCChromeResponse } from '../types'; import { getErrorFromUnknown } from './errors'; @@ -40,11 +41,9 @@ export const createChromeHandler = ( port.onDisconnect.addListener(onDisconnect); listeners.push(() => port.onDisconnect.removeListener(onDisconnect)); - const onMessage = async (message: TRPCChromeRequest) => { - if (!('trpc' in message)) return; + const onMessage = async (message: unknown) => { + if (!isTRPCRequestWithId(message)) return; const { trpc } = message; - if (!('id' in trpc) || trpc.id === null || trpc.id === undefined) return; - if (!trpc) return; const { id, jsonrpc, method } = trpc; diff --git a/src/link/chrome.ts b/src/link/chrome.ts new file mode 100644 index 0000000..a280619 --- /dev/null +++ b/src/link/chrome.ts @@ -0,0 +1,30 @@ +import type { TRPCLink } from '@trpc/client'; +import type { AnyRouter } from '@trpc/server'; + +import { createBaseLink } from './internal/base'; + +export type ChromeLinkOptions = { + port: chrome.runtime.Port; +}; + +export const chromeLink = ( + opts: ChromeLinkOptions, +): TRPCLink => { + return createBaseLink({ + postMessage(message) { + opts.port.postMessage(message); + }, + addMessageListener(listener) { + opts.port.onMessage.addListener(listener); + }, + removeMessageListener(listener) { + opts.port.onMessage.removeListener(listener); + }, + addCloseListener(listener) { + opts.port.onDisconnect.addListener(listener); + }, + removeCloseListener(listener) { + opts.port.onDisconnect.removeListener(listener); + }, + }); +}; diff --git a/src/link/index.ts b/src/link/index.ts index 80b90d3..2982fbb 100644 --- a/src/link/index.ts +++ b/src/link/index.ts @@ -1,92 +1,2 @@ -import { TRPCClientError, TRPCLink } from '@trpc/client'; -import type { AnyRouter } from '@trpc/server'; -import { observable } from '@trpc/server/observable'; - -import type { TRPCChromeRequest, TRPCChromeResponse } from '../types'; - -export type ChromeLinkOptions = { - port: chrome.runtime.Port; -}; - -export const chromeLink = ( - opts: ChromeLinkOptions, -): TRPCLink => { - return (runtime) => { - const { port } = opts; - return ({ op }) => { - return observable((observer) => { - const listeners: (() => void)[] = []; - - const { id, type, path } = op; - - try { - const input = runtime.transformer.serialize(op.input); - - const onDisconnect = () => { - observer.error(new TRPCClientError('Port disconnected prematurely')); - }; - - port.onDisconnect.addListener(onDisconnect); - listeners.push(() => port.onDisconnect.removeListener(onDisconnect)); - - const onMessage = (message: TRPCChromeResponse) => { - if (!('trpc' in message)) return; - const { trpc } = message; - if (!trpc) return; - if (!('id' in trpc) || trpc.id === null || trpc.id === undefined) return; - if (id !== trpc.id) return; - - if ('error' in trpc) { - const error = runtime.transformer.deserialize(trpc.error); - observer.error(TRPCClientError.from({ ...trpc, error })); - return; - } - - observer.next({ - result: { - ...trpc.result, - ...((!trpc.result.type || trpc.result.type === 'data') && { - type: 'data', - data: runtime.transformer.deserialize(trpc.result.data), - }), - } as any, - }); - - if (type !== 'subscription' || trpc.result.type === 'stopped') { - observer.complete(); - } - }; - - port.onMessage.addListener(onMessage); - listeners.push(() => port.onMessage.removeListener(onMessage)); - - port.postMessage({ - trpc: { - id, - jsonrpc: undefined, - method: type, - params: { path, input }, - }, - } as TRPCChromeRequest); - } catch (cause) { - observer.error( - new TRPCClientError(cause instanceof Error ? cause.message : 'Unknown error'), - ); - } - - return () => { - listeners.forEach((unsub) => unsub()); - if (type === 'subscription') { - port.postMessage({ - trpc: { - id, - jsonrpc: undefined, - method: 'subscription.stop', - }, - } as TRPCChromeRequest); - } - }; - }); - }; - }; -}; +export * from './chrome'; +export * from './window'; diff --git a/src/link/internal/base.ts b/src/link/internal/base.ts new file mode 100644 index 0000000..a09e88a --- /dev/null +++ b/src/link/internal/base.ts @@ -0,0 +1,94 @@ +import { TRPCClientError, TRPCLink } from '@trpc/client'; +import type { AnyRouter } from '@trpc/server'; +import { observable } from '@trpc/server/observable'; + +import { isTRPCResponse } from '../../shared/trpcMessage'; +import type { TRPCChromeMessage, TRPCChromeRequest } from '../../types'; + +interface LinkMethods { + postMessage: (message: TRPCChromeMessage) => void; + addMessageListener: (listener: (message: TRPCChromeMessage) => void) => void; + removeMessageListener: (listener: (message: TRPCChromeMessage) => void) => void; + addCloseListener: (listener: () => void) => void; + removeCloseListener: (listener: () => void) => void; +} + +export const createBaseLink = ( + methods: LinkMethods, +): TRPCLink => { + return (runtime) => { + return ({ op }) => { + return observable((observer) => { + const listeners: (() => void)[] = []; + + const { id, type, path } = op; + + try { + const input = runtime.transformer.serialize(op.input); + + const onDisconnect = () => { + observer.error(new TRPCClientError('Port disconnected prematurely')); + }; + + methods.addCloseListener(onDisconnect); + listeners.push(() => methods.removeCloseListener(onDisconnect)); + + const onMessage = (message: unknown) => { + if (!isTRPCResponse(message)) return; + const { trpc } = message; + if (id !== trpc.id) return; + + if ('error' in trpc) { + const error = runtime.transformer.deserialize(trpc.error); + observer.error(TRPCClientError.from({ ...trpc, error })); + return; + } + + observer.next({ + result: { + ...trpc.result, + ...((!trpc.result.type || trpc.result.type === 'data') && { + type: 'data', + data: runtime.transformer.deserialize(trpc.result.data), + }), + } as any, + }); + + if (type !== 'subscription' || trpc.result.type === 'stopped') { + observer.complete(); + } + }; + + methods.addMessageListener(onMessage); + listeners.push(() => methods.removeMessageListener(onMessage)); + + methods.postMessage({ + trpc: { + id, + jsonrpc: undefined, + method: type, + params: { path, input }, + }, + } as TRPCChromeRequest); + } catch (cause) { + observer.error( + new TRPCClientError(cause instanceof Error ? cause.message : 'Unknown error'), + ); + } + + return () => { + if (type === 'subscription') { + methods.postMessage({ + trpc: { + id, + jsonrpc: undefined, + method: 'subscription.stop', + }, + } as TRPCChromeRequest); + } + listeners.forEach((unsub) => unsub()); + }; + }); + }; + }; +}; diff --git a/src/link/window.ts b/src/link/window.ts new file mode 100644 index 0000000..e3b4149 --- /dev/null +++ b/src/link/window.ts @@ -0,0 +1,43 @@ +import type { TRPCLink } from '@trpc/client'; +import type { AnyRouter } from '@trpc/server'; + +import type { MinimalWindow, TRPCChromeMessage } from '../types'; +import { createBaseLink } from './internal/base'; + +export type WindowLinkOptions = { + window: MinimalWindow; +}; + +export const windowLink = ( + opts: WindowLinkOptions, +): TRPCLink => { + const handlerMap = new Map< + (message: TRPCChromeMessage) => void, + (ev: MessageEvent) => void + >(); + + return createBaseLink({ + postMessage(message) { + opts.window.postMessage(message, '*'); + }, + addMessageListener(listener) { + const handler = (ev: MessageEvent) => { + listener(ev.data); + }; + handlerMap.set(listener, handler); + opts.window.addEventListener('message', handler); + }, + removeMessageListener(listener) { + const handler = handlerMap.get(listener); + if (handler) { + opts.window.removeEventListener('message', handler); + } + }, + addCloseListener(listener) { + opts.window.addEventListener('beforeunload', listener); + }, + removeCloseListener(listener) { + opts.window.removeEventListener('beforeunload', listener); + }, + }); +}; diff --git a/src/relay/index.ts b/src/relay/index.ts new file mode 100644 index 0000000..313f297 --- /dev/null +++ b/src/relay/index.ts @@ -0,0 +1,43 @@ +// can relay messages between two links +import { isTRPCMessage } from '../shared/trpcMessage'; +import type { MinimalWindow, RelayedTRPCMessage } from '../types'; + +type UnSubscibeFn = () => void; + +export function relay( + window: MinimalWindow, + port: chrome.runtime.Port, + windowPostOrigin?: string, +): UnSubscibeFn { + function relayToWindow(message: RelayedTRPCMessage) { + if (message.relayed) return; + const relayedMessage: RelayedTRPCMessage = { ...message, relayed: true }; + if (windowPostOrigin) { + window.postMessage(relayedMessage, windowPostOrigin); + } else { + window.postMessage(relayedMessage); + } + } + function relayToPort(message: RelayedTRPCMessage) { + if (message.relayed) return; + const relayedMessage: RelayedTRPCMessage = { ...message, relayed: true }; + port.postMessage(relayedMessage); + } + + const onWindowMessage = (event: MessageEvent) => { + if (!isTRPCMessage(event.data)) return; + relayToPort(event.data); + }; + const onPortMessage = (message: unknown) => { + if (!isTRPCMessage(message)) return; + relayToWindow(message); + }; + + window.addEventListener('message', onWindowMessage); + port.onMessage.addListener(onPortMessage); + + return () => { + window.removeEventListener('message', onWindowMessage); + port.onMessage.removeListener(onPortMessage); + }; +} diff --git a/src/shared/trpcMessage.ts b/src/shared/trpcMessage.ts new file mode 100644 index 0000000..ecd32e8 --- /dev/null +++ b/src/shared/trpcMessage.ts @@ -0,0 +1,31 @@ +import type { TRPCChromeMessage, TRPCChromeRequest, TRPCChromeResponse } from '../types'; + +type WithTRPCId = T & { trpc: { id: string } }; + +function isPlainObject(obj: unknown): obj is Record { + return typeof obj === 'object' && obj !== null && !Array.isArray(obj); +} +function isNullOrUndefined(x: unknown): x is null | undefined { + return x === null || x === undefined; +} +export function isTRPCMessage(message: unknown): message is TRPCChromeMessage { + return Boolean(isPlainObject(message) && 'trpc' in message && isPlainObject(message.trpc)); +} + +function isTRPCMessageWithId(message: unknown): message is WithTRPCId { + return isTRPCMessage(message) && 'id' in message.trpc && !isNullOrUndefined(message.trpc.id); +} + +// reponse needs error or result +export function isTRPCResponse(message: unknown): message is TRPCChromeResponse { + return isTRPCMessageWithId(message) && ('error' in message.trpc || 'result' in message.trpc); +} + +// request needs method +export function isTRPCRequest(message: unknown): message is TRPCChromeRequest { + return isTRPCMessageWithId(message) && 'method' in message.trpc; +} + +export function isTRPCRequestWithId(message: unknown): message is WithTRPCId { + return isTRPCRequest(message) && isTRPCMessageWithId(message); +} diff --git a/src/types/index.ts b/src/types/index.ts index 1d51079..5c6f5b8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,3 +18,11 @@ export type TRPCChromeErrorResponse = { }; export type TRPCChromeResponse = TRPCChromeSuccessResponse | TRPCChromeErrorResponse; + +export type TRPCChromeMessage = TRPCChromeRequest | TRPCChromeResponse; +export type RelayedTRPCMessage = TRPCChromeMessage & { relayed?: true }; + +export type MinimalWindow = Pick< + Window, + 'postMessage' | 'addEventListener' | 'removeEventListener' +>; diff --git a/test/__setup.ts b/test/__setup.ts index 503fe64..649681b 100644 --- a/test/__setup.ts +++ b/test/__setup.ts @@ -1,6 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ + /* eslint-disable @typescript-eslint/no-unsafe-call */ + /* eslint-disable @typescript-eslint/no-unsafe-return */ +import type { MinimalWindow } from '../src/types'; type OnMessageListener = (message: any) => void; type OnConnectListener = (port: any) => void; @@ -10,25 +13,31 @@ const getMockChrome = jest.fn(() => { const handlerPortOnMessageListeners: OnMessageListener[] = []; const handlerPortOnConnectListeners: OnConnectListener[] = []; + const handlerPort = { + postMessage: jest.fn((message) => { + linkPortOnMessageListeners.forEach((listener) => listener(message)); + }), + onMessage: { + addListener: jest.fn((listener) => { + handlerPortOnMessageListeners.push(listener); + }), + removeListener: jest.fn((listener) => { + const index = handlerPortOnMessageListeners.indexOf(listener); + if (index > -1) { + handlerPortOnMessageListeners.splice(index, 1); + } + }), + }, + onDisconnect: { + addListener: jest.fn(), + removeListener: jest.fn(), + }, + }; + return { + __handlerPort: handlerPort, runtime: { connect: jest.fn(() => { - const handlerPort = { - postMessage: jest.fn((message) => { - linkPortOnMessageListeners.forEach((listener) => listener(message)); - }), - onMessage: { - addListener: jest.fn((listener) => { - handlerPortOnMessageListeners.push(listener); - }), - removeListener: jest.fn(), - }, - onDisconnect: { - addListener: jest.fn(), - removeListener: jest.fn(), - }, - }; - const linkPort = { postMessage: jest.fn((message) => { handlerPortOnMessageListeners.forEach((listener) => listener(message)); @@ -37,7 +46,12 @@ const getMockChrome = jest.fn(() => { addListener: jest.fn((listener) => { linkPortOnMessageListeners.push(listener); }), - removeListener: jest.fn(), + removeListener: jest.fn((listener) => { + const index = linkPortOnMessageListeners.indexOf(listener); + if (index > -1) { + linkPortOnMessageListeners.splice(index, 1); + } + }), }, onDisconnect: { addListener: jest.fn(), @@ -64,3 +78,24 @@ export const resetMocks = () => { }; resetMocks(); + +export const getMockWindow = (): MinimalWindow => { + const listeners: ((event: MessageEvent) => void)[] = []; + + return { + addEventListener: jest.fn((event, listener: EventListener) => { + if (event !== 'message') return; + listeners.push(listener); + }), + removeEventListener: jest.fn((event, listener: EventListener) => { + if (event !== 'message') return; + const index = listeners.indexOf(listener); + if (index > -1) { + listeners.splice(index, 1); + } + }), + postMessage: jest.fn((message) => { + listeners.forEach((listener) => listener({ data: message } as any)); + }), + }; +}; diff --git a/test/relay.test.ts b/test/relay.test.ts new file mode 100644 index 0000000..5c40c4f --- /dev/null +++ b/test/relay.test.ts @@ -0,0 +1,66 @@ +import { getMockWindow, resetMocks } from './__setup'; + +import { relay } from '../src/relay'; +import { isTRPCMessage } from '../src/shared/trpcMessage'; +import type { TRPCChromeMessage } from '../src/types'; + +afterEach(() => { + resetMocks(); +}); + +const mockMessage: TRPCChromeMessage = { + trpc: { + id: '1', + result: { + type: 'data', + data: { + payload: 'hello', + }, + }, + }, +}; + +describe('relay', () => { + test('validate test data', () => { + expect(isTRPCMessage(mockMessage)).toBe(true); + }); + test('relays messages between window to port', async () => { + const port = chrome.runtime.connect(); + // @ts-expect-error handler port is just available in tests + const handlerPort: chrome.runtime.Port = chrome.__handlerPort; + const window = getMockWindow(); + const cleanup = relay(window, port); + + expect(port.onMessage.addListener).toHaveBeenCalledTimes(1); + expect(port.onDisconnect.addListener).toHaveBeenCalledTimes(0); + expect(port.onDisconnect.removeListener).toHaveBeenCalledTimes(0); + expect(port.onMessage.removeListener).toHaveBeenCalledTimes(0); + expect(port.postMessage).toHaveBeenCalledTimes(0); + expect(window.addEventListener).toHaveBeenCalledTimes(1); + expect(window.removeEventListener).toHaveBeenCalledTimes(0); + expect(window.postMessage).toHaveBeenCalledTimes(0); + + window.postMessage(mockMessage); + + expect(port.postMessage).toHaveBeenCalledTimes(1); + expect(port.postMessage).toHaveBeenCalledWith({ + ...mockMessage, + relayed: true, + }); + + handlerPort.postMessage(mockMessage); + + expect(window.postMessage).toHaveBeenCalledTimes(2); + expect(window.postMessage).toHaveBeenCalledWith({ + ...mockMessage, + relayed: true, + }); + + cleanup(); + + window.postMessage(mockMessage); + handlerPort.postMessage(mockMessage); + + expect(port.postMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/webext.test.ts b/test/webext.test.ts index 08af294..922144f 100644 --- a/test/webext.test.ts +++ b/test/webext.test.ts @@ -1,12 +1,13 @@ -import { resetMocks } from './__setup'; +import { getMockWindow, resetMocks } from './__setup'; -import { createTRPCProxyClient } from '@trpc/client'; -import { initTRPC } from '@trpc/server'; +import { TRPCLink, createTRPCProxyClient } from '@trpc/client'; +import { AnyRouter, initTRPC } from '@trpc/server'; import { Unsubscribable, observable } from '@trpc/server/observable'; import { z } from 'zod'; import { createChromeHandler } from '../src/adapter'; -import { chromeLink } from '../src/link'; +import { chromeLink, windowLink } from '../src/link'; +import { relay } from '../src/relay'; afterEach(() => { resetMocks(); @@ -37,99 +38,128 @@ const appRouter = t.router({ }), }); -test('with query', async () => { - // background - createChromeHandler({ router: appRouter }); - expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledTimes(1); - - // content - const port = chrome.runtime.connect(); - const trpc = createTRPCProxyClient({ - links: [chromeLink({ port })], +type LinkName = 'chrome' | 'window'; +function createLink(type: LinkName): { link: TRPCLink; cleanup?: () => void } { + switch (type) { + case 'chrome': { + const port = chrome.runtime.connect(); + return { link: chromeLink({ port }) }; + } + case 'window': { + const port = chrome.runtime.connect(); + const window = getMockWindow(); + const cleanup = relay(window, port); + return { link: windowLink({ window }), cleanup }; + } + default: { + throw new Error('unknown link requested'); + } + } +} + +const testCases: Array<{ linkName: LinkName }> = [{ linkName: 'chrome' }, { linkName: 'window' }]; + +describe.each(testCases)('with $linkName link', ({ linkName }) => { + test('with query', async () => { + // background + createChromeHandler({ router: appRouter }); + expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledTimes(1); + + // content + const { link, cleanup } = createLink(linkName); + const trpc = createTRPCProxyClient({ + links: [link], + }); + + const data1 = await trpc.echoQuery.query({ payload: 'query1' }); + expect(data1).toEqual({ payload: 'query1' }); + + const data2 = await trpc.nestedRouter.echoQuery.query({ payload: 'query2' }); + expect(data2).toEqual({ payload: 'query2' }); + + const [data3, data4] = await Promise.all([ + trpc.echoQuery.query({ payload: 'query3' }), + trpc.echoQuery.query({ payload: 'query4' }), + ]); + expect(data3).toEqual({ payload: 'query3' }); + expect(data4).toEqual({ payload: 'query4' }); + + cleanup?.(); }); - const data1 = await trpc.echoQuery.query({ payload: 'query1' }); - expect(data1).toEqual({ payload: 'query1' }); - - const data2 = await trpc.nestedRouter.echoQuery.query({ payload: 'query2' }); - expect(data2).toEqual({ payload: 'query2' }); - - const [data3, data4] = await Promise.all([ - trpc.echoQuery.query({ payload: 'query3' }), - trpc.echoQuery.query({ payload: 'query4' }), - ]); - expect(data3).toEqual({ payload: 'query3' }); - expect(data4).toEqual({ payload: 'query4' }); -}); - -test('with mutation', async () => { - // background - createChromeHandler({ router: appRouter }); - expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledTimes(1); - - // content - const port = chrome.runtime.connect(); - const trpc = createTRPCProxyClient({ - links: [chromeLink({ port })], - }); + test('with mutation', async () => { + // background + createChromeHandler({ router: appRouter }); + expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledTimes(1); - const data1 = await trpc.echoMutation.mutate({ payload: 'mutation1' }); - expect(data1).toEqual({ payload: 'mutation1' }); + // content + const { link, cleanup } = createLink(linkName); + const trpc = createTRPCProxyClient({ + links: [link], + }); - const data2 = await trpc.nestedRouter.echoMutation.mutate({ payload: 'mutation2' }); - expect(data2).toEqual({ payload: 'mutation2' }); + const data1 = await trpc.echoMutation.mutate({ payload: 'mutation1' }); + expect(data1).toEqual({ payload: 'mutation1' }); - const [data3, data4] = await Promise.all([ - trpc.echoMutation.mutate({ payload: 'mutation3' }), - trpc.echoMutation.mutate({ payload: 'mutation4' }), - ]); - expect(data3).toEqual({ payload: 'mutation3' }); - expect(data4).toEqual({ payload: 'mutation4' }); -}); + const data2 = await trpc.nestedRouter.echoMutation.mutate({ payload: 'mutation2' }); + expect(data2).toEqual({ payload: 'mutation2' }); -test('with subscription', async () => { - // background - createChromeHandler({ router: appRouter }); - expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledTimes(1); + const [data3, data4] = await Promise.all([ + trpc.echoMutation.mutate({ payload: 'mutation3' }), + trpc.echoMutation.mutate({ payload: 'mutation4' }), + ]); + expect(data3).toEqual({ payload: 'mutation3' }); + expect(data4).toEqual({ payload: 'mutation4' }); - // content - const port = chrome.runtime.connect(); - const trpc = createTRPCProxyClient({ - links: [chromeLink({ port })], + cleanup?.(); }); - const onDataMock = jest.fn(); - const onCompleteMock = jest.fn(); - const onErrorMock = jest.fn(); - const onStartedMock = jest.fn(); - const onStoppedMock = jest.fn(); - const subscription = await new Promise((resolve) => { - const subscription = trpc.echoSubscription.subscribe( - { payload: 'subscription1' }, - { - onData: (data) => { - onDataMock(data); - resolve(subscription); + test('with subscription', async () => { + // background + createChromeHandler({ router: appRouter }); + expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledTimes(1); + + // content + const { link, cleanup } = createLink(linkName); + const trpc = createTRPCProxyClient({ + links: [link], + }); + + const onDataMock = jest.fn(); + const onCompleteMock = jest.fn(); + const onErrorMock = jest.fn(); + const onStartedMock = jest.fn(); + const onStoppedMock = jest.fn(); + const subscription = await new Promise((resolve) => { + const subscription = trpc.echoSubscription.subscribe( + { payload: 'subscription1' }, + { + onData: (data) => { + onDataMock(data); + resolve(subscription); + }, + onComplete: onCompleteMock, + onError: onErrorMock, + onStarted: onStartedMock, + onStopped: onStoppedMock, }, - onComplete: onCompleteMock, - onError: onErrorMock, - onStarted: onStartedMock, - onStopped: onStoppedMock, - }, - ); + ); + }); + expect(onDataMock).toHaveBeenCalledTimes(1); + expect(onDataMock).toHaveBeenNthCalledWith(1, { payload: 'subscription1' }); + expect(onCompleteMock).toHaveBeenCalledTimes(0); + expect(onErrorMock).toHaveBeenCalledTimes(0); + expect(onStartedMock).toHaveBeenCalledTimes(1); + expect(onStoppedMock).toHaveBeenCalledTimes(0); + subscription.unsubscribe(); + expect(onDataMock).toHaveBeenCalledTimes(1); + expect(onCompleteMock).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledTimes(0); + expect(onStartedMock).toHaveBeenCalledTimes(1); + expect(onStoppedMock).toHaveBeenCalledTimes(1); + + cleanup?.(); }); - expect(onDataMock).toHaveBeenCalledTimes(1); - expect(onDataMock).toHaveBeenNthCalledWith(1, { payload: 'subscription1' }); - expect(onCompleteMock).toHaveBeenCalledTimes(0); - expect(onErrorMock).toHaveBeenCalledTimes(0); - expect(onStartedMock).toHaveBeenCalledTimes(1); - expect(onStoppedMock).toHaveBeenCalledTimes(0); - subscription.unsubscribe(); - expect(onDataMock).toHaveBeenCalledTimes(1); - expect(onCompleteMock).toHaveBeenCalledTimes(1); - expect(onErrorMock).toHaveBeenCalledTimes(0); - expect(onStartedMock).toHaveBeenCalledTimes(1); - expect(onStoppedMock).toHaveBeenCalledTimes(1); }); // with subscription