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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +175 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +984 -0
  5. data/exe/tarsier +7 -0
  6. data/lib/tarsier/application.rb +336 -0
  7. data/lib/tarsier/cli/commands/console.rb +87 -0
  8. data/lib/tarsier/cli/commands/generate.rb +85 -0
  9. data/lib/tarsier/cli/commands/help.rb +50 -0
  10. data/lib/tarsier/cli/commands/new.rb +59 -0
  11. data/lib/tarsier/cli/commands/routes.rb +139 -0
  12. data/lib/tarsier/cli/commands/server.rb +123 -0
  13. data/lib/tarsier/cli/commands/version.rb +14 -0
  14. data/lib/tarsier/cli/generators/app.rb +528 -0
  15. data/lib/tarsier/cli/generators/base.rb +93 -0
  16. data/lib/tarsier/cli/generators/controller.rb +91 -0
  17. data/lib/tarsier/cli/generators/middleware.rb +81 -0
  18. data/lib/tarsier/cli/generators/migration.rb +109 -0
  19. data/lib/tarsier/cli/generators/model.rb +109 -0
  20. data/lib/tarsier/cli/generators/resource.rb +27 -0
  21. data/lib/tarsier/cli/loader.rb +18 -0
  22. data/lib/tarsier/cli.rb +46 -0
  23. data/lib/tarsier/controller.rb +282 -0
  24. data/lib/tarsier/database.rb +588 -0
  25. data/lib/tarsier/errors.rb +77 -0
  26. data/lib/tarsier/middleware/base.rb +47 -0
  27. data/lib/tarsier/middleware/compression.rb +113 -0
  28. data/lib/tarsier/middleware/cors.rb +101 -0
  29. data/lib/tarsier/middleware/csrf.rb +88 -0
  30. data/lib/tarsier/middleware/logger.rb +74 -0
  31. data/lib/tarsier/middleware/rate_limit.rb +110 -0
  32. data/lib/tarsier/middleware/stack.rb +143 -0
  33. data/lib/tarsier/middleware/static.rb +124 -0
  34. data/lib/tarsier/model.rb +590 -0
  35. data/lib/tarsier/params.rb +269 -0
  36. data/lib/tarsier/query.rb +495 -0
  37. data/lib/tarsier/request.rb +274 -0
  38. data/lib/tarsier/response.rb +282 -0
  39. data/lib/tarsier/router/compiler.rb +173 -0
  40. data/lib/tarsier/router/node.rb +97 -0
  41. data/lib/tarsier/router/route.rb +119 -0
  42. data/lib/tarsier/router.rb +272 -0
  43. data/lib/tarsier/version.rb +5 -0
  44. data/lib/tarsier/websocket.rb +275 -0
  45. data/lib/tarsier.rb +167 -0
  46. data/sig/tarsier.rbs +485 -0
  47. 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