UNPKG

@orpc/server

Version:

<div align="center"> <image align="center" src="https://orpc.unnoq.com/logo.webp" width=280 alt="oRPC logo" /> </div>

286 lines (279 loc) • 10.6 kB
import { runWithSpan, value, setSpanError, isAsyncIteratorObject, clone } from '@orpc/shared'; import { flattenHeader } from '@orpc/standard-server'; import { parseBatchRequest, toBatchResponse } from '@orpc/standard-server/batch'; import { toFetchHeaders } from '@orpc/standard-server-fetch'; import { ORPCError } from '@orpc/client'; export { S as StrictGetMethodPlugin } from '../shared/server.BW-nUGgA.mjs'; import '@orpc/contract'; class BatchHandlerPlugin { maxSize; mapRequestItem; successStatus; headers; order = 5e6; constructor(options = {}) { this.maxSize = options.maxSize ?? 10; this.mapRequestItem = options.mapRequestItem ?? ((request, { request: batchRequest }) => ({ ...request, headers: { ...batchRequest.headers, ...request.headers } })); this.successStatus = options.successStatus ?? 207; this.headers = options.headers ?? {}; } init(options) { options.rootInterceptors ??= []; options.rootInterceptors.unshift(async (options2) => { const xHeader = flattenHeader(options2.request.headers["x-orpc-batch"]); if (xHeader === void 0) { return options2.next(); } let isParsing = false; try { return await runWithSpan({ name: "handle_batch_request" }, async (span) => { const mode = xHeader === "buffered" ? "buffered" : "streaming"; isParsing = true; const parsed = parseBatchRequest({ ...options2.request, body: await options2.request.body() }); isParsing = false; span?.setAttribute("batch.mode", mode); span?.setAttribute("batch.size", parsed.length); const maxSize = await value(this.maxSize, options2); if (parsed.length > maxSize) { const message = "Batch request size exceeds the maximum allowed size"; setSpanError(span, message); return { matched: true, response: { status: 413, headers: {}, body: message } }; } const responses = parsed.map( (request, index) => { const mapped = this.mapRequestItem(request, options2); return options2.next({ ...options2, request: { ...mapped, body: () => Promise.resolve(mapped.body) } }).then(({ response: response2, matched }) => { span?.addEvent(`response.${index}.${matched ? "success" : "not_matched"}`); if (matched) { if (response2.body instanceof Blob || response2.body instanceof FormData || isAsyncIteratorObject(response2.body)) { return { index, status: 500, headers: {}, body: "Batch responses do not support file/blob, or event-iterator. Please call this procedure separately outside of the batch request." }; } return { ...response2, index }; } return { index, status: 404, headers: {}, body: "No procedure matched" }; }).catch((err) => { Promise.reject(err); return { index, status: 500, headers: {}, body: "Internal server error" }; }); } ); await Promise.race(responses); const status = await value(this.successStatus, responses, options2); const headers = await value(this.headers, responses, options2); const response = await toBatchResponse({ status, headers, mode, body: async function* () { const promises = [...responses]; while (true) { const handling = promises.filter((p) => p !== void 0); if (handling.length === 0) { return; } const result = await Promise.race(handling); promises[result.index] = void 0; yield result; } }() }); return { matched: true, response }; }); } catch (cause) { if (isParsing) { return { matched: true, response: { status: 400, headers: {}, body: "Invalid batch request, this could be caused by a malformed request body or a missing header" } }; } throw cause; } }); } } class CORSPlugin { options; order = 9e6; constructor(options = {}) { const defaults = { origin: (origin) => origin, allowMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH"] }; this.options = { ...defaults, ...options }; } init(options) { options.rootInterceptors ??= []; options.rootInterceptors.unshift(async (interceptorOptions) => { if (interceptorOptions.request.method === "OPTIONS") { const resHeaders = {}; if (this.options.maxAge !== void 0) { resHeaders["access-control-max-age"] = this.options.maxAge.toString(); } if (this.options.allowMethods?.length) { resHeaders["access-control-allow-methods"] = flattenHeader(this.options.allowMethods); } const allowHeaders = this.options.allowHeaders ?? interceptorOptions.request.headers["access-control-request-headers"]; if (typeof allowHeaders === "string" || allowHeaders?.length) { resHeaders["access-control-allow-headers"] = flattenHeader(allowHeaders); } return { matched: true, response: { status: 204, headers: resHeaders, body: void 0 } }; } return interceptorOptions.next(); }); options.rootInterceptors.unshift(async (interceptorOptions) => { const result = await interceptorOptions.next(); if (!result.matched) { return result; } const origin = flattenHeader(interceptorOptions.request.headers.origin) ?? ""; const allowedOrigin = await value(this.options.origin, origin, interceptorOptions); const allowedOriginArr = Array.isArray(allowedOrigin) ? allowedOrigin : [allowedOrigin]; if (allowedOriginArr.includes("*")) { result.response.headers["access-control-allow-origin"] = "*"; } else { if (allowedOriginArr.includes(origin)) { result.response.headers["access-control-allow-origin"] = origin; } result.response.headers.vary = interceptorOptions.request.headers.vary ?? "origin"; } const allowedTimingOrigin = await value(this.options.timingOrigin, origin, interceptorOptions); const allowedTimingOriginArr = Array.isArray(allowedTimingOrigin) ? allowedTimingOrigin : [allowedTimingOrigin]; if (allowedTimingOriginArr.includes("*")) { result.response.headers["timing-allow-origin"] = "*"; } else if (allowedTimingOriginArr.includes(origin)) { result.response.headers["timing-allow-origin"] = origin; } if (this.options.credentials) { result.response.headers["access-control-allow-credentials"] = "true"; } if (this.options.exposeHeaders?.length) { result.response.headers["access-control-expose-headers"] = flattenHeader(this.options.exposeHeaders); } return result; }); } } class RequestHeadersPlugin { init(options) { options.rootInterceptors ??= []; options.rootInterceptors.push((interceptorOptions) => { const reqHeaders = interceptorOptions.context.reqHeaders ?? toFetchHeaders(interceptorOptions.request.headers); return interceptorOptions.next({ ...interceptorOptions, context: { ...interceptorOptions.context, reqHeaders } }); }); } } class ResponseHeadersPlugin { init(options) { options.rootInterceptors ??= []; options.rootInterceptors.push(async (interceptorOptions) => { const resHeaders = interceptorOptions.context.resHeaders ?? new Headers(); const result = await interceptorOptions.next({ ...interceptorOptions, context: { ...interceptorOptions.context, resHeaders } }); if (!result.matched) { return result; } const responseHeaders = clone(result.response.headers); for (const [key, value] of resHeaders) { if (Array.isArray(responseHeaders[key])) { responseHeaders[key].push(value); } else if (responseHeaders[key] !== void 0) { responseHeaders[key] = [responseHeaders[key], value]; } else { responseHeaders[key] = value; } } return { ...result, response: { ...result.response, headers: responseHeaders } }; }); } } const SIMPLE_CSRF_PROTECTION_CONTEXT_SYMBOL = Symbol("SIMPLE_CSRF_PROTECTION_CONTEXT"); class SimpleCsrfProtectionHandlerPlugin { headerName; headerValue; exclude; error; constructor(options = {}) { this.headerName = options.headerName ?? "x-csrf-token"; this.headerValue = options.headerValue ?? "orpc"; this.exclude = options.exclude ?? false; this.error = options.error ?? new ORPCError("CSRF_TOKEN_MISMATCH", { status: 403, message: "Invalid CSRF token" }); } order = 8e6; init(options) { options.rootInterceptors ??= []; options.clientInterceptors ??= []; options.rootInterceptors.unshift(async (options2) => { const headerName = await value(this.headerName, options2); const headerValue = await value(this.headerValue, options2); return options2.next({ ...options2, context: { ...options2.context, [SIMPLE_CSRF_PROTECTION_CONTEXT_SYMBOL]: options2.request.headers[headerName] === headerValue } }); }); options.clientInterceptors.unshift(async (options2) => { if (typeof options2.context[SIMPLE_CSRF_PROTECTION_CONTEXT_SYMBOL] !== "boolean") { throw new TypeError("[SimpleCsrfProtectionHandlerPlugin] CSRF protection context has been corrupted or modified by another plugin or interceptor"); } const excluded = await value(this.exclude, options2); if (!excluded && !options2.context[SIMPLE_CSRF_PROTECTION_CONTEXT_SYMBOL]) { throw this.error; } return options2.next(); }); } } export { BatchHandlerPlugin, CORSPlugin, RequestHeadersPlugin, ResponseHeadersPlugin, SimpleCsrfProtectionHandlerPlugin };