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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3784d2aa4059d1cc6faa8b79321ef0ec669dd69fac0b5543a3b881a5342b1961
4
- data.tar.gz: d31ff12aa8a32a70315f38216fa4c423f0e130147e21bde0473ae189f337ed45
3
+ metadata.gz: 771d11b3c4a9a70c254bbe8f403fe4196f1aff26395fd31d023638b261fcea50
4
+ data.tar.gz: cc8ef5bf1ef7dc04f66eff71481297782ea8496ada69f7bc48cab57fe3e5bfca
5
5
  SHA512:
6
- metadata.gz: b9a7ceca8836ce9238635a4dce4083eb5e04c60d1fb8241957b85a7c2ac5bff69099a8f19ceee1fa7075f1177075511790e783c9ff41df75cb01651a826f9ce0
7
- data.tar.gz: 6fe9125a8525af6a62d4e40f728a4260d12aba7bcef2bc9fb75f12eb89fea1ebe1f75b125c20b47a36597eebe91321e3d94d4d43fe622912e35aea42ae34bb26
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` can also choose branding from the current request:
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
- STATUSES.each do |status|
37
- define_method(:"#{status}?") { self.status == status }
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
- AUTHOR_TYPES.each do |type|
39
- define_method(:"#{type}?") { author_type == type }
40
- end
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
- ActionCable.server.broadcast("livechat:conversation:#{conversation_id}", { nudge: id })
158
- ActionCable.server.broadcast('livechat:inbox', { nudge: id })
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
@@ -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) {}
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Livechat
4
- VERSION = '1.0.0'
4
+ VERSION = '1.2.0'
5
5
  end
@@ -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:, avatar_url: nil, nonce: nil)
62
- json = config_json(locale:, authenticated:, avatar_url:)
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="#{src}" defer#{nonce_attr} data-livechat-widget></script>)
66
+ %(<script src="#{script_src}" defer#{nonce_attr} data-livechat-widget></script>)
68
67
  end
69
68
 
70
- def config_json(locale:, authenticated:, avatar_url: nil)
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: config.show_launcher ? true : false,
102
+ launcher: settings[:show_launcher] ? true : false,
78
103
  appName: Livechat.app_name,
79
- avatarUrl: avatar_url,
80
- accentColor: config.accent_color,
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: config.launcher_label.presence || t(:launcher, 'Chat with us'),
99
- greeting: config.greeting.presence || t(:greeting, 'Hi! How can we help?'),
100
- replyTime: config.reply_time_text.presence ||
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
- avatar_url: livechat_avatar_url,
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.0.0
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.16
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.'