Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
dist
dev-dist
.git
.github
*.tsbuildinfo
*.local
66 changes: 66 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Container image

# Publishes ghcr.io/libreble/<repo> (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
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<link rel="canonical">` pointing at libreble.github.io, so
search engines don't treat them as duplicates.

## Layout

```
Expand Down
36 changes: 36 additions & 0 deletions docker/nginx.conf.template
Original file line number Diff line number Diff line change
@@ -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;
}
}
23 changes: 23 additions & 0 deletions docker/smoke.sh
Original file line number Diff line number Diff line change
@@ -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 '<div id="root"' || fail "no #root in index"

# Every asset the page references must resolve.
for a in $(echo "$html" | grep -oE '(src|href)="[^"]+\.(js|css|svg|png|webmanifest)"' | sed -E 's/^[a-z]+="//; s/"$//' | grep -v '^http'); do
case "$a" in /*) u="$origin$a" ;; *) u="$url$a" ;; esac
curl -fsS -o /dev/null "$u" || fail "asset $u"
done

curl -fsS "${url}sw.js" | grep -q . || fail sw.js
curl -fsSI "${url}manifest.webmanifest" | grep -qi 'content-type: application/manifest+json' || fail "manifest content-type"
curl -fsS "${url}some/deep/link" | grep -q '<div id="root"' || fail "SPA fallback"
echo "smoke OK: $url"
9 changes: 9 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@
</head>
<body>
<div id="root"></div>
<noscript>
<h1>COLLET — Dremel 8260 companion</h1>
<p>
Live battery, speed and temperature, speed control, and an accessory &amp; material speed
guide for the Dremel 8260 smart rotary tool — over Web Bluetooth, with no vendor account and
no cloud. It needs JavaScript and Chrome on Android or Chrome/Edge on desktop.
</p>
<p><a href="https://github.com/libreble/collet">Source and protocol documentation</a></p>
</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
14 changes: 13 additions & 1 deletion src/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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]);
}
16 changes: 11 additions & 5 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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(),
Expand Down
Loading