swarpc
Version:
Full type-safe RPC library for service worker -- move things off of the UI thread with ease!
220 lines (219 loc) • 8.86 kB
JavaScript
import { createLogger, } from "./log.js";
import { makeNodeId, nodeIdOrSW, whoToSendTo } from "./nodes.js";
import { zProcedures, } from "./types.js";
import { findTransferables } from "./utils.js";
const pendingRequests = new Map();
let _clientListenerStarted = new Set();
export function Client(procedures, { worker, nodes: nodeCount, loglevel = "debug", restartListener = false, hooks = {}, localStorage = {}, } = {}) {
const l = createLogger("client", loglevel);
if (restartListener)
_clientListenerStarted.clear();
const instance = { [zProcedures]: procedures };
nodeCount ??= navigator.hardwareConcurrency || 1;
let nodes;
if (worker) {
nodes = {};
for (const _ of Array.from({ length: nodeCount })) {
const id = makeNodeId();
if (typeof worker === "string") {
nodes[id] = new Worker(worker, { name: id });
}
else {
nodes[id] = new worker({ name: id });
}
}
l.info(null, `Started ${nodeCount} node${nodeCount > 1 ? "s" : ""}`, Object.keys(nodes));
}
for (const functionName of Object.keys(procedures)) {
if (typeof functionName !== "string") {
throw new Error(`[SWARPC Client] Invalid function name, don't use symbols`);
}
const send = async (node, nodeId, requestId, msg, options) => {
const ctx = {
logger: l,
node,
nodeId,
hooks,
localStorage,
};
return postMessage(ctx, {
...msg,
by: "sw&rpc",
requestId,
functionName,
}, options);
};
const _runProcedure = async (input, onProgress = () => { }, reqid, nodeId) => {
const validation = procedures[functionName].input["~standard"].validate(input);
if (validation instanceof Promise)
throw new Error("Validations must not be async");
if (validation.issues)
throw new Error(`Invalid input: ${validation.issues}`);
const requestId = reqid ?? makeRequestId();
nodeId ??= whoToSendTo(nodes, pendingRequests);
const node = nodes && nodeId ? nodes[nodeId] : undefined;
const l = createLogger("client", loglevel, nodeIdOrSW(nodeId), requestId);
return new Promise((resolve, reject) => {
pendingRequests.set(requestId, {
nodeId,
functionName,
resolve,
onProgress,
reject,
});
const transfer = procedures[functionName].autotransfer === "always"
? findTransferables(input)
: [];
l.debug(`Requesting ${functionName} with`, input);
return send(node, nodeId, requestId, { input }, { transfer })
.then(() => { })
.catch(reject);
});
};
instance[functionName] = _runProcedure;
instance[functionName].broadcast = async (input, onProgresses, nodesCount) => {
let nodesToUse = [undefined];
if (nodes)
nodesToUse = Object.keys(nodes);
if (nodesCount)
nodesToUse = nodesToUse.slice(0, nodesCount);
const progresses = new Map();
function onProgress(nodeId) {
if (!onProgresses)
return (_) => { };
return (progress) => {
progresses.set(nodeIdOrSW(nodeId), progress);
onProgresses(progresses);
};
}
const results = await Promise.allSettled(nodesToUse.map(async (id) => _runProcedure(input, onProgress(id), undefined, id)));
return results.map((r, i) => ({ ...r, node: nodeIdOrSW(nodesToUse[i]) }));
};
instance[functionName].cancelable = (input, onProgress) => {
const requestId = makeRequestId();
const nodeId = whoToSendTo(nodes, pendingRequests);
const l = createLogger("client", loglevel, nodeIdOrSW(nodeId), requestId);
return {
request: _runProcedure(input, onProgress, requestId, nodeId),
cancel(reason) {
if (!pendingRequests.has(requestId)) {
l.warn(requestId, `Cannot cancel ${functionName} request, it has already been resolved or rejected`);
return;
}
l.debug(requestId, `Cancelling ${functionName} with`, reason);
postMessageSync(l, nodeId ? nodes?.[nodeId] : undefined, {
by: "sw&rpc",
requestId,
functionName,
abort: { reason },
});
pendingRequests.delete(requestId);
},
};
};
}
return instance;
}
async function postMessage(ctx, message, options) {
await startClientListener(ctx);
const { logger: l, node: worker } = ctx;
if (!worker && !navigator.serviceWorker.controller)
l.warn("", "Service Worker is not controlling the page");
const w = worker instanceof SharedWorker
? worker.port
: worker === undefined
? await navigator.serviceWorker.ready.then((r) => r.active)
: worker;
if (!w) {
throw new Error("[SWARPC Client] No active service worker found");
}
w.postMessage(message, options);
}
function postMessageSync(l, worker, message, options) {
if (!worker && !navigator.serviceWorker.controller)
l.warn("Service Worker is not controlling the page");
const w = worker instanceof SharedWorker
? worker.port
: worker === undefined
? navigator.serviceWorker.controller
: worker;
if (!w) {
throw new Error("[SWARPC Client] No active service worker found");
}
w.postMessage(message, options);
}
async function startClientListener(ctx) {
if (_clientListenerStarted.has(nodeIdOrSW(ctx.nodeId)))
return;
const { logger: l, node: worker } = ctx;
if (!worker) {
const sw = await navigator.serviceWorker.ready;
if (!sw?.active) {
throw new Error("[SWARPC Client] Service Worker is not active");
}
if (!navigator.serviceWorker.controller) {
l.warn("", "Service Worker is not controlling the page");
}
}
const w = worker ?? navigator.serviceWorker;
l.debug(null, "Starting client listener", { w, ...ctx });
const listener = (event) => {
const eventData = event.data || {};
if (eventData?.by !== "sw&rpc")
return;
const payload = eventData;
if ("isInitializeRequest" in payload) {
l.warn(null, "Ignoring unexpected #initialize from server", payload);
return;
}
const { requestId, ...data } = payload;
if (!requestId) {
throw new Error("[SWARPC Client] Message received without requestId");
}
const handlers = pendingRequests.get(requestId);
if (!handlers) {
throw new Error(`[SWARPC Client] ${requestId} has no active request handlers, cannot process ${JSON.stringify(data)}`);
}
if ("error" in data) {
ctx.hooks.error?.({
procedure: data.functionName,
error: new Error(data.error.message),
});
handlers.reject(new Error(data.error.message));
pendingRequests.delete(requestId);
}
else if ("progress" in data) {
ctx.hooks.progress?.({
procedure: data.functionName,
data: data.progress,
});
handlers.onProgress(data.progress);
}
else if ("result" in data) {
ctx.hooks.success?.({
procedure: data.functionName,
data: data.result,
});
handlers.resolve(data.result);
pendingRequests.delete(requestId);
}
};
if (w instanceof SharedWorker) {
w.port.addEventListener("message", listener);
w.port.start();
}
else {
w.addEventListener("message", listener);
}
_clientListenerStarted.add(nodeIdOrSW(ctx.nodeId));
await postMessage(ctx, {
by: "sw&rpc",
functionName: "#initialize",
isInitializeRequest: true,
localStorageData: ctx.localStorage,
nodeId: nodeIdOrSW(ctx.nodeId),
});
}
function makeRequestId() {
return Math.random().toString(16).substring(2, 8).toUpperCase();
}