ru.Bee 1.11.1 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6c1764a41eaa86619270fbaf3077cb5fc55b8d9c6b4f4edac37c2ac7f584fb7
4
- data.tar.gz: e298000985901fea481a623fd1a08759b6be441251dba2efd78d0120f1ee16f1
3
+ metadata.gz: fd4247baa0c82fd81b4be82e8e63446b8b0d7934cee6243dda47ea3b350d4213
4
+ data.tar.gz: 1ba2c534bcebc7e898408fcf9d4e594c9535ca6fc218a5de8d51895edadc9d7f
5
5
  SHA512:
6
- metadata.gz: d11f20305783059eef64fa35530e2c6861ff708db527e6ff628b8d735b833d3ec565ce7bbeb4faa9ac0fb98d7868007c8c8728804ecd07adf37a49fc7f665c20
7
- data.tar.gz: 91b9a63e9bc18c4be04d6e11b097607c01733921b6ba9e33134ea0ee07780fbdf4352083e76f901f741856048732f2a55b8998451a735260aa61da05df80644c
6
+ metadata.gz: cff63aa019e6f5895908a7b1fd7122cefd3986c9a4c1aede556aee667cb750ca82ec95727babda0bd4a234a54b3b159fac5668e9a07de7f7974913015b7dcca5
7
+ data.tar.gz: b4ae2fb9d36dc6c06d87da67d479029fb7c4399f18b26dd57e10ad176a4135a81e2c7840f92393441287e29d470859ef9ec15043dad06b414cf13b680980e35c
@@ -0,0 +1,57 @@
1
+ class UsersController < Rubee::BaseController
2
+ attach_websocket! # Method required to turn controller to been able to handle websocket requests
3
+ using ChargedHash
4
+
5
+ # Endpoint to find or create user
6
+ def create
7
+ user = User.where(**params).last
8
+ user ||= User.create(**params)
9
+
10
+ response_with(object: user, type: :json)
11
+ rescue StandardError => e
12
+ response_with(object: { error: e.message }, type: :json)
13
+ end
14
+
15
+ def subscribe
16
+ channel = params[:channel]
17
+ sender_id = params[:options][:id]
18
+ io = params[:options][:io]
19
+
20
+ User.sub(channel, sender_id, io) do |channel, args|
21
+ websocket_connections.register(channel, args[:io])
22
+ end
23
+
24
+ response_with(object: { type: 'system', channel: params[:channel], status: :subscribed }, type: :websocket)
25
+ rescue StandardError => e
26
+ response_with(object: { type: 'system', error: e.message }, type: :websocket)
27
+ end
28
+
29
+ def unsubscribe
30
+ channel = params[:channel]
31
+ sender_id = params[:options][:id]
32
+ io = params[:options][:io]
33
+
34
+ User.unsub(channel, sender_id, io) do |channel, args|
35
+ websocket_connections.remove(channel, args[:io])
36
+ end
37
+
38
+ response_with(object: params.merge(type: 'system', status: :unsubscribed), type: :websocket)
39
+ rescue StandardError => e
40
+ response_with(object: { type: 'system', error: e.message }, type: :websocket)
41
+ end
42
+
43
+ def publish
44
+ args = {}
45
+ User.pub(params[:channel], message: params[:message]) do |channel|
46
+ user = User.find(params[:options][:id])
47
+ args[:message] = params[:message]
48
+ args[:sender] = params[:options][:id]
49
+ args[:sender_name] = user.email
50
+ websocket_connections.stream(channel, args)
51
+ end
52
+
53
+ response_with(object: { type: 'system', message: params[:message], status: :published }, type: :websocket)
54
+ rescue StandardError => e
55
+ response_with(object: { type: 'system', error: e.message }, type: :websocket)
56
+ end
57
+ end
@@ -1,4 +1,6 @@
1
1
  class WelcomeController < Rubee::BaseController
2
+ using ChargedHash
3
+
2
4
  def show
3
5
  response_with
4
6
  end
@@ -2,5 +2,7 @@
2
2
  # If you remove or modify it, make sure all changes are inlined
3
3
  # with AuthTokenMiddleware and AuthTokenable modules
4
4
  class User < Rubee::SequelObject
5
+ include Rubee::PubSub::Publisher
6
+ include Rubee::PubSub::Subscriber
5
7
  attr_accessor :id, :email, :password, :created, :updated
6
8
  end
data/lib/config/routes.rb CHANGED
@@ -1,3 +1,4 @@
1
1
  Rubee::Router.draw do |router|
2
2
  router.get('/', to: 'welcome#show') # override it for your app
3
+ router.get('/ws', to: 'users#websocket')
3
4
  end
@@ -0,0 +1,7 @@
1
+ def reload
2
+ app_files = Dir["./#{Rubee::APP_ROOT}/**/*.rb"]
3
+ app_files.each { |file| load(file) }
4
+ puts "\e[32mReloaded..\e[0m"
5
+ end
6
+
7
+
@@ -1,5 +1,6 @@
1
1
  module Rubee
2
2
  class Autoload
3
+ BLACKLIST = ['rubee.rb', 'test_helper.rb']
3
4
  class << self
4
5
  def call(black_list = [], **options)
5
6
  load_whitelisted(options[:white_list_dirs]) && return if options[:white_list_dirs]
@@ -12,7 +13,7 @@ module Rubee
12
13
  Dir.glob(File.join(Rubee::APP_ROOT, '**', '*.rb')).sort.each do |file|
13
14
  base_name = File.basename(file)
14
15
 
15
- unless base_name.end_with?('_test.rb') || (black_list + ['rubee.rb', 'test_helper.rb']).include?(base_name)
16
+ unless base_name.end_with?('_test.rb') || (black_list + BLACKLIST).include?(base_name)
16
17
  require_relative file
17
18
  end
18
19
  end
@@ -35,6 +36,14 @@ module Rubee
35
36
  Dir[File.join(Rubee::APP_ROOT, 'inits/**', '*.rb')].each do |file|
36
37
  require_relative file unless black_list.include?("#{file}.rb")
37
38
  end
39
+ # rubee pub sub
40
+ Dir[File.join(root_directory, 'rubee/pubsub/**', '*.rb')].each do |file|
41
+ require_relative file unless black_list.include?("#{file}.rb")
42
+ end
43
+ # rubee websocket
44
+ Dir[File.join(root_directory, 'rubee/websocket/**', '*.rb')].each do |file|
45
+ require_relative file unless black_list.include?("#{file}.rb")
46
+ end
38
47
  # rubee async
39
48
  Dir[File.join(root_directory, 'rubee/async/**', '*.rb')].each do |file|
40
49
  require_relative file unless black_list.include?("#{file}.rb")
@@ -14,14 +14,6 @@ module Rubee
14
14
  Rubee::Configuration.setup(env = :test) do |config|
15
15
  config.database_url = { url: 'sqlite://lib/tests/test.db', env: }
16
16
  end
17
- # Rubee::Autoload.call
18
- # Rubee::SequelObject.reconnect!
19
- end
20
-
21
- def reload
22
- app_files = Dir["./#{Rubee::APP_ROOT}/**/*.rb"]
23
- app_files.each { |file| load(file) }
24
- color_puts('Reloaded ..', color: :green)
25
17
  end
26
18
 
27
19
  begin
@@ -29,7 +29,7 @@ module Rubee
29
29
  FileUtils.mkdir_p(target_dir)
30
30
  # Define blacklist
31
31
  blacklist_files = %w[rubee.rb print_colors.rb version.rb config.ru test_helper.rb Gemfile.lock test.yml test.db
32
- development.db production.db]
32
+ development.db production.db users_controller.rb users_controller.rb]
33
33
  blacklist_dirs = %w[rubee tests .git .github .idea node_modules db inits]
34
34
  # Copy files, excluding blacklisted ones
35
35
  copy_project_files(source_dir, target_dir, blacklist_files, blacklist_dirs)
@@ -108,6 +108,11 @@ module Rubee
108
108
  gem 'json'
109
109
  gem 'jwt'
110
110
 
111
+ # Websocket is required to use integrated websocket feature
112
+ gem 'websocket'
113
+ # Redis is required for pubsub and websocket
114
+ gem 'redis'
115
+
111
116
  group :development do
112
117
  gem 'rerun'
113
118
  gem 'minitest'
@@ -7,7 +7,7 @@ module Rubee
7
7
  | |_) | | | || _ \| _|
8
8
  | _ <| |__| || |_) | |___
9
9
  |_| \_\\____/ |____/|_____|
10
- Ver: %s
10
+ Ver: %s ...bzzz
11
11
  LOGO
12
12
 
13
13
  class << self
@@ -23,7 +23,7 @@ LOGO
23
23
 
24
24
  port ||= '7000'
25
25
  print_logo
26
- color_puts("Starting takeoff of ruBee server on port #{port}...", color: :yellow)
26
+ color_puts("Starting takeoff of ruBee on port: #{port}...", color: :yellow)
27
27
  command = "#{jit_prefix(jit)}rackup #{ENV['RACKUP_FILE']} -p #{port}"
28
28
  color_puts(command, color: :gray)
29
29
  exec(command)
@@ -9,6 +9,7 @@ module Rubee
9
9
  development: {
10
10
  database_url: '',
11
11
  port: 7000,
12
+ redis_url: '',
12
13
  },
13
14
  production: {},
14
15
  test: {},
@@ -36,6 +37,11 @@ module Rubee
36
37
  @configuraiton[args[:app].to_sym][args[:env].to_sym][:database_url] = args[:url]
37
38
  end
38
39
 
40
+ def redis_url=(args)
41
+ args[:app] ||= :app
42
+ @configuraiton[args[:app].to_sym][args[:env].to_sym][:redis_url] = args[:url]
43
+ end
44
+
39
45
  def async_adapter=(args)
40
46
  args[:app] ||= :app
41
47
  @configuraiton[args[:app].to_sym][args[:env].to_sym][:async_adapter] = args[:async_adapter]
@@ -82,6 +88,16 @@ module Rubee
82
88
  @configuraiton[args[:app].to_sym][ENV['RACK_ENV']&.to_sym || :development][:react] || {}
83
89
  end
84
90
 
91
+ def pubsub_container=(args)
92
+ args[:app] ||= :app
93
+ @configuraiton[args[:app].to_sym][args[:env].to_sym][:pubsub_container] = args[:pubsub_container]
94
+ end
95
+
96
+ def pubsub_container(**args)
97
+ args[:app] ||= :app
98
+ @configuraiton[args[:app].to_sym][ENV['RACK_ENV']&.to_sym || :development][:pubsub_container] || ::Rubee::PubSub::Redis.instance
99
+ end
100
+
85
101
  def method_missing(method_name, *args)
86
102
  return unless method_name.to_s.start_with?('get_')
87
103
 
@@ -51,6 +51,8 @@ module Rubee
51
51
  [status, headers.merge('content-type' => 'application/javascript'), [object]]
52
52
  in :css
53
53
  [status, headers.merge('content-type' => 'text/css'), [object]]
54
+ in :websocket
55
+ object # hash is expected
54
56
  in :file
55
57
  [
56
58
  status,
@@ -101,23 +103,38 @@ module Rubee
101
103
  erb_template.result(binding)
102
104
  end
103
105
 
104
- def params
105
- inputs = @request.env['rack.input'].read
106
- body = begin
107
- JSON.parse(@request.body.read.strip)
108
- rescue StandardError
109
- {}
110
- end
111
- begin
112
- body.merge!(URI.decode_www_form(inputs).to_h.transform_keys(&:to_sym))
113
- rescue StandardError
114
- nil
106
+ def websocket
107
+ action = @params[:action]
108
+ unless ['subscribe', 'unsubscribe', 'publish'].include?(action)
109
+ response_with(object: "Unknown action: #{action}", type: :websocket)
115
110
  end
111
+
112
+ public_send(action)
113
+ end
114
+
115
+ def params
116
+ # Read raw input safely (only once)
117
+ raw_input = @request.body.read.to_s.strip
118
+ @request.body.rewind if @request.body.respond_to?(:rewind)
119
+
120
+ # Try parsing JSON first, fall back to form-encoded data
121
+ parsed_input =
122
+ begin
123
+ JSON.parse(raw_input)
124
+ rescue StandardError
125
+ begin
126
+ URI.decode_www_form(raw_input).to_h.transform_keys(&:to_sym)
127
+ rescue
128
+ {}
129
+ end
130
+ end
131
+
132
+ # Combine route params, request params, and body
116
133
  @params ||= extract_params(@request.path, @route[:path])
117
- .merge(body)
134
+ .merge(parsed_input)
118
135
  .merge(@request.params)
119
136
  .transform_keys(&:to_sym)
120
- .reject { |k, _v| [:_method].include?(k.downcase.to_sym) }
137
+ .reject { |k, _v| k.to_sym == :_method }
121
138
  end
122
139
 
123
140
  def headers
@@ -125,6 +142,10 @@ module Rubee
125
142
  .collect { |key, val| [key.sub(/^HTTP_/, ''), val] }
126
143
  end
127
144
 
145
+ def websocket_connections
146
+ Rubee::WebSocketConnections.instance
147
+ end
148
+
128
149
  def extract_params(path, pattern)
129
150
  regex_pattern = pattern.gsub(/\{(\w+)\}/, '(?<\1>[^/]+)')
130
151
  regex = Regexp.new("^#{regex_pattern}$")
@@ -135,5 +156,26 @@ module Rubee
135
156
 
136
157
  {}
137
158
  end
159
+
160
+ def handle_websocket
161
+ res = Rubee::WebSocket.call(@request.env) do |payload|
162
+ @params = payload
163
+ yield
164
+ end
165
+ res
166
+ end
167
+
168
+ class << self
169
+ def attach_websocket!
170
+ around(
171
+ :websocket, :handle_websocket,
172
+ if: -> do
173
+ redis_available = Rubee::Features.redis_available?
174
+ Rubee::Logger.error(message: 'Please make sure redis server is running') unless redis_available
175
+ redis_available
176
+ end
177
+ )
178
+ end
179
+ end
138
180
  end
139
181
  end
@@ -0,0 +1,22 @@
1
+ module Rubee
2
+ class Features
3
+ class << self
4
+ def redis_available?
5
+ require "redis"
6
+ redis_url = Rubee::Configuration.get_redis_url
7
+ redis = redis_url&.empty? ? Redis.new : Redis.new(url: redis_url)
8
+ redis.ping
9
+ true
10
+ rescue LoadError, Redis::CannotConnectError
11
+ false
12
+ end
13
+
14
+ def websocket_available?
15
+ require "websocket"
16
+ true
17
+ rescue LoadError
18
+ false
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,44 @@
1
+ module Rubee
2
+ module PubSub
3
+ class Container
4
+ def pub(*)
5
+ raise NotImplementedError
6
+ end
7
+
8
+ # Container Implementation of sub
9
+ def sub(*)
10
+ raise NotImplementedError
11
+ end
12
+
13
+ # Container Implementation of unsub
14
+ def unsub(*)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ protected
19
+
20
+ def retrieve_klasses(iterable)
21
+ iterable.map { |clazz| turn_to_class(clazz) }
22
+ end
23
+
24
+ def turn_to_class(string)
25
+ string.split('::').inject(Object) { |o, c| o.const_get(c) }
26
+ end
27
+
28
+ def fan_out(clazzes, args, &block)
29
+ mutex = Mutex.new
30
+
31
+ mutex.synchronize do
32
+ clazzes.each do |clazz|
33
+ if block
34
+ block.call(clazz.name, args)
35
+ else
36
+ clazz.on_pub(clazz.name, args)
37
+ end
38
+ end
39
+ true
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,18 @@
1
+ module Rubee
2
+ module PubSub
3
+ module Publisher
4
+ Error = Class.new(StandardError)
5
+
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def pub(channel, args = {}, &block)
12
+ Rubee::Configuration.pubsub_container.pub(channel, args, &block)
13
+ true
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,99 @@
1
+ require "connection_pool"
2
+ require "redis"
3
+ require "singleton"
4
+ require "json"
5
+
6
+ module Rubee
7
+ module PubSub
8
+ class Redis < Container
9
+ include Singleton
10
+
11
+ DEFAULT_POOL_SIZE = 5
12
+ DEFAULT_TIMEOUT = 5
13
+
14
+ def initialize
15
+ redis_url = Rubee::Configuration.get_redis_url
16
+ @pool = ConnectionPool.new(size: DEFAULT_POOL_SIZE, timeout: DEFAULT_TIMEOUT) do
17
+ if redis_url&.empty?
18
+ ::Redis.new
19
+ else
20
+ ::Redis.new(url: redis_url)
21
+ end
22
+ end
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # Example: pub("ok", message: "hello")
27
+ def pub(channel, args = {}, &block)
28
+ keys = with_redis { |r| r.scan_each(match: "#{channel}:*").to_a }
29
+ return false if keys.empty?
30
+
31
+ values = with_redis { |r| r.mget(*keys) }
32
+
33
+ iterable = values.each_with_index.each_with_object({}) do |(val, i), hash|
34
+ key = keys[i]
35
+ hash[key] = val
36
+ end
37
+
38
+ clazzes = retrieve_klasses(iterable)
39
+ fan_out(clazzes, args, &block)
40
+ end
41
+
42
+ # Example: sub("ok", "User", ["123"])
43
+ def sub(channel, klass_name, *args, &block)
44
+ @mutex.synchronize do
45
+ id = args.first
46
+ id_string = id ? ":#{id}" : ""
47
+ key = "#{channel}:#{klass_name}#{id_string}"
48
+ existing = with_redis { |r| r.get(key) }
49
+ io = args.last.respond_to?(:call) ? args.pop : nil
50
+
51
+ with_redis { |r| r.set(key, args.join(",")) } unless existing
52
+ block&.call(key, io: io)
53
+ end
54
+ true
55
+ end
56
+
57
+ def unsub(channel, klass_name, *args, &block)
58
+ @mutex.synchronize do
59
+ id = args.first
60
+ id_string = id ? ":#{id}" : ""
61
+ key = "#{channel}:#{klass_name}#{id_string}"
62
+ value = with_redis { |r| r.get(key) }
63
+ return false unless value
64
+
65
+ io = args.pop if args.last.respond_to?(:call)
66
+ with_redis { |r| r.del(key) }
67
+ block&.call(key, io: io)
68
+ end
69
+ true
70
+ end
71
+
72
+ protected
73
+
74
+ def with_redis(&block)
75
+ @pool.with(&block)
76
+ end
77
+
78
+ def retrieve_klasses(iterable)
79
+ iterable.each_with_object({}) do |(key, args), hash|
80
+ channel, clazz, id = key.split(":")
81
+ arg_list = args.to_s.split(",")
82
+ hash[key] = { channel:, clazz:, args: arg_list, id: }
83
+ end
84
+ end
85
+
86
+ def fan_out(clazzes, method_args = {}, &block)
87
+ clazzes.each do |_key, opts|
88
+ clazz = turn_to_class(opts[:clazz])
89
+ clazz_args = opts[:args]
90
+
91
+ clazz.on_pub(opts[:channel], *clazz_args, **method_args) if clazz.respond_to?(:on_pub)
92
+ id_string = opts[:id] ? ":#{opts[:id]}" : ""
93
+ block&.call("#{opts[:channel]}:#{opts[:clazz]}#{id_string}", **method_args)
94
+ end
95
+ true
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,25 @@
1
+ module Rubee
2
+ module PubSub
3
+ module Subscriber
4
+ Error = Class.new(StandardError)
5
+
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def sub(channel, *args, &block)
12
+ Rubee::Configuration.pubsub_container.sub(channel, name, *args, &block)
13
+
14
+ true
15
+ end
16
+
17
+ def unsub(channel, *args, &block)
18
+ Rubee::Configuration.pubsub_container.unsub(channel, name, *args, &block)
19
+
20
+ true
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,29 @@
1
+ # class Subscriber
2
+ # include Rubee::PubSub::Subscriber
3
+
4
+ # def self.on_pub(channel, message, options = {})
5
+ # puts "channel=#{channel} message=#{message} options=#{options}"
6
+ # end
7
+ # end
8
+
9
+ # class SubscriberOne
10
+ # include Rubee::PubSub::Subscriber
11
+
12
+ # def self.on_pub(channel, message, options = {})
13
+ # puts "channel=#{channel} message=#{message} options=#{options}"
14
+ # end
15
+ # end
16
+
17
+ # class Publisher
18
+ # include Rubee::PubSub::Publisher
19
+ # end
20
+
21
+ # Subscriber.sub("ok", ["123456"])
22
+
23
+ # SubscriberOne.sub("ok", ["123"])
24
+
25
+ # Publisher.pub("ok", { message: "hello" })
26
+
27
+ # SubscriberOne.unsub("ok", ["123"])
28
+
29
+ # Publisher.pub("ok", { message: "hello" })
@@ -0,0 +1,102 @@
1
+ require 'websocket'
2
+ require 'websocket/frame'
3
+ require 'websocket/handshake'
4
+ require 'websocket/handshake/server'
5
+ require 'json'
6
+ require 'redis'
7
+
8
+ module Rubee
9
+ class WebSocket
10
+ using ChargedHash
11
+ class << self
12
+ def call(env, &controller_block)
13
+ unless env['rack.hijack']
14
+ return [500, { 'Content-Type' => 'text/plain' }, ['Hijack not supported']]
15
+ end
16
+
17
+ env['rack.hijack'].call
18
+ io = env['rack.hijack_io']
19
+
20
+ handshake = ::WebSocket::Handshake::Server.new
21
+ handshake.from_rack(env)
22
+ unless handshake.valid?
23
+ io.write("HTTP/1.1 400 Bad Request\r\n\r\n")
24
+ io.close
25
+ return [-1, {}, []]
26
+ end
27
+
28
+ io.write(handshake.to_s)
29
+ incoming = ::WebSocket::Frame::Incoming::Server.new(version: handshake.version)
30
+
31
+ outgoing = ->(data) do
32
+ frame = ::WebSocket::Frame::Outgoing::Server.new(
33
+ version: handshake.version,
34
+ type: :text,
35
+ data: data.to_json
36
+ )
37
+ io.write(frame.to_s)
38
+ rescue IOError
39
+ nil
40
+ end
41
+
42
+ # --- Listen to incoming data ---
43
+ Thread.new do
44
+ loop do
45
+ data = io.readpartial(1024)
46
+ incoming << data
47
+
48
+ while (frame = incoming.next)
49
+ case frame.type
50
+ when :text
51
+ out = controller_out(frame, outgoing, &controller_block)
52
+ outgoing.call(**out)
53
+
54
+ when :close
55
+ # Client closed connection
56
+ handle_close(frame, io, handshake)
57
+ break
58
+ end
59
+ end
60
+ end
61
+ rescue EOFError, IOError
62
+ begin
63
+ handle_close(frame, io, handshake)
64
+ rescue
65
+ nil
66
+ end
67
+ end
68
+
69
+ [101, handshake.headers, []]
70
+ end
71
+
72
+ def payload(frame)
73
+ JSON.parse(frame.data)
74
+ rescue
75
+ {}
76
+ end
77
+
78
+ def handle_close(frame, io, handshake)
79
+ payload_hash = payload(frame)
80
+ channel = payload_hash["channel"]
81
+ subcriber = payload_hash["subcriber"]
82
+ ::Rubee::WebSocketConnections.instance.remove("#{channel}:#{subcriber}", io)
83
+ io.write(::WebSocket::Frame::Outgoing::Server.new(
84
+ version: handshake.version,
85
+ type: :close
86
+ ).to_s)
87
+ io.close
88
+ end
89
+
90
+ def controller_out(frame, io, &block)
91
+ payload_hash = payload(frame)
92
+ action = payload_hash["action"]
93
+ channel = payload_hash["channel"]
94
+ message = payload_hash["message"]
95
+ options = payload_hash.select { |k, _| !["action", "channel", "message"].include?(k) }
96
+ options.merge!(io:)
97
+
98
+ block.call(channel:, message:, action:, options:)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,35 @@
1
+ module Rubee
2
+ class WebSocketConnections
3
+ include Singleton
4
+ def initialize
5
+ @subscribers ||= Hash.new { |h, k| h[k] = [] }
6
+ end
7
+
8
+ def register(channel, io)
9
+ @subscribers[channel] << io unless @subscribers[channel].include?(io)
10
+ end
11
+
12
+ def remove(channel, io)
13
+ @subscribers[channel].delete(io)
14
+ end
15
+
16
+ def remove_all(io)
17
+ @subscribers.each_value { _1.delete(io) }
18
+ end
19
+
20
+ def flush_all
21
+ @subscribers.each_value(&:clear)
22
+ end
23
+
24
+ def stream(channel, args = {})
25
+ ios = @subscribers[channel]
26
+ if !ios&.empty? && ios.all? { _1.respond_to?(:call) }
27
+ ios.each { _1.call(args) }
28
+ end
29
+ end
30
+
31
+ def clear
32
+ @subscribers = Hash.new { |h, k| h[k] = [] }
33
+ end
34
+ end
35
+ end
data/lib/rubee.rb CHANGED
@@ -16,11 +16,13 @@ module Rubee
16
16
  JS_DIR = File.join(APP_ROOT, LIB, 'js') unless defined?(JS_DIR)
17
17
  CSS_DIR = File.join(APP_ROOT, LIB, 'css') unless defined?(CSS_DIR)
18
18
  ROOT_PATH = File.expand_path(File.join(__dir__, '..')) unless defined?(ROOT_PATH)
19
- VERSION = '1.11.1'
19
+
20
+ VERSION = '2.0.0'
20
21
 
21
22
  require_relative 'rubee/router'
22
23
  require_relative 'rubee/logger'
23
24
  require_relative 'rubee/generator'
25
+ require_relative 'rubee/features'
24
26
  require_relative 'rubee/autoload'
25
27
  require_relative 'rubee/configuration'
26
28
 
@@ -31,15 +33,10 @@ module Rubee
31
33
  def call(env)
32
34
  # autoload rb files
33
35
  Autoload.call
34
-
35
- # register images paths
36
+ # init rack request
36
37
  request = Rack::Request.new(env)
37
38
  # Add default path for assets
38
- Router.draw do |route|
39
- route.get('/images/{path}', to: 'base#image', namespace: 'Rubee')
40
- route.get('/js/{path}', to: 'base#js', namespace: 'Rubee')
41
- route.get('/css/{path}', to: 'base#css', namespace: 'Rubee')
42
- end
39
+ register_assets_routes
43
40
  # define route
44
41
  route = Router.route_for(request)
45
42
  # if react is the view so we would like to delegate not cauth by rubee routes to it.
@@ -62,5 +59,15 @@ module Rubee
62
59
  # fire the action
63
60
  controller.send(action)
64
61
  end
62
+
63
+ private
64
+
65
+ def register_assets_routes
66
+ Router.draw do |route|
67
+ route.get('/images/{path}', to: 'base#image', namespace: 'Rubee')
68
+ route.get('/js/{path}', to: 'base#js', namespace: 'Rubee')
69
+ route.get('/css/{path}', to: 'base#css', namespace: 'Rubee')
70
+ end
71
+ end
65
72
  end
66
73
  end
@@ -1,6 +1,6 @@
1
1
  require_relative '../test_helper'
2
2
 
3
- class BaseControllerTest < Minitest::Test
3
+ class WebSocketControllerTest < Minitest::Test
4
4
  include Rack::Test::Methods
5
5
 
6
6
  def app
@@ -0,0 +1,41 @@
1
+ require_relative '../test_helper'
2
+
3
+ class UsersControllerTest < Minitest::Test
4
+ include Rack::Test::Methods
5
+
6
+ def app
7
+ Rubee::Application.instance
8
+ end
9
+
10
+ def test_websocket_handshake_written_to_io
11
+ env = Rack::MockRequest.env_for(
12
+ '/ws',
13
+ {
14
+ 'REQUEST_METHOD' => 'GET',
15
+ 'PATH_INFO' => '/ws',
16
+ 'HTTP_CONNECTION' => 'keep-alive, Upgrade',
17
+ 'HTTP_UPGRADE' => 'websocket',
18
+ 'HTTP_HOST' => 'localhost:9292',
19
+ 'HTTP_ORIGIN' => 'http://localhost:9292',
20
+ 'HTTP_SEC_WEBSOCKET_KEY' => 'dGhlIHNhbXBsZSBub25jZQ==',
21
+ 'HTTP_SEC_WEBSOCKET_VERSION' => '13',
22
+ 'rack.url_scheme' => 'http'
23
+ }
24
+ )
25
+
26
+ # Mock hijack interface expected by Rubee::WebSocket
27
+ io = StringIO.new
28
+ env['rack.hijack'] = proc {}
29
+ env['rack.hijack_io'] = io
30
+
31
+ # Call the WebSocket handler
32
+ Rubee::WebSocket.call(env)
33
+
34
+ # Expect the handshake response written to IO
35
+ io.rewind
36
+ handshake_response = io.read
37
+ assert_includes(handshake_response, "HTTP/1.1 101 Switching Protocols")
38
+ assert_includes(handshake_response, "Upgrade: websocket")
39
+ assert_includes(handshake_response, "Connection: Upgrade")
40
+ end
41
+ end
@@ -345,4 +345,98 @@ zip: '555555')
345
345
  end
346
346
  end
347
347
  end
348
+
349
+ describe 'pubsub' do
350
+ describe 'when Rubee::PubSub::Subscriber is included' do
351
+ before { User.include(Rubee::PubSub::Subscriber) }
352
+
353
+ it 'reveals sub method' do
354
+ _(User.respond_to?(:sub)).must_equal(true)
355
+ end
356
+
357
+ it 'reveals unsub method' do
358
+ _(User.respond_to?(:unsub)).must_equal(true)
359
+ end
360
+ end
361
+
362
+ describe 'when Rubee::PubSub::Publisher is included' do
363
+ before { User.include(Rubee::PubSub::Publisher) }
364
+
365
+ it 'reveals pub method' do
366
+ _(User.respond_to?(:pub)).must_equal(true)
367
+ end
368
+ end
369
+
370
+ describe '.sub' do
371
+ before do
372
+ User.include(Rubee::PubSub::Subscriber)
373
+ User.include(Rubee::PubSub::Publisher)
374
+ end
375
+
376
+ user = User.create(email: 'ok-test@test.com', password: '123')
377
+
378
+ describe 'when sub with channel and args' do
379
+ it 'returns true' do
380
+ _(User.sub("ok", [user.id.to_s])).must_equal(true)
381
+
382
+ User.unsub("ok", [user.id.to_s])
383
+ user.destroy
384
+ end
385
+ end
386
+ end
387
+
388
+ describe '.unsub' do
389
+ before do
390
+ User.include(Rubee::PubSub::Subscriber)
391
+ User.include(Rubee::PubSub::Publisher)
392
+ end
393
+
394
+ describe 'when unsub with channel and args' do
395
+ it 'returns true' do
396
+ _(User.unsub("ok", ["123456"])).must_equal(true)
397
+ end
398
+ end
399
+ end
400
+
401
+ describe 'pub flow' do
402
+ describe 'when pub with channel and args' do
403
+ after do
404
+ User.destroy_all(cascade: true)
405
+ end
406
+
407
+ it 'fan out ouput' do
408
+ User.include(Rubee::PubSub::Subscriber)
409
+ User.include(Rubee::PubSub::Publisher)
410
+
411
+ user_one = User.create(email: 'ok-test1@test.com', password: '123')
412
+ user_two = User.create(email: 'ok-test@2test.com', password: '123')
413
+ user_three = User.create(email: 'ok-test3@test.com', password: '123')
414
+
415
+ User.singleton_class.define_method(:on_pub) do |channel, *args, **options|
416
+ id = args.first
417
+ user = User.find(id)
418
+ if user
419
+ user.update(password: '321')
420
+ else
421
+ raise "User with id=#{id} not found"
422
+ end
423
+ end
424
+
425
+ User.sub("ok", [user_one.id.to_s])
426
+ User.sub("ok", [user_two.id.to_s])
427
+ User.sub("ok", [user_three.id.to_s])
428
+
429
+ User.pub("ok", message: "hello")
430
+
431
+ User.unsub("ok", [user_one.id.to_s])
432
+ User.unsub("ok", [user_two.id.to_s])
433
+ User.unsub("ok", [user_three.id.to_s])
434
+
435
+ _(user_one.reload.password).must_equal('321')
436
+ _(user_two.reload.password).must_equal('321')
437
+ _(user_three.reload.password).must_equal('321')
438
+ end
439
+ end
440
+ end
441
+ end
348
442
  end
data/lib/tests/test.db CHANGED
Binary file
data/readme.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  # <img src="lib/images/rubee.svg" alt="RUBEE" height="40"> ... RUBEE
10
10
 
11
- Rubee is a Ruby-based web framework designed to streamline the development of modular monolith web applications. \
11
+ RUBEE is a Ruby-based web framework designed to streamline the development of modular monolith web applications. \
12
12
  Under the hood, it leverages the power of Ruby and Rack backed by Puma, offering a clean, efficient, and flexible architecture. \
13
13
  It offers a structured approach to building scalable, maintainable, and React-ready projects, \
14
14
  making it an ideal choice for developers seeking a balance between monolithic simplicity and modular flexibility.
@@ -19,7 +19,7 @@ Want to get a quick API server up and runing? You can do it for real quick!
19
19
 
20
20
  ## Production ready
21
21
 
22
- Take a look on the rubee demo site with all documentation stored in there: https://rubee.dedyn.io/
22
+ Take a look on the RUBEE demo site with all documentation stored in there: https://rubee.dedyn.io/
23
23
  Want to explore how it built? https://github.com/nucleom42/rubee-site
24
24
 
25
25
  ## Stress tested
@@ -41,9 +41,9 @@ Transfer/sec: 140.07KB
41
41
  - Average latency: ~305 ms
42
42
  - Total requests handled: 9,721
43
43
  - Hardware: Raspberry Pi 5(8 Gb) (single board computer)
44
- - Server: Rubee app hosted via Nginx + HTTPS
44
+ - Server: RUBEE app hosted via Nginx + HTTPS
45
45
 
46
- This demonstrates RUBEE’s efficient architecture and suitability for lightweight deployments — even on low-power hardware.
46
+ This demonstrate RUBEE’s efficient architecture and suitability for lightweight deployments — even on low-power hardware.
47
47
 
48
48
  ## Comparison
49
49
  Here below is a **short web frameworks comparison** built with Ruby, so you can evaluate your choice with RUBEE.
@@ -51,7 +51,7 @@ Here below is a **short web frameworks comparison** built with Ruby, so you can
51
51
  **Disclaimer:**
52
52
  The comparison is based on a very generic and subjective information open in the Internet and is not a real benchmark. The comparison is aimed to give you a general idea of the differences between the frameworks and Rubee and not to compare the frameworks directly.
53
53
 
54
- | Feature / Framework | **Rubee** | Rails | Sinatra | Hanami | Padrino | Grape |
54
+ | Feature / Framework | **RUBEE** | Rails | Sinatra | Hanami | Padrino | Grape |
55
55
  |---------------------|-----------|-------|---------|--------|---------|-------|
56
56
  | **React readiness** | Built-in React integration (route generator can scaffold React components that fetch data via controllers) | React via webpacker/importmap, but indirect | No direct React support | Can integrate React | Can integrate via JS pipelines | API-focused, no React support |
57
57
  | **Routing style** | Explicit, file-based routes with clear JSON/HTML handling | DSL, routes often implicit inside controllers | Explicit DSL, inline in code | Declarative DSL | Rails-like DSL | API-oriented DSL |
@@ -74,6 +74,7 @@ The comparison is based on a very generic and subjective information open in the
74
74
  - [Views](#views)
75
75
  - [Hooks](#hooks)
76
76
  - [JWT based authentification](#jwt-based-authentification)
77
+ - [OAuth2 based authentification](#oauth-authentification)
77
78
  - [Rubee commands](#rubee-commands)
78
79
  - [Generate commands](#generate-commands)
79
80
  - [Migration commands](#migration-commands)
@@ -105,9 +106,9 @@ Databases – Supports SQLite3, PostgreSQL, MySQL, and more via the Sequel gem.
105
106
  <br>
106
107
  Views – JSON, ERB, and plain HTML out of the box.
107
108
  <br>
108
- React Ready – React is supported as a first-class Rubee view engine.
109
+ React Ready – React is supported as a first-class RUBEE view engine.
109
110
  <br>
110
- Bundlable – Charge your Rubee app with any gem you need. Update effortlessly via Bundler.
111
+ Bundlable – Charge your RUBEE app with any gem you need. Update effortlessly via Bundler.
111
112
  <br>
112
113
  ORM-agnostic – Models are native ORM objects, but you can use them as blueprints for any data source.
113
114
  <br>
@@ -150,7 +151,7 @@ Make sure:
150
151
  bundle install
151
152
  ```
152
153
 
153
- 4. Run RUBEE server. Default port is 7000
154
+ 4. Run RUBER server. Default port is 7000
154
155
  ```bash
155
156
  rubee start # or rubee start_dev for development
156
157
 
@@ -354,7 +355,7 @@ irb(main):023> User.all
354
355
  => []
355
356
  ```
356
357
 
357
- Use complex queries chains and when ready serialize it back to Rubee object.
358
+ Use complex queries chains and when ready serialize it back to RUBEE object.
358
359
  ```Ruby
359
360
  # user model
360
361
  class User < Rubee::SequelObject
@@ -391,7 +392,7 @@ irb(main):009> .where(comment_id: Comment.where(text: "test").last.id)
391
392
  irb(main):010> .then { |dataset| Comment.serialize(dataset) }
392
393
  => [#<Comment:0x0000000121889998 @id=30, @text="test", @user_id=702, @created=2025-09-28 22:03:07.011332 -0400, @updated=2025-09-28 22:03:07.011332 -0400>]
393
394
  ```
394
- This is recommended when you want to run one query and serialize it back to Rubee object only once.
395
+ This is recommended when you want to run one query and serialize it back to RUBEE object only once.
395
396
  So it may safe some resources.
396
397
 
397
398
  [Back to content](#content)
@@ -407,7 +408,7 @@ If you feel comfortable you can play with retry configuration parameters:
407
408
  config.db_busy_timeout = { env:, value: 1000 } # this is busy timeout in ms, before raising bussy error
408
409
  ```
409
410
 
410
- For Rubee model class persist methods create and update retry will be added automatically. However, \
411
+ For RUBEE model class persist methods create and update retry will be added automatically. However, \
411
412
  if you want to do it with Sequel dataset you need to do it yourself:
412
413
 
413
414
  ```ruby
@@ -416,7 +417,7 @@ if you want to do it with Sequel dataset you need to do it yourself:
416
417
  [Back to content](#content)
417
418
 
418
419
  ## Routing
419
- Rubee uses explicit routes. In the routes.rb yout can define routes for any of the main HTTP methods. \
420
+ RUBEE uses explicit routes. In the routes.rb yout can define routes for any of the main HTTP methods. \
420
421
  You can also add any matched parameter denoted by a pair of `{ }` in the path of the route. \
421
422
  Eg. `/path/to/{a_key}/somewhere`
422
423
 
@@ -442,7 +443,7 @@ route.{http_method} {path}, to: "{controller}#{action}",
442
443
  ```
443
444
 
444
445
  ### Defining Model attributes in routes
445
- One of Rubee's unique traits is where we can define our models for generation. \
446
+ One of RUBEE's unique traits is where we can define our models for generation. \
446
447
  You've seen above one possible way you can set up.
447
448
 
448
449
  ```ruby
@@ -577,7 +578,7 @@ Main philosophy of attach functinality is to keep the main project clean and eas
577
578
  share data with the main app. So where to define a border between the main app and subprojects is up to developer.
578
579
  Howerver by attching new subproject you will get a new folder and files configured and namespaced respectively.
579
580
 
580
- So if you need to extend your main app with a separate project, you can do it easily in RUBEE.
581
+ So if you need to extend your main app with a separate project, you can do it easily in ruBEE.
581
582
  1. Attach new subrpoject
582
583
 
583
584
  ```bash
@@ -874,7 +875,7 @@ Feel free to customize it in the /db/create_users.rb file before running migrati
874
875
  Then in the controller you can include the AuthTokenable module and use its methods:
875
876
  ```ruby
876
877
  class UsersController < Rubee::BaseController
877
- include AuthTokenable
878
+ include Rubee::AuthTokenable
878
879
  # List methods you want to restrict
879
880
  auth_methods :index # unless the user is authentificated it will return unauthentificated
880
881
 
@@ -907,10 +908,103 @@ class UsersController < Rubee::BaseController
907
908
  end
908
909
  end
909
910
  ```
911
+ ## OAuth authentification
912
+ If you want to plug in the OAuth 2.0 authentication, you can use the following code using OAuth2 gem:
913
+ First thing you need to do is to add the gem to your Gemfile
914
+ ```bash
915
+ gem 'oauth2'
916
+ ```
917
+ Then use down below code as an example and add yours to your controller
918
+ ```ruby
919
+ class UsersController < Rubee::BaseController
920
+ include Rubee::AuthTokenable
921
+
922
+ REDIRECT_URI = 'https://mysite.com/users/outh_callback'
923
+ CLIENT_ID = ENV['GOOGLE_CLIENT_ID']
924
+ CLIENT_SECRET = ENV['GOOGLE_CLIENT_SECRET']
925
+
926
+ # GET /login (login form page)
927
+ def edit
928
+ response_with
929
+ end
930
+
931
+ # POST /users/login (login logic)
932
+ def login
933
+ Rubee::Logger.info(message: "Login attempt for user #{params[:email]}")
934
+ if authentificate! # AuthTokenable method that init @token_header
935
+ Rubee::Logger.info(message: "Successful login for user #{@authentificated_user.email}")
936
+ response_with(type: :redirect, to: "/sections", headers: @token_header)
937
+ else
938
+ @error = "Wrong email or password"
939
+ response_with(render_view: "users_edit")
940
+ end
941
+ end
942
+
943
+ # GET /users/outh_login
944
+ def outh_login
945
+ response_with(
946
+ type: :redirect,
947
+ to: auth_client.auth_code.authorize_url(
948
+ redirect_uri: REDIRECT_URI,
949
+ scope: 'email profile openid'
950
+ )
951
+ )
952
+ end
953
+
954
+ # GET /users/outh_callback
955
+ def outh_callback
956
+ code = params[:code]
957
+ token = auth_client.auth_code.get_token(code, redirect_uri: REDIRECT_URI)
958
+ user_info = JSON.parse(token.get('https://www.googleapis.com/oauth2/v1/userinfo?alt=json').body)
959
+ Rubee::Logger.debug(object: user_info, method: "outh_callback", class: "UsersController")
960
+
961
+ user = User.where(email: user_info['email'])&.last
962
+ unless user
963
+ raise "User with email #{user_info['email']} not found"
964
+ end
965
+
966
+ params[:email] = user_info['email']
967
+ params[:password] = user.password
968
+
969
+ if authentificate! # AuthTokenable method that init @token_header
970
+ Rubee::Logger.info(message: "Successful Outh login for user #{@authentificated_user.email}")
971
+ response_with(type: :redirect, to: "/sections", headers: @token_header)
972
+ else
973
+ @error = "Something went wrong"
974
+ response_with(render_view: "users_edit")
975
+ end
976
+ rescue OAuth2::Error => e
977
+ @error = "OAuth login failed"
978
+ response_with(render_view: "users_edit")
979
+ rescue StandardError => e
980
+ @error = "Something went wrong"
981
+ response_with(render_view: "users_edit")
982
+ end
983
+
984
+ # POST /users/logout (logout logic)
985
+ def logout
986
+ unauthentificate! # AuthTokenable method aimed to handle logout action.
987
+ # Make sure @zeroed_token_header is paRssed within headers options
988
+ response_with(type: :redirect, to: "/login", headers: @zeroed_token_header)
989
+ end
990
+
991
+ private
992
+
993
+ def auth_client
994
+ @client ||= OAuth2::Client.new(
995
+ CLIENT_ID,
996
+ CLIENT_SECRET,
997
+ site: 'https://accounts.google.com',
998
+ authorize_url: '/o/oauth2/auth',
999
+ token_url: 'https://oauth2.googleapis.com/token'
1000
+ )
1001
+ end
1002
+ end
1003
+ ```
910
1004
 
911
1005
  [Back to content](#content)
912
1006
 
913
- ## Rubee commands
1007
+ ## RUBEE commands
914
1008
  ```bash
915
1009
  rubee start # start the server
916
1010
  rubee start_dev # start the server in dev mode, which restart server on changes
@@ -932,7 +1026,7 @@ rubee db run:create_apples # where create_apples is the name of the migration fi
932
1026
  rubee db structure # generate migration file for the database structure
933
1027
  ```
934
1028
 
935
- ## Rubee console
1029
+ ## RUBEE console
936
1030
  ```bash
937
1031
  rubee console # start the console
938
1032
  # you can reload the console by typing reload, so it will pick up latest changes
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ru.Bee
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.11.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oleg Saltykov
@@ -41,6 +41,7 @@ files:
41
41
  - bin/rubee
42
42
  - bin/test.db
43
43
  - lib/Dockerfile
44
+ - lib/app/controllers/users_controller.rb
44
45
  - lib/app/controllers/welcome_controller.rb
45
46
  - lib/app/models/user.rb
46
47
  - lib/app/views/App.tsx
@@ -66,6 +67,7 @@ files:
66
67
  - lib/inits/charged_hash.rb
67
68
  - lib/inits/charged_string.rb
68
69
  - lib/inits/print_colors.rb
70
+ - lib/inits/system.rb
69
71
  - lib/js/app.js
70
72
  - lib/js/app.js.map
71
73
  - lib/js/bundle.js
@@ -254,18 +256,27 @@ files:
254
256
  - lib/rubee/controllers/middlewares/auth_token_middleware.rb
255
257
  - lib/rubee/extensions/hookable.rb
256
258
  - lib/rubee/extensions/serializable.rb
259
+ - lib/rubee/features.rb
257
260
  - lib/rubee/generator.rb
258
261
  - lib/rubee/logger.rb
259
262
  - lib/rubee/models/database_objectable.rb
260
263
  - lib/rubee/models/db_tools.rb
261
264
  - lib/rubee/models/sequel_object.rb
265
+ - lib/rubee/pubsub/container.rb
266
+ - lib/rubee/pubsub/publisher.rb
267
+ - lib/rubee/pubsub/redis.rb
268
+ - lib/rubee/pubsub/subscriber.rb
269
+ - lib/rubee/pubsub/test_one.rb
262
270
  - lib/rubee/router.rb
271
+ - lib/rubee/websocket/websocket.rb
272
+ - lib/rubee/websocket/websocket_connections.rb
263
273
  - lib/tests/async/thread_async_test.rb
264
274
  - lib/tests/cli/attach_test.rb
265
275
  - lib/tests/controllers/auth_tokenable_test.rb
266
276
  - lib/tests/controllers/base_controller_test.rb
267
277
  - lib/tests/controllers/hookable_test.rb
268
278
  - lib/tests/controllers/rubeeapp_test.rb
279
+ - lib/tests/controllers/users_controller_test.rb
269
280
  - lib/tests/example_models/account.rb
270
281
  - lib/tests/example_models/address.rb
271
282
  - lib/tests/example_models/comment.rb
@@ -300,7 +311,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
300
311
  - !ruby/object:Gem::Version
301
312
  version: '0'
302
313
  requirements: []
303
- rubygems_version: 3.7.0
314
+ rubygems_version: 3.7.2
304
315
  specification_version: 4
305
316
  summary: Fast and lightweight Ruby application server designed for minimalism and
306
317
  flexibility