@trpc/server
Version:
541 lines (538 loc) • 21.5 kB
JavaScript
import { getErrorShape } from '../unstable-core-do-not-import/error/getErrorShape.mjs';
import { TRPCError, getTRPCErrorFromUnknown } from '../unstable-core-do-not-import/error/TRPCError.mjs';
import { callProcedure } from '../unstable-core-do-not-import/router.mjs';
import { run, isObject, isAsyncIterable } from '../unstable-core-do-not-import/utils.mjs';
import { parseConnectionParamsFromUnknown } from '../unstable-core-do-not-import/http/parseConnectionParams.mjs';
import '../unstable-core-do-not-import/rpc/codes.mjs';
import { parseTRPCMessage } from '../unstable-core-do-not-import/rpc/parseTRPCMessage.mjs';
import { isObservable, observableToAsyncIterable } from '../observable/observable.mjs';
import { iteratorResource } from '../unstable-core-do-not-import/stream/utils/asyncIterable.mjs';
import '../unstable-core-do-not-import/stream/utils/disposable.mjs';
import { Unpromise } from '../vendor/unpromise/unpromise.mjs';
import { isTrackedEnvelope } from '../unstable-core-do-not-import/stream/tracked.mjs';
import { transformTRPCResponse } from '../unstable-core-do-not-import/transformer.mjs';
import '../unstable-core-do-not-import/rootConfig.mjs';
import { createURL } from './node-http/incomingMessageToRequest.mjs';
function _ts_add_disposable_resource(env, value, async) {
if (value !== null && value !== void 0) {
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
var dispose, inner;
{
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
dispose = value[Symbol.asyncDispose];
}
if (dispose === void 0) {
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
dispose = value[Symbol.dispose];
inner = dispose;
}
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
if (inner) dispose = function() {
try {
inner.call(this);
} catch (e) {
return Promise.reject(e);
}
};
env.stack.push({
value: value,
dispose: dispose,
async: async
});
} else {
env.stack.push({
async: true
});
}
return value;
}
function _ts_dispose_resources(env) {
var _SuppressedError = typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
return (_ts_dispose_resources = function _ts_dispose_resources(env) {
function fail(e) {
env.error = env.hasError ? new _SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
env.hasError = true;
}
var r, s = 0;
function next() {
while(r = env.stack.pop()){
try {
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
if (r.dispose) {
var result = r.dispose.call(r.value);
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
fail(e);
return next();
});
} else s |= 1;
} catch (e) {
fail(e);
}
}
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
if (env.hasError) throw env.error;
}
return next();
})(env);
}
/**
* Importing ws causes a build error
* @see https://github.com/trpc/trpc/pull/5279
*/ const WEBSOCKET_OPEN = 1; /* ws.WebSocket.OPEN */
function getWSConnectionHandler(opts) {
const { createContext, router } = opts;
const { transformer } = router._def._config;
return (client, req)=>{
const clientSubscriptions = new Map();
const abortController = new AbortController();
if (opts.keepAlive?.enabled) {
const { pingMs, pongWaitMs } = opts.keepAlive;
handleKeepAlive(client, pingMs, pongWaitMs);
}
function respond(untransformedJSON) {
client.send(JSON.stringify(transformTRPCResponse(router._def._config, untransformedJSON)));
}
async function createCtxPromise(getConnectionParams) {
try {
return await run(async ()=>{
ctx = await createContext?.({
req,
res: client,
info: {
connectionParams: getConnectionParams(),
calls: [],
isBatchCall: false,
accept: null,
type: 'unknown',
signal: abortController.signal,
url: null
}
});
return {
ok: true,
value: ctx
};
});
} catch (cause) {
const error = getTRPCErrorFromUnknown(cause);
opts.onError?.({
error,
path: undefined,
type: 'unknown',
ctx,
req,
input: undefined
});
respond({
id: null,
error: getErrorShape({
config: router._def._config,
error,
type: 'unknown',
path: undefined,
input: undefined,
ctx
})
});
// close in next tick
(globalThis.setImmediate ?? globalThis.setTimeout)(()=>{
client.close();
});
return {
ok: false,
error
};
}
}
let ctx = undefined;
/**
* promise for initializing the context
*
* - the context promise will be created immediately on connection if no connectionParams are expected
* - if connection params are expected, they will be created once received
*/ let ctxPromise = createURL(req).searchParams.get('connectionParams') === '1' ? null : createCtxPromise(()=>null);
function handleRequest(msg) {
const { id, jsonrpc } = msg;
if (id === null) {
const error = getTRPCErrorFromUnknown(new TRPCError({
code: 'PARSE_ERROR',
message: '`id` is required'
}));
opts.onError?.({
error,
path: undefined,
type: 'unknown',
ctx,
req,
input: undefined
});
respond({
id,
jsonrpc,
error: getErrorShape({
config: router._def._config,
error,
type: 'unknown',
path: undefined,
input: undefined,
ctx
})
});
return;
}
if (msg.method === 'subscription.stop') {
clientSubscriptions.get(id)?.abort();
return;
}
const { path, lastEventId } = msg.params;
let { input } = msg.params;
const type = msg.method;
if (lastEventId !== undefined) {
if (isObject(input)) {
input = {
...input,
lastEventId: lastEventId
};
} else {
input ?? (input = {
lastEventId: lastEventId
});
}
}
run(async ()=>{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const res = await ctxPromise; // asserts context has been set
if (!res.ok) {
throw res.error;
}
const abortController = new AbortController();
const result = await callProcedure({
router,
path,
getRawInput: async ()=>input,
ctx,
type,
signal: abortController.signal
});
const isIterableResult = isAsyncIterable(result) || isObservable(result);
if (type !== 'subscription') {
if (isIterableResult) {
throw new TRPCError({
code: 'UNSUPPORTED_MEDIA_TYPE',
message: `Cannot return an async iterable or observable from a ${type} procedure with WebSockets`
});
}
// send the value as data if the method is not a subscription
respond({
id,
jsonrpc,
result: {
type: 'data',
data: result
}
});
return;
}
if (!isIterableResult) {
throw new TRPCError({
message: `Subscription ${path} did not return an observable or a AsyncGenerator`,
code: 'INTERNAL_SERVER_ERROR'
});
}
/* istanbul ignore next -- @preserve */ if (client.readyState !== WEBSOCKET_OPEN) {
// if the client got disconnected whilst initializing the subscription
// no need to send stopped message if the client is disconnected
return;
}
/* istanbul ignore next -- @preserve */ if (clientSubscriptions.has(id)) {
// duplicate request ids for client
throw new TRPCError({
message: `Duplicate id ${id}`,
code: 'BAD_REQUEST'
});
}
const iterable = isObservable(result) ? observableToAsyncIterable(result, abortController.signal) : result;
run(async ()=>{
const env = {
stack: [],
error: void 0,
hasError: false
};
try {
const iterator = _ts_add_disposable_resource(env, iteratorResource(iterable), true);
;
const abortPromise = new Promise((resolve)=>{
abortController.signal.onabort = ()=>resolve('abort');
});
// We need those declarations outside the loop for garbage collection reasons. If they
// were declared inside, they would not be freed until the next value is present.
let next;
let result;
while(true){
next = await Unpromise.race([
iterator.next().catch(getTRPCErrorFromUnknown),
abortPromise
]);
if (next === 'abort') {
await iterator.return?.();
break;
}
if (next instanceof Error) {
const error = getTRPCErrorFromUnknown(next);
opts.onError?.({
error,
path,
type,
ctx,
req,
input
});
respond({
id,
jsonrpc,
error: getErrorShape({
config: router._def._config,
error,
type,
path,
input,
ctx
})
});
break;
}
if (next.done) {
break;
}
result = {
type: 'data',
data: next.value
};
if (isTrackedEnvelope(next.value)) {
const [id, data] = next.value;
result.id = id;
result.data = {
id,
data
};
}
respond({
id,
jsonrpc,
result
});
// free up references for garbage collection
next = null;
result = null;
}
respond({
id,
jsonrpc,
result: {
type: 'stopped'
}
});
clientSubscriptions.delete(id);
} catch (e) {
env.error = e;
env.hasError = true;
} finally{
const result = _ts_dispose_resources(env);
if (result) await result;
}
}).catch((cause)=>{
const error = getTRPCErrorFromUnknown(cause);
opts.onError?.({
error,
path,
type,
ctx,
req,
input
});
respond({
id,
jsonrpc,
error: getErrorShape({
config: router._def._config,
error,
type,
path,
input,
ctx
})
});
abortController.abort();
});
clientSubscriptions.set(id, abortController);
respond({
id,
jsonrpc,
result: {
type: 'started'
}
});
}).catch((cause)=>{
// procedure threw an error
const error = getTRPCErrorFromUnknown(cause);
opts.onError?.({
error,
path,
type,
ctx,
req,
input
});
respond({
id,
jsonrpc,
error: getErrorShape({
config: router._def._config,
error,
type,
path,
input,
ctx
})
});
});
}
client.on('message', (rawData)=>{
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const msgStr = rawData.toString();
if (msgStr === 'PONG') {
return;
}
if (msgStr === 'PING') {
if (!opts.dangerouslyDisablePong) {
client.send('PONG');
}
return;
}
if (!ctxPromise) {
// If the ctxPromise wasn't created immediately, we're expecting the first message to be a TRPCConnectionParamsMessage
ctxPromise = createCtxPromise(()=>{
let msg;
try {
msg = JSON.parse(msgStr);
if (!isObject(msg)) {
throw new Error('Message was not an object');
}
} catch (cause) {
throw new TRPCError({
code: 'PARSE_ERROR',
message: `Malformed TRPCConnectionParamsMessage`,
cause
});
}
const connectionParams = parseConnectionParamsFromUnknown(msg.data);
return connectionParams;
});
return;
}
const parsedMsgs = run(()=>{
try {
const msgJSON = JSON.parse(msgStr);
const msgs = Array.isArray(msgJSON) ? msgJSON : [
msgJSON
];
return msgs.map((raw)=>parseTRPCMessage(raw, transformer));
} catch (cause) {
const error = new TRPCError({
code: 'PARSE_ERROR',
cause
});
respond({
id: null,
error: getErrorShape({
config: router._def._config,
error,
type: 'unknown',
path: undefined,
input: undefined,
ctx
})
});
return [];
}
});
parsedMsgs.map(handleRequest);
});
// WebSocket errors should be handled, as otherwise unhandled exceptions will crash Node.js.
// This line was introduced after the following error brought down production systems:
// "RangeError: Invalid WebSocket frame: RSV2 and RSV3 must be clear"
// Here is the relevant discussion: https://github.com/websockets/ws/issues/1354#issuecomment-774616962
client.on('error', (cause)=>{
opts.onError?.({
ctx,
error: getTRPCErrorFromUnknown(cause),
input: undefined,
path: undefined,
type: 'unknown',
req
});
});
client.once('close', ()=>{
for (const sub of clientSubscriptions.values()){
sub.abort();
}
clientSubscriptions.clear();
abortController.abort();
});
};
}
/**
* Handle WebSocket keep-alive messages
*/ function handleKeepAlive(client, pingMs = 30000, pongWaitMs = 5000) {
let timeout = undefined;
let ping = undefined;
const schedulePing = ()=>{
const scheduleTimeout = ()=>{
timeout = setTimeout(()=>{
client.terminate();
}, pongWaitMs);
};
ping = setTimeout(()=>{
client.send('PING');
scheduleTimeout();
}, pingMs);
};
const onMessage = ()=>{
clearTimeout(ping);
clearTimeout(timeout);
schedulePing();
};
client.on('message', onMessage);
client.on('close', ()=>{
clearTimeout(ping);
clearTimeout(timeout);
});
schedulePing();
}
function applyWSSHandler(opts) {
const onConnection = getWSConnectionHandler(opts);
opts.wss.on('connection', (client, req)=>{
if (opts.prefix && !req.url?.startsWith(opts.prefix)) {
return;
}
onConnection(client, req);
});
return {
broadcastReconnectNotification: ()=>{
const response = {
id: null,
method: 'reconnect'
};
const data = JSON.stringify(response);
for (const client of opts.wss.clients){
if (client.readyState === WEBSOCKET_OPEN) {
client.send(data);
}
}
}
};
}
export { applyWSSHandler, getWSConnectionHandler, handleKeepAlive };