side_bro 0.2.2

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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +50 -0
  5. data/Rakefile +10 -0
  6. data/lib/side_bro/version.rb +5 -0
  7. data/lib/side_bro/web/action.rb +115 -0
  8. data/lib/side_bro/web/application.rb +331 -0
  9. data/lib/side_bro/web/helpers.rb +176 -0
  10. data/lib/side_bro/web/router.rb +50 -0
  11. data/lib/side_bro/web.rb +96 -0
  12. data/lib/side_bro.rb +8 -0
  13. data/sig/side_bro.rbs +4 -0
  14. data/web/assets/images/.keep +0 -0
  15. data/web/assets/javascripts/application.js +62 -0
  16. data/web/assets/javascripts/base-charts.js +1 -0
  17. data/web/assets/javascripts/dashboard-charts.js +1 -0
  18. data/web/assets/javascripts/dashboard.js +262 -0
  19. data/web/assets/javascripts/metrics.js +1 -0
  20. data/web/assets/stylesheets/style.css +636 -0
  21. data/web/locales/en.yml +88 -0
  22. data/web/views/_footer.html.erb +3 -0
  23. data/web/views/_job_info.html.erb +23 -0
  24. data/web/views/_metrics_period_select.html.erb +5 -0
  25. data/web/views/_nav.html.erb +76 -0
  26. data/web/views/_paging.html.erb +11 -0
  27. data/web/views/_poll_link.html.erb +4 -0
  28. data/web/views/_summary.html.erb +44 -0
  29. data/web/views/busy.html.erb +104 -0
  30. data/web/views/dashboard.html.erb +124 -0
  31. data/web/views/dead.html.erb +31 -0
  32. data/web/views/layout.html.erb +61 -0
  33. data/web/views/metrics.html.erb +56 -0
  34. data/web/views/metrics_for_job.html.erb +67 -0
  35. data/web/views/morgue.html.erb +82 -0
  36. data/web/views/queue.html.erb +190 -0
  37. data/web/views/queues.html.erb +59 -0
  38. data/web/views/retries.html.erb +99 -0
  39. data/web/views/retry.html.erb +32 -0
  40. data/web/views/scheduled.html.erb +79 -0
  41. data/web/views/scheduled_job_info.html.erb +31 -0
  42. metadata +126 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2882e3f5f9428e6068b4a7a96b5ff253454dd60692a11eb53e1054399df0a6f4
4
+ data.tar.gz: 840234c4510a1af907e837e36ab1308419438194b5a8e205a9eb194e5292644e
5
+ SHA512:
6
+ metadata.gz: 74635e64db2372ac7013e346a50361b126e10082da20a1c199c9f476f58f84bde3c2de46e6a7ecfe8ef088eaee94bba198b735f138add225e66fbe8f56fe7d0b
7
+ data.tar.gz: f2bc7ca9cde7caed603b8ffa73abd624ca8e51c2b189145b2c8d063ee5000c8409ee31b56726d296c878a1df077d53b7521caff7d47158ab2587edda11163ea4
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.2.2] - 2026-05-21
4
+
5
+ - Fix table cell overflow on retries/morgue/queues/busy/scheduled pages — removed `white-space: pre-line` override that defeated `.args` truncation
6
+ - Add `overflow: hidden` to `tbody td` and `thead th` so fixed-layout columns never bleed content into adjacent cells
7
+ - Move column widths to CSS (`col.w-xs/sm/md/lg`) so they are inspectable and overridable; add `col-compact` padding class for narrow columns
8
+ - Shorten retries table "Next Retry" → "Next" and "Retry Count" → "#" headers; tighten those column widths
9
+ - Fix refresh button broken by CSP nonce policy — moved `onclick` to `application.js`
10
+ - Remove spurious page auto-refresh on non-dashboard pages introduced by live toggle wiring
11
+ - Hide poll interval ("· 5s") from live toggle button on all pages except the dashboard
12
+ - Template caching now only active when `RACK_ENV=production` so ERB edits are live in development
13
+
14
+ ## [0.2.1] - 2026-05-20
15
+
16
+ - Add `Content-Security-Policy` header with per-request nonce on all HTML responses
17
+ - Cache compiled ERB templates at class level to avoid re-reading files on every request
18
+ - Warn at startup when `SIDE_BRO_SESSION_SECRET` is not set
19
+ - Display flash messages (`notice`/`error`) in the layout below the topbar
20
+ - Wire up extension system: load extension locale files and mount extension routes/assets on `register_extension`
21
+ - Fix live toggle double-firing on dashboard (layout inline script removed; `application.js` now owns the button)
22
+ - Auto-refresh non-dashboard pages when live toggle is on; expose `window.SideBroLive` API for dashboard sync
23
+ - Queue job filter now uses substring match instead of exact match for both class name and args
24
+ - Redis memory usage bar now shows actual used/peak percentage instead of hardcoded 30%
25
+
26
+ ## [0.2.0] - 2026-05-20
27
+
28
+ - Retries, morgue, scheduled, busy, and queue detail pages
29
+ - Sticky table headers and scrollable job tables
30
+ - Compact sidebar layout with brand mark
31
+ - Standardised `format_args_short` helper across all job list views
32
+ - Throughput chart tooltip smart positioning; dynamic time-window label
33
+ - Dashboard history chart with 1 week / 1 month / 3 month / 6 month range tabs
34
+
35
+ ## [0.1.0] - 2026-05-09
36
+
37
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Cremz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # SideBro
2
+
3
+ A Rack-mountable Sidekiq Web UI alternative with a customizable design.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```gem "side_bro"```
10
+
11
+ ## Mounting
12
+
13
+ ### Rails
14
+
15
+ ```ruby
16
+ # config/routes.rb
17
+ require "side_bro"
18
+ mount SideBro::Web, at: "/side_bro"
19
+ ```
20
+
21
+ ### Rack (`config.ru`)
22
+
23
+ ```ruby
24
+ require "side_bro"
25
+ run SideBro::Web
26
+ ```
27
+
28
+ ## Authentication
29
+
30
+ SideBro has no built-in authentication. Wrap it with any Rack middleware:
31
+
32
+ ### HTTP Basic Auth
33
+
34
+ ```ruby
35
+ SideBro::Web.use Rack::Auth::Basic, "SideBro" do |user, password|
36
+ [user, password] == ["admin", ENV["SIDE_BRO_PASSWORD"]]
37
+ end
38
+ ```
39
+
40
+ ### Devise (Rails)
41
+
42
+ ```ruby
43
+ authenticate :user, ->(u) { u.admin? } do
44
+ mount SideBro::Web, at: "/side_bro"
45
+ end
46
+ ```
47
+
48
+ ## Session Secret
49
+
50
+ Set `SIDE_BRO_SESSION_SECRET` env var for a stable session secret across restarts.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[spec standard]
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SideBro
4
+ VERSION = "0.2.2"
5
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "securerandom"
5
+
6
+ module SideBro
7
+ class Web
8
+ class Action
9
+ include SideBro::WebHelpers
10
+
11
+ VIEWS_PATH = File.expand_path("../../../web/views", __dir__)
12
+ TEMPLATE_CACHE = {}
13
+
14
+ attr_reader :env, :request, :response, :nonce
15
+
16
+ def action
17
+ self
18
+ end
19
+
20
+ def initialize(env)
21
+ @env = env
22
+ @request = Rack::Request.new(env)
23
+ @response = Rack::Response.new
24
+ @response["Content-Type"] = "text/html; charset=utf-8"
25
+ end
26
+
27
+ def params
28
+ @params ||= begin
29
+ route_params = env[SideBro::Web::Router::ROUTE_PARAMS] || {}
30
+ request.params.merge(route_params)
31
+ end
32
+ end
33
+
34
+ def session
35
+ request.session
36
+ end
37
+
38
+ def flash
39
+ @flash ||= FlashHash.new(session)
40
+ end
41
+
42
+ def redirect(location)
43
+ response.redirect(location)
44
+ throw :halt, response.finish
45
+ end
46
+
47
+ def halt(*resp)
48
+ res = if resp.length == 1 && resp.first.is_a?(Integer)
49
+ Rack::Response.new([], resp.first)
50
+ else
51
+ resp.first
52
+ end
53
+ throw :halt, res.is_a?(Array) ? res : res.finish
54
+ end
55
+
56
+ def json(payload)
57
+ response["Content-Type"] = "application/json; charset=utf-8"
58
+ response.body = [JSON.generate(payload)]
59
+ throw :halt, response.finish
60
+ end
61
+
62
+ def erb(template_name)
63
+ @nonce = SecureRandom.base64(16)
64
+ env["side_bro.csp_nonce"] = @nonce
65
+ response["Content-Security-Policy"] =
66
+ "default-src 'self'; " \
67
+ "script-src 'nonce-#{@nonce}'; " \
68
+ "style-src 'nonce-#{@nonce}' https://fonts.googleapis.com; " \
69
+ "font-src 'self' https://fonts.gstatic.com; " \
70
+ "img-src 'self' data:; " \
71
+ "connect-src 'self'"
72
+ layout = load_template(:layout)
73
+ content = render_template(template_name)
74
+ response.body = [layout.result_with_hash(content: content, nonce: @nonce, action: self)]
75
+ response.finish
76
+ end
77
+
78
+ def render_partial(name)
79
+ load_template(:"_#{name}").result(binding)
80
+ end
81
+
82
+ private
83
+
84
+ def render_template(name)
85
+ load_template(name).result(binding)
86
+ end
87
+
88
+ def load_template(name)
89
+ tpl = ERB.new(File.read(File.join(VIEWS_PATH, "#{name}.html.erb")), trim_mode: "-")
90
+ return tpl unless ENV["RACK_ENV"] == "production"
91
+ TEMPLATE_CACHE[name] ||= tpl
92
+ end
93
+ end
94
+
95
+ class FlashHash
96
+ def initialize(session)
97
+ @session = session
98
+ @session[:flash] ||= {}
99
+ @read = {}
100
+ end
101
+
102
+ def [](key)
103
+ @read[key] ||= @session[:flash].delete(key.to_s)
104
+ end
105
+
106
+ def []=(key, value)
107
+ @session[:flash][key.to_s] = value
108
+ end
109
+
110
+ def any?
111
+ @session[:flash].any?
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+
5
+ module SideBro
6
+ class Web
7
+ class Application
8
+ SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
9
+
10
+ def initialize
11
+ @router = SideBro::Web::Router.new
12
+ register_routes
13
+ register_extension_routes
14
+ end
15
+
16
+ def call(env)
17
+ unless SAFE_METHODS.include?(env["REQUEST_METHOD"])
18
+ unless env["HTTP_SEC_FETCH_SITE"] == "same-origin"
19
+ return [403, {"Content-Type" => "text/plain"}, ["Forbidden"]]
20
+ end
21
+ end
22
+
23
+ block = @router.match(env)
24
+ return [404, {"Content-Type" => "text/plain"}, ["Not Found"]] unless block
25
+
26
+ action = SideBro::Web::Action.new(env)
27
+ catch(:halt) do
28
+ action.instance_exec(&block)
29
+ action.response.finish
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def register_routes
36
+ @router.get("/assets/*") do
37
+ asset_path = File.expand_path(File.join(SideBro::Web::ASSETS_PATH, params["splat"].to_s))
38
+ unless asset_path.start_with?(SideBro::Web::ASSETS_PATH) && File.file?(asset_path)
39
+ halt(404)
40
+ end
41
+ content_type = case File.extname(asset_path)
42
+ when ".css" then "text/css"
43
+ when ".js" then "application/javascript"
44
+ when ".png" then "image/png"
45
+ when ".svg" then "image/svg+xml"
46
+ else "application/octet-stream"
47
+ end
48
+ response["Content-Type"] = content_type
49
+ response["Cache-Control"] = "private, max-age=86400"
50
+ response.body = [File.binread(asset_path)]
51
+ response.finish
52
+ end
53
+
54
+ @router.head("/") do
55
+ Sidekiq.redis { |c| c.ping }
56
+ response["Content-Type"] = "text/plain"
57
+ response.body = [""]
58
+ response.finish
59
+ rescue => e
60
+ throw :halt, [500, {"Content-Type" => "text/plain"}, [e.message]]
61
+ end
62
+
63
+ @router.get("/") do
64
+ @stats = Sidekiq::Stats.new
65
+ days = (params["days"] || 30).to_i.clamp(1, 180)
66
+ @history = Sidekiq::Stats::History.new(days)
67
+ erb :dashboard
68
+ end
69
+
70
+ @router.get("/stats") do
71
+ stats = Sidekiq::Stats.new
72
+ redis_info = begin
73
+ Sidekiq.redis { |c| c.info }
74
+ rescue
75
+ {}
76
+ end
77
+ json({
78
+ processed: stats.processed,
79
+ failed: stats.failed,
80
+ busy: stats.workers_size,
81
+ enqueued: stats.enqueued,
82
+ retries: stats.retry_size,
83
+ scheduled: stats.scheduled_size,
84
+ dead: stats.dead_size,
85
+ sidekiq: Sidekiq::VERSION,
86
+ redis: redis_info
87
+ })
88
+ end
89
+
90
+ @router.get("/stats/queues") do
91
+ queues = Sidekiq::Queue.all.each_with_object({}) do |q, h|
92
+ h[q.name] = q.size
93
+ end
94
+ json(queues)
95
+ end
96
+
97
+ @router.get("/busy") do
98
+ @processes = Sidekiq::ProcessSet.new.to_a
99
+ @workers = Sidekiq::WorkSet.new.to_a
100
+ erb :busy
101
+ end
102
+
103
+ @router.post("/busy") do
104
+ if params["quiet"]
105
+ Sidekiq::ProcessSet.new.each(&:quiet!)
106
+ elsif params["stop"]
107
+ Sidekiq::ProcessSet.new.each(&:stop!)
108
+ elsif (identity = params["identity"])
109
+ process = Sidekiq::ProcessSet.new.find { |p| p.identity == identity }
110
+ process&.quiet! if params["quiet_process"]
111
+ process&.stop! if params["stop_process"]
112
+ end
113
+ redirect "#{root_path}busy"
114
+ end
115
+
116
+ @router.get("/queues") do
117
+ @queues = Sidekiq::Queue.all
118
+ erb :queues
119
+ end
120
+
121
+ @router.get("/queues/:name") do
122
+ validate_queue_name!(params["name"])
123
+ @queue = Sidekiq::Queue.new(params["name"])
124
+ @page, @per_page = current_page
125
+ @asc = params["direction"] != "desc"
126
+ @filter_job = params["filter_job"].to_s.strip
127
+ @filter_args = params["filter_args"].to_s.strip
128
+ @filtering = !@filter_job.empty? || !@filter_args.empty?
129
+
130
+ if @filtering
131
+ @jobs = @queue.lazy.select { |j|
132
+ (@filter_job.empty? || job_display_class(j).include?(@filter_job)) &&
133
+ (@filter_args.empty? || display_args(j).include?(@filter_args))
134
+ }.first(@per_page)
135
+ @total = nil
136
+ else
137
+ @total = @queue.size
138
+ if @asc
139
+ @jobs = @queue.lazy.drop(@page * @per_page).first(@per_page)
140
+ else
141
+ all = @queue.map { |j| j }
142
+ all.reverse!
143
+ @jobs = all.slice(@page * @per_page, @per_page) || []
144
+ end
145
+ end
146
+ erb :queue
147
+ end
148
+
149
+ @router.post("/queues/:name") do
150
+ validate_queue_name!(params["name"])
151
+ q = Sidekiq::Queue.new(params["name"])
152
+ if params["pause"]
153
+ q.pause! if q.respond_to?(:pause!)
154
+ elsif params["unpause"]
155
+ q.unpause! if q.respond_to?(:unpause!)
156
+ elsif params["clear"]
157
+ q.clear
158
+ end
159
+ redirect "#{root_path}queues"
160
+ end
161
+
162
+ @router.post("/queues/:name/delete_filtered") do
163
+ validate_queue_name!(params["name"])
164
+ filter_job = params["filter_job"].to_s.strip
165
+ filter_args = params["filter_args"].to_s.strip
166
+ q = Sidekiq::Queue.new(params["name"])
167
+ to_delete = q.select { |j|
168
+ (filter_job.empty? || job_display_class(j) == filter_job) &&
169
+ (filter_args.empty? || display_args(j) == filter_args)
170
+ }
171
+ to_delete.each(&:delete)
172
+ redirect "#{root_path}queues/#{params["name"]}"
173
+ end
174
+
175
+ @router.post("/queues/:name/delete") do
176
+ validate_queue_name!(params["name"])
177
+ q = Sidekiq::Queue.new(params["name"])
178
+ Array(params["key_val"]).each do |jid|
179
+ job = q.find { |j| j.jid == jid }
180
+ job&.delete
181
+ end
182
+ redirect "#{root_path}queues/#{params["name"]}"
183
+ end
184
+
185
+ # Retries
186
+ @router.get("/retries") do
187
+ @total = Sidekiq::RetrySet.new.size
188
+ @page, @per_page = current_page
189
+ @jobs = Sidekiq::RetrySet.new.map { |j| j }.slice(@page * @per_page, @per_page) || []
190
+ erb :retries
191
+ end
192
+
193
+ @router.post("/retries/all/:op") do
194
+ case params["op"]
195
+ when "retry" then Sidekiq::RetrySet.new.retry_all
196
+ when "delete" then Sidekiq::RetrySet.new.clear
197
+ when "kill" then Sidekiq::RetrySet.new.kill_all
198
+ end
199
+ redirect "#{root_path}retries"
200
+ end
201
+
202
+ @router.get("/retries/:key") do
203
+ @job = Sidekiq::RetrySet.new.find { |j| j.jid == params["key"] }
204
+ halt(404) unless @job
205
+ erb :retry
206
+ end
207
+
208
+ @router.post("/retries") do
209
+ handle_job_action(Sidekiq::RetrySet.new)
210
+ redirect "#{root_path}retries"
211
+ end
212
+
213
+ # Morgue (Dead)
214
+ @router.get("/morgue") do
215
+ @total = Sidekiq::DeadSet.new.size
216
+ @page, @per_page = current_page
217
+ @jobs = Sidekiq::DeadSet.new.map { |j| j }.slice(@page * @per_page, @per_page) || []
218
+ erb :morgue
219
+ end
220
+
221
+ @router.post("/morgue/all/:op") do
222
+ case params["op"]
223
+ when "retry" then Sidekiq::DeadSet.new.retry_all
224
+ when "delete" then Sidekiq::DeadSet.new.clear
225
+ end
226
+ redirect "#{root_path}morgue"
227
+ end
228
+
229
+ @router.get("/morgue/:key") do
230
+ @job = Sidekiq::DeadSet.new.find { |j| j.jid == params["key"] }
231
+ halt(404) unless @job
232
+ erb :dead
233
+ end
234
+
235
+ @router.post("/morgue") do
236
+ handle_job_action(Sidekiq::DeadSet.new)
237
+ redirect "#{root_path}morgue"
238
+ end
239
+
240
+ # Scheduled
241
+ @router.get("/scheduled") do
242
+ @total = Sidekiq::ScheduledSet.new.size
243
+ @page, @per_page = current_page
244
+ @jobs = Sidekiq::ScheduledSet.new.map { |j| j }.slice(@page * @per_page, @per_page) || []
245
+ erb :scheduled
246
+ end
247
+
248
+ @router.post("/scheduled/all/:op") do
249
+ case params["op"]
250
+ when "delete" then Sidekiq::ScheduledSet.new.clear
251
+ when "add_to_queue" then Sidekiq::ScheduledSet.new.each(&:add_to_queue)
252
+ end
253
+ redirect "#{root_path}scheduled"
254
+ end
255
+
256
+ @router.get("/scheduled/:key") do
257
+ @job = Sidekiq::ScheduledSet.new.find { |j| j.jid == params["key"] }
258
+ halt(404) unless @job
259
+ erb :scheduled_job_info
260
+ end
261
+
262
+ @router.post("/scheduled") do
263
+ handle_job_action(Sidekiq::ScheduledSet.new)
264
+ redirect "#{root_path}scheduled"
265
+ end
266
+
267
+ # Metrics (Sidekiq 7+ only)
268
+ @router.get("/metrics") do
269
+ halt(404) unless metrics_enabled?
270
+ require "sidekiq/metrics/query"
271
+ @period = params["period"] || "1h"
272
+ hours = {"1h" => 1, "8h" => 8, "24h" => 24, "72h" => 72}[@period]
273
+ @metrics = Sidekiq::Metrics::Query.new.top_jobs(hours: hours)
274
+ erb :metrics
275
+ end
276
+
277
+ @router.get("/metrics/:name") do
278
+ halt(404) unless metrics_enabled?
279
+ require "sidekiq/metrics/query"
280
+ @period = params["period"] || "1h"
281
+ hours = {"1h" => 1, "8h" => 8, "24h" => 24, "72h" => 72}[@period]
282
+ @name = params["name"]
283
+ @metrics = Sidekiq::Metrics::Query.new.for_job(@name, hours: hours)
284
+ erb :metrics_for_job
285
+ end
286
+
287
+ @router.post("/change_locale") do
288
+ new_locale = params["locale"].to_s.strip
289
+ session[:locale] = new_locale if SideBro::Web.translations.key?(new_locale)
290
+ redirect(request.env["HTTP_REFERER"] || root_path.to_s)
291
+ end
292
+ end
293
+
294
+ def register_extension_routes
295
+ SideBro::Web.extensions.each do |ext|
296
+ ext_name = ext[:name]
297
+ ext_class = ext[:class]
298
+ root_dir = ext[:root_dir]
299
+ cache_for = ext[:cache_for] || 86400
300
+
301
+ if root_dir
302
+ assets_base = File.expand_path("assets", root_dir)
303
+ @router.get("/#{ext_name}/assets/*") do
304
+ asset_path = File.expand_path(File.join(assets_base, params["splat"].to_s))
305
+ unless asset_path.start_with?(assets_base) && File.file?(asset_path)
306
+ halt(404)
307
+ end
308
+ content_type = case File.extname(asset_path)
309
+ when ".css" then "text/css"
310
+ when ".js" then "application/javascript"
311
+ when ".png" then "image/png"
312
+ when ".svg" then "image/svg+xml"
313
+ else "application/octet-stream"
314
+ end
315
+ response["Content-Type"] = content_type
316
+ response["Cache-Control"] = "private, max-age=#{cache_for}"
317
+ response.body = [File.binread(asset_path)]
318
+ response.finish
319
+ end
320
+ end
321
+
322
+ next unless ext_class.respond_to?(:call)
323
+ ["/#{ext_name}", "/#{ext_name}/*"].each do |path|
324
+ @router.get(path) { throw :halt, ext_class.call(env) }
325
+ @router.post(path) { throw :halt, ext_class.call(env) }
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end