async-background 0.7.2 → 1.0.1
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/CHANGELOG.md +348 -198
- data/README.md +91 -103
- data/async-background.gemspec +3 -1
- data/lib/async/background/metrics.rb +3 -1
- data/lib/async/background/queue/schema.rb +6 -1
- data/lib/async/background/queue/sql.rb +15 -4
- data/lib/async/background/runner/schedule.rb +2 -0
- data/lib/async/background/runner.rb +17 -2
- data/lib/async/background/version.rb +1 -1
- data/lib/async/background/web/app.rb +159 -0
- data/lib/async/background/web/assets.rb +726 -0
- data/lib/async/background/web/auth.rb +24 -0
- data/lib/async/background/web/configuration.rb +172 -0
- data/lib/async/background/web/cursor.rb +58 -0
- data/lib/async/background/web/errors.rb +14 -0
- data/lib/async/background/web/event_hub.rb +71 -0
- data/lib/async/background/web/metrics_reader.rb +96 -0
- data/lib/async/background/web/request.rb +36 -0
- data/lib/async/background/web/response.rb +107 -0
- data/lib/async/background/web/router.rb +32 -0
- data/lib/async/background/web/serializer.rb +154 -0
- data/lib/async/background/web/snapshot.rb +247 -0
- data/lib/async/background/web/sql.rb +88 -0
- data/lib/async/background/web/stream.rb +82 -0
- data/lib/async/background/web.rb +52 -0
- metadata +46 -2
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Web
|
|
6
|
+
class Auth
|
|
7
|
+
def initialize(callable, logger: nil)
|
|
8
|
+
@callable = callable
|
|
9
|
+
@logger = logger
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def authorized?(env)
|
|
13
|
+
!!@callable.call(env)
|
|
14
|
+
rescue StandardError => error
|
|
15
|
+
@logger&.warn(
|
|
16
|
+
"[async-background-web] auth callable raised: " \
|
|
17
|
+
"#{error.class}: #{error.message}"
|
|
18
|
+
)
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../queue/store'
|
|
4
|
+
|
|
5
|
+
module Async
|
|
6
|
+
module Background
|
|
7
|
+
module Web
|
|
8
|
+
class Configuration
|
|
9
|
+
DEFAULT_LIST_LIMIT = 50
|
|
10
|
+
MAX_LIST_LIMIT = 200
|
|
11
|
+
DEFAULT_COUNTS_TTL = 3.0
|
|
12
|
+
DEFAULT_POLL_INTERVAL_MS = 2000
|
|
13
|
+
DEFAULT_STREAM_POLL_SECONDS = 0.5
|
|
14
|
+
DEFAULT_STREAM_HEARTBEAT_SECONDS = 25.0
|
|
15
|
+
DEFAULT_STREAM_RETRY_MS = 5000
|
|
16
|
+
TRANSPORTS = %i[polling sse].freeze
|
|
17
|
+
DEFAULT_TRANSPORT = :sse
|
|
18
|
+
DEFAULT_REDACT = ->(args) { args.is_a?(Array) ? args.map { '***' } : args }
|
|
19
|
+
|
|
20
|
+
attr_accessor :queue_path,
|
|
21
|
+
:auth,
|
|
22
|
+
:expose_args,
|
|
23
|
+
:redact_args,
|
|
24
|
+
:metrics_path,
|
|
25
|
+
:total_workers,
|
|
26
|
+
:counts_cache_ttl,
|
|
27
|
+
:list_limit,
|
|
28
|
+
:poll_interval_ms,
|
|
29
|
+
:transport,
|
|
30
|
+
:stream_poll_seconds,
|
|
31
|
+
:stream_heartbeat_seconds,
|
|
32
|
+
:stream_retry_ms,
|
|
33
|
+
:title,
|
|
34
|
+
:mount_path,
|
|
35
|
+
:logger
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@queue_path = Queue::Store.default_path
|
|
39
|
+
@auth = nil
|
|
40
|
+
@expose_args = false
|
|
41
|
+
@redact_args = DEFAULT_REDACT
|
|
42
|
+
@metrics_path = nil
|
|
43
|
+
@total_workers = nil
|
|
44
|
+
@counts_cache_ttl = DEFAULT_COUNTS_TTL
|
|
45
|
+
@list_limit = DEFAULT_LIST_LIMIT
|
|
46
|
+
@poll_interval_ms = DEFAULT_POLL_INTERVAL_MS
|
|
47
|
+
@transport = DEFAULT_TRANSPORT
|
|
48
|
+
@stream_poll_seconds = DEFAULT_STREAM_POLL_SECONDS
|
|
49
|
+
@stream_heartbeat_seconds = DEFAULT_STREAM_HEARTBEAT_SECONDS
|
|
50
|
+
@stream_retry_ms = DEFAULT_STREAM_RETRY_MS
|
|
51
|
+
@title = 'Async::Background'
|
|
52
|
+
@mount_path = ''
|
|
53
|
+
@logger = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate!
|
|
57
|
+
validate_queue_path!
|
|
58
|
+
validate_auth!
|
|
59
|
+
validate_list_limit!
|
|
60
|
+
validate_cache_ttl!
|
|
61
|
+
validate_poll_interval!
|
|
62
|
+
validate_transport!
|
|
63
|
+
validate_stream!
|
|
64
|
+
validate_redactor!
|
|
65
|
+
validate_metrics!
|
|
66
|
+
validate_mount_path!
|
|
67
|
+
validate_logger!
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Strict request-path parsing. Silently changing a malformed requested
|
|
72
|
+
# page size to the default makes API clients repeat or skip work.
|
|
73
|
+
def limit_for(requested)
|
|
74
|
+
return list_limit if requested.nil? || requested.empty?
|
|
75
|
+
|
|
76
|
+
value = Integer(requested, 10)
|
|
77
|
+
raise RequestError, 'limit must be a positive integer' unless value.positive?
|
|
78
|
+
|
|
79
|
+
[value, MAX_LIST_LIMIT].min
|
|
80
|
+
rescue ArgumentError, TypeError
|
|
81
|
+
raise RequestError, 'limit must be a positive integer'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def metrics_enabled?
|
|
85
|
+
!metrics_path.nil?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def validate_queue_path!
|
|
91
|
+
raise ConfigurationError, 'queue_path must be set' if queue_path.nil? || queue_path.to_s.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_auth!
|
|
95
|
+
raise ConfigurationError, 'auth must be configured (gem ships no permissive default)' if auth.nil?
|
|
96
|
+
|
|
97
|
+
return if auth.respond_to?(:call)
|
|
98
|
+
|
|
99
|
+
raise ConfigurationError, 'auth must respond to #call(env) and return truthy on success'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_list_limit!
|
|
103
|
+
return if list_limit.is_a?(Integer) && list_limit.between?(1, MAX_LIST_LIMIT)
|
|
104
|
+
|
|
105
|
+
raise ConfigurationError, "list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate_cache_ttl!
|
|
109
|
+
return if counts_cache_ttl.is_a?(Numeric) && counts_cache_ttl >= 0
|
|
110
|
+
|
|
111
|
+
raise ConfigurationError, 'counts_cache_ttl must be a non-negative Numeric'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_poll_interval!
|
|
115
|
+
return if poll_interval_ms.is_a?(Integer) && poll_interval_ms >= 200
|
|
116
|
+
|
|
117
|
+
raise ConfigurationError, 'poll_interval_ms must be an Integer >= 200'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def validate_transport!
|
|
121
|
+
return if TRANSPORTS.include?(transport)
|
|
122
|
+
|
|
123
|
+
raise ConfigurationError, "transport must be one of #{TRANSPORTS.inspect}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_stream!
|
|
127
|
+
unless stream_poll_seconds.is_a?(Numeric) && stream_poll_seconds >= 0.1
|
|
128
|
+
raise ConfigurationError, 'stream_poll_seconds must be a Numeric >= 0.1'
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
unless stream_heartbeat_seconds.is_a?(Numeric) && stream_heartbeat_seconds >= 5
|
|
132
|
+
raise ConfigurationError, 'stream_heartbeat_seconds must be a Numeric >= 5'
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
return if stream_retry_ms.is_a?(Integer) && stream_retry_ms >= 500
|
|
136
|
+
|
|
137
|
+
raise ConfigurationError, 'stream_retry_ms must be an Integer >= 500'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_redactor!
|
|
141
|
+
return unless expose_args && redact_args && !redact_args.respond_to?(:call)
|
|
142
|
+
|
|
143
|
+
raise ConfigurationError, 'redact_args must respond to #call(args)'
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_metrics!
|
|
147
|
+
return unless metrics_enabled?
|
|
148
|
+
return if total_workers.is_a?(Integer) && total_workers.positive?
|
|
149
|
+
|
|
150
|
+
raise ConfigurationError, 'metrics_path requires total_workers to be a positive Integer'
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def validate_mount_path!
|
|
154
|
+
raise ConfigurationError, 'mount_path must be a String' unless mount_path.is_a?(String)
|
|
155
|
+
return if mount_path.empty?
|
|
156
|
+
|
|
157
|
+
raise ConfigurationError, 'mount_path must start with "/" or be empty' unless mount_path.start_with?('/')
|
|
158
|
+
raise ConfigurationError, 'mount_path must not end with "/"' if mount_path.end_with?('/')
|
|
159
|
+
raise ConfigurationError, 'mount_path must not contain control characters' if mount_path.match?(/[[:cntrl:]]/)
|
|
160
|
+
raise ConfigurationError, 'mount_path must not contain whitespace' if mount_path.match?(/\s/)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_logger!
|
|
164
|
+
return if logger.nil?
|
|
165
|
+
return if logger.respond_to?(:warn) && logger.respond_to?(:error)
|
|
166
|
+
|
|
167
|
+
raise ConfigurationError, 'logger must respond to #warn and #error'
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module Async
|
|
6
|
+
module Background
|
|
7
|
+
module Web
|
|
8
|
+
module Cursor
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def encode_finished(finished_at, id)
|
|
12
|
+
encode(finished_at, id)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def encode_pending(run_at, id)
|
|
16
|
+
encode(run_at, id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def decode_finished(value)
|
|
20
|
+
timestamp, id = decode(value)
|
|
21
|
+
return unless timestamp
|
|
22
|
+
|
|
23
|
+
{finished_at: timestamp, id: id}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def decode_pending(value)
|
|
27
|
+
timestamp, id = decode(value)
|
|
28
|
+
return unless timestamp
|
|
29
|
+
|
|
30
|
+
{run_at: timestamp, id: id}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def encode(timestamp, id)
|
|
34
|
+
return if timestamp.nil? || id.nil?
|
|
35
|
+
|
|
36
|
+
Base64.urlsafe_encode64("#{Float(timestamp)}:#{Integer(id)}", padding: false)
|
|
37
|
+
end
|
|
38
|
+
private_class_method :encode
|
|
39
|
+
|
|
40
|
+
def decode(value)
|
|
41
|
+
return if value.nil? || value.to_s.empty?
|
|
42
|
+
|
|
43
|
+
timestamp_raw, id_raw, extra = Base64.urlsafe_decode64(value.to_s).split(':', 3)
|
|
44
|
+
raise RequestError, 'invalid cursor' if timestamp_raw.nil? || id_raw.nil? || extra
|
|
45
|
+
|
|
46
|
+
timestamp = Float(timestamp_raw)
|
|
47
|
+
id = Integer(id_raw)
|
|
48
|
+
raise RequestError, 'invalid cursor' unless timestamp.finite? && id.positive?
|
|
49
|
+
|
|
50
|
+
[timestamp, id]
|
|
51
|
+
rescue ArgumentError, TypeError
|
|
52
|
+
raise RequestError, 'invalid cursor'
|
|
53
|
+
end
|
|
54
|
+
private_class_method :decode
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Web
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class ConfigurationError < Error; end
|
|
8
|
+
class NotConfiguredError < Error; end
|
|
9
|
+
class RequestError < Error; end
|
|
10
|
+
class UnavailableError < Error; end
|
|
11
|
+
class ClosedError < Error; end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Async
|
|
6
|
+
module Background
|
|
7
|
+
module Web
|
|
8
|
+
class EventHub
|
|
9
|
+
HEARTBEAT_FRAME = ":keepalive\n\n"
|
|
10
|
+
UNAVAILABLE_FRAME = "event: unavailable\ndata: #{JSON.generate(error: 'unavailable')}\n\n".freeze
|
|
11
|
+
|
|
12
|
+
def initialize(snapshot, serializer, metrics_reader: nil)
|
|
13
|
+
@snapshot = snapshot
|
|
14
|
+
@serializer = serializer
|
|
15
|
+
@metrics_reader = metrics_reader
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@cached_version = nil
|
|
18
|
+
@cached_frame = nil
|
|
19
|
+
@closed = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def current_version
|
|
23
|
+
@mutex.synchronize { raise ClosedError, 'event hub is closed' if @closed }
|
|
24
|
+
@snapshot.data_version
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def frame_for(version)
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
raise ClosedError, 'event hub is closed' if @closed
|
|
30
|
+
return @cached_frame if @cached_version == version && @cached_frame
|
|
31
|
+
|
|
32
|
+
refresh_frame_locked!
|
|
33
|
+
@cached_frame
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initial_frame
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
raise ClosedError, 'event hub is closed' if @closed
|
|
40
|
+
|
|
41
|
+
refresh_frame_locked!
|
|
42
|
+
[@cached_version, @cached_frame]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def close
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@closed = true
|
|
49
|
+
@cached_frame = nil
|
|
50
|
+
@cached_version = nil
|
|
51
|
+
end
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def closed?
|
|
56
|
+
@mutex.synchronize { @closed }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def refresh_frame_locked!
|
|
62
|
+
overview = @snapshot.overview(force: true)
|
|
63
|
+
metrics = @metrics_reader&.aggregated
|
|
64
|
+
payload = @serializer.overview(overview, metrics)
|
|
65
|
+
@cached_version = payload.fetch(:data_version)
|
|
66
|
+
@cached_frame = "event: overview\ndata: #{JSON.generate(payload)}\n\n"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../clock'
|
|
4
|
+
require_relative '../metrics'
|
|
5
|
+
|
|
6
|
+
module Async
|
|
7
|
+
module Background
|
|
8
|
+
module Web
|
|
9
|
+
class MetricsReader
|
|
10
|
+
include Clock
|
|
11
|
+
|
|
12
|
+
DEFAULT_TTL = 1.0
|
|
13
|
+
EMPTY_WORKERS = [].freeze
|
|
14
|
+
EMPTY_TOTALS = {
|
|
15
|
+
total_runs: 0,
|
|
16
|
+
total_successes: 0,
|
|
17
|
+
total_failures: 0,
|
|
18
|
+
total_timeouts: 0,
|
|
19
|
+
total_skips: 0,
|
|
20
|
+
active_jobs: 0,
|
|
21
|
+
last_run_at: 0,
|
|
22
|
+
last_duration_ms: nil
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(path:, total_workers:, ttl: DEFAULT_TTL)
|
|
26
|
+
@path = path
|
|
27
|
+
@total_workers = total_workers
|
|
28
|
+
@ttl = ttl
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
@cache = nil
|
|
31
|
+
@cached_at = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def aggregated
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
now = monotonic_now
|
|
37
|
+
return @cache if cache_current?(now)
|
|
38
|
+
|
|
39
|
+
@cache = read_metrics.freeze
|
|
40
|
+
@cached_at = now
|
|
41
|
+
@cache
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def cache_current?(now)
|
|
48
|
+
@cache && @cached_at && (now - @cached_at) < @ttl
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_metrics
|
|
52
|
+
return unavailable unless Metrics.available? && File.file?(@path)
|
|
53
|
+
|
|
54
|
+
workers = Metrics.read_all(total_workers: @total_workers, path: @path)
|
|
55
|
+
{available: true, workers: workers, totals: aggregate(workers)}
|
|
56
|
+
rescue StandardError
|
|
57
|
+
unavailable
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def unavailable
|
|
61
|
+
{available: false, workers: EMPTY_WORKERS, totals: EMPTY_TOTALS}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def aggregate(workers)
|
|
65
|
+
totals = {
|
|
66
|
+
total_runs: 0,
|
|
67
|
+
total_successes: 0,
|
|
68
|
+
total_failures: 0,
|
|
69
|
+
total_timeouts: 0,
|
|
70
|
+
total_skips: 0,
|
|
71
|
+
active_jobs: 0,
|
|
72
|
+
last_run_at: 0,
|
|
73
|
+
last_duration_ms: nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
workers.each do |worker|
|
|
77
|
+
totals[:total_runs] += worker[:total_runs].to_i
|
|
78
|
+
totals[:total_successes] += worker[:total_successes].to_i
|
|
79
|
+
totals[:total_failures] += worker[:total_failures].to_i
|
|
80
|
+
totals[:total_timeouts] += worker[:total_timeouts].to_i
|
|
81
|
+
totals[:total_skips] += worker[:total_skips].to_i
|
|
82
|
+
totals[:active_jobs] += worker[:active_jobs].to_i
|
|
83
|
+
|
|
84
|
+
last_run_at = worker[:last_run_at].to_i
|
|
85
|
+
next unless last_run_at > totals[:last_run_at]
|
|
86
|
+
|
|
87
|
+
totals[:last_run_at] = last_run_at
|
|
88
|
+
totals[:last_duration_ms] = worker[:last_duration_ms]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
totals.freeze
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Web
|
|
6
|
+
class Request
|
|
7
|
+
def initialize(env, config)
|
|
8
|
+
@config = config
|
|
9
|
+
@params = parse(env['QUERY_STRING'])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def limit
|
|
13
|
+
@config.limit_for(@params['limit'])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def finished_cursor
|
|
17
|
+
Cursor.decode_finished(@params['cursor'])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pending_cursor
|
|
21
|
+
Cursor.decode_pending(@params['cursor'])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def parse(query)
|
|
27
|
+
return {} if query.nil? || query.empty?
|
|
28
|
+
|
|
29
|
+
Rack::Utils.parse_query(query)
|
|
30
|
+
rescue StandardError
|
|
31
|
+
raise RequestError, 'invalid query string'
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Async
|
|
6
|
+
module Background
|
|
7
|
+
module Web
|
|
8
|
+
module Response
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
JSON_TYPE = 'application/json; charset=utf-8'
|
|
12
|
+
HTML_TYPE = 'text/html; charset=utf-8'
|
|
13
|
+
TEXT_TYPE = 'text/plain; charset=utf-8'
|
|
14
|
+
JAVASCRIPT_TYPE = 'application/javascript; charset=utf-8'
|
|
15
|
+
CSS_TYPE = 'text/css; charset=utf-8'
|
|
16
|
+
NO_STORE = 'no-store'
|
|
17
|
+
ASSET_CACHE = 'public, max-age=31536000, immutable'
|
|
18
|
+
BASE_SECURITY_HEADERS = {
|
|
19
|
+
'x-content-type-options' => 'nosniff',
|
|
20
|
+
'referrer-policy' => 'no-referrer',
|
|
21
|
+
'cross-origin-resource-policy' => 'same-origin'
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
HTML_SECURITY_HEADERS = BASE_SECURITY_HEADERS.merge(
|
|
25
|
+
'x-frame-options' => 'DENY',
|
|
26
|
+
'content-security-policy' =>
|
|
27
|
+
"default-src 'none'; " \
|
|
28
|
+
"script-src 'self'; " \
|
|
29
|
+
"style-src 'self'; " \
|
|
30
|
+
"img-src 'self' data:; " \
|
|
31
|
+
"connect-src 'self'; " \
|
|
32
|
+
"frame-ancestors 'none'; " \
|
|
33
|
+
"base-uri 'none'; " \
|
|
34
|
+
"form-action 'none'"
|
|
35
|
+
).freeze
|
|
36
|
+
|
|
37
|
+
UNAUTHORIZED_BODY = JSON.generate(error: 'unauthorized').freeze
|
|
38
|
+
NOT_FOUND_BODY = JSON.generate(error: 'not_found').freeze
|
|
39
|
+
BAD_REQUEST_BODY = JSON.generate(error: 'invalid_request').freeze
|
|
40
|
+
UNAVAILABLE_BODY = JSON.generate(error: 'service_unavailable').freeze
|
|
41
|
+
INTERNAL_ERROR_BODY = JSON.generate(error: 'internal_error').freeze
|
|
42
|
+
EVENT_STREAM_TYPE = 'text/event-stream; charset=utf-8'
|
|
43
|
+
|
|
44
|
+
def sse(body)
|
|
45
|
+
[200, sse_headers, body]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def json(payload, status: 200)
|
|
49
|
+
[status, no_store_headers(JSON_TYPE), [JSON.generate(payload)]]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def html(body)
|
|
53
|
+
[200, html_headers, [body]]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def javascript(body)
|
|
57
|
+
[200, asset_headers(JAVASCRIPT_TYPE), [body]]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stylesheet(body)
|
|
61
|
+
[200, asset_headers(CSS_TYPE), [body]]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def unauthorized
|
|
65
|
+
[401, no_store_headers(JSON_TYPE), [UNAUTHORIZED_BODY]]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def not_found
|
|
69
|
+
[404, no_store_headers(JSON_TYPE), [NOT_FOUND_BODY]]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def bad_request(message = nil)
|
|
73
|
+
body = message.nil? ? BAD_REQUEST_BODY : JSON.generate(error: 'invalid_request', message: message)
|
|
74
|
+
[400, no_store_headers(JSON_TYPE), [body]]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def unavailable
|
|
78
|
+
[503, no_store_headers(JSON_TYPE), [UNAVAILABLE_BODY]]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def internal_error
|
|
82
|
+
[500, no_store_headers(JSON_TYPE), [INTERNAL_ERROR_BODY]]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def no_store_headers(content_type)
|
|
86
|
+
{'content-type' => content_type, 'cache-control' => NO_STORE}.merge(BASE_SECURITY_HEADERS)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def html_headers
|
|
90
|
+
{'content-type' => HTML_TYPE, 'cache-control' => NO_STORE}.merge(HTML_SECURITY_HEADERS)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def asset_headers(content_type)
|
|
94
|
+
{'content-type' => content_type, 'cache-control' => ASSET_CACHE}.merge(BASE_SECURITY_HEADERS)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def sse_headers
|
|
98
|
+
{
|
|
99
|
+
'content-type' => EVENT_STREAM_TYPE,
|
|
100
|
+
'cache-control' => 'no-cache, no-transform',
|
|
101
|
+
'x-accel-buffering' => 'no'
|
|
102
|
+
}.merge(BASE_SECURITY_HEADERS)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Web
|
|
6
|
+
class Router
|
|
7
|
+
GET_ROUTES = {
|
|
8
|
+
'/' => :index,
|
|
9
|
+
'/assets/app.js' => :javascript,
|
|
10
|
+
'/assets/app.css' => :stylesheet,
|
|
11
|
+
'/api/overview' => :overview,
|
|
12
|
+
'/api/executing' => :executing,
|
|
13
|
+
'/api/claimed' => :claimed,
|
|
14
|
+
'/api/done' => :done,
|
|
15
|
+
'/api/failed' => :failed,
|
|
16
|
+
'/api/pending' => :pending,
|
|
17
|
+
'/api/metrics' => :metrics,
|
|
18
|
+
'/api/config' => :config,
|
|
19
|
+
'/api/stream' => :stream
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
ALLOWED_METHODS = %w[GET HEAD].freeze
|
|
23
|
+
|
|
24
|
+
def match(env)
|
|
25
|
+
return unless ALLOWED_METHODS.include?(env['REQUEST_METHOD'])
|
|
26
|
+
|
|
27
|
+
GET_ROUTES[env['PATH_INFO'] || '/']
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|