trpc-uwebsockets
Version:
tRPC adapter for uWebSockets.js server
242 lines (201 loc) • 5.53 kB
text/typescript
import { HttpResponse, HttpRequest } from 'uWebSockets.js';
import { TRPCError } from '@trpc/server';
// this implements uWs compatibility with fetch api as its needed for v11 of trpc
// mostly following /trpc/packages/server/src/adapters/node-http/incomingMessageToRequest.ts
// response with extra parameters
// ssl specifies if https is used
export type HttpResponseDecorated = HttpResponse & {
aborted: boolean;
ssl: boolean;
};
export function decorateHttpResponse(
res: HttpResponse,
ssl = false
): HttpResponseDecorated {
const resDecorated: HttpResponseDecorated = res as any;
resDecorated.aborted = false;
resDecorated.ssl = ssl;
return resDecorated;
}
export function uWsToRequestNoBody(
req: HttpRequest,
res: HttpResponseDecorated
): Request {
const headers = createHeaders(req);
const method = req.getCaseSensitiveMethod().toUpperCase();
const isSsl = res.ssl;
const url = createURL(req, isSsl ? 'https' : 'http');
const init: RequestInit = {
headers: headers,
method: method,
};
const request = new Request(url, init);
return request;
}
export function uWsToRequest(
req: HttpRequest,
res: HttpResponseDecorated,
opts: {
/**
* Max body size in bytes. If the body is larger than this, the request will be aborted
*/
maxBodySize: number | null;
}
): Request {
const ac = new AbortController();
const onAbort = () => {
res.aborted = true;
ac.abort();
};
res.onAborted(onAbort);
const headers = createHeaders(req);
const method = req.getCaseSensitiveMethod().toUpperCase();
const isSsl = res.ssl;
const url = createURL(req, isSsl ? 'https' : 'http');
const init: RequestInit = {
headers: headers,
method: method,
signal: ac.signal,
};
if (method !== 'GET' && method !== 'HEAD') {
init.body = createBody(res, opts);
init.duplex = 'half';
}
const request = new Request(url, init);
return request;
}
function createHeaders(req: HttpRequest): Headers {
const headers = new Headers();
req.forEach((key, value) => {
/* istanbul ignore next -- @preserve */
if (key.startsWith(':')) {
// Skip HTTP/2 pseudo-headers
return;
}
if (value.length != 0) {
headers.append(key, value);
}
});
return headers;
}
export function createURL(req: HttpRequest, protocol: string): URL {
try {
const host = req.getHeader('host') ?? 'localhost';
const path = req.getUrl();
const qs = req.getQuery();
if (qs) {
return new URL(`${path}?${qs}`, `${protocol}://${host}`);
} else {
return new URL(path, `${protocol}://${host}`);
}
} catch (cause) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid URL',
cause,
});
}
}
function createBody(
res: HttpResponse,
opts: {
maxBodySize: number | null;
}
): RequestInit['body'] {
let size = 0;
let hasClosed = false;
return new ReadableStream<Uint8Array>({
start(controller) {
res.onData((tempChunk, isLast) => {
if (hasClosed) return;
// This copies the buffer *immediately* and avoids using a detached buffer
const chunk = new Uint8Array(tempChunk.byteLength);
chunk.set(new Uint8Array(tempChunk)); // must be done before enqueue
size += chunk.byteLength;
if (!opts.maxBodySize || size <= opts.maxBodySize) {
controller.enqueue(chunk);
if (isLast) {
hasClosed = true;
controller.close();
}
} else {
hasClosed = true;
controller.error(new TRPCError({ code: 'PAYLOAD_TOO_LARGE' }));
}
});
res.onAborted(() => {
if (!hasClosed) {
res.aborted = true;
hasClosed = true;
controller.error(new TRPCError({ code: 'CLIENT_CLOSED_REQUEST' }));
}
});
},
cancel() {
res.aborted = true;
res.cork(() => {
res.close();
});
},
});
}
export async function uWsSendResponse(
res: HttpResponseDecorated,
fetchRes: Response
): Promise<void> {
const unsteamed_text = await fetchRes.text();
if (res.aborted) return;
res.cork(() => {
res.writeStatus(fetchRes.status.toString());
fetchRes.headers.forEach((value, key) => {
res.writeHeader(key, value);
});
res.end(unsteamed_text);
});
}
export async function uWsSendResponseStreamed(
fetchRes: Response,
res: HttpResponseDecorated
): Promise<void> {
if (res.aborted) return;
res.cork(() => {
res.writeStatus(fetchRes.status.toString());
fetchRes.headers.forEach((value, key) => {
res.writeHeader(key, value);
});
});
if (!fetchRes.body) {
if (!res.aborted) {
res.cork(() => {
res.end();
});
}
return;
}
const reader = fetchRes.body.getReader();
try {
while (true) {
if (res.aborted) break;
const { value, done } = await reader.read();
if (res.aborted) break;
if (done) {
if (!res.aborted) {
res.cork(() => {
res.end();
});
}
break;
}
if (!res.aborted) {
res.cork(() => {
res.write(value);
});
}
}
} finally {
// let the reader know that nothing will be read from it
// potentially useful during res.aborted early return; also should theoretically help GC clean up the reader?
// this may not be needed, but lets keep it here just in case
reader.releaseLock();
}
}