Skip to content

🐛 BUG: connect() on remote VPC Network bindings fails in local dev with "Incoming CONNECT on a worker not supported" #14710

Description

@mack-erel

Which Cloudflare product(s) does this pertain to?

Wrangler, Miniflare (remote bindings)

What versions are you using?

  • wrangler 4.110.0 (also reproduced on workers-sdk main @ c7dbe1a)
  • miniflare 4.20260708.1 / workerd 1.20260708.1
  • node v26.4.0, macOS (darwin arm64)

What operating system and mode are you using?

macOS, wrangler dev (local mode with remote bindings) and getPlatformProxy() (via @sveltejs/adapter-cloudflare)

Describe the Bug

What happened

With a vpc_networks binding configured with remote: true, calling the binding's raw-TCP API from a Worker in local dev fails immediately:

// wrangler.jsonc
{
  "vpc_networks": [
    { "binding": "VPC_NETWORK", "tunnel_id": "<TUNNEL_ID>", "remote": true }
  ]
}
const socket = await env.VPC_NETWORK.connect("db-host:3306"); // throws
  • wrangler dev: TypeError: Incoming CONNECT on a worker not supported (thrown synchronously from connect())
  • getPlatformProxy() (vite dev): surfaces as TypeError: This ReadableStream is disturbed (has already been read from), and cannot be used as a body., with the underlying workerd log showing the same root error:
    workerd/api/sockets.c++:449: error: Socket proxy disconnected abruptly; e = workerd/io/worker-entrypoint.c++:648: failed: jsg.TypeError: Incoming CONNECT on a worker not supported

fetch() on the same binding relays correctly in both modes. The same connect() call works when the Worker is deployed (TCP connect() over VPC Networks, GA 2026-06-16).

Root cause (traced to source)

Remote bindings are wired locally as a JS proxy worker, and that worker cannot receive the native CONNECT that Fetcher#connect() produces:

  1. Miniflare registers every remote-capable binding (including vpc_networks) as a workerd Service.worker running packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts, which implements fetch() and capnweb JSRPC relay only — there is no connect handler (packages/miniflare/src/plugins/vpc-networks/index.tsremoteProxyClientWorker() in packages/miniflare/src/plugins/shared/constants.ts).
  2. env.BINDING.connect(addr) is a native (C++) code path: workerd turns it into an HTTP CONNECT delivered to that proxy worker's entrypoint.
  3. WorkerEntrypoint::connect rejects inbound CONNECT unless the receiving worker has the connect_pass_through or experimental compat flag (workerd/src/workerd/io/worker-entrypoint.c++:648). The proxy client worker has neither, so the call dies locally — the request never reaches the remote proxy session.
  4. The edge side (packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts) also only handles fetch/JSRPC; there is no socket relay path.

Note the existing remote-bindings e2e test for VPC Networks only exercises .fetch() (and is currently skipped), so this raw-TCP gap has no coverage.

Feasibility evidence (we prototyped each building block)

We ran three spikes on main to check a fix is viable without any workerd changes:

  1. Local ingress works. A miniflare worker with compatibilityFlags: ["experimental"] and an export default { connect(socket) } handler (workerd's inbound connect handler, merged in EW-9330 Implement connect() handler workerd#6059) successfully receives a service-binding connect() from another worker, with bidirectional bytes. Without the per-worker flag it fails with exactly the error above (the global --experimental CLI flag miniflare already passes is not sufficient — the check is per-worker).
  2. The target address is recoverable. The caller's connect("host:port") authority arrives verbatim as (await socket.opened).localAddress in the handler (matching the contract exercised by workerd/src/workerd/api/tests/connect-handler-test.js), so no extra address-negotiation protocol is needed.
  3. The edge side already works. We added a temporary diagnostic branch to ProxyServerWorker.ts that calls env[binding].connect(addr) inside a real remote proxy session (started via startRemoteProxySession() with a real vpc_network binding + Cloudflare Tunnel): the connect succeeded and returned the first 78 bytes of a genuine MySQL handshake from a private-network database. So connect() on the raw binding is fully functional in the preview session context.

Proposed fix (we're happy to submit a PR)

  • remote-proxy-client.worker.ts: add a connect(socket) handler; recover the target address from socket.opened.localAddress; relay the byte stream to the remote proxy session over a WebSocket (binary frames), following the auth/headers conventions of the existing fetch/JSRPC relay.
  • ProxyServerWorker.ts: add a WebSocket tunnel endpoint that calls await env[binding].connect(address) and pipes both directions, propagating close/error.
  • plugins/shared/constants.ts / plugins/vpc-networks: opt the proxy client worker into the experimental compat flag (scoped to bindings that need raw TCP), since the inbound connect handler is still experimental-gated in workerd.

Open questions we'd like maintainer input on before sending the PR:

  • Is enabling the experimental compat flag on miniflare's internal proxy-client worker acceptable, given the inbound connect handler (workerd#6059) is not yet stable? If not, would you prefer this gated behind an --experimental-* wrangler flag per the naming conventions in CONTRIBUTING.md?
  • Should the relay reuse the existing capnweb WebSocket session or open a dedicated WebSocket per socket? (Our prototype assumes a dedicated WS per connect() for simplicity.)

Reproduction

Minimal repro:

// wrangler.jsonc
{
  "name": "vpc-connect-repro",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-15",
  "vpc_networks": [{ "binding": "VPC_NETWORK", "tunnel_id": "<TUNNEL_ID>", "remote": true }]
}
// src/index.ts
export default {
  async fetch(_req: Request, env: { VPC_NETWORK: Fetcher }) {
    try {
      const socket = await (env.VPC_NETWORK as any).connect("<PRIVATE_HOST>:3306");
      const { value } = await socket.readable.getReader().read();
      return Response.json({ ok: true, bytes: value?.length });
    } catch (e) {
      return Response.json({ ok: false, error: String(e) }, { status: 500 });
    }
  }
};

wrangler dev → curl → {"ok":false,"error":"TypeError: Incoming CONNECT on a worker not supported"}.
The same Worker deployed → {"ok":true,"bytes":78} (MySQL greeting via Cloudflare Tunnel).

Please provide a link to a minimal reproduction

The snippets above are the minimal reproduction. A self-contained repro repository is not practical here because reproducing requires an account-specific Cloudflare Tunnel and a private database behind it.

Please provide any relevant log output

✘ [ERROR] workerd/server/server.c++:...: error: Uncaught: workerd/io/worker-entrypoint.c++:648: failed: jsg.TypeError: Incoming CONNECT on a worker not supported
workerd/api/sockets.c++:449: error: Socket proxy disconnected abruptly

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Untriaged

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions