Replace webxdc.app with iframe.diy for webxdc sandboxing
Migrate the webxdc iframe runtime from webxdc.app to iframe.diy. Instead of sending ZIP bytes to the iframe and having the SW unzip them, the parent now unzips the .xdc archive and serves files via iframe.diy's fetch-proxy RPC. A webxdc bridge script is served as a virtual /webxdc.js file, and a <script> tag is injected into HTML responses via DOMParser to load it. - Rewrite Webxdc.tsx to use iframe.diy's ready/init/fetch protocol - Unzip .xdc archives on the parent side and serve via fetch RPC responses - Serve webxdc bridge as virtual /webxdc.js via the fetch handler - Inject <script src="/webxdc.js"> into HTML using DOMParser
This commit is contained in:
+355
-54
@@ -6,7 +6,9 @@ import {
|
||||
forwardRef,
|
||||
type IframeHTMLAttributes,
|
||||
} from "react";
|
||||
import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from "@webxdc/types";
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from "@webxdc/types/webxdc";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -14,7 +16,7 @@ import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from "@webxdc/types";
|
||||
|
||||
export interface WebxdcProps
|
||||
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, "src" | "id"> {
|
||||
/** Unique session identifier — used as the subdomain: `<id>.webxdc.app`. */
|
||||
/** Unique session identifier — used as the subdomain: `<id>.iframe.diy`. */
|
||||
id: string;
|
||||
/** The `.xdc` archive: raw bytes or a URL to fetch them from. */
|
||||
xdc: Uint8Array | string;
|
||||
@@ -30,21 +32,231 @@ export interface WebxdcHandle {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MIME type lookup (covers common web-relevant file types)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html", ".htm": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript", ".mjs": "application/javascript",
|
||||
".json": "application/json",
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
|
||||
".ico": "image/x-icon", ".bmp": "image/bmp", ".avif": "image/avif",
|
||||
".woff": "font/woff", ".woff2": "font/woff2",
|
||||
".ttf": "font/ttf", ".otf": "font/otf",
|
||||
".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav",
|
||||
".opus": "audio/opus", ".weba": "audio/webm",
|
||||
".mp4": "video/mp4", ".webm": "video/webm",
|
||||
".xml": "application/xml", ".txt": "text/plain",
|
||||
".wasm": "application/wasm", ".pdf": "application/pdf",
|
||||
".toml": "application/toml",
|
||||
};
|
||||
|
||||
function getMimeType(path: string): string {
|
||||
const dot = path.lastIndexOf(".");
|
||||
if (dot === -1) return "application/octet-stream";
|
||||
const ext = path.slice(dot).toLowerCase();
|
||||
return MIME_TYPES[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve `xdc` prop to an ArrayBuffer. */
|
||||
async function resolveXdc(xdc: Uint8Array | string): Promise<ArrayBuffer> {
|
||||
/** Resolve `xdc` prop to a Uint8Array. */
|
||||
async function resolveXdc(xdc: Uint8Array | string): Promise<Uint8Array> {
|
||||
if (typeof xdc === "string") {
|
||||
const res = await fetch(xdc);
|
||||
if (!res.ok) throw new Error(`Failed to fetch xdc: ${res.status}`);
|
||||
return res.arrayBuffer();
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
}
|
||||
// Uint8Array → ArrayBuffer (copy so we can transfer)
|
||||
const copy = new ArrayBuffer(xdc.byteLength);
|
||||
new Uint8Array(copy).set(xdc);
|
||||
return copy;
|
||||
return xdc;
|
||||
}
|
||||
|
||||
/** Unzip a `.xdc` archive into a normalised file map. */
|
||||
function unzipXdc(bytes: Uint8Array): Map<string, Uint8Array> {
|
||||
const unzipped = unzipSync(bytes);
|
||||
const fileMap = new Map<string, Uint8Array>();
|
||||
for (const [path, content] of Object.entries(unzipped)) {
|
||||
const normalised = path.replace(/^\/+/, "").replace(/\\/g, "/");
|
||||
if (normalised.endsWith("/")) continue; // skip directories
|
||||
fileMap.set(normalised, content);
|
||||
}
|
||||
return fileMap;
|
||||
}
|
||||
|
||||
/** Encode a Uint8Array to base64. */
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Encode a UTF-8 string to base64. */
|
||||
function utf8ToBase64(str: string): string {
|
||||
const bytes = new TextEncoder().encode(str);
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the webxdc bridge script that will be injected into HTML responses.
|
||||
* This script implements window.webxdc by sending JSON-RPC requests to the
|
||||
* parent (Ditto) through iframe.diy's relay.
|
||||
*/
|
||||
function generateWebxdcBridge(api: WebxdcAPI<unknown>): string {
|
||||
return `(function(){
|
||||
var nextId = 1;
|
||||
var pending = {};
|
||||
var updateListener = null;
|
||||
var updateListenerReady = null;
|
||||
var realtimeDataListener = null;
|
||||
var realtimeChannelId = null;
|
||||
|
||||
function send(msg) {
|
||||
window.parent.postMessage(msg, "*");
|
||||
}
|
||||
|
||||
function sendRequest(method, params) {
|
||||
var id = nextId++;
|
||||
return new Promise(function(resolve, reject) {
|
||||
pending[id] = { resolve: resolve, reject: reject };
|
||||
send({ jsonrpc: "2.0", id: id, method: method, params: params });
|
||||
});
|
||||
}
|
||||
|
||||
function sendNotification(method, params) {
|
||||
send({ jsonrpc: "2.0", method: method, params: params });
|
||||
}
|
||||
|
||||
window.addEventListener("message", function(event) {
|
||||
var data = event.data;
|
||||
if (!data || typeof data !== "object" || data.jsonrpc !== "2.0") return;
|
||||
|
||||
// JSON-RPC response
|
||||
if (data.id !== undefined && !data.method) {
|
||||
var p = pending[data.id];
|
||||
if (p) {
|
||||
delete pending[data.id];
|
||||
if (data.error) {
|
||||
p.reject(new Error(data.error.message));
|
||||
} else {
|
||||
p.resolve(data.result);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Notifications from parent
|
||||
if (data.method && data.id === undefined) {
|
||||
switch (data.method) {
|
||||
case "webxdc.update":
|
||||
if (updateListener) updateListener(data.params.update);
|
||||
break;
|
||||
case "webxdc.realtimeChannel.data":
|
||||
if (realtimeDataListener) realtimeDataListener(new Uint8Array(data.params.data));
|
||||
break;
|
||||
case "webxdc.keyboard":
|
||||
var p2 = data.params;
|
||||
var evt = new KeyboardEvent(p2.type, {
|
||||
key: p2.key, code: p2.code, keyCode: p2.keyCode,
|
||||
bubbles: true, cancelable: true, composed: true
|
||||
});
|
||||
window.dispatchEvent(evt);
|
||||
document.dispatchEvent(new KeyboardEvent(p2.type, {
|
||||
key: p2.key, code: p2.code, keyCode: p2.keyCode,
|
||||
bubbles: true, cancelable: true
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.webxdc = {
|
||||
selfAddr: ${JSON.stringify(api.selfAddr)},
|
||||
selfName: ${JSON.stringify(api.selfName)},
|
||||
sendUpdateInterval: ${api.sendUpdateInterval},
|
||||
sendUpdateMaxSize: ${api.sendUpdateMaxSize},
|
||||
|
||||
sendUpdate: function(update, descr) {
|
||||
sendRequest("webxdc.sendUpdate", { update: update, descr: descr });
|
||||
},
|
||||
|
||||
setUpdateListener: function(cb, serial) {
|
||||
updateListener = cb;
|
||||
return new Promise(function(resolve) {
|
||||
updateListenerReady = resolve;
|
||||
sendRequest("webxdc.setUpdateListener", { serial: serial || 0 }).then(function() {
|
||||
if (updateListenerReady) { updateListenerReady(); updateListenerReady = null; }
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
getAllUpdates: function() {
|
||||
return sendRequest("webxdc.getAllUpdates");
|
||||
},
|
||||
|
||||
sendToChat: function(message) {
|
||||
return sendRequest("webxdc.sendToChat", { message: message });
|
||||
},
|
||||
|
||||
importFiles: function(filter) {
|
||||
return sendRequest("webxdc.importFiles", { filter: filter || {} });
|
||||
},
|
||||
|
||||
joinRealtimeChannel: function() {
|
||||
if (realtimeChannelId) throw new Error("Already joined a realtime channel. Leave first.");
|
||||
var channelIdPromise = sendRequest("webxdc.joinRealtimeChannel");
|
||||
var joined = true;
|
||||
channelIdPromise.then(function(r) { realtimeChannelId = r.channelId; });
|
||||
return {
|
||||
setListener: function(cb) {
|
||||
if (!joined) throw new Error("Channel has been left.");
|
||||
realtimeDataListener = cb;
|
||||
},
|
||||
send: function(data) {
|
||||
if (!joined) throw new Error("Channel has been left.");
|
||||
channelIdPromise.then(function(r) {
|
||||
sendRequest("webxdc.realtimeChannel.send", { channelId: r.channelId, data: Array.from(data) });
|
||||
});
|
||||
},
|
||||
leave: function() {
|
||||
if (!joined) return;
|
||||
joined = false;
|
||||
realtimeDataListener = null;
|
||||
channelIdPromise.then(function(r) {
|
||||
sendRequest("webxdc.realtimeChannel.leave", { channelId: r.channelId });
|
||||
realtimeChannelId = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
})();`;
|
||||
}
|
||||
|
||||
/** Virtual path used to serve the webxdc bridge script. */
|
||||
const BRIDGE_SCRIPT_PATH = "webxdc.js";
|
||||
|
||||
/**
|
||||
* Inject a `<script src="/webxdc.js">` tag into an HTML document string.
|
||||
* Uses DOMParser so we don't rely on fragile regex against HTML.
|
||||
* The tag is prepended inside `<head>` so it runs before any app scripts.
|
||||
*/
|
||||
function injectScriptTag(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const script = doc.createElement("script");
|
||||
script.src = `/${BRIDGE_SCRIPT_PATH}`;
|
||||
// Prepend as first child of <head> so it loads before the app's own scripts.
|
||||
doc.head.prepend(script);
|
||||
// Serialise back to an HTML string. doctype is lost by DOMParser, so
|
||||
// we re-add it when the original document had one.
|
||||
const hasDoctype = /^<!doctype\s/i.test(html.trimStart());
|
||||
const serialised = doc.documentElement.outerHTML;
|
||||
return hasDoctype ? "<!DOCTYPE html>\n" + serialised : serialised;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -52,13 +264,16 @@ async function resolveXdc(xdc: Uint8Array | string): Promise<ArrayBuffer> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renders a webxdc app inside an iframe hosted on `<id>.webxdc.app`.
|
||||
* Renders a webxdc app inside an iframe hosted on `<id>.iframe.diy`.
|
||||
*
|
||||
* 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.
|
||||
* The component handles the full lifecycle:
|
||||
* 1. Waits for `ready` from the iframe.diy frame.
|
||||
* 2. Fetches and unzips the `.xdc` archive on the parent side.
|
||||
* 3. Sends `init` to signal the frame to start.
|
||||
* 4. Responds to `fetch` RPC requests by serving files from the archive,
|
||||
* injecting the webxdc bridge script into HTML responses.
|
||||
* 5. Handles `webxdc.*` RPC requests from the bridge script (relayed by
|
||||
* iframe.diy) and proxies them to the provided `WebxdcAPI` instance.
|
||||
*/
|
||||
export const Webxdc = forwardRef<WebxdcHandle, WebxdcProps>(function Webxdc(
|
||||
{ id, xdc, webxdc, ...iframeProps },
|
||||
@@ -77,7 +292,12 @@ export const Webxdc = forwardRef<WebxdcHandle, WebxdcProps>(function Webxdc(
|
||||
xdcRef.current = xdc;
|
||||
}, [xdc]);
|
||||
|
||||
const origin = `https://${id}.webxdc.app`;
|
||||
// The unzipped file map, populated on first `ready` message.
|
||||
const fileMapRef = useRef<Map<string, Uint8Array> | null>(null);
|
||||
// The generated bridge script, cached per webxdc instance.
|
||||
const bridgeScriptRef = useRef<string>("");
|
||||
|
||||
const origin = `https://${id}.iframe.diy`;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Post a JSON-RPC message to the iframe
|
||||
@@ -120,39 +340,133 @@ export const Webxdc = forwardRef<WebxdcHandle, WebxdcProps>(function Webxdc(
|
||||
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
|
||||
);
|
||||
});
|
||||
// --- Notification: ready → fetch xdc, unzip, send init -----------
|
||||
if (msg.method === "ready" && msg.id === undefined) {
|
||||
handleReady();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Requests (have an `id`) ------------------------------------
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
handleRequest(msg.id, msg.method, msg.params ?? {});
|
||||
if (msg.method === "fetch") {
|
||||
handleFetch(msg.id, msg.params);
|
||||
} else {
|
||||
// webxdc.* RPC methods relayed from the bridge script
|
||||
handleWebxdcRequest(msg.id, msg.method, msg.params ?? {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReady() {
|
||||
try {
|
||||
// Fetch and unzip the .xdc archive on the parent side.
|
||||
const bytes = await resolveXdc(xdcRef.current);
|
||||
fileMapRef.current = unzipXdc(bytes);
|
||||
|
||||
// Generate the bridge script with current webxdc API values.
|
||||
bridgeScriptRef.current = generateWebxdcBridge(webxdcRef.current);
|
||||
|
||||
// Send init notification (iframe.diy protocol).
|
||||
post({
|
||||
jsonrpc: "2.0",
|
||||
method: "init",
|
||||
params: { version: 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Webxdc] Failed to initialise:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function handleRequest(id: string | number, method: string, params: any) {
|
||||
function handleFetch(id: string | number, params: any) {
|
||||
const reqUrl: string | undefined = params?.request?.url;
|
||||
if (!reqUrl) {
|
||||
post({ jsonrpc: "2.0", id, error: { code: -32001, message: "Invalid request" } });
|
||||
return;
|
||||
}
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(reqUrl).pathname;
|
||||
} catch {
|
||||
post({ jsonrpc: "2.0", id, error: { code: -32003, message: "Invalid URL" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const fileMap = fileMapRef.current;
|
||||
if (!fileMap) {
|
||||
post({
|
||||
jsonrpc: "2.0", id,
|
||||
result: {
|
||||
status: 503, statusText: "Not Ready",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: utf8ToBase64("Archive not loaded"),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalise: "/" and "/index.html" both resolve to "index.html".
|
||||
const filePath =
|
||||
pathname === "/" ? "index.html" : decodeURIComponent(pathname.slice(1));
|
||||
|
||||
// Serve the virtual webxdc bridge script.
|
||||
if (filePath === BRIDGE_SCRIPT_PATH) {
|
||||
post({
|
||||
jsonrpc: "2.0", id,
|
||||
result: {
|
||||
status: 200, statusText: "OK",
|
||||
headers: { "Content-Type": "application/javascript", "Cache-Control": "no-cache" },
|
||||
body: utf8ToBase64(bridgeScriptRef.current),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileBytes = fileMap.get(filePath);
|
||||
if (!fileBytes) {
|
||||
post({
|
||||
jsonrpc: "2.0", id,
|
||||
result: {
|
||||
status: 404, statusText: "Not Found",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: utf8ToBase64("Not Found: " + pathname),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = getMimeType(filePath);
|
||||
|
||||
// Inject a <script src="/webxdc.js"> tag into HTML responses.
|
||||
if (contentType.includes("text/html")) {
|
||||
const html = new TextDecoder().decode(fileBytes);
|
||||
const injected = injectScriptTag(html);
|
||||
post({
|
||||
jsonrpc: "2.0", id,
|
||||
result: {
|
||||
status: 200, statusText: "OK",
|
||||
headers: { "Content-Type": contentType, "Cache-Control": "no-cache" },
|
||||
body: utf8ToBase64(injected),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
post({
|
||||
jsonrpc: "2.0", id,
|
||||
result: {
|
||||
status: 200, statusText: "OK",
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(fileBytes.byteLength),
|
||||
},
|
||||
body: bytesToBase64(fileBytes),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function handleWebxdcRequest(id: string | number, method: string, params: any) {
|
||||
const api = webxdcRef.current;
|
||||
|
||||
const respond = (result: unknown) =>
|
||||
@@ -204,7 +518,7 @@ export const Webxdc = forwardRef<WebxdcHandle, WebxdcProps>(function Webxdc(
|
||||
files.map(async (f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
data: bufToBase64(await f.arrayBuffer()),
|
||||
data: bytesToBase64(new Uint8Array(await f.arrayBuffer())),
|
||||
})),
|
||||
);
|
||||
respond(result);
|
||||
@@ -282,17 +596,4 @@ export const Webxdc = forwardRef<WebxdcHandle, WebxdcProps>(function Webxdc(
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Webxdc as WebxdcAPI, SendingStatusUpdate, ReceivedStatusUpdate, RealtimeListener } from '@webxdc/types';
|
||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { NSecSigner } from '@nostrify/nostrify';
|
||||
|
||||
import type { Webxdc as WebxdcAPI, SendingStatusUpdate, ReceivedStatusUpdate, RealtimeListener } from '@webxdc/types/webxdc';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user