UNPKG

@remix-run/server-runtime

Version:
539 lines (516 loc) • 21.9 kB
/** * @remix-run/server-runtime v2.16.8 * * Copyright (c) Remix Software Inc. * * This source code is licensed under the MIT license found in the * LICENSE.md file in the root directory of this source tree. * * @license MIT */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var router = require('@remix-run/router'); var entry = require('./entry.js'); var errors = require('./errors.js'); var headers = require('./headers.js'); var invariant = require('./invariant.js'); var mode = require('./mode.js'); var routeMatching = require('./routeMatching.js'); var routes = require('./routes.js'); var responses = require('./responses.js'); var serverHandoff = require('./serverHandoff.js'); var dev = require('./dev.js'); var singleFetch = require('./single-fetch.js'); var deprecations = require('./deprecations.js'); // Do not include a response body if the status code is one of these, // otherwise `undici` will throw an error when constructing the Response: // https://github.com/nodejs/undici/blob/bd98a6303e45d5e0d44192a93731b1defdb415f3/lib/web/fetch/response.js#L522-L528 // // Specs: // https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx // https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content // https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content // https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205, 304]); function derive(build, mode$1) { var _build$future, _build$future2; let routes$1 = routes.createRoutes(build.routes); let dataRoutes = routes.createStaticHandlerDataRoutes(build.routes, build.future); let serverMode = mode.isServerMode(mode$1) ? mode$1 : mode.ServerMode.Production; let staticHandler = router.createStaticHandler(dataRoutes, { basename: build.basename, future: { v7_relativeSplatPath: ((_build$future = build.future) === null || _build$future === void 0 ? void 0 : _build$future.v3_relativeSplatPath) === true, v7_throwAbortReason: ((_build$future2 = build.future) === null || _build$future2 === void 0 ? void 0 : _build$future2.v3_throwAbortReason) === true } }); let errorHandler = build.entry.module.handleError || ((error, { request }) => { if (serverMode !== mode.ServerMode.Test && !request.signal.aborted) { console.error( // @ts-expect-error This is "private" from users but intended for internal use router.isRouteErrorResponse(error) && error.error ? error.error : error); } }); return { routes: routes$1, dataRoutes, serverMode, staticHandler, errorHandler }; } const createRequestHandler = (build, mode$1) => { let _build; let routes; let serverMode; let staticHandler; let errorHandler; return async function requestHandler(request, loadContext = {}) { _build = typeof build === "function" ? await build() : build; mode$1 ??= _build.mode; if (typeof build === "function") { let derived = derive(_build, mode$1); routes = derived.routes; serverMode = derived.serverMode; staticHandler = derived.staticHandler; errorHandler = derived.errorHandler; } else if (!routes || !serverMode || !staticHandler || !errorHandler) { let derived = derive(_build, mode$1); routes = derived.routes; serverMode = derived.serverMode; staticHandler = derived.staticHandler; errorHandler = derived.errorHandler; } let url = new URL(request.url); let params = {}; let handleError = error => { if (mode$1 === mode.ServerMode.Development) { var _getDevServerHooks, _getDevServerHooks$pr; (_getDevServerHooks = dev.getDevServerHooks()) === null || _getDevServerHooks === void 0 ? void 0 : (_getDevServerHooks$pr = _getDevServerHooks.processRequestError) === null || _getDevServerHooks$pr === void 0 ? void 0 : _getDevServerHooks$pr.call(_getDevServerHooks, error); } errorHandler(error, { context: loadContext, params, request }); }; // Manifest request for fog of war let manifestUrl = `${_build.basename ?? "/"}/__manifest`.replace(/\/+/g, "/"); if (url.pathname === manifestUrl) { try { let res = await handleManifestRequest(_build, routes, url); return res; } catch (e) { handleError(e); return new Response("Unknown Server Error", { status: 500 }); } } let matches = routeMatching.matchServerRoutes(routes, url.pathname, _build.basename); if (matches && matches.length > 0) { Object.assign(params, matches[0].params); } let response; if (url.searchParams.has("_data")) { if (_build.future.v3_singleFetch) { handleError(new Error("Warning: Single fetch-enabled apps should not be making ?_data requests, " + "this is likely to break in the future")); } let routeId = url.searchParams.get("_data"); response = await handleDataRequest(serverMode, _build, staticHandler, routeId, request, loadContext, handleError); if (_build.entry.module.handleDataRequest) { response = await _build.entry.module.handleDataRequest(response, { context: loadContext, params, request }); if (responses.isRedirectResponse(response)) { response = createRemixRedirectResponse(response, _build.basename); } } } else if (_build.future.v3_singleFetch && url.pathname.endsWith(".data")) { let handlerUrl = new URL(request.url); handlerUrl.pathname = handlerUrl.pathname.replace(/\.data$/, "").replace(/^\/_root$/, "/"); let singleFetchMatches = routeMatching.matchServerRoutes(routes, handlerUrl.pathname, _build.basename); response = await handleSingleFetchRequest(serverMode, _build, staticHandler, request, handlerUrl, loadContext, handleError); if (_build.entry.module.handleDataRequest) { response = await _build.entry.module.handleDataRequest(response, { context: loadContext, params: singleFetchMatches ? singleFetchMatches[0].params : {}, request }); if (responses.isRedirectResponse(response)) { let result = singleFetch.getSingleFetchRedirect(response.status, response.headers, _build.basename); if (request.method === "GET") { result = { [singleFetch.SingleFetchRedirectSymbol]: result }; } let headers = new Headers(response.headers); headers.set("Content-Type", "text/x-script"); return new Response(singleFetch.encodeViaTurboStream(result, request.signal, _build.entry.module.streamTimeout, serverMode), { status: singleFetch.SINGLE_FETCH_REDIRECT_STATUS, headers }); } } } else if (matches && matches[matches.length - 1].route.module.default == null && matches[matches.length - 1].route.module.ErrorBoundary == null) { response = await handleResourceRequest(serverMode, _build, staticHandler, matches.slice(-1)[0].route.id, request, loadContext, handleError); } else { var _getDevServerHooks2, _getDevServerHooks2$g; let criticalCss = mode$1 === mode.ServerMode.Development ? await ((_getDevServerHooks2 = dev.getDevServerHooks()) === null || _getDevServerHooks2 === void 0 ? void 0 : (_getDevServerHooks2$g = _getDevServerHooks2.getCriticalCss) === null || _getDevServerHooks2$g === void 0 ? void 0 : _getDevServerHooks2$g.call(_getDevServerHooks2, _build, url.pathname)) : undefined; response = await handleDocumentRequest(serverMode, _build, staticHandler, request, loadContext, handleError, criticalCss); } if (request.method === "HEAD") { return new Response(null, { headers: response.headers, status: response.status, statusText: response.statusText }); } return response; }; }; async function handleManifestRequest(build, routes, url) { if (build.assets.version !== url.searchParams.get("version")) { return new Response(null, { status: 204, headers: { "X-Remix-Reload-Document": "true" } }); } let patches = {}; if (url.searchParams.has("p")) { let paths = new Set(); // In addition to responding with the patches for the requested paths, we // need to include patches for each partial path so that we pick up any // pathless/index routes below ancestor segments. So if we // get a request for `/parent/child`, we need to look for a match on `/parent` // so that if a `parent._index` route exists we return it so it's available // for client side matching if the user routes back up to `/parent`. // This is the same thing we do on initial load in <Scripts> via // `getPartialManifest()` url.searchParams.getAll("p").forEach(path => { if (!path.startsWith("/")) { path = `/${path}`; } let segments = path.split("/").slice(1); segments.forEach((_, i) => { let partialPath = segments.slice(0, i + 1).join("/"); paths.add(`/${partialPath}`); }); }); for (let path of paths) { let matches = routeMatching.matchServerRoutes(routes, path, build.basename); if (matches) { for (let match of matches) { let routeId = match.route.id; patches[routeId] = build.assets.routes[routeId]; } } } return responses.json(patches, { headers: { "Cache-Control": "public, max-age=31536000, immutable" } }); // Override the TypedResponse stuff from json() } return new Response("Invalid Request", { status: 400 }); } async function handleDataRequest(serverMode, build, staticHandler, routeId, request, loadContext, handleError) { try { let response = await staticHandler.queryRoute(request, { routeId, requestContext: loadContext }); if (responses.isRedirectResponse(response)) { return createRemixRedirectResponse(response, build.basename); } if (router.UNSAFE_DEFERRED_SYMBOL in response) { let deferredData = response[router.UNSAFE_DEFERRED_SYMBOL]; let body = responses.createDeferredReadableStream(deferredData, request.signal, serverMode); let init = deferredData.init || {}; let headers = new Headers(init.headers); headers.set("Content-Type", "text/remix-deferred"); // Mark successful responses with a header so we can identify in-flight // network errors that are missing this header headers.set("X-Remix-Response", "yes"); init.headers = headers; return new Response(body, init); } // Mark all successful responses with a header so we can identify in-flight // network errors that are missing this header response = safelySetHeader(response, "X-Remix-Response", "yes"); return response; } catch (error) { if (responses.isResponse(error)) { let response = safelySetHeader(error, "X-Remix-Catch", "yes"); return response; } if (router.isRouteErrorResponse(error)) { handleError(error); return errorResponseToJson(error, serverMode); } let errorInstance = error instanceof Error || error instanceof DOMException ? error : new Error("Unexpected Server Error"); handleError(errorInstance); return router.json(errors.serializeError(errorInstance, serverMode), { status: 500, headers: { "X-Remix-Error": "yes" } }); } } async function handleSingleFetchRequest(serverMode, build, staticHandler, request, handlerUrl, loadContext, handleError) { let { result, headers, status } = request.method !== "GET" ? await singleFetch.singleFetchAction(build, serverMode, staticHandler, request, handlerUrl, loadContext, handleError) : await singleFetch.singleFetchLoaders(build, serverMode, staticHandler, request, handlerUrl, loadContext, handleError); // Mark all successful responses with a header so we can identify in-flight // network errors that are missing this header let resultHeaders = new Headers(headers); resultHeaders.set("X-Remix-Response", "yes"); // Skip response body for unsupported status codes if (NO_BODY_STATUS_CODES.has(status)) { return new Response(null, { status, headers: resultHeaders }); } // We use a less-descriptive `text/x-script` here instead of something like // `text/x-turbo` to enable compression when deployed via Cloudflare. See: // - https://github.com/remix-run/remix/issues/9884 // - https://developers.cloudflare.com/speed/optimization/content/brotli/content-compression/ resultHeaders.set("Content-Type", "text/x-script"); // Note: Deferred data is already just Promises, so we don't have to mess // `activeDeferreds` or anything :) return new Response(singleFetch.encodeViaTurboStream(result, request.signal, build.entry.module.streamTimeout, serverMode), { status: status || 200, headers: resultHeaders }); } async function handleDocumentRequest(serverMode, build, staticHandler, request, loadContext, handleError, criticalCss) { let context; try { context = await staticHandler.query(request, { requestContext: loadContext }); } catch (error) { handleError(error); return new Response(null, { status: 500 }); } if (responses.isResponse(context)) { return context; } let headers$1 = headers.getDocumentHeaders(build, context); // Skip response body for unsupported status codes if (NO_BODY_STATUS_CODES.has(context.statusCode)) { return new Response(null, { status: context.statusCode, headers: headers$1 }); } // Sanitize errors outside of development environments if (context.errors) { Object.values(context.errors).forEach(err => { // @ts-expect-error `err.error` is "private" from users but intended for internal use if (!router.isRouteErrorResponse(err) || err.error) { handleError(err); } }); context.errors = errors.sanitizeErrors(context.errors, serverMode); } // Server UI state to send to the client. // - When single fetch is enabled, this is streamed down via `serverHandoffStream` // - Otherwise it's stringified into `serverHandoffString` let state = { loaderData: context.loaderData, actionData: context.actionData, errors: errors.serializeErrors(context.errors, serverMode) }; let entryContext = { manifest: build.assets, routeModules: entry.createEntryRouteModules(build.routes), staticHandlerContext: context, criticalCss, serverHandoffString: serverHandoff.createServerHandoffString({ basename: build.basename, criticalCss, future: build.future, isSpaMode: build.isSpaMode, ...(!build.future.v3_singleFetch ? { state } : null) }), ...(build.future.v3_singleFetch ? { serverHandoffStream: singleFetch.encodeViaTurboStream(state, request.signal, build.entry.module.streamTimeout, serverMode), renderMeta: {} } : null), future: build.future, isSpaMode: build.isSpaMode, serializeError: err => errors.serializeError(err, serverMode) }; let handleDocumentRequestFunction = build.entry.module.default; try { return await handleDocumentRequestFunction(request, context.statusCode, headers$1, entryContext, loadContext); } catch (error) { handleError(error); let errorForSecondRender = error; // If they threw a response, unwrap it into an ErrorResponse like we would // have for a loader/action if (responses.isResponse(error)) { try { let data = await unwrapResponse(error); errorForSecondRender = new router.UNSAFE_ErrorResponseImpl(error.status, error.statusText, data); } catch (e) { // If we can't unwrap the response - just leave it as-is } } // Get a new StaticHandlerContext that contains the error at the right boundary context = router.getStaticContextFromError(staticHandler.dataRoutes, context, errorForSecondRender); // Sanitize errors outside of development environments if (context.errors) { context.errors = errors.sanitizeErrors(context.errors, serverMode); } // Get a new entryContext for the second render pass // Server UI state to send to the client. // - When single fetch is enabled, this is streamed down via `serverHandoffStream` // - Otherwise it's stringified into `serverHandoffString` let state = { loaderData: context.loaderData, actionData: context.actionData, errors: errors.serializeErrors(context.errors, serverMode) }; entryContext = { ...entryContext, staticHandlerContext: context, serverHandoffString: serverHandoff.createServerHandoffString({ basename: build.basename, future: build.future, isSpaMode: build.isSpaMode, ...(!build.future.v3_singleFetch ? { state } : null) }), ...(build.future.v3_singleFetch ? { serverHandoffStream: singleFetch.encodeViaTurboStream(state, request.signal, build.entry.module.streamTimeout, serverMode), renderMeta: {} } : null) }; try { return await handleDocumentRequestFunction(request, context.statusCode, headers$1, entryContext, loadContext); } catch (error) { handleError(error); return returnLastResortErrorResponse(error, serverMode); } } } async function handleResourceRequest(serverMode, build, staticHandler, routeId, request, loadContext, handleError) { try { // Note we keep the routeId here to align with the Remix handling of // resource routes which doesn't take ?index into account and just takes // the leaf match let response = await staticHandler.queryRoute(request, { routeId, requestContext: loadContext }); if (typeof response === "object" && response !== null) { invariant["default"](!(router.UNSAFE_DEFERRED_SYMBOL in response), `You cannot return a \`defer()\` response from a Resource Route. Did you ` + `forget to export a default UI component from the "${routeId}" route?`); } if (build.future.v3_singleFetch && !responses.isResponse(response)) { console.warn(deprecations.resourceRouteJsonWarning(request.method === "GET" ? "loader" : "action", routeId)); response = responses.json(response); } // callRouteLoader/callRouteAction always return responses (w/o single fetch). // With single fetch, users should always be Responses from resource routes invariant["default"](responses.isResponse(response), "Expected a Response to be returned from queryRoute"); return response; } catch (error) { if (responses.isResponse(error)) { // Note: Not functionally required but ensures that our response headers // match identically to what Remix returns let response = safelySetHeader(error, "X-Remix-Catch", "yes"); return response; } if (router.isRouteErrorResponse(error)) { if (error) { handleError(error); } return errorResponseToJson(error, serverMode); } handleError(error); return returnLastResortErrorResponse(error, serverMode); } } function errorResponseToJson(errorResponse, serverMode) { return router.json(errors.serializeError( // @ts-expect-error This is "private" from users but intended for internal use errorResponse.error || new Error("Unexpected Server Error"), serverMode), { status: errorResponse.status, statusText: errorResponse.statusText, headers: { "X-Remix-Error": "yes" } }); } function returnLastResortErrorResponse(error, serverMode) { let message = "Unexpected Server Error"; if (serverMode !== mode.ServerMode.Production) { message += `\n\n${String(error)}`; } // Good grief folks, get your act together 😂! return new Response(message, { status: 500, headers: { "Content-Type": "text/plain" } }); } function unwrapResponse(response) { let contentType = response.headers.get("Content-Type"); // Check between word boundaries instead of startsWith() due to the last // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type return contentType && /\bapplication\/json\b/.test(contentType) ? response.body == null ? null : response.json() : response.text(); } function createRemixRedirectResponse(response, basename) { // We don't have any way to prevent a fetch request from following // redirects. So we use the `X-Remix-Redirect` header to indicate the // next URL, and then "follow" the redirect manually on the client. let headers = new Headers(response.headers); let redirectUrl = headers.get("Location"); headers.set("X-Remix-Redirect", basename ? router.stripBasename(redirectUrl, basename) || redirectUrl : redirectUrl); headers.set("X-Remix-Status", String(response.status)); headers.delete("Location"); if (response.headers.get("Set-Cookie") !== null) { headers.set("X-Remix-Revalidate", "yes"); } return new Response(null, { status: 204, headers }); } // Anytime we are setting a header on a `Response` created in the loader/action, // we have to so it in this manner since in an `undici` world, if the `Response` // came directly from a `fetch` call, the headers are immutable will throw if // we try to set a new header. This is a sort of shallow clone of the `Response` // so we can safely set our own header. function safelySetHeader(response, name, value) { let headers = new Headers(response.headers); headers.set(name, value); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, duplex: response.body ? "half" : undefined }); } exports.createRequestHandler = createRequestHandler;