librecast-live
Version:
Live Streaming Video Platform with IPv6 Multicast
991 lines (956 loc) • 26.3 kB
JavaScript
const live = (function () {
const components = [];
let store = {};
let chat = {};
const lctx = new LIBRECAST.Context();
// eslint-disable-next-line no-unused-vars
class Store {
_state = {}
event = {}
get state () {
return this._state
}
set state (newstate) {
this._state = newstate
}
getItem = (key) => {
if (this._state[key] === undefined && window.localStorage.getItem(key) !== null) {
this._state[key] = window.localStorage.getItem(key)
}
return this._state[key]
}
mutate = (key, value, cacheLocal) => {
console.log(`mutating ${key}`)
this._state[key] = value
if (cacheLocal === true) {
window.localStorage.setItem(key, value)
}
this.publish('state.' + key)
}
publish = (event) => {
console.log(`publish ${event}`)
if (this.event[event] instanceof Event) {
document.dispatchEvent(this.event[event])
}
}
subscribe = (event, listener) => {
console.log(`subscribing to ${event}`)
if (this.event[event] === undefined) {
console.log('new event created: ' + event)
this.event[event] = new Event(event)
}
document.addEventListener(event, listener)
}
unsubscribe = (event, listener) => {
console.log(`unsubscribing from ${event}`)
document.removeEventListener(event, listener)
}
}
// eslint-disable-next-line no-unused-vars
class Chat {
constructor (lctx) {
this.lctx = lctx
this.sockets = {}
this.channels = {}
this.sockidx = {}
this.lastmsgs = []
this.lastmsg = 0
store.subscribe('state.chat', this.chatStateUpdated)
store.subscribe('state.activeChannel', this.changeChannel)
}
chatStateUpdated () {
console.log('Chat.chatStateUpdated')
}
changeChannel = () => {
console.log('Chat.changeChannel')
if (this.channels[store.state.activeChannel] === undefined) {
this.join(store.state.activeChannel)
}
}
join = channelName => {
this.lctx.onconnect.then(() => {
const p = []
this.sockets[channelName] = new LIBRECAST.Socket(this.lctx)
this.channels[channelName] = new LIBRECAST.Channel(this.lctx, channelName)
p.push(this.sockets[channelName].oncreate)
p.push(this.channels[channelName].oncreate)
Promise.all(p).then(() => {
console.log('socket and channel ready')
this.sockidx[this.sockets[channelName].id] = channelName
store.mutate(`chat.channels.${channelName}.status`, 'ready')
this.channels[channelName].bind(this.sockets[channelName])
.then(() => {
this.channels[channelName].join()
})
this.sockets[channelName].listen(this.packetDecode)
})
})
}
isDuplicate = msg => {
const hash = sodium.crypto_generichash(64, sodium.from_string(JSON.stringify(msg)))
const hex = sodium.to_hex(hash)
for (let i = 0; i < 100; i++) {
if (this.lastmsgs[i] === undefined) break
if (this.lastmsgs[i] === hex) {
console.warn('suppressing duplicate message')
return true
}
}
console.log('hashed msg' + this.lastmsg)
this.lastmsgs[this.lastmsg++] = hex
if (this.lastmsg > 99) { this.lastmsg = 0 }
return false
}
findUser = (channelName, key) => {
console.log("searching for key '" + key + "'")
return store.state.chat.channels[channelName].users.find(user => user.key === key)
}
packetDecode = pkt => {
console.log('message received, decoding')
const msg = new LIBRECAST.Message(pkt)
const chatState = store.state.chat
const decoder = new TextDecoder('utf-8')
const newmsg = JSON.parse(decoder.decode(new Uint8Array(pkt.payload)))
console.log('message received on socket ' + pkt.id)
// de-duplicate msgs
// we shouldn't be receiving duplicates, but as this is UDP, we might
if (this.isDuplicate(newmsg)) { return }
// messages must have user
if (newmsg.user === undefined) { return }
newmsg.received = Date.now()
// identify channel for this socket
const channelName = this.sockidx[pkt.id]
console.log(`socket ${pkt.id} => channel '${channelName}'`)
chatState.channels[channelName].msgs.push(newmsg)
// append user to user list for channel
if (newmsg.user.key !== undefined && !this.findUser(channelName, newmsg.user.key)) {
chatState.channels[channelName].users.push(newmsg.user)
}
// update stats
if (chatState.channels[channelName].bytin === undefined) {
chatState.channels[channelName].bytin = 0
}
chatState.channels[channelName].bytin += pkt.len
store.mutate('chat', chatState)
}
send = (channelName, msgText) => {
console.log(`${channelName}: '${msgText}'`)
const user = {
nick: store.state.nick,
key: 1234
}
const msg = {
timestamp: Date.now(),
username: store.state.nick,
user,
msg: msgText,
id: this.lctx.token
}
const jstr = JSON.stringify(msg)
this.channels[channelName].send(jstr)
// update stats
const chatState = store.state.chat
if (chatState.channels[channelName].bytout === undefined) {
chatState.channels[channelName].bytout = 0
}
chatState.channels[channelName].bytout += jstr.length
store.mutate('chat', chatState)
}
}
class Component {
componentDidMount = () => {
}
render = () => {
const range = document.createRange()
const frag = range.createContextualFragment(this.template)
const sevents = []
// add all events we allow in components
sevents.push('click')
sevents.push('keypress')
for (let child = frag.firstChild; child; child = child.nextElementSibling) {
// component event handlers
let tree = document.createTreeWalker(child, NodeFilter.SHOW_ELEMENT)
for (let node = tree.currentNode; node; node = tree.nextNode()) {
sevents.forEach(e => {
if (node.attributes[e]) {
node.addEventListener(e, (evt) => {
this[node.attributes[e].value](evt)
})
}
})
};
// search fragment for more components before inserting
const q = []
tree = document.createTreeWalker(child, NodeFilter.SHOW_ELEMENT)
for (let node = tree.currentNode; node; node = tree.nextNode()) {
components.filter(c => c.nodeName === node.nodeName)
.forEach(c => {
const component = new c.component()
component.node = node
component.parent = this
component.props = [];
[...node.attributes].forEach(a => {
component.props[a.name] = a.value
})
q.push(component)
})
}
while (q.length > 0) {
const c = q.shift()
console.log('rendering component: ' + c.nodeName)
c.render()
};
}
const newnode = frag.firstChild
this.node.replaceWith(...frag.children)
this.node = newnode
this.componentDidMount()
}
}
class App extends Component {
nodeName = 'APP';
constructor() {
super();
}
get template() {
return `<div class="outerspace">
<header></header>
<middle></middle>
<footbox></footbox>
</div>
`
}
}
class Avatar extends Component {
nodeName = 'AVATAR';
constructor() {
super();
}
get template() {
//let html = `<img src="${this.props.src}" />`;
let html = `<img class="avatar" src="/media/librestack.svg" height="30" width="30" />`;
return html;
}
}
class ChannelListItem extends Component {
nodeName = 'CHANNELLISTITEM';
constructor() {
super();
}
switchChannel(e) {
const chan = e.target.attributes.channel.value;
store.mutate("activeChannel", chan);
}
get template() {
const channel = this.props.channel;
let classes = 'channellistitem';
if (store.state.activeChannel === channel) {
classes += " active";
}
if (chat.channels[channel] !== undefined) {
classes += ' ready';
}
return `<li class="${classes}" click="switchChannel" channel="${channel}">${channel}</li>`;
}
componentDidMount = (() => {
store.subscribe(`state.chat.channels.${this.props.channel}.status`, this.render);
});
}
class ChannelList extends Component {
nodeName = 'CHANNELLIST';
constructor() {
super();
}
get template() {
let html = `<div class="channellist">
<h2>Channels</h2>
<ul>`;
//store.state.chat.channels.forEach(chan => {
for (const [key, value] of Object.entries(store.state.chat.channels)) {
html += `<channellistitem channel="${key}"></channellistitem>`
};
html += `</ul></div>`;
return html;
}
}
class ChatBar extends Component {
nodeName = 'CHATBAR';
constructor() {
super();
}
clear() {
this.node.firstElementChild.value = "";
this.node.firstElementChild.focus();
}
keypress(e) {
if (e.keyCode === 13) {
this.newMsg();
this.clear();
}
}
newMsg() {
const str = he.encode(this.node.firstElementChild.value);
const msgText = `<p>${str}</p>`;
chat.send(store.state.activeChannel, msgText);
}
get template() {
let html = `<div class="chatbar">
<input type="text" keypress="keypress" />
</div>`;
return html;
}
}
class ChatBox extends Component {
nodeName = 'CHATBOX';
constructor() {
super();
store.subscribe('state.activeChannel', this.render);
store.subscribe('state.showStats', this.render);
}
close() {
store.mutate('showChat', undefined);
}
get template() {
let html = `<div class="chatbox">
<chattopic></chattopic>
<section>
<channellist></channellist>
<chatpane></chatpane>
<!--userlist></userlist-->`;
if (store.state.showStats) {
html += `<stats></stats>`;
}
html += `</section>
</div>`;
return html;
}
}
class ChatMsgs extends Component {
nodeName = 'CHATMSGS';
constructor() {
super();
store.subscribe('state.chat', this.render);
}
componentDidMount = () => {
this.node.scrollTop = this.node.scrollHeight;
}
get template() {
let html = `<div class="chatmsgs">`;
if (store.state.activeChannel !== undefined) {
const chan = store.state.chat.channels[store.state.activeChannel];
const opt = {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: 'numeric', second: 'numeric',
hour12: false,
};
chan.msgs.forEach(msg => {
const sent = new Intl.DateTimeFormat('default', opt).format(msg.timestamp);
html += `<div class="chatmsg">`;
html += `<div class="msghdr">`;
html += `<avatar src="/media/${msg.username}.jpg"></avatar>`;
html += `<div class="username">${msg.username}</div>`;
html += `<div class="timestamp">${sent}</div>`;
html += `</div>`;
html += `<section>${msg.msg}</section>`;
html += `</div>`;
});
}
html += `</div>`;
return html;
}
}
class ChatPane extends Component {
nodeName = 'CHATPANE';
constructor() {
super();
store.subscribe('state.activeChannel', this.render);
}
get template() {
console.log(store.state.activeChannel);
let html = `<div class="chatpane">
<h2>#${store.state.activeChannel}</h2>
<chatmsgs></chatmsgs>
<chatbar></chatbar>
</div>`;
return html;
}
}
class ChatTopic extends Component {
nodeName = 'CHATTOPIC';
constructor() {
super();
}
get template() {
let html = `<div class="chattopic">
Multicast Chat
</div>`;
return html;
}
}
class CloseX extends Component {
nodeName = 'CLOSEX';
constructor() {
super();
}
close() {
console.log("closeX clicked");
store.mutate("closex", true);
}
get template() {
return `<div class="closex" click="close">X</div>`;
}
}
class Dialog extends Component {
nodeName = 'DIALOG';
constructor() {
super();
store.subscribe('state.closex', this.close);
}
close() {
console.log("closex clicked");
}
get template() {
return `<div class="dialog">
<closex></closex>
${this.content}
</div>`;
}
}
class FootBox extends Component {
nodeName = 'FOOTBOX';
get template() {
return `<div class="footbox">
<p class="invisible"> </p><!-- clear footer in text browsers -->
</div>`;
}
}
class Header extends Component {
nodeName = 'HEADER';
constructor() {
super();
}
get template() {
return `<div class="header">
<logobox></logobox>
<projectbox></projectbox>
<loginmenu></loginmenu>
</div>`;
}
}
class LoginMenu extends Component {
nodeName = 'LOGINMENU';
connectButtons = `
<button class="login" click='showLogin' title="Log In"></button>
`;
logoutButtons = `
<button class="stats" click='stats' title="Stats"></button>
<button class="logout" click='logout' title="Log Out"></button>
<button class="profile" click='showProfile' title="Edit Profile"></button>
`;
constructor() {
super();
store.subscribe('state.loggedIn', this.render);
}
logout() {
console.log("logging out");
store.mutate('loggedIn', undefined);
}
stats() {
store.mutate('showStats', !store.state.showStats);
}
showDialog(mode) {
store.mutate('showSignup', mode);
}
showLogin() {
this.showDialog('login');
}
showProfile() {
store.mutate('showProfile', 'profileGeneral');
}
showSignup() {
this.showDialog('signup');
}
get template() {
this.content = (store.state.loggedIn) ? this.logoutButtons : this.connectButtons;
return `<div class="loginmenu">
${this.content}
</div>`;
}
}
class LogoBox extends Component {
nodeName = 'LOGOBOX';
constructor() {
super();
}
get template() {
return `<div class="logobox">
<img class="logo" src="/media/live.svg" height="100" width="100" alt="" />
</div>`;
}
}
class MenuBox extends Component {
nodeName = 'MENUBOX';
constructor() {
super();
}
get template() {
return `<div class="menubox">
<nav>
<ul>
<!--
<li><a href="/about.html">About</a></li>
<li><a href="/code.html">Code</a></li>
<li><a href="/roadmap.html">Roadmap</a></li>
<li><a href="/team.html">Team</a></li>
<li><a href="/videos.html">Videos</a></li>
-->
</ul>
</nav>
</div>`;
}
}
class Middle extends Component {
nodeName = 'MIDDLE';
constructor() {
super();
store.subscribe('state.showSignup', this.render);
store.subscribe('state.showProfile', this.render);
store.subscribe('state.showChat', this.render);
store.subscribe('state.loggedIn', this.render);
}
get template() {
let html = `<div class="middle">`;
if (store.state.showSignup !== undefined) {
html += `<signupbox></signupbox>`;
}
if (store.state.loggedIn !== undefined && store.state.showChat !== undefined) {
html += `<chatbox></chatbox>`;
}
if (store.state.loggedIn !== undefined && store.state.showProfile !== undefined) {
html += `<profile></profile>`;
}
html += `</div>`;
return html;
}
}
class Profile extends Dialog {
nodeName = 'PROFILE';
constructor() {
super();
this.nick = store.getItem('nick');
this.bio = store.getItem('bio');
if (this.nick === undefined) { this.nick = ''; }
if (this.bio === undefined) { this.bio = ''; }
this.forms = {
profileGeneral: `
<h1>Profile</h1>
<div class="tabmenu">
<button click='showLogin' class="down">General</button>
<button click='showSignup'>Particular</button>
</div>
<div class="dialogform">
<form action="javascript:void(0);">
<label for="nick">Nick</label>
<input id="nick" type="text" placeholder="your user nick/handle" value="${this.nick}" />
<label for="bio">Bio</label>
<input id="bio" type="text" placeholder="bio" value="${this.bio}" />
<button type="submit" click="save">Save Changes</button>
</form>
</div>`
}
this.content = this.forms[store.state.showProfile];
}
close() {
store.mutate('showProfile', undefined);
if (store.state.nick !== undefined) {
store.mutate('showChat', true);
}
}
saveField(fieldName) {
const value = document.getElementById(fieldName).value;
store.mutate(fieldName, value, true);
}
save() {
// TODO store transactions to batch updates?
//TODO store.begin();
this.saveField("nick");
this.saveField("bio");
this.close();
//TODO store.commit();
//TODO store.abort();
}
}
class ProjectBox extends Component {
nodeName = 'PROJECTBOX';
constructor() {
super();
}
get template() {
return `<div class="projectbox">
<div class="projectname">
Libre<span class="latter">cast</span>
<span class="itsalive">LIVE</span>
</div>
</div>
</div>`;
}
}
class SetPassword extends Component {
nodeName = 'SETPASSWORD';
constructor() {
super();
}
get template() {
return `<div class="setpassword">
<h1>Complete Account Signup</h1>
<input id="password" type="password" />
<button type="submit" onclick="live.setPassword();">Sign Up</button>
</div>`;
}
}
class SignupBox extends Dialog {
nodeName = 'SIGNUPBOX';
forms = {
signup: `
<h1>Join Librecast LIVE!</h1>
<div class="tabmenu">
<button click='showLogin'>Log In</button>
<button click='showSignup' class="down">Sign Up</button>
</div>
<div class="dialogform">
<form action="javascript:void(0);">
<label for="emailAddress">Email Address</label>
<input id="emailAddress" type="email" placeholder="you@example.com" />
<button type="submit" click="signup">Sign Up</button>
</form>
</div>`
,
login: `
<h1>Sign In to Librecast LIVE!</h1>
<div class="tabmenu">
<button click='showLogin' class="down">Log In</button>
<button click='showSignup'>Sign Up</button>
</div>
<div class="dialogform">
<form action="javascript:void(0);">
<label for="emailAddress">Email Address</label>
<input id="emailAddress" type="email" placeholder="you@example.com" />
<label for="password">Password</label>
<input id="password" type="password" />
<button type="submit" click="login">Log In</button>
</form>
</div>`
,
confirmSignup: `
<h1>Complete Account Signup</h1>
<div class="dialogform">
<form action="javascript:void(0);">
<label for="password">Password</label>
<input id="password" type="password" />
<button type="submit" click="setPassword">Sign Up</button>
</form>
</div>`
,
loggingIn: `
<h1>Logging In</h1>
<p>Please wait a moment...</p>`
,
signedup: `
<h1>Sign Up Successful</h1>
<p>You will receive an email with instructions to complete your signup.</p>
`
}
constructor() {
super();
this.content = this.forms[store.state.showSignup];
}
close() {
store.mutate('showSignup', undefined);
}
async login() {
const emailAddress = document.getElementById("emailAddress");
const password = document.getElementById("password");
store.mutate('showSignup', "loggingIn");
const s1 = Symbol('timeout')
const timeout = new Promise((resolve, reject) => setTimeout(() => resolve(s1), 2000))
try {
const login = live.login(emailAddress, password)
const res = await Promise.race([login, timeout])
if (res === s1) {
console.error('login TIMEOUT')
store.mutate('showSignup', "login");
alert("Server Error: login timeout")
} else {
console.log("logged in successfully");
this.close();
store.mutate('loggedIn', true);
if (store.getItem('nick') === undefined) {
store.mutate('showProfile', 'profileGeneral');
}
else {
store.mutate('showChat', true);
}
}
}
catch(e) {
console.error("login failed")
store.mutate('showSignup', "login");
alert("login failed")
}
}
showLogin() {
store.mutate('showSignup', "login");
}
showSignup() {
store.mutate('showSignup', "signup");
}
setPassword() {
console.log("setting password");
const password = document.getElementById("password");
live.setPassword(store.state.passToken, password)
.then(() => {
alert("password set");
store.mutate('showSignup', "login");
})
.catch(() => {
console.error("failed to set password");
alert("failed to set password");
});
}
signup() {
live.signup().then(() => {
})
.catch(() => {
console.error("signup failed");
alert("signup failed");
});
store.mutate('showSignup', "signedup");
}
}
class Stats extends Component {
nodeName = 'STATS';
constructor() {
super();
store.subscribe('state.chat', this.render);
}
userSelect(e) {
const user = e.target.attributes.user.value;
}
get template() {
const chan = store.state.chat.channels[store.state.activeChannel];
const bytin = (chan.bytin !== undefined) ? chan.bytin : 0;
const bytout = (chan.bytout !== undefined) ? chan.bytout : 0;
let html = `<div class="stats">
<h2>Statistics</h2>
<dl>
<div>
<dt>bytes received</dt><dd>${bytin}</dd>
</div>
<div>
<dt>bytes sent</dt><dd>${bytout}</dd>
</div>
</dl>
</div>`;
return html;
}
}
class TopBar extends Component {
nodeName = 'TOPBAR';
constructor() {
super();
store.subscribe('state.title', this.render);
}
myfunc = () => {
store.mutate('title', "Was Clicked");
}
get template() {
return `<div class="topbar" click="myfunc">
${store.state.title}
</div>`;
}
}
class UserList extends Component {
nodeName = 'USERLIST';
constructor() {
super();
store.subscribe('state.chat', this.render);
}
userSelect(e) {
const user = e.target.attributes.user.value;
}
get template() {
let html = `<div class="userlist">
<h2>Users</h2>
<ul>`;
const chan = store.state.chat.channels[store.state.activeChannel];
chan.users.forEach(user => {
html += `<li click="userSelect" user="${user.nick}"><avatar></avatar>${user.nick}</li>`;
});
html += `</ul></div>`;
return html;
}
}
class VidBox extends Component {
nodeName = 'VIDBOX';
constructor() {
super();
}
get template() {
console.log(this.props);
let html = `<div class="vidbox"><avatar></avatar></div>`;
return html;
}
}
class VidChat extends Component {
nodeName = 'VIDCHAT';
constructor() {
super();
}
close() {
store.mutate('showChat', undefined);
}
get template() {
let html = `<div class="chat vidchat">
<chattopic></chattopic>
<section>
<vidbox></vidbox>
<vidbox></vidbox>
<chatpane></chatpane>
</section>
</div>`;
return html;
}
}
const authComboKeyHex = "fbdd352740551bd867f1970e7fc1a8fb23b0f84865beb7550aa51e5aa349e9271fd320c9db88bdbf249527b9720d90b30c03bdf0e06efe667e3e1e7e6f243c1f";
const login = function(emailAddress, password) {
const kp = sodium.crypto_box_keypair();
let loggedin = false;
return new Promise((resolve, reject) => {
const auth = new Auth(lctx, authComboKeyHex, kp, (opcode, flags, fields, pre) => {
if (loggedin) return;
loggedin = true;
const view = new DataView(pre)
const responseCode = view.getUint8(0).toString();
if (responseCode === "0") {
resolve();
}
else {
reject();
}
});
auth.ready.then(() => {
auth.login(emailAddress.value, password.value);
});
});
}
const setPassword = function(token, password) {
const kp = sodium.crypto_box_keypair();
let passet = false;
return new Promise((resolve, reject) => {
const auth = new Auth(lctx, authComboKeyHex, kp, (opcode, flags, fields, pre) => {
if (passet) return;
passet = true;
const view = new DataView(pre)
const responseCode = view.getUint8(0).toString();
if (responseCode === "0") {
resolve();
}
else {
reject();
}
})
auth.ready.then(() => {
auth.setPassword(token, password.value);
});
});
}
const signup = function() {
const emailAddress = document.getElementById("emailAddress");
const kp = sodium.crypto_box_keypair();
let signup = false;
return new Promise((resolve, reject) => {
const auth = new Auth(lctx, authComboKeyHex, kp, (opcode, flags, fields, pre) => {
if (signup) return;
signup = true;
const view = new DataView(pre)
const responseCode = view.getUint8(0).toString();
if (responseCode === "0") {
resolve();
}
else {
reject();
}
})
auth.ready.then(() => {
auth.signup(emailAddress.value, null);
});
});
}
const registerComponent = function(nodeName, component) {
console.log(`registering ${nodeName} component`);
components.push({ 'nodeName': nodeName, 'component': component });
}
const render = function() {
const tree = document.createTreeWalker(document.getRootNode(), NodeFilter.SHOW_ELEMENT);
for (let node = tree.currentNode; node; node = tree.nextNode()) {
const q = [];
components.filter(c => c.nodeName === node.nodeName)
.forEach(c => {
console.log('rendering ' + c.nodeName);
const component = new c.component();
component.node = node;
q.push(component);
});
while (q.length > 0) {
const c = q.shift();
c.render();
};
}
}
const main = function() {
store = new Store();
chat = new Chat(lctx);
registerComponent('APP', App);
registerComponent('AVATAR', Avatar);
registerComponent('CHANNELLIST', ChannelList);
registerComponent('CHANNELLISTITEM', ChannelListItem);
registerComponent('CHATBAR', ChatBar);
registerComponent('CHATBOX', ChatBox);
registerComponent('CHATPANE', ChatPane);
registerComponent('CHATMSGS', ChatMsgs);
registerComponent('CHATTOPIC', ChatTopic);
registerComponent('CLOSEX', CloseX);
registerComponent('FOOTBOX', FootBox);
registerComponent('HEADER', Header);
registerComponent('LOGINMENU', LoginMenu);
registerComponent('LOGOBOX', LogoBox);
registerComponent('MENUBOX', MenuBox);
registerComponent('MIDDLE', Middle);
registerComponent('PROFILE', Profile);
registerComponent('PROJECTBOX', ProjectBox);
registerComponent('SETPASSWORD', SetPassword);
registerComponent('SIGNUPBOX', SignupBox);
registerComponent('STATS', Stats);
registerComponent('USERLIST', UserList);
registerComponent('VIDBOX', VidBox);
registerComponent('VIDCHAT', VidChat);
// load state
fetch('/data/0001.state.json').then(response => {
if (response.ok) {
return response.json();
}
})
.then(newstate => {
store.mutate("activeChannel", newstate.activeChannel);
store.mutate("chat", newstate.chat);
});
const token = window.location.pathname.split("/")[2];
if (token !== undefined) {
store.mutate('passToken', token);
store.mutate('showSignup', "confirmSignup");
}
render();
}
return { main, signup, setPassword, login };
})();
live.main();