299 lines
9.3 KiB
TypeScript
299 lines
9.3 KiB
TypeScript
import {
|
|
useRef,
|
|
useEffect,
|
|
useCallback,
|
|
useImperativeHandle,
|
|
forwardRef,
|
|
type IframeHTMLAttributes,
|
|
} from "react";
|
|
import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from "@webxdc/types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface WebxdcProps
|
|
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, "src" | "id"> {
|
|
/** Unique session identifier — used as the subdomain: `<id>.webxdc.app`. */
|
|
id: string;
|
|
/** The `.xdc` archive: raw bytes or a URL to fetch them from. */
|
|
xdc: Uint8Array | string;
|
|
/** A `Webxdc` instance that backs the iframe's webxdc API calls. */
|
|
webxdc: WebxdcAPI<unknown>;
|
|
}
|
|
|
|
/** Imperative handle exposed by the Webxdc component. */
|
|
export interface WebxdcHandle {
|
|
/** Send a postMessage to the iframe (used for synthetic keyboard events). */
|
|
postMessage: (msg: Record<string, unknown>, transfer?: Transferable[]) => void;
|
|
/** Focus the iframe element. */
|
|
focus: () => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Resolve `xdc` prop to an ArrayBuffer. */
|
|
async function resolveXdc(xdc: Uint8Array | string): Promise<ArrayBuffer> {
|
|
if (typeof xdc === "string") {
|
|
const res = await fetch(xdc);
|
|
if (!res.ok) throw new Error(`Failed to fetch xdc: ${res.status}`);
|
|
return res.arrayBuffer();
|
|
}
|
|
// Uint8Array → ArrayBuffer (copy so we can transfer)
|
|
const copy = new ArrayBuffer(xdc.byteLength);
|
|
new Uint8Array(copy).set(xdc);
|
|
return copy;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Renders a webxdc app inside an iframe hosted on `<id>.webxdc.app`.
|
|
*
|
|
* The component handles the full JSON-RPC lifecycle:
|
|
* 1. Waits for `webxdc.ready` from the frame.
|
|
* 2. Sends `webxdc.init` with the `.xdc` bytes.
|
|
* 3. Proxies every JSON-RPC request to the provided `Webxdc` instance.
|
|
* 4. Forwards `webxdc.update` notifications into the frame.
|
|
*/
|
|
export const Webxdc = forwardRef<WebxdcHandle, WebxdcProps>(function Webxdc(
|
|
{ id, xdc, webxdc, ...iframeProps },
|
|
ref,
|
|
) {
|
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
|
|
// Keep latest props in refs so the message handler always sees current values
|
|
// without needing to re-register the listener.
|
|
const webxdcRef = useRef(webxdc);
|
|
const xdcRef = useRef(xdc);
|
|
useEffect(() => {
|
|
webxdcRef.current = webxdc;
|
|
}, [webxdc]);
|
|
useEffect(() => {
|
|
xdcRef.current = xdc;
|
|
}, [xdc]);
|
|
|
|
const origin = `https://${id}.webxdc.app`;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Post a JSON-RPC message to the iframe
|
|
// ------------------------------------------------------------------
|
|
const post = useCallback(
|
|
(msg: Record<string, unknown>, transfer?: Transferable[]) => {
|
|
iframeRef.current?.contentWindow?.postMessage(
|
|
msg,
|
|
origin,
|
|
transfer ?? [],
|
|
);
|
|
},
|
|
[origin],
|
|
);
|
|
|
|
// Expose imperative handle so parent components can post messages and focus.
|
|
useImperativeHandle(ref, () => ({
|
|
postMessage: (msg: Record<string, unknown>, transfer?: Transferable[]) => {
|
|
iframeRef.current?.contentWindow?.postMessage(
|
|
msg,
|
|
origin,
|
|
transfer ?? [],
|
|
);
|
|
},
|
|
focus: () => {
|
|
iframeRef.current?.focus();
|
|
},
|
|
}), [origin]);
|
|
|
|
// ------------------------------------------------------------------
|
|
// Handle messages coming from the iframe
|
|
// ------------------------------------------------------------------
|
|
useEffect(() => {
|
|
function onMessage(event: MessageEvent) {
|
|
// Only accept messages from our iframe's origin.
|
|
if (event.origin !== origin) return;
|
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const msg = event.data as any;
|
|
if (!msg || msg.jsonrpc !== "2.0") return;
|
|
|
|
const api = webxdcRef.current;
|
|
|
|
// --- Notification: webxdc.ready → send webxdc.init ---------------
|
|
if (msg.method === "webxdc.ready" && msg.id === undefined) {
|
|
resolveXdc(xdcRef.current).then((buf) => {
|
|
const initMsg = {
|
|
jsonrpc: "2.0" as const,
|
|
method: "webxdc.init",
|
|
params: {
|
|
xdc: buf,
|
|
selfAddr: api.selfAddr,
|
|
selfName: api.selfName,
|
|
sendUpdateInterval: api.sendUpdateInterval,
|
|
sendUpdateMaxSize: api.sendUpdateMaxSize,
|
|
},
|
|
};
|
|
iframeRef.current?.contentWindow?.postMessage(
|
|
initMsg,
|
|
origin,
|
|
[buf], // transfer
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// --- Requests (have an `id`) ------------------------------------
|
|
if (msg.id !== undefined && msg.method) {
|
|
handleRequest(msg.id, msg.method, msg.params ?? {});
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async function handleRequest(id: string | number, method: string, params: any) {
|
|
const api = webxdcRef.current;
|
|
|
|
const respond = (result: unknown) =>
|
|
post({ jsonrpc: "2.0", id, result });
|
|
const respondError = (code: number, message: string) =>
|
|
post({ jsonrpc: "2.0", id, error: { code, message } });
|
|
|
|
try {
|
|
switch (method) {
|
|
case "webxdc.sendUpdate": {
|
|
api.sendUpdate(params.update, "");
|
|
respond(null);
|
|
break;
|
|
}
|
|
|
|
case "webxdc.setUpdateListener": {
|
|
const serial: number = params.serial ?? 0;
|
|
// Forward every update into the frame as a notification.
|
|
await api.setUpdateListener(
|
|
(update: ReceivedStatusUpdate<unknown>) => {
|
|
post({
|
|
jsonrpc: "2.0",
|
|
method: "webxdc.update",
|
|
params: { update },
|
|
});
|
|
},
|
|
serial,
|
|
);
|
|
respond(null);
|
|
break;
|
|
}
|
|
|
|
case "webxdc.getAllUpdates": {
|
|
const updates = await api.getAllUpdates();
|
|
respond(updates);
|
|
break;
|
|
}
|
|
|
|
case "webxdc.sendToChat": {
|
|
await api.sendToChat(params.message);
|
|
respond(null);
|
|
break;
|
|
}
|
|
|
|
case "webxdc.importFiles": {
|
|
const files = await api.importFiles(params.filter ?? {});
|
|
// File objects can't be serialised — convert to transferable form.
|
|
const result = await Promise.all(
|
|
files.map(async (f) => ({
|
|
name: f.name,
|
|
type: f.type,
|
|
data: bufToBase64(await f.arrayBuffer()),
|
|
})),
|
|
);
|
|
respond(result);
|
|
break;
|
|
}
|
|
|
|
case "webxdc.joinRealtimeChannel": {
|
|
const rt = api.joinRealtimeChannel();
|
|
// Generate a channel id to track this listener.
|
|
const channelId = crypto.randomUUID();
|
|
|
|
rt.setListener((data: Uint8Array) => {
|
|
post({
|
|
jsonrpc: "2.0",
|
|
method: "webxdc.realtimeChannel.data",
|
|
params: { channelId, data: Array.from(data) },
|
|
});
|
|
});
|
|
|
|
// Store on ref so subsequent calls can find it.
|
|
realtimeChannels.current.set(channelId, rt);
|
|
respond({ channelId });
|
|
break;
|
|
}
|
|
|
|
case "webxdc.realtimeChannel.send": {
|
|
const ch = realtimeChannels.current.get(params.channelId);
|
|
if (ch) ch.send(new Uint8Array(params.data));
|
|
respond(null);
|
|
break;
|
|
}
|
|
|
|
case "webxdc.realtimeChannel.leave": {
|
|
const ch = realtimeChannels.current.get(params.channelId);
|
|
if (ch) {
|
|
ch.leave();
|
|
realtimeChannels.current.delete(params.channelId);
|
|
}
|
|
respond(null);
|
|
break;
|
|
}
|
|
|
|
default:
|
|
respondError(-32601, `Method not found: ${method}`);
|
|
}
|
|
} catch (err) {
|
|
respondError(-1, String(err));
|
|
}
|
|
}
|
|
|
|
window.addEventListener("message", onMessage);
|
|
return () => window.removeEventListener("message", onMessage);
|
|
}, [origin, post]);
|
|
|
|
// Realtime channel handles, keyed by channelId.
|
|
const realtimeChannels = useRef<
|
|
Map<string, ReturnType<WebxdcAPI<unknown>["joinRealtimeChannel"]>>
|
|
>(new Map());
|
|
|
|
// Clean up realtime channels on unmount.
|
|
useEffect(() => {
|
|
const channels = realtimeChannels.current;
|
|
return () => {
|
|
for (const ch of channels.values()) ch.leave();
|
|
channels.clear();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<iframe
|
|
ref={iframeRef}
|
|
src={`${origin}/`}
|
|
{...iframeProps}
|
|
/>
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Utilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function bufToBase64(buf: ArrayBuffer): string {
|
|
const bytes = new Uint8Array(buf);
|
|
let binary = "";
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
export default Webxdc;
|