3 Commits

Author SHA1 Message Date
20317226b7 Merge branch 'main' into fix/spa-reconnect-channel-tabs
All checks were successful
check / check (push) Successful in 1m1s
2026-03-10 00:55:32 +01:00
user
122968d7c4 docs: document ?replay=1 query parameter for GET /state
All checks were successful
check / check (push) Successful in 2m14s
2026-03-09 15:49:07 -07:00
user
785e557b87 fix: replay channel state on SPA reconnect
Some checks failed
check / check (push) Has been cancelled
When a client reconnects to an existing session (e.g. browser tab
closed and reopened), the server now enqueues synthetic JOIN messages
plus TOPIC/NAMES numerics for every channel the session belongs to.
These are delivered only to the reconnecting client, not broadcast
to other users.

Server changes:
- Add replayChannelState() to handlers that enqueues per-channel
  JOIN + join-numerics (332/353/366) to a specific client.
- HandleState accepts ?replay=1 query parameter to trigger replay.
- HandleLogin (password auth) also replays channel state for the
  new client since it creates a fresh client for an existing session.

SPA changes:
- On resume, call /state?replay=1 instead of /state so the server
  enqueues channel state into the message queue.
- processMessage now creates channel tabs when receiving a JOIN
  where msg.from matches the current nick (handles both live joins
  and replayed joins on reconnect).
- onLogin no longer re-sends JOIN commands for saved channels on
  resume — the server handles it via the replay mechanism, avoiding
  spurious JOIN broadcasts to other channel members.

Closes #60
2026-03-09 15:48:02 -07:00
11 changed files with 535 additions and 91 deletions

1
.gitignore vendored
View File

@@ -21,7 +21,6 @@ node_modules/
*.key
# Build artifacts
web/dist/
/neoircd
/bin/
*.exe

View File

@@ -1,13 +1,3 @@
# Web build stage — compile SPA from source
# node:22-alpine, 2026-03-09
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/src/ src/
COPY web/build.sh build.sh
RUN sh build.sh
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6, 2026-03-02
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
@@ -15,9 +5,6 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Create placeholder files so //go:embed dist/* in web/embed.go resolves
# without depending on the web-builder stage (lint should fail fast)
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
RUN make fmt-check
RUN make lint
@@ -34,7 +21,6 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web-builder /web/dist/ web/dist/
RUN make test

View File

@@ -1036,7 +1036,7 @@ Return the current user's session state.
| Parameter | Type | Default | Description |
|-----------|--------|---------|-------------|
| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. |
| `replay` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. |
**Response:** `200 OK`
```json
@@ -1070,9 +1070,9 @@ curl -s http://localhost:8080/api/v1/state \
-H "Authorization: Bearer $TOKEN" | jq .
```
**Reconnect with channel state initialization:**
**Reconnect with channel replay:**
```bash
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
curl -s "http://localhost:8080/api/v1/state?replay=1" \
-H "Authorization: Bearer $TOKEN" | jq .
```
@@ -1374,16 +1374,14 @@ Return server metadata. No authentication required.
```json
{
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42
}
```
| Field | Type | Description |
|-----------|---------|-------------|
|---------|---------|-------------|
| `name` | string | Server display name |
| `version` | string | Server version |
| `motd` | string | Message of the day |
| `users` | integer | Number of currently active user sessions |
@@ -1852,16 +1850,26 @@ docker run -p 8080:8080 \
neoirc
```
The Dockerfile is a four-stage build:
1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX →
bundled JS via esbuild) into `web/dist/`
2. **lint**: Runs formatting checks and golangci-lint against the Go source
(uses empty placeholder files for `web/dist/` so it runs independently of
web-builder for fast feedback)
3. **builder**: Runs tests and compiles static `neoircd` and `neoirc-cli`
binaries with the real SPA assets from web-builder (CLI built to verify
The Dockerfile is a multi-stage build:
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
compilation, not included in final image)
4. **final**: Minimal Alpine image with only the `neoircd` binary
2. **Final stage**: Alpine Linux + `neoircd` binary only
```dockerfile
FROM golang:1.24-alpine AS builder
WORKDIR /src
RUN apk add --no-cache make
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /neoircd ./cmd/neoircd/
RUN go build -o /neoirc-cli ./cmd/neoirc-cli/
FROM alpine:latest
COPY --from=builder /neoircd /usr/local/bin/neoircd
EXPOSE 8080
CMD ["neoircd"]
```
### Binary
@@ -2310,14 +2318,10 @@ neoirc/
│ └── http.go # HTTP timeouts
├── web/
│ ├── embed.go # go:embed directive for SPA
── build.sh # SPA build script (esbuild, runs in Docker)
├── package.json # Node dependencies (preact, esbuild)
├── package-lock.json
├── src/ # SPA source files (JSX + HTML + CSS)
│ │ ├── app.jsx
│ │ ├── index.html
│ │ └── style.css
│ └── dist/ # Generated at Docker build time (not committed)
── dist/ # Built SPA (vanilla JS, no build step)
├── index.html
├── style.css
└── app.js
├── schema/ # JSON Schema definitions (planned)
├── go.mod
├── go.sum

View File

@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-03-09
last_modified: 2026-02-22
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -98,13 +98,6 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
@@ -151,14 +144,8 @@ style conventions are in separate documents:
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
Post-1.0.0: add new migration files.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.

View File

@@ -444,11 +444,10 @@ func (hdlr *Handlers) enqueueNumeric(
}
// HandleState returns the current session's info and
// channels. When called with ?initChannelState=1, it also
// enqueues synthetic JOIN + TOPIC + NAMES messages for
// every channel the session belongs to so that a
// reconnecting client can rebuild its channel tabs from
// the message stream.
// channels. When called with ?replay=1, it also enqueues
// synthetic JOIN + TOPIC + NAMES messages for every channel
// the session belongs to so that a reconnecting client can
// rebuild its channel tabs from the message stream.
func (hdlr *Handlers) HandleState() http.HandlerFunc {
return func(
writer http.ResponseWriter,
@@ -476,8 +475,8 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
return
}
if request.URL.Query().Get("initChannelState") == "1" {
hdlr.initChannelState(
if request.URL.Query().Get("replay") == "1" {
hdlr.replayChannelState(
request, clientID, sessionID, nick,
)
}
@@ -490,12 +489,12 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
}
}
// initChannelState enqueues synthetic JOIN messages and
// replayChannelState enqueues synthetic JOIN messages and
// join-numerics (TOPIC, NAMES) for every channel the
// session belongs to. Messages are enqueued only to the
// specified client so other clients/sessions are not
// affected.
func (hdlr *Handlers) initChannelState(
func (hdlr *Handlers) replayChannelState(
request *http.Request,
clientID, sessionID int64,
nick string,
@@ -517,7 +516,7 @@ func (hdlr *Handlers) initChannelState(
)
if insErr != nil {
hdlr.log.Error(
"initChannelState: insert JOIN",
"replay: insert JOIN",
"error", insErr,
)
@@ -2393,7 +2392,6 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
hdlr.respondJSON(writer, request, map[string]any{
"name": hdlr.params.Config.ServerName,
"version": hdlr.params.Globals.Version,
"motd": hdlr.params.Config.MOTD,
"users": users,
}, http.StatusOK)

View File

@@ -182,9 +182,9 @@ func (hdlr *Handlers) handleLogin(
request, clientID, sessionID, payload.Nick,
)
// Initialize channel state so the new client knows
// which channels the session already belongs to.
hdlr.initChannelState(
// Replay channel state so the new client knows which
// channels the session already belongs to.
hdlr.replayChannelState(
request, clientID, sessionID, payload.Nick,
)

View File

@@ -16,11 +16,6 @@ import (
const routeTimeout = 60 * time.Second
// cspHeader is the Content-Security-Policy applied to the embedded web SPA.
// The SPA loads external scripts and stylesheets from the same origin only;
// all API communication uses same-origin fetch (no WebSockets).
const cspHeader = "default-src 'self'; script-src 'self'; style-src 'self'"
// SetupRoutes configures the HTTP routes and middleware.
func (srv *Server) SetupRoutes() {
srv.router = chi.NewRouter()
@@ -138,11 +133,6 @@ func (srv *Server) setupSPA() {
writer http.ResponseWriter,
request *http.Request,
) {
writer.Header().Set(
"Content-Security-Policy",
cspHeader,
)
readFS, ok := distFS.(fs.ReadFileFS)
if !ok {
fileServer.ServeHTTP(writer, request)

2
web/dist/app.js vendored Normal file

File diff suppressed because one or more lines are too long

13
web/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeoIRC</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/app.js"></script>
</body>
</html>

466
web/dist/style.css vendored Normal file
View File

@@ -0,0 +1,466 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #0a0e14;
--bg-panel: #0d1117;
--bg-input: #0d1117;
--bg-tab: #161b22;
--bg-tab-active: #0d1117;
--bg-topic: #0d1117;
--text: #c9d1d9;
--text-dim: #6e7681;
--text-bright: #e6edf3;
--accent: #58a6ff;
--accent-dim: #1f6feb;
--border: #21262d;
--system: #7d8590;
--action: #d2a8ff;
--warn: #d29922;
--error: #f85149;
--unread: #f0883e;
--nick-brackets: #6e7681;
--timestamp: #484f58;
--input-bg: #161b22;
--prompt: #3fb950;
--tab-indicator: #58a6ff;
--user-list-bg: #0d1117;
--user-list-header: #484f58;
}
html,
body,
#root {
height: 100%;
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
"Consolas", "Liberation Mono", "Courier New", monospace;
font-size: 13px;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
/* ============================================
Login Screen
============================================ */
.login-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: var(--bg);
}
.login-box {
text-align: center;
max-width: 360px;
width: 100%;
padding: 32px;
}
.login-box h1 {
color: var(--accent);
font-size: 1.8em;
margin-bottom: 16px;
font-weight: 400;
}
.login-box .motd {
color: var(--accent);
font-size: 11px;
margin-bottom: 20px;
text-align: left;
white-space: pre;
font-family: inherit;
line-height: 1.2;
overflow-x: auto;
}
.login-box form {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.login-box label {
color: var(--text-dim);
text-align: left;
font-size: 12px;
}
.login-box input {
padding: 8px 12px;
font-family: inherit;
font-size: 14px;
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text-bright);
border-radius: 3px;
outline: none;
}
.login-box input:focus {
border-color: var(--accent-dim);
}
.login-box button {
padding: 8px 16px;
font-family: inherit;
font-size: 14px;
background: var(--accent-dim);
border: none;
color: var(--text-bright);
border-radius: 3px;
cursor: pointer;
margin-top: 4px;
}
.login-box button:hover {
background: var(--accent);
}
.login-box .error {
color: var(--error);
font-size: 12px;
margin-top: 8px;
}
/* ============================================
IRC App Layout
============================================ */
.irc-app {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ============================================
Tab Bar
============================================ */
.tab-bar {
display: flex;
background: var(--bg-tab);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
height: 32px;
align-items: stretch;
}
.tabs {
display: flex;
overflow-x: auto;
flex: 1;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tab {
display: flex;
align-items: center;
padding: 0 12px;
cursor: pointer;
color: var(--text-dim);
white-space: nowrap;
user-select: none;
border-right: 1px solid var(--border);
font-size: 12px;
gap: 4px;
position: relative;
}
.tab:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.03);
}
.tab.active {
color: var(--text-bright);
background: var(--bg-tab-active);
border-bottom: 2px solid var(--tab-indicator);
margin-bottom: -1px;
}
.tab.has-unread .tab-label {
color: var(--unread);
font-weight: bold;
}
.tab .unread-count {
color: var(--unread);
font-size: 11px;
font-weight: bold;
}
.tab-close {
color: var(--text-dim);
font-size: 14px;
line-height: 1;
margin-left: 2px;
}
.tab-close:hover {
color: var(--error);
}
.status-area {
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
flex-shrink: 0;
font-size: 12px;
}
.status-nick {
color: var(--accent);
font-weight: bold;
}
.status-warn {
color: var(--warn);
animation: blink 1.5s ease-in-out infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* ============================================
Topic Bar
============================================ */
.topic-bar {
padding: 4px 12px;
background: var(--bg-topic);
border-bottom: 1px solid var(--border);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
line-height: 1.5;
}
.topic-label {
color: var(--text-dim);
}
.topic-text {
color: var(--text);
}
/* ============================================
Main Content Area
============================================ */
.main-area {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* ============================================
Messages Panel
============================================ */
.messages-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.messages-scroll {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.messages-scroll::-webkit-scrollbar {
width: 8px;
}
.messages-scroll::-webkit-scrollbar-track {
background: transparent;
}
.messages-scroll::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
/* ============================================
Message Lines
============================================ */
.message {
padding: 1px 0;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 13px;
}
.message .timestamp {
color: var(--timestamp);
font-size: 12px;
}
.message .nick {
font-weight: bold;
}
.message .content {
color: var(--text);
}
/* System messages (joins, parts, quits, etc.) */
.system-message {
color: var(--system);
}
.system-message .system-text {
color: var(--system);
}
/* /me action messages */
.action-message .action-text {
color: var(--action);
}
/* ============================================
User List (Right Panel)
============================================ */
.user-list {
width: 160px;
background: var(--user-list-bg);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
.user-list-header {
padding: 6px 10px;
color: var(--user-list-header);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.user-list-entries {
overflow-y: auto;
padding: 4px 0;
flex: 1;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.nick-entry {
padding: 2px 10px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
}
.nick-entry:hover {
background: rgba(255, 255, 255, 0.04);
}
.nick-prefix {
color: var(--text-dim);
display: inline-block;
width: 1ch;
text-align: right;
margin-right: 1px;
}
.nick-name {
font-weight: normal;
}
/* ============================================
Input Line (Bottom)
============================================ */
.input-line {
display: flex;
align-items: center;
background: var(--input-bg);
border-top: 1px solid var(--border);
flex-shrink: 0;
height: 36px;
padding: 0 8px;
gap: 6px;
}
.input-prompt {
color: var(--prompt);
font-size: 13px;
flex-shrink: 0;
white-space: nowrap;
}
.input-line input {
flex: 1;
padding: 4px 0;
font-family: inherit;
font-size: 13px;
background: transparent;
border: none;
color: var(--text-bright);
outline: none;
caret-color: var(--accent);
}
.input-line input::placeholder {
color: var(--text-dim);
font-style: italic;
}
/* ============================================
Responsive
============================================ */
@media (max-width: 600px) {
.user-list {
display: none;
}
.tab {
padding: 0 8px;
font-size: 11px;
}
.input-prompt {
font-size: 12px;
}
}

View File

@@ -70,7 +70,7 @@ function LoginScreen({ onLogin }) {
.catch(() => {});
const saved = localStorage.getItem("neoirc_token");
if (saved) {
api("/state?initChannelState=1")
api("/state?replay=1")
.then((u) => onLogin(u.nick, true))
.catch(() => localStorage.removeItem("neoirc_token"));
}
@@ -335,7 +335,7 @@ function App() {
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith("#")) {
// Create a tab when the current user joins a channel
// (including JOINs from initChannelState on reconnect).
// (including replayed JOINs on reconnect).
if (msg.from === nickRef.current) {
setTabs((prev) => {
if (
@@ -656,10 +656,9 @@ function App() {
if (isResumed) {
// Request MOTD on resumed sessions (new sessions
// get it automatically from the server during
// creation). Channel state is initialized by the
// server via the message queue
// (?initChannelState=1), so we do not need to
// re-JOIN channels here.
// creation). Channel state is replayed by the
// server via the message queue (?replay=1), so we
// do not need to re-JOIN channels here.
try {
await api("/messages", {
method: "POST",