pug-sh 0.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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +247 -0
  4. data/lib/pug/batcher.rb +384 -0
  5. data/lib/pug/client.rb +248 -0
  6. data/lib/pug/connect.rb +291 -0
  7. data/lib/pug/countries.rb +19 -0
  8. data/lib/pug/errors.rb +16 -0
  9. data/lib/pug/event_builder.rb +522 -0
  10. data/lib/pug/gen/buf/validate/validate_pb.rb +59 -0
  11. data/lib/pug/gen/common/events/v1/api_events_pb.rb +26 -0
  12. data/lib/pug/gen/common/events/v1/app_events_pb.rb +33 -0
  13. data/lib/pug/gen/common/events/v1/auth_events_pb.rb +32 -0
  14. data/lib/pug/gen/common/events/v1/billing_events_pb.rb +41 -0
  15. data/lib/pug/gen/common/events/v1/chat_events_pb.rb +56 -0
  16. data/lib/pug/gen/common/events/v1/commerce_events_pb.rb +38 -0
  17. data/lib/pug/gen/common/events/v1/discovery_events_pb.rb +31 -0
  18. data/lib/pug/gen/common/events/v1/error_events_pb.rb +26 -0
  19. data/lib/pug/gen/common/events/v1/file_events_pb.rb +29 -0
  20. data/lib/pug/gen/common/events/v1/form_events_pb.rb +27 -0
  21. data/lib/pug/gen/common/events/v1/integration_events_pb.rb +27 -0
  22. data/lib/pug/gen/common/events/v1/invitation_events_pb.rb +26 -0
  23. data/lib/pug/gen/common/events/v1/media_events_pb.rb +36 -0
  24. data/lib/pug/gen/common/events/v1/navigation_events_pb.rb +31 -0
  25. data/lib/pug/gen/common/events/v1/notification_events_pb.rb +28 -0
  26. data/lib/pug/gen/common/events/v1/options_pb.rb +25 -0
  27. data/lib/pug/gen/common/events/v1/social_events_pb.rb +25 -0
  28. data/lib/pug/gen/common/events/v1/support_events_pb.rb +33 -0
  29. data/lib/pug/gen/common/events/v1/workspace_events_pb.rb +30 -0
  30. data/lib/pug/gen/common/v1/filter_schema_pb.rb +29 -0
  31. data/lib/pug/gen/common/v1/filters_pb.rb +27 -0
  32. data/lib/pug/gen/common/v1/property_value_pb.rb +24 -0
  33. data/lib/pug/gen/common/v1/time_pb.rb +25 -0
  34. data/lib/pug/gen/sdk/devices/v1/devices_pb.rb +35 -0
  35. data/lib/pug/gen/sdk/events/v1/events_pb.rb +30 -0
  36. data/lib/pug/gen/sdk/profiles/v1/profiles_pb.rb +28 -0
  37. data/lib/pug/gen/shared/activity/v1/activity_pb.rb +46 -0
  38. data/lib/pug/gen/shared/insights/v1/insights_pb.rb +69 -0
  39. data/lib/pug/gen/shared/profiles/v1/profiles_pb.rb +43 -0
  40. data/lib/pug/log.rb +35 -0
  41. data/lib/pug/proto.rb +22 -0
  42. data/lib/pug/reads.rb +118 -0
  43. data/lib/pug/rpc.rb +73 -0
  44. data/lib/pug/validator.rb +475 -0
  45. data/lib/pug/version.rb +5 -0
  46. data/lib/pug/well_known.rb +33 -0
  47. data/lib/pug-sh.rb +3 -0
  48. data/lib/pug.rb +5 -0
  49. metadata +122 -0
data/lib/pug/client.rb ADDED
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "uri"
6
+ require_relative "batcher"
7
+ require_relative "connect"
8
+ require_relative "errors"
9
+ require_relative "event_builder"
10
+ require_relative "log"
11
+ require_relative "reads"
12
+ require_relative "rpc"
13
+ require_relative "validator"
14
+
15
+ module Pug
16
+ class Client
17
+ DEFAULT_ENDPOINT = "https://api.pugs.dev"
18
+ DEFAULT_TIMEOUT = 10
19
+ RESERVED_PREFIX = "cookieless-"
20
+ TRACK_OPTIONS = %w[timestamp session_id location].freeze
21
+ IDENTIFY_OPTIONS = %w[anonymous_id device_id].freeze
22
+
23
+ class NotSerializable < StandardError; end
24
+ OMITTED = Object.new.freeze
25
+ private_constant :NotSerializable, :OMITTED
26
+
27
+ attr_reader :profiles, :activity, :insights
28
+
29
+ def initialize(api_key:, endpoint: nil, batch: nil, on_error: nil, logger: nil, timeout: nil)
30
+ unless api_key.is_a?(String) && !api_key.empty?
31
+ raise ArgumentError, "[Pug SDK] api_key is required and must be a non-empty String"
32
+ end
33
+ # A trailing newline from a secrets file would fail every request.
34
+ unless api_key.ascii_only? && api_key.match?(/\A[!-~]+\z/)
35
+ raise ArgumentError, "[Pug SDK] api_key must be printable ASCII without spaces (check for a trailing newline)"
36
+ end
37
+ unless api_key.start_with?("prv_")
38
+ raise ArgumentError, "[Pug SDK] api_key must be a private key (prv_…) for the server SDK"
39
+ end
40
+ raise ArgumentError, "[Pug SDK] on_error must be callable" unless on_error.nil? || on_error.respond_to?(:call)
41
+ unless on_error.nil? || takes_two?(on_error)
42
+ raise ArgumentError, "[Pug SDK] on_error must take two arguments, (error, events)"
43
+ end
44
+ raise ArgumentError, "[Pug SDK] batch must be a Hash" unless batch.nil? || batch.is_a?(Hash)
45
+ unless logger.nil? || %i[warn error].all? { |level| logger.respond_to?(level) }
46
+ raise ArgumentError, "[Pug SDK] logger must respond to warn and error, like a Logger (wrap an IO in Logger.new)"
47
+ end
48
+
49
+ endpoint = endpoint.to_s
50
+ endpoint = DEFAULT_ENDPOINT if endpoint.empty?
51
+ unless http_url?(endpoint)
52
+ raise ArgumentError, "[Pug SDK] endpoint must be an absolute http(s) URL, got #{endpoint.inspect}"
53
+ end
54
+
55
+ @log = Log.new(logger)
56
+ @connect = Connect.new(endpoint, api_key, timeout: resolve_timeout(timeout))
57
+ @rpc = RPC.new(@connect)
58
+ @events = EventBuilder.new(@log)
59
+ @batcher = Batcher.new(method(:send_batch), batch, on_error, @log)
60
+ @session_id = SecureRandom.uuid_v7
61
+ @closed = false
62
+ @profiles = Profiles.new(@rpc)
63
+ @activity = Activity.new(@rpc)
64
+ @insights = Insights.new(@rpc)
65
+ end
66
+
67
+ def track(distinct_id, kind, properties = OMITTED, options = nil, **keywords)
68
+ return @log.warn("track() called after close().") if @closed
69
+
70
+ distinct_id = id_string(distinct_id, "track", "distinct_id")
71
+ return if distinct_id.nil?
72
+ # The server rejects a whole batch over one such id, taking other users' events with it.
73
+ if distinct_id.start_with?(RESERVED_PREFIX)
74
+ return @log.error("track() distinct_id must not start with the reserved '#{RESERVED_PREFIX}' prefix.")
75
+ end
76
+ return @log.error("track() requires a non-empty event kind String.") unless kind?(kind)
77
+
78
+ properties, options = split_options(properties, options, keywords, TRACK_OPTIONS, "track")
79
+ return @log.error("track() properties must be a Hash.") unless properties.nil? || properties.is_a?(Hash)
80
+
81
+ session_id = options["session_id"]
82
+ return @log.error("track() session_id must be a String.") unless session_id.nil? || session_id.is_a?(String)
83
+
84
+ session_id = @session_id if session_id.nil? || session_id.empty?
85
+ event, bytesize = @events.build(kind, session_id, distinct_id, properties,
86
+ timestamp: options["timestamp"], location: options["location"])
87
+ @batcher.enqueue(event, bytesize) if event
88
+ nil
89
+ rescue StandardError => e
90
+ @log.error("Unexpected error in track(#{kind.inspect}): #{Log.describe(e)}")
91
+ end
92
+
93
+ def identify(external_id, traits = OMITTED, options = nil, **keywords)
94
+ return @log.warn("identify() called after close().") if @closed
95
+
96
+ external_id = id_string(external_id, "identify", "external_id")
97
+ return if external_id.nil?
98
+
99
+ traits, options = split_options(traits, options, keywords, IDENTIFY_OPTIONS, "identify")
100
+ return @log.error("identify() traits must be a Hash.") unless traits.nil? || traits.is_a?(Hash)
101
+
102
+ request = identify_request(external_id, traits, options)
103
+ return if request.nil?
104
+
105
+ violations = Validator.validate(request)
106
+ return @log.error("Invalid identify request: #{Validator.format(violations)}") unless violations.empty?
107
+
108
+ @rpc.unary(:identify, request)
109
+ nil
110
+ rescue NotSerializable => e
111
+ @log.error("identify() traits must be JSON-serializable: #{e.message}")
112
+ rescue StandardError => e
113
+ @log.error("Failed to identify #{external_id.inspect}: #{Log.describe(e)}")
114
+ end
115
+
116
+ def flush(timeout: nil)
117
+ @batcher.flush(timeout: wait_timeout(timeout, "flush"))
118
+ end
119
+
120
+ def close(timeout: nil)
121
+ @closed = true
122
+ @batcher.close(timeout: wait_timeout(timeout, "close"))
123
+ ensure
124
+ @connect.close
125
+ end
126
+
127
+ # The default would print the API key and every buffered event.
128
+ def inspect = "#<#{self.class.name}>"
129
+
130
+ private
131
+
132
+ def send_batch(events)
133
+ response = @rpc.unary(:batch_create, RPC::EventsV1::BatchCreateRequest.new(events: events))
134
+ # A refused event isn't an RPC error, so this is the only sign it was lost.
135
+ return unless response.dropped.positive?
136
+
137
+ @log.warn("Server dropped #{response.dropped} of #{events.size} event(s): #{response.dropped_by_reason.to_h}")
138
+ end
139
+
140
+ def identify_request(external_id, traits, options)
141
+ ids = IDENTIFY_OPTIONS.filter_map do |name|
142
+ value = options[name]
143
+ [name.to_sym, id_string(value, "identify", name, integers: false)] unless value.nil? || value == ""
144
+ end.to_h
145
+ return if ids.value?(nil)
146
+
147
+ request = { external_id: external_id, **ids }
148
+ request[:traits] = to_struct(traits) unless traits.nil?
149
+ RPC::SdkProfilesV1::IdentifyRequest.new(request)
150
+ end
151
+
152
+ def resolve_timeout(timeout)
153
+ return DEFAULT_TIMEOUT if timeout.nil?
154
+ return timeout if Connect.timeout?(timeout)
155
+
156
+ @log.warn("timeout must be a positive number of seconds; using the default #{DEFAULT_TIMEOUT}.")
157
+ DEFAULT_TIMEOUT
158
+ end
159
+
160
+ # A timeout that isn't a number waits without one, as close must still drain; one already
161
+ # past doesn't wait at all.
162
+ def wait_timeout(timeout, method)
163
+ return if timeout.nil? || timeout == Float::INFINITY
164
+ return [timeout.to_f, 0].max if timeout.is_a?(Numeric) && timeout.real? && timeout.finite?
165
+
166
+ @log.warn("#{method}() timeout must be a number of seconds; waiting without one.")
167
+ nil
168
+ end
169
+
170
+ # Lambdas and methods check their arity, so one taking fewer arguments would fail every call.
171
+ def takes_two?(callable)
172
+ callable = callable.method(:call) unless callable.is_a?(Proc) || callable.is_a?(Method)
173
+ return true if callable.is_a?(Proc) && !callable.lambda?
174
+
175
+ arity = callable.arity
176
+ arity.negative? ? -arity - 1 <= 2 : arity == 2
177
+ end
178
+
179
+ def http_url?(endpoint)
180
+ uri = URI.parse(endpoint)
181
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
182
+ rescue URI::InvalidURIError
183
+ false
184
+ end
185
+
186
+ # An Integer passes as an id too, since a Rails record's id is one. Unlike property values,
187
+ # ids aren't repaired: a mangled id would be a different person.
188
+ def id_string(value, method, name, integers: true)
189
+ value = value.to_s if integers && value.is_a?(Integer)
190
+ unless value.is_a?(String) && !value.empty?
191
+ return @log.error("#{method}() requires a non-empty #{name} String#{" or Integer" if integers}.")
192
+ end
193
+
194
+ utf8 = if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(value.encoding)
195
+ value.dup.force_encoding(Encoding::UTF_8)
196
+ else
197
+ value.encode(Encoding::UTF_8)
198
+ end
199
+ return utf8 if utf8.valid_encoding?
200
+
201
+ @log.error("#{method}() #{name} must be valid UTF-8.")
202
+ rescue EncodingError
203
+ @log.error("#{method}() #{name} must be valid UTF-8.")
204
+ end
205
+
206
+ def kind?(kind)
207
+ (kind.is_a?(String) || kind.is_a?(Symbol)) && !kind.empty?
208
+ end
209
+
210
+ # Braceless keywords arrive as one Hash, so track(id, kind, plan: "pro") and
211
+ # track(id, kind, timestamp: t) look alike. A keyword named after an option sets that option;
212
+ # any other is data, properties or traits, unless the data came first in an argument of its own.
213
+ def split_options(data, options, keywords, known, method)
214
+ named = keywords
215
+ if data.equal?(OMITTED)
216
+ named, data = keywords.partition { |key, _| known.include?(key.to_s) }.map(&:to_h)
217
+ data = nil if data.empty?
218
+ end
219
+ [data, options_hash(options, known, method).merge(options_hash(named, known, method))]
220
+ end
221
+
222
+ def options_hash(options, known, method)
223
+ return {} if options.nil?
224
+
225
+ unless options.is_a?(Hash)
226
+ @log.warn("Ignoring #{method}() options: expected a Hash, got #{options.class}")
227
+ return {}
228
+ end
229
+ options.each_with_object({}) do |(key, value), out|
230
+ if known.include?(key.to_s)
231
+ out[key.to_s] = value
232
+ else
233
+ @log.warn("Ignoring unknown #{method}() option #{key.inspect}")
234
+ end
235
+ end
236
+ end
237
+
238
+ # A JSON round trip accepts what JSON does (symbol keys, nested arrays) and refuses what a
239
+ # Struct can't carry, such as NaN. A Struct's numbers are doubles, so an integer past 2^53
240
+ # would arrive changed.
241
+ def to_struct(traits)
242
+ ready = EventBuilder.json_ready(traits, max_integer: EventBuilder::MAX_SAFE_FLOAT)
243
+ ::Google::Protobuf::Struct.from_hash(JSON.parse(JSON.generate(ready)))
244
+ rescue StandardError => e
245
+ raise NotSerializable, e.message
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require "zlib"
8
+ require_relative "errors"
9
+ require_relative "version"
10
+
11
+ module Pug
12
+ # A Connect protocol client (https://connectrpc.com/docs/protocol) for the two call shapes the
13
+ # SDK uses: unary, and server streaming for profiles.list. Binary protobuf over HTTP/1.1, like
14
+ # sdk-node. Every failure surfaces as a Pug::Error carrying a Connect code.
15
+ class Connect
16
+ USER_AGENT = "pug-ruby/#{VERSION}".freeze
17
+ UNARY_CONTENT_TYPE = "application/proto"
18
+ STREAM_CONTENT_TYPE = "application/connect+proto"
19
+ CONTENT_TYPE = %r{\Aapplication/(?<stream>connect\+)?(?:(?<json>json)(?:; ?charset=utf-?8)?|(?<proto>proto))\z}i
20
+ END_STREAM = 0b10
21
+ COMPRESSED = 0b01
22
+
23
+ # Carries an exception raised by the caller's block past the transport's rescue clauses, which
24
+ # would otherwise mistake, say, the caller's IOError for a network failure. Whoever passed
25
+ # the block re-raises the original.
26
+ class Passthrough < Exception # rubocop:disable Lint/InheritException
27
+ attr_reader :error
28
+
29
+ def initialize(error)
30
+ @error = error
31
+ super(error.message)
32
+ end
33
+ end
34
+
35
+ CODES = %i[canceled unknown invalid_argument deadline_exceeded not_found already_exists permission_denied
36
+ resource_exhausted failed_precondition aborted out_of_range unimplemented internal unavailable
37
+ data_loss unauthenticated].to_h { |code| [code.to_s, code] }.freeze
38
+
39
+ # https://connectrpc.com/docs/protocol/#http-to-error-code: 400 is internal and 404
40
+ # unimplemented on purpose.
41
+ HTTP_CODES = { 400 => :internal, 401 => :unauthenticated, 403 => :permission_denied, 404 => :unimplemented,
42
+ 429 => :unavailable, 502 => :unavailable, 503 => :unavailable, 504 => :unavailable }.freeze
43
+
44
+ def self.timeout?(value)
45
+ value.is_a?(Numeric) && value.real? && value.positive? && value.finite?
46
+ end
47
+
48
+ def initialize(endpoint, api_key, timeout:)
49
+ @uri = URI(endpoint)
50
+ @base_path = @uri.path.to_s.chomp("/")
51
+ @api_key = api_key
52
+ @timeout = timeout
53
+ @pool = Pool.new(@uri)
54
+ end
55
+
56
+ def unary(procedure, request, response_class)
57
+ response = call(procedure, request.class.encode(request), UNARY_CONTENT_TYPE, @timeout) do |http, post|
58
+ http.request(post)
59
+ end
60
+ raise unary_error(response) unless response.code == "200"
61
+
62
+ check_content_type(response, stream: false)
63
+ decode(response_class, response.body.to_s)
64
+ end
65
+
66
+ # The deadline covers the whole stream, including the time the caller's block takes.
67
+ def server_stream(procedure, request, response_class, timeout: nil)
68
+ timeout ||= @timeout
69
+ body = envelope(0, request.class.encode(request))
70
+ deadline = monotonic + timeout
71
+ call(procedure, body, STREAM_CONTENT_TYPE, timeout) do |http, post|
72
+ http.request(post) do |response|
73
+ raise http_error(response) unless response.code == "200"
74
+
75
+ check_content_type(response, stream: true)
76
+ check_stream_encoding(response)
77
+ read_stream(http, response, response_class, deadline) do |message|
78
+ yield message
79
+ rescue Exception => e # rubocop:disable Lint/RescueException
80
+ raise Passthrough, e
81
+ end
82
+ end
83
+ end
84
+ nil
85
+ end
86
+
87
+ def close
88
+ @pool.close
89
+ end
90
+
91
+ # The default would print the API key.
92
+ def inspect = "#<#{self.class.name}>"
93
+
94
+ private
95
+
96
+ def call(procedure, body, content_type, timeout)
97
+ post = Net::HTTP::Post.new("#{@base_path}/#{procedure}")
98
+ post["Content-Type"] = content_type
99
+ post["Connect-Protocol-Version"] = "1"
100
+ post["Connect-Timeout-Ms"] = (timeout * 1000).ceil.to_s
101
+ post["User-Agent"] = USER_AGENT
102
+ post["X-Api-Key"] = @api_key
103
+ post.body = body
104
+ @pool.with(timeout) { |http| yield http, post }
105
+ rescue Timeout::Error => e
106
+ raise Error.new("request timed out: #{e.message}", code: :deadline_exceeded)
107
+ rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
108
+ raise Error.new("#{e.class}: #{e.message}", code: :unavailable)
109
+ rescue Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error => e
110
+ raise Error.new("#{e.class}: #{e.message}", code: :internal)
111
+ end
112
+
113
+ def read_stream(http, response, response_class, deadline)
114
+ buffer = "".b
115
+ ended = false
116
+ response.read_body do |chunk|
117
+ buffer << chunk
118
+ while buffer.bytesize >= 5
119
+ flags, length = buffer.unpack("CN")
120
+ break if buffer.bytesize < 5 + length
121
+
122
+ data = buffer.byteslice(5, length)
123
+ buffer = buffer.byteslice((5 + length)..)
124
+ if ended
125
+ raise Error.new("protocol error: received extra message after EndStreamResponse", code: :invalid_argument)
126
+ end
127
+ if flags.anybits?(COMPRESSED)
128
+ raise Error.new("protocol error: received a compressed message it did not ask for", code: :internal)
129
+ end
130
+
131
+ if flags.anybits?(END_STREAM)
132
+ end_stream(data)
133
+ ended = true
134
+ else
135
+ yield decode(response_class, data)
136
+ end
137
+ end
138
+ next if ended
139
+
140
+ remaining = deadline - monotonic
141
+ raise Error.new("request timed out", code: :deadline_exceeded) unless remaining.positive?
142
+
143
+ http.read_timeout = remaining
144
+ end
145
+ raise Error.new("protocol error: premature end of stream", code: :data_loss) unless buffer.empty?
146
+ raise Error.new("protocol error: missing EndStreamResponse", code: :invalid_argument) unless ended
147
+ end
148
+
149
+ def end_stream(data)
150
+ invalid = Error.new("invalid end stream", code: :unknown)
151
+ parsed = parse_json(data)
152
+ raise invalid unless parsed.is_a?(Hash)
153
+ raise wire_error(parsed["error"], invalid) unless parsed["error"].nil?
154
+ end
155
+
156
+ def unary_error(response)
157
+ fallback = http_error(response)
158
+ match = CONTENT_TYPE.match(response["Content-Type"].to_s)
159
+ return fallback unless match && match[:json]
160
+
161
+ wire_error(parse_json(response.body.to_s), fallback)
162
+ end
163
+
164
+ def wire_error(json, fallback)
165
+ return fallback unless json.is_a?(Hash)
166
+
167
+ message = json["message"]
168
+ return fallback unless message.nil? || message.is_a?(String)
169
+
170
+ Error.new(message.to_s, code: CODES.fetch(json["code"].to_s, fallback.code))
171
+ end
172
+
173
+ def parse_json(data)
174
+ JSON.parse(data.dup.force_encoding(Encoding::UTF_8))
175
+ rescue JSON::ParserError, EncodingError
176
+ nil
177
+ end
178
+
179
+ def check_content_type(response, stream:)
180
+ type = response["Content-Type"].to_s
181
+ match = CONTENT_TYPE.match(type)
182
+ return if match && match[:proto] && !match[:stream] == !stream
183
+
184
+ raise Error.new("unsupported content type #{type}", code: match ? :internal : :unknown)
185
+ end
186
+
187
+ def check_stream_encoding(response)
188
+ encoding = response["Connect-Content-Encoding"]
189
+ return if encoding.nil? || encoding.casecmp?("identity")
190
+
191
+ raise Error.new("unsupported response encoding \"#{encoding}\"", code: :internal)
192
+ end
193
+
194
+ def decode(response_class, data)
195
+ response_class.decode(data)
196
+ rescue ::Google::Protobuf::ParseError => e
197
+ raise Error.new("failed to decode #{response_class.descriptor.name}: #{e.message}", code: :internal)
198
+ end
199
+
200
+ def envelope(flags, data)
201
+ [flags, data.bytesize].pack("CN") + data
202
+ end
203
+
204
+ # Net::HTTP doesn't follow redirects, so one means the endpoint is misconfigured, as a 404
205
+ # does: unimplemented, which nothing retries. A proxy may have answered instead of a Connect
206
+ # server, so the status gets the redirect target or the start of the body.
207
+ def http_error(response)
208
+ status = response.code.to_i
209
+ redirect = (300..399).cover?(status)
210
+ detail = redirect ? response["Location"]&.then { |url| "redirected to #{url}" } : snippet(response.body)
211
+ code = redirect ? :unimplemented : HTTP_CODES.fetch(status, :unknown)
212
+ Error.new(["HTTP #{status}", detail].compact.join(": "), code: code)
213
+ end
214
+
215
+ def snippet(body)
216
+ text = body.to_s.byteslice(0, 512).force_encoding(Encoding::UTF_8).scrub("").gsub(/\s+/, " ").strip
217
+ text[0, 120] unless text.empty?
218
+ end
219
+
220
+ def monotonic
221
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
222
+ end
223
+
224
+ # Keep-alive connections, one per concurrent caller. Net::HTTP sessions aren't thread-safe,
225
+ # so each is checked out by one thread at a time.
226
+ class Pool
227
+ MAX_IDLE = 4
228
+
229
+ def initialize(uri)
230
+ @uri = uri
231
+ @idle = []
232
+ @lock = Mutex.new
233
+ @pid = Process.pid
234
+ end
235
+
236
+ def with(timeout)
237
+ http = checkout
238
+ http.open_timeout = timeout
239
+ http.read_timeout = timeout
240
+ http.write_timeout = timeout
241
+ http.start unless http.started?
242
+ result = yield http
243
+ checkin(http)
244
+ http = nil
245
+ result
246
+ ensure
247
+ discard(http) if http
248
+ end
249
+
250
+ def close
251
+ connections = @lock.synchronize do
252
+ forked
253
+ @idle.slice!(0..)
254
+ end
255
+ connections.each { |http| discard(http) }
256
+ end
257
+
258
+ private
259
+
260
+ def checkout
261
+ @lock.synchronize do
262
+ forked
263
+ @idle.pop
264
+ end || Net::HTTP.new(@uri.hostname, @uri.port).tap { |http| http.use_ssl = @uri.scheme == "https" }
265
+ end
266
+
267
+ def checkin(http)
268
+ keep = @lock.synchronize do
269
+ forked
270
+ @idle.size < MAX_IDLE && @idle.push(http)
271
+ end
272
+ discard(http) unless keep
273
+ end
274
+
275
+ # A forked child must not reuse, or even close, its parent's sockets: a TLS close_notify
276
+ # would be written into the parent's live connection. They are dropped instead.
277
+ def forked
278
+ return if @pid == Process.pid
279
+
280
+ @pid = Process.pid
281
+ @idle = []
282
+ end
283
+
284
+ def discard(http)
285
+ http.finish if http.started?
286
+ rescue StandardError
287
+ nil
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ # Mirrors internal/geo/countries.go in the pug server. The server stores $country unvalidated
5
+ # and only the choropleth filters it, so a bad code becomes a permanent rollup dimension that
6
+ # is simply missing from the map. "XX", Cloudflare's unknown, passes a two-letter check.
7
+ COUNTRY_CODES = %w[
8
+ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ
9
+ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR
10
+ CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
11
+ GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU
12
+ ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ
13
+ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ
14
+ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF
15
+ PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI
16
+ SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR
17
+ TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS XK YE YT ZA ZM ZW
18
+ ].to_set.freeze
19
+ end
data/lib/pug/errors.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ class Error < StandardError
5
+ attr_reader :code
6
+
7
+ def initialize(message = nil, code: nil)
8
+ @code = code
9
+ super(code ? [code, message].reject { |part| part.to_s.empty? }.join(": ") : message)
10
+ end
11
+ end
12
+
13
+ class ClosedError < Error; end
14
+
15
+ class QueueOverflowError < Error; end
16
+ end