puma-plus 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.
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "puma_plus/wire"
5
+ require "puma_plus/rack_env"
6
+ require "puma_plus/input"
7
+ require "puma_plus/gvl"
8
+
9
+ module PumaPlus
10
+ # One Ruby thread serving one connection to the Go server.
11
+ #
12
+ # This replaces the seam puma occupies with Server#process_client plus
13
+ # Response#handle_request. There is no thread pool and no reactor: the thread
14
+ # dials in, blocks on read, serves one request, and blocks again. Because the
15
+ # connection carries exactly one request at a time, nothing here needs a mutex
16
+ # or a demultiplexer.
17
+ #
18
+ # An idle connection *is* the capacity signal the Go dispatcher counts, so
19
+ # "blocked in read" and "available" are the same state -- there is no separate
20
+ # bookkeeping to drift out of sync.
21
+ class WorkerThread
22
+ MONOTONIC = Process::CLOCK_MONOTONIC
23
+ THREAD_CPU = Process::CLOCK_THREAD_CPUTIME_ID
24
+
25
+ def initialize(socket_path:, app:, worker_id:, thread_index:,
26
+ multithread: true, multiprocess: false, logger: $stderr)
27
+ @socket_path = socket_path
28
+ @app = app
29
+ @worker_id = worker_id
30
+ @thread_index = thread_index
31
+ @logger = logger
32
+ @env_builder = RackEnv.new(multithread: multithread, multiprocess: multiprocess)
33
+ end
34
+
35
+ def run
36
+ conn = UNIXSocket.new(@socket_path)
37
+ conn.sync = true
38
+ hello(conn)
39
+
40
+ loop do
41
+ begin
42
+ type, payload = Wire.read_frame(conn)
43
+ rescue Wire::Closed
44
+ break
45
+ end
46
+
47
+ case type
48
+ when Wire::REQUEST
49
+ break if serve(conn, payload) == :hijacked
50
+ when Wire::GOAWAY
51
+ # Sent only to an idle conn, so there is never in-flight work to lose.
52
+ break
53
+ else
54
+ @logger.puts "[puma-plus] unexpected frame #{Wire.type_name(type)}; closing"
55
+ break
56
+ end
57
+ end
58
+ rescue Errno::ECONNRESET, Errno::EPIPE => e
59
+ @logger.puts "[puma-plus] worker #{@worker_id}/#{@thread_index} lost the server: #{e.class}"
60
+ ensure
61
+ conn&.close
62
+ end
63
+
64
+ private
65
+
66
+ # The readiness signal. Sent only once the Rack app is loaded and callable,
67
+ # so the Go side never has to poll for readiness.
68
+ def hello(conn)
69
+ conn.write Wire.frame(Wire::HELLO, Wire.encode_kv([
70
+ ["version", Wire::VERSION],
71
+ ["role", "data"],
72
+ ["pid", Process.pid],
73
+ ["worker_id", @worker_id],
74
+ ["thread_index", @thread_index]
75
+ ]))
76
+ end
77
+
78
+ def serve(conn, payload)
79
+ meta, offset = Wire.decode_request_meta(payload)
80
+ pairs, offset = Wire.decode_kv(payload, offset)
81
+ body = payload.byteslice(offset, payload.bytesize - offset)
82
+
83
+ env = @env_builder.build(pairs, meta)
84
+ input = Input.for(meta[:body_mode], body, conn)
85
+ env["rack.input"] = input
86
+
87
+ # Rack full hijack. Calling env['rack.hijack'] hands the application the
88
+ # raw socket; from that moment Go stops framing and splices bytes straight
89
+ # to the client, so the app owns the whole conversation including writing
90
+ # its own status line. That is what makes WebSockets work without
91
+ # puma-plus knowing anything about WebSockets.
92
+ hijack = Hijack.new(conn, env)
93
+ env["rack.hijack"] = hijack.to_proc
94
+
95
+ status = headers = app_body = nil
96
+ error = nil
97
+
98
+ wall0 = Process.clock_gettime(MONOTONIC, :nanosecond)
99
+ cpu0 = Process.clock_gettime(THREAD_CPU, :nanosecond)
100
+
101
+
102
+ begin
103
+ status, headers, app_body = @app.call(env)
104
+ rescue Exception => e # rubocop:disable Lint/RescueException
105
+ error = e
106
+ log_error(e, env)
107
+ status, headers, app_body = lowlevel_error(e)
108
+ end
109
+
110
+ # A hijacked connection is no longer ours: no drain, no response, no
111
+ # RESP_END. The socket is the tunnel and the app has finished with it.
112
+ return :hijacked if hijack.hijacked?
113
+
114
+ # Consume anything the app left unread BEFORE writing the response. Go
115
+ # streams body frames concurrently with the app running, so an early
116
+ # return leaves frames in flight; sending RESP_END on top of them would
117
+ # desynchronise the connection and the next request would read a body
118
+ # chunk where it expected a REQUEST. Protocol invariant, not politeness.
119
+ input.drain if input.respond_to?(:drain)
120
+
121
+ service_ns = Process.clock_gettime(MONOTONIC, :nanosecond) - wall0
122
+ # The controller derives io_fraction = 1 - cpu_ns/service_ns from this pair.
123
+ # Two clock reads per request, and it is the cheapest honest signal for
124
+ # "would another thread actually help, or is the GVL the bottleneck".
125
+ cpu_ns = Process.clock_gettime(THREAD_CPU, :nanosecond) - cpu0
126
+
127
+ # Time blocked waiting on the uploader is not the application being slow.
128
+ # Reporting it separately lets the controller subtract it, so a slow client
129
+ # cannot masquerade as a workload that needs more capacity.
130
+ body_read_ns = input.respond_to?(:read_ns) ? input.read_ns : 0
131
+
132
+ # This PROCESS's cumulative GVL wait, not a per-request delta. Go
133
+ # differences it between observations and divides by available
134
+ # thread-time.
135
+ #
136
+ # A per-request delta would measure the wrong window: a worker thread does
137
+ # most of its waiting before the handler starts, while waking from the
138
+ # socket read and queueing for the GVL. Measured under pure CPU load the
139
+ # delta reported 0.03 where the truth was near 0.80.
140
+ gvl_wait_ns = GVL.wait_ns
141
+
142
+ write_response(conn, status, headers, app_body, service_ns, cpu_ns,
143
+ body_read_ns, gvl_wait_ns)
144
+ :ok
145
+ ensure
146
+ # Ordering transcribed from puma/lib/puma/response.rb:109-131: close the
147
+ # body, then rack.after_reply in order, then rack.response_finished in
148
+ # reverse with (env, status, headers, error). Each callback is rescued
149
+ # individually so one bad hook cannot poison the rest or kill the thread.
150
+ app_body.close if app_body.respond_to?(:close)
151
+ run_hooks(env&.[]("rack.after_reply")) { |o| o.call }
152
+ run_hooks(env&.[]("rack.response_finished")&.reverse) do |o|
153
+ o.call(env, status, headers, error)
154
+ end
155
+ end
156
+
157
+ def write_response(conn, status, headers, body, service_ns, cpu_ns,
158
+ body_read_ns, gvl_wait_ns = 0)
159
+ pairs = []
160
+ headers&.each do |key, value|
161
+ # Rack 3 permits array-valued headers, and a String value may still carry
162
+ # embedded newlines from Rack 2-era middleware. Both become repeated kv
163
+ # entries, which Go turns back into separate header lines -- this is what
164
+ # keeps multiple Set-Cookie headers intact.
165
+ if value.is_a?(Array)
166
+ value.each { |v| pairs << [key, v] }
167
+ elsif value.is_a?(String) && value.include?("\n")
168
+ value.split("\n").each { |v| pairs << [key, v] }
169
+ else
170
+ pairs << [key, value]
171
+ end
172
+ end
173
+
174
+ conn.write Wire.frame(Wire::RESPONSE, [status.to_i].pack("n") << Wire.encode_kv(pairs))
175
+ write_body(conn, body)
176
+
177
+ conn.write Wire.frame(Wire::RESP_END,
178
+ Wire.encode_resp_end_meta(
179
+ service_ns: service_ns,
180
+ cpu_ns: cpu_ns,
181
+ body_read_ns: body_read_ns,
182
+ gvl_wait_ns: gvl_wait_ns
183
+ ) << Wire.encode_kv([]))
184
+ end
185
+
186
+ # Emit the response body as RESP_CHUNK frames.
187
+ #
188
+ # Each yielded chunk becomes one frame, and Go flushes each frame straight to
189
+ # the client, so an SSE or long-poll body reaches the browser as the app
190
+ # produces it. This is the puma-dev bug we are deliberately not repeating:
191
+ # its reverse proxy sets FlushInterval to 1 second (puma-dev/dev/http.go:63),
192
+ # which batches event streams into one-second hiccups.
193
+ def write_body(conn, body)
194
+ return if body.nil?
195
+
196
+ # Rack 3 streaming body: an object responding to #call(stream) rather than
197
+ # #each. This is how Rack 3 apps do SSE without hijacking.
198
+ if !body.respond_to?(:each) && body.respond_to?(:call)
199
+ body.call(ChunkWriter.new(conn))
200
+ return
201
+ end
202
+
203
+ if body.respond_to?(:each)
204
+ body.each do |chunk|
205
+ next if chunk.nil? || chunk.empty?
206
+
207
+ conn.write Wire.frame(Wire::RESP_CHUNK, chunk)
208
+ end
209
+ else
210
+ conn.write Wire.frame(Wire::RESP_CHUNK, body.to_s)
211
+ end
212
+ end
213
+
214
+ # The `stream` object handed to a Rack 3 streaming body.
215
+ #
216
+ # The Rack SPEC requires read, write, <<, flush, close, close_read,
217
+ # close_write and closed?, and says their semantics "must be a best effort
218
+ # match to those of a normal Ruby IO or Socket object, using standard
219
+ # arguments and raising standard exceptions" (rack SPEC.rdoc:252). So this
220
+ # behaves like a Ruby IO opened for writing only -- including raising the
221
+ # same errors that one does, rather than inventing gentler ones.
222
+ #
223
+ # Rack::Lint enforces only that the methods exist, which is how an earlier
224
+ # version passed every other check while omitting three of them and failing
225
+ # the moment a streaming body was actually used.
226
+ class ChunkWriter
227
+ def initialize(conn)
228
+ @conn = conn
229
+ @closed = false
230
+ end
231
+
232
+ # IO#write takes any number of arguments and returns the total bytes.
233
+ def write(*chunks)
234
+ raise IOError, "closed stream" if @closed
235
+
236
+ total = 0
237
+ chunks.each do |chunk|
238
+ data = chunk.to_s
239
+ next if data.empty?
240
+
241
+ @conn.write Wire.frame(Wire::RESP_CHUNK, data)
242
+ total += data.bytesize
243
+ end
244
+ total
245
+ end
246
+
247
+ # IO#<< returns self so it can be chained, where #write returns a count.
248
+ # Aliasing the two would break `stream << a << b`.
249
+ def <<(chunk)
250
+ write(chunk)
251
+ self
252
+ end
253
+
254
+ # Nothing is buffered on this side: every write is already a frame on the
255
+ # wire, which is the point (puma-dev batches SSE into one-second hiccups
256
+ # with a 1s FlushInterval). IO#flush returns self.
257
+ def flush = self
258
+
259
+ # Write-only, so reading raises exactly what a Ruby IO opened "w" raises.
260
+ # Full-duplex streaming would mean interleaving BODY_CHUNK reads with
261
+ # RESP_CHUNK writes on one connection, which the protocol's drain
262
+ # invariant (consume the body to BODY_END before RESP_END) forbids.
263
+ def read(*)
264
+ raise IOError, "not opened for reading"
265
+ end
266
+
267
+ def close_read
268
+ raise IOError, "closing non-duplex IO for reading"
269
+ end
270
+
271
+ # Closing the stream ends the body but must NOT close the connection: the
272
+ # connection outlives the request and goes back to the idle pool. RESP_END
273
+ # is what terminates the body, and write_response sends it.
274
+ def close
275
+ @closed = true
276
+ nil
277
+ end
278
+ alias close_write close
279
+
280
+ def closed? = @closed
281
+ end
282
+
283
+ def run_hooks(hooks)
284
+ return unless hooks
285
+
286
+ hooks.each do |hook|
287
+ yield hook
288
+ rescue StandardError => e
289
+ @logger.puts "[puma-plus] hook error: #{e.class}: #{e.message}"
290
+ end
291
+ end
292
+
293
+ # The object behind env['rack.hijack'].
294
+ #
295
+ # Per the Rack SPEC, calling it returns the IO and also sets
296
+ # env['rack.hijack_io']. Sending the HIJACK frame first is what tells Go to
297
+ # stop expecting frames -- if the app wrote to the socket before that frame
298
+ # went out, Go would try to parse the app's bytes as a response frame.
299
+ class Hijack
300
+ def initialize(conn, env)
301
+ @conn = conn
302
+ @env = env
303
+ @hijacked = false
304
+ end
305
+
306
+ def hijacked? = @hijacked
307
+
308
+ def to_proc
309
+ method(:call).to_proc
310
+ end
311
+
312
+ def call
313
+ return @conn if @hijacked
314
+
315
+ @conn.write Wire.frame(Wire::HIJACK)
316
+ @hijacked = true
317
+ @env["rack.hijack_io"] = @conn
318
+ end
319
+ end
320
+
321
+ def lowlevel_error(_error)
322
+ [500, { "content-type" => "text/plain" }, ["Internal Server Error\n"]]
323
+ end
324
+
325
+ def log_error(error, env)
326
+ @logger.puts "[puma-plus] #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}: " \
327
+ "#{error.class}: #{error.message}"
328
+ @logger.puts(error.backtrace&.first(10)&.map { |l| " #{l}" }&.join("\n"))
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "monitor"
5
+ require "puma_plus/wire"
6
+
7
+ module PumaPlus
8
+ # Outbound WebSocket API.
9
+ #
10
+ # The application never touches a socket. Go owns every WebSocket connection;
11
+ # this module sends it commands over a per-process control channel, which means
12
+ # the app can address a connection at any time from any thread -- not only
13
+ # while handling a message from it.
14
+ #
15
+ # PumaPlus::WS.send(conn_id, "pong")
16
+ # PumaPlus::WS.subscribe(conn_id, "room:42")
17
+ # PumaPlus::WS.publish("room:42", payload) # one call -> N sockets
18
+ # PumaPlus::WS.close(conn_id, code: 1000)
19
+ #
20
+ # #publish is the reason this exists. Go does the fan-out on goroutines, so a
21
+ # broadcast to ten thousand subscribers costs the application exactly one frame
22
+ # instead of ten thousand. Doing that in Ruby is what currently pushes
23
+ # ActionCable into a separate process with Redis behind it.
24
+ module WS
25
+ class NotConnected < StandardError; end
26
+
27
+ TEXT = "text"
28
+ BINARY = "binary"
29
+
30
+ class << self
31
+ # Called once per worker process, before any threads start.
32
+ def connect!(socket_path, logger: $stderr)
33
+ @logger = logger
34
+ @socket_path = socket_path
35
+ @monitor = Monitor.new
36
+ @pending = {}
37
+ @seq = 0
38
+ @conn = UNIXSocket.new(socket_path)
39
+ @conn.sync = true
40
+ @conn.write Wire.frame(Wire::HELLO, Wire.encode_kv([
41
+ ["version", Wire::VERSION],
42
+ ["role", "ws"],
43
+ ["pid", Process.pid]
44
+ ]))
45
+ start_reader
46
+ true
47
+ rescue SystemCallError => e
48
+ @conn = nil
49
+ logger.puts "[puma-plus] ws control channel unavailable: #{e.class}: #{e.message}"
50
+ false
51
+ end
52
+
53
+ # Is the control channel usable?
54
+ def connected? = !@conn.nil?
55
+
56
+ # Send a reliable, ordered message to one connection.
57
+ #
58
+ # +stream:+ targets one stream of a WebTransport session. Without it the
59
+ # message goes to the connection's default channel, which is all a
60
+ # WebSocket has anyway -- so code that ignores streams works on both.
61
+ def send(conn_id, payload, opcode: TEXT, stream: nil)
62
+ args = { "conn_id" => conn_id, "opcode" => opcode }
63
+ args["stream_id"] = stream if stream
64
+ command(Wire::WS_SEND, args, payload)
65
+ end
66
+
67
+ # Send a best-effort datagram to one connection. WebTransport only.
68
+ #
69
+ # Deliberately a separate method from #send rather than a keyword on it.
70
+ # A datagram may be dropped, duplicated or delivered out of order; that is
71
+ # a fundamentally different contract, and a one-word argument is far too
72
+ # easy to copy between call sites without noticing the guarantee changed.
73
+ #
74
+ # Silently does nothing for a WebSocket client, which has no unreliable
75
+ # channel. Check PumaPlus::WSEvent#transport if you need to know.
76
+ def datagram(conn_id, payload)
77
+ command(Wire::WS_DATAGRAM, { "conn_id" => conn_id }, payload)
78
+ end
79
+
80
+ # Open a server-initiated stream on a WebTransport session.
81
+ # Returns the new stream id, or nil if the transport has no streams.
82
+ def open_stream(conn_id)
83
+ id = query_raw(Wire::WS_OPEN_STREAM, "conn_id" => conn_id)
84
+ id.nil? || id.empty? ? nil : id
85
+ end
86
+
87
+ # Broadcast to every subscriber of a topic.
88
+ #
89
+ # One frame regardless of how many subscribers there are; Go performs the
90
+ # writes. Returns nil rather than a delivery count because waiting for one
91
+ # would serialise the caller behind every recipient's socket -- use
92
+ # #subscribers if you need the number.
93
+ def publish(topic, payload, opcode: TEXT)
94
+ command(Wire::WS_PUBLISH, { "topic" => topic, "opcode" => opcode }, payload)
95
+ end
96
+
97
+ # Broadcast a best-effort datagram to a topic. WebTransport subscribers
98
+ # receive it; WebSocket subscribers are skipped, since they have no
99
+ # unreliable channel.
100
+ #
101
+ # WS.publish("room:42", chat) # reliable -- everyone
102
+ # WS.publish_datagram("game:7", pos) # lossy -- WebTransport only
103
+ #
104
+ # The asymmetry is the point: one of those is safe for chat, the other is
105
+ # not.
106
+ def publish_datagram(topic, payload)
107
+ command(Wire::WS_PUBLISH_DATAGRAM, { "topic" => topic }, payload)
108
+ end
109
+
110
+ # Which transport a connection is using: "websocket" or "webtransport".
111
+ def transport(conn_id)
112
+ query("transport", "conn_id" => conn_id)
113
+ end
114
+
115
+ def subscribe(conn_id, topic)
116
+ command(Wire::WS_SUBSCRIBE, { "conn_id" => conn_id, "topic" => topic })
117
+ end
118
+
119
+ def unsubscribe(conn_id, topic)
120
+ command(Wire::WS_UNSUBSCRIBE, { "conn_id" => conn_id, "topic" => topic })
121
+ end
122
+
123
+ def close(conn_id, code: 1000, reason: "")
124
+ command(Wire::WS_CLOSE,
125
+ { "conn_id" => conn_id, "code" => code.to_s, "reason" => reason })
126
+ end
127
+
128
+ # How many connections are subscribed to a topic. Synchronous.
129
+ def subscribers(topic)
130
+ query("subscribers", "topic" => topic).to_i
131
+ end
132
+
133
+ # Total live WebSocket connections across the whole server, not just this
134
+ # worker -- Go holds them all.
135
+ def connections
136
+ query("connections").to_i
137
+ end
138
+
139
+ # Is this connection still open?
140
+ def open?(conn_id)
141
+ query("exists", "conn_id" => conn_id) == "true"
142
+ end
143
+
144
+ private
145
+
146
+ def command(type, args, payload = nil)
147
+ raise NotConnected, "ws control channel is not connected" unless @conn
148
+
149
+ frame = Wire.frame(type, Wire.encode_kv(args.to_a) + (payload ? payload.to_s.b : ""))
150
+ # One writer at a time: frames must not interleave on the wire, and any
151
+ # thread may be publishing.
152
+ @monitor.synchronize { @conn.write(frame) }
153
+ nil
154
+ rescue IOError, SystemCallError => e
155
+ @conn = nil
156
+ raise NotConnected, "ws control channel lost: #{e.class}"
157
+ end
158
+
159
+ # Synchronous request/reply on a command frame that answers directly,
160
+ # rather than through the generic query path.
161
+ def query_raw(type, extra = {})
162
+ raise NotConnected, "ws control channel is not connected" unless @conn
163
+
164
+ seq = nil
165
+ q = Queue.new
166
+ @monitor.synchronize do
167
+ @seq += 1
168
+ seq = @seq.to_s
169
+ @pending[seq] = q
170
+ @conn.write Wire.frame(type, Wire.encode_kv([["seq", seq]] + extra.to_a))
171
+ end
172
+ begin
173
+ Timeout.timeout(5) { q.pop }
174
+ rescue Timeout::Error
175
+ @monitor.synchronize { @pending.delete(seq) }
176
+ nil
177
+ end
178
+ end
179
+
180
+ # Synchronous request/reply, correlated by sequence number so many threads
181
+ # can have questions outstanding on the one channel.
182
+ def query(kind, extra = {})
183
+ raise NotConnected, "ws control channel is not connected" unless @conn
184
+
185
+ seq = nil
186
+ q = Queue.new
187
+ @monitor.synchronize do
188
+ @seq += 1
189
+ seq = @seq.to_s
190
+ @pending[seq] = q
191
+ @conn.write Wire.frame(Wire::WS_QUERY,
192
+ Wire.encode_kv([["seq", seq], ["kind", kind]] + extra.to_a))
193
+ end
194
+
195
+ # Bounded wait: a lost reply must not park an application thread forever.
196
+ result = nil
197
+ begin
198
+ Timeout.timeout(5) { result = q.pop }
199
+ rescue Timeout::Error
200
+ @monitor.synchronize { @pending.delete(seq) }
201
+ raise NotConnected, "ws query #{kind} timed out"
202
+ end
203
+ result
204
+ end
205
+
206
+ def start_reader
207
+ Thread.new do
208
+ Thread.current.name = "puma-plus ws-control"
209
+ loop do
210
+ type, payload = Wire.read_frame(@conn)
211
+ next unless type == Wire::WS_QUERY_REPLY
212
+
213
+ pairs, = Wire.decode_kv(payload)
214
+ fields = pairs.to_h
215
+ seq = fields["seq"]
216
+ waiter = @monitor.synchronize { @pending.delete(seq) }
217
+ waiter&.push(fields["result"])
218
+ rescue Wire::Closed, IOError, SystemCallError
219
+ break
220
+ end
221
+ # Wake anything still waiting rather than leaving it parked forever.
222
+ @monitor.synchronize do
223
+ @pending.each_value { |w| w.push(nil) }
224
+ @pending.clear
225
+ @conn = nil
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+
233
+ require "timeout"
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaPlus
4
+ # Convenience wrapper over the WebSocket keys in a Rack env.
5
+ #
6
+ # A WebSocket event arrives as an ordinary Rack call with REQUEST_METHOD set to
7
+ # WEBSOCKET, so existing routing and middleware work unchanged. This just saves
8
+ # the app from string-keyed env lookups.
9
+ class WSEvent
10
+ def self.websocket?(env) = env["REQUEST_METHOD"] == "WEBSOCKET"
11
+
12
+ def initialize(env)
13
+ @env = env
14
+ end
15
+
16
+ # open | message | datagram | stream_open | stream_close | close
17
+ def kind = @env["puma_plus.ws.event"]
18
+ def open? = kind == "open"
19
+ def message? = kind == "message"
20
+ def close? = kind == "close"
21
+
22
+ # An unreliable datagram. A distinct event kind from #message? on purpose:
23
+ # this payload may have been dropped, duplicated or reordered, and handling
24
+ # it identically to a guaranteed message is usually a bug.
25
+ def datagram? = kind == "datagram"
26
+
27
+ def stream_open? = kind == "stream_open"
28
+ def stream_close? = kind == "stream_close"
29
+
30
+ # "websocket" or "webtransport". Usually irrelevant -- that is the point of
31
+ # a shared hub -- but needed before reaching for a datagram.
32
+ def transport = @env["puma_plus.ws.transport"]
33
+ def webtransport? = transport == "webtransport"
34
+
35
+ # Which stream a WebTransport message arrived on. nil for WebSocket, whose
36
+ # single channel needs no name.
37
+ def stream_id = @env["puma_plus.ws.stream_id"]
38
+
39
+ def conn_id = @env["puma_plus.ws.conn_id"]
40
+ def mode = @env["puma_plus.ws.mode"]
41
+ def sticky? = mode == "sticky"
42
+ def binary? = @env["puma_plus.ws.opcode"] == "binary"
43
+
44
+ def close_code = @env["puma_plus.ws.close_code"]&.to_i
45
+ def close_reason = @env["puma_plus.ws.close_reason"]
46
+
47
+ # The message payload.
48
+ def data
49
+ @data ||= (@env["rack.input"]&.read || "")
50
+ end
51
+
52
+ # Metadata captured at upgrade time and echoed on every event, so a flexible
53
+ # handler knows who it is talking to without a lookup.
54
+ def meta
55
+ @meta ||= @env.each_with_object({}) do |(k, v), h|
56
+ h[k.delete_prefix("puma_plus.ws.meta.")] = v if k.start_with?("puma_plus.ws.meta.")
57
+ end
58
+ end
59
+
60
+ # Queue time for this event, in seconds. WebSocket messages are queued and
61
+ # measured exactly like HTTP requests, which is what lets them drive
62
+ # autoscaling.
63
+ def queue_time = @env["puma.request_queue_time"]
64
+
65
+ # Build the 101 response that asks Go to take over a connection.
66
+ #
67
+ # PumaPlus::WSEvent.upgrade(mode: :sticky, topics: ["room:42"])
68
+ def self.upgrade(mode: :flexible, topics: [], meta: {}, transport: nil)
69
+ headers = { "puma-plus-websocket" => mode.to_s }
70
+ headers["puma-plus-transport"] = transport.to_s if transport
71
+ headers["puma-plus-websocket-topic"] = Array(topics).map(&:to_s) unless Array(topics).empty?
72
+ unless meta.empty?
73
+ headers["puma-plus-websocket-meta"] = meta.map { |k, v| "#{k}=#{v}" }
74
+ end
75
+ [101, headers, []]
76
+ end
77
+ end
78
+ end