wurk 0.0.5 → 1.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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -0
  3. data/app/controllers/wurk/api/serializers.rb +48 -2
  4. data/app/controllers/wurk/api_controller.rb +216 -1
  5. data/app/controllers/wurk/dashboard_controller.rb +20 -2
  6. data/app/controllers/wurk/extensions_controller.rb +56 -0
  7. data/app/controllers/wurk/profiles_controller.rb +68 -0
  8. data/config/routes.rb +54 -1
  9. data/exe/sidekiqswarm +8 -0
  10. data/exe/wurkswarm +23 -0
  11. data/lib/active_job/queue_adapters/wurk_adapter.rb +35 -0
  12. data/lib/generators/wurk/install/templates/wurk.rb +14 -3
  13. data/lib/sidekiq/api.rb +4 -0
  14. data/lib/sidekiq/cli.rb +9 -0
  15. data/lib/sidekiq/client.rb +4 -0
  16. data/lib/sidekiq/job.rb +4 -0
  17. data/lib/sidekiq/launcher.rb +4 -0
  18. data/lib/sidekiq/middleware/chain.rb +4 -0
  19. data/lib/sidekiq/middleware/server/statsd.rb +12 -0
  20. data/lib/sidekiq/rails.rb +10 -0
  21. data/lib/sidekiq/redis_connection.rb +4 -0
  22. data/lib/sidekiq/scheduled.rb +4 -0
  23. data/lib/sidekiq/testing.rb +4 -0
  24. data/lib/sidekiq/version.rb +4 -0
  25. data/lib/sidekiq/web.rb +4 -0
  26. data/lib/sidekiq/worker.rb +4 -0
  27. data/lib/sidekiq.rb +16 -0
  28. data/lib/wurk/batch/callbacks.rb +103 -13
  29. data/lib/wurk/batch/death_handler.rb +5 -2
  30. data/lib/wurk/batch/server_middleware.rb +35 -3
  31. data/lib/wurk/batch/status.rb +9 -0
  32. data/lib/wurk/batch.rb +23 -1
  33. data/lib/wurk/capsule.rb +20 -1
  34. data/lib/wurk/cli.rb +84 -1
  35. data/lib/wurk/client.rb +20 -17
  36. data/lib/wurk/compat.rb +44 -2
  37. data/lib/wurk/component.rb +5 -4
  38. data/lib/wurk/configuration.rb +120 -3
  39. data/lib/wurk/cron.rb +51 -9
  40. data/lib/wurk/dead_set.rb +8 -3
  41. data/lib/wurk/deploy.rb +8 -4
  42. data/lib/wurk/encryption.rb +6 -1
  43. data/lib/wurk/fetcher/reaper.rb +78 -11
  44. data/lib/wurk/fetcher/reliable.rb +14 -4
  45. data/lib/wurk/heartbeat.rb +45 -0
  46. data/lib/wurk/history.rb +174 -0
  47. data/lib/wurk/iterable_job/active_record_enumerator.rb +71 -0
  48. data/lib/wurk/iterable_job/csv_enumerator.rb +51 -0
  49. data/lib/wurk/iterable_job.rb +41 -0
  50. data/lib/wurk/iterable_job_query.rb +75 -0
  51. data/lib/wurk/job.rb +8 -0
  52. data/lib/wurk/job_record.rb +16 -1
  53. data/lib/wurk/job_set.rb +4 -4
  54. data/lib/wurk/job_util.rb +15 -6
  55. data/lib/wurk/keys.rb +10 -0
  56. data/lib/wurk/launcher.rb +35 -1
  57. data/lib/wurk/leader.rb +15 -6
  58. data/lib/wurk/limiter/bucket.rb +14 -3
  59. data/lib/wurk/limiter/concurrent.rb +1 -1
  60. data/lib/wurk/limiter/window.rb +2 -1
  61. data/lib/wurk/limiter.rb +12 -0
  62. data/lib/wurk/lua/loader.rb +10 -0
  63. data/lib/wurk/lua.rb +106 -14
  64. data/lib/wurk/metrics/history.rb +5 -0
  65. data/lib/wurk/metrics/query.rb +39 -0
  66. data/lib/wurk/metrics/queue_rollup.rb +151 -0
  67. data/lib/wurk/metrics/statsd.rb +11 -0
  68. data/lib/wurk/middleware/current_attributes.rb +29 -6
  69. data/lib/wurk/middleware/interrupt_handler.rb +5 -0
  70. data/lib/wurk/middleware/poison_pill.rb +35 -5
  71. data/lib/wurk/processor.rb +17 -8
  72. data/lib/wurk/profile_set.rb +65 -0
  73. data/lib/wurk/profiler.rb +127 -0
  74. data/lib/wurk/railtie.rb +19 -5
  75. data/lib/wurk/redis_client_adapter.rb +72 -0
  76. data/lib/wurk/redis_connection.rb +30 -0
  77. data/lib/wurk/redis_pool.rb +5 -1
  78. data/lib/wurk/scheduled.rb +42 -0
  79. data/lib/wurk/sorted_entry.rb +13 -11
  80. data/lib/wurk/stats.rb +11 -4
  81. data/lib/wurk/swarm/child_boot.rb +26 -4
  82. data/lib/wurk/swarm.rb +1 -1
  83. data/lib/wurk/transaction_aware_client.rb +69 -0
  84. data/lib/wurk/unique.rb +49 -7
  85. data/lib/wurk/version.rb +1 -1
  86. data/lib/wurk/web/batch_status.rb +42 -0
  87. data/lib/wurk/web/config.rb +219 -17
  88. data/lib/wurk/web/enterprise.rb +14 -0
  89. data/lib/wurk/web/extension.rb +348 -0
  90. data/lib/wurk/web/rack_app.rb +77 -0
  91. data/lib/wurk/web.rb +2 -0
  92. data/lib/wurk/worker/setter.rb +5 -1
  93. data/lib/wurk/worker.rb +17 -6
  94. data/lib/wurk.rb +44 -0
  95. data/vendor/assets/dashboard/assets/fa-brands-400-BP5tdqmh.woff2 +0 -0
  96. data/vendor/assets/dashboard/assets/fa-regular-400-nyy7hhHF.woff2 +0 -0
  97. data/vendor/assets/dashboard/assets/fa-solid-900-DRAAbZTg.woff2 +0 -0
  98. data/vendor/assets/dashboard/assets/index-9CFRWpfG.js +77 -0
  99. data/vendor/assets/dashboard/assets/index-CW8AFQIv.css +2 -0
  100. data/vendor/assets/dashboard/assets/wurk-logo-Vy3xW4K0.png +0 -0
  101. data/vendor/assets/dashboard/favicon.png +0 -0
  102. data/vendor/assets/dashboard/index.html +10 -3
  103. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  104. metadata +42 -3
  105. data/vendor/assets/dashboard/assets/index-D2XR0iGw.js +0 -60
  106. data/vendor/assets/dashboard/assets/index-DlPr4YXw.css +0 -1
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'forwardable'
4
+ require 'rack/builder'
5
+
3
6
  module Wurk
4
7
  class Web
5
8
  # Web UI configuration. Holds the authorization callback documented in
@@ -22,25 +25,87 @@ module Wurk
22
25
  # When no block is registered, every request is authorized (matches
23
26
  # Sidekiq's default — no auth until the user opts in).
24
27
  class Config
28
+ extend Forwardable
29
+
25
30
  # String forms that mean "off" — so `config.web.read_only = ENV[...]`
26
31
  # doesn't flip on when the env var is "0"/"false"/empty.
27
32
  FALSEY_STRINGS = ['', '0', 'false', 'no', 'off'].freeze
28
33
 
34
+ # Firefox-profiler endpoints for the Profiles pane (spec §25.2). The
35
+ # dashboard uploads a stored profile to `profile_store_url` and redirects
36
+ # the operator to `profile_view_url % <returned-hash>`. Overridable for
37
+ # self-hosted profiler instances.
38
+ PROFILE_VIEW_URL = 'https://profiler.firefox.com/public/%s'
39
+ PROFILE_STORE_URL = 'https://api.profiler.firefox.com/compressed-store'
40
+
41
+ # Hash-style settings, same surface as Sidekiq::Web::Config (#204):
42
+ # gems and apps write `Sidekiq::Web.configure { |c| c[:csrf] = false }`.
43
+ # Seeded like upstream's OPTIONS; unknown keys are stored verbatim so a
44
+ # setting wurk doesn't consume still round-trips (e.g. :csrf — wurk's
45
+ # extension POST guard is the Sec-Fetch-Site check in
46
+ # ExtensionsController, spec §25.1, not a token).
47
+ OPTIONS = {
48
+ profile_view_url: PROFILE_VIEW_URL,
49
+ profile_store_url: PROFILE_STORE_URL
50
+ }.freeze
51
+
52
+ # Sidekiq's built-in dashboard tabs (spec §25.3). The `tabs` hash starts
53
+ # as a copy of this; extensions add to it via `register_extension` or by
54
+ # mutating `tabs` directly — the same surface third-party gems use.
55
+ DEFAULT_TABS = {
56
+ 'Dashboard' => '', 'Busy' => 'busy', 'Queues' => 'queues',
57
+ 'Retries' => 'retries', 'Scheduled' => 'scheduled', 'Dead' => 'morgue',
58
+ 'Metrics' => 'metrics', 'Profiles' => 'profiles'
59
+ }.freeze
60
+
61
+ # Tab paths the SPA already renders natively (Sidekiq DEFAULT_TABS plus
62
+ # wurk's Pro/Ent extras). A gem re-registering one of these — e.g.
63
+ # sidekiq-cron's "cron" — must not produce a duplicate nav item, so
64
+ # `custom_tabs` filters them out.
65
+ NATIVE_TAB_PATHS = %w[
66
+ busy queues retries scheduled dead morgue metrics profiles
67
+ batches limiters cron search
68
+ ].freeze
69
+
29
70
  # Host-app Rack middleware stacked in front of the dashboard, newest
30
- # last. Each entry is `[middleware, args, block]`. Returns a frozen copy
31
- # so the memoized chain (`#rack_app`) can only be invalidated through
32
- # `#use` direct mutation can't silently desync it.
33
- def middlewares
34
- @middlewares.dup.freeze
35
- end
71
+ # last. Each entry is `[middleware, args, block]`. The LIVE array, like
72
+ # Sidekiq::Web::Config#middlewares callers mutate it directly
73
+ # (sidekiq-cron's tests do `c.middlewares.clear`), so `#rack_app`
74
+ # detects drift instead of this returning a frozen copy.
75
+ attr_reader :middlewares
36
76
 
37
77
  def initialize
78
+ @options = OPTIONS.dup
38
79
  @authorization = nil
39
80
  @read_only = env_read_only?
81
+ @read_only_message = nil
40
82
  @middlewares = []
41
83
  @rack_app = nil
84
+ init_extensions!
85
+ end
86
+
87
+ def_delegators :@options, :[], :[]=, :fetch, :key?, :has_key?, :merge!, :dig
88
+
89
+ # Firefox-profiler URLs — named views over the same @options keys the
90
+ # bracket surface exposes, so `c[:profile_view_url] = …` and
91
+ # `c.profile_view_url = …` can't drift apart.
92
+ def profile_view_url = @options[:profile_view_url]
93
+ def profile_store_url = @options[:profile_store_url]
94
+
95
+ def profile_view_url=(value)
96
+ @options[:profile_view_url] = value
97
+ end
98
+
99
+ def profile_store_url=(value)
100
+ @options[:profile_store_url] = value
42
101
  end
43
102
 
103
+ # Optional banner copy shown by the dashboard in read-only mode. Nil →
104
+ # the SPA falls back to its localized default ("Read-only mode"). Lets a
105
+ # host explain *why* it's read-only — e.g. the public demo sets
106
+ # "This is a public demo — actions are disabled."
107
+ attr_accessor :read_only_message
108
+
44
109
  # Registers a `(env, method, path) -> truthy/falsey` block. Re-calling
45
110
  # overwrites; the spec exposes a single hook, not a chain.
46
111
  def authorization(&block)
@@ -48,6 +113,77 @@ module Wurk
48
113
  @authorization
49
114
  end
50
115
 
116
+ # Web-extension surface (spec §25.2). Third-party gems (sidekiq-cron,
117
+ # sidekiq-unique-jobs, sidekiq-status, …) register dashboard tabs at load
118
+ # time. `tabs` is a mutable name→path hash seeded from DEFAULT_TABS;
119
+ # `custom_job_info_rows` collects callables that add rows to the job
120
+ # detail view; `app_url` / `assets_path` mirror Sidekiq's accessors.
121
+ # `locales` is the mutable locale-directory list extensions append to
122
+ # (`Sidekiq::Web.configure.locales << dir`) — the Extension renderer's
123
+ # `t()` reads en.yml from every listed dir. Wurk's own SPA i18n is
124
+ # separate, so it starts empty.
125
+ attr_reader :tabs, :extensions, :locales
126
+ attr_accessor :custom_job_info_rows, :app_url, :assets_path
127
+
128
+ # Matches Sidekiq::Web::Config#register_extension (aliased `register`,
129
+ # spec §25.2): `tab` is the label(s), `index` the path(s), `name` the
130
+ # asset namespace. `tab`/`index` are zipped into the `tabs` hash
131
+ # (label => path) so the tab surfaces in the SPA nav via /api/meta, and
132
+ # the extension's `registered(app)` routes/ERB views are served by
133
+ # Wurk::Web::Extension::Renderer under `ext/:name/*` (#187) — the SPA's
134
+ # Extension page embeds the rendered HTML. Yields self to an optional
135
+ # block for further config, and returns self.
136
+ # rubocop:disable Metrics/ParameterLists -- signature matches Sidekiq::Web::Config#register_extension (spec §25.2)
137
+ def register_extension(extension, name:, tab:, index:, root_dir: nil,
138
+ cache_for: 86_400, asset_paths: nil)
139
+ Array(tab).zip(Array(index)).each { |label, path| @tabs[label] = path if label }
140
+ # Upstream registers root_dir/locales automatically; extensions
141
+ # without a root_dir append their dir to `locales` themselves.
142
+ @locales << ::File.join(root_dir, 'locales') if root_dir
143
+ @extensions << {
144
+ extension: extension, name: name, tab: tab, index: index,
145
+ root_dir: root_dir, cache_for: cache_for, asset_paths: asset_paths
146
+ }
147
+ yield self if block_given?
148
+ self
149
+ end
150
+ # rubocop:enable Metrics/ParameterLists
151
+ alias register register_extension
152
+
153
+ # Tabs the SPA should render in the nav: everything registered beyond the
154
+ # Sidekiq defaults and wurk's own native pages, as `{ name:, path:,
155
+ # ext_name: }`. `ext_name` ties the tab back to a registered extension so
156
+ # the Extension page can fetch its server-rendered view from
157
+ # `ext/:ext_name/*`; nil for tabs added by bare `tabs[]=` mutation (the
158
+ # SPA falls back to the iframe embed for those).
159
+ def custom_tabs
160
+ @tabs.filter_map do |name, path|
161
+ # Same trailing-slash normalization as extension_for_index, so a
162
+ # native path registered as "cron/" doesn't duplicate the native tab.
163
+ next if DEFAULT_TABS.key?(name) || NATIVE_TAB_PATHS.include?(path.to_s.delete_suffix('/'))
164
+
165
+ { name: name, path: path.to_s, ext_name: extension_for_index(path)&.dig(:name)&.to_s }
166
+ end
167
+ end
168
+
169
+ # The registered extension whose `index` covers `path` ("locks/" and
170
+ # "locks" are the same index).
171
+ def extension_for_index(path)
172
+ norm = path.to_s.delete_suffix('/')
173
+ @extensions.find { |e| Array(e[:index]).any? { |i| i.to_s.delete_suffix('/') == norm } }
174
+ end
175
+
176
+ # Evaluate the registered `custom_job_info_rows` against a job (spec
177
+ # §25.2), returning `[[label, value], …]` for the SPA's job-detail modal.
178
+ # Each row is a callable (`call(job)`) or a Sidekiq-style `add_pair(job)`
179
+ # object; a row that raises or returns a non-pair is skipped so one bad
180
+ # extension can't break the job views.
181
+ def job_info_pairs(job)
182
+ return [] if @custom_job_info_rows.empty?
183
+
184
+ @custom_job_info_rows.filter_map { |row| job_info_pair(row, job) }
185
+ end
186
+
51
187
  # Sidekiq-compatible (`Sidekiq::Web.use`). Registers a Rack middleware
52
188
  # that wraps the dashboard, in front of the authorization hook, so a
53
189
  # host app can gate the UI with Devise/Warden/Sorcery/Rack::Auth::Basic
@@ -59,17 +195,21 @@ module Wurk
59
195
  @rack_app = nil
60
196
  end
61
197
 
62
- # Builds (once) the host-middleware chain wrapping `inner` and memoizes
63
- # it on this Config. `reset_config!` swaps in a fresh Config, so each
64
- # test rebuilds cleanly; production builds exactly once at boot.
198
+ # Builds the host-middleware chain wrapping `inner`, memoized against
199
+ # the middleware list it was built from `middlewares` is the live
200
+ # array (upstream surface), so direct mutation after the first request
201
+ # triggers a rebuild instead of silently serving the stale chain.
202
+ # Production builds exactly once at boot; the per-request comparison is
203
+ # an == over a handful of entries.
65
204
  def rack_app(inner)
66
- @rack_app ||= begin
67
- stack = @middlewares
68
- ::Rack::Builder.new do
69
- stack.each { |middleware, args, block| use(middleware, *args, &block) }
70
- run inner
71
- end.to_app
72
- end
205
+ return @rack_app if @rack_app && @rack_app_stack == @middlewares
206
+
207
+ @rack_app_stack = @middlewares.dup
208
+ stack = @middlewares
209
+ @rack_app = ::Rack::Builder.new do
210
+ stack.each { |middleware, args, block| use(middleware, *args, &block) }
211
+ run inner
212
+ end.to_app
73
213
  end
74
214
 
75
215
  # Read-only mode. When on, the Authorization middleware blocks every
@@ -86,10 +226,13 @@ module Wurk
86
226
  end
87
227
 
88
228
  def reset!
229
+ @options = OPTIONS.dup
89
230
  @authorization = nil
90
231
  @read_only = env_read_only?
232
+ @read_only_message = nil
91
233
  @middlewares = []
92
234
  @rack_app = nil
235
+ init_extensions!
93
236
  end
94
237
 
95
238
  # Returns true when no block is registered, otherwise the block's
@@ -107,6 +250,30 @@ module Wurk
107
250
  def env_read_only?
108
251
  ENV['WURK_WEB_READ_ONLY'] == '1'
109
252
  end
253
+
254
+ def init_extensions!
255
+ @tabs = DEFAULT_TABS.dup
256
+ @extensions = []
257
+ @locales = []
258
+ @custom_job_info_rows = []
259
+ @app_url = nil
260
+ @assets_path = nil
261
+ end
262
+
263
+ def job_info_pair(row, job)
264
+ pair = job_info_row_value(row, job)
265
+ return unless pair.is_a?(::Array) && pair.size == 2
266
+
267
+ [pair[0].to_s, pair[1].to_s]
268
+ rescue ::StandardError
269
+ nil
270
+ end
271
+
272
+ def job_info_row_value(row, job)
273
+ return row.call(job) if row.respond_to?(:call)
274
+
275
+ row.add_pair(job) if row.respond_to?(:add_pair)
276
+ end
110
277
  end
111
278
 
112
279
  class << self
@@ -114,8 +281,11 @@ module Wurk
114
281
  @config ||= Config.new
115
282
  end
116
283
 
284
+ # With a block: yields the config (the documented configure form).
285
+ # Without: returns it — upstream's blockless-getter form, which gems
286
+ # use as `Sidekiq::Web.configure.tabs` (#204).
117
287
  def configure
118
- yield config
288
+ block_given? ? yield(config) : config
119
289
  end
120
290
 
121
291
  # Class-level shorthand for `config.use` — mirrors `Sidekiq::Web.use`.
@@ -123,6 +293,38 @@ module Wurk
123
293
  config.use(...)
124
294
  end
125
295
 
296
+ # Class-level extension surface — gems call these straight off
297
+ # `Sidekiq::Web` (e.g. `Sidekiq::Web.register(Ext, name:, tab:)` or
298
+ # `Sidekiq::Web.tabs["Locks"] = "locks"`), not only inside `configure`.
299
+ def register(extension, **, &)
300
+ config.register_extension(extension, **, &)
301
+ end
302
+ alias register_extension register
303
+
304
+ def tabs
305
+ config.tabs
306
+ end
307
+
308
+ def custom_job_info_rows
309
+ config.custom_job_info_rows
310
+ end
311
+
312
+ def app_url
313
+ config.app_url
314
+ end
315
+
316
+ def app_url=(value)
317
+ config.app_url = value
318
+ end
319
+
320
+ def assets_path
321
+ config.assets_path
322
+ end
323
+
324
+ def assets_path=(value)
325
+ config.assets_path = value
326
+ end
327
+
126
328
  # Test helper — exposed for parity with `Wurk::Limiter.reset_config!`.
127
329
  # Production callers should not need to drop the auth block at runtime.
128
330
  def reset_config!
@@ -132,6 +132,20 @@ module Wurk
132
132
  def history(bucket, window:, now: ::Time.now)
133
133
  Wurk::Metrics::Query.history(bucket, window, now: now)
134
134
  end
135
+
136
+ # Per-queue size/latency gauge time-series for the Historical tab.
137
+ # `bucket` is '1m'/'5m'/'1h'; `window` is in seconds (clamped to the
138
+ # bucket's retention). `queues:` narrows to one queue (else every live
139
+ # queue). Returns `[{name:, points: [{at:, size:, latency:}, …]}, …]`.
140
+ def queue_history(bucket, window:, queues: nil, now: ::Time.now)
141
+ Wurk::Metrics::Query.queue_history(bucket, window, queues: queues, now: now)
142
+ end
143
+
144
+ # Ent §5.3 Historical snapshots from the `history:metrics` stream,
145
+ # oldest→newest. Returns `[{at:, processed:, failures:, …}, …]`.
146
+ def snapshots(limit: Wurk::History::STREAM_DEFAULT_LIMIT)
147
+ Wurk::History.recent(limit: limit)
148
+ end
135
149
  end
136
150
  end
137
151
  end
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'cgi'
5
+ require 'securerandom'
6
+
7
+ module Wurk
8
+ class Web
9
+ # Server-side renderer for third-party Web extensions (spec §25, #187).
10
+ #
11
+ # Sidekiq extensions register Sinatra-style routes + ERB views via
12
+ # `Sidekiq::Web.register(Ext, name:, tab:, index:, root_dir:, asset_paths:)`,
13
+ # where `Ext.registered(app)` calls `app.get "/path" do … end` and
14
+ # `app.helpers SomeModule`. wurk's dashboard is a React SPA with no Sinatra
15
+ # render path, so this module provides a minimal, Sidekiq-Web-compatible
16
+ # renderer: it captures the routes, runs the matched block in an `Action`
17
+ # context (a `WebHelpers` subset + the ext's own helpers + `erb`), and
18
+ # returns the rendered HTML for the SPA's Extension page to embed.
19
+ #
20
+ # It does NOT depend on the `sidekiq` gem — extensions registered through
21
+ # wurk's own `Sidekiq::Web.register` alias render unchanged. (Running a
22
+ # real third-party gem unmodified additionally needs the shim in #204.)
23
+ module Extension
24
+ # Captures the routes + helpers an extension declares in `registered`.
25
+ # Quacks like the slice of `Sidekiq::Web::Application` extensions touch.
26
+ class App
27
+ attr_reader :routes, :helper_modules, :helper_blocks
28
+
29
+ HTTP_METHODS = %i[get post put patch delete head].freeze
30
+
31
+ def initialize
32
+ @routes = [] # [[method_string, Route, block], …]
33
+ @helper_modules = []
34
+ @helper_blocks = []
35
+ end
36
+
37
+ HTTP_METHODS.each do |verb|
38
+ define_method(verb) do |path, &block|
39
+ @routes << [verb.to_s.upcase, Route.new(path), block]
40
+ end
41
+ end
42
+
43
+ # Sidekiq extensions add helpers via `app.helpers(Mod)` and/or a block.
44
+ def helpers(*mods, &block)
45
+ @helper_modules.concat(mods)
46
+ @helper_blocks << block if block
47
+ self
48
+ end
49
+
50
+ # Sinatra settings calls some extensions make — accepted no-op.
51
+ def set(*); end
52
+ def settings = self
53
+ def configure(*) = (yield self if block_given?)
54
+ end
55
+
56
+ # Compiles a Sinatra-style path ("/locks/:digest") into a matcher that
57
+ # extracts Symbol-keyed route params. A trailing "*" matches the rest.
58
+ class Route
59
+ NAMED = %r{/([^/]*):([^./:$]+)}
60
+
61
+ attr_reader :path
62
+
63
+ def initialize(path)
64
+ @path = path.to_s
65
+ @keys = []
66
+ @regex = compile(@path)
67
+ end
68
+
69
+ # Returns a `{key => value}` Hash of route params, or nil if no match.
70
+ def match(path)
71
+ m = @regex.match(path)
72
+ return nil unless m
73
+
74
+ @keys.each_with_index.to_h { |k, i| [k, m[i + 1] && CGI.unescape(m[i + 1])] }
75
+ end
76
+
77
+ private
78
+
79
+ def compile(path)
80
+ return /\A#{::Regexp.escape(path[0..-2])}.*\z/ if path.end_with?('*')
81
+
82
+ %r{\A#{escaped_pattern(path)}/?\z}
83
+ end
84
+
85
+ # Escape every literal fragment so route text like ".json" or "+"
86
+ # matches itself instead of acting as a regex metacharacter.
87
+ def escaped_pattern(path)
88
+ pattern = +''
89
+ pos = 0
90
+ path.scan(NAMED) { pos = append_param(pattern, path, pos, ::Regexp.last_match) }
91
+ pattern << ::Regexp.escape(path[pos..])
92
+ end
93
+
94
+ def append_param(pattern, path, pos, match)
95
+ @keys << match[2].to_sym
96
+ pattern << ::Regexp.escape(path[pos...match.begin(0)]) << "/#{::Regexp.escape(match[1])}([^/?#]+)"
97
+ match.end(0)
98
+ end
99
+ end
100
+
101
+ # The `WebHelpers` subset real extension views/routes call. Mixed into
102
+ # every `Action`. Anything an exotic gem needs beyond this can be added
103
+ # incrementally; the common surface (escaping, paths, time, i18n) is here.
104
+ module Helpers
105
+ def h(text) = ::CGI.escapeHTML(text.to_s)
106
+
107
+ # Truncate long text (display safety) — mirrors Sidekiq's default cap.
108
+ def truncate(text, max = 2000)
109
+ str = text.to_s
110
+ str.size > max ? "#{str[0, max]}…" : str
111
+ end
112
+
113
+ # Best-effort arg display, matching Sidekiq's `to_display`.
114
+ def to_display(arg)
115
+ str = arg.inspect
116
+ str.size > 100 ? "#{str[0, 100]}…" : str
117
+ end
118
+
119
+ # i18n: look up the extension's own locale strings if present, else the
120
+ # humanized key (so a missing translation degrades, never raises).
121
+ def t(key, options = {})
122
+ val = (@ext_strings || {}).dig(*key.to_s.split('.'))
123
+ str = val.is_a?(::String) ? val : key.to_s.tr('_', ' ').capitalize
124
+ options.each { |k, v| str = str.gsub("%{#{k}}", v.to_s) }
125
+ str
126
+ end
127
+
128
+ # Base path for this extension, trailing slash. Embedded in the
129
+ # engine, ext links must land back on the embed endpoint
130
+ # (`/wurk/ext/<name>/…`); standalone (`run Sidekiq::Web`, #204) the
131
+ # ext's routes ARE the URL space, so it's the app root — upstream's
132
+ # `"#{env['SCRIPT_NAME']}/"` semantics.
133
+ def root_path = @embed ? "#{@mount}/ext/#{@ext_name}/" : "#{@mount}/"
134
+ def current_path = @subpath.to_s.sub(%r{\A/}, '')
135
+
136
+ def asset_path(file)
137
+ @embed ? "#{@mount}/ext-assets/#{@ext_name}/#{file}" : "#{@mount}/#{@ext_name}/#{file}"
138
+ end
139
+
140
+ # GET-form embeds don't need CSRF; return an empty, benign tag.
141
+ def csrf_tag = ''
142
+ def csp_nonce = (@csp_nonce ||= ::SecureRandom.hex(8))
143
+
144
+ # `<time>` element like Sidekiq's relative_time; JS upgrades it client-side.
145
+ def relative_time(time)
146
+ t = time.is_a?(::Time) ? time : ::Time.at(time.to_f)
147
+ iso = t.utc.iso8601
148
+ %(<time class="ltr" dir="ltr" title="#{iso}" datetime="#{iso}">#{iso}</time>)
149
+ end
150
+
151
+ def redis(&) = ::Wurk.redis(&)
152
+ def product_version = ::Wurk::VERSION
153
+ def number_with_delimiter(num) = num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
154
+ end
155
+
156
+ # Per-request render context: instance-evals a matched route block, then
157
+ # renders ERB with the block's ivars + helpers in scope.
158
+ class Action
159
+ include Helpers
160
+
161
+ # Internal-redirect signal carried out of a route block via throw/catch.
162
+ Redirect = ::Struct.new(:location)
163
+
164
+ def initialize(env:, route_params:, ext:, mount:, embed: true)
165
+ @env = env
166
+ @request = ::Rack::Request.new(env)
167
+ @route_params = route_params
168
+ @ext_name = ext[:name].to_s
169
+ @root_dir = ext[:root_dir]
170
+ @ext_strings = ext[:strings]
171
+ @mount = mount.to_s
172
+ @embed = embed
173
+ @subpath = env['wurk.ext.subpath']
174
+ extend_helpers(ext[:helpers])
175
+ end
176
+
177
+ attr_reader :env, :request
178
+
179
+ def params = @params ||= symbolize(@request.params).merge(@route_params)
180
+ def url_params(key) = @request.params[key.to_s]
181
+ def route_params(key = nil) = key ? @route_params[key.to_sym] : @route_params
182
+ def session = (@env['rack.session'] ||= {})
183
+ def logger = ::Wurk.logger
184
+ def redirect(location) = throw(:wurk_ext_halt, Redirect.new(location))
185
+
186
+ # Render an ERB template. `content` is either the template String (the
187
+ # common path — the ext's own helper reads the file) or a Symbol naming
188
+ # a `*.erb` under the ext's root_dir/views.
189
+ def erb(content, _options = {})
190
+ template = content.is_a?(::Symbol) ? read_view(content) : content.to_s
191
+ ::ERB.new(template, trim_mode: '-').result(binding)
192
+ end
193
+
194
+ # Run the route block in this context, capturing redirects. Returns the
195
+ # rendered HTML String, or a Redirect.
196
+ def run(block)
197
+ catch(:wurk_ext_halt) { instance_exec(&block) }
198
+ end
199
+
200
+ private
201
+
202
+ def read_view(name)
203
+ raise ::ArgumentError, "extension #{@ext_name} has no root_dir" unless @root_dir
204
+
205
+ ::File.read(::File.join(@root_dir, 'views', "#{name}.erb"))
206
+ end
207
+
208
+ def extend_helpers(helpers)
209
+ Array(helpers && helpers[:modules]).each { |mod| extend(mod) }
210
+ Array(helpers && helpers[:blocks]).each { |blk| instance_eval(&blk) if blk }
211
+ end
212
+
213
+ def symbolize(hash) = hash.to_h.transform_keys(&:to_sym)
214
+ end
215
+
216
+ # Ties it together: finds a registered extension by name, captures its
217
+ # routes once (`registered(app)`), matches the request, and renders.
218
+ class Renderer
219
+ class << self
220
+ # @return [Array(Integer, Hash, String)] Rack-ish [status, headers,
221
+ # body] — 200 HTML, 302 redirect, or 404 — or nil when no extension
222
+ # with `name` is registered (so the engine can fall through).
223
+ # `embed: true` (the engine's ext/:name/* endpoint) rewrites links
224
+ # and redirects into the embed URL space; `embed: false` (the
225
+ # standalone `run Sidekiq::Web` Rack app, #204) leaves the
226
+ # extension's own route paths as the URL space, like upstream.
227
+ # rubocop:disable Metrics/ParameterLists -- request facts (name/verb/path/env) + URL-space (mount/embed); bundling them would just rename the list
228
+ def call(name:, method:, subpath:, env:, mount:, embed: true)
229
+ ext = registered_extension(name)
230
+ return nil unless ext
231
+
232
+ verb = method.to_s.upcase
233
+ route, block, route_params = match_route(ext, verb, subpath)
234
+ return [404, html_headers, "No #{verb} route #{subpath} in extension #{name}"] unless route
235
+
236
+ env['wurk.ext.subpath'] = subpath
237
+ render(ext, route_params, block, env, { mount: mount, embed: embed })
238
+ end
239
+ # rubocop:enable Metrics/ParameterLists
240
+
241
+ # `[absolute_path, cache_for_seconds]` of an asset under the
242
+ # extension's asset_paths, or nil if the extension/file isn't found.
243
+ # The expand_path prefix check rejects `../` traversal out of the dir.
244
+ def asset_file(name, file)
245
+ ext = registered_extension(name)
246
+ return nil unless ext
247
+
248
+ Array(ext[:asset_paths]).each do |dir|
249
+ path = ::File.expand_path(::File.join(dir, file.to_s))
250
+ next unless path.start_with?("#{::File.expand_path(dir)}/") && ::File.file?(path)
251
+
252
+ return [path, ext[:cache_for] || 86_400]
253
+ end
254
+ nil
255
+ end
256
+
257
+ private
258
+
259
+ def registered_extension(name)
260
+ ::Wurk::Web.config.extensions.find { |e| e[:name].to_s == name.to_s }
261
+ end
262
+
263
+ # `ctx` is `{ mount:, embed: }` — the URL-space the response renders
264
+ # into (engine embed vs standalone root).
265
+ def render(ext, route_params, block, env, ctx)
266
+ result = action_for(ext, route_params, env, ctx).run(block)
267
+ if result.is_a?(Action::Redirect)
268
+ [302, { 'Location' => redirect_target(result.location, ext, ctx) }, '']
269
+ else
270
+ [200, html_headers, result.to_s]
271
+ end
272
+ rescue ::StandardError => e
273
+ ::Wurk.configuration.handle_exception(e, context: 'web-extension-render')
274
+ [500, html_headers, "Extension render error: #{::CGI.escapeHTML(e.message)}"]
275
+ end
276
+
277
+ def action_for(ext, route_params, env, ctx)
278
+ app = captured_app(ext)
279
+ Action.new(
280
+ env: env, route_params: route_params, mount: ctx[:mount], embed: ctx[:embed],
281
+ ext: ext.merge(helpers: { modules: app.helper_modules, blocks: app.helper_blocks },
282
+ strings: strings_for(ext))
283
+ )
284
+ end
285
+
286
+ def match_route(ext, verb, subpath)
287
+ captured_app(ext).routes.each do |meth, route, block|
288
+ next unless meth == verb
289
+
290
+ params = route.match(subpath)
291
+ return [route, block, params] if params
292
+ end
293
+ [nil, nil, nil]
294
+ end
295
+
296
+ # Capture the ext's routes/helpers once; memoize on the config entry.
297
+ def captured_app(ext)
298
+ ext[:_app] ||= App.new.tap do |app|
299
+ ext[:extension].registered(app) if ext[:extension].respond_to?(:registered)
300
+ end
301
+ end
302
+
303
+ # An ext's redirect target is relative to its mount ("locks" → the
304
+ # embed endpoint; standalone → the app root, where root_path-built
305
+ # targets already carry the mount). Absolute URLs pass through.
306
+ def redirect_target(location, ext, ctx)
307
+ loc = location.to_s
308
+ return loc if loc.match?(%r{\A[a-z]+://}i)
309
+ return "#{ctx[:mount]}/ext/#{ext[:name]}/#{loc.sub(%r{\A/}, '')}" if ctx[:embed]
310
+
311
+ loc.start_with?('/') ? loc : "#{ctx[:mount]}/#{loc}"
312
+ end
313
+
314
+ # The ext's own root_dir/locales plus every dir appended to
315
+ # `config.locales` (the upstream protocol for extensions without a
316
+ # root_dir — sidekiq-cron does `Sidekiq::Web.configure.locales <<
317
+ # dir` inside `registered`, which `captured_app` has already run by
318
+ # the time we land here).
319
+ def strings_for(ext)
320
+ ext.fetch(:_strings) do
321
+ ext[:_strings] = locale_dirs(ext).each_with_object({}) do |dir, acc|
322
+ file = ::File.join(dir.to_s, 'en.yml')
323
+ acc.merge!(load_yaml(file)) if ::File.exist?(file)
324
+ end
325
+ end
326
+ end
327
+
328
+ def locale_dirs(ext)
329
+ dirs = []
330
+ dirs << ::File.join(ext[:root_dir], 'locales') if ext[:root_dir]
331
+ dirs.concat(::Wurk::Web.config.locales)
332
+ dirs.uniq
333
+ end
334
+
335
+ def load_yaml(file)
336
+ require 'yaml'
337
+ data = ::YAML.safe_load_file(file)
338
+ data.is_a?(::Hash) ? (data.values.first || data) : {}
339
+ rescue ::StandardError
340
+ {}
341
+ end
342
+
343
+ def html_headers = { 'Content-Type' => 'text/html; charset=utf-8' }
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end