Skip to content

Commit 995683e

Browse files
authored
Merge pull request #67 from diogob/metadata-channel
Add a configuration option to open a channel for metadata
2 parents 9f2cfc4 + 444fd89 commit 995683e

14 files changed

+186
-115
lines changed

CHANGELOG.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
# CHANGELOG
22

3+
## 0.10.0.0
4+
5+
- Add `PGWS_META_CHANNEL` to configure optional metadata channel to send events from the server. Initially the oply event is `ConnectionOpen`.
6+
- Add property `event` to message JSON. Two possible values so far: `ConnectionOpen` and `WebsocketMessage`.
7+
- Breaking change: the property `channel` is not appended to claims anymore. If `channel` is in the original token claims it will still be present.
8+
39
## 0.9.0.0
410

511
- Add @filename semantics to PGWS_DB_URI configiration variable to allow secret management to use a file instead of an environment variable.
6-
- Add PGWS_RETRIES to limit the amount of times the server tries to open a database connection upon startup (defaults to 5). This breaks backward compatibility if you rely on the behaviour of the server to try infitite times.
12+
- Add `PGWS_RETRIES` to limit the amount of times the server tries to open a database connection upon startup (defaults to 5). This breaks backward compatibility if you rely on the behaviour of the server to try infitite times.
713

814
## 0.8.0.1
915

README.md

+15-3
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,10 @@ To use a secure socket (`wss://`) you will need a proxy server like nginx to han
9090
Every message received from the browser will be in JSON format as:
9191
```javascript
9292
{
93-
"claims": { "message_delivered_at": 0.0, "a_custom_claim_from_the_jwt": "your_custom_value" },
93+
"event": "WebsocketMessage",
9494
"channel": "destination_channel",
95-
"payload": "message content"
95+
"payload": "message content",
96+
"claims": { "message_delivered_at": 0.0, "a_custom_claim_from_the_jwt": "your_custom_value" }
9697
}
9798
```
9899

@@ -109,8 +110,19 @@ To send a message to a particular channel on the browser one should notify the p
109110
```sql
110111
SELECT pg_notify(
111112
'postgres-websockets-listener',
112-
json_build_object('channel', 'chat', 'payload', 'test')::text
113+
json_build_object('event', 'WebsocketMessage', 'channel', 'chat', 'payload', 'test')::text
113114
);
114115
```
115116

116117
Where `postgres-websockets-listener` is the database channel used by your instance of postgres-websockets and `chat` is the channel where the browser is connected (the same issued in the JWT used to connect).
118+
119+
## Monitoring Connections
120+
121+
To monitor connection opening one should set the variable `PGWS_META_CHANNEL` which will enable the meta-data messages generation in the server on the channel name specified.
122+
For instamce, if we use the configuration in the [sample-env](./sample-env) we will see messages like the one bellow each time a connection is estabilished (only after the JWT is validated).
123+
124+
```javascript
125+
{"event":"ConnectionOpen","channel":"server-info","payload":"server-info","claims":{"mode":"rw","message_delivered_at":1.602719440727465893e9}}
126+
```
127+
128+
You can monitor these messages on another websocket connection with a proper read token for the channel `server-info` or also having an additional database listener on the `PGWS_LISTEN_CHANNEL`.

client-example/client.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function jwt() {
6464
}
6565

6666
$(document).ready(function () {
67-
var ws = null;
67+
var ws = null, meta = null;
6868

6969
$('#channel').keyup(updateJWT);
7070
updateJWT();
@@ -74,6 +74,10 @@ $(document).ready(function () {
7474
if(ws === null){
7575
var jwt = $('#jwt').val();
7676
var channel = $('#channel').val();
77+
78+
meta = createWebSocket('/server-info/' + jwt);
79+
meta.onmessage = onMessage('#meta-messages');
80+
7781
if(channel == ""){
7882
ws = createWebSocket('/' + jwt);
7983
} else {

client-example/index.html

+3
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ <h2>Chat</h2>
3434
<h2>Messages sent to chat channel</h2>
3535
<div id="messages">
3636
</div>
37+
<h2>Messages sent to meta channel</h2>
38+
<div id="meta-messages">
39+
</div>
3740
</div>
3841
</div>
3942
</body>

postgres-websockets.cabal

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: postgres-websockets
2-
version: 0.9.0.0
2+
version: 0.10.0.0
33
synopsis: Middleware to map LISTEN/NOTIFY messages to Websockets
44
description: Please see README.md
55
homepage: https://github.com/diogob/postgres-websockets#readme
@@ -25,6 +25,7 @@ library
2525
other-modules: Paths_postgres_websockets
2626
, PostgresWebsockets.Server
2727
, PostgresWebsockets.Middleware
28+
, PostgresWebsockets.Context
2829
build-depends: base >= 4.7 && < 5
2930
, hasql-pool >= 0.5 && < 0.6
3031
, text >= 1.2 && < 1.3

sample-env

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export PGWS_ROOT_PATH="./client-example"
1010
## Sends a copy of every message received from websocket clients to the channel specified bellow as an aditional NOTIFY command.
1111
export PGWS_LISTEN_CHANNEL="postgres-websockets-listener"
1212

13+
## Send postgres-websockets server events to this channel (will be sent both to the database and the connected websocket clients)
14+
export PGWS_META_CHANNEL="server-info"
15+
1316
## Host and port on which the websockets server (and the static files server) will be listening.
1417
export PGWS_HOST="*4"
1518
export PGWS_PORT=3000

src/PostgresWebsockets.hs

+3-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ module PostgresWebsockets
1111
, postgresWsMiddleware
1212
) where
1313

14-
import PostgresWebsockets.Middleware
15-
import PostgresWebsockets.Server
16-
import PostgresWebsockets.Config
14+
import PostgresWebsockets.Middleware ( postgresWsMiddleware )
15+
import PostgresWebsockets.Server ( serve )
16+
import PostgresWebsockets.Config ( prettyVersion, loadConfig )

src/PostgresWebsockets/Claims.hs

+6-5
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,16 @@ module PostgresWebsockets.Claims
1111
( ConnectionInfo,validateClaims
1212
) where
1313

14-
import Control.Lens
15-
import qualified Crypto.JOSE.Types as JOSE.Types
16-
import Crypto.JWT
17-
import qualified Data.HashMap.Strict as M
18-
import Protolude
14+
import Protolude
15+
import Control.Lens
16+
import Crypto.JWT
1917
import Data.List
2018
import Data.Time.Clock (UTCTime)
19+
import qualified Crypto.JOSE.Types as JOSE.Types
20+
import qualified Data.HashMap.Strict as M
2121
import qualified Data.Aeson as JSON
2222

23+
2324
type Claims = M.HashMap Text JSON.Value
2425
type ConnectionInfo = ([ByteString], ByteString, Claims)
2526

src/PostgresWebsockets/Config.hs

+2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ data AppConfig = AppConfig {
3131
, configHost :: Text
3232
, configPort :: Int
3333
, configListenChannel :: Text
34+
, configMetaChannel :: Maybe Text
3435
, configJwtSecret :: ByteString
3536
, configJwtSecretIsBase64 :: Bool
3637
, configPool :: Int
@@ -68,6 +69,7 @@ readOptions =
6869
<*> var str "PGWS_HOST" (def "*4" <> helpDef show <> help "Address the server will listen for websocket connections")
6970
<*> var auto "PGWS_PORT" (def 3000 <> helpDef show <> help "Port the server will listen for websocket connections")
7071
<*> var str "PGWS_LISTEN_CHANNEL" (def "postgres-websockets-listener" <> helpDef show <> help "Master channel used in the database to send or read messages in any notification channel")
72+
<*> optional (var str "PGWS_META_CHANNEL" (help "Websockets channel used to send events about the server state changes."))
7173
<*> var str "PGWS_JWT_SECRET" (help "Secret used to sign JWT tokens used to open communications channels")
7274
<*> var auto "PGWS_JWT_SECRET_BASE64" (def False <> helpDef show <> help "Indicate whether the JWT secret should be decoded from a base64 encoded string")
7375
<*> var auto "PGWS_POOL_SIZE" (def 10 <> helpDef show <> help "How many connection to the database should be used by the connection pool")

src/PostgresWebsockets/Context.hs

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{-|
2+
Module : PostgresWebsockets.Context
3+
Description : Produce a context capable of running postgres-websockets sessions
4+
-}
5+
module PostgresWebsockets.Context
6+
( Context (..)
7+
, mkContext
8+
) where
9+
10+
import Protolude
11+
import Data.Time.Clock (UTCTime, getCurrentTime)
12+
import Control.AutoUpdate ( defaultUpdateSettings
13+
, mkAutoUpdate
14+
, updateAction
15+
)
16+
import qualified Hasql.Pool as P
17+
18+
import PostgresWebsockets.Config ( AppConfig(..) )
19+
import PostgresWebsockets.HasqlBroadcast (newHasqlBroadcaster)
20+
import PostgresWebsockets.Broadcast (Multiplexer)
21+
22+
data Context = Context {
23+
ctxConfig :: AppConfig
24+
, ctxPool :: P.Pool
25+
, ctxMulti :: Multiplexer
26+
, ctxGetTime :: IO UTCTime
27+
}
28+
29+
-- | Given a configuration and a shutdown action (performed when the Multiplexer's listen connection dies) produces the context necessary to run sessions
30+
mkContext :: AppConfig -> IO () -> IO Context
31+
mkContext conf@AppConfig{..} shutdown = do
32+
Context conf
33+
<$> P.acquire (configPool, 10, pgSettings)
34+
<*> newHasqlBroadcaster shutdown (toS configListenChannel) configRetries pgSettings
35+
<*> mkGetTime
36+
where
37+
mkGetTime :: IO (IO UTCTime)
38+
mkGetTime = mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}
39+
pgSettings = toS configDatabase

src/PostgresWebsockets/HasqlBroadcast.hs

+6-6
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ import Protolude hiding (putErrLn)
1919

2020
import Hasql.Connection
2121
import Hasql.Notifications
22-
import Data.Aeson (decode, Value(..))
23-
import Data.HashMap.Lazy (lookupDefault)
22+
import Data.Aeson (decode, Value(..))
23+
import Data.HashMap.Lazy (lookupDefault)
2424
import Data.Either.Combinators (mapBoth)
25-
import Data.Function (id)
26-
import Control.Retry (RetryStatus(..), retrying, capDelay, exponentialBackoff)
25+
import Data.Function (id)
26+
import Control.Retry (RetryStatus(..), retrying, capDelay, exponentialBackoff)
2727

2828
import PostgresWebsockets.Broadcast
2929

@@ -99,11 +99,11 @@ newHasqlBroadcasterForChannel onConnectionFailure ch getCon = do
9999
_ -> d
100100
lookupStringDef _ d _ = d
101101
channelDef = lookupStringDef "channel"
102-
openProducer msgs = do
102+
openProducer msgQ = do
103103
con <- getCon
104104
listen con $ toPgIdentifier ch
105105
waitForNotifications
106-
(\c m-> atomically $ writeTQueue msgs $ toMsg c m)
106+
(\c m-> atomically $ writeTQueue msgQ $ toMsg c m)
107107
con
108108

109109
putErrLn :: Text -> IO ()

src/PostgresWebsockets/Middleware.hs

+60-50
Original file line numberDiff line numberDiff line change
@@ -10,49 +10,58 @@ module PostgresWebsockets.Middleware
1010
( postgresWsMiddleware
1111
) where
1212

13-
import qualified Hasql.Pool as H
14-
import qualified Hasql.Notifications as H
15-
import qualified Network.Wai as Wai
13+
import Protolude
14+
import Data.Time.Clock (UTCTime)
15+
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds, posixSecondsToUTCTime)
16+
import Control.Concurrent.AlarmClock (newAlarmClock, setAlarm)
17+
import qualified Hasql.Notifications as H
18+
import qualified Hasql.Pool as H
19+
import qualified Network.Wai as Wai
1620
import qualified Network.Wai.Handler.WebSockets as WS
17-
import qualified Network.WebSockets as WS
18-
import Protolude
19-
20-
import qualified Data.Aeson as A
21-
import qualified Data.ByteString.Char8 as BS
22-
import qualified Data.ByteString.Lazy as BL
23-
import qualified Data.HashMap.Strict as M
24-
import qualified Data.Text.Encoding.Error as T
25-
import Data.Time.Clock (UTCTime)
26-
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds, posixSecondsToUTCTime)
27-
import Control.Concurrent.AlarmClock (newAlarmClock, setAlarm)
28-
import PostgresWebsockets.Broadcast (Multiplexer, onMessage)
29-
import qualified PostgresWebsockets.Broadcast as B
30-
import PostgresWebsockets.Claims
21+
import qualified Network.WebSockets as WS
22+
23+
import qualified Data.Aeson as A
24+
import qualified Data.ByteString.Char8 as BS
25+
import qualified Data.ByteString.Lazy as BL
26+
import qualified Data.HashMap.Strict as M
27+
import qualified Data.Text.Encoding.Error as T
28+
29+
import PostgresWebsockets.Broadcast (onMessage)
30+
import PostgresWebsockets.Claims ( ConnectionInfo, validateClaims )
31+
import PostgresWebsockets.Context ( Context(..) )
32+
import PostgresWebsockets.Config (AppConfig(..))
33+
import qualified PostgresWebsockets.Broadcast as B
34+
35+
36+
data Event =
37+
WebsocketMessage
38+
| ConnectionOpen
39+
deriving (Show, Eq, Generic)
3140

3241
data Message = Message
3342
{ claims :: A.Object
34-
, channel :: Text
43+
, event :: Event
3544
, payload :: Text
45+
, channel :: Text
3646
} deriving (Show, Eq, Generic)
3747

48+
instance A.ToJSON Event
3849
instance A.ToJSON Message
3950

4051
-- | Given a secret, a function to fetch the system time, a Hasql Pool and a Multiplexer this will give you a WAI middleware.
41-
postgresWsMiddleware :: IO UTCTime -> Text -> ByteString -> H.Pool -> Multiplexer -> Wai.Application -> Wai.Application
52+
postgresWsMiddleware :: Context -> Wai.Middleware
4253
postgresWsMiddleware =
43-
WS.websocketsOr WS.defaultConnectionOptions `compose` wsApp
44-
where
45-
compose = (.) . (.) . (.) . (.) . (.)
54+
WS.websocketsOr WS.defaultConnectionOptions . wsApp
4655

4756
-- private functions
4857
jwtExpirationStatusCode :: Word16
4958
jwtExpirationStatusCode = 3001
5059

5160
-- when the websocket is closed a ConnectionClosed Exception is triggered
5261
-- this kills all children and frees resources for us
53-
wsApp :: IO UTCTime -> Text -> ByteString -> H.Pool -> Multiplexer -> WS.ServerApp
54-
wsApp getTime dbChannel secret pool multi pendingConn =
55-
getTime >>= validateClaims requestChannel secret (toS jwtToken) >>= either rejectRequest forkSessions
62+
wsApp :: Context -> WS.ServerApp
63+
wsApp Context{..} pendingConn =
64+
ctxGetTime >>= validateClaims requestChannel (configJwtSecret ctxConfig) (toS jwtToken) >>= either rejectRequest forkSessions
5665
where
5766
hasRead m = m == ("r" :: ByteString) || m == ("rw" :: ByteString)
5867
hasWrite m = m == ("w" :: ByteString) || m == ("rw" :: ByteString)
@@ -85,43 +94,44 @@ wsApp getTime dbChannel secret pool multi pendingConn =
8594
Just _ -> pure ()
8695
Nothing -> pure ()
8796

97+
let sendNotification msg channel = sendMessageWithTimestamp $ websocketMessageForChannel msg channel
98+
sendMessageToDatabase = sendToDatabase ctxPool (configListenChannel ctxConfig)
99+
sendMessageWithTimestamp = timestampMessage ctxGetTime >=> sendMessageToDatabase
100+
websocketMessageForChannel = Message validClaims WebsocketMessage
101+
connectionOpenMessage = Message validClaims ConnectionOpen
102+
103+
case configMetaChannel ctxConfig of
104+
Nothing -> pure ()
105+
Just ch -> sendMessageWithTimestamp $ connectionOpenMessage (toS $ BS.intercalate "," chs) ch
106+
88107
when (hasRead mode) $
89-
forM_ chs $ flip (onMessage multi) $ WS.sendTextData conn . B.payload
108+
forM_ chs $ flip (onMessage ctxMulti) $ WS.sendTextData conn . B.payload
90109

91110
when (hasWrite mode) $
92-
let sendNotifications = void . H.notifyPool pool dbChannel . toS
93-
in notifySession validClaims conn getTime sendNotifications chs
111+
notifySession conn sendNotification chs
94112

95113
waitForever <- newEmptyMVar
96114
void $ takeMVar waitForever
97115

98116
-- Having both channel and claims as parameters seem redundant
99117
-- But it allows the function to ignore the claims structure and the source
100118
-- of the channel, so all claims decoding can be coded in the caller
101-
notifySession :: A.Object
102-
-> WS.Connection
103-
-> IO UTCTime
104-
-> (ByteString -> IO ())
105-
-> [ByteString]
106-
-> IO ()
107-
notifySession claimsToSend wsCon getTime send chs =
119+
notifySession :: WS.Connection -> (Text -> Text -> IO ()) -> [ByteString] -> IO ()
120+
notifySession wsCon sendToChannel chs =
108121
withAsync (forever relayData) wait
109122
where
110-
relayData = do
123+
relayData = do
111124
msg <- WS.receiveData wsCon
112-
forM_ chs (relayChannelData msg . toS)
125+
forM_ chs (sendToChannel msg . toS)
113126

114-
relayChannelData msg ch = do
115-
claims' <- claimsWithTime ch
116-
send $ jsonMsg ch claims' msg
117-
118-
-- we need to decode the bytestring to re-encode valid JSON for the notification
119-
jsonMsg :: Text -> M.HashMap Text A.Value -> ByteString -> ByteString
120-
jsonMsg ch cl = BL.toStrict . A.encode . Message cl ch . decodeUtf8With T.lenientDecode
121-
122-
claimsWithTime :: Text -> IO (M.HashMap Text A.Value)
123-
claimsWithTime ch = do
124-
time <- utcTimeToPOSIXSeconds <$> getTime
125-
return $ M.insert "message_delivered_at" (A.Number $ realToFrac time) (claimsWithChannel ch)
127+
sendToDatabase :: H.Pool -> Text -> Message -> IO ()
128+
sendToDatabase pool dbChannel =
129+
notify . jsonMsg
130+
where
131+
notify = void . H.notifyPool pool dbChannel . toS
132+
jsonMsg = BL.toStrict . A.encode
126133

127-
claimsWithChannel ch = M.insert "channel" (A.String ch) claimsToSend
134+
timestampMessage :: IO UTCTime -> Message -> IO Message
135+
timestampMessage getTime msg@Message{..} = do
136+
time <- utcTimeToPOSIXSeconds <$> getTime
137+
return $ msg{ claims = M.insert "message_delivered_at" (A.Number $ realToFrac time) claims}

0 commit comments

Comments
 (0)