Real-time — Socket.IO, WebRTC Meetings & Terminal Relay¶
This document is the canonical reference for every real-time transport in the MAS / Mr. Mentor backend: the Socket.IO server that powers 1:1 mentorship video meetings (WebRTC signalling, recording, presence, chat, meeting timers) and the separate raw-WebSocket "terminal relay" that bridges a student's browser to a CLI running on their own laptop. It covers the connection handshake, the in-memory room model, every socket event group, the S3 multipart recording flow, presence/online tracking, and the token-paired terminal relay.
Status: documented from source on this branch.
Overview¶
There are two independent real-time servers, both bolted onto the same Node HTTP server created in
src/index.ts:
-
Socket.IO server (
src/socket.ts, ~1170 lines) — mounted at path/socket.io. It drives the live mentorship meeting room: WebRTC peer signalling, media toggles, the meeting timer (start / pause / resume / auto-end), client-side recording uploaded to S3 via multipart, in-room chat, and a global online-presence registry. It also doubles as the notification fan-out bus — services and workers push events to a single user viaemitToUser(src/socket/bus.ts). -
Terminal relay WebSocket server (
src/services/TerminalRelayWsServer.ts) — a barewsWebSocketServerinnoServermode, listening only for HTTPupgraderequests on/api/terminal/relay. It pairs exactly two peers (a student browser running xterm.js and the@myanalyticsschool/connectCLI on the student's own machine) sharing one short-lived token, then pipes bytes between them. This is the in-browser terminal / Monaco "Run" feature.
Who uses it
| Persona | Real-time touchpoint |
|---|---|
| Student (USER) | Joins the meeting room, sends/receives WebRTC media, chats, runs code in the browser terminal |
| Mentor (EXPERT) | Joins the meeting room, starts/stops recording, ends the meeting |
| Observer | Joins silently with the reserved name __observer__ (no participant logging, hidden from rosters) |
| Admin / Superadmin | Reviews recordings and meeting logs over REST |
| Backend services / workers | Push targeted notifications via emitToUser |
Where it sits: Socket.IO is part of the Mr. Mentor mentorship product; the terminal relay belongs to the MAS LMS in-browser coding experience. Both are cross-cutting transports rather than a single feature domain. For the booking/earnings side of meetings see mentorship-and-meetings.
Key concepts & entities¶
Glossary
- Room (
roomId) — the Socket.IO room a meeting runs in. All meeting events are scoped to it viaio.to(roomId). Frontends deriveroomIdfrom the slot. - Slot (
slotId) — the booked mentorship session (theSlotsentity). Drives start/end times, the hard auto-end deadline, and earnings crediting. - Meeting state — the in-memory
MeetingStateobject per active room (activeMeetingsmap). Not persisted as a whole; reconstructed on demand from the slot and Redis. - Participant vs Observer — a participant has a real
userIdand is counted toward starting/ending the meeting and crediting earnings. An observer joins withuserName === '__observer__'and is excluded from rosters, logging, and timers. - Notification room (
user:<userId>) — every authenticated socket joins this personal room on connect so backend code can target a user withemitToUser. - Recording session — an in-memory
RecordingSessionkeyed by<roomId>-<timestamp>, holding the S3 multipartuploadId, accumulatedparts, and apendingBufferfor sub-5MB chunks. - Terminal token — a
mas_term_*token (≈144 bits) stored in Redis with a 300s TTL that authorises one browser↔CLI bridge.
Main entities & state (file paths)
| Thing | Where | Persisted? |
|---|---|---|
MeetingState interface |
src/types/socketTypes/index.ts |
In-memory only (activeMeetings) |
User (socket roster entry) |
src/types/socketTypes/index.ts |
In-memory (roomUsers) |
RecordingSession |
src/types/socketTypes/index.ts |
In-memory (recordingSessions, src/state/recordingSessions.ts) |
MeetingRecording (DB row) |
src/entities/MeetingRecording.ts |
PostgreSQL meeting_recordings |
MeetingLogs (DB row) |
src/entities/MeetingLogs.ts |
PostgreSQL — every lifecycle event |
Slots |
src/entities/Slots.ts |
PostgreSQL — status, actualStartTime, endDateTime |
TerminalSessionRecord |
src/services/TerminalRelayService.ts |
Redis (tterm:tok:*, 300s TTL) |
In-memory maps in src/socket.ts (all module-scoped, single-process):
roomUsers // roomId -> User[] socket roster per room
activeMeetings // roomId -> MeetingState live meeting timer/participants
onlineUsers // userId -> Set<socketId> global presence
endedMeetings // Set<slotId> formally-ended guard (blocks rejoin)
slotExpiryTimers // roomId -> setTimeout handle hard auto-end timer
disconnectGraceTimers// socketId -> handle LEGACY, no longer armed
activeUserSockets // userId -> socketId takeover bookkeeping
Architecture¶
flowchart TD
subgraph CLIENT["Clients"]
FE["mr-mentor-frontend meeting room"]
XT["mas-website xterm.js + Monaco"]
CLI["mas-connect CLI on student laptop"]
end
subgraph HTTP["Node HTTP server (src/index.ts)"]
SIO["Socket.IO server (path /socket.io)"]
UPG["upgrade handler /api/terminal/relay"]
REST["Express REST routes"]
end
subgraph SOCKET["src/socket.ts setupSocket"]
ROOMS["In-memory maps: roomUsers, activeMeetings, onlineUsers, recordingSessions"]
SIG["WebRTC signalling relay"]
REC["Recording session manager"]
TIMER["Meeting timer + auto-end"]
end
subgraph RELAY["TerminalRelayWsServer"]
PAIR["pairings map (token -> peer)"]
BRIDGE["bridgePeers byte pump"]
end
subgraph SERVICES["Services"]
SCS["SlotCompletionService"]
MRS["MeetingRecordingService"]
MLS["MeetingLogsService"]
S3U["S3UploaderService"]
TRS["TerminalRelayService"]
BUS["socket/bus emitToUser"]
end
subgraph EXT["External + stores"]
PG[("PostgreSQL")]
RD[("Redis")]
S3[("AWS S3 recordings")]
end
FE -->|websocket| SIO
XT -->|browser peer| UPG
CLI -->|cli peer| UPG
FE -->|POST upload-chunk| REST
XT -->|POST terminal session| REST
SIO --> SOCKET
UPG --> RELAY
REST --> MRS
REST --> TRS
SIG --> ROOMS
REC --> MRS
REC --> S3U
TIMER --> SCS
SOCKET --> MLS
RELAY --> TRS
TRS --> RD
SCS --> PG
MRS --> PG
MLS --> PG
S3U --> S3
BUS --> SIO
TIMER --> RD
Data model¶
Real-time code owns no entities of its own beyond what it persists for meetings. The durable footprint
is MeetingRecording and MeetingLogs, both keyed by Slots.
erDiagram
SLOTS ||--o{ MEETING_RECORDINGS : "has"
SLOTS ||--o{ MEETING_LOGS : "logs"
USER ||--o{ MEETING_RECORDINGS : "recordedBy"
USER ||--o{ MEETING_LOGS : "actor"
SLOTS {
uuid id PK
uuid studentId FK
uuid mentorId FK
timestamp startDateTime
timestamp endDateTime
timestamp actualStartTime
int durationMinutes
string status
}
MEETING_RECORDINGS {
uuid id PK
uuid slotId FK
uuid recordedBy FK
string sessionId
string fileName
string s3Key
string s3Url
string s3Bucket
string status
timestamp recordingStartTime
timestamp recordingEndTime
int durationSeconds
bigint fileSizeBytes
string mimeType
boolean localFileDeleted
string errorMessage
}
MEETING_LOGS {
uuid id PK
uuid slotId FK
uuid userId FK
string roomId
string eventType
string userRole
timestamp eventTime
json metadata
string notes
}
Notable enums / status fields
RecordingStatus(src/entities/MeetingRecording.ts):recording,processing,uploaded,failed,deleted. Default on create isrecording.MeetingStatus(src/entities/Slots.ts): the socket layer specifically guards againstCOMPLETED.MeetingLogEventType(src/entities/MeetingLogs.ts):meeting_initialized,user_joined,user_left,meeting_started,meeting_ended,recording_started,recording_stopped.ParticipantRole:mentor,student,observer,unknown— derived indetermineUserRoleby comparing the socketuserIdtomeeting.mentorId/meeting.studentId.TerminalMode(src/services/TerminalRelayService.ts):sandbox,local-shell,local-ide.
API surface¶
The transports themselves are not REST. Two REST endpoints feed them, plus the recording read/write endpoints. Socket.IO and the relay listen on the same host/port as the HTTP server (default 8000).
HTTP / WebSocket entry points¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| WS | /socket.io |
handshake.auth.userId (no JWT verify — see gotchas) |
Socket.IO meeting + presence + notification transport |
| WS upgrade | /api/terminal/relay |
Token in first frame (resolved against Redis) | Browser↔CLI terminal byte relay |
| POST | /api/terminal/session |
authMiddleware (student) |
Mint a terminal token + return the relay wss:// URL |
src/routes/terminalSession.routes.ts mounts /terminal/session under /api. The relay path is matched
directly in the HTTP upgrade handler (TerminalRelayWsServer.attachTerminalRelay).
Recording REST (mounted under /api, src/routes/recording.routes.ts)¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| POST | /api/recordings/db/upload-chunk |
authMiddleware + multer 10MB |
Stream one recording chunk into the active S3 multipart upload |
| POST | /api/recordings/db/upload |
authMiddleware + multer 500MB |
Legacy full-file upload |
| POST | /api/recordings/db/upload-url |
authMiddleware |
Presigned client-side upload URL |
| POST | /api/recordings/db/confirm-upload |
authMiddleware |
Confirm a client-side upload |
| GET | /api/recordings/db/slot/:slotId |
authMiddleware |
Recordings for a slot |
| GET | /api/recordings/db/session/:sessionId |
authMiddleware |
Recording by session id |
| GET | /api/recordings/db/all |
authMiddleware + adminMiddleware |
All recordings |
| GET | /api/recordings/db/status/:status |
authMiddleware + adminMiddleware |
Recordings by status |
| GET | /api/recordings/db/stats/storage |
authMiddleware + adminMiddleware |
Storage stats |
| DELETE | /api/recordings/db/:sessionId |
authMiddleware + adminMiddleware |
Soft-delete a recording |
| GET/DELETE | /api/recordings, /api/recordings/:filename |
none (legacy local disk) | Legacy local-file listing/download/delete |
Socket.IO event catalogue¶
Inbound = client emits to server; Outbound = server emits to client(s).
| Group | Inbound events | Outbound events |
|---|---|---|
| Presence | get-online-users |
user-online, user-offline, online-users-list |
| Meeting lifecycle | initialize-meeting, join-room, end-meeting |
meeting-initialized, meeting-started, meeting-ended, meeting-ending-soon, join-denied, join-error, meeting-error, meeting-end-error, room-users, room-users-updated, user-joined, user-left, participant-rejoined |
| WebRTC signalling | offer, answer, ice-candidate |
offer, answer, ice-candidate |
| Media toggles | audio-toggle, video-toggle |
audio-toggled, video-toggled |
| Recording | start-recording, stop-recording, pause-recording, resume-recording, recording-notification-started, recording-notification-stopped |
recording-session-created, recording-bot-ready, recording-started, recording-stopped, recording-stopped-confirmed, recording-paused, recording-resumed, recording-error |
| Connection takeover | takeover-connection |
takeover-confirmed, connection-takenover |
| Timer | timer-sync-request, timer-sync-response, timer-started, timer-extended |
timer-paused, timer-resumed, timer-stopped, plus echoes of the inbound sync events |
| Chat | chat-message |
chat-message |
There is no explicit leave-room handler — leaving is handled entirely by the disconnect handler.
meeting-ending-soon is emitted by the server during auto-end (it is not a client event).
User journeys¶
1. Connection + auth handshake and presence¶
On connect, the server reads socket.handshake.auth.userId. There is no JWT verification at the socket
layer — the userId is trusted as supplied (see gotchas). If present, the socket joins its personal
notification room and is registered in the global presence map.
sequenceDiagram
participant FE as Frontend
participant IO as Socket.IO server
participant MAP as onlineUsers map
participant OTH as Other clients
FE->>IO: connect with auth userId
IO->>IO: read handshake.auth.userId
alt userId present and first socket for user
IO->>MAP: create set then add socketId
IO->>OTH: broadcast user-online for userId
else additional tab for same user
IO->>MAP: add socketId to existing set
end
IO->>IO: socket.join user room for userId
Note over IO: personal room enables emitToUser fan-out
FE->>IO: get-online-users
IO-->>FE: online-users-list with all userIds
On disconnect the socket id is removed from the user's set; when the set becomes empty the user is
dropped and user-offline is broadcast globally.
2. Meeting initialization and join (timer start)¶
initialize-meeting is idempotent per room and seeds MeetingState. join-room admits a participant,
starts the meeting when the first real participant arrives, and syncs the timer for rejoiners. A
"backfill" path covers the race where a participant joins the socket room before initialize-meeting
runs.
sequenceDiagram
participant FE as Frontend
participant IO as Socket.IO server
participant SCS as SlotCompletionService
participant DB as PostgreSQL
participant RD as Redis
FE->>IO: initialize-meeting with roomId slotId mentorId studentId
IO->>DB: find slot by id
alt slot ended or completed or past hard deadline
IO-->>FE: join-denied with reason flags
else ok and room not yet active
IO->>RD: get cached pausedMs for slot
IO->>IO: create MeetingState and store in activeMeetings
IO->>IO: schedule hard auto-end at slot end plus 5 min
IO->>DB: log MEETING_INITIALIZED
opt participants already in room
IO->>SCS: startMeeting slotId
IO->>IO: emit meeting-started to room
end
IO-->>FE: meeting-initialized
end
FE->>IO: join-room with roomId userName userId
IO->>DB: re-check slot status and deadline
alt blocked
IO-->>FE: join-denied
else allowed
IO->>IO: socket.join roomId and add to roomUsers
IO->>IO: store participant socketId to userId
IO->>DB: log USER_JOINED
opt first participant this session
IO->>SCS: startMeeting slotId
IO->>DB: set actualStartTime
IO->>IO: emit meeting-started to room
end
opt timer was paused and someone rejoined
IO->>SCS: savePausedMs for slot
IO->>IO: emit timer-resumed to room
IO-->>OTH: participant-rejoined
end
IO-->>FE: room-users with existing peers
IO-->>FE: room-users-updated broadcast
end
Note: the elapsed timer the client renders is now minus startTime minus totalPausedMs, computed
server-side and sent in meeting-started / timer-resumed payloads so reconnecting clients stay in sync.
3. WebRTC peer-connection establishment (two participants)¶
The server is a pure signalling relay — it forwards offer / answer / ice-candidate verbatim to
the single target socket (data.to) and stamps from: socket.id. Media never touches the server. Each
new joiner learns existing peers via room-users, and existing peers learn the joiner via user-joined;
the frontend decides who initiates the offer (typically the existing peer offers to the newcomer).
sequenceDiagram
participant A as Peer A existing
participant IO as Socket.IO relay
participant B as Peer B joining
Note over A,B: both already in the same roomId
B->>IO: join-room
IO-->>B: room-users lists A.socketId
IO-->>A: user-joined with B
A->>A: createOffer then setLocalDescription
A->>IO: offer with to B.socketId
IO-->>B: offer from A.socketId
B->>B: setRemoteDescription then createAnswer
B->>IO: answer with to A.socketId
IO-->>A: answer from B.socketId
A->>A: setRemoteDescription
par ICE trickle both directions
A->>IO: ice-candidate with to B.socketId
IO-->>B: ice-candidate from A.socketId
and
B->>IO: ice-candidate with to A.socketId
IO-->>A: ice-candidate from B.socketId
end
Note over A,B: peer connection established and media flows directly
A->>IO: audio-toggle isEnabled false
IO-->>B: audio-toggled with A.socketId
Media-toggle state is persisted on the roster User (initialAudioEnabled / initialVideoEnabled) so a
late joiner receives the correct current mute state.
4. Recording — client capture, S3 multipart upload, DB metadata¶
Recording is client-side: the browser does MediaRecorder capture and POSTs chunks over REST while
the socket layer manages the S3 multipart lifecycle and DB row. There is no server-side recording bot —
recording-bot-ready is emitted immediately so the client can begin. Chunks under S3's 5MB minimum are
buffered in session.pendingBuffer and flushed once they cross the threshold (or on the final chunk).
sequenceDiagram
participant FE as Recorder client
participant IO as Socket.IO server
participant REST as Recording REST
participant S3U as S3UploaderService
participant MRS as MeetingRecordingService
participant S3 as AWS S3
FE->>IO: start-recording with roomId meetingId slotId recordedBy
IO->>IO: build s3Key and create RecordingSession
IO->>MRS: createRecording status recording
IO->>S3U: startMultipartUpload for s3Key
S3U->>S3: CreateMultipartUpload
S3-->>S3U: uploadId
IO->>IO: store uploadId and init parts and nextPartNumber
IO-->>FE: recording-session-created with sessionId uploadId
IO-->>FE: recording-bot-ready
IO->>IO: emit recording-started to room
loop every chunk via REST
FE->>REST: POST upload-chunk with sessionId partNumber chunk
REST->>REST: append to pendingBuffer
alt buffer at least 5MB or final
REST->>S3U: uploadPart with buffer
S3U->>S3: UploadPart
S3-->>S3U: eTag
REST->>REST: push PartNumber and eTag to session.parts
REST-->>FE: flushed true with partNumber
else still accumulating
REST-->>FE: flushed false with bufferSize
end
end
FE->>IO: stop-recording with sessionId durationSeconds
opt pending buffer remains
IO->>S3U: uploadPart final part
end
IO->>S3U: completeMultipartUpload with sorted parts
S3U->>S3: CompleteMultipartUpload
S3-->>S3U: s3Url
IO->>MRS: updateRecordingOnStop then updateRecordingAfterUpload status uploaded
IO->>IO: log RECORDING_STOPPED
IO-->>FE: recording-stopped-confirmed success true
IO->>IO: emit recording-stopped to room
IO->>IO: delete recording session from map
Failure paths: if no parts were uploaded, the multipart upload is aborted and the row is marked
failed via updateRecordingOnUploadFailure. If the recorder socket disconnects mid-recording, the
disconnect handler marks the session failed and deletes it. pause-recording / resume-recording
only flip the in-memory isRecording flag — they do not pause the S3 upload.
5. Ending a meeting (manual + automatic)¶
A user clicks End → end-meeting. The server completes the slot (crediting mentor earnings if
participation was valid), marks the slot in endedMeetings to block rejoin, stops the timer, notifies
everyone, and cleans the room after a 1s grace so clients receive the event.
sequenceDiagram
participant FE as Frontend
participant IO as Socket.IO server
participant SCS as SlotCompletionService
participant DB as PostgreSQL
FE->>IO: end-meeting with roomId
alt meeting missing or not started
IO-->>FE: meeting-end-error
else ok
IO->>DB: log MEETING_ENDED
IO->>SCS: completeMeeting slotId now
SCS->>DB: set status COMPLETED and credit earnings
SCS-->>IO: result with earnings and participation
IO->>IO: add slotId to endedMeetings and clear expiry timer
IO->>IO: emit timer-stopped reason ended
IO->>IO: emit meeting-ended with earnings info
IO->>IO: after 1s delete roomUsers and cleanupRoom
end
Automatic end runs via scheduleSlotExpiryAutoEnd: a setTimeout fires at slot.endDateTime + 5
minutes. It emits timer-stopped, meeting-ending-soon, completes the slot, emits meeting-ended, and
cleans up. The same hard-deadline check also runs immediately when the last participant leaves an empty
room (if already past deadline, end now; otherwise just pause and allow rejoin).
6. Connection takeover (multi-tab)¶
When a user opens the meeting in a second tab, the new tab emits takeover-connection. The server
disconnects the user's older socket(s) for that room, tells the old tab it was taken over, and confirms
to the new tab.
sequenceDiagram
participant NEW as New tab
participant IO as Socket.IO server
participant OLD as Old tab
NEW->>IO: takeover-connection with roomId userId
IO->>IO: find other sockets for userId in room
loop each old socket
IO-->>OLD: connection-takenover newTab true
IO->>OLD: disconnect true
end
IO->>IO: activeUserSockets set userId to new socketId
IO-->>NEW: takeover-confirmed success true
Note a separate dedup path in join-room: if the same userId rejoins with a new socket, the old
roster entry is removed and a user-left for the old socket id is broadcast so peers drop the stale
video tile immediately.
7. In-browser terminal relay (browser ↔ student CLI)¶
The student requests a token over REST, pastes/launches the CLI with that token, and both peers dial the relay WebSocket. The relay parks the first peer and bridges the two once both present the same token.
sequenceDiagram
participant BR as Browser xterm
participant API as POST terminal session
participant TRS as TerminalRelayService
participant RD as Redis
participant WS as TerminalRelay WS
participant CLI as mas-connect CLI
BR->>API: POST terminal session with sceneId mode
API->>TRS: issueToken for studentId sceneId
TRS->>RD: reuse bind key token or mint mas_term token TTL 300s
TRS-->>API: token and expiresAt
API-->>BR: token relayUrl expiresAt
Note over BR,CLI: student starts CLI with the same token
BR->>WS: upgrade then first frame auth token peer browser
WS->>TRS: resolveToken
TRS->>RD: get token record
TRS-->>WS: record valid
WS-->>BR: ready
WS->>WS: park browser peer in pairings
CLI->>WS: upgrade then first frame auth token peer cli
WS->>TRS: resolveToken
TRS-->>WS: record valid
WS-->>CLI: ready
WS->>WS: both present so bridge peers and clear pairing
WS-->>BR: peer-connected and peer-resize dims
WS-->>CLI: peer-connected and peer-resize dims
loop interactive session
CLI-->>BR: stdout bytes binary
BR-->>CLI: keystrokes binary unless read only
BR-->>CLI: resize control message
end
CLI->>WS: close
WS-->>BR: peer-disconnect then close both
Control-frame whitelist forwarded between peers: resize, close, ide-ready, ide-error. Everything
else that is text JSON is dropped; binary frames are stdio. See the
[browser-terminal-and-editor skill] reference for the client side.
Background jobs & async¶
- Slot-expiry auto-end timers —
slotExpiryTimersholds onesetTimeoutper active room, armed atinitialize-meetingforslot.endDateTime + 5 min. These are in-process timers, not BullMQ jobs, so they are lost on restart (the deadline is re-checked on the next join/disconnect, which re-guards). - Relay heartbeat —
TerminalRelayWsServerruns a 30ssetIntervalping/pong sweep, terminating dead peers so dangling halves do not accumulate. The interval is.unref()-ed. - Meeting notification emails —
MeetingNotificationHandler(src/handlers/MeetingNotificationHandler.ts) enqueuesmeeting-approval/meeting-confirmationjobs onto the BullMQ email queue viaQueueService.addEmailJob(3 attempts, exponential backoff). This is the async path that complements the live socket events. - Notification bus —
emitToUser(userId, event, payload)insrc/socket/bus.tslets any service or worker push a real-time event to a connected user via theiruser:<userId>room. No-ops if the socket server has not booted, so workers can call it freely. - Recording uploads — chunk uploads are REST POSTs (not a queue); the multipart
partsarray lives in the in-memory session untilstop-recordingcompletes the upload. - Paused-time persistence —
totalPausedMsis mirrored to Redis (meeting:pausedMs:<slotId>) and the DB viaSlotCompletionService.savePausedMsso elapsed time survives a room re-init.
External integrations¶
| System | Used by | Env vars | Failure / fallback |
|---|---|---|---|
| AWS S3 | Recording multipart upload | AWS_S3_BUCKET_NAME, AWS_REGION, AWS creds |
On startMultipartUpload failure: emit recording-error, delete session. On complete failure: abort MPU, mark row failed |
| Redis | Terminal tokens, paused-ms cache | REDIS_HOST, REDIS_PORT |
Token issuance/resolution requires Redis; relay auth fails closed if record missing |
| PostgreSQL | Meeting logs, recordings, slots | DB_* |
Log writes are wrapped non-fatal (try/catch, logged and continued) |
| Socket.IO CORS | Browser origin allow-list | CORS_ALLOWED_ORIGINS (JSON array) |
Defaults to [] (empty) if unset — no origins allowed |
| Meeting join window | Early-join control | ALLOW_EARLY_MEETING_JOIN, MEETING_JOIN_BUFFER_MINUTES |
If true, all join-time checks pass; on DB error canJoinMeeting fails open (allows) |
Feature flags / config
ALLOW_EARLY_MEETING_JOIN=truebypasses the start-time buffer check entirely.MEETING_JOIN_BUFFER_MINUTES(default 5) is how early a user may join beforestartDateTime.- The hard auto-end grace is a constant:
GRACE_PERIOD_AFTER_SLOT_END_MS = 5 * 60 * 1000. - Terminal token TTL is a constant
300sinTerminalRelayService.
Status lifecycles¶
MeetingRecording¶
stateDiagram-v2
[*] --> recording: createRecording on start-recording
recording --> uploaded: stop-recording then multipart complete
recording --> failed: complete fails or no parts or disconnect
recording --> processing: reserved transitional state
processing --> uploaded
uploaded --> deleted: admin DELETE
failed --> [*]
deleted --> [*]
uploaded --> [*]
Meeting state (in-memory, per room)¶
stateDiagram-v2
[*] --> initialized: initialize-meeting
initialized --> started: first participant join-room
started --> paused: last participant leaves before deadline
paused --> started: someone rejoins
started --> ended: end-meeting or hard deadline
paused --> ended: deadline reached while empty
ended --> [*]: cleanupRoom after delay
Terminal relay pairing¶
stateDiagram-v2
[*] --> waiting: first peer auth ok and parked
waiting --> bridged: second peer auth ok
waiting --> dropped: first peer closes before partner
bridged --> closed: either peer closes or errors
dropped --> [*]
closed --> [*]
Edge cases, limits & gotchas¶
- No JWT verification on the socket handshake.
src/socket.tstrustssocket.handshake.auth.userIdas-is for presence and notification-room membership. A client could supply anyuserId. (inferred: this is acceptable today because meeting authorisation is enforced at booking time and meeting events are room-scoped, but it is a real trust gap to flag.) - CORS defaults to deny. If
CORS_ALLOWED_ORIGINSis unset, the parsed origin list is[], so all browser origins are rejected. This env var must be set in every environment. - Single-process in-memory state.
roomUsers,activeMeetings,onlineUsers,endedMeetings, and the recording-session map live in module scope. The app does not use a Socket.IO Redis adapter, so it will not scale horizontally without sticky sessions and/or an adapter. A blue-green/restart loses active meeting timers (re-derived from slot + Redis paused-ms on next join). - Terminal relay requires both peers on the same instance.
pairingsis in-memory; the code explicitly documents the multi-replica caveat (needs sticky sessions or a Redis pub/sub router). - Recording-bot-ready is a no-op signal. There is no server bot — the name is historical; recording is fully client-driven.
- S3 5MB minimum part size. Chunks are accumulated in
pendingBufferuntil they reach 5MB; only the final part may be smaller. Parts are sorted byPartNumberbeforecompleteMultipartUpload. fileSizeBytesis always 0. The stop handler does not track per-part sizes, so the DB row stores 0 for size (totalFileSize = 0).- Idempotent terminal tokens.
issueTokenreuses the live token for a(studentId, sceneId)pair via thetterm:bind:reverse index, so React strict-mode double mounts and refreshes do not orphan a token the CLI is already paired with. Tokens are not consumed on read because both peers present the same one. - Read-only terminal sessions. If the CLI handshakes with
readOnly: true, the relay drops browser→CLI binary frames server-side (defense in depth; the CLI also drops them). - Disconnect grace is legacy.
disconnectGraceTimersexists but disconnect handling is now immediate — peers seeuser-leftat once. Code comments note the old 15s grace period was removed. - Observer escape hatch.
userName === '__observer__'bypasses join checks, participant logging, timers, and rosters. Anyone using that exact name joins silently. leave-roomis not handled explicitly — all departures flow throughdisconnect. A client that navigates away without disconnecting the socket can briefly remain in the roster.- Multi-platform (
x-platform) routing does not affect these transports directly; the socket layer is platform-agnostic and keys everything offslotId/roomId.
Related docs¶
- Mentorship & Meetings — slot booking, completion, earnings.
- Recordings & S3 storage — recording entity, retention, S3 layout.
- Background jobs & BullMQ — email queue and workers.
- MAS LMS in-browser terminal — the
mas-connectCLI client side. - Architecture overview — where real-time sits in the suite.