Skip to content

Implement inbound UDP handler - #7130

Open
ThomasRubini wants to merge 4 commits into
cloudflare:mainfrom
ThomasRubini:udp_handler
Open

Implement inbound UDP handler#7130
ThomasRubini wants to merge 4 commits into
cloudflare:mainfrom
ThomasRubini:udp_handler

Conversation

@ThomasRubini

@ThomasRubini ThomasRubini commented Aug 26, 2026

Copy link
Copy Markdown

Summary

This PR adds support for handling inbound UDP connections. It does 2 things:

  • adds the protocol field to the Socket object used in connect() handlers, following [wrangler] add connect wrangler config section workers-sdk#14995
  • adds a UDP listener to trigger that connect() handler for UDP connections, grouping UDP packets by the same source IP+port under the same connection, with a timeout
    The UDP connect() handler has been implemented as a custom event because connect() required a kj::AsyncIoStream which is byte-oriented rather than message-oriented like UDP requires

These commits can be split into 2 PRs if necessary

Example usage

export default {
	async connect(socket): Promise<void> { // Note we reuse the existing connect() handler
		const reader = socket.readable.getReader();
		const writer = socket.writable.getWriter();

		const { remoteAddress } = await socket.opened;
		await writer.write(
			new TextEncoder().encode(
				`hello from connect(), protocol: ${socket.protocol}, remoteAddress: ${remoteAddress ?? "unknown"}\n`,
			),
		);

		try {
			for (;;) {
				const { value, done } = await reader.read();
				if (done) {
					break;
				}
				await writer.write(value);
			}
		} finally {
			await writer.close();
		}
	},
} satisfies ExportedHandler<Env>;

testable by adding this bit in a workerd config:

sockets = [
    ( name = "http", address = "*:8787", http = (), service = "main" ),
    ( name = "tcp", address = "*:5432", tcp = (), service = "main" ),
    ( name = "udp", address = "*:5599", udp = (idleTimeoutMs = 30000), service = "main" ),
  ]

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@ThomasRubini

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

Comment thread src/workerd/api/sockets.h

// Resolves with the next inbound datagram, or kj::none once the flow has ended (e.g. an idle
// timeout). Must not be called again after resolving kj::none, and must not have more than one
// outstanding call at a time.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

How is back pressure handled? Specifically, if we're receiving datagrams faster than calls to receive() .. what happens?

Comment thread src/workerd/api/sockets.h
virtual kj::Promise<kj::Maybe<kj::Array<kj::byte>>> receive() = 0;

// Sends one outbound datagram to the peer.
virtual kj::Promise<void> send(kj::ArrayPtr<const kj::byte> datagram) = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

kj::Promise<void>... implies that these aren't send-and-forget. What is the resolve criteria here?

Comment thread src/workerd/api/sockets.h
JSG_READONLY_PROTOTYPE_PROPERTY(opened, getOpened);
JSG_READONLY_PROTOTYPE_PROPERTY(upgraded, getUpgraded);
JSG_READONLY_PROTOTYPE_PROPERTY(secureTransport, getSecureTransport);
JSG_READONLY_PROTOTYPE_PROPERTY(protocol, getProtocol);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just note that any additional properties that are not part of the proposed standard spec should be marked as such.

https://sockets-api.proposal.wintertc.org/

Comment thread src/workerd/api/sockets.c++ Outdated
}

// A WritableStreamSink that sends each JS write() call as exactly one outbound datagram. Unlike a
// byte-stream sink, there is no buffering: one write() call is one DatagramChannel::send() call,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What does "there is no buffering" mean here? The WritableStream controller still has it's own buffering.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Apologies, I misunderstood how internal streams worked, I thought there was no buffering done for them, and TCP's sink was the one doing it.


kj::Promise<void> write(kj::ArrayPtr<const kj::byte> buffer) override {
return channel->send(buffer);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

hmm.. this might end up being a bit surprising to users. The behavior here needs to be thought through. Specifically, take something like:

const rs = new ReadableStream({ ... });
await rs.pipeTo(socket.writable);

With a TCP socket, this is fine, regular stream semantics.

With this, doh, everything is a separate datagram. Each packet may be of a different size, might split surrogate pair bytes, might split utf8 bytes, etc. I'm not convinced it's a great idea to re-use the Socket in this way. Not going to block but I'm far from convinced.

Comment thread src/workerd/api/sockets.c++ Outdated
jsg::Ref<ReadableStream> newDatagramReadableStream(jsg::Lock& js, kj::Rc<DatagramChannel> channel) {
auto controller = newReadableStreamJsController();
auto stream = js.allocAccounted<ReadableStream>(
sizeof(ReadableStream) + controller->jsgGetMemorySelfSize(), kj::mv(controller));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this needs to use the JsReadableStream abstraction for creating the ReadableStream or this breaks under the TS streams work.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done! I'll let you resolve this comment if my changes are good

JSG_FAIL_REQUIRE(Error, "Handler does not export a connect() function.");
}

kj::Promise<void> ServiceWorkerGlobalScope::connectUdp(kj::String host,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm definitely not a fan of introducing a new non-standard global. connect(...) is one thing because we have a standards-track spec behind it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

by "new global" do you mean new handler function exposed to customers ? My current goal was to reuse the existing connect() handler. I updated the description with a code usage example to show that
Does this change your comment ?

@dom96 dom96 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.

I would ideally like to see this added to the sockets spec before it is implemented: https://sockets-api.proposal.wintertc.org/

It's also worth considering how our design compares to the direct-socket API. Ideally we shouldn't diverge from it unless it's necessary. It would be nice to reuse its UDPMessage + ReadableStream/WritableStream approach for example.

readonly readable: unknown;
readonly writable: unknown;
readonly closed: Promise<void>;
readonly protocol: 'tcp' | 'udp';

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.

Worth noting that the direct-sockets spec proposal defines separate types: a TCPSocket and a UDPSocket. https://wicg.github.io/direct-sockets/#udpsocket-interface

It may be a good idea for us to do the same. The fact that the UDP socket doesn't support TLS nor startTls (plus a bunch of other methods that work on Socket) seems to suggest that it shouldn't reuse Socket.

@ThomasRubini ThomasRubini Aug 27, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I'm not sure I understand. Were you asking about exposing a Socket object for TCP and a UDPSocket object for UDP ?

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.

Yeah, exactly. Curious what @jasnell thinks on this though.

Comment thread src/workerd/api/global-scope.c++
@ThomasRubini
ThomasRubini marked this pull request as ready for review August 27, 2026 20:12
@ThomasRubini
ThomasRubini requested review from a team as code owners August 27, 2026 20:12
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.

3 participants