Skip to content

Commit 101ba57

Browse files
authored
feat: Web Stream API (fastify#5286)
* feat: Web Stream API * chore: update error PR number * test: skip test when not available * test: exit when skip test * test: use Readable.toWeb instead * docs: update ToC and onSend hook * refactor: toString * refactor: direct for-of payload.headers * test: ensure compatibility of third-party Response * refactor: reduce toString call
1 parent bdd647d commit 101ba57

File tree

7 files changed

+336
-10
lines changed

7 files changed

+336
-10
lines changed

docs/Reference/Errors.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
- [FST_ERR_LOG_INVALID_DESTINATION](#fst_err_log_invalid_destination)
4646
- [FST_ERR_LOG_INVALID_LOGGER](#fst_err_log_invalid_logger)
4747
- [FST_ERR_REP_INVALID_PAYLOAD_TYPE](#fst_err_rep_invalid_payload_type)
48+
- [FST_ERR_REP_RESPONSE_BODY_CONSUMED](#fst_err_rep_response_body_consumed)
4849
- [FST_ERR_REP_ALREADY_SENT](#fst_err_rep_already_sent)
4950
- [FST_ERR_REP_SENT_VALUE](#fst_err_rep_sent_value)
5051
- [FST_ERR_SEND_INSIDE_ONERR](#fst_err_send_inside_onerr)
@@ -312,6 +313,8 @@ Below is a table with all the error codes that Fastify uses.
312313
| <a id="fst_err_log_invalid_destination">FST_ERR_LOG_INVALID_DESTINATION</a> | The logger does not accept the specified destination. | Use a `'stream'` or a `'file'` as the destination. | [#1168](https://github.com/fastify/fastify/pull/1168) |
313314
| <a id="fst_err_log_invalid_logger">FST_ERR_LOG_INVALID_LOGGER</a> | The logger should have all these methods: `'info'`, `'error'`, `'debug'`, `'fatal'`, `'warn'`, `'trace'`, `'child'`. | Use a logger with all the required methods. | [#4520](https://github.com/fastify/fastify/pull/4520) |
314315
| <a id="fst_err_rep_invalid_payload_type">FST_ERR_REP_INVALID_PAYLOAD_TYPE</a> | Reply payload can be either a `string` or a `Buffer`. | Use a `string` or a `Buffer` for the payload. | [#1168](https://github.com/fastify/fastify/pull/1168) |
316+
| <a id="fst_err_rep_response_body_consumed">FST_ERR_REP_RESPONSE_BODY_CONSUMED</a> | Using `Response` as reply payload
317+
but the body is being consumed. | Make sure you don't consume the `Response.body` | [#5286](https://github.com/fastify/fastify/pull/5286) |
315318
| <a id="fst_err_rep_already_sent">FST_ERR_REP_ALREADY_SENT</a> | A response was already sent. | - | [#1336](https://github.com/fastify/fastify/pull/1336) |
316319
| <a id="fst_err_rep_sent_value">FST_ERR_REP_SENT_VALUE</a> | The only possible value for `reply.sent` is `true`. | - | [#1336](https://github.com/fastify/fastify/pull/1336) |
317320
| <a id="fst_err_send_inside_onerr">FST_ERR_SEND_INSIDE_ONERR</a> | You cannot use `send` inside the `onError` hook. | - | [#1348](https://github.com/fastify/fastify/pull/1348) |

docs/Reference/Hooks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ fastify.addHook('onSend', (request, reply, payload, done) => {
232232
> `null`.
233233
234234
Note: If you change the payload, you may only change it to a `string`, a
235-
`Buffer`, a `stream`, or `null`.
235+
`Buffer`, a `stream`, a `ReadableStream`, a `Response`, or `null`.
236236

237237

238238
### onResponse

docs/Reference/Reply.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
- [Strings](#strings)
3434
- [Streams](#streams)
3535
- [Buffers](#buffers)
36+
- [ReadableStream](#send-readablestream)
37+
- [Response](#send-response)
3638
- [Errors](#errors)
3739
- [Type of the final payload](#type-of-the-final-payload)
3840
- [Async-Await and Promises](#async-await-and-promises)
@@ -756,6 +758,52 @@ fastify.get('/streams', function (request, reply) {
756758
})
757759
```
758760

761+
#### ReadableStream
762+
<a id="send-readablestream"></a>
763+
764+
`ReadableStream` will be treated as a node stream mentioned above,
765+
the content is considered to be pre-serialized, so they will be
766+
sent unmodified without response validation.
767+
768+
```js
769+
const fs = require('node:fs')
770+
const { ReadableStream } = require('node:stream/web')
771+
fastify.get('/streams', function (request, reply) {
772+
const stream = fs.createReadStream('some-file')
773+
reply.header('Content-Type', 'application/octet-stream')
774+
reply.send(ReadableStream.from(stream))
775+
})
776+
```
777+
778+
#### Response
779+
<a id="send-response"></a>
780+
781+
`Response` allows to manage the reply payload, status code and
782+
headers in one place. The payload provided inside `Response` is
783+
considered to be pre-serialized, so they will be sent unmodified
784+
without response validation.
785+
786+
Plese be aware when using `Response`, the status code and headers
787+
will not directly reflect to `reply.statusCode` and `reply.getHeaders()`.
788+
Such behavior is based on `Response` only allow `readonly` status
789+
code and headers. The data is not allow to be bi-direction editing,
790+
and may confuse when checking the `payload` in `onSend` hooks.
791+
792+
```js
793+
const fs = require('node:fs')
794+
const { ReadableStream } = require('node:stream/web')
795+
fastify.get('/streams', function (request, reply) {
796+
const stream = fs.createReadStream('some-file')
797+
const readableStream = ReadableStream.from(stream)
798+
const response = new Response(readableStream, {
799+
status: 200,
800+
headers: { 'content-type': 'application/octet-stream' }
801+
})
802+
reply.send(response)
803+
})
804+
```
805+
806+
759807
#### Errors
760808
<a id="errors"></a>
761809

lib/errors.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ const codes = {
212212
500,
213213
TypeError
214214
),
215+
FST_ERR_REP_RESPONSE_BODY_CONSUMED: createError(
216+
'FST_ERR_REP_RESPONSE_BODY_CONSUMED',
217+
'Response.body is already consumed.'
218+
),
215219
FST_ERR_REP_ALREADY_SENT: createError(
216220
'FST_ERR_REP_ALREADY_SENT',
217221
'Reply was already sent, did you forget to "return reply" in "%s" (%s)?'

lib/reply.js

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

33
const eos = require('node:stream').finished
4+
const Readable = require('node:stream').Readable
45

56
const {
67
kFourOhFourContext,
@@ -44,6 +45,7 @@ const CONTENT_TYPE = {
4445
}
4546
const {
4647
FST_ERR_REP_INVALID_PAYLOAD_TYPE,
48+
FST_ERR_REP_RESPONSE_BODY_CONSUMED,
4749
FST_ERR_REP_ALREADY_SENT,
4850
FST_ERR_REP_SENT_VALUE,
4951
FST_ERR_SEND_INSIDE_ONERR,
@@ -55,6 +57,8 @@ const {
5557
} = require('./errors')
5658
const { FSTDEP010, FSTDEP013, FSTDEP019, FSTDEP020 } = require('./warnings')
5759

60+
const toString = Object.prototype.toString
61+
5862
function Reply (res, request, log) {
5963
this.raw = res
6064
this[kReplySerializer] = null
@@ -163,7 +167,14 @@ Reply.prototype.send = function (payload) {
163167
const hasContentType = contentType !== undefined
164168

165169
if (payload !== null) {
166-
if (typeof payload.pipe === 'function') {
170+
if (
171+
// node:stream
172+
typeof payload.pipe === 'function' ||
173+
// node:stream/web
174+
typeof payload.getReader === 'function' ||
175+
// Response
176+
toString.call(payload) === '[object Response]'
177+
) {
167178
onSendHook(this, payload)
168179
return this
169180
}
@@ -570,7 +581,6 @@ function safeWriteHead (reply, statusCode) {
570581
function onSendEnd (reply, payload) {
571582
const res = reply.raw
572583
const req = reply.request
573-
const statusCode = res.statusCode
574584

575585
// we check if we need to update the trailers header and set it
576586
if (reply[kReplyTrailers] !== null) {
@@ -586,6 +596,17 @@ function onSendEnd (reply, payload) {
586596
reply.header('Trailer', header.trim())
587597
}
588598

599+
// since Response contain status code, we need to update before
600+
// any action that used statusCode
601+
const isResponse = toString.call(payload) === '[object Response]'
602+
if (isResponse) {
603+
// https://developer.mozilla.org/en-US/docs/Web/API/Response/status
604+
if (typeof payload.status === 'number') {
605+
reply.code(payload.status)
606+
}
607+
}
608+
const statusCode = res.statusCode
609+
589610
if (payload === undefined || payload === null) {
590611
// according to https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2
591612
// we cannot send a content-length for 304 and 204, and all status code
@@ -617,11 +638,38 @@ function onSendEnd (reply, payload) {
617638
return
618639
}
619640

641+
// node:stream
620642
if (typeof payload.pipe === 'function') {
621643
sendStream(payload, res, reply)
622644
return
623645
}
624646

647+
// node:stream/web
648+
if (typeof payload.getReader === 'function') {
649+
sendWebStream(payload, res, reply)
650+
return
651+
}
652+
653+
// Response
654+
if (isResponse) {
655+
// https://developer.mozilla.org/en-US/docs/Web/API/Response/headers
656+
if (typeof payload.headers === 'object' && typeof payload.headers.forEach === 'function') {
657+
for (const [headerName, headerValue] of payload.headers) {
658+
reply.header(headerName, headerValue)
659+
}
660+
}
661+
662+
// https://developer.mozilla.org/en-US/docs/Web/API/Response/body
663+
if (payload.body != null) {
664+
if (payload.bodyUsed) {
665+
throw new FST_ERR_REP_RESPONSE_BODY_CONSUMED()
666+
}
667+
// Response.body always a ReadableStream
668+
sendWebStream(payload.body, res, reply)
669+
}
670+
return
671+
}
672+
625673
if (typeof payload !== 'string' && !Buffer.isBuffer(payload)) {
626674
throw new FST_ERR_REP_INVALID_PAYLOAD_TYPE(typeof payload)
627675
}
@@ -654,6 +702,11 @@ function logStreamError (logger, err, res) {
654702
}
655703
}
656704

705+
function sendWebStream (payload, res, reply) {
706+
const nodeStream = Readable.fromWeb(payload)
707+
sendStream(nodeStream, res, reply)
708+
}
709+
657710
function sendStream (payload, res, reply) {
658711
let sourceOpen = true
659712
let errorLogged = false

test/internals/errors.test.js

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const errors = require('../../lib/errors')
55
const { readFileSync } = require('node:fs')
66
const { resolve } = require('node:path')
77

8-
test('should expose 78 errors', t => {
8+
test('should expose 79 errors', t => {
99
t.plan(1)
1010
const exportedKeys = Object.keys(errors)
1111
let counter = 0
@@ -14,11 +14,11 @@ test('should expose 78 errors', t => {
1414
counter++
1515
}
1616
}
17-
t.equal(counter, 78)
17+
t.equal(counter, 79)
1818
})
1919

2020
test('ensure name and codes of Errors are identical', t => {
21-
t.plan(78)
21+
t.plan(79)
2222
const exportedKeys = Object.keys(errors)
2323
for (const key of exportedKeys) {
2424
if (errors[key].name === 'FastifyError') {
@@ -337,6 +337,16 @@ test('FST_ERR_REP_INVALID_PAYLOAD_TYPE', t => {
337337
t.ok(error instanceof TypeError)
338338
})
339339

340+
test('FST_ERR_REP_RESPONSE_BODY_CONSUMED', t => {
341+
t.plan(5)
342+
const error = new errors.FST_ERR_REP_RESPONSE_BODY_CONSUMED()
343+
t.equal(error.name, 'FastifyError')
344+
t.equal(error.code, 'FST_ERR_REP_RESPONSE_BODY_CONSUMED')
345+
t.equal(error.message, 'Response.body is already consumed.')
346+
t.equal(error.statusCode, 500)
347+
t.ok(error instanceof Error)
348+
})
349+
340350
test('FST_ERR_REP_ALREADY_SENT', t => {
341351
t.plan(5)
342352
const error = new errors.FST_ERR_REP_ALREADY_SENT('/hello', 'GET')
@@ -818,7 +828,7 @@ test('FST_ERR_LISTEN_OPTIONS_INVALID', t => {
818828
})
819829

820830
test('Ensure that all errors are in Errors.md TOC', t => {
821-
t.plan(78)
831+
t.plan(79)
822832
const errorsMd = readFileSync(resolve(__dirname, '../../docs/Reference/Errors.md'), 'utf8')
823833

824834
const exportedKeys = Object.keys(errors)
@@ -830,7 +840,7 @@ test('Ensure that all errors are in Errors.md TOC', t => {
830840
})
831841

832842
test('Ensure that non-existing errors are not in Errors.md TOC', t => {
833-
t.plan(78)
843+
t.plan(79)
834844
const errorsMd = readFileSync(resolve(__dirname, '../../docs/Reference/Errors.md'), 'utf8')
835845

836846
const matchRE = / {4}- \[([A-Z0-9_]+)\]\(#[a-z0-9_]+\)/g
@@ -843,7 +853,7 @@ test('Ensure that non-existing errors are not in Errors.md TOC', t => {
843853
})
844854

845855
test('Ensure that all errors are in Errors.md documented', t => {
846-
t.plan(78)
856+
t.plan(79)
847857
const errorsMd = readFileSync(resolve(__dirname, '../../docs/Reference/Errors.md'), 'utf8')
848858

849859
const exportedKeys = Object.keys(errors)
@@ -855,7 +865,7 @@ test('Ensure that all errors are in Errors.md documented', t => {
855865
})
856866

857867
test('Ensure that non-existing errors are not in Errors.md documented', t => {
858-
t.plan(78)
868+
t.plan(79)
859869
const errorsMd = readFileSync(resolve(__dirname, '../../docs/Reference/Errors.md'), 'utf8')
860870

861871
const matchRE = /<a id="[0-9a-zA-Z_]+">([0-9a-zA-Z_]+)<\/a>/g

0 commit comments

Comments
 (0)