From 8b64cf362b3619d9d536f26968701adca11bc7db Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 2 Apr 2024 18:18:24 +0900 Subject: [PATCH] feat(react-server): action redirect headers and context (#254) --- .../examples/basic/e2e/basic.test.ts | 40 +++++++++++++++ .../react-server/examples/basic/package.json | 2 + .../examples/basic/src/routes/test/layout.tsx | 1 + .../basic/src/routes/test/session/_action.tsx | 40 +++++++++++++++ .../basic/src/routes/test/session/layout.tsx | 10 ++++ .../basic/src/routes/test/session/page.tsx | 49 +++++++++++++++++++ .../src/routes/test/session/signin/page.tsx | 22 +++++++++ .../basic/src/routes/test/session/utils.ts | 39 +++++++++++++++ .../examples/basic/vite.config.ts | 16 +++++- packages/react-server/package.json | 2 +- .../react-server/src/entry/react-server.tsx | 16 +++--- packages/react-server/src/entry/server.tsx | 20 ++++---- .../src/features/router/client.tsx | 19 ++++--- .../features/server-action/react-server.tsx | 18 +++++++ .../src/lib/client/error-boundary.tsx | 7 +-- packages/react-server/src/lib/error.tsx | 28 ++++++++--- packages/react-server/src/server.ts | 1 + pnpm-lock.yaml | 6 +++ 18 files changed, 299 insertions(+), 37 deletions(-) create mode 100644 packages/react-server/examples/basic/src/routes/test/session/_action.tsx create mode 100644 packages/react-server/examples/basic/src/routes/test/session/layout.tsx create mode 100644 packages/react-server/examples/basic/src/routes/test/session/page.tsx create mode 100644 packages/react-server/examples/basic/src/routes/test/session/signin/page.tsx create mode 100644 packages/react-server/examples/basic/src/routes/test/session/utils.ts diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts index ee1ab0e78..cfc89d671 100644 --- a/packages/react-server/examples/basic/e2e/basic.test.ts +++ b/packages/react-server/examples/basic/e2e/basic.test.ts @@ -489,6 +489,46 @@ test("redirect server action @js", async ({ page }) => { await page.waitForURL("/test/redirect?ok=server-action"); }); +test("action context @js", async ({ page }) => { + checkNoError(page); + await page.goto("/test/session"); + await waitForHydration(page); + await testActionContext(page); +}); + +test("action context @nojs", async ({ browser }) => { + const page = await browser.newPage({ javaScriptEnabled: false }); + checkNoError(page); + await page.goto("/test/session"); + await testActionContext(page); +}); + +async function testActionContext(page: Page) { + await page.goto("/test/session"); + + // redirected from auth protected action + await page.getByText("Hi, anonymous user!").click(); + await page.getByRole("button", { name: "+1" }).click(); + await page.waitForURL("/test/session/signin"); + + // signin + await page.getByPlaceholder("Input name...").fill("asdf"); + await page.getByRole("button", { name: "Signin" }).click(); + await page.waitForURL("/test/session"); + + // try auth protected action + await page.getByText("Hello, asdf!").click(); + await page.getByText("Counter: 0").click(); + await page.getByRole("button", { name: "+1" }).click(); + await page.getByText("Counter: 1").click(); + await page.getByRole("button", { name: "-1" }).click(); + await page.getByText("Counter: 0").click(); + + // signout + await page.getByRole("button", { name: "Signout" }).click(); + await page.getByText("Hi, anonymous user!").click(); +} + async function setupCheckClientState(page: Page) { // setup client state await page.getByPlaceholder("test-input").fill("hello"); diff --git a/packages/react-server/examples/basic/package.json b/packages/react-server/examples/basic/package.json index ba4a33dd9..a836674e2 100644 --- a/packages/react-server/examples/basic/package.json +++ b/packages/react-server/examples/basic/package.json @@ -20,6 +20,7 @@ "@hiogawa/react-server": "latest", "@hiogawa/test-dep-server-component": "file:deps/server-component", "@hiogawa/test-dep-use-client": "file:deps/use-client", + "cookie": "^0.6.0", "react": "18.3.0-canary-14898b6a9-20240318", "react-dom": "18.3.0-canary-14898b6a9-20240318", "react-server-dom-webpack": "18.3.0-canary-14898b6a9-20240318", @@ -32,6 +33,7 @@ "@hiogawa/vite-plugin-ssr-middleware": "latest", "@iconify-json/ri": "^1.1.20", "@playwright/test": "^1.42.1", + "@types/cookie": "^0.6.0", "@types/react": "18.2.66", "@types/react-dom": "18.2.22", "@unocss/postcss": "^0.58.6", diff --git a/packages/react-server/examples/basic/src/routes/test/layout.tsx b/packages/react-server/examples/basic/src/routes/test/layout.tsx index 989a0c993..9bd35f8c2 100644 --- a/packages/react-server/examples/basic/src/routes/test/layout.tsx +++ b/packages/react-server/examples/basic/src/routes/test/layout.tsx @@ -19,6 +19,7 @@ export default async function Layout(props: LayoutProps) { "/test/not-found", "/test/transition", "/test/redirect", + "/test/session", ]} />
diff --git a/packages/react-server/examples/basic/src/routes/test/session/_action.tsx b/packages/react-server/examples/basic/src/routes/test/session/_action.tsx new file mode 100644 index 000000000..2caecb4ba --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/session/_action.tsx @@ -0,0 +1,40 @@ +"use server"; + +import { getActionContext, redirect } from "@hiogawa/react-server/server"; +import { tinyassert } from "@hiogawa/utils"; +import { getSession, setSession } from "./utils"; + +export async function signin(formData: FormData) { + // TODO: return error on invalid input + const name = formData.get("name"); + tinyassert(typeof name === "string"); + + throw redirect("/test/session", { + headers: { + "set-cookie": setSession({ name }), + }, + }); +} + +export async function signout() { + throw redirect("/test/session", { + headers: { + "set-cookie": setSession({}), + }, + }); +} + +let counter = 0; + +export function getCounter() { + return counter; +} + +export async function incrementCounter(formData: FormData) { + const ctx = getActionContext(formData); + const session = getSession(ctx.request); + if (!session?.name) { + throw redirect("/test/session/signin"); + } + counter += Number(formData.get("delta")); +} diff --git a/packages/react-server/examples/basic/src/routes/test/session/layout.tsx b/packages/react-server/examples/basic/src/routes/test/session/layout.tsx new file mode 100644 index 000000000..63a0227ce --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/session/layout.tsx @@ -0,0 +1,10 @@ +import type { LayoutProps } from "@hiogawa/react-server/server"; + +export default function Layout(props: LayoutProps) { + return ( +
+

Session Demo

+
{props.children}
+
+ ); +} diff --git a/packages/react-server/examples/basic/src/routes/test/session/page.tsx b/packages/react-server/examples/basic/src/routes/test/session/page.tsx new file mode 100644 index 000000000..04667c247 --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/session/page.tsx @@ -0,0 +1,49 @@ +import { Link } from "@hiogawa/react-server/client"; +import type { PageProps } from "@hiogawa/react-server/server"; +import { getCounter, incrementCounter, signout } from "./_action"; +import { getSession } from "./utils"; + +export default function Page(props: PageProps) { + const session = getSession(props.request); + return ( +
+
+

Counter: {getCounter()}

+ + + (signin required) +
+ {session?.name && ( +
+

Hello, {session.name}!

+
+ +
+
+ )} + {!session?.name && ( +
+

Hi, anonymous user!

+ + Signin + +
+ )} +
+ ); +} diff --git a/packages/react-server/examples/basic/src/routes/test/session/signin/page.tsx b/packages/react-server/examples/basic/src/routes/test/session/signin/page.tsx new file mode 100644 index 000000000..4bf75ea3d --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/session/signin/page.tsx @@ -0,0 +1,22 @@ +import { type PageProps, redirect } from "@hiogawa/react-server/server"; +import { signin } from "../_action"; +import { getSession } from "../utils"; + +export default function Page(props: PageProps) { + const session = getSession(props.request); + if (session?.name) { + throw redirect("/test/session"); + } + + return ( +
+ + +
+ ); +} diff --git a/packages/react-server/examples/basic/src/routes/test/session/utils.ts b/packages/react-server/examples/basic/src/routes/test/session/utils.ts new file mode 100644 index 000000000..34e856c5b --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/session/utils.ts @@ -0,0 +1,39 @@ +import { objectHas, tinyassert } from "@hiogawa/utils"; +import * as cookieLib from "cookie"; + +// mini cookie session utils + +type SessionData = { + name?: string; +}; + +const SESSION_KEY = "__session"; + +export function getSession(request: Request): SessionData | undefined { + const raw = request.headers.get("cookie"); + if (raw) { + const parsed = cookieLib.parse(raw); + const token = parsed[SESSION_KEY]; + if (token) { + try { + const data = JSON.parse(decodeURIComponent(token)); + tinyassert(objectHas(data, "name")); + tinyassert(typeof data.name === "string"); + return { name: data.name }; + } catch (e) {} + } + } +} + +export function setSession(data: SessionData) { + const token = encodeURIComponent(JSON.stringify(data)); + const cookie = cookieLib.serialize(SESSION_KEY, token, { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 14 * 24 * 60 * 60, // two week + }); + tinyassert(cookie.length < 2 ** 12, "too large cookie session"); + return cookie; +} diff --git a/packages/react-server/examples/basic/vite.config.ts b/packages/react-server/examples/basic/vite.config.ts index b153fa115..d42616e07 100644 --- a/packages/react-server/examples/basic/vite.config.ts +++ b/packages/react-server/examples/basic/vite.config.ts @@ -14,7 +14,21 @@ export default defineConfig({ react(), unocss(), vitePluginReactServer({ - plugins: [testVitePluginVirtual()], + plugins: [ + testVitePluginVirtual(), + { + name: "cusotm-react-server-config", + config() { + return { + ssr: { + optimizeDeps: { + include: ["cookie"], + }, + }, + }; + }, + }, + ], }), vitePluginLogger(), vitePluginSsrMiddleware({ diff --git a/packages/react-server/package.json b/packages/react-server/package.json index c360e1bc4..24e67e978 100644 --- a/packages/react-server/package.json +++ b/packages/react-server/package.json @@ -1,6 +1,6 @@ { "name": "@hiogawa/react-server", - "version": "0.1.10", + "version": "0.1.11", "license": "MIT", "type": "module", "exports": { diff --git a/packages/react-server/src/entry/react-server.tsx b/packages/react-server/src/entry/react-server.tsx index 90c0e54fb..cf8bb8319 100644 --- a/packages/react-server/src/entry/react-server.tsx +++ b/packages/react-server/src/entry/react-server.tsx @@ -11,6 +11,7 @@ import { type ServerRouterData, createLayoutContentRequest, } from "../features/router/utils"; +import { actionContextMap } from "../features/server-action/react-server"; import { ejectActionId } from "../features/server-action/utils"; import { unwrapRscRequest } from "../features/server-component/utils"; import { createBundlerConfig } from "../features/use-client/react-server"; @@ -55,7 +56,7 @@ export const handler: ReactServerHandler = async (ctx) => { const errorCtx = getErrorContext(e) ?? DEFAULT_ERROR_CONTEXT; if (rscOnly) { // returns empty layout to keep current layout and - // let browser initiate clie-side navigation for redirection error + // let browser initiate client-side navigation for redirection error const data: ServerRouterData = { action: { error: errorCtx }, layout: {}, @@ -63,6 +64,7 @@ export const handler: ReactServerHandler = async (ctx) => { const stream = reactServerDomServer.renderToReadableStream(data, {}); return new Response(stream, { headers: { + ...errorCtx.headers, "content-type": "text/x-component; charset=utf-8", }, }); @@ -70,11 +72,7 @@ export const handler: ReactServerHandler = async (ctx) => { // TODO: general action error handling? return new Response(null, { status: errorCtx.status, - headers: errorCtx.redirectLocation - ? { - location: errorCtx.redirectLocation, - } - : {}, + headers: errorCtx.headers, }); } } @@ -188,6 +186,12 @@ async function actionHandler({ request }: { request: Request }) { action = mod[name]; } + const responseHeaders = new Headers(); + actionContextMap.set(formData, { request, responseHeaders }); + // TODO: action return value? await action(formData); + + // TODO: write headers on successfull action + return { responseHeaders }; } diff --git a/packages/react-server/src/entry/server.tsx b/packages/react-server/src/entry/server.tsx index 0f78648c9..b8a05e461 100644 --- a/packages/react-server/src/entry/server.tsx +++ b/packages/react-server/src/entry/server.tsx @@ -9,7 +9,12 @@ import { ssrImportPromiseCache, } from "../features/use-client/server"; import { Router, RouterContext } from "../lib/client/router"; -import { getErrorContext, getStatusText } from "../lib/error"; +import { + DEFAULT_ERROR_CONTEXT, + getErrorContext, + getStatusText, + isRedirectError, +} from "../lib/error"; import { __global } from "../lib/global"; import { ENTRY_REACT_SERVER_WRAPPER, @@ -121,16 +126,11 @@ export async function renderHtml( }, }); } catch (e) { - const ctx = getErrorContext(e); - status = ctx?.status ?? 500; - if (ctx?.redirectLocation) { - return new Response(null, { - status, - headers: { - location: ctx.redirectLocation, - }, - }); + const ctx = getErrorContext(e) ?? DEFAULT_ERROR_CONTEXT; + if (isRedirectError(ctx)) { + return new Response(null, { status: ctx.status, headers: ctx.headers }); } + status = ctx.status; // render empty as error fallback and // let browser render full CSR instead of hydration // which will replay client error boudnary from RSC error diff --git a/packages/react-server/src/features/router/client.tsx b/packages/react-server/src/features/router/client.tsx index cbf9cb474..b351d2f4c 100644 --- a/packages/react-server/src/features/router/client.tsx +++ b/packages/react-server/src/features/router/client.tsx @@ -1,5 +1,6 @@ import React from "react"; import { RedirectHandler } from "../../lib/client/error-boundary"; +import { isRedirectError } from "../../lib/error"; import { __global } from "../../lib/global"; import { LAYOUT_ROOT_NAME, type ServerRouterData } from "./utils"; @@ -25,14 +26,16 @@ export function ServerActionRedirectHandler() { const ctx = React.useContext(LayoutStateContext); const data = React.use(ctx.data); - if (data.action?.error?.redirectLocation) { - return ( - - ); + if (data.action?.error) { + const redirect = isRedirectError(data.action.error); + if (redirect) { + return ( + + ); + } } - return null; } diff --git a/packages/react-server/src/features/server-action/react-server.tsx b/packages/react-server/src/features/server-action/react-server.tsx index 2e3e1847d..51cfbb427 100644 --- a/packages/react-server/src/features/server-action/react-server.tsx +++ b/packages/react-server/src/features/server-action/react-server.tsx @@ -1,3 +1,5 @@ +import { tinyassert } from "@hiogawa/utils"; + export function createServerReference(id: string, action: Function): React.FC { return Object.defineProperties(action, { $$typeof: { @@ -16,3 +18,19 @@ export function createServerReference(id: string, action: Function): React.FC { }, }) as any; } + +// Builtin action context system based on FormData identity. +// Users can easilty setup own AsyncLocalStorage based request context using custom handler, +// but we don't make it as a builtin feature until async hooks are properly supported on Stackblitz. +export const actionContextMap = new WeakMap(); + +export interface ActionContext { + request: Request; + responseHeaders: Headers; +} + +export function getActionContext(formData: FormData) { + const ctx = actionContextMap.get(formData); + tinyassert(ctx); + return ctx; +} diff --git a/packages/react-server/src/lib/client/error-boundary.tsx b/packages/react-server/src/lib/client/error-boundary.tsx index f47cb0459..29615748d 100644 --- a/packages/react-server/src/lib/client/error-boundary.tsx +++ b/packages/react-server/src/lib/client/error-boundary.tsx @@ -1,6 +1,6 @@ import { tinyassert } from "@hiogawa/utils"; import React from "react"; -import { getErrorContext, getStatusText } from "../error"; +import { getErrorContext, getStatusText, isRedirectError } from "../error"; import type { ErrorPageProps } from "../router"; import { useRouter } from "./router"; @@ -89,8 +89,9 @@ export class RedirectBoundary extends React.Component { static getDerivedStateFromError(error: Error) { if (!import.meta.env.SSR) { const ctx = getErrorContext(error); - if (ctx?.redirectLocation) { - return { redirectLocation: ctx.redirectLocation }; + const redirect = ctx && isRedirectError(ctx); + if (redirect) { + return { redirectLocation: redirect.location }; } } throw error; diff --git a/packages/react-server/src/lib/error.tsx b/packages/react-server/src/lib/error.tsx index 6b602a8b6..7c5157b63 100644 --- a/packages/react-server/src/lib/error.tsx +++ b/packages/react-server/src/lib/error.tsx @@ -1,12 +1,7 @@ // TODO: custom (de)serialization? export interface ReactServerErrorContext { status: number; - // TODO: hide from public typing? - redirectLocation?: string; -} - -export interface ReactServerRedirectErrorContext { - redirectLocation: string; + headers?: Record; } export class ReactServerDigestError extends Error { @@ -20,8 +15,25 @@ export function createError(ctx: ReactServerErrorContext) { return new ReactServerDigestError(digest); } -export function redirect(location: string, status: number = 302) { - return createError({ status, redirectLocation: location }); +export function redirect( + location: string, + options?: { status?: number; headers?: Record }, +) { + return createError({ + status: options?.status ?? 302, + headers: { + ...options?.headers, + location, + }, + }); +} + +export function isRedirectError(ctx: ReactServerErrorContext) { + const location = ctx.headers?.["location"]; + if (300 <= ctx.status && ctx.status <= 399 && typeof location === "string") { + return { location }; + } + return false; } export function getErrorContext( diff --git a/packages/react-server/src/server.ts b/packages/react-server/src/server.ts index 4ac764182..982a74a6a 100644 --- a/packages/react-server/src/server.ts +++ b/packages/react-server/src/server.ts @@ -4,3 +4,4 @@ export { redirect, type ReactServerErrorContext, } from "./lib/error"; +export { getActionContext } from "./features/server-action/react-server"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 172d0d578..54f597edd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@hiogawa/test-dep-use-client': specifier: file:deps/use-client version: file:packages/react-server/examples/basic/deps/use-client(react@18.3.0-canary-14898b6a9-20240318) + cookie: + specifier: ^0.6.0 + version: 0.6.0 react: specifier: 18.3.0-canary-14898b6a9-20240318 version: 18.3.0-canary-14898b6a9-20240318 @@ -136,6 +139,9 @@ importers: '@playwright/test': specifier: ^1.42.1 version: 1.42.1 + '@types/cookie': + specifier: ^0.6.0 + version: 0.6.0 '@types/react': specifier: 18.2.66 version: 18.2.66