anycable-rack-server 0.0.1 → 0.3.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 +4 -4
- data/LICENSE +1 -1
- data/README.md +101 -15
- data/lib/anycable-rack-server.rb +15 -1
- data/lib/anycable/rack/broadcast_subscribers/base_subscriber.rb +41 -0
- data/lib/anycable/rack/broadcast_subscribers/http_subscriber.rb +44 -0
- data/lib/anycable/rack/broadcast_subscribers/redis_subscriber.rb +57 -0
- data/lib/anycable/{rack-server → rack}/coders/json.rb +4 -2
- data/lib/anycable/rack/config.rb +21 -0
- data/lib/anycable/rack/connection.rb +197 -0
- data/lib/anycable/{rack-server → rack}/errors.rb +1 -2
- data/lib/anycable/{rack-server → rack}/hub.rb +42 -1
- data/lib/anycable/{rack-server → rack}/logging.rb +2 -2
- data/lib/anycable/rack/middleware.rb +95 -0
- data/lib/anycable/{rack-server → rack}/pinger.rb +4 -5
- data/lib/anycable/rack/railtie.rb +31 -0
- data/lib/anycable/rack/rpc/client.rb +70 -0
- data/lib/anycable/{rack-server → rack}/rpc/rpc.proto +18 -4
- data/lib/anycable/rack/server.rb +117 -0
- data/lib/anycable/{rack-server → rack}/socket.rb +21 -28
- data/lib/anycable/{rack-server → rack}/version.rb +2 -2
- metadata +106 -40
- data/lib/anycable/rack-server.rb +0 -107
- data/lib/anycable/rack-server/broadcast_subscribers/redis_subscriber.rb +0 -40
- data/lib/anycable/rack-server/connection.rb +0 -189
- data/lib/anycable/rack-server/middleware.rb +0 -82
- data/lib/anycable/rack-server/rpc/client.rb +0 -42
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 61ac3aaa885caabfc4e6761dc77f3c8f792d966c13dc598b6c100c58c160c334
|
4
|
+
data.tar.gz: f1317d8d0b6d38266bd8c9f6866c2d4e1e7e24225b758d2e8c06ef6176251cc4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 577a63b90c45172860302d22893db7828f748a80dc3fe8443a819c78b4c91eed10d61fecf70e6d8aeb00f50daa667cb31d9dcfe661d73e20ae65027ebbdb6a87
|
7
|
+
data.tar.gz: 4d1c8fa0810a6ea93ebebcbbc868e0713e648cebfe1af0d6761205b396b0a44cc2c79b716f50d740314c8d8faa382f873124e60275430563faa8460e019754b6
|
data/LICENSE
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
MIT License
|
2
2
|
|
3
|
-
Copyright (c)
|
3
|
+
Copyright (c) 2019-2020 Yulia Oletskaya, Vladimir Dementyev
|
4
4
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
data/README.md
CHANGED
@@ -1,37 +1,123 @@
|
|
1
|
+
[](https://cultofmartians.com/tasks/anycable-ruby-server.html)
|
2
|
+
[](https://rubygems.org/gems/anycable-rack-server)
|
3
|
+
[](https://github.com/anycable/anycable-rack-server/actions)
|
4
|
+
|
1
5
|
# anycable-rack-server
|
2
6
|
|
3
|
-
AnyCable-compatible Rack hijack based Ruby Web Socket server
|
7
|
+
[AnyCable](https://anycable.io)-compatible Rack hijack based Ruby Web Socket server designed for development and testing purposes.
|
4
8
|
|
5
|
-
##
|
9
|
+
## Using with Rack
|
6
10
|
|
7
|
-
Mount the rack middleware
|
8
11
|
```ruby
|
9
|
-
#
|
10
|
-
|
11
|
-
|
12
|
+
# Initialize server instance first.
|
13
|
+
ws_server = AnyCable::Rack::Server.new
|
14
|
+
|
15
|
+
app = Rack::Builder.new do
|
16
|
+
map "/cable" do
|
17
|
+
run ws_server
|
18
|
+
end
|
12
19
|
end
|
20
|
+
|
21
|
+
# NOTE: don't forget to call `start!` method
|
22
|
+
ws_server.start!
|
23
|
+
|
24
|
+
run app
|
13
25
|
```
|
14
26
|
|
15
|
-
##
|
27
|
+
## Usage with Rails
|
28
|
+
|
29
|
+
Add `gem "anycable-rack-server"` to you `Gemfile` and make sure your Action Cable adapter is set to `:any_cable`. That's it! We automatically start AnyCable Rack server for your at `/cable` path.
|
30
|
+
|
31
|
+
## Configuration
|
16
32
|
|
17
|
-
|
33
|
+
AnyCable Rack Server uses [`anyway_config`](https://github.com/palkan/anyway_config) gem for configuration; thus it is possible to set configuration parameters through environment vars (prefixed with `ANYCABLE_`), `config/anycable.yml` file or `secrets.yml` when using Rails.
|
34
|
+
|
35
|
+
**NOTE:** AnyCable Rack Server uses the same config name (i.e., env prefix, YML file name, etc.) as AnyCable itself.
|
36
|
+
|
37
|
+
You can pass a config object as the option to `AnyCable::Rack::Server.new`:
|
38
|
+
|
39
|
+
```ruby
|
40
|
+
server = AnyCable::Server::Rack.new(config: AnyCable::Rack::Config.new(**params))
|
41
|
+
```
|
42
|
+
|
43
|
+
If no config is passed, a default, global, configuration would be used (`AnyCable::Rack.config`).
|
44
|
+
|
45
|
+
When using Rails, `config.anycable_rack` points to `AnyCable::Rack.config`.
|
46
|
+
|
47
|
+
### Headers
|
48
|
+
|
49
|
+
You can customize the headers being sent with each gRPC request.
|
18
50
|
|
19
51
|
Default headers: `'cookie', 'x-api-token'`.
|
20
52
|
|
21
|
-
Can be specified via
|
53
|
+
Can be specified via configuration:
|
54
|
+
|
55
|
+
```ruby
|
56
|
+
AnyCable::Rack.config.headers = ["cookie", "x-my-header"]
|
57
|
+
```
|
58
|
+
|
59
|
+
Or in Rails:
|
60
|
+
|
61
|
+
```ruby
|
62
|
+
# <environment>.rb
|
63
|
+
config.any_cable_rack.headers = %w[cookie]
|
64
|
+
```
|
65
|
+
|
66
|
+
### Rails-specific options
|
67
|
+
|
68
|
+
```ruby
|
69
|
+
# Mount WebSocket server at the specified path
|
70
|
+
config.any_cable_rack.mount_path = "/cable"
|
71
|
+
# NOTE: here we specify only the port (we assume that a server is running locally)
|
72
|
+
config.any_cable_rack.rpc_port = 50051
|
22
73
|
```
|
23
|
-
|
74
|
+
|
75
|
+
## Broadcast adapters
|
76
|
+
|
77
|
+
AnyCable Rack supports Redis (default) and HTTP broadcast adapters (see [the documentation](https://docs.anycable.io/#/ruby/broadcast_adapters)).
|
78
|
+
|
79
|
+
Broadcast adapter is inherited from AnyCable configuration (so, you don't need to configure it twice).
|
80
|
+
|
81
|
+
### Using HTTP broadcast adapter
|
82
|
+
|
83
|
+
### With Rack
|
84
|
+
|
85
|
+
```ruby
|
86
|
+
AnyCable::Rack.config.broadast_adapter = :http
|
87
|
+
|
88
|
+
ws_server = AnyCable::Rack::Server
|
89
|
+
|
90
|
+
app = Rack::Builder.new do
|
91
|
+
map "/cable" do
|
92
|
+
run ws_server
|
93
|
+
end
|
94
|
+
|
95
|
+
map "/_anycable_rack_broadcast" do
|
96
|
+
run ws_server.broadcast
|
97
|
+
end
|
98
|
+
end
|
24
99
|
```
|
25
100
|
|
26
|
-
|
101
|
+
### With Rails
|
102
|
+
|
103
|
+
By default, we mount broadcasts endpoint at `/_anycable_rack_broadcast`.
|
104
|
+
|
105
|
+
You can change this setting:
|
27
106
|
|
28
107
|
```ruby
|
29
|
-
|
30
|
-
AnyCable::Rack.new(nil, options)
|
108
|
+
config.any_cable_rack.http_broadcast_path = "/_my_broadcast"
|
31
109
|
```
|
32
110
|
|
111
|
+
**NOTE:** Don't forget to configure `http_broadcast_url` for AnyCable pointing to your web server and the specified broadcast path.
|
112
|
+
|
33
113
|
## Testing
|
34
114
|
|
35
|
-
Run units with `rake`.
|
115
|
+
Run units with `bundle exec rake`.
|
116
|
+
|
117
|
+
## Contributing
|
118
|
+
|
119
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/anycable/anycable-rack-server](https://github.com/anycable/anycable-rack-server).
|
120
|
+
|
121
|
+
## License
|
36
122
|
|
37
|
-
|
123
|
+
The gem is available as open source under the terms of the [MIT License](./LICENSE).
|
data/lib/anycable-rack-server.rb
CHANGED
@@ -1,3 +1,17 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
3
|
+
require "anycable/rack/version"
|
4
|
+
require "anycable/rack/config"
|
5
|
+
require "anycable/rack/server"
|
6
|
+
|
7
|
+
module AnyCable
|
8
|
+
module Rack
|
9
|
+
class << self
|
10
|
+
def config
|
11
|
+
@config ||= Config.new
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
require "anycable/rack/railtie" if defined?(Rails)
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module AnyCable
|
6
|
+
module Rack
|
7
|
+
module BroadcastSubscribers
|
8
|
+
class BaseSubscriber
|
9
|
+
include Logging
|
10
|
+
|
11
|
+
attr_reader :hub, :coder
|
12
|
+
|
13
|
+
def initialize(hub:, coder:, **options)
|
14
|
+
@hub = hub
|
15
|
+
@coder = coder
|
16
|
+
end
|
17
|
+
|
18
|
+
def start
|
19
|
+
# no-op
|
20
|
+
end
|
21
|
+
|
22
|
+
def stop
|
23
|
+
# no-op
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def handle_message(msg)
|
29
|
+
log(:debug) { "Received pub/sub message: #{msg}" }
|
30
|
+
|
31
|
+
data = JSON.parse(msg)
|
32
|
+
if data["stream"]
|
33
|
+
hub.broadcast(data["stream"], data["data"], coder)
|
34
|
+
elsif data["command"] == "disconnect"
|
35
|
+
hub.disconnect(data["payload"]["identifier"], data["payload"]["reconnect"])
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module AnyCable
|
6
|
+
module Rack
|
7
|
+
module BroadcastSubscribers
|
8
|
+
# HTTP Pub/Sub subscriber
|
9
|
+
class HTTPSubscriber < BaseSubscriber
|
10
|
+
attr_reader :token, :path
|
11
|
+
|
12
|
+
def initialize(**options)
|
13
|
+
super
|
14
|
+
@token = options[:token]
|
15
|
+
@path = options[:path]
|
16
|
+
end
|
17
|
+
|
18
|
+
def start
|
19
|
+
log(:info) { "Accepting pub/sub request at #{path}" }
|
20
|
+
end
|
21
|
+
|
22
|
+
def call(env)
|
23
|
+
req = ::Rack::Request.new(env)
|
24
|
+
|
25
|
+
return invalid_request unless req.post?
|
26
|
+
|
27
|
+
if token && req.get_header("HTTP_AUTHORIZATION") != "Bearer #{token}"
|
28
|
+
return invalid_request(401)
|
29
|
+
end
|
30
|
+
|
31
|
+
handle_message req.body.read
|
32
|
+
|
33
|
+
[201, {"Content-Type" => "text/plain"}, ["OK"]]
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def invalid_request(code = 422)
|
39
|
+
[code, {"Content-Type" => "text/plain"}, ["Invalid request"]]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
gem "redis", "~> 4"
|
4
|
+
|
5
|
+
require "redis"
|
6
|
+
require "json"
|
7
|
+
|
8
|
+
module AnyCable
|
9
|
+
module Rack
|
10
|
+
module BroadcastSubscribers
|
11
|
+
# Redis Pub/Sub subscriber
|
12
|
+
class RedisSubscriber < BaseSubscriber
|
13
|
+
attr_reader :redis_conn, :thread, :channel
|
14
|
+
|
15
|
+
def initialize(hub:, coder:, channel:, **options)
|
16
|
+
super
|
17
|
+
@redis_conn = ::Redis.new(options)
|
18
|
+
@channel = channel
|
19
|
+
end
|
20
|
+
|
21
|
+
def start
|
22
|
+
subscribe(channel)
|
23
|
+
|
24
|
+
log(:info) { "Subscribed to #{channel}" }
|
25
|
+
end
|
26
|
+
|
27
|
+
def stop
|
28
|
+
thread&.terminate
|
29
|
+
end
|
30
|
+
|
31
|
+
def subscribe(channel)
|
32
|
+
@thread ||= Thread.new do
|
33
|
+
Thread.current.abort_on_exception = true
|
34
|
+
|
35
|
+
redis_conn.without_reconnect do
|
36
|
+
redis_conn.subscribe(channel) do |on|
|
37
|
+
on.subscribe do |chan, count|
|
38
|
+
log(:debug) { "Redis subscriber connected to #{chan} (#{count})" }
|
39
|
+
end
|
40
|
+
|
41
|
+
on.unsubscribe do |chan, count|
|
42
|
+
log(:debug) { "Redis subscribed disconnected from #{chan} (#{count})" }
|
43
|
+
end
|
44
|
+
|
45
|
+
on.message do |_channel, msg|
|
46
|
+
handle_message(msg)
|
47
|
+
rescue
|
48
|
+
log(:error) { "Failed to broadcast message: #{msg}" }
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "anyway_config"
|
4
|
+
|
5
|
+
module AnyCable
|
6
|
+
module Rack
|
7
|
+
class Config < Anyway::Config
|
8
|
+
DEFAULT_HEADERS = %w[cookie x-api-token].freeze
|
9
|
+
|
10
|
+
config_name :anycable
|
11
|
+
env_prefix "ANYCABLE"
|
12
|
+
|
13
|
+
attr_config mount_path: "/cable",
|
14
|
+
headers: DEFAULT_HEADERS,
|
15
|
+
rpc_addr: "localhost:50051",
|
16
|
+
rpc_client_pool_size: 5,
|
17
|
+
rpc_client_timeout: 5,
|
18
|
+
http_broadcast_path: "/_anycable_rack_broadcast"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
require "set"
|
5
|
+
require "json"
|
6
|
+
|
7
|
+
require "anycable/rack/rpc/client"
|
8
|
+
require "anycable/rack/logging"
|
9
|
+
require "anycable/rack/errors"
|
10
|
+
|
11
|
+
module AnyCable
|
12
|
+
module Rack
|
13
|
+
class Connection # :nodoc:
|
14
|
+
include Logging
|
15
|
+
|
16
|
+
attr_reader :coder,
|
17
|
+
:headers,
|
18
|
+
:hub,
|
19
|
+
:socket,
|
20
|
+
:rpc_client,
|
21
|
+
:sid
|
22
|
+
|
23
|
+
def initialize(socket, hub:, coder:, rpc_client:, headers:)
|
24
|
+
@socket = socket
|
25
|
+
@coder = coder
|
26
|
+
@headers = headers
|
27
|
+
@hub = hub
|
28
|
+
@sid = SecureRandom.hex(6)
|
29
|
+
|
30
|
+
@rpc_client = rpc_client
|
31
|
+
|
32
|
+
@_identifiers = "{}"
|
33
|
+
@_subscriptions = Set.new
|
34
|
+
@_istate = {}
|
35
|
+
end
|
36
|
+
|
37
|
+
def handle_open
|
38
|
+
response = rpc_connect
|
39
|
+
process_open(response)
|
40
|
+
end
|
41
|
+
|
42
|
+
def handle_close
|
43
|
+
response = rpc_disconnect
|
44
|
+
process_close(response)
|
45
|
+
reset_connection
|
46
|
+
end
|
47
|
+
|
48
|
+
def handle_command(websocket_message)
|
49
|
+
decoded = decode(websocket_message)
|
50
|
+
command = decoded.delete("command")
|
51
|
+
|
52
|
+
channel_identifier = decoded["identifier"]
|
53
|
+
|
54
|
+
log(:debug) { "Command: #{decoded}" }
|
55
|
+
|
56
|
+
case command
|
57
|
+
when "subscribe" then subscribe(channel_identifier)
|
58
|
+
when "unsubscribe" then unsubscribe(channel_identifier)
|
59
|
+
when "message" then send_message(channel_identifier, decoded["data"])
|
60
|
+
else
|
61
|
+
log(:error, "Command not found #{command}")
|
62
|
+
end
|
63
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
64
|
+
log(:error, "Failed to execute command #{command}: #{e.message}")
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def transmit(cable_message)
|
70
|
+
socket.transmit(encode(cable_message))
|
71
|
+
end
|
72
|
+
|
73
|
+
def close
|
74
|
+
socket.close
|
75
|
+
end
|
76
|
+
|
77
|
+
def request
|
78
|
+
socket.request
|
79
|
+
end
|
80
|
+
|
81
|
+
def rpc_connect
|
82
|
+
rpc_client.connect(headers: headers, url: request.url)
|
83
|
+
end
|
84
|
+
|
85
|
+
def rpc_disconnect
|
86
|
+
rpc_client.disconnect(
|
87
|
+
identifiers: @_identifiers,
|
88
|
+
subscriptions: @_subscriptions.to_a,
|
89
|
+
headers: headers,
|
90
|
+
url: request.url,
|
91
|
+
state: @_cstate,
|
92
|
+
channels_state: @_istate
|
93
|
+
)
|
94
|
+
end
|
95
|
+
|
96
|
+
def rpc_command(command, identifier, data = "")
|
97
|
+
rpc_client.command(
|
98
|
+
command: command,
|
99
|
+
identifier: identifier,
|
100
|
+
connection_identifiers: @_identifiers,
|
101
|
+
data: data,
|
102
|
+
headers: headers,
|
103
|
+
url: request.url,
|
104
|
+
connection_state: @_cstate,
|
105
|
+
state: @_istate[identifier]
|
106
|
+
)
|
107
|
+
end
|
108
|
+
|
109
|
+
def subscribe(identifier)
|
110
|
+
response = rpc_command("subscribe", identifier)
|
111
|
+
if response.status == :SUCCESS
|
112
|
+
@_subscriptions.add(identifier)
|
113
|
+
elsif response.status == :ERROR
|
114
|
+
log(:error, "RPC subscribe command failed: #{response.inspect}")
|
115
|
+
end
|
116
|
+
process_command(response, identifier)
|
117
|
+
end
|
118
|
+
|
119
|
+
def unsubscribe(identifier)
|
120
|
+
response = rpc_command("unsubscribe", identifier)
|
121
|
+
if response.status == :SUCCESS
|
122
|
+
@_subscriptions.delete(identifier)
|
123
|
+
elsif response.status == :ERROR
|
124
|
+
log(:error, "RPC unsubscribe command failed: #{response.inspect}")
|
125
|
+
end
|
126
|
+
process_command(response, identifier)
|
127
|
+
end
|
128
|
+
|
129
|
+
def send_message(identifier, data)
|
130
|
+
response = rpc_command("message", identifier, data)
|
131
|
+
log(:error, "RPC message command failed: #{response.inspect}") if response.status == :ERROR
|
132
|
+
process_command(response, identifier)
|
133
|
+
end
|
134
|
+
|
135
|
+
def process_command(response, identifier)
|
136
|
+
response.transmissions.each { |transmission| transmit(decode(transmission)) }
|
137
|
+
hub.remove_channel(socket, identifier) if response.stop_streams
|
138
|
+
response.streams.each { |stream| hub.add_subscriber(stream, socket, identifier) }
|
139
|
+
response.stopped_streams.each { |stream| hub.remove_subscriber(stream, socket, identifier) }
|
140
|
+
|
141
|
+
@_istate[identifier] ||= {}
|
142
|
+
@_istate[identifier].merge!(response.env.istate&.to_h || {})
|
143
|
+
|
144
|
+
close_connection if response.disconnect
|
145
|
+
end
|
146
|
+
|
147
|
+
def process_open(response)
|
148
|
+
response.transmissions&.each { |transmission| transmit(decode(transmission)) }
|
149
|
+
if response.status == :SUCCESS
|
150
|
+
@_identifiers = response.identifiers
|
151
|
+
@_cstate = response.env.cstate&.to_h || {}
|
152
|
+
hub.add_socket(socket, @_identifiers)
|
153
|
+
log(:debug) { "Opened" }
|
154
|
+
else
|
155
|
+
log(:error, "RPC connection command failed: #{response.inspect}")
|
156
|
+
close_connection
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def process_close(response)
|
161
|
+
if response.status == :SUCCESS
|
162
|
+
log(:debug) { "Closed" }
|
163
|
+
else
|
164
|
+
log(:error, "RPC disconnection command failed: #{response.inspect}")
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def reset_connection
|
169
|
+
@_identifiers = "{}"
|
170
|
+
@_subscriptions = []
|
171
|
+
|
172
|
+
hub.remove_socket(socket)
|
173
|
+
end
|
174
|
+
|
175
|
+
def close_connection
|
176
|
+
reset_connection
|
177
|
+
close
|
178
|
+
end
|
179
|
+
|
180
|
+
def encode(cable_message)
|
181
|
+
coder.encode(cable_message)
|
182
|
+
end
|
183
|
+
|
184
|
+
def decode(websocket_message)
|
185
|
+
coder.decode(websocket_message)
|
186
|
+
end
|
187
|
+
|
188
|
+
def log(level, msg = nil)
|
189
|
+
super(level, msg ? log_fmt(msg) : nil) { log_fmt(yield) }
|
190
|
+
end
|
191
|
+
|
192
|
+
def log_fmt(msg)
|
193
|
+
"[sid=#{sid}] #{msg}"
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|