Skip to content

Instantly share code, notes, and snippets.

@crabmusket
Last active February 9, 2023 01:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save crabmusket/14d1b74e6120107531ac5e6064ac93dd to your computer and use it in GitHub Desktop.
Save crabmusket/14d1b74e6120107531ac5e6064ac93dd to your computer and use it in GitHub Desktop.
Helper for sending JSON-RPC to/from an iframe

Frame RPC helper

This set of helpers makes it easy to communicate with an iframe via JSON RPC.

This gist contains both the TypeScript source code, and a stripped JavaScript version.

If you have an iframe on your page you want to communicate with, download the source code and use it like so:

import {makeRpcClient, makeRpcServer} from "./framerpc.js";

const iframe = document.getElementById("your-element-id");

// To communicate with the iframe, let's act as both a server and a client.
const server = makeRpcServer(window);
const client = makeRpcClient({
    send: iframe.contentWindow,
    listen: window,
});

// Define methods for the server to respond do:
server.methods.set("ping", request => {
    return {
        jsonrpc: "2.0",
        id: request.id,
        result: "pong",
    };
});

// Send a request to the iframe:
client.request({
    jsonrpc: "2.0",
    id: crypto.randomUUID(), // this may be overkill
    method: "ping",
}).then(response => console.log(response));
// Helpers for performing JSON-RPC communication between iframes on a page.
// This helper includes both a server part and a client part. If you want bidirectional
// communication, each frame can create both a server and a client.
/** Make a server which listens on the given port, and responds to the message source. */
export function makeRpcServer(port) {
return new Server().listen(port);
}
/** Make a client which sends requests to a specific port, and listens on a different port. */
export function makeRpcClient(params) {
return new Client(params.send).listen(params.listen);
}
class Server {
constructor() {
this.methods = new Map();
this.listening = new Map();
}
listen(port) {
const cb = async (event) => {
const message = event;
const data = message.data;
if (isRpcRequest(data)) {
this.handleRequest(data, message.source);
}
};
this.listening.set(port, cb);
port.addEventListener("message", cb);
return this;
}
async handleRequest(request, source) {
const handler = this.methods.get(request.method) || methodNotFound;
const result = await handler(request, {
source,
});
if (result && request.id) {
source?.postMessage(result, "*");
}
}
}
class Client {
constructor(sendPort) {
this.port = sendPort;
this.requests = new Map();
this.listening = new Map();
}
listen(port) {
const cb = async (event) => {
const message = event;
const data = message.data;
if (isRpcResponse(data)) {
this.handleResponse(data);
}
};
this.listening.set(port, cb);
port.addEventListener("message", cb);
return this;
}
async request(request) {
const id = request.id;
if (!id) {
// Send immediately if it's just a notification.
this.port.postMessage(request, "*");
return null;
}
// If it's a request with an ID, we need to make sure it's not a duplicate.
if (this.requests.get(id)) {
console.warn("conflicting ID, request is already in progress", id, this);
throw new Error("request with same ID already in progress");
}
return new Promise((resolve, reject) => {
this.port.postMessage(request, "*");
this.requests.set(id, { request, resolve, reject });
});
}
handleResponse(response) {
const id = response.id;
if (!id) {
// TODO: loose response event
console.warn("response received with no id");
return;
}
const record = this.requests.get(id);
if (!record) {
console.warn("could not find outstanding request to client with id; are you using more than one client?", id, response, this);
return;
}
this.requests.delete(id);
record.resolve(response);
}
}
/** Is `data` a valid JSONRPC request? */
export function isRpcRequest(data) {
if (!data) {
return false;
}
if (Object.getPrototypeOf(data) !== Object.prototype) {
return false;
}
const candidate = data;
return candidate.jsonrpc === "2.0"
&& typeof candidate.method === "string"
&& (!Object.hasOwn(candidate, "id")
|| candidate.id === null
|| typeof candidate.id === "string"
|| typeof candidate.id === "number")
&& (!Object.hasOwn(candidate, "params")
|| Object.getPrototypeOf(candidate) === Object.prototype
|| Array.isArray(candidate.params));
}
/** Is `data` a valid JSONRPC response? */
export function isRpcResponse(data) {
if (!data) {
return false;
}
if (Object.getPrototypeOf(data) !== Object.prototype) {
return false;
}
const candidate = data;
return candidate.jsonrpc === "2.0"
&& (!Object.hasOwn(candidate, "id")
|| candidate.id === null
|| typeof candidate.id === "string"
|| typeof candidate.id === "number")
&& (Object.hasOwn(candidate, "result")
|| (Object.hasOwn(candidate, "error")
&& Object.getPrototypeOf(candidate.error) === Object.prototype
&& typeof candidate.error?.code === "number"
&& typeof candidate.error?.message === "string"));
}
async function methodNotFound(request) {
return {
jsonrpc: "2.0",
id: request.id || null,
error: {
code: -32601,
message: "Method not found: " + request.method,
},
};
}
// Helpers for performing JSON-RPC communication between iframes on a page.
// This helper includes both a server part and a client part. If you want bidirectional
// communication, each frame can create both a server and a client.
/** A loosely-typed JSON-RPC message. Apply your own refinements using casting or parsing. */
export type Request = {
jsonrpc: "2.0";
method: string;
id?: string | number | null;
params?: Record<string, unknown> | Array<unknown>;
};
/** Used for typechecking the Request type in a way that tsc doesn't complain about. */
type MaybeRequest = {
jsonrpc?: unknown;
method?: unknown;
id?: unknown;
params?: unknown;
};
/** Is `data` a valid JSONRPC request? */
export function isRpcRequest(data: unknown): data is Request {
if (!data) {
return false;
}
if (Object.getPrototypeOf(data) !== Object.prototype) {
return false;
}
const candidate = data as MaybeRequest;
return candidate.jsonrpc === "2.0"
&& typeof candidate.method === "string"
&& (
!Object.hasOwn(candidate, "id")
|| candidate.id === null
|| typeof candidate.id === "string"
|| typeof candidate.id === "number"
)
&& (
!Object.hasOwn(candidate, "params")
|| Object.getPrototypeOf(candidate) === Object.prototype
|| Array.isArray(candidate.params)
);
}
/** A loosely-typed JSON-RPC response. */
export type Response = {
jsonrpc: "2.0";
id: string | number | null;
result: unknown;
} | {
jsonrpc: "2.0";
id: string | number | null;
error: {
code: number;
message: string;
data?: unknown;
};
};
/** Used for typechecking the Response type in a way that tsc doesn't complain about. */
type MaybeResponse = {
jsonrpc?: unknown;
id?: unknown;
result?: unknown;
error?: {
code?: unknown;
message?: unknown;
};
};
export function isRpcResponse(data: unknown): data is Response {
if (!data) {
return false;
}
if (Object.getPrototypeOf(data) !== Object.prototype) {
return false;
}
const candidate = data as MaybeResponse;
return candidate.jsonrpc === "2.0"
&& (
!Object.hasOwn(candidate, "id")
|| candidate.id === null
|| typeof candidate.id === "string"
|| typeof candidate.id === "number"
)
&& (
Object.hasOwn(candidate, "result")
|| (
Object.hasOwn(candidate, "error")
&& Object.getPrototypeOf(candidate.error) === Object.prototype
&& typeof candidate.error?.code === "number"
&& typeof candidate.error?.message === "string"
)
);
}
async function methodNotFound(request: Request): Promise<Response> {
console.warn("method not found: " + request.method);
return {
jsonrpc: "2.0",
id: request.id || null,
error: {
code: -32601,
message: "Method not found: " + request.method,
},
};
}
class Server {
readonly methods: ServerMethods;
private listening: Map<Window, unknown>;
constructor() {
this.methods = new Map();
this.listening = new Map();
}
listen(port: Window): this {
const cb = async (event: Event) => {
const message = event as MessageEvent;
const data = message.data;
if (isRpcRequest(data)) {
this.handleRequest(data, message.source as Window);
}
};
this.listening.set(port, cb);
port.addEventListener("message", cb);
return this;
}
private async handleRequest(request: Request, source: Window | null) {
const handler = this.methods.get(request.method) || methodNotFound;
const result = await handler(request, {
source,
});
if (result && request.id) {
source?.postMessage(result, "*");
}
}
}
class Client {
readonly port: Window;
private requests: Map<string | number, {request: Request; resolve: any; reject: any}>;
private listening: Map<Window, unknown>;
constructor(sendPort: Window) {
this.port = sendPort;
this.requests = new Map();
this.listening = new Map();
}
listen(port: Window): this {
const cb = async (event: Event) => {
const message = event as MessageEvent;
const data = message.data;
if (isRpcResponse(data)) {
this.handleResponse(data);
}
};
this.listening.set(port, cb);
port.addEventListener("message", cb);
return this;
}
async request(request: Request): Promise<Response | null> {
const id = request.id;
if (!id) {
// Send immediately if it's just a notification.
this.port.postMessage(request, "*");
return null;
}
// If it's a request with an ID, we need to make sure it's not a duplicate.
if (this.requests.get(id)) {
console.warn("conflicting ID, request is already in progress", id, this);
throw new Error("request with same ID already in progress");
}
return new Promise((resolve, reject) => {
this.port.postMessage(request, "*");
this.requests.set(id, {request, resolve, reject});
});
}
private handleResponse(response: Response) {
const id = response.id;
if (!id) {
// TODO: loose response event
console.warn("response received with no id");
return;
}
const record = this.requests.get(id);
if (!record) {
console.warn(
"could not find outstanding request to client with id; are you using more than one client?",
id,
response,
this,
);
return;
}
this.requests.delete(id);
record.resolve(response);
}
}
type ServerMethodHandler = (request: Request, ctx: ServerMethodContext) => Promise<Response> | Promise<void>;
export type ServerMethods = Map<string, ServerMethodHandler>;
type ServerMethodContext = {
source: Window | null;
};
/** Make a server which listens on the given port, and responds to the message source. */
export function makeRpcServer(port: Window): Server {
return new Server().listen(port);
}
/** Make a client which sends requests to a specific port, and listens on a different port. */
export function makeRpcClient(params: {
send: Window;
listen: Window;
}): Client {
return new Client(params.send).listen(params.listen);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment