glottis 0.3.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f9b168031125cddf48f2087d3e3cd6c849e9a16972866b21db5d38c9cd2f83e
4
- data.tar.gz: 64495dc081d2130d83ab6f3992e6f40de2bcdde85e65c0b3580cd46adcbf4af5
3
+ metadata.gz: b19cc9121495467f570a8989b7cbd66aba5069f70bfa7044e36541ae8d407483
4
+ data.tar.gz: a854ee9fe9e2cfa2b0969e495b9853884738d202e7a1b21ccae2f8b36bc7f9e6
5
5
  SHA512:
6
- metadata.gz: ef03824e6fc0c56a6d1df2a93965f851b1795a9892c9bc35a404cd795514f0a692bdb4dcfdcd6b06896f61241fb4010186395b1d845c88a896a709bef6c3c5d9
7
- data.tar.gz: 77c0d6bd62771b0fd28af07625ef8f1cedb986655ff68eb9bb0260c92beb47ca1b26c42f0b0cc482b6bb503599363e5daa81304546a8680865d073476329d613
6
+ metadata.gz: dc5f81d11d5ab1a4fee50a7e1384534799abb05d24ea34c47460d8c05d07834f3ce3a5684a945f824315d8e9df83e11fe67df1511b0dee8d08000b486eb090e6
7
+ data.tar.gz: 2de12c2620eda6634c55775794408f6569aaab040cfaa64de49639f17e7434d8dce573582b5e5cc7cd6f6fecca794a12aec10559c63f8a45d87f153fc9525269
data/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semmle.com/semver/1.0.0/).
7
7
 
8
+ ## [0.4.0] - 2026-07-24
9
+
10
+ ### Changed
11
+ - Minor version release for full Ruby 3.4+ compatibility and modernized dependency stack.
12
+ - Enhanced `RemoteInputHandler` with Server-Sent Events (SSE) stream parsing (`data: {...}`).
13
+ - Enhanced `RemoteOutputHandler` with automatic session cookie forwarding (`SID`) and strict JSON payload formatting (`{"to": "*", "body": "..."}`).
14
+ - Verified live end-to-end message streaming against the modernized Valyx server.
15
+
8
16
  ## [0.3.1] - 2026-07-24
9
17
 
10
18
  ### Changed
data/README.md CHANGED
@@ -17,7 +17,7 @@ Install the gem by executing:
17
17
 
18
18
  Or add it to your application's Gemfile:
19
19
 
20
- $ gem 'glottis', '~> 0.3'
20
+ $ gem 'glottis', '~> 0.4'
21
21
 
22
22
  ## Usage
23
23
 
@@ -42,14 +42,21 @@ module Glottis
42
42
  private
43
43
 
44
44
  def start_handlers
45
+ remote_output = Handlers::RemoteOutputHandler.new(@outgoing, @host, @port)
46
+ remote_output.start
47
+
48
+ sleep 0.1 until remote_output.cookie || remote_output.sid
49
+
50
+ cookie = remote_output.cookie
51
+ remote_input = Handlers::RemoteInputHandler.new(@incoming, @host, @port, cookie)
52
+ remote_input.start
53
+
45
54
  @handlers = [
46
- Handlers::ConsoleInputHandler.new(@outgoing),
47
- Handlers::ConsoleOutputHandler.new(@incoming),
48
- Handlers::RemoteInputHandler.new(@incoming, @host, @port),
49
- Handlers::RemoteOutputHandler.new(@outgoing, @host, @port)
55
+ Handlers::ConsoleInputHandler.new(@outgoing).start,
56
+ Handlers::ConsoleOutputHandler.new(@incoming).start,
57
+ remote_input,
58
+ remote_output
50
59
  ]
51
-
52
- @handlers.each(&:start)
53
60
  end
54
61
 
55
62
  def stop_handlers
@@ -1,86 +1,117 @@
1
- # frozen_string_literal: true
2
-
3
- require 'logger'
4
- require 'uri'
5
- require 'net/http'
6
- require 'json'
7
-
8
- module Glottis
9
- module Handlers
10
- # Manages stream input from the remote valyx server.
11
- class RemoteInputHandler
12
- PROTOCOL = 'http'
13
- STREAM_DELIMITER = "\0"
14
- READ_TIMEOUT = 3600
15
- REMOTE_PATHS = {
16
- get_message_stream: '/api/messages/stream'
17
- }.freeze
18
-
19
- def initialize(incoming, host, port)
20
- @incoming = incoming
21
- @host = host
22
- @port = port
23
- @running = false
24
- @thread = nil
25
- @buffer = String.new
26
- setup_http
27
- end
28
-
29
- def start
30
- @running = true
31
- @thread = Thread.new do
32
- @http.start
33
- read_loop
34
- end
35
- self
36
- end
37
-
38
- def join
39
- @thread&.join
40
- end
41
-
42
- def cleanup
43
- @running = false
44
- @http.finish if @http.started?
45
- @thread&.kill if @thread&.alive?
46
- end
47
-
48
- private
49
-
50
- def setup_http
51
- @http = Net::HTTP.new(@host, @port)
52
- @http.open_timeout = 1
53
- @http.read_timeout = READ_TIMEOUT
54
- end
55
-
56
- def read_loop
57
- Client.logger.info('reading stream...')
58
- @http.get(REMOTE_PATHS.fetch(:get_message_stream)) do |chunk|
59
- break unless @running
60
-
61
- Client.logger.debug("got chunk of length: #{chunk.length}")
62
- process_chunk(chunk)
63
- end
64
- rescue StandardError => e
65
- Client.logger.error("Error reading message stream: #{e.message}") if @running
66
- end
67
-
68
- def process_chunk(chunk)
69
- @buffer << chunk
70
-
71
- while (delimiter_index = @buffer.index(STREAM_DELIMITER))
72
- msg_raw = @buffer.slice!(0..delimiter_index)
73
- msg_data = msg_raw.chomp(STREAM_DELIMITER)
74
- next if msg_data.empty?
75
-
76
- begin
77
- parsed_msg = JSON.parse(msg_data)
78
- @incoming.push(parsed_msg)
79
- rescue JSON::ParserError => e
80
- Client.logger.warn("Failed to parse JSON payload: #{e.message}")
81
- end
82
- end
83
- end
84
- end
85
- end
86
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'uri'
5
+ require 'net/http'
6
+ require 'json'
7
+
8
+ module Glottis
9
+ module Handlers
10
+ # Manages stream input from the remote valyx server.
11
+ class RemoteInputHandler
12
+ PROTOCOL = 'http'
13
+ STREAM_DELIMITER = "\0"
14
+ READ_TIMEOUT = 3600
15
+ REMOTE_PATHS = {
16
+ get_message_stream: '/api/v1/messages/stream',
17
+ legacy_message_stream: '/api/messages/stream'
18
+ }.freeze
19
+
20
+ def initialize(incoming, host, port, cookie = nil)
21
+ @incoming = incoming
22
+ @host = host
23
+ @port = port
24
+ @cookie = cookie
25
+ @running = false
26
+ @thread = nil
27
+ @buffer = String.new
28
+ setup_http
29
+ end
30
+
31
+ def start
32
+ @running = true
33
+ @thread = Thread.new do
34
+ @http.start
35
+ read_loop
36
+ end
37
+ self
38
+ end
39
+
40
+ def join
41
+ @thread&.join
42
+ end
43
+
44
+ def cleanup
45
+ @running = false
46
+ @http.finish if @http.started?
47
+ @thread&.kill if @thread&.alive?
48
+ end
49
+
50
+ private
51
+
52
+ def setup_http
53
+ @http = Net::HTTP.new(@host, @port)
54
+ @http.open_timeout = 2
55
+ @http.read_timeout = READ_TIMEOUT
56
+ end
57
+
58
+ def read_loop
59
+ Client.logger.info('reading stream...')
60
+ headers = @cookie ? { 'Cookie' => @cookie } : {}
61
+
62
+ stream_request(headers) do |chunk|
63
+ break unless @running
64
+
65
+ process_chunk(chunk)
66
+ end
67
+ rescue StandardError => e
68
+ Client.logger.error("Error reading message stream: #{e.message}") if @running
69
+ end
70
+
71
+ def stream_request(headers, &block)
72
+ req = Net::HTTP::Get.new(REMOTE_PATHS.fetch(:get_message_stream), headers)
73
+
74
+ @http.request(req) do |response|
75
+ if Integer(response.code) == 404
76
+ legacy_req = Net::HTTP::Get.new(REMOTE_PATHS.fetch(:legacy_message_stream), headers)
77
+ @http.request(legacy_req) { |legacy_resp| legacy_resp.read_body(&block) }
78
+ else
79
+ response.read_body(&block)
80
+ end
81
+ end
82
+ end
83
+
84
+ def process_chunk(chunk)
85
+ @buffer << chunk
86
+
87
+ # Process SSE format ("data: {...}\n\n")
88
+ while (line_end = @buffer.index("\n"))
89
+ line = @buffer.slice!(0..line_end).strip
90
+ next if line.empty? || line.start_with?(':')
91
+
92
+ if line.start_with?('data:')
93
+ json_str = line.sub(/^data:\s*/, '').chomp
94
+ parse_and_push(json_str)
95
+ end
96
+ end
97
+
98
+ # Process null-delimited format
99
+ while (delimiter_index = @buffer.index(STREAM_DELIMITER))
100
+ msg_raw = @buffer.slice!(0..delimiter_index)
101
+ msg_data = msg_raw.chomp(STREAM_DELIMITER)
102
+ parse_and_push(msg_data)
103
+ end
104
+ end
105
+
106
+ def parse_and_push(raw)
107
+ return if raw.empty?
108
+
109
+ parsed_msg = JSON.parse(raw)
110
+ parsed_msg['msg'] ||= parsed_msg['body']
111
+ @incoming.push(parsed_msg)
112
+ rescue JSON::ParserError => e
113
+ Client.logger.warn("Failed to parse JSON payload: #{e.message}")
114
+ end
115
+ end
116
+ end
117
+ end
@@ -1,96 +1,126 @@
1
- # frozen_string_literal: true
2
-
3
- require 'logger'
4
- require 'uri'
5
- require 'net/http'
6
- require 'json'
7
-
8
- module Glottis
9
- module Handlers
10
- # Manages posting outgoing messages to the remote valyx server.
11
- class RemoteOutputHandler
12
- POLL_INTERVAL = 0.1
13
- PROTOCOL = 'http'
14
-
15
- REMOTE_PATHS = {
16
- get_session: '/api/session',
17
- post_message: '/api/message'
18
- }.freeze
19
-
20
- def initialize(outgoing, host, port)
21
- @outgoing = outgoing
22
- @host = host
23
- @port = port
24
- @running = false
25
- @thread = nil
26
- @sid = nil
27
- setup_http
28
- end
29
-
30
- def start
31
- @running = true
32
- @thread = Thread.new do
33
- @http.start
34
- request_session
35
-
36
- send_queued while @running
37
- end
38
- self
39
- end
40
-
41
- def join
42
- @thread&.join
43
- end
44
-
45
- def cleanup
46
- @running = false
47
- @http.finish if @http.started?
48
- @thread&.kill if @thread&.alive?
49
- end
50
-
51
- private
52
-
53
- def setup_http
54
- @http = Net::HTTP.new(@host, @port)
55
- @http.open_timeout = 1
56
- @http.read_timeout = 1
57
- end
58
-
59
- def request_session
60
- Client.logger.info('making initial request for session...')
61
-
62
- session_req = @http.get(REMOTE_PATHS.fetch(:get_session))
63
- response = JSON.parse(session_req.body)
64
-
65
- raise "failed to retrieve session: #{response}" if Integer(session_req.code) != 201
66
-
67
- @sid = response['sid']
68
- Client.logger.info("current session id: #{@sid}")
69
- end
70
-
71
- def if_not_ok(http_req, ok_code = 200)
72
- yield http_req.body if Integer(http_req.code) != ok_code
73
- end
74
-
75
- def send_queued
76
- raw_msg = @outgoing.pop
77
- return if raw_msg.nil? || raw_msg == :shutdown
78
-
79
- Client.logger.info("processing message with #{@outgoing.size} messages outgoing")
80
-
81
- message_data = {
82
- from: @sid,
83
- to: '*',
84
- msg: raw_msg
85
- }.to_json
86
-
87
- headers = { 'Content-Type' => 'application/json' }
88
- post_req = @http.post(REMOTE_PATHS.fetch(:post_message), message_data, headers)
89
-
90
- if_not_ok(post_req) do |err|
91
- Client.logger.warn("failed to post message: #{message_data} #{err}")
92
- end
93
- end
94
- end
95
- end
96
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'uri'
5
+ require 'net/http'
6
+ require 'json'
7
+
8
+ module Glottis
9
+ module Handlers
10
+ # Manages posting outgoing messages to the remote valyx server.
11
+ class RemoteOutputHandler
12
+ POLL_INTERVAL = 0.1
13
+ PROTOCOL = 'http'
14
+
15
+ REMOTE_PATHS = {
16
+ get_session: '/api/v1/session',
17
+ legacy_session: '/api/session',
18
+ post_message: '/api/v1/messages',
19
+ legacy_post_message: '/api/message'
20
+ }.freeze
21
+
22
+ attr_reader :cookie, :sid
23
+
24
+ def initialize(outgoing, host, port)
25
+ @outgoing = outgoing
26
+ @host = host
27
+ @port = port
28
+ @running = false
29
+ @thread = nil
30
+ @sid = nil
31
+ @cookie = nil
32
+ setup_http
33
+ end
34
+
35
+ def start
36
+ @running = true
37
+ @thread = Thread.new do
38
+ @http.start
39
+ request_session
40
+
41
+ send_queued while @running
42
+ end
43
+ self
44
+ end
45
+
46
+ def join
47
+ @thread&.join
48
+ end
49
+
50
+ def cleanup
51
+ @running = false
52
+ @http.finish if @http.started?
53
+ @thread&.kill if @thread&.alive?
54
+ end
55
+
56
+ private
57
+
58
+ def setup_http
59
+ @http = Net::HTTP.new(@host, @port)
60
+ @http.open_timeout = 2
61
+ @http.read_timeout = 2
62
+ end
63
+
64
+ def request_session
65
+ Client.logger.info('making initial request for session...')
66
+
67
+ headers = { 'Content-Type' => 'application/json' }
68
+ session_req = fetch_session(headers)
69
+
70
+ code = Integer(session_req.code)
71
+ raise "failed to retrieve session: #{session_req.body}" unless [200, 201].include?(code)
72
+
73
+ parse_session_response(session_req)
74
+ end
75
+
76
+ def fetch_session(headers)
77
+ req = @http.post(REMOTE_PATHS.fetch(:get_session), '', headers)
78
+ return req if Integer(req.code) != 404
79
+
80
+ @http.get(REMOTE_PATHS.fetch(:legacy_session))
81
+ end
82
+
83
+ def parse_session_response(session_req)
84
+ if session_req['set-cookie']
85
+ raw_cookie = session_req['set-cookie']
86
+ @cookie = raw_cookie.split(';').first
87
+ end
88
+
89
+ response = begin
90
+ JSON.parse(session_req.body)
91
+ rescue StandardError
92
+ {}
93
+ end
94
+
95
+ @sid = response['username'] || response['sid'] || @cookie
96
+ Client.logger.info("current session id: #{@sid}")
97
+ end
98
+
99
+ def if_not_ok(http_req, ok_codes = [200, 201])
100
+ yield http_req.body unless ok_codes.include?(Integer(http_req.code))
101
+ end
102
+
103
+ def send_queued
104
+ raw_msg = @outgoing.pop
105
+ return if raw_msg.nil? || raw_msg == :shutdown
106
+
107
+ Client.logger.info("processing message with #{@outgoing.size} messages outgoing")
108
+
109
+ headers = { 'Content-Type' => 'application/json' }
110
+ headers['Cookie'] = @cookie if @cookie
111
+
112
+ modern_data = { to: '*', body: raw_msg }.to_json
113
+ post_req = @http.post(REMOTE_PATHS.fetch(:post_message), modern_data, headers)
114
+
115
+ if Integer(post_req.code) == 404
116
+ legacy_data = { from: @sid, to: '*', msg: raw_msg }.to_json
117
+ post_req = @http.post(REMOTE_PATHS.fetch(:legacy_post_message), legacy_data, headers)
118
+ end
119
+
120
+ if_not_ok(post_req) do |err|
121
+ Client.logger.warn("failed to post message: #{raw_msg} #{err}")
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Glottis
4
- VERSION = '0.3.1'
4
+ VERSION = '0.4.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glottis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Max DeLiso