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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +473 -1
  3. data/demo/local_notes.html +207 -0
  4. data/docs/architecture.md +181 -3
  5. data/docs/local_database.md +1035 -0
  6. data/lib/funicular/assets/funicular.rb +14 -0
  7. data/lib/funicular/configuration.rb +65 -0
  8. data/lib/funicular/epoch_header.rb +69 -0
  9. data/lib/funicular/epoch_stamping.rb +66 -0
  10. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  11. data/lib/funicular/railtie.rb +30 -0
  12. data/lib/funicular/schema.rb +45 -12
  13. data/lib/funicular/session_epoch.rb +110 -0
  14. data/lib/funicular/ssr/runtime.rb +57 -12
  15. data/lib/funicular/ssr.rb +25 -0
  16. data/lib/funicular/testing/node_runner.mjs +19 -0
  17. data/lib/funicular/testing.rb +47 -0
  18. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  19. data/lib/funicular/vendor/mrbc/mrbc.js +82 -124
  20. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  21. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  22. data/lib/funicular/vendor/picoruby/debug/picoruby.js +241 -126
  23. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  24. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  25. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
  28. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  29. data/lib/funicular/version.rb +4 -3
  30. data/lib/funicular.rb +1 -0
  31. data/lib/tasks/funicular.rake +33 -17
  32. data/minitest/callback_error_visibility_test.rb +48 -0
  33. data/minitest/configuration_test.rb +78 -0
  34. data/minitest/dsl_test.rb +27 -0
  35. data/minitest/epoch_header_test.rb +149 -0
  36. data/minitest/epoch_stamping_test.rb +225 -0
  37. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  38. data/minitest/navigation_guard_test.rb +65 -0
  39. data/minitest/picoruby_helper_test.rb +236 -0
  40. data/minitest/schema_test.rb +47 -0
  41. data/minitest/session_epoch_test.rb +122 -0
  42. data/minitest/ssr_database_test.rb +78 -0
  43. data/minitest/ssr_reload_test.rb +106 -0
  44. data/minitest/ssr_test.rb +41 -0
  45. data/minitest/testing_ensure_compiled_test.rb +52 -0
  46. data/minitest/validations_test.rb +35 -5
  47. data/mrbgem.rake +2 -0
  48. data/mrblib/cable.rb +1 -1
  49. data/mrblib/component.rb +113 -1
  50. data/mrblib/db.rb +3116 -0
  51. data/mrblib/debug.rb +54 -10
  52. data/mrblib/differ.rb +7 -5
  53. data/mrblib/file_upload.rb +17 -7
  54. data/mrblib/funicular.rb +137 -21
  55. data/mrblib/http.rb +84 -107
  56. data/mrblib/model.rb +1178 -23
  57. data/mrblib/patcher.rb +57 -25
  58. data/mrblib/relation.rb +342 -0
  59. data/mrblib/router.rb +45 -4
  60. data/mrblib/styles.rb +20 -0
  61. data/mrblib/vdom.rb +5 -5
  62. data/mrblib/version.rb +11 -0
  63. data/sig/component.rbs +7 -0
  64. data/sig/db.rbs +328 -0
  65. data/sig/debug.rbs +5 -0
  66. data/sig/funicular.rbs +5 -0
  67. data/sig/http.rbs +8 -21
  68. data/sig/model.rbs +101 -7
  69. data/sig/patcher.rbs +1 -0
  70. data/sig/relation.rbs +44 -0
  71. data/sig/router.rbs +1 -0
  72. data/sig/styles.rbs +1 -0
  73. metadata +21 -3
  74. 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
- begin
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
- begin
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, no move)
107
- # 3. inserts in ascending new_index using insertBefore on the live DOM
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 unless child_patches.empty?
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 unless old_child.is_a?(VNode)
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
@@ -16,8 +16,9 @@ module Funicular
16
16
  if (window.funicularFormDataUpload) {
17
17
  return;
18
18
  }
19
- // FormData upload helper
20
- window.funicularFormDataUpload = function(url, fieldsObj, fileFieldName, fileRefId) {
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
- return fetch(url, {
32
- method: 'PATCH',
32
+ const options = {
33
+ method: method || 'PATCH',
34
+ credentials: 'include',
33
35
  body: formData
34
- }).then(response => response.json());
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
- # 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.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
- # Load schemas for models
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
- 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
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
- 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
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
- schemas_loaded += 1
132
- check_completion.call
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
- # 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)
28
+ def self.get(url, &block)
29
+ request("GET", url, nil, &block)
77
30
  end
78
31
 
79
- def self.post(url, body = nil, cache: nil, &block)
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, cache: nil, &block)
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, cache: nil, &block)
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, cache: nil, &block)
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, cache: nil, &block)
149
- if method == "GET" && cache.is_a?(Integer) && cache > 0
150
- entry = cache_lookup(url)
151
- if cache_hit?(entry, cache)
152
- serve_from_cache(entry, &block)
153
- return
154
- end
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
- JS.global.fetch(url, options) do |response|
175
- status = response.status.to_i
176
- json_text = response.to_binary
177
- data = parse_response_body(json_text)
178
- # @type var status: Integer
179
- http_response = Response.new(status, data)
180
-
181
- if method == "GET" && cache.is_a?(Integer) && cache > 0 && http_response.ok
182
- cache_write(url, {
183
- "status" => status,
184
- "data" => data,
185
- "cached_at" => now_seconds
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