Skip to content

Instantly share code, notes, and snippets.

@wesleytodd
Last active September 18, 2020 15:55
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save wesleytodd/e5642c0d39fa71bdebf8ef31ddbd5e40 to your computer and use it in GitHub Desktop.
Save wesleytodd/e5642c0d39fa71bdebf8ef31ddbd5e40 to your computer and use it in GitHub Desktop.
Just noodling on the future Node.js and http
'use strict'
// Notice no certs, one thing I have thought
// for a long time is that frameworks should
// directly have support for spinning up with
// a cert if none was provided.
require('h3xt')()
.get('/', (req) => {
// Access the associated session
// req.session
// No need to check if can push, frameworks does this
// The framework has something like a ROOT so you can
// resolve static files, but the content type negotiation
// is something I think belongs in the underlying core api
req.pushFile('favicon.ico')
req.pushJSON('/foo.json', {
message: await req.body()
})
// Frameworks could do whatever templating they saw fit,
// delivering the resulting string/buffer to `req.respond()`
// In this example I am assuming a "return response" approach,
// and the `.sendFile` call would return a `Response`
// object, and the middleware/routing layer would use that
// object to actually send
return req.sendFile('index.html', {
// template data
})
})
.listen(8443)
'use strict'
const http = require('http-next')
const fs = require('fs')
http.createServer({
allowHTTP1: true,
allowHTTP2: true,
allowHTTP3: true,
key: fs.readFileSync('localhost-privkey.pem'),
cert: fs.readFileSync('localhost-cert.pem')
})
.on('session', (session) => {
// I am not even sure what an end user would do with session.
// Is it ok to store application state on? Like if a user cookie
// was present in a request, could you load the user metadata onto
// the session object?
// Seems like a very useful feature with session pinning on your LB
// and something like session.locals = Object.create(null) where users
// could load on to.
// And none of this would apply in http1, so there is also that to consider.
session.on('stream', (stream, headers, flags) => {
// Do a push stream if allowed
// This covers the cases for no support in http1, http2 settings
// and could even go from true to false when http3's max push is hit
if (stream.pushAllowed) {
stream.pushStream({
path: '/favicon.ico'
}, (err, pushStream, headers) => {
if (err) {
throw err
}
// Even in HTTP1 noone liked the way statusCode works!
// I honeslty think we can do it all in one method signature
// again with this, it is like 90% uf use cases
pushStream.respond(200, {
'content-type': 'image/x-icon'
}, ourPreloadedFaviconBuffer)
})
}
// A conviencence method for consuming the entire body
// This covers like 90% of use cases
// Also, we could add .consumeAsJSON to map to the new .respondWithJSON?
stream.consume((body) => {
// Always check again, because if we hit the max streams this would change
// Actually, I wonder if it would be better for `stream.pushStream` to do
// this check internally then just return false if it was not allowed?
if (stream.pushAllowed) {
stream.pushStream({
path: '/foo.json'
}, (err, pushStream, headers) => {
if (err) {
throw err
}
// Again with this, it is like 90% uf use cases
pushStream.respondWithJSON({
message: body
})
})
}
// The basic response with html
stream.respondWithFile('index.html', {
// This should be infered from the file name if not specified
// 'content-type': 'text/html; charset=utf-8'
})
})
})
})
.listen(8443)
const http = require('http-next')
module.exports = function (opts) {
return new Application()
}
class Application {
constructor () {
this.stack = []
for (const method of http.METHODS) {
this[method] = this.use.bind(this, method)
}
this.server = http.createServer()
this.server.onRequest(this.onRequest)
}
use (method, path, fnc) {
this.stack.push({ path, method, fnc })
}
async onRequest (ctx) {
// Accept and parse bodies
if (ctx.method !== 'HEAD' && ctx.method !== 'GET') {
// Read body
const _body = []
for await (const chunk of ctx.body) {
_body.push(chunk)
}
let body = Buffer.from(..._body)
if (ctx.headers['content-type'] === 'application/json') {
body = JSON.parse(body.toString())
}
ctx.body = body
}
for (const layer of this.stack) {
if (layer.method && ctx.method !== layer.method) {
continue
}
if (layer.path && ctx.path !== layer.path) {
continue
}
let statusCode = 200
let headers = {}
let body = null
const ret = await layer.fnc(ctx)
if (ret === undefined) {
continue
}
if (Number.isFinite(ret)) {
statusCode = ret
let msg = ''
switch (statusCode) {
case 200:
msg = 'Success'
break
case 404:
msg = 'Not Found'
break
case 500:
msg = 'Server Error'
break
}
body = Buffer.from(msg)
headers = {
'content-type': 'text/plain',
'content-length': body.length
}
} else if (typeof ret !== 'undefined' && typeof ret === 'object') {
body = Buffer.from(JSON.stringify(ret))
headers = {
'content-type': 'application/json',
'content-length': body.length
}
} else if (typeof ret === 'string') {
body = Buffer.from(ret, 'utf8')
headers = {
'content-type': 'text/plain',
'content-length': body.length
}
}
return ctx.respondWith(statusCode, headers, body)
}
}
listen (...args) {
return this.server.listen(...args)
}
}
@ronag
Copy link

ronag commented Sep 4, 2020

stream.consume feels a bit too high level to start with.

@ronag
Copy link

ronag commented Sep 4, 2020

I think we should normalize http1 with pseudo headers.

@ronag
Copy link

ronag commented Sep 4, 2020

status code can be pseudo header

          pushStream.respond({
            ':status': 200,
            'content-type': 'image/x-icon'
          }, ourPreloadedFaviconBuffer)

@ronag
Copy link

ronag commented Sep 4, 2020

I'm still open to on('request', (req, res) => ), i.e. have a readable and writable (which are decoupled) rather than a duplex. Might have some performance advantages, e.g. in undici the pipeline/Duplex API is significantly slower since we basically need to wrap the low level readable and writable parts into a duplex.

@wesleytodd
Copy link
Author

stream.consume feels a bit too high level to start with.

Do you mean having an api at all to consume the body? Tell me more.

I think we should normalize http1 with pseudo headers.

I agree, and I think the way to do that is the end user should not see the pseudo headers at all. Not only are the confusing to beginners ("why is there a : in there?"), also by keeping all that in the underlying implementation details away we give an experience closer to what users "want" anyway (experiences like the frameworks offer).

Might have some performance advantages

If it has perf advantages I am open, can you tell me more? Is the slowdown in undici because you have to wrap it in another stream because the underlying implementation is not a duplex? Seems like if this is duplex from the bottom up, it solves that.

I think it would be a better end user experience to move toward a single ctx or req for a few reasons:

  1. Simplicity. One object's api, only one object to pass around in apps, etc.
  2. req & res are so tightly coupled, you can rarely do something with one without the other
  3. In the "new world" with multiple responses to the same request, I think keeping res around at the top level is going to make user code more difficult to follow (res1, res2, resN....)

Another note, I added a small file to illustrate what a framework version of the same code might look like. So the framework would abstract away some of the book keeping things, just greatly simplifying what you need from the first example. And the example I gave is intentionally express like.

@ronag
Copy link

ronag commented Sep 7, 2020

stream.consume feels a bit too high level to start with.

Do you mean having an api at all to consume the body? Tell me more.

I think our first job is to implement an API that something like consume can be implemented on top of. I think we should try avoiding forcing one specific high-level API as that discourages experimentation and better future ideas on the framework level.

I think we should normalize http1 with pseudo headers.

I agree, and I think the way to do that is the end user should not see the pseudo headers at all. Not only are the confusing to beginners ("why is there a : in there?"), also by keeping all that in the underlying implementation details away we give an experience closer to what users "want" anyway (experiences like the frameworks offer).

I was actually thinking in the other direction similar to my comment above. I think experimenting and finding the best user experience should be left to the frameworks like e.g. express. I think our first priority should be providing the best possible experience for frameworks.

Might have some performance advantages

If it has perf advantages I am open, can you tell me more? Is the slowdown in undici because you have to wrap it in another stream because the underlying implementation is not a duplex? Seems like if this is duplex from the bottom up, it solves that.

The problem in undici is because of wrapping. But that's usually where things end up. I personally think Duplex is a leaky/bad abstraction with bad performance since as soon as you want to do anything with either input or output you always have to re-wrap it, e.g. you usually end up with something like:

                 / Decoder \                    / Decoder \
Socket <-> Duplex           Duplex <-> Framework            Duplex <-> User
                 \ Encoder /                    \ Encoder /

Notice how we end up re-wrapping twice just in order to maintain the Duplex API. Instead of:

                 / Decoder -> Framework -> Decoder -> User
Socket <-> Duplex                                 
                 \ Encoder <- Framework <- Encoder <- User

Using a req & res model has several advantages if done right. The current problem in Node is that even though the abstraction is decoupled the implementation is coupled which is bad and confusing, i.e. the reason it's considered bad in Node is more of an issue with implementation rather than the abstraction itself.

Advantages:

  • Avoid re-wrapping as soon as some transformation needs to be applied.
  • Matches current paradigm.
  • Better decoupling and separation of concerns.

I think it would be a better end user experience to move toward a single ctx or req for a few reasons:

  1. Simplicity. One object's api, only one object to pass around in apps, etc.

I think this can be left to frameworks. Also one does not exclude the other. A Duplex is basically and object with req & res inline, i.e.

const ctx = { input: { write }, output: { read } } // explicit source and sink, { req, res }

is conceptually the same as:

const ctx = { write, read } // implicit source and sink, i.e. "duplex"
  1. req & res are so tightly coupled, you can rarely do something with one without the other

This is a problem of implementation, not abstraction.

  1. In the "new world" with multiple responses to the same request, I think keeping res around at the top level is going to make user code more difficult to follow (res1, res2, resN....)

This is new for me? How does that look?

Another note, I added a small file to illustrate what a framework version of the same code might look like. So the framework would abstract away some of the book keeping things, just greatly simplifying what you need from the first example. And the example I gave is intentionally express like.

@wesleytodd
Copy link
Author

I think our first job is to implement an API that something like consume can be implemented on top of. I think we should try avoiding forcing one specific high-level API as that discourages experimentation and better future ideas on the framework level.

I agree in principal. This specific case is well paved path and the only experimentation has been around which of the 10 packages which do the same thing you install. In the other gist from @bengl he brings up just adding this straight to ReadableStream which IMO is an even better idea.

And to group the headers normalization together, I have the same opinion, go look at the api's the frameworks offer and you will see they are basically all the same .status(), .code() or a composite method which does more than one thing along with setting the status code. This is not "high level", this is just the api consumer want. This is not the innovation space, the node core api has just been missing out on what users actually want all along.

Also, from a framework author standpoint, anything which I can leverage from "core" means less code to maintain. If the api's we see in framework-land today are consistent and can be offered in core, then it is our obligation to serve our users needs IMO (also that is the top priority in the "next 10 years of node" values conversation.

The problem in undici is because of wrapping

So I wonder in your example, what is the reason not to pass along the entire duplex but only use the one side? Is there a specific use case of transforms where this is not possible? I think that one benefit of consolidating on a single duplex with all the sugar methods for both the request and response sides would also be a way to resolve the wrapping perf hit.

Matches current paradigm.

I think as folks leverage Lambda or Serverless abstractions this is actually less and less true. One of the reasons I like moving to the single interface paradigm would be to align with platforms like this.

One popular api which I think is like { req, res } is the service worker api. That api receives a single event with a .request and methods like .respondWith which takes a request.

I think one might make a strong case that we should just follow the service worker api, would give web api parity, a backing spec, and make it easier to write universal js.

Better decoupling and separation of concerns.

This is a problem of implementation, not abstraction.

Can you provide more details on why you think this? Maybe there is some code example which would illustrate why you see these as separate concerns? Is this maybe what you see on the "client" side of this?

I think the separation is the problem today, you (an end user) never just create a response. What would you respond to without a request? I think that req.respond() recognizes the reality in which application developers live.

And to the point of "framework authors being our number 1 constituent", creating an abstraction which directly contradicts how we think frameworks will work just makes more work for the frameworks to map their single ctx back to the dual interface.

is conceptually the same as:

I think it is the same as {...write, ....read}, which is distinctly different to an end user. The difference between ctx.req.method and ctx.method, which IMO is an important difference in api.

This is new for me? How does that look?

Each push stream is a new response with the same api. In my example above I kept the var names from the http2 docs, but when this is popularized I think it will end up being called res, res1, res2` or some equally silly or confusing variable names. This is probably a very minor concern, I just like to think through these types of things at the design phase to make sure our well meaning api design will not result in real applications having confusing patterns.


To conclude, I think we are generally in agreement on the approach we need to take. I think we might have a healthy disagreement on what makes the right api's to achieve that goal, but I have no double we can find a happy middle ground on which higher level api's make it in.

@ronag
Copy link

ronag commented Sep 7, 2020

anything which I can leverage from "core" means less code to maintain

As a node core developer. Anything that can be implemented in frameworks means less code to maintain. 😄

As you notice from my comments I'm much in favor of a small core. However, this does provide motivation for something between core and the frameworks, where e.g. WG modules could come into play.

I don't quite agree with you on the Duplex thing but I think it's easier to discuss over a call.

(also that is the top priority in the "next 10 years of node" values conversation.

Maintainer experience is also on the list.

To conclude, I think we are generally in agreement on the approach we need to take.

I think we are in agreement I'm just looking for a layered approach. With a low level API which your higher level API could/would be implemented on top of either in core, WG packages or frameworks.

@wesleytodd
Copy link
Author

I think we are in agreement I'm just looking for a layered approach

Same! I think that the net layer might be where some of the things you are bringing up might live, no? My understanding was that the lowest one was what he was working on now, and that we would have a layer in between which had the http "primatives" and then we would build an api on top of that. Maybe I miss-understand what others thought of that diagram.

If the "high level/user api" box is where the http parser and everything from the current http1/2 api's lives I don't know how much that gets us. I think we need a better split there.

I think this does make it clear we need to have a firm understanding of that those lower two boxes in James diagram mean. Which is one of the reasons I wanted to know how to best contact you, because we are planning a video chat next week for him to present the work and to discuss.

@wesleytodd
Copy link
Author

TBQH, maybe we do need one more layer in there?

@ronag
Copy link

ronag commented Sep 7, 2020

TBQH, maybe we do need one more layer in there?

Yea. That's what I'm thinking.

This is my thoughts:

transport |    protocol  |            core api         |        helper api        |    libraries/frameworks
-----------------------------------------------------------------------------------------------------------
net       | http1        |                             | low level framework      |
tls       | https/http2  | low level http generic API  | high level http generic  | high level framework
quic      | http3        |                             |                          |

Essentially 3-5 layers depending on what makes sense. At least conceptually. In practice it might be difficult, e.g. I'm not sure whether the transport and protocol layers should/can be separated in all cases.

I think what you are aiming for is the "helper api" layer while I'm looking for "core api". Whether it actually makes sense to separate these is worth discussing.

reasons I wanted to know how to best contact you

I hope you got the answer?

@wesleytodd
Copy link
Author

This is a great diagram! And totally aligns with how I have grown to think about it lately. One addition I would make is to add http1 and https to the quic protocol layer so we understand what the future will look like in a complete sense.

I might also think about naming a bit more, maybe: transport, protocol, foundation, user and framework apis? My nit pick on this is that the word core is ambiguous, as in the end I think we would expose all but the framework api as a part of "node core" which would lead the double usage of "core" to confuse people.

@ronag
Copy link

ronag commented Sep 7, 2020

This is a great diagram! And totally aligns with how I have grown to think about it lately. One addition I would make is to add http1 and https to the quic protocol layer so we understand what the future will look like in a complete sense.

I might also think about naming a bit more, maybe: transport, protocol, foundation, user and framework apis? My nit pick on this is that the word core is ambiguous, as in the end I think we would expose all but the framework api as a part of "node core" which would lead the double usage of "core" to confuse people.

I agree with all of the above.

@ronag
Copy link

ronag commented Sep 7, 2020

transport protocol core working group user
net http low level generic api high level generic api framework api
tls https
quic http2
http3

@ronag
Copy link

ronag commented Sep 7, 2020

Applying this to undici it would look something like:

transport protocol core working group user
net http dispatch request, stream, connect, upgrade got
tls https

@wesleytodd
Copy link
Author

A few proposed updates from our call:

transport protocol core working group user
tcp http quic compat api high level generic api framework api
tls https
quic
httpNext.createServer({})

http.createServer({
  protocol: 'quic+http1',
  port: 1234
}).on('session', (sess) => fmw.emit('session', sess))

http.createServer({
  protocol: 'https'
}).on('session', (sess) => fmw.emit('session', sess)) 

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment