@tanstack/start-server-core
Version:
Modern and scalable routing for React applications
1,491 lines (1,375 loc) • 46 kB
text/typescript
import { AsyncLocalStorage } from 'node:async_hooks'
import { isRedirect } from '@tanstack/router-core'
import { parseCookie, parseSetCookie, serializeCookie } from 'cookie-es'
import { getSetCookieValues } from './headers'
import type {
RequestHeaderMap,
RequestHeaderName,
ResponseHeaderMap,
ResponseHeaderName,
TypedHeaders,
} from 'fetchdts'
import type { CookieSerializeOptions } from 'cookie-es'
import type { RequestHandler } from './request-handler'
export interface StartEvent {
request: Request
/** The parsed request URL. Callers must not mutate it. */
requestUrl: URL
/**
* Lazily created response state. Stays `undefined` until the first
* response helper write so requests that never use helpers pay nothing.
*/
responseState?: ResponseState
/** The most recently reconciled response, used by response read helpers. */
currentResponse?: Response
/** Applied helper operations belong to this invocation, across runtime copies. */
responseHeaderAppends?: WeakMap<Response, Map<string, HeaderAppendOperation>>
}
/**
* Event-owned mutable response state, recorded by the response helpers.
*
* A plain `Headers` object cannot distinguish between "not set" and
* "explicitly removed", so removals and Set-Cookie intent are tracked
* alongside the headers. All mutations flow through the helper functions in
* this module (`setResponseHeader`, `setCookie`, ...), which record that
* intent directly.
*/
interface ResponseState {
status?: number
statusText?: string
headers: Headers
removedHeaders?: Set<string>
clearHeaders: boolean
setCookieBehavior?: 'merge' | 'replace'
headerAppends?: Map<string, HeaderAppendOperation>
/** Outside production: a discarded bodyless status was already reported. */
bodylessStatusWarned?: boolean
/** Identity of each Set-Cookie string seen by this request's merges. */
setCookieKeys?: Map<string, string>
}
interface HeaderAppendOperation {
value: string
previous?: HeaderAppendOperation
}
// Use a global symbol to ensure the same AsyncLocalStorage instance is shared
// across different bundles that may each bundle this module.
const GLOBAL_EVENT_STORAGE_KEY = Symbol.for('tanstack-start:event-storage')
const globalObj = globalThis as typeof globalThis & {
[GLOBAL_EVENT_STORAGE_KEY]?: AsyncLocalStorage<StartEvent>
}
if (!globalObj[GLOBAL_EVENT_STORAGE_KEY]) {
globalObj[GLOBAL_EVENT_STORAGE_KEY] = new AsyncLocalStorage<StartEvent>()
}
const eventStorage = globalObj[GLOBAL_EVENT_STORAGE_KEY]
export type { ResponseHeaderName, RequestHeaderName }
/** A detached view of outgoing headers. Use the response helpers for writes. */
export type ReadonlyResponseHeaders = Omit<
TypedHeaders<ResponseHeaderMap>,
'append' | 'delete' | 'set' | 'forEach'
> & {
forEach: (
callback: (
value: string,
name: ResponseHeaderName,
headers: ReadonlyResponseHeaders,
) => void,
thisArg?: unknown,
) => void
}
// Lowercase protocol header names mapped to a required value, or to null when
// the header must be absent. Set-Cookie is never protected: it has one value
// per cookie.
type ProtectedHeaders = ReadonlyMap<string, string | null>
type MaybePromise<T> = T | Promise<T>
// Protocol requirements describe the response body, so they follow the response
// through delegated requests as well as separately bundled runtime copies.
const PROTECTED_RESPONSE_HEADERS = Symbol.for(
'tanstack-start:protected-response-headers',
)
type ProtectedResponse = Response & {
[PROTECTED_RESPONSE_HEADERS]?: ProtectedHeaders
}
function getProtectedResponseHeaders(
response: Response,
): ProtectedHeaders | undefined {
return (response as ProtectedResponse)[PROTECTED_RESPONSE_HEADERS]
}
function installProtectedResponseHeaders(
response: Response,
headers: ProtectedHeaders,
): void {
Object.defineProperty(response, PROTECTED_RESPONSE_HEADERS, {
value: headers,
configurable: true,
})
}
/** Transport requirements belong to the encoded body, not its wrapper. */
export function transferResponseProtocol(
source: Response,
target: Response,
): void {
const protectedHeaders = getProtectedResponseHeaders(source)
if (
protectedHeaders &&
getProtectedResponseHeaders(target) !== protectedHeaders
) {
installProtectedResponseHeaders(target, protectedHeaders)
}
}
/** Preserve metadata when Start reconstructs an existing response. */
export function transferResponseMetadata(
source: Response,
target: Response,
): void {
transferResponseProtocol(source, target)
const event = eventStorage.getStore()
const appliedHeaderAppends = event?.responseHeaderAppends?.get(source)
if (appliedHeaderAppends) {
event!.responseHeaderAppends!.set(target, appliedHeaderAppends)
}
}
function normalizeHeaderName(name: string): string {
return name.toLowerCase()
}
function sanitizeStatusMessage(statusMessage: string): string {
// Strip everything except horizontal tab and printable ASCII (space–'~')
// to prevent header injection through status text.
return statusMessage.replace(/[^\t\x20-\x7e]/g, '')
}
// The Fetch `Response` constructor only accepts statuses in the 200-599
// range (anything else throws a RangeError). Start responses are always
// represented as `Response` objects — for server routes and SSR documents
// just like for server functions — so out-of-range statuses (1xx, or exotic
// codes like 783/999) cannot be carried through the pipeline at all.
function sanitizeStatusCode(
statusCode: string | number | undefined,
): number | undefined {
const code = typeof statusCode === 'string' ? Number(statusCode) : statusCode
if (
code === undefined ||
!Number.isInteger(code) ||
code < 200 ||
code > 599
) {
return undefined
}
return code
}
function getObjectProperty(value: unknown, key: string): unknown {
if ((typeof value === 'object' && value) || typeof value === 'function') {
return (value as Record<string, unknown>)[key]
}
return undefined
}
function getStatusCodeProperty(
value: unknown,
key: 'status' | 'statusCode',
): string | number | undefined {
const property = getObjectProperty(value, key)
if (typeof property === 'string' || typeof property === 'number') {
return property
}
return undefined
}
function getErrorStatus(error: unknown): number | undefined {
const cause = getObjectProperty(error, 'cause')
return sanitizeStatusCode(
getStatusCodeProperty(error, 'status') ??
getStatusCodeProperty(error, 'statusCode') ??
getStatusCodeProperty(cause, 'status') ??
getStatusCodeProperty(cause, 'statusCode'),
)
}
function getErrorStatusText(error: unknown): string | undefined {
const cause = getObjectProperty(error, 'cause')
const statusText =
getObjectProperty(error, 'statusText') ??
getObjectProperty(error, 'statusMessage') ??
getObjectProperty(cause, 'statusText') ??
getObjectProperty(cause, 'statusMessage')
if (typeof statusText === 'string') {
return sanitizeStatusMessage(statusText)
}
return undefined
}
// Start builds error bodies and serialized replies itself. These headers
// describe another body's framing or its connection, whether they come from
// error metadata or from helpers, so they would corrupt that body on the wire.
const GENERATED_BODY_EXCLUDED_HEADERS: ReadonlySet<string> = new Set([
'connection',
'content-encoding',
'content-length',
'keep-alive',
'proxy-connection',
'te',
'trailer',
'transfer-encoding',
])
// Responses from another fetch implementation or realm fail instanceof.
function isResponse(value: unknown): boolean {
return (
value instanceof Response ||
Object.prototype.toString.call(value) === '[object Response]'
)
}
export function getErrorHeaders(error: unknown): Headers | undefined {
let init = getObjectProperty(error, 'headers')
if (!init) {
const cause = getObjectProperty(error, 'cause')
// A Response cause is another server's reply, such as a failed upstream
// fetch. Its headers were written for that reply, not for this client.
if (isResponse(cause)) {
return undefined
}
init = getObjectProperty(cause, 'headers')
}
if (!init) {
return undefined
}
let headers: Headers
try {
headers = new Headers(init as HeadersInit)
} catch {
return undefined
}
for (const name of GENERATED_BODY_EXCLUDED_HEADERS) {
headers.delete(name)
}
return headers
}
function getResponseState(event: StartEvent): ResponseState {
return (event.responseState ||= {
headers: new Headers(),
clearHeaders: false,
})
}
function setHeaderState(event: StartEvent, name: string, value: string): void {
const state = getResponseState(event)
const normalizedName = normalizeHeaderName(name)
state.headers.set(name, value)
state.headerAppends?.delete(normalizedName)
state.removedHeaders?.delete(normalizedName)
if (normalizedName === 'set-cookie') {
state.setCookieBehavior = 'replace'
}
}
function appendHeaderState(
event: StartEvent,
name: string,
value: string,
): void {
const state = getResponseState(event)
const normalizedName = normalizeHeaderName(name)
const appendToReturnedHeader =
normalizedName !== 'set-cookie' &&
!state.clearHeaders &&
!state.removedHeaders?.has(normalizedName) &&
(!state.headers.has(normalizedName) ||
state.headerAppends?.has(normalizedName))
// Validate and normalize through native Headers before recording intent.
state.headers.append(name, value)
if (appendToReturnedHeader) {
const appends = (state.headerAppends ||= new Map())
appends.set(normalizedName, {
value,
previous: appends.get(normalizedName),
})
}
state.removedHeaders?.delete(normalizedName)
if (
normalizedName === 'set-cookie' &&
state.setCookieBehavior !== 'replace'
) {
state.setCookieBehavior = 'merge'
}
}
function deleteHeaderState(event: StartEvent, name: string): void {
const state = getResponseState(event)
const normalizedName = normalizeHeaderName(name)
state.headers.delete(name)
state.headerAppends?.delete(normalizedName)
;(state.removedHeaders ||= new Set()).add(normalizedName)
if (normalizedName === 'set-cookie') {
state.setCookieBehavior = 'replace'
}
}
function getDistinctCookieKey(
name: string,
options: { domain?: string; path?: string },
defaultPath = '',
): string {
return [name, options.domain || '', options.path ?? defaultPath].join(';')
}
function getDistinctCookieKeyFromHeader(cookie: string): string {
const parsed = parseSetCookie(cookie)
// A cookie the parser rejects is identified by its exact string, so merging
// it again replaces it. Header values cannot contain NUL, so this key cannot
// match a parsed name, domain, and path.
return parsed ? getDistinctCookieKey(parsed.name, parsed) : `\0${cookie}`
}
function replaceSetCookieValues(
headers: Headers,
cookies: Array<string>,
): void {
headers.delete('set-cookie')
for (const cookie of cookies) {
headers.append('set-cookie', cookie)
}
}
// Every reconcile pass merges the same immutable cookie strings again, so
// parse each string's identity once per request.
function getCookieKey(state: ResponseState, cookie: string): string {
const keys = (state.setCookieKeys ||= new Map())
let key = keys.get(cookie)
if (key === undefined) {
key = getDistinctCookieKeyFromHeader(cookie)
keys.set(cookie, key)
}
return key
}
function getMergedSetCookieValues(
state: ResponseState,
currentCookies: Array<string>,
cookiesToMerge: Array<string>,
): Array<string> {
if (currentCookies.length === 0) {
return cookiesToMerge
}
const cookieKeysToMerge = new Set<string>()
for (const cookie of cookiesToMerge) {
cookieKeysToMerge.add(getCookieKey(state, cookie))
}
return currentCookies
.filter((cookie) => !cookieKeysToMerge.has(getCookieKey(state, cookie)))
.concat(cookiesToMerge)
}
function mergeStartSetCookieValues(
event: StartEvent,
cookies: Array<string>,
): void {
if (cookies.length === 0) {
return
}
const state = getResponseState(event)
state.removedHeaders?.delete('set-cookie')
if (state.setCookieBehavior !== 'replace') {
state.setCookieBehavior = 'merge'
}
replaceSetCookieValues(
state.headers,
getMergedSetCookieValues(state, getSetCookieValues(state.headers), cookies),
)
}
function hasProtectedHeaderChanges(
response: Response,
protectedHeaders: ProtectedHeaders,
): boolean {
const headers = response.headers
for (const [name, value] of protectedHeaders) {
if (headers.get(name) !== value) {
return true
}
}
return false
}
function applyProtectedHeaders(
protectedHeaders: ProtectedHeaders,
headers: Headers,
): void {
for (const [name, value] of protectedHeaders) {
if (value === null) {
headers.delete(name)
} else {
headers.set(name, value)
}
}
}
function applyHeaderState(
target: Headers,
state: ResponseState,
generatedBody: boolean,
protectedHeaders?: ProtectedHeaders,
appliedHeaderAppends?: Map<string, HeaderAppendOperation>,
): Headers {
let headers = target
const mutableHeaders = () => {
if (headers === target) {
headers = new Headers(target)
}
return headers
}
if (state.clearHeaders) {
// Snapshot the keys before deleting: deleting during live Headers
// iteration skips entries.
for (const name of Array.from(target.keys())) {
if (!protectedHeaders?.has(name)) {
mutableHeaders().delete(name)
}
}
}
if (!state.clearHeaders && state.removedHeaders) {
for (const name of state.removedHeaders) {
if (!protectedHeaders?.has(name) && headers.has(name)) {
mutableHeaders().delete(name)
}
}
}
// Headers iteration combines repeated header values into one comma-joined
// value, so `set` carries multi-value headers (Link, Vary, ...) losslessly.
// Set-Cookie is the one header where comma-joining is unsafe (cookie
// attributes like Expires contain commas), so it is handled separately.
for (const [name, value] of state.headers) {
if (
name !== 'set-cookie' &&
!protectedHeaders?.has(name) &&
!(generatedBody && GENERATED_BODY_EXCLUDED_HEADERS.has(name))
) {
let append = state.headerAppends?.get(name)
if (!append) {
if (headers.get(name) !== value) {
mutableHeaders().set(name, value)
}
continue
}
const applied = appliedHeaderAppends?.get(name)
const pending: Array<string> = []
while (append && append !== applied) {
pending.push(append.value)
append = append.previous
}
for (let index = pending.length - 1; index >= 0; index--) {
mutableHeaders().append(name, pending[index]!)
}
}
}
if (state.setCookieBehavior && !protectedHeaders?.has('set-cookie')) {
const eventSetCookies = getSetCookieValues(state.headers)
const currentCookies = getSetCookieValues(headers)
const cookies =
state.setCookieBehavior === 'replace'
? eventSetCookies
: getMergedSetCookieValues(state, currentCookies, eventSetCookies)
if (
cookies.length !== currentCookies.length ||
cookies.some((cookie, index) => cookie !== currentCookies[index])
) {
replaceSetCookieValues(mutableHeaders(), cookies)
}
}
return headers
}
function canHaveBody(method: string, status: number): boolean {
return method !== 'HEAD' && status !== 204 && status !== 205 && status !== 304
}
/** A protected content type marks a Start-serialized body the client decodes. */
function isSerializedResponse(protectedHeaders: ProtectedHeaders | undefined) {
return typeof protectedHeaders?.get('content-type') === 'string'
}
/**
* A helper status that forbids a body never applies to a serialized
* server-function reply, because the client must decode it. Only a HEAD request
* drops its body.
*/
function getHelperStatus(
state: ResponseState | undefined,
serialized: boolean,
): number | undefined {
const status = state?.status
if (!serialized || status === undefined || canHaveBody('GET', status)) {
return status
}
if (process.env.NODE_ENV !== 'production' && !state!.bodylessStatusWarned) {
state!.bodylessStatusWarned = true
console.warn(
`setResponseStatus(${status}) does not apply to serialized server function responses, because the client must decode their body. Return a Response from the server function to send a response without a body.`,
)
}
return undefined
}
/** Helper status text describes the helper status and is ignored with it. */
function getHelperStatusText(
state: ResponseState | undefined,
helperStatus: number | undefined,
): string | undefined {
return helperStatus === state?.status ? state?.statusText : undefined
}
function cancelDroppedBody(response: Response, reason: string): void {
try {
response.body?.cancel(reason).catch(() => {})
} catch {
// Ignore locked or already-consumed bodies.
}
}
function createReconciledResponse(
body: Response['body'],
status: number,
statusText: string,
headers: Headers,
): Response {
try {
return new Response(body, {
status,
statusText,
headers,
})
} catch (cause) {
throw new Error(
'Unable to reconcile response because its body has already been consumed or locked.',
{ cause },
)
}
}
/** Apply helper state to a response that leaves Start. */
export function finalizeResponse(
response: Response,
event: StartEvent,
): Response {
return applyResponseState(response, event, event.request.method)
}
/**
* Apply helper state with the body rules of `method`. Request middleware still
* reads a HEAD response's body, so the pipeline applies GET rules and strips a
* HEAD body only when the response leaves it.
*/
function applyResponseState(
response: Response,
event: StartEvent,
method: string,
disposeBody?: (reason: string) => void,
): Response {
const protectedHeaders = getProtectedResponseHeaders(response)
const serialized = isSerializedResponse(protectedHeaders)
let helperStatus = getHelperStatus(event.responseState, serialized)
// Some runtimes, such as Bun, allow a body on a 204, 205, or 304 response,
// so check the response's own status too. Read the body last: the getter
// allocates a stream for responses that have not exposed one yet.
const mustDropBody =
!canHaveBody(method, helperStatus ?? response.status) &&
response.body !== null
if (mustDropBody) {
const reason =
method === 'HEAD'
? 'HEAD body stripped'
: 'Response body dropped by Start reconciliation'
if (disposeBody) {
disposeBody(reason)
} else {
cancelDroppedBody(response, reason)
}
// Cancellation/SSR cleanup can write helpers. Read their final state
// before applying headers or recording appends.
helperStatus = getHelperStatus(event.responseState, serialized)
}
const state = event.responseState
// Without helper writes, preserve the response.
if (!state && !mustDropBody) {
event.currentResponse = response
return response
}
const status = helperStatus ?? response.status
const statusText =
getHelperStatusText(state, helperStatus) ?? response.statusText
const statusChanged = status !== response.status
const statusTextChanged = statusText !== response.statusText
// applyHeaderState returns the same headers when helper intent changes
// nothing.
const headers = state
? applyHeaderState(
response.headers,
state,
serialized,
protectedHeaders,
event.responseHeaderAppends?.get(response),
)
: response.headers
const reconciled =
statusChanged ||
statusTextChanged ||
mustDropBody ||
headers !== response.headers
? createReconciledResponse(
mustDropBody ? null : response.body,
status,
statusText,
headers,
)
: response
if (protectedHeaders && reconciled !== response) {
installProtectedResponseHeaders(reconciled, protectedHeaders)
}
return publishResponse(reconciled, event)
}
function publishResponse(response: Response, event: StartEvent): Response {
const headerAppends = event.responseState?.headerAppends
if (headerAppends?.size) {
;(event.responseHeaderAppends ||= new WeakMap()).set(
response,
new Map(headerAppends),
)
}
event.currentResponse = response
return response
}
function getHttpErrorStatus(status: number | undefined): number | undefined {
return status !== undefined && status >= 400 ? status : undefined
}
/**
* Status for a response built from an uncaught error. Only an error status
* (400-599) from a helper or from error metadata describes the failure, so a
* success or redirect status set before the throw cannot hide it. The error is
* logged unless its own metadata marks it as an HTTP error. Reporting runs
* before helper state is read, so hooks that write helpers are included.
*/
function resolveErrorStatus(
error: unknown,
event: Pick<StartEvent, 'responseState'> | undefined,
): { status: number; statusText: string } {
const metadataStatus = getErrorStatus(error)
const errorStatus = getHttpErrorStatus(metadataStatus)
if (errorStatus === undefined) {
console.error(error)
}
const state = event?.responseState
const helperStatus = getHttpErrorStatus(state?.status)
const status = helperStatus ?? errorStatus ?? 500
const statusText =
getHelperStatusText(state, helperStatus) ??
(status === errorStatus || metadataStatus === undefined
? getErrorStatusText(error)
: undefined) ??
''
if (state && state.status !== helperStatus) {
// The failure replaced the response that status was meant for. Discard it
// so later reconciliation keeps the error status.
state.status = undefined
state.statusText = undefined
}
return { status, statusText }
}
/**
* Internal: status and status text for a serialized server-function error.
* Logs the error and discards a non-error helper status, like any error
* response Start builds.
*/
export function resolveErrorResponseStatus(error: unknown): {
status: number
statusText: string
} {
return resolveErrorStatus(error, eventStorage.getStore())
}
function createErrorResponse(error: unknown, event: StartEvent): Response {
// Error metadata and reporting hooks can call public response helpers.
// Materialize them before taking the state used to build the response.
let headers = getErrorHeaders(error) ?? new Headers()
const { status, statusText } = resolveErrorStatus(error, event)
const state = event.responseState
const body = canHaveBody(event.request.method, status)
? JSON.stringify({
status,
statusText,
unhandled: true,
message: 'HTTPError',
})
: null
if (!headers.has('content-type')) {
headers.set('content-type', 'application/json')
}
if (state) {
headers = applyHeaderState(headers, state, true)
}
// Build Start-owned errors with their final headers instead of creating a
// response that reconciliation must immediately reconstruct. A byte body
// keeps Fetch from restoring Content-Type after an explicit helper removal.
return publishResponse(
new Response(
body !== null && !headers.has('content-type')
? new TextEncoder().encode(body)
: body,
{ status, statusText, headers },
),
event,
)
}
function finalizeError(error: unknown, event: StartEvent): Response {
if (error instanceof Response) {
return finalizeResponse(error, event)
}
return createErrorResponse(error, event)
}
export function handleStartError(error: unknown): Response {
const event = eventStorage.getStore()
if (event) {
return finalizeError(error, event)
}
if (error instanceof Response) {
return error
}
// Outside a server entry there is no invocation to recover. The thrown
// value is never used as a request identifier.
const request = new Request('http://localhost')
return createErrorResponse(error, {
request,
requestUrl: new URL(request.url),
})
}
/** Apply helper state to a response inside the request middleware pipeline. */
export function reconcileResponse(
response: Response,
event: StartEvent,
disposeBody?: (reason: string) => void,
): Response {
// Without helper writes, only a bodyless status could change the response,
// and the response Start last published already satisfies it. Router
// redirects still need destination resolution (or an RPC envelope), so they
// keep their identity until createStartHandler resolves them; response
// getters can already read the helper overlay on this snapshot.
if (
(!event.responseState &&
(response === event.currentResponse ||
canHaveBody('GET', response.status))) ||
isRedirect(response)
) {
event.currentResponse = response
return response
}
return applyResponseState(response, event, 'GET', disposeBody)
}
/**
* Restore protocol headers that middleware changed directly on the response.
* Start does this once, when the response leaves the request middleware
* pipeline; helper writes never override them.
*/
export function restoreResponseProtocol(
response: Response,
event: StartEvent,
): Response {
const protectedHeaders = getProtectedResponseHeaders(response)
if (
!protectedHeaders ||
!hasProtectedHeaderChanges(response, protectedHeaders)
) {
return response
}
const headers = new Headers(response.headers)
applyProtectedHeaders(protectedHeaders, headers)
const restored = createReconciledResponse(
response.body,
response.status,
response.statusText,
headers,
)
transferResponseMetadata(response, restored)
event.currentResponse = restored
return restored
}
/**
* Build a Start-owned serialized reply with already normalized protocol
* headers. A bodyless helper status never applies to it; only a HEAD request
* drops its body.
*/
export function createFinalizedResponse(
body: string | Uint8Array,
headers: Headers,
protectedHeaders: ProtectedHeaders,
event: StartEvent,
): Response {
const state = event.responseState
let status = 200
let statusText = ''
if (state) {
const helperStatus = getHelperStatus(state, true)
status = helperStatus ?? 200
statusText = getHelperStatusText(state, helperStatus) ?? ''
headers = applyHeaderState(headers, state, true, protectedHeaders)
}
const response = new Response(
canHaveBody(event.request.method, status) ? (body as BodyInit) : null,
{ status, statusText, headers },
)
// A new response has no protection to merge with.
installProtectedResponseHeaders(response, protectedHeaders)
return publishResponse(response, event)
}
export function protectResponseHeaders(
response: Response,
headers: ProtectedHeaders,
): void {
// Callers supply immutable protocol requirements with lowercase names.
// Share those requirements across responses instead of capturing each one.
const previous = getProtectedResponseHeaders(response)
if (previous === headers) {
return
}
if (previous) {
const merged = new Map(previous)
for (const [name, value] of headers) {
merged.set(name, value)
}
headers = merged
}
installProtectedResponseHeaders(response, headers)
}
/**
* Set and protect transport headers on a new response. Reuse the body stream
* without cloning or teeing it; callers transfer ownership to the result.
*/
export function setProtectedResponseHeaders(
response: Response,
headers: ProtectedHeaders,
): Response {
let next: Response
try {
// Response copies its initializer's headers; change only that fresh copy.
next = new Response(response.body, response)
applyProtectedHeaders(headers, next.headers)
} catch (cause) {
throw new Error(
'Unable to set response header because its body has already been consumed or locked.',
{ cause },
)
}
transferResponseMetadata(response, next)
protectResponseHeaders(next, headers)
return next
}
function decodePathname(pathname: string): string | undefined {
try {
// Return the value so bundlers preserve the potentially throwing decode.
return decodeURI(pathname)
} catch (error) {
if (error instanceof URIError) {
return undefined
}
throw error
}
}
/** A Start handler that runs in a request scope and receives its event. */
type ScopedRequestHandler = (
request: Request,
requestOpts: any,
event: StartEvent,
) => MaybePromise<Response>
function runInStartRequest(
request: Request,
requestOpts: any,
handler: ScopedRequestHandler,
): MaybePromise<Response> {
let requestUrl: URL
try {
requestUrl = new URL(request.url)
} catch (error) {
if (error instanceof TypeError) {
return new Response(null, { status: 400, statusText: 'Bad Request' })
}
throw error
}
const pathname = requestUrl.pathname
// Validate encoded paths without changing the pathname used for routing.
if (pathname.includes('%') && decodePathname(pathname) === undefined) {
return new Response(null, { status: 400, statusText: 'Bad Request' })
}
const event: StartEvent = { request, requestUrl }
return eventStorage.run(event, handler, request, requestOpts, event)
}
/**
* Keep the complete server entry, including custom error handling, inside one
* request scope. A custom returned response is used as-is; call handleStartError
* in a custom catch to preserve Start's response helper state.
*/
export function createServerEntry<TRegister = unknown>(entry: {
fetch: RequestHandler<TRegister>
}): { fetch: RequestHandler<TRegister> } {
return {
fetch: withStartRequest(async (request: Request, requestOpts: any) => {
try {
return await entry.fetch(request, requestOpts)
} catch (error) {
return handleStartError(error)
}
}),
}
}
/**
* Establish request scope; the response pipeline owns reconciliation. A nested
* handler delegating the same request participates in its scope. Independent
* fetch invocations and different requests get a fresh event.
*/
export function withStartRequest(handler: ScopedRequestHandler) {
return (request: Request, requestOpts: any): MaybePromise<Response> => {
const event = eventStorage.getStore()
if (event?.request === request) {
return handler(request, requestOpts, event)
}
return runInStartRequest(request, requestOpts, handler)
}
}
export function getStartEvent() {
const event = eventStorage.getStore()
if (!event) {
throw new Error(
`No StartEvent found in AsyncLocalStorage. Make sure you are using the function within the server runtime.`,
)
}
return event
}
/**
* Internal: returns the per-request parsed URL for `request`, memoized on the
* Start event so the framework parses each request URL only once. Falls back
* to parsing when called outside a Start event (e.g. in unit tests) or for a
* foreign request. Callers MUST NOT mutate the returned URL.
*/
export function getParsedRequestUrl(request: Request): URL {
const event = eventStorage.getStore()
if (event && event.request === request) {
return event.requestUrl
}
return new URL(request.url)
}
export function getRequest(): Request {
return getStartEvent().request
}
export function getRequestHeaders(): TypedHeaders<RequestHeaderMap> {
return getStartEvent().request.headers as TypedHeaders<RequestHeaderMap>
}
export function getRequestHeader(name: RequestHeaderName): string | undefined {
return getRequestHeaders().get(name) ?? undefined
}
export function getRequestIP(opts?: {
/**
* Use the X-Forwarded-For HTTP header set by proxies.
*
* Note: Make sure that this header can be trusted (your application running behind a CDN or reverse proxy) before enabling.
*/
xForwardedFor?: boolean
}) {
const request = getRequest()
if (opts?.xForwardedFor) {
const forwardedFor = request.headers.get('x-forwarded-for')
const forwardedIp = forwardedFor?.split(',', 1)[0]?.trim()
if (forwardedIp) {
return forwardedIp
}
}
return (
(request as Request & { context?: { clientAddress?: string }; ip?: string })
.context?.clientAddress ||
(request as Request & { ip?: string }).ip ||
undefined
)
}
/**
* Get the request hostname.
*
* If `xForwardedHost` is `true`, it will use the `x-forwarded-host` header if it exists.
*
* If no host header is found, it uses the request URL's host.
*/
export function getRequestHost(opts?: { xForwardedHost?: boolean }) {
const headers = getRequestHeaders()
if (opts?.xForwardedHost) {
const forwardedHost = headers.get('x-forwarded-host')
const host = forwardedHost?.split(',', 1)[0]?.trim()
if (host) {
return host
}
}
return headers.get('host') || getStartEvent().requestUrl.host || 'localhost'
}
/**
* Get the full incoming request URL.
*
* If `xForwardedHost` is `true`, it will use the `x-forwarded-host` header if it exists.
*
* If `xForwardedProto` is `false`, it will not use the `x-forwarded-proto` header.
*/
export function getRequestUrl(opts?: {
xForwardedHost?: boolean
xForwardedProto?: boolean
}) {
const event = getStartEvent()
// Clone the memoized URL so callers can mutate the result freely.
const url = new URL(event.requestUrl)
url.protocol = getRequestProtocol(opts)
if (opts?.xForwardedHost) {
const host = getRequestHost(opts)
if (host) {
url.host = host
if (!/:\d+$/.test(host)) {
url.port = ''
}
}
}
return url
}
/**
* Get the request protocol.
*
* If `x-forwarded-proto` header is set to "https", it will return "https". You can disable this behavior by setting `xForwardedProto` to `false`.
*
* If protocol cannot be determined, it will default to "http".
*/
export function getRequestProtocol(opts?: {
xForwardedProto?: boolean
}): 'http' | 'https' | (string & {}) {
const request = getRequest()
if (opts?.xForwardedProto !== false) {
const forwardedProto = request.headers
.get('x-forwarded-proto')
?.split(',', 1)[0]
?.trim()
.toLowerCase()
if (forwardedProto === 'https') {
return 'https'
}
if (forwardedProto === 'http') {
return 'http'
}
}
const url = getStartEvent().requestUrl
return url.protocol.slice(0, -1) as 'http' | 'https' | (string & {})
}
export function setResponseHeaders(
headers: TypedHeaders<ResponseHeaderMap> | ReadonlyResponseHeaders,
): void {
if (headers instanceof Headers) {
for (const [name, value] of headers) {
if (name !== 'set-cookie') {
setResponseHeader(name as ResponseHeaderName, value)
}
}
const cookies = getSetCookieValues(headers)
if (cookies.length > 0) {
setResponseHeader(
'set-cookie',
cookies.length === 1 ? cookies[0]! : cookies,
)
}
return
}
if (!Array.isArray(headers)) {
for (const [name, value] of Object.entries(
headers as unknown as Record<string, string | Array<string>>,
)) {
setResponseHeader(name as ResponseHeaderName, value)
}
return
}
const groupedHeaders = new Map<
string,
{ name: ResponseHeaderName; values: Array<string> }
>()
const addHeader = (name: string, value: string) => {
const normalizedName = normalizeHeaderName(name)
let header = groupedHeaders.get(normalizedName)
if (!header) {
header = { name: name as ResponseHeaderName, values: [] }
groupedHeaders.set(normalizedName, header)
}
header.values.push(value)
}
for (const [name, value] of headers) {
addHeader(name, value)
}
for (const { name, values } of groupedHeaders.values()) {
setResponseHeader(name, values.length === 1 ? values[0]! : values)
}
}
let SnapshotHeaders: typeof Headers | undefined
/**
* Outside production, return the snapshot as a Headers subclass that warns when
* code tries to change the outgoing response through it. Its contents and
* behavior are identical to the production snapshot.
*/
function toResponseHeadersSnapshot(headers: Headers): ReadonlyResponseHeaders {
if (process.env.NODE_ENV === 'production') {
return headers as ReadonlyResponseHeaders
}
SnapshotHeaders ??= class ResponseHeadersSnapshot extends Headers {
override append(name: string, value: string): void {
warnSnapshotMutation('append')
super.append(name, value)
}
override delete(name: string): void {
warnSnapshotMutation('delete')
super.delete(name)
}
override set(name: string, value: string): void {
warnSnapshotMutation('set')
super.set(name, value)
}
}
// The constructor fills entries without calling the overridden methods.
const entries: Array<[string, string]> = []
for (const [name, value] of headers) {
if (name !== 'set-cookie') {
entries.push([name, value])
}
}
for (const cookie of getSetCookieValues(headers)) {
entries.push(['set-cookie', cookie])
}
return new SnapshotHeaders(entries) as ReadonlyResponseHeaders
}
function warnSnapshotMutation(method: string): void {
console.warn(
`getResponseHeaders().${method}() does not change the response: getResponseHeaders() returns a read-only snapshot. Use setResponseHeader, appendResponseHeader, removeResponseHeader, clearResponseHeaders, or setCookie instead.`,
)
}
/**
* Read a detached snapshot of the effective response headers.
* Use response header and cookie helpers to change the outgoing response.
*/
export function getResponseHeaders(): ReadonlyResponseHeaders {
const event = getStartEvent()
const state = event.responseState
const currentResponse = event.currentResponse
if (!currentResponse) {
return toResponseHeadersSnapshot(
state ? new Headers(state.headers) : new Headers(),
)
}
const protectedHeaders = getProtectedResponseHeaders(currentResponse)
let headers = currentResponse.headers
if (state) {
headers = applyHeaderState(
headers,
state,
isSerializedResponse(protectedHeaders),
protectedHeaders,
event.responseHeaderAppends?.get(currentResponse),
)
}
// Read helpers always return a detached snapshot, including when applying
// helper intent did not need to copy any headers.
if (headers === currentResponse.headers) {
headers = new Headers(headers)
}
if (protectedHeaders) {
applyProtectedHeaders(protectedHeaders, headers)
}
return toResponseHeadersSnapshot(headers)
}
export function getResponseHeader(
name: ResponseHeaderName,
): string | undefined {
const event = getStartEvent()
const normalizedName = normalizeHeaderName(name)
if (
normalizedName === 'set-cookie' ||
event.responseState?.headerAppends?.has(normalizedName)
) {
return getResponseHeaders().get(name) ?? undefined
}
const currentResponse = event.currentResponse
const protectedHeaders = currentResponse
? getProtectedResponseHeaders(currentResponse)
: undefined
if (protectedHeaders?.has(normalizedName)) {
return protectedHeaders.get(normalizedName) ?? undefined
}
if (
GENERATED_BODY_EXCLUDED_HEADERS.has(normalizedName) &&
isSerializedResponse(protectedHeaders)
) {
return currentResponse!.headers.get(name) ?? undefined
}
const state = event.responseState
if (state) {
const stateValue = state.headers.get(name)
if (stateValue !== null) {
return stateValue
}
if (state.clearHeaders) {
return undefined
}
if (state.removedHeaders?.has(normalizedName)) {
return undefined
}
}
return currentResponse?.headers.get(name) ?? undefined
}
export function setResponseHeader(
name: ResponseHeaderName,
value: string | Array<string>,
): void {
const event = getStartEvent()
if (Array.isArray(value)) {
deleteHeaderState(event, name)
for (const valueItem of value) {
appendHeaderState(event, name, valueItem)
}
} else {
setHeaderState(event, name, value)
}
}
/**
* Append a response header value without replacing existing values.
*
* Ordinary values are appended once per returned response. Start transfers
* that application state when it reconstructs a response. A new response
* created by application code is fresh input: copying already-reconciled
* headers into it also copies their appended values, and helper appends apply
* again. Mutate the current response or pass its middleware result through
* when preserving an existing response's headers.
*
* For `set-cookie`, values must be fully serialized cookie strings; they are
* merged into the outgoing cookies and deduped by cookie identity
* (name + domain + path), exactly like `setCookie`. A value that cannot be
* parsed is identified by its exact string. This is the primitive to
* use when bridging external session/auth libraries that produce raw
* `Set-Cookie` strings.
*
* ```ts
* appendResponseHeader('set-cookie', await externalLib.commitSession(session))
* ```
*/
export function appendResponseHeader(
name: ResponseHeaderName,
value: string | Array<string>,
): void {
const event = getStartEvent()
const values = Array.isArray(value) ? value : [value]
if (normalizeHeaderName(name) === 'set-cookie') {
// Merge one value at a time so a batched call behaves exactly like
// sequential calls (same-identity cookies dedupe, last wins).
for (const valueItem of values) {
// Validate before changing cookie identity or removal intent. External
// libraries supply serialized strings, which can fail native validation.
const headers = new Headers({ 'set-cookie': valueItem })
mergeStartSetCookieValues(event, getSetCookieValues(headers))
}
return
}
for (const valueItem of values) {
appendHeaderState(event, name, valueItem)
}
}
export function removeResponseHeader(name: ResponseHeaderName): void {
deleteHeaderState(getStartEvent(), name)
}
export function clearResponseHeaders(
headerNames?: Array<ResponseHeaderName>,
): void {
const event = getStartEvent()
if (headerNames && headerNames.length > 0) {
for (const name of headerNames) {
deleteHeaderState(event, name)
}
return
}
const state = getResponseState(event)
state.clearHeaders = true
state.headers = new Headers()
state.headerAppends = undefined
}
export function getResponseStatus(): number {
const event = getStartEvent()
const currentResponse = event.currentResponse
const serialized =
!!currentResponse &&
isSerializedResponse(getProtectedResponseHeaders(currentResponse))
return (
getHelperStatus(event.responseState, serialized) ??
currentResponse?.status ??
200
)
}
export function setResponseStatus(code?: number, text?: string): void {
const event = getStartEvent()
if (code !== undefined) {
// An unusable code must not replace an earlier, returned, or error status.
// Ignore the whole call, including its status text, which describes it.
const status = sanitizeStatusCode(code)
if (status === undefined) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`setResponseStatus(${code}) was ignored: Fetch Response status codes must be integers from 200 to 599.`,
)
}
return
}
getResponseState(event).status = status
}
if (text) {
getResponseState(event).statusText = sanitizeStatusMessage(text)
}
}
/**
* Parse the request to get HTTP Cookie header string and return an object of all cookie name-value pairs.
* @returns Object of cookie name-value pairs
* ```ts
* const cookies = getCookies()
* ```
*/
export function getCookies(): Record<string, string> {
const cookies = parseCookie(getRequestHeaders().get('cookie') || '')
const definedCookies: Record<string, string> = Object.create(null)
for (const [name, value] of Object.entries(cookies)) {
if (value !== undefined) {
definedCookies[name] = value
}
}
return definedCookies
}
/**
* Get a cookie value by name.
* @param name Name of the cookie to get
* @returns {*} Value of the cookie (String or undefined)
* ```ts
* const authorization = getCookie('Authorization')
* ```
*/
export function getCookie(name: string): string | undefined {
return getCookies()[name]
}
/**
* Set a cookie value by name.
* @param name Name of the cookie to set
* @param value Value of the cookie to set
* @param options {CookieSerializeOptions} Options for serializing the cookie
* ```ts
* setCookie('Authorization', '1234567')
* ```
*/
export function setCookie(
name: string,
value: string,
options?: CookieSerializeOptions,
): void {
const { encode, stringify, ...attrs } = options ?? {}
mergeStartSetCookieValues(getStartEvent(), [
serializeCookie(
{ name, value, path: '/', ...attrs },
{ encode, stringify },
),
])
}
/**
* Remove a cookie by name.
* @param name Name of the cookie to delete
* @param serializeOptions {CookieSerializeOptions} Cookie options
* ```ts
* deleteCookie('SessionId')
* ```
*/
export function deleteCookie(
name: string,
options?: CookieSerializeOptions,
): void {
setCookie(name, '', {
...options,
maxAge: 0,
})
}
/**
* Internal: helper status state for a serialized server-function reply.
* Bodyless statuses never apply to it and are omitted with their status text.
*/
export function getSerializedResponseState(): {
status: number | undefined
statusText: string | undefined
} {
const state = getStartEvent().responseState
const status = getHelperStatus(state, true)
return { status, statusText: getHelperStatusText(state, status) }
}