From 20631203d874d17e7b10c7ef729931164c5d4530 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 27 Mar 2024 11:43:31 -0400 Subject: [PATCH 1/2] Move single fetch server code into standalone file --- packages/remix-server-runtime/index.ts | 8 +- packages/remix-server-runtime/server.ts | 452 +---------------- packages/remix-server-runtime/single-fetch.ts | 455 ++++++++++++++++++ 3 files changed, 470 insertions(+), 445 deletions(-) create mode 100644 packages/remix-server-runtime/single-fetch.ts diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts index 845ae0d55de..7744129cdb7 100644 --- a/packages/remix-server-runtime/index.ts +++ b/packages/remix-server-runtime/index.ts @@ -8,11 +8,9 @@ export { defer, json, redirect, redirectDocument } from "./responses"; export type { SingleFetchResult as UNSAFE_SingleFetchResult, SingleFetchResults as UNSAFE_SingleFetchResults, -} from "./server"; -export { - createRequestHandler, - SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol, -} from "./server"; +} from "./single-fetch"; +export { SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol } from "./single-fetch"; +export { createRequestHandler } from "./server"; export { createSession, createSessionStorageFactory, diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index f7e30332914..269f56551cb 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -2,8 +2,6 @@ import type { UNSAFE_DeferredData as DeferredData, ErrorResponse, StaticHandler, - unstable_DataStrategyFunctionArgs as DataStrategyFunctionArgs, - StaticHandlerContext, } from "@remix-run/router"; import { UNSAFE_DEFERRED_SYMBOL as DEFERRED_SYMBOL, @@ -14,35 +12,37 @@ import { stripBasename, UNSAFE_ErrorResponseImpl as ErrorResponseImpl, } from "@remix-run/router"; -import { encode } from "turbo-stream"; import type { AppLoadContext } from "./data"; import type { HandleErrorFunction, ServerBuild } from "./build"; import type { EntryContext } from "./entry"; import { createEntryRouteModules } from "./entry"; -import { - sanitizeError, - sanitizeErrors, - serializeError, - serializeErrors, -} from "./errors"; +import { sanitizeErrors, serializeError, serializeErrors } from "./errors"; import { getDocumentHeaders } from "./headers"; import invariant from "./invariant"; import { ServerMode, isServerMode } from "./mode"; import { matchServerRoutes } from "./routeMatching"; -import type { ResponseStub, ResponseStubOperation } from "./routeModules"; -import { ResponseStubOperationsSymbol } from "./routeModules"; import type { ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { createDeferredReadableStream, - isDeferredData, isRedirectResponse, isRedirectStatusCode, isResponse, } from "./responses"; import { createServerHandoffString } from "./serverHandoff"; import { getDevServerHooks } from "./dev"; +import type { SingleFetchResult, SingleFetchResults } from "./single-fetch"; +import { + encodeViaTurboStream, + getResponseStubs, + getSingleFetchDataStrategy, + getSingleFetchRedirect, + mergeResponseStubs, + singleFetchAction, + singleFetchLoaders, + SingleFetchRedirectSymbol, +} from "./single-fetch"; export type RequestHandler = ( request: Request, @@ -85,9 +85,6 @@ function derive(build: ServerBuild, mode?: string) { }; } -export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); -export const ResponseStubActionSymbol = Symbol("ResponseStubAction"); - export const createRequestHandler: CreateRequestHandlerFunction = ( build, mode @@ -329,22 +326,6 @@ async function handleDataRequest( } } -type SingleFetchRedirectResult = { - redirect: string; - status: number; - revalidate: boolean; - reload: boolean; -}; -export type SingleFetchResult = - | { data: unknown } - | { error: unknown } - | SingleFetchRedirectResult; - -export type SingleFetchResults = { - [key: string]: SingleFetchResult; - [SingleFetchRedirectSymbol]?: SingleFetchRedirectResult; -}; - async function handleSingleFetchRequest( serverMode: ServerMode, build: ServerBuild, @@ -395,192 +376,6 @@ async function handleSingleFetchRequest( ); } -async function singleFetchAction( - serverMode: ServerMode, - staticHandler: StaticHandler, - request: Request, - handlerUrl: URL, - loadContext: AppLoadContext, - handleError: (err: unknown) => void -): Promise<{ result: SingleFetchResult; headers: Headers; status: number }> { - try { - let handlerRequest = new Request(handlerUrl, { - method: request.method, - body: request.body, - headers: request.headers, - signal: request.signal, - ...(request.body ? { duplex: "half" } : undefined), - }); - - let responseStubs = getResponseStubs(); - let result = await staticHandler.query(handlerRequest, { - requestContext: loadContext, - skipLoaderErrorBubbling: true, - skipLoaders: true, - unstable_dataStrategy: getSingleFetchDataStrategy(responseStubs), - }); - - // Unlike `handleDataRequest`, when singleFetch is enabled, queryRoute does - // let non-Response return values through - if (isResponse(result)) { - return { - result: getSingleFetchRedirect(result.status, result.headers), - headers: result.headers, - status: 200, - }; - } - - let context = result; - - let singleFetchResult: SingleFetchResult; - let { statusCode, headers } = mergeResponseStubs(context, responseStubs); - - if (isRedirectStatusCode(statusCode) && headers.has("Location")) { - return { - result: getSingleFetchRedirect(statusCode, headers), - headers, - status: 200, // Don't want the `fetch` call to follow the redirect - }; - } - - // Sanitize errors outside of development environments - if (context.errors) { - Object.values(context.errors).forEach((err) => { - // @ts-expect-error This is "private" from users but intended for internal use - if (!isRouteErrorResponse(err) || err.error) { - handleError(err); - } - }); - context.errors = sanitizeErrors(context.errors, serverMode); - } - - if (context.errors) { - let error = Object.values(context.errors)[0]; - singleFetchResult = { error: isResponseStub(error) ? null : error }; - } else { - singleFetchResult = { data: Object.values(context.actionData || {})[0] }; - } - - return { - result: singleFetchResult, - headers, - status: statusCode, - }; - } catch (error) { - handleError(error); - // These should only be internal remix errors, no need to deal with responseStubs - return { - result: { error }, - headers: new Headers(), - status: 500, - }; - } -} - -async function singleFetchLoaders( - serverMode: ServerMode, - staticHandler: StaticHandler, - request: Request, - handlerUrl: URL, - loadContext: AppLoadContext, - handleError: (err: unknown) => void -): Promise<{ result: SingleFetchResults; headers: Headers; status: number }> { - try { - let handlerRequest = new Request(handlerUrl, { - headers: request.headers, - signal: request.signal, - }); - let loadRouteIds = - new URL(request.url).searchParams.get("_routes")?.split(",") || undefined; - - let responseStubs = getResponseStubs(); - let result = await staticHandler.query(handlerRequest, { - requestContext: loadContext, - loadRouteIds, - skipLoaderErrorBubbling: true, - unstable_dataStrategy: getSingleFetchDataStrategy(responseStubs), - }); - - if (isResponse(result)) { - return { - result: { - [SingleFetchRedirectSymbol]: getSingleFetchRedirect( - result.status, - result.headers - ), - }, - headers: result.headers, - status: 200, // Don't want the `fetch` call to follow the redirect - }; - } - - let context = result; - - let { statusCode, headers } = mergeResponseStubs(context, responseStubs); - - if (isRedirectStatusCode(statusCode) && headers.has("Location")) { - return { - result: { - [SingleFetchRedirectSymbol]: getSingleFetchRedirect( - statusCode, - headers - ), - }, - headers, - status: 200, // Don't want the `fetch` call to follow the redirect - }; - } - - // Sanitize errors outside of development environments - if (context.errors) { - Object.values(context.errors).forEach((err) => { - // @ts-expect-error This is "private" from users but intended for internal use - if (!isRouteErrorResponse(err) || err.error) { - handleError(err); - } - }); - context.errors = sanitizeErrors(context.errors, serverMode); - } - - // Aggregate results based on the matches we intended to load since we get - // `null` values back in `context.loaderData` for routes we didn't load - let results: SingleFetchResults = {}; - let loadedMatches = loadRouteIds - ? context.matches.filter( - (m) => m.route.loader && loadRouteIds!.includes(m.route.id) - ) - : context.matches; - - loadedMatches.forEach((m) => { - let data = context.loaderData?.[m.route.id]; - let error = context.errors?.[m.route.id]; - if (error !== undefined) { - if (isResponseStub(error)) { - results[m.route.id] = { error: null }; - } else { - results[m.route.id] = { error }; - } - } else if (data !== undefined) { - results[m.route.id] = { data }; - } - }); - - return { - result: results, - headers, - status: statusCode, - }; - } catch (error: unknown) { - handleError(error); - // These should only be internal remix errors, no need to deal with responseStubs - return { - result: { root: { error } }, - headers: new Headers(), - status: 500, - }; - } -} - async function handleDocumentRequest( serverMode: ServerMode, build: ServerBuild, @@ -859,229 +654,6 @@ function unwrapResponse(response: Response) { : response.text(); } -function getResponseStub(status?: number) { - let headers = new Headers(); - let operations: ResponseStubOperation[] = []; - let headersProxy = new Proxy(headers, { - get(target, prop, receiver) { - if (prop === "set" || prop === "append" || prop === "delete") { - return (name: string, value: string) => { - operations.push([prop, name, value]); - Reflect.apply(target[prop], target, [name, value]); - }; - } - return Reflect.get(target, prop, receiver); - }, - }); - return { - status, - headers: headersProxy, - [ResponseStubOperationsSymbol]: operations, - }; -} - -function getSingleFetchDataStrategy( - responseStubs: ReturnType -) { - return async ({ request, matches }: DataStrategyFunctionArgs) => { - let results = await Promise.all( - matches.map(async (match) => { - let responseStub: ResponseStub | undefined; - if (request.method !== "GET") { - responseStub = responseStubs[ResponseStubActionSymbol]; - } else { - responseStub = responseStubs[match.route.id]; - } - - let result = await match.resolve(async (handler) => { - let data = await handler({ response: responseStub }); - return { type: "data", result: data }; - }); - - // Transfer raw Response status/headers to responseStubs - if (isResponse(result.result)) { - proxyResponseToResponseStub( - result.result.status, - result.result.headers, - responseStub - ); - } else if (isDeferredData(result.result) && result.result.init) { - proxyResponseToResponseStub( - result.result.init.status, - new Headers(result.result.init.headers), - responseStub - ); - } - - return result; - }) - ); - return results; - }; -} - -function proxyResponseToResponseStub( - status: number | undefined, - headers: Headers, - responseStub: ResponseStub -) { - if (status != null && responseStub.status == null) { - responseStub.status = status; - } - for (let [k, v] of headers) { - if (k.toLowerCase() !== "set-cookie") { - responseStub.headers.set(k, v); - } - } - - // Unsure why this is complaining? It's fine in VSCode but fails with tsc... - // @ts-ignore - ignoring instead of expecting because otherwise build fails locally - for (let v of headers.getSetCookie()) { - responseStub.headers.append("Set-Cookie", v); - } -} - -function isResponseStub(value: any): value is ResponseStub { - return ( - value && typeof value === "object" && ResponseStubOperationsSymbol in value - ); -} - -function getResponseStubs() { - return new Proxy({} as Record, { - get(responseStubCache, prop) { - let cached = responseStubCache[prop]; - if (!cached) { - responseStubCache[prop] = cached = getResponseStub(); - } - return cached; - }, - }); -} - -function mergeResponseStubs( - context: StaticHandlerContext, - responseStubs: ReturnType -) { - let statusCode: number | undefined = undefined; - let headers = new Headers(); - - // Action followed by top-down loaders - let actionStub = responseStubs[ResponseStubActionSymbol]; - let stubs = [ - actionStub, - ...context.matches.map((m) => responseStubs[m.route.id]), - ]; - - for (let stub of stubs) { - // Take the highest error/redirect, or the lowest success value - preferring - // action 200's over loader 200s - if ( - // first status found on the way down - (statusCode === undefined && stub.status) || - // deeper 2xx status found while not overriding the action status - (statusCode !== undefined && - statusCode < 300 && - stub.status && - statusCode !== actionStub?.status) - ) { - statusCode = stub.status; - } - - // Replay headers operations in order - let ops = stub[ResponseStubOperationsSymbol]; - for (let [op, ...args] of ops) { - // @ts-expect-error - headers[op](...args); - } - } - - // If no response stubs set it, use whatever we got back from the router - // context which handles internal ErrorResponse cases like 404/405's where - // we may never run a loader/action - if (statusCode === undefined) { - statusCode = context.statusCode; - } - if (statusCode === undefined) { - statusCode = 200; - } - - return { statusCode, headers }; -} - -function getSingleFetchRedirect( - status: number, - headers: Headers -): SingleFetchRedirectResult { - return { - redirect: headers.get("Location")!, - status, - revalidate: - // Technically X-Remix-Revalidate isn't needed here - that was an implementation - // detail of ?_data requests as our way to tell the front end to revalidate when - // we didn't have a response body to include that information in. - // With single fetch, we tell the front end via this revalidate boolean field. - // However, we're respecting it for now because it may be something folks have - // used in their own responses - // TODO(v3): Consider removing or making this official public API - headers.has("X-Remix-Revalidate") || headers.has("Set-Cookie"), - reload: headers.has("X-Remix-Reload-Document"), - }; -} - -// Note: If you change this function please change the corresponding -// decodeViaTurboStream function in server-runtime -function encodeViaTurboStream( - data: any, - requestSignal: AbortSignal, - streamTimeout: number | undefined, - serverMode: ServerMode -) { - let controller = new AbortController(); - // How long are we willing to wait for all of the promises in `data` to resolve - // before timing out? We default this to 50ms shorter than the default value for - // `ABORT_DELAY` in our built-in `entry.server.tsx` so that once we reject we - // have time to flush the rejections down through React's rendering stream before ` - // we call abort() on that. If the user provides their own it's up to them to - // decouple the aborting of the stream from the aborting of React's renderToPipeableStream - let timeoutId = setTimeout( - () => controller.abort(new Error("Server Timeout")), - typeof streamTimeout === "number" ? streamTimeout : 4950 - ); - requestSignal.addEventListener("abort", () => clearTimeout(timeoutId)); - - return encode(data, { - signal: controller.signal, - plugins: [ - (value) => { - // Even though we sanitized errors on context.errors prior to responding, - // we still need to handle this for any deferred data that rejects with an - // Error - as those will not be sanitized yet - if (value instanceof Error) { - let { name, message, stack } = - serverMode === ServerMode.Production - ? sanitizeError(value, serverMode) - : value; - return ["SanitizedError", name, message, stack]; - } - - if (value instanceof ErrorResponseImpl) { - let { data, status, statusText } = value; - return ["ErrorResponse", data, status, statusText]; - } - - if ( - value && - typeof value === "object" && - SingleFetchRedirectSymbol in value - ) { - return ["SingleFetchRedirect", value[SingleFetchRedirectSymbol]]; - } - }, - ], - }); -} - function createRemixRedirectResponse( response: Response, basename: string | undefined diff --git a/packages/remix-server-runtime/single-fetch.ts b/packages/remix-server-runtime/single-fetch.ts new file mode 100644 index 00000000000..0ebf9e2fae4 --- /dev/null +++ b/packages/remix-server-runtime/single-fetch.ts @@ -0,0 +1,455 @@ +import type { + StaticHandler, + unstable_DataStrategyFunctionArgs as DataStrategyFunctionArgs, + StaticHandlerContext, +} from "@remix-run/router"; +import { + UNSAFE_DEFERRED_SYMBOL as DEFERRED_SYMBOL, + getStaticContextFromError, + isRouteErrorResponse, + createStaticHandler, + json as routerJson, + stripBasename, + UNSAFE_ErrorResponseImpl as ErrorResponseImpl, +} from "@remix-run/router"; +import { encode } from "turbo-stream"; + +import type { AppLoadContext } from "./data"; +import { + sanitizeError, + sanitizeErrors, + serializeError, + serializeErrors, +} from "./errors"; +import { ServerMode } from "./mode"; +import type { ResponseStub, ResponseStubOperation } from "./routeModules"; +import { ResponseStubOperationsSymbol } from "./routeModules"; +import { isDeferredData, isRedirectStatusCode, isResponse } from "./responses"; + +export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); +const ResponseStubActionSymbol = Symbol("ResponseStubAction"); + +type SingleFetchRedirectResult = { + redirect: string; + status: number; + revalidate: boolean; + reload: boolean; +}; +export type SingleFetchResult = + | { data: unknown } + | { error: unknown } + | SingleFetchRedirectResult; + +export type SingleFetchResults = { + [key: string]: SingleFetchResult; + [SingleFetchRedirectSymbol]?: SingleFetchRedirectResult; +}; + +export function getSingleFetchDataStrategy( + responseStubs: ReturnType +) { + return async ({ request, matches }: DataStrategyFunctionArgs) => { + let results = await Promise.all( + matches.map(async (match) => { + let responseStub: ResponseStub | undefined; + if (request.method !== "GET") { + responseStub = responseStubs[ResponseStubActionSymbol]; + } else { + responseStub = responseStubs[match.route.id]; + } + + let result = await match.resolve(async (handler) => { + let data = await handler({ response: responseStub }); + return { type: "data", result: data }; + }); + + // Transfer raw Response status/headers to responseStubs + if (isResponse(result.result)) { + proxyResponseToResponseStub( + result.result.status, + result.result.headers, + responseStub + ); + } else if (isDeferredData(result.result) && result.result.init) { + proxyResponseToResponseStub( + result.result.init.status, + new Headers(result.result.init.headers), + responseStub + ); + } + + return result; + }) + ); + return results; + }; +} + +export async function singleFetchAction( + serverMode: ServerMode, + staticHandler: StaticHandler, + request: Request, + handlerUrl: URL, + loadContext: AppLoadContext, + handleError: (err: unknown) => void +): Promise<{ result: SingleFetchResult; headers: Headers; status: number }> { + try { + let handlerRequest = new Request(handlerUrl, { + method: request.method, + body: request.body, + headers: request.headers, + signal: request.signal, + ...(request.body ? { duplex: "half" } : undefined), + }); + + let responseStubs = getResponseStubs(); + let result = await staticHandler.query(handlerRequest, { + requestContext: loadContext, + skipLoaderErrorBubbling: true, + skipLoaders: true, + unstable_dataStrategy: getSingleFetchDataStrategy(responseStubs), + }); + + // Unlike `handleDataRequest`, when singleFetch is enabled, queryRoute does + // let non-Response return values through + if (isResponse(result)) { + return { + result: getSingleFetchRedirect(result.status, result.headers), + headers: result.headers, + status: 200, + }; + } + + let context = result; + + let singleFetchResult: SingleFetchResult; + let { statusCode, headers } = mergeResponseStubs(context, responseStubs); + + if (isRedirectStatusCode(statusCode) && headers.has("Location")) { + return { + result: getSingleFetchRedirect(statusCode, headers), + headers, + status: 200, // Don't want the `fetch` call to follow the redirect + }; + } + + // Sanitize errors outside of development environments + if (context.errors) { + Object.values(context.errors).forEach((err) => { + // @ts-expect-error This is "private" from users but intended for internal use + if (!isRouteErrorResponse(err) || err.error) { + handleError(err); + } + }); + context.errors = sanitizeErrors(context.errors, serverMode); + } + + if (context.errors) { + let error = Object.values(context.errors)[0]; + singleFetchResult = { error: isResponseStub(error) ? null : error }; + } else { + singleFetchResult = { data: Object.values(context.actionData || {})[0] }; + } + + return { + result: singleFetchResult, + headers, + status: statusCode, + }; + } catch (error) { + handleError(error); + // These should only be internal remix errors, no need to deal with responseStubs + return { + result: { error }, + headers: new Headers(), + status: 500, + }; + } +} + +export async function singleFetchLoaders( + serverMode: ServerMode, + staticHandler: StaticHandler, + request: Request, + handlerUrl: URL, + loadContext: AppLoadContext, + handleError: (err: unknown) => void +): Promise<{ result: SingleFetchResults; headers: Headers; status: number }> { + try { + let handlerRequest = new Request(handlerUrl, { + headers: request.headers, + signal: request.signal, + }); + let loadRouteIds = + new URL(request.url).searchParams.get("_routes")?.split(",") || undefined; + + let responseStubs = getResponseStubs(); + let result = await staticHandler.query(handlerRequest, { + requestContext: loadContext, + loadRouteIds, + skipLoaderErrorBubbling: true, + unstable_dataStrategy: getSingleFetchDataStrategy(responseStubs), + }); + + if (isResponse(result)) { + return { + result: { + [SingleFetchRedirectSymbol]: getSingleFetchRedirect( + result.status, + result.headers + ), + }, + headers: result.headers, + status: 200, // Don't want the `fetch` call to follow the redirect + }; + } + + let context = result; + + let { statusCode, headers } = mergeResponseStubs(context, responseStubs); + + if (isRedirectStatusCode(statusCode) && headers.has("Location")) { + return { + result: { + [SingleFetchRedirectSymbol]: getSingleFetchRedirect( + statusCode, + headers + ), + }, + headers, + status: 200, // Don't want the `fetch` call to follow the redirect + }; + } + + // Sanitize errors outside of development environments + if (context.errors) { + Object.values(context.errors).forEach((err) => { + // @ts-expect-error This is "private" from users but intended for internal use + if (!isRouteErrorResponse(err) || err.error) { + handleError(err); + } + }); + context.errors = sanitizeErrors(context.errors, serverMode); + } + + // Aggregate results based on the matches we intended to load since we get + // `null` values back in `context.loaderData` for routes we didn't load + let results: SingleFetchResults = {}; + let loadedMatches = loadRouteIds + ? context.matches.filter( + (m) => m.route.loader && loadRouteIds!.includes(m.route.id) + ) + : context.matches; + + loadedMatches.forEach((m) => { + let data = context.loaderData?.[m.route.id]; + let error = context.errors?.[m.route.id]; + if (error !== undefined) { + if (isResponseStub(error)) { + results[m.route.id] = { error: null }; + } else { + results[m.route.id] = { error }; + } + } else if (data !== undefined) { + results[m.route.id] = { data }; + } + }); + + return { + result: results, + headers, + status: statusCode, + }; + } catch (error: unknown) { + handleError(error); + // These should only be internal remix errors, no need to deal with responseStubs + return { + result: { root: { error } }, + headers: new Headers(), + status: 500, + }; + } +} + +export function isResponseStub(value: any): value is ResponseStub { + return ( + value && typeof value === "object" && ResponseStubOperationsSymbol in value + ); +} + +function getResponseStub(status?: number) { + let headers = new Headers(); + let operations: ResponseStubOperation[] = []; + let headersProxy = new Proxy(headers, { + get(target, prop, receiver) { + if (prop === "set" || prop === "append" || prop === "delete") { + return (name: string, value: string) => { + operations.push([prop, name, value]); + Reflect.apply(target[prop], target, [name, value]); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + return { + status, + headers: headersProxy, + [ResponseStubOperationsSymbol]: operations, + }; +} + +export function getResponseStubs() { + return new Proxy({} as Record, { + get(responseStubCache, prop) { + let cached = responseStubCache[prop]; + if (!cached) { + responseStubCache[prop] = cached = getResponseStub(); + } + return cached; + }, + }); +} + +function proxyResponseToResponseStub( + status: number | undefined, + headers: Headers, + responseStub: ResponseStub +) { + if (status != null && responseStub.status == null) { + responseStub.status = status; + } + for (let [k, v] of headers) { + if (k.toLowerCase() !== "set-cookie") { + responseStub.headers.set(k, v); + } + } + + // Unsure why this is complaining? It's fine in VSCode but fails with tsc... + // @ts-ignore - ignoring instead of expecting because otherwise build fails locally + for (let v of headers.getSetCookie()) { + responseStub.headers.append("Set-Cookie", v); + } +} + +export function mergeResponseStubs( + context: StaticHandlerContext, + responseStubs: ReturnType +) { + let statusCode: number | undefined = undefined; + let headers = new Headers(); + + // Action followed by top-down loaders + let actionStub = responseStubs[ResponseStubActionSymbol]; + let stubs = [ + actionStub, + ...context.matches.map((m) => responseStubs[m.route.id]), + ]; + + for (let stub of stubs) { + // Take the highest error/redirect, or the lowest success value - preferring + // action 200's over loader 200s + if ( + // first status found on the way down + (statusCode === undefined && stub.status) || + // deeper 2xx status found while not overriding the action status + (statusCode !== undefined && + statusCode < 300 && + stub.status && + statusCode !== actionStub?.status) + ) { + statusCode = stub.status; + } + + // Replay headers operations in order + let ops = stub[ResponseStubOperationsSymbol]; + for (let [op, ...args] of ops) { + // @ts-expect-error + headers[op](...args); + } + } + + // If no response stubs set it, use whatever we got back from the router + // context which handles internal ErrorResponse cases like 404/405's where + // we may never run a loader/action + if (statusCode === undefined) { + statusCode = context.statusCode; + } + if (statusCode === undefined) { + statusCode = 200; + } + + return { statusCode, headers }; +} + +export function getSingleFetchRedirect( + status: number, + headers: Headers +): SingleFetchRedirectResult { + return { + redirect: headers.get("Location")!, + status, + revalidate: + // Technically X-Remix-Revalidate isn't needed here - that was an implementation + // detail of ?_data requests as our way to tell the front end to revalidate when + // we didn't have a response body to include that information in. + // With single fetch, we tell the front end via this revalidate boolean field. + // However, we're respecting it for now because it may be something folks have + // used in their own responses + // TODO(v3): Consider removing or making this official public API + headers.has("X-Remix-Revalidate") || headers.has("Set-Cookie"), + reload: headers.has("X-Remix-Reload-Document"), + }; +} + +// Note: If you change this function please change the corresponding +// decodeViaTurboStream function in server-runtime +export function encodeViaTurboStream( + data: any, + requestSignal: AbortSignal, + streamTimeout: number | undefined, + serverMode: ServerMode +) { + let controller = new AbortController(); + // How long are we willing to wait for all of the promises in `data` to resolve + // before timing out? We default this to 50ms shorter than the default value for + // `ABORT_DELAY` in our built-in `entry.server.tsx` so that once we reject we + // have time to flush the rejections down through React's rendering stream before ` + // we call abort() on that. If the user provides their own it's up to them to + // decouple the aborting of the stream from the aborting of React's renderToPipeableStream + let timeoutId = setTimeout( + () => controller.abort(new Error("Server Timeout")), + typeof streamTimeout === "number" ? streamTimeout : 4950 + ); + requestSignal.addEventListener("abort", () => clearTimeout(timeoutId)); + + return encode(data, { + signal: controller.signal, + plugins: [ + (value) => { + // Even though we sanitized errors on context.errors prior to responding, + // we still need to handle this for any deferred data that rejects with an + // Error - as those will not be sanitized yet + if (value instanceof Error) { + let { name, message, stack } = + serverMode === ServerMode.Production + ? sanitizeError(value, serverMode) + : value; + return ["SanitizedError", name, message, stack]; + } + + if (value instanceof ErrorResponseImpl) { + let { data, status, statusText } = value; + return ["ErrorResponse", data, status, statusText]; + } + + if ( + value && + typeof value === "object" && + SingleFetchRedirectSymbol in value + ) { + return ["SingleFetchRedirect", value[SingleFetchRedirectSymbol]]; + } + }, + ], + }); +} From 63f5739825c8e701b4c48ab41f313e2237f741e9 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 27 Mar 2024 11:49:39 -0400 Subject: [PATCH 2/2] Remove unused imports --- packages/remix-server-runtime/single-fetch.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/remix-server-runtime/single-fetch.ts b/packages/remix-server-runtime/single-fetch.ts index 0ebf9e2fae4..2d983b6fd46 100644 --- a/packages/remix-server-runtime/single-fetch.ts +++ b/packages/remix-server-runtime/single-fetch.ts @@ -4,23 +4,13 @@ import type { StaticHandlerContext, } from "@remix-run/router"; import { - UNSAFE_DEFERRED_SYMBOL as DEFERRED_SYMBOL, - getStaticContextFromError, isRouteErrorResponse, - createStaticHandler, - json as routerJson, - stripBasename, UNSAFE_ErrorResponseImpl as ErrorResponseImpl, } from "@remix-run/router"; import { encode } from "turbo-stream"; import type { AppLoadContext } from "./data"; -import { - sanitizeError, - sanitizeErrors, - serializeError, - serializeErrors, -} from "./errors"; +import { sanitizeError, sanitizeErrors } from "./errors"; import { ServerMode } from "./mode"; import type { ResponseStub, ResponseStubOperation } from "./routeModules"; import { ResponseStubOperationsSymbol } from "./routeModules";