product_tours 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +6 -0
- data/Rakefile +9 -1
- data/app/models/product_tours/tour.rb +7 -0
- data/docs/ractors.md +138 -0
- data/lib/product_tours/engine.rb +12 -0
- data/lib/product_tours/ractor_support.rb +519 -0
- data/lib/product_tours/version.rb +1 -1
- data/lib/product_tours/widget.rb +3 -3
- data/lib/product_tours.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5493d4177a606096eb7c630c0dd4fbab70a5b68ec013e3033b58217ec7de5c02
|
|
4
|
+
data.tar.gz: f150d164554a4d4a710214b2924fb191aa28e8aee6f87f813cb5c3418e6c9efc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 137467ccfedd59ff4abaa2b4a8f35735b8b02bcde9a9439901905191036d419b221f810f92d57ae66737d45ed492b7b5583e0043006ec68eb7c193bac8fa60d5
|
|
7
|
+
data.tar.gz: 91993e998b3d5fa607b35d38d20c58c3dfe6963d9a3860df6dc2dd9c8f872f496d7bc501e3ae26301bf843987fd53a532fe2eaacd1785c9b6dc7fe093df3776f
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.1.0] - 2026-10-18
|
|
6
|
+
|
|
7
|
+
- Add Rails edge CI and experimental Ruby 4.0+ Ractor request support. The host
|
|
8
|
+
must opt into `Rails.application.ractorize!` and use the upstream Rack/I18n
|
|
9
|
+
dependencies documented in `docs/ractors.md`.
|
|
10
|
+
- Exercise actual multipart uploads and private downloads in worker Ractors
|
|
11
|
+
when Active Storage is installed, alongside the optional-dependency-free host.
|
|
12
|
+
- With Action Text, dashboard/editor and tour-content responses run on the
|
|
13
|
+
main Ractor because Nokogiri is not Ractor safe. Those paths buffer request
|
|
14
|
+
bodies and responses and do not gain parallel execution; other paths stay
|
|
15
|
+
on workers.
|
|
16
|
+
- Prepare immutable widget assets and a frozen configuration snapshot at
|
|
17
|
+
Ractorization. Callbacks still run for each request, with isolated captures;
|
|
18
|
+
callbacks that capture mutable state fail at boot with the setting name.
|
|
19
|
+
Normal Rails boots retain mutable configuration and lazy asset loading.
|
|
20
|
+
- Bridge framework operations that are not yet Ractor safe (mounted URL
|
|
21
|
+
generation, digests/key derivation and attribute factories) to the main
|
|
22
|
+
Ractor. This adds dispatch overhead in Ractor mode; it is compatibility
|
|
23
|
+
support, not a claim of improved throughput. Existing Rails 7.1–8.1 installs
|
|
24
|
+
need no migration or initializer changes.
|
|
25
|
+
|
|
5
26
|
## [1.0.0] - 2026-08-11
|
|
6
27
|
|
|
7
28
|
- **The record is a tour, all the way down.** `ProductTours::Post` is now
|
data/README.md
CHANGED
|
@@ -447,3 +447,9 @@ touch your own CSS — and migrations that follow your app's `primary_key_type`.
|
|
|
447
447
|
|
|
448
448
|
MIT. If it saved you a subscription, a
|
|
449
449
|
[⭐](https://github.com/yshmarov/product_tours) is a fair trade.
|
|
450
|
+
|
|
451
|
+
## Rails edge and Ractors
|
|
452
|
+
|
|
453
|
+
Rails edge is tested alongside Rails 7.1–8.1. Experimental Ruby 4.0+ Ractor hosting
|
|
454
|
+
requires additional upstream dependencies and freezes configuration at startup.
|
|
455
|
+
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 |task|
|
|
7
7
|
task.libs << 'test'
|
|
8
|
-
task.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*')
|
|
8
|
+
task.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
|
|
@@ -10,6 +10,13 @@ module ProductTours
|
|
|
10
10
|
|
|
11
11
|
enum :status, STATUSES.index_by(&:itself), default: :draft
|
|
12
12
|
|
|
13
|
+
# Explicit methods work in non-main Ractors; Rails' enum-generated
|
|
14
|
+
# predicates and bang methods are currently unisolated closures.
|
|
15
|
+
def draft? = status_for_database == 'draft'
|
|
16
|
+
def draft! = update!(status: 'draft')
|
|
17
|
+
def published? = status_for_database == 'published'
|
|
18
|
+
def published! = update!(status: 'published')
|
|
19
|
+
|
|
13
20
|
has_rich_text :description if respond_to?(:has_rich_text)
|
|
14
21
|
|
|
15
22
|
has_one_attached :video, service: ProductTours.config.storage_service if defined?(::ActiveStorage)
|
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.
|
data/lib/product_tours/engine.rb
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
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
|
+
# Nokogiri's native parser is not Ractor safe. Keep Action Text page
|
|
210
|
+
# processing on the main Ractor, preserving Rails' renderer, sanitizer,
|
|
211
|
+
# callbacks and transaction behavior rather than duplicating their logic.
|
|
212
|
+
def self.main_page?(env)
|
|
213
|
+
return false unless @action_text && !Ractor.main?
|
|
214
|
+
|
|
215
|
+
mount = ProductTours.config.mount_path.chomp('/')
|
|
216
|
+
request_path = env['PATH_INFO'].to_s
|
|
217
|
+
return false unless request_path == mount || request_path.start_with?("#{mount}/")
|
|
218
|
+
|
|
219
|
+
path = request_path.delete_prefix(mount)
|
|
220
|
+
path.empty? || path == '/' || path == '/tours' || path.start_with?('/tours/') || path == '/widget/tour'
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def self.main_page(env)
|
|
224
|
+
headers = env.select do |_key, value|
|
|
225
|
+
Ractor.shareable?(value) || value.is_a?(String) || value.is_a?(Numeric)
|
|
226
|
+
end
|
|
227
|
+
shared_env = Ractor.make_shareable(headers, copy: true)
|
|
228
|
+
shared_input = Ractor.make_shareable(env['rack.input']&.read.to_s)
|
|
229
|
+
ActiveSupport::Ractors.on_main(Rails.application) do
|
|
230
|
+
request_env = shared_env.transform_values { |value| value.is_a?(String) ? value.dup : value }
|
|
231
|
+
request_env['rack.input'] = StringIO.new(shared_input)
|
|
232
|
+
request_env['rack.errors'] = $stderr
|
|
233
|
+
status, response_headers, body = call(request_env)
|
|
234
|
+
content = +''
|
|
235
|
+
begin
|
|
236
|
+
body.each { |part| content << part }
|
|
237
|
+
ensure
|
|
238
|
+
body.close if body.respond_to?(:close)
|
|
239
|
+
end
|
|
240
|
+
[status, response_headers, [content]]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
class Middleware
|
|
245
|
+
def initialize(app)
|
|
246
|
+
@app = app
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def call(env)
|
|
250
|
+
return RactorSupport.main_page(env) if RactorSupport.main_page?(env)
|
|
251
|
+
|
|
252
|
+
if (fallbacks = RactorSupport.fallbacks)
|
|
253
|
+
Fiber[:i18n_fallbacks] ||= fallbacks
|
|
254
|
+
env['rack.multipart.tempfile_factory'] ||= RactorSupport.method(:upload_tempfile) unless Ractor.main?
|
|
255
|
+
end
|
|
256
|
+
@app.call(env)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def self.fallbacks
|
|
261
|
+
@fallbacks
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
module StorageMetadata
|
|
265
|
+
def analyzed = read_store_attribute(:metadata, :analyzed)
|
|
266
|
+
def identified = read_store_attribute(:metadata, :identified)
|
|
267
|
+
def composed = read_store_attribute(:metadata, :composed)
|
|
268
|
+
|
|
269
|
+
def analyzed=(value)
|
|
270
|
+
write_store_attribute(:metadata, :analyzed, value)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def identified=(value)
|
|
274
|
+
write_store_attribute(:metadata, :identified, value)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def composed=(value)
|
|
278
|
+
write_store_attribute(:metadata, :composed, value)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
class StorageServices
|
|
283
|
+
def initialize(configurations)
|
|
284
|
+
@configurations = Ractor.make_shareable(configurations, copy: true)
|
|
285
|
+
freeze
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def fetch(name, &)
|
|
289
|
+
registry = if Ractor.main?
|
|
290
|
+
RactorSupport.storage_registry
|
|
291
|
+
else
|
|
292
|
+
Ractor[:engine_storage_services] ||= ActiveStorage::Service::Registry.new(@configurations)
|
|
293
|
+
end
|
|
294
|
+
registry.fetch(name, &)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def self.storage_registry
|
|
299
|
+
@storage_registry
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
module StorageChecksums
|
|
303
|
+
def checksum_implementation(**options)
|
|
304
|
+
implementation = super
|
|
305
|
+
if Ractor.main? || !implementation.respond_to?(:name) || !implementation.name&.start_with?('OpenSSL::Digest::')
|
|
306
|
+
return implementation
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
DigestFactory.new(implementation.name.delete_prefix('OpenSSL::Digest::'))
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
class DigestFactory
|
|
314
|
+
def initialize(algorithm)
|
|
315
|
+
@algorithm = algorithm.freeze
|
|
316
|
+
freeze
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def new
|
|
320
|
+
OpenSSL::Digest.new(@algorithm)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def file(io)
|
|
324
|
+
new.file(io)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def base64digest(value)
|
|
328
|
+
new.base64digest(value)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def self.prepare_redirects!
|
|
333
|
+
base = ActionController::Base
|
|
334
|
+
readers = Module.new
|
|
335
|
+
%i[raise_on_open_redirects action_on_open_redirect action_on_path_relative_redirect].each do |key|
|
|
336
|
+
source = base.method(key).source_location&.first
|
|
337
|
+
next unless source&.end_with?('/action_controller/metal/redirecting.rb')
|
|
338
|
+
|
|
339
|
+
value = Ractor.make_shareable(base.public_send(key), copy: true)
|
|
340
|
+
readers.define_method(key, Ractor.shareable_proc { Ractor.main? ? super() : value })
|
|
341
|
+
end
|
|
342
|
+
return if readers.instance_methods(false).empty?
|
|
343
|
+
|
|
344
|
+
base.prepend(readers)
|
|
345
|
+
base.singleton_class.prepend(readers)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def self.prepare_previewers!
|
|
349
|
+
ActiveStorage.previewers.each do |previewer|
|
|
350
|
+
readers = Module.new
|
|
351
|
+
previewer.methods.grep(/_exists\?\z/).each do |capability|
|
|
352
|
+
method = previewer.method(capability)
|
|
353
|
+
next unless method.arity.zero? && method.source_location&.first&.include?('/active_storage/previewer/')
|
|
354
|
+
|
|
355
|
+
readers.define_method(capability, Ractor.shareable_proc do
|
|
356
|
+
if Ractor.main?
|
|
357
|
+
super()
|
|
358
|
+
else
|
|
359
|
+
ActiveSupport::Ractors.on_main(self) { public_send(capability) }
|
|
360
|
+
end
|
|
361
|
+
end)
|
|
362
|
+
end
|
|
363
|
+
previewer.singleton_class.prepend(readers) unless readers.instance_methods(false).empty?
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def self.prepare_storage!
|
|
368
|
+
return unless defined?(ActiveStorage::Blob)
|
|
369
|
+
|
|
370
|
+
prepare_previewers!
|
|
371
|
+
ActiveStorage::Blob.prepend(StorageMetadata) unless ActiveStorage::Blob < StorageMetadata
|
|
372
|
+
%i[EXTENSIONS TYPE_EXTS TYPE_ALIASES TYPE_PARENTS ROOT_XML MAGIC].each do |name|
|
|
373
|
+
Ractor.make_shareable(Marcel.const_get(name)) if Marcel.const_defined?(name)
|
|
374
|
+
end
|
|
375
|
+
ActiveStorage::Service.prepend(StorageChecksums) unless ActiveStorage::Service < StorageChecksums
|
|
376
|
+
unless Ractor.shareable?(ActiveStorage::Blob.services)
|
|
377
|
+
@storage_registry = ActiveStorage::Blob.services
|
|
378
|
+
configurations = @storage_registry.instance_variable_get(:@configurations)
|
|
379
|
+
default_name = ActiveStorage::Blob.service.name
|
|
380
|
+
default_reader = Module.new
|
|
381
|
+
default_reader.define_method(:service, Ractor.shareable_proc do
|
|
382
|
+
Ractor.main? ? super() : services.fetch(default_name)
|
|
383
|
+
end)
|
|
384
|
+
ActiveStorage::Blob.singleton_class.prepend(default_reader)
|
|
385
|
+
ActiveStorage::Blob.services = StorageServices.new(configurations)
|
|
386
|
+
end
|
|
387
|
+
settings = ActiveStorage.class_variables.map { |name| name.to_s.delete_prefix('@@').to_sym }
|
|
388
|
+
settings.select! { |key| ActiveStorage.respond_to?(key) }
|
|
389
|
+
snapshot = settings.to_h { |key| [key, ActiveStorage.public_send(key)] }
|
|
390
|
+
checksum = snapshot[:checksum_implementation]
|
|
391
|
+
if checksum.is_a?(Class) && checksum.name&.start_with?('OpenSSL::Digest::')
|
|
392
|
+
snapshot[:checksum_implementation] = DigestFactory.new(checksum.name.delete_prefix('OpenSSL::Digest::'))
|
|
393
|
+
end
|
|
394
|
+
Ractor.make_shareable(snapshot)
|
|
395
|
+
readers = Module.new
|
|
396
|
+
settings.each do |key|
|
|
397
|
+
readers.define_method(key, Ractor.shareable_proc { Ractor.main? ? super() : snapshot.fetch(key) })
|
|
398
|
+
end
|
|
399
|
+
ActiveStorage.singleton_class.prepend(readers)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def self.prepare_framework!
|
|
403
|
+
CGI.singleton_class.prepend(UriUnescaping) unless CGI.singleton_class < UriUnescaping
|
|
404
|
+
Ractor.make_shareable(GlobalID.app) if defined?(GlobalID) && GlobalID.respond_to?(:app)
|
|
405
|
+
I18n.available_locales.each { |locale| I18n.transliterate('ractor', locale: locale) }
|
|
406
|
+
require 'i18n/ractorize'
|
|
407
|
+
require 'tempfile'
|
|
408
|
+
prepare_redirects!
|
|
409
|
+
%i[BASE36_ALPHABET BASE58_ALPHABET].each do |name|
|
|
410
|
+
Ractor.make_shareable(SecureRandom.const_get(name)) if SecureRandom.const_defined?(name)
|
|
411
|
+
end
|
|
412
|
+
uniqueness = ActiveRecord::Validations::UniquenessValidator
|
|
413
|
+
uniqueness.prepend(UniquenessCoverage) unless uniqueness < UniquenessCoverage
|
|
414
|
+
dependencies = ActiveSupport::Dependencies.singleton_class
|
|
415
|
+
dependencies.prepend(DependencyInterlock) unless dependencies < DependencyInterlock
|
|
416
|
+
prepare_storage!
|
|
417
|
+
Ractor.make_shareable(ActiveRecord::Associations::AssociationScope::INSTANCE)
|
|
418
|
+
Ractor.make_shareable(ActionDispatch::Http::Cache::Response::SPECIAL_KEYS)
|
|
419
|
+
freshness = ActionDispatch::Http::Cache::Request.singleton_class
|
|
420
|
+
freshness.prepend(CacheFreshness) unless freshness < CacheFreshness
|
|
421
|
+
fallbacks = I18n.fallbacks
|
|
422
|
+
I18n.available_locales.each { |locale| fallbacks[locale] }
|
|
423
|
+
@fallbacks = Ractor.make_shareable(fallbacks)
|
|
424
|
+
ActiveSupport::Digest.singleton_class.prepend(Digesting) unless ActiveSupport::Digest.singleton_class < Digesting
|
|
425
|
+
ActiveSupport::KeyGenerator.prepend(KeyGeneration) unless ActiveSupport::KeyGenerator < KeyGeneration
|
|
426
|
+
ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool|
|
|
427
|
+
adapter = pool.db_config.adapter_class.singleton_class
|
|
428
|
+
adapter.prepend(AdapterQuoting) unless adapter < AdapterQuoting
|
|
429
|
+
end
|
|
430
|
+
rescue LoadError => e
|
|
431
|
+
raise LoadError,
|
|
432
|
+
"Ractor requests need the upstream I18n Ractor support (see gemfiles/rails_ractor.gemfile): #{e.message}"
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def self.prepare!
|
|
436
|
+
@action_text = defined?(ActionText) ? true : false
|
|
437
|
+
# A cache store can reference the whole middleware stack and freeze
|
|
438
|
+
# other mounted engines before their own hooks run. Warm every engine's
|
|
439
|
+
# request environment before making any of that graph shareable.
|
|
440
|
+
Rails.application.railties.each do |railtie|
|
|
441
|
+
railtie.env_config if railtie.is_a?(Rails::Engine)
|
|
442
|
+
end
|
|
443
|
+
Engine.instance.env_config
|
|
444
|
+
prepare_framework!
|
|
445
|
+
ActiveRecord::Base.descendants.each do |model|
|
|
446
|
+
next if model.abstract_class? || !model.table_exists?
|
|
447
|
+
|
|
448
|
+
model.reflect_on_all_associations.each { |reflection| reflection.send(:inverse_name) }
|
|
449
|
+
prepare_attributes(model)
|
|
450
|
+
Ractor.make_shareable(model.defined_enums)
|
|
451
|
+
model.defined_enums.each do |attribute, mapping|
|
|
452
|
+
method_name = attribute.pluralize
|
|
453
|
+
source = model.method(method_name).source_location&.first
|
|
454
|
+
next unless source&.end_with?('/active_record/enum.rb')
|
|
455
|
+
|
|
456
|
+
model.define_singleton_method(method_name, Ractor.shareable_proc { mapping })
|
|
457
|
+
end
|
|
458
|
+
model.define_attribute_methods
|
|
459
|
+
if model.respond_to?(:attachment_reflections)
|
|
460
|
+
model.attachment_reflections.each_value(&:named_variants)
|
|
461
|
+
Ractor.make_shareable(model.attachment_reflections)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
ActionController::Base.descendants.each do |controller|
|
|
465
|
+
next unless controller.name&.start_with?('ProductTours::')
|
|
466
|
+
|
|
467
|
+
controller.prepend(LiveThreads) if defined?(ActionController::Live) && controller < ActionController::Live
|
|
468
|
+
renderer_source = controller.instance_method(:_render_with_renderer_json).source_location&.first
|
|
469
|
+
if renderer_source&.end_with?('/action_controller/metal/renderers.rb') && !(controller < JsonRendering)
|
|
470
|
+
controller.prepend(JsonRendering)
|
|
471
|
+
end
|
|
472
|
+
Ractor.make_shareable(controller.controller_name)
|
|
473
|
+
controller.etaggers = controller.etaggers.map { |callback| Ractor.shareable_proc(&callback) }.freeze
|
|
474
|
+
end
|
|
475
|
+
ProductTours.instance_variable_set(:@config, configuration(ProductTours.config))
|
|
476
|
+
%i[javascript dashboard_javascript dashboard_stylesheet fingerprint
|
|
477
|
+
dashboard_fingerprint dashboard_stylesheet_fingerprint].each do |method|
|
|
478
|
+
next unless Widget.respond_to?(method)
|
|
479
|
+
|
|
480
|
+
Widget.public_send(method)
|
|
481
|
+
end
|
|
482
|
+
Widget.instance_variables.each do |variable|
|
|
483
|
+
Widget.instance_variable_set(variable, Ractor.make_shareable(Widget.instance_variable_get(variable)))
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def self.configuration_value(value)
|
|
488
|
+
case value
|
|
489
|
+
when Proc
|
|
490
|
+
return value if Ractor.shareable?(value)
|
|
491
|
+
|
|
492
|
+
# Like Rails callbacks, isolated callbacks use arguments and constants,
|
|
493
|
+
# not their initializer's implicit self. Keep lambda/proc arity intact.
|
|
494
|
+
value.lambda? ? Ractor.shareable_lambda(&value) : Ractor.shareable_proc(&value)
|
|
495
|
+
when Hash
|
|
496
|
+
value.to_h { |key, item| [configuration_value(key), configuration_value(item)] }.freeze
|
|
497
|
+
when Array
|
|
498
|
+
value.map { |item| configuration_value(item) }.freeze
|
|
499
|
+
else
|
|
500
|
+
Ractor.make_shareable(value, copy: true)
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def self.configuration(original)
|
|
505
|
+
prepared = original.dup
|
|
506
|
+
original.instance_variables.each do |variable|
|
|
507
|
+
value = original.instance_variable_get(variable)
|
|
508
|
+
begin
|
|
509
|
+
shared = configuration_value(value)
|
|
510
|
+
prepared.instance_variable_set(variable, shared)
|
|
511
|
+
rescue Ractor::Error, TypeError => e
|
|
512
|
+
setting = variable.to_s.delete_prefix('@')
|
|
513
|
+
raise ArgumentError, "ProductTours.config.#{setting} cannot be shared with Ractors: #{e.message}"
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
Ractor.make_shareable(prepared)
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|
data/lib/product_tours/widget.rb
CHANGED
|
@@ -5,9 +5,9 @@ require 'json'
|
|
|
5
5
|
|
|
6
6
|
module ProductTours
|
|
7
7
|
module Widget
|
|
8
|
-
SOURCE = File.expand_path('widget.js', __dir__)
|
|
9
|
-
DASHBOARD_SOURCE = File.expand_path('dashboard.js', __dir__)
|
|
10
|
-
DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__)
|
|
8
|
+
SOURCE = File.expand_path('widget.js', __dir__).freeze
|
|
9
|
+
DASHBOARD_SOURCE = File.expand_path('dashboard.js', __dir__).freeze
|
|
10
|
+
DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__).freeze
|
|
11
11
|
RTL_LANGUAGES = %w[ar arc ckb dv fa ha he ks ku ps sd ug ur yi].freeze
|
|
12
12
|
|
|
13
13
|
class << self
|
data/lib/product_tours.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: product_tours
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -92,6 +92,7 @@ files:
|
|
|
92
92
|
- config/locales/product_tours.vi.yml
|
|
93
93
|
- config/locales/product_tours.zh-CN.yml
|
|
94
94
|
- config/routes.rb
|
|
95
|
+
- docs/ractors.md
|
|
95
96
|
- docs/screenshots/01-walkthrough.jpg
|
|
96
97
|
- docs/screenshots/02-dashboard.jpg
|
|
97
98
|
- docs/screenshots/03-mobile.jpg
|
|
@@ -107,6 +108,7 @@ files:
|
|
|
107
108
|
- lib/product_tours/dashboard.js
|
|
108
109
|
- lib/product_tours/engine.rb
|
|
109
110
|
- lib/product_tours/errors.rb
|
|
111
|
+
- lib/product_tours/ractor_support.rb
|
|
110
112
|
- lib/product_tours/seeds.rb
|
|
111
113
|
- lib/product_tours/version.rb
|
|
112
114
|
- lib/product_tours/video_metadata.rb
|
|
@@ -138,7 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
138
140
|
- !ruby/object:Gem::Version
|
|
139
141
|
version: '0'
|
|
140
142
|
requirements: []
|
|
141
|
-
rubygems_version: 4.0.
|
|
143
|
+
rubygems_version: 4.0.20
|
|
142
144
|
specification_version: 4
|
|
143
145
|
summary: Self-hosted product tours and video tutorials for Rails apps.
|
|
144
146
|
test_files: []
|