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.
Files changed (197) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +70 -7
  3. data/.yard/templates/default/layout/html/layout.erb +34 -0
  4. data/AGENTS.md +16 -0
  5. data/CHANGELOG.md +158 -1
  6. data/CODE_OF_CONDUCT.md +1 -1
  7. data/README.md +590 -23
  8. data/ROADMAP.md +55 -0
  9. data/Rakefile +4 -4
  10. data/Steepfile +39 -0
  11. data/bin/takagi-dev +159 -0
  12. data/docs/FIRST_PLUGIN_GUIDE.md +224 -0
  13. data/docs/HOOKS.md +31 -0
  14. data/examples/client_lifecycle_example.rb +118 -0
  15. data/examples/cloud_gateway_app.rb +217 -0
  16. data/examples/nested_api_app.rb +258 -0
  17. data/examples/simple_device_app.rb +71 -0
  18. data/examples/takagi.yml +138 -0
  19. data/lib/takagi/application.rb +256 -0
  20. data/lib/takagi/base/middleware_management.rb +39 -0
  21. data/lib/takagi/base/plugin_management.rb +75 -0
  22. data/lib/takagi/base/reactor_management.rb +104 -0
  23. data/lib/takagi/base/server_lifecycle.rb +156 -0
  24. data/lib/takagi/base.rb +103 -11
  25. data/lib/takagi/branding.rb +88 -0
  26. data/lib/takagi/cbor/decoder.rb +385 -0
  27. data/lib/takagi/cbor/encoder.rb +260 -0
  28. data/lib/takagi/cbor/error.rb +17 -0
  29. data/lib/takagi/cbor/version.rb +9 -0
  30. data/lib/takagi/client/response.rb +236 -0
  31. data/lib/takagi/client.rb +265 -0
  32. data/lib/takagi/client_base.rb +204 -0
  33. data/lib/takagi/coap/code_helpers.rb +190 -0
  34. data/lib/takagi/coap/registries/base.rb +165 -0
  35. data/lib/takagi/coap/registries/content_format.rb +71 -0
  36. data/lib/takagi/coap/registries/message_type.rb +69 -0
  37. data/lib/takagi/coap/registries/method.rb +38 -0
  38. data/lib/takagi/coap/registries/option.rb +71 -0
  39. data/lib/takagi/coap/registries/response.rb +93 -0
  40. data/lib/takagi/coap/registries/signaling.rb +34 -0
  41. data/lib/takagi/coap/signaling.rb +10 -0
  42. data/lib/takagi/coap.rb +37 -0
  43. data/lib/takagi/composite_router.rb +186 -0
  44. data/lib/takagi/config.rb +337 -0
  45. data/lib/takagi/controller/resource_allocator.rb +164 -0
  46. data/lib/takagi/controller/thread_pool.rb +144 -0
  47. data/lib/takagi/controller.rb +319 -0
  48. data/lib/takagi/core/attribute_set.rb +128 -0
  49. data/lib/takagi/discovery/core_link_format.rb +137 -0
  50. data/lib/takagi/errors.rb +536 -0
  51. data/lib/takagi/event_bus/address_prefix.rb +142 -0
  52. data/lib/takagi/event_bus/async_executor.rb +235 -0
  53. data/lib/takagi/event_bus/coap_bridge.rb +208 -0
  54. data/lib/takagi/event_bus/future.rb +153 -0
  55. data/lib/takagi/event_bus/lru_cache.rb +157 -0
  56. data/lib/takagi/event_bus/message_buffer.rb +237 -0
  57. data/lib/takagi/event_bus/observer_cleanup.rb +110 -0
  58. data/lib/takagi/event_bus/scope.rb +74 -0
  59. data/lib/takagi/event_bus.rb +594 -0
  60. data/lib/takagi/helpers.rb +88 -0
  61. data/lib/takagi/hooks.rb +82 -0
  62. data/lib/takagi/initializer.rb +18 -0
  63. data/lib/takagi/logger.rb +15 -6
  64. data/lib/takagi/message/base.rb +155 -0
  65. data/lib/takagi/message/deduplication_cache.rb +84 -0
  66. data/lib/takagi/message/inbound.rb +147 -0
  67. data/lib/takagi/message/outbound.rb +223 -0
  68. data/lib/takagi/message/request.rb +158 -0
  69. data/lib/takagi/message/retransmission_manager.rb +193 -0
  70. data/lib/takagi/middleware/authentication.rb +19 -0
  71. data/lib/takagi/middleware/caching.rb +23 -0
  72. data/lib/takagi/middleware/debugging.rb +16 -0
  73. data/lib/takagi/middleware/logging.rb +14 -0
  74. data/lib/takagi/middleware/metrics.rb +440 -0
  75. data/lib/takagi/middleware/rate_limiting.rb +24 -0
  76. data/lib/takagi/middleware_stack.rb +166 -0
  77. data/lib/takagi/network/base.rb +76 -0
  78. data/lib/takagi/network/framing/tcp.rb +222 -0
  79. data/lib/takagi/network/framing/udp.rb +110 -0
  80. data/lib/takagi/network/registry.rb +72 -0
  81. data/lib/takagi/network/tcp.rb +60 -0
  82. data/lib/takagi/network/tcp_sender.rb +21 -0
  83. data/lib/takagi/network/udp.rb +61 -0
  84. data/lib/takagi/network/udp_sender.rb +20 -0
  85. data/lib/takagi/observable/emitter.rb +62 -0
  86. data/lib/takagi/observable/reactor.rb +488 -0
  87. data/lib/takagi/observable/registry.rb +122 -0
  88. data/lib/takagi/observe_registry.rb +10 -0
  89. data/lib/takagi/observer/client.rb +68 -0
  90. data/lib/takagi/observer/registry.rb +137 -0
  91. data/lib/takagi/observer/sender.rb +39 -0
  92. data/lib/takagi/observer/watcher.rb +43 -0
  93. data/lib/takagi/plugin.rb +313 -0
  94. data/lib/takagi/profiles.rb +176 -0
  95. data/lib/takagi/reactor.rb +23 -0
  96. data/lib/takagi/reactor_registry.rb +64 -0
  97. data/lib/takagi/registry/base.rb +268 -0
  98. data/lib/takagi/response_builder.rb +141 -0
  99. data/lib/takagi/router/metadata_extractor.rb +133 -0
  100. data/lib/takagi/router/route_matcher.rb +83 -0
  101. data/lib/takagi/router.rb +284 -25
  102. data/lib/takagi/serialization/base.rb +102 -0
  103. data/lib/takagi/serialization/cbor_serializer.rb +92 -0
  104. data/lib/takagi/serialization/json_serializer.rb +96 -0
  105. data/lib/takagi/serialization/octet_stream_serializer.rb +82 -0
  106. data/lib/takagi/serialization/registry.rb +187 -0
  107. data/lib/takagi/serialization/text_serializer.rb +87 -0
  108. data/lib/takagi/serialization.rb +117 -0
  109. data/lib/takagi/server/multi.rb +41 -0
  110. data/lib/takagi/server/registry.rb +71 -0
  111. data/lib/takagi/server/tcp.rb +249 -0
  112. data/lib/takagi/server/udp.rb +139 -0
  113. data/lib/takagi/server/udp_worker.rb +174 -0
  114. data/lib/takagi/server.rb +1 -31
  115. data/lib/takagi/server_registry.rb +10 -0
  116. data/lib/takagi/tcp_client.rb +142 -0
  117. data/lib/takagi/version.rb +2 -1
  118. data/lib/takagi.rb +24 -3
  119. data/sig/takagi/application.rbs +48 -0
  120. data/sig/takagi/base/middleware_management.rbs +33 -0
  121. data/sig/takagi/base/reactor_management.rbs +52 -0
  122. data/sig/takagi/base/server_lifecycle.rbs +54 -0
  123. data/sig/takagi/base.rbs +48 -0
  124. data/sig/takagi/cbor/decoder.rbs +171 -0
  125. data/sig/takagi/cbor/encoder.rbs +146 -0
  126. data/sig/takagi/cbor/error.rbs +19 -0
  127. data/sig/takagi/cbor/version.rbs +7 -0
  128. data/sig/takagi/client/response.rbs +148 -0
  129. data/sig/takagi/client.rbs +119 -0
  130. data/sig/takagi/client_base.rbs +135 -0
  131. data/sig/takagi/coap/code_helpers.rbs +91 -0
  132. data/sig/takagi/coap/registries/base.rbs +95 -0
  133. data/sig/takagi/coap/registries/content_format.rbs +47 -0
  134. data/sig/takagi/coap/registries/message_type.rbs +53 -0
  135. data/sig/takagi/coap/registries/method.rbs +27 -0
  136. data/sig/takagi/coap/registries/option.rbs +43 -0
  137. data/sig/takagi/coap/registries/response.rbs +52 -0
  138. data/sig/takagi/coap.rbs +24 -0
  139. data/sig/takagi/composite_router.rbs +46 -0
  140. data/sig/takagi/config.rbs +134 -0
  141. data/sig/takagi/controller.rbs +73 -0
  142. data/sig/takagi/core/attribute_set.rbs +57 -0
  143. data/sig/takagi/discovery/core_link_format.rbs +50 -0
  144. data/sig/takagi/event_bus/address_prefix.rbs +78 -0
  145. data/sig/takagi/event_bus/async_executor.rbs +88 -0
  146. data/sig/takagi/event_bus/coap_bridge.rbs +93 -0
  147. data/sig/takagi/event_bus/future.rbs +78 -0
  148. data/sig/takagi/event_bus/lru_cache.rbs +86 -0
  149. data/sig/takagi/event_bus/message_buffer.rbs +133 -0
  150. data/sig/takagi/event_bus/observer_cleanup.rbs +62 -0
  151. data/sig/takagi/event_bus.rbs +320 -0
  152. data/sig/takagi/helpers.rbs +34 -0
  153. data/sig/takagi/initializer.rbs +9 -0
  154. data/sig/takagi/logger.rbs +17 -0
  155. data/sig/takagi/message/base.rbs +64 -0
  156. data/sig/takagi/message/deduplication_cache.rbs +49 -0
  157. data/sig/takagi/message/inbound.rbs +76 -0
  158. data/sig/takagi/message/outbound.rbs +48 -0
  159. data/sig/takagi/message/request.rbs +32 -0
  160. data/sig/takagi/message/retransmission_manager.rbs +76 -0
  161. data/sig/takagi/middleware/authentication.rbs +11 -0
  162. data/sig/takagi/middleware/caching.rbs +13 -0
  163. data/sig/takagi/middleware/debugging.rbs +9 -0
  164. data/sig/takagi/middleware/logging.rbs +7 -0
  165. data/sig/takagi/middleware/metrics.rbs +15 -0
  166. data/sig/takagi/middleware/rate_limiting.rbs +13 -0
  167. data/sig/takagi/middleware_stack.rbs +69 -0
  168. data/sig/takagi/network/tcp_sender.rbs +10 -0
  169. data/sig/takagi/network/udp_sender.rbs +14 -0
  170. data/sig/takagi/observe_registry.rbs +36 -0
  171. data/sig/takagi/observer/client.rbs +36 -0
  172. data/sig/takagi/observer/sender.rbs +12 -0
  173. data/sig/takagi/observer/watcher.rbs +18 -0
  174. data/sig/takagi/profiles.rbs +33 -0
  175. data/sig/takagi/reactor.rbs +20 -0
  176. data/sig/takagi/reactor_registry.rbs +14 -0
  177. data/sig/takagi/response_builder.rbs +12 -0
  178. data/sig/takagi/router/metadata_extractor.rbs +71 -0
  179. data/sig/takagi/router/route_matcher.rbs +43 -0
  180. data/sig/takagi/router.rbs +166 -0
  181. data/sig/takagi/serialization.rbs +32 -0
  182. data/sig/takagi/server/multi.rbs +16 -0
  183. data/sig/takagi/server/tcp.rbs +42 -0
  184. data/sig/takagi/server/udp.rbs +52 -0
  185. data/sig/takagi/server/udp_worker.rbs +42 -0
  186. data/sig/takagi/server.rbs +4 -0
  187. data/sig/takagi/server_registry.rbs +71 -0
  188. data/sig/takagi/tcp_client.rbs +23 -0
  189. data/sig/takagi/version.rbs +5 -0
  190. data/takagi.gemspec +37 -35
  191. metadata +204 -31
  192. data/.idea/.gitignore +0 -8
  193. data/.idea/misc.xml +0 -4
  194. data/.idea/modules.xml +0 -8
  195. data/.idea/takagi.iml +0 -81
  196. data/.idea/vcs.xml +0 -6
  197. 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