Skip to content
This repository has been archived by the owner on Jun 9, 2024. It is now read-only.

feat: spec compliant early hints #5

Merged
merged 20 commits into from
Oct 6, 2022
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 89 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ Draft proposal of plugin handling the HTTP 103 code.
Based on : https://github.com/fastify/fastify/issues/2683

## Install
```

```shell
npm i @fastify/early-hints
```
## Options

You can pass the following options during the plugin registration:

## Options

You can pass the following options during the plugin registration:

Expand All @@ -29,7 +28,72 @@ await fastify.register(import('@fastify/early-hints'), {

## Usage

- `eh.add`: Every call writes to the socket and returns a promise. Altought all the promises created throught the reply lifecycle are awaited in the `onSend` hook.
### Reply.writeEarlyHints

This method used to write early hints with any header you. It accepts
climba03003 marked this conversation as resolved.
Show resolved Hide resolved
either `object` or `Array` of headers and return `Promise`.

```javascript
const Fastify = require("fastify");
const eh = require("@fastify/early-hints");

const fastify = Fastify({ logger: true });
fastify.register(eh);

fastify.get("/", async (request, reply) => {
// object
await reply.writeEarlyHints({
'Content-Security-Policy': 'style-src: self;',
Link: ['</style.css>; rel=preload; as=style', '</script.js>; rel=preload; as=script']
})
// array
await reply.writeEarlyHints([
{ name: 'Content-Security-Policy', value: 'style-src: self;' },
{ name: 'Link', value: '</style.css>; rel=preload; as=style' },
{ name: 'Link', value: '</script.js>; rel=preload; as=script' },
])
return { hello: "world" };
});

const start = async () => {
try {
await fastify.listen({ port: 3000 });
fastify.log.info(`server listening on ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
```

Result

```shell
$ curl -D - http://localhost:3000
HTTP/1.1 103 Early Hints
Content-Security-Policy: style-src: self;
Link: </style.css>; rel=preload; as=style
Link: </script.js>; rel=preload; as=script

HTTP/1.1 103 Early Hints
Content-Security-Policy: style-src: self;
Link: </style.css>; rel=preload; as=style
Link: </script.js>; rel=preload; as=script

HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 17
Date: Thu, 12 Nov 2020 22:45:54 GMT
Connection: keep-alive

{"hello":"world"}
```

### Reply.writeEarlyHintsLinks

This method used to write only the `Link` header. It accepts an `Array` and
return `Promise`.

```javascript
const Fastify = require("fastify");
Expand All @@ -39,17 +103,17 @@ const fastify = Fastify({ logger: true });
fastify.register(eh);

fastify.get("/", async (request, reply) => {
reply.eh.add([
await reply.writeEarlyHintsLinks([
"Link: </style.css>; rel=preload; as=style",
"Link: </script.js>; rel=preload; as=script",
]);
await reply.eh.add([
])
await reply.writeEarlyHintsLinks([
{ href: "//example.com", rel: "preload", as: "style" },
{ href: "//example.com", rel: "preload", as: "style", cors: true },
{ href: "//example.com", rel: "preconnect" },
{ href: "//example2.com", rel: "preconnect", cors: true },
{ href: "//example3.com", rel: "preconnect", cors: "use-credentials" },
]);
])
return { hello: "world" };
});

Expand All @@ -66,7 +130,8 @@ start();
```

Result
```

```shell
$ curl -D - http://localhost:3000
HTTP/1.1 103 Early Hints
Link: </style.css>; rel=preload; as=style
Expand All @@ -90,10 +155,22 @@ Connection: keep-alive
{"hello":"world"}
```

## Browser Limitation

Currently (2022-09-29), only Chrome 103 is supporting `103 Early Hints` and
Chrome will ignore `103 Early Hints` in the following situations.

- Early Hints sent on subresource requests
- Early Hints sent on iframe navigation
- Early Hints sent on HTTP/1.1 or earlier
- Second and following Early Hints

Read more on <https://chromium.googlesource.com/chromium/src/+/master/docs/early-hints.md#103-early-hints>

## References

- https://httpwg.org/specs/rfc8297.html
- https://www.w3.org/TR/resource-hints/
- <https://httpwg.org/specs/rfc8297.html>
- <https://www.w3.org/TR/resource-hints/>

## License

Expand Down
90 changes: 55 additions & 35 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,57 +3,77 @@
const fp = require('fastify-plugin')
const formatEntry = require('./lib/formatEntry')
const CRLF = '\r\n'
const EarlyHints = `HTTP/1.1 103 Early Hint${CRLF}`

function fastifyEarlyHints (fastify, opts, next) {
if (fastify.initialConfig.http2 === true) {
return next(new Error('Early Hints cannot be used with a HTTP2 server.'))
return next(Error('Early Hints cannot be used with a HTTP2 server.'))
}

const formatEntryOpts = {
warn: opts.warn
}

function earlyHints (reply) {
const promiseBuffer = []
const serialize = function (c) {
let message = `HTTP/1.1 103 Early Hints${CRLF}`
for (let i = 0; i < c.length; i++) {
message += `${formatEntry(c[i], formatEntryOpts)}${CRLF}`
}
return `${message}${CRLF}`
}

return {
close: async function () {
if (promiseBuffer.length) {
await Promise.all(promiseBuffer)
fastify.decorateReply('writeEarlyHints', function (headers) {
Eomm marked this conversation as resolved.
Show resolved Hide resolved
const reply = this
let message = ''
if (Array.isArray(headers)) {
for (const nameValues of headers) {
if (typeof nameValues === 'object' && typeof nameValues.name === 'string' && typeof nameValues.value === 'string') {
message += `${nameValues.name}: ${nameValues.value}${CRLF}`
} else {
return Promise.reject(Error('"headers" expected to be name-value object'))
}
},
add: function (content) {
const p = new Promise(resolve => {
if (reply.raw.socket) {
reply.raw.socket.write(serialize(content), 'utf-8', resolve)
} else {
reply.raw.write(serialize(content), 'utf-8', resolve)
}
} else if (typeof headers === 'object' && headers !== null) {
for (const key of Object.keys(headers)) {
if (Array.isArray(headers[key])) {
for (const value of headers[key]) {
message += `${key}: ${value}${CRLF}`
}
})
promiseBuffer.push(p)
return p
} else {
message += `${key}: ${headers[key]}${CRLF}`
}
}
} else {
return Promise.reject(Error(`"headers" expected to be object or Array, but received ${typeof headers}`))
}
}

function onRequest (request, reply, done) {
reply.eh = earlyHints(reply)
done()
}
return new Promise(function (resolve) {
if (reply.raw.socket === null) {
Eomm marked this conversation as resolved.
Show resolved Hide resolved
resolve()
return
}
reply.raw.socket.write(`${EarlyHints}${message}${CRLF}`, 'ascii', () => {
// we do not care the message is sent or lost. Since early hints
// is metadata to instruct the clients to do something before actual
// content. It should never affect the final result if it lost.
resolve()
})
})
})

async function onSend (request, reply, payload) {
await reply.eh.close()
}
// we provide a handy method to write link header only
fastify.decorateReply('writeEarlyHintsLinks', function (links) {
const reply = this
let message = ''
for (let i = 0; i < links.length; i++) {
message += `${formatEntry(links[i], formatEntryOpts)}${CRLF}`
}

fastify.addHook('onRequest', onRequest)
fastify.addHook('onSend', onSend)
return new Promise(function (resolve) {
if (reply.raw.socket === null) {
resolve()
return
}
reply.raw.socket.write(`${EarlyHints}${message}${CRLF}`, 'ascii', () => {
// we do not care the message is sent or lost. Since early hints
// is metadata to instruct the clients to do something before actual
// content. It should never affect the final result if it lost.
resolve()
})
})
})

next()
}
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"fastify": "^4.0.0",
"standard": "^17.0.0",
"tap": "^16.0.0",
"tsd": "^0.24.1"
"tsd": "^0.24.1",
"undici": "^5.10.0"
},
"pre-commit": [
"lint",
Expand Down
14 changes: 5 additions & 9 deletions test/http2.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,16 @@ const { test } = require('tap')
const Fastify = require('fastify')
const eh = require('../index')

test('Should throw when http2 server', (t) => {
t.plan(2)
test('Should throw when http2 server', async (t) => {
t.plan(1)
const fastify = Fastify({ http2: true })
fastify.register(eh)
fastify.get('/', (req, reply) => {
reply.eh.add([
fastify.get('/', async (request, reply) => {
await reply.writeEarlyHintsLinks([
'Link: </style.css>; rel=preload; as=style',
'Link: </script.js>; rel=preload; as=script'
])
return { hello: 'world' }
})
fastify.listen({ port: 3000 }, (err) => {
t.ok(err)
t.equal(err.message, 'Early Hints cannot be used with a HTTP2 server.')
fastify.close()
})
t.rejects(fastify.ready(), 'Early Hints cannot be used with a HTTP2 server.')
})
47 changes: 0 additions & 47 deletions test/index.test.js

This file was deleted.

35 changes: 33 additions & 2 deletions test/stress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,44 @@ const Fastify = require('fastify')
const autocannon = require('autocannon')
const fastifyEarlyHints = require('../index')

test('Should not add Early Hints', t => {
test('Stress for writeEarlyHints', t => {
t.plan(8)

const fastify = Fastify({ logger: false })
fastify.register(fastifyEarlyHints)
fastify.get('/', async (request, reply) => {
await reply.eh.add([
await reply.writeEarlyHints([
{ name: 'Link', value: '</style.css>; rel=preload; as=style' },
{ name: 'Link', value: '</script.js>; rel=preload; as=script' }
])
return { hello: 'world' }
})

fastify.listen({ port: 0 }, async (err, address) => {
t.error(err)
autocannon({
url: address,
amount: 10000
}, (err, result) => {
t.error(err)
t.not(result['1xx'], 0)
t.not(result['2xx'], 0)
t.equal(result['3xx'], 0)
t.equal(result['4xx'], 0)
t.equal(result['5xx'], 0)
t.strictSame(Object.keys(result.statusCodeStats), ['103', '200'])
fastify.close()
})
})
})

test('Stress for writeEarlyHintsLinks', t => {
t.plan(8)

const fastify = Fastify({ logger: false })
fastify.register(fastifyEarlyHints)
fastify.get('/', async (request, reply) => {
await reply.writeEarlyHintsLinks([
'Link: </style.css>; rel=preload; as=style',
'Link: </script.js>; rel=preload; as=script'
])
Expand Down
Loading