HTTP Client/Server Abstractions
By: Vinson Chuong
Tags: Nodejs, JavaScript

Today, I’m rewriting passing-notes to leverage the current features of Node.js and HTTP/2. In the process, I want to reconsider some of the other abstractions I’m using.

I recently came across the following:

Well, it turns out WebSockets are here to stay:

I got to thinking about what the abstractions for SSE, WebSockets, and HTTP/2 Server Push should be.

Server-Sent Events

A client sends a request, asking for content type text/event-stream , and the server responds with multiple text messages over time. The frequency of messages is dictated by the server.

From the point of view of the client, messages can come at any time, and the client might want to perform an action whenever a message arrives. This sounds like an event listener or a stream or an iterator:

for await (const message of stream) {

// perform actions

}

Adding in some hypothetical code for the initial HTTP request/response:

const response = sendRequest({

method: 'GET',

url: 'http://example.com',

headers: {

'Accept': 'text/event-stream'

}

}) for await (const message of response.body) {

// perform actions

}

and:

respondToRequests(request => {

const body = new stream.Readable()



setInterval(() => {

body.push('data: Ping



')

}, 500)



return {

status: 200,

headers: {

'Content-Type': 'text/event-stream'

},

body

}

})

The actual code for the client will be different as browsers have native support for SSE via the EventSource interface.

WebSockets

The HTTP interaction to initiate a WebSockets connection is similar to that of SSE. The client requests a WebSocket connection and the server responds in the affirmative. This time however, both client and server end up with a socket that can both send and receive data.

Again, browsers provide a WebSocket interface for connecting to servers.

As far as how to define an abstraction for this in code, the first thing that comes to mind is to make it similar to the one for SSE:

const outgoingStream = new stream.Readable() const response = sendRequest({

method: 'GET',

url: 'http://example.com',

headers: {

'Upgrade': 'websocket',

'Connection': 'Upgrade',

'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',

'Sec-WebSocket-Version': '13'

},

body: outgoingStream

}) const incomingStream = response.body outgoingStream.push('Hello!')

for await (const incomingMessage of incomingStream) {

console.log(incomingMessage)

outgoingStream.push('Hello!')

}

and:

respondToRequests(request => {

const incomingStream = request.body

const outgoingStream = new stream.Readable()



incomingStream.on('data', incomingMessage => {

outgoingStream.push('Received: ${incomingMessage}')

}, 500)



return {

status: 101,

headers: {

'Upgrade': 'websocket'

'Connection': 'Upgrade',

'Sec-WebSocket-Accept: 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='

},

body: outgoingStream

}

})

The abstraction feels a little strange, as technically, WebSocket frames have nothing to do with the body of an HTTP request/response. But, I feel it well reflects the symmetry of the different forms of HTTP communication. The client can send one chunk of data or a stream of chunks over time. The server can also send one chunk of data or stream of chunks over time.

Alternatively, the client could receive a duplex stream in the response object, and the server could create and return a duplex stream in the response object. I think I like the former approach better since with this approach, the client can put anything in the request body, which would make the entire HTTP interaction invalid.

On one hand, it doesn’t make much sense to think too hard about the representation of such a low level abstraction since application developers tend to work several levels higher. But, decisions at this level affect decisions made in the design of the higher levels.

gRPC

gRPC is a protocol implemented on top of HTTP/2. Though I’m having some trouble finding an example of what its protocol upgrade handshake looks like, it’s programming abstraction is similar to what I have above.

HTTP/2 Server Push

For developers, there’s not much of a difference between writing code for HTTP/1.1 and writing code for HTTP/2. The main difference is the server being able to push responses for anticipated future requests.

When a client sends a request, the server may decide to, in addition to responding to that request, send responses for other requests that the client hasn’t sent yet.

respondToRequests(request => {

return [

{ status: 200, headers: {}, body: '<!html>...' },

[

{ method: 'GET', url: '/index.js', headers: {}, body: '' },

{ status: 200, headers: {}, body: '...' }

],

[

{ method: 'GET', url: '/index.css', headers: {}, body: '' },

{ status: 200, headers: {}, body: '...' }

]

]

})

In terms of abstraction, the server can return the response for the requested resource as well as other request-response pairs, all in an iterable.

Starting the Server

When designing programming abstractions, there’s often a trade off between ease of use and flexibility/re-usability. That trade off becomes most apparent when dealing with side-effects.

For example, should respondToRequests start an HTTP server on a specific port or just return a function that can be passed into http.createServer ?

The former would take less code to use. However, it would be more challenging to use in automated tests because of the fixed port; a fixed port means only one server can be running at a time, making running tests in parallel impossible. To make this possible, either respondToRequests includes additional logic to find open ports, or takes a port as an argument, leaving that side-effect to be handled elsewhere.

For the latter, the output of respondToRequests can be flexibly used to start an HTTP/1.1 server or start an HTTP/2 server or even a fake server. The main downside is that the user would be responsible for writing code to do one of them.

I prefer to have many small tools that each do just enough to be useful. That way, I have to flexibility to combine as few or as many of them together as I want to meet different scenarios. For the scenarios that I run into a lot, I could decide to publish a bigger tool.

Choosing Between Encrypted vs Unencrypted HTTP/1.1 and HTTP/2

I’d prefer to be able to just always use encrypted HTTP/2, but most cloud hosting providers still don’t support it.

Cloud providers typically use unencrypted HTTP/1.1 between the app and the provider and encrypted HTTP/1.1 between the provider and the end user. In this way, apps are forced to run HTTP/1.1 servers.

Services like CloudFlare try to work around this limitation to offer HTTP/2 server push to end users. Apps can opt in by setting specific headers to indicate to CloudFlare which resources should be pushed. It would be nice if there could be a programming abstraction where HTTP/2 server push just works, both in development and in production.

I realized that I didn’t know how clients and servers negotiate the correct protocol to use. So, I did some experimentation with cURL.

Before making a request, we need a URL; that URL starts with a protocol, two of which are http: and https: . http: is assumed to be unencrypted HTTP/1.1. When a client uses https: , the server uses ALPN to notify the client of whether it supports HTTP/1.1 and/or HTTP/2, and the client picks one of them. This means that the client chooses the protocol.

So, I can in theory have a server that can accommodate all of the above.

Hot Reloading

I realized that I didn’t really understand how webpack-style hot module replacement works. So, I did some reading.

In a scenario where the code consists only of pure functions, just re-importing files that change is sufficient. But, if there is a file that manages state local to just that file, simply re-importing that file would cause that state to be lost. Custom code can be written to reconcile that existing state with any updated code, hence the need for module.hot.accept .

So, either hot reloading can account for more kinds of application code or application code can just conform to a more restrictive set of rules to be reload-able — e.g. not having any state.

I wrote a simple first pass implementation: