expo_turbo-rails 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 63dbea7a9148945dfb80e069492b057b96a6dd831f80f5a99c9f9ae0eb64757a
4
+ data.tar.gz: d8e65c67fe6fcc44861cb3cd9c56c98ba4e7edb9b4c205618bd4a207d8326bbf
5
+ SHA512:
6
+ metadata.gz: de668ead0d2d6ca7167168a265d3224963683e766d611295be54a8cd1e2851426b4d34374aee8e8c74d73bdd714cb63bc1705c7792d4bc1cb093f74c4d2b523f
7
+ data.tar.gz: 3131f093848678238706514bb4ec397a40c19e0a1a655b824c67fb327f93c1e57eeec47e3d0f47b169f959fdd9eea2fd30de083d51b2d23790617429ea113f27
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NoScrubs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # expo_turbo-rails
2
+
3
+ The Rails package for Expo Turbo. It registers the distinct `application/vnd.expo-turbo+xml` MIME type and provides an opt-in controller concern for rendering host-owned XML documents, matching native Frame responses, standard Turbo Stream response fragments, and immediate or queued public Stream broadcasts. Use `ExpoTurbo::Rails::Controller` rather than including its helper modules directly. The Engine remains route-free.
4
+
5
+ The package validates rendered Expo Turbo documents structurally and rejects blank or duplicate literal IDs across the complete response, including Frame IDs. A controller must declare the components and style tokens it is allowed to render documents; when it does, the same policy also applies to its Frame, Stream, and raw controller-broadcast output. Its optional protected Cable boundary delegates all credentials and resource policy to the host.
6
+
7
+ > [!IMPORTANT]
8
+ > Version [`0.1.0`](https://rubygems.org/gems/expo_turbo-rails) is the stable
9
+ > release published on 2026-07-27. Manual VoiceOver, TalkBack, and browser
10
+ > screen-reader evidence remains an explicit follow-up and is not claimed by
11
+ > the `0.1.0` compatibility surface.
12
+
13
+ ```ruby
14
+ gem "expo_turbo-rails"
15
+ ```
16
+
17
+ ```ruby
18
+ require "expo_turbo/rails"
19
+ ```
20
+
21
+ Opt a controller into XML rendering and confine it to one host-owned view root:
22
+
23
+ ```ruby
24
+ class ExpoTurboController < ActionController::API
25
+ include ExpoTurbo::Rails::Controller
26
+
27
+ expo_turbo_view_root Rails.root.join("app/views/expo_turbo")
28
+ expo_turbo_template_capabilities(
29
+ components: {
30
+ "Gallery" => {},
31
+ "DemoCard" => {style_tokens: true},
32
+ "DemoText" => {}
33
+ },
34
+ max_style_tokens: 5,
35
+ style_tokens: {
36
+ "space:compact" => {components: ["DemoCard"], group: "space"},
37
+ "tone:info" => {components: ["DemoCard"], group: "tone"}
38
+ }
39
+ )
40
+
41
+ def show
42
+ render_expo_turbo "documents/show"
43
+ end
44
+ end
45
+ ```
46
+
47
+ The template argument is relative to the configured root; absolute paths, traversal, missing files, and symlink escapes are rejected. The resolved `.xml.erb` source is evaluated as ERB with layouts disabled, rather than served as raw file content. Before it renders, the exact output must be a strict UTF-8 XML document: one root, valid namespaces and attributes, no DTD or processing instruction, and an optional leading UTF-8 XML declaration only. Every literal `id` must also be nonblank and unique across the complete rendered document, including nested Frames. The capability declaration then admits only its exact components (and explicit aliases), exact unprefixed `turbo-frame`, `turbo-stream`, `template`, and `turbo-cable-stream-source` wrappers (including default-namespace elements), and declared `style-tokens`. Style-token lists use the same JavaScript whitespace split, count, duplicate, component, and group-conflict rules as the native adapter. A component must opt into the `style-tokens` attribute, and style-token component lists are canonicalized through aliases. The host declaration must mirror its installed registry and style adapter; it deliberately does not attempt to derive or validate arbitrary Zod props/codecs. Validation never serializes the output, so it does not alter preserved XML text.
48
+
49
+ For a native Frame GET, read the validated request header and emit an exact matching Frame from the host-owned XML template. `expo_turbo_frame_tag` accepts a nonblank UTF-8 literal ID without control characters, or a model class that it normalizes with Rails' `dom_id`, then delegates tag generation to `turbo-rails`. It deliberately does not install `Turbo::Frames::FrameRequest`, so it does not alter HTML layouts or adopt its raw-header behavior. Before returning, it parses the exact Frame output under a private synthetic root and applies the same configured component/style admission: markup must be valid UTF-8 XML without declarations, DTDs, or processing instructions, and any XML prefix must be declared by the Frame fragment itself. Validation does not serialize or alter the returned `SafeBuffer`, so inline `xml:space="preserve"` text keeps its authored bytes for the native parser.
50
+
51
+ ```ruby
52
+ def show
53
+ return head :bad_request unless expo_turbo_frame_request_id == "account-details"
54
+
55
+ render_expo_turbo "accounts/details"
56
+ end
57
+ ```
58
+
59
+ ```erb
60
+ <%= expo_turbo_frame_tag "account-details" do %>
61
+ <AccountDetails id="account-details-content">...</AccountDetails>
62
+ <% end %>
63
+ ```
64
+
65
+ For records, use the opt-in `expo_turbo_dom_id` helper to derive literal target IDs before passing them to a Frame or Stream helper. It supports only the shared `record`, `document`, `frame`, `list`, `form`, `error`, and `loading` roles, so every role stays deterministic and distinct:
66
+
67
+ ```erb
68
+ <%= expo_turbo_frame_tag expo_turbo_dom_id(@account, :frame) do %>
69
+ <AccountDetails id="<%= expo_turbo_dom_id(@account) %>">...</AccountDetails>
70
+ <% end %>
71
+ ```
72
+
73
+ For a persisted `Account` with ID `7`, those values are `account_7`, `document_account_7`, `frame_account_7`, `list_account_7`, `form_account_7`, `error_account_7`, and `loading_account_7`. Only `:list` accepts a model class, producing `list_account`; every record role requires `persisted?` plus a complete `to_key`, so unpersisted or incomplete records fail instead of producing a shared `new_*` target. Generated IDs must still be unique within each host document; the helper does not add tenant scope or accept a caller-supplied raw target segment.
74
+
75
+ `expo_turbo_frame_request?` and `expo_turbo_frame_request_id` are also available in the XML view. For an endpoint that can emit a full document or a Frame, pass `expo_turbo_cache_key` to the host's existing conditional-GET API:
76
+
77
+ ```ruby
78
+ def show
79
+ representation = expo_turbo_frame_request? ? "accounts/details-frame-v1" : "accounts/details-document-v1"
80
+ fresh_when etag: expo_turbo_cache_key(@account, representation)
81
+ return if performed?
82
+
83
+ return render_expo_turbo("accounts/details") unless expo_turbo_frame_request?
84
+
85
+ render_expo_turbo "accounts/details_frame"
86
+ end
87
+ ```
88
+
89
+ The helper preserves existing `Vary` dimensions and adds `Turbo-Frame`. Its returned key distinguishes a document from each valid Frame ID, so Rails generates separate validators for representations whose bodies differ. Because Expo Turbo renders its configured XML source as inline ERB, the host-supplied key must also include a representation version or digest that changes with every template, partial, layout, or other rendered-byte change. `expo_turbo_vary_by_frame!` and `expo_turbo_cache_variant` are available when a host needs to compose another cache API directly. The gem does not make a response public, set a TTL, or infer tenant/user variation; the host must add every other representation input.
90
+
91
+ Use the same opt-in concern to emit one or more standard Stream siblings. The builder supports `append`, `prepend`, `before`, `after`, `replace`, `update`, `remove`, `refresh`, and their `*_all` selector variants:
92
+
93
+ ```ruby
94
+ def update
95
+ render_expo_turbo_stream(
96
+ expo_turbo_stream.update(
97
+ "notice",
98
+ partial: "notices/notice",
99
+ locals: {message: "Saved"}
100
+ ),
101
+ expo_turbo_stream.remove("new_notice")
102
+ )
103
+ end
104
+ ```
105
+
106
+ `partial: "notices/notice"` resolves only `app/views/expo_turbo/notices/_notice.xml.erb`; it never searches normal host view paths or falls back to `.html.erb`. Raw positional content, keyword `content:`, and captured blocks are inserted as XML template payloads, so hosts must provide valid XML. For target and selector actions, keyword `content:` is consumed as the `<template>` payload rather than emitted as a `<turbo-stream content>` attribute; provide exactly one of positional content, keyword content, a block, or a partial. A record with `to_partial_path` is rendered through the same confined XML partial resolver and receives its conventional local; `layout:` accepts the same XML partial path only with a captured block. A positional renderable must implement `render_in`, declare `format: :xml`, and receives a limited context that exposes only `render(partial:, locals:)` plus `capture`; its partial render is likewise confined to the Expo XML root. These boundaries prevent ordinary lookup from selecting host HTML, but templates and renderables remain trusted host code rather than a Ruby sandbox. Record-compatible Stream targets use Turbo 8.0.23's `dom_id` rules on every supported `turbo-rails` version: a record becomes its `dom_id`, a bare model class becomes `new_*`, and a selector gets the corresponding `#` prefix; raw string IDs and selectors remain unchanged. `refresh` omits a blank or `false` request ID while preserving all other attributes. `remove`, `remove_all`, and `refresh` have no template and reject `content:`. Each generated tag and final response is parsed as a self-contained sibling Stream fragment before it is returned; output built through the configured controller also receives the same component/style admission. Use `head :no_content` when there is no Stream action. The response uses `text/vnd.turbo-stream.html` and keeps multiple Stream actions as normal siblings without a custom wrapper.
107
+
108
+ For a public Action Cable stream, render the source inside an Expo Turbo XML document and broadcast pre-rendered Stream markup from an explicit controller/view context:
109
+
110
+ ```erb
111
+ <%= expo_turbo_stream_from @room, id: "room-stream" %>
112
+ ```
113
+
114
+ ```ruby
115
+ broadcast_expo_turbo_stream_to @room do |stream|
116
+ stream.append("messages", partial: "messages/message", locals: {message: @message})
117
+ end
118
+
119
+ broadcast_expo_turbo_stream_later_to @room do |stream|
120
+ stream.append("messages", partial: "messages/message", locals: {message: @message})
121
+ end
122
+
123
+ broadcast_expo_turbo_refresh_to @room, request_id: Turbo.current_request_id, method: "morph", scroll: "preserve"
124
+ broadcast_expo_turbo_refresh_later_to @room, request_id: Turbo.current_request_id, method: "morph", scroll: "preserve"
125
+ ```
126
+
127
+ All three operations use the same normalized streamables and append the fixed `:expo` suffix. For example, the literal streamables `:room, "42"` map to `room:42:expo`, keeping Expo XML distinct from the browser HTML stream `room:42`. `expo_turbo_stream_from` emits the standard `Turbo::StreamsChannel` descriptor with a matching signed stream name and reserves its channel/signature attributes. `ExpoTurbo::Rails::Streams.broadcast_to(*streamables, content:)` is available when the host already owns a rendered nonblank UTF-8 Stream payload; it parses that payload as a self-contained sibling Stream fragment before sending or enqueueing it, and the queued job validates again before delivery.
128
+
129
+ `broadcast_expo_turbo_stream_to` sends immediately to the host's Action Cable pubsub. `broadcast_expo_turbo_stream_later_to` uses the host-configured Active Job adapter and enqueues `ExpoTurbo::Rails::Streams::BroadcastJob` with only the resolved stream-name string and already-rendered payload; it does not serialize a host model or render a template when the job runs. The dedicated refresh variants build and validate their tag before sending or deferring it. The later variant uses Turbo's caller-thread debouncer for an identical resolved Expo stream name plus request ID, so repeated refreshes on that thread collapse to the newest pre-rendered XML while different streams or request IDs remain independent. It does not coordinate across threads or processes. The job disables Active Job argument logging and discards an argument-deserialization failure rather than retrying it. Context-free `ExpoTurbo::Rails::Streams.broadcast_to` remains structural-only because it has no host capability declaration; render a payload through a configured controller before sending when component/style admission is required. The host owns Action Cable configuration (including its logger, adapter, and any mounted client endpoint) plus its Active Job adapter. This API does not establish a client connection, prove receipt, provide replay, issue credentials, or authorize protected resources. Do not use this public-stream API for sensitive XML.
130
+
131
+ ## Protected Cable streams
132
+
133
+ For a protected resource, configure host-owned credential, subject, authorization, and redacted callback-error hooks during application boot. Include the connection module in the host's chosen Cable connection; it resolves and caches the subject only when a protected subscription asks for it, and does not add an Action Cable connection identifier.
134
+
135
+ The callback receives the included connection. Use its public `expo_turbo_request` bridge for request headers/cookies because Action Cable keeps `request` private.
136
+
137
+ ```ruby
138
+ ExpoTurbo::Rails::Cable.configure(
139
+ credential_extractor: ->(connection) {
140
+ host_extract_short_lived_credential(connection.expo_turbo_request)
141
+ },
142
+ subject_resolver: ->(credential) { host_resolve_subject(credential) },
143
+ subscription_authorizer: ->(subject:, stream_name:, grant:) {
144
+ host_authorize_expo_stream(subject:, stream_name:, grant:)
145
+ },
146
+ subscription_error_reporter: ->(code:, error_class:) {
147
+ host_report_expo_turbo_cable_error(code:, error_class:)
148
+ }
149
+ )
150
+
151
+ class ApplicationCable::Connection < ActionCable::Connection::Base
152
+ include ExpoTurbo::Rails::Cable::Connection
153
+ end
154
+ ```
155
+
156
+ Render a protected source with a short-lived, resource-bound client-visible grant, then publish only through the matching protected API:
157
+
158
+ ```erb
159
+ <%= expo_turbo_protected_stream_from @room, grant: expo_stream_grant(@room) %>
160
+ ```
161
+
162
+ ```ruby
163
+ ExpoTurbo::Rails::Cable.broadcast_protected_to(
164
+ @room,
165
+ content: expo_turbo_stream.remove("notice").to_s
166
+ )
167
+ ```
168
+
169
+ The helper renders `ExpoTurbo::Rails::Cable::ProtectedStreamsChannel`, a token from the gem's own verifier, and `data-grant`. The Channel decodes that token to the canonical Expo stream name only for the host authorizer, but subscribes and broadcasts on the opaque token itself. The generic `Turbo::StreamsChannel` cannot verify the token, so it cannot bypass the host authorizer or receive protected broadcasts through the public `:expo` topic. Invalid descriptors, missing subjects, invalid grants, and false authorization reject only that subscription. Callback failures reject and send only a code and exception class to the configured reporter; grant, credential, token, and exception message are never passed to it.
170
+
171
+ The gem deliberately does not choose a WebSocket header, ticket encoding, identity type, grant schema, expiry, rotation, tenant policy, or native reconnect policy. The grant is delivered in XML and is therefore client-visible: make it short-lived, resource-bound, and safe to send to that client. The host must authenticate the socket, validate grant expiry and resource ownership in `subscription_authorizer`, and recreate/reconnect the native Cable client when identity or credentials change.
172
+
173
+ `subscription_authorizer` is evaluated when the subscription is admitted; it is not a reauthorization lease. When a grant expires or is revoked, the host must terminate the affected subscription or connection and require a fresh admission. If targeted disconnect is needed, the host may use its own safe Action Cable connection identity or channel mechanism; this gem intentionally adds no connection identifier.
174
+
175
+ ## Structural XML test helpers
176
+
177
+ Host tests can opt into strict structural XML assertions without relying on raw-string matching:
178
+
179
+ ```ruby
180
+ require "expo_turbo/rails/testing"
181
+
182
+ document = ExpoTurbo::Rails::Testing.parse_document(response.body)
183
+ streams = ExpoTurbo::Rails::Testing.parse_stream_fragment(response.body)
184
+ .xpath("/expo-turbo-test-root/turbo-stream")
185
+ ```
186
+
187
+ `parse_document` returns a strict `Nokogiri::XML::Document` for one XML document. `parse_stream_fragment` returns a document with a private synthetic root so one or more sibling `<turbo-stream>` elements retain their authored order. Both accept only nonblank UTF-8 input (including binary HTTP bytes that validate as UTF-8), reject recovery parsing, DTDs, entity declarations, processing instructions, malformed namespaces, and non-Stream top-level fragment content, and never make network requests.
188
+
189
+ This entrypoint is deliberately opt-in: `require "expo_turbo/rails"` does not load Nokogiri. Production Frame/Stream fragments use the same strict parser lazily at their output boundaries, but this entrypoint remains test support: it does not admit complete XML document templates or perform component, style, duplicate-ID, or other semantic protocol validation.
190
+
191
+ Run the gem against both supported server pins with:
192
+
193
+ ```sh
194
+ bundle exec appraisal ruby "$(bundle show rake)/exe/rake"
195
+ ```
196
+
197
+ ## Changelog
198
+
199
+ **2026-07-21**:
200
+
201
+ - Changed: Added an opt-in protected Cable source, Channel, verifier namespace, and immediate/queued broadcast APIs.
202
+ - Why: Public Turbo stream signatures cannot carry host resource authorization safely.
203
+ - Impact: Protected hosts must configure the four callbacks above and terminate affected subscriptions or connections after grant expiry/revocation; public stream behavior remains unchanged.
204
+
205
+ See the repository
206
+ [README](https://github.com/noscrubs-dev/expo-turbo#readme) for project status
207
+ and development commands.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExpoTurbo
4
+ module Rails
5
+ module Cable
6
+ class Configuration
7
+ attr_reader :credential_extractor, :subject_resolver, :subscription_authorizer, :subscription_error_reporter
8
+
9
+ def initialize(credential_extractor:, subject_resolver:, subscription_authorizer:, subscription_error_reporter:)
10
+ @credential_extractor = callable!(credential_extractor, "credential_extractor")
11
+ @subject_resolver = callable!(subject_resolver, "subject_resolver")
12
+ @subscription_authorizer = callable!(subscription_authorizer, "subscription_authorizer")
13
+ @subscription_error_reporter = callable!(subscription_error_reporter, "subscription_error_reporter")
14
+ freeze
15
+ end
16
+
17
+ private
18
+
19
+ def callable!(value, name)
20
+ return value if value.respond_to?(:call)
21
+
22
+ raise ConfigurationError, "Expo Turbo Cable #{name} must respond to #call"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExpoTurbo
4
+ module Rails
5
+ module Cable
6
+ module Connection
7
+ def expo_turbo_request
8
+ request
9
+ end
10
+
11
+ def expo_turbo_subject
12
+ return @expo_turbo_subject if defined?(@expo_turbo_subject_resolved)
13
+
14
+ @expo_turbo_subject = ExpoTurbo::Rails::Cable.resolve_subject(self)
15
+ @expo_turbo_subject_resolved = true
16
+ @expo_turbo_subject
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExpoTurbo
4
+ module Rails
5
+ module Cable
6
+ class ProtectedBroadcastJob < ::ActiveJob::Base
7
+ discard_on ActiveJob::DeserializationError
8
+
9
+ self.log_arguments = false
10
+
11
+ def perform(token, content:)
12
+ ExpoTurbo::Rails::Cable.broadcast_to_protected_token(token, content:)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExpoTurbo
4
+ module Rails
5
+ module Cable
6
+ class ProtectedStreamsChannel < ::ActionCable::Channel::Base
7
+ CALLBACK_FAILURE = Object.new.freeze
8
+
9
+ def subscribed
10
+ return reject unless ExpoTurbo::Rails::Cable.configured?
11
+
12
+ token = params[:signed_stream_name]
13
+ stream_name = ExpoTurbo::Rails::Cable.verified_protected_stream_name(token)
14
+ return reject unless stream_name && valid_grant?(params[:grant])
15
+
16
+ subject = resolved_subject
17
+ return if subject.equal?(CALLBACK_FAILURE)
18
+ return reject unless subject.present?
19
+
20
+ authorized = subscription_authorized?(subject, stream_name, params[:grant])
21
+ return if authorized.equal?(CALLBACK_FAILURE)
22
+ return reject unless authorized
23
+
24
+ stream_from token
25
+ end
26
+
27
+ private
28
+
29
+ def valid_grant?(grant)
30
+ grant.is_a?(String) && grant.encoding == Encoding::UTF_8 && grant.valid_encoding? && grant.present?
31
+ end
32
+
33
+ def resolved_subject
34
+ connection.expo_turbo_subject
35
+ rescue => error
36
+ callback_failure(:subject_resolution_failed, error)
37
+ end
38
+
39
+ def subscription_authorized?(subject, stream_name, grant)
40
+ ExpoTurbo::Rails::Cable.subscription_authorized?(subject:, stream_name:, grant:)
41
+ rescue => error
42
+ callback_failure(:subscription_authorization_failed, error)
43
+ end
44
+
45
+ def callback_failure(code, error)
46
+ ExpoTurbo::Rails::Cable.report_subscription_error(code:, error:)
47
+ reject
48
+ CALLBACK_FAILURE
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cable/configuration"
4
+ require_relative "cable/connection"
5
+ require_relative "cable/protected_broadcast_job"
6
+ require_relative "cable/protected_streams_channel"
7
+
8
+ module ExpoTurbo
9
+ module Rails
10
+ module Cable
11
+ PROTECTED_STREAM_VERIFIER_NAME = "expo_turbo/protected_stream"
12
+ PROTECTED_STREAM_PURPOSE = "expo_turbo.protected_stream"
13
+
14
+ class << self
15
+ def configure(credential_extractor:, subject_resolver:, subscription_authorizer:, subscription_error_reporter:)
16
+ @configuration = Configuration.new(
17
+ credential_extractor:,
18
+ subject_resolver:,
19
+ subscription_authorizer:,
20
+ subscription_error_reporter:
21
+ )
22
+ end
23
+
24
+ def configured?
25
+ defined?(@configuration)
26
+ end
27
+
28
+ def configuration
29
+ @configuration || raise(
30
+ ConfigurationError,
31
+ "configure ExpoTurbo::Rails::Cable before rendering or accepting protected Expo Turbo subscriptions"
32
+ )
33
+ end
34
+
35
+ def protected_stream_token_for(*streamables)
36
+ protected_stream_verifier.generate(
37
+ Streams.stream_name_for(*streamables),
38
+ purpose: PROTECTED_STREAM_PURPOSE
39
+ ).encode(Encoding::UTF_8)
40
+ end
41
+
42
+ def verified_protected_stream_name(token)
43
+ return unless valid_token?(token)
44
+
45
+ stream_name = protected_stream_verifier.verified(token, purpose: PROTECTED_STREAM_PURPOSE)
46
+ stream_name if Streams.valid_stream_name?(stream_name)
47
+ end
48
+
49
+ def broadcast_protected_to(*streamables, content:)
50
+ broadcast_to_protected_token(protected_stream_token_for(*streamables), content:)
51
+ end
52
+
53
+ def broadcast_protected_later_to(*streamables, content:)
54
+ ProtectedBroadcastJob.perform_later(
55
+ protected_stream_token_for(*streamables),
56
+ content: Streams.valid_content!(content)
57
+ )
58
+ end
59
+
60
+ def broadcast_to_protected_token(token, content:)
61
+ unless verified_protected_stream_name(token)
62
+ raise ArgumentError, "protected stream token must be a valid Expo Turbo protected stream token"
63
+ end
64
+
65
+ ::Turbo::StreamsChannel.broadcast_stream_to(token, content: Streams.valid_content!(content))
66
+ end
67
+
68
+ def resolve_subject(connection)
69
+ credential = configuration.credential_extractor.call(connection)
70
+ configuration.subject_resolver.call(credential)
71
+ end
72
+
73
+ def subscription_authorized?(subject:, stream_name:, grant:)
74
+ configuration.subscription_authorizer.call(subject:, stream_name:, grant:) == true
75
+ end
76
+
77
+ def report_subscription_error(code:, error:)
78
+ configuration.subscription_error_reporter.call(code:, error_class: error.class.name)
79
+ rescue
80
+ # A failing observer must not make Action Cable log a descriptor containing the client-visible grant.
81
+ end
82
+
83
+ private
84
+
85
+ def protected_stream_verifier
86
+ ::Rails.application.message_verifier(PROTECTED_STREAM_VERIFIER_NAME)
87
+ end
88
+
89
+ def valid_token?(token)
90
+ token.is_a?(String) && token.encoding == Encoding::UTF_8 && token.valid_encoding? && token.present?
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/module/attr_internal"
5
+ require "action_controller/metal/helpers"
6
+ require "action_view/rendering"
7
+ require "pathname"
8
+
9
+ module ExpoTurbo
10
+ module Rails
11
+ module Controller
12
+ extend ActiveSupport::Concern
13
+ include ActionController::Helpers
14
+ include ActionView::Rendering
15
+
16
+ included do
17
+ class_attribute :expo_turbo_views_path, instance_accessor: false
18
+ class_attribute :expo_turbo_template_capabilities_config, instance_accessor: false
19
+ helper ExpoTurbo::Rails::Frames::Helper
20
+ helper ExpoTurbo::Rails::DomIds::Helper
21
+ helper ExpoTurbo::Rails::Streams::Helper
22
+ helper_method :expo_turbo_frame_request?, :expo_turbo_frame_request_id
23
+ end
24
+
25
+ class_methods do
26
+ def expo_turbo_view_root(path)
27
+ self.expo_turbo_views_path = Pathname(path).expand_path
28
+ end
29
+
30
+ def expo_turbo_template_capabilities(components:, style_tokens: {}, max_style_tokens: 5)
31
+ self.expo_turbo_template_capabilities_config = TemplateCapabilities.new(
32
+ components:,
33
+ style_tokens:,
34
+ max_style_tokens:
35
+ )
36
+ end
37
+ end
38
+
39
+ def render_expo_turbo(template, locals: {}, status: :ok)
40
+ body = render_to_string(
41
+ inline: File.read(expo_turbo_template_file(template)),
42
+ type: :erb,
43
+ formats: [:xml],
44
+ layout: false,
45
+ locals: locals
46
+ )
47
+ raise TemplateError, "Expo Turbo templates must render valid UTF-8" unless body.encoding == Encoding::UTF_8 && body.valid_encoding?
48
+
49
+ document = XmlFragments.parse_document(body)
50
+ XmlFragments.validate_document_ids!(document)
51
+ expo_turbo_validate_document!(document)
52
+
53
+ render plain: body, content_type: MIME_TYPE, status: status
54
+ rescue XmlFragments::DocumentIdError
55
+ raise TemplateError, "Expo Turbo templates must use unique nonblank literal ids"
56
+ rescue XmlFragments::ParseError
57
+ raise TemplateError, "Expo Turbo templates must render well-formed UTF-8 XML"
58
+ end
59
+
60
+ def expo_turbo_frame_request?
61
+ expo_turbo_frame_request_id.present?
62
+ end
63
+
64
+ def expo_turbo_frame_request_id
65
+ frame_id = request.get_header("HTTP_TURBO_FRAME")
66
+ frame_id = frame_id.dup.force_encoding(Encoding::UTF_8) if frame_id.is_a?(String)
67
+ Frames.valid_id?(frame_id) ? frame_id : nil
68
+ end
69
+
70
+ def expo_turbo_cache_variant
71
+ Frames.cache_variant(expo_turbo_frame_request_id)
72
+ end
73
+
74
+ def expo_turbo_vary_by_frame!
75
+ values = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:blank?)
76
+ return response.headers["Vary"] if values.include?("*")
77
+
78
+ values << "Accept" if request.should_apply_vary_header? && values.none? { |value| value.casecmp?("Accept") }
79
+ values << "Turbo-Frame" if values.none? { |value| value.casecmp?("Turbo-Frame") }
80
+ response.set_header "Vary", values.join(", ")
81
+ end
82
+
83
+ def expo_turbo_cache_key(*keys)
84
+ expo_turbo_vary_by_frame!
85
+ [*keys, *expo_turbo_cache_variant]
86
+ end
87
+
88
+ def expo_turbo_stream
89
+ view_context.expo_turbo_stream
90
+ end
91
+
92
+ def render_expo_turbo_stream(*streams, status: :ok)
93
+ streams << yield(expo_turbo_stream) if block_given?
94
+ body = streams.flatten.compact.join
95
+ raise TemplateError, "Expo Turbo Stream responses must render valid UTF-8" unless body.encoding == Encoding::UTF_8 && body.valid_encoding?
96
+
97
+ document = XmlFragments.parse_stream_fragment(body)
98
+ expo_turbo_validate_stream_fragment!(document)
99
+
100
+ render plain: body, content_type: TURBO_STREAM_MIME_TYPE, status: status
101
+ rescue XmlFragments::ParseError
102
+ raise TemplateError, "Expo Turbo Stream responses must contain well-formed XML Stream fragments"
103
+ end
104
+
105
+ def broadcast_expo_turbo_stream_to(*streamables, content: nil)
106
+ raise ArgumentError, "provide content or a block, not both" if block_given? && !content.nil?
107
+
108
+ content = yield(expo_turbo_stream) if block_given?
109
+ expo_turbo_validate_broadcast_stream!(content)
110
+ ExpoTurbo::Rails::Streams.broadcast_to(*streamables, content: content)
111
+ end
112
+
113
+ def broadcast_expo_turbo_stream_later_to(*streamables, content: nil)
114
+ raise ArgumentError, "provide content or a block, not both" if block_given? && !content.nil?
115
+
116
+ content = yield(expo_turbo_stream) if block_given?
117
+ expo_turbo_validate_broadcast_stream!(content)
118
+ ExpoTurbo::Rails::Streams.broadcast_later_to(*streamables, content: content)
119
+ end
120
+
121
+ def broadcast_expo_turbo_refresh_to(*streamables, request_id: ::Turbo.current_request_id, **attributes)
122
+ ExpoTurbo::Rails::Streams.broadcast_refresh_to(*streamables, request_id:, **attributes)
123
+ end
124
+
125
+ def broadcast_expo_turbo_refresh_later_to(*streamables, request_id: ::Turbo.current_request_id, **attributes)
126
+ ExpoTurbo::Rails::Streams.broadcast_refresh_later_to(*streamables, request_id:, **attributes)
127
+ end
128
+
129
+ private
130
+
131
+ def expo_turbo_validate_document!(document)
132
+ expo_turbo_template_capabilities!.validate_document!(document)
133
+ rescue TemplateCapabilities::ValidationError
134
+ raise TemplateError, "Expo Turbo templates must use declared components and valid style tokens"
135
+ end
136
+
137
+ def expo_turbo_validate_frame_fragment!(document)
138
+ capabilities = self.class.expo_turbo_template_capabilities_config
139
+ return document unless capabilities
140
+
141
+ capabilities.validate_frame_fragment!(document)
142
+ rescue TemplateCapabilities::ValidationError
143
+ raise TemplateError, "Expo Turbo templates must use declared components and valid style tokens"
144
+ end
145
+
146
+ def expo_turbo_validate_stream_fragment!(document)
147
+ capabilities = self.class.expo_turbo_template_capabilities_config
148
+ return document unless capabilities
149
+
150
+ capabilities.validate_stream_fragment!(document)
151
+ rescue TemplateCapabilities::ValidationError
152
+ raise TemplateError, "Expo Turbo templates must use declared components and valid style tokens"
153
+ end
154
+
155
+ def expo_turbo_template_capabilities!
156
+ self.class.expo_turbo_template_capabilities_config || raise(
157
+ ConfigurationError,
158
+ "configure expo_turbo_template_capabilities before rendering Expo Turbo templates"
159
+ )
160
+ end
161
+
162
+ def expo_turbo_validate_broadcast_stream!(content)
163
+ return content unless self.class.expo_turbo_template_capabilities_config
164
+ return content unless content.is_a?(String) && content.encoding == Encoding::UTF_8 && content.valid_encoding? && content.present?
165
+
166
+ expo_turbo_validate_stream_fragment!(XmlFragments.parse_stream_fragment(content))
167
+ content
168
+ rescue XmlFragments::ParseError
169
+ raise TemplateError, "Expo Turbo Stream broadcasts must contain well-formed XML Stream fragments"
170
+ end
171
+
172
+ def expo_turbo_template_file(template)
173
+ expo_turbo_view_file("#{template}.xml.erb")
174
+ end
175
+
176
+ def expo_turbo_partial_file(partial)
177
+ relative_path = Pathname(partial.to_s)
178
+ raise TemplateError, "Expo Turbo partial must be named" if partial.blank? || relative_path.absolute? || relative_path.extname.present?
179
+
180
+ expo_turbo_view_file(relative_path.dirname.join("_#{relative_path.basename}").to_s + ".xml.erb")
181
+ end
182
+
183
+ def expo_turbo_view_file(relative_path)
184
+ root = self.class.expo_turbo_views_path
185
+ raise ConfigurationError, "configure expo_turbo_view_root before rendering" unless root
186
+
187
+ root = root.realpath
188
+ relative_path = Pathname(relative_path)
189
+ raise TemplateError, "Expo Turbo template is outside the configured view root" if relative_path.absolute?
190
+
191
+ candidate = root.join(relative_path).cleanpath
192
+ raise TemplateError, "Expo Turbo template does not exist" unless candidate.file?
193
+
194
+ candidate = candidate.realpath
195
+ raise TemplateError, "Expo Turbo template is outside the configured view root" unless candidate.to_s.start_with?("#{root}#{File::SEPARATOR}")
196
+
197
+ candidate.to_s
198
+ end
199
+ end
200
+ end
201
+ end