diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b1cbfa7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +dev-dist +.git +.github +*.tsbuildinfo +*.local diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..035e978 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,66 @@ +name: Container image + +# Publishes ghcr.io/libreble/ (linux/amd64 + linux/arm64) with the built-in +# GITHUB_TOKEN — no registry account or secrets. Pull requests build and smoke-test only. +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + # Build for the runner, run it, and check the page, assets, SW, manifest and SPA fallback. + - uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: smoke:test + cache-from: type=gha + cache-to: type=gha,mode=max + - run: docker run -d --name smoke -p 8080:8080 smoke:test + - run: ./docker/smoke.sh http://localhost:8080/ + - if: failure() + run: docker logs smoke + + - if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - if: github.event_name != 'pull_request' + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + cache-from: type=gha diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72ecdce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1 +# COLLET as a static site: build with Node, serve with unprivileged nginx on :8080. +# docker build -t collet . # served at / +# docker build --build-arg BASE_PATH=/collet/ -t collet . # served at /collet/ + +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +ARG BASE_PATH=/ +RUN BASE_PATH="$BASE_PATH" npm run build + +FROM nginxinc/nginx-unprivileged:1.29-alpine +ARG BASE_PATH=/ +ENV BASE_PATH=$BASE_PATH +COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template +COPY --from=build /app/dist /usr/share/nginx/html${BASE_PATH} +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO /dev/null http://127.0.0.1:8080/healthz || exit 1 diff --git a/README.md b/README.md index e859a5f..6b35ab2 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,51 @@ npm run selftest # runtime tests: STP framing, reassembly, protobuf, mock hand Web Bluetooth needs a secure context — `localhost` counts, otherwise serve over HTTPS. +## Self-host + +The hosted app above is the easiest way. If you'd rather run your own copy, it's a static site — +nothing to configure, no backend, no database. + +**Docker** — a prebuilt image (linux/amd64 + arm64) is published to the GitHub Container Registry: + +```bash +docker run -d --name collet -p 8080:8080 --restart unless-stopped ghcr.io/libreble/collet +# → http://localhost:8080/ +``` + +```yaml +# compose.yaml +services: + collet: + image: ghcr.io/libreble/collet:latest + ports: ['8080:8080'] + restart: unless-stopped +``` + +The image serves the app at `/`. To serve it under a subpath behind your own proxy, build it +yourself: `docker build --build-arg BASE_PATH=/collet/ -t collet .` + +**Build and host it yourself** — any static web server works: + +```bash +npm ci +BASE_PATH=/ npm run build # → dist/ +# upload dist/ to nginx, Caddy, Netlify, Cloudflare Pages, a bucket, … +``` + +Set `BASE_PATH` to the path you serve from (it defaults to `/collet/`, the GitHub Pages path). +Two things your server should do: send unknown paths to `index.html` (client-side routes), and +serve `index.html` and `sw.js` with `Cache-Control: no-cache` so updates reach installed copies. +[`docker/nginx.conf.template`](docker/nginx.conf.template) is a working nginx example. + +> **HTTPS is required.** Web Bluetooth only works in a secure context. `http://localhost` counts, +> so the app works on the machine running it — but `http://192.168.x.x:8080` from your phone +> will load and then refuse to connect. For phones, put it behind TLS: a reverse proxy with a +> real certificate (Caddy does this automatically for a domain), or `tailscale serve`. + +Self-hosted copies keep their `` pointing at libreble.github.io, so +search engines don't treat them as duplicates. + ## Layout ``` diff --git a/docker/nginx.conf.template b/docker/nginx.conf.template new file mode 100644 index 0000000..2cc7686 --- /dev/null +++ b/docker/nginx.conf.template @@ -0,0 +1,36 @@ +# Static server for the built PWA. BASE_PATH is substituted at container start +# (nginx image envsubst; only defined env vars are replaced, so $uri stays as is). +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + server_tokens off; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + # Hashed build output: cache forever. + location ${BASE_PATH}assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header X-Content-Type-Options nosniff; + try_files $uri =404; + } + + location ~ \.webmanifest$ { + default_type application/manifest+json; + add_header Cache-Control "no-cache"; + add_header X-Content-Type-Options nosniff; + } + + # index.html, sw.js and everything else: always revalidate so updates land. + # Unknown paths get the app shell (client-side routes, deep links). + location ${BASE_PATH} { + add_header Cache-Control "no-cache"; + add_header X-Content-Type-Options nosniff; + try_files $uri $uri/ ${BASE_PATH}index.html; + } +} diff --git a/docker/smoke.sh b/docker/smoke.sh new file mode 100755 index 0000000..89be7ca --- /dev/null +++ b/docker/smoke.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Smoke-test a running image: ./docker/smoke.sh http://localhost:8080/ +set -eu +url="${1:-http://localhost:8080/}" +origin=$(printf '%s' "$url" | sed -E 's#(https?://[^/]+).*#\1#') +fail() { echo "FAIL: $*" >&2; exit 1; } + +for i in $(seq 1 30); do curl -fs "$origin/healthz" >/dev/null && break; sleep 1; done +curl -fsS "$origin/healthz" | grep -q ok || fail healthz + +html=$(curl -fsS "$url") || fail "index at $url" +echo "$html" | grep -q '
+ diff --git a/src/meta.ts b/src/meta.ts index 97c38ae..d925e8e 100644 --- a/src/meta.ts +++ b/src/meta.ts @@ -20,6 +20,18 @@ function canonical(href: string) { el.setAttribute('href', href); } +// Canonical URLs always point at the public app, also in self-hosted copies (any base path), +// so search engines don't index those as duplicates. +const CANONICAL_ROOT = 'https://libreble.github.io/collet/'; + +function canonicalFor(pathname: string) { + const base = import.meta.env.BASE_URL; + const route = pathname.startsWith(base) + ? pathname.slice(base.length) + : pathname.replace(/^\//, ''); + return CANONICAL_ROOT + route; +} + /** Set title + description + Open Graph/Twitter tags for the current route. */ export function useDocumentMeta(title: string, description: string) { useEffect(() => { @@ -29,6 +41,6 @@ export function useDocumentMeta(title: string, description: string) { upsert('property', 'og:description', description); upsert('name', 'twitter:title', title); upsert('name', 'twitter:description', description); - canonical(window.location.origin + window.location.pathname); + canonical(canonicalFor(window.location.pathname)); }, [title, description]); } diff --git a/vite.config.ts b/vite.config.ts index af6c4ab..a3c4ce7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,16 +23,22 @@ function pagesSpaFallback(): Plugin { // COLLET is a fully local PWA: no backend, no analytics, no cloud. // Served from the /collet/ subpath on GitHub Pages (https://libreble.github.io/collet/). +// Self-hosters override it: `BASE_PATH=/ npm run build` (the Docker image does this). +const base = `/${(process.env.BASE_PATH ?? '/collet/').replace(/^\/+|\/+$/g, '')}/`.replace( + '//', + '/', +); + export default defineConfig({ - base: '/collet/', + base, plugins: [ react(), VitePWA({ registerType: 'autoUpdate', manifest: { - id: '/collet/', - start_url: '/collet/', - scope: '/collet/', + id: base, + start_url: base, + scope: base, name: 'COLLET — Dremel 8260 companion', short_name: 'COLLET', description: @@ -57,7 +63,7 @@ export default defineConfig({ // Precache the app shell, icons and the bundled guide data so the whole // guide works offline. The tool link is local BLE — no server needed. globPatterns: ['**/*.{js,css,html,svg,png,json,woff2}'], - navigateFallback: '/collet/index.html', + navigateFallback: `${base}index.html`, }, }), pagesSpaFallback(),