Skip to content

[Workers for Platforms] Add custom bindings docs for R2 and D1 - #32646

Closed
ivoryibu wants to merge 15 commits into
productionfrom
d1-custom-bindings-docs
Closed

ivoryibu wants to merge 15 commits into
productionfrom
d1-custom-bindings-docs

Conversation

@ivoryibu

@ivoryibu ivoryibu commented Aug 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds a unified custom bindings page under Workers for Platforms that documents how platform operators can place a platform-owned Worker in front of R2 or D1 to enforce policy before operations reach the resource.

  • Moved from the D1-specific page (/d1/platform/custom-bindings/) to Workers for Platforms (/cloudflare-for-platforms/workers-for-platforms/configuration/custom-bindings/) since custom bindings are a platform-level pattern that applies across multiple resources.
  • Added R2 per-bucket storage quota pattern alongside the existing D1 query hook pattern. The page now covers both with a shared "How it works" overview, then resource-specific sections for R2 and D1.
  • Added a CustomBindingsDiagram Astro component (modeled after MeshHostnameRoutingDiagram) showing the User Worker → Platform Worker → Resource flow.
  • Moved the changelog entry from d1/ to workers-for-platforms/ and future-dated it to 2026-10-01.
  • R2 examples use plain JS as provided; D1 examples use TypeScript with the d1_custom_binding compatibility flag.
  • No redirect needed — the old D1 page was never merged to production.

Screenshots (optional)

Documentation checklist

@github-actions

github-actions Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

This pull request requires reviews from CODEOWNERS as it changes files that match the following patterns:

Pattern Owners
*.ts @cloudflare/content-engineering, @kodster28
*.astro @cloudflare/content-engineering, @kodster28
/src/content/docs/cloudflare-for-platforms/workers-for-platforms/ @irvinebroque, @dinasaur404, @cloudflare/deploy-config, @cloudflare/product-owners

@ivoryibu ivoryibu changed the title [D1] Add custom bindings docs [Workers for Platforms] Add custom bindings docs for R2 and D1 Aug 12, 2026
@ivoryibu
ivoryibu force-pushed the d1-custom-bindings-docs branch from afe431c to be33a51 Compare August 20, 2026 17:45
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://d1-custom-bindings-docs.previews.developers.cloudflare.com, https://d1-custom-bindings-docs-cloudflare-docs.cloudflare-docs.workers.dev (commit 74992af)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Failed ❌

View logs ↗
7e4c0aa 2026-10-16T19:31:24.161Z View logs ↗
  • Build: Failed ❌

View logs ↗
43185e8 2026-10-09T17:06:45.986Z View logs ↗
  • Build: Failed ❌

View logs ↗
54c40f0 2026-10-09T16:36:24.310Z View logs ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://2abdbd9f.previews.developers.cloudflare.com, https://2abdbd9f-cloudflare-docs.cloudflare-docs.workers.dev 74992af 2026-08-25T00:49:14.120Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://51e47fea.previews.developers.cloudflare.com, https://51e47fea-cloudflare-docs.cloudflare-docs.workers.dev 205e1cf 2026-08-24T23:19:39.390Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://0f4bf0fb.previews.developers.cloudflare.com 4bcfb22 2026-08-24T18:20:47.058Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://f423f0a3-cloudflare-docs.cloudflare-docs.workers.dev 460d053 2026-08-21T20:50:59.091Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://2c5b9dca-cloudflare-docs.cloudflare-docs.workers.dev 65c31f1 2026-08-21T19:07:03.889Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://27eed99a-cloudflare-docs.cloudflare-docs.workers.dev f38ddf4 2026-08-21T04:19:51.259Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://b23bf03a-cloudflare-docs.cloudflare-docs.workers.dev 2d3c6a1 2026-08-20T23:19:38.255Z Visit the dashboard ↗

import { WorkerEntrypoint } from "cloudflare:workers";

export class D1CustomBinding extends WorkerEntrypoint {
async query(params) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For the TypeScript example this won't compile since it's missing types in the for the function.
I'll provide you with a file having all the types needed, and we can put this file in the documentation for now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

File: binding-types.ts

/**
 * This file contains the interface that a platform worker must implement when intercepting
 * D1 binding requests.
 */

/**
 * Service implemented by the platform worker intercepting D1 binding requests.
 */
export interface D1BindingService {
	/**
	 * Executes one or more SQL queries in a single transaction.
	 */
	query(params: QueryRequest): Promise<QueryResponse>;
}

/**
 * Bookmark identifying a point in the database session timeline, or a reserved
 * initial routing constraint such as `first-primary` or `first-unconstrained`.
 */
export type D1Bookmark = 'first-primary' | 'first-unconstrained' | (string & {});

/**
 * Request sent by the D1 binding to execute a batch of SQL queries.
 */
export type QueryRequest = {
	/**
	 * SQL queries to execute in a single transaction.
	 */
	queries: QuerySql[];
	/**
	 * Bookmark or routing constraint used to select an up-to-date database instance.
	 */
	bookmark?: D1Bookmark;
};

/**
 * RPC response returned by a D1 binding query.
 */
export type QueryResponse = RpcResponse<{
	/**
	 * Result for each SQL query in the request, in request order.
	 */
	queryResults: QuerySqlResult[];
	/**
	 * Session bookmark returned after executing the query batch.
	 */
	bookmark?: D1Bookmark;
}>;

export type QuerySql = {
	/**
	 * SQL statement to execute, with optional placeholders bound from `params`.
	 */
	sql: string;
	/**
	 * Positional parameters bound to placeholders in the SQL query.
	 */
	params?: unknown[];
};

/**
 * Result for one SQL query.
 */
export type QuerySqlResult = {
	/**
	 * Metadata about the query execution.
	 */
	meta: D1Meta;
	data: QuerySqlResultData;
};

/**
 * Discriminated union for query data formats returned by the binding service.
 */
export type QuerySqlResultData = {
	kind: 'raw';
	columns: string[];
	rows: unknown;
};

/**
 * Success or failure envelope used by binding RPC methods.
 */
export type RpcResponse<T = unknown> = { success: true; results: T } | { success: false; error: Error };

/**
 * Metadata returned for a query result.
 */
export type D1Meta = {
	/**
	 * Denotes if the query has been handled by the database primary instance.
	 */
	served_by_primary?: boolean;
	/**
	 * Region location hint of the database instance that handled the query.
	 */
	served_by_region?: string;
	/**
	 * The three-letter airport code of the colo that handled the query.
	 */
	served_by_colo?: string;
	/**
	 * Various durations for the query.
	 */
	timings?: {
		/**
		 * The duration of the SQL query execution inside the database. Does not include any network communication.
		 */
		sql_duration_ms: number;
	};
	/**
	 * The duration of the SQL query execution inside the database. Does not include any network communication.
	 */
	duration: number;
	/**
	 * Number of rows read during the SQL query execution, including indices (not all rows are necessarily returned).
	 */
	rows_read: number;
	/**
	 * Number of rows written during the SQL query execution, including indices.
	 */
	rows_written: number;
	/**
	 * Rough indication of how many rows were modified by the query, as provided by SQLite's `sqlite3_total_changes()`.
	 */
	changes: number;
	/**
	 * The row ID of the last inserted row in a table with an `INTEGER PRIMARY KEY` as provided by SQLite. Tables created with `WITHOUT ROWID` do not populate this.
	 */
	last_row_id: number;
	/**
	 * Denotes if the database has been altered in some way, like deleting rows.
	 */
	changed_db: boolean;
	/**
	 * Size of the database after the query committed, in bytes.
	 */
	size_after: number;
	/**
	 * Number of total attempts to execute the query, due to automatic retries.
	 * Note: All other fields in the response like `timings` only apply to the last attempt.
	 */
	total_attempts?: number;
};

And for the platform worker the minimum required is

interface Props {
	D1: D1Database & D1BindingService;
}

export class D1CustomBinding extends WorkerEntrypoint<Env, Props> implements D1BindingService {
	async query(params: QueryRequest): Promise<QueryResponse> {
        return this.ctx.props.D1.query(params);
    }
}


Only D1 custom bindings are supported today. R2 support is planned, and other `$binding` or `binding_interface` types return a `400`.

## How it works

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I find this section to be difficult to understand when reading it. I believe just showing a simple code example would be easier, but I also know that as a developer I like seeing the code first.


import { TypeScriptExample, WranglerConfig, CustomBindingsDiagram } from "~/components";

Custom bindings are a [Workers for Platforms](/cloudflare-for-platforms/workers-for-platforms/) feature that routes resource ([R2](/r2/) or [D1](/d1/)) operations through a platform-owned Worker before they reach the resource itself. Use this pattern when you deploy [User Workers](/cloudflare-for-platforms/workers-for-platforms/) and need to enforce query policy, log operations, or limit usage per tenant.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The feature is a simple Cloudflare Workers feature. They don't require Workers for Platforms even though I'd expect most users of this feature to be WfP users.

Therefore, I'm not sure if this is the best place to write the documentation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Having read the rest, if you ignore "worker for platforms" and only focus on the worker feature this would remove at least half of the documentation.

Because it's a normal worker feature, they can use it with worker for platforms if they want too.

This is how I would present it:

  1. Small description like you wrote at the top describing the feature (+ optionally a small diagram showing user worker <-> platform worker <-> D1 database)
  2. Showing the simplest version of a user worker doing a D1 request and a platform worker intercepting the D1 request
  3. Show the wrangler.jsonc for both the user worker and the platform worker
  4. Explain how to deploy using only wrangler (wrangler deploy) and specify that the platform worker must be deployed first

Like that the only commands they need to run is wrangler deploy and curl to do a request to the user worker.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

i updated that its a workers feature but still included the some WFp context. we can also move this to a different section if needed

Comment on lines +53 to +61
Export a `WorkerEntrypoint` with a `query()` method. The wrapped D1 binding calls this method for every database operation the User Worker runs.

Inside `query()`, inspect the incoming SQL and decide what to do. To forward a query to the real database, call `query()` on the D1 database passed through `props`.

:::caution[Important]
The `query()` parameter uses `params.queries`. This is an array of statements, and each statement has a `.sql` field. Do not use `params.statements`.

A single `env.DB.prepare(...).all()` call arrives as one entry in `params.queries`.
:::

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm reading this as a D1 engineer, and I find it quite hard to follow even though I implemented it 😅.

For the user, the most important part is to know that platform worker implement a single method query which receives all the query made to a D1 database. From there, they can inspect the query and then forward it to the D1 database using this.ctx.props.D1.query(params).

Comment on lines +76 to +84
const sql = stmt.sql.trim().toUpperCase();
if (
sql.startsWith("DELETE") ||
sql.startsWith("INSERT") ||
sql.startsWith("UPDATE")
) {
// A rejected query. The User Worker sees this as a D1_ERROR.
return { success: false, error: new Error("Writes are not allowed") };
}

@FlorentCollin FlorentCollin Aug 21, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not confident that we should give this example to users because it's actually unsafe to do it like that. For example this query could entirely bypass this if statement.

/* pwned */ DELETE FROM users

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I can't think of a better example right now, so if you want to keep it please add a disclaimer that this code is not production ready and is only show what a platform worker can do.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

that's a good point. i borrowed from the example https://gitlab.cfdata.org/tlee/custom-binding-demo/-/blob/main/platform-worker/src/index.ts?ref_type=heads but you're right there are a lot of ways to bypass a 'startsWith"


You will see the intercepted query logged. Rejected writes surface to the User Worker as a `D1_ERROR`.

## Full example

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't this section redundant if everything was already defined above?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

fair comment. For preview docs like these we normally reiterate the steps in one fully formed example for doing everything in one place to copy without scrolling and reassembling.

// inspect every statement in the batch
for (const stmt of params.queries) {
const sql = stmt.sql.trimStart();
if (!/^select\b/i.test(sql)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

btw I think your example is making the assumption that so long as the query doest not contain select then it is a read query

consider:

INSERT INTO backup SELECT * FROM users;
CREATE TABLE backup AS SELECT * FROM users;

- The Platform Worker inspects every query and allows only read-only queries.
- The binding routes `env.DB` through the Platform Worker first, then to the real D1 database passed in through `props`.

## TypeScript types

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If we are putting these here then are they going to get out of sync with D1's API interface?

Seems prone to bitrot?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

They'll be moved out to @cloudflare/workers-types or similar once the feature is ready for all users.
It needs to be as easy as

import type { D1BindingService } from "@cloudflare/workers-types"

/**
* The row data format returned by the binding service.
*
* Note: this shape is an implementation detail and is expected to change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't follow why it would change or why it is an implemetnation detail?

}
}

// A default export is required so Wrangler builds this as an ES module Worker.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there a reason why we are separating the Worker that intercepts D1 requests from the dispatch worker?

The wording "platform worker" made it ambiguous — I read "platform worker" as synonomous with Dispatch Worker

Something like this...

  import { WorkerEntrypoint } from "cloudflare:workers";

  export class D1CustomBinding extends WorkerEntrypoint {
    async query(params) {
      // inspect/reject/log...
      return this.ctx.props.D1.query(params);
    }
  }

  // The default entrypoint is also the dispatcher.
  export default {
    async fetch(request, env) {
      const userWorker = env.DISPATCHER.get("user-worker");
      return userWorker.fetch(request);
    },
  };

...would mean that you don't have to caveat

}
}

// A default export is required so Wrangler builds this as an ES module Worker.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also this just seems like a Wrangler bug, no?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

```js title="src/index.js"
export default {
async fetch(request, env) {
const userWorker = env.DISPATCHER.get("user-worker");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think the multitenancy is clear in this demo if this is hardcoded in this way?

it's also implicit here:

https://github.com/cloudflare/cloudflare-docs/pull/32646/changes#diff-9fd82cbe780ed42ec7d874506840059000f8d1c07f4acffa679bfc702a0d5d4aR255

the person or agent reading this doesn't necessarily know what this is:

env.DISPATCHER.get("user-worker");

...that they're supposed to pass in the ID of the user worker here

docs can help make that clearer — ex: take a path and map path onto the identifier of the user Worker

@irvinebroque irvinebroque left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

While keeping things minimal

The examples we show about platforms need to in some way communicate the platform component not just the product feature

ex: multiple D1 DBs, multiple user Workers

how can we give someone a demo like exists here:

https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/get-started/#deploy-a-platform

that actually shows a platform that uses this feature? Help someone coming in here understand how the pieces fit?

should that other demo be adapted or updated?

@cloudflare-docs-bot

Copy link
Copy Markdown
Contributor

@ivoryibu This draft pull request has had no activity for 3 days. It will close after 7 days of inactivity. Transition to ready for review if ready. A codeowner can comment /draft-never-stale to keep it open.

@ivoryibu ivoryibu reopened this Sep 23, 2026
@github-actions github-actions Bot added size/l and removed product:d1 D1: https://developers.cloudflare.com/d1/ product:changelog labels Sep 23, 2026
@cloudflare-docs-bot

Copy link
Copy Markdown
Contributor

@ivoryibu This draft pull request has had no activity for 3 days. It will close after 7 days of inactivity. Transition to ready for review if ready. A codeowner can comment /draft-never-stale to keep it open.

ivoryibu and others added 14 commits October 9, 2026 17:52
Move custom bindings docs from D1-specific page to Workers for
Platforms configuration. Add R2 per-bucket quota pattern alongside
the existing D1 query hook pattern. Add flow diagram component.
Update changelog to workers-for-platforms.
Focus the custom bindings page on D1 only. Omit R2 until it is ready, restructure into Overview, How it works, Get started, and Full code example, and drop the changelog entry since this is not a public release.

- Rewrite CustomBindingsDiagram to show the D1 query interception flow with reject and forward paths
- Model code and config on the working demo (query()/queries types, unsafe.bindings with binding_interface, $binding for D1)
- Remove the Related resources section
Import the Icon component from @cloudflare/nimbus-docs instead of astro-icon so the diagram type-checks against the current repo setup.
- Replace complex example with minimal read-only policy pattern
- Use one consistent compat flag (d1_custom_binding) throughout
- Remove binding_types.ts, QueryRequest/QueryResponse types, unsafe block
- Simplify How it works section to plain language
- Add explicit three-step deploy flow
- Add redeploy note for existing User Workers
- Shorten prerequisites
- Inline SVG icons in diagram component
Reorder sections to match the Dynamic Hyperdrive reference pattern:
- How it works: high-level three-step flow + diagram + props note
- Getting started: prereqs, then four numbered step sections with code
- Full example: combined Platform Worker, User Worker, and binding config
  with a What this shows callout

Move redeploy note and props details inline in the getting started flow
instead of collapsible sections.
…s/configuration/custom-bindings.mdx

Co-authored-by: FlorentCollin <florentcollinpro@gmail.com>
…pattern

Reframe custom bindings from standalone API feature to platform pattern.
Add use cases, supported resources table, generic three-step blueprint,
and dedicated D1 implementation section. Remove redundant full example.
This is the breaking change we have been talking about. The main change
is that the D1 worker which was previously returning `{ success:
boolean, results: ... }` will directly return an object of this type

```ts
export type QueryResponse = {
	/** Result for each SQL query in the request, in request order. */
	results: QuerySqlResult[];
	/** Session bookmark returned after executing the query batch. */
	bookmark?: D1Bookmark;
};
```

To reject a request or to do some error handling the platform worker
must throw an error instead of returning `{ success: false, error }`.
@FlorentCollin
FlorentCollin force-pushed the d1-custom-bindings-docs branch from 54c40f0 to 43185e8 Compare October 9, 2026 16:55
@cloudflare-docs-bot

Copy link
Copy Markdown
Contributor

@ivoryibu This draft pull request has had no activity for 3 days. It will close after 7 days of inactivity. Transition to ready for review if ready. A codeowner can comment /draft-never-stale to keep it open.

@cloudflare cloudflare deleted a comment from github-actions Bot Oct 16, 2026
@cloudflare-docs-bot

Copy link
Copy Markdown
Contributor

@ivoryibu This draft pull request has had no activity for 3 days. It will close after 7 days of inactivity. Transition to ready for review if ready. A codeowner can comment /draft-never-stale to keep it open.

@cloudflare-docs-bot

Copy link
Copy Markdown
Contributor

@ivoryibu Closing this draft pull request after 7 days of inactivity. Re-open at a later date and transition to ready for review when you are ready if you seek review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.