otto 2.5.0 → 2.7.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 +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.pre-commit-config.yaml +22 -5
- data/CHANGELOG.rst +283 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +14 -12
- data/README.md +13 -3
- data/docs/.gitignore +1 -0
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
- data/docs/geo-country.md +172 -0
- data/docs/reverse-proxy-network-services.md +19 -6
- data/examples/advanced_routes/README.md +49 -0
- data/examples/advanced_routes/config.rb +15 -2
- data/examples/advanced_routes/routes +12 -0
- data/examples/lambda_handlers/README.md +128 -0
- data/examples/lambda_handlers/config.ru +26 -0
- data/examples/lambda_handlers/handlers.rb +75 -0
- data/examples/lambda_handlers/routes +28 -0
- data/examples/simple_geo_resolver.rb +38 -5
- data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +72 -25
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +43 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/logging_helpers.rb +50 -1
- data/lib/otto/mcp/rate_limiting.rb +5 -2
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +281 -3
- data/lib/otto/privacy/core.rb +104 -8
- data/lib/otto/privacy/geo_resolver.rb +228 -128
- data/lib/otto/privacy/ip_privacy.rb +24 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +4 -1
- data/lib/otto/request.rb +35 -1
- data/lib/otto/route.rb +103 -41
- data/lib/otto/route_definition.rb +56 -6
- data/lib/otto/route_handlers/base.rb +4 -0
- data/lib/otto/route_handlers/factory.rb +15 -0
- data/lib/otto/route_handlers/lambda.rb +47 -32
- data/lib/otto/security/authentication/auth_failure.rb +36 -2
- data/lib/otto/security/authentication/auth_strategy.rb +12 -2
- data/lib/otto/security/authentication/authorization_failure.rb +7 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
- data/lib/otto/security/config.rb +123 -6
- data/lib/otto/security/core.rb +4 -1
- data/lib/otto/security/csp/policy.rb +135 -3
- data/lib/otto/security/csp/report_middleware.rb +3 -1
- data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
- data/lib/otto/security/csrf_validation.rb +75 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
- data/lib/otto/security/rate_limiter.rb +7 -1
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +100 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +37 -5
- metadata +13 -6
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
# Otto Streaming Architecture Analysis: SSE and WebSocket Support
|
|
2
|
+
|
|
3
|
+
**Author**: Claude Code Investigation
|
|
4
|
+
**Date**: 2025-11-08
|
|
5
|
+
**Scope**: Analysis of Server-Sent Events (SSE) and WebSocket support in Otto framework
|
|
6
|
+
|
|
7
|
+
> **Note**: The Ruby snippets below illustrate the recommended architecture. They
|
|
8
|
+
> are written against the current Otto Logic-class contract
|
|
9
|
+
> (`initialize(context, params, locale)` + `process`), but have not been executed
|
|
10
|
+
> end-to-end. Runnable examples are tracked separately.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Executive Summary
|
|
15
|
+
|
|
16
|
+
After comprehensive analysis of Otto's architecture and modern web framework patterns, **I recommend NOT integrating SSE/WebSocket support directly into Otto's core**. Instead, I recommend a **separation of concerns** approach where streaming functionality is handled by dedicated services or separate routing layers.
|
|
17
|
+
|
|
18
|
+
**Key Findings**:
|
|
19
|
+
- SSE and WebSocket are fundamentally incompatible with Otto's stateless, synchronous request/response model
|
|
20
|
+
- Modern frameworks (Rails, Sinatra, Roda) either run streaming as separate processes or require async server infrastructure
|
|
21
|
+
- Best practice is to separate real-time communication from REST API routing
|
|
22
|
+
- Otto should remain focused on stateless HTTP APIs with clear security guarantees
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 1. Current State: Otto's Architecture
|
|
27
|
+
|
|
28
|
+
### 1.1 Request/Response Lifecycle
|
|
29
|
+
|
|
30
|
+
Otto uses a **fully synchronous, stateless** request/response model:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
HTTP Request
|
|
34
|
+
→ Middleware IN (IPPrivacy, CSRF, RateLimit, Validation)
|
|
35
|
+
→ Route Matching (static → literal → dynamic → 404)
|
|
36
|
+
→ RouteAuthWrapper (per-route authentication)
|
|
37
|
+
→ Handler Execution (Logic class, instance method, or class method)
|
|
38
|
+
→ Response Handler (JSON, View, Redirect, Auto, Default)
|
|
39
|
+
→ Middleware OUT
|
|
40
|
+
→ HTTP Response (complete, connection closed)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 1.2 Response Handler System
|
|
44
|
+
|
|
45
|
+
Otto's response handling is based on the `response=` parameter:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# lib/otto/route_handlers/base.rb:97-103
|
|
49
|
+
handler_class = case response_type
|
|
50
|
+
in 'json' then Otto::ResponseHandlers::JSONHandler
|
|
51
|
+
in 'redirect' then Otto::ResponseHandlers::RedirectHandler
|
|
52
|
+
in 'view' then Otto::ResponseHandlers::ViewHandler
|
|
53
|
+
in 'auto' then Otto::ResponseHandlers::AutoHandler
|
|
54
|
+
else Otto::ResponseHandlers::DefaultHandler
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
handler_class.handle(result, response, context)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Current handlers generate complete responses**:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# lib/otto/response_handlers/json.rb:18-33
|
|
64
|
+
response['Content-Type'] = 'application/json'
|
|
65
|
+
response.body = [JSON.generate(data)]
|
|
66
|
+
ensure_status_set(response, context[:status_code] || 200)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The response body is **always an array** (`[JSON.generate(data)]`), finalized via:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# lib/otto/route_handlers/base.rb:85-86
|
|
73
|
+
res.body = [res.body] unless res.body.respond_to?(:each)
|
|
74
|
+
res.finish
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 1.3 Key Architectural Characteristics
|
|
78
|
+
|
|
79
|
+
1. **Stateless**: Each request is independent, no connection state maintained
|
|
80
|
+
2. **Synchronous**: Handler executes, response generated, connection closed
|
|
81
|
+
3. **Frozen Configuration**: All security config frozen after first request (prevents runtime bypasses)
|
|
82
|
+
4. **Thread-Safe**: Designed for concurrent requests with isolated contexts
|
|
83
|
+
5. **Privacy by Default**: IP masking, geo-location, anonymization happen in middleware
|
|
84
|
+
6. **Security First**: CSRF, validation, rate limiting, error handler registration
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 2. Technical Requirements: SSE and WebSocket
|
|
89
|
+
|
|
90
|
+
### 2.1 Server-Sent Events (SSE)
|
|
91
|
+
|
|
92
|
+
**Protocol**: HTTP-based unidirectional streaming (server → client)
|
|
93
|
+
|
|
94
|
+
**Technical Requirements**:
|
|
95
|
+
- Keep HTTP connection open indefinitely
|
|
96
|
+
- Stream data in `text/event-stream` format
|
|
97
|
+
- Requires Rack streaming response body (Rack 3+)
|
|
98
|
+
- Needs async server (Falcon, Puma with threaded mode, Iodine)
|
|
99
|
+
|
|
100
|
+
**Rack 3 Streaming Format**:
|
|
101
|
+
```ruby
|
|
102
|
+
# Modern Rack 3 approach
|
|
103
|
+
[200,
|
|
104
|
+
{'Content-Type' => 'text/event-stream'},
|
|
105
|
+
streaming_body_enumerator]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**SSE Example**:
|
|
109
|
+
```ruby
|
|
110
|
+
def sse_handler
|
|
111
|
+
stream = lambda do |out|
|
|
112
|
+
10.times do |i|
|
|
113
|
+
out << "data: #{i}\n\n"
|
|
114
|
+
sleep 1
|
|
115
|
+
end
|
|
116
|
+
out.close
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
[200, {'Content-Type' => 'text/event-stream'}, stream]
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### 2.2 WebSocket
|
|
124
|
+
|
|
125
|
+
**Protocol**: Bidirectional, full-duplex communication over TCP
|
|
126
|
+
|
|
127
|
+
**Technical Requirements**:
|
|
128
|
+
- HTTP upgrade handshake (101 Switching Protocols)
|
|
129
|
+
- Persistent TCP connection (not HTTP request/response)
|
|
130
|
+
- Requires Rack hijack API (`rack.hijack`, `rack.hijack_io`)
|
|
131
|
+
- Needs async server with WebSocket support (Falcon, Iodine, Puma)
|
|
132
|
+
- Frame-based binary protocol (not HTTP)
|
|
133
|
+
|
|
134
|
+
**Rack Hijack Example**:
|
|
135
|
+
```ruby
|
|
136
|
+
def websocket_handler(env)
|
|
137
|
+
if Faye::WebSocket.websocket?(env)
|
|
138
|
+
ws = Faye::WebSocket.new(env)
|
|
139
|
+
|
|
140
|
+
ws.on :message do |event|
|
|
141
|
+
ws.send(event.data)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
ws.on :close do |event|
|
|
145
|
+
ws = nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
ws.rack_response
|
|
149
|
+
else
|
|
150
|
+
[400, {}, ['Expected WebSocket connection']]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 3. Industry Patterns: How Other Frameworks Handle Streaming
|
|
158
|
+
|
|
159
|
+
### 3.1 Rails ActionCable (WebSocket)
|
|
160
|
+
|
|
161
|
+
**Architecture**: **Separate process/server** from main Rails app
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
# config/cable.yml
|
|
165
|
+
production:
|
|
166
|
+
adapter: redis
|
|
167
|
+
url: redis://localhost:6379/1
|
|
168
|
+
channel_prefix: myapp_production
|
|
169
|
+
|
|
170
|
+
# Separate ActionCable server process
|
|
171
|
+
# bin/cable
|
|
172
|
+
#!/usr/bin/env ruby
|
|
173
|
+
require_relative '../config/environment'
|
|
174
|
+
Rails::ActionCable::Server.start
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Key Insights**:
|
|
178
|
+
- ActionCable runs as **standalone process** (separate from Puma/Unicorn)
|
|
179
|
+
- Uses **Redis pub/sub** for message queue (stateless app servers)
|
|
180
|
+
- Rails app pushes to Redis, ActionCable streams to clients
|
|
181
|
+
- **Separation of concerns**: HTTP API ≠ WebSocket server
|
|
182
|
+
|
|
183
|
+
**Why Separate?**:
|
|
184
|
+
- Different scaling characteristics (long-lived vs short-lived connections)
|
|
185
|
+
- Different server requirements (async vs sync)
|
|
186
|
+
- Stateful WebSocket connections don't fit stateless Rails app model
|
|
187
|
+
|
|
188
|
+
### 3.2 Sinatra (SSE)
|
|
189
|
+
|
|
190
|
+
**Architecture**: **Requires async server** (Thin, Rainbows, Falcon)
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
# Gemfile
|
|
194
|
+
gem 'sinatra'
|
|
195
|
+
gem 'thin' # EventMachine-based async server
|
|
196
|
+
|
|
197
|
+
# app.rb
|
|
198
|
+
require 'sinatra'
|
|
199
|
+
require 'sinatra/streaming'
|
|
200
|
+
|
|
201
|
+
get '/stream' do
|
|
202
|
+
content_type 'text/event-stream'
|
|
203
|
+
stream(:keep_open) do |out|
|
|
204
|
+
EventMachine.add_periodic_timer(1) do
|
|
205
|
+
out << "data: #{Time.now}\n\n"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Run with Thin (NOT WEBrick/Puma)
|
|
211
|
+
# thin start -p 4567
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Key Insights**:
|
|
215
|
+
- **Must use EventMachine-based server** (Thin, Rainbows)
|
|
216
|
+
- Cannot use blocking servers (WEBrick, Mongrel)
|
|
217
|
+
- Sinatra's `streaming` plugin abstracts async complexity
|
|
218
|
+
- **Server dependency**: Framework requires specific infrastructure
|
|
219
|
+
|
|
220
|
+
### 3.3 Roda (SSE)
|
|
221
|
+
|
|
222
|
+
**Architecture**: **Streaming plugin** with async option
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
plugin :streaming
|
|
226
|
+
|
|
227
|
+
route do |r|
|
|
228
|
+
r.get 'stream' do
|
|
229
|
+
response['Content-Type'] = 'text/event-stream'
|
|
230
|
+
|
|
231
|
+
# Async streaming in separate thread
|
|
232
|
+
stream(async: true, loop: true) do |out|
|
|
233
|
+
out << "data: #{Time.now}\n\n"
|
|
234
|
+
sleep 1
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Key Insights**:
|
|
241
|
+
- Roda provides **plugin-based streaming** support
|
|
242
|
+
- `async: true` runs stream block in **separate thread**
|
|
243
|
+
- Uses `SizedQueue` for inter-thread communication
|
|
244
|
+
- **Still requires async server** (Falcon, Iodine) for production
|
|
245
|
+
|
|
246
|
+
### 3.4 Go (Gin/Echo) WebSocket
|
|
247
|
+
|
|
248
|
+
**Architecture**: **Route-level handler upgrade**
|
|
249
|
+
|
|
250
|
+
```go
|
|
251
|
+
// Gin framework
|
|
252
|
+
router := gin.Default()
|
|
253
|
+
|
|
254
|
+
// WebSocket route
|
|
255
|
+
router.GET("/ws", func(c *gin.Context) {
|
|
256
|
+
upgrader := websocket.Upgrader{}
|
|
257
|
+
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
258
|
+
if err != nil {
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
defer conn.Close()
|
|
262
|
+
|
|
263
|
+
// Handle WebSocket connection
|
|
264
|
+
for {
|
|
265
|
+
messageType, p, err := conn.ReadMessage()
|
|
266
|
+
if err != nil {
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
conn.WriteMessage(messageType, p)
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**Key Insights**:
|
|
275
|
+
- WebSocket handlers **coexist with HTTP routes** (same router)
|
|
276
|
+
- Go's goroutines enable **cheap concurrency** (not possible in Ruby)
|
|
277
|
+
- HTTP/2 and WebSocket support built into standard library
|
|
278
|
+
- **Language advantage**: Go's async model ≠ Ruby's threading model
|
|
279
|
+
|
|
280
|
+
### 3.5 Node.js (Express + Socket.IO)
|
|
281
|
+
|
|
282
|
+
**Architecture**: **Separate Socket.IO server** attached to HTTP server
|
|
283
|
+
|
|
284
|
+
```javascript
|
|
285
|
+
const express = require('express');
|
|
286
|
+
const http = require('http');
|
|
287
|
+
const socketIO = require('socket.io');
|
|
288
|
+
|
|
289
|
+
const app = express();
|
|
290
|
+
const server = http.createServer(app);
|
|
291
|
+
const io = socketIO(server);
|
|
292
|
+
|
|
293
|
+
// Regular HTTP routes
|
|
294
|
+
app.get('/api/users', (req, res) => {
|
|
295
|
+
res.json({ users: [] });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// WebSocket namespace (separate from HTTP routing)
|
|
299
|
+
io.on('connection', (socket) => {
|
|
300
|
+
socket.on('message', (data) => {
|
|
301
|
+
io.emit('message', data);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
server.listen(3000);
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**Key Insights**:
|
|
309
|
+
- Socket.IO is **separate layer** from Express routing
|
|
310
|
+
- Same HTTP server, **different routing/handling logic**
|
|
311
|
+
- Node's event loop enables async by default
|
|
312
|
+
- **Architectural separation**: HTTP routes ≠ WebSocket events
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## 4. Compatibility Analysis: SSE/WebSocket vs. Otto's Design
|
|
317
|
+
|
|
318
|
+
### 4.1 Fundamental Incompatibilities
|
|
319
|
+
|
|
320
|
+
| Otto Design Principle | SSE/WebSocket Requirement | Compatibility |
|
|
321
|
+
|----------------------|--------------------------|---------------|
|
|
322
|
+
| **Stateless request/response** | Long-lived stateful connections | ❌ Incompatible |
|
|
323
|
+
| **Synchronous handlers** | Async streaming enumerators | ❌ Incompatible |
|
|
324
|
+
| **Response finalized immediately** | Keep connection open indefinitely | ❌ Incompatible |
|
|
325
|
+
| **Thread-safe isolated contexts** | Shared connection state | ⚠️ Complex |
|
|
326
|
+
| **Frozen security config** | Runtime streaming config | ⚠️ Must freeze before first request |
|
|
327
|
+
| **Privacy by default (IP masking)** | Long-lived connection tracking | ⚠️ Can work but complex |
|
|
328
|
+
| **JSON/View/Redirect responses** | Streaming enumerator responses | ❌ Incompatible |
|
|
329
|
+
|
|
330
|
+
### 4.2 Technical Barriers
|
|
331
|
+
|
|
332
|
+
#### 4.2.1 Response Handler Architecture
|
|
333
|
+
|
|
334
|
+
**Current**: All response handlers generate **complete, finalized** responses:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
# lib/otto/response_handlers/json.rb
|
|
338
|
+
response.body = [JSON.generate(data)]
|
|
339
|
+
ensure_status_set(response, 200)
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
**Required for SSE**: Streaming enumerable that yields data over time:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
# Hypothetical SSE handler
|
|
346
|
+
response.body = Enumerator.new do |yielder|
|
|
347
|
+
loop do
|
|
348
|
+
yielder << "data: #{Time.now}\n\n"
|
|
349
|
+
sleep 1
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
**Problem**: Otto's `finalize_response` expects array-like body:
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
# lib/otto/route_handlers/base.rb:85
|
|
358
|
+
res.body = [res.body] unless res.body.respond_to?(:each)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
This would **wrap the enumerator in an array**, breaking streaming.
|
|
362
|
+
|
|
363
|
+
#### 4.2.2 Middleware Stack
|
|
364
|
+
|
|
365
|
+
**Current**: Middleware runs **before and after** handler execution:
|
|
366
|
+
|
|
367
|
+
```
|
|
368
|
+
IPPrivacyMiddleware IN
|
|
369
|
+
→ CSRFMiddleware IN
|
|
370
|
+
→ RateLimitMiddleware IN
|
|
371
|
+
→ Handler (complete response generated)
|
|
372
|
+
← RateLimitMiddleware OUT
|
|
373
|
+
← CSRFMiddleware OUT
|
|
374
|
+
← IPPrivacyMiddleware OUT
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
**Problem for SSE**: Once streaming starts, middleware cannot "unwind" because **connection is still open**:
|
|
378
|
+
|
|
379
|
+
```
|
|
380
|
+
IPPrivacyMiddleware IN
|
|
381
|
+
→ CSRFMiddleware IN
|
|
382
|
+
→ RateLimitMiddleware IN
|
|
383
|
+
→ SSE Handler (starts streaming)
|
|
384
|
+
→ [Connection stays open for minutes/hours]
|
|
385
|
+
→ [Middleware stack never unwinds]
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
**Consequences**:
|
|
389
|
+
- CSRF tokens can't be refreshed mid-stream
|
|
390
|
+
- Rate limiting can't be updated during stream
|
|
391
|
+
- IP privacy middleware can't re-mask IPs (not that it needs to)
|
|
392
|
+
- Error handling becomes complex (stream already started)
|
|
393
|
+
|
|
394
|
+
#### 4.2.3 Server Requirements
|
|
395
|
+
|
|
396
|
+
**Current**: Otto is **server-agnostic** (works with Puma, Unicorn, Passenger, WEBrick)
|
|
397
|
+
|
|
398
|
+
**Required**: SSE/WebSocket need **async servers**:
|
|
399
|
+
|
|
400
|
+
| Server | SSE Support | WebSocket Support | Notes |
|
|
401
|
+
|--------|-------------|-------------------|-------|
|
|
402
|
+
| **Falcon** | ✅ Full | ✅ Full | Fiber-based, async-http |
|
|
403
|
+
| **Iodine** | ✅ Full | ✅ Full | C extension, async I/O |
|
|
404
|
+
| **Puma** | ⚠️ Partial | ⚠️ Partial | Threaded mode only, not optimal |
|
|
405
|
+
| **Unicorn** | ❌ No | ❌ No | Pre-fork, synchronous |
|
|
406
|
+
| **Passenger** | ⚠️ Partial | ⚠️ Partial | Threaded mode only |
|
|
407
|
+
| **WEBrick** | ❌ No | ❌ No | Single-threaded |
|
|
408
|
+
|
|
409
|
+
**Problem**: Adding SSE/WebSocket would **force server choice**, breaking server-agnostic design.
|
|
410
|
+
|
|
411
|
+
#### 4.2.4 Scaling and State Management
|
|
412
|
+
|
|
413
|
+
**Current**: Otto apps scale horizontally (stateless load balancing):
|
|
414
|
+
|
|
415
|
+
```
|
|
416
|
+
Load Balancer
|
|
417
|
+
/ | \
|
|
418
|
+
Otto-1 Otto-2 Otto-3
|
|
419
|
+
(any) (any) (any)
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
**Required for WebSocket**: Sticky sessions or Redis pub/sub:
|
|
423
|
+
|
|
424
|
+
```
|
|
425
|
+
Load Balancer (sticky sessions)
|
|
426
|
+
/ | \
|
|
427
|
+
Otto-1 Otto-2 Otto-3
|
|
428
|
+
(WS1) (WS2) (WS3)
|
|
429
|
+
\ | /
|
|
430
|
+
Redis Pub/Sub
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
**Problem**: Stateful routing complicates:
|
|
434
|
+
- Load balancing (client must stay connected to same server)
|
|
435
|
+
- Zero-downtime deploys (existing connections must be drained)
|
|
436
|
+
- Horizontal scaling (connection state not shared)
|
|
437
|
+
- Rate limiting (per-server vs cluster-wide limits)
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
## 5. Best Practices and Anti-Patterns
|
|
442
|
+
|
|
443
|
+
### 5.1 Best Practices
|
|
444
|
+
|
|
445
|
+
#### ✅ **Separate Services for Real-Time Communication**
|
|
446
|
+
|
|
447
|
+
**Pattern**: Run SSE/WebSocket as dedicated service, separate from REST API
|
|
448
|
+
|
|
449
|
+
```
|
|
450
|
+
┌─────────────────┐
|
|
451
|
+
│ REST API │ ← Otto (stateless HTTP)
|
|
452
|
+
│ (Otto) │
|
|
453
|
+
└─────────────────┘
|
|
454
|
+
↓
|
|
455
|
+
┌─────────┐
|
|
456
|
+
│ Redis │ ← Message queue
|
|
457
|
+
│ Pub/Sub │
|
|
458
|
+
└─────────┘
|
|
459
|
+
↓
|
|
460
|
+
┌─────────────────┐
|
|
461
|
+
│ WebSocket │ ← Separate Falcon/Iodine server
|
|
462
|
+
│ Service │
|
|
463
|
+
└─────────────────┘
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
**Benefits**:
|
|
467
|
+
- Independent scaling (scale WebSocket separately from API)
|
|
468
|
+
- Technology choice (use best tool for each job)
|
|
469
|
+
- Fault isolation (WebSocket crash doesn't affect API)
|
|
470
|
+
- Clear separation of concerns (stateless vs stateful)
|
|
471
|
+
|
|
472
|
+
**Example (Rails ActionCable pattern)**:
|
|
473
|
+
```ruby
|
|
474
|
+
# Otto app pushes messages to Redis
|
|
475
|
+
REDIS = Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
|
|
476
|
+
|
|
477
|
+
class NotificationLogic
|
|
478
|
+
attr_reader :context, :params, :locale
|
|
479
|
+
|
|
480
|
+
def initialize(context, params, locale)
|
|
481
|
+
@context = context
|
|
482
|
+
@params = params
|
|
483
|
+
@locale = locale
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def process
|
|
487
|
+
# Publish under the *authenticated* identity. Taking the target from
|
|
488
|
+
# params would let any caller write into another user's channel.
|
|
489
|
+
# redis-rb removed Redis.current in 5.x; hold a shared client (and reach
|
|
490
|
+
# for the connection_pool gem in production).
|
|
491
|
+
REDIS.publish('notifications', {
|
|
492
|
+
user_id: context.user_id,
|
|
493
|
+
message: params['message'].to_s,
|
|
494
|
+
}.to_json)
|
|
495
|
+
|
|
496
|
+
{ success: true }
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Separate Falcon app consumes from Redis and streams via SSE
|
|
501
|
+
# falcon_sse.rb
|
|
502
|
+
require 'async'
|
|
503
|
+
require 'async/http/endpoint'
|
|
504
|
+
require 'async/websocket'
|
|
505
|
+
require 'redis'
|
|
506
|
+
|
|
507
|
+
class SSEHandler
|
|
508
|
+
def call(env)
|
|
509
|
+
redis = Redis.new
|
|
510
|
+
|
|
511
|
+
body = Enumerator.new do |yielder|
|
|
512
|
+
redis.subscribe('notifications') do |on|
|
|
513
|
+
on.message do |channel, message|
|
|
514
|
+
yielder << "data: #{message}\n\n"
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
[200, {'Content-Type' => 'text/event-stream'}, body]
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
#### ✅ **Use HTTP/2 Server Push (Alternative to SSE for some use cases)**
|
|
525
|
+
|
|
526
|
+
**Pattern**: Server push for static assets, not dynamic data
|
|
527
|
+
|
|
528
|
+
```ruby
|
|
529
|
+
# Rack::EarlyHints for HTTP/2 push
|
|
530
|
+
# (Not a replacement for SSE, but useful for preloading)
|
|
531
|
+
def call(env)
|
|
532
|
+
early_hints = {
|
|
533
|
+
'Link' => '</styles.css>; rel=preload; as=style'
|
|
534
|
+
}
|
|
535
|
+
env['rack.early_hints'].call(early_hints)
|
|
536
|
+
|
|
537
|
+
[200, {}, ['Body']]
|
|
538
|
+
end
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
**Benefits**:
|
|
542
|
+
- No connection state required
|
|
543
|
+
- Works with standard HTTP/2 servers
|
|
544
|
+
- Good for asset preloading, not real-time data
|
|
545
|
+
|
|
546
|
+
**Limitations**:
|
|
547
|
+
- Browser cache only, not bidirectional
|
|
548
|
+
- Not suitable for live updates
|
|
549
|
+
|
|
550
|
+
#### ✅ **Polling with Long-Polling for Simple Cases**
|
|
551
|
+
|
|
552
|
+
**Pattern**: Client polls Otto endpoint, Otto returns immediately or waits (long-polling)
|
|
553
|
+
|
|
554
|
+
```ruby
|
|
555
|
+
# Otto route
|
|
556
|
+
GET /api/notifications/poll NotificationLogic response=json auth=session
|
|
557
|
+
|
|
558
|
+
class NotificationLogic
|
|
559
|
+
attr_reader :context, :params, :locale
|
|
560
|
+
|
|
561
|
+
def initialize(context, params, locale)
|
|
562
|
+
@context = context
|
|
563
|
+
@params = params
|
|
564
|
+
@locale = locale
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def process
|
|
568
|
+
timeout = params['timeout'].to_i.clamp(1, 30)
|
|
569
|
+
start_time = Time.now
|
|
570
|
+
|
|
571
|
+
# Long-polling: wait for new data up to timeout. Note that each in-flight
|
|
572
|
+
# poll holds one server thread for the whole timeout window — size the
|
|
573
|
+
# pool for peak concurrent pollers, not peak request rate.
|
|
574
|
+
loop do
|
|
575
|
+
notifications = fetch_new_notifications(context.user_id)
|
|
576
|
+
return { notifications: notifications } if notifications.any?
|
|
577
|
+
|
|
578
|
+
break if Time.now - start_time > timeout
|
|
579
|
+
|
|
580
|
+
sleep 0.5
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
{ notifications: [] }
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
**Benefits**:
|
|
589
|
+
- Works with Otto's synchronous model
|
|
590
|
+
- No streaming infrastructure required
|
|
591
|
+
- HTTP-based, cacheable, RESTful
|
|
592
|
+
|
|
593
|
+
**Limitations**:
|
|
594
|
+
- Not as efficient as SSE/WebSocket
|
|
595
|
+
- Increased latency (poll interval)
|
|
596
|
+
- More server load (repeated connections)
|
|
597
|
+
|
|
598
|
+
### 5.2 Anti-Patterns
|
|
599
|
+
|
|
600
|
+
#### ❌ **Mixing Stateless HTTP and Stateful WebSocket in Same Router**
|
|
601
|
+
|
|
602
|
+
**Problem**: Confuses architectural boundaries, complicates security
|
|
603
|
+
|
|
604
|
+
```ruby
|
|
605
|
+
# ANTI-PATTERN: Don't do this in Otto
|
|
606
|
+
GET /api/users UserLogic response=json # Stateless
|
|
607
|
+
POST /api/users CreateUserLogic response=json # Stateless
|
|
608
|
+
GET /api/stream StreamLogic response=sse # Stateful ← Doesn't fit
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
**Why Bad**:
|
|
612
|
+
- Security middleware (CSRF, rate limiting) designed for request/response
|
|
613
|
+
- Authentication strategies assume short-lived requests
|
|
614
|
+
- Error handling expects finalized responses
|
|
615
|
+
- Configuration freezing prevents runtime changes
|
|
616
|
+
|
|
617
|
+
#### ❌ **Using SSE/WebSocket for Simple Updates**
|
|
618
|
+
|
|
619
|
+
**Problem**: Over-engineering when polling suffices
|
|
620
|
+
|
|
621
|
+
**Example**: Dashboard metrics that update every 10 seconds
|
|
622
|
+
|
|
623
|
+
```ruby
|
|
624
|
+
# ANTI-PATTERN: SSE for low-frequency updates
|
|
625
|
+
GET /dashboard/metrics StreamLogic response=sse
|
|
626
|
+
|
|
627
|
+
# BETTER: Simple polling
|
|
628
|
+
GET /dashboard/metrics MetricsLogic response=json
|
|
629
|
+
# Client: setInterval(() => fetch('/dashboard/metrics'), 10000)
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
**When to Use SSE/WebSocket**:
|
|
633
|
+
- High-frequency updates (>1/second)
|
|
634
|
+
- Instant notification required (<100ms latency)
|
|
635
|
+
- Bidirectional communication needed (chat, multiplayer)
|
|
636
|
+
|
|
637
|
+
**When to Use Polling**:
|
|
638
|
+
- Low-frequency updates (<1/minute)
|
|
639
|
+
- Latency tolerance (seconds acceptable)
|
|
640
|
+
- Simple implementation preferred
|
|
641
|
+
|
|
642
|
+
#### ❌ **Implementing WebSocket Without Redis Pub/Sub (Multi-Server)**
|
|
643
|
+
|
|
644
|
+
**Problem**: Doesn't scale horizontally
|
|
645
|
+
|
|
646
|
+
```ruby
|
|
647
|
+
# ANTI-PATTERN: In-memory WebSocket state
|
|
648
|
+
class WebSocketHandler
|
|
649
|
+
@@connections = [] # Stored in single server's memory
|
|
650
|
+
|
|
651
|
+
def call(env)
|
|
652
|
+
ws = Faye::WebSocket.new(env)
|
|
653
|
+
@@connections << ws
|
|
654
|
+
# Problem: Other servers don't see this connection
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
**Why Bad**:
|
|
660
|
+
- Connections only exist on one server
|
|
661
|
+
- Can't broadcast across cluster
|
|
662
|
+
- Zero-downtime deploys fail (connections lost)
|
|
663
|
+
|
|
664
|
+
**Better**: Use Redis pub/sub for cross-server messaging (see ActionCable pattern above)
|
|
665
|
+
|
|
666
|
+
#### ❌ **Blocking Servers for Streaming**
|
|
667
|
+
|
|
668
|
+
**Problem**: Using Unicorn/Passenger for SSE ties up workers
|
|
669
|
+
|
|
670
|
+
```ruby
|
|
671
|
+
# ANTI-PATTERN: Unicorn with SSE
|
|
672
|
+
# config/unicorn.rb
|
|
673
|
+
worker_processes 4
|
|
674
|
+
|
|
675
|
+
# SSE route blocks worker for entire stream duration
|
|
676
|
+
# 10 concurrent SSE clients = 10 blocked workers (out of 4 total)
|
|
677
|
+
# Result: All workers blocked, no capacity for regular requests
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
**Why Bad**:
|
|
681
|
+
- Worker pool exhaustion
|
|
682
|
+
- Degrades HTTP API performance
|
|
683
|
+
- Creates cascading failures
|
|
684
|
+
|
|
685
|
+
**Better**: Separate SSE service on async server (Falcon, Iodine)
|
|
686
|
+
|
|
687
|
+
---
|
|
688
|
+
|
|
689
|
+
## 6. Recommendations for Otto
|
|
690
|
+
|
|
691
|
+
### 6.1 Primary Recommendation: **DO NOT INTEGRATE SSE/WebSocket into Otto Core**
|
|
692
|
+
|
|
693
|
+
**Rationale**:
|
|
694
|
+
1. **Architectural Mismatch**: Otto's stateless, synchronous design is fundamentally incompatible with streaming
|
|
695
|
+
2. **Server Coupling**: Would force users to specific async servers (Falcon, Iodine)
|
|
696
|
+
3. **Security Complexity**: Streaming breaks middleware assumptions (CSRF, rate limiting)
|
|
697
|
+
4. **Scaling Concerns**: Introduces stateful routing, complicates horizontal scaling
|
|
698
|
+
5. **Maintenance Burden**: Adds significant complexity for niche use case
|
|
699
|
+
6. **Clear Separation**: Industry best practice is separate services (ActionCable model)
|
|
700
|
+
|
|
701
|
+
### 6.2 Alternative Solutions
|
|
702
|
+
|
|
703
|
+
#### Option 1: **Document External Integration Pattern** (RECOMMENDED)
|
|
704
|
+
|
|
705
|
+
Create official guide for integrating Otto with separate streaming service:
|
|
706
|
+
|
|
707
|
+
```markdown
|
|
708
|
+
# Otto + Falcon SSE Integration Guide
|
|
709
|
+
|
|
710
|
+
## Architecture
|
|
711
|
+
|
|
712
|
+
- Otto: Stateless REST API (authentication, business logic)
|
|
713
|
+
- Falcon: SSE streaming service (real-time updates)
|
|
714
|
+
- Redis: Message queue (pub/sub)
|
|
715
|
+
|
|
716
|
+
## Setup
|
|
717
|
+
|
|
718
|
+
### 1. Otto API publishes events
|
|
719
|
+
# routes.txt
|
|
720
|
+
POST /api/events PublishEventLogic response=json auth=session
|
|
721
|
+
|
|
722
|
+
# lib/logic/publish_event_logic.rb
|
|
723
|
+
# One shared client; use the connection_pool gem in production.
|
|
724
|
+
REDIS = Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
|
|
725
|
+
|
|
726
|
+
class PublishEventLogic
|
|
727
|
+
attr_reader :context, :params, :locale
|
|
728
|
+
|
|
729
|
+
def initialize(context, params, locale)
|
|
730
|
+
@context = context
|
|
731
|
+
@params = params
|
|
732
|
+
@locale = locale
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def process
|
|
736
|
+
REDIS.publish('events', {
|
|
737
|
+
event: params['event'].to_s,
|
|
738
|
+
data: params['data'],
|
|
739
|
+
}.to_json)
|
|
740
|
+
{ success: true }
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
### 2. Falcon SSE service subscribes and streams
|
|
745
|
+
# sse_service.rb (separate process)
|
|
746
|
+
require 'async'
|
|
747
|
+
require 'falcon'
|
|
748
|
+
require 'redis'
|
|
749
|
+
|
|
750
|
+
class SSEService
|
|
751
|
+
def call(env)
|
|
752
|
+
redis = Redis.new
|
|
753
|
+
|
|
754
|
+
body = Enumerator.new do |yielder|
|
|
755
|
+
redis.subscribe('events') do |on|
|
|
756
|
+
on.message do |channel, message|
|
|
757
|
+
yielder << "data: #{message}\n\n"
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
[200, {'Content-Type' => 'text/event-stream'}, body]
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# Run with: falcon serve -b 0.0.0.0:9292
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
**Benefits**:
|
|
770
|
+
- Preserves Otto's design integrity
|
|
771
|
+
- Provides clear architectural guidance
|
|
772
|
+
- Supports advanced users who need streaming
|
|
773
|
+
- No core complexity added
|
|
774
|
+
|
|
775
|
+
#### Option 2: **Plugin System for Advanced Users**
|
|
776
|
+
|
|
777
|
+
Create **experimental** plugin interface (NOT in core):
|
|
778
|
+
|
|
779
|
+
```ruby
|
|
780
|
+
# Hypothetical (NOT recommended for core, but possible as plugin)
|
|
781
|
+
|
|
782
|
+
# otto-streaming-plugin gem (community-maintained)
|
|
783
|
+
class Otto
|
|
784
|
+
module Plugins
|
|
785
|
+
module Streaming
|
|
786
|
+
def enable_streaming!
|
|
787
|
+
# WARNING: Requires Falcon/Iodine server
|
|
788
|
+
# WARNING: Breaks middleware guarantees
|
|
789
|
+
# WARNING: Not compatible with frozen config
|
|
790
|
+
@streaming_enabled = true
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
def sse_route(verb, path, handler)
|
|
794
|
+
route = Otto::Route.new(verb, path, "#{handler} response=sse")
|
|
795
|
+
# ... streaming-specific setup
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
# User's app (opt-in, experimental)
|
|
802
|
+
otto.extend Otto::Plugins::Streaming
|
|
803
|
+
otto.enable_streaming! # Must be called before first request
|
|
804
|
+
|
|
805
|
+
otto.sse_route(:GET, '/stream', 'StreamHandler')
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
**Benefits**:
|
|
809
|
+
- Keeps core clean
|
|
810
|
+
- Community can experiment
|
|
811
|
+
- Clear "experimental" status
|
|
812
|
+
- Users understand trade-offs
|
|
813
|
+
|
|
814
|
+
**Risks**:
|
|
815
|
+
- Still complicates Otto's architecture
|
|
816
|
+
- May give false impression it's "supported"
|
|
817
|
+
- Security implications unclear
|
|
818
|
+
|
|
819
|
+
#### Option 3: **Recommend Third-Party Solutions**
|
|
820
|
+
|
|
821
|
+
Document integrations with existing solutions:
|
|
822
|
+
|
|
823
|
+
**For SSE**:
|
|
824
|
+
- **Mercure**: Open-source SSE hub (Go-based, protocol spec)
|
|
825
|
+
- **Ably**: Commercial real-time messaging platform
|
|
826
|
+
- **Pusher**: Commercial WebSocket/SSE service
|
|
827
|
+
|
|
828
|
+
**For WebSocket**:
|
|
829
|
+
- **AnyCable**: Rails-compatible WebSocket server (Go/Rust)
|
|
830
|
+
- **Socket.IO**: Node.js-based (can integrate with Otto via message queue)
|
|
831
|
+
- **Phoenix Channels**: Elixir (if building new real-time service)
|
|
832
|
+
|
|
833
|
+
**Example Integration**:
|
|
834
|
+
```ruby
|
|
835
|
+
# Otto publishes to Mercure
|
|
836
|
+
POST /api/.well-known/mercure MercurePublishLogic response=json auth=session
|
|
837
|
+
|
|
838
|
+
class MercurePublishLogic
|
|
839
|
+
attr_reader :context, :params, :locale
|
|
840
|
+
|
|
841
|
+
def initialize(context, params, locale)
|
|
842
|
+
@context = context
|
|
843
|
+
@params = params
|
|
844
|
+
@locale = locale
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def process
|
|
848
|
+
# Namespace the topic under the authenticated identity. Publishing to a
|
|
849
|
+
# caller-supplied topic would let any user push into another user's feed.
|
|
850
|
+
topic = "/users/#{context.user_id}/#{params['topic'].to_s[/\A[\w-]+\z/]}"
|
|
851
|
+
|
|
852
|
+
# Publish to Mercure hub (separate service)
|
|
853
|
+
HTTParty.post('http://mercure-hub/.well-known/mercure', {
|
|
854
|
+
body: {
|
|
855
|
+
topic: topic,
|
|
856
|
+
data: params['data'],
|
|
857
|
+
},
|
|
858
|
+
headers: {
|
|
859
|
+
'Authorization' => "Bearer #{ENV.fetch('MERCURE_JWT')}",
|
|
860
|
+
},
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
{ success: true }
|
|
864
|
+
end
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# Client subscribes to Mercure hub directly
|
|
868
|
+
# <script>
|
|
869
|
+
# const eventSource = new EventSource('http://mercure-hub/.well-known/mercure?topic=notifications');
|
|
870
|
+
# eventSource.onmessage = (e) => console.log(e.data);
|
|
871
|
+
# </script>
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
---
|
|
875
|
+
|
|
876
|
+
## 7. Conclusion
|
|
877
|
+
|
|
878
|
+
### 7.1 Summary
|
|
879
|
+
|
|
880
|
+
**SSE and WebSocket are fundamentally incompatible with Otto's design philosophy**:
|
|
881
|
+
|
|
882
|
+
- Otto: Stateless, synchronous, request/response, frozen security, server-agnostic
|
|
883
|
+
- SSE/WebSocket: Stateful, async, long-lived connections, runtime state, server-specific
|
|
884
|
+
|
|
885
|
+
**Industry consensus**: Separate real-time communication from REST APIs
|
|
886
|
+
|
|
887
|
+
- Rails: ActionCable runs as separate process
|
|
888
|
+
- Node.js: Socket.IO is separate layer from Express
|
|
889
|
+
- Go: Goroutines enable coexistence (not applicable to Ruby)
|
|
890
|
+
|
|
891
|
+
### 7.2 Final Recommendation
|
|
892
|
+
|
|
893
|
+
**For Otto Project**:
|
|
894
|
+
|
|
895
|
+
1. ✅ **Do NOT add SSE/WebSocket to core**
|
|
896
|
+
- Preserves architectural integrity
|
|
897
|
+
- Avoids server coupling
|
|
898
|
+
- Maintains security guarantees
|
|
899
|
+
|
|
900
|
+
2. ✅ **Document external integration patterns**
|
|
901
|
+
- Otto + Falcon/Iodine SSE service
|
|
902
|
+
- Otto + Redis + AnyCable
|
|
903
|
+
- Otto + Mercure hub
|
|
904
|
+
|
|
905
|
+
3. ✅ **Recommend long-polling for simple cases**
|
|
906
|
+
- Works with Otto's synchronous model
|
|
907
|
+
- Good for low-frequency updates
|
|
908
|
+
- Example implementation in docs
|
|
909
|
+
|
|
910
|
+
4. ⚠️ **Consider plugin system (if community demands)**
|
|
911
|
+
- Clearly marked "experimental"
|
|
912
|
+
- Requires async server
|
|
913
|
+
- Security implications documented
|
|
914
|
+
|
|
915
|
+
**For Otto Users Who Need Real-Time**:
|
|
916
|
+
|
|
917
|
+
- **Low-frequency updates (<1/min)**: Use HTTP polling with Otto routes
|
|
918
|
+
- **Medium-frequency updates (1-10/sec)**: Separate Falcon SSE service + Redis
|
|
919
|
+
- **Bidirectional communication**: Separate WebSocket service (Falcon/AnyCable)
|
|
920
|
+
- **Commercial requirements**: Use Ably, Pusher, or similar managed service
|
|
921
|
+
|
|
922
|
+
### 7.3 Key Insight
|
|
923
|
+
|
|
924
|
+
**The question isn't "Can Otto support SSE/WebSocket?"** (technically possible with massive refactoring)
|
|
925
|
+
|
|
926
|
+
**The question is "Should Otto support SSE/WebSocket?"** (architecturally inadvisable)
|
|
927
|
+
|
|
928
|
+
Answer: **No**. Otto should remain focused on its strength: **stateless, secure, privacy-first HTTP APIs with clear architectural boundaries**.
|
|
929
|
+
|
|
930
|
+
---
|
|
931
|
+
|
|
932
|
+
## Appendix A: Code Examples
|
|
933
|
+
|
|
934
|
+
### A.1 Otto + Falcon SSE Integration (Full Example)
|
|
935
|
+
|
|
936
|
+
See "Otto + Falcon SSE Integration" in section 6 above. A runnable
|
|
937
|
+
`examples/otto_falcon_sse_integration.rb` is tracked as follow-up work.
|
|
938
|
+
|
|
939
|
+
### A.2 Long-Polling Implementation in Otto
|
|
940
|
+
|
|
941
|
+
```ruby
|
|
942
|
+
# routes.txt
|
|
943
|
+
GET /api/notifications/poll NotificationPollLogic response=json auth=session
|
|
944
|
+
|
|
945
|
+
# lib/logic/notification_poll_logic.rb
|
|
946
|
+
class NotificationPollLogic
|
|
947
|
+
attr_reader :context, :params, :locale
|
|
948
|
+
|
|
949
|
+
def initialize(context, params, locale)
|
|
950
|
+
@context = context
|
|
951
|
+
@params = params
|
|
952
|
+
@locale = locale
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def process
|
|
956
|
+
timeout = params['timeout'].to_i.clamp(1, 30)
|
|
957
|
+
last_id = params['last_id'].to_i
|
|
958
|
+
start_time = Time.now
|
|
959
|
+
|
|
960
|
+
loop do
|
|
961
|
+
notifications = Notification.where(user_id: context.user_id)
|
|
962
|
+
.where('id > ?', last_id)
|
|
963
|
+
.order(id: :asc)
|
|
964
|
+
.limit(10)
|
|
965
|
+
|
|
966
|
+
if notifications.any?
|
|
967
|
+
return {
|
|
968
|
+
notifications: notifications.map(&:to_h),
|
|
969
|
+
last_id: notifications.last.id
|
|
970
|
+
}
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
# Check timeout
|
|
974
|
+
break if Time.now - start_time > timeout
|
|
975
|
+
|
|
976
|
+
# Wait before checking again (reduces CPU/DB load)
|
|
977
|
+
sleep 0.5
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# Timeout reached, return empty
|
|
981
|
+
{ notifications: [], last_id: last_id }
|
|
982
|
+
end
|
|
983
|
+
end
|
|
984
|
+
```
|
|
985
|
+
|
|
986
|
+
Client-side:
|
|
987
|
+
|
|
988
|
+
```javascript
|
|
989
|
+
async function pollNotifications() {
|
|
990
|
+
let lastId = 0;
|
|
991
|
+
|
|
992
|
+
while (true) {
|
|
993
|
+
try {
|
|
994
|
+
const response = await fetch(`/api/notifications/poll?timeout=30&last_id=${lastId}`);
|
|
995
|
+
const data = await response.json();
|
|
996
|
+
|
|
997
|
+
if (data.notifications.length > 0) {
|
|
998
|
+
data.notifications.forEach(notif => console.log(notif));
|
|
999
|
+
lastId = data.last_id;
|
|
1000
|
+
}
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
console.error('Polling error:', error);
|
|
1003
|
+
await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5s on error
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
pollNotifications();
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
### A.3 Separate Falcon SSE Service
|
|
1012
|
+
|
|
1013
|
+
```ruby
|
|
1014
|
+
# sse_service.rb (separate process)
|
|
1015
|
+
require 'async'
|
|
1016
|
+
require 'async/http/endpoint'
|
|
1017
|
+
require 'falcon'
|
|
1018
|
+
require 'redis'
|
|
1019
|
+
|
|
1020
|
+
class SSEHandler
|
|
1021
|
+
def initialize(redis_url = 'redis://localhost:6379/0')
|
|
1022
|
+
@redis_url = redis_url
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
def call(env)
|
|
1026
|
+
# Authentication (verify token from Otto)
|
|
1027
|
+
token = env['HTTP_AUTHORIZATION']&.sub(/^Bearer /, '')
|
|
1028
|
+
user_id = verify_token(token)
|
|
1029
|
+
return [401, {}, ['Unauthorized']] unless user_id
|
|
1030
|
+
|
|
1031
|
+
# Subscribe to user's channel
|
|
1032
|
+
redis = Redis.new(url: @redis_url)
|
|
1033
|
+
channel = "notifications:#{user_id}"
|
|
1034
|
+
|
|
1035
|
+
body = Enumerator.new do |yielder|
|
|
1036
|
+
# Send heartbeat to keep connection alive
|
|
1037
|
+
Thread.new do
|
|
1038
|
+
loop do
|
|
1039
|
+
yielder << ":heartbeat\n\n"
|
|
1040
|
+
sleep 30
|
|
1041
|
+
end
|
|
1042
|
+
rescue IOError
|
|
1043
|
+
# Connection closed
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
# Subscribe and stream events
|
|
1047
|
+
redis.subscribe(channel) do |on|
|
|
1048
|
+
on.message do |ch, message|
|
|
1049
|
+
yielder << "data: #{message}\n\n"
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
1052
|
+
rescue IOError
|
|
1053
|
+
# Connection closed
|
|
1054
|
+
ensure
|
|
1055
|
+
redis.quit
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
[200, {
|
|
1059
|
+
'Content-Type' => 'text/event-stream',
|
|
1060
|
+
'Cache-Control' => 'no-cache',
|
|
1061
|
+
'X-Accel-Buffering' => 'no' # Disable nginx buffering
|
|
1062
|
+
}, body]
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
private
|
|
1066
|
+
|
|
1067
|
+
def verify_token(token)
|
|
1068
|
+
# Verify JWT/token issued by Otto
|
|
1069
|
+
# Return user_id if valid, nil otherwise
|
|
1070
|
+
# (Implementation depends on Otto's auth strategy)
|
|
1071
|
+
end
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
# config.ru
|
|
1075
|
+
run SSEHandler.new
|
|
1076
|
+
|
|
1077
|
+
# Run with: falcon serve -b 0.0.0.0:9292
|
|
1078
|
+
```
|
|
1079
|
+
|
|
1080
|
+
---
|
|
1081
|
+
|
|
1082
|
+
## Appendix B: Further Reading
|
|
1083
|
+
|
|
1084
|
+
**Rack Streaming**:
|
|
1085
|
+
- [Rack 3 Streaming Responses](https://github.com/rack/rack/issues/1600)
|
|
1086
|
+
- [Rack Hijack API](https://github.com/rack/rack/discussions/2162)
|
|
1087
|
+
- [Rails SSE with Rack Hijacking](https://blog.chumakoff.com/en/posts/rails_sse_rack_hijacking_api)
|
|
1088
|
+
|
|
1089
|
+
**Framework Patterns**:
|
|
1090
|
+
- [Rails ActionCable Overview](https://guides.rubyonrails.org/action_cable_overview.html)
|
|
1091
|
+
- [Roda Streaming Plugin](https://github.com/jeremyevans/roda/blob/master/lib/roda/plugins/streaming.rb)
|
|
1092
|
+
- [Sinatra SSE](https://github.com/radiospiel/sinatra-sse)
|
|
1093
|
+
|
|
1094
|
+
**Server-Sent Events**:
|
|
1095
|
+
- [SSE vs WebSocket Comparison](https://ably.com/blog/websockets-vs-sse)
|
|
1096
|
+
- [MDN: Using Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
|
|
1097
|
+
|
|
1098
|
+
**WebSocket Architecture**:
|
|
1099
|
+
- [WebSocket Best Practices](https://ably.com/topic/websocket-architecture-best-practices)
|
|
1100
|
+
- [Falcon WebSocket Support](https://socketry.github.io/falcon/)
|
|
1101
|
+
|
|
1102
|
+
**Ruby Async Servers**:
|
|
1103
|
+
- [Falcon](https://github.com/socketry/falcon)
|
|
1104
|
+
- [Iodine](https://github.com/boazsegev/iodine)
|
|
1105
|
+
- [AnyCable](https://anycable.io/)
|