@arcjet/decorate 1.0.0-alpha.9 → 1.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +52 -26
  2. package/index.d.ts +1 -1
  3. package/index.js +13 -12
  4. package/package.json +21 -19
  5. package/index.ts +0 -312
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <a href="https://arcjet.com" target="_arcjet-home">
2
2
  <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="https://arcjet.com/arcjet-logo-dark-planet-arrival.svg">
4
- <img src="https://arcjet.com/arcjet-logo-light-planet-arrival.svg" alt="Arcjet Logo" height="144" width="auto">
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://arcjet.com/logo/arcjet-dark-lockup-voyage-horizontal.svg">
4
+ <img src="https://arcjet.com/logo/arcjet-light-lockup-voyage-horizontal.svg" alt="Arcjet Logo" height="128" width="auto">
5
5
  </picture>
6
6
  </a>
7
7
 
@@ -18,53 +18,79 @@
18
18
 
19
19
  [Arcjet][arcjet] utilities for decorating responses with information.
20
20
 
21
- ## Installation
21
+ - [npm package (`@arcjet/decorate`)](https://www.npmjs.com/package/@arcjet/decorate)
22
+ - [GitHub source code (`decorate/` in `arcjet/arcjet-js`)](https://github.com/arcjet/arcjet-js/tree/main/decorate)
22
23
 
23
- ```shell
24
- npm install -S @arcjet/decorate
24
+ ## What is this?
25
+
26
+ This is a utility that lets you decorate responses based on Arcjet decisions.
27
+ It currently supports experimental rate limit headers.
28
+
29
+ ## When should I use this?
30
+
31
+ You can use this package if you use the rate limit rule and want to set
32
+ experimental `RateLimit-Policy` and `RateLimit` headers.
33
+ See [_RateLimit header fields for HTTP_ on `ietf.org`][ietf-rate-limit] for
34
+ more info.
35
+
36
+ ## Install
37
+
38
+ This package is ESM only.
39
+ Install with npm in Node.js:
40
+
41
+ ```sh
42
+ npm install @arcjet/decorate
25
43
  ```
26
44
 
27
45
  ## Example
28
46
 
29
47
  ```ts
30
- import arcjet, { fixedWindow } from "@arcjet/next";
48
+ import http from "node:http";
31
49
  import { setRateLimitHeaders } from "@arcjet/decorate";
32
- import { NextApiRequest, NextApiResponse } from "next";
50
+ import arcjet, { shield } from "@arcjet/node";
51
+
52
+ // Get your Arcjet key at <https://app.arcjet.com>.
53
+ // Set it as an environment variable instead of hard coding it.
54
+ const arcjetKey = process.env.ARCJET_KEY;
55
+
56
+ if (!arcjetKey) {
57
+ throw new Error("Cannot find `ARCJET_KEY` environment variable");
58
+ }
33
59
 
34
60
  const aj = arcjet({
35
- key: process.env.ARCJET_KEY!, // Get your site key from https://app.arcjet.com
61
+ key: arcjetKey,
36
62
  rules: [
37
- // Create a fixed window rate limit. Other algorithms are supported.
38
- fixedWindow({
39
- mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
40
- window: "1m", // 1 min fixed window
41
- max: 1, // allow a single request
42
- }),
63
+ // Shield protects your app from common attacks.
64
+ // Use `DRY_RUN` instead of `LIVE` to only log.
65
+ shield({ mode: "LIVE" }),
43
66
  ],
44
67
  });
45
68
 
46
- export default async function handler(
47
- req: NextApiRequest,
48
- res: NextApiResponse,
69
+ const server = http.createServer(async function (
70
+ request: http.IncomingMessage,
71
+ response: http.ServerResponse,
49
72
  ) {
50
- const decision = await aj.protect(req);
73
+ const decision = await aj.protect(request);
51
74
 
52
- setRateLimitHeaders(res, decision);
75
+ setRateLimitHeaders(response, decision);
53
76
 
54
77
  if (decision.isDenied()) {
55
- return res.status(429).json({
56
- error: "Too Many Requests",
57
- reason: decision.reason,
58
- });
78
+ response.writeHead(403, { "Content-Type": "application/json" });
79
+ response.end(JSON.stringify({ message: "Forbidden" }));
80
+ return;
59
81
  }
60
82
 
61
- res.status(200).json({ name: "Hello world" });
62
- }
83
+ response.writeHead(200, { "Content-Type": "application/json" });
84
+ response.end(JSON.stringify({ message: "Hello world" }));
85
+ });
86
+
87
+ server.listen(8000);
63
88
  ```
64
89
 
65
90
  ## License
66
91
 
67
- Licensed under the [Apache License, Version 2.0][apache-license].
92
+ [Apache License, Version 2.0][apache-license] © [Arcjet Labs, Inc.][arcjet]
68
93
 
69
94
  [arcjet]: https://arcjet.com
70
95
  [apache-license]: http://www.apache.org/licenses/LICENSE-2.0
96
+ [ietf-rate-limit]: https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers-08
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ArcjetDecision } from "@arcjet/protocol";
1
+ import type { ArcjetDecision } from "@arcjet/protocol";
2
2
  interface HeaderLike {
3
3
  has(name: string): boolean;
4
4
  get(name: string): string | null;
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import logger from '@arcjet/logger';
1
+ import format from '@arcjet/sprintf';
2
+ import '@arcjet/protocol';
2
3
 
3
4
  function isHeaderLike(value) {
4
5
  if ("has" in value &&
@@ -89,14 +90,14 @@ function setRateLimitHeaders(value, decision) {
89
90
  const policies = new Map();
90
91
  for (const reason of rateLimitReasons) {
91
92
  if (policies.has(reason.max)) {
92
- logger.error("Invalid rate limit policy—two policies should not share the same limit");
93
+ console.error("Invalid rate limit policy—two policies should not share the same limit");
93
94
  return;
94
95
  }
95
96
  if (typeof reason.max !== "number" ||
96
97
  typeof reason.window !== "number" ||
97
98
  typeof reason.remaining !== "number" ||
98
99
  typeof reason.reset !== "number") {
99
- logger.error("Invalid rate limit encountered: %s", reason);
100
+ console.error(format("Invalid rate limit encountered: %o", reason));
100
101
  return;
101
102
  }
102
103
  policies.set(reason.max, reason.window);
@@ -116,7 +117,7 @@ function setRateLimitHeaders(value, decision) {
116
117
  typeof decision.reason.window !== "number" ||
117
118
  typeof decision.reason.remaining !== "number" ||
118
119
  typeof decision.reason.reset !== "number") {
119
- logger.error("Invalid rate limit encountered: %s", decision.reason);
120
+ console.error(format("Invalid rate limit encountered: %o", decision.reason));
120
121
  return;
121
122
  }
122
123
  limit = toLimitString(decision.reason);
@@ -128,10 +129,10 @@ function setRateLimitHeaders(value, decision) {
128
129
  }
129
130
  if (isHeaderLike(value)) {
130
131
  if (value.has("RateLimit")) {
131
- logger.warn("Response already contains `RateLimit` header\n Original: %s\n New: %s", value.get("RateLimit"), limit);
132
+ console.warn(format("Response already contains `RateLimit` header\n Original: %s\n New: %s", value.get("RateLimit"), limit));
132
133
  }
133
134
  if (value.has("RateLimit-Policy")) {
134
- logger.warn("Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s", value.get("RateLimit-Policy"), limit);
135
+ console.warn(format("Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s", value.get("RateLimit-Policy"), limit));
135
136
  }
136
137
  value.set("RateLimit", limit);
137
138
  value.set("RateLimit-Policy", policy);
@@ -140,10 +141,10 @@ function setRateLimitHeaders(value, decision) {
140
141
  }
141
142
  if (isResponseLike(value)) {
142
143
  if (value.headers.has("RateLimit")) {
143
- logger.warn("Response already contains `RateLimit` header\n Original: %s\n New: %s", value.headers.get("RateLimit"), limit);
144
+ console.warn(format("Response already contains `RateLimit` header\n Original: %s\n New: %s", value.headers.get("RateLimit"), limit));
144
145
  }
145
146
  if (value.headers.has("RateLimit-Policy")) {
146
- logger.warn("Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s", value.headers.get("RateLimit-Policy"), limit);
147
+ console.warn(format("Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s", value.headers.get("RateLimit-Policy"), limit));
147
148
  }
148
149
  value.headers.set("RateLimit", limit);
149
150
  value.headers.set("RateLimit-Policy", policy);
@@ -152,21 +153,21 @@ function setRateLimitHeaders(value, decision) {
152
153
  }
153
154
  if (isOutgoingMessageLike(value)) {
154
155
  if (value.headersSent) {
155
- logger.error("Headers have already been sent—cannot set RateLimit header");
156
+ console.error("Headers have already been sent—cannot set RateLimit header");
156
157
  return;
157
158
  }
158
159
  if (value.hasHeader("RateLimit")) {
159
- logger.warn("Response already contains `RateLimit` header\n Original: %s\n New: %s", value.getHeader("RateLimit"), limit);
160
+ console.warn(format("Response already contains `RateLimit` header\n Original: %s\n New: %s", value.getHeader("RateLimit"), limit));
160
161
  }
161
162
  if (value.hasHeader("RateLimit-Policy")) {
162
- logger.warn("Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s", value.getHeader("RateLimit-Policy"), limit);
163
+ console.warn(format("Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s", value.getHeader("RateLimit-Policy"), limit));
163
164
  }
164
165
  value.setHeader("RateLimit", limit);
165
166
  value.setHeader("RateLimit-Policy", policy);
166
167
  // The response was handled
167
168
  return;
168
169
  }
169
- logger.debug("Cannot determine if response is Response or OutgoingMessage type");
170
+ console.debug("Cannot determine if response is Response or OutgoingMessage type");
170
171
  }
171
172
 
172
173
  export { setRateLimitHeaders };
package/package.json CHANGED
@@ -1,7 +1,13 @@
1
1
  {
2
2
  "name": "@arcjet/decorate",
3
- "version": "1.0.0-alpha.9",
3
+ "version": "1.0.0-beta.10",
4
4
  "description": "Arcjet utilities for decorating responses with information",
5
+ "keywords": [
6
+ "arcjet",
7
+ "decore",
8
+ "utility",
9
+ "util"
10
+ ],
5
11
  "license": "Apache-2.0",
6
12
  "homepage": "https://arcjet.com",
7
13
  "repository": {
@@ -25,33 +31,29 @@
25
31
  "main": "./index.js",
26
32
  "types": "./index.d.ts",
27
33
  "files": [
28
- "LICENSE",
29
- "README.md",
30
- "*.js",
31
- "*.d.ts",
32
- "*.ts",
33
- "!*.config.js"
34
+ "index.d.ts",
35
+ "index.js"
34
36
  ],
35
37
  "scripts": {
36
- "prepublishOnly": "npm run build",
37
38
  "build": "rollup --config rollup.config.js",
38
39
  "lint": "eslint .",
39
- "pretest": "npm run build",
40
- "test": "NODE_OPTIONS=--experimental-vm-modules jest"
40
+ "prepublishOnly": "npm run build",
41
+ "test-api": "node --test",
42
+ "test-coverage": "node --experimental-test-coverage --test",
43
+ "test": "npm run build && npm run lint && npm run test-coverage"
41
44
  },
42
45
  "dependencies": {
43
- "@arcjet/logger": "1.0.0-alpha.9",
44
- "@arcjet/protocol": "1.0.0-alpha.9"
46
+ "@arcjet/protocol": "1.0.0-beta.10",
47
+ "@arcjet/sprintf": "1.0.0-beta.10"
45
48
  },
46
49
  "devDependencies": {
47
- "@arcjet/eslint-config": "1.0.0-alpha.9",
48
- "@arcjet/rollup-config": "1.0.0-alpha.9",
49
- "@arcjet/tsconfig": "1.0.0-alpha.9",
50
- "@jest/globals": "29.7.0",
51
- "@rollup/wasm-node": "4.12.0",
50
+ "@arcjet/eslint-config": "1.0.0-beta.10",
51
+ "@arcjet/rollup-config": "1.0.0-beta.10",
52
+ "@arcjet/tsconfig": "1.0.0-beta.10",
53
+ "@rollup/wasm-node": "4.46.2",
52
54
  "@types/node": "18.18.0",
53
- "jest": "29.7.0",
54
- "typescript": "5.3.3"
55
+ "eslint": "9.32.0",
56
+ "typescript": "5.9.2"
55
57
  },
56
58
  "publishConfig": {
57
59
  "access": "public",
package/index.ts DELETED
@@ -1,312 +0,0 @@
1
- import logger from "@arcjet/logger";
2
- import {
3
- ArcjetDecision,
4
- ArcjetRateLimitReason,
5
- ArcjetReason,
6
- ArcjetRuleResult,
7
- } from "@arcjet/protocol";
8
-
9
- interface HeaderLike {
10
- has(name: string): boolean;
11
- get(name: string): string | null;
12
- set(name: string, value: string): void;
13
- }
14
-
15
- interface ResponseLike {
16
- // If this is defined, we can expect to be working with a `Response` or
17
- // `NextResponse`.
18
- headers: HeaderLike;
19
- }
20
-
21
- interface OutgoingMessageLike {
22
- headersSent: boolean;
23
- hasHeader: (name: string) => boolean;
24
- getHeader: (name: string) => number | string | string[] | undefined;
25
- setHeader: (
26
- name: string,
27
- value: number | string | ReadonlyArray<string>,
28
- ) => unknown;
29
- }
30
-
31
- export interface ArcjetCanDecorate {
32
- // If these are defined, we can expect to be working with `Headers` directly
33
- has?: (name: string) => boolean;
34
- get?: (name: string) => string | null;
35
- set?: (name: string, value: string) => void;
36
-
37
- // If this is defined, we can expect to be working with a `Response` or
38
- // `NextResponse`.
39
- headers?: HeaderLike;
40
-
41
- // Otherwise, we'll be working with an `http.OutgoingMessage` and we'll need
42
- // to use these values.
43
- headersSent?: boolean;
44
- hasHeader?: (name: string) => boolean;
45
- getHeader?: (name: string) => number | string | string[] | undefined;
46
- setHeader?: (
47
- name: string,
48
- value: number | string | ReadonlyArray<string>,
49
- ) => unknown;
50
- }
51
-
52
- function isHeaderLike(value: ArcjetCanDecorate): value is HeaderLike {
53
- if (
54
- "has" in value &&
55
- typeof value.has === "function" &&
56
- "get" in value &&
57
- typeof value.get === "function" &&
58
- "set" in value &&
59
- typeof value.set === "function"
60
- ) {
61
- return true;
62
- }
63
-
64
- return false;
65
- }
66
-
67
- function isResponseLike(value: ArcjetCanDecorate): value is ResponseLike {
68
- if (typeof value.headers === "undefined") {
69
- return false;
70
- }
71
-
72
- return isHeaderLike(value.headers);
73
- }
74
-
75
- function isOutgoingMessageLike(
76
- response: ArcjetCanDecorate,
77
- ): response is OutgoingMessageLike {
78
- if (typeof response.headersSent !== "boolean") {
79
- return false;
80
- }
81
-
82
- if (typeof response.hasHeader !== "function") {
83
- return false;
84
- }
85
-
86
- if (typeof response.getHeader !== "function") {
87
- return false;
88
- }
89
-
90
- if (typeof response.setHeader !== "function") {
91
- return false;
92
- }
93
-
94
- return true;
95
- }
96
-
97
- function sortByLowestMax(
98
- [maxA, _windowA]: [number, number],
99
- [maxB, _windowB]: [number, number],
100
- ) {
101
- return maxA - maxB;
102
- }
103
-
104
- function toPolicyString([max, window]: [number, number]) {
105
- return `${max};w=${window}`;
106
- }
107
-
108
- function toLimitString({
109
- max,
110
- remaining,
111
- reset,
112
- }: {
113
- max: number;
114
- remaining: number;
115
- reset: number;
116
- }) {
117
- return `limit=${max}, remaining=${remaining}, reset=${reset}`;
118
- }
119
-
120
- function extractReason(result: ArcjetRuleResult): ArcjetReason {
121
- return result.reason;
122
- }
123
-
124
- function isRateLimitReason(
125
- reason: ArcjetReason,
126
- ): reason is ArcjetRateLimitReason {
127
- return reason.isRateLimit();
128
- }
129
-
130
- function nearestLimit(
131
- current: ArcjetRateLimitReason,
132
- next: ArcjetRateLimitReason,
133
- ) {
134
- if (current.remaining < next.remaining) {
135
- return current;
136
- }
137
-
138
- if (current.remaining > next.remaining) {
139
- return next;
140
- }
141
-
142
- // Reaching here means `remaining` is equal so prioritize closest reset
143
- if (current.reset < next.reset) {
144
- return current;
145
- }
146
-
147
- if (current.reset > next.reset) {
148
- return next;
149
- }
150
-
151
- // Reaching here means that `remaining` and `reset` are equal, so prioritize
152
- // the smallest `max`
153
- if (current.max < next.max) {
154
- return current;
155
- }
156
-
157
- // All else equal, just return the next item in the list
158
- return next;
159
- }
160
-
161
- /**
162
- * Decorates an object with `RateLimit` and `RateLimit-Policy` headers based
163
- * on an {@link ArcjetDecision} and conforming to the [Rate Limit fields for
164
- * HTTP](https://ietf-wg-httpapi.github.io/ratelimit-headers/draft-ietf-httpapi-ratelimit-headers.html)
165
- * draft specification.
166
- *
167
- * @param value The object to decorate—must be similar to {@link Headers}, {@link Response} or
168
- * {@link OutgoingMessage}.
169
- * @param decision The {@link ArcjetDecision} that was made by calling `protect()` on the SDK.
170
- */
171
- export function setRateLimitHeaders(
172
- value: ArcjetCanDecorate,
173
- decision: ArcjetDecision,
174
- ) {
175
- const rateLimitReasons = decision.results
176
- .map(extractReason)
177
- .filter(isRateLimitReason);
178
-
179
- let policy: string;
180
- let limit: string;
181
- if (rateLimitReasons.length > 0) {
182
- const policies = new Map<number, number>();
183
- for (const reason of rateLimitReasons) {
184
- if (policies.has(reason.max)) {
185
- logger.error(
186
- "Invalid rate limit policy—two policies should not share the same limit",
187
- );
188
- return;
189
- }
190
-
191
- if (
192
- typeof reason.max !== "number" ||
193
- typeof reason.window !== "number" ||
194
- typeof reason.remaining !== "number" ||
195
- typeof reason.reset !== "number"
196
- ) {
197
- logger.error("Invalid rate limit encountered: %s", reason);
198
- return;
199
- }
200
-
201
- policies.set(reason.max, reason.window);
202
- }
203
-
204
- const rl = rateLimitReasons.reduce(nearestLimit);
205
-
206
- limit = toLimitString(rl);
207
- policy = Array.from(policies.entries())
208
- .sort(sortByLowestMax)
209
- .map(toPolicyString)
210
- .join(", ");
211
- } else {
212
- // For cached decisions, we may not have rule results, but we'd still have
213
- // the top-level reason.
214
- if (isRateLimitReason(decision.reason)) {
215
- if (
216
- typeof decision.reason.max !== "number" ||
217
- typeof decision.reason.window !== "number" ||
218
- typeof decision.reason.remaining !== "number" ||
219
- typeof decision.reason.reset !== "number"
220
- ) {
221
- logger.error("Invalid rate limit encountered: %s", decision.reason);
222
- return;
223
- }
224
-
225
- limit = toLimitString(decision.reason);
226
- policy = toPolicyString([decision.reason.max, decision.reason.window]);
227
- } else {
228
- return;
229
- }
230
- }
231
-
232
- if (isHeaderLike(value)) {
233
- if (value.has("RateLimit")) {
234
- logger.warn(
235
- "Response already contains `RateLimit` header\n Original: %s\n New: %s",
236
- value.get("RateLimit"),
237
- limit,
238
- );
239
- }
240
- if (value.has("RateLimit-Policy")) {
241
- logger.warn(
242
- "Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s",
243
- value.get("RateLimit-Policy"),
244
- limit,
245
- );
246
- }
247
-
248
- value.set("RateLimit", limit);
249
- value.set("RateLimit-Policy", policy);
250
-
251
- // The response was handled
252
- return;
253
- }
254
-
255
- if (isResponseLike(value)) {
256
- if (value.headers.has("RateLimit")) {
257
- logger.warn(
258
- "Response already contains `RateLimit` header\n Original: %s\n New: %s",
259
- value.headers.get("RateLimit"),
260
- limit,
261
- );
262
- }
263
- if (value.headers.has("RateLimit-Policy")) {
264
- logger.warn(
265
- "Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s",
266
- value.headers.get("RateLimit-Policy"),
267
- limit,
268
- );
269
- }
270
-
271
- value.headers.set("RateLimit", limit);
272
- value.headers.set("RateLimit-Policy", policy);
273
-
274
- // The response was handled
275
- return;
276
- }
277
-
278
- if (isOutgoingMessageLike(value)) {
279
- if (value.headersSent) {
280
- logger.error(
281
- "Headers have already been sent—cannot set RateLimit header",
282
- );
283
- return;
284
- }
285
-
286
- if (value.hasHeader("RateLimit")) {
287
- logger.warn(
288
- "Response already contains `RateLimit` header\n Original: %s\n New: %s",
289
- value.getHeader("RateLimit"),
290
- limit,
291
- );
292
- }
293
-
294
- if (value.hasHeader("RateLimit-Policy")) {
295
- logger.warn(
296
- "Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s",
297
- value.getHeader("RateLimit-Policy"),
298
- limit,
299
- );
300
- }
301
-
302
- value.setHeader("RateLimit", limit);
303
- value.setHeader("RateLimit-Policy", policy);
304
-
305
- // The response was handled
306
- return;
307
- }
308
-
309
- logger.debug(
310
- "Cannot determine if response is Response or OutgoingMessage type",
311
- );
312
- }