volucer-worker 0.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +442 -0
- data/lib/volucer/grpc.rb +83 -0
- data/lib/volucer/message.rb +152 -0
- data/lib/volucer/protocol.rb +244 -0
- data/lib/volucer/rack.rb +196 -0
- data/lib/volucer/rpc.rb +622 -0
- data/lib/volucer/services.rb +529 -0
- data/lib/volucer/supervisor.rb +299 -0
- data/lib/volucer/version.rb +15 -0
- data/lib/volucer/worker.rb +836 -0
- data/lib/volucer.rb +111 -0
- metadata +58 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The request and response objects a Ruby worker's handler sees.
|
|
4
|
+
#
|
|
5
|
+
# These are the SDK's own types rather than Rack's, because the SDK must work with every
|
|
6
|
+
# Ruby web stack and none of them owns the wire. `Volucer::Rack.env` turns a Request into a
|
|
7
|
+
# Rack environment; a framework adapter is that environment, not these objects.
|
|
8
|
+
|
|
9
|
+
require 'uri'
|
|
10
|
+
|
|
11
|
+
module Volucer
|
|
12
|
+
# One request, as it arrived on the channel.
|
|
13
|
+
#
|
|
14
|
+
# `headers` is a list of pairs rather than a Hash, because the same name may arrive twice
|
|
15
|
+
# and the order is the client's. `#header` is the ordered lookup and `#header_map` is the
|
|
16
|
+
# collapsed form a Rack environment needs.
|
|
17
|
+
class Request
|
|
18
|
+
attr_reader :method, :uri, :protocol, :headers, :remote_addr, :script_filename, :body
|
|
19
|
+
attr_reader :https
|
|
20
|
+
|
|
21
|
+
def initialize(
|
|
22
|
+
method: 'GET', uri: '/', protocol: 'HTTP/1.1', headers: [], remote_addr: '',
|
|
23
|
+
script_filename: '', https: false, body: +''
|
|
24
|
+
)
|
|
25
|
+
@method = method
|
|
26
|
+
@uri = uri
|
|
27
|
+
@protocol = protocol
|
|
28
|
+
@headers = headers
|
|
29
|
+
@remote_addr = remote_addr
|
|
30
|
+
@script_filename = script_filename
|
|
31
|
+
@https = https
|
|
32
|
+
@body = body
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def https?
|
|
36
|
+
@https
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The first value of a header, case-insensitively, or nil.
|
|
40
|
+
def header(name)
|
|
41
|
+
lowered = name.downcase
|
|
42
|
+
@headers.each { |key, value| return value if key.downcase == lowered }
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Every header as a name => value map, repeated names joined with a comma.
|
|
47
|
+
#
|
|
48
|
+
# Repeated names are joined rather than overwritten, because that is what a repeated
|
|
49
|
+
# HTTP field means. `Set-Cookie` is the exception and it is a *response* header, so the
|
|
50
|
+
# response side keeps the pair list instead.
|
|
51
|
+
def header_map
|
|
52
|
+
@headers.each_with_object({}) do |(key, value), collapsed|
|
|
53
|
+
collapsed[key] = collapsed.key?(key) ? "#{collapsed[key]}, #{value}" : value
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The path, without the query string.
|
|
58
|
+
def path
|
|
59
|
+
@uri.split('?', 2).first
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Everything after the first `?`, or an empty string.
|
|
63
|
+
def query_string
|
|
64
|
+
_, _, query = @uri.partition('?')
|
|
65
|
+
query
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The query string as a Hash, later values winning.
|
|
69
|
+
def query
|
|
70
|
+
URI.decode_www_form(query_string).to_h
|
|
71
|
+
rescue ArgumentError
|
|
72
|
+
{}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def content_type
|
|
76
|
+
header('content-type')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Whether this request is a gRPC call.
|
|
80
|
+
#
|
|
81
|
+
# The media type decides, on both sides of the channel — see `docs/worker-protocol.md`.
|
|
82
|
+
# A worker that serves both HTTP routes and gRPC services is the ordinary shape, so this
|
|
83
|
+
# is the question an adapter asks rather than something the loop decides for it.
|
|
84
|
+
def grpc?
|
|
85
|
+
media_type = (content_type || '').strip.downcase
|
|
86
|
+
return false unless media_type.start_with?('application/grpc')
|
|
87
|
+
|
|
88
|
+
rest = media_type['application/grpc'.length..] || ''
|
|
89
|
+
rest.empty? || [';', '+', '-'].include?(rest[0])
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A response the SDK will send.
|
|
94
|
+
#
|
|
95
|
+
# Header order is preserved as given, because the server relays it verbatim and two
|
|
96
|
+
# `Set-Cookie` headers are two cookies.
|
|
97
|
+
class Response
|
|
98
|
+
attr_accessor :status, :headers, :body
|
|
99
|
+
|
|
100
|
+
def initialize(status = 200, headers = [], body = +'')
|
|
101
|
+
@status = status
|
|
102
|
+
@headers = headers
|
|
103
|
+
@body = body.b
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.text(body, status = 200)
|
|
107
|
+
new(status, [['Content-Type', 'text/plain; charset=utf-8']], body)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.html(body, status = 200)
|
|
111
|
+
new(status, [['Content-Type', 'text/html; charset=utf-8']], body)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.json(value, status = 200)
|
|
115
|
+
new(status, [['Content-Type', 'application/json']], JSON.generate(value))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def with_header(name, value)
|
|
119
|
+
@headers << [name, value]
|
|
120
|
+
self
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def with_status(status)
|
|
124
|
+
@status = status
|
|
125
|
+
self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def with_body(body)
|
|
129
|
+
@body = body.b
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# One unit of queued work, as a consumer sees it.
|
|
135
|
+
#
|
|
136
|
+
# A job is a separate exchange from a request rather than a flag on one, because the two
|
|
137
|
+
# differ in what an application may assume: a request handler is expected to reset what
|
|
138
|
+
# the last one left behind, and a consumer is expected to keep its connection pool and
|
|
139
|
+
# warm caches across jobs.
|
|
140
|
+
class Job
|
|
141
|
+
attr_reader :pipeline, :id, :task, :headers, :attempts, :body
|
|
142
|
+
|
|
143
|
+
def initialize(pipeline: '', id: '', task: '', headers: [], attempts: 0, body: +'')
|
|
144
|
+
@pipeline = pipeline
|
|
145
|
+
@id = id
|
|
146
|
+
@task = task
|
|
147
|
+
@headers = headers
|
|
148
|
+
@attempts = attempts
|
|
149
|
+
@body = body
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The volucer worker protocol, as a Ruby client implements it.
|
|
4
|
+
#
|
|
5
|
+
# The specification is `docs/worker-protocol.md` in `sailantis/volucer`; this module is
|
|
6
|
+
# the other end of it, beside the PHP SDK in `sailantis/volucer-php-sdk` and the Python one
|
|
7
|
+
# in `sailantis/volucer-python-sdk`. All three implement the same document rather than each
|
|
8
|
+
# other, and the wire-format tests assert the same frozen bytes.
|
|
9
|
+
#
|
|
10
|
+
# Everything here is stdlib-only, deliberately: a worker SDK is loaded before an
|
|
11
|
+
# application's own gems are known to work, and a protocol codec that needs a gem is one
|
|
12
|
+
# that can fail for a reason unrelated to the protocol.
|
|
13
|
+
|
|
14
|
+
require 'json'
|
|
15
|
+
|
|
16
|
+
module Volucer
|
|
17
|
+
# The only protocol version that exists so far.
|
|
18
|
+
PROTOCOL_VERSION = 1
|
|
19
|
+
|
|
20
|
+
# The size of a frame header on the wire.
|
|
21
|
+
HEADER_LEN = 16
|
|
22
|
+
|
|
23
|
+
# Refuse absurd payloads before allocating, matching MAX_PAYLOAD_LEN in Rust.
|
|
24
|
+
MAX_PAYLOAD_LEN = 64 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
# The largest body slice sent in one frame, matching MAX_BODY_CHUNK in Rust.
|
|
27
|
+
MAX_BODY_CHUNK = 64 * 1024
|
|
28
|
+
|
|
29
|
+
# The request id reserved for unsolicited frames.
|
|
30
|
+
UNSOLICITED = 0
|
|
31
|
+
|
|
32
|
+
# The message type table. The numbering *is* the protocol: never reorder, never reuse.
|
|
33
|
+
module MessageType
|
|
34
|
+
HELLO = 1
|
|
35
|
+
HELLO_ACK = 2
|
|
36
|
+
REQUEST = 3
|
|
37
|
+
RESPONSE = 4
|
|
38
|
+
BODY = 5
|
|
39
|
+
EVENT = 6
|
|
40
|
+
LOG = 7
|
|
41
|
+
METRIC = 8
|
|
42
|
+
CONTROL = 9
|
|
43
|
+
CONTROL_ACK = 10
|
|
44
|
+
END_FRAME = 11
|
|
45
|
+
PING = 12
|
|
46
|
+
PONG = 13
|
|
47
|
+
ERROR = 14
|
|
48
|
+
CALL = 15
|
|
49
|
+
RETURN = 16
|
|
50
|
+
JOB = 17
|
|
51
|
+
ACK = 18
|
|
52
|
+
|
|
53
|
+
NAMES = {
|
|
54
|
+
HELLO => 'HELLO', HELLO_ACK => 'HELLO_ACK', REQUEST => 'REQUEST',
|
|
55
|
+
RESPONSE => 'RESPONSE', BODY => 'BODY', EVENT => 'EVENT', LOG => 'LOG',
|
|
56
|
+
METRIC => 'METRIC', CONTROL => 'CONTROL', CONTROL_ACK => 'CONTROL_ACK',
|
|
57
|
+
END_FRAME => 'END', PING => 'PING', PONG => 'PONG', ERROR => 'ERROR',
|
|
58
|
+
CALL => 'CALL', RETURN => 'RETURN', JOB => 'JOB', ACK => 'ACK'
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
# The wire name of a type, for diagnostics.
|
|
62
|
+
def self.name(type)
|
|
63
|
+
NAMES.fetch(type, "unknown (#{type})")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Bits in the `flags` field.
|
|
68
|
+
module Flags
|
|
69
|
+
COMPRESSED = 0x0001
|
|
70
|
+
CHUNKED = 0x0002
|
|
71
|
+
LAST = 0x0004
|
|
72
|
+
ERROR = 0x0008
|
|
73
|
+
NO_CHECKSUM = 0x0010
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A framing or message failure.
|
|
77
|
+
#
|
|
78
|
+
# The names are the protocol's own: a *framing* failure means the reader can no longer
|
|
79
|
+
# trust its position in the stream and the worker must be discarded, while a *message*
|
|
80
|
+
# failure (a wrong request id, a remote error) leaves the stream aligned.
|
|
81
|
+
class ProtocolError < StandardError
|
|
82
|
+
attr_reader :fatal
|
|
83
|
+
|
|
84
|
+
# Whether the failed operation may nevertheless have taken effect.
|
|
85
|
+
#
|
|
86
|
+
# The third answer `RETURN` has room for, and the one a retry has to know about: a call
|
|
87
|
+
# that was **written** and never answered -- a broken connection, an expired deadline --
|
|
88
|
+
# may have been served, so retrying it may duplicate a side effect. A refusal the server
|
|
89
|
+
# actually sent is `false`: nothing happened, and retrying is safe.
|
|
90
|
+
#
|
|
91
|
+
# Always `false` on a worker's channel, where the protocol guarantees an answer; it is
|
|
92
|
+
# {Rpc} that can reach the undecided case, because it reads a socket.
|
|
93
|
+
attr_reader :unknown
|
|
94
|
+
|
|
95
|
+
def initialize(message, fatal: true, unknown: false)
|
|
96
|
+
super(message)
|
|
97
|
+
@fatal = fatal
|
|
98
|
+
@unknown = unknown
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Whether the failure may have taken effect anyway, named for the question a caller asks.
|
|
102
|
+
#
|
|
103
|
+
# `may_have_taken_effect?` rather than a predicate over `#unknown`, because the sentence a caller
|
|
104
|
+
# actually needs to answer is "is it safe to retry this?", and `unknown?` reads as "the error is
|
|
105
|
+
# of an unknown kind" to anyone who has not read this file.
|
|
106
|
+
def may_have_taken_effect?
|
|
107
|
+
@unknown
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The field's own name, kept because `RETURN` spells it `unknown` on the wire.
|
|
111
|
+
def unknown?
|
|
112
|
+
@unknown
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# CRC-32C (Castagnoli).
|
|
117
|
+
#
|
|
118
|
+
# A table rather than `Zlib.crc32`, which is CRC-32B: a different polynomial, so using it
|
|
119
|
+
# would make every frame fail its checksum against a correct peer. The check value is the
|
|
120
|
+
# standard one — `crc32c("123456789") == 0xE3069283` — and the test suite pins it.
|
|
121
|
+
module Crc32c
|
|
122
|
+
POLYNOMIAL = 0x82F63B78
|
|
123
|
+
|
|
124
|
+
TABLE = Array.new(256) do |index|
|
|
125
|
+
value = index
|
|
126
|
+
8.times { value = (value & 1).zero? ? value >> 1 : (value >> 1) ^ POLYNOMIAL }
|
|
127
|
+
value
|
|
128
|
+
end.freeze
|
|
129
|
+
|
|
130
|
+
def self.digest(data)
|
|
131
|
+
crc = 0xFFFFFFFF
|
|
132
|
+
data.each_byte do |byte|
|
|
133
|
+
crc = TABLE[(crc ^ byte) & 0xFF] ^ (crc >> 8)
|
|
134
|
+
end
|
|
135
|
+
crc ^ 0xFFFFFFFF
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A decoded frame header: the 16 bytes that precede a payload.
|
|
140
|
+
#
|
|
141
|
+
# A direct transliteration of `Header::decode` in
|
|
142
|
+
# `crates/volucer-worker/src/header.rs`. Layout, little-endian:
|
|
143
|
+
#
|
|
144
|
+
# offset size field
|
|
145
|
+
# 0 4 payload_len
|
|
146
|
+
# 4 1 protocol_version
|
|
147
|
+
# 5 1 message_type
|
|
148
|
+
# 6 2 flags
|
|
149
|
+
# 8 4 request_id
|
|
150
|
+
# 12 4 crc32c(payload)
|
|
151
|
+
Header = Struct.new(:payload_len, :message_type, :flags, :request_id, :checksum) do
|
|
152
|
+
# Parse a header, which must be exactly 16 bytes.
|
|
153
|
+
#
|
|
154
|
+
# Exactly, not "at least". A stream that ended mid-header is an error rather than a
|
|
155
|
+
# clean close: treating it as one would hide a truncated frame.
|
|
156
|
+
def self.decode(raw)
|
|
157
|
+
length = raw.bytesize
|
|
158
|
+
unless length == HEADER_LEN
|
|
159
|
+
raise ProtocolError, "a frame header is exactly #{HEADER_LEN} bytes, got #{length}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
payload_len, version, type, flags, request_id, checksum =
|
|
163
|
+
raw.unpack('VCCvVV')
|
|
164
|
+
|
|
165
|
+
unless version == PROTOCOL_VERSION
|
|
166
|
+
raise ProtocolError,
|
|
167
|
+
"unsupported protocol version #{version} (this SDK speaks #{PROTOCOL_VERSION})"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
unless type.between?(MessageType::HELLO, MessageType::ACK)
|
|
171
|
+
raise ProtocolError, "unknown message type #{type} (this SDK defines 1..18)"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Refused rather than misread: with no codec negotiated, a "compressed" payload would
|
|
175
|
+
# be handed to JSON as if it were plain text.
|
|
176
|
+
unless (flags & Flags::COMPRESSED).zero?
|
|
177
|
+
raise ProtocolError, 'refusing a compressed frame: no codec is negotiated'
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
new(payload_len, type, flags, request_id, checksum)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def last?
|
|
184
|
+
(flags & Flags::LAST) != 0
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The writer's *claim*, not a permission. Whether it may be believed is the reader's
|
|
188
|
+
# decision, and `Worker#read_payload` refuses one the handshake never agreed to.
|
|
189
|
+
def declares_no_checksum?
|
|
190
|
+
(flags & Flags::NO_CHECKSUM) != 0
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def type_name
|
|
194
|
+
MessageType.name(message_type)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A frame ready to send: a message type, a request id and a payload.
|
|
199
|
+
Frame = Struct.new(:message_type, :request_id, :payload, :flags) do
|
|
200
|
+
def initialize(message_type, request_id, payload = '', flags = 0)
|
|
201
|
+
super(message_type, request_id, payload.b, flags)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Serialize to header + payload.
|
|
205
|
+
#
|
|
206
|
+
# With the checksum off the field is written as zero and NO_CHECKSUM is raised, which
|
|
207
|
+
# is what makes the zero mean "not a checksum" rather than "the checksum of an empty
|
|
208
|
+
# payload".
|
|
209
|
+
def encode(checksum: true)
|
|
210
|
+
effective_flags = checksum ? flags : (flags | Flags::NO_CHECKSUM)
|
|
211
|
+
value = checksum ? Crc32c.digest(payload) : 0
|
|
212
|
+
[payload.bytesize, PROTOCOL_VERSION, message_type, effective_flags,
|
|
213
|
+
request_id, value].pack('VCCvVV') + payload
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# JSON-encode a payload the way the protocol expects it.
|
|
218
|
+
#
|
|
219
|
+
# `JSON.generate` carries no spaces, which is what the golden fixtures in all three SDKs
|
|
220
|
+
# are written against.
|
|
221
|
+
def self.json_bytes(value)
|
|
222
|
+
JSON.generate(value, ascii_only: false).b
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Decode a JSON payload, turning a bad payload into a protocol error.
|
|
226
|
+
def self.decode_json(payload, what)
|
|
227
|
+
return {} if payload.nil? || payload.empty?
|
|
228
|
+
|
|
229
|
+
decoded = begin
|
|
230
|
+
JSON.parse(payload)
|
|
231
|
+
rescue JSON::ParserError => e
|
|
232
|
+
raise ProtocolError, "#{what} was not valid JSON: #{e.message}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
unless decoded.is_a?(Hash)
|
|
236
|
+
raise ProtocolError, "#{what} must be a JSON object"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
decoded
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The extensions, message types and flags are loaded by `volucer/worker`; requiring this
|
|
243
|
+
# file alone gives a caller the codec without a process.
|
|
244
|
+
end
|
data/lib/volucer/rack.rb
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A Rack adapter: turn a `Volucer::Request` into a Rack environment.
|
|
4
|
+
#
|
|
5
|
+
# This is the half of a Ruby worker that is genuinely new work rather than a transliteration
|
|
6
|
+
# of the PHP SDK. PHP has a SAPI that builds `$_SERVER` from a CGI environment, so the PHP
|
|
7
|
+
# SDK already receives one; Rack is *also* CGI-derived but has its own opinions, and two of
|
|
8
|
+
# them are where a naive adapter goes wrong:
|
|
9
|
+
#
|
|
10
|
+
# * `rack.input` has to respond to `gets`, `each`, `read` and `rewind`. A Rack app, and
|
|
11
|
+
# `Rack::Lint`, will use all four.
|
|
12
|
+
# * `rack.errors` has to be a stream. Pointing it at the protocol stream would corrupt the
|
|
13
|
+
# framing the first time an application logged a warning.
|
|
14
|
+
#
|
|
15
|
+
# `SCRIPT_NAME` is empty and `PATH_INFO` is the whole path, which is the shape a middleware
|
|
16
|
+
# stack mounted at the root expects. A framework that needs to route on a prefix does it from
|
|
17
|
+
# `PATH_INFO`, exactly as it does under any other Rack server.
|
|
18
|
+
|
|
19
|
+
require 'stringio'
|
|
20
|
+
|
|
21
|
+
module Volucer
|
|
22
|
+
module Rack
|
|
23
|
+
# The default server name, when a `Host` header carries none.
|
|
24
|
+
DEFAULT_SERVER_NAME = 'localhost'
|
|
25
|
+
|
|
26
|
+
# Split a `Host` header into a name and a port, with the scheme's default filled in.
|
|
27
|
+
#
|
|
28
|
+
# A bracketed IPv6 literal keeps its brackets and its colons; everything else splits on
|
|
29
|
+
# the last colon, which is the only unambiguous rule a host header has.
|
|
30
|
+
def self.split_host(host, https)
|
|
31
|
+
default_port = https ? '443' : '80'
|
|
32
|
+
return [DEFAULT_SERVER_NAME, default_port] if host.nil? || host.strip.empty?
|
|
33
|
+
|
|
34
|
+
host = host.strip
|
|
35
|
+
if host.start_with?('[')
|
|
36
|
+
closing = host.index(']')
|
|
37
|
+
unless closing.nil?
|
|
38
|
+
name = host[0..closing]
|
|
39
|
+
rest = host[(closing + 1)..] || ''
|
|
40
|
+
return [name, rest.start_with?(':') && rest.length > 1 ? rest[1..] : default_port]
|
|
41
|
+
end
|
|
42
|
+
return [host, default_port]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if host.include?(':')
|
|
46
|
+
name, _, port = host.rpartition(':')
|
|
47
|
+
return [name, port] unless name.empty? || port.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
[host, default_port]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Build the Rack environment for one request.
|
|
54
|
+
#
|
|
55
|
+
# `server_port` overrides the port derived from the `Host` header, for a deployment whose
|
|
56
|
+
# listener is not the port a client sees. The header is the better source when it is
|
|
57
|
+
# there, so this is an override rather than the default.
|
|
58
|
+
def self.env(request, server_port = nil)
|
|
59
|
+
name, port = split_host(request.header('host'), request.https?)
|
|
60
|
+
port = server_port unless server_port.nil? || server_port.to_s.empty?
|
|
61
|
+
|
|
62
|
+
path = decode_path(request.path)
|
|
63
|
+
|
|
64
|
+
env = {
|
|
65
|
+
'REQUEST_METHOD' => request.method,
|
|
66
|
+
'SCRIPT_NAME' => '',
|
|
67
|
+
'PATH_INFO' => path,
|
|
68
|
+
'QUERY_STRING' => request.query_string,
|
|
69
|
+
'SERVER_NAME' => name,
|
|
70
|
+
'SERVER_PORT' => port.to_s,
|
|
71
|
+
'SERVER_PROTOCOL' => request.protocol,
|
|
72
|
+
'REMOTE_ADDR' => request.remote_addr,
|
|
73
|
+
'rack.version' => [1, 3],
|
|
74
|
+
'rack.url_scheme' => request.https? ? 'https' : 'http',
|
|
75
|
+
'rack.input' => StringIO.new(request.body),
|
|
76
|
+
'rack.errors' => ErrorsStream.new,
|
|
77
|
+
'rack.multithread' => false,
|
|
78
|
+
'rack.multiprocess' => true,
|
|
79
|
+
'rack.run_once' => false,
|
|
80
|
+
'rack.hijack?' => false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Routing metadata, not a file to execute — the protocol's own documentation says so,
|
|
84
|
+
# and a Rack app that opened this path would be a path-traversal bug waiting to happen.
|
|
85
|
+
# Prefixing it keeps nothing from mistaking it for a CGI variable.
|
|
86
|
+
env['volucer.script_filename'] = request.script_filename unless request.script_filename.empty?
|
|
87
|
+
|
|
88
|
+
request.headers.each do |key, value|
|
|
89
|
+
upper = key.upcase.tr('-', '_')
|
|
90
|
+
case upper
|
|
91
|
+
when 'CONTENT_TYPE', 'CONTENT_LENGTH'
|
|
92
|
+
env[upper] = value
|
|
93
|
+
else
|
|
94
|
+
env["HTTP_#{upper}"] = value
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A Rack server is expected to supply this for a body; the request may have arrived
|
|
99
|
+
# without one, and a middleware that read `rack.input` to the end would otherwise not
|
|
100
|
+
# know how much to expect.
|
|
101
|
+
if !env.key?('CONTENT_LENGTH') && !request.body.empty?
|
|
102
|
+
env['CONTENT_LENGTH'] = request.body.bytesize.to_s
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
env
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Percent-decode a URL path, leaving it as the bytes a Rack app expects.
|
|
109
|
+
def self.decode_path(raw)
|
|
110
|
+
decoded = raw.gsub(/%([0-9A-Fa-f]{2})/) { [Regexp.last_match(1)].pack('H2') }
|
|
111
|
+
decoded.force_encoding(Encoding::UTF_8)
|
|
112
|
+
# A path that is not valid UTF-8 is still a path a request can carry, so it stays as
|
|
113
|
+
# bytes rather than raising. Rack apps that care will inspect `PATH_INFO` themselves.
|
|
114
|
+
decoded.valid_encoding? ? decoded : decoded.dup.force_encoding(Encoding::BINARY)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# `rack.errors`, writing somewhere that is not the protocol channel.
|
|
118
|
+
#
|
|
119
|
+
# `$stderr` is free (the supervisor drains it), so this is a thin pass-through. An
|
|
120
|
+
# application's warning is worth seeing, and losing it to a full pipe would be the worst
|
|
121
|
+
# of both.
|
|
122
|
+
class ErrorsStream
|
|
123
|
+
def write(text)
|
|
124
|
+
$stderr.write(text)
|
|
125
|
+
text.to_s.length
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def <<(text)
|
|
129
|
+
write(text)
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def puts(*lines)
|
|
134
|
+
lines = [''] if lines.empty?
|
|
135
|
+
lines.each { |line| write("#{line}\n") }
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def flush
|
|
140
|
+
$stderr.flush
|
|
141
|
+
self
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def sync
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def sync=(value); end
|
|
149
|
+
|
|
150
|
+
def close; end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Wrap a Rack application so it can be handed to `Worker#run`.
|
|
154
|
+
#
|
|
155
|
+
# The usual shape of a Ruby worker, which is why this exists as a function rather than as
|
|
156
|
+
# documentation:
|
|
157
|
+
#
|
|
158
|
+
# require 'volucer'
|
|
159
|
+
# require 'my_app'
|
|
160
|
+
#
|
|
161
|
+
# Volucer::Worker.connect.run(&Volucer::Rack.handler(MyApp.new))
|
|
162
|
+
#
|
|
163
|
+
# The result is a callable taking `(request, worker)` and returning a `Response`, which
|
|
164
|
+
# is what `run` wants.
|
|
165
|
+
def self.handler(app)
|
|
166
|
+
lambda do |request, _worker|
|
|
167
|
+
status, headers, body = app.call(env(request))
|
|
168
|
+
|
|
169
|
+
collected = +''
|
|
170
|
+
begin
|
|
171
|
+
body.each { |chunk| collected << chunk.to_s }
|
|
172
|
+
ensure
|
|
173
|
+
body.close if body.respond_to?(:close)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
Response.new(status.to_i, normalise_headers(headers), collected)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def self.normalise_headers(headers)
|
|
181
|
+
return [] if headers.nil?
|
|
182
|
+
|
|
183
|
+
collected = []
|
|
184
|
+
headers.each do |name, value|
|
|
185
|
+
if value.is_a?(Array)
|
|
186
|
+
# Rack 3 allows an Array value for a header that may repeat; two cookies are two
|
|
187
|
+
# headers, and joining them would silently break a session.
|
|
188
|
+
value.each { |one| collected << [name.to_s, one.to_s] }
|
|
189
|
+
else
|
|
190
|
+
collected << [name.to_s, value.to_s]
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
collected
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|