@arcjet/node
Version:
Arcjet SDK for Node.js
246 lines (243 loc) • 8.9 kB
JavaScript
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 };