feat: prerender every route + Gitea Actions deploy
Some checks failed
deploy / Build api + worker + web (push) Failing after 53s
deploy / Deploy moments-api to nikola (push) Has been skipped
deploy / Deploy moments-worker to frootmig (push) Has been skipped
deploy / Deploy web to oolon (push) Has been skipped

Make the site fully prerendered so a plain curl returns complete content
for every route (crawlers / AI screening tools see real text, not an empty
#root), while humans keep full client interactivity.

Prerender:
- Build-time per-route render: prefetch data, renderToString, inline the
  dehydrated react-query cache as window.__RQ_STATE__; client hydrateRoots
  and refetches live (activity stays fresh; crawlers get the baked snapshot).
- New entry-server.tsx + prerender/{prefetch,routes,meta}.ts + run-prerender.mjs;
  shared lib/ranges.ts keeps SSR and client query keys identical.
- pnpm build now: tsc -b -> vite client build -> ssr build -> prerender.
- API base absolute at build (VITE_API_BASE), relative /api/v1 in the browser.
- CSS imports moved to the client entry so the tree imports under Node.
- schema.org Person + Occupation JSON-LD and per-route title/description/og.
- UTC + explicit field widths on shared date formatting so SSR and client
  hydration match byte-for-byte (fixes hydration mismatch on /activity).
- Strip non-text gist content from the CV fetch (1MB -> 25KB gzipped page).

Deploy (Gitea Actions, replaces script/deploy.sh):
- deploy.yml: on push to main, lint/test gate, build api+worker as static
  musl binaries (pure-rustls, no glibc skew) + prerendered web, deploy each
  over SSH as gitea_ci with scoped sudo.
- refresh.yml: daily cron re-bakes only the web snapshot so gist/activity
  edits propagate without a push or bouncing the api/worker.
- script/infra-setup.sh + asset/sudoers.d/{api,worker,web}-host.conf for
  one-time per-host provisioning. Secrets: RSYNC_SSH_KEY, QUERY_GITHUB_TOKEN,
  QUERY_GITEA_TOKEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
This commit is contained in:
2026-06-25 12:53:46 +03:00
parent 70b4b265c3
commit 1b753f991f
27 changed files with 1390 additions and 24 deletions

354
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,354 @@
name: deploy
# Build and roll out all three moments components on a push to main:
# - moments-api (static musl binary, read-only API) -> nikola
# - moments-worker (static musl binary, ingestion daemon) -> frootmig
# - web (prerendered static SPA + nginx vhost) -> oolon
#
# This workflow is the source of infra truth (hosts, ports, paths live in `env`
# below — there is no manifest.yml in this model). It replaces script/deploy.sh:
# instead of an operator running it from a workstation with `pass`, a Gitea
# Actions runner deploys as the dedicated `gitea_ci` user over SSH, with secrets
# from the repo settings and scoped sudo (see asset/sudoers.d/ + script/infra-setup.sh).
#
# The api/worker binaries are pure-rustls (no openssl), so they build as fully
# static musl — a runner newer than the target host can't produce an unloadable
# glibc binary (architecture/deployment-gitea-actions.md §6).
#
# Secrets (Gitea -> repo -> Settings -> Actions -> Secrets):
# RSYNC_SSH_KEY private SSH key whose pubkey script/infra-setup.sh installed
# QUERY_GITHUB_TOKEN github api token for the worker's poller
# QUERY_GITEA_TOKEN git.lair.cafe api token for the worker's poller
# (GITHUB_TOKEN / GITEA_TOKEN are reserved Actions names, hence the QUERY_ prefix.)
#
# One-time per-host provisioning (gitea_ci user, authorized_keys, scoped
# sudoers, and the postgres mTLS host cert the api/worker need) is done by
# script/infra-setup.sh — run it once per host before this workflow can succeed.
on:
push:
branches: [main]
workflow_dispatch:
# Serialize deploys; never cancel an in-flight one (a half-applied rollout is
# worse than a slightly stale one).
concurrency:
group: deploy
cancel-in-progress: false
env:
# --- infra truth (mirrors the former asset/manifest.yml) ---
API_HOST: nikola.kosherinata.internal
WORKER_HOST: frootmig.kosherinata.internal
WEB_HOST: oolon.kosherinata.internal
API_BIND: 0.0.0.0:42424
API_PORT: "42424"
SERVER_NAME: rob.tn
WEB_ROOT: /var/www/rob.tn
API_UPSTREAM_SCHEME: http
API_UPSTREAM_ADDR: nikola.kosherinata.internal:42424
MUSL_TARGET: x86_64-unknown-linux-musl
# The prerender fetches the live public API at build time; the client bundle
# keeps the same-origin relative /api/v1 that nginx proxies.
VITE_API_BASE: https://rob.tn/api/v1
DEPLOY_KEY: |
${{ secrets.RSYNC_SSH_KEY }}
jobs:
build:
name: Build api + worker + web
runs-on: rust
steps:
- uses: actions/checkout@v4
# The `rust` runner image provides cargo/clippy/rustfmt, musl-gcc, the
# x86_64-unknown-linux-musl std, and node. No package installs at run time.
- name: Lint + test (deploy gate)
run: |
cargo fmt --check --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
- name: Build moments-api + moments-worker (static musl release)
run: cargo build --release --target "${MUSL_TARGET}" -p moments-api -p moments-worker
- name: Build web (vite client + prerender)
run: |
corepack enable
corepack prepare pnpm@10 --activate
pnpm --dir ui install --frozen-lockfile
pnpm --dir ui run build
- name: Stage binaries
run: |
mkdir --parents artifacts
cp "target/${MUSL_TARGET}/release/moments-api" artifacts/moments-api
cp "target/${MUSL_TARGET}/release/moments-worker" artifacts/moments-worker
- uses: actions/upload-artifact@v3
with: { name: moments-api, path: artifacts/moments-api, retention-days: 1 }
- uses: actions/upload-artifact@v3
with: { name: moments-worker, path: artifacts/moments-worker, retention-days: 1 }
- uses: actions/upload-artifact@v3
with: { name: web-dist, path: ui/dist, retention-days: 1 }
deploy-api:
name: Deploy moments-api to nikola
needs: build
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: moments-api, path: artifact }
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@"${API_HOST}" 'hostname -f'
- name: Render config + units from infra truth
run: |
mkdir -p rendered
# HOSTNAME is the host's own fqdn (used for the postgres mTLS cert path).
python3 - "$API_HOST" "$API_BIND" "$API_PORT" <<'PY'
import sys
host, bind, port = sys.argv[1:4]
def render(src, dst, subs):
t = open(src).read()
for k, v in subs.items():
t = t.replace("{{%s}}" % k, v)
open(dst, "w").write(t)
render("asset/config/api.env.tmpl", "rendered/api.env",
{"HOSTNAME": host, "BIND": bind})
render("asset/systemd/moments-api-cert.path", "rendered/moments-api-cert.path",
{"HOSTNAME": host})
render("asset/firewalld/moments-api.xml.tmpl", "rendered/moments-api.xml",
{"API_PORT": port})
PY
- name: Provision service account + directories
run: |
# --mkpath: /etc/sysusers.d and /etc/firewalld/services don't exist by
# default on Fedora (only the /usr/lib variants ship).
rsync -az --mkpath --rsync-path='sudo rsync' \
asset/systemd/moments.sysusers.conf \
gitea_ci@"${API_HOST}":/etc/sysusers.d/moments.conf
ssh gitea_ci@"${API_HOST}" '
set -euo pipefail
sudo /usr/bin/systemd-sysusers
sudo /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
sudo /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments'
- name: Sync binary, env, units, firewalld service
run: |
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0755 \
artifact/moments-api gitea_ci@"${API_HOST}":/usr/local/bin/moments-api
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:moments --chmod=0640 \
rendered/api.env gitea_ci@"${API_HOST}":/etc/moments/api.env
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-api.service \
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api.service
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/moments-api-cert.path \
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api-cert.path
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-api-cert-reload.service \
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api-cert-reload.service
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/moments-api.xml \
gitea_ci@"${API_HOST}":/etc/firewalld/services/moments-api.xml
- name: Apply SELinux + firewalld + cert ACL (idempotent)
run: |
ssh gitea_ci@"${API_HOST}" '
set -euo pipefail
fqdn="$(hostname -f)"
# Let the moments user read the host private key for postgres mTLS.
sudo /usr/bin/setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem"
sudo /usr/sbin/restorecon -R /usr/local/bin/moments-api /etc/moments /var/lib/moments
if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then
sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"'
fi
# firewalld only learns a freshly-shipped custom service after reload.
sudo /usr/bin/firewall-cmd --reload
if ! sudo /usr/bin/firewall-cmd --query-service=moments-api; then
sudo /usr/bin/firewall-cmd --add-service=moments-api --permanent
sudo /usr/bin/firewall-cmd --reload
fi'
- name: Restart moments-api
run: |
ssh gitea_ci@"${API_HOST}" '
set -euo pipefail
sudo /usr/bin/systemctl daemon-reload
sudo /usr/bin/systemctl enable --now moments-api-cert.path
sudo /usr/bin/systemctl enable moments-api.service
sudo /usr/bin/systemctl restart moments-api.service'
- name: Health probe
run: |
for i in 1 2 3 4 5 6 7 8 9 10; do
if ssh gitea_ci@"${API_HOST}" "curl -fsS http://127.0.0.1:${API_PORT}/v1/healthz"; then
echo; echo "moments-api healthy"; exit 0
fi
sleep 2
done
echo "moments-api did not become healthy" >&2
exit 1
- name: Capture startup journal
if: always()
run: |
sleep 3
ssh gitea_ci@"${API_HOST}" 'journalctl --unit moments-api.service --no-pager -n 200'
deploy-worker:
name: Deploy moments-worker to frootmig
needs: build
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: moments-worker, path: artifact }
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@"${WORKER_HOST}" 'hostname -f'
- name: Render worker config + cert.path from secrets
env:
# GITHUB_TOKEN / GITEA_TOKEN are reserved Actions secret names, so the
# repo secrets are QUERY_*; the rendered worker.env still uses the
# GITHUB_TOKEN / GITEA_TOKEN env vars the worker poller expects.
GITHUB_TOKEN: ${{ secrets.QUERY_GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.QUERY_GITEA_TOKEN }}
run: |
mkdir -p rendered
# Literal substitution via python so tokens with shell-special chars
# survive; secrets come from the environment, never a command line.
python3 - "$WORKER_HOST" <<'PY'
import os, sys
host = sys.argv[1]
t = open("asset/config/worker.env.tmpl").read()
subs = {
"HOSTNAME": host,
"GITHUB_TOKEN": os.environ.get("GITHUB_TOKEN", ""),
"GITEA_TOKEN": os.environ.get("GITEA_TOKEN", ""),
}
for k, v in subs.items():
t = t.replace("{{%s}}" % k, v)
open("rendered/worker.env", "w").write(t)
c = open("asset/systemd/moments-worker-cert.path").read().replace("{{HOSTNAME}}", host)
open("rendered/moments-worker-cert.path", "w").write(c)
PY
- name: Provision service account + directories
run: |
rsync -az --mkpath --rsync-path='sudo rsync' \
asset/systemd/moments.sysusers.conf \
gitea_ci@"${WORKER_HOST}":/etc/sysusers.d/moments.conf
ssh gitea_ci@"${WORKER_HOST}" '
set -euo pipefail
sudo /usr/bin/systemd-sysusers
sudo /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
sudo /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments'
- name: Sync binary, env, units
run: |
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0755 \
artifact/moments-worker gitea_ci@"${WORKER_HOST}":/usr/local/bin/moments-worker
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:moments --chmod=0640 \
rendered/worker.env gitea_ci@"${WORKER_HOST}":/etc/moments/worker.env
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-worker.service \
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker.service
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/moments-worker-cert.path \
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker-cert.path
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-worker-cert-reload.service \
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker-cert-reload.service
- name: Apply cert ACL + SELinux, restart worker
run: |
ssh gitea_ci@"${WORKER_HOST}" '
set -euo pipefail
fqdn="$(hostname -f)"
sudo /usr/bin/setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem"
sudo /usr/sbin/restorecon -R /usr/local/bin/moments-worker /etc/moments /var/lib/moments
sudo /usr/bin/systemctl daemon-reload
sudo /usr/bin/systemctl enable --now moments-worker-cert.path
sudo /usr/bin/systemctl enable moments-worker.service
sudo /usr/bin/systemctl restart moments-worker.service
sudo /usr/bin/systemctl is-active --quiet moments-worker.service'
- name: Capture startup journal
if: always()
run: |
sleep 3
ssh gitea_ci@"${WORKER_HOST}" 'journalctl --unit moments-worker.service --no-pager -n 200'
deploy-web:
name: Deploy web to oolon
needs: build
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: web-dist, path: dist }
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@"${WEB_HOST}" 'hostname -f'
- name: Render nginx vhost
run: |
mkdir -p rendered
python3 - <<'PY'
import os
t = open("asset/nginx/site.conf.tmpl").read()
subs = {
"SERVER_NAME": os.environ["SERVER_NAME"],
"DOCROOT": os.environ["WEB_ROOT"],
"API_UPSTREAM_SCHEME": os.environ["API_UPSTREAM_SCHEME"],
"API_UPSTREAM_ADDR": os.environ["API_UPSTREAM_ADDR"],
}
for k, v in subs.items():
t = t.replace("{{%s}}" % k, v)
open("rendered/site.conf", "w").write(t)
PY
- name: Sync static site (prerendered)
run: |
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/bin/install -d -m 0755 '"${WEB_ROOT}"
rsync -az --delete --mkpath --rsync-path='sudo rsync' \
--chown=root:root --chmod=D755,F644 \
dist/ gitea_ci@"${WEB_HOST}":"${WEB_ROOT}/"
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/sbin/restorecon -R '"${WEB_ROOT}"
- name: Sync nginx vhost + apply SELinux
run: |
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/site.conf \
gitea_ci@"${WEB_HOST}":/etc/nginx/conf.d/"${SERVER_NAME}".conf
ssh gitea_ci@"${WEB_HOST}" '
set -euo pipefail
# nginx proxies /api/ to the api host across the WG mesh.
sudo /usr/sbin/setsebool -P httpd_can_network_connect on
if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then
sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"'
fi
sudo /usr/sbin/restorecon -R /etc/nginx/conf.d/'"${SERVER_NAME}"'.conf
sudo /usr/sbin/nginx -t
sudo /usr/bin/systemctl reload nginx'

View File

@@ -0,0 +1,98 @@
name: refresh
# Daily re-bake of the prerendered site. The crawler-visible HTML is a static
# snapshot taken at build time; this job rebuilds it from the current gist (CV)
# and activity API and redeploys *only* the web tier — so edits propagate to
# what crawlers and AI screeners see without a code push and without bouncing
# the api/worker. Humans already get live data via post-hydration refetch.
#
# Uses the same gitea_ci + scoped-sudo path as deploy.yml's deploy-web job;
# see asset/sudoers.d/web-host.conf and script/infra-setup.sh.
on:
schedule:
# 04:17 UTC daily — off-peak, arbitrary minute to avoid the top-of-hour herd.
- cron: '17 4 * * *'
workflow_dispatch:
concurrency:
group: deploy # share the deploy lock so a refresh never races a push deploy
cancel-in-progress: false
env:
WEB_HOST: oolon.kosherinata.internal
SERVER_NAME: rob.tn
WEB_ROOT: /var/www/rob.tn
API_PORT: "42424"
API_UPSTREAM_SCHEME: http
API_UPSTREAM_ADDR: nikola.kosherinata.internal:42424
VITE_API_BASE: https://rob.tn/api/v1
DEPLOY_KEY: |
${{ secrets.RSYNC_SSH_KEY }}
jobs:
build-web:
name: Rebuild prerendered web
runs-on: rust # image has node; we only need the web toolchain here
steps:
- uses: actions/checkout@v4
- name: Build web (vite client + prerender)
run: |
corepack enable
corepack prepare pnpm@10 --activate
pnpm --dir ui install --frozen-lockfile
pnpm --dir ui run build
- uses: actions/upload-artifact@v3
with: { name: web-dist, path: ui/dist, retention-days: 1 }
deploy-web:
name: Deploy refreshed web to oolon
needs: build-web
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: web-dist, path: dist }
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@"${WEB_HOST}" 'hostname -f'
- name: Render nginx vhost
run: |
mkdir -p rendered
python3 - <<'PY'
import os
t = open("asset/nginx/site.conf.tmpl").read()
for k in ("SERVER_NAME", "API_UPSTREAM_SCHEME", "API_UPSTREAM_ADDR"):
t = t.replace("{{%s}}" % k, os.environ[k])
t = t.replace("{{DOCROOT}}", os.environ["WEB_ROOT"])
open("rendered/site.conf", "w").write(t)
PY
- name: Sync static site (prerendered)
run: |
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/bin/install -d -m 0755 '"${WEB_ROOT}"
rsync -az --delete --mkpath --rsync-path='sudo rsync' \
--chown=root:root --chmod=D755,F644 \
dist/ gitea_ci@"${WEB_HOST}":"${WEB_ROOT}/"
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/sbin/restorecon -R '"${WEB_ROOT}"
- name: Sync nginx vhost + reload
run: |
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/site.conf \
gitea_ci@"${WEB_HOST}":/etc/nginx/conf.d/"${SERVER_NAME}".conf
ssh gitea_ci@"${WEB_HOST}" '
set -euo pipefail
sudo /usr/sbin/setsebool -P httpd_can_network_connect on
if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then
sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"'
fi
sudo /usr/sbin/restorecon -R /etc/nginx/conf.d/'"${SERVER_NAME}"'.conf
sudo /usr/sbin/nginx -t
sudo /usr/bin/systemctl reload nginx'

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@
# frontend # frontend
/ui/node_modules /ui/node_modules
/ui/dist /ui/dist
/ui/dist-server
/ui/.vite /ui/.vite
*.tsbuildinfo *.tsbuildinfo

View File

@@ -62,9 +62,19 @@ cd ui
pnpm install # install deps pnpm install # install deps
pnpm dev # dev server on :5173 (proxies /api/* to localhost:8080) pnpm dev # dev server on :5173 (proxies /api/* to localhost:8080)
pnpm lint # tsc --noEmit type-check pnpm lint # tsc --noEmit type-check
pnpm build # production build (tsc -b && vite build) pnpm build # production build: client bundle, then prerender
``` ```
The build is three steps (see `ui/package.json`): `tsc -b``vite build` (client
SPA) → `pnpm run prerender` (an SSR build of `src/entry-server.tsx`, driven by
`run-prerender.mjs`, that bakes one static `index.html` per route into `ui/dist/`).
The prerender fetches data at build time from `VITE_API_BASE` (default
`https://rob.tn/api/v1`) and inlines the dehydrated react-query cache as
`window.__RQ_STATE__`; the client hydrates it and refetches live. So a plain
`curl` of any route returns full content (for crawlers / AI screeners), while the
browser keeps full interactivity. Date formatting in the shared tree is pinned to
UTC + explicit field widths so SSR and client hydration match byte-for-byte.
## Database ## Database
PostgreSQL with three migrations in `crates/moments-data/migrations/`. Two roles: `moments_rw` (worker, full access) and `moments_ro` (API, SELECT-only). PostgreSQL with three migrations in `crates/moments-data/migrations/`. Two roles: `moments_rw` (worker, full access) and `moments_ro` (API, SELECT-only).
@@ -77,4 +87,26 @@ Blog posts are markdown files with YAML frontmatter (`title`, `slug`, `date`; op
## Deployment ## Deployment
Production uses `./script/deploy.sh`. Services run under systemd with hardened units. Secrets resolved from `pass` store via template substitution. Nginx reverse-proxies `/api/` to the API host. CI-driven via **Gitea Actions** (`.gitea/workflows/`), the source of infra truth
(hosts/ports/paths live in the workflow `env`, not a manifest):
- `deploy.yml` — on push to `main` (or manual dispatch): lint/test gate, build the
api + worker as static musl binaries (pure-rustls, so no glibc skew) and the
prerendered web bundle, then deploy each component over SSH as the `gitea_ci`
user with scoped sudo (`asset/sudoers.d/`). Services run under systemd with
hardened units; the api/worker reach postgres over mTLS using the host cert.
- `refresh.yml` — daily `schedule:` (+ manual): rebuilds and redeploys only the
web tier, re-baking the prerendered crawler snapshot from the current gist (CV)
and activity API without bouncing the api/worker.
One-time per-host provisioning (the `gitea_ci` user, its `authorized_keys`, the
scoped sudoers drop-in) is `script/infra-setup.sh`, run once per host by an
operator. Gitea repo secrets: `RSYNC_SSH_KEY`, `QUERY_GITHUB_TOKEN`,
`QUERY_GITEA_TOKEN` (the bare `GITHUB_TOKEN`/`GITEA_TOKEN` names are reserved by
Actions, so the worker poller's tokens use the `QUERY_` prefix).
Nginx reverse-proxies `/api/` to the API host and serves the per-route static
files via `try_files $uri $uri/ /index.html`.
`./script/deploy.sh` is the legacy operator-driven path (workstation + `pass`);
it still works and the Gitea workflow supersedes it. Remove it once the workflow
is validated on the live hosts.

View File

@@ -0,0 +1,35 @@
# Scoped sudo for the gitea_ci deploy user on the moments-api host (nikola).
# Installed by script/infra-setup.sh as /etc/sudoers.d/moments_api_gitea_ci and
# verified with `visudo -cf`. Every rule is pinned to one literal destination;
# the `*` in an rsync rule matches rsync's --server argument vector, the trailing
# path is what actually bounds it. `:` and `=` are escaped (sudoers reserves them).
# --- file pushes (rsync --rsync-path='sudo rsync') ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/moments.conf
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/moments-api
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/moments/api.env
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-api-cert.path
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-api-cert-reload.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/moments-api.xml
# --- service account + directories ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments
# --- cert ACL, SELinux, firewalld ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/setfacl -m u\:moments\:r /etc/pki/tls/private/*.pem
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/moments-api /etc/moments /var/lib/moments
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -l
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -a -t http_port_t -p tcp 42424
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --query-service\=moments-api
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --add-service\=moments-api --permanent
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --add-service\=moments-api
# --- service lifecycle ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable --now moments-api-cert.path
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable moments-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart moments-api.service

View File

@@ -0,0 +1,20 @@
# Scoped sudo for the gitea_ci deploy user on the web host (oolon). Installed by
# script/infra-setup.sh as /etc/sudoers.d/moments_web_gitea_ci and verified with
# `visudo -cf`. Used by both deploy.yml's deploy-web and refresh.yml.
# See api-host.conf for the rsync-rule convention.
# --- docroot + static site ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -m 0755 /var/www/rob.tn
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/rob.tn/
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /var/www/rob.tn
# --- nginx vhost ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/nginx/conf.d/rob.tn.conf
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /etc/nginx/conf.d/rob.tn.conf
# --- SELinux booleans/ports for the /api proxy + reload ---
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/setsebool -P httpd_can_network_connect on
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -l
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -a -t http_port_t -p tcp 42424
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/nginx -t
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx

View File

@@ -0,0 +1,27 @@
# Scoped sudo for the gitea_ci deploy user on the moments-worker host (frootmig).
# Installed by script/infra-setup.sh as /etc/sudoers.d/moments_worker_gitea_ci and
# verified with `visudo -cf`. See api-host.conf for the rsync-rule convention.
# --- file pushes (rsync --rsync-path='sudo rsync') ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/moments.conf
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/moments-worker
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/moments/worker.env
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-worker.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-worker-cert.path
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-worker-cert-reload.service
# --- service account + directories ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments
# --- cert ACL, SELinux ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/setfacl -m u\:moments\:r /etc/pki/tls/private/*.pem
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/moments-worker /etc/moments /var/lib/moments
# --- service lifecycle ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable --now moments-worker-cert.path
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable moments-worker.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart moments-worker.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl is-active --quiet moments-worker.service

132
script/infra-setup.sh Executable file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env bash
#
# One-time setup for the gitea_ci deploy-user on every host that
# .gitea/workflows/{deploy,refresh}.yml targets:
# - create the gitea_ci system user (if missing)
# - install the runner's pubkey into ~gitea_ci/.ssh/authorized_keys
# - add gitea_ci to the systemd-journal group (so the workflow can capture
# `journalctl -u moments-*.service` without a sudoers entry)
# - install the host-appropriate /etc/sudoers.d/moments_*_gitea_ci drop-in,
# verified with `visudo -cf` so a typo can't lock the host out
# - check the postgres mTLS host cert the api/worker need already exists
#
# Run this from a workstation with ssh + sudo access to the hosts, once per
# host, before the deploy workflow can succeed. Idempotent — safe to re-run.
# Application config is NOT shipped here: the workflow renders /etc/moments/*.env
# from Gitea secrets on every deploy.
#
# Never suppresses errors; hosts that fail to provision are reported and skipped
# so one offline host doesn't block the rest.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_path="$(cd "${script_dir}/.." && pwd)"
# host -> sudoers drop-in (the source of infra truth, mirroring the workflow env)
api_host="nikola.kosherinata.internal"
worker_host="frootmig.kosherinata.internal"
web_host="oolon.kosherinata.internal"
api_sudoers="${repo_path}/asset/sudoers.d/api-host.conf"
worker_sudoers="${repo_path}/asset/sudoers.d/worker-host.conf"
web_sudoers="${repo_path}/asset/sudoers.d/web-host.conf"
pubkey="${HOME}/.ssh/id_gitea_ci.pub"
if [[ ! -f "${pubkey}" ]]; then
echo "fatal: ${pubkey} not found" >&2
echo " generate with: ssh-keygen -t ed25519 -f ${pubkey%.pub} -C gitea_ci" >&2
echo " then add the matching private key as the RSYNC_SSH_KEY Gitea secret" >&2
exit 1
fi
# Create gitea_ci, install its authorized_keys, and grant journal read access.
provision_user() {
local host="$1"
echo "==> ${host}: provisioning gitea_ci"
if ! ssh "${host}" '
set -eu
if id -u gitea_ci >/dev/null 2>&1; then
echo " gitea_ci user already present"
else
sudo useradd --system --create-home \
--home-dir /var/lib/gitea_ci --shell /bin/bash gitea_ci
echo " gitea_ci user created"
fi
# `install -o` does its own fresh user lookup, avoiding the brief NSS
# cache lag that makes `sudo -u gitea_ci` fail right after useradd.
sudo install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
sudo usermod -aG systemd-journal gitea_ci
'; then
echo " failed to provision gitea_ci — skipping ${host}"
return 1
fi
if rsync --archive --compress \
--chown gitea_ci:gitea_ci --chmod 0600 \
--rsync-path 'sudo rsync' \
"${pubkey}" \
"${host}:/var/lib/gitea_ci/.ssh/authorized_keys"; then
echo " authorized_keys synced"
else
echo " failed to sync authorized_keys to ${host}"
return 1
fi
}
# Install the sudoers drop-in and verify it parses, so a typo can't lock out.
install_sudoers() {
local host="$1" template="$2" name="$3"
local dest="/etc/sudoers.d/${name}"
echo "==> ${host}: installing ${dest}"
if ! rsync --archive --compress \
--chown root:root --chmod 0440 \
--rsync-path 'sudo rsync' \
"${template}" \
"${host}:${dest}"; then
echo " failed to sync ${template##*/}"
return 1
fi
if ssh "${host}" "sudo visudo -cf ${dest}" >/dev/null; then
echo " installed and verified"
else
echo " WARNING: visudo rejected the installed file — review on ${host}"
return 1
fi
}
# The api/worker connect to postgres with mTLS using the host's own cert. The
# workflow only ACLs it to the moments user; it must already exist (provisioned
# by the host's PKI/step convention — see architecture/internal-tls.md).
check_pg_cert() {
local host="$1"
echo "==> ${host}: checking postgres mTLS host cert"
if ssh "${host}" '
fqdn="$(hostname -f)"
test -f "/etc/pki/tls/private/${fqdn}.pem" && test -f "/etc/pki/tls/misc/${fqdn}.pem"
'; then
echo " host cert present"
else
echo " WARNING: /etc/pki/tls/{private,misc}/<fqdn>.pem missing on ${host}"
echo " the moments-{api,worker} service will fail to reach postgres until it exists"
fi
}
setup_host() {
local host="$1" sudoers="$2" name="$3"
provision_user "${host}" && install_sudoers "${host}" "${sudoers}" "${name}" \
|| { echo " ${host}: setup incomplete"; return 1; }
}
setup_host "${api_host}" "${api_sudoers}" moments_api_gitea_ci
check_pg_cert "${api_host}"
setup_host "${worker_host}" "${worker_sudoers}" moments_worker_gitea_ci
check_pg_cert "${worker_host}"
setup_host "${web_host}" "${web_sudoers}" moments_web_gitea_ci
echo "==> done."
echo " Gitea repo secrets to set (Settings -> Actions -> Secrets):"
echo " RSYNC_SSH_KEY private key matching ${pubkey}"
echo " QUERY_GITHUB_TOKEN github api token for the worker poller"
echo " QUERY_GITEA_TOKEN git.lair.cafe api token for the worker poller"
echo " (GITHUB_TOKEN / GITEA_TOKEN are reserved Actions names — hence QUERY_.)"

View File

@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && VITE_API_BASE= vite build && pnpm run prerender",
"prerender": "VITE_API_BASE=\"${VITE_API_BASE:-https://rob.tn/api/v1}\" vite build --ssr src/entry-server.tsx --outDir dist-server --emptyOutDir && node run-prerender.mjs",
"preview": "vite preview", "preview": "vite preview",
"lint": "tsc --noEmit" "lint": "tsc --noEmit"
}, },

127
ui/run-prerender.mjs Normal file
View File

@@ -0,0 +1,127 @@
// Drives the SSR bundle (built by `vite build --ssr`) to bake one static HTML
// file per route into dist/. Reads the client build's dist/index.html as the
// template, then for each route injects: route <title>/description/og tags, a
// schema.org JSON-LD block, the server-rendered markup into #root, and the
// dehydrated react-query cache as window.__RQ_STATE__ for hydration.
//
// nginx serves these via `try_files $uri $uri/ /index.html`: /cv → cv/index.html,
// /blog/<slug> → blog/<slug>/index.html, etc., with unbaked routes (e.g.
// /activity/:timespan) SPA-falling-back to the home page and rendering client-side.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const distDir = join(here, 'dist');
const SITE_URL = 'https://rob.tn';
const { collectRoutes, renderRoute } = await import(
pathToFileURL(join(here, 'dist-server', 'entry-server.js')).href
);
const escapeText = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapeAttr = (s) => escapeText(s).replace(/"/g, '&quot;');
// JS line/paragraph separators are valid in JSON strings but illegal in a
// <script>; written here as escape sequences so the source file stays ASCII.
const JS_LINE_SEPARATORS = new RegExp('[\u2028\u2029]', 'g');
/** Safe for embedding JSON inside an inline <script>: neutralise `<` (so a
* `</script>` in the data can't close the tag) and the line separators. */
const serialize = (value) =>
JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(JS_LINE_SEPARATORS, (c) => '\\u' + c.charCodeAt(0).toString(16));
function replaceOrAppendHead(html, regex, tag) {
return regex.test(html)
? html.replace(regex, tag)
: html.replace('</head>', ` ${tag}\n </head>`);
}
function applyHead(html, head, url) {
let out = html.replace(
/<title>[\s\S]*?<\/title>/,
`<title>${escapeText(head.title)}</title>`,
);
const desc = escapeAttr(head.description);
out = replaceOrAppendHead(
out,
/<meta\s+name="description"[^>]*>/i,
`<meta name="description" content="${desc}" />`,
);
out = replaceOrAppendHead(
out,
/<meta\s+property="og:title"[^>]*>/i,
`<meta property="og:title" content="${escapeAttr(head.title)}" />`,
);
out = replaceOrAppendHead(
out,
/<meta\s+property="og:description"[^>]*>/i,
`<meta property="og:description" content="${desc}" />`,
);
out = replaceOrAppendHead(
out,
/<meta\s+property="og:url"[^>]*>/i,
`<meta property="og:url" content="${escapeAttr(url)}" />`,
);
const ld = `<script type="application/ld+json">${serialize(head.jsonLd)}</script>`;
return out.replace('</head>', ` ${ld}\n </head>`);
}
function outPathFor(route) {
if (route === '/') return join(distDir, 'index.html');
return join(distDir, route.replace(/^\//, ''), 'index.html');
}
const template = await readFile(join(distDir, 'index.html'), 'utf8');
let routes = await collectRoutes();
// Optional filter args: `node run-prerender.mjs /activity /cv` bakes only the
// routes whose path starts with one of the given prefixes (useful for quick
// iteration). With no args, every route is baked.
const filters = process.argv.slice(2);
if (filters.length > 0) {
routes = routes.filter((r) => filters.some((f) => r === f || r.startsWith(f)));
}
let ok = 0;
let failed = 0;
for (const route of routes) {
const url = route === '/' ? `${SITE_URL}/` : `${SITE_URL}${route}`;
try {
const { html, state, head } = await renderRoute(route);
let page = applyHead(template, head, url);
page = page.replace(
'<div id="root"></div>',
`<div id="root">${html}</div>\n <script>window.__RQ_STATE__=${serialize(state)}</script>`,
);
const outPath = outPathFor(route);
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, page);
ok += 1;
console.log(`prerendered ${route}`);
} catch (err) {
// Don't fail the whole build for one route — write the bare template so the
// route still resolves and hydrates client-side, and surface the error.
failed += 1;
console.error(`prerender FAILED for ${route}: ${err?.stack || err}`);
const outPath = outPathFor(route);
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, template);
}
}
console.log(`prerender complete: ${ok} ok, ${failed} failed, ${routes.length} routes`);
// A handful of failed routes still ship a working client-rendered fallback, so
// don't block the deploy for those. Only fail the build if nothing prerendered
// at all (e.g. the API was unreachable), which signals a genuinely broken build.
if (ok === 0) {
console.error('prerender produced no pages — failing the build');
process.exitCode = 1;
}

View File

@@ -1,9 +1,8 @@
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import 'bootstrap/dist/css/bootstrap.min.css'; // CSS imports live in the client entry (`main.tsx`), not here, so this module
import 'rc-slider/assets/index.css'; // stays importable under Node during the prerender build (renderToString can't
import 'react-vertical-timeline-component/style.min.css'; // process CSS imports).
import './App.css';
import { Layout } from './components/Layout'; import { Layout } from './components/Layout';
import { DashPage } from './pages/DashPage'; import { DashPage } from './pages/DashPage';

View File

@@ -91,7 +91,10 @@ export interface EventQuery {
limit?: number; limit?: number;
} }
const API_BASE = '/api/v1'; // Browser builds use a same-origin relative base (nginx proxies `/api/`).
// The prerender build runs under Node, which has no relative-URL origin, so it
// sets VITE_API_BASE to the absolute public API. See `vite-env.d.ts`.
const API_BASE = import.meta.env.VITE_API_BASE || '/api/v1';
/** Decode base64 content as UTF-8 (atob only handles Latin-1). */ /** Decode base64 content as UTF-8 (atob only handles Latin-1). */
function decodeBase64Utf8(b64: string): string { function decodeBase64Utf8(b64: string): string {

View File

@@ -57,13 +57,24 @@ export async function fetchCv(): Promise<CvData> {
} }
const gist = (await resp.json()) as GistResponse; const gist = (await resp.json()) as GistResponse;
const cfgFile = gist.files[CONFIG_FILENAME]; // Drop inlined `content` for non-text files (the photo, company logos, and
// pdf/docx/odt exports). The CV only ever references those by their raw gist
// URL — keeping the base64 here would bloat every render, and balloons the
// prerendered /cv page by ~1 MB of uncompressible data.
const files: Record<string, GistFile> = {};
for (const [name, file] of Object.entries(gist.files)) {
const isText =
file.type.startsWith('text/') || file.type === 'application/json';
files[name] = isText ? file : { ...file, content: '' };
}
const cfgFile = files[CONFIG_FILENAME];
if (!cfgFile) { if (!cfgFile) {
throw new Error(`gist: missing ${CONFIG_FILENAME}`); throw new Error(`gist: missing ${CONFIG_FILENAME}`);
} }
const config = JSON.parse(cfgFile.content) as CvConfig; const config = JSON.parse(cfgFile.content) as CvConfig;
return { config, files: gist.files }; return { config, files };
} }
// Pick out the gist files whose names start with the given prefix, applying // Pick out the gist files whose names start with the given prefix, applying

View File

@@ -88,11 +88,14 @@ export function Filters({
} }
function formatDate(ts: number): string { function formatDate(ts: number): string {
// Format in UTC so the prerendered (Node) and client (browser) output match
// regardless of the viewer's timezone — otherwise hydration mismatches.
return new Date(ts) return new Date(ts)
.toLocaleDateString('en-GB', { .toLocaleDateString('en-GB', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
timeZone: 'UTC',
}) })
.toLowerCase(); .toLowerCase();
} }

View File

@@ -74,6 +74,9 @@ function renderSegment(seg: TitleSegment, i: number) {
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
// Format in UTC so the prerendered (Node) and client (browser) renders are
// byte-identical regardless of the viewer's timezone — otherwise the dates
// mismatch and React rejects the hydration (error #418).
const d = new Date(iso); const d = new Date(iso);
const date = d const date = d
.toLocaleDateString('en-GB', { .toLocaleDateString('en-GB', {
@@ -81,10 +84,21 @@ function formatDate(iso: string): string {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
timeZone: 'UTC',
}) })
.toLowerCase(); .toLowerCase();
const time = d const time = d
.toLocaleTimeString('en-GB', { timeZoneName: 'short' }) .toLocaleTimeString('en-GB', {
timeZone: 'UTC',
// Explicit 2-digit fields + 24h cycle: en-GB's *default* hour rendering
// isn't padded identically across JS engines (Node emits "9:53", Firefox
// "09:53"), which breaks hydration. Pinning the fields makes it match.
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
timeZoneName: 'short',
})
.toLowerCase(); .toLowerCase();
return `${date}${time}`; return `${date}${time}`;
} }

61
ui/src/entry-server.tsx Normal file
View File

@@ -0,0 +1,61 @@
// Server entry for the prerender. Built once with `vite build --ssr` and then
// driven by `run-prerender.mjs` under Node. For each route it prefetches the
// route's data, renders the real React tree to an HTML string, and returns the
// markup alongside the dehydrated react-query cache and the route's <head>
// metadata. No browser globals are touched on the render path.
import { StrictMode } from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import {
QueryClient,
QueryClientProvider,
dehydrate,
type DehydratedState,
} from '@tanstack/react-query';
import App from './App';
import { prefetchRoute } from './prerender/prefetch';
import { headForRoute, type HeadMeta } from './prerender/meta';
export { collectRoutes } from './prerender/routes';
export type { HeadMeta };
export interface RenderResult {
html: string;
state: DehydratedState;
head: HeadMeta;
}
export async function renderRoute(path: string): Promise<RenderResult> {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
// Treat prefetched data as fresh + non-collectable so it survives
// until dehydration and `useQuery` reads it synchronously during
// renderToString instead of trying to refetch.
staleTime: Infinity,
gcTime: Infinity,
},
},
});
await prefetchRoute(queryClient, path);
const html = renderToString(
<StrictMode>
<QueryClientProvider client={queryClient}>
<StaticRouter location={path}>
<App />
</StaticRouter>
</QueryClientProvider>
</StrictMode>,
);
return {
html,
state: dehydrate(queryClient),
head: headForRoute(path, queryClient),
};
}

71
ui/src/lib/ranges.ts Normal file
View File

@@ -0,0 +1,71 @@
// Date-range helpers shared by the dashboard/timeline components and the
// build-time prerender prefetch. Centralising the window maths keeps the
// react-query *keys* identical between the SSR snapshot and the client's
// first render, so hydration reuses the inlined cache instead of refetching.
//
// All windows are stamped to the UTC day (YYYY-MM-DD). Same-day loads hydrate
// from the baked snapshot; loads on a later day compute a different key and
// refetch live — the intended freshness tradeoff for the activity data.
import type { SourceSummary } from '../api/client';
export function fmtDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** Contribution graph + language stream: trailing 365 days. */
export function lastYearRange(now: Date = new Date()) {
const to = new Date(now);
const from = new Date(to);
from.setFullYear(from.getFullYear() - 1);
return { from, to, fromStr: fmtDate(from), toStr: fmtDate(to) };
}
/** Earliest activity date across all sources, or null if none reported. */
export function earliestFrom(sources: SourceSummary[]): Date | null {
const dates = sources
.map((s) => s.earliest)
.filter((d): d is string => d != null)
.map((d) => new Date(d));
return dates.length > 0
? new Date(Math.min(...dates.map((d) => d.getTime())))
: null;
}
/** All-time graphs/stats: earliest source date (fallback 5y) through now. */
export function allTimeRange(earliest: Date | null, now: Date = new Date()) {
const to = new Date(now);
const from = earliest ?? new Date(to.getFullYear() - 5, 0, 1);
return { from, to, fromStr: fmtDate(from), toStr: fmtDate(to) };
}
/** Timeline slider upper bound, bucketed to end-of-day (UTC) so the SSR and
* client renders agree on the slider scale and labels (no hydration mismatch). */
export function endOfTodayMs(now: Date = new Date()): number {
const d = new Date(now);
d.setUTCHours(23, 59, 59, 999);
return d.getTime();
}
/** Timeline default window: trailing 30 days, with both bounds bucketed to the
* UTC day so the slider's millisecond values are identical across the
* prerender and the client's first render. */
export function defaultActivityRange(now: number = Date.now()) {
const to = endOfTodayMs(new Date(now));
const from = to - 30 * 24 * 60 * 60 * 1000;
return {
from,
to,
fromStr: fmtDate(new Date(from)),
toStr: fmtDate(new Date(to)),
};
}
/** The hour-of-day histogram buckets in the viewer's timezone. */
export function resolvedTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}

View File

@@ -1,9 +1,30 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot, hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import {
QueryClient,
QueryClientProvider,
hydrate,
type DehydratedState,
} from '@tanstack/react-query';
import App from './App'; import App from './App';
// Global stylesheets are imported here, in the client entry only — keeping
// them out of the shared component tree lets the prerender build import that
// tree under Node (where `import './x.css'` would otherwise fail).
import 'bootstrap/dist/css/bootstrap.min.css';
import 'rc-slider/assets/index.css';
import 'react-vertical-timeline-component/style.min.css';
import './App.css';
import './pages/CvPage.css';
declare global {
interface Window {
/** Dehydrated react-query cache inlined by the prerender step. */
__RQ_STATE__?: DehydratedState;
}
}
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@@ -13,12 +34,29 @@ const queryClient = new QueryClient({
}, },
}); });
createRoot(document.getElementById('root')!).render( // Seed the cache from the prerendered snapshot so the first client render
// matches the server HTML (no loading flash) and refetches only when stale.
const dehydratedState = window.__RQ_STATE__;
if (dehydratedState) {
hydrate(queryClient, dehydratedState);
}
const tree = (
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>
); );
const container = document.getElementById('root')!;
// Hydrate the prerendered markup when present; fall back to a fresh render
// (e.g. SPA-fallback routes that weren't prerendered, or a dev server).
if (dehydratedState && container.firstChild) {
hydrateRoot(container, tree);
} else {
createRoot(container).render(tree);
}

View File

@@ -38,11 +38,14 @@ export function BlogIndexPage() {
} }
export function formatDate(iso: string): string { export function formatDate(iso: string): string {
// UTC-fixed so prerender (Node) and client (browser) agree — see formatDate
// in TimelineEntry for the hydration rationale.
return new Date(iso) return new Date(iso)
.toLocaleDateString('en-GB', { .toLocaleDateString('en-GB', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
timeZone: 'UTC',
}) })
.toLowerCase(); .toLowerCase();
} }

View File

@@ -10,7 +10,8 @@ import { fetchCv, filesForSection } from '../api/cv';
import { CvHeader } from '../components/cv/CvHeader'; import { CvHeader } from '../components/cv/CvHeader';
import { CvSection } from '../components/cv/CvSection'; import { CvSection } from '../components/cv/CvSection';
import { CvTimeline } from '../components/cv/CvTimeline'; import { CvTimeline } from '../components/cv/CvTimeline';
import './CvPage.css'; // CvPage.css is imported from the client entry (`main.tsx`) so this page stays
// importable under Node during the prerender build.
export function CvPage() { export function CvPage() {
const { hash } = useLocation(); const { hash } = useLocation();

View File

@@ -113,7 +113,9 @@ function forgeIcon(source: string): string {
function formatRange(first: string | null, last: string | null): string { function formatRange(first: string | null, last: string | null): string {
const fmt = (iso: string) => const fmt = (iso: string) =>
new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase(); new Date(iso)
.toLocaleDateString('en-GB', { month: 'short', year: 'numeric', timeZone: 'UTC' })
.toLowerCase();
if (first && last) return `${fmt(first)} — ${fmt(last)}`; if (first && last) return `${fmt(first)} — ${fmt(last)}`;
if (last) return fmt(last); if (last) return fmt(last);
return ''; return '';

View File

@@ -8,9 +8,10 @@ import { VerticalTimeline } from 'react-vertical-timeline-component';
import { fetchDailyCounts, fetchEvents, fetchSources, type Source } from '../api/client'; import { fetchDailyCounts, fetchEvents, fetchSources, type Source } from '../api/client';
import { Filters } from '../components/Filters'; import { Filters } from '../components/Filters';
import { TimelineEntry } from '../components/TimelineEntry'; import { TimelineEntry } from '../components/TimelineEntry';
import { defaultActivityRange, endOfTodayMs, fmtDate } from '../lib/ranges';
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime(); const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
const RANGE_MAX = Date.now(); const RANGE_MAX = endOfTodayMs();
function parseDate(s: string): number { function parseDate(s: string): number {
// Accept YYYY-MM-DD or full ISO datetime // Accept YYYY-MM-DD or full ISO datetime
@@ -51,9 +52,8 @@ export function TimelineHome() {
const [rangeValue, setRangeValue] = useState<[number, number]>(() => { const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
const parsed = parseTimespan(timespan); const parsed = parseTimespan(timespan);
if (parsed) return parsed; if (parsed) return parsed;
const now = Date.now(); const { from, to } = defaultActivityRange();
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; return [from, to];
return [thirtyDaysAgo, now];
}); });
const [limit, setLimit] = useState<number>(100); const [limit, setLimit] = useState<number>(100);
@@ -69,8 +69,13 @@ export function TimelineHome() {
[enabledSources], [enabledSources],
); );
// Day-stamped keys (rather than raw millisecond bounds) so the prerendered
// snapshot and the client's first render agree on the same UTC day.
const fromStr = fmtDate(new Date(rangeValue[0]));
const toStr = fmtDate(new Date(rangeValue[1]));
const eventsQ = useQuery({ const eventsQ = useQuery({
queryKey: ['events', rangeValue, activeSources, limit], queryKey: ['events', fromStr, toStr, activeSources, limit],
queryFn: () => queryFn: () =>
fetchEvents({ fetchEvents({
from: new Date(rangeValue[0]), from: new Date(rangeValue[0]),
@@ -83,8 +88,6 @@ export function TimelineHome() {
const events = eventsQ.data ?? []; const events = eventsQ.data ?? [];
const fromStr = new Date(rangeValue[0]).toISOString().slice(0, 10);
const toStr = new Date(rangeValue[1]).toISOString().slice(0, 10);
const dailyQ = useQuery({ const dailyQ = useQuery({
queryKey: ['daily-counts', fromStr, toStr], queryKey: ['daily-counts', fromStr, toStr],
queryFn: () => fetchDailyCounts(fromStr, toStr), queryFn: () => fetchDailyCounts(fromStr, toStr),

133
ui/src/prerender/meta.ts Normal file
View File

@@ -0,0 +1,133 @@
// Per-route <head> metadata baked into the prerendered HTML: a real title and
// description for each route, plus a schema.org Person + Occupation JSON-LD
// block so crawlers and AI screening tools get structured identity data even
// without running JS.
import type { QueryClient } from '@tanstack/react-query';
import type { BlogPost, ProjectSummary } from '../api/client';
export interface HeadMeta {
title: string;
description: string;
/** schema.org JSON-LD object, serialised into a <script type=ld+json>. */
jsonLd: object;
}
const SITE_URL = 'https://rob.tn';
const DEFAULT_DESCRIPTION =
'a timeline of open source contributions across github, gitea, and mozilla hg. ranked projects, language trends, and commit activity since 2012.';
/** schema.org Person + Occupation — stable across every route. */
function personJsonLd(): object {
return {
'@context': 'https://schema.org',
'@type': 'Person',
name: 'Rob Thijssen',
alternateName: 'grenade',
url: SITE_URL,
jobTitle: 'Software Engineer',
description:
'software engineer working across systems, infrastructure, and web — contributing to open source since 2012.',
sameAs: [
'https://github.com/grenade',
'https://git.lair.cafe/grenade',
'https://linkedin.com/in/thijssen/',
'https://stackoverflow.com/users/68115/grenade',
],
hasOccupation: {
'@type': 'Occupation',
name: 'Software Engineer',
occupationalCategory: '15-1252.00',
description:
'designs, builds, and operates software systems and the infrastructure they run on.',
},
};
}
/** First ~155 chars of a markdown body, stripped of the noisiest syntax. */
function excerpt(markdown: string, limit = 155): string {
const text = markdown
.replace(/^---[\s\S]*?---/, '') // drop frontmatter if present
.replace(/```[\s\S]*?```/g, ' ') // code fences
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // images
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links → text
.replace(/[#>*_`~|-]/g, ' ') // residual markdown punctuation
.replace(/\s+/g, ' ')
.trim();
if (text.length <= limit) return text;
return text.slice(0, limit).replace(/\s+\S*$/, '') + '…';
}
export function headForRoute(path: string, qc: QueryClient): HeadMeta {
const jsonLd = personJsonLd();
if (path === '/cv') {
return {
title: 'rob thijssen — cv',
description:
'rob thijssen — software engineer. résumé, work history, and skills, including roles at mozilla and across open source infrastructure.',
jsonLd,
};
}
if (path === '/activity') {
return {
title: 'activity — rob thijssen',
description:
'a chronological timeline of rob thijssens open source activity: commits, pull requests, issues, and releases across github, gitea, and mozilla hg.',
jsonLd,
};
}
if (path === '/blog') {
return {
title: 'blog — rob thijssen',
description: 'writing by rob thijssen on software, infrastructure, and open source.',
jsonLd,
};
}
if (path.startsWith('/blog/')) {
const slug = decodeURIComponent(path.slice('/blog/'.length));
const post = qc.getQueryData<BlogPost>(['blog-post', slug]);
return {
title: post ? `${post.title} — rob thijssen` : 'blog — rob thijssen',
description: post ? excerpt(post.markdown) : DEFAULT_DESCRIPTION,
jsonLd,
};
}
if (path.startsWith('/project/')) {
const rest = path.slice('/project/'.length);
const slash = rest.indexOf('/');
const source = slash === -1 ? rest : rest.slice(0, slash);
const repo = slash === -1 ? '' : rest.slice(slash + 1);
const projects = (qc.getQueryData(['projects']) as ProjectSummary[]) ?? [];
const project = projects.find((p) => p.source === source && p.repo === repo);
const counts = project
? [
project.commit_count ? `${project.commit_count} commits` : null,
project.pr_count ? `${project.pr_count} pull requests` : null,
project.issue_count ? `${project.issue_count} issues` : null,
]
.filter(Boolean)
.join(', ')
: '';
return {
title: `${repo} — rob thijssen`,
description: counts
? `rob thijssens contributions to ${repo}: ${counts}.`
: `rob thijssens open source activity and readme for ${repo}.`,
jsonLd,
};
}
// Home / dashboard.
return {
title: 'rob thijssen — developer activity and contribution history',
description: DEFAULT_DESCRIPTION,
jsonLd,
};
}

View File

@@ -0,0 +1,153 @@
// Build-time data fetching for the prerender. For each route we populate a
// fresh QueryClient with exactly the queries that route's components read,
// under the *same* query keys those components compute at runtime — so the
// dehydrated cache hydrates cleanly on the client. Keys that depend on the
// current day (contribution windows, the activity range) match same-day loads
// and refetch live afterwards; see `lib/ranges.ts`.
import type { QueryClient } from '@tanstack/react-query';
import {
fetchBlogPost,
fetchBlogPosts,
fetchDailyCounts,
fetchEvents,
fetchHourlyAvgs,
fetchLanguageDailyCounts,
fetchProjects,
fetchReadme,
fetchRepoLanguages,
fetchSources,
type ProjectSummary,
type Source,
type SourceSummary,
} from '../api/client';
import { fetchCv } from '../api/cv';
import {
allTimeRange,
defaultActivityRange,
earliestFrom,
lastYearRange,
resolvedTimeZone,
} from '../lib/ranges';
// Sources the timeline shows by default, in the same insertion order as
// TimelineHome's `enabledSources` (the array order is part of the query key).
const ALL_SOURCES: Source[] = ['github', 'gitea', 'hg', 'bugzilla', 'blog'];
const TIMELINE_LIMIT = 100;
const PROJECT_EVENT_LIMIT = 500;
async function prefetchDash(qc: QueryClient): Promise<void> {
await qc.prefetchQuery({ queryKey: ['sources'], queryFn: fetchSources });
const sources = (qc.getQueryData(['sources']) as SourceSummary[]) ?? [];
const year = lastYearRange();
const all = allTimeRange(earliestFrom(sources));
const tz = resolvedTimeZone();
await Promise.all([
qc.prefetchQuery({ queryKey: ['projects'], queryFn: fetchProjects }),
qc.prefetchQuery({ queryKey: ['repo-languages'], queryFn: fetchRepoLanguages }),
qc.prefetchQuery({
queryKey: ['daily-counts', year.fromStr, year.toStr],
queryFn: () => fetchDailyCounts(year.fromStr, year.toStr),
}),
qc.prefetchQuery({
queryKey: ['language-daily', year.fromStr, year.toStr],
queryFn: () => fetchLanguageDailyCounts(year.fromStr, year.toStr),
}),
qc.prefetchQuery({
queryKey: ['daily-counts-alltime', all.fromStr, all.toStr],
queryFn: () => fetchDailyCounts(all.fromStr, all.toStr),
}),
qc.prefetchQuery({
queryKey: ['language-daily-alltime', all.fromStr, all.toStr],
queryFn: () => fetchLanguageDailyCounts(all.fromStr, all.toStr),
}),
qc.prefetchQuery({
queryKey: ['hourly-avgs-alltime', all.fromStr, all.toStr, tz],
queryFn: () => fetchHourlyAvgs(all.fromStr, all.toStr, tz),
}),
]);
}
async function prefetchActivity(qc: QueryClient): Promise<void> {
// Only the default `/activity` view is prerendered. Timespan-parameterised
// routes (`/activity/:timespan`) are unbounded, so they SPA-fall-back to the
// client and refetch.
const range = defaultActivityRange();
await Promise.all([
qc.prefetchQuery({ queryKey: ['sources'], queryFn: fetchSources }),
qc.prefetchQuery({
queryKey: ['events', range.fromStr, range.toStr, ALL_SOURCES, TIMELINE_LIMIT],
queryFn: () =>
fetchEvents({
from: new Date(range.from),
to: new Date(range.to),
sources: ALL_SOURCES,
limit: TIMELINE_LIMIT,
}),
}),
qc.prefetchQuery({
queryKey: ['daily-counts', range.fromStr, range.toStr],
queryFn: () => fetchDailyCounts(range.fromStr, range.toStr),
}),
]);
}
async function prefetchProject(
qc: QueryClient,
source: Source,
repo: string,
): Promise<void> {
await qc.prefetchQuery({ queryKey: ['projects'], queryFn: fetchProjects });
const projects = (qc.getQueryData(['projects']) as ProjectSummary[]) ?? [];
const host = projects.find((p) => p.source === source && p.repo === repo)?.host ?? '';
const tasks = [
qc.prefetchQuery({
queryKey: ['project-events', source, repo],
queryFn: () =>
fetchEvents({ sources: [source], repo, limit: PROJECT_EVENT_LIMIT }),
}),
qc.prefetchQuery({ queryKey: ['repo-languages'], queryFn: fetchRepoLanguages }),
];
if (host && (source === 'github' || source === 'gitea')) {
tasks.push(
qc.prefetchQuery({
queryKey: ['readme', source, host, repo],
queryFn: () => fetchReadme(source, host, repo),
}),
);
}
await Promise.all(tasks);
}
/** Populate `qc` with the data the given route renders from. */
export async function prefetchRoute(qc: QueryClient, path: string): Promise<void> {
if (path === '/' || path === '/dash') return prefetchDash(qc);
if (path === '/activity' || path.startsWith('/activity/')) return prefetchActivity(qc);
if (path === '/blog') {
await qc.prefetchQuery({ queryKey: ['blog-posts'], queryFn: fetchBlogPosts });
return;
}
if (path.startsWith('/blog/')) {
const slug = decodeURIComponent(path.slice('/blog/'.length));
await qc.prefetchQuery({
queryKey: ['blog-post', slug],
queryFn: () => fetchBlogPost(slug),
});
return;
}
if (path === '/cv') {
await qc.prefetchQuery({ queryKey: ['cv-gist'], queryFn: fetchCv });
return;
}
if (path.startsWith('/project/')) {
const rest = path.slice('/project/'.length);
const slash = rest.indexOf('/');
const source = (slash === -1 ? rest : rest.slice(0, slash)) as Source;
const repo = slash === -1 ? '' : rest.slice(slash + 1);
return prefetchProject(qc, source, repo);
}
}

View File

@@ -0,0 +1,22 @@
// Enumerate every route to prerender. The four static routes are fixed; the
// dynamic ones are derived from the same APIs the site reads, so publishing a
// blog post or gaining a project automatically adds its prerendered page on
// the next build.
import { fetchBlogPosts, fetchProjects } from '../api/client';
export async function collectRoutes(): Promise<string[]> {
const routes = ['/', '/activity', '/blog', '/cv'];
const [posts, projects] = await Promise.all([
fetchBlogPosts().catch(() => []),
fetchProjects().catch(() => []),
]);
for (const post of posts) routes.push(`/blog/${post.slug}`);
for (const project of projects) {
routes.push(`/project/${project.source}/${project.repo}`);
}
return routes;
}

14
ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/**
* Absolute base for API calls during the build-time prerender (Node has no
* relative-URL origin). Unset in the client build so the browser keeps the
* same-origin relative `/api/v1` that nginx proxies. See `api/client.ts`.
*/
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -7,6 +7,14 @@ import react from '@vitejs/plugin-react-swc';
// frontend's URL shape is identical in both environments. // frontend's URL shape is identical in both environments.
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// For the prerender (`vite build --ssr`), bundle dependencies into the server
// output rather than externalising them. Several deps (react-bootstrap,
// react-markdown, …) use subpath/ESM-directory imports or pull in CSS that a
// raw Node `import` of the externalised package can't resolve; bundling lets
// vite resolve the subpaths and strip the CSS. Has no effect on client builds.
ssr: {
noExternal: true,
},
server: { server: {
proxy: { proxy: {
'/api': { '/api': {