UNPKG

solid-start-trpc

Version:

```ts import { createSolidAPIHandler } from "solid-start-trpc";

459 lines (448 loc) 13.5 kB
function getMessageFromUnknownError(err, fallback) { if (typeof err === 'string') { return err; } if (err instanceof Error && typeof err.message === 'string') { return err.message; } return fallback; } function getErrorFromUnknown(cause) { if (cause instanceof Error) { return cause; } const message = getMessageFromUnknownError(cause, 'Unknown error'); return new Error(message); } function getTRPCErrorFromUnknown(cause) { const error = getErrorFromUnknown(cause); // this should ideally be an `instanceof TRPCError` but for some reason that isn't working // ref https://github.com/trpc/trpc/issues/331 if (error.name === 'TRPCError') { return cause; } const trpcError = new TRPCError({ code: 'INTERNAL_SERVER_ERROR', cause: error, message: error.message }); // Inherit stack from error trpcError.stack = error.stack; return trpcError; } function getCauseFromUnknown(cause) { if (cause instanceof Error) { return cause; } return undefined; } class TRPCError extends Error { constructor(opts){ const code = opts.code; const message = opts.message ?? getMessageFromUnknownError(opts.cause, code); const cause = opts !== undefined ? getErrorFromUnknown(opts.cause) : undefined; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore https://github.com/tc39/proposal-error-cause super(message, { cause }); this.code = code; this.cause = cause; this.name = 'TRPCError'; Object.setPrototypeOf(this, new.target.prototype); } } function invert(obj) { const newObj = Object.create(null); for(const key in obj){ const v = obj[key]; newObj[v] = key; } return newObj; } // reference: https://www.jsonrpc.org/specification /** * JSON-RPC 2.0 Error codes * * `-32000` to `-32099` are reserved for implementation-defined server-errors. * For tRPC we're copying the last digits of HTTP 4XX errors. */ const TRPC_ERROR_CODES_BY_KEY = { /** * Invalid JSON was received by the server. * An error occurred on the server while parsing the JSON text. */ PARSE_ERROR: -32700, /** * The JSON sent is not a valid Request object. */ BAD_REQUEST: -32600, /** * Internal JSON-RPC error. */ INTERNAL_SERVER_ERROR: -32603, // Implementation specific errors UNAUTHORIZED: -32001, FORBIDDEN: -32003, NOT_FOUND: -32004, METHOD_NOT_SUPPORTED: -32005, TIMEOUT: -32008, CONFLICT: -32009, PRECONDITION_FAILED: -32012, PAYLOAD_TOO_LARGE: -32013, TOO_MANY_REQUESTS: -32029, CLIENT_CLOSED_REQUEST: -32099 }; invert(TRPC_ERROR_CODES_BY_KEY); const TRPC_ERROR_CODES_BY_NUMBER = invert(TRPC_ERROR_CODES_BY_KEY); const JSONRPC2_TO_HTTP_CODE = { PARSE_ERROR: 400, BAD_REQUEST: 400, NOT_FOUND: 404, INTERNAL_SERVER_ERROR: 500, UNAUTHORIZED: 401, FORBIDDEN: 403, TIMEOUT: 408, CONFLICT: 409, CLIENT_CLOSED_REQUEST: 499, PRECONDITION_FAILED: 412, PAYLOAD_TOO_LARGE: 413, METHOD_NOT_SUPPORTED: 405, TOO_MANY_REQUESTS: 429 }; function getStatusCodeFromKey(code) { return JSONRPC2_TO_HTTP_CODE[code] ?? 500; } function getHTTPStatusCode(json) { const arr = Array.isArray(json) ? json : [ json ]; const httpStatuses = new Set(arr.map((res)=>{ if ('error' in res) { const data = res.error.data; if (typeof data.httpStatus === 'number') { return data.httpStatus; } const code = TRPC_ERROR_CODES_BY_NUMBER[res.error.code]; return getStatusCodeFromKey(code); } return 200; })); if (httpStatuses.size !== 1) { return 207; } const httpStatus = httpStatuses.values().next().value; return httpStatus; } /** * @internal */ function callProcedure(opts) { const { type , path } = opts; if (!(path in opts.procedures) || !opts.procedures[path]?._def[type]) { throw new TRPCError({ code: 'NOT_FOUND', message: `No "${type}"-procedure on path "${path}"` }); } const procedure = opts.procedures[path]; return procedure(opts); } /** * The default check to see if we're in a server */ typeof window === 'undefined' || 'Deno' in window || globalThis.process?.env?.NODE_ENV === 'test' || !!globalThis.process?.env?.JEST_WORKER_ID; function getPath(args) { const p = args.params.trpc; if (typeof p === "string") { return p; } if (Array.isArray(p)) { return p.join("/"); } return null; } function notFoundError(opts) { const error = opts.router.getErrorShape({ error: new TRPCError({ message: 'Query "trpc" not found - is the file named `[trpc]`.ts or `[...trpc].ts`?', code: "INTERNAL_SERVER_ERROR" }), type: "unknown", ctx: undefined, path: undefined, input: undefined }); const json = { id: -1, error }; return new Response(JSON.stringify(json), { status: 500 }); } function transformTRPCResponseItem(router, item) { if ('error' in item) { return { ...item, error: router._def._config.transformer.output.serialize(item.error) }; } if ('data' in item.result) { return { ...item, result: { ...item.result, data: router._def._config.transformer.output.serialize(item.result.data) } }; } return item; } /** * Takes a unserialized `TRPCResponse` and serializes it with the router's transformers **/ function transformTRPCResponse(router, itemOrItems) { return Array.isArray(itemOrItems) ? itemOrItems.map((item)=>transformTRPCResponseItem(router, item)) : transformTRPCResponseItem(router, itemOrItems); } const HTTP_METHOD_PROCEDURE_TYPE_MAP = { GET: 'query', POST: 'mutation' }; function getRawProcedureInputOrThrow(req) { try { if (req.method === 'GET') { if (!req.query.has('input')) { return undefined; } const raw = req.query.get('input'); return JSON.parse(raw); } if (typeof req.body === 'string') { // A mutation with no inputs will have req.body === '' return req.body.length === 0 ? undefined : JSON.parse(req.body); } return req.body; } catch (err) { throw new TRPCError({ code: 'PARSE_ERROR', cause: getCauseFromUnknown(err) }); } } async function resolveHTTPResponse(opts) { const { createContext , onError , router , req } = opts; const batchingEnabled = opts.batching?.enabled ?? true; if (req.method === 'HEAD') { // can be used for lambda warmup return { status: 204 }; } const type = HTTP_METHOD_PROCEDURE_TYPE_MAP[req.method] ?? 'unknown'; let ctx = undefined; let paths = undefined; const isBatchCall = !!req.query.get('batch'); function endResponse(untransformedJSON, errors) { let status = getHTTPStatusCode(untransformedJSON); const headers = { 'Content-Type': 'application/json' }; const meta = opts.responseMeta?.({ ctx, paths, type, data: Array.isArray(untransformedJSON) ? untransformedJSON : [ untransformedJSON ], errors }) ?? {}; for (const [key, value] of Object.entries(meta.headers ?? {})){ headers[key] = value; } if (meta.status) { status = meta.status; } const transformedJSON = transformTRPCResponse(router, untransformedJSON); const body = JSON.stringify(transformedJSON); return { body, status, headers }; } try { if (opts.error) { throw opts.error; } if (isBatchCall && !batchingEnabled) { throw new Error(`Batching is not enabled on the server`); } /* istanbul ignore if */ if (type === 'subscription') { throw new TRPCError({ message: 'Subscriptions should use wsLink', code: 'METHOD_NOT_SUPPORTED' }); } if (type === 'unknown') { throw new TRPCError({ message: `Unexpected request method ${req.method}`, code: 'METHOD_NOT_SUPPORTED' }); } const rawInput = getRawProcedureInputOrThrow(req); paths = isBatchCall ? opts.path.split(',') : [ opts.path ]; ctx = await createContext(); const deserializeInputValue = (rawValue)=>{ return typeof rawValue !== 'undefined' ? router._def._config.transformer.input.deserialize(rawValue) : rawValue; }; const getInputs = ()=>{ if (!isBatchCall) { return { 0: deserializeInputValue(rawInput) }; } /* istanbul ignore if */ if (rawInput == null || typeof rawInput !== 'object' || Array.isArray(rawInput)) { throw new TRPCError({ code: 'BAD_REQUEST', message: '"input" needs to be an object when doing a batch call' }); } const input = {}; for(const key in rawInput){ const k = key; const rawValue = rawInput[k]; const value = deserializeInputValue(rawValue); input[k] = value; } return input; }; const inputs = getInputs(); const rawResults = await Promise.all(paths.map(async (path, index)=>{ const input = inputs[index]; try { const output = await callProcedure({ procedures: router._def.procedures, path, rawInput: input, ctx, type }); return { input, path, data: output }; } catch (cause) { const error = getTRPCErrorFromUnknown(cause); onError?.({ error, path, input, ctx, type: type, req }); return { input, path, error }; } })); const errors = rawResults.flatMap((obj)=>obj.error ? [ obj.error ] : []); const resultEnvelopes = rawResults.map((obj)=>{ const { path , input } = obj; if (obj.error) { return { error: router.getErrorShape({ error: obj.error, type, path, input, ctx }) }; } else { return { result: { data: obj.data } }; } }); const result = isBatchCall ? resultEnvelopes : resultEnvelopes[0]; return endResponse(result, errors); } catch (cause) { // we get here if // - batching is called when it's not enabled // - `createContext()` throws // - post body is too large // - input deserialization fails const error = getTRPCErrorFromUnknown(cause); onError?.({ error, path: undefined, input: undefined, ctx, type: type, req }); return endResponse({ error: router.getErrorShape({ error, type, path: undefined, input: undefined, ctx }) }, [ error ]); } } function createSolidAPIHandler(opts) { return async args => { const path = getPath(args); if (path === null) { return notFoundError(opts); } const res = { headers: {} }; const url = new URL(args.request.url); const req = { query: url.searchParams, method: args.request.method, headers: Object.fromEntries(args.request.headers), body: await args.request.text() }; const result = await resolveHTTPResponse({ router: opts.router, responseMeta: opts.responseMeta, req, path, createContext: async () => await opts.createContext?.({ req: args.request, res }) }); const mRes = new Response(result.body, { status: result.status }); for (const [key, value] of Object.entries(result.headers ? { ...res.headers, ...result.headers } : res.headers)) { if (typeof value === "undefined") { continue; } if (typeof value === "string") { mRes.headers.set(key, value); continue; } for (const v of value) { mRes.headers.append(key, v); } } return mRes; }; } export { createSolidAPIHandler };