├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src └── index.js └── test └── index.test.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "plugin:prettier/recommended"], 3 | "plugins": [], 4 | "rules": { 5 | "no-use-before-define": ["error", { "functions": false }], 6 | "comma-dangle": ["error", "always-multiline"], 7 | "arrow-parens": 0 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "always" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Markus Ahlstrand 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 | # cloudflare-kv-storage-rest 2 | 3 | Tiny javascript and node wrapper for the cloudflare kv-storage rest-api. 4 | 5 | There are a few api-wrappers available on github but so far I haven't found any that supports the new metadata functionality. 6 | 7 | The idea with library is to create a wrapper around the the rest api that works exactly as the runtime API (https://developers.cloudflare.com/workers/reference/apis/kv) 8 | 9 | ## Usage in browser 10 | 11 | Create an instance of Kv-Storage. The instance will expose the same api as the variable in the runtime API. 12 | 13 | ## Usage in nodejs 14 | 15 | To use this is nodejs you can add node-fetch and form-data to the global context: 16 | 17 | ``` 18 | global.fetch = require('node-fetch'); 19 | global.FormData = require('form-data'); 20 | ``` 21 | 22 | Another options is to pass the fetch and form-data shims to the constructor 23 | 24 | ``` 25 | const fetch = require('node-fetch'); 26 | const FormData = require('form-data'); 27 | 28 | const kvStorage = new KvStorage({ 29 | namespace: 'namespace', 30 | accountId: 'accountId', 31 | fetch, 32 | FormData, 33 | }); 34 | ``` 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare-kv-storage-rest", 3 | "version": "1.1.1", 4 | "description": "Tiny javascript and node wrapper for the cloudflare kv-storage rest-api", 5 | "main": "src/index.js", 6 | "files": [ 7 | "src/index.js" 8 | ], 9 | "devDependencies": { 10 | "chai": "4.3.4", 11 | "eslint": "7.24.0", 12 | "eslint-config-airbnb-base": "14.2.1", 13 | "eslint-config-prettier": "8.2.0", 14 | "eslint-plugin-import": "2.22.1", 15 | "eslint-plugin-prettier": "3.4.0", 16 | "form-data": "4.0.0", 17 | "mocha": "8.3.2", 18 | "nock": "13.0.11", 19 | "node-fetch": "2.6.1", 20 | "prettier": "2.2.1" 21 | }, 22 | "scripts": { 23 | "lint": "eslint src", 24 | "test": "npm run unit && npm run lint", 25 | "unit": "mocha test/**/*.test.js", 26 | "package": "rm -rf node_modules && npm install --prod" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/markusahlstrand/cloudflare-kv-storage-rest.git" 31 | }, 32 | "keywords": [ 33 | "cloudflare", 34 | "kv-storage" 35 | ], 36 | "author": "Markus Ahlstrand", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/markusahlstrand/cloudflare-kv-storage-rest/issues" 40 | }, 41 | "homepage": "https://github.com/markusahlstrand/cloudflare-kv-storage-rest#readme", 42 | "dependencies": { 43 | "fetch-retry": "4.1.1", 44 | "lodash.get": "4.4.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const lodashGet = require('lodash.get'); 2 | const fetchRetry = require('fetch-retry'); 3 | 4 | const _ = { 5 | get: lodashGet, 6 | }; 7 | 8 | module.exports = class KvStorage { 9 | constructor({ accountId, namespace, authEmail, authKey, authToken, fetch, FormData }) { 10 | this.accountId = accountId; 11 | this.namespace = namespace; 12 | this.authEmail = authEmail; 13 | this.authKey = authKey; 14 | this.authToken = authToken; 15 | this.fetch = fetchRetry(fetch || global.fetch, { 16 | retryOn: [429, 500, 503], 17 | }); 18 | this.FormData = FormData || global.FormData; 19 | } 20 | 21 | getNamespaceUrl() { 22 | return `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/storage/kv/namespaces/${this.namespace}`; 23 | } 24 | 25 | getUrlForKey(key) { 26 | return new URL(`${this.getNamespaceUrl()}/values/${key}`); 27 | } 28 | 29 | getAuthHeaders() { 30 | const headers = {}; 31 | if (this.authEmail) headers["X-Auth-Email"] = this.authEmail; 32 | if (this.authKey) headers["X-Auth-Key"] = this.authKey; 33 | if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`; 34 | return headers; 35 | } 36 | 37 | async list(options = {}) { 38 | const { prefix, limit, cursor } = options; 39 | const url = new URL(`${this.getNamespaceUrl()}/keys`); 40 | 41 | const searchParams = new URLSearchParams(); 42 | 43 | if (prefix) { 44 | searchParams.append('prefix', prefix); 45 | } 46 | 47 | if (limit) { 48 | searchParams.append('limit', limit); 49 | } 50 | 51 | if (cursor) { 52 | searchParams.append('cursor', cursor); 53 | } 54 | 55 | url.search = searchParams.toString(); 56 | 57 | // eslint-disable-next-line no-undef 58 | const response = await this.fetch(url, { 59 | headers: this.getAuthHeaders(), 60 | }); 61 | 62 | if (response.ok) { 63 | const body = await response.json(); 64 | 65 | return { 66 | keys: body.result, 67 | list_complete: body.result_info.count < (limit || 1000), 68 | cursor: body.result_info.cursor, 69 | }; 70 | } 71 | 72 | return null; 73 | } 74 | 75 | async get(key, type = 'text') { 76 | const url = this.getUrlForKey(key); 77 | 78 | // eslint-disable-next-line no-undef 79 | const response = await this.fetch(url, { 80 | headers: this.getAuthHeaders(), 81 | }); 82 | 83 | if (response.ok) { 84 | switch (type) { 85 | case 'text': 86 | return response.text(); 87 | case 'json': 88 | return response.json(); 89 | case 'stream': 90 | return response.body; 91 | case 'arraybuffer': 92 | return response.arrayBuffer(); 93 | default: 94 | throw new Error('Type not supported'); 95 | } 96 | } 97 | 98 | return null; 99 | } 100 | 101 | async getWithMetadata(key, type) { 102 | const [value, keys] = await Promise.all([ 103 | this.get(key, type), 104 | this.list({ prefix: key, limit: 10 }), 105 | ]); 106 | 107 | const metadata = _.get(keys, 'keys.0.metadata', {}); 108 | return { 109 | value, 110 | metadata, 111 | }; 112 | } 113 | 114 | async put(key, value, options = {}) { 115 | const { expiration, expirationTtl, metadata } = options; 116 | 117 | const url = this.getUrlForKey(key); 118 | const searchParams = new URLSearchParams(); 119 | 120 | if (expiration) { 121 | searchParams.append('expiration', expiration); 122 | } else if (expirationTtl) { 123 | searchParams.append('expiration_ttl', expirationTtl); 124 | } 125 | 126 | const headers = this.getAuthHeaders(); 127 | 128 | url.search = searchParams.toString(); 129 | 130 | if (!metadata) { 131 | // https://api.cloudflare.com/#workers-kv-namespace-write-key-value-pair 132 | // eslint-disable-next-line no-undef 133 | const response = await this.fetch(url.toString(), { 134 | method: 'PUT', 135 | headers: { ...headers }, 136 | body: value, 137 | }); 138 | return response.ok; 139 | } 140 | // https://api.cloudflare.com/#workers-kv-namespace-write-key-value-pair-with-metadata 141 | // eslint-disable-next-line no-undef 142 | const formData = new this.FormData(); 143 | formData.append('value', value); 144 | formData.append('metadata', JSON.stringify(metadata)); 145 | // eslint-disable-next-line no-undef 146 | const response = await this.fetch(url.toString(), { 147 | method: 'PUT', 148 | headers: { ...formData.getHeaders(), ...headers }, 149 | body: formData, 150 | }); 151 | 152 | return response.ok; 153 | } 154 | 155 | async delete(key) { 156 | const url = this.getUrlForKey(key); 157 | 158 | // eslint-disable-next-line no-undef 159 | const response = await fetch(url, { 160 | headers: this.getAuthHeaders(), 161 | method: 'DELETE', 162 | }); 163 | 164 | return response.ok; 165 | } 166 | }; 167 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const nock = require('nock'); 3 | 4 | global.fetch = require('node-fetch'); 5 | global.FormData = require('form-data'); 6 | 7 | const KvStorage = require('../src/index'); 8 | 9 | describe('kv-storage', () => { 10 | describe('get', () => { 11 | it('should return a single key', async () => { 12 | const kvStorage = new KvStorage({ 13 | namespace: 'namespace', 14 | accountId: 'accountId', 15 | fetch, 16 | }); 17 | nock('https://api.cloudflare.com') 18 | .get('/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/key') 19 | .reply(200, 'value'); 20 | 21 | const value = await kvStorage.get('key'); 22 | 23 | expect(value).to.equal('value'); 24 | }); 25 | 26 | it('should return a single key as json', async () => { 27 | const kvStorage = new KvStorage({ 28 | namespace: 'namespace', 29 | accountId: 'accountId', 30 | }); 31 | nock('https://api.cloudflare.com') 32 | .get('/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/key') 33 | .reply(200, '{"foo":"bar"}'); 34 | 35 | const value = await kvStorage.get('key', 'json'); 36 | 37 | expect(value.foo).to.equal('bar'); 38 | }); 39 | 40 | it('should return a single key as text', async () => { 41 | const kvStorage = new KvStorage({ 42 | namespace: 'namespace', 43 | accountId: 'accountId', 44 | }); 45 | nock('https://api.cloudflare.com') 46 | .get('/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/key') 47 | .reply(200, '{"foo":"bar"}'); 48 | 49 | const value = await kvStorage.get('key', 'text'); 50 | 51 | expect(value).to.equal('{"foo":"bar"}'); 52 | }); 53 | 54 | it('should return a single key as stream', async () => { 55 | const kvStorage = new KvStorage({ 56 | namespace: 'namespace', 57 | accountId: 'accountId', 58 | }); 59 | nock('https://api.cloudflare.com') 60 | .get('/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/key') 61 | .reply(200, '{"foo":"bar"}'); 62 | 63 | const value = await kvStorage.get('key', 'stream'); 64 | 65 | expect(value.readable).to.be.true; 66 | }); 67 | 68 | it('should return a single key as arrayBuffer', async () => { 69 | const kvStorage = new KvStorage({ 70 | namespace: 'namespace', 71 | accountId: 'accountId', 72 | }); 73 | nock('https://api.cloudflare.com') 74 | .get('/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/key') 75 | .reply(200, '{"foo":"bar"}'); 76 | 77 | const value = await kvStorage.get('key', 'arraybuffer'); 78 | 79 | expect(value).to.be.instanceOf(ArrayBuffer); 80 | }); 81 | }); 82 | 83 | describe('getWithMetadata', () => { 84 | it('should fetch a key with metadata', async () => { 85 | const kvStorage = new KvStorage({ 86 | namespace: 'namespace', 87 | accountId: 'accountId', 88 | }); 89 | 90 | nock('https://api.cloudflare.com') 91 | .get('/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/key') 92 | .reply(200, 'value'); 93 | 94 | nock('https://api.cloudflare.com') 95 | .get( 96 | '/client/v4/accounts/accountId/storage/kv/namespaces/namespace/keys?prefix=key&limit=10', 97 | ) 98 | .reply(200, { 99 | result: [{ metadata: { foo: 'bar' } }], 100 | result_info: { count: 1, cursor: 'fake_cursor' }, 101 | }); 102 | 103 | const { value, metadata } = await kvStorage.getWithMetadata('key'); 104 | expect(value).to.equal('value'); 105 | expect(metadata.foo).to.equal('bar'); 106 | }); 107 | }); 108 | 109 | describe('put', () => { 110 | it('should put a single key', async () => { 111 | const kvStorage = new KvStorage({ 112 | namespace: 'namespace', 113 | accountId: 'accountId', 114 | }); 115 | 116 | nock('https://api.cloudflare.com') 117 | .put('/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/key') 118 | .reply(200, 'OK'); 119 | 120 | const response = await kvStorage.put('key', 'value'); 121 | 122 | expect(response).to.be.true; 123 | }); 124 | 125 | it('should put a single key with exipiration', async () => { 126 | const kvStorage = new KvStorage({ 127 | namespace: 'namespace', 128 | accountId: 'accountId', 129 | }); 130 | 131 | nock('https://api.cloudflare.com') 132 | .put( 133 | '/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/key?expiration=1234', 134 | ) 135 | .reply(200, 'OK'); 136 | 137 | const response = await kvStorage.put('key', 'value', { expiration: 1234 }); 138 | 139 | expect(response).to.be.true; 140 | }); 141 | 142 | it('should put a single key with ttl', async () => { 143 | const kvStorage = new KvStorage({ 144 | namespace: 'namespace', 145 | accountId: 'accountId', 146 | }); 147 | 148 | nock('https://api.cloudflare.com') 149 | .put( 150 | '/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/key?expiration_ttl=1234', 151 | ) 152 | .reply(200, 'OK'); 153 | 154 | const response = await kvStorage.put('key', 'value', { expirationTtl: 1234 }); 155 | 156 | expect(response).to.be.true; 157 | }); 158 | 159 | it('should put a single key with metadata', async () => { 160 | const kvStorage = new KvStorage({ 161 | namespace: 'namespace', 162 | accountId: 'accountId', 163 | }); 164 | 165 | nock('https://api.cloudflare.com') 166 | .put('/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/key') 167 | .reply(200, 'OK'); 168 | 169 | const response = await kvStorage.put('key', 'value', { metadata: { foo: 'bar' } }); 170 | 171 | expect(response).to.be.true; 172 | }); 173 | }); 174 | 175 | describe('delete', () => { 176 | it('should delete a key', async () => { 177 | const kvStorage = new KvStorage({ 178 | namespace: 'namespace', 179 | accountId: 'accountId', 180 | }); 181 | 182 | nock('https://api.cloudflare.com') 183 | .delete('/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/key') 184 | .reply(200, 'OK'); 185 | 186 | const response = await kvStorage.delete('key'); 187 | 188 | expect(response).to.be.true; 189 | }); 190 | }); 191 | 192 | describe('list', () => { 193 | it('should list all keys', async () => { 194 | const kvStorage = new KvStorage({ 195 | namespace: 'namespace', 196 | accountId: 'accountId', 197 | }); 198 | 199 | nock('https://api.cloudflare.com') 200 | .get('/client/v4/accounts/accountId/storage/kv/namespaces/namespace/keys') 201 | .reply(200, { 202 | success: true, 203 | errors: [], 204 | messages: [], 205 | result: [ 206 | { 207 | name: 'My-Key', 208 | expiration: 1577836800, 209 | metadata: { 210 | someMetadataKey: 'someMetadataValue', 211 | }, 212 | }, 213 | ], 214 | result_info: { 215 | count: 1, 216 | cursor: 217 | '6Ck1la0VxJ0djhidm1MdX2FyDGxLKVeeHZZmORS_8XeSuhz9SjIJRaSa2lnsF01tQOHrfTGAP3R5X1Kv5iVUuMbNKhWNAXHOl6ePB0TUL8nw', 218 | }, 219 | }); 220 | 221 | const response = await kvStorage.list(); 222 | 223 | expect(response.keys.length).to.be.equal(1); 224 | expect(response.list_complete).to.be.true; 225 | expect(response.cursor).to.exist; 226 | }); 227 | 228 | it('should list keys with prefix, limit and cursor', async () => { 229 | const kvStorage = new KvStorage({ 230 | namespace: 'namespace', 231 | accountId: 'accountId', 232 | }); 233 | 234 | nock('https://api.cloudflare.com') 235 | .get( 236 | '/client/v4/accounts/accountId/storage/kv/namespaces/namespace/keys?prefix=test&limit=10&cursor=fake-cursor', 237 | ) 238 | .reply(200, { 239 | success: true, 240 | errors: [], 241 | messages: [], 242 | result: [ 243 | { 244 | name: 'My-Key', 245 | expiration: 1577836800, 246 | metadata: { 247 | someMetadataKey: 'someMetadataValue', 248 | }, 249 | }, 250 | ], 251 | result_info: { 252 | count: 1, 253 | cursor: 254 | '6Ck1la0VxJ0djhidm1MdX2FyDGxLKVeeHZZmORS_8XeSuhz9SjIJRaSa2lnsF01tQOHrfTGAP3R5X1Kv5iVUuMbNKhWNAXHOl6ePB0TUL8nw', 255 | }, 256 | }); 257 | 258 | await kvStorage.list({ prefix: 'test', limit: 10, cursor: 'fake-cursor' }); 259 | }); 260 | }); 261 | }); 262 | --------------------------------------------------------------------------------