geometric packing game (tetris-ish) on an icosahedron. save boards and completed runs as records in your atproto PDS oscillatory.net/rad-cohesion
rad-cohesion docs deployment.md
14 kB · 257 lines
Markdown

Deployment and operations #

The website, APIs, SQLite index, ingestion worker, and multiplayer coordinator run in one Node process. Use one service instance with persistent local storage. For code ownership and data flow, see Architecture; public formats and endpoints are in records and APIs.

Local and production configuration #

npm run dev starts the backend runtime with Vite on port 5173. Browser source uses HMR; imported server modules restart through Node watch mode. npm run build typechecks and builds with adapter-node; npm start or npm run preview serves that build on port 3000. Builds do not open the database or run ingestion.

The development and start scripts load the ignored root .env.local. Compose also reads that file; it is not copied into the image. Start from .env.example for public settings. Only explicitly selected public build settings enter client bundles; keep the Jetstream key server-side.

Variable Purpose and default
SITE_URL Build-time full public URL, including subdirectory. Compose defaults to http://127.0.0.1:3000/.
BASE_PATH Local build subpath checks when no public SITE_URL is supplied; use the same setting when running.
HANDLE_RESOLVER Build-time browser handle resolver; defaults to https://bsky.social.
HOST / PORT Node listener; defaults to 127.0.0.1:3000, or 0.0.0.0:3000 in the image.
DATABASE_PATH Defaults to appview/data/appview.sqlite, or /data/appview.sqlite in the image.
INGESTION_ENABLED Defaults to false; enable explicitly for public PDS indexing.
JETSTREAM_SERVICE Defaults to https://jetstream.us-west.bsky.network.
JETSTREAM_API_KEY Required when ingestion is enabled.
PUBLIC_ORIGIN Exact public origin, without a path, for API origin checks and secure Guest cookies.
TRUSTED_PROXY_IPS Comma-separated exact proxy peer addresses allowed to supply X-Real-IP.
HEALTHCHECK_PATH Optional container probe override; otherwise derived from SITE_URL.

An explicitly relative DATABASE_PATH resolves from the working directory; the default is anchored to the application. Parent directories are created automatically. Changing Jetstream source or filters while retaining its database is rejected because the durable cursor belongs to that source and filter.

For a public build:

SITE_URL=https://example.com/rad-cohesion/ npm run build
PUBLIC_ORIGIN=https://example.com npm start

SITE_URL sets the base path, SvelteKit request origin, OAuth metadata, canonical URLs, and social previews. Rebuild when the public host or path changes. The runtime ORIGIN variable is not used by the current adapter. Without SITE_URL, local builds work but omit absolute canonical metadata. The social image source is src/assets/social-preview.png; its emitted URL is hashed.

Reverse proxy and container #

docker compose up --build -d appview
docker compose logs -f appview

The service name remains appview, but the image serves the whole website. Compose publishes host loopback port 3000 and mounts a named volume at /data. The image pins its Node release in Dockerfile, runs as the unprivileged node user, and requires no separate API proxy or CORS setup.

For HTTPS hosting, set the public SITE_URL before building, set runtime PUBLIC_ORIGIN to its origin, and forward the complete public prefix to Node without stripping it. Configure the exact proxy peer address seen by Node in TRUSTED_PROXY_IPS, and have the proxy overwrite X-Real-IP. The application does not trust X-Forwarded-For or X-Forwarded-Host; unlisted peers are rate-limited by their socket address.

The existing Oscillatory deployment uses /rad-cohesion/ and host loopback port 3010 because port 3000 belongs to the PDS. Its playbook and nginx template live in the sibling my-servers repository. This repository owns the image and Compose setup.

Health probes include the deployment prefix, for example /rad-cohesion/healthz. The image derives this from SITE_URL unless HEALTHCHECK_PATH overrides it. Docker's restart policy restarts exited processes, not merely unhealthy ones. SIGTERM/SIGINT drain HTTP requests, stop ingestion, and close SQLite.

WebSocket forwarding #

Multiplayer uses the same origin at /api/multiplayer beneath the public base path. The reverse proxy must forward WebSocket upgrades as well as ordinary HTTP requests. In the existing nginx location, retain the full path and proxy headers above and include:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

The endpoint checks browser origin and negotiates the rad-cohesion.multiplayer.v1 subprotocol. PUBLIC_ORIGIN and exact TRUSTED_PROXY_IPS also apply to race connections and room-admission limits. Native clients must supply the expected Origin header. Development shares the Vite HTTP server while leaving its hot-reload upgrades alone.

Run one coordinator instance: rooms are ephemeral process memory, not SQLite records, and are lost on deployment/restart. Shutdown closes race sockets before draining HTTP. Multiplayer needs a live connection even when the static shell is available offline. Existing single-player data and PDS indexing are separate.

Bot-enabled rooms use Node worker threads in the same deployment, with up to three bot rooms per process by default and at most three bots per room. Set MULTIPLAYER_MAX_BOT_ROOMS to 0, 1, 2 or 3 to disable bots or bound concurrent bot rooms. Other values fail startup. Preparation and simulation stay off the HTTP/coordinator thread. Adding a bot reports a busy error when capacity is occupied; workers are released when their room finishes or is abandoned. Bots require no extra service or credentials.

Each worker shares one prepared board, placement index, ID lookup and pair of fingerprints across its bots. Different rooms still have separate workers and heaps. In a local preparation-only benchmark at k=10, a minimal Node process with one three-bot room used roughly 166 MiB RSS for an octahedron and 238 MiB for an icosahedron, including a roughly 73 MiB parent-process baseline. Two three-bot rooms used about 244 MiB and 390 MiB respectively. These are development measurements, not memory limits: the full service, active gameplay, allocator behavior and garbage collection need additional headroom. Adding a second or third bot no longer duplicates the room's prepared lookup containers or hashing work, but adding another room still adds another prepared context.

OAuth and Lexicons #

The public oauth-client-metadata.json URL is the browser OAuth client ID; there is no client secret. Metadata and callback pages share the app's origin and deployment base and are excluded from service-worker caching.

For local sign-in, use http://127.0.0.1:5173/game/ and keep the same host and port through the callback. The SDK uses the optional loopback client-ID exception, so provider support is required. The localhost account dialog links to the same page at 127.0.0.1; click Sign in again there. Browser data does not transfer between those hostnames. Local OAuth can publish directly to a real PDS without a local PDS or public development deployment.

The two checked-in schemas are under lexicons. To make them discoverable, publish each as a com.atproto.lexicon.schema record with its NSID as the record key, then configure:

_lexicon.radcohesion.oscillatory.net TXT "did=did:plc:<schema-owner>"

Checked-in JSON and builds do not publish schemas or change DNS. See the OAuth specification and Lexicon publishing guide.

Ingestion and troubleshooting #

Enable indexing in root .env.local:

INGESTION_ENABLED=true
JETSTREAM_API_KEY=your-key

The key is sent only to the configured HTTPS Jetstream service. Browser tests disable ingestion. The archive reader prefetches one block with an 8 MiB compressed-buffer target; decompressed size adds to memory use.

GET /healthz checks database availability and returns 503 after permanent ingestion failure. GET /status reports phase, cursors, indexed counts, pending author checks, retry information, and sanitized errors without the key. Both paths include the configured base. Phases include disabled, starting, backfilling, live, retrying, failed, stopping, and stopped.

Status field Meaning
cursor Durable live-stream progress.
discoveryCursor Separate initial archive-discovery progress.
checkpointedAtMs Local time progress was saved.
lastBatchAtMs Local time a batch was processed.
lastEventAtMs Timestamp carried by the last newly processed event.
eventLagMs Age of that event, clamped to zero; not a count of missing game records.
retryAtMs / retryCount Scheduled retry time and cumulative retries in this worker lifetime.

Times are Unix milliseconds. A sequence cursor cannot be converted to a date. Empty batches retain the last event date. Filtered/no-op progress is saved at most once every five seconds as batches arrive; replay after a crash is safe.

Initial discovery scans commit history for the two app collections, then checks each candidate's current PDS. Bootstrap completion means every candidate has had an initial attempt, not that all PDS checks succeeded. Failed checks remain pending. Regular author checks continue while Jetstream is disconnected.

If a publication is missing, inspect status and logs for archive progress, pending author checks, invalid-record rejection, or an inactive account. A PDS failure preserves last-known state. Deactivation can take about an hour plus queue delay to disappear; record refreshes run daily and after accepted commits. Confirmed deletion removes records, while reactivation causes a fresh listing.

An expired live cursor triggers archive catch-up from the saved live cursor, not a reset to zero. Catch-up progress and its fixed endpoint survive restart. HTTP 429 from hosted Jetstream can indicate archive byte quota exhaustion; respect the reported retry time. Explicit Retry-After delays are not capped, while ordinary exponential backoff is. Do not reset the database or skip a cursor to bypass a retry. See Jetstream replay.

The install hook patch-jetstream.mjs patches SDK 1.0.0's empty optional sync timestamp handling and explicit Retry-After cap. It runs on npm ci and in Docker, is idempotent, and rejects unrecognized SDK sources. After npm ci --ignore-scripts, run it manually. Review these patches when updating the SDK; preserve the database when rebuilding to resume from the committed cursor.

Database, backups, and upgrades #

Use local storage, not NFS, for the /data volume. Recreating the container preserves it; docker compose down --volumes deletes it. Guest submissions are stored there in addition to the rebuildable PDS index.

For a stopped-service backup and upgrade:

docker compose stop appview
docker compose run --rm --no-deps --entrypoint tar appview -C /data -cf - . > appview-backup.tar
docker compose up --build -d appview

Keep a copy off-host. Restore with the service stopped into an empty volume with its original ownership, using the application image matching the backup. Do not copy only a live .sqlite file: committed changes may still be in its WAL. Use SQLite's online backup API if uninterrupted backups become necessary.

Startup applies all pending SQL and migration bookkeeping in one transaction before listening. Failures roll back the batch and prevent startup. Append migrations; never edit, remove, or rename applied files. Rollback means restoring both the old image and its matching database backup. Stop the old service before schema upgrades. See migration conventions for the local implementation contract and migration inventory.

All connections go through appview/src/database.ts: foreign keys on, 5-second busy timeout, WAL journaling, FULL synchronous durability, trusted schema off, double-quoted string literals and extension loading disabled, and defensive mode enabled. Tables use STRICT, NOT NULL, and domain checks. Connection settings are reapplied per connection; page/cache sizes, memory mapping, and automatic WAL checkpointing retain SQLite defaults. Keep data changes and ingestion cursors transactional, use bound query parameters, and handle lock errors even with a busy timeout.

Asset maintenance #

Replace src/assets/social-preview.png to update link previews; rebuilding changes the hashed image URL. Sharing services may retain cached previews. Audio ships as lazy-loaded MP3s; the untracked WAV masters stay outside builds. Regenerate them from the repository root when editing audio:

ffmpeg -y -i theme-2.wav -map_metadata -1 -codec:a libmp3lame -q:a 3 src/assets/track-1.mp3
ffmpeg -y -i theme.wav -map_metadata -1 -codec:a libmp3lame -q:a 3 src/assets/track-2.mp3
ffmpeg -y -i placement.wav -map_metadata -1 -codec:a libmp3lame -q:a 3 src/assets/placement.mp3
ffmpeg -y -i clear.wav -map_metadata -1 -codec:a libmp3lame -q:a 3 src/assets/clear.mp3
ffmpeg -y -i invalid-placement.wav -map_metadata -1 -codec:a libmp3lame -q:a 3 src/assets/invalid-placement.mp3