Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: api routes #482

Merged
merged 7 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/react-server/examples/basic/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1256,3 +1256,55 @@ test("server assses", async ({ page }) => {
await expect(page.getByTestId("js-import")).toHaveScreenshot();
await expect(page.getByTestId("css-url")).toHaveScreenshot();
});

test("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" } },
});
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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,
text: await request.text(),
context,
});
}
18 changes: 18 additions & 0 deletions packages/react-server/examples/basic/src/routes/test/api/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export default function Page() {
return (
<div className="flex flex-col gap-2 p-2">
<h3>Test API routes</h3>
{links.map((href) => (
<a key={href} className="antd-link" href={href}>
{href}
</a>
))}
</div>
);
}

const links = [
"/test/api/static",
"/test/api/dynamic/hello",
"/test/api/not-found",
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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,
text: await request.text(),
context,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default async function Layout(props: LayoutProps) {
"/test/cache",
"/test/metadata",
"/test/template",
"/test/api",
]}
/>
<div className="flex items-center gap-2 text-sm">
Expand Down
4 changes: 4 additions & 0 deletions packages/react-server/src/entry/react-server.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -59,6 +60,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,
Expand Down
39 changes: 39 additions & 0 deletions packages/react-server/src/features/router/api-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { RouteModuleTree } from "./server";
import { type MatchParams, matchRouteTree, toMatchParamsObject } 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> | Response;

export type ApiRouteMoudle = Record<ApiMethod, ApiHandler>;

export async function handleApiRoutes(
tree: RouteModuleTree,
request: Request,
): Promise<Response | undefined> {
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;
}
4 changes: 3 additions & 1 deletion packages/react-server/src/features/router/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -36,11 +37,12 @@ export interface RouteModule {
template?: {
default: React.FC<{ children?: React.ReactNode }>;
};
route?: ApiRouteMoudle;
}

export type RouteModuleKey = keyof RouteModule;

type RouteModuleTree = TreeNode<RouteModule>;
export type RouteModuleTree = TreeNode<RouteModule>;

export function generateRouteModuleTree(globEntries: Record<string, any>) {
const { tree, entries } = createFsRouteTree<RouteModule>(globEntries);
Expand Down
2 changes: 1 addition & 1 deletion packages/react-server/src/features/router/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function createFsRouteTree<T>(globEntries: Record<string, unknown>): {
const entries: Record<string, T> = {};
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] || "/";
Expand Down
2 changes: 1 addition & 1 deletion packages/react-server/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down