From 66d75964aa95a298400f3526122414c990acc891 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 19:28:06 +0000 Subject: [PATCH 1/2] feat: add optional self-hosted app with SQLite-backed UUID artifact storage Adds a separate self-hosted variant that stores agent-render payloads in SQLite under UUID v4 keys and serves them at /{uuid} routes through the existing viewer UI. This is an optional add-on for power users and agents who need persistent short links or payloads exceeding the fragment budget. - selfhosted/src/server.ts: Express server with CRUD API and viewer route - selfhosted/src/db.ts: SQLite database with 24h sliding TTL - selfhosted/Dockerfile + docker-compose.yml: container deployment - src/lib/payload/injected.ts: viewer-shell integration for server-injected envelopes - skills/selfhosted-agent-render/SKILL.md: agent workflow skill - Updated docs across architecture, deployment, payload-format, testing, and dependency-notes - 8 new unit tests for injected envelope resolution (all artifact kinds, validation, TTL) The existing static fragment-based app is fully preserved and unmodified. https://claude.ai/code/session_01FWECp4ZWSgoxbPvzhZbp5n --- AGENTS.md | 9 + README.md | 20 +- docs/architecture.md | 41 ++++ docs/dependency-notes.md | 8 + docs/deployment.md | 100 +++++++++ docs/payload-format.md | 13 ++ docs/testing.md | 14 ++ selfhosted/.env.example | 14 ++ selfhosted/.gitignore | 4 + selfhosted/Dockerfile | 39 ++++ selfhosted/docker-compose.yml | 19 ++ selfhosted/package.json | 27 +++ selfhosted/src/cleanup.ts | 7 + selfhosted/src/db.ts | 152 +++++++++++++ selfhosted/src/server.ts | 234 ++++++++++++++++++++ selfhosted/tsconfig.json | 17 ++ skills/agent-render-linking/SKILL.md | 10 +- skills/selfhosted-agent-render/SKILL.md | 281 ++++++++++++++++++++++++ src/components/viewer-shell.tsx | 20 +- src/lib/payload/injected.ts | 58 +++++ tests/injected.test.ts | 126 +++++++++++ tsconfig.json | 3 +- 22 files changed, 1211 insertions(+), 5 deletions(-) create mode 100644 selfhosted/.env.example create mode 100644 selfhosted/.gitignore create mode 100644 selfhosted/Dockerfile create mode 100644 selfhosted/docker-compose.yml create mode 100644 selfhosted/package.json create mode 100644 selfhosted/src/cleanup.ts create mode 100644 selfhosted/src/db.ts create mode 100644 selfhosted/src/server.ts create mode 100644 selfhosted/tsconfig.json create mode 100644 skills/selfhosted-agent-render/SKILL.md create mode 100644 src/lib/payload/injected.ts create mode 100644 tests/injected.test.ts diff --git a/AGENTS.md b/AGENTS.md index fb7fd50..8a5a9f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,14 @@ If you change the payload contract, update the code, docs, examples, and the Ope ### Diff handling - `src/lib/diff/git-patch.ts` - patch parsing support for diff rendering +### Self-hosted mode (optional add-on) +- `selfhosted/src/server.ts` - Express server with API routes and viewer route +- `selfhosted/src/db.ts` - SQLite database setup and queries +- `selfhosted/src/cleanup.ts` - Standalone expired artifact cleanup script +- `selfhosted/Dockerfile` - Multi-stage Docker build +- `selfhosted/docker-compose.yml` - Docker Compose configuration +- `src/lib/payload/injected.ts` - Injected envelope resolver for the viewer shell + ### Docs and external contract - `README.md` - `docs/architecture.md` @@ -136,6 +144,7 @@ If you change the payload contract, update the code, docs, examples, and the Ope - `docs/dependency-notes.md` - `docs/testing.md` - `skills/agent-render-linking/SKILL.md` +- `skills/selfhosted-agent-render/SKILL.md` ## Development commands diff --git a/README.md b/README.md index 16bc017..f40339e 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,25 @@ Built for the OpenClaw ecosystem, `agent-render` focuses on fragment-based shari ## Principles - Fully static export with Next.js App Router -- No backend, no database, no server-side persistence +- No backend, no database, no server-side persistence for the default fragment-based mode - Fragment-based payloads (`#...`) so the server never receives artifact contents - Public-safe naming and MIT-compatible dependencies +## Self-Hosted Mode (Optional) + +An optional self-hosted variant is available in `selfhosted/` for use cases where fragment-based links are impractical (payloads too large, chat platforms mangle URLs, or persistent short links are needed). + +The self-hosted server: +- Stores artifact payloads in SQLite under UUID v4 keys +- Serves the same viewer UI at `/{uuid}` routes +- Provides a simple CRUD API at `/api/artifacts` +- Implements 24-hour sliding TTL (each view extends expiry) +- Supports Docker Compose and daemon/service deployments + +This is a separate add-on for power users and agents. The default static fragment-based product is unaffected. + +See `docs/deployment.md` for setup instructions and `skills/selfhosted-agent-render/SKILL.md` for agent workflow guidance. + ## Local Development ```bash @@ -79,9 +94,10 @@ The shell keeps first load lean and defers renderer-heavy code until needed. The - `docs/architecture.md` - architecture and tradeoffs - `docs/payload-format.md` - fragment protocol, limits, and examples -- `docs/deployment.md` - deployment notes +- `docs/deployment.md` - deployment notes (including self-hosted mode) - `docs/dependency-notes.md` - major dependency and license notes - `docs/testing.md` - test commands, screenshot workflow, and CI notes +- `skills/selfhosted-agent-render/SKILL.md` - self-hosted agent workflow skill ## Zero Retention diff --git a/docs/architecture.md b/docs/architecture.md index 7f73cbf..3d5ab3c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,3 +110,44 @@ The static host does not receive fragment contents as part of the request, but t - GitHub Pages-compatible `basePath` and `assetPrefix` - `.nojekyll` included for Pages compatibility - Fragment size budget enforced before render + +## Self-hosted mode (optional) + +An optional self-hosted variant lives in `selfhosted/` and provides server-backed artifact storage as an add-on to the static product. + +### How it differs from the default static mode + +| Aspect | Static (default) | Self-hosted | +|--------|------------------|-------------| +| Storage | URL fragment only | SQLite + UUID | +| Server | None (static files) | Express server | +| Payload limits | 8,000 char fragment budget | 1 MB per artifact | +| Persistence | None (zero-retention) | 24h sliding TTL | +| Links | `host/#agent-render=v1...` | `host/{uuid}` | +| Dependencies | None beyond static hosting | Node.js, SQLite | + +### Architecture + +The self-hosted server: +- Builds the same Next.js static export and serves it from `out/` +- Adds Express API routes (`/api/artifacts`) for CRUD operations +- Handles `/{uuid}` routes by injecting the stored payload into the static HTML template via `window.__AGENT_RENDER_ENVELOPE__` +- The viewer shell checks for this injected global on mount and uses it instead of fragment decoding when present +- SQLite stores `id -> payload` mappings with timestamps and TTL tracking +- Expired artifacts are filtered at query time and can be cleaned up explicitly + +### Viewer integration + +The viewer shell (`src/components/viewer-shell.tsx`) checks for `window.__AGENT_RENDER_ENVELOPE__` on mount via the `resolveInjectedEnvelope()` helper in `src/lib/payload/injected.ts`. When present and valid, the injected envelope is used directly, bypassing fragment decoding. When absent, the standard fragment-based path runs as before. + +This integration is minimal and non-breaking: the injected path only activates when the self-hosted server has set the global, which never happens in the static export. + +### Key files + +- `selfhosted/src/server.ts` - Express server with API routes and viewer route +- `selfhosted/src/db.ts` - SQLite database setup and queries +- `selfhosted/src/cleanup.ts` - Standalone expired artifact cleanup script +- `selfhosted/Dockerfile` - Multi-stage Docker build +- `selfhosted/docker-compose.yml` - Docker Compose configuration +- `src/lib/payload/injected.ts` - Injected envelope resolver for the viewer shell +- `skills/selfhosted-agent-render/SKILL.md` - Agent workflow skill diff --git a/docs/dependency-notes.md b/docs/dependency-notes.md index 42e8bb5..8965497 100644 --- a/docs/dependency-notes.md +++ b/docs/dependency-notes.md @@ -31,6 +31,14 @@ - `papaparse` plus `@tanstack/react-table` keeps CSV parsing and rendering readable without coupling to a heavyweight data-grid framework. - `fflate` provides portable deflate/inflate support across iOS Safari and Android Chromium without relying on browser-specific compression streams. +## Self-hosted mode (optional, in selfhosted/) + +- `express` - MIT — minimal HTTP server for the self-hosted variant +- `better-sqlite3` - MIT — synchronous SQLite bindings for Node.js, used for artifact storage +- `tsx` - MIT — TypeScript execution for Node.js, used as the dev/runtime runner + +These dependencies are only required for the self-hosted server and live in `selfhosted/package.json`, separate from the main app's dependencies. + ## Notable removals - `rehype-highlight` was removed after review because markdown fences now reuse the CodeMirror viewer stack directly. diff --git a/docs/deployment.md b/docs/deployment.md index c7221b7..3686a49 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -45,3 +45,103 @@ Cloudflare Pages works well with the current project shape. - Environment variable: set `NEXT_PUBLIC_BASE_PATH` only if you intentionally deploy under a subpath If you deploy at the domain root on Cloudflare Pages, leave `NEXT_PUBLIC_BASE_PATH` unset. + +--- + +## Self-hosted mode (optional) + +The self-hosted variant in `selfhosted/` adds server-backed SQLite storage and UUID-based artifact links. This is a separate add-on deployment, not a replacement for static hosting. + +### When to use it + +- Payloads exceed the 8,000-character fragment budget +- Chat platforms mangle long or Unicode-heavy URLs +- You want short, persistent `/{uuid}` links +- Agents need a simple API to create and manage artifacts + +### Quick start + +```bash +# Build the static frontend first +npm ci && npm run build + +# Set up the self-hosted server +cd selfhosted +npm install +cp .env.example .env +# Edit .env as needed (BASE_URL, PORT, etc.) +npm start +``` + +The server starts at `http://localhost:3001`. + +### Docker Compose + +```bash +cd selfhosted +docker compose up -d +``` + +Set `BASE_URL` and `PORT` via environment variables or edit `docker-compose.yml`. + +### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `3001` | Server port | +| `DB_PATH` | `./data/agent-render.db` | SQLite database file path | +| `STATIC_DIR` | `../out` | Path to the built static export | +| `BASE_URL` | `http://localhost:3001` | Base URL for artifact links in API responses | +| `TTL_HOURS` | `24` | Artifact expiry TTL in hours | + +### Storage + +Uses SQLite with a single `artifacts` table: + +| Column | Type | Description | +|--------|------|-------------| +| `id` | TEXT (PK) | UUID v4 | +| `payload` | TEXT | Envelope JSON | +| `created_at` | TEXT | ISO datetime | +| `updated_at` | TEXT | ISO datetime | +| `last_viewed_at` | TEXT | ISO datetime (nullable) | +| `expires_at` | TEXT | ISO datetime | + +An index on `expires_at` supports efficient TTL queries. + +### TTL behavior + +- 24-hour sliding TTL by default (configurable via `TTL_HOURS`) +- Each successful view extends expiry by the configured TTL +- Expired artifacts return 404 +- Cleanup: `POST /api/cleanup` or `cd selfhosted && npm run cleanup` +- You can also ask your agent to clean up old DB records on a schedule + +### API + +- `POST /api/artifacts` — create an artifact (returns UUID and URL) +- `GET /api/artifacts/:id` — retrieve an artifact (refreshes TTL) +- `PUT /api/artifacts/:id` — update an artifact's payload +- `DELETE /api/artifacts/:id` — delete an artifact +- `POST /api/cleanup` — remove expired artifacts + +### Daemon/service deployment + +For persistent deployments, use pm2, systemd, or similar: + +```bash +# pm2 +cd selfhosted && pm2 start "node --import tsx src/server.ts" --name agent-render + +# systemd: see skills/selfhosted-agent-render/SKILL.md for a unit file example +``` + +### Optional auth and perimeter protection + +The server does not include built-in auth. For private deployments, consider: + +- **Cloudflare Tunnel + Zero Trust**: install `cloudflared`, create a tunnel to your server, and configure Access policies. This is the recommended approach for exposing the server to the internet with identity-based access control. +- **Reverse proxy with auth**: nginx, Caddy, or Traefik with OAuth2 Proxy or basic auth in front of the server. +- **Localhost binding**: for same-machine deployments, the server listens on all interfaces by default. Use a reverse proxy or firewall to restrict access if needed. + +The server can also be made fully public if desired. diff --git a/docs/payload-format.md b/docs/payload-format.md index 9160a63..0ce7a9a 100644 --- a/docs/payload-format.md +++ b/docs/payload-format.md @@ -226,3 +226,16 @@ Real diff artifacts can contain multiple `diff --git` sections inside one `patch ``` Malformed JSON should still use `kind: "json"`; the viewer will show the parse error and a raw fallback instead of crashing. + +## Self-hosted payload storage + +The optional self-hosted variant (`selfhosted/`) stores the same envelope JSON in SQLite under UUID v4 keys instead of encoding it into URL fragments. + +When storing payloads for the self-hosted server: +- Use the standard envelope format documented above +- Set `codec` to `"plain"` (no fragment encoding is needed) +- The `POST /api/artifacts` endpoint accepts the envelope as a JSON object or string in the `payload` field +- Maximum payload size: 1 MB (vs. 8,000 characters for fragment-based links) +- The viewer renders the stored payload identically to a fragment-decoded payload + +This mode is for use cases where fragment-length limits or URL mangling make fragment-based sharing impractical. See `docs/deployment.md` and `skills/selfhosted-agent-render/SKILL.md` for details. diff --git a/docs/testing.md b/docs/testing.md index 19fcab0..159e017 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -44,6 +44,20 @@ The suite is intentionally split by responsibility: - component tests protect selector/disclosure UI contracts - unit tests protect transport codecs, envelope validation, diff parsing, and language inference +## Self-hosted server tests + +Unit tests for the injected envelope resolver live alongside the existing test suite: + +```bash +npm run test -- tests/injected.test.ts +``` + +The self-hosted server (`selfhosted/`) can be type-checked separately: + +```bash +cd selfhosted && npm run typecheck +``` + ## CI The repository includes `.github/workflows/test.yml`, which installs Playwright browsers and runs `npm run test:ci` on pushes, pull requests, and manual dispatch. diff --git a/selfhosted/.env.example b/selfhosted/.env.example new file mode 100644 index 0000000..1e77ab7 --- /dev/null +++ b/selfhosted/.env.example @@ -0,0 +1,14 @@ +# Port for the self-hosted server +PORT=3001 + +# Path to the SQLite database file +DB_PATH=./data/agent-render.db + +# Path to the built static export from the main app (npm run build in repo root) +STATIC_DIR=../out + +# Base URL for generating artifact links in API responses (no trailing slash) +BASE_URL=http://localhost:3001 + +# TTL in hours for artifact expiry (default: 24) +TTL_HOURS=24 diff --git a/selfhosted/.gitignore b/selfhosted/.gitignore new file mode 100644 index 0000000..297f95d --- /dev/null +++ b/selfhosted/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +dist/ +*.db diff --git a/selfhosted/Dockerfile b/selfhosted/Dockerfile new file mode 100644 index 0000000..a050d3e --- /dev/null +++ b/selfhosted/Dockerfile @@ -0,0 +1,39 @@ +FROM node:20-slim AS builder + +WORKDIR /app + +# Build the static frontend +COPY package.json package-lock.json* ./ +RUN npm ci +COPY . . +RUN npm run build + +# Set up the self-hosted server +FROM node:20-slim + +WORKDIR /app/selfhosted + +# Copy the built static output +COPY --from=builder /app/out /app/out + +# Install server dependencies +COPY selfhosted/package.json selfhosted/package-lock.json* ./ +RUN npm ci --omit=dev + +# Copy server source +COPY selfhosted/src ./src +COPY selfhosted/tsconfig.json ./ + +# Create data directory for SQLite +RUN mkdir -p /app/selfhosted/data + +ENV PORT=3001 +ENV STATIC_DIR=/app/out +ENV DB_PATH=/app/selfhosted/data/agent-render.db +ENV BASE_URL=http://localhost:3001 + +EXPOSE 3001 + +VOLUME ["/app/selfhosted/data"] + +CMD ["npm", "start"] diff --git a/selfhosted/docker-compose.yml b/selfhosted/docker-compose.yml new file mode 100644 index 0000000..1e083e4 --- /dev/null +++ b/selfhosted/docker-compose.yml @@ -0,0 +1,19 @@ +services: + agent-render: + build: + context: .. + dockerfile: selfhosted/Dockerfile + ports: + - "${PORT:-3001}:3001" + volumes: + - agent-render-data:/app/selfhosted/data + environment: + - PORT=3001 + - STATIC_DIR=/app/out + - DB_PATH=/app/selfhosted/data/agent-render.db + - BASE_URL=${BASE_URL:-http://localhost:3001} + - TTL_HOURS=${TTL_HOURS:-24} + restart: unless-stopped + +volumes: + agent-render-data: diff --git a/selfhosted/package.json b/selfhosted/package.json new file mode 100644 index 0000000..b1c361a --- /dev/null +++ b/selfhosted/package.json @@ -0,0 +1,27 @@ +{ + "name": "agent-render-selfhosted", + "version": "0.1.0", + "private": true, + "description": "Self-hosted server for agent-render with SQLite-backed UUID artifact storage.", + "type": "module", + "scripts": { + "start": "node --import tsx src/server.ts", + "dev": "node --import tsx --watch src/server.ts", + "cleanup": "node --import tsx src/cleanup.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "better-sqlite3": "^11.9.1", + "express": "^5.1.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/express": "^5.0.3", + "@types/node": "^22.13.10", + "tsx": "^4.19.4", + "typescript": "^5.8.2" + }, + "engines": { + "node": ">=20.10.0" + } +} diff --git a/selfhosted/src/cleanup.ts b/selfhosted/src/cleanup.ts new file mode 100644 index 0000000..5fd87fa --- /dev/null +++ b/selfhosted/src/cleanup.ts @@ -0,0 +1,7 @@ +import { initDb, cleanupExpired, closeDb } from "./db.js"; + +/** Standalone cleanup script that removes expired artifacts and exits. */ +initDb(); +const deleted = cleanupExpired(); +console.log(`Cleaned up ${deleted} expired artifact${deleted === 1 ? "" : "s"}.`); +closeDb(); diff --git a/selfhosted/src/db.ts b/selfhosted/src/db.ts new file mode 100644 index 0000000..c9e3243 --- /dev/null +++ b/selfhosted/src/db.ts @@ -0,0 +1,152 @@ +import Database from "better-sqlite3"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import fs from "node:fs"; + +export type ArtifactRow = { + id: string; + payload: string; + created_at: string; + updated_at: string; + last_viewed_at: string | null; + expires_at: string; +}; + +const TTL_HOURS = parseInt(process.env.TTL_HOURS ?? "24", 10); +const TTL_MODIFIER = `+${TTL_HOURS} hours`; + +let db: Database.Database; + +/** Initializes the SQLite database connection and creates the artifacts table if needed. */ +export function initDb(dbPath?: string): Database.Database { + const resolvedPath = dbPath ?? process.env.DB_PATH ?? "./data/agent-render.db"; + const dir = path.dirname(resolvedPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + db = new Database(resolvedPath); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + + db.exec(` + CREATE TABLE IF NOT EXISTS artifacts ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + last_viewed_at TEXT, + expires_at TEXT NOT NULL DEFAULT (datetime('now', '${TTL_MODIFIER}')) + ) + `); + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_artifacts_expires_at ON artifacts(expires_at) + `); + + return db; +} + +/** Returns the active database instance. Throws if initDb has not been called. */ +export function getDb(): Database.Database { + if (!db) { + throw new Error("Database not initialized. Call initDb() first."); + } + return db; +} + +/** Creates a new artifact with a UUID v4 id and returns the row. */ +export function createArtifact(payload: string): ArtifactRow { + const id = randomUUID(); + const stmt = getDb().prepare(` + INSERT INTO artifacts (id, payload, created_at, updated_at, expires_at) + VALUES (?, ?, datetime('now'), datetime('now'), datetime('now', ?)) + `); + stmt.run(id, payload, TTL_MODIFIER); + return getArtifact(id)!; +} + +/** + * Retrieves an artifact by id if it has not expired. + * Extends the TTL by refreshing expires_at on each successful read (sliding window). + */ +export function getArtifact(id: string): ArtifactRow | null { + const row = getDb() + .prepare( + `SELECT * FROM artifacts WHERE id = ? AND expires_at > datetime('now')` + ) + .get(id) as ArtifactRow | undefined; + + if (!row) return null; + + getDb() + .prepare( + `UPDATE artifacts SET last_viewed_at = datetime('now'), expires_at = datetime('now', ?) WHERE id = ?` + ) + .run(TTL_MODIFIER, id); + + return row; +} + +/** + * Retrieves an artifact by id without extending TTL. + * Used for the API GET endpoint where TTL refresh is separate from the viewer route. + */ +export function getArtifactRaw(id: string): ArtifactRow | null { + return ( + (getDb() + .prepare( + `SELECT * FROM artifacts WHERE id = ? AND expires_at > datetime('now')` + ) + .get(id) as ArtifactRow | undefined) ?? null + ); +} + +/** Refreshes the TTL for an artifact (sliding window). */ +export function refreshArtifactTtl(id: string): void { + getDb() + .prepare( + `UPDATE artifacts SET last_viewed_at = datetime('now'), expires_at = datetime('now', ?) WHERE id = ?` + ) + .run(TTL_MODIFIER, id); +} + +/** Updates the payload of an existing artifact and resets its TTL. */ +export function updateArtifact( + id: string, + payload: string +): ArtifactRow | null { + const existing = getArtifactRaw(id); + if (!existing) return null; + + getDb() + .prepare( + `UPDATE artifacts SET payload = ?, updated_at = datetime('now'), expires_at = datetime('now', ?) WHERE id = ?` + ) + .run(payload, TTL_MODIFIER, id); + + return getArtifactRaw(id); +} + +/** Deletes an artifact by id. Returns true if a row was deleted. */ +export function deleteArtifact(id: string): boolean { + const result = getDb() + .prepare(`DELETE FROM artifacts WHERE id = ?`) + .run(id); + return result.changes > 0; +} + +/** Removes all expired artifacts from the database. Returns the number of rows deleted. */ +export function cleanupExpired(): number { + const result = getDb() + .prepare(`DELETE FROM artifacts WHERE expires_at <= datetime('now')`) + .run(); + return result.changes; +} + +/** Closes the database connection. */ +export function closeDb(): void { + if (db) { + db.close(); + } +} diff --git a/selfhosted/src/server.ts b/selfhosted/src/server.ts new file mode 100644 index 0000000..ae3cc90 --- /dev/null +++ b/selfhosted/src/server.ts @@ -0,0 +1,234 @@ +import express from "express"; +import path from "node:path"; +import fs from "node:fs"; +import { + initDb, + createArtifact, + getArtifact, + getArtifactRaw, + refreshArtifactTtl, + updateArtifact, + deleteArtifact, + cleanupExpired, +} from "./db.js"; + +const PORT = parseInt(process.env.PORT ?? "3001", 10); +const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(import.meta.dirname, "../../out")); +const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +// Maximum payload size: 1 MB +const MAX_PAYLOAD_SIZE = 1_000_000; + +// --- Initialize database --- +initDb(); + +// --- Load the static index.html template --- +const indexHtmlPath = path.join(STATIC_DIR, "index.html"); +if (!fs.existsSync(indexHtmlPath)) { + console.error( + `Static index.html not found at ${indexHtmlPath}.\n` + + `Build the main app first: cd .. && npm run build\n` + + `Or set STATIC_DIR to point to the built output directory.` + ); + process.exit(1); +} +const indexHtmlTemplate = fs.readFileSync(indexHtmlPath, "utf-8"); + +/** + * Injects the artifact payload into the static index.html template. + * Uses a JSON script tag for safe embedding without XSS risk. + */ +function renderViewerPage(payload: string, artifactId: string): string { + const safePayload = JSON.stringify(payload) + .replace(/<\//g, "<\\/") + .replace(/