FRE-600: Fix code review blockers

- Consolidated duplicate UndoManagers to single instance
- Fixed connection promise to only resolve on 'connected' status
- Fixed WebSocketProvider import (WebsocketProvider)
- Added proper doc.destroy() cleanup
- Renamed isPresenceInitialized property to avoid conflict

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-25 00:08:01 -04:00
parent 65b552bb08
commit 7c684a42cc
48450 changed files with 5679671 additions and 383 deletions

View File

@@ -0,0 +1,254 @@
import { Readable, type Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type {
APIGatewayProxyEvent,
APIGatewayProxyEventV2,
APIGatewayProxyResult,
APIGatewayProxyStructuredResultV2,
} from 'aws-lambda';
import { splitSetCookieString } from '../../vendor/cookie-es/set-cookie/split';
export type LambdaEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2;
export type APIGatewayResult =
| APIGatewayProxyResult
| APIGatewayProxyStructuredResultV2;
function determinePayloadFormat(event: LambdaEvent): string {
// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
// According to AWS support, version is is extracted from the version property in the event.
// If there is no version property, then the version is implied as 1.0
const unknownEvent = event as { version?: string };
if (typeof unknownEvent.version === 'undefined') {
return '1.0';
} else {
return unknownEvent.version;
}
}
/** 1:1 mapping of v1 or v2 input events, deduces which is which.
* @internal
**/
export type inferAPIGWReturn<TEvent> = TEvent extends APIGatewayProxyEvent
? APIGatewayProxyResult
: TEvent extends APIGatewayProxyEventV2
? APIGatewayProxyStructuredResultV2
: never;
interface Processor<TEvent extends LambdaEvent> {
getTRPCPath: (event: TEvent) => string;
url(event: TEvent): Pick<URL, 'hostname' | 'pathname' | 'search'>;
getHeaders: (event: TEvent) => Headers;
getMethod: (event: TEvent) => string;
toResult: (response: Response) => Promise<inferAPIGWReturn<TEvent>>;
toStream: (response: Response, stream: Writable) => Promise<void>;
}
function getHeadersAndCookiesFromResponse(response: Response) {
const headers = Object.fromEntries(response.headers.entries());
const cookies: string[] = splitSetCookieString(
response.headers.getSetCookie(),
).map((cookie) => cookie.trim());
delete headers['set-cookie'];
return { headers, cookies };
}
const v1Processor: Processor<APIGatewayProxyEvent> = {
// same as getPath above
getTRPCPath: (event) => {
if (!event.pathParameters) {
// Then this event was not triggered by a resource denoted with {proxy+}
return event.path.split('/').pop() ?? '';
}
const matches = event.resource.matchAll(/\{(.*?)\}/g);
for (const match of matches) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const group = match[1]!;
if (group.includes('+') && event.pathParameters) {
return event.pathParameters[group.replace('+', '')] ?? '';
}
}
return event.path.slice(1);
},
url(event) {
const hostname: string =
event.requestContext.domainName ??
event.headers['host'] ??
event.multiValueHeaders?.['host']?.[0] ??
'localhost';
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(
event.queryStringParameters ?? {},
)) {
if (value !== undefined) {
searchParams.append(key, value);
}
}
const qs = searchParams.toString();
return {
hostname,
pathname: event.path,
search: qs && `?${qs}`,
};
},
getHeaders: (event) => {
const headers = new Headers();
// Process multiValueHeaders first (takes precedence per AWS docs)
// This handles headers that can have multiple values
for (const [k, values] of Object.entries(event.multiValueHeaders ?? {})) {
if (values) {
values.forEach((v) => headers.append(k, v));
}
}
// Then process single-value headers, but skip any that were already
// added from multiValueHeaders to avoid duplication
for (const [key, value] of Object.entries(event.headers ?? {})) {
if (value !== undefined && !headers.has(key)) {
headers.append(key, value);
}
}
return headers;
},
getMethod: (event) => event.httpMethod,
toResult: async (response) => {
const { headers, cookies } = getHeadersAndCookiesFromResponse(response);
const result: APIGatewayProxyResult = {
...(cookies.length && { multiValueHeaders: { 'set-cookie': cookies } }),
statusCode: response.status,
body: await response.text(),
headers,
};
return result;
},
toStream: async (response, stream) => {
const { headers, cookies } = getHeadersAndCookiesFromResponse(response);
const metadata = {
statusCode: response.status,
headers,
cookies,
};
const responseStream = awslambda.HttpResponseStream.from(stream, metadata);
if (response.body) {
await pipeline(Readable.fromWeb(response.body as any), responseStream);
} else {
responseStream.end();
}
},
};
const v2Processor: Processor<APIGatewayProxyEventV2> = {
getTRPCPath: (event) => {
const matches = event.routeKey.matchAll(/\{(.*?)\}/g);
for (const match of matches) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const group = match[1]!;
if (group.includes('+') && event.pathParameters) {
return event.pathParameters[group.replace('+', '')] ?? '';
}
}
return event.rawPath.slice(1);
},
url(event) {
return {
hostname: event.requestContext.domainName,
pathname: event.rawPath,
search: event.rawQueryString && `?${event.rawQueryString}`,
};
},
getHeaders: (event) => {
const headers = new Headers();
for (const [key, value] of Object.entries(event.headers ?? {})) {
if (value !== undefined) {
headers.append(key, value);
}
}
if (event.cookies) {
headers.append('cookie', event.cookies.join('; '));
}
return headers;
},
getMethod: (event) => event.requestContext.http.method,
toResult: async (response) => {
const { headers, cookies } = getHeadersAndCookiesFromResponse(response);
const result: APIGatewayProxyStructuredResultV2 = {
cookies,
statusCode: response.status,
body: await response.text(),
headers,
};
return result;
},
toStream: async (response, stream) => {
const { headers, cookies } = getHeadersAndCookiesFromResponse(response);
const metadata = {
statusCode: response.status,
headers,
cookies,
};
const responseStream = awslambda.HttpResponseStream.from(stream, metadata);
if (response.body) {
await pipeline(Readable.fromWeb(response.body as any), responseStream);
} else {
responseStream.end();
}
},
};
export function getPlanner<TEvent extends LambdaEvent>(event: TEvent) {
const version = determinePayloadFormat(event);
let processor: Processor<TEvent>;
switch (version) {
case '1.0':
processor = v1Processor as Processor<TEvent>;
break;
case '2.0':
processor = v2Processor as Processor<TEvent>;
break;
default:
throw new Error(`Unsupported version: ${version}`);
}
const urlParts = processor.url(event);
const url = `https://${urlParts.hostname}${urlParts.pathname}${urlParts.search}`;
const init: RequestInit = {
headers: processor.getHeaders(event),
method: processor.getMethod(event),
// @ts-expect-error this is fine
duplex: 'half',
};
if (event.body) {
init.body = event.isBase64Encoded
? Buffer.from(event.body, 'base64')
: event.body;
}
const request = new Request(url, init);
return {
path: processor.getTRPCPath(event),
request,
toResult: processor.toResult,
toStream: processor.toStream,
};
}

View File

@@ -0,0 +1,115 @@
/**
* If you're making an adapter for tRPC and looking at this file for reference, you should import types and functions from `@trpc/server` and `@trpc/server/http`
*
* @example
* ```ts
* import type { AnyTRPCRouter } from '@trpc/server'
* import type { HTTPBaseHandlerOptions } from '@trpc/server/http'
* ```
*/
import type { Context as APIGWContext, StreamifyHandler } from 'aws-lambda';
// @trpc/server
import type {
AnyRouter,
CreateContextCallback,
inferRouterContext,
} from '../../@trpc/server';
// @trpc/server
import type {
HTTPBaseHandlerOptions,
ResolveHTTPRequestOptionsContextFn,
TRPCRequestInfo,
} from '../../@trpc/server/http';
import { resolveResponse } from '../../@trpc/server/http';
import type { inferAPIGWReturn, LambdaEvent } from './getPlanner';
import { getPlanner } from './getPlanner';
export type CreateAWSLambdaContextOptions<TEvent extends LambdaEvent> = {
event: TEvent;
context: APIGWContext;
info: TRPCRequestInfo;
};
export type AWSLambdaOptions<
TRouter extends AnyRouter,
TEvent extends LambdaEvent,
> = HTTPBaseHandlerOptions<TRouter, TEvent> &
CreateContextCallback<
inferRouterContext<AnyRouter>,
AWSLambdaCreateContextFn<TRouter, TEvent>
>;
export type AWSLambdaCreateContextFn<
TRouter extends AnyRouter,
TEvent extends LambdaEvent,
> = ({
event,
context,
info,
}: CreateAWSLambdaContextOptions<TEvent>) =>
| inferRouterContext<TRouter>
| Promise<inferRouterContext<TRouter>>;
export function awsLambdaRequestHandler<
TRouter extends AnyRouter,
TEvent extends LambdaEvent,
>(
opts: AWSLambdaOptions<TRouter, TEvent>,
): (event: TEvent, context: APIGWContext) => Promise<inferAPIGWReturn<TEvent>> {
return async (event, context) => {
const planner = getPlanner(event);
const createContext: ResolveHTTPRequestOptionsContextFn<TRouter> = async (
innerOpts,
) => {
return await opts.createContext?.({ event, context, ...innerOpts });
};
const response = await resolveResponse({
...opts,
createContext,
req: planner.request,
path: planner.path,
error: null,
onError(o) {
opts?.onError?.({
...o,
req: event,
});
},
});
return await planner.toResult(response);
};
}
export function awsLambdaStreamingRequestHandler<
TRouter extends AnyRouter,
TEvent extends LambdaEvent,
>(opts: AWSLambdaOptions<TRouter, TEvent>): StreamifyHandler<TEvent> {
return async (event, responseStream, context) => {
const planner = getPlanner(event);
const createContext: ResolveHTTPRequestOptionsContextFn<TRouter> = async (
innerOpts,
) => {
return await opts.createContext?.({ event, context, ...innerOpts });
};
const response = await resolveResponse({
...opts,
createContext,
req: planner.request,
path: planner.path,
error: null,
onError(o) {
opts?.onError?.({
...o,
req: event,
});
},
});
await planner.toStream(response, responseStream);
};
}

48
node_modules/@trpc/server/src/adapters/express.ts generated vendored Normal file
View File

@@ -0,0 +1,48 @@
/**
* If you're making an adapter for tRPC and looking at this file for reference, you should import types and functions from `@trpc/server` and `@trpc/server/http`
*
* @example
* ```ts
* import type { AnyTRPCRouter } from '@trpc/server'
* import type { HTTPBaseHandlerOptions } from '@trpc/server/http'
* ```
*/
import type * as express from 'express';
import type { AnyRouter } from '../@trpc/server';
// eslint-disable-next-line no-restricted-imports
import { run } from '../unstable-core-do-not-import';
import type {
NodeHTTPCreateContextFnOptions,
NodeHTTPHandlerOptions,
} from './node-http';
import { internal_exceptionHandler, nodeHTTPRequestHandler } from './node-http';
export type CreateExpressContextOptions = NodeHTTPCreateContextFnOptions<
express.Request,
express.Response
>;
export function createExpressMiddleware<TRouter extends AnyRouter>(
opts: NodeHTTPHandlerOptions<TRouter, express.Request, express.Response>,
): express.Handler {
return (req, res) => {
let path = '';
run(async () => {
path = req.path.slice(req.path.lastIndexOf('/') + 1);
await nodeHTTPRequestHandler({
...(opts as any),
req,
res,
path,
});
}).catch(
internal_exceptionHandler({
req,
res,
path,
...opts,
}),
);
};
}

View File

@@ -0,0 +1,81 @@
/**
* If you're making an adapter for tRPC and looking at this file for reference, you should import types and functions from `@trpc/server` and `@trpc/server/http`
*
* @example
* ```ts
* import type { AnyTRPCRouter } from '@trpc/server'
* import type { HTTPBaseHandlerOptions } from '@trpc/server/http'
* ```
*/
import type { FastifyReply, FastifyRequest } from 'fastify';
// @trpc/server
import type { AnyRouter } from '../../@trpc/server';
// @trpc/server/http
import {
resolveResponse,
type HTTPBaseHandlerOptions,
type ResolveHTTPRequestOptionsContextFn,
} from '../../@trpc/server/http';
// @trpc/server/node-http
import type { NodeHTTPRequest } from '../node-http';
import {
incomingMessageToRequest,
type NodeHTTPCreateContextOption,
} from '../node-http';
export type FastifyHandlerOptions<
TRouter extends AnyRouter,
TRequest extends FastifyRequest,
TResponse extends FastifyReply,
> = HTTPBaseHandlerOptions<TRouter, TRequest> &
NodeHTTPCreateContextOption<TRouter, TRequest, TResponse>;
type FastifyRequestHandlerOptions<
TRouter extends AnyRouter,
TRequest extends FastifyRequest,
TResponse extends FastifyReply,
> = FastifyHandlerOptions<TRouter, TRequest, TResponse> & {
req: TRequest;
res: TResponse;
path: string;
};
export async function fastifyRequestHandler<
TRouter extends AnyRouter,
TRequest extends FastifyRequest,
TResponse extends FastifyReply,
>(opts: FastifyRequestHandlerOptions<TRouter, TRequest, TResponse>) {
const createContext: ResolveHTTPRequestOptionsContextFn<TRouter> = async (
innerOpts,
) => {
return await opts.createContext?.({
...opts,
...innerOpts,
});
};
const incomingMessage: NodeHTTPRequest = opts.req.raw;
// monkey-path body to the IncomingMessage
if ('body' in opts.req) {
incomingMessage.body = opts.req.body;
}
const req = incomingMessageToRequest(incomingMessage, opts.res.raw, {
maxBodySize: null,
});
const res = await resolveResponse({
...opts,
req,
error: null,
createContext,
onError(o) {
opts?.onError?.({
...o,
req: opts.req,
});
},
});
await opts.res.send(res);
}

View File

@@ -0,0 +1,89 @@
/**
* If you're making an adapter for tRPC and looking at this file for reference, you should import types and functions from `@trpc/server` and `@trpc/server/http`
*
* @example
* ```ts
* import type { AnyTRPCRouter } from '@trpc/server'
* import type { HTTPBaseHandlerOptions } from '@trpc/server/http'
* ```
*/
/// <reference types="@fastify/websocket" />
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
// @trpc/server
import type { AnyRouter } from '../../@trpc/server';
// @trpc/server/http
import type { NodeHTTPCreateContextFnOptions } from '../node-http';
// @trpc/server/ws
import {
getWSConnectionHandler,
handleKeepAlive,
type WSSHandlerOptions,
} from '../ws';
import type { FastifyHandlerOptions } from './fastifyRequestHandler';
import { fastifyRequestHandler } from './fastifyRequestHandler';
export interface FastifyTRPCPluginOptions<TRouter extends AnyRouter> {
prefix?: string;
useWSS?: boolean;
trpcOptions: FastifyHandlerOptions<TRouter, FastifyRequest, FastifyReply>;
}
export type CreateFastifyContextOptions = NodeHTTPCreateContextFnOptions<
FastifyRequest,
FastifyReply
>;
export function fastifyTRPCPlugin<TRouter extends AnyRouter>(
fastify: FastifyInstance,
opts: FastifyTRPCPluginOptions<TRouter>,
done: (err?: Error) => void,
) {
fastify.removeContentTypeParser('application/json');
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'string' },
function (_, body, _done) {
_done(null, body);
},
);
fastify.removeContentTypeParser('multipart/form-data');
fastify.addContentTypeParser(
'multipart/form-data',
{},
function (_, body, _done) {
_done(null, body);
},
);
let prefix = opts.prefix ?? '';
// https://github.com/fastify/fastify-plugin/blob/fe079bef6557a83794bf437e14b9b9edb8a74104/plugin.js#L11
// @ts-expect-error property 'default' does not exists on type ...
if (typeof fastifyTRPCPlugin.default !== 'function') {
prefix = ''; // handled by fastify internally
}
fastify.all(`${prefix}/:path`, async (req, res) => {
const path = (req.params as any).path;
await fastifyRequestHandler({ ...opts.trpcOptions, req, res, path });
});
if (opts.useWSS) {
const trpcOptions =
opts.trpcOptions as unknown as WSSHandlerOptions<TRouter>;
const onConnection = getWSConnectionHandler<TRouter>({
...trpcOptions,
});
fastify.get(prefix ?? '/', { websocket: true }, (socket, req) => {
onConnection(socket, req.raw);
if (trpcOptions?.keepAlive?.enabled) {
const { pingMs, pongWaitMs } = trpcOptions.keepAlive;
handleKeepAlive(socket, pingMs, pongWaitMs);
}
});
}
done();
}

View File

@@ -0,0 +1,2 @@
export * from './fastifyRequestHandler';
export * from './fastifyTRPCPlugin';

View File

@@ -0,0 +1,80 @@
/**
* If you're making an adapter for tRPC and looking at this file for reference, you should import types and functions from `@trpc/server` and `@trpc/server/http`
*
* @example
* ```ts
* import type { AnyTRPCRouter } from '@trpc/server'
* import type { HTTPBaseHandlerOptions } from '@trpc/server/http'
* ```
*/
// @trpc/server
import type { AnyRouter } from '../../@trpc/server';
import type { ResolveHTTPRequestOptionsContextFn } from '../../@trpc/server/http';
import { resolveResponse } from '../../@trpc/server/http';
import type { FetchHandlerRequestOptions } from './types';
const trimSlashes = (path: string): string => {
path = path.startsWith('/') ? path.slice(1) : path;
path = path.endsWith('/') ? path.slice(0, -1) : path;
return path;
};
export async function fetchRequestHandler<TRouter extends AnyRouter>(
opts: FetchHandlerRequestOptions<TRouter>,
): Promise<Response> {
const resHeaders = new Headers();
const createContext: ResolveHTTPRequestOptionsContextFn<TRouter> = async (
innerOpts,
) => {
return opts.createContext?.({ req: opts.req, resHeaders, ...innerOpts });
};
const url = new URL(opts.req.url);
const pathname = trimSlashes(url.pathname);
const endpoint = trimSlashes(opts.endpoint);
const path = trimSlashes(pathname.slice(endpoint.length));
return await resolveResponse({
...opts,
req: opts.req,
createContext,
path,
error: null,
onError(o) {
opts?.onError?.({ ...o, req: opts.req });
},
responseMeta(data) {
const meta = opts.responseMeta?.(data);
if (meta?.headers) {
if (meta.headers instanceof Headers) {
for (const [key, value] of meta.headers.entries()) {
resHeaders.append(key, value);
}
} else {
/**
* @deprecated, delete in v12
*/
for (const [key, value] of Object.entries(meta.headers)) {
if (Array.isArray(value)) {
for (const v of value) {
resHeaders.append(key, v);
}
} else if (typeof value === 'string') {
resHeaders.set(key, value);
}
}
}
}
return {
headers: resHeaders,
status: meta?.status,
};
},
});
}

View File

@@ -0,0 +1,2 @@
export * from './fetchRequestHandler';
export * from './types';

53
node_modules/@trpc/server/src/adapters/fetch/types.ts generated vendored Normal file
View File

@@ -0,0 +1,53 @@
/**
* If you're making an adapter for tRPC and looking at this file for reference, you should import types and functions from `@trpc/server` and `@trpc/server/http`
*
* @example
* ```ts
* import type { AnyTRPCRouter } from '@trpc/server'
* import type { HTTPBaseHandlerOptions } from '@trpc/server/http'
* ```
*/
// @trpc/server
import type {
AnyRouter,
CreateContextCallback,
inferRouterContext,
} from '../../@trpc/server';
// @trpc/server/http
import type {
HTTPBaseHandlerOptions,
TRPCRequestInfo,
} from '../../@trpc/server/http';
export type FetchCreateContextFnOptions = {
req: Request;
resHeaders: Headers;
info: TRPCRequestInfo;
};
export type FetchCreateContextFn<TRouter extends AnyRouter> = (
opts: FetchCreateContextFnOptions,
) => inferRouterContext<TRouter> | Promise<inferRouterContext<TRouter>>;
export type FetchCreateContextOption<TRouter extends AnyRouter> =
CreateContextCallback<
inferRouterContext<TRouter>,
FetchCreateContextFn<TRouter>
>;
export type FetchHandlerOptions<TRouter extends AnyRouter> =
FetchCreateContextOption<TRouter> &
HTTPBaseHandlerOptions<TRouter, Request> & {
req: Request;
endpoint: string;
};
export type FetchHandlerRequestOptions<TRouter extends AnyRouter> =
HTTPBaseHandlerOptions<TRouter, Request> &
CreateContextCallback<
inferRouterContext<TRouter>,
FetchCreateContextFn<TRouter>
> & {
req: Request;
endpoint: string;
};

View File

@@ -0,0 +1,7 @@
export { nextAppDirCaller as experimental_nextAppDirCaller } from './next-app-dir/nextAppDirCaller';
export { redirect as experimental_redirect } from './next-app-dir/redirect';
export { notFound as experimental_notFound } from './next-app-dir/notFound';
export {
/** @internal */
rethrowNextErrors,
} from './next-app-dir/rethrowNextErrors';

View File

@@ -0,0 +1,135 @@
import type { CreateContextCallback } from '../../@trpc/server';
import { getTRPCErrorFromUnknown, TRPCError } from '../../@trpc/server';
// eslint-disable-next-line no-restricted-imports
import { formDataToObject } from '../../unstable-core-do-not-import';
// FIXME: fix lint rule, this is ok
// eslint-disable-next-line no-restricted-imports
import type { ErrorHandlerOptions } from '../../unstable-core-do-not-import/procedure';
// FIXME: fix lint rule, this is ok
// eslint-disable-next-line no-restricted-imports
import type { CallerOverride } from '../../unstable-core-do-not-import/procedureBuilder';
// FIXME: fix lint rule, this is ok
// eslint-disable-next-line no-restricted-imports
import type {
MaybePromise,
Simplify,
} from '../../unstable-core-do-not-import/types';
import { TRPCRedirectError } from './redirect';
import { rethrowNextErrors } from './rethrowNextErrors';
/**
* Create a caller that works with Next.js React Server Components & Server Actions
*/
export function nextAppDirCaller<TContext, TMeta>(
config: Simplify<
{
/**
* Extract the path from the procedure metadata
*/
pathExtractor?: (opts: { meta: TMeta }) => string;
/**
* Transform form data to a `Record` before passing it to the procedure
* @default true
*/
normalizeFormData?: boolean;
/**
* Called when an error occurs in the handler
*/
onError?: (opts: ErrorHandlerOptions<TContext>) => void;
} & CreateContextCallback<TContext, () => MaybePromise<TContext>>
>,
): CallerOverride<TContext> {
const {
normalizeFormData = true,
// rethrowNextErrors = true
} = config;
const createContext = async (): Promise<TContext> => {
return config?.createContext?.() ?? ({} as TContext);
};
return async (opts) => {
const path =
config.pathExtractor?.({ meta: opts._def.meta as TMeta }) ?? '';
const ctx: TContext = await createContext().catch((cause) => {
const error = new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to create context',
cause,
});
throw error;
});
const handleError = (cause: unknown) => {
const error = getTRPCErrorFromUnknown(cause);
config.onError?.({
ctx,
error,
input: opts.args[0],
path,
type: opts._def.type,
});
rethrowNextErrors(error);
throw error;
};
switch (opts._def.type) {
case 'mutation': {
/**
* When you wrap an action with useFormState, it gets an extra argument as its first argument.
* The submitted form data is therefore its second argument instead of its first as it would usually be.
* The new first argument that gets added is the current state of the form.
* @see https://react.dev/reference/react-dom/hooks/useFormState#my-action-can-no-longer-read-the-submitted-form-data
*/
let input = opts.args.length === 1 ? opts.args[0] : opts.args[1];
if (normalizeFormData && input instanceof FormData) {
input = formDataToObject(input);
}
return await opts
.invoke({
type: opts._def.type,
ctx,
getRawInput: async () => input,
path,
input,
signal: undefined,
batchIndex: 0,
})
.then((data) => {
if (data instanceof TRPCRedirectError) throw data;
return data;
})
.catch(handleError);
}
case 'query': {
const input = opts.args[0];
return await opts
.invoke({
type: opts._def.type,
ctx,
getRawInput: async () => input,
path,
input,
signal: undefined,
batchIndex: 0,
})
.then((data) => {
if (data instanceof TRPCRedirectError) throw data;
return data;
})
.catch(handleError);
}
case 'subscription':
default: {
throw new TRPCError({
code: 'NOT_IMPLEMENTED',
message: `Not implemented for type ${opts._def.type}`,
});
}
}
};
}

View File

@@ -0,0 +1,12 @@
import type { notFound as __notFound } from 'next/navigation';
import { TRPCError } from '../../@trpc/server';
/**
* Like `next/navigation`'s `notFound()` but throws a `TRPCError` that later will be handled by Next.js
* @public
*/
export const notFound: typeof __notFound = () => {
throw new TRPCError({
code: 'NOT_FOUND',
});
};

View File

@@ -0,0 +1,30 @@
import type { RedirectType } from 'next/navigation';
import { TRPCError } from '../../@trpc/server';
/**
* @internal
*/
export class TRPCRedirectError extends TRPCError {
public readonly args;
constructor(url: URL | string, redirectType?: RedirectType) {
super({
// TODO(?): This should maybe a custom error code
code: 'UNPROCESSABLE_CONTENT',
message: `Redirect error to "${url}" that will be handled by Next.js`,
});
this.args = [url.toString(), redirectType] as const;
}
}
/**
* Like `next/navigation`'s `redirect()` but throws a `TRPCError` that later will be handled by Next.js
* This provides better typesafety than the `next/navigation`'s `redirect()` since the action continues
* to execute on the frontend even if Next's `redirect()` has a return type of `never`.
* @public
* @remark You should only use this if you're also using `nextAppDirCaller`.
*/
export const redirect = (url: URL | string, redirectType?: RedirectType) => {
// We rethrow this internally so the returntype on the client is undefined.
return new TRPCRedirectError(url, redirectType) as unknown as undefined;
};

View File

@@ -0,0 +1,68 @@
import * as nextNavigation from 'next/navigation';
import type { TRPCError } from '../../@trpc/server';
import { TRPCRedirectError } from './redirect';
/**
* @remarks The helpers from `next/dist/client/components/*` has been removed in Next.js 15.
* Inlining them here instead...
* @see https://github.com/vercel/next.js/blob/5ae286ffd664e5c76841ed64f6e2da85a0835922/packages/next/src/client/components/redirect.ts#L97-L123
*/
const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT';
function isRedirectError(error: unknown) {
if (
typeof error !== 'object' ||
error === null ||
!('digest' in error) ||
typeof error.digest !== 'string'
) {
return false;
}
const [errorCode, type, destination, status] = error.digest.split(';', 4);
const statusCode = Number(status);
return (
errorCode === REDIRECT_ERROR_CODE &&
(type === 'replace' || type === 'push') &&
typeof destination === 'string' &&
!isNaN(statusCode)
);
}
/**
* @remarks The helpers from `next/dist/client/components/*` has been removed in Next.js 15.
* Inlining them here instead...
* @see https://github.com/vercel/next.js/blob/5ae286ffd664e5c76841ed64f6e2da85a0835922/packages/next/src/client/components/not-found.ts#L33-L39
*/
const NOT_FOUND_ERROR_CODE = 'NEXT_NOT_FOUND';
function isNotFoundError(error: unknown) {
if (typeof error !== 'object' || error === null || !('digest' in error)) {
return false;
}
return error.digest === NOT_FOUND_ERROR_CODE;
}
/**
* Rethrow errors that should be handled by Next.js
*/
export const rethrowNextErrors = (error: TRPCError) => {
if (error.code === 'NOT_FOUND') {
nextNavigation.notFound();
}
if (error instanceof TRPCRedirectError) {
nextNavigation.redirect(...error.args);
}
const { cause } = error;
// Next.js 15 has `unstable_rethrow`. Use that if it exists.
if (
'unstable_rethrow' in nextNavigation &&
typeof nextNavigation.unstable_rethrow === 'function'
) {
nextNavigation.unstable_rethrow(cause);
}
// Before Next.js 15, we have to check and rethrow the error manually.
if (isRedirectError(cause) || isNotFoundError(cause)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
throw cause!;
}
};

69
node_modules/@trpc/server/src/adapters/next.ts generated vendored Normal file
View File

@@ -0,0 +1,69 @@
/**
* If you're making an adapter for tRPC and looking at this file for reference, you should import types and functions from `@trpc/server` and `@trpc/server/http`
*
* @example
* ```ts
* import type { AnyTRPCRouter } from '@trpc/server'
* import type { HTTPBaseHandlerOptions } from '@trpc/server/http'
* ```
*/
import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next';
// @trpc/server
import type { AnyRouter } from '../@trpc/server';
// @trpc/server
import { TRPCError } from '../@trpc/server';
// eslint-disable-next-line no-restricted-imports
import { run } from '../unstable-core-do-not-import';
import type {
NodeHTTPCreateContextFnOptions,
NodeHTTPHandlerOptions,
} from './node-http';
import { internal_exceptionHandler, nodeHTTPRequestHandler } from './node-http';
export type CreateNextContextOptions = NodeHTTPCreateContextFnOptions<
NextApiRequest,
NextApiResponse
>;
/**
* Preventing "TypeScript where it's tough not to get "The inferred type of 'xxxx' cannot be named without a reference to [...]"
*/
export type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next';
export function createNextApiHandler<TRouter extends AnyRouter>(
opts: NodeHTTPHandlerOptions<TRouter, NextApiRequest, NextApiResponse>,
): NextApiHandler {
return async (req, res) => {
let path = '';
await run(async () => {
path = run(() => {
if (typeof req.query['trpc'] === 'string') {
return req.query['trpc'];
}
if (Array.isArray(req.query['trpc'])) {
return req.query['trpc'].join('/');
}
throw new TRPCError({
message:
'Query "trpc" not found - is the file named `[trpc]`.ts or `[...trpc].ts`?',
code: 'INTERNAL_SERVER_ERROR',
});
});
await nodeHTTPRequestHandler({
...(opts as any),
req,
res,
path,
});
}).catch(
internal_exceptionHandler({
req,
res,
path,
...opts,
}),
);
};
}

View File

@@ -0,0 +1,167 @@
import type * as http from 'http';
import { IncomingMessage } from 'node:http';
import { TRPCError } from '../../@trpc/server';
import type { NodeHTTPRequest, NodeHTTPResponse } from './types';
function createBody(
req: NodeHTTPRequest,
opts: {
/**
* Max body size in bytes. If the body is larger than this, the request will be aborted
*/
maxBodySize: number | null;
},
): RequestInit['body'] {
// Some adapters will pre-parse the body and add it to the request object
if ('body' in req) {
if (req.body === undefined) {
// If body property exists but is undefined, return undefined
return undefined;
}
// If the body is already a string, return it directly
if (typeof req.body === 'string') {
return req.body;
}
// formData use
if (req.body instanceof IncomingMessage) {
return req.body as any;
}
// If body exists but isn't a string, stringify it as JSON
return JSON.stringify(req.body);
}
let size = 0;
let hasClosed = false;
return new ReadableStream({
start(controller) {
const onData = (chunk: Buffer) => {
size += chunk.length;
if (!opts.maxBodySize || size <= opts.maxBodySize) {
controller.enqueue(
new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength),
);
return;
}
controller.error(
new TRPCError({
code: 'PAYLOAD_TOO_LARGE',
}),
);
hasClosed = true;
req.off('data', onData);
req.off('end', onEnd);
};
const onEnd = () => {
if (hasClosed) {
return;
}
hasClosed = true;
req.off('data', onData);
req.off('end', onEnd);
controller.close();
};
req.on('data', onData);
req.on('end', onEnd);
},
cancel() {
req.destroy();
},
});
}
export function createURL(req: NodeHTTPRequest): URL {
try {
const protocol =
// http2
(req.headers[':scheme'] && req.headers[':scheme'] === 'https') ||
// http1
(req.socket && 'encrypted' in req.socket && req.socket.encrypted)
? 'https:'
: 'http:';
const host = req.headers.host ?? req.headers[':authority'] ?? 'localhost';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return new URL(req.url!, `${protocol}//${host}`);
} catch (cause) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid URL',
cause,
});
}
}
function createHeaders(incoming: http.IncomingHttpHeaders): Headers {
const headers = new Headers();
for (const key in incoming) {
const value = incoming[key];
if (typeof key === 'string' && key.startsWith(':')) {
// Skip HTTP/2 pseudo-headers
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
headers.append(key, item);
}
} else if (value != null) {
headers.append(key, value);
}
}
return headers;
}
/**
* Convert an [`IncomingMessage`](https://nodejs.org/api/http.html#class-httpincomingmessage) to a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
*/
export function incomingMessageToRequest(
req: NodeHTTPRequest,
res: NodeHTTPResponse,
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.off('close', onAbort);
req.off('aborted', onAbort);
// abort the request
ac.abort();
};
res.once('close', onAbort);
req.once('aborted', onAbort);
// Get host from either regular header or HTTP/2 pseudo-header
const url = createURL(req);
const init: RequestInit = {
headers: createHeaders(req.headers),
method: req.method,
signal: ac.signal,
};
if (req.method !== 'GET' && req.method !== 'HEAD') {
init.body = createBody(req, opts);
// init.duplex = 'half' must be set when body is a ReadableStream, and Node follows the spec.
// However, this property is not defined in the TypeScript types for RequestInit, so we have
// to cast it here in order to set it without a type error.
// See https://fetch.spec.whatwg.org/#dom-requestinit-duplex
// @ts-expect-error this is fine
init.duplex = 'half';
}
const request = new Request(url, init);
return request;
}

View File

@@ -0,0 +1,3 @@
export * from './nodeHTTPRequestHandler';
export * from './types';
export * from './incomingMessageToRequest';

View File

@@ -0,0 +1,121 @@
/**
* If you're making an adapter for tRPC and looking at this file for reference, you should import types and functions from `@trpc/server` and `@trpc/server/http`
*
* @example
* ```ts
* import type { AnyTRPCRouter } from '@trpc/server'
* import type { HTTPBaseHandlerOptions } from '@trpc/server/http'
* ```
*/
// @trpc/server
import {
getTRPCErrorFromUnknown,
transformTRPCResponse,
type AnyRouter,
} from '../../@trpc/server';
import type { ResolveHTTPRequestOptionsContextFn } from '../../@trpc/server/http';
import { resolveResponse } from '../../@trpc/server/http';
// eslint-disable-next-line no-restricted-imports
import { getErrorShape, run } from '../../unstable-core-do-not-import';
import { incomingMessageToRequest } from './incomingMessageToRequest';
import type {
NodeHTTPRequest,
NodeHTTPRequestHandlerOptions,
NodeHTTPResponse,
} from './types';
import { writeResponse } from './writeResponse';
/**
* @internal
*/
export function internal_exceptionHandler<
TRouter extends AnyRouter,
TRequest extends NodeHTTPRequest,
TResponse extends NodeHTTPResponse,
>(opts: NodeHTTPRequestHandlerOptions<TRouter, TRequest, TResponse>) {
return (cause: unknown) => {
const { res, req } = opts;
const error = getTRPCErrorFromUnknown(cause);
const shape = getErrorShape({
config: opts.router._def._config,
error,
type: 'unknown',
path: undefined,
input: undefined,
ctx: undefined,
});
opts.onError?.({
req,
error,
type: 'unknown',
path: undefined,
input: undefined,
ctx: undefined,
});
const transformed = transformTRPCResponse(opts.router._def._config, {
error: shape,
});
res.statusCode = shape.data.httpStatus;
res.end(JSON.stringify(transformed));
};
}
/**
* @remark the promise never rejects
*/
export async function nodeHTTPRequestHandler<
TRouter extends AnyRouter,
TRequest extends NodeHTTPRequest,
TResponse extends NodeHTTPResponse,
>(opts: NodeHTTPRequestHandlerOptions<TRouter, TRequest, TResponse>) {
return new Promise<void>((resolve) => {
const handleViaMiddleware =
opts.middleware ?? ((_req, _res, next) => next());
opts.res.once('finish', () => {
resolve();
});
return handleViaMiddleware(opts.req, opts.res, (err: unknown) => {
run(async () => {
const request = incomingMessageToRequest(opts.req, opts.res, {
maxBodySize: opts.maxBodySize ?? null,
});
// Build tRPC dependencies
const createContext: ResolveHTTPRequestOptionsContextFn<
TRouter
> = async (innerOpts) => {
return await opts.createContext?.({
...opts,
...innerOpts,
});
};
const response = await resolveResponse({
...opts,
req: request,
error: err ? getTRPCErrorFromUnknown(err) : null,
createContext,
onError(o) {
opts?.onError?.({
...o,
req: opts.req,
});
},
});
await writeResponse({
request,
response,
rawResponse: opts.res,
});
}).catch(internal_exceptionHandler(opts));
});
});
}

View File

@@ -0,0 +1,132 @@
/**
* If you're making an adapter for tRPC and looking at this file for reference, you should import types and functions from `@trpc/server` and `@trpc/server/http`
*
* @example
* ```ts
* import type { AnyTRPCRouter } from '@trpc/server'
* import type { HTTPBaseHandlerOptions } from '@trpc/server/http'
* ```
*/
import type * as http from 'http';
import type * as http2 from 'http2';
// @trpc/server
import type {
AnyRouter,
CreateContextCallback,
inferRouterContext,
} from '../../@trpc/server';
// @trpc/server/http
import type {
HTTPBaseHandlerOptions,
TRPCRequestInfo,
} from '../../@trpc/server/http';
// eslint-disable-next-line no-restricted-imports
import type {
DistributiveOmit,
MaybePromise,
} from '../../unstable-core-do-not-import';
export type NodeHTTPRequest = DistributiveOmit<
http.IncomingMessage | http2.Http2ServerRequest,
'socket'
> & {
/**
* Many adapters will add a `body` property to the incoming message and pre-parse the body
*/
body?: unknown;
/**
* Socket is not always available in all deployments, so we need to make it optional
* @see https://github.com/trpc/trpc/issues/6341
* The socket object provided in the request does not fully implement the expected Node.js Socket interface.
* @see https://github.com/trpc/trpc/pull/6358
*/
socket?:
| Partial<http.IncomingMessage['socket']>
| Partial<http2.Http2ServerRequest['socket']>;
};
export type NodeHTTPResponse = DistributiveOmit<
http.ServerResponse | http2.Http2ServerResponse,
'write'
> & {
/**
* Force the partially-compressed response to be flushed to the client.
*
* Added by compression middleware
* (depending on the environment,
* e.g. Next 12 and below,
* e.g. Express w/ `compression()`)
*/
flush?: () => void;
write: (chunk: string | Uint8Array) => boolean;
};
export type NodeHTTPCreateContextOption<
TRouter extends AnyRouter,
TRequest,
TResponse,
> = CreateContextCallback<
inferRouterContext<TRouter>,
NodeHTTPCreateContextFn<TRouter, TRequest, TResponse>
>;
/**
* @internal
*/
type ConnectMiddleware<
TRequest extends NodeHTTPRequest = NodeHTTPRequest,
TResponse extends NodeHTTPResponse = NodeHTTPResponse,
> = (req: TRequest, res: TResponse, next: (err?: any) => any) => void;
export type NodeHTTPHandlerOptions<
TRouter extends AnyRouter,
TRequest extends NodeHTTPRequest,
TResponse extends NodeHTTPResponse,
> = HTTPBaseHandlerOptions<TRouter, TRequest> &
NodeHTTPCreateContextOption<TRouter, TRequest, TResponse> & {
/**
* By default, http `OPTIONS` requests are not handled, and CORS headers are not returned.
*
* This can be used to handle them manually or via the `cors` npm package: https://www.npmjs.com/package/cors
*
* ```ts
* import cors from 'cors'
*
* nodeHTTPRequestHandler({
* middleware: cors()
* })
* ```
*
* You can also use it for other needs which a connect/node.js compatible middleware can solve,
* though you might wish to consider an alternative solution like the Express adapter if your needs are complex.
*/
middleware?: ConnectMiddleware<TRequest, TResponse>;
maxBodySize?: number;
};
export type NodeHTTPRequestHandlerOptions<
TRouter extends AnyRouter,
TRequest extends NodeHTTPRequest,
TResponse extends NodeHTTPResponse,
> = NodeHTTPHandlerOptions<TRouter, TRequest, TResponse> & {
req: TRequest;
res: TResponse;
/**
* The tRPC path to handle requests for
* @example 'post.all'
*/
path: string;
};
export type NodeHTTPCreateContextFnOptions<TRequest, TResponse> = {
req: TRequest;
res: TResponse;
info: TRPCRequestInfo;
};
export type NodeHTTPCreateContextFn<
TRouter extends AnyRouter,
TRequest,
TResponse,
> = (
opts: NodeHTTPCreateContextFnOptions<TRequest, TResponse>,
) => MaybePromise<inferRouterContext<TRouter>>;

View File

@@ -0,0 +1,99 @@
// eslint-disable-next-line no-restricted-imports
import { isAbortError } from '../../unstable-core-do-not-import';
import type { NodeHTTPResponse } from './types';
async function writeResponseBodyChunk(
res: NodeHTTPResponse,
chunk: Uint8Array,
) {
// useful for debugging 🙃
// console.debug('writing', new TextDecoder().decode(chunk));
if (res.write(chunk) === false) {
await new Promise<void>((resolve, reject) => {
const onError = (err: unknown) => {
reject(err);
cleanup();
};
const onDrain = () => {
resolve();
cleanup();
};
const cleanup = () => {
res.off('error', onError);
res.off('drain', onDrain);
};
res.once('error', onError);
res.once('drain', onDrain);
});
}
}
/**
* @internal
*/
export async function writeResponseBody(opts: {
res: NodeHTTPResponse;
signal: AbortSignal;
body: NonNullable<Response['body']>;
}) {
const { res } = opts;
try {
const writableStream = new WritableStream({
async write(chunk) {
await writeResponseBodyChunk(res, chunk);
res.flush?.();
},
});
await opts.body.pipeTo(writableStream, {
signal: opts.signal,
});
} catch (err) {
if (isAbortError(err)) {
return;
}
throw err;
}
}
/**
* @internal
*/
export async function writeResponse(opts: {
request: Request;
response: Response;
rawResponse: NodeHTTPResponse;
}) {
const { response, rawResponse } = opts;
// Only override status code if it hasn't been explicitly set in a procedure etc
if (rawResponse.statusCode === 200) {
rawResponse.statusCode = response.status;
}
for (const [key, value] of response.headers) {
if (key.toLowerCase() === 'set-cookie') continue;
rawResponse.setHeader(key, value);
}
const cookies = response.headers.getSetCookie();
if (cookies.length > 0) {
rawResponse.setHeader('set-cookie', cookies);
}
try {
if (response.body) {
await writeResponseBody({
res: rawResponse,
signal: opts.request.signal,
body: response.body,
});
}
} catch (err) {
if (!rawResponse.headersSent) {
rawResponse.statusCode = 500;
}
throw err;
} finally {
rawResponse.end();
}
}

121
node_modules/@trpc/server/src/adapters/standalone.ts generated vendored Normal file
View File

@@ -0,0 +1,121 @@
/**
* If you're making an adapter for tRPC and looking at this file for reference, you should import types and functions from `@trpc/server` and `@trpc/server/http`
*
* @example
* ```ts
* import type { AnyTRPCRouter } from '@trpc/server'
* import type { HTTPBaseHandlerOptions } from '@trpc/server/http'
* ```
*/
import http from 'http';
// --- http2 ---
import type * as http2 from 'http2';
// @trpc/server
import { type AnyRouter } from '../@trpc/server';
// eslint-disable-next-line no-restricted-imports
import { run } from '../unstable-core-do-not-import';
import type {
NodeHTTPCreateContextFnOptions,
NodeHTTPHandlerOptions,
NodeHTTPRequest,
NodeHTTPResponse,
} from './node-http';
import {
createURL,
internal_exceptionHandler,
nodeHTTPRequestHandler,
} from './node-http';
type StandaloneHandlerOptions<
TRouter extends AnyRouter,
TRequest extends NodeHTTPRequest,
TResponse extends NodeHTTPResponse,
> = NodeHTTPHandlerOptions<TRouter, TRequest, TResponse> & {
/**
* The base path to handle requests for.
* This will be sliced from the beginning of the request path
* (Do not miss including the trailing slash)
* @default '/'
* @example '/trpc/'
* @example '/trpc/api/'
*/
basePath?: string;
};
// --- http1 ---
export type CreateHTTPHandlerOptions<TRouter extends AnyRouter> =
StandaloneHandlerOptions<TRouter, http.IncomingMessage, http.ServerResponse>;
export type CreateHTTPContextOptions = NodeHTTPCreateContextFnOptions<
http.IncomingMessage,
http.ServerResponse
>;
function createHandler<
TRouter extends AnyRouter,
TRequest extends NodeHTTPRequest,
TResponse extends NodeHTTPResponse,
>(
opts: StandaloneHandlerOptions<TRouter, TRequest, TResponse>,
): (req: TRequest, res: TResponse) => void {
const basePath = opts.basePath ?? '/';
const sliceLength = basePath.length;
return (req, res) => {
let path = '';
run(async () => {
const url = createURL(req);
// get procedure(s) path and remove the leading slash
path = url.pathname.slice(sliceLength);
await nodeHTTPRequestHandler({
...(opts as any),
req,
res,
path,
});
}).catch(
internal_exceptionHandler({
req,
res,
path,
...opts,
}),
);
};
}
/**
* @internal
*/
export function createHTTPHandler<TRouter extends AnyRouter>(
opts: CreateHTTPHandlerOptions<TRouter>,
): http.RequestListener {
return createHandler(opts);
}
export function createHTTPServer<TRouter extends AnyRouter>(
opts: CreateHTTPHandlerOptions<TRouter>,
) {
return http.createServer(createHTTPHandler(opts));
}
// --- http2 ---
export type CreateHTTP2HandlerOptions<TRouter extends AnyRouter> =
StandaloneHandlerOptions<
TRouter,
http2.Http2ServerRequest,
http2.Http2ServerResponse
>;
export type CreateHTTP2ContextOptions = NodeHTTPCreateContextFnOptions<
http2.Http2ServerRequest,
http2.Http2ServerResponse
>;
export function createHTTP2Handler(opts: CreateHTTP2HandlerOptions<AnyRouter>) {
return createHandler(opts);
}

644
node_modules/@trpc/server/src/adapters/ws.ts generated vendored Normal file
View File

@@ -0,0 +1,644 @@
import type { IncomingMessage } from 'http';
import type ws from 'ws';
import type {
AnyRouter,
CreateContextCallback,
inferRouterContext,
} from '../@trpc/server';
import {
callTRPCProcedure,
getErrorShape,
getTRPCErrorFromUnknown,
transformTRPCResponse,
TRPCError,
} from '../@trpc/server';
import type { TRPCRequestInfo } from '../@trpc/server/http';
import { type BaseHandlerOptions } from '../@trpc/server/http';
import { parseTRPCMessage } from '../@trpc/server/rpc';
// @trpc/server/rpc
import type {
TRPCClientOutgoingMessage,
TRPCConnectionParamsMessage,
TRPCReconnectNotification,
TRPCResponseMessage,
TRPCResultMessage,
} from '../@trpc/server/rpc';
import { parseConnectionParamsFromUnknown } from '../http';
import { isObservable, observableToAsyncIterable } from '../observable';
// eslint-disable-next-line no-restricted-imports
import {
isAsyncIterable,
isObject,
isTrackedEnvelope,
run,
type MaybePromise,
} from '../unstable-core-do-not-import';
// eslint-disable-next-line no-restricted-imports
import type { Result } from '../unstable-core-do-not-import';
// eslint-disable-next-line no-restricted-imports
import { iteratorResource } from '../unstable-core-do-not-import/stream/utils/asyncIterable';
import { Unpromise } from '../vendor/unpromise';
import { createURL, type NodeHTTPCreateContextFnOptions } from './node-http';
import type { Encoder } from './wsEncoder';
import { jsonEncoder } from './wsEncoder';
/**
* Importing ws causes a build error
* @see https://github.com/trpc/trpc/pull/5279
*/
const WEBSOCKET_OPEN = 1; /* ws.WebSocket.OPEN */
/**
* @public
*/
export type CreateWSSContextFnOptions = NodeHTTPCreateContextFnOptions<
IncomingMessage,
ws.WebSocket
>;
/**
* @public
*/
export type CreateWSSContextFn<TRouter extends AnyRouter> = (
opts: CreateWSSContextFnOptions,
) => MaybePromise<inferRouterContext<TRouter>>;
export type WSConnectionHandlerOptions<TRouter extends AnyRouter> =
BaseHandlerOptions<TRouter, IncomingMessage> &
CreateContextCallback<
inferRouterContext<TRouter>,
CreateWSSContextFn<TRouter>
>;
/**
* Web socket server handler
*/
export type WSSHandlerOptions<TRouter extends AnyRouter> =
WSConnectionHandlerOptions<TRouter> & {
wss: ws.WebSocketServer;
prefix?: string;
keepAlive?: {
/**
* Enable heartbeat messages
* @default false
*/
enabled: boolean;
/**
* Heartbeat interval in milliseconds
* @default 30_000
*/
pingMs?: number;
/**
* Terminate the WebSocket if no pong is received after this many milliseconds
* @default 5_000
*/
pongWaitMs?: number;
};
/**
* Disable responding to ping messages from the client
* **Not recommended** - this is mainly used for testing
* @default false
*/
dangerouslyDisablePong?: boolean;
/**
* Custom encoder for wire encoding (e.g. custom binary formats)
* @default jsonEncoder
*/
experimental_encoder?: Encoder;
};
export function getWSConnectionHandler<TRouter extends AnyRouter>(
opts: WSSHandlerOptions<TRouter>,
) {
const { createContext, router } = opts;
const { transformer } = router._def._config;
const encoder = opts.experimental_encoder ?? jsonEncoder;
return (client: ws.WebSocket, req: IncomingMessage) => {
type Context = inferRouterContext<TRouter>;
type ContextResult = Result<Context>;
const clientSubscriptions = new Map<number | string, AbortController>();
const abortController = new AbortController();
if (opts.keepAlive?.enabled) {
const { pingMs, pongWaitMs } = opts.keepAlive;
handleKeepAlive(client, pingMs, pongWaitMs);
}
function respond(untransformedJSON: TRPCResponseMessage) {
client.send(
encoder.encode(
transformTRPCResponse(router._def._config, untransformedJSON),
),
);
}
async function createCtxPromise(
getConnectionParams: () => TRPCRequestInfo['connectionParams'],
): Promise<ContextResult> {
try {
return await run(async (): Promise<ContextResult> => {
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: Context | undefined = 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: TRPCClientOutgoingMessage, batchIndex: number) {
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 ??= {
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 callTRPCProcedure({
router,
path,
getRawInput: async () => input,
ctx,
type,
signal: abortController.signal,
batchIndex,
});
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 () => {
await using iterator = iteratorResource(iterable);
const abortPromise = new Promise<'abort'>((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:
| null
| TRPCError
| Awaited<
typeof abortPromise | ReturnType<(typeof iterator)['next']>
>;
let result: null | TRPCResultMessage<unknown>['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((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, isBinary) => {
// Handle PING/PONG as text regardless of encoder
if (!isBinary) {
// 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;
}
}
// Convert rawData to a format our encoder accepts
// ws gives us Buffer for both text and binary frames
if (!Buffer.isBuffer(rawData)) {
const error = new TRPCError({
code: 'UNPROCESSABLE_CONTENT',
message: 'Unexpected WebSocket message format',
});
respond({
id: null,
error: getErrorShape({
config: router._def._config,
error,
type: 'unknown',
path: undefined,
input: undefined,
ctx,
}),
});
return;
}
const data: string | Uint8Array = isBinary
? rawData
: rawData.toString('utf8');
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 = encoder.decode(data) as TRPCConnectionParamsMessage;
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: unknown = encoder.decode(data);
const msgs: unknown[] = 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((msg, index) => handleRequest(msg, index));
});
// 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
*/
export function handleKeepAlive(
client: ws.WebSocket,
pingMs = 30_000,
pongWaitMs = 5_000,
) {
let timeout: NodeJS.Timeout | undefined = undefined;
let ping: NodeJS.Timeout | undefined = undefined;
const schedulePing = () => {
const scheduleTimeout = () => {
timeout = setTimeout(() => {
client.terminate();
}, pongWaitMs) as any;
};
ping = setTimeout(() => {
client.send('PING');
scheduleTimeout();
}, pingMs) as any;
};
const onMessage = () => {
clearTimeout(ping);
clearTimeout(timeout);
schedulePing();
};
client.on('message', onMessage);
client.on('close', () => {
clearTimeout(ping);
clearTimeout(timeout);
});
schedulePing();
}
export function applyWSSHandler<TRouter extends AnyRouter>(
opts: WSSHandlerOptions<TRouter>,
) {
const encoder = opts.experimental_encoder ?? jsonEncoder;
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: TRPCReconnectNotification = {
id: null,
method: 'reconnect',
};
const data = encoder.encode(response);
for (const client of opts.wss.clients) {
if (client.readyState === WEBSOCKET_OPEN) {
client.send(data);
}
}
},
};
}
export type { Encoder } from './wsEncoder';
export { jsonEncoder } from './wsEncoder';

35
node_modules/@trpc/server/src/adapters/wsEncoder.ts generated vendored Normal file
View File

@@ -0,0 +1,35 @@
/**
* Encoder for WebSocket wire format.
* Encodes outgoing messages and decodes incoming messages.
*
* @example
* ```ts
* const customEncoder: Encoder = {
* encode: (data) => myFormat.stringify(data),
* decode: (data) => myFormat.parse(data),
* };
* ```
*/
export interface Encoder {
/** Encode data for transmission over the wire */
encode(data: unknown): string | Uint8Array;
/** Decode data received from the wire */
decode(data: string | ArrayBuffer | Uint8Array): unknown;
}
/**
* Default JSON encoder - used when no encoder is specified.
* This maintains backwards compatibility with existing behavior.
*/
export const jsonEncoder: Encoder = {
encode: (data) => JSON.stringify(data),
decode: (data) => {
if (typeof data !== 'string') {
throw new Error(
'jsonEncoder received binary data. JSON uses text frames. ' +
'Use a binary encoder for binary data.',
);
}
return JSON.parse(data);
},
};