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));
}),