BetterFrame/BSB_LEARNINGS.md
Mitchell R b0f42d29c2
feat: pre-boot firmware self-update + public endpoints
Kiosk checks for stable firmware update before pairing. If available,
downloads + verifies + swaps binary and restarts. No auth needed.

Server: GET /api/firmware/public/check (stable channel, no auth)
        GET /api/firmware/public/download/:id (rate-limited, no auth)

Kiosk: check_public() + apply_public() in firmware.rs. Called from
ui.rs worker thread before entering pairing loop. kiosk_app_version
made pub for access from ui.rs.

Also includes kiosk_id deserialization fix (Value instead of String).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 04:16:17 +02:00

7 KiB

BSB Framework — Practical Learnings

Lessons learned building BetterFrame on BSB v9. Intended for updating bsbcode.dev/llms.txt so future LLM sessions don't repeat these mistakes.


Container Runtime

Base Image

  • Image: betterweb/service-base:9 (Docker Hub)
  • Alpine-based (use apk not apt-get)
  • Entrypoint: /root/entrypoint.sh — runs as root, drops to unprivileged user
  • DO NOT set USER in child Dockerfiles — BSB entrypoint handles privilege dropping
  • DO NOT override CMD or ENTRYPOINT — BSB handles startup

Production Environment

  • Set ENV NODE_ENV=production and ENV BSB_LIVE=true in Dockerfile
  • Without BSB_LIVE=true, BSB warns about non-production and tries to write sec-config.yaml

Plugin Discovery

  • BSB searches: /home/bsb/node_modules/<package>/lib/plugins/<type>-<name>/index.js
  • Also searches: /mnt/bsb-plugins/node_modules/... but this is a VOLUME — build-time COPY gets shadowed by empty anonymous volume at runtime
  • USE /home/bsb/node_modules/ for build-time plugin installation
  • Working directory is /home/bsb

Plugin Registration in Config

services:
  service-my-plugin:
    package: my-npm-package-name    # <-- REQUIRED for container mode
    plugin: service-my-plugin
    enabled: true
    config: {}

Without package:, BSB only finds built-in plugins (config-default, events-default, log-default).

The package value must match the name field in the plugin's package.json.


Config Schema

Schema Extractor Limitations

  • bsb-plugin-cli build extracts config schemas from TypeScript source statically
  • Cannot resolve cross-file imports — imported schema variables show as "not defined"
  • Workaround: inline anyvali schema definitions in each plugin's ConfigSchema
  • Type-only imports (import type { ... }) are fine — only runtime value imports break extraction
// BAD — schema extractor can't resolve this:
import { dbConfigSchema } from "../../shared/db/config.js";
const ConfigSchema = av.object({ db: dbConfigSchema, ... });

// GOOD — inline the schema:
const ConfigSchema = av.object({
  db: av.object({
    driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
    host: av.string().default("postgres"),
    // ...
  }, { unknownKeys: "strip" }),
  // ...
});

Nested Config Objects

  • Config supports nested av.object() — use for grouping related fields:
config:
  db:
    driver: postgres
    host: postgres
  host: 0.0.0.0
  port: 18080
  • Access via this.config.db.driver, this.config.host

Config Defaults

  • BSB does NOT apply anyvali schema defaults for keys missing from sec-config.yaml
  • Always declare config values explicitly in sec-config.yaml
  • Defaults in the schema are documentation, not runtime fallbacks

No Environment Variable Access

  • Application code should never read process.env
  • All config comes from sec-config.yaml via this.config.*
  • For Docker/Coolify: use build-time envsubst on a template to generate sec-config.yaml
  • sec-config.yaml should be baked into the image or bind-mounted — not generated at runtime

Plugin Architecture

What Should Be a Plugin

  • Services with own port, lifecycle, independent scaling
  • Examples: HTTP server, WebSocket server, API server

What Should NOT Be a Plugin

  • Database access layer (just a library — no port, no lifecycle)
  • Shared utilities, crypto helpers, auth logic
  • Anything that's just a function other plugins call

Cross-Plugin Communication

  • Plugins should have zero runtime imports from other plugins
  • Type-only imports (import type { ... }) are acceptable
  • Communication between plugins: BSB event bus (emitBroadcast, emitReturnableEvent)
  • Shared code goes in src/shared/ — imported by multiple plugins as a library

Plugin Init Order

  • initAfterPlugins / initBeforePlugins control order
  • Not needed when plugins are independent (each inits own DB, etc.)

Build System

Build Command

cross-env NODE_OPTIONS="--import tsx" bsb-plugin-cli build
  • BSB build needs tsx for schema extraction from TypeScript source
  • Build output: lib/ directory with compiled JS + bsb-plugin.json

bsb-plugin.json

  • Auto-generated by build, lists all discovered plugins
  • Must match the plugins present in src/plugins/
  • If a plugin is removed, this file must be regenerated

Dev Mode

cross-env NODE_OPTIONS="--import tsx" bsb-plugin-cli dev
  • Hot reload, runs TypeScript directly
  • Creates .bsbdevwatch file for include/exclude patterns

Logging

Log Message Format

  • obs.log.info("text {tag}", { tag: value }) — structured logging
  • Message strings MUST be string literals (BSB SmartLogMeta extracts placeholders from literal type)
  • String concatenation ("a " + "b") widens to string and breaks SmartLogMeta placeholder extraction
  • Template literals in log messages work for the value, not the message key
// BAD:
obs.log.info("connecting to " + url, {});

// GOOD:
obs.log.info("connecting to {url}", { url });

Docker Deployment Pattern

Dockerfile Structure

# Builder — compile TypeScript + native deps
FROM node:24-trixie-slim AS builder
WORKDIR /app
# ... npm ci, npm run build ...

# Runtime — BSB container
FROM betterweb/service-base:9

# Install extras (Alpine)
RUN apk add --no-cache gettext ffmpeg

# Copy built plugin into BSB's node_modules
COPY --from=builder /app/server/package.json /home/bsb/node_modules/<pkg>/package.json
COPY --from=builder /app/server/bsb-plugin.json /home/bsb/node_modules/<pkg>/bsb-plugin.json
COPY --from=builder /app/server/lib /home/bsb/node_modules/<pkg>/lib
COPY --from=builder /app/node_modules /home/bsb/node_modules/<pkg>/node_modules

# Generate config from template
COPY sec-config.template.yaml /tmp/sec-config.template.yaml
RUN envsubst < /tmp/sec-config.template.yaml > /home/bsb/sec-config.yaml \
    && chmod 444 /home/bsb/sec-config.yaml \
    && rm /tmp/sec-config.template.yaml

ENV NODE_ENV=production
ENV BSB_LIVE=true

sec-config.template.yaml Pattern

  • Template with ${VAR} placeholders (NOT ${VAR:-default} — envsubst doesn't support defaults)
  • Defaults come from Dockerfile ARG declarations
  • Secrets set as Coolify build args (not in git)
  • Template committed to public repo (safe — no secrets)
  • Generated sec-config.yaml baked into image at build time

Common Pitfalls

  1. betterweb/service-base:node is v8, :9 is v9 — use the correct tag
  2. VOLUME declarations in base image shadow build-time COPY — don't write to /mnt/bsb-plugins
  3. USER node blocks BSB entrypoint — entrypoint is at /root/entrypoint.sh, needs root access
  4. Schema extractor can't follow imports — inline schemas in plugin ConfigSchema
  5. package: required in sec-config — without it, BSB won't find external plugins
  6. Alpine = apk, not apt-get — base image is Alpine Linux
  7. sec-config.yaml must be writable in dev, read-only in prod — BSB_LIVE=true skips write attempts