Skip to content

Add optional self-hosted UUID-based artifact server#27

Merged
baanish merged 7 commits intomainfrom
claude/add-selfhosted-uuid-mode-eCSLN
Apr 8, 2026
Merged

Add optional self-hosted UUID-based artifact server#27
baanish merged 7 commits intomainfrom
claude/add-selfhosted-uuid-mode-eCSLN

Conversation

@baanish
Copy link
Copy Markdown
Owner

@baanish baanish commented Apr 7, 2026

Summary

This PR adds an optional self-hosted server mode to agent-render that stores artifact payloads in SQLite and serves them under UUID-based links. The default static/fragment-based product remains unchanged. This new mode is designed for use cases where payloads exceed the ~8 KB fragment budget or when links are shared on platforms that mangle long URLs.

Key Changes

  • Self-hosted HTTP server (selfhosted/server.ts): Node.js HTTP server that serves static files, exposes a REST API for artifact CRUD operations, and renders the viewer with injected payloads for UUID routes

    • POST /api/artifacts — create a new artifact with a UUID and 24-hour TTL
    • GET /api/artifacts/:id — retrieve artifact (refreshes TTL)
    • PUT /api/artifacts/:id — update artifact payload
    • DELETE /api/artifacts/:id — delete artifact
    • POST /api/cleanup — batch-remove expired artifacts
    • GET /:uuid — render viewer with stored payload injected via window.__AGENT_RENDER_PAYLOAD__
  • SQLite storage layer (selfhosted/db.ts): Database abstraction using better-sqlite3 with automatic table/index creation, UUID v4 artifact IDs, and sliding 24-hour TTL

    • Lazy deletion of expired artifacts on read
    • TTL refresh on successful reads and updates
  • Payload validation (selfhosted/validate.ts): Server-side validation that payloads are non-empty strings under 500 KB

  • TTL utilities (selfhosted/ttl.ts): Helper functions for computing expiration timestamps and checking expiry status

  • Viewer integration (src/components/viewer-shell.tsx): Modified ViewerShell to check for window.__AGENT_RENDER_PAYLOAD__ on mount and use it as the payload source when present, allowing the same decode/render pipeline to work for both fragment and UUID modes

  • Deployment options:

    • Docker Compose setup (selfhosted/docker-compose.yml, selfhosted/Dockerfile) for containerized deployment
    • TypeScript configuration (selfhosted/tsconfig.json) for server compilation
    • npm scripts for building and running the server
  • Comprehensive test suite (tests/selfhosted/): Unit tests for database operations, TTL behavior, and payload validation

  • Documentation: Updated README, deployment guide, architecture docs, and added a dedicated skill document (skills/selfhosted-agent-render/SKILL.md) with API reference, deployment instructions, and auth guidance

Implementation Details

  • The server uses Node.js built-in http module (no external framework) for minimal dependencies
  • Artifacts are stored with created_at, updated_at, last_viewed_at, and expires_at timestamps
  • Each successful read extends the TTL by 24 hours, enabling long-lived artifacts with periodic access
  • The server serves the pre-built static frontend (out/) alongside the API, so both modes coexist in one deployment
  • CORS headers are set on API routes to allow cross-origin requests
  • Payload injection uses a simple script tag in the </head> to avoid DOM parsing issues
  • No built-in authentication; deployment docs recommend Cloudflare Tunnel + Zero Trust, reverse proxy, or network-level restrictions

https://claude.ai/code/session_01NeNkCL34w4hCodzArQhZgJ

Summary by CodeRabbit

  • New Features

    • Optional self-hosted UUID mode: server-stored artifacts with short UUID links, REST CRUD, and 24-hour sliding TTL; viewer supports server-injected payloads for UUID links.
  • Documentation

    • Expanded README, architecture, deployment, skills, and dependency docs with self-hosted mode guidance, deployment options, access-control recommendations, and quick-start instructions.
  • Tests

    • Added Node-based test suites for DB, TTL, and payload validation.
  • Chores

    • Added self-hosted run/build scripts, SQLite runtime dependency, TypeScript config, and Docker/Compose packaging.

Open with Devin

Introduces a separate self-hosted server variant that stores agent-render
payloads in SQLite under UUID v4 keys, enabling short stable links like
/{uuid} that bypass fragment length limits and URL mangling on chat platforms.

The existing static fragment-based viewer remains the default product and
is fully preserved. The self-hosted mode reuses the same frontend by
injecting the stored payload via window.__AGENT_RENDER_PAYLOAD__ on the
server-rendered page, feeding into the existing decode/render pipeline
with full feature parity.

Includes REST API (CRUD), 24h sliding TTL, Docker Compose support,
selfhosted-agent-render skill, and comprehensive documentation updates.

https://claude.ai/code/session_01NeNkCL34w4hCodzArQhZgJ
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 7, 2026

Warning

Rate limit exceeded

@baanish has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 minutes and 6 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 2 minutes and 6 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8f9f2709-5298-48ff-9267-719a9a779dbb

📥 Commits

Reviewing files that changed from the base of the PR and between 6fe8b3c and c329bae.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (3)
  • package.json
  • selfhosted/server.ts
  • src/components/viewer-shell.tsx
📝 Walkthrough

Walkthrough

Adds an optional self-hosted UUID mode: a standalone Node.js HTTP server that serves the static frontend, provides REST CRUD for artifact payloads persisted in SQLite under UUID keys, injects payloads into the viewer for /:uuid routes, and enforces a 24-hour sliding TTL.

Changes

Cohort / File(s) Summary
Documentation & Skills
AGENTS.md, README.md, docs/architecture.md, docs/deployment.md, docs/testing.md, docs/dependency-notes.md, skills/agent-render-linking/SKILL.md, skills/selfhosted-agent-render/SKILL.md
Add docs describing optional self-hosted UUID mode, API contract, storage/TTL semantics, deployment (Docker Compose, systemd, pm2), testing notes, dependency licensing, and skill guidance linking to self-hosted workflows.
Self-hosted Server Core
selfhosted/server.ts, selfhosted/db.ts, selfhosted/ttl.ts, selfhosted/validate.ts
New Node HTTP server and modules: static file serving, /api/artifacts CRUD, /api/cleanup, payload injection into index.html, SQLite-backed artifacts table, 24-hour sliding TTL logic, payload validation, and exported handlers (handleRequest, injectPayload).
Build & Deployment Artifacts
selfhosted/tsconfig.json, selfhosted/Dockerfile, selfhosted/docker-compose.yml, package.json
Add selfhosted TypeScript config, multi-stage Dockerfile, Docker Compose service with persistent volume, npm scripts (selfhosted:dev, selfhosted:build, selfhosted:start), and better-sqlite3 (+ types) and tsx deps.
Viewer Integration
src/components/viewer-shell.tsx
ViewerShell reads window.__AGENT_RENDER_PAYLOAD__ on mount when present, initializes hash from it, skips the initial hash-sync for that pass, and decodes fragments with a flag when payload originated from injection.
Payload Decode API
src/lib/payload/fragment.ts
Adds DecodeOptions and a skipFragmentBudget?: boolean option; updates parseFragmentHeader and decodeFragmentAsync signatures to accept options and allow bypassing fragment transport-size budget when requested.
Tests
tests/selfhosted/db.test.ts, tests/selfhosted/ttl.test.ts, tests/selfhosted/validate.test.ts
Add Vitest Node tests for DB CRUD/expiry, TTL computation, and payload validation; tests use isolated temp DBs and run under Node environment.

Sequence Diagram

sequenceDiagram
    participant Browser as Browser Client
    participant Server as Self-hosted Server
    participant DB as SQLite DB

    Browser->>Server: GET /:uuid
    Server->>DB: SELECT artifact WHERE id = :uuid
    DB-->>Server: artifact row / not found

    alt not found or expired
        Server-->>Browser: 404 (error HTML)
    else valid artifact
        Server->>Server: inject payload into cached index.html\n(window.__AGENT_RENDER_PAYLOAD__ = ...)
        Server->>DB: UPDATE last_viewed_at, expires_at (refresh TTL)
        DB-->>Server: update confirmation
        Server-->>Browser: 200 with injected HTML
        Browser->>Browser: ViewerShell reads __AGENT_RENDER_PAYLOAD__ and renders UI
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

Poem

🐰 A server springs up where fragments once played,
SQLite hides carrots in a neat little glade.
UUID trails make links short and spry,
Twenty-four hours to munch—then they fly!
ViewerShell wakes, hops in, and says "hi!"

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'Add optional self-hosted UUID-based artifact server' is clear, specific, and directly summarizes the main change: introducing an optional self-hosted server mode for artifact storage and serving via UUID-based links.
Docstring Coverage ✅ Passed Docstring coverage is 95.65% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/add-selfhosted-uuid-mode-eCSLN

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 643e0cca61

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread selfhosted/server.ts Outdated
* via `window.__AGENT_RENDER_PAYLOAD__` on load.
*/
function injectPayload(html: string, payload: string): string {
const script = `<script>window.__AGENT_RENDER_PAYLOAD__=${JSON.stringify(payload)};</script>`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Escape payload before injecting into HTML script tag

Embedding JSON.stringify(payload) directly inside a <script> block lets attacker-controlled payloads break out of the string with </script>..., which enables script execution whenever someone opens a UUID link. This is reachable because POST /api/artifacts accepts arbitrary strings and GET /:uuid injects them into HTML, so a crafted artifact can trigger stored XSS in any browser visiting that link.

Useful? React with 👍 / 👎.

Comment thread selfhosted/db.ts Outdated
Comment on lines +110 to +112
`UPDATE artifacts SET last_viewed_at = ?, expires_at = ?, updated_at = ? WHERE id = ?`,
)
.run(now, newExpiresAt, now, id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve updated_at when serving artifact reads

getArtifact currently writes updated_at = now for every successful read, so plain views/GETs mutate the record as if the payload changed. In environments that poll GET /api/artifacts/:id or render via GET /:uuid, this makes updated_at unusable for detecting real content edits and can cause incorrect sync or cache-invalidation behavior.

Useful? React with 👍 / 👎.

coderabbitai[bot]

This comment was marked as resolved.

- Escape </script> sequences in injected payload to prevent stored XSS
- Add path traversal guard in serveStatic to keep resolved paths within
  the output directory
- Stop mutating updated_at on read-only GET operations (only refresh
  last_viewed_at and expires_at)
- Run Docker container as non-root nodejs user

https://claude.ai/code/session_01NeNkCL34w4hCodzArQhZgJ
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 7, 2026

Deploying agent-render with  Cloudflare Pages  Cloudflare Pages

Latest commit: c329bae
Status: ✅  Deploy successful!
Preview URL: https://53a13ea4.agent-render.pages.dev
Branch Preview URL: https://claude-add-selfhosted-uuid-m.agent-render.pages.dev

View logs

@kilo-code-bot
Copy link
Copy Markdown

kilo-code-bot Bot commented Apr 7, 2026

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Issue Resolution

The previous WARNING at selfhosted/server.ts:76 (missing return after reject) has been RESOLVED in this commit - line 76 now correctly returns after rejecting when body limit is exceeded.

Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
selfhosted/db.ts 108-112 P2: getArtifact updates updated_at on every read
selfhosted/server.ts 53-58 P1 (resolved): XSS vulnerability - properly escaped
selfhosted/server.ts 104-138 P1 (resolved): Path traversal - properly validated
selfhosted/Dockerfile 37 Minor (resolved): Container runs as non-root user
Files Reviewed (1 file in diff)
  • selfhosted/server.ts - fix applied, no new issues

Incremental review of changes since commit 16d897a7ae241f78f5561c83dec385843c66e0f8. The missing return statement has been added after the reject call, preventing chunk accumulation when the body limit is exceeded.


Reviewed by minimax-m2.5-20260211 · 387,611 tokens


Reviewed by minimax-m2.5-20260211 · 79,457 tokens

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
selfhosted/db.ts (1)

17-49: Singleton pattern has a subtle limitation with different dbPath values.

The singleton caches the first connection. If getDb() is called with different dbPath arguments after the initial call, subsequent paths are ignored. This is probably fine for production (single server instance) but could cause confusion in tests.

Consider documenting this behavior or throwing an error if called with a different path while a connection exists:

 export function getDb(dbPath?: string): Database.Database {
-  if (db) return db;
+  const resolvedPath = dbPath ?? process.env.DB_PATH ?? "./data/agent-render.db";
+  if (db) {
+    // Connection already exists; subsequent dbPath arguments are ignored
+    return db;
+  }

-  const resolvedPath = dbPath ?? process.env.DB_PATH ?? "./data/agent-render.db";
   mkdirSync(dirname(resolvedPath), { recursive: true });

The WAL mode and indexed expires_at column are good choices for this workload.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@selfhosted/db.ts` around lines 17 - 49, The getDb singleton caches the first
opened database and silently ignores later dbPath arguments, which can surprise
tests; fix by recording the initial resolvedPath when you first open the DB
(store it in a new module-level variable like initialDbPath) and, at the top of
getDb, compare the new resolvedPath to that stored value and throw a clear error
(or return the existing db only if paths match) when they differ; update
references to the module-level db and use resolvedPath in the comparison so
callers are immediately informed if they attempt to open a different file.
selfhosted/Dockerfile (1)

14-27: Copy node_modules from builder stage instead of reinstalling in production.

The production stage runs npm ci --omit=dev to install better-sqlite3, which relies on prebuilt binaries being available for download. While better-sqlite3 v12.8.0 has prebuilt binaries for Linux + Node 20, this approach is unnecessarily inefficient and creates a reliability risk if network access is unavailable or prebuild servers are inaccessible.

Since the builder stage already runs npm ci and builds all dependencies, copy node_modules directly from the builder:

COPY --from=builder /app/node_modules ./node_modules

This eliminates the redundant installation, reduces build time, and removes the network/prebuild dependency for production deployments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@selfhosted/Dockerfile` around lines 14 - 27, The Dockerfile currently runs
npm ci --omit=dev in the production stage which reinstalls dependencies
(including better-sqlite3) and creates network/prebuild fragility; instead, copy
the already-built node_modules from the builder stage into production by
replacing the production-stage npm ci step with a COPY from the builder
(reference the existing COPY --from=builder patterns and the npm ci --omit=dev
invocation and the better-sqlite3 dependency) so production uses
/app/node_modules produced in the builder and avoids reinstalling dependencies
at runtime.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@selfhosted/db.ts`:
- Around line 17-49: The getDb singleton caches the first opened database and
silently ignores later dbPath arguments, which can surprise tests; fix by
recording the initial resolvedPath when you first open the DB (store it in a new
module-level variable like initialDbPath) and, at the top of getDb, compare the
new resolvedPath to that stored value and throw a clear error (or return the
existing db only if paths match) when they differ; update references to the
module-level db and use resolvedPath in the comparison so callers are
immediately informed if they attempt to open a different file.

In `@selfhosted/Dockerfile`:
- Around line 14-27: The Dockerfile currently runs npm ci --omit=dev in the
production stage which reinstalls dependencies (including better-sqlite3) and
creates network/prebuild fragility; instead, copy the already-built node_modules
from the builder stage into production by replacing the production-stage npm ci
step with a COPY from the builder (reference the existing COPY --from=builder
patterns and the npm ci --omit=dev invocation and the better-sqlite3 dependency)
so production uses /app/node_modules produced in the builder and avoids
reinstalling dependencies at runtime.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c6d253fd-a25d-40b1-b26b-fd5caacadfc2

📥 Commits

Reviewing files that changed from the base of the PR and between 643e0cc and a3fa60a.

📒 Files selected for processing (3)
  • selfhosted/Dockerfile
  • selfhosted/db.ts
  • selfhosted/server.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • selfhosted/server.ts

devin-ai-integration[bot]

This comment was marked as resolved.

Remove early return in ViewerShell useEffect when consuming an injected
payload so the hashchange listener is still registered for subsequent
navigation (sample links, back/forward, manual URL edits).

Add tsx to devDependencies so selfhosted:dev script works out of the box.

https://claude.ai/code/session_01NeNkCL34w4hCodzArQhZgJ
devin-ai-integration[bot]

This comment was marked as resolved.

The self-hosted mode's core use case is payloads that exceed the ~8 KB
fragment budget. Add a skipFragmentBudget option to decodeFragmentAsync
and use it when decoding server-injected payloads so large artifacts
render correctly. The decoded payload size limit (200K) is still enforced.

https://claude.ai/code/session_01NeNkCL34w4hCodzArQhZgJ
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/lib/payload/fragment.ts (1)

259-262: Consider exporting DecodeOptions for API discoverability.

The type is well-documented internally, but callers of decodeFragmentAsync currently have to guess the option shape. Exporting it would improve API ergonomics without any downside.

♻️ Suggested change
-type DecodeOptions = {
+/**
+ * Options for async fragment decoding.
+ */
+export type DecodeOptions = {
   /** Skip the fragment transport size budget check (for server-injected payloads). */
   skipFragmentBudget?: boolean;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/payload/fragment.ts` around lines 259 - 262, The DecodeOptions type
is currently internal; export it so callers can import the options shape used by
decodeFragmentAsync. Change the declaration of DecodeOptions to an exported type
(export type DecodeOptions = { ... }) and update any local references/imports if
necessary (e.g., places that import or annotate decodeFragmentAsync options) so
the public API exposes the type for discoverability without altering runtime
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/lib/payload/fragment.ts`:
- Around line 259-262: The DecodeOptions type is currently internal; export it
so callers can import the options shape used by decodeFragmentAsync. Change the
declaration of DecodeOptions to an exported type (export type DecodeOptions = {
... }) and update any local references/imports if necessary (e.g., places that
import or annotate decodeFragmentAsync options) so the public API exposes the
type for discoverability without altering runtime behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1856c70b-1c8e-4d37-ba52-336619446c7e

📥 Commits

Reviewing files that changed from the base of the PR and between 8ee228a and 6fe8b3c.

📒 Files selected for processing (2)
  • src/components/viewer-shell.tsx
  • src/lib/payload/fragment.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/viewer-shell.tsx

devin-ai-integration[bot]

This comment was marked as resolved.

Static-only users should not be forced to install a native SQLite module.
Moving it to optionalDependencies means npm install succeeds even without
native build tools, keeping the core static product install lightweight.

https://claude.ai/code/session_01NeNkCL34w4hCodzArQhZgJ
Comment thread selfhosted/server.ts
req.destroy();
reject(new Error("Request body too large."));
}
chunks.push(chunk);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Missing return after reject causes chunk accumulation after body limit exceeded

In readBody, when the body exceeds 1 MB limit, reject() is called on line 74 but execution falls through to chunks.push(chunk) on line 76 because there is no return statement. The oversized chunk is still pushed after the promise is rejected.

Prevents the oversized chunk from being pushed into the buffer after
the promise is already rejected and the request is destroyed.

https://claude.ai/code/session_01NeNkCL34w4hCodzArQhZgJ
devin-ai-integration[bot]

This comment was marked as resolved.

Use the same typeof/length check for skipping syncHash as for consuming
the injected payload, so a truthy non-string value cannot suppress the
initial hash sync.

https://claude.ai/code/session_01NeNkCL34w4hCodzArQhZgJ
@baanish baanish merged commit eb2cafb into main Apr 8, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants