From 36c476cff10061f2ae19ac26a8b3c63faaa74841 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Mar 2026 17:11:50 +0000 Subject: [PATCH 1/2] feat: optional self-hosted UUID server with SQLite - Add Node selfhosted server (CRUD API, sliding 24h TTL, static + /{uuid} shell) - Reuse viewer: fetch payload, decode with optional fragment-length bypass - Gate UUID client behavior on NEXT_PUBLIC_SELFHOSTED_SERVER=1 for static safety - Document deployment, add selfhosted-agent-render skill, tests for store and path Co-authored-by: Aanish Bhirud --- .gitignore | 3 + AGENTS.md | 31 +- README.md | 19 +- docs/architecture.md | 7 +- docs/dependency-notes.md | 2 + docs/deployment.md | 71 +++ docs/payload-format.md | 6 +- docs/testing.md | 3 + package-lock.json | 423 +++++++++++++++++- package.json | 5 +- selfhosted/Dockerfile | 24 + selfhosted/README.md | 10 + selfhosted/artifact-db.mjs | 163 +++++++ selfhosted/cleanup.mjs | 13 + selfhosted/docker-compose.yml | 17 + selfhosted/server.mjs | 274 ++++++++++++ skills/agent-render-linking/SKILL.md | 2 + skills/selfhosted-agent-render/SKILL.md | 101 +++++ src/components/viewer-shell.tsx | 250 ++++++++++- .../viewer/fragment-details-disclosure.tsx | 33 +- src/lib/payload/fragment.ts | 33 +- src/lib/selfhosted/artifact-path.ts | 36 ++ tests/artifact-path.test.ts | 20 + tests/fragment.test.ts | 39 ++ tests/selfhosted-artifact-db.test.ts | 109 +++++ 25 files changed, 1642 insertions(+), 52 deletions(-) create mode 100644 selfhosted/Dockerfile create mode 100644 selfhosted/README.md create mode 100644 selfhosted/artifact-db.mjs create mode 100644 selfhosted/cleanup.mjs create mode 100644 selfhosted/docker-compose.yml create mode 100644 selfhosted/server.mjs create mode 100644 skills/selfhosted-agent-render/SKILL.md create mode 100644 src/lib/selfhosted/artifact-path.ts create mode 100644 tests/artifact-path.test.ts create mode 100644 tests/selfhosted-artifact-db.test.ts diff --git a/.gitignore b/.gitignore index 6dec77b..d6d115f 100644 --- a/.gitignore +++ b/.gitignore @@ -132,6 +132,9 @@ dist playwright-report/ test-results/ +# Self-hosted SQLite (local runs) +/data/ + # yarn v2 .yarn/cache .yarn/unplugged diff --git a/AGENTS.md b/AGENTS.md index fb7fd50..35e0da7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,11 +17,14 @@ Core product traits right now: - self-hostable - static-export friendly - fragment-based transport so artifact contents stay out of the request URL and off the server request path +- optional **separate** self-hosted Node + SQLite server for UUID links (same viewer bundle with `NEXT_PUBLIC_SELFHOSTED_SERVER=1`; see `docs/deployment.md`) ## Product contract Treat these as core constraints unless the owner explicitly changes the product direction. +### Default static export (primary product) + - The app is a single exported client-side shell, not a backend product. - Artifact payloads live in the URL fragment, using `#agent-render=v1..` for `plain|lz|deflate`, and `#agent-render=v1.arx..` for `arx`. - The deployed host should not receive artifact contents as part of the initial page request. @@ -30,13 +33,20 @@ Treat these as core constraints unless the owner explicitly changes the product - The product is zero-retention by host design, not secret-safe in an absolute sense. - Links may still leak through browser history, copied URLs, screenshots, and any future client-side analytics. -Do not casually introduce: +Do not casually introduce into the **default static path**: - server persistence - databases - auth requirements for the core viewing path - request-body upload flows for the main sharing workflow - normal query-param transport for artifact contents +### Optional self-hosted UUID mode (explicit add-on) + +- Ships as `selfhosted/server.mjs` + SQLite; stores the canonical `agent-render=v1...` string keyed by UUID v4; sliding 24h TTL on successful `GET /api/artifacts/:id`. +- Build the static bundle with `NEXT_PUBLIC_SELFHOSTED_SERVER=1` so `/{uuid}` routes fetch from the API; leave unset for normal static deployments. +- Same artifact kinds, codecs, decode, and renderers as the fragment product; no new envelope schema. +- Auth is perimeter-only; document Cloudflare Tunnel / Zero Trust as optional hardening, not requirements. + ## Current shipped behavior Describe and preserve what is already true in the repo today. @@ -102,7 +112,7 @@ If you change the payload contract, update the code, docs, examples, and the Ope ## Key files ### App shell and UI -- `src/components/viewer-shell.tsx` - main shell, fragment-driven state, empty state, artifact-stage layout +- `src/components/viewer-shell.tsx` - main shell, fragment-driven state, optional UUID fetch path, empty state, artifact-stage layout - `src/components/viewer/artifact-selector.tsx` - bundle artifact switching UI - `src/components/viewer/fragment-details-disclosure.tsx` - fragment inspector/status disclosure - `src/components/home/link-creator.tsx` - browser-side link creation UX @@ -114,9 +124,15 @@ If you change the payload contract, update the code, docs, examples, and the Ope - `src/components/renderers/csv-renderer.tsx` - `src/components/renderers/json-renderer.tsx` +### Optional self-hosted server +- `selfhosted/server.mjs` - static file + API + `/{uuid}` shell routing +- `selfhosted/artifact-db.mjs` - SQLite schema, sliding TTL, CRUD +- `selfhosted/cleanup.mjs` - batch expiry purge +- `src/lib/selfhosted/artifact-path.ts` - UUID path parsing helper for the client + ### Payload and protocol - `src/lib/payload/schema.ts` - type surface, limits, fragment key, supported kinds/codecs -- `src/lib/payload/fragment.ts` - encode/decode logic and transport behavior +- `src/lib/payload/fragment.ts` - encode/decode logic and transport behavior (optional stored-mode decode skips fragment wire budget) - `src/lib/payload/arx-codec.ts` - arx codec: domain dictionary + brotli + base76/base1k/baseBMP encoding - `public/arx-dictionary.json` - shared substitution dictionary for the arx codec (served as a static endpoint) - `public/arx-dictionary.json.br` - pre-compressed brotli variant of the dictionary @@ -136,6 +152,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 @@ -160,6 +177,13 @@ NEXT_PUBLIC_BASE_PATH=/agent-render npm run build npm run preview ``` +Optional self-hosted server (after `NEXT_PUBLIC_SELFHOSTED_SERVER=1 npm run build`): + +```bash +npm run selfhosted:start +npm run selfhosted:cleanup +``` + Validation: ```bash @@ -217,6 +241,7 @@ At minimum, verify alignment across: - `docs/dependency-notes.md` - `docs/testing.md` - `skills/agent-render-linking/SKILL.md` +- `skills/selfhosted-agent-render/SKILL.md` ## Default contributor stance diff --git a/README.md b/README.md index 16bc017..5da4a00 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # agent-render -`agent-render` is a fully static, zero-retention artifact viewer for AI-generated outputs. +`agent-render` is a fully static, zero-retention artifact viewer for AI-generated outputs (with an **optional** self-hosted UUID mode for agents who need server-backed links). -Built for the OpenClaw ecosystem, `agent-render` focuses on fragment-based sharing for markdown, code, diffs, CSV, and JSON so the payload stays in the browser URL fragment instead of being sent to a server. +Built for the OpenClaw ecosystem, `agent-render` focuses on fragment-based sharing for markdown, code, diffs, CSV, and JSON so the payload stays in the browser URL fragment instead of being sent to a server on the default static path. ## OpenClaw @@ -30,9 +30,10 @@ 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 -- Fragment-based payloads (`#...`) so the server never receives artifact contents +- Fully static export with Next.js App Router for the **default** product +- No backend, no database, and no server-side persistence on the **static** path +- Fragment-based payloads (`#...`) so the static host never receives artifact contents during the page request +- Optional **self-hosted** UUID + SQLite mode for agents (separate Node server in `selfhosted/`; see `docs/deployment.md`) - Public-safe naming and MIT-compatible dependencies ## Local Development @@ -53,6 +54,10 @@ npm run preview Set `NEXT_PUBLIC_BASE_PATH` before `npm run build` when you want to preview a subpath deployment locally. +## Optional self-hosted mode (UUID links) + +Power users can run a small Node server that stores the same `agent-render=v1...` payload string in SQLite and opens it from `https://your-host/{uuid}/`. This **does not** replace the static fragment product: build with `NEXT_PUBLIC_SELFHOSTED_SERVER=1`, then `npm run selfhosted:start` after `npm run build`. Full notes live in `docs/deployment.md` and `skills/selfhosted-agent-render/SKILL.md`. + ## Contributing - Public exported functions/components in `src/lib/**` and `src/components/**` must have a preceding `/** ... */` JSDoc block. @@ -85,9 +90,9 @@ The shell keeps first load lean and defers renderer-heavy code until needed. The ## Zero Retention -The project keeps artifact contents in the URL fragment so the static host does not receive the payload during the page request. This improves privacy for shared artifacts, but the link still lives in browser history, copied URLs, and any client-side telemetry you add later. +On the **default static deployment**, artifact contents stay in the URL fragment so the static host does not receive the payload during the page request. This improves privacy for shared artifacts, but the link still lives in browser history, copied URLs, and any client-side telemetry you add later. -`Zero Data Retention by design` means the deployed static host does not receive artifact contents as part of the request. It does not mean the data disappears from places like browser history, copied links, screenshots, or any client-side analytics you may add later. +`Zero Data Retention by design` in the static UI refers to that static-host boundary. The **optional self-hosted** server intentionally stores payloads in SQLite with a sliding TTL; treat that as a different deployment contract (see `docs/deployment.md`). ## License diff --git a/docs/architecture.md b/docs/architecture.md index 7f73cbf..6ac65be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,8 +4,9 @@ `agent-render` is a single exported client-side shell built with Next.js 15, React 19, and Tailwind CSS 4. -- The application ships as static files only. -- All artifact data lives in the URL fragment. +- The default product ships as static files only. +- In the default product, artifact data lives in the URL fragment. +- An **optional** self-hosted Node server (see `docs/deployment.md`) can store the same canonical payload string in SQLite and serve `GET /{uuid}` with the **same** viewer bundle; this is off by default and requires `NEXT_PUBLIC_SELFHOSTED_SERVER=1` at build time. - The app renders one viewer shell and selects a renderer based on the artifact kind. - Renderers stay modular so they can evolve independently without coupling to Next.js routing. @@ -92,7 +93,7 @@ The fragment protocol keeps the JSON envelope stable and treats compression stri - packed wire mode (`p: 1`) shortens transport keys before compression, then unpacks back to the standard envelope during decode - automatic async codec selection tries `arx -> deflate -> lz -> plain` and compares packed + non-packed candidates - sync codec selection (used by examples and legacy paths) tries `deflate -> lz -> plain` -- decode enforces both fragment length and decoded payload size ceilings before UI rendering +- decode enforces the fragment wire budget and decoded payload size ceilings before UI rendering on the static path; optional server-stored payloads may skip the wire budget while keeping the decoded ceiling - invalid bundle state is normalized or rejected before renderers mount ## Zero-retention boundaries diff --git a/docs/dependency-notes.md b/docs/dependency-notes.md index 42e8bb5..aa8c0a9 100644 --- a/docs/dependency-notes.md +++ b/docs/dependency-notes.md @@ -15,6 +15,7 @@ - `@tanstack/react-table` - MIT - `lz-string` - MIT - `fflate` - MIT +- `better-sqlite3` - MIT (optional; used only by the self-hosted Node server under `selfhosted/`) ## Notes @@ -30,6 +31,7 @@ - `@git-diff-view/*` fits review-style diffs better than a generic merge editor for the current viewer. - `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. +- `better-sqlite3` keeps the optional self-hosted server simple with a local SQLite file and synchronous reads for the CRUD API. ## Notable removals diff --git a/docs/deployment.md b/docs/deployment.md index c7221b7..a14f520 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -45,3 +45,74 @@ 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. + +## Optional self-hosted mode (UUID links + SQLite) + +This is a **separate deployment shape** from static fragment hosting. It targets agents and operators who accept a Node runtime, a SQLite file on disk, and **server-side retention** with a **24-hour sliding TTL** (each successful `GET /api/artifacts/:id` extends expiry by another 24h). It is **not** the default product and does not replace Cloudflare Pages–style static hosting. + +### How it differs from the static product + +| | Static (default) | Self-hosted (optional) | +| --- | --- | --- | +| Runtime | Static files only | Node.js HTTP server | +| Payload location | URL fragment | SQLite keyed by UUID v4 | +| Host sees payload | No (fragment not sent) | Yes (stored server-side) | +| Viewer UI | This repo’s shell | Same shell + build flag | +| Auth | None required | None built-in; use perimeter controls | + +### Build the client bundle for UUID routes + +The static export must include the self-hosted client switch: + +```bash +NEXT_PUBLIC_SELFHOSTED_SERVER=1 npm run build +``` + +Omit this variable for normal static deployments (including `agent-render.com`). When the flag is absent, `/{uuid}` paths are not treated as artifact routes in the client. + +### Run the server + +From the repository root (after `npm run build`): + +```bash +npm run selfhosted:start +``` + +Environment variables: + +- `PORT` — listen port (default `3000`) +- `DATABASE_PATH` — SQLite file path (default `./data/artifacts.sqlite`) +- `STATIC_ROOT` — directory containing the static export (default `./out`) +- `NEXT_PUBLIC_BASE_PATH` — must match how the static assets were built (same as static deployment) + +### HTTP API + +All routes respect `NEXT_PUBLIC_BASE_PATH` when set. + +- `POST /api/artifacts` — body `{ "payload": "agent-render=v1...." }` → `201` with `id`, `createdAt`, `expiresAt` +- `GET /api/artifacts/:id` — JSON metadata plus `payload`; refreshes sliding TTL +- `PUT /api/artifacts/:id` — replace `payload`, refresh TTL +- `DELETE /api/artifacts/:id` — remove row +- `GET /{uuid}` — serves `index.html` so the client can fetch the artifact API + +### TTL and cleanup + +- Expired artifacts return `404` with `{ "error": "expired" }` and are removed on read. +- Run `npm run selfhosted:cleanup` on a schedule to delete expired rows in batch. +- Operators can ask an agent to delete specific IDs or vacuum old data. + +### Docker Compose + +```bash +docker compose -f selfhosted/docker-compose.yml up --build +``` + +The image runs `npm run build` with `NEXT_PUBLIC_SELFHOSTED_SERVER=1` unless you override build args. + +### Perimeter protection (practical, neutral) + +Bind to loopback, use a private network, terminate TLS and auth at a reverse proxy, or place the service behind **Cloudflare Tunnel** with optional **Zero Trust** access policies. Public exposure is possible if you deliberately choose it; document retention and TTL for your users. + +### Same-machine / agent co-location + +A common pattern is to run the server on `127.0.0.1` next to the agent process so share URLs stay on localhost without exposing the SQLite file to the internet. diff --git a/docs/payload-format.md b/docs/payload-format.md index 9160a63..cda419b 100644 --- a/docs/payload-format.md +++ b/docs/payload-format.md @@ -2,7 +2,11 @@ ## Goals -The project uses a fragment-based payload so the raw artifact content stays in the browser and is not sent to the server during the request. +The default product uses a fragment-based payload so the raw artifact content stays in the browser and is not sent to the server during the request. + +## Optional self-hosted UUID transport + +An **optional** deployment (see `docs/deployment.md`) can store the exact same `agent-render=v1...` wire string in SQLite and load it by UUID. That path bypasses the **fragment wire size budget** while decoding, but the **decoded JSON budget** (`MAX_DECODED_PAYLOAD_LENGTH`, currently `200000` characters) still applies in the viewer. This is intended for power users and agents who accept server retention with a sliding TTL. ## Fragment shape diff --git a/docs/testing.md b/docs/testing.md index 19fcab0..822472c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,8 +15,11 @@ npm run test:e2e npm run test:e2e:update npm run test:browsers npm run test:ci +npm run selfhosted:cleanup ``` +Self-hosted integration is covered by `tests/selfhosted-artifact-db.test.ts` (SQLite store) and fragment decode tests for stored-mode wire lengths. End-to-end Playwright suites target the **static** export only (no `NEXT_PUBLIC_SELFHOSTED_SERVER` flag). + ## Browser install Before running Playwright locally for the first time: diff --git a/package-lock.json b/package-lock.json index 71db50b..ed16d38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@git-diff-view/react": "^0.1.1", "@replit/codemirror-indentation-markers": "^6.5.3", "@tanstack/react-table": "^8.21.3", + "better-sqlite3": "^11.10.0", "brotli-wasm": "^3.0.1", "clsx": "^2.1.1", "fflate": "^0.8.2", @@ -3931,6 +3932,37 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -3941,6 +3973,26 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3974,6 +4026,30 @@ "node": ">=v18.0.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -4142,6 +4218,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4395,6 +4477,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4451,7 +4557,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -4522,6 +4627,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -5239,6 +5353,15 @@ "node": ">=0.10.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -5341,6 +5464,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5408,6 +5537,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5544,6 +5679,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -5816,6 +5957,26 @@ "node": ">= 14" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5863,6 +6024,18 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -7809,6 +7982,18 @@ "node": ">=8.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -7836,12 +8021,17 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7866,6 +8056,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -7981,6 +8177,18 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -8144,6 +8352,15 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8408,6 +8625,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8478,6 +8722,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8509,6 +8763,30 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -8578,6 +8856,20 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -8867,6 +9159,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8925,7 +9237,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9129,6 +9440,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -9201,6 +9557,15 @@ "node": ">=10.0.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -9475,6 +9840,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9661,6 +10054,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9943,6 +10348,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -10354,6 +10765,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index eef86b3..6666057 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "codec:poc": "node scripts/codec-poc.mjs", "typecheck": "node scripts/ensure-next-types.mjs && tsc --noEmit", "check": "npm run lint && npm run test && npm run typecheck && npm run build", - "check:public-export-docs": "node scripts/check-public-export-docs.mjs" + "check:public-export-docs": "node scripts/check-public-export-docs.mjs", + "selfhosted:start": "node selfhosted/server.mjs", + "selfhosted:cleanup": "node selfhosted/cleanup.mjs" }, "dependencies": { "@codemirror/commands": "^6.10.1", @@ -37,6 +39,7 @@ "@git-diff-view/react": "^0.1.1", "@replit/codemirror-indentation-markers": "^6.5.3", "@tanstack/react-table": "^8.21.3", + "better-sqlite3": "^11.10.0", "brotli-wasm": "^3.0.1", "clsx": "^2.1.1", "fflate": "^0.8.2", diff --git a/selfhosted/Dockerfile b/selfhosted/Dockerfile new file mode 100644 index 0000000..a01223b --- /dev/null +++ b/selfhosted/Dockerfile @@ -0,0 +1,24 @@ +FROM node:22-bookworm-slim + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +ARG NEXT_PUBLIC_BASE_PATH= +ARG NEXT_PUBLIC_SELFHOSTED_SERVER=1 +ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH +ENV NEXT_PUBLIC_SELFHOSTED_SERVER=$NEXT_PUBLIC_SELFHOSTED_SERVER + +COPY . . +RUN npm run build + +ENV NODE_ENV=production +ENV PORT=3000 +ENV DATABASE_PATH=/data/artifacts.sqlite +ENV STATIC_ROOT=/app/out + +VOLUME ["/data"] +EXPOSE 3000 + +CMD ["node", "selfhosted/server.mjs"] diff --git a/selfhosted/README.md b/selfhosted/README.md new file mode 100644 index 0000000..2016c64 --- /dev/null +++ b/selfhosted/README.md @@ -0,0 +1,10 @@ +# Self-hosted server + +This folder contains the optional **Node + SQLite** server that stores canonical `agent-render=v1...` payload strings behind UUID links. + +- Entry: `server.mjs` +- Store: `artifact-db.mjs` +- Expiry sweeper: `cleanup.mjs` +- Container: `Dockerfile` and `docker-compose.yml` + +See `docs/deployment.md` for build flags, environment variables, API semantics, and security notes. For agent-oriented workflows, see `skills/selfhosted-agent-render/SKILL.md`. diff --git a/selfhosted/artifact-db.mjs b/selfhosted/artifact-db.mjs new file mode 100644 index 0000000..fd02000 --- /dev/null +++ b/selfhosted/artifact-db.mjs @@ -0,0 +1,163 @@ +import Database from "better-sqlite3"; +import { randomUUID } from "node:crypto"; + +export const PAYLOAD_FRAGMENT_KEY = "agent-render"; +export const TTL_MS = 24 * 60 * 60 * 1000; +/** Stored wire string cap (characters), separate from the static fragment budget. */ +export const MAX_STORED_WIRE_LENGTH = 5_000_000; + +function assertValidPayloadString(payload) { + if (typeof payload !== "string") { + throw new TypeError("payload must be a string"); + } + + if (payload.length === 0 || payload.length > MAX_STORED_WIRE_LENGTH) { + throw new RangeError(`payload length must be between 1 and ${MAX_STORED_WIRE_LENGTH}`); + } + + if (!payload.startsWith(`${PAYLOAD_FRAGMENT_KEY}=v1.`)) { + throw new Error(`payload must start with "${PAYLOAD_FRAGMENT_KEY}=v1."`); + } +} + +function migrate(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS artifacts ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_viewed_at INTEGER, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_artifacts_expires_at ON artifacts (expires_at); + `); +} + +/** + * Opens the SQLite backing store and returns CRUD helpers used by the self-hosted HTTP server. + * + * @param {string} [databasePath=":memory:"] - Path on disk or ":memory:" for tests. + * @returns {{ db: import("better-sqlite3").Database, createArtifact: Function, getArtifact: Function, updateArtifact: Function, deleteArtifact: Function, purgeExpired: Function }} + */ +export function openArtifactStore(databasePath = ":memory:") { + const db = new Database(databasePath); + db.pragma("journal_mode = WAL"); + migrate(db); + + const insert = db.prepare(` + INSERT INTO artifacts (id, payload, created_at, updated_at, last_viewed_at, expires_at) + VALUES (@id, @payload, @created_at, @updated_at, @last_viewed_at, @expires_at) + `); + + const selectById = db.prepare(`SELECT * FROM artifacts WHERE id = ?`); + + const touchView = db.prepare(` + UPDATE artifacts + SET last_viewed_at = @now, expires_at = @expires_at, updated_at = @now + WHERE id = @id + `); + + const updatePayload = db.prepare(` + UPDATE artifacts + SET payload = @payload, updated_at = @now, expires_at = @expires_at + WHERE id = @id + `); + + const deleteById = db.prepare(`DELETE FROM artifacts WHERE id = ?`); + + const deleteExpired = db.prepare(`DELETE FROM artifacts WHERE expires_at <= ?`); + + return { + db, + + /** + * @param {string} payload + * @returns {{ id: string, createdAt: number, expiresAt: number }} + */ + createArtifact(payload) { + assertValidPayloadString(payload); + const id = randomUUID(); + const now = Date.now(); + const expiresAt = now + TTL_MS; + + insert.run({ + id, + payload, + created_at: now, + updated_at: now, + last_viewed_at: null, + expires_at: expiresAt, + }); + + return { id, createdAt: now, expiresAt }; + }, + + /** + * Returns artifact row after sliding TTL refresh, or a reason string. + * + * @param {string} id + * @returns {{ ok: true, row: object } | { ok: false, reason: "not_found" | "expired" }} + */ + getArtifact(id) { + const now = Date.now(); + const row = selectById.get(id); + + if (!row) { + return { ok: false, reason: "not_found" }; + } + + if (row.expires_at <= now) { + deleteById.run(id); + return { ok: false, reason: "expired" }; + } + + const nextExpires = now + TTL_MS; + touchView.run({ id, now, expires_at: nextExpires }); + + const refreshed = selectById.get(id); + return { ok: true, row: refreshed }; + }, + + /** + * @param {string} id + * @param {string} payload + * @returns {{ ok: true, expiresAt: number } | { ok: false, reason: "not_found" | "expired" }} + */ + updateArtifact(id, payload) { + assertValidPayloadString(payload); + const now = Date.now(); + const row = selectById.get(id); + + if (!row) { + return { ok: false, reason: "not_found" }; + } + + if (row.expires_at <= now) { + deleteById.run(id); + return { ok: false, reason: "expired" }; + } + + const expiresAt = now + TTL_MS; + updatePayload.run({ id, payload, now, expires_at: expiresAt }); + + return { ok: true, expiresAt }; + }, + + /** + * @param {string} id + * @returns {boolean} + */ + deleteArtifact(id) { + const result = deleteById.run(id); + return result.changes > 0; + }, + + /** Deletes all rows at or past expiry. @returns {number} rows removed */ + purgeExpired() { + const now = Date.now(); + const result = deleteExpired.run(now); + return result.changes; + }, + }; +} diff --git a/selfhosted/cleanup.mjs b/selfhosted/cleanup.mjs new file mode 100644 index 0000000..9708182 --- /dev/null +++ b/selfhosted/cleanup.mjs @@ -0,0 +1,13 @@ +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { openArtifactStore } from "./artifact-db.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const databasePath = process.env.DATABASE_PATH || path.join(repoRoot, "data", "artifacts.sqlite"); + +const store = openArtifactStore(databasePath); +const removed = store.purgeExpired(); +console.log(`Purged ${removed} expired artifact(s).`); +store.db.close(); diff --git a/selfhosted/docker-compose.yml b/selfhosted/docker-compose.yml new file mode 100644 index 0000000..533c5e2 --- /dev/null +++ b/selfhosted/docker-compose.yml @@ -0,0 +1,17 @@ +services: + agent-render-selfhosted: + build: + context: .. + dockerfile: selfhosted/Dockerfile + args: + NEXT_PUBLIC_SELFHOSTED_SERVER: "1" + ports: + - "3000:3000" + environment: + PORT: "3000" + DATABASE_PATH: /data/artifacts.sqlite + volumes: + - agent-render-data:/data + +volumes: + agent-render-data: diff --git a/selfhosted/server.mjs b/selfhosted/server.mjs new file mode 100644 index 0000000..a54f2ac --- /dev/null +++ b/selfhosted/server.mjs @@ -0,0 +1,274 @@ +import { createServer } from "node:http"; +import { existsSync, createReadStream } from "node:fs"; +import { mkdirSync } from "node:fs"; +import { stat } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { openArtifactStore } from "./artifact-db.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); + +const port = Number(process.env.PORT || 3000); +const configuredBasePath = (process.env.NEXT_PUBLIC_BASE_PATH || "").trim(); +const basePath = configuredBasePath === "/" ? "" : configuredBasePath.replace(/\/$/, ""); +const staticRoot = path.resolve(process.env.STATIC_ROOT || path.join(repoRoot, "out")); +const databasePath = process.env.DATABASE_PATH || path.join(repoRoot, "data", "artifacts.sqlite"); + +const UUID_SEGMENT = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +if (!existsSync(staticRoot)) { + console.error(`Missing static root at ${staticRoot}. Run npm run build before starting the self-hosted server.`); + process.exit(1); +} + +const dataDir = path.dirname(databasePath); +if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }); +} + +const store = openArtifactStore(databasePath); + +const contentTypes = new Map([ + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".css", "text/css; charset=utf-8"], + [".svg", "image/svg+xml"], + [".json", "application/json; charset=utf-8"], + [".txt", "text/plain; charset=utf-8"], + [".png", "image/png"], + [".jpg", "image/jpeg"], + [".jpeg", "image/jpeg"], + [".woff", "font/woff"], + [".woff2", "font/woff2"], + [".br", "application/octet-stream"], +]); + +function normalizeRequestPath(url) { + const raw = url.split("?", 1)[0].split("#", 1)[0]; + let requestPath = raw; + + if (basePath) { + if (requestPath === "/" || requestPath === basePath) { + requestPath = `${basePath}/`; + } + + if (!requestPath.startsWith(basePath)) { + return null; + } + + requestPath = requestPath.slice(basePath.length) || "/"; + } + + return requestPath; +} + +function toStaticFilePath(requestPath) { + const normalizedPath = requestPath === "/" ? "/index.html" : requestPath; + const tentativePath = path.join(staticRoot, normalizedPath); + return normalizedPath.endsWith("/") ? path.join(tentativePath, "index.html") : tentativePath; +} + +function sendJson(response, status, body) { + response.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(body)); +} + +function readBody(request) { + return new Promise((resolve, reject) => { + const chunks = []; + request.on("data", (chunk) => { + chunks.push(chunk); + if (chunks.reduce((acc, c) => acc + c.length, 0) > 12_000_000) { + reject(new Error("body too large")); + } + }); + request.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf8")); + }); + request.on("error", reject); + }); +} + +function matchApiPath(requestPath) { + const prefix = "/api/artifacts"; + if (!requestPath.startsWith(prefix)) { + return null; + } + + const rest = requestPath.slice(prefix.length); + if (rest === "" || rest === "/") { + return { kind: "collection" }; + } + + const trimmed = rest.replace(/^\/+/, ""); + if (!trimmed) { + return null; + } + + const id = trimmed.split("/")[0]; + return { kind: "item", id }; +} + +function isUuidArtifactPath(requestPath) { + const trimmed = requestPath.replace(/\/+$/, ""); + const segment = trimmed.split("/").filter(Boolean).pop() ?? ""; + return UUID_SEGMENT.test(segment); +} + +const server = createServer(async (request, response) => { + try { + const requestPath = normalizeRequestPath(request.url || "/"); + + if (!requestPath) { + response.writeHead(404); + response.end("Not found"); + return; + } + + const api = matchApiPath(requestPath); + + if (api?.kind === "collection" && request.method === "POST") { + const raw = await readBody(request); + let body; + try { + body = JSON.parse(raw || "{}"); + } catch { + sendJson(response, 400, { error: "invalid_json" }); + return; + } + + const payload = body.payload; + if (typeof payload !== "string") { + sendJson(response, 400, { error: "invalid_payload", message: "Expected a string `payload` field." }); + return; + } + + try { + const created = store.createArtifact(payload); + sendJson(response, 201, { + id: created.id, + createdAt: new Date(created.createdAt).toISOString(), + expiresAt: new Date(created.expiresAt).toISOString(), + }); + } catch (error) { + sendJson(response, 400, { error: "invalid_payload", message: String(error.message || error) }); + } + return; + } + + if (api?.kind === "item" && api.id) { + if (request.method === "GET") { + const result = store.getArtifact(api.id); + if (!result.ok) { + sendJson(response, 404, { error: result.reason }); + return; + } + + const row = result.row; + sendJson(response, 200, { + id: row.id, + payload: row.payload, + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + lastViewedAt: row.last_viewed_at ? new Date(row.last_viewed_at).toISOString() : null, + expiresAt: new Date(row.expires_at).toISOString(), + }); + return; + } + + if (request.method === "PUT") { + const raw = await readBody(request); + let body; + try { + body = JSON.parse(raw || "{}"); + } catch { + sendJson(response, 400, { error: "invalid_json" }); + return; + } + + if (typeof body.payload !== "string") { + sendJson(response, 400, { error: "invalid_payload", message: "Expected a string `payload` field." }); + return; + } + + try { + const updated = store.updateArtifact(api.id, body.payload); + if (!updated.ok) { + sendJson(response, 404, { error: updated.reason }); + return; + } + + sendJson(response, 200, { id: api.id, expiresAt: new Date(updated.expiresAt).toISOString() }); + } catch (error) { + sendJson(response, 400, { error: "invalid_payload", message: String(error.message || error) }); + } + return; + } + + if (request.method === "DELETE") { + const removed = store.deleteArtifact(api.id); + if (!removed) { + sendJson(response, 404, { error: "not_found" }); + return; + } + + sendJson(response, 200, { ok: true }); + return; + } + + response.writeHead(405, { Allow: "GET, PUT, DELETE" }); + response.end("Method Not Allowed"); + return; + } + + if (request.method === "GET" && isUuidArtifactPath(requestPath)) { + const indexFile = path.join(staticRoot, "index.html"); + if (!existsSync(indexFile)) { + response.writeHead(500); + response.end("Missing index.html export"); + return; + } + + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + createReadStream(indexFile).pipe(response); + return; + } + + let finalPath = toStaticFilePath(requestPath); + + try { + const details = await stat(finalPath); + if (details.isDirectory()) { + finalPath = path.join(finalPath, "index.html"); + } + } catch { + if (!path.extname(finalPath)) { + finalPath = path.join(finalPath, "index.html"); + } + } + + if (!existsSync(finalPath)) { + response.writeHead(404); + response.end("Not found"); + return; + } + + const contentType = contentTypes.get(path.extname(finalPath)) || "application/octet-stream"; + response.writeHead(200, { "Content-Type": contentType }); + createReadStream(finalPath).pipe(response); + } catch (error) { + console.error(error); + response.writeHead(500); + response.end("Internal Server Error"); + } +}); + +server.listen(port, () => { + const suffix = basePath ? `${basePath}/` : "/"; + console.log(`Self-hosted agent-render at http://127.0.0.1:${port}${suffix}`); + console.log(`SQLite database: ${databasePath}`); + console.log(`Static root: ${staticRoot}`); +}); diff --git a/skills/agent-render-linking/SKILL.md b/skills/agent-render-linking/SKILL.md index 03eae58..75410b2 100644 --- a/skills/agent-render-linking/SKILL.md +++ b/skills/agent-render-linking/SKILL.md @@ -7,6 +7,8 @@ description: Create zero-retention agent-render.com links for markdown, code, di Create browser links for artifacts rendered by `agent-render.com`. +If fragment links are too large or unstable for a chat surface, operators may run the **optional self-hosted** deployment (UUID + SQLite) documented in `skills/selfhosted-agent-render/SKILL.md`. The wire format stays the same; only transport changes. + ## Project context Agent Render is: diff --git a/skills/selfhosted-agent-render/SKILL.md b/skills/selfhosted-agent-render/SKILL.md new file mode 100644 index 0000000..369cc76 --- /dev/null +++ b/skills/selfhosted-agent-render/SKILL.md @@ -0,0 +1,101 @@ +--- +name: selfhosted-agent-render +description: Run and use the optional self-hosted agent-render server that stores canonical fragment-shaped payloads in SQLite behind UUID URLs. Use when fragment links are too large, mangled by chat platforms, or when the same machine as the agent should hold artifacts. Covers Docker Compose, daemon-style processes, CRUD API, sliding TTL, perimeter auth, and Cloudflare Tunnel / Zero Trust as optional hardening. +--- + +# Self-hosted agent-render (UUID + SQLite) + +## When to use this instead of fragment links + +Prefer the **static fragment product** (`skills/agent-render-linking`) when: + +- the payload fits the fragment budget and survives your chat surface +- you want the default zero server-retention static deployment story + +Use **self-hosted UUID mode** when: + +- payloads are large or you want to skip fragment length pressure entirely +- chat platforms rewrite, truncate, or break long `#agent-render=...` links +- the agent and viewer can share a private network or the same host +- you accept **server-side retention** (SQLite file) with a **24h sliding TTL** + +The stored string is still the normal `agent-render=v1..` body (no new artifact schema). The viewer shell is the same; only transport changes. + +## Build-time switch + +The static export must be built with: + +```bash +NEXT_PUBLIC_SELFHOSTED_SERVER=1 npm run build +``` + +Without this, the client will not treat `/{uuid}` paths as server-backed artifacts (keeps the default static-only behavior safe on public hosts). + +## Deploy + +### Docker Compose (from repo root) + +```bash +NEXT_PUBLIC_SELFHOSTED_SERVER=1 npm run build +docker compose -f selfhosted/docker-compose.yml build +docker compose -f selfhosted/docker-compose.yml up +``` + +Adjust build args in `selfhosted/Dockerfile` if you need `NEXT_PUBLIC_BASE_PATH`. + +### Daemon-style (pm2, systemd, etc.) + +1. `NEXT_PUBLIC_SELFHOSTED_SERVER=1 npm run build` +2. Run `npm run selfhosted:start` with working directory at the repo root. +3. Set `DATABASE_PATH` if you want the SQLite file outside the default `./data/artifacts.sqlite`. +4. Set `STATIC_ROOT` if `out/` lives elsewhere. +5. Set `PORT` and `NEXT_PUBLIC_BASE_PATH` to match how users reach the app. + +## API (same origin as the viewer) + +Base path mirrors `NEXT_PUBLIC_BASE_PATH` (empty at domain root). + +- `POST /api/artifacts` — JSON `{ "payload": "" }` → `{ id, createdAt, expiresAt }` +- `GET /api/artifacts/:id` — returns `{ id, payload, expiresAt, ... }` and **extends** `expiresAt` by 24h on success +- `PUT /api/artifacts/:id` — JSON `{ "payload": "..." }` replaces payload and resets sliding window +- `DELETE /api/artifacts/:id` — removes the row + +Share viewer links: `https://your-host//` (trailing slash matches the static export layout). + +## TTL semantics + +- Rows expire `24h` after the last **successful** `GET /api/artifacts/:id` (or after create/update refresh). +- In deployments that terminate TLS or auth **in front** of Node, only requests that reach the app count—configure your proxy so authorized users trigger successful GETs. +- Expired rows behave like missing (`404` with `{ "error": "expired" }`). + +## Cleanup + +- Lazy deletion happens on read for expired rows. +- Run `npm run selfhosted:cleanup` (cron-friendly) to purge expired IDs in batch. +- Agents or operators can also `DELETE` known IDs or run SQL maintenance against the SQLite file. + +## Perimeter protection (recommended, not built-in) + +The server does **not** ship mandatory auth. Practical patterns: + +- bind to `127.0.0.1` and rely on same-machine agents +- private network + firewall +- reverse proxy with SSO, mTLS, or API tokens +- **Cloudflare Tunnel** to expose privately, optionally **Cloudflare Zero Trust** access policies in front of the tunnel hostname + +Stay neutral: public exposure is possible if you choose it; document the tradeoffs for your team. + +## Agent workflow sketch + +1. Encode a normal envelope to the fragment body string (same helpers as fragment mode). +2. `POST` that string as `payload`. +3. Return `https:////` to the user. +4. Optionally `DELETE` when no longer needed. + +## Local same-machine pattern + +Run the self-hosted server on `127.0.0.1`, point agents at `http://127.0.0.1:PORT`, and keep the SQLite file on disk you control. This avoids exposing artifacts to the public internet while preserving UUID ergonomics. + +## Docs + +See `docs/deployment.md` for full deployment notes and environment variables. diff --git a/src/components/viewer-shell.tsx b/src/components/viewer-shell.tsx index 9e37028..8456f60 100644 --- a/src/components/viewer-shell.tsx +++ b/src/components/viewer-shell.tsx @@ -2,6 +2,7 @@ import dynamic from "next/dynamic"; import Image from "next/image"; +import { usePathname } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import type { LucideIcon } from "lucide-react"; @@ -23,7 +24,9 @@ import { import { sampleEnvelopes, sampleLinks } from "@/lib/payload/examples"; import { decodeFragment, decodeFragmentAsync, encodeEnvelope, encodeEnvelopeAsync } from "@/lib/payload/fragment"; import { loadArxDictionary } from "@/lib/payload/arx-codec"; +import { getArtifactIdFromPathname } from "@/lib/selfhosted/artifact-path"; import { + MAX_DECODED_PAYLOAD_LENGTH, MAX_FRAGMENT_LENGTH, PAYLOAD_FRAGMENT_KEY, artifactKinds, @@ -34,6 +37,7 @@ import { type DiffArtifact, type JsonArtifact, type MarkdownArtifact, + type ParsedPayload, type PayloadEnvelope, } from "@/lib/payload/schema"; import { copyTextToClipboard } from "@/lib/copy-text"; @@ -203,7 +207,7 @@ function getHashPreview(hash: string): string { return `${hash.slice(0, 160)}...${hash.slice(-44)}`; } -function getStatusTone(parsed: ReturnType) { +function getStatusTone(parsed: ParsedPayload) { if (parsed.ok) { return { label: "Decoded", @@ -240,7 +244,24 @@ function getAnimationStyle(delay: number): CSSProperties { * @returns The root React element for the viewer shell UI */ export function ViewerShell() { + const pathname = usePathname() ?? "/"; + const selfHostedServerEnabled = process.env.NEXT_PUBLIC_SELFHOSTED_SERVER === "1"; + const selfHostedArtifactId = useMemo(() => { + if (!selfHostedServerEnabled) { + return null; + } + + return getArtifactIdFromPathname(pathname); + }, [pathname, selfHostedServerEnabled]); + const [hash, setHash] = useState(""); + const [selfHostedPhase, setSelfHostedPhase] = useState<"idle" | "loading" | "ready" | "error">("idle"); + const [selfHostedError, setSelfHostedError] = useState<{ code: string; message: string } | null>(null); + const [storedEnvelope, setStoredEnvelope] = useState(null); + const [storedWireLength, setStoredWireLength] = useState(0); + const [storedWireString, setStoredWireString] = useState(""); + const [pendingStoredWire, setPendingStoredWire] = useState(null); + const [storedExpiresAt, setStoredExpiresAt] = useState(null); const [rendererReady, setRendererReady] = useState(true); const [artifactCopyState, setArtifactCopyState] = useState<"idle" | "copied" | "failed">("idle"); const activeArtifactRef = useRef(null); @@ -266,7 +287,7 @@ export function ViewerShell() { loadArxDictionary().then(() => setDictReady(true)); }, []); - const [parsed, setParsed] = useState>(() => decodeFragment(hash)); + const [parsed, setParsed] = useState(() => decodeFragment(hash)); useEffect(() => { let cancelled = false; @@ -276,8 +297,141 @@ export function ViewerShell() { return () => { cancelled = true; }; }, [hash, dictReady]); - const fragmentLength = hash.startsWith("#") ? hash.length - 1 : hash.length; - const envelope = parsed.ok ? parsed.envelope : null; + useEffect(() => { + if (!selfHostedArtifactId) { + setSelfHostedPhase("idle"); + setSelfHostedError(null); + setStoredEnvelope(null); + setStoredWireLength(0); + setStoredWireString(""); + setPendingStoredWire(null); + setStoredExpiresAt(null); + return; + } + + let cancelled = false; + setSelfHostedPhase("loading"); + setSelfHostedError(null); + setStoredEnvelope(null); + setPendingStoredWire(null); + + const apiBase = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/$/, ""); + const requestUrl = `${apiBase}/api/artifacts/${selfHostedArtifactId}`; + + fetch(requestUrl, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }) + .then(async (response) => { + if (cancelled) { + return; + } + + if (!response.ok) { + let errorCode = "not_found"; + try { + const body = (await response.json()) as { error?: string }; + if (body.error === "expired") { + errorCode = "expired"; + } + } catch { + // ignore malformed error bodies + } + + const message = + errorCode === "expired" + ? "This artifact expired (24h sliding TTL). Create a new link or ask your operator to adjust retention." + : "Artifact not found."; + setSelfHostedError({ code: errorCode, message }); + setSelfHostedPhase("error"); + return; + } + + const body = (await response.json()) as { payload?: string; expiresAt?: string }; + const wire = body.payload; + + if (typeof wire !== "string") { + setSelfHostedError({ code: "invalid_payload", message: "Server response was missing a payload string." }); + setSelfHostedPhase("error"); + return; + } + + setStoredWireLength(wire.length); + setStoredWireString(wire); + setStoredExpiresAt(body.expiresAt ?? null); + setPendingStoredWire(wire); + }) + .catch(() => { + if (cancelled) { + return; + } + + setSelfHostedError({ code: "network", message: "Could not load artifact from this server." }); + setSelfHostedPhase("error"); + }); + + return () => { + cancelled = true; + }; + }, [selfHostedArtifactId]); + + useEffect(() => { + if (!pendingStoredWire || !dictReady) { + return; + } + + let cancelled = false; + + decodeFragmentAsync(`#${pendingStoredWire}`, { enforceFragmentLengthLimit: false }).then((decoded) => { + if (cancelled) { + return; + } + + if (!decoded.ok) { + setSelfHostedError({ code: decoded.code, message: decoded.message }); + setSelfHostedPhase("error"); + setPendingStoredWire(null); + return; + } + + setStoredEnvelope(decoded.envelope); + setSelfHostedPhase("ready"); + setPendingStoredWire(null); + }); + + return () => { + cancelled = true; + }; + }, [dictReady, pendingStoredWire]); + + const viewerParsed: ParsedPayload = useMemo(() => { + if (!selfHostedArtifactId) { + return parsed; + } + + if (selfHostedPhase === "loading") { + return { ok: false, code: "empty", message: "Loading stored artifact…" }; + } + + if (selfHostedPhase === "error" && selfHostedError) { + return { ok: false, code: "invalid-envelope", message: selfHostedError.message }; + } + + if (selfHostedPhase === "ready" && storedEnvelope) { + return { ok: true, envelope: storedEnvelope, rawLength: storedWireLength }; + } + + return { ok: false, code: "empty", message: "Loading stored artifact…" }; + }, [parsed, selfHostedArtifactId, selfHostedError, selfHostedPhase, storedEnvelope, storedWireLength]); + + const fragmentLength = + selfHostedArtifactId && selfHostedPhase === "ready" + ? storedWireLength + : hash.startsWith("#") + ? hash.length - 1 + : hash.length; + const envelope = + selfHostedArtifactId && selfHostedPhase === "ready" && storedEnvelope ? storedEnvelope : parsed.ok ? parsed.envelope : null; const activeArtifact = envelope ? getActiveArtifact(envelope) : null; activeArtifactRef.current = activeArtifact; const markdownArtifact: MarkdownArtifact | null = activeArtifact?.kind === "markdown" ? activeArtifact : null; @@ -286,9 +440,19 @@ export function ViewerShell() { const csvArtifact: CsvArtifact | null = activeArtifact?.kind === "csv" ? activeArtifact : null; const jsonArtifact: JsonArtifact | null = activeArtifact?.kind === "json" ? activeArtifact : null; const hasKnownRenderer = Boolean(markdownArtifact || codeArtifact || diffArtifact || csvArtifact || jsonArtifact); - const budgetRatio = Math.min(fragmentLength / MAX_FRAGMENT_LENGTH, 1); - const statusTone = getStatusTone(parsed); - const viewerState = activeArtifact && envelope ? "artifact" : parsed.ok ? "decoded-no-artifact" : parsed.code === "empty" ? "empty" : "error"; + const budgetRatio = Math.min( + selfHostedArtifactId && selfHostedPhase === "ready" ? fragmentLength / MAX_DECODED_PAYLOAD_LENGTH : fragmentLength / MAX_FRAGMENT_LENGTH, + 1, + ); + const statusTone = getStatusTone(viewerParsed); + const viewerState = + activeArtifact && envelope + ? "artifact" + : viewerParsed.ok + ? "decoded-no-artifact" + : viewerParsed.code === "empty" + ? "empty" + : "error"; useEffect(() => { if (!activeArtifact) { @@ -337,13 +501,31 @@ export function ViewerShell() { }, []); const handleGoHome = useCallback(() => { + if (selfHostedArtifactId) { + const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/$/, ""); + const homePath = basePath ? `${basePath}/` : "/"; + window.location.assign(homePath); + return; + } + const url = window.location.pathname + (window.location.search || ""); window.history.replaceState(null, "", url); setHash(""); - }, []); + }, [selfHostedArtifactId]); const handleArtifactSelect = useCallback( (artifactId: string) => { + if (selfHostedArtifactId && selfHostedPhase === "ready") { + setStoredEnvelope((previous) => { + if (!previous || previous.activeArtifactId === artifactId) { + return previous; + } + + return { ...previous, activeArtifactId: artifactId }; + }); + return; + } + if (!envelope || envelope.activeArtifactId === artifactId) { return; } @@ -352,7 +534,7 @@ export function ViewerShell() { setFragmentHash(`#${encoded}`); }); }, - [envelope, setFragmentHash], + [envelope, selfHostedArtifactId, selfHostedPhase, setFragmentHash], ); const handleArtifactCopy = useCallback(async () => { @@ -431,6 +613,7 @@ export function ViewerShell() {
- Zero Data Retention by design + + {selfHostedArtifactId ? "Server-stored · sliding TTL" : "Zero Data Retention by design"} +
@@ -470,7 +655,9 @@ export function ViewerShell() { {envelope.artifacts.length} item{envelope.artifacts.length === 1 ? "" : "s"}

- Selecting an artifact updates the active fragment target while keeping the rendered payload front and center. + {selfHostedArtifactId + ? "Selecting an artifact updates the active item in this bundle. The canonical payload stays on the server; UUID links avoid fragment size limits." + : "Selecting an artifact updates the active fragment target while keeping the rendered payload front and center."}

@@ -557,15 +744,42 @@ export function ViewerShell() { + ) : selfHostedArtifactId && selfHostedPhase === "loading" ? ( +
+

Self-hosted artifact

+

Loading stored payload…

+

+ Fetching the canonical `agent-render` payload for {selfHostedArtifactId}. Successful views extend the 24h sliding expiry on this server. +

+
+ ) : selfHostedArtifactId && selfHostedPhase === "error" ? ( +
+

Self-hosted artifact

+

Could not open this link

+

{selfHostedError?.message ?? "Something went wrong."}

+ +
) : (
@@ -691,11 +905,11 @@ export function ViewerShell() {

Codec

-

{parsed.ok ? parsed.envelope.codec : "plain"}

+

{viewerParsed.ok ? viewerParsed.envelope.codec : "plain"}

Artifacts

-

{parsed.ok ? numberFormatter.format(parsed.envelope.artifacts.length) : "0"}

+

{viewerParsed.ok ? numberFormatter.format(viewerParsed.envelope.artifacts.length) : "0"}

@@ -784,9 +998,9 @@ export function ViewerShell() { The live renderer stage appears here as soon as a fragment is selected.

- {parsed.ok + {viewerParsed.ok ? "A decoded fragment is already present, so the active artifact can take over this frame immediately." - : parsed.message} + : viewerParsed.message}

diff --git a/src/components/viewer/fragment-details-disclosure.tsx b/src/components/viewer/fragment-details-disclosure.tsx index 9ee8e33..928354b 100644 --- a/src/components/viewer/fragment-details-disclosure.tsx +++ b/src/components/viewer/fragment-details-disclosure.tsx @@ -7,12 +7,16 @@ type FragmentDetailsDisclosureProps = { maxLength: string; codec: string; hashPreview: string; + /** Defaults to fragment transport copy for the static product. */ + transportMode?: "fragment" | "stored"; + /** Optional ISO timestamp shown when `transportMode` is `stored`. */ + expiresAtLabel?: string | null; }; /** - * Shows protocol diagnostics for the current fragment payload in a collapsible viewer panel. + * Shows protocol diagnostics for the current payload in a collapsible viewer panel. * Receives status, codec, length budget, and hash preview props from the shell-level decode state. - * Stays read-only and provides quick visibility into transport/fallback conditions. + * Supports both fragment transport and optional server-stored UUID transport via `transportMode`. */ export function FragmentDetailsDisclosure({ statusLabel, @@ -21,15 +25,22 @@ export function FragmentDetailsDisclosure({ maxLength, codec, hashPreview, + transportMode = "fragment", + expiresAtLabel, }: FragmentDetailsDisclosureProps) { + const transportLabel = transportMode === "stored" ? "UUID (server-stored)" : "Fragment only"; + const kicker = transportMode === "stored" ? "Payload details" : "Fragment details"; + const title = + transportMode === "stored" + ? "Codec, stored wire length, and payload preview" + : "Codec, transport, budget, and hash preview"; + return ( -
+
- Fragment details - - Codec, transport, budget, and hash preview - + {kicker} + {title}
@@ -49,8 +60,14 @@ export function FragmentDetailsDisclosure({

Transport

-

Fragment only

+

{transportLabel}

+ {transportMode === "stored" && expiresAtLabel ? ( +
+

Expires (UTC)

+

{expiresAtLabel}

+
+ ) : null}

Hash preview

diff --git a/src/lib/payload/fragment.ts b/src/lib/payload/fragment.ts index 516cb18..e83e344 100644 --- a/src/lib/payload/fragment.ts +++ b/src/lib/payload/fragment.ts @@ -29,6 +29,15 @@ type EncodeOptions = { codecPriority?: PayloadCodec[]; }; +/** + * Options for decoding fragment-shaped payloads. The static product enforces {@link MAX_FRAGMENT_LENGTH} + * on the wire string; server-stored payloads may exceed that budget while still using the same + * `agent-render=v1..` shape. + */ +export type DecodeFragmentOptions = { + enforceFragmentLengthLimit?: boolean; +}; + type CandidateFragment = { value: string; codec: PayloadCodec; @@ -256,14 +265,15 @@ type ParsedFragmentHeader = | { ok: false; errorResponse: ParsedPayload } | { ok: true; fragment: string; version: string; codec: PayloadCodec; encoded: string; fragmentLength: number }; -function parseFragmentHeader(hash: string): ParsedFragmentHeader { +function parseFragmentHeader(hash: string, options?: DecodeFragmentOptions): ParsedFragmentHeader { const fragment = hash.startsWith("#") ? hash.slice(1) : hash; + const enforceFragmentLengthLimit = options?.enforceFragmentLengthLimit !== false; if (!fragment) { return { ok: false, errorResponse: { ok: false, code: "empty", message: "Add a fragment payload to start rendering artifacts." } }; } - if (fragment.length > MAX_FRAGMENT_LENGTH) { + if (enforceFragmentLengthLimit && fragment.length > MAX_FRAGMENT_LENGTH) { return { ok: false, errorResponse: { ok: false, code: "too-large", message: `This payload exceeds the supported fragment budget of ${MAX_FRAGMENT_LENGTH.toLocaleString()} characters.` } }; } @@ -305,9 +315,12 @@ function parseFragmentHeader(hash: string): ParsedFragmentHeader { * * Sync decoding supports `plain`, `lz`, and `deflate` codecs only. `arx` fragments return an * `invalid-format` result instructing callers to use {@link decodeFragmentAsync}. + * + * @param options - When `enforceFragmentLengthLimit` is `false`, the wire string may exceed + * {@link MAX_FRAGMENT_LENGTH} (used for server-stored canonical payloads). */ -export function decodeFragment(hash: string): ParsedPayload { - const header = parseFragmentHeader(hash); +export function decodeFragment(hash: string, options?: DecodeFragmentOptions): ParsedPayload { + const header = parseFragmentHeader(hash, options); if (!header.ok) { return header.errorResponse; } @@ -399,14 +412,18 @@ function resolveArxDictVersion(version: number | null): boolean { * * Accepted input follows `#agent-render=v1...` where non-`arx` payloads use * `v1..`, and `arx` supports `v1.arx..` (plus legacy - * fallback forms). This decoder applies the same header and fragment-size checks as the sync - * path, then enforces decoded payload size limits before JSON parsing and envelope validation. + * fallback forms). This decoder applies the same header checks as the sync path (including + * optional fragment-size enforcement via `options`), then enforces decoded payload size limits + * before JSON parsing and envelope validation. * * Returns structured `ParsedPayload` error responses for malformed fragments or invalid * envelopes, rather than throwing decode errors. + * + * @param options - When `enforceFragmentLengthLimit` is `false`, the wire string may exceed + * {@link MAX_FRAGMENT_LENGTH} (used for server-stored canonical payloads). */ -export async function decodeFragmentAsync(hash: string): Promise { - const header = parseFragmentHeader(hash); +export async function decodeFragmentAsync(hash: string, options?: DecodeFragmentOptions): Promise { + const header = parseFragmentHeader(hash, options); if (!header.ok) { return header.errorResponse; } diff --git a/src/lib/selfhosted/artifact-path.ts b/src/lib/selfhosted/artifact-path.ts new file mode 100644 index 0000000..9c7ea48 --- /dev/null +++ b/src/lib/selfhosted/artifact-path.ts @@ -0,0 +1,36 @@ +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function normalizeBasePath(basePath: string): string { + const trimmed = basePath.trim(); + if (!trimmed || trimmed === "/") { + return ""; + } + + return trimmed.replace(/\/$/, ""); +} + +/** + * Returns the final path segment when the URL targets a UUID v4 artifact route, for example + * `/550e8400-e29b-41d4-a716-446655440000/`. + * + * When this app is built with a Next.js `basePath`, `usePathname()` already strips that prefix, + * so callers should pass that pathname here and omit `basePath` unless the string still includes it. + */ +export function getArtifactIdFromPathname(pathname: string, basePath = ""): string | null { + const normalizedBase = normalizeBasePath(basePath); + let tail = pathname; + + if (normalizedBase) { + if (!tail.startsWith(normalizedBase)) { + return null; + } + + tail = tail.slice(normalizedBase.length) || "/"; + } + + const trimmed = tail.replace(/\/+$/, ""); + const segment = trimmed.split("/").filter(Boolean).pop() ?? ""; + + return UUID_V4.test(segment) ? segment : null; +} diff --git a/tests/artifact-path.test.ts b/tests/artifact-path.test.ts new file mode 100644 index 0000000..9e4c59d --- /dev/null +++ b/tests/artifact-path.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { getArtifactIdFromPathname } from "@/lib/selfhosted/artifact-path"; + +const sample = "550e8400-e29b-41d4-a716-446655440000"; + +describe("getArtifactIdFromPathname", () => { + it("parses a trailing UUID segment", () => { + expect(getArtifactIdFromPathname(`/${sample}/`)).toBe(sample); + expect(getArtifactIdFromPathname(`/${sample}`)).toBe(sample); + }); + + it("strips an optional base path prefix", () => { + expect(getArtifactIdFromPathname(`/app/${sample}/`, "/app")).toBe(sample); + }); + + it("returns null for the homepage", () => { + expect(getArtifactIdFromPathname("/")).toBeNull(); + expect(getArtifactIdFromPathname("/app/", "/app")).toBeNull(); + }); +}); diff --git a/tests/fragment.test.ts b/tests/fragment.test.ts index 71b52e9..50e60e4 100644 --- a/tests/fragment.test.ts +++ b/tests/fragment.test.ts @@ -158,6 +158,45 @@ describe("fragment payload transport", () => { expect(parsed.envelope.activeArtifactId).toBe("doc"); }); + it("decodes stored payloads that exceed the fragment wire budget when opted in", () => { + const padding = "p".repeat(12_000); + const bigEnvelope: PayloadEnvelope = { + ...envelope, + artifacts: [ + { + id: "doc", + kind: "markdown", + filename: "doc.md", + content: padding, + }, + ], + }; + const wire = encodeEnvelope(bigEnvelope, { codec: "plain" }); + const hash = `#${wire}`; + + expect(wire.length).toBeGreaterThan(8000); + + const blocked = decodeFragment(hash); + expect(blocked.ok).toBe(false); + if (blocked.ok) { + return; + } + + expect(blocked.code).toBe("too-large"); + + const decoded = decodeFragment(hash, { enforceFragmentLengthLimit: false }); + expect(decoded.ok).toBe(true); + if (!decoded.ok) { + return; + } + + const artifact = decoded.envelope.artifacts[0]; + expect(artifact.kind).toBe("markdown"); + if (artifact.kind === "markdown") { + expect(artifact.content).toBe(padding); + } + }); + it("rejects an oversized decoded payload even when the compressed fragment is short", () => { const hugeEnvelope: PayloadEnvelope = { v: 1, diff --git a/tests/selfhosted-artifact-db.test.ts b/tests/selfhosted-artifact-db.test.ts new file mode 100644 index 0000000..b7a35bf --- /dev/null +++ b/tests/selfhosted-artifact-db.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { encodeEnvelope } from "@/lib/payload/fragment"; +import type { PayloadEnvelope } from "@/lib/payload/schema"; +// Self-hosted store ships as JavaScript for the Node entrypoint; types are not emitted. +// @ts-expect-error — JavaScript module without a TS declaration file +import { openArtifactStore } from "../selfhosted/artifact-db.mjs"; + +const sampleEnvelope: PayloadEnvelope = { + v: 1, + codec: "plain", + activeArtifactId: "a", + artifacts: [ + { + id: "a", + kind: "markdown", + content: "# test", + }, + ], +}; + +const samplePayload = encodeEnvelope(sampleEnvelope, { codec: "plain" }); + +describe("selfhosted artifact-db", () => { + let store: ReturnType; + + beforeEach(() => { + store = openArtifactStore(":memory:"); + }); + + afterEach(() => { + store.db.close(); + }); + + it("creates and retrieves artifacts with sliding expiry", () => { + const created = store.createArtifact(samplePayload); + expect(created.id).toMatch(/^[0-9a-f-]{36}$/i); + + const first = store.getArtifact(created.id); + expect(first.ok).toBe(true); + if (!first.ok) { + return; + } + + const firstExpiry = first.row.expires_at; + const second = store.getArtifact(created.id); + expect(second.ok).toBe(true); + if (!second.ok) { + return; + } + + expect(second.row.expires_at).toBeGreaterThanOrEqual(firstExpiry); + }); + + it("returns expired for stale rows", () => { + const created = store.createArtifact(samplePayload); + const shift = store.db.prepare(`UPDATE artifacts SET expires_at = ? WHERE id = ?`); + shift.run(Date.now() - 1000, created.id); + + const result = store.getArtifact(created.id); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.reason).toBe("expired"); + }); + + it("updates payload and refreshes expiry", () => { + const created = store.createArtifact(samplePayload); + const nextPayload = encodeEnvelope( + { + ...sampleEnvelope, + artifacts: [{ id: "b", kind: "markdown", content: "updated" }], + activeArtifactId: "b", + }, + { codec: "plain" }, + ); + const updated = store.updateArtifact(created.id, nextPayload); + expect(updated.ok).toBe(true); + if (!updated.ok) { + return; + } + + const row = store.getArtifact(created.id); + expect(row.ok).toBe(true); + if (!row.ok) { + return; + } + + expect(row.row.payload).toBe(nextPayload); + }); + + it("deletes artifacts", () => { + const created = store.createArtifact(samplePayload); + expect(store.deleteArtifact(created.id)).toBe(true); + expect(store.deleteArtifact(created.id)).toBe(false); + }); + + it("purges expired artifacts", () => { + const created = store.createArtifact(samplePayload); + const shift = store.db.prepare(`UPDATE artifacts SET expires_at = ? WHERE id = ?`); + shift.run(Date.now() - 1000, created.id); + expect(store.purgeExpired()).toBe(1); + }); + + it("rejects invalid stored payloads", () => { + expect(() => store.createArtifact("not-a-payload")).toThrow(); + }); +}); From 38d1cc5d951a017736adf8d896ab022378d7130c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Mar 2026 17:12:32 +0000 Subject: [PATCH 2/2] docs: update ViewerShell JSDoc for self-hosted mode Co-authored-by: Aanish Bhirud --- src/components/viewer-shell.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/viewer-shell.tsx b/src/components/viewer-shell.tsx index 8456f60..ad53a5a 100644 --- a/src/components/viewer-shell.tsx +++ b/src/components/viewer-shell.tsx @@ -236,10 +236,12 @@ function getAnimationStyle(delay: number): CSSProperties { } /** - * Render the main viewer shell for decoding and displaying artifact fragments from the URL hash. + * Render the main viewer shell for decoding and displaying artifacts from the URL hash or, when built with + * `NEXT_PUBLIC_SELFHOSTED_SERVER=1`, from a UUID path backed by the self-hosted API. * * Manages fragment decoding and ARX dictionary loading, synchronizes component state with the browser hash, - * and provides UI and handlers for selecting, copying, downloading, printing, and navigating artifacts or clearing the fragment. + * fetches stored payloads when appropriate, and provides UI and handlers for selecting, copying, downloading, + * printing, and navigating artifacts or returning to the homepage. * * @returns The root React element for the viewer shell UI */