tarsier 0.1.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 +7 -0
- data/CHANGELOG.md +175 -0
- data/LICENSE.txt +21 -0
- data/README.md +984 -0
- data/exe/tarsier +7 -0
- data/lib/tarsier/application.rb +336 -0
- data/lib/tarsier/cli/commands/console.rb +87 -0
- data/lib/tarsier/cli/commands/generate.rb +85 -0
- data/lib/tarsier/cli/commands/help.rb +50 -0
- data/lib/tarsier/cli/commands/new.rb +59 -0
- data/lib/tarsier/cli/commands/routes.rb +139 -0
- data/lib/tarsier/cli/commands/server.rb +123 -0
- data/lib/tarsier/cli/commands/version.rb +14 -0
- data/lib/tarsier/cli/generators/app.rb +528 -0
- data/lib/tarsier/cli/generators/base.rb +93 -0
- data/lib/tarsier/cli/generators/controller.rb +91 -0
- data/lib/tarsier/cli/generators/middleware.rb +81 -0
- data/lib/tarsier/cli/generators/migration.rb +109 -0
- data/lib/tarsier/cli/generators/model.rb +109 -0
- data/lib/tarsier/cli/generators/resource.rb +27 -0
- data/lib/tarsier/cli/loader.rb +18 -0
- data/lib/tarsier/cli.rb +46 -0
- data/lib/tarsier/controller.rb +282 -0
- data/lib/tarsier/database.rb +588 -0
- data/lib/tarsier/errors.rb +77 -0
- data/lib/tarsier/middleware/base.rb +47 -0
- data/lib/tarsier/middleware/compression.rb +113 -0
- data/lib/tarsier/middleware/cors.rb +101 -0
- data/lib/tarsier/middleware/csrf.rb +88 -0
- data/lib/tarsier/middleware/logger.rb +74 -0
- data/lib/tarsier/middleware/rate_limit.rb +110 -0
- data/lib/tarsier/middleware/stack.rb +143 -0
- data/lib/tarsier/middleware/static.rb +124 -0
- data/lib/tarsier/model.rb +590 -0
- data/lib/tarsier/params.rb +269 -0
- data/lib/tarsier/query.rb +495 -0
- data/lib/tarsier/request.rb +274 -0
- data/lib/tarsier/response.rb +282 -0
- data/lib/tarsier/router/compiler.rb +173 -0
- data/lib/tarsier/router/node.rb +97 -0
- data/lib/tarsier/router/route.rb +119 -0
- data/lib/tarsier/router.rb +272 -0
- data/lib/tarsier/version.rb +5 -0
- data/lib/tarsier/websocket.rb +275 -0
- data/lib/tarsier.rb +167 -0
- data/sig/tarsier.rbs +485 -0
- metadata +230 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module Tarsier
|
|
7
|
+
# WebSocket handler with room/channel abstraction
|
|
8
|
+
# Built on Fiber-based concurrency for efficient handling
|
|
9
|
+
class WebSocket
|
|
10
|
+
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
11
|
+
OPCODES = { text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xA }.freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Define event handlers
|
|
15
|
+
def on(event, &block)
|
|
16
|
+
handlers[event] = block
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Get all handlers
|
|
20
|
+
def handlers
|
|
21
|
+
@handlers ||= {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Subscribe to a channel
|
|
25
|
+
def subscribe(channel)
|
|
26
|
+
channels[channel] ||= []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Broadcast to a channel
|
|
30
|
+
def broadcast(channel, data)
|
|
31
|
+
channels[channel]&.each { |socket| socket.send_message(data) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get all channels
|
|
35
|
+
def channels
|
|
36
|
+
@channels ||= {}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :request, :params, :socket
|
|
41
|
+
|
|
42
|
+
def initialize(request, socket)
|
|
43
|
+
@request = request
|
|
44
|
+
@params = request.params
|
|
45
|
+
@socket = socket
|
|
46
|
+
@subscriptions = []
|
|
47
|
+
@open = false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Perform WebSocket handshake
|
|
51
|
+
# @return [Boolean] true if handshake successful
|
|
52
|
+
def handshake!
|
|
53
|
+
key = @request.header("Sec-WebSocket-Key")
|
|
54
|
+
return false unless key
|
|
55
|
+
|
|
56
|
+
accept = Base64.strict_encode64(
|
|
57
|
+
Digest::SHA1.digest("#{key}#{GUID}")
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
response = [
|
|
61
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
62
|
+
"Upgrade: websocket",
|
|
63
|
+
"Connection: Upgrade",
|
|
64
|
+
"Sec-WebSocket-Accept: #{accept}",
|
|
65
|
+
"",
|
|
66
|
+
""
|
|
67
|
+
].join("\r\n")
|
|
68
|
+
|
|
69
|
+
@socket.write(response)
|
|
70
|
+
@open = true
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Start the WebSocket connection loop
|
|
75
|
+
def run
|
|
76
|
+
return unless @open
|
|
77
|
+
|
|
78
|
+
trigger(:connect)
|
|
79
|
+
|
|
80
|
+
loop do
|
|
81
|
+
frame = read_frame
|
|
82
|
+
break unless frame
|
|
83
|
+
|
|
84
|
+
case frame[:opcode]
|
|
85
|
+
when OPCODES[:text], OPCODES[:binary]
|
|
86
|
+
trigger(:message, frame[:data])
|
|
87
|
+
when OPCODES[:ping]
|
|
88
|
+
send_frame(OPCODES[:pong], frame[:data])
|
|
89
|
+
when OPCODES[:close]
|
|
90
|
+
trigger(:close)
|
|
91
|
+
break
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
rescue IOError, Errno::ECONNRESET
|
|
95
|
+
trigger(:close)
|
|
96
|
+
ensure
|
|
97
|
+
cleanup
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Send a text message
|
|
101
|
+
# @param data [String] message data
|
|
102
|
+
def send_message(data)
|
|
103
|
+
send_frame(OPCODES[:text], data.to_s)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
alias send send_message
|
|
107
|
+
|
|
108
|
+
# Send binary data
|
|
109
|
+
# @param data [String] binary data
|
|
110
|
+
def send_binary(data)
|
|
111
|
+
send_frame(OPCODES[:binary], data)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Close the connection
|
|
115
|
+
# @param code [Integer] close code
|
|
116
|
+
# @param reason [String] close reason
|
|
117
|
+
def close(code: 1000, reason: "")
|
|
118
|
+
data = [code].pack("n") + reason
|
|
119
|
+
send_frame(OPCODES[:close], data)
|
|
120
|
+
@open = false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Subscribe to a channel
|
|
124
|
+
# @param channel [String] channel name
|
|
125
|
+
def subscribe(channel)
|
|
126
|
+
self.class.channels[channel] ||= []
|
|
127
|
+
self.class.channels[channel] << self
|
|
128
|
+
@subscriptions << channel
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Unsubscribe from a channel
|
|
132
|
+
# @param channel [String] channel name
|
|
133
|
+
def unsubscribe(channel)
|
|
134
|
+
self.class.channels[channel]&.delete(self)
|
|
135
|
+
@subscriptions.delete(channel)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Broadcast to a channel
|
|
139
|
+
# @param channel [String] channel name
|
|
140
|
+
# @param data [String] message data
|
|
141
|
+
def broadcast(channel, data)
|
|
142
|
+
self.class.broadcast(channel, data)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Check if connection is open
|
|
146
|
+
# @return [Boolean]
|
|
147
|
+
def open?
|
|
148
|
+
@open
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def trigger(event, *args)
|
|
154
|
+
handler = self.class.handlers[event]
|
|
155
|
+
instance_exec(*args, &handler) if handler
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def read_frame
|
|
159
|
+
first_byte = @socket.read(1)&.unpack1("C")
|
|
160
|
+
return nil unless first_byte
|
|
161
|
+
|
|
162
|
+
second_byte = @socket.read(1)&.unpack1("C")
|
|
163
|
+
return nil unless second_byte
|
|
164
|
+
|
|
165
|
+
opcode = first_byte & 0x0F
|
|
166
|
+
masked = (second_byte & 0x80) != 0
|
|
167
|
+
length = second_byte & 0x7F
|
|
168
|
+
|
|
169
|
+
# Extended payload length
|
|
170
|
+
length = case length
|
|
171
|
+
when 126 then @socket.read(2).unpack1("n")
|
|
172
|
+
when 127 then @socket.read(8).unpack1("Q>")
|
|
173
|
+
else length
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Masking key (client frames are always masked)
|
|
177
|
+
mask = masked ? @socket.read(4).bytes : nil
|
|
178
|
+
|
|
179
|
+
# Payload
|
|
180
|
+
data = @socket.read(length)
|
|
181
|
+
data = unmask(data, mask) if mask
|
|
182
|
+
|
|
183
|
+
{ opcode: opcode, data: data }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def send_frame(opcode, data)
|
|
187
|
+
frame = []
|
|
188
|
+
|
|
189
|
+
# First byte: FIN + opcode
|
|
190
|
+
frame << (0x80 | opcode)
|
|
191
|
+
|
|
192
|
+
# Length (server frames are not masked)
|
|
193
|
+
length = data.bytesize
|
|
194
|
+
if length < 126
|
|
195
|
+
frame << length
|
|
196
|
+
elsif length < 65_536
|
|
197
|
+
frame << 126
|
|
198
|
+
frame.concat([length].pack("n").bytes)
|
|
199
|
+
else
|
|
200
|
+
frame << 127
|
|
201
|
+
frame.concat([length].pack("Q>").bytes)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
@socket.write(frame.pack("C*") + data)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def unmask(data, mask)
|
|
208
|
+
data.bytes.map.with_index { |byte, i| byte ^ mask[i % 4] }.pack("C*")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def cleanup
|
|
212
|
+
@subscriptions.each { |channel| unsubscribe(channel) }
|
|
213
|
+
@socket.close unless @socket.closed?
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Server-Sent Events (SSE) handler
|
|
218
|
+
class SSE
|
|
219
|
+
attr_reader :request, :response
|
|
220
|
+
|
|
221
|
+
def initialize(request, response)
|
|
222
|
+
@request = request
|
|
223
|
+
@response = response
|
|
224
|
+
@open = false
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Start SSE stream
|
|
228
|
+
def start
|
|
229
|
+
@response.status = 200
|
|
230
|
+
@response.set_header("Content-Type", "text/event-stream")
|
|
231
|
+
@response.set_header("Cache-Control", "no-cache")
|
|
232
|
+
@response.set_header("Connection", "keep-alive")
|
|
233
|
+
@response.set_header("X-Accel-Buffering", "no")
|
|
234
|
+
@open = true
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Send an event
|
|
238
|
+
# @param data [String] event data
|
|
239
|
+
# @param event [String, nil] event type
|
|
240
|
+
# @param id [String, nil] event ID
|
|
241
|
+
# @param retry_ms [Integer, nil] retry interval
|
|
242
|
+
def send(data, event: nil, id: nil, retry_ms: nil)
|
|
243
|
+
return unless @open
|
|
244
|
+
|
|
245
|
+
message = +""
|
|
246
|
+
message << "id: #{id}\n" if id
|
|
247
|
+
message << "event: #{event}\n" if event
|
|
248
|
+
message << "retry: #{retry_ms}\n" if retry_ms
|
|
249
|
+
|
|
250
|
+
data.to_s.each_line do |line|
|
|
251
|
+
message << "data: #{line.chomp}\n"
|
|
252
|
+
end
|
|
253
|
+
message << "\n"
|
|
254
|
+
|
|
255
|
+
@response.write(message)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Send a comment (keep-alive)
|
|
259
|
+
# @param text [String] comment text
|
|
260
|
+
def comment(text)
|
|
261
|
+
@response.write(": #{text}\n\n") if @open
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Close the stream
|
|
265
|
+
def close
|
|
266
|
+
@open = false
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Check if stream is open
|
|
270
|
+
# @return [Boolean]
|
|
271
|
+
def open?
|
|
272
|
+
@open
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
data/lib/tarsier.rb
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tarsier/version"
|
|
4
|
+
require_relative "tarsier/errors"
|
|
5
|
+
require_relative "tarsier/router/node"
|
|
6
|
+
require_relative "tarsier/router/route"
|
|
7
|
+
require_relative "tarsier/router/compiler"
|
|
8
|
+
require_relative "tarsier/router"
|
|
9
|
+
require_relative "tarsier/request"
|
|
10
|
+
require_relative "tarsier/response"
|
|
11
|
+
require_relative "tarsier/params"
|
|
12
|
+
require_relative "tarsier/controller"
|
|
13
|
+
require_relative "tarsier/middleware/stack"
|
|
14
|
+
require_relative "tarsier/middleware/base"
|
|
15
|
+
require_relative "tarsier/middleware/logger"
|
|
16
|
+
require_relative "tarsier/middleware/cors"
|
|
17
|
+
require_relative "tarsier/middleware/csrf"
|
|
18
|
+
require_relative "tarsier/middleware/rate_limit"
|
|
19
|
+
require_relative "tarsier/middleware/compression"
|
|
20
|
+
require_relative "tarsier/middleware/static"
|
|
21
|
+
require_relative "tarsier/database"
|
|
22
|
+
require_relative "tarsier/query"
|
|
23
|
+
require_relative "tarsier/model"
|
|
24
|
+
require_relative "tarsier/websocket"
|
|
25
|
+
require_relative "tarsier/application"
|
|
26
|
+
|
|
27
|
+
# Tarsier - A minimal, elegant Ruby web framework
|
|
28
|
+
#
|
|
29
|
+
# Tarsier provides a clean, expressive API for building web applications
|
|
30
|
+
# with minimal boilerplate. Inspired by Flask, Sinatra, and Roda.
|
|
31
|
+
#
|
|
32
|
+
# @example Minimal application
|
|
33
|
+
# require 'tarsier'
|
|
34
|
+
#
|
|
35
|
+
# app = Tarsier.app do
|
|
36
|
+
# get '/' do
|
|
37
|
+
# { message: 'Hello, World!' }
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# run app
|
|
42
|
+
#
|
|
43
|
+
# @example With database
|
|
44
|
+
# Tarsier.db :sqlite, 'app.db'
|
|
45
|
+
#
|
|
46
|
+
# class User < Tarsier::Model
|
|
47
|
+
# attribute :name, :string
|
|
48
|
+
# attribute :email, :string
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# @author Tarsier Contributors
|
|
52
|
+
# @since 0.1.0
|
|
53
|
+
module Tarsier
|
|
54
|
+
class << self
|
|
55
|
+
# @return [Tarsier::Application] the current application instance
|
|
56
|
+
attr_accessor :current_app
|
|
57
|
+
|
|
58
|
+
# Create a new Tarsier application with minimal syntax
|
|
59
|
+
#
|
|
60
|
+
# @yield block for defining routes inline
|
|
61
|
+
# @return [Application] the application instance
|
|
62
|
+
#
|
|
63
|
+
# @example
|
|
64
|
+
# app = Tarsier.app do
|
|
65
|
+
# get('/') { { status: 'ok' } }
|
|
66
|
+
# end
|
|
67
|
+
def app(&block)
|
|
68
|
+
@current_app = Application.new
|
|
69
|
+
@current_app.instance_eval(&block) if block_given?
|
|
70
|
+
@current_app.boot!
|
|
71
|
+
@current_app
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Create application with configuration block (Rails-style)
|
|
75
|
+
#
|
|
76
|
+
# @yield [Application] the application instance for configuration
|
|
77
|
+
# @return [Application]
|
|
78
|
+
#
|
|
79
|
+
# @example
|
|
80
|
+
# Tarsier.application do
|
|
81
|
+
# configure { self.secret_key = 'secret' }
|
|
82
|
+
# routes do
|
|
83
|
+
# root to: 'home#index'
|
|
84
|
+
# end
|
|
85
|
+
# end
|
|
86
|
+
def application(&block)
|
|
87
|
+
@current_app = Application.new
|
|
88
|
+
@current_app.instance_eval(&block) if block_given?
|
|
89
|
+
@current_app
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Configure database connection
|
|
93
|
+
#
|
|
94
|
+
# @param adapter [Symbol] database adapter (:sqlite, :postgres, :mysql)
|
|
95
|
+
# @param connection [String, Hash] connection string or options
|
|
96
|
+
# @return [Database::Connection]
|
|
97
|
+
#
|
|
98
|
+
# @example SQLite
|
|
99
|
+
# Tarsier.db :sqlite, 'app.db'
|
|
100
|
+
#
|
|
101
|
+
# @example PostgreSQL
|
|
102
|
+
# Tarsier.db :postgres, 'postgres://localhost/myapp'
|
|
103
|
+
#
|
|
104
|
+
# @example MySQL with options
|
|
105
|
+
# Tarsier.db :mysql, host: 'localhost', database: 'myapp'
|
|
106
|
+
def db(adapter, connection = nil, **options)
|
|
107
|
+
Database.connect(adapter, connection, **options)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Access the database connection
|
|
111
|
+
#
|
|
112
|
+
# @return [Database::Connection, nil]
|
|
113
|
+
def database
|
|
114
|
+
Database.connection
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Define routes for the application
|
|
118
|
+
#
|
|
119
|
+
# @yield [Router] the router instance
|
|
120
|
+
def routes(&block)
|
|
121
|
+
current_app&.routes(&block)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Access the current router
|
|
125
|
+
#
|
|
126
|
+
# @return [Router, nil]
|
|
127
|
+
def router
|
|
128
|
+
current_app&.router
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Current environment
|
|
132
|
+
#
|
|
133
|
+
# @return [String]
|
|
134
|
+
def env
|
|
135
|
+
@env ||= ENV.fetch("TARSIER_ENV") { ENV.fetch("RACK_ENV", "development") }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Boolean] true if in development environment
|
|
139
|
+
def development?
|
|
140
|
+
env == "development"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Boolean] true if in production environment
|
|
144
|
+
def production?
|
|
145
|
+
env == "production"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @return [Boolean] true if in test environment
|
|
149
|
+
def test?
|
|
150
|
+
env == "test"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Framework root directory
|
|
154
|
+
#
|
|
155
|
+
# @return [String]
|
|
156
|
+
def root
|
|
157
|
+
@root ||= Dir.pwd
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Set framework root
|
|
161
|
+
#
|
|
162
|
+
# @param path [String]
|
|
163
|
+
def root=(path)
|
|
164
|
+
@root = File.expand_path(path)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|