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 +7 -0
- data/.github/workflows/ci.yml +37 -0
- data/.gitignore +58 -0
- data/.standard.yml +1 -0
- data/Gemfile +3 -0
- data/README.md +30 -0
- data/Rakefile +26 -0
- data/lib/serve_byte_range/version.rb +3 -0
- data/lib/serve_byte_range.rb +297 -0
- data/rbi/serve_byte_range.rbi +208 -0
- data/serve_byte_range.gemspec +31 -0
- data/test/range_server_test.rb +214 -0
- metadata +154 -0
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
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,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: []
|