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.
- checksums.yaml +4 -4
- data/lib/daytona/common/event_dispatcher.rb +326 -0
- data/lib/daytona/common/event_subscription_manager.rb +144 -0
- data/lib/daytona/common/socketio_client.rb +289 -0
- data/lib/daytona/config.rb +21 -0
- data/lib/daytona/daytona.rb +34 -4
- data/lib/daytona/file_system.rb +1 -1
- data/lib/daytona/lsp_server.rb +1 -1
- data/lib/daytona/sandbox.rb +386 -111
- data/lib/daytona/sdk/version.rb +1 -1
- data/lib/daytona/sdk.rb +5 -2
- data/project.json +4 -3
- metadata +22 -5
|
@@ -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
|
data/lib/daytona/config.rb
CHANGED
|
@@ -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
|
data/lib/daytona/daytona.rb
CHANGED
|
@@ -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(
|
|
59
|
-
|
|
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
|
|
data/lib/daytona/file_system.rb
CHANGED
|
@@ -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 =
|
|
405
|
+
replace_request = DaytonaToolboxApiClient::ReplaceRequest.new(
|
|
406
406
|
files: files,
|
|
407
407
|
pattern: pattern,
|
|
408
408
|
new_value: new_value
|
data/lib/daytona/lsp_server.rb
CHANGED
|
@@ -56,7 +56,7 @@ module Daytona
|
|
|
56
56
|
language_id:,
|
|
57
57
|
path_to_project:,
|
|
58
58
|
uri: uri(path),
|
|
59
|
-
position:
|
|
59
|
+
position: DaytonaToolboxApiClient::LspPosition.new(line: position.line, character: position.character)
|
|
60
60
|
)
|
|
61
61
|
)
|
|
62
62
|
end
|