Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: type-safe messaging api #899

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/messaging/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# WXT Messaging

Allows sending and receiving messages between different contexts in a Web Extension.

## Usage

Install the package:

```sh
npm i --save-dev @wxt-dev/messaging
pnpm i -D @wxt-dev/messaging
yarn add --dev @wxt-dev/messaging
bun i -D @wxt-dev/messaging
```

util/counter.ts

```ts
import { registerRpcService } from '@wxt-dev/messaging';

export class CounterService {
count = 0;

increment() {
this.count++;
}

decrement() {
this.count--;
}

reset() {
this.count = 0;
}

getCount() {
return this.count;
}
}
```

### background.ts

```ts
import { CounterService } from './util/counter.js';
import { registerRpcService } from '@wxt-dev/messaging';

export default defineBackground(() => {
registerRpcService('counter', new CounterService());
});
```

### content.ts

```ts
import type { CounterService } from './util/counter.js';
import { createMessageBridge } from '@wxt-dev/messaging';

export default defineContentScript({
matches: ['*://*/*'],
async main() {
const counter = createRpcProxy<CounterService>('counter');

await counter.increment();
console.log(await counter.getCount());
},
});
```
21 changes: 21 additions & 0 deletions packages/messaging/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@wxt-dev/messaging",
"version": "0.1.0",
"type": "module",
"scripts": {
"check": "check",
"test": "vitest",
"build": "buildc -- unbuild"
},
"devDependencies": {
"@aklinker1/check": "^1.3.1",
"publint": "^0.2.9",
"typescript": "^5.5.4",
"unbuild": "^2.0.0",
"vitest": "^2.0.4"
},
"dependencies": {
"nanoevents": "^9.0.0",
"serialize-error": "^11.0.3"
}
}
163 changes: 163 additions & 0 deletions packages/messaging/src/__tests__/rpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, it, expect, expectTypeOf, vi } from 'vitest';
import { AsyncRpcService, createRpcProxy, registerRpcService } from '../rpc';
import { createTestMessageTransport } from '../transports';

describe('RPC Messaging API', () => {
describe('AsyncRpcService types', () => {
it('should make non-async functions async', () => {
type input = (a: string, b: boolean) => number;
type expected = (a: string, b: boolean) => Promise<number>;

type actual = AsyncRpcService<input>;

expectTypeOf<actual>().toEqualTypeOf<expected>();
});

it('should not change already async functions', () => {
type input = (a: string, b: boolean) => Promise<number>;
type expected = input;

type actual = AsyncRpcService<input>;

expectTypeOf<actual>().toEqualTypeOf<expected>();
});

it('should make class functions async and set non-functions to never', () => {
class input {
a: number;
b(_: number): void {
throw Error('Not implemented');
}
c(_: boolean): Promise<number> {
throw Error('Not implemented');
}
}
type expected = {
a: never;
b: (_: number) => Promise<void>;
c: (_: boolean) => Promise<number>;
};

type actual = AsyncRpcService<input>;

expectTypeOf<actual>().toEqualTypeOf<expected>();
});

it('should convert deeply nested functions on objects to async', () => {
type input = {
a: number;
b: {
c: (_: number) => void;
d: boolean;
e: () => Promise<number>;
};
f: () => void;
};
type expected = {
a: never;
b: {
c: (_: number) => Promise<void>;
d: never;
e: () => Promise<number>;
};
f: () => Promise<void>;
};

type actual = AsyncRpcService<input>;

expectTypeOf<actual>().toEqualTypeOf<expected>();
});
});

describe('RPC Behavior', () => {
it('should support function services', async () => {
const name = 'test';
const service = (n: number) => n + 1;
const input = Math.random();
const expected = input + 1;
const transport = createTestMessageTransport();

registerRpcService(name, service, transport);
const proxy = createRpcProxy<typeof service>(name, transport);
const actual = await proxy(input);

expect(actual).toBe(expected);
});

it('should support object services', async () => {
const service = {
a: (n: number) => n + 1,
};
const input = Math.random();
const expected = input + 1;
const name = 'name';
const transport = createTestMessageTransport();

registerRpcService(name, service, transport);
const proxy = createRpcProxy<typeof service>(name, transport);
const actual = await proxy.a(input);

expect(actual).toEqual(expected);
});

it('should support class services', async () => {
class Service {
a(n: number) {
return n + 1;
}
}
const service = new Service();
const input = Math.random();
const expected = input + 1;
const name = 'name';
const transport = createTestMessageTransport();

registerRpcService(name, service, transport);
const proxy = createRpcProxy<typeof service>(name, transport);
const actual = await proxy.a(input);

expect(actual).toEqual(expected);
});

it('should support deeply nested services', async () => {
const service = {
a: {
b: (n: number) => n + 1,
},
};
const input = Math.random();
const expected = input + 1;
const name = 'name';
const transport = createTestMessageTransport();

registerRpcService(name, service, transport);
const proxy = createRpcProxy<typeof service>(name, transport);
const actual = await proxy.a.b(input);

expect(actual).toEqual(expected);
});

it('should bind `this` to the object containing the function being executed', async () => {
const service = {
a: {
b() {
return this;
},
},
c() {
return this;
},
};
const input = Math.random();
const expected = input + 1;
const name = 'name';
const transport = createTestMessageTransport();

registerRpcService(name, service, transport);
const proxy = createRpcProxy<typeof service>(name, transport);

expect(await proxy.a.b()).toBe(service.a);
expect(await proxy.c()).toBe(service);
});
});
});
23 changes: 23 additions & 0 deletions packages/messaging/src/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type {
GetProtocolData,
GetProtocolResponse,
ProtocolMap,
RemoveListener,
} from './types';

/**
* Create a messaging API that can directly send messages to any JS context.
*/
export function createMessageBridge<
TProtocolMap extends ProtocolMap = ProtocolMap,
>(): MessageBridge<TProtocolMap> {
throw Error('TODO');
}

export interface MessageBridge<TProtocolMap extends ProtocolMap> {
send: <TType extends keyof TProtocolMap>(
type: TType,
data: GetProtocolData<TProtocolMap, TType>,
) => GetProtocolResponse<TProtocolMap, TType>;
onMessage: () => RemoveListener;
}
14 changes: 14 additions & 0 deletions packages/messaging/src/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ProtocolMap } from './types';

/**
* TODO - events API
*/
export function createGlobalEvents<
TProtocolMap extends ProtocolMap = ProtocolMap,
>(): EventBus<TProtocolMap> {
throw Error('TODO');
}

export interface EventBus<TProtocolMap extends ProtocolMap> {
// TODO
}
3 changes: 3 additions & 0 deletions packages/messaging/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './rpc';
export * from './transports';
export * from './types';
Loading