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 +4 -4
- data/README.md +76 -12
- data/lib/whoosh/app.rb +7 -0
- data/lib/whoosh/streaming/websocket.rb +157 -13
- data/lib/whoosh/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99edefedd7d17b1c477fcd07d7ba72b317560d670706ec1361c197df013a9834
|
|
4
|
+
data.tar.gz: d94a866bf3e588e64591b1e1e32e62db4b5fae74511420b5942e42a04eb1dc8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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
|
|
25
|
-
- **Fast** — 2.5µs framework overhead,
|
|
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
|
-
- **
|
|
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
|
-
#
|
|
158
|
-
app.post "/summarize",
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/whoosh/version.rb
CHANGED