From 77fc6a0ed3802ac316716df8a0fb9be5d9760aa3 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Fri, 15 Oct 2021 21:59:43 -0700 Subject: [PATCH] feat: added entry.server handleDataRequest --- docs/api/app.md | 17 +++++++++- fixtures/gists-app/app/entry.server.jsx | 5 +++ .../tests/handle-data-request-test.ts | 34 +++++++++++++++++++ packages/remix-server-runtime/build.ts | 22 ++++++++---- packages/remix-server-runtime/index.ts | 8 ++++- packages/remix-server-runtime/routeModules.ts | 15 ++++++-- packages/remix-server-runtime/server.ts | 13 +++++-- 7 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 fixtures/gists-app/tests/handle-data-request-test.ts diff --git a/docs/api/app.md b/docs/api/app.md index 648b43b9f08..731f19e3a93 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -136,11 +136,16 @@ Remix uses `app/entry.server.tsx` to generate the HTTP response when rendering o This module should render the markup for the current page using a `` element with the `context` and `url` for the current request. This markup will (optionally) be re-hydrated once JavaScript loads in the browser using the [browser entry module]("../entry.client"). +You can also export an optional `handleDataRequest` function that will allow you to modify the response of a data request. These are the requests that do not render HTML, but rather return the loader and action data to the browser once client side hydration has occured. + Here's a basic example: ```tsx import ReactDOMServer from "react-dom/server"; -import type { EntryContext } from "remix"; +import type { + EntryContext, + HandleDataRequestFunction, +} from "remix"; import { RemixServer } from "remix"; export default function handleRequest( @@ -160,6 +165,16 @@ export default function handleRequest( headers: responseHeaders, }); } + +// this is an optional export +export let handleDataRequest: HandleDataRequestFunction = ( + response: Response, + // same args that get passed to the action or loader that was called + { request, params, context } +) => { + response.headers.set("x-custom", "yay!"); + return response; +}; ``` # Route Module API diff --git a/fixtures/gists-app/app/entry.server.jsx b/fixtures/gists-app/app/entry.server.jsx index 2a902ea1764..b75bb4fa539 100644 --- a/fixtures/gists-app/app/entry.server.jsx +++ b/fixtures/gists-app/app/entry.server.jsx @@ -18,3 +18,8 @@ export default function handleRequest( headers: responseHeaders }); } + +export function handleDataRequest(response) { + response.headers.set("x-hdr", "yes"); + return response; +} diff --git a/fixtures/gists-app/tests/handle-data-request-test.ts b/fixtures/gists-app/tests/handle-data-request-test.ts new file mode 100644 index 00000000000..2c4112dc2c6 --- /dev/null +++ b/fixtures/gists-app/tests/handle-data-request-test.ts @@ -0,0 +1,34 @@ +import type { Browser, Page } from "puppeteer"; +import puppeteer from "puppeteer"; + +import * as Utils from "./utils"; + +const testPort = 3000; +const testServer = `http://localhost:${testPort}`; + +describe("handle data request function", () => { + let browser: Browser; + let page: Page; + beforeEach(async () => { + browser = await puppeteer.launch(); + page = await browser.newPage(); + }); + + afterEach(() => browser.close()); + + describe("is called", () => { + it("on client side navigation", async () => { + let responses = Utils.collectDataResponses(page); + await page.goto(`${testServer}/`); + await Utils.reactIsHydrated(page); + + await page.click('a[href="/gists"]'); + await page.waitForSelector('[data-test-id="/gists/index"]'); + + expect(responses.length).toEqual(2); + responses.forEach(response => + expect(response.headers()["x-hdr"]).toBe("yes") + ); + }); + }); +}); diff --git a/packages/remix-server-runtime/build.ts b/packages/remix-server-runtime/build.ts index afc1f5c0731..80f6ed4b024 100644 --- a/packages/remix-server-runtime/build.ts +++ b/packages/remix-server-runtime/build.ts @@ -1,3 +1,4 @@ +import type { DataFunctionArgs } from "./routeModules"; import type { EntryContext, AssetsManifest } from "./entry"; import type { ServerRouteManifest } from "./routes"; @@ -12,15 +13,24 @@ export interface ServerBuild { assets: AssetsManifest; } +export interface HandleDocumentRequestFunction { + ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + context: EntryContext + ): Promise | Response; +} + +export interface HandleDataRequestFunction { + (response: Response, args: DataFunctionArgs): Promise | Response; +} + /** * A module that serves as the entry point for a Remix app during server * rendering. */ export interface ServerEntryModule { - default( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - context: EntryContext - ): Promise; + default: HandleDocumentRequestFunction; + handleDataRequest?: HandleDataRequestFunction; } diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts index 7251840f65b..cca1125ac39 100644 --- a/packages/remix-server-runtime/index.ts +++ b/packages/remix-server-runtime/index.ts @@ -1,4 +1,9 @@ -export type { ServerBuild, ServerEntryModule } from "./build"; +export type { + ServerBuild, + ServerEntryModule, + HandleDataRequestFunction, + HandleDocumentRequestFunction +} from "./build"; export type { CookieParseOptions, @@ -23,6 +28,7 @@ export type { ServerPlatform } from "./platform"; export type { ActionFunction, + DataFunctionArgs, ErrorBoundaryComponent, HeadersFunction, LinksFunction, diff --git a/packages/remix-server-runtime/routeModules.ts b/packages/remix-server-runtime/routeModules.ts index 481e6fd24ca..fa7f7a96fac 100644 --- a/packages/remix-server-runtime/routeModules.ts +++ b/packages/remix-server-runtime/routeModules.ts @@ -1,6 +1,6 @@ import type { Location } from "history"; import type { ComponentType } from "react"; -import type { Params } from "react-router"; // TODO: import/export from react-router-dom +import type { Params } from "react-router-dom"; import type { AppLoadContext, AppData } from "./data"; import type { LinkDescriptor } from "./links"; @@ -10,11 +10,20 @@ export interface RouteModules { [routeId: string]: RouteModule; } +/** + * The arguments passed to ActionFunction and LoaderFunction. + */ +export interface DataFunctionArgs { + request: Request; + context: AppLoadContext; + params: Params; +} + /** * A function that handles data mutations for a route. */ export interface ActionFunction { - (args: { request: Request; context: AppLoadContext; params: Params }): + (args: DataFunctionArgs): | Promise | Response | Promise @@ -55,7 +64,7 @@ export interface LinksFunction { * A function that loads data for a route. */ export interface LoaderFunction { - (args: { request: Request; context: AppLoadContext; params: Params }): + (args: DataFunctionArgs): | Promise | Response | Promise diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 94c6f78075d..cfe07d79e64 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -1,4 +1,4 @@ -import type { AppLoadContext} from "./data"; +import type { AppLoadContext } from "./data"; import { extractData, isCatchResponse } from "./data"; import { loadRouteData, callRouteAction } from "./data"; import type { ComponentDidCatchEmulator } from "./errors"; @@ -127,7 +127,7 @@ async function handleDataRequest( ); } catch (error: any) { let formattedError = (await platform.formatServerError?.(error)) || error; - return json(await serializeError(formattedError), { + response = json(await serializeError(formattedError), { status: 500, headers: { "X-Remix-Error": "unfortunately, yes" @@ -149,6 +149,15 @@ async function handleDataRequest( }); } + if (build.entry.module.handleDataRequest) { + clonedRequest = stripIndexParam(stripDataParam(request)); + return build.entry.module.handleDataRequest(response, { + request: clonedRequest, + context: loadContext, + params: routeMatch.params + }); + } + return response; }