Compare commits
6 Commits
986f460b6e
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1f037aad | |||
| 22fd8c5da7 | |||
| 5ce711048c | |||
| f211969ee1 | |||
| 9018e55488 | |||
| b33d21d71b |
229
.gitea/workflows/pr-checks.yml
Normal file
229
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,229 @@
|
||||
# Gate every pull request into `main` on a fast, DB-free check suite, so a broken
|
||||
# build or a failing test can't reach the branch that gets released.
|
||||
#
|
||||
# Mirrors RunicGateway/website's pr-checks.yml — this module is two npm packages
|
||||
# shaped like that repo's `server/` and `client/`, and it is loaded into that
|
||||
# repo's process, so it is checked the same way with the same Node version.
|
||||
#
|
||||
# Phase 1 built all of these checks and ran them BY HAND. That is the gap this
|
||||
# file closes: a guard nothing invokes is a guard whose state nobody knows.
|
||||
#
|
||||
# ── What each job is really asking ───────────────────────────────────────────
|
||||
#
|
||||
# The tests are the ordinary half. The `check:*` scripts are the interesting one,
|
||||
# because they are the acceptance criteria of the module contract itself
|
||||
# (docs/website/MODULE_API.md Part 5) rather than of this module's behaviour:
|
||||
#
|
||||
# • `server: check:imports` — no relative path escapes the module root, and no
|
||||
# shipped file resolves a bare specifier. A module that reaches into core's
|
||||
# tree works right up until core moves a file, and the whole boundary is
|
||||
# worth exactly as much as this check is (§5.1).
|
||||
#
|
||||
# • `server: check:bundle` — the release ships everything the entry point can
|
||||
# reach, and still declares no runtime dependency. Every other job here runs
|
||||
# against the whole repo, but a release is a SUBSET of it (release.yml
|
||||
# assembles from the include list in `ci/bundle.json`), and nothing else
|
||||
# compares the two. Module-uo's v1.0.0 is the cautionary tale: `server/commands/`
|
||||
# arrived in a cutover, the include list did not learn about it, and the
|
||||
# module installed and then died at the register stage on the operator's box
|
||||
# with "Cannot find module './commands/guild.command'". Green in CI, broken
|
||||
# there — because the subset only exists in the release.
|
||||
#
|
||||
# • `client: check:externals` — the BUILT chunk has no bare imports left. That
|
||||
# failure is invisible in source: `import { useState } from 'react'` is
|
||||
# correct in every file, and whether it becomes core's React or a bare
|
||||
# specifier no browser can resolve is decided by vite.config.js. It has to be
|
||||
# asked of the artifact, so it runs after the build. (The other half — a
|
||||
# shared dependency being BUNDLED — fails the build itself, from a
|
||||
# resolution-time guard inside vite.config.js.)
|
||||
#
|
||||
# • `server: check:swagger` — `swagger-fragment.json` describes the routes this
|
||||
# module registers, today. Core has no way to generate it: core is a prebuilt
|
||||
# image, this module arrives on a volume afterwards, and it mounts through a
|
||||
# call no static parser can follow. So the fragment core merges into
|
||||
# `/api/docs.json` is whatever this repo committed, and a stale one documents
|
||||
# a URL surface that does not exist (§2.8).
|
||||
#
|
||||
# • `frozen-manifest` — the job with the interesting shape. It clones CORE at
|
||||
# the ref pinned in `ci/core-ref.json`, generates its route manifest twice
|
||||
# (without this module, then with) and takes the difference. That difference
|
||||
# is what this module serves, and it is checked three ways: it must match the
|
||||
# committed `routes.manifest.json`, it must not have REMOVED or changed one of
|
||||
# core's own routes, and every route in it must have an operation in
|
||||
# `swagger-fragment.json` — the per-module form of core's rule that a route
|
||||
# which isn't in the spec doesn't ship (§5.3, §2.8).
|
||||
#
|
||||
# Nothing else can ask those questions. Every other check here runs against
|
||||
# this repo alone, where a mount prefix is a string in `server/index.js` and a
|
||||
# documented path is a string in a JSON file; whether they name the same URL
|
||||
# is a fact about a running core, and this is the only job that has one. It is
|
||||
# also the only thing that can see the blind spot phase 1 had to check by
|
||||
# reading: core answers several public routes mounted at the TIER ROOT rather
|
||||
# than under a prefix (`/status`, `/version`), which the loader's own collision
|
||||
# probe cannot find, so `/rust` being free is now asserted by a core.
|
||||
#
|
||||
# Enforcement (one-time, in the Gitea UI):
|
||||
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||
# • Enable Status Check
|
||||
# • Status check patterns: PR Checks / *
|
||||
# Note: Gitea only lists a context in its dropdown after it has reported once,
|
||||
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
|
||||
# without needing the dropdown, and keeps matching as jobs are added.
|
||||
#
|
||||
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
|
||||
# Node — no Docker socket, no database.
|
||||
#
|
||||
# Scope note: `edge` is gated as well as `main`, though this repo has no `edge`
|
||||
# branch yet. Multi-phase work lands there first everywhere else in this project,
|
||||
# and gating only the `main` hop would run these checks for the first time at the
|
||||
# cutover — the one moment a red build is most expensive to discover. Naming the
|
||||
# branch before it exists costs nothing; an Android workstream that landed nine
|
||||
# PRs on an ungated `edge` is why it is here from the start.
|
||||
|
||||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, edge]
|
||||
|
||||
# A newer push to the same PR cancels the in-flight run.
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# npm's own retry, turned up. The shared runner reads ETIMEDOUT from the registry
|
||||
# often enough to matter, and a red X that means "the network hiccuped" costs a
|
||||
# reviewer more than it costs the runner to retry, and teaches everyone to re-run
|
||||
# rather than read a failure.
|
||||
env:
|
||||
NPM_CONFIG_FETCH_RETRIES: 5
|
||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||
|
||||
jobs:
|
||||
server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
|
||||
# `npm ci` rather than `npm install`: it also proves the lockfile is in
|
||||
# sync with package.json instead of silently updating it.
|
||||
- name: Install server deps
|
||||
run: npm ci --prefix server
|
||||
|
||||
- name: Run server tests
|
||||
run: npm test --prefix server
|
||||
|
||||
- name: Check the module boundary (MODULE_API.md §5.1)
|
||||
run: npm run check:imports --prefix server
|
||||
|
||||
- name: Check the release ships what the module requires
|
||||
run: npm run check:bundle --prefix server
|
||||
|
||||
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
|
||||
run: npm run check:swagger --prefix server
|
||||
|
||||
client-build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/package-lock.json
|
||||
|
||||
- name: Install client deps
|
||||
run: npm ci --prefix client
|
||||
|
||||
# The build comes FIRST, and that ordering is load-bearing. Two of the
|
||||
# client tests read `dist/entry.js` — the chunk's externals, and what it
|
||||
# registers when imported against a fake `window.__rg` — and both skip when
|
||||
# there is no build. Run the other way round they skip silently in CI, which
|
||||
# is the worst of both: green, and not asking the question.
|
||||
- name: Build the client chunk
|
||||
run: npm run build --prefix client
|
||||
|
||||
- name: Run client tests
|
||||
run: npm test --prefix client
|
||||
|
||||
- name: Check the built chunk's externals (MODULE_API.md §3.6)
|
||||
run: npm run check:externals --prefix client
|
||||
|
||||
# ── The URLs this module actually serves ──────────────────────────────────
|
||||
#
|
||||
# Everything above proves the module against itself. This proves it against a
|
||||
# real core: the one place where "the prefix I register" and "the path I
|
||||
# document" are the same fact rather than two strings that ought to agree.
|
||||
#
|
||||
# The module is COPIED into the core checkout, never symlinked — core's loader
|
||||
# filters its scan with `entry.isDirectory()`, which reports a link as a link
|
||||
# and skips it silently, so a symlinked module produces a manifest with no
|
||||
# module routes in it and a diff that looks like the module registering nothing.
|
||||
frozen-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: module
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
# Anonymous HTTPS, and a full clone rather than a shallow one: the pin is a
|
||||
# commit sha, and `--depth 1` can only fetch a branch tip.
|
||||
- name: Clone core at the pinned ref (MODULE_API.md §5.3)
|
||||
run: |
|
||||
REPO=$(node -p "require('./module/ci/core-ref.json').repo")
|
||||
REF=$(node -p "require('./module/ci/core-ref.json').ref")
|
||||
echo "core: $REPO @ $REF"
|
||||
git clone --quiet "$REPO" core
|
||||
git -C core checkout --quiet "$REF"
|
||||
|
||||
- name: Install core's server deps
|
||||
run: npm ci --prefix core/server
|
||||
|
||||
# Core alone. `--check` first, so a pin that no longer regenerates its own
|
||||
# committed manifest fails HERE, naming the pin, instead of showing up below
|
||||
# as this module having removed a route it never touched.
|
||||
- name: Generate core's manifest without this module
|
||||
run: |
|
||||
npm run routes:manifest --prefix core/server -- --check
|
||||
cp core/server/routes.manifest.json before.json
|
||||
|
||||
# The chunk has to exist before the loader will accept the module at all —
|
||||
# `client.entry` is validated during the manifest step of the scan, and a
|
||||
# missing one is a load failure, not a warning.
|
||||
- name: Build the client chunk
|
||||
run: |
|
||||
npm ci --prefix module/client
|
||||
npm run build --prefix module/client
|
||||
|
||||
# No `npm ci` on the installed copy, because the shipped half declares no
|
||||
# runtime dependencies and the release packs no `node_modules` (org lead,
|
||||
# phase 2). `check:bundle` in the job above is what keeps that true; if it
|
||||
# ever stops being true, this step and release.yml both grow an install.
|
||||
- name: Install the module into core
|
||||
run: |
|
||||
mkdir -p core/modules/rust
|
||||
tar -C module --exclude=.git --exclude=node_modules -cf - . | tar -C core/modules/rust -xf -
|
||||
|
||||
- name: Generate core's manifest with this module
|
||||
run: |
|
||||
npm run routes:manifest --prefix core/server
|
||||
cp core/server/routes.manifest.json after.json
|
||||
|
||||
- name: Check the frozen manifest and the fragment's coverage
|
||||
working-directory: module
|
||||
run: node server/scripts/frozenManifest.js --before ../before.json --after ../after.json --check
|
||||
442
.gitea/workflows/release.yml
Normal file
442
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,442 @@
|
||||
# Build and publish the installable bundle: `module-rust-<version>.tar.gz` plus
|
||||
# the manifest carrying its sha256 (docs/website/MODULE_SYSTEM.md §2.3, §2.5).
|
||||
#
|
||||
# ── What a release IS here ──────────────────────────────────────────────────
|
||||
#
|
||||
# **An operator never builds anything** (MODULE_SYSTEM.md §1.14 — the constraint
|
||||
# the whole module system is shaped around). So a release is not source: it is the
|
||||
# directory core's loader expects to find at `modules/rust/`, already assembled —
|
||||
# the prebuilt client chunk, the schema fragment and the OpenAPI fragment — packed
|
||||
# as it will be unpacked. Core's admin install downloads the tarball, verifies it
|
||||
# against the `sha256` in the manifest, and unpacks it onto the volume. Nothing
|
||||
# runs `npm` on the way.
|
||||
#
|
||||
# **And nothing is installed into the bundle either**, which is where this repo
|
||||
# differs from Module-uo: the shipped half declares no runtime dependencies, so
|
||||
# there is no `npm ci --omit=dev` and no `server/node_modules` in the tarball (org
|
||||
# lead, phase 2). That is a decision worth being loud about rather than a detail —
|
||||
# `server/scripts/checkBundle.js` fails the PR that adds a dependency without also
|
||||
# teaching this file to install and pack it, because a bundle that declares an
|
||||
# import it does not carry fails the same way a missing directory does.
|
||||
#
|
||||
# ── The version is DERIVED, and the declaration is a floor ──────────────────
|
||||
#
|
||||
# The engine `link`, `installer` and `Module-uo` already run (MODULE_SYSTEM
|
||||
# §2.7.1, decision 19 as amended):
|
||||
#
|
||||
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
|
||||
# nothing releasable -> no release is cut
|
||||
# (first ever run, no tag) -> releases what module.json declares
|
||||
#
|
||||
# Module-uo learned this the expensive way: it released only when a merge left
|
||||
# `module.json` at a version with no release yet — the version DECLARED, never
|
||||
# computed — and between 2026-08-12 and 2026-08-19 that cost it *every* bundle,
|
||||
# because nine phases of work landed without anyone touching that line.
|
||||
#
|
||||
# **The declared version is kept as a floor, not deleted.** If `module.json` names
|
||||
# a version above the newest tag, that version releases. Raising it by hand is how
|
||||
# you say "this one is a minor, whatever the subjects imply", and it is the natural
|
||||
# place to move when a `coreApi` bump forces the question.
|
||||
#
|
||||
# The number that ships is therefore the TAG, and CI writes it into the
|
||||
# `module.json` inside the bundle at assembly time. The committed `module.json` is
|
||||
# a floor and a starting point, not a record of the last release — a release engine
|
||||
# that has to commit a bump back to `main` stops working the day someone protects
|
||||
# the branch, and this one is protected.
|
||||
#
|
||||
# ── The backdoor ────────────────────────────────────────────────────────────
|
||||
#
|
||||
# `workflow_dispatch` publishes on demand, for the case the rules above cannot
|
||||
# reach: `module.json` changed in a way worth shipping — a widened `coreApi`, a
|
||||
# new mount, a capability — with no releasable code behind it. Leave `version`
|
||||
# blank to bump the newest tag by `bump` (default `patch`), or name an exact
|
||||
# version to publish that. A dispatch releases even when nothing in the log is
|
||||
# releasable; that is the entire point of pressing the button.
|
||||
#
|
||||
# Re-running on a version that is already released is a no-op, so a rerun after an
|
||||
# unrelated failure is safe. A tag that exists with no release behind it is NOT a
|
||||
# no-op — see the recovery branch in the plan step. That state is not theoretical:
|
||||
# `servuo-plugins`' first release pushed its tag and then 401'd on the release API
|
||||
# because the secret was absent, and without the recovery branch the repo would
|
||||
# have been stuck there permanently.
|
||||
#
|
||||
# This workflow never writes to a branch. It tags and publishes, so `main` needs
|
||||
# no push exception.
|
||||
#
|
||||
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-Rust):
|
||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
|
||||
# and create the release.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Exact version to publish (e.g. 0.1.1). Blank = bump the newest tag by the level below.'
|
||||
required: false
|
||||
default: ''
|
||||
bump:
|
||||
description: 'Bump level when version is blank: patch | minor | major'
|
||||
required: false
|
||||
default: 'patch'
|
||||
|
||||
concurrency:
|
||||
group: release-module-rust
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: RunicGateway/Module-Rust
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# Full history: the plan step reads every tag and every subject since the
|
||||
# newest one, and a shallow clone has neither.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Plan the release (version + changelog)
|
||||
id: plan
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
IN_VERSION: ${{ github.event.inputs.version }}
|
||||
IN_BUMP: ${{ github.event.inputs.bump }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
DECLARED="$(node -p "require('./module.json').version")"
|
||||
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||
CURRENT="${LAST_TAG#v}"
|
||||
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
|
||||
echo "module.json declares ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
|
||||
|
||||
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
|
||||
|
||||
BUMP=none
|
||||
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
|
||||
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
|
||||
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
|
||||
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi
|
||||
|
||||
bump() { # <x.y.z> <major|minor|patch> -> bumped
|
||||
IFS=. read -r MA MI PA <<< "$1"
|
||||
case "$2" in
|
||||
major) echo "$((MA+1)).0.0" ;;
|
||||
minor) echo "${MA}.$((MI+1)).0" ;;
|
||||
patch) echo "${MA}.${MI}.$((PA+1))" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# `sort -V` orders version strings, so the higher of two is its last
|
||||
# line. Used rather than a hand-rolled field compare because 0.10.0 vs
|
||||
# 0.9.0 is exactly the comparison a string sort gets wrong.
|
||||
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
|
||||
|
||||
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
|
||||
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
|
||||
|
||||
VERSION=""
|
||||
if [ -n "${IN_VERSION:-}" ]; then
|
||||
# The backdoor's exact form. Deliberately unvalidated against the log:
|
||||
# a human typed it, and the already-released check below is the only
|
||||
# guard that matters.
|
||||
VERSION="${IN_VERSION}"
|
||||
echo "dispatch: publishing the requested version ${VERSION}"
|
||||
else
|
||||
LEVEL="$BUMP"
|
||||
# A dispatch with nothing releasable in the log still releases — that
|
||||
# is what the button is for. Where the log DOES say something, the
|
||||
# larger of the two wins rather than the input: pressing the button on
|
||||
# a log full of `feat:` without touching the dropdown would otherwise
|
||||
# publish its `patch` default over a minor's worth of work, and a
|
||||
# version that undersells its own contents cannot be taken back.
|
||||
if [ "${EVENT:-}" = workflow_dispatch ]; then
|
||||
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
|
||||
if [ "$BUMP" = none ]; then
|
||||
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
|
||||
elif [ "$LEVEL" != "$BUMP" ]; then
|
||||
echo "dispatch: the log says ${BUMP}, the run asked for ${LEVEL} — taking ${LEVEL}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$CURRENT" ]; then
|
||||
VERSION="$DECLARED" # first ever release: ship what is declared
|
||||
elif [ "$LEVEL" != none ]; then
|
||||
VERSION="$(bump "$CURRENT" "$LEVEL")"
|
||||
fi
|
||||
|
||||
# The floor. A `module.json` above the newest tag releases at that
|
||||
# version even when the log says nothing and even when the log says
|
||||
# patch.
|
||||
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
|
||||
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
|
||||
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
|
||||
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
|
||||
VERSION="$DECLARED"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
RELEASE=true
|
||||
if [ -z "$VERSION" ]; then
|
||||
RELEASE=false
|
||||
VERSION="$CURRENT"
|
||||
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
|
||||
fi
|
||||
|
||||
# An existing tag is NOT automatically "nothing to do". A tag with no
|
||||
# release behind it means a previous run tagged and then died before
|
||||
# publishing — which is what happened on servuo-plugins' first release,
|
||||
# where absent secrets took the release API call to 401 after the tag had
|
||||
# already been pushed. Standing down on the tag alone makes that state
|
||||
# permanent. Note this deliberately OVERRIDES the RELEASE=false above:
|
||||
# with the tag in place there is nothing releasable after it, so the
|
||||
# normal path would stand down, which is why it could never self-heal.
|
||||
# Anything other than 200/404 — a network failure, a bad token — is not
|
||||
# evidence of absence, and guessing "no" would publish over a good
|
||||
# release, so refuse instead.
|
||||
REUSE_TAG=false
|
||||
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
|
||||
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
|
||||
case "$REL_HTTP" in
|
||||
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
|
||||
404) echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
|
||||
REUSE_TAG=true; RELEASE=true ;;
|
||||
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${REL_HTTP}). Refusing to guess."; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Changelog range. A recovery run has nothing after the tag, so
|
||||
# summarize what the tag itself contains rather than emitting an empty
|
||||
# list: the range that produced it, i.e. previous-tag..this-tag.
|
||||
if [ "$REUSE_TAG" = true ]; then
|
||||
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
|
||||
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
|
||||
SINCE="$PREV_TAG"
|
||||
else
|
||||
CL_RANGE="$RANGE"
|
||||
SINCE="$LAST_TAG"
|
||||
fi
|
||||
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
|
||||
|
||||
{
|
||||
echo "## module-rust v${VERSION}"
|
||||
echo
|
||||
echo "Install from the website's Admin → Modules screen by pasting the URL of"
|
||||
echo "\`module-rust-${VERSION}.json\`, or unpack the tarball onto the modules volume"
|
||||
echo "as \`modules/rust/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
|
||||
echo "\`$(node -p "require('./module.json').coreApi")\`."
|
||||
echo
|
||||
echo "A Rust server also needs the other two halves of the bridge:"
|
||||
echo "[Rust-Link](https://${GITEA_HOST}/RunicGateway/Rust-Link) (the sidecar) and"
|
||||
echo "[Rust-Plugins](https://${GITEA_HOST}/RunicGateway/Rust-Plugins) (the Oxide/Carbon plugin)."
|
||||
echo
|
||||
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
|
||||
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
|
||||
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
|
||||
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
|
||||
echo "### All changes"
|
||||
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
|
||||
echo "$CL_SUBJECTS" | sed 's/^/- /'
|
||||
echo
|
||||
echo "### Verifying this download"
|
||||
echo
|
||||
echo "Releases are **unsigned** — the \`sha256\` in \`module-rust-${VERSION}.json\` is the"
|
||||
echo "trust anchor, and the website verifies it before unpacking."
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "sha256sum -c SHA256SUMS --ignore-missing"
|
||||
echo '```'
|
||||
} > dist/CHANGELOG.md
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
|
||||
|
||||
# Before anything is built or tagged, so a repo without secrets fails
|
||||
# legibly rather than half-publishing: the tag push can succeed on the
|
||||
# credential actions/checkout left in the local git config while the release
|
||||
# API call 401s, leaving the repo tagged and unreleased.
|
||||
- name: Verify release credentials are configured
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then
|
||||
echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}."
|
||||
exit 1
|
||||
fi
|
||||
echo "Release credentials present."
|
||||
|
||||
- name: Build the client chunk
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
npm ci --prefix client
|
||||
npm run build --prefix client
|
||||
|
||||
# ── Assemble exactly what an operator's volume gets ──────────────────
|
||||
#
|
||||
# Stated as an INCLUDE list, not an exclude list. An exclude list ships
|
||||
# whatever it forgot: the day someone adds `server/tools/` with a scratch
|
||||
# credential in it, an exclude list packs it and nobody finds out.
|
||||
#
|
||||
# The list itself lives in `ci/bundle.json`, not here, because it has a
|
||||
# second reader: `server/scripts/checkBundle.js` runs in PR checks and asks
|
||||
# whether the list still covers everything `server/index.js` reaches. One
|
||||
# declaration, two readers, so a new directory cannot go missing quietly.
|
||||
- name: Assemble the bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
OUT="dist/module-rust-${VERSION}"
|
||||
rm -rf "$OUT" && mkdir -p "$OUT"
|
||||
|
||||
# The manifest core reads — with the RELEASED version written into it.
|
||||
# The committed `module.json` is a floor, not a record of the last
|
||||
# release (see the header), so copying it verbatim would ship a bundle
|
||||
# whose `installed_modules` row and admin screen disagree with the tag it
|
||||
# came from. This is the one place the derived number becomes the
|
||||
# module's own.
|
||||
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
|
||||
|
||||
# The two fragments, and the licence the code is under — a bundle that
|
||||
# ships GPL code without its licence is not distributable.
|
||||
for f in $(jq -r '.root[]' ci/bundle.json); do
|
||||
cp "$f" "$OUT/"
|
||||
done
|
||||
|
||||
# The server half, minus what never runs inside core's process. No
|
||||
# node_modules: the shipped half declares no runtime dependencies, and
|
||||
# check:bundle is what keeps that true.
|
||||
mkdir -p "$OUT/server"
|
||||
for d in $(jq -r '.server[]' ci/bundle.json); do
|
||||
cp -r "server/$d" "$OUT/server/"
|
||||
done
|
||||
|
||||
# The client half is the BUILT chunk only. `client/src` is source an
|
||||
# operator has no use for and core will never read.
|
||||
mkdir -p "$OUT/client/dist"
|
||||
cp client/dist/entry.js "$OUT/client/dist/"
|
||||
|
||||
# Prove the bundle is loadable before it is published: these are the
|
||||
# paths core's loader resolves out of module.json, and a release whose
|
||||
# entry point is missing fails on an operator's box with a
|
||||
# `startup_failed` row instead of here. The version assertion guards the
|
||||
# rewrite above — a bundle that still carried the declared version would
|
||||
# install under a number that is not the one it was released as.
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const [root, want] = process.argv.slice(1);
|
||||
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
|
||||
if (m.version !== want) {
|
||||
console.error(`bundle declares ${m.version}, but this is release ${want}`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) {
|
||||
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
|
||||
}
|
||||
console.log("bundle contents check: ok");
|
||||
' "$OUT" "$VERSION"
|
||||
|
||||
# ── And that it can actually LOAD ─────────────────────────────────
|
||||
#
|
||||
# The check above stats the paths `module.json` declares, which is a real
|
||||
# question but a shallow one: Module-uo's v1.0.0 passed exactly that and
|
||||
# was still missing `server/commands/`, because a file reached only by a
|
||||
# require inside `register()` is named nowhere in `module.json`. This
|
||||
# resolves every relative require in the assembled tree and asserts the
|
||||
# target is in it — asked of the artifact, so it also catches a copy that
|
||||
# half failed or a list naming a path that has since moved.
|
||||
#
|
||||
# Run from the SOURCE tree (`server/scripts/` never ships) against the
|
||||
# assembled bundle.
|
||||
node server/scripts/checkBundle.js --bundle "$OUT"
|
||||
|
||||
tar -C dist -czf "dist/module-rust-${VERSION}.tar.gz" "module-rust-${VERSION}"
|
||||
rm -rf "$OUT"
|
||||
|
||||
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
|
||||
|
||||
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
||||
# the model this project already has and a second one would be a second
|
||||
# thing to get right (MODULE_SYSTEM.md §1.11).
|
||||
jq -n \
|
||||
--arg id "$(node -p "require('./module.json').id")" \
|
||||
--arg name "$(node -p "require('./module.json').name")" \
|
||||
--arg version "$VERSION" \
|
||||
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
|
||||
--arg artifact "module-rust-${VERSION}.tar.gz" \
|
||||
--arg sha256 "$SHA" \
|
||||
--argjson size "$SIZE" \
|
||||
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
|
||||
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||
> "dist/module-rust-${VERSION}.json"
|
||||
|
||||
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||
cat "dist/module-rust-${VERSION}.json"
|
||||
|
||||
# Skipped on a recovery run: the tag is already there and is the thing being
|
||||
# published against.
|
||||
- name: Tag the release
|
||||
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
git config user.name 'Runic Gateway CI'
|
||||
git config user.email 'ci@whitlocktech.net'
|
||||
git tag -a "$TAG" -m "module-rust ${TAG}"
|
||||
git push origin "$TAG"
|
||||
|
||||
- name: Create the Gitea release and upload the bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
|
||||
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg body "$(cat dist/CHANGELOG.md)" \
|
||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||
| jq -r '.id')"
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "module-rust-${VERSION}.tar.gz" "module-rust-${VERSION}.json" SHA256SUMS; do
|
||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
80
README.md
80
README.md
@@ -36,11 +36,23 @@ rows here; the website core never learns there is more than one.
|
||||
| Surface | Route |
|
||||
|---|---|
|
||||
| Public | `GET /api/v1/public/rust/servers` — every server and what it last reported |
|
||||
| Player | `GET /api/v1/player/rust/servers` — the same, on the authenticated tier |
|
||||
| Public | `GET …/servers/:id` — one server, or a `404`; the only route under `:id` that can say a server does not exist |
|
||||
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
|
||||
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed |
|
||||
| Public | `GET …/servers/:id/wipes` and `…/online` |
|
||||
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
|
||||
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
|
||||
| Page | `/rust/servers` |
|
||||
| Pages | `/rust` — the server list, and the module's landing page |
|
||||
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes |
|
||||
| Slot | `site.footer.status` — a live server/player count in core's footer |
|
||||
|
||||
Two tables, `rust_servers` (configuration) and `rust_server_state` (what each sidecar reported).
|
||||
Every page reads this module's own tables and never calls a game server, which is what lets the
|
||||
whole surface render while every server in the fleet is off. Tab, feed filter, wipe and leaderboard
|
||||
sort all live in the URL, so any view of it is a link.
|
||||
|
||||
Seven tables: `rust_servers` (configuration), `rust_server_state` and `rust_presence` (observed
|
||||
state), `rust_wipes`, `rust_players`, `rust_player_wipe_stats` and `rust_gather_totals` (the record a
|
||||
wipe does not erase), plus the bounded `rust_events` window and the `rust_ingest_cursor`.
|
||||
|
||||
The rest of the module — identity, site-owned permissions, Teams from Rust's clans, notifications,
|
||||
events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it
|
||||
@@ -52,6 +64,7 @@ both surfaces an operator can configure and then wait on, which is worse than an
|
||||
```bash
|
||||
npm ci --prefix server && npm test --prefix server
|
||||
npm run check:imports --prefix server
|
||||
npm run check:bundle --prefix server
|
||||
npm run check:swagger --prefix server
|
||||
npm ci --prefix client && npm run build --prefix client
|
||||
npm run check:externals --prefix client && npm test --prefix client
|
||||
@@ -66,11 +79,66 @@ Regenerate the OpenAPI fragment whenever a route or an annotation changes:
|
||||
npm run swagger --prefix server # writes swagger-fragment.json; commit it
|
||||
```
|
||||
|
||||
`.gitea/workflows/pr-checks.yml` runs all of the above on every pull request, plus one job this
|
||||
machine cannot run on its own: **frozen-manifest** clones core at the sha pinned in
|
||||
[`ci/core-ref.json`](ci/core-ref.json), generates its route table without this module and then with
|
||||
it, and takes the difference. That difference is the URL surface this module serves — checked
|
||||
against the committed [`routes.manifest.json`](routes.manifest.json), against the OpenAPI fragment
|
||||
in both directions, and against the rule that **a module may only add**. It is the only thing that
|
||||
can see whether `/rust` collides with one of the routes core mounts at a tier root (`/status`,
|
||||
`/version`), which the loader's own collision probe cannot find.
|
||||
|
||||
## How it reaches an operator
|
||||
|
||||
**An operator never builds anything.** A release is not source: it is the directory core's loader
|
||||
expects at `modules/rust/`, already assembled — the prebuilt client chunk, the schema fragment and
|
||||
the OpenAPI fragment, packed as they will be unpacked.
|
||||
|
||||
**Every merge to `main` carrying a releasable commit publishes a bundle.** The next version is
|
||||
computed from conventional-commit subjects since the newest `v*` tag, as in `link`, `installer` and
|
||||
`Module-uo`: `feat!:` or `BREAKING CHANGE` is a major, `feat:` a minor, `fix:` or `perf:` a patch,
|
||||
and a `main` that gained none of those cuts no release. The number that ships is the **tag**, and CI
|
||||
writes it into the `module.json` inside the bundle. `module.json`'s version survives as a **floor**:
|
||||
name a version there above the newest tag and that version releases, which is how you overrule the
|
||||
subjects. For a change with nothing releasable behind it — a widened `coreApi`, a new mount, a
|
||||
capability — run the **Release** workflow by hand (Actions → Release → Run workflow).
|
||||
|
||||
Each release carries:
|
||||
|
||||
| Asset | What it is |
|
||||
|---|---|
|
||||
| `module-rust-<version>.tar.gz` | the directory core expects at `modules/rust/`, already assembled |
|
||||
| `module-rust-<version>.json` | the install manifest: id, version, `coreApi`, the artifact's URL, size and **`sha256`** |
|
||||
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
|
||||
|
||||
Releases are **unsigned**; the `sha256` is the trust anchor, and the website verifies it before
|
||||
unpacking. That is the model `installer`'s bundles already use, and a second trust model would be a
|
||||
second thing to get right.
|
||||
|
||||
The tarball is assembled from an **include** list ([`ci/bundle.json`](ci/bundle.json)), never an
|
||||
exclude list — an exclude list ships whatever it forgot. Tests, scripts, `client/src`, `ci/` and the
|
||||
dev dependencies are not in it. It carries **no `node_modules`**, because the shipped half declares
|
||||
no runtime dependencies: everything it needs arrives on `ctx`. `npm run check:bundle` holds both
|
||||
halves of that — that the list still covers every file `server/index.js` can reach, and that no
|
||||
runtime dependency has appeared without the release learning to pack it.
|
||||
|
||||
## Install it into a core
|
||||
|
||||
Copy the whole tree to `<website>/modules/rust/` and restart. **Copy, do not symlink** — the loader
|
||||
lists directory entries and asks each whether it is a directory; a symlink answers no and the module
|
||||
is skipped in complete silence.
|
||||
**From a release**, which is the supported path: in Admin → Modules, paste the URL of that release's
|
||||
`module-rust-<version>.json`, and restart when the panel offers. Core fetches the manifest, checks
|
||||
every URL and redirect hop against its own host allowlist, streams the artifact under a byte cap
|
||||
while hashing it, verifies the `sha256`, inspects the archive in full before unpacking it to a
|
||||
temporary directory, and only then moves it into `modules/rust/`. Nothing is written into the
|
||||
modules directory until every check has passed. The allowlist must contain
|
||||
`gitea.whitlocktech.com` — it is seeded from `MODULE_SOURCE_HOSTS` on a fresh install and is
|
||||
DB-owned from then on, edited on that same screen. **An empty allowlist forbids every install rather
|
||||
than permitting all of them.**
|
||||
|
||||
**From a working tree**, for development: copy the whole tree to `<website>/modules/rust/` and
|
||||
restart. **Copy, do not symlink** — the loader lists directory entries and asks each whether it is a
|
||||
directory; a symlink answers no and the module is skipped in complete silence.
|
||||
|
||||
Either way, the module appears when the process restarts: the volume is read at require time.
|
||||
|
||||
Then, in Admin → Rust, add a server: its name, the sidecar's base URL, and the token the sidecar
|
||||
printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is
|
||||
|
||||
49
ci/bundle.json
Normal file
49
ci/bundle.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$comment": [
|
||||
"What a release copies into the bundle, declared ONCE. Read by .gitea/workflows/release.yml when",
|
||||
"it assembles the tarball, and by server/scripts/checkBundle.js when CI asks whether that list",
|
||||
"still covers everything the module's entry point can reach.",
|
||||
"",
|
||||
"This is an INCLUDE list on purpose. An exclude list ships whatever it forgot: the day someone",
|
||||
"adds server/tools/ with a scratch credential in it, an exclude list packs it and nobody finds",
|
||||
"out. The cost of that choice is that a new top-level directory silently drops OUT of every",
|
||||
"release instead — which is exactly what happened to Module-uo between v0.3.0 and v1.0.0, where",
|
||||
"server/commands/ arrived with a cutover, the list did not learn about it, and the module",
|
||||
"installed and then died at the register stage on the operator's box. checkBundle.js exists so",
|
||||
"that cannot happen twice, and it runs on the PR that adds the directory.",
|
||||
"",
|
||||
"server[] entries are paths under server/; root[] and generated[] are paths under the module",
|
||||
"root.",
|
||||
"",
|
||||
"node_modules is NOT here, and its absence is asserted rather than assumed: this module declares",
|
||||
"no runtime dependencies (everything the shipped half needs arrives on ctx), so the release runs",
|
||||
"no npm ci and packs no dependency tree. checkBundle.js fails the PR that adds a `dependencies`",
|
||||
"entry to server/package.json without also teaching the release to pack it — because a module",
|
||||
"whose bundle silently lacks its own dependency fails the same way the missing directory did.",
|
||||
"",
|
||||
"generated[] ships but is not copied — release.yml writes module.json through jq to stamp the",
|
||||
"released version into it, since the committed one is a floor rather than a record of the last",
|
||||
"release. It is listed because server/index.js requires it, and a check that did not know it",
|
||||
"ships would report the module's own manifest as missing from the bundle."
|
||||
],
|
||||
"server": [
|
||||
"boot.js",
|
||||
"catalogue.js",
|
||||
"core.js",
|
||||
"db",
|
||||
"index.js",
|
||||
"ingest.js",
|
||||
"model",
|
||||
"package.json",
|
||||
"router",
|
||||
"sidecarClient.js"
|
||||
],
|
||||
"root": [
|
||||
"swagger-fragment.json",
|
||||
"LICENSE.md",
|
||||
"README.md"
|
||||
],
|
||||
"generated": [
|
||||
"module.json"
|
||||
]
|
||||
}
|
||||
6
ci/core-ref.json
Normal file
6
ci/core-ref.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet.",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "efa9db73304552dd8bb7a84030b258c6320f79f7",
|
||||
"refName": "main @ MODULE_API 1.10.0, the Asset Bridge cutover 2 of 5 (website#202)"
|
||||
}
|
||||
@@ -25,6 +25,45 @@ const { request: req, BASE } = rg.api
|
||||
// registers under the `/rust` prefix `module.json` declares.
|
||||
export const servers = {
|
||||
list: () => req('/public/rust/servers'),
|
||||
|
||||
// One server, and the only route under `/servers/:id` that can answer "no such
|
||||
// server": the four below answer an empty list for an id nobody ever
|
||||
// configured, because an unknown server genuinely has no events.
|
||||
get: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}`),
|
||||
|
||||
// `kind` is a comma-separated list and `wipe` a wipe id; both are optional and
|
||||
// both are built here rather than in a page, so the query string this module
|
||||
// sends exists in one file.
|
||||
events: (id, { kinds = null, wipe = null, limit = null } = {}) =>
|
||||
req(`/public/rust/servers/${encodeURIComponent(id)}/events${query({
|
||||
kind: kinds && kinds.length ? kinds.join(',') : null,
|
||||
wipe,
|
||||
limit,
|
||||
})}`),
|
||||
|
||||
leaderboard: (id, { wipe = null, sort = null, limit = null } = {}) =>
|
||||
req(`/public/rust/servers/${encodeURIComponent(id)}/leaderboard${query({ wipe, sort, limit })}`),
|
||||
|
||||
wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`),
|
||||
|
||||
online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`),
|
||||
}
|
||||
|
||||
/**
|
||||
* A query string from the parameters that have a value, or `''`.
|
||||
*
|
||||
* **An absent parameter must be absent, not empty.** `?wipe=` is not the same
|
||||
* question as no `wipe` at all — the first asks for a wipe whose id is the empty
|
||||
* string — and a page that sends one because a `<select>` is on "All time" gets
|
||||
* an empty leaderboard and no error.
|
||||
*/
|
||||
function query(params) {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== null && value !== undefined && value !== '') search.set(key, String(value))
|
||||
}
|
||||
const string = search.toString()
|
||||
return string ? `?${string}` : ''
|
||||
}
|
||||
|
||||
// ── player ────────────────────────────────────────────────────────────────
|
||||
@@ -52,6 +91,6 @@ export const admin = {
|
||||
|
||||
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||
// download link, an EventSource. Reach for `request` first.
|
||||
export { BASE }
|
||||
export { BASE, query }
|
||||
|
||||
export default { servers, playerServers, admin, BASE }
|
||||
|
||||
141
client/src/components/Feed.jsx
Normal file
141
client/src/components/Feed.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
// ── The feed: what happened on one server ─────────────────────────────────
|
||||
//
|
||||
// Rows come from `/public/rust/servers/:id/events`, which serves a default-deny
|
||||
// ALLOWLIST (`server/catalogue.js`). Everything carrying an IP address, a
|
||||
// player's report about another player, or the grid square somebody's base is in
|
||||
// is stored and never answered here — so this component cannot leak one by
|
||||
// forgetting to filter, which is the point of the boundary living on the server.
|
||||
//
|
||||
// It polls (org lead, phase 4): every twenty seconds while the tab is visible,
|
||||
// paused when it is not. `usePolled` keeps the rows on screen across a refresh —
|
||||
// see the comment at the top of that file for why core's `useAsync` cannot do
|
||||
// this job.
|
||||
|
||||
import { EmptyState, ErrorState, Loading } from '../core.js'
|
||||
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
|
||||
import { ago, clock } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import api from '../api.js'
|
||||
|
||||
const TONE = {
|
||||
kill: 'var(--accent-bright)',
|
||||
death: 'var(--muted)',
|
||||
join: 'var(--mode-live, #5fb98a)',
|
||||
leave: 'var(--dim)',
|
||||
chat: 'var(--text)',
|
||||
server: 'var(--mode-maint, #e6c26a)',
|
||||
other: 'var(--muted)',
|
||||
}
|
||||
|
||||
export default function Feed({ serverId, wipeId, filter, onFilter }) {
|
||||
const kinds = kindsFor(filter)
|
||||
|
||||
const { data, error, loading, at } = usePolled(
|
||||
() => api.servers.events(serverId, { kinds, wipe: wipeId, limit: 100 }),
|
||||
// The key is the QUESTION. Changing server, wipe or filter blanks the rows,
|
||||
// because what is on screen is an answer to a different one; a poll tick
|
||||
// does not, because it is the same question asked again.
|
||||
{ key: `${serverId}|${wipeId || ''}|${filter}`, intervalMs: 20_000 },
|
||||
)
|
||||
|
||||
const events = data ? data.events : []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 16 }}
|
||||
>
|
||||
<label style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
|
||||
Showing{' '}
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => onFilter(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
{FILTERS.map((f) => (
|
||||
<option key={f.id} value={f.id}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* What a refresh is FOR: saying when the page last managed one. Without
|
||||
it a feed that stopped updating looks exactly like a quiet server. */}
|
||||
{at && (
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.74rem' }}>updated {ago(at)}</span>
|
||||
)}
|
||||
{error && (
|
||||
<span style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
|
||||
the last refresh failed — showing what we had
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
|
||||
{/* An error with nothing to fall back on is the only case that takes over
|
||||
the panel. A failed REFRESH keeps the rows and says so in the line
|
||||
above, because a site whose premise is "it renders while the game is
|
||||
off" must not blank itself the first time a request does. */}
|
||||
{error && !data && <ErrorState error={error} />}
|
||||
|
||||
{data && events.length === 0 && (
|
||||
<EmptyState
|
||||
title="Nothing here yet"
|
||||
message="Nothing this server has reported matches. A server that has just been added has no history until it says something."
|
||||
/>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{events.map((event) => {
|
||||
const line = describe(event)
|
||||
return (
|
||||
<li
|
||||
key={event.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'baseline',
|
||||
padding: '7px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<time
|
||||
className="sans"
|
||||
dateTime={new Date(event.t).toISOString()}
|
||||
title={new Date(event.t).toLocaleString()}
|
||||
style={{ flex: 'none', color: 'var(--dim)', fontSize: '0.74rem', minWidth: '5.6rem' }}
|
||||
>
|
||||
{clock(event.t)}
|
||||
</time>
|
||||
<span style={{ color: TONE[line.tone] || 'var(--muted)', fontSize: '0.92rem' }}>
|
||||
{line.actor && <strong style={{ color: 'var(--ink)' }}>{line.actor}</strong>}
|
||||
{line.actor && (line.join || ' ')}
|
||||
{line.verb}
|
||||
{line.subject && ' '}
|
||||
{line.subject && <strong style={{ color: 'var(--ink)' }}>{line.subject}</strong>}
|
||||
{line.detail && (
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
{' · '}
|
||||
{line.detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.78rem',
|
||||
}
|
||||
73
client/src/components/FooterStatus.jsx
Normal file
73
client/src/components/FooterStatus.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// ── This module's fill for core's `site.footer.status` slot ───────────────
|
||||
//
|
||||
// R13, and the contract is MODULE_API.md §3.7. Core owns the position in the
|
||||
// footer's info row and the separator around it, and passes `linkStyle` so the
|
||||
// row stays visually one row. **The label, the destination, the data and whether
|
||||
// anything renders at all are this component's** — that is the whole division,
|
||||
// and it is why the slot is named for a place rather than for a meaning.
|
||||
//
|
||||
// ── The live count, and what it costs ─────────────────────────────────────
|
||||
//
|
||||
// The org lead chose a live count ("3 servers · 42 online") over a static link,
|
||||
// so this fetches. Be clear-eyed about where it fetches from: core renders
|
||||
// `SiteFooter` inside `PublicLayout`, and every public page renders
|
||||
// `PublicLayout` ITSELF (§3.3) — so this component mounts once per public page
|
||||
// view, not once per session. Every public page on the site therefore carries one
|
||||
// `/public/rust/servers` request, including pages that have nothing to do with
|
||||
// Rust.
|
||||
//
|
||||
// Two things keep that honest rather than merely cheap:
|
||||
//
|
||||
// • **It renders NOTHING until it has an answer, and nothing again if the
|
||||
// request fails.** An unfilled slot renders nothing and core's `wrap` takes
|
||||
// the separator with it, so a failed fetch degrades to exactly the footer an
|
||||
// instance with no module installed has. A spinner in a footer would be worse
|
||||
// than silence on every page of the site.
|
||||
// • **It never polls.** One request per page view is a cost; a timer in the
|
||||
// footer of every page would be a different kind of thing entirely.
|
||||
//
|
||||
// If that per-page request ever shows up in an operator's logs as a problem, the
|
||||
// fix is a short-lived module-scope cache here — the decision to keep the number
|
||||
// live stays intact, and nothing else on the site has to change.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api.js'
|
||||
|
||||
export default function FooterStatus({ linkStyle }) {
|
||||
const [summary, setSummary] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
|
||||
api.servers
|
||||
.list()
|
||||
.then(({ servers }) => {
|
||||
if (!live) return
|
||||
// `online` already accounts for staleness — the model refuses to let a
|
||||
// row that has not been written in five minutes claim a server is up —
|
||||
// so this is a sum, not a judgement.
|
||||
setSummary({
|
||||
servers: servers.length,
|
||||
players: servers.reduce((total, server) => total + (server.online ? server.players : 0), 0),
|
||||
})
|
||||
})
|
||||
// Silence, deliberately. This is the footer of every page on the site; a
|
||||
// module that cannot reach its own API has nothing to say there.
|
||||
.catch(() => {})
|
||||
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!summary || summary.servers === 0) return null
|
||||
|
||||
return (
|
||||
<Link to="/rust" style={linkStyle}>
|
||||
{summary.servers === 1 ? '1 server' : `${summary.servers} servers`}
|
||||
{' · '}
|
||||
{summary.players === 1 ? '1 online' : `${summary.players} online`}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
110
client/src/components/Leaderboard.jsx
Normal file
110
client/src/components/Leaderboard.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
// ── The leaderboard ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Per wipe when a wipe is selected, all-time when it is not (R12). The two are
|
||||
// the same rows summed differently rather than two sets of counters, so they can
|
||||
// never disagree — which is worth knowing here because it means "All time" is
|
||||
// not a slower or less accurate answer, it is the same table without a WHERE.
|
||||
//
|
||||
// It does NOT poll. A leaderboard moves on the scale of a session; a table that
|
||||
// re-sorted itself under the reader's cursor every twenty seconds would be worse
|
||||
// than one that is four minutes old, and the page has a `Refresh` on the tab
|
||||
// strip for anybody who disagrees.
|
||||
|
||||
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
|
||||
import { ago, count, duration, shortId } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
|
||||
// and the column it maps to is this file's. Keeping them in one list is what
|
||||
// stops a header that sorts by something other than what it says.
|
||||
const COLUMNS = [
|
||||
{ key: 'kills', label: 'Kills', sort: 'kills', value: (r) => count(r.kills) },
|
||||
{ key: 'deaths', label: 'Deaths', sort: 'deaths', value: (r) => count(r.deaths) },
|
||||
{ key: 'npcKills', label: 'NPC kills', sort: 'npcKills', value: (r) => count(r.npcKills) },
|
||||
{ key: 'structures', label: 'Structures', sort: null, value: (r) => count(r.structures) },
|
||||
{ key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) },
|
||||
]
|
||||
|
||||
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
const { data, loading, error } = useAsync(
|
||||
() => api.servers.leaderboard(serverId, { wipe: wipeId, sort, limit: 50 }),
|
||||
[serverId, wipeId, sort],
|
||||
)
|
||||
|
||||
const rows = data ? data.leaderboard : []
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No scores yet"
|
||||
message={
|
||||
wipeId
|
||||
? 'Nobody has done anything countable on this wipe yet.'
|
||||
: 'This server has not reported anything countable yet.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em' }}>
|
||||
<th style={{ ...cell, textTransform: 'uppercase' }}>Player</th>
|
||||
{COLUMNS.map((column) => (
|
||||
<th key={column.key} style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>
|
||||
{column.sort ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column.sort)}
|
||||
aria-label={`Sort by ${column.label}`}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
textTransform: 'inherit',
|
||||
color: column.sort === sort ? 'var(--accent-bright)' : 'var(--dim)',
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
</button>
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.steamId} style={{ borderTop: '1px solid var(--line-soft, var(--line))' }}>
|
||||
<td style={cell}>
|
||||
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{index + 1}</span>
|
||||
{/* A player this module has never seen NAMED is shown by the tail
|
||||
of their id rather than as a blank: the row is real, and a
|
||||
nameless one reads as a rendering fault. */}
|
||||
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
|
||||
</td>
|
||||
{COLUMNS.map((column) => (
|
||||
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
|
||||
{column.value(row)}
|
||||
</td>
|
||||
))}
|
||||
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cell = { padding: '8px 10px', whiteSpace: 'nowrap' }
|
||||
94
client/src/components/Online.jsx
Normal file
94
client/src/components/Online.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
// ── Who is on the server right now ────────────────────────────────────────
|
||||
//
|
||||
// Read from the presence BOARD, not counted from connect and disconnect events:
|
||||
// the bridge re-sends the whole board on every connect and every sixty seconds,
|
||||
// so this is right even after the website has missed something (PROTOCOL.md
|
||||
// §8.3). Counting transitions instead would drift, and drift in the direction
|
||||
// people notice — players who never left.
|
||||
//
|
||||
// It polls with the feed, because "who is on" is the one thing on this page that
|
||||
// is a live question.
|
||||
|
||||
import { EmptyState, ErrorState, Loading } from '../core.js'
|
||||
import { duration, shortId } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import api from '../api.js'
|
||||
|
||||
export default function Online({ serverId, online }) {
|
||||
const { data, error, loading } = usePolled(() => api.servers.online(serverId), {
|
||||
key: serverId,
|
||||
intervalMs: 20_000,
|
||||
})
|
||||
|
||||
const players = data ? data.players : []
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !data) return <ErrorState error={error} />
|
||||
|
||||
if (players.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={online ? 'Nobody is on' : 'The server is offline'}
|
||||
message={
|
||||
online
|
||||
? 'The server is up and the island is empty. Somebody has to be first.'
|
||||
: 'Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* A board is the last one that ARRIVED, and an unreachable sidecar does not
|
||||
clear it — deliberately, because the rows are still the best answer
|
||||
anybody has. But presented bare they read as "these people are on right
|
||||
now", which is the one thing an offline server cannot be saying. The
|
||||
page walk found this with a fixture server whose header said Offline
|
||||
above three apparently-connected players. */}
|
||||
{!online && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
This server is offline. Below is the last board it sent, not who is on it now.
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{players.map((player) => (
|
||||
<li
|
||||
key={player.steamId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>{player.name || shortId(player.steamId)}</strong>
|
||||
{/* Sleeping is not idle and not offline — a sleeping player's body is
|
||||
in the world and can be killed, which is why the board carries the
|
||||
flag at all. */}
|
||||
{player.sleeping && (
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}> · sleeping</span>
|
||||
)}
|
||||
</span>
|
||||
{/* `connectedAt` is absent for a player who was already on when the
|
||||
plugin loaded — an unknown session length, which is not a session of
|
||||
no length. Saying nothing is the honest render of that. */}
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||
{player.connectedAt ? `on for ${sessionSoFar(player.connectedAt)}` : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** How long a player has been on, from the DATETIME the board reported. */
|
||||
function sessionSoFar(connectedAt) {
|
||||
const since = Date.parse(connectedAt)
|
||||
if (Number.isNaN(since)) return ''
|
||||
return duration((Date.now() - since) / 1000)
|
||||
}
|
||||
63
client/src/components/Tabs.jsx
Normal file
63
client/src/components/Tabs.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// ── Tabs, bundled rather than borrowed ────────────────────────────────────
|
||||
//
|
||||
// The shared kit is nine members and it is CLOSED (MODULE_API.md §3.4): layout,
|
||||
// headings, the three data-page states, the fetch hook, the session, the site
|
||||
// and `Slot`. A tab strip is not in it, so it is here — which is the kit working
|
||||
// as designed rather than a gap in it. What the kit guarantees is that a module
|
||||
// page looks like the site while it loads and while it fails; everything a page
|
||||
// builds on top of that is the module's own.
|
||||
//
|
||||
// It is styled with core's CSS VARIABLES and its `.pill` class rather than with
|
||||
// colours of its own, so it re-themes with the instance (THEMING_AND_NAV.md).
|
||||
// The one class this module must never write by hand is the shell wrapper —
|
||||
// `PublicLayout`'s `shell` prop exists precisely so that one stays core's.
|
||||
//
|
||||
// **The selected tab lives in the URL, not in this component.** A tab strip that
|
||||
// owned its own state would make every panel on this page unlinkable: "look at
|
||||
// the leaderboard for this server" would be a sentence rather than a link, back
|
||||
// would leave the page entirely, and a refresh would land on the first tab. So
|
||||
// this is a controlled component and `ServerDetail` keeps the state in a search
|
||||
// parameter.
|
||||
|
||||
export default function Tabs({ tabs, active, onSelect, label = 'Sections' }) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
borderBottom: '1px solid var(--line)',
|
||||
paddingBottom: 12,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === active
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '6px 14px',
|
||||
borderRadius: 'var(--radius-pill, 999px)',
|
||||
fontSize: '0.82rem',
|
||||
letterSpacing: '0.04em',
|
||||
border: `1px solid ${selected ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: selected ? 'var(--blue)' : 'transparent',
|
||||
color: selected ? 'var(--accent-bright)' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
client/src/components/WipeSelect.jsx
Normal file
54
client/src/components/WipeSelect.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// ── "This wipe" or "All time" ─────────────────────────────────────────────
|
||||
//
|
||||
// One control, used by two panels, because the wipe is a property of the PAGE
|
||||
// rather than of the feed or the leaderboard — a reader who has chosen last
|
||||
// month's map means it for both, and two selects that could disagree is a page
|
||||
// that shows one wipe's kills next to another's leaderboard.
|
||||
//
|
||||
// It loads the wipe list itself. That is a second request for the same list the
|
||||
// Wipes tab fetches, and it is the right trade: the alternative is the page
|
||||
// fetching it on mount for a control most visitors never touch, on every visit,
|
||||
// for every server.
|
||||
|
||||
import { useAsync } from '../core.js'
|
||||
import { day } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
/** The value that means "no wipe filter at all". Never the empty string — see `api.js`'s `query`. */
|
||||
export const ALL_TIME = 'all'
|
||||
|
||||
export default function WipeSelect({ serverId, value, onChange, currentWipeId }) {
|
||||
const { data } = useAsync(() => api.servers.wipes(serverId), [serverId])
|
||||
const wipes = data ? data.wipes : []
|
||||
|
||||
// A server with one wipe has nothing to choose between, so the control is not
|
||||
// offered. "All time" and "this wipe" are the same answer there, and a select
|
||||
// with one real option is furniture that invites a question with no answer.
|
||||
if (wipes.length < 2) return null
|
||||
|
||||
return (
|
||||
<label className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
|
||||
Wipe{' '}
|
||||
<select
|
||||
value={value || ALL_TIME}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
style={{
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.78rem',
|
||||
}}
|
||||
>
|
||||
<option value={ALL_TIME}>All time</option>
|
||||
{wipes.map((wipe) => (
|
||||
<option key={wipe.wipeId} value={wipe.wipeId}>
|
||||
{day(wipe.saveCreatedAt || wipe.firstSeen)}
|
||||
{wipe.wipeId === currentWipeId ? ' (current)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
85
client/src/components/Wipes.jsx
Normal file
85
client/src/components/Wipes.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
// ── Every wipe this server has had ────────────────────────────────────────
|
||||
//
|
||||
// The list is what makes the rest of the page navigable — picking a wipe here
|
||||
// filters the feed and the leaderboard — and it is also the proof R12 asks for:
|
||||
// a wipe that ended is still here, with its record still attached. A Rust server
|
||||
// wipes monthly, and a community site that forgot the previous map every time
|
||||
// would throw away most of what it knows about its own players.
|
||||
//
|
||||
// `wipeId` is derived by the bridge PLUGIN from the save's creation time and
|
||||
// stamped on every frame (PROTOCOL.md §8.2), so the id in this list is the same
|
||||
// id the events and the leaderboard filter by. There is no second derivation
|
||||
// anywhere that could disagree.
|
||||
|
||||
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
|
||||
import { ago, day } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
export default function Wipes({ serverId, currentWipeId, selected, onSelect }) {
|
||||
const { data, loading, error } = useAsync(() => api.servers.wipes(serverId), [serverId])
|
||||
const wipes = data ? data.wipes : []
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
if (wipes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No wipes recorded"
|
||||
message="A wipe appears here once this server has reported something during it."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{wipes.map((wipe) => {
|
||||
const current = wipe.wipeId === currentWipeId
|
||||
const active = wipe.wipeId === selected
|
||||
return (
|
||||
<li key={wipe.wipeId} style={{ borderBottom: '1px solid var(--line-soft, var(--line))' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(wipe.wipeId)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: 12,
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 6px',
|
||||
cursor: 'pointer',
|
||||
background: active ? 'var(--blue)' : 'transparent',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>
|
||||
{/* The save's creation time is the wipe's own date; `firstSeen`
|
||||
is when THIS website first heard about it, and they differ
|
||||
by however long the module was not installed. The first is
|
||||
the wipe, so it leads. */}
|
||||
{day(wipe.saveCreatedAt || wipe.firstSeen)}
|
||||
</strong>
|
||||
{current && (
|
||||
<span className="sans" style={{ color: 'var(--mode-live, #5fb98a)', fontSize: '0.74rem' }}>
|
||||
{' · current'}
|
||||
</span>
|
||||
)}
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem' }}>
|
||||
{wipe.wipeId}
|
||||
</span>
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||
last heard {ago(wipe.lastSeen)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
import { registry, coreApiVersion } from './core.js'
|
||||
|
||||
import Servers from './routes/public/Servers.jsx'
|
||||
import ServerDetail from './routes/public/ServerDetail.jsx'
|
||||
import FooterStatus from './components/FooterStatus.jsx'
|
||||
|
||||
// The module id, exactly as `module.json` spells it. Core keys the registry by it
|
||||
// and prefixes every route path with it.
|
||||
@@ -33,7 +35,7 @@ const ID = 'rust'
|
||||
// installed side by side cannot collide, and an operator can see from a URL which
|
||||
// module served it.
|
||||
//
|
||||
// So this page is at `/rust/servers`.
|
||||
// So the list below is at `/rust` and the detail page at `/rust/servers/:id`.
|
||||
//
|
||||
// **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is
|
||||
// available and core applies it as its own `RoleGate`; supplying your own is not
|
||||
@@ -41,11 +43,22 @@ const ID = 'rust'
|
||||
// see what, and they only do if one thing decides.
|
||||
//
|
||||
// R8's landing page is the server list, and `/rust/servers/:id` hangs beneath it.
|
||||
// The detail route is a later phase's, and it is deliberately not stubbed here: a
|
||||
// registered route that renders nothing is a 200 with a blank page, which is
|
||||
// worse than the 404 an unregistered one gives.
|
||||
//
|
||||
// **The list is registered with an EMPTY path**, which core renders as the
|
||||
// module's namespace root: `/rust`. The prefixing code strips the separator it
|
||||
// would otherwise leave behind (`registry.js`: `${id}/${path}` with trailing
|
||||
// slashes trimmed), so a module can own its own root without being able to spell
|
||||
// its way out of it. Phase 1 served this page at `/rust/servers` and left `/rust`
|
||||
// to core's CMS catch-all; the org lead settled it at `/rust` in phase 4, so the
|
||||
// address an operator links to is the module's name.
|
||||
//
|
||||
// React Router ranks a static segment above a dynamic one, so `/rust` wins
|
||||
// against core's `/:slug` CMS route without depending on registration order.
|
||||
registry.registerRoutes(ID, {
|
||||
public: [{ path: 'servers', element: <Servers /> }],
|
||||
public: [
|
||||
{ path: '', element: <Servers /> },
|
||||
{ path: 'servers/:id', element: <ServerDetail /> },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Nav ───────────────────────────────────────────────────────────────────
|
||||
@@ -67,9 +80,23 @@ registry.registerRoutes(ID, {
|
||||
// one is the only row in its sidebar with no glyph, which reads as breakage.
|
||||
registry.registerNav(ID, {
|
||||
area: 'public',
|
||||
items: [{ label: 'Servers', to: '/rust/servers' }],
|
||||
items: [{ label: 'Servers', to: '/rust' }],
|
||||
})
|
||||
|
||||
// ── Extension slots ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Core declares a slot, only core may declare one, and at most one module may
|
||||
// fill it (§3.7). `site.footer.status` is the status-ish spot in core's footer
|
||||
// info row: core owns the position and passes `linkStyle`; the label, the
|
||||
// destination, the data and whether anything renders at all are the module's.
|
||||
//
|
||||
// It is a CLIENT slot and cannot be named in `module.json`'s `extensions` —
|
||||
// that array is validated against the SERVER registry, and naming a client slot
|
||||
// there fails the load outright with `unknown extension slot`. Phase 1 found
|
||||
// that the hard way; the two halves of R13 are declared in different places on
|
||||
// purpose.
|
||||
registry.registerExtension(ID, 'site.footer.status', FooterStatus)
|
||||
|
||||
// `module.json`'s `coreApi` range was checked by the loader before this file was
|
||||
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
|
||||
// between the core that validated the manifest and the core that published this
|
||||
|
||||
116
client/src/hooks/usePolled.js
Normal file
116
client/src/hooks/usePolled.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// ── A poll that keeps what it already had ─────────────────────────────────
|
||||
//
|
||||
// **Why this is not `useAsync`.** Core's hook (MODULE_API.md §3.4, and
|
||||
// `client/src/lib/useAsync.js` in core) is `useState({loading:true,error:null,data:null})`
|
||||
// re-run on a dependency change — and the first thing it does on every run is
|
||||
// blank `data` and set `loading`. That is right for a page load and wrong for a
|
||||
// poll: bumping a dependency every twenty seconds would clear the killfeed,
|
||||
// render `<Loading />` in its place and re-fill it, four times a minute, for ever.
|
||||
//
|
||||
// So a poll needs a hook whose refresh is INVISIBLE when it succeeds. It keeps
|
||||
// the previous rows on screen, replaces them when the new ones arrive, and keeps
|
||||
// them *and* reports the error when the fetch fails — because a site whose whole
|
||||
// premise is "it renders while the game is off" must not blank the page the
|
||||
// first time a request does.
|
||||
//
|
||||
// `useAsync` is still the right hook for everything that loads once, and the
|
||||
// pages here use it for exactly that. Bundling this beside it is the kit working
|
||||
// as intended: the nine shared members are the chrome every module must share,
|
||||
// not a ceiling on what a module may write.
|
||||
//
|
||||
// ── Two behaviours worth knowing ──────────────────────────────────────────
|
||||
//
|
||||
// 1. **A backgrounded tab does not poll.** Page Visibility, plus an immediate
|
||||
// refresh when the viewer comes back — which is also the moment stale rows
|
||||
// are most visible. A tab left open overnight is otherwise a request every
|
||||
// twenty seconds until the laptop dies.
|
||||
// 2. **`key` resets, dependencies do not.** Switching server or wipe SHOULD
|
||||
// blank the rows: what is on screen belongs to a different question. That is
|
||||
// what `key` is for, and it is separate from the interval.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* @param {() => Promise<any>} fetcher called with no arguments; must not throw synchronously
|
||||
* @param {object} options
|
||||
* @param {string} options.key changes when the QUESTION changes, blanking the answer
|
||||
* @param {number} options.intervalMs 0 disables polling — the hook then loads once
|
||||
* @param {boolean} options.enabled false while the page has nothing to ask about yet
|
||||
*/
|
||||
export function usePolled(fetcher, { key = '', intervalMs = 20000, enabled = true } = {}) {
|
||||
const [state, setState] = useState({ data: null, error: null, loading: enabled, at: null })
|
||||
|
||||
// The fetcher is rebuilt on every render — it closes over props — and a hook
|
||||
// that listed it as a dependency would restart its interval every render. The
|
||||
// ref is how the timer keeps calling the CURRENT one without depending on it.
|
||||
const latest = useRef(fetcher)
|
||||
latest.current = fetcher
|
||||
|
||||
// Guards a reply from a question nobody is asking any more: a slow request
|
||||
// whose page has moved on, or one still in flight at unmount.
|
||||
const generation = useRef(0)
|
||||
|
||||
const run = useCallback(
|
||||
async (mine) => {
|
||||
try {
|
||||
const data = await latest.current()
|
||||
if (mine !== generation.current) return
|
||||
setState({ data, error: null, loading: false, at: Date.now() })
|
||||
} catch (error) {
|
||||
if (mine !== generation.current) return
|
||||
// `data` is carried forward deliberately. A failed refresh is a page that
|
||||
// says "this is what we last knew, and it did not refresh", which is the
|
||||
// same promise the server list makes about a game server being down.
|
||||
setState((prev) => ({ data: prev.data, error, loading: false, at: prev.at }))
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const refresh = useCallback(() => run(generation.current), [run])
|
||||
|
||||
useEffect(() => {
|
||||
generation.current += 1
|
||||
const mine = generation.current
|
||||
|
||||
if (!enabled) {
|
||||
setState({ data: null, error: null, loading: false, at: null })
|
||||
return undefined
|
||||
}
|
||||
|
||||
setState({ data: null, error: null, loading: true, at: null })
|
||||
run(mine)
|
||||
|
||||
if (!intervalMs) return () => { generation.current += 1 }
|
||||
|
||||
let timer = null
|
||||
|
||||
const visible = () => typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||
|
||||
const start = () => {
|
||||
if (timer === null) timer = setInterval(() => run(mine), intervalMs)
|
||||
}
|
||||
const stop = () => {
|
||||
if (timer !== null) { clearInterval(timer); timer = null }
|
||||
}
|
||||
|
||||
const onVisibility = () => {
|
||||
if (visible()) { run(mine); start() } else stop()
|
||||
}
|
||||
|
||||
if (visible()) start()
|
||||
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
|
||||
|
||||
return () => {
|
||||
// Bumping the generation on teardown is what makes an in-flight reply from
|
||||
// the old question land nowhere. Clearing the timer alone would not.
|
||||
generation.current += 1
|
||||
stop()
|
||||
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
}, [key, intervalMs, enabled, run])
|
||||
|
||||
return { ...state, refresh }
|
||||
}
|
||||
|
||||
export default usePolled
|
||||
178
client/src/lib/feed.js
Normal file
178
client/src/lib/feed.js
Normal file
@@ -0,0 +1,178 @@
|
||||
// ── One stored frame as one line of a feed ────────────────────────────────
|
||||
//
|
||||
// `GET /public/rust/servers/:id/events` answers rows shaped
|
||||
// `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame
|
||||
// the plugin emitted — this module stores what it is given and indexes only the
|
||||
// columns it serves (PROTOCOL.md §8.4, and the `raw` column in schema.sql). So
|
||||
// everything a killfeed line needs is in `frame`, under the names the plugin
|
||||
// wrote, and this file is the one place that knows them.
|
||||
//
|
||||
// **It returns PARTS, not a sentence.** A component wants the names emphasised
|
||||
// and the detail muted, and a function returning `"Alice killed Bob"` forces
|
||||
// either a `dangerouslySetInnerHTML` or a re-parse. Parts also make this
|
||||
// testable without a DOM, which is the whole reason it is not a component.
|
||||
//
|
||||
// ── The rule for an unknown kind ──────────────────────────────────────────
|
||||
//
|
||||
// It renders as itself. A later protocol adds kinds, an operator's module may be
|
||||
// older than their game host, and a feed that DROPPED what it did not recognise
|
||||
// would be a page that quietly says less than the truth. The server's allowlist
|
||||
// has already decided this row may be seen (`server/catalogue.js`); what is left
|
||||
// here is presentation, and the honest presentation of a kind we have no words
|
||||
// for is its own name.
|
||||
|
||||
import { duration, prefab } from './format.js'
|
||||
|
||||
/**
|
||||
* Kinds this feed asks for.
|
||||
*
|
||||
* `player.tally` is public and deliberately NOT here: it is an aggregate the
|
||||
* plugin flushes every sixty seconds per active player (§8.6), so a feed
|
||||
* including it would be mostly wood counts. It is the leaderboard's input, and
|
||||
* the leaderboard is where it shows up.
|
||||
*/
|
||||
export const FEED_KINDS = Object.freeze([
|
||||
'player.death',
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
'player.chat',
|
||||
'server.wipe',
|
||||
'server.initialized',
|
||||
'server.shutdown',
|
||||
])
|
||||
|
||||
/** The filters the feed offers, and the kinds each one asks the API for. */
|
||||
export const FILTERS = Object.freeze([
|
||||
{ id: 'all', label: 'Everything', kinds: FEED_KINDS },
|
||||
{ id: 'kills', label: 'Kills', kinds: ['player.death'] },
|
||||
{ id: 'chat', label: 'Chat', kinds: ['player.chat'] },
|
||||
{
|
||||
id: 'sessions',
|
||||
label: 'Comings and goings',
|
||||
kinds: ['player.connected', 'player.disconnected', 'player.respawned'],
|
||||
},
|
||||
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
|
||||
])
|
||||
|
||||
export function kindsFor(filterId) {
|
||||
const filter = FILTERS.find((f) => f.id === filterId)
|
||||
return (filter || FILTERS[0]).kinds
|
||||
}
|
||||
|
||||
/**
|
||||
* One row as `{ tone, actor, join, verb, subject, detail }`.
|
||||
*
|
||||
* `actor` and `subject` are names and are emphasised; `verb` and `detail` are
|
||||
* prose. Any of them may be empty. `tone` is the row's category, for the small
|
||||
* colour the component gives it — never for deciding what a row means.
|
||||
*
|
||||
* `join` is what goes between the actor and the verb, and it exists for exactly
|
||||
* one case: chat. "Brannock see you in september" is not a sentence anybody
|
||||
* writes, and putting the colon in the message would put presentation inside the
|
||||
* text a player typed.
|
||||
*/
|
||||
export function describe(row) {
|
||||
const frame = (row && row.frame) || {}
|
||||
const name = frame.name || null
|
||||
|
||||
switch (row && row.kind) {
|
||||
case 'player.death':
|
||||
return death(frame, name)
|
||||
|
||||
case 'player.connected':
|
||||
return { tone: 'join', actor: name, verb: 'connected', subject: null, detail: '' }
|
||||
|
||||
case 'player.disconnected':
|
||||
return {
|
||||
tone: 'leave',
|
||||
actor: name,
|
||||
verb: 'disconnected',
|
||||
subject: null,
|
||||
// Two optional halves, and the session is the interesting one: the plugin
|
||||
// omits `sessionSec` for a player who was already on when it loaded, so an
|
||||
// absent value means "unknown", never zero (§8.4's note, and OnPlayerDisconnected).
|
||||
detail: [frame.reason || null, frame.sessionSec ? `after ${duration(frame.sessionSec)}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
}
|
||||
|
||||
case 'player.respawned':
|
||||
return { tone: 'join', actor: name, verb: 'respawned', subject: null, detail: '' }
|
||||
|
||||
case 'player.chat':
|
||||
return {
|
||||
tone: 'chat',
|
||||
actor: name,
|
||||
join: ': ',
|
||||
// The message is the row, so it goes in `verb` where a component renders
|
||||
// it unemphasised — and it is the one field on this wire a player chooses
|
||||
// the bytes of. React escapes it; nothing here may ever stop doing that.
|
||||
verb: frame.message || '',
|
||||
subject: null,
|
||||
detail: frame.channel && frame.channel !== 'Global' ? frame.channel : '',
|
||||
}
|
||||
|
||||
case 'server.wipe':
|
||||
return {
|
||||
tone: 'server',
|
||||
actor: null,
|
||||
verb: 'The map was wiped',
|
||||
subject: null,
|
||||
detail: frame.wipeId ? `new wipe ${frame.wipeId}` : '',
|
||||
}
|
||||
|
||||
case 'server.initialized':
|
||||
return { tone: 'server', actor: null, verb: 'The server came up', subject: null, detail: '' }
|
||||
|
||||
case 'server.shutdown':
|
||||
return { tone: 'server', actor: null, verb: 'The server went down', subject: null, detail: '' }
|
||||
|
||||
default:
|
||||
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A death, which is four different sentences.
|
||||
*
|
||||
* The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so
|
||||
* that a reader does not have to guess from an absent field, and collapsing any
|
||||
* two of them loses something (see `DescribeAttacker` in the bridge plugin). A
|
||||
* killfeed that reported a fall as a kill by nobody is the failure this avoids.
|
||||
*/
|
||||
function death(frame, name) {
|
||||
const where = [
|
||||
frame.weapon ? `with ${prefab(frame.weapon)}` : null,
|
||||
frame.distance ? `${Math.round(frame.distance)}m` : null,
|
||||
frame.grid || null,
|
||||
frame.sleeping ? 'while sleeping' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
switch (frame.attackerType) {
|
||||
case 'player':
|
||||
return { tone: 'kill', actor: frame.attackerName || null, verb: 'killed', subject: name, detail: where }
|
||||
|
||||
case 'self':
|
||||
return { tone: 'death', actor: name, verb: 'died by their own hand', subject: null, detail: where }
|
||||
|
||||
case 'npc':
|
||||
return {
|
||||
tone: 'death',
|
||||
actor: prefab(frame.attackerName) || 'Something',
|
||||
verb: 'killed',
|
||||
subject: name,
|
||||
detail: where,
|
||||
}
|
||||
|
||||
// `environment` and anything else: falling, drowning, the world. `HitInfo`
|
||||
// is legitimately null on this path, so an absent attacker type is this case
|
||||
// rather than a missing field to complain about.
|
||||
default:
|
||||
return { tone: 'death', actor: name, verb: 'died', subject: null, detail: where }
|
||||
}
|
||||
}
|
||||
|
||||
export default { describe, FEED_KINDS, FILTERS, kindsFor }
|
||||
136
client/src/lib/format.js
Normal file
136
client/src/lib/format.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// ── Formatting, with no dependencies and no React ─────────────────────────
|
||||
//
|
||||
// Every function here is pure and takes what the API answered, so the suite next
|
||||
// door can ask all of it without a DOM. That is deliberate: the client half's
|
||||
// real failures are timing and resolution (see `test/build.test.js`), which a
|
||||
// DOM-less runner cannot see — so the way to have any test coverage at all on
|
||||
// this side is to keep the parts that CAN be tested free of React.
|
||||
//
|
||||
// `Intl` does the work. It is in every browser core supports, it knows the
|
||||
// viewer's locale and their clock, and it is one fewer thing in a chunk an
|
||||
// operator ships.
|
||||
|
||||
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
const UNITS = [
|
||||
['year', 31536000],
|
||||
['month', 2592000],
|
||||
['week', 604800],
|
||||
['day', 86400],
|
||||
['hour', 3600],
|
||||
['minute', 60],
|
||||
['second', 1],
|
||||
]
|
||||
|
||||
/**
|
||||
* "3 minutes ago", from an ISO string or an epoch-millisecond number.
|
||||
*
|
||||
* Both shapes arrive from this module's own API: `updatedAt` is an ISO string
|
||||
* the model produced, and an event's `t` is the millisecond stamp the plugin put
|
||||
* on the frame. Accepting both here is what stops every caller remembering which
|
||||
* is which.
|
||||
*/
|
||||
export function ago(value, now = Date.now()) {
|
||||
const at = toMillis(value)
|
||||
if (at === null) return 'never'
|
||||
|
||||
const seconds = Math.round((at - now) / 1000)
|
||||
const magnitude = Math.abs(seconds)
|
||||
|
||||
// Under a minute, "in 0 seconds" is what `numeric: 'auto'` produces and it is
|
||||
// not what anybody means. Say the thing.
|
||||
if (magnitude < 45) return 'just now'
|
||||
|
||||
const [unit, size] = UNITS.find(([, s]) => magnitude >= s) || ['second', 1]
|
||||
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* The stamp on a feed row.
|
||||
*
|
||||
* **Today's rows get a time; everything older gets a date as well.** The feed can
|
||||
* be filtered to a past wipe, and a row from six weeks ago rendered as `02:03 PM`
|
||||
* reads as this afternoon — which the page walk found the moment it looked at the
|
||||
* previous wipe: three events from August, all apparently a few minutes old.
|
||||
*
|
||||
* `now` is a parameter so the boundary is testable rather than a property of the
|
||||
* machine the test runs on.
|
||||
*/
|
||||
export function clock(value, now = Date.now()) {
|
||||
const at = toMillis(value)
|
||||
if (at === null) return ''
|
||||
|
||||
const when = new Date(at)
|
||||
const time = when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const today = new Date(now)
|
||||
const sameDay =
|
||||
when.getFullYear() === today.getFullYear() &&
|
||||
when.getMonth() === today.getMonth() &&
|
||||
when.getDate() === today.getDate()
|
||||
|
||||
if (sameDay) return time
|
||||
return `${when.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`
|
||||
}
|
||||
|
||||
/** A date, for a wipe: the thing people actually compare wipes by. */
|
||||
export function day(value) {
|
||||
const at = toMillis(value)
|
||||
if (at === null) return 'unknown'
|
||||
return new Date(at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* A session or a playtime, as `4h 12m`.
|
||||
*
|
||||
* Seconds are dropped above a minute and kept below it, because a two-hour
|
||||
* session reported to the second is noise and a forty-second one reported as
|
||||
* "0m" is wrong.
|
||||
*/
|
||||
export function duration(seconds) {
|
||||
const total = Number(seconds)
|
||||
if (!Number.isFinite(total) || total <= 0) return '—'
|
||||
if (total < 60) return `${Math.round(total)}s`
|
||||
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.round((total % 3600) / 60)
|
||||
|
||||
if (hours === 0) return `${minutes}m`
|
||||
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
|
||||
}
|
||||
|
||||
/** Thousands separators, in the viewer's locale. */
|
||||
export function count(value) {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefab short name as something readable — `patrolhelicopter` stays itself,
|
||||
* `rifle.ak` becomes `rifle ak`.
|
||||
*
|
||||
* Deliberately a light touch rather than a lookup table. A table mapping every
|
||||
* Rust prefab to a pretty name is a second copy of the game's item list that
|
||||
* goes stale every wipe, and the short name is what a Rust player reads on their
|
||||
* own server console anyway.
|
||||
*/
|
||||
export function prefab(name) {
|
||||
if (!name) return ''
|
||||
return String(name).replace(/[_.]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** A steam id, shortened for a table cell, without pretending it is a name. */
|
||||
export function shortId(steamId) {
|
||||
const id = String(steamId || '')
|
||||
return id.length > 10 ? `…${id.slice(-6)}` : id
|
||||
}
|
||||
|
||||
function toMillis(value) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
export default { ago, clock, day, duration, count, prefab, shortId }
|
||||
184
client/src/routes/public/ServerDetail.jsx
Normal file
184
client/src/routes/public/ServerDetail.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
// ── One server ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// R8's page beneath the landing page, and the phase-4 criterion lives here: it
|
||||
// renders the last thing this server said while every server is off. Nothing on
|
||||
// it is a live call to a game host — every panel reads this module's own tables,
|
||||
// filled by the ingest cursor — so a shard that has been down for a week renders
|
||||
// a week-old killfeed and a leaderboard that is still correct, rather than an
|
||||
// error page.
|
||||
//
|
||||
// ── Everything selectable is in the URL ───────────────────────────────────
|
||||
//
|
||||
// Tab, feed filter, wipe and leaderboard sort all live in search parameters.
|
||||
// That costs a little ceremony here and buys the thing a community site is for:
|
||||
// "look at last wipe's leaderboard on Main" is a LINK. State held in `useState`
|
||||
// would make every one of those sentences unlinkable, lose the reader's place on
|
||||
// a refresh, and make the browser's back button leave the page instead of
|
||||
// undoing what they just clicked.
|
||||
//
|
||||
// `useSearchParams` comes from CORE's router (the shim in `src/shim/`), so it is
|
||||
// the same live navigation context core's own pages use. A module with its own
|
||||
// copy of react-router would get a `useParams` that returns nothing on a page
|
||||
// that otherwise renders perfectly — see `core.js`'s identity check.
|
||||
|
||||
import { useSearchParams, useParams, Link } from 'react-router-dom'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import Feed from '../../components/Feed.jsx'
|
||||
import Leaderboard from '../../components/Leaderboard.jsx'
|
||||
import Online from '../../components/Online.jsx'
|
||||
import Tabs from '../../components/Tabs.jsx'
|
||||
import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx'
|
||||
import Wipes from '../../components/Wipes.jsx'
|
||||
import { ago, count, day } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'feed', label: 'Feed' },
|
||||
{ id: 'leaderboard', label: 'Leaderboard' },
|
||||
{ id: 'online', label: 'Online' },
|
||||
{ id: 'wipes', label: 'Wipes' },
|
||||
]
|
||||
|
||||
export default function ServerDetail() {
|
||||
const { id } = useParams()
|
||||
const [params, setParams] = useSearchParams()
|
||||
|
||||
const { data, loading, error } = useAsync(() => api.servers.get(id), [id])
|
||||
const server = data ? data.server : null
|
||||
|
||||
const tab = TABS.some((t) => t.id === params.get('tab')) ? params.get('tab') : 'feed'
|
||||
const filter = params.get('show') || 'all'
|
||||
const sort = params.get('sort') || 'kills'
|
||||
|
||||
// `wipe` absent means all time; `wipe=current` means whatever wipe the server
|
||||
// is on now, which is a moving target and therefore a word rather than an id —
|
||||
// a link somebody shares stays about "now" rather than about the map that was
|
||||
// current when they sent it.
|
||||
const wipeParam = params.get('wipe')
|
||||
const wipeId = !wipeParam || wipeParam === ALL_TIME ? null : wipeParam === 'current' ? (server && server.wipeId) || null : wipeParam
|
||||
|
||||
const set = (key, value) => {
|
||||
const next = new URLSearchParams(params)
|
||||
if (!value || value === 'all' || (key === 'tab' && value === 'feed')) next.delete(key)
|
||||
else next.set(key, value)
|
||||
// `replace` so that flipping between tabs does not fill the reader's history
|
||||
// with one entry per click — back should leave the page they arrived on.
|
||||
setParams(next, { replace: true })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<Loading />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// A 404 from the detail route is the one answer the other four cannot give:
|
||||
// an unknown id has no events, no leaderboard and nobody online, and each of
|
||||
// those empty lists is a perfectly good answer to its own question. So this is
|
||||
// where "there is no such server" is said.
|
||||
//
|
||||
// **A mistyped address is not a fault, and must not be dressed as one.** The
|
||||
// first version of this page rendered core's `ErrorState` under the heading and
|
||||
// the result read "No such server / Something went wrong" — which sends a
|
||||
// reader who fat-fingered a URL looking for an outage. `ErrorState` is kept for
|
||||
// the case it is for: a request that failed for a reason nobody can see.
|
||||
if (error || !server) {
|
||||
const missing = !error || error.status === 404
|
||||
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<PageHeader
|
||||
title={missing ? 'No such server' : 'That server could not be loaded'}
|
||||
lead={
|
||||
missing
|
||||
? 'This address does not name a server this site follows.'
|
||||
: 'The site could not read this server just now. It is worth trying again.'
|
||||
}
|
||||
/>
|
||||
{!missing && <ErrorState error={error} />}
|
||||
<p className="sans" style={{ marginTop: 20 }}>
|
||||
<Link to="/rust">Back to the server list</Link>
|
||||
</p>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<PageHeader
|
||||
eyebrow="Rust"
|
||||
title={server.name}
|
||||
lead={describeWorld(server)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: 16, alignItems: 'baseline', marginBottom: 24 }}
|
||||
>
|
||||
<span style={{ color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
|
||||
{server.online
|
||||
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
||||
: 'Offline'}
|
||||
</span>
|
||||
{/* `lastSeenAt` is when a frame arrived; `updatedAt` is when this site
|
||||
last wrote the row, which a FAILED poll does too. Reading the second
|
||||
as the first is what made an offline server claim it had reported just
|
||||
now, every thirty seconds, for as long as it stayed down. */}
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>
|
||||
{server.lastSeenAt ? `last reported ${ago(server.lastSeenAt)}` : 'has never reported'}
|
||||
{server.stale && server.lastSeenAt ? ' — out of date, so it is shown as offline' : ''}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<WipeSelect
|
||||
serverId={server.id}
|
||||
value={wipeParam}
|
||||
currentWipeId={server.wipeId}
|
||||
onChange={(value) => set('wipe', value === ALL_TIME ? null : value)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs tabs={TABS} active={tab} onSelect={(next) => set('tab', next)} label={`${server.name} sections`} />
|
||||
|
||||
{tab === 'feed' && (
|
||||
<Feed serverId={server.id} wipeId={wipeId} filter={filter} onFilter={(value) => set('show', value)} />
|
||||
)}
|
||||
|
||||
{tab === 'leaderboard' && (
|
||||
<Leaderboard serverId={server.id} wipeId={wipeId} sort={sort} onSort={(value) => set('sort', value)} />
|
||||
)}
|
||||
|
||||
{tab === 'online' && <Online serverId={server.id} online={server.online} />}
|
||||
|
||||
{tab === 'wipes' && (
|
||||
<Wipes
|
||||
serverId={server.id}
|
||||
currentWipeId={server.wipeId}
|
||||
selected={wipeId}
|
||||
// Picking a wipe here is a navigation as much as a filter: it is the
|
||||
// question "what happened during that map", and the answer is the feed.
|
||||
onSelect={(value) => {
|
||||
const next = new URLSearchParams(params)
|
||||
next.set('wipe', value)
|
||||
next.delete('tab')
|
||||
setParams(next, { replace: true })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
/** The world line under the heading — the things a Rust player asks first. */
|
||||
function describeWorld(server) {
|
||||
const parts = [
|
||||
server.level || null,
|
||||
server.worldSize ? `size ${count(server.worldSize)}` : null,
|
||||
server.seed ? `seed ${server.seed}` : null,
|
||||
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.'
|
||||
}
|
||||
@@ -1,43 +1,45 @@
|
||||
// ── The server list ───────────────────────────────────────────────────────
|
||||
// ── The server list, and the module's landing page ────────────────────────
|
||||
//
|
||||
// R8: the list is what `/rust` renders, and `/rust/servers/:id` hangs beneath
|
||||
// it. The route is registered with an empty path in `entry.jsx` — core turns
|
||||
// that into the module's own namespace root — so this page's address is the one
|
||||
// an operator links to when they mean "our Rust servers".
|
||||
//
|
||||
// An ordinary React component. Nothing about being inside a module changes how
|
||||
// you write one — the only differences are where React comes from (core, via the
|
||||
// you write one; the only differences are where React comes from (core, via the
|
||||
// aliases in `vite.config.js`, so the import below looks completely normal and is
|
||||
// not) and where the chrome comes from (`../../core.js`, the shared UI kit).
|
||||
//
|
||||
// **Render `PublicLayout` yourself.** Core wraps public routes in its maintenance
|
||||
// gate and nothing else, so a page that omits the layout renders bare — no
|
||||
// header, no footer, no site chrome — which looks like a bug and is the contract
|
||||
// (§3.3). Admin and player routes are the other way round: core wraps those.
|
||||
// **Render `PublicLayout` yourself, and pass a `shell`.** Core wraps public
|
||||
// routes in its maintenance gate and nothing else, so a page that omits the
|
||||
// layout renders bare; without a `shell` it renders full-bleed with the footer
|
||||
// riding up underneath it. Name a width, never a class — the classes are core's
|
||||
// (MODULE_API.md §3.3).
|
||||
//
|
||||
// **And pass a `shell`.** The layout is the chrome; `shell` is the body — the
|
||||
// centred column, the vertical padding, and the thing that holds the footer at
|
||||
// the bottom of the viewport. Widths are 'narrow', 'mid' and 'wide'; name a
|
||||
// width, never a class, because the classes belong to core's stylesheet.
|
||||
//
|
||||
// This is the phase-1 version of the landing page R8 calls for. It lists servers
|
||||
// and links nowhere yet — `/rust/servers/:id` is the next phase's work — so it is
|
||||
// deliberately a table and not a design.
|
||||
// **This page never calls a game server.** Every field it renders comes from
|
||||
// this module's own tables, written by the ingest cursor, which is what lets it
|
||||
// render "offline, last seen an hour ago" instead of an error page when a shard
|
||||
// is down. The site's availability does not depend on the game's.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import { ago, count, day } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
// A relative time that does not need a date library. `Intl.RelativeTimeFormat`
|
||||
// is in every browser core supports, and one fewer dependency in the chunk is
|
||||
// one fewer thing an operator ships.
|
||||
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
function ago(iso) {
|
||||
if (!iso) return 'never'
|
||||
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000)
|
||||
const [unit, size] = Math.abs(seconds) < 3600 ? ['minute', 60] : ['hour', 3600]
|
||||
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||
/** The "last reported" line, which has three cases and not one. */
|
||||
function reported(server) {
|
||||
if (!server.lastSeenAt) return 'This server has never reported.'
|
||||
if (server.stale) return `Last reported ${ago(server.lastSeenAt)} — out of date, so it is shown as offline.`
|
||||
return `Last reported ${ago(server.lastSeenAt)}.`
|
||||
}
|
||||
|
||||
export default function Servers() {
|
||||
// `useAsync` is core's fetch/loading/error hook, and the components below are
|
||||
// its states. Using them rather than rolling your own is what makes a module
|
||||
// page indistinguishable from a core one while it loads and while it fails.
|
||||
//
|
||||
// It loads once, deliberately. The DETAIL page polls, because that is where
|
||||
// somebody watching a server sits; a list is a place people pass through.
|
||||
const { data, loading, error } = useAsync(() => api.servers.list(), [])
|
||||
const servers = data ? data.servers : []
|
||||
|
||||
@@ -66,37 +68,54 @@ export default function Servers() {
|
||||
)}
|
||||
|
||||
{servers.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
// The whole row is the link. A server's name being the only clickable
|
||||
// part is the thing people miss on a list of cards, and `a.card`
|
||||
// already carries core's own hover treatment.
|
||||
<Link
|
||||
key={server.id}
|
||||
to={`/rust/servers/${encodeURIComponent(server.id)}`}
|
||||
className="card"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '1rem',
|
||||
padding: '0.75rem 0',
|
||||
borderBottom: '1px solid rgba(128,128,128,0.25)',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{server.name}</strong>
|
||||
{server.level ? <span style={{ opacity: 0.7 }}> · {server.level}</span> : null}
|
||||
<div style={{ opacity: 0.7, fontSize: '0.9em' }}>
|
||||
{/* `stale` is a first-class part of the answer rather than
|
||||
something the page infers from a timestamp. The server
|
||||
decides what counts as stale, because the server is what
|
||||
knows how often a sidecar is supposed to check in. */}
|
||||
Last reported {ago(server.updatedAt)}
|
||||
{server.stale ? ' — out of date, so it is shown as offline.' : '.'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'nowrap' }}>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>{server.name}</strong>
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
{[
|
||||
server.level || null,
|
||||
server.worldSize ? `size ${count(server.worldSize)}` : null,
|
||||
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem', marginTop: 2 }}>
|
||||
{/* `lastSeenAt`, never `updatedAt`. The second is when THIS
|
||||
site last wrote the row — which a failed poll does too — so
|
||||
a page reading it told a reader that a server down for three
|
||||
days had reported just now. And `stale` is a first-class
|
||||
part of the answer rather than something inferred from a
|
||||
timestamp: the server decides what counts as stale, because
|
||||
the server knows how often a sidecar is supposed to check in. */}
|
||||
{reported(server)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ whiteSpace: 'nowrap', color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}
|
||||
>
|
||||
{server.online
|
||||
? `${server.players}${server.maxPlayers ? ` / ${server.maxPlayers}` : ''} online`
|
||||
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
||||
: 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
141
client/test/feed.test.js
Normal file
141
client/test/feed.test.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// ── The feed's sentences ──────────────────────────────────────────────────
|
||||
//
|
||||
// `lib/feed.js` is the one part of the client half with real branching in it, and
|
||||
// it is pure on purpose so that a DOM-less runner can ask all of it. Everything
|
||||
// here is a claim about what a reader sees for a given frame — which is exactly
|
||||
// the kind of thing that rots silently, because a wrong killfeed line is still a
|
||||
// killfeed line.
|
||||
//
|
||||
// The fixtures are the frames the bridge plugin actually emits (its
|
||||
// `DescribeAttacker`, and PROTOCOL.md §8.4), not invented shapes.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { describe, FEED_KINDS, FILTERS, kindsFor } from '../src/lib/feed.js'
|
||||
|
||||
const row = (kind, frame = {}) => ({ id: 1, kind, t: Date.now(), wipeId: 'w1', steamId: '7656', frame })
|
||||
|
||||
test('a player kill names the killer and the victim, in that order', () => {
|
||||
const line = describe(row('player.death', {
|
||||
name: 'Bob',
|
||||
attackerType: 'player',
|
||||
attackerName: 'Alice',
|
||||
weapon: 'rifle.ak',
|
||||
distance: 42.4,
|
||||
grid: 'H7',
|
||||
}))
|
||||
|
||||
assert.equal(line.tone, 'kill')
|
||||
assert.equal(line.actor, 'Alice')
|
||||
assert.equal(line.verb, 'killed')
|
||||
assert.equal(line.subject, 'Bob')
|
||||
assert.match(line.detail, /rifle ak/)
|
||||
assert.match(line.detail, /42m/)
|
||||
assert.match(line.detail, /H7/)
|
||||
})
|
||||
|
||||
test('the four attacker types are four different sentences', () => {
|
||||
// The plugin distinguishes them precisely so a reader does not have to guess
|
||||
// from an absent field, and collapsing any two loses something: a fall reported
|
||||
// as a kill by nobody is the failure this prevents.
|
||||
const victim = { name: 'Bob' }
|
||||
|
||||
const npc = describe(row('player.death', { ...victim, attackerType: 'npc', attackerName: 'scientistnpc_full_any' }))
|
||||
assert.equal(npc.actor, 'scientistnpc full any')
|
||||
assert.equal(npc.subject, 'Bob')
|
||||
|
||||
const self = describe(row('player.death', { ...victim, attackerType: 'self' }))
|
||||
assert.equal(self.actor, 'Bob')
|
||||
assert.equal(self.subject, null)
|
||||
assert.match(self.verb, /own hand/)
|
||||
|
||||
const environment = describe(row('player.death', { ...victim, attackerType: 'environment' }))
|
||||
assert.equal(environment.actor, 'Bob')
|
||||
assert.equal(environment.verb, 'died')
|
||||
assert.equal(environment.subject, null)
|
||||
|
||||
// `HitInfo` is legitimately null on the environment path, so a death frame with
|
||||
// NO attacker type at all is that case — not a missing field to render around.
|
||||
const bare = describe(row('player.death', victim))
|
||||
assert.equal(bare.verb, 'died')
|
||||
assert.equal(bare.subject, null)
|
||||
})
|
||||
|
||||
test('a sleeping victim is said to have been sleeping', () => {
|
||||
const line = describe(row('player.death', { name: 'Bob', attackerType: 'player', attackerName: 'Alice', sleeping: true }))
|
||||
assert.match(line.detail, /while sleeping/)
|
||||
})
|
||||
|
||||
test('a disconnect with no session length says nothing about one', () => {
|
||||
// The plugin OMITS `sessionSec` for a player who was already on when it loaded:
|
||||
// an unknown session is not a session of no length. A line reading "after 0s"
|
||||
// would be a lie this module invented.
|
||||
const unknown = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit' }))
|
||||
assert.equal(unknown.detail, 'Quit')
|
||||
|
||||
const known = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit', sessionSec: 3720 }))
|
||||
assert.equal(known.detail, 'Quit · after 1h 2m')
|
||||
})
|
||||
|
||||
test('a chat line carries the message as text, never as markup', () => {
|
||||
// The message is the one field on this wire whose bytes a player chooses. It
|
||||
// comes back as a STRING and is rendered as a React child, which escapes it;
|
||||
// this test is here so that a later "render the message with formatting" idea
|
||||
// has to delete an explicit assertion rather than quietly change behaviour.
|
||||
const line = describe(row('player.chat', { name: 'Bob', message: '<img src=x onerror=alert(1)>', channel: 'Global' }))
|
||||
assert.equal(line.verb, '<img src=x onerror=alert(1)>')
|
||||
assert.equal(typeof line.verb, 'string')
|
||||
// Global is the default channel and saying so on every line is noise; Team is
|
||||
// information.
|
||||
assert.equal(line.detail, '')
|
||||
assert.equal(describe(row('player.chat', { name: 'B', message: 'hi', channel: 'Team' })).detail, 'Team')
|
||||
|
||||
// A chat row is the one line where the actor is a speaker rather than a
|
||||
// subject, and "Brannock see you in september" is not a sentence anybody
|
||||
// writes. The colon is presentation, so it lives here and not inside the text
|
||||
// the player typed.
|
||||
assert.equal(line.join, ': ')
|
||||
assert.equal(describe(row('player.connected', { name: 'B' })).join, undefined)
|
||||
})
|
||||
|
||||
test('an unknown kind renders as itself rather than vanishing', () => {
|
||||
// A later protocol adds kinds, and a module may be older than the game host it
|
||||
// is reading. The server's allowlist has already decided the row may be seen;
|
||||
// dropping it here would make the page quietly say less than the truth.
|
||||
const line = describe(row('player.teleported', { name: 'Bob' }))
|
||||
assert.equal(line.verb, 'player.teleported')
|
||||
assert.equal(line.tone, 'other')
|
||||
})
|
||||
|
||||
test('the feed never asks for the aggregate kind', () => {
|
||||
// `player.tally` is public and is flushed once a minute per active player
|
||||
// (§8.6). A feed that included it would be mostly wood counts; it is the
|
||||
// leaderboard's input, and that is where it shows up.
|
||||
assert.ok(!FEED_KINDS.includes('player.tally'))
|
||||
for (const filter of FILTERS) {
|
||||
for (const kind of filter.kinds) {
|
||||
assert.ok(FEED_KINDS.includes(kind), `filter "${filter.id}" asks for ${kind}, which the feed does not carry`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('every kind the feed asks for is one the public route will serve', () => {
|
||||
// Held against the module's own allowlist rather than against a copy of it: a
|
||||
// kind this file asked for and `server/catalogue.js` refuses is a filter that
|
||||
// silently returns nothing, which reads as a quiet server.
|
||||
//
|
||||
// A CommonJS file from the server half, read by an ESM test through
|
||||
// `createRequire`. Crossing the two halves is fine HERE and nowhere else:
|
||||
// `test/` is not shipped, and `scripts/checkImports.js` governs what is.
|
||||
const catalogue = createRequire(import.meta.url)('../../server/catalogue.js')
|
||||
for (const kind of FEED_KINDS) {
|
||||
assert.ok(catalogue.PUBLIC_KINDS.includes(kind), `the feed asks for ${kind}, which is not public`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unknown filter falls back to everything rather than to nothing', () => {
|
||||
assert.deepEqual(kindsFor('nonsense'), FEED_KINDS)
|
||||
assert.deepEqual(kindsFor(undefined), FEED_KINDS)
|
||||
})
|
||||
96
client/test/format.test.js
Normal file
96
client/test/format.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// ── Formatting ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Small functions, and the tests are small too — but three of them guard claims
|
||||
// that would otherwise be made by a page that looks fine: an unknown duration
|
||||
// rendered as zero, a timestamp in the wrong unit, and "in 0 seconds".
|
||||
//
|
||||
// Locale-dependent output is asserted loosely on purpose. `Intl` formats to the
|
||||
// RUNNER's locale, and a test pinned to "3 minutes ago" would be a test that
|
||||
// fails on a machine set to French while the page it describes is correct.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { ago, clock, count, day, duration, prefab, shortId } from '../src/lib/format.js'
|
||||
|
||||
const NOW = Date.parse('2026-09-16T12:00:00Z')
|
||||
|
||||
test('a relative time picks the unit that fits', () => {
|
||||
assert.match(ago(NOW - 3 * 60_000, NOW), /3/)
|
||||
assert.match(ago(NOW - 5 * 3600_000, NOW), /5/)
|
||||
assert.match(ago(NOW - 3 * 86400_000, NOW), /3/)
|
||||
})
|
||||
|
||||
test('"just now" rather than "in 0 seconds"', () => {
|
||||
// What `numeric: 'auto'` produces under a minute is not what anybody means,
|
||||
// and a feed row a few seconds old is the commonest row on the page.
|
||||
assert.equal(ago(NOW, NOW), 'just now')
|
||||
assert.equal(ago(NOW - 10_000, NOW), 'just now')
|
||||
})
|
||||
|
||||
test('both time shapes this module serves are accepted', () => {
|
||||
// `updatedAt` is an ISO string the model produced; an event's `t` is the
|
||||
// millisecond stamp the plugin put on the frame. A helper that took only one
|
||||
// would be a helper every caller has to remember the type for.
|
||||
assert.equal(ago('2026-09-16T11:57:00.000Z', NOW), ago(NOW - 3 * 60_000, NOW))
|
||||
})
|
||||
|
||||
test('a missing time is "never", not the epoch', () => {
|
||||
assert.equal(ago(null), 'never')
|
||||
assert.equal(ago(undefined), 'never')
|
||||
assert.equal(ago(''), 'never')
|
||||
assert.equal(day(null), 'unknown')
|
||||
})
|
||||
|
||||
test('an unknown duration is a dash, and a short one keeps its seconds', () => {
|
||||
// The distinction the plugin makes and this must not lose: `sessionSec` is
|
||||
// ABSENT for a player who was already on when it loaded, so zero and unknown
|
||||
// arrive at the same function and must not render the same way.
|
||||
assert.equal(duration(null), '—')
|
||||
assert.equal(duration(0), '—')
|
||||
assert.equal(duration(40), '40s')
|
||||
assert.equal(duration(90), '2m')
|
||||
assert.equal(duration(3720), '1h 2m')
|
||||
assert.equal(duration(7200), '2h')
|
||||
})
|
||||
|
||||
test('a prefab reads as words, without a lookup table', () => {
|
||||
assert.equal(prefab('rifle.ak'), 'rifle ak')
|
||||
assert.equal(prefab('scientistnpc_full_any'), 'scientistnpc full any')
|
||||
assert.equal(prefab(null), '')
|
||||
})
|
||||
|
||||
test('a steam id is shortened without pretending to be a name', () => {
|
||||
assert.equal(shortId('76561198000000001'), '…000001')
|
||||
assert.equal(shortId(''), '')
|
||||
})
|
||||
|
||||
test('a count that is not a number is zero, never NaN on the page', () => {
|
||||
assert.equal(count(undefined), '0')
|
||||
assert.equal(count(null), '0')
|
||||
})
|
||||
|
||||
test("a feed row from another day carries its date, not just a time", () => {
|
||||
// Found by the page walk: with the feed filtered to the previous wipe, three
|
||||
// events from six weeks ago rendered as `02:03 PM` and read as this afternoon.
|
||||
// Today's rows stay bare, because a killfeed of today's fights does not want
|
||||
// the date on every line.
|
||||
// Asserted against `Intl` rather than against a literal: a 12-hour locale puts
|
||||
// letters in a bare time ("05:30 AM"), so "has letters in it" is not the test —
|
||||
// "is exactly the time, and nothing else" is.
|
||||
const time = (at) => new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const todayAt = NOW - 90 * 60_000
|
||||
assert.equal(clock(todayAt, NOW), time(todayAt))
|
||||
|
||||
const olderAt = NOW - 46 * 86400_000
|
||||
assert.ok(clock(olderAt, NOW).endsWith(time(olderAt)))
|
||||
assert.ok(clock(olderAt, NOW).length > time(olderAt).length, 'an older row carries no date')
|
||||
|
||||
// Yesterday counts as another day even when it is only a few hours back — the
|
||||
// boundary is the calendar, not a duration, because that is what a reader
|
||||
// means by "what time was that".
|
||||
const lateLastNight = Date.parse('2026-09-15T23:50:00')
|
||||
const earlyToday = Date.parse('2026-09-16T00:20:00')
|
||||
assert.ok(clock(lateLastNight, earlyToday).length > time(lateLastNight).length)
|
||||
})
|
||||
@@ -66,9 +66,22 @@ function fakeRg() {
|
||||
),
|
||||
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
|
||||
registry: {
|
||||
// Core's own prefixing, character for character (client/src/modules/registry.js):
|
||||
// the leading separators of the module's path are stripped and so are the
|
||||
// TRAILING ones, which is what lets a module register `path: ''` and own its
|
||||
// namespace root — `/rust` rather than `/rust/`.
|
||||
//
|
||||
// This fake did the obvious `${id}/${path}` until phase 4, and the day a
|
||||
// module registered an index route it produced `rust/` while a real core
|
||||
// produced `rust`. The suite then failed the nav check for a link that works
|
||||
// perfectly in a browser. A fake that is nearly core is worse than one that
|
||||
// is obviously not: it fails on the truth.
|
||||
registerRoutes(id, byArea) {
|
||||
for (const [area, list] of Object.entries(byArea || {})) {
|
||||
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id })
|
||||
for (const r of list || []) {
|
||||
const path = `${id}/${String(r.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
|
||||
routes[area].push({ ...r, path, moduleId: id })
|
||||
}
|
||||
}
|
||||
},
|
||||
registerNav(id, { area, items }) {
|
||||
@@ -120,7 +133,12 @@ it('registers at least one route, namespaced under the module id', () => {
|
||||
assert.ok(all.length > 0, 'the chunk registered no routes at all')
|
||||
for (const [area, list] of Object.entries(registered.routes)) {
|
||||
for (const r of list) {
|
||||
assert.ok(r.path.startsWith(`${manifest.id}/`), `${area} route "${r.path}" is not under the namespace`)
|
||||
// Either the namespace root itself (a module's index route, `rust`) or
|
||||
// something under it (`rust/servers/:id`). `startsWith('rust/')` alone
|
||||
// would reject the root — and `startsWith('rust')` alone would accept a
|
||||
// hypothetical `rustling`, which is why this is spelled out.
|
||||
const under = r.path === manifest.id || r.path.startsWith(`${manifest.id}/`)
|
||||
assert.ok(under, `${area} route "${r.path}" is not under the namespace`)
|
||||
assert.ok(r.element, `${area} route "${r.path}" has no element`)
|
||||
}
|
||||
}
|
||||
@@ -176,6 +194,19 @@ it('a nav row that gates on a feature has a provider to resolve it', () => {
|
||||
assert.ok(registered.providers.size > 0, 'rows carry feature gates but no provider was registered')
|
||||
})
|
||||
|
||||
it('the footer slot core declares is filled, and by a component', () => {
|
||||
// R13's first slot, and the half that lives in the CHUNK: `site.footer.status`
|
||||
// is a CLIENT slot, so it cannot be named in `module.json`'s `extensions` —
|
||||
// that array is validated against the SERVER registry and naming a client slot
|
||||
// there fails the load outright. Nothing else holds this registration, and an
|
||||
// extension that stopped being registered is invisible: an unfilled slot
|
||||
// renders nothing, exactly as an uninstalled module does.
|
||||
const footer = registered.extensions.get('site.footer.status')
|
||||
assert.ok(footer, 'nothing fills site.footer.status')
|
||||
assert.equal(footer.id, manifest.id)
|
||||
assert.equal(typeof footer.Component, 'function')
|
||||
})
|
||||
|
||||
it('every slot module.json declares is one the chunk fills', () => {
|
||||
// `module.json` declares SERVER slots, and the loader validates those before
|
||||
// the chunk is ever served. Client slots cannot be declared there — the server
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"admin": ["/rust"],
|
||||
"player": ["/rust"]
|
||||
},
|
||||
"capabilities": ["servers"]
|
||||
"capabilities": ["servers", "killfeed", "leaderboard", "presence", "wipes"]
|
||||
}
|
||||
|
||||
60
routes.manifest.json
Normal file
60
routes.manifest.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"$comment": "Generated inventory of the URLs module-rust serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see server/scripts/frozenManifest.js.",
|
||||
"routes": [
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/servers/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/servers",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/servers",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/events",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/leaderboard",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/online",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/wipes",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/servers/:id/test",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/servers/:id",
|
||||
"tier": "public"
|
||||
}
|
||||
]
|
||||
}
|
||||
118
server/boot.js
118
server/boot.js
@@ -21,25 +21,52 @@
|
||||
// letting that fail the boot would make installing the module before installing
|
||||
// the bridge impossible.
|
||||
//
|
||||
// ── Polling, in phase 1 ───────────────────────────────────────────────────
|
||||
// ── Three timers, and they answer three different questions ───────────────
|
||||
//
|
||||
// This is a poll, and the live feed it will become is a later phase's work. The
|
||||
// poll is not a placeholder for it: a sidecar's store-backed reads are exactly
|
||||
// what answers while a game server is off, and the module will keep reading them
|
||||
// on an interval to notice a server that went away without saying anything.
|
||||
// What the feed adds is latency, not coverage.
|
||||
// refresh (30s) what is each server, and who is on it — the BOARDS
|
||||
// ingest (5s) what has happened since we last looked — the CURSOR
|
||||
// prune (1h) forgetting the detail we promised not to keep for ever
|
||||
//
|
||||
// The boards poll and the ingest are deliberately separate rather than one loop
|
||||
// reading both. They fail differently and they matter differently: a board that
|
||||
// is 30 seconds stale shows a player count slightly behind, and an ingest that
|
||||
// is 30 seconds behind shows a killfeed that feels broken. Splitting them lets
|
||||
// the cheap one run often and the expensive one run rarely, and it means a
|
||||
// sidecar that answers one and not the other degrades in exactly one place.
|
||||
//
|
||||
// The poll was never a placeholder for a socket: a sidecar's store-backed reads
|
||||
// are what answer while a game server is off, which is most of what this module
|
||||
// renders. See `ingest.js` for why the live feed is a cursor and not a
|
||||
// WebSocket.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const db = require('./model/servers/servers.db')
|
||||
const eventsDb = require('./model/events/events.db')
|
||||
const ingest = require('./ingest')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const sidecar = require('./sidecarClient')
|
||||
|
||||
const log = core.logger('boot')
|
||||
|
||||
let refreshTimer = null
|
||||
let ingestTimer = null
|
||||
let pruneTimer = null
|
||||
|
||||
const REFRESH_MS = 30 * 1000
|
||||
const INGEST_MS = 5 * 1000
|
||||
const PRUNE_MS = 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* How long this module keeps raw events.
|
||||
*
|
||||
* Longer than the sidecar's 14 days, because this is the richer store and the
|
||||
* one a page reads — and because the sidecar lives on somebody's game host while
|
||||
* this lives on the website's own database. What is NOT bounded by it is the
|
||||
* record: `rust_player_wipe_stats` and `rust_gather_totals` are permanent, which
|
||||
* is the whole of R12's "a wipe does not erase a player's history".
|
||||
*/
|
||||
const EVENT_RETENTION_DAYS = 30
|
||||
|
||||
/**
|
||||
* Ask every configured sidecar how its server is doing, and store what it said.
|
||||
@@ -63,7 +90,10 @@ async function refresh() {
|
||||
|
||||
async function refreshOne(server) {
|
||||
try {
|
||||
const board = await sidecar.serverBoard(server)
|
||||
// One call for both boards. `/server` would answer the same question about
|
||||
// the server itself, but presence would then be a second round trip to the
|
||||
// same process for a fact it already had in hand.
|
||||
const board = await sidecar.boards(server)
|
||||
|
||||
// Three outcomes, and collapsing any two of them loses something an operator
|
||||
// needs:
|
||||
@@ -76,16 +106,29 @@ async function refreshOne(server) {
|
||||
// whose plugin is not loaded yet, and reporting it as unreachable sends the
|
||||
// operator to look at the network instead of at the game server.
|
||||
if (!board.ok) {
|
||||
await db.putState({ serverId: server.id, reachable: false, online: false })
|
||||
// `markUnreachable`, not `putState`: nothing answered, so the only new fact
|
||||
// is that nothing answered. Writing the whole row from that one fact would
|
||||
// blank the hostname, the map, the seed and the wipe — the last thing this
|
||||
// server said, which is exactly what the pages exist to render while it is
|
||||
// off.
|
||||
await db.markUnreachable(server.id, false)
|
||||
return
|
||||
}
|
||||
|
||||
const frame = board.data
|
||||
const boards = (board.data && board.data.boards) || {}
|
||||
const frame = boards['server.hello']
|
||||
|
||||
if (!frame) {
|
||||
await db.putState({ serverId: server.id, reachable: true, online: false })
|
||||
// The sidecar is up and has never heard from the game. Presence is emptied
|
||||
// rather than left alone: a stale list of players on a server nobody can
|
||||
// reach is worse than an empty one, because it looks current.
|
||||
await db.markUnreachable(server.id, true)
|
||||
await ingest.applyBoards(server.id, {})
|
||||
return
|
||||
}
|
||||
|
||||
await ingest.applyBoards(server.id, boards)
|
||||
|
||||
await db.putState({
|
||||
serverId: server.id,
|
||||
reachable: true,
|
||||
@@ -102,6 +145,7 @@ async function refreshOne(server) {
|
||||
worldSize: frame.worldSize === undefined ? null : Number(frame.worldSize),
|
||||
bootId: frame.bootId || null,
|
||||
saveCreatedAt: frame.saveCreatedAt || null,
|
||||
wipeId: frame.wipeId || null,
|
||||
protocol: frame.protocol === undefined ? null : Number(frame.protocol),
|
||||
raw: frame,
|
||||
})
|
||||
@@ -119,14 +163,44 @@ async function refreshOne(server) {
|
||||
* built to look like it — so a module that only needs core at boot time can skip
|
||||
* `core.init` entirely and use this argument.
|
||||
*/
|
||||
/** Runs the cursor for every configured server, independently. */
|
||||
async function ingestAll() {
|
||||
let rows
|
||||
|
||||
try {
|
||||
rows = await servers.listForPolling()
|
||||
} catch (err) {
|
||||
log.warn('could not read the server list', { error: err.message })
|
||||
return
|
||||
}
|
||||
|
||||
// `allSettled`, for the same reason the board poll uses it: six servers behind
|
||||
// one unreachable host must not stop the other five being ingested.
|
||||
await Promise.allSettled(rows.map((server) => ingest.ingestServer(server)))
|
||||
}
|
||||
|
||||
async function prune() {
|
||||
try {
|
||||
const gone = await eventsDb.pruneEvents(EVENT_RETENTION_DAYS)
|
||||
if (gone > 0) log.info('pruned old events', { events: gone, days: EVENT_RETENTION_DAYS })
|
||||
} catch (err) {
|
||||
log.warn('could not prune events', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function onBoot() {
|
||||
await refresh()
|
||||
refreshTimer = setInterval(refresh, REFRESH_MS)
|
||||
ingestTimer = setInterval(ingestAll, INGEST_MS)
|
||||
pruneTimer = setInterval(prune, PRUNE_MS)
|
||||
// Node keeps the process alive for a pending timer. Core's own intervals are
|
||||
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
|
||||
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
|
||||
if (typeof refreshTimer.unref === 'function') refreshTimer.unref()
|
||||
log.info('booted', { refreshMs: REFRESH_MS })
|
||||
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
|
||||
if (timer && typeof timer.unref === 'function') timer.unref()
|
||||
}
|
||||
|
||||
log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,9 +212,25 @@ async function onBoot() {
|
||||
* rather than cancelled, since nothing can stop a promise that is still running.
|
||||
*/
|
||||
async function onShutdown() {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
|
||||
if (timer) clearInterval(timer)
|
||||
}
|
||||
|
||||
refreshTimer = null
|
||||
ingestTimer = null
|
||||
pruneTimer = null
|
||||
|
||||
log.info('shut down')
|
||||
}
|
||||
|
||||
module.exports = { onBoot, onShutdown, refresh, refreshOne, REFRESH_MS }
|
||||
module.exports = {
|
||||
onBoot,
|
||||
onShutdown,
|
||||
refresh,
|
||||
refreshOne,
|
||||
ingestAll,
|
||||
prune,
|
||||
REFRESH_MS,
|
||||
INGEST_MS,
|
||||
EVENT_RETENTION_DAYS,
|
||||
}
|
||||
|
||||
118
server/catalogue.js
Normal file
118
server/catalogue.js
Normal file
@@ -0,0 +1,118 @@
|
||||
// ── What the bridge can say, and who may hear it ──────────────────────────
|
||||
//
|
||||
// One file, because these two questions have to be answered together or the
|
||||
// second one rots: which frame kinds exist, and which of them a member of the
|
||||
// public may see.
|
||||
//
|
||||
// ── The boundary ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Protocol 2's catalogue includes frames carrying **IP addresses** (a login
|
||||
// attempt, an approval, a ban) and **one player's complaint about another** (a
|
||||
// report), and one — a destroyed structure — that names where somebody lives.
|
||||
// They are stored, because an operator chasing ban evasion needs them and
|
||||
// because the sidecar persists what it is told. They must never reach a public
|
||||
// page.
|
||||
//
|
||||
// **The boundary is enforced HERE, on the side that serves, and not on the wire.**
|
||||
// The plugin could have stamped a `class` on every frame and saved this file the
|
||||
// trouble; it deliberately does not (PROTOCOL.md §8.5). A boundary declared by
|
||||
// the sender is a boundary a compromised — or merely out-of-date — game host can
|
||||
// widen. Core's own shard fan-out works the same way: a public stream with an
|
||||
// allowlist of kinds, and an admin stream that adds the rest.
|
||||
//
|
||||
// ── Default deny, and why it is not paranoia ──────────────────────────────
|
||||
//
|
||||
// `isPublic` answers `false` for a kind it has never heard of. That matters
|
||||
// because of the shape of the mistake it prevents: the next protocol version
|
||||
// adds a kind, this module ingests it happily (`rust_events` stores what it is
|
||||
// given), and a page that filtered by a DENY list would publish it the day it
|
||||
// first arrived — before anybody had decided whether it should be public. With
|
||||
// an allowlist the new kind is invisible until somebody adds it here, which is
|
||||
// the same moment they think about it.
|
||||
//
|
||||
// The test holds this list against `docs/rust-link/PROTOCOL.md` §8.4's table, so
|
||||
// adding a kind to the spec without classifying it fails a build rather than
|
||||
// shipping an address to a public page.
|
||||
|
||||
/**
|
||||
* Kinds a public, signed-out visitor may see.
|
||||
*
|
||||
* Each entry is a decision. `player.chat` is here because a shard's chat is
|
||||
* public by the same logic that makes a killfeed public — it happened in front
|
||||
* of everyone who was on the server — and an operator who disagrees turns the
|
||||
* feature off rather than relying on this list being wrong.
|
||||
*/
|
||||
const PUBLIC_KINDS = Object.freeze([
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
'player.death',
|
||||
'player.chat',
|
||||
'player.tally',
|
||||
'server.wipe',
|
||||
'server.initialized',
|
||||
'server.shutdown',
|
||||
])
|
||||
|
||||
/**
|
||||
* Kinds an admin may see and nobody else.
|
||||
*
|
||||
* Listed rather than implied by absence, so that "we know about this kind and it
|
||||
* is restricted" is distinguishable from "nobody has classified this kind" — the
|
||||
* second is a finding, and a bare allowlist cannot tell you which you are
|
||||
* looking at.
|
||||
*/
|
||||
const STAFF_KINDS = Object.freeze([
|
||||
'entity.destroyed',
|
||||
'player.reported',
|
||||
'player.banned',
|
||||
'player.unbanned',
|
||||
'player.login.attempt',
|
||||
'player.approved',
|
||||
])
|
||||
|
||||
/** Every kind protocol 2 defines. */
|
||||
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||
|
||||
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||
const STAFF = new Set(STAFF_KINDS)
|
||||
|
||||
/**
|
||||
* May a signed-out visitor see this kind?
|
||||
*
|
||||
* Default deny: an unknown kind is not public. Callers pass whatever arrived on
|
||||
* the wire, including a kind from a newer protocol this build has never seen.
|
||||
*/
|
||||
function isPublic(kind) {
|
||||
return PUBLIC.has(kind)
|
||||
}
|
||||
|
||||
/** Is this a kind this build knows about at all? */
|
||||
function isKnown(kind) {
|
||||
return PUBLIC.has(kind) || STAFF.has(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows a list of requested kinds to the ones a viewer may have.
|
||||
*
|
||||
* Returning the allowlist itself when nothing was requested is what makes the
|
||||
* public route safe by construction rather than by remembering to filter: there
|
||||
* is no code path where "no filter" means "everything".
|
||||
*/
|
||||
function kindsFor({ admin = false, requested = null } = {}) {
|
||||
const permitted = admin ? ALL_KINDS : PUBLIC_KINDS
|
||||
|
||||
if (!requested || requested.length === 0) return [...permitted]
|
||||
|
||||
const allowed = new Set(permitted)
|
||||
return requested.filter((k) => allowed.has(k))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PUBLIC_KINDS,
|
||||
STAFF_KINDS,
|
||||
ALL_KINDS,
|
||||
isPublic,
|
||||
isKnown,
|
||||
kindsFor,
|
||||
}
|
||||
@@ -19,5 +19,12 @@
|
||||
-- it knows this module registered, because it is the side that knows which
|
||||
-- registrant owned what.
|
||||
|
||||
DROP TABLE IF EXISTS rust_ingest_cursor;
|
||||
DROP TABLE IF EXISTS rust_presence;
|
||||
DROP TABLE IF EXISTS rust_events;
|
||||
DROP TABLE IF EXISTS rust_gather_totals;
|
||||
DROP TABLE IF EXISTS rust_player_wipe_stats;
|
||||
DROP TABLE IF EXISTS rust_players;
|
||||
DROP TABLE IF EXISTS rust_wipes;
|
||||
DROP TABLE IF EXISTS rust_server_state;
|
||||
DROP TABLE IF EXISTS rust_servers;
|
||||
|
||||
@@ -14,14 +14,20 @@
|
||||
-- Every table here is prefixed `rust_`, which is this module's id and the only
|
||||
-- prefix it may create under.
|
||||
--
|
||||
-- ── Two tables, and the split between them is the whole design ────────────
|
||||
-- ── Four kinds of table, and the split between them is the whole design ───
|
||||
--
|
||||
-- `rust_servers` is CONFIGURATION: rows an operator writes, from Admin → Rust.
|
||||
-- `rust_server_state` is OBSERVED STATE: rows this module writes from what a
|
||||
-- sidecar reported. They are separate tables rather than columns on one because
|
||||
-- they have different writers, different lifetimes and different audiences —
|
||||
-- and because a purge of observed state while keeping the configuration is a
|
||||
-- thing an operator will eventually want.
|
||||
-- CONFIGURATION `rust_servers` — rows an operator writes, from Admin → Rust.
|
||||
-- OBSERVED STATE `rust_server_state`, `rust_presence` — what a sidecar last
|
||||
-- reported, replaced rather than appended.
|
||||
-- THE RECORD `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
|
||||
-- `rust_gather_totals` — permanent, and the reason a wipe does
|
||||
-- not erase a player's history.
|
||||
-- THE WINDOW `rust_events` — recent detail, bounded by a sweep.
|
||||
--
|
||||
-- They are separate tables rather than columns on one because they have
|
||||
-- different writers, different lifetimes and different audiences — and because
|
||||
-- a purge of observed state while keeping the configuration is a thing an
|
||||
-- operator will eventually want.
|
||||
--
|
||||
-- Teardown is `purge.sql`, which no boot ever runs.
|
||||
|
||||
@@ -97,3 +103,209 @@ CREATE TABLE IF NOT EXISTS rust_server_state (
|
||||
CONSTRAINT fk_rust_server_state_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── The read path ─────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Protocol 2 turned the bridge from a greeting into a catalogue, and these are
|
||||
-- the tables that hold it. They divide on one line, and it is the line R12 drew:
|
||||
--
|
||||
-- PERMANENT `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
|
||||
-- `rust_gather_totals` — a player's record, kept for ever. All-time
|
||||
-- is a SUM across wipes rather than a second set of counters, so
|
||||
-- there is no second number that can disagree with the first.
|
||||
--
|
||||
-- BOUNDED `rust_events` — the recent raw window the killfeed reads, pruned
|
||||
-- on a sweep. It is detail, not record: losing last month's
|
||||
-- individual deaths costs a scroll-back, losing last month's
|
||||
-- totals costs a player their history.
|
||||
--
|
||||
-- DERIVED `rust_presence` — who is on right now, replaced wholesale from
|
||||
-- the `players.online` board. Never a history, never appended.
|
||||
--
|
||||
-- The sidecar keeps its own bounded copy of the same events (default 14 days),
|
||||
-- so shortening either window loses recent detail and neither loses a total.
|
||||
|
||||
|
||||
-- ── Wipes ─────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- One row per (server, wipe). The id is the plugin's, derived from the save's
|
||||
-- creation time and stamped on every frame (PROTOCOL.md §8.2) — this module
|
||||
-- never derives one, because two derivations of one fact eventually disagree
|
||||
-- about a boundary.
|
||||
--
|
||||
-- Rows appear by being MENTIONED: the first frame carrying a wipe id this module
|
||||
-- has not seen creates it. There is no "start a wipe" call and there must not be
|
||||
-- one, because the website is not present when a wipe happens — a wipe is a fact
|
||||
-- about a world that was restarted while nobody was watching.
|
||||
CREATE TABLE IF NOT EXISTS rust_wipes (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
wipe_id VARCHAR(48) NOT NULL,
|
||||
save_created_at VARCHAR(32) NULL,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, wipe_id),
|
||||
CONSTRAINT fk_rust_wipes_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Players ───────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Identity, and deliberately nothing else. It is keyed on the Steam id alone
|
||||
-- and carries no server: a player is the same person on all six of a community's
|
||||
-- servers, and everything that is per-server lives in the stats table.
|
||||
--
|
||||
-- `user_id` is NOT here. Linking a Steam id to a website account is phase 6's
|
||||
-- work (R1), and a column waiting for it would be a column every read has to
|
||||
-- remember is always null.
|
||||
CREATE TABLE IF NOT EXISTS rust_players (
|
||||
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(191) NULL,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
-- ── The permanent record ──────────────────────────────────────────────────
|
||||
--
|
||||
-- One row per player per wipe per server, and the only counters this module
|
||||
-- keeps. R12's "per-wipe detail plus all-time rollups" is satisfied by SUMming
|
||||
-- this rather than by maintaining a second all-time row, because two counters
|
||||
-- for one fact drift the first time an ingest is replayed.
|
||||
--
|
||||
-- Every column is a COUNT that only ever goes up within a wipe, which is what
|
||||
-- makes ingest idempotent-ish in the only way that matters: the cursor advances
|
||||
-- only after the batch commits, so a crash re-reads a batch it has not counted.
|
||||
--
|
||||
-- `playtime_sec` comes from `sessionSec` on a disconnect, and a session whose
|
||||
-- start this module never saw contributes NOTHING rather than zero — the plugin
|
||||
-- omits the field, the ingest skips it, and the number stays honestly short
|
||||
-- instead of quietly wrong.
|
||||
CREATE TABLE IF NOT EXISTS rust_player_wipe_stats (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
wipe_id VARCHAR(48) NOT NULL,
|
||||
steam_id VARCHAR(32) NOT NULL,
|
||||
kills INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
deaths INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
suicides INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
npc_kills INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
structures INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
sessions INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
playtime_sec BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_seen DATETIME NULL,
|
||||
PRIMARY KEY (server_id, wipe_id, steam_id),
|
||||
KEY idx_rust_stats_kills (server_id, wipe_id, kills DESC),
|
||||
KEY idx_rust_stats_player (steam_id)
|
||||
);
|
||||
|
||||
|
||||
-- ── What they gathered ────────────────────────────────────────────────────
|
||||
--
|
||||
-- A row per resource rather than a JSON blob on the stats row, for one reason:
|
||||
-- the leaderboard question is "who gathered the most sulfur this wipe", and that
|
||||
-- is an ORDER BY over a column in every SQL engine and a JSON function call in
|
||||
-- exactly one. The resource name is the game's own shortname, unknown in advance
|
||||
-- and not worth a lookup table.
|
||||
CREATE TABLE IF NOT EXISTS rust_gather_totals (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
wipe_id VARCHAR(48) NOT NULL,
|
||||
steam_id VARCHAR(32) NOT NULL,
|
||||
resource VARCHAR(64) NOT NULL,
|
||||
amount BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, wipe_id, steam_id, resource),
|
||||
KEY idx_rust_gather_top (server_id, wipe_id, resource, amount DESC)
|
||||
);
|
||||
|
||||
|
||||
-- ── The recent raw window ─────────────────────────────────────────────────
|
||||
--
|
||||
-- Every ingested event, whole, for as long as the retention sweep keeps it. The
|
||||
-- killfeed reads this; so does an admin looking at what happened.
|
||||
--
|
||||
-- `raw` holds the entire frame and the columns beside it are only what a query
|
||||
-- needs to reach — the same rule the sidecar's own store follows, one hop along:
|
||||
-- a protocol version that adds a field needs no migration here.
|
||||
--
|
||||
-- **`kind` is a security boundary, not a label.** Some kinds carry IP addresses
|
||||
-- and player reports (PROTOCOL.md §8.4), and what makes them safe is that the
|
||||
-- public read is filtered by an allowlist this module holds, default-deny. The
|
||||
-- rows are stored either way, because an operator chasing ban evasion needs them.
|
||||
CREATE TABLE IF NOT EXISTS rust_events (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
wipe_id VARCHAR(48) NULL,
|
||||
kind VARCHAR(64) NOT NULL,
|
||||
t BIGINT NOT NULL,
|
||||
steam_id VARCHAR(32) NULL,
|
||||
raw LONGTEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_rust_events_server (server_id, id DESC),
|
||||
KEY idx_rust_events_kind (server_id, kind, id DESC),
|
||||
KEY idx_rust_events_wipe (server_id, wipe_id, id DESC),
|
||||
KEY idx_rust_events_created (created_at)
|
||||
);
|
||||
|
||||
|
||||
-- ── Who is on right now ───────────────────────────────────────────────────
|
||||
--
|
||||
-- Replaced wholesale every time the `players.online` board arrives, which is on
|
||||
-- every bridge connect and every 60 seconds. It is a BOARD, and the reason it is
|
||||
-- its own table rather than rows in `rust_events` is that a board answers "now"
|
||||
-- and an event answers "then"; storing a board as history is the mistake the
|
||||
-- wire's `type` field exists to prevent, and it would be a shame to make it here
|
||||
-- after the sidecar went to the trouble of not making it there.
|
||||
CREATE TABLE IF NOT EXISTS rust_presence (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
steam_id VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(191) NULL,
|
||||
sleeping TINYINT(1) NOT NULL DEFAULT 0,
|
||||
connected_at DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, steam_id)
|
||||
);
|
||||
|
||||
|
||||
-- ── The ingest cursor ─────────────────────────────────────────────────────
|
||||
--
|
||||
-- Where this module has read up to in each sidecar's feed. One row per server.
|
||||
--
|
||||
-- It is persisted rather than held in memory because the alternative is a module
|
||||
-- that re-reads everything on every boot or nothing at all, and both are wrong in
|
||||
-- a way that only shows up in production. The cursor advances **after** the batch
|
||||
-- is written, never before: a crash mid-batch re-reads rows it has not counted,
|
||||
-- which is the safe direction to be wrong in.
|
||||
--
|
||||
-- A NEW server starts at the sidecar's current end rather than at zero (see
|
||||
-- `GET /feed` with no `since`). A module installed today against a sidecar that
|
||||
-- has been running a month wants what happens next — replaying a fortnight of
|
||||
-- deaths into stats whose wipes it never saw is not a catch-up, it is a
|
||||
-- fabrication of history it was not present for.
|
||||
CREATE TABLE IF NOT EXISTS rust_ingest_cursor (
|
||||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
last_event_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
events_seen BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_cursor_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Changes to tables that already shipped ────────────────────────────────
|
||||
--
|
||||
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`
|
||||
-- does nothing against a database that already has the table, so an edited column
|
||||
-- would reach fresh installs only — which is the worst possible distribution for
|
||||
-- a schema change, because it works everywhere it is tested.
|
||||
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS wipe_id VARCHAR(48) NULL;
|
||||
|
||||
-- Phase 4. `updated_at` is when THIS module last wrote the row, which is not the
|
||||
-- same fact as when the server last said something — and the pages were reading
|
||||
-- the first as if it were the second, so a server that had been down for three
|
||||
-- days rendered "last reported just now" on every failed poll.
|
||||
--
|
||||
-- They are genuinely two facts and both are wanted: `updated_at` decides whether
|
||||
-- the row is stale (a module that stopped polling must not leave a page claiming
|
||||
-- a server is up), and `last_seen_at` is when a `server.hello` last arrived. Only
|
||||
-- a successful refresh moves it.
|
||||
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS last_seen_at DATETIME NULL;
|
||||
|
||||
242
server/ingest.js
Normal file
242
server/ingest.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// ── Reading a sidecar's feed, and turning it into a record ────────────────
|
||||
//
|
||||
// One job: move each server's cursor forward, and apply what it passed.
|
||||
//
|
||||
// ── Why a cursor and not a socket ─────────────────────────────────────────
|
||||
//
|
||||
// The obvious design is a WebSocket — the sidecar has one, and module-uo takes
|
||||
// exactly that route for the UO bridge. This module polls a cursor instead, and
|
||||
// the reason is not laziness about latency.
|
||||
//
|
||||
// Core runs on Node 20, where a global `WebSocket` is still behind a flag, so a
|
||||
// socket means taking `ws` as a runtime dependency — and this module's release
|
||||
// asserts that it has none (D5: everything it needs arrives on `ctx`, and the
|
||||
// bundle ships no `node_modules`). That is a cost worth paying for latency, but
|
||||
// the deciding argument is the other one: **a socket needs a cursor anyway.**
|
||||
// Whatever a feed misses while a module is restarting has to be caught up from
|
||||
// somewhere, and the catch-up path is the one that must be right. A socket on
|
||||
// top of a cursor is two mechanisms where the second is load-bearing; a cursor
|
||||
// alone is one mechanism that is exercised every few seconds rather than only
|
||||
// after an outage nobody planned.
|
||||
//
|
||||
// What it costs is seconds of latency on a killfeed. What it buys is that the
|
||||
// path which recovers from a five-hour outage is the same path that ran a moment
|
||||
// ago.
|
||||
//
|
||||
// ── The ordering the whole thing rests on ─────────────────────────────────
|
||||
//
|
||||
// **The cursor advances after the batch is written, never before.** A crash
|
||||
// between the two re-reads events already counted, which inflates a total; a
|
||||
// crash the other way round loses them silently and for ever. Neither is good and
|
||||
// they are not equally bad — one is visible and bounded, the other is invisible
|
||||
// and permanent — so the code is arranged to fail in the visible direction.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const db = require('./model/events/events.db')
|
||||
const sidecar = require('./sidecarClient')
|
||||
|
||||
const log = core.logger('ingest')
|
||||
|
||||
/** How many events to ask for at once. */
|
||||
const BATCH = 200
|
||||
|
||||
/**
|
||||
* How many batches one tick will drain before letting the loop breathe.
|
||||
*
|
||||
* A module that has been down for a day has thousands of events waiting, and
|
||||
* draining them in one unbounded loop would hold the tick — and a pool
|
||||
* connection — for as long as that takes. Bounded, it catches up over several
|
||||
* ticks and the site stays responsive while it does.
|
||||
*/
|
||||
const MAX_BATCHES_PER_TICK = 10
|
||||
|
||||
/**
|
||||
* Applies one feed item.
|
||||
*
|
||||
* Every frame is stored raw, and only some of them move a counter. That split is
|
||||
* deliberate: the raw row is what an admin reads and what a later phase can
|
||||
* re-derive from, and the counters are what a leaderboard sums. A kind this
|
||||
* build has never heard of still lands in `rust_events` — it costs nothing and
|
||||
* the alternative is losing the one copy of an event the next version will know
|
||||
* how to read.
|
||||
*/
|
||||
async function apply(serverId, item) {
|
||||
const frame = (item && item.frame) || {}
|
||||
const kind = item.kind || frame.kind
|
||||
const wipeId = frame.wipeId || null
|
||||
|
||||
// A wipe exists because something mentioned it. There is no "a wipe started"
|
||||
// call and there must not be one: the website is not there when a wipe happens.
|
||||
await db.touchWipe(serverId, wipeId, frame.saveCreatedAt || null)
|
||||
|
||||
await db.insertEvent({
|
||||
serverId,
|
||||
wipeId,
|
||||
kind,
|
||||
t: Number(frame.t) || item.t || Date.now(),
|
||||
steamId: frame.steamId || null,
|
||||
raw: frame,
|
||||
})
|
||||
|
||||
const at = { serverId, wipeId, steamId: frame.steamId }
|
||||
|
||||
switch (kind) {
|
||||
case 'player.connected':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
break
|
||||
|
||||
case 'player.disconnected': {
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
|
||||
// `sessionSec` is ABSENT when the plugin never saw the connect — a player
|
||||
// already on the server when it loaded. Absent is not zero: adding a zero
|
||||
// would be recording a session of no length, which is a different claim
|
||||
// from recording no session, and it is the one that quietly under-reports
|
||||
// playtime for ever.
|
||||
const seconds = Number(frame.sessionSec)
|
||||
await db.addStats(at, {
|
||||
sessions: Number.isFinite(seconds) ? 1 : 0,
|
||||
playtimeSec: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'player.death': {
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
|
||||
// A suicide is a death AND a suicide, not one instead of the other: the
|
||||
// deaths column is "how many times did this player die", and a leaderboard
|
||||
// that silently omitted self-inflicted ones would disagree with the
|
||||
// killfeed sitting next to it on the same page.
|
||||
await db.addStats(at, { deaths: 1, suicides: frame.attackerType === 'self' ? 1 : 0 })
|
||||
|
||||
// Only a real player's kill counts. `npc` and `environment` have no
|
||||
// attacker to credit, and `self` must not credit the victim with a kill —
|
||||
// which is the one line here that would look right in review and produce a
|
||||
// leaderboard topped by whoever died the most.
|
||||
if (frame.attackerType === 'player' && frame.attackerId) {
|
||||
await db.touchPlayer(frame.attackerId, frame.attackerName || null)
|
||||
await db.addStats({ ...at, steamId: frame.attackerId }, { kills: 1 })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'player.tally': {
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
await db.addStats(at, {
|
||||
npcKills: Number(frame.npcKills) || 0,
|
||||
structures: Number(frame.structures) || 0,
|
||||
})
|
||||
|
||||
// A tally is a DELTA since the last flush, which is what makes adding it
|
||||
// correct. If it ever becomes a running total this loop doubles every
|
||||
// number in it, slowly, and looks right the whole time.
|
||||
const gathered = frame.gathered || {}
|
||||
for (const [resource, amount] of Object.entries(gathered)) {
|
||||
await db.addGathered(at, resource, Number(amount) || 0)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'player.chat':
|
||||
case 'player.respawned':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
break
|
||||
|
||||
default:
|
||||
// Stored, not counted. Moderation frames, the server lifecycle, and
|
||||
// anything a newer protocol sends that this build does not understand.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings one server's cursor up to date.
|
||||
*
|
||||
* Returns the number of events applied, for the log and for the tests.
|
||||
*/
|
||||
async function ingestServer(server) {
|
||||
const cursor = await db.getCursor(server.id)
|
||||
|
||||
// A server this module has never ingested starts at the sidecar's CURRENT end,
|
||||
// not at zero. A module installed today against a sidecar that has been running
|
||||
// for a month should read what happens next — replaying a fortnight of deaths
|
||||
// into stats for wipes it never saw is not a catch-up, it is inventing a
|
||||
// history it was not present for. `/feed` with no `since` asks exactly that
|
||||
// question, which is why the sidecar answers it that way.
|
||||
if (!cursor) {
|
||||
const tail = await sidecar.feedTail(server)
|
||||
|
||||
if (!tail.ok || !tail.data) {
|
||||
// Unreachable. Write nothing: a cursor of 0 written now would replay the
|
||||
// whole retained history the moment the sidecar came back.
|
||||
return 0
|
||||
}
|
||||
|
||||
await db.setCursor(server.id, Number(tail.data.lastId) || 0, 0)
|
||||
log.info('cursor started at the feed tail', { server: server.id, at: tail.data.lastId })
|
||||
return 0
|
||||
}
|
||||
|
||||
let since = Number(cursor.lastEventId) || 0
|
||||
let applied = 0
|
||||
|
||||
for (let batch = 0; batch < MAX_BATCHES_PER_TICK; batch += 1) {
|
||||
const res = await sidecar.feed(server, since, BATCH)
|
||||
|
||||
if (!res.ok || !res.data) return applied
|
||||
|
||||
const items = Array.isArray(res.data.items) ? res.data.items : []
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
await apply(server.id, item)
|
||||
applied += 1
|
||||
} catch (err) {
|
||||
// One malformed event must not wedge a server's cursor for ever. It is
|
||||
// logged with its id so it can be found, and the cursor moves past it:
|
||||
// the alternative is an ingest that stops at a single bad row and then
|
||||
// silently stops being a feed at all.
|
||||
log.warn('could not apply an event', {
|
||||
server: server.id,
|
||||
id: item && item.id,
|
||||
kind: item && item.kind,
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const lastId = Number(res.data.lastId)
|
||||
|
||||
if (Number.isFinite(lastId) && lastId > since) {
|
||||
// AFTER the batch. See the header.
|
||||
await db.setCursor(server.id, lastId, items.length)
|
||||
since = lastId
|
||||
}
|
||||
|
||||
if (!res.data.more) break
|
||||
}
|
||||
|
||||
if (applied > 0) log.info('ingested', { server: server.id, events: applied, cursor: since })
|
||||
|
||||
return applied
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the boards: what is true right now, rather than what happened.
|
||||
*
|
||||
* `players.online` replaces the presence rows wholesale, because that is what a
|
||||
* board is. Storing it as history is the mistake the wire's `type` field exists
|
||||
* to prevent, and it would be a poor return for the sidecar's trouble to make it
|
||||
* here after it went out of its way not to make it there.
|
||||
*/
|
||||
async function applyBoards(serverId, boards) {
|
||||
const presence = boards && boards['players.online']
|
||||
|
||||
if (presence && Array.isArray(presence.players)) {
|
||||
await db.replacePresence(serverId, presence.players)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { apply, applyBoards, ingestServer, BATCH, MAX_BATCHES_PER_TICK }
|
||||
289
server/model/events/events.db.js
Normal file
289
server/model/events/events.db.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// ── SQL for the read path ─────────────────────────────────────────────────
|
||||
//
|
||||
// Writes come from one caller (`server/ingest.js`) and reads from the routers.
|
||||
// They live together because they are the same tables and the invariants are
|
||||
// easier to keep true when the UPDATE and the SELECT are on the same screen.
|
||||
//
|
||||
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always —
|
||||
// except for one place where a list of kinds is expanded into placeholders, and
|
||||
// that expansion is checked in `events.model.js` before it ever reaches here.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const EVENTS = 'rust_events'
|
||||
const STATS = 'rust_player_wipe_stats'
|
||||
const GATHER = 'rust_gather_totals'
|
||||
const PLAYERS = 'rust_players'
|
||||
const WIPES = 'rust_wipes'
|
||||
const PRESENCE = 'rust_presence'
|
||||
const CURSOR = 'rust_ingest_cursor'
|
||||
|
||||
// ── The cursor ────────────────────────────────────────────────────────────
|
||||
|
||||
async function getCursor(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT server_id AS serverId, last_event_id AS lastEventId, events_seen AS eventsSeen
|
||||
FROM ${CURSOR} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a server's cursor forward, counting what it passed.
|
||||
*
|
||||
* **Called only after the batch it describes has been written.** The whole
|
||||
* correctness of the ingest is in that ordering: if this ran first, a crash
|
||||
* between the two would skip events for ever, silently, with no way to notice.
|
||||
* Running it last means a crash re-reads events it has already counted at worst
|
||||
* — see `ingest.js` for what makes that survivable.
|
||||
*/
|
||||
async function setCursor(serverId, lastEventId, seen = 0) {
|
||||
await core.query(
|
||||
`INSERT INTO ${CURSOR} (server_id, last_event_id, events_seen, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_event_id = VALUES(last_event_id),
|
||||
events_seen = events_seen + VALUES(events_seen),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[serverId, lastEventId, seen],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Writes ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function insertEvent({ serverId, wipeId, kind, t, steamId, raw }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${EVENTS} (server_id, wipe_id, kind, t, steam_id, raw)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[serverId, wipeId || null, kind, t, steamId || null, JSON.stringify(raw)],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes that a wipe exists, from any frame that mentions it.
|
||||
*
|
||||
* There is no "a wipe started" call, because the website is not there when one
|
||||
* does — a wipe happens to a game server that was restarted while nobody was
|
||||
* watching. A wipe is therefore created by being mentioned, and `last_seen`
|
||||
* moves every time it is mentioned again.
|
||||
*/
|
||||
async function touchWipe(serverId, wipeId, saveCreatedAt = null) {
|
||||
if (!wipeId) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${WIPES} (server_id, wipe_id, save_created_at, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
save_created_at = COALESCE(VALUES(save_created_at), save_created_at)`,
|
||||
[serverId, wipeId, saveCreatedAt],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes that a player exists and what they were last called.
|
||||
*
|
||||
* `name` is COALESCEd rather than overwritten so that a frame which carries no
|
||||
* name — a ban by id, a tally — cannot blank out the name every other frame
|
||||
* supplied.
|
||||
*/
|
||||
async function touchPlayer(steamId, name = null) {
|
||||
if (!steamId) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = COALESCE(VALUES(name), name),
|
||||
last_seen = CURRENT_TIMESTAMP`,
|
||||
[steamId, name],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds to one player's counters for one wipe.
|
||||
*
|
||||
* Every column is a running total that only rises within a wipe, so this is an
|
||||
* upsert that ADDS rather than sets. `deltas` names only what moved; a `+ 0` on
|
||||
* everything else is what keeps the caller from having to read the row first.
|
||||
*/
|
||||
async function addStats({ serverId, wipeId, steamId }, deltas = {}) {
|
||||
if (!serverId || !steamId) return
|
||||
|
||||
const cols = ['kills', 'deaths', 'suicides', 'npc_kills', 'structures', 'sessions', 'playtime_sec']
|
||||
const values = {
|
||||
kills: deltas.kills || 0,
|
||||
deaths: deltas.deaths || 0,
|
||||
suicides: deltas.suicides || 0,
|
||||
npc_kills: deltas.npcKills || 0,
|
||||
structures: deltas.structures || 0,
|
||||
sessions: deltas.sessions || 0,
|
||||
playtime_sec: deltas.playtimeSec || 0,
|
||||
}
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${STATS} (server_id, wipe_id, steam_id, ${cols.join(', ')}, last_seen)
|
||||
VALUES (?, ?, ?, ${cols.map(() => '?').join(', ')}, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
${cols.map((c) => `${c} = ${c} + VALUES(${c})`).join(',\n ')},
|
||||
last_seen = CURRENT_TIMESTAMP`,
|
||||
[serverId, wipeId || '', steamId, ...cols.map((c) => values[c])],
|
||||
)
|
||||
}
|
||||
|
||||
async function addGathered({ serverId, wipeId, steamId }, resource, amount) {
|
||||
if (!serverId || !steamId || !resource || !(amount > 0)) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${GATHER} (server_id, wipe_id, steam_id, resource, amount)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE amount = amount + VALUES(amount)`,
|
||||
[serverId, wipeId || '', steamId, resource, amount],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a server's presence rows with exactly what the board said.
|
||||
*
|
||||
* Two statements, delete then insert, because a board is a REPLACEMENT: a player
|
||||
* who left between two boards has to disappear, and an upsert alone would leave
|
||||
* them online for ever. It is not wrapped in a transaction on purpose — the
|
||||
* window between the two is a fraction of a second of a page possibly showing an
|
||||
* empty player list, against holding a lock on a table two routes read.
|
||||
*/
|
||||
async function replacePresence(serverId, players = []) {
|
||||
await core.query(`DELETE FROM ${PRESENCE} WHERE server_id = ?`, [serverId])
|
||||
|
||||
for (const p of players) {
|
||||
if (!p || !p.steamId) continue
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${PRESENCE} (server_id, steam_id, name, sleeping, connected_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ${p.connectedAt ? 'FROM_UNIXTIME(? / 1000)' : 'NULL'}, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name), sleeping = VALUES(sleeping), updated_at = CURRENT_TIMESTAMP`,
|
||||
p.connectedAt
|
||||
? [serverId, p.steamId, p.name || null, p.sleeping ? 1 : 0, p.connectedAt]
|
||||
: [serverId, p.steamId, p.name || null, p.sleeping ? 1 : 0],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes raw events older than `days`. Totals are never touched — that is the point of them. */
|
||||
async function pruneEvents(days) {
|
||||
if (!(days > 0)) return 0
|
||||
|
||||
const res = await core.query(
|
||||
`DELETE FROM ${EVENTS} WHERE created_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? DAY)`,
|
||||
[days],
|
||||
)
|
||||
return (res && res.affectedRows) || 0
|
||||
}
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recent events, newest first, restricted to `kinds`.
|
||||
*
|
||||
* **`kinds` is never optional.** A default of "all kinds" is one forgotten
|
||||
* argument away from publishing an IP address, so the caller is made to say it
|
||||
* every time; `events.model.js` builds the list from the catalogue's allowlist
|
||||
* and an empty list answers with no rows rather than with everything.
|
||||
*/
|
||||
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
|
||||
if (!Array.isArray(kinds) || kinds.length === 0) return []
|
||||
|
||||
const holes = kinds.map(() => '?').join(', ')
|
||||
const params = [serverId, ...kinds]
|
||||
|
||||
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw
|
||||
FROM ${EVENTS}
|
||||
WHERE server_id = ? AND kind IN (${holes})`
|
||||
|
||||
if (wipeId) {
|
||||
sql += ' AND wipe_id = ?'
|
||||
params.push(wipeId)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY id DESC LIMIT ?'
|
||||
params.push(limit)
|
||||
|
||||
return core.query(sql, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* The leaderboard for one wipe, or across every wipe when `wipeId` is null.
|
||||
*
|
||||
* All-time is a SUM over the per-wipe rows rather than a separate set of
|
||||
* counters, which is what makes it impossible for the two to disagree — there
|
||||
* is only ever one number, added up differently.
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 }) {
|
||||
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
|
||||
|
||||
const params = [serverId]
|
||||
let where = 's.server_id = ?'
|
||||
|
||||
if (wipeId) {
|
||||
where += ' AND s.wipe_id = ?'
|
||||
params.push(wipeId)
|
||||
}
|
||||
|
||||
params.push(limit)
|
||||
|
||||
return core.query(
|
||||
`SELECT s.steam_id AS steamId,
|
||||
p.name AS name,
|
||||
SUM(s.kills) AS kills,
|
||||
SUM(s.deaths) AS deaths,
|
||||
SUM(s.npc_kills) AS npcKills,
|
||||
SUM(s.structures) AS structures,
|
||||
SUM(s.playtime_sec) AS playtimeSec,
|
||||
MAX(s.last_seen) AS lastSeen
|
||||
FROM ${STATS} s
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = s.steam_id
|
||||
WHERE ${where}
|
||||
GROUP BY s.steam_id, p.name
|
||||
ORDER BY SUM(s.${column}) DESC, MAX(s.last_seen) DESC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
async function listWipes(serverId) {
|
||||
return core.query(
|
||||
`SELECT wipe_id AS wipeId, save_created_at AS saveCreatedAt,
|
||||
first_seen AS firstSeen, last_seen AS lastSeen
|
||||
FROM ${WIPES}
|
||||
WHERE server_id = ?
|
||||
ORDER BY wipe_id DESC`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
async function presenceFor(serverId) {
|
||||
return core.query(
|
||||
`SELECT steam_id AS steamId, name, sleeping, connected_at AS connectedAt
|
||||
FROM ${PRESENCE}
|
||||
WHERE server_id = ?
|
||||
ORDER BY name ASC`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCursor,
|
||||
setCursor,
|
||||
insertEvent,
|
||||
touchWipe,
|
||||
touchPlayer,
|
||||
addStats,
|
||||
addGathered,
|
||||
replacePresence,
|
||||
pruneEvents,
|
||||
recentEvents,
|
||||
leaderboard,
|
||||
listWipes,
|
||||
presenceFor,
|
||||
}
|
||||
162
server/model/events/events.model.js
Normal file
162
server/model/events/events.model.js
Normal file
@@ -0,0 +1,162 @@
|
||||
// ── The read path's logic ─────────────────────────────────────────────────
|
||||
//
|
||||
// Everything that decides WHAT a caller gets, separated from the SQL that
|
||||
// fetches it, so this file can be tested with no database and `events.db.js` has
|
||||
// no branching to test.
|
||||
//
|
||||
// The decision that matters here is not a business rule, it is a boundary: what
|
||||
// a signed-out visitor may see. Protocol 2 carries IP addresses and player
|
||||
// reports, and the only thing standing between them and a public page is
|
||||
// `catalogue.js`'s allowlist and the fact that **every read on this file takes an
|
||||
// explicit viewer**. There is no default, because a default is what a caller
|
||||
// gets when they forget — and the safe value is never the one that is easier to
|
||||
// type.
|
||||
|
||||
const catalogue = require('../../catalogue')
|
||||
const db = require('./events.db')
|
||||
|
||||
/** Hard ceiling on a page, whatever a caller asks for. */
|
||||
const MAX_LIMIT = 200
|
||||
|
||||
function boundedLimit(requested, fallback = 50) {
|
||||
const n = Number(requested)
|
||||
if (!Number.isFinite(n) || n <= 0) return fallback
|
||||
return Math.min(Math.trunc(n), MAX_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a `kind` query parameter into a list.
|
||||
*
|
||||
* Accepts `?kind=player.death` and `?kind=player.death,player.chat`, and answers
|
||||
* `null` for anything empty — which means "whatever this viewer may see" rather
|
||||
* than "nothing", and is then narrowed by the catalogue.
|
||||
*/
|
||||
function parseKinds(raw) {
|
||||
if (!raw) return null
|
||||
|
||||
const list = String(raw)
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return list.length > 0 ? list : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent events for one server, already narrowed to what this viewer may see.
|
||||
*
|
||||
* **`admin` is a parameter, not a default.** A route that forgets it gets the
|
||||
* public list, which is the direction it is safe to be wrong in. And a kind the
|
||||
* caller asked for that they may not see is dropped silently rather than
|
||||
* refused: naming it in an error would confirm the kind exists, which is a small
|
||||
* thing to leak and a free one to avoid.
|
||||
*/
|
||||
async function recent({ serverId, admin = false, kind = null, wipeId = null, limit }) {
|
||||
const kinds = catalogue.kindsFor({ admin, requested: parseKinds(kind) })
|
||||
|
||||
// Every requested kind was refused. Answering with an empty list is right —
|
||||
// the events they asked for are, as far as they are concerned, not there.
|
||||
if (kinds.length === 0) return []
|
||||
|
||||
const rows = await db.recentEvents({
|
||||
serverId,
|
||||
kinds,
|
||||
wipeId,
|
||||
limit: boundedLimit(limit),
|
||||
})
|
||||
|
||||
return rows.map(shape)
|
||||
}
|
||||
|
||||
/**
|
||||
* One stored row as an API object.
|
||||
*
|
||||
* `raw` comes back from the database as text and is parsed here rather than in
|
||||
* the db layer, because a row whose JSON will not parse is a reporting problem
|
||||
* and not a query problem: it answers with the envelope it does know and an
|
||||
* empty body, instead of failing a whole page over one bad row.
|
||||
*/
|
||||
function shape(row) {
|
||||
let frame = {}
|
||||
|
||||
try {
|
||||
frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw || {}
|
||||
} catch {
|
||||
frame = {}
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id),
|
||||
kind: row.kind,
|
||||
t: Number(row.t),
|
||||
wipeId: row.wipeId || null,
|
||||
steamId: row.steamId || null,
|
||||
frame,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The leaderboard for a server, per wipe or all-time.
|
||||
*
|
||||
* All-time is the same rows summed differently rather than a second set of
|
||||
* counters, so the two can never disagree — which is the whole reason R12's
|
||||
* "per-wipe detail plus all-time rollups" is one table and not two.
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit }) {
|
||||
const rows = await db.leaderboard({
|
||||
serverId,
|
||||
wipeId,
|
||||
sort,
|
||||
limit: boundedLimit(limit, 25),
|
||||
})
|
||||
|
||||
return rows.map((r) => ({
|
||||
steamId: r.steamId,
|
||||
name: r.name || null,
|
||||
kills: Number(r.kills) || 0,
|
||||
deaths: Number(r.deaths) || 0,
|
||||
npcKills: Number(r.npcKills) || 0,
|
||||
structures: Number(r.structures) || 0,
|
||||
playtimeSec: Number(r.playtimeSec) || 0,
|
||||
lastSeen: r.lastSeen || null,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wipe this server has had, newest first.
|
||||
*
|
||||
* The list is what makes the per-wipe view navigable, and it is also the proof
|
||||
* R12 asks for: a wipe that ended is still here, with its stats still attached.
|
||||
*/
|
||||
async function wipes(serverId) {
|
||||
const rows = await db.listWipes(serverId)
|
||||
|
||||
return rows.map((r) => ({
|
||||
wipeId: r.wipeId,
|
||||
saveCreatedAt: r.saveCreatedAt || null,
|
||||
firstSeen: r.firstSeen,
|
||||
lastSeen: r.lastSeen,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is on the server right now.
|
||||
*
|
||||
* Read from the presence board rather than counted from connect and disconnect
|
||||
* events: the board is re-sent on every bridge connect and every minute, so it
|
||||
* is right even after this module has missed something. Counting transitions
|
||||
* instead would drift, and drift in exactly the direction people notice —
|
||||
* players who never left.
|
||||
*/
|
||||
async function online(serverId) {
|
||||
const rows = await db.presenceFor(serverId)
|
||||
|
||||
return rows.map((r) => ({
|
||||
steamId: r.steamId,
|
||||
name: r.name || null,
|
||||
sleeping: Boolean(r.sleeping),
|
||||
connectedAt: r.connectedAt || null,
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, MAX_LIMIT }
|
||||
@@ -79,11 +79,57 @@ async function listState() {
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
|
||||
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
|
||||
save_created_at AS saveCreatedAt, protocol, updated_at AS updatedAt
|
||||
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
|
||||
last_seen_at AS lastSeenAt, updated_at AS updatedAt
|
||||
FROM ${STATE}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** One server's observed state, or `null`. The single-row twin of `listState`. */
|
||||
async function getState(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
|
||||
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
|
||||
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
|
||||
last_seen_at AS lastSeenAt, updated_at AS updatedAt
|
||||
FROM ${STATE}
|
||||
WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a server unreachable **without forgetting what it last said**.
|
||||
*
|
||||
* `putState` replaces the row whole, which is right when a sidecar answered: the
|
||||
* frame it answered with is the complete truth about that server. It is wrong
|
||||
* when nothing answered. A refresh that cannot reach a sidecar knows exactly one
|
||||
* new fact — that it could not reach it — and writing the whole row from that
|
||||
* one fact sets `hostname`, `level`, `seed`, `world_size` and `wipe_id` to NULL.
|
||||
*
|
||||
* The site's whole premise is that it renders the last thing each server said
|
||||
* while every server is off. A row blanked the first time a game host reboots
|
||||
* cannot do that: the page loses the map, the size, the seed and the wipe, and
|
||||
* what it shows is not "offline, here is what we know" but "offline, and we have
|
||||
* never heard of it". It is invisible in every test that stubs a reachable
|
||||
* sidecar, and it shows up as a page that was complete an hour ago.
|
||||
*
|
||||
* So: three columns move, and the description stays where it is.
|
||||
*/
|
||||
async function markUnreachable(serverId, reachable = false) {
|
||||
await core.query(
|
||||
`INSERT INTO ${STATE} (server_id, reachable, online, players, updated_at)
|
||||
VALUES (?, ?, 0, 0, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
reachable = VALUES(reachable),
|
||||
online = 0,
|
||||
players = 0,
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[serverId, reachable ? 1 : 0],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one server's observed state.
|
||||
*
|
||||
@@ -99,14 +145,20 @@ async function putState(state) {
|
||||
await core.query(
|
||||
`INSERT INTO ${STATE}
|
||||
(server_id, reachable, online, players, max_players, hostname, level, seed,
|
||||
world_size, boot_id, save_created_at, protocol, raw, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
world_size, boot_id, save_created_at, wipe_id, protocol, raw, last_seen_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players),
|
||||
max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level),
|
||||
seed = VALUES(seed), world_size = VALUES(world_size), boot_id = VALUES(boot_id),
|
||||
save_created_at = VALUES(save_created_at), protocol = VALUES(protocol),
|
||||
raw = VALUES(raw), updated_at = CURRENT_TIMESTAMP`,
|
||||
save_created_at = VALUES(save_created_at), wipe_id = VALUES(wipe_id),
|
||||
protocol = VALUES(protocol),
|
||||
raw = VALUES(raw),
|
||||
-- Only a frame moves this; an unreachable write leaves it alone, which is
|
||||
-- what lets a page say how long a server has been down rather than how
|
||||
-- recently we failed to reach it.
|
||||
last_seen_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
state.serverId,
|
||||
state.reachable ? 1 : 0,
|
||||
@@ -119,6 +171,7 @@ async function putState(state) {
|
||||
state.worldSize === undefined ? null : state.worldSize,
|
||||
state.bootId || null,
|
||||
state.saveCreatedAt || null,
|
||||
state.wipeId || null,
|
||||
state.protocol === undefined ? null : state.protocol,
|
||||
state.raw ? JSON.stringify(state.raw) : null,
|
||||
],
|
||||
@@ -133,5 +186,7 @@ module.exports = {
|
||||
upsertServer,
|
||||
deleteServer,
|
||||
listState,
|
||||
getState,
|
||||
markUnreachable,
|
||||
putState,
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ async function listPublic(now = Date.now()) {
|
||||
|
||||
function shapePublic(row, state, now) {
|
||||
const updatedAt = state && state.updatedAt ? new Date(state.updatedAt) : null
|
||||
const lastSeenAt = state && state.lastSeenAt ? new Date(state.lastSeenAt) : null
|
||||
const stale = !updatedAt || now - updatedAt.getTime() > STALE_AFTER_MS
|
||||
|
||||
return {
|
||||
@@ -87,11 +88,43 @@ function shapePublic(row, state, now) {
|
||||
level: (state && state.level) || null,
|
||||
worldSize: state && state.worldSize != null ? Number(state.worldSize) : null,
|
||||
seed: state && state.seed != null ? Number(state.seed) : null,
|
||||
// The CURRENT wipe, from the state row rather than from the newest row in
|
||||
// `rust_wipes`. The two usually agree and the state row is the one that is
|
||||
// right when they do not: a wipe list is derived from events that have been
|
||||
// ingested, so a server that has just wiped and said nothing since has a new
|
||||
// wipe id here and no row there at all.
|
||||
wipeId: (state && state.wipeId) || null,
|
||||
wipedAt: (state && state.saveCreatedAt) || null,
|
||||
// Two timestamps, because they are two facts. `lastSeenAt` is when a frame
|
||||
// last arrived and is what a page means by "last reported"; `updatedAt` is
|
||||
// when this module last wrote the row, and is what `stale` is computed from.
|
||||
// Reading the second as the first is what made an offline server claim it had
|
||||
// reported just now, on every failed poll, for as long as it stayed down.
|
||||
lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null,
|
||||
updatedAt: updatedAt ? updatedAt.toISOString() : null,
|
||||
stale,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One enabled server, or `null`.
|
||||
*
|
||||
* It exists because `/rust/servers/:id` is a page and a page needs to be able to
|
||||
* 404. A detail view built by fetching the list and finding the row in it cannot
|
||||
* tell "no such server" from "a server that has said nothing" — both are an
|
||||
* absence — and renders an empty page under a heading for a server that does not
|
||||
* exist. Filtering happens here, where `enabled = 0` and "never configured" are
|
||||
* the same answer on purpose: a disabled server is not a 403, it is not there.
|
||||
*/
|
||||
async function getPublic(id, now = Date.now()) {
|
||||
if (!id) return null
|
||||
|
||||
const row = await db.getServer(id)
|
||||
if (!row || !row.enabled) return null
|
||||
|
||||
return shapePublic(row, await db.getState(row.id), now)
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin view: configuration plus reachability, and **no token**.
|
||||
*
|
||||
@@ -133,6 +166,7 @@ module.exports = {
|
||||
withToken,
|
||||
listForPolling,
|
||||
listPublic,
|
||||
getPublic,
|
||||
listForAdmin,
|
||||
shapePublic,
|
||||
encryptToken,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"check:imports": "node scripts/checkImports.js",
|
||||
"check:bundle": "node scripts/checkBundle.js",
|
||||
"swagger": "node scripts/swaggerFragment.js",
|
||||
"check:swagger": "node scripts/swaggerFragment.js --check"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const events = require('../../model/events/events.model')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
|
||||
const log = core.logger('public')
|
||||
@@ -24,4 +25,86 @@ async function listServers(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers }
|
||||
/**
|
||||
* One server, or a 404.
|
||||
*
|
||||
* **The 404 is the feature.** Everything else under `/servers/:id` answers an
|
||||
* empty list for a server that does not exist — an unknown id has no events, no
|
||||
* leaderboard and nobody online, and each of those is a perfectly good answer to
|
||||
* the question it was asked. Only this route can tell the page that the server
|
||||
* itself is not there, which is what stops `/rust/servers/typo` rendering as a
|
||||
* quiet server with nothing to say.
|
||||
*/
|
||||
async function getServer(req, res) {
|
||||
try {
|
||||
const server = await servers.getPublic(req.params.id)
|
||||
if (!server) {
|
||||
res.status(404).json({ error: 'No such server' })
|
||||
return
|
||||
}
|
||||
res.json({ server })
|
||||
} catch (err) {
|
||||
log.error('failed to read a server', { server: req.params.id, error: err.message })
|
||||
res.status(500).json({ error: 'Failed to read the server' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The killfeed, and everything else public that happened on one server.
|
||||
*
|
||||
* **`admin` is not passed, and that is the whole security posture of this
|
||||
* handler.** `events.recent` takes the viewer explicitly and defaults to the
|
||||
* public allowlist, so the way to leak an IP address from here is to add an
|
||||
* argument rather than to forget one.
|
||||
*/
|
||||
async function listEvents(req, res) {
|
||||
try {
|
||||
res.json({
|
||||
events: await events.recent({
|
||||
serverId: req.params.id,
|
||||
kind: req.query.kind,
|
||||
wipeId: req.query.wipe || null,
|
||||
limit: req.query.limit,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to read events', { server: req.params.id, error: err.message })
|
||||
res.status(500).json({ error: 'Failed to read events' })
|
||||
}
|
||||
}
|
||||
|
||||
async function listLeaderboard(req, res) {
|
||||
try {
|
||||
res.json({
|
||||
leaderboard: await events.leaderboard({
|
||||
serverId: req.params.id,
|
||||
wipeId: req.query.wipe || null,
|
||||
sort: req.query.sort,
|
||||
limit: req.query.limit,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })
|
||||
res.status(500).json({ error: 'Failed to read the leaderboard' })
|
||||
}
|
||||
}
|
||||
|
||||
async function listWipes(req, res) {
|
||||
try {
|
||||
res.json({ wipes: await events.wipes(req.params.id) })
|
||||
} catch (err) {
|
||||
log.error('failed to read wipes', { server: req.params.id, error: err.message })
|
||||
res.status(500).json({ error: 'Failed to read wipes' })
|
||||
}
|
||||
}
|
||||
|
||||
async function listOnline(req, res) {
|
||||
try {
|
||||
res.json({ players: await events.online(req.params.id) })
|
||||
} catch (err) {
|
||||
log.error('failed to read presence', { server: req.params.id, error: err.message })
|
||||
res.status(500).json({ error: 'Failed to read who is online' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers, getServer, listEvents, listLeaderboard, listWipes, listOnline }
|
||||
|
||||
@@ -41,4 +41,77 @@ rustRouter.get(
|
||||
servers.listServers,
|
||||
)
|
||||
|
||||
// ── One server's read path ────────────────────────────────────────────────
|
||||
//
|
||||
// Every route below is public, and every one of them answers from this module's
|
||||
// own tables — never from a live call to a sidecar. That is what lets the
|
||||
// killfeed and the leaderboard render while every game server in the fleet is
|
||||
// off, which is the same promise the server list makes.
|
||||
//
|
||||
// **The events route serves an ALLOWLIST, default-deny** (`catalogue.js`).
|
||||
// Protocol 2 carries IP addresses and player reports; they are stored, and they
|
||||
// do not come out here.
|
||||
|
||||
rustRouter.get(
|
||||
'/servers/:id',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'One Rust server'
|
||||
// #swagger.description = 'The same shape the list answers with, for one server, and a `404` when there is no such server or an operator has disabled it. The detail page needs the difference: every other route under this path answers an empty list for an id that does not exist, because an unknown server genuinely has no events and nobody online.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'The server' } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
|
||||
siteMode,
|
||||
servers.getServer,
|
||||
)
|
||||
|
||||
rustRouter.get(
|
||||
'/servers/:id/events',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'Recent events on one Rust server'
|
||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'Recent events, newest first' } */
|
||||
siteMode,
|
||||
servers.listEvents,
|
||||
)
|
||||
|
||||
rustRouter.get(
|
||||
'/servers/:id/leaderboard',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'The leaderboard for one Rust server'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills or playtime', schema: { type: 'string' } }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The leaderboard' } */
|
||||
siteMode,
|
||||
servers.listLeaderboard,
|
||||
)
|
||||
|
||||
rustRouter.get(
|
||||
'/servers/:id/wipes',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'Every wipe this server has had'
|
||||
// #swagger.description = 'Newest first. A wipe id is derived by the bridge plugin from the save’s creation time and stamped on every frame, so it is the same id the events and the leaderboard are filtered by.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'The wipes' } */
|
||||
siteMode,
|
||||
servers.listWipes,
|
||||
)
|
||||
|
||||
rustRouter.get(
|
||||
'/servers/:id/online',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'Who is on one Rust server right now'
|
||||
// #swagger.description = 'Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'Who is online' } */
|
||||
siteMode,
|
||||
servers.listOnline,
|
||||
)
|
||||
|
||||
module.exports = rustRouter
|
||||
|
||||
301
server/scripts/checkBundle.js
Normal file
301
server/scripts/checkBundle.js
Normal file
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env node
|
||||
// ── Does the release actually ship everything the module needs? ────────────
|
||||
//
|
||||
// `ci/bundle.json` says what a release copies. `server/index.js` says what the
|
||||
// module requires. Nothing keeps two lists in agreement on its own, and the first
|
||||
// module this project shipped proved it: `Module-uo` added `server/commands/` in a
|
||||
// cutover, its include list did not learn about it, and v1.0.0 installed cleanly
|
||||
// and then died on the operator's box with
|
||||
//
|
||||
// module "uo" failed to load — {"stage":"register","reason":"Cannot find
|
||||
// module './commands/guild.command'"}
|
||||
//
|
||||
// Nothing caught it, because the PR checks install the module by copying the
|
||||
// WHOLE repo into core — they only ever exercised a tree that had the file. **The
|
||||
// subset only exists in the release**, and the release had no check that the
|
||||
// subset was complete. This module has that check from its first release rather
|
||||
// than after its first outage.
|
||||
//
|
||||
// It asks the question in the two places it can be asked:
|
||||
//
|
||||
// --check (PR checks) Every file reachable from the entry point by a
|
||||
// relative require lives under something ci/bundle.json
|
||||
// lists. Source-tree only, so it is fast and needs no
|
||||
// assembled bundle — it fails on the PR that adds the
|
||||
// directory, which is where the fix is cheapest.
|
||||
//
|
||||
// --bundle <dir> (release) Every relative specifier inside an ASSEMBLED bundle
|
||||
// resolves to a file that is in it. Asked of the
|
||||
// artifact rather than of the source, so it also
|
||||
// catches a copy that half-failed, a list that names a
|
||||
// path that has moved, and anything else between the
|
||||
// declaration and the tarball.
|
||||
//
|
||||
// The two are deliberately not the same question. The first is about the list
|
||||
// being right; the second is about the tarball being right. A release runs both.
|
||||
//
|
||||
// ── The third question, which is this module's own ─────────────────────────
|
||||
//
|
||||
// `server/package.json` declares **no runtime dependencies**, and the release
|
||||
// therefore runs no `npm ci` and packs no `node_modules`. That is a decision, not
|
||||
// an accident (org lead, phase 2), and the whole value of it is that the day it
|
||||
// stops being true is a loud day. So both modes also assert the declaration is
|
||||
// still empty: add a `dependencies` entry without teaching release.yml to install
|
||||
// and pack it, and the bundle ships an import of something that is not there —
|
||||
// the missing-directory failure again, wearing a different hat.
|
||||
//
|
||||
// ── Why reachability, and not "require the entry point" ────────────────────
|
||||
//
|
||||
// The obvious check — require the bundle's entry and see if it throws — does not
|
||||
// work here, and the reason is in index.js's own header: its requires are inside
|
||||
// `register()` because require order is load-bearing (`core.init(ctx)` has to run
|
||||
// before anything under `router/` is required). So requiring the entry evaluates
|
||||
// exactly one line, `require('./core')`, and reports success on a bundle missing
|
||||
// every router it has. Calling `register()` for real would need a fake `ctx`
|
||||
// complete enough to satisfy the whole module — which is what `test/` is for, and
|
||||
// `test/` does not ship. Walking the requires statically asks the same question
|
||||
// without needing either.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { stripCommentsAndTemplates } = require('./checkImports')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
|
||||
// Only relative specifiers. A bare one is checkImports.js's question, not this
|
||||
// one, and the two failures want different advice.
|
||||
const RELATIVE = /(?:require\(|from\s+|import\()\s*['"](\.[^'"]+)['"]/g
|
||||
|
||||
/**
|
||||
* Resolve a relative specifier the way Node would, for the file cases that can
|
||||
* appear here: an exact path, `+.js`/`+.json`, or a directory's `index.js`.
|
||||
*
|
||||
* Returns null when nothing exists — which is the finding, not an error.
|
||||
*/
|
||||
function resolveFile(fromDir, specifier) {
|
||||
const base = path.resolve(fromDir, specifier)
|
||||
const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')]
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file reachable from `entry` by following relative requires, plus every
|
||||
* specifier that resolved to nothing.
|
||||
*
|
||||
* Exported so the test can point it at fixtures — the same reason checkImports.js
|
||||
* exports `scan`. A check that has never been shown to fail is a check nobody
|
||||
* knows the state of, and this one is load-bearing for every release.
|
||||
*/
|
||||
function reachable(entry) {
|
||||
const seen = new Set()
|
||||
const missing = []
|
||||
const queue = [entry]
|
||||
|
||||
while (queue.length) {
|
||||
const file = queue.shift()
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
|
||||
// A .json dependency is a leaf: it is reached, it ships, and it has no
|
||||
// requires of its own to follow.
|
||||
if (file.endsWith('.json')) continue
|
||||
|
||||
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
||||
for (const [, specifier] of source.matchAll(RELATIVE)) {
|
||||
const target = resolveFile(path.dirname(file), specifier)
|
||||
if (target) queue.push(target)
|
||||
else missing.push({ file, specifier })
|
||||
}
|
||||
}
|
||||
|
||||
return { files: [...seen], missing }
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything ci/bundle.json says ends up in the bundle, as absolute paths:
|
||||
* `server[]` relative to server/, `root[]` and `generated[]` relative to the
|
||||
* module root. All three are equally "in the tarball" as far as a require is
|
||||
* concerned — the only difference is how they get there.
|
||||
*/
|
||||
function declaredServerPaths(moduleRoot = MODULE_ROOT) {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(moduleRoot, 'ci', 'bundle.json'), 'utf8'))
|
||||
return [
|
||||
...manifest.server.map((p) => path.join(moduleRoot, 'server', p)),
|
||||
...(manifest.root || []).map((p) => path.join(moduleRoot, p)),
|
||||
...(manifest.generated || []).map((p) => path.join(moduleRoot, p)),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime dependencies the shipped half declares.
|
||||
*
|
||||
* Empty is the shape this module is built around, and the release packs no
|
||||
* `node_modules` because of it. Returned rather than asserted so both modes can
|
||||
* report it with their own advice.
|
||||
*/
|
||||
function runtimeDependencies(moduleRoot = MODULE_ROOT) {
|
||||
const pkgPath = path.join(moduleRoot, 'server', 'package.json')
|
||||
if (!fs.existsSync(pkgPath)) return []
|
||||
return Object.keys(JSON.parse(fs.readFileSync(pkgPath, 'utf8')).dependencies || {})
|
||||
}
|
||||
|
||||
const DEPENDENCY_ADVICE =
|
||||
'The release packs no node_modules, because this module declared none. A dependency\n' +
|
||||
'listed here but not installed and copied by .gitea/workflows/release.yml ships as an\n' +
|
||||
'import of something that is not in the tarball — the module installs and then dies at\n' +
|
||||
'the register stage on the operator\'s box.\n\n' +
|
||||
'Either drop the dependency (everything the shipped half needs arrives on ctx — §2.3),\n' +
|
||||
'or add the `npm ci --omit=dev` + copy steps to release.yml and list "node_modules" in\n' +
|
||||
'ci/bundle.json\'s server[], then update this check.\n'
|
||||
|
||||
const covers = (declared, file) => declared.some((d) => file === d || file.startsWith(d + path.sep))
|
||||
|
||||
/**
|
||||
* --check: is ci/bundle.json's list sufficient for what the entry point reaches?
|
||||
*
|
||||
* Reports the top-level entry to ADD rather than the individual files, because
|
||||
* that is the edit: the list is stated in top-level paths, and a new directory
|
||||
* arrives with a dozen files in it.
|
||||
*/
|
||||
function checkDeclaration(moduleRoot = MODULE_ROOT) {
|
||||
const serverRoot = path.join(moduleRoot, 'server')
|
||||
const entry = path.join(serverRoot, 'index.js')
|
||||
const { files, missing } = reachable(entry)
|
||||
const declared = declaredServerPaths(moduleRoot)
|
||||
|
||||
// Grouped by the entry that would have to be added, which is the top-level
|
||||
// path under server/ — or, for the rare reachable file outside it, the path
|
||||
// itself, since that one belongs in root[] instead.
|
||||
const uncovered = new Map()
|
||||
for (const file of files) {
|
||||
if (covers(declared, file)) continue
|
||||
const inServer = file.startsWith(serverRoot + path.sep)
|
||||
const key = inServer
|
||||
? `server/${path.relative(serverRoot, file).split(path.sep)[0]}`
|
||||
: path.relative(moduleRoot, file).split(path.sep).join('/')
|
||||
if (!uncovered.has(key)) uncovered.set(key, [])
|
||||
uncovered.get(key).push(file)
|
||||
}
|
||||
|
||||
return { uncovered, missing, reached: files.length, dependencies: runtimeDependencies(moduleRoot) }
|
||||
}
|
||||
|
||||
/**
|
||||
* --bundle: does every relative specifier inside an assembled bundle resolve?
|
||||
*
|
||||
* Walks the bundle's own server tree rather than starting from the entry point,
|
||||
* so a file that ships but is broken is caught too.
|
||||
*/
|
||||
function checkBundle(bundleRoot) {
|
||||
const serverRoot = path.join(bundleRoot, 'server')
|
||||
const missing = []
|
||||
const files = []
|
||||
|
||||
const walk = (dir) => {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
// An installed dependency tree would be npm's business, not this
|
||||
// check's. This module ships none; the skip stays so that the day one
|
||||
// arrives, this is not also the thing that breaks.
|
||||
if (e.name !== 'node_modules') walk(p)
|
||||
} else if (/\.(js|mjs|cjs)$/.test(e.name)) {
|
||||
files.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(serverRoot)
|
||||
|
||||
for (const file of files) {
|
||||
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
||||
for (const [, specifier] of source.matchAll(RELATIVE)) {
|
||||
if (!resolveFile(path.dirname(file), specifier)) missing.push({ file, specifier })
|
||||
}
|
||||
}
|
||||
|
||||
return { missing, scanned: files.length, dependencies: runtimeDependencies(bundleRoot) }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
reachable,
|
||||
resolveFile,
|
||||
checkDeclaration,
|
||||
checkBundle,
|
||||
declaredServerPaths,
|
||||
runtimeDependencies,
|
||||
}
|
||||
|
||||
// Required by a test, or run as the check? Only the second one exits.
|
||||
if (require.main !== module) return
|
||||
|
||||
const bundleFlag = process.argv.indexOf('--bundle')
|
||||
|
||||
if (bundleFlag !== -1) {
|
||||
const root = process.argv[bundleFlag + 1]
|
||||
if (!root) {
|
||||
console.error('--bundle needs the path to an assembled bundle')
|
||||
process.exit(2)
|
||||
}
|
||||
const { missing, scanned, dependencies } = checkBundle(path.resolve(root))
|
||||
if (missing.length) {
|
||||
console.error(`\nThe assembled bundle is incomplete — ${missing.length} require(s) resolve to nothing:\n`)
|
||||
for (const m of missing) {
|
||||
console.error(` ${path.relative(root, m.file)}\n requires "${m.specifier}" — not in the bundle`)
|
||||
}
|
||||
console.error('\nAdd the missing path to ci/bundle.json.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
if (dependencies.length) {
|
||||
console.error(`\nThe assembled bundle declares ${dependencies.length} runtime dependency(ies) it does not carry:\n`)
|
||||
for (const d of dependencies) console.error(` ${d}`)
|
||||
console.error(`\n${DEPENDENCY_ADVICE}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`OK — every relative require in the bundle resolves (${scanned} files scanned), and it needs no node_modules.`)
|
||||
} else {
|
||||
const { uncovered, missing, reached, dependencies } = checkDeclaration()
|
||||
|
||||
if (missing.length) {
|
||||
console.error(`\n${missing.length} require(s) resolve to nothing in the source tree:\n`)
|
||||
for (const m of missing) {
|
||||
console.error(` ${path.relative(MODULE_ROOT, m.file)}\n requires "${m.specifier}"`)
|
||||
}
|
||||
console.error('')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (uncovered.size) {
|
||||
console.error('\nci/bundle.json does not ship everything server/index.js reaches.\n')
|
||||
console.error('A release built from this list would install and then fail at the')
|
||||
console.error("register stage with \"Cannot find module\", on the operator's box.\n")
|
||||
for (const [key, files] of uncovered) {
|
||||
console.error(` ${key} (${files.length} file${files.length === 1 ? '' : 's'} reachable)`)
|
||||
for (const f of files.slice(0, 5)) console.error(` ${path.relative(MODULE_ROOT, f)}`)
|
||||
if (files.length > 5) console.error(` … and ${files.length - 5} more`)
|
||||
}
|
||||
// server[] is written relative to server/, so name the entry to add rather
|
||||
// than the path just displayed — they differ by exactly that prefix.
|
||||
const toServer = [...uncovered.keys()].filter((k) => k.startsWith('server/'))
|
||||
const toRoot = [...uncovered.keys()].filter((k) => !k.startsWith('server/'))
|
||||
if (toServer.length) {
|
||||
console.error(`\nAdd ${toServer.map((k) => `"${k.slice('server/'.length)}"`).join(', ')} to ci/bundle.json's server[].`)
|
||||
}
|
||||
if (toRoot.length) {
|
||||
console.error(`\nAdd ${toRoot.map((k) => `"${k}"`).join(', ')} to ci/bundle.json's root[].`)
|
||||
}
|
||||
console.error('')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (dependencies.length) {
|
||||
console.error(`\nserver/package.json declares ${dependencies.length} runtime dependency(ies):\n`)
|
||||
for (const d of dependencies) console.error(` ${d}`)
|
||||
console.error(`\n${DEPENDENCY_ADVICE}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`OK — ci/bundle.json ships every file server/index.js reaches (${reached} files), and no runtime dependency is declared.`)
|
||||
}
|
||||
196
server/scripts/frozenManifest.js
Normal file
196
server/scripts/frozenManifest.js
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §5.3 — this module's frozen route manifest ─────────────────────────────
|
||||
//
|
||||
// Core freezes its URL surface in `server/routes.manifest.json` by walking the
|
||||
// live Express stack and committing the result; a PR that moves a URL has to
|
||||
// commit the new manifest, which puts the change in front of a reviewer. The URLs
|
||||
// this module serves are not in that file. They are here, frozen the same way and
|
||||
// by the same generator.
|
||||
//
|
||||
// **The module's routes are DERIVED, never listed.** This script is handed two
|
||||
// manifests generated from the SAME core at the pinned ref — one without this
|
||||
// module on the volume, one with — and the difference is what this module serves.
|
||||
// Nothing here says "/api/v1/public/rust/*"; a mount prefix appears in exactly one
|
||||
// place, `server/index.js`'s `registerRoutes` call, which is where an operator's
|
||||
// core reads it from too.
|
||||
//
|
||||
// Taking the difference rather than filtering by prefix buys the other half of
|
||||
// §5.3 for free, and it is the half that matters most: **no core URL may move.**
|
||||
// A module that shadowed a core route, or whose mount displaced one, shows up here
|
||||
// as a removal or a change, not merely as an addition somewhere else. That is the
|
||||
// promise §1.2 makes to the shipped Android app and the Discord bot.
|
||||
//
|
||||
// It is also the only check that can see the blind spot §13's own registration
|
||||
// comment names: core answers several public routes mounted at the TIER ROOT
|
||||
// rather than under a prefix — `/status` and `/version` among them — and the
|
||||
// loader's collision probe cannot find those. `/rust` was checked against core's
|
||||
// mount tables by hand when phase 1 chose it. From here it is checked by a core.
|
||||
//
|
||||
// The third thing it checks is the OpenAPI fragment (§2.8). `swagger-fragment.json`
|
||||
// is generated from the module's own registrations against §2.4's stated tier
|
||||
// bases — the one place a constant could be wrong. Here there is ground truth: a
|
||||
// real core with this module loaded, reporting the URLs it actually serves. Every
|
||||
// route must have a documented operation and every documented operation must be a
|
||||
// route. That is the per-module form of core's standing rule, never ship a route
|
||||
// that isn't in the spec — and it is what stops a wrong constant in the generator
|
||||
// from producing a fragment that is internally consistent and describes nothing
|
||||
// core will ever serve.
|
||||
//
|
||||
// Usage (the workflow does the cloning; see .gitea/workflows/pr-checks.yml):
|
||||
// node scripts/frozenManifest.js --before core-only.json --after core-plus-rust.json
|
||||
// node scripts/frozenManifest.js --before … --after … --check
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const MANIFEST = path.join(MODULE_ROOT, 'routes.manifest.json')
|
||||
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
|
||||
|
||||
const COMMENT =
|
||||
'Generated inventory of the URLs module-rust serves - the module half of the freeze ' +
|
||||
'core keeps in server/routes.manifest.json. DERIVED as the difference between a core ' +
|
||||
'without this module and the same core with it, both at the pinned ref in ci/core-ref.json. ' +
|
||||
'Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see ' +
|
||||
'server/scripts/frozenManifest.js.'
|
||||
|
||||
const key = (r) => `${r.method} ${r.path}`
|
||||
|
||||
/**
|
||||
* The module's routes, plus proof that core's own surface did not move.
|
||||
*
|
||||
* @param {object} before routes.manifest.json from core alone
|
||||
* @param {object} after routes.manifest.json from the same core with this module
|
||||
* @returns {{ added: object[], removed: string[] }}
|
||||
*/
|
||||
function diffManifests(before, after) {
|
||||
const added = []
|
||||
const removed = []
|
||||
|
||||
for (const tier of ['public', 'internal']) {
|
||||
const was = new Set((before[tier] || []).map(key))
|
||||
for (const route of after[tier] || []) {
|
||||
if (!was.has(key(route))) added.push({ ...route, tier })
|
||||
was.delete(key(route))
|
||||
}
|
||||
for (const gone of was) removed.push(`${tier} ${gone}`)
|
||||
}
|
||||
|
||||
added.sort((a, b) => (key(a) < key(b) ? -1 : 1))
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the module's routes the fragment fails to document, and vice versa.
|
||||
*
|
||||
* Express `:id` is OpenAPI `{id}`; the fragment is already in OpenAPI's spelling
|
||||
* because that is what core merges, so the manifest's paths are converted here
|
||||
* rather than the other way round.
|
||||
*/
|
||||
function coverage(added, fragment) {
|
||||
const documented = new Set()
|
||||
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
||||
for (const method of Object.keys(item)) documented.add(`${method.toUpperCase()} ${p}`)
|
||||
}
|
||||
|
||||
const undocumented = []
|
||||
for (const route of added) {
|
||||
const oas = `${route.method} ${route.path.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`
|
||||
if (documented.has(oas)) documented.delete(oas)
|
||||
else undocumented.push(oas)
|
||||
}
|
||||
|
||||
// Whatever is left is documented and not served: a route that moved or was
|
||||
// deleted while its annotation stayed behind. Core's own spec has no equivalent
|
||||
// check and grew four orphan tags and thirty-three orphan schemas because of it.
|
||||
return { undocumented, unserved: [...documented].sort() }
|
||||
}
|
||||
|
||||
function serialize(routes) {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
$comment: COMMENT,
|
||||
routes: routes.map(({ method, path: p, tier }) => ({ method, path: p, tier })),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`
|
||||
}
|
||||
|
||||
function main() {
|
||||
const arg = (name) => {
|
||||
const i = process.argv.indexOf(name)
|
||||
return i === -1 ? null : process.argv[i + 1]
|
||||
}
|
||||
const beforePath = arg('--before')
|
||||
const afterPath = arg('--after')
|
||||
if (!beforePath || !afterPath) {
|
||||
process.stderr.write('usage: frozenManifest.js --before <manifest> --after <manifest> [--check]\n')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const before = JSON.parse(fs.readFileSync(beforePath, 'utf8'))
|
||||
const after = JSON.parse(fs.readFileSync(afterPath, 'utf8'))
|
||||
const { added, removed } = diffManifests(before, after)
|
||||
|
||||
let failed = false
|
||||
|
||||
if (removed.length > 0) {
|
||||
process.stderr.write(
|
||||
`\nLoading this module REMOVED or CHANGED ${removed.length} of core's own route(s):\n` +
|
||||
`${removed.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'A module may only add. This is the frozen-URL promise (MODULE_SYSTEM.md §1.2) breaking.\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if (added.length === 0) {
|
||||
process.stderr.write(
|
||||
'\nLoading this module added NO routes. Either it failed to load in the core checkout\n' +
|
||||
'(check the boot log for a startup_failed line) or the two manifests are the same file.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const fragment = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
const { undocumented, unserved } = coverage(added, fragment)
|
||||
if (undocumented.length > 0) {
|
||||
process.stderr.write(
|
||||
`\n${undocumented.length} route(s) this module serves have no operation in swagger-fragment.json:\n` +
|
||||
`${undocumented.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'Run `npm run swagger --prefix server` and commit the result (MODULE_API.md §2.8).\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
if (unserved.length > 0) {
|
||||
process.stderr.write(
|
||||
`\n${unserved.length} operation(s) in swagger-fragment.json are not routes this module serves:\n` +
|
||||
`${unserved.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'A documented URL nobody serves is a client following the docs into a 404.\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if (failed) process.exit(1)
|
||||
|
||||
const contents = serialize(added)
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = fs.existsSync(MANIFEST) ? fs.readFileSync(MANIFEST, 'utf8').replace(/\r\n/g, '\n') : null
|
||||
if (current !== contents) {
|
||||
process.stderr.write(
|
||||
'\nroutes.manifest.json is stale. The URLs this module serves changed — regenerate it and\n' +
|
||||
'commit the result so the move is reviewed rather than merged as mechanical.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(`routes.manifest.json is current — ${added.length} routes, all documented\n`)
|
||||
return
|
||||
}
|
||||
|
||||
fs.writeFileSync(MANIFEST, contents)
|
||||
process.stdout.write(`wrote routes.manifest.json — ${added.length} routes, all documented\n`)
|
||||
}
|
||||
|
||||
if (require.main === module) main()
|
||||
|
||||
module.exports = { diffManifests, coverage, serialize, MANIFEST, FRAGMENT }
|
||||
@@ -48,14 +48,22 @@ const log = core.logger('sidecar')
|
||||
const TIMEOUT_MS = 12000
|
||||
|
||||
/**
|
||||
* The wire version this module speaks. Declared in three places that must agree:
|
||||
* here, `PROTOCOL_VERSION` in the sidecar, and `overlay.toml` in Rust-Plugins.
|
||||
* The wire version this module speaks. Declared in FOUR places that must agree:
|
||||
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
|
||||
* plugin, and `protocol` in its `overlay.toml`.
|
||||
*
|
||||
* **2 — the read path.** The bump lands here in the same change as the emitters,
|
||||
* even though this module does not yet consume any of the new frames: the
|
||||
* sidecar refuses a client declaring a different version with a `409`, so a
|
||||
* module left on 1 would stop being able to read the server board it has been
|
||||
* reading all along. A constant that lags the deployment is not a safe default;
|
||||
* it is an outage with a version number on it.
|
||||
*
|
||||
* It is sent on every request as `X-RustLink-Version`, which turns a mismatched
|
||||
* deployment into a `409` naming both numbers instead of a parse failure three
|
||||
* layers further in.
|
||||
*/
|
||||
const PROTOCOL_VERSION = 1
|
||||
const PROTOCOL_VERSION = 2
|
||||
|
||||
/** What a caller gets back. Shaped once so every call site reads the same. */
|
||||
function reply(ok, status, data = null) {
|
||||
@@ -163,6 +171,24 @@ const serverBoard = (server) => request(server, '/server')
|
||||
/** A live round trip through the sidecar to the game. Fails when the game is down, by design. */
|
||||
const liveStatus = (server) => request(server, '/status')
|
||||
|
||||
/** Every board at once: what is true now, before following what happens next. */
|
||||
const boards = (server) => request(server, '/boards')
|
||||
|
||||
/**
|
||||
* The ingest cursor: events after `since`, oldest first.
|
||||
*
|
||||
* **`since` is required here, unlike on the wire.** The sidecar treats an omitted
|
||||
* cursor as "tell me where the end is", which is a genuinely useful question and
|
||||
* a catastrophic default for an ingest loop that would silently store nothing
|
||||
* and advance past everything. So the question is asked explicitly, by name, and
|
||||
* a caller cannot get it by forgetting an argument.
|
||||
*/
|
||||
const feed = (server, since, limit = 200) =>
|
||||
request(server, `/feed?since=${encodeURIComponent(since)}&limit=${encodeURIComponent(limit)}`)
|
||||
|
||||
/** Where the sidecar's history currently ends. What a new server's cursor starts at. */
|
||||
const feedTail = (server) => request(server, '/feed')
|
||||
|
||||
module.exports = {
|
||||
TIMEOUT_MS,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -170,5 +196,8 @@ module.exports = {
|
||||
health,
|
||||
serverBoard,
|
||||
liveStatus,
|
||||
boards,
|
||||
feed,
|
||||
feedTail,
|
||||
joinUrl,
|
||||
}
|
||||
|
||||
110
server/test/catalogue.test.js
Normal file
110
server/test/catalogue.test.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// ── The boundary, asserted ────────────────────────────────────────────────
|
||||
//
|
||||
// `catalogue.js` is the only thing standing between a frame carrying an IP
|
||||
// address and a public page, so it gets a suite of its own rather than being
|
||||
// covered incidentally by a route test.
|
||||
//
|
||||
// The most valuable test here is the last one: it holds the classification
|
||||
// against the specification in `docs/rust-link/PROTOCOL.md` §8.4. Without it the
|
||||
// two drift the first time somebody adds a kind to the protocol, and the drift
|
||||
// is silent in the direction that matters — a new kind is simply never served,
|
||||
// until the day somebody "fixes" that by adding it to the wrong list.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const catalogue = require('../catalogue')
|
||||
|
||||
test('an unknown kind is not public — the default is deny', () => {
|
||||
assert.equal(catalogue.isPublic('player.death'), true)
|
||||
assert.equal(catalogue.isPublic('something.new'), false)
|
||||
assert.equal(catalogue.isPublic(''), false)
|
||||
assert.equal(catalogue.isPublic(undefined), false)
|
||||
|
||||
// The shape of the mistake this prevents: a kind a LATER protocol adds, which
|
||||
// this build ingests happily and would publish on the day it first arrived if
|
||||
// the filter were a deny list.
|
||||
assert.equal(catalogue.isKnown('player.location'), false)
|
||||
assert.equal(catalogue.isPublic('player.location'), false)
|
||||
})
|
||||
|
||||
test('nothing carrying an IP address or a report is public', () => {
|
||||
for (const kind of [
|
||||
'player.login.attempt',
|
||||
'player.approved',
|
||||
'player.banned',
|
||||
'player.unbanned',
|
||||
'player.reported',
|
||||
'entity.destroyed',
|
||||
]) {
|
||||
assert.equal(catalogue.isPublic(kind), false, `${kind} must not be public`)
|
||||
assert.ok(catalogue.STAFF_KINDS.includes(kind), `${kind} must be classified, not merely absent`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a viewer with no kinds asked for gets the allowlist, never everything', () => {
|
||||
const asPublic = catalogue.kindsFor({})
|
||||
const asAdmin = catalogue.kindsFor({ admin: true })
|
||||
|
||||
assert.deepEqual(asPublic, [...catalogue.PUBLIC_KINDS])
|
||||
assert.equal(asAdmin.length, catalogue.ALL_KINDS.length)
|
||||
|
||||
// The property that makes the route safe by construction: there is no argument
|
||||
// a caller can omit that turns the filter off.
|
||||
assert.ok(asPublic.length > 0)
|
||||
assert.ok(!asPublic.includes('player.banned'))
|
||||
})
|
||||
|
||||
test('a kind a viewer may not see is dropped, not refused', () => {
|
||||
const asked = catalogue.kindsFor({ requested: ['player.death', 'player.banned'] })
|
||||
|
||||
assert.deepEqual(asked, ['player.death'])
|
||||
|
||||
// Asking for only forbidden kinds answers with nothing to select, which the
|
||||
// model turns into an empty list — the events are, as far as this viewer is
|
||||
// concerned, not there.
|
||||
assert.deepEqual(catalogue.kindsFor({ requested: ['player.banned'] }), [])
|
||||
|
||||
// And an admin gets what they asked for.
|
||||
assert.deepEqual(catalogue.kindsFor({ admin: true, requested: ['player.banned'] }), [
|
||||
'player.banned',
|
||||
])
|
||||
})
|
||||
|
||||
test('every kind is classified exactly once', () => {
|
||||
const seen = new Set()
|
||||
|
||||
for (const kind of catalogue.ALL_KINDS) {
|
||||
assert.ok(!seen.has(kind), `${kind} appears in both lists`)
|
||||
seen.add(kind)
|
||||
}
|
||||
|
||||
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
|
||||
})
|
||||
|
||||
test('the classification covers exactly the kinds protocol 2 defines', () => {
|
||||
// The spec lives in another repository, so the list is restated here rather
|
||||
// than parsed — and restating it is the point: adding a kind to the protocol
|
||||
// without deciding who may see it has to fail somewhere, and this is where.
|
||||
//
|
||||
// Sourced from docs/rust-link/PROTOCOL.md §8.4.
|
||||
const PROTOCOL_2 = [
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
'player.death',
|
||||
'player.chat',
|
||||
'player.tally',
|
||||
'entity.destroyed',
|
||||
'player.reported',
|
||||
'player.banned',
|
||||
'player.unbanned',
|
||||
'player.login.attempt',
|
||||
'player.approved',
|
||||
'server.wipe',
|
||||
'server.initialized',
|
||||
'server.shutdown',
|
||||
]
|
||||
|
||||
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_2].sort())
|
||||
})
|
||||
273
server/test/checkBundle.test.js
Normal file
273
server/test/checkBundle.test.js
Normal file
@@ -0,0 +1,273 @@
|
||||
// The bundle check, checked.
|
||||
//
|
||||
// `scripts/checkBundle.js` exists because of a failure this module has not had
|
||||
// and does not intend to: Module-uo's v1.0.0 shipped without `server/commands/`
|
||||
// and died at the register stage on the operator's box. A check written in
|
||||
// response to one bug is worth exactly as much as its coverage of that bug, so
|
||||
// the first two tests below are that bug, in both modes — a list that has stopped
|
||||
// covering what the entry point reaches, and a tarball with the file missing from
|
||||
// it — and the third pair is this module's own version of it, a runtime
|
||||
// dependency declared and not packed.
|
||||
//
|
||||
// **Every fixture is a template literal, and that is load-bearing** — the same
|
||||
// reason checkImports.test.js gives. `scripts/checkImports.js` scans this
|
||||
// directory too, so an ordinary quoted string holding a relative require would
|
||||
// make this file fail that check. Templates are blanked by the stripper.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
reachable,
|
||||
resolveFile,
|
||||
checkDeclaration,
|
||||
checkBundle,
|
||||
declaredServerPaths,
|
||||
runtimeDependencies,
|
||||
} = require('../scripts/checkBundle')
|
||||
|
||||
/**
|
||||
* Write a throwaway module tree: `files` under server/, `bundle` as its
|
||||
* ci/bundle.json, `pkg` as its server/package.json. Returns the module root.
|
||||
*/
|
||||
function fixture(files, bundle = { server: ['index.js'] }, pkg = null) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-rust-bundle-'))
|
||||
for (const [name, source] of Object.entries(files)) {
|
||||
const file = path.join(root, 'server', name)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, source)
|
||||
}
|
||||
fs.mkdirSync(path.join(root, 'ci'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'ci', 'bundle.json'), JSON.stringify(bundle))
|
||||
if (pkg) {
|
||||
fs.mkdirSync(path.join(root, 'server'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'server', 'package.json'), JSON.stringify(pkg))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true })
|
||||
|
||||
// ── The regression this script was written for ─────────────────────────────
|
||||
|
||||
test('--check catches a directory the include list has stopped covering', () => {
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `const r = require('./router/public/rust.router')`,
|
||||
'router/public/rust.router.js': `module.exports = {}`,
|
||||
},
|
||||
{ server: ['index.js'] }, // `router` missing — exactly Module-uo's v1.0.0
|
||||
)
|
||||
try {
|
||||
const { uncovered } = checkDeclaration(root)
|
||||
assert.strictEqual(uncovered.size, 1)
|
||||
assert.ok(uncovered.has('server/router'))
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('--bundle catches the file missing from an assembled tarball', () => {
|
||||
const root = fixture({ 'index.js': `require('./router/public/rust.router')` })
|
||||
try {
|
||||
const { missing } = checkBundle(root)
|
||||
assert.strictEqual(missing.length, 1)
|
||||
assert.strictEqual(missing[0].specifier, './router/public/rust.router')
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── This module's own version of that failure ──────────────────────────────
|
||||
//
|
||||
// The release packs no node_modules because the shipped half declares no
|
||||
// dependencies (org lead, phase 2). The value of that decision is entirely in
|
||||
// the day it stops being true being a LOUD day, so both modes ask.
|
||||
|
||||
test('--check reports a runtime dependency the release would not pack', () => {
|
||||
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
|
||||
name: 'x',
|
||||
dependencies: { ws: '^8.21.0' },
|
||||
})
|
||||
try {
|
||||
assert.deepStrictEqual(checkDeclaration(root).dependencies, ['ws'])
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('--bundle reports a dependency the assembled bundle declares and does not carry', () => {
|
||||
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
|
||||
name: 'x',
|
||||
dependencies: { ws: '^8.21.0' },
|
||||
})
|
||||
try {
|
||||
assert.deepStrictEqual(checkBundle(root).dependencies, ['ws'])
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('devDependencies are not runtime dependencies', () => {
|
||||
// express, express-validator and swagger-autogen are all here and none of them
|
||||
// ships: the shipped half is handed express on `ctx` (§2.3). A check that
|
||||
// confused the two would fail on a correct repo, which is the one way to make
|
||||
// everyone stop reading it.
|
||||
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
|
||||
name: 'x',
|
||||
devDependencies: { express: '^4.19.2' },
|
||||
})
|
||||
try {
|
||||
assert.deepStrictEqual(runtimeDependencies(root), [])
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── It has to reach requires that are not at the top level ─────────────────
|
||||
|
||||
test('follows requires written inside a function', () => {
|
||||
// index.js requires inside `register()` because require order is load-bearing:
|
||||
// `core.init(ctx)` has to run before anything under router/ is required. A
|
||||
// check that only saw file-scope requires would miss every router this module
|
||||
// has.
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `module.exports = function register(ctx) { const r = require('./router/a') }`,
|
||||
'router/a.js': `module.exports = {}`,
|
||||
},
|
||||
{ server: ['index.js', 'router'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
assert.strictEqual(checkBundle(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('follows requires transitively, not just one hop', () => {
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `require('./a')`,
|
||||
'a.js': `require('./b')`,
|
||||
'b.js': `require('./deep/c')`,
|
||||
'deep/c.js': `module.exports = {}`,
|
||||
},
|
||||
{ server: ['index.js', 'a.js', 'b.js'] }, // `deep` missing
|
||||
)
|
||||
try {
|
||||
assert.ok(checkDeclaration(root).uncovered.has('server/deep'))
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Resolution has to match Node's, or it invents failures ─────────────────
|
||||
|
||||
test('resolves a directory to its index.js', () => {
|
||||
const root = fixture(
|
||||
{ 'index.js': `require('./boot')`, 'boot/index.js': `module.exports = {}` },
|
||||
{ server: ['index.js', 'boot'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('resolves a .json dependency, and does not try to parse it for requires', () => {
|
||||
// server/index.js's last line requires ../module.json, which is why this case
|
||||
// is not hypothetical and why `generated` is in the declared list at all.
|
||||
const root = fixture(
|
||||
{ 'index.js': `require('./data/atlas.json')`, 'data/atlas.json': `{"a":1}` },
|
||||
{ server: ['index.js', 'data'] },
|
||||
)
|
||||
try {
|
||||
const { uncovered, missing } = checkDeclaration(root)
|
||||
assert.strictEqual(missing.length, 0)
|
||||
assert.strictEqual(uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('survives a require cycle', () => {
|
||||
const root = fixture(
|
||||
{ 'index.js': `require('./a')`, 'a.js': `require('./index')` },
|
||||
{ server: ['index.js', 'a.js'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('a specifier that resolves to nothing is reported, not thrown', () => {
|
||||
const root = fixture({ 'index.js': `require('./gone')` })
|
||||
try {
|
||||
const { missing } = checkDeclaration(root)
|
||||
assert.strictEqual(missing.length, 1)
|
||||
assert.strictEqual(missing[0].specifier, './gone')
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('prose describing a require is not a require', () => {
|
||||
// The failure mode checkImports.js hit the first time it ran: index.js's own
|
||||
// header explains why it must never require express, and comments in this repo
|
||||
// name module paths constantly.
|
||||
const root = fixture(
|
||||
{ 'index.js': `// this file used to require('./router/gone')\nmodule.exports = 1` },
|
||||
{ server: ['index.js'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test("node_modules inside a bundle is npm's business, not this check's", () => {
|
||||
// Nothing ships one today. The skip stays so that the day a dependency does
|
||||
// arrive, this is not also the thing that breaks.
|
||||
const root = fixture({
|
||||
'index.js': `module.exports = 1`,
|
||||
'node_modules/ws/index.js': `require('./lib/that-npm-owns')`,
|
||||
})
|
||||
try {
|
||||
assert.strictEqual(checkBundle(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── And the real repo, which is the check that actually gates a release ────
|
||||
|
||||
test('the real ci/bundle.json covers everything the real entry point reaches', () => {
|
||||
const { uncovered, missing, reached, dependencies } = checkDeclaration()
|
||||
assert.deepStrictEqual([...uncovered.keys()], [])
|
||||
assert.deepStrictEqual(missing, [])
|
||||
assert.deepStrictEqual(dependencies, [])
|
||||
assert.ok(reached > 1, 'the walk should reach more than the entry point itself')
|
||||
})
|
||||
|
||||
test('every path ci/bundle.json declares exists', () => {
|
||||
// A list naming a path that has moved packs nothing and says nothing — `cp` in
|
||||
// the release would fail, but only after the tag had been pushed.
|
||||
for (const p of declaredServerPaths()) {
|
||||
assert.ok(fs.existsSync(p), `ci/bundle.json names ${p}, which does not exist`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the entry point is reachable from the declared list', () => {
|
||||
const entry = path.resolve(__dirname, '..', 'index.js')
|
||||
assert.ok(reachable(entry).files.includes(entry))
|
||||
assert.ok(resolveFile(path.dirname(entry), './core'))
|
||||
})
|
||||
144
server/test/events.test.js
Normal file
144
server/test/events.test.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// ── The read path's logic ─────────────────────────────────────────────────
|
||||
//
|
||||
// The model decides what a caller gets. Two properties are worth more than the
|
||||
// rest, and both are about a caller who did something slightly wrong:
|
||||
//
|
||||
// • a route that forgets to say who is asking gets the PUBLIC view;
|
||||
// • a caller asking for a million rows gets two hundred.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
function withCore() {
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx())
|
||||
}
|
||||
|
||||
test('the limit is bounded, whatever was asked for', () => {
|
||||
withCore()
|
||||
const model = require('../model/events/events.model')
|
||||
|
||||
assert.equal(model.boundedLimit(10), 10)
|
||||
assert.equal(model.boundedLimit(undefined), 50)
|
||||
assert.equal(model.boundedLimit('nonsense'), 50)
|
||||
assert.equal(model.boundedLimit(-5), 50)
|
||||
assert.equal(model.boundedLimit(0), 50)
|
||||
assert.equal(model.boundedLimit(1e9), model.MAX_LIMIT)
|
||||
assert.equal(model.boundedLimit(12.9), 12)
|
||||
})
|
||||
|
||||
test('kinds parse from one name or a list, and nothing means "not specified"', () => {
|
||||
withCore()
|
||||
const model = require('../model/events/events.model')
|
||||
|
||||
assert.deepEqual(model.parseKinds('player.death'), ['player.death'])
|
||||
assert.deepEqual(model.parseKinds('player.death, player.chat'), ['player.death', 'player.chat'])
|
||||
|
||||
// Null rather than an empty list: "I did not ask" and "I asked for nothing"
|
||||
// are different, and only the first means "whatever I am allowed".
|
||||
assert.equal(model.parseKinds(''), null)
|
||||
assert.equal(model.parseKinds(undefined), null)
|
||||
assert.equal(model.parseKinds(' , , '), null)
|
||||
})
|
||||
|
||||
test('a reader who does not say who they are gets the public view', async () => {
|
||||
withCore()
|
||||
|
||||
const db = require('../model/events/events.db')
|
||||
const model = require('../model/events/events.model')
|
||||
const original = db.recentEvents
|
||||
let asked = null
|
||||
|
||||
db.recentEvents = async (args) => {
|
||||
asked = args
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
await model.recent({ serverId: 'main' })
|
||||
|
||||
assert.ok(!asked.kinds.includes('player.banned'), 'no IP-carrying kind by default')
|
||||
assert.ok(asked.kinds.includes('player.death'))
|
||||
|
||||
await model.recent({ serverId: 'main', admin: true })
|
||||
assert.ok(asked.kinds.includes('player.banned'), 'an admin who says so gets them')
|
||||
} finally {
|
||||
db.recentEvents = original
|
||||
}
|
||||
})
|
||||
|
||||
test('asking only for kinds you may not see answers with nothing, and queries nothing', async () => {
|
||||
withCore()
|
||||
|
||||
const db = require('../model/events/events.db')
|
||||
const model = require('../model/events/events.model')
|
||||
const original = db.recentEvents
|
||||
let called = false
|
||||
|
||||
db.recentEvents = async () => {
|
||||
called = true
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await model.recent({ serverId: 'main', kind: 'player.banned,player.approved' })
|
||||
|
||||
assert.deepEqual(rows, [])
|
||||
assert.equal(called, false, 'a query with no permitted kinds must not reach the database')
|
||||
} finally {
|
||||
db.recentEvents = original
|
||||
}
|
||||
})
|
||||
|
||||
test('a row whose stored frame will not parse still answers with its envelope', async () => {
|
||||
withCore()
|
||||
|
||||
const db = require('../model/events/events.db')
|
||||
const model = require('../model/events/events.model')
|
||||
const original = db.recentEvents
|
||||
|
||||
db.recentEvents = async () => [
|
||||
{ id: 7, kind: 'player.death', t: 12, wipeId: 'w-1', steamId: 'p1', raw: '{not json' },
|
||||
]
|
||||
|
||||
try {
|
||||
const [row] = await model.recent({ serverId: 'main' })
|
||||
|
||||
// One unreadable row must not fail a whole page. What is known is still
|
||||
// reported; the body is empty rather than absent.
|
||||
assert.equal(row.id, 7)
|
||||
assert.equal(row.kind, 'player.death')
|
||||
assert.deepEqual(row.frame, {})
|
||||
} finally {
|
||||
db.recentEvents = original
|
||||
}
|
||||
})
|
||||
|
||||
test('the leaderboard answers numbers, never nulls', async () => {
|
||||
withCore()
|
||||
|
||||
const db = require('../model/events/events.db')
|
||||
const model = require('../model/events/events.model')
|
||||
const original = db.leaderboard
|
||||
|
||||
// SUM() over no rows is NULL in SQL, and a JOIN with no player row gives a
|
||||
// null name. A page that has to defend against both is a page with the
|
||||
// defence in three places.
|
||||
db.leaderboard = async () => [
|
||||
{ steamId: 'p1', name: null, kills: null, deaths: '3', npcKills: null, playtimeSec: null },
|
||||
]
|
||||
|
||||
try {
|
||||
const [row] = await model.leaderboard({ serverId: 'main' })
|
||||
|
||||
assert.equal(row.kills, 0)
|
||||
assert.equal(row.deaths, 3)
|
||||
assert.equal(row.npcKills, 0)
|
||||
assert.equal(row.playtimeSec, 0)
|
||||
assert.equal(row.name, null)
|
||||
} finally {
|
||||
db.leaderboard = original
|
||||
}
|
||||
})
|
||||
177
server/test/frozenManifest.test.js
Normal file
177
server/test/frozenManifest.test.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// The frozen manifest's derivation, checked.
|
||||
//
|
||||
// `scripts/frozenManifest.js` runs in one place — a CI job with a whole core
|
||||
// checked out beside it — so it is the least-exercised piece of machinery in this
|
||||
// repo, and it is the piece that decides whether the URLs this module claims are
|
||||
// the URLs it serves (MODULE_API.md §5.3). Its three answers are pure functions of
|
||||
// two manifests and a fragment, so all three are asked here, with fixtures rather
|
||||
// than a clone.
|
||||
//
|
||||
// What is deliberately NOT asserted here: the numbers. `routes.manifest.json`'s
|
||||
// routes are proved by the job that generates them from a real core, and a copy of
|
||||
// that count in this file would only ever be a second thing to update.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { diffManifests, coverage, MANIFEST, FRAGMENT } = require('../scripts/frozenManifest')
|
||||
|
||||
const manifest = (public_ = [], internal = []) => ({ public: public_, internal })
|
||||
const get = (p) => ({ method: 'GET', path: p })
|
||||
|
||||
test("the module's routes are the ones a core gains by loading it", () => {
|
||||
const before = manifest([get('/api/v1/public/settings')])
|
||||
const after = manifest([get('/api/v1/public/settings'), get('/api/v1/public/rust/servers')])
|
||||
|
||||
const { added, removed } = diffManifests(before, after)
|
||||
assert.deepStrictEqual(removed, [])
|
||||
assert.deepStrictEqual(added, [{ method: 'GET', path: '/api/v1/public/rust/servers', tier: 'public' }])
|
||||
})
|
||||
|
||||
test('a route core loses to the module is reported, not quietly absorbed', () => {
|
||||
// The failure this exists for, and the one phase 1 could only check by reading:
|
||||
// core mounts several routes at the TIER ROOT (/status, /version) that the
|
||||
// loader's collision probe cannot see, so a module whose mount displaced one
|
||||
// would not show up as an addition — the URL is unchanged — and a check that
|
||||
// only looked at what appeared would call it clean.
|
||||
const before = manifest([get('/api/v1/public/settings'), get('/api/v1/public/status')])
|
||||
const after = manifest([get('/api/v1/public/settings')])
|
||||
|
||||
const { removed } = diffManifests(before, after)
|
||||
assert.deepStrictEqual(removed, ['public GET /api/v1/public/status'])
|
||||
})
|
||||
|
||||
test('a route whose METHOD changed counts as removed and added', () => {
|
||||
const { added, removed } = diffManifests(
|
||||
manifest([{ method: 'POST', path: '/api/v1/admin/thing' }]),
|
||||
manifest([{ method: 'PUT', path: '/api/v1/admin/thing' }]),
|
||||
)
|
||||
assert.deepStrictEqual(removed, ['public POST /api/v1/admin/thing'])
|
||||
assert.strictEqual(added.length, 1)
|
||||
})
|
||||
|
||||
test('the internal app is diffed too, and keeps its own tier', () => {
|
||||
const { added } = diffManifests(
|
||||
manifest([], [get('/internal/health')]),
|
||||
manifest([], [get('/internal/health'), get('/internal/rust/thing')]),
|
||||
)
|
||||
assert.deepStrictEqual(added, [{ method: 'GET', path: '/internal/rust/thing', tier: 'internal' }])
|
||||
})
|
||||
|
||||
test('added routes are sorted, so the committed file does not churn on traversal order', () => {
|
||||
const { added } = diffManifests(
|
||||
manifest([]),
|
||||
manifest([get('/b'), get('/a'), { method: 'POST', path: '/a' }]),
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
added.map((r) => `${r.method} ${r.path}`),
|
||||
['GET /a', 'GET /b', 'POST /a'],
|
||||
)
|
||||
})
|
||||
|
||||
// ── coverage: the route ⇄ fragment agreement ────────────────────────────────
|
||||
|
||||
const fragment = (paths) => ({ paths })
|
||||
|
||||
test('a served route with no documented operation is named', () => {
|
||||
const { undocumented, unserved } = coverage([get('/api/v1/public/rust/servers')], fragment({}))
|
||||
assert.deepStrictEqual(undocumented, ['GET /api/v1/public/rust/servers'])
|
||||
assert.deepStrictEqual(unserved, [])
|
||||
})
|
||||
|
||||
test('a documented operation nobody serves is named too', () => {
|
||||
// The direction core's own spec has no check for, which is how it accumulated
|
||||
// orphan tags and schemas describing routes that had moved out of it. A
|
||||
// documented URL nobody serves is a client following the docs into a 404.
|
||||
const { undocumented, unserved } = coverage([], fragment({ '/api/v1/public/rust/gone': { get: {} } }))
|
||||
assert.deepStrictEqual(undocumented, [])
|
||||
assert.deepStrictEqual(unserved, ['GET /api/v1/public/rust/gone'])
|
||||
})
|
||||
|
||||
test('express :params and OpenAPI {params} are the same route', () => {
|
||||
const { undocumented, unserved } = coverage(
|
||||
[{ method: 'DELETE', path: '/api/v1/admin/rust/servers/:id' }],
|
||||
fragment({ '/api/v1/admin/rust/servers/{id}': { delete: {} } }),
|
||||
)
|
||||
assert.deepStrictEqual(undocumented, [])
|
||||
assert.deepStrictEqual(unserved, [])
|
||||
})
|
||||
|
||||
test('methods are matched, not just paths', () => {
|
||||
const { undocumented, unserved } = coverage(
|
||||
[{ method: 'POST', path: '/api/v1/admin/rust/servers/:id/test' }],
|
||||
fragment({ '/api/v1/admin/rust/servers/{id}/test': { get: {} } }),
|
||||
)
|
||||
assert.deepStrictEqual(undocumented, ['POST /api/v1/admin/rust/servers/{id}/test'])
|
||||
assert.deepStrictEqual(unserved, ['GET /api/v1/admin/rust/servers/{id}/test'])
|
||||
})
|
||||
|
||||
// ── the committed artifacts, against each other ─────────────────────────────
|
||||
//
|
||||
// These two files are generated together by a job that has a real core; here
|
||||
// there is no core, so what can still be asked is whether they agree with each
|
||||
// other. If they do not, one of them was committed without the other.
|
||||
|
||||
test('every route in the committed manifest has a committed operation', () => {
|
||||
const { routes } = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'))
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
const { undocumented, unserved } = coverage(routes, spec)
|
||||
assert.deepStrictEqual(undocumented, [], 'routes.manifest.json lists routes swagger-fragment.json does not document')
|
||||
assert.deepStrictEqual(unserved, [], 'swagger-fragment.json documents operations routes.manifest.json does not list')
|
||||
})
|
||||
|
||||
test('the fragment carries only the three sections §6.1a allows', () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
assert.deepStrictEqual(Object.keys(spec).sort(), ['components', 'paths', 'tags'])
|
||||
assert.deepStrictEqual(Object.keys(spec.components), ['schemas'])
|
||||
})
|
||||
|
||||
test("the fragment defines only namespaced schemas, and redefines none of core's", () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
for (const name of Object.keys(spec.components.schemas)) {
|
||||
assert.match(name, /^Rust[A-Z]/, `${name} is not namespaced — core wins the collision and drops it (§6.1a)`)
|
||||
}
|
||||
// Anything this fragment REFERENCES and does not define has to be one of
|
||||
// core's shared schemas, which resolve in the merged document — that is the
|
||||
// whole point of a fragment. A typo'd $ref is otherwise invisible until a
|
||||
// reader opens /api/docs.json and finds a dangling pointer.
|
||||
const refs = JSON.stringify(spec.paths).match(/#[/]components[/]schemas[/]([A-Za-z0-9_]+)/g) || []
|
||||
const shared = ['Error', 'ValidationError']
|
||||
for (const name of new Set(refs.map((r) => r.split('/').pop()))) {
|
||||
const resolvable = Object.hasOwn(spec.components.schemas, name) || shared.includes(name)
|
||||
assert.ok(resolvable, `$ref to ${name} resolves to nothing — not defined here, not one of core's shared schemas`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every path in the fragment is fully qualified', () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
for (const p of Object.keys(spec.paths)) {
|
||||
// §6.1a: core merges the fragment verbatim and never re-derives a prefix, so
|
||||
// a router-relative path here is a path nothing serves.
|
||||
assert.match(p, /^\/api\/v1\/(public|admin|player)\//, `${p} is not a fully-qualified URL`)
|
||||
assert.doesNotMatch(p, /\/$/, `${p} has a trailing slash — no client calls that URL`)
|
||||
}
|
||||
})
|
||||
|
||||
test("the manifest and the module's declared mounts agree", () => {
|
||||
const { routes } = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'))
|
||||
const { mounts } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8'))
|
||||
|
||||
const declared = []
|
||||
for (const [tier, prefixes] of Object.entries(mounts)) {
|
||||
for (const prefix of prefixes) declared.push(`/api/v1/${tier}${prefix}/`)
|
||||
}
|
||||
|
||||
// Every route this module serves is under a prefix it declared. There is no
|
||||
// exception here yet, and that is the point of asserting it now: phase 6 adds
|
||||
// the `admin.users.detail` extension slot, whose routes live under core's
|
||||
// `/api/v1/admin/users/` rather than under any mount of ours (§2.4). When that
|
||||
// arrives this test must grow the exception deliberately, rather than a route
|
||||
// outside every declared mount arriving unnoticed.
|
||||
for (const route of routes) {
|
||||
const under = declared.some((d) => route.path.startsWith(d))
|
||||
assert.ok(under, `${route.method} ${route.path} is served from outside every mount module.json declares`)
|
||||
}
|
||||
})
|
||||
329
server/test/ingest.test.js
Normal file
329
server/test/ingest.test.js
Normal file
@@ -0,0 +1,329 @@
|
||||
// ── The ingest ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every test here is about one of three things, and all three are mistakes that
|
||||
// look correct in review:
|
||||
//
|
||||
// • **who gets credited.** A suicide must not credit the victim with a kill.
|
||||
// That single line would produce a leaderboard topped by whoever died most,
|
||||
// and it would look plausible for a whole wipe.
|
||||
// • **the cursor's ordering.** It advances AFTER the batch, never before, so a
|
||||
// crash re-reads rather than skips. Skipping is silent and permanent.
|
||||
// • **absent is not zero.** A session whose start was never seen contributes
|
||||
// no playtime rather than zero playtime.
|
||||
//
|
||||
// The database is a recorder. Asserting the SQL exactly would be a test of the
|
||||
// SQL's punctuation, so each case asserts the *statement shape* and the values —
|
||||
// which table was written, and with what.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
/** Installs a core whose `db.query` records every statement. */
|
||||
function withRecorder() {
|
||||
const statements = []
|
||||
|
||||
const ctx = fakeCtx({
|
||||
db: {
|
||||
query: (sql, params = []) => {
|
||||
statements.push({ sql, params })
|
||||
return Promise.resolve([])
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
})
|
||||
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
|
||||
return {
|
||||
statements,
|
||||
/** Every statement that touched a table, with its parameters. */
|
||||
touching(table) {
|
||||
return statements.filter((s) => s.sql.includes(table))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const frame = (over = {}) => ({
|
||||
type: 'event',
|
||||
t: 1789560564452,
|
||||
serverId: 'main',
|
||||
wipeId: 'w-20260915T195817Z',
|
||||
...over,
|
||||
})
|
||||
|
||||
const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...over }) })
|
||||
|
||||
test('every frame is stored, whether or not this build understands it', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
await apply('main', item('player.death', { steamId: '76561198000000001' }))
|
||||
await apply('main', item('something.from.protocol.9'))
|
||||
|
||||
const stored = rec.touching('rust_events')
|
||||
assert.equal(stored.length, 2, 'an unrecognised kind must still be stored')
|
||||
|
||||
// The one copy of an event a later version will know how to read is the one
|
||||
// this version chose not to throw away.
|
||||
assert.ok(stored[1].params.includes('something.from.protocol.9'))
|
||||
})
|
||||
|
||||
test('a wipe exists because a frame mentioned it', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
await apply('main', item('player.chat', { steamId: '1', message: 'hello' }))
|
||||
|
||||
const wipes = rec.touching('rust_wipes')
|
||||
assert.equal(wipes.length, 1)
|
||||
assert.deepEqual(wipes[0].params.slice(0, 2), ['main', 'w-20260915T195817Z'])
|
||||
})
|
||||
|
||||
test('a kill credits the attacker and a death the victim', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
await apply(
|
||||
'main',
|
||||
item('player.death', {
|
||||
steamId: 'victim',
|
||||
attackerType: 'player',
|
||||
attackerId: 'killer',
|
||||
attackerName: 'Killer',
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = rec.touching('rust_player_wipe_stats')
|
||||
assert.equal(stats.length, 2, 'one row for the victim, one for the attacker')
|
||||
|
||||
// The parameter order is (server, wipe, steam, kills, deaths, suicides, ...).
|
||||
const victim = stats.find((s) => s.params[2] === 'victim')
|
||||
const killer = stats.find((s) => s.params[2] === 'killer')
|
||||
|
||||
assert.ok(victim && killer)
|
||||
assert.equal(victim.params[3], 0, 'the victim scored no kill')
|
||||
assert.equal(victim.params[4], 1, 'the victim died once')
|
||||
assert.equal(killer.params[3], 1, 'the attacker scored one kill')
|
||||
assert.equal(killer.params[4], 0, 'the attacker did not die')
|
||||
})
|
||||
|
||||
test('a suicide is a death and a suicide, and credits nobody with a kill', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
await apply('main', item('player.death', { steamId: 'victim', attackerType: 'self' }))
|
||||
|
||||
const stats = rec.touching('rust_player_wipe_stats')
|
||||
assert.equal(stats.length, 1, 'nobody is credited with the kill')
|
||||
assert.equal(stats[0].params[4], 1, 'it is still a death')
|
||||
assert.equal(stats[0].params[5], 1, 'and a suicide')
|
||||
assert.equal(stats[0].params[3], 0)
|
||||
})
|
||||
|
||||
test('an environment or NPC death credits no attacker', async () => {
|
||||
for (const attackerType of ['environment', 'npc']) {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
await apply('main', item('player.death', { steamId: 'victim', attackerType }))
|
||||
|
||||
const stats = rec.touching('rust_player_wipe_stats')
|
||||
assert.equal(stats.length, 1, `${attackerType} must credit nobody`)
|
||||
assert.equal(stats[0].params[4], 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('an absent session length adds no playtime and no session', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
// A player who was already on the server when the plugin loaded: the plugin
|
||||
// omits `sessionSec` rather than sending 0, and the difference has to survive
|
||||
// all the way to the column. Adding a zero would record a session of no
|
||||
// length, which is a different claim from recording no session.
|
||||
await apply('main', item('player.disconnected', { steamId: 'p1', reason: 'quit' }))
|
||||
|
||||
const stats = rec.touching('rust_player_wipe_stats')
|
||||
assert.equal(stats[0].params[8], 0, 'no session counted')
|
||||
assert.equal(stats[0].params[9], 0, 'no playtime added')
|
||||
|
||||
const rec2 = withRecorder()
|
||||
await require('../ingest').apply(
|
||||
'main',
|
||||
item('player.disconnected', { steamId: 'p1', sessionSec: 600 }),
|
||||
)
|
||||
|
||||
const counted = rec2.touching('rust_player_wipe_stats')
|
||||
assert.equal(counted[0].params[8], 1)
|
||||
assert.equal(counted[0].params[9], 600)
|
||||
})
|
||||
|
||||
test('a tally is added per resource, as a delta', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
await apply(
|
||||
'main',
|
||||
item('player.tally', {
|
||||
steamId: 'p1',
|
||||
gathered: { wood: 1200, stones: 300 },
|
||||
npcKills: 3,
|
||||
structures: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
const gathered = rec.touching('rust_gather_totals')
|
||||
assert.equal(gathered.length, 2)
|
||||
assert.deepEqual(
|
||||
gathered.map((g) => [g.params[3], g.params[4]]),
|
||||
[
|
||||
['wood', 1200],
|
||||
['stones', 300],
|
||||
],
|
||||
)
|
||||
|
||||
const stats = rec.touching('rust_player_wipe_stats')
|
||||
assert.equal(stats[0].params[6], 3, 'npc kills')
|
||||
assert.equal(stats[0].params[7], 2, 'structures')
|
||||
|
||||
// `amount = amount + VALUES(amount)` is what makes a delta correct. A running
|
||||
// total on the wire would double every number here, slowly, looking right.
|
||||
assert.match(gathered[0].sql, /amount = amount \+ VALUES\(amount\)/)
|
||||
})
|
||||
|
||||
test('a new server starts at the feed tail, not at the beginning of history', async () => {
|
||||
withRecorder()
|
||||
|
||||
const sidecar = require('../sidecarClient')
|
||||
const db = require('../model/events/events.db')
|
||||
const ingest = require('../ingest')
|
||||
|
||||
const originalTail = sidecar.feedTail
|
||||
const originalCursor = db.getCursor
|
||||
const originalSet = db.setCursor
|
||||
const written = []
|
||||
|
||||
db.getCursor = async () => null
|
||||
db.setCursor = async (...args) => written.push(args)
|
||||
sidecar.feedTail = async () => ({ ok: true, status: 'ok', data: { lastId: 4021, items: [] } })
|
||||
|
||||
try {
|
||||
const applied = await ingest.ingestServer({ id: 'main' })
|
||||
|
||||
assert.equal(applied, 0, 'nothing is replayed')
|
||||
assert.deepEqual(written, [['main', 4021, 0]], 'the cursor starts at the end')
|
||||
} finally {
|
||||
sidecar.feedTail = originalTail
|
||||
db.getCursor = originalCursor
|
||||
db.setCursor = originalSet
|
||||
}
|
||||
})
|
||||
|
||||
test('an unreachable sidecar writes no cursor at all', async () => {
|
||||
withRecorder()
|
||||
|
||||
const sidecar = require('../sidecarClient')
|
||||
const db = require('../model/events/events.db')
|
||||
const ingest = require('../ingest')
|
||||
|
||||
const originalTail = sidecar.feedTail
|
||||
const originalCursor = db.getCursor
|
||||
const originalSet = db.setCursor
|
||||
const written = []
|
||||
|
||||
db.getCursor = async () => null
|
||||
db.setCursor = async (...args) => written.push(args)
|
||||
sidecar.feedTail = async () => ({ ok: false, status: 'transport-error', data: null })
|
||||
|
||||
try {
|
||||
await ingest.ingestServer({ id: 'main' })
|
||||
|
||||
// A cursor of 0 written here would replay the sidecar's whole retained
|
||||
// history the moment it came back — which is the failure that looks like a
|
||||
// working catch-up until somebody reads the leaderboard.
|
||||
assert.deepEqual(written, [])
|
||||
} finally {
|
||||
sidecar.feedTail = originalTail
|
||||
db.getCursor = originalCursor
|
||||
db.setCursor = originalSet
|
||||
}
|
||||
})
|
||||
|
||||
test('the cursor advances after the batch, and one bad event does not wedge it', async () => {
|
||||
withRecorder()
|
||||
|
||||
const sidecar = require('../sidecarClient')
|
||||
const db = require('../model/events/events.db')
|
||||
const ingest = require('../ingest')
|
||||
|
||||
const originals = {
|
||||
feed: sidecar.feed,
|
||||
getCursor: db.getCursor,
|
||||
setCursor: db.setCursor,
|
||||
insertEvent: db.insertEvent,
|
||||
}
|
||||
|
||||
const order = []
|
||||
|
||||
db.getCursor = async () => ({ lastEventId: 10 })
|
||||
db.setCursor = async (_id, last) => order.push(`cursor:${last}`)
|
||||
db.insertEvent = async (row) => {
|
||||
order.push(`event:${row.kind}`)
|
||||
if (row.kind === 'player.chat') throw new Error('malformed')
|
||||
}
|
||||
|
||||
sidecar.feed = async (_server, since) =>
|
||||
since === 10
|
||||
? {
|
||||
ok: true,
|
||||
status: 'ok',
|
||||
data: {
|
||||
items: [item('player.chat'), item('player.connected', { steamId: 'p1' })],
|
||||
lastId: 12,
|
||||
more: false,
|
||||
},
|
||||
}
|
||||
: { ok: true, status: 'ok', data: { items: [], lastId: since, more: false } }
|
||||
|
||||
try {
|
||||
const applied = await ingest.ingestServer({ id: 'main' })
|
||||
|
||||
// The bad row is logged and skipped; the good one still counts.
|
||||
assert.equal(applied, 1)
|
||||
|
||||
// And the ordering the whole design rests on: every event is written before
|
||||
// the cursor moves past it.
|
||||
assert.deepEqual(order, ['event:player.chat', 'event:player.connected', 'cursor:12'])
|
||||
} finally {
|
||||
Object.assign(db, {
|
||||
getCursor: originals.getCursor,
|
||||
setCursor: originals.setCursor,
|
||||
insertEvent: originals.insertEvent,
|
||||
})
|
||||
sidecar.feed = originals.feed
|
||||
}
|
||||
})
|
||||
|
||||
test('a board replaces presence rather than appending to it', async () => {
|
||||
const rec = withRecorder()
|
||||
const ingest = require('../ingest')
|
||||
|
||||
await ingest.applyBoards('main', {
|
||||
'players.online': {
|
||||
kind: 'players.online',
|
||||
type: 'snapshot',
|
||||
count: 1,
|
||||
players: [{ steamId: 'p1', name: 'One', sleeping: false }],
|
||||
},
|
||||
})
|
||||
|
||||
const presence = rec.touching('rust_presence')
|
||||
|
||||
// The DELETE is what makes it a board. Without it a player who left stays
|
||||
// online for ever, which is the exact drift the board exists to correct.
|
||||
assert.match(presence[0].sql, /^DELETE FROM rust_presence/)
|
||||
assert.match(presence[1].sql, /INSERT INTO rust_presence/)
|
||||
})
|
||||
116
server/test/refresh.test.js
Normal file
116
server/test/refresh.test.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// ── What a refresh writes when nobody answers ─────────────────────────────
|
||||
//
|
||||
// The refresh loop has three outcomes (see `boot.js`), and the two unhappy ones
|
||||
// are the interesting half of this module's promise: the site renders the last
|
||||
// thing each server said **while every server is off**. A page can only do that
|
||||
// if the row still holds what the server said.
|
||||
//
|
||||
// The defect this suite exists for shipped in phase 3 and was found by walking
|
||||
// phase 4's own pages: an unreachable refresh called `putState` with two fields,
|
||||
// and `putState` replaces the row — so the first time a game host rebooted, the
|
||||
// hostname, the map, the size, the seed and the wipe id were all set to NULL.
|
||||
// The list then read "Offline" with nothing beside it, which is not "here is
|
||||
// what we know about a server that is down", it is "we have never heard of it".
|
||||
//
|
||||
// It is invisible to any test that stubs a sidecar which answers, which is why
|
||||
// there was not one.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
function withCore(ctx = fakeCtx()) {
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** The columns a description lives in — the ones an unreachable write must not touch. */
|
||||
const DESCRIPTION = ['hostname', 'level', 'seed', 'world_size', 'boot_id', 'save_created_at', 'wipe_id']
|
||||
|
||||
test('an unreachable refresh does not write the description columns at all', async () => {
|
||||
const queries = []
|
||||
withCore(fakeCtx({
|
||||
db: {
|
||||
query: (sql, params) => {
|
||||
queries.push({ sql, params })
|
||||
return Promise.resolve([])
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
}))
|
||||
|
||||
const db = require('../model/servers/servers.db')
|
||||
await db.markUnreachable('main', false)
|
||||
|
||||
assert.equal(queries.length, 1)
|
||||
const { sql, params } = queries[0]
|
||||
|
||||
// Asserted against the SQL rather than against a round trip, because the whole
|
||||
// failure is about which columns a statement mentions. A column named here is
|
||||
// a column that can be nulled.
|
||||
for (const column of DESCRIPTION) {
|
||||
assert.ok(!sql.includes(column), `markUnreachable writes ${column}, which is the server's description`)
|
||||
}
|
||||
|
||||
assert.ok(sql.includes('reachable'))
|
||||
assert.ok(sql.includes('online'))
|
||||
assert.ok(sql.includes('updated_at'))
|
||||
assert.deepStrictEqual(params, ['main', 0])
|
||||
})
|
||||
|
||||
test('a sidecar that is up with no game behind it is reachable and offline', async () => {
|
||||
// The middle outcome, and the one that is easy to collapse into the other two:
|
||||
// a fresh install whose plugin is not loaded yet. Reporting it as unreachable
|
||||
// sends an operator to look at the network instead of at the game server.
|
||||
const queries = []
|
||||
withCore(fakeCtx({
|
||||
db: {
|
||||
query: (sql, params) => {
|
||||
queries.push({ sql, params })
|
||||
return Promise.resolve([])
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
}))
|
||||
|
||||
await require('../model/servers/servers.db').markUnreachable('main', true)
|
||||
assert.deepStrictEqual(queries[0].params, ['main', 1])
|
||||
})
|
||||
|
||||
test('neither unhappy path calls putState', async () => {
|
||||
// The regression in one assertion: `putState` is the whole-row write, and
|
||||
// calling it with two fields is what blanked the description.
|
||||
withCore(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
|
||||
|
||||
const db = require('../model/servers/servers.db')
|
||||
const sidecar = require('../sidecarClient')
|
||||
const boot = require('../boot')
|
||||
|
||||
const originalPut = db.putState
|
||||
const originalMark = db.markUnreachable
|
||||
const originalBoards = sidecar.boards
|
||||
const marked = []
|
||||
let putCalls = 0
|
||||
|
||||
db.putState = async () => { putCalls += 1 }
|
||||
db.markUnreachable = async (id, reachable) => { marked.push([id, reachable]) }
|
||||
|
||||
try {
|
||||
// Nothing answered.
|
||||
sidecar.boards = async () => ({ ok: false, status: 0, data: null })
|
||||
await boot.refreshOne({ id: 'main', baseUrl: 'http://127.0.0.1:1', token: 't', protocol: 2 })
|
||||
|
||||
// The sidecar answered, and has never heard from a game.
|
||||
sidecar.boards = async () => ({ ok: true, status: 200, data: { boards: {} } })
|
||||
await boot.refreshOne({ id: 'main', baseUrl: 'http://127.0.0.1:1', token: 't', protocol: 2 })
|
||||
|
||||
assert.equal(putCalls, 0, 'an unhappy refresh replaced the whole state row')
|
||||
assert.deepStrictEqual(marked, [['main', false], ['main', true]])
|
||||
} finally {
|
||||
db.putState = originalPut
|
||||
db.markUnreachable = originalMark
|
||||
sidecar.boards = originalBoards
|
||||
}
|
||||
})
|
||||
@@ -97,10 +97,96 @@ test('the public shape carries nothing about the sidecar', () => {
|
||||
// someone who did not read this file, and an allowlist is the only assertion
|
||||
// that catches one.
|
||||
assert.deepStrictEqual(Object.keys(shaped).sort(), [
|
||||
'hostname', 'id', 'level', 'maxPlayers', 'name', 'online', 'players', 'seed', 'stale', 'updatedAt', 'worldSize',
|
||||
'hostname', 'id', 'lastSeenAt', 'level', 'maxPlayers', 'name', 'online', 'players', 'seed', 'stale',
|
||||
'updatedAt', 'wipeId', 'wipedAt', 'worldSize',
|
||||
])
|
||||
})
|
||||
|
||||
test('"last reported" is when a frame arrived, not when we last polled', () => {
|
||||
withCore()
|
||||
const servers = require('../model/servers/servers.model')
|
||||
|
||||
// The defect the phase-4 page walk found, in one assertion. A refresh that
|
||||
// cannot reach a sidecar still writes `updated_at` — it has to, because that is
|
||||
// what staleness is computed from — and a page reading it as "last reported"
|
||||
// told a reader that a server which had been down for days had reported just
|
||||
// now, every thirty seconds, for as long as it stayed down.
|
||||
const state = stateRow({
|
||||
online: 0,
|
||||
reachable: 0,
|
||||
updatedAt: new Date(NOW - 5_000).toISOString(),
|
||||
lastSeenAt: new Date(NOW - 3 * 86400_000).toISOString(),
|
||||
})
|
||||
|
||||
const shaped = servers.shapePublic(serverRow(), state, NOW)
|
||||
assert.strictEqual(shaped.lastSeenAt, new Date(NOW - 3 * 86400_000).toISOString())
|
||||
assert.strictEqual(shaped.stale, false, 'the row itself is fresh — it was written five seconds ago')
|
||||
assert.strictEqual(shaped.online, false)
|
||||
|
||||
// A server nothing has ever heard from has no such moment, and `null` is what
|
||||
// a page renders as "never" rather than as the epoch.
|
||||
assert.strictEqual(servers.shapePublic(serverRow(), undefined, NOW).lastSeenAt, null)
|
||||
})
|
||||
|
||||
test('the public shape carries the current wipe, from the state row', () => {
|
||||
withCore()
|
||||
const servers = require('../model/servers/servers.model')
|
||||
|
||||
// The wipe id on the STATE row, not the newest row in `rust_wipes`. The two
|
||||
// usually agree, and the state row is the one that is right when they do not:
|
||||
// the wipe list is derived from events that have been ingested, so a server
|
||||
// that has just wiped and said nothing since has a new id here and no row there.
|
||||
const shaped = servers.shapePublic(serverRow(), stateRow({ wipeId: 'w-2026-09', saveCreatedAt: '2026-09-04T18:00:00Z' }), NOW)
|
||||
assert.strictEqual(shaped.wipeId, 'w-2026-09')
|
||||
assert.strictEqual(shaped.wipedAt, '2026-09-04T18:00:00Z')
|
||||
|
||||
// A server nothing has polled yet has no wipe, and `null` is the honest answer
|
||||
// — an empty string would be sent back as `?wipe=`, which asks a different
|
||||
// question and answers nothing.
|
||||
const never = servers.shapePublic(serverRow(), undefined, NOW)
|
||||
assert.strictEqual(never.wipeId, null)
|
||||
assert.strictEqual(never.wipedAt, null)
|
||||
})
|
||||
|
||||
test('a disabled server is not there, rather than forbidden', async () => {
|
||||
withCore()
|
||||
|
||||
const db = require('../model/servers/servers.db')
|
||||
const model = require('../model/servers/servers.model')
|
||||
const originalServer = db.getServer
|
||||
const originalState = db.getState
|
||||
|
||||
db.getState = async () => stateRow()
|
||||
|
||||
try {
|
||||
// The detail route is the only one under `/servers/:id` that can say "no such
|
||||
// server" — the other four answer an empty list, because an unknown id
|
||||
// genuinely has no events. So what `null` means here decides what a page
|
||||
// renders, and a disabled server and a missing one must mean the same thing:
|
||||
// an operator who switched a server off did not switch it into a 403.
|
||||
db.getServer = async () => ({ ...serverRow(), enabled: 0 })
|
||||
assert.strictEqual(await model.getPublic('main', NOW), null)
|
||||
|
||||
db.getServer = async () => null
|
||||
assert.strictEqual(await model.getPublic('nope', NOW), null)
|
||||
|
||||
// And an id nobody asked about never reaches the database.
|
||||
let asked = false
|
||||
db.getServer = async () => { asked = true; return null }
|
||||
assert.strictEqual(await model.getPublic('', NOW), null)
|
||||
assert.strictEqual(asked, false)
|
||||
|
||||
db.getServer = async () => serverRow()
|
||||
const server = await model.getPublic('main', NOW)
|
||||
assert.strictEqual(server.id, 'main')
|
||||
assert.strictEqual(server.online, true)
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(server, 'sidecarBaseUrl'))
|
||||
} finally {
|
||||
db.getServer = originalServer
|
||||
db.getState = originalState
|
||||
}
|
||||
})
|
||||
|
||||
test('the admin shape reports whether a token is stored, never the token', () => {
|
||||
withCore()
|
||||
const servers = require('../model/servers/servers.model')
|
||||
|
||||
@@ -195,6 +195,203 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/rust/servers/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Rust"
|
||||
],
|
||||
"summary": "One Rust server",
|
||||
"description": "The same shape the list answers with, for one server, and a `404` when there is no such server or an operator has disabled it. The detail page needs the difference: every other route under this path answers an empty list for an id that does not exist, because an unknown server genuinely has no events and nobody online.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The server’s slug"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The server"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such server, or it is disabled"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/rust/servers/{id}/events": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Rust"
|
||||
],
|
||||
"summary": "Recent events on one Rust server",
|
||||
"description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The server’s slug"
|
||||
},
|
||||
{
|
||||
"name": "kind",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "One kind, or several comma-separated",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "wipe",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Restrict to one wipe id",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Rows to return, capped at 200",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Recent events, newest first"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/rust/servers/{id}/leaderboard": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Rust"
|
||||
],
|
||||
"summary": "The leaderboard for one Rust server",
|
||||
"description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The server’s slug"
|
||||
},
|
||||
{
|
||||
"name": "wipe",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Restrict to one wipe id",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "kills, deaths, npcKills or playtime",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Rows to return, capped at 200",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The leaderboard"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/rust/servers/{id}/online": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Rust"
|
||||
],
|
||||
"summary": "Who is on one Rust server right now",
|
||||
"description": "Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The server’s slug"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Who is online"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/rust/servers/{id}/wipes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Rust"
|
||||
],
|
||||
"summary": "Every wipe this server has had",
|
||||
"description": "Newest first. A wipe id is derived by the bridge plugin from the save’s creation time and stamped on every frame, so it is the same id the events and the leaderboard are filtered by.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The server’s slug"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The wipes"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
Reference in New Issue
Block a user