event_stream_parser 0.1.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: 498814773bcffd664024e918c1f94e32393e6b8af266fe88084cadb4cd8c86d7
4
+ data.tar.gz: da45b31afcb41e23bb2fb4dbde9d0c5ed173d8fb8ac0759464129528b19e49bd
5
+ SHA512:
6
+ metadata.gz: 2123d8df807cb4cb1a3f1981f3bd3d59eec2393579d4c41bc7a4f597be6f827e285ef8cffb37abfd12d9266e69febe5be09ad79f7dff53ef288c950ffc077603
7
+ data.tar.gz: dbadc0f0bc184c0d08f8e6466f4384ce51baf25da1d97bbc90ba75a0d92f3a48468dc82882015f82d17d5f047d64625ddffe8897a51dd7b8e972a19b3d585fbb
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023-present, Shopify Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # event_stream_parser
2
+
3
+ A lightweight, fully spec-compliant parser for the
4
+ [event stream](https://www.w3.org/TR/eventsource/) format.
5
+
6
+ It only deals with the parsing of events and not any of the client/transport
7
+ aspects. This is not a Server-sent Events (SSE) client.
8
+
9
+ Under the hood, it's a stateful parser that receives chunks (that are received
10
+ from an HTTP client, for example) and emits events as it parses them. But it
11
+ remembers the last event id and reconnection time and keeps emitting them as
12
+ long as they are not overwritten by new ones.
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ ```ruby
19
+ gem 'event_stream_parser'
20
+ ```
21
+
22
+ And then execute:
23
+
24
+ ```sh
25
+ bundle
26
+ ```
27
+
28
+ Or install it yourself as:
29
+
30
+ ```sh
31
+ gem install event_stream_parser
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ Create a new Parser and give it a block to receive events:
37
+
38
+ ```rb
39
+ parser = EventStreamParser::Parser.new
40
+
41
+ parser.feed do |type, data, id, reconnection_time|
42
+ puts "Event type: #{type}"
43
+ puts "Event data: #{data}"
44
+ puts "Event id: #{id}"
45
+ puts "Reconnection time: #{reconnection_time}"
46
+ end
47
+ ```
48
+
49
+ Then, feed it chunks as they come in:
50
+
51
+ ```rb
52
+ do_something_that_yields_chunks do { |chunk| parser.feed(chunk) }
53
+ ```
54
+
55
+ Or use the `stream` method to generate a proc that you can pass to a chunk
56
+ producer:
57
+
58
+ ```rb
59
+ parser_stream = parser.stream do |type, data, id, reconnection_time|
60
+ puts "Event type: #{type}"
61
+ puts "Event data: #{data}"
62
+ puts "Event id: #{id}"
63
+ puts "Reconnection time: #{reconnection_time}"
64
+ end
65
+
66
+ do_something_that_yields_chunks(&parser_stream)
67
+ ```
68
+
69
+ ## Development
70
+
71
+ After checking out the repo:
72
+
73
+ 1. Run `bundle` to install dependencies.
74
+ 2. Run `rake test` to run the tests.
75
+ 3. Run `rubocop` to run Rubocop.
76
+
77
+ To install this gem onto your local machine, run `bundle exec rake install`. To
78
+ release a new version, update the version number in `version.rb`, and then run
79
+ `bundle exec rake release`, which will create a git tag for the version, push
80
+ git commits and tags, and push the `.gem` file to
81
+ [rubygems.org](https://rubygems.org).
82
+
83
+ ## Contributing
84
+
85
+ Bug reports and pull requests are welcome on GitHub at
86
+ https://github.com/Shopify/event_stream_parser. This project is intended to be a
87
+ safe, welcoming space for collaboration, and contributors are expected to adhere
88
+ to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. Read more about contributing [here](https://github.com/Shopify/event_stream_parser/blob/main/CONTRIBUTING.md).
89
+
90
+ ## License
91
+
92
+ The gem is available as open source under the terms of the
93
+ [MIT License](https://opensource.org/licenses/MIT).
94
+
95
+ ## Code of Conduct
96
+
97
+ Everyone interacting in this repository is expected to follow the
98
+ [Code of Conduct](https://github.com/Shopify/event_stream_parser/blob/main/CODE_OF_CONDUCT.md).
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventStreamParser
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "event_stream_parser/version"
4
+
5
+ module EventStreamParser
6
+ ##
7
+ # Implements a spec-compliant event stream parser, following:
8
+ #
9
+ # https://html.spec.whatwg.org/multipage/server-sent-events.html
10
+ # Section: 9.2.6 Interpreting an event stream
11
+ #
12
+ # Code comments are copied from the spec.
13
+ #
14
+ class Parser
15
+ UTF_8_BOM = [0xEF, 0xBB, 0xBF].pack("C*").force_encoding("UTF-8").freeze
16
+
17
+ def initialize
18
+ ##
19
+ # When a stream is parsed, a data buffer, an event type buffer, and a last
20
+ # event ID buffer must be associated with it. They must be initialized to
21
+ # the empty string.
22
+ #
23
+ @data_buffer = +""
24
+ @event_type_buffer = +""
25
+ @last_event_id_buffer = +""
26
+
27
+ @reconnection_time = nil
28
+ @buffer = +""
29
+ @first_chunk = true
30
+ @last_delimiter = nil
31
+ end
32
+
33
+ def feed(chunk, &proc)
34
+ ##
35
+ # The UTF-8 decode algorithm strips one leading UTF-8 Byte Order Mark (BOM),
36
+ # if any.
37
+ #
38
+ if @first_chunk
39
+ chunk = chunk.delete_prefix(UTF_8_BOM)
40
+ @first_chunk = false
41
+ end
42
+
43
+ @buffer << chunk
44
+
45
+ ##
46
+ # The stream must then be parsed by reading everything line by line, with a
47
+ # U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair, a single
48
+ # U+000A LINE FEED (LF) character not preceded by a U+000D CARRIAGE RETURN
49
+ # (CR) character, and a single U+000D CARRIAGE RETURN (CR) character not
50
+ # followed by a U+000A LINE FEED (LF) character being the ways in which a
51
+ # line can end.
52
+ #
53
+ if @last_delimiter == "\r"
54
+ @buffer.delete_prefix!("\n")
55
+ end
56
+
57
+ while (line = @buffer.slice!(/.*?(?<delim>\r\n|\r|\n)/))
58
+ line.chomp!
59
+ @last_delimiter = $~[:delim]
60
+ process_line(line, &proc)
61
+ end
62
+ ##
63
+ # Once the end of the file is reached, any pending data must be discarded.
64
+ # (If the file ends in the middle of an event, before the final empty line,
65
+ # the incomplete event is not dispatched.)
66
+ #
67
+ end
68
+
69
+ def stream
70
+ proc { |chunk| feed(chunk) { |*args| yield(*args) } }
71
+ end
72
+
73
+ private
74
+
75
+ def process_line(line, &proc)
76
+ ##
77
+ # Lines must be processed, in the order they are received, as follows:
78
+ #
79
+ case line
80
+ ##
81
+ # If the line is empty (a blank line)
82
+ #
83
+ when ""
84
+ ##
85
+ # Dispatch the event, as defined below.
86
+ #
87
+ dispatch_event(&proc)
88
+ ##
89
+ # If the line starts with a U+003A COLON character (:)
90
+ #
91
+ when /^:/
92
+ ##
93
+ # Ignore the line.
94
+ #
95
+ ignore
96
+ ##
97
+ # If the line contains a U+003A COLON character (:)
98
+ #
99
+ # Collect the characters on the line before the first U+003A COLON character
100
+ # (:), and let field be that string.
101
+ #
102
+ # Collect the characters on the line after the first U+003A COLON character
103
+ # (:), and let value be that string. If value starts with a U+0020 SPACE
104
+ # character, remove it from value.
105
+ #
106
+ when /\A(?<field>[^:]+):\s?(?<value>.*)\z/
107
+ ##
108
+ # Process the field using the steps described below, using field as the
109
+ # field name and value as the field value.
110
+ #
111
+ process_field($~[:field], $~[:value])
112
+ ##
113
+ # Otherwise, the string is not empty but does not contain a U+003A COLON
114
+ # character (:)
115
+ #
116
+ else
117
+ ##
118
+ # Process the field using the steps described below, using the whole line
119
+ # as the field name, and the empty string as the field value.
120
+ #
121
+ process_field(line, "")
122
+ end
123
+ end
124
+
125
+ def process_field(field, value)
126
+ ##
127
+ # The steps to process the field given a field name and a field value depend
128
+ # on the field name, as given in the following list. Field names must be
129
+ # compared literally, with no case folding performed.
130
+ #
131
+ case field
132
+ ##
133
+ # If the field name is "event"
134
+ #
135
+ when "event"
136
+ ##
137
+ # Set the event type buffer to field value.
138
+ #
139
+ @event_type_buffer = value
140
+ ##
141
+ # If the field name is "data"
142
+ #
143
+ when "data"
144
+ ##
145
+ # Append the field value to the data buffer, then append a single U+000A
146
+ # LINE FEED (LF) character to the data buffer.
147
+ #
148
+ @data_buffer << value << "\n"
149
+ ##
150
+ # If the field name is "id"
151
+ #
152
+ when "id"
153
+ ##
154
+ # If the field value does not contain U+0000 NULL, then set the last event
155
+ # ID buffer to the field value. Otherwise, ignore the field.
156
+ #
157
+ @last_event_id_buffer = value unless value.include?("\u0000")
158
+ ##
159
+ # If the field name is "retry"
160
+ #
161
+ when "retry"
162
+ ##
163
+ # If the field value consists of only ASCII digits, then interpret the
164
+ # field value as an integer in base ten, and set the event stream's
165
+ # reconnection time to that integer. Otherwise, ignore the field.
166
+ #
167
+ @reconnection_time = value.to_i if /\A\d+\z/.match?(value)
168
+ ##
169
+ # Otherwise
170
+ #
171
+ else
172
+ ##
173
+ # The field is ignored.
174
+ #
175
+ ignore
176
+ end
177
+ end
178
+
179
+ def dispatch_event
180
+ ##
181
+ # When the user agent is required to dispatch the event, the user agent must
182
+ # process the data buffer, the event type buffer, and the last event ID
183
+ # buffer using steps appropriate for the user agent.
184
+ #
185
+ # For web browsers, the appropriate steps to dispatch the event are as
186
+ # follows:
187
+ #
188
+ # 1. Set the last event ID string of the event source to the value of the
189
+ # last event ID buffer. The buffer does not get reset, so the last event
190
+ # ID string of the event source remains set to this value until the next
191
+ # time it is set by the server.
192
+ #
193
+ # Note: If an event doesn't have an "id" field, but an earlier event did set
194
+ # the event source's last event ID string, then the event's lastEventId
195
+ # field will be set to the value of whatever the last seen "id" field was.
196
+ #
197
+ id = @last_event_id_buffer
198
+ ##
199
+ # 2. If the data buffer is an empty string, set the data buffer and the
200
+ # event type buffer to the empty string and return.
201
+ #
202
+ if @data_buffer.empty?
203
+ @data_buffer = +""
204
+ @event_type_buffer = +""
205
+ return
206
+ end
207
+ ##
208
+ # 3. If the data buffer's last character is a U+000A LINE FEED (LF)
209
+ # character, then remove the last character from the data buffer.
210
+ #
211
+ @data_buffer.chomp!
212
+ ##
213
+ # 5. Initialize event's type attribute to "message", its data attribute to
214
+ # data, ...
215
+ # 6. If the event type buffer has a value other than the empty string,
216
+ # change the type of the newly created event to equal the value of the
217
+ # event type buffer.
218
+ #
219
+ # For other user agents, the appropriate steps to dispatch the event are
220
+ # implementation dependent, but at a minimum they must set the data and
221
+ # event type buffers to the empty string before returning.
222
+ #
223
+ type = @event_type_buffer
224
+ data = @data_buffer
225
+ ##
226
+ # 7. Set the data buffer and the event type buffer to the empty string.
227
+ #
228
+ @data_buffer = +""
229
+ @event_type_buffer = +""
230
+
231
+ yield type, data, id, @reconnection_time
232
+ end
233
+
234
+ def ignore; end
235
+ end
236
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: event_stream_parser
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ates Goral
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-10-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description:
42
+ email:
43
+ - ates.goral@shopify.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files:
47
+ - README.md
48
+ files:
49
+ - LICENSE.md
50
+ - README.md
51
+ - lib/event_stream_parser.rb
52
+ - lib/event_stream_parser/version.rb
53
+ homepage: https://github.com/Shopify/event_stream_parser
54
+ licenses:
55
+ - MIT
56
+ metadata:
57
+ allowed_push_host: https://rubygems.org
58
+ post_install_message:
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 2.7.0
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 1.3.7
72
+ requirements: []
73
+ rubygems_version: 3.4.20
74
+ signing_key:
75
+ specification_version: 4
76
+ summary: A spec-compliant event stream parser
77
+ test_files: []