Skip to content

Commit 43c462a

Browse files
authored
[explorer] add DO api (part 1) (#12546)
1 parent 65cd337 commit 43c462a

File tree

17 files changed

+1605
-88
lines changed

17 files changed

+1605
-88
lines changed

.changeset/plain-trains-marry.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"miniflare": minor
3+
---
4+
5+
Local explorer: add endpoints to list DO namespaces and objects
6+
7+
This is part of an experimental, WIP feature.

packages/miniflare/scripts/openapi-filter-config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ const config = {
3333
path: "/accounts/{account_id}/d1/database/{database_id}/raw",
3434
methods: ["post"],
3535
},
36+
37+
// Durable Objects endpoints
38+
{
39+
path: "/accounts/{account_id}/workers/durable_objects/namespaces",
40+
methods: ["get"],
41+
},
42+
{
43+
path: "/accounts/{account_id}/workers/durable_objects/namespaces/{id}/objects",
44+
methods: ["get"],
45+
},
3646
],
3747

3848
// Ignored features (not implemented in local explorer)

packages/miniflare/src/index.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
DurableObjectClassNames,
4545
getDirectSocketName,
4646
getGlobalServices,
47+
getPersistPath,
4748
HELLO_WORLD_PLUGIN_NAME,
4849
HOST_CAPNP_CONNECT,
4950
KV_PLUGIN_NAME,
@@ -102,6 +103,7 @@ import {
102103
} from "./runtime";
103104
import {
104105
_isCyclic,
106+
isFileNotFoundError,
105107
Log,
106108
MiniflareCoreError,
107109
NoOpLog,
@@ -1160,6 +1162,54 @@ export class Miniflare {
11601162
}
11611163
}
11621164

1165+
/**
1166+
* Gets DO object IDs by checking filenames in the DO persistence directory.
1167+
*
1168+
* @param url in format: /core/do-storage/<namespaceId>
1169+
* @returns [{ name: string, type: "file" | "directory" }, ...]
1170+
*/
1171+
async #handleLoopbackDOStorageRequest(url: URL): Promise<Response> {
1172+
// Extract namespace ID from path (e.g., "/core/do-storage/worker-TestDO" -> "worker-TestDO")
1173+
const namespaceId = decodeURIComponent(
1174+
url.pathname.slice("/core/do-storage/".length)
1175+
);
1176+
assert(namespaceId, "Namespace ID is required");
1177+
1178+
const doSharedOpts = this.#sharedOpts.do;
1179+
const coreSharedOpts = this.#sharedOpts.core;
1180+
const doPersistPath = getPersistPath(
1181+
DURABLE_OBJECTS_PLUGIN_NAME,
1182+
this.#tmpPath,
1183+
coreSharedOpts.defaultPersistRoot,
1184+
doSharedOpts.durableObjectsPersist
1185+
);
1186+
1187+
const namespacePath = path.join(doPersistPath, namespaceId);
1188+
1189+
// Prevent escaping directory through dodgy namespaceId that encodes (`../` etc.)
1190+
// by ensuring the resolved path is still within the persistence directory
1191+
if (!namespacePath.startsWith(path.resolve(doPersistPath) + path.sep)) {
1192+
return new Response("Invalid namespace ID", { status: 400 });
1193+
}
1194+
1195+
try {
1196+
const dirEntries = await fs.promises.readdir(namespacePath, {
1197+
withFileTypes: true,
1198+
});
1199+
return Response.json(
1200+
dirEntries.map((entry) => ({
1201+
name: entry.name,
1202+
type: entry.isDirectory() ? "directory" : "file",
1203+
}))
1204+
);
1205+
} catch (e) {
1206+
if (isFileNotFoundError(e)) {
1207+
return new Response("Not Found", { status: 404 });
1208+
}
1209+
throw e;
1210+
}
1211+
}
1212+
11631213
get #workerSrcOpts(): NameSourceOptions[] {
11641214
return this.#workerOpts.map<NameSourceOptions>(({ core }) => core);
11651215
}
@@ -1286,6 +1336,8 @@ export class Miniflare {
12861336
);
12871337
await writeFile(filePath, await request.text());
12881338
response = new Response(filePath, { status: 200 });
1339+
} else if (url.pathname.startsWith("/core/do-storage/")) {
1340+
response = await this.#handleLoopbackDOStorageRequest(url);
12891341
}
12901342
} catch (e: any) {
12911343
this.#log.error(e);
@@ -1913,6 +1965,7 @@ export class Miniflare {
19131965
loopbackPort,
19141966
log: this.#log,
19151967
proxyBindings,
1968+
durableObjectClassNames,
19161969
});
19171970
for (const service of globalServices) {
19181971
// Global services should all have unique names
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import assert from "node:assert";
2+
import SCRIPT_LOCAL_EXPLORER from "worker:local-explorer/explorer";
3+
import { Service, Worker_Binding } from "../../runtime";
4+
import { OUTBOUND_DO_PROXY_SERVICE_NAME } from "../../shared/external-service";
5+
import { CoreBindings } from "../../workers";
6+
import {
7+
DurableObjectClassNames,
8+
WORKER_BINDING_SERVICE_LOOPBACK,
9+
} from "../shared";
10+
import {
11+
getUserServiceName,
12+
LOCAL_EXPLORER_DISK,
13+
SERVICE_LOCAL_EXPLORER,
14+
} from "./constants";
15+
import type { BindingIdMap } from "./types";
16+
17+
export interface ExplorerServicesOptions {
18+
localExplorerUiPath: string;
19+
proxyBindings: Worker_Binding[];
20+
bindingIdMap: BindingIdMap;
21+
hasDurableObjects: boolean;
22+
}
23+
24+
/**
25+
* Creates the services needed for the local explorer feature.
26+
*/
27+
export function getExplorerServices(
28+
options: ExplorerServicesOptions
29+
): Service[] {
30+
const {
31+
localExplorerUiPath,
32+
proxyBindings,
33+
bindingIdMap,
34+
hasDurableObjects,
35+
} = options;
36+
37+
const explorerBindings: Worker_Binding[] = [
38+
// Gives explorer access to all user resource bindings
39+
...proxyBindings,
40+
{
41+
name: CoreBindings.JSON_LOCAL_EXPLORER_BINDING_MAP,
42+
json: JSON.stringify(bindingIdMap),
43+
},
44+
{
45+
name: CoreBindings.EXPLORER_DISK,
46+
service: { name: LOCAL_EXPLORER_DISK },
47+
},
48+
];
49+
50+
if (hasDurableObjects) {
51+
// Add loopback service binding if DOs are configured
52+
// The explorer worker uses this to call the /core/do-storage endpoint
53+
// which reads the filesystem using Node.js (bypassing workerd disk service issues on Windows)
54+
explorerBindings.push(WORKER_BINDING_SERVICE_LOOPBACK);
55+
56+
// Add Durable Object namespace bindings for the explorer
57+
// Yes we are binding to 'unbound' DOs, but that has no effect
58+
// on the user's access via ctx.exports
59+
for (const durableObject of Object.values(bindingIdMap.do)) {
60+
explorerBindings.push({
61+
name: durableObject.binding,
62+
durableObjectNamespace: {
63+
className: durableObject.className,
64+
serviceName: getUserServiceName(durableObject.scriptName),
65+
},
66+
});
67+
}
68+
}
69+
70+
return [
71+
// Disk service for serving explorer UI assets
72+
{
73+
name: LOCAL_EXPLORER_DISK,
74+
disk: { path: localExplorerUiPath, writable: false },
75+
},
76+
{
77+
name: SERVICE_LOCAL_EXPLORER,
78+
worker: {
79+
compatibilityDate: "2026-01-01",
80+
compatibilityFlags: ["nodejs_compat"],
81+
modules: [
82+
{
83+
name: "explorer.worker.js",
84+
esModule: SCRIPT_LOCAL_EXPLORER(),
85+
},
86+
],
87+
bindings: explorerBindings,
88+
},
89+
},
90+
];
91+
}
92+
93+
/**
94+
* Build binding ID map from proxyBindings and durableObjectClassNames
95+
* Maps resource IDs to binding information for the local explorer
96+
*/
97+
export function constructExplorerBindingMap(
98+
proxyBindings: Worker_Binding[],
99+
durableObjectClassNames: DurableObjectClassNames
100+
): BindingIdMap {
101+
const IDToBindingName: BindingIdMap = {
102+
d1: {},
103+
kv: {},
104+
do: {},
105+
};
106+
107+
for (const binding of proxyBindings) {
108+
// D1 bindings: name = "MINIFLARE_PROXY:d1:worker-*:BINDING", wrapped.innerBindings[0].service.name = "d1:db:ID"
109+
if (
110+
binding.name?.startsWith(
111+
`${CoreBindings.DURABLE_OBJECT_NAMESPACE_PROXY}:d1:`
112+
) &&
113+
"wrapped" in binding
114+
) {
115+
const [innerBinding] = binding.wrapped?.innerBindings ?? [];
116+
assert(innerBinding && "service" in innerBinding);
117+
118+
const databaseId = innerBinding.service?.name?.replace(/^d1:db:/, "");
119+
assert(databaseId);
120+
121+
IDToBindingName.d1[databaseId] = binding.name;
122+
}
123+
124+
// KV bindings: name = "MINIFLARE_PROXY:kv:worker:BINDING", kvNamespace.name = "kv:ns:ID"
125+
if (
126+
binding.name?.startsWith(
127+
`${CoreBindings.DURABLE_OBJECT_NAMESPACE_PROXY}:kv:`
128+
) &&
129+
"kvNamespace" in binding &&
130+
binding.kvNamespace?.name?.startsWith("kv:ns:")
131+
) {
132+
// Extract ID from service name "kv:ns:ID"
133+
const namespaceId = binding.kvNamespace.name.replace(/^kv:ns:/, "");
134+
IDToBindingName.kv[namespaceId] = binding.name;
135+
}
136+
}
137+
138+
// Handle DOs separately, since we need more information than is
139+
// present in proxy bindings.
140+
// durableObjectClassNames includes internal and unbound DOs,
141+
// but not external ones, which we don't want anyway.
142+
for (const [serviceName, classMap] of durableObjectClassNames) {
143+
// Skip the outbound DO proxy service (used for external DOs)
144+
if (serviceName === getUserServiceName(OUTBOUND_DO_PROXY_SERVICE_NAME)) {
145+
continue;
146+
}
147+
const scriptName = serviceName.replace(/^core:user:/, "");
148+
for (const [className, classInfo] of classMap) {
149+
const uniqueKey = `${scriptName}-${className}`;
150+
IDToBindingName.do[uniqueKey] = {
151+
className,
152+
scriptName,
153+
useSQLite: classInfo.enableSql ?? false,
154+
binding: `EXPLORER_DO_${uniqueKey}`,
155+
};
156+
}
157+
}
158+
159+
return IDToBindingName;
160+
}

0 commit comments

Comments
 (0)