Hagia
log in
morj / pokesz
overview
files
history
wiki
Viewing at
-module(websocket_handler).
-behavior(cowboy_handler).

-export([client_manager/0, init/2, websocket_init/1, websocket_handle/2, websocket_info/2, terminate/3]).

init(Req, State) ->
case cowboy_req:parse_header(<<"upgrade">>, Req) of
[<<"websocket">>] ->
{cowboy_websocket, Req, State};
_ ->
% Serve index page when upgrade not demanded
{ok, Body} = file:read_file(code:priv_dir(erlang_server) ++ "/client/host.html"),
Req2 = cowboy_req:reply(200, #{<<"content-type">> => <<"text/html">>}, Body, Req),
{ok, Req2, State}
end.

%% State is:
%% - ClientManager - handle to client_manager_loop, shared with all connections, set in advance
%% - ClientId - id of this client, local to this process. Set it
%% websocket_info, upon 'room_created' response received after a 'new_room'
%% request is sent in 'websocket_init'

websocket_init({ClientManager}) ->
ClientManager ! {new_room, self()},
%% Client manger respons with 'room_created', handled in 'websocket_info'
{[], {ClientManager, no_id_assigned}}.

websocket_handle(Data, State) ->
{ClientManager, ClientId} = State,
if
ClientId =:= no_id_assigned ->
throw("No id assigned to client when it sends a message");
true -> ok
end,

%% We may receive different kinds of data and need to handle that by hand
Msg = case Data of
{text, Text} -> json:decode(Text);
{binary, Text} -> json:decode(Text); %% I do hope this actually works
ping -> #{};
pong -> #{}
end,
io:format("~nwebsocket_handle| decoded data: ~p~n", [Msg]),

{TargetId, Body} = case Msg of
#{ <<"targetId">> := Tid
, <<"body">> := B
}
-> {Tid, B};
_ -> throw(bad_client_message)
end,

Type = maps:get(<<"t">>, Body),
case Type of
<<"ice">> ->
ClientManager ! {forward_ice, TargetId, ClientId, Body};
<<"sdp">> ->
ClientManager ! {forward_sdp, TargetId, ClientId, Body};
<<"joinRoom">> ->
RoomId = maps:get(<<"roomId">>, Body),
ClientManager ! {join_room, RoomId, ClientId}
end,
{[], State}.


websocket_info(Msg, State) ->
case Msg of
{room_created, RoomId, ClientId} ->
io:format("Created room for new connection: clientId = ~p, roomId = ~p~n", [ClientId, RoomId]),
Resp =
#{ senderId => ClientId
, body =>
#{ t => <<"identity">>
, roomId => RoomId
}
},
%% Remember the client id for this connection
{ClientManager, no_id_assigned} = State, %% fail if id was assigned
NewState = {ClientManager, ClientId},
%% Send the identity to the client
io:format("Sending id to client: ~p~n", [Resp]),
{[{text, json:encode(Resp)}], NewState};
{watcher_left, PartyId} ->
%% Notify the client that the watcher has left
Notif =
#{ senderId => PartyId
, body => #{ t => <<"leaveRoom">> }
},
io:format("Sending watcher left to client: ~p~n", [Notif]),
{[{text, json:encode(Notif)}], State};
{room_deleted, HostId} ->
%% Notify the client that the room it's in has been deleted
Notif =
#{ senderId => HostId
, body => #{ t => <<"leaveRoom">> }
},
io:format("Sending host left to client: ~p~n", [Notif]),
{[{text, json:encode(Notif)}], State};
{forward_ice, SenderId, Body} ->
Notif =
#{ senderId => SenderId
, body => Body
},
io:format("Sending ice to client: ~p~n", [Notif]),
{[{text, json:encode(Notif)}], State};
{forward_sdp, SenderId, Body} ->
Notif =
#{ senderId => SenderId
, body => Body
},
io:format("Sending sdp to client: ~p~n", [Notif]),
{[{text, json:encode(Notif)}], State};
{party_joins, ClientId} ->
Notif =
#{ senderId => ClientId
, body => #{ t => <<"partyJoins">> }
},
io:format("Sending join to client: ~p~n", [Notif]),
{[{text, json:encode(Notif)}], State};
{join_success, SelfId} -> %% I could extract self id from state, but that's less convenient
Notif =
#{ senderId => SelfId
, body =>
#{ t => <<"joinRoom">>
, status => <<"success">>
}
},
io:format("Sending join success to client: ~p~n", [Notif]),
{[{text, json:encode(Notif)}], State};
{join_failure, SelfId} -> %% I could extract self id from state, but that's less convenient
Notif =
#{ senderId => SelfId
, body =>
#{ t => <<"joinRoom">>
, status => <<"404">>
}
},
io:format("Sending join failure to client: ~p~n", [Notif]),
{[{text, json:encode(Notif)}], State};
Other ->
io:format("Unexpected message: ~p~n", [Other]),
{[], State}
end.

terminate(_Reason, _Req, State) ->
case State of
{ClientManager, ClientId} ->
io:format("~nWebsocket terminated for party ~p~n", [ClientId]),
ClientManager ! {remove_party, ClientId};
{_ClientManager} -> io:format("~nClient without id disconnected~n", [])
end,
ok.

client_manager() ->
%% Read the wordlist to generate IDs from
WordsPath = os:getenv("WORDLIST", "./words.txt"),
{ok, WordBlob} = file:read_file(WordsPath),
WordList = binary:split(WordBlob, <<"\n">>, [global]),
Words = array:from_list(WordList),
%% Start the main loop
client_manager_loop(Words, #{}, #{}, #{}).

-record(room,
{ host %% String
, watchers %% [String]
}).

%% - Rooms :: map(room_id, record room) -- all room info by id
%% - Parties :: map(party_id, thread handle) -- the thread handling this party
%% - Hosts :: map(party_id, room_id) -- the room in which this party is in
client_manager_loop(Words, Rooms, Parties, Hosts) ->
GetHost = fun(PartyId) ->
RoomId = maps:get(PartyId, Hosts),
Room = maps:get(RoomId, Rooms),
Room#room.host
end,
receive
{new_room, R} ->
NewRoom = make_id(Words),
NewClient = make_id(Words),
R ! {room_created, NewRoom, NewClient},
client_manager_loop(
Words,
maps:put(NewRoom, #room{host = NewClient, watchers = []}, Rooms),
maps:put(NewClient, R, Parties),
maps:put(NewClient, NewRoom, Hosts));
{remove_party, PartyId} ->
{RoomId, NewHosts} = maps:take(PartyId, Hosts),
NewParties = maps:remove(PartyId, Parties),
%% If the host left before the client, the room doesn't exist anymore
Room = maps:get(RoomId, Rooms, #room{host = nil, watchers = []}),
HostId = Room#room.host,
if
HostId =:= nil ->
%% This room is already closed
client_manager_loop(Words, Rooms, NewParties, NewHosts);
PartyId =:= HostId ->
%% We're the host
%% Notify all watchers that the room is closed
WatcherIds = Room#room.watchers,
Watchers = lists:map(fun(Id) -> maps:get(Id, Parties) end, WatcherIds),
lists:foreach(fun(H) -> H ! {room_deleted, HostId} end, Watchers),
%% Delete the room
NewRooms = maps:remove(RoomId, Rooms),

client_manager_loop(Words, NewRooms, NewParties, NewHosts);
true ->
%% We're a watcher
%% Remove us from the room
NewWatchers = lists:delete(PartyId, Room#room.watchers),
NewRoom = Room#room{ watchers = NewWatchers },
NewRooms = maps:put(RoomId, NewRoom, Rooms),
%% And notify the host
HostProcess = maps:get(HostId, Parties),
HostProcess ! {watcher_left, PartyId},

client_manager_loop(Words, NewRooms, NewParties, NewHosts)
end;
{forward_ice, null, SenderId, Body} ->
%% Watcher message which doesn't include TargetId, needs to be
%% forwarded to the room owner
R = maps:get(GetHost(SenderId), Parties),
R ! {forward_ice, SenderId, Body},
client_manager_loop(Words, Rooms, Parties, Hosts);
{forward_ice, TargetId, SenderId, Body} ->
R = maps:get(TargetId, Parties),
R ! {forward_ice, SenderId, Body},
client_manager_loop(Words, Rooms, Parties, Hosts);
{forward_sdp, null, SenderId, Body} ->
%% Watcher message which doesn't include TargetId, needs to be
%% forwarded to the room owner
R = maps:get(GetHost(SenderId), Parties),

R ! {forward_sdp, SenderId, Body},
client_manager_loop(Words, Rooms, Parties, Hosts);
{forward_sdp, TargetId, SenderId, Body} ->
%% Host to client, target id is specified by the host itself
R = maps:get(TargetId, Parties),
R ! {forward_sdp, SenderId, Body},
client_manager_loop(Words, Rooms, Parties, Hosts);
{join_room, RoomId, ClientId} ->
OldRoomId = maps:get(ClientId, Hosts),
OldRoom = maps:get(OldRoomId, Rooms),
%% Delete old room if we were the host
Rooms_ = if
OldRoom#room.host =:= ClientId ->
%% TODO notify all members
maps:remove(OldRoomId, Rooms);
true -> Rooms
end,

MbRoom = maps:get(RoomId, Rooms_, not_found),
case MbRoom of
not_found ->
R = maps:get(ClientId, Parties),
R ! {join_failure, ClientId},
client_manager_loop(Words, Rooms_, Parties, Hosts);
Room ->
WatchersNew = [ ClientId | Room#room.watchers ],
RoomNew = Room#room{ watchers = WatchersNew },
%% Update the joined room
RoomsNew = maps:put(RoomId, RoomNew, Rooms_),
HostsNew = maps:put(ClientId, RoomId, Hosts),

%% Notify host
HostR = maps:get(Room#room.host, Parties),
HostR ! {party_joins, ClientId},
%% Notify watcher
R = maps:get(ClientId, Parties),
R ! {join_success, ClientId},

client_manager_loop(Words, RoomsNew, Parties, HostsNew)
end;
Other ->
io:format("Unexpected message: ~p~n", [Other]),
client_manager_loop(Words, Rooms, Parties, Hosts)
end.

make_id(Words) ->
I1 = rand:uniform(array:size(Words)) - 1,
I2 = rand:uniform(array:size(Words)) - 1,
W1 = array:get(I1, Words),
W2 = array:get(I2, Words),
<< W1/binary, "-", W2/binary >>.