JSON-RPC & postMessage on the encapsulation skills of browser message communication

Wedge

postMessage It is commonly used in embedded iframe or Web Workers for cross page (thread) message communication. Similar shadows can be seen in some other development environments, such as Chrome plug-in environment, Electron environment, sigma plug-in, etc.

In recent work, we often deal with iframe and Web Workers, deal with pages and embedded pages, and the main thread communicates with workers. We have developed a tool library for dealing with browser message communication rpc-shooter , covering the main message communication interface support of the browser:

Here we share some experience and skills in the development process.

Fundamentalism

Let's take a look at an example of iframe parent-child page communication:

// parent.js
const childWindow = document.querySelector('iframe').contentWindow;
window.addEventListener('message', function (event) {
    const data = event.data;
    if (data.method === 'do_something') {
        // ... handle iframe data
        childWindow.postMessage({
            method: 're:do_something',
            data: 'some data',
        });
    }
});

// iframe.js
window.top.postMessage(
    {
        method: 'do_something',
        data: 'ifame data',
    },
    '*'
);
window.addEventListener('message', function (event) {
    const data = event.data;
    if (data.method === 're:do_something') {
        // ... handle parent data
    }
});

Using the fundamentalist writing method can easily write the above code, and there will be no problem in dealing with simple message communication. However, for cross page (thread) communication in complex scenarios, there needs to be a simple and effective mechanism to maintain message communication.

Smart, you must have thought of the method call to maintain message events based on the unified message format and the corresponding message processing strategy. It is a very simple mechanism, but it is easy to use:

const childWindow = document.querySelector('iframe').contentWindow;
const handlers = {
    add: (a: number, b: number) => a + b,
    subtract: (a: number, b: number) => a - b,
};
window.addEventListener('message', function (event) {
    const { method, args } = event.data;
    const result = handlers[method](...args);
    childWindow.postMessage({
        method: `re:${method}`,
        args: [result],
    });
});

Using the above processing method, the processing of message communication can maintain a policy processing function, and the next work is also based on this, just add a little "detail".

Event encapsulation

Message communication itself is a kind of event, so you might as well move towards the direction of event encapsulation. At this time, there are many interface designs that can be used for reference, which can be used for reference here socket.io Interface design. Compared with the local event call, the essence of message communication is to listen for the events sent by the remote service, and connect with the socket IO similar:

// client
socket.emit('join-in', input.value);
// server
socket.on('join-in',(user) => {...});

Interface oriented

For the packaging design of a tool function (Library), it is best to start from the interface. The interface design can directly determine the final tool use form. This is also the development mode change brought by Typescript. Interface oriented design can help us better assemble modules to achieve the purpose of decoupling.

Encapsulated interface format definition:

interface RPCHandler {
    (...args: any[]): any;
}

interface RPCEvent {
    emit(event: string, ...args: any[]): void;
    on(event: string, fn: RPCHandler): void;
    off(event: string, fn?: RPCHandler): void;
}

Based on the interface defined above, take the parent-child communication of iframe as an example to encapsulate the tool library:

interface RPCHandler {
    (...args: any[]): any;
}

interface RPCEvent {
    emit(event: string, ...args: any[]): void;
    on(event: string, fn: RPCHandler): void;
    off(event: string, fn?: RPCHandler): void;
}

interface RPCMessageDataFormat {
    event: string;
    args: any[];
}

interface RPCMessageEventOptions {
    currentEndpoint: Window;
    targetEndpoint: Window;
    targetOrigin: string;
}

class RPCMessageEvent implements RPCEvent {
    private _currentEndpoint: RPCMessageEventOptions['currentEndpoint'];
    private _targetEndpoint: RPCMessageEventOptions['targetEndpoint'];
    private _targetOrigin: RPCMessageEventOptions['targetOrigin'];
    private _events: Record<string, Array<RPCHandler>>;

    constructor(options: RPCMessageEventOptions) {
        this._events = {};
        this._currentEndpoint = options.currentEndpoint;
        this._targetEndpoint = options.targetEndpoint;
        this._targetOrigin = options.targetOrigin;
        // Listen for remote message events
        const receiveMessage = (event: MessageEvent) => {
            const { data } = event;
            const eventHandlers = this._events[data.event] || [];
            if (eventHandlers.length) {
                eventHandlers.forEach((handler) => {
                    handler(...(data.args || []));
                });
                return;
            }
        };
        this._currentEndpoint.addEventListener(
            'message',
            receiveMessage as EventListenerOrEventListenerObject,
            false
        );
    }

    emit(event: string, ...args: any[]): void {
        const data: RPCMessageDataFormat = {
            event,
            args,
        };
        // postMessage
        this._targetEndpoint.postMessage(data, this._targetOrigin);
    }

    on(event: string, fn: RPCHandler): void {
        if (!this._events[event]) {
            this._events[event] = [];
        }
        this._events[event].push(fn);
    }

    off(event: string, fn?: RPCHandler): void {
        if (!this._events[event]) return;
        if (!fn) {
            this._events[event] = [];
            return;
        }
        const handlers = this._events[event] || [];
        this._events[event] = handlers.filter((handler) => handler !== fn);
    }
}

The classic event implementation, which will not be repeated here, is used as follows:

// Parent page
const childWindow = document.querySelector('iframe').contentWindow;
const parentEvent: RPCEvent = new RPCMessageEvent({
    targetEndpoint: window,
    currentEndpoint: childWindow,
    targetOrigin: '*',
});
parentEvent.on('add', (a, b) => a + b);
parentEvent.emit('test');

// Child page
const childEvent: RPCEvent = new RPCMessageEvent({
    targetEndpoint: window,
    currentEndpoint: window.top,
    targetOrigin: '',
});
childEvent.emit('add', 1, 2);
childEvent.on('test', () => {});
childEvent.on('max', (a, b) => Math.max(a, b));
childEvent.off('max');

Consider a question. The above implements the message communication encapsulation of parent-child window objects. Can it be generalized to support all browser message events?

The answer is yes. Take a look at the Window encapsulation initialization options of events:

interface RPCMessageEventOptions {
    currentEndpoint: Window;
    targetEndpoint: Window;
    targetOrigin: string;
}

The event receiving and sending objects here are windows, but in fact we just rely on:

  • message event on currentEndpoint
  • The postMessage method on targetEndpoint and its configuration

In other words, as long as other objects in the browser support message events and postMessage methods, the same encapsulation can be realized, that is, the interface can be satisfied.

interface RPCMessageEventOptions {
    currentEndpoint: {
        addEventListener<K extends keyof MessagePortEventMap>(
            type: K,
            listener: (
                this: RPCMessageEventOptions['currentEndpoint'],
                ev: MessagePortEventMap[K]
            ) => any,
            options?: boolean | AddEventListenerOptions
        ): void;
    };
    targetEndpoint: {
        postMessage(message: any, ...args: any[]): void;
    };
}

Communication interface in browser

The following are the main objects that currently support message communication in browsers, all of which implement similar message event interfaces:

interface MessagePort extends EventTarget {
    postMessage(message: any, transfer: Transferable[]): void;
    postMessage(message: any, options?: StructuredSerializeOptions): void;
    addEventListener<K extends keyof MessagePortEventMap>(
        type: K,
        listener: (this: MessagePort, ev: MessagePortEventMap[K]) => any,
        options?: boolean | AddEventListenerOptions
    ): void;
    addEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | AddEventListenerOptions
    ): void;
    removeEventListener<K extends keyof MessagePortEventMap>(
        type: K,
        listener: (this: MessagePort, ev: MessagePortEventMap[K]) => any,
        options?: boolean | EventListenerOptions
    ): void;
    removeEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | EventListenerOptions
    ): void;
}

Interested students can turn to lib dom. d. TS interface definitions are sometimes clearer than documents:

To sum up, we can use the whole ultimate sewing monster to adapt to all interfaces:

// Interface definition of message sending object
interface AbstractMessageSendEndpoint {
    // BroadcastChannel
    postMessage(message: any): void;
    // Wroker && ServiceWorker && MessagePort
    postMessage(message: any, transfer: Transferable[]): void;
    postMessage(message: any, options?: StructuredSerializeOptions): void;
    // window
    postMessage(message: any, options?: WindowPostMessageOptions): void;
    postMessage(message: any, targetOrigin: string, transfer?: Transferable[]): void;
}

// Interface definition of message receiving object
interface AbstractMessageReceiveEndpoint extends EventTarget, AbstractMessageSendEndpoint {
    onmessage?: ((this: AbstractMessageReceiveEndpoint, ev: MessageEvent) => any) | null;
    onmessageerror?: ((this: AbstractMessageReceiveEndpoint, ev: MessageEvent) => any) | null;
    close?: () => void;
    start?: () => void;

    addEventListener<K extends keyof MessagePortEventMap>(
        type: K,
        listener: (this: AbstractMessageReceiveEndpoint, ev: MessagePortEventMap[K]) => any,
        options?: boolean | AddEventListenerOptions
    ): void;
    addEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | AddEventListenerOptions
    ): void;
    removeEventListener<K extends keyof MessagePortEventMap>(
        type: K,
        listener: (this: AbstractMessageReceiveEndpoint, ev: MessagePortEventMap[K]) => any,
        options?: boolean | EventListenerOptions
    ): void;
    removeEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | EventListenerOptions
    ): void;
}

Pay attention to the definition of the postMessage interface. The actual use of the WindowPostMessageOptions interface can cover all current message communications, including targetOrigin and transfer configurations.

interface StructuredSerializeOptions {
    transfer?: Transferable[];
}

interface WindowPostMessageOptions extends StructuredSerializeOptions {
    targetOrigin?: string;
}

interface AbstractMessageSendEndpoint {
    postMessage(message: any, options?: WindowPostMessageOptions): void;
}

The final event initialization option interface is as follows. A config configuration item is added to pass configuration parameters to postMessage:

interface RPCMessageEventOptions {
    currentEndpoint: AbstractMessageReceiveEndpoint;
    targetEndpoint: AbstractMessageSendEndpoint;
    config?:
        | ((data: any, context: AbstractMessageSendEndpoint) => WindowPostMessageOptions)
        | WindowPostMessageOptions;
}

The specific encapsulation implementation can be seen here Implementation of RPCMessageEvent , interface oriented design can well abstract the same kind of problems. Even if the browser adds a new communication mechanism in the future, as long as it still meets the interface configuration, our encapsulation is still effective.

Remote procedure call (RPC)

After the above encapsulation, we get an event driven message communication tool, but this is not enough, because its use is still atomic (primitive), and it is cumbersome to process message replies. For example:

import { RPCMessageEvent } from 'rpc-shooter';
// main
const mainEvent = new RPCMessageEvent({
    currentEndpoint: window,
    targetEndpoint: iframe.contentWindow,
    config: {
        targetOrigin: '*',
    },
});
mainEvent.on('reply:max', (data) => {
    console.log('invoke max result:', data);
});
mainEvent.emit('max', 1, 2);

// child
const childEvent = new RPCMessageEvent({
    currentEndpoint: window,
    targetEndpoint: window.top,
});
childEvent.on('max', (a, b) => {
    const result = Math.max(a, b);
    childEvent.emit('reply:max', result);
});

When calling the max method of child in main, you also need to listen to a reply (reply:max) event in a child. Child accepts a successful reply:max event after receiving the message calling method. This time, it is not elegant. If you don't see it, you need to do another layer of packaging to trigger and respond to packaging events.

promisify

Asynchronous events naturally use Promise, which is reasonable and easy to encapsulate:

// child
function registerMethod(method: string, handler: RPCHandler) {
    const synEventName = `syn:${method}`;
    const ackEventName = `ack:${method}`;
    const synEventHandler = (data) => {
        Promise.resolve(handler(data.params)).then((result) => {
            this._event.emit(ackEventName, result);
        });
    };
    this._event.on(synEventName, synEventHandler);
}
registerMethod('max', ([a, b]) => Math.max(a, b));

// main
function invoke(method: string, params: any): Promise<any> {
    return new Promise((resolve) => {
        const synEventName = `syc:${method}`;
        const ackEventName = `ack:${method}`;
        this._event.emit(synEventName, params);
        this._event.on(ackEventName, (res) => {
            resolve(res);
        });
    });
}
invoke('max', [1, 2]).then((res) => {
    console.log(res);
});

The caller emit s an event with syc: prefix. The callee registers and listens for the event with the same name. After the message is called successfully, an event with ack: prefix will be replied. The caller listens to ack: event to identify that the message is successful. Promise resolve.

Promise is simple, but there are various problems when using message communication in practice:

  • Remote method call error
  • Calling method does not exist
  • connection timed out
  • The data format is wrong (for example, a dom object that cannot be serialized is passed in error in the worker)
  • ......

We need to describe various situations of the communication process.

In fact, the web message communication process and RPC Calling is very similar to calling a method of a remote service. And there happened to be one JSON-RPC The protocol specification can describe this process very simply and clearly, which may be borrowed for use.

JSON-RPC

JSON-RPC is a stateless and lightweight remote procedure call (RPC) protocol. This specification mainly defines some data structures and their related processing rules. It allows to run in the same process based on socket,http and many other different message transmission environments. Its use JSON(RFC 4627 )As a data format.

There are only a few hundred students who are interested in RPC specification, and only a few hundred students are interested in http-json specification JSON-RPC 2.0 specification.

Here we mainly look at the data format of JSON-RPC defined request and response:

// Wrong object
interface RPCError {
    code: number;
    message: string;
    data: any;
}

// RPC request object
interface RPCSYNEvent {
    jsonrpc: '2.0';
    method: string;
    params: any;
    id?: string;
}

// RPC response
interface RPCSACKEvent {
    jsonrpc: '2.0';
    result?: any;
    error?: RPCError;
    id?: string;
}

rpc calls with indexed array parameters:

--> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
<-- {"jsonrpc": "2.0", "result": 19, "id": 1}

Notice:

--> {"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}
--> {"jsonrpc": "2.0", "method": "foobar"}

rpc call without calling method:

--> {"jsonrpc": "2.0", "method": "foobar", "id": "1"}
<-- {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}

The most important rules in the specification are as follows:

id

The unique identification id of the established client. The value must contain a string, numeric value or NULL value. If the member is not included, it is deemed to be a notice. This value is generally not NULL[[1]]( http://wiki.geekdream.com/Spe... ), if it is a numeric value, it should not contain decimal [[2]]( http://wiki.geekdream.com/Spe...).

Each call needs a unique id to identify this call, because we may call the same remote service many times, so we need an id to identify each call. If there is no id, it means that the caller does not care about the call result (it means that this call is a notification).

error and result

The response object must contain a result or error member, but the two members must not contain both.

If there is an empty object, the call returns error. If there is an empty object, the call returns error. If there is an empty object, the call returns error, which means the call failed.

JOSN-RPC protocol simply and clearly describes the data request and response. We only need to encapsulate the Promise call according to its requirements, resolve when successful, and reject when failed.

Encapsulation implementation

It's still the old rule. Let's look at the interface definition first:

interface RPCHandler {
    (...args: any[]): any;
}

interface RPCEvent {
    emit(event: string, ...args: any[]): void;
    on(event: string, fn: RPCHandler): void;
    off(event: string, fn?: RPCHandler): void;
}

interface RPCInitOptions {
    event: RPCEvent;
    methods?: Record<string, RPCHandler>;
    timeout?: number;
}

interface RPCInvokeOptions {
    isNotify: boolean;
    timeout?: number;
}

declare class RPC {
    private _event;
    private _methods;
    static uuid(): string;
    constructor(options: RPCInitOptions);
    registerMethod(method: string, handler: RPCHandler): void;
    removeMethod(method: string): void;
    invoke(method: string, params: any, options?: RPCInvokeOptions): Promise<any>;
}

See the specific package RPC implementation The final RPC tools are as follows:

// main.ts
import { RPCMessageEvent, RPC } from 'rpc-shooter';

(async function () {
    const iframe = document.querySelector('iframe')!;
    const rpc = new RPC({
        event: new RPCMessageEvent({
            currentEndpoint: window,
            targetEndpoint: iframe.contentWindow!,
            config: { targetOrigin: '*' },
        }),
        // Register processing function on initialization
        methods: {
            'Main.max': (a: number, b: number) => Math.max(a, b),
        },
    });
    // Dynamic registration processing function
    rpc.registerMethod('Main.min', (a: number, b: number) => {
        return Promise.resolve(Math.min(a, b));
    });

    // Call the registration method in the iframe service
    const randomValue = await rpc.invoke('Child.random', null, { isNotify: false, timeout: 2000 });
    console.log(`Main invoke Child.random result: ${randomValue}`);
})();
// child.ts
import { RPCMessageEvent, RPC } from 'rpc-shooter';
(async function () {
    const rpc = new RPC({
        event: new RPCMessageEvent({
            currentEndpoint: window,
            targetEndpoint: window.top,
        }),
    });

    rpc.registerMethod('Child.random', () => Math.random());

    const max = await rpc.invoke('Main.max', [1, 2]);
    const min = await rpc.invoke('Main.min', [1, 2]);
    console.log({ max, min });
})();

It should be noted that in RPC initialization, we only rely on the RPCEvent interface. The communication of the browser is realized by the RPCMessageEvent module. We can also replace it with other business implementations, such as using socket.io To replace RPCMessageEvent to achieve the purpose of communication with the server, another advantage of interface oriented development.

So far, we have completed the encapsulation from basic message communication to page RPC service call. Students interested in implementation details can stamp: rpc-shooter Welcome advice.

Note: Google professional tool library for worker calls comlink , students with production needs can try.

other

rpc-shooter I've learned a lot in the development process of. It's also a small tool that I write very carefully at present. If you have the courage, you might as well try it.

Personal feelings are:

  • TS really smells good
  • Interface priority, interface priority, or interface priority


over~

Keywords: Front-end TypeScript

Added by Ting on Thu, 10 Mar 2022 08:26:20 +0200