studio-engine 0.9.0 → 0.10.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: 31ed2941026e5bf0114872ba16dd7a9b94b1a472d5407ff82ec604ba2e659962
4
- data.tar.gz: 1fda4b1328315323b8afcee644a68acc8678e756762a88b289ea10e0fc46a6cf
3
+ metadata.gz: 20b0e58f17421ce741452b329000bc657f0dba0304198d7dc1fb59cb448bfc6d
4
+ data.tar.gz: d4446a5bad4b0b744e54ad7d4f275cf77455a68f2cf3289d6d9c472ca1f3c548
5
5
  SHA512:
6
- metadata.gz: 6431e1dafbd0a614e1c0a6abe25004ab3888a3d1e7c263ab97d7883e10bcedde60da03f16c98b262d9926173749343b00c33d6a5580db9fc299c8b1b355a9665
7
- data.tar.gz: 7513d2735c2535ed520530eab4adc0b1ce60de617488e4fa934e43beb31ef330e7e49c8837e3310aa92467559dbabf714fab43b81d86a0d52cebce8f85674e35
6
+ metadata.gz: b15b1f884815e8afbad3d9aa181589230193942970d62457075d08c47521c967a005025560e665b436ceb8a6c78f425688430f122cad9a7f21e8a4432bd4ca22
7
+ data.tar.gz: 5a6c6817018309298bb3a8eb686f61d7e93d9718d5b8ce5b5a8e642efc82f99695d6bf0c4f23b9421fa8c65481722e9164934c7cbe47f254e3abb482d7ea42ed
data/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.10.0 — 2026-06-24
8
+
9
+ ### Added
10
+ - **Shared websocket / Redis primitive** (`docs/CABLE.md`) — one place for the
11
+ setup every host app's realtime needs, extracted after a SEV-1 (a host app
12
+ shipped an ActionCable channel with no `redis` gem and no TLS cable config; the
13
+ broadcast raised `Gem::LoadError` — a `ScriptError`, not a `StandardError` — which
14
+ escaped a `rescue StandardError` and 500'd every task write).
15
+ - **`redis` is now an engine dependency** (plus `turbo-rails`), so a consuming app
16
+ can never hit that `Gem::LoadError` again.
17
+ - **`Studio::Redis`** — the single source of Redis connection truth for `cable.yml`,
18
+ the `cache_store`, and Sidekiq: `.url`, `.tls?`, and `.options(**extra)`, which
19
+ auto-applies `ssl_params: { verify_mode: VERIFY_NONE }` for Heroku's `rediss://`
20
+ self-signed TLS (the gotcha that silently drops every broadcast).
21
+ - **`Studio::Cable.safe_broadcast { … }`** — best-effort broadcast guard that
22
+ catches `StandardError` **and** `ScriptError`, so a cable failure never breaks
23
+ the caller (the exact hole that caused the SEV-1).
24
+ - **`Studio::Broadcastable`** — model concern with safe Turbo-Streams wrappers
25
+ (`safe_broadcast_replace_to`, `_append_to`, `_prepend_to`, `_update_to`,
26
+ `_remove_to`) every app should broadcast through.
27
+
7
28
  ## 0.9.0 — 2026-06-23
8
29
 
9
30
  ### Added
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Mixin for ActiveRecord models that broadcast Turbo Streams. It wraps
5
+ # turbo-rails' broadcast_*_to methods in Studio::Cable.safe_broadcast, so a cable
6
+ # failure (a Redis hiccup, a missing/misconfigured adapter) can NEVER break the
7
+ # model save / after_commit that triggered the broadcast — the SEV-1 guard, in
8
+ # one place. The host model already has the raw broadcast_*_to methods (turbo-rails
9
+ # includes Turbo::Broadcastable into ActiveRecord::Base); these are the SAFE
10
+ # variants every app should broadcast through.
11
+ #
12
+ # class Task < ApplicationRecord
13
+ # include Studio::Broadcastable
14
+ # after_create_commit { safe_broadcast_replace_to [:board], target: "card_#{id}",
15
+ # partial: "tasks/card", locals: { task: self } }
16
+ # end
17
+ module Broadcastable
18
+ extend ActiveSupport::Concern
19
+
20
+ def safe_broadcast_replace_to(*args, **kwargs, &block)
21
+ Studio::Cable.safe_broadcast { broadcast_replace_to(*args, **kwargs, &block) }
22
+ end
23
+
24
+ def safe_broadcast_update_to(*args, **kwargs, &block)
25
+ Studio::Cable.safe_broadcast { broadcast_update_to(*args, **kwargs, &block) }
26
+ end
27
+
28
+ def safe_broadcast_append_to(*args, **kwargs, &block)
29
+ Studio::Cable.safe_broadcast { broadcast_append_to(*args, **kwargs, &block) }
30
+ end
31
+
32
+ def safe_broadcast_prepend_to(*args, **kwargs, &block)
33
+ Studio::Cable.safe_broadcast { broadcast_prepend_to(*args, **kwargs, &block) }
34
+ end
35
+
36
+ def safe_broadcast_remove_to(*args, **kwargs, &block)
37
+ Studio::Cable.safe_broadcast { broadcast_remove_to(*args, **kwargs, &block) }
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "redis"
4
+
5
+ module Studio
6
+ # Shared ActionCable / Turbo-Streams helpers for host apps.
7
+ module Cable
8
+ # Run a broadcast best-effort: a cable failure must NEVER break the caller (the
9
+ # model save / task write that triggered it). ScriptError is caught ON PURPOSE —
10
+ # a missing or misconfigured cable adapter raises Gem::LoadError, which is a
11
+ # ScriptError, NOT a StandardError. A plain `rescue StandardError` let exactly
12
+ # that escape an after_commit and 500 every task write in production (the SEV-1
13
+ # this primitive exists to prevent). Failures are captured to ErrorLog when the
14
+ # host defines it, else logged. Returns nil on failure.
15
+ #
16
+ # The never-raise guarantee is ABSOLUTE: even logging the failure must not break
17
+ # the caller. ErrorLog.capture! writes to the DB and can itself raise (e.g.
18
+ # ActiveRecord::NoDatabaseError when the DB is down), so the logging is wrapped
19
+ # in its own rescue — the guard cannot be defeated by its own error path.
20
+ def self.safe_broadcast
21
+ yield
22
+ rescue StandardError, ScriptError => e
23
+ begin
24
+ if defined?(ErrorLog) && ErrorLog.respond_to?(:capture!)
25
+ ErrorLog.capture!(e)
26
+ elsif defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
27
+ Rails.logger.warn("[studio-cable] broadcast failed (non-fatal): #{e.class}: #{e.message}")
28
+ end
29
+ rescue StandardError, ScriptError
30
+ nil # logging is best-effort too — the never-raise guarantee is absolute
31
+ end
32
+ nil
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Studio
6
+ # Single source of Redis connection truth for EVERY Redis client in a host app —
7
+ # ActionCable (config/cable.yml), the Rails cache_store, and Sidekiq. It bakes in
8
+ # the Heroku gotcha so no app re-hits it:
9
+ #
10
+ # Heroku Redis serves rediss:// (TLS) with a SELF-SIGNED cert. redis-client
11
+ # verifies peer certs by default, so the connection is silently REJECTED — and
12
+ # because ActionCable's pubsub failure is silent (the /cable socket still
13
+ # upgrades to 101), broadcasts simply never reach subscribers; the cache no-ops.
14
+ # The fix is ssl_params verify_mode VERIFY_NONE, applied automatically here for
15
+ # any rediss:// URL.
16
+ #
17
+ # Usage:
18
+ # cable.yml -> Studio::Redis.options (url + ssl_params)
19
+ # cache_store -> Studio::Redis.options(namespace: "x", expires_in: 90.minutes)
20
+ # sidekiq -> Studio::Redis.options
21
+ module Redis
22
+ # The Redis URL from the environment, with a local-dev fallback.
23
+ def self.url(default = "redis://localhost:6379/1")
24
+ ENV.fetch("REDIS_URL", default)
25
+ end
26
+
27
+ # True when the endpoint is TLS (Heroku Redis), i.e. a rediss:// URL.
28
+ def self.tls?(redis_url = url)
29
+ redis_url.to_s.start_with?("rediss://")
30
+ end
31
+
32
+ # Connection options for a Redis client: the url, plus — for a TLS endpoint —
33
+ # the self-signed-cert handling Heroku requires. Caller extras (namespace,
34
+ # reconnect_attempts, error_handler, …) merge through untouched.
35
+ def self.options(redis_url: url, **extra)
36
+ opts = { url: redis_url }.merge(extra)
37
+ opts[:ssl_params] = { verify_mode: OpenSSL::SSL::VERIFY_NONE } if tls?(redis_url)
38
+ opts
39
+ end
40
+ end
41
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.9.0"
2
+ VERSION = "0.10.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -10,6 +10,8 @@ require "studio/link_token"
10
10
  require "studio/email"
11
11
  require "studio/email_smoke"
12
12
  require "studio/mail_transport"
13
+ require "studio/redis"
14
+ require "studio/cable"
13
15
 
14
16
  module Studio
15
17
  mattr_accessor :app_name, default: "Studio"
@@ -28,4 +28,9 @@ Gem::Specification.new do |spec|
28
28
  spec.add_dependency "aws-sdk-s3", "~> 1.218"
29
29
  spec.add_dependency "mini_magick", "~> 5.0"
30
30
  spec.add_dependency "resend", "~> 1.1"
31
+ # Realtime: the redis cable/cache/Sidekiq adapter (Studio::Redis) + Turbo Streams
32
+ # broadcasting (Studio::Broadcastable). `redis` is the dependency whose ABSENCE
33
+ # 500'd a host app's task board — declaring it here makes that impossible to repeat.
34
+ spec.add_dependency "redis", ">= 4.0.1"
35
+ spec.add_dependency "turbo-rails", ">= 1.0"
31
36
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-24 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -126,6 +125,34 @@ dependencies:
126
125
  - - "~>"
127
126
  - !ruby/object:Gem::Version
128
127
  version: '1.1'
128
+ - !ruby/object:Gem::Dependency
129
+ name: redis
130
+ requirement: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: 4.0.1
135
+ type: :runtime
136
+ prerelease: false
137
+ version_requirements: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: 4.0.1
142
+ - !ruby/object:Gem::Dependency
143
+ name: turbo-rails
144
+ requirement: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">="
147
+ - !ruby/object:Gem::Version
148
+ version: '1.0'
149
+ type: :runtime
150
+ prerelease: false
151
+ version_requirements: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: '1.0'
129
156
  description: Studio Engine is a non-isolated Rails engine that ships an opinionated
130
157
  authentication + SSO contract, a polymorphic ErrorLog model, a Sluggable concern,
131
158
  a 7-role dynamic theme system with CSS-custom-property generation, and an S3-backed
@@ -169,6 +196,7 @@ files:
169
196
  - app/mailers/application_mailer.rb
170
197
  - app/mailers/user_mailer.rb
171
198
  - app/models/concerns/sluggable.rb
199
+ - app/models/concerns/studio/broadcastable.rb
172
200
  - app/models/current.rb
173
201
  - app/models/error_log.rb
174
202
  - app/models/image_cache.rb
@@ -239,6 +267,7 @@ files:
239
267
  - db/migrate/20260623130000_create_studio_enumerals.rb
240
268
  - lib/studio-engine.rb
241
269
  - lib/studio.rb
270
+ - lib/studio/cable.rb
242
271
  - lib/studio/color_scale.rb
243
272
  - lib/studio/email.rb
244
273
  - lib/studio/email_smoke.rb
@@ -246,6 +275,7 @@ files:
246
275
  - lib/studio/image_cache.rb
247
276
  - lib/studio/link_token.rb
248
277
  - lib/studio/mail_transport.rb
278
+ - lib/studio/redis.rb
249
279
  - lib/studio/s3.rb
250
280
  - lib/studio/theme_resolver.rb
251
281
  - lib/studio/ui_primitives.rb
@@ -263,7 +293,6 @@ metadata:
263
293
  source_code_uri: https://github.com/amcritchie/studio-engine/tree/main
264
294
  bug_tracker_uri: https://github.com/amcritchie/studio-engine/issues
265
295
  changelog_uri: https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md
266
- post_install_message:
267
296
  rdoc_options: []
268
297
  require_paths:
269
298
  - lib
@@ -278,8 +307,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
278
307
  - !ruby/object:Gem::Version
279
308
  version: '0'
280
309
  requirements: []
281
- rubygems_version: 3.5.11
282
- signing_key:
310
+ rubygems_version: 4.0.9
283
311
  specification_version: 4
284
312
  summary: Shared Rails engine providing auth, SSO, error logging, theming, and S3-backed
285
313
  image caching