otto 2.6.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.pre-commit-config.yaml +22 -5
  10. data/CHANGELOG.rst +218 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +12 -10
  13. data/README.md +13 -3
  14. data/docs/.gitignore +1 -0
  15. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
  16. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
  17. data/docs/geo-country.md +172 -0
  18. data/docs/reverse-proxy-network-services.md +19 -6
  19. data/examples/simple_geo_resolver.rb +38 -5
  20. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  21. data/lib/otto/core/middleware_stack.rb +72 -25
  22. data/lib/otto/env_keys.rb +32 -0
  23. data/lib/otto/logging_helpers.rb +50 -1
  24. data/lib/otto/mcp/rate_limiting.rb +5 -2
  25. data/lib/otto/privacy/config.rb +245 -3
  26. data/lib/otto/privacy/core.rb +93 -14
  27. data/lib/otto/privacy/geo_resolver.rb +228 -128
  28. data/lib/otto/privacy/ip_privacy.rb +24 -0
  29. data/lib/otto/privacy/redacted_fingerprint.rb +54 -2
  30. data/lib/otto/privacy.rb +3 -1
  31. data/lib/otto/request.rb +8 -1
  32. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  33. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  34. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  35. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  36. data/lib/otto/security/config.rb +23 -1
  37. data/lib/otto/security/core.rb +4 -1
  38. data/lib/otto/security/csp/report_middleware.rb +3 -1
  39. data/lib/otto/security/middleware/ip_privacy_middleware.rb +201 -12
  40. data/lib/otto/security/rate_limiter.rb +7 -1
  41. data/lib/otto/utils.rb +100 -0
  42. data/lib/otto/version.rb +1 -1
  43. data/lib/otto.rb +11 -3
  44. metadata +6 -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/)