feat(websocket): add Prism and WebSocket
This commit is contained in:
1 parent
d673a50ed4
commit
6e8b60ce9e
1 file changed
+136
@@ -0,0 +1,136 @@
|
||||
module Prism
|
||||
module WebSocket
|
||||
# Wraps a raw socket that has already completed the opening handshake.
|
||||
# Handles the framing-level protocol on its own (ping->pong, message
|
||||
# fragmentation reassembly, close handshake) so application handlers
|
||||
# only ever see whole text/binary messages via #read_message.
|
||||
class Connection
|
||||
# RFC 6455 sets no upper bound on frame/message size - a raw length
|
||||
# field can claim up to 2**64 bytes. Left unchecked, a single
|
||||
# malicious or buggy peer can force an unbounded buffer allocation
|
||||
# (or an effectively infinite fragmented message) per connection.
|
||||
# 16 MiB comfortably covers any real chat/notification/live-update
|
||||
# payload this framework's Hub is meant to broadcast.
|
||||
DEFAULT_MAX_MESSAGE_SIZE = 16 * 1024 * 1024
|
||||
|
||||
attr_reader :env
|
||||
|
||||
def initialize(socket, env: {}, max_message_size: DEFAULT_MAX_MESSAGE_SIZE)
|
||||
@socket = socket
|
||||
@env = env
|
||||
@closed = false
|
||||
@max_message_size = max_message_size
|
||||
end
|
||||
|
||||
def closed?
|
||||
@closed
|
||||
end
|
||||
|
||||
# Blocks until one complete (possibly reassembled from several
|
||||
# fragments) text/binary message arrives, or returns nil once the
|
||||
# peer has closed the connection (after echoing the close frame back,
|
||||
# per RFC 6455 section 5.5.1).
|
||||
def read_message
|
||||
return nil if @closed
|
||||
message_opcode = nil
|
||||
buffer = String.new(encoding: Encoding::BINARY)
|
||||
|
||||
loop do
|
||||
frame = Frame.decode(@socket, max_length: @max_message_size)
|
||||
# RFC 6455 section 5.1: "the server MUST close the connection
|
||||
# upon receiving a frame that is not masked" - a Connection is
|
||||
# always the server's view of a single client, so every frame it
|
||||
# reads must be masked regardless of opcode.
|
||||
raise ProtocolError, 'received an unmasked frame from the client' unless frame.masked
|
||||
|
||||
case frame.opcode
|
||||
when Frame::PING
|
||||
write_frame(Frame::PONG, frame.payload)
|
||||
when Frame::PONG
|
||||
nil
|
||||
when Frame::CLOSE
|
||||
write_frame(Frame::CLOSE, frame.payload)
|
||||
@closed = true
|
||||
return nil
|
||||
when Frame::CONTINUATION
|
||||
raise ProtocolError, 'continuation frame without a preceding data frame' if message_opcode.nil?
|
||||
buffer << frame.payload
|
||||
check_message_size!(buffer)
|
||||
return finish(message_opcode, buffer) if frame.fin
|
||||
else
|
||||
unless message_opcode.nil?
|
||||
raise ProtocolError, 'new data frame received before previous fragmented message finished'
|
||||
end
|
||||
return finish(frame.opcode, frame.payload) if frame.fin
|
||||
message_opcode = frame.opcode
|
||||
buffer << frame.payload
|
||||
check_message_size!(buffer)
|
||||
end
|
||||
end
|
||||
rescue ProtocolError => e
|
||||
# RFC 6455 section 7.1.5/7.4: a server that is about to drop the
|
||||
# connection because of a protocol violation SHOULD still send a
|
||||
# Close frame carrying the reason, rather than just vanishing -
|
||||
# best-effort (the peer, or the TCP connection itself, may already
|
||||
# be gone, in which case #close's own write_frame swallows the
|
||||
# resulting EPIPE/IOError same as everywhere else in this class).
|
||||
close(e.code, e.message.to_s.byteslice(0, 123).to_s)
|
||||
@closed = true
|
||||
raise
|
||||
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
||||
@closed = true
|
||||
nil
|
||||
end
|
||||
|
||||
def write_text(text)
|
||||
write_frame(Frame::TEXT, text)
|
||||
end
|
||||
|
||||
def write_binary(bytes)
|
||||
write_frame(Frame::BINARY, bytes)
|
||||
end
|
||||
|
||||
def close(code = 1000, reason = '')
|
||||
return if @closed
|
||||
write_frame(Frame::CLOSE, [code].pack('n') + reason)
|
||||
@closed = true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# RFC 6455 section 8.1: "When an endpoint is to interpret a byte
|
||||
# stream as UTF-8 but finds that the byte stream is not, in fact, a
|
||||
# valid UTF-8 stream, that endpoint MUST _Fail the WebSocket
|
||||
# Connection_" - checked on the fully reassembled message, not per
|
||||
# fragment, since a multi-byte UTF-8 character can legally be split
|
||||
# across a CONTINUATION boundary and only becomes checkable once
|
||||
# whole again.
|
||||
def finish(opcode, buffer)
|
||||
return buffer unless opcode == Frame::TEXT
|
||||
text = buffer.dup.force_encoding(Encoding::UTF_8)
|
||||
unless text.valid_encoding?
|
||||
raise ProtocolError.new('text frame payload is not valid UTF-8', code: 1007)
|
||||
end
|
||||
text
|
||||
end
|
||||
|
||||
# A message reassembled from many small fragments can exceed
|
||||
# @max_message_size even though each individual frame passed
|
||||
# Frame.decode's per-frame check - checked again after every
|
||||
# fragment is appended, not just once at the end.
|
||||
def check_message_size!(buffer)
|
||||
return unless buffer.bytesize > @max_message_size
|
||||
raise ProtocolError.new(
|
||||
"reassembled message of #{buffer.bytesize} bytes exceeds the #{@max_message_size} byte limit",
|
||||
code: 1009
|
||||
)
|
||||
end
|
||||
|
||||
def write_frame(opcode, payload)
|
||||
@socket.write(Frame.encode(opcode, payload))
|
||||
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
||||
@closed = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user