|
// 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); |
|
} |