finesse 0.0.1 → 0.1.0.beta1

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: f788a2956c44f5c8691e1f860f1d8087c3ca98959c227a37a6bb1134128060e9
4
- data.tar.gz: ba6afdc9b27030849ca07267c465bba8a890e5a9eba973625fbd73ea7e9400c2
3
+ metadata.gz: 4063a626a91837c3ad59b9dd687af6548bcbb16ada46f5b3c01e768bea5dc73b
4
+ data.tar.gz: 25cebe7a2f3a2206ae2704d44a78943b92c1e45b4f4b68d7e3884d9d7f8a50e7
5
5
  SHA512:
6
- metadata.gz: '093eaf11240daad7ee22c45c61da3e636b85cad7a8834b605d929ce9caa755ae49f1f639db5d3a938a8c926e99e5bd403df944d617f30a41501e3810c9c8ecd5'
7
- data.tar.gz: 7e9a02dceff25be3138042276674d5ac1391e3ab2988151af1da93e2c082214b82809f80e3be3cecf2d3d2fdf39baf49dad3573ae171d96406800d1932af2bf2
6
+ metadata.gz: '068fd87c382d4ca33b17a0687ebed2d742c234bd23f017bb05e83a6728ecc495e8b98f718b09d6e21ee10cc14f45292f709f7abdcb95f0a8ad77666e367832d9'
7
+ data.tar.gz: dc5a0732991394c1cea7abb149e639c12b07b26749923ca281d4f1533136994657fa883fec434f93fb175821b8d240b0ae4331ddebc96c4d52607a375323432e
data/README.md ADDED
@@ -0,0 +1,211 @@
1
+ <p align="center">
2
+ <img src="finesse-logo.png" alt="Finesse" width="200">
3
+ </p>
4
+
5
+ # Finesse
6
+
7
+ Finesse replaces ActionCable's WebSocket transport with a lightweight Go binary that polls SolidCable's SQLite table and streams Turbo updates to browsers via Server-Sent Events (SSE). No WebSocket infrastructure needed — just a single binary alongside your Rails app.
8
+
9
+ ## Prerequisites
10
+
11
+ Finesse currently supports **SolidCable with SQLite** only. Your Rails app needs:
12
+
13
+ - **[SolidCable](https://github.com/rails/solid_cable)** as your ActionCable adapter, with a `cable:` database configured in `config/database.yml`
14
+ - **[turbo-rails](https://github.com/hotwired/turbo-rails)** (>= 2.0) — Finesse uses Turbo's signed stream tokens for authentication
15
+
16
+ PostgreSQL and other adapters are on the [roadmap](#roadmap).
17
+
18
+ ## Quick Start
19
+
20
+ 1. Add to your Gemfile:
21
+ ```ruby
22
+ gem "finesse"
23
+ ```
24
+
25
+ 2. Install:
26
+ ```sh
27
+ bundle install
28
+ ```
29
+
30
+ 3. Add to your `Procfile.dev`:
31
+ ```
32
+ sse: bundle exec finesse --allow-origin http://localhost:3000
33
+ ```
34
+ For multiple origins (e.g., accessing from another device on your network), repeat the flag:
35
+ ```
36
+ sse: bundle exec finesse --allow-origin http://localhost:3000 --allow-origin http://192.168.1.10:3000
37
+ ```
38
+
39
+ 4. Start your app:
40
+ ```sh
41
+ bin/dev
42
+ ```
43
+
44
+ ## How It Works
45
+
46
+ ```
47
+ Browser <turbo-stream-source> ──GET /events?signed_stream=…──> Finesse (Go binary, :4000)
48
+
49
+ Rails app ──broadcast_append_to──> SolidCable ──INSERT──> SQLite ←──POLL─┘
50
+ ```
51
+
52
+ Finesse polls the `solid_cable_messages` table for new rows and streams them as SSE events to connected browsers. It uses SQLite WAL mode for concurrent read access alongside Rails writes.
53
+
54
+ Key design choices:
55
+ - **Polling over triggers** — simple, no SQLite extensions required, 10ms default interval
56
+ - **Per-channel fan-out** — each channel gets its own broadcaster with a ring buffer for reconnection catch-up
57
+ - **Pure Go SQLite** — uses `modernc.org/sqlite` (no CGO), enabling easy cross-compilation
58
+
59
+ ## CLI Options
60
+
61
+ | Flag | Default | Description |
62
+ |------|---------|-------------|
63
+ | `--port` | `4000` | HTTP listen port |
64
+ | `--db-path` | auto from `config/database.yml` | Path to SolidCable SQLite database |
65
+ | `--table-name` | `solid_cable_messages` | SolidCable messages table name |
66
+ | `--poll-interval` | `10` | Database poll interval (integer, milliseconds) |
67
+ | `--allow-origin` | *(required)* | `Access-Control-Allow-Origin` header value |
68
+
69
+ ## Environment Variables
70
+
71
+ | Variable | Description |
72
+ |----------|-------------|
73
+ | `FINESSE_SIGNING_KEY` | Hex-encoded HMAC key for signed stream verification (auto-derived from Rails when using `bundle exec finesse`) |
74
+ | `BINDING` | Bind address (default: `127.0.0.1`) |
75
+
76
+ ## Endpoints
77
+
78
+ | Path | Description |
79
+ |------|-------------|
80
+ | `GET /up` | Health check (returns 200) |
81
+ | `GET /events?signed_stream=<token>` | SSE stream for the given signed stream |
82
+
83
+ The SSE endpoint supports the `Last-Event-ID` header for automatic reconnection catch-up. Stream tokens use the same `ActiveSupport::MessageVerifier` format as Turbo's signed streams.
84
+
85
+ ## Development
86
+
87
+ ### Compiling from source
88
+
89
+ Requires Go 1.24+.
90
+
91
+ ```sh
92
+ bundle exec rake build:local # Build for current platform
93
+ bundle exec rake build:all # Cross-compile for all platforms
94
+ ```
95
+
96
+ ### Running tests
97
+
98
+ All build and test commands should be run via `bundle exec`:
99
+
100
+ ```sh
101
+ bundle exec rake test # Run Go + Ruby tests
102
+ bundle exec rake test:go # Go tests only
103
+ bundle exec rake test:ruby # Ruby tests only
104
+ ```
105
+
106
+ ### Running locally
107
+
108
+ ```sh
109
+ ./exe/arm64-darwin/finesse --port 4000 --db-path ../your-app/storage/development_cable.sqlite3
110
+ ```
111
+
112
+ ## Security
113
+
114
+ Finesse verifies every SSE connection using Turbo's signed stream tokens (HMAC-SHA256). CORS is restricted to the origins you specify with `--allow-origin`, and the server binds to `127.0.0.1` by default.
115
+
116
+ ### Signing Key Flow
117
+
118
+ **TL;DR:** Finesse needs the same HMAC key that Turbo uses to sign stream names. The Ruby wrapper extracts it from Rails automatically — zero config in development.
119
+
120
+ The problem: Finesse is a standalone Go binary, but it needs to verify tokens that Rails signs. Rails derives its signing key from `SECRET_KEY_BASE` via PBKDF2, and that secret lives in encrypted credentials (`config/credentials.yml.enc`) which require Ruby's `Marshal` deserializer to read. Reimplementing that in Go would be fragile and version-dependent.
121
+
122
+ The solution: let Ruby do the Ruby parts, then hand the result to Go.
123
+
124
+ ```
125
+ Rails Boot Finesse Boot
126
+ ────────── ────────────
127
+
128
+ config/credentials.yml.enc exe/finesse (Ruby wrapper)
129
+ │ │
130
+ ▼ ▼
131
+ SECRET_KEY_BASE rails runner "Finesse.signing_key"
132
+ │ │
133
+ ▼ ▼
134
+ PBKDF2-HMAC-SHA256 Turbo.signed_stream_verifier_key
135
+ salt: "turbo/signed_ │
136
+ stream_verifier_key" ▼
137
+ iterations: 65,536 hex-encode the derived key
138
+ output: 64 bytes │
139
+ │ ▼
140
+ ▼ ENV["FINESSE_SIGNING_KEY"] = hex
141
+ Turbo.signed_stream_ │
142
+ verifier_key ▼
143
+ │ exec(go_binary)
144
+ ▼ ├── reads env, decodes hex
145
+ turbo_stream_from @chat └── verifies HMAC on every
146
+ signs channel name ─────────▶ /events?signed_stream=<token>
147
+ ```
148
+
149
+ **Token format** (Rails `ActiveSupport::MessageVerifier`):
150
+ ```
151
+ base64strict(json(channel_name))--hex(hmac_sha256(base64_data, derived_key))
152
+ ```
153
+
154
+ The Go binary splits the token on `--`, recomputes the HMAC over the base64 data, and compares using constant-time `hmac.Equal`. If it matches, the channel name is trusted.
155
+
156
+ **In development**, this is fully automatic — Rails writes `secret_key_base` to `tmp/local_secret.txt` and the wrapper reads it. **In production**, either let the wrapper derive the key at boot (slower — spawns a `rails runner`), or set `FINESSE_SIGNING_KEY` directly as a hex-encoded env var to skip the Rails boot entirely.
157
+
158
+ ## Production Deployment
159
+
160
+ Finesse binds to `127.0.0.1` by default and should **not** be exposed directly on port 80/443. Run it alongside your app server (Puma, etc.) on the same host and proxy to it from your web server. For example, with Nginx:
161
+
162
+ ```nginx
163
+ upstream finesse {
164
+ server 127.0.0.1:4000;
165
+ }
166
+
167
+ location /finesse/ {
168
+ proxy_pass http://finesse/;
169
+ proxy_set_header Host $host;
170
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
171
+
172
+ # SSE requires unbuffered responses
173
+ proxy_buffering off;
174
+ proxy_cache off;
175
+ proxy_read_timeout 86400s;
176
+ }
177
+ ```
178
+
179
+ ### Kamal
180
+
181
+ If you deploy with [Kamal](https://kamal-deploy.org), add Finesse as a separate server role in `config/deploy.yml`. kamal-proxy automatically detects `Content-Type: text/event-stream` responses and disables buffering — no extra proxy configuration needed.
182
+
183
+ ```yaml
184
+ servers:
185
+ web:
186
+ - <your-public-server-ip>
187
+ sse:
188
+ hosts:
189
+ - <your-public-server-ip>
190
+ cmd: bundle exec finesse --allow-origin "https://yourapp.com"
191
+ proxy:
192
+ ssl: true
193
+ host: sse.yourapp.com
194
+ app_port: 4000
195
+ healthcheck:
196
+ path: /up
197
+ ```
198
+
199
+ Point a DNS record for `sse.yourapp.com` at your server and kamal-proxy handles TLS and routing.
200
+
201
+ Set `FINESSE_SIGNING_KEY` as a hex-encoded env var in production to avoid a `rails runner` invocation on every boot. The `--allow-origin` flag should match your production domain.
202
+
203
+ ## Roadmap
204
+
205
+ - **Rails generator** — `rails g finesse:install` for config, initializer, and binstub
206
+ - **Engine/Railtie** — view helpers (`finesse_sse_url`)
207
+ - **PostgreSQL support** — `LISTEN/NOTIFY` adapter as alternative to SQLite polling. 8KB limit may not be worth it, but polling in PostgreSQL would still be on the roadmap.
208
+
209
+ ## License
210
+
211
+ MIT License. See [LICENSE](LICENSE) for details.
Binary file
Binary file
data/exe/finesse ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'English'
5
+ require_relative '../lib/finesse/platforms'
6
+
7
+ args = ARGV.dup
8
+
9
+ # If --db-path wasn't explicitly provided, try to resolve it from config/database.yml
10
+ unless args.any? { |a| a.start_with?('--db-path') }
11
+ db_path = Finesse::Platforms.cable_db_path
12
+ args.unshift('--db-path', db_path) if db_path
13
+ end
14
+
15
+ # Derive FINESSE_SIGNING_KEY via Rails if not already set.
16
+ if ENV['FINESSE_SIGNING_KEY'].nil? || ENV['FINESSE_SIGNING_KEY'].empty?
17
+ key = `bundle exec rails runner "print Finesse.signing_key" 2>/dev/null`.strip
18
+ ENV['FINESSE_SIGNING_KEY'] = key if $CHILD_STATUS.success? && !key.empty?
19
+ end
20
+
21
+ exec(Finesse::Platforms.executable, *args)
Binary file
Binary file
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Finesse
4
+ # Platform detection, binary resolution, and database.yml parsing.
5
+ module Platforms
6
+ SUPPORTED_PLATFORMS = %w[
7
+ arm64-darwin
8
+ x86_64-darwin
9
+ aarch64-linux
10
+ x86_64-linux
11
+ ].freeze
12
+
13
+ class UnsupportedPlatformError < StandardError; end
14
+
15
+ class << self
16
+ # Root directory of the gem (two levels up from lib/finesse/).
17
+ def root
18
+ File.expand_path('../..', __dir__)
19
+ end
20
+
21
+ PLATFORM_MAP = {
22
+ %w[arm64 darwin] => 'arm64-darwin',
23
+ %w[x86_64 darwin] => 'x86_64-darwin',
24
+ %w[aarch64 linux] => 'aarch64-linux',
25
+ %w[x86_64 linux] => 'x86_64-linux'
26
+ }.freeze
27
+
28
+ def platform
29
+ cpu = Gem::Platform.local.cpu
30
+ os = Gem::Platform.local.os
31
+
32
+ PLATFORM_MAP.fetch([cpu, os]) do
33
+ raise UnsupportedPlatformError,
34
+ "Finesse does not support #{cpu}-#{os}. " \
35
+ "Supported platforms: #{SUPPORTED_PLATFORMS.join(', ')}"
36
+ end
37
+ end
38
+
39
+ def executable
40
+ exe = File.join(root, 'exe', platform, 'finesse')
41
+
42
+ unless File.exist?(exe)
43
+ raise UnsupportedPlatformError,
44
+ "Finesse binary not found at #{exe}. " \
45
+ 'Run `rake build:go` to compile from source.'
46
+ end
47
+
48
+ exe
49
+ end
50
+
51
+ # Resolve the cable database path from config/database.yml.
52
+ # Returns nil if the file doesn't exist or no cable DB is configured.
53
+ def cable_db_path
54
+ config = load_database_yml
55
+ return nil unless config
56
+
57
+ env_config = config.dig(ENV.fetch('RAILS_ENV', 'development'), 'cable')
58
+ env_config['database'] if env_config.is_a?(Hash)
59
+ end
60
+
61
+ private
62
+
63
+ def load_database_yml
64
+ config_path = File.join(Dir.pwd, 'config', 'database.yml')
65
+ return nil unless File.exist?(config_path)
66
+
67
+ require 'yaml'
68
+ require 'erb'
69
+
70
+ yaml = ERB.new(File.read(config_path)).result
71
+ YAML.safe_load(yaml, permitted_classes: [Symbol], aliases: true)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Finesse
2
- VERSION = "0.0.1"
4
+ VERSION = '0.1.0.beta1'
3
5
  end
data/lib/finesse.rb CHANGED
@@ -1,4 +1,14 @@
1
- require_relative "finesse/version"
1
+ # frozen_string_literal: true
2
2
 
3
+ require_relative 'finesse/version'
4
+ require_relative 'finesse/platforms'
5
+
6
+ # SSE server gem for ActionCable via SolidCable.
3
7
  module Finesse
8
+ # Returns the Turbo signed stream verifier key as a hex string.
9
+ # Called by the exe wrapper via `rails runner` to set FINESSE_SIGNING_KEY.
10
+ def self.signing_key
11
+ require 'turbo-rails'
12
+ Turbo.signed_stream_verifier_key.unpack1('H*')
13
+ end
4
14
  end
metadata CHANGED
@@ -1,27 +1,51 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: finesse
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0.beta1
5
5
  platform: ruby
6
6
  authors:
7
7
  - scudco
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: turbo-rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
12
26
  description: Finesse replaces ActionCable's default WebSocket transport with a lightweight
13
- Go binary
14
- executables: []
27
+ Go binary that polls SolidCable's SQLite table and streams Turbo updates to browsers
28
+ via Server-Sent Events.
29
+ executables:
30
+ - finesse
15
31
  extensions: []
16
32
  extra_rdoc_files: []
17
33
  files:
18
34
  - LICENSE
35
+ - README.md
36
+ - exe/aarch64-linux/finesse
37
+ - exe/arm64-darwin/finesse
38
+ - exe/finesse
39
+ - exe/x86_64-darwin/finesse
40
+ - exe/x86_64-linux/finesse
19
41
  - lib/finesse.rb
42
+ - lib/finesse/platforms.rb
20
43
  - lib/finesse/version.rb
21
44
  homepage: https://github.com/scudco/finesse
22
45
  licenses:
23
46
  - MIT
24
- metadata: {}
47
+ metadata:
48
+ rubygems_mfa_required: 'true'
25
49
  rdoc_options: []
26
50
  require_paths:
27
51
  - lib
@@ -36,7 +60,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
36
60
  - !ruby/object:Gem::Version
37
61
  version: '0'
38
62
  requirements: []
39
- rubygems_version: 4.0.6
63
+ rubygems_version: 4.0.3
40
64
  specification_version: 4
41
65
  summary: SSE server for ActionCable in Rails
42
66
  test_files: []