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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +32 -1
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- metadata +154 -8
data/mrblib/funicular.rb
ADDED
|
@@ -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("&", "&")
|
|
112
|
+
.gsub("<", "<")
|
|
113
|
+
.gsub(">", ">")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def escape_attr(str)
|
|
117
|
+
escape_html(str).gsub('"', """)
|
|
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
|