ld-eventsource 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 294a32bc78de81b8f7c5ffa0a8b9ffc25847b0ecd20eee8f27caea576ae2b6fc
4
+ data.tar.gz: 5c0bb7736f090364098525477ec192b89ee02b96e0d12542bf72f08112180ba2
5
+ SHA512:
6
+ metadata.gz: b6466b0b4c060b681640e2b47cc1844bb93a9588076fbcc901a5922be303860538d6e064d1f9ea897062ffeafcb86690e68e26e0c28d6f795ea68bb41dc7d364
7
+ data.tar.gz: 67a62d448f673893e9fc397cb536ad6ba4194292e3f8c3058d96bcc39b0f8bf3ce6bf12b1bdeb8e843ff962e3324505b9cddc3e42486cdb915456dbe1dba4dcc
@@ -0,0 +1,90 @@
1
+ version: 2
2
+
3
+ workflows:
4
+ version: 2
5
+ test:
6
+ jobs:
7
+ - test-misc-rubies
8
+ - test-2.2
9
+ - test-2.3
10
+ - test-2.4
11
+ - test-2.5
12
+ - test-jruby-9.2
13
+
14
+ ruby-docker-template: &ruby-docker-template
15
+ steps:
16
+ - checkout
17
+ - run: |
18
+ if [[ $CIRCLE_JOB == test-jruby* ]]; then
19
+ gem install jruby-openssl; # required by bundler, no effect on Ruby MRI
20
+ fi
21
+ - run: ruby -v
22
+ - run: gem install bundler -v "~> 1.17"
23
+ - run: bundle install
24
+ - run: mkdir ./rspec
25
+ - run: bundle exec rspec --format progress --format RspecJunitFormatter -o ./rspec/rspec.xml spec
26
+ - store_test_results:
27
+ path: ./rspec
28
+ - store_artifacts:
29
+ path: ./rspec
30
+
31
+ jobs:
32
+ test-2.2:
33
+ <<: *ruby-docker-template
34
+ docker:
35
+ - image: circleci/ruby:2.2.10-jessie
36
+ test-2.3:
37
+ <<: *ruby-docker-template
38
+ docker:
39
+ - image: circleci/ruby:2.3.7-jessie
40
+ test-2.4:
41
+ <<: *ruby-docker-template
42
+ docker:
43
+ - image: circleci/ruby:2.4.5-stretch
44
+ test-2.5:
45
+ <<: *ruby-docker-template
46
+ docker:
47
+ - image: circleci/ruby:2.5.3-stretch
48
+ test-jruby-9.2:
49
+ <<: *ruby-docker-template
50
+ docker:
51
+ - image: circleci/jruby:9-jdk
52
+
53
+ # The following very slow job uses an Ubuntu container to run the Ruby versions that
54
+ # CircleCI doesn't provide Docker images for.
55
+ test-misc-rubies:
56
+ machine:
57
+ image: circleci/classic:latest
58
+ environment:
59
+ - RUBIES: "jruby-9.1.17.0"
60
+ steps:
61
+ - checkout
62
+ - run:
63
+ name: install all Ruby versions
64
+ command: "parallel rvm install ::: $RUBIES"
65
+ - run:
66
+ name: bundle install for all versions
67
+ shell: /bin/bash -leo pipefail # need -l in order for "rvm use" to work
68
+ command: |
69
+ set -e;
70
+ for i in $RUBIES;
71
+ do
72
+ rvm use $i;
73
+ if [[ $i == jruby* ]]; then
74
+ gem install jruby-openssl; # required by bundler, no effect on Ruby MRI
75
+ fi
76
+ gem install bundler -v "~> 1.17";
77
+ bundle install;
78
+ mv Gemfile.lock "Gemfile.lock.$i"
79
+ done
80
+ - run:
81
+ name: run tests for all versions
82
+ shell: /bin/bash -leo pipefail
83
+ command: |
84
+ set -e;
85
+ for i in $RUBIES;
86
+ do
87
+ rvm use $i;
88
+ cp "Gemfile.lock.$i" Gemfile.lock;
89
+ bundle exec rspec spec;
90
+ done
data/.gitignore ADDED
@@ -0,0 +1,15 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ *.bundle
10
+ *.so
11
+ *.o
12
+ *.a
13
+ mkmf.log
14
+ *.gem
15
+ .DS_Store
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Change log
2
+
3
+ All notable changes to the LaunchDarkly SSE Client for Ruby will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org).
4
+
5
+ ## [1.0.0] - 2019-01-03
6
+
7
+ Initial release.
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,47 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ ld-eventsource (1.0.0)
5
+ concurrent-ruby (~> 1.0)
6
+ http_tools (~> 0.4.5)
7
+ socketry (~> 0.5.1)
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ concurrent-ruby (1.0.5)
13
+ diff-lcs (1.3)
14
+ hitimes (1.3.0)
15
+ http_tools (0.4.5)
16
+ rake (10.5.0)
17
+ rspec (3.7.0)
18
+ rspec-core (~> 3.7.0)
19
+ rspec-expectations (~> 3.7.0)
20
+ rspec-mocks (~> 3.7.0)
21
+ rspec-core (3.7.1)
22
+ rspec-support (~> 3.7.0)
23
+ rspec-expectations (3.7.0)
24
+ diff-lcs (>= 1.2.0, < 2.0)
25
+ rspec-support (~> 3.7.0)
26
+ rspec-mocks (3.7.0)
27
+ diff-lcs (>= 1.2.0, < 2.0)
28
+ rspec-support (~> 3.7.0)
29
+ rspec-support (3.7.0)
30
+ rspec_junit_formatter (0.3.0)
31
+ rspec-core (>= 2, < 4, != 2.12.0)
32
+ socketry (0.5.1)
33
+ hitimes (~> 1.2)
34
+
35
+ PLATFORMS
36
+ java
37
+ ruby
38
+
39
+ DEPENDENCIES
40
+ bundler (~> 1.7)
41
+ ld-eventsource!
42
+ rake (~> 10.0)
43
+ rspec (~> 3.2)
44
+ rspec_junit_formatter (~> 0.3.0)
45
+
46
+ BUNDLED WITH
47
+ 1.17.3
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright 2018 Catamorphic, Co.
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,45 @@
1
+ LaunchDarkly SSE Client for Ruby
2
+ ================================
3
+
4
+ [![Gem Version](https://badge.fury.io/rb/ld-eventsource.svg)](http://badge.fury.io/rb/ld-eventsource) [![Circle CI](https://circleci.com/gh/launchdarkly/ruby-eventsource/tree/master.svg?style=svg)](https://circleci.com/gh/launchdarkly/ruby-eventsource/tree/master)
5
+
6
+ A client for the [Server-Sent Events](https://www.w3.org/TR/eventsource/) protocol. This implementation runs on a worker thread, and uses the [`socketry`](https://rubygems.org/gems/socketry) gem to manage a persistent connection. Its primary purpose is to support the [LaunchDarkly SDK for Ruby](https://github.com/launchdarkly/ruby-client), but it can be used independently.
7
+
8
+ Parts of this code are based on https://github.com/Tonkpils/celluloid-eventsource, but it does not use Celluloid.
9
+
10
+ Supported Ruby versions
11
+ -----------------------
12
+
13
+ This gem has a minimum Ruby version of 2.2.6, or 9.1.6 for JRuby.
14
+
15
+ Quick setup
16
+ -----------
17
+
18
+ 1. Install the Ruby SDK with `gem`:
19
+
20
+ ```shell
21
+ gem install ld-eventsource
22
+ ```
23
+
24
+ 2. Import the code:
25
+
26
+ ```ruby
27
+ require 'ld-eventsource'
28
+ ```
29
+
30
+ 3. Create a new SSE client instance and register your event handler:
31
+
32
+ ```ruby
33
+ sse_client = SSE::Client.new("http://hostname/resource/path") do |client|
34
+ client.on_event do |event|
35
+ puts "I received an event: #{event.type}, #{event.data}"
36
+ end
37
+ end
38
+ ```
39
+
40
+ For other options available with the `Client` constructor, see the [API documentation](https://www.rubydoc.info/gems/ld-eventsource).
41
+
42
+ Contributing
43
+ ------------
44
+
45
+ We welcome questions, suggestions, and pull requests at our [Github repository](https://github.com/launchdarkly/ruby-eventsource). Pull requests should be done from a fork.
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require "rspec/core/rake_task"
4
+ RSpec::Core::RakeTask.new(:spec)
5
+ task default: :spec
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+
3
+ lib = File.expand_path("../lib", __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "ld-eventsource/version"
6
+
7
+ # rubocop:disable Metrics/BlockLength
8
+ Gem::Specification.new do |spec|
9
+ spec.name = "ld-eventsource"
10
+ spec.version = SSE::VERSION
11
+ spec.authors = ["LaunchDarkly"]
12
+ spec.email = ["team@launchdarkly.com"]
13
+ spec.summary = "LaunchDarkly SSE client"
14
+ spec.description = "LaunchDarkly SSE client for Ruby"
15
+ spec.homepage = "https://github.com/launchdarkly/ruby-eventsource"
16
+ spec.license = "Apache-2.0"
17
+
18
+ spec.files = `git ls-files -z`.split("\x0")
19
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
20
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.7"
24
+ spec.add_development_dependency "rspec", "~> 3.2"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "rspec_junit_formatter", "~> 0.3.0"
27
+
28
+ spec.add_runtime_dependency "concurrent-ruby", "~> 1.0"
29
+ spec.add_runtime_dependency "http_tools", '~> 0.4.5'
30
+ spec.add_runtime_dependency "socketry", "~> 0.5.1"
31
+ end
@@ -0,0 +1,14 @@
1
+ require "ld-eventsource/client"
2
+ require "ld-eventsource/version"
3
+
4
+ #
5
+ # A client for the Server-Sent Events protocol.
6
+ #
7
+ module SSE
8
+ #
9
+ # Internal components of the SSE implementation. All classes in this module should be
10
+ # considered unsupported and subject to change.
11
+ #
12
+ module Impl
13
+ end
14
+ end
@@ -0,0 +1,296 @@
1
+ require "ld-eventsource/impl/backoff"
2
+ require "ld-eventsource/impl/event_parser"
3
+ require "ld-eventsource/impl/streaming_http"
4
+ require "ld-eventsource/events"
5
+ require "ld-eventsource/errors"
6
+
7
+ require "concurrent/atomics"
8
+ require "logger"
9
+ require "thread"
10
+ require "uri"
11
+
12
+ module SSE
13
+ #
14
+ # A lightweight SSE client implementation. The client uses a worker thread to read from the
15
+ # streaming HTTP connection. Events are dispatched from the same worker thread.
16
+ #
17
+ # The client will attempt to recover from connection failures as follows:
18
+ #
19
+ # * The first time the connection is dropped, it will wait about one second (or whatever value is
20
+ # specified for `reconnect_time`) before attempting to reconnect. The actual delay has a
21
+ # pseudo-random jitter value added.
22
+ # * If the connection fails again within the time range specified by `reconnect_reset_interval`,
23
+ # it will exponentially increase the delay between attempts (and also apply a random jitter).
24
+ # However, if the connection stays up for at least that amount of time, the delay will be reset
25
+ # to the minimum.
26
+ # * Each time a new connection is made, the client will send a `Last-Event-Id` header so the server
27
+ # can pick up where it left off (if the server has been sending ID values for events).
28
+ #
29
+ # It is also possible to force the connection to be restarted if the server sends no data within an
30
+ # interval specified by `read_timeout`. Using a read timeout is advisable because otherwise it is
31
+ # possible in some circumstances for a connection failure to go undetected. To keep the connection
32
+ # from timing out if there are no events to send, the server could send a comment line (`":"`) at
33
+ # regular intervals as a heartbeat.
34
+ #
35
+ class Client
36
+ # The default value for `connect_timeout` in {#initialize}.
37
+ DEFAULT_CONNECT_TIMEOUT = 10
38
+
39
+ # The default value for `read_timeout` in {#initialize}.
40
+ DEFAULT_READ_TIMEOUT = 300
41
+
42
+ # The default value for `reconnect_time` in {#initialize}.
43
+ DEFAULT_RECONNECT_TIME = 1
44
+
45
+ # The maximum number of seconds that the client will wait before reconnecting.
46
+ MAX_RECONNECT_TIME = 30
47
+
48
+ # The default value for `reconnect_reset_interval` in {#initialize}.
49
+ DEFAULT_RECONNECT_RESET_INTERVAL = 60
50
+
51
+ #
52
+ # Creates a new SSE client.
53
+ #
54
+ # Once the client is created, it immediately attempts to open the SSE connection. You will
55
+ # normally want to register your event handler before this happens, so that no events are missed.
56
+ # To do this, provide a block after the constructor; the block will be executed before opening
57
+ # the connection.
58
+ #
59
+ # @example Specifying an event handler at initialization time
60
+ # client = SSE::Client.new(uri) do |c|
61
+ # c.on_event do |event|
62
+ # puts "I got an event: #{event.type}, #{event.data}"
63
+ # end
64
+ # end
65
+ #
66
+ # @param uri [String] the URI to connect to
67
+ # @param headers [Hash] ({}) custom headers to send with each HTTP request
68
+ # @param connect_timeout [Float] (DEFAULT_CONNECT_TIMEOUT) maximum time to wait for a
69
+ # connection, in seconds
70
+ # @param read_timeout [Float] (DEFAULT_READ_TIMEOUT) the connection will be dropped and
71
+ # restarted if this number of seconds elapse with no data; nil for no timeout
72
+ # @param reconnect_time [Float] (DEFAULT_RECONNECT_TIME) the initial delay before reconnecting
73
+ # after a failure, in seconds; this can increase as described in {Client}
74
+ # @param reconnect_reset_interval [Float] (DEFAULT_RECONNECT_RESET_INTERVAL) if a connection
75
+ # stays alive for at least this number of seconds, the reconnect interval will return to the
76
+ # initial value
77
+ # @param last_event_id [String] (nil) the initial value that the client should send in the
78
+ # `Last-Event-Id` header, if any
79
+ # @param proxy [String] (nil) optional URI of a proxy server to use (you can also specify a
80
+ # proxy with the `HTTP_PROXY` or `HTTPS_PROXY` environment variable)
81
+ # @param logger [Logger] a Logger instance for the client to use for diagnostic output;
82
+ # defaults to a logger with WARN level that goes to standard output
83
+ # @yieldparam [Client] client the new client instance, before opening the connection
84
+ #
85
+ def initialize(uri,
86
+ headers: {},
87
+ connect_timeout: DEFAULT_CONNECT_TIMEOUT,
88
+ read_timeout: DEFAULT_READ_TIMEOUT,
89
+ reconnect_time: DEFAULT_RECONNECT_TIME,
90
+ reconnect_reset_interval: DEFAULT_RECONNECT_RESET_INTERVAL,
91
+ last_event_id: nil,
92
+ proxy: nil,
93
+ logger: nil)
94
+ @uri = URI(uri)
95
+ @stopped = Concurrent::AtomicBoolean.new(false)
96
+
97
+ @headers = headers.clone
98
+ @connect_timeout = connect_timeout
99
+ @read_timeout = read_timeout
100
+ @logger = logger || default_logger
101
+
102
+ if proxy
103
+ @proxy = proxy
104
+ else
105
+ proxy_uri = @uri.find_proxy
106
+ if !proxy_uri.nil? && (proxy_uri.scheme == 'http' || proxy_uri.scheme == 'https')
107
+ @proxy = proxy_uri
108
+ end
109
+ end
110
+
111
+ @backoff = Impl::Backoff.new(reconnect_time || DEFAULT_RECONNECT_TIME, MAX_RECONNECT_TIME,
112
+ reconnect_reset_interval: reconnect_reset_interval)
113
+
114
+ @on = { event: ->(_) {}, error: ->(_) {} }
115
+ @last_id = last_event_id
116
+
117
+ yield self if block_given?
118
+
119
+ Thread.new do
120
+ run_stream
121
+ end
122
+ end
123
+
124
+ #
125
+ # Specifies a block or Proc to receive events from the stream. This will be called once for every
126
+ # valid event received, with a single parameter of type {StreamEvent}. It is called from the same
127
+ # worker thread that reads the stream, so no more events will be dispatched until it returns.
128
+ #
129
+ # Any exception that propagates out of the handler will cause the stream to disconnect and
130
+ # reconnect, on the assumption that data may have been lost and that restarting the stream will
131
+ # cause it to be resent.
132
+ #
133
+ # Any previously specified event handler will be replaced.
134
+ #
135
+ # @yieldparam event [StreamEvent]
136
+ #
137
+ def on_event(&action)
138
+ @on[:event] = action
139
+ end
140
+
141
+ #
142
+ # Specifies a block or Proc to receive connection errors. This will be called with a single
143
+ # parameter that is an instance of some exception class-- normally, either some I/O exception or
144
+ # one of the classes in {SSE::Errors}. It is called from the same worker thread that
145
+ # reads the stream, so no more events or errors will be dispatched until it returns.
146
+ #
147
+ # If the error handler decides that this type of error is not recoverable, it has the ability
148
+ # to prevent any further reconnect attempts by calling {Client#close} on the Client. For instance,
149
+ # you might want to do this if the server returned a `401 Unauthorized` error and no other authorization
150
+ # credentials are available, since any further requests would presumably also receive a 401.
151
+ #
152
+ # Any previously specified error handler will be replaced.
153
+ #
154
+ # @yieldparam error [StandardError]
155
+ #
156
+ def on_error(&action)
157
+ @on[:error] = action
158
+ end
159
+
160
+ #
161
+ # Permanently shuts down the client and its connection. No further events will be dispatched. This
162
+ # has no effect if called a second time.
163
+ #
164
+ def close
165
+ if @stopped.make_true
166
+ @cxn.close if !@cxn.nil?
167
+ @cxn = nil
168
+ end
169
+ end
170
+
171
+ private
172
+
173
+ def default_logger
174
+ log = ::Logger.new($stdout)
175
+ log.level = ::Logger::WARN
176
+ log.progname = 'ld-eventsource'
177
+ log
178
+ end
179
+
180
+ def run_stream
181
+ while !@stopped.value
182
+ @cxn = nil
183
+ begin
184
+ @cxn = connect
185
+ # There's a potential race if close was called in the middle of the previous line, i.e. after we
186
+ # connected but before @cxn was set. Checking the variable again is a bit clunky but avoids that.
187
+ return if @stopped.value
188
+ read_stream(@cxn) if !@cxn.nil?
189
+ rescue Errno::EBADF
190
+ # Don't log this as an error - it probably means we closed our own connection deliberately
191
+ @logger.info { "Stream connection closed" }
192
+ rescue StandardError => e
193
+ # This should not be possible because connect catches all StandardErrors
194
+ log_and_dispatch_error(e, "Unexpected error from event source")
195
+ end
196
+ begin
197
+ @cxn.close if !@cxn.nil?
198
+ rescue StandardError => e
199
+ log_and_dispatch_error(e, "Unexpected error while closing stream")
200
+ end
201
+ end
202
+ end
203
+
204
+ # Try to establish a streaming connection. Returns the StreamingHTTPConnection object if successful.
205
+ def connect
206
+ loop do
207
+ return if @stopped.value
208
+ interval = @backoff.next_interval
209
+ if interval > 0
210
+ @logger.info { "Will retry connection after #{'%.3f' % interval} seconds" }
211
+ sleep(interval)
212
+ end
213
+ begin
214
+ @logger.info { "Connecting to event stream at #{@uri}" }
215
+ cxn = Impl::StreamingHTTPConnection.new(@uri,
216
+ proxy: @proxy,
217
+ headers: build_headers,
218
+ connect_timeout: @connect_timeout,
219
+ read_timeout: @read_timeout
220
+ )
221
+ if cxn.status == 200
222
+ content_type = cxn.headers["content-type"]
223
+ if content_type && content_type.start_with?("text/event-stream")
224
+ return cxn # we're good to proceed
225
+ else
226
+ cxn.close
227
+ err = Errors::HTTPContentTypeError.new(cxn.headers["content-type"])
228
+ @on[:error].call(err)
229
+ @logger.warn { "Event source returned unexpected content type '#{cxn.headers["content-type"]}'" }
230
+ end
231
+ else
232
+ body = cxn.read_all # grab the whole response body in case it has error details
233
+ cxn.close
234
+ @logger.info { "Server returned error status #{cxn.status}" }
235
+ err = Errors::HTTPStatusError.new(cxn.status, body)
236
+ @on[:error].call(err)
237
+ end
238
+ rescue Errno::EBADF
239
+ raise # See EBADF comment in run_stream
240
+ rescue StandardError => e
241
+ cxn.close if !cxn.nil?
242
+ log_and_dispatch_error(e, "Unexpected error from event source")
243
+ end
244
+ # if unsuccessful, continue the loop to connect again
245
+ end
246
+ end
247
+
248
+ # Pipe the output of the StreamingHTTPConnection into the EventParser, and dispatch events as
249
+ # they arrive.
250
+ def read_stream(cxn)
251
+ # Tell the Backoff object that the connection is now in a valid state. It uses that information so
252
+ # it can automatically reset itself if enough time passes between failures.
253
+ @backoff.mark_success
254
+
255
+ event_parser = Impl::EventParser.new(cxn.read_lines)
256
+ event_parser.items.each do |item|
257
+ return if @stopped.value
258
+ case item
259
+ when StreamEvent
260
+ dispatch_event(item)
261
+ when Impl::SetRetryInterval
262
+ @logger.debug { "Received 'retry:' directive, setting interval to #{item.milliseconds}ms" }
263
+ @backoff.base_interval = item.milliseconds.to_f / 1000
264
+ end
265
+ end
266
+ end
267
+
268
+ def dispatch_event(event)
269
+ @logger.debug { "Received event: #{event}" }
270
+ @last_id = event.id
271
+
272
+ # Pass the event to the caller
273
+ @on[:event].call(event)
274
+ end
275
+
276
+ def log_and_dispatch_error(e, message)
277
+ @logger.warn { "#{message}: #{e.inspect}"}
278
+ @logger.debug { "Exception trace: #{e.backtrace}" }
279
+ begin
280
+ @on[:error].call(e)
281
+ rescue StandardError => ee
282
+ @logger.warn { "Error handler threw an exception: #{ee.inspect}"}
283
+ @logger.debug { "Exception trace: #{ee.backtrace}" }
284
+ end
285
+ end
286
+
287
+ def build_headers
288
+ h = {
289
+ 'Accept' => 'text/event-stream',
290
+ 'Cache-Control' => 'no-cache'
291
+ }
292
+ h['Last-Event-Id'] = @last_id if !@last_id.nil?
293
+ h.merge(@headers)
294
+ end
295
+ end
296
+ end