durable_streams-rails 0.3.0 → 0.4.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: 5c2dc408918802eb41264671f804a2bcee440341b56b53ef2f1e58fa77b84708
4
- data.tar.gz: '0496a9b62af4f8ed219f0f6d31df0da2e7f6c6b9ee871de91b11acc1cce8d707'
3
+ metadata.gz: 9fca76d28cee5e469a38766c7bec549acc60f5a6eddf5b840950ce96329dc31e
4
+ data.tar.gz: 95b45e16b2a3d40024a71c7281b5f17da3009163145b54148dceb493db919b09
5
5
  SHA512:
6
- metadata.gz: b4c1e809fdf66dd7b648156d5574c61f8d5ca47c365c60fd2eb4c5366fc7f87ec46bdbae0c32f848c7ef1f60863c9b96f2b5a706c9baff05f2dee082284a3e24
7
- data.tar.gz: e41d5c76287f2ccbcf8a3224bb654b40087468d84796bc5c69ed9f37f7425b49a36450bf9e4bf0daec522aaeb9bff3152adc65fb531526b748296e4ea16f2a22
6
+ metadata.gz: fc8227f3d31a0bbc46028c32f23c3cbdd29e5b09aa7a3ad378b69972fb25a1ed8b5e08b84020918dbdcb071a41329dc7f052ce6cc590f7cc112e7355156d9e39
7
+ data.tar.gz: f8cd42dc4ec64c26415e9793646d84786e104adcfcfb292c82d5662b181a280668ea13408033742649453049e94453351fbe42a9436224ebad5262943380a9f6
data/README.md CHANGED
@@ -265,6 +265,65 @@ This gem broadcasts JSON State Protocol events over HTTP/SSE (Durable Streams).
265
265
  - [`durable_streams`](https://github.com/tokimonki/durable_streams) — Ruby client for the Durable Streams protocol (resolved automatically via RubyGems)
266
266
  - `railties` >= 8.0
267
267
 
268
+ ## Protocol Coverage
269
+
270
+ The [Durable Streams protocol](https://github.com/durable-streams/durable-streams/blob/main/PROTOCOL.md)
271
+ defines 8 operations on streams. This gem handles the **server side** — broadcasting events,
272
+ provisioning streams, and authorizing client access via signed URLs. Client-side reads and
273
+ writes are handled by the official
274
+ [`@durable-streams/client`](https://github.com/durable-streams/durable-streams) JavaScript
275
+ package, not this gem.
276
+
277
+ ### Operations
278
+
279
+ | # | Operation | HTTP | This gem's role |
280
+ |---|---|---|---|
281
+ | 1 | Create | PUT | `StreamProvisioner` — automatic on first use |
282
+ | 2 | Append | POST | `Broadcasts` — `broadcast_event_to`, `broadcast_to` |
283
+ | 3 | Read (catch-up) | GET | Authorization only — `signed_stream_url` |
284
+ | 4 | Read (long-poll) | GET + `live=long-poll` | Authorization only — `signed_stream_url` |
285
+ | 5 | Read (SSE) | GET + `live=sse` | Authorization only — `signed_stream_url` |
286
+ | 6 | Close | POST + `Stream-Closed` | Not yet implemented |
287
+ | 7 | Delete | DELETE | Not yet implemented |
288
+ | 8 | Metadata | HEAD | Not yet implemented |
289
+
290
+ `signed_stream_url` generates a signed, expiring URL and provisions the stream. The client
291
+ uses this URL with `@durable-streams/client` and chooses the read mode via query parameters —
292
+ the gem doesn't need to know which mode the client will use.
293
+
294
+ ### Access control
295
+
296
+ Only the server may create streams. The server authenticates with the Durable Streams server
297
+ via API key; clients authenticate via signed, expiring tokens generated by this gem.
298
+
299
+ | | Create | Append | Read | Close | Delete | Metadata |
300
+ |---|---|---|---|---|---|---|
301
+ | **Server** (this gem) | Yes | Yes | Planned | Planned | Planned | Planned |
302
+ | **Client** (JS package) | No | Planned | Yes | No | No | Planned |
303
+
304
+ **Yes** = this gem provides the implementation or authorization today.
305
+ **Planned** = permitted by design, not yet implemented.
306
+ **No** = not permitted (security decision).
307
+
308
+ Client append (planned) enables use cases like live cursors and typing indicators where
309
+ the write payload goes directly to the stream server rather than through a Rails controller.
310
+ The Durable Streams server still performs forward_auth to Rails on every write to verify
311
+ the signed token, but this is lightweight — pure HMAC verification with no database or
312
+ session loading. For reads (SSE), forward_auth happens once at connection time.
313
+
314
+ ### Stream provisioning
315
+
316
+ The Durable Streams protocol requires explicit stream creation (PUT) before any read or write —
317
+ unlike Action Cable which auto-creates channels on subscribe. `StreamProvisioner` hides this
318
+ requirement from application code:
319
+
320
+ - `signed_stream_url` calls `ensure_stream_exists` before generating the URL
321
+ - `append_to_stream` calls `ensure_stream_exists` before appending
322
+ - A `Concurrent::Set` cache ensures the PUT only fires once per stream name per process
323
+
324
+ Whichever path touches a stream first provisions it. The application developer never thinks
325
+ about stream creation.
326
+
268
327
  ## Future
269
328
 
270
329
  Brewing secretly — an async-backed durable streams server inside Rails.
data/Rakefile CHANGED
@@ -13,7 +13,7 @@ end
13
13
 
14
14
  task :test_prereq do
15
15
  puts "Preparing test database"
16
- `cd test/dummy && RAILS_ENV=test bin/rails db:migrate`
16
+ system("bin/rails", "db:migrate", chdir: "test/dummy", exception: true)
17
17
  end
18
18
 
19
19
  task default: [ :test_prereq, :test ]
@@ -2,8 +2,8 @@ module DurableStreams
2
2
  module Rails
3
3
  class AuthController < ActionController::API
4
4
  def verify
5
- if request.headers["X-Forwarded-Uri"]
6
- stream_name ? head(:ok) : head(:unauthorized)
5
+ if request.headers["X-Forwarded-Uri"] && authorized_by_token?
6
+ head :ok
7
7
  elsif valid_server_api_key?
8
8
  head :ok
9
9
  else
@@ -12,8 +12,23 @@ module DurableStreams
12
12
  end
13
13
 
14
14
  private
15
- def stream_name
16
- DurableStreams.verify_signed_url(request.headers["X-Forwarded-Uri"])
15
+ def authorized_by_token?
16
+ if payload = DurableStreams.verify_signed_url(request.headers["X-Forwarded-Uri"])
17
+ permissions = Array(payload["permissions"]) & DurableStreams::Rails::StreamName::PERMISSIONS
18
+ permitted_method?(permissions)
19
+ end
20
+ end
21
+
22
+ # Only GET and POST reach token auth. PUT and DELETE are server-only
23
+ # operations authenticated via API key (valid_server_api_key?), not tokens.
24
+ # See README.md "Access control" for the full matrix.
25
+ def permitted_method?(permissions)
26
+ case request.headers["X-Forwarded-Method"]&.upcase
27
+ when "POST"
28
+ permissions.include?("write")
29
+ else
30
+ permissions.include?("read")
31
+ end
17
32
  end
18
33
 
19
34
  def valid_server_api_key?
@@ -196,7 +196,23 @@ module DurableStreams::Rails::Broadcastable
196
196
  end
197
197
 
198
198
  private
199
- def stream_event_attributes(operation:, value: as_json)
199
+ def stream_event_attributes(operation:, value: to_stream_value)
200
200
  { type: model_name.singular, key: id.to_s, value: value, operation: operation }
201
201
  end
202
+
203
+ # Returns the value used in State Protocol broadcast events. Override this in your model
204
+ # to customize the serialized payload — for example, to use an Alba resource or any other
205
+ # serializer instead of the default +as_json+.
206
+ #
207
+ # class Message < ApplicationRecord
208
+ # streams_to :room
209
+ #
210
+ # private
211
+ # def to_stream_value
212
+ # MessageResource.new(self).to_h
213
+ # end
214
+ # end
215
+ def to_stream_value
216
+ as_json
217
+ end
202
218
  end
@@ -2,9 +2,29 @@
2
2
  # See <tt>DurableStreams::Rails::Broadcastable</tt> for the user-facing API that invokes these methods with most of the
3
3
  # paperwork filled out already.
4
4
  #
5
- # Can be used directly using something like <tt>DurableStreams.broadcast_event_to :room, type: "message", key: "5",
6
- # value: { content: "Hello" }, operation: :insert</tt>.
5
+ # All broadcasts produce JSON. Two flavors are supported:
6
+ #
7
+ # ==== State Protocol events
8
+ #
9
+ # <tt>broadcast_event_to</tt> and <tt>broadcast_event_later_to</tt> produce State Protocol events —
10
+ # the keyed insert/update/delete format consumed by <tt>@durable-streams/state</tt> and <tt>@tanstack/db</tt>
11
+ # for reactive collections on the client:
12
+ #
13
+ # DurableStreams.broadcast_event_to :room, type: "message", key: "5",
14
+ # value: { content: "Hello" }, operation: :insert
15
+ #
16
+ # ==== Arbitrary JSON
17
+ #
18
+ # <tt>broadcast_to</tt> sends free-form JSON with no State Protocol structure:
19
+ #
20
+ # DurableStreams.broadcast_to :room, event: "typing", user_id: 5
21
+ #
22
+ # Although the underlying Durable Streams server supports any content type (bytes, text, etc.),
23
+ # this gem only creates <tt>application/json</tt> streams. Non-JSON broadcasts would require
24
+ # extending <tt>append_to_stream</tt> to accept a configurable content type.
7
25
  module DurableStreams::Rails::Broadcasts
26
+ # Broadcast a State Protocol event to one or more streamables. Returns the generated +txid+
27
+ # for optimistic update confirmation on the client, or +nil+ if streamables are blank.
8
28
  def broadcast_event_to(*streamables, type:, key:, value:, operation:)
9
29
  streamables.flatten!
10
30
  streamables.compact_blank!
@@ -45,11 +65,19 @@ module DurableStreams::Rails::Broadcasts
45
65
  end
46
66
 
47
67
  private
68
+ # Single transport point for all broadcasts. Testing intercepts here.
69
+ #
70
+ # Content type is hardcoded to <tt>application/json</tt> because every public method in this
71
+ # module produces JSON (State Protocol events via +broadcast_event_to+, free-form JSON via
72
+ # +broadcast_to+). This ensures the POST body is correctly wrapped in a JSON array for the
73
+ # Durable Streams server. If non-JSON broadcast support is needed in the future, this method
74
+ # should accept a content type parameter.
48
75
  def append_to_stream(stream_name, message)
49
76
  if DurableStreams::Rails::Testing.recording?
50
77
  DurableStreams::Rails::Testing.record(stream_name, message)
51
78
  else
52
- stream(stream_name).append(message)
79
+ ensure_stream_exists(stream_name)
80
+ DurableStreams::Stream.new(stream_name, context: DurableStreams.default_context, content_type: "application/json").append(message)
53
81
  end
54
82
  end
55
83
  end
@@ -13,6 +13,12 @@ module DurableStreams
13
13
  }
14
14
 
15
15
  <%= address %> {
16
+ log {
17
+ output stderr
18
+ format <%= log_format %>
19
+ level <%= log_level %>
20
+ }
21
+
16
22
  route <%= route_path %> {
17
23
  forward_auth <%= auth["url"] %> {
18
24
  uri <%= auth["path"] %>
@@ -54,6 +60,14 @@ module DurableStreams
54
60
  auth.fetch("copy_headers", [ "Cookie" ])
55
61
  end
56
62
 
63
+ def log_format
64
+ @config.fetch("log_format", "console")
65
+ end
66
+
67
+ def log_level
68
+ @config.fetch("log_level", "INFO")
69
+ end
70
+
57
71
  def storage_block
58
72
  if dir = @config.dig("storage", "data_dir")
59
73
  " {\n\t\t\tdata_dir #{dir}\n\t\t}"
@@ -2,15 +2,31 @@
2
2
  # are exposed directly to the client via signed URL tokens, we need to ensure that the name isn't
3
3
  # tampered with, so the names are signed upon generation and verified upon receipt. All verification
4
4
  # happens through the <tt>DurableStreams.signed_stream_verifier</tt>.
5
+ #
6
+ # Tokens embed a permissions array (<tt>["read"]</tt>, <tt>["write"]</tt>, or
7
+ # <tt>["read", "write"]</tt>) that determines what operations the client can perform.
8
+ # The auth controller checks the permissions against the HTTP method of the incoming request.
5
9
  module DurableStreams::Rails::StreamName
6
- # Used by the stream auth endpoint to verify a signed stream name.
10
+ PERMISSIONS = %w[ read write ].freeze
11
+
12
+ # Verifies a signed token and returns the payload hash.
13
+ # Returns +nil+ if the token is invalid or expired.
14
+ #
15
+ # DurableStreams.verified_stream_name(token)
16
+ # # => { "stream" => "gid://app/Room/1/messages", "permissions" => ["read"] }
7
17
  def verified_stream_name(signed_stream_name)
8
18
  DurableStreams.signed_stream_verifier.verified signed_stream_name
9
19
  end
10
20
 
11
- # Used by <tt>DurableStreams.signed_stream_url</tt> to generate a signed stream name.
12
- def signed_stream_name(streamables)
13
- DurableStreams.signed_stream_verifier.generate stream_name_from(streamables)
21
+ # Generates a signed token embedding the stream name and permissions.
22
+ #
23
+ # Tokens generated without +expires_in+ never expire. Prefer
24
+ # <tt>DurableStreams.signed_stream_url</tt> for client-facing URLs.
25
+ def signed_stream_name(streamables, permissions: [ "read" ], expires_in: nil)
26
+ DurableStreams.signed_stream_verifier.generate(
27
+ { "stream" => stream_name_from(streamables), "permissions" => permissions.map(&:to_s) },
28
+ expires_in: expires_in
29
+ )
14
30
  end
15
31
 
16
32
  private
@@ -0,0 +1,31 @@
1
+ require "concurrent/set"
2
+
3
+ # Ensures streams exist on the Durable Streams server before they are used.
4
+ #
5
+ # The Durable Streams protocol requires explicit stream creation (PUT) before any read (GET/SSE)
6
+ # or write (POST) operation — unlike Action Cable which auto-creates channels on subscribe.
7
+ # This module hides that requirement from application code.
8
+ #
9
+ # Both the signing path (<tt>signed_stream_url</tt>) and the broadcast path
10
+ # (<tt>append_to_stream</tt>) call <tt>ensure_stream_exists</tt>. A thread-safe
11
+ # <tt>Concurrent::Set</tt> cache ensures the PUT only fires once per stream name per process,
12
+ # regardless of which path touches the stream first.
13
+ module DurableStreams::Rails::StreamProvisioner
14
+ private
15
+ # Creates the stream on the server if it hasn't been created by this process yet.
16
+ # Idempotent — a 409 means the stream already exists.
17
+ def ensure_stream_exists(stream_name)
18
+ return if DurableStreams::Rails::Testing.recording?
19
+ return if known_streams.include?(stream_name)
20
+
21
+ DurableStreams::Stream.new(stream_name, context: DurableStreams.default_context)
22
+ .create_stream(content_type: "application/json")
23
+ known_streams.add(stream_name)
24
+ rescue DurableStreams::StreamExistsError
25
+ known_streams.add(stream_name)
26
+ end
27
+
28
+ def known_streams
29
+ @known_streams ||= Concurrent::Set.new
30
+ end
31
+ end
@@ -1,5 +1,5 @@
1
1
  module DurableStreams
2
2
  module Rails
3
- VERSION = "0.3.0"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
@@ -1,17 +1,20 @@
1
1
  require "durable_streams"
2
+ require "durable_streams/rails/engine"
2
3
  require "durable_streams/rails/stream_name"
4
+ require "durable_streams/rails/stream_provisioner"
3
5
  require "durable_streams/rails/broadcasts"
4
6
  require "durable_streams/rails/testing"
5
- require "durable_streams/rails/engine"
6
7
  require "active_support/core_ext/module/attribute_accessors_per_thread"
7
8
 
8
9
  module DurableStreams
9
10
  extend ActiveSupport::Autoload
10
11
  extend DurableStreams::Rails::StreamName
12
+ extend DurableStreams::Rails::StreamProvisioner
11
13
  extend DurableStreams::Rails::Broadcasts
12
14
 
13
15
  mattr_accessor :base_url
14
16
  mattr_accessor :draw_routes, default: true
17
+ mattr_accessor :signed_stream_url_expires_in, default: 24.hours
15
18
 
16
19
  class << self
17
20
  attr_writer :signed_stream_verifier_key, :server_api_key
@@ -28,15 +31,21 @@ module DurableStreams
28
31
  @server_api_key or raise ArgumentError, "DurableStreams requires a server_api_key"
29
32
  end
30
33
 
31
- def signed_stream_url(*streamables, expires_in: 24.hours)
34
+ def signed_stream_url(*streamables, permissions: [ :read ], expires_in: DurableStreams.signed_stream_url_expires_in)
32
35
  path = stream_name_from(streamables)
33
36
  ensure_stream_exists(path)
34
- token = signed_stream_verifier.generate(path, expires_in: expires_in)
37
+ token = signed_stream_verifier.generate(
38
+ { "stream" => path, "permissions" => permissions.map(&:to_s) },
39
+ expires_in: expires_in
40
+ )
35
41
  "#{base_url}/#{path}?token=#{token}"
36
42
  end
37
43
 
38
44
  # Verifies the signed token embedded in a stream URL. Used by the auth controller
39
45
  # to validate forward_auth requests from the Durable Streams server.
46
+ #
47
+ # Returns a hash with <tt>"stream"</tt> and <tt>"permissions"</tt> keys, or +nil+
48
+ # if the token is invalid or expired.
40
49
  def verify_signed_url(url)
41
50
  if token = extract_token_from_url(url)
42
51
  verified_stream_name(token)
@@ -48,18 +57,5 @@ module DurableStreams
48
57
  Rack::Utils.parse_query(query)["token"]
49
58
  end
50
59
  end
51
-
52
- private
53
- # Creates the stream on the server if it doesn't already exist. Called automatically
54
- # by +signed_stream_url+ so clients can subscribe immediately without hitting a 404.
55
- # Idempotent — the server returns 200 when the stream already exists with matching config.
56
- # Skipped during tests (no server) and when base_url is not configured.
57
- def ensure_stream_exists(stream_name)
58
- return if DurableStreams::Rails::Testing.recording?
59
-
60
- stream(stream_name).create_stream(content_type: "application/json")
61
- rescue DurableStreams::StreamExistsError
62
- # Already exists with different config — still usable
63
- end
64
60
  end
65
61
  end
@@ -20,8 +20,8 @@ RAILS_ENV="${RAILS_ENV:-development}"
20
20
  CONFIG_FILE="${APP_ROOT}/tmp/durable_streams.caddyfile"
21
21
 
22
22
  mkdir -p "${APP_ROOT}/tmp"
23
- APP_ROOT="$APP_ROOT" RAILS_ENV="$RAILS_ENV" bundle exec ruby -r durable_streams/server_config \
24
- -e "puts DurableStreams::ServerConfig.generate(File.join(ENV['APP_ROOT'], 'config/durable_streams.yml'), ENV['RAILS_ENV'])" \
23
+ APP_ROOT="$APP_ROOT" RAILS_ENV="$RAILS_ENV" bundle exec ruby -r durable_streams/rails/server_config \
24
+ -e "puts DurableStreams::Rails::ServerConfig.generate(File.join(ENV['APP_ROOT'], 'config/durable_streams.yml'), ENV['RAILS_ENV'])" \
25
25
  > "$CONFIG_FILE"
26
26
 
27
27
  exec "$BIN_PATH" run --config "$CONFIG_FILE" "$@"
@@ -4,6 +4,15 @@ namespace :durable_streams do
4
4
  require "net/http"
5
5
  require "json"
6
6
 
7
+ resolve_latest_version = lambda do
8
+ uri = URI("https://api.github.com/repos/durable-streams/durable-streams/releases/latest")
9
+ response = Net::HTTP.get(uri)
10
+ tag = JSON.parse(response).fetch("tag_name")
11
+ tag.delete_prefix("v")
12
+ rescue => e
13
+ abort "Failed to resolve latest version: #{e.message}"
14
+ end
15
+
7
16
  version = ENV.fetch("DURABLE_STREAMS_VERSION", "latest")
8
17
  install_dir = Rails.root.join("bin", "dist")
9
18
  bin_path = install_dir.join("durable-streams-server")
@@ -29,7 +38,7 @@ namespace :durable_streams do
29
38
  }
30
39
 
31
40
  if version == "latest"
32
- version = resolve_latest_version
41
+ version = resolve_latest_version.call
33
42
  end
34
43
 
35
44
  FileUtils.mkdir_p(install_dir)
@@ -43,12 +52,3 @@ namespace :durable_streams do
43
52
  puts "Installed to #{bin_path}"
44
53
  end
45
54
  end
46
-
47
- def resolve_latest_version
48
- uri = URI("https://api.github.com/repos/durable-streams/durable-streams/releases/latest")
49
- response = Net::HTTP.get(uri)
50
- tag = JSON.parse(response).fetch("tag_name")
51
- tag.delete_prefix("v")
52
- rescue => e
53
- abort "Failed to resolve latest version: #{e.message}"
54
- end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: durable_streams-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tokimonki
@@ -58,6 +58,7 @@ files:
58
58
  - lib/durable_streams/rails/engine.rb
59
59
  - lib/durable_streams/rails/server_config.rb
60
60
  - lib/durable_streams/rails/stream_name.rb
61
+ - lib/durable_streams/rails/stream_provisioner.rb
61
62
  - lib/durable_streams/rails/testing.rb
62
63
  - lib/durable_streams/rails/version.rb
63
64
  - lib/generators/durable_streams/install/USAGE