whoosh 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 589d3cd63bfe7cb2a7ae32d8de43c064eb0e9dadfae68ecb1907bea9a099cd7d
4
- data.tar.gz: a18f684c03fa83b5e7e86febd6eca0bc79d01d5c3cf719b729118eea91510e9c
3
+ metadata.gz: 99edefedd7d17b1c477fcd07d7ba72b317560d670706ec1361c197df013a9834
4
+ data.tar.gz: d94a866bf3e588e64591b1e1e32e62db4b5fae74511420b5942e42a04eb1dc8a
5
5
  SHA512:
6
- metadata.gz: 3ffb5d9e6d905e3914d9cf54738966c5d3ba625a89f33c8c2b3c98698f9a04ba1b6710584efa69917e24ef02b52b870f616a4ad6e6a7d631c4707319d1db7e47
7
- data.tar.gz: e57044ee4ec0c6a1b872d85cd0abeb274ee3c260c46dcc38b925994a6b88c7b004188314c7b60e8e3a7ce5aef4198581c817fbd8870ceaca6ddbe20fce4abd75
6
+ metadata.gz: c2d1793270458869874e09f720c053eb3625b2943323bd1ff24a9e9620bebd3cbe9705fb1bde81cfe60211084f8d7a0862407548f1699a5f5a25c4bb392f764b
7
+ data.tar.gz: f86e8837a57fcbee513a514d6bf32d2901833187f038cd64d0b8bfb71897a97507c65ee5297af3ce92e940ee697d0443165efeda980a8fb1d8b3cb334a4ae89b
data/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  <img src="https://img.shields.io/badge/ruby-%3E%3D%203.4.0-red" alt="Ruby">
14
14
  <img src="https://img.shields.io/badge/rack-3.0-blue" alt="Rack">
15
15
  <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
16
- <img src="https://img.shields.io/badge/tests-509%20passing-brightgreen" alt="Tests">
16
+ <img src="https://img.shields.io/badge/tests-540%20passing-brightgreen" alt="Tests">
17
17
  <img src="https://img.shields.io/badge/overhead-2.5%C2%B5s-orange" alt="Performance">
18
18
  </p>
19
19
 
@@ -21,11 +21,11 @@
21
21
 
22
22
  ## Why Whoosh?
23
23
 
24
- - **AI-first** — MCP server built-in, LLM streaming, token tracking, plugin auto-discovery for 18+ AI gems
25
- - **Fast** — 2.5µs framework overhead, 406K req/s on simple JSON, YJIT + Oj auto-enabled
26
- - **Batteries included** — Auth, rate limiting, caching, background jobs, file uploads, pagination, metrics
27
- - **Zero config to start** — `whoosh new myapp && cd myapp && whoosh s`
28
- - **OpenAPI 3.1** — Swagger UI + ReDoc auto-generated from your routes and schemas
24
+ - **AI-first** — Every app is an MCP server automatically. LLM wrapper with structured output, caching, and streaming. Vector search built-in. 18+ AI gem auto-discovery.
25
+ - **Fast** — 2.5µs framework overhead, 87K req/s with Falcon. Beats Fastify on multi-worker PostgreSQL benchmarks.
26
+ - **Batteries included** — Auth, rate limiting, caching, background jobs, file uploads, vector search, pagination, metrics, CI pipeline.
27
+ - **Zero config to start** — `whoosh new myapp && cd myapp && whoosh s` — everything works.
28
+ - **AI-agent friendly** — `whoosh describe` dumps your app as JSON for AI tools. Generated `CLAUDE.md` in every project. `whoosh check` catches mistakes before runtime.
29
29
 
30
30
  ## Install
31
31
 
@@ -130,6 +130,45 @@ app.access_control do
130
130
  end
131
131
  ```
132
132
 
133
+ ### AI / LLM Integration
134
+
135
+ ```ruby
136
+ # Chat with any LLM (auto-detects ruby_llm gem)
137
+ app.post "/chat" do |req, llm:|
138
+ { reply: llm.chat(req.body["message"]) }
139
+ end
140
+
141
+ # Structured output — LLM returns validated JSON
142
+ app.post "/extract" do |req, llm:|
143
+ data = llm.extract(req.body["text"], schema: InvoiceSchema)
144
+ { invoice: data }
145
+ end
146
+
147
+ # RAG in 3 lines
148
+ app.post "/ask" do |req, vectors:, llm:|
149
+ context = vectors.search("knowledge", vector: embed(req.body["q"]), limit: 5)
150
+ { answer: llm.chat(req.body["q"], system: "Context: #{context}") }
151
+ end
152
+ ```
153
+
154
+ ### Vector Search
155
+
156
+ ```ruby
157
+ app.post "/index" do |req, vectors:|
158
+ vectors.insert("docs", id: req.body["id"],
159
+ vector: req.body["embedding"],
160
+ metadata: { title: req.body["title"] })
161
+ { indexed: true }
162
+ end
163
+
164
+ app.post "/search" do |req, vectors:|
165
+ results = vectors.search("docs", vector: req.body["embedding"], limit: 10)
166
+ { results: results }
167
+ end
168
+ ```
169
+
170
+ In-memory by default (cosine similarity). Install `zvec` gem for production-grade HNSW index.
171
+
133
172
  ### LLM Streaming (OpenAI-compatible)
134
173
 
135
174
  ```ruby
@@ -153,16 +192,21 @@ end
153
192
 
154
193
  ### MCP (Model Context Protocol)
155
194
 
195
+ **Every route is automatically an MCP tool.** No `mcp: true` needed.
196
+
156
197
  ```ruby
157
- # Any route with mcp: true becomes an MCP tool automatically
158
- app.post "/summarize", mcp: true, request: SummarizeRequest do |req|
198
+ # These are all MCP tools automatically:
199
+ app.post "/summarize", request: SummarizeRequest do |req|
159
200
  { summary: llm.summarize(req.body[:text]) }
160
201
  end
161
202
 
162
- # Groups propagate mcp: true to all child routes
163
- app.group "/tools", mcp: true do
164
- post("/translate") { |req| { result: translate(req.body[:text]) } }
165
- post("/analyze") { |req| { result: analyze(req.body[:text]) } }
203
+ app.post "/translate" do |req|
204
+ { result: translate(req.body["text"]) }
205
+ end
206
+
207
+ # Opt OUT with mcp: false for internal routes:
208
+ app.get "/internal", mcp: false do
209
+ { debug: "not exposed as MCP tool" }
166
210
  end
167
211
  ```
168
212
 
@@ -319,9 +363,13 @@ whoosh new my_api # scaffold project (with Dockerfile)
319
363
  whoosh s # start server (like rails s)
320
364
  whoosh s --reload # hot reload on file changes
321
365
  whoosh routes # list all routes
366
+ whoosh describe # dump app as JSON (AI-friendly)
367
+ whoosh check # validate config, catch mistakes
322
368
  whoosh console # IRB with app loaded
369
+ whoosh ci # lint + security + audit + tests + coverage
323
370
  whoosh worker # background job worker
324
371
  whoosh mcp # MCP stdio server
372
+ whoosh mcp --list # list all MCP tools
325
373
 
326
374
  whoosh generate endpoint chat # endpoint + schema + test
327
375
  whoosh generate schema User # schema file
@@ -335,6 +383,22 @@ whoosh db rollback # rollback
335
383
  whoosh db status # migration status
336
384
  ```
337
385
 
386
+ ## AI Agent DX
387
+
388
+ Every `whoosh new` project includes a `CLAUDE.md` with all framework patterns, commands, and conventions — so AI agents (Claude Code, Cursor, Copilot) can build with Whoosh immediately.
389
+
390
+ ```bash
391
+ # Dump your entire app structure as JSON (routes, schemas, config, MCP tools)
392
+ whoosh describe
393
+
394
+ # AI tools can consume this to understand your API
395
+ whoosh describe --routes # routes with request/response schemas
396
+ whoosh describe --schemas # all schema definitions
397
+
398
+ # Catch mistakes before runtime
399
+ whoosh check # validates config, auth, dependencies
400
+ ```
401
+
338
402
  ## Performance
339
403
 
340
404
  ### HTTP Benchmark: `GET /health → {"status":"ok"}`
data/lib/whoosh/app.rb CHANGED
@@ -235,6 +235,13 @@ module Whoosh
235
235
  [200, Streaming::LlmStream.headers, body]
236
236
  end
237
237
 
238
+ # WebSocket endpoint helper — use in handle_request, returns hijack response
239
+ def websocket(env, &block)
240
+ ws = Streaming::WebSocket.new(env)
241
+ block.call(ws)
242
+ ws.rack_response
243
+ end
244
+
238
245
  def paginate(collection, page:, per_page: 20)
239
246
  Paginate.offset(collection, page: page, per_page: per_page)
240
247
  end
@@ -1,25 +1,34 @@
1
- # lib/whoosh/streaming/websocket.rb
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require "json"
4
+ require "digest"
5
+ require "base64"
5
6
 
6
7
  module Whoosh
7
8
  module Streaming
8
9
  class WebSocket
9
- def initialize(io)
10
- @io = io
10
+ GUID = "258EAFA5-E914-47DA-95CA-5AB5DC65C3E5"
11
+
12
+ attr_reader :env
13
+
14
+ def initialize(env)
15
+ @env = env
16
+ @io = nil
11
17
  @closed = false
12
18
  @on_message = nil
13
19
  @on_close = nil
20
+ @on_open = nil
14
21
  end
15
22
 
16
- def send(data)
17
- return if @closed
18
- formatted = data.is_a?(String) ? data : JSON.generate(data)
19
- @io.write(formatted + "\n")
20
- @io.flush if @io.respond_to?(:flush)
21
- rescue IOError, Errno::EPIPE
22
- @closed = true
23
+ # Check if the request is a WebSocket upgrade
24
+ def self.websocket?(env)
25
+ env["HTTP_UPGRADE"]&.downcase == "websocket" &&
26
+ env["HTTP_CONNECTION"]&.downcase&.include?("upgrade")
27
+ end
28
+
29
+ # Register callbacks
30
+ def on_open(&block)
31
+ @on_open = block
23
32
  end
24
33
 
25
34
  def on_message(&block)
@@ -30,6 +39,67 @@ module Whoosh
30
39
  @on_close = block
31
40
  end
32
41
 
42
+ # Send data to the client
43
+ def send(data)
44
+ return if @closed
45
+ formatted = data.is_a?(String) ? data : JSON.generate(data)
46
+ write_frame(formatted)
47
+ end
48
+
49
+ def close
50
+ return if @closed
51
+ @closed = true
52
+ write_close_frame
53
+ @io&.close rescue nil
54
+ @on_close&.call
55
+ end
56
+
57
+ def closed?
58
+ @closed
59
+ end
60
+
61
+ # Returns a Rack response that hijacks the connection
62
+ def rack_response
63
+ unless self.class.websocket?(@env)
64
+ return [400, { "content-type" => "text/plain" }, ["Not a WebSocket request"]]
65
+ end
66
+
67
+ # WebSocket handshake
68
+ key = @env["HTTP_SEC_WEBSOCKET_KEY"]
69
+ accept = Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
70
+
71
+ headers = {
72
+ "Upgrade" => "websocket",
73
+ "Connection" => "Upgrade",
74
+ "Sec-WebSocket-Accept" => accept
75
+ }
76
+
77
+ # Use rack.hijack to take over the connection
78
+ if @env["rack.hijack"]
79
+ @env["rack.hijack"].call
80
+ @io = @env["rack.hijack_io"]
81
+
82
+ # Send handshake response manually
83
+ @io.write("HTTP/1.1 101 Switching Protocols\r\n")
84
+ headers.each { |k, v| @io.write("#{k}: #{v}\r\n") }
85
+ @io.write("\r\n")
86
+ @io.flush
87
+
88
+ # Notify open
89
+ @on_open&.call
90
+
91
+ # Start reading frames in a thread
92
+ Thread.new { read_loop }
93
+
94
+ # Return a dummy response (connection is hijacked)
95
+ [-1, {}, []]
96
+ else
97
+ # Fallback: return 101 and let the server handle hijack
98
+ [101, headers, []]
99
+ end
100
+ end
101
+
102
+ # For testing — simulate without real socket
33
103
  def trigger_message(msg)
34
104
  @on_message&.call(msg)
35
105
  end
@@ -39,12 +109,86 @@ module Whoosh
39
109
  @closed = true
40
110
  end
41
111
 
42
- def close
112
+ private
113
+
114
+ def read_loop
115
+ while !@closed && @io && !@io.closed?
116
+ frame = read_frame
117
+ break unless frame
118
+
119
+ case frame[:opcode]
120
+ when 0x1 # Text frame
121
+ @on_message&.call(frame[:data])
122
+ when 0x8 # Close frame
123
+ close
124
+ break
125
+ when 0x9 # Ping
126
+ write_pong(frame[:data])
127
+ end
128
+ end
129
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE
43
130
  @closed = true
131
+ @on_close&.call
44
132
  end
45
133
 
46
- def closed?
47
- @closed
134
+ def read_frame
135
+ return nil unless @io && !@io.closed?
136
+
137
+ first_byte = @io.readbyte
138
+ fin = (first_byte & 0x80) != 0
139
+ opcode = first_byte & 0x0F
140
+
141
+ second_byte = @io.readbyte
142
+ masked = (second_byte & 0x80) != 0
143
+ length = second_byte & 0x7F
144
+
145
+ if length == 126
146
+ length = @io.read(2).unpack1("n")
147
+ elsif length == 127
148
+ length = @io.read(8).unpack1("Q>")
149
+ end
150
+
151
+ mask_key = masked ? @io.read(4).bytes : nil
152
+ payload = @io.read(length)&.bytes || []
153
+
154
+ if masked && mask_key
155
+ payload = payload.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
156
+ end
157
+
158
+ { opcode: opcode, data: payload.pack("C*"), fin: fin }
159
+ rescue EOFError, IOError
160
+ nil
161
+ end
162
+
163
+ def write_frame(data, opcode: 0x1)
164
+ return unless @io && !@io.closed?
165
+
166
+ bytes = data.encode("UTF-8").bytes
167
+ frame = [0x80 | opcode] # FIN + opcode
168
+
169
+ if bytes.length < 126
170
+ frame << bytes.length
171
+ elsif bytes.length < 65536
172
+ frame << 126
173
+ frame += [bytes.length].pack("n").bytes
174
+ else
175
+ frame << 127
176
+ frame += [bytes.length].pack("Q>").bytes
177
+ end
178
+
179
+ frame += bytes
180
+ @io.write(frame.pack("C*"))
181
+ @io.flush
182
+ rescue IOError, Errno::EPIPE
183
+ @closed = true
184
+ end
185
+
186
+ def write_close_frame
187
+ write_frame("", opcode: 0x8) rescue nil
188
+ end
189
+
190
+ def write_pong(data)
191
+ write_frame(data, opcode: 0xA) rescue nil
48
192
  end
49
193
  end
50
194
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Whoosh
4
- VERSION = "1.3.0"
4
+ VERSION = "1.3.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whoosh
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwi Cahyo