trpc-uwebsockets
Version:
tRPC adapter for uWebSockets.js server
317 lines (282 loc) • 8.1 kB
text/typescript
import { vi } from 'vitest';
import { AnyTRPCRouter } from '@trpc/server';
import {
applyWebsocketHandler,
WebSocketBehaviorOptions,
WebsocketsKeepAlive,
} from './websockets';
import { observable } from '@trpc/server/observable';
import uWs from 'uWebSockets.js';
import {
applyRequestHandler,
CreateContextOptions,
CreateHandlerOptions,
} from './requestHandler';
import type {
HTTPHeaders,
TRPCLink,
WebSocketClientOptions,
} from '@trpc/client';
import {
createTRPCClient,
createWSClient,
httpBatchLink,
httpBatchStreamLink,
httpSubscriptionLink,
wsLink,
} from '@trpc/client';
import { EventSourcePolyfill } from 'event-source-polyfill';
global.EventSource = EventSourcePolyfill;
export interface ServerOptions<AppRouter extends AnyTRPCRouter> {
appRouter: AppRouter;
prefix?: string;
createContext?: (opts: CreateContextOptions) => Promise<any>;
uWsBehaviorOptions?: WebSocketBehaviorOptions;
keepAlive?: WebsocketsKeepAlive;
}
function createServer<AppRouter extends AnyTRPCRouter>(
opts: ServerOptions<AppRouter>
) {
const prefix = opts.prefix ?? '/trpc';
const instance = uWs.App();
const router = opts.appRouter;
const onServerErrorSpy = vi.fn();
applyRequestHandler(instance, {
prefix,
ssl: false,
trpcOptions: {
router,
createContext: opts.createContext ?? (() => ({})),
onError(data) {
onServerErrorSpy(data);
},
responseMeta() {
return {
headers: {
'Access-Control-Allow-Origin': '*',
},
};
},
} satisfies CreateHandlerOptions<AppRouter>['trpcOptions'],
});
applyWebsocketHandler(instance, {
prefix,
ssl: false,
router,
createContext: opts.createContext ?? (() => ({})),
onError(data) {
onServerErrorSpy(data);
},
uWsBehaviorOptions: opts.uWsBehaviorOptions,
keepAlive: opts.keepAlive,
});
instance.get('/hello', async (res) => {
res.end('Hello world');
});
instance.post('/hello', async (res) => {
res.end(JSON.stringify({ hello: 'POST', body: 'TODO, why?' }));
});
instance.ws('/ws', {
message: (client, rawMsg) => {
client.send(rawMsg);
},
});
instance.ws('/pubsub', {
open: (client) => {
const ok = client.subscribe('topic');
if (!ok) {
throw new Error("assertion: failed to subscribe to 'topic'");
}
},
});
instance.any('/*', (res) => {
res.writeStatus('404 NOT FOUND');
res.end();
});
let socket: uWs.us_listen_socket | false | null = null;
instance.listen('0.0.0.0', 0, (token) => {
socket = token;
});
if (!socket) {
throw new Error('could not make a socket');
}
const port = uWs.us_socket_local_port(socket);
return {
stop() {
if (!socket) {
throw new Error('could not close socket as socket is already closed');
}
uWs.us_listen_socket_close(socket);
socket = null;
},
port,
instance,
onServerErrorSpy,
};
}
function makeLinkSpy() {
const orderedResults: number[] = [];
const linkSpy: TRPCLink<any> = () => {
// here we just got initialized in the app - this happens once per app
// useful for storing cache for instance
return ({ next, op }) => {
// this is when passing the result to the next link
// each link needs to return an observable which propagates results
return observable((observer) => {
const unsubscribe = next(op).subscribe({
next(value) {
const data = value.result.data;
if (data !== undefined) {
orderedResults.push(data as number);
}
observer.next(value);
},
error: observer.error,
});
return unsubscribe;
});
};
};
return {
orderedResults,
linkSpy,
};
}
interface ClientOptions {
prefix?: string;
wsClientOptions?: Omit<WebSocketClientOptions, 'url'> | undefined;
queryParams?: Record<string, string> | undefined;
headers?: HTTPHeaders | undefined;
port: number;
}
function toQueryString(queryParams: Record<string, string>) {
const raw = new URLSearchParams(queryParams).toString();
return raw.length == 0 ? '' : `?${raw}`;
}
function createClientWs<TRouter extends AnyTRPCRouter>(
router: TRouter,
opts: ClientOptions
) {
const prefix = opts.prefix ?? '/trpc';
const transformer = router._def._config.transformer as any;
const qs = toQueryString(opts.queryParams ?? {});
const host = `localhost:${opts.port}${prefix}${qs}`;
const wsClient = createWSClient({
retryDelayMs: () => 99999, // never retry by default
...opts.wsClientOptions,
url: `ws://${host}`,
});
const { orderedResults, linkSpy } = makeLinkSpy();
const client = createTRPCClient<TRouter>({
links: [linkSpy, wsLink({ client: wsClient, transformer })],
});
return { client, wsClient, orderedResults };
}
function createClientBatchStream<TRouter extends AnyTRPCRouter>(
router: TRouter,
opts: ClientOptions
) {
const prefix = opts.prefix ?? '/trpc';
const transformer = router._def._config.transformer as any;
const qs = toQueryString(opts.queryParams ?? {});
const host = `localhost:${opts.port}${prefix}${qs}`;
const { orderedResults, linkSpy } = makeLinkSpy();
const client = createTRPCClient<TRouter>({
links: [
linkSpy,
httpBatchStreamLink({
url: `http://${host}`,
headers: opts.headers,
transformer,
}),
],
});
return { client, orderedResults };
}
function createClientBatch<TRouter extends AnyTRPCRouter>(
router: TRouter,
opts: ClientOptions
) {
const prefix = opts.prefix ?? '/trpc';
const transformer = router._def._config.transformer as any;
const qs = toQueryString(opts.queryParams ?? {});
const host = `localhost:${opts.port}${prefix}${qs}`;
const client = createTRPCClient<TRouter>({
links: [
httpBatchLink({
url: `http://${host}`,
headers: opts.headers,
transformer,
}),
],
});
return { client };
}
function createClientSse<TRouter extends AnyTRPCRouter>(
router: TRouter,
opts: ClientOptions
) {
const prefix = opts.prefix ?? '/trpc';
const transformer = router._def._config.transformer as any;
const qs = toQueryString(opts.queryParams ?? {});
const host = `localhost:${opts.port}${prefix}${qs}`;
const { orderedResults, linkSpy } = makeLinkSpy();
const client = createTRPCClient<TRouter>({
links: [
linkSpy,
httpSubscriptionLink({
url: `http://${host}`,
connectionParams: opts.wsClientOptions?.connectionParams,
// ponyfill EventSource
EventSource: EventSourcePolyfill as any,
transformer,
}),
],
});
return { client, orderedResults };
}
export function testFactory<AppRouter extends AnyTRPCRouter>(
serverOptions: ServerOptions<AppRouter>
) {
const { instance, port, stop, onServerErrorSpy } = createServer({
...serverOptions,
});
return {
server: instance,
stop,
onServerErrorSpy,
port,
opts: serverOptions,
fetch(path: string, opts: RequestInit) {
return fetch(`http://localhost:${port}${path}`, { ...opts });
},
clientBatchStream(clientOptions?: Partial<ClientOptions> | undefined) {
return createClientBatchStream(serverOptions.appRouter, {
...clientOptions,
port,
});
},
clientBatch(clientOptions?: Partial<ClientOptions> | undefined) {
return createClientBatch(serverOptions.appRouter, {
...clientOptions,
port,
});
},
clientSse(clientOptions?: Partial<ClientOptions> | undefined) {
return createClientSse(serverOptions.appRouter, {
...clientOptions,
port,
});
},
clientWs(clientOptions?: Partial<ClientOptions> | undefined) {
const { client, wsClient, orderedResults } = createClientWs(
serverOptions.appRouter,
{
...clientOptions,
port,
}
);
return { client, wsClient, orderedResults };
},
};
}