parse-stack-next 4.5.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 +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require "base64"
|
|
10
|
+
require "digest"
|
|
11
|
+
require "monitor"
|
|
12
|
+
require "timeout"
|
|
13
|
+
|
|
14
|
+
require_relative "health_monitor"
|
|
15
|
+
require_relative "circuit_breaker"
|
|
16
|
+
require_relative "event_queue"
|
|
17
|
+
require_relative "../pipeline_security"
|
|
18
|
+
|
|
19
|
+
module Parse
|
|
20
|
+
module LiveQuery
|
|
21
|
+
# WebSocket client for Parse LiveQuery server.
|
|
22
|
+
# Manages WebSocket connection, authentication, and subscription lifecycle.
|
|
23
|
+
#
|
|
24
|
+
# Features:
|
|
25
|
+
# - Automatic ping/pong keep-alive with stale connection detection
|
|
26
|
+
# - Circuit breaker for intelligent failure handling
|
|
27
|
+
# - Event queue with backpressure protection
|
|
28
|
+
# - Automatic reconnection with exponential backoff and jitter
|
|
29
|
+
#
|
|
30
|
+
# @example Basic usage
|
|
31
|
+
# client = Parse::LiveQuery::Client.new(
|
|
32
|
+
# url: "wss://your-parse-server.com",
|
|
33
|
+
# application_id: "your_app_id",
|
|
34
|
+
# client_key: "your_client_key"
|
|
35
|
+
# )
|
|
36
|
+
#
|
|
37
|
+
# subscription = client.subscribe("Song", where: { artist: "Beatles" })
|
|
38
|
+
# subscription.on(:create) { |song| puts "New song!" }
|
|
39
|
+
#
|
|
40
|
+
# client.shutdown(timeout: 5)
|
|
41
|
+
#
|
|
42
|
+
class Client
|
|
43
|
+
# WebSocket operation codes
|
|
44
|
+
OPCODE_CONTINUATION = 0x0
|
|
45
|
+
OPCODE_TEXT = 0x1
|
|
46
|
+
OPCODE_BINARY = 0x2
|
|
47
|
+
OPCODE_CLOSE = 0x8
|
|
48
|
+
OPCODE_PING = 0x9
|
|
49
|
+
OPCODE_PONG = 0xA
|
|
50
|
+
|
|
51
|
+
# Default maximum message size (1MB) - prevents memory exhaustion attacks
|
|
52
|
+
DEFAULT_MAX_MESSAGE_SIZE = 1_048_576
|
|
53
|
+
|
|
54
|
+
# Default frame read timeout in seconds - prevents indefinite blocking
|
|
55
|
+
DEFAULT_FRAME_READ_TIMEOUT = 30
|
|
56
|
+
|
|
57
|
+
# @return [String] WebSocket URL
|
|
58
|
+
attr_reader :url
|
|
59
|
+
|
|
60
|
+
# @return [String] Parse application ID
|
|
61
|
+
attr_reader :application_id
|
|
62
|
+
|
|
63
|
+
# @return [String, nil] Parse client key (REST API key)
|
|
64
|
+
attr_reader :client_key
|
|
65
|
+
|
|
66
|
+
# @return [String, nil] Parse master key
|
|
67
|
+
attr_reader :master_key
|
|
68
|
+
|
|
69
|
+
# @return [Symbol] connection state (:disconnected, :connecting, :connected, :closed)
|
|
70
|
+
attr_reader :state
|
|
71
|
+
|
|
72
|
+
# @return [Hash<Integer, Subscription>] active subscriptions by request ID
|
|
73
|
+
attr_reader :subscriptions
|
|
74
|
+
|
|
75
|
+
# @return [HealthMonitor] connection health monitor
|
|
76
|
+
attr_reader :health_monitor
|
|
77
|
+
|
|
78
|
+
# @return [CircuitBreaker] connection circuit breaker
|
|
79
|
+
attr_reader :circuit_breaker
|
|
80
|
+
|
|
81
|
+
# @return [EventQueue] event processing queue
|
|
82
|
+
attr_reader :event_queue
|
|
83
|
+
|
|
84
|
+
# @return [Integer] maximum allowed message size in bytes
|
|
85
|
+
attr_reader :max_message_size
|
|
86
|
+
|
|
87
|
+
# @return [Integer] frame read timeout in seconds
|
|
88
|
+
attr_reader :frame_read_timeout
|
|
89
|
+
|
|
90
|
+
# Create a new LiveQuery client
|
|
91
|
+
# @param url [String] WebSocket URL (wss://...)
|
|
92
|
+
# @param application_id [String] Parse application ID
|
|
93
|
+
# @param client_key [String] Parse REST API key
|
|
94
|
+
# @param master_key [String, nil] Parse master key (optional)
|
|
95
|
+
# @param auto_connect [Boolean] connect immediately (default: true)
|
|
96
|
+
# @param auto_reconnect [Boolean] automatically reconnect on disconnect (default: true)
|
|
97
|
+
def initialize(url: nil, application_id: nil, client_key: nil, master_key: nil,
|
|
98
|
+
auto_connect: nil, auto_reconnect: nil)
|
|
99
|
+
cfg = config
|
|
100
|
+
|
|
101
|
+
# Use provided values or fall back to configuration/environment
|
|
102
|
+
@url = url || cfg.url || derive_websocket_url
|
|
103
|
+
@application_id = application_id || cfg.application_id ||
|
|
104
|
+
parse_client_value(:application_id)
|
|
105
|
+
@client_key = client_key || cfg.client_key ||
|
|
106
|
+
parse_client_value(:api_key)
|
|
107
|
+
@master_key = master_key || cfg.master_key ||
|
|
108
|
+
parse_client_value(:master_key)
|
|
109
|
+
|
|
110
|
+
@auto_connect = auto_connect.nil? ? cfg.auto_connect : auto_connect
|
|
111
|
+
@auto_reconnect = auto_reconnect.nil? ? cfg.auto_reconnect : auto_reconnect
|
|
112
|
+
@max_message_size = cfg.max_message_size || DEFAULT_MAX_MESSAGE_SIZE
|
|
113
|
+
@frame_read_timeout = cfg.frame_read_timeout || DEFAULT_FRAME_READ_TIMEOUT
|
|
114
|
+
|
|
115
|
+
@state = :disconnected
|
|
116
|
+
@subscriptions = {}
|
|
117
|
+
@monitor = Monitor.new
|
|
118
|
+
@socket = nil
|
|
119
|
+
@reader_thread = nil
|
|
120
|
+
@reconnect_thread = nil
|
|
121
|
+
@reconnect_interval = cfg.initial_reconnect_interval
|
|
122
|
+
@callbacks = Hash.new { |h, k| h[k] = [] }
|
|
123
|
+
@client_id = nil
|
|
124
|
+
|
|
125
|
+
# Initialize production components
|
|
126
|
+
@health_monitor = HealthMonitor.new(
|
|
127
|
+
client: self,
|
|
128
|
+
ping_interval: cfg.ping_interval,
|
|
129
|
+
pong_timeout: cfg.pong_timeout,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@circuit_breaker = CircuitBreaker.new(
|
|
133
|
+
failure_threshold: cfg.circuit_failure_threshold,
|
|
134
|
+
reset_timeout: cfg.circuit_reset_timeout,
|
|
135
|
+
on_state_change: method(:on_circuit_state_change),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@event_queue = EventQueue.new(
|
|
139
|
+
max_size: cfg.event_queue_size,
|
|
140
|
+
strategy: cfg.backpressure_strategy,
|
|
141
|
+
on_drop: method(:on_event_dropped),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
Logging.info("LiveQuery client initialized", url: @url, application_id: @application_id)
|
|
145
|
+
|
|
146
|
+
connect if @auto_connect && @url
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Connect to the LiveQuery server
|
|
150
|
+
# @return [Boolean] true if connection initiated
|
|
151
|
+
def connect
|
|
152
|
+
return true if connected? || connecting?
|
|
153
|
+
|
|
154
|
+
# Check circuit breaker before attempting connection
|
|
155
|
+
unless @circuit_breaker.allow_request?
|
|
156
|
+
time_remaining = @circuit_breaker.time_until_half_open
|
|
157
|
+
Logging.warn("Connection blocked by circuit breaker",
|
|
158
|
+
state: @circuit_breaker.state,
|
|
159
|
+
time_until_retry: time_remaining)
|
|
160
|
+
emit(:circuit_open, time_remaining)
|
|
161
|
+
schedule_reconnect if @auto_reconnect
|
|
162
|
+
return false
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
@monitor.synchronize do
|
|
166
|
+
@state = :connecting
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
Logging.info("Connecting to LiveQuery server", url: @url)
|
|
171
|
+
establish_connection
|
|
172
|
+
start_reader_thread
|
|
173
|
+
send_connect_message
|
|
174
|
+
true
|
|
175
|
+
rescue => e
|
|
176
|
+
@circuit_breaker.record_failure
|
|
177
|
+
@state = :disconnected
|
|
178
|
+
Logging.error("Failed to connect", error: e)
|
|
179
|
+
emit(:error, ConnectionError.new("Failed to connect: #{e.message}"))
|
|
180
|
+
schedule_reconnect if @auto_reconnect
|
|
181
|
+
false
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Disconnect from the LiveQuery server
|
|
186
|
+
# @param code [Integer] WebSocket close code
|
|
187
|
+
# @param reason [String] close reason
|
|
188
|
+
def close(code: 1000, reason: "Client closing")
|
|
189
|
+
@auto_reconnect = false
|
|
190
|
+
@monitor.synchronize do
|
|
191
|
+
return if @state == :closed
|
|
192
|
+
|
|
193
|
+
Logging.info("Closing connection", code: code, reason: reason)
|
|
194
|
+
send_close_frame(code, reason) if @socket
|
|
195
|
+
cleanup_connection
|
|
196
|
+
@state = :closed
|
|
197
|
+
end
|
|
198
|
+
emit(:close)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Graceful shutdown with timeout
|
|
202
|
+
# @param timeout [Float] seconds to wait for graceful shutdown
|
|
203
|
+
# @return [void]
|
|
204
|
+
def shutdown(timeout: 5.0)
|
|
205
|
+
Logging.info("Shutting down LiveQuery client", timeout: timeout)
|
|
206
|
+
|
|
207
|
+
@auto_reconnect = false
|
|
208
|
+
|
|
209
|
+
# Cancel any pending reconnect thread
|
|
210
|
+
cancel_reconnect_thread
|
|
211
|
+
|
|
212
|
+
# Stop health monitor
|
|
213
|
+
@health_monitor.stop
|
|
214
|
+
|
|
215
|
+
# Stop event queue and drain remaining events
|
|
216
|
+
@event_queue.stop(drain: true, timeout: timeout / 2)
|
|
217
|
+
|
|
218
|
+
# Close connection
|
|
219
|
+
close(code: 1000, reason: "Shutdown")
|
|
220
|
+
|
|
221
|
+
# Wait for reader thread to finish
|
|
222
|
+
@reader_thread&.join(timeout / 2)
|
|
223
|
+
|
|
224
|
+
# Force kill if still running
|
|
225
|
+
@reader_thread&.kill
|
|
226
|
+
@reader_thread = nil
|
|
227
|
+
|
|
228
|
+
Logging.info("Shutdown complete",
|
|
229
|
+
events_processed: @event_queue.processed_count,
|
|
230
|
+
events_dropped: @event_queue.dropped_count)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# @return [Boolean] true if connected
|
|
234
|
+
def connected?
|
|
235
|
+
@state == :connected
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# @return [Boolean] true if connecting
|
|
239
|
+
def connecting?
|
|
240
|
+
@state == :connecting
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# @return [Boolean] true if closed
|
|
244
|
+
def closed?
|
|
245
|
+
@state == :closed
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Check if connection is healthy
|
|
249
|
+
# @return [Boolean]
|
|
250
|
+
def healthy?
|
|
251
|
+
connected? && @health_monitor.healthy?
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Get comprehensive health information
|
|
255
|
+
# @return [Hash]
|
|
256
|
+
def health_info
|
|
257
|
+
{
|
|
258
|
+
state: @state,
|
|
259
|
+
connected: connected?,
|
|
260
|
+
healthy: healthy?,
|
|
261
|
+
client_id: @client_id,
|
|
262
|
+
subscription_count: @subscriptions.size,
|
|
263
|
+
max_message_size: @max_message_size,
|
|
264
|
+
health_monitor: @health_monitor.health_info,
|
|
265
|
+
circuit_breaker: @circuit_breaker.info,
|
|
266
|
+
event_queue: @event_queue.stats,
|
|
267
|
+
}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Subscribe to a Parse class with optional query constraints
|
|
271
|
+
# @param class_name [String, Class] Parse class name or model class
|
|
272
|
+
# @param where [Hash] query constraints
|
|
273
|
+
# @param fields [Array<String>] specific fields to watch
|
|
274
|
+
# @param session_token [String] session token for ACL-aware subscriptions
|
|
275
|
+
# @return [Subscription]
|
|
276
|
+
def subscribe(class_name, where: {}, fields: nil, session_token: nil)
|
|
277
|
+
# Handle Parse::Object subclass
|
|
278
|
+
if class_name.is_a?(Class) && class_name < Parse::Object
|
|
279
|
+
class_name = class_name.parse_class
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Handle Parse::Query object
|
|
283
|
+
if class_name.is_a?(Parse::Query)
|
|
284
|
+
query = class_name
|
|
285
|
+
class_name = query.table
|
|
286
|
+
where = query.compile_where
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Refuse server-side-JS / data-mutating operators in the `where`
|
|
290
|
+
# filter at any nesting depth. LiveQuery subscriptions are a
|
|
291
|
+
# persistent server-evaluated channel; without this gate, a
|
|
292
|
+
# caller could plant `$where`/`$function`/`$accumulator` (or
|
|
293
|
+
# data-mutating stages nested inside) and have them re-evaluated
|
|
294
|
+
# on every matching event for the lifetime of the subscription.
|
|
295
|
+
# Permissive mode (recursive denylist only) mirrors the
|
|
296
|
+
# `Parse::MongoDB`/`Parse::AtlasSearch` filter posture so the
|
|
297
|
+
# SDK enforces one consistent set of refusals on every
|
|
298
|
+
# user-influenced filter path.
|
|
299
|
+
Parse::PipelineSecurity.validate_filter!(where) if where.is_a?(Hash) && !where.empty?
|
|
300
|
+
|
|
301
|
+
subscription = Subscription.new(
|
|
302
|
+
client: self,
|
|
303
|
+
class_name: class_name,
|
|
304
|
+
query: where,
|
|
305
|
+
fields: fields,
|
|
306
|
+
session_token: session_token,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
@monitor.synchronize do
|
|
310
|
+
@subscriptions[subscription.request_id] = subscription
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
Logging.debug("Subscription created",
|
|
314
|
+
request_id: subscription.request_id,
|
|
315
|
+
class_name: class_name)
|
|
316
|
+
|
|
317
|
+
# Send subscribe message if connected
|
|
318
|
+
if connected?
|
|
319
|
+
send_message(subscription.to_subscribe_message)
|
|
320
|
+
else
|
|
321
|
+
# Queue subscription for when connection is established
|
|
322
|
+
connect unless connecting?
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
subscription
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Unsubscribe from a subscription
|
|
329
|
+
# @param subscription [Subscription]
|
|
330
|
+
def unsubscribe(subscription)
|
|
331
|
+
Logging.debug("Unsubscribing", request_id: subscription.request_id)
|
|
332
|
+
send_message(subscription.to_unsubscribe_message) if connected?
|
|
333
|
+
|
|
334
|
+
@monitor.synchronize do
|
|
335
|
+
@subscriptions.delete(subscription.request_id)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Register callback for connection events
|
|
340
|
+
# @param event [Symbol] :open, :close, :error, :circuit_open, :circuit_closed
|
|
341
|
+
# @yield callback block
|
|
342
|
+
def on(event, &block)
|
|
343
|
+
@monitor.synchronize do
|
|
344
|
+
@callbacks[event] << block if block_given?
|
|
345
|
+
end
|
|
346
|
+
self
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Callback for connection opened
|
|
350
|
+
def on_open(&block)
|
|
351
|
+
on(:open, &block)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Callback for connection closed
|
|
355
|
+
def on_close(&block)
|
|
356
|
+
on(:close, &block)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Callback for errors
|
|
360
|
+
def on_error(&block)
|
|
361
|
+
on(:error, &block)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
private
|
|
365
|
+
|
|
366
|
+
# Get configuration object
|
|
367
|
+
# @return [Configuration]
|
|
368
|
+
def config
|
|
369
|
+
LiveQuery.config
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Safely get a value from the default Parse::Client if it exists
|
|
373
|
+
# @param method [Symbol] the method to call on the client
|
|
374
|
+
# @return [Object, nil] the value or nil if client not configured
|
|
375
|
+
def parse_client_value(method)
|
|
376
|
+
return nil unless Parse::Client.client?
|
|
377
|
+
Parse::Client.client.send(method)
|
|
378
|
+
rescue Parse::Error::ConnectionError
|
|
379
|
+
nil
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Loopback hostnames exempt from the `ws://` refusal in
|
|
383
|
+
# {#derive_websocket_url}. These addresses can't reach the
|
|
384
|
+
# Internet, so the cleartext-credentials threat model doesn't
|
|
385
|
+
# apply — but we still emit a warning so the operator knows
|
|
386
|
+
# they're on `ws://`.
|
|
387
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1] 0.0.0.0].freeze
|
|
388
|
+
|
|
389
|
+
# Derive WebSocket URL from Parse server URL. Refuses to
|
|
390
|
+
# synthesize a `ws://` URL from an `http://` server URL on any
|
|
391
|
+
# non-loopback host unless the LiveQuery configuration has
|
|
392
|
+
# `allow_insecure = true`. The master key and session tokens
|
|
393
|
+
# travel in the connect frame; downgrading to cleartext WebSocket
|
|
394
|
+
# on a routable host is an MITM-grade leak.
|
|
395
|
+
def derive_websocket_url
|
|
396
|
+
server_url = parse_client_value(:server_url)
|
|
397
|
+
return nil unless server_url
|
|
398
|
+
|
|
399
|
+
uri = URI.parse(server_url)
|
|
400
|
+
scheme = uri.scheme == "https" ? "wss" : "ws"
|
|
401
|
+
host = uri.host.to_s
|
|
402
|
+
|
|
403
|
+
if scheme == "ws" && !LOOPBACK_HOSTS.include?(host)
|
|
404
|
+
if config.allow_insecure
|
|
405
|
+
warn "[Parse::LiveQuery] Deriving insecure ws:// URL for #{host} " \
|
|
406
|
+
"(allow_insecure is enabled). Master key and session tokens " \
|
|
407
|
+
"will traverse a cleartext socket."
|
|
408
|
+
else
|
|
409
|
+
raise ArgumentError,
|
|
410
|
+
"[Parse::LiveQuery] Refusing to derive insecure ws:// URL from " \
|
|
411
|
+
"http:// server URL (#{server_url.inspect}). The LiveQuery " \
|
|
412
|
+
"connect frame carries the master key and any session token " \
|
|
413
|
+
"in plaintext on this socket. Use an https:// Parse server URL " \
|
|
414
|
+
"(so wss:// is derived) or pass an explicit wss:// `url:` to " \
|
|
415
|
+
"Parse::LiveQuery::Client.new. To opt into cleartext for local " \
|
|
416
|
+
"development on a routable host, set " \
|
|
417
|
+
"`Parse::LiveQuery.configure { |c| c.allow_insecure = true }`."
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
"#{scheme}://#{host}:#{uri.port || (scheme == "wss" ? 443 : 80)}"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Establish TCP/SSL connection and perform WebSocket handshake
|
|
425
|
+
def establish_connection
|
|
426
|
+
uri = URI.parse(@url)
|
|
427
|
+
host = uri.host
|
|
428
|
+
port = uri.port || (uri.scheme == "wss" ? 443 : 80)
|
|
429
|
+
path = uri.path.empty? ? "/" : uri.path
|
|
430
|
+
|
|
431
|
+
# Create TCP socket
|
|
432
|
+
tcp_socket = TCPSocket.new(host, port)
|
|
433
|
+
|
|
434
|
+
begin
|
|
435
|
+
# Wrap with SSL if wss://
|
|
436
|
+
if uri.scheme == "wss"
|
|
437
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
|
438
|
+
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
439
|
+
ssl_context.cert_store = OpenSSL::X509::Store.new
|
|
440
|
+
ssl_context.cert_store.set_default_paths
|
|
441
|
+
|
|
442
|
+
# Apply TLS version constraints from configuration
|
|
443
|
+
cfg = config
|
|
444
|
+
if cfg.ssl_min_version
|
|
445
|
+
ssl_context.min_version = Configuration.tls_version_constant(cfg.ssl_min_version)
|
|
446
|
+
end
|
|
447
|
+
if cfg.ssl_max_version
|
|
448
|
+
ssl_context.max_version = Configuration.tls_version_constant(cfg.ssl_max_version)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
@socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
|
|
452
|
+
@socket.sync_close = true
|
|
453
|
+
@socket.hostname = host
|
|
454
|
+
@socket.connect
|
|
455
|
+
# SNI does not verify the cert matches the hostname; this does.
|
|
456
|
+
# Without it, any cert signed by a trusted CA for any host would be
|
|
457
|
+
# accepted, enabling MITM of LiveQuery sessions.
|
|
458
|
+
@socket.post_connection_check(host)
|
|
459
|
+
else
|
|
460
|
+
@socket = tcp_socket
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Perform WebSocket handshake
|
|
464
|
+
perform_handshake(host, path)
|
|
465
|
+
rescue
|
|
466
|
+
# Clean up both sockets on any failure mid-handshake. sync_close=true
|
|
467
|
+
# would close tcp_socket via @socket.close, but if @socket wasn't yet
|
|
468
|
+
# assigned (or assignment failed), we still need to close tcp_socket.
|
|
469
|
+
if @socket
|
|
470
|
+
begin
|
|
471
|
+
@socket.close
|
|
472
|
+
rescue StandardError
|
|
473
|
+
end
|
|
474
|
+
@socket = nil
|
|
475
|
+
else
|
|
476
|
+
begin
|
|
477
|
+
tcp_socket.close
|
|
478
|
+
rescue StandardError
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
raise
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# RFC 6455 §1.3 magic GUID used to derive +Sec-WebSocket-Accept+
|
|
486
|
+
# from the client-supplied +Sec-WebSocket-Key+. Server proves it
|
|
487
|
+
# spoke WebSocket (and not e.g. a confused HTTP/1.1 server that
|
|
488
|
+
# happens to return +HTTP/1.1 101+) by echoing
|
|
489
|
+
# +Base64(SHA1(key || GUID))+ back to the client.
|
|
490
|
+
WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".freeze
|
|
491
|
+
|
|
492
|
+
# @!visibility private
|
|
493
|
+
# Cap on bytes read during the pre-handshake header phase, so a
|
|
494
|
+
# malicious server cannot stream unbounded headers and OOM the
|
|
495
|
+
# Ruby process before {Frame#read_frame}'s message-size cap kicks
|
|
496
|
+
# in.
|
|
497
|
+
HANDSHAKE_MAX_BYTES = 16 * 1024
|
|
498
|
+
# @!visibility private
|
|
499
|
+
HANDSHAKE_PER_LINE_BYTES = 8 * 1024
|
|
500
|
+
|
|
501
|
+
# Perform WebSocket handshake. Verifies the server's
|
|
502
|
+
# +Sec-WebSocket-Accept+ matches the SHA-1 of the random key +
|
|
503
|
+
# the WebSocket magic GUID, preventing cross-protocol acceptance
|
|
504
|
+
# of any HTTP/1.1 101 response from a non-WebSocket server.
|
|
505
|
+
def perform_handshake(host, path)
|
|
506
|
+
key = Base64.strict_encode64(SecureRandom.random_bytes(16))
|
|
507
|
+
expected_accept = Base64.strict_encode64(
|
|
508
|
+
Digest::SHA1.digest(key + WEBSOCKET_GUID)
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
handshake = [
|
|
512
|
+
"GET #{path} HTTP/1.1",
|
|
513
|
+
"Host: #{host}",
|
|
514
|
+
"Upgrade: websocket",
|
|
515
|
+
"Connection: Upgrade",
|
|
516
|
+
"Sec-WebSocket-Key: #{key}",
|
|
517
|
+
"Sec-WebSocket-Version: 13",
|
|
518
|
+
"Sec-WebSocket-Protocol: graphql-ws",
|
|
519
|
+
"",
|
|
520
|
+
].join("\r\n") + "\r\n"
|
|
521
|
+
|
|
522
|
+
@socket.write(handshake)
|
|
523
|
+
|
|
524
|
+
# Read response with strict byte caps so a hostile/MITM server
|
|
525
|
+
# cannot stream unbounded header bytes pre-handshake.
|
|
526
|
+
response = +""
|
|
527
|
+
loop do
|
|
528
|
+
if response.bytesize > HANDSHAKE_MAX_BYTES
|
|
529
|
+
raise ConnectionError,
|
|
530
|
+
"WebSocket handshake exceeded #{HANDSHAKE_MAX_BYTES} bytes"
|
|
531
|
+
end
|
|
532
|
+
line = @socket.gets("\r\n", HANDSHAKE_PER_LINE_BYTES)
|
|
533
|
+
break if line.nil?
|
|
534
|
+
response << line
|
|
535
|
+
break if line == "\r\n"
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
validate_handshake_response!(response, expected_accept)
|
|
539
|
+
Logging.debug("WebSocket handshake complete")
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# @!visibility private
|
|
543
|
+
# Parses and validates the server handshake response per RFC 6455 §4.1.
|
|
544
|
+
# Refuses any response that does not (a) start with HTTP/1.1 101,
|
|
545
|
+
# (b) carry +Upgrade: websocket+, (c) carry +Connection: Upgrade+,
|
|
546
|
+
# and (d) carry +Sec-WebSocket-Accept: <expected>+ matching the
|
|
547
|
+
# client-derived value.
|
|
548
|
+
def validate_handshake_response!(response, expected_accept)
|
|
549
|
+
if response.empty?
|
|
550
|
+
raise ConnectionError, "WebSocket handshake: empty server response"
|
|
551
|
+
end
|
|
552
|
+
unless response =~ /\AHTTP\/1\.[01]\s+101[ \t]/
|
|
553
|
+
raise ConnectionError,
|
|
554
|
+
"WebSocket handshake: expected HTTP 101 status, got: #{response.lines.first.to_s.strip.inspect}"
|
|
555
|
+
end
|
|
556
|
+
headers = {}
|
|
557
|
+
response.lines.drop(1).each do |line|
|
|
558
|
+
break if line == "\r\n"
|
|
559
|
+
name, _, value = line.chomp.partition(":")
|
|
560
|
+
headers[name.strip.downcase] = value.strip if name && value
|
|
561
|
+
end
|
|
562
|
+
unless headers["upgrade"]&.casecmp?("websocket")
|
|
563
|
+
raise ConnectionError,
|
|
564
|
+
"WebSocket handshake: missing or wrong Upgrade header (got #{headers["upgrade"].inspect})"
|
|
565
|
+
end
|
|
566
|
+
# Connection header may be a comma-separated list; check that
|
|
567
|
+
# "upgrade" appears among the tokens.
|
|
568
|
+
conn_tokens = headers["connection"].to_s.downcase.split(",").map(&:strip)
|
|
569
|
+
unless conn_tokens.include?("upgrade")
|
|
570
|
+
raise ConnectionError,
|
|
571
|
+
"WebSocket handshake: missing Upgrade token in Connection header"
|
|
572
|
+
end
|
|
573
|
+
accept = headers["sec-websocket-accept"]
|
|
574
|
+
unless accept && secure_str_compare(accept, expected_accept)
|
|
575
|
+
raise ConnectionError,
|
|
576
|
+
"WebSocket handshake: Sec-WebSocket-Accept mismatch — refusing to treat connection as WebSocket"
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# @!visibility private
|
|
581
|
+
# Constant-time string comparison. Falls back to ActiveSupport if
|
|
582
|
+
# available, otherwise rolls a short manual implementation.
|
|
583
|
+
def secure_str_compare(a, b)
|
|
584
|
+
if defined?(ActiveSupport::SecurityUtils)
|
|
585
|
+
ActiveSupport::SecurityUtils.secure_compare(a, b)
|
|
586
|
+
else
|
|
587
|
+
return false unless a.bytesize == b.bytesize
|
|
588
|
+
l = a.unpack("C*")
|
|
589
|
+
res = 0
|
|
590
|
+
b.each_byte.with_index { |byte, i| res |= byte ^ l[i] }
|
|
591
|
+
res.zero?
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# Start background thread for reading messages
|
|
596
|
+
def start_reader_thread
|
|
597
|
+
@reader_thread = Thread.new do
|
|
598
|
+
read_loop
|
|
599
|
+
end
|
|
600
|
+
@reader_thread.abort_on_exception = false
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# Main read loop
|
|
604
|
+
def read_loop
|
|
605
|
+
while @socket && !@socket.closed?
|
|
606
|
+
begin
|
|
607
|
+
frame = read_frame
|
|
608
|
+
handle_frame(frame) if frame
|
|
609
|
+
rescue IOError, Errno::ECONNRESET, EOFError => e
|
|
610
|
+
Logging.debug("Connection closed", reason: e.class.name)
|
|
611
|
+
break
|
|
612
|
+
rescue => e
|
|
613
|
+
Logging.error("Read loop error", error: e)
|
|
614
|
+
emit(:error, e)
|
|
615
|
+
break
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
handle_disconnect
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Read a WebSocket frame with timeout protection
|
|
623
|
+
def read_frame
|
|
624
|
+
first_byte = read_with_timeout(1)
|
|
625
|
+
return nil unless first_byte
|
|
626
|
+
|
|
627
|
+
first_byte = first_byte.unpack1("C")
|
|
628
|
+
fin = (first_byte & 0x80) != 0
|
|
629
|
+
opcode = first_byte & 0x0F
|
|
630
|
+
|
|
631
|
+
second_byte = read_with_timeout(1).unpack1("C")
|
|
632
|
+
masked = (second_byte & 0x80) != 0
|
|
633
|
+
length = second_byte & 0x7F
|
|
634
|
+
|
|
635
|
+
if length == 126
|
|
636
|
+
length = read_with_timeout(2).unpack1("n")
|
|
637
|
+
elsif length == 127
|
|
638
|
+
length = read_with_timeout(8).unpack1("Q>")
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Prevent memory exhaustion from oversized frames
|
|
642
|
+
if length > @max_message_size
|
|
643
|
+
Logging.error("Message size exceeds limit",
|
|
644
|
+
size: length,
|
|
645
|
+
max_size: @max_message_size)
|
|
646
|
+
raise ConnectionError, "Message size #{length} exceeds maximum allowed #{@max_message_size}"
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
mask_key = masked ? read_with_timeout(4) : nil
|
|
650
|
+
payload = length > 0 ? read_with_timeout(length) : ""
|
|
651
|
+
|
|
652
|
+
if masked && payload && mask_key
|
|
653
|
+
payload = payload.bytes.each_with_index.map do |byte, i|
|
|
654
|
+
byte ^ mask_key.bytes[i % 4]
|
|
655
|
+
end.pack("C*")
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
{ fin: fin, opcode: opcode, payload: payload }
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Read from socket with timeout protection
|
|
662
|
+
# @param length [Integer] number of bytes to read
|
|
663
|
+
# @return [String] the data read
|
|
664
|
+
# @raise [ConnectionError] if read times out
|
|
665
|
+
def read_with_timeout(length)
|
|
666
|
+
return @socket.read(length) unless @frame_read_timeout && @frame_read_timeout > 0
|
|
667
|
+
|
|
668
|
+
Timeout.timeout(@frame_read_timeout) do
|
|
669
|
+
@socket.read(length)
|
|
670
|
+
end
|
|
671
|
+
rescue Timeout::Error
|
|
672
|
+
Logging.error("Frame read timeout", timeout: @frame_read_timeout)
|
|
673
|
+
raise ConnectionError, "Frame read timed out after #{@frame_read_timeout} seconds"
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Handle a WebSocket frame
|
|
677
|
+
def handle_frame(frame)
|
|
678
|
+
# Record activity for health monitoring
|
|
679
|
+
@health_monitor.record_activity
|
|
680
|
+
|
|
681
|
+
case frame[:opcode]
|
|
682
|
+
when OPCODE_TEXT
|
|
683
|
+
handle_message(frame[:payload])
|
|
684
|
+
when OPCODE_PING
|
|
685
|
+
send_pong(frame[:payload])
|
|
686
|
+
when OPCODE_PONG
|
|
687
|
+
@health_monitor.record_pong
|
|
688
|
+
when OPCODE_CLOSE
|
|
689
|
+
handle_close_frame(frame[:payload])
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Handle incoming text message
|
|
694
|
+
def handle_message(data)
|
|
695
|
+
return unless data
|
|
696
|
+
|
|
697
|
+
begin
|
|
698
|
+
message = JSON.parse(data)
|
|
699
|
+
process_server_message(message)
|
|
700
|
+
rescue JSON::ParserError => e
|
|
701
|
+
Logging.error("Failed to parse message", error: e, data: data)
|
|
702
|
+
emit(:error, e)
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Process a server message
|
|
707
|
+
def process_server_message(message)
|
|
708
|
+
op = message["op"]
|
|
709
|
+
|
|
710
|
+
case op
|
|
711
|
+
when "connected"
|
|
712
|
+
handle_connected(message)
|
|
713
|
+
when "subscribed"
|
|
714
|
+
handle_subscribed(message)
|
|
715
|
+
when "unsubscribed"
|
|
716
|
+
handle_unsubscribed(message)
|
|
717
|
+
when "create", "update", "delete", "enter", "leave"
|
|
718
|
+
handle_event(op, message)
|
|
719
|
+
when "error"
|
|
720
|
+
handle_server_error(message)
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# Handle connected message from server
|
|
725
|
+
def handle_connected(message)
|
|
726
|
+
@client_id = message["clientId"]
|
|
727
|
+
@monitor.synchronize do
|
|
728
|
+
@state = :connected
|
|
729
|
+
@reconnect_interval = config.initial_reconnect_interval
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
# Record successful connection
|
|
733
|
+
@circuit_breaker.record_success
|
|
734
|
+
|
|
735
|
+
# Start health monitoring
|
|
736
|
+
@health_monitor.start
|
|
737
|
+
|
|
738
|
+
# Start event queue processing
|
|
739
|
+
@event_queue.start { |event| dispatch_event(event) }
|
|
740
|
+
|
|
741
|
+
Logging.info("Connected to LiveQuery server", client_id: @client_id)
|
|
742
|
+
emit(:open)
|
|
743
|
+
|
|
744
|
+
# Send pending subscriptions
|
|
745
|
+
resubscribe_all
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# Handle subscription confirmed
|
|
749
|
+
def handle_subscribed(message)
|
|
750
|
+
request_id = message["requestId"]
|
|
751
|
+
subscription = @subscriptions[request_id]
|
|
752
|
+
if subscription
|
|
753
|
+
subscription.confirm!
|
|
754
|
+
Logging.debug("Subscription confirmed", request_id: request_id)
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# Handle unsubscription confirmed
|
|
759
|
+
def handle_unsubscribed(message)
|
|
760
|
+
request_id = message["requestId"]
|
|
761
|
+
@monitor.synchronize do
|
|
762
|
+
@subscriptions.delete(request_id)
|
|
763
|
+
end
|
|
764
|
+
Logging.debug("Unsubscription confirmed", request_id: request_id)
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# Handle data event (create/update/delete/enter/leave)
|
|
768
|
+
# Routes through event queue for backpressure handling
|
|
769
|
+
def handle_event(op, message)
|
|
770
|
+
request_id = message["requestId"]
|
|
771
|
+
subscription = @subscriptions[request_id]
|
|
772
|
+
return unless subscription
|
|
773
|
+
|
|
774
|
+
event = Event.new(
|
|
775
|
+
type: op.to_sym,
|
|
776
|
+
class_name: message.dig("object", "className") || subscription.class_name,
|
|
777
|
+
object_data: message["object"],
|
|
778
|
+
original_data: message["original"],
|
|
779
|
+
request_id: request_id,
|
|
780
|
+
raw: message,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
# Route through event queue for backpressure handling
|
|
784
|
+
@event_queue.enqueue({ subscription: subscription, event: event })
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
# Dispatch event to subscription (called from event queue processor)
|
|
788
|
+
# @param item [Hash] contains :subscription and :event
|
|
789
|
+
def dispatch_event(item)
|
|
790
|
+
subscription = item[:subscription]
|
|
791
|
+
event = item[:event]
|
|
792
|
+
subscription.handle_event(event)
|
|
793
|
+
rescue => e
|
|
794
|
+
Logging.error("Event dispatch error", error: e, event_type: event.type)
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Handle server error
|
|
798
|
+
def handle_server_error(message)
|
|
799
|
+
request_id = message["requestId"]
|
|
800
|
+
error_message = message["error"] || "Unknown server error"
|
|
801
|
+
code = message["code"]
|
|
802
|
+
|
|
803
|
+
Logging.error("Server error", error: error_message, code: code, request_id: request_id)
|
|
804
|
+
|
|
805
|
+
if request_id && @subscriptions[request_id]
|
|
806
|
+
@subscriptions[request_id].fail!("#{error_message} (code: #{code})")
|
|
807
|
+
else
|
|
808
|
+
emit(:error, Error.new("#{error_message} (code: #{code})"))
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Handle close frame
|
|
813
|
+
def handle_close_frame(payload)
|
|
814
|
+
code = payload[0..1].unpack1("n") if payload && payload.length >= 2
|
|
815
|
+
Logging.debug("Received close frame", code: code)
|
|
816
|
+
cleanup_connection
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
# Handle disconnect
|
|
820
|
+
def handle_disconnect
|
|
821
|
+
was_connected = connected?
|
|
822
|
+
cleanup_connection
|
|
823
|
+
|
|
824
|
+
if was_connected
|
|
825
|
+
emit(:close)
|
|
826
|
+
schedule_reconnect if @auto_reconnect
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Cleanup connection resources
|
|
831
|
+
def cleanup_connection
|
|
832
|
+
# Stop health monitor
|
|
833
|
+
@health_monitor.stop
|
|
834
|
+
|
|
835
|
+
# Stop event queue (but don't drain during disconnect - we may reconnect)
|
|
836
|
+
@event_queue.stop(drain: false)
|
|
837
|
+
|
|
838
|
+
@monitor.synchronize do
|
|
839
|
+
@state = :disconnected unless @state == :closed
|
|
840
|
+
@socket&.close rescue nil
|
|
841
|
+
@socket = nil
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
Logging.debug("Connection cleaned up")
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
# Schedule reconnection with exponential backoff and jitter
|
|
848
|
+
def schedule_reconnect
|
|
849
|
+
return if @state == :closed
|
|
850
|
+
|
|
851
|
+
# Cancel any existing reconnect thread to prevent accumulation
|
|
852
|
+
cancel_reconnect_thread
|
|
853
|
+
|
|
854
|
+
cfg = config
|
|
855
|
+
jitter_factor = cfg.reconnect_jitter
|
|
856
|
+
jitter = @reconnect_interval * jitter_factor * (rand - 0.5) * 2
|
|
857
|
+
delay = @reconnect_interval + jitter
|
|
858
|
+
delay = [delay, 0.1].max # Ensure positive delay
|
|
859
|
+
|
|
860
|
+
Logging.info("Scheduling reconnect", delay: delay.round(2))
|
|
861
|
+
|
|
862
|
+
@reconnect_thread = Thread.new do
|
|
863
|
+
sleep delay
|
|
864
|
+
@monitor.synchronize do
|
|
865
|
+
@reconnect_thread = nil
|
|
866
|
+
end
|
|
867
|
+
@reconnect_interval = [@reconnect_interval * cfg.reconnect_multiplier,
|
|
868
|
+
cfg.max_reconnect_interval].min
|
|
869
|
+
connect
|
|
870
|
+
end
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
# Cancel any pending reconnect thread
|
|
874
|
+
def cancel_reconnect_thread
|
|
875
|
+
@monitor.synchronize do
|
|
876
|
+
if @reconnect_thread&.alive?
|
|
877
|
+
@reconnect_thread.kill
|
|
878
|
+
@reconnect_thread = nil
|
|
879
|
+
end
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
# Resubscribe all pending subscriptions
|
|
884
|
+
def resubscribe_all
|
|
885
|
+
subs = @monitor.synchronize { @subscriptions.values.dup }
|
|
886
|
+
subs.each do |subscription|
|
|
887
|
+
send_message(subscription.to_subscribe_message)
|
|
888
|
+
end
|
|
889
|
+
Logging.debug("Resubscribed to all subscriptions", count: subs.size)
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
# Send connect message to server
|
|
893
|
+
def send_connect_message
|
|
894
|
+
message = {
|
|
895
|
+
op: "connect",
|
|
896
|
+
applicationId: @application_id,
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
message[:clientKey] = @client_key if @client_key
|
|
900
|
+
message[:masterKey] = @master_key if @master_key
|
|
901
|
+
|
|
902
|
+
send_message(message)
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
# Send a message through the WebSocket
|
|
906
|
+
def send_message(message)
|
|
907
|
+
data = message.is_a?(String) ? message : message.to_json
|
|
908
|
+
send_frame(OPCODE_TEXT, data)
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
# Send a WebSocket frame
|
|
912
|
+
def send_frame(opcode, data)
|
|
913
|
+
@monitor.synchronize do
|
|
914
|
+
return unless @socket && !@socket.closed?
|
|
915
|
+
|
|
916
|
+
bytes = data.bytes
|
|
917
|
+
length = bytes.length
|
|
918
|
+
|
|
919
|
+
# Build frame
|
|
920
|
+
frame = [0x80 | opcode].pack("C") # FIN + opcode
|
|
921
|
+
|
|
922
|
+
# Length with mask bit set (client must mask)
|
|
923
|
+
if length < 126
|
|
924
|
+
frame += [0x80 | length].pack("C")
|
|
925
|
+
elsif length < 65536
|
|
926
|
+
frame += [0x80 | 126, length].pack("Cn")
|
|
927
|
+
else
|
|
928
|
+
frame += [0x80 | 127, length].pack("CQ>")
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
# Generate mask key and apply
|
|
932
|
+
mask = SecureRandom.random_bytes(4)
|
|
933
|
+
frame += mask
|
|
934
|
+
|
|
935
|
+
masked_data = bytes.each_with_index.map do |byte, i|
|
|
936
|
+
byte ^ mask.bytes[i % 4]
|
|
937
|
+
end.pack("C*")
|
|
938
|
+
|
|
939
|
+
frame += masked_data
|
|
940
|
+
|
|
941
|
+
@socket.write(frame)
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
# Send ping frame (called by health monitor)
|
|
946
|
+
def send_ping
|
|
947
|
+
Logging.debug("Sending ping")
|
|
948
|
+
send_frame(OPCODE_PING, "")
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# Send pong frame
|
|
952
|
+
def send_pong(data)
|
|
953
|
+
send_frame(OPCODE_PONG, data || "")
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
# Send close frame
|
|
957
|
+
def send_close_frame(code, reason)
|
|
958
|
+
data = [code].pack("n") + reason.to_s
|
|
959
|
+
send_frame(OPCODE_CLOSE, data)
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
# Handle stale connection (called by health monitor)
|
|
963
|
+
def handle_stale_connection
|
|
964
|
+
Logging.warn("Connection stale, triggering reconnect")
|
|
965
|
+
cleanup_connection
|
|
966
|
+
schedule_reconnect if @auto_reconnect
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
# Circuit breaker state change callback
|
|
970
|
+
def on_circuit_state_change(old_state, new_state)
|
|
971
|
+
Logging.info("Circuit breaker state change", from: old_state, to: new_state)
|
|
972
|
+
case new_state
|
|
973
|
+
when :open
|
|
974
|
+
emit(:circuit_open, @circuit_breaker.time_until_half_open)
|
|
975
|
+
when :closed
|
|
976
|
+
emit(:circuit_closed)
|
|
977
|
+
end
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# Event dropped callback
|
|
981
|
+
def on_event_dropped(event, reason)
|
|
982
|
+
Logging.warn("Event dropped due to backpressure",
|
|
983
|
+
reason: reason,
|
|
984
|
+
event_type: event[:event]&.type)
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# Emit event to callbacks (thread-safe)
|
|
988
|
+
def emit(event, *args)
|
|
989
|
+
# Copy callbacks under lock, iterate outside to prevent deadlocks
|
|
990
|
+
callbacks = @monitor.synchronize { @callbacks[event].dup }
|
|
991
|
+
callbacks.each do |callback|
|
|
992
|
+
begin
|
|
993
|
+
callback.call(*args)
|
|
994
|
+
rescue => e
|
|
995
|
+
Logging.error("Callback error", event: event, error: e)
|
|
996
|
+
end
|
|
997
|
+
end
|
|
998
|
+
end
|
|
999
|
+
end
|
|
1000
|
+
end
|
|
1001
|
+
end
|