Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .changeset/quiet-rivers-remote-bindings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@cloudflare/remote-bindings": minor
---

Add `@cloudflare/remote-bindings`, a standalone package that powers remote bindings during local development without depending on `wrangler`.

It exposes `startRemoteProxySession`, `maybeStartOrUpdateRemoteProxySession`, and `pickRemoteBindings`, replacing the previous DevEnv-based proxy with a lightweight, direct edge-preview API client plus a minimal Node.js HTTP/WebSocket proxy.

Auth is customisable: by default `createEnvAuthResolver` reads `CLOUDFLARE_*` credentials or refreshes the stored OAuth token discovered via the environment, so a top-level CLI can delegate remote bindings down a `cf dev → vite dev → @cloudflare/remote-bindings` chain and have tokens refreshed mid-run. Everything needed is configurable through environment variables: `CLOUDFLARE_CONFIG_DIR` / `CLOUDFLARE_AUTH_CONFIG_FILE` (token location and format, including JSON/JSONC), `CLOUDFLARE_OAUTH_CLIENT_ID` (the app to refresh with), `CLOUDFLARE_ALLOW_GLOBAL_API_KEY`, and `CLOUDFLARE_LOGIN_COMMAND`. Consumers calling the package directly can also inject their own credentials, storage backend, client ID, or `loginHint`.

When no credentials are available and no stored token can be refreshed (e.g. the user hasn't logged in yet), the resolver fails fast with a clear, CLI-agnostic message — it never attempts an interactive login, since the top-level CLI owns authentication.
7 changes: 7 additions & 0 deletions .changeset/spotty-meadows-vite-remote.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/vite-plugin": patch
---

Source remote bindings directly from `@cloudflare/remote-bindings` instead of going through `wrangler`.

This removes the plugin's reliance on `wrangler` for establishing remote binding proxy sessions and enables environment-variable-driven auth discovery, so remote bindings keep working (and refresh their OAuth token) when the plugin is driven by a top-level CLI.
7 changes: 7 additions & 0 deletions .changeset/wise-lions-wrangler-remote.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Establish remote binding proxy sessions via the new `@cloudflare/remote-bindings` package.

`wrangler dev`'s remote bindings now use a lightweight direct edge-preview client and a minimal local proxy instead of spinning up a full dev environment, while preserving wrangler's existing auth resolution and error reporting. There is no change to user-facing behaviour or configuration.
3 changes: 3 additions & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@
"!packages/create-cloudflare/**/templates*/**/c3.ts",
// TODO: we really should be linting this folder
"packages/wrangler/templates/**",
// Worker template bundled into the package at build time; mirrors the
// (ignored) wrangler ProxyServerWorker template.
"packages/remote-bindings/templates/**",
// Ignore generated files
"packages/containers-shared/src/client/**",
"packages/local-explorer-ui/src/api/generated/**",
Expand Down
61 changes: 61 additions & 0 deletions packages/remote-bindings/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "@cloudflare/remote-bindings",
"version": "0.0.0",
"description": "Remote bindings support for Cloudflare Workers local development",
"homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/remote-bindings#readme",
"bugs": {
"url": "https://github.com/cloudflare/workers-sdk/issues"
},
"license": "MIT OR Apache-2.0",
"author": "workers-devprod@cloudflare.com",
"repository": {
"type": "git",
"url": "https://github.com/cloudflare/workers-sdk.git",
"directory": "packages/remote-bindings"
},
"files": [
"dist"
],
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
}
},
"scripts": {
"build": "tsdown",
"check:type": "tsc -p ./tsconfig.json",
"test": "vitest",
"test:ci": "vitest run"
},
"dependencies": {
"@cloudflare/deploy-helpers": "workspace:*",
"@cloudflare/workers-auth": "workspace:*",
"@cloudflare/workers-utils": "workspace:*",
"undici": "catalog:default"
},
"devDependencies": {
"@cloudflare/workers-tsconfig": "workspace:*",
"@types/node": "catalog:default",
"capnweb": "catalog:default",
"esbuild": "catalog:default",
"get-port": "^7.0.0",
"tsdown": "0.16.3",
"typescript": "catalog:default",
"vitest": "catalog:default"
},
"peerDependencies": {
"miniflare": "workspace:*"
},
"engines": {
"node": ">20.0.0"
},
"volta": {
"extends": "../../package.json"
},
"workers-sdk": {
"prerelease": true
}
}
15 changes: 15 additions & 0 deletions packages/remote-bindings/scripts/deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Dependencies that _are not_ bundled along with @cloudflare/remote-bindings.
*
* These must be explicitly documented with a reason why they cannot be bundled.
* This list is validated by `tools/deployments/validate-package-dependencies.ts`.
*/
export const EXTERNAL_DEPENDENCIES = [
// Bundling `undici` would produce a duplicate copy in every downstream
// consumer that already depends on undici (e.g. wrangler), which breaks
// `instanceof Request`/`Response`/`Headers` checks across the boundary and
// prevents the local proxy from interoperating with the consumer's undici.
// Keeping it external lets the package manager deduplicate undici to a
// single shared instance.
"undici",
];
39 changes: 39 additions & 0 deletions packages/remote-bindings/src/api/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { fetchResultBase } from "@cloudflare/workers-utils";
import type { Logger } from "../logger";
import type { AuthCredentials } from "../types";
import type { ComplianceConfig } from "@cloudflare/workers-utils";
import type { RequestInit } from "undici";

/** Identifies remote-bindings requests to the Cloudflare API. */
const USER_AGENT = "@cloudflare/remote-bindings";

function toComplianceConfig(region: string | undefined): ComplianceConfig {
return { compliance_region: region as ComplianceConfig["compliance_region"] };
}

/**
* Make an authenticated Cloudflare API request and return its `result`.
*
* Thin wrapper over workers-utils' shared API client (`fetchResultBase`), so we
* don't duplicate authorization headers, base-URL / compliance-region handling,
* or error parsing.
*/
export async function fetchResult<T>(
auth: AuthCredentials,
resource: string,
init: RequestInit | undefined,
complianceRegion: string | undefined,
logger: Logger,
abortSignal?: AbortSignal
): Promise<T> {
return fetchResultBase<T>(
toComplianceConfig(complianceRegion),
resource,
init ?? {},
USER_AGENT,
logger,
undefined,
abortSignal,
auth.apiToken
);
}
97 changes: 97 additions & 0 deletions packages/remote-bindings/src/api/preview-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import crypto from "node:crypto";
import { getAccessHeaders } from "@cloudflare/workers-auth";
import { fetch } from "undici";
import { fetchResult } from "./fetch";
import { getWorkersDevSubdomain } from "./subdomain";
import type { Logger } from "../logger";
import type { AuthCredentials, PreviewSession } from "../types";

/**
* Try to exchange a session token via the exchange URL.
*
* The edge-preview API may return an `exchange_url` alongside the session
* token. When present, we must fetch it to get a re-encoded token. This
* handles various edge configurations (including Cloudflare Access-protected
* preview domains).
*
* Returns null if the exchange fails for any reason — the caller should
* fall back to the raw token.
*/
async function tryExchangeToken(
exchangeUrl: string,
logger: Logger,
abortSignal?: AbortSignal
): Promise<string | null> {
try {
const url = new URL(exchangeUrl);

// Attach Cloudflare Access headers when the exchange domain is
// Access-protected (e.g. staging). Resolved via `@cloudflare/workers-auth`
// so the same Access handling is shared with wrangler.
const headers = await getAccessHeaders(url.hostname, {
logger,
isNonInteractiveOrCI: () => true,
});

const response = await fetch(url, {
signal: abortSignal,
headers,
});

if (!response.ok) {
return null;
}

const body = (await response.json()) as { token?: string };
if (typeof body?.token !== "string") {
return null;
}
return body.token;
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
throw e;
}
return null;
}
}

/**
* Create an edge-preview session. This is the first of two API calls needed
* to set up a remote preview — it creates a session token that's used when
* uploading the worker.
*
* If the API returns an `exchange_url`, we perform the token exchange to get
* a re-encoded token. Falls back to the raw token if exchange fails.
*/
export async function createPreviewSession(
auth: AuthCredentials,
name: string | undefined,
complianceRegion: string | undefined,
logger: Logger,
abortSignal?: AbortSignal
): Promise<PreviewSession> {
const initUrl = `/accounts/${auth.accountId}/workers/subdomain/edge-preview`;

const { token, exchange_url } = await fetchResult<{
token: string;
exchange_url?: string;
}>(auth, initUrl, undefined, complianceRegion, logger, abortSignal);

// Exchange the token if the API tells us to
const sessionToken = exchange_url
? ((await tryExchangeToken(exchange_url, logger, abortSignal)) ?? token)
: token;

const subdomain = await getWorkersDevSubdomain(
auth,
complianceRegion,
logger
);
const host = `${name ?? crypto.randomUUID()}.${subdomain}`;

return {
value: sessionToken,
host,
name,
};
}
53 changes: 53 additions & 0 deletions packages/remote-bindings/src/api/preview-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { fetchResult } from "./fetch";
import { createProxyWorkerUploadForm } from "./upload-form";
import type { Logger } from "../logger";
import type { AuthCredentials, PreviewSession, PreviewToken } from "../types";
import type { Binding } from "@cloudflare/workers-utils";

/**
* Upload the ProxyServerWorker to the edge-preview API and get a preview token.
* This is the second of two API calls needed to set up a remote preview.
*
* The worker is uploaded with `minimal_mode: true`, which tells the edge to
* provide raw bindings (pass-through to the real resources).
*/
export async function createPreviewToken(
auth: AuthCredentials,
session: PreviewSession,
bindings: Record<string, Binding>,
workerName: string,
complianceRegion: string | undefined,
logger: Logger,
abortSignal?: AbortSignal
): Promise<PreviewToken> {
const url = `/accounts/${auth.accountId}/workers/scripts/${encodeURIComponent(workerName)}/edge-preview`;

const formData = createProxyWorkerUploadForm(bindings);
formData.set(
"wrangler-session-config",
JSON.stringify({ workers_dev: true, minimal_mode: true })
);

const { preview_token } = await fetchResult<{
preview_token: string;
tail_url: string;
}>(
auth,
url,
{
method: "POST",
body: formData,
headers: {
"cf-preview-upload-config-token": session.value,
},
},
complianceRegion,
logger,
abortSignal
);

return {
value: preview_token,
host: session.host,
};
}
21 changes: 21 additions & 0 deletions packages/remote-bindings/src/api/subdomain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { fetchResult } from "./fetch";
import type { Logger } from "../logger";
import type { AuthCredentials } from "../types";

/**
* Get the workers.dev subdomain for an account.
*/
export async function getWorkersDevSubdomain(
auth: AuthCredentials,
complianceRegion: string | undefined,
logger: Logger
): Promise<string> {
const result = await fetchResult<{ subdomain: string }>(
auth,
`/accounts/${auth.accountId}/workers/subdomain`,
undefined,
complianceRegion,
logger
);
return result.subdomain;
}
51 changes: 51 additions & 0 deletions packages/remote-bindings/src/api/upload-form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { createWorkerUploadForm } from "@cloudflare/deploy-helpers";
import proxyServerWorkerScript from "virtual:proxy-server-worker";
import type { Binding } from "@cloudflare/workers-utils";
import type { FormData } from "undici";

/**
* Create a FormData upload for the ProxyServerWorker with the given bindings.
*
* Reuses the canonical `createWorkerUploadForm` (from `@cloudflare/deploy-helpers`,
* which has no dependency on wrangler) so every remote binding type is serialised
* identically to a real deploy. The worker itself is a single pre-bundled ES
* module, and all bindings are marked `raw` so the edge gives the proxy worker
* direct, pass-through access to the real resources.
*/
export function createProxyWorkerUploadForm(
bindings: Record<string, Binding>
): FormData {
const rawBindings: Record<string, Binding> = {};
for (const [name, binding] of Object.entries(bindings)) {
rawBindings[name] = { ...binding, raw: true } as Binding;
}

return createWorkerUploadForm(
{
name: "remote-bindings-proxy",
main: {
name: "ProxyServerWorker.mjs",
filePath: "ProxyServerWorker.mjs",
type: "esm",
content: proxyServerWorkerScript,
},
modules: [],
migrations: undefined,
compatibility_date: "2025-04-28",
compatibility_flags: [],
keepVars: undefined,
keepSecrets: undefined,
keepBindings: undefined,
logpush: undefined,
sourceMaps: undefined,
placement: undefined,
tail_consumers: undefined,
limits: undefined,
assets: undefined,
containers: undefined,
observability: undefined,
cache: undefined,
},
rawBindings
);
}
Loading
Loading