funicular 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +473 -1
- data/demo/local_notes.html +207 -0
- data/docs/architecture.md +181 -3
- data/docs/local_database.md +1035 -0
- data/lib/funicular/assets/funicular.rb +14 -0
- data/lib/funicular/configuration.rb +65 -0
- data/lib/funicular/epoch_header.rb +69 -0
- data/lib/funicular/epoch_stamping.rb +66 -0
- data/lib/funicular/helpers/picoruby_helper.rb +96 -1
- data/lib/funicular/railtie.rb +30 -0
- data/lib/funicular/schema.rb +45 -12
- data/lib/funicular/session_epoch.rb +110 -0
- data/lib/funicular/ssr/runtime.rb +57 -12
- data/lib/funicular/ssr.rb +25 -0
- data/lib/funicular/testing/node_runner.mjs +19 -0
- data/lib/funicular/testing.rb +47 -0
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +82 -124
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +241 -126
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +4 -3
- data/lib/funicular.rb +1 -0
- data/lib/tasks/funicular.rake +33 -17
- data/minitest/callback_error_visibility_test.rb +48 -0
- data/minitest/configuration_test.rb +78 -0
- data/minitest/dsl_test.rb +27 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/navigation_guard_test.rb +65 -0
- data/minitest/picoruby_helper_test.rb +236 -0
- data/minitest/schema_test.rb +47 -0
- data/minitest/session_epoch_test.rb +122 -0
- data/minitest/ssr_database_test.rb +78 -0
- data/minitest/ssr_reload_test.rb +106 -0
- data/minitest/ssr_test.rb +41 -0
- data/minitest/testing_ensure_compiled_test.rb +52 -0
- data/minitest/validations_test.rb +35 -5
- data/mrbgem.rake +2 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +113 -1
- data/mrblib/db.rb +3116 -0
- data/mrblib/debug.rb +54 -10
- data/mrblib/differ.rb +7 -5
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/funicular.rb +137 -21
- data/mrblib/http.rb +84 -107
- data/mrblib/model.rb +1178 -23
- data/mrblib/patcher.rb +57 -25
- data/mrblib/relation.rb +342 -0
- data/mrblib/router.rb +45 -4
- data/mrblib/styles.rb +20 -0
- data/mrblib/vdom.rb +5 -5
- data/mrblib/version.rb +11 -0
- data/sig/component.rbs +7 -0
- data/sig/db.rbs +328 -0
- data/sig/debug.rbs +5 -0
- data/sig/funicular.rbs +5 -0
- data/sig/http.rbs +8 -21
- data/sig/model.rbs +101 -7
- data/sig/patcher.rbs +1 -0
- data/sig/relation.rbs +44 -0
- data/sig/router.rbs +1 -0
- data/sig/styles.rbs +1 -0
- metadata +21 -3
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
data/mrblib/debug.rb
CHANGED
|
@@ -136,11 +136,7 @@ module Funicular
|
|
|
136
136
|
state = component.instance_variable_get(:@state) || {}
|
|
137
137
|
result = {} #: Hash[String, String]
|
|
138
138
|
state.each do |key, value|
|
|
139
|
-
|
|
140
|
-
result[key.to_s] = value.inspect
|
|
141
|
-
rescue
|
|
142
|
-
result[key.to_s] = "<error inspecting value>"
|
|
143
|
-
end
|
|
139
|
+
result[key.to_s] = safe_inspect(value)
|
|
144
140
|
end
|
|
145
141
|
JSON.generate(result)
|
|
146
142
|
end
|
|
@@ -159,11 +155,7 @@ module Funicular
|
|
|
159
155
|
result[name] = "<omitted>"
|
|
160
156
|
next
|
|
161
157
|
end
|
|
162
|
-
|
|
163
|
-
result[name] = component.instance_variable_get(var).inspect
|
|
164
|
-
rescue
|
|
165
|
-
result[name] = "<error inspecting value>"
|
|
166
|
-
end
|
|
158
|
+
result[name] = safe_inspect(component.instance_variable_get(var))
|
|
167
159
|
end
|
|
168
160
|
JSON.generate(result)
|
|
169
161
|
end
|
|
@@ -176,6 +168,58 @@ module Funicular
|
|
|
176
168
|
|
|
177
169
|
private
|
|
178
170
|
|
|
171
|
+
# Bounded inspect for the DevTools inspector. A plain Object#inspect
|
|
172
|
+
# walks the whole object graph: a component's @runtime reaches the
|
|
173
|
+
# router, the mounted component and its entire VDOM tree, and that
|
|
174
|
+
# recursion overflows the wasm C stack in a -O0 build. Without a
|
|
175
|
+
# guard page the overflow silently overwrites the heap below the
|
|
176
|
+
# stack, which surfaces later as garbage registers and GC crashes.
|
|
177
|
+
# Only leaves are inspected in full; containers and objects are
|
|
178
|
+
# summarized past INSPECT_MAX_DEPTH.
|
|
179
|
+
INSPECT_MAX_DEPTH = 3
|
|
180
|
+
INSPECT_MAX_ITEMS = 25
|
|
181
|
+
|
|
182
|
+
def safe_inspect(value, depth = 0)
|
|
183
|
+
case value
|
|
184
|
+
when nil, true, false, Integer, Float, Symbol, String
|
|
185
|
+
value.inspect
|
|
186
|
+
when Array
|
|
187
|
+
return "[...#{value.size} items]" if depth >= INSPECT_MAX_DEPTH
|
|
188
|
+
items = value.first(INSPECT_MAX_ITEMS).map { |v| safe_inspect(v, depth + 1) }
|
|
189
|
+
items << "...#{value.size - INSPECT_MAX_ITEMS} more" if value.size > INSPECT_MAX_ITEMS
|
|
190
|
+
"[#{items.join(', ')}]"
|
|
191
|
+
when Hash
|
|
192
|
+
return "{...#{value.size} pairs}" if depth >= INSPECT_MAX_DEPTH
|
|
193
|
+
pairs = [] #: Array[String]
|
|
194
|
+
value.each do |k, v|
|
|
195
|
+
break if pairs.size >= INSPECT_MAX_ITEMS
|
|
196
|
+
pairs << "#{safe_inspect(k, depth + 1)} => #{safe_inspect(v, depth + 1)}"
|
|
197
|
+
end
|
|
198
|
+
pairs << "...#{value.size - INSPECT_MAX_ITEMS} more" if value.size > INSPECT_MAX_ITEMS
|
|
199
|
+
"{#{pairs.join(', ')}}"
|
|
200
|
+
else
|
|
201
|
+
safe_inspect_object(value, depth)
|
|
202
|
+
end
|
|
203
|
+
rescue => e
|
|
204
|
+
"<#{e.class}: #{e.message}>"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def safe_inspect_object(value, depth)
|
|
208
|
+
# JS::Object#inspect is a shallow C implementation; other BasicObject
|
|
209
|
+
# proxies (style accessors) raise from method_missing on any name.
|
|
210
|
+
return value.inspect if defined?(::JS::Object) && ::JS::Object === value
|
|
211
|
+
return "#<BasicObject>" unless ::Object === value
|
|
212
|
+
klass = value.class.to_s
|
|
213
|
+
return "#<#{klass}>" if depth >= INSPECT_MAX_DEPTH
|
|
214
|
+
ivars = value.instance_variables
|
|
215
|
+
return value.inspect if ivars.empty?
|
|
216
|
+
parts = ivars.first(INSPECT_MAX_ITEMS).map do |iv|
|
|
217
|
+
"#{iv}=#{safe_inspect(value.instance_variable_get(iv), depth + 1)}"
|
|
218
|
+
end
|
|
219
|
+
parts << "...#{ivars.size - INSPECT_MAX_ITEMS} more" if ivars.size > INSPECT_MAX_ITEMS
|
|
220
|
+
"#<#{klass} #{parts.join(', ')}>"
|
|
221
|
+
end
|
|
222
|
+
|
|
179
223
|
def get_state_keys(component)
|
|
180
224
|
state = component.instance_variable_get(:@state)
|
|
181
225
|
return [] unless state.is_a?(Hash)
|
data/mrblib/differ.rb
CHANGED
|
@@ -103,8 +103,8 @@ module Funicular
|
|
|
103
103
|
# The new shape is `[[:keyed_children, ops, removes]]`, applied as
|
|
104
104
|
# three phases by the patcher:
|
|
105
105
|
# 1. removes (descending old_index, against the original DOM snapshot)
|
|
106
|
-
# 2. content updates for kept children (against the snapshot
|
|
107
|
-
# 3. inserts in
|
|
106
|
+
# 2. content updates for kept children (against the snapshot)
|
|
107
|
+
# 3. kept-node moves and inserts in new-index order on the live DOM
|
|
108
108
|
def self.diff_children_with_keys(old_children, new_children)
|
|
109
109
|
# 1. Build key map from old children
|
|
110
110
|
old_key_map = {} #: Hash[untyped, [Integer, child_t]]
|
|
@@ -127,7 +127,7 @@ module Funicular
|
|
|
127
127
|
matched_old_indices[old_index] = true
|
|
128
128
|
child_patches = diff(old_child, new_child)
|
|
129
129
|
ops << [:keep, old_index, new_index, child_patches]
|
|
130
|
-
has_change = true
|
|
130
|
+
has_change = true if old_index != new_index || !child_patches.empty?
|
|
131
131
|
else
|
|
132
132
|
ops << [:insert, new_index, new_child]
|
|
133
133
|
has_change = true
|
|
@@ -156,10 +156,12 @@ module Funicular
|
|
|
156
156
|
# unkeyed ones) is gone from the new render and must be removed.
|
|
157
157
|
# Skipping unkeyed olds here left stale nodes in the DOM, e.g. a
|
|
158
158
|
# "Loading..." placeholder that never disappeared once the keyed
|
|
159
|
-
# list it was replaced by arrived.
|
|
159
|
+
# list it was replaced by arrived. Raw String children are text
|
|
160
|
+
# nodes in the DOM (normalize_children keeps them as Strings), so
|
|
161
|
+
# they must be collected here as well; only nil slots are skipped.
|
|
160
162
|
removes = [] #: Array[[Integer, child_t]]
|
|
161
163
|
old_children.each_with_index do |old_child, old_index|
|
|
162
|
-
next
|
|
164
|
+
next if old_child.nil?
|
|
163
165
|
next if matched_old_indices[old_index]
|
|
164
166
|
removes << [old_index, old_child]
|
|
165
167
|
has_change = true
|
data/mrblib/file_upload.rb
CHANGED
|
@@ -16,8 +16,9 @@ module Funicular
|
|
|
16
16
|
if (window.funicularFormDataUpload) {
|
|
17
17
|
return;
|
|
18
18
|
}
|
|
19
|
-
// FormData upload helper
|
|
20
|
-
|
|
19
|
+
// FormData upload helper. The CSRF token is read fresh per
|
|
20
|
+
// request (Rails rotates it), matching Funicular::HTTP.
|
|
21
|
+
window.funicularFormDataUpload = function(url, fieldsObj, fileFieldName, fileRefId, method) {
|
|
21
22
|
const formData = new FormData();
|
|
22
23
|
for (const [key, value] of Object.entries(fieldsObj)) {
|
|
23
24
|
formData.append(key, String(value));
|
|
@@ -28,10 +29,17 @@ module Funicular
|
|
|
28
29
|
formData.append(fileFieldName, file);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
-
method: 'PATCH',
|
|
32
|
+
const options = {
|
|
33
|
+
method: method || 'PATCH',
|
|
34
|
+
credentials: 'include',
|
|
33
35
|
body: formData
|
|
34
|
-
}
|
|
36
|
+
};
|
|
37
|
+
const meta = document.querySelector('meta[name="csrf-token"]');
|
|
38
|
+
const token = meta && meta.getAttribute('content');
|
|
39
|
+
if (token) {
|
|
40
|
+
options.headers = { 'X-CSRF-Token': token };
|
|
41
|
+
}
|
|
42
|
+
return fetch(url, options).then(response => response.json());
|
|
35
43
|
};
|
|
36
44
|
console.log('Funicular helpers mounted');
|
|
37
45
|
})();
|
|
@@ -83,8 +91,9 @@ module Funicular
|
|
|
83
91
|
# @param fields [Hash] Form fields to include
|
|
84
92
|
# @param file_field [String] Name of the file field
|
|
85
93
|
# @param file [JS::Object] File object from input element
|
|
94
|
+
# @param method [String] HTTP method; PATCH is the historical default
|
|
86
95
|
# @param block [Proc] Callback with response data
|
|
87
|
-
def self.upload_with_formdata(url, fields: {}, file_field: nil, file: nil, &block)
|
|
96
|
+
def self.upload_with_formdata(url, fields: {}, file_field: nil, file: nil, method: "PATCH", &block)
|
|
88
97
|
# Store file and callback ID
|
|
89
98
|
@callback_counters ||= [] # steep:ignore UnannotatedEmptyCollection
|
|
90
99
|
callback_id = _ = nil
|
|
@@ -116,7 +125,8 @@ module Funicular
|
|
|
116
125
|
'#{url}',
|
|
117
126
|
fieldsObj,
|
|
118
127
|
#{file_field ? "'#{file_field}'" : 'null'},
|
|
119
|
-
fileRefId
|
|
128
|
+
fileRefId,
|
|
129
|
+
#{JSON.generate(method.to_s)}
|
|
120
130
|
).then(function(data) {
|
|
121
131
|
// Store as JSON string for easy Ruby parsing
|
|
122
132
|
window._funicularUploadResult_#{callback_id} = JSON.stringify(data);
|
data/mrblib/funicular.rb
CHANGED
|
@@ -15,11 +15,8 @@ rescue LoadError
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
module Funicular
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# VERSION for the CRuby gem. In the wasm build VERSION is undefined here.
|
|
21
|
-
VERSION = '0.4.0' unless Funicular.const_defined?(:VERSION)
|
|
22
|
-
|
|
18
|
+
# VERSION is defined in mrblib/version.rb, which is compiled into the wasm
|
|
19
|
+
# build alongside this file and loaded by lib/funicular/version.rb on CRuby.
|
|
23
20
|
def self.version
|
|
24
21
|
VERSION
|
|
25
22
|
end
|
|
@@ -60,6 +57,23 @@ module Funicular
|
|
|
60
57
|
@router
|
|
61
58
|
end
|
|
62
59
|
|
|
60
|
+
# Confirmation dialog used by the router's navigation guard. The
|
|
61
|
+
# default asks through window.confirm; tests (and apps wanting a
|
|
62
|
+
# custom dialog) can replace it with a proc taking the message and
|
|
63
|
+
# returning true to leave, false to stay.
|
|
64
|
+
@confirm_handler = nil
|
|
65
|
+
|
|
66
|
+
def self.confirm_handler=(handler)
|
|
67
|
+
@confirm_handler = handler
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.confirm(message)
|
|
71
|
+
handler = @confirm_handler
|
|
72
|
+
return !!handler.call(message) if handler
|
|
73
|
+
return true if server?
|
|
74
|
+
!!JS.global.confirm(message)
|
|
75
|
+
end
|
|
76
|
+
|
|
63
77
|
# Read the SSR state embedded by the server (funicular_state_tag) as a
|
|
64
78
|
# Ruby Hash with string keys. Returns {} when absent or on the server.
|
|
65
79
|
# Goes through JSON.stringify/parse for a reliable JS->Ruby conversion.
|
|
@@ -97,11 +111,17 @@ module Funicular
|
|
|
97
111
|
child.is_a?(JS::Element) ? child : nil
|
|
98
112
|
end
|
|
99
113
|
|
|
100
|
-
#
|
|
114
|
+
# The schema boot barrier (docs decisions 6/19).
|
|
101
115
|
# Usage:
|
|
102
116
|
# Funicular.load_schemas({ User => "user", Session => "session" }) do
|
|
103
117
|
# Funicular.start(container: 'app') { |router| ... }
|
|
104
118
|
# end
|
|
119
|
+
# EVERY request settles its slot exactly once -- success, HTTP
|
|
120
|
+
# error, or a schema that fails to apply -- so the barrier always
|
|
121
|
+
# completes. All green: an opted-in local database boots before the
|
|
122
|
+
# completion block; a REST-only app runs the block directly. Any failure:
|
|
123
|
+
# the block is NEVER invoked and the errors reach the console and
|
|
124
|
+
# config.on_boot_error. Only an active DB lifecycle is marked failed.
|
|
105
125
|
def self.load_schemas(models, &block)
|
|
106
126
|
# On the server there is no fetch and no need for client-side schemas:
|
|
107
127
|
# SSR injects plain data into component state directly. Just run the
|
|
@@ -111,28 +131,116 @@ module Funicular
|
|
|
111
131
|
return
|
|
112
132
|
end
|
|
113
133
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
134
|
+
# Arm the page's epoch before the first request leaves: schema
|
|
135
|
+
# responses are epoch-checked too (docs decision 13). The response
|
|
136
|
+
# gate latches lazily on its own; the explicit call keeps the
|
|
137
|
+
# whole barrier deterministically armed at issue time.
|
|
138
|
+
local_database = Funicular::DB.local_database_enabled?
|
|
139
|
+
Funicular::DB.__latch_page_epoch if local_database
|
|
140
|
+
|
|
141
|
+
total = models.size
|
|
142
|
+
settled = 0
|
|
143
|
+
# @type var errors: Array[untyped]
|
|
144
|
+
errors = []
|
|
145
|
+
completed = false
|
|
146
|
+
|
|
147
|
+
settle = -> {
|
|
148
|
+
settled += 1
|
|
149
|
+
# Exactly once, and only with every slot settled.
|
|
150
|
+
next if completed
|
|
151
|
+
next if settled < total
|
|
152
|
+
completed = true
|
|
153
|
+
__settle_boot_barrier(errors, &block)
|
|
122
154
|
}
|
|
123
155
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
156
|
+
if total == 0
|
|
157
|
+
__settle_boot_barrier(errors, &block)
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
entries = models.to_a
|
|
162
|
+
entries_size = entries.size
|
|
163
|
+
i = 0
|
|
164
|
+
while i < entries_size
|
|
165
|
+
entry = entries[i]
|
|
166
|
+
# One request per method call: the response block must capture
|
|
167
|
+
# ITS model and name, and a while loop's shared locals would all
|
|
168
|
+
# resolve to the last pair by response time.
|
|
169
|
+
__request_schema(entry[0], entry[1], errors, settle)
|
|
170
|
+
i += 1
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def self.__request_schema(model_class, schema_name, errors, settle)
|
|
175
|
+
HTTP.get("/api/schema/#{schema_name}") do |response|
|
|
176
|
+
if response.error?
|
|
177
|
+
# Status and model always; the body's message only when the
|
|
178
|
+
# server actually sent one (an empty or HTML error body has
|
|
179
|
+
# no error_message).
|
|
180
|
+
message = "schema #{schema_name} (#{model_class.to_s}): " \
|
|
181
|
+
"HTTP #{response.status}"
|
|
182
|
+
detail = response.error_message
|
|
183
|
+
message = "#{message}: #{detail}" if detail
|
|
184
|
+
errors << Funicular::DB::Error.new(message)
|
|
185
|
+
else
|
|
186
|
+
begin
|
|
129
187
|
model_class.load_schema(response.data)
|
|
130
188
|
puts "[Schema] #{schema_name} model initialized"
|
|
131
|
-
|
|
132
|
-
|
|
189
|
+
rescue => e
|
|
190
|
+
# A schema that arrived but cannot be applied settles as a
|
|
191
|
+
# failure -- the barrier must never hang on it. Wrapped so
|
|
192
|
+
# on_boot_error can tell WHICH model broke among several.
|
|
193
|
+
errors << Funicular::DB::Error.new(
|
|
194
|
+
"schema #{schema_name} (#{model_class.to_s}): " \
|
|
195
|
+
"#{e.class}: #{e.message}")
|
|
133
196
|
end
|
|
134
197
|
end
|
|
198
|
+
settle.call
|
|
135
199
|
end
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The barrier settled: boot on all-green (the completion block runs
|
|
204
|
+
# only when the boot itself succeeded too), fail loud otherwise.
|
|
205
|
+
def self.__settle_boot_barrier(errors, &block)
|
|
206
|
+
if errors.empty?
|
|
207
|
+
if Funicular::DB.local_database_enabled?
|
|
208
|
+
block.call if Funicular::DB.boot && block
|
|
209
|
+
else
|
|
210
|
+
block.call if block
|
|
211
|
+
end
|
|
212
|
+
else
|
|
213
|
+
if Funicular::DB.local_database_enabled?
|
|
214
|
+
Funicular::DB.__fail_boot(errors)
|
|
215
|
+
else
|
|
216
|
+
Funicular::DB.__report_boot_errors(errors)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Funicular.start's client-side gate (docs decision 19): apps with
|
|
223
|
+
# opted-in replica models boot inside the schema barrier above; opted-in
|
|
224
|
+
# local-only apps (no load_schemas call) boot right here. REST-only apps
|
|
225
|
+
# bypass DB boot, except that an explicit storage :local declaration fails.
|
|
226
|
+
def self.__boot_for_start
|
|
227
|
+
unless Funicular::DB.local_database_enabled?
|
|
228
|
+
models = Funicular::Model.__registered_models
|
|
229
|
+
i = 0
|
|
230
|
+
models_size = models.size
|
|
231
|
+
while i < models_size
|
|
232
|
+
if models[i].local?
|
|
233
|
+
raise Funicular::DB::ConfigError,
|
|
234
|
+
"storage :local requires config.local_database = true"
|
|
235
|
+
end
|
|
236
|
+
i += 1
|
|
237
|
+
end
|
|
238
|
+
return true
|
|
239
|
+
end
|
|
240
|
+
state = Funicular::DB.boot_state
|
|
241
|
+
return true if state == :ready
|
|
242
|
+
return false unless state == :unbooted
|
|
243
|
+
Funicular::DB.boot
|
|
136
244
|
end
|
|
137
245
|
|
|
138
246
|
# Start Funicular application
|
|
@@ -153,6 +261,14 @@ module Funicular
|
|
|
153
261
|
return nil
|
|
154
262
|
end
|
|
155
263
|
|
|
264
|
+
# An opted-in local database comes up before anything mounts. A failed
|
|
265
|
+
# boot already reported itself, so start quietly refuses to mount on it;
|
|
266
|
+
# a REST-only application passes this gate without touching the DB.
|
|
267
|
+
unless __boot_for_start
|
|
268
|
+
puts "[Funicular] start aborted: the local database did not boot"
|
|
269
|
+
return nil
|
|
270
|
+
end
|
|
271
|
+
|
|
156
272
|
# Export debug configuration to JavaScript
|
|
157
273
|
export_debug_config
|
|
158
274
|
|
data/mrblib/http.rb
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
module Funicular
|
|
2
2
|
module HTTP
|
|
3
|
-
CACHE_DB_NAME = 'funicular_http_cache'.freeze
|
|
4
|
-
CACHE_STORE = 'responses'.freeze
|
|
5
|
-
|
|
6
|
-
@cache = nil
|
|
7
|
-
|
|
8
3
|
class Response
|
|
9
4
|
attr_reader :data, :status, :ok
|
|
10
5
|
|
|
6
|
+
# Every mainstream HTTP client calls the payload `body`; keep
|
|
7
|
+
# that name working alongside `data`.
|
|
8
|
+
alias body data
|
|
9
|
+
|
|
11
10
|
def initialize(status, data)
|
|
12
11
|
@status = status
|
|
13
12
|
@ok = @status >= 200 && @status < 300
|
|
@@ -26,73 +25,23 @@ module Funicular
|
|
|
26
25
|
end
|
|
27
26
|
end
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
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)
|
|
28
|
+
def self.get(url, &block)
|
|
29
|
+
request("GET", url, nil, &block)
|
|
77
30
|
end
|
|
78
31
|
|
|
79
|
-
def self.post(url, body = nil,
|
|
80
|
-
warn_unsupported_cache("post") if cache
|
|
32
|
+
def self.post(url, body = nil, &block)
|
|
81
33
|
request("POST", url, body, &block)
|
|
82
34
|
end
|
|
83
35
|
|
|
84
|
-
def self.patch(url, body = nil,
|
|
85
|
-
warn_unsupported_cache("patch") if cache
|
|
36
|
+
def self.patch(url, body = nil, &block)
|
|
86
37
|
request("PATCH", url, body, &block)
|
|
87
38
|
end
|
|
88
39
|
|
|
89
|
-
def self.delete(url,
|
|
90
|
-
warn_unsupported_cache("delete") if cache
|
|
40
|
+
def self.delete(url, &block)
|
|
91
41
|
request("DELETE", url, nil, &block)
|
|
92
42
|
end
|
|
93
43
|
|
|
94
|
-
def self.put(url, body = nil,
|
|
95
|
-
warn_unsupported_cache("put") if cache
|
|
44
|
+
def self.put(url, body = nil, &block)
|
|
96
45
|
request("PUT", url, body, &block)
|
|
97
46
|
end
|
|
98
47
|
|
|
@@ -111,29 +60,6 @@ module Funicular
|
|
|
111
60
|
class << self
|
|
112
61
|
private
|
|
113
62
|
|
|
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
63
|
def parse_response_body(text)
|
|
138
64
|
return nil if text.nil?
|
|
139
65
|
|
|
@@ -145,15 +71,17 @@ module Funicular
|
|
|
145
71
|
body
|
|
146
72
|
end
|
|
147
73
|
|
|
148
|
-
def request(method, url, body,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
74
|
+
def request(method, url, body, &block)
|
|
75
|
+
# A terminal page must not TALK to the server either (docs
|
|
76
|
+
# decision 13): discarding the response is not enough, because
|
|
77
|
+
# the request itself would already have executed under the NEW
|
|
78
|
+
# session's cookies -- an old screen's click could mutate
|
|
79
|
+
# another user's data. Refused BEFORE the fetch; the callback
|
|
80
|
+
# still settles exactly once.
|
|
81
|
+
if Funicular::DB.session_terminated?
|
|
82
|
+
block.call(session_changed_response) if block
|
|
83
|
+
return nil
|
|
155
84
|
end
|
|
156
|
-
|
|
157
85
|
# @type var options: Hash[Symbol, String | Hash[String, String]]
|
|
158
86
|
options = { method: method, credentials: "include" }
|
|
159
87
|
|
|
@@ -171,24 +99,73 @@ module Funicular
|
|
|
171
99
|
|
|
172
100
|
options[:headers] = headers unless headers.empty?
|
|
173
101
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
102
|
+
settled = false
|
|
103
|
+
begin
|
|
104
|
+
JS.global.fetch(url, options) do |response|
|
|
105
|
+
# The epoch decides BEFORE the body is touched. fetch
|
|
106
|
+
# resolves once the headers arrive -- which is all this
|
|
107
|
+
# check needs -- but to_binary can still fail on an
|
|
108
|
+
# interrupted body stream, and the rescue below would then
|
|
109
|
+
# settle with a network error without ever processing the
|
|
110
|
+
# mismatch: the page would stay non-terminal and free to
|
|
111
|
+
# issue another request under the NEW session.
|
|
112
|
+
if Funicular::DB.__session_epoch_ok?(response_epoch(response))
|
|
113
|
+
# @type var status: Integer
|
|
114
|
+
status = response.status.to_i
|
|
115
|
+
json_text = response.to_binary
|
|
116
|
+
data = parse_response_body(json_text)
|
|
117
|
+
http_response = Response.new(status, data)
|
|
118
|
+
else
|
|
119
|
+
# The session changed under this page (docs decision 13):
|
|
120
|
+
# the response is DISCARDED, and the caller settles with
|
|
121
|
+
# an error instead of applying stale-session data.
|
|
122
|
+
http_response = session_changed_response
|
|
123
|
+
end
|
|
124
|
+
settled = true
|
|
125
|
+
block.call(http_response) if block
|
|
126
|
+
end
|
|
127
|
+
rescue => e
|
|
128
|
+
# Exactly-once settle: a rejected fetch (network failure,
|
|
129
|
+
# invalid URL) must still deliver a response -- a hanging
|
|
130
|
+
# callback would hang the schema barrier and every REST
|
|
131
|
+
# caller. An exception out of the caller's OWN block must
|
|
132
|
+
# NOT settle a second time. It is re-raised into the JS
|
|
133
|
+
# bridge, where it can vanish silently, so name the culprit
|
|
134
|
+
# on the console first: a swallowed typo in a response
|
|
135
|
+
# handler otherwise just freezes the page in its loading
|
|
136
|
+
# state.
|
|
137
|
+
if settled
|
|
138
|
+
puts "[Funicular::HTTP] #{method} #{url} callback raised " \
|
|
139
|
+
"#{e.class}: #{e.message}"
|
|
140
|
+
raise e
|
|
141
|
+
end
|
|
142
|
+
settled = true
|
|
143
|
+
if block
|
|
144
|
+
block.call(Response.new(0,
|
|
145
|
+
{ "error" => "network error: #{e.class}: #{e.message}" }))
|
|
187
146
|
end
|
|
188
|
-
|
|
189
|
-
block.call(http_response) if block
|
|
190
147
|
end
|
|
191
148
|
end
|
|
149
|
+
|
|
150
|
+
def session_changed_response
|
|
151
|
+
Response.new(0,
|
|
152
|
+
{ "error" => "the session changed; this page is " \
|
|
153
|
+
"terminal (reload to continue)" })
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The X-Funicular-Epoch response header, nil when absent (no
|
|
157
|
+
# headers surface, no such header, or a null value through the
|
|
158
|
+
# JS bridge).
|
|
159
|
+
def response_epoch(response)
|
|
160
|
+
# @type var raw: untyped
|
|
161
|
+
raw = response
|
|
162
|
+
value = raw[:headers].get("X-Funicular-Epoch").to_s
|
|
163
|
+
return nil if value.empty?
|
|
164
|
+
return nil if value == "null" || value == "undefined"
|
|
165
|
+
value
|
|
166
|
+
rescue
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
192
169
|
end
|
|
193
170
|
end
|
|
194
171
|
end
|