tonic-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: c1578d591791ff33ce8365ae3fe5b5a6bb5dfbac8759e4f9762c7c511e71057a
4
- data.tar.gz: e23782ccd2cf964d5db2cf25c98547780525e20d7880f0dab85a0c4c956b45cd
3
+ metadata.gz: df7cc56c6ca7ca2594bc38ab5e676d9c68f6dbcd6464155d14b3d7a83ef98ef2
4
+ data.tar.gz: 9763e38411184f3c9a796f9e72bf9238ae3e20c16c7687cefa3792192cba7516
5
5
  SHA512:
6
- metadata.gz: 616f736a4e29cd3fcf8fd4297d6159d9abb145f3973b27a08f6f2c153bf7f5ae87422870ef304dab552e62f8ef7b3037716432c89bfd5d12088ef97e1ae58166
7
- data.tar.gz: 07a9749e9188700473883761291731a3f0b947bcdf4d986cd55df81dcb658229863149cfda1174a186a0cfe321e8e09810b7c1113232fefdc38c0ca2f1bba962
6
+ metadata.gz: d2bf2cad63a78055da0874e0a934a44f41a015c9dd98d2512b1b8ef8b6830cfdb64d262d65c55fad5b532574ec6b7a63f4777f79b6f13e7d23dd06808417c46c
7
+ data.tar.gz: 04df783ef65a2eb4d8a717d4643e4e406a0040a053289d5c2841014c6bb69ec267223e2bf52b9323dc1314b179969e7bb6da2606fee8bf4f93121b2932f5ce2a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ minor versions may contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - gRPC-Web: `config.grpc_web = true` serves browser clients (unary and
15
+ server-streaming RPCs, binary and text encodings) without a proxy, on the
16
+ same port as native gRPC, with CORS via `grpc_web_origins`,
17
+ `grpc_web_allowed_headers` and `grpc_web_exposed_headers`. Closes #22.
18
+
10
19
  ## [0.3.0] - 2026-10-11
11
20
 
12
21
  ### Added
@@ -116,7 +125,8 @@ First public release.
116
125
  - Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
117
126
  macOS (x86_64, arm64), Ruby 3.3 to 4.0.
118
127
 
119
- [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...HEAD
128
+ [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.4.0...HEAD
129
+ [0.4.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...v0.4.0
120
130
  [0.3.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.2.0...v0.3.0
121
131
  [0.2.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...v0.2.0
122
132
  [0.1.0]: https://github.com/CodingAnarchy/tonic-rails/releases/tag/v0.1.0
data/Cargo.lock CHANGED
@@ -1021,9 +1021,27 @@ dependencies = [
1021
1021
  "tonic-prost",
1022
1022
  ]
1023
1023
 
1024
+ [[package]]
1025
+ name = "tonic-web"
1026
+ version = "0.14.6"
1027
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1028
+ checksum = "b5e6a1b6319ca4b61a4c0f0c94d439c8f3ed344cca56fe0df40e1fe4be11380b"
1029
+ dependencies = [
1030
+ "base64",
1031
+ "bytes",
1032
+ "http",
1033
+ "http-body",
1034
+ "pin-project",
1035
+ "tokio-stream",
1036
+ "tonic",
1037
+ "tower-layer",
1038
+ "tower-service",
1039
+ "tracing",
1040
+ ]
1041
+
1024
1042
  [[package]]
1025
1043
  name = "tonic_rails_native"
1026
- version = "0.3.0"
1044
+ version = "0.4.0"
1027
1045
  dependencies = [
1028
1046
  "axum",
1029
1047
  "bytes",
@@ -1043,7 +1061,9 @@ dependencies = [
1043
1061
  "tonic",
1044
1062
  "tonic-health",
1045
1063
  "tonic-reflection",
1064
+ "tonic-web",
1046
1065
  "tower",
1066
+ "tower-http",
1047
1067
  ]
1048
1068
 
1049
1069
  [[package]]
@@ -1065,6 +1085,20 @@ dependencies = [
1065
1085
  "tracing",
1066
1086
  ]
1067
1087
 
1088
+ [[package]]
1089
+ name = "tower-http"
1090
+ version = "0.6.11"
1091
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1092
+ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
1093
+ dependencies = [
1094
+ "bitflags",
1095
+ "bytes",
1096
+ "http",
1097
+ "pin-project-lite",
1098
+ "tower-layer",
1099
+ "tower-service",
1100
+ ]
1101
+
1068
1102
  [[package]]
1069
1103
  name = "tower-layer"
1070
1104
  version = "0.3.3"
data/README.md CHANGED
@@ -363,6 +363,44 @@ Tonic::Rails.configure do |config|
363
363
  end
364
364
  ```
365
365
 
366
+ ## Browser clients (gRPC-Web)
367
+
368
+ Browsers can't speak native gRPC, but they can speak
369
+ [gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md).
370
+ Turn it on and your existing handlers serve web frontends directly, with
371
+ no Envoy or other proxy:
372
+
373
+ ```ruby
374
+ Tonic::Rails.configure do |config|
375
+ config.grpc_web = true
376
+ config.grpc_web_origins = ["https://app.example.com"] # CORS; omit for same-origin only
377
+ config.grpc_web_exposed_headers = %w[x-request-id] # response metadata browsers may read
378
+ end
379
+ ```
380
+
381
+ Then call it from the browser with any gRPC-Web client, for example
382
+ [Connect](https://connectrpc.com/docs/web/using-clients):
383
+
384
+ ```ts
385
+ import { createClient } from "@connectrpc/connect";
386
+ import { createGrpcWebTransport } from "@connectrpc/connect-web";
387
+ import { ItemService } from "./gen/demo/v1/items_pb";
388
+
389
+ const client = createClient(ItemService, createGrpcWebTransport({ baseUrl: "https://api.example.com" }));
390
+ const item = await client.getItem({ id: 1n });
391
+ ```
392
+
393
+ - Unary and server-streaming RPCs work; browsers can't do client or
394
+ bidirectional streaming.
395
+ - Errors, response headers and trailers reach the browser as usual.
396
+ - Native gRPC clients keep working on the same port. Enabling gRPC-Web
397
+ also accepts HTTP/1.1, which browsers use without TLS; over HTTPS they
398
+ negotiate HTTP/2.
399
+ - `grpc_web_origins` sets CORS: listed origins may call with credentials
400
+ (cookies); `"*"` allows any origin without credentials (logged as a
401
+ warning). `grpc_web_allowed_headers` adds request headers beyond the
402
+ standard ones (`authorization` is already allowed).
403
+
366
404
  ## Testing handlers
367
405
 
368
406
  `Tonic::Rails::Testing` calls a handler in-process, with real protobuf
@@ -441,6 +479,9 @@ end
441
479
  | `tls_client_ca_path` | `nil` | PEM CA bundle; requires client certificates (mutual TLS) |
442
480
  | `drain_timeout` | `10` | Seconds to let in-flight calls finish on shutdown |
443
481
  | `keepalive_interval`, `keepalive_timeout` | `15`, `5` | HTTP/2 PING interval and timeout, seconds |
482
+ | `grpc_web` | `false` | Serve gRPC-Web for browser clients (see [Browser clients](#browser-clients-grpc-web)) |
483
+ | `grpc_web_origins` | `[]` | Browser origins allowed via CORS; `"*"` for any (no credentials) |
484
+ | `grpc_web_allowed_headers`, `grpc_web_exposed_headers` | `[]` | Extra CORS request / response headers |
444
485
  | `health_check` | `nil` | Callable; while false or raising, health reports `NOT_SERVING` |
445
486
  | `health_check_interval` | `5` | Seconds between health checks |
446
487
  | `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tonic_rails_native"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  publish = false
@@ -28,6 +28,8 @@ rb-sys = "0.9"
28
28
  tonic = { version = "0.14", default-features = false, features = ["transport", "router", "server", "tls-ring"] }
29
29
  tonic-reflection = { version = "0.14", default-features = false, features = ["server"] }
30
30
  tonic-health = { version = "0.14", default-features = false }
31
+ tonic-web = { version = "0.14", default-features = false }
32
+ tower-http = { version = "0.6", default-features = false, features = ["cors"] }
31
33
  axum = { version = "0.8", default-features = false }
32
34
  tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time"] }
33
35
  tokio-stream = { version = "0.1", features = ["net"] }
@@ -64,6 +64,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
64
64
  )?;
65
65
  class.define_method("set_compression", method!(NativeServer::set_compression, 1))?;
66
66
  class.define_method("set_reflection", method!(NativeServer::set_reflection, 1))?;
67
+ class.define_method("set_grpc_web", method!(NativeServer::set_grpc_web, 3))?;
67
68
  class.define_method("set_routes", method!(NativeServer::set_routes, 1))?;
68
69
  class.define_method(
69
70
  "set_reflection_descriptors",
@@ -29,6 +29,9 @@ use tokio::sync::{Semaphore, watch};
29
29
  use tonic_health::ServingStatus;
30
30
  use tonic_health::server::HealthReporter;
31
31
 
32
+ use http::{HeaderName, HeaderValue, Method};
33
+ use tower_http::cors::{AllowOrigin, CorsLayer};
34
+
32
35
  use crate::body::ServerStats;
33
36
  use crate::dispatch::{DispatchInner, DispatchService};
34
37
  use crate::gvl::{self, Cancel, DispatchContext};
@@ -47,6 +50,7 @@ struct Options {
47
50
  keepalive_interval: Duration,
48
51
  keepalive_timeout: Duration,
49
52
  reflection: bool,
53
+ grpc_web: Option<GrpcWebOptions>,
50
54
  dispatch_queue_size: usize,
51
55
  routes: Vec<String>,
52
56
  descriptors: Vec<Vec<u8>>,
@@ -63,6 +67,7 @@ impl Default for Options {
63
67
  keepalive_interval: Duration::from_secs(15),
64
68
  keepalive_timeout: Duration::from_secs(5),
65
69
  reflection: false,
70
+ grpc_web: None,
66
71
  dispatch_queue_size: 128,
67
72
  routes: Vec::new(),
68
73
  descriptors: Vec::new(),
@@ -70,6 +75,76 @@ impl Default for Options {
70
75
  }
71
76
  }
72
77
 
78
+ /// gRPC-Web and the CORS policy browsers need to use it.
79
+ struct GrpcWebOptions {
80
+ /// Allowed browser origins; empty = same-origin only; ["*"] = any.
81
+ origins: Vec<String>,
82
+ allow_headers: Vec<String>,
83
+ expose_headers: Vec<String>,
84
+ }
85
+
86
+ /// Request headers gRPC-Web clients send, beyond the CORS-safelisted ones.
87
+ const GRPC_WEB_REQUEST_HEADERS: [&str; 5] = [
88
+ "content-type",
89
+ "x-grpc-web",
90
+ "x-user-agent",
91
+ "grpc-timeout",
92
+ "authorization",
93
+ ];
94
+ /// Response headers browsers must be allowed to read for gRPC-Web.
95
+ const GRPC_WEB_RESPONSE_HEADERS: [&str; 3] = ["grpc-status", "grpc-message", "grpc-status-details-bin"];
96
+
97
+ fn header_names(ruby: &Ruby, defaults: &[&str], extra: &[String]) -> Result<Vec<HeaderName>, Error> {
98
+ defaults
99
+ .iter()
100
+ .map(|name| name.to_string())
101
+ .chain(extra.iter().map(|name| name.to_ascii_lowercase()))
102
+ .map(|name| {
103
+ HeaderName::from_bytes(name.as_bytes())
104
+ .map_err(|_| arg_error(ruby, format!("invalid gRPC-Web header name: {name:?}")))
105
+ })
106
+ .collect()
107
+ }
108
+
109
+ /// None when no cross-origin access is configured (same-origin only).
110
+ fn cors_layer(ruby: &Ruby, web: &GrpcWebOptions) -> Result<Option<CorsLayer>, Error> {
111
+ if web.origins.is_empty() {
112
+ return Ok(None);
113
+ }
114
+ let layer = CorsLayer::new()
115
+ .allow_methods([Method::POST])
116
+ .allow_headers(header_names(ruby, &GRPC_WEB_REQUEST_HEADERS, &web.allow_headers)?)
117
+ .expose_headers(header_names(
118
+ ruby,
119
+ &GRPC_WEB_RESPONSE_HEADERS,
120
+ &web.expose_headers,
121
+ )?)
122
+ .max_age(Duration::from_secs(24 * 60 * 60));
123
+ if web.origins.iter().any(|origin| origin == "*") {
124
+ if web.origins.len() > 1 {
125
+ return Err(arg_error(
126
+ ruby,
127
+ "grpc_web_origins: \"*\" can't be combined with other origins",
128
+ ));
129
+ }
130
+ // Browsers refuse credentials with a wildcard origin, so none here.
131
+ return Ok(Some(layer.allow_origin(AllowOrigin::any())));
132
+ }
133
+ let origins = web
134
+ .origins
135
+ .iter()
136
+ .map(|origin| {
137
+ HeaderValue::from_str(origin.trim_end_matches('/'))
138
+ .map_err(|_| arg_error(ruby, format!("invalid gRPC-Web origin: {origin:?}")))
139
+ })
140
+ .collect::<Result<Vec<_>, _>>()?;
141
+ Ok(Some(
142
+ layer
143
+ .allow_origin(AllowOrigin::list(origins))
144
+ .allow_credentials(true),
145
+ ))
146
+ }
147
+
73
148
  /// State that exists only while the server is running.
74
149
  struct Running {
75
150
  runtime: Runtime,
@@ -199,6 +274,20 @@ impl NativeServer {
199
274
  self.compression.store(enabled, Ordering::Relaxed);
200
275
  }
201
276
 
277
+ /// Enable gRPC-Web (and HTTP/1.1, which browsers use without TLS).
278
+ pub fn set_grpc_web(
279
+ &self,
280
+ origins: Vec<String>,
281
+ allow_headers: Vec<String>,
282
+ expose_headers: Vec<String>,
283
+ ) {
284
+ self.options.lock().grpc_web = Some(GrpcWebOptions {
285
+ origins,
286
+ allow_headers,
287
+ expose_headers,
288
+ });
289
+ }
290
+
202
291
  pub fn set_reflection(&self, enabled: bool) {
203
292
  self.options.lock().reflection = enabled;
204
293
  }
@@ -347,7 +436,17 @@ impl NativeServer {
347
436
  .tls_config(tls)
348
437
  .map_err(|e| runtime_error(ruby, format!("invalid TLS configuration: {e}")))?;
349
438
  }
350
- let router = builder.add_routes(grpc_routes);
439
+ // CORS outermost, so preflight requests are answered before the
440
+ // gRPC-Web translation sees them. Both are no-ops when disabled.
441
+ let (cors, grpc_web) = match &options.grpc_web {
442
+ Some(web) => (cors_layer(ruby, web)?, Some(tonic_web::GrpcWebLayer::new())),
443
+ None => (None, None),
444
+ };
445
+ let router = builder
446
+ .accept_http1(options.grpc_web.is_some())
447
+ .layer(tower::util::option_layer(cors))
448
+ .layer(tower::util::option_layer(grpc_web))
449
+ .add_routes(grpc_routes);
351
450
 
352
451
  let permits = match options.max_connections {
353
452
  0 => Semaphore::MAX_PERMITS,
@@ -66,6 +66,25 @@ module Tonic
66
66
  # API schema; keep it off in production. Default false.
67
67
  attr_accessor :reflection
68
68
 
69
+ # Accept gRPC-Web requests, so browser apps can call services
70
+ # directly (unary and server-streaming RPCs). Also enables HTTP/1.1,
71
+ # which browsers use when there's no TLS. Default false.
72
+ attr_accessor :grpc_web
73
+
74
+ # Browser origins allowed to call gRPC-Web services (CORS), e.g.
75
+ # ["https://app.example.com"]. Empty = same-origin only. "*" allows
76
+ # any origin, without credentials (cookies). Default [].
77
+ attr_accessor :grpc_web_origins
78
+
79
+ # Extra request headers browsers may send (beyond content-type,
80
+ # x-grpc-web, x-user-agent, grpc-timeout and authorization).
81
+ attr_accessor :grpc_web_allowed_headers
82
+
83
+ # Extra response headers browsers may read, e.g. custom response
84
+ # metadata (grpc-status, grpc-message and grpc-status-details-bin are
85
+ # always exposed).
86
+ attr_accessor :grpc_web_exposed_headers
87
+
69
88
  # HTTP/2 PING interval and ack timeout, in seconds. Defaults 15 / 5.
70
89
  attr_accessor :keepalive_interval, :keepalive_timeout
71
90
 
@@ -105,6 +124,10 @@ module Tonic
105
124
  @keepalive_interval = 15
106
125
  @keepalive_timeout = 5
107
126
  @reload_handlers = false
127
+ @grpc_web = false
128
+ @grpc_web_origins = []
129
+ @grpc_web_allowed_headers = []
130
+ @grpc_web_exposed_headers = []
108
131
  @health_check = nil
109
132
  @health_check_interval = 5
110
133
  end
@@ -128,12 +151,22 @@ module Tonic
128
151
  raise ArgumentError, "tls_client_ca_path requires tls_cert_path and tls_key_path" if tls_client_ca_path && !tls?
129
152
 
130
153
  validate_callables!
154
+ validate_grpc_web!
131
155
 
132
156
  self
133
157
  end
134
158
 
135
159
  private
136
160
 
161
+ def validate_grpc_web!
162
+ %i[grpc_web_origins grpc_web_allowed_headers grpc_web_exposed_headers].each do |name|
163
+ value = public_send(name)
164
+ next if value.is_a?(Array) && value.all?(String)
165
+
166
+ raise ArgumentError, "#{name} must be an Array of Strings, got #{value.inspect}"
167
+ end
168
+ end
169
+
137
170
  def validate_callables!
138
171
  raise ArgumentError, "health_check must respond to #call" if health_check && !health_check.respond_to?(:call)
139
172
  unless health_check_interval.is_a?(Numeric) && health_check_interval.positive?
@@ -130,19 +130,30 @@ module Tonic
130
130
  native = TonicRails::Native::Server.new(config.bind_address, config.max_message_size)
131
131
  native.on_request(build_callback)
132
132
  native.set_routes(@router.paths)
133
- if config.tls?
134
- native.set_tls(config.tls_cert_path, config.tls_key_path)
135
- native.set_tls_client_ca(config.tls_client_ca_path) if config.tls_client_ca_path
136
- end
133
+ apply_connection_settings(native, config)
134
+ apply_features(native, config)
135
+ native
136
+ end
137
+
138
+ def apply_connection_settings(native, config)
137
139
  native.set_max_connections(config.max_connections)
138
140
  native.set_concurrency_limit_per_connection(config.concurrency_limit_per_connection)
139
141
  native.set_worker_threads(config.worker_threads || 0)
140
142
  native.set_keepalive(config.keepalive_interval.to_f, config.keepalive_timeout.to_f)
141
143
  native.set_dispatch_queue_size(config.dispatch_queue_size)
144
+ end
145
+
146
+ def apply_features(native, config)
147
+ if config.tls?
148
+ native.set_tls(config.tls_cert_path, config.tls_key_path)
149
+ native.set_tls_client_ca(config.tls_client_ca_path) if config.tls_client_ca_path
150
+ end
142
151
  native.set_compression(config.compression ? true : false)
143
152
  native.set_reflection(config.reflection ? true : false)
144
153
  native.set_reflection_descriptors(Reflection.file_descriptors(@router.service_names)) if config.reflection
145
- native
154
+ return unless config.grpc_web
155
+
156
+ native.set_grpc_web(config.grpc_web_origins, config.grpc_web_allowed_headers, config.grpc_web_exposed_headers)
146
157
  end
147
158
 
148
159
  # The proc the transport calls for every RPC:
@@ -273,6 +284,16 @@ module Tonic
273
284
  logger&.info("tonic-rails: stopped")
274
285
  end
275
286
 
287
+ def log_grpc_web(logger, config)
288
+ return unless config.grpc_web
289
+
290
+ origins = config.grpc_web_origins
291
+ logger.info("tonic-rails: gRPC-Web enabled (#{origins.empty? ? 'same-origin only' : "origins: #{origins.join(', ')}"})")
292
+ return unless origins.include?("*")
293
+
294
+ logger.warn("tonic-rails: gRPC-Web allows any origin; any website can call these services from a visitor's browser")
295
+ end
296
+
276
297
  def log_startup
277
298
  config = @configuration
278
299
  logger = config.logger
@@ -284,6 +305,7 @@ module Tonic
284
305
  logger.warn("tonic-rails: TLS is not configured; gRPC traffic is plaintext")
285
306
  end
286
307
  logger.info("tonic-rails: reflection enabled") if config.reflection
308
+ log_grpc_web(logger, config)
287
309
  logger.info(
288
310
  "tonic-rails: serving #{@router.service_names.join(', ')} on #{config.bind_address} " \
289
311
  "(#{config.dispatch_threads} dispatch threads)",
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tonic-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
  - Matt Tanous