phlex-reactive 0.4.8 → 0.9.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 +924 -4
- data/README.md +986 -32
- data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
- data/app/javascript/phlex/reactive/compute.js +52 -7
- data/app/javascript/phlex/reactive/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/confirm.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
- data/lib/generators/phlex/reactive/component/USAGE +2 -1
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
- data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
- data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
- data/lib/phlex/reactive/component/dsl.rb +249 -0
- data/lib/phlex/reactive/component/helpers.rb +595 -0
- data/lib/phlex/reactive/component/identity.rb +92 -0
- data/lib/phlex/reactive/component/registry.rb +200 -0
- data/lib/phlex/reactive/component.rb +30 -442
- data/lib/phlex/reactive/doctor.rb +333 -0
- data/lib/phlex/reactive/engine.rb +27 -9
- data/lib/phlex/reactive/js.rb +249 -0
- data/lib/phlex/reactive/log_subscriber.rb +64 -0
- data/lib/phlex/reactive/param_schema.rb +390 -0
- data/lib/phlex/reactive/reply.rb +5 -3
- data/lib/phlex/reactive/response.rb +160 -16
- data/lib/phlex/reactive/stream.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +307 -43
- data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
- data/lib/phlex/reactive/test_helpers.rb +308 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +333 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +19 -1
data/lib/phlex/reactive.rb
CHANGED
|
@@ -29,13 +29,62 @@ module Phlex
|
|
|
29
29
|
class Error < StandardError; end
|
|
30
30
|
|
|
31
31
|
# Raised when a signed identity token fails verification (tampered, expired,
|
|
32
|
-
# or signed with a different key)
|
|
33
|
-
|
|
32
|
+
# or signed with a different key) — or when a verified token names a class
|
|
33
|
+
# that doesn't resolve to a reactive component. `diagnostic` classifies the
|
|
34
|
+
# cause (:tampered, :unknown_class, :not_reactive_class) so the endpoint can
|
|
35
|
+
# render a verbose_errors body explaining WHICH failure this was.
|
|
36
|
+
class InvalidToken < Error
|
|
37
|
+
attr_reader :diagnostic
|
|
38
|
+
|
|
39
|
+
def initialize(msg = nil, diagnostic: nil)
|
|
40
|
+
@diagnostic = diagnostic
|
|
41
|
+
super(msg)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Raised at DECLARATION time (Component.action -> ParamSchema.compile) when a
|
|
46
|
+
# param schema names a type symbol that isn't in the registry — a typo like
|
|
47
|
+
# `params: { count: :interger }`. A stdlib ArgumentError subclass (NOT a
|
|
48
|
+
# NameError): the mistake is a bad ARGUMENT to `action`, and callers rescue
|
|
49
|
+
# ArgumentError, not NameError, for a bad-input contract. Loud at class load
|
|
50
|
+
# (eager loading in production; first constant reference under Zeitwerk dev)
|
|
51
|
+
# instead of a silent `to_s` at request time.
|
|
52
|
+
#
|
|
53
|
+
# Superclass is fully qualified `::ArgumentError`: the phlex gem defines
|
|
54
|
+
# Phlex::ArgumentError, and a bare `ArgumentError` here resolves LEXICALLY to
|
|
55
|
+
# that (Phlex::Reactive -> Phlex), coupling us to a Phlex error rather than
|
|
56
|
+
# the stdlib one an app catches.
|
|
57
|
+
class UnknownParamType < ::ArgumentError; end
|
|
34
58
|
|
|
35
59
|
# Purpose string bound into every identity token's signature so a token
|
|
36
60
|
# minted for phlex-reactive can't be replayed against another verifier use.
|
|
37
61
|
IDENTITY_PURPOSE = "phlex-reactive/identity"
|
|
38
62
|
|
|
63
|
+
# The current identity-token payload version (issue #111), stamped into every
|
|
64
|
+
# signed token under the "v" key. It exists so the NEXT breaking shape change
|
|
65
|
+
# (a rename, per-token expiry, a nonce) can upgrade tokens already in flight
|
|
66
|
+
# instead of breaking every open page at deploy. A token minted before this
|
|
67
|
+
# existed carries no "v" — treated as version 0 (today's shape). Bump this and
|
|
68
|
+
# register a register_token_upgrader(old_version) when you change the shape.
|
|
69
|
+
TOKEN_VERSION = 1
|
|
70
|
+
|
|
71
|
+
# The ActiveSupport::Notifications namespace for the gem's hot-path events
|
|
72
|
+
# (issue #107): action.phlex_reactive, render.phlex_reactive,
|
|
73
|
+
# broadcast.phlex_reactive. APM tools (AppSignal, Datadog, Skylight)
|
|
74
|
+
# auto-subscribe to `*.phlex_reactive` and get component-level visibility.
|
|
75
|
+
# Payloads carry NAMES/outcome/sizes ONLY — never the token, params, or state.
|
|
76
|
+
INSTRUMENTATION_NAMESPACE = "phlex_reactive"
|
|
77
|
+
|
|
78
|
+
# What a component-aware around_action wrapper sees (issue #112). Frozen: a
|
|
79
|
+
# wrapper OBSERVES the resolved action — it must not mutate the context to
|
|
80
|
+
# widen invokability (the token verify, component resolution, default-deny,
|
|
81
|
+
# and schema coercion have already run and are not negotiable here).
|
|
82
|
+
# * component — the resolved, identity-rebuilt component instance
|
|
83
|
+
# * action_name — the declared action Symbol about to run
|
|
84
|
+
# * params — the SCHEMA-COERCED params (dropped keys already gone)
|
|
85
|
+
# * request — the ActionDispatch::Request (remote_ip, headers, ...)
|
|
86
|
+
ActionContext = Data.define(:component, :action_name, :params, :request)
|
|
87
|
+
|
|
39
88
|
class << self
|
|
40
89
|
# The message verifier used to sign/verify component identity tokens.
|
|
41
90
|
# Defaults to a purpose-scoped verifier derived from secret_key_base.
|
|
@@ -70,6 +119,162 @@ module Phlex
|
|
|
70
119
|
@action_path ||= "/reactive/actions"
|
|
71
120
|
end
|
|
72
121
|
|
|
122
|
+
# Diagnostic endpoint error bodies + dropped-param logging. When true, an
|
|
123
|
+
# endpoint failure (400/403/404) carries a plain-text explanation body
|
|
124
|
+
# (the client already console.errors it) and param coercion warn-logs
|
|
125
|
+
# every dropped key with its bracketed path and reason. Statuses never
|
|
126
|
+
# change with the flag, and the endpoint's warn log fires regardless.
|
|
127
|
+
#
|
|
128
|
+
# Defaults LAZILY to Rails.env.local? — that's development AND test — so
|
|
129
|
+
# production stays opaque unless you opt in. The `defined?` guard (not
|
|
130
|
+
# `||=`) makes an explicit `= false` stick even in dev/test.
|
|
131
|
+
attr_writer :verbose_errors
|
|
132
|
+
|
|
133
|
+
def verbose_errors
|
|
134
|
+
return @verbose_errors if defined?(@verbose_errors)
|
|
135
|
+
|
|
136
|
+
defined?(::Rails.env) && ::Rails.env.local?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Attach the opt-in LogSubscriber (issue #107) so each hot-path event is
|
|
140
|
+
# debug-logged as one compact line (`[reactive] Counter#increment ok
|
|
141
|
+
# (3.1ms)`). Default OFF — the events still fire for APMs regardless; this
|
|
142
|
+
# only controls the gem's own log lines. The engine reads it at boot and
|
|
143
|
+
# attaches exactly once. Set true in an initializer to see the lines.
|
|
144
|
+
attr_writer :log_events
|
|
145
|
+
|
|
146
|
+
def log_events
|
|
147
|
+
return @log_events if defined?(@log_events)
|
|
148
|
+
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Client debug mode (issue #108): the "devtools-lite" lens on the reactive
|
|
153
|
+
# round trip. When true, reactive_attrs (and so reactive_root) stamps
|
|
154
|
+
# data-reactive-debug="true" on the root, and the generic controller
|
|
155
|
+
# console.groups every dispatch — action, param/collected NAMES (never
|
|
156
|
+
# values), request encoding, HTTP status, the response's stream actions +
|
|
157
|
+
# targets, whether a token refresh arrived (never the token VALUE), and the
|
|
158
|
+
# round-trip ms. Default OFF (the initializer template suggests
|
|
159
|
+
# Rails.env.development?); zero cost when off (one nil-check per dispatch, no
|
|
160
|
+
# attr, no string building). The `defined?` guard (not `||=`) makes an
|
|
161
|
+
# explicit `= false` stick even where a truthy default might otherwise apply.
|
|
162
|
+
attr_writer :debug
|
|
163
|
+
|
|
164
|
+
def debug
|
|
165
|
+
return @debug if defined?(@debug)
|
|
166
|
+
|
|
167
|
+
false
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Register an app-defined param type (issue #109). The block receives the
|
|
171
|
+
# raw client value and returns the coerced value — or Phlex::Reactive::
|
|
172
|
+
# ParamSchema::DROP to reject it (the keyword default then applies, keeping
|
|
173
|
+
# the drop-don't-fabricate contract). Register in an INITIALIZER: the
|
|
174
|
+
# registry is frozen after boot (freeze_param_types!), so a runtime
|
|
175
|
+
# registration raises, and a schema referencing the type is validated at
|
|
176
|
+
# declaration.
|
|
177
|
+
#
|
|
178
|
+
# # config/initializers/phlex_reactive.rb
|
|
179
|
+
# Phlex::Reactive.param_type(:money) do |v|
|
|
180
|
+
# /\A\d+(\.\d{1,2})?\z/.match?(v.to_s) ? BigDecimal(v) : Phlex::Reactive::ParamSchema::DROP
|
|
181
|
+
# end
|
|
182
|
+
# # then: action :charge, params: { amount: :money }
|
|
183
|
+
def param_type(name, &block)
|
|
184
|
+
raise ::ArgumentError, "Phlex::Reactive.param_type requires a block" unless block
|
|
185
|
+
|
|
186
|
+
if param_types_frozen?
|
|
187
|
+
raise Error, "Phlex::Reactive.param_type(#{name.inspect}) called after boot — the param-type " \
|
|
188
|
+
"registry is frozen once the app is initialized. Register custom param types in an " \
|
|
189
|
+
"initializer (config/initializers/phlex_reactive.rb)."
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
param_types[name.to_sym] = block
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The param-type registry: Symbol => callable(value) -> coerced | DROP.
|
|
196
|
+
# Seeded lazily with the built-ins; ParamSchema.compile validates every
|
|
197
|
+
# declared type symbol against it, and #coerce dispatches through it.
|
|
198
|
+
def param_types
|
|
199
|
+
@param_types ||= Phlex::Reactive::ParamSchema.built_in_types.dup
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A declared type symbol is known iff it's a registry key. ParamSchema
|
|
203
|
+
# asks this at compile so an unknown symbol raises loudly at declaration.
|
|
204
|
+
def param_type?(name)
|
|
205
|
+
param_types.key?(name.to_sym)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Freeze the registry so no further param_type registration is accepted —
|
|
209
|
+
# the initializer-only contract. Called by the engine's after_initialize.
|
|
210
|
+
# Idempotent.
|
|
211
|
+
def freeze_param_types!
|
|
212
|
+
param_types.freeze
|
|
213
|
+
@param_types_frozen = true
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def param_types_frozen?
|
|
217
|
+
@param_types_frozen ||= false
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Drop the registry back to the built-ins and unfreeze it. For tests that
|
|
221
|
+
# register a temporary type; never called in production.
|
|
222
|
+
def reset_param_types!
|
|
223
|
+
@param_types = Phlex::Reactive::ParamSchema.built_in_types.dup
|
|
224
|
+
@param_types_frozen = false
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Emit an `<event>.phlex_reactive` ActiveSupport::Notifications event around
|
|
228
|
+
# a block, yielding the mutable payload so a rescue can finalize the outcome
|
|
229
|
+
# (issue #107). ASN.instrument is cheap when nothing is subscribed (the hot
|
|
230
|
+
# paths depend on this — proven by the render bench), so this wraps the
|
|
231
|
+
# render/broadcast/action paths unconditionally. The payload must carry
|
|
232
|
+
# NAMES/outcome/sizes ONLY — never token/params/state.
|
|
233
|
+
def instrument(event, payload = {}, &)
|
|
234
|
+
::ActiveSupport::Notifications.instrument("#{event}.#{INSTRUMENTATION_NAMESPACE}", payload, &)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Register a COMPONENT-AWARE around_action wrapper (issue #112). The block
|
|
238
|
+
# is folded into the endpoint BETWEEN with_connection_id and the action's
|
|
239
|
+
# transaction — so it sees the resolved component instance, the declared
|
|
240
|
+
# action name, and the coerced params (an ActionContext), and a rejection
|
|
241
|
+
# NEVER opens a transaction. This is the seam for audit logging,
|
|
242
|
+
# component-aware rate limiting, and assertions; the base controller
|
|
243
|
+
# (base_controller_name) remains the seam for HTTP-layer concerns (auth,
|
|
244
|
+
# CSRF, coarse per-IP rate limiting) that don't need the resolved action.
|
|
245
|
+
#
|
|
246
|
+
# Phlex::Reactive.around_action do |ctx, &action|
|
|
247
|
+
# RateLimiter.check!(ctx.request.remote_ip, ctx.action_name) # raise -> 403
|
|
248
|
+
# result = action.call
|
|
249
|
+
# AuditLog.record!(actor: Current.user, action: ctx.action_name)
|
|
250
|
+
# result # <- REQUIRED: return the continuation's value
|
|
251
|
+
# end
|
|
252
|
+
#
|
|
253
|
+
# CONTRACT — each wrapper MUST return `action.call`'s value. The endpoint
|
|
254
|
+
# type-checks the action's return for a Phlex::Reactive::Response; a wrapper
|
|
255
|
+
# that returns its logger's result instead silently downgrades every reply
|
|
256
|
+
# to the implicit self-replace. Wrappers nest in registration order with the
|
|
257
|
+
# LAST-registered outermost. Register in an initializer.
|
|
258
|
+
def around_action(&block)
|
|
259
|
+
raise ::ArgumentError, "Phlex::Reactive.around_action requires a block" unless block
|
|
260
|
+
|
|
261
|
+
around_actions << block
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The registered around_action wrappers, oldest first. The endpoint folds
|
|
265
|
+
# them so the last-registered runs outermost; an empty stack is the default
|
|
266
|
+
# hot path (the controller short-circuits with `return yield`).
|
|
267
|
+
def around_actions
|
|
268
|
+
@around_actions ||= []
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Drop all registered around_action wrappers. For test isolation (the
|
|
272
|
+
# shipped hook — specs registering a temporary wrapper reset around every
|
|
273
|
+
# example); never called in production.
|
|
274
|
+
def reset_around_actions!
|
|
275
|
+
@around_actions = []
|
|
276
|
+
end
|
|
277
|
+
|
|
73
278
|
def verifier
|
|
74
279
|
@verifier ||= default_verifier
|
|
75
280
|
end
|
|
@@ -110,6 +315,24 @@ module Phlex
|
|
|
110
315
|
|
|
111
316
|
attr_writer :flash_target
|
|
112
317
|
|
|
318
|
+
# A user-visible flash rendered on every endpoint rescue path (issue #100).
|
|
319
|
+
# Default nil = today's behavior (a bare head, or the verbose_errors
|
|
320
|
+
# plain-text diagnostic). Set a lambda ->(kind) { "message" } (kind is
|
|
321
|
+
# :tampered/:unknown_class/:not_reactive_class/:forbidden/:not_found) and
|
|
322
|
+
# the ActionsController ALSO renders a turbo-stream flash into flash_target
|
|
323
|
+
# — at the SAME status it returns today (statuses never change). Composes
|
|
324
|
+
# with verbose_errors: the turbo-stream flash wins the response body, the
|
|
325
|
+
# diagnostic still goes to the log.
|
|
326
|
+
attr_accessor :error_flash
|
|
327
|
+
|
|
328
|
+
# Component class used to render STRING flash content (issue #77).
|
|
329
|
+
# Instantiated as flash_component.new(level:, content:) and rendered
|
|
330
|
+
# through the existing render path, replacing the built-in
|
|
331
|
+
# <div class="reactive-flash reactive-flash--{level}"> wrapper. Default
|
|
332
|
+
# nil (the built-in wrapper). Phlex component content passed to
|
|
333
|
+
# reply.flash always renders verbatim and bypasses this.
|
|
334
|
+
attr_accessor :flash_component
|
|
335
|
+
|
|
113
336
|
# Render a Phlex component to HTML with a full (off-request) view context.
|
|
114
337
|
# Uses phlex-rails' #render_in against the memoized view context — a direct
|
|
115
338
|
# component.call that skips ActionController's renderer.render machinery
|
|
@@ -121,20 +344,24 @@ module Phlex
|
|
|
121
344
|
end
|
|
122
345
|
|
|
123
346
|
# A Turbo::Streams::TagBuilder bound to an off-request view context, used
|
|
124
|
-
# to build standalone streams
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
|
|
347
|
+
# to build standalone streams not tied to a specific component's id — a
|
|
348
|
+
# Response flash append, a reactive_collection row removal, a count
|
|
349
|
+
# companion update, an also_update companion. Cached PER THREAD alongside
|
|
350
|
+
# the context it's bound to (see off_request_view_context for why
|
|
351
|
+
# per-thread). Renamed from flash_builder (issue #113): the builder does
|
|
352
|
+
# far more than flashes, so the name misled. flash_builder stays a
|
|
353
|
+
# permanent alias below so the engine's to_prepare and app code keep working.
|
|
354
|
+
def stream_builder
|
|
128
355
|
off_request_view_context_cache[:builder]
|
|
129
356
|
end
|
|
130
357
|
|
|
131
358
|
# The off-request view context for the current thread, built once and
|
|
132
|
-
# reused for both the
|
|
359
|
+
# reused for both the stream builder and standalone component renders.
|
|
133
360
|
# Cached PER THREAD, not per process: an ActionView context carries mutable
|
|
134
361
|
# output_buffer/view_flow state (render_in's capture swaps it), so sharing
|
|
135
362
|
# one instance across threads can interleave content on a threaded server.
|
|
136
363
|
# Rebuilt when the renderer object changes or the generation is bumped
|
|
137
|
-
# (
|
|
364
|
+
# (reset_stream_builder! / Rails code reload), so a reloaded controller is
|
|
138
365
|
# never served stale.
|
|
139
366
|
def off_request_view_context
|
|
140
367
|
off_request_view_context_cache[:view_context]
|
|
@@ -143,11 +370,19 @@ module Phlex
|
|
|
143
370
|
# Invalidate the per-thread context + builder for ALL threads by bumping
|
|
144
371
|
# the generation; each thread rebuilds lazily on next use. Registered on
|
|
145
372
|
# Rails' reloader by the engine; also used by specs. Thread-safe (an
|
|
146
|
-
# integer bump, no shared structure to tear down).
|
|
147
|
-
|
|
373
|
+
# integer bump, no shared structure to tear down). Renamed from
|
|
374
|
+
# reset_flash_builder! (issue #113); the old name stays a permanent alias
|
|
375
|
+
# below so the engine's to_prepare hook keeps working.
|
|
376
|
+
def reset_stream_builder!
|
|
148
377
|
@off_request_view_context_generation = off_request_view_context_generation + 1
|
|
149
378
|
end
|
|
150
379
|
|
|
380
|
+
# Permanent aliases for the pre-#113 names. Kept forever (no deprecation)
|
|
381
|
+
# so the engine's to_prepare hook and any app code calling the old names
|
|
382
|
+
# keep working unchanged.
|
|
383
|
+
alias flash_builder stream_builder
|
|
384
|
+
alias reset_flash_builder! reset_stream_builder!
|
|
385
|
+
|
|
151
386
|
def off_request_view_context_generation
|
|
152
387
|
@off_request_view_context_generation ||= 0
|
|
153
388
|
end
|
|
@@ -182,14 +417,69 @@ module Phlex
|
|
|
182
417
|
base_controller_name.constantize
|
|
183
418
|
end
|
|
184
419
|
|
|
185
|
-
# Returns the verified payload hash, or nil if the token
|
|
420
|
+
# Returns the verified, version-upgraded payload hash, or nil if the token
|
|
421
|
+
# is invalid (bad signature/purpose) OR carries a version this code doesn't
|
|
422
|
+
# understand. The single verify choke point (issue #111): after the
|
|
423
|
+
# signature check we run upgrade_token so an older-shape payload is migrated
|
|
424
|
+
# to the current shape before from_identity ever sees it.
|
|
186
425
|
def verify(token)
|
|
187
|
-
verifier.verified(token, purpose: IDENTITY_PURPOSE)
|
|
426
|
+
payload = verifier.verified(token, purpose: IDENTITY_PURPOSE)
|
|
427
|
+
payload && upgrade_token(payload)
|
|
188
428
|
end
|
|
189
429
|
|
|
190
|
-
# Signs a payload hash into an identity token
|
|
430
|
+
# Signs a payload hash into an identity token, stamping the current
|
|
431
|
+
# TOKEN_VERSION (issue #111). The "v" key is the ONLY thing added — a
|
|
432
|
+
# from_identity that ignores unknown keys is unaffected.
|
|
191
433
|
def sign(payload)
|
|
192
|
-
verifier.generate(payload, purpose: IDENTITY_PURPOSE)
|
|
434
|
+
verifier.generate(payload.merge("v" => TOKEN_VERSION), purpose: IDENTITY_PURPOSE)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Migrate a verified payload from whatever version it was signed at up to
|
|
438
|
+
# TOKEN_VERSION (issue #111). Runs the registered upgraders oldest → current,
|
|
439
|
+
# then stamps the payload to the current version. Contract:
|
|
440
|
+
# * no "v" → version 0 (the shape from before versioning existed). With no
|
|
441
|
+
# v0 upgrader registered this is a pure passthrough — introducing
|
|
442
|
+
# versioning invalidates NOTHING already in flight.
|
|
443
|
+
# * v == current → returned as-is (the hot path — one integer compare).
|
|
444
|
+
# * v > current → nil. A rolled-back deploy verifying a token minted by
|
|
445
|
+
# NEWER code must not guess the newer shape; returning nil fails closed
|
|
446
|
+
# through the endpoint's existing `|| raise(InvalidToken)` → 400.
|
|
447
|
+
def upgrade_token(payload)
|
|
448
|
+
version = payload.fetch("v", 0)
|
|
449
|
+
# "v" is inside the signed blob, so only our own key could produce a
|
|
450
|
+
# malformed one — but fail closed rather than 500 on the comparison
|
|
451
|
+
# (String vs Integer) or silently treat a negative "v" as legacy.
|
|
452
|
+
return nil unless version.is_a?(::Integer) && version >= 0
|
|
453
|
+
return payload if version == TOKEN_VERSION
|
|
454
|
+
return nil if version > TOKEN_VERSION
|
|
455
|
+
|
|
456
|
+
upgrade_from(payload, version)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Register an upgrader that rewrites a payload signed at `from_version` into
|
|
460
|
+
# the shape of `from_version + 1` (issue #111). Register in load order at
|
|
461
|
+
# boot when you bump TOKEN_VERSION; the block receives the payload hash and
|
|
462
|
+
# returns the migrated hash (the "v" stamp is applied by upgrade_token, so
|
|
463
|
+
# the block only reshapes the data). Example, for a future count → n rename:
|
|
464
|
+
#
|
|
465
|
+
# Phlex::Reactive.register_token_upgrader(0) do |payload|
|
|
466
|
+
# payload.merge("s" => { "n" => payload.dig("s", "count") })
|
|
467
|
+
# end
|
|
468
|
+
def register_token_upgrader(from_version, &block)
|
|
469
|
+
raise ::ArgumentError, "register_token_upgrader requires a block" unless block
|
|
470
|
+
|
|
471
|
+
token_upgraders[Integer(from_version)] = block
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# from_version => callable(payload) -> migrated payload. Sparse: an entry
|
|
475
|
+
# exists only for a version that actually changed shape.
|
|
476
|
+
def token_upgraders
|
|
477
|
+
@token_upgraders ||= {}
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Drop all registered upgraders. For tests that register a temporary one.
|
|
481
|
+
def reset_token_upgraders!
|
|
482
|
+
@token_upgraders = {}
|
|
193
483
|
end
|
|
194
484
|
|
|
195
485
|
# The acting client's SSE connection id during an action, or nil. Set by
|
|
@@ -265,6 +555,27 @@ module Phlex
|
|
|
265
555
|
::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
266
556
|
end
|
|
267
557
|
|
|
558
|
+
# Walk the upgrader chain from `version` up to TOKEN_VERSION, applying each
|
|
559
|
+
# registered upgrader in turn (issue #111). A gap in the chain (a version
|
|
560
|
+
# bumped with no shape change, so no upgrader registered) is a no-op step —
|
|
561
|
+
# the payload passes through unchanged to the next version. Only stamp the
|
|
562
|
+
# current "v" if an upgrader ACTUALLY reshaped the payload: a versionless
|
|
563
|
+
# (v0) token with no upgraders registered — today's case — is returned
|
|
564
|
+
# byte-identical, so introducing versioning invalidates nothing in flight.
|
|
565
|
+
# Runs only for a genuinely old token (the v == current hot path already
|
|
566
|
+
# returned in upgrade_token), so this is off the render path.
|
|
567
|
+
def upgrade_from(payload, version)
|
|
568
|
+
upgraded = false
|
|
569
|
+
version.upto(TOKEN_VERSION - 1) do
|
|
570
|
+
upgrader = token_upgraders[it]
|
|
571
|
+
next unless upgrader
|
|
572
|
+
|
|
573
|
+
payload = upgrader.call(payload)
|
|
574
|
+
upgraded = true
|
|
575
|
+
end
|
|
576
|
+
upgraded ? payload.merge("v" => TOKEN_VERSION) : payload
|
|
577
|
+
end
|
|
578
|
+
|
|
268
579
|
def default_verifier
|
|
269
580
|
unless defined?(::Rails) && ::Rails.application
|
|
270
581
|
raise Error, "Phlex::Reactive.verifier is unset and Rails.application is unavailable; " \
|
|
@@ -285,6 +596,9 @@ loader.tag = "phlex-reactive"
|
|
|
285
596
|
# (lib/phlex/reactive/foo.rb -> Phlex::Reactive::Foo).
|
|
286
597
|
lib = File.expand_path("..", __dir__)
|
|
287
598
|
loader.push_dir(lib)
|
|
599
|
+
# js.rb defines JS and component/dsl.rb defines Component::DSL (acronyms), not
|
|
600
|
+
# the default-inflected `Js`/`Dsl`.
|
|
601
|
+
loader.inflector.inflect("js" => "JS", "dsl" => "DSL")
|
|
288
602
|
# The gem-name shim (`require "phlex-reactive"`) is a plain require, not a
|
|
289
603
|
# managed file.
|
|
290
604
|
loader.ignore("#{lib}/phlex-reactive.rb")
|
|
@@ -296,6 +610,11 @@ loader.ignore("#{lib}/phlex/reactive/version.rb")
|
|
|
296
610
|
# (generators/phlex/reactive/... defining Phlex::Reactive::Generators::...)
|
|
297
611
|
# deliberately doesn't follow Zeitwerk's rules, so the loader must ignore them.
|
|
298
612
|
loader.ignore("#{lib}/generators")
|
|
613
|
+
# The RSpec matchers file (issue #110) defines RSpec::Matchers, not a
|
|
614
|
+
# Phlex::Reactive::TestHelpers::Matchers constant, so it doesn't follow
|
|
615
|
+
# Zeitwerk's naming — test_helpers.rb requires it explicitly (only when RSpec is
|
|
616
|
+
# present), and the loader must ignore it.
|
|
617
|
+
loader.ignore("#{lib}/phlex/reactive/test_helpers/matchers.rb")
|
|
299
618
|
# The engine is required explicitly below (only when Rails is present) and must
|
|
300
619
|
# not be eager-loaded before that.
|
|
301
620
|
loader.do_not_eager_load("#{__dir__}/reactive/engine.rb")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rails engines auto-load rake tasks under lib/tasks — no engine.rb change is
|
|
4
|
+
# needed for `bin/rails phlex_reactive:doctor` to appear in the host app.
|
|
5
|
+
namespace :phlex_reactive do
|
|
6
|
+
desc "Validate the phlex-reactive install (route, Stimulus, verifier, components) — issue #106"
|
|
7
|
+
task doctor: :environment do
|
|
8
|
+
# Doctor.run eager-loads so the component registry is populated, prints the
|
|
9
|
+
# ✓/✗/? report (with a fix per failure), and returns false if any check
|
|
10
|
+
# FAILED (advisory `?` lines don't count) — abort so CI / a setup script can
|
|
11
|
+
# gate on `bin/rails phlex_reactive:doctor`.
|
|
12
|
+
abort unless Phlex::Reactive::Doctor.run
|
|
13
|
+
end
|
|
14
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phlex-reactive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -118,8 +118,14 @@ files:
|
|
|
118
118
|
- README.md
|
|
119
119
|
- app/controllers/phlex/reactive/actions_controller.rb
|
|
120
120
|
- app/javascript/phlex/reactive/compute.js
|
|
121
|
+
- app/javascript/phlex/reactive/compute.min.js
|
|
122
|
+
- app/javascript/phlex/reactive/compute.min.js.map
|
|
121
123
|
- app/javascript/phlex/reactive/confirm.js
|
|
124
|
+
- app/javascript/phlex/reactive/confirm.min.js
|
|
125
|
+
- app/javascript/phlex/reactive/confirm.min.js.map
|
|
122
126
|
- app/javascript/phlex/reactive/reactive_controller.js
|
|
127
|
+
- app/javascript/phlex/reactive/reactive_controller.min.js
|
|
128
|
+
- app/javascript/phlex/reactive/reactive_controller.min.js.map
|
|
123
129
|
- lib/generators/phlex/reactive/component/USAGE
|
|
124
130
|
- lib/generators/phlex/reactive/component/component_generator.rb
|
|
125
131
|
- lib/generators/phlex/reactive/component/templates/component.rb.erb
|
|
@@ -130,11 +136,23 @@ files:
|
|
|
130
136
|
- lib/phlex-reactive.rb
|
|
131
137
|
- lib/phlex/reactive.rb
|
|
132
138
|
- lib/phlex/reactive/component.rb
|
|
139
|
+
- lib/phlex/reactive/component/dsl.rb
|
|
140
|
+
- lib/phlex/reactive/component/helpers.rb
|
|
141
|
+
- lib/phlex/reactive/component/identity.rb
|
|
142
|
+
- lib/phlex/reactive/component/registry.rb
|
|
143
|
+
- lib/phlex/reactive/doctor.rb
|
|
133
144
|
- lib/phlex/reactive/engine.rb
|
|
145
|
+
- lib/phlex/reactive/js.rb
|
|
146
|
+
- lib/phlex/reactive/log_subscriber.rb
|
|
147
|
+
- lib/phlex/reactive/param_schema.rb
|
|
134
148
|
- lib/phlex/reactive/reply.rb
|
|
135
149
|
- lib/phlex/reactive/response.rb
|
|
150
|
+
- lib/phlex/reactive/stream.rb
|
|
136
151
|
- lib/phlex/reactive/streamable.rb
|
|
152
|
+
- lib/phlex/reactive/test_helpers.rb
|
|
153
|
+
- lib/phlex/reactive/test_helpers/matchers.rb
|
|
137
154
|
- lib/phlex/reactive/version.rb
|
|
155
|
+
- lib/tasks/phlex_reactive.rake
|
|
138
156
|
homepage: https://github.com/mhenrixon/phlex-reactive
|
|
139
157
|
licenses:
|
|
140
158
|
- MIT
|