funicular 0.0.1 → 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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +66 -20
  4. data/Rakefile +103 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/architecture.md +118 -0
  15. data/exe/funicular +32 -0
  16. data/lib/funicular/assets/funicular.css +23 -0
  17. data/lib/funicular/assets/funicular.rb +21 -0
  18. data/lib/funicular/assets/funicular_debug.css +73 -0
  19. data/lib/funicular/assets/funicular_debug.js +183 -0
  20. data/lib/funicular/commands/routes.rb +69 -0
  21. data/lib/funicular/compiler.rb +143 -0
  22. data/lib/funicular/configuration.rb +76 -0
  23. data/lib/funicular/helpers/picoruby_helper.rb +112 -0
  24. data/lib/funicular/middleware.rb +123 -0
  25. data/lib/funicular/plugin.rb +147 -0
  26. data/lib/funicular/railtie.rb +26 -0
  27. data/lib/funicular/route_parser.rb +137 -0
  28. data/lib/funicular/schema.rb +167 -0
  29. data/lib/funicular/ssr/runtime.rb +101 -0
  30. data/lib/funicular/ssr.rb +51 -0
  31. data/lib/funicular/testing/node_runner.mjs +293 -0
  32. data/lib/funicular/testing/node_runner.rb +190 -0
  33. data/lib/funicular/testing.rb +22 -0
  34. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  35. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  37. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  38. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  39. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  41. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  42. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  44. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  45. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  46. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  47. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  48. data/lib/funicular/version.rb +1 -1
  49. data/lib/funicular.rb +32 -1
  50. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  51. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  52. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  53. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  54. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  55. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  56. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  57. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  58. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  59. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  60. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  61. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  62. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  63. data/lib/tasks/funicular.rake +218 -0
  64. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  65. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  66. data/minitest/funicular_test.rb +13 -0
  67. data/minitest/hydration_test.rb +87 -0
  68. data/minitest/plugin_test.rb +51 -0
  69. data/minitest/schema_test.rb +106 -0
  70. data/minitest/ssr_test.rb +94 -0
  71. data/minitest/test_helper.rb +7 -0
  72. data/minitest/validations_test.rb +183 -0
  73. data/mrbgem.rake +16 -0
  74. data/mrblib/0_validations.rb +206 -0
  75. data/mrblib/1_validators.rb +180 -0
  76. data/mrblib/cable.rb +432 -0
  77. data/mrblib/component.rb +1050 -0
  78. data/mrblib/debug.rb +208 -0
  79. data/mrblib/differ.rb +254 -0
  80. data/mrblib/environment_inquirer.rb +34 -0
  81. data/mrblib/error_boundary.rb +125 -0
  82. data/mrblib/file_upload.rb +192 -0
  83. data/mrblib/form_builder.rb +300 -0
  84. data/mrblib/funicular.rb +245 -0
  85. data/mrblib/html_serializer.rb +121 -0
  86. data/mrblib/http.rb +183 -0
  87. data/mrblib/model.rb +196 -0
  88. data/mrblib/patcher.rb +269 -0
  89. data/mrblib/router.rb +266 -0
  90. data/mrblib/store.rb +304 -0
  91. data/mrblib/store_collection.rb +171 -0
  92. data/mrblib/store_singleton.rb +79 -0
  93. data/mrblib/styles.rb +83 -0
  94. data/mrblib/vdom.rb +273 -0
  95. data/sig/cable.rbs +66 -0
  96. data/sig/component.rbs +149 -0
  97. data/sig/debug.rbs +28 -0
  98. data/sig/differ.rbs +18 -0
  99. data/sig/environment_iquirer.rbs +10 -0
  100. data/sig/error_boundary.rbs +14 -0
  101. data/sig/file_upload.rbs +18 -0
  102. data/sig/form_builder.rbs +29 -0
  103. data/sig/funicular.rbs +24 -1
  104. data/sig/html_serializer.rbs +20 -0
  105. data/sig/http.rbs +37 -0
  106. data/sig/model.rbs +28 -0
  107. data/sig/patcher.rbs +18 -0
  108. data/sig/router.rbs +44 -0
  109. data/sig/store.rbs +89 -0
  110. data/sig/store_collection.rbs +43 -0
  111. data/sig/store_singleton.rbs +19 -0
  112. data/sig/styles.rbs +25 -0
  113. data/sig/validations.rbs +103 -0
  114. data/sig/vdom.rbs +59 -0
  115. metadata +154 -8
@@ -0,0 +1,245 @@
1
+ # The 'js' gem (picoruby-wasm) provides JavaScript interop and is only
2
+ # available in wasm builds. During test builds, picoruby-wasm is excluded
3
+ # from dependencies (see mrbgem.rake), so `require 'js'` raises LoadError.
4
+ #
5
+ # Additionally, gem init order is not guaranteed to be stable. A dummy
6
+ # `require` in picoruby-mruby/mrblib/require.rb exists to suppress
7
+ # LoadError during picogem_init, but if picoruby-require initializes
8
+ # before this gem, the real `require` (which raises LoadError) will
9
+ # already be active. Rescuing LoadError here makes the code robust
10
+ # regardless of init order.
11
+ begin
12
+ require 'js'
13
+ rescue LoadError
14
+ # not available outside wasm environment
15
+ end
16
+
17
+ module Funicular
18
+ # Guard against redefinition: when the mrblib runtime is loaded into a
19
+ # CRuby/Rails process for SSR, lib/funicular/version.rb has already defined
20
+ # VERSION for the CRuby gem. In the wasm build VERSION is undefined here.
21
+ VERSION = '0.1.0' unless Funicular.const_defined?(:VERSION)
22
+
23
+ def self.version
24
+ VERSION
25
+ end
26
+
27
+ # True when the runtime is loaded under CRuby on the server (SSR) rather
28
+ # than running as PicoRuby.wasm in the browser. JS-dependent entry points
29
+ # become no-ops in this mode. Defaults to false (browser).
30
+ @server = false
31
+
32
+ def self.server?
33
+ @server
34
+ end
35
+
36
+ def self.server=(value)
37
+ @server = value ? true : false
38
+ end
39
+
40
+ def self.env
41
+ @env ||= EnvironmentInquirer.new(ENV['FUNICULAR_ENV'] || ENV['RAILS_ENV'] || 'development')
42
+ end
43
+
44
+ def self.env=(environment)
45
+ case environment
46
+ when EnvironmentInquirer
47
+ @env = environment
48
+ when nil
49
+ @env = nil
50
+ else
51
+ @env = EnvironmentInquirer.new(environment)
52
+ end
53
+ # @type ivar @env: EnvironmentInquirer?
54
+ @env
55
+ end
56
+
57
+ @router = nil
58
+
59
+ def self.router
60
+ @router
61
+ end
62
+
63
+ # Read the SSR state embedded by the server (funicular_state_tag) as a
64
+ # Ruby Hash with string keys. Returns {} when absent or on the server.
65
+ # Goes through JSON.stringify/parse for a reliable JS->Ruby conversion.
66
+ def self.window_state
67
+ return {} if server?
68
+ win = JS.global[:window]
69
+ # @type var win: JS::Object?
70
+ return {} unless win
71
+ raw = win[:__FUNICULAR_STATE__]
72
+ return {} if raw.nil?
73
+ json = JS.global[:JSON]
74
+ # @type var json: untyped
75
+ json_str = json.stringify(raw)
76
+ JSON.parse(json_str.to_s)
77
+ rescue => e
78
+ puts "[Funicular] Failed to read window state: #{e.message}"
79
+ {}
80
+ end
81
+
82
+ # True when the server embedded hydration state on the page.
83
+ def self.has_ssr_state?
84
+ return false if server?
85
+ win = JS.global[:window]
86
+ # @type var win: JS::Object?
87
+ return false unless win
88
+ !win[:__FUNICULAR_STATE__].nil?
89
+ rescue
90
+ false
91
+ end
92
+
93
+ # The first element child of a container, or nil. Used to find the
94
+ # server-rendered root for hydration.
95
+ def self.first_element_child(container_element)
96
+ child = container_element[:firstElementChild]
97
+ child.is_a?(JS::Element) ? child : nil
98
+ end
99
+
100
+ # Load schemas for models
101
+ # Usage:
102
+ # Funicular.load_schemas({ User => "user", Session => "session" }) do
103
+ # Funicular.start(container: 'app') { |router| ... }
104
+ # end
105
+ def self.load_schemas(models, &block)
106
+ # On the server there is no fetch and no need for client-side schemas:
107
+ # SSR injects plain data into component state directly. Just run the
108
+ # block so route registration (Funicular.start) still happens.
109
+ if server?
110
+ block.call if block
111
+ return
112
+ end
113
+
114
+ schemas_loaded = 0
115
+ total_schemas = models.size
116
+
117
+ check_completion = -> {
118
+ if schemas_loaded >= total_schemas
119
+ puts "[Funicular] All schemas loaded (#{schemas_loaded}/#{total_schemas})"
120
+ block.call if block
121
+ end
122
+ }
123
+
124
+ models.each do |model_class, schema_name|
125
+ HTTP.get("/api/schema/#{schema_name}") do |response|
126
+ if response.error?
127
+ puts "[Schema] Failed to load #{schema_name} schema: #{response.error_message}"
128
+ else
129
+ model_class.load_schema(response.data)
130
+ puts "[Schema] #{schema_name} model initialized"
131
+ schemas_loaded += 1
132
+ check_completion.call
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ # Start Funicular application
139
+ # Usage:
140
+ # Funicular.start(MyComponent, container: 'app')
141
+ # Funicular.start(MyComponent, container: 'app', props: { name: 'John' })
142
+ def self.start(component_class = nil, container: 'app', props: {}, hydrate: false, &block)
143
+ # On the server we only need route registration so SSR can resolve a
144
+ # path to a component. Skip all DOM/JS work (container lookup, popstate
145
+ # listener, debug export).
146
+ if server?
147
+ if block
148
+ router = Router.new(nil)
149
+ @router = router
150
+ block.call(router)
151
+ return router
152
+ end
153
+ return nil
154
+ end
155
+
156
+ # Export debug configuration to JavaScript
157
+ export_debug_config
158
+
159
+ # Initialize debug module in development mode
160
+ Funicular::Debug.expose_to_global if Funicular.env.development?
161
+
162
+ container_element = if container.is_a?(String)
163
+ JS.document.getElementById(container)
164
+ else
165
+ container
166
+ end
167
+
168
+ unless container_element.is_a?(JS::Element)
169
+ raise "Container element not found: #{container}"
170
+ end
171
+
172
+ # Hydrate automatically when the server embedded state, unless the caller
173
+ # explicitly opted out.
174
+ hydrate = true if hydrate == false && has_ssr_state?
175
+
176
+ # If block is given, use router mode
177
+ if block
178
+ router = Router.new(container_element)
179
+ @router = router
180
+ block.call(router)
181
+ router.start(hydrate: hydrate)
182
+ return router
183
+ end
184
+
185
+ # Otherwise, mount single component (backward compatible)
186
+ if component_class
187
+ instance = component_class.new(props)
188
+ server_root = hydrate ? first_element_child(container_element) : nil
189
+ if server_root
190
+ instance.seed_state(window_state)
191
+ instance.hydrate(server_root)
192
+ else
193
+ instance.mount(container_element)
194
+ end
195
+ return instance
196
+ end
197
+
198
+ raise "Either component_class or block must be provided"
199
+ rescue => e
200
+ puts "Exception in Funicular.start: #{e.message}"
201
+ puts e.backtrace
202
+ raise e
203
+ end
204
+
205
+ # Form builder configuration
206
+ class << self
207
+ attr_accessor :form_builder_config
208
+
209
+ def configure_forms
210
+ # Defaults are semantic class names whose CSS the gem ships and injects
211
+ # via picoruby_include_tag (see assets/funicular.css).
212
+ @form_builder_config ||= {
213
+ error_class: "funicular-error",
214
+ field_error_class: "funicular-field-error"
215
+ }
216
+ config = @form_builder_config
217
+ # @type var config: Hash[Symbol, String]
218
+ yield config if block_given?
219
+ end
220
+ end
221
+
222
+ # Initialize default form configuration
223
+ configure_forms
224
+
225
+ # Debug highlighter configuration
226
+ class << self
227
+ attr_accessor :debug_color
228
+
229
+ def configure_debug
230
+ @debug_color = 'green'
231
+ yield self if block_given?
232
+ end
233
+ end
234
+
235
+ # Initialize default debug configuration
236
+ configure_debug
237
+
238
+ # Export debug_color to JavaScript global variable
239
+ def self.export_debug_config
240
+ return if server?
241
+ if JS.global[:window]
242
+ JS.global[:window][:FUNICULAR_DEBUG_COLOR] = @debug_color # steep:ignore
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,121 @@
1
+ module Funicular
2
+ module VDOM
3
+ # HTMLSerializer is a string-emitting counterpart of VDOM::Renderer.
4
+ # While Renderer builds a live DOM tree via JS (createElement/appendChild),
5
+ # HTMLSerializer walks the same VNode tree and produces an HTML string.
6
+ #
7
+ # It is pure Ruby with no JS dependency, so it runs under both mruby
8
+ # (in the browser, if ever needed) and CRuby (on the Rails server for SSR).
9
+ #
10
+ # Event handler props (on*) are intentionally skipped: they are Procs that
11
+ # cannot be serialized. They are re-bound on the client during hydration.
12
+ class HTMLSerializer
13
+ # Elements that have no closing tag and no children.
14
+ VOID_ELEMENTS = %w[
15
+ area base br col embed hr img input link meta param source track wbr
16
+ ]
17
+
18
+ # Props that must never be emitted as HTML attributes.
19
+ SKIP_PROPS = %i[ref key children_block]
20
+
21
+ def self.serialize(vnode)
22
+ new.render(vnode)
23
+ end
24
+
25
+ def render(vnode)
26
+ case vnode&.type
27
+ when :element
28
+ # @type var vnode: Funicular::VDOM::Element
29
+ render_element(vnode)
30
+ when :text
31
+ # @type var vnode: Funicular::VDOM::Text
32
+ render_text(vnode)
33
+ when :component
34
+ # @type var vnode: Funicular::VDOM::Component
35
+ render_component(vnode)
36
+ when nil
37
+ ""
38
+ else
39
+ raise "Unknown vnode type: #{vnode&.type}"
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def render_element(element)
46
+ tag = element.tag
47
+ attrs = serialize_props(element.props)
48
+
49
+ if VOID_ELEMENTS.include?(tag)
50
+ "<#{tag}#{attrs}>"
51
+ else
52
+ "<#{tag}#{attrs}>#{render_children(element.children)}</#{tag}>"
53
+ end
54
+ end
55
+
56
+ def render_children(children)
57
+ parts = [] #: Array[String]
58
+ children.each do |child|
59
+ if child.is_a?(VNode)
60
+ parts << render(child)
61
+ elsif child.is_a?(String)
62
+ parts << escape_html(child)
63
+ elsif child.is_a?(Array)
64
+ parts << render_children(child)
65
+ elsif !child.nil?
66
+ parts << escape_html(child.to_s)
67
+ end
68
+ end
69
+ parts.join
70
+ end
71
+
72
+ def render_text(text)
73
+ escape_html(text.content)
74
+ end
75
+
76
+ def render_component(component_vnode)
77
+ instance = component_vnode.component_class.new(component_vnode.props)
78
+ component_vnode.instance = instance
79
+ render(instance.build_vdom)
80
+ end
81
+
82
+ def serialize_props(props)
83
+ parts = [] #: Array[String]
84
+ props.each do |key, value|
85
+ key_str = key.to_s
86
+ next if SKIP_PROPS.include?(key)
87
+ # Event handlers are bound on the client, never serialized.
88
+ next if key_str.start_with?("on")
89
+
90
+ if URL_ATTRIBUTES.include?(key_str) &&
91
+ value.to_s.strip.downcase.start_with?("javascript:")
92
+ # Prevent XSS via javascript: URIs (mirrors Renderer#render_element).
93
+ next
94
+ end
95
+
96
+ if BOOLEAN_ATTRIBUTES.include?(key_str)
97
+ # Boolean attributes are absent when false/nil, otherwise present.
98
+ next if value.nil? || value.to_s == "false"
99
+ parts << " #{key_str}=\"#{key_str}\""
100
+ else
101
+ parts << " #{key_str}=\"#{escape_attr(value.to_s)}\""
102
+ end
103
+ end
104
+ parts.join
105
+ end
106
+
107
+ # Minimal HTML escaping that works the same under mruby and CRuby.
108
+ # Avoids any dependency on CGI/ERB::Util.
109
+ def escape_html(str)
110
+ str.to_s
111
+ .gsub("&", "&amp;")
112
+ .gsub("<", "&lt;")
113
+ .gsub(">", "&gt;")
114
+ end
115
+
116
+ def escape_attr(str)
117
+ escape_html(str).gsub('"', "&quot;")
118
+ end
119
+ end
120
+ end
121
+ end
data/mrblib/http.rb ADDED
@@ -0,0 +1,183 @@
1
+ module Funicular
2
+ module HTTP
3
+ CACHE_DB_NAME = 'funicular_http_cache'.freeze
4
+ CACHE_STORE = 'responses'.freeze
5
+
6
+ @cache = nil
7
+
8
+ class Response
9
+ attr_reader :data, :status, :ok
10
+
11
+ def initialize(status, data)
12
+ @status = status
13
+ @ok = @status >= 200 && @status < 300
14
+ @data = data
15
+ end
16
+
17
+ def error?
18
+ return true unless @ok
19
+ return false unless @data.is_a?(Hash)
20
+ @data["error"] || @data["errors"]
21
+ end
22
+
23
+ def error_message
24
+ return nil unless @data.is_a?(Hash)
25
+ @data["error"] || (@data["errors"].is_a?(Array) ? @data["errors"].join(", ") : @data["errors"])
26
+ end
27
+ end
28
+
29
+ # Open (or reuse) the response cache store. Idempotent and safe to call
30
+ # multiple times. Falls back to the in-memory backing if browser
31
+ # IndexedDB is unavailable.
32
+ def self.cache_init!
33
+ cache = @cache
34
+ return cache if cache
35
+ @cache = IndexedDB::KVS.open(CACHE_DB_NAME, store: CACHE_STORE)
36
+ end
37
+
38
+ # Drop a single cached entry by URL key. No-op if the cache is not
39
+ # initialized.
40
+ def self.cache_purge(url)
41
+ cache = @cache
42
+ return nil unless cache
43
+ cache.delete(url)
44
+ nil
45
+ end
46
+
47
+ # Drop every cached entry. No-op if the cache is not initialized.
48
+ def self.cache_clear
49
+ cache = @cache
50
+ return nil unless cache
51
+ cache.clear
52
+ nil
53
+ end
54
+
55
+ # Internal: read the cache for *url*. Returns the parsed entry hash or
56
+ # nil. Lazily initializes the cache on first use so callers can pass
57
+ # `cache:` without booting the SPA shell first.
58
+ def self.cache_lookup(url)
59
+ cache_init! unless @cache
60
+ cache = @cache
61
+ return nil unless cache
62
+ cache[url]
63
+ end
64
+
65
+ # Internal: write *entry* (a Hash with status/data/cached_at) to the
66
+ # cache. Awaits one extra Promise so the next request reliably hits.
67
+ def self.cache_write(url, entry)
68
+ cache_init! unless @cache
69
+ cache = @cache
70
+ return nil unless cache
71
+ cache[url] = entry
72
+ nil
73
+ end
74
+
75
+ def self.get(url, cache: nil, &block)
76
+ request("GET", url, nil, cache: cache, &block)
77
+ end
78
+
79
+ def self.post(url, body = nil, cache: nil, &block)
80
+ warn_unsupported_cache("post") if cache
81
+ request("POST", url, body, &block)
82
+ end
83
+
84
+ def self.patch(url, body = nil, cache: nil, &block)
85
+ warn_unsupported_cache("patch") if cache
86
+ request("PATCH", url, body, &block)
87
+ end
88
+
89
+ def self.delete(url, cache: nil, &block)
90
+ warn_unsupported_cache("delete") if cache
91
+ request("DELETE", url, nil, &block)
92
+ end
93
+
94
+ def self.put(url, body = nil, cache: nil, &block)
95
+ warn_unsupported_cache("put") if cache
96
+ request("PUT", url, body, &block)
97
+ end
98
+
99
+ # Get CSRF token from meta tag
100
+ # Note: Don't cache the token - Rails may rotate it after each request
101
+ def self.csrf_token
102
+ meta = JS.document.querySelector('meta[name="csrf-token"]')
103
+ if meta
104
+ token_obj = meta.getAttribute('content')
105
+ token_obj ? token_obj.to_s : nil
106
+ else
107
+ nil
108
+ end
109
+ end
110
+
111
+ class << self
112
+ private
113
+
114
+ def warn_unsupported_cache(verb)
115
+ puts "[Funicular::HTTP] cache: option is GET-only; ignoring on #{verb.upcase}"
116
+ end
117
+
118
+ def now_seconds
119
+ # JavaScript Date.now() returns ms since epoch
120
+ ms = JS.global[:Date].now # steep:ignore
121
+ (ms.to_i / 1000)
122
+ end
123
+
124
+ def cache_hit?(entry, ttl)
125
+ return false unless entry.is_a?(Hash)
126
+ cached_at = entry["cached_at"]
127
+ return false unless cached_at.is_a?(Integer)
128
+ (now_seconds - cached_at) <= ttl
129
+ end
130
+
131
+ def serve_from_cache(entry, &block)
132
+ status = entry["status"].to_i
133
+ data = entry["data"]
134
+ block.call(Response.new(status, data)) if block
135
+ end
136
+
137
+ def request(method, url, body, cache: nil, &block)
138
+ if method == "GET" && cache.is_a?(Integer) && cache > 0
139
+ entry = cache_lookup(url)
140
+ if cache_hit?(entry, cache)
141
+ serve_from_cache(entry, &block)
142
+ return
143
+ end
144
+ end
145
+
146
+ # @type var options: Hash[Symbol, String | Hash[String, String]]
147
+ options = { method: method, credentials: "include" }
148
+
149
+ headers = {} #: Hash[String, String]
150
+
151
+ if body
152
+ headers["Content-Type"] = "application/json"
153
+ options[:body] = JSON.generate(body)
154
+ end
155
+
156
+ if method != "GET"
157
+ token = csrf_token
158
+ headers["X-CSRF-Token"] = token if token
159
+ end
160
+
161
+ options[:headers] = headers unless headers.empty?
162
+
163
+ JS.global.fetch(url, options) do |response|
164
+ status = response.status.to_i
165
+ json_text = response.to_binary
166
+ data = JSON.parse(json_text)
167
+ # @type var status: Integer
168
+ http_response = Response.new(status, data)
169
+
170
+ if method == "GET" && cache.is_a?(Integer) && cache > 0 && http_response.ok
171
+ cache_write(url, {
172
+ "status" => status,
173
+ "data" => data,
174
+ "cached_at" => now_seconds
175
+ })
176
+ end
177
+
178
+ block.call(http_response) if block
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end