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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +207 -0
- data/lib/expo_turbo/rails/cable/configuration.rb +27 -0
- data/lib/expo_turbo/rails/cable/connection.rb +21 -0
- data/lib/expo_turbo/rails/cable/protected_broadcast_job.rb +17 -0
- data/lib/expo_turbo/rails/cable/protected_streams_channel.rb +53 -0
- data/lib/expo_turbo/rails/cable.rb +95 -0
- data/lib/expo_turbo/rails/controller.rb +201 -0
- data/lib/expo_turbo/rails/dom_ids/helper.rb +13 -0
- data/lib/expo_turbo/rails/dom_ids.rb +54 -0
- data/lib/expo_turbo/rails/engine.rb +17 -0
- data/lib/expo_turbo/rails/errors.rb +11 -0
- data/lib/expo_turbo/rails/frames/helper.rb +28 -0
- data/lib/expo_turbo/rails/frames.rb +31 -0
- data/lib/expo_turbo/rails/protocol.rb +14 -0
- data/lib/expo_turbo/rails/streams/broadcast_job.rb +17 -0
- data/lib/expo_turbo/rails/streams/helper.rb +89 -0
- data/lib/expo_turbo/rails/streams/tag_builder.rb +269 -0
- data/lib/expo_turbo/rails/streams.rb +100 -0
- data/lib/expo_turbo/rails/template_capabilities.rb +232 -0
- data/lib/expo_turbo/rails/testing.rb +29 -0
- data/lib/expo_turbo/rails/version.rb +7 -0
- data/lib/expo_turbo/rails/xml_fragments.rb +126 -0
- data/lib/expo_turbo/rails.rb +31 -0
- metadata +172 -0
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
|