feat(websocket): introduce Prism and WebSocket
This commit is contained in:
1 parent
6e8b60ce9e
commit
33708860fa
2 files changed
+136
No files matched your search
@@ -0,0 +1,102 @@
|
||||
module Prism
|
||||
module WebSocket
|
||||
# `code` is the RFC 6455 section 7.4.1 close status code the caller
|
||||
# should echo back to the peer before dropping the connection (1002
|
||||
# generic protocol error by default, 1009 for the message/frame-too-big
|
||||
# cases below, 1007 for invalid UTF-8 in a TEXT message payload -
|
||||
# callers other than Connection#read_message are free to ignore it).
|
||||
class ProtocolError < StandardError
|
||||
attr_reader :code
|
||||
|
||||
def initialize(message = nil, code: 1002)
|
||||
super(message)
|
||||
@code = code
|
||||
end
|
||||
end
|
||||
|
||||
# RFC 6455 section 5.2 frame codec - encode/decode a single WebSocket
|
||||
# frame. Server->client frames are sent unmasked; client->server frames
|
||||
# arrive masked and must be unmasked before use (both directions are
|
||||
# exercised by Connection and by the tests, since this file has no
|
||||
# opinion on which side it is running as).
|
||||
module Frame
|
||||
CONTINUATION = 0x0
|
||||
TEXT = 0x1
|
||||
BINARY = 0x2
|
||||
CLOSE = 0x8
|
||||
PING = 0x9
|
||||
PONG = 0xA
|
||||
|
||||
Decoded = Struct.new(:fin, :opcode, :payload, :masked, keyword_init: true)
|
||||
|
||||
module_function
|
||||
|
||||
def encode(opcode, payload, fin: true, mask: false)
|
||||
payload = payload.to_s.dup.force_encoding(Encoding::BINARY)
|
||||
length = payload.bytesize
|
||||
mask_bit = mask ? 0x80 : 0x00
|
||||
|
||||
header = String.new(encoding: Encoding::BINARY)
|
||||
header << (((fin ? 0x80 : 0x00) | (opcode & 0x0F))).chr
|
||||
if length <= 125
|
||||
header << (mask_bit | length).chr
|
||||
elsif length <= 0xFFFF
|
||||
header << (mask_bit | 126).chr << [length].pack('n')
|
||||
else
|
||||
header << (mask_bit | 127).chr << [length].pack('Q>')
|
||||
end
|
||||
|
||||
return header + payload unless mask
|
||||
|
||||
mask_key = Random.bytes(4)
|
||||
header << mask_key << apply_mask(payload, mask_key)
|
||||
end
|
||||
|
||||
# Blocks on `io` (anything responding to #read) until one full frame
|
||||
# (header, optional mask key, payload) has arrived. `max_length`, when
|
||||
# given, rejects an oversized length field before it is ever used to
|
||||
# read from `io` - without it a peer only has to send a truthful
|
||||
# 64-bit length header (no need to actually deliver that many bytes
|
||||
# for this check alone) to make the caller block forever/allocate an
|
||||
# unbounded buffer.
|
||||
def decode(io, max_length: nil)
|
||||
first, second = read_exact(io, 2).unpack('C2')
|
||||
fin = (first & 0x80) != 0
|
||||
opcode = first & 0x0F
|
||||
masked = (second & 0x80) != 0
|
||||
length = second & 0x7F
|
||||
|
||||
length = read_exact(io, 2).unpack1('n') if length == 126
|
||||
length = read_exact(io, 8).unpack1('Q>') if length == 127
|
||||
|
||||
if max_length && length > max_length
|
||||
raise ProtocolError.new("frame payload of #{length} bytes exceeds the #{max_length} byte limit", code: 1009)
|
||||
end
|
||||
|
||||
mask_key = read_exact(io, 4) if masked
|
||||
payload = read_exact(io, length)
|
||||
payload = apply_mask(payload, mask_key) if masked
|
||||
|
||||
Decoded.new(fin: fin, opcode: opcode, payload: payload, masked: masked)
|
||||
end
|
||||
|
||||
def apply_mask(payload, mask_key)
|
||||
masked = payload.dup.force_encoding(Encoding::BINARY)
|
||||
length = masked.bytesize
|
||||
index = 0
|
||||
while index < length
|
||||
masked.setbyte(index, masked.getbyte(index) ^ mask_key.getbyte(index % 4))
|
||||
index += 1
|
||||
end
|
||||
masked
|
||||
end
|
||||
|
||||
def read_exact(io, count)
|
||||
return String.new(encoding: Encoding::BINARY) if count.zero?
|
||||
data = io.read(count)
|
||||
raise ProtocolError, 'connection closed mid-frame' if data.nil? || data.bytesize != count
|
||||
data
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
module Prism
|
||||
module WebSocket
|
||||
# RFC 6455 section 4.2 opening handshake - detects an Upgrade:
|
||||
# websocket request and computes the Sec-WebSocket-Accept response
|
||||
# header from the client's Sec-WebSocket-Key.
|
||||
module Handshake
|
||||
GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||
|
||||
module_function
|
||||
|
||||
def request?(env)
|
||||
env['REQUEST_METHOD'] == 'GET' &&
|
||||
env['HTTP_UPGRADE'].to_s.downcase == 'websocket' &&
|
||||
env['HTTP_CONNECTION'].to_s.downcase.split(/,\s*/).include?('upgrade') &&
|
||||
!env['HTTP_SEC_WEBSOCKET_KEY'].to_s.empty?
|
||||
end
|
||||
|
||||
# The famous RFC 6455 section 1.3 worked example is the regression
|
||||
# test for this: key "dGhlIHNhbXBsZSBub25jZQ==" must produce accept
|
||||
# value "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=".
|
||||
def accept_key(client_key)
|
||||
[Digest::SHA1.digest(client_key + GUID)].pack('m0')
|
||||
end
|
||||
|
||||
def response_headers(env)
|
||||
{
|
||||
'Upgrade' => 'websocket',
|
||||
'Connection' => 'Upgrade',
|
||||
'Sec-WebSocket-Accept' => accept_key(env.fetch('HTTP_SEC_WEBSOCKET_KEY'))
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user