takagi 0.1.0 → 1.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 +4 -4
- data/.rubocop.yml +70 -7
- data/.yard/templates/default/layout/html/layout.erb +34 -0
- data/AGENTS.md +16 -0
- data/CHANGELOG.md +158 -1
- data/CODE_OF_CONDUCT.md +1 -1
- data/README.md +590 -23
- data/ROADMAP.md +55 -0
- data/Rakefile +4 -4
- data/Steepfile +39 -0
- data/bin/takagi-dev +159 -0
- data/docs/FIRST_PLUGIN_GUIDE.md +224 -0
- data/docs/HOOKS.md +31 -0
- data/examples/client_lifecycle_example.rb +118 -0
- data/examples/cloud_gateway_app.rb +217 -0
- data/examples/nested_api_app.rb +258 -0
- data/examples/simple_device_app.rb +71 -0
- data/examples/takagi.yml +138 -0
- data/lib/takagi/application.rb +256 -0
- data/lib/takagi/base/middleware_management.rb +39 -0
- data/lib/takagi/base/plugin_management.rb +75 -0
- data/lib/takagi/base/reactor_management.rb +104 -0
- data/lib/takagi/base/server_lifecycle.rb +156 -0
- data/lib/takagi/base.rb +103 -11
- data/lib/takagi/branding.rb +88 -0
- data/lib/takagi/cbor/decoder.rb +385 -0
- data/lib/takagi/cbor/encoder.rb +260 -0
- data/lib/takagi/cbor/error.rb +17 -0
- data/lib/takagi/cbor/version.rb +9 -0
- data/lib/takagi/client/response.rb +236 -0
- data/lib/takagi/client.rb +265 -0
- data/lib/takagi/client_base.rb +204 -0
- data/lib/takagi/coap/code_helpers.rb +190 -0
- data/lib/takagi/coap/registries/base.rb +165 -0
- data/lib/takagi/coap/registries/content_format.rb +71 -0
- data/lib/takagi/coap/registries/message_type.rb +69 -0
- data/lib/takagi/coap/registries/method.rb +38 -0
- data/lib/takagi/coap/registries/option.rb +71 -0
- data/lib/takagi/coap/registries/response.rb +93 -0
- data/lib/takagi/coap/registries/signaling.rb +34 -0
- data/lib/takagi/coap/signaling.rb +10 -0
- data/lib/takagi/coap.rb +37 -0
- data/lib/takagi/composite_router.rb +186 -0
- data/lib/takagi/config.rb +337 -0
- data/lib/takagi/controller/resource_allocator.rb +164 -0
- data/lib/takagi/controller/thread_pool.rb +144 -0
- data/lib/takagi/controller.rb +319 -0
- data/lib/takagi/core/attribute_set.rb +128 -0
- data/lib/takagi/discovery/core_link_format.rb +137 -0
- data/lib/takagi/errors.rb +536 -0
- data/lib/takagi/event_bus/address_prefix.rb +142 -0
- data/lib/takagi/event_bus/async_executor.rb +235 -0
- data/lib/takagi/event_bus/coap_bridge.rb +208 -0
- data/lib/takagi/event_bus/future.rb +153 -0
- data/lib/takagi/event_bus/lru_cache.rb +157 -0
- data/lib/takagi/event_bus/message_buffer.rb +237 -0
- data/lib/takagi/event_bus/observer_cleanup.rb +110 -0
- data/lib/takagi/event_bus/scope.rb +74 -0
- data/lib/takagi/event_bus.rb +594 -0
- data/lib/takagi/helpers.rb +88 -0
- data/lib/takagi/hooks.rb +82 -0
- data/lib/takagi/initializer.rb +18 -0
- data/lib/takagi/logger.rb +15 -6
- data/lib/takagi/message/base.rb +155 -0
- data/lib/takagi/message/deduplication_cache.rb +84 -0
- data/lib/takagi/message/inbound.rb +147 -0
- data/lib/takagi/message/outbound.rb +223 -0
- data/lib/takagi/message/request.rb +158 -0
- data/lib/takagi/message/retransmission_manager.rb +193 -0
- data/lib/takagi/middleware/authentication.rb +19 -0
- data/lib/takagi/middleware/caching.rb +23 -0
- data/lib/takagi/middleware/debugging.rb +16 -0
- data/lib/takagi/middleware/logging.rb +14 -0
- data/lib/takagi/middleware/metrics.rb +440 -0
- data/lib/takagi/middleware/rate_limiting.rb +24 -0
- data/lib/takagi/middleware_stack.rb +166 -0
- data/lib/takagi/network/base.rb +76 -0
- data/lib/takagi/network/framing/tcp.rb +222 -0
- data/lib/takagi/network/framing/udp.rb +110 -0
- data/lib/takagi/network/registry.rb +72 -0
- data/lib/takagi/network/tcp.rb +60 -0
- data/lib/takagi/network/tcp_sender.rb +21 -0
- data/lib/takagi/network/udp.rb +61 -0
- data/lib/takagi/network/udp_sender.rb +20 -0
- data/lib/takagi/observable/emitter.rb +62 -0
- data/lib/takagi/observable/reactor.rb +488 -0
- data/lib/takagi/observable/registry.rb +122 -0
- data/lib/takagi/observe_registry.rb +10 -0
- data/lib/takagi/observer/client.rb +68 -0
- data/lib/takagi/observer/registry.rb +137 -0
- data/lib/takagi/observer/sender.rb +39 -0
- data/lib/takagi/observer/watcher.rb +43 -0
- data/lib/takagi/plugin.rb +313 -0
- data/lib/takagi/profiles.rb +176 -0
- data/lib/takagi/reactor.rb +23 -0
- data/lib/takagi/reactor_registry.rb +64 -0
- data/lib/takagi/registry/base.rb +268 -0
- data/lib/takagi/response_builder.rb +141 -0
- data/lib/takagi/router/metadata_extractor.rb +133 -0
- data/lib/takagi/router/route_matcher.rb +83 -0
- data/lib/takagi/router.rb +284 -25
- data/lib/takagi/serialization/base.rb +102 -0
- data/lib/takagi/serialization/cbor_serializer.rb +92 -0
- data/lib/takagi/serialization/json_serializer.rb +96 -0
- data/lib/takagi/serialization/octet_stream_serializer.rb +82 -0
- data/lib/takagi/serialization/registry.rb +187 -0
- data/lib/takagi/serialization/text_serializer.rb +87 -0
- data/lib/takagi/serialization.rb +117 -0
- data/lib/takagi/server/multi.rb +41 -0
- data/lib/takagi/server/registry.rb +71 -0
- data/lib/takagi/server/tcp.rb +249 -0
- data/lib/takagi/server/udp.rb +139 -0
- data/lib/takagi/server/udp_worker.rb +174 -0
- data/lib/takagi/server.rb +1 -31
- data/lib/takagi/server_registry.rb +10 -0
- data/lib/takagi/tcp_client.rb +142 -0
- data/lib/takagi/version.rb +2 -1
- data/lib/takagi.rb +24 -3
- data/sig/takagi/application.rbs +48 -0
- data/sig/takagi/base/middleware_management.rbs +33 -0
- data/sig/takagi/base/reactor_management.rbs +52 -0
- data/sig/takagi/base/server_lifecycle.rbs +54 -0
- data/sig/takagi/base.rbs +48 -0
- data/sig/takagi/cbor/decoder.rbs +171 -0
- data/sig/takagi/cbor/encoder.rbs +146 -0
- data/sig/takagi/cbor/error.rbs +19 -0
- data/sig/takagi/cbor/version.rbs +7 -0
- data/sig/takagi/client/response.rbs +148 -0
- data/sig/takagi/client.rbs +119 -0
- data/sig/takagi/client_base.rbs +135 -0
- data/sig/takagi/coap/code_helpers.rbs +91 -0
- data/sig/takagi/coap/registries/base.rbs +95 -0
- data/sig/takagi/coap/registries/content_format.rbs +47 -0
- data/sig/takagi/coap/registries/message_type.rbs +53 -0
- data/sig/takagi/coap/registries/method.rbs +27 -0
- data/sig/takagi/coap/registries/option.rbs +43 -0
- data/sig/takagi/coap/registries/response.rbs +52 -0
- data/sig/takagi/coap.rbs +24 -0
- data/sig/takagi/composite_router.rbs +46 -0
- data/sig/takagi/config.rbs +134 -0
- data/sig/takagi/controller.rbs +73 -0
- data/sig/takagi/core/attribute_set.rbs +57 -0
- data/sig/takagi/discovery/core_link_format.rbs +50 -0
- data/sig/takagi/event_bus/address_prefix.rbs +78 -0
- data/sig/takagi/event_bus/async_executor.rbs +88 -0
- data/sig/takagi/event_bus/coap_bridge.rbs +93 -0
- data/sig/takagi/event_bus/future.rbs +78 -0
- data/sig/takagi/event_bus/lru_cache.rbs +86 -0
- data/sig/takagi/event_bus/message_buffer.rbs +133 -0
- data/sig/takagi/event_bus/observer_cleanup.rbs +62 -0
- data/sig/takagi/event_bus.rbs +320 -0
- data/sig/takagi/helpers.rbs +34 -0
- data/sig/takagi/initializer.rbs +9 -0
- data/sig/takagi/logger.rbs +17 -0
- data/sig/takagi/message/base.rbs +64 -0
- data/sig/takagi/message/deduplication_cache.rbs +49 -0
- data/sig/takagi/message/inbound.rbs +76 -0
- data/sig/takagi/message/outbound.rbs +48 -0
- data/sig/takagi/message/request.rbs +32 -0
- data/sig/takagi/message/retransmission_manager.rbs +76 -0
- data/sig/takagi/middleware/authentication.rbs +11 -0
- data/sig/takagi/middleware/caching.rbs +13 -0
- data/sig/takagi/middleware/debugging.rbs +9 -0
- data/sig/takagi/middleware/logging.rbs +7 -0
- data/sig/takagi/middleware/metrics.rbs +15 -0
- data/sig/takagi/middleware/rate_limiting.rbs +13 -0
- data/sig/takagi/middleware_stack.rbs +69 -0
- data/sig/takagi/network/tcp_sender.rbs +10 -0
- data/sig/takagi/network/udp_sender.rbs +14 -0
- data/sig/takagi/observe_registry.rbs +36 -0
- data/sig/takagi/observer/client.rbs +36 -0
- data/sig/takagi/observer/sender.rbs +12 -0
- data/sig/takagi/observer/watcher.rbs +18 -0
- data/sig/takagi/profiles.rbs +33 -0
- data/sig/takagi/reactor.rbs +20 -0
- data/sig/takagi/reactor_registry.rbs +14 -0
- data/sig/takagi/response_builder.rbs +12 -0
- data/sig/takagi/router/metadata_extractor.rbs +71 -0
- data/sig/takagi/router/route_matcher.rbs +43 -0
- data/sig/takagi/router.rbs +166 -0
- data/sig/takagi/serialization.rbs +32 -0
- data/sig/takagi/server/multi.rbs +16 -0
- data/sig/takagi/server/tcp.rbs +42 -0
- data/sig/takagi/server/udp.rbs +52 -0
- data/sig/takagi/server/udp_worker.rbs +42 -0
- data/sig/takagi/server.rbs +4 -0
- data/sig/takagi/server_registry.rbs +71 -0
- data/sig/takagi/tcp_client.rbs +23 -0
- data/sig/takagi/version.rbs +5 -0
- data/takagi.gemspec +37 -35
- metadata +204 -31
- data/.idea/.gitignore +0 -8
- data/.idea/misc.xml +0 -4
- data/.idea/modules.xml +0 -8
- data/.idea/takagi.iml +0 -81
- data/.idea/vcs.xml +0 -6
- data/lib/takagi/message.rb +0 -75
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../hooks'
|
|
4
|
+
|
|
5
|
+
module Takagi
|
|
6
|
+
module Observer
|
|
7
|
+
# Keeps track of observers and broadcasts state changes to interested parties.
|
|
8
|
+
#
|
|
9
|
+
# NOTE: This registry intentionally does NOT use Registry::Base because:
|
|
10
|
+
# 1. It stores arrays of subscriptions per path (not simple key-value pairs)
|
|
11
|
+
# 2. It has complex domain logic (delta checking, notifications, cleanup)
|
|
12
|
+
# 3. It needs fine-grained mutex control for notify operations
|
|
13
|
+
# 4. The API is fundamentally different (subscribe/notify vs register/get)
|
|
14
|
+
#
|
|
15
|
+
# This is already thread-safe with its own @mutex implementation.
|
|
16
|
+
class Registry
|
|
17
|
+
@subscriptions = {}
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
attr_reader :subscriptions
|
|
22
|
+
|
|
23
|
+
def subscribe(path, subscriber)
|
|
24
|
+
entry = subscriber.dup
|
|
25
|
+
entry[:created_at] ||= Time.now
|
|
26
|
+
entry[:last_notified_at] ||= nil
|
|
27
|
+
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
@subscriptions[path] ||= []
|
|
30
|
+
@subscriptions[path] << entry
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Takagi::Hooks.emit(:observe_subscribed, path: path, subscription: entry)
|
|
34
|
+
|
|
35
|
+
entry
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def unsubscribe(path, token)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
return unless @subscriptions[path]
|
|
41
|
+
|
|
42
|
+
@subscriptions[path].reject! { |s| s[:token] == token }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Takagi::Hooks.emit(:observe_unsubscribed, path: path, token: token)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def notify(path, new_value)
|
|
49
|
+
# Get a snapshot of subscribers to avoid holding the lock during notification
|
|
50
|
+
subscribers = @mutex.synchronize { @subscriptions[path]&.dup }
|
|
51
|
+
return unless subscribers
|
|
52
|
+
|
|
53
|
+
Takagi.logger.debug "Notify called for: #{path}"
|
|
54
|
+
Takagi.logger.debug "Subscriptions count: #{subscribers.size}"
|
|
55
|
+
|
|
56
|
+
Takagi::Hooks.emit(:observe_notify_start, path: path, subscribers: subscribers&.size || 0, value: new_value)
|
|
57
|
+
|
|
58
|
+
subscribers.each do |subscription|
|
|
59
|
+
next unless should_notify?(subscription, new_value)
|
|
60
|
+
|
|
61
|
+
deliver_notification(subscription, path, new_value)
|
|
62
|
+
update_sequence(subscription, new_value)
|
|
63
|
+
subscription[:last_notified_at] = Time.now
|
|
64
|
+
|
|
65
|
+
Takagi::Hooks.emit(:observer_notification, path: path, subscription: subscription)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Takagi::Hooks.emit(:observe_notify_end, path: path, delivered: subscribers&.size || 0, value: new_value)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def sender
|
|
72
|
+
@sender ||= Takagi::Observer::Sender.new
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def subscription_paths
|
|
76
|
+
@mutex.synchronize { @subscriptions.keys.dup }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cleanup_stale_observers(max_age:, now: Time.now)
|
|
80
|
+
cutoff = now - max_age
|
|
81
|
+
cleaned = 0
|
|
82
|
+
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
@subscriptions.each do |path, subscribers|
|
|
85
|
+
subscribers.reject! do |subscription|
|
|
86
|
+
stale = stale_subscription?(subscription, cutoff)
|
|
87
|
+
cleaned += 1 if stale
|
|
88
|
+
stale
|
|
89
|
+
end
|
|
90
|
+
@subscriptions.delete(path) if subscribers.empty?
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
cleaned
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def should_notify?(subscription, new_value)
|
|
100
|
+
return true unless subscription[:delta] && subscription[:last_value]
|
|
101
|
+
|
|
102
|
+
delta_exceeded?(subscription[:last_value], new_value, subscription[:delta])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def delta_exceeded?(last_value, new_value, threshold)
|
|
106
|
+
(last_value - new_value).abs >= threshold
|
|
107
|
+
rescue StandardError
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def deliver_notification(subscription, path, new_value)
|
|
112
|
+
if subscription[:handler]
|
|
113
|
+
Takagi.logger.debug "Calling local handler for #{path}"
|
|
114
|
+
subscription[:handler].call(new_value, nil)
|
|
115
|
+
else
|
|
116
|
+
Takagi.logger.debug "Sending packet to #{subscription[:address]}:#{subscription[:port]}"
|
|
117
|
+
sender.send_packet(subscription, new_value)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def update_sequence(subscription, new_value)
|
|
122
|
+
subscription[:last_value] = new_value
|
|
123
|
+
subscription[:last_seq] = (subscription[:last_seq] || 0) + 1
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def stale_subscription?(subscription, cutoff)
|
|
127
|
+
return false if subscription[:handler]
|
|
128
|
+
|
|
129
|
+
last_activity = subscription[:last_notified_at] || subscription[:created_at]
|
|
130
|
+
return false unless last_activity
|
|
131
|
+
|
|
132
|
+
last_activity < cutoff
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Takagi
|
|
4
|
+
module Observer
|
|
5
|
+
# Dispatches outbound notifications to subscribed observers.
|
|
6
|
+
# Supports multiple transports (UDP, TCP, etc.) via transport registry.
|
|
7
|
+
class Sender
|
|
8
|
+
def initialize(transport: :udp)
|
|
9
|
+
@transport = transport
|
|
10
|
+
# NEW: Use transport registry to get appropriate sender
|
|
11
|
+
transport_class = Takagi::Network::Registry.get(@transport)
|
|
12
|
+
transport_impl = transport_class.new
|
|
13
|
+
@sender = transport_impl.create_sender
|
|
14
|
+
rescue Takagi::Network::Registry::TransportNotFoundError
|
|
15
|
+
# Fallback to UDP if transport not found
|
|
16
|
+
Takagi.logger.warn "Transport #{@transport} not found, using UDP"
|
|
17
|
+
@sender = Takagi::Network::UdpSender.instance
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def send_packet(subscriber, value)
|
|
21
|
+
# Get transport from subscriber metadata, or use instance default
|
|
22
|
+
transport = subscriber[:transport] || @transport
|
|
23
|
+
|
|
24
|
+
message = Takagi::Message::Outbound.new(
|
|
25
|
+
code: '2.05',
|
|
26
|
+
payload: value.to_s,
|
|
27
|
+
token: subscriber[:token],
|
|
28
|
+
message_id: rand(0..0xFFFF),
|
|
29
|
+
type: 1, # NON
|
|
30
|
+
transport: transport
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@sender.transmit(message, subscriber[:address], subscriber[:port])
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Takagi.logger.error "Observer Notify Error: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Takagi
|
|
4
|
+
module Observer
|
|
5
|
+
# Periodically notifies observers by re-running registered handlers.
|
|
6
|
+
class Watcher
|
|
7
|
+
def initialize(interval: 1)
|
|
8
|
+
@interval = interval
|
|
9
|
+
@running = false
|
|
10
|
+
@thread = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def start
|
|
14
|
+
return @thread if @running
|
|
15
|
+
|
|
16
|
+
@running = true
|
|
17
|
+
@thread = Thread.new do
|
|
18
|
+
while @running
|
|
19
|
+
Takagi::ObserveRegistry.subscription_paths.each do |path|
|
|
20
|
+
observable_route = Takagi::Base.router.find_observable(path)
|
|
21
|
+
next unless observable_route
|
|
22
|
+
|
|
23
|
+
handler = observable_route.block
|
|
24
|
+
current_value = handler.call(nil)
|
|
25
|
+
|
|
26
|
+
Takagi::ObserveRegistry.notify(path, current_value)
|
|
27
|
+
end
|
|
28
|
+
sleep @interval
|
|
29
|
+
end
|
|
30
|
+
@thread
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
Takagi.logger.error "Observer Watcher Error: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stop
|
|
37
|
+
@running = false
|
|
38
|
+
@thread&.wakeup if @thread&.alive?
|
|
39
|
+
@thread&.join(2) # Wait max 2 seconds
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rubygems'
|
|
4
|
+
require_relative 'hooks'
|
|
5
|
+
|
|
6
|
+
module Takagi
|
|
7
|
+
# Plugin manager: registers plugins, validates metadata/config, resolves dependencies, and emits lifecycle events.
|
|
8
|
+
class Plugin
|
|
9
|
+
PluginInfo = Struct.new(:name, :module, :enabled, :metadata, :dependencies, :requires, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
@registry = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
attr_reader :registry
|
|
16
|
+
|
|
17
|
+
# Register a plugin module that responds to .apply(app, opts = {}) and .metadata
|
|
18
|
+
def register(plugin_module)
|
|
19
|
+
metadata = safe_metadata(plugin_module)
|
|
20
|
+
raw_name = metadata[:name] || plugin_module.name&.split('::')&.last || "plugin_#{plugin_module.object_id}"
|
|
21
|
+
name = raw_name.to_sym
|
|
22
|
+
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
# Return existing info if already registered
|
|
25
|
+
return @registry[name] if @registry.key?(name)
|
|
26
|
+
|
|
27
|
+
deps = Array(metadata[:dependencies]).map { |dep| normalize_dependency(dep) }
|
|
28
|
+
requires = metadata[:requires]
|
|
29
|
+
|
|
30
|
+
info = PluginInfo.new(
|
|
31
|
+
name: name.to_sym,
|
|
32
|
+
module: plugin_module,
|
|
33
|
+
enabled: false,
|
|
34
|
+
metadata: metadata,
|
|
35
|
+
dependencies: deps,
|
|
36
|
+
requires: requires
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@registry[name.to_sym] = info
|
|
40
|
+
Takagi::Hooks.emit(:plugin_registered, name: name, metadata: metadata)
|
|
41
|
+
info
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Enable a plugin by name. Optionally pass options to apply.
|
|
46
|
+
def enable(name, app:, options: {})
|
|
47
|
+
info = @mutex.synchronize { @registry[name.to_sym] }
|
|
48
|
+
raise ArgumentError, "Plugin #{name} not registered" unless info
|
|
49
|
+
return info if info.enabled
|
|
50
|
+
|
|
51
|
+
validate_version!(info)
|
|
52
|
+
resolve_dependencies!(info, app: app)
|
|
53
|
+
|
|
54
|
+
validated_options = validate_config!(info.module, options, plugin_name: info.name)
|
|
55
|
+
|
|
56
|
+
app_for_plugin = wrap_app_with_prefix(app, info)
|
|
57
|
+
|
|
58
|
+
Takagi::Hooks.emit(:plugin_enabling, name: name, metadata: info.metadata, options: validated_options)
|
|
59
|
+
plugin = info.module
|
|
60
|
+
plugin.before_apply(app_for_plugin, validated_options) if plugin.respond_to?(:before_apply)
|
|
61
|
+
plugin.apply(app_for_plugin, validated_options)
|
|
62
|
+
plugin.after_apply(app_for_plugin, validated_options) if plugin.respond_to?(:after_apply)
|
|
63
|
+
|
|
64
|
+
info.enabled = true
|
|
65
|
+
Takagi::Hooks.emit(:plugin_enabled, name: name, metadata: info.metadata)
|
|
66
|
+
info
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Takagi::Hooks.emit(:plugin_error, name: name, metadata: info&.metadata, error: e)
|
|
69
|
+
raise
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def disable(name, app:)
|
|
73
|
+
info = @mutex.synchronize { @registry[name.to_sym] }
|
|
74
|
+
return unless info&.enabled
|
|
75
|
+
|
|
76
|
+
Takagi::Hooks.emit(:plugin_disabling, name: name, metadata: info.metadata)
|
|
77
|
+
plugin = info.module
|
|
78
|
+
plugin.before_unload(app) if plugin.respond_to?(:before_unload)
|
|
79
|
+
plugin.shutdown(app) if plugin.respond_to?(:shutdown)
|
|
80
|
+
info.enabled = false
|
|
81
|
+
Takagi::Hooks.emit(:plugin_disabled, name: name, metadata: info.metadata)
|
|
82
|
+
info
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
Takagi::Hooks.emit(:plugin_error, name: name, metadata: info&.metadata, error: e)
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def unregister(name)
|
|
89
|
+
@mutex.synchronize { @registry.delete(name.to_sym) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def list
|
|
93
|
+
@mutex.synchronize { @registry.values.map { |info| { name: info.name, enabled: info.enabled, metadata: info.metadata } } }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Auto-discover plugins under Takagi::Plugins namespace and takagi-plugin-* gems
|
|
97
|
+
def auto_discover!
|
|
98
|
+
discover_namespace_plugins
|
|
99
|
+
discover_gem_plugins
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def wrap_app_with_prefix(app, info)
|
|
105
|
+
prefix = info.metadata[:route_prefix]
|
|
106
|
+
return app unless prefix
|
|
107
|
+
|
|
108
|
+
RoutePrefixProxy.new(app, prefix)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def safe_metadata(mod)
|
|
112
|
+
mod.respond_to?(:metadata) ? (mod.metadata || {}) : {}
|
|
113
|
+
rescue StandardError
|
|
114
|
+
{}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_version!(info)
|
|
118
|
+
return unless info.requires && defined?(Takagi::VERSION)
|
|
119
|
+
|
|
120
|
+
# naive comparison; assumes semver strings
|
|
121
|
+
required = info.requires
|
|
122
|
+
current = Gem::Version.new(Takagi::VERSION) rescue nil
|
|
123
|
+
required_version = Gem::Version.new(required) rescue nil
|
|
124
|
+
return unless current && required_version
|
|
125
|
+
|
|
126
|
+
if current < required_version
|
|
127
|
+
raise ArgumentError, "Plugin #{info.name} requires Takagi #{required} but current is #{Takagi::VERSION}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def resolve_dependencies!(info, app:)
|
|
132
|
+
return if info.dependencies.nil? || info.dependencies.empty?
|
|
133
|
+
|
|
134
|
+
info.dependencies.each do |dep_name|
|
|
135
|
+
dep_key = dep_name[:name]
|
|
136
|
+
dep = @mutex.synchronize { @registry[dep_key] }
|
|
137
|
+
raise ArgumentError, "Plugin #{info.name} missing dependency #{dep_key}" unless dep
|
|
138
|
+
|
|
139
|
+
if dep_name[:version] && dep.metadata[:version]
|
|
140
|
+
requirement = Gem::Requirement.new(dep_name[:version])
|
|
141
|
+
dep_version = Gem::Version.new(dep.metadata[:version])
|
|
142
|
+
unless requirement.satisfied_by?(dep_version)
|
|
143
|
+
raise ArgumentError, "Plugin #{info.name} requires #{dep_key} #{dep_name[:version]}, found #{dep.metadata[:version]}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
enable(dep.name, app: app) unless dep.enabled
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_config!(plugin_mod, options, plugin_name:)
|
|
152
|
+
return options unless plugin_mod.respond_to?(:config_schema)
|
|
153
|
+
|
|
154
|
+
schema = plugin_mod.config_schema || {}
|
|
155
|
+
opts = symbolize_keys(options || {})
|
|
156
|
+
validated = {}
|
|
157
|
+
|
|
158
|
+
schema.each do |key, rules|
|
|
159
|
+
value = opts.key?(key) ? opts[key] : rules[:default]
|
|
160
|
+
|
|
161
|
+
if value.nil? && rules[:required]
|
|
162
|
+
raise ArgumentError, "Missing required config for #{key} in plugin #{plugin_name}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
unless value.nil?
|
|
166
|
+
expected = rules[:type]
|
|
167
|
+
validate_type!(key, value, expected, plugin_name: plugin_name) if expected
|
|
168
|
+
validate_enum!(key, value, rules[:enum], plugin_name: plugin_name) if rules[:enum]
|
|
169
|
+
validate_range!(key, value, rules[:range], plugin_name: plugin_name) if rules[:range]
|
|
170
|
+
if rules[:validate].respond_to?(:call)
|
|
171
|
+
raise ArgumentError, "Invalid value for #{key} in plugin #{plugin_name}" unless rules[:validate].call(value)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
validated[key] = value
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Pass through extra keys
|
|
179
|
+
opts.each do |k, v|
|
|
180
|
+
validated[k] = v unless validated.key?(k)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
validated
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate_type!(key, value, expected, plugin_name:)
|
|
187
|
+
ok = case expected
|
|
188
|
+
when :string then value.is_a?(String)
|
|
189
|
+
when :integer then value.is_a?(Integer)
|
|
190
|
+
when :boolean then value == true || value == false
|
|
191
|
+
when :hash then value.is_a?(Hash)
|
|
192
|
+
when :array then value.is_a?(Array)
|
|
193
|
+
else true
|
|
194
|
+
end
|
|
195
|
+
raise ArgumentError, "Invalid type for #{key} in plugin #{plugin_name}: expected #{expected}, got #{value.class}" unless ok
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_enum!(key, value, enum, plugin_name:)
|
|
199
|
+
return unless enum
|
|
200
|
+
raise ArgumentError, "Invalid value for #{key} in plugin #{plugin_name}: #{value}, expected one of #{enum.inspect}" unless enum.include?(value)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def validate_range!(key, value, range, plugin_name:)
|
|
204
|
+
return unless range && value.is_a?(Numeric)
|
|
205
|
+
raise ArgumentError, "Value for #{key} in plugin #{plugin_name} out of range #{range}" unless range.cover?(value)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def symbolize_keys(hash)
|
|
209
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def discover_namespace_plugins
|
|
213
|
+
return unless defined?(Takagi::Plugins)
|
|
214
|
+
|
|
215
|
+
Takagi::Plugins.constants.each do |const|
|
|
216
|
+
mod = Takagi::Plugins.const_get(const)
|
|
217
|
+
next unless mod.is_a?(Module)
|
|
218
|
+
next unless mod.respond_to?(:apply)
|
|
219
|
+
|
|
220
|
+
register(mod) unless @registry.key?(infer_name(mod))
|
|
221
|
+
end
|
|
222
|
+
rescue StandardError
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def discover_gem_plugins
|
|
227
|
+
specs = Gem::Specification.each.select { |s| s.name.start_with?('takagi-plugin-') }
|
|
228
|
+
specs.each do |spec|
|
|
229
|
+
begin
|
|
230
|
+
require spec.name
|
|
231
|
+
rescue LoadError => e
|
|
232
|
+
Takagi::Hooks.emit(:plugin_error, name: spec.name, metadata: nil, error: e)
|
|
233
|
+
next
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
mod = infer_module_from_gem(spec)
|
|
237
|
+
register(mod) if mod && mod.respond_to?(:apply) && !@registry.key?(infer_name(mod))
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def infer_name(mod)
|
|
242
|
+
if mod.respond_to?(:metadata) && mod.metadata && mod.metadata[:name]
|
|
243
|
+
mod.metadata[:name].to_sym
|
|
244
|
+
elsif mod.name
|
|
245
|
+
mod.name.split('::').last.downcase.to_sym
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def infer_module_from_gem(spec)
|
|
250
|
+
return unless defined?(Takagi::Plugins)
|
|
251
|
+
|
|
252
|
+
suffix = spec.name.sub(/^takagi-plugin-/, '')
|
|
253
|
+
const_name = suffix.split(/[-_]/).map(&:capitalize).join
|
|
254
|
+
Takagi::Plugins.const_get(const_name)
|
|
255
|
+
rescue NameError
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def normalize_dependency(dep)
|
|
260
|
+
return { name: dep.to_sym } unless dep.is_a?(Hash)
|
|
261
|
+
|
|
262
|
+
{
|
|
263
|
+
name: (dep[:name] || dep['name']).to_sym,
|
|
264
|
+
version: dep[:version] || dep['version']
|
|
265
|
+
}
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Wraps the app to prefix routes registered by a plugin.
|
|
272
|
+
class Takagi::Plugin::RoutePrefixProxy
|
|
273
|
+
ROUTE_METHODS = %i[get post put delete fetch observable observe].freeze
|
|
274
|
+
|
|
275
|
+
def initialize(app, prefix)
|
|
276
|
+
@app = app
|
|
277
|
+
@prefix = normalize_prefix(prefix)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
ROUTE_METHODS.each do |method_name|
|
|
281
|
+
define_method(method_name) do |path, *args, **kwargs, &block|
|
|
282
|
+
prefixed_path = prefix_path(path)
|
|
283
|
+
@app.public_send(method_name, prefixed_path, *args, **kwargs, &block)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
288
|
+
if @app.respond_to?(name)
|
|
289
|
+
@app.public_send(name, *args, **kwargs, &block)
|
|
290
|
+
else
|
|
291
|
+
super
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def respond_to_missing?(name, include_private = false)
|
|
296
|
+
@app.respond_to?(name, include_private) || super
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
private
|
|
300
|
+
|
|
301
|
+
def prefix_path(path)
|
|
302
|
+
return path unless path.is_a?(String)
|
|
303
|
+
|
|
304
|
+
"#{@prefix}#{path}".gsub(%r{//+}, '/')
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def normalize_prefix(prefix)
|
|
308
|
+
str = prefix.to_s
|
|
309
|
+
return '' if str.empty?
|
|
310
|
+
|
|
311
|
+
str.start_with?('/') ? str : "/#{str}"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Takagi
|
|
4
|
+
# Load profiles for different IoT/CoAP use cases
|
|
5
|
+
#
|
|
6
|
+
# Provides predefined configurations for common scenarios to simplify
|
|
7
|
+
# performance tuning without manual process/thread configuration.
|
|
8
|
+
#
|
|
9
|
+
# @example Using a profile
|
|
10
|
+
# class TelemetryController < Takagi::Controller
|
|
11
|
+
# configure do
|
|
12
|
+
# profile :high_throughput
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @example Profile with overrides
|
|
17
|
+
# class TelemetryController < Takagi::Controller
|
|
18
|
+
# configure do
|
|
19
|
+
# profile :high_throughput
|
|
20
|
+
# set :processes, 16 # Override default
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
module Profiles
|
|
24
|
+
# Predefined load profiles for common IoT scenarios
|
|
25
|
+
PROFILES = {
|
|
26
|
+
# For devices with minimal traffic (single worker)
|
|
27
|
+
# Use case: Status endpoints, health checks on constrained devices
|
|
28
|
+
minimal: {
|
|
29
|
+
processes: 1,
|
|
30
|
+
threads: 1,
|
|
31
|
+
description: 'Single-threaded, lowest resource usage for constrained devices'
|
|
32
|
+
}.freeze,
|
|
33
|
+
|
|
34
|
+
# For typical low-traffic device endpoints
|
|
35
|
+
# Use case: Configuration, device status, infrequent queries
|
|
36
|
+
low_traffic: {
|
|
37
|
+
processes: 1,
|
|
38
|
+
threads: 2,
|
|
39
|
+
description: 'Configuration, status endpoints with light traffic'
|
|
40
|
+
}.freeze,
|
|
41
|
+
|
|
42
|
+
# For observable endpoints with long-lived connections
|
|
43
|
+
# Use case: CoAP Observe, streaming sensor data, push notifications
|
|
44
|
+
long_lived: {
|
|
45
|
+
processes: 2,
|
|
46
|
+
threads: 8,
|
|
47
|
+
description: 'Observable resources, long-lived connections (CoAP Observe)'
|
|
48
|
+
}.freeze,
|
|
49
|
+
|
|
50
|
+
# For high-volume sensor data ingestion
|
|
51
|
+
# Use case: Telemetry, sensor data from many devices, high req/sec
|
|
52
|
+
high_throughput: {
|
|
53
|
+
processes: 8,
|
|
54
|
+
threads: 4,
|
|
55
|
+
description: 'High-volume sensor telemetry and data ingestion'
|
|
56
|
+
}.freeze,
|
|
57
|
+
|
|
58
|
+
# For firmware updates, images, large file transfers
|
|
59
|
+
# Use case: OTA updates, firmware downloads, large payloads
|
|
60
|
+
large_payloads: {
|
|
61
|
+
processes: 2,
|
|
62
|
+
threads: 2,
|
|
63
|
+
buffer_size: 10 * 1024 * 1024, # 10MB
|
|
64
|
+
description: 'Firmware updates, file transfers, large payloads'
|
|
65
|
+
}.freeze,
|
|
66
|
+
|
|
67
|
+
# Custom configuration (must specify all parameters)
|
|
68
|
+
# Use case: Fine-tuned performance for specific requirements
|
|
69
|
+
custom: {
|
|
70
|
+
processes: nil,
|
|
71
|
+
threads: nil,
|
|
72
|
+
description: 'User-defined custom configuration'
|
|
73
|
+
}.freeze
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
class << self
|
|
77
|
+
# Get a profile by name
|
|
78
|
+
#
|
|
79
|
+
# @param name [Symbol] Profile name
|
|
80
|
+
# @return [Hash, nil] Profile configuration or nil if not found
|
|
81
|
+
#
|
|
82
|
+
# @example
|
|
83
|
+
# Profiles.get(:high_throughput)
|
|
84
|
+
# # => { processes: 8, threads: 4, description: '...' }
|
|
85
|
+
def get(name)
|
|
86
|
+
PROFILES[name]&.dup # Return copy to prevent modification
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check if a profile exists
|
|
90
|
+
#
|
|
91
|
+
# @param name [Symbol] Profile name
|
|
92
|
+
# @return [Boolean] true if profile exists
|
|
93
|
+
#
|
|
94
|
+
# @example
|
|
95
|
+
# Profiles.exists?(:high_throughput) # => true
|
|
96
|
+
# Profiles.exists?(:unknown) # => false
|
|
97
|
+
def exists?(name)
|
|
98
|
+
PROFILES.key?(name)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get all available profile names
|
|
102
|
+
#
|
|
103
|
+
# @return [Array<Symbol>] List of profile names
|
|
104
|
+
#
|
|
105
|
+
# @example
|
|
106
|
+
# Profiles.available
|
|
107
|
+
# # => [:minimal, :low_traffic, :long_lived, :high_throughput, :large_payloads, :custom]
|
|
108
|
+
def available
|
|
109
|
+
PROFILES.keys
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Get human-readable summary of all profiles
|
|
113
|
+
#
|
|
114
|
+
# @return [String] Formatted summary
|
|
115
|
+
#
|
|
116
|
+
# @example
|
|
117
|
+
# puts Profiles.summary
|
|
118
|
+
def summary
|
|
119
|
+
lines = ['Available Load Profiles:']
|
|
120
|
+
PROFILES.each do |name, config|
|
|
121
|
+
lines << ''
|
|
122
|
+
lines << " #{name}:"
|
|
123
|
+
lines << " Description: #{config[:description]}"
|
|
124
|
+
lines << " Processes: #{config[:processes] || 'custom'}"
|
|
125
|
+
lines << " Threads: #{config[:threads] || 'custom'}"
|
|
126
|
+
lines << " Buffer Size: #{config[:buffer_size]}" if config[:buffer_size]
|
|
127
|
+
end
|
|
128
|
+
lines.join("\n")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Validate profile configuration
|
|
132
|
+
#
|
|
133
|
+
# @param name [Symbol] Profile name
|
|
134
|
+
# @param config [Hash] Configuration to validate
|
|
135
|
+
# @raise [ArgumentError] if profile is invalid
|
|
136
|
+
#
|
|
137
|
+
# @return [void]
|
|
138
|
+
def validate!(name, config = nil)
|
|
139
|
+
config ||= get(name)
|
|
140
|
+
|
|
141
|
+
raise ArgumentError, "Unknown profile: #{name}" unless exists?(name)
|
|
142
|
+
|
|
143
|
+
if name == :custom
|
|
144
|
+
raise ArgumentError, 'Custom profile requires :processes' unless config[:processes]
|
|
145
|
+
raise ArgumentError, 'Custom profile requires :threads' unless config[:threads]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
if config[:processes] && config[:processes] < 1
|
|
149
|
+
raise ArgumentError, 'Processes must be >= 1'
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
if config[:threads] && config[:threads] < 1
|
|
153
|
+
raise ArgumentError, 'Threads must be >= 1'
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Apply a profile to a configuration hash
|
|
158
|
+
#
|
|
159
|
+
# @param name [Symbol] Profile name
|
|
160
|
+
# @param overrides [Hash] Optional overrides
|
|
161
|
+
# @return [Hash] Merged configuration
|
|
162
|
+
#
|
|
163
|
+
# @example
|
|
164
|
+
# Profiles.apply(:high_throughput, processes: 16)
|
|
165
|
+
# # => { processes: 16, threads: 4, description: '...' }
|
|
166
|
+
def apply(name, overrides = {})
|
|
167
|
+
profile = get(name)
|
|
168
|
+
raise ArgumentError, "Unknown profile: #{name}" unless profile
|
|
169
|
+
|
|
170
|
+
merged = profile.merge(overrides)
|
|
171
|
+
validate!(name, merged)
|
|
172
|
+
merged
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|