on master help sign in
wai-websockets
commit1bc4ea4239027d6f4d1bf429365354f719850dd9 authorMichael Snoyman <michael@snoyman.com> dateWed 5 Oct 2011, 22:33 (15 years ago) parentc7b1e53
8 files changed+464
wai-websockets/LICENSE+25 wai-websockets/Network/Wai/Handler/WebSockets.hs+36 wai-websockets/Setup.lhs+7 wai-websockets/client.html+42 wai-websockets/client.js+80 wai-websockets/screen.css+86 wai-websockets/server.lhs+158 wai-websockets/wai-websockets.cabal+30
wai-websockets/LICENSEnew file+25
@@ -0,0 +1,25 @@
1+The following license covers this documentation, and the source code, except
2+where otherwise indicated.
3+
4+Copyright 2010, Suite Solutions. All rights reserved.
5+
6+Redistribution and use in source and binary forms, with or without
7+modification, are permitted provided that the following conditions are met:
8+
9+* Redistributions of source code must retain the above copyright notice, this
10+ list of conditions and the following disclaimer.
11+
12+* Redistributions in binary form must reproduce the above copyright notice,
13+ this list of conditions and the following disclaimer in the documentation
14+ and/or other materials provided with the distribution.
15+
16+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS "AS IS" AND ANY EXPRESS OR
17+IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
18+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
19+EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT,
20+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
21+NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
22+OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
23+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
24+OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
25+ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
wai-websockets/Network/Wai/Handler/WebSockets.hsnew file+36
@@ -0,0 +1,36 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+module Network.Wai.Handler.WebSockets
3+ ( intercept
4+ ) where
5+
6+import Network.Wai (Request, requestHeaders)
7+import Network.Socket (Socket)
8+import Data.Enumerator (Iteratee, Stream (Chunks), yield)
9+import Data.ByteString (ByteString)
10+import Network.WebSockets (WebSockets, runWebSockets)
11+import Network.Socket.Enumerator (iterSocket)
12+import Data.Monoid (mappend)
13+import Blaze.ByteString.Builder (toLazyByteString, copyByteString)
14+import Data.CaseInsensitive (original)
15+import Data.ByteString.Lazy (toChunks)
16+
17+intercept :: WebSockets ()
18+ -> Request
19+ -> Maybe (Socket -> Iteratee ByteString IO ())
20+intercept app req =
21+ case lookup "upgrade" $ requestHeaders req of
22+ Just "websocket" -> Just $ \socket -> do
23+ -- put back the request in the buffer
24+ yield () $ Chunks $ toChunks $ toLazyByteString reqBuilder
25+ runWebSockets app (iterSocket socket)
26+ _ -> Nothing
27+ where
28+ reqBuilder =
29+ copyByteString "GET / HTTP/1.1\r\n" `mappend` -- dummy value
30+ foldr mappend (copyByteString "\r\n") headers
31+ headers = map headerToBuilder $ requestHeaders req
32+ headerToBuilder (k, v) =
33+ copyByteString (original k)
34+ `mappend` copyByteString ": "
35+ `mappend` copyByteString v
36+ `mappend` copyByteString "\r\n"
wai-websockets/Setup.lhsnew file+7
@@ -0,0 +1,7 @@
1+#!/usr/bin/env runhaskell
2+
3+> module Main where
4+> import Distribution.Simple
5+
6+> main :: IO ()
7+> main = defaultMain
wai-websockets/client.htmlnew file+42
@@ -0,0 +1,42 @@
1+<html>
2+ <head>
3+ <title>Haskell WebSockets example</title>
4+ <script type="text/JavaScript"
5+ src="http://code.jquery.com/jquery-1.6.3.min.js"></script>
6+ <script type="text/JavaScript" src="client.js"></script>
7+ <link rel="stylesheet" type="text/css" href="screen.css"></script>
8+ </head>
9+ <body>
10+ <h1>Haskell WebSockets example</h1>
11+ <div id="main">
12+ <div id="warnings">
13+ </div>
14+ <div id="join-section">
15+ <h2>Join</h2>
16+ <form id="join-form" action="">
17+ <label for="user">Username: </label>
18+ <input id="user" type="text" size="12" />
19+ <input id="send" type="submit" value="Join" />
20+ </form>
21+ </div>
22+ <div id="users-section" style="display: none">
23+ <h2>Users</h2>
24+ <ul id="users">
25+ </ul>
26+ </div>
27+ <div id="chat-section" style="display: none">
28+ <h2>Chat</h2>
29+ <div id="messages">
30+ </div>
31+ <br />
32+ <form id="message-form" action="">
33+ <input id="text" type="text" size="40" />
34+ <input id="send" type="submit" value="Send" />
35+ </form>
36+ </div>
37+ </div>
38+ <div id="footer">
39+ Source code available <a href="http://github.com/jaspervdj/websockets/tree/master/example">here</a>
40+ </div>
41+ </body>
42+</html>
wai-websockets/client.jsnew file+80
@@ -0,0 +1,80 @@
1+function createWebSocket(path) {
2+ var host = window.location.hostname;
3+ if(host == '') host = 'localhost';
4+ var uri = 'ws://' + host + ':9160' + path;
5+
6+ var Socket = "MozWebSocket" in window ? MozWebSocket : WebSocket;
7+ return new Socket(uri);
8+}
9+
10+var users = [];
11+
12+function refreshUsers() {
13+ $('#users').html('');
14+ for(i in users) {
15+ $('#users').append($(document.createElement('li')).text(users[i]));
16+ }
17+}
18+
19+function onMessage(event) {
20+ var p = $(document.createElement('p')).text(event.data);
21+
22+ $('#messages').append(p);
23+ $('#messages').animate({scrollTop: $('#messages')[0].scrollHeight});
24+
25+ if(event.data.match(/^[^:]* joined/)) {
26+ var user = event.data.replace(/ .*/, '');
27+ users.push(user);
28+ refreshUsers();
29+ }
30+
31+ if(event.data.match(/^[^:]* disconnected/)) {
32+ var user = event.data.replace(/ .*/, '');
33+ var idx = users.indexOf(user);
34+ users = users.slice(0, idx).concat(users.slice(idx + 1));
35+ refreshUsers();
36+ }
37+}
38+
39+$(document).ready(function () {
40+ $('#join-form').submit(function () {
41+ $('#warnings').html('');
42+ var user = $('#user').val();
43+ var ws = createWebSocket('/');
44+
45+ ws.onopen = function() {
46+ ws.send('Hi! I am ' + user);
47+ };
48+
49+ ws.onmessage = function(event) {
50+ if(event.data.match('^Welcome! Users: ')) {
51+ /* Calculate the list of initial users */
52+ var str = event.data.replace(/^Welcome! Users: /, '');
53+ if(str != "") {
54+ users = str.split(", ");
55+ refreshUsers();
56+ }
57+
58+ $('#join-section').hide();
59+ $('#chat-section').show();
60+ $('#users-section').show();
61+
62+ ws.onmessage = onMessage;
63+
64+ $('#message-form').submit(function () {
65+ var text = $('#text').val();
66+ ws.send(text);
67+ $('#text').val('');
68+ return false;
69+ });
70+ } else {
71+ $('#warnings').append(event.data);
72+ ws.close();
73+ }
74+ };
75+
76+ $('#join').append('Connecting...');
77+
78+ return false;
79+ });
80+});
wai-websockets/screen.cssnew file+86
@@ -0,0 +1,86 @@
1+html {
2+ font-family: sans-serif;
3+ background-color: #335;
4+ font-size: 16px;
5+}
6+
7+body {
8+}
9+
10+h1 {
11+ text-align: center;
12+ font-size: 20px;
13+ color: #fff;
14+ padding: 10px 10px 20px 10px;
15+}
16+
17+h2 {
18+ border-bottom: 1px solid black;
19+ display: block;
20+ font-size: 18px;
21+}
22+
23+div#main {
24+ width: 600px;
25+ margin: 0px auto 0px auto;
26+ padding: 0px;
27+ background-color: #fff;
28+ height: 460px;
29+}
30+
31+div#warnings {
32+ color: red;
33+ font-weight: bold;
34+ margin: 10px;
35+}
36+
37+div#join-section {
38+ float: left;
39+ margin: 10px;
40+}
41+
42+div#users-section {
43+ width: 170px;
44+ float: right;
45+ padding: 0px;
46+ margin: 10px;
47+}
48+
49+ul#users {
50+ list-style-type: none;
51+ padding-left: 0px;
52+ height: 300px;
53+ overflow: auto;
54+}
55+
56+div#chat-section {
57+ width: 390px;
58+ float: left;
59+ margin: 10px;
60+}
61+
62+div#messages {
63+ margin: 0px;
64+ height: 300px;
65+ overflow: auto;
66+}
67+
68+div#messages p {
69+ margin: 0px;
70+ padding: 0px;
71+}
72+
73+div#footer {
74+ text-align: center;
75+ font-size: 12px;
76+ color: #fff;
77+ margin: 10px 0px 30px 0px;
78+}
79+
80+div#footer a {
81+ color: #fff;
82+}
83+
84+div.clear {
85+ clear: both;
86+}
wai-websockets/server.lhsnew file+158
@@ -0,0 +1,158 @@
1+This is the Haskell implementation of the example for the WebSockets library. We
2+implement a simple multi-user chat program.
3+
4+> {-# LANGUAGE OverloadedStrings #-}
5+> import Data.Char (isPunctuation, isSpace)
6+> import Data.Monoid (mappend)
7+> import Data.Text (Text)
8+> import Control.Monad (forM_)
9+> import Control.Concurrent (MVar, newMVar, modifyMVar_, readMVar)
10+> import Control.Monad.IO.Class (liftIO)
11+> import qualified Data.Text as T
12+> import qualified Data.Text.IO as T
13+
14+> import qualified Network.WebSockets as WS
15+> import qualified Network.Wai.Handler.Warp as Warp
16+> import qualified Network.Wai.Handler.WebSockets as WaiWS
17+> import qualified Network.Wai.Application.Static as Static
18+
19+We represent a client by his username and a 'WS.Sender'. We can use this sender
20+to asynchronously send 'Text' to the client later.
21+
22+> type Client = (Text, WS.Sender Text)
23+
24+The state kept on the server is simply a list of connected clients. We've added
25+an alias and some utility functions, so it will be easier to extend this state
26+later on.
27+
28+> type ServerState = [Client]
29+
30+Create a new, initial state
31+
32+> newServerState :: ServerState
33+> newServerState = []
34+
35+Get the number of active clients
36+
37+> numClients :: ServerState -> Int
38+> numClients = length
39+
40+Check if a user already exists (based on username)
41+
42+> clientExists :: Client -> ServerState -> Bool
43+> clientExists client = any ((== fst client) . fst)
44+
45+Add a client (first, you should verify the client is not already connected using
46+'clientExists')
47+
48+> addClient :: Client -> ServerState -> ServerState
49+> addClient client clients = client : clients
50+
51+Remove a client
52+
53+> removeClient :: Client -> ServerState -> ServerState
54+> removeClient client = filter ((/= fst client) . fst)
55+
56+Send a message to all clients, and log it on stdout.
57+
58+> broadcast :: Text -> ServerState -> IO ()
59+> broadcast message clients = do
60+> T.putStrLn message
61+> forM_ clients $ \(_, sender) -> sender WS.textData message
62+
63+The main function first creates a new state for the server, then spawns the
64+actual server. For this purpose, we use the simple server provided by
65+'WS.runServer'.
66+
67+> main :: IO ()
68+> main = do
69+> state <- newMVar newServerState
70+> Warp.runSettings Warp.defaultSettings
71+> { Warp.settingsPort = 9160
72+> , Warp.settingsIntercept = WaiWS.intercept (webSocketsApp state)
73+> } staticApp
74+
75+> staticApp = Static.staticApp Static.defaultFileServerSettings
76+> { Static.ssFolder = Static.fileSystemLookup $ Static.toFilePath "."
77+> }
78+
79+> webSocketsApp state = do
80+
81+When a client connects, we first obtain the request. Once we have that, we use
82+the 'WS.handshake' function to generate a response, which we finally send to the
83+client.
84+
85+We use irrefutable pattern matches here because the server will spawn a thread
86+for each connection -- and a crashing thread does not effect the rest of the
87+program. For a real application, proper logging is appropriate.
88+
89+> Just rq <- WS.receiveRequest
90+> let Right rsp = WS.handshake rq
91+> WS.sendResponse rsp
92+
93+If we want to be able to send data to this client later, from another thread, we
94+obtain a sender. We will add this to the server state later.
95+
96+> sender <- WS.getSender
97+
98+When a client is succesfully connected, we read the first message. This should
99+be in the format of "Hi, I am Jasper", where Jasper is the requested username.
100+
101+> msg <- WS.receiveData
102+> clients <- liftIO $ readMVar state
103+> case msg of
104+> Nothing -> return ()
105+> Just m
106+
107+Check that the first message has the right format
108+
109+> | not (prefix `T.isPrefixOf` m) ->
110+> WS.sendTextData ("Wrong announcement" :: Text)
111+
112+Check the validity of the username
113+
114+> | any ($ fst client)
115+> [T.null, T.any isPunctuation, T.any isSpace] ->
116+> WS.sendTextData ("Name cannot " `mappend`
117+> "contain punctuation or whitespace, and " `mappend`
118+> "cannot be empty" :: Text)
119+
120+Check that the given username is not already taken
121+
122+> | clientExists client clients ->
123+> WS.sendTextData ("User already exists" :: Text)
124+
125+All is right!
126+
127+> | otherwise -> do
128+
129+We send a "Welcome!", according to our own little protocol. We add the client to
130+the list and broadcast the fact that he has joined. Then, we give control to the
131+'talk' function.
132+
133+> liftIO $ modifyMVar_ state $ \s -> do
134+> let s' = addClient client s
135+> sender WS.textData $ "Welcome! Users: " `mappend`
136+> T.intercalate ", " (map fst s)
137+> broadcast (fst client `mappend` " joined") s'
138+> return s'
139+> talk state client
140+> where
141+> prefix = "Hi! I am "
142+> client = (T.drop (T.length prefix) m, sender)
143+
144+The talk function continues to read messages from a single client until he
145+disconnects. All messages are broadcasted to the other clients.
146+
147+> talk :: MVar ServerState -> Client -> WS.WebSockets ()
148+> talk state client@(user, _) = do
149+> msg <- WS.receiveData
150+> case msg of
151+> Nothing -> liftIO $ modifyMVar_ state $ \s -> do
152+> let s' = removeClient client s
153+> broadcast (user `mappend` " disconnected") s'
154+> return s'
155+> Just m -> do
156+> liftIO $ readMVar state >>= broadcast
157+> (user `mappend` ": " `mappend` m)
158+> talk state client
wai-websockets/wai-websockets.cabalnew file+30
@@ -0,0 +1,30 @@
1+Name: wai-websockets
2+Version: 0.4.0
3+Synopsis: Provide a bridge betweeen WAI and the websockets package.
4+License: BSD3
5+License-file: LICENSE
6+Author: Michael Snoyman
7+Maintainer: michael@snoyman.com
8+Homepage: http://github.com/yesodweb/wai
9+Category: Web, Yesod
10+Build-Type: Simple
11+Cabal-Version: >=1.6
12+Stability: Stable
13+Description: This is primarily intended for use with Warp and its settingsIntercept.
14+
15+Library
16+ Build-Depends: base >= 3 && < 5
17+ , bytestring >= 0.9 && < 0.10
18+ , wai >= 0.4 && < 0.5
19+ , enumerator >= 0.4.5 && < 0.5
20+ , network-enumerator >= 0.1.2 && < 0.2
21+ , blaze-builder >= 0.2.1.4 && < 0.4
22+ , case-insensitive >= 0.2 && < 0.4
23+ , network >= 2.2 && < 2.4
24+ , websockets >= 0.3 && < 0.4
25+ Exposed-modules: Network.Wai.Handler.WebSockets
26+ ghc-options: -Wall
27+
28+source-repository head
29+ type: git
30+ location: git://github.com/yesodweb/wai.git