trpc-webrtc
Version:
A set of TRPC adapters for communication via RTCDataChannel in the browser
300 lines (299 loc) • 10.6 kB
JavaScript
import { callProcedure, TRPCError, getTRPCErrorFromUnknown, } from "@trpc/server";
import { isObservable } from "@trpc/server/observable";
import { transformTRPCResponse } from "./internals/transformTRPCResponse.js";
// converted from https://github.com/trpc/trpc/blob/9c2df391fea0ff735d0a6c4c0bbf6c1f7c2cbecd/packages/server/src/adapters/ws.ts
/* istanbul ignore next -- @preserve */
function assertIsObject(obj) {
if (typeof obj !== "object" || Array.isArray(obj) || !obj) {
throw new Error("Not an object");
}
}
/* istanbul ignore next -- @preserve */
function assertIsProcedureType(obj) {
if (obj !== "query" && obj !== "subscription" && obj !== "mutation") {
throw new Error("Invalid procedure type");
}
}
/* istanbul ignore next -- @preserve */
function assertIsRequestId(obj) {
if (obj !== null &&
typeof obj === "number" &&
isNaN(obj) &&
typeof obj !== "string") {
throw new Error("Invalid request id");
}
}
/* istanbul ignore next -- @preserve */
function assertIsString(obj) {
if (typeof obj !== "string") {
throw new Error("Invalid string");
}
}
/* istanbul ignore next -- @preserve */
function assertIsJSONRPC2OrUndefined(obj) {
if (typeof obj !== "undefined" && obj !== "2.0") {
throw new Error("Must be JSONRPC 2.0");
}
}
export function parseMessage(obj, transformer) {
assertIsObject(obj);
const { method, params, id, jsonrpc } = obj;
assertIsRequestId(id);
assertIsJSONRPC2OrUndefined(jsonrpc);
if (method === "subscription.stop") {
return {
id,
jsonrpc,
method,
};
}
assertIsProcedureType(method);
assertIsObject(params);
const { input: rawInput, path } = params;
assertIsString(path);
const input = transformer.input.deserialize(rawInput);
return {
id,
jsonrpc,
method,
params: {
input,
path,
},
};
}
export function applyDataChannelHandler(opts) {
const { dataChannel, createContext, router } = opts;
const { transformer } = router._def._config;
async function onOpen() {
const clientSubscriptions = new Map();
function respond(untransformedJSON) {
dataChannel.send(JSON.stringify(transformTRPCResponse(router, untransformedJSON)));
}
function stopSubscription(subscription, { id, jsonrpc }) {
subscription.unsubscribe();
respond({
id,
jsonrpc,
result: {
type: "stopped",
},
});
}
const ctxPromise = createContext?.();
let ctx = undefined;
async function handleRequest(msg) {
const { id, jsonrpc } = msg;
/* istanbul ignore next -- @preserve */
if (id === null) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "`id` is required",
});
}
if (msg.method === "subscription.stop") {
const sub = clientSubscriptions.get(id);
if (sub) {
stopSubscription(sub, { id, jsonrpc });
}
clientSubscriptions.delete(id);
return;
}
const { path, input } = msg.params;
const type = msg.method;
try {
await ctxPromise; // asserts context has been set
const result = await callProcedure({
procedures: router._def.procedures,
path,
rawInput: input,
ctx,
type,
});
if (type === "subscription") {
if (!isObservable(result)) {
throw new TRPCError({
message: `Subscription ${path} did not return an observable`,
code: "INTERNAL_SERVER_ERROR",
});
}
}
else {
// send the value as data if the method is not a subscription
respond({
id,
jsonrpc,
result: {
type: "data",
data: result,
},
});
return;
}
const observable = result;
const sub = observable.subscribe({
next(data) {
respond({
id,
jsonrpc,
result: {
type: "data",
data,
},
});
},
error(err) {
const error = getTRPCErrorFromUnknown(err);
opts.onError?.({ error, path, type, ctx, input });
respond({
id,
jsonrpc,
error: router.getErrorShape({
error,
type,
path,
input,
ctx,
}),
});
},
complete() {
respond({
id,
jsonrpc,
result: {
type: "stopped",
},
});
},
});
/* istanbul ignore next -- @preserve */
if (dataChannel.readyState !== "open") {
// if the client got disconnected whilst initializing the subscription
// no need to send stopped message if the client is disconnected
sub.unsubscribe();
return;
}
/* istanbul ignore next -- @preserve */
if (clientSubscriptions.has(id)) {
// duplicate request ids for client
stopSubscription(sub, { id, jsonrpc });
throw new TRPCError({
message: `Duplicate id ${id}`,
code: "BAD_REQUEST",
});
}
clientSubscriptions.set(id, sub);
respond({
id,
jsonrpc,
result: {
type: "started",
},
});
}
catch (cause) /* istanbul ignore next -- @preserve */ {
// procedure threw an error
const error = getTRPCErrorFromUnknown(cause);
opts.onError?.({ error, path, type, ctx, input });
respond({
id,
jsonrpc,
error: router.getErrorShape({
error,
type,
path,
input,
ctx,
}),
});
}
}
dataChannel.addEventListener("message", async (message) => {
try {
const msgJSON = JSON.parse(message.data.toString());
const msgs = Array.isArray(msgJSON) ? msgJSON : [msgJSON];
const promises = msgs
.map((raw) => parseMessage(raw, transformer))
.map(handleRequest);
await Promise.all(promises);
}
catch (cause) {
const error = new TRPCError({
code: "PARSE_ERROR",
cause: cause instanceof Error ? cause : undefined,
});
respond({
id: null,
error: router.getErrorShape({
error,
type: "unknown",
path: undefined,
input: undefined,
ctx: undefined,
}),
});
}
});
dataChannel.addEventListener("error", () => {
opts.onError?.({
ctx,
error: getTRPCErrorFromUnknown(new Error("Underlying RTCDataChannel error")),
input: undefined,
path: undefined,
type: "unknown",
});
});
dataChannel.addEventListener("close", () => {
for (const sub of clientSubscriptions.values()) {
sub.unsubscribe();
}
clientSubscriptions.clear();
}, { once: true });
async function createContextAsync() {
try {
ctx = await ctxPromise;
}
catch (cause) {
const error = getTRPCErrorFromUnknown(cause);
opts.onError?.({
error,
path: undefined,
type: "unknown",
ctx,
input: undefined,
});
respond({
id: null,
error: router.getErrorShape({
error,
type: "unknown",
path: undefined,
input: undefined,
ctx,
}),
});
// close in next tick
(global.setImmediate ?? global.setTimeout)(() => {
dataChannel.close();
});
}
}
await createContextAsync();
}
if (dataChannel.readyState !== "open") {
dataChannel.addEventListener("open", onOpen, { once: true });
}
else {
onOpen();
}
return {
/**
* Don't use this, data channels can't be automatically reconnected.
*/
broadcastReconnectNotification: () => {
// TODO(bengreenier): remove this handler from the returned object
throw new Error("Reconnection is not supported");
},
};
}