phlex-reactive 0.1.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.
@@ -0,0 +1,149 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The ONE generic controller behind every reactive Phlex component. It
4
+ // replaces the per-feature Stimulus controllers you'd otherwise hand-write
5
+ // for interactive components. A component declares its actions in Ruby (via
6
+ // Phlex::Reactive::Component); this controller binds DOM events to a single
7
+ // HTTP round trip and lets Turbo morph the re-rendered component back in.
8
+ //
9
+ // Wire format (client -> server), POST <action path>, turbo-stream Accept:
10
+ // { token: "<signed identity>", act: "<action>", params: {...} }
11
+ // (`act`, not `action`: `action` is a reserved Rails routing param.)
12
+ // The token is a MessageVerifier-signed { component, gid } — NO state is sent.
13
+ // The response is a <turbo-stream> that replaces the component by its id.
14
+ //
15
+ // Server -> client live updates use the SAME element id, pushed over the
16
+ // stream transport (pgbus SSE / Action Cable) via the Streamable
17
+ // .broadcast_* methods — so a click and a background broadcast converge on
18
+ // one re-render unit.
19
+ //
20
+ // Register this controller eagerly (not lazily) so a click immediately after
21
+ // page load is never missed. The phlex-reactive engine auto-pins it with
22
+ // preload: true for importmap apps; see the README for esbuild/webpack.
23
+ export default class extends Controller {
24
+ static values = {
25
+ token: String, // signed identity token (component + record gid/state)
26
+ }
27
+
28
+ #tokenCache // freshest token, threaded synchronously across queued requests
29
+
30
+ // Serialize requests per component. Each round trip rewrites the signed
31
+ // token in the DOM (state lives in the token, not the client). If events
32
+ // fire faster than round trips complete, concurrent requests would all read
33
+ // the SAME stale token and clobber each other (last-write-wins). Chaining on
34
+ // a per-controller promise makes each dispatch wait for the previous one, so
35
+ // it always uses the freshest token.
36
+ dispatch(event) {
37
+ this.queue = (this.queue ?? Promise.resolve()).then(() => this.#perform(event))
38
+ return this.queue
39
+ }
40
+
41
+ async #perform(event) {
42
+ const { action, params } = event.params
43
+ if (!action) return
44
+
45
+ // Stop native behavior (button submit / form navigation): the reactive
46
+ // round trip replaces it.
47
+ event.preventDefault()
48
+
49
+ // Auto-collect named field values inside this component so a button-
50
+ // triggered action still receives sibling inputs (Livewire-style).
51
+ // Explicit params (data-reactive-params-param) win over collected fields.
52
+ const fieldParams = this.#collectFields()
53
+
54
+ const body = JSON.stringify({
55
+ token: this.#currentToken,
56
+ act: action,
57
+ params: { ...fieldParams, ...this.#parseParams(params) },
58
+ })
59
+
60
+ this.element.setAttribute("aria-busy", "true")
61
+
62
+ try {
63
+ const response = await fetch(this.#actionPath(), {
64
+ method: "POST",
65
+ headers: {
66
+ "Content-Type": "application/json",
67
+ Accept: "text/vnd.turbo-stream.html",
68
+ "X-CSRF-Token": this.#csrfToken(),
69
+ },
70
+ body,
71
+ credentials: "same-origin",
72
+ })
73
+
74
+ if (response.redirected) {
75
+ console.error("[phlex-reactive] action was redirected (auth/CSRF?) — no update applied")
76
+ return
77
+ }
78
+ if (!response.ok) {
79
+ console.error(`[phlex-reactive] action failed: HTTP ${response.status}`, await response.text())
80
+ return
81
+ }
82
+
83
+ const contentType = response.headers.get("Content-Type") || ""
84
+ if (!contentType.includes("turbo-stream")) {
85
+ console.error(`[phlex-reactive] expected a turbo-stream, got "${contentType}" — no update applied`)
86
+ return
87
+ }
88
+
89
+ const html = await response.text()
90
+ // Capture the new token from the response synchronously, so the next
91
+ // queued request uses it without waiting for the async DOM morph.
92
+ this.#currentToken = this.#extractToken(html) ?? this.#currentToken
93
+ // Turbo applies the <turbo-stream> ops (replace/morph by id), preserving
94
+ // focus/scroll/listeners on unchanged nodes.
95
+ window.Turbo.renderStreamMessage(html)
96
+ } catch (error) {
97
+ console.error("[phlex-reactive] action error", error)
98
+ } finally {
99
+ this.element.removeAttribute("aria-busy")
100
+ }
101
+ }
102
+
103
+ get #currentToken() {
104
+ return this.#tokenCache ?? this.tokenValue
105
+ }
106
+
107
+ set #currentToken(value) {
108
+ this.#tokenCache = value
109
+ }
110
+
111
+ #extractToken(html) {
112
+ const match = html.match(/data-reactive-token-value="([^"]+)"/)
113
+ return match?.[1]
114
+ }
115
+
116
+ #collectFields() {
117
+ const fields = {}
118
+ this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
119
+ if (field.type === "checkbox") {
120
+ fields[field.name] = field.checked
121
+ } else if (field.type === "radio") {
122
+ if (field.checked) fields[field.name] = field.value
123
+ } else {
124
+ fields[field.name] = field.value
125
+ }
126
+ })
127
+ return fields
128
+ }
129
+
130
+ #parseParams(raw) {
131
+ if (!raw) return {}
132
+ try {
133
+ return typeof raw === "string" ? JSON.parse(raw) : raw
134
+ } catch {
135
+ return {}
136
+ }
137
+ }
138
+
139
+ #actionPath() {
140
+ return (
141
+ document.querySelector('meta[name="phlex-reactive-action-path"]')?.content ||
142
+ "/reactive/actions"
143
+ )
144
+ }
145
+
146
+ #csrfToken() {
147
+ return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
148
+ }
149
+ }
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # Component turns a self-contained Phlex component into a Livewire-style
6
+ # reactive unit: declare actions in Ruby, and the generic `reactive`
7
+ # Stimulus controller wires clicks/inputs to an HTTP round trip that
8
+ # re-renders the component and morphs it back into the DOM. No per-feature
9
+ # Stimulus controllers, no hand-picked Turbo targets.
10
+ #
11
+ # Include alongside Phlex::Reactive::Streamable (which provides #id and the
12
+ # re-render machinery).
13
+ #
14
+ # === Security model (the decisive design choice) ===
15
+ # We do NOT ship component STATE to the browser (no snapshot). The DOM
16
+ # carries a signed IDENTITY:
17
+ #
18
+ # * Record-backed (the common case): reactive_record :todo signs the
19
+ # record's GlobalID. The server re-finds it via GlobalID — the client
20
+ # can neither forge the component class nor swap the record. State =
21
+ # the database.
22
+ # * State-backed (record-less, e.g. a counter): reactive_state :count
23
+ # signs the listed instance variables. Use only when there is genuinely
24
+ # no record to re-find.
25
+ #
26
+ # Actions are DEFAULT-DENY: only methods declared with `action :name` may be
27
+ # invoked. The signature proves the token is ours, NOT that this user may
28
+ # act — your action must still authorize the record. Action params pass
29
+ # through a declared schema; nothing else reaches the method.
30
+ #
31
+ # Usage (record-backed):
32
+ # class Todos::Item < ApplicationComponent
33
+ # include Phlex::Reactive::Streamable
34
+ # include Phlex::Reactive::Component
35
+ #
36
+ # reactive_record :todo
37
+ # action :toggle
38
+ # action :rename, params: { title: :string }
39
+ #
40
+ # def initialize(todo:) = @todo = todo
41
+ # def id = dom_id(@todo)
42
+ #
43
+ # def toggle = (authorize!(@todo, :update?); @todo.toggle!(:done))
44
+ # def rename(title:) = (authorize!(@todo, :update?); @todo.update!(title:))
45
+ #
46
+ # def view_template
47
+ # li(id:, **reactive_attrs) do
48
+ # button(**on(:toggle)) { @todo.done? ? "✓" : "○" }
49
+ # span { @todo.title }
50
+ # end
51
+ # end
52
+ # end
53
+ module Component
54
+ extend ActiveSupport::Concern
55
+
56
+ # A declared, client-invokable action and its param schema.
57
+ Action = Data.define(:name, :params)
58
+
59
+ class_methods do
60
+ # Declare the ActiveRecord (GlobalID-able) record this component is
61
+ # rebuilt from. The signed token carries its GlobalID; the server
62
+ # re-finds it on each action. State lives in the DB.
63
+ def reactive_record(name)
64
+ @reactive_record_key = name.to_sym
65
+ end
66
+
67
+ def reactive_record_key
68
+ return @reactive_record_key if defined?(@reactive_record_key)
69
+
70
+ superclass.respond_to?(:reactive_record_key) ? superclass.reactive_record_key : nil
71
+ end
72
+
73
+ # Opt into signed STATE for record-less components only.
74
+ # reactive_state :count, :open
75
+ def reactive_state(*names)
76
+ reactive_state_keys.concat(names.map(&:to_sym))
77
+ end
78
+
79
+ def reactive_state_keys
80
+ @reactive_state_keys ||= (superclass.respond_to?(:reactive_state_keys) ? superclass.reactive_state_keys.dup : [])
81
+ end
82
+
83
+ # Declare a client-invokable action with an optional param schema.
84
+ # action :increment
85
+ # action :rename, params: { title: :string }
86
+ # Param types: :string (default), :integer, :float, :boolean.
87
+ def action(name, params: {})
88
+ reactive_actions[name.to_sym] = Action.new(name: name.to_sym, params: params)
89
+ end
90
+
91
+ def reactive_actions
92
+ @reactive_actions ||= (superclass.respond_to?(:reactive_actions) ? superclass.reactive_actions.dup : {})
93
+ end
94
+
95
+ def reactive_action(name)
96
+ reactive_actions[name.to_sym]
97
+ end
98
+
99
+ def reactive_action?(name)
100
+ reactive_actions.key?(name.to_sym)
101
+ end
102
+
103
+ # Rebuild a component instance from a verified identity payload. Called
104
+ # by the action endpoint after the token signature is verified.
105
+ def from_identity(payload)
106
+ if reactive_record_key
107
+ record = GlobalID::Locator.locate(payload.fetch("gid"))
108
+ raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
109
+
110
+ new(reactive_record_key => record)
111
+ else
112
+ state = payload.fetch("s", {})
113
+ kwargs = reactive_state_keys.to_h { |k| [k, state[k.to_s]] }.compact
114
+ new(**kwargs)
115
+ end
116
+ end
117
+ end
118
+
119
+ # Root-element attributes: marks the element reactive and carries the
120
+ # signed identity token. Spread onto the root:
121
+ # div(id:, **reactive_attrs) { ... }
122
+ def reactive_attrs
123
+ {
124
+ data: {
125
+ controller: "reactive",
126
+ reactive_token_value: reactive_token
127
+ }
128
+ }
129
+ end
130
+
131
+ # Attributes for an element that triggers an action.
132
+ # button(**on(:toggle)) { "○" }
133
+ # form(**on(:save, event: "submit")) { ... }
134
+ #
135
+ # Extra keyword args become explicit params merged over collected form
136
+ # fields. For click triggers we force type="button" so a bare button
137
+ # inside a <form> can't submit it and cause a full-page navigation.
138
+ def on(action_name, event: "click", **params)
139
+ attrs = {
140
+ data: {
141
+ action: "#{event}->reactive#dispatch",
142
+ reactive_action_param: action_name.to_s,
143
+ reactive_params_param: params.to_json
144
+ }
145
+ }
146
+ attrs[:type] = "button" if event == "click"
147
+ attrs
148
+ end
149
+
150
+ private
151
+
152
+ # Signed { c, gid } (record-backed) or { c, s } (state-backed).
153
+ def reactive_token
154
+ payload =
155
+ if self.class.reactive_record_key
156
+ record = instance_variable_get(:"@#{self.class.reactive_record_key}")
157
+ { "c" => self.class.name, "gid" => record.to_gid.to_s }
158
+ else
159
+ state = self.class.reactive_state_keys.to_h do |k|
160
+ [k.to_s, instance_variable_get(:"@#{k}").as_json]
161
+ end
162
+ { "c" => self.class.name, "s" => state }
163
+ end
164
+
165
+ Phlex::Reactive.sign(payload)
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Phlex
6
+ module Reactive
7
+ # Rails engine: mounts the action endpoint, makes the client runtime
8
+ # available to the asset pipeline, and auto-pins it for importmap apps so
9
+ # `phlex-reactive` works with zero manual wiring on a standard Rails+Phlex
10
+ # app. (Configurable — see config/ options on Phlex::Reactive.)
11
+ class Engine < ::Rails::Engine
12
+ isolate_namespace Phlex::Reactive
13
+
14
+ # Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create.
15
+ # Apps can change the path with Phlex::Reactive.action_path before boot.
16
+ initializer "phlex_reactive.routes" do |app|
17
+ app.routes.append do
18
+ post Phlex::Reactive.action_path, to: "phlex/reactive/actions#create", as: :phlex_reactive_action
19
+ end
20
+ end
21
+
22
+ # Make reactive_controller.js available to Propshaft/Sprockets so it can
23
+ # be included via the asset pipeline or pinned in importmap.
24
+ initializer "phlex_reactive.assets" do |app|
25
+ if app.config.respond_to?(:assets)
26
+ app.config.assets.paths << root.join("app/javascript").to_s
27
+ app.config.assets.precompile += %w[phlex/reactive/reactive_controller.js]
28
+ end
29
+ end
30
+
31
+ # Auto-pin the client controller for importmap apps so it loads without
32
+ # manual configuration. Apps that don't use importmap include it via the
33
+ # asset pipeline instead (see README).
34
+ initializer "phlex_reactive.importmap", after: "importmap" do |app|
35
+ if defined?(::Importmap::Map) && app.respond_to?(:importmap)
36
+ app.importmap.pin(
37
+ "phlex/reactive/reactive_controller",
38
+ to: "phlex/reactive/reactive_controller.js",
39
+ preload: true
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # Streamable gives a self-contained Phlex component the ability to render
6
+ # ITSELF as a Turbo Stream and to broadcast itself over a stream. Every
7
+ # streamable component must implement `#id` returning a stable DOM id —
8
+ # that id is the Turbo Stream target, so you never hand-pick targets.
9
+ #
10
+ # Class methods (use in controllers):
11
+ # render turbo_stream: Counter.replace(counter)
12
+ # render turbo_stream: [Row.append(target: "items", model: @item),
13
+ # Totals.update(@order)]
14
+ #
15
+ # Broadcast methods (use in models/jobs/actions):
16
+ # Counter.broadcast_replace_to(counter, model: counter)
17
+ # Row.broadcast_append_to(@list, target: "items", model: @item)
18
+ #
19
+ # Convention: the `id` you set on the root element in `view_template` must
20
+ # equal what `#id` returns, so replace/broadcast_replace target it.
21
+ #
22
+ # NOTE: we intentionally do NOT include Turbo::Streams::ActionHelper — it
23
+ # pulls in ActionView::Helpers::TagHelper, which overrides Phlex's internal
24
+ # `tag` method and breaks rendering. We use Turbo::Streams::TagBuilder
25
+ # directly instead.
26
+ module Streamable
27
+ extend ActiveSupport::Concern
28
+
29
+ class_methods do
30
+ # The keyword the positional model maps to in `initialize`. Convention:
31
+ # demodulized, underscored class name (Invoice -> :invoice,
32
+ # InvoiceItem -> :invoice_item). Override when it differs.
33
+ def model_param_name
34
+ name.demodulize.underscore.to_sym
35
+ end
36
+
37
+ def component_args(model, options)
38
+ { model_param_name => model }.merge(options)
39
+ end
40
+
41
+ def turbo_stream_builder
42
+ ::Turbo::Streams::TagBuilder.new(renderer)
43
+ end
44
+
45
+ # Render a component to HTML with a full Rails view context. Routing
46
+ # through the controller renderer keeps dom_id/url_for/t() working
47
+ # during a re-render or broadcast.
48
+ def render_component(component)
49
+ renderer.render(component, layout: false)
50
+ end
51
+
52
+ def replace(model = nil, **options)
53
+ component = build(model, options)
54
+ turbo_stream_builder.replace(component.id, html: render_component(component))
55
+ end
56
+
57
+ def update(model = nil, **options)
58
+ component = build(model, options)
59
+ turbo_stream_builder.update(component.id, html: render_component(component))
60
+ end
61
+
62
+ def append(target:, model: nil, **options)
63
+ component = build(model, options)
64
+ turbo_stream_builder.append(target, html: render_component(component))
65
+ end
66
+
67
+ def prepend(target:, model: nil, **options)
68
+ component = build(model, options)
69
+ turbo_stream_builder.prepend(target, html: render_component(component))
70
+ end
71
+
72
+ def remove(model = nil, **options)
73
+ component = build(model, options)
74
+ turbo_stream_builder.remove(component.id)
75
+ end
76
+
77
+ # --- Broadcasts (server -> client over the stream transport) ---
78
+ # With pgbus installed, Turbo::StreamsChannel broadcasts route over
79
+ # Postgres SSE instead of Action Cable, transactionally.
80
+ #
81
+ # Pass RAW key parts as *streamables (e.g. broadcast_append_to(@list, :items))
82
+ # or (model, :symbol). Do NOT pass a pre-built stream key string — the
83
+ # broadcaster builds the key itself, and double-keying can trip the
84
+ # transport's separator guard.
85
+
86
+ def broadcast_replace_to(*streamables, model: nil, **options)
87
+ component = build(model, options)
88
+ ::Turbo::StreamsChannel.broadcast_replace_to(
89
+ *streamables, target: component.id, html: render_component(component)
90
+ )
91
+ end
92
+
93
+ def broadcast_update_to(*streamables, model: nil, **options)
94
+ component = build(model, options)
95
+ ::Turbo::StreamsChannel.broadcast_update_to(
96
+ *streamables, target: component.id, html: render_component(component)
97
+ )
98
+ end
99
+
100
+ def broadcast_append_to(*streamables, target:, model: nil, **options)
101
+ component = build(model, options)
102
+ ::Turbo::StreamsChannel.broadcast_append_to(
103
+ *streamables, target:, html: render_component(component)
104
+ )
105
+ end
106
+
107
+ def broadcast_prepend_to(*streamables, target:, model: nil, **options)
108
+ component = build(model, options)
109
+ ::Turbo::StreamsChannel.broadcast_prepend_to(
110
+ *streamables, target:, html: render_component(component)
111
+ )
112
+ end
113
+
114
+ def broadcast_remove_to(*streamables, model: nil, **options)
115
+ component = build(model, options)
116
+ ::Turbo::StreamsChannel.broadcast_remove_to(*streamables, target: component.id)
117
+ end
118
+
119
+ private
120
+
121
+ def build(model, options)
122
+ new(**(model ? component_args(model, options) : options))
123
+ end
124
+
125
+ def renderer
126
+ Phlex::Reactive.renderer
127
+ end
128
+ end
129
+
130
+ # Required: the stable DOM id used as the Turbo Stream target. It MUST
131
+ # match the id set on the component's root element in `view_template`.
132
+ def id
133
+ raise NotImplementedError, "#{self.class} must implement #id for Turbo Stream targeting"
134
+ end
135
+
136
+ # Render THIS already-built instance as a replace stream (used by the
137
+ # reactive action endpoint after an action mutated state).
138
+ def to_stream_replace
139
+ self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self))
140
+ end
141
+
142
+ def to_stream_update
143
+ self.class.turbo_stream_builder.update(id, html: self.class.render_component(self))
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "globalid"
5
+
6
+ module Phlex
7
+ # phlex-reactive: reactive Phlex components for Rails.
8
+ #
9
+ # Two cooperating mixins, one client runtime, one endpoint:
10
+ #
11
+ # * Phlex::Reactive::Streamable — gives a component a stable `id` and class
12
+ # methods to render itself as a Turbo Stream (`.replace`, `.append`, ...)
13
+ # and to broadcast itself (`.broadcast_replace_to`, ...). The server->client
14
+ # half (controller responses + background broadcasts).
15
+ #
16
+ # * Phlex::Reactive::Component — declares client-invokable `action`s and
17
+ # emits a signed identity token + the wiring the generic `reactive`
18
+ # Stimulus controller needs. The client->server half (clicks, form input).
19
+ #
20
+ # Both halves converge on ONE re-render unit: the component, targeted by its
21
+ # `id`. See the README for the mental model and examples.
22
+ module Reactive
23
+ class Error < StandardError; end
24
+
25
+ # Raised when a signed identity token fails verification (tampered, expired,
26
+ # or signed with a different key).
27
+ class InvalidToken < Error; end
28
+
29
+ # Purpose string bound into every identity token's signature so a token
30
+ # minted for phlex-reactive can't be replayed against another verifier use.
31
+ IDENTITY_PURPOSE = "phlex-reactive/identity"
32
+
33
+ class << self
34
+ # The message verifier used to sign/verify component identity tokens.
35
+ # Defaults to a purpose-scoped verifier derived from secret_key_base.
36
+ # Override to use a dedicated key.
37
+ attr_writer :verifier
38
+
39
+ # The controller class used to render components with a full Rails view
40
+ # context (url helpers, CSRF, i18n) during re-renders and broadcasts.
41
+ # Defaults to ActionController::Base. Set to your ApplicationController if
42
+ # components rely on app-level helpers or Current attributes.
43
+ attr_writer :renderer
44
+
45
+ # The controller the reactive ActionsController inherits from, given as a
46
+ # String (resolved lazily to avoid load-order issues). Defaults to
47
+ # "ActionController::Base"; set to "ApplicationController" to inherit your
48
+ # app's auth/CSRF/Current. If you do, ensure the action path isn't
49
+ # force-redirected for logged-out users when you have public components.
50
+ attr_writer :base_controller_name
51
+
52
+ # Exception classes the action endpoint renders as 403. Append your
53
+ # authorization library's error (Pundit::NotAuthorizedError,
54
+ # ActionPolicy::Unauthorized, ...).
55
+ attr_accessor :authorization_errors
56
+
57
+ # The path the action endpoint is mounted at. Default "/reactive/actions".
58
+ # Set before boot if it collides with an app route. The client runtime
59
+ # reads it from a <meta name="phlex-reactive-action-path"> tag if present,
60
+ # falling back to this default.
61
+ attr_writer :action_path
62
+
63
+ def action_path
64
+ @action_path ||= "/reactive/actions"
65
+ end
66
+
67
+ def verifier
68
+ @verifier ||= default_verifier
69
+ end
70
+
71
+ def renderer
72
+ @renderer ||= defined?(::ActionController::Base) ? ::ActionController::Base : nil
73
+ end
74
+
75
+ def base_controller_name
76
+ @base_controller_name ||= "ActionController::Base"
77
+ end
78
+
79
+ def base_controller
80
+ base_controller_name.constantize
81
+ end
82
+
83
+ # Returns the verified payload hash, or nil if the token is invalid.
84
+ def verify(token)
85
+ verifier.verified(token, purpose: IDENTITY_PURPOSE)
86
+ end
87
+
88
+ # Signs a payload hash into an identity token.
89
+ def sign(payload)
90
+ verifier.generate(payload, purpose: IDENTITY_PURPOSE)
91
+ end
92
+
93
+ private
94
+
95
+ def default_verifier
96
+ unless defined?(::Rails) && ::Rails.application
97
+ raise Error, "Phlex::Reactive.verifier is unset and Rails.application is unavailable; " \
98
+ "set Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(secret)"
99
+ end
100
+
101
+ ::Rails.application.message_verifier(IDENTITY_PURPOSE)
102
+ end
103
+ end
104
+
105
+ self.authorization_errors = []
106
+ end
107
+ end
108
+
109
+ loader = Zeitwerk::Loader.new
110
+ loader.tag = "phlex-reactive"
111
+ loader.push_dir(File.expand_path("..", __dir__))
112
+ loader.ignore("#{__dir__}/../phlex-reactive.rb")
113
+ loader.do_not_eager_load("#{__dir__}/reactive/engine.rb")
114
+ loader.setup
115
+
116
+ require "phlex/reactive/engine" if defined?(::Rails::Engine)
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Allow `require "phlex-reactive"` (gem name) as well as `require "phlex/reactive"`.
4
+ require_relative "phlex/reactive"