UNPKG

@arcjet/node

Version:

Arcjet SDK for Node.js

246 lines (243 loc) 8.9 kB
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'; import { readBody } from '@arcjet/body'; // An object with getters that access the `process.env.SOMEVAR` values directly. // This allows bundlers to replace the dot-notation access with string literals // while still allowing dynamic access in runtime environments. const env = { get FLY_APP_NAME() { return process.env.FLY_APP_NAME; }, get VERCEL() { return process.env.VERCEL; }, get RENDER() { return process.env.RENDER; }, get MODE() { return process.env.MODE; }, get NODE_ENV() { return process.env.NODE_ENV; }, get ARCJET_KEY() { return process.env.ARCJET_KEY; }, get ARCJET_ENV() { return process.env.ARCJET_ENV; }, get ARCJET_LOG_LEVEL() { return process.env.ARCJET_LOG_LEVEL; }, get ARCJET_BASE_URL() { return process.env.ARCJET_BASE_URL; }, }; // 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"; } /** * Create a remote client. * * @param options * Configuration (optional). * @returns * Client. */ function createRemoteClient(options) { const url = options?.baseUrl ?? baseUrl(env); const timeout = options?.timeout ?? (isDevelopment(env) ? 1000 : 500); // Transport is the HTTP client that the client uses to make requests. const transport = createTransport(url); const sdkStack = "NODEJS"; const sdkVersion = "1.0.0-beta.13"; return createClient({ transport, baseUrl: url, timeout, sdkStack, sdkVersion, }); } function cookiesToString(cookies) { if (typeof cookies === "undefined") { return ""; } // This should never be the case with a Node.js cookie header, but we are safe if (Array.isArray(cookies)) { return cookies.join("; "); } return cookies; } /** * Create a new Node.js integration of Arcjet. * * > 👉 **Tip**: * > build your initial base client with as many rules as possible outside of a * > request handler; * > if you need more rules inside handlers later then you can call `withRule()` * > on that base client. * * @template Rules * List of rules. * @template Characteristics * Characteristics to track a user by. * @param options * Configuration. * @returns * Node.js integration of Arcjet. */ function arcjet(options) { const client = options.client ?? createRemoteClient(); const log = options.log ? options.log : new Logger({ level: logLevel(env), }); const proxies = Array.isArray(options.proxies) ? options.proxies.map(parseProxy) : undefined; if (isDevelopment(env)) { log.warn("Arcjet will use 127.0.0.1 when missing public IP address in development mode"); } function toArcjetRequest(request, props) { // We pull the cookies from the request before wrapping them in ArcjetHeaders const cookies = cookiesToString(request.headers?.cookie); // We construct an ArcjetHeaders to normalize over Headers const headers = new ArcjetHeaders(request.headers); let ip = findIp({ socket: request.socket, headers, }, { platform: platform(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(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 = ""; 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 ?? ""; } return { ...props, 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 request.body is present then the body was likely read by a package like express' `body-parser`. // If it's not present then we attempt to read the bytes from the IncomingMessage ourselves. 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") { return JSON.stringify(request.body); } if (typeof request.on === "function" && typeof request.removeListener === "function") { let expectedLength; // TODO: This shouldn't need to build headers again but the type // for `req` above is overly relaxed const headers = new ArcjetHeaders(request.headers); const expectedLengthStr = headers.get("content-length"); if (typeof expectedLengthStr === "string") { try { expectedLength = parseInt(expectedLengthStr, 10); } catch { // If the expected length couldn't be parsed we'll just not set one. } } // Awaited to throw if it rejects and we'll just return undefined const body = await readBody(request, { // We will process 1mb bodies limit: 1048576, expectedLength, }); return body; } 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); } export { createRemoteClient, arcjet as default };