diff --git a/docs/content/6.drivers/cloudflare-kv-binding.md b/docs/content/6.drivers/cloudflare-kv-binding.md index 1a83b4eb..554b99bf 100644 --- a/docs/content/6.drivers/cloudflare-kv-binding.md +++ b/docs/content/6.drivers/cloudflare-kv-binding.md @@ -33,3 +33,19 @@ const storage = createStorage({ - `binding`: KV binding or name of namespace. Default is `STORAGE`. - `base`: Adds prefix to all stored keys + +## Using Cloudlflare KV Options + +You can pass options to cloudflare such as metadata and expiration as the 3rd argument of the `setItem` function. +Refer to the [cloudflare KV docs](https://developers.cloudflare.com/workers/runtime-apis/kv/#writing-key-value-pairs) for the list of supported options. + +```ts +await storage.setItem("key", "value", { + ttl: 300, // key expiration shorthand (in seconds) + cloudflareKvBinding: { + expiration: 1578435000, + expirationTtl: 300, + metadata: { someMetadataKey: "someMetadataValue" }, + }, +}); +``` diff --git a/docs/content/6.drivers/cloudflare-kv-http.md b/docs/content/6.drivers/cloudflare-kv-http.md index b4c0a033..cf4ed986 100644 --- a/docs/content/6.drivers/cloudflare-kv-http.md +++ b/docs/content/6.drivers/cloudflare-kv-http.md @@ -58,3 +58,20 @@ const storage = createStorage({ - `removeItem`: Maps to [Delete key-value pair](https://api.cloudflare.com/#workers-kv-namespace-delete-key-value-pair) `DELETE accounts/:account_identifier/storage/kv/namespaces/:namespace_identifier/values/:key_name` - `getKeys`: Maps to [List a Namespace's Keys](https://api.cloudflare.com/#workers-kv-namespace-list-a-namespace-s-keys) `GET accounts/:account_identifier/storage/kv/namespaces/:namespace_identifier/keys` - `clear`: Maps to [Delete key-value pair](https://api.cloudflare.com/#workers-kv-namespace-delete-multiple-key-value-pairs) `DELETE accounts/:account_identifier/storage/kv/namespaces/:namespace_identifier/bulk` + +## Using Cloudlflare KV Options + +You can pass cloudflare options such as metadata and expiration as the 3rd argument of the `setItem` function. +Refer to the [cloudflare KV API docs](https://developers.cloudflare.com/api/operations/workers-kv-namespace-write-multiple-key-value-pairs) for the list of supported options. + +```ts +await storage.setItem("key", "value", { + ttl: 300, // key expiration shorthand (in seconds) + cloudflareKvHttp: { + expiration: 1578435000, + expiration_ttl: 300, + base64: false, + metadata: { someMetadataKey: "someMetadataValue" }, + }, +}); +``` diff --git a/package.json b/package.json index d757e6c1..6ec53038 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@vue/compiler-sfc": "^3.3.4", "azurite": "^3.25.1", "changelogen": "^0.5.4", + "defu": "^6.1.2", "eslint": "^8.47.0", "eslint-config-unjs": "^0.2.1", "fake-indexeddb": "^4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5818610..1885f848 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ devDependencies: changelogen: specifier: ^0.5.4 version: 0.5.4 + defu: + specifier: ^6.1.2 + version: 6.1.2 eslint: specifier: ^8.47.0 version: 8.47.0 diff --git a/src/drivers/cloudflare-kv-binding.ts b/src/drivers/cloudflare-kv-binding.ts index de3b83c2..63d87695 100644 --- a/src/drivers/cloudflare-kv-binding.ts +++ b/src/drivers/cloudflare-kv-binding.ts @@ -1,12 +1,21 @@ /// import { createError, defineDriver, joinKeys } from "./utils"; +import defu from "defu"; + export interface KVOptions { binding?: string | KVNamespace; /** Adds prefix to all stored keys */ base?: string; + /** + * Default TTL for all items in seconds. + */ + ttl?: number; } +//https://developers.cloudflare.com/workers/runtime-apis/kv#writing-key-value-pairs +export type CloudflareKvBindingSetItemOptions = KVNamespacePutOptions; + // https://developers.cloudflare.com/workers/runtime-apis/kv const DRIVER_NAME = "cloudflare-kv-binding"; @@ -34,10 +43,18 @@ export default defineDriver((opts: KVOptions = {}) => { const binding = getBinding(opts.binding); return binding.get(key); }, - setItem(key, value) { + setItem(key, value, options) { key = r(key); const binding = getBinding(opts.binding); - return binding.put(key, value); + const o = defu( + options?.cloudflareKvBinding, + options?.ttl + ? { expirationTtl: options.ttl } + : opts.ttl + ? { expirationTtl: opts.ttl } + : {} + ); + return binding.put(key, value, o); }, removeItem(key) { key = r(key); diff --git a/src/drivers/cloudflare-kv-http.ts b/src/drivers/cloudflare-kv-http.ts index 026bceb8..2d41a993 100644 --- a/src/drivers/cloudflare-kv-http.ts +++ b/src/drivers/cloudflare-kv-http.ts @@ -1,10 +1,12 @@ -import { $fetch } from "ofetch"; +import { $fetch, type FetchOptions } from "ofetch"; import { createError, createRequiredError, defineDriver, joinKeys, } from "./utils"; +import { defu } from "defu"; +import { TransactionOptions } from "../types"; interface KVAuthAPIToken { /** @@ -57,6 +59,10 @@ export type KVHTTPOptions = { * Adds prefix to all stored keys */ base?: string; + /** + * Default TTL for all items in seconds. + */ + ttl?: number; } & (KVAuthServiceKey | KVAuthAPIToken | KVAuthEmailKey); type CloudflareAuthorizationHeaders = @@ -79,6 +85,36 @@ type CloudflareAuthorizationHeaders = Authorization: `Bearer ${string}`; }; +type GetKeysResponse = { + result: { name: string }[]; + result_info: { cursor?: string }; +}; + +// https://developers.cloudflare.com/api/operations/workers-kv-namespace-write-multiple-key-value-pairs +export type CloudflareKvHttpSetItemOptions = { + /** + * 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 + */ + base64?: boolean; + /** + * The time, measured in number of seconds since the UNIX epoch, at which the key should expire. + * @example 1578435000 + */ + expiration?: number; + /** + * The number of seconds for which the key should be visible before it expires. At least 60. + * @example 300 + */ + expiration_ttl?: number; + /** + * Arbitrary JSON that is associated with a key. + * @example {"someMetadataKey":"someMetadataValue"} + */ + metadata?: Record; +}; + const DRIVER_NAME = "cloudflare-kv-http"; export default defineDriver((opts) => { @@ -106,46 +142,50 @@ export default defineDriver((opts) => { const apiURL = opts.apiURL || "https://api.cloudflare.com"; const baseURL = `${apiURL}/client/v4/accounts/${opts.accountId}/storage/kv/namespaces/${opts.namespaceId}`; - const kvFetch = $fetch.create({ baseURL, headers }); + + const kvFetch = async (url: string, fetchOptions?: FetchOptions) => + $fetch.native(`${baseURL}${url}`, { + headers, + ...(fetchOptions as any), + }); const r = (key: string = "") => (opts.base ? joinKeys(opts.base, key) : key); const hasItem = async (key: string) => { - try { - const res = await kvFetch(`/metadata/${r(key)}`); - return res?.success === true; - } catch (err: any) { - if (!err?.response) { - throw err; - } - if (err?.response?.status === 404) { - return false; - } - throw err; - } + const response = await kvFetch(`/metadata/${r(key)}`); + if (response.status === 404) return false; + const data = await response.json<{ success: boolean }>(); + return data?.success === true; }; const getItem = async (key: string) => { - try { - // Cloudflare API returns with `content-type: application/octet-stream` - return await kvFetch(`/values/${r(key)}`).then((r) => r.text()); - } catch (err: any) { - if (!err?.response) { - throw err; - } - if (err?.response?.status === 404) { - return null; - } - throw err; - } + // Cloudflare API returns with `content-type: application/json`: https://developers.cloudflare.com/api/operations/workers-kv-namespace-read-key-value-pair + const response = await kvFetch(`/values/${r(key)}`); + if (response.status === 404) return null; + return response.json(); }; - const setItem = async (key: string, value: any) => { - return await kvFetch(`/values/${r(key)}`, { method: "PUT", body: value }); + const setItem = async ( + key: string, + value: unknown, + options: TransactionOptions + ) => { + const cloudflareOptions = defu( + options?.cloudflareKvHttp, + options?.ttl + ? { expiration_ttl: options.ttl } + : opts.ttl + ? { expiration_ttl: opts.ttl } + : {} + ); + await kvFetch(`/bulk`, { + method: "PUT", + body: JSON.stringify([{ ...cloudflareOptions, key: r(key), value }]), + }); }; const removeItem = async (key: string) => { - return await kvFetch(`/values/${r(key)}`, { method: "DELETE" }); + await kvFetch(`/values/${r(key)}`, { method: "DELETE" }); }; const getKeys = async (base?: string) => { @@ -157,19 +197,19 @@ export default defineDriver((opts) => { } const firstPage = await kvFetch("/keys", { params }); - firstPage.result.forEach(({ name }: { name: string }) => keys.push(name)); + const data = await firstPage.json(); + data.result.forEach(({ name }) => keys.push(name)); - const cursor = firstPage.result_info.cursor; + const cursor = data.result_info.cursor; if (cursor) { params.cursor = cursor; } while (params.cursor) { const pageResult = await kvFetch("/keys", { params }); - pageResult.result.forEach(({ name }: { name: string }) => - keys.push(name) - ); - const pageCursor = pageResult.result_info.cursor; + const dataPageResult = await pageResult.json(); + dataPageResult.result.forEach(({ name }) => keys.push(name)); + const pageCursor = dataPageResult.result_info.cursor; if (pageCursor) { params.cursor = pageCursor; } else { diff --git a/src/drivers/cloudflare-r2-binding.ts b/src/drivers/cloudflare-r2-binding.ts index 1a87d514..5522d0f6 100644 --- a/src/drivers/cloudflare-r2-binding.ts +++ b/src/drivers/cloudflare-r2-binding.ts @@ -43,22 +43,22 @@ export default defineDriver((opts: CloudflareR2Options) => { getItem(key, topts) { key = r(key); const binding = getBinding(opts.binding); - return binding.get(key, topts).then((r) => r?.text()); + return binding.get(key, topts as any).then((r: any) => r?.text()); }, getItemRaw(key, topts) { key = r(key); const binding = getBinding(opts.binding); - return binding.get(key, topts).then((r) => r?.arrayBuffer()); + return binding.get(key, topts as any).then((r: any) => r?.arrayBuffer()); }, async setItem(key, value, topts) { key = r(key); const binding = getBinding(opts.binding); - await binding.put(key, value, topts); + await binding.put(key, value, topts as any); }, async setItemRaw(key, value, topts) { key = r(key); const binding = getBinding(opts.binding); - await binding.put(key, value, topts); + await binding.put(key, value, topts as any); }, async removeItem(key) { key = r(key); diff --git a/src/drivers/http.ts b/src/drivers/http.ts index 01fc9901..5690c356 100644 --- a/src/drivers/http.ts +++ b/src/drivers/http.ts @@ -21,7 +21,7 @@ export default defineDriver((opts: HTTPOptions) => { hasItem(key, topts) { return _fetch(r(key), { method: "HEAD", - headers: { ...opts.headers, ...topts.headers }, + headers: { ...opts.headers, ...topts?.headers }, }) .then(() => true) .catch(() => false); @@ -37,7 +37,7 @@ export default defineDriver((opts: HTTPOptions) => { headers: { accept: "application/octet-stream", ...opts.headers, - ...topts.headers, + ...topts?.headers, }, }); return value; @@ -45,7 +45,7 @@ export default defineDriver((opts: HTTPOptions) => { async getMeta(key, topts) { const res = await _fetch.raw(r(key), { method: "HEAD", - headers: { ...opts.headers, ...topts.headers }, + headers: { ...opts.headers, ...topts?.headers }, }); let mtime = undefined; const _lastModified = res.headers.get("last-modified"); @@ -71,26 +71,26 @@ export default defineDriver((opts: HTTPOptions) => { headers: { "content-type": "application/octet-stream", ...opts.headers, - ...topts.headers, + ...topts?.headers, }, }); }, async removeItem(key, topts) { await _fetch(r(key), { method: "DELETE", - headers: { ...opts.headers, ...topts.headers }, + headers: { ...opts.headers, ...topts?.headers }, }); }, async getKeys(base, topts) { const value = await _fetch(rBase(base), { - headers: { ...opts.headers, ...topts.headers }, + headers: { ...opts.headers, ...topts?.headers }, }); return Array.isArray(value) ? value : []; }, async clear(base, topts) { await _fetch(rBase(base), { method: "DELETE", - headers: { ...opts.headers, ...topts.headers }, + headers: { ...opts.headers, ...topts?.headers }, }); }, }; diff --git a/src/types.ts b/src/types.ts index e33a8da5..703b67c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,6 @@ +import { CloudflareKvBindingSetItemOptions } from "./drivers/cloudflare-kv-binding"; +import { CloudflareKvHttpSetItemOptions } from "./drivers/cloudflare-kv-http"; + export type StorageValue = null | string | number | boolean | object; export type WatchEvent = "update" | "remove"; export type WatchCallback = (event: WatchEvent, key: string) => any; @@ -14,7 +17,13 @@ export interface StorageMeta { [key: string]: StorageValue | Date | undefined; } -export type TransactionOptions = Record; +export type TransactionOptions = + | ({ + cloudflareKvBinding?: CloudflareKvBindingSetItemOptions; + cloudflareKvHttp?: CloudflareKvHttpSetItemOptions; + ttl?: number; + } & Record) + | undefined; export interface Driver { name?: string; diff --git a/test/drivers/cloudflare-kv-http.test.ts b/test/drivers/cloudflare-kv-http.test.ts index 0f5fe9d9..894ed522 100644 --- a/test/drivers/cloudflare-kv-http.test.ts +++ b/test/drivers/cloudflare-kv-http.test.ts @@ -18,8 +18,8 @@ const server = setupServer( } return res( ctx.status(200), - ctx.set("content-type", "application/octet-stream"), - ctx.body(store[key]) + ctx.set("content-type", "application/json"), + ctx.json(store[key]) ); }), @@ -31,9 +31,13 @@ const server = setupServer( return res(ctx.status(200), ctx.json({ success: true })); }), - rest.put(`${baseURL}/values/:key`, async (req, res, ctx) => { - const key = req.params.key as string; - store[key] = await req.text(); + rest.put(`${baseURL}/bulk`, async (req, res, ctx) => { + const items = (await req.json()) as Record[]; + if (!items) return res(ctx.status(404), ctx.json(null)); + for (const item of items) { + const { key, value } = item; + store[key] = value; + } return res(ctx.status(204), ctx.json(null)); }),