Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/all-streets-crash.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions fixtures/worker-with-resources/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be JSON (and should we check the content type ?)

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 () => {
Expand Down
4 changes: 4 additions & 0 deletions fixtures/worker-with-resources/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
{
"binding": "KV",
},
{
"binding": "KV_WITH_ID",
"id": "some-kv-id",
},
],
"d1_databases": [
{
Expand Down
2 changes: 2 additions & 0 deletions packages/miniflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -87,6 +88,7 @@
"get-port": "^7.1.0",
"glob-to-regexp": "0.4.1",
"heap-js": "^2.5.0",
"hono": "^4.11.5",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious about why we use hono for vs its size?
Looks like the route matching we do is pretty basic and would not really require an external lib or maybe a tiny one?

"http-cache-semantics": "^4.1.0",
"kleur": "^4.1.5",
"mime": "^3.0.0",
Expand Down
27 changes: 26 additions & 1 deletion packages/miniflare/src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> } = { 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: {
Expand All @@ -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),
},
],
},
});
}
Expand Down
1 change: 1 addition & 0 deletions packages/miniflare/src/workers/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
57 changes: 51 additions & 6 deletions packages/miniflare/src/workers/local-explorer/api.worker.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
return new Response("Hello from local explorer API");
}
}
type BindingIdMap = {
kv: Record<string, string>; // 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<AppBindings>().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.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A local subset of Cloudflare's REST API

Have we changed our mind on this ?

},
},
});

// ============================================================================
// 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;
51 changes: 51 additions & 0 deletions packages/miniflare/src/workers/local-explorer/common.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends z.ZodTypeAny>(resultSchema: T) =>
z.object({
success: z.boolean(),
errors: z.array(ResponseInfoSchema),
messages: z.array(ResponseInfoSchema),
result: resultSchema,
});

// ============================================================================
// Response Helpers
// ============================================================================

export function wrapResponse<T>(result: T) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would love to see more JSDoc across this PR.
We don't know what response this takes nor what it returns

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