funicular 0.4.0 → 0.5.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 +429 -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 +69 -115
- 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 +170 -120
- 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 +1 -1
- data/lib/funicular.rb +1 -0
- data/lib/tasks/funicular.rake +10 -2
- 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/file_upload.rb +17 -7
- data/mrblib/funicular.rb +136 -17
- data/mrblib/http.rb +84 -107
- data/mrblib/model.rb +1178 -23
- data/mrblib/relation.rb +342 -0
- data/mrblib/router.rb +45 -4
- data/mrblib/styles.rb +20 -0
- data/sig/component.rbs +7 -0
- data/sig/db.rbs +328 -0
- data/sig/funicular.rbs +5 -0
- data/sig/http.rbs +8 -21
- data/sig/model.rbs +101 -7
- data/sig/relation.rbs +44 -0
- data/sig/router.rbs +1 -0
- data/sig/styles.rbs +1 -0
- metadata +19 -2
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
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
|
@@ -18,7 +18,7 @@ module Funicular
|
|
|
18
18
|
# Guard against redefinition: when the mrblib runtime is loaded into a
|
|
19
19
|
# CRuby/Rails process for SSR, lib/funicular/version.rb has already defined
|
|
20
20
|
# VERSION for the CRuby gem. In the wasm build VERSION is undefined here.
|
|
21
|
-
VERSION = '0.
|
|
21
|
+
VERSION = '0.5.0' unless Funicular.const_defined?(:VERSION)
|
|
22
22
|
|
|
23
23
|
def self.version
|
|
24
24
|
VERSION
|
|
@@ -60,6 +60,23 @@ module Funicular
|
|
|
60
60
|
@router
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Confirmation dialog used by the router's navigation guard. The
|
|
64
|
+
# default asks through window.confirm; tests (and apps wanting a
|
|
65
|
+
# custom dialog) can replace it with a proc taking the message and
|
|
66
|
+
# returning true to leave, false to stay.
|
|
67
|
+
@confirm_handler = nil
|
|
68
|
+
|
|
69
|
+
def self.confirm_handler=(handler)
|
|
70
|
+
@confirm_handler = handler
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.confirm(message)
|
|
74
|
+
handler = @confirm_handler
|
|
75
|
+
return !!handler.call(message) if handler
|
|
76
|
+
return true if server?
|
|
77
|
+
!!JS.global.confirm(message)
|
|
78
|
+
end
|
|
79
|
+
|
|
63
80
|
# Read the SSR state embedded by the server (funicular_state_tag) as a
|
|
64
81
|
# Ruby Hash with string keys. Returns {} when absent or on the server.
|
|
65
82
|
# Goes through JSON.stringify/parse for a reliable JS->Ruby conversion.
|
|
@@ -97,11 +114,17 @@ module Funicular
|
|
|
97
114
|
child.is_a?(JS::Element) ? child : nil
|
|
98
115
|
end
|
|
99
116
|
|
|
100
|
-
#
|
|
117
|
+
# The schema boot barrier (docs decisions 6/19).
|
|
101
118
|
# Usage:
|
|
102
119
|
# Funicular.load_schemas({ User => "user", Session => "session" }) do
|
|
103
120
|
# Funicular.start(container: 'app') { |router| ... }
|
|
104
121
|
# end
|
|
122
|
+
# EVERY request settles its slot exactly once -- success, HTTP
|
|
123
|
+
# error, or a schema that fails to apply -- so the barrier always
|
|
124
|
+
# completes. All green: an opted-in local database boots before the
|
|
125
|
+
# completion block; a REST-only app runs the block directly. Any failure:
|
|
126
|
+
# the block is NEVER invoked and the errors reach the console and
|
|
127
|
+
# config.on_boot_error. Only an active DB lifecycle is marked failed.
|
|
105
128
|
def self.load_schemas(models, &block)
|
|
106
129
|
# On the server there is no fetch and no need for client-side schemas:
|
|
107
130
|
# SSR injects plain data into component state directly. Just run the
|
|
@@ -111,28 +134,116 @@ module Funicular
|
|
|
111
134
|
return
|
|
112
135
|
end
|
|
113
136
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
137
|
+
# Arm the page's epoch before the first request leaves: schema
|
|
138
|
+
# responses are epoch-checked too (docs decision 13). The response
|
|
139
|
+
# gate latches lazily on its own; the explicit call keeps the
|
|
140
|
+
# whole barrier deterministically armed at issue time.
|
|
141
|
+
local_database = Funicular::DB.local_database_enabled?
|
|
142
|
+
Funicular::DB.__latch_page_epoch if local_database
|
|
143
|
+
|
|
144
|
+
total = models.size
|
|
145
|
+
settled = 0
|
|
146
|
+
# @type var errors: Array[untyped]
|
|
147
|
+
errors = []
|
|
148
|
+
completed = false
|
|
149
|
+
|
|
150
|
+
settle = -> {
|
|
151
|
+
settled += 1
|
|
152
|
+
# Exactly once, and only with every slot settled.
|
|
153
|
+
next if completed
|
|
154
|
+
next if settled < total
|
|
155
|
+
completed = true
|
|
156
|
+
__settle_boot_barrier(errors, &block)
|
|
122
157
|
}
|
|
123
158
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
159
|
+
if total == 0
|
|
160
|
+
__settle_boot_barrier(errors, &block)
|
|
161
|
+
return
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
entries = models.to_a
|
|
165
|
+
entries_size = entries.size
|
|
166
|
+
i = 0
|
|
167
|
+
while i < entries_size
|
|
168
|
+
entry = entries[i]
|
|
169
|
+
# One request per method call: the response block must capture
|
|
170
|
+
# ITS model and name, and a while loop's shared locals would all
|
|
171
|
+
# resolve to the last pair by response time.
|
|
172
|
+
__request_schema(entry[0], entry[1], errors, settle)
|
|
173
|
+
i += 1
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.__request_schema(model_class, schema_name, errors, settle)
|
|
178
|
+
HTTP.get("/api/schema/#{schema_name}") do |response|
|
|
179
|
+
if response.error?
|
|
180
|
+
# Status and model always; the body's message only when the
|
|
181
|
+
# server actually sent one (an empty or HTML error body has
|
|
182
|
+
# no error_message).
|
|
183
|
+
message = "schema #{schema_name} (#{model_class.to_s}): " \
|
|
184
|
+
"HTTP #{response.status}"
|
|
185
|
+
detail = response.error_message
|
|
186
|
+
message = "#{message}: #{detail}" if detail
|
|
187
|
+
errors << Funicular::DB::Error.new(message)
|
|
188
|
+
else
|
|
189
|
+
begin
|
|
129
190
|
model_class.load_schema(response.data)
|
|
130
191
|
puts "[Schema] #{schema_name} model initialized"
|
|
131
|
-
|
|
132
|
-
|
|
192
|
+
rescue => e
|
|
193
|
+
# A schema that arrived but cannot be applied settles as a
|
|
194
|
+
# failure -- the barrier must never hang on it. Wrapped so
|
|
195
|
+
# on_boot_error can tell WHICH model broke among several.
|
|
196
|
+
errors << Funicular::DB::Error.new(
|
|
197
|
+
"schema #{schema_name} (#{model_class.to_s}): " \
|
|
198
|
+
"#{e.class}: #{e.message}")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
settle.call
|
|
202
|
+
end
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The barrier settled: boot on all-green (the completion block runs
|
|
207
|
+
# only when the boot itself succeeded too), fail loud otherwise.
|
|
208
|
+
def self.__settle_boot_barrier(errors, &block)
|
|
209
|
+
if errors.empty?
|
|
210
|
+
if Funicular::DB.local_database_enabled?
|
|
211
|
+
block.call if Funicular::DB.boot && block
|
|
212
|
+
else
|
|
213
|
+
block.call if block
|
|
214
|
+
end
|
|
215
|
+
else
|
|
216
|
+
if Funicular::DB.local_database_enabled?
|
|
217
|
+
Funicular::DB.__fail_boot(errors)
|
|
218
|
+
else
|
|
219
|
+
Funicular::DB.__report_boot_errors(errors)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Funicular.start's client-side gate (docs decision 19): apps with
|
|
226
|
+
# opted-in replica models boot inside the schema barrier above; opted-in
|
|
227
|
+
# local-only apps (no load_schemas call) boot right here. REST-only apps
|
|
228
|
+
# bypass DB boot, except that an explicit storage :local declaration fails.
|
|
229
|
+
def self.__boot_for_start
|
|
230
|
+
unless Funicular::DB.local_database_enabled?
|
|
231
|
+
models = Funicular::Model.__registered_models
|
|
232
|
+
i = 0
|
|
233
|
+
models_size = models.size
|
|
234
|
+
while i < models_size
|
|
235
|
+
if models[i].local?
|
|
236
|
+
raise Funicular::DB::ConfigError,
|
|
237
|
+
"storage :local requires config.local_database = true"
|
|
133
238
|
end
|
|
239
|
+
i += 1
|
|
134
240
|
end
|
|
241
|
+
return true
|
|
135
242
|
end
|
|
243
|
+
state = Funicular::DB.boot_state
|
|
244
|
+
return true if state == :ready
|
|
245
|
+
return false unless state == :unbooted
|
|
246
|
+
Funicular::DB.boot
|
|
136
247
|
end
|
|
137
248
|
|
|
138
249
|
# Start Funicular application
|
|
@@ -153,6 +264,14 @@ module Funicular
|
|
|
153
264
|
return nil
|
|
154
265
|
end
|
|
155
266
|
|
|
267
|
+
# An opted-in local database comes up before anything mounts. A failed
|
|
268
|
+
# boot already reported itself, so start quietly refuses to mount on it;
|
|
269
|
+
# a REST-only application passes this gate without touching the DB.
|
|
270
|
+
unless __boot_for_start
|
|
271
|
+
puts "[Funicular] start aborted: the local database did not boot"
|
|
272
|
+
return nil
|
|
273
|
+
end
|
|
274
|
+
|
|
156
275
|
# Export debug configuration to JavaScript
|
|
157
276
|
export_debug_config
|
|
158
277
|
|
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
|