Private
Public Access
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86584660fb | ||
|
|
6f67f36578 | ||
|
|
b1c9a85ab3 | ||
|
|
62a25c6278 | ||
|
|
7a84d2f09f | ||
|
|
d74527a29c | ||
|
|
f0e4c76ffd |
No files matched your search
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.0.1",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
|
|
||||||
"port": 5173
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "frontend-preview",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["--prefix", "frontend", "run", "preview"],
|
|
||||||
"port": 4173
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,7 @@ dist-ssr/
|
|||||||
## Editors / OS
|
## Editors / OS
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
## Test / coverage
|
## Test / coverage
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Contributing to DS Chat
|
||||||
|
|
||||||
|
Thanks for considering a contribution.
|
||||||
|
|
||||||
|
## Reporting bugs and requesting features
|
||||||
|
|
||||||
|
Open an issue on this project's issue tracker. Include steps to reproduce
|
||||||
|
for a bug, or the problem you're trying to solve for a feature request —
|
||||||
|
that's usually more useful than a proposed solution.
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
See the root [README.md](README.md)'s Quickstart, plus
|
||||||
|
[backend/README.md](backend/README.md) and
|
||||||
|
[frontend/README.md](frontend/README.md) for the full local dev setup
|
||||||
|
(Postgres, Redis, Python venv, migrations, the Vite dev server).
|
||||||
|
[ARCHITECTURE.md](ARCHITECTURE.md) covers the overall system design if
|
||||||
|
you're orienting yourself before a larger change.
|
||||||
|
|
||||||
|
## Before opening a pull request
|
||||||
|
|
||||||
|
- **Tests**: run `pytest` in `backend/` for any backend change, and add
|
||||||
|
tests for new behavior rather than just the happy path — see
|
||||||
|
`backend/README.md`'s "Run tests" section. For frontend changes, run
|
||||||
|
`npx tsc -b` in `frontend/` and confirm `npm run build` succeeds.
|
||||||
|
- **Style**: match the conventions already in the file you're editing
|
||||||
|
rather than introducing a new pattern — this codebase doesn't have a
|
||||||
|
separate style guide beyond "look at what's already there."
|
||||||
|
- **Scope**: smaller, focused PRs are easier to review than large ones
|
||||||
|
that mix unrelated changes.
|
||||||
|
|
||||||
|
## Contributor terms
|
||||||
|
|
||||||
|
By submitting a contribution (a pull request, patch, or similar), you
|
||||||
|
agree that:
|
||||||
|
|
||||||
|
1. Your contribution is licensed under the project's own license,
|
||||||
|
AGPL-3.0-or-later ([LICENSE](LICENSE)), and
|
||||||
|
2. You grant the project's maintainer(s) a perpetual, worldwide,
|
||||||
|
non-exclusive right to also relicense your contribution under different
|
||||||
|
terms — for example, as part of a separately-licensed commercial
|
||||||
|
offering built on this project.
|
||||||
|
|
||||||
|
This keeps the option of a future dual-licensed (open-source +
|
||||||
|
commercial) version of the project available, without requiring a
|
||||||
|
separate signed agreement for every contribution.
|
||||||
|
|
||||||
|
*This is a lightweight starting point, not a substitute for legal advice —
|
||||||
|
if you're contributing something substantial, or maintaining a fork with
|
||||||
|
your own commercial plans, it's worth having this reviewed by a lawyer
|
||||||
|
rather than relying on the paragraph above alone.*
|
||||||
+37
-15
@@ -132,35 +132,45 @@ sudo -u ds-chat ssh-keygen -t ed25519 -f /srv/ds-chat/.ssh/id_ed25519 -N ""
|
|||||||
sudo cat /srv/ds-chat/.ssh/id_ed25519.pub
|
sudo cat /srv/ds-chat/.ssh/id_ed25519.pub
|
||||||
```
|
```
|
||||||
|
|
||||||
Add that public key as a **read-only deploy key** on the Gitea repo
|
This project is hosted at
|
||||||
(Settings → Deploy Keys), then:
|
**[github.com/ds-ksmith/DS-Chat](https://github.com/ds-ksmith/DS-Chat)**.
|
||||||
|
Add that public key there as a **read-only deploy key** (Settings → Deploy
|
||||||
|
Keys on the repo), then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u ds-chat ssh-keyscan git.darksingularity.org >> /srv/ds-chat/.ssh/known_hosts
|
sudo -u ds-chat ssh-keyscan github.com >> /srv/ds-chat/.ssh/known_hosts
|
||||||
sudo -u ds-chat git clone git@git.darksingularity.org:DarkSingularity/ds-chat.git /srv/ds-chat
|
sudo -u ds-chat git clone git@github.com:ds-ksmith/DS-Chat.git /srv/ds-chat
|
||||||
```
|
```
|
||||||
|
|
||||||
(If your Gitea's SSH is on a non-default port, adjust the clone URL and
|
(Deploying from your own fork instead? Substitute its clone URL — the same
|
||||||
`ssh-keyscan -p <port>` accordingly.)
|
deploy-key/access-token steps work the same way on GitHub, GitLab, Gitea,
|
||||||
|
and most other git hosts.)
|
||||||
|
|
||||||
**Alternative: a personal/deployment-user access token instead of a deploy
|
**Alternative: a personal/deployment-user access token instead of a deploy
|
||||||
key** — skip the `.ssh`/`ssh-keygen`/`ssh-keyscan` commands above entirely
|
key** — skip the `.ssh`/`ssh-keygen`/`ssh-keyscan` commands above entirely
|
||||||
and clone over HTTPS with the token embedded in the URL:
|
and clone over HTTPS with the token embedded in the URL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u ds-chat git clone https://<TOKEN>@git.darksingularity.org/DarkSingularity/ds-chat.git /srv/ds-chat
|
sudo -u ds-chat git clone https://<TOKEN>@github.com/ds-ksmith/DS-Chat.git /srv/ds-chat
|
||||||
```
|
```
|
||||||
|
|
||||||
The token then lives in plaintext in `/srv/ds-chat/.git/config` (`git
|
The token then lives in plaintext in `/srv/ds-chat/.git/config` (`git
|
||||||
remote -v` shows it) — readable by root and the `ds-chat` user, not by
|
remote -v` shows it) — readable by root and the `ds-chat` user, not by
|
||||||
anyone else under normal file permissions. `deploy/upgrade.sh`'s later
|
anyone else under normal file permissions. `deploy/upgrade.sh`'s later
|
||||||
`git pull`s reuse this same authenticated URL automatically, no extra
|
`git fetch`es reuse this same authenticated URL automatically, no extra
|
||||||
setup needed. Fine as long as the token is scoped to read-only access on
|
setup needed. Fine as long as the token is scoped to read-only access on
|
||||||
just this repo.
|
just this repo.
|
||||||
|
|
||||||
Either way, now that the repo is cloned:
|
Either way, now that the repo is cloned, check out the latest release tag
|
||||||
|
rather than deploying whatever the default branch's tip happens to be —
|
||||||
|
`deploy/upgrade.sh` follows the same rule on every later upgrade (see §6),
|
||||||
|
so this keeps the very first deploy consistent with all the ones after it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd /srv/ds-chat
|
||||||
|
sudo -u ds-chat git fetch --tags
|
||||||
|
LATEST_TAG="$(sudo -u ds-chat git tag --sort=-creatordate | head -n1)"
|
||||||
|
sudo -u ds-chat git checkout --detach "$LATEST_TAG"
|
||||||
sudo -u ds-chat mkdir -p /srv/ds-chat/uploads
|
sudo -u ds-chat mkdir -p /srv/ds-chat/uploads
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -232,7 +242,16 @@ admin sets it up.
|
|||||||
`/ws`) whenever that directory exists — that's what lets Nginx Proxy
|
`/ws`) whenever that directory exists — that's what lets Nginx Proxy
|
||||||
Manager forward the whole domain to one port with no custom path routing.
|
Manager forward the whole domain to one port with no custom path routing.
|
||||||
|
|
||||||
|
Before building, copy `frontend/.env.example` to `frontend/.env.production`
|
||||||
|
and set `VITE_SOURCE_URL` to wherever *your* copy of the repo lives — see
|
||||||
|
that file's own comment for why this matters (AGPL-3.0 source-availability
|
||||||
|
compliance). Vite bakes this in at build time, so it needs to be in place
|
||||||
|
before `npm run build` runs, and needs re-running after any future change
|
||||||
|
to it.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
sudo -u ds-chat cp /srv/ds-chat/frontend/.env.example /srv/ds-chat/frontend/.env.production
|
||||||
|
sudo -u ds-chat nano /srv/ds-chat/frontend/.env.production # set VITE_SOURCE_URL
|
||||||
sudo -u ds-chat bash -c 'cd /srv/ds-chat/frontend && npm ci && npm run build'
|
sudo -u ds-chat bash -c 'cd /srv/ds-chat/frontend && npm ci && npm run build'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -320,12 +339,15 @@ This is config in NPM's own UI/database, not a file this repo ships:
|
|||||||
sudo -u ds-chat /srv/ds-chat/deploy/upgrade.sh
|
sudo -u ds-chat /srv/ds-chat/deploy/upgrade.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Pulls latest `main`, reinstalls backend deps, runs `alembic upgrade head`,
|
Fetches tags and checks out whichever one sorts newest (`git tag
|
||||||
rebuilds the frontend, restarts `ds-chat`, and curls `/api/health` to
|
--sort=-creatordate`) — deliberately not the default branch's tip, so
|
||||||
confirm it came back up. Fails loudly (`set -euo pipefail`) and stops
|
running this between releases is a safe no-op rather than pulling in
|
||||||
before restarting anything if an earlier step — most importantly a failed
|
whatever's mid-flight on `main`. Then reinstalls backend deps, runs
|
||||||
migration — errors out, so a bad deploy doesn't take down the previously
|
`alembic upgrade head`, rebuilds the frontend, restarts `ds-chat`, and
|
||||||
working one.
|
curls `/api/health` to confirm it came back up. Fails loudly
|
||||||
|
(`set -euo pipefail`) and stops before restarting anything if an earlier
|
||||||
|
step — most importantly a failed migration — errors out, so a bad deploy
|
||||||
|
doesn't take down the previously working one.
|
||||||
|
|
||||||
Active users get disconnected for a few seconds during the restart and
|
Active users get disconnected for a few seconds during the restart and
|
||||||
reconnect automatically (same reconnect logic as §4's NPM-timeout note) —
|
reconnect automatically (same reconnect logic as §4's NPM-timeout note) —
|
||||||
|
|||||||
@@ -629,8 +629,8 @@ to attach them to the start of each source file to most effectively
|
|||||||
state the exclusion of warranty; and each file should have at least
|
state the exclusion of warranty; and each file should have at least
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
DS Chat, a self-hosted, real-time team chat service.
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) 2026 Keith Smith
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from app.ws.connection_manager import ConnectionManager
|
from app.ws.connection_manager import ConnectionManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ROOM_CHANNEL_PREFIX = "room:"
|
ROOM_CHANNEL_PREFIX = "room:"
|
||||||
USER_CHANNEL_PREFIX = "user:"
|
USER_CHANNEL_PREFIX = "user:"
|
||||||
|
|
||||||
@@ -43,14 +46,26 @@ class Broadcaster:
|
|||||||
async for message in pubsub.listen():
|
async for message in pubsub.listen():
|
||||||
if message["type"] != "pmessage":
|
if message["type"] != "pmessage":
|
||||||
continue
|
continue
|
||||||
channel = message["channel"]
|
# #76: this is the one pubsub listener for the whole process
|
||||||
payload = json.loads(message["data"])
|
# (see main.py's lifespan) -- ConnectionManager.broadcast/
|
||||||
if channel.startswith(ROOM_CHANNEL_PREFIX):
|
# send_to_user already guard against one dead socket taking
|
||||||
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX))
|
# the rest of a single fan-out down, but this belt-and-
|
||||||
await self._manager.broadcast(room_id, payload)
|
# suspenders catch is for anything else unexpected (a
|
||||||
elif channel.startswith(USER_CHANNEL_PREFIX):
|
# malformed payload, e.g.) doing the same. An uncaught
|
||||||
user_id = uuid.UUID(channel.removeprefix(USER_CHANNEL_PREFIX))
|
# exception here previously meant *this whole async for*
|
||||||
await self._manager.send_to_user(user_id, payload)
|
# loop died silently -- no more live delivery to any room on
|
||||||
|
# this instance, for anyone, until the process restarted.
|
||||||
|
try:
|
||||||
|
channel = message["channel"]
|
||||||
|
payload = json.loads(message["data"])
|
||||||
|
if channel.startswith(ROOM_CHANNEL_PREFIX):
|
||||||
|
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX))
|
||||||
|
await self._manager.broadcast(room_id, payload)
|
||||||
|
elif channel.startswith(USER_CHANNEL_PREFIX):
|
||||||
|
user_id = uuid.UUID(channel.removeprefix(USER_CHANNEL_PREFIX))
|
||||||
|
await self._manager.send_to_user(user_id, payload)
|
||||||
|
except Exception:
|
||||||
|
logger.error("Error handling pubsub message on channel %s", message.get("channel"), exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
||||||
await pubsub.aclose()
|
await pubsub.aclose()
|
||||||
+33
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||||
@@ -31,6 +32,18 @@ router = APIRouter(tags=["ws"])
|
|||||||
|
|
||||||
WS_UNAUTHENTICATED = 4401
|
WS_UNAUTHENTICATED = 4401
|
||||||
|
|
||||||
|
# #76: comfortably under the ~60s idle-connection timeout common to reverse
|
||||||
|
# proxies/load balancers sitting in front of this endpoint, and short enough
|
||||||
|
# that the frontend's own zombie-detection watchdog (useChatSocket.ts) can
|
||||||
|
# notice a dead connection and reconnect well within a session -- a socket
|
||||||
|
# can go silently dead (no close frame at all, e.g. a laptop sleeping or a
|
||||||
|
# NAT dropping an idle mapping) without this, and no live message reaches it
|
||||||
|
# again until *something* else finally triggers a close, which used to be
|
||||||
|
# "switch rooms and back" purely by accident (that remounts the message
|
||||||
|
# list, which refetches history over plain REST -- unrelated to the socket
|
||||||
|
# itself actually recovering).
|
||||||
|
WS_PING_INTERVAL_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
class ClientEnvelope(BaseModel):
|
class ClientEnvelope(BaseModel):
|
||||||
type: str
|
type: str
|
||||||
@@ -124,7 +137,18 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
raw = await websocket.receive_json()
|
try:
|
||||||
|
raw = await asyncio.wait_for(
|
||||||
|
websocket.receive_json(), timeout=WS_PING_INTERVAL_SECONDS
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Nothing received in a while -- ping rather than assume the
|
||||||
|
# connection's still good. A genuinely dead socket fails this
|
||||||
|
# send (raising, caught below same as any other disconnect);
|
||||||
|
# a merely quiet one just gets proded and the loop continues
|
||||||
|
# waiting.
|
||||||
|
await websocket.send_json({"type": "ping"})
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
envelope = ClientEnvelope.model_validate(raw)
|
envelope = ClientEnvelope.model_validate(raw)
|
||||||
@@ -146,6 +170,14 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
|||||||
joined_rooms.add(envelope.room_id)
|
joined_rooms.add(envelope.room_id)
|
||||||
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
|
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
|
||||||
|
|
||||||
|
elif envelope.type == "pong":
|
||||||
|
# #76: the client's reply to our "ping" above -- nothing
|
||||||
|
# to do with it. Its only purpose is resetting the
|
||||||
|
# frontend's own liveness watchdog; the timeout on the
|
||||||
|
# receive_json() above already gets reset by receiving
|
||||||
|
# any frame at all, this one included.
|
||||||
|
pass
|
||||||
|
|
||||||
elif envelope.type == "leave":
|
elif envelope.type == "leave":
|
||||||
if envelope.room_id is None:
|
if envelope.room_id is None:
|
||||||
await websocket.send_json({"type": "error", "detail": "room_id required"})
|
await websocket.send_json({"type": "error", "detail": "room_id required"})
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
"""Local, single-process WebSocket socket registry.
|
"""Local, single-process WebSocket socket registry.
|
||||||
@@ -43,8 +46,22 @@ class ConnectionManager:
|
|||||||
|
|
||||||
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
|
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
|
||||||
for websocket in list(self._rooms.get(room_id, ())):
|
for websocket in list(self._rooms.get(room_id, ())):
|
||||||
await websocket.send_json(payload)
|
# #76: one dead/racing socket (e.g. its own disconnect cleanup
|
||||||
|
# hasn't finished removing it from _rooms yet) must not take the
|
||||||
|
# rest of this broadcast down with it -- this runs inside
|
||||||
|
# Broadcaster.listen()'s single, never-restarted per-process
|
||||||
|
# pubsub loop, so an unhandled exception here previously meant
|
||||||
|
# one bad socket silently killed *live delivery for every room
|
||||||
|
# on this instance*, not just the one socket, until the process
|
||||||
|
# was restarted.
|
||||||
|
try:
|
||||||
|
await websocket.send_json(payload)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Dropping dead socket during broadcast to room %s", room_id, exc_info=True)
|
||||||
|
|
||||||
async def send_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
|
async def send_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
|
||||||
for websocket in list(self._users.get(user_id, ())):
|
for websocket in list(self._users.get(user_id, ())):
|
||||||
await websocket.send_json(payload)
|
try:
|
||||||
|
await websocket.send_json(payload)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Dropping dead socket sending to user %s", user_id, exc_info=True)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ds-chat"
|
name = "ds-chat"
|
||||||
version = "2026.9.3"
|
version = "2026.9.23"
|
||||||
description = "DS Chat backend service"
|
description = "DS Chat backend service"
|
||||||
license = { text = "AGPL-3.0-or-later" }
|
license = { text = "AGPL-3.0-or-later" }
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -109,6 +109,24 @@ def test_history_tiebreaks_identical_timestamps_by_id(ws_client):
|
|||||||
assert [m["content"] for m in history] == [m["content"] for m in expected_order]
|
assert [m["content"] for m in history] == [m["content"] for m in expected_order]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_sends_heartbeat_ping(ws_client, monkeypatch):
|
||||||
|
# #76: with nothing else sent on the connection, a "ping" must arrive
|
||||||
|
# proactively once the idle timeout elapses -- shrunk here so the test
|
||||||
|
# doesn't actually wait the real 30s. Replying "pong" (the frontend's
|
||||||
|
# own behavior) must be accepted silently and leave the connection
|
||||||
|
# fully usable afterward, not treated as an unknown message type.
|
||||||
|
monkeypatch.setattr("app.ws.chat.WS_PING_INTERVAL_SECONDS", 0.05)
|
||||||
|
username = _unique("alice")
|
||||||
|
_register(ws_client, username=username)
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
assert ws.receive_json() == {"type": "ping"}
|
||||||
|
ws.send_json({"type": "pong"})
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json() == {"type": "joined", "room_id": room["id"]}
|
||||||
|
|
||||||
|
|
||||||
def test_ws_message_without_join_errors(ws_client):
|
def test_ws_message_without_join_errors(ws_client):
|
||||||
_register(ws_client, username=_unique("alice"))
|
_register(ws_client, username=_unique("alice"))
|
||||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|||||||
+11
-2
@@ -16,9 +16,18 @@ BACKEND_DIR="${REPO_DIR}/backend"
|
|||||||
FRONTEND_DIR="${REPO_DIR}/frontend"
|
FRONTEND_DIR="${REPO_DIR}/frontend"
|
||||||
ENV_FILE="/etc/ds-chat/env"
|
ENV_FILE="/etc/ds-chat/env"
|
||||||
|
|
||||||
echo "==> Pulling latest code"
|
echo "==> Fetching latest release"
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
git pull --ff-only
|
git fetch --tags --force
|
||||||
|
LATEST_TAG="$(git tag --sort=-creatordate | head -n1)"
|
||||||
|
if [[ -z "$LATEST_TAG" ]]; then
|
||||||
|
echo "No tags found -- nothing to deploy" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Deploying $LATEST_TAG"
|
||||||
|
# Detached HEAD, not a branch checkout -- this directory only ever runs a
|
||||||
|
# tagged release, never whatever the default branch's tip happens to be.
|
||||||
|
git checkout --quiet --detach "$LATEST_TAG"
|
||||||
|
|
||||||
echo "==> Installing backend dependencies"
|
echo "==> Installing backend dependencies"
|
||||||
cd "$BACKEND_DIR"
|
cd "$BACKEND_DIR"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Shown as the "Source code" link in the app's About screen -- required
|
||||||
|
# for AGPL-3.0 section 13 compliance once you deploy this (a link so users
|
||||||
|
# interacting with the app over the network can get the actual source,
|
||||||
|
# including any modifications you've made). The default below points at
|
||||||
|
# the upstream project -- fine if you're running it unmodified, but if
|
||||||
|
# you've forked or patched the code, point this at *your* copy instead.
|
||||||
|
VITE_SOURCE_URL=https://github.com/ds-ksmith/DS-Chat
|
||||||
+1
-1
@@ -112,7 +112,7 @@ src/
|
|||||||
LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx
|
LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx
|
||||||
ChatShellPage.tsx, AdminPage.tsx, HelpPage.tsx
|
ChatShellPage.tsx, AdminPage.tsx, HelpPage.tsx
|
||||||
|
|
||||||
styles/tokens.css design tokens (DarkSingularity theme: colors, spacing, etc.)
|
styles/tokens.css design tokens (default theme: colors, spacing, etc.)
|
||||||
sw.ts custom service worker (injectManifest): app-shell
|
sw.ts custom service worker (injectManifest): app-shell
|
||||||
precache + NetworkFirst runtime caching, push/notificationclick
|
precache + NetworkFirst runtime caching, push/notificationclick
|
||||||
handlers, SKIP_WAITING messaging for the update-prompt flow
|
handlers, SKIP_WAITING messaging for the update-prompt flow
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2026.9.3",
|
"version": "2026.9.23",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ interface AboutModalProps {
|
|||||||
// users remotely through a computer network, you should also make sure
|
// users remotely through a computer network, you should also make sure
|
||||||
// that it provides a way for users to get its source... its interface
|
// that it provides a way for users to get its source... its interface
|
||||||
// could display a 'Source' link" -- this modal is that link, not just a
|
// could display a 'Source' link" -- this modal is that link, not just a
|
||||||
// courtesy credits screen.
|
// courtesy credits screen. Deliberately not a hardcoded URL: whoever
|
||||||
const SOURCE_URL = 'https://git.darksingularity.org/DarkSingularity/ds-chat'
|
// deploys this needs to point it at *their* copy of the repo (including
|
||||||
|
// any modifications), not the upstream project -- see frontend/.env.example.
|
||||||
|
const SOURCE_URL = import.meta.env.VITE_SOURCE_URL as string | undefined
|
||||||
|
|
||||||
export function AboutModal({ onClose }: AboutModalProps) {
|
export function AboutModal({ onClose }: AboutModalProps) {
|
||||||
return (
|
return (
|
||||||
@@ -39,11 +41,13 @@ export function AboutModal({ onClose }: AboutModalProps) {
|
|||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
<p className="about-modal-line">
|
{SOURCE_URL && (
|
||||||
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
|
<p className="about-modal-line">
|
||||||
Source code
|
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
|
||||||
</a>
|
Source code
|
||||||
</p>
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="modal-actions" style={{ marginTop: '1rem' }}>
|
<div className="modal-actions" style={{ marginTop: '1rem' }}>
|
||||||
<button type="button" className="btn-secondary" onClick={onClose}>
|
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ interface UseChatSocketOptions {
|
|||||||
const RECONNECT_BASE_DELAY_MS = 1000
|
const RECONNECT_BASE_DELAY_MS = 1000
|
||||||
const RECONNECT_MAX_DELAY_MS = 30000
|
const RECONNECT_MAX_DELAY_MS = 30000
|
||||||
|
|
||||||
|
// #76: the backend pings every 30s (WS_PING_INTERVAL_SECONDS in chat.py) --
|
||||||
|
// this needs to comfortably tolerate one missed ping plus ordinary network
|
||||||
|
// jitter before declaring the connection dead, not fire on the very first
|
||||||
|
// late beat.
|
||||||
|
const ZOMBIE_TIMEOUT_MS = 65000
|
||||||
|
const ZOMBIE_CHECK_INTERVAL_MS = 10000
|
||||||
|
|
||||||
const desktopMode = isDesktopNotificationsSupported()
|
const desktopMode = isDesktopNotificationsSupported()
|
||||||
|
|
||||||
// Room join/leave (live message delivery) depends only on visibility --
|
// Room join/leave (live message delivery) depends only on visibility --
|
||||||
@@ -51,6 +58,16 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
|
|||||||
// live WebSocket delivery the user can't actually see will do the job.
|
// live WebSocket delivery the user can't actually see will do the job.
|
||||||
const desiredRoomsRef = useRef(new Set<string>())
|
const desiredRoomsRef = useRef(new Set<string>())
|
||||||
const isVisibleRef = useRef(isVisible())
|
const isVisibleRef = useRef(isVisible())
|
||||||
|
// #76: last time *anything* arrived on the socket (a real message, a
|
||||||
|
// "ping", doesn't matter which) -- the only signal that a connection
|
||||||
|
// sitting at readyState OPEN is actually still alive rather than a
|
||||||
|
// zombie. Without this, a connection that dies without ever sending a
|
||||||
|
// close frame (laptop sleep, a NAT silently dropping an idle mapping)
|
||||||
|
// looks identical to a healthy-but-quiet one: onclose never fires, so the
|
||||||
|
// already-correct reconnect+rejoin logic below never runs, and new
|
||||||
|
// messages just stop arriving until something unrelated (e.g. switching
|
||||||
|
// rooms, which refetches history over plain REST) papers over it.
|
||||||
|
const lastActivityRef = useRef(Date.now())
|
||||||
|
|
||||||
const sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => {
|
const sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => {
|
||||||
const ws = socketRef.current
|
const ws = socketRef.current
|
||||||
@@ -99,6 +116,7 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
|
|||||||
if (hasConnectedBefore) checkForUpdate()
|
if (hasConnectedBefore) checkForUpdate()
|
||||||
hasConnectedBefore = true
|
hasConnectedBefore = true
|
||||||
reconnectDelay = RECONNECT_BASE_DELAY_MS
|
reconnectDelay = RECONNECT_BASE_DELAY_MS
|
||||||
|
lastActivityRef.current = Date.now()
|
||||||
setConnected(true)
|
setConnected(true)
|
||||||
// Re-join whatever rooms were joined before a reconnect -- the
|
// Re-join whatever rooms were joined before a reconnect -- the
|
||||||
// server has no memory of a dropped connection's prior state. Only
|
// server has no memory of a dropped connection's prior state. Only
|
||||||
@@ -122,7 +140,17 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
|
|||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
if (socketRef.current !== ws) return
|
if (socketRef.current !== ws) return
|
||||||
const envelope = JSON.parse(event.data) as ServerEnvelope
|
lastActivityRef.current = Date.now()
|
||||||
|
const parsed = JSON.parse(event.data)
|
||||||
|
// #76: the server's heartbeat -- purely transport-level, not a
|
||||||
|
// domain event, so it's answered and swallowed right here instead
|
||||||
|
// of being forwarded to subscribers (ServerEnvelope's own type
|
||||||
|
// doesn't include it for exactly that reason).
|
||||||
|
if (parsed.type === 'ping') {
|
||||||
|
ws.send(JSON.stringify({ type: 'pong' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const envelope = parsed as ServerEnvelope
|
||||||
for (const handler of subscribersRef.current) handler(envelope)
|
for (const handler of subscribersRef.current) handler(envelope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,10 +171,9 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
|
|||||||
if (stopped) return
|
if (stopped) return
|
||||||
|
|
||||||
// Unexpected close -- a deploy restarting the app server, a brief
|
// Unexpected close -- a deploy restarting the app server, a brief
|
||||||
// network blip, or (absent any app-level ping/pong) an idle
|
// network blip, or an idle connection getting recycled by a reverse
|
||||||
// connection getting recycled by a reverse proxy in front of it.
|
// proxy in front of it. Retry with exponential backoff instead of
|
||||||
// Retry with exponential backoff instead of leaving the chat
|
// leaving the chat silently dead until the user manually reloads.
|
||||||
// silently dead until the user manually reloads.
|
|
||||||
reconnectTimer = setTimeout(connect, reconnectDelay)
|
reconnectTimer = setTimeout(connect, reconnectDelay)
|
||||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS)
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS)
|
||||||
}
|
}
|
||||||
@@ -154,9 +181,21 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
|
|||||||
|
|
||||||
connect()
|
connect()
|
||||||
|
|
||||||
|
// #76: catches the case onclose can't -- a connection that dies without
|
||||||
|
// ever sending a close frame at all (readyState stays OPEN forever) is
|
||||||
|
// otherwise invisible to this hook. Forcing a close here just routes it
|
||||||
|
// through the exact same onclose/reconnect/rejoin path as a normal
|
||||||
|
// disconnect, rather than needing separate recovery logic of its own.
|
||||||
|
const zombieCheckTimer = setInterval(() => {
|
||||||
|
if (Date.now() - lastActivityRef.current > ZOMBIE_TIMEOUT_MS) {
|
||||||
|
socketRef.current?.close()
|
||||||
|
}
|
||||||
|
}, ZOMBIE_CHECK_INTERVAL_MS)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true
|
stopped = true
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
|
clearInterval(zombieCheckTimer)
|
||||||
socketRef.current?.close()
|
socketRef.current?.close()
|
||||||
socketRef.current = null
|
socketRef.current = null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user