Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/scope-do-workflow-explorer-storage-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": patch
---

Scope Durable Object and Workflow local explorer peers by storageScope

Restricts Durable Object and Workflow peer discovery and owner resolution to Miniflare peers sharing the same storageScope when Shared Storage is enabled. This ensures consistency with KV, D1, and R2 local explorer behaviors and prevents cross-project access to local development state across instances with different persistence roots.
13 changes: 8 additions & 5 deletions packages/miniflare/src/workers/local-explorer/resources/do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ async function findDONamespaceOwner(
c: AppContext,
namespaceId: string
): Promise<string | null> {
const peerUrls = await getPeerUrlsIfAggregating(c);
const peerUrls = await getPeerUrlsIfAggregating(c, {
sharedStorageOnly: true,
});
if (peerUrls.length === 0) {
return null;
}
Expand Down Expand Up @@ -118,13 +120,14 @@ async function findDONamespaceOwner(
*/
export async function listDONamespaces(c: AppContext) {
const localNamespaces = getLocalDONamespaces(c.env);
// note that we don't have duplication issues here like
// we do for listD1Namespaces etc. because DOs are tied
// to scripts and external DOs have already been filtered out
const allNamespaces = await aggregateListResults(
c,
localNamespaces,
"/workers/durable_objects/namespaces"
"/workers/durable_objects/namespaces",
{
getKey: (namespace) => namespace.id,
sharedStorageOnly: true,
}
);

return c.json({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,9 @@ async function findWorkflowOwner(
return cached.url;
}

const peerUrls = await getPeerUrlsIfAggregating(c);
const peerUrls = await getPeerUrlsIfAggregating(c, {
sharedStorageOnly: true,
});
if (peerUrls.length === 0) {
return null;
}
Expand Down Expand Up @@ -247,7 +249,8 @@ export async function listWorkflows(c: AppContext): Promise<Response> {
const aggregatedWorkflows = await aggregateListResults(
c,
localWorkflows,
"/workflows"
"/workflows",
{ getKey: (wf) => wf.name, sharedStorageOnly: true }
);

// Deduplicate by name — first occurrence wins (local takes priority)
Expand Down
232 changes: 217 additions & 15 deletions packages/miniflare/test/plugins/local-explorer/aggregation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,14 @@ describe("Cross-process aggregation", () => {
name: "worker-a",
compatibilityDate: "2025-01-01",
manifest: singleModuleManifest(`
import { WorkflowEntrypoint } from "cloudflare:workers";
export class MyDO {
constructor(state) { this.state = state; }
async fetch() { return new Response("DO A"); }
}
export class WorkflowA extends WorkflowEntrypoint {
async run() { return "Workflow A"; }
}
export default { fetch() { return new Response("Worker A"); } }
`),
env: {
Expand All @@ -70,6 +74,12 @@ describe("Cross-process aggregation", () => {
exportName: "MyDO",
},
BUCKET_A: { type: "r2", name: "bucket-a" },
WF_A: {
type: "workflow",
name: "workflow-a",
worker: "worker-a",
exportName: "WorkflowA",
},
},
exports: {
MyDO: { type: "durable-object", storage: "legacy-kv" },
Expand All @@ -91,10 +101,14 @@ describe("Cross-process aggregation", () => {
name: "worker-b",
compatibilityDate: "2025-01-01",
manifest: singleModuleManifest(`
import { WorkflowEntrypoint } from "cloudflare:workers";
export class OtherDO {
constructor(state) { this.state = state; }
async fetch() { return new Response("DO B"); }
}
export class WorkflowB extends WorkflowEntrypoint {
async run() { return "Workflow B"; }
}
export default { fetch() { return new Response("Worker B"); } }
`),
env: {
Expand All @@ -106,6 +120,12 @@ describe("Cross-process aggregation", () => {
exportName: "OtherDO",
},
BUCKET_B: { type: "r2", name: "bucket-b" },
WF_B: {
type: "workflow",
name: "workflow-b",
worker: "worker-b",
exportName: "WorkflowB",
},
},
exports: {
OtherDO: { type: "durable-object", storage: "legacy-kv" },
Expand Down Expand Up @@ -325,7 +345,9 @@ describe("Cross-process aggregation", () => {
});

describe("DO namespace aggregation", () => {
test("lists DO namespaces from both instances", async ({ expect }) => {
test("only lists local DO namespaces without shared storage", async ({
expect,
}) => {
const response = await instanceA.dispatchFetch(
`${BASE_URL}/workers/durable_objects/namespaces`
);
Expand All @@ -340,20 +362,30 @@ describe("Cross-process aggregation", () => {
"id": "worker-a-MyDO",
"name": "worker-a_MyDO",
},
{
"id": "worker-b-OtherDO",
"name": "worker-b_OtherDO",
},
]
`);
expect(normalized.result_info).toMatchInlineSnapshot(`
{
"count": 2,
"count": 1,
}
`);
});
});

describe("workflow aggregation", () => {
test("only lists local workflows without shared storage", async ({
expect,
}) => {
const response = await instanceA.dispatchFetch(`${BASE_URL}/workflows`);
const data = (await response.json()) as {
result?: Array<{ name: string }>;
result_info?: { count?: number };
};
expect(data.result).toMatchObject([{ name: "workflow-a" }]);
expect(data.result_info?.count).toBe(1);
});
});

describe("r2 bucket aggregation", () => {
test("only lists local R2 buckets without shared storage", async ({
expect,
Expand Down Expand Up @@ -416,11 +448,33 @@ describe("Multi-worker peer deduplication", () => {
type: "worker",
name: "worker-a",
compatibilityDate: "2025-01-01",
manifest: singleModuleManifest(
`export default { fetch() { return new Response("Worker A"); } }`
),
manifest: singleModuleManifest(`
import { WorkflowEntrypoint } from "cloudflare:workers";
export class MyDO {
constructor(state) { this.state = state; }
async fetch() { return new Response("DO A"); }
}
export class MyWorkflowA extends WorkflowEntrypoint {
async run() { return "Workflow A"; }
}
export default { fetch() { return new Response("Worker A"); } }
`),
env: {
KV_A: { type: "kv", id: "kv-a" },
DO_A: {
type: "durable-object",
worker: "worker-a",
exportName: "MyDO",
},
WF_A: {
type: "workflow",
name: "workflow-a",
worker: "worker-a",
exportName: "MyWorkflowA",
},
},
exports: {
MyDO: { type: "durable-object", storage: "legacy-kv" },
},
},
},
Expand All @@ -444,11 +498,33 @@ describe("Multi-worker peer deduplication", () => {
type: "worker",
name: "worker-b1",
compatibilityDate: "2025-01-01",
manifest: singleModuleManifest(
`export default { fetch() { return new Response("Worker B1"); } }`
),
manifest: singleModuleManifest(`
import { WorkflowEntrypoint } from "cloudflare:workers";
export class OtherDO {
constructor(state) { this.state = state; }
async fetch() { return new Response("DO B1"); }
}
export class MyWorkflowB extends WorkflowEntrypoint {
async run() { return "Workflow B"; }
}
export default { fetch() { return new Response("Worker B1"); } }
`),
env: {
KV_B1: { type: "kv", id: "kv-b1" },
DO_B: {
type: "durable-object",
worker: "worker-b1",
exportName: "OtherDO",
},
WF_B: {
type: "workflow",
name: "workflow-b",
worker: "worker-b1",
exportName: "MyWorkflowB",
},
},
exports: {
OtherDO: { type: "durable-object", storage: "legacy-kv" },
},
},
},
Expand All @@ -466,6 +542,23 @@ describe("Multi-worker peer deduplication", () => {
},
},
},
{
config: {
type: "worker",
name: "worker-shared",
compatibilityDate: "2025-01-01",
manifest: singleModuleManifest(`
export class SharedDO {
constructor(state) { this.state = state; }
async fetch() { return new Response("Shared DO"); }
}
export default { fetch() { return new Response("Shared Worker"); } }
`),
exports: {
SharedDO: { type: "durable-object", storage: "legacy-kv" },
},
},
},
],
});
await instanceB.ready;
Expand All @@ -491,6 +584,29 @@ describe("Multi-worker peer deduplication", () => {
),
env: {
KV_B1: { type: "kv", id: "kv-b1" },
WF_B: {
type: "workflow",
name: "workflow-b",
worker: "worker-b1",
exportName: "MyWorkflowB",
},
},
},
},
{
config: {
type: "worker",
name: "worker-shared",
compatibilityDate: "2025-01-01",
manifest: singleModuleManifest(`
export class SharedDO {
constructor(state) { this.state = state; }
async fetch() { return new Response("Shared DO"); }
}
export default { fetch() { return new Response("Shared Worker"); } }
`),
exports: {
SharedDO: { type: "durable-object", storage: "legacy-kv" },
},
},
},
Expand All @@ -512,11 +628,33 @@ describe("Multi-worker peer deduplication", () => {
type: "worker",
name: "worker-c",
compatibilityDate: "2025-01-01",
manifest: singleModuleManifest(
`export default { fetch() { return new Response("Worker C"); } }`
),
manifest: singleModuleManifest(`
import { WorkflowEntrypoint } from "cloudflare:workers";
export class ScopedDO {
constructor(state) { this.state = state; }
async fetch() { return new Response("DO C"); }
}
export class MyWorkflowC extends WorkflowEntrypoint {
async run() { return "Workflow C"; }
}
export default { fetch() { return new Response("Worker C"); } }
`),
env: {
KV_C: { type: "kv", id: "kv-c" },
DO_C: {
type: "durable-object",
worker: "worker-c",
exportName: "ScopedDO",
},
WF_C: {
type: "workflow",
name: "workflow-c",
worker: "worker-c",
exportName: "MyWorkflowC",
},
},
exports: {
ScopedDO: { type: "durable-object", storage: "legacy-kv" },
},
},
},
Expand Down Expand Up @@ -576,6 +714,70 @@ describe("Multi-worker peer deduplication", () => {
}
`);
});

test("only lists DO namespaces from peers in the same shared-storage scope", async ({
expect,
}) => {
const response = await instanceA.dispatchFetch(
`${BASE_URL}/workers/durable_objects/namespaces`
);
const data = (await response.json()) as ListResponse;
expect(normalizeListResponse(data)).toMatchObject({
result: [
{ id: "worker-a-MyDO", name: "worker-a_MyDO" },
{ id: "worker-b1-OtherDO", name: "worker-b1_OtherDO" },
{ id: "worker-shared-SharedDO", name: "worker-shared_SharedDO" },
],
result_info: { count: 3 },
});
});

test("only lists workflows from peers in the same shared-storage scope", async ({
expect,
}) => {
const response = await instanceA.dispatchFetch(`${BASE_URL}/workflows`);
const data = (await response.json()) as {
result?: Array<{ name: string }>;
result_info?: { count?: number };
};
expect(data.result).toMatchObject([
{ name: "workflow-a" },
{ name: "workflow-b" },
]);
expect(data.result_info?.count).toBe(2);
});

test("resolves DO namespace owner only within the same shared-storage scope", async ({
expect,
}) => {
const responseSameScope = await instanceA.dispatchFetch(
`${BASE_URL}/workers/durable_objects/namespaces/worker-b1-OtherDO/objects`
);
expect(responseSameScope.status).toBe(200);
await responseSameScope.text();

const responseDiffScope = await instanceA.dispatchFetch(
`${BASE_URL}/workers/durable_objects/namespaces/worker-c-ScopedDO/objects`
);
expect(responseDiffScope.status).toBe(404);
await responseDiffScope.text();
});

test("resolves workflow owner only within the same shared-storage scope", async ({
expect,
}) => {
const responseSameScope = await instanceA.dispatchFetch(
`${BASE_URL}/workflows/workflow-b`
);
expect(responseSameScope.status).toBe(200);
await responseSameScope.text();

const responseDiffScope = await instanceA.dispatchFetch(
`${BASE_URL}/workflows/workflow-c`
);
expect(responseDiffScope.status).toBe(404);
await responseDiffScope.text();
});
});

describe("Same ID across multiple instances with different persistence directories", () => {
Expand Down
Loading