trpc-uwebsockets
Version:
tRPC adapter for uWebSockets.js server
647 lines (586 loc) • 17.7 kB
text/typescript
import { TRPCClientError } from '@trpc/client';
import { initTRPC, TRPCError } from '@trpc/server';
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
import { afterEach, describe, expect, expectTypeOf, test, vi } from 'vitest';
import { z } from 'zod';
import { testFactory } from './___testHelpers';
import { type CreateContextOptions } from './requestHandler';
import { TRPCRequestInfo } from '@trpc/server/unstable-core-do-not-import';
async function createContext({ req, res, info, client }: CreateContextOptions) {
const user = { name: req.headers.get('username') || 'anonymous' };
await new Promise((resolve) => setTimeout(resolve, 10));
if (info.url?.searchParams.get('throw')) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: info.url!.searchParams.get('throw')!,
});
}
// for websocket context throwing after connection
if (info.connectionParams?.throw) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: info.connectionParams?.throw,
});
}
// test writing headers inside the context
if (info.type === 'query') {
res.cork(() => {
res.writeHeader('x-test', 'true');
});
}
return { req, res, user, info, client };
}
interface Message {
id: string;
}
function createAppRouter() {
const ee = new EventEmitter();
const onNewMessageSubscription = vi.fn();
const onSubscriptionEnded = vi.fn();
const t = initTRPC.create();
const router = t.router;
const publicProcedure = t.procedure;
const appRouter = router({
ping: publicProcedure.query(() => {
return 'pong';
}),
echo: publicProcedure.input(z.string()).query(({ input }) => {
return input;
}),
hello: publicProcedure
.input(
z
.object({
username: z.string().nullish(),
})
.nullish()
)
.query(({ input, ctx }) => ({
text: `hello ${input?.username ?? (ctx as any).user?.name ?? 'world'}`,
})),
helloMutation: publicProcedure
.input(z.string())
.mutation(({ input }) => `hello ${input}`),
editPost: publicProcedure
.input(
z.object({
id: z.string(),
data: z.object({
title: z.string(),
text: z.string(),
}),
})
)
.mutation(async ({ input, ctx }) => {
if ((ctx as any).user.name === 'anonymous') {
return { error: 'Unauthorized user' };
}
const { id, data } = input;
return { id, ...data };
}),
onMessage: publicProcedure.input(z.string()).subscription(() => {
const sub = observable<Message>((emit) => {
const onMessage = (data: Message) => {
emit.next(data);
};
ee.on('server:msg', onMessage);
return () => {
onSubscriptionEnded();
ee.off('server:msg', onMessage);
};
});
ee.emit('subscription:created');
onNewMessageSubscription();
return sub;
}),
pubSub: publicProcedure.input(z.null()).subscription(({ ctx }) => {
// TODO: fix all these
const anyContext = ctx as any;
if (!anyContext.client) {
throw new Error('assertion: client should never be null in websockets');
}
const client = anyContext.client!;
// for some reason client.publish does not work...
// currently okay is false, not sure why uWs behaves this way
// publishing from the server works though
// app.server.publish('topic', 'created2');
const okCreated = client.publish('topic', 'created');
// console.log('publishing created to topic result', okCreated);
const sub = observable<Message>((emit) => {
emit.next({
id: 'message',
});
setTimeout(() => {
emit.complete();
}, 50);
return () => {
const okEnded = client.publish('topic', 'ended');
// console.log('publishing ended to topic result', okEnded);
};
});
return sub;
}),
waitForMs: publicProcedure.input(z.number()).subscription(({ input }) => {
const sub = observable<Message>((emit) => {
setTimeout(() => {
emit.complete();
}, input);
return () => {};
});
return sub;
}),
request: router({
info: publicProcedure.query(({ ctx }) => {
return (ctx as any).info as TRPCRequestInfo;
}),
}),
deferred: publicProcedure
.input(
z.object({
wait: z.number(),
})
)
.query(async (opts) => {
await new Promise<void>((resolve) =>
setTimeout(resolve, opts.input.wait * 10)
);
return opts.input.wait;
}),
throw: t.procedure.input(z.string()).query(({ input }) => {
throw new TRPCError({
code: 'BAD_REQUEST',
message: input,
});
}),
count: t.procedure
.input(
z.object({
from: z.number(),
count: z.number(),
throw: z.number().optional(),
})
)
.subscription(async function* ({ input, signal }) {
for (let i = input.from; i < input.from + input.count; i++) {
await new Promise((resolve) => setTimeout(resolve, 20));
if (signal?.aborted) {
return;
}
if (input.throw === i) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: i.toString(),
});
}
yield i;
}
}),
});
return { appRouter, ee, onNewMessageSubscription, onSubscriptionEnded };
}
let stopFn = () => {};
afterEach(() => {
stopFn();
});
function defaultFactory(config?: {
createContext?: (opts: CreateContextOptions) => Promise<any>;
}) {
const router = createAppRouter();
const factoryVal = testFactory({
appRouter: router.appRouter,
createContext: config?.createContext ?? createContext,
});
stopFn = factoryVal.stop;
return {
...router,
...factoryVal,
};
}
describe('server', () => {
test('fetch GET smoke', async () => {
const ctx = defaultFactory();
const res = await ctx.fetch(`/hello`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
// body should be object
expect(await res.text()).toEqual('Hello world');
});
test('forwards response meta', async () => {
const ctx = defaultFactory();
const res = await ctx.fetch(`/trpc/ping?input=${encodeURI('{}')}`, {});
expect(res.status).toEqual(200);
expect(res.headers.get('Access-Control-Allow-Origin')).toEqual('*'); // from the meta
expect(res.headers.get('x-test')).toEqual('true'); // from the context
});
test('query', async () => {
const ctx = defaultFactory();
const clients = [
ctx.clientBatchStream().client,
ctx.clientBatch().client,
ctx.clientWs().client,
];
for (const client of clients) {
expect(await client.ping.query()).toMatchInlineSnapshot(`"pong"`);
expect(await client.hello.query()).toMatchInlineSnapshot(`
Object {
"text": "hello anonymous",
}
`);
expect(
await client.hello.query({
username: 'test',
})
).toMatchInlineSnapshot(`
Object {
"text": "hello test",
}
`);
}
});
test('mutation', async () => {
const ctx = defaultFactory();
const clients = [
ctx.clientBatchStream().client,
ctx.clientBatch().client,
ctx.clientWs().client,
];
for (const client of clients) {
expect(
await client.editPost.mutate({
id: '42',
data: { title: 'new_title', text: 'new_text' },
})
).toMatchInlineSnapshot(`
Object {
"error": "Unauthorized user",
}
`);
}
});
test('subscription', { timeout: 5000 }, async () => {
const ctx = defaultFactory();
const ee = ctx.ee;
const clients = [ctx.clientSse().client, ctx.clientWs().client];
for (const client of clients) {
ee.once('subscription:created', () => {
setTimeout(() => {
ee.emit('server:msg', {
id: '1',
});
ee.emit('server:msg', {
id: '2',
});
});
});
const onStartedMock = vi.fn();
const onDataMock = vi.fn();
const sub = client.onMessage.subscribe('onMessage', {
onStarted: onStartedMock,
onData(data) {
expectTypeOf(data).not.toBeAny();
expectTypeOf(data).toMatchTypeOf<Message>();
onDataMock(data);
},
});
await vi.waitFor(
() => {
expect(onStartedMock).toHaveBeenCalledTimes(1);
expect(onDataMock).toHaveBeenCalledTimes(2);
},
{ timeout: 3000 }
);
ee.emit('server:msg', {
id: '3',
});
await vi.waitFor(() => {
expect(onDataMock).toHaveBeenCalledTimes(3);
});
expect(onDataMock.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"id": "1",
},
],
Array [
Object {
"id": "2",
},
],
Array [
Object {
"id": "3",
},
],
]
`);
sub.unsubscribe();
await vi.waitFor(() => {
expect(ee.listenerCount('server:msg')).toBe(0);
expect(ee.listenerCount('server:error')).toBe(0);
});
}
});
test('streaming', async () => {
const ctx = defaultFactory();
const tests = [ctx.clientBatchStream(), ctx.clientWs()];
for (const { client, orderedResults } of tests) {
const results = await Promise.all([
client.deferred.query({ wait: 3 }),
client.deferred.query({ wait: 1 }),
client.deferred.query({ wait: 2 }),
]);
expect(results).toEqual([3, 1, 2]);
expect(orderedResults).toEqual([1, 2, 3]);
}
});
test('batched requests', async () => {
const ctx = defaultFactory();
const clients = [
ctx.clientBatchStream().client,
ctx.clientBatch().client,
ctx.clientWs().client,
];
for (const client of clients) {
const res = await Promise.all([
client.helloMutation.mutate('world'),
client.helloMutation.mutate('KATT'),
]);
expect(res).toEqual(['hello world', 'hello KATT']);
}
});
test('handles throwing context', async () => {
const ctx = defaultFactory();
const clientOpts = {
queryParams: {
throw: 'expected_context_error',
},
};
const clients = [
ctx.clientBatchStream(clientOpts).client,
ctx.clientBatch(clientOpts).client,
];
for (const client of clients) {
await expect(
client.echo.query('hii')
).rejects.toThrowErrorMatchingInlineSnapshot(
`[TRPCClientError: expected_context_error]`
);
}
// websocket context resolution failure hides the message
const wsClient = ctx.clientWs(clientOpts).client;
await expect(
wsClient.echo.query('hii')
).rejects.toThrowErrorMatchingInlineSnapshot(
`[TRPCClientError: Unknown error]`
);
});
test('handles throwing procedure', async () => {
const ctx = defaultFactory();
const clients = [
ctx.clientBatchStream().client,
ctx.clientBatch().client,
ctx.clientWs().client,
];
for (const client of clients) {
await expect(
client.throw.query('expected_procedure_error')
).rejects.toThrowErrorMatchingInlineSnapshot(
`[TRPCClientError: expected_procedure_error]`
);
expect(ctx.onServerErrorSpy).toHaveBeenCalled();
}
});
test('handles throwing subscription', async () => {
const ctx = defaultFactory();
const clients = [ctx.clientWs(), ctx.clientSse()];
for (const { client, orderedResults } of clients) {
let error: TRPCClientError<any> | null = null;
client.count.subscribe(
{
from: 0,
count: 10,
throw: 5,
},
{
onError(err) {
error = err;
},
}
);
await vi.waitFor(() => {
expect(orderedResults).toEqual([0, 1, 2, 3, 4]);
expect(ctx.onServerErrorSpy).toHaveBeenCalled();
expect(error!.message).toEqual(new TRPCClientError('5').message);
});
}
});
});
describe('websocket', () => {
test('does not bind other websocket connection', async () => {
const ctx = defaultFactory();
const client = new WebSocket(`ws://localhost:${ctx.port}/ws`);
await new Promise<void>((resolve, reject) => {
client.onopen = () => {
client.send('hello');
resolve();
};
client.onerror = reject;
});
const promise = new Promise<string>((resolve) => {
client.onmessage = (msg) => {
return resolve(msg.data);
};
});
const message = await promise;
expect(message.toString()).toBe('hello');
client.close();
});
test('properly passes context', async () => {
const contextDone = vi.fn();
const ctx = defaultFactory({
createContext: async ({ req, res, info, client }) => {
expect(client != null);
expect(res != null);
expect(client).equals(res);
// can still get the removeAddress from res
// hopefully this stays constant in ci?
const ip = new TextDecoder().decode(res.getRemoteAddressAsText());
expect(ip).equals('127.0.0.1');
// but other res-like features are not possible
expect('writeHeader' in res).equals(false);
contextDone();
return { req, res, info, client };
},
});
const client = ctx.clientWs().client;
await client.echo.query('testing');
expect(contextDone).toHaveBeenCalledOnce();
});
test('handles throwing context with connection params', async () => {
const server = defaultFactory();
let closeCode: number | undefined = undefined;
const { client } = server.clientWs({
wsClientOptions: {
connectionParams: {
throw: 'expected_context_error',
},
onClose(cause) {
closeCode = cause?.code;
},
onError(cause) {
console.warn('on error called', cause);
},
},
});
await expect(
client.echo.query('hi')
).rejects.toThrowErrorMatchingInlineSnapshot(
`[TRPCClientError: Unknown error]`
);
await vi.waitFor(() => {
expect(closeCode).toEqual(1008);
});
});
test('handles throwing context without connection params', async () => {
const server = defaultFactory();
let closeCode: number | undefined = undefined;
const { client } = server.clientWs({
queryParams: {
throw: 'expected_context_error',
},
wsClientOptions: {
onClose(cause) {
closeCode = cause?.code;
},
},
});
await expect(
client.echo.query('hi')
).rejects.toThrowErrorMatchingInlineSnapshot(
`[TRPCClientError: Unknown error]`
);
await vi.waitFor(() => {
expect(closeCode).toEqual(1008);
});
});
test('keep alive', async () => {
const ctx = defaultFactory();
let closeCode: number | undefined = undefined;
const { client, wsClient } = ctx.clientWs({
wsClientOptions: {
onClose(cause) {
closeCode = cause?.code;
},
keepAlive: {
enabled: true,
intervalMs: 100,
pongTimeoutMs: 200,
},
},
});
const { unsubscribe } = client.waitForMs.subscribe(2_000, {});
await vi.waitFor(() => {
expect(wsClient.connection!.state).toBe('open');
});
let pongCount = 0;
wsClient.connection!.ws!.addEventListener('message', (ev) => {
if (ev.data === 'PONG') {
pongCount++;
}
});
await vi.waitFor(
() => {
expect(pongCount).toEqual(4);
expect(closeCode).toEqual(undefined);
},
{
timeout: 2_000,
}
);
unsubscribe();
wsClient.close();
await vi.waitFor(() => {
expect(closeCode).toEqual(1005);
});
});
// // for some reason client.publish does not work, even though it should
// test('uWebsockets pubsub', { skip: true }, async () => {
// const clientWs = new WebSocket(`ws://localhost:${app.port}/pubsub`);
// const messages: string[] = [];
// let connected = false;
// clientWs.onmessage = (ev) => {
// console.log('triggered onmessage', ev.data);
// messages.push(JSON.parse(ev.data));
// };
// clientWs.onerror = () => {
// throw new Error('client had an error');
// };
// clientWs.onopen = () => {
// connected = true;
// };
// await vi.waitFor(() => {
// expect(connected).toBe(true);
// });
// expect(app.server.numSubscribers('topic')).toBe(1);
// const { client } = app.clientWs();
// const sub = client.pubSub.subscribe(null, {
// onData(data) {
// console.log('trpc onMessage', data);
// expectTypeOf(data).not.toBeAny();
// expectTypeOf(data).toMatchTypeOf<Message>();
// messages.push(data.id);
// },
// });
// await vi.waitFor(() => {
// expect(messages).toEqual(['created', 'message', 'ended']);
// });
// sub.unsubscribe();
// clientWs.close();
// });
});