smplkit 3.0.134 → 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.
- checksums.yaml +4 -4
- data/lib/smplkit/client.rb +27 -27
- data/lib/smplkit/config/client.rb +44 -41
- data/lib/smplkit/event_stream.rb +453 -0
- data/lib/smplkit/flags/client.rb +45 -42
- data/lib/smplkit/jobs/client.rb +2 -2
- data/lib/smplkit/jobs/models.rb +2 -2
- data/lib/smplkit/logging/client.rb +69 -59
- data/lib/smplkit/transport.rb +1 -1
- data/lib/smplkit/version.rb +2 -2
- data/lib/smplkit.rb +1 -1
- metadata +2 -22
- data/lib/smplkit/ws.rb +0 -268
data/lib/smplkit/jobs/models.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
34
|
-
#
|
|
35
|
-
#
|
|
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
|
|
49
|
-
#
|
|
50
|
-
#
|
|
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
|
-
# +"
|
|
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
|
|
461
|
-
# +true+): +install+ opens a shared
|
|
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
|
-
@
|
|
518
|
-
@
|
|
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 /
|
|
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
|
|
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
|
|
604
|
-
#
|
|
605
|
-
#
|
|
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
|
-
@
|
|
608
|
-
|
|
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
|
|
647
|
-
# down the owned
|
|
648
|
-
# parent's transport and
|
|
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 @
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
@
|
|
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
|
-
@
|
|
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
|
|
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 +
|
|
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
|
|
707
|
-
@
|
|
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
|
-
|
|
717
|
-
|
|
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 @
|
|
720
|
-
@
|
|
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
|
-
@
|
|
724
|
-
@
|
|
733
|
+
@event_stream.start
|
|
734
|
+
@owns_stream = true
|
|
725
735
|
end
|
|
726
|
-
@
|
|
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
|
|
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
|
|
871
|
+
# --- Internal: event handlers (called by EventStream) ---
|
|
862
872
|
|
|
863
873
|
def handle_logger_changed(data)
|
|
864
874
|
key = data["id"] || ""
|
|
865
|
-
Smplkit.debug("
|
|
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("
|
|
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, "
|
|
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("
|
|
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, "
|
|
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("
|
|
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("
|
|
894
|
-
"failed to fetch log group #{key.inspect} after
|
|
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, "
|
|
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("
|
|
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, "
|
|
915
|
+
apply_deltas_and_fire(pre, "push")
|
|
906
916
|
end
|
|
907
917
|
|
|
908
918
|
def handle_loggers_changed(_data)
|
|
909
|
-
Smplkit.debug("
|
|
910
|
-
fetch_and_apply_deltas(trigger: "loggers_changed
|
|
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("
|
|
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
|
data/lib/smplkit/transport.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/smplkit/version.rb
CHANGED
|
@@ -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 (
|
|
30
|
-
#
|
|
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/
|
|
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.
|
|
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
|
|
@@ -933,6 +913,7 @@ files:
|
|
|
933
913
|
- lib/smplkit/context.rb
|
|
934
914
|
- lib/smplkit/debug.rb
|
|
935
915
|
- lib/smplkit/errors.rb
|
|
916
|
+
- lib/smplkit/event_stream.rb
|
|
936
917
|
- lib/smplkit/flags/client.rb
|
|
937
918
|
- lib/smplkit/flags/helpers.rb
|
|
938
919
|
- lib/smplkit/flags/models.rb
|
|
@@ -960,7 +941,6 @@ files:
|
|
|
960
941
|
- lib/smplkit/railtie.rb
|
|
961
942
|
- lib/smplkit/transport.rb
|
|
962
943
|
- lib/smplkit/version.rb
|
|
963
|
-
- lib/smplkit/ws.rb
|
|
964
944
|
- sig/smplkit.rbs
|
|
965
945
|
- sig/smplkit/config.rbs
|
|
966
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
|