funicular 0.3.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +486 -1
  3. data/demo/local_notes.html +207 -0
  4. data/demo/test_chartjs.html +9 -9
  5. data/demo/test_component.html +8 -8
  6. data/demo/test_error_boundary.html +44 -41
  7. data/demo/test_router.html +48 -48
  8. data/demo/tic-tac-toe.html +25 -25
  9. data/docs/architecture.md +227 -12
  10. data/docs/local_database.md +1035 -0
  11. data/lib/funicular/assets/funicular.rb +14 -0
  12. data/lib/funicular/configuration.rb +65 -0
  13. data/lib/funicular/epoch_header.rb +69 -0
  14. data/lib/funicular/epoch_stamping.rb +66 -0
  15. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  16. data/lib/funicular/railtie.rb +30 -0
  17. data/lib/funicular/schema.rb +45 -12
  18. data/lib/funicular/session_epoch.rb +110 -0
  19. data/lib/funicular/ssr/runtime.rb +58 -12
  20. data/lib/funicular/ssr.rb +25 -0
  21. data/lib/funicular/testing/node_runner.mjs +19 -0
  22. data/lib/funicular/testing.rb +47 -0
  23. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  24. data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
  25. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
  28. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  30. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  31. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  32. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
  33. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  34. data/lib/funicular/version.rb +1 -1
  35. data/lib/funicular.rb +1 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  37. data/lib/tasks/funicular.rake +10 -2
  38. data/minitest/callback_error_visibility_test.rb +48 -0
  39. data/minitest/configuration_test.rb +78 -0
  40. data/minitest/dsl_test.rb +264 -0
  41. data/minitest/epoch_header_test.rb +149 -0
  42. data/minitest/epoch_stamping_test.rb +225 -0
  43. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  44. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  45. data/minitest/form_for_test.rb +2 -2
  46. data/minitest/hydration_test.rb +2 -2
  47. data/minitest/navigation_guard_test.rb +65 -0
  48. data/minitest/picoruby_helper_test.rb +236 -0
  49. data/minitest/schema_test.rb +47 -0
  50. data/minitest/session_epoch_test.rb +122 -0
  51. data/minitest/sig_tags_test.rb +30 -0
  52. data/minitest/ssr_database_test.rb +78 -0
  53. data/minitest/ssr_reload_test.rb +106 -0
  54. data/minitest/ssr_test.rb +41 -0
  55. data/minitest/testing_ensure_compiled_test.rb +52 -0
  56. data/minitest/validations_test.rb +35 -5
  57. data/minitest/view_context_test.rb +15 -15
  58. data/mrbgem.rake +2 -0
  59. data/mrblib/0_tags.rb +62 -0
  60. data/mrblib/cable.rb +1 -1
  61. data/mrblib/component.rb +226 -24
  62. data/mrblib/db.rb +3116 -0
  63. data/mrblib/error_boundary.rb +25 -19
  64. data/mrblib/file_upload.rb +17 -7
  65. data/mrblib/form_builder.rb +10 -10
  66. data/mrblib/funicular.rb +136 -17
  67. data/mrblib/http.rb +84 -107
  68. data/mrblib/model.rb +1178 -23
  69. data/mrblib/relation.rb +342 -0
  70. data/mrblib/router.rb +45 -4
  71. data/mrblib/styles.rb +122 -12
  72. data/mrblib/view_context.rb +3 -32
  73. data/sig/component.rbs +25 -4
  74. data/sig/db.rbs +328 -0
  75. data/sig/error_boundary.rbs +4 -4
  76. data/sig/funicular.rbs +5 -0
  77. data/sig/http.rbs +8 -21
  78. data/sig/model.rbs +101 -7
  79. data/sig/relation.rbs +44 -0
  80. data/sig/router.rbs +1 -0
  81. data/sig/styles.rbs +19 -5
  82. data/sig/tags.rbs +54 -0
  83. data/sig/view_context.rbs +47 -34
  84. metadata +23 -2
  85. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
@@ -3,17 +3,23 @@ module Funicular
3
3
  # a fallback UI instead of crashing the entire application.
4
4
  #
5
5
  # Usage:
6
- # h.component(ErrorBoundary) do |hh|
7
- # hh.component(RiskyComponent)
6
+ # component(ErrorBoundary) do
7
+ # component(RiskyComponent)
8
8
  # end
9
9
  #
10
10
  # With custom fallback:
11
- # h.component(ErrorBoundary, fallback: ->(h, error) { h.div { "Error: #{error.message}" } }) do |hh|
12
- # hh.component(RiskyComponent)
11
+ # component(ErrorBoundary, fallback: ->(h, error) { h.div { "Error: #{error.message}" } }) do
12
+ # component(RiskyComponent)
13
13
  # end
14
14
  #
15
15
  # Props:
16
- # - fallback: Proc or Method that receives the error and returns VDOM
16
+ # - fallback: Proc or Method that receives (view_context, error) and
17
+ # returns VDOM. NOTE: unlike everywhere else, the fallback cannot use
18
+ # bareword tags. The proc is created in the parent component's scope
19
+ # but runs during the boundary's render, where only the boundary's
20
+ # cursor is active; a bareword tag would dispatch against the parent
21
+ # and raise RenderContextError. Build elements through the view
22
+ # context argument (`h.div { ... }`) instead.
17
23
  # - on_error: Optional callback when error is caught (for logging, reporting)
18
24
  #
19
25
  class ErrorBoundary < Component
@@ -60,47 +66,47 @@ module Funicular
60
66
  end
61
67
  end
62
68
 
63
- def render(h)
69
+ def render
64
70
  if state[:has_error]
65
- render_fallback(h)
71
+ render_fallback
66
72
  else
67
- render_children(h)
73
+ render_children
68
74
  end
69
75
  end
70
76
 
71
77
  private
72
78
 
73
- def render_fallback(h)
79
+ def render_fallback
74
80
  if props[:fallback]
75
- result = props[:fallback].call(h, state[:error])
81
+ result = props[:fallback].call(__view__, state[:error])
76
82
  if result.is_a?(VDOM::VNode)
77
83
  result
78
84
  else
79
- h.div { result.to_s }
85
+ div { result.to_s }
80
86
  end
81
87
  else
82
- default_fallback(h)
88
+ default_fallback
83
89
  end
84
90
  end
85
91
 
86
- def default_fallback(h)
87
- h.div(class: 'error-boundary-fallback', style: 'padding: 20px; background: #fee; border: 1px solid #f00; border-radius: 4px;') do |hh|
88
- hh.h3(style: 'color: #c00; margin: 0 0 10px 0;') { "Something went wrong" }
92
+ def default_fallback
93
+ div(class: 'error-boundary-fallback', style: 'padding: 20px; background: #fee; border: 1px solid #f00; border-radius: 4px;') do
94
+ h3(style: 'color: #c00; margin: 0 0 10px 0;') { "Something went wrong" }
89
95
  if state[:error]
90
- hh.div(style: 'font-family: monospace; white-space: pre-wrap; font-size: 12px; color: #600;') do
96
+ div(style: 'font-family: monospace; white-space: pre-wrap; font-size: 12px; color: #600;') do
91
97
  "#{state[:error].class}: #{state[:error].message}"
92
98
  end
93
99
  end
94
100
  if Funicular.env.development? && state[:error_info]
95
- hh.div(style: 'margin-top: 10px; font-size: 11px; color: #666;') do
101
+ div(style: 'margin-top: 10px; font-size: 11px; color: #666;') do
96
102
  "Component: #{state[:error_info][:component_class]}"
97
103
  end
98
104
  end
99
105
  end
100
106
  end
101
107
 
102
- def render_children(h)
103
- h.div(class: 'error-boundary-content') do
108
+ def render_children
109
+ div(class: 'error-boundary-content') do
104
110
  children
105
111
  end
106
112
  end
@@ -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);
@@ -65,10 +65,10 @@ module Funicular
65
65
  attrs[:class] = css_class unless css_class.empty?
66
66
 
67
67
  # Render field + error message
68
- @view_context.div do |h|
69
- h.input(attrs)
68
+ @view_context.div do
69
+ @view_context.input(attrs)
70
70
  if has_error
71
- h.div(class: @error_class) { error_message }
71
+ @view_context.div(class: @error_class) { error_message }
72
72
  end
73
73
  end
74
74
  end
@@ -128,10 +128,10 @@ module Funicular
128
128
 
129
129
  attrs[:class] = css_class unless css_class.empty?
130
130
 
131
- @view_context.div do |h|
132
- h.textarea(attrs)
131
+ @view_context.div do
132
+ @view_context.textarea(attrs)
133
133
  if has_error
134
- h.div(class: @error_class) { error_message }
134
+ @view_context.div(class: @error_class) { error_message }
135
135
  end
136
136
  end
137
137
  end
@@ -194,18 +194,18 @@ module Funicular
194
194
 
195
195
  attrs[:class] = css_class unless css_class.empty?
196
196
 
197
- @view_context.div do |h|
198
- h.select(attrs) do |hh|
197
+ @view_context.div do
198
+ @view_context.select(attrs) do
199
199
  choices.each do |choice|
200
200
  option_value, option_text = choice.is_a?(Array) ? choice : [choice, choice]
201
201
  selected = value.to_s == option_value.to_s
202
- hh.option(value: option_value, selected: selected) do
202
+ @view_context.option(value: option_value, selected: selected) do
203
203
  option_text
204
204
  end
205
205
  end
206
206
  end
207
207
  if has_error
208
- h.div(class: @error_class) { error_message }
208
+ @view_context.div(class: @error_class) { error_message }
209
209
  end
210
210
  end
211
211
  end
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.3.0' unless Funicular.const_defined?(:VERSION)
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
- # Load schemas for models
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
- 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
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
- 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
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
- schemas_loaded += 1
132
- check_completion.call
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
- # 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