livechat 1.0.0 → 1.2.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 +4 -4
- data/AGENTS.md +22 -4
- data/CHANGELOG.md +42 -0
- data/README.md +73 -1
- data/Rakefile +9 -1
- data/app/controllers/livechat/application_controller.rb +9 -0
- data/app/controllers/livechat/visitor_controller.rb +1 -1
- data/app/controllers/livechat/widgets_controller.rb +29 -0
- data/app/models/livechat/conversation.rb +2 -3
- data/app/models/livechat/message.rb +5 -5
- data/config/routes.rb +1 -0
- data/docs/ractors.md +123 -0
- data/docs/screenshots/01-widget.png +0 -0
- data/docs/screenshots/03-inbox.png +0 -0
- data/docs/screenshots/04-conversation.png +0 -0
- data/lib/livechat/configuration.rb +22 -2
- data/lib/livechat/engine.rb +11 -0
- data/lib/livechat/ractor_support.rb +534 -0
- data/lib/livechat/version.rb +1 -1
- data/lib/livechat/widget.rb +45 -17
- data/lib/livechat/widget_helper.rb +1 -7
- data/lib/livechat.rb +44 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 771d11b3c4a9a70c254bbe8f403fe4196f1aff26395fd31d023638b261fcea50
|
|
4
|
+
data.tar.gz: cc8ef5bf1ef7dc04f66eff71481297782ea8496ada69f7bc48cab57fe3e5bfca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4e9dcd6248961f6785a85fb77c79d3fbc7c5a4d79871ef7cffc7b137615c81467f77aeab2354475fd1f69ed9233ab4759eec426a52ad1aaf14cb009527e4bd0b
|
|
7
|
+
data.tar.gz: a214328cf56cede8a3179256e30e7fc9756d4e725d69001bc396c2d0343b2beb911f0644866f6b0bf74cad4ddba026f0b0bc83f7b901ab8f96960e7106e78d6f
|
data/AGENTS.md
CHANGED
|
@@ -119,9 +119,24 @@ For an account-erasure request, destroy that user's conversations with
|
|
|
119
119
|
email notifications and hook destinations separately. The content-type
|
|
120
120
|
allowlist is not malware scanning.
|
|
121
121
|
|
|
122
|
+
### Embedding on a static site
|
|
123
|
+
|
|
124
|
+
A marketing site that proxies the mount path through to Rails gets the widget from one tag:
|
|
125
|
+
|
|
126
|
+
```html
|
|
127
|
+
<script src="/livechat/embed.js" defer></script>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Add `?locale=de` for a translated page. Two requirements, and both are load-bearing:
|
|
131
|
+
|
|
132
|
+
1. **Same origin.** The widget sends `credentials: "same-origin"`, so a cross-origin `<script src>` loads and then silently fails to carry the visitor cookie. Proxy `/livechat/*` from the static site to Rails; do not point the tag at another host.
|
|
133
|
+
2. **`config.allowed_embed_origins = ["https://example.com"]`.** A static page cannot mint a CSRF token, so a request from a listed origin is exempted. Safe because `Origin` is browser-set and unforgeable by page script, it covers only the visitor endpoints, and a request with no `Origin` is never treated as embedded.
|
|
134
|
+
|
|
135
|
+
Give that surface its own wording with a callable — `config.greeting = ->(request) { ... }` keyed on `request.origin`.
|
|
136
|
+
|
|
122
137
|
### Do not
|
|
123
138
|
|
|
124
|
-
- **Do not copy the widget JavaScript into `app/javascript`, or add a `<script>` tag for it.** `livechat_tag` renders what is needed, and the engine serves the code with a content fingerprint. There is no build step and nothing for esbuild/importmap/Tailwind to know about.
|
|
139
|
+
- **Do not copy the widget JavaScript into `app/javascript`, or add a `<script>` tag for it.** `livechat_tag` renders what is needed (on a page Rails does not render, use `embed.js` above — still not a copy), and the engine serves the code with a content fingerprint. There is no build step and nothing for esbuild/importmap/Tailwind to know about.
|
|
125
140
|
- **Do not rebuild the inbox, and do not edit views inside the gem.** To put it inside an admin you already have, set `config.base_controller_class = "Admin::BaseController"` — it inherits that controller's layout, helpers, authentication and request context. For the shell alone, `config.agent_layout = "admin/application"`. Both work with nothing else wired up: the inbox's own assets are declared by its views, not the layout.
|
|
126
141
|
- **Do not add an Action Cable mount to "make chat realtime"** unless you also set `config.action_cable = true`. Polling is the default and is not broken.
|
|
127
142
|
- **Do not set config outside the initializer.** `rate_limit` in particular is read when the controller class loads; assigning config per-request mutates it process-wide.
|
|
@@ -139,9 +154,10 @@ Everything is optional; a fresh install works with zero config. Full list with c
|
|
|
139
154
|
| `current_user` | `nil` | Receives the request; nil means guest-by-cookie |
|
|
140
155
|
| `visitor_label`, `agent_label` | name/email/to_s | Receive the user |
|
|
141
156
|
| `agent_display_name` | the label unchanged | Receives the label; return "Support team" to keep agents anonymous |
|
|
142
|
-
| `app_name`, `greeting`, `reply_time_text`, `launcher_label` | localized defaults | Widget copy |
|
|
143
|
-
| `avatar_url`, `accent_color` | `nil` | Header avatar (URL or callable) and brand hex |
|
|
144
|
-
| `show_launcher` | `true` | `false` = open only from your own elements |
|
|
157
|
+
| `app_name`, `greeting`, `reply_time_text`, `launcher_label` | localized defaults | Widget copy. All but `app_name` also take a callable receiving the request |
|
|
158
|
+
| `avatar_url`, `accent_color` | `nil` | Header avatar (URL or callable) and brand hex (also callable) |
|
|
159
|
+
| `show_launcher` | `true` | `false` = open only from your own elements. Also takes a callable |
|
|
160
|
+
| `allowed_embed_origins` | `[]` | Origins that may embed the widget on a page this app does not render |
|
|
145
161
|
| `mailer_from` | `nil` | **Required for any email at all** |
|
|
146
162
|
| `agent_emails` | `nil` | Array or callable |
|
|
147
163
|
| `attach_files` | `true` | Needs Active Storage; inert without it |
|
|
@@ -167,6 +183,8 @@ Turbo Drive and strict nonce-based CSP work out of the box. 26 locales ship with
|
|
|
167
183
|
| Messages only appear on refresh | Expected: polling is the default. `config.action_cable = true` (with `/cable` mounted) for push |
|
|
168
184
|
| No attachment button | Active Storage not installed, or `attach_files = false` |
|
|
169
185
|
| `undefined local variable current_user` in the initializer | A gate lambda treated its argument as a controller. It is a `request` |
|
|
186
|
+
| Widget loads on a static site but every message 422s | The embedding origin is not in `allowed_embed_origins`. A static page has no CSRF token |
|
|
187
|
+
| Widget on a static site never finds its thread | It is pointed at another origin. The widget sends `credentials: "same-origin"` — proxy the mount path instead of linking across hosts |
|
|
170
188
|
|
|
171
189
|
---
|
|
172
190
|
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.0 - 2026-10-18
|
|
4
|
+
|
|
5
|
+
- Add Rails edge CI and experimental Ruby 4.0+ Ractor request support. The host
|
|
6
|
+
must opt into `Rails.application.ractorize!` and use the upstream Rack/I18n
|
|
7
|
+
dependencies documented in `docs/ractors.md`.
|
|
8
|
+
- Exercise actual multipart uploads and private downloads in worker Ractors
|
|
9
|
+
when Active Storage is installed, alongside the optional-dependency-free host.
|
|
10
|
+
- Exercise signed Action Cable stream tokens and worker-originated broadcasts;
|
|
11
|
+
broadcasts are coordinated with the main Cable server.
|
|
12
|
+
- Prepare immutable widget assets and a frozen configuration snapshot at
|
|
13
|
+
Ractorization. Callbacks still run for each request, with isolated captures;
|
|
14
|
+
callbacks that capture mutable state fail at boot with the setting name.
|
|
15
|
+
Normal Rails boots retain mutable configuration and lazy asset loading.
|
|
16
|
+
- Bridge framework operations that are not yet Ractor safe (mounted URL
|
|
17
|
+
generation, digests/key derivation and attribute factories) to the main
|
|
18
|
+
Ractor. This adds dispatch overhead in Ractor mode; it is compatibility
|
|
19
|
+
support, not a claim of improved throughput. Existing Rails 7.1–8.1 installs
|
|
20
|
+
need no migration or initializer changes.
|
|
21
|
+
|
|
22
|
+
## 1.1.0 - 2026-09-25
|
|
23
|
+
|
|
24
|
+
- **The widget can now live on a site this app does not render.** A static
|
|
25
|
+
marketing site that proxies the mount path through to Rails loads the whole
|
|
26
|
+
widget from one tag — `<script src="/livechat/embed.js" defer>` — and never
|
|
27
|
+
reproduces the config block itself. `?locale=` picks the copy. The response is
|
|
28
|
+
never cached; the widget code it pulls in stays immutably cached as before.
|
|
29
|
+
- Added `config.allowed_embed_origins`. A page served from a listed origin
|
|
30
|
+
cannot carry a Rails CSRF token, so a matching request is exempted from
|
|
31
|
+
forgery protection. The exemption rests on the `Origin` header, which the
|
|
32
|
+
browser sets and page script cannot forge; it covers only the widget's own
|
|
33
|
+
visitor endpoints, never the inbox; and a request that sends no `Origin` at
|
|
34
|
+
all — curl, a server-side client — is never treated as embedded.
|
|
35
|
+
- Added `Livechat.public_paths`: the endpoints that answer with JavaScript, JSON
|
|
36
|
+
or a file and need no Rails session. Hosts that mint a CSP nonce (or anything
|
|
37
|
+
else) from the session per request can skip these, rather than handing a
|
|
38
|
+
cookie to every visitor who only ever loaded the page.
|
|
39
|
+
- `greeting`, `reply_time_text`, `launcher_label`, `show_launcher` and
|
|
40
|
+
`accent_color` each accept a callable receiving the request, the way
|
|
41
|
+
`avatar_url` already did. One app serving both an in-app widget and an
|
|
42
|
+
embedded one can greet each surface differently, and show the bubble on one
|
|
43
|
+
but not the other.
|
|
44
|
+
|
|
3
45
|
## 1.0.0 - 2026-08-11
|
|
4
46
|
|
|
5
47
|
- **The 1.x record names are settled.** `Livechat::Conversation` and
|
data/README.md
CHANGED
|
@@ -136,6 +136,7 @@ Everything is optional — a fresh install works with zero config. In
|
|
|
136
136
|
| `avatar_url` | `nil` | Customer-facing avatar in the widget header; URL or per-request callable |
|
|
137
137
|
| `accent_color` | `nil` | One hex restyles launcher, header, bubbles, send button |
|
|
138
138
|
| `show_launcher` | `true` | `false` hides the bubble — bring your own entry point |
|
|
139
|
+
| `allowed_embed_origins` | `[]` | Origins allowed to embed the widget on a page this app doesn't render |
|
|
139
140
|
| `visitor_label` | name, else email | How a visitor is labelled in the inbox |
|
|
140
141
|
| `agent_label` | name, else email | How an agent is labelled internally |
|
|
141
142
|
| `agent_display_name` | the full label | What **visitors** see — trim it or anonymise it |
|
|
@@ -167,7 +168,9 @@ Livechat.configure do |config|
|
|
|
167
168
|
end
|
|
168
169
|
```
|
|
169
170
|
|
|
170
|
-
`avatar_url`
|
|
171
|
+
`avatar_url`, `greeting`, `reply_time_text`, `launcher_label`, `show_launcher`
|
|
172
|
+
and `accent_color` each take a callable receiving the request, so one app can
|
|
173
|
+
brand or word the widget per tenant — or per surface:
|
|
171
174
|
|
|
172
175
|
```ruby
|
|
173
176
|
config.avatar_url = ->(request) { request.env["current_account"]&.support_avatar_url }
|
|
@@ -245,6 +248,66 @@ config.storage_service = :livechat_uploads
|
|
|
245
248
|
|
|
246
249
|
</details>
|
|
247
250
|
|
|
251
|
+
## On a site this app doesn't render
|
|
252
|
+
|
|
253
|
+
Marketing sites are usually static and live on their own host, which is exactly
|
|
254
|
+
where people ask presales questions. The widget can go there too, with one tag:
|
|
255
|
+
|
|
256
|
+
```html
|
|
257
|
+
<script src="/livechat/embed.js" defer></script>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`embed.js` carries the config block the layout helper would have rendered and
|
|
261
|
+
pulls in the widget, so the static page never reproduces engine markup that a
|
|
262
|
+
future release might change. Add `?locale=de` for a translated site.
|
|
263
|
+
|
|
264
|
+
Two things have to be true.
|
|
265
|
+
|
|
266
|
+
**The mount path must be same-origin with that site.** The widget sends
|
|
267
|
+
`credentials: "same-origin"`, so a cross-origin script tag would load and then
|
|
268
|
+
quietly fail to carry the visitor's cookie. Proxy `/livechat/*` from the static
|
|
269
|
+
site through to Rails — one rule on a CDN, one `location` block in nginx — and
|
|
270
|
+
the browser sees a single origin.
|
|
271
|
+
|
|
272
|
+
**The embedding origin must be allowlisted**, because a static page cannot mint
|
|
273
|
+
a CSRF token:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
config.allowed_embed_origins = ["https://example.com"]
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`Livechat.public_paths` lists the endpoints that need no Rails session — the
|
|
280
|
+
widget script, the embed bootstrap, the visitor API and attachments. If your app
|
|
281
|
+
touches the session on every request (a CSP nonce generator usually does), skip
|
|
282
|
+
them there, or every visitor who merely loads a page leaves with a cookie:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
config.content_security_policy_nonce_generator = lambda do |request|
|
|
286
|
+
next if Livechat.public_paths.any? { |path| request.path.start_with?(path) }
|
|
287
|
+
request.session[:csp_nonce] ||= SecureRandom.hex(16)
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
A request from a listed origin skips forgery protection. What makes that safe is
|
|
292
|
+
that `Origin` is set by the browser and cannot be forged by page script, so
|
|
293
|
+
listing an origin grants nothing to any other site; the exemption covers only
|
|
294
|
+
the widget's visitor endpoints and never the inbox; and a request with no
|
|
295
|
+
`Origin` header at all is never treated as embedded, so a non-browser client
|
|
296
|
+
gains nothing.
|
|
297
|
+
|
|
298
|
+
Marketing visitors usually want a different greeting from signed-in users, and
|
|
299
|
+
the same app serves both:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
config.greeting = lambda do |request|
|
|
303
|
+
if request.origin == "https://example.com"
|
|
304
|
+
"Questions before you buy? Ask away."
|
|
305
|
+
else
|
|
306
|
+
"Hi! How can we help?"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
248
311
|
## Privacy, retention, and deletion
|
|
249
312
|
|
|
250
313
|
Livechat stores conversation identity and context, message bodies and
|
|
@@ -340,6 +403,9 @@ stable under semantic versioning:
|
|
|
340
403
|
- `Livechat.configure`, `mount_livechat`, `livechat_tag`, `livechat_button`,
|
|
341
404
|
`window.Livechat.open/close`, `data-livechat-open`, and
|
|
342
405
|
`data-livechat-message`.
|
|
406
|
+
- The `embed.js` endpoint under the mount path, including its `locale`
|
|
407
|
+
parameter. The markup it writes is private — embed the endpoint, not a copy
|
|
408
|
+
of its output.
|
|
343
409
|
- `config.on_visitor_message` and `config.on_agent_message`, each called with
|
|
344
410
|
the saved `Livechat::Message` documented by the initializer.
|
|
345
411
|
|
|
@@ -402,3 +468,9 @@ touch your own CSS — and migrations that follow your app's `primary_key_type`.
|
|
|
402
468
|
## License
|
|
403
469
|
|
|
404
470
|
MIT.
|
|
471
|
+
|
|
472
|
+
## Rails edge and Ractors
|
|
473
|
+
|
|
474
|
+
Rails edge is tested alongside Rails 7.1–8.1. Experimental Ruby 4.0+ Ractor hosting
|
|
475
|
+
requires additional upstream dependencies and freezes configuration at startup.
|
|
476
|
+
See [the Ractor hosting guide](docs/ractors.md) for callbacks, requirements and checks.
|
data/Rakefile
CHANGED
|
@@ -5,7 +5,7 @@ require 'rake/testtask'
|
|
|
5
5
|
|
|
6
6
|
Rake::TestTask.new(:test) do |t|
|
|
7
7
|
t.libs << 'test'
|
|
8
|
-
t.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*')
|
|
8
|
+
t.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*', 'test/ractor/**/*')
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
namespace :test do
|
|
@@ -17,3 +17,11 @@ namespace :test do
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
task default: :test
|
|
20
|
+
|
|
21
|
+
namespace :test do
|
|
22
|
+
desc 'Run isolated configuration and Rails edge Ractor request tests (Ruby 4.0+)'
|
|
23
|
+
task :ractor do
|
|
24
|
+
ruby '-Itest', 'test/ractor/configuration_test.rb'
|
|
25
|
+
ruby '-Itest', 'test/ractor/requests_test.rb'
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -13,5 +13,14 @@ module Livechat
|
|
|
13
13
|
include RequestContext
|
|
14
14
|
|
|
15
15
|
protect_from_forgery with: :exception
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# A page on an allowlisted embed origin is static and has no Rails session
|
|
20
|
+
# to mint a token from, so the Origin header stands in for one. Only this
|
|
21
|
+
# controller's descendants are affected — the inbox verifies as usual.
|
|
22
|
+
def verified_request?
|
|
23
|
+
Livechat.embedded?(request) || super
|
|
24
|
+
end
|
|
16
25
|
end
|
|
17
26
|
end
|
|
@@ -9,7 +9,7 @@ module Livechat
|
|
|
9
9
|
before_action :claim_guest_conversations
|
|
10
10
|
|
|
11
11
|
if respond_to?(:rate_limit) && Livechat.config.rate_limit
|
|
12
|
-
rate_limit(**Livechat.config.rate_limit,
|
|
12
|
+
Livechat::RactorSupport.rate_limit(self, **Livechat.config.rate_limit,
|
|
13
13
|
only: %i[create email],
|
|
14
14
|
with: -> { render_rate_limited })
|
|
15
15
|
end
|
|
@@ -20,6 +20,27 @@ module Livechat
|
|
|
20
20
|
serve(Widget.javascript, Widget.fingerprint)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Bootstraps the widget on a page this app does not render. Carries the
|
|
24
|
+
# config the layout helper would have rendered, so an external page needs
|
|
25
|
+
# one <script src> and nothing that has to keep up with this engine.
|
|
26
|
+
#
|
|
27
|
+
# Never cached: it reflects the current visitor and whatever copy the host
|
|
28
|
+
# resolves per request. The heavy widget.js it pulls in stays immutable.
|
|
29
|
+
def embed
|
|
30
|
+
body = if Livechat.enabled?(request)
|
|
31
|
+
I18n.with_locale(embed_locale) do
|
|
32
|
+
Widget.embed_javascript(locale: I18n.locale,
|
|
33
|
+
authenticated: current_visitor.present?,
|
|
34
|
+
settings: Livechat.settings_for(request))
|
|
35
|
+
end
|
|
36
|
+
else
|
|
37
|
+
''
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
response.headers['Cache-Control'] = 'no-store'
|
|
41
|
+
render plain: body, content_type: 'text/javascript'
|
|
42
|
+
end
|
|
43
|
+
|
|
23
44
|
def dashboard
|
|
24
45
|
serve(Widget.dashboard_javascript, Widget.dashboard_fingerprint, 'text/javascript')
|
|
25
46
|
end
|
|
@@ -30,6 +51,14 @@ module Livechat
|
|
|
30
51
|
|
|
31
52
|
private
|
|
32
53
|
|
|
54
|
+
# An embedding site carries its own language; it asks for the matching
|
|
55
|
+
# copy with ?locale=. Anything the host does not actually have falls back
|
|
56
|
+
# rather than raising.
|
|
57
|
+
def embed_locale
|
|
58
|
+
requested = params[:locale].to_s
|
|
59
|
+
I18n.available_locales.map(&:to_s).include?(requested) ? requested : I18n.locale
|
|
60
|
+
end
|
|
61
|
+
|
|
33
62
|
def serve(source, fingerprint, content_type = 'text/javascript')
|
|
34
63
|
expires_in 1.year, public: true if params[:v] == fingerprint
|
|
35
64
|
return unless stale?(etag: [Livechat::VERSION, fingerprint])
|
|
@@ -33,9 +33,8 @@ module Livechat
|
|
|
33
33
|
destroyed
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
end
|
|
36
|
+
def open? = status == 'open'
|
|
37
|
+
def resolved? = status == 'resolved'
|
|
39
38
|
|
|
40
39
|
# The visitor's ongoing thread. Signed-in identity wins; guests are found
|
|
41
40
|
# by their cookie token (only unclaimed threads — a token that has been
|
|
@@ -35,9 +35,9 @@ module Livechat
|
|
|
35
35
|
|
|
36
36
|
after_create_commit :on_created
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
def visitor? = author_type == 'visitor'
|
|
39
|
+
def agent? = author_type == 'agent'
|
|
40
|
+
def system? = author_type == 'system'
|
|
41
41
|
|
|
42
42
|
def read? = read_at.present?
|
|
43
43
|
|
|
@@ -154,8 +154,8 @@ module Livechat
|
|
|
154
154
|
def broadcast_new_message
|
|
155
155
|
return unless Livechat.action_cable_enabled?
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
RactorSupport.broadcast("livechat:conversation:#{conversation_id}", { nudge: id })
|
|
158
|
+
RactorSupport.broadcast('livechat:inbox', { nudge: id })
|
|
159
159
|
rescue StandardError => e
|
|
160
160
|
Rails.logger.error("[livechat] cable broadcast failed: #{e.class}: #{e.message}")
|
|
161
161
|
end
|
data/config/routes.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Livechat::Engine.routes.draw do
|
|
4
4
|
get 'widget.js', to: 'widgets#show', as: :widget
|
|
5
|
+
get 'embed.js', to: 'widgets#embed', as: :embed
|
|
5
6
|
get 'dashboard.js', to: 'widgets#dashboard', as: :dashboard_script
|
|
6
7
|
get 'dashboard.css', to: 'widgets#dashboard_stylesheet', as: :dashboard_stylesheet
|
|
7
8
|
|
data/docs/ractors.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Rails edge and experimental Ractor hosting
|
|
2
|
+
|
|
3
|
+
The regular CI matrix covers Rails 7.1, 7.2, 8.0 and 8.1. A required Rails
|
|
4
|
+
edge job runs the same suite against `rails/rails` main. A separate job boots
|
|
5
|
+
an eager-loaded host, calls `Rails.application.ractorize!`, and sends real
|
|
6
|
+
Rack requests through the mounted engine in non-main Ractors, including
|
|
7
|
+
simultaneous requests. The Ractor checks run in separate processes because
|
|
8
|
+
Ractorization freezes Rails; they never freeze the ordinary test application.
|
|
9
|
+
|
|
10
|
+
## Host prerequisites
|
|
11
|
+
|
|
12
|
+
Ractor hosting currently requires Ruby 4.0+, Rails main, Rack main's
|
|
13
|
+
`rack/ractorize`, and the unreleased [I18n Ractor support](https://github.com/ruby-i18n/i18n/pull/741).
|
|
14
|
+
`gemfiles/rails_ractor.gemfile` records the exact I18n revision tested. These
|
|
15
|
+
are development/test dependencies, not new runtime dependencies for released
|
|
16
|
+
Rails. The gem's supported dependency range remains Rails >= 7.1 and < 9.
|
|
17
|
+
|
|
18
|
+
For a host experimenting with Ractor hosting, the tested upstream overrides are:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "rails", github: "rails/rails", branch: "main"
|
|
22
|
+
gem "rack", github: "rack/rack", branch: "main"
|
|
23
|
+
gem "i18n", github: "Shopify/i18n", ref: "58aa1dcd3e04c34831e1c2ec06c83391559c7c91"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Follow Rails' own Ractor hosting requirements: disable reloading, eager-load
|
|
27
|
+
application code, use a shareable logger/cache and a Ractor-compatible job
|
|
28
|
+
adapter. Before application classes load, enable Rails' callback isolation:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "active_support/ractors"
|
|
32
|
+
ActiveSupport::Ractors.unshareable_proc_action = :raise
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Our isolated fixture uses `ActiveSupport::Ractors::Logger`, a null
|
|
36
|
+
cache, and (where jobs are loaded) `:inline`. Its separate shared counter proves
|
|
37
|
+
that public submission limits remain effective across worker Ractors. The
|
|
38
|
+
ordinary dummy application's `:test` job adapter is unchanged.
|
|
39
|
+
|
|
40
|
+
The engine prepares itself automatically on `before_ractorize`; ordinary Rails
|
|
41
|
+
boots do not freeze its configuration or load the experimental I18n extension.
|
|
42
|
+
An unavailable I18n Ractor extension produces a startup error explaining the
|
|
43
|
+
prerequisite. Rails edge and Ractor hosting remain experimental: this is not a
|
|
44
|
+
promise that every third-party host middleware, database driver, storage
|
|
45
|
+
service, mail adapter or Action Cable adapter is Ractor safe.
|
|
46
|
+
|
|
47
|
+
## Configuration and callbacks
|
|
48
|
+
|
|
49
|
+
Finish configuring Livechat before calling `ractorize!`. The engine publishes
|
|
50
|
+
a frozen copy of the configuration and warms/freezes widget JS, CSS and content
|
|
51
|
+
fingerprints. Changes after Ractorization require a process restart. Arrays and
|
|
52
|
+
hashes supplied by the host are copied before freezing.
|
|
53
|
+
|
|
54
|
+
Callbacks are isolated with Ruby's `Ractor.shareable_lambda` or
|
|
55
|
+
`Ractor.shareable_proc`, preserving lambda/proc arity. They continue to receive
|
|
56
|
+
the same request, user, record or event arguments and run on each request.
|
|
57
|
+
Isolated callbacks use arguments and constants; their implicit initializer
|
|
58
|
+
`self` becomes nil, following Rails' isolated callback convention.
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
Livechat.configure do |config|
|
|
62
|
+
config.enabled = ->(request) { request.get_header("HTTP_X_WIDGET") != "off" }
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Immutable captured values must already be shareable:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
expected_host = "app.example.com".freeze
|
|
70
|
+
Livechat.configure do |config|
|
|
71
|
+
config.enabled = ->(request) { request.host == expected_host }
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Mutable captures (an array collecting notifications, an unfrozen captured
|
|
76
|
+
string, or reassigned outer locals) cannot be shared. The engine fails at
|
|
77
|
+
startup with `Livechat.config.<setting>` in the error rather than failing on
|
|
78
|
+
a visitor's request. Use request-local state and external persistence for
|
|
79
|
+
side effects. A pre-isolated callback made with an explicit shareable `self:`
|
|
80
|
+
keeps that receiver. Stateful callable objects must be copyable/shareable;
|
|
81
|
+
custom objects remain subject to Ruby's Ractor rules.
|
|
82
|
+
|
|
83
|
+
## Scope and cost
|
|
84
|
+
|
|
85
|
+
Some Rails main paths are still being converted to Ractors. The engine warms
|
|
86
|
+
model attributes and controller names, prepares ETag callbacks, and supplies
|
|
87
|
+
narrow bridges for mounted URL generation, cache freshness, digest/key
|
|
88
|
+
operations, GlobalID URI decoding and boot-time attribute decorators. Those bridges preserve the
|
|
89
|
+
original operation on the main Ractor; requests, authorization callbacks,
|
|
90
|
+
rendering and persistence orchestration execute in their worker Ractors.
|
|
91
|
+
Adapter name quoting delegates to its original implementation on the main Ractor,
|
|
92
|
+
which preserves escaping and avoids process-global mutable caches in workers.
|
|
93
|
+
Disk-backed uploads use Ractor-local storage clients and plain temporary files
|
|
94
|
+
with explicit cleanup, because Tempfile's generated delegation methods cannot
|
|
95
|
+
be called from another Ractor. MIME registrations must finish before
|
|
96
|
+
Ractorization, when Marcel's MIME tables become immutable. Eager model schema
|
|
97
|
+
preparation adds startup database work.
|
|
98
|
+
The bridges are internal compatibility code, add coordination overhead, and
|
|
99
|
+
must be reviewed as upstream Rails evolves. No throughput improvement is claimed.
|
|
100
|
+
|
|
101
|
+
## Run the checks
|
|
102
|
+
|
|
103
|
+
Use the Ruby configured in `mise.toml`, never macOS's system Ruby.
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
BUNDLE_GEMFILE=gemfiles/rails_edge.gemfile bundle install
|
|
107
|
+
BUNDLE_GEMFILE=gemfiles/rails_edge.gemfile bundle exec rake test
|
|
108
|
+
BUNDLE_GEMFILE=gemfiles/rails_ractor.gemfile bundle install
|
|
109
|
+
BUNDLE_GEMFILE=gemfiles/rails_ractor.gemfile bundle exec rake test:ractor
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The request fixture creates and removes its own temporary database and host;
|
|
113
|
+
it does not reuse or freeze `test/dummy`. The shared schema lives in
|
|
114
|
+
`test/support/schema.rb` so the ordinary and Ractor suites exercise the same
|
|
115
|
+
record shape. Successful checks establish the tested SQLite host and explicit
|
|
116
|
+
request paths, not all combinations of host integrations.
|
|
117
|
+
|
|
118
|
+
The media job also runs with `RACTOR_MEDIA=1`, exercising private disk-backed
|
|
119
|
+
Active Storage downloads through the engine's authorization gate. Storage
|
|
120
|
+
services are initialized per Ractor, and streamed responses use a thread local
|
|
121
|
+
to that Ractor. Livechat additionally runs with `RACTOR_CABLE=1`, checking the
|
|
122
|
+
signed stream token and broadcasts delegated to the main Action Cable server.
|
|
123
|
+
Other storage services and production Cable adapters need host-specific QA.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -61,6 +61,9 @@ module Livechat
|
|
|
61
61
|
|
|
62
62
|
# Widget copy. nil uses the localized defaults. reply_time_text is the
|
|
63
63
|
# honest line under the greeting — "We usually reply within a few hours."
|
|
64
|
+
# A String, or a callable receiving the request: one app serving both an
|
|
65
|
+
# in-app widget and an embedded one on a marketing site needs to greet
|
|
66
|
+
# those visitors differently.
|
|
64
67
|
attr_accessor :greeting, :reply_time_text, :launcher_label
|
|
65
68
|
|
|
66
69
|
# Optional customer-facing avatar shown in the widget header. Use a URL
|
|
@@ -70,13 +73,29 @@ module Livechat
|
|
|
70
73
|
|
|
71
74
|
# Brand color for the widget (launcher, header, visitor bubbles, send
|
|
72
75
|
# button) as a hex value, e.g. "#7c3aed". The widget picks black or white
|
|
73
|
-
# text automatically for contrast. nil keeps the built-in blue.
|
|
76
|
+
# text automatically for contrast. nil keeps the built-in blue. A callable
|
|
77
|
+
# receiving the request works too.
|
|
74
78
|
attr_accessor :accent_color
|
|
75
79
|
|
|
76
80
|
# The floating launcher bubble. Set false to open the widget only from
|
|
77
|
-
# your own elements carrying `data-livechat-open`.
|
|
81
|
+
# your own elements carrying `data-livechat-open`. Also accepts a callable
|
|
82
|
+
# receiving the request, for a host that wants the bubble on one surface
|
|
83
|
+
# and its own opener on another.
|
|
78
84
|
attr_accessor :show_launcher
|
|
79
85
|
|
|
86
|
+
# Origins allowed to embed the widget on pages this app does not render —
|
|
87
|
+
# a static marketing site that proxies `mount_path` through to this app,
|
|
88
|
+
# say. An Array of origins ("https://example.com"), or a callable
|
|
89
|
+
# receiving the request and returning one.
|
|
90
|
+
#
|
|
91
|
+
# Such a page cannot carry a Rails CSRF token, so a matching request is
|
|
92
|
+
# exempted from forgery protection. That exemption rests on the Origin
|
|
93
|
+
# header, which the browser sets and script cannot forge, and applies only
|
|
94
|
+
# to the widget's own visitor endpoints — never to the inbox. A request
|
|
95
|
+
# arriving without an Origin at all (curl, a server-side client) is never
|
|
96
|
+
# embedded and keeps the full check.
|
|
97
|
+
attr_accessor :allowed_embed_origins
|
|
98
|
+
|
|
80
99
|
# Email addresses to notify when a visitor writes and nobody has read it —
|
|
81
100
|
# an array, or a callable returning one. nil disables the built-in email.
|
|
82
101
|
attr_accessor :agent_emails
|
|
@@ -140,6 +159,7 @@ module Livechat
|
|
|
140
159
|
@avatar_url = nil
|
|
141
160
|
@accent_color = nil
|
|
142
161
|
@show_launcher = true
|
|
162
|
+
@allowed_embed_origins = []
|
|
143
163
|
@agent_emails = nil
|
|
144
164
|
@mailer_from = nil
|
|
145
165
|
@on_visitor_message = ->(_message) {}
|
data/lib/livechat/engine.rb
CHANGED
|
@@ -2,8 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module Livechat
|
|
4
4
|
class Engine < ::Rails::Engine
|
|
5
|
+
initializer 'livechat.ractor_middleware' do |app|
|
|
6
|
+
app.middleware.insert_before 0, RactorSupport::Middleware
|
|
7
|
+
end
|
|
8
|
+
|
|
5
9
|
isolate_namespace Livechat
|
|
6
10
|
|
|
11
|
+
initializer 'livechat.ractorize', before: 'action_controller.ractorize' do
|
|
12
|
+
if Rails::Application.method_defined?(:ractorize!)
|
|
13
|
+
Engine.routes.singleton_class.prepend(RactorSupport::MountedRoutes)
|
|
14
|
+
end
|
|
15
|
+
ActiveSupport.on_load(:before_ractorize) { Livechat::RactorSupport.prepare! }
|
|
16
|
+
end
|
|
17
|
+
|
|
7
18
|
rake_tasks do
|
|
8
19
|
load File.expand_path('../tasks/livechat_tasks.rake', __dir__)
|
|
9
20
|
end
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# Used only by Rails' opt-in before_ractorize hook. Normal Rails boots keep
|
|
5
|
+
# mutable configuration and lazy assets, including on older Ruby versions.
|
|
6
|
+
module RactorSupport
|
|
7
|
+
# Small bridges for framework paths that Rails main does not prepare yet.
|
|
8
|
+
# They are installed only when the host explicitly calls ractorize!.
|
|
9
|
+
# Rails' built-in JSON renderer is still defined from an unisolated Proc.
|
|
10
|
+
# Keep its JSON/JSONP and escaping behavior in a regular Ruby method.
|
|
11
|
+
module JsonRendering
|
|
12
|
+
def _render_with_renderer_json(json, options)
|
|
13
|
+
json_options = options.except(:callback, :content_type, :status)
|
|
14
|
+
json_options[:escape] ||= false if !self.class.escape_json_responses? && options[:callback].blank?
|
|
15
|
+
json = json.to_json(json_options) unless json.is_a?(String)
|
|
16
|
+
if options[:callback].present?
|
|
17
|
+
self.content_type = :js if media_type.nil? || media_type == Mime[:json]
|
|
18
|
+
"/**/#{options[:callback]}(#{json})"
|
|
19
|
+
else
|
|
20
|
+
self.content_type = :json if media_type.nil?
|
|
21
|
+
json
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module DependencyInterlock
|
|
27
|
+
def interlock
|
|
28
|
+
return super if Ractor.main?
|
|
29
|
+
|
|
30
|
+
Ractor[:engine_dependency_interlock] ||= ActiveSupport::Dependencies::Interlock.new
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module LiveThreads
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def new_controller_thread
|
|
38
|
+
return super if Ractor.main?
|
|
39
|
+
|
|
40
|
+
Thread.new do
|
|
41
|
+
Thread.current.abort_on_exception = true
|
|
42
|
+
yield
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
module UniquenessCoverage
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def covered_by_unique_index?(klass, record, attribute, scope)
|
|
51
|
+
return super unless frozen?
|
|
52
|
+
|
|
53
|
+
copies = Ractor[:engine_uniqueness_validators] ||= {}
|
|
54
|
+
copy = copies[[self, klass]] ||= dup
|
|
55
|
+
copy.send(:covered_by_unique_index?, klass, record, attribute, scope)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# GlobalID calls CGI.unescape without an encoding, whose default reads a
|
|
60
|
+
# process-wide class variable. Preserve CGI's configured default on main.
|
|
61
|
+
module UriUnescaping
|
|
62
|
+
def unescape(string, *arguments)
|
|
63
|
+
return super if Ractor.main?
|
|
64
|
+
|
|
65
|
+
shared_arguments = Ractor.make_shareable([string, *arguments], copy: true)
|
|
66
|
+
ActiveSupport::Ractors.on_main(self) { unescape(*shared_arguments) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
module CacheFreshness
|
|
71
|
+
def strict_freshness
|
|
72
|
+
return super if Ractor.main?
|
|
73
|
+
|
|
74
|
+
ActiveSupport::Ractors.on_main(self) { strict_freshness }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Attribute decorators (including Rails enums and serialized attributes)
|
|
79
|
+
# are boot-time factories. Keep their original receivers and captures on
|
|
80
|
+
# the main Ractor, rather than changing the semantics of Rails' closures.
|
|
81
|
+
class Decorator
|
|
82
|
+
def initialize(callback)
|
|
83
|
+
@key = RactorSupport.register_decorator(callback)
|
|
84
|
+
freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def call(*arguments)
|
|
88
|
+
return RactorSupport.call_decorator(@key, arguments) if Ractor.main?
|
|
89
|
+
|
|
90
|
+
shared_arguments = Ractor.make_shareable(arguments, copy: true)
|
|
91
|
+
ActiveSupport::Ractors.on_main(self) { RactorSupport.call_decorator(@key, shared_arguments) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.register_decorator(callback)
|
|
96
|
+
@decorators ||= []
|
|
97
|
+
@decorators << callback
|
|
98
|
+
@decorators.length - 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.call_decorator(key, arguments)
|
|
102
|
+
@decorators.fetch(key).call(*arguments)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.prepare_attributes(model)
|
|
106
|
+
prepare_attributes(model.superclass) if model.superclass.respond_to?(:pending_attribute_modifications, true)
|
|
107
|
+
model.send(:pending_attribute_modifications)&.each do |modification|
|
|
108
|
+
next unless modification.respond_to?(:decorator) && modification.decorator.is_a?(Proc)
|
|
109
|
+
|
|
110
|
+
modification.decorator = Decorator.new(modification.decorator)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
module AdapterQuoting
|
|
115
|
+
def quote_column_name(name)
|
|
116
|
+
return super if Ractor.main?
|
|
117
|
+
|
|
118
|
+
shared_name = Ractor.make_shareable(name, copy: true)
|
|
119
|
+
ActiveSupport::Ractors.on_main(self) { quote_column_name(shared_name) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def quote_table_name(name)
|
|
123
|
+
return super if Ractor.main?
|
|
124
|
+
|
|
125
|
+
shared_name = Ractor.make_shareable(name, copy: true)
|
|
126
|
+
ActiveSupport::Ractors.on_main(self) { quote_table_name(shared_name) }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Route arguments can contain records with native Set caches. Only their
|
|
131
|
+
# to_param value is needed to generate a mount prefix; keep the caller's
|
|
132
|
+
# original record objects in the options passed to the actual route.
|
|
133
|
+
def self.routing_snapshot(value)
|
|
134
|
+
return value if Ractor.shareable?(value)
|
|
135
|
+
|
|
136
|
+
copy = case value
|
|
137
|
+
when ActiveRecord::Base then value.to_param
|
|
138
|
+
when Hash then value.to_h { |key, item| [routing_snapshot(key), routing_snapshot(item)] }
|
|
139
|
+
when Array then value.map { |item| routing_snapshot(item) }
|
|
140
|
+
when Set then Set.new(value.map { |item| routing_snapshot(item) })
|
|
141
|
+
else return Ractor.make_shareable(value, copy: true)
|
|
142
|
+
end
|
|
143
|
+
Ractor.make_shareable(copy)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
module MountedRoutes
|
|
147
|
+
def find_script_name(options)
|
|
148
|
+
return super unless defined?(Ractor.shareable_proc) && !Ractor.main?
|
|
149
|
+
|
|
150
|
+
shared_options = RactorSupport.routing_snapshot(options)
|
|
151
|
+
prefix, updated = ActiveSupport::Ractors.on_main(self) do
|
|
152
|
+
local_options = shared_options.dup
|
|
153
|
+
[find_script_name(local_options), local_options]
|
|
154
|
+
end
|
|
155
|
+
(shared_options.keys - updated.keys).each { |key| options.delete(key) }
|
|
156
|
+
updated.each do |key, value|
|
|
157
|
+
options[key] = value unless shared_options.key?(key) && shared_options[key] == value
|
|
158
|
+
end
|
|
159
|
+
prefix
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
module Digesting
|
|
164
|
+
def hexdigest(value)
|
|
165
|
+
return super if Ractor.main?
|
|
166
|
+
|
|
167
|
+
shared_value = Ractor.make_shareable(value, copy: true)
|
|
168
|
+
ActiveSupport::Ractors.on_main(self) { hexdigest(shared_value) }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
module KeyGeneration
|
|
173
|
+
def generate_key(salt, key_size = 64)
|
|
174
|
+
return super if Ractor.main?
|
|
175
|
+
|
|
176
|
+
shared_salt = Ractor.make_shareable(salt, copy: true)
|
|
177
|
+
ActiveSupport::Ractors.on_main(self) { generate_key(shared_salt, key_size) }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
module UploadCleanup
|
|
182
|
+
def open
|
|
183
|
+
reopen(path, 'rb+')
|
|
184
|
+
self
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Tempfile#close takes a positional unlink flag.
|
|
188
|
+
def close(unlink_now = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
189
|
+
super() unless closed?
|
|
190
|
+
unlink if unlink_now
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def unlink
|
|
194
|
+
File.unlink(path)
|
|
195
|
+
rescue Errno::ENOENT
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def close!
|
|
200
|
+
close(true)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.upload_tempfile(filename, _content_type)
|
|
205
|
+
extension = File.extname(filename.gsub("\0", '%00'))[0, 129]
|
|
206
|
+
Tempfile.create(['RackMultipart', extension]).extend(UploadCleanup)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
class Middleware
|
|
210
|
+
def initialize(app)
|
|
211
|
+
@app = app
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def call(env)
|
|
215
|
+
if (fallbacks = RactorSupport.fallbacks)
|
|
216
|
+
Fiber[:i18n_fallbacks] ||= fallbacks
|
|
217
|
+
env['rack.multipart.tempfile_factory'] ||= RactorSupport.method(:upload_tempfile) unless Ractor.main?
|
|
218
|
+
end
|
|
219
|
+
@app.call(env)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def self.fallbacks
|
|
224
|
+
@fallbacks
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
module StorageMetadata
|
|
228
|
+
def analyzed = read_store_attribute(:metadata, :analyzed)
|
|
229
|
+
def identified = read_store_attribute(:metadata, :identified)
|
|
230
|
+
def composed = read_store_attribute(:metadata, :composed)
|
|
231
|
+
|
|
232
|
+
def analyzed=(value)
|
|
233
|
+
write_store_attribute(:metadata, :analyzed, value)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def identified=(value)
|
|
237
|
+
write_store_attribute(:metadata, :identified, value)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def composed=(value)
|
|
241
|
+
write_store_attribute(:metadata, :composed, value)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
class StorageServices
|
|
246
|
+
def initialize(configurations)
|
|
247
|
+
@configurations = Ractor.make_shareable(configurations, copy: true)
|
|
248
|
+
freeze
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def fetch(name, &)
|
|
252
|
+
registry = if Ractor.main?
|
|
253
|
+
RactorSupport.storage_registry
|
|
254
|
+
else
|
|
255
|
+
Ractor[:engine_storage_services] ||= ActiveStorage::Service::Registry.new(@configurations)
|
|
256
|
+
end
|
|
257
|
+
registry.fetch(name, &)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def self.storage_registry
|
|
262
|
+
@storage_registry
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
module StorageChecksums
|
|
266
|
+
def checksum_implementation(**options)
|
|
267
|
+
implementation = super
|
|
268
|
+
if Ractor.main? || !implementation.respond_to?(:name) || !implementation.name&.start_with?('OpenSSL::Digest::')
|
|
269
|
+
return implementation
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
DigestFactory.new(implementation.name.delete_prefix('OpenSSL::Digest::'))
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
class DigestFactory
|
|
277
|
+
def initialize(algorithm)
|
|
278
|
+
@algorithm = algorithm.freeze
|
|
279
|
+
freeze
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def new
|
|
283
|
+
OpenSSL::Digest.new(@algorithm)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def file(io)
|
|
287
|
+
new.file(io)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def base64digest(value)
|
|
291
|
+
new.base64digest(value)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def self.prepare_redirects!
|
|
296
|
+
base = ActionController::Base
|
|
297
|
+
readers = Module.new
|
|
298
|
+
%i[raise_on_open_redirects action_on_open_redirect action_on_path_relative_redirect].each do |key|
|
|
299
|
+
source = base.method(key).source_location&.first
|
|
300
|
+
next unless source&.end_with?('/action_controller/metal/redirecting.rb')
|
|
301
|
+
|
|
302
|
+
value = Ractor.make_shareable(base.public_send(key), copy: true)
|
|
303
|
+
readers.define_method(key, Ractor.shareable_proc { Ractor.main? ? super() : value })
|
|
304
|
+
end
|
|
305
|
+
return if readers.instance_methods(false).empty?
|
|
306
|
+
|
|
307
|
+
base.prepend(readers)
|
|
308
|
+
base.singleton_class.prepend(readers)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def self.prepare_previewers!
|
|
312
|
+
ActiveStorage.previewers.each do |previewer|
|
|
313
|
+
readers = Module.new
|
|
314
|
+
previewer.methods.grep(/_exists\?\z/).each do |capability|
|
|
315
|
+
method = previewer.method(capability)
|
|
316
|
+
next unless method.arity.zero? && method.source_location&.first&.include?('/active_storage/previewer/')
|
|
317
|
+
|
|
318
|
+
readers.define_method(capability, Ractor.shareable_proc do
|
|
319
|
+
if Ractor.main?
|
|
320
|
+
super()
|
|
321
|
+
else
|
|
322
|
+
ActiveSupport::Ractors.on_main(self) { public_send(capability) }
|
|
323
|
+
end
|
|
324
|
+
end)
|
|
325
|
+
end
|
|
326
|
+
previewer.singleton_class.prepend(readers) unless readers.instance_methods(false).empty?
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def self.prepare_storage!
|
|
331
|
+
return unless defined?(ActiveStorage::Blob)
|
|
332
|
+
|
|
333
|
+
prepare_previewers!
|
|
334
|
+
ActiveStorage::Blob.prepend(StorageMetadata) unless ActiveStorage::Blob < StorageMetadata
|
|
335
|
+
%i[EXTENSIONS TYPE_EXTS TYPE_ALIASES TYPE_PARENTS ROOT_XML MAGIC].each do |name|
|
|
336
|
+
Ractor.make_shareable(Marcel.const_get(name)) if Marcel.const_defined?(name)
|
|
337
|
+
end
|
|
338
|
+
ActiveStorage::Service.prepend(StorageChecksums) unless ActiveStorage::Service < StorageChecksums
|
|
339
|
+
unless Ractor.shareable?(ActiveStorage::Blob.services)
|
|
340
|
+
@storage_registry = ActiveStorage::Blob.services
|
|
341
|
+
configurations = @storage_registry.instance_variable_get(:@configurations)
|
|
342
|
+
default_name = ActiveStorage::Blob.service.name
|
|
343
|
+
default_reader = Module.new
|
|
344
|
+
default_reader.define_method(:service, Ractor.shareable_proc do
|
|
345
|
+
Ractor.main? ? super() : services.fetch(default_name)
|
|
346
|
+
end)
|
|
347
|
+
ActiveStorage::Blob.singleton_class.prepend(default_reader)
|
|
348
|
+
ActiveStorage::Blob.services = StorageServices.new(configurations)
|
|
349
|
+
end
|
|
350
|
+
settings = ActiveStorage.class_variables.map { |name| name.to_s.delete_prefix('@@').to_sym }
|
|
351
|
+
settings.select! { |key| ActiveStorage.respond_to?(key) }
|
|
352
|
+
snapshot = settings.to_h { |key| [key, ActiveStorage.public_send(key)] }
|
|
353
|
+
checksum = snapshot[:checksum_implementation]
|
|
354
|
+
if checksum.is_a?(Class) && checksum.name&.start_with?('OpenSSL::Digest::')
|
|
355
|
+
snapshot[:checksum_implementation] = DigestFactory.new(checksum.name.delete_prefix('OpenSSL::Digest::'))
|
|
356
|
+
end
|
|
357
|
+
Ractor.make_shareable(snapshot)
|
|
358
|
+
readers = Module.new
|
|
359
|
+
settings.each do |key|
|
|
360
|
+
readers.define_method(key, Ractor.shareable_proc { Ractor.main? ? super() : snapshot.fetch(key) })
|
|
361
|
+
end
|
|
362
|
+
ActiveStorage.singleton_class.prepend(readers)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def self.prepare_framework!
|
|
366
|
+
CGI.singleton_class.prepend(UriUnescaping) unless CGI.singleton_class < UriUnescaping
|
|
367
|
+
Ractor.make_shareable(GlobalID.app) if defined?(GlobalID) && GlobalID.respond_to?(:app)
|
|
368
|
+
I18n.available_locales.each { |locale| I18n.transliterate('ractor', locale: locale) }
|
|
369
|
+
require 'i18n/ractorize'
|
|
370
|
+
require 'tempfile'
|
|
371
|
+
prepare_redirects!
|
|
372
|
+
%i[BASE36_ALPHABET BASE58_ALPHABET].each do |name|
|
|
373
|
+
Ractor.make_shareable(SecureRandom.const_get(name)) if SecureRandom.const_defined?(name)
|
|
374
|
+
end
|
|
375
|
+
uniqueness = ActiveRecord::Validations::UniquenessValidator
|
|
376
|
+
uniqueness.prepend(UniquenessCoverage) unless uniqueness < UniquenessCoverage
|
|
377
|
+
dependencies = ActiveSupport::Dependencies.singleton_class
|
|
378
|
+
dependencies.prepend(DependencyInterlock) unless dependencies < DependencyInterlock
|
|
379
|
+
prepare_storage!
|
|
380
|
+
Ractor.make_shareable(ActiveRecord::Associations::AssociationScope::INSTANCE)
|
|
381
|
+
Ractor.make_shareable(ActionDispatch::Http::Cache::Response::SPECIAL_KEYS)
|
|
382
|
+
freshness = ActionDispatch::Http::Cache::Request.singleton_class
|
|
383
|
+
freshness.prepend(CacheFreshness) unless freshness < CacheFreshness
|
|
384
|
+
fallbacks = I18n.fallbacks
|
|
385
|
+
I18n.available_locales.each { |locale| fallbacks[locale] }
|
|
386
|
+
@fallbacks = Ractor.make_shareable(fallbacks)
|
|
387
|
+
ActiveSupport::Digest.singleton_class.prepend(Digesting) unless ActiveSupport::Digest.singleton_class < Digesting
|
|
388
|
+
ActiveSupport::KeyGenerator.prepend(KeyGeneration) unless ActiveSupport::KeyGenerator < KeyGeneration
|
|
389
|
+
ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool|
|
|
390
|
+
adapter = pool.db_config.adapter_class.singleton_class
|
|
391
|
+
adapter.prepend(AdapterQuoting) unless adapter < AdapterQuoting
|
|
392
|
+
end
|
|
393
|
+
rescue LoadError => e
|
|
394
|
+
raise LoadError,
|
|
395
|
+
"Ractor requests need the upstream I18n Ractor support (see gemfiles/rails_ractor.gemfile): #{e.message}"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def self.broadcast(name, payload)
|
|
399
|
+
if @prepared && !Ractor.main?
|
|
400
|
+
shared_name = Ractor.make_shareable(name, copy: true)
|
|
401
|
+
shared_payload = Ractor.make_shareable(payload, copy: true)
|
|
402
|
+
ActiveSupport::Ractors.on_main { ActionCable.server.broadcast(shared_name, shared_payload) }
|
|
403
|
+
else
|
|
404
|
+
ActionCable.server.broadcast(name, payload)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def self.prepare!
|
|
409
|
+
# A cache store can reference the whole middleware stack and freeze
|
|
410
|
+
# other mounted engines before their own hooks run. Warm every engine's
|
|
411
|
+
# request environment before making any of that graph shareable.
|
|
412
|
+
Rails.application.railties.each do |railtie|
|
|
413
|
+
railtie.env_config if railtie.is_a?(Rails::Engine)
|
|
414
|
+
end
|
|
415
|
+
Engine.instance.env_config
|
|
416
|
+
prepare_framework!
|
|
417
|
+
ActiveRecord::Base.descendants.each do |model|
|
|
418
|
+
next if model.abstract_class? || !model.table_exists?
|
|
419
|
+
|
|
420
|
+
model.reflect_on_all_associations.each { |reflection| reflection.send(:inverse_name) }
|
|
421
|
+
prepare_attributes(model)
|
|
422
|
+
Ractor.make_shareable(model.defined_enums)
|
|
423
|
+
model.defined_enums.each do |attribute, mapping|
|
|
424
|
+
method_name = attribute.pluralize
|
|
425
|
+
source = model.method(method_name).source_location&.first
|
|
426
|
+
next unless source&.end_with?('/active_record/enum.rb')
|
|
427
|
+
|
|
428
|
+
model.define_singleton_method(method_name, Ractor.shareable_proc { mapping })
|
|
429
|
+
end
|
|
430
|
+
model.define_attribute_methods
|
|
431
|
+
if model.respond_to?(:attachment_reflections)
|
|
432
|
+
model.attachment_reflections.each_value(&:named_variants)
|
|
433
|
+
Ractor.make_shareable(model.attachment_reflections)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
ActionController::Base.descendants.each do |controller|
|
|
437
|
+
next unless controller.name&.start_with?('Livechat::')
|
|
438
|
+
|
|
439
|
+
controller.prepend(LiveThreads) if defined?(ActionController::Live) && controller < ActionController::Live
|
|
440
|
+
renderer_source = controller.instance_method(:_render_with_renderer_json).source_location&.first
|
|
441
|
+
if renderer_source&.end_with?('/action_controller/metal/renderers.rb') && !(controller < JsonRendering)
|
|
442
|
+
controller.prepend(JsonRendering)
|
|
443
|
+
end
|
|
444
|
+
Ractor.make_shareable(controller.controller_name)
|
|
445
|
+
controller.etaggers = controller.etaggers.map { |callback| Ractor.shareable_proc(&callback) }.freeze
|
|
446
|
+
end
|
|
447
|
+
Livechat.instance_variable_set(:@config, configuration(Livechat.config))
|
|
448
|
+
%i[javascript dashboard_javascript dashboard_stylesheet fingerprint
|
|
449
|
+
dashboard_fingerprint dashboard_stylesheet_fingerprint].each do |method|
|
|
450
|
+
next unless Widget.respond_to?(method)
|
|
451
|
+
|
|
452
|
+
Widget.public_send(method)
|
|
453
|
+
end
|
|
454
|
+
ActionController::Base.descendants.each do |controller|
|
|
455
|
+
next unless controller.respond_to?(:livechat_rate_limit_options)
|
|
456
|
+
|
|
457
|
+
options = controller.livechat_rate_limit_options
|
|
458
|
+
controller.livechat_rate_limit_options = share_options(options)
|
|
459
|
+
end
|
|
460
|
+
if Livechat.config.action_cable && defined?(ActionCable)
|
|
461
|
+
verifier = Livechat.stream_verifier
|
|
462
|
+
verifier.verify(verifier.generate('livechat:ractor:warmup'))
|
|
463
|
+
Livechat.instance_variable_set(:@stream_verifier, Ractor.make_shareable(verifier))
|
|
464
|
+
end
|
|
465
|
+
@prepared = true
|
|
466
|
+
Widget.instance_variables.each do |variable|
|
|
467
|
+
Widget.instance_variable_set(variable, Ractor.make_shareable(Widget.instance_variable_get(variable)))
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Rails edge's rate_limit currently closes over reassigned keyword
|
|
472
|
+
# arguments. A named filter avoids that unshareable closure; the actual
|
|
473
|
+
# counter, cache key and rejection still use Rails' rate_limiting method.
|
|
474
|
+
def self.rate_limit(controller, to:, within:, only:, with:, **options)
|
|
475
|
+
unless Rails::Application.method_defined?(:ractorize!)
|
|
476
|
+
return controller.rate_limit(to: to, within: within, only: only, with: with, **options)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
controller.class_attribute :livechat_rate_limit_options, instance_accessor: false
|
|
480
|
+
controller.livechat_rate_limit_options = {
|
|
481
|
+
to: to, within: within, by: -> { request.remote_ip }, with: with,
|
|
482
|
+
store: controller.cache_store, name: nil, scope: controller.controller_path
|
|
483
|
+
}.merge(options.slice(:by, :store, :name, :scope))
|
|
484
|
+
controller.livechat_rate_limit_options[:scope] ||= controller.controller_path
|
|
485
|
+
filters = options.except(:by, :store, :name, :scope)
|
|
486
|
+
controller.before_action :livechat_rate_limit, only: only, **filters
|
|
487
|
+
controller.class_eval <<~RUBY, __FILE__, __LINE__ + 1
|
|
488
|
+
private
|
|
489
|
+
def livechat_rate_limit
|
|
490
|
+
rate_limiting(**self.class.livechat_rate_limit_options)
|
|
491
|
+
end
|
|
492
|
+
RUBY
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def self.share_options(options)
|
|
496
|
+
options.to_h do |key, value|
|
|
497
|
+
value = configuration_value(value) if value.is_a?(Proc)
|
|
498
|
+
[key, Ractor.make_shareable(value)]
|
|
499
|
+
end.freeze
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def self.configuration_value(value)
|
|
503
|
+
case value
|
|
504
|
+
when Proc
|
|
505
|
+
return value if Ractor.shareable?(value)
|
|
506
|
+
|
|
507
|
+
# Like Rails callbacks, isolated callbacks use arguments and constants,
|
|
508
|
+
# not their initializer's implicit self. Keep lambda/proc arity intact.
|
|
509
|
+
value.lambda? ? Ractor.shareable_lambda(&value) : Ractor.shareable_proc(&value)
|
|
510
|
+
when Hash
|
|
511
|
+
value.to_h { |key, item| [configuration_value(key), configuration_value(item)] }.freeze
|
|
512
|
+
when Array
|
|
513
|
+
value.map { |item| configuration_value(item) }.freeze
|
|
514
|
+
else
|
|
515
|
+
Ractor.make_shareable(value, copy: true)
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def self.configuration(original)
|
|
520
|
+
prepared = original.dup
|
|
521
|
+
original.instance_variables.each do |variable|
|
|
522
|
+
value = original.instance_variable_get(variable)
|
|
523
|
+
begin
|
|
524
|
+
shared = configuration_value(value)
|
|
525
|
+
prepared.instance_variable_set(variable, shared)
|
|
526
|
+
rescue Ractor::Error, TypeError => e
|
|
527
|
+
setting = variable.to_s.delete_prefix('@')
|
|
528
|
+
raise ArgumentError, "Livechat.config.#{setting} cannot be shared with Ractors: #{e.message}"
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
Ractor.make_shareable(prepared)
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
data/lib/livechat/version.rb
CHANGED
data/lib/livechat/widget.rb
CHANGED
|
@@ -9,9 +9,9 @@ module Livechat
|
|
|
9
9
|
# Rails app regardless of its CSS or JS setup. It lives under lib/ (not
|
|
10
10
|
# app/assets/) so a host that runs an asset pipeline never ingests it.
|
|
11
11
|
module Widget
|
|
12
|
-
SOURCE = File.expand_path('widget.js', __dir__)
|
|
13
|
-
DASHBOARD_SOURCE = File.expand_path('dashboard.js', __dir__)
|
|
14
|
-
DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__)
|
|
12
|
+
SOURCE = File.expand_path('widget.js', __dir__).freeze
|
|
13
|
+
DASHBOARD_SOURCE = File.expand_path('dashboard.js', __dir__).freeze
|
|
14
|
+
DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__).freeze
|
|
15
15
|
|
|
16
16
|
# Right-to-left scripts, so the chat renders mirrored for those locales.
|
|
17
17
|
# Matched on the language subtag, so region variants ("ar-EG") count too.
|
|
@@ -58,29 +58,54 @@ module Livechat
|
|
|
58
58
|
# nonce gets refused; a same-origin src is covered by `'self'` on every
|
|
59
59
|
# visit. `nonce:` is still stamped for hosts whose script-src has no
|
|
60
60
|
# 'self'; pass nil when the app has no nonce.
|
|
61
|
-
def snippet(locale:, authenticated:,
|
|
62
|
-
json = config_json(locale:, authenticated:,
|
|
61
|
+
def snippet(locale:, authenticated:, settings: nil, nonce: nil)
|
|
62
|
+
json = config_json(locale:, authenticated:, settings:)
|
|
63
63
|
nonce_attr = nonce ? %( nonce="#{nonce}") : ''
|
|
64
|
-
src = "#{Livechat.config.mount_path.chomp('/')}/widget.js?v=#{fingerprint}"
|
|
65
64
|
|
|
66
65
|
%(<script type="application/json" data-livechat-config>#{json}</script>) +
|
|
67
|
-
%(<script src="#{
|
|
66
|
+
%(<script src="#{script_src}" defer#{nonce_attr} data-livechat-widget></script>)
|
|
68
67
|
end
|
|
69
68
|
|
|
70
|
-
|
|
69
|
+
# The same two tags, assembled by the browser instead of by a Rails
|
|
70
|
+
# template. A page this app does not render — a static marketing site
|
|
71
|
+
# proxying the mount path through to Rails — gets the widget from one
|
|
72
|
+
# plain <script src>, and never reproduces the config block itself.
|
|
73
|
+
def embed_javascript(locale:, authenticated:, settings: nil)
|
|
74
|
+
<<~JS
|
|
75
|
+
/* livechat embed */
|
|
76
|
+
(function () {
|
|
77
|
+
if (window.__livechatEmbedded) return;
|
|
78
|
+
window.__livechatEmbedded = true;
|
|
79
|
+
|
|
80
|
+
var config = document.createElement("script");
|
|
81
|
+
config.type = "application/json";
|
|
82
|
+
config.setAttribute("data-livechat-config", "");
|
|
83
|
+
config.textContent = JSON.stringify(#{config_json(locale:, authenticated:, settings:)});
|
|
84
|
+
document.head.appendChild(config);
|
|
85
|
+
|
|
86
|
+
var widget = document.createElement("script");
|
|
87
|
+
widget.src = #{script_src.to_json};
|
|
88
|
+
widget.setAttribute("data-livechat-widget", "");
|
|
89
|
+
document.head.appendChild(widget);
|
|
90
|
+
})();
|
|
91
|
+
JS
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def config_json(locale:, authenticated:, settings: nil)
|
|
71
95
|
config = Livechat.config
|
|
96
|
+
settings ||= Livechat.settings_for(nil)
|
|
72
97
|
payload = {
|
|
73
98
|
endpoint: config.widget_endpoint,
|
|
74
99
|
locale: locale.to_s,
|
|
75
100
|
rtl: rtl?(locale),
|
|
76
101
|
authenticated: authenticated ? true : false,
|
|
77
|
-
launcher:
|
|
102
|
+
launcher: settings[:show_launcher] ? true : false,
|
|
78
103
|
appName: Livechat.app_name,
|
|
79
|
-
avatarUrl: avatar_url,
|
|
80
|
-
accentColor:
|
|
104
|
+
avatarUrl: settings[:avatar_url],
|
|
105
|
+
accentColor: settings[:accent_color],
|
|
81
106
|
attachments: Livechat.attachments_enabled?,
|
|
82
107
|
maxAttachments: config.max_attachments,
|
|
83
|
-
labels: labels
|
|
108
|
+
labels: labels(settings)
|
|
84
109
|
}
|
|
85
110
|
# Escape "</" so a value can't close the <script> block early.
|
|
86
111
|
payload.to_json.gsub('</', '<\/')
|
|
@@ -88,16 +113,19 @@ module Livechat
|
|
|
88
113
|
|
|
89
114
|
private
|
|
90
115
|
|
|
116
|
+
def script_src
|
|
117
|
+
"#{Livechat.config.mount_path.chomp('/')}/widget.js?v=#{fingerprint}"
|
|
118
|
+
end
|
|
119
|
+
|
|
91
120
|
# Every user-facing string in the widget, resolved through Rails I18n so
|
|
92
121
|
# the chat follows the app's current locale. Each lookup carries an
|
|
93
122
|
# English default, so the widget stays fully worded even when a key is
|
|
94
123
|
# missing for the active locale.
|
|
95
|
-
def labels
|
|
96
|
-
config = Livechat.config
|
|
124
|
+
def labels(settings)
|
|
97
125
|
{
|
|
98
|
-
launcher:
|
|
99
|
-
greeting:
|
|
100
|
-
replyTime:
|
|
126
|
+
launcher: settings[:launcher_label].presence || t(:launcher, 'Chat with us'),
|
|
127
|
+
greeting: settings[:greeting].presence || t(:greeting, 'Hi! How can we help?'),
|
|
128
|
+
replyTime: settings[:reply_time_text].presence ||
|
|
101
129
|
t(:reply_time, 'We usually reply within a few hours.'),
|
|
102
130
|
placeholder: t(:placeholder, 'Write a message…'),
|
|
103
131
|
send: t(:send, 'Send'),
|
|
@@ -12,7 +12,7 @@ module Livechat
|
|
|
12
12
|
Widget.snippet(
|
|
13
13
|
locale: I18n.locale,
|
|
14
14
|
authenticated: livechat_visitor.present?,
|
|
15
|
-
|
|
15
|
+
settings: Livechat.settings_for(request),
|
|
16
16
|
nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))
|
|
17
17
|
).html_safe
|
|
18
18
|
end
|
|
@@ -33,12 +33,6 @@ module Livechat
|
|
|
33
33
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
|
-
def livechat_avatar_url
|
|
37
|
-
value = Livechat.config.avatar_url
|
|
38
|
-
value = value.call(request) if value.respond_to?(:call)
|
|
39
|
-
value.to_s.presence
|
|
40
|
-
end
|
|
41
|
-
|
|
42
36
|
def livechat_visitor
|
|
43
37
|
return @livechat_visitor if defined?(@livechat_visitor)
|
|
44
38
|
|
data/lib/livechat.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'livechat/version'
|
|
4
4
|
require 'livechat/configuration'
|
|
5
|
+
require 'livechat/ractor_support'
|
|
5
6
|
require 'livechat/widget'
|
|
6
7
|
require 'livechat/widget_helper'
|
|
7
8
|
require 'livechat/attachment_policy'
|
|
@@ -30,6 +31,49 @@ module Livechat
|
|
|
30
31
|
!!config.enabled.call(request)
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
# The engine's public surface: the widget script, the embed bootstrap, the
|
|
35
|
+
# visitor API and the attachment route. All of them answer with JavaScript,
|
|
36
|
+
# JSON or a file, and none of them need a Rails session.
|
|
37
|
+
#
|
|
38
|
+
# Exposed because a host usually has session, CSP or caching rules written
|
|
39
|
+
# for its own HTML pages, and applying those here costs every visitor a
|
|
40
|
+
# cookie for a request that never had a use for one.
|
|
41
|
+
def public_paths
|
|
42
|
+
mount = config.mount_path.chomp('/')
|
|
43
|
+
["#{mount}/widget.js", "#{mount}/embed.js", "#{mount}/widget/", "#{mount}/attachments/"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolve a setting that may be a plain value or a per-request callable.
|
|
47
|
+
def resolve(value, request)
|
|
48
|
+
value.respond_to?(:call) ? value.call(request) : value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The per-request widget settings a host may vary by surface. Resolved by
|
|
52
|
+
# the helper and by the embed script, so both routes see the same values.
|
|
53
|
+
def settings_for(request)
|
|
54
|
+
{
|
|
55
|
+
greeting: resolve(config.greeting, request),
|
|
56
|
+
reply_time_text: resolve(config.reply_time_text, request),
|
|
57
|
+
launcher_label: resolve(config.launcher_label, request),
|
|
58
|
+
show_launcher: resolve(config.show_launcher, request),
|
|
59
|
+
accent_color: resolve(config.accent_color, request),
|
|
60
|
+
avatar_url: resolve(config.avatar_url, request).to_s.presence
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Is this request coming from a page on an allowlisted external origin?
|
|
65
|
+
#
|
|
66
|
+
# Only ever true for a request the browser stamped with a matching Origin.
|
|
67
|
+
# A missing Origin is not a match: a non-browser client must not inherit a
|
|
68
|
+
# browser-guaranteed exemption.
|
|
69
|
+
def embedded?(request)
|
|
70
|
+
origin = request.origin.presence
|
|
71
|
+
return false unless origin
|
|
72
|
+
|
|
73
|
+
allowed = resolve(config.allowed_embed_origins, request)
|
|
74
|
+
Array(allowed).any? { |candidate| candidate.to_s.chomp('/') == origin.chomp('/') }
|
|
75
|
+
end
|
|
76
|
+
|
|
33
77
|
# Can this request work the inbox? Checked by every inbox action.
|
|
34
78
|
# The class Livechat::DashboardController inherits from. Resolved on every
|
|
35
79
|
# call rather than memoized, so a host that reassigns base_controller_class
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: livechat
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -97,6 +97,10 @@ files:
|
|
|
97
97
|
- config/locales/livechat.vi.yml
|
|
98
98
|
- config/locales/livechat.zh-CN.yml
|
|
99
99
|
- config/routes.rb
|
|
100
|
+
- docs/ractors.md
|
|
101
|
+
- docs/screenshots/01-widget.png
|
|
102
|
+
- docs/screenshots/03-inbox.png
|
|
103
|
+
- docs/screenshots/04-conversation.png
|
|
100
104
|
- lib/generators/livechat/install/install_generator.rb
|
|
101
105
|
- lib/generators/livechat/install/templates/create_livechat_tables.rb.tt
|
|
102
106
|
- lib/generators/livechat/install/templates/initializer.rb
|
|
@@ -109,6 +113,7 @@ files:
|
|
|
109
113
|
- lib/livechat/dashboard.js
|
|
110
114
|
- lib/livechat/engine.rb
|
|
111
115
|
- lib/livechat/notifications.rb
|
|
116
|
+
- lib/livechat/ractor_support.rb
|
|
112
117
|
- lib/livechat/seeds.rb
|
|
113
118
|
- lib/livechat/version.rb
|
|
114
119
|
- lib/livechat/widget.js
|
|
@@ -138,7 +143,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
138
143
|
- !ruby/object:Gem::Version
|
|
139
144
|
version: '0'
|
|
140
145
|
requirements: []
|
|
141
|
-
rubygems_version: 4.0.
|
|
146
|
+
rubygems_version: 4.0.20
|
|
142
147
|
specification_version: 4
|
|
143
148
|
summary: 'Open-source live chat for Rails: a drop-in support widget plus a team inbox,
|
|
144
149
|
in your own database.'
|