tipi 0.38 → 0.42

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +5 -1
  3. data/.gitignore +5 -0
  4. data/CHANGELOG.md +34 -0
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +58 -16
  7. data/Rakefile +7 -3
  8. data/TODO.md +77 -1
  9. data/benchmarks/bm_http1_parser.rb +61 -0
  10. data/bin/benchmark +37 -0
  11. data/bin/h1pd +6 -0
  12. data/bin/tipi +3 -21
  13. data/df/sample_agent.rb +1 -1
  14. data/df/server.rb +16 -47
  15. data/df/server_utils.rb +178 -0
  16. data/examples/full_service.rb +13 -0
  17. data/examples/http1_parser.rb +55 -0
  18. data/examples/http_server.rb +15 -3
  19. data/examples/http_server_forked.rb +5 -1
  20. data/examples/http_server_routes.rb +29 -0
  21. data/examples/http_server_static.rb +26 -0
  22. data/examples/http_server_throttled.rb +3 -2
  23. data/examples/https_server.rb +6 -4
  24. data/examples/https_wss_server.rb +2 -1
  25. data/examples/rack_server.rb +5 -0
  26. data/examples/rack_server_https.rb +1 -1
  27. data/examples/rack_server_https_forked.rb +4 -3
  28. data/examples/routing_server.rb +5 -4
  29. data/examples/servername_cb.rb +37 -0
  30. data/examples/websocket_demo.rb +2 -8
  31. data/examples/ws_page.html +2 -2
  32. data/ext/tipi/extconf.rb +13 -0
  33. data/ext/tipi/http1_parser.c +823 -0
  34. data/ext/tipi/http1_parser.h +18 -0
  35. data/ext/tipi/tipi_ext.c +5 -0
  36. data/lib/tipi.rb +89 -1
  37. data/lib/tipi/acme.rb +308 -0
  38. data/lib/tipi/cli.rb +30 -0
  39. data/lib/tipi/digital_fabric/agent.rb +22 -17
  40. data/lib/tipi/digital_fabric/agent_proxy.rb +95 -40
  41. data/lib/tipi/digital_fabric/executive.rb +6 -2
  42. data/lib/tipi/digital_fabric/protocol.rb +87 -15
  43. data/lib/tipi/digital_fabric/request_adapter.rb +6 -10
  44. data/lib/tipi/digital_fabric/service.rb +77 -51
  45. data/lib/tipi/http1_adapter.rb +116 -117
  46. data/lib/tipi/http2_adapter.rb +56 -10
  47. data/lib/tipi/http2_stream.rb +106 -53
  48. data/lib/tipi/rack_adapter.rb +2 -53
  49. data/lib/tipi/response_extensions.rb +17 -0
  50. data/lib/tipi/version.rb +1 -1
  51. data/security/http1.rb +12 -0
  52. data/test/helper.rb +60 -11
  53. data/test/test_http1_parser.rb +586 -0
  54. data/test/test_http_server.rb +0 -27
  55. data/test/test_request.rb +1 -28
  56. data/tipi.gemspec +11 -5
  57. metadata +96 -22
  58. data/e +0 -0
@@ -24,9 +24,11 @@ module DigitalFabric
24
24
  class GracefulShutdown < RuntimeError
25
25
  end
26
26
 
27
+ @@id = 0
28
+
27
29
  def run
28
30
  @fiber = Fiber.current
29
- @keep_alive_timer = spin_loop(interval: 5) { keep_alive }
31
+ @keep_alive_timer = spin_loop("#{@fiber.tag}-keep_alive", interval: 5) { keep_alive }
30
32
  while true
31
33
  connect_and_process_incoming_requests
32
34
  return if @shutdown
@@ -119,7 +121,7 @@ module DigitalFabric
119
121
 
120
122
  def recv_df_message(msg)
121
123
  @last_recv = Time.now
122
- case msg['kind']
124
+ case msg[Protocol::Attribute::KIND]
123
125
  when Protocol::SHUTDOWN
124
126
  recv_shutdown
125
127
  when Protocol::HTTP_REQUEST
@@ -130,7 +132,7 @@ module DigitalFabric
130
132
  recv_ws_request(msg)
131
133
  when Protocol::CONN_DATA, Protocol::CONN_CLOSE,
132
134
  Protocol::WS_DATA, Protocol::WS_CLOSE
133
- fiber = @requests[msg['id']]
135
+ fiber = @requests[msg[Protocol::Attribute::ID]]
134
136
  fiber << msg if fiber
135
137
  end
136
138
  end
@@ -140,7 +142,7 @@ module DigitalFabric
140
142
  # messages. This is so we can correctly stop long-running requests
141
143
  # upon graceful shutdown
142
144
  if is_long_running_request_response?(msg)
143
- id = msg[:id]
145
+ id = msg[Protocol::Attribute::ID]
144
146
  @long_running_requests[id] = @requests[id]
145
147
  end
146
148
  @last_send = Time.now
@@ -148,11 +150,11 @@ module DigitalFabric
148
150
  end
149
151
 
150
152
  def is_long_running_request_response?(msg)
151
- case msg[:kind]
153
+ case msg[Protocol::Attribute::KIND]
152
154
  when Protocol::HTTP_UPGRADE
153
155
  true
154
156
  when Protocol::HTTP_RESPONSE
155
- msg[:body] && !msg[:complete]
157
+ !msg[Protocol::Attribute::HttpResponse::COMPLETE]
156
158
  end
157
159
  end
158
160
 
@@ -165,13 +167,14 @@ module DigitalFabric
165
167
 
166
168
  def recv_http_request(msg)
167
169
  req = prepare_http_request(msg)
168
- id = msg['id']
169
- @requests[id] = spin do
170
+ id = msg[Protocol::Attribute::ID]
171
+ @requests[id] = spin("#{Fiber.current.tag}.#{id}") do
170
172
  http_request(req)
171
173
  rescue IOError, Errno::ECONNREFUSED, Errno::EPIPE
172
174
  # ignore
173
- rescue Polyphony::Terminate
175
+ rescue Polyphony::Terminate => e
174
176
  req.respond(nil, { ':status' => Qeweney::Status::SERVICE_UNAVAILABLE }) if Fiber.current.graceful_shutdown?
177
+ raise e
175
178
  ensure
176
179
  @requests.delete(id)
177
180
  @long_running_requests.delete(id)
@@ -180,17 +183,19 @@ module DigitalFabric
180
183
  end
181
184
 
182
185
  def prepare_http_request(msg)
183
- req = Qeweney::Request.new(msg['headers'], RequestAdapter.new(self, msg))
184
- req.buffer_body_chunk(msg['body']) if msg['body']
185
- req.complete! if msg['complete']
186
+ headers = msg[Protocol::Attribute::HttpRequest::HEADERS]
187
+ body_chunk = msg[Protocol::Attribute::HttpRequest::BODY_CHUNK]
188
+ complete = msg[Protocol::Attribute::HttpRequest::COMPLETE]
189
+ req = Qeweney::Request.new(headers, RequestAdapter.new(self, msg))
190
+ req.buffer_body_chunk(body_chunk) if body_chunk
186
191
  req
187
192
  end
188
193
 
189
194
  def recv_http_request_body(msg)
190
- fiber = @requests[msg['id']]
195
+ fiber = @requests[msg[Protocol::Attribute::ID]]
191
196
  return unless fiber
192
197
 
193
- fiber << msg['body']
198
+ fiber << msg[Protocol::Attribute::HttpRequestBody::BODY]
194
199
  end
195
200
 
196
201
  def get_http_request_body(id, limit)
@@ -199,9 +204,9 @@ module DigitalFabric
199
204
  end
200
205
 
201
206
  def recv_ws_request(msg)
202
- req = Qeweney::Request.new(msg['headers'], RequestAdapter.new(self, msg))
203
- id = msg['id']
204
- @requests[id] = @long_running_requests[id] = spin do
207
+ req = Qeweney::Request.new(msg[Protocol::Attribute::WS::HEADERS], RequestAdapter.new(self, msg))
208
+ id = msg[Protocol::Attribute::ID]
209
+ @requests[id] = @long_running_requests[id] = spin("#{Fiber.current.tag}.#{id}-ws") do
205
210
  ws_request(req)
206
211
  rescue IOError, Errno::ECONNREFUSED, Errno::EPIPE
207
212
  # ignore
@@ -31,14 +31,15 @@ module DigitalFabric
31
31
  def run
32
32
  @fiber = Fiber.current
33
33
  @service.mount(route, self)
34
- keep_alive_timer = spin_loop(interval: 5) { keep_alive }
34
+ @mounted = true
35
+ # keep_alive_timer = spin_loop("#{@fiber.tag}-keep_alive", interval: 5) { keep_alive }
35
36
  process_incoming_messages(false)
36
37
  rescue GracefulShutdown
37
38
  puts "Proxy got graceful shutdown, left: #{@requests.size} requests" if @requests.size > 0
38
- process_incoming_messages(true)
39
+ move_on_after(15) { process_incoming_messages(true) }
39
40
  ensure
40
- keep_alive_timer&.stop
41
- @service.unmount(self)
41
+ # keep_alive_timer&.stop
42
+ unmount
42
43
  end
43
44
 
44
45
  def process_incoming_messages(shutdown = false)
@@ -48,10 +49,18 @@ module DigitalFabric
48
49
  recv_df_message(msg)
49
50
  return if shutdown && @requests.empty?
50
51
  end
51
- rescue TimeoutError, IOError
52
+ rescue TimeoutError, IOError, SystemCallError
53
+ # ignore and just return in order to terminate the proxy
54
+ end
55
+
56
+ def unmount
57
+ return unless @mounted
58
+
59
+ @service.unmount(self)
60
+ @mounted = nil
52
61
  end
53
62
 
54
- def shutdown
63
+ def send_shutdown
55
64
  send_df_message(Protocol.shutdown)
56
65
  @fiber.raise GracefulShutdown.new
57
66
  end
@@ -82,9 +91,18 @@ module DigitalFabric
82
91
 
83
92
  def recv_df_message(message)
84
93
  @last_recv = Time.now
85
- return if message['kind'] == Protocol::PING
94
+ # puts "<<< #{message.inspect}"
95
+
96
+ case message[Protocol::Attribute::KIND]
97
+ when Protocol::PING
98
+ return
99
+ when Protocol::UNMOUNT
100
+ return unmount
101
+ when Protocol::STATS_REQUEST
102
+ return handle_stats_request(message[Protocol::Attribute::ID])
103
+ end
86
104
 
87
- handler = @requests[message['id']]
105
+ handler = @requests[message[Protocol::Attribute::ID]]
88
106
  if !handler
89
107
  # puts "Unknown request id in #{message}"
90
108
  return
@@ -94,6 +112,8 @@ module DigitalFabric
94
112
  end
95
113
 
96
114
  def send_df_message(message)
115
+ # puts ">>> #{message.inspect}" unless message[Protocol::Attribute::KIND] == Protocol::PING
116
+
97
117
  @last_send = Time.now
98
118
  @conn << message.to_msgpack
99
119
  end
@@ -124,57 +144,68 @@ module DigitalFabric
124
144
  t0 = Time.now
125
145
  t1 = nil
126
146
  with_request do |id|
127
- send_df_message(Protocol.http_request(id, req))
147
+ msg = Protocol.http_request(id, req.headers, req.next_chunk(true), req.complete?)
148
+ send_df_message(msg)
128
149
  while (message = receive)
129
150
  unless t1
130
151
  t1 = Time.now
131
- @service.record_latency_measurement(t1 - t0)
152
+ @service.record_latency_measurement(t1 - t0, req)
132
153
  end
133
- return if http_request_message(id, req, message)
154
+ kind = message[Protocol::Attribute::KIND]
155
+ attributes = message[Protocol::Attribute::HttpRequest::HEADERS..-1]
156
+ return if http_request_message(id, req, kind, attributes)
134
157
  end
135
158
  end
136
159
  rescue => e
137
- req.respond("Error: #{e.inspect}", ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR)
160
+ p "Internal server error: #{e.inspect}"
161
+ puts e.backtrace.join("\n")
162
+ http_request_send_error_response(e)
163
+ end
164
+
165
+ def http_request_send_error_response(error)
166
+ response = format("Error: %s\n%s", error.inspect, error.backtrace.join("\n"))
167
+ req.respond(response, ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR)
168
+ rescue IOError, SystemCallError
169
+ # ignore
138
170
  end
139
171
 
140
172
  # @return [Boolean] true if response is complete
141
- def http_request_message(id, req, message)
142
- case message['kind']
173
+ def http_request_message(id, req, kind, message)
174
+ case kind
143
175
  when Protocol::HTTP_UPGRADE
144
- http_custom_upgrade(id, req, message)
176
+ http_custom_upgrade(id, req, *message)
145
177
  true
146
178
  when Protocol::HTTP_GET_REQUEST_BODY
147
- http_get_request_body(id, req, message)
179
+ http_get_request_body(id, req, *message)
148
180
  false
149
181
  when Protocol::HTTP_RESPONSE
150
- headers = message['headers']
151
- body = message['body']
152
- done = message['complete']
153
- if !req.headers_sent? && done
154
- req.respond(body, headers|| {})
155
- true
156
- else
157
- req.send_headers(headers) if headers && !req.headers_sent?
158
- req.send_chunk(body, done: done) if body or done
159
- done
160
- end
182
+ http_response(id, req, *message)
161
183
  else
162
184
  # invalid message
163
185
  true
164
186
  end
165
187
  end
166
188
 
189
+ def send_transfer_count(key, rx, tx)
190
+ send_df_message(Protocol.transfer_count(key, rx, tx))
191
+ end
192
+
193
+ def handle_stats_request(id)
194
+ stats = @service.get_stats
195
+ send_df_message(Protocol.stats_response(id, stats))
196
+ end
197
+
167
198
  HTTP_RESPONSE_UPGRADE_HEADERS = { ':status' => Qeweney::Status::SWITCHING_PROTOCOLS }
168
199
 
169
- def http_custom_upgrade(id, req, message)
200
+ def http_custom_upgrade(id, req, headers)
170
201
  # send upgrade response
171
- upgrade_headers = message['headers'] ?
172
- message['headers'].merge(HTTP_RESPONSE_UPGRADE_HEADERS) :
202
+ upgrade_headers = headers ?
203
+ headers.merge(HTTP_RESPONSE_UPGRADE_HEADERS) :
173
204
  HTTP_RESPONSE_UPGRADE_HEADERS
174
205
  req.send_headers(upgrade_headers, true)
175
206
 
176
207
  conn = req.adapter.conn
177
- reader = spin do
208
+ reader = spin("#{Fiber.current.tag}.#{id}") do
178
209
  conn.recv_loop do |data|
179
210
  send_df_message(Protocol.conn_data(id, data))
180
211
  end
@@ -187,9 +218,9 @@ module DigitalFabric
187
218
  end
188
219
 
189
220
  def http_custom_upgrade_message(conn, message)
190
- case message['kind']
221
+ case message[Protocol::Attribute::KIND]
191
222
  when Protocol::CONN_DATA
192
- conn << message['data']
223
+ conn << message[:Protocol::Attribute::ConnData::DATA]
193
224
  false
194
225
  when Protocol::CONN_CLOSE
195
226
  true
@@ -199,8 +230,30 @@ module DigitalFabric
199
230
  end
200
231
  end
201
232
 
202
- def http_get_request_body(id, req, message)
203
- case (limit = message['limit'])
233
+ def http_response(id, req, body, headers, complete, transfer_count_key)
234
+ if !req.headers_sent? && complete
235
+ req.respond(body, headers|| {})
236
+ if transfer_count_key
237
+ rx, tx = req.transfer_counts
238
+ send_transfer_count(transfer_count_key, rx, tx)
239
+ end
240
+ true
241
+ else
242
+ req.send_headers(headers) if headers && !req.headers_sent?
243
+ req.send_chunk(body, done: complete) if body or complete
244
+
245
+ if complete && transfer_count_key
246
+ rx, tx = req.transfer_counts
247
+ send_transfer_count(transfer_count_key, rx, tx)
248
+ end
249
+ complete
250
+ end
251
+ rescue IOError, SystemCallError
252
+ # ignore error
253
+ end
254
+
255
+ def http_get_request_body(id, req, limit)
256
+ case limit
204
257
  when nil
205
258
  body = req.read
206
259
  else
@@ -230,9 +283,9 @@ module DigitalFabric
230
283
  with_request do |id|
231
284
  send_df_message(Protocol.ws_request(id, req.headers))
232
285
  response = receive
233
- case response['kind']
286
+ case response[0]
234
287
  when Protocol::WS_RESPONSE
235
- headers = response['headers'] || {}
288
+ headers = response[2] || {}
236
289
  status = headers[':status'] || Qeweney::Status::SWITCHING_PROTOCOLS
237
290
  if status != Qeweney::Status::SWITCHING_PROTOCOLS
238
291
  req.respond(nil, headers)
@@ -244,18 +297,20 @@ module DigitalFabric
244
297
  req.respond(nil, ':status' => Qeweney::Status::SERVICE_UNAVAILABLE)
245
298
  end
246
299
  end
300
+ rescue IOError, SystemCallError
301
+ # ignore
247
302
  end
248
303
 
249
304
  def run_websocket_connection(id, websocket)
250
- reader = spin do
305
+ reader = spin("#{Fiber.current}.#{id}-ws") do
251
306
  websocket.recv_loop do |data|
252
307
  send_df_message(Protocol.ws_data(id, data))
253
308
  end
254
309
  end
255
310
  while (message = receive)
256
- case message['kind']
311
+ case message[Protocol::Attribute::KIND]
257
312
  when Protocol::WS_DATA
258
- websocket << message['data']
313
+ websocket << message[Protocol::Attribute::WS::DATA]
259
314
  when Protocol::WS_CLOSE
260
315
  return
261
316
  else
@@ -15,7 +15,7 @@ module DigitalFabric
15
15
  route[:executive] = true
16
16
  @service.mount(route, self)
17
17
  @current_request_count = 0
18
- @updater = spin_loop(interval: 10) { update_service_stats }
18
+ # @updater = spin_loop(:executive_updater, interval: 10) { update_service_stats }
19
19
  update_service_stats
20
20
  end
21
21
 
@@ -33,9 +33,13 @@ module DigitalFabric
33
33
  req.respond(message.to_json, { 'Content-Type' => 'text.json' })
34
34
  when '/stream/stats'
35
35
  stream_stats(req)
36
+ when '/upload'
37
+ req.respond("body: #{req.read.inspect}")
36
38
  else
37
39
  req.respond('Invalid path', { ':status' => Qeweney::Status::NOT_FOUND })
38
40
  end
41
+ rescue => e
42
+ puts "Error: #{e.inspect}"
39
43
  ensure
40
44
  @current_request_count -= 1
41
45
  end
@@ -43,7 +47,7 @@ module DigitalFabric
43
47
  def stream_stats(req)
44
48
  req.send_headers({ 'Content-Type' => 'text/event-stream' })
45
49
 
46
- @service.timer.every(10) do
50
+ every(10) do
47
51
  message = last_service_stats
48
52
  req.send_chunk(format_sse_event(message.to_json))
49
53
  end
@@ -4,6 +4,7 @@ module DigitalFabric
4
4
  module Protocol
5
5
  PING = 'ping'
6
6
  SHUTDOWN = 'shutdown'
7
+ UNMOUNT = 'unmount'
7
8
 
8
9
  HTTP_REQUEST = 'http_request'
9
10
  HTTP_RESPONSE = 'http_response'
@@ -19,16 +20,75 @@ module DigitalFabric
19
20
  WS_DATA = 'ws_data'
20
21
  WS_CLOSE = 'ws_close'
21
22
 
23
+ TRANSFER_COUNT = 'transfer_count'
24
+
25
+ STATS_REQUEST = 'stats_request'
26
+ STATS_RESPONSE = 'stats_response'
27
+
22
28
  SEND_TIMEOUT = 15
23
29
  RECV_TIMEOUT = SEND_TIMEOUT + 5
24
30
 
31
+ module Attribute
32
+ KIND = 0
33
+ ID = 1
34
+
35
+ module HttpRequest
36
+ HEADERS = 2
37
+ BODY_CHUNK = 3
38
+ COMPLETE = 4
39
+ end
40
+
41
+ module HttpResponse
42
+ BODY = 2
43
+ HEADERS = 3
44
+ COMPLETE = 4
45
+ TRANSFER_COUNT_KEY = 5
46
+ end
47
+
48
+ module HttpUpgrade
49
+ HEADERS = 2
50
+ end
51
+
52
+ module HttpGetRequestBody
53
+ LIMIT = 2
54
+ end
55
+
56
+ module HttpRequestBody
57
+ BODY = 2
58
+ COMPLETE = 3
59
+ end
60
+
61
+ module ConnectionData
62
+ DATA = 2
63
+ end
64
+
65
+ module WS
66
+ HEADERS = 2
67
+ DATA = 2
68
+ end
69
+
70
+ module TransferCount
71
+ KEY = 1
72
+ RX = 2
73
+ TX = 3
74
+ end
75
+
76
+ module Stats
77
+ STATS = 2
78
+ end
79
+ end
80
+
25
81
  class << self
26
82
  def ping
27
- { kind: PING }
83
+ [ PING ]
28
84
  end
29
85
 
30
86
  def shutdown
31
- { kind: SHUTDOWN }
87
+ [ SHUTDOWN ]
88
+ end
89
+
90
+ def unmount
91
+ [ UNMOUNT ]
32
92
  end
33
93
 
34
94
  DF_UPGRADE_RESPONSE = <<~HTTP.gsub("\n", "\r\n")
@@ -42,48 +102,60 @@ module DigitalFabric
42
102
  DF_UPGRADE_RESPONSE
43
103
  end
44
104
 
45
- def http_request(id, req)
46
- { kind: HTTP_REQUEST, id: id, headers: req.headers, body: req.next_chunk, complete: req.complete? }
105
+ def http_request(id, headers, buffered_chunk, complete)
106
+ [ HTTP_REQUEST, id, headers, buffered_chunk, complete ]
47
107
  end
48
108
 
49
- def http_response(id, body, headers, complete)
50
- { kind: HTTP_RESPONSE, id: id, body: body, headers: headers, complete: complete }
109
+ def http_response(id, body, headers, complete, transfer_count_key = nil)
110
+ [ HTTP_RESPONSE, id, body, headers, complete, transfer_count_key ]
51
111
  end
52
112
 
53
113
  def http_upgrade(id, headers)
54
- { kind: HTTP_UPGRADE, id: id }
114
+ [ HTTP_UPGRADE, id, headers ]
55
115
  end
56
116
 
57
117
  def http_get_request_body(id, limit = nil)
58
- { kind: HTTP_GET_REQUEST_BODY, id: id, limit: limit }
118
+ [ HTTP_GET_REQUEST_BODY, id, limit ]
59
119
  end
60
120
 
61
121
  def http_request_body(id, body, complete)
62
- { kind: HTTP_REQUEST_BODY, id: id, body: body, complete: complete }
122
+ [ HTTP_REQUEST_BODY, id, body, complete ]
63
123
  end
64
124
 
65
125
  def connection_data(id, data)
66
- { kind: CONN_DATA, id: id, data: data }
126
+ [ CONN_DATA, id, data ]
67
127
  end
68
128
 
69
129
  def connection_close(id)
70
- { kind: CONN_CLOSE, id: id }
130
+ [ CONN_CLOSE, id ]
71
131
  end
72
132
 
73
133
  def ws_request(id, headers)
74
- { kind: WS_REQUEST, id: id, headers: headers }
134
+ [ WS_REQUEST, id, headers ]
75
135
  end
76
136
 
77
137
  def ws_response(id, headers)
78
- { kind: WS_RESPONSE, id: id, headers: headers }
138
+ [ WS_RESPONSE, id, headers ]
79
139
  end
80
140
 
81
141
  def ws_data(id, data)
82
- { id: id, kind: WS_DATA, data: data }
142
+ [ WS_DATA, id, data ]
83
143
  end
84
144
 
85
145
  def ws_close(id)
86
- { id: id, kind: WS_CLOSE }
146
+ [ WS_CLOSE, id ]
147
+ end
148
+
149
+ def transfer_count(key, rx, tx)
150
+ [ TRANSFER_COUNT, key, rx, tx ]
151
+ end
152
+
153
+ def stats_request(id)
154
+ [ STATS_REQUEST, id ]
155
+ end
156
+
157
+ def stats_response(id, stats)
158
+ [ STATS_RESPONSE, id, stats ]
87
159
  end
88
160
  end
89
161
  end