@arcjet/decorate 1.0.0-alpha.9 → 1.0.0-beta.1
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.
- package/README.md +2 -2
- package/index.d.ts +1 -1
- package/index.js +13 -12
- package/package.json +10 -12
- 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-
|
|
4
|
-
<img src="https://arcjet.com/arcjet-
|
|
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
|
|
package/index.d.ts
CHANGED
package/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
+
console.error("Headers have already been sent—cannot set RateLimit header");
|
|
156
157
|
return;
|
|
157
158
|
}
|
|
158
159
|
if (value.hasHeader("RateLimit")) {
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcjet/decorate",
|
|
3
|
-
"version": "1.0.0-
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
4
|
"description": "Arcjet utilities for decorating responses with information",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://arcjet.com",
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
"README.md",
|
|
30
30
|
"*.js",
|
|
31
31
|
"*.d.ts",
|
|
32
|
-
"*.ts",
|
|
33
32
|
"!*.config.js"
|
|
34
33
|
],
|
|
35
34
|
"scripts": {
|
|
@@ -37,21 +36,20 @@
|
|
|
37
36
|
"build": "rollup --config rollup.config.js",
|
|
38
37
|
"lint": "eslint .",
|
|
39
38
|
"pretest": "npm run build",
|
|
40
|
-
"test": "
|
|
39
|
+
"test": "node --test --experimental-test-coverage"
|
|
41
40
|
},
|
|
42
41
|
"dependencies": {
|
|
43
|
-
"@arcjet/
|
|
44
|
-
"@arcjet/
|
|
42
|
+
"@arcjet/protocol": "1.0.0-beta.1",
|
|
43
|
+
"@arcjet/sprintf": "1.0.0-beta.1"
|
|
45
44
|
},
|
|
46
45
|
"devDependencies": {
|
|
47
|
-
"@arcjet/eslint-config": "1.0.0-
|
|
48
|
-
"@arcjet/rollup-config": "1.0.0-
|
|
49
|
-
"@arcjet/tsconfig": "1.0.0-
|
|
50
|
-
"@
|
|
51
|
-
"@rollup/wasm-node": "4.12.0",
|
|
46
|
+
"@arcjet/eslint-config": "1.0.0-beta.1",
|
|
47
|
+
"@arcjet/rollup-config": "1.0.0-beta.1",
|
|
48
|
+
"@arcjet/tsconfig": "1.0.0-beta.1",
|
|
49
|
+
"@rollup/wasm-node": "4.30.1",
|
|
52
50
|
"@types/node": "18.18.0",
|
|
53
|
-
"
|
|
54
|
-
"typescript": "5.
|
|
51
|
+
"expect": "29.7.0",
|
|
52
|
+
"typescript": "5.7.3"
|
|
55
53
|
},
|
|
56
54
|
"publishConfig": {
|
|
57
55
|
"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
|
-
}
|