Chatroom Demo

The locale is detected from HTTP header accept-language in the initial request.

When Javascript is enabled, the timeZone is detected from Intl.DateTimeFormat().resolvedOptions().timeZone

Online:

Typing:

Number of messages: 2


  1. SystemHello World

  2. WilhelminaDI think the admin of this site is really working hard for his site, because here every material is quality based data.
Source Code of chatroom.tsx
(import statements omitted for simplicity, click to expand)
import { allNames } from '@beenotung/tslib/constant/character-name.js'
import { Random } from '@beenotung/tslib/random.js'
import { YEAR } from '@beenotung/tslib/time.js'
import type { ServerMessage } from '../../../client/types'
import { debugLog } from '../../debug.js'
import { Style } from '../components/style.js'
import { getContextCookies } from '../cookie.js'
import { o } from '../jsx/jsx.js'
import type { Node } from '../jsx/types'
import {
  onWsSessionClose,
  Session,
  sessions,
  sessionToContext,
} from '../session.js'
import { ManagedWebsocket } from '../../ws/wss.js'
import { EarlyTerminate } from '../../exception.js'
import DateTimeText, {
  createRelativeTimer,
  toLocaleDateTimeString,
} from '../components/datetime.js'
import { nodeToVNode } from '../jsx/vnode.js'
import { Request, Response, NextFunction, Router } from 'express'
import SourceCode from '../components/source-code.js'
import type { Context } from '../context'
import { Routes } from '../routes.js'
import { apiEndpointTitle, title } from '../../config.js'
let log = debugLog('chatroom.tsx')
log.enabled = true

const style = Style(/* css */ `
#chatroom label {
  text-transform: capitalize;
  margin-top: 0.5em;
}
#chatroom label::after {
  content: ":";
}
#chatroom .name-list span::after {
  content: ", "
}
#chatroom .name-list span:last-child::after {
  content: ""
}
#chatroom .chat-time {
  font-size: small;
}
#chatroom .chat-time::before {
  content: "[";
}
#chatroom .chat-time::after {
  content: "]";
}
#chatroom .chat-author {
  font-weight: bold;
}
#chatroom .chat-author::after {
  content: ": ";
  font-weight: normal;
}
#chatroom .msg-list {
  display: flex;
  flex-direction: column-reverse;
}
#chatroom .chat-record {
  flex: 0 0 auto;
}
`)

const typing_timeout = 1000 * 5

type NameSpan = ['span', { id: string }, [name: string]]
const NameNode = {
  attr: 1 as const,
  name: 2 as const,
}

function sendMessage(message: ServerMessage) {
  sessions.forEach(session => {
    if (session.url?.startsWith('/chatroom')) {
      session.ws.send(message)
    }
  })
}

function remove<T>(array: T[], item: T, onRemove: () => void) {
  let index = array.indexOf(item)
  if (index === -1) return
  array.splice(index, 1)
  onRemove()
}

class ChatroomState {
  msg_list: Node[] = []
  online_span_list: NameSpan[] = []
  typing_span_list: NameSpan[] = []

  typing_timeout_map = new Map<NameSpan, NodeJS.Timeout>()

  addMessage(nickname: string, message: string) {
    let date = new Date()
    let time = date.getTime()
    let attrs = {
      nickname: nickname,
      message: message,
      timeISOString: date.toISOString(),
      time: time,
      id: 'msg-' + (this.msg_list.length + 1),
    }
    let li = <MessageItem {...attrs} />
    this.msg_list.push(li)
    sessions.forEach(session => {
      if (!session.url?.startsWith('/chatroom')) return
      let context = sessionToContext(session, session.url)
      let node = nodeToVNode(li, context)
      let message: ServerMessage = ['append', '#chatroom .msg-list', node]
      session.ws.send(message)
    })
  }
  addTyping(span: NameSpan) {
    let timeout = this.typing_timeout_map.get(span)
    if (timeout) {
      clearTimeout(timeout)
    }
    if (!this.typing_span_list.includes(span)) {
      this.typing_span_list.push(span)
      sendMessage(['append', '#chatroom .typing-list', span])
    }
    const remove = () => {
      const idx = this.typing_span_list.indexOf(span)
      if (idx === -1) return
      this.typing_span_list.splice(idx, 1)
      sendMessage([
        'remove',
        '#chatroom .typing-list span#' + span[NameNode.attr].id,
      ])
    }
    timeout = setTimeout(remove, typing_timeout)
    this.typing_timeout_map.set(span, timeout)
  }
  addOnline(span: NameSpan) {
    this.online_span_list.push(span)
    sendMessage(['append', '#chatroom .online-list', span])
  }
}
class ChatUserSession {
  static nextId = 1
  span: NameSpan
  constructor(
    public room: ChatroomState,
    public id: string,
    nickname: string,
  ) {
    this.span = ['span', { id }, [nickname]]
    room.addOnline(this.span)
  }
  rename(nickname: string) {
    this.span[NameNode.name][0] = nickname
    const message: ServerMessage = [
      'update-all-text',
      `#chatroom .name-list span#${this.id}`,
      nickname,
    ]
    sessions.forEach((session, ws) => {
      const url = session.url
      if (url && url.startsWith('/chatroom')) {
        ws.send(message)
      }
    })
  }
  markTyping() {
    this.room.addTyping(this.span)
  }
  markOffline() {
    remove(this.room.online_span_list, this.span, () =>
      sendMessage([
        'remove',
        '#chatroom .online-list span#' + this.span[NameNode.attr].id,
      ]),
    )
    remove(this.room.typing_span_list, this.span, () => {
      sendMessage([
        'remove',
        '#chatroom .typing-list span#' + this.span[NameNode.attr].id,
      ])
    })
  }

  static ws_state_map = new WeakMap<ManagedWebsocket, ChatUserSession>()
  static fromWS(
    room: ChatroomState,
    ws: ManagedWebsocket,
    nickname: string,
  ): ChatUserSession {
    const session = this.ws_state_map.get(ws)
    if (session) return session
    let id = 'author-' + this.nextId
    this.nextId++
    const newSession = new ChatUserSession(room, id, nickname)
    this.ws_state_map.set(ws, newSession)
    onWsSessionClose(ws, () => {
      newSession.markOffline()
    })
    return newSession
  }
}
let room = new ChatroomState()
room.addMessage('System', 'Hello World')

function randomName() {
  const name = Random.element(allNames)
  // TODO avoid name clash
  log('new name:', name)
  return name
}

function getChatSession(context: Context) {
  if (context.type !== 'ws') {
    throw new Error('this route is only for ws context')
  }
  let session = ChatUserSession.ws_state_map.get(context.ws)
  if (!session) {
    throw new Error('session not found')
  }
  return {
    session,
    context,
    ws: context.ws,
  }
}

function Typing(_attrs: {}, context: Context) {
  let { session } = getChatSession(context)
  session.markTyping()
  throw EarlyTerminate
}

function Rename(_attrs: {}, _context: Context) {
  let { session, context } = getChatSession(_context)
  let newNickname = context.args?.[0]
  if (!newNickname) {
    throw new Error('missing nickname in args')
  }
  if (typeof newNickname !== 'string') {
    throw new TypeError('newNickname must be a string')
  }
  session.rename(newNickname)
  throw EarlyTerminate
}

type PostBody = {
  nickname?: string
  message?: string
}
function Send(_attrs: {}, context: Context) {
  if (context.type === 'express') {
    const { req, res } = context
    if (req.method !== 'POST') {
      res.status(405).end('Only POST is allowed on this route')
      return
    }
    if (!req.body) {
      res.status(400).end('Missing json body in request')
      return
    }
    let { nickname, message } = req.body
    if (!message) {
      res.status(400).end('Missing message in request body')
      return
    }
    nickname = nickname || randomName()
    room.addMessage(nickname, message)
    return
  }
  if (context.type === 'ws') {
    let body = context.args?.[0]
    if (!body) {
      throw new Error('Missing form body in args')
    }
    let { nickname, message } = body as PostBody
    if (!message) {
      throw new Error('Missing message in args')
    }
    nickname = nickname || randomName()
    room.addMessage(nickname, message)
    let update: ServerMessage = ['set-value', '#chatroom form #message', '']
    context.ws.send(update)
    throw EarlyTerminate
  }
  throw new Error('unknown context type:' + context.type)
}

let nicknameMiddleware = (req: Request, res: Response, next: NextFunction) => {
  if (!req.cookies.nickname) {
    let nickname = randomName()
    req.cookies.nickname = nickname
    res.cookie('nickname', nickname, { sameSite: 'lax' })
  }
  next()
}

function Chatroom(_attrs: {}, context: Context) {
  let cookies = getContextCookies(context)
  let nickname = cookies?.unsignedCookies.nickname || ''
  log({ type: context.type, cookies })
  switch (context.type) {
    case 'express': {
      const { req, res } = context
      if (req.method === 'POST') {
        nickname = req.body.nickname || nickname || randomName()
        if (req.cookies.nickname !== nickname) {
          res.cookie('nickname', nickname, { sameSite: 'lax' })
        }
        let message = req.body.message
        if (message) {
          room.addMessage(nickname, message)
        }
        break
      }
      break
    }
    case 'ws': {
      let ws = context.ws
      let body = context.args?.[0] as PostBody | undefined
      if (body) {
        nickname = body.nickname || nickname
      }
      if (!nickname) {
        nickname = randomName()
        if (cookies) {
          cookies.unsignedCookies.nickname = nickname
        }
        let message: ServerMessage = [
          'set-cookie',
          `nickname=${nickname};SameSite=lax`,
        ]
        ws.send(message)
      }
      ChatUserSession.fromWS(room, ws, nickname)
      if (body?.message) {
        room.addMessage(nickname, body.message)
      }
      break
    }
  }
  return (
    <>
      {style}
      <div id="chatroom">
        <h1>Chatroom Demo</h1>
        <p>
          The locale is detected from HTTP header <code>accept-language</code>{' '}
          in the initial request.
        </p>
        <p>
          When Javascript is enabled, the timeZone is detected from{' '}
          <code>Intl.DateTimeFormat().resolvedOptions().timeZone</code>
        </p>
        <form method="POST" onsubmit="emitForm(event)" action="/chatroom/send">
          <div>
            <label for="nickname">nickname</label>
            <input
              type="text"
              name="nickname"
              id="nickname"
              value={nickname}
              pattern="[a-zA-Z0-9 ]+"
              minlength="1"
              maxlength="70"
              oninput="emit('/chatroom/rename', this.value)"
            />
          </div>
          <div>
            <label for="message">message</label>
            <input
              type="text"
              name="message"
              id="message"
              oninput="emit('/chatroom/typing')"
            />
          </div>
          <input type="submit" value="Send" />
        </form>
        <p class="name-list online-list" style="color: green">
          Online: {[room.online_span_list]}
        </p>
        <p class="name-list typing-list" style={`color: grey; opacity: ${1}`}>
          Typing: {[room.typing_span_list]}
        </p>
        <p>Number of messages: {room.msg_list.length}</p>
        <ol class="msg-list">{[room.msg_list]}</ol>
        <SourceCode page="chatroom.tsx" />
      </div>
    </>
  )
}

function sessionFilter(session: Session): boolean {
  return !!session.url?.startsWith('/chatroom')
}
const { startRelativeTimer } = createRelativeTimer({ sessionFilter })

type MessageItemAttrs = {
  id: string
  nickname: string
  message: string
  timeISOString: string
  time: number
}

function MessageItem(attrs: MessageItemAttrs, context: Context) {
  let time = attrs.time
  startRelativeTimer({ time, selector: `#${attrs.id} .chat-time` }, context)
  return (
    <li class="chat-record" id={attrs.id}>
      <time
        class="chat-time"
        datetime={attrs.timeISOString}
        title={toLocaleDateTimeString(time, context)}
      >
        <DateTimeText time={time} relativeTimeThreshold={YEAR} />
      </time>{' '}
      <br />
      <span class="chat-author">{attrs.nickname}</span>
      <span class="chat-message">{attrs.message}</span>
    </li>
  )
}

let routes = {
  '/chatroom': {
    title: title('Chatroom'),
    description: 'Live chatroom with realtime-update powered by websocket',
    menuText: 'Chatroom',
    node: <Chatroom />,
  },
  '/chatroom/typing': {
    title: apiEndpointTitle,
    description: 'declare typing status in chatroom',
    node: <Typing />,
  },
  '/chatroom/rename': {
    title: apiEndpointTitle,
    description: 'rename user in chatroom',
    node: <Rename />,
    streaming: false,
  },
  '/chatroom/send': {
    title: apiEndpointTitle,
    description: 'send message in chatroom',
    node: <Send />,
    streaming: false,
  },
} satisfies Routes

function attachRoutes(app: Router) {
  app.get('/chatroom', nicknameMiddleware)
}

export default {
  routes,
  attachRoutes,
}