Skip to content

[miniflare] Support connect() on remote VPC Network bindings in local dev#14712

Open
mack-erel wants to merge 4 commits into
cloudflare:mainfrom
mack-erel:vpc-network-connect-tunnel
Open

[miniflare] Support connect() on remote VPC Network bindings in local dev#14712
mack-erel wants to merge 4 commits into
cloudflare:mainfrom
mack-erel:vpc-network-connect-tunnel

Conversation

@mack-erel

@mack-erel mack-erel commented Jul 16, 2026

Copy link
Copy Markdown

Fixes #14710.

What

Adds raw-TCP connect() support for remote VPC Network bindings in local development. Previously, env.VPC_NETWORK.connect("host:port") worked when deployed but failed under wrangler dev / getPlatformProxy() with Incoming CONNECT on a worker not supported, because the local remote-bindings proxy only relayed HTTP and JSRPC.

Raw TCP is now tunnelled end-to-end through the existing remote-bindings proxy:

  • Miniflare proxy client (packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts) gains an inbound connect(socket) handler. It recovers the caller's target address, opens a WebSocket to the remote proxy server, and relays bytes in both directions.
  • Wrangler ProxyServerWorker (packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts) gains a WebSocket tunnel endpoint that calls env[binding].connect(address) (routing through the real VPC tunnel) and relays bytes back.
  • The proxy client worker opts into workerd's inbound connect handler only for raw-TCP bindings (VPC networks today).

Why

The full root-cause analysis, source trace, and feasibility spikes are in #14710, so only a summary is repeated here. In short: remote-capable bindings are wired locally as a proxy worker that implements fetch()/capnweb JSRPC only. env.BINDING.connect(addr) is a native workerd path that delivers an inbound CONNECT to that worker's entrypoint, which workerd rejects unless the worker carries the connect_pass_through or experimental compat flag. Neither the proxy client nor the edge proxy server had a socket relay path, so the call died locally before ever reaching the remote proxy session.

Design decisions

  • Inbound connect handler (workerd#6059): the proxy client worker implements export default { connect(socket) }. This handler is experimental-gated in workerd, so it is enabled via a per-worker experimental compat flag rather than relying on Miniflare's global --experimental CLI flag (which is not sufficient — the check is per-worker).
  • Address recovery via socket.opened.localAddress: the authority passed to connect("host:port") arrives verbatim as the inbound socket's localAddress on the service-binding path, matching workerd's connect-handler-test.js contract. No extra address-negotiation protocol is needed; the address is forwarded to the edge in an MF-Connect-Address header.
  • A dedicated WebSocket per connect(): each tunnel opens its own WebSocket to the proxy server rather than multiplexing over the existing capnweb JSRPC session. This keeps the relay independent of the JSRPC framing and simple to reason about for teardown.
  • rawTcp opt-in (existing call sites unaffected): remoteProxyClientWorker() takes a new options?: { rawTcp?: boolean } parameter; only VPC networks pass { rawTcp: true }. All existing HTTP/JSRPC proxy call sites keep their current behaviour and do not enable the experimental flag.

Open questions (happy to adjust to maintainer preference)

Both of these are unchanged from the questions raised in #14710 and can be reshaped however maintainers prefer:

  1. Experimental gating. This enables the experimental compat flag on Miniflare's internal proxy-client worker (scoped to raw-TCP bindings), because workerd#6059's inbound connect handler is not yet stable. If maintainers would rather not enable experimental on the internal worker, this could instead be gated behind an --experimental-* Wrangler flag per the naming conventions in CONTRIBUTING.md.
  2. WebSocket topology. The relay opens a dedicated WebSocket per connect() for simplicity. It could instead reuse the existing capnweb WebSocket session if a single multiplexed connection is preferred.

Known limitations

Documented in the relay's code comments as well:

  • No TCP half-close. When either direction ends, the tunnel is torn down in full (the WebSocket is closed and the socket's writable side is closed) rather than closing a single direction. Protocols relying on one-way half-close while continuing to read the other direction are not supported.
  • No backpressure. WebSockets expose no backpressure signal, so socket.readable is drained as fast as it produces; inbound WebSocket messages are written to the socket in order via a serialised write chain.

Testing

New test file packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts13 tests total:

  • End-to-end tunnel (1): tunnels bytes end-to-end through the real, bundled ProxyServerWorker (run in a second "edge" Miniflare instance) and asserts the connect() address is forwarded verbatim.
  • Close/error matrix, end-to-end (3): a target connect failure terminates the tunnel without hanging; a target half-close with the user still open tears the relay down; a user-initiated close propagates to the target pipe.
  • pipeSocketOverWebSocket unit (7): clean WS close cancels the parked socket read and resolves; socket EOF closes the WS and settles the WS-wait direction; a remote 1011 close rejects and cancels the opposite direction; a WS error event rejects, cancels, and closes the writer; a socket read error closes with 1011 and rejects; a socket write failure tears down with 1011 and rejects; the 1011 close reason is truncated to <=123 UTF-8 bytes on a char boundary.
  • rawTcp opt-in unit (2): the experimental flag is off by default and enabled only when rawTcp is set.

Real-environment E2E (manual): verified against a real Cloudflare Tunnel with a private-network MySQL database. env.VPC_NETWORK.connect(host:3306) under local dev returned a genuine MySQL server greeting and executed a Drizzle query end-to-end, matching deployed behaviour. (No tunnel IDs, database hosts, or account identifiers are included here.)

Build, typecheck, oxfmt, and oxlint are green.


PR template checklist (to fill in the actual PR)

Fixes #14710.

  • Tests
    • Tests included/updated — 13 new tests in remote-bindings-connect.spec.ts, plus manual real-tunnel E2E as described above.
  • Public documentation
    • Documentation not necessary because: this fixes a local-dev parity gap for an already-GA feature (TCP connect() over VPC Networks); no new user-facing API or flag is introduced.

A raccoon waving hello


Open in Devin Review

@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc96fcb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
miniflare Minor
wrangler Minor
@cloudflare/deploy-helpers Patch
@cloudflare/pages-shared Patch
@cloudflare/runtime-types Patch
@cloudflare/vite-plugin Patch
@cloudflare/vitest-pool-workers Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@workers-devprod
workers-devprod requested review from a team and ascorbic and removed request for a team July 16, 2026 04:52
@workers-devprod

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/vpc-networks-connect-tunnel.md: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/shared/constants.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/vpc-networks/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/shared/remote-bindings-utils.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts: [@cloudflare/wrangler]
  • packages/miniflare/vitest.config.mts: [@cloudflare/wrangler]
  • packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts: [@cloudflare/wrangler]

@devin-ai-integration devin-ai-integration Bot 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

devin-ai-integration[bot]

This comment was marked as resolved.

mack-erel and others added 4 commits July 21, 2026 11:51
… proxy client

Remote bindings were wired locally as a proxy worker that only relayed
fetch/JSRPC, so `binding.connect(address)` against a raw-TCP binding (e.g. a
VPC network) failed with "Incoming CONNECT on a worker not supported".

Add an inbound `connect` handler to the proxy client worker: it recovers the
target address from `socket.opened.localAddress` (the verbatim `connect()`
authority, per workerd#6059), opens a dedicated WebSocket to the remote proxy
server, and relays bytes in both directions via a new `pipeSocketOverWebSocket`
helper (with `truncateCloseReason` for the 123-byte close-reason limit). The
relay tears both directions down together so a parked read or a missing close
event can't leak the socket.

The inbound connect handler is experimental-gated in workerd, so opt the proxy
client worker into the `experimental` compat flag only for raw-TCP bindings via
a new `rawTcp` option on `remoteProxyClientWorker`; VPC networks pass it. The
existing HTTP/JSRPC proxy call sites are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…orker

The remote-bindings proxy server now detects the WebSocket connect upgrade
(carrying the target in `MF-Connect-Address`), opens the real binding socket
via `fetcher.connect(address)`, and relays bytes bidirectionally over the
WebSocket. This is the edge half of the Miniflare `connect()` tunnel.

The relay helper is a byte-for-byte mirror of the one in miniflare's
remote-bindings-utils; this template is bundled standalone for the edge, so the
logic is duplicated here rather than imported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add 13 tests for the `connect()` tunnel: end-to-end byte relay and address
propagation plus a close/error matrix run against the real bundled
ProxyServerWorker (in a second "edge" Miniflare instance), unit coverage of
`pipeSocketOverWebSocket` teardown/error paths, and opt-in assertions that the
`experimental` flag is set only when `rawTcp` is requested.

Add a `@relay-under-test` vitest alias so the worker-side relay helper can be
unit-tested without pulling worker-typed source into the node-side tsconfig
(which excludes `src/workers/**`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mack-erel
mack-erel force-pushed the vpc-network-connect-tunnel branch from d3b9589 to bc96fcb Compare July 21, 2026 03:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants