ruby-mana 0.4.0 → 0.5.1

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,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mini_racer"
5
+ rescue LoadError
6
+ raise LoadError, "mini_racer gem is required for JavaScript support. Add `gem 'mini_racer'` to your Gemfile."
7
+ end
8
+
9
+ require "json"
10
+ require "set"
11
+
12
+ module Mana
13
+ module Engines
14
+ class JavaScript < Base
15
+ # JS helper code that creates Proxy wrappers for remote Ruby objects.
16
+ # Injected once per V8 context.
17
+ JS_PROXY_HELPER = <<~JS
18
+ (function() {
19
+ if (typeof __mana_create_proxy !== 'undefined') return;
20
+
21
+ // FinalizationRegistry for automatic GC of remote refs.
22
+ // When a JS proxy is garbage collected, notify Ruby to release the original object.
23
+ if (typeof FinalizationRegistry !== 'undefined') {
24
+ globalThis.__mana_ref_gc = new FinalizationRegistry(function(refId) {
25
+ try { ruby.__ref_release(refId); } catch(e) {}
26
+ });
27
+ }
28
+
29
+ globalThis.__mana_create_proxy = function(refId, typeName) {
30
+ var proxy = new Proxy({ __mana_ref: refId, __mana_type: typeName }, {
31
+ get: function(target, prop) {
32
+ if (prop === '__mana_ref') return target.__mana_ref;
33
+ if (prop === '__mana_type') return target.__mana_type;
34
+ if (prop === '__mana_alive') return ruby.__ref_alive(refId);
35
+ if (prop === 'release') return function() { ruby.__ref_release(refId); };
36
+ if (prop === 'toString' || prop === Symbol.toPrimitive) {
37
+ return function() { return ruby.__ref_to_s(refId); };
38
+ }
39
+ if (prop === 'inspect') {
40
+ return function() { return 'RemoteRef<' + typeName + '#' + refId + '>'; };
41
+ }
42
+ if (typeof prop === 'symbol') return undefined;
43
+ return function() {
44
+ var args = Array.prototype.slice.call(arguments);
45
+ return ruby.__ref_call(refId, prop, JSON.stringify(args));
46
+ };
47
+ }
48
+ });
49
+
50
+ // Register with FinalizationRegistry so GC triggers release
51
+ if (globalThis.__mana_ref_gc) {
52
+ globalThis.__mana_ref_gc.register(proxy, refId);
53
+ }
54
+
55
+ return proxy;
56
+ };
57
+ })();
58
+ JS
59
+
60
+ # Thread-local persistent V8 context (lazy-loaded, long-running)
61
+ def self.context
62
+ Thread.current[:mana_js_context] ||= create_context
63
+ end
64
+
65
+ def self.create_context
66
+ MiniRacer::Context.new
67
+ end
68
+
69
+ def self.reset!
70
+ ctx = Thread.current[:mana_js_context]
71
+ ctx&.dispose
72
+ Thread.current[:mana_js_context] = nil
73
+ Thread.current[:mana_js_callbacks_attached] = nil
74
+ ObjectRegistry.reset!
75
+ end
76
+
77
+ def execute(code)
78
+ ctx = self.class.context
79
+
80
+ # 1. Attach Ruby callbacks (methods + effects + ref operations)
81
+ attach_ruby_callbacks(ctx)
82
+
83
+ # 2. Inject Ruby variables into JS scope
84
+ inject_ruby_vars(ctx, code)
85
+
86
+ # 3. Execute the JS code
87
+ result = ctx.eval(code)
88
+
89
+ # 4. Extract any new/modified variables back to Ruby binding
90
+ extract_js_vars(ctx, code)
91
+
92
+ result
93
+ end
94
+
95
+ private
96
+
97
+ def attach_ruby_callbacks(ctx)
98
+ attached = Thread.current[:mana_js_callbacks_attached] ||= Set.new
99
+
100
+ # Install the JS Proxy helper (once per context)
101
+ unless attached.include?("__proxy_helper")
102
+ ctx.eval(JS_PROXY_HELPER)
103
+ attached << "__proxy_helper"
104
+ end
105
+
106
+ # ruby.read / ruby.write -- variable bridge
107
+ unless attached.include?("ruby.read")
108
+ bnd = @binding
109
+ ctx.attach("ruby.read", proc { |name|
110
+ sym = name.to_sym
111
+ if bnd.local_variables.include?(sym)
112
+ val = bnd.local_variable_get(sym)
113
+ json_safe(val)
114
+ else
115
+ nil
116
+ end
117
+ })
118
+ attached << "ruby.read"
119
+ end
120
+
121
+ unless attached.include?("ruby.write")
122
+ bnd = @binding
123
+ ctx.attach("ruby.write", proc { |name, value|
124
+ bnd.local_variable_set(name.to_sym, value)
125
+ value
126
+ })
127
+ attached << "ruby.write"
128
+ end
129
+
130
+ # Remote reference operations
131
+ attach_ref_callbacks(ctx, attached)
132
+
133
+ # Attach methods from the caller's receiver
134
+ attach_receiver_methods(ctx, attached)
135
+
136
+ # Attach registered Mana effects
137
+ attach_effects(ctx, attached)
138
+ end
139
+
140
+ # Attach callbacks for operating on remote Ruby object references from JS.
141
+ def attach_ref_callbacks(ctx, attached)
142
+ registry = ObjectRegistry.current
143
+
144
+ unless attached.include?("ruby.__ref_call")
145
+ ctx.attach("ruby.__ref_call", proc { |ref_id, method_name, args_json|
146
+ obj = registry.get(ref_id)
147
+ raise "Remote reference #{ref_id} has been released" unless obj
148
+
149
+ args = args_json ? JSON.parse(args_json) : []
150
+ result = obj.public_send(method_name.to_sym, *args)
151
+ json_safe(result)
152
+ })
153
+ attached << "ruby.__ref_call"
154
+ end
155
+
156
+ unless attached.include?("ruby.__ref_release")
157
+ ctx.attach("ruby.__ref_release", proc { |ref_id|
158
+ registry.release(ref_id)
159
+ nil
160
+ })
161
+ attached << "ruby.__ref_release"
162
+ end
163
+
164
+ unless attached.include?("ruby.__ref_alive")
165
+ ctx.attach("ruby.__ref_alive", proc { |ref_id|
166
+ registry.registered?(ref_id)
167
+ })
168
+ attached << "ruby.__ref_alive"
169
+ end
170
+
171
+ unless attached.include?("ruby.__ref_to_s")
172
+ ctx.attach("ruby.__ref_to_s", proc { |ref_id|
173
+ obj = registry.get(ref_id)
174
+ obj ? obj.to_s : "released"
175
+ })
176
+ attached << "ruby.__ref_to_s"
177
+ end
178
+ end
179
+
180
+ def attach_receiver_methods(ctx, attached)
181
+ receiver = @binding.receiver
182
+ user_methods = receiver.class.instance_methods(false) -
183
+ Object.instance_methods -
184
+ [:~@]
185
+
186
+ user_methods.each do |method_name|
187
+ key = "ruby.#{method_name}"
188
+ next if attached.include?(key)
189
+
190
+ recv = receiver
191
+ ctx.attach(key, proc { |*args| json_safe(recv.public_send(method_name, *args)) })
192
+ attached << key
193
+ end
194
+ end
195
+
196
+ def attach_effects(ctx, attached)
197
+ Mana::EffectRegistry.registry.each do |name, effect|
198
+ key = "ruby.#{name}"
199
+ next if attached.include?(key)
200
+
201
+ eff = effect
202
+ ctx.attach(key, proc { |*args|
203
+ input = if args.length == 1 && args[0].is_a?(Hash)
204
+ args[0]
205
+ elsif eff.params.length == args.length
206
+ eff.params.zip(args).to_h { |p, v| [p[:name], v] }
207
+ else
208
+ {}
209
+ end
210
+ json_safe(eff.call(input))
211
+ })
212
+ attached << key
213
+ end
214
+ end
215
+
216
+ def inject_ruby_vars(ctx, code)
217
+ @binding.local_variables.each do |var_name|
218
+ next unless code.match?(/\b#{Regexp.escape(var_name.to_s)}\b/)
219
+
220
+ value = @binding.local_variable_get(var_name)
221
+ inject_value(ctx, var_name.to_s, value)
222
+ rescue => e
223
+ next
224
+ end
225
+ end
226
+
227
+ # Inject a single value into the JS context.
228
+ # Simple types are serialized to JSON. Complex objects become remote ref proxies.
229
+ def inject_value(ctx, name, value)
230
+ case value
231
+ when Numeric, String, TrueClass, FalseClass, NilClass
232
+ ctx.eval("var #{name} = #{JSON.generate(value)}")
233
+ when Symbol
234
+ ctx.eval("var #{name} = #{JSON.generate(value.to_s)}")
235
+ when Array
236
+ ctx.eval("var #{name} = #{JSON.generate(serialize_array(value))}")
237
+ when Hash
238
+ ctx.eval("var #{name} = #{JSON.generate(serialize_hash(value))}")
239
+ else
240
+ # Complex object → register and create JS Proxy
241
+ ref_id = ObjectRegistry.current.register(value)
242
+ ctx.eval("var #{name} = __mana_create_proxy(#{ref_id}, #{JSON.generate(value.class.name)})")
243
+ end
244
+ end
245
+
246
+ def serialize_array(arr)
247
+ arr.map { |v| simple_value?(v) ? serialize_simple(v) : v.to_s }
248
+ end
249
+
250
+ def serialize_hash(hash)
251
+ hash.transform_keys(&:to_s).transform_values { |v|
252
+ simple_value?(v) ? serialize_simple(v) : v.to_s
253
+ }
254
+ end
255
+
256
+ def simple_value?(value)
257
+ case value
258
+ when Numeric, String, Symbol, TrueClass, FalseClass, NilClass, Array, Hash
259
+ true
260
+ else
261
+ false
262
+ end
263
+ end
264
+
265
+ def serialize_simple(value)
266
+ case value
267
+ when Symbol then value.to_s
268
+ when Array then serialize_array(value)
269
+ when Hash then serialize_hash(value)
270
+ else value
271
+ end
272
+ end
273
+
274
+ def extract_js_vars(ctx, code)
275
+ declared_vars = extract_declared_vars(code)
276
+ declared_vars.each do |var_name|
277
+ begin
278
+ value = ctx.eval(var_name)
279
+ deserialized = deserialize(value)
280
+ write_var(var_name, deserialized)
281
+ rescue MiniRacer::RuntimeError
282
+ next
283
+ end
284
+ end
285
+ end
286
+
287
+ def extract_declared_vars(code)
288
+ vars = []
289
+ code.scan(/\b(?:const|let|var)\s+(\w+)\s*=/).each { |m| vars << m[0] }
290
+ code.scan(/^(\w+)\s*=[^=>]/).each { |m| vars << m[0] }
291
+ vars.uniq
292
+ end
293
+
294
+ def deserialize(value)
295
+ value
296
+ end
297
+
298
+ def json_safe(value)
299
+ case value
300
+ when Numeric, String, TrueClass, FalseClass, NilClass
301
+ value
302
+ when Symbol
303
+ value.to_s
304
+ when Array
305
+ value.map { |v| json_safe(v) }
306
+ when Hash
307
+ value.transform_keys(&:to_s).transform_values { |v| json_safe(v) }
308
+ else
309
+ value.to_s
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end