diff --git a/.changeset/all-streets-crash.md b/.changeset/all-streets-crash.md new file mode 100644 index 000000000000..aa128dee39f9 --- /dev/null +++ b/.changeset/all-streets-crash.md @@ -0,0 +1,13 @@ +--- +"miniflare": minor +--- + +Implement local KV API for experimental/WIP local resource explorer + +The following APIs have been (mostly) implemented: +GET /storage/kv/namespaces - List namespaces +GET /storage/kv/namespaces/:id/keys - List keys +GET /storage/kv/namespaces/:id/values/:key - Get value +PUT /storage/kv/namespaces/:id/values/:key - Write value +DELETE /storage/kv/namespaces/:id/values/:key - Delete key +POST /storage/kv/namespaces/:id/bulk/get - Bulk get values diff --git a/fixtures/worker-with-resources/tests/index.test.ts b/fixtures/worker-with-resources/tests/index.test.ts index 859711884e4b..c9ce8db17d64 100644 --- a/fixtures/worker-with-resources/tests/index.test.ts +++ b/fixtures/worker-with-resources/tests/index.test.ts @@ -21,9 +21,11 @@ describe("local explorer", () => { }); it("returns local explorer API response for /cdn-cgi/explorer/api", async () => { - const response = await fetch(`http://${ip}:${port}/cdn-cgi/explorer/api`); + const response = await fetch( + `http://${ip}:${port}/cdn-cgi/explorer/api/storage/kv/namespaces` + ); const text = await response.text(); - expect(text).toBe("Hello from local explorer API"); + expect(text).toMatchInlineSnapshot(`"{"success":true,"errors":[],"messages":[],"result":[{"id":"KV","title":"KV"},{"id":"some-kv-id","title":"KV_WITH_ID"}],"result_info":{"count":2,"page":1,"per_page":20,"total_count":2}}"`); }); it("returns worker response for normal requests", async () => { diff --git a/fixtures/worker-with-resources/wrangler.jsonc b/fixtures/worker-with-resources/wrangler.jsonc index 0daf63a0958e..764ee4f1d32c 100644 --- a/fixtures/worker-with-resources/wrangler.jsonc +++ b/fixtures/worker-with-resources/wrangler.jsonc @@ -7,6 +7,10 @@ { "binding": "KV", }, + { + "binding": "KV_WITH_ID", + "id": "some-kv-id", + }, ], "d1_databases": [ { diff --git a/packages/miniflare/package.json b/packages/miniflare/package.json index 52e6429c8d34..772e97d4b1eb 100644 --- a/packages/miniflare/package.json +++ b/packages/miniflare/package.json @@ -73,6 +73,7 @@ "acorn-walk": "8.3.2", "capnp-es": "catalog:default", "capnweb": "^0.1.0", + "chanfana": "^2.8.3", "chokidar": "^4.0.1", "concurrently": "^8.2.2", "devalue": "^5.3.2", @@ -87,6 +88,7 @@ "get-port": "^7.1.0", "glob-to-regexp": "0.4.1", "heap-js": "^2.5.0", + "hono": "^4.11.5", "http-cache-semantics": "^4.1.0", "kleur": "^4.1.5", "mime": "^3.0.0", diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 6463381fa164..967af29eb941 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -1077,6 +1077,25 @@ export function getGlobalServices({ ]; if (sharedOptions.unsafeLocalExplorer) { + // Build binding ID map from proxyBindings + // Maps binding names to their actual namespace/bucket IDs + const IDToBindingName: { kv: Record } = { kv: {} }; + + for (const binding of proxyBindings) { + // KV bindings: name = "MINIFLARE_PROXY:kv:worker:BINDING", kvNamespace.name = "kv:ns:ID" + if ( + binding.name?.startsWith( + `${CoreBindings.DURABLE_OBJECT_NAMESPACE_PROXY}:kv:` + ) && + "kvNamespace" in binding && + binding.kvNamespace?.name + ) { + // Extract ID from service name "kv:ns:ID" + const namespaceId = binding.kvNamespace.name.replace("kv:ns:", ""); + IDToBindingName.kv[namespaceId] = binding.name; + } + } + services.push({ name: SERVICE_LOCAL_EXPLORER, worker: { @@ -1088,7 +1107,13 @@ export function getGlobalServices({ esModule: SCRIPT_LOCAL_EXPLORER_API(), }, ], - bindings: [...proxyBindings], + bindings: [ + ...proxyBindings, + { + name: CoreBindings.JSON_LOCAL_EXPLORER_BINDING_MAP, + json: JSON.stringify(IDToBindingName), + }, + ], }, }); } diff --git a/packages/miniflare/src/workers/core/constants.ts b/packages/miniflare/src/workers/core/constants.ts index 9a70eaae5c72..db98680b4bdb 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -45,6 +45,7 @@ export const CoreBindings = { TRIGGER_HANDLERS: "TRIGGER_HANDLERS", LOG_REQUESTS: "LOG_REQUESTS", STRIP_DISABLE_PRETTY_ERROR: "STRIP_DISABLE_PRETTY_ERROR", + JSON_LOCAL_EXPLORER_BINDING_MAP: "LOCAL_EXPLORER_BINDING_MAP", } as const; export const ProxyOps = { diff --git a/packages/miniflare/src/workers/local-explorer/api.worker.ts b/packages/miniflare/src/workers/local-explorer/api.worker.ts index 06d2149905e6..0880da3e1919 100644 --- a/packages/miniflare/src/workers/local-explorer/api.worker.ts +++ b/packages/miniflare/src/workers/local-explorer/api.worker.ts @@ -1,10 +1,55 @@ // local explorer API Worker // Provides a REST API for viewing and manipulating user resources -import { WorkerEntrypoint } from "cloudflare:workers"; +import { fromHono } from "chanfana"; +import { Hono } from "hono"; +import { + BulkGetKVValues, + DeleteKVValue, + GetKVValue, + ListKVKeys, + ListKVNamespaces, + PutKVValue, +} from "./resources/kv"; -export default class LocalExplorerAPI extends WorkerEntrypoint { - async fetch(): Promise { - return new Response("Hello from local explorer API"); - } -} +type BindingIdMap = { + kv: Record; // namespaceId -> bindingName +}; +export type Env = { + [key: string]: unknown; + LOCAL_EXPLORER_BINDING_MAP: BindingIdMap; +}; + +export type AppBindings = { Bindings: Env }; + +const BASE_PATH = "/cdn-cgi/explorer/api"; + +const app = new Hono().basePath(BASE_PATH); + +const openapi = fromHono(app, { + base: BASE_PATH, + schema: { + info: { + title: "Local Explorer API", + version: "1.0.0", + description: + "A local subset of Cloudflare's REST API for exploring resources during local development.", + }, + }, +}); + +// ============================================================================ +// KV Endpoints +// ============================================================================ + +openapi.get("/storage/kv/namespaces", ListKVNamespaces); +openapi.get("/storage/kv/namespaces/:namespaceId/keys", ListKVKeys); +openapi.get("/storage/kv/namespaces/:namespaceId/values/:keyName", GetKVValue); +openapi.put("/storage/kv/namespaces/:namespaceId/values/:keyName", PutKVValue); +openapi.delete( + "/storage/kv/namespaces/:namespaceId/values/:keyName", + DeleteKVValue +); +openapi.post("/storage/kv/namespaces/:namespaceId/bulk/get", BulkGetKVValues); + +export default app; diff --git a/packages/miniflare/src/workers/local-explorer/common.ts b/packages/miniflare/src/workers/local-explorer/common.ts new file mode 100644 index 000000000000..59a9c57b29d6 --- /dev/null +++ b/packages/miniflare/src/workers/local-explorer/common.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +export const ResponseInfoSchema = z.object({ + code: z.number(), + message: z.string(), +}); + +export const PageResultInfoSchema = z.object({ + count: z.number(), + page: z.number(), + per_page: z.number(), + total_count: z.number(), +}); + +export const CursorResultInfoSchema = z.object({ + count: z.number(), + cursor: z.string(), +}); + +export const CloudflareEnvelope = (resultSchema: T) => + z.object({ + success: z.boolean(), + errors: z.array(ResponseInfoSchema), + messages: z.array(ResponseInfoSchema), + result: resultSchema, + }); + +// ============================================================================ +// Response Helpers +// ============================================================================ + +export function wrapResponse(result: T) { + return { + success: true as const, + errors: [] as { code: number; message: string }[], + messages: [] as { code: number; message: string }[], + result, + }; +} + +export function errorResponse(status: number, code: number, message: string) { + return Response.json( + { + success: false, + errors: [{ code, message }], + messages: [], + result: null, + }, + { status } + ); +} diff --git a/packages/miniflare/src/workers/local-explorer/resources/kv.ts b/packages/miniflare/src/workers/local-explorer/resources/kv.ts new file mode 100644 index 000000000000..ec8fc9fbecaa --- /dev/null +++ b/packages/miniflare/src/workers/local-explorer/resources/kv.ts @@ -0,0 +1,434 @@ +import { OpenAPIRoute } from "chanfana"; +import { z } from "zod"; +import { + CloudflareEnvelope, + CursorResultInfoSchema, + errorResponse, + PageResultInfoSchema, + wrapResponse, +} from "../common"; +import type { AppBindings, Env } from "../api.worker"; +import type { Context } from "hono"; + +type AppContext = Context; + +// ============================================================================ +// KV Schemas +// ============================================================================ + +const KVNamespaceSchema = z.object({ + id: z.string(), + title: z.string(), +}); + +const KVKeySchema = z.object({ + name: z.string(), + expiration: z.number().optional(), + metadata: z.unknown().optional(), +}); + +// ============================================================================ +// KV Helpers +// ============================================================================ + +// Get a KV binding by namespace ID +function getKVBinding(env: Env, namespaceId: string): KVNamespace | null { + const bindingMap = env.LOCAL_EXPLORER_BINDING_MAP.kv; + + // Find the binding name for this namespace ID + const bindingName = bindingMap[namespaceId]; + if (!bindingName) return null; + + return env[bindingName] as KVNamespace; +} + +// ============================================================================ +// KV Endpoints +// ============================================================================ + +// https://developers.cloudflare.com/api/resources/kv/subresources/namespaces/methods/list/ +export class ListKVNamespaces extends OpenAPIRoute { + schema = { + tags: ["KV Storage"], + summary: "List Namespaces", + description: "Returns the KV namespaces available in this worker", + request: { + query: z.object({ + direction: z + .enum(["asc", "desc"]) + .default("asc") + .describe("Direction to order namespaces"), + order: z + .enum(["id", "title"]) + .default("id") + .describe("Field to order results by"), + page: z + .number() + .min(1) + .default(1) + .describe("Page number of paginated results"), + per_page: z + .number() + .min(1) + .max(1000) + .default(20) + .describe("Maximum number of results per page"), + }), + }, + responses: { + "200": { + description: "List of KV namespaces", + content: { + "application/json": { + schema: CloudflareEnvelope(z.array(KVNamespaceSchema)).extend({ + result_info: PageResultInfoSchema, + }), + }, + }, + }, + }, + }; + + async handle(c: AppContext) { + const data = await this.getValidatedData(); + const { direction, order, page, per_page } = data.query; + + const kvBindingMap = c.env.LOCAL_EXPLORER_BINDING_MAP.kv; + let namespaces = Object.entries(kvBindingMap).map(([id, bindingName]) => ({ + id: id, + // this is not technically correct, but the title doesn't exist locally + title: bindingName.split(":").pop() || bindingName, + })); + + namespaces.sort( + (a: { id: string; title: string }, b: { id: string; title: string }) => { + const aVal = a[order]; + const bVal = b[order]; + const cmp = aVal.localeCompare(bVal); + return direction === "asc" ? cmp : -cmp; + } + ); + + const total_count = namespaces.length; + + // Paginate + const startIndex = (page - 1) * per_page; + const endIndex = startIndex + per_page; + namespaces = namespaces.slice(startIndex, endIndex); + + return { + ...wrapResponse(namespaces), + result_info: { + count: namespaces.length, + page, + per_page, + total_count, + }, + }; + } +} + +// https://developers.cloudflare.com/api/resources/kv/subresources/namespaces/subresources/keys/methods/list/ +export class ListKVKeys extends OpenAPIRoute { + schema = { + tags: ["KV Storage"], + summary: "List a Namespace's Keys", + description: "Lists a namespace's keys", + request: { + params: z.object({ + namespaceId: z.string().describe("Namespace identifier"), + }), + query: z.object({ + cursor: z.string().optional().describe("Pagination cursor"), + limit: z + .number() + .min(10) + .max(1000) + .default(1000) + .describe("Number of keys to return"), + }), + }, + responses: { + "200": { + description: "List of keys", + content: { + "application/json": { + schema: CloudflareEnvelope(z.array(KVKeySchema)).extend({ + result_info: CursorResultInfoSchema.optional(), + }), + }, + }, + }, + "404": { + description: "Namespace not found", + }, + }, + }; + + async handle(c: AppContext) { + const data = await this.getValidatedData(); + const { namespaceId } = data.params; + const { cursor, limit } = data.query; + + const kv = getKVBinding(c.env, namespaceId); + if (!kv) { + return errorResponse(404, 10000, "Namespace not found"); + } + + const listResult = await kv.list({ cursor, limit }); + const resultCursor = "cursor" in listResult ? listResult.cursor ?? "" : ""; + + return { + ...wrapResponse( + listResult.keys.map((key) => ({ + name: key.name, + expiration: key.expiration, + metadata: key.metadata, + })) + ), + result_info: { + count: listResult.keys.length, + cursor: resultCursor, + }, + }; + } +} + +// https://developers.cloudflare.com/api/resources/kv/subresources/namespaces/subresources/values/methods/get/ +export class GetKVValue extends OpenAPIRoute { + schema = { + tags: ["KV Storage"], + summary: "Read key-value pair", + description: + "Returns the value associated with the given key. Use URL-encoding for special characters in key names.", + request: { + params: z.object({ + namespaceId: z.string().describe("Namespace identifier (binding name)"), + keyName: z.string().describe("Key name (URL-encoded)"), + }), + }, + responses: { + "200": { + description: "The value associated with the key", + }, + "404": { + description: "Namespace or key not found", + }, + }, + }; + + async handle(c: AppContext) { + const data = await this.getValidatedData(); + const { namespaceId, keyName } = data.params; + + const kv = getKVBinding(c.env, namespaceId); + if (!kv) { + return errorResponse(404, 10000, "Namespace not found"); + } + + const value = await kv.get(keyName, { type: "arrayBuffer" }); + if (value === null) { + return errorResponse(404, 10000, "Key not found"); + } + + // this specific API doesn't wrap the response in the envelope + return new Response(value); + } +} + +// https://developers.cloudflare.com/api/resources/kv/subresources/namespaces/subresources/values/methods/update/ +export class PutKVValue extends OpenAPIRoute { + schema = { + tags: ["KV Storage"], + summary: "Write key-value pair with optional metadata", + description: + "Write a value identified by a key. Supports multipart/form-data for metadata.", + request: { + params: z.object({ + namespaceId: z.string().describe("Namespace identifier (binding name)"), + keyName: z.string().describe("Key name (URL-encoded, max 512 bytes)"), + }), + }, + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: CloudflareEnvelope(z.object({})), + }, + }, + }, + "404": { + description: "Namespace not found", + }, + }, + }; + + async handle(c: AppContext) { + const data = await this.getValidatedData(); + const { namespaceId, keyName } = data.params; + + const kv = getKVBinding(c.env, namespaceId); + if (!kv) { + return errorResponse(404, 10000, "Namespace not found"); + } + + let value: ArrayBuffer | string; + let metadata: unknown | undefined; + + const contentType = c.req.header("content-type") || ""; + + if (contentType.includes("multipart/form-data")) { + const formData = await c.req.formData(); + const formValue = formData.get("value"); + const formMetadata = formData.get("metadata"); + + if (formValue instanceof Blob) { + // Handle File or Blob + value = await formValue.arrayBuffer(); + } else if (typeof formValue === "string") { + value = formValue; + } else if (formValue === null) { + return errorResponse(400, 10001, "Missing value field"); + } else { + // Unknown type, try to convert to string + value = String(formValue); + } + + if (formMetadata instanceof Blob) { + const metadataText = await formMetadata.text(); + try { + metadata = JSON.parse(metadataText); + } catch { + return errorResponse(400, 10002, "Invalid metadata JSON"); + } + } else if (typeof formMetadata === "string") { + try { + metadata = JSON.parse(formMetadata); + } catch { + return errorResponse(400, 10002, "Invalid metadata JSON"); + } + } + } else { + value = await c.req.arrayBuffer(); + } + + const options: KVNamespacePutOptions = {}; + if (metadata) options.metadata = metadata; + + await kv.put(keyName, value, options); + + return wrapResponse({}); + } +} + +// https://developers.cloudflare.com/api/resources/kv/subresources/namespaces/subresources/values/methods/delete/ +export class DeleteKVValue extends OpenAPIRoute { + schema = { + tags: ["KV Storage"], + summary: "Delete key-value pair", + description: + "Remove a KV pair from the namespace. Use URL-encoding for special characters in key names.", + request: { + params: z.object({ + namespaceId: z.string().describe("Namespace identifier (binding name)"), + keyName: z.string().describe("Key name (URL-encoded)"), + }), + }, + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: CloudflareEnvelope(z.object({})), + }, + }, + }, + "404": { + description: "Namespace not found", + }, + }, + }; + + async handle(c: AppContext) { + const data = await this.getValidatedData(); + const { namespaceId, keyName } = data.params; + + const kv = getKVBinding(c.env, namespaceId); + if (!kv) { + return errorResponse(404, 10000, "Namespace not found"); + } + + await kv.delete(keyName); + + return wrapResponse({}); + } +} + +// https://developers.cloudflare.com/api/resources/kv/subresources/namespaces/methods/bulk_get/ +export class BulkGetKVValues extends OpenAPIRoute { + schema = { + tags: ["KV Storage"], + summary: "Get multiple key-value pairs", + description: "Retrieve up to 100 KV pairs from the namespace.", + request: { + params: z.object({ + namespaceId: z.string().describe("Namespace identifier"), + }), + body: { + content: { + "application/json": { + schema: z.object({ + keys: z + .array(z.string()) + .max(100) + .describe("Array of key names to retrieve"), + }), + }, + }, + }, + }, + responses: { + "200": { + description: "Key-value pairs", + content: { + "application/json": { + schema: CloudflareEnvelope( + z.object({ + values: z.record(z.string(), z.string()), + }) + ), + }, + }, + }, + "404": { + description: "Namespace not found", + }, + }, + }; + + async handle(c: AppContext) { + const data = await this.getValidatedData(); + const { namespaceId } = data.params; + const { keys } = data.body; + + const kv = getKVBinding(c.env, namespaceId); + if (!kv) { + return errorResponse(404, 10000, "Namespace not found"); + } + + // Fetch all keys at once - returns Map + const results = await kv.get(keys); + + // Convert Map to object, filtering out null values + //TODO: figure out what api actually does with nulls in a bulk get + const values: Record = {}; + for (const [key, value] of results) { + if (value !== null) { + values[key] = value; + } + } + + return wrapResponse({ values }); + } +} diff --git a/packages/miniflare/test/plugins/local-explorer/kv.spec.ts b/packages/miniflare/test/plugins/local-explorer/kv.spec.ts new file mode 100644 index 000000000000..8ab8903d335f --- /dev/null +++ b/packages/miniflare/test/plugins/local-explorer/kv.spec.ts @@ -0,0 +1,449 @@ +import { Miniflare } from "miniflare"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { disposeWithRetry } from "../../test-shared"; + +const BASE_URL = "http://localhost/cdn-cgi/explorer/api"; + +describe("KV API", () => { + let mf: Miniflare; + + beforeAll(async () => { + mf = new Miniflare({ + compatibilityDate: "2025-01-01", + modules: true, + script: `export default { fetch() { return new Response("user worker"); } }`, + unsafeLocalExplorer: true, + kvNamespaces: { + TEST_KV: "test-kv-id", + ANOTHER_KV: "another-kv-id", + ZEBRA_KV: "zebra-kv-id", + }, + }); + }); + + afterAll(async () => { + await disposeWithRetry(mf); + }); + + describe("GET /storage/kv/namespaces", () => { + test("lists available KV namespaces with default pagination", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces` + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + success: true, + errors: [], + result: expect.arrayContaining([ + expect.objectContaining({ id: "test-kv-id", title: "TEST_KV" }), + expect.objectContaining({ id: "another-kv-id", title: "ANOTHER_KV" }), + expect.objectContaining({ id: "zebra-kv-id", title: "ZEBRA_KV" }), + ]), + result_info: { + count: 3, + page: 1, + per_page: 20, + total_count: 3, + }, + }); + }); + + test("sorts namespaces by id", async () => { + let response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces` + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: [ + expect.objectContaining({ id: "another-kv-id" }), + expect.objectContaining({ id: "test-kv-id" }), + expect.objectContaining({ id: "zebra-kv-id" }), + ], + }); + + response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces?direction=desc` + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: [ + expect.objectContaining({ id: "zebra-kv-id" }), + expect.objectContaining({ id: "test-kv-id" }), + expect.objectContaining({ id: "another-kv-id" }), + ], + }); + }); + + test("sorts namespaces by title", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces?order=title&direction=desc` + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: [ + expect.objectContaining({ title: "ZEBRA_KV" }), + expect.objectContaining({ title: "TEST_KV" }), + expect.objectContaining({ title: "ANOTHER_KV" }), + ], + }); + }); + + test("pagination works", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces?per_page=2&page=2` + ); + + expect(response.status).toBe(200); + // Sorted by ID: "another-kv-id", "test-kv-id", "zebra-kv-id" + // Page 2 with per_page=2 should return only "zebra-kv-id" + expect(await response.json()).toMatchObject({ + result: [expect.objectContaining({ id: "zebra-kv-id" })], + result_info: { + count: 1, + page: 2, + per_page: 2, + total_count: 3, + }, + }); + }); + + test("returns empty result for page beyond total", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces?per_page=20&page=100` + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: [], + result_info: { + count: 0, + page: 100, + per_page: 20, + total_count: 3, + }, + }); + }); + }); + + describe("GET /storage/kv/namespaces/:namespaceId/keys", () => { + test("lists keys in a namespace", async () => { + const kv = await mf.getKVNamespace("TEST_KV"); + await kv.put("test-key-1", "value1"); + await kv.put("test-key-2", "value2"); + + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/keys` + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + success: true, + result: expect.arrayContaining([ + expect.objectContaining({ name: "test-key-1" }), + expect.objectContaining({ name: "test-key-2" }), + ]), + result_info: expect.objectContaining({ + count: expect.any(Number), + cursor: expect.any(String), + }), + }); + }); + + test("respects limit parameter", async () => { + const kv = await mf.getKVNamespace("TEST_KV"); + for (let i = 0; i < 15; i++) { + await kv.put(`limit-test-${i}`, "value"); + } + + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/keys?limit=10` + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + success: true, + result_info: expect.objectContaining({ count: 10 }), + }); + }); + + test("returns 404 for non-existent namespace", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/NON_EXISTENT/keys` + ); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + success: false, + errors: [expect.objectContaining({ message: "Namespace not found" })], + }); + }); + }); + + describe("GET /storage/kv/namespaces/:namespaceId/values/:keyName", () => { + test("returns value for existing key", async () => { + const kv = await mf.getKVNamespace("TEST_KV"); + await kv.put("get-test-key", "test-value-123"); + + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/values/get-test-key` + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("test-value-123"); + }); + + test("returns 404 for non-existent key", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/values/non-existent-key-xyz` + ); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + success: false, + errors: [expect.objectContaining({ message: "Key not found" })], + }); + }); + + test("returns 404 for non-existent namespace", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/NON_EXISTENT/values/some-key` + ); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + success: false, + errors: [expect.objectContaining({ message: "Namespace not found" })], + }); + }); + + test("handles URL-encoded key names", async () => { + const kv = await mf.getKVNamespace("TEST_KV"); + const specialKey = "key:with:colons"; + await kv.put(specialKey, "special-value"); + + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/values/${encodeURIComponent(specialKey)}` + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("special-value"); + }); + }); + + describe("PUT /storage/kv/namespaces/:namespaceId/values/:keyName", () => { + test("writes a new value", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/values/put-test-key`, + { + method: "PUT", + body: "new-value", + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ success: true }); + + // Verify the value was written + const kv = await mf.getKVNamespace("TEST_KV"); + expect(await kv.get("put-test-key")).toBe("new-value"); + }); + + test("overwrites existing value", async () => { + const kv = await mf.getKVNamespace("TEST_KV"); + await kv.put("overwrite-key", "old-value"); + + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/values/overwrite-key`, + { + method: "PUT", + body: "updated-value", + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ success: true }); + expect(await kv.get("overwrite-key")).toBe("updated-value"); + }); + + test("returns 404 for non-existent namespace", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/NON_EXISTENT/values/some-key`, + { + method: "PUT", + body: "value", + } + ); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + success: false, + errors: [expect.objectContaining({ message: "Namespace not found" })], + }); + }); + }); + + describe("DELETE /storage/kv/namespaces/:namespaceId/values/:keyName", () => { + test("deletes an existing key", async () => { + const kv = await mf.getKVNamespace("TEST_KV"); + await kv.put("delete-test-key", "value-to-delete"); + + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/values/delete-test-key`, + { + method: "DELETE", + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ success: true }); + + // Verify the value was deleted + expect(await kv.get("delete-test-key")).toBeNull(); + }); + + test("succeeds even if key does not exist", async () => { + // KV delete is idempotent - deleting non-existent key should succeed + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/values/definitely-does-not-exist`, + { + method: "DELETE", + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ success: true }); + }); + + test("returns 404 for non-existent namespace", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/NON_EXISTENT/values/some-key`, + { + method: "DELETE", + } + ); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + success: false, + errors: [expect.objectContaining({ message: "Namespace not found" })], + }); + }); + + test("handles URL-encoded key names", async () => { + const kv = await mf.getKVNamespace("TEST_KV"); + const specialKey = "delete:key:with:colons"; + await kv.put(specialKey, "value"); + + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/values/${encodeURIComponent(specialKey)}`, + { + method: "DELETE", + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ success: true }); + expect(await kv.get(specialKey)).toBeNull(); + }); + }); + + describe("POST /storage/kv/namespaces/:namespaceId/bulk/get", () => { + test("returns multiple key-value pairs", async () => { + const kv = await mf.getKVNamespace("TEST_KV"); + await kv.put("bulk-key-1", "value-1"); + await kv.put("bulk-key-2", "value-2"); + await kv.put("bulk-key-3", "value-3"); + + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/bulk/get`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + keys: ["bulk-key-1", "bulk-key-2", "bulk-key-3"], + }), + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + success: true, + result: { + values: { + "bulk-key-1": "value-1", + "bulk-key-2": "value-2", + "bulk-key-3": "value-3", + }, + }, + }); + }); + + test("omits non-existent keys from result", async () => { + const kv = await mf.getKVNamespace("TEST_KV"); + await kv.put("existing-key", "existing-value"); + + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/bulk/get`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + keys: ["existing-key", "non-existent-key"], + }), + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + success: true, + result: { + values: { + "existing-key": "existing-value", + }, + }, + }); + }); + + test("returns empty values object for all non-existent keys", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/test-kv-id/bulk/get`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + keys: ["does-not-exist-1", "does-not-exist-2"], + }), + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + success: true, + result: { + values: {}, + }, + }); + }); + + test("returns 404 for non-existent namespace", async () => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces/NON_EXISTENT/bulk/get`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keys: ["key1"] }), + } + ); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + success: false, + errors: [expect.objectContaining({ message: "Namespace not found" })], + }); + }); + }); +}); diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index 1ab84bb2d622..38df55cb4dd6 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -7,7 +7,7 @@ import { generateContainerBuildId, resolveDockerHost, } from "@cloudflare/containers-shared"; -import { getLocalExplorerFromEnv } from "@cloudflare/workers-utils"; +import { getLocalExplorerEnabledFromEnv } from "@cloudflare/workers-utils"; import { getDefaultDevRegistryPath, kUnsafeEphemeralUniqueKey, @@ -431,7 +431,7 @@ export async function getDevMiniflareOptions( inputInspectorPort === false ? undefined : inputInspectorPort, unsafeDevRegistryPath: getDefaultDevRegistryPath(), unsafeTriggerHandlers: true, - unsafeLocalExplorer: getLocalExplorerFromEnv(), + unsafeLocalExplorer: getLocalExplorerEnabledFromEnv(), handleStructuredLogs: getStructuredLogsLogger(logger), defaultPersistRoot: getPersistenceRoot( resolvedViteConfig.root, @@ -623,7 +623,7 @@ export async function getPreviewMiniflareOptions( inputInspectorPort === false ? undefined : inputInspectorPort, unsafeDevRegistryPath: getDefaultDevRegistryPath(), unsafeTriggerHandlers: true, - unsafeLocalExplorer: getLocalExplorerFromEnv(), + unsafeLocalExplorer: getLocalExplorerEnabledFromEnv(), handleStructuredLogs: getStructuredLogsLogger(logger), defaultPersistRoot: getPersistenceRoot( resolvedViteConfig.root, diff --git a/packages/workers-utils/src/environment-variables/misc-variables.ts b/packages/workers-utils/src/environment-variables/misc-variables.ts index fc730b330c0e..69f55beaa022 100644 --- a/packages/workers-utils/src/environment-variables/misc-variables.ts +++ b/packages/workers-utils/src/environment-variables/misc-variables.ts @@ -341,7 +341,8 @@ export const getOpenNextDeployFromEnv = getEnvironmentVariableFactory({ * `X_LOCAL_EXPLORER` enables the local explorer UI at /cdn-cgi/explorer. * This is an experimental feature flag. Defaults to false when not set. */ -export const getLocalExplorerFromEnv = getBooleanEnvironmentVariableFactory({ - variableName: "X_LOCAL_EXPLORER", - defaultValue: false, -}); +export const getLocalExplorerEnabledFromEnv = + getBooleanEnvironmentVariableFactory({ + variableName: "X_LOCAL_EXPLORER", + defaultValue: false, + }); diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 837fe33ea948..441a951ac662 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -2,7 +2,7 @@ import assert from "node:assert"; import { randomUUID } from "node:crypto"; import path from "node:path"; import { getDevContainerImageName } from "@cloudflare/containers-shared"; -import { getLocalExplorerFromEnv } from "@cloudflare/workers-utils"; +import { getLocalExplorerEnabledFromEnv } from "@cloudflare/workers-utils"; import { Log, LogLevel } from "miniflare"; import { ModuleTypeToRuleType } from "../../deployment-bundle/module-collection"; import { withSourceURLs } from "../../deployment-bundle/source-url"; @@ -865,7 +865,7 @@ export async function buildMiniflareOptions( unsafeHandleDevRegistryUpdate: onDevRegistryUpdate, unsafeProxySharedSecret: proxyToUserWorkerAuthenticationSecret, unsafeTriggerHandlers: true, - unsafeLocalExplorer: getLocalExplorerFromEnv(), + unsafeLocalExplorer: getLocalExplorerEnabledFromEnv(), // The way we run Miniflare instances with wrangler dev is that there are two: // - one holding the proxy worker, // - and one holding the user worker. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e72f2a93216..3ec35ee98679 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2003,6 +2003,9 @@ importers: capnweb: specifier: ^0.1.0 version: 0.1.0 + chanfana: + specifier: ^2.8.3 + version: 2.8.3 chokidar: specifier: ^4.0.1 version: 4.0.1 @@ -2045,6 +2048,9 @@ importers: heap-js: specifier: ^2.5.0 version: 2.5.0 + hono: + specifier: ^4.11.5 + version: 4.11.5 http-cache-semantics: specifier: ^4.1.0 version: 4.1.1 @@ -4235,6 +4241,11 @@ packages: resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} engines: {node: '>=6.0.0'} + '@asteasolutions/zod-to-openapi@7.3.4': + resolution: {integrity: sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA==} + peerDependencies: + zod: ^3.20.2 + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -8781,6 +8792,10 @@ packages: resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chanfana@2.8.3: + resolution: {integrity: sha512-OBefbT8n+CeRYkC6lUoRgySTFbth+3LetxiNrCe0t+WgpQhhoIxPlo+lDYvNrLJgtsQr7XuPazm8yyxR8zwB7g==} + hasBin: true + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -10264,6 +10279,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hono@4.11.5: + resolution: {integrity: sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==} + engines: {node: '>=16.9.0'} + hono@4.7.1: resolution: {integrity: sha512-V3eWoPkBxoNgFCkSc5Y5rpLF6YoQQx1pkYO4qrF6YfOw8RZbujUNlJLZCxh0z9gZct70+je2Ih7Zrdpv21hP9w==} engines: {node: '>=16.9.0'} @@ -11587,6 +11606,9 @@ packages: resolution: {integrity: sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==} engines: {node: '>=14.16'} + openapi3-ts@4.5.0: + resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -14103,6 +14125,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -14181,6 +14207,11 @@ snapshots: '@jridgewell/gen-mapping': 0.1.1 '@jridgewell/trace-mapping': 0.3.31 + '@asteasolutions/zod-to-openapi@7.3.4(zod@3.25.76)': + dependencies: + openapi3-ts: 4.5.0 + zod: 3.25.76 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -18584,7 +18615,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.3(@types/debug@4.1.12)(@types/node@20.19.9)(@vitest/ui@3.2.3)(jiti@2.6.0)(lightningcss@1.30.2)(msw@2.12.0(@types/node@20.19.9)(typescript@5.8.3))(supports-color@9.2.2)(yaml@2.8.1) + vitest: 3.2.3(@types/debug@4.1.7)(@types/node@20.19.9)(@vitest/ui@3.2.3)(jiti@2.6.0)(lightningcss@1.30.2)(msw@2.12.0(@types/node@20.19.9)(typescript@5.8.3))(yaml@2.8.1) '@vitest/utils@2.1.8': dependencies: @@ -19239,6 +19270,14 @@ snapshots: chalk@5.3.0: {} + chanfana@2.8.3: + dependencies: + '@asteasolutions/zod-to-openapi': 7.3.4(zod@3.25.76) + js-yaml: 4.1.0 + openapi3-ts: 4.5.0 + yargs-parser: 22.0.0 + zod: 3.25.76 + chardet@2.1.1: {} check-error@2.1.1: {} @@ -21145,6 +21184,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hono@4.11.5: {} + hono@4.7.1: {} hono@4.7.10: {} @@ -22411,6 +22452,10 @@ snapshots: is-inside-container: 1.0.0 is-wsl: 2.2.0 + openapi3-ts@4.5.0: + dependencies: + yaml: 2.8.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -25462,6 +25507,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@17.7.2: dependencies: cliui: 8.0.1