@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.
Files changed (55) hide show
  1. package/dist/index.min.js +57 -45
  2. package/dist/index.min.js.map +4 -4
  3. package/dist/src/plugins/plugin-handle-byte-range-context.d.ts.map +1 -1
  4. package/dist/src/plugins/plugin-handle-byte-range-context.js +5 -0
  5. package/dist/src/plugins/plugin-handle-byte-range-context.js.map +1 -1
  6. package/dist/src/plugins/plugin-handle-car.js +2 -2
  7. package/dist/src/plugins/plugin-handle-car.js.map +1 -1
  8. package/dist/src/plugins/plugin-handle-dag-cbor.d.ts.map +1 -1
  9. package/dist/src/plugins/plugin-handle-dag-cbor.js +3 -2
  10. package/dist/src/plugins/plugin-handle-dag-cbor.js.map +1 -1
  11. package/dist/src/plugins/plugin-handle-dag-pb.d.ts +1 -0
  12. package/dist/src/plugins/plugin-handle-dag-pb.d.ts.map +1 -1
  13. package/dist/src/plugins/plugin-handle-dag-pb.js +63 -23
  14. package/dist/src/plugins/plugin-handle-dag-pb.js.map +1 -1
  15. package/dist/src/plugins/plugin-handle-dir-index-html.js +2 -2
  16. package/dist/src/plugins/plugin-handle-dir-index-html.js.map +1 -1
  17. package/dist/src/plugins/plugin-handle-ipns-record.js +2 -2
  18. package/dist/src/plugins/plugin-handle-ipns-record.js.map +1 -1
  19. package/dist/src/plugins/plugin-handle-json.d.ts.map +1 -1
  20. package/dist/src/plugins/plugin-handle-json.js +2 -3
  21. package/dist/src/plugins/plugin-handle-json.js.map +1 -1
  22. package/dist/src/plugins/plugin-handle-raw.d.ts.map +1 -1
  23. package/dist/src/plugins/plugin-handle-raw.js +6 -5
  24. package/dist/src/plugins/plugin-handle-raw.js.map +1 -1
  25. package/dist/src/plugins/plugin-handle-tar.js +2 -2
  26. package/dist/src/plugins/plugin-handle-tar.js.map +1 -1
  27. package/dist/src/utils/byte-range-context.d.ts +39 -16
  28. package/dist/src/utils/byte-range-context.d.ts.map +1 -1
  29. package/dist/src/utils/byte-range-context.js +305 -104
  30. package/dist/src/utils/byte-range-context.js.map +1 -1
  31. package/dist/src/utils/{set-content-type.d.ts → get-content-type.d.ts} +3 -4
  32. package/dist/src/utils/get-content-type.d.ts.map +1 -0
  33. package/dist/src/utils/{set-content-type.js → get-content-type.js} +3 -4
  34. package/dist/src/utils/get-content-type.js.map +1 -0
  35. package/dist/src/utils/responses.d.ts.map +1 -1
  36. package/dist/src/utils/responses.js +16 -4
  37. package/dist/src/utils/responses.js.map +1 -1
  38. package/dist/src/verified-fetch.js +1 -1
  39. package/dist/src/verified-fetch.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/plugins/plugin-handle-byte-range-context.ts +6 -0
  42. package/src/plugins/plugin-handle-car.ts +2 -2
  43. package/src/plugins/plugin-handle-dag-cbor.ts +4 -3
  44. package/src/plugins/plugin-handle-dag-pb.ts +68 -24
  45. package/src/plugins/plugin-handle-dir-index-html.ts +2 -2
  46. package/src/plugins/plugin-handle-ipns-record.ts +2 -2
  47. package/src/plugins/plugin-handle-json.ts +2 -3
  48. package/src/plugins/plugin-handle-raw.ts +7 -5
  49. package/src/plugins/plugin-handle-tar.ts +2 -2
  50. package/src/utils/byte-range-context.ts +373 -103
  51. package/src/utils/{set-content-type.ts → get-content-type.ts} +4 -5
  52. package/src/utils/responses.ts +17 -4
  53. package/src/verified-fetch.ts +1 -1
  54. package/dist/src/utils/set-content-type.d.ts.map +0 -1
  55. 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
- const match = rangeHeader.match(/^bytes=(?<start>\d+)?-(?<end>\d+)?$/)
35
- if (match?.groups == null) {
44
+ if (!rangeHeader.startsWith('bytes=')) {
36
45
  throw new InvalidRangeError('Invalid range request')
37
46
  }
38
47
 
39
- const { start, end } = match.groups
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 { start, end }
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
- private readonly requestRangeStart: number | null
55
- private readonly requestRangeEnd: number | null
56
- private byteStart: number | undefined
57
- private byteEnd: number | undefined
58
- private byteSize: number | undefined
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 { start, end } = getByteRangeFromHeader(this.rangeRequestHeader)
68
- this.requestRangeStart = start != null ? parseInt(start) : null
69
- this.requestRangeEnd = end != null ? parseInt(end) : null
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: %o', e)
72
- this.requestRangeStart = null
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 setBody (body: SupportedBodyTypes): void {
86
- this._body = body
87
- // if fileSize was already set, don't recalculate it
88
- this.setFileSize(this._fileSize ?? getBodySizeSync(body))
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
- const byteStart = this.byteStart
104
- const byteEnd = this.byteEnd
105
- const byteSize = this.byteSize
106
- if (byteStart != null || byteEnd != null) {
107
- this.log.trace('returning body with byteStart=%o, byteEnd=%o, byteSize=%o', byteStart, byteEnd, byteSize)
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
- return this.getSlicedBody(body)
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 = this.byteStart ?? 0
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 (this.byteEnd != null && this.byteStart != null) {
194
+ if (range.end != null && range.start != null) {
129
195
  // Exact number of bytes is (end - start + 1) due to inclusive ranges
130
- length = this.byteEnd - this.byteStart + 1
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 (this.byteStart != null) {
171
- if (this.byteStart < 0) {
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 && this.byteStart >= this._fileSize) {
241
+ if (this._fileSize != null && byteStart >= this._fileSize) {
175
242
  return false
176
243
  }
177
- if (this.byteEnd != null && this.byteStart > this.byteEnd) {
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 (this.byteEnd != null) {
186
- if (this.byteEnd < 0) {
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 && this.byteEnd >= this._fileSize) {
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 (this.byteStart != null && this.byteEnd < this.byteStart) {
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 (!this.isRangeRequest) {
205
- return false
288
+ if (this._isValidRangeRequest) {
289
+ // prevent unnecessary re-validation of each byte range
290
+ return true
206
291
  }
207
- if (this.requestRangeStart == null && this.requestRangeEnd == null) {
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
- if (!this.isValidByteEnd()) {
216
- this.log.trace('invalid range request, byteEnd is less than 0 or greater than fileSize')
295
+
296
+ if (this.byteRanges.length === 0) {
297
+ this.log.trace('invalid range request, no valid ranges')
217
298
  return false
218
299
  }
219
- if (this.requestRangeEnd != null && this.requestRangeStart != null) {
220
- // we may not have enough info.. base check on requested bytes
221
- if (this.requestRangeStart > this.requestRangeEnd) {
222
- this.log.trace('invalid range request, start is greater than end')
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
- * Given all the information we have, this function returns the offset that will be used when:
242
- * 1. calling unixfs.cat
243
- * 2. slicing the body
244
- */
245
- public get offset (): number {
246
- return this.byteStart ?? 0
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 get length (): number | undefined {
255
- if (this.byteEnd != null && this.byteStart != null) {
256
- // For a range like bytes=1000-2000, we want a length of 1001 bytes
257
- return this.byteEnd - this.byteStart + 1
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
- if (this.byteEnd != null) {
260
- return this.byteEnd + 1
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
- return this.byteSize
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.requestRangeStart == null && this.requestRangeEnd == null) {
285
- this.log.trace('requestRangeStart and requestRangeEnd are null')
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
- const { start, end, byteSize } = calculateByteRangeIndexes(this.requestRangeStart ?? undefined, this.requestRangeEnd ?? undefined, this._fileSize ?? undefined)
291
- this.log.trace('set byteStart to %o, byteEnd to %o, byteSize to %o', start, end, byteSize)
292
- this.byteStart = start
293
- this.byteEnd = end
294
- this.byteSize = byteSize
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.byteStart = undefined
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
- return getContentRangeHeader({
322
- byteStart: this.byteStart,
323
- byteEnd: this.byteEnd,
324
- byteSize: this._fileSize ?? undefined
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 SetContentTypeOptions {
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 setContentType ({ bytes, path, response, contentTypeParser, log, defaultContentType = 'application/octet-stream', filename: filenameParam }: SetContentTypeOptions): Promise<void> {
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
- log.trace('setting content type to "%s"', contentType ?? defaultContentType)
55
- response.headers.set('content-type', contentType ?? defaultContentType)
53
+
54
+ return contentType ?? defaultContentType
56
55
  }