diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 6f510540c..ef6e46afb 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -3,6 +3,7 @@ "analyze": "1.0.0-alpha.7", "arcjet": "1.0.0-alpha.7", "arcjet-next": "1.0.0-alpha.7", + "duration": "1.0.0-alpha.7", "eslint-config": "1.0.0-alpha.7", "ip": "1.0.0-alpha.7", "logger": "1.0.0-alpha.7", diff --git a/.github/release-please-config.json b/.github/release-please-config.json index 7a0c88f1b..f4509ca3b 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -41,6 +41,10 @@ "component": "@arcjet/next", "skip-github-release": true }, + "duration": { + "component": "@arcjet/duration", + "skip-github-release": true + }, "eslint-config": { "component": "@arcjet/eslint-config", "skip-github-release": true @@ -79,6 +83,7 @@ "@arcjet/analyze", "arcjet", "@arjcet/next", + "@arjcet/duration", "@arcjet/eslint-config", "@arcjet/ip", "@arcjet/logger", diff --git a/README.md b/README.md index 7e1f1dedc..e86257085 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ find a specific one through the categories and descriptions below. into the Arcjet protocol. - [`@arcjet/logger`](./logger/README.md): Logging interface which mirrors the console interface but allows log levels. +- [`@arcjet/duration`](./duration/README.md): Utilities for parsing duration + strings into seconds integers. ### Internal development diff --git a/arcjet/index.ts b/arcjet/index.ts index 874f631bd..7aa8acafd 100644 --- a/arcjet/index.ts +++ b/arcjet/index.ts @@ -39,6 +39,7 @@ import { Timestamp, } from "@arcjet/protocol/proto.js"; import * as analyze from "@arcjet/analyze"; +import * as duration from "@arcjet/duration"; import { Logger } from "@arcjet/logger"; export * from "@arcjet/protocol"; @@ -421,7 +422,7 @@ type TokenBucketRateLimitOptions = { match?: string; characteristics?: string[]; refillRate: number; - interval: number; + interval: string | number; capacity: number; }; @@ -429,7 +430,7 @@ type FixedWindowRateLimitOptions = { mode?: ArcjetMode; match?: string; characteristics?: string[]; - window: string; + window: string | number; max: number; }; @@ -437,7 +438,7 @@ type SlidingWindowRateLimitOptions = { mode?: ArcjetMode; match?: string; characteristics?: string[]; - interval: number; + interval: string | number; max: number; }; @@ -605,7 +606,7 @@ export function tokenBucket( const characteristics = opt.characteristics; const refillRate = opt.refillRate; - const interval = opt.interval; + const interval = duration.parse(opt.interval); const capacity = opt.capacity; rules.push({ @@ -640,7 +641,7 @@ export function fixedWindow( const characteristics = opt.characteristics; const max = opt.max; - const window = opt.window; + const window = duration.parse(opt.window); rules.push({ type: "RATE_LIMIT", @@ -684,7 +685,7 @@ export function slidingWindow( const characteristics = opt.characteristics; const max = opt.max; - const interval = opt.interval; + const interval = duration.parse(opt.interval); rules.push({ type: "RATE_LIMIT", diff --git a/arcjet/package.json b/arcjet/package.json index fb86f8696..c8f0d5ebd 100644 --- a/arcjet/package.json +++ b/arcjet/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@arcjet/analyze": "1.0.0-alpha.7", + "@arcjet/duration": "1.0.0-alpha.7", "@arcjet/logger": "1.0.0-alpha.7", "@arcjet/protocol": "1.0.0-alpha.7" }, diff --git a/arcjet/test/index.node.test.ts b/arcjet/test/index.node.test.ts index 904d6cb3a..1888057cf 100644 --- a/arcjet/test/index.node.test.ts +++ b/arcjet/test/index.node.test.ts @@ -1948,6 +1948,36 @@ describe("Primitive > tokenBucket", () => { expect(rule).toHaveProperty("mode", "LIVE"); }); + test("can specify interval as a string duration", async () => { + const options = { + refillRate: 60, + interval: "60s", + capacity: 120, + }; + + const rules = tokenBucket(options); + expect(rules).toHaveLength(1); + expect(rules[0].type).toEqual("RATE_LIMIT"); + expect(rules[0]).toHaveProperty("refillRate", 60); + expect(rules[0]).toHaveProperty("interval", 60); + expect(rules[0]).toHaveProperty("capacity", 120); + }); + + test("can specify interval as an integer duration", async () => { + const options = { + refillRate: 60, + interval: 60, + capacity: 120, + }; + + const rules = tokenBucket(options); + expect(rules).toHaveLength(1); + expect(rules[0].type).toEqual("RATE_LIMIT"); + expect(rules[0]).toHaveProperty("refillRate", 60); + expect(rules[0]).toHaveProperty("interval", 60); + expect(rules[0]).toHaveProperty("capacity", 120); + }); + test("produces a rules based on single `limit` specified", async () => { const options = { match: "/test", @@ -2096,6 +2126,32 @@ describe("Primitive > fixedWindow", () => { expect(rule).toHaveProperty("mode", "LIVE"); }); + test("can specify window as a string duration", async () => { + const options = { + window: "60s", + max: 1, + }; + + const rules = fixedWindow(options); + expect(rules).toHaveLength(1); + expect(rules[0].type).toEqual("RATE_LIMIT"); + expect(rules[0]).toHaveProperty("window", 60); + expect(rules[0]).toHaveProperty("max", 1); + }); + + test("can specify window as an integer duration", async () => { + const options = { + window: 60, + max: 1, + }; + + const rules = fixedWindow(options); + expect(rules).toHaveLength(1); + expect(rules[0].type).toEqual("RATE_LIMIT"); + expect(rules[0]).toHaveProperty("window", 60); + expect(rules[0]).toHaveProperty("max", 1); + }); + test("produces a rules based on single `limit` specified", async () => { const options = { match: "/test", @@ -2111,7 +2167,7 @@ describe("Primitive > fixedWindow", () => { expect(rules[0]).toHaveProperty("match", "/test"); expect(rules[0]).toHaveProperty("characteristics", ["ip.src"]); expect(rules[0]).toHaveProperty("algorithm", "FIXED_WINDOW"); - expect(rules[0]).toHaveProperty("window", "1h"); + expect(rules[0]).toHaveProperty("window", 3600); expect(rules[0]).toHaveProperty("max", 1); }); @@ -2140,7 +2196,7 @@ describe("Primitive > fixedWindow", () => { match: "/test", characteristics: ["ip.src"], algorithm: "FIXED_WINDOW", - window: "1h", + window: 3600, max: 1, }), expect.objectContaining({ @@ -2149,7 +2205,7 @@ describe("Primitive > fixedWindow", () => { match: "/test-double", characteristics: ["ip.src"], algorithm: "FIXED_WINDOW", - window: "2h", + window: 7200, max: 2, }), ]); @@ -2187,7 +2243,7 @@ describe("Primitive > fixedWindow", () => { match: undefined, characteristics: undefined, algorithm: "FIXED_WINDOW", - window: "1h", + window: 3600, max: 1, }), expect.objectContaining({ @@ -2196,7 +2252,7 @@ describe("Primitive > fixedWindow", () => { match: undefined, characteristics: undefined, algorithm: "FIXED_WINDOW", - window: "2h", + window: 7200, max: 2, }), ]); @@ -2234,6 +2290,32 @@ describe("Primitive > slidingWindow", () => { expect(rule).toHaveProperty("mode", "LIVE"); }); + test("can specify interval as a string duration", async () => { + const options = { + interval: "60s", + max: 1, + }; + + const rules = slidingWindow(options); + expect(rules).toHaveLength(1); + expect(rules[0].type).toEqual("RATE_LIMIT"); + expect(rules[0]).toHaveProperty("interval", 60); + expect(rules[0]).toHaveProperty("max", 1); + }); + + test("can specify interval as an integer duration", async () => { + const options = { + interval: 60, + max: 1, + }; + + const rules = slidingWindow(options); + expect(rules).toHaveLength(1); + expect(rules[0].type).toEqual("RATE_LIMIT"); + expect(rules[0]).toHaveProperty("interval", 60); + expect(rules[0]).toHaveProperty("max", 1); + }); + test("produces a rules based on single `limit` specified", async () => { const options = { match: "/test", @@ -2390,7 +2472,7 @@ describe("Primitive > rateLimit", () => { expect(rules[0]).toHaveProperty("match", "/test"); expect(rules[0]).toHaveProperty("characteristics", ["ip.src"]); expect(rules[0]).toHaveProperty("algorithm", "FIXED_WINDOW"); - expect(rules[0]).toHaveProperty("window", "1h"); + expect(rules[0]).toHaveProperty("window", 3600); expect(rules[0]).toHaveProperty("max", 1); }); @@ -2419,7 +2501,7 @@ describe("Primitive > rateLimit", () => { match: "/test", characteristics: ["ip.src"], algorithm: "FIXED_WINDOW", - window: "1h", + window: 3600, max: 1, }), expect.objectContaining({ @@ -2428,7 +2510,7 @@ describe("Primitive > rateLimit", () => { match: "/test-double", characteristics: ["ip.src"], algorithm: "FIXED_WINDOW", - window: "2h", + window: 7200, max: 2, }), ]); @@ -2466,7 +2548,7 @@ describe("Primitive > rateLimit", () => { match: undefined, characteristics: undefined, algorithm: "FIXED_WINDOW", - window: "1h", + window: 3600, max: 1, }), expect.objectContaining({ @@ -2475,7 +2557,7 @@ describe("Primitive > rateLimit", () => { match: undefined, characteristics: undefined, algorithm: "FIXED_WINDOW", - window: "2h", + window: 7200, max: 2, }), ]); diff --git a/duration/.eslintignore b/duration/.eslintignore new file mode 100644 index 000000000..9cfa2cae7 --- /dev/null +++ b/duration/.eslintignore @@ -0,0 +1,6 @@ +/.turbo/ +/coverage/ +/node_modules/ +*.d.ts +*.js +!*.config.js diff --git a/duration/.eslintrc.cjs b/duration/.eslintrc.cjs new file mode 100644 index 000000000..abe4cd7b4 --- /dev/null +++ b/duration/.eslintrc.cjs @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ["@arcjet/eslint-config"], +}; diff --git a/duration/.gitignore b/duration/.gitignore new file mode 100644 index 000000000..35b162da3 --- /dev/null +++ b/duration/.gitignore @@ -0,0 +1,135 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Generated files +index.js +index.d.ts +test/*.js diff --git a/duration/LICENSE b/duration/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/duration/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/duration/README.md b/duration/README.md new file mode 100644 index 000000000..e65d4dbcc --- /dev/null +++ b/duration/README.md @@ -0,0 +1,52 @@ + + + + +# `@arcjet/ip` + +
+ +
+ +[Arcjet][arcjet] utilities for parsing duration strings. + +## Installation + +```shell +npm install -S @arcjet/duration +``` + +## Example + +```ts +import { parse } from "@arcjet/duration"; + +const seconds = parse("1h"); +console.log(seconds); // prints 3600 +``` + +## Implementation + +The implementation of this library is based on the [ParseDuration][go-parser] +function in the Go stdlib. Originally licensed BSD-3.0 with the license included +in our source code. + +We've chosen the approach of porting this to TypeScript because our protocol +operates exclusively on unsigned 32-bit integers representing seconds. However, +we don't want to require our SDK users to manually calculate seconds. By +providing this utility, our SDK can accept duration strings and numbers while +normalizing the values for our protocol. + +## License + +Licensed under the [Apache License, Version 2.0][apache-license]. + +[arcjet]: https://arcjet.com +[go-parser]: https://github.com/golang/go/blob/c18ddc84e1ec6406b26f7e9d0e1ee3d1908d7c27/src/time/format.go#L1589-L1686 +[apache-license]: http://www.apache.org/licenses/LICENSE-2.0 diff --git a/duration/index.ts b/duration/index.ts new file mode 100644 index 000000000..690744a13 --- /dev/null +++ b/duration/index.ts @@ -0,0 +1,168 @@ +// This Parser is a TypeScript implementation of similar code in the Go stdlib +// with deviations made to support usage in the Arcjet SDK. +// +// Parser source: +// https://github.com/golang/go/blob/c18ddc84e1ec6406b26f7e9d0e1ee3d1908d7c27/src/time/format.go#L1589-L1686 +// +// Licensed: BSD 3-Clause "New" or "Revised" License +// Copyright (c) 2009 The Go Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +const second = 1; +const minute = 60 * second; +const hour = 60 * minute; +const day = 24 * hour; + +const maxUint32 = 4294967295; + +const units = new Map([ + ["s", second], + ["m", minute], + ["h", hour], + ["d", day], +]); + +const integers = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + +// leadingInt consumes the leading [0-9]* from s. +function leadingInt(s: string): [number, string] { + let i = 0; + let x = 0; + for (; i < s.length; i++) { + const c = s[i]; + if (!integers.includes(c)) { + break; + } + x = x * 10 + parseInt(c, 10); + if (x > maxUint32) { + // overflow + throw new Error("bad [0-9]*"); // never printed + } + } + return [x, s.slice(i)]; +} + +/** + * Parses a duration into a number representing seconds while ensuring the value + * fits within an unsigned 32-bit integer. + * + * If a JavaScript number is provided to the function, it is validated and + * returned verbatim. + * + * If a string is provided to the function, it must be in the form of digits + * followed by a unit. Supported units are `s` (seconds), `m` (minutes), `h` + * (hours), and `d` (days). + * + * @param s The value to parse into seconds. + * @returns A number representing seconds parsed from the provided duration. + * + * @example + * parse("1s") === 1 + * parse("1m") === 60 + * parse("1h") === 3600 + * parse("1d") === 86400 + */ +export function parse(s: string | number): number { + const original = s; + + if (typeof s === "number") { + if (s > maxUint32) { + throw new Error(`invalid duration: ${original}`); + } + + if (s < 0) { + throw new Error(`invalid duration: ${original}`); + } + + if (!Number.isInteger(s)) { + throw new Error(`invalid duration: ${original}`); + } + + return s; + } + + if (typeof s !== "string") { + throw new Error("can only parse a duration string"); + } + + let d = 0; + + // Special case: if all that is left is "0", this is zero. + if (s === "0") { + return 0; + } + if (s === "") { + throw new Error(`invalid duration: ${original}`); + } + + while (s !== "") { + let v = 0; + + // The next character must be [0-9] + if (!integers.includes(s[0])) { + throw new Error(`invalid duration: ${original}`); + } + // Consume [0-9]* + [v, s] = leadingInt(s); + // Error on decimal (\.[0-9]*)? + if (s !== "" && s[0] == ".") { + // TODO: We could support decimals that turn into non-decimal seconds—e.g. + // 1.5hours becomes 5400 seconds + throw new Error(`unsupported decimal duration: ${original}`); + } + + // Consume unit. + let i = 0; + for (; i < s.length; i++) { + const c = s[i]; + if (integers.includes(c)) { + break; + } + } + if (i == 0) { + throw new Error(`missing unit in duration: ${original}`); + } + const u = s.slice(0, i); + s = s.slice(i); + const unit = units.get(u); + if (typeof unit === "undefined") { + throw new Error(`unknown unit "${u}" in duration ${original}`); + } + if (v > maxUint32 / unit) { + // overflow + throw new Error(`invalid duration ${original}`); + } + v *= unit; + d += v; + if (d > maxUint32) { + throw new Error(`invalid duration ${original}`); + } + } + + return d; +} diff --git a/duration/jest.config.js b/duration/jest.config.js new file mode 100644 index 000000000..6d5656840 --- /dev/null +++ b/duration/jest.config.js @@ -0,0 +1,16 @@ +/** @type {import('jest').Config} */ +const config = { + // We only test JS files once compiled with TypeScript + moduleFileExtensions: ["js"], + coverageDirectory: "coverage", + collectCoverage: true, + // If this is set to default (babel) rather than v8, tests fail with the edge + // runtime and the error "EvalError: Code generation from strings disallowed + // for this context". Tracking in + // https://github.com/vercel/edge-runtime/issues/250 + coverageProvider: "v8", + verbose: true, + testEnvironment: "node", +}; + +export default config; diff --git a/duration/package.json b/duration/package.json new file mode 100644 index 000000000..6f3237815 --- /dev/null +++ b/duration/package.json @@ -0,0 +1,48 @@ +{ + "name": "@arcjet/duration", + "version": "1.0.0-alpha.7", + "description": "Arcjet utilities for parsing duration strings", + "license": "Apache-2.0", + "homepage": "https://arcjet.com", + "repository": { + "type": "git", + "url": "git+https://github.com/arcjet/arcjet-js.git", + "directory": "ip" + }, + "engines": { + "node": ">=18" + }, + "type": "module", + "main": "./index.js", + "types": "./index.d.ts", + "files": [ + "LICENSE", + "README.md", + "*.js", + "*.d.ts", + "*.ts", + "!*.config.js" + ], + "scripts": { + "prepublishOnly": "npm run build", + "build": "rollup --config rollup.config.js", + "lint": "eslint .", + "pretest": "npm run build", + "test": "NODE_OPTIONS=--experimental-vm-modules jest" + }, + "dependencies": {}, + "devDependencies": { + "@arcjet/eslint-config": "1.0.0-alpha.7", + "@arcjet/rollup-config": "1.0.0-alpha.7", + "@arcjet/tsconfig": "1.0.0-alpha.7", + "@jest/globals": "29.7.0", + "@rollup/wasm-node": "4.9.6", + "@types/node": "18.18.0", + "jest": "29.7.0", + "typescript": "5.3.3" + }, + "publishConfig": { + "access": "public", + "tag": "latest" + } +} diff --git a/duration/rollup.config.js b/duration/rollup.config.js new file mode 100644 index 000000000..79177f236 --- /dev/null +++ b/duration/rollup.config.js @@ -0,0 +1,3 @@ +import { createConfig } from "@arcjet/rollup-config"; + +export default createConfig(import.meta.url); diff --git a/duration/test/index.test.ts b/duration/test/index.test.ts new file mode 100644 index 000000000..62966aa72 --- /dev/null +++ b/duration/test/index.test.ts @@ -0,0 +1,112 @@ +/** + * @jest-environment node + */ +import { describe, expect, test, afterEach, jest } from "@jest/globals"; +import { parse } from "../index"; + +afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); +}); + +describe("parse", () => { + test("always returns 0 if the duration string is just 0", () => { + expect(parse("0")).toEqual(0); + }); + + test("fails if duration string is negative", () => { + expect(() => parse("-1s")).toThrow(); + }); + + test("fails if duration string starts with a +", () => { + expect(() => parse("+1s")).toThrow(); + }); + + test("fails if duration string does not start with a number", () => { + expect(() => parse("abc1000s")).toThrow(); + }); + + test("fails if duration string is empty", () => { + expect(() => parse("")).toThrow(); + }); + + test("fails if duration string is contains a decimal", () => { + expect(() => parse("1.5s")).toThrow(); + }); + + test("fails if duration string uses unknown unit", () => { + expect(() => parse("1y")).toThrow(); + }); + + test("fails if duration is not a string or number", () => { + expect(() => { + //@ts-expect-error + parse({}); + }).toThrow(); + }); + + test("returns duration number directly as seconds", () => { + expect(parse(1)).toEqual(1); + expect(parse(10000)).toEqual(10000); + }); + + test("fails if duration number overflows", () => { + expect(() => parse(4294967296)).toThrow(); + }); + + test("fails if duration number is negative", () => { + expect(() => parse(-1)).toThrow(); + }); + + test("fails if duration number is decimal", () => { + expect(() => parse(100.5)).toThrow(); + }); + + test("parses seconds", () => { + expect(parse("1s")).toEqual(1); + expect(parse("1000s")).toEqual(1000); + }); + + test("fails if seconds overflow", () => { + expect(() => parse("4294967296s")).toThrow(); + }); + + test("parses minutes into seconds", () => { + expect(parse("1m")).toEqual(60); + expect(parse("60m")).toEqual(3600); + }); + + test("fails if minutes-to-seconds overflow", () => { + expect(() => parse("71582789m")).toThrow(); + }); + + test("parses hours into seconds", () => { + expect(parse("1h")).toEqual(3600); + expect(parse("24h")).toEqual(86400); + }); + + test("fails if hours-to-seconds overflow", () => { + expect(() => parse("1193047h")).toThrow(); + }); + + test("parses days into seconds", () => { + expect(parse("1d")).toEqual(86400); + expect(parse("3d")).toEqual(259200); + }); + + test("fails if days-to-seconds overflow", () => { + expect(() => parse("49711d")).toThrow(); + }); + + test("can combine multiple units", () => { + expect(parse("1h10m30s")).toEqual(4230); + }); + + test("fails if multiple units overflow", () => { + expect(() => parse("1193040h420m30s")).toThrow(); + }); + + test("fails if missing unit on final value in multiple units", () => { + expect(() => parse("1d2h30")).toThrow(); + }); +}); diff --git a/duration/tsconfig.json b/duration/tsconfig.json new file mode 100644 index 000000000..95929e097 --- /dev/null +++ b/duration/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@arcjet/tsconfig/base", + "include": ["index.ts", "test/*.ts"] +} diff --git a/examples/nextjs-14-openai/app/api/chat/route.ts b/examples/nextjs-14-openai/app/api/chat/route.ts index 8605554a4..f9957dbed 100644 --- a/examples/nextjs-14-openai/app/api/chat/route.ts +++ b/examples/nextjs-14-openai/app/api/chat/route.ts @@ -15,7 +15,7 @@ const aj = arcjet({ mode: "LIVE", characteristics: ["ip.src"], refillRate: 40_000, - interval: 1 /* day */ * 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */, + interval: "1d", capacity: 40_000, }), ], diff --git a/package-lock.json b/package-lock.json index 93540ccb7..73f2d86b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "license": "Apache-2.0", "dependencies": { "@arcjet/analyze": "1.0.0-alpha.7", + "@arcjet/duration": "1.0.0-alpha.7", "@arcjet/logger": "1.0.0-alpha.7", "@arcjet/protocol": "1.0.0-alpha.7" }, @@ -82,6 +83,24 @@ "node": ">=18" } }, + "duration": { + "name": "@arcjet/duration", + "version": "1.0.0-alpha.7", + "license": "Apache-2.0", + "devDependencies": { + "@arcjet/eslint-config": "1.0.0-alpha.7", + "@arcjet/rollup-config": "1.0.0-alpha.7", + "@arcjet/tsconfig": "1.0.0-alpha.7", + "@jest/globals": "29.7.0", + "@rollup/wasm-node": "4.9.6", + "@types/node": "18.18.0", + "jest": "29.7.0", + "typescript": "5.3.3" + }, + "engines": { + "node": ">=18" + } + }, "eslint-config": { "name": "@arcjet/eslint-config", "version": "1.0.0-alpha.7", @@ -164,6 +183,10 @@ "resolved": "analyze", "link": true }, + "node_modules/@arcjet/duration": { + "resolved": "duration", + "link": true + }, "node_modules/@arcjet/eslint-config": { "resolved": "eslint-config", "link": true diff --git a/protocol/convert.ts b/protocol/convert.ts index 4725adf17..0d311f388 100644 --- a/protocol/convert.ts +++ b/protocol/convert.ts @@ -522,7 +522,7 @@ export function ArcjetRuleToProtocol