Skip to content

Commit

Permalink
feat(react-server): action redirect headers and context (#254)
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa authored Apr 2, 2024
1 parent 7dff313 commit 8b64cf3
Show file tree
Hide file tree
Showing 18 changed files with 299 additions and 37 deletions.
40 changes: 40 additions & 0 deletions packages/react-server/examples/basic/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions packages/react-server/examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default async function Layout(props: LayoutProps) {
"/test/not-found",
"/test/transition",
"/test/redirect",
"/test/session",
]}
/>
<div className="flex items-center gap-2 text-sm">
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { LayoutProps } from "@hiogawa/react-server/server";

export default function Layout(props: LayoutProps) {
return (
<div className="flex flex-col gap-2 p-2">
<h3 className="font-bold">Session Demo</h3>
<div>{props.children}</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-4 p-3 max-w-sm">
<form className="flex items-center gap-2" action={incrementCounter}>
<p>Counter: {getCounter()}</p>
<button
className="antd-btn antd-btn-default px-2"
name="delta"
value={-1}
>
-1
</button>
<button
className="antd-btn antd-btn-default px-2"
name="delta"
value={+1}
>
+1
</button>
<span className="text-colorTextLabel text-sm">(signin required)</span>
</form>
{session?.name && (
<div className="flex items-center gap-3">
<p>Hello, {session.name}!</p>
<form action={signout}>
<button className="antd-btn antd-btn-default px-2">Signout</button>
</form>
</div>
)}
{!session?.name && (
<div className="flex items-center gap-3 ">
<p>Hi, anonymous user!</p>
<Link
className="antd-btn antd-btn-default px-2"
href="/test/session/signin"
>
Signin
</Link>
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<form className="flex flex-col gap-2 p-2 max-w-sm" action={signin}>
<input
className="antd-input px-2"
name="name"
placeholder="Input name..."
required
/>
<button className="antd-btn antd-btn-primary">Signin</button>
</form>
);
}
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 15 additions & 1 deletion packages/react-server/examples/basic/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion packages/react-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hiogawa/react-server",
"version": "0.1.10",
"version": "0.1.11",
"license": "MIT",
"type": "module",
"exports": {
Expand Down
16 changes: 10 additions & 6 deletions packages/react-server/src/entry/react-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,26 +56,23 @@ 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: {},
};
const stream = reactServerDomServer.renderToReadableStream(data, {});
return new Response(stream, {
headers: {
...errorCtx.headers,
"content-type": "text/x-component; charset=utf-8",
},
});
}
// TODO: general action error handling?
return new Response(null, {
status: errorCtx.status,
headers: errorCtx.redirectLocation
? {
location: errorCtx.redirectLocation,
}
: {},
headers: errorCtx.headers,
});
}
}
Expand Down Expand Up @@ -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 };
}
20 changes: 10 additions & 10 deletions packages/react-server/src/entry/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
19 changes: 11 additions & 8 deletions packages/react-server/src/features/router/client.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -25,14 +26,16 @@ export function ServerActionRedirectHandler() {
const ctx = React.useContext(LayoutStateContext);
const data = React.use(ctx.data);

if (data.action?.error?.redirectLocation) {
return (
<RedirectHandler
suspensionKey={data.action?.error}
redirectLocation={data.action?.error.redirectLocation}
/>
);
if (data.action?.error) {
const redirect = isRedirectError(data.action.error);
if (redirect) {
return (
<RedirectHandler
suspensionKey={data.action.error}
redirectLocation={redirect.location}
/>
);
}
}

return null;
}
Loading

0 comments on commit 8b64cf3

Please sign in to comment.