mimic-browser 0.1.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,240 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'openssl'
4
+ require 'socket'
5
+ require 'uri'
6
+ require 'timeout'
7
+ require 'websocket/driver'
8
+
9
+ module MimicSDK
10
+ UNSET = Generated::UNSET
11
+
12
+ class ProtocolError < StandardError
13
+ attr_reader :code, :data
14
+ def initialize(envelope)
15
+ @code = envelope.fetch('code')
16
+ @data = envelope.fetch('data', UNSET)
17
+ super(envelope.fetch('message'))
18
+ end
19
+ end
20
+
21
+ class Transport
22
+ attr_reader :url
23
+
24
+ def self.discover(endpoint, timeout: 15)
25
+ uri = URI(endpoint)
26
+ return endpoint if %w[ws wss].include?(uri.scheme) && uri.host
27
+ raise ArgumentError, 'Expected an HTTP or WebSocket endpoint' unless %w[http https].include?(uri.scheme) && uri.host
28
+ uri.path = uri.path.sub(%r{/$}, '') + '/json/version'
29
+ uri.query = nil
30
+ http = Net::HTTP.new(uri.host, uri.port)
31
+ http.use_ssl = uri.scheme == 'https'
32
+ http.open_timeout = http.read_timeout = timeout
33
+ response = http.get(uri.request_uri)
34
+ raise IOError, "CDP discovery HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
35
+ result = JSON.parse(response.body).fetch('webSocketDebuggerUrl')
36
+ parsed = URI(result)
37
+ raise IOError, 'Invalid CDP discovery websocket' unless %w[ws wss].include?(parsed.scheme) && parsed.host
38
+ result
39
+ end
40
+
41
+ def initialize(endpoint, timeout: 15)
42
+ @url = self.class.discover(endpoint, timeout: timeout)
43
+ uri = URI(@url)
44
+ @mutex = Mutex.new
45
+ @send_mutex = Mutex.new
46
+ @write_mutex = Mutex.new
47
+ @changed = ConditionVariable.new
48
+ @pending = {}
49
+ @subscriptions = []
50
+ @next_id = 0
51
+ @socket = Socket.tcp(uri.host, uri.port, connect_timeout: timeout)
52
+ @write_timeout = timeout
53
+ if uri.scheme == 'wss'
54
+ tls = OpenSSL::SSL::SSLContext.new
55
+ tls.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
56
+ @socket = OpenSSL::SSL::SSLSocket.new(@socket, tls)
57
+ @socket.sync_close = true
58
+ @socket.hostname = uri.host
59
+ Timeout.timeout(timeout, IOError, 'CDP TLS handshake timed out') { @socket.connect }
60
+ @socket.post_connection_check(uri.host)
61
+ end
62
+ @driver = WebSocket::Driver.client(self, max_length: 64 * 1024 * 1024)
63
+ @driver.on(:open) { @mutex.synchronize { @opened = true; @changed.broadcast } }
64
+ @driver.on(:message) { |event| receive(JSON.parse(event.data)) }
65
+ @driver.on(:error) { |error| fail_connection(IOError.new(error.message)) }
66
+ @driver.on(:close) { |_event| fail_connection(IOError.new('CDP connection closed')) }
67
+ @driver.start
68
+ @reader = Thread.new do
69
+ begin
70
+ loop { @driver.parse(@socket.readpartial(16_384)) }
71
+ rescue StandardError => error
72
+ fail_connection(error)
73
+ end
74
+ end
75
+ deadline = monotonic + timeout
76
+ @mutex.synchronize do
77
+ until @opened || @failure
78
+ remaining = deadline - monotonic
79
+ raise IOError, 'CDP handshake timed out' unless remaining.positive?
80
+ @changed.wait(@mutex, remaining)
81
+ end
82
+ raise @failure if @failure
83
+ end
84
+ rescue StandardError
85
+ close
86
+ raise
87
+ end
88
+
89
+ def write(bytes)
90
+ context = Thread.current[:mimic_sdk_write_context]
91
+ _, deadline, cancelled = context if context && context[0].equal?(self)
92
+ deadline ||= monotonic + @write_timeout
93
+ acquire_mutex(@write_mutex, deadline, cancelled)
94
+ begin
95
+ offset = 0
96
+ while offset < bytes.bytesize
97
+ check_deadline(deadline, cancelled)
98
+ written = @socket.write_nonblock(bytes.byteslice(offset..), exception: false)
99
+ case written
100
+ when :wait_readable
101
+ IO.select([@socket], nil, nil, [deadline - monotonic, 0.05].min.clamp(0, 0.05))
102
+ when :wait_writable
103
+ IO.select(nil, [@socket], nil, [deadline - monotonic, 0.05].min.clamp(0, 0.05))
104
+ else
105
+ offset += written
106
+ end
107
+ end
108
+ offset
109
+ ensure
110
+ @write_mutex.unlock
111
+ end
112
+ end
113
+
114
+ def call_raw(method, params = UNSET, session_id: nil, timeout: 30, cancelled: nil)
115
+ raise ArgumentError, 'Expected a qualified CDP method' unless method.is_a?(String) && method.match?(/\A[^.\s]+\.[^\s]+\z/)
116
+ raise IOError, 'Call cancelled before dispatch' if cancelled&.call
117
+ deadline = monotonic + timeout
118
+ id = @mutex.synchronize do
119
+ raise @failure if @failure
120
+ @next_id += 1
121
+ @pending[@next_id] = { session_id: session_id, response: nil }
122
+ @next_id
123
+ end
124
+ envelope = { 'id' => id, 'method' => method }
125
+ envelope['params'] = params unless params.equal?(UNSET)
126
+ envelope['sessionId'] = session_id if session_id
127
+ encoded = JSON.generate(envelope, allow_nan: false)
128
+ acquire_mutex(@send_mutex, deadline, cancelled)
129
+ previous_write_context = Thread.current[:mimic_sdk_write_context]
130
+ begin
131
+ Thread.current[:mimic_sdk_write_context] = [self, deadline, cancelled]
132
+ @driver.text(encoded)
133
+ rescue StandardError => error
134
+ fail_connection(error) # A partially written frame cannot be reused.
135
+ raise
136
+ ensure
137
+ Thread.current[:mimic_sdk_write_context] = previous_write_context
138
+ @send_mutex.unlock
139
+ end
140
+ response = @mutex.synchronize do
141
+ loop do
142
+ raise @failure if @failure
143
+ break @pending[id][:response] if @pending[id][:response]
144
+ raise IOError, 'Call cancelled after dispatch; rollback is not implied' if cancelled&.call
145
+ remaining = deadline - monotonic
146
+ raise IOError, 'CDP call timed out; rollback is not implied' unless remaining.positive?
147
+ @changed.wait(@mutex, [remaining, 0.05].min)
148
+ end
149
+ end
150
+ raise IOError, 'CDP response session does not match the request' unless response['sessionId'] == session_id
151
+ raise ProtocolError.new(response['error']) if response.key?('error')
152
+ response.fetch('result')
153
+ ensure
154
+ @mutex&.synchronize { @pending.delete(id) } if id
155
+ end
156
+
157
+ def subscribe
158
+ queue = Queue.new
159
+ @mutex.synchronize { raise @failure if @failure; @subscriptions << queue }
160
+ [queue, -> { @mutex.synchronize { @subscriptions.delete(queue) }; queue.close }]
161
+ end
162
+
163
+ def close
164
+ return if @closed
165
+ @closed = true
166
+ fail_connection(IOError.new('CDP connection closed'))
167
+ @socket&.close rescue nil
168
+ @reader&.join(1) unless @reader == Thread.current
169
+ nil
170
+ end
171
+
172
+ private
173
+
174
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
175
+
176
+ def check_deadline(deadline, cancelled)
177
+ raise IOError, 'Call cancelled during dispatch; rollback is not implied' if cancelled&.call
178
+ raise IOError, 'CDP write timed out; rollback is not implied' unless monotonic < deadline
179
+ end
180
+
181
+ def acquire_mutex(mutex, deadline, cancelled)
182
+ until mutex.try_lock
183
+ check_deadline(deadline, cancelled)
184
+ sleep 0.005
185
+ end
186
+ end
187
+
188
+ def receive(message)
189
+ @mutex.synchronize do
190
+ if message.key?('id')
191
+ @pending[message['id']][:response] = message if @pending.key?(message['id'])
192
+ @changed.broadcast
193
+ elsif message.key?('method')
194
+ @subscriptions.each do |queue|
195
+ raise IOError, 'CDP event subscriber overflow' if queue.length >= 1024
196
+ queue << message
197
+ end
198
+ end
199
+ end
200
+ end
201
+
202
+ def fail_connection(error)
203
+ @mutex&.synchronize do
204
+ @failure ||= error
205
+ @changed.broadcast
206
+ @subscriptions.each(&:close)
207
+ @subscriptions.clear
208
+ end
209
+ @socket&.close unless @socket&.closed?
210
+ rescue IOError
211
+ nil
212
+ end
213
+ end
214
+
215
+ class Experimental
216
+ def initialize(sender) = (@sender = sender)
217
+ def call(name, params = UNSET, **options)
218
+ raise ArgumentError, 'Expected an exact unqualified wire command leaf' unless name.is_a?(String) && name.match?(/\A[^.\s]+\z/)
219
+ @sender.call("Mimic.#{name}", params, **options)
220
+ end
221
+ def method_missing(name, *args, **options)
222
+ return super if name.to_s.start_with?('_') || %i[to_ary to_hash to_json inspect].include?(name)
223
+ raise ArgumentError, 'Expected at most one params value' if args.length > 1
224
+ call(name.to_s, args.empty? ? UNSET : args.first, **options)
225
+ end
226
+ def respond_to_missing?(_name, _private = false) = false
227
+ end
228
+
229
+ class Client < Generated::MimicCommands
230
+ attr_reader :experimental
231
+ def initialize(transport, session_id: nil)
232
+ @transport, @session_id = transport, session_id
233
+ super(self)
234
+ @experimental = Experimental.new(self)
235
+ end
236
+ def call(method, params = UNSET, **options)
237
+ @transport.call_raw(method, params, session_id: @session_id, **options)
238
+ end
239
+ end
240
+ end
data/lib/mimic_sdk.rb ADDED
@@ -0,0 +1,7 @@
1
+ require_relative 'mimic_sdk/generated'
2
+ require_relative 'mimic_sdk/transport'
3
+ require_relative 'mimic_sdk/runtime'
4
+
5
+ module MimicSDK
6
+ VERSION = '0.1.0'
7
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mimic-browser
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mimic Browser contributors
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: websocket-driver
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rubyzip
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.4'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '4'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '2.4'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '4'
47
+ description:
48
+ email:
49
+ executables:
50
+ - mimic-sdk
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - LICENSE
55
+ - README.md
56
+ - bin/mimic-sdk
57
+ - lib/mimic_sdk.rb
58
+ - lib/mimic_sdk/ferrum.rb
59
+ - lib/mimic_sdk/generated.rb
60
+ - lib/mimic_sdk/runtime-lock.json
61
+ - lib/mimic_sdk/runtime.rb
62
+ - lib/mimic_sdk/transport.rb
63
+ homepage: https://github.com/mimic-browser/sdk
64
+ licenses:
65
+ - Prosperity-3.0.0
66
+ metadata: {}
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.2'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.4.19
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: Pinned Mimic runtimes and typed capabilities beside native browser clients
86
+ test_files: []