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 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 {
throw Error('"headers" expected to be name-value object')
climba03003 marked this conversation as resolved.
Show resolved Hide resolved
climba03003 marked this conversation as resolved.
Show resolved Hide resolved
}
},
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 {
throw Error(`"headers" expected to be object or Array, but recieved ${typeof headers}`)
climba03003 marked this conversation as resolved.
Show resolved Hide resolved
}
}

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 instructs the clients to do action before actual
climba03003 marked this conversation as resolved.
Show resolved Hide resolved
climba03003 marked this conversation as resolved.
Show resolved Hide resolved
// 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('writeEarlyHintsLink', 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 instructs the clients to do action before actual
climba03003 marked this conversation as resolved.
Show resolved Hide resolved
// 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.writeEarlyHintsLink([
'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 writeEarlyHintsLink', t => {
t.plan(8)

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