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..dfb8cca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1 +# Brushlog as a static site: build with Node, serve with unprivileged nginx on :8080. +# docker build -t brushlog . # served at / +# docker build --build-arg BASE_PATH=/brushlog/ -t brushlog . # served at /brushlog/ + +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 5d261d5..1f6f9d7 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,51 @@ npm run preview # serve the built app npm run typecheck # tsc, no emit ``` +## 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 brushlog -p 8080:8080 --restart unless-stopped ghcr.io/libreble/brushlog +# → http://localhost:8080/ +``` + +```yaml +# compose.yaml +services: + brushlog: + image: ghcr.io/libreble/brushlog: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=/brushlog/ -t brushlog .` + +**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 `/brushlog/`, 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. + ## Browser support Web Bluetooth works in **Chrome/Edge on desktop and Android**. **iOS Safari is not supported** 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/package-lock.json b/package-lock.json index a0bff21..481aa98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/node": "^24.13.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/web-bluetooth": "^0.0.21", @@ -2945,6 +2946,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -6222,6 +6233,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", diff --git a/package.json b/package.json index 327d7c0..52fc49d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/node": "^24.13.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/web-bluetooth": "^0.0.21", diff --git a/tsconfig.node.json b/tsconfig.node.json index 847d071..f2c9792 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2023", "lib": ["ES2023"], + "types": ["node"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", diff --git a/vite.config.ts b/vite.config.ts index 6b1b78c..0544770 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,9 +5,13 @@ import { VitePWA } from 'vite-plugin-pwa'; // Brushlog is a fully local PWA: no backend, no analytics, no cloud. // The service worker precaches the app shell so it runs offline once installed. + +// Served from the /brushlog/ subpath on GitHub Pages (https://libreble.github.io/brushlog/). +// Self-hosters override it: `BASE_PATH=/ npm run build` (the Docker image does this). +const base = `/${(process.env.BASE_PATH ?? '/brushlog/').replace(/^\/+|\/+$/g, '')}/`.replace('//', '/'); + export default defineConfig({ - // Served from the /brushlog/ subpath on GitHub Pages (https://libreble.github.io/brushlog/). - base: '/brushlog/', + base, plugins: [ react(), tailwindcss(), @@ -30,9 +34,9 @@ export default defineConfig({ background_color: '#0b1120', display: 'standalone', orientation: 'portrait', - id: '/brushlog/', - start_url: '/brushlog/', - scope: '/brushlog/', + id: base, + start_url: base, + scope: base, // PNG icons (raster) for launchers that don't render SVG app icons; SVG kept as scalable // `any`. Generated from the SVGs (see public/icon*.png). Maskable variants full-bleed the // teal background so Android's adaptive-icon safe-zone crops cleanly.