Skip to content
Merged
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/fix-d1-json-null-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Fix `wrangler d1 execute --json` returning `"null"` (string) instead of `null` (JSON null) for SQL NULL values

When using `wrangler d1 execute --json` with local execution, SQL NULL values were incorrectly serialized as the string `"null"` instead of JSON `null`. This produced invalid JSON output that violated RFC 4627. The fix removes the explicit null-to-string conversion so NULL values are preserved as proper JSON null in the output.
7 changes: 7 additions & 0 deletions .changeset/local-explorer-cors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": patch
---

local explorer: validate origin and host headers

The local explorer is a WIP experimental feature.
8 changes: 8 additions & 0 deletions .changeset/tidy-hairs-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@cloudflare/local-explorer-ui": minor
"miniflare": minor
---

Add cross-process support to the local explorer

When running multiple miniflare processes, the local explorer will now be able to view and edit resources that are bound to workers in other miniflare instances.
1 change: 1 addition & 0 deletions fixtures/worker-with-resources/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"cf-typegen": "wrangler types --no-include-runtime",
"deploy": "wrangler deploy",
"start": "X_LOCAL_EXPLORER=true wrangler dev",
"start:worker-b": "X_LOCAL_EXPLORER=true wrangler dev -c worker-b/wrangler.jsonc",
"test:ci": "vitest run",
"test:watch": "vitest"
},
Expand Down
3 changes: 0 additions & 3 deletions fixtures/worker-with-resources/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,6 @@ describe("local explorer", () => {
],
result_info: {
count: 2,
page: 1,
per_page: 20,
total_count: 2,
},
success: true,
});
Expand Down
45 changes: 45 additions & 0 deletions fixtures/worker-with-resources/worker-b/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { DurableObject } from "cloudflare:workers";

export default {
async fetch(request, env) {
const url = new URL(request.url);
switch (url.pathname) {
case "/kv/seed": {
await env.KV_B.put("worker-b-key-1", "value from worker B");
await env.KV_B.put("worker-b-key-2", "another value from worker B");
return new Response("Seeded Worker B KV");
}
case "/d1/seed": {
await env.DB_B.exec(`
DROP TABLE IF EXISTS products;
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL);
INSERT INTO products (id, name, price) VALUES
(1, 'Widget', 9.99),
(2, 'Gadget', 19.99),
(3, 'Gizmo', 29.99);
`);
return new Response("Seeded Worker B D1");
}
case "/do/seed": {
const doId = env.DO_B.idFromName("worker-b-do");
const stub = env.DO_B.get(doId);
return stub.fetch(request);
}
}
return new Response("Hello from Worker B!");
},
} satisfies ExportedHandler<Env>;

export class WorkerBDurableObject extends DurableObject<Env> {
async fetch(_request: Request): Promise<Response> {
this.ctx.storage.sql.exec(`
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (id INTEGER PRIMARY KEY, product_id INTEGER, quantity INTEGER);
INSERT INTO orders (id, product_id, quantity) VALUES
(1, 1, 5),
(2, 2, 3),
(3, 3, 10);
`);
return new Response("Seeded Worker B Durable Object");
}
}
7 changes: 7 additions & 0 deletions fixtures/worker-with-resources/worker-b/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types"]
},
"include": ["./**/*.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Generated by Wrangler by running `wrangler types --no-include-runtime`

interface Env {
KV_B: KVNamespace;
DB_B: D1Database;
DO_B: DurableObjectNamespace<import("./index").WorkerBDurableObject>;
}
32 changes: 32 additions & 0 deletions fixtures/worker-with-resources/worker-b/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"$schema": "../node_modules/wrangler/config-schema.json",
"name": "worker-b",
"main": "index.ts",
"compatibility_date": "2023-05-04",
"kv_namespaces": [
{
"binding": "KV_B",
"id": "worker-b-kv-id",
},
],
"d1_databases": [
{
"binding": "DB_B",
"database_id": "worker-b-db-id",
},
],
"durable_objects": {
"bindings": [
{
"name": "DO_B",
"class_name": "WorkerBDurableObject",
},
],
},
"migrations": [
{
"new_sqlite_classes": ["WorkerBDurableObject"],
"tag": "v1",
},
],
}
2 changes: 1 addition & 1 deletion packages/local-explorer-ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export function Sidebar({
/>

<SidebarItemGroup
emptyLabel="No namespaces"
emptyLabel="No SQLite namespaces"
error={doError}
icon={CubeIcon}
items={doNamespaces.map((ns) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/local-explorer-ui/src/drivers/d1.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cloudflareD1RawDatabaseQuery } from "../api";
import { d1RawDatabaseQuery } from "../api";
import { transformStudioArrayBasedResult } from "../utils/studio";
import { StudioSQLiteDriver } from "./sqlite";
import type { D1RawResultResponse } from "../api";
Expand Down Expand Up @@ -52,7 +52,7 @@ export class LocalD1Connection implements IStudioConnection {
s.trim().replace(/;+$/, "")
);

const response = await cloudflareD1RawDatabaseQuery({
const response = await d1RawDatabaseQuery({
body: {
sql: trimmedStatements.join(";"),
},
Expand Down
4 changes: 2 additions & 2 deletions packages/local-explorer-ui/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
useRouterState,
} from "@tanstack/react-router";
import {
cloudflareD1ListDatabases,
d1ListDatabases,
durableObjectsNamespaceListNamespaces,
workersKvNamespaceListNamespaces,
} from "../api";
Expand All @@ -20,7 +20,7 @@ export const Route = createRootRoute({
loader: async () => {
const [kvResponse, d1Response, doResponse] = await Promise.allSettled([
workersKvNamespaceListNamespaces(),
cloudflareD1ListDatabases(),
d1ListDatabases(),
durableObjectsNamespaceListNamespaces(),
]);

Expand Down
153 changes: 146 additions & 7 deletions packages/miniflare/scripts/filter-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,19 @@ export interface RequestBodyIgnore {
properties: string[];
}

export interface ResponsePropertyIgnore {
path: string;
method: string;
/** Dot-notation path to the property to remove, e.g. "result_info.total_count" */
propertyPath: string;
}

export interface IgnoresConfig {
parameters?: ParameterIgnore[];
requestBodyProperties?: RequestBodyIgnore[];
schemaProperties?: Record<string, string[]>;
/** Properties to remove from inline response schemas */
responseProperties?: ResponsePropertyIgnore[];
}
interface OpenAPIOperation {
parameters?: Array<{ name: string; [key: string]: unknown }>;
Expand Down Expand Up @@ -168,6 +177,9 @@ function filterOpenAPISpec(
// Apply request body ignores (if any)
applyRequestBodyIgnores(operation, originalPath, method, ignores);

// Apply response property ignores (if any)
applyResponsePropertyIgnores(operation, originalPath, method, ignores);

// Remove security from operation since we implement that differently locally
delete operation.security;

Expand Down Expand Up @@ -422,15 +434,38 @@ function removeSchemaProperties(
schema: OpenAPISchema,
propsToRemove: string[]
): void {
if (!schema.properties) {
return;
// Handle direct properties
if (schema.properties) {
const props = schema.properties;
for (const prop of propsToRemove) {
delete props[prop];
}
if (schema.required) {
schema.required = schema.required.filter(
(r) => !propsToRemove.includes(r)
);
}
}
const props = schema.properties;
for (const prop of propsToRemove) {
delete props[prop];

// Handle allOf - recurse into each sub-schema
if (Array.isArray(schema.allOf)) {
for (const subSchema of schema.allOf) {
removeSchemaProperties(subSchema as OpenAPISchema, propsToRemove);
}
}

// Handle oneOf
if (Array.isArray(schema.oneOf)) {
for (const subSchema of schema.oneOf) {
removeSchemaProperties(subSchema as OpenAPISchema, propsToRemove);
}
}
if (schema.required) {
schema.required = schema.required.filter((r) => !propsToRemove.includes(r));

// Handle anyOf
if (Array.isArray(schema.anyOf)) {
for (const subSchema of schema.anyOf) {
removeSchemaProperties(subSchema as OpenAPISchema, propsToRemove);
}
}
}

Expand Down Expand Up @@ -480,6 +515,110 @@ function applySchemaIgnores(
}
}

/**
* Recursively find and process all inline schemas in response content.
* Applies property removal at specified paths.
*/
function applyResponsePropertyIgnores(
operation: OpenAPIOperation,
path: string,
method: string,
ignores: IgnoresConfig
): void {
if (!ignores.responseProperties) {
return;
}

const pathIgnores = ignores.responseProperties.filter(
(p) => p.path === path && p.method === method
);

if (pathIgnores.length === 0) {
return;
}

const responses = operation.responses as
| Record<string, { content?: Record<string, { schema?: unknown }> }>
| undefined;
if (!responses) {
return;
}

for (const response of Object.values(responses)) {
if (!response.content) {
continue;
}
for (const mediaType of Object.values(response.content)) {
if (!mediaType.schema) {
continue;
}
// Walk through the schema and apply ignores
for (const ignore of pathIgnores) {
removePropertyFromSchema(mediaType.schema, ignore.propertyPath);
}
}
}
}

/**
* Remove a property from an OpenAPI schema at a dot-notation path.
* Handles allOf/oneOf/anyOf and nested properties.
* E.g., "result_info.total_count" removes total_count from result_info's properties.
*/
function removePropertyFromSchema(schema: unknown, propertyPath: string): void {
if (!schema || typeof schema !== "object") {
return;
}

const schemaObj = schema as Record<string, unknown>;

// Handle allOf - search in each sub-schema
if (Array.isArray(schemaObj.allOf)) {
for (const subSchema of schemaObj.allOf) {
removePropertyFromSchema(subSchema, propertyPath);
}
}

// Handle oneOf
if (Array.isArray(schemaObj.oneOf)) {
for (const subSchema of schemaObj.oneOf) {
removePropertyFromSchema(subSchema, propertyPath);
}
}

// Handle anyOf
if (Array.isArray(schemaObj.anyOf)) {
for (const subSchema of schemaObj.anyOf) {
removePropertyFromSchema(subSchema, propertyPath);
}
}

// Handle direct properties
if (schemaObj.properties && typeof schemaObj.properties === "object") {
const props = schemaObj.properties as Record<string, unknown>;
const parts = propertyPath.split(".");

if (parts.length === 1) {
// Simple property removal
delete props[parts[0]];
// Also remove from required array if present
if (Array.isArray(schemaObj.required)) {
schemaObj.required = (schemaObj.required as string[]).filter(
(r) => r !== parts[0]
);
}
} else {
// Nested property removal: e.g., "result_info.total_count"
// Navigate to the parent property's schema and remove from its properties
const [parentProp, ...rest] = parts;
const parentSchema = props[parentProp] as Record<string, unknown>;
if (parentSchema) {
removePropertyFromSchema(parentSchema, rest.join("."));
}
}
}
}

function removeAccountPathParam(path: string): string {
return path.replace("/accounts/{account_id}", "");
}
Loading
Loading