smplkit 3.0.133 → 3.0.135

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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/api/runs_api.rb +70 -0
  3. data/lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat.rb +246 -0
  4. data/lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat_bucket.rb +193 -0
  5. data/lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat_failure.rb +249 -0
  6. data/lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat_next_scheduled.rb +231 -0
  7. data/lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat_resource.rb +187 -0
  8. data/lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat_tally.rb +274 -0
  9. data/lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stats_response.rb +165 -0
  10. data/lib/smplkit/_generated/jobs/lib/smplkit_jobs_client.rb +7 -0
  11. data/lib/smplkit/_generated/jobs/spec/api/runs_api_spec.rb +14 -0
  12. data/lib/smplkit/_generated/jobs/spec/models/run_stat_bucket_spec.rb +42 -0
  13. data/lib/smplkit/_generated/jobs/spec/models/run_stat_failure_spec.rb +58 -0
  14. data/lib/smplkit/_generated/jobs/spec/models/run_stat_next_scheduled_spec.rb +54 -0
  15. data/lib/smplkit/_generated/jobs/spec/models/run_stat_resource_spec.rb +48 -0
  16. data/lib/smplkit/_generated/jobs/spec/models/run_stat_spec.rb +60 -0
  17. data/lib/smplkit/_generated/jobs/spec/models/run_stat_tally_spec.rb +60 -0
  18. data/lib/smplkit/_generated/jobs/spec/models/run_stats_response_spec.rb +36 -0
  19. data/lib/smplkit/client.rb +27 -27
  20. data/lib/smplkit/config/client.rb +44 -41
  21. data/lib/smplkit/event_stream.rb +453 -0
  22. data/lib/smplkit/flags/client.rb +45 -42
  23. data/lib/smplkit/jobs/client.rb +2 -2
  24. data/lib/smplkit/jobs/models.rb +2 -2
  25. data/lib/smplkit/logging/client.rb +69 -59
  26. data/lib/smplkit/transport.rb +1 -1
  27. data/lib/smplkit/version.rb +2 -2
  28. data/lib/smplkit.rb +1 -1
  29. metadata +16 -22
  30. data/lib/smplkit/ws.rb +0 -268
@@ -4,8 +4,8 @@ module Smplkit
4
4
  # Smpl Jobs surface — exposed through +client.jobs.*+.
5
5
  #
6
6
  # Unlike Config/Flags/Logging, Jobs has no live "phone-home" agent — no
7
- # environment registration, no WebSocket — so its entire surface lives on a
8
- # single client. A {Job} is an active record: build it with
7
+ # environment registration, no event stream — so its entire surface lives on
8
+ # a single client. A {Job} is an active record: build it with
9
9
  # +client.jobs.new_recurring_job(...)+ / +new_manual_job(...)+ /
10
10
  # +schedule(...)+, set fields, and call {Job#save} (create when new,
11
11
  # full-replace update when it already exists) or {Job#delete}. Runs are
@@ -20,19 +20,19 @@
20
20
  # * *Live surface* — directly on the client. +register_adapter+ is a PRE-install
21
21
  # configuration call (allowed before +install+). +install+ opens the live
22
22
  # connection (monkey-patches the app's logging framework, discovers loggers,
23
- # fetches + applies levels, opens the shared WebSocket). +on_change+ /
23
+ # fetches + applies levels, opens the shared event stream). +on_change+ /
24
24
  # +refresh+ require +install+ first; calling them earlier raises
25
25
  # +NotInstalledError+.
26
26
  #
27
27
  # The client supports two construction shapes:
28
28
  #
29
29
  # * *Wired* into +Smplkit::Client+ — borrows the parent's logging transport for
30
- # both runtime fetch and CRUD and the parent's shared WebSocket for the live
31
- # channel. This is the common path.
30
+ # both runtime fetch and CRUD and the parent's shared event stream for the
31
+ # live channel. This is the common path.
32
32
  # * *Standalone* — +LoggingClient.new(api_key: ..., base_url: ..., ...)+ builds
33
- # and owns its own logging transport and an app transport (the WebSocket
34
- # gateway lives on the app service), and on +install+ opens and owns its own
35
- # WebSocket. +close+ tears down only the owned transports and owned WebSocket.
33
+ # and owns its own logging transport and an app transport (the event stream
34
+ # lives on the app service), and on +install+ opens and owns its own event
35
+ # stream. +close+ tears down only the owned transports and owned event stream.
36
36
  module Smplkit
37
37
  module Logging
38
38
  NOT_INSTALLED_MESSAGE = "Smpl Logging live operations require install() first — this opens a live " \
@@ -45,10 +45,9 @@ module Smplkit
45
45
  # client takes after it has already resolved them); otherwise the management
46
46
  # config resolver fills in whatever is missing (+~/.smplkit+ / env vars /
47
47
  # defaults). +environment+/+service+ resolve the same way (constructor
48
- # argument wins). The app transport is needed for the WebSocket gateway,
49
- # which lives on the app service (like flags); the app base URL is returned
50
- # so a standalone client can open its own WebSocket against the event
51
- # gateway.
48
+ # argument wins). The app transport is needed because the event stream
49
+ # lives on the app service (like flags); the app base URL is returned so a
50
+ # standalone client can open its own event stream against the app service.
52
51
  #
53
52
  # @api private
54
53
  def self.logging_transport(api_key:, base_url:, profile:, base_domain:, scheme:,
@@ -97,7 +96,7 @@ module Smplkit
97
96
  # +"INFO"+, +"DEBUG"+) — the same value the resolution algorithm returns.
98
97
  # @!attribute [rw] source
99
98
  # @return [String] Short string identifying the trigger — typically
100
- # +"websocket"+ or +"manual"+ (a +refresh+ call).
99
+ # +"push"+ (a live update) or +"manual"+ (a +refresh+ call).
101
100
  LoggerChangeEvent = Struct.new(:id, :level, :source, keyword_init: true) do
102
101
  def ==(other)
103
102
  other.is_a?(LoggerChangeEvent) &&
@@ -457,8 +456,8 @@ module Smplkit
457
456
  # @param debug [Boolean, nil] Enable SDK debug logging.
458
457
  # @param extra_headers [Hash{String => String}, nil] Extra headers
459
458
  # attached to every request.
460
- # @param streaming [Boolean] Live updates over WebSocket (default
461
- # +true+): +install+ opens a shared socket and server-side level
459
+ # @param streaming [Boolean] Live updates over the event stream (default
460
+ # +true+): +install+ opens a shared stream and server-side level
462
461
  # changes stream in. Set +false+ for the stateless apply-once surface:
463
462
  # +install+ still loads adapters, flushes discovery, and applies the
464
463
  # server's levels — all blocking — but NO socket or background thread
@@ -514,8 +513,8 @@ module Smplkit
514
513
  @key_listeners = Hash.new { |h, k| h[k] = [] }
515
514
  @adapters = []
516
515
  @explicit_adapters = false
517
- @ws_manager = nil
518
- @owns_ws = false
516
+ @event_stream = nil
517
+ @owns_stream = false
519
518
  @lock = Mutex.new
520
519
  end
521
520
 
@@ -545,12 +544,12 @@ module Smplkit
545
544
  @adapters.dup
546
545
  end
547
546
 
548
- # --- Live surface: install (gate) + transport / WebSocket helpers ---
547
+ # --- Live surface: install (gate) + transport / event stream helpers ---
549
548
 
550
549
  # Hook smplkit into the application's logging machinery.
551
550
  #
552
551
  # Loads adapters, scans existing loggers, applies levels from the smplkit
553
- # server, and wires WebSocket handlers for live updates. This IS the
552
+ # server, and wires event stream handlers for live updates. This IS the
554
553
  # explicit consent gate — +on_change+ / +refresh+ require it first.
555
554
  #
556
555
  # Idempotent — safe to call multiple times.
@@ -600,12 +599,15 @@ module Smplkit
600
599
  "(logging: #{@logging_http&.config&.host}): #{e.class}: #{e.message}")
601
600
  end
602
601
 
603
- # 7. Register WebSocket event handlers for real-time level updates.
604
- # In stateless mode (+streaming: false+) no socket is ever created
605
- # level changes then arrive only via +refresh+.
602
+ # 7. Register event stream handlers for real-time level updates, plus
603
+ # the bulk-refresh path as the stream's reconnect refetch so a stream
604
+ # outage ends with a full re-sync. In stateless mode
605
+ # (+streaming: false+) no stream is ever created — level changes then
606
+ # arrive only via +refresh+.
606
607
  if @streaming
607
- @ws_manager = ensure_ws
608
- ws_handlers.each { |event, handler| @ws_manager.on(event, &handler) }
608
+ @event_stream = ensure_event_stream
609
+ stream_handlers.each { |event, handler| @event_stream.on(event, &handler) }
610
+ @event_stream.on_reconnect(refetch_callback)
609
611
  end
610
612
 
611
613
  @connected = true
@@ -643,9 +645,9 @@ module Smplkit
643
645
 
644
646
  # Release resources — only those this client owns.
645
647
  #
646
- # Uninstalls the adapter hooks, unsubscribes from the WebSocket, and tears
647
- # down the owned WebSocket (standalone install). A wired client borrows the
648
- # parent's transport and WebSocket and closes neither.
648
+ # Uninstalls the adapter hooks, unsubscribes from the event stream, and
649
+ # tears down the owned event stream (standalone install). A wired client
650
+ # borrows the parent's transport and event stream and closes neither.
649
651
  def close
650
652
  Smplkit.debug("lifecycle", "LoggingClient.close() called")
651
653
  @adapters.each do |adapter|
@@ -653,13 +655,14 @@ module Smplkit
653
655
  rescue StandardError => e
654
656
  Smplkit.debug("logging", "adapter #{adapter.name} uninstall_hook failed: #{e.class}: #{e.message}")
655
657
  end
656
- if @ws_manager
657
- ws_handlers.each { |event, handler| @ws_manager.off(event, handler) }
658
- if @owns_ws
659
- @ws_manager.stop
660
- @owns_ws = false
658
+ if @event_stream
659
+ stream_handlers.each { |event, handler| @event_stream.off(event, handler) }
660
+ @event_stream.off_reconnect(refetch_callback)
661
+ if @owns_stream
662
+ @event_stream.stop
663
+ @owns_stream = false
661
664
  end
662
- @ws_manager = nil
665
+ @event_stream = nil
663
666
  end
664
667
  @connected = false
665
668
  end
@@ -674,7 +677,7 @@ module Smplkit
674
677
  #
675
678
  # Mirrors Ruby's +File.open+ block form: the client is closed
676
679
  # automatically when the block returns or raises, so a standalone client's
677
- # owned transports and WebSocket are always torn down.
680
+ # owned transports and event stream are always torn down.
678
681
  #
679
682
  # Smplkit::LoggingClient.open(environment: "production") do |logging|
680
683
  # logging.loggers.new("sqlalchemy.engine").save
@@ -701,10 +704,10 @@ module Smplkit
701
704
 
702
705
  # Memoized event → handler map so +install+ registers and +close+
703
706
  # unsubscribes the exact same callback objects. They are stored as procs
704
- # (not bound methods) because +SharedWebSocket#off+ removes by object
707
+ # (not bound methods) because +EventStream#off+ removes by object
705
708
  # identity, and +on(event, &proc)+ stores the very proc passed here.
706
- def ws_handlers
707
- @ws_handlers ||= {
709
+ def stream_handlers
710
+ @stream_handlers ||= {
708
711
  "logger_changed" => proc { |data| handle_logger_changed(data) },
709
712
  "logger_deleted" => proc { |data| handle_logger_deleted(data) },
710
713
  "group_changed" => proc { |data| handle_group_changed(data) },
@@ -713,17 +716,24 @@ module Smplkit
713
716
  }
714
717
  end
715
718
 
716
- def ensure_ws
717
- return @parent._ensure_ws unless @parent.nil?
719
+ # Memoized reconnect refetch — the same bulk-refresh path the
720
+ # +loggers_changed+ handler runs, registered with the stream on
721
+ # +install+ and deregistered (by object identity) on +close+.
722
+ def refetch_callback
723
+ @refetch_callback ||= proc { handle_loggers_changed({}) }
724
+ end
725
+
726
+ def ensure_event_stream
727
+ return @parent._ensure_event_stream unless @parent.nil?
718
728
 
719
- if @ws_manager.nil?
720
- @ws_manager = SharedWebSocket.new(
729
+ if @event_stream.nil?
730
+ @event_stream = EventStream.new(
721
731
  app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
722
732
  )
723
- @ws_manager.start
724
- @owns_ws = true
733
+ @event_stream.start
734
+ @owns_stream = true
725
735
  end
726
- @ws_manager
736
+ @event_stream
727
737
  end
728
738
 
729
739
  # --- Internal ---
@@ -777,8 +787,8 @@ module Smplkit
777
787
  # path).
778
788
  #
779
789
  # Silent — does not fire change-listener events. Use
780
- # +fetch_and_apply_deltas+ from the WS / refresh paths to get per-logger
781
- # fanout.
790
+ # +fetch_and_apply_deltas+ from the push / refresh paths to get
791
+ # per-logger fanout.
782
792
  def fetch_and_apply(trigger: "unknown")
783
793
  fetch_cache(trigger)
784
794
  apply_levels
@@ -858,58 +868,58 @@ module Smplkit
858
868
  end
859
869
  end
860
870
 
861
- # --- Internal: event handlers (called by SharedWebSocket) ---
871
+ # --- Internal: event handlers (called by EventStream) ---
862
872
 
863
873
  def handle_logger_changed(data)
864
874
  key = data["id"] || ""
865
- Smplkit.debug("websocket", "logger_changed: fetching logger #{key.inspect}")
875
+ Smplkit.debug("events", "logger_changed: fetching logger #{key.inspect}")
866
876
  pre = snapshot_effective_levels
867
877
  begin
868
878
  entry_id, entry = @loggers.get_logger_entry(key)
869
879
  @loggers_cache[entry_id || key] = entry
870
880
  rescue StandardError => e
871
- Smplkit.debug("websocket", "failed to fetch logger #{key.inspect} after WS event: #{e.class}: #{e.message}")
881
+ Smplkit.debug("events", "failed to fetch logger #{key.inspect} after push event: #{e.class}: #{e.message}")
872
882
  return
873
883
  end
874
- apply_deltas_and_fire(pre, "websocket")
884
+ apply_deltas_and_fire(pre, "push")
875
885
  end
876
886
 
877
887
  def handle_logger_deleted(data)
878
888
  key = data["id"] || ""
879
- Smplkit.debug("websocket", "logger_deleted: removing logger #{key.inspect}")
889
+ Smplkit.debug("events", "logger_deleted: removing logger #{key.inspect}")
880
890
  pre = snapshot_effective_levels
881
891
  @loggers_cache.delete(key)
882
- apply_deltas_and_fire(pre, "websocket")
892
+ apply_deltas_and_fire(pre, "push")
883
893
  end
884
894
 
885
895
  def handle_group_changed(data)
886
896
  key = data["id"] || ""
887
- Smplkit.debug("websocket", "group_changed: fetching group #{key.inspect}")
897
+ Smplkit.debug("events", "group_changed: fetching group #{key.inspect}")
888
898
  pre = snapshot_effective_levels
889
899
  begin
890
900
  entry_id, entry = @log_groups.get_group_entry(key)
891
901
  @groups_cache[entry_id || key] = entry
892
902
  rescue StandardError => e
893
- Smplkit.debug("websocket",
894
- "failed to fetch log group #{key.inspect} after WS event: #{e.class}: #{e.message}")
903
+ Smplkit.debug("events",
904
+ "failed to fetch log group #{key.inspect} after push event: #{e.class}: #{e.message}")
895
905
  return
896
906
  end
897
- apply_deltas_and_fire(pre, "websocket")
907
+ apply_deltas_and_fire(pre, "push")
898
908
  end
899
909
 
900
910
  def handle_group_deleted(data)
901
911
  key = data["id"] || ""
902
- Smplkit.debug("websocket", "group_deleted: removing group #{key.inspect}")
912
+ Smplkit.debug("events", "group_deleted: removing group #{key.inspect}")
903
913
  pre = snapshot_effective_levels
904
914
  @groups_cache.delete(key)
905
- apply_deltas_and_fire(pre, "websocket")
915
+ apply_deltas_and_fire(pre, "push")
906
916
  end
907
917
 
908
918
  def handle_loggers_changed(_data)
909
- Smplkit.debug("websocket", "loggers_changed: full re-fetch")
910
- fetch_and_apply_deltas(trigger: "loggers_changed WS event", source: "websocket")
919
+ Smplkit.debug("events", "loggers_changed: full re-fetch")
920
+ fetch_and_apply_deltas(trigger: "loggers_changed event", source: "push")
911
921
  rescue StandardError => e
912
- Smplkit.debug("websocket",
922
+ Smplkit.debug("events",
913
923
  "failed to re-fetch/apply logging levels after loggers_changed event: #{e.class}: #{e.message}")
914
924
  end
915
925
  end
@@ -65,7 +65,7 @@ module Smplkit
65
65
  #
66
66
  # Construction is side-effect-free: each transport connects lazily on its
67
67
  # first call. +app_url+ is carried alongside so the account settings client
68
- # and the WebSocket can reach the app service. +close+ tears down the
68
+ # and the event stream can reach the app service. +close+ tears down the
69
69
  # underlying Faraday connection pools.
70
70
  #
71
71
  # @api private
@@ -26,8 +26,8 @@ module Smplkit
26
26
  spec ? spec.version.to_s : VERSION
27
27
  end
28
28
 
29
- # The default User-Agent stamped on every outbound request (HTTP and the
30
- # WebSocket handshake) when the caller has not supplied their own.
29
+ # The default User-Agent stamped on every outbound request (including the
30
+ # live-updates event stream) when the caller has not supplied their own.
31
31
  #
32
32
  # The platform sits behind a WAF that rejects requests carrying no
33
33
  # User-Agent, and an SDK-identifying value keeps support/telemetry able to
data/lib/smplkit.rb CHANGED
@@ -43,7 +43,7 @@ require_relative "smplkit/log_level"
43
43
  require_relative "smplkit/context"
44
44
  require_relative "smplkit/config_resolution"
45
45
  require_relative "smplkit/metrics"
46
- require_relative "smplkit/ws"
46
+ require_relative "smplkit/event_stream"
47
47
 
48
48
  # Internal foundation shared by every product client.
49
49
  require_relative "smplkit/buffers"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smplkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.133
4
+ version: 3.0.135
5
5
  platform: ruby
6
6
  authors:
7
7
  - Smpl Solutions LLC
@@ -49,26 +49,6 @@ dependencies:
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
51
  version: '1'
52
- - !ruby/object:Gem::Dependency
53
- name: async-websocket
54
- requirement: !ruby/object:Gem::Requirement
55
- requirements:
56
- - - ">="
57
- - !ruby/object:Gem::Version
58
- version: '0.26'
59
- - - "<"
60
- - !ruby/object:Gem::Version
61
- version: '1'
62
- type: :runtime
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - ">="
67
- - !ruby/object:Gem::Version
68
- version: '0.26'
69
- - - "<"
70
- - !ruby/object:Gem::Version
71
- version: '1'
72
52
  - !ruby/object:Gem::Dependency
73
53
  name: concurrent-ruby
74
54
  requirement: !ruby/object:Gem::Requirement
@@ -787,6 +767,13 @@ files:
787
767
  - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_resource.rb
788
768
  - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_response.rb
789
769
  - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_retry.rb
770
+ - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat.rb
771
+ - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat_bucket.rb
772
+ - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat_failure.rb
773
+ - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat_next_scheduled.rb
774
+ - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat_resource.rb
775
+ - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stat_tally.rb
776
+ - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/run_stats_response.rb
790
777
  - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/usage.rb
791
778
  - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/usage_resource.rb
792
779
  - lib/smplkit/_generated/jobs/lib/smplkit_jobs_client/models/usage_response.rb
@@ -822,6 +809,13 @@ files:
822
809
  - lib/smplkit/_generated/jobs/spec/models/run_response_spec.rb
823
810
  - lib/smplkit/_generated/jobs/spec/models/run_retry_spec.rb
824
811
  - lib/smplkit/_generated/jobs/spec/models/run_spec.rb
812
+ - lib/smplkit/_generated/jobs/spec/models/run_stat_bucket_spec.rb
813
+ - lib/smplkit/_generated/jobs/spec/models/run_stat_failure_spec.rb
814
+ - lib/smplkit/_generated/jobs/spec/models/run_stat_next_scheduled_spec.rb
815
+ - lib/smplkit/_generated/jobs/spec/models/run_stat_resource_spec.rb
816
+ - lib/smplkit/_generated/jobs/spec/models/run_stat_spec.rb
817
+ - lib/smplkit/_generated/jobs/spec/models/run_stat_tally_spec.rb
818
+ - lib/smplkit/_generated/jobs/spec/models/run_stats_response_spec.rb
825
819
  - lib/smplkit/_generated/jobs/spec/models/usage_resource_spec.rb
826
820
  - lib/smplkit/_generated/jobs/spec/models/usage_response_spec.rb
827
821
  - lib/smplkit/_generated/jobs/spec/models/usage_spec.rb
@@ -919,6 +913,7 @@ files:
919
913
  - lib/smplkit/context.rb
920
914
  - lib/smplkit/debug.rb
921
915
  - lib/smplkit/errors.rb
916
+ - lib/smplkit/event_stream.rb
922
917
  - lib/smplkit/flags/client.rb
923
918
  - lib/smplkit/flags/helpers.rb
924
919
  - lib/smplkit/flags/models.rb
@@ -946,7 +941,6 @@ files:
946
941
  - lib/smplkit/railtie.rb
947
942
  - lib/smplkit/transport.rb
948
943
  - lib/smplkit/version.rb
949
- - lib/smplkit/ws.rb
950
944
  - sig/smplkit.rbs
951
945
  - sig/smplkit/config.rbs
952
946
  - sig/smplkit/flags.rbs
data/lib/smplkit/ws.rb DELETED
@@ -1,268 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "async"
5
- require "async/http/endpoint"
6
- require "async/http/protocol/http1"
7
- require "async/websocket/client"
8
- require "concurrent"
9
-
10
- module Smplkit
11
- # Manages a single WebSocket connection to the app service event gateway.
12
- #
13
- # A single +SharedWebSocket+ instance is shared across all product modules
14
- # (config, flags, logging) within one +Smplkit::Client+. Product modules
15
- # register listeners for specific event types; the shared connection
16
- # dispatches incoming events to the appropriate listeners.
17
- #
18
- # The connection runs on a dedicated SDK-owned thread that hosts the
19
- # +Async+ reactor and the underlying +async-websocket+ I/O. Public
20
- # methods are thread-safe and non-blocking.
21
- #
22
- # Gateway protocol:
23
- #
24
- # - Connect to +wss://app.<base_domain>/api/ws/v1/events?api_key={key}+
25
- # - Receive +{"type": "connected"}+ on success
26
- # - Receive events: +{"event": "config_changed", ...}+, etc.
27
- # - No subscribe message — the API key determines the account
28
- # - Heartbeat: server sends +"ping"+ (text), client responds with +"pong"+
29
- #
30
- # On disconnect the reactor reconnects with exponential backoff
31
- # (1, 2, 4, 8, 16, 32, 60 seconds, then capped). +stop+ closes the
32
- # connection from the outer thread; the reader exits and the daemon
33
- # thread terminates.
34
- class SharedWebSocket
35
- BACKOFF_SCHEDULE = [1, 2, 4, 8, 16, 32, 60].freeze
36
-
37
- # Sent on the WebSocket upgrade request — the platform WAF rejects
38
- # handshakes that carry no User-Agent. There is no caller-supplied
39
- # header surface on the WebSocket, so the SDK default always applies.
40
- USER_AGENT = Smplkit.user_agent.freeze
41
-
42
- def initialize(app_base_url:, api_key:, metrics: nil)
43
- @app_base_url = app_base_url
44
- @api_key = api_key
45
- @metrics = metrics
46
- @listeners = Hash.new { |h, k| h[k] = [] }
47
- @listeners_lock = Mutex.new
48
- @connection_status = "disconnected"
49
- @closed = false
50
- @ws_thread = nil
51
- @connection = nil
52
- @connection_lock = Mutex.new
53
- end
54
-
55
- # ----- Listener registration ------------------------------------
56
-
57
- def on(event_name, &callback)
58
- @listeners_lock.synchronize { @listeners[event_name] << callback }
59
- end
60
-
61
- def off(event_name, callback)
62
- @listeners_lock.synchronize { @listeners[event_name].delete(callback) }
63
- end
64
-
65
- # Dispatch +data+ to every listener registered for +event_name+.
66
- # Listener exceptions are caught and logged; one bad listener never
67
- # blocks the rest.
68
- def dispatch(event_name, data)
69
- callbacks = @listeners_lock.synchronize { @listeners[event_name].dup }
70
- callbacks.each do |cb|
71
- cb.call(data)
72
- rescue StandardError => e
73
- Smplkit.debug("websocket", "listener for #{event_name} raised: #{e.class}: #{e.message}")
74
- end
75
- end
76
-
77
- # ----- Connection status ----------------------------------------
78
-
79
- attr_reader :connection_status
80
-
81
- # ----- Lifecycle ------------------------------------------------
82
-
83
- def start
84
- return if @ws_thread&.alive?
85
-
86
- Smplkit.debug("websocket", "starting shared WebSocket background thread")
87
- @closed = false
88
- @connection_status = "connecting"
89
- @ws_thread = Thread.new { run_reactor }
90
- @ws_thread.name = "smplkit-shared-ws" if @ws_thread.respond_to?(:name=)
91
- end
92
-
93
- def stop
94
- Smplkit.debug("websocket", "stopping shared WebSocket")
95
- @closed = true
96
- close_active_connection
97
- thread = @ws_thread
98
- @ws_thread = nil
99
- if thread
100
- thread.join(2.0)
101
- thread.kill if thread.alive?
102
- end
103
- # Set authoritatively after the thread is dead so a racing connect
104
- # call (which also sets "connecting") cannot clobber this value.
105
- @connection_status = "disconnected"
106
- end
107
-
108
- # ----- URL builder ----------------------------------------------
109
-
110
- def build_ws_url
111
- url = @app_base_url.dup
112
- ws_url =
113
- if url.start_with?("https://")
114
- "wss://#{url[("https://".length)..]}"
115
- elsif url.start_with?("http://")
116
- "ws://#{url[("http://".length)..]}"
117
- else
118
- "wss://#{url}"
119
- end
120
- ws_url = ws_url.chomp("/")
121
- "#{ws_url}/api/ws/v1/events?api_key=#{@api_key}"
122
- end
123
-
124
- # ----- Inbound message handling (extracted for tests) -----------
125
-
126
- # Process a single inbound text frame the way the live reactor does:
127
- # +"ping"+ → call +send_pong+ with +"pong"+; otherwise parse JSON and,
128
- # if a +"event"+ key is present, dispatch to listeners.
129
- #
130
- # Returns one of +:ping+, +:event+, +:no_event+, +:unparseable+ for the
131
- # caller to log/observe; the live reactor ignores the return value.
132
- def handle_inbound(text, send_pong:)
133
- if text == "ping"
134
- send_pong.call("pong")
135
- return :ping
136
- end
137
-
138
- data =
139
- begin
140
- JSON.parse(text)
141
- rescue JSON::ParserError
142
- return :unparseable
143
- end
144
-
145
- event = data["event"]
146
- if event
147
- dispatch(event, data)
148
- :event
149
- else
150
- :no_event
151
- end
152
- end
153
-
154
- private
155
-
156
- def run_reactor
157
- Sync do |task|
158
- ws_main(task)
159
- end
160
- rescue StandardError => e
161
- Smplkit.debug("websocket", "shared WebSocket thread exited unexpectedly: #{e.class}: #{e.message}")
162
- end
163
-
164
- def ws_main(task)
165
- connect(task)
166
- rescue StandardError => e
167
- return if @closed
168
-
169
- Smplkit.debug(
170
- "websocket",
171
- "connection failed on startup (url: #{safe_url}): #{e.class}: #{e.message}"
172
- )
173
- reconnect(task)
174
- end
175
-
176
- def connect(task)
177
- return if @closed
178
-
179
- url = build_ws_url
180
- @connection_status = "connecting"
181
- Smplkit.debug("websocket", "connecting to #{safe_url}")
182
-
183
- # Force HTTP/1.1 for the WebSocket upgrade. async-http defaults to
184
- # HTTP/2 on TLS endpoints, but the smplkit event gateway speaks the
185
- # classic RFC 6455 upgrade over HTTP/1.1, not the HTTP/2-tunneled
186
- # variant from RFC 8441 — without this override the upgrade returns
187
- # a Protocol::HTTP2::StreamError before any frame is exchanged.
188
- endpoint = Async::HTTP::Endpoint.parse(url, protocol: Async::HTTP::Protocol::HTTP1)
189
- headers = { "user-agent" => USER_AGENT }
190
- connection = Async::WebSocket::Client.connect(endpoint, headers: headers)
191
- @connection_lock.synchronize { @connection = connection }
192
- Smplkit.debug("websocket", "WebSocket connected, waiting for confirmation")
193
-
194
- raw = connection.read
195
- data = JSON.parse(message_to_string(raw))
196
- if data["type"] == "error"
197
- err = data["message"]
198
- Smplkit.debug("websocket", "connection error from server: #{err.inspect}")
199
- raise "Connection error: #{err}"
200
- end
201
-
202
- @connection_status = "connected"
203
- @metrics&.record_gauge("platform.websocket_connections", 1, unit: "connections")
204
- receive_loop(task, connection)
205
- end
206
-
207
- def receive_loop(task, connection)
208
- until @closed
209
- message = connection.read
210
- break if message.nil?
211
-
212
- text = message_to_string(message)
213
- handle_inbound(text, send_pong: ->(reply) { connection.write(reply) })
214
- end
215
- rescue StandardError => e
216
- return if @closed
217
-
218
- Smplkit.debug("websocket", "receive loop error: #{e.class}: #{e.message}")
219
- @connection_status = "reconnecting"
220
- @metrics&.record_gauge("platform.websocket_connections", 0, unit: "connections")
221
- reconnect(task)
222
- end
223
-
224
- def reconnect(task)
225
- attempt = 0
226
- until @closed
227
- delay = BACKOFF_SCHEDULE[[attempt, BACKOFF_SCHEDULE.length - 1].min]
228
- Smplkit.debug("websocket", "reconnecting in #{delay}s (attempt #{attempt + 1})")
229
- task.sleep(delay)
230
- return if @closed
231
-
232
- begin
233
- connect(task)
234
- return
235
- rescue StandardError => e
236
- Smplkit.debug("websocket", "reconnect attempt #{attempt + 1} failed: #{e.class}: #{e.message}")
237
- attempt += 1
238
- end
239
- end
240
- end
241
-
242
- def message_to_string(message)
243
- return message if message.is_a?(String)
244
- return message.to_str if message.respond_to?(:to_str)
245
-
246
- message.to_s
247
- end
248
-
249
- def close_active_connection
250
- conn = @connection_lock.synchronize do
251
- c = @connection
252
- @connection = nil
253
- c
254
- end
255
- return unless conn
256
-
257
- begin
258
- conn.close
259
- rescue StandardError => e
260
- Smplkit.debug("websocket", "close raised: #{e.class}: #{e.message}")
261
- end
262
- end
263
-
264
- def safe_url
265
- build_ws_url.split("?", 2).first
266
- end
267
- end
268
- end