Build video call prototype layout
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
.git-kkkmeet/
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
;;; kkkmeet-server.el --- Tiny WebRTC signaling server -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
|
;; Run from a shell:
|
||||||
|
;; emacs -Q --batch -l kkkmeet-server.el -f kkkmeet-server-run-batch
|
||||||
|
;;
|
||||||
|
;; Or from Emacs:
|
||||||
|
;; M-x kkkmeet-server-start
|
||||||
|
|
||||||
|
(require 'cl-lib)
|
||||||
|
(require 'json)
|
||||||
|
(require 'subr-x)
|
||||||
|
(require 'url-util)
|
||||||
|
|
||||||
|
(defgroup kkkmeet-server nil
|
||||||
|
"Small static file and WebRTC signaling server for kkkmeet."
|
||||||
|
:group 'applications)
|
||||||
|
|
||||||
|
(defcustom kkkmeet-server-host "127.0.0.1"
|
||||||
|
"Host address for `kkkmeet-server-start'."
|
||||||
|
:type 'string)
|
||||||
|
|
||||||
|
(defcustom kkkmeet-server-port 3000
|
||||||
|
"Port for `kkkmeet-server-start'."
|
||||||
|
:type 'integer)
|
||||||
|
|
||||||
|
(defcustom kkkmeet-server-public-dir
|
||||||
|
(expand-file-name "public" default-directory)
|
||||||
|
"Directory containing the browser client."
|
||||||
|
:type 'directory)
|
||||||
|
|
||||||
|
(defvar kkkmeet-server--process nil)
|
||||||
|
(defvar kkkmeet-server--rooms (make-hash-table :test 'equal))
|
||||||
|
(defconst kkkmeet-server--default-room-id "main")
|
||||||
|
|
||||||
|
(defconst kkkmeet-server--mime-types
|
||||||
|
'(("html" . "text/html; charset=utf-8")
|
||||||
|
("css" . "text/css; charset=utf-8")
|
||||||
|
("js" . "text/javascript; charset=utf-8")
|
||||||
|
("json" . "application/json; charset=utf-8")
|
||||||
|
("svg" . "image/svg+xml")))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--uuid ()
|
||||||
|
"Return a UUID-like random peer id."
|
||||||
|
(let ((hex (md5 (format "%s-%s-%s" (current-time) (random) (emacs-pid)))))
|
||||||
|
(format "%s-%s-%s-%s-%s"
|
||||||
|
(substring hex 0 8)
|
||||||
|
(substring hex 8 12)
|
||||||
|
(substring hex 12 16)
|
||||||
|
(substring hex 16 20)
|
||||||
|
(substring hex 20 32))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--room (room-id)
|
||||||
|
"Return room hash table for ROOM-ID, creating it when needed."
|
||||||
|
(or (gethash room-id kkkmeet-server--rooms)
|
||||||
|
(puthash room-id (make-hash-table :test 'equal) kkkmeet-server--rooms)))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--hash-keys (table)
|
||||||
|
"Return keys from hash TABLE."
|
||||||
|
(let (keys)
|
||||||
|
(maphash (lambda (key _value) (push key keys)) table)
|
||||||
|
(nreverse keys)))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--room-peers (table)
|
||||||
|
"Return peer objects from room TABLE."
|
||||||
|
(let (peers)
|
||||||
|
(maphash
|
||||||
|
(lambda (peer-id process)
|
||||||
|
(push `((id . ,peer-id)
|
||||||
|
(name . ,(or (process-get process :kkkmeet-name) "Guest")))
|
||||||
|
peers))
|
||||||
|
table)
|
||||||
|
(vconcat (nreverse peers))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--normalize-name (name)
|
||||||
|
"Return a display-safe NAME."
|
||||||
|
(let ((normalized (string-trim (replace-regexp-in-string "[ \t\n\r]+" " " (or name "")))))
|
||||||
|
(if (string-empty-p normalized)
|
||||||
|
"Guest"
|
||||||
|
(substring normalized 0 (min 48 (length normalized))))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--json (value)
|
||||||
|
"Encode VALUE as JSON."
|
||||||
|
(let ((json-array-type 'list)
|
||||||
|
(json-object-type 'alist)
|
||||||
|
(json-false :false))
|
||||||
|
(json-encode value)))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--send-response (process status headers &optional body keep-open)
|
||||||
|
"Send HTTP response to PROCESS with STATUS, HEADERS, and BODY.
|
||||||
|
When KEEP-OPEN is nil, close PROCESS after writing."
|
||||||
|
(let* ((reason (pcase status
|
||||||
|
(200 "OK")
|
||||||
|
(202 "Accepted")
|
||||||
|
(400 "Bad Request")
|
||||||
|
(403 "Forbidden")
|
||||||
|
(404 "Not Found")
|
||||||
|
(405 "Method Not Allowed")
|
||||||
|
(_ "Internal Server Error")))
|
||||||
|
(payload (or body ""))
|
||||||
|
(base-headers `(("Connection" . ,(if keep-open "keep-alive" "close"))
|
||||||
|
,@headers))
|
||||||
|
(full-headers (if keep-open
|
||||||
|
base-headers
|
||||||
|
`(("Content-Length" . ,(number-to-string (string-bytes payload)))
|
||||||
|
,@base-headers))))
|
||||||
|
(process-send-string process (format "HTTP/1.1 %s %s\r\n" status reason))
|
||||||
|
(dolist (header full-headers)
|
||||||
|
(process-send-string process (format "%s: %s\r\n" (car header) (cdr header))))
|
||||||
|
(process-send-string process "\r\n")
|
||||||
|
(when body
|
||||||
|
(process-send-string process payload))
|
||||||
|
(unless keep-open
|
||||||
|
(delete-process process))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--send-json (process status value)
|
||||||
|
"Send JSON VALUE to PROCESS."
|
||||||
|
(kkkmeet-server--send-response
|
||||||
|
process status
|
||||||
|
'(("Content-Type" . "application/json; charset=utf-8")
|
||||||
|
("Cache-Control" . "no-store"))
|
||||||
|
(kkkmeet-server--json value)))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--send-sse (process value)
|
||||||
|
"Send VALUE as a server-sent event to PROCESS."
|
||||||
|
(when (process-live-p process)
|
||||||
|
(process-send-string process (format "data: %s\n\n" (kkkmeet-server--json value)))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--broadcast (room-id sender-id value)
|
||||||
|
"Broadcast VALUE in ROOM-ID to everyone except SENDER-ID."
|
||||||
|
(let ((room (gethash room-id kkkmeet-server--rooms)))
|
||||||
|
(when room
|
||||||
|
(maphash
|
||||||
|
(lambda (peer-id process)
|
||||||
|
(unless (equal peer-id sender-id)
|
||||||
|
(kkkmeet-server--send-sse process value)))
|
||||||
|
room))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--remove-peer (process)
|
||||||
|
"Remove PROCESS from its room and notify remaining peers."
|
||||||
|
(let ((room-id (process-get process :kkkmeet-room-id))
|
||||||
|
(peer-id (process-get process :kkkmeet-peer-id)))
|
||||||
|
(when (and room-id peer-id)
|
||||||
|
(let ((room (gethash room-id kkkmeet-server--rooms)))
|
||||||
|
(when room
|
||||||
|
(remhash peer-id room)
|
||||||
|
(kkkmeet-server--broadcast
|
||||||
|
room-id peer-id `((type . "peer-left") (peerId . ,peer-id)))
|
||||||
|
(when (= (hash-table-count room) 0)
|
||||||
|
(remhash room-id kkkmeet-server--rooms)))))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--parse-request (raw)
|
||||||
|
"Parse RAW HTTP request into an alist."
|
||||||
|
(let* ((split (string-match-p "\r\n\r\n" raw))
|
||||||
|
(head (substring raw 0 split))
|
||||||
|
(body (substring raw (+ split 4)))
|
||||||
|
(lines (split-string head "\r\n"))
|
||||||
|
(request-line (split-string (car lines) " "))
|
||||||
|
(headers (make-hash-table :test 'equal)))
|
||||||
|
(dolist (line (cdr lines))
|
||||||
|
(when (string-match "\\`\\([^:]+\\):[ \t]*\\(.*\\)\\'" line)
|
||||||
|
(puthash (downcase (match-string 1 line)) (match-string 2 line) headers)))
|
||||||
|
`((method . ,(nth 0 request-line))
|
||||||
|
(target . ,(nth 1 request-line))
|
||||||
|
(path . ,(car (split-string (nth 1 request-line) "?")))
|
||||||
|
(headers . ,headers)
|
||||||
|
(body . ,body))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--request-complete-p (raw)
|
||||||
|
"Return non-nil when RAW contains a complete HTTP request."
|
||||||
|
(when-let ((split (string-match-p "\r\n\r\n" raw)))
|
||||||
|
(let* ((head (substring raw 0 split))
|
||||||
|
(headers (make-hash-table :test 'equal)))
|
||||||
|
(dolist (line (cdr (split-string head "\r\n")))
|
||||||
|
(when (string-match "\\`\\([^:]+\\):[ \t]*\\(.*\\)\\'" line)
|
||||||
|
(puthash (downcase (match-string 1 line)) (match-string 2 line) headers)))
|
||||||
|
(let ((length (string-to-number (or (gethash "content-length" headers) "0"))))
|
||||||
|
(>= (string-bytes (substring raw (+ split 4))) length)))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--safe-static-path (url-path)
|
||||||
|
"Return a safe static file path for URL-PATH, or nil."
|
||||||
|
(let* ((relative (if (equal url-path "/") "/index.html" url-path))
|
||||||
|
(decoded (url-unhex-string relative))
|
||||||
|
(full-path (expand-file-name (string-remove-prefix "/" decoded)
|
||||||
|
kkkmeet-server-public-dir))
|
||||||
|
(root (file-name-as-directory (expand-file-name kkkmeet-server-public-dir))))
|
||||||
|
(when (string-prefix-p root full-path)
|
||||||
|
full-path)))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--serve-static (process method path)
|
||||||
|
"Serve static file PATH to PROCESS for METHOD."
|
||||||
|
(let ((file-path (kkkmeet-server--safe-static-path path)))
|
||||||
|
(cond
|
||||||
|
((not file-path)
|
||||||
|
(kkkmeet-server--send-response process 403 nil "Forbidden"))
|
||||||
|
((not (file-regular-p file-path))
|
||||||
|
(kkkmeet-server--send-response process 404 nil "Not found"))
|
||||||
|
(t
|
||||||
|
(let* ((extension (file-name-extension file-path))
|
||||||
|
(mime (or (cdr (assoc extension kkkmeet-server--mime-types))
|
||||||
|
"application/octet-stream"))
|
||||||
|
(body (unless (equal method "HEAD")
|
||||||
|
(with-temp-buffer
|
||||||
|
(set-buffer-multibyte nil)
|
||||||
|
(insert-file-contents-literally file-path)
|
||||||
|
(buffer-string)))))
|
||||||
|
(kkkmeet-server--send-response
|
||||||
|
process 200
|
||||||
|
`(("Content-Type" . ,mime)
|
||||||
|
("Cache-Control" . "no-store"))
|
||||||
|
body))))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--query-param (target name)
|
||||||
|
"Return query parameter NAME from request TARGET."
|
||||||
|
(when-let ((query (cadr (split-string target "?" t))))
|
||||||
|
(cadr (assoc name (url-parse-query-string query)))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--handle-events (process room-id display-name)
|
||||||
|
"Attach PROCESS to ROOM-ID as an SSE client."
|
||||||
|
(let* ((safe-room-id (if (string-empty-p room-id) kkkmeet-server--default-room-id room-id))
|
||||||
|
(room (kkkmeet-server--room safe-room-id))
|
||||||
|
(peer-id (kkkmeet-server--uuid))
|
||||||
|
(peers (kkkmeet-server--room-peers room))
|
||||||
|
(safe-name (kkkmeet-server--normalize-name display-name)))
|
||||||
|
(process-put process :kkkmeet-room-id safe-room-id)
|
||||||
|
(process-put process :kkkmeet-peer-id peer-id)
|
||||||
|
(process-put process :kkkmeet-name safe-name)
|
||||||
|
(puthash peer-id process room)
|
||||||
|
(kkkmeet-server--send-response
|
||||||
|
process 200
|
||||||
|
'(("Content-Type" . "text/event-stream; charset=utf-8")
|
||||||
|
("Cache-Control" . "no-store, no-transform")
|
||||||
|
("X-Accel-Buffering" . "no"))
|
||||||
|
nil t)
|
||||||
|
(kkkmeet-server--send-sse
|
||||||
|
process `((type . "welcome")
|
||||||
|
(peerId . ,peer-id)
|
||||||
|
(name . ,safe-name)
|
||||||
|
(peers . ,peers)))
|
||||||
|
(kkkmeet-server--broadcast
|
||||||
|
safe-room-id peer-id `((type . "peer-joined")
|
||||||
|
(peerId . ,peer-id)
|
||||||
|
(name . ,safe-name)))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--handle-signal (process room-id body)
|
||||||
|
"Handle signaling BODY from PROCESS for ROOM-ID."
|
||||||
|
(condition-case error
|
||||||
|
(let* ((json-object-type 'alist)
|
||||||
|
(payload (json-read-from-string body))
|
||||||
|
(from (alist-get 'from payload))
|
||||||
|
(to (alist-get 'to payload))
|
||||||
|
(type (alist-get 'type payload))
|
||||||
|
(target (gethash to (gethash room-id kkkmeet-server--rooms))))
|
||||||
|
(if (and from to type)
|
||||||
|
(progn
|
||||||
|
(when target
|
||||||
|
(kkkmeet-server--send-sse target payload))
|
||||||
|
(kkkmeet-server--send-json process 202 '((ok . t))))
|
||||||
|
(kkkmeet-server--send-json
|
||||||
|
process 400 '((error . "Signal messages require from, to, and type.")))))
|
||||||
|
(error
|
||||||
|
(kkkmeet-server--send-json process 400 `((error . ,(error-message-string error)))))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--handle-request (process request)
|
||||||
|
"Route REQUEST for PROCESS."
|
||||||
|
(let ((method (alist-get 'method request))
|
||||||
|
(target (alist-get 'target request))
|
||||||
|
(path (alist-get 'path request))
|
||||||
|
(body (alist-get 'body request)))
|
||||||
|
(cond
|
||||||
|
((and (equal method "GET") (string-prefix-p "/events/" path))
|
||||||
|
(kkkmeet-server--handle-events
|
||||||
|
process
|
||||||
|
(url-unhex-string (string-remove-prefix "/events/" path))
|
||||||
|
(kkkmeet-server--query-param target "name")))
|
||||||
|
((and (equal method "POST") (string-prefix-p "/signal/" path))
|
||||||
|
(kkkmeet-server--handle-signal
|
||||||
|
process (url-unhex-string (string-remove-prefix "/signal/" path)) body))
|
||||||
|
((member method '("GET" "HEAD"))
|
||||||
|
(kkkmeet-server--serve-static process method path))
|
||||||
|
(t
|
||||||
|
(kkkmeet-server--send-response process 405 nil "Method not allowed")))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--filter (process chunk)
|
||||||
|
"Accumulate request CHUNK for PROCESS and respond when complete."
|
||||||
|
(let ((raw (concat (or (process-get process :kkkmeet-raw-request) "") chunk)))
|
||||||
|
(process-put process :kkkmeet-raw-request raw)
|
||||||
|
(when (kkkmeet-server--request-complete-p raw)
|
||||||
|
(process-put process :kkkmeet-raw-request nil)
|
||||||
|
(condition-case error
|
||||||
|
(kkkmeet-server--handle-request process (kkkmeet-server--parse-request raw))
|
||||||
|
(error
|
||||||
|
(kkkmeet-server--send-json process 500 `((error . ,(error-message-string error)))))))))
|
||||||
|
|
||||||
|
(defun kkkmeet-server--sentinel (process _event)
|
||||||
|
"Clean up PROCESS when a connection closes."
|
||||||
|
(unless (process-live-p process)
|
||||||
|
(kkkmeet-server--remove-peer process)))
|
||||||
|
|
||||||
|
;;;###autoload
|
||||||
|
(defun kkkmeet-server-start (&optional port host)
|
||||||
|
"Start kkkmeet server on PORT and HOST."
|
||||||
|
(interactive)
|
||||||
|
(kkkmeet-server-stop)
|
||||||
|
(setq kkkmeet-server--rooms (make-hash-table :test 'equal))
|
||||||
|
(setq kkkmeet-server--process
|
||||||
|
(make-network-process
|
||||||
|
:name "kkkmeet-server"
|
||||||
|
:server t
|
||||||
|
:host (or host kkkmeet-server-host)
|
||||||
|
:service (or port kkkmeet-server-port)
|
||||||
|
:coding 'binary
|
||||||
|
:filter #'kkkmeet-server--filter
|
||||||
|
:sentinel #'kkkmeet-server--sentinel))
|
||||||
|
(message "kkkmeet is running at http://%s:%s"
|
||||||
|
(or host kkkmeet-server-host)
|
||||||
|
(or port kkkmeet-server-port)))
|
||||||
|
|
||||||
|
;;;###autoload
|
||||||
|
(defun kkkmeet-server-stop ()
|
||||||
|
"Stop the kkkmeet server and all active SSE clients."
|
||||||
|
(interactive)
|
||||||
|
(when (process-live-p kkkmeet-server--process)
|
||||||
|
(delete-process kkkmeet-server--process))
|
||||||
|
(maphash
|
||||||
|
(lambda (_room-id room)
|
||||||
|
(maphash (lambda (_peer-id process)
|
||||||
|
(when (process-live-p process)
|
||||||
|
(delete-process process)))
|
||||||
|
room))
|
||||||
|
kkkmeet-server--rooms)
|
||||||
|
(setq kkkmeet-server--process nil)
|
||||||
|
(setq kkkmeet-server--rooms (make-hash-table :test 'equal)))
|
||||||
|
|
||||||
|
;;;###autoload
|
||||||
|
(defun kkkmeet-server-run-batch ()
|
||||||
|
"Run kkkmeet server forever for `emacs --batch'."
|
||||||
|
(let ((port (or (and (getenv "PORT") (string-to-number (getenv "PORT")))
|
||||||
|
kkkmeet-server-port))
|
||||||
|
(host (or (getenv "HOST") kkkmeet-server-host)))
|
||||||
|
(kkkmeet-server-start port host)
|
||||||
|
(while t
|
||||||
|
(accept-process-output nil 1))))
|
||||||
|
|
||||||
|
(provide 'kkkmeet-server)
|
||||||
|
;;; kkkmeet-server.el ends here
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "kkkmeet",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"start:elisp": "emacs -Q --batch -l kkkmeet-server.el -f kkkmeet-server-run-batch"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
const localVideo = document.querySelector("#local-video");
|
||||||
|
const localNameLabel = document.querySelector("#local-name");
|
||||||
|
const remoteGrid = document.querySelector("#remote-grid");
|
||||||
|
const remoteTemplate = document.querySelector("#remote-template");
|
||||||
|
const nameForm = document.querySelector("#name-form");
|
||||||
|
const nameInput = document.querySelector("#name-input");
|
||||||
|
const statusText = document.querySelector("#status");
|
||||||
|
const cameraButton = document.querySelector("#camera-button");
|
||||||
|
const micButton = document.querySelector("#mic-button");
|
||||||
|
const shareButton = document.querySelector("#share-button");
|
||||||
|
const leaveButton = document.querySelector("#leave-button");
|
||||||
|
|
||||||
|
const roomId = "main";
|
||||||
|
const nameStorageKey = "kkkmeet.displayName";
|
||||||
|
const rtcConfig = {
|
||||||
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
|
||||||
|
};
|
||||||
|
|
||||||
|
let peerId = "";
|
||||||
|
let displayName = "";
|
||||||
|
let localStream;
|
||||||
|
let screenStream;
|
||||||
|
let events;
|
||||||
|
const peers = new Map();
|
||||||
|
|
||||||
|
function setStatus(message) {
|
||||||
|
statusText.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeName(name) {
|
||||||
|
return name.trim().replace(/\s+/g, " ").slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDisplayName(nextName) {
|
||||||
|
displayName = normalizeName(nextName);
|
||||||
|
localStorage.setItem(nameStorageKey, displayName);
|
||||||
|
nameInput.value = displayName;
|
||||||
|
localNameLabel.textContent = displayName ? `${displayName} (you)` : "You";
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelForPeer(peer) {
|
||||||
|
return peer?.name || `Participant ${peer?.id?.slice(0, 4) || ""}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLocalStream() {
|
||||||
|
if (localStream) return localStream;
|
||||||
|
|
||||||
|
localStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: true,
|
||||||
|
video: { width: { ideal: 1280 }, height: { ideal: 720 } }
|
||||||
|
});
|
||||||
|
localVideo.srcObject = localStream;
|
||||||
|
return localStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRemoteLabel(id, name) {
|
||||||
|
const label = document.querySelector(`[data-peer-id="${id}"] span`);
|
||||||
|
if (label) label.textContent = name || `Participant ${id.slice(0, 4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRemoteTile(peer) {
|
||||||
|
const id = typeof peer === "string" ? peer : peer.id;
|
||||||
|
const fragment = remoteTemplate.content.cloneNode(true);
|
||||||
|
const tile = fragment.querySelector(".tile");
|
||||||
|
const video = fragment.querySelector("video");
|
||||||
|
const label = fragment.querySelector("span");
|
||||||
|
tile.dataset.peerId = id;
|
||||||
|
label.textContent = labelForPeer(typeof peer === "string" ? { id } : peer);
|
||||||
|
remoteGrid.append(tile);
|
||||||
|
return { tile, video };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRemoteTile(id) {
|
||||||
|
document.querySelector(`[data-peer-id="${id}"]`)?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSignal(to, payload) {
|
||||||
|
await fetch(`/signal/${encodeURIComponent(roomId)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...payload, from: peerId, to, name: displayName })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPeerConnection(remotePeer) {
|
||||||
|
const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id;
|
||||||
|
if (peers.has(remotePeerId)) {
|
||||||
|
updateRemoteLabel(remotePeerId, remotePeer.name);
|
||||||
|
return peers.get(remotePeerId).connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = new RTCPeerConnection(rtcConfig);
|
||||||
|
const remoteStream = new MediaStream();
|
||||||
|
const { video } = createRemoteTile(remotePeer);
|
||||||
|
video.srcObject = remoteStream;
|
||||||
|
|
||||||
|
localStream.getTracks().forEach((track) => {
|
||||||
|
connection.addTrack(track, localStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.ontrack = (event) => {
|
||||||
|
event.streams[0].getTracks().forEach((track) => {
|
||||||
|
remoteStream.addTrack(track);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
sendSignal(remotePeerId, { type: "ice-candidate", candidate: event.candidate });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.onconnectionstatechange = () => {
|
||||||
|
if (["closed", "disconnected", "failed"].includes(connection.connectionState)) {
|
||||||
|
closePeer(remotePeerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
peers.set(remotePeerId, { connection, remoteStream, name: remotePeer.name });
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callPeer(remotePeer) {
|
||||||
|
const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id;
|
||||||
|
const connection = createPeerConnection(remotePeer);
|
||||||
|
const offer = await connection.createOffer();
|
||||||
|
await connection.setLocalDescription(offer);
|
||||||
|
await sendSignal(remotePeerId, { type: "offer", description: connection.localDescription });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function answerPeer(message) {
|
||||||
|
const connection = createPeerConnection({ id: message.from, name: message.name });
|
||||||
|
await connection.setRemoteDescription(message.description);
|
||||||
|
const answer = await connection.createAnswer();
|
||||||
|
await connection.setLocalDescription(answer);
|
||||||
|
await sendSignal(message.from, { type: "answer", description: connection.localDescription });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignal(message) {
|
||||||
|
if (message.type === "welcome") {
|
||||||
|
peerId = message.peerId;
|
||||||
|
setStatus(`Joined the call as ${displayName}. ${message.peers.length} other participant(s).`);
|
||||||
|
for (const existingPeer of message.peers) {
|
||||||
|
await callPeer(existingPeer);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "peer-joined") {
|
||||||
|
updateRemoteLabel(message.peerId, message.name);
|
||||||
|
setStatus(`${message.name || "A participant"} joined.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "peer-left") {
|
||||||
|
closePeer(message.peerId);
|
||||||
|
setStatus("A participant left.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "offer") {
|
||||||
|
await answerPeer(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = peers.get(message.from)?.connection;
|
||||||
|
if (!connection) return;
|
||||||
|
|
||||||
|
if (message.type === "answer") {
|
||||||
|
await connection.setRemoteDescription(message.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "ice-candidate") {
|
||||||
|
await connection.addIceCandidate(message.candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePeer(remotePeerId) {
|
||||||
|
const peer = peers.get(remotePeerId);
|
||||||
|
if (!peer) return;
|
||||||
|
|
||||||
|
peer.connection.close();
|
||||||
|
peers.delete(remotePeerId);
|
||||||
|
removeRemoteTile(remotePeerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinCall() {
|
||||||
|
if (!displayName) {
|
||||||
|
setStatus("Enter your name to join the call.");
|
||||||
|
nameInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveCall();
|
||||||
|
await ensureLocalStream();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ name: displayName });
|
||||||
|
events = new EventSource(`/events/${encodeURIComponent(roomId)}?${params}`);
|
||||||
|
events.onmessage = (event) => handleSignal(JSON.parse(event.data)).catch(console.error);
|
||||||
|
events.onerror = () => setStatus("Connection to signaling server was interrupted.");
|
||||||
|
setStatus(`Joining as ${displayName}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveCall() {
|
||||||
|
events?.close();
|
||||||
|
events = undefined;
|
||||||
|
peerId = "";
|
||||||
|
for (const remotePeerId of peers.keys()) closePeer(remotePeerId);
|
||||||
|
setStatus(displayName ? `Ready as ${displayName}.` : "Enter your name to join the call.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTrackEnabled(kind, enabled) {
|
||||||
|
localStream?.getTracks().forEach((track) => {
|
||||||
|
if (track.kind === kind) track.enabled = enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nameForm.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDisplayName(nameInput.value);
|
||||||
|
joinCall().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
setStatus("Could not start camera or join the call.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
nameInput.addEventListener("change", () => {
|
||||||
|
setDisplayName(nameInput.value);
|
||||||
|
if (events) {
|
||||||
|
joinCall().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
setStatus("Could not rejoin with the updated name.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraButton.addEventListener("click", () => {
|
||||||
|
const enabled = cameraButton.getAttribute("aria-pressed") !== "true";
|
||||||
|
cameraButton.setAttribute("aria-pressed", String(enabled));
|
||||||
|
setTrackEnabled("video", enabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
micButton.addEventListener("click", () => {
|
||||||
|
const enabled = micButton.getAttribute("aria-pressed") !== "true";
|
||||||
|
micButton.setAttribute("aria-pressed", String(enabled));
|
||||||
|
setTrackEnabled("audio", enabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
shareButton.addEventListener("click", async () => {
|
||||||
|
if (!localStream) return;
|
||||||
|
|
||||||
|
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
||||||
|
const screenTrack = screenStream.getVideoTracks()[0];
|
||||||
|
|
||||||
|
for (const { connection } of peers.values()) {
|
||||||
|
const sender = connection.getSenders().find((item) => item.track?.kind === "video");
|
||||||
|
await sender?.replaceTrack(screenTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
localVideo.srcObject = screenStream;
|
||||||
|
screenTrack.onended = async () => {
|
||||||
|
const cameraTrack = localStream.getVideoTracks()[0];
|
||||||
|
for (const { connection } of peers.values()) {
|
||||||
|
const sender = connection.getSenders().find((item) => item.track?.kind === "video");
|
||||||
|
await sender?.replaceTrack(cameraTrack);
|
||||||
|
}
|
||||||
|
localVideo.srcObject = localStream;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
leaveButton.addEventListener("click", leaveCall);
|
||||||
|
|
||||||
|
const savedName = localStorage.getItem(nameStorageKey);
|
||||||
|
if (savedName) {
|
||||||
|
setDisplayName(savedName);
|
||||||
|
joinCall().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
setStatus("Could not start camera or join the call.");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
nameInput.focus();
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>kkkmeet</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>kkkmeet</h1>
|
||||||
|
<p id="status">Enter your name to join the call.</p>
|
||||||
|
</div>
|
||||||
|
<form id="name-form" class="name-form">
|
||||||
|
<input id="name-input" autocomplete="name" maxlength="48" placeholder="Your name" required />
|
||||||
|
<button type="submit">Join</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="stage" aria-label="Video call">
|
||||||
|
<div class="remote-grid" id="remote-grid">
|
||||||
|
<div class="empty-stage" id="empty-stage">Waiting for others to join</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="controls">
|
||||||
|
<div class="control-actions" aria-label="Call controls">
|
||||||
|
<button id="camera-button" type="button" aria-pressed="true">Camera</button>
|
||||||
|
<button id="mic-button" type="button" aria-pressed="true">Mic</button>
|
||||||
|
<button id="share-button" type="button">Share Screen</button>
|
||||||
|
<button id="leave-button" type="button" class="danger">Leave</button>
|
||||||
|
</div>
|
||||||
|
<article class="self-preview">
|
||||||
|
<video id="local-video" autoplay muted playsinline></video>
|
||||||
|
<span id="local-name">You</span>
|
||||||
|
</article>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<template id="remote-template">
|
||||||
|
<article class="tile">
|
||||||
|
<video autoplay playsinline></video>
|
||||||
|
<span>Participant</span>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script type="module" src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #101214;
|
||||||
|
color: #f2f4f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(46, 125, 50, 0.16), transparent 35%),
|
||||||
|
linear-gradient(315deg, rgba(3, 169, 244, 0.16), transparent 34%),
|
||||||
|
#101214;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #aeb7c3;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: min(42vw, 260px);
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-width: 92px;
|
||||||
|
height: 42px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1f8f5f;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[aria-pressed="false"] {
|
||||||
|
background: #4a535f;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #c73838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 18px 18px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
|
||||||
|
grid-auto-rows: minmax(210px, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-content: stretch;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 220px;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1b2026;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #171b20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile span {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
max-width: calc(100% - 24px);
|
||||||
|
padding: 6px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.56);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-stage {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: min(58vh, 560px);
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #aeb7c3;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-grid:has(.tile) .empty-stage {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: initial;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(16, 18, 20, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions button {
|
||||||
|
height: 48px;
|
||||||
|
min-width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-preview {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: clamp(150px, 18vw, 240px);
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1b2026;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-preview video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #171b20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-preview span {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
max-width: calc(100% - 20px);
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.58);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.topbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-width: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-auto-rows: minmax(190px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions button {
|
||||||
|
min-width: calc(50% - 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-preview {
|
||||||
|
justify-self: end;
|
||||||
|
width: min(42vw, 180px);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { extname, join, normalize } from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 3000);
|
||||||
|
const host = process.env.HOST || "127.0.0.1";
|
||||||
|
const publicDir = join(process.cwd(), "public");
|
||||||
|
const rooms = new Map();
|
||||||
|
const defaultRoomId = "main";
|
||||||
|
|
||||||
|
const mimeTypes = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml"
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendJson(res, status, body) {
|
||||||
|
res.writeHead(status, {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"cache-control": "no-store"
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoom(roomId) {
|
||||||
|
if (!rooms.has(roomId)) {
|
||||||
|
rooms.set(roomId, new Map());
|
||||||
|
}
|
||||||
|
return rooms.get(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeName(name) {
|
||||||
|
const normalized = String(name || "").trim().replace(/\s+/g, " ");
|
||||||
|
return normalized.slice(0, 48) || "Guest";
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(roomId, senderId, event) {
|
||||||
|
const room = rooms.get(roomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
for (const [peerId, peer] of room) {
|
||||||
|
if (peerId === senderId) continue;
|
||||||
|
peer.res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePeer(roomId, peerId) {
|
||||||
|
const room = rooms.get(roomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
room.delete(peerId);
|
||||||
|
broadcast(roomId, peerId, { type: "peer-left", peerId });
|
||||||
|
|
||||||
|
if (room.size === 0) {
|
||||||
|
rooms.delete(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJson(req) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
|
const raw = Buffer.concat(chunks).toString("utf8");
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveStatic(req, res) {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
|
||||||
|
const safePath = normalize(decodeURIComponent(requestedPath)).replace(/^(\.\.[/\\])+/, "");
|
||||||
|
const filePath = join(publicDir, safePath);
|
||||||
|
|
||||||
|
if (!filePath.startsWith(publicDir)) {
|
||||||
|
res.writeHead(403);
|
||||||
|
res.end("Forbidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await readFile(filePath);
|
||||||
|
res.writeHead(200, {
|
||||||
|
"content-type": mimeTypes[extname(filePath)] || "application/octet-stream",
|
||||||
|
"cache-control": "no-store"
|
||||||
|
});
|
||||||
|
res.end(req.method === "HEAD" ? undefined : data);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end("Not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.method === "GET" && url.pathname.startsWith("/events/")) {
|
||||||
|
const roomId = url.pathname.split("/").at(-1) || defaultRoomId;
|
||||||
|
const displayName = normalizeName(url.searchParams.get("name"));
|
||||||
|
const peerId = randomUUID();
|
||||||
|
const room = getRoom(roomId);
|
||||||
|
const existingPeers = [...room].map(([id, peer]) => ({ id, name: peer.name }));
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
|
"cache-control": "no-store, no-transform",
|
||||||
|
connection: "keep-alive",
|
||||||
|
"x-accel-buffering": "no"
|
||||||
|
});
|
||||||
|
|
||||||
|
room.set(peerId, { res, name: displayName });
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: "welcome",
|
||||||
|
peerId,
|
||||||
|
name: displayName,
|
||||||
|
peers: existingPeers
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
|
broadcast(roomId, peerId, { type: "peer-joined", peerId, name: displayName });
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
res.write(": heartbeat\n\n");
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
req.on("close", () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
removePeer(roomId, peerId);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname.startsWith("/signal/")) {
|
||||||
|
const roomId = url.pathname.split("/").at(-1);
|
||||||
|
const payload = await parseJson(req);
|
||||||
|
|
||||||
|
if (!payload.from || !payload.to || !payload.type) {
|
||||||
|
sendJson(res, 400, { error: "Signal messages require from, to, and type." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = rooms.get(roomId);
|
||||||
|
const target = room?.get(payload.to);
|
||||||
|
if (target) {
|
||||||
|
target.res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, 202, { ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" || req.method === "HEAD") {
|
||||||
|
await serveStatic(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(405);
|
||||||
|
res.end("Method not allowed");
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, { error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
console.log(`kkkmeet is running at http://${host}:${port}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user