Repository navigation
Conversation
|
This pull request requires reviews from CODEOWNERS as it changes files that match the following patterns:
|
afe431c to
be33a51
Compare
| import { WorkerEntrypoint } from "cloudflare:workers"; | ||
|
|
||
| export class D1CustomBinding extends WorkerEntrypoint { | ||
| async query(params) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- Small description like you wrote at the top describing the feature (+ optionally a small diagram showing user worker <-> platform worker <-> D1 database)
- Showing the simplest version of a user worker doing a D1 request and a platform worker intercepting the D1 request
- Show the wrangler.jsonc for both the user worker and the platform worker
- 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.
There was a problem hiding this comment.
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
| 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`. | ||
| ::: |
There was a problem hiding this comment.
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).
| 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") }; | ||
| } |
There was a problem hiding this comment.
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 usersThere was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Isn't this section redundant if everything was already defined above?
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
If we are putting these here then are they going to get out of sync with D1's API interface?
Seems prone to bitrot?
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Also this just seems like a Wrangler bug, no?
There was a problem hiding this comment.
There was a problem hiding this comment.
| ```js title="src/index.js" | ||
| export default { | ||
| async fetch(request, env) { | ||
| const userWorker = env.DISPATCHER.get("user-worker"); |
There was a problem hiding this comment.
I don't think the multitenancy is clear in this demo if this is hardcoded in this way?
it's also implicit here:
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
left a comment
There was a problem hiding this comment.
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:
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?
4bcfb22 to
205e1cf
Compare
|
@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 |
|
@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 |
4b5fd9c to
54c40f0
Compare
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.
…s and clearer intent
…ding comments in custom bindings
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 }`.
54c40f0 to
43185e8
Compare
|
@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 |
|
Worker Preview URL: https://gh-d1-custom-bindings-docs.previews.developers.cloudflare.com Files with changes (up to 15)
|
|
@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 |
|
@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. |
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.
/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.CustomBindingsDiagramAstro component (modeled afterMeshHostnameRoutingDiagram) showing the User Worker → Platform Worker → Resource flow.d1/toworkers-for-platforms/and future-dated it to 2026-10-01.d1_custom_bindingcompatibility flag.Screenshots (optional)
Documentation checklist