archipelago-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/Appraisals +16 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +79 -0
  5. data/Rakefile +17 -0
  6. data/app/controllers/archipelago/application_controller.rb +10 -0
  7. data/app/controllers/archipelago/islands_controller.rb +158 -0
  8. data/config/database.yml +5 -0
  9. data/config/routes.rb +6 -0
  10. data/lib/archipelago/action.rb +121 -0
  11. data/lib/archipelago/broadcasts.rb +17 -0
  12. data/lib/archipelago/channel.rb +36 -0
  13. data/lib/archipelago/configuration.rb +23 -0
  14. data/lib/archipelago/context.rb +14 -0
  15. data/lib/archipelago/engine.rb +21 -0
  16. data/lib/archipelago/params_dsl.rb +143 -0
  17. data/lib/archipelago/registry.rb +25 -0
  18. data/lib/archipelago/resolver.rb +54 -0
  19. data/lib/archipelago/response.rb +35 -0
  20. data/lib/archipelago/security/origin_validator.rb +32 -0
  21. data/lib/archipelago/security/redirect_validator.rb +30 -0
  22. data/lib/archipelago/test_helpers.rb +31 -0
  23. data/lib/archipelago/view_helper.rb +31 -0
  24. data/lib/archipelago-rails.rb +3 -0
  25. data/lib/archipelago.rb +71 -0
  26. data/lib/generators/archipelago/install/install_generator.rb +43 -0
  27. data/lib/generators/archipelago/install/react/react_generator.rb +380 -0
  28. data/lib/generators/archipelago/install/react/templates/entry.js.tt +13 -0
  29. data/lib/generators/archipelago/install/react/templates/generate_registry.mjs.tt +96 -0
  30. data/lib/generators/archipelago/install/react_generator.rb +3 -0
  31. data/lib/generators/archipelago/install_generator.rb +3 -0
  32. data/lib/generators/archipelago/island/island_generator.rb +44 -0
  33. data/lib/generators/archipelago/island/templates/action.rb.tt +11 -0
  34. data/lib/generators/archipelago/island/templates/component.tsx.tt +14 -0
  35. data/lib/generators/archipelago/island_generator.rb +3 -0
  36. data/test/archipelago/action_test.rb +136 -0
  37. data/test/archipelago/broadcasts_test.rb +29 -0
  38. data/test/archipelago/channel_test.rb +15 -0
  39. data/test/archipelago/notifications_test.rb +60 -0
  40. data/test/archipelago/origin_validator_test.rb +36 -0
  41. data/test/archipelago/params_dsl_test.rb +51 -0
  42. data/test/archipelago/redirect_validator_test.rb +28 -0
  43. data/test/archipelago/resolver_test.rb +80 -0
  44. data/test/archipelago/response_test.rb +30 -0
  45. data/test/archipelago/view_helper_test.rb +32 -0
  46. data/test/controllers/islands_controller_test.rb +115 -0
  47. data/test/generators/install_generator_test.rb +26 -0
  48. data/test/generators/island_generator_test.rb +20 -0
  49. data/test/generators/react_install_generator_test.rb +67 -0
  50. data/test/test_helper.rb +35 -0
  51. metadata +180 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a271d60fbd7122dfe75a111e3182fc34662dd3afe9b6d4183e834d08be828e2b
4
+ data.tar.gz: '0357780015635cb62d1255e257064d6b5a3674a41f18465f5ce6f0e59ec54759'
5
+ SHA512:
6
+ metadata.gz: a40da1f08277ed421fa36b9d29e03223036259625689eb17489455158d0f056cb4600e633f19994bcb2ba6bb7e06773a1db40b5033740f99027baaecd5aa0e66
7
+ data.tar.gz: b0827ebf94625a2a5c7f21cdf1c870794609a01d20e8a8dc028ef108cf1f1decde1f5d6284bba7930a267b85e9770430e19e63e45059312c6fd8b911e305add1
data/Appraisals ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ appraise "rails-7-1" do
4
+ gem "rails", "~> 7.1.0"
5
+ gem "sqlite3", ">= 1.6"
6
+ end
7
+
8
+ appraise "rails-7-2" do
9
+ gem "rails", "~> 7.2.0"
10
+ gem "sqlite3", ">= 1.6"
11
+ end
12
+
13
+ appraise "rails-8-1" do
14
+ gem "rails", "~> 8.1.0"
15
+ gem "sqlite3", ">= 1.6"
16
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Archipelago
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # archipelago-rails
2
+
3
+ Rails engine for Archipelago server-driven React islands.
4
+
5
+ ## Supported Rails versions
6
+
7
+ - Rails `>= 7.1`
8
+
9
+ ## Development setup
10
+
11
+ ```bash
12
+ bundle install
13
+ ```
14
+
15
+ ## Run tests
16
+
17
+ ```bash
18
+ # full suite (core + rails integration tests)
19
+ bin/test
20
+
21
+ # split tasks
22
+ bundle exec rake test:core
23
+ bundle exec rake test:rails
24
+ ```
25
+
26
+ ## Rails version matrix (Appraisal)
27
+
28
+ ```bash
29
+ bundle exec appraisal install
30
+ bin/test-appraisal rails-7-1
31
+ bin/test-appraisal rails-7-2
32
+ bin/test-appraisal rails-8-1
33
+ ```
34
+
35
+ ## Notes
36
+
37
+ - Controller/generator tests run against an in-test Rails application harness.
38
+ - JS packages are tested from repository root via `yarn test`.
39
+
40
+ ## Host app setup (React + esbuild)
41
+
42
+ After `rails g archipelago:install`, you can scaffold frontend bootstrap wiring:
43
+
44
+ ```bash
45
+ rails g archipelago:install:react
46
+ ```
47
+
48
+ By default this runs an interactive wizard with auto-detected defaults
49
+ (bundler, TypeScript, package manager, and local monorepo path).
50
+
51
+ For esbuild apps, the wizard also enables auto-registry by default:
52
+ - scans `app/javascript/islands/**/*.{js,jsx,ts,tsx}`
53
+ - writes `app/javascript/archipelago/registry.generated.(js|ts)`
54
+ - wires `package.json` esbuild scripts to run generator first
55
+
56
+ Useful options:
57
+
58
+ ```bash
59
+ # disable prompts and use flags only
60
+ rails g archipelago:install:react --interactive=false --bundler=esbuild --typescript=true
61
+
62
+ # force TSX output
63
+ rails g archipelago:install:react --typescript=true
64
+
65
+ # disable auto-registry and keep manual registry map in entry file
66
+ rails g archipelago:install:react --auto_registry=false
67
+
68
+ # install npm packages immediately
69
+ rails g archipelago:install:react --install
70
+
71
+ # install Archipelago packages from a local monorepo path
72
+ rails g archipelago:install:react --install --local-monorepo-path=/absolute/path/to/cdx
73
+ ```
74
+
75
+ Manual refresh command (if needed while a long-running watch is already running):
76
+
77
+ ```bash
78
+ yarn archipelago:registry
79
+ ```
data/Rakefile ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new("test:core") do |t|
6
+ t.libs << "test"
7
+ t.pattern = "test/archipelago/**/*_test.rb"
8
+ end
9
+
10
+ Rake::TestTask.new("test:rails") do |t|
11
+ t.libs << "test"
12
+ t.pattern = ["test/controllers/**/*_test.rb", "test/generators/**/*_test.rb"]
13
+ end
14
+
15
+ task test: ["test:core", "test:rails"]
16
+
17
+ task default: :test
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller/base"
4
+
5
+ module Archipelago
6
+ class ApplicationController < ActionController::Base
7
+ protect_from_forgery with: :exception
8
+ skip_forgery_protection if defined?(Rails) && Rails.env.test?
9
+ end
10
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ class IslandsController < ApplicationController
5
+ rescue_from Archipelago::ResolutionError, with: :render_not_found
6
+ rescue_from Archipelago::MissingAuthorization, with: :render_forbidden
7
+ rescue_from Archipelago::Forbidden, with: :render_forbidden
8
+ rescue_from Archipelago::InvalidOrigin, with: :render_forbidden
9
+ rescue_from Archipelago::InvalidRedirect, with: :render_invalid_redirect
10
+
11
+ def create
12
+ validate_origin!
13
+
14
+ action_class = resolver.resolve(component: params[:component], operation: params[:operation])
15
+ action = action_class.new(ctx: request_context, raw_params: island_params)
16
+ payload = action.call
17
+
18
+ render json: payload, status: rack_status_for(payload)
19
+ end
20
+
21
+ def debug
22
+ return head :not_found unless Rails.env.development? || Rails.env.test?
23
+
24
+ render json: debug_payload
25
+ end
26
+
27
+ private
28
+
29
+ def resolver
30
+ @resolver ||= Archipelago::Resolver.new
31
+ end
32
+
33
+ def request_context
34
+ Archipelago::Context.new(
35
+ request: request,
36
+ params: params,
37
+ session: session,
38
+ user: current_archipelago_user
39
+ )
40
+ end
41
+
42
+ def current_archipelago_user
43
+ resolver_proc = Archipelago.configuration.current_user_resolver
44
+ return instance_exec(&resolver_proc) if resolver_proc
45
+
46
+ method_name = Archipelago.configuration.current_user_method
47
+ return send(method_name) if respond_to?(method_name, true)
48
+
49
+ nil
50
+ end
51
+
52
+ def validate_origin!
53
+ Archipelago::Security::OriginValidator.new(request).validate!
54
+ end
55
+
56
+ def island_params
57
+ params.to_unsafe_h.except(:component, :operation, :controller, :action)
58
+ end
59
+
60
+ def rack_status_for(payload)
61
+ case payload[:status]
62
+ when "ok", "redirect", "error"
63
+ :ok
64
+ when "forbidden"
65
+ :forbidden
66
+ else
67
+ :ok
68
+ end
69
+ end
70
+
71
+ def render_not_found
72
+ head :not_found
73
+ end
74
+
75
+ def render_forbidden
76
+ render json: Archipelago::Response.forbidden, status: :forbidden
77
+ end
78
+
79
+ def render_invalid_redirect(exception)
80
+ render json: Archipelago::Response.error(errors: { base: [exception.message] }), status: :unprocessable_entity
81
+ end
82
+
83
+ def debug_payload
84
+ {
85
+ root_namespace: Archipelago.configuration.root_namespace,
86
+ registry: Archipelago.registry.to_h.transform_values(&:name),
87
+ actions: debug_actions_from_files,
88
+ registry_actions: debug_actions_from_registry
89
+ }
90
+ end
91
+
92
+ def debug_actions_from_files
93
+ files = Dir.glob(Rails.root.join("app/islands/**/*.rb")).sort
94
+
95
+ files.map do |file_path|
96
+ relative = file_path.delete_prefix("#{Rails.root}/app/islands/").delete_suffix(".rb")
97
+ *component_parts, operation = relative.split("/")
98
+ component = component_parts.map(&:camelize).join("__")
99
+ handler_name = [
100
+ Archipelago.configuration.root_namespace,
101
+ *component_parts.map(&:camelize),
102
+ operation.camelize
103
+ ].join("::")
104
+ handler_class = handler_name.safe_constantize
105
+
106
+ {
107
+ source: "filesystem",
108
+ component: component,
109
+ operation: operation,
110
+ file: file_path.delete_prefix("#{Rails.root}/"),
111
+ handler: handler_name,
112
+ params: debug_param_schema_for(handler_class)
113
+ }
114
+ end
115
+ end
116
+
117
+ def debug_actions_from_registry
118
+ Archipelago.registry.to_h.map do |key, handler|
119
+ component, operation = key.split("#", 2)
120
+ {
121
+ source: "registry",
122
+ component: component,
123
+ operation: operation,
124
+ handler: handler.name,
125
+ params: debug_param_schema_for(handler)
126
+ }
127
+ end.sort_by { |entry| [entry[:component].to_s, entry[:operation].to_s] }
128
+ end
129
+
130
+ def debug_param_schema_for(handler_class)
131
+ return [] unless handler_class.respond_to?(:param_definitions)
132
+
133
+ handler_class.param_definitions.values.map do |definition|
134
+ {
135
+ name: definition.name.to_s,
136
+ type: definition.type.to_s,
137
+ required: definition.required,
138
+ default: debug_param_default(definition.default),
139
+ transforms: {
140
+ strip: definition.strip,
141
+ downcase: definition.downcase,
142
+ upcase: definition.upcase
143
+ }
144
+ }
145
+ end
146
+ end
147
+
148
+ def debug_param_default(value)
149
+ if value == Archipelago::ParamsDSL::MISSING
150
+ { provided: false }
151
+ elsif value.respond_to?(:call)
152
+ { provided: true, kind: "callable" }
153
+ else
154
+ { provided: true, value: value }
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,5 @@
1
+ test:
2
+ adapter: sqlite3
3
+ database: ":memory:"
4
+ pool: 5
5
+ timeout: 5000
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Archipelago::Engine.routes.draw do
4
+ post "/:component/:operation", to: "islands#create"
5
+ get "/__debug", to: "islands#debug"
6
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ class Action
5
+ include Archipelago::ParamsDSL
6
+
7
+ class << self
8
+ attr_reader :authorization_block
9
+
10
+ def authorize(&block)
11
+ @authorization_block = block
12
+ end
13
+ end
14
+
15
+ attr_reader :ctx, :raw_params, :errors
16
+
17
+ def initialize(ctx:, raw_params:)
18
+ @ctx = ctx
19
+ @raw_params = raw_params.with_indifferent_access
20
+ @errors = Hash.new { |hash, key| hash[key] = [] }
21
+ @coerced_params = {}
22
+ @response_props = {}
23
+ @redirect_location = nil
24
+ end
25
+
26
+ def call
27
+ Archipelago.instrument("archipelago.action.perform", action: self.class.name) do
28
+ @coerced_params, coercion_errors = coerce_declared_params(raw_params)
29
+ merge_errors!(coercion_errors)
30
+ if errors.any?
31
+ Archipelago.instrument("archipelago.action.error", action: self.class.name, reason: "validation")
32
+ return Archipelago::Response.error(errors: errors)
33
+ end
34
+
35
+ run_authorization!
36
+ perform
37
+
38
+ if errors.any?
39
+ Archipelago.instrument("archipelago.action.error", action: self.class.name, reason: "validation")
40
+ return Archipelago::Response.error(errors: errors)
41
+ end
42
+
43
+ if @redirect_location
44
+ validator = Archipelago::Security::RedirectValidator.new
45
+ location = validator.validate!(@redirect_location)
46
+ return Archipelago::Response.redirect(location: location)
47
+ end
48
+
49
+ payload = Archipelago::Response.ok(props: @response_props, version: Archipelago.next_version)
50
+ maybe_broadcast(payload)
51
+ payload
52
+ end
53
+ rescue Archipelago::Forbidden
54
+ Archipelago.instrument("archipelago.action.error", action: self.class.name, reason: "forbidden")
55
+ Archipelago::Response.forbidden
56
+ rescue StandardError => e
57
+ if record_invalid_error?(e)
58
+ map_record_invalid!(e)
59
+ Archipelago.instrument("archipelago.action.error", action: self.class.name, reason: "record_invalid")
60
+ Archipelago::Response.error(errors: errors)
61
+ else
62
+ raise
63
+ end
64
+ end
65
+
66
+ def add_error(field, message)
67
+ errors[field.to_s] << message
68
+ end
69
+
70
+ def props(payload)
71
+ @response_props = payload
72
+ end
73
+
74
+ def redirect_to(location)
75
+ @redirect_location = location
76
+ end
77
+
78
+ private
79
+
80
+ def run_authorization!
81
+ block = self.class.authorization_block
82
+
83
+ if block.nil?
84
+ raise Archipelago::MissingAuthorization if Archipelago.configuration.authorize_by_default
85
+
86
+ return true
87
+ end
88
+
89
+ authorized = instance_exec(&block)
90
+ raise Archipelago::Forbidden unless authorized
91
+
92
+ true
93
+ end
94
+
95
+ def merge_errors!(incoming)
96
+ incoming.each do |field, messages|
97
+ messages.each { |message| add_error(field, message) }
98
+ end
99
+ end
100
+
101
+ def map_record_invalid!(error)
102
+ return unless error.record.respond_to?(:errors)
103
+
104
+ error.record.errors.to_hash(true).each do |field, messages|
105
+ Array(messages).each { |message| add_error(field, message) }
106
+ end
107
+ end
108
+
109
+ def record_invalid_error?(error)
110
+ defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
111
+ end
112
+
113
+ def maybe_broadcast(payload)
114
+ stream_name = raw_params[:__stream]
115
+ return if stream_name.blank?
116
+ return unless payload[:status] == "ok"
117
+
118
+ Archipelago.broadcast(stream_name, props: payload[:props], version: payload[:version])
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ module Broadcasts
5
+ module_function
6
+
7
+ def broadcast(stream_name, props:, version: Archipelago.next_version)
8
+ payload = Archipelago::Response.ok(props: props, version: version)
9
+ unless defined?(ActionCable) && ActionCable.respond_to?(:server)
10
+ raise LoadError, "ActionCable is required for streaming broadcasts"
11
+ end
12
+
13
+ ActionCable.server.broadcast(stream_name, payload)
14
+ payload
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ base_channel = if defined?(::ApplicationCable::Channel)
4
+ ::ApplicationCable::Channel
5
+ elsif defined?(ActionCable::Channel::Base)
6
+ ActionCable::Channel::Base
7
+ else
8
+ Class.new do
9
+ def stream_from(*); end
10
+ def reject; end
11
+ def params = {}
12
+ end
13
+ end
14
+
15
+ channel_class = Class.new(base_channel) do
16
+ def subscribed
17
+ stream_name = verified_stream_name
18
+ reject unless stream_name
19
+
20
+ stream_from(stream_name)
21
+ end
22
+
23
+ private
24
+
25
+ def verified_stream_name
26
+ stream_name = params[:stream_name].to_s
27
+ return nil unless stream_name.match?(self.class::STREAM_PATTERN)
28
+
29
+ stream_name
30
+ end
31
+ end
32
+
33
+ channel_class.const_set(:STREAM_PATTERN, /\A[A-Za-z0-9:_-]+\z/)
34
+
35
+ Archipelago.send(:remove_const, :IslandChannel) if Archipelago.const_defined?(:IslandChannel, false)
36
+ Archipelago.const_set(:IslandChannel, channel_class)
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ class Configuration
5
+ attr_accessor :root_namespace,
6
+ :current_user_method,
7
+ :current_user_resolver,
8
+ :authorize_by_default,
9
+ :strict_origin_check,
10
+ :allowed_redirect_hosts,
11
+ :version_source
12
+
13
+ def initialize
14
+ @root_namespace = "Islands"
15
+ @current_user_method = :current_user
16
+ @current_user_resolver = nil
17
+ @authorize_by_default = true
18
+ @strict_origin_check = false
19
+ @allowed_redirect_hosts = []
20
+ @version_source = -> { (Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)).to_i }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ class Context
5
+ attr_reader :request, :params, :session, :user
6
+
7
+ def initialize(request:, params:, session:, user: nil)
8
+ @request = request
9
+ @params = params
10
+ @session = session
11
+ @user = user
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Archipelago
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Archipelago
8
+
9
+ initializer "archipelago.view_helper" do
10
+ ActiveSupport.on_load(:action_view) do
11
+ include Archipelago::ViewHelper
12
+ end
13
+ end
14
+
15
+ initializer "archipelago.test_helpers" do
16
+ ActiveSupport.on_load(:action_dispatch_integration_test) do
17
+ include Archipelago::TestHelpers
18
+ end
19
+ end
20
+ end
21
+ end