daytona 0.197.0 → 0.198.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.
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Daytona Platforms Inc.
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'timeout'
7
+
8
+ require 'websocket-client-simple'
9
+ require 'json'
10
+ require 'uri'
11
+
12
+ module Daytona
13
+ # Minimal Engine.IO/Socket.IO v4 client over raw WebSocket.
14
+ # Supports connect with auth, heartbeat, and event reception.
15
+ #
16
+ # Engine.IO v4 heartbeat protocol (WebSocket transport):
17
+ # - Server sends PING (type 2) every pingInterval ms
18
+ # - Client must respond with PONG (type 3) within pingTimeout ms
19
+ # - Client monitors for missing server PINGs to detect dead connections
20
+ class SocketIOClient
21
+ # Engine.IO v4 packet types
22
+ EIO_OPEN = '0'
23
+ EIO_CLOSE = '1'
24
+ EIO_PING = '2'
25
+ EIO_PONG = '3'
26
+ EIO_MESSAGE = '4'
27
+
28
+ # Socket.IO v4 packet types (inside Engine.IO messages)
29
+ SIO_CONNECT = '0'
30
+ SIO_DISCONNECT = '1'
31
+ SIO_EVENT = '2'
32
+ SIO_CONNECT_ERROR = '4'
33
+
34
+ attr_reader :connected
35
+
36
+ # @param api_url [String] The API URL (e.g., "https://app.daytona.io/api")
37
+ # @param token [String] Auth token (API key or JWT)
38
+ # @param organization_id [String, nil] Organization ID for room joining
39
+ # @param on_event [Proc] Called with (event_name, data_hash) for each Socket.IO event
40
+ # @param on_disconnect [Proc] Called when the connection is lost
41
+ # @param connect_timeout [Numeric] Connection timeout in seconds
42
+ def initialize(api_url:, token:, organization_id: nil, source: nil, sdk_version: nil, on_event: nil,
43
+ on_disconnect: nil, connect_timeout: 5)
44
+ @api_url = api_url
45
+ @token = token
46
+ @organization_id = organization_id
47
+ @source = source
48
+ @sdk_version = sdk_version
49
+ @on_event = on_event
50
+ @on_disconnect = on_disconnect
51
+ @connect_timeout = connect_timeout
52
+ @connected = false
53
+ @mutex = Mutex.new
54
+ @write_mutex = Mutex.new
55
+ @health_thread = nil
56
+ @ping_interval = 25
57
+ @ping_timeout = 20
58
+ @last_server_activity = Time.now
59
+ @ws = nil
60
+ @close_requested = false
61
+ end
62
+
63
+ # Establish the WebSocket connection and perform Socket.IO handshake.
64
+ # @return [Boolean] true if connection succeeded
65
+ # @raise [StandardError] on connection failure
66
+ def connect
67
+ ws_url = build_ws_url
68
+ connected_queue = Queue.new
69
+
70
+ # Capture self because websocket-client-simple uses instance_exec for callbacks
71
+ client = self
72
+
73
+ # Register callbacks inside the connect block so they are installed before the
74
+ # socket dials — otherwise the Engine.IO handshake can arrive before :message
75
+ # is bound, stalling the connection into a polling fallback/timeout.
76
+ @ws = WebSocket::Client::Simple.connect(ws_url) do |ws|
77
+ ws.on :message do |msg|
78
+ client.send(:handle_raw_message, msg.data.to_s, connected_queue)
79
+ end
80
+
81
+ ws.on :error do |_e|
82
+ was_connected = client.instance_variable_get(:@mutex).synchronize do
83
+ prev = client.instance_variable_get(:@connected)
84
+ client.instance_variable_set(:@connected, false)
85
+ prev
86
+ end
87
+ connected_queue.push(:error) unless was_connected
88
+ end
89
+
90
+ ws.on :close do
91
+ mutex = client.instance_variable_get(:@mutex)
92
+ was_connected = mutex.synchronize do
93
+ prev = client.instance_variable_get(:@connected)
94
+ client.instance_variable_set(:@connected, false)
95
+ prev
96
+ end
97
+ on_disconnect = client.instance_variable_get(:@on_disconnect)
98
+ close_requested = client.instance_variable_get(:@close_requested)
99
+ begin
100
+ on_disconnect&.call if was_connected && !close_requested
101
+ rescue StandardError
102
+ nil
103
+ end
104
+ end
105
+ end
106
+
107
+ # Wait for connection with timeout
108
+ result = nil
109
+ begin
110
+ Timeout.timeout(@connect_timeout) { result = connected_queue.pop }
111
+ rescue Timeout::Error
112
+ close
113
+ raise "WebSocket connection timed out after #{@connect_timeout}s"
114
+ end
115
+
116
+ if result != :connected
117
+ close
118
+ raise "WebSocket connection failed: #{result}"
119
+ end
120
+
121
+ @mutex.synchronize { @connected }
122
+ end
123
+
124
+ # @return [Boolean]
125
+ def connected?
126
+ @mutex.synchronize { @connected }
127
+ end
128
+
129
+ # Gracefully close the connection.
130
+ def close
131
+ @close_requested = true
132
+ @health_thread&.kill
133
+ @health_thread = nil
134
+
135
+ send_raw(EIO_CLOSE) if @ws
136
+ @ws&.close
137
+ @mutex.synchronize { @connected = false }
138
+ rescue StandardError
139
+ # Ignore errors during close
140
+ end
141
+
142
+ private
143
+
144
+ def build_ws_url
145
+ parsed = URI.parse(@api_url)
146
+ ws_scheme = parsed.scheme == 'https' ? 'wss' : 'ws'
147
+ host = parsed.host
148
+ port = parsed.port
149
+
150
+ query_parts = ['EIO=4', 'transport=websocket']
151
+ query_parts << "organizationId=#{URI.encode_www_form_component(@organization_id)}" if @organization_id
152
+ query_parts << "source=#{URI.encode_www_form_component(@source)}" if @source
153
+ query_parts << "sdkVersion=#{URI.encode_www_form_component(@sdk_version)}" if @sdk_version
154
+
155
+ port_str = (parsed.scheme == 'https' && port == 443) || (parsed.scheme == 'http' && port == 80) ? '' : ":#{port}"
156
+ base_path = parsed.path.to_s.chomp('/')
157
+ "#{ws_scheme}://#{host}#{port_str}#{base_path}/socket.io/?#{query_parts.join('&')}"
158
+ end
159
+
160
+ def handle_raw_message(raw, connected_queue)
161
+ return if raw.nil? || raw.empty?
162
+
163
+ # Track all server activity for health monitoring.
164
+ # If the server stops sending ANY data (pings, events, etc.)
165
+ # the health monitor will detect the dead connection.
166
+ @last_server_activity = Time.now
167
+
168
+ case raw[0]
169
+ when EIO_OPEN
170
+ # Parse open payload for ping interval
171
+ begin
172
+ payload = JSON.parse(raw[1..])
173
+ @ping_interval = (payload['pingInterval'] || 25_000) / 1000.0
174
+ @ping_timeout = (payload['pingTimeout'] || 20_000) / 1000.0
175
+ rescue JSON::ParserError
176
+ # Use default ping interval
177
+ end
178
+ # Send Socket.IO CONNECT with auth
179
+ auth = JSON.generate({ token: @token })
180
+ send_raw("#{EIO_MESSAGE}#{SIO_CONNECT}#{auth}")
181
+
182
+ when EIO_PING
183
+ # Server heartbeat — respond immediately with PONG
184
+ send_raw(EIO_PONG)
185
+
186
+ when EIO_PONG
187
+ # Unexpected in EIO v4 (server doesn't respond to client pings),
188
+ # but handle gracefully — activity already tracked above.
189
+ nil
190
+
191
+ when EIO_MESSAGE
192
+ handle_socketio_packet(raw[1..], connected_queue)
193
+
194
+ when EIO_CLOSE
195
+ @mutex.synchronize { @connected = false }
196
+ end
197
+ end
198
+
199
+ def handle_socketio_packet(data, connected_queue)
200
+ return if data.nil? || data.empty?
201
+
202
+ case data[0]
203
+ when SIO_CONNECT
204
+ # Connection acknowledged
205
+ @mutex.synchronize { @connected = true }
206
+ start_health_monitor
207
+ connected_queue&.push(:connected)
208
+
209
+ when SIO_CONNECT_ERROR
210
+ # Connection rejected
211
+ error_msg = begin
212
+ payload = JSON.parse(data[1..])
213
+ payload['message'] || 'Unknown error'
214
+ rescue JSON::ParserError
215
+ data[1..]
216
+ end
217
+ @mutex.synchronize { @connected = false }
218
+ connected_queue&.push("Auth rejected: #{error_msg}")
219
+
220
+ when SIO_EVENT
221
+ handle_event(data[1..])
222
+
223
+ when SIO_DISCONNECT
224
+ was_connected = @mutex.synchronize do
225
+ prev = @connected
226
+ @connected = false
227
+ prev
228
+ end
229
+ @on_disconnect&.call if was_connected && !@close_requested
230
+ end
231
+ end
232
+
233
+ def handle_event(json_str)
234
+ return unless @on_event
235
+
236
+ # Skip namespace prefix if present (e.g., "/ns,")
237
+ if json_str&.start_with?('/')
238
+ comma_idx = json_str.index(',')
239
+ json_str = json_str[(comma_idx + 1)..] if comma_idx
240
+ end
241
+
242
+ event_array = JSON.parse(json_str)
243
+ return unless event_array.is_a?(Array) && event_array.length >= 1
244
+
245
+ event_name = event_array[0]
246
+ event_data = event_array[1]
247
+
248
+ @on_event.call(event_name, event_data)
249
+ rescue JSON::ParserError
250
+ # Malformed event, ignore
251
+ end
252
+
253
+ # Monitors connection health by checking for server activity.
254
+ # In Engine.IO v4, the server sends PING every pingInterval ms.
255
+ # If no server activity (pings, events, any data) is seen within
256
+ # pingInterval + pingTimeout, the connection is considered dead.
257
+ HEALTH_CHECK_INTERVAL = 5 # seconds — check frequently for fast detection
258
+
259
+ def start_health_monitor
260
+ @health_thread&.kill
261
+ @last_server_activity = Time.now
262
+ @health_thread = Thread.new do
263
+ loop do
264
+ sleep(HEALTH_CHECK_INTERVAL)
265
+ break unless connected?
266
+
267
+ if Time.now - @last_server_activity > @ping_interval + @ping_timeout
268
+ # No server activity within expected window — connection is dead
269
+ @mutex.synchronize { @connected = false }
270
+ @on_disconnect&.call unless @close_requested
271
+ # Force-close the dead WebSocket to ensure cleanup
272
+ @ws&.close rescue nil # rubocop:disable Style/RescueModifier
273
+ break
274
+ end
275
+ rescue StandardError
276
+ break
277
+ end
278
+ end
279
+ end
280
+
281
+ def send_raw(msg)
282
+ @write_mutex.synchronize do
283
+ @ws&.send(msg)
284
+ end
285
+ rescue StandardError
286
+ # Ignore write errors
287
+ end
288
+ end
289
+ end
@@ -39,6 +39,16 @@ module Daytona
39
39
  # @return [Boolean, nil]
40
40
  attr_accessor :otel_enabled
41
41
 
42
+ # Observe sandbox state by legacy polling instead of WebSocket event streaming.
43
+ # Defaults to false (event streaming). Can also be enabled via the
44
+ # DAYTONA_USE_DEPRECATED_POLLING environment variable.
45
+ #
46
+ # @deprecated Polling-only mode will be removed in a future release;
47
+ # event streaming is the default and falls back to polling automatically
48
+ # when WebSockets are unavailable.
49
+ # @return [Boolean]
50
+ attr_accessor :use_deprecated_polling
51
+
42
52
  # Experimental configuration options
43
53
  #
44
54
  # @return [Hash, nil] Experimental configuration hash
@@ -52,6 +62,9 @@ module Daytona
52
62
  # @param organization_id [String, nil] Daytona organization ID. Defaults to ENV['DAYTONA_ORGANIZATION_ID'].
53
63
  # @param target [String, nil] Daytona target. Defaults to ENV['DAYTONA_TARGET'].
54
64
  # @param otel_enabled [Boolean, nil] Enable OpenTelemetry tracing for SDK operations.
65
+ # @param use_deprecated_polling [Boolean, nil] Observe sandbox state by legacy polling instead of
66
+ # WebSocket event streaming. Defaults to false (event streaming). Can also be enabled via the
67
+ # DAYTONA_USE_DEPRECATED_POLLING environment variable.
55
68
  # @param _experimental [Hash, nil] Experimental configuration options.
56
69
  def initialize( # rubocop:disable Metrics/ParameterLists
57
70
  api_key: nil,
@@ -60,6 +73,7 @@ module Daytona
60
73
  organization_id: nil,
61
74
  target: nil,
62
75
  otel_enabled: nil,
76
+ use_deprecated_polling: nil,
63
77
  _experimental: nil
64
78
  )
65
79
  @env_reader = daytona_env_reader
@@ -71,6 +85,7 @@ module Daytona
71
85
  @organization_id = organization_id || @env_reader.call('DAYTONA_ORGANIZATION_ID')
72
86
  @otel_enabled = otel_enabled
73
87
  @_experimental = _experimental
88
+ @use_deprecated_polling = resolve_use_deprecated_polling(use_deprecated_polling)
74
89
  end
75
90
 
76
91
  # Reads a DAYTONA_-prefixed environment variable using the same precedence
@@ -105,5 +120,11 @@ module Daytona
105
120
  def daytona_filter(env_hash)
106
121
  env_hash.select { |k, _| k.start_with?('DAYTONA_') }
107
122
  end
123
+
124
+ def resolve_use_deprecated_polling(use_deprecated_polling)
125
+ return use_deprecated_polling unless use_deprecated_polling.nil?
126
+
127
+ @env_reader.call('DAYTONA_USE_DEPRECATED_POLLING') == 'true'
128
+ end
108
129
  end
109
130
  end
@@ -55,14 +55,42 @@ module Daytona
55
55
  @secret = SecretService.new(DaytonaApiClient::SecretApi.new(api_client), otel_state:)
56
56
  @object_storage_api = DaytonaApiClient::ObjectStorageApi.new(api_client)
57
57
  @snapshots_api = DaytonaApiClient::SnapshotsApi.new(api_client)
58
- @snapshot = SnapshotService.new(snapshots_api:, object_storage_api:, default_region_id: config.target,
59
- otel_state:)
58
+ @snapshot = SnapshotService.new(
59
+ snapshots_api:,
60
+ object_storage_api:,
61
+ default_region_id: config.target,
62
+ otel_state:
63
+ )
64
+ token = config.api_key || config.jwt_token
65
+
66
+ if config.use_deprecated_polling
67
+ warn(
68
+ '[DEPRECATION] Polling-only mode (use_deprecated_polling / DAYTONA_USE_DEPRECATED_POLLING) ' \
69
+ 'is deprecated and will be removed in a future release.',
70
+ uplevel: 1
71
+ )
72
+ else
73
+ @event_dispatcher = EventDispatcher.new(
74
+ api_url: config.api_url,
75
+ token: token,
76
+ organization_id: config.organization_id,
77
+ source: SOURCE_RUBY,
78
+ sdk_version: Sdk::VERSION
79
+ )
80
+ @event_dispatcher.ensure_connected
81
+ end
82
+
83
+ @subscription_manager = EventSubscriptionManager.new(@event_dispatcher)
60
84
  end
61
85
 
62
86
  # Shuts down OTel providers, flushing any pending telemetry data.
63
87
  #
64
88
  # @return [void]
65
89
  def close
90
+ @subscription_manager&.shutdown
91
+ @subscription_manager = nil
92
+ @event_dispatcher&.disconnect
93
+ @event_dispatcher = nil
66
94
  ::Daytona.shutdown_otel(@otel_state)
67
95
  @otel_state = nil
68
96
  end
@@ -91,8 +119,9 @@ module Daytona
91
119
  # Deletes a Sandbox.
92
120
  #
93
121
  # @param sandbox [Daytona::Sandbox]
122
+ # @param wait [Boolean] When +true+, block until the Sandbox is destroyed.
94
123
  # @return [void]
95
- def delete(sandbox) = sandbox.delete
124
+ def delete(sandbox, wait: false) = sandbox.delete(wait:)
96
125
 
97
126
  # Gets a Sandbox by its ID.
98
127
  #
@@ -352,7 +381,8 @@ module Daytona
352
381
  config:,
353
382
  sandbox_api:,
354
383
  otel_state: @otel_state,
355
- analytics_api_url_provider: method(:analytics_api_url)
384
+ analytics_api_url_provider: method(:analytics_api_url),
385
+ subscription_manager: @subscription_manager
356
386
  )
357
387
  end
358
388
 
@@ -402,7 +402,7 @@ module Daytona
402
402
  # end
403
403
  # end
404
404
  def replace_in_files(files:, pattern:, new_value:)
405
- replace_request = DaytonaApiClient::ReplaceRequest.new(
405
+ replace_request = DaytonaToolboxApiClient::ReplaceRequest.new(
406
406
  files: files,
407
407
  pattern: pattern,
408
408
  new_value: new_value
@@ -56,7 +56,7 @@ module Daytona
56
56
  language_id:,
57
57
  path_to_project:,
58
58
  uri: uri(path),
59
- position: DaytonaApiClient::Position.new(line: position.line, character: position.character)
59
+ position: DaytonaToolboxApiClient::LspPosition.new(line: position.line, character: position.character)
60
60
  )
61
61
  )
62
62
  end