Today we will be using the Web browser (Google Chrome for Testing 125) as a local HTTP over TCP socket server.
WICG Direct Sockets specifies an API
that provides TCPSocket
, UDPSocket
, and TCPServerSocket
.
In Chromium based browsers, for example Chrome, this capability is exposed in an Isolated Web Apps (IWA).
Previously we have created an IWA that we launch from arbitrary Web sites
with open()
,
including SDP
from a RTCDataChannel
in query string of the URL,
created in the arbotrary Web page, and exchanged signals with the RTCDataChannel
created in the IWA window
using WICG File System Access for the
ability to send data to the IWA which is then passed to a TCPSocket
instance for
that sends the data to a Node.js, Deno, Bun, or txiki.js TCP socket server for processing,
then sends the processed data back to the Web page using RTCDataChannel
in each window
, see telnet-client (user-defined-tcpsocket-controller-web-api branch), which is a
fork of telnet-client.
Now we will use the browser itself as a local HTTP server over the TCPServerSocket
interface.
Let's start with HTTP
HTTP is simple
HTTP is generally designed to be simple and human-readable, even with the added complexity introduced in HTTP/2 by encapsulating HTTP messages into frames. HTTP messages can be read and understood by humans, providing easier testing for developers, and reduced complexity for newcomers.
We'll also note this claim on the MDN Web Docs page from Client: the user-agent
The browser is always the entity initiating the request. It is never the server (though some mechanisms have been added over the years to simulate server-initiated messages).
which is no longer the case, as we'll demonstrate below, in code.
Some further reading about HTTP can be found here HTTP - Hypertext Transfer Protocol.
The reason for and use of the Access-Control-Request-Private-Network
and Access-Control-Allow-Private-Network
headers can be found here Private Network Access: introducing preflights.
An excellent article and example of a basic HTTP server with comments explaining what is going on, including comments in the code, written in C,
can be found here Making a simple HTTP webserver in C. We have
previously used that example to create a simple HTTP Web server for QuickJS, which does
not include a built-in Web server in the compiled qjs
executable, see webserver-c (quickjs-webserver branch).
We are just going to read the full request, headers and body, use a RegExp
to match the body of a POST
request, convert the text we post to uppercase letters, then respond with the uppercase letters.
We'll leave it to the reader to implement processing FormData
(multipart/form-data
), and other features, including novel ways to stream data in the server.
Currently no browser supports full-duplex streaming using fetch()
, see See Fetch body streams are not full duplex #1254 - except for the case
of between a ServiceWorker
and a WindowClient
on Chromium-based browsers, i.e., Chrome; here's an example of that exception Half duplex stream.
We have previously used Native Messaging to implement full duplex streaming using Deno's and Node.js'
fetch()
implementation from the browser, see native-messaging-deno (fetch-duplex branch) and
native-messaging-nodejs (full-duplex branch).
Bun does not currently implement HTTP/2 for client or server, see Implement HTTP2 server support to enable gRPC #8823.
We'll use Transfer-Encoding: chunked
in our browser-based server, just in case we want to
serve a chunked response, e.g., media to be appended to a MediaSource
instance during the read of the stream from fetch()
.
This we can do without an HTTP/2 or QUIC server to stream data from the server to the client.
Our request from DevTools console
of an arbitrary Web page looks something like this
fetch("http://0.0.0.0:8000", {
method: "post",
body: "test",
headers: {
"Access-Control-Request-Private-Network": true
}
})
.then((r) => r.text()).then((text) => console.log({
text
})).catch(console.error);
Our Direct Sockets TCPServerSocket
that is out HTTP server in the browser looks
something like this
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const encode = (text) => encoder.encode(text);
const socket = new TCPServerSocket("0.0.0.0", {
localPort: 8000,
});
console.log({
socket
});
const {
readable: server,
localAddress,
localPort,
} = await socket.opened;
await server.pipeTo(
new WritableStream({
async write(connection) {
console.log({
connection
});
const {
readable: client,
writable,
remoteAddress,
remotePort,
} = await connection.opened;
const writer = writable.getWriter();
console.log({
remoteAddress,
remotePort,
});
await client.pipeThrough(new TextDecoderStream()).pipeTo(
new WritableStream({
async write(request, controller) {
console.log({
request
});
if (/^OPTIONS/.test(request)) {
await writer.write(encode("HTTP/1.1 204 OK\r\n"));
await writer.write(
encode(
"Access-Control-Allow-Headers: Access-Control-Request-Private-Network\r\n",
),
);
await writer.write(
encode("Access-Control-Allow-Origin: *\r\n"),
);
await writer.write(
encode("Access-Control-Allow-Private-Network: true\r\n"),
);
await writer.write(
encode(
"Access-Control-Allow-Headers: Access-Control-Request-Private-Network\r\n\r\n",
),
);
await writer.close();
}
if (/^(POST|query)/i.test(request)) {
const [body] = request.match(
/(?<=\r\n\r\n)[a-zA-Z\d\s\r\n-:;=]+/igm,
);
console.log({
body,
});
await writer.write(encode("HTTP/1.1 200 OK\r\n"));
await writer.write(
encode("Content-Type: application/octet-stream\r\n"),
);
await writer.write(
encode("Access-Control-Allow-Origin: *\r\n"),
);
await writer.write(
encode("Access-Control-Allow-Private-Network: true\r\n"),
);
await writer.write(
encode(
"Access-Control-Allow-Headers: Access-Control-Request-Private-Network\r\n",
),
);
await writer.write(encode("Cache-Control: no-cache\r\n"));
await writer.write(encode("Connection: close\r\n"));
await writer.write(
encode("Transfer-Encoding: chunked\r\n\r\n"),
);
const chunk = encode(body.toUpperCase());
const size = chunk.buffer.byteLength.toString(16);
await writer.write(encode(`${size}\r\n`));
await writer.write(chunk.buffer);
await writer.write(encode("\r\n"));
await writer.write(encode("0\r\n"));
await writer.write(encode("\r\n"));
await writer.close();
await writer.closed;
}
},
close() {
console.log("Client closed");
},
abort(reason) {
console.log("Client aborted");
},
}),
).catch(console.error);
},
close() {
console.log("Host closed");
},
abort(reason) {
console.log("Host aborted", reason);
},
}),
).catch(console.error);
We've just implemented a local HTTP over TCP socket server in and from the browser.
Happy hacking. Cheers!