diff --git a/bun.lockb b/bun.lockb index 68eb4fc..c5ace04 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/content/messaging/api.md b/docs/content/messaging/api.md index 19aa8a5..6275f8a 100644 --- a/docs/content/messaging/api.md +++ b/docs/content/messaging/api.md @@ -19,7 +19,7 @@ interface BaseMessagingConfig { Shared configuration between all the different messengers. -### Properties +### Properties - ***`logger?: Logger`*** (default: `console`)
The logger to use when logging messages. Set to `null` to disable logging. @@ -35,7 +35,7 @@ interface CustomEventMessage { Additional fields available on the `Message` from a `CustomEventMessenger`. -### Properties +### Properties - ***`event: CustomEvent`***
The event that was fired, resulting in the message being passed. @@ -153,7 +153,7 @@ interface ExtensionMessage { Additional fields available on the `Message` from an `ExtensionMessenger`. -### Properties +### Properties - ***`sender: Runtime.MessageSender`***
Information about where the message came from. See [`Runtime.MessageSender`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/MessageSender). @@ -196,6 +196,12 @@ interface GenericMessenger< TMessageExtension, TSendMessageArgs extends any[], > { + sendMessage( + type: TType, + ...args: GetDataType extends undefined + ? [data?: undefined, ...args: TSendMessageArgs] + : never + ): Promise>; sendMessage( type: TType, data: GetDataType, @@ -283,7 +289,7 @@ interface Message< Contains information about the message received. -### Properties +### Properties - ***`id: number`***
A semi-unique, auto-incrementing number used to trace messages being sent. @@ -306,7 +312,7 @@ interface MessageSender { An object containing information about the script context that sent a message or request. -### Properties +### Properties - ***`tab?: Tabs.Tab`***
The $(ref:tabs.Tab) which opened the connection, if any. This property will only be present when the connection was opened from a tab (including content scripts), and only @@ -332,7 +338,7 @@ interface NamespaceMessagingConfig extends BaseMessagingConfig { } ``` -### Properties +### Properties - ***`namespace: string`***
A string used to ensure the messenger only sends messages to and listens for messages from other messengers of the same type, with the same namespace. @@ -354,7 +360,7 @@ Used to add a return type to a message in the protocol map. > Internally, this is just an object with random keys for the data and return types. -### Properties +### Properties - ***`BtVgCTPYZu: TData`***
Stores the data type. Randomly named so that it isn't accidentally implemented. @@ -392,7 +398,7 @@ interface SendMessageOptions { Options for sending a message to a specific tab/frame -### Properties +### Properties - ***`tabId: number`***
The tab to send a message to @@ -429,4 +435,4 @@ details. --- -_API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts)_ +_API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts)_ \ No newline at end of file diff --git a/docs/content/proxy-service/0.installation.md b/docs/content/proxy-service/0.installation.md index bf8512f..fcc32c3 100644 --- a/docs/content/proxy-service/0.installation.md +++ b/docs/content/proxy-service/0.installation.md @@ -17,17 +17,19 @@ class MathService { ... } } -export const [registerMathService, getMathService] = defineProxyService( - 'MathService', - () => new MathService(), -); +export const [registerMathService, getMathService] = defineServiceProxy('MathService'); ``` ```ts [background.ts] import { registerMathService } from './MathService'; // 2. Register the service at the beginning of the background script -registerMathService(); +export default defineBackground(() => { + registerMathService(async () => { + const { MathService } = await import('../utils/math-service'); + return new MathService(); + }); +}); ``` ```ts [anywhere-else.ts] diff --git a/docs/content/proxy-service/1.defining-services.md b/docs/content/proxy-service/1.defining-services.md index a622b16..2ea848d 100644 --- a/docs/content/proxy-service/1.defining-services.md +++ b/docs/content/proxy-service/1.defining-services.md @@ -8,9 +8,9 @@ Define a class whose methods are available in other JS contexts: ```ts import { openDB, IDBPDatabase } from 'idb'; -import { defineProxyService } from '@webext-core/proxy-service'; +import { defineServiceProxy } from '@webext-core/proxy-service'; -class TodosRepo { +export class TodosRepo { constructor(private db: Promise) {} async getAll(): Promise { @@ -18,16 +18,13 @@ class TodosRepo { } } -export const [registerTodosRepo, getTodosRepo] = defineProxyService( - 'TodosRepo', - (idb: Promise) => new TodosRepo(idb), -); +export const [registerTodosRepo, getTodosRepo] = defineServiceProxy('TodosRepo'); ``` ```ts // Register const db = openDB('todos'); -registerTodosRepo(db); +registerTodosRepo((idb: Promise) => new TodosRepo(idb), db); ``` ```ts @@ -42,24 +39,24 @@ Objects can be used as services as well. All functions defined on the object are ```ts import { openDB, IDBPDatabase } from 'idb'; -import { defineProxyService } from '@webext-core/proxy-service'; +import { defineServiceProxy } from '@webext-core/proxy-service'; + +export const [registerTodosRepo, getTodosRepo] = defineServiceProxy('TodosRepo'); +``` -export const [registerTodosRepo, getTodosRepo] = defineProxyService( - 'TodosRepo', +```ts +// Register +const db = openDB('todos'); +await registerTodosRepo( (db: Promise) => ({ async getAll(): Promise { return (await this.db).getAll('todos'); }, }), + db, ); ``` -```ts -// Register -const db = openDB('todos'); -registerTodosRepo(db); -``` - ```ts // Get an instance const todosRepo = getTodosRepo(); @@ -72,21 +69,20 @@ If you only need to define a single function, you can! ```ts import { openDB, IDBPDatabase } from 'idb'; -import { defineProxyService } from '@webext-core/proxy-service'; +import { defineServiceProxy } from '@webext-core/proxy-service'; -export const [registerGetAllTodos, getGetAllTodos] = defineProxyService( - 'TodosRepo', - (db: Promise) => - function getAllTodos() { - return (await this.db).getAll('todos'); - }, -); +export async function getAllTodos(db: Promise) { + return (await db).getAll('todos'); +} + +export const [registerGetAllTodos, getGetAllTodos] = + defineServiceProxy('TodosRepo'); ``` ```ts // Register const db = openDB('todos'); -registerGetAllTodos(db); +await registerGetAllTodos((db: Promise) => getAllTodos, db); ``` ```ts @@ -101,9 +97,9 @@ If you need to register "deep" objects containing multiple services, you can do ```ts import { openDB, IDBPDatabase } from 'idb'; -import { defineProxyService } from '@webext-core/proxy-service'; +import { defineServiceProxy } from '@webext-core/proxy-service'; -class TodosRepo { +export class TodosRepo { constructor(private db: Promise) {} async getAll(): Promise { @@ -111,26 +107,24 @@ class TodosRepo { } } -const createAuthorsRepo = (db: Promise) => ({ +export const createAuthorsRepo = (db: Promise) => ({ async getOne(id: string): Promise { return (await this.db).getAll('authors', id); }, }); -function createApi(db: Promise) { - return { - todos: new TodosRepo(db), - authors: createAuthorsRepo(db), - }; -} - -export const [registerApi, getApi] = defineProxyService('Api', createApi); +export const [registerApi, getApi] = defineServiceProxy('Api'); ``` ```ts // Register const db = openDB('todos'); -registerApi(db); +await registerApi((db: Promise) => { + return { + todos: new TodosRepo(db), + authors: reateAuthorsRepo(db), + }; +}, db); ``` ```ts diff --git a/docs/content/proxy-service/api.md b/docs/content/proxy-service/api.md index f14a6d1..0e9f883 100644 --- a/docs/content/proxy-service/api.md +++ b/docs/content/proxy-service/api.md @@ -24,6 +24,10 @@ A recursive type that deeply converts all methods in `TService` to be async. ## `defineProxyService` +:::danger Deprecated +Use {@link defineServiceProxy } instead. +::: + ```ts function defineProxyService( name: string, @@ -54,6 +58,37 @@ of the JS context the they are called from. - `registerService`: Used to register your service in the background - `getService`: Used to get an instance of the service anywhere in the extension. +## `defineServiceProxy` + +```ts +function defineServiceProxy( + name: string, + config?: ProxyServiceConfig, +): [ + registerService: ( + init: (...args: any[]) => TService | Promise, + ...args: any[] + ) => Promise, + getService: () => ProxyService, +] { + // ... +} +``` + +Utility for creating a service whose functions are executed in the background script regardless +of the JS context they are called from. + +### Parameters + +- ***`name: string`***
A unique name for the service. Used to identify which service is being executed. + +- ***`config?: ProxyServiceConfig`***
An object that allows configuration of the underlying messaging service + +### Returns + +- `registerService`: Used to register your service in the background. Requires an `init()` callback used to create the actual service object. +- `getService`: Used to get an instance of the service anywhere in the extension. + ## `flattenPromise` ```ts diff --git a/packages/proxy-service-demo/src/entrypoints/background.ts b/packages/proxy-service-demo/src/entrypoints/background.ts index c8b65c1..016135e 100644 --- a/packages/proxy-service-demo/src/entrypoints/background.ts +++ b/packages/proxy-service-demo/src/entrypoints/background.ts @@ -1,3 +1,6 @@ export default defineBackground(() => { - registerMathService(); + registerMathService(async () => { + const { MathService } = await import('../utils/math-service'); + return new MathService(); + }); }); diff --git a/packages/proxy-service-demo/src/entrypoints/popup/main.ts b/packages/proxy-service-demo/src/entrypoints/popup/main.ts index d1414f7..648ddaa 100644 --- a/packages/proxy-service-demo/src/entrypoints/popup/main.ts +++ b/packages/proxy-service-demo/src/entrypoints/popup/main.ts @@ -1,3 +1,5 @@ +import { getMathService } from '../../utils/math-service'; + function getMathServiceElements(id: string) { const parent = document.getElementById(id)!; return [ diff --git a/packages/proxy-service-demo/src/utils/math-service.ts b/packages/proxy-service-demo/src/utils/math-service.ts index 2cde22b..3aae2bd 100644 --- a/packages/proxy-service-demo/src/utils/math-service.ts +++ b/packages/proxy-service-demo/src/utils/math-service.ts @@ -1,6 +1,6 @@ -import { defineProxyService } from '@webext-core/proxy-service'; +import { defineServiceProxy } from '@webext-core/proxy-service'; -class MathService { +export class MathService { add(x: number, y: number): number { console.log(`MathService.add(${x}, ${y})`); return x + y; @@ -25,7 +25,4 @@ class MathService { } } -export const [registerMathService, getMathService] = defineProxyService( - 'MathService', - () => new MathService(), -); +export const [registerMathService, getMathService] = defineServiceProxy('MathService'); diff --git a/packages/proxy-service/src/defineProxyService.ts b/packages/proxy-service/src/defineProxyService.ts index 6794fbf..ba5134e 100644 --- a/packages/proxy-service/src/defineProxyService.ts +++ b/packages/proxy-service/src/defineProxyService.ts @@ -14,6 +14,9 @@ import get from 'get-value'; * @returns * - `registerService`: Used to register your service in the background * - `getService`: Used to get an instance of the service anywhere in the extension. + * + * @deprecated + * Use {@link defineServiceProxy} instead. */ export function defineProxyService( name: string, diff --git a/packages/proxy-service/src/defineServiceProxy.test.ts b/packages/proxy-service/src/defineServiceProxy.test.ts new file mode 100644 index 0000000..af037c5 --- /dev/null +++ b/packages/proxy-service/src/defineServiceProxy.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fakeBrowser } from '@webext-core/fake-browser'; +import { defineServiceProxy } from './defineServiceProxy'; +import { isBackground } from './isBackground'; + +vi.mock('webextension-polyfill'); + +vi.mock('./isBackground', () => ({ + isBackground: vi.fn(), +})); +const isBackgroundMock = vi.mocked(isBackground); + +class ShakableTestService { + private version: number; + + constructor(version: number) { + this.version = version; + } + + getVersion() { + return this.version; + } + + getNextVersion() { + return Promise.resolve(this.version + 1); + } +} + +const defineShakableTestService = () => + defineServiceProxy('ShakableTestService'); + +describe('defineServiceProxy', () => { + beforeEach(() => { + vi.resetAllMocks(); + fakeBrowser.reset(); + }); + + it("getService should fail to get the service in the background if one hasn't been registered", () => { + const [_, getShakableTestService] = defineShakableTestService(); + isBackgroundMock.mockReturnValue(true); + + expect(getShakableTestService).toThrowError(); + }); + + it('getService should return a proxy in other contexts', () => { + const [registerShakableTestService, getShakableTestService] = defineShakableTestService(); + registerShakableTestService(() => new ShakableTestService(1)); + isBackgroundMock.mockReturnValue(false); + + // @ts-expect-error: __proxy is not apart of the type, but it's there + expect(getShakableTestService().__proxy).toEqual(true); + }); + + it('should defer execution of the proxy service methods to the real service methods', async () => { + const version = 10; + const [registerShakableTestService, getShakableTestService] = defineShakableTestService(); + await registerShakableTestService((ver: number) => new ShakableTestService(ver), version); + + isBackgroundMock.mockReturnValue(true); + const real = getShakableTestService(); + isBackgroundMock.mockReturnValue(false); + const proxy = getShakableTestService(); + const realGetVersionSpy = vi.spyOn(real, 'getVersion'); + + const actual = await proxy.getVersion(); + + expect(actual).toEqual(version); + expect(realGetVersionSpy).toBeCalledTimes(1); + }); + + it('should support executing functions directly', async () => { + const expected = 5; + const fn: () => Promise = vi.fn().mockResolvedValue(expected); + const [registerFn, getFn] = defineServiceProxy('fn'); + registerFn(() => fn); + + isBackgroundMock.mockReturnValue(false); + const proxyFn = getFn(); + + const actual = await proxyFn(); + + expect(actual).toBe(expected); + expect(fn).toBeCalledTimes(1); + }); + + it('should support executing deeply nested functions at multiple depths', async () => { + const expected1 = 5; + const expected2 = 6; + const fn1 = vi.fn<() => Promise>().mockResolvedValue(expected1); + const fn2 = vi.fn<() => Promise>().mockResolvedValue(expected2); + const deepObj = { + fn1, + path: { + to: { + fn2, + }, + }, + }; + const [registerDeepObject, getDeepObject] = defineServiceProxy('DeepObject'); + registerDeepObject(() => deepObj); + + isBackgroundMock.mockReturnValue(false); + const deepObject = getDeepObject(); + + await expect(deepObject.fn1()).resolves.toBe(expected1); + expect(fn1).toBeCalledTimes(1); + await expect(deepObject.path.to.fn2()).resolves.toBe(expected2); + expect(fn2).toBeCalledTimes(1); + }); +}); diff --git a/packages/proxy-service/src/defineServiceProxy.ts b/packages/proxy-service/src/defineServiceProxy.ts new file mode 100644 index 0000000..a1b04c1 --- /dev/null +++ b/packages/proxy-service/src/defineServiceProxy.ts @@ -0,0 +1,89 @@ +import { isBackground } from './isBackground'; +import { ProxyService, ProxyServiceConfig, Service } from './types'; +import { defineExtensionMessaging, ProtocolWithReturn } from '@webext-core/messaging'; +import get from 'get-value'; + +/** + * Utility for creating a service whose functions are executed in the background script regardless + * of the JS context they are called from. + * + * @param name A unique name for the service. Used to identify which service is being executed. + * @param config An object that allows configuration of the underlying messaging service + * + * @returns + * - `registerService`: Used to register your service in the background. Requires an `init()` callback used to create the actual service object. + * - `getService`: Used to get an instance of the service anywhere in the extension. + */ +export function defineServiceProxy( + name: string, + config?: ProxyServiceConfig, +): [ + registerService: ( + init: (...args: any[]) => TService | Promise, + ...args: any[] + ) => Promise, + getService: () => ProxyService, +] { + let service: TService | undefined; + + const messageKey = `proxy-service.${name}`; + const { onMessage, sendMessage } = defineExtensionMessaging<{ + [key: string]: ProtocolWithReturn<{ path?: string; args: any[] }, any>; + }>(config); + + /** + * Create and returns a "deep" proxy. Every property that is accessed returns another proxy, and + * when a function is called at any depth (0 to infinity), a message is sent to the background. + */ + function createProxy(path?: string): ProxyService { + const wrapped = (() => {}) as ProxyService; + const proxy = new Proxy(wrapped, { + // Executed when the object is called as a function + async apply(_target, _thisArg, args) { + const res = await sendMessage(messageKey, { + path, + args: args, + }); + return res; + }, + + // Executed when accessing a property on an object + get(target, propertyName, receiver) { + if (propertyName === '__proxy' || typeof propertyName === 'symbol') { + return Reflect.get(target, propertyName, receiver); + } + return createProxy(path == null ? propertyName : `${path}.${propertyName}`); + }, + }); + // @ts-expect-error: Adding a hidden property + proxy.__proxy = true; + return proxy; + } + + return [ + async function registerService( + init: (...args: TArgs) => TService | Promise, + ...args: TArgs + ): Promise { + service = await init(...args); + onMessage(messageKey, ({ data }) => { + const method = data.path == null ? service : get(service ?? {}, data.path); + if (method) return Promise.resolve(method.bind(service)(...data.args)); + }); + return service; + }, + + function getService() { + // Create proxy for non-background + if (!isBackground()) return createProxy(); + + // Register the service if it hasn't been registered yet + if (service == null) { + throw Error( + `Failed to get an instance of ${name}: in background, but registerService has not been called. Did you forget to call registerService?`, + ); + } + return service as ProxyService; + }, + ]; +} diff --git a/packages/proxy-service/src/index.ts b/packages/proxy-service/src/index.ts index a506956..5b83e01 100644 --- a/packages/proxy-service/src/index.ts +++ b/packages/proxy-service/src/index.ts @@ -1,3 +1,4 @@ export { defineProxyService } from './defineProxyService'; +export { defineServiceProxy } from './defineServiceProxy'; export { flattenPromise } from './flattenPromise'; export type { ProxyServiceConfig, ProxyService, DeepAsync } from './types';