feat: prerender every route + Gitea Actions deploy
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:
354
.gitea/workflows/deploy.yml
Normal file
354
.gitea/workflows/deploy.yml
Normal 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'
|
||||||
98
.gitea/workflows/refresh.yml
Normal file
98
.gitea/workflows/refresh.yml
Normal 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
1
.gitignore
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
36
CLAUDE.md
36
CLAUDE.md
@@ -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.
|
||||||
|
|||||||
35
asset/sudoers.d/api-host.conf
Normal file
35
asset/sudoers.d/api-host.conf
Normal 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
|
||||||
20
asset/sudoers.d/web-host.conf
Normal file
20
asset/sudoers.d/web-host.conf
Normal 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
|
||||||
27
asset/sudoers.d/worker-host.conf
Normal file
27
asset/sudoers.d/worker-host.conf
Normal 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
132
script/infra-setup.sh
Executable 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_.)"
|
||||||
@@ -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
127
ui/run-prerender.mjs
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const escapeAttr = (s) => escapeText(s).replace(/"/g, '"');
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
61
ui/src/entry-server.tsx
Normal 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
71
ui/src/lib/ranges.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|||||||
@@ -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
133
ui/src/prerender/meta.ts
Normal 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 thijssen’s 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 thijssen’s contributions to ${repo}: ${counts}.`
|
||||||
|
: `rob thijssen’s open source activity and readme for ${repo}.`,
|
||||||
|
jsonLd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home / dashboard.
|
||||||
|
return {
|
||||||
|
title: 'rob thijssen — developer activity and contribution history',
|
||||||
|
description: DEFAULT_DESCRIPTION,
|
||||||
|
jsonLd,
|
||||||
|
};
|
||||||
|
}
|
||||||
153
ui/src/prerender/prefetch.ts
Normal file
153
ui/src/prerender/prefetch.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
ui/src/prerender/routes.ts
Normal file
22
ui/src/prerender/routes.ts
Normal 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
14
ui/src/vite-env.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
@@ -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': {
|
||||||
|
|||||||
Reference in New Issue
Block a user