diff --git a/README.md b/README.md index ff3b298..19b2cbd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ utilities to convert values which can be safely serialized to JSON as well as deserialize them back. This makes it possible to fully represent entries and values in a browser, or communicate them between Deno processes. -The JSON utilities are: +The synchronous JSON utilities are: - `entryMaybeToJSON` - Convert a `Deno.KvEntryMaybe` to JSON. - `entryToJSON` - Convert a `Deno.KvEntry` to JSON. @@ -24,19 +24,36 @@ The JSON utilities are: - `toKeyPart` - Convert a JSON object to a `Deno.KvKeyPart`. - `toValue` - Convert a JSON object to a value which can be stored in Deno KV. +Since Deno 2.8.1, Deno KV also supports storing web objects such as `Blob`, +`File`, `CryptoKey`, and `DOMException`. Reading a `Blob`/`File` and exporting a +`CryptoKey` are inherently asynchronous, so the following asynchronous utilities +are provided as counterparts to the synchronous ones above and transparently +handle all value types: + +- `entryMaybeToJSONAsync` - Asynchronous version of `entryMaybeToJSON`. +- `entryToJSONAsync` - Asynchronous version of `entryToJSON`. +- `valueToJSONAsync` - Asynchronous version of `valueToJSON`. +- `toEntryAsync` - Asynchronous version of `toEntry`. +- `toEntryMaybeAsync` - Asynchronous version of `toEntryMaybe`. +- `toValueAsync` - Asynchronous version of `toValue`. + +The synchronous `valueToJSON` and `toValue` functions throw a `TypeError` when +given a `Blob`, `File`, or `CryptoKey`. Use the `*Async` variants for any value +that may contain these types. + ### Examples Taking a maybe entry from Deno.Kv and converting it to JSON and sending it as a response: ```ts ignore -import { entryMaybeToJSON } from "@deno/kv-utils"; +import { entryMaybeToJSONAsync } from "@deno/kv-utils"; const db = await Deno.openKv(); Deno.serve(async (_req) => { const maybeEntry = await db.get(["a"]); - const json = entryMaybeToJSON(maybeEntry); + const json = await entryMaybeToJSONAsync(maybeEntry); return Response.json(json); }); ``` @@ -45,13 +62,13 @@ Taking a value that was serialized to JSON in a browser and storing it in Deno KV: ```ts ignore -import { toValue } from "@deno/kv-utils"; +import { toValueAsync } from "@deno/kv-utils"; const db = await Deno.openKv(); Deno.serve(async (req) => { const json = await req.json(); - const value = toValue(json); + const value = await toValueAsync(json); await db.set(["a"], value); return new Response(null, { status: 204 }); }); @@ -93,6 +110,10 @@ The import and export utilities are: response. - `importEntries` - Import entries into a Deno KV store. +`exportEntries` and `importEntries` use the asynchronous `*Async` JSON utilities +internally, so they transparently support values that contain `Blob`, `File`, +`CryptoKey`, or `DOMException`. + ### Examples Exporting entries from a Deno KV store and saving them to a file: diff --git a/_benches/byte_size.ts b/_benches/byte_size.ts index 82982b3..3c61d08 100644 --- a/_benches/byte_size.ts +++ b/_benches/byte_size.ts @@ -6,6 +6,7 @@ * @module */ +// deno-lint-ignore no-import-prefix import { Serializer } from "jsr:@denostack/superserial@0.3.5"; import { serialize } from "node:v8"; import { estimateSize } from "../estimate_size.ts"; diff --git a/import_export.test.ts b/import_export.test.ts index d18a0a0..7826901 100644 --- a/import_export.test.ts +++ b/import_export.test.ts @@ -296,3 +296,35 @@ Deno.test({ return teardown(); }, }); + +Deno.test({ + name: "exportEntries / importEntries - Blob round trip", + sanitizeResources: false, + async fn() { + const kv = await setup(); + await kv.set(["a"], new Blob([new Uint8Array([1, 2, 3])])); + const bytes: Uint8Array[] = []; + for await (const chunk of exportEntries(kv, { prefix: [] })) { + bytes.push(chunk); + } + const exportData = decoder.decode(concat(bytes)); + assert(exportData.includes('"type":"Blob"')); + + const target = await Deno.openKv(":memory:"); + const result = await importEntries( + target, + new Blob([encoder.encode(exportData)]), + ); + assertEquals(result.errors, 0); + const entry = await target.get(["a"]); + if (!entry.value) { + throw new Error("expected entry to have a value"); + } + assertEquals(entry.value.size, 3); + assert( + new Uint8Array(await entry.value.arrayBuffer())[0] === 1, + ); + target.close(); + return teardown(); + }, +}); diff --git a/import_export.ts b/import_export.ts index 24b419e..cabae8a 100644 --- a/import_export.ts +++ b/import_export.ts @@ -80,7 +80,12 @@ * @module */ -import { entryToJSON, type KvEntryJSON, toKey, toValue } from "./json.ts"; +import { + entryToJSONAsync, + type KvEntryJSON, + toKey, + toValueAsync, +} from "./json.ts"; import { LinesTransformStream } from "./line_transform_stream.ts"; /** @@ -316,7 +321,7 @@ export function exportEntries( async start(controller) { try { for await (const entry of db.list(selector, options)) { - const chunk = entryToJSON(entry); + const chunk = await entryToJSONAsync(entry); controller.enqueue( text ? `${JSON.stringify(chunk)}\n` @@ -483,7 +488,7 @@ export async function importEntries( continue; } } - await db.set(entryKey, toValue(value)); + await db.set(entryKey, await toValueAsync(value)); onProgress?.(count, skipped, errors); } if (result.done) { diff --git a/json.test.ts b/json.test.ts index 113b948..d8061df 100644 --- a/json.test.ts +++ b/json.test.ts @@ -10,15 +10,21 @@ import { timingSafeEqual } from "@std/crypto/timing-safe-equal"; import { entryMaybeToJSON, + entryMaybeToJSONAsync, entryToJSON, + entryToJSONAsync, keyPartToJSON, keyToJSON, toEntry, + toEntryAsync, toEntryMaybe, + toEntryMaybeAsync, toKey, toKeyPart, toValue, + toValueAsync, valueToJSON, + valueToJSONAsync, } from "./json.ts"; Deno.test({ @@ -1148,3 +1154,536 @@ Deno.test({ }); }, }); + +Deno.test({ + name: "valueToJSON - DOMException", + fn() { + const exception = new DOMException("aborted", "AbortError"); + const actual = valueToJSON(exception); + assertEquals(actual.type, "DOMException"); + if (actual.type !== "DOMException") { + throw new Error("expected DOMException"); + } + assertEquals(actual.value.name, "AbortError"); + assertEquals(actual.value.message, "aborted"); + assert(actual.value.stack); + }, +}); + +Deno.test({ + name: "valueToJSON - DOMException with no stack", + fn() { + const exception = new DOMException("boom", "NetworkError"); + // deno-lint-ignore no-explicit-any + (exception as any).stack = undefined; + const actual = valueToJSON(exception); + assertEquals(actual.type, "DOMException"); + if (actual.type !== "DOMException") { + throw new Error("expected DOMException"); + } + assertEquals(actual.value.name, "NetworkError"); + assertEquals(actual.value.message, "boom"); + assertStrictEquals(actual.value.stack, undefined); + }, +}); + +Deno.test({ + name: "valueToJSON - Blob throws", + fn() { + assertThrows( + () => valueToJSON(new Blob(["hello"])), + TypeError, + "Cannot synchronously serialize a Blob", + ); + }, +}); + +Deno.test({ + name: "valueToJSON - File throws", + fn() { + assertThrows( + () => valueToJSON(new File(["hello"], "a.txt")), + TypeError, + "Cannot synchronously serialize a File", + ); + }, +}); + +Deno.test({ + name: "valueToJSON - CryptoKey throws", + async fn() { + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], + ); + assertThrows( + () => valueToJSON(key), + TypeError, + "Cannot synchronously serialize a CryptoKey", + ); + }, +}); + +Deno.test({ + name: "toValue - DOMException", + fn() { + const actual = toValue({ + type: "DOMException", + value: { + name: "AbortError", + message: "aborted", + stack: "Error: aborted\n at foo", + }, + }); + assert(actual instanceof DOMException); + assertEquals(actual.name, "AbortError"); + assertEquals(actual.message, "aborted"); + assertEquals(actual.stack, "Error: aborted\n at foo"); + }, +}); + +Deno.test({ + name: "toValue - Blob throws", + fn() { + assertThrows( + () => + toValue({ + type: "Blob", + value: "AQID", + contentType: "", + size: 3, + }), + TypeError, + "Cannot synchronously deserialize a Blob", + ); + }, +}); + +Deno.test({ + name: "toValue - File throws", + fn() { + assertThrows( + () => + toValue({ + type: "File", + value: "AQID", + name: "a.txt", + contentType: "", + lastModified: 0, + size: 3, + }), + TypeError, + "Cannot synchronously deserialize a File", + ); + }, +}); + +Deno.test({ + name: "toValue - CryptoKey throws", + fn() { + assertThrows( + () => + toValue({ + type: "CryptoKey", + value: { + algorithm: { name: "AES-GCM", length: 256 }, + extractable: true, + format: "raw", + keyData: "AAAAAAAAAAAAAAAAAAAAAA", + type: "secret", + usages: ["encrypt", "decrypt"], + }, + }), + TypeError, + "Cannot synchronously deserialize a CryptoKey", + ); + }, +}); + +Deno.test({ + name: "valueToJSONAsync - Blob", + async fn() { + const value = new Blob([new Uint8Array([1, 2, 3])], { type: "text/plain" }); + const actual = await valueToJSONAsync(value); + assertEquals(actual, { + type: "Blob", + value: "AQID", + contentType: "text/plain", + size: 3, + }); + }, +}); + +Deno.test({ + name: "valueToJSONAsync - empty Blob", + async fn() { + const actual = await valueToJSONAsync(new Blob([])); + assertEquals(actual, { + type: "Blob", + value: "", + contentType: "", + size: 0, + }); + }, +}); + +Deno.test({ + name: "valueToJSONAsync - File", + async fn() { + const value = new File( + [new Uint8Array([1, 2, 3])], + "hello.txt", + { type: "text/plain", lastModified: 1700000000000 }, + ); + const actual = await valueToJSONAsync(value); + assertEquals(actual, { + type: "File", + value: "AQID", + name: "hello.txt", + contentType: "text/plain", + lastModified: 1700000000000, + size: 3, + }); + }, +}); + +Deno.test({ + name: "valueToJSONAsync - CryptoKey AES-GCM secret", + async fn() { + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], + ); + const actual = await valueToJSONAsync(key); + assertEquals(actual.type, "CryptoKey"); + assertEquals(actual.value.type, "secret"); + assertEquals(actual.value.extractable, true); + assertEquals(actual.value.format, "raw"); + assertEquals(actual.value.usages, ["encrypt", "decrypt"]); + assertEquals(actual.value.algorithm, { name: "AES-GCM", length: 256 }); + // 32 bytes of key material + assertEquals( + new Uint8Array(Uint8Array.from( + atob(actual.value.keyData.replaceAll("-", "+").replaceAll("_", "/")), + // deno-lint-ignore no-explicit-any + ) as any).byteLength, + 32, + ); + }, +}); + +Deno.test({ + name: "valueToJSONAsync - CryptoKey RSA-OAEP public", + async fn() { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["encrypt", "decrypt"], + ); + const actual = await valueToJSONAsync(keyPair.publicKey); + assertEquals(actual.type, "CryptoKey"); + assertEquals(actual.value.type, "public"); + assertEquals(actual.value.format, "spki"); + assertEquals(actual.value.usages, ["encrypt"]); + assert(actual.value.keyData.length > 0); + }, +}); + +Deno.test({ + name: "valueToJSONAsync - CryptoKey RSA-OAEP private", + async fn() { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["encrypt", "decrypt"], + ); + const actual = await valueToJSONAsync(keyPair.privateKey); + assertEquals(actual.type, "CryptoKey"); + assertEquals(actual.value.type, "private"); + assertEquals(actual.value.format, "pkcs8"); + assertEquals(actual.value.usages, ["decrypt"]); + assert(actual.value.keyData.length > 0); + }, +}); + +Deno.test({ + name: "valueToJSONAsync - CryptoKey non-extractable throws", + async fn() { + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + let error: DOMException; + try { + await valueToJSONAsync(key); + throw new Error("expected error to be thrown"); + } catch (e) { + if (!(e instanceof DOMException)) { + throw e; + } + error = e; + } + assertEquals(error.name, "InvalidAccessError"); + }, +}); + +Deno.test({ + name: "valueToJSONAsync - DOMException", + async fn() { + const exception = new DOMException("aborted", "AbortError"); + const actual = await valueToJSONAsync(exception); + assertEquals(actual, { + type: "DOMException", + value: { + name: "AbortError", + message: "aborted", + stack: exception.stack, + }, + }); + }, +}); + +Deno.test({ + name: "valueToJSONAsync - sync types still work", + async fn() { + const actual = await valueToJSONAsync(new Map([["a", 1]])); + assertEquals(actual, { + type: "Map", + value: [ + [{ type: "string", value: "a" }, { type: "number", value: 1 }], + ], + }); + }, +}); + +Deno.test({ + name: "toValueAsync - Blob", + async fn() { + const value = await toValueAsync({ + type: "Blob", + value: "AQID", + contentType: "text/plain", + size: 3, + }); + assert(value instanceof Blob); + assertEquals(value.type, "text/plain"); + assertEquals(value.size, 3); + assert( + timingSafeEqual( + new Uint8Array(await value.arrayBuffer()), + new Uint8Array([1, 2, 3]), + ), + ); + }, +}); + +Deno.test({ + name: "toValueAsync - empty Blob", + async fn() { + const value = await toValueAsync({ + type: "Blob", + value: "", + contentType: "", + size: 0, + }); + assert(value instanceof Blob); + assertEquals(value.size, 0); + }, +}); + +Deno.test({ + name: "toValueAsync - File", + async fn() { + const value = await toValueAsync({ + type: "File", + value: "AQID", + name: "hello.txt", + contentType: "text/plain", + lastModified: 1700000000000, + size: 3, + }); + assert(value instanceof File); + assertEquals(value.name, "hello.txt"); + assertEquals(value.type, "text/plain"); + assertEquals(value.lastModified, 1700000000000); + assertEquals(value.size, 3); + assert( + timingSafeEqual( + new Uint8Array(await value.arrayBuffer()), + new Uint8Array([1, 2, 3]), + ), + ); + }, +}); + +Deno.test({ + name: "toValueAsync - CryptoKey AES-GCM round trip", + async fn() { + const original = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], + ); + const json = await valueToJSONAsync(original); + const restored = await toValueAsync(json); + assert(restored instanceof CryptoKey); + assertEquals(restored.type, "secret"); + assertEquals(restored.extractable, true); + assertEquals(restored.usages, ["encrypt", "decrypt"]); + // Verify functional equivalence by encrypting with the original and + // decrypting with the restored key. + const iv = crypto.getRandomValues(new Uint8Array(12)); + const data = new TextEncoder().encode("hello, world!"); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + original, + data, + ); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + restored, + ciphertext, + ); + assertEquals(new Uint8Array(plaintext), data); + }, +}); + +Deno.test({ + name: "toValueAsync - DOMException", + async fn() { + const value = (await toValueAsync({ + type: "DOMException", + value: { + name: "AbortError", + message: "aborted", + stack: "Error: aborted\n at foo", + }, + })) as DOMException; + assert(value instanceof DOMException); + assertEquals(value.name, "AbortError"); + assertEquals(value.message, "aborted"); + assertEquals(value.stack, "Error: aborted\n at foo"); + }, +}); + +Deno.test({ + name: "toValueAsync - sync types still work", + async fn() { + const value = await toValueAsync({ type: "string", value: "hello" }); + assertEquals(value, "hello"); + }, +}); + +Deno.test({ + name: "entryToJSONAsync - Blob", + async fn() { + const actual = await entryToJSONAsync({ + key: ["a"], + value: new Blob([new Uint8Array([1, 2, 3])]), + versionstamp: "00000000", + }); + assertEquals(actual, { + key: [{ type: "string", value: "a" }], + value: { + type: "Blob", + value: "AQID", + contentType: "", + size: 3, + }, + versionstamp: "00000000", + }); + }, +}); + +Deno.test({ + name: "entryMaybeToJSONAsync - Blob", + async fn() { + const actual = await entryMaybeToJSONAsync({ + key: ["a"], + value: new Blob([new Uint8Array([1, 2, 3])]), + versionstamp: "00000000", + }); + assertEquals(actual, { + key: [{ type: "string", value: "a" }], + value: { + type: "Blob", + value: "AQID", + contentType: "", + size: 3, + }, + versionstamp: "00000000", + }); + }, +}); + +Deno.test({ + name: "entryMaybeToJSONAsync - no entry", + async fn() { + const actual = await entryMaybeToJSONAsync({ + key: ["a"], + value: null, + versionstamp: null, + }); + assertEquals(actual, { + key: [{ type: "string", value: "a" }], + value: null, + versionstamp: null, + }); + }, +}); + +Deno.test({ + name: "toEntryAsync - Blob round trip", + async fn() { + const entry = await toEntryAsync({ + key: [{ type: "string", value: "a" }], + value: { type: "Blob", value: "AQID", contentType: "", size: 3 }, + versionstamp: "00000000", + }); + assertEquals(entry.key, ["a"]); + assertEquals(entry.versionstamp, "00000000"); + assert(entry.value instanceof Blob); + assertEquals(entry.value.size, 3); + }, +}); + +Deno.test({ + name: "toEntryMaybeAsync - Blob round trip", + async fn() { + const entry = await toEntryMaybeAsync({ + key: [{ type: "string", value: "a" }], + value: { type: "Blob", value: "AQID", contentType: "", size: 3 }, + versionstamp: "00000000", + }); + assertEquals(entry.key, ["a"]); + assertEquals(entry.versionstamp, "00000000"); + assert(entry.value instanceof Blob); + }, +}); + +Deno.test({ + name: "toEntryMaybeAsync - no entry", + async fn() { + const entry = await toEntryMaybeAsync({ + key: [{ type: "string", value: "a" }], + value: null, + versionstamp: null, + }); + assertEquals(entry.key, ["a"]); + assertStrictEquals(entry.value, null); + assertStrictEquals(entry.versionstamp, null); + }, +}); diff --git a/json.ts b/json.ts index a449fd1..40bc40a 100644 --- a/json.ts +++ b/json.ts @@ -210,7 +210,90 @@ export interface KvArrayJSON { } /** - * A representation of an {@linkcode DataView} Deno KV value. The value is + * A representation of a {@linkcode Blob} Deno KV value. The value is the + * bytes of the blob encoded as a URL safe base64 string, for example a blob + * containing the byte values of `[ 1, 2, 3 ]` would be: + * + * ```json + * { + * "type": "Blob", + * "value": "AQID", + * "contentType": "", + * "size": 3 + * } + * ``` + */ +export interface KvBlobJSON { + /** + * The type of the value, which is always `"Blob"`. + */ + type: "Blob"; + /** + * The URL safe base64 encoded value of the blob's bytes. + */ + value: string; + /** + * The MIME type of the blob. + */ + contentType: string; + /** + * The size of the blob in bytes. + */ + size: number; +} + +/** + * A representation of a {@linkcode CryptoKey} Deno KV value. The value + * contains the algorithm, extractable flag, type, usages, the format the key + * was exported in, and the exported key material encoded as a URL safe base64 + * string. + * + * The key material is obtained by calling + * {@linkcode SubtleCrypto.exportKey} and re-imported via + * {@linkcode SubtleCrypto.importKey}. Non-extractable keys cannot be + * serialized and will cause the underlying DOMException thrown by + * `exportKey` to be re-thrown. + */ +export interface KvCryptoKeyJSON { + /** + * The type of the value, which is always `"CryptoKey"`. + */ + type: "CryptoKey"; + /** + * The serialized key, including algorithm details, the extractable flag, + * the type, the usages, the format the key was exported in, and the URL + * safe base64 encoded key material. + */ + value: { + /** + * The algorithm of the key, as a JSON-serializable object. + */ + algorithm: Record; + /** + * Whether the key can be exported. + */ + extractable: boolean; + /** + * The format the key was exported in. + */ + format: "raw" | "spki" | "pkcs8"; + /** + * The URL safe base64 encoded exported key material. + */ + keyData: string; + /** + * The type of the key. + */ + type: "public" | "private" | "secret"; + /** + * The permitted usages of the key. + */ + usages: readonly KeyUsage[]; + }; +} + +/** + * A representation of a {@linkcode DataView} Deno KV value. The value is * the bytes of the buffer encoded as a URL safe base64 string, for example a * data view with the byte values of `[ 1, 2, 3 ]` would be: * @@ -249,6 +332,36 @@ export interface KvDateJSON { value: string; } +/** + * A representation of a {@linkcode DOMException} Deno KV value. The value + * contains the `name`, `message`, and optionally the `stack` of the + * exception. + */ +export interface KvDOMExceptionJSON { + /** + * The type of the value, which is always `"DOMException"`. + */ + type: "DOMException"; + /** + * The JSON serialized version of the exception, including the `name`, + * `message`, and optionally the `stack`. + */ + value: { + /** + * The name of the exception. + */ + name: string; + /** + * The message of the exception. + */ + message: string; + /** + * The stack trace of the exception, if available. + */ + stack?: string; + }; +} + /** * Error instances which are * [cloneable](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#error_types) @@ -302,6 +415,51 @@ export interface KvErrorJSON< }; } +/** + * A representation of a {@linkcode File} Deno KV value. The value is the + * bytes of the file encoded as a URL safe base64 string, for example a file + * with the name `"hello.txt"`, MIME type `"text/plain"`, last modified at + * `1700000000000`, and the byte values of `[ 1, 2, 3 ]` would be: + * + * ```json + * { + * "type": "File", + * "value": "AQID", + * "name": "hello.txt", + * "contentType": "text/plain", + * "lastModified": 1700000000000, + * "size": 3 + * } + * ``` + */ +export interface KvFileJSON { + /** + * The type of the value, which is always `"File"`. + */ + type: "File"; + /** + * The URL safe base64 encoded value of the file's bytes. + */ + value: string; + /** + * The name of the file. + */ + name: string; + /** + * The MIME type of the file. + */ + contentType: string; + /** + * The last modified time of the file as the number of milliseconds since + * the epoch. + */ + lastModified: number; + /** + * The size of the file in bytes. + */ + size: number; +} + /** * A representation of a {@linkcode Deno.KvU64} value. The value is the string * representation of the unsigned integer. @@ -484,10 +642,14 @@ export type KvValueJSON = | KvArrayBufferJSON | KvArrayJSON | KvBigIntJSON + | KvBlobJSON | KvBooleanJSON + | KvCryptoKeyJSON | KvDataViewJSON | KvDateJSON + | KvDOMExceptionJSON | KvErrorJSON + | KvFileJSON | KvKvU64JSON | KvMapJSON | KvNullJSON @@ -626,6 +788,113 @@ function typedArrayToJSON(typedArray: ArrayBufferView): KvTypedArrayJSON { throw TypeError("Unexpected typed array type, could not serialize."); } +/** + * Internal function to determine the default export format to use when + * serializing a {@linkcode CryptoKey}. + * + * Public keys are exported as `spki`, private keys as `pkcs8`, and secret + * keys as `raw`. + * + * @param key The key to determine the export format for. + * @returns The export format to use. + * + * @private + */ +function defaultKeyFormat(key: CryptoKey): "raw" | "spki" | "pkcs8" { + if (key.type === "public") { + return "spki"; + } + if (key.type === "private") { + return "pkcs8"; + } + return "raw"; +} + +/** + * Internal function to serialize a {@linkcode Blob} to JSON. + * + * @param blob The blob to serialize. + * @returns The JSON representation of the blob. + * + * @private + */ +async function blobToJSON(blob: Blob): Promise { + const bytes = await blob.bytes(); + return { + type: "Blob", + value: encodeBase64Url(bytes), + contentType: blob.type, + size: blob.size, + }; +} + +/** + * Internal function to serialize a {@linkcode File} to JSON. + * + * @param file The file to serialize. + * @returns The JSON representation of the file. + * + * @private + */ +async function fileToJSON(file: File): Promise { + const bytes = await file.bytes(); + return { + type: "File", + value: encodeBase64Url(bytes), + name: file.name, + contentType: file.type, + lastModified: file.lastModified, + size: file.size, + }; +} + +/** + * Internal function to serialize a {@linkcode CryptoKey} to JSON. + * + * Uses {@linkcode SubtleCrypto.exportKey} to obtain the raw key material. + * The DOMException thrown by `exportKey` (for example when the key is not + * extractable) is re-thrown unchanged. + * + * @param key The key to serialize. + * @returns The JSON representation of the key. + * + * @private + */ +async function cryptoKeyToJSON(key: CryptoKey): Promise { + const format = defaultKeyFormat(key); + const buffer = await crypto.subtle.exportKey(format, key); + return { + type: "CryptoKey", + value: { + algorithm: key.algorithm as unknown as Record, + extractable: key.extractable, + format, + keyData: encodeBase64Url(new Uint8Array(buffer)), + type: key.type, + usages: [...key.usages], + }, + }; +} + +/** + * Internal function to serialize a {@linkcode DOMException} to JSON. + * + * @param exception The exception to serialize. + * @returns The JSON representation of the exception. + * + * @private + */ +function domExceptionToJSON(exception: DOMException): KvDOMExceptionJSON { + const value: KvDOMExceptionJSON["value"] = { + name: exception.name, + message: exception.message, + }; + if (exception.stack) { + value.stack = exception.stack; + } + return { type: "DOMException", value }; +} + /** * Internal function to encode an object. * @@ -797,6 +1066,23 @@ export function valueToJSON(value: boolean): KvBooleanJSON; * ``` */ export function valueToJSON(value: Date): KvDateJSON; +/** + * Serialize a {@linkcode DOMException} that can be stored in Deno KV to JSON. + * + * @param value The exception value to serialize + * @returns The JSON representation of the value + * @example Serialize a value to JSON + * + * ```ts + * import { valueToJSON } from "@deno/kv-utils/json"; + * import { assertEquals } from "@std/assert"; + * + * const value = new DOMException("aborted", "AbortError"); + * const json = valueToJSON(value); + * assertEquals(json.type, "DOMException"); + * ``` + */ +export function valueToJSON(value: DOMException): KvDOMExceptionJSON; /** * Serialize an error that can be stored in Deno KV to JSON. * @@ -1072,6 +1358,21 @@ export function valueToJSON(value: unknown): KvValueJSON { case "undefined": return { type: "undefined" }; case "object": + if (value instanceof File) { + throw new TypeError( + "Cannot synchronously serialize a File; use valueToJSONAsync instead.", + ); + } + if (value instanceof Blob) { + throw new TypeError( + "Cannot synchronously serialize a Blob; use valueToJSONAsync instead.", + ); + } + if (typeof crypto !== "undefined" && value instanceof CryptoKey) { + throw new TypeError( + "Cannot synchronously serialize a CryptoKey; use valueToJSONAsync instead.", + ); + } if (Array.isArray(value)) { return { type: "Array", value: value.map(valueToJSON) }; } @@ -1098,6 +1399,9 @@ export function valueToJSON(value: unknown): KvValueJSON { if ("Deno" in globalThis && value instanceof Deno.KvU64) { return { type: "KvU64", value: String(value) }; } + if (value instanceof DOMException) { + return domExceptionToJSON(value); + } if (value instanceof Error) { return errorToJSON(value); } @@ -1263,6 +1567,86 @@ function toTypedArray(json: KvTypedArrayJSON): ArrayBufferView { } } +/** + * Internal function to deserialize a {@linkcode Blob}. + * + * @param json The JSON representation of the blob. + * @returns The deserialized blob. + * @private + */ +function toBlob(json: KvBlobJSON): Blob { + return new Blob([toArrayBuffer(json.value)], { type: json.contentType }); +} + +/** + * Internal function to deserialize a {@linkcode File}. + * + * @param json The JSON representation of the file. + * @returns The deserialized file. + * @private + */ +function toFile(json: KvFileJSON): File { + return new File([toArrayBuffer(json.value)], json.name, { + type: json.contentType, + lastModified: json.lastModified, + }); +} + +/** + * Internal function to copy a base64url-decoded value into a new + * {@linkcode ArrayBuffer}, ensuring the resulting buffer is not a + * `SharedArrayBuffer`. + * + * @param value The base64url encoded value. + * @returns An `ArrayBuffer` containing the decoded bytes. + * @private + */ +function toArrayBuffer(value: string): ArrayBuffer { + const bytes = decodeBase64Url(value); + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return buffer; +} + +/** + * Internal function to deserialize a {@linkcode CryptoKey}. + * + * @param json The JSON representation of the key. + * @returns A promise that resolves to the deserialized key. + * @private + */ +async function toCryptoKey(json: KvCryptoKeyJSON): Promise { + return await crypto.subtle.importKey( + json.value.format, + toArrayBuffer(json.value.keyData), + // deno-lint-ignore no-explicit-any + json.value.algorithm as any, + json.value.extractable, + // deno-lint-ignore no-explicit-any + json.value.usages as any, + ); +} + +/** + * Internal function to deserialize a {@linkcode DOMException}. + * + * @param json The JSON representation of the exception. + * @returns The deserialized exception. + * @private + */ +function toDOMException(json: KvDOMExceptionJSON): DOMException { + const exception = new DOMException(json.value.message, json.value.name); + if (json.value.stack) { + Object.defineProperty(exception, "stack", { + value: json.value.stack, + writable: false, + enumerable: false, + configurable: true, + }); + } + return exception; +} + /** * Deserialize {@linkcode KvBigIntJSON} to a bigint. * @@ -1522,6 +1906,27 @@ export function toValue(json: KvDataViewJSON): DataView; * ``` */ export function toValue(json: KvDateJSON): Date; +/** + * Deserialize {@linkcode KvDOMExceptionJSON} to a {@linkcode DOMException} + * which can be stored in a Deno KV store. + * + * @param json The JSON representation of the value. + * @returns The deserialized value. + * @example Deserialize a value from JSON + * + * ```ts + * import { toValue } from "@deno/kv-utils/json"; + * import { assert } from "@std/assert"; + * + * const json = { + * type: "DOMException", + * value: { name: "AbortError", message: "aborted" }, + * } as const; + * const value = toValue(json); + * assert(value instanceof DOMException); + * ``` + */ +export function toValue(json: KvDOMExceptionJSON): DOMException; /** * Deserialize {@linkcode KvErrorJSON} to an error value which can be stored in * a Deno KV store. @@ -1784,6 +2189,8 @@ export function toValue(json: KvValueJSON): unknown { return new DataView(decodeBase64Url(json.value).buffer); case "Date": return new Date(json.value); + case "DOMException": + return toDOMException(json); case "Error": case "EvalError": case "RangeError": @@ -1792,6 +2199,18 @@ export function toValue(json: KvValueJSON): unknown { case "TypeError": case "URIError": return toError(json); + case "Blob": + throw new TypeError( + "Cannot synchronously deserialize a Blob; use toValueAsync instead.", + ); + case "File": + throw new TypeError( + "Cannot synchronously deserialize a File; use toValueAsync instead.", + ); + case "CryptoKey": + throw new TypeError( + "Cannot synchronously deserialize a CryptoKey; use toValueAsync instead.", + ); case "KvU64": return new Deno.KvU64(BigInt(json.value)); case "RegExp": { @@ -1883,3 +2302,360 @@ export function toEntryMaybe( versionstamp, } as Deno.KvEntryMaybe; } + +// Async serialization and deserialization + +/** + * Asynchronously serialize a {@linkcode Blob} that can be stored in Deno KV + * to JSON. + * + * @param value The blob value to serialize + * @returns A promise that resolves to the JSON representation of the value + * @example Serialize a value to JSON + * + * ```ts + * import { valueToJSONAsync } from "@deno/kv-utils/json"; + * import { assertEquals } from "@std/assert"; + * + * const value = new File([new Uint8Array([1, 2, 3])], "hello.txt"); + * const json = await valueToJSONAsync(value); + * assertEquals(json.type, "File"); + * ``` + */ +export function valueToJSONAsync(value: File): Promise; +/** + * Asynchronously serialize a {@linkcode Blob} that can be stored in Deno KV + * to JSON. + * + * @param value The blob value to serialize + * @returns A promise that resolves to the JSON representation of the value + * @example Serialize a value to JSON + * + * ```ts + * import { valueToJSONAsync } from "@deno/kv-utils/json"; + * import { assertEquals } from "@std/assert"; + * + * const value = new Blob([new Uint8Array([1, 2, 3])]); + * const json = await valueToJSONAsync(value); + * assertEquals(json.type, "Blob"); + * ``` + */ +export function valueToJSONAsync(value: Blob): Promise; +/** + * Asynchronously serialize a {@linkcode CryptoKey} that can be stored in Deno + * KV to JSON. + * + * @param value The key value to serialize + * @returns A promise that resolves to the JSON representation of the value + * @example Serialize a value to JSON + * + * ```ts + * import { valueToJSONAsync } from "@deno/kv-utils/json"; + * import { assertEquals } from "@std/assert"; + * + * const value = await crypto.subtle.generateKey( + * { name: "AES-GCM", length: 256 }, + * true, + * ["encrypt", "decrypt"], + * ); + * const json = await valueToJSONAsync(value); + * assertEquals(json.type, "CryptoKey"); + * ``` + */ +export function valueToJSONAsync(value: CryptoKey): Promise; +/** + * Asynchronously serialize a value that can be stored in Deno KV to JSON. + * + * This is the asynchronous counterpart to {@linkcode valueToJSON} which is + * required for values that cannot be serialized synchronously, such as + * {@linkcode Blob}, {@linkcode File}, and {@linkcode CryptoKey}. All + * synchronous value types are still supported. + * + * @param value The value to serialize + * @returns A promise that resolves to the JSON representation of the value + * @example Serialize a value to JSON + * + * ```ts + * import { valueToJSONAsync } from "@deno/kv-utils/json"; + * import { assertEquals } from "@std/assert"; + * + * const value = new Map([["a", 1], ["b", 2]]); + * const json = await valueToJSONAsync(value); + * assertEquals(json, { type: "Map", value: [ + * [{ type: "string", value: "a" }, { type: "number", value: 1 }], + * [{ type: "string", value: "b" }, { type: "number", value: 2 }], + * ] }); + * ``` + */ +export function valueToJSONAsync(value: unknown): Promise; +export function valueToJSONAsync(value: unknown): Promise { + if (value instanceof File) { + return fileToJSON(value); + } + if (value instanceof Blob) { + return blobToJSON(value); + } + if (typeof crypto !== "undefined" && value instanceof CryptoKey) { + return cryptoKeyToJSON(value); + } + return Promise.resolve(valueToJSON(value)); +} + +/** + * Asynchronously deserialize {@linkcode KvBlobJSON} to a {@linkcode Blob} + * which can be stored in a Deno KV store. + * + * @param json The JSON representation of the value. + * @returns A promise that resolves to the deserialized value. + * @example Deserialize a value from JSON + * + * ```ts + * import { toValueAsync } from "@deno/kv-utils/json"; + * import { assert, assertEquals } from "@std/assert"; + * + * const json = { type: "Blob", value: "AQID", contentType: "", size: 3 } as const; + * const value = await toValueAsync(json); + * assert(value instanceof Blob); + * assertEquals(await value.bytes(), new Uint8Array([1, 2, 3])); + * ``` + */ +export function toValueAsync(json: KvBlobJSON): Promise; +/** + * Asynchronously deserialize {@linkcode KvFileJSON} to a {@linkcode File} + * which can be stored in a Deno KV store. + * + * @param json The JSON representation of the value. + * @returns A promise that resolves to the deserialized value. + * @example Deserialize a value from JSON + * + * ```ts + * import { toValueAsync } from "@deno/kv-utils/json"; + * import { assert, assertEquals } from "@std/assert"; + * + * const json = { + * type: "File", + * value: "AQID", + * name: "hello.txt", + * contentType: "", + * lastModified: 0, + * size: 3, + * } as const; + * const value = await toValueAsync(json); + * assert(value instanceof File); + * assertEquals(value.name, "hello.txt"); + * ``` + */ +export function toValueAsync(json: KvFileJSON): Promise; +/** + * Asynchronously deserialize {@linkcode KvCryptoKeyJSON} to a + * {@linkcode CryptoKey} which can be stored in a Deno KV store. + * + * @param json The JSON representation of the value. + * @returns A promise that resolves to the deserialized value. + * @example Deserialize a value from JSON + * + * ```ts + * import { toValueAsync } from "@deno/kv-utils/json"; + * import { assert } from "@std/assert"; + * + * const key = await crypto.subtle.generateKey( + * { name: "AES-GCM", length: 256 }, + * true, + * ["encrypt", "decrypt"], + * ); + * const json = await valueToJSONAsync(key); + * const restored = await toValueAsync(json); + * assert(restored instanceof CryptoKey); + * ``` + */ +export function toValueAsync(json: KvCryptoKeyJSON): Promise; +/** + * Asynchronously deserialize a {@linkcode KvValueJSON} to a value which can + * be stored in a Deno KV store. + * + * This is the asynchronous counterpart to {@linkcode toValue} which is + * required for values that cannot be deserialized synchronously, such as + * {@linkcode Blob}, {@linkcode File}, and {@linkcode CryptoKey}. All + * synchronous value types are still supported. + * + * @param json The JSON representation of the value. + * @returns A promise that resolves to the deserialized value. + * @example Deserialize a value from JSON + * + * ```ts + * import { toValueAsync } from "@deno/kv-utils/json"; + * import { assertEquals } from "@std/assert"; + * + * const json = { type: "string", value: "value" } as const; + * const value = await toValueAsync(json); + * assertEquals(value, "value"); + * ``` + */ +export function toValueAsync(json: KvValueJSON): Promise; +export function toValueAsync(json: KvValueJSON): Promise { + switch (json.type) { + case "Blob": + return Promise.resolve(toBlob(json)); + case "File": + return Promise.resolve(toFile(json)); + case "CryptoKey": + return toCryptoKey(json); + default: + return Promise.resolve(toValue(json)); + } +} + +/** + * Asynchronously serialize a {@linkcode Deno.KvEntry} to JSON. + * + * This is the asynchronous counterpart to {@linkcode entryToJSON} which is + * required when the entry's value contains types that cannot be serialized + * synchronously, such as {@linkcode Blob}, {@linkcode File}, and + * {@linkcode CryptoKey}. + * + * @param entry The entry to serialize. + * @returns A promise that resolves to the JSON representation of the entry. + * @example Serialize an entry to JSON + * + * ```ts + * import { assert } from "@std/assert/assert"; + * import { entryToJSONAsync } from "@deno/kv-utils/json"; + * + * const db = await Deno.openKv(); + * const maybeEntry = await db.get(["a"]); + * assert(maybeEntry.versionstamp); + * const json = await entryToJSONAsync(maybeEntry); + * db.close(); + * ``` + */ +export async function entryToJSONAsync( + { key, value, versionstamp }: Deno.KvEntry, +): Promise { + return { + key: key.map(keyPartToJSON), + value: await valueToJSONAsync(value), + versionstamp, + }; +} + +/** + * Asynchronously serialize a {@linkcode Deno.KvEntryMaybe} to JSON. + * + * This is the asynchronous counterpart to {@linkcode entryMaybeToJSON} which + * is required when the maybe entry's value contains types that cannot be + * serialized synchronously, such as {@linkcode Blob}, {@linkcode File}, and + * {@linkcode CryptoKey}. + * + * @param entryMaybe The maybe entry to serialize. + * @returns A promise that resolves to the JSON representation of the maybe + * entry. + * @example Serialize a maybe entry to JSON as a response + * + * ```ts ignore + * import { entryMaybeToJSONAsync } from "@deno/kv-utils"; + * + * const db = await Deno.openKv(); + * + * Deno.serve(async (_req) => { + * const maybeEntry = await db.get(["a"]); + * const json = await entryMaybeToJSONAsync(maybeEntry); + * return Response.json(json); + * }); + * ``` + */ +export async function entryMaybeToJSONAsync( + entryMaybe: Deno.KvEntryMaybe, +): Promise { + const { key, value, versionstamp } = entryMaybe; + return { + key: key.map(keyPartToJSON), + value: value === null && versionstamp === null + ? null + : await valueToJSONAsync(value), + versionstamp, + } as KvEntryMaybeJSON; +} + +/** + * Asynchronously deserialize a {@linkcode KvEntryJSON} to a + * {@linkcode Deno.KvEntry}. + * + * This is the asynchronous counterpart to {@linkcode toEntry} which is + * required when the entry's value contains types that cannot be + * deserialized synchronously, such as {@linkcode Blob}, {@linkcode File}, + * and {@linkcode CryptoKey}. + * + * @typeParam T The type of the value of the entry. + * @param entry The entry to deserialize. + * @returns A promise that resolves to the deserialized entry. + * @example Deserialize an entry from JSON + * + * ```ts + * import { toEntryAsync } from "@deno/kv-utils/json"; + * import { assertEquals } from "@std/assert"; + * + * const json = { + * key: [ { type: "string", value: "a" } ], + * value: { type: "string", value: "b" }, + * versionstamp: "00000123456789abcdef", + * } as const; + * const entry = await toEntryAsync(json); + * assertEquals(entry, { + * key: ["a"], + * value: "b", + * versionstamp: "00000123456789abcdef", + * }); + * ``` + */ +export async function toEntryAsync( + entry: KvEntryJSON, +): Promise> { + const { key, value, versionstamp } = entry; + return { + key: key.map(toKeyPart), + value: (await toValueAsync(value)) as T, + versionstamp, + }; +} + +/** + * Asynchronously deserialize a {@linkcode KvEntryMaybeJSON} to a + * {@linkcode Deno.KvEntryMaybe}. + * + * This is the asynchronous counterpart to {@linkcode toEntryMaybe} which is + * required when the maybe entry's value contains types that cannot be + * deserialized synchronously, such as {@linkcode Blob}, {@linkcode File}, + * and {@linkcode CryptoKey}. + * + * @typeParam T The type of the value of the entry. + * @param maybeEntry The entry to deserialize. + * @returns A promise that resolves to the deserialized entry. + * @example Deserialize an entry maybe from JSON + * + * ```ts + * import { toEntryMaybeAsync } from "@deno/kv-utils/json"; + * import { assertEquals } from "@std/assert"; + * + * const json = { + * key: [ { type: "string", value: "a" } ], + * value: null, + * versionstamp: null, + * } as const; + * const maybeEntry = await toEntryMaybeAsync(json); + * assertEquals(maybeEntry, { + * key: ["a"], + * value: null, + * versionstamp: null, + * }); + * ``` + */ +export async function toEntryMaybeAsync( + maybeEntry: KvEntryMaybeJSON, +): Promise> { + const { key, value, versionstamp } = maybeEntry; + return { + key: key.map(toKeyPart), + value: value === null ? null : (await toValueAsync(value)) as T, + versionstamp, + } as Deno.KvEntryMaybe; +} diff --git a/mod.ts b/mod.ts index 3652751..4f0278e 100644 --- a/mod.ts +++ b/mod.ts @@ -13,7 +13,7 @@ * represent entries and values in a browser, or communicate them between Deno * processes. * - * The JSON utilities are: + * The synchronous JSON utilities are: * * - {@linkcode entryMaybeToJSON} - Convert a {@linkcode Deno.KvEntryMaybe} to * JSON. @@ -31,19 +31,41 @@ * - {@linkcode toValue} - Convert a JSON object to a value which can be stored * in Deno KV. * + * Since Deno 2.8.1, Deno KV also supports storing web objects such as + * {@linkcode Blob}, {@linkcode File}, {@linkcode CryptoKey}, and + * {@linkcode DOMException}. Reading a `Blob`/`File` and exporting a + * `CryptoKey` are inherently asynchronous, so the following asynchronous + * utilities are provided as counterparts to the synchronous ones above and + * transparently handle all value types: + * + * - {@linkcode entryMaybeToJSONAsync} - Asynchronous version of + * {@linkcode entryMaybeToJSON}. + * - {@linkcode entryToJSONAsync} - Asynchronous version of + * {@linkcode entryToJSON}. + * - {@linkcode valueToJSONAsync} - Asynchronous version of + * {@linkcode valueToJSON}. + * - {@linkcode toEntryAsync} - Asynchronous version of {@linkcode toEntry}. + * - {@linkcode toEntryMaybeAsync} - Asynchronous version of + * {@linkcode toEntryMaybe}. + * - {@linkcode toValueAsync} - Asynchronous version of {@linkcode toValue}. + * + * The synchronous {@linkcode valueToJSON} and {@linkcode toValue} functions + * throw a `TypeError` when given a `Blob`, `File`, or `CryptoKey`. Use the + * `*Async` variants for any value that may contain these types. + * * ### Examples * * Taking a maybe entry from Deno.Kv and converting it to JSON and sending it * as a response: * * ```ts ignore - * import { entryMaybeToJSON } from "@deno/kv-utils"; + * import { entryMaybeToJSONAsync } from "@deno/kv-utils"; * * const db = await Deno.openKv(); * * Deno.serve(async (_req) => { * const maybeEntry = await db.get(["a"]); - * const json = entryMaybeToJSON(maybeEntry); + * const json = await entryMaybeToJSONAsync(maybeEntry); * return Response.json(json); * }); * ``` @@ -52,13 +74,13 @@ * Deno KV: * * ```ts ignore - * import { toValue } from "@deno/kv-utils"; + * import { toValueAsync } from "@deno/kv-utils"; * * const db = await Deno.openKv(); * * Deno.serve(async (req) => { * const json = await req.json(); - * const value = toValue(json); + * const value = await toValueAsync(json); * await db.set(["a"], value); * return new Response(null, { status: 204 }); * }); @@ -100,6 +122,10 @@ * or a response. * - {@linkcode importEntries} - Import entries into a Deno KV store. * + * `exportEntries` and `importEntries` use the asynchronous `*Async` JSON + * utilities internally, so they transparently support values that contain + * `Blob`, `File`, `CryptoKey`, or `DOMException`. + * * ### Examples * * Exporting entries from a Deno KV store and saving them to a file: