Compare commits
3 Commits
3513943d47
...
ff9a943e6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff9a943e6d | ||
|
|
b48e164d88 | ||
| f287fdf6d1 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,6 +21,7 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
|
web/dist/
|
||||||
/neoircd
|
/neoircd
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
@@ -36,4 +37,3 @@ data.db
|
|||||||
debug.log
|
debug.log
|
||||||
/neoirc-cli
|
/neoirc-cli
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -1046,6 +1046,12 @@ Return the current user's session state.
|
|||||||
|
|
||||||
**Request:** No body. Requires auth.
|
**Request:** No body. Requires auth.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1078,6 +1084,12 @@ curl -s http://localhost:8080/api/v1/state \
|
|||||||
-H "Authorization: Bearer $TOKEN" | jq .
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Reconnect with channel state initialization:**
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
||||||
|
|
||||||
Retrieve messages from the client's delivery queue. This is the primary
|
Retrieve messages from the client's delivery queue. This is the primary
|
||||||
@@ -1858,16 +1870,16 @@ docker run -p 8080:8080 \
|
|||||||
neoirc
|
neoirc
|
||||||
```
|
```
|
||||||
|
|
||||||
The Dockerfile is a 4-stage build:
|
The Dockerfile is a four-stage build:
|
||||||
1. **Web builder stage** (`web-builder`): Compiles the Preact JSX SPA into
|
1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX →
|
||||||
static assets (`web/dist/`) using esbuild
|
bundled JS via esbuild) into `web/dist/`
|
||||||
2. **Lint stage** (`lint`): Runs formatting checks and linting via golangci-lint
|
2. **lint**: Runs formatting checks and golangci-lint against the Go source
|
||||||
3. **Build stage** (`builder`): Compiles `neoircd` and `neoirc-cli`, runs tests
|
(uses empty placeholder files for `web/dist/` so it runs independently of
|
||||||
(CLI built to verify compilation, not included in final image)
|
web-builder for fast feedback)
|
||||||
4. **Runtime stage**: Alpine Linux + `neoircd` binary only
|
3. **builder**: Runs tests and compiles static `neoircd` and `neoirc-cli`
|
||||||
|
binaries with the real SPA assets from web-builder (CLI built to verify
|
||||||
`web/dist/` is not committed to git — it is built from `web/src/` by the
|
compilation, not included in final image)
|
||||||
web-builder stage during `docker build`.
|
4. **final**: Minimal Alpine image with only the `neoircd` binary
|
||||||
|
|
||||||
### Binary
|
### Binary
|
||||||
|
|
||||||
@@ -2329,16 +2341,14 @@ neoirc/
|
|||||||
│ └── http.go # HTTP timeouts
|
│ └── http.go # HTTP timeouts
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── embed.go # go:embed directive for SPA
|
│ ├── embed.go # go:embed directive for SPA
|
||||||
│ ├── build.sh # esbuild script: JSX → dist/
|
│ ├── build.sh # SPA build script (esbuild, runs in Docker)
|
||||||
│ ├── package.json # Node dependencies (esbuild, preact)
|
│ ├── package.json # Node dependencies (preact, esbuild)
|
||||||
│ ├── src/ # SPA source (Preact JSX)
|
│ ├── package-lock.json
|
||||||
|
│ ├── src/ # SPA source files (JSX + HTML + CSS)
|
||||||
│ │ ├── app.jsx
|
│ │ ├── app.jsx
|
||||||
│ │ ├── index.html
|
│ │ ├── index.html
|
||||||
│ │ └── style.css
|
│ │ └── style.css
|
||||||
│ └── dist/ # Built SPA (generated by web-builder Docker stage)
|
│ └── dist/ # Generated at Docker build time (not committed)
|
||||||
│ ├── index.html
|
|
||||||
│ ├── style.css
|
|
||||||
│ └── app.js
|
|
||||||
├── schema/ # JSON Schema definitions (planned)
|
├── schema/ # JSON Schema definitions (planned)
|
||||||
├── go.mod
|
├── go.mod
|
||||||
├── go.sum
|
├── go.sum
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-03-09
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -98,6 +98,13 @@ style conventions are in separate documents:
|
|||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
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 use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
- Never force-push to `main`.
|
- Never force-push to `main`.
|
||||||
@@ -144,8 +151,14 @@ style conventions are in separate documents:
|
|||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
the binary.
|
||||||
Post-1.0.0: add new migration files.
|
- `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.
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings.
|
||||||
|
|||||||
@@ -471,13 +471,17 @@ func (hdlr *Handlers) enqueueNumeric(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleState returns the current session's info and
|
// HandleState returns the current session's info and
|
||||||
// channels.
|
// 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.
|
||||||
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
sessionID, _, nick, ok :=
|
sessionID, clientID, nick, ok :=
|
||||||
hdlr.requireAuth(writer, request)
|
hdlr.requireAuth(writer, request)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -499,6 +503,12 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if request.URL.Query().Get("initChannelState") == "1" {
|
||||||
|
hdlr.initChannelState(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": nick,
|
"nick": nick,
|
||||||
@@ -507,6 +517,52 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initChannelState 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(
|
||||||
|
request *http.Request,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
channels, err := hdlr.params.Database.
|
||||||
|
GetSessionChannels(ctx, sessionID)
|
||||||
|
if err != nil || len(channels) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, chanInfo := range channels {
|
||||||
|
// Enqueue a synthetic JOIN (only to this client).
|
||||||
|
dbID, _, insErr := hdlr.params.Database.
|
||||||
|
InsertMessage(
|
||||||
|
ctx, "JOIN", nick, chanInfo.Name,
|
||||||
|
nil, nil, nil,
|
||||||
|
)
|
||||||
|
if insErr != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"initChannelState: insert JOIN",
|
||||||
|
"error", insErr,
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = hdlr.params.Database.EnqueueToClient(
|
||||||
|
ctx, clientID, dbID,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enqueue TOPIC + NAMES numerics.
|
||||||
|
hdlr.deliverJoinNumerics(
|
||||||
|
request, clientID, sessionID,
|
||||||
|
nick, chanInfo.Name, chanInfo.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleListAllChannels returns all channels on the server.
|
// HandleListAllChannels returns all channels on the server.
|
||||||
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
|
|||||||
@@ -182,6 +182,12 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
request, clientID, sessionID, payload.Nick,
|
request, clientID, sessionID, payload.Nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Initialize channel state so the new client knows
|
||||||
|
// which channels the session already belongs to.
|
||||||
|
hdlr.initChannelState(
|
||||||
|
request, clientID, sessionID, payload.Nick,
|
||||||
|
)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": payload.Nick,
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ function LoginScreen({ onLogin }) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
const saved = localStorage.getItem("neoirc_token");
|
const saved = localStorage.getItem("neoirc_token");
|
||||||
if (saved) {
|
if (saved) {
|
||||||
api("/state")
|
api("/state?initChannelState=1")
|
||||||
.then((u) => onLogin(u.nick, true))
|
.then((u) => onLogin(u.nick, true))
|
||||||
.catch(() => localStorage.removeItem("neoirc_token"));
|
.catch(() => localStorage.removeItem("neoirc_token"));
|
||||||
}
|
}
|
||||||
@@ -398,7 +398,24 @@ function App() {
|
|||||||
case "JOIN": {
|
case "JOIN": {
|
||||||
const text = `${msg.from} has joined ${msg.to}`;
|
const text = `${msg.from} has joined ${msg.to}`;
|
||||||
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
||||||
if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
|
if (msg.to && msg.to.startsWith("#")) {
|
||||||
|
// Create a tab when the current user joins a channel
|
||||||
|
// (including JOINs from initChannelState on reconnect).
|
||||||
|
if (msg.from === nickRef.current) {
|
||||||
|
setTabs((prev) => {
|
||||||
|
if (
|
||||||
|
prev.find(
|
||||||
|
(t) => t.type === "channel" && t.name === msg.to,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return prev;
|
||||||
|
|
||||||
|
return [...prev, { type: "channel", name: msg.to }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshMembers(msg.to);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -701,9 +718,13 @@ function App() {
|
|||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
addSystemMessage("Server", `Connected as ${userNick}`);
|
addSystemMessage("Server", `Connected as ${userNick}`);
|
||||||
|
|
||||||
// Request MOTD on resumed sessions (new sessions get
|
|
||||||
// it automatically from the server during creation).
|
|
||||||
if (isResumed) {
|
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.
|
||||||
try {
|
try {
|
||||||
await api("/messages", {
|
await api("/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -712,8 +733,11 @@ function App() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// MOTD is non-critical.
|
// MOTD is non-critical.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fresh session — join any previously saved channels.
|
||||||
const saved = JSON.parse(
|
const saved = JSON.parse(
|
||||||
localStorage.getItem("neoirc_channels") || "[]",
|
localStorage.getItem("neoirc_channels") || "[]",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user