@helia/verified-fetch 3.0.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.min.js +57 -45
- package/dist/index.min.js.map +4 -4
- package/dist/src/plugins/plugin-handle-byte-range-context.d.ts.map +1 -1
- package/dist/src/plugins/plugin-handle-byte-range-context.js +5 -0
- package/dist/src/plugins/plugin-handle-byte-range-context.js.map +1 -1
- package/dist/src/plugins/plugin-handle-car.js +2 -2
- package/dist/src/plugins/plugin-handle-car.js.map +1 -1
- package/dist/src/plugins/plugin-handle-dag-cbor.d.ts.map +1 -1
- package/dist/src/plugins/plugin-handle-dag-cbor.js +3 -2
- package/dist/src/plugins/plugin-handle-dag-cbor.js.map +1 -1
- package/dist/src/plugins/plugin-handle-dag-pb.d.ts +1 -0
- package/dist/src/plugins/plugin-handle-dag-pb.d.ts.map +1 -1
- package/dist/src/plugins/plugin-handle-dag-pb.js +63 -23
- package/dist/src/plugins/plugin-handle-dag-pb.js.map +1 -1
- package/dist/src/plugins/plugin-handle-dir-index-html.js +2 -2
- package/dist/src/plugins/plugin-handle-dir-index-html.js.map +1 -1
- package/dist/src/plugins/plugin-handle-ipns-record.js +2 -2
- package/dist/src/plugins/plugin-handle-ipns-record.js.map +1 -1
- package/dist/src/plugins/plugin-handle-json.d.ts.map +1 -1
- package/dist/src/plugins/plugin-handle-json.js +2 -3
- package/dist/src/plugins/plugin-handle-json.js.map +1 -1
- package/dist/src/plugins/plugin-handle-raw.d.ts.map +1 -1
- package/dist/src/plugins/plugin-handle-raw.js +6 -5
- package/dist/src/plugins/plugin-handle-raw.js.map +1 -1
- package/dist/src/plugins/plugin-handle-tar.js +2 -2
- package/dist/src/plugins/plugin-handle-tar.js.map +1 -1
- package/dist/src/utils/byte-range-context.d.ts +39 -16
- package/dist/src/utils/byte-range-context.d.ts.map +1 -1
- package/dist/src/utils/byte-range-context.js +305 -104
- package/dist/src/utils/byte-range-context.js.map +1 -1
- package/dist/src/utils/{set-content-type.d.ts → get-content-type.d.ts} +3 -4
- package/dist/src/utils/get-content-type.d.ts.map +1 -0
- package/dist/src/utils/{set-content-type.js → get-content-type.js} +3 -4
- package/dist/src/utils/get-content-type.js.map +1 -0
- package/dist/src/utils/responses.d.ts.map +1 -1
- package/dist/src/utils/responses.js +16 -4
- package/dist/src/utils/responses.js.map +1 -1
- package/dist/src/verified-fetch.js +1 -1
- package/dist/src/verified-fetch.js.map +1 -1
- package/package.json +1 -1
- package/src/plugins/plugin-handle-byte-range-context.ts +6 -0
- package/src/plugins/plugin-handle-car.ts +2 -2
- package/src/plugins/plugin-handle-dag-cbor.ts +4 -3
- package/src/plugins/plugin-handle-dag-pb.ts +68 -24
- package/src/plugins/plugin-handle-dir-index-html.ts +2 -2
- package/src/plugins/plugin-handle-ipns-record.ts +2 -2
- package/src/plugins/plugin-handle-json.ts +2 -3
- package/src/plugins/plugin-handle-raw.ts +7 -5
- package/src/plugins/plugin-handle-tar.ts +2 -2
- package/src/utils/byte-range-context.ts +373 -103
- package/src/utils/{set-content-type.ts → get-content-type.ts} +4 -5
- package/src/utils/responses.ts +17 -4
- package/src/verified-fetch.ts +1 -1
- package/dist/src/utils/set-content-type.d.ts.map +0 -1
- package/dist/src/utils/set-content-type.js.map +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import toBrowserReadableStream from 'it-to-browser-readablestream'
|
|
1
2
|
import { InvalidRangeError } from '../errors.js'
|
|
2
3
|
import { calculateByteRangeIndexes, getHeader } from './request-headers.js'
|
|
3
4
|
import { getContentRangeHeader } from './response-headers.js'
|
|
@@ -6,6 +7,15 @@ import type { ComponentLogger, Logger } from '@libp2p/interface'
|
|
|
6
7
|
|
|
7
8
|
type SliceableBody = Exclude<SupportedBodyTypes, ReadableStream<Uint8Array> | null>
|
|
8
9
|
|
|
10
|
+
interface RequestRange {
|
|
11
|
+
start: number | undefined
|
|
12
|
+
end: number | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ByteRange extends RequestRange {
|
|
16
|
+
size: number | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
/**
|
|
10
20
|
* Gets the body size of a given body if it's possible to calculate it synchronously.
|
|
11
21
|
*/
|
|
@@ -27,18 +37,36 @@ function getBodySizeSync (body: SupportedBodyTypes): number | null {
|
|
|
27
37
|
return null
|
|
28
38
|
}
|
|
29
39
|
|
|
30
|
-
function getByteRangeFromHeader (rangeHeader: string): { start: string, end: string } {
|
|
40
|
+
function getByteRangeFromHeader (rangeHeader: string): { ranges: Array<{ start: string | null, end: string | null }> } {
|
|
31
41
|
/**
|
|
32
|
-
* Range: bytes=<start>-<end> | bytes=<start2>- | bytes=-<end2>
|
|
42
|
+
* Range: bytes=<start>-<end> | bytes=<start2>- | bytes=-<end2> | bytes=<start1>-<end1>,<start2>-<end2>,...
|
|
33
43
|
*/
|
|
34
|
-
|
|
35
|
-
if (match?.groups == null) {
|
|
44
|
+
if (!rangeHeader.startsWith('bytes=')) {
|
|
36
45
|
throw new InvalidRangeError('Invalid range request')
|
|
37
46
|
}
|
|
38
47
|
|
|
39
|
-
const
|
|
48
|
+
const rangesStr = rangeHeader.substring(6) // Remove "bytes=" prefix
|
|
49
|
+
const rangeParts = rangesStr.split(',').map(part => part.trim())
|
|
50
|
+
const ranges: Array<{ start: string | null, end: string | null }> = []
|
|
51
|
+
|
|
52
|
+
for (const part of rangeParts) {
|
|
53
|
+
const match = part.match(/^(?<start>\d+)?-(?<end>\d+)?$/)
|
|
54
|
+
if (match?.groups == null) {
|
|
55
|
+
throw new InvalidRangeError(`Invalid range specification: ${part}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { start, end } = match.groups
|
|
59
|
+
ranges.push({
|
|
60
|
+
start: start ?? null,
|
|
61
|
+
end: end ?? null
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (ranges.length === 0) {
|
|
66
|
+
throw new InvalidRangeError('No valid ranges found')
|
|
67
|
+
}
|
|
40
68
|
|
|
41
|
-
return {
|
|
69
|
+
return { ranges }
|
|
42
70
|
}
|
|
43
71
|
|
|
44
72
|
export class ByteRangeContext {
|
|
@@ -51,66 +79,104 @@ export class ByteRangeContext {
|
|
|
51
79
|
private _body: SupportedBodyTypes = null
|
|
52
80
|
private readonly rangeRequestHeader: string | undefined
|
|
53
81
|
private readonly log: Logger
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
private
|
|
82
|
+
/**
|
|
83
|
+
* multiPartBoundary is required for multipart responses
|
|
84
|
+
*/
|
|
85
|
+
private readonly multiPartBoundary?: string
|
|
86
|
+
private readonly requestRanges: Array<{ start: number | null, end: number | null }> = []
|
|
87
|
+
private byteRanges: ByteRange[] = []
|
|
88
|
+
readonly isMultiRangeRequest: boolean = false
|
|
89
|
+
|
|
90
|
+
// to be set by isValidRangeRequest so that we don't need to re-check the byteRanges
|
|
91
|
+
private _isValidRangeRequest: boolean = false
|
|
59
92
|
|
|
60
93
|
constructor (logger: ComponentLogger, private readonly headers?: HeadersInit) {
|
|
61
94
|
this.log = logger.forComponent('helia:verified-fetch:byte-range-context')
|
|
62
95
|
this.rangeRequestHeader = getHeader(this.headers, 'Range')
|
|
96
|
+
|
|
63
97
|
if (this.rangeRequestHeader != null) {
|
|
64
98
|
this.isRangeRequest = true
|
|
65
99
|
this.log.trace('range request detected')
|
|
100
|
+
|
|
66
101
|
try {
|
|
67
|
-
const {
|
|
68
|
-
this.
|
|
69
|
-
|
|
102
|
+
const { ranges } = getByteRangeFromHeader(this.rangeRequestHeader)
|
|
103
|
+
this.isMultiRangeRequest = ranges.length > 1
|
|
104
|
+
|
|
105
|
+
this.requestRanges = ranges.map(range => ({
|
|
106
|
+
start: range.start != null ? parseInt(range.start) : null,
|
|
107
|
+
end: range.end != null ? parseInt(range.end) : null
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
this.multiPartBoundary = `multipart_byteranges_${Math.floor(Math.random() * 1000000000)}`
|
|
70
111
|
} catch (e) {
|
|
71
|
-
this.log.error('error parsing range request header
|
|
72
|
-
this.
|
|
73
|
-
this.requestRangeEnd = null
|
|
112
|
+
this.log.error('error parsing range request header - %e', e)
|
|
113
|
+
this.requestRanges = []
|
|
74
114
|
}
|
|
75
115
|
|
|
76
116
|
this.setOffsetDetails()
|
|
77
117
|
} else {
|
|
78
118
|
this.log.trace('no range request detected')
|
|
79
119
|
this.isRangeRequest = false
|
|
80
|
-
this.requestRangeStart = null
|
|
81
|
-
this.requestRangeEnd = null
|
|
82
120
|
}
|
|
83
121
|
}
|
|
84
122
|
|
|
85
|
-
public
|
|
86
|
-
this.
|
|
87
|
-
|
|
88
|
-
|
|
123
|
+
public getByteRanges (): ByteRange[] {
|
|
124
|
+
return this.byteRanges
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* You can pass a function when you need to support multi-range requests but have your own slicing logic, such as in the case of dag-pb/unixfs.
|
|
129
|
+
*
|
|
130
|
+
* @param bodyOrProvider - A supported body type or a function that returns a supported body type.
|
|
131
|
+
* @param contentType - The content type of the body.
|
|
132
|
+
*/
|
|
133
|
+
public setBody (
|
|
134
|
+
bodyOrProvider: SupportedBodyTypes | ((range: ByteRange) => AsyncGenerator<Uint8Array, void, unknown>),
|
|
135
|
+
contentType: string = 'application/octet-stream'
|
|
136
|
+
): void {
|
|
137
|
+
if (typeof bodyOrProvider === 'function') {
|
|
138
|
+
this._body = this.createRangeStream(bodyOrProvider, contentType)
|
|
139
|
+
} else {
|
|
140
|
+
this._body = bodyOrProvider
|
|
141
|
+
|
|
142
|
+
// if fileSize was already set, don't recalculate it
|
|
143
|
+
this.setFileSize(this._fileSize ?? getBodySizeSync(bodyOrProvider))
|
|
144
|
+
}
|
|
89
145
|
|
|
90
146
|
this.log.trace('set request body with fileSize %o', this._fileSize)
|
|
91
147
|
}
|
|
92
148
|
|
|
93
|
-
public getBody (): SupportedBodyTypes {
|
|
149
|
+
public getBody (responseContentType?: string): SupportedBodyTypes {
|
|
94
150
|
const body = this._body
|
|
95
151
|
if (body == null) {
|
|
96
152
|
this.log.trace('body is null')
|
|
97
153
|
return body
|
|
98
154
|
}
|
|
155
|
+
|
|
99
156
|
if (!this.isRangeRequest || !this.isValidRangeRequest) {
|
|
100
157
|
this.log.trace('returning body unmodified for non-range, or invalid range, request')
|
|
101
158
|
return body
|
|
102
159
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
160
|
+
|
|
161
|
+
if (this.isMultiRangeRequest) {
|
|
162
|
+
if (this._body instanceof ReadableStream) {
|
|
163
|
+
return this._body
|
|
164
|
+
}
|
|
165
|
+
return toBrowserReadableStream(this.getMultipartBody(responseContentType))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Single range request handling
|
|
169
|
+
if (this.byteRanges.length > 0) {
|
|
170
|
+
const range = this.byteRanges[0]
|
|
108
171
|
if (body instanceof ReadableStream) {
|
|
109
172
|
// stream should already be spliced by `unixfs.cat`
|
|
110
173
|
// TODO: if the content is not unixfs and unixfs.cat was not called, we need to slice the body here.
|
|
111
174
|
return body
|
|
112
175
|
}
|
|
113
|
-
|
|
176
|
+
if (range.start != null || range.end != null) {
|
|
177
|
+
this.log.trace('returning body with byteStart=%o, byteEnd=%o, byteSize=%o', range.start, range.end, range.size)
|
|
178
|
+
}
|
|
179
|
+
return this.getSlicedBody(body, range)
|
|
114
180
|
}
|
|
115
181
|
|
|
116
182
|
// we should not reach this point, but return body untouched.
|
|
@@ -118,16 +184,16 @@ export class ByteRangeContext {
|
|
|
118
184
|
return body
|
|
119
185
|
}
|
|
120
186
|
|
|
121
|
-
private getSlicedBody <T extends SliceableBody>(body: T): SliceableBody {
|
|
122
|
-
const offset =
|
|
187
|
+
private getSlicedBody <T extends SliceableBody>(body: T, range: ByteRange): SliceableBody {
|
|
188
|
+
const offset = range.start ?? 0
|
|
123
189
|
|
|
124
190
|
// Calculate the correct number of bytes to return
|
|
125
191
|
// For a range like bytes=1000-2000, we want exactly 1001 bytes
|
|
126
192
|
let length: number | undefined
|
|
127
193
|
|
|
128
|
-
if (
|
|
194
|
+
if (range.end != null && range.start != null) {
|
|
129
195
|
// Exact number of bytes is (end - start + 1) due to inclusive ranges
|
|
130
|
-
length =
|
|
196
|
+
length = range.end - range.start + 1
|
|
131
197
|
} else {
|
|
132
198
|
length = undefined
|
|
133
199
|
}
|
|
@@ -143,10 +209,10 @@ export class ByteRangeContext {
|
|
|
143
209
|
} else if (body instanceof ArrayBuffer || body instanceof Uint8Array) {
|
|
144
210
|
// ArrayBuffer.slice and Uint8Array.slice take start and end positions
|
|
145
211
|
return body.slice(offset, length !== undefined ? offset + length : undefined) satisfies SliceableBody
|
|
146
|
-
} else {
|
|
147
|
-
// This should never happen due to type constraints
|
|
148
|
-
return body as SliceableBody
|
|
149
212
|
}
|
|
213
|
+
|
|
214
|
+
// This should never happen due to type constraints
|
|
215
|
+
return body satisfies SliceableBody
|
|
150
216
|
}
|
|
151
217
|
|
|
152
218
|
/**
|
|
@@ -157,6 +223,7 @@ export class ByteRangeContext {
|
|
|
157
223
|
*/
|
|
158
224
|
public setFileSize (size: number | bigint | null): void {
|
|
159
225
|
this._fileSize = size != null ? Number(size) : null
|
|
226
|
+
this._isValidRangeRequest = false // body has changed, so we need to re-validate the byte ranges
|
|
160
227
|
this.log.trace('set _fileSize to %o', this._fileSize)
|
|
161
228
|
// when fileSize changes, we need to recalculate the offset details
|
|
162
229
|
this.setOffsetDetails()
|
|
@@ -166,101 +233,123 @@ export class ByteRangeContext {
|
|
|
166
233
|
return this._fileSize
|
|
167
234
|
}
|
|
168
235
|
|
|
169
|
-
private isValidByteStart (): boolean {
|
|
170
|
-
if (
|
|
171
|
-
if (
|
|
236
|
+
private isValidByteStart (byteStart: number | undefined, byteEnd: number | undefined): boolean {
|
|
237
|
+
if (byteStart != null) {
|
|
238
|
+
if (byteStart < 0) {
|
|
172
239
|
return false
|
|
173
240
|
}
|
|
174
|
-
if (this._fileSize != null &&
|
|
241
|
+
if (this._fileSize != null && byteStart >= this._fileSize) {
|
|
175
242
|
return false
|
|
176
243
|
}
|
|
177
|
-
if (
|
|
244
|
+
if (byteEnd != null && byteStart > byteEnd) {
|
|
178
245
|
return false
|
|
179
246
|
}
|
|
180
247
|
}
|
|
181
248
|
return true
|
|
182
249
|
}
|
|
183
250
|
|
|
184
|
-
private isValidByteEnd (): boolean {
|
|
185
|
-
if (
|
|
186
|
-
if (
|
|
251
|
+
private isValidByteEnd (byteStart: number | undefined, byteEnd: number | undefined): boolean {
|
|
252
|
+
if (byteEnd != null) {
|
|
253
|
+
if (byteEnd < 0) {
|
|
254
|
+
this.log.trace('invalid range request, byteEnd is less than 0')
|
|
187
255
|
return false
|
|
188
256
|
}
|
|
189
|
-
if (this._fileSize != null &&
|
|
257
|
+
if (this._fileSize != null && byteEnd >= this._fileSize) {
|
|
258
|
+
this.log.trace('invalid range request, byteEnd is greater than fileSize')
|
|
190
259
|
return false
|
|
191
260
|
}
|
|
192
|
-
if (
|
|
261
|
+
if (byteStart != null && byteEnd < byteStart) {
|
|
262
|
+
this.log.trace('invalid range request, byteEnd is less than byteStart')
|
|
193
263
|
return false
|
|
194
264
|
}
|
|
195
265
|
}
|
|
196
266
|
return true
|
|
197
267
|
}
|
|
198
268
|
|
|
269
|
+
private isValidByteRange (range: ByteRange): boolean {
|
|
270
|
+
this.log.trace('validating byte range: %o', range)
|
|
271
|
+
if (range.start != null && !this.isValidByteStart(range.start, range.end)) {
|
|
272
|
+
this.log.trace('invalid range request, byteStart is less than 0 or greater than fileSize')
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
275
|
+
if (range.end != null && !this.isValidByteEnd(range.start, range.end)) {
|
|
276
|
+
this.log.trace('invalid range request, byteEnd is less than 0 or greater than fileSize')
|
|
277
|
+
return false
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return true
|
|
281
|
+
}
|
|
282
|
+
|
|
199
283
|
/**
|
|
200
284
|
* We may get the values required to determine if this is a valid range request at different times
|
|
201
285
|
* so we need to calculate it when asked.
|
|
202
286
|
*/
|
|
203
287
|
public get isValidRangeRequest (): boolean {
|
|
204
|
-
if (
|
|
205
|
-
|
|
288
|
+
if (this._isValidRangeRequest) {
|
|
289
|
+
// prevent unnecessary re-validation of each byte range
|
|
290
|
+
return true
|
|
206
291
|
}
|
|
207
|
-
if (this.
|
|
208
|
-
this.log.trace('invalid range request, range request values not provided')
|
|
209
|
-
return false
|
|
210
|
-
}
|
|
211
|
-
if (!this.isValidByteStart()) {
|
|
212
|
-
this.log.trace('invalid range request, byteStart is less than 0 or greater than fileSize')
|
|
292
|
+
if (!this.isRangeRequest) {
|
|
213
293
|
return false
|
|
214
294
|
}
|
|
215
|
-
|
|
216
|
-
|
|
295
|
+
|
|
296
|
+
if (this.byteRanges.length === 0) {
|
|
297
|
+
this.log.trace('invalid range request, no valid ranges')
|
|
217
298
|
return false
|
|
218
299
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return false
|
|
224
|
-
} else if (this.requestRangeStart < 0) {
|
|
225
|
-
this.log.trace('invalid range request, start is less than 0')
|
|
226
|
-
return false
|
|
227
|
-
} else if (this.requestRangeEnd < 0) {
|
|
228
|
-
this.log.trace('invalid range request, end is less than 0')
|
|
229
|
-
return false
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
if (this.byteEnd == null && this.byteStart == null && this.byteSize == null) {
|
|
233
|
-
this.log.trace('invalid range request, could not calculate byteStart, byteEnd, or byteSize')
|
|
300
|
+
|
|
301
|
+
const isValid = this.byteRanges.every(range => this.isValidByteRange(range))
|
|
302
|
+
if (!isValid) {
|
|
303
|
+
this.log.trace('invalid range request, not all ranges are valid')
|
|
234
304
|
return false
|
|
235
305
|
}
|
|
236
306
|
|
|
307
|
+
this._isValidRangeRequest = true
|
|
308
|
+
|
|
237
309
|
return true
|
|
238
310
|
}
|
|
239
311
|
|
|
240
|
-
/**
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
public
|
|
246
|
-
|
|
247
|
-
|
|
312
|
+
// /**
|
|
313
|
+
// * Given all the information we have, this function returns the offset that will be used when:
|
|
314
|
+
// * 1. calling unixfs.cat
|
|
315
|
+
// * 2. slicing the body
|
|
316
|
+
// */
|
|
317
|
+
// public offset (range: ByteRange): number {
|
|
318
|
+
// if (this.byteRanges.length > 0) {
|
|
319
|
+
// return this.byteRanges[0].start ?? 0
|
|
320
|
+
// }
|
|
321
|
+
// return 0
|
|
322
|
+
// }
|
|
248
323
|
|
|
249
324
|
/**
|
|
250
325
|
* Given all the information we have, this function returns the length that will be used when:
|
|
251
326
|
* 1. calling unixfs.cat
|
|
252
327
|
* 2. slicing the body
|
|
253
328
|
*/
|
|
254
|
-
public
|
|
255
|
-
if (this.
|
|
256
|
-
|
|
257
|
-
return
|
|
329
|
+
public getLength (range?: ByteRange): number | undefined {
|
|
330
|
+
if (!this.isValidRangeRequest) {
|
|
331
|
+
this.log.error('cannot get length for invalid range request')
|
|
332
|
+
return undefined
|
|
258
333
|
}
|
|
259
|
-
|
|
260
|
-
|
|
334
|
+
|
|
335
|
+
if (this.isMultiRangeRequest && range == null) {
|
|
336
|
+
/**
|
|
337
|
+
* The content-length for a multi-range request is the sum of the lengths of all the ranges, plus the boundaries and part headers and newlines.
|
|
338
|
+
*/
|
|
339
|
+
// TODO: figure out a way to calculate the correct content-length for multi-range requests' response.
|
|
340
|
+
return undefined
|
|
261
341
|
}
|
|
342
|
+
range ??= this.byteRanges[0]
|
|
343
|
+
this.log.trace('getting length for range: %o', range)
|
|
262
344
|
|
|
263
|
-
|
|
345
|
+
if (range.end != null && range.start != null) {
|
|
346
|
+
// For a range like bytes=1000-2000, we want a length of 1001 bytes
|
|
347
|
+
return range.end - range.start + 1
|
|
348
|
+
}
|
|
349
|
+
if (range.end != null) {
|
|
350
|
+
return range.end + 1
|
|
351
|
+
}
|
|
352
|
+
return range.size
|
|
264
353
|
}
|
|
265
354
|
|
|
266
355
|
/**
|
|
@@ -273,33 +362,138 @@ export class ByteRangeContext {
|
|
|
273
362
|
* Range: bytes=<range-start>-<range-end>
|
|
274
363
|
* Range: bytes=<range-start>-
|
|
275
364
|
* Range: bytes=-<suffix-length> // must pass size so we can calculate the offset. suffix-length is the number of bytes from the end of the file.
|
|
276
|
-
*
|
|
277
|
-
* NOT SUPPORTED:
|
|
278
365
|
* Range: bytes=<range-start>-<range-end>, <range-start>-<range-end>
|
|
279
366
|
* Range: bytes=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
|
|
280
367
|
*
|
|
281
368
|
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range#directives
|
|
282
369
|
*/
|
|
283
370
|
private setOffsetDetails (): void {
|
|
284
|
-
if (this.
|
|
285
|
-
this.log.trace('
|
|
371
|
+
if (this.requestRanges.length === 0) {
|
|
372
|
+
this.log.trace('no request ranges defined')
|
|
286
373
|
return
|
|
287
374
|
}
|
|
288
375
|
|
|
289
376
|
try {
|
|
290
|
-
|
|
291
|
-
this.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
377
|
+
// Calculate byte ranges for all requests
|
|
378
|
+
this.byteRanges = this.requestRanges.map(range => {
|
|
379
|
+
const { start, end, byteSize } = calculateByteRangeIndexes(
|
|
380
|
+
range.start ?? undefined,
|
|
381
|
+
range.end ?? undefined,
|
|
382
|
+
this._fileSize ?? undefined
|
|
383
|
+
)
|
|
384
|
+
return { start, end, size: byteSize }
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
this.log.trace('set byte ranges: %o', this.byteRanges)
|
|
295
388
|
} catch (e) {
|
|
296
389
|
this.log.error('error setting offset details: %o', e)
|
|
297
|
-
this.
|
|
298
|
-
this.byteEnd = undefined
|
|
299
|
-
this.byteSize = undefined
|
|
390
|
+
this.byteRanges = []
|
|
300
391
|
}
|
|
301
392
|
}
|
|
302
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Helper to convert a SliceableBody to a Uint8Array
|
|
396
|
+
*/
|
|
397
|
+
private async convertToUint8Array (content: SliceableBody): Promise<Uint8Array> {
|
|
398
|
+
if (typeof content === 'string') {
|
|
399
|
+
return new TextEncoder().encode(content)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if ('arrayBuffer' in content && typeof content.arrayBuffer === 'function') {
|
|
403
|
+
// This is a Blob
|
|
404
|
+
const buffer = await content.arrayBuffer()
|
|
405
|
+
return new Uint8Array(buffer)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if ('byteLength' in content && !('buffer' in content)) {
|
|
409
|
+
// This is an ArrayBuffer
|
|
410
|
+
return new Uint8Array(content)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if ('buffer' in content && 'byteLength' in content && 'byteOffset' in content) {
|
|
414
|
+
// This is a Uint8Array
|
|
415
|
+
return content as Uint8Array
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
throw new Error('Unsupported content type for multipart response')
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private async * getMultipartBody (responseContentType: string = 'application/octet-stream'): AsyncIterable<Uint8Array> {
|
|
422
|
+
const body = this._body
|
|
423
|
+
if (body instanceof ReadableStream) {
|
|
424
|
+
// in the case of unixfs, the body is a readable stream, and setBody is called with a function that returns a readable stream that generates the
|
|
425
|
+
// correct multipartBody.. so we just return that body.
|
|
426
|
+
return body
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (body === null) {
|
|
430
|
+
throw new Error('Cannot create multipart body from null')
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const encoder = new TextEncoder()
|
|
434
|
+
|
|
435
|
+
for (const range of this.byteRanges) {
|
|
436
|
+
if (range.start === undefined || range.end === undefined) {
|
|
437
|
+
continue
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Calculate part headers
|
|
441
|
+
const partHeaderString =
|
|
442
|
+
`\r\n--${this.multiPartBoundary}\r\n` +
|
|
443
|
+
`Content-Type: ${responseContentType}\r\n` +
|
|
444
|
+
`Content-Range: ${getContentRangeHeader({
|
|
445
|
+
byteStart: range.start,
|
|
446
|
+
byteEnd: range.end,
|
|
447
|
+
byteSize: this._fileSize ?? undefined
|
|
448
|
+
})}\r\n\r\n`
|
|
449
|
+
|
|
450
|
+
// Convert header to Uint8Array
|
|
451
|
+
yield encoder.encode(partHeaderString)
|
|
452
|
+
|
|
453
|
+
// Get content for this range and convert to Uint8Array
|
|
454
|
+
const slicedContent = this.getSlicedBodyForRange(body, range.start, range.end)
|
|
455
|
+
yield await this.convertToUint8Array(slicedContent)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Add final this.multiPartBoundary
|
|
459
|
+
yield encoder.encode(`\r\n--${this.multiPartBoundary}--`)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private getSlicedBodyForRange<T extends SliceableBody>(
|
|
463
|
+
body: T,
|
|
464
|
+
start: number,
|
|
465
|
+
end: number
|
|
466
|
+
): SliceableBody {
|
|
467
|
+
// Calculate the correct number of bytes to return
|
|
468
|
+
// For a range like bytes=1000-2000, we want exactly 1001 bytes
|
|
469
|
+
const offset = start
|
|
470
|
+
const length = end - start + 1
|
|
471
|
+
|
|
472
|
+
this.log.trace('slicing body with offset=%o and length=%o', offset, length)
|
|
473
|
+
|
|
474
|
+
if (typeof body === 'string') {
|
|
475
|
+
return body.slice(offset, offset + length) satisfies SliceableBody
|
|
476
|
+
} else if (body instanceof Blob) {
|
|
477
|
+
return body.slice(offset, offset + length) satisfies SliceableBody
|
|
478
|
+
} else if (body instanceof ArrayBuffer || body instanceof Uint8Array) {
|
|
479
|
+
return body.slice(offset, offset + length) satisfies SliceableBody
|
|
480
|
+
} else {
|
|
481
|
+
// This should never happen due to type constraints
|
|
482
|
+
return body as SliceableBody
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Returns the content type for the response.
|
|
488
|
+
* For multipart ranges, this will be multipart/byteranges with a boundary.
|
|
489
|
+
*/
|
|
490
|
+
public getContentType (): string | undefined {
|
|
491
|
+
if (this.isMultiRangeRequest && this.isValidRangeRequest) {
|
|
492
|
+
return `multipart/byteranges; boundary=${this.multiPartBoundary}`
|
|
493
|
+
}
|
|
494
|
+
return undefined
|
|
495
|
+
}
|
|
496
|
+
|
|
303
497
|
/**
|
|
304
498
|
* This function returns the value of the "content-range" header.
|
|
305
499
|
*
|
|
@@ -313,15 +507,91 @@ export class ByteRangeContext {
|
|
|
313
507
|
*/
|
|
314
508
|
// - Content-Range: <unit> */<byteSize> // this is purposefully not in jsdoc block
|
|
315
509
|
public get contentRangeHeaderValue (): string {
|
|
510
|
+
// For multipart responses, this will be included in each part
|
|
511
|
+
// So this method is only used for single-range responses
|
|
316
512
|
if (!this.isValidRangeRequest) {
|
|
317
513
|
this.log.error('cannot get contentRangeHeaderValue for invalid range request')
|
|
318
514
|
throw new InvalidRangeError('Invalid range request')
|
|
319
515
|
}
|
|
320
516
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
517
|
+
if (this.isMultiRangeRequest) {
|
|
518
|
+
this.log.error('contentRangeHeaderValue should not be called for multipart responses')
|
|
519
|
+
throw new InvalidRangeError('Content-Range header not applicable for multipart responses')
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (this.byteRanges.length > 0) {
|
|
523
|
+
const range = this.byteRanges[0]
|
|
524
|
+
return getContentRangeHeader({
|
|
525
|
+
byteStart: range.start,
|
|
526
|
+
byteEnd: range.end,
|
|
527
|
+
byteSize: this._fileSize ?? undefined
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
throw new InvalidRangeError('No valid ranges found')
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Unified method to create a stream for either single or multi-range requests
|
|
535
|
+
private createRangeStream (
|
|
536
|
+
contentProvider: ((range: ByteRange) => AsyncGenerator<Uint8Array, void, unknown>),
|
|
537
|
+
contentType: string
|
|
538
|
+
): ReadableStream<Uint8Array> {
|
|
539
|
+
const encoder = new TextEncoder()
|
|
540
|
+
const byteRanges = this.byteRanges
|
|
541
|
+
const multiPartBoundary = this.multiPartBoundary
|
|
542
|
+
const fileSize = this._fileSize
|
|
543
|
+
const log = this.log
|
|
544
|
+
const isMultiRangeRequest = this.isMultiRangeRequest
|
|
545
|
+
|
|
546
|
+
if (byteRanges.length === 0) {
|
|
547
|
+
// TODO: create a stream with a range of *
|
|
548
|
+
log.error('Cannot create range stream with no byte ranges')
|
|
549
|
+
throw new InvalidRangeError('No valid ranges found')
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return new ReadableStream({
|
|
553
|
+
async start (controller) {
|
|
554
|
+
try {
|
|
555
|
+
// For multi-range requests, we need to handle multiple parts with headers
|
|
556
|
+
for (const range of byteRanges) {
|
|
557
|
+
// Write part header for multipart responses
|
|
558
|
+
if (isMultiRangeRequest) {
|
|
559
|
+
const partHeader =
|
|
560
|
+
`\r\n--${multiPartBoundary}\r\n` +
|
|
561
|
+
`Content-Type: ${contentType}\r\n` +
|
|
562
|
+
`Content-Range: ${getContentRangeHeader({
|
|
563
|
+
byteStart: range.start,
|
|
564
|
+
byteEnd: range.end,
|
|
565
|
+
byteSize: fileSize ?? undefined
|
|
566
|
+
})}\r\n\r\n`
|
|
567
|
+
|
|
568
|
+
controller.enqueue(encoder.encode(partHeader))
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Get and stream content for this range
|
|
572
|
+
try {
|
|
573
|
+
// Get content for this range
|
|
574
|
+
const rangeContent = contentProvider(range)
|
|
575
|
+
for await (const chunk of rangeContent) {
|
|
576
|
+
controller.enqueue(chunk)
|
|
577
|
+
}
|
|
578
|
+
} catch (err) {
|
|
579
|
+
log.error('Error processing range %o: %o', range, err)
|
|
580
|
+
throw err // Re-throw to be caught by the outer try/catch
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (isMultiRangeRequest) {
|
|
585
|
+
// Write final boundary for multipart
|
|
586
|
+
controller.enqueue(encoder.encode(`\r\n--${multiPartBoundary}--`))
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
controller.close()
|
|
590
|
+
} catch (err) {
|
|
591
|
+
log.error('Error processing range(s): %o', err)
|
|
592
|
+
controller.error(err)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
325
595
|
})
|
|
326
596
|
}
|
|
327
597
|
}
|
|
@@ -3,10 +3,9 @@ import { isPromise } from './type-guards.js'
|
|
|
3
3
|
import type { ContentTypeParser } from '../types.js'
|
|
4
4
|
import type { Logger } from '@libp2p/interface'
|
|
5
5
|
|
|
6
|
-
export interface
|
|
6
|
+
export interface GetContentTypeOptions {
|
|
7
7
|
bytes: Uint8Array
|
|
8
8
|
path: string
|
|
9
|
-
response: Response
|
|
10
9
|
defaultContentType?: string
|
|
11
10
|
contentTypeParser: ContentTypeParser | undefined
|
|
12
11
|
log: Logger
|
|
@@ -19,7 +18,7 @@ export interface SetContentTypeOptions {
|
|
|
19
18
|
filename?: string
|
|
20
19
|
}
|
|
21
20
|
|
|
22
|
-
export async function
|
|
21
|
+
export async function getContentType ({ bytes, path, contentTypeParser, log, defaultContentType = 'application/octet-stream', filename: filenameParam }: GetContentTypeOptions): Promise<string> {
|
|
23
22
|
let contentType: string | undefined
|
|
24
23
|
|
|
25
24
|
if (contentTypeParser != null) {
|
|
@@ -51,6 +50,6 @@ export async function setContentType ({ bytes, path, response, contentTypeParser
|
|
|
51
50
|
// if the content type is the default in our content-type-parser, instead, set it to the default content type provided to this function.
|
|
52
51
|
contentType = defaultContentType
|
|
53
52
|
}
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
|
|
54
|
+
return contentType ?? defaultContentType
|
|
56
55
|
}
|