├── .babelrc.js ├── .circleci └── config.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── eslint.config.js ├── example └── index.js ├── index.js ├── jest.config.js ├── package.json ├── src ├── methods.js ├── methods.test.js ├── utils.js ├── utils.test.js ├── workers.js └── workers.test.js ├── types.d.ts └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | const targets = { node: 'current' }; 2 | const presets = [['@babel/preset-env', { targets }]]; 3 | 4 | const plugins = []; 5 | 6 | module.exports = { presets, plugins }; 7 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 | jobs: 3 | build: 4 | working_directory: ~/repo 5 | docker: 6 | - image: cimg/node:20.17.0 7 | environment: 8 | TZ: "Asia/Jerusalem" 9 | steps: 10 | - checkout # special step to check out source code to working directory 11 | - restore_cache: 12 | name: Restore Yarn Package Cache 13 | keys: 14 | - yarn-packages-{{ checksum "yarn.lock" }} 15 | - run: 16 | name: Install Dependencies 17 | command: yarn install --frozen-lockfile 18 | - save_cache: 19 | name: Save Yarn Package Cache 20 | key: yarn-packages-{{ checksum "yarn.lock" }} 21 | paths: 22 | - ~/.cache/yarn 23 | - run: 24 | name: test 25 | command: yarn test 26 | - run: 27 | name: prettier 28 | command: yarn prettier:ci 29 | - run: 30 | name: code-coverage 31 | command: yarn test --coverage 32 | - store_artifacts: 33 | path: coverage 34 | prefix: coverage 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | *.swp 4 | yarn-error.log 5 | lib 6 | *.tgz 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !index.js 3 | !/lib/* 4 | !package.json 5 | !README.md 6 | !LICENSE 7 | !types.d.ts 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "es5", 7 | "useTabs": false, 8 | "parser": "babel", 9 | "overrides": [ 10 | { 11 | "files": "*.json", 12 | "options": { "parser": "json", "printWidth": 200 } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sagi Kedmi (https://sagi.io) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # workers-kv 2 | 3 | [`@sagi.io/workers-kv`](https://www.npmjs.com/package/@sagi.io/workers-kv) is a Cloudflare Workers KV API for Node.js. 4 | 5 | ⭐ We use it at **[OpenSay](https://opensay.co/?s=workers-kv)** to efficiently cache data on Cloudflare Workers KV. 6 | 7 | [![CircleCI](https://circleci.com/gh/sagi/workers-kv.svg?style=svg&circle-token=c5ae7a8993d47db9ca08a628614585ca45c75f33)](https://circleci.com/gh/sagi/workers-kv) 8 | [![MIT License](https://img.shields.io/npm/l/@sagi.io/workers-kv.svg?style=flat-square)](http://opensource.org/licenses/MIT) 9 | [![version](https://img.shields.io/npm/v/@sagi.io/workers-kv.svg?style=flat-square)](http://npm.im/@sagi.io/workers-kv) 10 | 11 | ## Installation 12 | 13 | ~~~ 14 | $ npm i @sagi.io/workers-kv 15 | ~~~ 16 | 17 | ## Quickstart 18 | 19 | First, instantiate a `WorkersKVREST` instance: 20 | 21 | ~~~js 22 | const WorkersKVREST = require('@sagi.io/workers-kv') 23 | 24 | const cfAccountId = process.env.CLOUDFLARE_ACCOUNT_ID; 25 | const cfAuthKey = process.env.CLOUDFLARE_AUTH_KEY; 26 | const cfEmail = process.env.CLOUDFLARE_EMAIL; 27 | 28 | const WorkersKV = new WorkersKVREST({ cfAccountId, cfAuthKey, cfEmail }) 29 | ~~~ 30 | 31 | Then, access it's instance methods. For instance: 32 | 33 | ~~~js 34 | const namespaceId = '...' 35 | 36 | const allKeys = await KV.listAllKeys({ namespaceId }) 37 | ~~~ 38 | 39 | ## API 40 | 41 | We adhere to [Cloudflare's Workers KV REST API](https://api.cloudflare.com/#workers-kv-namespace-properties). 42 | 43 | ### **`WorkersKVREST({ ... })`** 44 | 45 | Instantiates a `WorkersKV` object with the defined below methods. 46 | 47 | Function definition: 48 | 49 | ```js 50 | const WorkersKVREST = function({ 51 | cfAccountId, 52 | cfEmail, 53 | cfAuthKey, 54 | namespaceId = '', 55 | }){ ... } 56 | ``` 57 | 58 | Where: 59 | 60 | - **`cfAccountId`** *required* Your Cloudflare account id. 61 | - **`cfEmail`** *optional|required* The email you registered with Cloudflare. 62 | - **`cfAuthKey`** *optional|required* Your Cloudflare Auth Key. 63 | - **`cfAuthToken`** *optional|required* Your Cloudflare Auth Token. 64 | - **`namespaceId`** *optional* The `Workers KV` namespace id. This argument is *optional* - either provide it here, or via the methods below. 65 | 66 | Use `cfAuthToken` with a [Cloudflare auth token](https://support.cloudflare.com/hc/en-us/articles/200167836-Managing-API-Tokens-and-Keys). You can also set `cfEmail` and `cfAuthKey` directly without using an auth token. 67 | 68 | ### **`WorkersKV.listKeys({ ... })`** 69 | 70 | Function definition: 71 | 72 | ```js 73 | const listKeys = async ({ 74 | namespaceId = '', 75 | limit = MAX_KEYS_LIMIT, 76 | cursor = undefined, 77 | prefix = undefined, 78 | } = {}) => { ... } 79 | ``` 80 | 81 | Where: 82 | 83 | - **`namespaceId`** *optional* The namespace id (can also be provided while instantiating `WorkersKV`). 84 | - **`limit`** *optional* The number of keys to return. The cursor attribute may be used to iterate over the next batch of keys if there are more than the limit. 85 | - **`cursor`** *optional* Opaque token indicating the position from which to continue when requesting the next set of records if the amount of list results was limited by the limit parameter. A valid value for the cursor can be obtained from the cursors object in the result_info structure. 86 | - **`prefix`** *optional* A string prefix used to filter down which keys will be returned. Exact matches and any key names that begin with the prefix will be returned. 87 | 88 | ### **`WorkersKV.listAllKeys({ ... })`** 89 | 90 | Cursors through `listKeys` requests for you. 91 | 92 | Function definition: 93 | 94 | ```js 95 | const listAllKeys = async ({ 96 | namespaceId = '', 97 | prefix = undefined, 98 | limit = MAX_KEYS_LIMIT, 99 | } = {}) => { ... } 100 | ``` 101 | 102 | Where: 103 | 104 | - **`namespaceId`** *optional* The namespace id (can also be provided while instantiating `WorkersKV`). 105 | - **`cursor`** *optional* Opaque token indicating the position from which to continue when requesting the next set of records if the amount of list results was limited by the limit parameter. A valid value for the cursor can be obtained from the cursors object in the result_info structure. 106 | - **`prefix`** *optional* A string prefix used to filter down which keys will be returned. Exact matches and any key names that begin with the prefix will be returned. 107 | 108 | ### **`listNamespaces({ ... })`** 109 | 110 | Function definition: 111 | 112 | ```js 113 | const listNamespaces = async ({ 114 | page = 1, 115 | per_page = 50, 116 | } = {}) => { ... } 117 | ``` 118 | 119 | Where: 120 | 121 | - **`page`** *optional* Page number of paginated results. 122 | - **`per_page`** *optional* Maximum number of results per page. 123 | 124 | ### **`readKey({ ... })`** 125 | 126 | Function definition: 127 | 128 | ```js 129 | const readKey = async ({ 130 | key, 131 | namespaceId = '', 132 | }) => { ... } 133 | ``` 134 | 135 | Where: 136 | 137 | - **`key`** *required* the key name. 138 | - **`namespaceId`** *optional* The namespace id (can also be provided while instantiating `WorkersKV`). 139 | 140 | ### **`WorkersKV.deleteKey({ ... })`** 141 | 142 | Function definition: 143 | 144 | ```js 145 | const deleteKey= async ({ 146 | key, 147 | namespaceId = '', 148 | }) => { ... } 149 | ``` 150 | 151 | Where: 152 | 153 | - **`key`** *required* the key name. 154 | - **`namespaceId`** *optional* The namespace id (can also be provided while instantiating `WorkersKV`). 155 | 156 | ### **`WorkersKV.writeKey({ ... })`** 157 | 158 | Function definition: 159 | 160 | ```js 161 | const writeKey=> async ({ 162 | key, 163 | value, 164 | namespaceId = '', 165 | expiration = undefined, 166 | expiration_ttl = undefined, 167 | }) => { ... } 168 | ``` 169 | 170 | Where: 171 | 172 | - **`key`** *required* A key's name. The name may be at most 512 bytes. All printable, non-whitespace characters are valid. 173 | - **`value`** *required* A UTF-8 encoded string to be stored, up to 10 MB in length. 174 | - **`namespaceId`** *optional* Is the namespace id (can also be provided while instantiating `WorkersKV`). 175 | - **`expiration`** *optional* The time, measured in number of seconds since the UNIX epoch, at which the key should expire. 176 | - **`expiration_ttl`** *optional* The number of seconds for which the key should be visible before it expires. At least 60. 177 | 178 | ### **`WorkersKV.writeMultipleKeys({ ... })`** 179 | 180 | Function definition: 181 | 182 | ```js 183 | const writeMultipleKeys => async ({ 184 | keyValueMap, 185 | namespaceId = '', 186 | expiration = undefined, 187 | expiration_ttl = undefined, 188 | base64 = false, 189 | }) => { ... } 190 | ``` 191 | 192 | Where: 193 | 194 | - **`keyValueMap`** *required* Is an object with string keys and values. e.g `{ keyName1: 'keyValue1', keyName2: 'keyValue2' }` 195 | - **`namespaceId`** *optional* Is the namespace id (can also be provided while instantiating `WorkersKV`). 196 | - **`expiration`** *optional* The time, measured in number of seconds since the UNIX epoch, at which the key should expire. 197 | - **`expiration_ttl`** *optional* The number of seconds for which the key should be visible before it expires. At least 60. 198 | - **`base64`** *optional* Whether or not the server should base64 decode the value before storing it. Useful for writing values that wouldn't otherwise be valid JSON strings, such as images. Default: false. 199 | 200 | ### **`WorkersKV.deleteMultipleKeys({ ... })`** 201 | 202 | Function definition: 203 | 204 | ```js 205 | const deleteMultipleKeys = async ({ 206 | keys, 207 | namespaceId = '', 208 | }) => { ... } 209 | ``` 210 | 211 | Where: 212 | 213 | - **`keys`** *required* An array of keys to be deleted. 214 | - **`namespaceId`** *optional* The namespace id (can also be provided while instantiating `WorkersKV`). 215 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import jest from "eslint-plugin-jest"; 2 | import babelParser from "babel-eslint"; 3 | import path from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | import js from "@eslint/js"; 6 | import { FlatCompat } from "@eslint/eslintrc"; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | const compat = new FlatCompat({ 11 | baseDirectory: __dirname, 12 | recommendedConfig: js.configs.recommended, 13 | allConfig: js.configs.all 14 | }); 15 | 16 | export default [...compat.extends("eslint:recommended", "prettier"), { 17 | plugins: { 18 | jest, 19 | }, 20 | 21 | languageOptions: { 22 | globals: { 23 | ...jest.environments.globals.globals, 24 | process: true, 25 | console: true, 26 | module: true, 27 | Promise: true, 28 | exports: true, 29 | Buffer: true, 30 | globalThis: true, 31 | fetch: true, 32 | URLSearchParams: true, 33 | URL: true, 34 | Response: true, 35 | ANONYMITY_BOT: true, 36 | addEventListener: true, 37 | }, 38 | 39 | parser: babelParser, 40 | }, 41 | }]; -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | const WorkersKV = require('../'); 2 | 3 | (async () => { 4 | const cfAccountId = process.env.CLOUDFLARE_ACCOUNT_ID; 5 | const cfAuthKey = process.env.CLOUDFLARE_AUTH_KEY; 6 | const cfEmail = process.env.CLOUDFLARE_EMAIL; 7 | 8 | const KV = new WorkersKV({ 9 | cfAccountId, 10 | cfAuthKey, 11 | cfEmail, 12 | }); 13 | 14 | const namespaceId = process.env.CLOUDFLARE_NAMESPACE_ID; 15 | const results = await KV.listKeys({ namespaceId }); 16 | 17 | console.log(results); 18 | })(); 19 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const WorkersKVREST = require('./lib/workers'); 2 | 3 | module.exports = WorkersKVREST.default; 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | coverageDirectory: './coverage/', 4 | collectCoverage: true, 5 | coverageThreshold: { 6 | global: { 7 | lines: 100, 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sagi.io/workers-kv", 3 | "version": "0.0.14", 4 | "description": "Cloudflare Workers KV API for Node.js", 5 | "author": "Sagi Kedmi (https://sagi.io)", 6 | "homepage": "https://sagi.io", 7 | "main": "index.js", 8 | "license": "MIT", 9 | "private": false, 10 | "scripts": { 11 | "build": "babel --ignore '**/*.test.js' --ignore testdata src -d lib --verbose", 12 | "prepublish": "yarn build", 13 | "prettier:ci": "prettier --list-different ./src/*.js", 14 | "coverage": "yarn build && yarn jest --coverage", 15 | "lint": "yarn eslint ./src", 16 | "test": "yarn build && yarn jest" 17 | }, 18 | "devDependencies": { 19 | "@babel/cli": "^7.25.6", 20 | "@babel/core": "^7.25.2", 21 | "@babel/preset-env": "^7.25.4", 22 | "@eslint/eslintrc": "^3.1.0", 23 | "@eslint/js": "^9.9.1", 24 | "babel-eslint": "^10.1.0", 25 | "coveralls": "^3.1.1", 26 | "debug": "^4.3.6", 27 | "eslint": "^9.9.1", 28 | "eslint-config-prettier": "^9.1.0", 29 | "eslint-plugin-jest": "^28.8.2", 30 | "eslint-plugin-prettier": "^5.2.1", 31 | "jest": "^29.7.0", 32 | "jest-junit": "^16.0.0", 33 | "prettier": "^3.3.3" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/sagi/workers-kv.git" 38 | }, 39 | "keywords": [ 40 | "cloudflare", 41 | "workers", 42 | "cloudflare workers", 43 | "kv", 44 | "api", 45 | "node" 46 | ], 47 | "types": "types.d.ts" 48 | } 49 | -------------------------------------------------------------------------------- /src/methods.js: -------------------------------------------------------------------------------- 1 | import { 2 | httpsReq, 3 | checkKey, 4 | checkKeys, 5 | checkLimit, 6 | checkKeyValue, 7 | getNamespaceId, 8 | getQueryString, 9 | checkKeyValueMap, 10 | getPathWithQueryString, 11 | MAX_KEYS_LIMIT, 12 | } from './utils'; 13 | 14 | export const listKeys = 15 | (baseInputs) => 16 | async ({ 17 | namespaceId = '', 18 | limit = MAX_KEYS_LIMIT, 19 | cursor = undefined, 20 | prefix = undefined, 21 | } = {}) => { 22 | checkLimit(limit); 23 | const { host, basePath, headers } = baseInputs; 24 | const nsId = getNamespaceId(baseInputs, namespaceId); 25 | 26 | const qs = getQueryString({ limit, cursor, prefix }); 27 | const path = getPathWithQueryString(`${basePath}/${nsId}/keys`, qs); 28 | const method = 'GET'; 29 | const options = { method, host, path, headers }; 30 | 31 | return httpsReq(options); 32 | }; 33 | 34 | export const listAllKeys = 35 | (baseInputs) => 36 | async ({ 37 | namespaceId = '', 38 | prefix = undefined, 39 | limit = MAX_KEYS_LIMIT, 40 | } = {}) => { 41 | checkLimit(limit); 42 | 43 | const results = []; 44 | let result_info = null; 45 | let cursor = ''; 46 | 47 | do { 48 | const data = await exports.listKeys(baseInputs)({ 49 | limit, 50 | namespaceId, 51 | prefix, 52 | cursor, 53 | }); 54 | const { success, result } = data; 55 | 56 | success && result.forEach((x) => results.push(x)); 57 | 58 | ({ result_info } = data); 59 | ({ cursor } = result_info); 60 | } while (result_info && result_info.cursor); 61 | 62 | return { 63 | success: true, 64 | result: results, 65 | result_info: { count: results.length }, 66 | }; 67 | }; 68 | 69 | export const listNamespaces = 70 | (baseInputs) => 71 | async ({ page = 1, per_page = 50 } = {}) => { 72 | const { host, basePath, headers } = baseInputs; 73 | const qs = getQueryString({ page, per_page }); 74 | const path = getPathWithQueryString(basePath, qs); 75 | const method = 'GET'; 76 | const options = { method, host, path, headers }; 77 | 78 | return httpsReq(options); 79 | }; 80 | 81 | export const writeKey = 82 | (baseInputs) => 83 | async ({ 84 | key, 85 | value, 86 | namespaceId = '', 87 | expiration = undefined, 88 | expiration_ttl = undefined, 89 | }) => { 90 | checkKeyValue(key, value); 91 | const { host, basePath, headers } = baseInputs; 92 | const nsId = getNamespaceId(baseInputs, namespaceId); 93 | 94 | const qs = getQueryString({ expiration, expiration_ttl }); 95 | const keyPath = `${basePath}/${nsId}/values/${key}`; 96 | const path = getPathWithQueryString(keyPath, qs); 97 | const method = 'PUT'; 98 | const putHeaders = { 99 | ...headers, 100 | 'Content-Type': 'text/plain', 101 | 'Content-Length': value.length, 102 | }; 103 | 104 | const options = { method, host, path, headers: putHeaders }; 105 | 106 | return httpsReq(options, value); 107 | }; 108 | 109 | export const readKey = 110 | (baseInputs) => 111 | async ({ key, namespaceId = '' }) => { 112 | checkKey(key); 113 | const { host, basePath, headers } = baseInputs; 114 | const nsId = getNamespaceId(baseInputs, namespaceId); 115 | 116 | const path = `${basePath}/${nsId}/values/${key}`; 117 | const method = 'GET'; 118 | const options = { method, host, path, headers }; 119 | 120 | return httpsReq(options); 121 | }; 122 | 123 | export const deleteKey = 124 | (baseInputs) => 125 | async ({ key, namespaceId = '' }) => { 126 | checkKey(key); 127 | const { host, basePath, headers } = baseInputs; 128 | const nsId = getNamespaceId(baseInputs, namespaceId); 129 | 130 | const path = `${basePath}/${nsId}/values/${key}`; 131 | const method = 'DELETE'; 132 | const options = { method, host, path, headers }; 133 | 134 | return httpsReq(options); 135 | }; 136 | 137 | export const writeMultipleKeys = 138 | (baseInputs) => 139 | async ({ 140 | keyValueMap, 141 | namespaceId = '', 142 | expiration = undefined, 143 | expiration_ttl = undefined, 144 | base64 = false, 145 | }) => { 146 | checkKeyValueMap(keyValueMap); 147 | const { host, basePath, headers } = baseInputs; 148 | const nsId = getNamespaceId(baseInputs, namespaceId); 149 | 150 | const qs = getQueryString({ expiration, expiration_ttl }); 151 | const bulkPath = `${basePath}/${nsId}/bulk`; 152 | const path = getPathWithQueryString(bulkPath, qs); 153 | const method = 'PUT'; 154 | 155 | const bodyArray = Object.entries(keyValueMap).map(([key, value]) => ({ 156 | key, 157 | value, 158 | base64, 159 | })); 160 | 161 | const body = JSON.stringify(bodyArray); 162 | const putHeaders = { 163 | ...headers, 164 | 'Content-Type': 'application/json', 165 | 'Content-Length': body.length, 166 | }; 167 | 168 | const options = { method, host, path, headers: putHeaders }; 169 | return httpsReq(options, body); 170 | }; 171 | 172 | export const deleteMultipleKeys = 173 | (baseInputs) => 174 | async ({ keys, namespaceId = '' }) => { 175 | checkKeys(keys); 176 | const { host, basePath, headers } = baseInputs; 177 | const nsId = getNamespaceId(baseInputs, namespaceId); 178 | 179 | const path = `${basePath}/${nsId}/bulk`; 180 | const method = 'DELETE'; 181 | const body = JSON.stringify(keys); 182 | const deleteHeaders = { 183 | ...headers, 184 | 'Content-Type': 'application/json', 185 | 'Content-Length': body.length, 186 | }; 187 | const options = { method, host, path, headers: deleteHeaders }; 188 | 189 | return httpsReq(options, body); 190 | }; 191 | 192 | export const METHODS = { 193 | listKeys, 194 | listAllKeys, 195 | readKey, 196 | writeKey, 197 | writeMultipleKeys, 198 | deleteKey, 199 | deleteMultipleKeys, 200 | listNamespaces, 201 | }; 202 | -------------------------------------------------------------------------------- /src/methods.test.js: -------------------------------------------------------------------------------- 1 | import * as methods from './methods'; 2 | import * as utils from './utils'; 3 | 4 | describe('methods', () => { 5 | utils.httpsReq = jest.fn(); 6 | const cfAccountId = 'cf_account_id'; 7 | const namespaceId = 'namespace_id'; 8 | const cfAuthKey = 'cf_auth_key'; 9 | const cfEmail = 'cf_email'; 10 | const host = 'api.cloudflare.com'; 11 | const basePath = `/client/v4/accounts/${cfAccountId}/storage/kv/namespaces`; 12 | 13 | const headers = { 14 | 'X-Auth-Email': cfEmail, 15 | 'X-Auth-Key': cfAuthKey, 16 | }; 17 | const baseInputs = { host, basePath, namespaceId, headers }; 18 | const baseInputsWithoutNamespaceId = { 19 | host, 20 | basePath, 21 | namespaceId: '', 22 | headers, 23 | }; 24 | 25 | beforeEach(() => { 26 | utils.httpsReq.mockClear(); 27 | }); 28 | 29 | test('listKeys', () => { 30 | methods.listKeys(baseInputs)({ limit: 123, prefix: 'prod_' }); 31 | const expectedOptions1 = { 32 | headers: { 'X-Auth-Email': 'cf_email', 'X-Auth-Key': 'cf_auth_key' }, 33 | host: 'api.cloudflare.com', 34 | method: 'GET', 35 | path: '/client/v4/accounts/cf_account_id/storage/kv/namespaces/namespace_id/keys?limit=123&prefix=prod_', 36 | }; 37 | expect(utils.httpsReq).toHaveBeenCalledWith(expectedOptions1); 38 | 39 | utils.httpsReq.mockClear(); 40 | 41 | methods.listKeys(baseInputsWithoutNamespaceId)({ 42 | namespaceId: 'namespace_id_123', 43 | limit: 123, 44 | prefix: 'prod_', 45 | }); 46 | const expectedOptions2 = { 47 | headers: { 'X-Auth-Email': 'cf_email', 'X-Auth-Key': 'cf_auth_key' }, 48 | host: 'api.cloudflare.com', 49 | method: 'GET', 50 | path: '/client/v4/accounts/cf_account_id/storage/kv/namespaces/namespace_id_123/keys?limit=123&prefix=prod_', 51 | }; 52 | 53 | expect(utils.httpsReq).toHaveBeenCalledWith(expectedOptions2); 54 | }); 55 | 56 | test('listAllKeys', async () => { 57 | const listKeysSpy = jest.spyOn(methods, 'listKeys'); 58 | 59 | const retVal1 = jest.fn(() => ({ 60 | success: true, 61 | result: ['prod_x', 'prod_y'], 62 | result_info: { cursor: 'xyz' }, 63 | })); 64 | 65 | const retVal2 = jest.fn(() => ({ 66 | success: true, 67 | result: ['prod_z', 'prod_t'], 68 | result_info: { cursor: '' }, 69 | })); 70 | 71 | listKeysSpy.mockReturnValueOnce(retVal1); 72 | listKeysSpy.mockReturnValueOnce(retVal2); 73 | 74 | const expected = { 75 | result: ['prod_x', 'prod_y', 'prod_z', 'prod_t'], 76 | result_info: { 77 | count: 4, 78 | }, 79 | success: true, 80 | }; 81 | await expect( 82 | methods.listAllKeys(baseInputs)({ prefix: 'prod_' }) 83 | ).resolves.toEqual(expected); 84 | }); 85 | 86 | test('listNamespaces', () => { 87 | methods.listNamespaces(baseInputs)({ page: 1, per_page: '17' }); 88 | const expectedOptions1 = { 89 | headers: { 'X-Auth-Email': 'cf_email', 'X-Auth-Key': 'cf_auth_key' }, 90 | host: 'api.cloudflare.com', 91 | method: 'GET', 92 | path: '/client/v4/accounts/cf_account_id/storage/kv/namespaces?page=1&per_page=17', 93 | }; 94 | expect(utils.httpsReq).toHaveBeenCalledWith(expectedOptions1); 95 | }); 96 | 97 | test('writeKey', () => { 98 | const key = 'xKey'; 99 | const value = 'xValue'; 100 | methods.writeKey(baseInputs)({ 101 | key, 102 | value, 103 | expiration_ttl: 123, 104 | }); 105 | const expectedOptions1 = { 106 | headers: { 107 | 'Content-Length': 6, 108 | 'Content-Type': 'text/plain', 109 | 'X-Auth-Email': 'cf_email', 110 | 'X-Auth-Key': 'cf_auth_key', 111 | }, 112 | host: 'api.cloudflare.com', 113 | method: 'PUT', 114 | path: '/client/v4/accounts/cf_account_id/storage/kv/namespaces/namespace_id/values/xKey?expiration_ttl=123', 115 | }; 116 | expect(utils.httpsReq).toHaveBeenCalledWith(expectedOptions1, value); 117 | }); 118 | 119 | test('readKey', () => { 120 | const key = 'xKey'; 121 | methods.readKey(baseInputs)({ key }); 122 | const expectedOptions1 = { 123 | headers: { 'X-Auth-Email': 'cf_email', 'X-Auth-Key': 'cf_auth_key' }, 124 | host: 'api.cloudflare.com', 125 | method: 'GET', 126 | path: '/client/v4/accounts/cf_account_id/storage/kv/namespaces/namespace_id/values/xKey', 127 | }; 128 | expect(utils.httpsReq).toHaveBeenCalledWith(expectedOptions1); 129 | }); 130 | 131 | test('deleteKey', () => { 132 | const key = 'xKey'; 133 | methods.deleteKey(baseInputs)({ key }); 134 | const expectedOptions1 = { 135 | headers: { 'X-Auth-Email': 'cf_email', 'X-Auth-Key': 'cf_auth_key' }, 136 | host: 'api.cloudflare.com', 137 | method: 'DELETE', 138 | path: '/client/v4/accounts/cf_account_id/storage/kv/namespaces/namespace_id/values/xKey', 139 | }; 140 | expect(utils.httpsReq).toHaveBeenCalledWith(expectedOptions1); 141 | }); 142 | 143 | test('deleteMultipleKeys', () => { 144 | const keys = ['key1', 'key2']; 145 | methods.deleteMultipleKeys(baseInputs)({ keys }); 146 | const expectedOptions1 = { 147 | headers: { 148 | 'Content-Length': 15, 149 | 'Content-Type': 'application/json', 150 | 'X-Auth-Email': 'cf_email', 151 | 'X-Auth-Key': 'cf_auth_key', 152 | }, 153 | host: 'api.cloudflare.com', 154 | method: 'DELETE', 155 | path: '/client/v4/accounts/cf_account_id/storage/kv/namespaces/namespace_id/bulk', 156 | }; 157 | expect(utils.httpsReq).toHaveBeenCalledWith( 158 | expectedOptions1, 159 | JSON.stringify(keys) 160 | ); 161 | }); 162 | 163 | test('writeMultipleKeys', () => { 164 | const base64 = true; 165 | const keyValueMap = { key1: 'value1', key2: 'value2' }; 166 | methods.writeMultipleKeys(baseInputs)({ 167 | keyValueMap, 168 | expiration_ttl: 123, 169 | base64, 170 | }); 171 | const body = JSON.stringify([ 172 | { key: 'key1', value: 'value1', base64 }, 173 | { key: 'key2', value: 'value2', base64 }, 174 | ]); 175 | const expectedOptions1 = { 176 | headers: { 177 | 'Content-Length': body.length, 178 | 'Content-Type': 'application/json', 179 | 'X-Auth-Email': 'cf_email', 180 | 'X-Auth-Key': 'cf_auth_key', 181 | }, 182 | host: 'api.cloudflare.com', 183 | method: 'PUT', 184 | path: '/client/v4/accounts/cf_account_id/storage/kv/namespaces/namespace_id/bulk?expiration_ttl=123', 185 | }; 186 | 187 | expect(utils.httpsReq).toHaveBeenCalledWith(expectedOptions1, body); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import https from 'https'; 2 | import querystring from 'querystring'; 3 | 4 | const workersKvDebug = require('debug')('workers-kv-debug'); 5 | 6 | export const MAX_KEYS_LIMIT = 1000; 7 | export const MIN_KEYS_LIMIT = 10; 8 | export const MAX_KEY_LENGTH = 512; 9 | export const MAX_VALUE_LENGTH = 10 * 1024 * 1024; 10 | export const MIN_EXPIRATION_TTL_SECONDS = 60; 11 | export const MAX_MULTIPLE_KEYS_LENGTH = 10000; 12 | export const ERROR_PREFIX = '@sagi.io/workers-kv'; 13 | 14 | export const httpsAgent = new https.Agent({ keepAlive: true }); 15 | 16 | export const httpsReq = (options, reqBody = '') => 17 | new Promise((resolve, reject) => { 18 | options.agent = httpsAgent; 19 | const req = https.request(options, (res) => { 20 | const { headers } = res; 21 | workersKvDebug({ headers }); 22 | let data = ''; 23 | res.on('data', (chunk) => (data += chunk)); 24 | res.on('end', () => responseBodyResolver(resolve)(headers, data)); 25 | }); 26 | req.on('error', (e) => reject(e)); 27 | !!reqBody && req.write(reqBody); 28 | req.end(); 29 | }); 30 | 31 | export const responseBodyResolver = (resolve) => (headers, data) => { 32 | const contentType = headers['content-type']; 33 | if (contentType.includes('text/plain')) { 34 | resolve(data); 35 | } else if (contentType.includes('application/json')) { 36 | resolve(JSON.parse(data)); 37 | } else if (contentType.includes('application/octet-stream')) { 38 | resolve(data); 39 | } else { 40 | throw new Error( 41 | `${ERROR_PREFIX} only JSON, octet-stream or plain text content types are expected. Received content-type: ${contentType}.` 42 | ); 43 | } 44 | }; 45 | 46 | export const removeUndefineds = (obj) => JSON.parse(JSON.stringify(obj)); 47 | 48 | export const getQueryString = (obj) => 49 | querystring.stringify(removeUndefineds(obj)); 50 | 51 | export const getPathWithQueryString = (path, qs) => path + (qs ? `?${qs}` : ''); 52 | 53 | export const checkLimit = (limit) => { 54 | if (limit < MIN_KEYS_LIMIT || limit > MAX_KEYS_LIMIT) { 55 | throw new Error( 56 | `${ERROR_PREFIX}: limit should be between ${MIN_KEYS_LIMIT} and ${MAX_KEYS_LIMIT}. Given limit: ${limit}.` 57 | ); 58 | } 59 | }; 60 | 61 | export const isString = (x) => 62 | typeof x === 'string' || 63 | (x && Object.prototype.toString.call(x) === '[object String]'); 64 | 65 | export const checkKey = (key) => { 66 | if (!key || !isString(key) || key.length > MAX_KEY_LENGTH) { 67 | throw new Error( 68 | `${ERROR_PREFIX}: Key length should be less than ${MAX_KEY_LENGTH}. ` 69 | ); 70 | } 71 | }; 72 | 73 | export const checkKeyValue = (key, value) => { 74 | checkKey(key); 75 | if (!value || !isString(value) || value.length > MAX_VALUE_LENGTH) { 76 | throw new Error( 77 | `${ERROR_PREFIX}: Value length should be less than ${MAX_VALUE_LENGTH}.` 78 | ); 79 | } 80 | }; 81 | 82 | export const checkMultipleKeysLength = (method, length) => { 83 | if (length > MAX_MULTIPLE_KEYS_LENGTH) { 84 | throw new Error( 85 | `${ERROR_PREFIX}: method ${method} must be provided a container with at most ${MAX_MULTIPLE_KEYS_LENGTH} items.` 86 | ); 87 | } 88 | }; 89 | 90 | export const checkKeyValueMap = (keyValueMap) => { 91 | const entries = keyValueMap ? Object.entries(keyValueMap) : []; 92 | if (!entries.length) { 93 | throw new Error( 94 | `${ERROR_PREFIX}: keyValueMap must be an object thats maps string keys to string values.` 95 | ); 96 | } 97 | checkMultipleKeysLength('checkKeyValue', entries.length); 98 | entries.forEach(([k, v]) => checkKeyValue(k, v)); 99 | }; 100 | 101 | export const checkKeys = (keys) => { 102 | if (!keys || !Array.isArray(keys) || !keys.length) { 103 | throw new Error( 104 | `${ERROR_PREFIX}: keys must be an array of strings (key names).` 105 | ); 106 | } 107 | checkMultipleKeysLength('checkKeys', keys.length); 108 | keys.forEach(checkKey); 109 | }; 110 | 111 | export const getNamespaceId = (baseInputs, namespaceId) => { 112 | const nsId = namespaceId || baseInputs.namespaceId; 113 | if (!nsId) { 114 | throw new Error( 115 | `${ERROR_PREFIX}: namepspaceId wasn't provided to either WorkersKV or the specific method.` 116 | ); 117 | } 118 | return nsId; 119 | }; 120 | 121 | export const getAuthHeaders = (cfEmail, cfAuthKey, cfAuthToken) => { 122 | if (cfAuthToken) { 123 | return { Authorization: `Bearer ${cfAuthToken}` }; 124 | } 125 | 126 | if (cfEmail && cfAuthKey) { 127 | return { 'X-Auth-Email': cfEmail, 'X-Auth-Key': cfAuthKey }; 128 | } 129 | 130 | throw new Error( 131 | `${ERROR_PREFIX}: Either cfAuthToken or cfEmail and cfAuthKey must be provided` 132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /src/utils.test.js: -------------------------------------------------------------------------------- 1 | import * as utils from './utils'; 2 | import https from 'https'; 3 | import EventEmitter from 'events'; 4 | 5 | jest.mock('https'); 6 | 7 | describe('utils', () => { 8 | test('httpsReq', async () => { 9 | const req = new EventEmitter(); 10 | req.write = jest.fn(); 11 | req.end = jest.fn(); 12 | https.request.mockReturnValueOnce(req); 13 | 14 | const options = { host: 'sagi.io', path: '/api.php' }; 15 | const body = 'abcdefg'; 16 | const p1 = utils.httpsReq(options, body); 17 | req.emit('error', 'errmsg'); 18 | await expect(p1).rejects.toEqual('errmsg'); 19 | 20 | https.request.mockClear(); 21 | https.request.mockReturnValueOnce(req); 22 | 23 | const p2 = utils.httpsReq(options, body); 24 | 25 | const reqCallback = https.request.mock.calls[0][1]; 26 | const res = new EventEmitter(); 27 | res.headers = { 'content-type': 'application/json' }; 28 | reqCallback(res); 29 | const data = '{"a":1234}'; 30 | res.emit('data', data); 31 | res.emit('end'); 32 | await expect(p2).resolves.toEqual({ a: 1234 }); 33 | 34 | expect(https.request).toHaveBeenCalledWith(options, expect.any(Function)); 35 | }); 36 | 37 | test('responseBodyResolver', () => { 38 | const resolve1 = jest.fn(); 39 | const headers1 = { 'content-type': 'text/plain' }; 40 | const data1 = 'abcdefg'; 41 | utils.responseBodyResolver(resolve1)(headers1, data1); 42 | expect(resolve1).toHaveBeenCalledWith(data1); 43 | 44 | const resolve2 = jest.fn(); 45 | const headers2 = { 'content-type': 'application/json' }; 46 | const data2 = '{ "a": 1234 }'; 47 | utils.responseBodyResolver(resolve2)(headers2, data2); 48 | expect(resolve2).toHaveBeenCalledWith(JSON.parse(data2)); 49 | 50 | const contentType = 'application/bla'; 51 | const headers3 = { 'content-type': contentType }; 52 | const data3 = 'ZIP..blaaaaa'; 53 | expect(() => utils.responseBodyResolver(resolve2)(headers3, data3)).toThrow( 54 | `${ 55 | utils.ERROR_PREFIX 56 | } only JSON, octet-stream or plain text content types are expected. Received content-type: ${contentType}.` 57 | ); 58 | 59 | const resolve4 = jest.fn(); 60 | const headers4 = { 'content-type': 'application/octet-stream' }; 61 | const data4 = '{ "a": 1234 }'; 62 | utils.responseBodyResolver(resolve4)(headers4, data4); 63 | expect(resolve4).toHaveBeenCalledWith(data4); 64 | }); 65 | 66 | test('removeUndefineds', () => { 67 | const obj = { a: 'b', c: undefined }; 68 | expect(utils.removeUndefineds(obj)).toEqual({ a: 'b' }); 69 | }); 70 | 71 | test('getQueryString', () => { 72 | const obj = { limit: 1000, cursor: undefined, prefix: 'prod_' }; 73 | expect(utils.getQueryString(obj)).toEqual('limit=1000&prefix=prod_'); 74 | }); 75 | 76 | test('getPathWithQueryString', () => { 77 | const path = '/api/bla.php'; 78 | const qs1 = 'limit=123&prefix=dev_'; 79 | expect(utils.getPathWithQueryString(path, qs1)).toEqual(`${path}?${qs1}`); 80 | const qs2 = ''; 81 | expect(utils.getPathWithQueryString(path, qs2)).toEqual(path); 82 | }); 83 | 84 | test('checkLimit', () => { 85 | const limit1 = 9; 86 | expect(() => utils.checkLimit(limit1)).toThrow( 87 | `${utils.ERROR_PREFIX}: limit should be between ${ 88 | utils.MIN_KEYS_LIMIT 89 | } and ${utils.MAX_KEYS_LIMIT}. Given limit: ${limit1}.` 90 | ); 91 | const limit2 = 1001; 92 | expect(() => utils.checkLimit(limit2)).toThrow( 93 | `${utils.ERROR_PREFIX}: limit should be between ${ 94 | utils.MIN_KEYS_LIMIT 95 | } and ${utils.MAX_KEYS_LIMIT}. Given limit: ${limit2}.` 96 | ); 97 | }); 98 | 99 | test('isString', () => { 100 | const s1 = 123; 101 | expect(utils.isString(s1)).toBe(false); 102 | const s2 = 'abcde'; 103 | expect(utils.isString(s2)).toBe(true); 104 | }); 105 | 106 | test('checkKey', () => { 107 | const k1 = null; 108 | expect(() => utils.checkKey(k1)).toThrow( 109 | `${utils.ERROR_PREFIX}: Key length should be less than ${ 110 | utils.MAX_KEY_LENGTH 111 | }` 112 | ); 113 | const k2 = ''; 114 | expect(() => utils.checkKey(k2)).toThrow( 115 | `${utils.ERROR_PREFIX}: Key length should be less than ${ 116 | utils.MAX_KEY_LENGTH 117 | }` 118 | ); 119 | }); 120 | 121 | test('checkKeyValue', () => { 122 | const key1 = 'abcd'; 123 | const value1 = null; 124 | expect(() => utils.checkKeyValue(key1, value1)).toThrow( 125 | `${utils.ERROR_PREFIX}: Value length should be less than ${ 126 | utils.MAX_VALUE_LENGTH 127 | }.` 128 | ); 129 | 130 | const key2 = ''; 131 | const value2 = 'bla'; 132 | expect(() => utils.checkKeyValue(key2, value2)).toThrow( 133 | `${utils.ERROR_PREFIX}: Key length should be less than ${ 134 | utils.MAX_KEY_LENGTH 135 | }` 136 | ); 137 | const key3 = 'key'; 138 | const value3 = 'value'; 139 | expect(() => utils.checkKeyValue(key3, value3)).not.toThrow(); 140 | }); 141 | 142 | test('checkKeyValueMap', () => { 143 | const keyValueMap1 = {}; 144 | expect(() => utils.checkKeyValueMap(keyValueMap1)).toThrow( 145 | `${ 146 | utils.ERROR_PREFIX 147 | }: keyValueMap must be an object thats maps string keys to string values.` 148 | ); 149 | const keyValueMap2 = { key1: 'value1' }; 150 | expect(() => utils.checkKeyValueMap(keyValueMap2)).not.toThrow(); 151 | }); 152 | 153 | test('checkMultipleKeysLength', () => { 154 | const method = 'xyz'; 155 | const length1 = utils.MAX_MULTIPLE_KEYS_LENGTH + 1; 156 | expect(() => utils.checkMultipleKeysLength(method, length1)).toThrow( 157 | `${ 158 | utils.ERROR_PREFIX 159 | }: method ${method} must be provided a container with at most ${ 160 | utils.MAX_MULTIPLE_KEYS_LENGTH 161 | } items.` 162 | ); 163 | const length2 = utils.MAX_MULTIPLE_KEYS_LENGTH + -1; 164 | expect(() => utils.checkMultipleKeysLength(method, length2)).not.toThrow(); 165 | }); 166 | 167 | test('checkKeys', () => { 168 | const keys1 = []; 169 | expect(() => utils.checkKeys(keys1)).toThrow( 170 | `${utils.ERROR_PREFIX}: keys must be an array of strings (key names).` 171 | ); 172 | const keys2 = ['the', 'next', 'one', 'is', 'bad ->', '']; 173 | expect(() => utils.checkKeys(keys2)).toThrow( 174 | `${utils.ERROR_PREFIX}: Key length should be less than ${ 175 | utils.MAX_KEY_LENGTH 176 | }` 177 | ); 178 | const keys3 = new Array(utils.MAX_MULTIPLE_KEYS_LENGTH + 1).fill('abcde'); 179 | expect(() => utils.checkKeys(keys3)).toThrow( 180 | `${ 181 | utils.ERROR_PREFIX 182 | }: method checkKeys must be provided a container with at most ${ 183 | utils.MAX_MULTIPLE_KEYS_LENGTH 184 | } items.` 185 | ); 186 | 187 | const keys4 = ['all', 'good', 'in', 'the', 'hood']; 188 | expect(() => utils.checkKeys(keys4)).not.toThrow(); 189 | }); 190 | 191 | test('getNamespaceId', () => { 192 | const baseInputs1 = { namespaceId: '' }; 193 | const namespaceId1 = ''; 194 | expect(() => utils.getNamespaceId(baseInputs1, namespaceId1)).toThrow( 195 | `${ 196 | utils.ERROR_PREFIX 197 | }: namepspaceId wasn't provided to either WorkersKV or the specific method.` 198 | ); 199 | 200 | const baseInputs2 = { namespaceId: '' }; 201 | const namespaceId2 = 'asdf'; 202 | expect(utils.getNamespaceId(baseInputs2, namespaceId2)).toEqual('asdf'); 203 | 204 | const baseInputs3 = { namespaceId: 'abcd' }; 205 | const namespaceId3 = ''; 206 | expect(utils.getNamespaceId(baseInputs3, namespaceId3)).toEqual('abcd'); 207 | }); 208 | 209 | test('getAuthHeaders', () => { 210 | expect(() => utils.getAuthHeaders(null, null, null)).toThrow(); 211 | expect(() => utils.getAuthHeaders('sagi@sagi.io', null, null)).toThrow(); 212 | expect(utils.getAuthHeaders('sagi@sagi.io', 'DEADBEEF', null)).toEqual({ 213 | 'X-Auth-Email': 'sagi@sagi.io', 214 | 'X-Auth-Key': 'DEADBEEF', 215 | }); 216 | expect(utils.getAuthHeaders(null, null, 'CAFEBABE')).toEqual({ 217 | Authorization: 'Bearer CAFEBABE', 218 | }); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /src/workers.js: -------------------------------------------------------------------------------- 1 | import { METHODS } from './methods'; 2 | import { getAuthHeaders } from './utils'; 3 | 4 | const WorkersKVREST = function ({ 5 | cfAccountId, 6 | cfEmail = null, 7 | cfAuthKey = null, 8 | cfAuthToken = null, 9 | namespaceId = '', 10 | }) { 11 | const host = 'api.cloudflare.com'; 12 | const basePath = `/client/v4/accounts/${cfAccountId}/storage/kv/namespaces`; 13 | const headers = getAuthHeaders(cfEmail, cfAuthKey, cfAuthToken); 14 | 15 | const baseInputs = { host, basePath, namespaceId, headers }; 16 | 17 | const API = {}; 18 | Object.entries(METHODS).forEach(([name, fn]) => (API[name] = fn(baseInputs))); 19 | return API; 20 | }; 21 | 22 | export default WorkersKVREST; 23 | -------------------------------------------------------------------------------- /src/workers.test.js: -------------------------------------------------------------------------------- 1 | import * as methods from './methods'; 2 | import WorkersKV from './workers'; 3 | 4 | describe('workers', () => { 5 | test('WorkersKV', () => { 6 | const cfMethod = jest.fn(() => () => {}); 7 | methods.METHODS = { cfMethod }; 8 | 9 | const cfAccountId = '1234'; 10 | const cfEmail = 'bla@gmail.com'; 11 | const cfAuthKey = 'abcd'; 12 | const namespaceId = 'bla_id'; 13 | 14 | const retVal = WorkersKV({ cfAccountId, cfEmail, cfAuthKey, namespaceId }); 15 | 16 | const expected = { 17 | basePath: '/client/v4/accounts/1234/storage/kv/namespaces', 18 | headers: { 'X-Auth-Email': 'bla@gmail.com', 'X-Auth-Key': 'abcd' }, 19 | host: 'api.cloudflare.com', 20 | namespaceId: 'bla_id', 21 | }; 22 | expect(cfMethod).toHaveBeenCalledWith(expected); 23 | 24 | expect(retVal).toEqual({ cfMethod: expect.any(Function) }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@sagi.io/workers-kv" { 2 | interface WorkersKVRESTOptions { 3 | cfAccountId: string; 4 | cfEmail?: string; 5 | cfAuthKey?: string; 6 | cfAuthToken?: string; 7 | namespaceId?: string; 8 | } 9 | 10 | interface ListKeysOptions { 11 | namespaceId?: string; 12 | limit?: number; 13 | cursor?: string; 14 | prefix?: string; 15 | } 16 | 17 | interface ListAllKeysOptions { 18 | namespaceId?: string; 19 | prefix?: string; 20 | limit?: number; 21 | } 22 | 23 | interface ListNamespacesOptions { 24 | page?: number; 25 | per_page?: number; 26 | } 27 | 28 | interface ReadKeyOptions { 29 | key: string; 30 | namespaceId?: string; 31 | } 32 | 33 | interface DeleteKeyOptions { 34 | key: string; 35 | namespaceId?: string; 36 | } 37 | 38 | interface WriteKeyOptions { 39 | key: string; 40 | value: string; 41 | namespaceId?: string; 42 | expiration?: number; 43 | expiration_ttl?: number; 44 | } 45 | 46 | interface WriteMultipleKeysOptions { 47 | keyValueMap: Record; 48 | namespaceId?: string; 49 | expiration?: number; 50 | expiration_ttl?: number; 51 | base64?: boolean; 52 | } 53 | 54 | interface DeleteMultipleKeysOptions { 55 | keys: string[]; 56 | namespaceId?: string; 57 | } 58 | 59 | interface Response { 60 | result: null; 61 | success: boolean; 62 | errors: { 63 | code: number; 64 | message: string; 65 | }[]; 66 | messages: { 67 | code: number; 68 | message: string; 69 | }[]; 70 | } 71 | 72 | interface ReadResponse extends Omit { 73 | result: string; 74 | } 75 | 76 | interface ListResponse extends Omit { 77 | result: { expiration?: number; metadata?: object; key: string }[]; 78 | result_info: { 79 | count: number; 80 | cursor: string; 81 | }; 82 | } 83 | 84 | interface ListNamespacesResponse extends Omit { 85 | result: { id: string; supports_url_encoding?: boolean; title: string }[]; 86 | result_info: { 87 | count: number; 88 | page: number; 89 | per_page: number; 90 | total_count: number; 91 | }; 92 | } 93 | 94 | class WorkersKVREST { 95 | constructor(options: WorkersKVRESTOptions); 96 | 97 | listKeys(options: ListKeysOptions): Promise; 98 | listAllKeys(options: ListAllKeysOptions): Promise; 99 | listNamespaces( 100 | options: ListNamespacesOptions, 101 | ): Promise; 102 | readKey(options: ReadKeyOptions): Promise; 103 | deleteKey(options: DeleteKeyOptions): Promise; 104 | writeKey(options: WriteKeyOptions): Promise; 105 | writeMultipleKeys(options: WriteMultipleKeysOptions): Promise; 106 | deleteMultipleKeys(options: DeleteMultipleKeysOptions): Promise; 107 | } 108 | 109 | export = WorkersKVREST; 110 | } 111 | --------------------------------------------------------------------------------