opal_proxy 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13a2623ddda8f5c4e6892bc3c96f4cca256c45bb7fa18831c75a38e738bb7cd1
4
- data.tar.gz: 2a54e5826508f77937177d5354054ac6f48408977ee9ffe3eeb78926f2bc462c
3
+ metadata.gz: 16f6f216939bb7ad43a3e8046eba3a468376b385b7c517e7f114ba3ed4fb6f8e
4
+ data.tar.gz: 2fd8b4d5b95354facae44f4978a2b8ed566ff70d005269d2ab715ab1e60a69f6
5
5
  SHA512:
6
- metadata.gz: 502c1850edd96983c5f1816aea4e365d9468bc18a558f2c57348126c52456a7847b7a5be8ac782008d5d195d795021e9b045a228944603631e2c84e3cca224d2
7
- data.tar.gz: 4326f0e789b89a6279b68d1eba455b9133b4a0e9c7192de0c1ad9b21ef749acb7493213a820aadf6555782267bd04e3a93ad5ed35bd433c8e3d6a732f4fca106
6
+ metadata.gz: 378e55ea22b7d67177c22cf580e4adf00fc92d9d76d67c3ee531fdc8a462a2471b12625639d8c7a82d213a6960b96e4a0f86511b5a5df2c733edd0da3416dbad
7
+ data.tar.gz: f4520e248767de0a95bb026eba5adadf06e0b0473626436421ff27759a73a4c455862a640535ccef3b0a62cb4b9b54d9108fbadb00b2907a689e2eea19e8c6cf
data/.rubocop.yml CHANGED
@@ -1,6 +1,42 @@
1
1
  AllCops:
2
+ NewCops: enable
3
+ SuggestExtensions: false
2
4
  TargetRubyVersion: 3.1
3
5
 
6
+ Metrics/ClassLength:
7
+ Exclude:
8
+ - "test/**/*.rb"
9
+ Max: 150
10
+
11
+ Metrics/MethodLength:
12
+ Exclude:
13
+ - "lib/js/**/*.rb"
14
+ - "test/**/*.rb"
15
+
16
+ Lint/LiteralAsCondition:
17
+ Exclude:
18
+ - "lib/js/**/*.rb"
19
+
20
+ Lint/UnusedMethodArgument:
21
+ Exclude:
22
+ - "lib/js/**/*.rb"
23
+
24
+ Lint/UselessAssignment:
25
+ Exclude:
26
+ - "lib/js/**/*.rb"
27
+
28
+ Lint/Void:
29
+ Exclude:
30
+ - "lib/js/**/*.rb"
31
+
32
+ Naming/PredicateMethod:
33
+ Exclude:
34
+ - "lib/js/**/*.rb"
35
+
36
+ Style/CommandLiteral:
37
+ Exclude:
38
+ - "lib/js/**/*.rb"
39
+
4
40
  Style/StringLiterals:
5
41
  EnforcedStyle: double_quotes
6
42
 
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-08-19
4
+
5
+ - Preserve `JS::Promise` wrappers across `then` and `catch` chains.
6
+ - Wrap callback receivers and arguments, and unwrap callback return values.
7
+ - Support JavaScript iterables and array-like objects through `Enumerable`.
8
+ - Make `respond_to?` reflect native property availability.
9
+ - Improve exact, camelCase, and acronym-aware property lookup.
10
+ - Refresh native property introspection for dynamically added properties.
11
+ - Add deterministic browser coverage without external network dependencies.
12
+
13
+ ## [0.1.1] - 2025-08-08
14
+
15
+ - Add `[]=` method to Proxy.
16
+
3
17
  ## [0.1.0] - 2025-07-24
4
18
 
5
- - Initial release
19
+ - Initial release.
data/README.md CHANGED
@@ -58,14 +58,44 @@ window.set_timeout(-> {
58
58
  puts "1. Timeout test OK (1s delay)"
59
59
  }, 1000)
60
60
  window.fetch("https://jsonplaceholder.typicode.com/todos/1")
61
- .then do |response|
62
- response.json().then do |data|
63
- puts "5. Fetched: #{data["title"]}"
64
- document.get_element_by_id("output").inner_html += "<p>5. Fetched: #{data["title"]}</p>"
65
- end
61
+ .then { |response| response.json }
62
+ .then do |data|
63
+ puts "5. Fetched: #{data["title"]}"
64
+ document.get_element_by_id("output").inner_html += "<p>5. Fetched: #{data["title"]}</p>"
66
65
  end
66
+ .catch { |error| warn error["message"] }
67
+ ```
68
+
69
+ Promise callbacks automatically unwrap returned `JS::Proxy` and `JS::Promise` values,
70
+ so native Promise flattening works across chained `then`, `catch`, and `finally` calls.
71
+
72
+ ## Interop behavior
73
+
74
+ Property lookup tries the original name first, then camelCase and common JavaScript
75
+ acronyms such as `HTML`, `URL`, and `URI`. For example, both `inner_html` and
76
+ `document_uri` map to their DOM equivalents.
77
+
78
+ JavaScript properties that collide with Ruby methods can always be accessed with `[]`:
79
+
80
+ ```ruby
81
+ proxy["count"]
82
+ proxy["class"]
67
83
  ```
68
84
 
85
+ When a Ruby block is passed to a JavaScript method, it is appended as the final callback
86
+ argument. The callback receives its JavaScript `this` value first, followed by the native
87
+ callback arguments, all wrapped when necessary:
88
+
89
+ ```ruby
90
+ target.add_event_listener("click") do |receiver, event|
91
+ event.prevent_default
92
+ puts receiver
93
+ end
94
+ ```
95
+
96
+ `JS::Proxy` is enumerable for JavaScript iterable and array-like objects, including
97
+ arrays, `Set`, `Map`, `NodeList`, and objects with numeric indexes and a `length`.
98
+
69
99
  ## JQuery example
70
100
 
71
101
  ```ruby
data/lib/js/proxy.rb CHANGED
@@ -1,12 +1,20 @@
1
+ # backtick_javascript: true
2
+ # frozen_string_literal: true
3
+
1
4
  require "opal"
2
5
  require "native"
3
6
 
4
7
  module JS
8
+ # Shared conversion helpers for values crossing the Ruby/JavaScript boundary.
5
9
  module Helpers
6
10
  def wrap_result(result)
7
- if `result && typeof result.then === 'function'`
11
+ return nil if `result == null`
12
+
13
+ if `typeof result.then === "function" && !result.then.$$owner`
8
14
  Promise.new(result)
9
- elsif `typeof result === 'object' && result !== null`
15
+ elsif `result instanceof Number || result instanceof String || result instanceof Boolean`
16
+ `result.valueOf()`
17
+ elsif `typeof result === "object"`
10
18
  Proxy.new(result)
11
19
  else
12
20
  result
@@ -14,109 +22,151 @@ module JS
14
22
  end
15
23
 
16
24
  def native_methods
17
- @native_methods ||= %x{
18
- let obj = #{to_n};
19
- const props = new Set();
25
+ %x{
26
+ let object = #{to_n};
27
+ const properties = new Set();
28
+
29
+ while (object !== null) {
30
+ for (const key of Reflect.ownKeys(object)) {
31
+ if (typeof key === "symbol") continue;
20
32
 
21
- while (obj !== null) {
22
- for (const key of Reflect.ownKeys(obj)) {
23
- const stringKey = key.toString()
24
- const rubyName = #{to_rb_name(`stringKey`)}
33
+ const nativeName = key.toString();
34
+ const rubyName = #{to_rb_name(`nativeName`)};
25
35
 
26
- if (typeof key !== 'symbol' && stringKey !== rubyName ) { props.add(rubyName); }
27
- props.add(stringKey);
36
+ properties.add(nativeName);
37
+ properties.add(rubyName);
28
38
  }
29
- obj = Object.getPrototypeOf(obj);
39
+ object = Object.getPrototypeOf(object);
30
40
  }
31
41
 
32
- return Array.from(props);
42
+ return Array.from(properties);
33
43
  }
34
44
  end
45
+
46
+ private
47
+
48
+ def unwrap_result(result)
49
+ Native.try_convert(result, result)
50
+ end
35
51
  end
36
52
 
53
+ # Provides Ruby-style access to the properties and methods of a JavaScript object.
37
54
  class Proxy
38
55
  include Enumerable
39
56
  include Helpers
40
57
 
41
- attr_accessor :native
42
-
43
- IRREGULARS = %w(html url uri)
58
+ IRREGULARS = %w[html url uri].freeze
44
59
 
45
60
  def initialize(native)
46
- @native = Native(native)
61
+ self.native = native
47
62
  end
48
63
 
49
- def method_missing(name, *args, &block)
50
- js_name = to_js_name(name)
51
-
52
- unless existing_property?(js_name) || js_name.end_with?("=")
53
- raise NoMethodError, "undefined method `#{name}` for #{self}"
54
- end
64
+ def native
65
+ Native(to_n)
66
+ end
55
67
 
56
- if js_name.end_with?("=")
57
- prop = js_name[0..-2]
58
- native[prop] = args.first
59
- else
60
- val = native[js_name]
61
-
62
- if `typeof val === 'function'`
63
- js_args = args.dup
64
-
65
- if block
66
- js_callback = %x{
67
- function() {
68
- let args = Array.prototype.slice.call(arguments);
69
- return #{block.call(self.class.new(`this`), *args)};
70
- }
71
- }
72
- js_args << js_callback
73
- end
74
-
75
- result = `val.apply(#{to_n}, #{js_args.to_n})`
76
- wrap_result(result)
77
- elsif `typeof val === 'object' && val !== null`
78
- wrap_result(val)
79
- else
80
- val
81
- end
82
- end
68
+ def native=(value)
69
+ @native = Native.try_convert(value, value)
83
70
  end
84
71
 
85
- def to_str
86
- `#{to_n}.toString()`
72
+ def method_missing(name, *args, &block)
73
+ setter = name.end_with?("=")
74
+ property = resolve_property_name(name, allow_missing: setter)
75
+
76
+ return super unless property
77
+ return write_property(property, args.first) if setter
78
+
79
+ read_property(property, args, block)
87
80
  end
88
81
 
89
82
  def respond_to_missing?(name, include_private = false)
90
- true
83
+ setter = name.end_with?("=")
84
+ !!resolve_property_name(name, allow_missing: setter) || super
91
85
  end
92
86
 
93
- def each
94
- return enum_for(:each) unless respond_to?(:length)
87
+ def each(&block)
88
+ return enum_for(:each) unless block
95
89
 
96
- length = self.length
97
- (0...length).each do |i|
98
- yield self[i]
90
+ if iterable?
91
+ each_iterable(&block)
92
+ elsif array_like?
93
+ each_array_like(&block)
94
+ else
95
+ raise TypeError, "#{self.class} does not wrap an iterable or array-like object"
99
96
  end
97
+
98
+ self
100
99
  end
101
100
 
102
101
  def [](index)
103
- val = native[index]
104
- wrap_result(val)
102
+ wrap_result(`#{to_n}[#{index}]`)
103
+ end
104
+
105
+ def []=(key, value)
106
+ converted = unwrap_result(value)
107
+ `#{to_n}[#{key}] = #{converted}`
108
+ value
105
109
  end
106
110
 
107
111
  def to_n
108
- native.to_n
112
+ @native
113
+ end
114
+
115
+ def to_str
116
+ `String(#{to_n})`
109
117
  end
110
118
 
111
119
  def length
112
- native.length
120
+ `#{to_n}.length`
113
121
  end
114
122
 
115
123
  private
116
124
 
117
- def to_js_name(name)
118
- name.to_s.split('_').map.with_index do |part, index|
119
- if IRREGULARS.include? part.gsub("=", "").downcase
125
+ def read_property(property, args, block)
126
+ value = `#{to_n}[#{property}]`
127
+ return wrap_result(value) unless `typeof value === "function"`
128
+
129
+ invoke_native_function(value, args, block)
130
+ end
131
+
132
+ def write_property(property, value)
133
+ converted = unwrap_result(value)
134
+ `#{to_n}[#{property}] = #{converted}`
135
+ value
136
+ end
137
+
138
+ def invoke_native_function(callable, args, block)
139
+ arguments = args.dup
140
+ arguments << callback_for(block) if block
141
+ wrap_result(`callable.apply(#{to_n}, #{arguments.to_n})`)
142
+ end
143
+
144
+ def callback_for(block)
145
+ %x{
146
+ return function() {
147
+ const callbackArgs = Array.prototype.slice.call(arguments).map(function(argument) {
148
+ return #{wrap_result(`argument`)};
149
+ });
150
+ const receiver = #{wrap_result(`this`)};
151
+ return #{unwrap_result(block.call(`receiver`, *`callbackArgs`))};
152
+ };
153
+ }
154
+ end
155
+
156
+ def resolve_property_name(name, allow_missing: false)
157
+ ruby_name = name.to_s.delete_suffix("=")
158
+ candidates = js_name_candidates(ruby_name)
159
+ candidates.find { |candidate| existing_property?(candidate) } ||
160
+ (candidates.last if allow_missing)
161
+ end
162
+
163
+ def js_name_candidates(name)
164
+ [name, camelize(name), camelize(name, acronyms: true)].uniq
165
+ end
166
+
167
+ def camelize(name, acronyms: false)
168
+ name.split("_").map.with_index do |part, index|
169
+ if acronyms && IRREGULARS.include?(part.downcase)
120
170
  part.upcase
121
171
  else
122
172
  index.zero? ? part : part.capitalize
@@ -127,51 +177,70 @@ module JS
127
177
  def to_rb_name(name)
128
178
  name
129
179
  .to_s
130
- .gsub(/([A-Z]+)/) { "_#{$1.downcase}" }
131
- .sub(/^_/, '')
180
+ .gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
181
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
182
+ .tr("-", "_")
183
+ .downcase
132
184
  end
133
185
 
134
186
  def existing_property?(property)
135
187
  `#{property} in #{to_n}`
136
188
  end
137
- end
138
189
 
139
- class Promise < Proxy
140
- include Helpers
190
+ def iterable?
191
+ `typeof Symbol !== "undefined" && typeof #{to_n}[Symbol.iterator] === "function"`
192
+ end
141
193
 
142
- def then(&block)
143
- js_callback = %x{
144
- function(value) {
145
- var ruby_result = #{block.call(wrap_result(`value`))};
146
- if (ruby_result && typeof ruby_result.then === 'function') {
147
- return ruby_result;
148
- } else if (ruby_result && typeof ruby_result.to_n === 'function') {
149
- return ruby_result.to_n();
150
- } else {
151
- return ruby_result;
152
- }
153
- }
194
+ def array_like?
195
+ %x{
196
+ const length = #{to_n}.length;
197
+ return typeof length === "number" &&
198
+ Number.isFinite(length) &&
199
+ length >= 0 &&
200
+ Math.floor(length) === length;
154
201
  }
202
+ end
203
+
204
+ def each_iterable
205
+ iterator = `#{to_n}[Symbol.iterator]()`
206
+
207
+ loop do
208
+ step = `iterator.next()`
209
+ break if `step.done`
155
210
 
156
- self.native = `#{to_n}.then(#{js_callback})`
211
+ yield wrap_result(`step.value`)
212
+ end
213
+ end
214
+
215
+ def each_array_like
216
+ (0...length).each { |index| yield self[index] }
217
+ end
218
+ end
219
+
220
+ # Ruby wrapper for JavaScript promises with chain-preserving return values.
221
+ class Promise < Proxy
222
+ def then(&block)
223
+ result = block ? `#{to_n}.then(#{promise_callback(block)})` : `#{to_n}.then()`
224
+ Promise.new(result)
157
225
  end
158
226
 
159
227
  def catch(&block)
160
- js_callback = %x{
161
- function(error) {
162
- var ruby_result = #{block.call(wrap_result(`error`))};
163
-
164
- if (ruby_result && typeof ruby_result.then === 'function') {
165
- return ruby_result;
166
- } else if (ruby_result && typeof ruby_result.to_n === 'function') {
167
- return ruby_result.to_n();
168
- } else {
169
- return ruby_result;
170
- }
171
- }
172
- }
228
+ result = block ? `#{to_n}.catch(#{promise_callback(block)})` : `#{to_n}.catch()`
229
+ Promise.new(result)
230
+ end
173
231
 
174
- self.native = `#{to_n}.catch(#{js_callback})`
232
+ private
233
+
234
+ def promise_callback(block)
235
+ %x{
236
+ const proxy = #{self};
237
+
238
+ return function(value) {
239
+ const wrappedValue = proxy.$wrap_result(value);
240
+ const rubyResult = block.$call(wrappedValue);
241
+ return proxy.$unwrap_result(rubyResult);
242
+ };
243
+ }
175
244
  end
176
245
  end
177
246
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpalProxy
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/opal_proxy.rb CHANGED
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if RUBY_ENGINE == 'opal'
3
+ if RUBY_ENGINE == "opal"
4
4
  require_relative "js/proxy"
5
5
  else
6
6
  require "opal"
7
7
  require_relative "opal_proxy/version"
8
8
 
9
- Opal.append_path File.expand_path('lib', __dir__)
9
+ Opal.append_path File.expand_path("lib", __dir__)
10
10
  end
11
11
 
12
+ # Namespace for Opal Proxy versioning and Ruby-side integration.
12
13
  module OpalProxy
13
14
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opal_proxy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joseph Schito
@@ -24,8 +24,7 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: 1.8.2
26
26
  description: Opal Proxy provides a dynamic interface to JavaScript objects in Opal,
27
- allowing seamless property access, method calls, and Promise handling using idiomatic
28
- Ruby syntax.
27
+ with idiomatic property access, method calls, and Promise handling.
29
28
  email:
30
29
  - joseph.schito@gmail.com
31
30
  executables: []
@@ -48,6 +47,7 @@ metadata:
48
47
  homepage_uri: https://github.com/josephschito/opal_proxy
49
48
  source_code_uri: https://github.com/josephschito/opal_proxy
50
49
  changelog_uri: https://github.com/josephschito/opal_proxy/blob/main/CHANGELOG.md
50
+ rubygems_mfa_required: 'true'
51
51
  rdoc_options: []
52
52
  require_paths:
53
53
  - lib
@@ -62,7 +62,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
62
62
  - !ruby/object:Gem::Version
63
63
  version: '0'
64
64
  requirements: []
65
- rubygems_version: 3.6.9
65
+ rubygems_version: 4.0.16
66
66
  specification_version: 4
67
67
  summary: Dynamic Ruby-style wrapper for JavaScript objects in Opal.
68
68
  test_files: []