restate-sdk 0.11.0 → 0.12.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.
@@ -0,0 +1,269 @@
1
+ # typed: ignore
2
+ # frozen_string_literal: true
3
+
4
+ require 'async'
5
+ require 'async/queue'
6
+ require 'logger'
7
+ require_relative 'context'
8
+
9
+ module Restate
10
+ module Server
11
+ # Rack-compatible application that handles Restate protocol requests.
12
+ # Designed to work with Falcon for HTTP/2 bidirectional streaming.
13
+ #
14
+ # Routes:
15
+ # GET /discover → service manifest
16
+ # GET /health → health check
17
+ # POST /invoke/:service/:handler → handler invocation
18
+ class Handler
19
+ SDK_VERSION = Internal::SDK_VERSION
20
+ X_RESTATE_SERVER = "restate-sdk-ruby/#{SDK_VERSION}".freeze
21
+
22
+ LOGGER = Logger.new($stdout, progname: 'Restate::Server::Handler')
23
+
24
+ def initialize(endpoint)
25
+ @endpoint = endpoint
26
+ @identity_verifier = Internal::IdentityVerifier.new(endpoint.identity_keys)
27
+ end
28
+
29
+ # Rack interface
30
+ def call(env)
31
+ path = env['PATH_INFO'] || '/'
32
+ parsed = parse_path(path)
33
+
34
+ case parsed[:type]
35
+ when :health
36
+ health_response
37
+ when :discover
38
+ handle_discover(env)
39
+ when :invocation
40
+ handle_invocation(env, parsed[:service], parsed[:handler])
41
+ else
42
+ not_found_response
43
+ end
44
+ rescue StandardError => e
45
+ LOGGER.error("Exception in Restate server: #{e.inspect}")
46
+ LOGGER.error(e.backtrace&.join("\n")) if e.backtrace
47
+ error_response(500, 'Internal server error')
48
+ end
49
+
50
+ private
51
+
52
+ def parse_path(path)
53
+ segments = path.split('/').reject(&:empty?)
54
+
55
+ # Check for /invoke/:service/:handler
56
+ if segments.length >= 3
57
+ invoke_idx = segments.rindex('invoke')
58
+ if invoke_idx && segments.length > invoke_idx + 2
59
+ return {
60
+ type: :invocation,
61
+ service: segments[invoke_idx + 1],
62
+ handler: segments[invoke_idx + 2]
63
+ }
64
+ end
65
+ end
66
+
67
+ case segments.last
68
+ when 'health'
69
+ { type: :health }
70
+ when 'discover'
71
+ { type: :discover }
72
+ else
73
+ { type: :unknown }
74
+ end
75
+ end
76
+
77
+ def health_response
78
+ [200, { 'content-type' => 'application/json', 'x-restate-server' => X_RESTATE_SERVER }, ['{"status":"ok"}']]
79
+ end
80
+
81
+ def not_found_response
82
+ [404, { 'x-restate-server' => X_RESTATE_SERVER }, ['']]
83
+ end
84
+
85
+ def error_response(status, message)
86
+ [status, { 'content-type' => 'text/plain', 'x-restate-server' => X_RESTATE_SERVER }, [message]]
87
+ end
88
+
89
+ def handle_discover(env)
90
+ # Detect HTTP version for protocol mode
91
+ http_version = env['HTTP_VERSION'] || env['SERVER_PROTOCOL'] || 'HTTP/1.1'
92
+ discovered_as = http_version.include?('2') ? 'bidi' : 'request_response'
93
+
94
+ # Negotiate discovery protocol version from Accept header
95
+ accept = env['HTTP_ACCEPT'] || ''
96
+ version = negotiate_version(accept)
97
+ return error_response(415, "Unsupported discovery version: #{accept}") unless version
98
+
99
+ begin
100
+ json = Discovery.compute_discovery_json(@endpoint, version, discovered_as)
101
+ content_type = "application/vnd.restate.endpointmanifest.v#{version}+json"
102
+ [
103
+ 200,
104
+ {
105
+ 'content-type' => content_type,
106
+ 'x-restate-server' => X_RESTATE_SERVER
107
+ },
108
+ [json]
109
+ ]
110
+ rescue StandardError => e
111
+ error_response(500, "Error computing discovery: #{e.message}")
112
+ end
113
+ end
114
+
115
+ def negotiate_version(accept)
116
+ if accept.include?('application/vnd.restate.endpointmanifest.v4+json')
117
+ 4
118
+ elsif accept.include?('application/vnd.restate.endpointmanifest.v3+json')
119
+ 3
120
+ elsif accept.include?('application/vnd.restate.endpointmanifest.v2+json')
121
+ 2
122
+ elsif accept.empty?
123
+ 2
124
+ end
125
+ end
126
+
127
+ def handle_invocation(env, service_name, handler_name)
128
+ # Verify identity
129
+ request_headers = extract_headers(env)
130
+ path = env['PATH_INFO'] || '/'
131
+ begin
132
+ @identity_verifier.verify(request_headers, path)
133
+ rescue Internal::IdentityVerificationError
134
+ return [401, { 'x-restate-server' => X_RESTATE_SERVER }, ['']]
135
+ end
136
+
137
+ # Find the service and handler
138
+ service = @endpoint.services[service_name]
139
+ return not_found_response unless service
140
+
141
+ handler = service.handlers[handler_name]
142
+ return not_found_response unless handler
143
+
144
+ # Process the invocation with streaming
145
+ process_invocation(env, handler, request_headers)
146
+ end
147
+
148
+ def process_invocation(env, handler, request_headers)
149
+ vm = VMWrapper.new(request_headers)
150
+ status, response_headers = vm.get_response_head
151
+
152
+ # Streaming response body — output chunks are sent to Restate as they're
153
+ # produced. This is critical for BidiStream mode where the VM needs output
154
+ # acknowledged before it can make further progress.
155
+ output_queue = Async::Queue.new
156
+ send_output = ->(chunk) { output_queue.enqueue(chunk) }
157
+
158
+ # Input queue bridges the HTTP body reader and the handler's progress loop.
159
+ input_queue = Async::Queue.new
160
+
161
+ # Read request body chunks and feed to VM until ready to execute,
162
+ # then continue feeding remaining chunks via the input queue.
163
+ rack_input = env['rack.input']
164
+ ready = false
165
+ if rack_input
166
+ # Feed chunks until the VM has enough to start execution
167
+ while (chunk = rack_input.read_partial(16_384))
168
+ vm.notify_input(chunk.b) unless chunk.empty?
169
+ if vm.is_ready_to_execute
170
+ ready = true
171
+ break
172
+ end
173
+ end
174
+ vm.notify_input_closed unless ready
175
+ end
176
+
177
+ invocation = vm.sys_input
178
+
179
+ # Spawn a background task to continue reading remaining input
180
+ if ready
181
+ Async do
182
+ while (chunk = rack_input.read_partial(16_384))
183
+ input_queue.enqueue(chunk.b) unless chunk.empty?
184
+ end
185
+ input_queue.enqueue(:eof)
186
+ rescue StandardError => e
187
+ LOGGER.error("Input reader error: #{e.inspect}")
188
+ input_queue.enqueue(:disconnected)
189
+ end
190
+ end
191
+
192
+ context = Context.new(
193
+ vm: vm,
194
+ handler: handler,
195
+ invocation: invocation,
196
+ send_output: send_output,
197
+ input_queue: input_queue,
198
+ middleware: @endpoint.middleware,
199
+ outbound_middleware: @endpoint.outbound_middleware
200
+ )
201
+
202
+ # Spawn the handler as an async task so the response body can stream
203
+ # output concurrently.
204
+ Async do
205
+ begin
206
+ context.enter
207
+ rescue DisconnectedError
208
+ # Client disconnected
209
+ rescue StandardError => e
210
+ LOGGER.error("Exception in handler: #{e.inspect}")
211
+ ensure
212
+ # Signal that the attempt is finished — wakes any waiters on
213
+ # ctx.request.attempt_finished_event and cancels pending background pool jobs.
214
+ context.on_attempt_finished
215
+ end
216
+
217
+ # Drain remaining output from VM
218
+ loop do
219
+ chunk = vm.take_output
220
+ break if chunk.nil? || chunk.empty?
221
+
222
+ output_queue.enqueue(chunk)
223
+ end
224
+
225
+ # Signal end of output
226
+ output_queue.enqueue(nil)
227
+ end
228
+
229
+ body = StreamingBody.new(output_queue)
230
+
231
+ merged_headers = response_headers.to_h { |pair| [pair[0], pair[1]] }
232
+ merged_headers['x-restate-server'] = X_RESTATE_SERVER
233
+
234
+ [status, merged_headers, body]
235
+ end
236
+
237
+ # Rack 3 streaming body that yields chunks from an Async::Queue.
238
+ # Terminates when nil is dequeued.
239
+ class StreamingBody
240
+ def initialize(queue)
241
+ @queue = queue
242
+ end
243
+
244
+ def each
245
+ loop do
246
+ chunk = @queue.dequeue
247
+ break if chunk.nil?
248
+
249
+ yield chunk
250
+ end
251
+ end
252
+ end
253
+
254
+ def extract_headers(env)
255
+ headers = []
256
+ env.each do |key, value|
257
+ next unless key.start_with?('HTTP_')
258
+
259
+ header_name = key.byteslice(5..).tr('_', '-').downcase!
260
+ headers << [header_name, value]
261
+ end
262
+ # Also include content-type and content-length if present
263
+ headers << ['content-type', env['CONTENT_TYPE']] if env['CONTENT_TYPE']
264
+ headers << ['content-length', env['CONTENT_LENGTH']] if env['CONTENT_LENGTH']
265
+ headers
266
+ end
267
+ end
268
+ end
269
+ end
@@ -1,266 +1,9 @@
1
- # typed: ignore
2
1
  # frozen_string_literal: true
3
2
 
4
- require 'async'
5
- require 'async/queue'
6
- require 'logger'
7
-
8
- module Restate
9
- # Rack-compatible application that handles Restate protocol requests.
10
- # Designed to work with Falcon for HTTP/2 bidirectional streaming.
11
- #
12
- # Routes:
13
- # GET /discover → service manifest
14
- # GET /health → health check
15
- # POST /invoke/:service/:handler → handler invocation
16
- class Server
17
- SDK_VERSION = Internal::SDK_VERSION
18
- X_RESTATE_SERVER = "restate-sdk-ruby/#{SDK_VERSION}".freeze
19
-
20
- LOGGER = Logger.new($stdout, progname: 'Restate::Server')
21
-
22
- def initialize(endpoint)
23
- @endpoint = endpoint
24
- @identity_verifier = Internal::IdentityVerifier.new(endpoint.identity_keys)
25
- end
26
-
27
- # Rack interface
28
- def call(env)
29
- path = env['PATH_INFO'] || '/'
30
- parsed = parse_path(path)
31
-
32
- case parsed[:type]
33
- when :health
34
- health_response
35
- when :discover
36
- handle_discover(env)
37
- when :invocation
38
- handle_invocation(env, parsed[:service], parsed[:handler])
39
- else
40
- not_found_response
41
- end
42
- rescue StandardError => e
43
- LOGGER.error("Exception in Restate server: #{e.inspect}")
44
- LOGGER.error(e.backtrace&.join("\n")) if e.backtrace
45
- error_response(500, 'Internal server error')
46
- end
47
-
48
- private
49
-
50
- def parse_path(path)
51
- segments = path.split('/').reject(&:empty?)
52
-
53
- # Check for /invoke/:service/:handler
54
- if segments.length >= 3
55
- invoke_idx = segments.rindex('invoke')
56
- if invoke_idx && segments.length > invoke_idx + 2
57
- return {
58
- type: :invocation,
59
- service: segments[invoke_idx + 1],
60
- handler: segments[invoke_idx + 2]
61
- }
62
- end
63
- end
64
-
65
- case segments.last
66
- when 'health'
67
- { type: :health }
68
- when 'discover'
69
- { type: :discover }
70
- else
71
- { type: :unknown }
72
- end
73
- end
74
-
75
- def health_response
76
- [200, { 'content-type' => 'application/json', 'x-restate-server' => X_RESTATE_SERVER }, ['{"status":"ok"}']]
77
- end
78
-
79
- def not_found_response
80
- [404, { 'x-restate-server' => X_RESTATE_SERVER }, ['']]
81
- end
82
-
83
- def error_response(status, message)
84
- [status, { 'content-type' => 'text/plain', 'x-restate-server' => X_RESTATE_SERVER }, [message]]
85
- end
86
-
87
- def handle_discover(env)
88
- # Detect HTTP version for protocol mode
89
- http_version = env['HTTP_VERSION'] || env['SERVER_PROTOCOL'] || 'HTTP/1.1'
90
- discovered_as = http_version.include?('2') ? 'bidi' : 'request_response'
91
-
92
- # Negotiate discovery protocol version from Accept header
93
- accept = env['HTTP_ACCEPT'] || ''
94
- version = negotiate_version(accept)
95
- return error_response(415, "Unsupported discovery version: #{accept}") unless version
96
-
97
- begin
98
- json = Discovery.compute_discovery_json(@endpoint, version, discovered_as)
99
- content_type = "application/vnd.restate.endpointmanifest.v#{version}+json"
100
- [
101
- 200,
102
- {
103
- 'content-type' => content_type,
104
- 'x-restate-server' => X_RESTATE_SERVER
105
- },
106
- [json]
107
- ]
108
- rescue StandardError => e
109
- error_response(500, "Error computing discovery: #{e.message}")
110
- end
111
- end
112
-
113
- def negotiate_version(accept)
114
- if accept.include?('application/vnd.restate.endpointmanifest.v4+json')
115
- 4
116
- elsif accept.include?('application/vnd.restate.endpointmanifest.v3+json')
117
- 3
118
- elsif accept.include?('application/vnd.restate.endpointmanifest.v2+json')
119
- 2
120
- elsif accept.empty?
121
- 2
122
- end
123
- end
124
-
125
- def handle_invocation(env, service_name, handler_name)
126
- # Verify identity
127
- request_headers = extract_headers(env)
128
- path = env['PATH_INFO'] || '/'
129
- begin
130
- @identity_verifier.verify(request_headers, path)
131
- rescue Internal::IdentityVerificationError
132
- return [401, { 'x-restate-server' => X_RESTATE_SERVER }, ['']]
133
- end
134
-
135
- # Find the service and handler
136
- service = @endpoint.services[service_name]
137
- return not_found_response unless service
138
-
139
- handler = service.handlers[handler_name]
140
- return not_found_response unless handler
141
-
142
- # Process the invocation with streaming
143
- process_invocation(env, handler, request_headers)
144
- end
145
-
146
- def process_invocation(env, handler, request_headers)
147
- vm = VMWrapper.new(request_headers)
148
- status, response_headers = vm.get_response_head
149
-
150
- # Streaming response body — output chunks are sent to Restate as they're
151
- # produced. This is critical for BidiStream mode where the VM needs output
152
- # acknowledged before it can make further progress.
153
- output_queue = Async::Queue.new
154
- send_output = ->(chunk) { output_queue.enqueue(chunk) }
155
-
156
- # Input queue bridges the HTTP body reader and the handler's progress loop.
157
- input_queue = Async::Queue.new
158
-
159
- # Read request body chunks and feed to VM until ready to execute,
160
- # then continue feeding remaining chunks via the input queue.
161
- rack_input = env['rack.input']
162
- ready = false
163
- if rack_input
164
- # Feed chunks until the VM has enough to start execution
165
- while (chunk = rack_input.read_partial(16_384))
166
- vm.notify_input(chunk.b) unless chunk.empty?
167
- if vm.is_ready_to_execute
168
- ready = true
169
- break
170
- end
171
- end
172
- vm.notify_input_closed unless ready
173
- end
174
-
175
- invocation = vm.sys_input
176
-
177
- # Spawn a background task to continue reading remaining input
178
- if ready
179
- Async do
180
- while (chunk = rack_input.read_partial(16_384))
181
- input_queue.enqueue(chunk.b) unless chunk.empty?
182
- end
183
- input_queue.enqueue(:eof)
184
- rescue StandardError => e
185
- LOGGER.error("Input reader error: #{e.inspect}")
186
- input_queue.enqueue(:disconnected)
187
- end
188
- end
189
-
190
- context = ServerContext.new(
191
- vm: vm,
192
- handler: handler,
193
- invocation: invocation,
194
- send_output: send_output,
195
- input_queue: input_queue,
196
- middleware: @endpoint.middleware,
197
- outbound_middleware: @endpoint.outbound_middleware
198
- )
199
-
200
- # Spawn the handler as an async task so the response body can stream
201
- # output concurrently.
202
- Async do
203
- begin
204
- context.enter
205
- rescue DisconnectedError
206
- # Client disconnected
207
- rescue StandardError => e
208
- LOGGER.error("Exception in handler: #{e.inspect}")
209
- ensure
210
- # Signal that the attempt is finished — wakes any waiters on
211
- # ctx.request.attempt_finished_event and cancels pending background pool jobs.
212
- context.on_attempt_finished
213
- end
214
-
215
- # Drain remaining output from VM
216
- loop do
217
- chunk = vm.take_output
218
- break if chunk.nil? || chunk.empty?
219
-
220
- output_queue.enqueue(chunk)
221
- end
222
-
223
- # Signal end of output
224
- output_queue.enqueue(nil)
225
- end
226
-
227
- body = StreamingBody.new(output_queue)
228
-
229
- merged_headers = response_headers.to_h { |pair| [pair[0], pair[1]] }
230
- merged_headers['x-restate-server'] = X_RESTATE_SERVER
231
-
232
- [status, merged_headers, body]
233
- end
234
-
235
- # Rack 3 streaming body that yields chunks from an Async::Queue.
236
- # Terminates when nil is dequeued.
237
- class StreamingBody
238
- def initialize(queue)
239
- @queue = queue
240
- end
241
-
242
- def each
243
- loop do
244
- chunk = @queue.dequeue
245
- break if chunk.nil?
246
-
247
- yield chunk
248
- end
249
- end
250
- end
251
-
252
- def extract_headers(env)
253
- headers = []
254
- env.each do |key, value|
255
- next unless key.start_with?('HTTP_')
256
-
257
- header_name = key.byteslice(5..).tr('_', '-').downcase!
258
- headers << [header_name, value]
259
- end
260
- # Also include content-type and content-length if present
261
- headers << ['content-type', env['CONTENT_TYPE']] if env['CONTENT_TYPE']
262
- headers << ['content-length', env['CONTENT_LENGTH']] if env['CONTENT_LENGTH']
263
- headers
264
- end
265
- end
266
- end
3
+ # Convenience entry point: `require "restate/server"` loads the core SDK
4
+ # plus the server module (Rack app + async execution context).
5
+ #
6
+ # Use this when you want both core and server loaded eagerly, e.g.:
7
+ # gem "restate-sdk", require: "restate/server"
8
+ require_relative '../restate'
9
+ require_relative 'server/handler'
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Restate
5
- VERSION = '0.11.0'
5
+ VERSION = '0.12.0'
6
6
  end
data/lib/restate.rb CHANGED
@@ -11,7 +11,6 @@ require_relative 'restate/service_dsl'
11
11
  require_relative 'restate/service'
12
12
  require_relative 'restate/virtual_object'
13
13
  require_relative 'restate/workflow'
14
- require_relative 'restate/server_context'
15
14
  require_relative 'restate/durable_future'
16
15
  require_relative 'restate/discovery'
17
16
  require_relative 'restate/endpoint'
data/sig/restate.rbs CHANGED
@@ -185,8 +185,14 @@ module Restate
185
185
 
186
186
  # ── Internal ──
187
187
 
188
- class Server
189
- def initialize: (untyped endpoint) -> void
188
+ module Server
189
+ class Handler
190
+ def initialize: (untyped endpoint) -> void
191
+ def call: (Hash[String, untyped] env) -> [Integer, Hash[String, String], untyped]
192
+ end
193
+
194
+ class Context
195
+ end
190
196
  end
191
197
 
192
198
  module JsonSerde
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: restate-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Restate Developers
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-12 00:00:00.000000000 Z
11
+ date: 2026-05-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -68,7 +68,8 @@ files:
68
68
  - lib/restate/railtie.rb
69
69
  - lib/restate/serde.rb
70
70
  - lib/restate/server.rb
71
- - lib/restate/server_context.rb
71
+ - lib/restate/server/context.rb
72
+ - lib/restate/server/handler.rb
72
73
  - lib/restate/service.rb
73
74
  - lib/restate/service_dsl.rb
74
75
  - lib/restate/service_proxy.rb