From fd90669d976125602871d06a40e60dc60d81dd03 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 2 Jul 2024 14:19:18 +0900 Subject: [PATCH 1/7] feat: api routes (wip) --- .../react-server/src/entry/react-server.tsx | 4 +++ .../src/features/router/api-route.ts | 31 +++++++++++++++++++ .../src/features/router/server.tsx | 4 ++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 packages/react-server/src/features/router/api-route.ts diff --git a/packages/react-server/src/entry/react-server.tsx b/packages/react-server/src/entry/react-server.tsx index b3cb89061..fdb60d84a 100644 --- a/packages/react-server/src/entry/react-server.tsx +++ b/packages/react-server/src/entry/react-server.tsx @@ -59,6 +59,9 @@ export const handler: ReactServerHandler = async (ctx) => { const handled = handleTrailingSlash(new URL(ctx.request.url)); if (handled) return handled; + const handledApi = await handleApiRoutes(router.tree, ctx.request); + if (handledApi) return handledApi; + // extract stream request details const { url, request, isStream, streamParam } = unwrapStreamRequest( ctx.request, @@ -152,6 +155,7 @@ const reactServerOnError: RenderToReadableStreamOptions["onError"] = ( // @ts-ignore untyped virtual import serverRoutes from "virtual:server-routes"; +import { handleApiRoutes } from "../features/router/api-route"; export const router = generateRouteModuleTree(serverRoutes); diff --git a/packages/react-server/src/features/router/api-route.ts b/packages/react-server/src/features/router/api-route.ts new file mode 100644 index 000000000..98e421736 --- /dev/null +++ b/packages/react-server/src/features/router/api-route.ts @@ -0,0 +1,31 @@ +import type { RouteModuleTree } from "./server"; +import type { MatchParams } from "./tree"; + +// https://nextjs.org/docs/app/api-reference/file-conventions/route + +export const API_METHODS = [ + "GET", + "HEAD", + "POST", + "PUT", + "DELETE", + "PATCH", +] as const; + +type ApiMethod = (typeof API_METHODS)[number]; + +type ApiHandler = ( + request: Request, + context: { params: MatchParams }, +) => Promise | Response; + +export type ApiRouteMoudle = Record; + +export async function handleApiRoutes( + tree: RouteModuleTree, + request: Request, +): Promise { + tree; + request; + return; +} diff --git a/packages/react-server/src/features/router/server.tsx b/packages/react-server/src/features/router/server.tsx index fc2faf8db..65c442a6b 100644 --- a/packages/react-server/src/features/router/server.tsx +++ b/packages/react-server/src/features/router/server.tsx @@ -3,6 +3,7 @@ import React from "react"; import { type ReactServerErrorContext, createError } from "../../lib/error"; import { renderMetadata } from "../meta/server"; import type { Metadata } from "../meta/utils"; +import type { ApiRouteMoudle } from "./api-route"; import { type MatchNodeEntry, type TreeNode, @@ -36,11 +37,12 @@ export interface RouteModule { template?: { default: React.FC<{ children?: React.ReactNode }>; }; + route?: ApiRouteMoudle; } export type RouteModuleKey = keyof RouteModule; -type RouteModuleTree = TreeNode; +export type RouteModuleTree = TreeNode; export function generateRouteModuleTree(globEntries: Record) { const { tree, entries } = createFsRouteTree(globEntries); From 5bb02ca34ea73480f4b2e54ea931d145d83c8fcb Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 2 Jul 2024 14:30:40 +0900 Subject: [PATCH 2/7] wip: handleApiRoutes --- .../react-server/src/features/router/api-route.ts | 14 +++++++++++--- packages/react-server/src/plugin/index.ts | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/react-server/src/features/router/api-route.ts b/packages/react-server/src/features/router/api-route.ts index 98e421736..aa7aa733b 100644 --- a/packages/react-server/src/features/router/api-route.ts +++ b/packages/react-server/src/features/router/api-route.ts @@ -1,5 +1,5 @@ import type { RouteModuleTree } from "./server"; -import type { MatchParams } from "./tree"; +import { type MatchParams, matchRouteTree, toMatchParamsObject } from "./tree"; // https://nextjs.org/docs/app/api-reference/file-conventions/route @@ -25,7 +25,15 @@ export async function handleApiRoutes( tree: RouteModuleTree, request: Request, ): Promise { - tree; - request; + const method = request.method as ApiMethod; + const url = new URL(request.url); + const { matches } = matchRouteTree(tree, url.pathname); + for (const m of matches) { + const handler = m.type === "page" && m.node.value?.route?.[method]; + if (handler) { + const params = toMatchParamsObject(m.params); + return handler(request, { params }); + } + } return; } diff --git a/packages/react-server/src/plugin/index.ts b/packages/react-server/src/plugin/index.ts index a258fa35a..b922d70aa 100644 --- a/packages/react-server/src/plugin/index.ts +++ b/packages/react-server/src/plugin/index.ts @@ -172,7 +172,7 @@ export function vitePluginReactServer(options?: { createVirtualPlugin("server-routes", () => { return ` const glob = import.meta.glob( - "/${routeDir}/**/(page|layout|error|not-found|loading|template).(js|jsx|ts|tsx)", + "/${routeDir}/**/(page|layout|error|not-found|loading|template|route).(js|jsx|ts|tsx)", { eager: true }, ); export default Object.fromEntries( From e077116759bd6cbff11ea5699e37371d7a242bb9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 2 Jul 2024 15:03:39 +0900 Subject: [PATCH 3/7] chore: add demo --- .../src/routes/test/api/dynamic/[id]/route.ts | 17 +++++++++++++++++ .../basic/src/routes/test/api/page.tsx | 18 ++++++++++++++++++ .../basic/src/routes/test/api/static/route.ts | 17 +++++++++++++++++ .../examples/basic/src/routes/test/layout.tsx | 1 + .../react-server/src/features/router/tree.ts | 2 +- 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 packages/react-server/examples/basic/src/routes/test/api/dynamic/[id]/route.ts create mode 100644 packages/react-server/examples/basic/src/routes/test/api/page.tsx create mode 100644 packages/react-server/examples/basic/src/routes/test/api/static/route.ts diff --git a/packages/react-server/examples/basic/src/routes/test/api/dynamic/[id]/route.ts b/packages/react-server/examples/basic/src/routes/test/api/dynamic/[id]/route.ts new file mode 100644 index 000000000..ec7cec4d2 --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/api/dynamic/[id]/route.ts @@ -0,0 +1,17 @@ +export async function GET(request: Request, context: unknown) { + return Response.json({ + route: "/test/api/dynamic/[id]", + method: "GET", + pathname: new URL(request.url).pathname, + context, + }); +} + +export async function POST(request: Request, context: unknown) { + return Response.json({ + route: "/test/api/dynamic/[id]", + method: "POST", + pathname: new URL(request.url).pathname, + context, + }); +} diff --git a/packages/react-server/examples/basic/src/routes/test/api/page.tsx b/packages/react-server/examples/basic/src/routes/test/api/page.tsx new file mode 100644 index 000000000..db8747036 --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/api/page.tsx @@ -0,0 +1,18 @@ +export default function Page() { + return ( +
+

Test API routes

+ {links.map((href) => ( + + {href} + + ))} +
+ ); +} + +const links = [ + "/test/api/static", + "/test/api/dynamic/hello", + "/test/api/not-found", +]; diff --git a/packages/react-server/examples/basic/src/routes/test/api/static/route.ts b/packages/react-server/examples/basic/src/routes/test/api/static/route.ts new file mode 100644 index 000000000..96d557763 --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/api/static/route.ts @@ -0,0 +1,17 @@ +export async function GET(request: Request, context: unknown) { + return Response.json({ + route: "/test/api/static", + method: "GET", + pathname: new URL(request.url).pathname, + context, + }); +} + +export async function POST(request: Request, context: unknown) { + return Response.json({ + route: "/test/api/static", + method: "POST", + pathname: new URL(request.url).pathname, + context, + }); +} 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 8db0694cf..3a12de441 100644 --- a/packages/react-server/examples/basic/src/routes/test/layout.tsx +++ b/packages/react-server/examples/basic/src/routes/test/layout.tsx @@ -28,6 +28,7 @@ export default async function Layout(props: LayoutProps) { "/test/cache", "/test/metadata", "/test/template", + "/test/api", ]} />
diff --git a/packages/react-server/src/features/router/tree.ts b/packages/react-server/src/features/router/tree.ts index 0603452b0..9ba853775 100644 --- a/packages/react-server/src/features/router/tree.ts +++ b/packages/react-server/src/features/router/tree.ts @@ -10,7 +10,7 @@ export function createFsRouteTree(globEntries: Record): { const entries: Record = {}; for (const [k, v] of Object.entries(globEntries)) { const m = k.match( - /^(.*)\/(page|layout|error|not-found|loading|template)\.\w*$/, + /^(.*)\/(page|layout|error|not-found|loading|template|route)\.\w*$/, ); tinyassert(m && 1 in m && 2 in m); const pathname = m[1] || "/"; From ccb1d2382b04be260bafbd56ecc9970875070f78 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 2 Jul 2024 15:11:05 +0900 Subject: [PATCH 4/7] test: add e2e --- .../examples/basic/e2e/basic.test.ts | 54 ++++++++++++++++++- .../src/routes/test/api/dynamic/[id]/route.ts | 1 + .../basic/src/routes/test/api/static/route.ts | 1 + 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts index 2868f7648..96e2052e8 100644 --- a/packages/react-server/examples/basic/e2e/basic.test.ts +++ b/packages/react-server/examples/basic/e2e/basic.test.ts @@ -1,4 +1,4 @@ -import { type Page, expect, test } from "@playwright/test"; +import { type APIResponse, type Page, expect, test } from "@playwright/test"; import { checkNoError, editFile, @@ -1256,3 +1256,55 @@ test("server assses", async ({ page }) => { await expect(page.getByTestId("js-import")).toHaveScreenshot(); await expect(page.getByTestId("css-url")).toHaveScreenshot(); }); + +test.only("api routes", async ({ request }) => { + { + const res = await request.get("/test/api/static"); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ + route: "/test/api/static", + method: "GET", + pathname: "/test/api/static", + context: { params: {} }, + }); + } + + { + const res = await request.post("/test/api/static", { + data: "hey", + }); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ + route: "/test/api/static", + method: "POST", + pathname: "/test/api/static", + text: "hey", + context: { params: {} }, + }); + } + + { + const res = await request.get("/test/api/dynamic/hello"); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ + route: "/test/api/dynamic/[id]", + method: "GET", + pathname: "/test/api/dynamic/hello", + context: { params: { id: "hello" } }, + }); + } + + { + const res = await request.post("/test/api/dynamic/hello", { + data: "hey", + }); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ + route: "/test/api/dynamic/[id]", + method: "POST", + pathname: "/test/api/dynamic/hello", + text: "hey", + context: { params: { id: "hello" } }, + }); + } +}); diff --git a/packages/react-server/examples/basic/src/routes/test/api/dynamic/[id]/route.ts b/packages/react-server/examples/basic/src/routes/test/api/dynamic/[id]/route.ts index ec7cec4d2..af5218a7a 100644 --- a/packages/react-server/examples/basic/src/routes/test/api/dynamic/[id]/route.ts +++ b/packages/react-server/examples/basic/src/routes/test/api/dynamic/[id]/route.ts @@ -12,6 +12,7 @@ export async function POST(request: Request, context: unknown) { route: "/test/api/dynamic/[id]", method: "POST", pathname: new URL(request.url).pathname, + text: await request.text(), context, }); } diff --git a/packages/react-server/examples/basic/src/routes/test/api/static/route.ts b/packages/react-server/examples/basic/src/routes/test/api/static/route.ts index 96d557763..ec5497a31 100644 --- a/packages/react-server/examples/basic/src/routes/test/api/static/route.ts +++ b/packages/react-server/examples/basic/src/routes/test/api/static/route.ts @@ -12,6 +12,7 @@ export async function POST(request: Request, context: unknown) { route: "/test/api/static", method: "POST", pathname: new URL(request.url).pathname, + text: await request.text(), context, }); } From 9fe010a9e275d21792949b3138f61c994f298c63 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 2 Jul 2024 15:11:43 +0900 Subject: [PATCH 5/7] chore: lint --- packages/react-server/src/entry/react-server.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-server/src/entry/react-server.tsx b/packages/react-server/src/entry/react-server.tsx index fdb60d84a..ca158ba77 100644 --- a/packages/react-server/src/entry/react-server.tsx +++ b/packages/react-server/src/entry/react-server.tsx @@ -1,6 +1,7 @@ import { createDebug, objectMapValues, objectPick } from "@hiogawa/utils"; import type { RenderToReadableStreamOptions } from "react-dom/server"; import ReactServer from "react-server-dom-webpack/server.edge"; +import { handleApiRoutes } from "../features/router/api-route"; import { generateRouteModuleTree, renderRouteMap, @@ -155,7 +156,6 @@ const reactServerOnError: RenderToReadableStreamOptions["onError"] = ( // @ts-ignore untyped virtual import serverRoutes from "virtual:server-routes"; -import { handleApiRoutes } from "../features/router/api-route"; export const router = generateRouteModuleTree(serverRoutes); From e0d43188a969149b7b3e660bb5276a57e1305412 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 2 Jul 2024 15:12:28 +0900 Subject: [PATCH 6/7] test: fix only --- packages/react-server/examples/basic/e2e/basic.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts index 96e2052e8..760c666fe 100644 --- a/packages/react-server/examples/basic/e2e/basic.test.ts +++ b/packages/react-server/examples/basic/e2e/basic.test.ts @@ -1257,7 +1257,7 @@ test("server assses", async ({ page }) => { await expect(page.getByTestId("css-url")).toHaveScreenshot(); }); -test.only("api routes", async ({ request }) => { +test("api routes", async ({ request }) => { { const res = await request.get("/test/api/static"); expect(res.status()).toBe(200); From 4ed93299e22c99979fddd82b73384600f33f7aa8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 2 Jul 2024 15:12:55 +0900 Subject: [PATCH 7/7] chore: lint --- packages/react-server/examples/basic/e2e/basic.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts index 760c666fe..e4287e73e 100644 --- a/packages/react-server/examples/basic/e2e/basic.test.ts +++ b/packages/react-server/examples/basic/e2e/basic.test.ts @@ -1,4 +1,4 @@ -import { type APIResponse, type Page, expect, test } from "@playwright/test"; +import { type Page, expect, test } from "@playwright/test"; import { checkNoError, editFile,