Websockets (experimental)

This commit is contained in:
Yessiest 2024-04-28 18:39:42 +04:00
parent 6d33fa3a0e
commit 005edca228
6 changed files with 323 additions and 10 deletions

View File

@ -176,7 +176,7 @@ that some of that functionality may require additional dependencies, which
would otherwise be optional. This functionality includes:
- PHP-like Session handling (via `landline/extensions/session`)
- Websockets (via `landline/extensions/websockets`) (coming soon)
- Websockets (via `landline/extensions/websockets`) (available for testing)
- (Probably something else eventually)
Landline is built entirely on Rack webserver interface, while being agnostic

View File

@ -3,7 +3,7 @@
require 'landline/extensions/session'
require 'landline'
Landline::Session.hmac_secret = "Your secure signing secret here"
#Landline::Session.hmac_secret = "Your secure signing secret here"
app = Landline::Server.new do
get "/make_cookie" do

32
examples/websockets.ru Normal file
View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
require 'landline'
require 'landline/extensions/websocket'
class Test < Landline::App
setup do
websocket "/test", version: 7 do |socket|
socket.on :message do |msg|
puts "Client wrote: #{msg}"
end
socket.on :error do |err|
puts "Error occured: #{err.inspect}"
puts err.backtrace
end
socket.on :close do
puts "Client closed read connection"
end
socket.ready
socket.write("Hi!")
response = socket.read
socket.write("You said: #{response}")
socket.write("Goodbye!")
socket.close
rescue Exception => e
puts e.inspect
puts e.backtrace
end
end
end
run Test.new

View File

@ -0,0 +1,286 @@
# frozen_string_literal: true
require 'websocket'
module Landline
# Module that holds websocket primitives
module WebSocket
# Event system
module Eventifier
# Attach event listener
# @param event [Symbol]
# @param listener [#call]
def on(event, &listener)
@__listeners ||= {}
@__listeners[event] ||= []
@__listeners[event].append(listener)
listener
end
# Attach event listener
# @param event [Symbol]
# @param listener [#call]
def off(event, listener)
@__listeners ||= {}
@__listeners[event]&.delete(listener)
end
# Await for an event
# @param event [Symbol, Array<Symbol>] event or array of events to wait for
# @return [Array]
# @sg-ignore
def await(event)
blocking = true
output = nil
listener = proc do |*data|
output = data
blocking = false
end
if event.is_a? Array
event.each { |x| on(x, &listener) }
else
on(event, &listener)
end
while blocking; end
return output[0] if output.is_a? Array and output.length == 1
output
end
private
# Trigger the queue clearing process
# @return [void]
def _process
return if @processing
@__processing = true
@__queue ||= []
@__listeners ||= {}
until @__queue.empty?
event, msg = @__queue.shift
if @__listeners.include? event
@__listeners[event].each { |x| x.call(*msg) }
end
end
@processing = false
end
# Send internal event
# @param event [Symbol]
# @param data [Array]
# @return [void]
def _emit(event, *data)
return unless @__listeners
return unless @__listeners.include? event
@__queue ||= []
@__queue.push([event, data])
_process
end
end
# Socket-like object representing websocket interface
class WSockWrapper
include Eventifier
class WebSocketError < StandardError
end
def initialize(io, version: 7)
@io = io
@version = version
@frame_parser = ::WebSocket::Frame::Incoming::Server.new(
version: version
)
@readable = true
@writable = true
@data = Queue.new
on :message do |msg|
@data.enq(msg)
end
end
# Start the main loop for the eventifier
# @return [void]
def ready
return if @ready
_loop
@ready = true
end
# Send data through websocket
# @param data [String] binary data
# @return [void]
def write(data, type: :text)
unless @writable
raise self.class::WebSocketError,
"socket closed for writing"
end
frame = ::WebSocket::Frame::Outgoing::Server.new(
version: @version,
data: data,
type: type
)
@io.write(frame.to_s)
end
# Read data from socket synchronously
# @return [String, nil] nil returned if socket closes
def read
unless @readable
raise self.class::WebSocketError,
"socket closed for reading"
end
@data.deq
end
# Close the socket for reading
# @return [void]
def close_read
_emit :close
@readable = false
@io.close_read
end
# Close the socket for writing
def close_write
@writable = false
@io.close_write
end
# Establish a connection through handshake
# @return [self]
def self.handshake(request, version: 7, **opts)
raise StandardError, "stream cannot be hijacked" unless request.hijack
handshake = create_handshake(request, version: version, **opts)
return nil unless handshake
io = request.hijack.call
io.sendmsg(handshake.to_s)
new(io, version: version)
end
# Initiate a handshake
def self.create_handshake(request, **opts)
handshake = ::WebSocket::Handshake::Server.new(**opts)
handshake.from_hash({
headers: request.headers,
path: request.path_info,
query: request.query.query,
body: request.body
})
return nil unless handshake.finished? and handshake.valid?
handshake
end
# Close the socket
# @return [void]
def close
_close
@writable = false
@readable = false
end
private
# Event reading loop
# @return [void]
def _loop
@thread = Thread.new do
loop do
msg = _read
if msg and [:text, :binary].include? msg.type
_emit :message, msg
elsif msg and msg.type == :close
_emit :__close, msg
break
end
end
rescue IOError => e
@writable = false
_emit :error, e
close
ensure
close_read
end
end
# Receive data through websocket
# @return [String] output from frame
def _read
while (char = @io.getc)
@frame_parser << char
frame = @frame_parser.next
return frame if frame
end
end
# Close the websocket
# @return [void]
def _close
frame = ::WebSocket::Frame::Outgoing::Server.new(
version: @version,
type: :close
)
@io.write(frame.to_s) if @writable
sleep 0.1
@io.close
end
end
end
end
module Landline
module Handlers
# Web socket server handler
class WebSockServer < Landline::Probe
# @param path [Object]
# @param parent [Landline::Node]
# @param params [Hash] options hash
# @param callback [#call] callback to process request within
# @option params [Integer] :version protocol version
# @option params [Array<String>] :protocols array of supported sub-protocols
# @option params [Boolean] :secure true if the server will use wss:// protocol
def initialize(path, parent:, **params, &callback)
nodeparams = params.dup
nodeparams.delete(:version)
nodeparams.delete(:protocols)
nodeparams.delete(:secure)
super(path, parent: parent, **nodeparams)
@callback = callback
@params = params
end
# Method callback on successful request navigation
# Creates a websocket from a given request
# @param request [Landline::Request]
def process(request)
@callback.call(
Landline::WebSocket::WSockWrapper.handshake(
request,
**@params
)
)
end
end
end
module DSL
module PathConstructors
# (in Landline::Path context)
# Create a new websocket handler
def websocket(path, **args, &setup)
register(Landline::Handlers::WebSockServer.new(path,
parent: @origin,
**args,
&setup))
end
end
end
end

View File

@ -100,7 +100,7 @@ module Landline
@filters.append(block)
end
attr_reader :children, :properties, :request
attr_reader :children, :properties
attr_accessor :bounce, :pipeline
@ -157,9 +157,7 @@ module Landline
@bounce ? exit_stack(request) : _die(404)
rescue StandardError => e
_die(500, backtrace: [e.to_s] + e.backtrace)
ensure
@request = nil
_die(request, 500, backtrace: [e.to_s] + e.backtrace)
end
# Run enqueued postprocessors on navigation failure
@ -190,7 +188,7 @@ module Landline
# @param errorcode [Integer]
# @param backtrace [Array(String), nil]
# @raise [UncaughtThrowError] throws :finish to stop processing
def _die(errorcode, backtrace: nil)
def _die(request, errorcode, backtrace: nil)
proccontext = get_context(request)
throw :finish, [errorcode].append(
*proccontext.instance_exec(

View File

@ -14,9 +14,6 @@ module Landline
@callback = exec
end
attr_accessor :response
attr_reader :request
# Method callback on successful request navigation.
# Runs block supplied with object initialization.
# Request's #splat and #param are passed to block.