serve_byte_range 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 699a0639bd7b8667bba8cb9b53ff26bfd55aaabfec562cfed332979aef0a3702
4
+ data.tar.gz: 07e18c40f407e2abd4c46cc2e808bcc42bafda180fc18bda1078d3d6e5c1e394
5
+ SHA512:
6
+ metadata.gz: 14b9ec669046f734e515d0211ee56342bd372ff7ac9d5e9b974b01835340cdb3f58a58caba4e23e82e8de9cb0ddb27a71dc36442f4e07862087bc88a51ee8510
7
+ data.tar.gz: cf3260ffd5b00d9c82d6dfc4eccf802506c8247c595ac26e479641c3bfb526ef3baea8434d4f0672d14dafc323456b3d9157950cc9e4005d2eac1840ac487940
@@ -0,0 +1,37 @@
1
+ name: CI
2
+
3
+ on:
4
+ - push
5
+
6
+ env:
7
+ BUNDLE_PATH: vendor/bundle
8
+
9
+ jobs:
10
+ test_and_lint_minimum_ruby:
11
+ name: Tests/Lint (2.7)
12
+ runs-on: ubuntu-22.04
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v4
16
+ - name: Setup Ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: 2.7
20
+ bundler-cache: true
21
+ - name: "Tests"
22
+ run: bundle exec rake test --backtrace
23
+ - name: "Lint"
24
+ run: bundle exec rake standard
25
+ test_recent_ruby:
26
+ name: Tests (3.4)
27
+ runs-on: ubuntu-22.04
28
+ steps:
29
+ - name: Checkout
30
+ uses: actions/checkout@v4
31
+ - name: Setup Ruby
32
+ uses: ruby/setup-ruby@v1
33
+ with:
34
+ ruby-version: 3.4
35
+ bundler-cache: true
36
+ - name: "Tests"
37
+ run: bundle exec rake test --backtrace
data/.gitignore ADDED
@@ -0,0 +1,58 @@
1
+ # rcov generated
2
+ coverage
3
+ coverage.data
4
+
5
+ # rdoc generated
6
+ rdoc
7
+
8
+ # yard generated
9
+ doc
10
+ .yardoc
11
+
12
+ # Rubocop
13
+ rubocop.html
14
+
15
+ # bundler
16
+ .bundle
17
+ Gemfile.lock
18
+
19
+ # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
20
+ #
21
+ # * Create a file at ~/.gitignore
22
+ # * Include files you want ignored
23
+ # * Run: git config --global core.excludesfile ~/.gitignore
24
+ #
25
+ # After doing this, these files will be ignored in all your git projects,
26
+ # saving you from having to 'pollute' every project you touch with them
27
+ #
28
+ # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line)
29
+ #
30
+ # For MacOS:
31
+ #
32
+ .DS_Store
33
+
34
+ tmp
35
+ .ruby-version
36
+
37
+ # For TextMate
38
+ #*.tmproj
39
+ #tmtags
40
+
41
+ # For emacs:
42
+ #*~
43
+ #\#*
44
+ #.\#*
45
+
46
+ # For vim:
47
+ #*.swp
48
+
49
+ # For redcar:
50
+ #.redcar
51
+
52
+ # For rubinius:
53
+ #*.rbc
54
+
55
+ # Rubocop report
56
+ rubocop.html
57
+
58
+ pkg/*.gem
data/.standard.yml ADDED
@@ -0,0 +1 @@
1
+ ruby_version: 2.7
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # serve_byte_range
2
+
3
+ is a small utility for serving HTTP `Range` responses (partial content responses) from your Rails or Rack app. It will allow you to output partial content in a lazy manner without buffering, and perform correct encoding of `multipart/byte-range` responses. It will also make serving byte ranges safer because it coalesces the requested byte ranges into larger, consecutive ranges - reducing overhead.
4
+
5
+ ## Installation and usage
6
+
7
+ Add it to your Gemfile:
8
+
9
+ ```shell
10
+ bundle add serve_byte_range
11
+ ```
12
+
13
+ and use code like this to serve a large `File` object:
14
+
15
+ ```ruby
16
+ status, headers, body = serve_ranges(env, resource_size: File.size(path_to_large_file)) do |range_in_file, io|
17
+ File.open(path_to_large_file, "rb") do |file|
18
+ file.seek(range_in_file.begin)
19
+ # `io` is an object that responds to `#write` and yields bytes to the Rack-compatible webserver
20
+ IO.copy_stream(file, io, range_in_file.size)
21
+ end
22
+ end
23
+ [status, headers, body] # And return the Rack response "triplet"
24
+ ```
25
+
26
+ You can also retrieve the `range_in_file` from an external resource, you can also do it in chunks - whatever you prefer. The important thing is that your response - even for multiple ranges:
27
+
28
+ * Will be correctly pre-sized with `Content-Length`
29
+ * Will not be generated and buffered eagerly, but will get generated as you serve the content out
30
+
data/Rakefile ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "standard/rake"
6
+
7
+ Rake::TestTask.new(:test) do |t|
8
+ t.libs << "test"
9
+ t.libs << "lib"
10
+ t.test_files = FileList["test/**/*_test.rb"]
11
+ end
12
+
13
+ task :format do
14
+ `bundle exec standardrb --fix`
15
+ `bundle exec magic_frozen_string_literal .`
16
+ end
17
+
18
+ task :generate_typedefs do
19
+ `bundle exec sord rbi/serve_byte_range.rbi`
20
+ end
21
+
22
+ # When building the gem, generate typedefs beforehand,
23
+ # so that they get included
24
+ Rake::Task["build"].enhance(["generate_typedefs"])
25
+
26
+ task default: [:test, :standard, :generate_typedefs]
@@ -0,0 +1,3 @@
1
+ module ServeByteRange
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module ServeByteRange
6
+ class BlockWritableWithLimit
7
+ def initialize(limit, &block_that_accepts_writes)
8
+ @limit = limit
9
+ @written = 0
10
+ @block_that_accepts_bytes = block_that_accepts_writes
11
+ end
12
+
13
+ def write(string)
14
+ return 0 if string.empty?
15
+ would_have_output = @written + string.bytesize
16
+ raise "You are trying to write more than advertised - #{would_have_output} bytes, the limit for this range is #{@limit}" if @limit < would_have_output
17
+ @written += string.bytesize
18
+ @block_that_accepts_bytes.call(string.b)
19
+ string.bytesize
20
+ end
21
+
22
+ def verify!
23
+ raise "You wrote #{@written} bytes but the range requires #{@limit} bytes" unless @written == @limit
24
+ end
25
+ end
26
+
27
+ class ByteRangeBody
28
+ def initialize(http_range:, resource_size:, resource_content_type: "binary/octet-stream", &serving_block)
29
+ @http_range = http_range
30
+ @resource_size = resource_size
31
+ @resource_content_type = resource_content_type
32
+ @serving_block = serving_block
33
+ end
34
+
35
+ def status
36
+ 206
37
+ end
38
+
39
+ def each(&blk)
40
+ writable_for_range_bytes = BlockWritableWithLimit.new(@http_range.size, &blk)
41
+ @serving_block.call(@http_range, writable_for_range_bytes)
42
+ writable_for_range_bytes.verify!
43
+ end
44
+
45
+ def content_length
46
+ @http_range.size
47
+ end
48
+
49
+ def content_type
50
+ @resource_content_type
51
+ end
52
+
53
+ def headers
54
+ {
55
+ "Accept-Ranges" => "bytes",
56
+ "Content-Length" => content_length.to_s,
57
+ "Content-Type" => content_type,
58
+ "Content-Range" => "bytes %d-%d/%d" % [@http_range.begin, @http_range.end, @resource_size]
59
+ }
60
+ end
61
+ end
62
+
63
+ class EmptyBody
64
+ def status
65
+ 200
66
+ end
67
+
68
+ def content_length
69
+ 0
70
+ end
71
+
72
+ def each(&blk)
73
+ # write nothing
74
+ end
75
+
76
+ def headers
77
+ {
78
+ "Accept-Ranges" => "bytes",
79
+ "Content-Length" => content_length.to_s
80
+ }
81
+ end
82
+ end
83
+
84
+ class NotModifiedBody < EmptyBody
85
+ def status
86
+ 304
87
+ end
88
+ end
89
+
90
+ class WholeBody < ByteRangeBody
91
+ def initialize(resource_size:, **more, &serving_block)
92
+ super(http_range: Range.new(0, resource_size - 1), resource_size: resource_size, **more, &serving_block)
93
+ end
94
+
95
+ def status
96
+ 200
97
+ end
98
+
99
+ def headers
100
+ super.tap { |hh| hh.delete("Content-Range") }
101
+ end
102
+ end
103
+
104
+ class Unsatisfiable < EmptyBody
105
+ def initialize(resource_size:)
106
+ @resource_size = resource_size
107
+ end
108
+
109
+ def status
110
+ 416
111
+ end
112
+
113
+ def headers
114
+ super.tap do |hh|
115
+ hh["Content-Range"] = "bytes */%s" % @resource_size
116
+ end
117
+ end
118
+ end
119
+
120
+ # See https://www.ietf.org/archive/id/draft-ietf-httpbis-p5-range-09.html
121
+ class MultipartByteRangesBody < ByteRangeBody
122
+ # @param http_ranges[Array<Range>]
123
+ def initialize(http_ranges:, boundary:, **params_for_single_range, &serving_block)
124
+ super(http_range: http_ranges.first, **params_for_single_range)
125
+ @http_ranges = http_ranges
126
+ @boundary = boundary
127
+ end
128
+
129
+ def each(&blk)
130
+ @http_ranges.each_with_index do |range, part_i|
131
+ yield(part_header(range, part_i))
132
+ writable_for_range_bytes = BlockWritableWithLimit.new(range.size, &blk)
133
+ @serving_block.call(range, writable_for_range_bytes)
134
+ writable_for_range_bytes.verify!
135
+ end
136
+ yield(trailer)
137
+ end
138
+
139
+ def content_length
140
+ # The Content-Length of a multipart response includes the length
141
+ # of all the ranges of the resource, but also the lengths of the
142
+ # multipart part headers - which we need to precompute. To do it
143
+ # we need to run through all of our ranges and output some strings,
144
+ # and if a lot of ranges are involved this can get expensive. So
145
+ # memoize the envelope size (it never changes between calls)
146
+ @envelope_size ||= compute_envelope_size
147
+ end
148
+
149
+ def content_type
150
+ "multipart/byteranges; boundary=#{@boundary}"
151
+ end
152
+
153
+ def headers
154
+ super.tap do |hh|
155
+ hh.delete("Content-Range")
156
+ end
157
+ end
158
+
159
+ private
160
+
161
+ def compute_envelope_size
162
+ @http_ranges.each_with_index.inject(0) do |size_sum, (http_range, part_index)|
163
+ # Generate the header for this multipart part ahead of time - we can do this
164
+ # since we know the boundary and can generate the part headers, without retrieving
165
+ # the actual bytes of the resource
166
+ header_bytes = part_header(http_range, part_index)
167
+ # The amount of output contributed by the part is:
168
+ # size of the header for the part + bytes of the part itself
169
+ part_size = header_bytes.bytesize + http_range.size
170
+ size_sum + part_size
171
+ end + trailer.bytesize # Account for the trailer as well
172
+ end
173
+
174
+ def trailer
175
+ "\r\n--%s--\r\n" % @boundary
176
+ end
177
+
178
+ def part_header(http_range, part_index)
179
+ [
180
+ (part_index > 0) ? "\r\n" : "", # Parts follwing the first have to be delimited "at the top"
181
+ "--%s\r\n" % @boundary,
182
+ "Content-Type: #{@resource_content_type}\r\n",
183
+ "Content-Range: bytes %d-%d/%d\r\n" % [http_range.begin, http_range.end, @resource_size],
184
+ "\r\n"
185
+ ].join
186
+ end
187
+ end
188
+
189
+ # Strictly - the boundary is supposed to not appear in any of the parts of the multipart response, so _first_
190
+ # you need to scan the response, pick a byte sequence that does not occur in it, and then use that. In practice,
191
+ # nobody does that - and a well-behaved HTTP client should honor the Content-Range header when extracting
192
+ # the byte range from the response. See https://stackoverflow.com/questions/37413715
193
+ def self.generate_boundary
194
+ Random.bytes(12).unpack1("H*")
195
+ end
196
+
197
+ # The RFC specifically gives an example of non-canonical, but still
198
+ # valid request for overlapping ranges:
199
+ # > Several legal but not canonical specifications of the second 500
200
+ # > bytes (byte offsets 500-999, inclusive):
201
+ # > bytes=500-600,601-999
202
+ # > bytes=500-700,601-999
203
+ # In such cases, ranges need to be collapsed together. First, to avoid serving
204
+ # a tiny byte range over and over - causing excessive requests to upstream,
205
+ # second - to optimize for doing less requests in total.
206
+ #
207
+ # @param ranges[Array<Range>] an array of inclusive, limited ranges of integers
208
+ # @return [Array] ranges squashed together honoring their overlaps
209
+ def self.coalesce_ranges(ranges)
210
+ return [] if ranges.empty?
211
+ # The RFC says https://www.rfc-editor.org/rfc/rfc7233#section-6.1
212
+ #
213
+ # > Servers ought to ignore, coalesce, or reject
214
+ # > egregious range requests, such as requests for more than two
215
+ # > overlapping ranges or for many small ranges in a single set,
216
+ # > particularly when the ranges are requested out of order for no
217
+ # > apparent reason.
218
+ sorted_ranges = ranges.sort_by(&:begin)
219
+ first = sorted_ranges.shift
220
+ coalesced_sorted_ranges = sorted_ranges.each_with_object([first]) do |next_range, acc|
221
+ prev_range = acc.pop
222
+ if prev_range.end >= next_range.begin # Range#overlap? can be used on 3.3+
223
+ new_begin = [prev_range.begin, next_range.begin].min
224
+ new_end = [prev_range.end, next_range.end].max
225
+ acc << Range.new(new_begin, new_end)
226
+ else
227
+ acc << prev_range << next_range
228
+ end
229
+ end
230
+ # Sort the ranges according to the order the client requested.
231
+ # The spec says that a client _may_ want to get a certain byte range first,
232
+ # and it seems a legitimate use case, not ill intent.
233
+ #
234
+ # > A client that is requesting multiple ranges SHOULD list those ranges
235
+ # > in ascending order (the order in which they would typically be
236
+ # > received in a complete representation) unless there is a specific
237
+ # > need to request a later part earlier. For example, a user agent
238
+ # > processing a large representation with an internal catalog of parts
239
+ # > might need to request later parts first, particularly if the
240
+ # > representation consists of pages stored in reverse order and the user
241
+ # > agent wishes to transfer one page at a time.
242
+ indices = ranges.map do |r|
243
+ coalesced_sorted_ranges.find_index { |cr| cr.begin <= r.begin && cr.end >= r.end }
244
+ end
245
+ indices.uniq.map { |i| coalesced_sorted_ranges.fetch(i) }
246
+ end
247
+
248
+ # @param env[Hash] the Rack env
249
+ # @param resource_size[Integer] the size of the complete resource in bytes
250
+ # @param etag[String] the current ETag of the resource, or nil if none
251
+ # @param resource_content_type[String] the MIME type string of the resource
252
+ # @param multipart_boundary[String] The string to use as multipart boundary. Default is an automatically generated pseudo-random string.
253
+ # @yield [range[Range], io[IO]] The HTTP range being requested and the IO(ish) object to `write()` the bytes into
254
+ # @example
255
+ # status, headers, body = serve_ranges(env, resource_size: file.size) do |range, io|
256
+ # file.seek(range.begin)
257
+ # IO.copy_stream(file, io, range.size)
258
+ # end
259
+ # [status, headers, body]
260
+ # @return [Array] the Rack response triplet of `[status, header_hash, enumerable_body]`
261
+ def self.serve_ranges(env, resource_size:, etag: nil, resource_content_type: "binary/octet-stream", multipart_boundary: generate_boundary, &range_serving_block)
262
+ # As per RFC:
263
+ # If the entity tag given in the If-Range header matches the current cache validator for the entity,
264
+ # then the server SHOULD provide the specified sub-range of the entity using a 206 (Partial Content)
265
+ # response. If the cache validator does not match, then the server SHOULD return the entire entity
266
+ # using a 200 (OK) response.
267
+ wants_ranges_and_etag_valid = env["HTTP_IF_RANGE"] && env["HTTP_IF_RANGE"] == etag && env["HTTP_RANGE"]
268
+ wants_ranges_and_no_etag = !env["HTTP_IF_RANGE"] && env["HTTP_RANGE"]
269
+ wants_no_ranges_and_supplies_etag = env["HTTP_IF_NONE_MATCH"] && !env["HTTP_RANGE"] && !env["HTTP_IF_RANGE"]
270
+
271
+ # Very old Rack versions do not have get_byte_ranges and have just byte_ranges
272
+ http_ranges_from_header = Rack::Utils.respond_to?(:get_byte_ranges) ? Rack::Utils.get_byte_ranges(env["HTTP_RANGE"], resource_size) : Rack::Utils.byte_ranges(env, resource_size)
273
+ http_ranges_from_header = coalesce_ranges(http_ranges_from_header) if http_ranges_from_header
274
+
275
+ body = if wants_no_ranges_and_supplies_etag && env["HTTP_IF_NONE_MATCH"] == etag
276
+ NotModifiedBody.new
277
+ elsif resource_size.zero?
278
+ EmptyBody.new
279
+ elsif http_ranges_from_header && (wants_ranges_and_no_etag || wants_ranges_and_etag_valid)
280
+ if http_ranges_from_header.none?
281
+ Unsatisfiable.new(resource_size: resource_size)
282
+ elsif http_ranges_from_header.one?
283
+ ByteRangeBody.new(http_range: http_ranges_from_header.first, resource_size: resource_size, resource_content_type: resource_content_type, &range_serving_block)
284
+ else
285
+ MultipartByteRangesBody.new(http_ranges: http_ranges_from_header, resource_size: resource_size, resource_content_type: resource_content_type, boundary: multipart_boundary, &range_serving_block)
286
+ end
287
+ else
288
+ WholeBody.new(resource_size: resource_size, resource_content_type: resource_content_type, &range_serving_block)
289
+ end
290
+ headers = body.headers
291
+
292
+ etag = etag.inspect if etag && !etag.match?(/^".+"$/)
293
+ headers["ETag"] = etag if etag
294
+
295
+ [body.status, headers, body]
296
+ end
297
+ end
@@ -0,0 +1,208 @@
1
+ # typed: strong
2
+ module ServeByteRange
3
+ VERSION = T.let("1.0.0", T.untyped)
4
+
5
+ # sord omit - no YARD return type given, using untyped
6
+ # Strictly - the boundary is supposed to not appear in any of the parts of the multipart response, so _first_
7
+ # you need to scan the response, pick a byte sequence that does not occur in it, and then use that. In practice,
8
+ # nobody does that - and a well-behaved HTTP client should honor the Content-Range header when extracting
9
+ # the byte range from the response. See https://stackoverflow.com/questions/37413715
10
+ sig { returns(T.untyped) }
11
+ def self.generate_boundary; end
12
+
13
+ # The RFC specifically gives an example of non-canonical, but still
14
+ # valid request for overlapping ranges:
15
+ # > Several legal but not canonical specifications of the second 500
16
+ # > bytes (byte offsets 500-999, inclusive):
17
+ # > bytes=500-600,601-999
18
+ # > bytes=500-700,601-999
19
+ # In such cases, ranges need to be collapsed together. First, to avoid serving
20
+ # a tiny byte range over and over - causing excessive requests to upstream,
21
+ # second - to optimize for doing less requests in total.
22
+ #
23
+ # _@param_ `ranges` — an array of inclusive, limited ranges of integers
24
+ #
25
+ # _@return_ — ranges squashed together honoring their overlaps
26
+ sig { params(ranges: T::Array[T::Range[T.untyped]]).returns(T::Array[T.untyped]) }
27
+ def self.coalesce_ranges(ranges); end
28
+
29
+ # _@param_ `env` — the Rack env
30
+ #
31
+ # _@param_ `resource_size` — the size of the complete resource in bytes
32
+ #
33
+ # _@param_ `etag` — the current ETag of the resource, or nil if none
34
+ #
35
+ # _@param_ `resource_content_type` — the MIME type string of the resource
36
+ #
37
+ # _@param_ `multipart_boundary` — The string to use as multipart boundary. Default is an automatically generated pseudo-random string.
38
+ #
39
+ # _@return_ — the Rack response triplet of `[status, header_hash, enumerable_body]`
40
+ #
41
+ # ```ruby
42
+ # status, headers, body = serve_ranges(env, resource_size: file.size) do |range, io|
43
+ # file.seek(range.begin)
44
+ # IO.copy_stream(file, io, range.size)
45
+ # end
46
+ # [status, headers, body]
47
+ # ```
48
+ sig do
49
+ params(
50
+ env: T::Hash[T.untyped, T.untyped],
51
+ resource_size: Integer,
52
+ etag: T.nilable(String),
53
+ resource_content_type: String,
54
+ multipart_boundary: String,
55
+ range_serving_block: T.untyped
56
+ ).returns(T::Array[T.untyped])
57
+ end
58
+ def self.serve_ranges(env, resource_size:, etag: nil, resource_content_type: "binary/octet-stream", multipart_boundary: generate_boundary, &range_serving_block); end
59
+
60
+ class BlockWritableWithLimit
61
+ # sord omit - no YARD type given for "limit", using untyped
62
+ sig { params(limit: T.untyped, block_that_accepts_writes: T.untyped).void }
63
+ def initialize(limit, &block_that_accepts_writes); end
64
+
65
+ # sord omit - no YARD type given for "string", using untyped
66
+ # sord omit - no YARD return type given, using untyped
67
+ sig { params(string: T.untyped).returns(T.untyped) }
68
+ def write(string); end
69
+
70
+ # sord omit - no YARD return type given, using untyped
71
+ sig { returns(T.untyped) }
72
+ def verify!; end
73
+ end
74
+
75
+ class ByteRangeBody
76
+ # sord omit - no YARD type given for "http_range:", using untyped
77
+ # sord omit - no YARD type given for "resource_size:", using untyped
78
+ # sord omit - no YARD type given for "resource_content_type:", using untyped
79
+ sig do
80
+ params(
81
+ http_range: T.untyped,
82
+ resource_size: T.untyped,
83
+ resource_content_type: T.untyped,
84
+ serving_block: T.untyped
85
+ ).void
86
+ end
87
+ def initialize(http_range:, resource_size:, resource_content_type: "binary/octet-stream", &serving_block); end
88
+
89
+ # sord omit - no YARD return type given, using untyped
90
+ sig { returns(T.untyped) }
91
+ def status; end
92
+
93
+ # sord omit - no YARD return type given, using untyped
94
+ sig { params(blk: T.untyped).returns(T.untyped) }
95
+ def each(&blk); end
96
+
97
+ # sord omit - no YARD return type given, using untyped
98
+ sig { returns(T.untyped) }
99
+ def content_length; end
100
+
101
+ # sord omit - no YARD return type given, using untyped
102
+ sig { returns(T.untyped) }
103
+ def content_type; end
104
+
105
+ # sord omit - no YARD return type given, using untyped
106
+ sig { returns(T.untyped) }
107
+ def headers; end
108
+ end
109
+
110
+ class EmptyBody
111
+ # sord omit - no YARD return type given, using untyped
112
+ sig { returns(T.untyped) }
113
+ def status; end
114
+
115
+ # sord omit - no YARD return type given, using untyped
116
+ sig { returns(T.untyped) }
117
+ def content_length; end
118
+
119
+ # sord omit - no YARD return type given, using untyped
120
+ sig { params(blk: T.untyped).returns(T.untyped) }
121
+ def each(&blk); end
122
+
123
+ # sord omit - no YARD return type given, using untyped
124
+ sig { returns(T.untyped) }
125
+ def headers; end
126
+ end
127
+
128
+ class NotModifiedBody < ServeByteRange::EmptyBody
129
+ # sord omit - no YARD return type given, using untyped
130
+ sig { returns(T.untyped) }
131
+ def status; end
132
+ end
133
+
134
+ class WholeBody < ServeByteRange::ByteRangeBody
135
+ # sord omit - no YARD type given for "resource_size:", using untyped
136
+ # sord omit - no YARD type given for "**more", using untyped
137
+ sig { params(resource_size: T.untyped, more: T.untyped, serving_block: T.untyped).void }
138
+ def initialize(resource_size:, **more, &serving_block); end
139
+
140
+ # sord omit - no YARD return type given, using untyped
141
+ sig { returns(T.untyped) }
142
+ def status; end
143
+
144
+ # sord omit - no YARD return type given, using untyped
145
+ sig { returns(T.untyped) }
146
+ def headers; end
147
+ end
148
+
149
+ class Unsatisfiable < ServeByteRange::EmptyBody
150
+ # sord omit - no YARD type given for "resource_size:", using untyped
151
+ sig { params(resource_size: T.untyped).void }
152
+ def initialize(resource_size:); end
153
+
154
+ # sord omit - no YARD return type given, using untyped
155
+ sig { returns(T.untyped) }
156
+ def status; end
157
+
158
+ # sord omit - no YARD return type given, using untyped
159
+ sig { returns(T.untyped) }
160
+ def headers; end
161
+ end
162
+
163
+ # See https://www.ietf.org/archive/id/draft-ietf-httpbis-p5-range-09.html
164
+ class MultipartByteRangesBody < ServeByteRange::ByteRangeBody
165
+ # sord omit - no YARD type given for "boundary:", using untyped
166
+ # sord omit - no YARD type given for "**params_for_single_range", using untyped
167
+ # _@param_ `http_ranges`
168
+ sig do
169
+ params(
170
+ http_ranges: T::Array[T::Range[T.untyped]],
171
+ boundary: T.untyped,
172
+ params_for_single_range: T.untyped,
173
+ serving_block: T.untyped
174
+ ).void
175
+ end
176
+ def initialize(http_ranges:, boundary:, **params_for_single_range, &serving_block); end
177
+
178
+ # sord omit - no YARD return type given, using untyped
179
+ sig { params(blk: T.untyped).returns(T.untyped) }
180
+ def each(&blk); end
181
+
182
+ # sord omit - no YARD return type given, using untyped
183
+ sig { returns(T.untyped) }
184
+ def content_length; end
185
+
186
+ # sord omit - no YARD return type given, using untyped
187
+ sig { returns(T.untyped) }
188
+ def content_type; end
189
+
190
+ # sord omit - no YARD return type given, using untyped
191
+ sig { returns(T.untyped) }
192
+ def headers; end
193
+
194
+ # sord omit - no YARD return type given, using untyped
195
+ sig { returns(T.untyped) }
196
+ def compute_envelope_size; end
197
+
198
+ # sord omit - no YARD return type given, using untyped
199
+ sig { returns(T.untyped) }
200
+ def trailer; end
201
+
202
+ # sord omit - no YARD type given for "http_range", using untyped
203
+ # sord omit - no YARD type given for "part_index", using untyped
204
+ # sord omit - no YARD return type given, using untyped
205
+ sig { params(http_range: T.untyped, part_index: T.untyped).returns(T.untyped) }
206
+ def part_header(http_range, part_index); end
207
+ end
208
+ end
@@ -0,0 +1,31 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "serve_byte_range/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "serve_byte_range"
7
+ spec.version = ServeByteRange::VERSION
8
+ spec.authors = ["Julik Tarkhanov", "Sebastian van Hesteren"]
9
+ spec.email = ["me@julik.nl"]
10
+ spec.license = "MIT"
11
+ spec.summary = "Serve byte range HTTP responses lazily"
12
+ spec.description = "Serve byte range HTTP responses lazily"
13
+ spec.homepage = "https://github.com/julik/serve_byte_range"
14
+
15
+ spec.required_ruby_version = ">= 2.7.0"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+ spec.files = `git ls-files -z`.split("\x0")
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_dependency "rack", ">= 1.0"
24
+
25
+ spec.add_development_dependency "minitest"
26
+ spec.add_development_dependency "rake"
27
+ spec.add_development_dependency "standard", "1.28.5" # Needed for 2.6
28
+ spec.add_development_dependency "yard", "~> 0.9"
29
+ spec.add_development_dependency "sord"
30
+ spec.add_development_dependency "redcarpet"
31
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ Bundler.setup
5
+
6
+ require_relative "../lib/serve_byte_range"
7
+ require "minitest"
8
+ require "minitest/autorun"
9
+
10
+ class ServeByteRangeTest < Minitest::Test
11
+ def test_serves_whole_body
12
+ rng = Random.new(Minitest.seed)
13
+ bytes = rng.bytes(474)
14
+ serve_proc = ->(range, io) {
15
+ io.write(bytes[range])
16
+ }
17
+
18
+ env = {}
19
+ status, headers, body = ServeByteRange.serve_ranges(env, resource_size: bytes.bytesize, etag: "AbCehZ", resource_content_type: "x-foo/ba", &serve_proc)
20
+
21
+ assert_equal 200, status
22
+ assert_equal({"Accept-Ranges" => "bytes", "Content-Length" => "474", "Content-Type" => "x-foo/ba", "ETag" => "\"AbCehZ\""}, headers)
23
+
24
+ output = StringIO.new.binmode
25
+ body.each { |chunk| output.write(chunk) }
26
+ assert_equal bytes, output.string
27
+ end
28
+
29
+ def test_serves_single_range
30
+ rng = Random.new(Minitest.seed)
31
+ bytes = rng.bytes(474)
32
+ serve_proc = ->(range, io) {
33
+ io.write(bytes[range])
34
+ }
35
+
36
+ env = {"HTTP_RANGE" => "bytes=1-2"}
37
+ status, headers, body = ServeByteRange.serve_ranges(env, resource_size: bytes.bytesize, etag: "AbCehZ", resource_content_type: "x-foo/ba", &serve_proc)
38
+
39
+ assert_equal 206, status
40
+ assert_equal({"Accept-Ranges" => "bytes", "Content-Length" => "2", "Content-Range" => "bytes 1-2/474", "Content-Type" => "x-foo/ba", "ETag" => "\"AbCehZ\""}, headers)
41
+
42
+ output = StringIO.new.binmode
43
+ body.each { |chunk| output.write(chunk) }
44
+ assert_equal bytes[1..2], output.string
45
+
46
+ env = {"HTTP_RANGE" => "bytes=319-"}
47
+ status, headers, body = ServeByteRange.serve_ranges(env, resource_size: bytes.bytesize, etag: "AbCehZ", resource_content_type: "x-foo/ba", &serve_proc)
48
+
49
+ assert_equal 206, status
50
+ assert_equal({"Accept-Ranges" => "bytes", "Content-Length" => "155", "Content-Range" => "bytes 319-473/474", "Content-Type" => "x-foo/ba", "ETag" => "\"AbCehZ\""}, headers)
51
+
52
+ output = StringIO.new.binmode
53
+ body.each { |chunk| output.write(chunk) }
54
+ assert_equal bytes[319..], output.string
55
+
56
+ env = {"HTTP_RANGE" => "bytes=0-0"}
57
+ status, headers, body = ServeByteRange.serve_ranges(env, resource_size: bytes.bytesize, etag: "AbCehZ", resource_content_type: "x-foo/ba", &serve_proc)
58
+
59
+ assert_equal 206, status
60
+ assert_equal({"Accept-Ranges" => "bytes", "Content-Length" => "1", "Content-Range" => "bytes 0-0/474", "Content-Type" => "x-foo/ba", "ETag" => "\"AbCehZ\""}, headers)
61
+
62
+ output = StringIO.new.binmode
63
+ body.each { |chunk| output.write(chunk) }
64
+ assert_equal bytes[0..0], output.string
65
+ end
66
+
67
+ def test_serves_single_range_of_same_range_supplied_multiple_times_just_once
68
+ rng = Random.new(Minitest.seed)
69
+ bytes = rng.bytes(474)
70
+ serve_proc = ->(range, io) {
71
+ io.write(bytes[range])
72
+ }
73
+
74
+ env = {"HTTP_RANGE" => "bytes=1-2,1-2,1-2,1-2"}
75
+ status, headers, body = ServeByteRange.serve_ranges(env, resource_size: bytes.bytesize, etag: "AbCehZ", resource_content_type: "x-foo/ba", &serve_proc)
76
+
77
+ assert_equal 206, status
78
+ assert_equal({"Accept-Ranges" => "bytes", "Content-Length" => "2", "Content-Range" => "bytes 1-2/474", "Content-Type" => "x-foo/ba", "ETag" => "\"AbCehZ\""}, headers)
79
+
80
+ output = StringIO.new.binmode
81
+ body.each { |chunk| output.write(chunk) }
82
+ assert_equal bytes[1..2], output.string
83
+ end
84
+
85
+ def test_unions_overlapping_ranges
86
+ rng = Random.new(Minitest.seed)
87
+ bytes = rng.bytes(474)
88
+ serve_proc = ->(range, io) {
89
+ io.write(bytes[range])
90
+ }
91
+
92
+ env = {"HTTP_RANGE" => "bytes=1-2,2-8,4-9"}
93
+ status, headers, body = ServeByteRange.serve_ranges(env, resource_size: bytes.bytesize, etag: "AbCehZ", resource_content_type: "x-foo/ba", &serve_proc)
94
+
95
+ assert_equal 206, status
96
+ assert_equal({"Accept-Ranges" => "bytes", "Content-Length" => "9", "Content-Range" => "bytes 1-9/474", "Content-Type" => "x-foo/ba", "ETag" => "\"AbCehZ\""}, headers)
97
+
98
+ output = StringIO.new.binmode
99
+ body.each { |chunk| output.write(chunk) }
100
+ assert_equal bytes[1..9], output.string
101
+ end
102
+
103
+ def test_serves_multiple_ranges
104
+ rng = Random.new(Minitest.seed)
105
+ bytes = rng.bytes(474)
106
+ serve_proc = ->(range, io) {
107
+ io.write(bytes[range])
108
+ }
109
+
110
+ env = {"HTTP_RANGE" => "bytes=1-2,4-9,472-"}
111
+ status, headers, body = ServeByteRange.serve_ranges(env, resource_size: bytes.bytesize, etag: "AbCehZ", multipart_boundary: "azuleju", resource_content_type: "x-foo/bar", &serve_proc)
112
+
113
+ assert_equal 206, status
114
+ assert_equal({"Accept-Ranges" => "bytes", "Content-Length" => "237", "Content-Type" => "multipart/byteranges; boundary=azuleju", "ETag" => "\"AbCehZ\""}, headers)
115
+
116
+ reference_lines = [
117
+ "--azuleju",
118
+ "Content-Type: x-foo/bar",
119
+ "Content-Range: bytes 1-2/474",
120
+ "",
121
+ bytes[1..2],
122
+ "--azuleju",
123
+ "Content-Type: x-foo/bar",
124
+ "Content-Range: bytes 4-9/474",
125
+ "",
126
+ bytes[4..9],
127
+ "--azuleju",
128
+ "Content-Type: x-foo/bar",
129
+ "Content-Range: bytes 472-473/474",
130
+ "",
131
+ bytes[472..473],
132
+ "--azuleju--"
133
+ ]
134
+
135
+ output = StringIO.new.binmode
136
+ body.each { |chunk| output.write(chunk) }
137
+ lines = output.string.split("\r\n")
138
+
139
+ assert_equal reference_lines, lines
140
+ end
141
+
142
+ def test_serves_not_modified_with_just_an_if_none_match
143
+ rng = Random.new(Minitest.seed)
144
+ bytes = rng.bytes(474)
145
+ serve_proc = ->(range, io) {
146
+ io.write(bytes[range])
147
+ }
148
+
149
+ env = {"HTTP_IF_NONE_MATCH" => "\"woof\""}
150
+ status, headers, _ = ServeByteRange.serve_ranges(env, resource_size: bytes.bytesize, etag: "\"woof\"", resource_content_type: "x-foo/bar", &serve_proc)
151
+
152
+ assert_equal 304, status
153
+ assert_equal({"Accept-Ranges" => "bytes", "Content-Length" => "0", "ETag" => "\"woof\""}, headers)
154
+ end
155
+
156
+ def test_serves_entire_document_on_if_range_header_mismatch
157
+ rng = Random.new(Minitest.seed)
158
+ bytes = rng.bytes(474)
159
+ serve_proc = ->(range, io) {
160
+ io.write(bytes[range])
161
+ }
162
+
163
+ env = {"HTTP_RANGE" => "bytes=1-2,4-9,472-", "HTTP_IF_RANGE" => "\"v2\""}
164
+ status, headers, _ = ServeByteRange.serve_ranges(env, resource_size: bytes.bytesize, etag: "\"v3\"", multipart_boundary: "azuleju", resource_content_type: "x-foo/bar", &serve_proc)
165
+
166
+ assert_equal 200, status
167
+ assert_equal({"Accept-Ranges" => "bytes", "Content-Length" => "474", "Content-Type" => "x-foo/bar", "ETag" => "\"v3\""}, headers)
168
+ end
169
+
170
+ def test_generates_boundary_for_multiple_ranges
171
+ rng = Random.new(Minitest.seed)
172
+ bytes = rng.bytes(474)
173
+ serve_proc = ->(range, io) {
174
+ io.write(bytes[range])
175
+ }
176
+
177
+ env = {"HTTP_RANGE" => "bytes=1-2,4-9,472-"}
178
+ status, headers, _ = ServeByteRange.serve_ranges(env, resource_size: bytes.bytesize, etag: "AbCehZ", &serve_proc)
179
+
180
+ assert_equal 206, status
181
+ assert_equal "335", headers["Content-Length"]
182
+ assert headers["Content-Type"].start_with?("multipart/byteranges; boundary=")
183
+ end
184
+
185
+ def test_refuses_invalid_range
186
+ rng = Random.new(Minitest.seed)
187
+ bytes = rng.bytes(474)
188
+ serve_proc = ->(range, io) {
189
+ io.write(bytes[range])
190
+ }
191
+
192
+ env = {"HTTP_RANGE" => "bytes=474-"}
193
+ status, headers, body = ServeByteRange.serve_ranges(env, resource_size: bytes.bytesize, etag: "AbCehZ", multipart_boundary: "azuleju", resource_content_type: "x-foo/bar", &serve_proc)
194
+
195
+ assert_equal 416, status
196
+ assert_equal({"Accept-Ranges" => "bytes", "Content-Length" => "0", "ETag" => "\"AbCehZ\"", "Content-Range" => "bytes */474"}, headers)
197
+
198
+ body.each { |chunk| raise "Should never be called" }
199
+ end
200
+
201
+ def test_coalesce_ranges
202
+ assert_equal [], ServeByteRange.coalesce_ranges([])
203
+
204
+ ranges = [1..1]
205
+ assert_equal [1..1], ServeByteRange.coalesce_ranges(ranges)
206
+
207
+ ranges = [0..0, 0..0, 145..900, 1..12, 3..78, 0..8].shuffle(random: Random.new(Minitest.seed))
208
+ assert_equal [0..78, 145..900], ServeByteRange.coalesce_ranges(ranges).sort_by(&:begin)
209
+
210
+ ordered_ranges = [14..32, 0..0, 145..900, 5..16, 4..4]
211
+ # Ordering should be maintained (roughly) in the coalesced ranges
212
+ assert_equal [5..32, 0..0, 145..900, 4..4], ServeByteRange.coalesce_ranges(ordered_ranges)
213
+ end
214
+ end
metadata ADDED
@@ -0,0 +1,154 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: serve_byte_range
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Julik Tarkhanov
8
+ - Sebastian van Hesteren
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2025-03-27 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rack
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '1.0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '1.0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: minitest
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: rake
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ - !ruby/object:Gem::Dependency
57
+ name: standard
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - '='
61
+ - !ruby/object:Gem::Version
62
+ version: 1.28.5
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - '='
68
+ - !ruby/object:Gem::Version
69
+ version: 1.28.5
70
+ - !ruby/object:Gem::Dependency
71
+ name: yard
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '0.9'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - "~>"
82
+ - !ruby/object:Gem::Version
83
+ version: '0.9'
84
+ - !ruby/object:Gem::Dependency
85
+ name: sord
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ - !ruby/object:Gem::Dependency
99
+ name: redcarpet
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ description: Serve byte range HTTP responses lazily
113
+ email:
114
+ - me@julik.nl
115
+ executables: []
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - ".github/workflows/ci.yml"
120
+ - ".gitignore"
121
+ - ".standard.yml"
122
+ - Gemfile
123
+ - README.md
124
+ - Rakefile
125
+ - lib/serve_byte_range.rb
126
+ - lib/serve_byte_range/version.rb
127
+ - rbi/serve_byte_range.rbi
128
+ - serve_byte_range.gemspec
129
+ - test/range_server_test.rb
130
+ homepage: https://github.com/julik/serve_byte_range
131
+ licenses:
132
+ - MIT
133
+ metadata:
134
+ allowed_push_host: https://rubygems.org
135
+ post_install_message:
136
+ rdoc_options: []
137
+ require_paths:
138
+ - lib
139
+ required_ruby_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: 2.7.0
144
+ required_rubygems_version: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">="
147
+ - !ruby/object:Gem::Version
148
+ version: '0'
149
+ requirements: []
150
+ rubygems_version: 3.1.6
151
+ signing_key:
152
+ specification_version: 4
153
+ summary: Serve byte range HTTP responses lazily
154
+ test_files: []