lilac-async 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 74b33ed4f34980024dd25d7c7c0a615ff6f54ab2e0ce2576791b9f2d27892865
4
+ data.tar.gz: 6fa9799812eff39b4fb269a7f170f06f6187a2faa9573a09cd54e6825e11a2fd
5
+ SHA512:
6
+ metadata.gz: f4d9aa41670489ba75dbeebd69b5dc7833570596437e8afe50f7d7da1abf94352adaadab63a4ee17feb0713ce52e4f3c4070b53cbaef5fff662ea924880865de
7
+ data.tar.gz: a8db560ce4e68567207f1216be1de7ad3e8a503b1054c3b9656f26865b5467dfb298c33acb1eba3e8d710fdecc03815e3ced2e2a0775faed2c2a98168f5ffe82
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "lilac-async"
5
+ spec.version = "0.2.0"
6
+ spec.authors = ["takahashim"]
7
+
8
+ spec.summary = "Lilac package: async data primitives (Fetchy, Lilac::Resource, selector helpers)"
9
+ spec.description = "Provides the `Fetchy` HTTP client, `Lilac::Resource` " \
10
+ "(signal-backed async data source), and selector " \
11
+ "helpers (`selector` / `select`). Designed for the " \
12
+ "lilac-compiled wasm variant; lilac-full already links " \
13
+ "the async gem in. Picked up automatically by " \
14
+ "`lilac build` via Bundler when the gem appears in the " \
15
+ "project's Gemfile."
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.2.0"
18
+
19
+ # See lilac-extras.gemspec for the mrblib source distribution rationale.
20
+ spec.files = Dir["mrblib/**/*.rb", "*.gemspec"]
21
+
22
+ spec.metadata = {
23
+ "lilac_package" => "true",
24
+ "source_code_uri" => "https://github.com/takahashim/lilac",
25
+ "rubygems_mfa_required" => "true",
26
+ }
27
+ end
data/mrblib/fetchy.rb ADDED
@@ -0,0 +1,394 @@
1
+ # Fetchy v2 — small HTTP DSL for Lilac / resource loaders.
2
+ #
3
+ # Callback API is intentionally not supported. The primary forms are:
4
+ #
5
+ # data = Fetchy.json("/api/items")
6
+ #
7
+ # user = resource(initial: nil) do |r|
8
+ # Fetchy.get("/api/users/#{@user_id.value}", signal: r.abort_signal).json
9
+ # end
10
+ #
11
+ # A block, when given, configures the request builder:
12
+ #
13
+ # Fetchy.get("/api/items") do |f|
14
+ # f.timeout 5000
15
+ # f.header "Accept", "application/json"
16
+ # end.json
17
+ class Fetchy
18
+ class Error < StandardError; end
19
+ class AbortError < Error; end
20
+ class TimeoutError < Error; end
21
+ class ParseError < Error; end
22
+
23
+ class HTTPError < Error
24
+ attr_reader :status, :status_text, :url, :response
25
+
26
+ def initialize(status, status_text, url, response = nil)
27
+ @status = status
28
+ @status_text = status_text
29
+ @url = url
30
+ @response = response
31
+ super("HTTP #{status}: #{status_text}")
32
+ end
33
+ end
34
+
35
+ class Builder
36
+ attr_reader :options
37
+
38
+ def initialize(opts = {})
39
+ @options = {
40
+ params: {},
41
+ headers: {},
42
+ }
43
+ apply(opts)
44
+ end
45
+
46
+ def param(name, value)
47
+ @options[:params][name.to_s] = value
48
+ self
49
+ end
50
+
51
+ def params(hash)
52
+ (hash || {}).each { |k, v| param(k, v) }
53
+ self
54
+ end
55
+
56
+ def header(name, value)
57
+ @options[:headers][name.to_s] = value
58
+ self
59
+ end
60
+
61
+ def headers(hash)
62
+ (hash || {}).each { |k, v| header(k, v) }
63
+ self
64
+ end
65
+
66
+ def json(value)
67
+ @options[:json] = value
68
+ self
69
+ end
70
+
71
+ def body(value)
72
+ @options[:body] = value
73
+ self
74
+ end
75
+
76
+ def timeout(ms)
77
+ @options[:timeout] = ms
78
+ self
79
+ end
80
+
81
+ def signal(abort_signal)
82
+ @options[:signal] = abort_signal
83
+ self
84
+ end
85
+
86
+ def base(url)
87
+ @options[:base] = url
88
+ self
89
+ end
90
+
91
+ def accept(type)
92
+ header("Accept", type)
93
+ end
94
+
95
+ def content_type(type)
96
+ header("Content-Type", type)
97
+ end
98
+
99
+ private
100
+
101
+ def apply(opts)
102
+ opts = opts.dup
103
+ params(opts.delete(:params))
104
+ headers(opts.delete(:headers))
105
+ json(opts.delete(:json)) if opts.key?(:json)
106
+ body(opts.delete(:body)) if opts.key?(:body)
107
+ timeout(opts.delete(:timeout)) if opts.key?(:timeout)
108
+ if opts.key?(:signal)
109
+ signal(opts.delete(:signal))
110
+ elsif (run = Lilac::Resource.current_run)
111
+ signal(run.abort_signal)
112
+ end
113
+ base(opts.delete(:base)) if opts.key?(:base)
114
+ accept(opts.delete(:accept)) if opts.key?(:accept)
115
+ content_type(opts.delete(:content_type)) if opts.key?(:content_type)
116
+ opts.each { |k, v| @options[k] = v }
117
+ end
118
+ end
119
+
120
+ class Response
121
+ def initialize(js_response, url)
122
+ @js_response = js_response
123
+ @url = url
124
+ @headers = nil
125
+ end
126
+
127
+ def status
128
+ @js_response[:status].to_i
129
+ end
130
+
131
+ def ok?
132
+ @js_response[:ok].js_bool
133
+ end
134
+
135
+ def url
136
+ u = @js_response[:url]
137
+ return @url if u.js_null? || u.to_s.empty?
138
+ u.to_s
139
+ end
140
+
141
+ def headers
142
+ return @headers if @headers
143
+ pairs = JS.global[:Array].call(:from, @js_response[:headers].call(:entries)).to_ruby
144
+ @headers = {}
145
+ pairs.each do |k, v|
146
+ @headers[k] = v
147
+ @headers[canonical_header_name(k)] ||= v
148
+ end
149
+ @headers
150
+ end
151
+
152
+ def text
153
+ @js_response.call(:text).await.to_s
154
+ end
155
+
156
+ def json
157
+ @js_response.call(:json).await.to_ruby
158
+ rescue JS::Error => e
159
+ raise ParseError, e.message
160
+ end
161
+
162
+ def body
163
+ @js_response[:body]
164
+ end
165
+
166
+ def bytes
167
+ @js_response.call(:arrayBuffer).await
168
+ end
169
+
170
+ private
171
+
172
+ def canonical_header_name(name)
173
+ name.to_s.split("-").map { |part| part[0] ? part[0].upcase + part[1..-1] : part }.join("-")
174
+ end
175
+ end
176
+
177
+ class Request
178
+ attr_reader :timeout_ms
179
+
180
+ def initialize(url, method:, options:)
181
+ @url = url.to_s
182
+ @method = method.to_s.upcase
183
+ @options = options
184
+ validate_options!
185
+ @controller = nil
186
+ @timeout_ms = @options[:timeout]
187
+ @aborted = false
188
+ @timed_out = false
189
+ @completed = false
190
+ @started = false
191
+ @settled_response = nil
192
+ @settled_error = nil
193
+ end
194
+
195
+ def abort
196
+ return if @aborted || @timed_out || @completed
197
+ @aborted = true
198
+ @controller&.call(:abort)
199
+ self
200
+ end
201
+
202
+ def aborted?
203
+ @aborted
204
+ end
205
+
206
+ def timed_out?
207
+ @timed_out
208
+ end
209
+
210
+ def completed?
211
+ @completed
212
+ end
213
+
214
+ def response
215
+ perform unless @started
216
+ raise @settled_error if @settled_error
217
+ @settled_response
218
+ end
219
+
220
+ def json
221
+ response.json
222
+ end
223
+
224
+ def text
225
+ response.text
226
+ end
227
+
228
+ def bytes
229
+ response.bytes
230
+ end
231
+
232
+ private
233
+
234
+ def perform
235
+ @started = true
236
+ url = build_url
237
+ controller_ctor = JS.global[:AbortController]
238
+ @controller = controller_ctor.js_null? ? nil : controller_ctor.new
239
+ @controller&.call(:abort) if @aborted
240
+ timer = wire_timeout
241
+ external = wire_external_signal
242
+
243
+ begin
244
+ response = JS.global.fetch(url, JS.object(build_init_hash)).await
245
+ wrapped = Response.new(response, url)
246
+ unless wrapped.ok?
247
+ raise HTTPError.new(wrapped.status, response[:statusText].to_s, wrapped.url, wrapped)
248
+ end
249
+ @settled_response = wrapped
250
+ rescue => e
251
+ @settled_error = classify_error(e, url)
252
+ ensure
253
+ clear_timeout(timer)
254
+ clear_external_signal(external)
255
+ @completed = true
256
+ end
257
+ end
258
+
259
+ def validate_options!
260
+ return unless @options.key?(:json) && @options.key?(:body)
261
+ raise ArgumentError, "Fetchy: pass either :json or :body, not both"
262
+ end
263
+
264
+ def build_url
265
+ url = @url
266
+ base = @options[:base]
267
+ if base && !base.empty? && !url.start_with?("http://", "https://", "//")
268
+ base = base.end_with?("/") ? base[0..-2] : base
269
+ tail = url.start_with?("/") ? url : "/#{url}"
270
+ url = base + tail
271
+ end
272
+
273
+ params = @options[:params] || {}
274
+ return url if params.empty?
275
+ encoded = params.map do |k, v|
276
+ "#{JS.encode_uri_component(k)}=#{JS.encode_uri_component(v)}"
277
+ end.join("&")
278
+ sep = url.include?("?") ? "&" : "?"
279
+ url + sep + encoded
280
+ end
281
+
282
+ def build_init_hash
283
+ headers = (@options[:headers] || {}).dup
284
+ init_h = { method: @method }
285
+
286
+ if @options.key?(:json)
287
+ init_h[:body] = Lilac::JSON.generate(@options[:json])
288
+ headers["Content-Type"] = "application/json" unless has_header?(headers, "content-type")
289
+ elsif @options.key?(:body)
290
+ init_h[:body] = @options[:body]
291
+ end
292
+
293
+ init_h[:headers] = headers unless headers.empty?
294
+ init_h[:signal] = @controller[:signal] if @controller
295
+ init_h
296
+ end
297
+
298
+ def has_header?(headers, name)
299
+ headers.keys.any? { |k| k.to_s.downcase == name }
300
+ end
301
+
302
+ def wire_timeout
303
+ return nil unless @timeout_ms
304
+ callback = JS.callback do
305
+ @timed_out = true
306
+ @controller&.call(:abort)
307
+ end
308
+ id = JS.global.call(:setTimeout, callback, @timeout_ms).to_i
309
+ [id, callback]
310
+ end
311
+
312
+ def clear_timeout(timer)
313
+ return unless timer
314
+ id, callback = timer
315
+ JS.global.call(:clearTimeout, id)
316
+ JS.release_callback(callback)
317
+ end
318
+
319
+ def wire_external_signal
320
+ signal = @options[:signal]
321
+ return nil if signal.nil? || signal.js_null? || @controller.nil?
322
+ callback = JS.callback do
323
+ @aborted = true
324
+ @controller.call(:abort)
325
+ end
326
+ signal.call(:addEventListener, "abort", callback)
327
+ if signal[:aborted].js_bool
328
+ @aborted = true
329
+ @controller.call(:abort)
330
+ end
331
+ [signal, callback]
332
+ end
333
+
334
+ def clear_external_signal(external)
335
+ return unless external
336
+ signal, callback = external
337
+ signal.call(:removeEventListener, "abort", callback)
338
+ JS.release_callback(callback)
339
+ end
340
+
341
+ def classify_error(err, url)
342
+ return TimeoutError.new("timeout after #{@timeout_ms}ms") if @timed_out
343
+ return AbortError.new("aborted") if @aborted
344
+ return err if err.is_a?(HTTPError)
345
+ if err.is_a?(JS::Error) && err.message.to_s.include?("AbortError")
346
+ return AbortError.new("aborted")
347
+ end
348
+ err
349
+ end
350
+ end
351
+
352
+ class << self
353
+ def get(url, **opts, &builder)
354
+ request(url, method: "GET", **opts, &builder)
355
+ end
356
+
357
+ def post(url, **opts, &builder)
358
+ request(url, method: "POST", **opts, &builder)
359
+ end
360
+
361
+ def put(url, **opts, &builder)
362
+ request(url, method: "PUT", **opts, &builder)
363
+ end
364
+
365
+ def patch(url, **opts, &builder)
366
+ request(url, method: "PATCH", **opts, &builder)
367
+ end
368
+
369
+ def delete(url, **opts, &builder)
370
+ request(url, method: "DELETE", **opts, &builder)
371
+ end
372
+
373
+ def request(url, method:, **opts, &builder)
374
+ built = Builder.new(opts)
375
+ builder.call(built) if builder
376
+ Request.new(url, method: method, options: built.options)
377
+ end
378
+
379
+ def json(url, **opts, &builder)
380
+ method = opts.key?(:method) ? opts.delete(:method) : "GET"
381
+ request(url, method: method, **opts, &builder).json
382
+ end
383
+
384
+ def text(url, **opts, &builder)
385
+ method = opts.key?(:method) ? opts.delete(:method) : "GET"
386
+ request(url, method: method, **opts, &builder).text
387
+ end
388
+
389
+ def bytes(url, **opts, &builder)
390
+ method = opts.key?(:method) ? opts.delete(:method) : "GET"
391
+ request(url, method: method, **opts, &builder).bytes
392
+ end
393
+ end
394
+ end
@@ -0,0 +1,322 @@
1
+ # Lilac async extensions.
2
+ #
3
+ # Loaded after core `mruby-lilac`. Houses the optional async / data
4
+ # layer so apps that only need the reactive component core can omit it.
5
+
6
+ module Lilac
7
+ module AsyncExtensions
8
+ def resource(initial: nil, defer: false, keep_value: true, &block)
9
+ raise ArgumentError, "block required" unless block
10
+ r = Resource.new(initial: initial, defer: defer, keep_value: keep_value, &block)
11
+ register_disposable("resource", r)
12
+ r
13
+ end
14
+
15
+ def selector(source, equals: nil)
16
+ s = Selector.new(source, equals: equals)
17
+ register_disposable("selector", s)
18
+ s
19
+ end
20
+ end
21
+
22
+ class SelectorEntry
23
+ include Reactive::Subscribable
24
+
25
+ def initialize
26
+ @subs = Subscribers.new
27
+ end
28
+ end
29
+
30
+ class Selector
31
+ include Reactive::Observer
32
+
33
+ def initialize(source, equals: nil)
34
+ raise ArgumentError, "selector source must respond to #value" unless source.respond_to?(:value)
35
+ @source = source
36
+ @equals = equals
37
+ @deps = []
38
+ @entries = {}
39
+ @value = nil
40
+ recompute
41
+ end
42
+
43
+ def call(key)
44
+ if (obs = Reactive.current)
45
+ entry = entry_for(key)
46
+ entry.subscribe(obs)
47
+ obs.add_dep(entry)
48
+ end
49
+ selected?(key)
50
+ end
51
+
52
+ def selected?(key)
53
+ compare(@value, key)
54
+ end
55
+
56
+ def notify
57
+ prev = @value
58
+ recompute
59
+ return if compare(prev, @value)
60
+ notify_entry(prev)
61
+ notify_entry(@value)
62
+ end
63
+
64
+ def dispose
65
+ remove_all_deps
66
+ @entries.clear
67
+ end
68
+
69
+ private
70
+
71
+ def recompute
72
+ remove_all_deps
73
+ Reactive.track(self) do
74
+ @value = @source.value
75
+ end
76
+ end
77
+
78
+ def notify_entry(key)
79
+ entry = @entries[key]
80
+ entry&.notify_subscribers
81
+ end
82
+
83
+ def entry_for(key)
84
+ @entries[key] ||= SelectorEntry.new
85
+ end
86
+
87
+ def compare(a, b)
88
+ return @equals.call(a, b) if @equals.respond_to?(:call)
89
+ a == b
90
+ end
91
+ end
92
+
93
+ class ResourceObserver
94
+ include Reactive::Observer
95
+
96
+ def initialize(resource)
97
+ @resource = resource
98
+ @deps = []
99
+ @disposed = false
100
+ end
101
+
102
+ def notify
103
+ return if @disposed
104
+ @resource.__observer_notified__(self)
105
+ end
106
+
107
+ # Override Observer#add_dep to guard against deps being added
108
+ # after dispose (e.g. a stale fiber resuming).
109
+ def add_dep(dep)
110
+ return if @disposed
111
+ super
112
+ end
113
+
114
+ def dispose
115
+ return if @disposed
116
+ @disposed = true
117
+ remove_all_deps
118
+ end
119
+ end
120
+
121
+ class ResourceRun
122
+ def initialize
123
+ ctor = JS.global[:AbortController]
124
+ @controller = ctor.js_null? ? nil : ctor.new
125
+ @cancelled = false
126
+ @null_signal = nil
127
+ end
128
+
129
+ def abort_signal
130
+ return @controller[:signal] if @controller
131
+ @null_signal ||= JS.wrap(nil)
132
+ end
133
+
134
+ def cancelled?
135
+ @cancelled
136
+ end
137
+
138
+ def __cancel__!
139
+ return if @cancelled
140
+ @cancelled = true
141
+ @controller&.call(:abort)
142
+ end
143
+ end
144
+
145
+ class Resource
146
+ # Per-fiber currently-executing `ResourceRun`, set by `start_run`
147
+ # around the user block. Read by `Fetchy::Builder` to default
148
+ # `signal:` when not explicitly provided, so a `Fetchy.json(url)`
149
+ # call inside a `resource { }` block auto-aborts on resource
150
+ # cancellation. Fiber-id keyed (like `Reactive::TRACKER`) so
151
+ # concurrent fibers don't trample each other.
152
+ CURRENT_RUNS = {}
153
+
154
+ def self.current_run
155
+ CURRENT_RUNS[fiber_key]
156
+ end
157
+
158
+ def self.fiber_key
159
+ fiber = ::Fiber.current
160
+ fiber ? fiber.__id__ : :main
161
+ end
162
+
163
+ # Set `run` as the current run for the duration of `yield`, then
164
+ # restore the previous value (or remove the entry if none).
165
+ def self.with_current_run(run)
166
+ key = fiber_key
167
+ prev = CURRENT_RUNS[key]
168
+ CURRENT_RUNS[key] = run
169
+ yield
170
+ ensure
171
+ prev.nil? ? CURRENT_RUNS.delete(key) : CURRENT_RUNS[key] = prev
172
+ end
173
+
174
+ def initialize(initial:, defer:, keep_value:, &block)
175
+ raise ArgumentError, "block required" unless block
176
+ @block = block
177
+ @initial = initial
178
+ @keep_value = keep_value
179
+ @value_signal = Signal.new(initial)
180
+ @error_signal = Signal.new(nil)
181
+ @state_signal = Signal.new(:idle)
182
+ @has_value = false
183
+ @disposed = false
184
+ @current_observer = nil
185
+ @current_run = nil
186
+ start_run unless defer
187
+ end
188
+
189
+ def value
190
+ @value_signal.value
191
+ end
192
+
193
+ def error
194
+ @error_signal.value
195
+ end
196
+
197
+ def state
198
+ @state_signal.value
199
+ end
200
+
201
+ def loading?
202
+ s = @state_signal.value
203
+ s == :pending || s == :refreshing
204
+ end
205
+
206
+ def idle?
207
+ @state_signal.value == :idle
208
+ end
209
+
210
+ def ready?
211
+ @state_signal.value == :ready
212
+ end
213
+
214
+ def refreshing?
215
+ @state_signal.value == :refreshing
216
+ end
217
+
218
+ def errored?
219
+ @state_signal.value == :errored
220
+ end
221
+
222
+ def reload
223
+ start_run
224
+ self
225
+ end
226
+
227
+ def mutate(&block)
228
+ raise ArgumentError, "block required" unless block
229
+ ret = @value_signal.update(&block)
230
+ @has_value = true
231
+ ret
232
+ end
233
+
234
+ def reset
235
+ return if @disposed
236
+ cancel_current
237
+ Lilac.batch do
238
+ @value_signal.value = @initial
239
+ @error_signal.value = nil
240
+ @state_signal.value = :idle
241
+ end
242
+ @has_value = false
243
+ self
244
+ end
245
+
246
+ def dispose
247
+ return if @disposed
248
+ @disposed = true
249
+ cancel_current
250
+ @current_observer&.dispose
251
+ @current_observer = nil
252
+ end
253
+
254
+ def __observer_notified__(observer)
255
+ return observer.dispose if @disposed
256
+ return observer.dispose unless observer.equal?(@current_observer)
257
+ start_run
258
+ end
259
+
260
+ private
261
+
262
+ def start_run
263
+ return if @disposed
264
+ previous = @current_observer
265
+ previous&.dispose
266
+ cancel_current
267
+
268
+ observer = ResourceObserver.new(self)
269
+ run = ResourceRun.new
270
+ @current_observer = observer
271
+ @current_run = run
272
+
273
+ Lilac.batch do
274
+ @value_signal.value = @initial if !@keep_value || !@has_value
275
+ @error_signal.value = nil
276
+ @state_signal.value = (@keep_value && @has_value) ? :refreshing : :pending
277
+ end
278
+
279
+ JS.__run_in_fiber__ do
280
+ Resource.with_current_run(run) do
281
+ begin
282
+ result = Reactive.track(observer) { @block.call(run) }
283
+ settle_success(observer, run, result)
284
+ rescue => e
285
+ settle_error(observer, run, e)
286
+ end
287
+ end
288
+ end
289
+ end
290
+
291
+ def settle_success(observer, run, result)
292
+ return observer.dispose if stale_run?(observer, run)
293
+ @current_run = nil
294
+ Lilac.batch do
295
+ @value_signal.value = result
296
+ @error_signal.value = nil
297
+ @state_signal.value = :ready
298
+ end
299
+ @has_value = true
300
+ end
301
+
302
+ def settle_error(observer, run, error)
303
+ return observer.dispose if stale_run?(observer, run)
304
+ @current_run = nil
305
+ Lilac.batch do
306
+ @error_signal.value = error
307
+ @state_signal.value = :errored
308
+ end
309
+ end
310
+
311
+ def stale_run?(observer, run)
312
+ !observer.equal?(@current_observer) || !run.equal?(@current_run)
313
+ end
314
+
315
+ def cancel_current
316
+ @current_run&.__cancel__!
317
+ @current_run = nil
318
+ end
319
+ end
320
+
321
+ Component.include(AsyncExtensions)
322
+ end
metadata ADDED
@@ -0,0 +1,47 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lilac-async
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - takahashim
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Provides the `Fetchy` HTTP client, `Lilac::Resource` (signal-backed async
13
+ data source), and selector helpers (`selector` / `select`). Designed for the lilac-compiled
14
+ wasm variant; lilac-full already links the async gem in. Picked up automatically
15
+ by `lilac build` via Bundler when the gem appears in the project's Gemfile.
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lilac-async.gemspec
21
+ - mrblib/fetchy.rb
22
+ - mrblib/lilac_async.rb
23
+ licenses:
24
+ - MIT
25
+ metadata:
26
+ lilac_package: 'true'
27
+ source_code_uri: https://github.com/takahashim/lilac
28
+ rubygems_mfa_required: 'true'
29
+ rdoc_options: []
30
+ require_paths:
31
+ - lib
32
+ required_ruby_version: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 3.2.0
37
+ required_rubygems_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ requirements: []
43
+ rubygems_version: 3.6.9
44
+ specification_version: 4
45
+ summary: 'Lilac package: async data primitives (Fetchy, Lilac::Resource, selector
46
+ helpers)'
47
+ test_files: []