├── .github └── workflows │ ├── branches.yml │ └── tags.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── index.ts ├── packer.ts ├── scripts └── build_npm.ts └── test └── index.test.ts /.github/workflows/branches.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: denoland/setup-deno@v1 17 | with: 18 | deno-version: v1.x 19 | - run: deno test ./test 20 | -------------------------------------------------------------------------------- /.github/workflows/tags.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | push: 8 | tags: 9 | - v* 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: denoland/setup-deno@v1 17 | with: 18 | deno-version: v1.x 19 | - run: deno test ./test 20 | 21 | publish-npm: 22 | needs: test 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: denoland/setup-deno@v1 27 | with: 28 | deno-version: v1.x 29 | - uses: actions/setup-node@v1 30 | with: 31 | node-version: 14 32 | registry-url: https://registry.npmjs.org/ 33 | - uses: pnpm/action-setup@v2 34 | with: 35 | version: 6 36 | run_install: false 37 | - run: deno run -A ./scripts/build_npm.ts 38 | - run: cd ./npm && npm publish 39 | env: 40 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /npm -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2020 Florian Klampfer (https://qwtel.com/) 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare Storage Area 2 | 3 | An implementation of the StorageArea ([1],[2],[3]) interface using [Cloudflare Worker's KV](https://developers.cloudflare.com/workers/runtime-apis/kv) 4 | storage as a backing store. 5 | 6 | The goal of this class is ease of use and compatibility with other Storage Area implementations, 7 | such as [`kv-storage-polyfill`](https://github.com/GoogleChromeLabs/kv-storage-polyfill). 8 | 9 | While work on [the specification](https://wicg.github.io/kv-storage/) itself has stopped, 10 | it's still a good interface for asynchronous data access that feels native to JavaScript. 11 | 12 | ## Usage 13 | 14 | ``` js 15 | import { StorageArea } from '@worker-tools/cloudflare-kv-storage'; 16 | const storage = new StorageArea('foobar'); 17 | ``` 18 | 19 | You can now write cross-platform, cross-worker-env code: 20 | 21 | ```js 22 | async function myFunc(storage) { 23 | await storage.set(['foo', 1], ['bar', 2], { expirationTtl: 5 * 60 }); 24 | await storage.get(['foo', 1]); // => ['bar', 2] 25 | } 26 | ``` 27 | 28 | Note that some of the underlying features of Cloudflare KV, such as [`expirationTtl`](https://developers.cloudflare.com/workers/runtime-apis/kv#expiring-keys), are still exposed via the optional options parameter. 29 | If the underlying implementation isn't a ` CloudflareStorageArea`, the setting simply won't have any effect. 30 | 31 | ## Prerequisites 32 | In your `wrangler.toml`, make sure to provide a kv namespace binding and a default namespace for the this implementation. 33 | 34 | ```toml 35 | kv_namespaces = [ 36 | { binding = "KV_NAMESPACE", id = "13c...", preview_id = "13c..." } 37 | ] 38 | 39 | [vars] 40 | CF_STORAGE_AREA__DEFAULT_KV_NAMESPACE = "KV_NAMESPACE" 41 | ``` 42 | 43 | [1]: https://developers.google.com/web/updates/2019/03/kv-storage 44 | [2]: https://css-tricks.com/kv-storage/ 45 | [3]: https://github.com/WICG/kv-storage 46 | 47 | ## Features 48 | 49 | Beyond the cross-worker-env aspects of using StorageArea, CloudflareStorageArea provides a number of quality of life improvements over using Cloudflare's KV directly: 50 | 51 | * Support for multiple storage areas within a single KV binding 52 | * Wrapping and Unwrapping of many built-in types, such as `Map` and `Set` (structured clone algorithm) 53 | * Support for non-string keys and complex keys 54 | * Abstraction over KV pagination when listing keys 55 | 56 | ## Disclaimers 57 | 58 | Note that efficiency is not a goal. Specifically, if you have sizable `ArrayBuffer`s, 59 | it's much better to use Cloudflare's KV directly. 60 | 61 |
62 | 63 | -------- 64 | 65 |
66 | 67 |

68 |

This module is part of the Worker Tools collection
⁕ 69 | 70 | [Worker Tools](https://workers.tools) are a collection of TypeScript libraries for writing web servers in [Worker Runtimes](https://workers.js.org) such as Cloudflare Workers, Deno Deploy and Service Workers in the browser. 71 | 72 | If you liked this module, you might also like: 73 | 74 | - 🧭 [__Worker Router__][router] --- Complete routing solution that works across CF Workers, Deno and Service Workers 75 | - 🔋 [__Worker Middleware__][middleware] --- A suite of standalone HTTP server-side middleware with TypeScript support 76 | - 📄 [__Worker HTML__][html] --- HTML templating and streaming response library 77 | - 📦 [__Storage Area__][kv-storage] --- Key-value store abstraction across [Cloudflare KV][cloudflare-kv-storage], [Deno][deno-kv-storage] and browsers. 78 | - 🆗 [__Response Creators__][response-creators] --- Factory functions for responses with pre-filled status and status text 79 | - 🎏 [__Stream Response__][stream-response] --- Use async generators to build streaming responses for SSE, etc... 80 | - 🥏 [__JSON Fetch__][json-fetch] --- Drop-in replacements for Fetch API classes with first class support for JSON. 81 | - 🦑 [__JSON Stream__][json-stream] --- Streaming JSON parser/stingifier with first class support for web streams. 82 | 83 | Worker Tools also includes a number of polyfills that help bridge the gap between Worker Runtimes: 84 | - ✏️ [__HTML Rewriter__][html-rewriter] --- Cloudflare's HTML Rewriter for use in Deno, browsers, etc... 85 | - 📍 [__Location Polyfill__][location-polyfill] --- A `Location` polyfill for Cloudflare Workers. 86 | - 🦕 [__Deno Fetch Event Adapter__][deno-fetch-event-adapter] --- Dispatches global `fetch` events using Deno’s native HTTP server. 87 | 88 | [router]: https://workers.tools/router 89 | [middleware]: https://workers.tools/middleware 90 | [html]: https://workers.tools/html 91 | [kv-storage]: https://workers.tools/kv-storage 92 | [cloudflare-kv-storage]: https://workers.tools/cloudflare-kv-storage 93 | [deno-kv-storage]: https://workers.tools/deno-kv-storage 94 | [kv-storage-polyfill]: https://workers.tools/kv-storage-polyfill 95 | [response-creators]: https://workers.tools/response-creators 96 | [stream-response]: https://workers.tools/stream-response 97 | [json-fetch]: https://workers.tools/json-fetch 98 | [json-stream]: https://workers.tools/json-stream 99 | [request-cookie-store]: https://workers.tools/request-cookie-store 100 | [extendable-promise]: https://workers.tools/extendable-promise 101 | [html-rewriter]: https://workers.tools/html-rewriter 102 | [location-polyfill]: https://workers.tools/location-polyfill 103 | [deno-fetch-event-adapter]: https://workers.tools/deno-fetch-event-adapter 104 | 105 | Fore more visit [workers.tools](https://workers.tools). -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | // import 'https://cdn.skypack.dev/@cloudflare/workers-types@3.11.0?dts' 3 | import type { StorageArea, AllowedKey, Key } from 'https://ghuc.cc/qwtel/kv-storage-interface/index.d.ts'; 4 | 5 | import { encodeKey, decodeKey, throwForDisallowedKey } from 'https://cdn.skypack.dev/idb-key-to-string?dts'; 6 | 7 | import { KVPacker, StructuredPacker } from './packer.ts'; 8 | 9 | const OLD_DEFAULT_KV_NAMESPACE_KEY = 'CF_STORAGE_AREA__DEFAULT_KV_NAMESPACE'; 10 | const DEFAULT_KV_NAMESPACE_KEY = 'DEFAULT_KV_NAMESPACE'; 11 | const DEFAULT_STORAGE_AREA_NAME = 'default'; 12 | const DIV = '/'; 13 | 14 | const getProcessEnv = (k: string) => Reflect.get(Reflect.get(Reflect.get(self, 'process') || {}, 'env') || {}, k); 15 | 16 | /** 17 | * An implementation of the `StorageArea` interface wrapping Cloudflare Worker's KV store. 18 | * 19 | * The goal of this class is ease of use and compatibility with other Storage Area implementations, 20 | * such as . 21 | * 22 | * While work on [the specification](https://wicg.github.io/kv-storage/) itself has stopped, 23 | * it's still a good interface for asynchronous data access that feels native to JavaScript. 24 | * 25 | * Note that efficiency is not a goal. Specifically, if you have sizable `ArrayBuffer`s, 26 | * it's much better to use Cloudflare's KV directly. 27 | */ 28 | export class CloudflareStorageArea implements StorageArea { 29 | // @ts-ignore: deno only 30 | #kv: KVNamespace; 31 | #packer: KVPacker; 32 | #encodeKey: typeof encodeKey; 33 | #decodeKey: typeof decodeKey; 34 | #paginationHelper: typeof paginationHelper; 35 | 36 | // @ts-ignore: deno only 37 | static defaultKVNamespace?: KVNamespace; 38 | 39 | constructor(name?: string, opts?: KVOptions); 40 | // @ts-ignore: deno only 41 | constructor(name?: KVNamespace, opts?: Omit); 42 | // @ts-ignore: deno only 43 | constructor(name: string | KVNamespace = DEFAULT_STORAGE_AREA_NAME, options: KVOptions = {}) { 44 | let { namespace, packer = new StructuredPacker() } = options; 45 | 46 | namespace = namespace 47 | || CloudflareStorageArea.defaultKVNamespace 48 | || Reflect.get(self, Reflect.get(self, DEFAULT_KV_NAMESPACE_KEY)) 49 | || Reflect.get(self, Reflect.get(self, OLD_DEFAULT_KV_NAMESPACE_KEY)) 50 | || Reflect.get(self, getProcessEnv(DEFAULT_KV_NAMESPACE_KEY)); 51 | 52 | this.#kv = namespace 53 | ? namespace 54 | : typeof name === 'string' 55 | ? Reflect.get(self, name) 56 | : name; 57 | 58 | if (!this.#kv) { 59 | throw Error('KV binding missing. Consult Workers documentation for details'); 60 | } 61 | 62 | this.#encodeKey = !namespace 63 | ? encodeKey 64 | : k => `${name}${DIV}${encodeKey(k)}`; 65 | 66 | this.#decodeKey = !namespace 67 | ? decodeKey 68 | : k => decodeKey(k.substring((name as string).length + 1)); 69 | 70 | this.#paginationHelper = !namespace 71 | ? paginationHelper 72 | : (kv, { prefix, ...opts } = {}) => paginationHelper(kv, { 73 | prefix: `${name}${DIV}${prefix ?? ''}`, 74 | ...opts, 75 | }); 76 | 77 | this.#packer = packer; 78 | } 79 | 80 | get(key: AllowedKey, opts?: unknown): Promise { 81 | throwForDisallowedKey(key); 82 | return this.#packer.get(this.#kv, this.#encodeKey(key), opts); 83 | } 84 | 85 | async set(key: AllowedKey, value: T | undefined, opts?: KVPutOptions): Promise { 86 | throwForDisallowedKey(key); 87 | if (value === undefined) 88 | await this.#kv.delete(this.#encodeKey(key)); 89 | else { 90 | await this.#packer.set(this.#kv, this.#encodeKey(key), value, opts); 91 | } 92 | } 93 | 94 | delete(key: AllowedKey) { 95 | throwForDisallowedKey(key); 96 | return this.#kv.delete(this.#encodeKey(key)); 97 | } 98 | 99 | async clear(opts?: KVListOptions) { 100 | for await (const key of this.#paginationHelper(this.#kv, opts)) { 101 | await this.#kv.delete(key) 102 | } 103 | } 104 | 105 | async *keys(opts?: KVListOptions): AsyncGenerator { 106 | for await (const key of this.#paginationHelper(this.#kv, opts)) { 107 | yield this.#decodeKey(key); 108 | } 109 | } 110 | 111 | async *values(opts?: KVListOptions): AsyncGenerator { 112 | for await (const key of this.#paginationHelper(this.#kv, opts)) { 113 | yield this.#packer.get(this.#kv, key, opts); 114 | } 115 | } 116 | 117 | async *entries(opts?: KVListOptions): AsyncGenerator<[Key, T]> { 118 | for await (const key of this.#paginationHelper(this.#kv, opts)) { 119 | yield [this.#decodeKey(key), await this.#packer.get(this.#kv, key, opts)]; 120 | } 121 | } 122 | 123 | backingStore() { 124 | return this.#kv; 125 | } 126 | } 127 | 128 | export interface KVOptions { 129 | // @ts-ignore: deno only 130 | namespace?: KVNamespace; 131 | /** @deprecated This feature is not stable yet. */ 132 | packer?: KVPacker; 133 | [k: string]: any; 134 | } 135 | 136 | export interface KVPutOptions { 137 | expiration?: string | number; 138 | expirationTtl?: string | number; 139 | [k: string]: any; 140 | } 141 | 142 | export interface KVListOptions { 143 | prefix?: string 144 | [k: string]: any; 145 | } 146 | 147 | /** Abstracts Cloudflare KV's cursor-based pagination with async iteration. */ 148 | // @ts-ignore: deno only 149 | async function* paginationHelper(kv: KVNamespace, opts: KVListOptions = {}) { 150 | let keys: { name: string; expiration?: number; metadata?: unknown }[]; 151 | let done: boolean; 152 | let cursor: string | undefined; 153 | do { 154 | ({ keys, list_complete: done, cursor } = await kv.list({ ...cursor ? { ...opts, cursor } : opts })); 155 | for (const { name } of keys) yield name; 156 | } while (!done); 157 | } 158 | 159 | /** @deprecated for backwards compat with v0.2.0 */ 160 | export class KVStorageArea extends CloudflareStorageArea { } 161 | 162 | export type { AllowedKey, Key }; 163 | export { CloudflareStorageArea as CFStorageArea }; 164 | export { CloudflareStorageArea as StorageArea }; 165 | -------------------------------------------------------------------------------- /packer.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | // import 'https://cdn.skypack.dev/@cloudflare/workers-types@3.11.0?dts' 3 | 4 | import * as Structured from 'https://ghuc.cc/worker-tools/structured-json/index.ts'; 5 | import { Encoder as BinaryEncoder, Decoder as BinaryDecoder } from 'https://cdn.skypack.dev/msgpackr@1.5.5?dts'; 6 | 7 | export interface KVPacker { 8 | // @ts-ignore: deno only 9 | set(kv: KVNamespace, key: string, value: any, opts?: any): Promise; 10 | // @ts-ignore: deno only 11 | get(kv: KVNamespace, key: string, opts?: any): Promise; 12 | } 13 | 14 | export class StructuredPacker implements KVPacker { 15 | // @ts-ignore: deno only 16 | async set(kv: KVNamespace, key: string, value: any, opts?: KVNamespacePutOptions) { 17 | await kv.put(key, Structured.stringify(value), opts); 18 | } 19 | // @ts-ignore: deno only 20 | async get(kv: KVNamespace, key: string) { 21 | return Structured.fromJSON(await kv.get(key, 'json')); 22 | } 23 | } 24 | 25 | /** @deprecated This doesn't match structured clone algorithm close enough. Not recommended */ 26 | export class MsgPacker implements KVPacker { 27 | // @ts-ignore: deno only 28 | async set(kv: KVNamespace, key: string, value: any, opts?: any): Promise { 29 | await kv.put(key, new BinaryEncoder({ structuredClone: true }).encode(value), opts); 30 | } 31 | // @ts-ignore: deno only 32 | async get(kv: KVNamespace, key: string): Promise { 33 | const data = await kv.get(key, 'arrayBuffer'); 34 | return data && new BinaryDecoder({ structuredClone: true }).decode(new Uint8Array(data)); 35 | } 36 | } 37 | 38 | export { StructuredPacker as TypesonPacker } 39 | -------------------------------------------------------------------------------- /scripts/build_npm.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S deno run --allow-read --allow-write=./,/Users/qwtel/Library/Caches/deno --allow-net --allow-env=HOME,DENO_AUTH_TOKENS,DENO_DIR --allow-run=git,pnpm 2 | 3 | import { basename } from "https://deno.land/std@0.133.0/path/mod.ts"; 4 | import { build, emptyDir } from "https://deno.land/x/dnt@0.23.0/mod.ts"; 5 | 6 | import { 7 | copyMdFiles, mkPackage, 8 | } from 'https://gist.githubusercontent.com/qwtel/ecf0c3ba7069a127b3d144afc06952f5/raw/latest-version.ts' 9 | 10 | await emptyDir("./npm"); 11 | 12 | const name = basename(Deno.cwd()) 13 | 14 | await build({ 15 | entryPoints: ["./index.ts"], 16 | outDir: "./npm", 17 | shims: {}, 18 | test: false, 19 | typeCheck: false, 20 | declaration: false, 21 | package: await mkPackage(name), 22 | packageManager: 'pnpm', 23 | compilerOptions: { 24 | sourceMap: true, 25 | target: 'ES2019', 26 | }, 27 | mappings: { 28 | "https://ghuc.cc/qwtel/kv-storage-interface/index.d.ts": { 29 | name: "kv-storage-interface", 30 | version: "latest", 31 | }, 32 | "https://cdn.skypack.dev/idb-key-to-string?dts": { 33 | name: "idb-key-to-string", 34 | version: "latest", 35 | }, 36 | "https://ghuc.cc/worker-tools/structured-json/index.ts": { 37 | name: "@worker-tools/structured-json", 38 | version: "latest", 39 | }, 40 | "https://cdn.skypack.dev/msgpackr@1.5.5?dts": { 41 | name: "msgpackr", 42 | version: "^1.5.5", 43 | }, 44 | }, 45 | }); 46 | 47 | // post build steps 48 | await copyMdFiles() 49 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any no-unused-vars require-await ban-unused-ignore 2 | import 'https://gist.githubusercontent.com/qwtel/b14f0f81e3a96189f7771f83ee113f64/raw/TestRequest.ts' 3 | import { 4 | assert, 5 | assertExists, 6 | assertEquals, 7 | assertStrictEquals, 8 | assertStringIncludes, 9 | assertThrows, 10 | assertRejects, 11 | assertArrayIncludes, 12 | } from 'https://deno.land/std@0.133.0/testing/asserts.ts' 13 | const { test } = Deno; 14 | 15 | import { StorageArea } from '../index.ts'; 16 | 17 | test('exists', () => { 18 | assertExists(StorageArea) 19 | }) 20 | --------------------------------------------------------------------------------