Skip to content
This repository was archived by the owner on Feb 12, 2024. It is now read-only.

Commit 48a8e75

Browse files
lidelAlan Shaw
authored and
Alan Shaw
committed
feat(gateway): add streaming, conditional and range requests (#1989)
This change simplifies code responsible for streaming response and makes the streaming actually work by telling the payload compression stream to flush its content on every read(). (previous version was buffering entire thing in Hapi's compressor memory) We also do content-type detection based on the beginning of the stream by peeking at first `fileType.minimumBytes` bytes. - Switched from deprecated `hapi` and `joi` to `@hapi/hapi` and `@hapi/joi` - Added support for Conditional Requests (RFC7232) - Returning `304 Not Modified` if `If-None-Match` is a CID matching `Etag` - Added `Last-Modified` to `/ipfs/` responses (improves client-side caching) - Always returning `304 Not Modified` when `If-Modified-Since` is present for immutable `/ipfs/` - Added support for Byte Range requests (RFC7233, Section-2.1) - Added support for `?filename=` parameter (improves downloads of raw cids) License: MIT Signed-off-by: Marcin Rataj <[email protected]>
1 parent bd3ade6 commit 48a8e75

File tree

4 files changed

+359
-49
lines changed

4 files changed

+359
-49
lines changed

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"stream-to-promise": "^2.2.0"
8383
},
8484
"dependencies": {
85+
"@hapi/ammo": "^3.1.0",
8586
"@hapi/hapi": "^18.3.1",
8687
"@hapi/joi": "^15.0.1",
8788
"async": "^2.6.1",
@@ -90,6 +91,7 @@
9091
"bl": "^3.0.0",
9192
"boom": "^7.2.0",
9293
"bs58": "^4.0.1",
94+
"buffer-peek-stream": "^1.0.1",
9395
"byteman": "^1.3.5",
9496
"cid-tool": "~0.2.0",
9597
"cids": "~0.5.8",

src/http/gateway/resources/gateway.js

+110-48
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
const debug = require('debug')
44
const log = debug('ipfs:http-gateway')
55
log.error = debug('ipfs:http-gateway:error')
6-
const pull = require('pull-stream')
7-
const pushable = require('pull-pushable')
8-
const toStream = require('pull-stream-to-stream')
6+
97
const fileType = require('file-type')
108
const mime = require('mime-types')
119
const { PassThrough } = require('readable-stream')
1210
const Boom = require('boom')
11+
const Ammo = require('@hapi/ammo') // HTTP Range processing utilities
12+
const peek = require('buffer-peek-stream')
1313

1414
const { resolver } = require('ipfs-http-response')
1515
const PathUtils = require('../utils/path')
@@ -30,6 +30,20 @@ function detectContentType (ref, chunk) {
3030
return mime.contentType(mimeType)
3131
}
3232

33+
// Enable streaming of compressed payload
34+
// https://github.com/hapijs/hapi/issues/3599
35+
class ResponseStream extends PassThrough {
36+
_read (size) {
37+
super._read(size)
38+
if (this._compressor) {
39+
this._compressor.flush()
40+
}
41+
}
42+
setCompressor (compressor) {
43+
this._compressor = compressor
44+
}
45+
}
46+
3347
module.exports = {
3448
checkCID (request, h) {
3549
if (!request.params.cid) {
@@ -85,66 +99,114 @@ module.exports = {
8599
return h.redirect(PathUtils.removeTrailingSlash(ref)).permanent(true)
86100
}
87101

88-
return new Promise((resolve, reject) => {
89-
let pusher
90-
let started = false
102+
// Support If-None-Match & Etag (Conditional Requests from RFC7232)
103+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
104+
const etag = `"${data.cid}"`
105+
const cachedEtag = request.headers['if-none-match']
106+
if (cachedEtag === etag || cachedEtag === `W/${etag}`) {
107+
return h.response().code(304) // Not Modified
108+
}
91109

92-
pull(
93-
ipfs.catPullStream(data.cid),
94-
pull.drain(
95-
chunk => {
96-
if (!started) {
97-
started = true
98-
pusher = pushable()
99-
const res = h.response(toStream.source(pusher).pipe(new PassThrough()))
110+
// Immutable content produces 304 Not Modified for all values of If-Modified-Since
111+
if (ref.startsWith('/ipfs/') && request.headers['if-modified-since']) {
112+
return h.response().code(304) // Not Modified
113+
}
100114

101-
// Etag maps directly to an identifier for a specific version of a resource
102-
res.header('Etag', `"${data.cid}"`)
115+
// This necessary to set correct Content-Length and validate Range requests
116+
// Note: we need `size` (raw data), not `cumulativeSize` (data + DAGNodes)
117+
const { size } = await ipfs.files.stat(`/ipfs/${data.cid}`)
118+
119+
// Handle Byte Range requests (https://tools.ietf.org/html/rfc7233#section-2.1)
120+
const catOptions = {}
121+
let rangeResponse = false
122+
if (request.headers.range) {
123+
// If-Range is respected (when present), but we compare it only against Etag
124+
// (Last-Modified date is too weak for IPFS use cases)
125+
if (!request.headers['if-range'] || request.headers['if-range'] === etag) {
126+
const ranges = Ammo.header(request.headers.range, size)
127+
if (!ranges) {
128+
const error = Boom.rangeNotSatisfiable()
129+
error.output.headers['content-range'] = `bytes */${size}`
130+
throw error
131+
}
132+
133+
if (ranges.length === 1) { // Ignore requests for multiple ranges (hard to map to ipfs.cat and not used in practice)
134+
rangeResponse = true
135+
const range = ranges[0]
136+
catOptions.offset = range.from
137+
catOptions.length = (range.to - range.from + 1)
138+
}
139+
}
140+
}
103141

104-
// Set headers specific to the immutable namespace
105-
if (ref.startsWith('/ipfs/')) {
106-
res.header('Cache-Control', 'public, max-age=29030400, immutable')
107-
}
142+
const rawStream = ipfs.catReadableStream(data.cid, catOptions)
143+
const responseStream = new ResponseStream()
108144

109-
const contentType = detectContentType(ref, chunk)
145+
// Pass-through Content-Type sniffing over initial bytes
146+
const { peekedStream, contentType } = await new Promise((resolve, reject) => {
147+
const peekBytes = fileType.minimumBytes
148+
peek(rawStream, peekBytes, (err, streamHead, peekedStream) => {
149+
if (err) {
150+
log.error(err)
151+
return reject(err)
152+
}
153+
resolve({ peekedStream, contentType: detectContentType(ref, streamHead) })
154+
})
155+
})
110156

111-
log('ref ', ref)
112-
log('mime-type ', contentType)
157+
peekedStream.pipe(responseStream)
113158

114-
if (contentType) {
115-
log('writing content-type header')
116-
res.header('Content-Type', contentType)
117-
}
159+
const res = h.response(responseStream).code(rangeResponse ? 206 : 200)
118160

119-
resolve(res)
120-
}
121-
pusher.push(chunk)
122-
},
123-
err => {
124-
if (err) {
125-
log.error(err)
126-
127-
// We already started flowing, abort the stream
128-
if (started) {
129-
return pusher.end(err)
130-
}
131-
132-
return reject(err)
133-
}
161+
// Etag maps directly to an identifier for a specific version of a resource
162+
// and enables smart client-side caching thanks to If-None-Match
163+
res.header('etag', etag)
134164

135-
pusher.end()
136-
}
137-
)
138-
)
139-
})
165+
// Set headers specific to the immutable namespace
166+
if (ref.startsWith('/ipfs/')) {
167+
res.header('Cache-Control', 'public, max-age=29030400, immutable')
168+
}
169+
170+
log('ref ', ref)
171+
log('content-type ', contentType)
172+
173+
if (contentType) {
174+
log('writing content-type header')
175+
res.header('Content-Type', contentType)
176+
}
177+
178+
if (rangeResponse) {
179+
const from = catOptions.offset
180+
const to = catOptions.offset + catOptions.length - 1
181+
res.header('Content-Range', `bytes ${from}-${to}/${size}`)
182+
res.header('Content-Length', catOptions.length)
183+
} else {
184+
// Announce support for Range requests
185+
res.header('Accept-Ranges', 'bytes')
186+
res.header('Content-Length', size)
187+
}
188+
189+
// Support Content-Disposition via ?filename=foo parameter
190+
// (useful for browser vendor to download raw CID into custom filename)
191+
// Source: https://github.com/ipfs/go-ipfs/blob/v0.4.20/core/corehttp/gateway_handler.go#L232-L236
192+
if (request.query.filename) {
193+
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(request.query.filename)}`)
194+
}
195+
196+
return res
140197
},
141198

142199
afterHandler (request, h) {
143200
const { response } = request
144-
if (response.statusCode === 200) {
201+
// Add headers to successfult responses (regular or range)
202+
if (response.statusCode === 200 || response.statusCode === 206) {
145203
const { ref } = request.pre.args
146204
response.header('X-Ipfs-Path', ref)
147205
if (ref.startsWith('/ipfs/')) {
206+
// "set modtime to a really long time ago, since files are immutable and should stay cached"
207+
// Source: https://github.com/ipfs/go-ipfs/blob/v0.4.20/core/corehttp/gateway_handler.go#L228-L229
208+
response.header('Last-Modified', 'Thu, 01 Jan 1970 00:00:01 GMT')
209+
// Suborigins: https://github.com/ipfs/in-web-browsers/issues/66
148210
const rootCid = ref.split('/')[2]
149211
const ipfsOrigin = cidToString(rootCid, { base: 'base32' })
150212
response.header('Suborigin', 'ipfs000' + ipfsOrigin)

src/http/gateway/routes/gateway.js

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ module.exports = {
1010
pre: [
1111
{ method: resources.gateway.checkCID, assign: 'args' }
1212
],
13+
response: {
14+
ranges: false // disable built-in support, we do it manually
15+
},
1316
ext: {
1417
onPostHandler: { method: resources.gateway.afterHandler }
1518
}

0 commit comments

Comments
 (0)