feat: add envelopes api (#2105)

This commit is contained in:
David Nguyen
2025-11-07 14:17:52 +11:00
committed by GitHub
parent d2a009d52e
commit d05bfa9fed
230 changed files with 10066 additions and 2812 deletions

View File

@ -0,0 +1,17 @@
import type { DataTransformer } from '@trpc/server';
import SuperJSON from 'superjson';
export const dataTransformer: DataTransformer = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serialize: (data: any) => {
if (data instanceof FormData) {
return data;
}
return SuperJSON.serialize(data);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deserialize: (data: any) => {
return SuperJSON.deserialize(data);
},
};

View File

@ -0,0 +1,202 @@
import { TRPCError } from '@trpc/server';
import type { FetchHandlerOptions } from '@trpc/server/adapters/fetch';
import type { ServerResponse } from 'node:http';
import { type OpenApiRouter, createOpenApiNodeHttpHandler } from 'trpc-to-openapi';
const CONTENT_TYPE_JSON = 'application/json';
const CONTENT_TYPE_URLENCODED = 'application/x-www-form-urlencoded';
const CONTENT_TYPE_MULTIPART = 'multipart/form-data';
const getUrlEncodedBody = async (req: Request) => {
const params = new URLSearchParams(await req.text());
const data: Record<string, unknown> = {};
for (const key of params.keys()) {
data[key] = params.getAll(key);
}
return data;
};
const getMultipartBody = async (req: Request) => {
const formData = await req.formData();
const data: Record<string, unknown> = {};
for (const key of formData.keys()) {
const values = formData.getAll(key);
// Return array for multiple values, single value otherwise (matches URL-encoded behavior)
data[key] = values.length > 1 ? values : values[0];
}
return data;
};
/**
* Parses the request body based on its content type.
*
* Handles JSON, URL-encoded, and multipart/form-data requests.
* For multipart requests, converts FormData to a plain object (similar to URL-encoded)
* so it can be validated by tRPC schemas. The content-type header is rewritten
* later to prevent downstream parsing issues.
*/
const getRequestBody = async (req: Request) => {
try {
const contentType = req.headers.get('content-type') || '';
if (contentType.includes(CONTENT_TYPE_JSON)) {
return {
isValid: true,
// Use JSON.parse instead of req.json() because req.json() does not throw on invalid JSON
data: JSON.parse(await req.text()),
};
}
if (contentType.includes(CONTENT_TYPE_URLENCODED)) {
return {
isValid: true,
data: await getUrlEncodedBody(req),
};
}
// Handle multipart/form-data by parsing as FormData and converting to a plain object.
// This mirrors how URL-encoded data is structured, allowing tRPC to validate it normally.
// The content-type header is rewritten to application/json later via the request proxy
// because createOpenApiNodeHttpHandler aborts on any bodied request that isn't application/json.
if (contentType.includes(CONTENT_TYPE_MULTIPART)) {
return {
isValid: true,
data: await getMultipartBody(req),
};
}
return {
isValid: true,
data: req.body,
};
} catch (err) {
return {
isValid: false,
cause: err,
};
}
};
/**
* Creates a proxy around the original Request that intercepts property access
* to transform the request for compatibility with the Node HTTP handler.
*
* Key transformations:
* - Parses and provides the body as a plain object (handles multipart/form-data conversion)
* - Rewrites content-type header for multipart requests to application/json
* (required because createOpenApiNodeHttpHandler aborts on non-JSON bodied requests)
*/
const createRequestProxy = async (req: Request, url?: string) => {
const body = await getRequestBody(req);
const originalContentType = req.headers.get('content-type') || '';
const isMultipart = originalContentType.includes(CONTENT_TYPE_MULTIPART);
return new Proxy(req, {
get: (target, prop) => {
switch (prop) {
case 'url':
return url ?? target.url;
case 'body': {
if (!body.isValid) {
throw new TRPCError({
code: 'PARSE_ERROR',
message: 'Failed to parse request body',
cause: body.cause,
});
}
return body.data;
}
case 'headers': {
const headers = new Headers(target.headers);
// Rewrite content-type header for multipart requests to application/json.
// This is necessary because `createOpenApiNodeHttpHandler` aborts on any bodied
// request that isn't application/json. Since we've already parsed the multipart
// data into a plain object above, this is safe to do.
if (isMultipart) {
headers.set('content-type', CONTENT_TYPE_JSON);
}
return headers;
}
default:
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
return (target as unknown as Record<string | number | symbol, unknown>)[prop];
}
},
});
};
export type CreateOpenApiFetchHandlerOptions<TRouter extends OpenApiRouter> = Omit<
FetchHandlerOptions<TRouter>,
'batching'
> & {
req: Request;
endpoint: `/${string}`;
};
export const createOpenApiFetchHandler = async <TRouter extends OpenApiRouter>(
opts: CreateOpenApiFetchHandlerOptions<TRouter>,
): Promise<Response> => {
const resHeaders = new Headers();
const url = new URL(opts.req.url.replace(opts.endpoint, ''));
const req: Request = await createRequestProxy(opts.req, url.toString());
// @ts-expect-error Inherited from original fetch handler in `trpc-to-openapi`
const openApiHttpHandler = createOpenApiNodeHttpHandler(opts);
return new Promise<Response>((resolve) => {
let statusCode: number;
// Create a mock ServerResponse object that bridges Node HTTP APIs with Fetch API Response.
// This allows the Node HTTP handler to work with Fetch API Request objects.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const res = {
setHeader: (key: string, value: string | readonly string[]) => {
if (typeof value === 'string') {
resHeaders.set(key, value);
} else {
for (const v of value) {
resHeaders.append(key, v);
}
}
},
get statusCode() {
return statusCode;
},
set statusCode(code: number) {
statusCode = code;
},
end: (body: string) => {
resolve(
new Response(body, {
headers: resHeaders,
status: statusCode,
}),
);
},
} as ServerResponse;
// Type assertions are necessary here for interop between Fetch API Request/Response
// and Node HTTP IncomingMessage/ServerResponse types.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const nodeReq = req as unknown as Parameters<typeof openApiHttpHandler>[0];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const nodeRes = res as unknown as Parameters<typeof openApiHttpHandler>[1];
void openApiHttpHandler(nodeReq, nodeRes);
});
};

View File

@ -0,0 +1,32 @@
import type { ZodRawShape } from 'zod';
import z from 'zod';
/**
* This helper takes the place of the `z.object` at the root of your schema.
* It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData`
* and transforms it into a regular object.
* If the `FormData` contains multiple entries with the same field name,
* it will automatically turn that field into an array.
*
* This is used instead of `zfd.formData()` because it receives `undefined`
* somewhere in the pipeline of our openapi schema generation and throws
* an error. This provides the same functionality as `zfd.formData()` but
* can be considered somewhat safer.
*/
export const zodFormData = <T extends ZodRawShape>(schema: T) => {
return z.preprocess((data) => {
if (data instanceof FormData) {
const formData: Record<string, unknown> = {};
for (const key of data.keys()) {
const values = data.getAll(key);
formData[key] = values.length > 1 ? values : values[0];
}
return formData;
}
return data;
}, z.object(schema));
};