tonic-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.
data/Cargo.toml ADDED
@@ -0,0 +1,11 @@
1
+ # Cargo workspace for the native extension (the layout rb_sys and
2
+ # `bundle gem --ext=rust` expect). The benchmark crate is standalone.
3
+ [workspace]
4
+ members = ["ext/tonic_rails_native"]
5
+ exclude = ["bench/rust_bench"]
6
+ resolver = "3"
7
+
8
+ [profile.release]
9
+ opt-level = 3
10
+ lto = true
11
+ codegen-units = 1
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Tanous
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,391 @@
1
+ # tonic-rails
2
+
3
+ [![CI](https://github.com/CodingAnarchy/tonic-rails/actions/workflows/ci.yml/badge.svg)](https://github.com/CodingAnarchy/tonic-rails/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/tonic-rails.svg)](https://rubygems.org/gems/tonic-rails)
5
+
6
+ Serve gRPC from your Rails app, with the transport handled by
7
+ [tonic], Rust's production gRPC stack.
8
+
9
+ Think of it like Puma for gRPC. Puma handles sockets and HTTP so your
10
+ controllers only see requests. tonic-rails does the same for gRPC:
11
+ HTTP/2, TLS, flow control, keepalives, health checks and reflection all
12
+ run in Rust, outside the GVL. Your handlers are plain Ruby classes that
13
+ receive protobuf messages and return them, inside the Rails executor,
14
+ with your models, caches and error reporting all available.
15
+
16
+ ```ruby
17
+ class ItemService
18
+ include Tonic::Rails::Base
19
+ rpc_service "demo.v1.ItemService"
20
+ request_format :message
21
+
22
+ def get_item(request)
23
+ item = Item.find_by(id: request.id)
24
+ raise Tonic::Rails::RpcError.new("item not found", code: :not_found) unless item
25
+
26
+ Demo::V1::Item.new(id: item.id, name: item.name)
27
+ end
28
+ end
29
+ ```
30
+
31
+ ## Why tonic-rails
32
+
33
+ - **Fast where it counts.** Transport overhead is small: an empty RPC
34
+ runs at roughly 75–80% of a pure-Rust tonic server's throughput. I/O-bound
35
+ handlers scale with dispatch threads because Ruby releases the GVL
36
+ while waiting. See [PERF.md](PERF.md).
37
+ - **All four RPC kinds.** Unary, server streaming, client streaming and
38
+ bidirectional streaming, with backpressure in both directions.
39
+ - **Production defaults.** Graceful draining on SIGTERM, health checks
40
+ that flip to NOT_SERVING during shutdown, deadlines, message size and
41
+ connection limits, load shedding, gzip, TLS and mutual TLS.
42
+ - **Safe by default.** Unexpected exceptions reach clients as a generic
43
+ INTERNAL error (the details go to your logs and `Rails.error`).
44
+ Reflection is off unless you turn it on. TLS problems stop the server
45
+ instead of silently falling back to plaintext.
46
+ - **No Rust toolchain needed** on the platforms we ship precompiled gems
47
+ for (Linux and macOS, x86_64 and arm64).
48
+
49
+ ## Installation
50
+
51
+ ```ruby
52
+ # Gemfile
53
+ gem "tonic-rails"
54
+ gem "google-protobuf"
55
+ ```
56
+
57
+ Requires Ruby 3.3+ and Rails 7.1+ (or just ActiveSupport, outside Rails).
58
+ On platforms without a precompiled gem, installation builds the
59
+ extension from source, which needs a [Rust toolchain](https://rustup.rs).
60
+
61
+ To generate Ruby classes from your `.proto` files you also need `protoc`
62
+ (the Protocol Buffers compiler) during development.
63
+
64
+ ## Quick start
65
+
66
+ ### 1. Define the service
67
+
68
+ ```protobuf
69
+ // app/protos/items.proto
70
+ syntax = "proto3";
71
+ package demo.v1;
72
+
73
+ message Item { int64 id = 1; string name = 2; }
74
+ message GetItemRequest { int64 id = 1; }
75
+
76
+ service ItemService {
77
+ rpc GetItem(GetItemRequest) returns (Item);
78
+ }
79
+ ```
80
+
81
+ Generate the Ruby classes:
82
+
83
+ ```sh
84
+ bin/rails tonic_rails:compile_protos # app/protos → lib/grpc
85
+ ```
86
+
87
+ Generated files `require` each other by name, so keep them out of the
88
+ autoload paths and put their directory on the load path:
89
+
90
+ ```ruby
91
+ # config/application.rb
92
+ $LOAD_PATH.unshift(File.expand_path("../lib/grpc", __dir__))
93
+
94
+ module MyApp
95
+ class Application < Rails::Application
96
+ config.autoload_lib(ignore: %w[grpc])
97
+ end
98
+ end
99
+ ```
100
+
101
+ ### 2. Write a handler
102
+
103
+ Handlers live anywhere Rails autoloads from; `app/grpc` is a good home.
104
+ Each RPC maps to a snake_case method.
105
+
106
+ ```ruby
107
+ # app/grpc/item_service.rb
108
+ require "items_pb"
109
+
110
+ class ItemService
111
+ include Tonic::Rails::Base
112
+
113
+ rpc_service "demo.v1.ItemService" # every RPC, read from the descriptor
114
+ request_format :message # receive protobuf messages
115
+
116
+ def get_item(request)
117
+ item = Item.find(request.id)
118
+ Demo::V1::Item.new(id: item.id, name: item.name)
119
+ rescue ActiveRecord::RecordNotFound
120
+ raise Tonic::Rails::RpcError.new("item #{request.id} not found", code: :not_found)
121
+ end
122
+ end
123
+ ```
124
+
125
+ ### 3. Register and run
126
+
127
+ Register handlers in `to_prepare` so they work with code reloading:
128
+
129
+ ```ruby
130
+ # config/initializers/tonic_rails.rb
131
+ Rails.application.config.to_prepare do
132
+ Tonic::Rails.server.register(ItemService)
133
+ end
134
+ ```
135
+
136
+ ```sh
137
+ bin/rails tonic_rails:serve # serves on 0.0.0.0:50051
138
+ bin/rails tonic_rails:routes # lists registered methods
139
+ ```
140
+
141
+ Run it as its own process (a separate Procfile entry, container or
142
+ Kubernetes deployment) next to your web server.
143
+
144
+ The [example app](example/) is a complete, runnable version of all of the
145
+ above.
146
+
147
+ ## Handlers
148
+
149
+ ### Requests and responses
150
+
151
+ By default handlers receive a string-keyed Hash. With
152
+ `request_format :message` they receive the decoded protobuf message
153
+ instead, which is faster and type-safe. Either way, return a protobuf
154
+ message or a Hash:
155
+
156
+ ```ruby
157
+ def get_item(request)
158
+ { "id" => request.id, "name" => "Widget" } # a Hash works too
159
+ end
160
+ ```
161
+
162
+ A fresh handler instance serves each call, so instance variables never
163
+ leak between requests.
164
+
165
+ ### Declaring RPCs
166
+
167
+ `rpc_service "package.Service"` reads every RPC (and its streaming kind)
168
+ from the generated descriptor. To declare RPCs one at a time instead:
169
+
170
+ ```ruby
171
+ class ItemService
172
+ include Tonic::Rails::Base
173
+ service "demo.v1.ItemService"
174
+
175
+ rpc :GetItem, Demo::V1::GetItemRequest, Demo::V1::Item
176
+ rpc :ListItems, Demo::V1::ListItemsRequest, Demo::V1::Item, stream: :server
177
+ end
178
+ ```
179
+
180
+ RPCs declared without a handler method answer `UNIMPLEMENTED`.
181
+
182
+ ### Streaming
183
+
184
+ Server streaming: return any Enumerable. Messages go out as you produce
185
+ them, and if the client reads slowly your handler waits (without holding
186
+ the GVL) instead of buffering everything in memory.
187
+
188
+ ```ruby
189
+ def list_items(request)
190
+ Enumerator.new do |stream|
191
+ Item.where(tag: request.tag).find_each do |item|
192
+ break if cancelled? # the server is shutting down
193
+ stream << Demo::V1::Item.new(id: item.id, name: item.name)
194
+ end
195
+ end
196
+ end
197
+ ```
198
+
199
+ Client streaming: the argument is an Enumerator that yields each request
200
+ as it arrives.
201
+
202
+ ```ruby
203
+ def import_items(requests)
204
+ created = requests.count { |r| Item.create!(name: r.name) }
205
+ Demo::V1::ImportItemsResponse.new(created: created)
206
+ end
207
+ ```
208
+
209
+ Bidirectional streaming combines both: take the request Enumerator and
210
+ return an Enumerable. Responses can be sent before the client finishes
211
+ sending.
212
+
213
+ ```ruby
214
+ def chat(messages)
215
+ Enumerator.new do |out|
216
+ messages.each { |m| out << Demo::V1::Reply.new(text: m.text.upcase) }
217
+ end
218
+ end
219
+ ```
220
+
221
+ ### Errors
222
+
223
+ Raise `Tonic::Rails::RpcError` to end a call with a status:
224
+
225
+ ```ruby
226
+ raise Tonic::Rails::RpcError.new("name is required", code: :invalid_argument)
227
+ ```
228
+
229
+ Codes are the standard gRPC names (`:not_found`, `:permission_denied`,
230
+ `:unauthenticated`, `:unavailable`, and so on) or their numbers. The
231
+ message goes to the client, so keep internal details out of it.
232
+
233
+ Any other exception becomes `INTERNAL` with the message `internal error`.
234
+ The real exception is logged and passed to `Rails.error.report`, so your
235
+ error tracker sees it.
236
+
237
+ ### Metadata, deadlines and shutdown
238
+
239
+ ```ruby
240
+ def get_item(request)
241
+ token = grpc_metadata["authorization"] # request headers, lower-case
242
+ deadline_ms # ms left before the client's deadline, or nil
243
+ deadline_exceeded? # true once it has passed
244
+ cancelled? # true while the server shuts down
245
+ end
246
+ ```
247
+
248
+ When the deadline passes, the client gets `DEADLINE_EXCEEDED` right away;
249
+ check `deadline_exceeded?` in long loops to stop wasted work.
250
+
251
+ ### Interceptors
252
+
253
+ Interceptors wrap every call, outermost first. Call `next_call` to
254
+ continue, or raise to reject:
255
+
256
+ ```ruby
257
+ Tonic::Rails.configure do |config|
258
+ config.interceptors << lambda do |path, metadata, next_call|
259
+ unless valid_token?(metadata["authorization"])
260
+ raise Tonic::Rails::RpcError.new("invalid token", code: :unauthenticated)
261
+ end
262
+ next_call.call
263
+ end
264
+ end
265
+ ```
266
+
267
+ ## Testing handlers
268
+
269
+ `Tonic::Rails::Testing` calls a handler in-process, with real protobuf
270
+ encoding, no network and no native extension:
271
+
272
+ ```ruby
273
+ require "tonic/rails/testing"
274
+
275
+ response = Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 1 })
276
+ expect(response.name).to eq("Widget")
277
+
278
+ expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
279
+ .to raise_error(Tonic::Rails::RpcError) { |e| expect(e.code_name).to eq(:not_found) }
280
+
281
+ # Streaming: pass an Array of requests, get an Array of responses
282
+ Tonic::Rails::Testing.call(ItemService, :ImportItems, [{ name: "a" }, { name: "b" }])
283
+ ```
284
+
285
+ ## Configuration
286
+
287
+ Set options in `config/application.rb` or an environment file:
288
+
289
+ ```ruby
290
+ config.tonic_rails.bind_address = "0.0.0.0:50051"
291
+ config.tonic_rails.dispatch_threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
292
+ ```
293
+
294
+ or with a block (which runs after, and wins over, `config.tonic_rails`):
295
+
296
+ ```ruby
297
+ Tonic::Rails.configure do |config|
298
+ config.tls_cert_path = "/etc/tls/tls.crt"
299
+ config.tls_key_path = "/etc/tls/tls.key"
300
+ end
301
+ ```
302
+
303
+ | Option | Default | Description |
304
+ |--------|---------|-------------|
305
+ | `bind_address` | `"0.0.0.0:50051"` | Address to listen on |
306
+ | `dispatch_threads` | `4` | Ruby threads running handlers; match your database pool |
307
+ | `dispatch_queue_size` | `128` | Calls that may wait for a thread; beyond this, `RESOURCE_EXHAUSTED` |
308
+ | `worker_threads` | CPU count | Tokio threads for the transport |
309
+ | `max_message_size` | 4 MiB | Request and response limit (also caps decompressed size) |
310
+ | `max_connections` | `64` | Concurrent connections; `0` = unlimited |
311
+ | `concurrency_limit_per_connection` | `32` | Concurrent streams per connection; `0` = unlimited |
312
+ | `tls_cert_path`, `tls_key_path` | `nil` | PEM certificate and key; set both to enable TLS |
313
+ | `tls_client_ca_path` | `nil` | PEM CA bundle; requires client certificates (mutual TLS) |
314
+ | `drain_timeout` | `10` | Seconds to let in-flight calls finish on shutdown |
315
+ | `keepalive_interval`, `keepalive_timeout` | `15`, `5` | HTTP/2 PING interval and timeout, seconds |
316
+ | `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
317
+ | `reflection` | `false` | Serve `grpc.reflection.v1`; exposes your schema |
318
+ | `interceptors` | `[]` | See [Interceptors](#interceptors) |
319
+ | `log_requests` | `false` | Log every call to stderr (errors are always logged) |
320
+ | `auto_start` | `false` | Start inside `rails server` (see below) |
321
+
322
+ ### Sizing threads
323
+
324
+ Handlers run on `dispatch_threads` Ruby threads. Blocking I/O (database,
325
+ HTTP) releases the GVL, so more threads means more concurrent I/O. CPU
326
+ work does not parallelise within a process. Set threads to the
327
+ concurrency your I/O needs, size the database pool to match, and add
328
+ processes for CPU-heavy services: the listener uses `SO_REUSEPORT`, so
329
+ several processes can bind the same port.
330
+
331
+ ### auto_start
332
+
333
+ `auto_start = true` starts the gRPC server in a background thread of
334
+ `rails server` (never in consoles, rake tasks or tests). It's handy in
335
+ development. In production prefer a dedicated `tonic_rails:serve`
336
+ process: forking servers such as Puma in cluster mode would need to
337
+ start one per worker.
338
+
339
+ ## Operations
340
+
341
+ - **Health checks.** `grpc.health.v1.Health` reports `SERVING` for the
342
+ server (`""`) and every registered service, and flips to `NOT_SERVING`
343
+ as soon as shutdown begins. Point your load balancer or Kubernetes
344
+ gRPC probes at it.
345
+ - **Graceful shutdown.** On SIGTERM or SIGINT the server stops
346
+ accepting connections, tells clients to go away (HTTP/2 GOAWAY), lets
347
+ in-flight calls finish for up to `drain_timeout` seconds, then exits.
348
+ - **Load shedding.** When every dispatch thread is busy and the queue is
349
+ full, new calls fail fast with `RESOURCE_EXHAUSTED` instead of piling up.
350
+ - **Stats.** `Tonic::Rails.server.stats` returns counters for totals,
351
+ successes, errors, cancellations, deadline and resource errors, active
352
+ connections and queued calls.
353
+ - **Reflection.** Turn on `reflection` in development to use
354
+ [grpcurl](https://github.com/fullstorydev/grpcurl) without proto files.
355
+
356
+ ## How it works
357
+
358
+ ```
359
+ client ──HTTP/2──▶ tonic (Rust, no GVL)
360
+ ├─ TLS, flow control, keepalive, limits
361
+ ├─ grpc.health.v1, grpc.reflection.v1
362
+ └─ everything else ─▶ queue ─▶ Ruby dispatch thread
363
+ (takes the GVL)
364
+ decode → handler → encode
365
+ ```
366
+
367
+ Rust never decodes your messages; it passes raw protobuf bytes to Ruby,
368
+ where the `google-protobuf` C extension decodes them once. Routes come
369
+ from the handlers you register, so nothing about your services is
370
+ compiled into the extension.
371
+
372
+ ## Development
373
+
374
+ ```sh
375
+ bundle install
376
+ bundle exec rake compile # build the extension (needs Rust)
377
+ bundle exec rake # Ruby specs + Rust unit tests
378
+ bundle exec rubocop
379
+ bundle exec rake cargo:lint
380
+ ```
381
+
382
+ The integration specs start real servers and call them with the official
383
+ `grpc` gem; the reflection specs also need `grpcurl` on your PATH.
384
+ Benchmarks live in [bench/](bench/) (see [PERF.md](PERF.md)). See
385
+ [CONTRIBUTING.md](CONTRIBUTING.md) for more.
386
+
387
+ ## License
388
+
389
+ MIT. See [LICENSE](LICENSE).
390
+
391
+ [tonic]: https://github.com/hyperium/tonic
@@ -0,0 +1,44 @@
1
+ [package]
2
+ name = "tonic_rails_native"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+ rust-version = "1.88"
6
+ publish = false
7
+
8
+ [lib]
9
+ crate-type = ["cdylib"]
10
+
11
+ # Magnus bridges Rust ↔ Ruby. rb-sys provides the raw Ruby FFI
12
+ # (including the GVL coordination functions). tonic supplies the
13
+ # gRPC transport layer (HTTP/2, TLS, keepalive, graceful shutdown).
14
+ # tonic-reflection and tonic-health provide built-in gRPC reflection
15
+ # (v1) and health-check services as sibling routes.
16
+ #
17
+ # The dispatch model is raw-byte passthrough: every path that is not
18
+ # health or reflection falls through to a generic tower Service that
19
+ # unframes the gRPC message, sends raw bytes to Ruby via the GVL bridge,
20
+ # and frames the response. No protobuf decode/encode on the hot path,
21
+ # and no service definitions are compiled into the extension — routes
22
+ # are registered from Ruby at runtime.
23
+ #
24
+ # socket2 enables SO_REUSEPORT for multi-process (Puma worker) scaling.
25
+ [dependencies]
26
+ magnus = "0.8"
27
+ rb-sys = "0.9"
28
+ tonic = { version = "0.14", default-features = false, features = ["transport", "router", "server", "tls-ring"] }
29
+ tonic-reflection = { version = "0.14", default-features = false, features = ["server"] }
30
+ tonic-health = { version = "0.14", default-features = false }
31
+ axum = { version = "0.8", default-features = false }
32
+ tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time"] }
33
+ tokio-stream = { version = "0.1", features = ["net"] }
34
+ prost = "0.14"
35
+ prost-types = "0.14"
36
+ socket2 = { version = "0.6", features = ["all"] }
37
+ parking_lot = "0.12"
38
+ crossbeam-channel = "0.5"
39
+ http = "1"
40
+ http-body = "1"
41
+ http-body-util = "0.1"
42
+ bytes = "1"
43
+ tower = "0.5"
44
+ flate2 = { version = "1", features = ["rust_backend"] }
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ # Builds the Rust crate with cargo and installs the library as
7
+ # lib/tonic/rails/tonic_rails_native.{so,bundle}, matching
8
+ # `require "tonic/rails/tonic_rails_native"`.
9
+ create_rust_makefile("tonic/rails/tonic_rails_native")