UNPKG

@arcjet/next

Version:

Arcjet SDK for the Next.js framework

337 lines (334 loc) 12.9 kB
import { NextResponse } from 'next/server.js'; import { headers, cookies } from 'next/headers.js'; import core__default from 'arcjet'; export * from 'arcjet'; import findIP, { parseProxy } from '@arcjet/ip'; import ArcjetHeaders from '@arcjet/headers'; import { logLevel, isDevelopment, baseUrl, platform } from '@arcjet/env'; import { Logger } from '@arcjet/logger'; import { createClient } from '@arcjet/protocol/client.js'; import { createTransport } from '@arcjet/transport'; async function request() { const hdrs = await headers(); const cook = await cookies(); const cookieEntries = cook .getAll() .map((cookie) => [cookie.name, cookie.value]); return { headers: hdrs, cookies: Object.fromEntries(cookieEntries), }; } // TODO: Deduplicate with other packages function errorMessage(err) { if (err) { if (typeof err === "string") { return err; } if (typeof err === "object" && "message" in err && typeof err.message === "string") { return err.message; } } return "Unknown problem"; } function createRemoteClient(options) { // The base URL for the Arcjet API. Will default to the standard production // API unless environment variable `ARCJET_BASE_URL` is set. const url = options?.baseUrl ?? baseUrl(process.env); // The timeout for the Arcjet API in milliseconds. This is set to a low value // in production so calls fail open. const timeout = options?.timeout ?? (isDevelopment(process.env) ? 1000 : 500); // Transport is the HTTP client that the client uses to make requests. const transport = createTransport(url); const sdkStack = "NEXTJS"; const sdkVersion = "1.0.0-beta.10"; return createClient({ transport, baseUrl: url, timeout, sdkStack, sdkVersion, }); } function isIterable(val) { return typeof val?.[Symbol.iterator] === "function"; } function cookiesToArray(cookies) { if (typeof cookies === "undefined") { return []; } if (isIterable(cookies)) { return Array.from(cookies).map(([_, cookie]) => cookie); } else { return Object.entries(cookies).map(([name, value]) => ({ name, value: value ?? "", })); } } function cookiesToString(cookies) { // This is essentially the implementation of `RequestCookies#toString` in // Next.js but normalized for NextApiRequest cookies object return cookiesToArray(cookies) .map((v) => `${v.name}=${encodeURIComponent(v.value)}`) .join("; "); } /** * Create a new {@link ArcjetNext} client. Always build your initial client * outside of a request handler so it persists across requests. If you need to * augment a client inside a handler, call the `withRule()` function on the base * client. * * @param options - Arcjet configuration options to apply to all requests. */ function arcjet(options) { const client = options.client ?? createRemoteClient(); const log = options.log ? options.log : new Logger({ level: logLevel(process.env), }); const proxies = Array.isArray(options.proxies) ? options.proxies.map(parseProxy) : undefined; if (isDevelopment(process.env)) { log.warn("Arcjet will use 127.0.0.1 when missing public IP address in development mode"); } function toArcjetRequest(request, props) { // We construct an ArcjetHeaders to normalize over Headers const headers = new ArcjetHeaders(request.headers); let ip = findIP({ ip: request.ip, socket: request.socket, info: request.info, requestContext: request.requestContext, headers, }, { platform: platform(process.env), proxies }); if (ip === "") { // If the `ip` is empty but we're in development mode, we default the IP // so the request doesn't fail. if (isDevelopment(process.env)) { ip = "127.0.0.1"; } else { log.warn(`Client IP address is missing. If this is a dev environment set the ARCJET_ENV env var to "development"`); } } const method = request.method ?? ""; const host = headers.get("host") ?? ""; let path = ""; let query = ""; let protocol = ""; // TODO(#36): nextUrl has formatting logic when you `toString` but // we don't account for that here if (typeof request.nextUrl !== "undefined") { path = request.nextUrl.pathname ?? ""; if (typeof request.nextUrl.search !== "undefined") { query = request.nextUrl.search; } if (typeof request.nextUrl.protocol !== "undefined") { protocol = request.nextUrl.protocol; } } else { if (typeof request.socket?.encrypted !== "undefined") { protocol = request.socket.encrypted ? "https:" : "http:"; } else { protocol = "http:"; } // Do some very simple validation, but also try/catch around URL parsing if (typeof request.url !== "undefined" && request.url !== "" && host !== "") { try { const url = new URL(request.url, `${protocol}//${host}`); path = url.pathname; query = url.search; protocol = url.protocol; } catch { // If the parsing above fails, just set the path as whatever url we // received. path = request.url ?? ""; log.warn('Unable to parse URL. Using "%s" as `path`.', path); } } else { path = request.url ?? ""; } } const cookies = cookiesToString(request.cookies); const extra = {}; // If we're running on Vercel, we can add some extra information if (process.env["VERCEL"]) { // Vercel ID https://vercel.com/docs/concepts/edge-network/headers extra["vercel-id"] = headers.get("x-vercel-id") ?? ""; // Vercel deployment URL // https://vercel.com/docs/concepts/edge-network/headers extra["vercel-deployment-url"] = headers.get("x-vercel-deployment-url") ?? ""; // Vercel git commit SHA // https://vercel.com/docs/concepts/projects/environment-variables/system-environment-variables extra["vercel-git-commit-sha"] = process.env["VERCEL_GIT_COMMIT_SHA"] ?? ""; extra["vercel-git-commit-sha"] = process.env["VERCEL_GIT_COMMIT_SHA"] ?? ""; } return { ...props, ...extra, ip, method, protocol, host, path, headers, cookies, query, }; } function withClient(aj) { return Object.freeze({ withRule(rule) { const client = aj.withRule(rule); return withClient(client); }, async protect(request, ...[props]) { // TODO(#220): The generic manipulations get really mad here, so we cast // Further investigation makes it seem like it has something to do with // the definition of `props` in the signature but it's hard to track down const req = toArcjetRequest(request, props ?? {}); const getBody = async () => { try { if (typeof request.clone === "function") { const cloned = request.clone(); // Awaited to throw if it rejects and we'll just return undefined const body = await cloned.text(); return body; } else if (typeof request.body === "string") { return request.body; } else if (typeof request.body !== "undefined" && // BigInt cannot be serialized with JSON.stringify typeof request.body !== "bigint" && // The body will be null if there was no body with the request. // Reference: // https://nextjs.org/docs/pages/building-your-application/routing/api-routes#request-helpers request.body !== null) { return JSON.stringify(request.body); } else { log.warn("no body available"); return; } } catch (e) { log.error("failed to get request body: %s", errorMessage(e)); return; } }; return aj.protect({ getBody }, req); }, }); } const aj = core__default({ ...options, client, log }); return withClient(aj); } /** * Protects your Next.js application using Arcjet middleware. * * @param arcjet An instantiated Arcjet SDK * @param middleware Any existing middleware you'd like to be called after * Arcjet decides a request is allowed. * @returns If the request is allowed, the next middleware or handler will be * called. If the request is denied, a `Response` will be returned immediately * and the no further middleware or handlers will be called. */ function createMiddleware(arcjet, existingMiddleware) { return async function middleware(request, event) { const decision = await arcjet.protect(request); if (decision.isDenied()) { // TODO(#222): Content type negotiation using `Accept` header if (decision.reason.isRateLimit()) { return NextResponse.json({ code: 429, message: "Too Many Requests" }, { status: 429 }); } else { return NextResponse.json({ code: 403, message: "Forbidden" }, { status: 403 }); } } else { if (typeof existingMiddleware === "function") { return existingMiddleware(request, event); } else { return NextResponse.next(); } } }; } function isNextApiResponse(val) { if (val === null) { return false; } if (typeof val !== "object") { return false; } if (!("status" in val)) { return false; } if (!("json" in val)) { return false; } if (typeof val.status !== "function" || typeof val.json !== "function") { return false; } return true; } /** * Wraps a Next.js page route, edge middleware, or an API route running on the * Edge Runtime. * * @param arcjet An instantiated Arcjet SDK * @param handler The request handler to wrap * @returns If the request is allowed, the wrapped `handler` will be called. If * the request is denied, a `Response` will be returned based immediately and * the wrapped `handler` will never be called. */ function withArcjet(arcjet, handler) { return async (...args) => { const request = args[0]; const response = args[1]; const decision = await arcjet.protect(request); if (decision.isDenied()) { if (isNextApiResponse(response)) { // TODO(#222): Content type negotiation using `Accept` header if (decision.reason.isRateLimit()) { return response .status(429) .json({ code: 429, message: "Too Many Requests" }); } else { return response.status(403).json({ code: 403, message: "Forbidden" }); } } else { // TODO(#222): Content type negotiation using `Accept` header if (decision.reason.isRateLimit()) { return NextResponse.json({ code: 429, message: "Too Many Requests" }, { status: 429 }); } else { return NextResponse.json({ code: 403, message: "Forbidden" }, { status: 403 }); } } } else { return handler(...args); } }; } export { createMiddleware, createRemoteClient, arcjet as default, request, withArcjet };