Merge pull request 'Dotcom Arcade: rename, one compose file, Ultimate setup script and docs' (#1) from rebrand-and-docs into main
Images / build (., dotcom-arcade) (push) Canceled after 0s
Images / build (streaming/session-manager, dotcom-arcade-session-manager) (push) Canceled after 0s
Images / build (streaming/streamer, dotcom-arcade-streamer) (push) Canceled after 0s

Reviewed-on: #1
Reviewed-by: branderson <brad@branderson.io>
This commit was merged in pull request #1.
This commit is contained in:
2026-10-13 22:17:00 -07:00
115 changed files with 1298 additions and 5862 deletions
+16 -13
View File
@@ -1,8 +1,5 @@
# Flashpoint Web - Environment Template
# Copy to .env and fill in required values.
#
# Production: docker compose up -d
# Development: docker compose -f docker-compose.dev.yml up -d --build
# Dotcom Arcade - Environment Template
# Copy to .env and fill in required values, then: docker compose up -d --build
# ---------------------------------------------------------------------------
# Required
@@ -11,10 +8,15 @@ FLASHPOINT_HOST_PATH=/path/to/Flashpoint
JWT_SECRET=replace-with-a-secure-random-string
# ---------------------------------------------------------------------------
# Container Image (production only)
# Container Images
# ---------------------------------------------------------------------------
# Override image tag (image from Docker Hub: darkraise/flashpoint-web)
# Built from source by default. To pull published images instead, set the
# registry path they live under (ending in /), then:
# docker compose pull && docker compose up -d
# IMAGE_PREFIX=registry.example.com/owner/
# IMAGE_TAG=latest
# "Latest release" API URL to check for updates (GitHub or Gitea); off when unset
# APP_UPDATE_URL=https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest
# ---------------------------------------------------------------------------
# Port (host mapping)
@@ -43,7 +45,7 @@ LOG_LEVEL=info
OTEL_ENABLED=false
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_API_KEY=your-api-key-here
OTEL_SERVICE_NAME=flashpoint-web
OTEL_SERVICE_NAME=dotcom-arcade
OTEL_TRACES_ENABLED=true
OTEL_METRICS_ENABLED=true
# OTEL_METRICS_EXPORT_INTERVAL=60000
@@ -103,15 +105,16 @@ ENABLE_CACHE_PREWARM=true
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
# HOME_RECENT_HOURS=24
# ENABLE_CGI=false
# Streamed play for games that can't run in a browser, such as Java applets.
# Off unless both are set. See docs/09-deployment/environment-variables.md.
# STREAMING_API_URL=http://flashpoint-session-manager:8080
# Streamed play for games that can't run in a browser (Java, Shockwave, the
# original Flash Player, Windows games). Set a token (e.g. `openssl rand -hex 32`)
# and enable the profile. See docs/10-features/13-streaming.md.
# COMPOSE_PROFILES=streaming
# STREAMING_API_TOKEN=
# MAX_SESSIONS=4
# Sign in users an authenticating reverse proxy (e.g. Authentik forward auth)
# names in a header. Off unless TRUSTED_HEADER_USERNAME and TRUSTED_PROXIES are
# both set. See docs/09-deployment/environment-variables.md.
# both set. See docs/10-features/14-proxy-sign-in.md.
# TRUSTED_HEADER_USERNAME=X-authentik-username
# TRUSTED_HEADER_EMAIL=X-authentik-email
# TRUSTED_PROXIES=192.0.2.10
+65
View File
@@ -0,0 +1,65 @@
# Builds the app, session manager and streamer images and pushes them to this
# Gitea instance's container registry, under the repository's owner.
#
# Needs repository secrets REGISTRY_USERNAME and REGISTRY_TOKEN: a user, and an
# access token of theirs with package write access.
name: Images
on:
push:
branches: [main]
tags: ['v*.*.*']
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- image: dotcom-arcade
context: .
- image: dotcom-arcade-session-manager
context: streaming/session-manager
- image: dotcom-arcade-streamer
context: streaming/streamer
steps:
- uses: actions/checkout@v4
# The registry lives on the same host as this Gitea instance
- name: Locate the registry
id: registry
run: |
host=${GITHUB_SERVER_URL#*://}
echo "host=${host%/}" >> "$GITHUB_OUTPUT"
echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ steps.registry.outputs.host }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.registry.outputs.host }}/${{ steps.registry.outputs.owner }}/${{ matrix.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=ref,event=branch
- uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
provenance: false
-3
View File
@@ -1,8 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Ask a Question
url: https://github.com/darkraise/flashpoint-web/discussions
about: Use Discussions for questions, help, and general conversation
- name: Flashpoint Archive
url: https://github.com/FlashpointProject
about: For issues with Flashpoint itself (not this web interface)
-204
View File
@@ -1,204 +0,0 @@
# GitHub Actions Workflows
This directory contains automated workflows for the Flashpoint Web project.
## Workflows
### 1. Docker Build and Push (`docker-build-push.yml`)
Builds and pushes the single Docker image to the container registry.
#### Triggers
- **Version tags** (`v*.*.*`): Builds and pushes the image with version tags and `latest` tag
- **Manual dispatch**: Can be triggered manually from GitHub Actions tab
#### Image Built
- `flashpoint-web` - Single image serving both the REST API (including game
content) and the React web UI
#### Container Registry
The workflow pushes images to **Docker Hub**.
#### Setup Instructions
##### Docker Hub (Required)
1. **Create Docker Hub account** at https://hub.docker.com
2. **Create access token**:
- Go to Account Settings → Security → Access Tokens
- Click "New Access Token"
- Name: "GitHub Actions"
- Permissions: Read & Write
- Copy the token (you won't see it again!)
3. **Add GitHub Secrets**:
- Go to your repository → Settings → Secrets and variables → Actions
- Click "New repository secret"
- Add two secrets:
- `DOCKERHUB_USERNAME`: Your Docker Hub username
- `DOCKERHUB_TOKEN`: The access token from step 2
4. **Image will be pushed to**:
```
docker.io/darkraise/flashpoint-web:latest
```
#### Image Tags
The workflow automatically creates multiple tags when you push a version tag:
| Tag Type | Example | Description |
|----------|---------|-------------|
| `latest` | `latest` | Always created for every version tag |
| Full version | `1.2.3` | Git tag `v1.2.3` → `1.2.3` |
| Major.Minor | `1.2` | Git tag `v1.2.3` → `1.2` |
| Major | `1` | Git tag `v1.2.3` → `1` |
#### Creating a Release
To create a versioned release:
```bash
# Ensure you're on master branch
git checkout master
# Tag the commit
git tag v1.0.0
# Push the tag (this triggers the workflow)
git push origin v1.0.0
```
This will create images tagged as:
- `1.0.0`
- `1.0`
- `1`
- `latest`
#### Platform Support
Images are built for:
- `linux/amd64` (x86_64) - Standard servers and desktops
**Note:** ARM64 support has been disabled to significantly reduce build times. If you need ARM64 images, you can build them locally or enable multi-platform builds by adding `linux/arm64` to the platforms in the workflow file.
#### Build Caching
The workflow uses GitHub Actions cache to speed up builds:
- Layer cache stored between runs
- Significantly faster builds after the first run
- Cache automatically invalidated when dependencies change
#### Usage Examples
##### Pull and Run the Image
```bash
# Pull the latest image
docker pull darkraise/flashpoint-web:latest
# Or use a specific version
docker pull darkraise/flashpoint-web:1.0.0
```
##### Use in Docker Compose
Update your `docker-compose.yml` to use the pre-built image:
```yaml
services:
flashpoint-web:
image: darkraise/flashpoint-web:latest
# Remove build section
ports:
- "80:3100"
# ... rest of config
```
Then simply run:
```bash
docker compose pull # Pull the latest image
docker compose up -d # Start the service
```
#### Making Images Public
By default, Docker Hub images are private. To make them public:
1. Go to https://hub.docker.com/repositories
2. Click on the repository (e.g., `flashpoint-web`)
3. Click "Settings"
4. Change visibility to "Public"
#### Troubleshooting
##### Build Fails
Check the Actions tab for detailed error logs:
```
https://github.com/<username>/<repo>/actions
```
##### Docker Hub Not Pushing
Verify secrets are set correctly:
- Repository → Settings → Secrets and variables → Actions
- Check `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` exist
##### Image Not Found
For private images, authenticate first:
```bash
docker login docker.io
```
#### Performance
Typical build times (optimized for amd64 only):
- First build: 3-5 minutes
- Subsequent builds (with cache): 1-2 minutes
- Frontend and backend build stages run within a single image build
**Optimizations applied:**
- Single platform (amd64) instead of multi-platform
- npm ci with `--prefer-offline` and `--no-audit` flags
- Disabled provenance and SBOM generation
- Latest BuildKit image
- GitHub Actions cache for Docker layers
#### Security
- Secrets are never exposed in logs
- Images are scanned for vulnerabilities (optional - can add)
- Multi-stage builds minimize attack surface
- Non-root users in all containers
- Read-only root filesystems where possible
---
### 2. Performance Budget (`performance-budget.yml`)
Frontend performance monitoring workflow.
See the workflow file for details.
---
## Adding New Workflows
To add a new workflow:
1. Create a new YAML file in `.github/workflows/`
2. Use the GitHub Actions syntax
3. Test locally with [act](https://github.com/nektos/act) (optional)
4. Push to trigger the workflow
## References
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
- [Docker Build Push Action](https://github.com/docker/build-push-action)
- [GitHub Container Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)
-144
View File
@@ -1,144 +0,0 @@
name: Build and Push Docker Images
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
env:
REGISTRY_DOCKERHUB: docker.io
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
outputs:
version: ${{ steps.meta.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
image=moby/buildkit:latest
network=host
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_DOCKERHUB }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_DOCKERHUB }}/${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web
tags: |
# Tag with full version (v1.0.0 -> 1.0.0)
type=semver,pattern={{version}}
# Tag with major.minor (v1.0.0 -> 1.0)
type=semver,pattern={{major}}.{{minor}}
# Tag with major version (v1.0.0 -> 1)
type=semver,pattern={{major}}
# Latest tag
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=flashpoint-web
cache-to: type=gha,scope=flashpoint-web,mode=max
platforms: linux/amd64
build-args: |
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
VCS_REF=${{ github.sha }}
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
provenance: false
sbom: false
summary:
needs: build-and-push
runs-on: ubuntu-latest
if: always()
steps:
- name: Build Summary
run: |
echo "## Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Image Built:" >> $GITHUB_STEP_SUMMARY
echo "- \`flashpoint-web\` (API + UI)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Registry:" >> $GITHUB_STEP_SUMMARY
echo "- Docker Hub: \`${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Pull Command:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web:latest" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
create-release:
needs: build-and-push
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Generate release notes
id: release-notes
run: |
# Get the previous tag
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
echo "## Docker Image" >> release_notes.md
echo "" >> release_notes.md
echo "Pull the image from Docker Hub:" >> release_notes.md
echo "" >> release_notes.md
echo "\`\`\`bash" >> release_notes.md
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web:${{ steps.version.outputs.version }}" >> release_notes.md
echo "\`\`\`" >> release_notes.md
echo "" >> release_notes.md
if [ -n "$PREV_TAG" ]; then
echo "## Changes since $PREV_TAG" >> release_notes.md
echo "" >> release_notes.md
git log --pretty=format:"- %s (%h)" $PREV_TAG..HEAD >> release_notes.md
echo "" >> release_notes.md
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.version }}
name: Release ${{ steps.version.outputs.version }}
body_path: release_notes.md
draft: false
prerelease: ${{ contains(steps.version.outputs.version, '-') }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -8
View File
@@ -2,8 +2,8 @@
## Project Overview
Flashpoint Web: self-hosted web app for browsing/playing Flashpoint Archive
games.
Dotcom Arcade: a self-hosted, Flash-era game portal for Flashpoint Ultimate.
Built on darkraise's flashpoint-web.
- **Backend** (Express/TypeScript, port 3100): REST API + game content via
`/game-proxy/*`, `/game-zip/*`
@@ -11,6 +11,8 @@ games.
Tailwind, Ruffle
- **Databases**: `flashpoint.sqlite` (read-only, never write) + `user.db` (app
data)
- **Streaming** (`streaming/`): a session manager and per-player streamer
containers for games that can't run in a browser; see `streaming/README.md`
## Commands
@@ -1191,13 +1193,8 @@ Before submitting code, verify:
## Environment
```bash
FLASHPOINT_PATH=D:/Flashpoint # Required - path to Flashpoint installation
FLASHPOINT_PATH=/path/to/flashpoint # Required - path to Flashpoint installation
JWT_SECRET=your-secret # Required - random string for JWT signing
DOMAIN=http://localhost:5173 # Optional - frontend URL for CORS
LOG_LEVEL=info # Optional - debug, info, warn, error
```
## Source References
- Flashpoint Launcher: `D:\Repositories\Community\launcher`
- Flashpoint Game Server: `D:\Repositories\Community\FlashpointGameServer`
+6 -9
View File
@@ -1,8 +1,8 @@
# Contributing to Flashpoint Web
# Contributing to Dotcom Arcade
Thanks for your interest in contributing! We welcome bug fixes, features, documentation improvements, and questions.
## What is Flashpoint Web?
## What is Dotcom Arcade?
A self-hosted web application for browsing and playing games from [Flashpoint Archive](https://flashpointarchive.org/).
@@ -57,17 +57,14 @@ docs/ # Documentation
cp .env.example .env
# Edit .env - set FLASHPOINT_HOST_PATH and JWT_SECRET (required)
# Build and start (development)
docker compose -f docker-compose.dev.yml up -d --build
# Build and start
docker compose up -d --build
# View logs
docker compose -f docker-compose.dev.yml logs -f
# Rebuild specific service
docker compose -f docker-compose.dev.yml up -d --build backend
docker compose logs -f
```
Frontend runs on `http://localhost:80`, backend API on `http://localhost:3100`.
The app (UI and API) runs on `http://localhost:80`.
### Option 2: Manual Setup (Recommended for Development)
+4 -5
View File
@@ -1,8 +1,8 @@
# Production Dockerfile for Flashpoint Web (single image: API + UI)
# Production Dockerfile for Dotcom Arcade (single image: API + UI)
#
# The backend serves both the REST API / game content and the built React UI,
# so this image runs a single Node process. Build from the repo root:
# docker build -t flashpoint-web .
# docker build -t dotcom-arcade .
# =============================================================================
# Stage 1: Frontend builder - Build the React app
@@ -54,10 +54,9 @@ FROM node:20.18.2-alpine
# Metadata labels
ARG VERSION=dev
LABEL org.opencontainers.image.title="Flashpoint Web"
LABEL org.opencontainers.image.description="Self-hosted web app for Flashpoint Archive (API + UI)"
LABEL org.opencontainers.image.title="Dotcom Arcade"
LABEL org.opencontainers.image.description="Self-hosted Flash-era game portal for Flashpoint Ultimate (API + UI)"
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.source="https://github.com/darkraise/flashpoint-web"
# An ARG does not survive into the running container; the server reads this to
# decide whether it is a released build.
+167 -198
View File
@@ -1,238 +1,207 @@
# Flashpoint Web
# Dotcom Arcade
<div align="center">
**Your own Flash-era game portal, powered by
[Flashpoint Ultimate](https://flashpointarchive.org/).**
**A modern web interface for the Flashpoint Archive**
Dotcom Arcade serves a [Flashpoint Ultimate](https://flashpointarchive.org/)
collection as a website you'd have played at a friend's house or in the computer
lab, with modern UX. It aims to play as much of the archive as possible in any
browser: Flash and HTML5 run in the page, and Java, Shockwave and the original
Flash Player stream from your server.
Browse and play 200,000+ preserved Flash games and animations directly in your
browser.
![The Dotcom Arcade homepage](docs/images/home.png)
<!-- Tech Stack Badges -->
<p>
<img src="https://img.shields.io/badge/Node.js-339933?style=for-the-badge&logo=nodedotjs&logoColor=white" alt="Node.js" />
<img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
<img src="https://img.shields.io/badge/React-61DAFB?style=for-the-badge&logo=react&logoColor=black" alt="React" />
<img src="https://img.shields.io/badge/Express-000000?style=for-the-badge&logo=express&logoColor=white" alt="Express" />
<img src="https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=vite&logoColor=white" alt="Vite" />
<img src="https://img.shields.io/badge/Tailwind_CSS-06B6D4?style=for-the-badge&logo=tailwindcss&logoColor=white" alt="Tailwind CSS" />
<img src="https://img.shields.io/badge/SQLite-003B57?style=for-the-badge&logo=sqlite&logoColor=white" alt="SQLite" />
<img src="https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white" alt="Docker" />
</p>
<!-- Project Badges -->
<p>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License" /></a>
<img src="https://img.shields.io/badge/Node.js-20%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white" alt="Node.js 20+" />
<img src="https://img.shields.io/badge/PRs-Welcome-brightgreen?style=flat-square" alt="PRs Welcome" />
</p>
</div>
---
> [!IMPORTANT]
> [!NOTE]
>
> **Already running 1.0.31 or earlier? Updating is a breaking change.** The
> separate `flashpoint-backend` and `flashpoint-frontend` containers were
> replaced in 1.0.32 by a single `darkraise/flashpoint-web` image, and the old
> images are no longer published. Your compose file and `.env` both need changes
> — see **[Upgrading](#upgrading)** before you pull.
> Dotcom Arcade is an unofficial community project, not affiliated with
> Flashpoint Archive. It's built on
> [flashpoint-web](https://github.com/darkraise/flashpoint-web) by darkraise.
## What is Flashpoint Web?
Flashpoint Web is a self-hosted web application for accessing your local
[Flashpoint Archive](https://flashpointarchive.org/) collection. It provides a
modern, browser-based alternative to the Flashpoint Launcher with multi-user
support, play tracking, and an intuitive interface.
### Key Features
- **Browse & Search** - Fast search across 200,000+ games with advanced
filtering by platform, developer, tags, and more
- **Downloaded View** - A dedicated page and filter for games whose data is
already on disk, so a curated collection is one click away
- **Play in Browser** - Flash games via [Ruffle](https://ruffle.rs/) emulator,
HTML5 games natively
- **Multi-User Support** - Individual accounts with role-based permissions
(admin, moderator, user, guest)
- **Play Tracking** - Track playtime, completion status, and view play
statistics
- **Playlists & Favorites** - Organize your game collection with custom
playlists
- **22 Theme Palettes** - Customizable dark/light modes with multiple color
schemes
- **Responsive Design** - Works on desktop and mobile devices
- **Database Hot-Reload** - Automatically syncs when Flashpoint Launcher updates
metadata
## Demo: [flash.quangtc.dev](https://flash.quangtc.dev)
Register new account and try your favorite flash games
## Upgrading
Routine updates need nothing beyond
`docker compose pull && docker compose up -d`.
## Before you start: hosting requirements
> [!WARNING]
>
> **Coming from 1.0.31 or earlier, that is not enough.** 1.0.32 replaced the
> two-container deployment with a single image, so an existing setup needs:
>
> - a new `docker-compose.yml` with one `flashpoint-web` service, mapping
> `WEB_PORT` to container port `3100`
> - `API_PORT`, `BACKEND_HOST`, and `BACKEND_PORT` removed from `.env` — they no
> longer do anything
> - a reverse proxy pointing at one upstream instead of two; any rule sending
> `/api` to port 3100 will break
> - the Flashpoint mount switched to read-write, or the Downloaded page stays
> empty
>
> Accounts, playlists, and play history are preserved.
> **Dotcom Arcade needs a lot of disk, and streaming needs a lot of CPU.** Check
> these before you download anything.
Full migration steps, every environment variable added, removed, or changed, and
per-version release notes are in the
**[Upgrade Guide](docs/09-deployment/upgrade-guide.md)**.
| Resource | Needed |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Storage** | **More than 2.3 TB** for Flashpoint Ultimate 14, and growing with each release. 4.6 TB if you keep the downloaded archives to seed them. A spare desktop drive won't do. |
| **Time** | A day or more to download and extract, depending on your connection and disks. |
| **Server** | Linux with Docker. 2 CPU cores and 4 GB of RAM serve the site and in-browser games to a household. |
| **Streaming** | Optional, and heavy: every player gets their own container running the game and encoding its video on the CPU. Plan on roughly 1 to 2 CPU cores and 1 GB of RAM per player streaming at once, and a few Mbps of upload each. |
| **HTTPS** | Required for streaming, since browsers only allow its video decoder on secure pages. Recommended anyway. |
## Getting Started
## Features
### Prerequisites
- **Plays almost everything.**
- Flash runs in [Ruffle](https://ruffle.rs/) and HTML5 runs natively.
- Games no browser can run stream from the server into the page, each player
in their own container: Java applets, Shockwave and Director games, and
Flash games that need the real Flash Player. With streaming on, any Flash
game can switch to the original Flash Player.
- **An arcade, not a file browser.** A portal-style layout with genre categories
and glossy tiles, and color schemes defined as data, so new themes are easy to
add.
- **Discovery first.** The homepage offers:
- a Game of the Day;
- a Time Machine of games released in any week, with a date picker;
- shelves from Flashpoint's curated playlists;
- your recently played games.
- [Docker](https://docs.docker.com/get-docker/) and
[Docker Compose](https://docs.docker.com/compose/install/)
- [Flashpoint Archive](https://flashpointarchive.org/) installed locally
Browse lists default to a shuffle, not A to Z.
### 1. Create a project directory
- **One page per game.** It has a click-to-play stage, details and tags, and
related games ranked by shared tags, series and developer.
- **Accounts and saves.**
- Multi-user, with roles.
- Favorites, playlists and play tracking.
- Game saves follow you between browsers, with the last 10 kept as backups.
- **Sign in through your proxy.** Authentik, Authelia, oauth2-proxy and similar
forward-auth proxies can sign users in.
- **Mature content filter.** Adult and extreme content is hidden unless a user
opts in.
```bash
mkdir flashpoint-web && cd flashpoint-web
<p align="center">
<img src="docs/images/game.png" alt="A game page" width="49%" />
<img src="docs/images/playing.png" alt="Playing a game in the page" width="49%" />
</p>
## Getting started
Check the [hosting requirements](#before-you-start-hosting-requirements) first.
### 1. Get Flashpoint Ultimate
Download two files from Flashpoint's
[downloads page](https://flashpointarchive.org/downloads):
- the **Flashpoint Ultimate** `.torrent` file;
- **Flashpoint Infinity for Linux** (`.7z`), whose software streaming needs.
[`scripts/setup-flashpoint.sh`](scripts/setup-flashpoint.sh) then does the rest:
- It checks that you have the disk space, and stops before downloading anything
if you don't.
- It downloads the torrent with `aria2c`.
- It extracts each archive in turn and deletes it afterwards, so the disk never
needs room for both.
- It pulls the database, settings and playlists out of `Base_1.zip`, which
otherwise only holds Windows software.
Re-run it to resume after an interruption.
```sh
sudo apt install aria2 7zip tmux
tmux new -s flashpoint
scripts/setup-flashpoint.sh --torrent FlashpointUltimate14.torrent \
--infinity fp14.0.3_lin_20251201.7z --seed-ratio 1.0 /srv/flashpoint
```
### 2. Create `docker-compose.yml`
#### Please seed
<!-- prettier-ignore -->
```yaml
services:
flashpoint-web:
image: darkraise/flashpoint-web:${IMAGE_TAG:-latest}
container_name: flashpoint-web
restart: unless-stopped
ports:
- "${WEB_PORT:-80}:3100"
volumes:
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
- ${DATA_PATH:-./data}:/app/data
- ${LOGS_PATH:-./logs}:/app/logs
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- NODE_ENV=production
- TZ=${TZ:-UTC}
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
- LOG_LEVEL=${LOG_LEVEL:-info}
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
- ENABLE_LOCAL_DB_COPY=${ENABLE_LOCAL_DB_COPY:-false}
- SQLITE_CACHE_SIZE=${SQLITE_CACHE_SIZE:--64000}
- SQLITE_MMAP_SIZE=${SQLITE_MMAP_SIZE:-268435456}
- ENABLE_CACHE_PREWARM=${ENABLE_CACHE_PREWARM:-true}
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:3100/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
Flashpoint Ultimate is shared over BitTorrent, and stays available only as long
as people seed it. If you can, give back at least what you downloaded:
- **Seed before extracting.** `--seed-ratio 1.0` keeps seeding after the
download until you've uploaded as much as you downloaded. `--seed-hours 24`
seeds for a day. With both, seeding stops at whichever comes first. This needs
no extra disk space, but extraction waits until seeding stops.
- **Seed for the long term.** `--keep-archives` keeps the archives after
extracting (about 4.6 TB in total), so a torrent client such as qBittorrent or
Transmission can go on seeding the downloads directory.
- **Let peers reach you.** The download listens on ports 6881-6999, TCP and UDP.
Forwarding them on your router and allowing them through your firewall lets
more people download from you.
### 2. Configure
```sh
cp .env.example .env
```
The Flashpoint directory is mounted read-write: the app records which games are
downloaded in `flashpoint.sqlite` and saves downloaded game data under
`Data/Games`. Mount it read-only and browsing still works, but downloads and the
Downloaded page do not.
Set at least these:
### 3. Create `.env`
Two values are required. This is a complete, working `.env`:
```env
FLASHPOINT_HOST_PATH=/srv/Flashpoint
JWT_SECRET=paste-the-output-of-openssl-rand-hex-32-here
```sh
FLASHPOINT_HOST_PATH=/srv/flashpoint
JWT_SECRET=<output of: openssl rand -hex 32>
PUID=1000 # your user, from `id -u` and `id -g`
PGID=1000
```
Generate the secret with `openssl rand -hex 32`. Everything else has a working
default; add only what you need:
### 3. Start
```env
# WEB_PORT=8080 # Host port for the app (UI + API) (default: 80)
# PUID=1000 # Host user ID (Linux — run 'id -u')
# PGID=1000 # Host group ID (Linux — run 'id -g')
# LOG_LEVEL=info # debug, info, warn, error
# TZ=UTC # Timezone
# DATA_PATH=./data # Persistent app data
# LOGS_PATH=./logs # Log files
# Extra allowed origins, comma-separated. Not needed for a normal install:
# same-origin requests are always allowed. Set this only when the UI is served
# from a different origin, or behind a proxy that rewrites the Host header.
# DOMAIN=http://192.168.0.10,https://flashpoint.example.com
# Database performance (network storage / large collections)
# ENABLE_LOCAL_DB_COPY=false # Copy flashpoint.sqlite to local storage
# SQLITE_CACHE_SIZE=-64000 # SQLite cache in KB (64MB default)
# SQLITE_MMAP_SIZE=268435456 # Memory-mapped I/O in bytes (256MB default)
# ENABLE_CACHE_PREWARM=true # Pre-warm common queries on startup
```sh
docker compose up -d --build
```
### 4. Start it
Open `http://<server>/` and create the admin account. On first start the app
indexes the collection, which takes a few minutes on a full Ultimate install.
Under **Settings → App** you can set the site name and tagline.
```bash
docker compose up -d
```
### 4. Optional: streaming
Open **http://localhost** in your browser. The first user to register
automatically becomes the **admin**.
Streaming plays the games browsers can't. To turn it on:
A single container serves both the UI and the API on one port — `WEB_PORT` on
the host, 3100 inside the container.
1. Run the setup script with `--infinity`.
2. Add to `.env`:
### Common Commands
```sh
COMPOSE_PROFILES=streaming
STREAMING_API_TOKEN=<output of: openssl rand -hex 32>
```
```bash
docker compose up -d # Start
docker compose down # Stop
docker compose logs -f # Follow logs
docker compose restart # Restart
docker compose pull && docker compose up -d # Update to the latest image
3. Rebuild with `docker compose up -d --build`.
4. Serve the site over HTTPS. The app proxies streams itself, so your reverse
proxy only needs to pass WebSocket upgrades and allow long-lived connections.
See the [streaming guide](docs/10-features/13-streaming.md).
The session manager starts containers through the Docker socket, which amounts
to root on the host. It publishes no ports: only the app talks to it.
### Signing in through a reverse proxy
If your site sits behind Authentik, Authelia or another forward-auth proxy, the
app can trust the username it passes along. See
[proxy sign-in](docs/10-features/14-proxy-sign-in.md).
## Updating
```sh
git pull
docker compose up -d --build
```
## Documentation
For architecture details, API reference, development setup, contributing
guidelines, and more, visit the
**[Wiki](https://github.com/darkraise/flashpoint-web/wiki)**.
The [docs](docs/README.md) cover features, deployment, configuration, the API
and development. Useful starting points:
## Related Projects
- [Environment variables](docs/09-deployment/environment-variables.md)
- [Discovery portal](docs/10-features/12-discovery-portal.md)
- [Streaming](docs/10-features/13-streaming.md) and
[streaming internals](streaming/README.md)
- [Development setup](docs/08-development/setup-guide.md)
- [Flashpoint Archive](https://flashpointarchive.org/) - The game preservation
project
- [Flashpoint Launcher](https://github.com/FlashpointProject/launcher) -
Official desktop launcher
- [Ruffle](https://ruffle.rs/) - Flash Player emulator
## Credits
- [Flashpoint Archive](https://flashpointarchive.org/), for preserving web games
and animations, and for the curated playlists the homepage draws on.
- [flashpoint-web](https://github.com/darkraise/flashpoint-web) by darkraise,
which this project is built on.
- [Ruffle](https://ruffle.rs/), for running Flash in modern browsers.
- [Selkies](https://github.com/selkies-project/selkies), which streams the
games.
## Legal
Flashpoint Archive is a preservation project whose legal standing has never been
settled. It removes games when their rights holders ask. Dotcom Arcade contains
no games and links to no game downloads. You get Flashpoint from Flashpoint's
own site, and you are responsible for complying with copyright law where you
live. Rights holders who want a game removed should contact
[Flashpoint Archive](https://flashpointarchive.org/).
## License
This project is licensed under the GNU General Public License v3.0 - see the
[LICENSE](LICENSE) file for details.
## Acknowledgments
- **Flashpoint Team** - For preserving web gaming history
- **Ruffle Contributors** - For making Flash playable in modern browsers
- **Community** - For testing, feedback, and contributions
---
<div align="center">
**[Wiki](https://github.com/darkraise/flashpoint-web/wiki)** |
**[Report Bug](https://github.com/darkraise/flashpoint-web/issues)** |
**[Request Feature](https://github.com/darkraise/flashpoint-web/issues)**
</div>
GPL-3.0, like flashpoint-web. See [LICENSE](LICENSE).
-1
View File
@@ -22,7 +22,6 @@ BCRYPT_SALT_ROUNDS=10
# ENABLE_CGI=false
# Misc
HOME_RECENT_HOURS=24
# ---------------------------------------------------------------------------
# Database Performance Optimization (for network storage)
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "flashpoint-web-backend",
"name": "dotcom-arcade-backend",
"version": "1.0.0",
"description": "Backend API for Flashpoint Archive web application",
"main": "dist/server.js",
+4 -2
View File
@@ -167,12 +167,12 @@ export const config = {
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '', 10) || 10,
homeRecentHours: parseInt(process.env.HOME_RECENT_HOURS || '', 10) || 24,
// Session manager that streams games which can't run in a browser, such as
// Java applets (flashpoint-ultimate-server). Streaming is off unless both are set.
streamingApiUrl: process.env.STREAMING_API_URL || null,
streamingApiToken: process.env.STREAMING_API_TOKEN || null,
// Where /stream/ is proxied to: the session manager's stream port
streamingStreamUrl: process.env.STREAMING_STREAM_URL || null,
// Sign users in from a header set by an authenticating reverse proxy, such as
// Authentik forward auth. Off unless the header name and the proxy addresses
@@ -208,6 +208,8 @@ export const config = {
// marks the build as unreleased. `||` is deliberate: a whitespace-only value
// must collapse to null, which `??` would not do.
appVersion: process.env.APP_VERSION?.trim() || null,
// A GitHub- or Gitea-style "latest release" API URL; update checks are off without it
appUpdateUrl: process.env.APP_UPDATE_URL?.trim() || null,
} as const;
/** Resolves external image CDN URLs from Flashpoint preferences, with hardcoded fallbacks. */
@@ -0,0 +1,11 @@
-- ===================================
-- Migration 011: Default site name
-- ===================================
-- The project is now Dotcom Arcade. Installs still on the old default take the
-- new one; a name an admin chose is left alone.
UPDATE system_settings
SET
default_value = 'Dotcom Arcade',
value = CASE WHEN value = 'Flashpoint Web' THEN 'Dotcom Arcade' ELSE value END
WHERE key = 'app.site_name';
+1 -1
View File
@@ -61,7 +61,7 @@ const clientErrorLogger = winston.createLogger({
})(),
winston.format.json()
),
defaultMeta: { service: 'flashpoint-web-client' },
defaultMeta: { service: 'dotcom-arcade-client' },
transports: [
new winston.transports.File({
filename: ERROR_LOG_FILE,
-56
View File
@@ -1,56 +0,0 @@
import { Router, Request, Response } from 'express';
import { logger } from '../utils/logger';
import { asyncHandler } from '../middleware/asyncHandler';
const router = Router();
// In-memory cache for GitHub star count (avoid hitting 60/hr unauthenticated rate limit)
let cachedStars: { count: number; fetchedAt: number } | null = null;
const CACHE_TTL_MS = 15 * 60 * 1000;
// Proxy to avoid browser CORS issues with GitHub API
router.get(
'/stars',
asyncHandler(async (_req: Request, res: Response) => {
if (cachedStars && Date.now() - cachedStars.fetchedAt < CACHE_TTL_MS) {
return res.json({
success: true,
data: { stars: cachedStars.count },
});
}
const repoUrl = 'https://api.github.com/repos/darkraise/flashpoint-web';
const response = await fetch(repoUrl, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Flashpoint-Web-App',
},
});
if (!response.ok) {
// If rate-limited and we have a stale cache, return it
if (cachedStars) {
logger.debug('[GitHub] Rate limited, returning stale cache');
return res.json({
success: true,
data: { stars: cachedStars.count },
});
}
throw new Error(`GitHub API error: ${response.status}`);
}
const data = (await response.json()) as { stargazers_count: number };
cachedStars = { count: data.stargazers_count, fetchedAt: Date.now() };
res.json({
success: true,
data: {
stars: data.stargazers_count,
},
});
})
);
export default router;
-2
View File
@@ -25,7 +25,6 @@ import ratingsRouter from './ratings';
import gameSavesRouter from './game-saves';
import discoveryRouter from './discovery';
import ruffleRouter from './ruffle';
import githubRouter from './github';
import domainsRouter from './domains';
import healthRouter from './health';
import cacheRouter from './cache';
@@ -40,7 +39,6 @@ export function setupRoutes(app: Express): void {
app.use('/api/settings/auth', authSettingsRouter);
app.use('/api/settings', systemSettingsRouter);
app.use('/api/ruffle', ruffleRouter);
app.use('/api/github', githubRouter);
app.use('/api/domains', domainsRouter);
app.use('/api/jobs', jobsRouter);
app.use('/api/users', usersRouter);
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import http, { type Server } from 'http';
import type { AddressInfo } from 'net';
import express from 'express';
import { mountStreamProxy } from './streamProxy';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
let upstream: Server;
let app: Server;
let base: string;
function listen(server: Server): Promise<number> {
return new Promise((resolve) =>
server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
);
}
beforeAll(async () => {
// A stand-in session manager: echoes the path, and accepts WebSocket upgrades
upstream = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/plain');
res.end(`upstream saw ${req.method} ${req.url}`);
});
upstream.on('upgrade', (req, socket) => {
socket.write(
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n'
);
socket.write(`ws ${req.url}`);
socket.end();
});
const upstreamPort = await listen(upstream);
const expressApp = express();
const attach = mountStreamProxy(expressApp, `http://127.0.0.1:${upstreamPort}`);
expressApp.get('/other', (_req, res) => {
res.send('app');
});
app = http.createServer(expressApp);
attach(app);
base = `http://127.0.0.1:${await listen(app)}`;
});
afterAll(() => {
app.close();
upstream.close();
});
describe('stream proxy', () => {
it('forwards /stream/ requests with their full path', async () => {
const res = await fetch(`${base}/stream/abc/_fp/start`, { method: 'POST' });
expect(await res.text()).toBe('upstream saw POST /stream/abc/_fp/start');
});
it('leaves other routes to the app', async () => {
expect(await (await fetch(`${base}/other`)).text()).toBe('app');
});
it('forwards WebSocket upgrades', async () => {
const received = await new Promise<string>((resolve, reject) => {
const req = http.request(`${base}/stream/abc/websocket`, {
headers: { Connection: 'Upgrade', Upgrade: 'websocket' },
});
req.on('upgrade', (res, socket, head) => {
expect(res.statusCode).toBe(101);
let data = head.toString();
socket.on('data', (chunk) => (data += chunk.toString()));
socket.on('end', () => resolve(data));
});
req.on('error', reject);
req.end();
});
expect(received).toBe('ws /stream/abc/websocket');
});
it('answers 502 when the session manager is down', async () => {
const down = express();
mountStreamProxy(down, 'http://127.0.0.1:9');
const server = http.createServer(down);
const port = await listen(server);
const res = await fetch(`http://127.0.0.1:${port}/stream/abc/`);
server.close();
expect(res.status).toBe(502);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { ServerResponse, type IncomingMessage, type Server } from 'http';
import type { Socket } from 'net';
import type { Express } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { logger } from '../utils/logger';
/**
* Proxies /stream/ to the session manager, WebSockets included, so a reverse
* proxy in front of the app needs no route of its own for streams. Returns a
* function that wires WebSocket upgrades into the HTTP server once it exists.
*/
export function mountStreamProxy(app: Express, target: string): (server: Server) => void {
const proxy = createProxyMiddleware('/stream/', {
target,
ws: true,
logLevel: 'silent',
// Save sync is a long-lived event stream; don't let the server's idle timeout cut it
onProxyReq: (_proxyReq, req) => {
req.socket.setTimeout(0);
},
onError: (error, req, res) => {
logger.warn('[Streaming] Stream proxy error', { url: req.url, error: error.message });
// A failed WebSocket upgrade hands over the raw socket instead of a response
if (!(res instanceof ServerResponse)) {
(res as Socket).destroy();
} else if (!res.headersSent) {
res.writeHead(502).end();
}
},
});
app.use(proxy);
return (server) => {
server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => {
if (!req.url?.startsWith('/stream/')) return;
socket.setTimeout(0);
proxy.upgrade?.(req as Parameters<NonNullable<typeof proxy.upgrade>>[0], socket, head);
});
};
}
-5
View File
@@ -80,11 +80,6 @@ router.get(
Record<string, unknown>
> = systemSettings.getPublicSettings();
if (!publicSettings.app) {
publicSettings.app = {};
}
publicSettings.app.homeRecentHours = config.homeRecentHours;
// Inject edition/version from config (auto-detected from version.txt, not stored in DB)
if (!publicSettings.metadata) {
publicSettings.metadata = {};
+1 -1
View File
@@ -51,7 +51,7 @@ const INFO = {
updateAvailable: true,
isUnreleasedBuild: false,
publishedAt: '2026-08-12T07:11:45Z',
releaseUrl: 'https://github.com/darkraise/flashpoint-web/releases/tag/v1.0.42',
releaseUrl: 'https://git.example.com/owner/dotcom-arcade/releases/tag/v1.0.42',
changelog: '## Changes',
checkedAt: '2026-08-12T08:00:00.000Z',
lastCheckFailed: false,
+11 -1
View File
@@ -44,6 +44,8 @@ import { zipManager } from './game/zip-manager';
import { gameZipServer } from './game/gamezipserver';
import gameProxyRouter from './routes/game-proxy';
import gameZipRouter from './routes/game-zip';
import { mountStreamProxy } from './routes/streamProxy';
import { StreamingService } from './services/StreamingService';
/**
* Compute SHA-256 CSP hashes for inline <script> blocks (those without a src
@@ -112,6 +114,13 @@ async function startServer() {
app.use('/game-proxy', gameCors, startupGate, gameProxyRouter);
app.use('/game-zip', gameCors, startupGate, gameZipRouter);
// Streams are proxied before helmet and compression, which would break the
// streamer's page and its event stream
const attachStreamProxy =
StreamingService.isEnabled() && config.streamingStreamUrl
? mountStreamProxy(app, config.streamingStreamUrl)
: null;
// When the backend also serves the SPA, allow the app's own resources by
// hash/keyword rather than the blanket 'unsafe-inline'/'unsafe-eval':
// - scriptSrc: 'self' for hashed bundles, sha256 hashes for the inline theme
@@ -225,11 +234,12 @@ async function startServer() {
// pre-warm can run for minutes, and a closed port shows the user a connection
// error instead of a page that explains itself.
const server = app.listen(config.port, config.host, () => {
logger.info(`🚀 Flashpoint Web API server running on http://${config.host}:${config.port}`);
logger.info(`🚀 Dotcom Arcade server running on http://${config.host}:${config.port}`);
logger.info(`📁 Flashpoint path: ${config.flashpointPath}`);
logger.info(`🎮 Game content: /game-proxy/* and /game-zip/* (integrated)`);
logger.info(`🌍 Environment: ${config.nodeEnv}`);
});
attachStreamProxy?.(server);
// Prevent connection exhaustion - critical for handling game file requests
server.keepAliveTimeout = 65000; // 65s - slightly above common load balancer timeout
+22 -3
View File
@@ -4,7 +4,15 @@ vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
const configMock = vi.hoisted(() => ({ config: { appVersion: '1.0.38' as string | null } }));
const RELEASE_URL = 'https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest';
const configMock = vi.hoisted(() => ({
config: {
appVersion: '1.0.38' as string | null,
appUpdateUrl: 'https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest' as
| string
| null,
},
}));
vi.mock('../config', () => configMock);
vi.mock('axios', () => ({
@@ -20,7 +28,7 @@ function release(tagName: string) {
data: {
tag_name: tagName,
published_at: '2026-08-12T07:11:45Z',
html_url: `https://github.com/darkraise/flashpoint-web/releases/tag/${tagName}`,
html_url: `https://git.example.com/owner/dotcom-arcade/releases/tag/${tagName}`,
body: '## Changes since v1.0.37\n\n- feat: something',
},
};
@@ -30,6 +38,7 @@ beforeEach(() => {
vi.clearAllMocks();
AppUpdateService.clearCache();
configMock.config.appVersion = '1.0.38';
configMock.config.appUpdateUrl = RELEASE_URL;
});
describe('AppUpdateService.getUpdateInfo', () => {
@@ -105,10 +114,20 @@ describe('AppUpdateService.getUpdateInfo', () => {
await AppUpdateService.getUpdateInfo();
expect(axios.get).toHaveBeenCalledWith(
'https://api.github.com/repos/darkraise/flashpoint-web/releases/latest',
RELEASE_URL,
expect.objectContaining({ timeout: 10000 })
);
});
it('checks nothing when no release source is configured', async () => {
configMock.config.appUpdateUrl = null;
const info = await AppUpdateService.getUpdateInfo(true);
expect(axios.get).not.toHaveBeenCalled();
expect(info.enabled).toBe(false);
expect(info.updateAvailable).toBe(false);
});
});
describe('AppUpdateService caching', () => {
+17 -11
View File
@@ -3,12 +3,13 @@ import { config } from '../config';
import { logger } from '../utils/logger';
import { compareVersions, isReleaseVersion, normalizeVersion } from '../utils/version';
const LATEST_RELEASE_URL = 'https://api.github.com/repos/darkraise/flashpoint-web/releases/latest';
const CACHE_TTL_MS = 60 * 60 * 1000;
const FORCED_REFRESH_FLOOR_MS = 60 * 1000;
const REQUEST_TIMEOUT_MS = 10000;
export interface AppUpdateInfo {
/** False when no release source is configured (APP_UPDATE_URL) */
enabled: boolean;
currentVersion: string | null;
latestVersion: string | null;
updateAvailable: boolean;
@@ -28,7 +29,8 @@ interface CachedRelease {
fetchedAt: number;
}
interface GitHubRelease {
/** The fields GitHub's and Gitea's release APIs have in common */
interface Release {
tag_name?: unknown;
published_at?: unknown;
html_url?: unknown;
@@ -50,13 +52,16 @@ export class AppUpdateService {
private static lastFailedAt: number | null = null;
static async getUpdateInfo(force = false): Promise<AppUpdateInfo> {
if (config.appUpdateUrl === null) {
return this.buildInfo();
}
// A forced check still respects a 60s floor. Express rate limiting allows
// 100 requests a minute, which is no protection for a GitHub quota of 60 an
// hour that this shares with the star count and the Ruffle update check.
// 100 requests a minute, which is no protection for a release API's quota
// (GitHub allows 60 an hour, shared with the Ruffle update check).
const maxAge = force ? FORCED_REFRESH_FLOOR_MS : CACHE_TTL_MS;
const age = this.cached === null ? Infinity : Date.now() - this.cached.fetchedAt;
// A failure also respects the 60s floor, even when force is true, so a
// GitHub outage or rate limit can't be hammered every request while it
// source's outage or rate limit can't be hammered every request while it
// still shares the API's blocking timeout with each caller.
const failureAge = this.lastFailedAt === null ? Infinity : Date.now() - this.lastFailedAt;
const withinFailureFloor = failureAge < FORCED_REFRESH_FLOOR_MS;
@@ -69,7 +74,7 @@ export class AppUpdateService {
} catch (error: unknown) {
this.lastCheckFailed = true;
this.lastFailedAt = Date.now();
logger.warn('[AppUpdate] Could not reach GitHub for the latest release', {
logger.warn('[AppUpdate] Could not fetch the latest release', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
@@ -96,19 +101,19 @@ export class AppUpdateService {
}
private static async requestLatestRelease(): Promise<CachedRelease> {
const response = await axios.get(LATEST_RELEASE_URL, {
const response = await axios.get(config.appUpdateUrl ?? '', {
timeout: REQUEST_TIMEOUT_MS,
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Flashpoint-Web',
Accept: 'application/json',
'User-Agent': 'Dotcom-Arcade',
},
});
const release = (response.data ?? {}) as GitHubRelease;
const release = (response.data ?? {}) as Release;
const tagName = asString(release.tag_name);
if (tagName === null) {
throw new Error('GitHub release response carried no tag_name');
throw new Error('Release response carried no tag_name');
}
return {
@@ -131,6 +136,7 @@ export class AppUpdateService {
: null;
return {
enabled: config.appUpdateUrl !== null,
currentVersion,
latestVersion: latest?.version ?? null,
updateAvailable: comparison !== null && comparison < 0,
+2 -2
View File
@@ -17,7 +17,7 @@ import { logger } from '../utils/logger';
* - OTEL_ENABLED: Enable/disable telemetry (true/false)
* - OTEL_EXPORTER_OTLP_ENDPOINT: OTLP endpoint (e.g., http://localhost:4318)
* - OTEL_API_KEY: API key for authentication (sent as "Authorization: Api-Key <key>")
* - OTEL_SERVICE_NAME: Service name (default: flashpoint-web-backend)
* - OTEL_SERVICE_NAME: Service name (default: dotcom-arcade-backend)
* - OTEL_TRACES_ENABLED: Enable traces (default: true)
* - OTEL_METRICS_ENABLED: Enable metrics (default: true)
* - OTEL_METRICS_EXPORT_INTERVAL: Metrics export interval in ms (default: 60000)
@@ -40,7 +40,7 @@ function getOtelConfig() {
enabled: process.env.OTEL_ENABLED === 'true',
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318',
apiKey: process.env.OTEL_API_KEY || '',
serviceName: process.env.OTEL_SERVICE_NAME || 'flashpoint-web-backend',
serviceName: process.env.OTEL_SERVICE_NAME || 'dotcom-arcade-backend',
serviceVersion: process.env.npm_package_version || '1.0.0',
tracesEnabled: process.env.OTEL_TRACES_ENABLED !== 'false',
metricsEnabled: process.env.OTEL_METRICS_ENABLED !== 'false',
+1 -1
View File
@@ -154,7 +154,7 @@ export const logger = winston.createLogger({
otelFormat(),
winston.format.json()
),
defaultMeta: { service: 'flashpoint-web-backend' },
defaultMeta: { service: 'dotcom-arcade-backend' },
transports,
});
-98
View File
@@ -1,98 +0,0 @@
# =============================================================================
# Flashpoint Web - Development Docker Compose
# =============================================================================
# Builds the single API + UI image locally from source.
#
# Usage:
# docker compose -f docker-compose.dev.yml up -d --build
#
# View logs:
# docker compose -f docker-compose.dev.yml logs -f
#
# User permissions:
# Set PUID/PGID to match your host user (run 'id -u' and 'id -g' to find)
# This ensures bind-mounted volumes have correct ownership
# =============================================================================
services:
flashpoint-web:
build:
context: .
dockerfile: ./Dockerfile
image: flashpoint-web:dev
container_name: flashpoint-web
restart: unless-stopped
ports:
# Serves both the UI and the API (/api, /game-proxy, /game-zip) on one port
- "${WEB_PORT:-80}:3100"
volumes:
# Read-write: the app records downloaded state in flashpoint.sqlite and
# writes downloaded game data under Data/Games.
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
# Holds user.db and the installed Ruffle. Without this mount an in-app
# Ruffle update is lost the next time the container is recreated.
- backend-db:/app/data
- ${LOGS_PATH:-./logs}:/app/logs
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- NODE_ENV=${NODE_ENV:-development}
- SERVE_FRONTEND=true
# Matches the read-only mount above. Set explicitly because the backend's
# fallback only points here when NODE_ENV=production.
- FLASHPOINT_PATH=/data/flashpoint
# Same reason as FLASHPOINT_PATH above: this container runs
# NODE_ENV=development, so the production default would put Ruffle outside
# the data volume and lose every update on recreate.
- RUFFLE_DATA_PATH=/app/data/ruffle
- TZ=${TZ:-UTC}
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
- LOG_LEVEL=${LOG_LEVEL:-info}
# File I/O parallelism: images and ZIP reads run on libuv's threadpool,
# whose default of 4 throttles every filesystem request in the process.
- UV_THREADPOOL_SIZE=${UV_THREADPOOL_SIZE:-16}
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
- ENABLE_LOCAL_DB_COPY=${ENABLE_LOCAL_DB_COPY:-false}
- SQLITE_CACHE_SIZE=${SQLITE_CACHE_SIZE:--64000}
- SQLITE_MMAP_SIZE=${SQLITE_MMAP_SIZE:-268435456}
- ENABLE_CACHE_PREWARM=${ENABLE_CACHE_PREWARM:-true}
# Auth & rate limiting
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-1h}
- BCRYPT_SALT_ROUNDS=${BCRYPT_SALT_ROUNDS:-10}
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-100}
# Application
- HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24}
- ENABLE_CGI=${ENABLE_CGI:-false}
# Streamed play for games that can't run in a browser (off unless both are set)
- STREAMING_API_URL=${STREAMING_API_URL:-}
- STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-}
# Sign in users named by an authenticating reverse proxy (off unless both are set)
- TRUSTED_HEADER_USERNAME=${TRUSTED_HEADER_USERNAME:-}
- TRUSTED_HEADER_EMAIL=${TRUSTED_HEADER_EMAIL:-}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
# OpenTelemetry
- OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
- OTEL_API_KEY=${OTEL_API_KEY:-}
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web}
- OTEL_TRACES_ENABLED=${OTEL_TRACES_ENABLED:-true}
- OTEL_METRICS_ENABLED=${OTEL_METRICS_ENABLED:-true}
- OTEL_METRICS_EXPORT_INTERVAL=${OTEL_METRICS_EXPORT_INTERVAL:-60000}
- OTEL_LOG_LEVEL=${OTEL_LOG_LEVEL:-info}
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:3100/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- flashpoint-network
networks:
flashpoint-network:
driver: bridge
volumes:
backend-db:
driver: local
+63 -21
View File
@@ -1,25 +1,27 @@
# =============================================================================
# Flashpoint Web - Production Docker Compose
# Dotcom Arcade
# =============================================================================
# Single image serving both the API and the web UI.
# Uses a pre-built image from the container registry.
# The app serves the UI and API. With the "streaming" profile, a session
# manager also streams games that can't run in a browser (Java, Shockwave,
# Flash Player, Windows games), one container per player.
#
# Usage:
# 1. Copy .env.example to .env and configure required variables
# 2. docker compose up -d
# 1. Copy .env.example to .env and set the required variables
# 2. docker compose up -d --build
# (or, with IMAGE_PREFIX set to a registry's images:
# docker compose pull && docker compose up -d)
# 3. For streaming, set STREAMING_API_TOKEN and COMPOSE_PROFILES=streaming in
# .env, then run `docker compose up -d --build` again
#
# For local development with image building, use:
# docker compose -f docker-compose.dev.yml up -d --build
#
# User permissions:
# Set PUID/PGID to match your host user (run 'id -u' and 'id -g' to find)
# This ensures bind-mounted volumes have correct ownership
# Set PUID/PGID to your host user (`id -u`, `id -g`) so bind-mounted volumes
# get the right ownership.
# =============================================================================
services:
flashpoint-web:
image: darkraise/flashpoint-web:${IMAGE_TAG:-latest}
container_name: flashpoint-web
app:
build: .
image: ${IMAGE_PREFIX:-}dotcom-arcade:${IMAGE_TAG:-latest}
container_name: dotcom-arcade
restart: unless-stopped
ports:
# Serves both the UI and the API (/api, /game-proxy, /game-zip) on one port
@@ -54,10 +56,12 @@ services:
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-100}
# Application
- HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24}
# "Latest release" API URL (GitHub or Gitea) for update checks; off when unset
- APP_UPDATE_URL=${APP_UPDATE_URL:-}
- ENABLE_CGI=${ENABLE_CGI:-false}
# Streamed play for games that can't run in a browser (off unless both are set)
- STREAMING_API_URL=${STREAMING_API_URL:-}
# Streamed play for games that can't run in a browser (off unless the token is set)
- STREAMING_API_URL=${STREAMING_API_URL:-http://session-manager:8080}
- STREAMING_STREAM_URL=${STREAMING_STREAM_URL:-http://session-manager:8081}
- STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-}
# Sign in users named by an authenticating reverse proxy (off unless both are set)
- TRUSTED_HEADER_USERNAME=${TRUSTED_HEADER_USERNAME:-}
@@ -67,7 +71,7 @@ services:
- OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
- OTEL_API_KEY=${OTEL_API_KEY:-}
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web}
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-dotcom-arcade}
- OTEL_TRACES_ENABLED=${OTEL_TRACES_ENABLED:-true}
- OTEL_METRICS_ENABLED=${OTEL_METRICS_ENABLED:-true}
- OTEL_METRICS_EXPORT_INTERVAL=${OTEL_METRICS_EXPORT_INTERVAL:-60000}
@@ -94,8 +98,46 @@ services:
cpus: "0.5"
memory: 512M
networks:
- flashpoint-network
- arcade
# Starts a streamer container per player. The app proxies /stream/ to it, so
# it publishes no ports.
session-manager:
profiles: [streaming]
build: streaming/session-manager
image: ${IMAGE_PREFIX:-}dotcom-arcade-session-manager:${IMAGE_TAG:-latest}
container_name: dotcom-arcade-session-manager
restart: unless-stopped
volumes:
# Starts and removes streamer containers. Docker socket access is
# effectively root on the host.
- /var/run/docker.sock:/var/run/docker.sock
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/flashpoint:ro
environment:
- FLASHPOINT_HOST_PATH
# The session manager refuses to start without it
- API_TOKEN=${STREAMING_API_TOKEN:-}
- STREAMER_IMAGE=${IMAGE_PREFIX:-}dotcom-arcade-streamer:${IMAGE_TAG:-latest}
- STREAMER_NETWORK=dotcom-arcade-streams
- MAX_SESSIONS=${MAX_SESSIONS:-4}
- STREAM_PLATFORMS=${STREAM_PLATFORMS:-Java,Shockwave,Flash}
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
networks:
- arcade
- streams
# The image the session manager runs per player; built here, never started by compose
streamer:
profiles: [streaming]
build: streaming/streamer
image: ${IMAGE_PREFIX:-}dotcom-arcade-streamer:${IMAGE_TAG:-latest}
scale: 0
networks:
flashpoint-network:
driver: bridge
arcade:
name: dotcom-arcade
# Streamer containers: reachable only from the session manager, no internet access
streams:
name: dotcom-arcade-streams
internal: true
+6 -6
View File
@@ -2,7 +2,7 @@
## Introduction
Flashpoint Web is a self-hosted web application designed to browse and play
Dotcom Arcade is a self-hosted web application designed to browse and play
games from the Flashpoint Archive. It provides a modern, user-friendly interface
to access thousands of preserved Flash games, HTML5 games, and animations from
the early web era.
@@ -14,9 +14,9 @@ dedicated to saving games and animations from the web. With Adobe Flash Player's
end-of-life in December 2020, millions of Flash games and animations became
unplayable. Flashpoint preserves these games and provides tools to play them.
## What is Flashpoint Web?
## What is Dotcom Arcade?
Flashpoint Web is a **web-based alternative** to the Flashpoint Launcher,
Dotcom Arcade is a **web-based alternative** to the Flashpoint Launcher,
offering:
- **Browser-based access** to your local Flashpoint collection
@@ -65,7 +65,7 @@ offering:
## Architecture Highlights
Flashpoint Web consists of two independent services:
Dotcom Arcade consists of two independent services:
1. **Backend (Port 3100)** - REST API server handling game metadata, user
management, authentication, and game file serving
@@ -127,7 +127,7 @@ This separation allows:
## Comparison with Flashpoint Launcher
| Feature | Flashpoint Launcher | Flashpoint Web |
| Feature | Flashpoint Launcher | Dotcom Arcade |
| -------------- | ----------------------------- | ------------------------------- |
| Platform | Desktop (Windows, Linux, Mac) | Web browser |
| Interface | Desktop application | Web application |
@@ -141,7 +141,7 @@ This separation allows:
## License
Flashpoint Web is open-source software. (Note: Add appropriate license
Dotcom Arcade is open-source software. (Note: Add appropriate license
information)
## Credits
+1 -1
View File
@@ -442,4 +442,4 @@ For questions about the architecture:
**Last Updated**: 2025-01-18
**Maintainers**: Flashpoint Web Development Team
**Maintainers**: Dotcom Arcade Development Team
+1 -1
View File
@@ -2,7 +2,7 @@
## Overview
Flashpoint Web uses JWT-based authentication with role-based access control
Dotcom Arcade uses JWT-based authentication with role-based access control
(RBAC).
## Authentication Architecture
@@ -2,7 +2,7 @@
## Overview
Flashpoint Web uses HTTP-based communication between two services. Frontend
Dotcom Arcade uses HTTP-based communication between two services. Frontend
communicates with backend via REST API. The backend handles both business logic
and game file serving through integrated routes.
+2 -2
View File
@@ -2,7 +2,7 @@
## Overview
Flashpoint Web is a monorepo containing **2 independent services**: frontend (React UI)
Dotcom Arcade is a monorepo containing **2 independent services**: frontend (React UI)
and backend (Express API with integrated game file serving). Services communicate via HTTP
with clean separation of concerns. The backend handles metadata, authentication, business
logic, and game file serving with hardened resource management (connection limits, stream
@@ -445,7 +445,7 @@ consistent tooling
## Conclusion
Flashpoint Web balances simplicity with scalability, maintaining clean separation
Dotcom Arcade balances simplicity with scalability, maintaining clean separation
of concerns between frontend UI and backend business logic. Game file serving is
integrated into the backend, eliminating inter-service HTTP overhead while
keeping the codebase maintainable and type-safe. The two-service design is
+1 -1
View File
@@ -1,7 +1,7 @@
# Backend Service
The backend is a REST API built with Express and TypeScript that serves as the
central hub for the Flashpoint Web application. It manages game metadata, user
central hub for the Dotcom Arcade application. It manages game metadata, user
accounts, authentication, and serves game content directly via integrated
game-proxying (formerly provided by a separate game-service).
+1 -1
View File
@@ -1,6 +1,6 @@
# Backend Architecture
Architectural patterns and design decisions for the Flashpoint Web backend
Architectural patterns and design decisions for the Dotcom Arcade backend
service.
## Architecture Overview
+1 -1
View File
@@ -1,7 +1,7 @@
# Backend Configuration
This document describes all environment variables and configuration options for
the Flashpoint Web backend service.
the Dotcom Arcade backend service.
## Configuration File
+3 -3
View File
@@ -1,7 +1,7 @@
# Frontend Architecture
This document describes the architectural patterns, design decisions, and
technical implementation details of the Flashpoint Web frontend.
technical implementation details of the Dotcom Arcade frontend.
## Architecture Overview
@@ -205,9 +205,9 @@ Routes are defined in `App.tsx` with nested routing structure:
<AppShell>
<Routes>
{/* Public routes */}
<Route path="/flash-games" element={
<Route path="/browse" element={
<ProtectedRoute requireAuth={false}>
<FlashGamesView />
<BrowseView />
</ProtectedRoute>
} />
@@ -1,7 +1,7 @@
# Component Overview
This document provides an overview of the component architecture and
organization in the Flashpoint Web frontend.
organization in the Dotcom Arcade frontend.
## Component Organization
@@ -1,7 +1,7 @@
# Layout Components
This document describes the layout components that form the structure of the
Flashpoint Web application.
Dotcom Arcade application.
Public pages use `PortalLayout` instead of `AppShell`; see
[Discovery Portal](../../10-features/12-discovery-portal.md). `AppShell` remains
@@ -286,16 +286,7 @@ interface SidebarProps {
```typescript
const gameNavItems: NavItem[] = [
{
path: '/flash-games',
iconImage: '/images/Flash.png',
label: 'Flash Games',
},
{
path: '/html5-games',
iconImage: '/images/HTML5.png',
label: 'HTML5 Games',
},
{ path: '/', icon: Home, label: 'Home' },
{ path: '/animations', icon: Film, label: 'Animations' },
{ path: '/browse', icon: Gamepad2, label: 'Browse' },
];
@@ -360,11 +351,6 @@ const isNavItemActive = (item: NavItem) => {
);
}
// Special case: Home (/) should highlight Flash Games
if (location.pathname === '/' && itemPath === '/flash-games') {
return true;
}
return location.pathname === itemPath;
};
```
+1 -1
View File
@@ -1,6 +1,6 @@
# Custom Hooks
Comprehensive documentation of all custom React hooks in the Flashpoint Web
Comprehensive documentation of all custom React hooks in the Dotcom Arcade
frontend.
## Overview
@@ -1,7 +1,7 @@
# Zustand Stores
This document describes the Zustand stores used for client-side state management
in the Flashpoint Web frontend.
in the Dotcom Arcade frontend.
## Overview
+10 -21
View File
@@ -1,29 +1,29 @@
# Views and Routing
Complete documentation of all views, routes, and route protection in the
Flashpoint Web frontend.
Dotcom Arcade frontend.
## Route Structure
```
/ → Redirects to /flash-games
/ → HomeView (discovery homepage)
/login → LoginView (no auth required)
/register → RegisterView (no auth required)
/unauthorized → UnauthorizedView
# Game Views (Public/Guest Accessible)
/flash-games → FlashGamesView
/html5-games → HTML5GamesView
/browse → BrowseView
/animations → AnimationsView
/games/:id → GameDetailView
/browse/:id → GamePage (also /animations/:id, /games/:id)
/flash/*, /html5/* → Redirect to the same path under /browse
# Protected Routes (Auth Required)
/games/:id/play → GamePlayerView (requires "games.play" permission)
/browse/:id/play → GamePage, game started (requires "games.play")
/dashboard → DashboardView
/favorites → FavoritesView
/playlists → PlaylistsView
/playlists/:id → PlaylistDetailView
/playlists → UserPlaylistsView
/playlists/:id → UserPlaylistDetailView
/flashpoint-playlists → PlaylistsView
/settings → SettingsView
# Admin Routes (Permission-Based)
@@ -91,17 +91,6 @@ interface ProtectedRouteProps {
## Key Views
### FlashGamesView / HTML5GamesView
Pre-filtered game browsing views.
**Features:**
- Platform-specific filtering
- Grid/List view toggle
- Pagination
- Add to favorites/playlists
### BrowseView
Main game browsing view with full filtering.
@@ -191,9 +180,9 @@ Routes are configured in `App.tsx`:
<AppShell>
<Routes>
{/* Public routes */}
<Route path="/flash-games" element={
<Route path="/browse" element={
<ProtectedRoute requireAuth={false}>
<FlashGamesView />
<BrowseView />
</ProtectedRoute>
} />
+1 -3
View File
@@ -1,6 +1,6 @@
# API Reference Documentation
Complete REST API reference for Flashpoint Web backend service.
Complete REST API reference for Dotcom Arcade backend service.
## Base URL
@@ -202,7 +202,6 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`,
- [GET /api/metrics/summary](./admin-api.md#get-metrics-summary)
- [GET /api/metrics/endpoints](./admin-api.md#get-endpoint-metrics)
- [GET /api/jobs](./admin-api.md#list-all-jobs)
- [GET /api/github/stars](./admin-api.md#get-github-star-count)
- [GET /api/settings/auth](./admin-api.md#get-auth-settings)
- [PATCH /api/settings/auth](./admin-api.md#update-auth-settings)
- [GET /game-files/\*](./admin-api.md#get-game-file)
@@ -227,5 +226,4 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`,
## Support
For issues: [GitHub Issues](https://github.com/darkraise/flashpoint-web/issues)
Documentation: [docs/README.md](../README.md)
-13
View File
@@ -213,19 +213,6 @@ Returns `202 Accepted`
Returns progress percentage, stage, estimated completion time.
## GitHub Endpoints
### Get GitHub Star Count
`GET /api/github/stars` - No auth required
```json
{
"success": true,
"data": { "stars": 2847 }
}
```
## Statistics Endpoints
### Get Archive Statistics
+1 -1
View File
@@ -111,7 +111,7 @@ Clears permission cache entries.
| auth | userRegistrationEnabled | boolean | true | - |
| auth | maxLoginAttempts | number | 5 | 1-20 |
| auth | lockoutDurationMinutes | number | 15 | 1-1440 |
| app | siteName | string | Flashpoint Web | maxLength: 100 |
| app | siteName | string | Dotcom Arcade | maxLength: 100 |
| app | maintenanceMode | boolean | false | - |
| app | defaultTheme | string | dark | light\|dark\|system |
| app | defaultPrimaryColor | string | blue | blue\|green\|red\|purple\|orange\|pink |
+1 -1
View File
@@ -1,6 +1,6 @@
# Design System Overview
The Flashpoint Web design system provides a consistent, accessible, and
The Dotcom Arcade design system provides a consistent, accessible, and
beautiful UI framework built on Tailwind CSS, Shadcn UI components, and Lucide
icons.
+1 -1
View File
@@ -1,6 +1,6 @@
# Design Patterns
Common UI patterns and best practices for Flashpoint Web.
Common UI patterns and best practices for Dotcom Arcade.
## Layout Patterns
+1 -1
View File
@@ -1,7 +1,7 @@
# Development Documentation
This directory contains comprehensive documentation for developers working on
the Flashpoint Web application.
the Dotcom Arcade application.
## Contents
+1 -1
View File
@@ -1,6 +1,6 @@
# Coding Standards
Coding standards and best practices for Flashpoint Web development.
Coding standards and best practices for Dotcom Arcade development.
## TypeScript Standards
+1 -1
View File
@@ -1,6 +1,6 @@
# Commands Reference
All npm commands available in Flashpoint Web monorepo.
All npm commands available in Dotcom Arcade monorepo.
## Monorepo Commands
+1 -1
View File
@@ -1,6 +1,6 @@
# Common Pitfalls
Known issues and solutions for Flashpoint Web development.
Known issues and solutions for Dotcom Arcade development.
## Environment Setup Issues
+1 -1
View File
@@ -1,6 +1,6 @@
# Debugging Guide
Debugging Flashpoint Web across all services.
Debugging Dotcom Arcade across all services.
## Development Tools
+1 -1
View File
@@ -1,6 +1,6 @@
# Project Structure
Flashpoint Web codebase organization.
Dotcom Arcade codebase organization.
## Repository Overview
+3 -3
View File
@@ -1,6 +1,6 @@
# Development Setup Guide
Complete guide to setting up Flashpoint Web development environment.
Complete guide to setting up Dotcom Arcade development environment.
## Prerequisites
@@ -38,8 +38,8 @@ git --version # Verify
### 1. Clone Repository
```bash
git clone https://github.com/darkraise/flashpoint-web.git
cd flashpoint-web
git clone <repository-url> dotcom-arcade
cd dotcom-arcade
```
### 2. Install Dependencies
+1 -1
View File
@@ -1,6 +1,6 @@
# Testing Guide
Testing practices for Flashpoint Web.
Testing practices for Dotcom Arcade.
## Testing Philosophy
+1 -1
View File
@@ -1,6 +1,6 @@
# Deployment Documentation
Complete deployment guides for Flashpoint Web in development, staging, and
Complete deployment guides for Dotcom Arcade in development, staging, and
production environments.
## Quick Links
+85 -219
View File
@@ -1,242 +1,108 @@
# Docker Deployment
Deploy Flashpoint Web using Docker Compose.
Deploy Dotcom Arcade with Docker Compose. The [README](../../README.md#getting-started)
walks through a first install; this page covers the details.
## Prerequisites
- Docker Engine 20.10+
- Docker Compose 2.0+
- 4GB+ RAM, 50GB+ disk space
- Flashpoint installation with:
- `Data/flashpoint.sqlite`
- `Data/Games/`
- `Data/Images/`
- `Legacy/htdocs/`
- **Docker:** Engine 24+ with the Compose plugin.
- **Storage:** more than 2.3 TB for Flashpoint Ultimate 14, or 4.6 TB to keep
the downloaded archives.
- **CPU and memory:** 2 cores and 4 GB of RAM for the site. Streaming adds
roughly 1 to 2 CPU cores and 1 GB of RAM per player streaming at once, since
each player's container runs the game and encodes its video on the CPU.
- **A Flashpoint install** at `FLASHPOINT_HOST_PATH`, with
`Data/flashpoint.sqlite`, `Data/Games/`, `Data/Images/`, `Data/Playlists/`
and `Legacy/htdocs/`. `scripts/setup-flashpoint.sh` produces one from the
Flashpoint Ultimate torrent.
## Quick Start
## Services
1. Set Flashpoint path:
| Service | Image | Profile | Purpose |
| ----------------- | ------------------------------ | ----------- | -------------------------------------------------------- |
| `app` | `dotcom-arcade` | (always) | UI, API and game content on port 3100 |
| `session-manager` | `dotcom-arcade-session-manager` | `streaming` | Starts streamer containers and serves their streams, through the app |
| `streamer` | `dotcom-arcade-streamer` | `streaming` | Built only; the session manager runs one per player |
```bash
# Linux/Mac
export FLASHPOINT_HOST_PATH=/path/to/flashpoint
All three are built from this repository by `docker compose up -d --build`.
# Windows PowerShell
$env:FLASHPOINT_HOST_PATH="D:\Flashpoint"
### Prebuilt images
To pull published images instead of building, set `IMAGE_PREFIX` in `.env` to
the registry path they live under, ending in `/`, and optionally `IMAGE_TAG`:
```sh
IMAGE_PREFIX=registry.example.com/owner/
IMAGE_TAG=latest
```
2. Start services:
Then run `docker compose pull && docker compose up -d`.
```bash
docker-compose up -d --build
### Publishing images
`.gitea/workflows/images.yml` builds all three images on Gitea Actions and
pushes them to the Gitea instance's container registry, under the repository's
owner. Pushes to `main` publish a `main` tag; `v1.2.3` tags publish `1.2.3`,
`1.2` and `latest`. It needs two repository secrets:
- `REGISTRY_USERNAME`: a user;
- `REGISTRY_TOKEN`: an access token of theirs with package write access.
## Volumes
| Host | Container | Purpose |
| ----------------------- | ------------------ | -------------------------------------------------------------------- |
| `FLASHPOINT_HOST_PATH` | `/data/flashpoint` | The Flashpoint install. Read-write: downloaded game data and download state are recorded there |
| `DATA_PATH` (`./data`) | `/app/data` | `user.db` and the installed Ruffle. Back this up |
| `LOGS_PATH` (`./logs`) | `/app/logs` | Log files |
The session manager mounts the Flashpoint install read-only and the Docker
socket, and passes `FLASHPOINT_HOST_PATH` on to the streamer containers.
## Ports and networks
- **App:** listens on `WEB_PORT` (default 80) on the host.
- **Session manager:** publishes no ports. The app proxies `/stream/` to it.
- **`dotcom-arcade` network:** the app and the session manager share it.
- **`dotcom-arcade-streams` network:** holds the streamer containers. It's
internal, so they have no internet access and only the session manager can
reach them.
Put a reverse proxy with HTTPS in front of the app. Streaming needs HTTPS, and
the proxy must pass WebSocket upgrades (see
[Streaming](../10-features/13-streaming.md#reverse-proxy)).
## Health checks
The app reports `starting` at `/health` until its database copy and cache
pre-warm finish, which can take minutes on network storage. The compose file
allows 5 minutes before counting failures.
```sh
docker compose ps
docker inspect dotcom-arcade --format '{{json .State.Health}}'
```
3. Access:
## Logs
- Frontend: http://localhost
- Backend API: http://localhost:3100
## Container Architecture
| Service | Image | Port | Purpose |
| -------- | -------------------------- | ---- | --------------------------------- |
| backend | flashpoint-backend:latest | 3100 | REST API (includes game service) |
| frontend | flashpoint-frontend:latest | 80 | Web UI (Nginx) |
**Resource Recommendations:**
- Backend: 2 CPUs, 2GB RAM (includes game service)
- Frontend: 0.5 CPU, 256MB RAM
## Volume Configuration
**Flashpoint Data (read-write):**
```yaml
volumes:
- ${FLASHPOINT_HOST_PATH}:/data/flashpoint
```
**User Database (persistent):**
```yaml
volumes:
- user-db:/app/data
```
## Network Configuration
**Port Mappings:**
| Service | Internal | Host | Notes |
| -------- | -------- | ---- | ----- |
| Frontend | 8080 | 80 | Nginx |
| Backend | 3100 | 3100 | API |
**Custom ports** via environment variables:
```bash
export WEB_PORT=8080
export API_PORT=8001
export FLASHPOINT_HOST_PATH=/path/to/flashpoint
docker-compose up -d
```
## Health Checks
All services include health checks:
```yaml
healthcheck:
test:
[
'CMD',
'wget',
'--quiet',
'--tries=1',
'--spider',
'http://localhost:3100/api/health',
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
Check status:
```bash
docker-compose ps
```
## Nginx Configuration
The frontend uses a template-based configuration with environment variable
substitution.
**Key files:**
- `frontend/.nginx/nginx.conf` - Global settings (worker processes, logging)
- `frontend/.nginx/default.conf.template` - Server block with `${BACKEND_HOST}`,
`${BACKEND_PORT}` variables
**At runtime:**
1. Docker entrypoint runs `envsubst` to substitute variables
2. Processed config written to `/etc/nginx/conf.d/default.conf`
3. Nginx starts with final configuration
**Features:**
- SPA routing (serves index.html for non-static routes)
- Gzip compression
- Security headers (HSTS, CSP, X-Frame-Options)
- Static asset caching (1 year for versioned assets, no-cache for HTML)
## Running Containers
Start all services:
```bash
docker-compose up -d
```
Stop services:
```bash
docker-compose down
```
Restart with rebuild:
```bash
docker-compose up -d --build --force-recreate
```
## Monitoring and Logs
View logs:
```bash
docker-compose logs -f backend
docker-compose logs -f frontend
```
Container stats:
```bash
docker stats
```
Execute command in container:
```bash
docker-compose exec backend node -v
docker-compose exec backend sh # Open shell
```sh
docker compose logs -f app
docker compose logs -f session-manager
```
## Troubleshooting
**Port already in use:**
- **"Source database not found":** `FLASHPOINT_HOST_PATH` doesn't point at a
folder with `Data/flashpoint.sqlite`. Check with
`docker compose exec app ls /data/flashpoint/Data`.
- **Missing playlists or settings:** `Data/Playlists` or `preferences.json` is
missing. Re-run `scripts/setup-flashpoint.sh`, which takes them from
`Base_1.zip`, or pass `--infinity`.
- **Permission errors on `./data` or `./logs`:** set `PUID`/`PGID` to the owner
of those folders.
```bash
# Linux/Mac
lsof -i :3100
# Windows
netstat -ano | findstr :3100
```
**Volume mount failed:**
- Verify `FLASHPOINT_HOST_PATH` is set
- Check path exists and is readable
- On Windows, ensure Docker has drive access
**Health check failing:**
```bash
# Check directly
curl http://localhost:3100/api/health
# From container
docker-compose exec backend curl http://localhost:3100/api/health
# View health details
docker inspect flashpoint-backend --format='{{json .State.Health}}' | jq
```
**Database connection issues:**
```bash
# Verify database exists
docker-compose exec backend ls -la /data/flashpoint/Data/flashpoint.sqlite
# Test connection
docker-compose exec backend sqlite3 /data/flashpoint/Data/flashpoint.sqlite "SELECT COUNT(*) FROM game;"
```
**Service communication:**
```bash
# Test backend health
docker-compose exec backend curl http://localhost:3100/api/health
```
## Best Practices
1. Use named volumes for persistent data
2. Set resource limits to prevent resource exhaustion
3. Enable health checks for automatic recovery
4. Restrict the Flashpoint mount to the single directory the app needs
5. Run as non-root user
6. Keep images updated with security patches
7. Monitor logs regularly
8. Backup user database before updates
9. Use .env file for configuration
10. Tag images with version numbers in production
## Next Steps
## Next steps
- [Environment Variables](./environment-variables.md)
- [Security Considerations](./security-considerations.md)
- [Upgrade Guide](./upgrade-guide.md)
+19 -13
View File
@@ -1,6 +1,6 @@
# Environment Variables
Configuration reference for all Flashpoint Web services.
Configuration reference for all Dotcom Arcade services.
## Backend Variables
@@ -103,7 +103,6 @@ Two notes that save debugging time:
| Variable | Default | Description |
| -------------------- | ------- | ------------------------------------------------ |
| `ENABLE_CGI` | false | Enable PHP CGI execution for legacy game content |
| `HOME_RECENT_HOURS` | 24 | Hours to look back for "recently added" games |
**Frontend Serving:**
@@ -116,23 +115,30 @@ and API share one origin.
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist` | Location of the built frontend (rarely needed) |
| `RUFFLE_DATA_PATH` | `/app/data/ruffle` in production | Where Ruffle is installed. Must sit on a mounted volume, or an in-app update is lost when the container is recreated |
**Images and updates:**
| Variable | Default | Description |
| ---------------- | -------- | ------------------------------------------------------------------------------------------------- |
| `IMAGE_PREFIX` | - | Registry path to pull images from, ending in `/`. Unset builds them from source |
| `IMAGE_TAG` | `latest` | Image tag to build or pull |
| `APP_UPDATE_URL` | - | "Latest release" API URL (GitHub or Gitea) that Settings checks for new versions; off when unset |
**Streaming:**
Games that can't run in a browser, such as Java applets, can be streamed from a
server-side container by a separate session manager (flashpoint-ultimate-server).
The backend asks it which games are streamable and starts sessions for players.
Browsers reach each stream at `/stream/<id>/` on this site, so the reverse proxy
in front of flashpoint-web must route `/stream/` to the session manager,
including WebSocket upgrades. Saves for streamed games are kept in each
browser's IndexedDB.
Games that can't run in a browser, such as Java applets and Shockwave, can be
streamed by the session manager under `streaming/`. It runs under the
`streaming` compose profile. See [Streaming](../10-features/13-streaming.md).
| Variable | Default | Description |
| --------------------- | ------- | ---------------------------------------------------------------------- |
| `STREAMING_API_URL` | - | Session manager's internal API, e.g. `http://flashpoint-session-manager:8080` |
| `STREAMING_API_TOKEN` | - | Token the session manager expects. Streaming is off unless both are set |
| Variable | Default | Description |
| --------------------- | ----------------------------- | --------------------------------------------------------------- |
| `STREAMING_API_URL` | `http://session-manager:8080` | Session manager's internal API |
| `STREAMING_STREAM_URL` | `http://session-manager:8081` | Where the app proxies `/stream/` to |
| `STREAMING_API_TOKEN` | - | Token shared with the session manager. Streaming is off without it |
**Trusted Header Login:**
See also [Proxy Sign-In](../10-features/14-proxy-sign-in.md).
When a reverse proxy such as Authentik (forward auth) authenticates users in
front of flashpoint-web, it can pass the signed-in username in a header so
users don't log in twice. The backend believes the header only on connections
+1 -1
View File
@@ -1,6 +1,6 @@
# Health Checks Configuration
Docker container health checks for Flashpoint Web services.
Docker container health checks for Dotcom Arcade services.
## Health Check Configuration
@@ -1,6 +1,6 @@
# Security Considerations
Production deployment security checklist for Flashpoint Web.
Production deployment security checklist for Dotcom Arcade.
## Pre-Production Checklist
+27 -235
View File
@@ -1,244 +1,36 @@
# Upgrade Guide and Release Notes
# Upgrade Guide
How to move an existing deployment to a newer version, and what changed in each
release. Versions follow the git tags published on Docker Hub.
## Updating
> **Upgrading from 1.0.31 or earlier?** The deployment model changed in 1.0.32:
> the separate backend and frontend containers were replaced by a single image.
> Read [Upgrading from 1.0.31](#upgrading-from-1031) before pulling.
---
## Upgrading from 1.0.31
### What changed
Up to 1.0.31 a deployment ran two containers: `darkraise/flashpoint-backend`
(Node/Express, port 3100) and `darkraise/flashpoint-frontend` (nginx serving the
built React app on port 8080, proxying `/api` to the backend).
From 1.0.32 there is one image, `darkraise/flashpoint-web`. The Express backend
serves the built React UI and its SPA fallback alongside the API and game
content, so the whole app lives on one origin and one port.
```
Before (<= 1.0.31) After (>= 1.0.32)
flashpoint-frontend :80 -> nginx flashpoint-web :80 -> :3100
flashpoint-backend :3100 -> Express / built React UI
/api, /game-proxy, /game-zip
```sh
git pull
docker compose up -d --build
```
The old `flashpoint-backend` and `flashpoint-frontend` images are no longer
built or published. 1.0.31 is the last version available under those names.
Database migrations for `user.db` run automatically when the app starts. Back
up `DATA_PATH` (default `./data`) first if you want a way back.
### Migration steps
## Moving from flashpoint-web
1. **Stop and remove the old stack.** From the directory holding your existing
compose file:
Dotcom Arcade keeps flashpoint-web's data format, so an existing install can
switch without losing accounts, playlists, favorites or play history.
```bash
docker compose down
```
1. Stop the old stack in its own directory with `docker compose down`. If you
ran flashpoint-ultimate-server's session manager, stop that too.
2. Copy the old `.env` next to Dotcom Arcade's `docker-compose.yml`. Then
compare it with `.env.example` for the new streaming settings.
3. Point `DATA_PATH` at the old data directory, which holds `user.db` and the
installed Ruffle.
4. Start with `docker compose up -d --build`.
This removes the `flashpoint-backend` and `flashpoint-frontend` containers.
Named volumes and bind mounts are kept.
What changes:
2. **Replace `docker-compose.yml`** with the single-service version from the
[README](../../README.md#2-create-docker-composeyml). The service is now named
`flashpoint-web`, maps `${WEB_PORT:-80}` to container port `3100`, and has no
`frontend` service and no `depends_on`.
3. **Update `.env`** — see the variable tables below. At minimum, delete
`API_PORT`, `BACKEND_HOST`, and `BACKEND_PORT`; they are no longer read by
anything.
4. **Check the Flashpoint mount.** As of 1.0.36 the shipped compose mounts it
read-write (no `:ro`). Downloading games, recording downloaded state in
`flashpoint.sqlite`, and metadata sync all write into that directory. Leaving
`:ro` in place still allows browsing and playing already-downloaded games, but
the Downloaded page will stay empty and the reconciler logs a warning on every
boot.
5. **Update your reverse proxy, if you have one.** Configurations that defined
two upstreams — the UI on the web port and the API on port 3100 — must be
collapsed into a single upstream on `WEB_PORT`. Any rule routing `/api` to
port 3100 will break, because nothing listens there on the host anymore.
Firewall rules opening port 3100 can be removed.
6. **Pull and start.**
```bash
docker compose pull && docker compose up -d
```
### What is preserved
Application data is untouched by the migration. `user.db` (accounts, playlists,
favourites, play history, settings) lives in the `DATA_PATH` bind mount at
`/app/data` and is used unchanged by the new image. Log and Flashpoint mounts
keep their paths. No database migration is required beyond the ones the backend
applies on startup.
### Environment variable changes
**Removed** — delete these from `.env`; they have no effect:
| Variable | Replacement |
| -------------- | ---------------------------------------------------- |
| `API_PORT` | `WEB_PORT` — one port now serves both the UI and API |
| `BACKEND_HOST` | none — there is no cross-container proxy |
| `BACKEND_PORT` | none — same reason |
`PORT` and `HOST` were documented as configurable before 1.0.32 but never were:
the backend binds `0.0.0.0:3100` unconditionally, and the Docker `EXPOSE` and
healthcheck depend on that. The docs were corrected rather than the code.
**Added:**
| Variable | Default | Since | Description |
| ------------------------------ | -------------------------------- | ------ | --------------------------------------------------------------- |
| `SERVE_FRONTEND` | `true` in production, else false | 1.0.32 | Serve the built UI and SPA fallback from the backend |
| `FRONTEND_DIST_PATH` | `/app/frontend/dist` in image | 1.0.32 | Where the built UI lives; rarely needs overriding |
| `FLASHPOINT_GAMES_PATH` | `<FLASHPOINT_PATH>/Data/Games` | 1.0.32 | Override the game ZIP directory |
| `OTEL_METRICS_EXPORT_INTERVAL` | `60000` | 1.0.32 | Metrics export interval in milliseconds |
| `OTEL_LOG_LEVEL` | `info` | 1.0.32 | OpenTelemetry SDK log level |
| `COOKIE_SECURE` | `auto` | 1.0.34 | Secure flag on auth cookies: `auto`, `true`, `false` |
| `UV_THREADPOOL_SIZE` | `16` | 1.0.36 | File I/O threads; Node's own default of 4 throttles image reads |
| `DB_WORKER_COUNT` | `4` | 1.0.36 | Read-only SQLite query worker threads (1–16) |
`UV_THREADPOOL_SIZE=16` is baked into the image, so it applies whether or not
your compose file passes it.
**Changed:**
| Variable | Change |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DOMAIN` | Now accepts a comma-separated list of origins. Same-origin requests are always allowed, so a standard single-image install needs no value at all. Default ports are normalized: `http://host:80` and `http://host` are treated as the same origin. |
| `IMAGE_TAG` | Selects the tag of `darkraise/flashpoint-web` instead of the two old images. |
| `OTEL_SERVICE_NAME` | Default changed from `flashpoint-web-backend` to `flashpoint-web`. Update dashboards and alert queries that filter on the old name. |
| `LOGS_PATH` | Dev compose mounts it at `/app/logs` directly; it previously mounted `${LOGS_PATH}/backend`. |
**Newly passed through compose** (these already existed in the backend, but the
shipped compose files now forward them with documented defaults):
`JWT_EXPIRES_IN`, `BCRYPT_SALT_ROUNDS`, `RATE_LIMIT_WINDOW_MS`,
`RATE_LIMIT_MAX_REQUESTS`, `HOME_RECENT_HOURS`, `ENABLE_CGI`.
`FLASHPOINT_PATH=/data/flashpoint` is now set explicitly in the dev compose file.
The backend's fallback only points there when `NODE_ENV=production`, so a
development container previously looked at the wrong path.
### Rolling back
Pin the old images (`darkraise/flashpoint-backend:1.0.31` and
`darkraise/flashpoint-frontend:1.0.31`) and restore the two-service compose file
and the `.env` entries removed in step 3. Application data written by 1.0.32+ is
readable by 1.0.31. Downloaded state is recorded in Flashpoint's own
`presentOnDisk` and `path` columns, which the Launcher already understands;
1.0.31 simply does not surface it.
---
## [1.0.36] — 2026-08-12
### Fixed
- **Flashpoint directory is mounted read-write by default.** The shipped compose
files mounted it `:ro`, which silently failed every write the app makes:
downloaded-state flags in `flashpoint.sqlite`, downloaded game data under
`Data/Games`, and metadata sync. If you copied an earlier compose file, drop
the `:ro` suffix.
- Image preloads are cancelled when a request is abandoned, so scrolling a large
library no longer queues work for images that left the viewport.
### Added
- A metadata sync can optionally download game images afterwards.
### Performance
- Heavy read-only queries run in SQLite worker threads (`DB_WORKER_COUNT`,
default 4). `better-sqlite3` is synchronous, and a filter-options query over a
slow mount was measured blocking the entire server for 167 seconds — `/health`
timed out along with everything else.
- `UV_THREADPOOL_SIZE` is set to 16 in the image. libuv's default of 4 throttled
every image and ZIP read in the process, which dominates on network storage.
## [1.0.35] — 2026-08-12
### Added
- Admins can choose the metadata source used for syncs.
### Fixed
- Ruffle installs in the background and into the directory that is actually
served (`frontend/dist/ruffle`). Startup previously awaited the download, so
the port stayed closed for minutes on slow storage — indefinitely if the
download stalled — and a fresh container re-downloaded the Ruffle it already
shipped.
- Locally served images are cached and no longer block on `stat`.
- `npm test` runs the backend suite once instead of entering watch mode.
## [1.0.34] — 2026-08-11
### Fixed
- **Auth cookies are marked `Secure` per connection, not per `NODE_ENV`.**
Production builds previously always set `Secure`, and browsers discard such a
cookie when it arrives over plain HTTP — logging LAN users straight back out
after login. The new default, `COOKIE_SECURE=auto`, decides per request based
on TLS (reading `X-Forwarded-Proto` when behind a proxy), so one deployment can
serve HTTP on a LAN and HTTPS through a proxy at once. Set `COOKIE_SECURE=true`
if your TLS-terminating proxy does not send that header.
### Documentation
- README carries a minimal working `.env` example and refreshed sections.
## [1.0.33] — 2026-08-11
### Fixed
- **CORS origins are normalized and same-origin requests are always allowed.**
With the UI and API on one origin, most deployments no longer need `DOMAIN` at
all. `DOMAIN` also accepts a comma-separated list, and explicit default ports
(`:80`, `:443`) match the port-less origin a browser actually sends.
- The dev compose file passes `FLASHPOINT_PATH=/data/flashpoint` explicitly.
### Performance
- Downloaded filter options are cached indefinitely.
## [1.0.32] — 2026-08-11
### Breaking
- **Single image deployment.** The backend and frontend images were collapsed
into `darkraise/flashpoint-web`, which serves the API, game content, and the
built UI from one Express process on port 3100. The `frontend` service, its
nginx config and entrypoint, and the per-service Dockerfiles are gone; the
release workflow builds one image instead of a two-service matrix. See
[Upgrading from 1.0.31](#upgrading-from-1031).
- `API_PORT`, `BACKEND_HOST`, and `BACKEND_PORT` were removed. Map a host port
with `WEB_PORT` instead.
### Added
- Downloaded games page, filter, and settings: browse games whose data is already
on disk, with the filter encoded in the URL and downloaded state reconciled
from disk at startup.
- 5-star game rating system.
- Setting to toggle the "I'm Feeling Lucky" button.
### Security
- The SPA-oriented CSP was ported from nginx into Helmet and hardened, dropping
`unsafe-inline` and `unsafe-eval`. The strict API-only policy still applies
when `SERVE_FRONTEND` is off.
### Fixed
- Guest browsing survives auth-only 401s instead of falling into a redirect loop
(thanks @MaxfieldKassel).
- Invalid `SQLITE_CACHE_SIZE` and `SQLITE_MMAP_SIZE` values fall back to their
defaults instead of becoming `NaN`.
- **Container and network names.** The app container is now `dotcom-arcade`,
and the network is `dotcom-arcade`. If your reverse proxy reached the old
`flashpoint-web` container by name, update it.
- **Site name.** If your site name was still the default "Flashpoint Web", it
becomes "Dotcom Arcade". A name you set yourself is kept.
- **Streaming config.** Streaming is now part of the same compose file. Replace
the separate `API_TOKEN` and `STREAMING_API_URL` with `STREAMING_API_TOKEN`
and `COMPOSE_PROFILES=streaming` (see [Streaming](../10-features/13-streaming.md)).
The `/stream/` proxy route is unchanged.
+1 -1
View File
@@ -2,7 +2,7 @@
## Overview
Flashpoint Web provides three systems for organizing games:
Dotcom Arcade provides three systems for organizing games:
1. **Flashpoint Playlists** - JSON files compatible with Flashpoint Launcher
2. **User Playlists** - Database-driven per-user playlists with sharing
+3 -3
View File
@@ -3,7 +3,7 @@
## Overview
System Settings provides flexible, centralized configuration management for
Flashpoint Web. Settings are organized into categories, validated using JSON
Dotcom Arcade. Settings are organized into categories, validated using JSON
schemas, cached for performance, and accessible through web UI and REST API.
## Settings Categories
@@ -98,7 +98,7 @@ to `window.location.origin`.
#### Update Tab
- **Flashpoint Web** - Compares the running version against the latest GitHub
- **Dotcom Arcade** - Compares the running version against the latest GitHub
release and shows the changelog and upgrade command. Notify only: it never
pulls or restarts anything. A build with no release version — a source
checkout, or an image built without the `VERSION` build arg — reports that
@@ -184,7 +184,7 @@ All settings use JSON Schema validation:
**App Defaults:**
- siteName: "Flashpoint Web"
- siteName: "Dotcom Arcade"
- maintenanceMode: false
- defaultTheme: "dark"
- defaultPrimaryColor: "blue"
+1 -1
View File
@@ -3,7 +3,7 @@
## Overview
Date & Time Formatting provides customizable date and time display formats
throughout Flashpoint Web. Users can choose from multiple format options that
throughout Dotcom Arcade. Users can choose from multiple format options that
apply globally to all date and time displays, ensuring consistent and localized
presentation.
+122
View File
@@ -0,0 +1,122 @@
# Streaming
## Overview
Some Flashpoint games can't run in a browser at all: Java applets, Shockwave
and Director movies, and Flash games that need the real Flash Player. Dotcom
Arcade runs each one natively on the server and streams it into the game page.
Every player gets their own container, which is removed when they leave.
Streaming is optional and off by default. Without it, those games' pages say
they need the Flashpoint Launcher.
## How it works
- **The app** (`backend/`) asks the session manager whether each game can be
streamed. It then offers Play, plus the Ruffle / original Flash Player switch
for Flash games.
- **The session manager** (`streaming/session-manager`) starts one streamer
container per session through the Docker socket, and proxies the browser's
connection to it at `/stream/<id>/`. The app forwards `/stream/` to it.
- **The streamer** (`streaming/streamer`) launches the game the way
Flashpoint's Linux launcher does, and streams the screen and sound with
[Selkies](https://github.com/selkies-project/selkies). The display is sized to
the game: a Director stage, a SWF's stage, or a page's applet or largest
embed.
A session is removed if nothing connects within 2 minutes, or 30 seconds after
the player's last connection closes. At most `MAX_SESSIONS` run at once. Games
save to files inside the container; those are sent back to the player's browser
as they change, and restored at the start of the next session (see
[Game Saves](./15-game-saves.md)).
Implementation details are in [streaming/README.md](../../streaming/README.md)
and [streaming/streamer/README.md](../../streaming/streamer/README.md).
## Requirements
- **Infinity for Linux's software.** The streamer runs Flashpoint's Linux
builds of the game server, Java, Wine and the Flash and Shockwave players.
Ultimate's archives only include the Windows builds. Download Flashpoint
Infinity for Linux from Flashpoint's downloads page and pass it to
`scripts/setup-flashpoint.sh --infinity`, which adds the software without
replacing anything.
- **HTTPS.** Browsers only allow the video decoder Selkies uses on secure
pages.
- **A server with CPU to spare.** Every player streaming at once has a
container running the game and encoding its video on the CPU. Plan on roughly
1 to 2 CPU cores and 1 GB of RAM each, and a few Mbps of upload. Set
`MAX_SESSIONS` so the server isn't overloaded.
## Enabling it
Add to `.env`:
```sh
COMPOSE_PROFILES=streaming
STREAMING_API_TOKEN=<output of: openssl rand -hex 32>
```
Then run `docker compose up -d --build`. This builds the session manager and
streamer images and starts the session manager. The app reaches the session
manager's internal API at `http://session-manager:8080`, which the compose file
sets up.
| Variable | Default | Description |
| --------------------- | ----------------------------- | ----------------------------------------------------------------- |
| `STREAMING_API_TOKEN` | - | Shared secret between the app and the session manager; required |
| `STREAMING_API_URL` | `http://session-manager:8080` | Session manager's internal API |
| `STREAMING_STREAM_URL` | `http://session-manager:8081` | Where the app proxies `/stream/` to |
| `MAX_SESSIONS` | `4` | Most streams running at once |
| `STREAM_PLATFORMS` | `Java,Shockwave,Flash` | Platforms offered for streaming |
## Reverse proxy
The app proxies `/stream/` to the session manager itself, so no extra route is
needed. A reverse proxy in front of the app must still:
- pass WebSocket upgrades;
- allow long-lived connections, at least as long as a play session;
- not buffer responses, which would hold back the save-sync event stream.
With nginx:
```nginx
location / {
proxy_pass http://127.0.0.1:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_buffering off;
proxy_read_timeout 1h;
}
```
If another proxy sits in front of this one, it needs the same. A stream that
connects and then immediately fails with HTTP 400 usually means a proxy in the
chain doesn't pass the WebSocket upgrade.
## Security
The session manager holds the Docker socket, which is effectively root on the
host. It publishes no ports; only the app reaches it, on the compose network.
Streamer containers run on an internal network with no internet access,
reachable only from the session manager.
## Troubleshooting
- **No "Original Flash Player" button, or "needs the Flashpoint Launcher" on
Java and Shockwave games.** The session manager isn't running, the tokens
don't match, or it can't resolve the game's launcher; check that Infinity's
software is installed. Ask it directly from the app's container:
```sh
docker compose exec app sh -c 'curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $STREAMING_API_TOKEN" \
http://session-manager:8080/games/<game id>'
```
`200` means the game can be streamed; `422` means it can't.
- **The stream stays black or disconnects at once.** Check that the page is on
HTTPS, and that your proxy passes WebSocket upgrades.
+60
View File
@@ -0,0 +1,60 @@
# Proxy Sign-In
## Overview
If an authenticating reverse proxy sits in front of Dotcom Arcade, such as
Authentik, Authelia or oauth2-proxy, the app can sign users in as whoever the
proxy says they are, so they don't log in twice. The proxy handles the login,
including any OAuth or OpenID Connect provider behind it, and passes the
username along in a header.
## Configuration
| Variable | Default | Description |
| ------------------------- | ------- | ------------------------------------------------------------------------ |
| `TRUSTED_HEADER_USERNAME` | - | Header holding the username |
| `TRUSTED_HEADER_EMAIL` | - | Optional header holding the email used for new accounts |
| `TRUSTED_PROXIES` | - | Comma-separated proxy addresses or CIDR ranges allowed to set the header |
Proxy sign-in is off unless both `TRUSTED_HEADER_USERNAME` and
`TRUSTED_PROXIES` are set.
The headers each proxy sets:
| Proxy | Username header | Email header |
| ------------------------------------------ | ---------------------- | ----------------------- |
| Authentik (forward auth) | `X-authentik-username` | `X-authentik-email` |
| Authelia (forward auth) | `Remote-User` | `Remote-Email` |
| oauth2-proxy (`--set-xauthrequest`) | `X-Auth-Request-User` | `X-Auth-Request-Email` |
With nginx forward auth, copy the header from the auth response into the
request to the app. Authentik's generated nginx snippet already does this;
adding the lines again sends each header twice. For oauth2-proxy:
```nginx
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $email $upstream_http_x_auth_request_email;
proxy_set_header X-Auth-Request-User $user;
proxy_set_header X-Auth-Request-Email $email;
```
## Security
The app believes the header only on connections from `TRUSTED_PROXIES`, checked
against the connection's own address, not `X-Forwarded-For`. So:
- The proxy must always set the header, overwriting anything a client sends.
- The app must not be reachable from untrusted networks except through the
proxy.
- If the header arrives more than once with different values, the request is
refused.
## Accounts
- **Matching:** users are matched by username, so an existing account with that
name is signed in.
- **New accounts:** otherwise an account is created. The first one becomes
admin, as with registration. New accounts get a random password.
- **Passwords:** while signed in through the proxy, users can set a password
from Settings without knowing the current one. They can then log in with it
when reaching the app without the proxy, such as on a LAN.
+59
View File
@@ -0,0 +1,59 @@
# Game Saves
## Overview
Games keep their saves in the player's browser. For signed-in players, Dotcom
Arcade also keeps them on their account, so a save made on one computer is
there on the next, and the last 10 distinct saves of each game can be restored.
Guests keep browser-only saves.
## What a save is
A save is a snapshot of up to three kinds of browser storage:
- **Streamed games' files:** what a streamed game wrote to its save directory.
It's sent back from the streamer as it changes, and kept in the
`flashpoint-stream-saves` IndexedDB database.
- **localStorage:** Ruffle's Flash SharedObjects, and HTML5 games' localStorage.
- **IndexedDB:** HTML5 games' databases, including Unity WebGL's `/idbfs`.
Games share the app's origin, so their keys can't tell which game wrote them.
The app works it out by watching instead: whatever changes while a game is
playing belongs to that game, and it remembers that for next time. The app's own
storage is never included.
## Sync
Before a game starts, the app compares the browser's save with the account's
latest:
- If they're the same, nothing happens.
- If only one side changed since this browser last synced, that side wins.
- If both changed, the newer one wins. The other is still kept as a backup.
While playing, changes are uploaded 10 seconds after they happen, when the
player leaves the game, and when the tab closes.
## Restoring
The **Saves** button under the game lists the account's backups with their
times and sizes. Restoring one applies it and restarts the game. The save it
replaced stays in the list.
## Limits
- **Size:** each snapshot is at most 10 MB.
- **History:** the 10 newest distinct snapshots per user and game are kept.
- **Not covered:** cookies set by HTML5 games.
- **No merging:** when two browsers both changed a save, the newer one wins
whole. Saves aren't combined.
## API
Endpoints require a signed-in user with `games.play`:
| Endpoint | Purpose |
| ------------------------------------------- | ------------------------------------------------------------ |
| `GET /api/game-saves/:gameId` | Latest snapshot with its data, plus metadata for all backups |
| `GET /api/game-saves/:gameId/:id` | One snapshot with its data |
| `POST /api/game-saves/:gameId` | Upload `{data, hash, savedAt}`; a duplicate of the latest is a no-op |
+6
View File
@@ -29,6 +29,12 @@ Web application.
during system updates while allowing admin access
12. [Discovery Portal](./12-discovery-portal.md) - Arcade portal layout, color
schemes, discovery homepage and mature content filtering
13. [Streaming](./13-streaming.md) - Java, Shockwave and original Flash Player
games streamed from the server, one container per player
14. [Proxy Sign-In](./14-proxy-sign-in.md) - Sign users in through Authentik,
Authelia, oauth2-proxy and other forward-auth proxies
15. [Game Saves](./15-game-saves.md) - Saves kept on the player's account, with
backups to restore
## Quick Reference
+1 -1
View File
@@ -1,6 +1,6 @@
# Mermaid Diagrams
This directory contains Mermaid diagram source files for the Flashpoint Web
This directory contains Mermaid diagram source files for the Dotcom Arcade
documentation. These diagrams can be rendered in any Markdown viewer that
supports Mermaid syntax (GitHub, GitLab, VS Code with Mermaid extension, etc.).
+1 -1
View File
@@ -1,4 +1,4 @@
%% Flashpoint Web System Architecture
%% Dotcom Arcade System Architecture
%% Complete system architecture showing all three services, databases, and data flow
graph TB
+1 -1
View File
@@ -1,6 +1,6 @@
# Reference Documentation
This directory contains comprehensive reference materials for the Flashpoint Web
This directory contains comprehensive reference materials for the Dotcom Arcade
application, including database schemas, type definitions, terminology, and
dependency information.
@@ -1,6 +1,6 @@
# Database Schema Reference
Reference for Flashpoint Web database schemas: flashpoint.sqlite (read-only) and
Reference for Dotcom Arcade database schemas: flashpoint.sqlite (read-only) and
user.db (read-write).
## Flashpoint Database (flashpoint.sqlite)
+1 -1
View File
@@ -1,6 +1,6 @@
# External Dependencies Reference
Reference for all npm packages used across the Flashpoint Web monorepo.
Reference for all npm packages used across the Dotcom Arcade monorepo.
## Backend Dependencies
+3 -3
View File
@@ -1,6 +1,6 @@
# Glossary
Technical terms and project-specific terminology used throughout Flashpoint Web.
Technical terms and project-specific terminology used throughout Dotcom Arcade.
## Project-Specific Terms
@@ -50,7 +50,7 @@ origins for public game embedding.
**bcrypt** - Password hashing function based on Blowfish cipher. Cost factor 10
(2^10 = 1024 rounds). Automatically salts and one-way hashes.
**SQLite** - Serverless SQL database. Flashpoint Web uses two: flashpoint.sqlite
**SQLite** - Serverless SQL database. Dotcom Arcade uses two: flashpoint.sqlite
(read-only) and user.db (read-write). No separate server required.
**Hot-Reload** - Automatic reloading when database files change. DatabaseService
@@ -183,7 +183,7 @@ ActionScript, timeline animation. MIME: application/x-shockwave-flash.
3 (performance/modern OOP).
**Shockwave** - Predecessor to Flash by Adobe/Macromedia. File format: DCR. Not
currently supported in Flashpoint Web.
currently supported in Dotcom Arcade.
**HTML5 Games** - Games using standard web tech (HTML, CSS, JavaScript). Canvas,
Web Audio, WebGL APIs.
+1 -1
View File
@@ -2,7 +2,7 @@
## Overview
This document catalogs all security measures implemented in the Flashpoint Web
This document catalogs all security measures implemented in the Dotcom Arcade
application, addressing OWASP Top 10 vulnerabilities and security best
practices.
+1 -1
View File
@@ -1,6 +1,6 @@
# Type Definitions Reference
Core TypeScript types and interfaces used throughout Flashpoint Web.
Core TypeScript types and interfaces used throughout Dotcom Arcade.
## Game Types
@@ -1,328 +0,0 @@
# Games Filter Panel & Filter Logic - Code Review
**Date:** 2026-02-15
**Reviewer:** Claude
**Status:** Review Complete
## Overview
The filter system spans frontend components, hooks, API layer, and backend services. Overall, the implementation is solid with good patterns like config-driven rendering and proper caching. However, there are several issues and improvement opportunities.
## Files Reviewed
### Frontend Components
- `frontend/src/components/search/FilterPanel.tsx`
- `frontend/src/components/search/FilterDropdown.tsx`
- `frontend/src/components/search/FilterChips.tsx`
- `frontend/src/components/search/filterConfig.ts`
- `frontend/src/components/search/SearchBar.tsx`
- `frontend/src/components/search/SortControls.tsx`
- `frontend/src/components/library/GameBrowseLayout.tsx`
### Frontend Hooks & Types
- `frontend/src/hooks/useGames.ts`
- `frontend/src/hooks/useFilterOptions.ts`
- `frontend/src/hooks/useFilterDropdowns.ts`
- `frontend/src/types/game.ts`
- `frontend/src/lib/api/games.ts`
### Backend
- `backend/src/routes/games.ts`
- `backend/src/services/GameService.ts`
---
## Issues Found
### 1. Type Safety Issues
| Location | Issue | Severity |
|----------|-------|----------|
| `FilterPanel.tsx:22-23` | Unsafe `split()` on potentially undefined value | Medium |
| `GameBrowseLayout.tsx:72` | Type assertion `as 'asc' | 'desc'` without validation | Medium |
| `FilterDropdown.tsx:42` | No validation that `selectedValues` array items exist in `options` | Low |
```typescript
// FilterPanel.tsx:22-23 - currentValue could be number/undefined for yearFrom/yearTo
const current = typeof currentValue === 'string' ? currentValue.split(',').filter(Boolean) : [];
// This works but paramKey includes non-string fields (yearFrom, yearTo, page, limit)
```
### 2. Missing Year Range Filter in UI
| Location | Issue | Severity |
|----------|-------|----------|
| `filterConfig.ts` | `yearFrom`/`yearTo` filters defined in `GameFilters` but not exposed in UI | Medium |
| `FilterPanel.tsx` | No year range slider or inputs despite backend support | Medium |
The `FilterOptions.yearRange` is fetched but never used - users cannot filter by release year.
### 3. Inconsistent Filter Chips Implementation
| Location | Issue | Severity |
|----------|-------|----------|
| `FilterPanel.tsx:105-140` | `renderFilterBadges()` duplicates logic already in `FilterChips.tsx` | Low |
| `GameBrowseLayout.tsx:110-198` | Separate filter chip generation vs `FilterChips` component | Low |
Two different chip rendering systems exist:
- `FilterPanel.tsx` renders inline badges
- `GameBrowseLayout.tsx` creates `FilterChip[]` array for `FilterChips` component
### 4. Performance: Redundant Handlers Recreation
| Location | Issue | Severity |
|----------|-------|----------|
| `FilterPanel.tsx:68-74` | `createFilterHandlers()` called on every render for every filter | Low |
| `FilterPanel.tsx:113-119` | Same handlers recreated in `renderFilterBadges()` | Low |
```typescript
// Called inside map() on every render
const handlers = createFilterHandlers(
config.paramKey,
config.id,
filters,
updateFilter,
dropdowns
);
```
### 5. Missing Search Debouncing in SearchBar
| Location | Issue | Severity |
|----------|-------|----------|
| `SearchBar.tsx:10-15` | No debounce - navigates immediately on form submit | Low |
| N/A | No "search as you type" with debounce option | Enhancement |
### 6. Backend: Filter Options Limit Inconsistency
| Location | Issue | Severity |
|----------|-------|----------|
| `GameService.ts:706-707` | Series limited to 100 | Low |
| `GameService.ts:730-731` | Developers limited to 100 | Low |
| `GameService.ts:780-781` | Play modes NOT limited | Inconsistent |
| `GameService.ts:849` | Tags limited to 100 | Low |
Play modes query lacks `LIMIT 100` that other filter queries have.
### 7. Missing Input Validation on Frontend
| Location | Issue | Severity |
|----------|-------|----------|
| `GameBrowseLayout.tsx:72` | `page` parsed without `isNaN()` check | Medium |
| `GameBrowseLayout.tsx:58-68` | Year parsing validates `isNaN()` - good! | N/A |
```typescript
// Line 72 - could pass NaN to API
page: parseInt(searchParams.get('page') || '1', 10),
```
### 8. URL Encoding Issues
| Location | Issue | Severity |
|----------|-------|----------|
| `GameBrowseLayout.tsx:134` | `encodeURIComponent()` used for chip IDs but inconsistent | Low |
| `FilterPanel.tsx:125` | Badge key uses raw `value` - may have special chars | Low |
```typescript
// Line 125 - key should be encoded for consistency
<Badge key={value} variant="secondary" className="gap-1">
```
### 9. Missing Error Boundary for Filter Options
| Location | Issue | Severity |
|----------|-------|----------|
| `useFilterOptions.ts` | No `onError` handler | Low |
| `FilterPanel.tsx:79` | Falls back to empty array `|| []` but no loading state | Low |
If filter options fail to load, dropdowns show "No options available" with no retry mechanism.
### 10. Accessibility Issues
| Location | Issue | Severity |
|----------|-------|----------|
| `SortControls.tsx:26` | Native `<select>` used without styling wrapper | Low |
| `FilterDropdown.tsx:103` | `<label>` wraps checkbox - good | N/A |
| `SortControls.tsx:35-45` | Sort order button shows "A-Z/Z-A" but not meaningful for dates | Medium |
```typescript
// "A-Z" / "Z-A" makes sense for title, not for dateAdded
<span className="ml-1 text-xs">{sortOrder === 'asc' ? 'A-Z' : 'Z-A'}</span>
```
---
## Improvements Recommended
### High Priority
1. **Add Year Range Filter UI**
- Add a dual-handle range slider component
- Use `yearRange` from `FilterOptions`
- Connect to `yearFrom`/`yearTo` URL params
2. **Validate Page Parameter**
```typescript
// GameBrowseLayout.tsx:72
page: Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1),
```
3. **Fix Sort Order Label for Dates**
```typescript
const sortOrderLabel = useMemo(() => {
if (['releaseDate', 'dateAdded', 'dateModified'].includes(sortBy)) {
return sortOrder === 'asc' ? 'Oldest' : 'Newest';
}
return sortOrder === 'asc' ? 'A-Z' : 'Z-A';
}, [sortBy, sortOrder]);
```
### Medium Priority
4. **Consolidate Filter Chip Rendering**
- Remove inline badge rendering from `FilterPanel.tsx`
- Use `FilterChips` component consistently
- Pass chip generation logic via props or shared utility
5. **Add Filter Options Loading State**
```typescript
const { data: filterOptions, isLoading: filterOptionsLoading, error } = useFilterOptions();
// Show skeleton or disabled state while loading
```
6. **Memoize Filter Handlers**
```typescript
const handlers = useMemo(() =>
FILTER_CONFIGS.reduce((acc, config) => ({
...acc,
[config.id]: createFilterHandlers(config.paramKey, config.id, filters, updateFilter, dropdowns)
}), {}),
[filters, updateFilter, dropdowns]
);
```
7. **Add Search-as-You-Type with Debounce**
```typescript
// In a new SearchInput component
const [inputValue, setInputValue] = useState('');
const debouncedSearch = useDebounce(inputValue, 300);
useEffect(() => {
if (debouncedSearch) {
navigate(`/browse?search=${encodeURIComponent(debouncedSearch)}`);
}
}, [debouncedSearch]);
```
### Low Priority
8. **Consistent LIMIT on All Filter Queries**
```sql
-- GameService.ts:780-781
ORDER BY count DESC, playMode ASC
LIMIT 100 -- Add this
```
9. **Add Filter Presets**
- Save/load common filter combinations
- "Popular Flash Games", "Recent Additions", etc.
10. **Add Filter Count in Dropdown Trigger**
- Show total matching games for current filter combination
- Update counts dynamically based on other selected filters (cascading filters)
11. **Keyboard Navigation Improvements**
- Arrow keys to navigate dropdown options
- Enter to toggle selection
- Escape to close dropdown
---
## Code Quality Observations
### Positive Patterns
| Pattern | Location | Notes |
|---------|----------|-------|
| Config-driven filter rendering | `filterConfig.ts` | Eliminates 7 duplicate filter definitions |
| Proper `asyncHandler()` usage | `games.ts` routes | All async routes wrapped |
| Zod validation | `games.ts:27-49` | Comprehensive query validation |
| Window function for count | `GameService.ts:247` | Efficient single-query pagination |
| Cache pre-warming | `GameService.ts:158-166` | Filter options cached at startup |
| Proper URL param handling | `GameBrowseLayout.tsx` | `searchParams` synced correctly |
### Areas for Improvement
| Area | Current | Recommended |
|------|---------|-------------|
| Handler memoization | Created inline per render | Use `useMemo` or move outside component |
| Error handling | Silent fallback to `[]` | Show error state with retry |
| Year range UI | Missing | Add range slider component |
| Filter persistence | URL only | Consider localStorage for user defaults |
---
## Summary
| Category | Issues | Improvements |
|----------|--------|--------------|
| Type Safety | 3 | Validate all URL params |
| Missing Features | 2 | Year filter, presets |
| Performance | 2 | Memoize handlers |
| Consistency | 3 | Unify chip rendering |
| Accessibility | 2 | Sort labels, keyboard nav |
| Backend | 1 | Consistent LIMIT |
| **Total** | **13** | **10 recommendations** |
The filter system is functional and well-architected with the config-driven approach. The main gaps are the missing year range filter UI, sort order labels that don't match the sort type, and some performance optimizations around handler memoization.
---
## Action Items
- [x] Add year range filter slider to FilterPanel *(Fixed: 2026-02-15)*
- [x] Fix page parameter validation in GameBrowseLayout *(Fixed: 2026-02-15)*
- [x] Update SortControls to show context-aware labels (Oldest/Newest vs A-Z/Z-A) *(Fixed: 2026-02-15)*
- [x] Add LIMIT 100 to play modes query in GameService *(Fixed: 2026-02-15)*
- [x] Consolidate filter chip rendering to use FilterChips component only *(Fixed: 2026-02-15)*
- [x] Add loading/error states for filter options *(Fixed: 2026-02-15)*
- [x] Memoize filter handlers in FilterPanel *(Fixed: 2026-02-15)*
## Fixes Applied (2026-02-15)
### Files Modified
1. **`frontend/src/components/library/GameBrowseLayout.tsx`**
- Added `isNaN()` validation for page parameter with `Math.max(1, ...)` fallback
- Added year range chip generation and removal handling
2. **`frontend/src/components/search/SortControls.tsx`**
- Added context-aware sort order labels: "Oldest/Newest" for date fields, "A-Z/Z-A" for text fields
- Improved accessibility with `aria-label`
3. **`frontend/src/components/search/FilterPanel.tsx`**
- Memoized filter handlers using `useMemo` to avoid recreation on every render
- Memoized `updateFilter` with `useCallback`
- Memoized `visibleConfigs` array
- Removed duplicate badge rendering (now uses `FilterChips` only)
- Added loading state for filter options ("Loading..." message)
- Integrated `YearRangeFilter` component
4. **`frontend/src/components/search/YearRangeFilter.tsx`** (new file)
- Created year range filter component with from/to inputs
- Supports compact mode for mobile
- Proper validation of year values
5. **`backend/src/services/GameService.ts`**
- Added `LIMIT 100` to play modes query for consistency with other filter queries
## Additional Improvements (2026-02-15)
### Error State with Retry for Filter Options
**`frontend/src/components/search/FilterPanel.tsx`** - Added:
- Error state detection from `useFilterOptions` hook
- User-friendly error message with alert styling
- Retry button to refetch filter options
- Graceful fallback - shows filters if data exists even with stale error
+11 -3
View File
@@ -1,7 +1,7 @@
# Flashpoint Web - Technical Documentation
# Dotcom Arcade - Technical Documentation
Complete technical documentation for the Flashpoint Web project, a self-hosted
web application for browsing and playing games from the Flashpoint Archive.
Technical documentation for Dotcom Arcade, a self-hosted, Flash-era game portal
for Flashpoint Ultimate.
## Documentation Structure
@@ -154,6 +154,14 @@ web application for browsing and playing games from the Flashpoint Archive.
- [System Settings](10-features/09-system-settings.md) - System configuration
- [Date/Time Formatting](10-features/10-date-time-formatting.md) - Date display
formatting
- [Maintenance Mode](10-features/11-maintenance-mode.md) - Restricting access
during updates
- [Discovery Portal](10-features/12-discovery-portal.md) - Portal layout,
homepage discovery, game page and mature content filter
- [Streaming](10-features/13-streaming.md) - Games streamed from the server
- [Proxy Sign-In](10-features/14-proxy-sign-in.md) - Sign-in through Authentik,
Authelia or oauth2-proxy
- [Game Saves](10-features/15-game-saves.md) - Saves kept on players' accounts
### 11. Diagrams
Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,798 +0,0 @@
# Persistent Ruffle Installation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **Implementer assignments:** each task names its implementer agent in an
> `**Implementer:**` line. When executing with
> superpowers:subagent-driven-development, REQUIRED SUB-SKILL:
> dcc-superpower-companions:dispatching-tiered-implementers. Under
> superpowers:executing-plans these lines are inert; ignore them.
**Goal:** A Ruffle version installed through Settings → Update survives container recreation.
**Architecture:** Ruffle installs into `/app/data/ruffle`, which sits on the already-mounted data volume, instead of `/app/frontend/dist/ruffle`, which lives in the container's throwaway writable layer. A fresh volume is seeded from the copy baked into the image, so Flash works immediately and offline. Express serves the persisted directory at `/ruffle`, ahead of the dist handler and with cache headers that let an updated loader actually reach browsers.
**Tech Stack:** Express + TypeScript, vitest + supertest, Docker Compose.
**Spec:** `docs/superpowers/specs/2026-08-13-ruffle-persistent-install-design.md`
## Global Constraints
- Backend tests run from the `backend/` directory with `npx vitest run`. Do **not** use `npm test` from the repository root — it enters watch mode and hangs.
- Never introduce `any` types or non-null assertions (`!`). Use `??` for defaults, never `||`.
- Use `logger` from `../utils/logger`, never `console`.
- Run `npx prettier --write <files>` on every changed file before committing.
- The environment variable name is exactly `RUFFLE_DATA_PATH`. The production default is exactly `/app/data/ruffle`.
- `config.ruffleDataPath` is the install directory itself, not its parent. `path.join(config.ruffleDataPath, 'ruffle')` is always wrong.
- Ruffle's content-hashed files are detected by a hex run of **16 or more** characters in the basename (`core.ruffle.4ca82b563e9711217164.js`, `f2a570ccf4468b20d95a.wasm`). Everything else — `ruffle.js`, `ruffle.js.map`, `package.json` — is stable-named and must revalidate.
- Do not delete `frontend/public/ruffle/`. It is the seed source, and Vite serves it in development.
- Do not add a release-note entry to `docs/09-deployment/upgrade-guide.md`. That file gains a version heading only when a release is cut, and inventing a version number here would be wrong.
---
### Task 1: Persisted install path
**Files:**
- Modify: `backend/src/config.ts:118-119`
- Modify: `backend/src/services/RuffleService.ts:41-52`
- Test: `backend/src/services/RuffleService.test.ts:7,78-85,124-126,138-157`
**Interfaces:**
- Consumes: nothing.
- Produces: `config.ruffleDataPath: string` — the absolute directory Ruffle is installed into. `RuffleService` gains a private `installPath` (the install target) and a private `bundledPath` (the image's baked copy at `<frontendDistPath>/ruffle`), both read by Task 2.
**Implementer:** dcc-superpower-companions:impl-sonnet-max
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 2 = 5
- [ ] **Step 1: Add the config value**
In `backend/src/config.ts`, find this block:
```typescript
// __dirname is backend/dist at runtime, so this resolves to the frontend build
// copied next to the backend in the image (/app/frontend/dist).
frontendDistPath:
process.env.FRONTEND_DIST_PATH ?? path.resolve(__dirname, '../../frontend/dist'),
```
Insert directly after it:
```typescript
// Ruffle installs here rather than under the served frontend build. That build
// is part of the image, so it lives in the container's writable layer and an
// in-app update was discarded whenever the container was recreated.
ruffleDataPath:
process.env.RUFFLE_DATA_PATH ??
(process.env.NODE_ENV === 'production'
? '/app/data/ruffle'
: path.resolve(__dirname, '../data/ruffle')),
```
- [ ] **Step 2: Update the test fixtures to a separate data directory**
In `backend/src/services/RuffleService.test.ts`, change the declaration on line 7:
```typescript
let distDir: string;
```
to:
```typescript
let distDir: string;
let dataDir: string;
```
Replace the config mock (lines 78-85):
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
get frontendDistPath() {
return distDir;
},
},
}));
```
with:
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
get frontendDistPath() {
return distDir;
},
get ruffleDataPath() {
return nodePath.join(dataDir, 'ruffle');
},
},
}));
```
Replace the `ruffleDir` helper (lines 124-126):
```typescript
function ruffleDir(): string {
return nodePath.join(distDir, 'ruffle');
}
```
with:
```typescript
function ruffleDir(): string {
return nodePath.join(dataDir, 'ruffle');
}
```
In `beforeEach`, replace the single `distDir` assignment:
```typescript
distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-'));
```
with:
```typescript
distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-dist-'));
dataDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-data-'));
```
In `afterEach`, replace:
```typescript
nodeFs.rmSync(distDir, { recursive: true, force: true });
```
with:
```typescript
nodeFs.rmSync(distDir, { recursive: true, force: true });
nodeFs.rmSync(dataDir, { recursive: true, force: true });
```
- [ ] **Step 3: Run the tests to verify they fail**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
Expected: FAIL. The suite now asserts against `dataDir/ruffle` while the service still installs into `distDir/ruffle`, so assertions such as `expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build')` throw ENOENT.
- [ ] **Step 4: Point the service at the persisted path**
In `backend/src/services/RuffleService.ts`, replace the field declaration and constructor (lines 41-52):
```typescript
private readonly frontendPublicPath: string;
private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';
constructor() {
// Install into the directory that is actually served. In a built deployment
// that is the frontend's dist output; in development Vite serves public/.
// Pointing at public/ in a container made every fresh start re-download
// Ruffle even though the image already ships it in dist/.
this.frontendPublicPath = config.serveFrontend
? path.join(config.frontendDistPath, 'ruffle')
: path.resolve(__dirname, '../../../frontend/public/ruffle');
}
```
with:
```typescript
private readonly installPath: string;
private readonly bundledPath: string;
private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';
constructor() {
// A built deployment installs onto the mounted data volume and serves it
// from there: the frontend build is part of the image, so anything written
// into it is discarded when the container is recreated. In development Vite
// serves public/, which is the only directory reachable there.
this.installPath = config.serveFrontend
? config.ruffleDataPath
: path.resolve(__dirname, '../../../frontend/public/ruffle');
this.bundledPath = path.join(config.frontendDistPath, 'ruffle');
}
```
Then rename the remaining nine references. Every other occurrence of `this.frontendPublicPath` in the file becomes `this.installPath` — lines 81, 447, 495, 520, 526, 527, 532, 546, and 576. A whole-word replace of `frontendPublicPath` with `installPath` across the file is exact; there are no other matches.
- [ ] **Step 5: Run the tests to verify they pass**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
Expected: PASS, all existing cases green. They now exercise install, backup, rollback, and the rename/copy fallbacks against the persisted directory.
- [ ] **Step 6: Typecheck**
Run from the repository root: `npm run typecheck`
Expected: no errors.
- [ ] **Step 7: Commit**
```bash
npx prettier --write backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
git add backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
git commit -m "fix(ruffle): install onto the persisted data volume"
```
---
### Task 2: Seed a fresh volume from the bundled copy
**Files:**
- Modify: `backend/src/services/RuffleService.ts` (add two methods after `verifyInstallation`)
- Modify: `backend/src/server.ts:296-314`
- Test: `backend/src/services/RuffleService.test.ts` (append a new `describe` block)
**Interfaces:**
- Consumes: `this.installPath` and `this.bundledPath` from Task 1; the existing private `copyDirectory(source: string, target: string): void`.
- Produces: `RuffleService.ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'>`, called by `backend/src/server.ts`.
**Implementer:** dcc-superpower-companions:impl-sonnet-xhigh
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 1 = 4
- [ ] **Step 1: Write the failing tests**
Append to `backend/src/services/RuffleService.test.ts`:
```typescript
function installBundledRuffle(): void {
const bundled = nodePath.join(distDir, 'ruffle');
nodeFs.mkdirSync(bundled, { recursive: true });
nodeFs.writeFileSync(nodePath.join(bundled, 'ruffle.js'), 'bundled build');
nodeFs.writeFileSync(
nodePath.join(bundled, 'package.json'),
JSON.stringify({ version: '0.2.0-nightly.2026.1.29' })
);
}
describe('RuffleService.ensureInstalled', () => {
it('leaves an existing installation alone', async () => {
installExistingRuffle();
installBundledRuffle();
const result = await new RuffleService().ensureInstalled();
expect(result).toBe('present');
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('old build');
expect(axios.get).not.toHaveBeenCalled();
});
it('seeds from the bundled copy when nothing is installed', async () => {
installBundledRuffle();
const service = new RuffleService();
const result = await service.ensureInstalled();
expect(result).toBe('seeded');
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe(
'bundled build'
);
expect(service.getCurrentVersion()).toBe('0.2.0-nightly.2026.1.29');
expect(axios.get).not.toHaveBeenCalled();
});
it('downloads when neither an installation nor a bundled copy exists', async () => {
const result = await new RuffleService().ensureInstalled();
expect(result).toBe('downloaded');
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build');
expect(axios.get).toHaveBeenCalled();
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts -t ensureInstalled`
Expected: FAIL with `ensureInstalled is not a function`.
- [ ] **Step 3: Implement the methods**
In `backend/src/services/RuffleService.ts`, add both methods directly after `verifyInstallation()`, inside the class:
```typescript
/**
* Copy the Ruffle shipped inside the image into the install directory. A fresh
* data volume otherwise has no player at all until a download finishes — and
* never gets one on a host that cannot reach GitHub.
*/
private seedFromBundle(): boolean {
if (this.bundledPath === this.installPath) {
return false;
}
if (!fs.existsSync(path.join(this.bundledPath, 'ruffle.js'))) {
return false;
}
try {
this.copyDirectory(this.bundledPath, this.installPath);
logger.info(
`✅ Ruffle seeded from the bundled copy (version: ${this.getCurrentVersion() ?? 'unknown'})`
);
return true;
} catch (error: unknown) {
logger.warn('[RuffleService] Could not seed Ruffle from the bundled copy:', error);
return false;
}
}
/**
* Make a Ruffle available, preferring what is already installed, then the copy
* baked into the image, and only then a download.
*/
async ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'> {
if (this.verifyInstallation()) {
logger.info(`✅ Ruffle verified (version: ${this.getCurrentVersion() ?? 'unknown'})`);
return 'present';
}
if (this.seedFromBundle()) {
return 'seeded';
}
logger.info('🎮 Ruffle not found, downloading latest version...');
await this.updateRuffle();
logger.info('✅ Ruffle installation complete');
return 'downloaded';
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
Expected: PASS, including the three new cases and every pre-existing one.
- [ ] **Step 5: Call it from startup**
In `backend/src/server.ts`, replace lines 296-314:
```typescript
// Ruffle installs in the background: it downloads from GitHub, and awaiting it
// here kept the port closed for minutes on slow storage — indefinitely if the
// download stalled. Flash games need it, everything else does not.
void (async () => {
try {
const ruffleService = new RuffleService();
if (!ruffleService.verifyInstallation()) {
logger.info('🎮 Ruffle not found, downloading latest version...');
await ruffleService.updateRuffle();
logger.info('✅ Ruffle installation complete');
} else {
const version = ruffleService.getCurrentVersion();
logger.info(`✅ Ruffle verified (version: ${version || 'unknown'})`);
}
} catch (error) {
logger.error('Failed to install Ruffle:', error);
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
}
})();
```
with:
```typescript
// Ruffle resolves in the background: seeding copies ~20 MB and the download
// fallback reaches GitHub, and awaiting either here kept the port closed for
// minutes on slow storage. Flash games need it, everything else does not.
void (async () => {
try {
await new RuffleService().ensureInstalled();
} catch (error) {
logger.error('Failed to install Ruffle:', error);
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
}
})();
```
- [ ] **Step 6: Typecheck and run the full backend suite**
Run from the repository root: `npm run typecheck`
Then run from `backend/`: `npx vitest run`
Expected: no type errors, no new test failures.
- [ ] **Step 7: Commit**
```bash
npx prettier --write backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
git add backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
git commit -m "feat(ruffle): seed a fresh data volume from the bundled copy"
```
---
### Task 3: Serve the persisted directory with correct cache headers
**Files:**
- Modify: `backend/src/middleware/frontendServing.ts:20-55`
- Modify: `backend/src/middleware/startupOrdering.test.ts:12-17`
- Create: `backend/src/middleware/ruffleServing.test.ts`
**Interfaces:**
- Consumes: `config.ruffleDataPath` from Task 1.
- Produces: nothing consumed by later tasks. `registerFrontendServing(app, distPath?)` keeps its existing signature; the Ruffle mount reads `config.ruffleDataPath` directly.
**Implementer:** dcc-superpower-companions:impl-sonnet-max
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 2 = 5
- [ ] **Step 1: Write the failing test**
Create `backend/src/middleware/ruffleServing.test.ts`:
```typescript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express, { Express } from 'express';
import request from 'supertest';
import fs from 'fs';
import os from 'os';
import path from 'path';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
let ruffleDir: string;
vi.mock('../config', () => ({
config: {
serveFrontend: true,
get ruffleDataPath() {
return ruffleDir;
},
},
}));
import { registerFrontendServing } from './frontendServing';
const HASHED_CHUNK = 'core.ruffle.4ca82b563e9711217164.js';
let distDir: string;
let app: Express;
beforeEach(() => {
distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-dist-'));
ruffleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-ruffle-'));
fs.writeFileSync(path.join(distDir, 'index.html'), '<!doctype html><div id="root"></div>');
fs.mkdirSync(path.join(distDir, 'ruffle'));
fs.writeFileSync(path.join(distDir, 'ruffle', 'ruffle.js'), 'bundled loader');
fs.writeFileSync(path.join(distDir, 'ruffle', 'only-in-bundle.js'), 'bundled extra');
fs.writeFileSync(path.join(ruffleDir, 'ruffle.js'), 'updated loader');
fs.writeFileSync(path.join(ruffleDir, HASHED_CHUNK), 'updated chunk');
app = express();
registerFrontendServing(app, distDir);
});
afterEach(() => {
fs.rmSync(distDir, { recursive: true, force: true });
fs.rmSync(ruffleDir, { recursive: true, force: true });
});
describe('Ruffle serving', () => {
it('serves the persisted copy in preference to the bundled one', async () => {
const response = await request(app).get('/ruffle/ruffle.js');
expect(response.status).toBe(200);
expect(response.text).toBe('updated loader');
});
it('falls back to the bundled copy for a file the persisted copy lacks', async () => {
const response = await request(app).get('/ruffle/only-in-bundle.js');
expect(response.status).toBe(200);
expect(response.text).toBe('bundled extra');
});
it('makes the stable-named loader revalidate', async () => {
const response = await request(app).get('/ruffle/ruffle.js');
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
});
it('makes a content-hashed chunk immutable', async () => {
const response = await request(app).get(`/ruffle/${HASHED_CHUNK}`);
expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable');
});
it('makes the bundled fallback loader revalidate too', async () => {
const response = await request(app).get('/ruffle/only-in-bundle.js');
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run from `backend/`: `npx vitest run src/middleware/ruffleServing.test.ts`
Expected: FAIL. `/ruffle/ruffle.js` returns `bundled loader` because no persisted mount exists, and its `Cache-Control` is `public, max-age=31536000, immutable`.
- [ ] **Step 3: Add the mount and the cache policy**
In `backend/src/middleware/frontendServing.ts`, add this import alongside the existing ones:
```typescript
import type { Response } from 'express';
```
Add these two declarations directly above `export function registerFrontendServing`:
```typescript
/**
* Ruffle content-hashes its chunks (`core.ruffle.4ca82b563e9711217164.js`,
* `f2a570ccf4468b20d95a.wasm`) but not its entry points. Detecting the hash
* rather than listing the stable names keeps this correct when a release adds a
* file.
*/
const HASHED_RUFFLE_ASSET = /[0-9a-f]{16,}/i;
/**
* `ruffle.js` keeps its name across versions, so serving it immutable pinned
* every returning browser to the loader it first saw — an update reached the
* disk and nothing else.
*/
function setRuffleCacheControl(res: Response, filePath: string): void {
res.setHeader(
'Cache-Control',
HASHED_RUFFLE_ASSET.test(path.basename(filePath))
? 'public, max-age=31536000, immutable'
: 'no-cache, no-store, must-revalidate'
);
}
```
Inside `registerFrontendServing`, insert this immediately after the `index.html` existence guard and before the existing `app.use(express.static(distPath, ...))` call:
```typescript
// Ahead of the dist handler so an updated Ruffle shadows the one baked into
// the image. `fallthrough` lets the baked copy answer for anything the
// persisted directory is missing, including before the first seed finishes.
app.use(
'/ruffle',
express.static(config.ruffleDataPath, {
index: false,
fallthrough: true,
setHeaders: setRuffleCacheControl,
})
);
```
Then replace the `setHeaders` option on the existing dist handler:
```typescript
setHeaders: (res, filePath) => {
// Hashed assets are immutable; index.html must always be revalidated.
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
```
with:
```typescript
setHeaders: (res, filePath) => {
// Hashed assets are immutable; index.html must always be revalidated.
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
return;
}
if (filePath.startsWith(bundledRufflePath + path.sep)) {
setRuffleCacheControl(res, filePath);
}
},
```
and declare `bundledRufflePath` at the top of the function body, directly below the existing `const indexHtml = path.join(distPath, 'index.html');`:
```typescript
const bundledRufflePath = path.join(distPath, 'ruffle');
```
- [ ] **Step 4: Run the test to verify it passes**
Run from `backend/`: `npx vitest run src/middleware/ruffleServing.test.ts`
Expected: PASS, all five cases.
- [ ] **Step 5: Repair the sibling middleware test's config mock**
`backend/src/middleware/startupOrdering.test.ts` mocks `../config` without `ruffleDataPath`, and `express.static(undefined)` throws `TypeError: root path required`. Replace its mock (lines 12-17):
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
frontendDistPath: '/unused-in-tests',
},
}));
```
with:
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
frontendDistPath: '/unused-in-tests',
ruffleDataPath: '/unused-in-tests/ruffle',
},
}));
```
- [ ] **Step 6: Run the full backend suite and typecheck**
Run from `backend/`: `npx vitest run`
Then run from the repository root: `npm run typecheck`
Expected: no failures and no type errors. `startupOrdering.test.ts` in particular must stay green — a missing-directory static mount with `fallthrough: true` calls `next()` instead of throwing.
- [ ] **Step 7: Commit**
```bash
npx prettier --write backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
git add backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
git commit -m "fix(ruffle): serve the persisted install ahead of the bundled one"
```
---
### Task 4: Deployment configuration and documentation
**Files:**
- Modify: `docker-compose.dev.yml:28-40`
- Modify: `docker-compose.yml:29-33`
- Modify: `docs/09-deployment/environment-variables.md:113-116`
**Interfaces:**
- Consumes: the `RUFFLE_DATA_PATH` variable name and the `/app/data/ruffle` default from Task 1.
- Produces: nothing.
**Implementer:** dcc-superpower-companions:impl-sonnet-medium
**Evaluation:** files 1 - spec 0 - coupling 1 - risk 0 = 2
- [ ] **Step 1: Set the variable in the dev compose file**
`docker-compose.dev.yml` runs `NODE_ENV=development` with `SERVE_FRONTEND=true`, so the production-keyed default resolves to a relative path inside the container and misses the `backend-db:/app/data` volume. In `docker-compose.dev.yml`, find:
```yaml
# Matches the read-only mount above. Set explicitly because the backend's
# fallback only points here when NODE_ENV=production.
- FLASHPOINT_PATH=/data/flashpoint
```
Insert directly after it:
```yaml
# Same reason as FLASHPOINT_PATH above: this container runs
# NODE_ENV=development, so the production default would put Ruffle outside
# the data volume and lose every update on recreate.
- RUFFLE_DATA_PATH=/app/data/ruffle
```
- [ ] **Step 2: Update both volume comments**
In `docker-compose.yml`, find:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
- ${DATA_PATH:-./data}:/app/data
```
Replace with:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
# Holds user.db and the installed Ruffle. Without this mount an in-app
# Ruffle update is lost the next time the container is recreated.
- ${DATA_PATH:-./data}:/app/data
```
In `docker-compose.dev.yml`, find:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
- backend-db:/app/data
```
Replace with:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
# Holds user.db and the installed Ruffle. Without this mount an in-app
# Ruffle update is lost the next time the container is recreated.
- backend-db:/app/data
```
- [ ] **Step 3: Document the variable**
In `docs/09-deployment/environment-variables.md`, find this table:
```markdown
| Variable | Default | Description |
| -------------------- | ----------------------------- | -------------------------------------------------------------- |
| `SERVE_FRONTEND` | true in production, else false | Serve the built frontend and SPA fallback from the backend |
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist` | Location of the built frontend (rarely needed) |
```
Add one row to it:
```markdown
| `RUFFLE_DATA_PATH` | `/app/data/ruffle` in production | Where Ruffle is installed. Must sit on a mounted volume, or an in-app update is lost when the container is recreated |
```
- [ ] **Step 4: Verify both compose files still parse**
Run from the repository root:
```bash
docker compose -f docker-compose.yml config --quiet
docker compose -f docker-compose.dev.yml config --quiet
```
Expected: both exit 0 and print nothing. Each file requires `FLASHPOINT_HOST_PATH` and `JWT_SECRET`; if the repository has no `.env`, supply throwaway values inline:
```bash
FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.yml config --quiet
FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.dev.yml config --quiet
```
If the Docker CLI is unavailable in this environment, say so rather than skipping silently, and parse both files directly instead:
```bash
node -e "const y=require('yaml');const fs=require('fs');for(const f of ['docker-compose.yml','docker-compose.dev.yml']){y.parse(fs.readFileSync(f,'utf8'));console.log(f,'ok')}"
```
The `yaml` package is already present in the repository's `node_modules` as a transitive dependency. This proves the YAML parses; it does not check Compose semantics, so report which of the two checks you ran.
- [ ] **Step 5: Commit**
```bash
npx prettier --write docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
git add docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
git commit -m "docs(ruffle): document the persisted install path"
```
---
## Verification
After Task 4, confirm the whole change from the repository root:
```bash
npm run typecheck
npm run build
```
Then from `backend/`: `npx vitest run`
Expected: typecheck clean, build succeeds, backend suite green.
Manual confirmation in a container, which is what the reported bug needs:
1. `docker compose -f docker-compose.dev.yml up -d --build`
2. Settings → Update → update Ruffle, and note the version.
3. `docker compose -f docker-compose.dev.yml up -d --force-recreate`
4. Settings → Update still reports the updated version, and a hard-refreshed
browser loads it.
@@ -1,184 +0,0 @@
# Downloaded games: page, filter, and settings
Date: 2026-08-11
## Goal
1. A "Downloaded" sidebar entry and page listing only downloaded games.
2. A downloaded filter in the filter panel on the existing browse pages.
3. An admin setting to show or hide the Downloaded page.
4. An admin setting that lets guests see the Downloaded page, offered only when
the page itself is enabled.
5. An admin setting that turns the downloaded filter on by default.
## Definition of "downloaded"
A game is downloaded when it has at least one `game_data` row with
`presentOnDisk = 1`.
Games with no `game_data` rows at all — legacy titles served from htdocs — can
never be downloaded under this definition and never appear on the page. This
matches the existing per-game badge, which reads the same flag through the
`MAX(presentOnDisk)` post-processing in `GameService.searchGames`.
## Keeping the flag truthful
`presentOnDisk` is currently written only by the Flashpoint Launcher and by the
explicit Download button (`DownloadManager` → `GameDatabaseUpdater.markAsDownloaded`).
Three gaps must close before the flag can drive a whole page.
**Play-path downloads never set it.** `GameZipServer.downloadAndMountInBackground`
writes the ZIP and returns without touching the database. On success it will call
`GameDatabaseUpdater.markAsDownloaded`. The `game_data.id` is already loaded by
`GameDataService.getGameDataEntry`; thread it through the `mountZip` params rather
than re-querying by `gameId` + `dateAdded`. Mark the row even when `sha256` is
absent, since `GameDataDownloader` only verifies a checksum when one exists and the
file is otherwise a complete, mountable download.
**Local-copy mode erases the flag.** With `ENABLE_LOCAL_DB_COPY=true`, queries and
our writes hit `config.localDbPath`, and `DatabaseService.syncAndReload` copies the
source over that file whenever the Launcher's database changes. A new
`DownloadedReconciler` service restores truth from disk:
- Scan `config.flashpointGamesPath` for files matching `{uuid}-{timestamp}.zip`,
the deterministic name produced by `GameDataDownloader.getFilename`.
- Resolve each to its `game_data` row by `gameId` plus the timestamp derived from
`dateAdded`, and bulk-set `presentOnDisk = 1` in a single transaction, skipping
rows already flagged.
- Update `game.activeDataOnDisk` where the marked row is the game's active data.
- Run once at startup after `DatabaseService` initialization, and again after every
`syncAndReload`. Both runs are fire-and-forget with a `.catch()`; an unreadable
games directory logs a warning and yields zero marks.
The reconciler only ever marks. It never clears the flag for a missing ZIP, because
the Launcher can store game data outside `flashpointGamesPath` and we would be
overwriting its state with a false negative.
This scan also backfills every game downloaded through the Play path before this
feature ships, which is otherwise the largest population of invisible downloads.
**Our own writes trigger a full reload.** In non-copy mode the write lands in the
file `DatabaseService` watches, so the watcher fires, reopens the connection, and
clears the search, flash-SWF, and filter-options caches — per download. Add
`DatabaseService.noteSelfWrite()`, which re-stats the source and advances
`lastModifiedTime` so `syncAndReload` short-circuits, and call it from
`markAsDownloaded` and the reconciler. The caller then clears only the search and
filter-options caches, so a freshly downloaded game appears immediately.
A Launcher write landing in the same millisecond as ours could be swallowed by this
check. The consequence is a delayed reload, not data loss, and the next Launcher
write recovers it.
## Backend filter
`downloaded?: boolean` joins the POST `/api/games` body schema, `GameSearchQuery`,
and the filter-options query schema. `true` restricts to downloaded games;
`false` and `undefined` both mean "no restriction", and the frontend omits the
field when the filter is off.
In `GameService.searchGames` the condition is:
```sql
AND EXISTS (SELECT 1 FROM game_data gd WHERE gd.gameId = g.id AND gd.presentOnDisk = 1)
```
`buildFilterOptionsConditions` gets the same condition so dropdown values and the
year range narrow to downloaded games; qualify the outer column with its table name
there, since those queries have no alias and `game_data` has its own `id` column.
Both `GameSearchCache.generateCacheKey` and `GameService.getFilterOptionsCacheKey`
include `downloaded`, normalized with `??` so `undefined` and `false` stay distinct.
Add `'downloaded'` to the `filters` array in the search route's `logActivity`
callback so the recorded filter count stays accurate.
## Frontend filter
`GameFilters.downloaded?: boolean` carries the value to the API.
`FilterUrlParams.downloaded?: '0' | '1'` carries it in the URL under the unused
single-character key `w`, guarded with `!== undefined` rather than truthiness so an
explicit off survives `toUrlSafeFormat`.
The value is tri-state: `w.1` on, `w.0` off, absent means "use the admin default".
`GameBrowseLayout` resolves it once and passes a plain boolean down.
Because absent means "default", removal cannot clear the parameter when the default
is on — the filter would immediately reapply. When the admin default is on,
`handleRemoveChip` and `handleClearAllFilters` write `w.0` instead of dropping the
key; when it is off, they clear it as every other filter does. `CATEGORY_TO_ABBR`
gains `Downloaded: 'W'` and `categoryToParamKeys` gains the matching entry so the
chip participates in the existing filter hierarchy.
A bookmarked URL with no `w` changes meaning if an admin later flips the default.
That is inherent to the tri-state and accepted.
`FilterPanel` renders a "Downloaded only" switch beside the year range in both the
desktop and mobile rows, and an active filter chip. A new `showDownloadedFilter`
prop hides the switch on the Downloaded page, where the filter is the page.
## Downloaded page
Route `/downloaded` renders `DownloadedView`, a thin wrapper over
`GameBrowseLayout` with the filter pinned on and no library restriction, so results
span arcade and theatre. `GameBrowseLayout`'s `library` prop becomes optional and it
gains `forceDownloaded`.
The page passes `sectionKey={null}`, so game links resolve to the existing
`/games/:id` detail route. No new per-section detail or play routes.
## Settings
Migration `004_downloaded_settings.sql` seeds three public boolean settings in the
`features` category, following the existing `enable_*` naming:
| Key | Default | Effect |
| --- | --- | --- |
| `features.enable_downloaded_page` | 1 | Sidebar entry and `/downloaded` route |
| `features.enable_downloaded_page_for_guests` | 0 | Guests may see the page |
| `features.enable_downloaded_filter_default` | 0 | Browse pages start filtered |
`useFeatureFlags` exposes `enableDownloadedPage` with the usual admin bypass, so an
admin cannot lock themselves out. The other two are read as `=== true`: they default
off, and the hook's `?? true` fallback would otherwise invert them during the
public-settings loading window and whenever the key is missing.
`ProtectedRoute` gains `'enableDownloadedPage'` in its `requireFeature` union and a
new `requireFeatureForGuests` prop, checked only when the viewer is a guest, so a
guest deep-linking to `/downloaded` with the guest flag off is redirected to
`/unauthorized`. A flag check alone would let them through, since guests satisfy
`requireAuth`.
These flags are presentation, not access control: `POST /api/games` is open to
guests and accepts `downloaded` regardless, and the page shows no data a guest
cannot already reach by filtering Browse. No backend enforcement is added.
`FeaturesSettingsTab` gains the three switches, with the guest row rendered only
while the page flag is on. Its stored value persists while hidden and resurfaces if
the page is re-enabled.
## Sidebar
The Downloaded entry sits in the Library section with Favorites. That block is
currently hidden wholesale for guests, so its items never check guest status
themselves. Relaxing the block requires giving each item its own predicate:
Favorites and both playlist entries become explicitly `!isGuest`, Downloaded
becomes `enableDownloadedPage && (!isGuest || enableDownloadedPageForGuests)`, and
the section renders when any item survives. The existing
`isViewingSharedPlaylistWithoutGuestAccess` case is unaffected.
## Testing
- `GameService.searchGames` returns only flagged games with `downloaded: true`, and
is unaffected when the field is absent.
- Filter options narrow when `downloaded` is set, and cache keys distinguish
`undefined`, `false`, and `true`.
- `DownloadedReconciler` maps ZIP filenames to the right `game_data` rows, ignores
unrelated files, and marks nothing when the directory is unreadable.
- `filterUrlCompression` round-trips `w.0` and `w.1`, and an explicit off survives
`buildFilterSearchParams`.
- Chip removal writes an explicit off when the admin default is on.
## Documentation
Update `docs/06-api-reference/games-api.md` (search and filter-options parameters),
`docs/06-api-reference/settings-api.md` (three new settings), and
`docs/10-features/02-game-browsing-filtering.md` (the filter and the page).
@@ -1,234 +0,0 @@
# Web app update check on the Update page
Date: 2026-08-12
## Goal
An admin opening Settings → Update sees whether a newer release of Flashpoint
Web exists, what changed in it, and how to upgrade.
The card notifies only. It never pulls an image, restarts a container, or writes
anything: self-updating from inside the container needs the Docker socket
mounted, which hands the container root-equivalent control of the host.
## What counts as an update
The running version comes from `APP_VERSION`, an environment variable baked into
the image from the `VERSION` build arg that CI already passes (see
`.github/workflows/docker-build-push.yml`). This mirrors `VITE_APP_VERSION`,
which the frontend build consumes for the same value.
A version counts as a release build only when it is three clean numbers, with an
optional leading `v` — `1.0.38` or `v1.0.38`. Anything else is an **unreleased
build**: the variable unset, `dev` from a local `docker build`, `latest` from a
`workflow_dispatch` run, a release candidate like `1.1.0-rc1`, or a
`git describe` string like `1.0.38-5-gabc123`. An unreleased build never claims
to be behind, because its commits may already include everything in the latest
release.
The comparison target is GitHub's
`/repos/darkraise/flashpoint-web/releases/latest`, which excludes prereleases by
definition. No prerelease filtering is needed, and release candidates never
surface.
Verified against the live endpoint on 2026-08-12: it answers 200 with
`tag_name: "v1.0.38"`, `prerelease: false`, `published_at`, `html_url`, and a
markdown `body`. The tag carries a leading `v` that `APP_VERSION` does not, so
both sides are stripped before comparing — the tag always has one, and a manual
`docker build --build-arg VERSION=v1.0.38` would otherwise be misread as
unreleased.
## The two version sources must not contradict each other
The frontend already resolves its own version at build time
(`frontend/vite.config.ts`, `frontend/src/lib/version.ts`) and shows it on the
General tab as "Web App Version". This design adds a second, server-side source,
and the two sit one tab apart.
In Docker they agree: both build stages receive the same `VERSION` build arg. In
a source checkout they can differ — the frontend falls back to `git describe`,
while the backend has no `APP_VERSION` at all — so the General tab could read
`1.0.38` while the card reads "unreleased build". The card's copy therefore
attributes the state to the server rather than to the operator: "The server did
not report a released version, so update checks are off."
`APP_VERSION` is documented in `docs/09-deployment/environment-variables.md` as
an optional operator-settable variable, so a non-Docker deployment can opt into
update checks.
## Backend
### `compareVersions(a, b)` — `backend/src/utils/version.ts`
Returns a negative number, zero, or a positive number by comparing major, minor,
and patch numerically, after stripping an optional leading `v` from each side.
Returns `null` when either input is not exactly three numeric parts, which is
what routes an unreleased build away from the comparison. Numeric comparison
matters: string ordering puts `1.0.9` above `1.0.38`.
A `null` result from a malformed *latest* tag — a hand-cut release tagged `v2.0`
— yields `updateAvailable: false`. Reporting nothing is preferable to reporting
a wrong direction.
### `AppUpdateService` — `backend/src/services/AppUpdateService.ts`
One public method, `getUpdateInfo(force = false)`, returning:
```ts
interface AppUpdateInfo {
currentVersion: string | null; // null when APP_VERSION is unset
latestVersion: string | null; // null when GitHub has never been reached
updateAvailable: boolean;
isUnreleasedBuild: boolean;
publishedAt: string | null;
releaseUrl: string | null;
changelog: string | null;
checkedAt: string | null; // when the held result was fetched
lastCheckFailed: boolean;
}
```
The result is cached in memory for one hour and shared by every admin. The cache
holds the last *successful* result; a failed check returns that result with
`lastCheckFailed: true` rather than discarding it.
**Concurrency.** The in-flight fetch promise is stored and returned to
concurrent callers, so a cold cache hit by several requests at once produces one
GitHub call, not several.
**Forced refresh.** `force` bypasses the one-hour cache but not a 60-second
floor: a forced check within 60 seconds of the last fetch returns the held
result. `rateLimitStandard` is 100 requests per 60-second window
(`backend/src/config.ts`, `RATE_LIMIT_MAX_REQUESTS`), which is no protection for
a 60-per-hour unauthenticated GitHub quota that this feature shares with the
star-count proxy and the Ruffle update check. The floor is what keeps the quota
intact.
`isUnreleasedBuild: true` still fetches and reports `latestVersion` and the
changelog for reference, but forces `updateAvailable: false`.
### Failure handling
The route always answers 200. A total failure — no cached result and GitHub
unreachable — returns `latestVersion: null` with `lastCheckFailed: true`, and
the card renders its failed state.
It does **not** throw `AppError(502)`, because the axios interceptor in
`frontend/src/lib/api/client.ts` fires a global "Server error occurred" toast for
any status at or above 500. A card that renders its own inline error would
double up with that toast, which is what the Ruffle card does today.
`backend/src/routes/github.ts` is the precedent for serving a stale result, but
it is a weaker version of this design and should not be copied line for line: it
throws a plain `Error`, has no request timeout, and its stale path covers only a
non-OK response, so a network failure or hang bypasses the cache entirely. Here
the `try` wraps the whole fetch, and the request carries an explicit timeout.
### `GET /api/updates/app`
Added to `backend/src/routes/updates.ts` behind `authenticate` and
`requirePermission('settings.update')` from `../middleware/rbac`. That matches
the tab's own gate in `UpdateSettingsTab.tsx`; note that two GETs in that file
use `settings.read`, so "the same as its neighbours" is not exact.
The handler returns the bare `AppUpdateInfo` object — `res.json(info)` — as
every endpoint in `updates.ts` does and as `updatesApi` expects. The
`{success, data}` envelope in `github.ts` is that router's convention, not this
one.
`?refresh=true` sets `force`, read as `req.query.refresh === 'true'`. Any other
value, array, or object is falsy, so no schema or bounds check is warranted; the
60-second floor is the real control. `rateLimitStandard` already applies to the
whole router.
### `config.appVersion`
`process.env.APP_VERSION?.trim() || null`, added to `backend/src/config.ts`
beside the Flashpoint version fields. `||` is deliberate here and must not be
"corrected" to `??`: an empty or whitespace-only variable has to collapse to
`null`, which `??` would not do.
### Dockerfile
The final stage already declares `ARG VERSION=dev` for the image label. It gains
`ENV APP_VERSION=$VERSION`, since an `ARG` alone does not survive into the
running container.
## Frontend
`AppUpdateCard` — `frontend/src/components/settings/AppUpdateCard.tsx` — renders
first in `UpdateSettingsTab`, above the metadata and Ruffle cards, admin-only
like both of them.
A `useQuery` on mount performs the check. The Check button cannot be a
`refetch()`, which reuses the original query function and so cannot add
`refresh=true`; it is a `useMutation` calling the endpoint with the flag, writing
its result back through `queryClient.setQueryData`. This is the shape the Ruffle
card already uses.
The card reuses the vocabulary of `RuffleManagementCard`: a collapsible
changelog rendered with `react-markdown` and `remark-gfm`, `FormattedDate` for
the release date, the same button placement and toast behaviour. No sanitizer is
added — without `rehype-raw`, react-markdown escapes raw HTML, and the source is
the project's own CI-generated release notes.
Five states:
| State | Condition | Shows |
| ---------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Update available | `updateAvailable` | Both versions, release date, changelog expanded, link to the release, upgrade commands |
| Up to date | versions match | Current version and when it was checked |
| Unreleased build | `isUnreleasedBuild` | "The server did not report a released version, so update checks are off", plus the latest release for reference |
| Stale | `lastCheckFailed` with a `latestVersion` | The held result, labelled as a check that failed at `checkedAt` |
| Check failed | `lastCheckFailed`, no `latestVersion` | A muted error and a retry button |
The upgrade command is `docker compose pull && docker compose up -d`, matching
the deployment `docker-compose.yml` documents. That file pins
`image: darkraise/flashpoint-web:${IMAGE_TAG:-latest}`, so the card adds one
line: an operator who set `IMAGE_TAG` to a fixed version must raise it first, or
the pull is a no-op.
The release body generated by CI opens with its own "## Docker Image" section
carrying a `docker pull` line, so the changelog repeats part of this. The card
still states the commands itself, because the compose commands are the actual
upgrade and the changelog is collapsible.
The API call goes through `updatesApi` in `frontend/src/lib/api/updates.ts`, with
`AppUpdateInfo` declared there and re-exported from `lib/api/index.ts`, as the
other update types are.
## Testing
Backend unit tests in the repo's existing style, with no test for the card —
`components/settings` has no test coverage today and this change does not
introduce the pattern.
- `compareVersions`: equal, newer, older, multi-digit ordering (`1.0.38` above
`1.0.9`), a leading `v` on either or both sides, and malformed inputs (`dev`,
`latest`, `1.0.38-5-gabc123`, `1.0`, empty).
- `AppUpdateService` with a mocked fetch: update available, up to date,
unreleased build, malformed latest tag, cache hit inside the hour, forced
refresh honoured after the 60-second floor and ignored inside it, one GitHub
call for concurrent cold-cache requests, a held result returned with
`lastCheckFailed` after a failure, and a 200 with `latestVersion: null` when a
failure has no cache to fall back on.
## Decisions taken
- **Notify only.** Self-update requires the Docker socket; a restart-only button
cannot pull, so it would only ever help someone who had already pulled.
- **The repository is hardcoded** to `darkraise/flashpoint-web`, as it already is
in the star-count proxy. A fork wanting its own release feed would need this
made configurable; nothing here is designed to prevent that later.
- **Docker Compose is the documented deployment**, so it is the only upgrade
command shown.
- **No notification badge** elsewhere in the UI, and no scheduled background
check. The cached on-open check covers the stated goal; a `JobScheduler` job
would be the way to add a badge later.
## Known limitation, not addressed here
A `workflow_dispatch` run of the build workflow tags the image `:latest` and
passes `VERSION=latest`, so that image reports an unreleased build. The card
makes a pre-existing CI wart visible; fixing it means changing what the manual
trigger publishes, which is out of scope for this feature.
@@ -1,131 +0,0 @@
# Persistent Ruffle installation
Date: 2026-08-13
## Goal
A Ruffle version installed through Settings → Update survives container
recreation. Today it does not: the update lands in the container's writable
layer, and the next `docker compose up -d` after an image pull or a config
change throws it away.
## Why it reverts
`RuffleService` installs into `config.frontendDistPath/ruffle` — `/app/frontend/dist/ruffle`
inside the container. The only mounted volumes are `/data/flashpoint`,
`/app/data`, and `/app/logs`, so that path lives entirely in the overlay
writable layer.
`frontend/public/ruffle/` is committed to the repository at
`0.2.0-nightly.2026.1.29`. Vite copies `public/` into `frontend/dist/` at build
time and the Dockerfile bakes that directory into the image. When a recreate
discards the writable layer, the baked copy is what remains.
Startup only downloads when `verifyInstallation()` is false
(`backend/src/server.ts:302`). The baked copy satisfies that check, so the
server logs the stale version and keeps it. The scheduled `ruffle-update` job
would eventually re-apply the update, but only if that job is enabled.
A plain `docker restart` preserves the writable layer and does not trigger this.
The recreate paths do: `docker compose up -d` after an image pull or any change
to the resolved service config, `down` then `up`, `--force-recreate`, and the
update actions in Portainer and Unraid.
## Install location
A new `config.ruffleDataPath` names the install directory:
```
process.env.RUFFLE_DATA_PATH ??
(NODE_ENV === 'production' ? '/app/data/ruffle' : <repo>/backend/data/ruffle)
```
`RuffleService` swaps only its `serveFrontend` branch to this path. The other
branch keeps writing to `frontend/public/ruffle`, because in a source checkout
Vite serves `public/` and an install anywhere else would not be reachable.
`docker-compose.dev.yml` sets `RUFFLE_DATA_PATH=/app/data/ruffle` explicitly. It
runs `NODE_ENV=development` with `SERVE_FRONTEND=true`, so the production-keyed
default would miss its `backend-db:/app/data` volume. The file already sets
`FLASHPOINT_PATH` for exactly this reason, and the new variable follows that
precedent. Production compose needs no change: `NODE_ENV=production` already
resolves to the right path.
The entrypoint chowns `/app/data` to the app user on every start, so a non-default
`PUID` can write the new directory without further work.
## Seeding a fresh install
`RuffleService.ensureInstalled()` replaces the inline block at
`backend/src/server.ts:299-314` and resolves in three steps:
1. `ruffle.js` exists at the data path — log the version and stop.
2. Otherwise the image's bundled `frontend/dist/ruffle` exists — copy it into
the data path via the existing private `copyDirectory`.
3. Otherwise — download the latest release from GitHub, which is the current
behaviour.
Seeding from the bundle rather than downloading means Flash works the moment a
fresh container is up, and works at all on a host that cannot reach GitHub. The
copy is roughly 20 MB and happens once per persisted volume, not once per
container.
The call stays fire-and-forget in the background. Awaiting it would hold the
port closed for the duration of a download, which is the regression the current
comment at `server.ts:296-298` records.
## Serving
`registerFrontendServing` mounts the data path at `/ruffle` **before**
`express.static(distPath)`, so the persisted copy shadows the baked one. The
mount uses `fallthrough: true`: if the persisted tree is missing a file, the
baked `dist/ruffle` still answers rather than 404ing into a dead player.
The frontend needs no change. `RufflePlayer.tsx` already loads
`/ruffle/ruffle.js` and sets `publicPath: '/ruffle/'`, both of which the new
mount serves.
## Cache headers
The dist handler currently serves everything with `maxAge: '1y', immutable: true`,
including `ruffle/ruffle.js`, which is not content-hashed. A browser that has
loaded the page once will therefore keep the old loader for a year, so
persistence alone would not make an update visible to returning users.
A shared helper decides the policy from the basename: `immutable, max-age=1y`
when it contains a hex run of 16 or more characters, otherwise
`no-cache, no-store, must-revalidate`. Ruffle's chunks carry such a run
(`core.ruffle.4ca82b563e9711217164.js`, `f2a570ccf4468b20d95a.wasm`) while its
stable entry points do not (`ruffle.js`, `ruffle.js.map`, `package.json`).
Detecting the hash rather than listing the stable names keeps the rule correct
when a future Ruffle release adds a file.
The helper applies to both the new `/ruffle` mount and to `ruffle/` paths under
the dist handler, so the fallback cannot reintroduce a stale loader.
## Testing
`RuffleService.test.ts` mocks `../config`; that mock gains `ruffleDataPath` and
the existing `ruffleDir()` helper points at it. The update tests are otherwise
unchanged — they exercise the rename and copy fallbacks, which the new path does
not alter.
New cases cover `ensureInstalled()`:
- an existing install is left alone and no download is attempted;
- an empty data path with a bundled copy present is seeded from the bundle, and
no download is attempted;
- an empty data path with no bundle falls through to the download.
A new `frontendServing` test asserts the `/ruffle` mount is registered ahead of
the dist handler, that `ruffle.js` comes back revalidating, and that a hashed
chunk comes back immutable.
## Out of scope
`config.userDbPath` and `config.tempDownloadPath` use the same
`NODE_ENV === 'production'` keying as the new variable. In the dev compose
container that evaluates to `development`, so `user.db` lands at
`/app/backend/user.db` and the `backend-db:/app/data` volume goes unused — dev
container app data is not persisted today. Real, but unrelated to Ruffle, and
touching database paths carries risk this change does not need.
+1 -1
View File
@@ -21,7 +21,7 @@
form-action 'self';
"
/>
<title>Flashpoint Archive</title>
<title>Dotcom Arcade</title>
<!-- Blocking script to prevent FOUC (Flash of Unstyled Content) -->
<!-- Applies theme before first paint by reading from localStorage -->
<script>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "flashpoint-web-frontend",
"name": "dotcom-arcade-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
+9 -67
View File
@@ -1,4 +1,4 @@
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useEffect, lazy, Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
import { MainLayout } from './components/layout/MainLayout';
@@ -41,12 +41,6 @@ const HomeView = lazy(() => import('./views/HomeView').then((m) => ({ default: m
const BrowseView = lazy(() =>
import('./views/BrowseView').then((m) => ({ default: m.BrowseView }))
);
const FlashGamesView = lazy(() =>
import('./views/FlashGamesView').then((m) => ({ default: m.FlashGamesView }))
);
const HTML5GamesView = lazy(() =>
import('./views/HTML5GamesView').then((m) => ({ default: m.HTML5GamesView }))
);
const AnimationsView = lazy(() =>
import('./views/AnimationsView').then((m) => ({ default: m.AnimationsView }))
);
@@ -95,6 +89,12 @@ const ActivitiesView = lazy(() =>
import('./views/ActivitiesView').then((m) => ({ default: m.ActivitiesView }))
);
/** The old /flash and /html5 sections are now part of /browse */
function LegacySectionRedirect() {
const { pathname, search } = useLocation();
return <Navigate to={pathname.replace(/^\/(flash|html5)/, '/browse') + search} replace />;
}
function App() {
const { isAuthenticated, isGuest, clearAuth, setGuestMode } = useAuthStore();
const isServerStarting = useServerStatusStore((state) => state.isStarting);
@@ -289,6 +289,8 @@ function App() {
{/* ==================== STANDALONE ==================== */}
<Route path="/maintenance" element={<MaintenancePage />} />
<Route path="/flash/*" element={<LegacySectionRedirect />} />
<Route path="/html5/*" element={<LegacySectionRedirect />} />
{/* ==================== PORTAL LAYOUT ==================== */}
<Route element={<PortalLayout />}>
@@ -304,26 +306,6 @@ function App() {
/>
{/* Public/guest accessible routes */}
<Route
path="/flash"
element={
<ProtectedRoute requireAuth={false}>
<Suspense fallback={<RouteLoadingFallback />}>
<FlashGamesView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/html5"
element={
<ProtectedRoute requireAuth={false}>
<Suspense fallback={<RouteLoadingFallback />}>
<HTML5GamesView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/browse"
element={
@@ -346,46 +328,6 @@ function App() {
/>
{/* Section-based game routes - must come before /games/:id */}
<Route
path="/flash/:id"
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/flash/:id/play"
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage autoPlay />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/html5/:id"
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/html5/:id/play"
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage autoPlay />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/animations/:id"
element={
+1 -1
View File
@@ -32,7 +32,7 @@ export function Header({ hideSidebarToggle = false, hideSearch = false }: Header
const { logout } = useAuth();
const { data: publicSettings } = usePublicSettings();
const siteName = publicSettings?.app?.siteName ?? 'Flashpoint Archive';
const siteName = publicSettings?.app?.siteName ?? 'Dotcom Arcade';
const isMobile = typeof window !== 'undefined' && window.innerWidth < 1024;
const effectiveCollapsed = isMobile ? false : sidebarCollapsed;
@@ -35,7 +35,7 @@ interface GameBrowseLayoutProps {
platform?: string;
headerContent?: ReactNode;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Pin the downloaded filter on and hide its switch (used by the Downloaded page) */
forceDownloaded?: boolean;
+1 -1
View File
@@ -29,7 +29,7 @@ interface GameCardProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Rating aggregate data for this game */
ratingData?: BatchRatingAggregate;
+1 -1
View File
@@ -12,7 +12,7 @@ interface GameGridProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Batch rating data keyed by game ID */
ratingDataMap?: Record<string, BatchRatingAggregate>;
+1 -1
View File
@@ -12,7 +12,7 @@ interface GameListProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Batch rating data keyed by game ID */
ratingDataMap?: Record<string, BatchRatingAggregate>;
@@ -28,7 +28,7 @@ interface GameListItemProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Rating aggregate data for this game */
ratingData?: BatchRatingAggregate;
@@ -20,7 +20,7 @@ import { useMatureStore } from '@/store/mature';
export const PortalHeader = forwardRef<HTMLInputElement>(function PortalHeader(_props, searchRef) {
const navigate = useNavigate();
const { data: publicSettings } = usePublicSettings();
const siteName = publicSettings?.app?.siteName ?? 'Flashpoint Archive';
const siteName = publicSettings?.app?.siteName ?? 'Dotcom Arcade';
const tagline = publicSettings?.app?.tagline;
const [query, setQuery] = useState('');
@@ -51,14 +51,14 @@ export function AppUpdateCard() {
if (result.lastCheckFailed) {
showToast('Could not reach GitHub to check for updates', 'error');
} else if (result.updateAvailable) {
showToast(`Flashpoint Web ${result.latestVersion} is available`, 'success');
showToast(`Dotcom Arcade ${result.latestVersion} is available`, 'success');
} else if (result.isUnreleasedBuild) {
showToast(
`Update checks are off for this build (latest release: ${result.latestVersion ?? 'unknown'})`,
'info'
);
} else if (versionsMatch(result.currentVersion, result.latestVersion)) {
showToast('Flashpoint Web is up to date', 'success');
showToast('Dotcom Arcade is up to date', 'success');
} else {
showToast(`Latest release: ${result.latestVersion ?? 'unknown'}`, 'info');
}
@@ -73,6 +73,9 @@ export function AppUpdateCard() {
setShowChangelog(true);
}
// Without a release source (APP_UPDATE_URL) there is nothing to check
if (info?.enabled === false) return null;
const checkFailedEntirely =
isLoadingError || (info?.lastCheckFailed === true && info.latestVersion === null);
const currentVersionMatchesLatest = info
@@ -84,7 +87,7 @@ export function AppUpdateCard() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Rocket size={24} className="text-primary" aria-hidden="true" />
<h2 className="text-xl font-semibold">Flashpoint Web</h2>
<h2 className="text-xl font-semibold">Dotcom Arcade</h2>
</div>
<Button
variant="outline"
+2
View File
@@ -38,6 +38,8 @@ export interface AssetDownloadProgress {
}
export interface AppUpdateInfo {
/** False when the server has no release source configured */
enabled: boolean;
currentVersion: string | null;
latestVersion: string | null;
updateAvailable: boolean;
+2 -2
View File
@@ -33,7 +33,7 @@ export interface SaveSyncState {
const STATE_DB = 'flashpoint-save-sync';
const STATE_STORE = 'games';
/** flashpoint-web's own storage, never part of a game's save */
/** The app's own storage, never part of a game's save */
const APP_DATABASES = new Set(['flashpoint-stream-saves', STATE_DB]);
// Unity WebGL's asset cache: large and re-downloadable, not a save
const IGNORED_DATABASES = new Set(['UnityCache']);
@@ -170,7 +170,7 @@ async function gameDatabaseHashes(): Promise<Map<string, string>> {
}
/**
* Notices what a running game changes. Games share flashpoint-web's origin and
* Notices what a running game changes. Games share the app's origin and
* Ruffle saves may use a site-wide path, so storage that changes while a game
* runs is attributed to it rather than guessed from names.
*/
+8 -24
View File
@@ -12,9 +12,9 @@ export interface SectionIcon {
* Section configuration for game routes
*/
export interface SectionConfig {
/** URL prefix for game routes (e.g., '/flash') */
/** URL prefix for game routes (e.g., '/browse') */
prefix: string;
/** Sidebar item path (e.g., '/flash') */
/** Sidebar item path (e.g., '/browse') */
sidebarPath: string;
/** Label for breadcrumbs */
breadcrumbLabel: string;
@@ -28,20 +28,6 @@ export interface SectionConfig {
* Section configurations keyed by section identifier
*/
export const SECTION_CONFIGS: Record<string, SectionConfig> = {
flash: {
prefix: '/flash',
sidebarPath: '/flash',
breadcrumbLabel: 'Flash Games',
breadcrumbHref: '/flash',
icon: { type: 'image', value: '/images/Flash.png' },
},
html5: {
prefix: '/html5',
sidebarPath: '/html5',
breadcrumbLabel: 'HTML5 Games',
breadcrumbHref: '/html5',
icon: { type: 'image', value: '/images/HTML5.png' },
},
animations: {
prefix: '/animations',
sidebarPath: '/animations',
@@ -65,8 +51,6 @@ export const SECTION_CONFIGS: Record<string, SectionConfig> = {
* When breadcrumb context exists, it takes priority over prefix matching.
*/
export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
'/flash': ['/flash/'],
'/html5': ['/html5/'],
'/animations': ['/animations/'],
'/browse': ['/browse/', '/games/'], // /games/ only highlights Browse on direct URL access
'/playlists': ['/playlists/'],
@@ -76,7 +60,7 @@ export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
/**
* Get section configuration from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
* @returns Section config if pathname matches a section route, null otherwise
*/
export function getSectionFromPath(pathname: string): SectionConfig | null {
@@ -90,8 +74,8 @@ export function getSectionFromPath(pathname: string): SectionConfig | null {
/**
* Get section key from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @returns Section key ('flash', 'html5', etc.) or null
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
* @returns Section key ('browse', 'animations') or null
*/
export function getSectionKeyFromPath(pathname: string): string | null {
for (const [key, config] of Object.entries(SECTION_CONFIGS)) {
@@ -105,9 +89,9 @@ export function getSectionKeyFromPath(pathname: string): string | null {
/**
* Build a section-based game URL
* @param gameId - Game UUID
* @param sectionKey - Section key ('flash', 'html5', 'animations', 'browse') or null
* @param sectionKey - Section key ('animations', 'browse') or null
* @param isPlay - Whether this is a play URL (adds '/play' suffix)
* @returns URL string (e.g., '/flash/abc-123' or '/flash/abc-123/play')
* @returns URL string (e.g., '/browse/abc-123' or '/browse/abc-123/play')
*/
export function buildSectionGameUrl(
gameId: string,
@@ -127,7 +111,7 @@ export function buildSectionGameUrl(
/**
* Derive breadcrumb context from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
* @returns BreadcrumbContext if pathname matches a section route, null otherwise
*/
export function getBreadcrumbContextFromPath(pathname: string): BreadcrumbContext | null {

Some files were not shown because too many files have changed in this diff Show More