product_tours 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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +2 -2
  3. data/CHANGELOG.md +45 -1
  4. data/README.md +33 -6
  5. data/Rakefile +9 -1
  6. data/SECURITY.md +2 -2
  7. data/app/controllers/product_tours/interactions_controller.rb +20 -2
  8. data/app/controllers/product_tours/tours_controller.rb +3 -0
  9. data/app/models/product_tours/tour.rb +59 -2
  10. data/app/views/product_tours/tours/_form.html.erb +15 -0
  11. data/app/views/product_tours/tours/_tour_panel.html.erb +6 -0
  12. data/config/locales/product_tours.ar.yml +1 -0
  13. data/config/locales/product_tours.bg.yml +1 -0
  14. data/config/locales/product_tours.bn.yml +1 -0
  15. data/config/locales/product_tours.de.yml +1 -0
  16. data/config/locales/product_tours.el.yml +1 -0
  17. data/config/locales/product_tours.en.yml +1 -0
  18. data/config/locales/product_tours.es.yml +1 -0
  19. data/config/locales/product_tours.fr.yml +1 -0
  20. data/config/locales/product_tours.hi.yml +1 -0
  21. data/config/locales/product_tours.hr.yml +1 -0
  22. data/config/locales/product_tours.id.yml +1 -0
  23. data/config/locales/product_tours.it.yml +1 -0
  24. data/config/locales/product_tours.ja.yml +1 -0
  25. data/config/locales/product_tours.ko.yml +1 -0
  26. data/config/locales/product_tours.lb.yml +1 -0
  27. data/config/locales/product_tours.nl.yml +1 -0
  28. data/config/locales/product_tours.pl.yml +1 -0
  29. data/config/locales/product_tours.pt.yml +1 -0
  30. data/config/locales/product_tours.ro.yml +1 -0
  31. data/config/locales/product_tours.ru.yml +1 -0
  32. data/config/locales/product_tours.th.yml +1 -0
  33. data/config/locales/product_tours.tr.yml +1 -0
  34. data/config/locales/product_tours.uk.yml +1 -0
  35. data/config/locales/product_tours.ur.yml +1 -0
  36. data/config/locales/product_tours.vi.yml +1 -0
  37. data/config/locales/product_tours.zh-CN.yml +1 -0
  38. data/docs/ractors.md +138 -0
  39. data/lib/generators/product_tours/install/templates/create_product_tours_tours.rb.tt +2 -0
  40. data/lib/generators/product_tours/upgrade/templates/add_player_media_to_product_tours_tours.rb.tt +17 -0
  41. data/lib/generators/product_tours/upgrade/upgrade_generator.rb +29 -0
  42. data/lib/product_tours/engine.rb +12 -0
  43. data/lib/product_tours/ractor_support.rb +519 -0
  44. data/lib/product_tours/version.rb +1 -1
  45. data/lib/product_tours/widget.js +13 -1
  46. data/lib/product_tours/widget.rb +3 -3
  47. data/lib/product_tours.rb +1 -0
  48. metadata +6 -2
data/docs/ractors.md ADDED
@@ -0,0 +1,138 @@
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 ProductTours 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
+ ProductTours.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
+ ProductTours.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 `ProductTours.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.
124
+
125
+ ## Action Text and Nokogiri
126
+
127
+ When Action Text is loaded, Nokogiri's native parser refuses to run outside the
128
+ main Ractor. Product Tours therefore delegates its HTML dashboard/editor paths
129
+ and `/widget/tour` responses to the main Rails application. It keeps the original
130
+ Action Text sanitizer, rendering, callbacks and transactions intact, including
131
+ rich-text editing. Request bodies and responses for those paths are buffered;
132
+ large editor uploads therefore have an extra memory cost. These paths do not
133
+ run in parallel. Static assets, lifecycle signals and gated media downloads
134
+ continue in worker Ractors. A host without Action Text does not use this fallback.
135
+ The Ractor media CI job covers both video uploads and rich-text rendering.
136
+
137
+ The Product Tours Ractor matrix separately checks a minimal host, an Active
138
+ Storage host without Action Text (`RACTOR_TEXT=0`), and a host with both engines.
@@ -9,6 +9,8 @@ class CreateProductToursTours < ActiveRecord::Migration<%= migration_version %>
9
9
  t.string :title, null: false
10
10
  t.text :video_url
11
11
  t.json :video_metadata, null: false, default: {}
12
+ t.text :poster_url
13
+ t.text :captions_url
12
14
  t.string :action_label
13
15
  t.text :action_url
14
16
  t.string :action_tour_key
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddPlayerMediaToProductToursTours < ActiveRecord::Migration<%= migration_version %>
4
+ COLUMNS = %i[poster_url captions_url].freeze
5
+
6
+ def up
7
+ COLUMNS.each do |column|
8
+ add_column :product_tours_tours, column, :text unless column_exists?(:product_tours_tours, column)
9
+ end
10
+ end
11
+
12
+ def down
13
+ COLUMNS.each do |column|
14
+ remove_column :product_tours_tours, column if column_exists?(:product_tours_tours, column)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
6
+
7
+ module ProductTours
8
+ module Generators
9
+ # Brings an install created by an earlier version up to the current schema.
10
+ # Every migration it writes checks before it changes anything, so running it
11
+ # on an app that is already current is harmless.
12
+ class UpgradeGenerator < Rails::Generators::Base
13
+ include ActiveRecord::Generators::Migration
14
+ include MigrationHelpers
15
+
16
+ source_root File.expand_path('templates', __dir__)
17
+ desc 'Adds the columns newer product_tours versions use to an existing install.'
18
+
19
+ def create_player_media_migration
20
+ migration_template 'add_player_media_to_product_tours_tours.rb.tt',
21
+ 'db/migrate/add_player_media_to_product_tours_tours.rb', skip: true
22
+ end
23
+
24
+ def post_upgrade
25
+ say "\nRun `bin/rails db:migrate` to finish the product_tours upgrade.", :green
26
+ end
27
+ end
28
+ end
29
+ end
@@ -4,8 +4,20 @@ require 'rails'
4
4
 
5
5
  module ProductTours
6
6
  class Engine < ::Rails::Engine
7
+ initializer 'product_tours.ractor_middleware' do |app|
8
+ app.middleware.insert_before 0, RactorSupport::Middleware
9
+ end
10
+
7
11
  isolate_namespace ProductTours
8
12
 
13
+ initializer 'product_tours.ractorize', before: 'action_controller.ractorize' do
14
+ if Rails::Application.method_defined?(:ractorize!)
15
+ Engine.routes.singleton_class.prepend(RactorSupport::MountedRoutes)
16
+ end
17
+
18
+ ActiveSupport.on_load(:before_ractorize) { ProductTours::RactorSupport.prepare! }
19
+ end
20
+
9
21
  initializer 'product_tours.helpers' do
10
22
  ActiveSupport.on_load(:action_view) do
11
23
  include ProductTours::WidgetHelper