@arcjet/next
Version:
Arcjet SDK for the Next.js framework
337 lines (334 loc) • 12.9 kB
JavaScript
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 };