diff --git a/packages/next/README.md b/packages/next/README.md new file mode 100644 index 000000000..f8b49ea57 --- /dev/null +++ b/packages/next/README.md @@ -0,0 +1,81 @@ +# Next.js Utilities / @kit/next + +This package provides utilities for working with Next.js. + +## Server Actions + +The `enhanceAction` function allows you to wrap a Next.js Server Action with additional functionality. + +```ts +import { enhanceAction } from '@kit/next/actions'; + +export const myServerAction = enhanceAction(async (data, user) => { + // "data" has been parsed with the schema + // and will correctly be typed as the schema type + // in the case below, data will be of type { id: number } + + // "user" is the user object from the session + + // if "captcha" is true, the action will require a captcha +}, { + captcha: true, + schema: z.object({ + id: z.number() + }), +}); +``` + +The `enhanceAction` function takes two arguments: +1. The action function +2. An options object + +The options object can contain the following properties: +- `captcha` - If true, the action will require a captcha to be passed to the body as `captchaToken` +- `schema` - A zod schema that the data will be validated against + +When successfully called, the action will return the result of the action function. + +1. The user will be automatically authenticated and the result will be passed as the second argument to the action function. +2. The data will be parsed/validated with the schema and passed as the first argument to the action function. +3. If the `captcha` option is true, the action will require a `captchaToken` to be passed in the body. + +The consumer can call the action like so: + +```ts +import { myServerAction } from 'path/to/myServerAction'; + +const result = await myServerAction({ id: 1 }); +``` + +or with an optional captcha token: + +```ts +import { myServerAction } from 'path/to/myServerAction'; + +const result = await myServerAction({ id: 1, captchaToken: 'captchaToken' }); +``` + +## Route Handlers + +The function `enhanceRouteHandler` allows you to wrap a Next.js API Route Handler with additional functionality. + +```ts +import { enhanceRouteHandler } from '@kit/next/routes'; + +export const POST = enhanceRouteHandler(({ request, body, user }) => { + // "body" has been parsed with the schema + // and will correctly be typed as the schema type + // in the case below, data will be of type { id: number } + + // "user" is the user object from the session + + // "request" is the raw request object passed by POST + + // if "captcha" is true, the action will require a captcha +}, { + captcha: true, + schema: z.object({ + id: z.number() + }), +}); +``` \ No newline at end of file diff --git a/packages/next/package.json b/packages/next/package.json index e20007b4b..34a9c2346 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -10,7 +10,8 @@ }, "prettier": "@kit/prettier-config", "exports": { - "./actions": "./src/actions/index.ts" + "./actions": "./src/actions/index.ts", + "./routes": "./src/routes/index.ts" }, "peerDependencies": { "@kit/auth": "workspace:*", diff --git a/packages/next/src/actions/index.ts b/packages/next/src/actions/index.ts index 3c07d4d25..7887573d4 100644 --- a/packages/next/src/actions/index.ts +++ b/packages/next/src/actions/index.ts @@ -2,24 +2,14 @@ import { redirect } from 'next/navigation'; import type { User } from '@supabase/supabase-js'; +import 'server-only'; import { z } from 'zod'; import { verifyCaptchaToken } from '@kit/auth/captcha/server'; import { requireUser } from '@kit/supabase/require-user'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; -const parseFactory = - (schema: T) => - (data: unknown): z.infer => { - try { - return schema.parse(data); - } catch (err) { - console.error(err); - - // handle error - throw new Error(`Invalid data: ${err}`); - } - }; +import { zodParseFactory } from '../utils'; /** * @@ -58,7 +48,7 @@ export function enhanceAction< } // validate the schema - const parsed = parseFactory(config.schema); + const parsed = zodParseFactory(config.schema); const data = parsed(params); // pass the data to the action diff --git a/packages/next/src/routes/index.ts b/packages/next/src/routes/index.ts new file mode 100644 index 000000000..eb71780b4 --- /dev/null +++ b/packages/next/src/routes/index.ts @@ -0,0 +1,105 @@ +import { redirect } from 'next/navigation'; +import { NextRequest, NextResponse } from 'next/server'; + +import { User } from '@supabase/supabase-js'; + +import 'server-only'; +import { z } from 'zod'; + +import { verifyCaptchaToken } from '@kit/auth/captcha/server'; +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-client'; + +import { zodParseFactory } from '../utils'; + +interface HandlerParams { + request: NextRequest; + user: User; + body: Body; +} + +/** + * Enhanced route handler function. + * + * This function takes a request and parameters object as arguments and returns a route handler function. + * The route handler function can be used to handle HTTP requests and apply additional enhancements + * based on the provided parameters. + * + * Usage: + * export const POST = enhanceRouteHandler( + * ({ request, body, user }) => { + * return new Response(`Hello, ${body.name}!`); + * }, + * { + * schema: z.object({ + * name: z.string(), + * }), + * }, + * ); + * + */ +export const enhanceRouteHandler = < + Body, + Schema extends z.ZodType, +>( + // Route handler function + handler: + | ((params: HandlerParams>) => NextResponse | Response) + | (( + params: HandlerParams>, + ) => Promise), + + // Parameters object + params?: { + captcha?: boolean; + schema?: Schema; + }, +) => { + /** + * Route handler function. + * + * This function takes a request object as an argument and returns a response object. + */ + return async function routeHandler(request: NextRequest) { + // Verify the captcha token if required + if (params?.captcha) { + const token = captchaTokenGetter(request); + + // If the captcha token is not provided, return a 400 response. + if (token) { + await verifyCaptchaToken(token); + } else { + return new Response('Captcha token is required', { status: 400 }); + } + } + + const client = getSupabaseRouteHandlerClient(); + const auth = await requireUser(client); + + // If the user is not authenticated, redirect to the specified URL. + if (auth.error) { + return redirect(auth.redirectTo); + } + + const user = auth.data; + + // clone the request to read the body + // so that we can pass it to the handler safely + let body = await request.clone().json(); + + if (params?.schema) { + body = zodParseFactory(params.schema)(body); + } + + // all good, call the handler with the request, body and user + return handler({ request, body, user }); + }; +}; + +/** + * Get the captcha token from the request headers. + * @param request + */ +function captchaTokenGetter(request: NextRequest) { + return request.headers.get('x-captcha-token'); +} diff --git a/packages/next/src/utils/index.ts b/packages/next/src/utils/index.ts new file mode 100644 index 000000000..35bfb0a6d --- /dev/null +++ b/packages/next/src/utils/index.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const zodParseFactory = + (schema: T) => + (data: unknown): z.infer => { + try { + return schema.parse(data); + } catch (err) { + console.error(err); + + // handle error + throw new Error(`Invalid data: ${err}`); + } + };