Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

302 lines
9.4 KiB
TypeScript

import { type NextRequest, NextResponse } from 'next/server'
import { ZodError, type z } from 'zod'
import type {
AnyApiRouteContract,
ApiSchema,
ContractBody,
ContractHeaders,
ContractParams,
ContractQuery,
} from '@/lib/api/contracts'
import { env } from '@/lib/core/config/env'
import {
assertContentLengthWithinLimit,
isPayloadSizeLimitError,
readStreamToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
/**
* Default upper bound on the JSON request body that contract routes will read
* and parse into memory. Next.js App Router imposes no body cap, so without
* this an unauthenticated caller could buffer an arbitrarily large body before
* schema validation runs. Override per-route via `ParseRequestOptions.maxBodyBytes`.
* Falls back to 50 MB if the env value is missing or non-numeric so a misconfig
* can never silently disable the cap (a NaN limit would never reject).
*/
export const DEFAULT_MAX_JSON_BODY_BYTES =
Number.parseInt(env.API_MAX_JSON_BODY_BYTES, 10) || 50 * 1024 * 1024
export interface ValidationErrorBody {
error: string
details: z.core.$ZodIssue[]
}
type ValidationResult<S extends ApiSchema> =
| { success: true; data: z.output<S> }
| { success: false; response: NextResponse<unknown>; error: z.ZodError }
export interface ParsedRequest<C extends AnyApiRouteContract> {
params: ContractParams<C>
query: ContractQuery<C>
body: ContractBody<C>
headers: ContractHeaders<C>
}
interface RouteContextWithParams {
params?:
| Promise<Record<string, string | string[] | undefined>>
| Record<string, string | string[] | undefined>
}
export interface ParseRequestOptions {
validationErrorResponse?: (error: z.ZodError) => NextResponse<unknown>
invalidJsonResponse?: () => NextResponse<unknown>
invalidJson?: 'response' | 'throw'
/**
* Maximum number of bytes to read for the JSON body before rejecting with a
* 413. Defaults to {@link DEFAULT_MAX_JSON_BODY_BYTES}. Raise this only for
* routes that legitimately accept large JSON payloads (e.g. inline file uploads).
*/
maxBodyBytes?: number
}
export function serializeZodIssues(error: z.ZodError): z.core.$ZodIssue[] {
return error.issues
}
export function isZodError(error: unknown): error is z.ZodError {
return error instanceof ZodError
}
export function validationErrorResponse(
error: z.ZodError,
message = 'Validation error',
status = 400
): NextResponse<ValidationErrorBody> {
return NextResponse.json({ error: message, details: serializeZodIssues(error) }, { status })
}
export function getValidationErrorMessage(
error: z.ZodError,
fallback = 'Invalid request data'
): string {
return error.issues[0]?.message || fallback
}
export function validationErrorResponseFromError(
error: unknown,
message = 'Validation error',
status = 400
): NextResponse<ValidationErrorBody> | null {
if (!isZodError(error)) return null
return validationErrorResponse(error, message, status)
}
const REQUEST_BODY_LABEL = 'Request body'
/**
* Reads the JSON body while enforcing a byte cap. The body is read through a
* size-limited stream so chunked/streamed bodies are bounded even when the
* `content-length` header is absent or lies about the true size. When no
* readable stream is available (e.g. a mocked request) the content-length guard
* is the only bound and parsing falls back to {@link Request.json}. Decoding
* uses {@link TextDecoder} so a leading UTF-8 BOM is stripped, matching the spec
* "UTF-8 decode" behavior of `request.json()`.
*/
async function readJsonBodyWithLimit(request: Request, maxBytes: number): Promise<unknown> {
assertContentLengthWithinLimit(request.headers, maxBytes, REQUEST_BODY_LABEL)
const stream = request.body
if (!stream) {
return request.json()
}
const buffer = await readStreamToBufferWithLimit(stream, {
maxBytes,
label: REQUEST_BODY_LABEL,
})
return JSON.parse(new TextDecoder().decode(buffer))
}
export async function parseJsonBody(
request: Request,
invalidJson: ParseRequestOptions['invalidJson'] = 'response',
maxBytes: number = DEFAULT_MAX_JSON_BODY_BYTES
): Promise<
| { success: true; data: unknown }
| {
success: false
reason: 'too_large' | 'invalid_json'
response: NextResponse<{ error: string }>
}
> {
try {
return { success: true, data: await readJsonBodyWithLimit(request, maxBytes) }
} catch (error) {
if (invalidJson === 'throw') throw error
if (isPayloadSizeLimitError(error)) {
return {
success: false,
reason: 'too_large',
response: NextResponse.json(
{ error: `Request body exceeds the maximum allowed size of ${maxBytes} bytes` },
{ status: 413 }
),
}
}
return {
success: false,
reason: 'invalid_json',
response: NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }),
}
}
}
/**
* Reads an entirely optional JSON body with the standard byte cap. An absent
* or empty body resolves to `{ success: true, data: undefined }`; malformed
* JSON and oversized payloads return the standard 400/413 error responses.
* Use for endpoints whose body may be omitted altogether (e.g. optional
* metadata on a deploy call) — `parseJsonBody` rejects empty bodies.
*/
export async function parseOptionalJsonBody(
request: Request,
maxBytes: number = DEFAULT_MAX_JSON_BODY_BYTES
): Promise<
{ success: true; data: unknown } | { success: false; response: NextResponse<{ error: string }> }
> {
try {
assertContentLengthWithinLimit(request.headers, maxBytes, REQUEST_BODY_LABEL)
const stream = request.body
const text = stream
? new TextDecoder().decode(
await readStreamToBufferWithLimit(stream, { maxBytes, label: REQUEST_BODY_LABEL })
)
: await request.text()
if (!text.trim()) {
return { success: true, data: undefined }
}
return { success: true, data: JSON.parse(text) }
} catch (error) {
if (isPayloadSizeLimitError(error)) {
return {
success: false,
response: NextResponse.json(
{ error: `Request body exceeds the maximum allowed size of ${maxBytes} bytes` },
{ status: 413 }
),
}
}
return {
success: false,
response: NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }),
}
}
}
export function searchParamsToObject(
searchParams: URLSearchParams
): Record<string, string | string[]> {
const output: Record<string, string | string[]> = {}
for (const key of new Set(searchParams.keys())) {
const values = searchParams.getAll(key)
output[key] = values.length > 1 ? values : (values[0] ?? '')
}
return output
}
export function headersToObject(headers: Headers): Record<string, string> {
const output: Record<string, string> = {}
headers.forEach((value, key) => {
output[key] = value
})
return output
}
async function parseContextParams<TContext>(
context: TContext
): Promise<Record<string, string | string[] | undefined>> {
const candidate = context as RouteContextWithParams
if (!candidate.params) return {}
return await candidate.params
}
function shouldReadJsonBody<C extends AnyApiRouteContract>(contract: C): boolean {
return Boolean(contract.body) && contract.method !== 'GET'
}
export async function parseRequest<C extends AnyApiRouteContract, TContext>(
contract: C,
request: NextRequest,
context: TContext,
options?: ParseRequestOptions
): Promise<
{ success: true; data: ParsedRequest<C> } | { success: false; response: NextResponse<unknown> }
> {
const rawParams = await parseContextParams(context)
const rawQuery = searchParamsToObject(request.nextUrl.searchParams)
const rawHeaders = headersToObject(request.headers)
let body: unknown
if (shouldReadJsonBody(contract)) {
const parsedBody = await parseJsonBody(request, options?.invalidJson, options?.maxBodyBytes)
if (!parsedBody.success) {
return options?.invalidJsonResponse && parsedBody.reason === 'invalid_json'
? { success: false, response: options.invalidJsonResponse() }
: parsedBody
}
body = parsedBody.data
}
const params = contract.params
? validateRequestSchema(contract.params, rawParams, options)
: undefined
if (params && !params.success) return params
const query = contract.query
? validateRequestSchema(contract.query, rawQuery, options)
: undefined
if (query && !query.success) return query
const headers = contract.headers
? validateRequestSchema(contract.headers, rawHeaders, options)
: undefined
if (headers && !headers.success) return headers
const parsedBody = contract.body ? validateRequestSchema(contract.body, body, options) : undefined
if (parsedBody && !parsedBody.success) return parsedBody
return {
success: true,
data: {
params: params?.data as ContractParams<C>,
query: query?.data as ContractQuery<C>,
headers: headers?.data as ContractHeaders<C>,
body: parsedBody?.data as ContractBody<C>,
},
}
}
function validateRequestSchema<S extends ApiSchema>(
schema: S,
data: unknown,
options?: ParseRequestOptions
): ValidationResult<S> {
const result = schema.safeParse(data)
if (!result.success) {
return {
success: false,
response: options?.validationErrorResponse
? options.validationErrorResponse(result.error)
: validationErrorResponse(result.error),
error: result.error,
}
}
return { success: true, data: result.data }
}