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 +7 -0
- data/lilac-async.gemspec +27 -0
- data/mrblib/fetchy.rb +394 -0
- data/mrblib/lilac_async.rb +322 -0
- metadata +47 -0
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
|
data/lilac-async.gemspec
ADDED
|
@@ -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: []
|