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 4 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
20 changes: 20 additions & 0 deletions packages/messaging-aaron/package.json
Timeraa marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@wxt-dev/messaging-aaron",
"version": "0.1.0",
"type": "module",
"scripts": {
"check": "check",
"test": "vitest"
},
"devDependencies": {
"@aklinker1/check": "^1.3.1",
"@types/chrome": "^0.0.269",
"nanoevents": "^9.0.0",
"publint": "^0.2.9",
"typescript": "^5.5.4",
"vitest": "^2.0.4"
},
"dependencies": {
"serialize-error": "^11.0.3"
}
}
163 changes: 163 additions & 0 deletions packages/messaging-aaron/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-aaron/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-aaron/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-aaron/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';
106 changes: 106 additions & 0 deletions packages/messaging-aaron/src/rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createPortMessageTransport } from './transports';
import { MessageTarget, MessageTransport, RemoveListener } from './types';

//
// PUBLIC API
//

/**
* Register a service to execute when calling functions on a RPC proxy of the
* same name in a different JS context. This function sets up a message
* listener using the provided transport, and executes function specified, and
* returns the response.
*
* @param name A unique identifier that connects the service passed into this
* function to RPC proxies created in other contexts.
* @param service The function, class, object, or deeply nested object that
* will be executed when the RPC proxy in the other context is
* called.
* @param transport The transport used when listening for messages. Defaults to
* using `createPortMessageTransport()`.
*/
export function registerRpcService<TService>(
name: string,
service: TService,
transport: MessageTransport = createPortMessageTransport(),
): RemoveListener {
const messageType = getMessageType(name);

return transport.onRequest<string, ProxyMessageData, unknown>(
messageType,
async ({ path, params }) => {
const { fn, thisArg } = path.reduce<any>(
({ fn }, key) => ({ fn: fn[key], thisArg: fn }),
{ fn: service, thisArg: undefined },
);
console.log({ thisArg, params, path, fn });
return await fn.apply(thisArg, params);
},
);
}

export function createRpcProxy<TService>(
name: string,
transport: MessageTransport = createPortMessageTransport(),
): AsyncRpcService<TService> {
const messageType = getMessageType(name);

const deepProxy = (path: string[] = []): any => {
const proxy = new Proxy(() => {}, {
// Executed when the proxy is called as a function
apply(_target, _thisArg, params) {
return transport.sendRequest<string, ProxyMessageData, unknown>(
messageType,
{ path, params },
);
},

// Executed when accessing a property on the proxy
get(target, propertyName, receiver) {
if (propertyName === '__proxy' || typeof propertyName === 'symbol') {
return Reflect.get(target, propertyName, receiver);
}
return deepProxy([...path, propertyName]);
},
});
// @ts-expect-error: Adding a hidden property
proxy.__proxy = true;
return proxy;
};

return deepProxy();
}

function getMessageType(name: string): string {
return `wxt:rpc-proxy:${name}`;
}

//
// TYPES
//

export type RpcFunction = (...args: any[]) => any;

/**
* Ensure's a function's return type is a promise.
*/
export type AsyncRpcFunction<T extends RpcFunction> =
T extends () => Promise<any>
? T
: (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>>;

/**
* Ensures the return type of all functions in TService are promises.
*/
export type AsyncRpcService<TService> = TService extends RpcFunction
? AsyncRpcFunction<TService>
: TService extends Record<string, any>
? {
[key in keyof TService]: AsyncRpcService<TService[key]>;
}
: never;

interface ProxyMessageData {
path: string[];
params: any[];
}
Loading