feat(apps): Rowboat Apps M1 — per-app origins, Host API core, apps skill

Implements M1 of the Apps V1 spec, replacing the mini-apps prototype:

- rowboat-app.json manifest + AppSummary/install/publish/registry schemas;
  sourceApp on background tasks
- apps server on 127.0.0.1:3210 (absorbs local-sites): Host-header routing to
  <slug>.apps.localhost origins, 421 rebinding guard, static from dist/ with
  entry/SPA fallback + realpath confinement, control host /health, chokidar
  watcher + per-app SSE with dist/data areas, embed-opt-in autosize bootstrap
  and cancelable rowboat:data-change event
- Host API: D17 anti-CSRF (X-Rowboat-App + strict Origin on non-GET),
  GET /_rowboat/app (manifest + theme), /_rowboat/events, data API under
  data/ with atomic writes and dataContracts enforcement
- indexer: list (ok/invalid surfaced) / create scaffold / delete
- IPC: apps:list/get/create/delete/setTheme; renderer reports theme live
- renderer: Apps home (My apps grid + catalog placeholder, server banner,
  invalid/kind badges) and full-height app view on the app origin
- apps copilot skill (replaces build-mini-app); app-set-data builtin replaces
  mini-app-install/set-data; deleted the app://miniapp protocol, postMessage
  bridge, mini-apps handler/channels, and local-sites
This commit is contained in:
Gagan 2026-07-04 17:12:54 +05:30
parent 2c038fe518
commit 7f15f67273
24 changed files with 1567 additions and 2359 deletions

View file

@ -0,0 +1,200 @@
export const skill = String.raw`
# Rowboat Apps
A *Rowboat app* is a static web application the user opens inside Rowboat its
own UI on its own origin, powered by their integrations and (optionally) a
background agent. Apps live at \`~/.rowboat/apps/<folder-slug>/\` and are served
at \`http://<folder-slug>.apps.localhost:3210/\`.
## 0. Should this even be an app? (intent gate)
- **Strong build it:** "make/build/create an app · dashboard for …", "turn
this into an app".
- **Ambiguous CONFIRM FIRST:** the request could be a one-off answer OR a
reopenable app (e.g. "show me my open PRs", "track competitor launches").
Ask once: *"Want this as an app you can reopen, or just a one-time answer?"*
Build only on yes building creates folders, possibly agents and OAuth
prompts; too heavy for a casual question.
- **Clear one-off lookups:** just answer. Don't build.
## 1. The contract (files on disk)
\`\`\`
~/.rowboat/apps/<folder-slug>/
rowboat-app.json # manifest (required)
dist/ # browser-ready files; served at / (index.html = entry)
agents/ # optional bundled agent definitions (*.yaml)
data/ # runtime data; read/written via the data API
\`\`\`
Folder slug: lowercase \`a-z0-9\` with single hyphens (e.g. \`pr-dashboard\`).
Minimal manifest (write it pretty-printed):
\`\`\`json
{
"schemaVersion": 1,
"name": "pr-dashboard",
"version": "0.1.0",
"description": "Open PRs across my repos",
"capabilities": ["github"],
"dataContracts": [
{ "file": "data.json", "requiredKeys": ["updatedAt", "items"], "nonEmptyArrayKeys": [] }
]
}
\`\`\`
- \`capabilities\`: every Composio toolkit slug the app calls via the tools API,
plus \`"llm"\` and/or \`"copilot"\` if it uses those endpoints. **Undeclared
capabilities are rejected at runtime** (403 \`capability_not_declared\`).
- \`dataContracts\`: shape guards for \`data/\` files an agent maintains — a
wrong-shaped write is rejected and last-good data survives.
## 2. No-build rule
Write plain, browser-ready HTML/JS/CSS **directly into \`dist/\`** with your file
tools. CDN \`<script>\` tags are fine; use relative asset URLs. Never require a
bundler or build step. \`dist/index.html\` is the app root.
## 3. Host API (same-origin, under \`/_rowboat/\`)
Errors are \`{ "error": { "code", "message" } }\`. **Every non-GET request MUST
include the header \`X-Rowboat-App: 1\`** — requests without it are rejected
(anti-CSRF).
**App info + theme**
\`\`\`js
const info = await (await fetch('/_rowboat/app')).json();
// { name, version, folder, description, theme: 'light'|'dark' }
\`\`\`
**Data** (backing store: the app's \`data/\` folder)
\`\`\`js
// read
const data = await (await fetch('/_rowboat/data/data.json')).json();
// write (atomic; contract-checked when dataContracts matches the file)
await fetch('/_rowboat/data/data.json', {
method: 'PUT',
headers: { 'X-Rowboat-App': '1', 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
// list
const { entries } = await (await fetch('/_rowboat/data?list=.')).json();
\`\`\`
**Composio tools** (capability = the toolkit slug)
\`\`\`js
const { items } = await (await fetch('/_rowboat/tools/search', {
method: 'POST', headers: { 'X-Rowboat-App': '1', 'Content-Type': 'application/json' },
body: JSON.stringify({ toolkit: 'github', query: 'list pull requests' }),
})).json();
const result = await (await fetch('/_rowboat/tools/execute', {
method: 'POST', headers: { 'X-Rowboat-App': '1', 'Content-Type': 'application/json' },
body: JSON.stringify({ toolkit: 'github', slug: items[0].slug, arguments: { owner, repo, state: 'open' } }),
})).json();
\`\`\`
**Third-party HTTP** see CORS below: always the proxy, never browser fetch.
\`\`\`js
const r = await (await fetch('/_rowboat/fetch', {
method: 'POST', headers: { 'X-Rowboat-App': '1', 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://api.example.com/rates.json' }),
})).json(); // { ok, status, text, truncated } — parse r.text yourself
\`\`\`
**LLM generation** (capability \`llm\` — spends the user's tokens; use sparingly)
\`\`\`js
const { text } = await (await fetch('/_rowboat/llm/generate', {
method: 'POST', headers: { 'X-Rowboat-App': '1', 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 'Summarize: …', maxOutputTokens: 512 }),
})).json();
\`\`\`
**Copilot run** (capability \`copilot\`) — a FULL headless agent run: far
costlier than \`llm/generate\`, takes seconds-to-minutes (show a pending state).
Use only when tools or the user's knowledge are actually needed.
\`\`\`js
const { text, turnId } = await (await fetch('/_rowboat/copilot/run', {
method: 'POST', headers: { 'X-Rowboat-App': '1', 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: '…' }),
})).json();
\`\`\`
## 4. Data conventions
- Durable state goes in \`data/\` via the data API — **never localStorage**
(invisible to agents; doesn't survive reinstalls).
- Set a \`dataContracts\` entry for any file a bundled agent maintains.
- Live updates: the page auto-reloads when \`dist/\` changes. When something
under \`data/\` changes, a **cancelable DOM event** fires first — subscribe and
re-fetch in place so agent refreshes don't yank the page mid-scroll:
\`\`\`js
window.addEventListener('rowboat:data-change', (e) => {
e.preventDefault(); // suppress the full reload
refreshFromData(); // re-fetch /_rowboat/data/... and re-render
});
\`\`\`
## 5. Background agents (self-updating data)
When the user wants data refreshed on a schedule, create a background task
(\`create-background-task\`) whose instructions fetch the data (Composio tools,
or the \`fetch-url\` builtin for plain HTTP — **the bg-task agent has NO
shell**; never generate a refresh script) and store it via the
**\`app-set-data\`** builtin: \`{ appFolder, file: "data.json", data: <object> }\`
pass the object directly, never \`JSON.stringify\` it. The write is atomic and
contract-checked; on a failed fetch keep the last good data (never overwrite
good series with empties). If the app should ship the agent, mirror the
definition into \`agents/<slug>.yaml\` with ONLY \`name\`, \`instructions\`,
\`triggers\`, and list the filename in \`manifest.agents\`.
## 6. Prohibitions
- Never write credentials or personal data anywhere in the app folder except
\`data/\`.
- Never edit \`.rowboat-install.json\` or \`.rowboat-publish.json\`.
- Never put files under a \`/_rowboat/\` path inside \`dist/\`.
## 7. Verify wiring BEFORE building (required do not speculate)
Ensure the needed toolkits are connected (prompt OAuth if not), then actually
call the intended tools yourself (\`composio-search-tools\`
\`composio-execute-tool\`) and derive the data shape **from the real
responses** never guess field names. That derived shape becomes the
\`dataContracts\` entry and the UI's contract.
## 8. CORS
From app code, call third-party APIs via \`/_rowboat/fetch\` — never the
browser's \`fetch\`. Most public APIs send no CORS headers, so a direct fetch
fails with "Failed to fetch" even though the endpoint works from curl.
## 9. Both themes (required)
Read \`theme\` from \`/_rowboat/app\` and subscribe to theme changes; style light
AND dark never a hard-coded dark-only palette (\`prefers-color-scheme\` tracks
the OS, not Rowboat):
\`\`\`js
const events = new EventSource('/_rowboat/events');
events.addEventListener('message', (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'theme') applyTheme(msg.theme); // 'light' | 'dark'
});
\`\`\`
## 10. Agent model
Data/side-effect bg-tasks need a capable model the default is too weak and
fabricates output or hallucinates tool names. Call \`list-models\` and set the
task's \`model\` to a strong ID from that list (its \`defaultModel\` is a safe
choice); never guess model IDs.
## 11. Verification loop
After writing files: tell the user the app URL
(\`http://<folder>.apps.localhost:3210/\`), note that edits hot-reload, and for
agent-backed apps trigger the agent once (\`run-background-task-agent\`) so data
exists before they open it. Then open it for them: \`app-navigation\` with
\`{ action: "open-app", appId: "<folder>" }\`.
`;
export default skill;

View file

@ -1,160 +0,0 @@
import { z } from 'zod';
import { stringify as stringifyYaml } from 'yaml';
import { MiniAppManifest } from '@x/shared/dist/mini-app.js';
const manifestSchema = stringifyYaml(z.toJSONSchema(MiniAppManifest)).trimEnd();
export const skill = String.raw`
# Build a Mini App
A *Mini App* is a small app the user opens inside Rowboat its own UI, powered by
their integrations and (optionally) a background agent. Apps live on disk at
\`~/.rowboat/apps/<id>/\` and are served at \`app://miniapp/<id>/\`:
\`\`\`
~/.rowboat/apps/<id>/
manifest.json # see schema below
dist/index.html # the app UI (self-contained), served via app://miniapp/<id>/
data.json # data the UI reads (produced by the app's agent; optional)
\`\`\`
You do NOT hand-write these files with file tools. Use **\`mini-app-install\`** to
write the folder, and **\`create-background-task\`** for the optional agent.
## 0. Should this even be an app? (intent gate)
- **Strong build it:** "make/build/create an app · mini app · dashboard for …",
"turn this into an app".
- **Medium CONFIRM FIRST:** the request could be a one-off answer OR a recurring
app (e.g. "show me my open PRs", "track competitor launches"). Ask once:
*"Want this as a Mini App you can reopen, or just a one-time answer?"* Build only
if they say app. (Building installs a folder, maybe a background agent, and may
prompt an OAuth connection too heavy for a casual question.)
- **Anti don't build:** a clear one-off lookup/question just answer it.
## 1. Scope the app
Decide: \`id\` (kebab slug), \`title\`, \`description\`, \`source\` (e.g. "GitHub"),
the Composio \`scope\` (toolkits it may use), and whether it is:
- **live** calls Composio when the user interacts (no agent), or
- **agent-backed** a background agent refreshes \`data.json\` on a schedule and the
UI just reads it (keeps tokens low; best for feeds/digests/dashboards).
## 2. Verify the wiring BEFORE building (required do not speculate)
Load the \`composio-integration\` skill. Then for each toolkit in scope:
1. Ensure it is connected (\`composio-connect-toolkit\` → user authorizes if needed).
2. Actually call the tools you intend to use (\`composio-search-tools\`
\`composio-execute-tool\`) and **inspect the real returned JSON**.
3. Derive the app's **data shape from those real responses** never guess field
names. This shape is the contract between the agent (or live calls) and the UI.
If a toolkit doesn't support managed OAuth2 (e.g. X/Twitter), tell the user it
can't be connected this way and pick a different integration or a browser-based
agent.
## 3. Write the UI (dist/index.html)
The app is a self-contained HTML document. It talks to Rowboat ONLY through the
bridge: include the shim and code against \`window.rowboat\`:
\`\`\`html
<script src="/__bridge__.js"></script>
\`\`\`
\`window.rowboat\` API:
- \`getData()\` / \`onData(cb)\` / \`refreshData()\` — the app's data. \`data.json\` is a
**served sibling of index.html**, so the app is self-contained: \`onData\`
fetches \`data.json\` via a relative URL (you can also just \`fetch('data.json')\`
yourself). \`refreshData()\` re-fetches. Rowboat does NOT inject data.
- \`getState()\` / \`setState(patch)\` — per-app persistent UI state.
- \`isConnected(scope)\` / \`connect(scope)\` — connection status / start OAuth.
- \`searchTools(scope, query)\` → [{slug,name,description}], and
\`callAction(scope, toolSlug, args)\` → tool result (rejects on error). Scope is
enforced against the manifest, so only declared toolkits work.
- \`fetch(url, opts?)\` → { ok, status, text, json } — a CORS-safe HTTP proxy
through the main process. **Use this for any third-party API, never the
browser's \`fetch\`** (public APIs rarely send CORS headers, so direct fetch
from the app origin fails with "Failed to fetch").
- \`ready()\` — call once after registering callbacks to receive initial data/state.
Keep it dependency-free (no remote CDNs unless truly needed; the app:// origin
allows them but offline-safe is better). Render loading / empty / error states.
**Support light AND dark.** The bridge applies the host theme to \`<html>\` — it
sets the class \`dark\` or \`light\` (and \`color-scheme\`) and updates live when the
user switches. Write CSS for BOTH: style defaults for light, then override under
\`html.dark { … }\` (or use CSS variables keyed on the theme). Never hard-code a
dark-only palette. \`rowboat.getTheme()\`/\`onTheme(cb)\` are also available if you
need JS. Do not build dark-only.
**Who writes this HTML:**
- If a **coding agent is active** (user toggled the Code chip), delegate authoring
via the \`code-with-agents\` skill — run it with \`cwd\` = the app's
\`~/.rowboat/apps/<id>/\` folder so it can iterate and test, then install.
- Otherwise, author the HTML yourself and install it with \`mini-app-install\`.
## 4. Data pipeline (agent-backed apps only)
Create a background task with \`create-background-task\` whose instructions:
- fetch the data via **Composio** if a toolkit exists, otherwise via the
builtin **\`fetch-url\`** tool (server-side HTTP, no CORS). **The bg-task agent
has NO shell** (\`executeCommand\` is disabled headlessly) and \`rowboat.fetch\`
is frontend-only never tell it to run a \`refresh.sh\`/script; it fetches via
\`fetch-url\` or Composio, and
- call **\`mini-app-set-data\`** with \`{ appId: "<id>", data: <object> }\`
pass the **object directly, never \`JSON.stringify\`** it. If a fetch fails,
**keep the last good data** (don't overwrite good series with empty ones).
Set the manifest's **\`dataContract\`** (\`requiredKeys\` + \`nonEmptyArrayKeys\`)
to the shape the UI needs \`mini-app-set-data\` enforces it, so a stale or
buggy run can't corrupt the app with the wrong shape or wipe series with empties.
The agent only RETURNS the data; \`mini-app-set-data\` writes \`data.json\`
atomically (deterministic path + write the agent never touches files). Set the
manifest's \`agent\` field to the task's slug.
**Give the task a capable model.** The default bg-task model is a light one that
fabricates output and hallucinates tool names on data/side-effect tasks. Set the
task's \`model\` (via \`create-background-task\`) to a strong reasoning model —
but only to an **allowed ID**: call **\`list-models\`** first and pick from what it
returns (arbitrary IDs are rejected). \`list-models.defaultModel\` is always a safe
capable choice. Give the task a sensible trigger (cron/window) from the
background-task skill.
## 5. Finalize
Call \`mini-app-install\` with the manifest + html (+ optional seed data). For
agent-backed apps, trigger the agent once (\`run-background-task-agent\`) so
\`data.json\` exists immediately instead of waiting for the first scheduled run.
Then **open it for the user**: call \`app-navigation\` with
\`{ action: "open-app", appId: "<id>" }\`. This opens the app in the middle pane
(under Mini Apps / its title) and shows an "Opened <app>" card in the chat. Only
do this once the app is installed AND its data is populated, so it renders ready.
## Common gotchas (read before building)
- **CORS:** third-party APIs usually block browser fetches. From the app UI use
\`rowboat.fetch(url)\`, not \`fetch(url)\`. If you don't, it fails with
"Failed to fetch" even though curl works.
- **No Composio toolkit for the data?** That's fine use \`rowboat.fetch\` from a
live app, or a bg-task that fetches with **\`fetch-url\`** and calls
\`mini-app-set-data\`. Don't force a toolkit that doesn't exist (e.g. there's no
FX/currency toolkit), and don't reach for a shell or MCP server for plain HTTP.
- **data.json shape:** pass a plain **object** to \`mini-app-set-data\`; never
\`JSON.stringify\` it first (double-encoding breaks the UI silently).
- **bg-task has no shell:** don't generate \`refresh.sh\` / \`executeCommand\`
steps for the data agent it can't run them headlessly.
- **model:** set a capable model on any data/side-effect bg-task; the default is
too weak and will fabricate results. Use \`list-models\` to get allowed IDs —
don't guess model IDs (unknown ones are rejected as "Model not allowed").
## Manifest schema (manifest.json)
\`\`\`yaml
` + manifestSchema + `
\`\`\`
`;
export default skill;

View file

@ -206,13 +206,13 @@ Embeds external content (YouTube videos, Figma designs, tweets, or generic links
### Iframe Block
Embeds an arbitrary web page or a locally-served dashboard in the note.
\`\`\`iframe
{"url": "http://localhost:3210/sites/example-dashboard/", "title": "Trend Dashboard", "height": 640}
{"url": "http://example-dashboard.apps.localhost:3210/?__rowboat_embed=1", "title": "Trend Dashboard", "height": 640}
\`\`\`
- \`url\` (required): Full URL to render. Use \`https://\` for remote sites, or \`http://localhost:3210/sites/<slug>/\` for local dashboards
- \`url\` (required): Full URL to render. Use \`https://\` for remote sites, or a Rowboat App origin (\`http://<folder>.apps.localhost:3210/?__rowboat_embed=1\`) for local dashboards
- \`title\` (optional): Title shown above the iframe
- \`height\` (optional): Height in pixels. Good dashboard defaults are 480-800
- \`allow\` (optional): Custom iframe \`allow\` attribute when the page needs extra browser capabilities
- Remote sites may refuse to render in iframes because of their own CSP / X-Frame-Options headers. When you need a reliable embed, create a local site in \`sites/<slug>/\` and use the localhost URL above
- Remote sites may refuse to render in iframes because of their own CSP / X-Frame-Options headers. When you need a reliable embed, build a Rowboat App (see the apps skill) and embed its origin with \`?__rowboat_embed=1\`
### Chart Block
Renders a chart from inline data.
@ -240,7 +240,7 @@ Renders a styled table from structured data.
- Insert blocks using \`file-editText\` just like any other content
- When the user asks for a chart, table, embed, or live dashboard use blocks rather than plain Markdown tables or image links
- When editing a note that already contains blocks, preserve them unless the user asks to change them
- For local dashboards and mini apps, put the site files in \`sites/<slug>/\` and point an \`iframe\` block at \`http://localhost:3210/sites/<slug>/\`
- For local dashboards and mini apps, build a Rowboat App (apps skill) and point an \`iframe\` block at \`http://<folder>.apps.localhost:3210/?__rowboat_embed=1\`
## Best Practices

View file

@ -16,7 +16,7 @@ import composioIntegrationSkill from "./composio-integration/skill.js";
import liveNoteSkill from "./live-note/skill.js";
import backgroundTaskSkill from "./background-task/skill.js";
import notifyUserSkill from "./notify-user/skill.js";
import buildMiniAppSkill from "./build-mini-app/skill.js";
import appsSkill from "./apps/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
@ -104,10 +104,10 @@ const definitions: SkillDefinition[] = [
content: codeWithAgentsSkill,
},
{
id: "build-mini-app",
title: "Build a Mini App",
summary: "Build a Mini App the user opens inside Rowboat — its own UI powered by their integrations and an optional background agent. Use when the user asks to make/build/create an app, mini app, or dashboard; for ambiguous 'show me X' requests, confirm whether they want an app first.",
content: buildMiniAppSkill,
id: "apps",
title: "Rowboat Apps",
summary: "Build a Rowboat App the user opens inside Rowboat — a static web app on its own origin, powered by their integrations and an optional background agent. Use when the user asks to make/build/create an app or dashboard; for ambiguous 'show me X' requests, confirm whether they want an app first.",
content: appsSkill,
},
{
id: "background-task",

View file

@ -15,7 +15,7 @@ import { WorkDir } from "../../config/config.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
import { MiniAppManifest } from "@x/shared/dist/mini-app.js";
import { RowboatAppManifestSchema } from "@x/shared/dist/rowboat-app.js";
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";
import type { CodeModeManager } from "../../code-mode/acp/manager.js";
@ -1124,14 +1124,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
case 'open-app': {
const appId = input.appId as string;
if (!appId) return { success: false, error: 'open-app requires appId' };
if (!appId) return { success: false, error: 'open-app requires appId (the app folder slug)' };
let appName = appId;
try {
const raw = await fs.readFile(path.join(WorkDir, 'apps', appId, 'manifest.json'), 'utf-8');
const m = JSON.parse(raw) as { title?: string };
if (m.title) appName = m.title;
const raw = await fs.readFile(path.join(WorkDir, 'apps', appId, 'rowboat-app.json'), 'utf-8');
const m = JSON.parse(raw) as { name?: string };
if (m.name) appName = m.name;
} catch {
return { success: false, error: `Mini App not found: ${appId}` };
return { success: false, error: `App not found: ${appId}` };
}
return { success: true, action: 'open-app', appId, appName };
}
@ -1514,54 +1514,17 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
isAvailable: async () => isComposioConfigured(),
},
'mini-app-install': {
description: "Install or update a Mini App on disk at ~/.rowboat/apps/<id>/. Writes manifest.json + dist/index.html (and optional initial data.json). Use this to materialize an app you built — do NOT hand-write these files with file tools. The HTML must be self-contained and include the bridge via <script src=\"/__bridge__.js\"></script>, coding against window.rowboat. After install, the app shows up in the Mini Apps gallery.",
'app-set-data': {
description: "Write a Rowboat App's data file — JSON its frontend reads via GET /_rowboat/data/<file>. Deterministic: you supply the content, code handles the path, atomicity (temp→rename), and the app's dataContracts validation. This is how a background task refreshes an app's data — the agent RETURNS the data; never hand-write files under apps/.",
inputSchema: z.object({
manifest: MiniAppManifest,
html: z.string().describe('Full self-contained HTML for dist/index.html.'),
data: z.unknown().optional().describe('Optional initial data.json content (only written if no data.json exists yet).'),
appFolder: z.string().describe('The app folder slug under ~/.rowboat/apps.'),
file: z.string().describe("Path relative to the app's data/ directory, e.g. \"data.json\"."),
data: z.unknown().describe('Full payload to store. Pass the object directly — do NOT JSON.stringify it.'),
}),
execute: async ({ manifest, html, data }: { manifest: z.infer<typeof MiniAppManifest>; html: string; data?: unknown }) => {
execute: async ({ appFolder, file, data }: { appFolder: string; file: string; data: unknown }) => {
try {
const m = MiniAppManifest.parse(manifest);
const dir = path.join(WorkDir, 'apps', m.id);
const dist = path.join(dir, 'dist');
await fs.mkdir(dist, { recursive: true });
// Atomic writes (temp→rename): an app opened mid-install must never
// see a partially-written manifest or index.html (blank screen).
const writeAtomic = async (p: string, content: string) => {
const tmp = `${p}.tmp`;
await fs.writeFile(tmp, content);
await fs.rename(tmp, p);
};
await writeAtomic(path.join(dist, 'index.html'), html);
await writeAtomic(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2));
if (data !== undefined) {
let payload: unknown = data;
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { payload = undefined; } }
if (payload !== undefined && payload !== null && typeof payload === 'object') {
// sibling of index.html so the app can fetch('data.json')
const dataPath = path.join(dist, 'data.json');
try { await fs.access(dataPath); } catch { await fs.writeFile(dataPath, JSON.stringify(payload, null, 2)); }
}
}
return { success: true, id: m.id, url: `app://miniapp/${m.id}/index.html` };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
},
'mini-app-set-data': {
description: "Write a Mini App's data.json — the JSON its frontend reads via rowboat.getData/onData. The path is derived from appId and the write is atomic (temp→rename), so you only supply the content. This is how a background task refreshes an app's data; the agent returns the data, the write is deterministic.",
inputSchema: z.object({
appId: z.string().describe('The app id (folder name under ~/.rowboat/apps).'),
data: z.unknown().describe('Full data payload to store as data.json (matching the app data schema).'),
}),
execute: async ({ appId, data }: { appId: string; data: unknown }) => {
try {
// Guard the #1 mistake: passing a JSON *string* (JSON.stringify'd)
// instead of the object. Auto-parse a string; reject non-objects so
// the UI never gets a double-encoded blob it can't read.
// #1 agent mistake: passing a stringified payload. Auto-parse
// strings; reject anything that isn't an object/array.
let payload: unknown = data;
if (typeof payload === 'string') {
try { payload = JSON.parse(payload); }
@ -1570,36 +1533,50 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
if (payload === null || typeof payload !== 'object') {
return { success: false, error: 'data must be a JSON object or array.' };
}
const dir = path.join(WorkDir, 'apps', appId);
// The app must already exist — never create a stray folder, and load
// its dataContract to guard the write.
let manifest: z.infer<typeof MiniAppManifest>;
// The app must exist with a valid manifest — never create stray folders.
const dir = path.join(WorkDir, 'apps', appFolder);
let manifest: z.infer<typeof RowboatAppManifestSchema>;
try {
manifest = MiniAppManifest.parse(JSON.parse(await fs.readFile(path.join(dir, 'manifest.json'), 'utf-8')));
manifest = RowboatAppManifestSchema.parse(JSON.parse(await fs.readFile(path.join(dir, 'rowboat-app.json'), 'utf-8')));
} catch {
return { success: false, error: `No installed Mini App "${appId}". Install it first (mini-app-install).` };
return { success: false, error: `No app "${appFolder}" (missing or invalid rowboat-app.json).` };
}
const contract = manifest.dataContract;
if (contract && !Array.isArray(payload)) {
const obj = payload as Record<string, unknown>;
const missing = (contract.requiredKeys ?? []).filter((k) => obj[k] === undefined || obj[k] === null);
if (missing.length) {
return { success: false, error: `data is missing required key(s): ${missing.join(', ')}. Match the app's data shape — do NOT write a different shape; keep the last good data.` };
// Same path rules as the data API: confined to data/.
const dataRoot = path.join(dir, 'data');
const relNorm = path.posix.normalize(file).replace(/^\/+/, '');
if (!relNorm || relNorm === '.' || relNorm.startsWith('..') || relNorm.includes('\0') || relNorm.includes('\\')) {
return { success: false, error: `invalid file path: ${file}` };
}
const abs = path.resolve(dataRoot, relNorm);
if (abs !== dataRoot && !abs.startsWith(dataRoot + path.sep)) {
return { success: false, error: `file path escapes data/: ${file}` };
}
const contract = manifest.dataContracts.find((c) => path.posix.normalize(c.file) === relNorm);
if (contract) {
if (Array.isArray(payload) && (contract.requiredKeys.length || contract.nonEmptyArrayKeys.length)) {
return { success: false, error: `${relNorm} must be a JSON object to satisfy its data contract. Keep the last good data — do not retry with a different shape.` };
}
const badArrays = (contract.nonEmptyArrayKeys ?? []).filter((k) => !Array.isArray(obj[k]) || (obj[k] as unknown[]).length === 0);
if (badArrays.length) {
return { success: false, error: `these key(s) must be non-empty arrays: ${badArrays.join(', ')}. Don't overwrite good series with empty ones — keep the last good data.` };
if (!Array.isArray(payload)) {
const obj = payload as Record<string, unknown>;
const missing = contract.requiredKeys.filter((k) => obj[k] === undefined || obj[k] === null);
if (missing.length) {
return { success: false, error: `data is missing required key(s): ${missing.join(', ')}. Match the app's data shape and keep the last good data — do NOT retry with a different shape.` };
}
const badArrays = contract.nonEmptyArrayKeys.filter((k) => !Array.isArray(obj[k]) || (obj[k] as unknown[]).length === 0);
if (badArrays.length) {
return { success: false, error: `these key(s) must be non-empty arrays: ${badArrays.join(', ')}. Don't overwrite good series with empty ones — keep the last good data.` };
}
}
}
// data.json is a served sibling of index.html so apps fetch it
// via a relative URL (app://miniapp/<id>/data.json).
const distDir = path.join(dir, 'dist');
await fs.mkdir(distDir, { recursive: true });
const dataPath = path.join(distDir, 'data.json');
const tmp = `${dataPath}.tmp`;
await fs.mkdir(path.dirname(abs), { recursive: true });
const tmp = `${abs}.tmp-${Math.random().toString(16).slice(2, 10)}`;
await fs.writeFile(tmp, JSON.stringify(payload, null, 2));
await fs.rename(tmp, dataPath);
return { success: true, appId };
await fs.rename(tmp, abs);
return { success: true, appFolder, file: relNorm };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}

View file

@ -0,0 +1,43 @@
import path from 'path';
import { WorkDir } from '../config/config.js';
// Rowboat Apps constants (spec §3). All apps constants live here; values the
// renderer needs are mirrored through IPC responses, never imported directly.
export const APPS_DIR = path.join(WorkDir, 'apps');
export const APPS_PORT = 3210; // reuses the local-sites port (D8)
export const APPS_HOST_SUFFIX = '.apps.localhost'; // full host: <slug>.apps.localhost:3210
export const CONTROL_HOST = 'apps.localhost'; // control endpoints only (§6.4)
export const REGISTRY_REPO = process.env.ROWBOAT_APPS_REGISTRY || 'rowboatlabs/apps-registry';
export const REGISTRY_BRANCH = 'main';
export const CATALOG_CACHE_PATH = path.join(WorkDir, 'config', 'apps-catalog.json');
export const CATALOG_TTL_MS = 300_000; // 5 min, matches raw CDN cache horizon
export const MAX_BUNDLE_COMPRESSED = 100 * 1024 * 1024; // 100 MB (§12.1)
export const MAX_BUNDLE_UNCOMPRESSED = 500 * 1024 * 1024; // 500 MB (§12.1)
export const MAX_BUNDLE_ENTRIES = 10_000; // §12.1
export const MAX_DATA_FILE_BYTES = 50 * 1024 * 1024; // 50 MB (§7.3 PUT limit)
export const MAX_PROXY_RESPONSE_BYTES = 5 * 1024 * 1024; // 5 MB (§7.5)
export const PROXY_TIMEOUT_MS = 30_000; // §7.5
export const MAX_LLM_REQUEST_BYTES = 256 * 1024; // 256 KB (§7.6)
export const LLM_MAX_OUTPUT_TOKENS = 4096; // §7.6 (requests clamp to it)
export const LLM_MAX_CONCURRENT_PER_APP = 2; // §7.6
export const MAX_COPILOT_PROMPT_BYTES = 16 * 1024; // 16 KB (§7.7)
export const COPILOT_RUN_TIMEOUT_MS = 600_000; // 10 min (§7.7)
export const COPILOT_MAX_CONCURRENT_PER_APP = 1; // §7.7
export const FOLDER_SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; // §4.1
// Networking note (§3): *.apps.localhost resolves to loopback in Chromium
// only. Main-process callers MUST connect to 127.0.0.1:APPS_PORT and set the
// Host header explicitly — never rely on OS DNS for *.localhost names.
export function appOrigin(folderSlug: string): string {
return `http://${folderSlug}${APPS_HOST_SUFFIX}:${APPS_PORT}`;
}

View file

@ -0,0 +1,189 @@
import fs from 'fs/promises';
import type { Dirent } from 'fs';
import path from 'path';
import {
RowboatAppManifestSchema,
AppInstallRecordSchema,
AppPublishRecordSchema,
type RowboatAppManifest,
type AppSummary,
} from '@x/shared/dist/rowboat-app.js';
import { APPS_DIR, FOLDER_SLUG_RE, appOrigin } from './constants.js';
// Local app management (spec §5). Scan-on-demand; correctness never depends
// on caching.
export function appDir(folder: string): string {
return path.join(APPS_DIR, folder);
}
async function readJsonIfExists(file: string): Promise<unknown | undefined> {
try {
return JSON.parse(await fs.readFile(file, 'utf-8'));
} catch {
return undefined;
}
}
/** Derive the materialized bg-task slug for a bundled agent file (§8.3). */
export function agentTaskSlug(folder: string, agentFile: string): string {
const base = agentFile.replace(/\.yaml$/, '');
return `app--${folder}--${base}`;
}
async function summarizeApp(folder: string): Promise<AppSummary | null> {
const dir = appDir(folder);
const manifestPath = path.join(dir, 'rowboat-app.json');
let manifestRaw: string;
try {
manifestRaw = await fs.readFile(manifestPath, 'utf-8');
} catch {
return null; // no manifest → not an app folder (old prototype folders are ignored)
}
let manifest: RowboatAppManifest | undefined;
let manifestError: string | undefined;
try {
const parsed = RowboatAppManifestSchema.safeParse(JSON.parse(manifestRaw));
if (parsed.success) {
manifest = parsed.data;
// entry/icon traversal guard (§4.2): must resolve inside dist/.
for (const rel of [parsed.data.entry, parsed.data.icon].filter((v): v is string => !!v)) {
if (rel.includes('..') || rel.startsWith('/') || rel.includes('\\') || rel.includes('\0')) {
manifest = undefined;
manifestError = `unsafe path in manifest: ${rel}`;
break;
}
}
} else {
manifestError = parsed.error.issues
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
.join('; ');
}
} catch (e) {
manifestError = `invalid JSON: ${e instanceof Error ? e.message : String(e)}`;
}
const installRaw = await readJsonIfExists(path.join(dir, '.rowboat-install.json'));
const install = installRaw !== undefined ? AppInstallRecordSchema.safeParse(installRaw) : undefined;
const publishRaw = await readJsonIfExists(path.join(dir, '.rowboat-publish.json'));
const publish = publishRaw !== undefined ? AppPublishRecordSchema.safeParse(publishRaw) : undefined;
let hasDist = false;
try {
hasDist = (await fs.stat(path.join(dir, 'dist'))).isDirectory();
} catch { /* absent */ }
return {
folder,
status: manifest ? 'ok' : 'invalid',
...(manifest ? { manifest } : {}),
...(manifestError ? { manifestError } : {}),
origin: appOrigin(folder),
kind: install?.success ? 'installed' : 'local',
...(install?.success ? { install: install.data } : {}),
...(publish?.success ? { publish: publish.data } : {}),
hasDist,
agentSlugs: (manifest?.agents ?? []).map((f) => agentTaskSlug(folder, f)),
};
}
/** List all apps under APPS_DIR (§5.1). Invalid manifests are surfaced, not hidden. */
export async function listApps(): Promise<AppSummary[]> {
let entries: Dirent[];
try {
entries = await fs.readdir(APPS_DIR, { withFileTypes: true });
} catch {
return [];
}
const out: AppSummary[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!FOLDER_SLUG_RE.test(entry.name)) {
if (!entry.name.startsWith('.')) {
console.warn(`[Apps] ignoring folder with invalid slug: ${entry.name}`);
}
continue;
}
const summary = await summarizeApp(entry.name);
if (summary) out.push(summary);
}
return out.sort((a, b) => a.folder.localeCompare(b.folder));
}
export async function getApp(folder: string): Promise<AppSummary | null> {
if (!FOLDER_SLUG_RE.test(folder)) return null;
return summarizeApp(folder);
}
const SCAFFOLD_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Rowboat app</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; display: grid; place-items: center; min-height: 100vh; margin: 0; }
.card { text-align: center; color: #555; }
code { background: rgba(127,127,127,.15); padding: 2px 6px; border-radius: 6px; }
</style>
</head>
<body>
<div class="card">
<h1 id="name">Loading</h1>
<p id="meta"></p>
<p>Edit <code>dist/index.html</code> to build this app.</p>
</div>
<script>
fetch('/_rowboat/app').then(function (r) { return r.json(); }).then(function (a) {
document.getElementById('name').textContent = a.name;
document.getElementById('meta').textContent = 'v' + a.version + ' · ' + a.folder;
document.title = a.name;
}).catch(function () {
document.getElementById('name').textContent = 'Host API unreachable';
});
</script>
</body>
</html>
`;
/** Create a minimal valid app scaffold (§5.2). */
export async function createApp(input: { folder: string; name: string; description: string }): Promise<AppSummary> {
const { folder, name, description } = input;
if (!FOLDER_SLUG_RE.test(folder)) throw new Error(`invalid_folder: "${folder}" must match ${FOLDER_SLUG_RE}`);
const dir = appDir(folder);
try {
await fs.mkdir(dir, { recursive: false });
} catch {
throw new Error(`folder_exists: ${folder}`);
}
const manifest = RowboatAppManifestSchema.parse({
schemaVersion: 1,
name,
version: '0.1.0',
description,
});
await fs.mkdir(path.join(dir, 'dist'), { recursive: true });
await fs.mkdir(path.join(dir, 'data'), { recursive: true });
// Pretty-printed manifest (§4.2) — keeps diffs clean in the author's repo.
await fs.writeFile(path.join(dir, 'rowboat-app.json'), JSON.stringify(manifest, null, 2) + '\n');
await fs.writeFile(path.join(dir, 'dist', 'index.html'), SCAFFOLD_HTML);
const summary = await summarizeApp(folder);
if (!summary) throw new Error('scaffold_failed');
return summary;
}
/** Delete a local app (§5.3). Installed apps must go through uninstall. */
export async function deleteApp(folder: string): Promise<void> {
if (!FOLDER_SLUG_RE.test(folder)) throw new Error(`invalid_folder: ${folder}`);
const dir = appDir(folder);
try {
await fs.access(path.join(dir, '.rowboat-install.json'));
throw new Error('app_is_installed: use uninstall instead');
} catch (e) {
if (e instanceof Error && e.message.startsWith('app_is_installed')) throw e;
// no install record → fine to delete
}
await fs.rm(dir, { recursive: true, force: true });
}

View file

@ -0,0 +1,762 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import type { Server } from 'node:http';
import chokidar, { type FSWatcher } from 'chokidar';
import express from 'express';
import { RowboatAppManifestSchema, type RowboatAppManifest } from '@x/shared/dist/rowboat-app.js';
import {
APPS_DIR,
APPS_PORT,
APPS_HOST_SUFFIX,
CONTROL_HOST,
FOLDER_SLUG_RE,
MAX_DATA_FILE_BYTES,
appOrigin,
} from './constants.js';
// Rowboat Apps server (spec §6§7). Adapted from the deleted local-sites
// server: one HTTP server on 127.0.0.1:3210, routing by Host header to
// per-app origins (<slug>.apps.localhost). Serves static files from each
// app's dist/ and the same-origin Host API under /_rowboat/*.
const RELOAD_DEBOUNCE_MS = 140;
const EVENTS_RETRY_MS = 1000;
const EVENTS_HEARTBEAT_MS = 15000;
const HOST_RE = /^([a-z0-9]+(?:-[a-z0-9]+)*)\.apps\.localhost$/;
const TEXT_EXTENSIONS = new Set(['.css', '.html', '.js', '.json', '.map', '.mjs', '.svg', '.txt', '.xml']);
const MIME_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.wasm': 'application/wasm',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.xml': 'application/xml; charset=utf-8',
};
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let server: Server | null = null;
let startPromise: Promise<void> | null = null;
let watcher: FSWatcher | null = null;
let serverError: string | null = null;
let currentTheme: 'light' | 'dark' = 'light';
// SSE clients per app slug.
const eventClients = new Map<string, Set<express.Response>>();
// Debounce timers keyed `<slug>|<area>`.
const reloadTimers = new Map<string, NodeJS.Timeout>();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function sendError(res: express.Response, status: number, code: string, message: string): void {
res.status(status).json({ error: { code, message } });
}
function appDirFor(slug: string): string {
return path.join(APPS_DIR, slug);
}
function loadManifest(slug: string): { manifest?: RowboatAppManifest; error?: string } {
try {
const raw = fs.readFileSync(path.join(appDirFor(slug), 'rowboat-app.json'), 'utf-8');
const parsed = RowboatAppManifestSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
return { error: parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ') };
}
return { manifest: parsed.data };
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) };
}
}
/**
* Normalize a requested path and confine it to `root`. Returns the absolute
* path or null when the request escapes. (Carried over from local-sites'
* resolveRequestedPath; dotfiles are allowed.)
*/
function confinePath(root: string, requestPath: string): string | null {
const normalized = path.posix.normalize(requestPath);
const relative = normalized.replace(/^\/+/, '');
if (!relative || relative === '.' || relative.startsWith('..') || relative.includes('\0') || relative.includes('\\')) {
return null;
}
const absolute = path.resolve(root, relative);
if (absolute !== root && !absolute.startsWith(root + path.sep)) return null;
return absolute;
}
function insideRoot(root: string, candidate: string): boolean {
return candidate === root || candidate.startsWith(root + path.sep);
}
/** Realpath escape check for existing paths (symlink guard). */
function realpathEscapes(root: string, existingPath: string): boolean {
try {
const realRoot = fs.realpathSync(root);
const real = fs.realpathSync(existingPath);
return !insideRoot(realRoot, real);
} catch {
return true;
}
}
function html503(res: express.Response, title: string, detail: string): void {
res.status(503).setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
<style>body{font-family:-apple-system,system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;color:#666}
.card{max-width:520px;padding:24px;text-align:center}</style></head>
<body><div class="card"><h2>${title}</h2><p>${detail}</p></div></body></html>`);
}
// ---------------------------------------------------------------------------
// Bootstrap injection (§6.5)
// ---------------------------------------------------------------------------
const BOOTSTRAP = String.raw`<script>
(() => {
let reloadRequested = false;
let source = null;
const scheduleReload = () => {
if (reloadRequested) return;
reloadRequested = true;
try { source?.close(); } catch {}
window.setTimeout(() => { window.location.reload(); }, 80);
};
const connect = () => {
if (typeof EventSource === 'undefined') return;
source = new EventSource(new URL('/_rowboat/events', window.location.origin).toString());
source.addEventListener('message', (event) => {
try {
const payload = JSON.parse(event.data);
if (payload?.type !== 'change') return;
if (payload.area === 'data') {
// Cancelable: apps that re-fetch data in place call preventDefault().
const domEvent = new CustomEvent('rowboat:data-change', { cancelable: true, detail: { path: payload.path } });
const proceed = window.dispatchEvent(domEvent);
if (proceed) scheduleReload();
return;
}
scheduleReload();
} catch {}
});
window.addEventListener('beforeunload', () => { try { source?.close(); } catch {} }, { once: true });
};
connect();
// Autosize is opt-in for inline embeds only (§6.5): the full-height app view
// must keep normal page scrolling.
const params = new URLSearchParams(window.location.search);
if (params.get('__rowboat_embed') !== '1') return;
if (window.parent === window || typeof window.parent?.postMessage !== 'function') return;
const MIN_HEIGHT = 240;
let animationFrameId = 0;
let lastHeight = 0;
const applyEmbeddedStyles = () => {
if (document.documentElement) document.documentElement.style.overflowY = 'hidden';
if (document.body) document.body.style.overflowY = 'hidden';
};
const measureHeight = () => {
const root = document.documentElement, body = document.body;
return Math.max(root?.scrollHeight ?? 0, root?.offsetHeight ?? 0, root?.clientHeight ?? 0,
body?.scrollHeight ?? 0, body?.offsetHeight ?? 0, body?.clientHeight ?? 0);
};
const publishHeight = () => {
animationFrameId = 0;
applyEmbeddedStyles();
const nextHeight = Math.max(MIN_HEIGHT, Math.ceil(measureHeight()));
if (Math.abs(nextHeight - lastHeight) < 2) return;
lastHeight = nextHeight;
window.parent.postMessage({ type: 'rowboat:iframe-height', height: nextHeight, href: window.location.href }, '*');
};
const schedulePublish = () => {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(publishHeight);
};
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedulePublish) : null;
if (resizeObserver && document.documentElement) resizeObserver.observe(document.documentElement);
if (resizeObserver && document.body) resizeObserver.observe(document.body);
const mutationObserver = new MutationObserver(schedulePublish);
if (document.documentElement) {
mutationObserver.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
}
window.addEventListener('load', schedulePublish);
window.addEventListener('resize', schedulePublish);
if (document.fonts?.addEventListener) document.fonts.addEventListener('loadingdone', schedulePublish);
for (const delay of [0, 50, 150, 300, 600, 1200]) setTimeout(schedulePublish, delay);
schedulePublish();
})();
</script>`;
function injectBootstrap(htmlContent: string): string {
if (/<\/body>/i.test(htmlContent)) return htmlContent.replace(/<\/body>/i, `${BOOTSTRAP}\n</body>`);
return `${htmlContent}\n${BOOTSTRAP}`;
}
// ---------------------------------------------------------------------------
// SSE (§6.5, §7.2)
// ---------------------------------------------------------------------------
function removeEventClient(slug: string, res: express.Response): void {
const clients = eventClients.get(slug);
if (!clients) return;
clients.delete(res);
if (clients.size === 0) eventClients.delete(slug);
}
function broadcast(slug: string, payload: Record<string, unknown>): void {
const clients = eventClients.get(slug);
if (!clients || clients.size === 0) return;
const data = `data: ${JSON.stringify(payload)}\n\n`;
for (const res of Array.from(clients)) {
try {
res.write(data);
} catch {
removeEventClient(slug, res);
}
}
}
function scheduleChangeBroadcast(slug: string, area: 'dist' | 'data', relPath: string): void {
const key = `${slug}|${area}`;
const existing = reloadTimers.get(key);
if (existing) clearTimeout(existing);
reloadTimers.set(key, setTimeout(() => {
reloadTimers.delete(key);
broadcast(slug, { type: 'change', area, path: relPath });
}, RELOAD_DEBOUNCE_MS));
}
function handleEventsRequest(slug: string, req: express.Request, res: express.Response): void {
const clients = eventClients.get(slug) ?? new Set<express.Response>();
eventClients.set(slug, clients);
clients.add(res);
res.status(200);
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
res.write(`retry: ${EVENTS_RETRY_MS}\n`);
res.write(`event: ready\ndata: {"ok":true}\n\n`);
const heartbeat = setInterval(() => {
try {
res.write(`: keepalive ${Date.now()}\n\n`);
} catch {
clearInterval(heartbeat);
removeEventClient(slug, res);
}
}, EVENTS_HEARTBEAT_MS);
const cleanup = () => {
clearInterval(heartbeat);
removeEventClient(slug, res);
};
req.on('close', cleanup);
res.on('close', cleanup);
}
/** Renderer-reported theme (§7.1); broadcast to all connected apps (§7.2). */
export function setAppsTheme(theme: 'light' | 'dark'): void {
if (theme === currentTheme) return;
currentTheme = theme;
for (const slug of eventClients.keys()) {
broadcast(slug, { type: 'theme', theme });
}
}
// ---------------------------------------------------------------------------
// Data API (§7.3)
// ---------------------------------------------------------------------------
async function readBody(req: express.Request, limit: number): Promise<Buffer | null> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
req.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > limit) {
resolve(null); // over limit
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function contractFor(manifest: RowboatAppManifest, relPath: string) {
return manifest.dataContracts.find((c) => path.posix.normalize(c.file) === relPath);
}
/**
* Validate a payload against a data contract. Returns null when valid, else
* the failure message naming the offending keys.
*/
export function checkDataContract(
contract: { requiredKeys: string[]; nonEmptyArrayKeys: string[] },
payload: unknown,
): string | null {
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
// Contracts describe top-level object keys; an array/None payload
// cannot satisfy requiredKeys.
if (contract.requiredKeys.length || contract.nonEmptyArrayKeys.length) {
return 'payload must be a JSON object to satisfy the data contract';
}
return null;
}
const obj = payload as Record<string, unknown>;
const missing = contract.requiredKeys.filter((k) => obj[k] === undefined || obj[k] === null);
if (missing.length) return `missing required key(s): ${missing.join(', ')}`;
const badArrays = contract.nonEmptyArrayKeys.filter((k) => !Array.isArray(obj[k]) || (obj[k] as unknown[]).length === 0);
if (badArrays.length) return `key(s) must be non-empty arrays: ${badArrays.join(', ')}`;
return null;
}
async function handleDataApi(
slug: string,
manifest: RowboatAppManifest,
req: express.Request,
res: express.Response,
pathname: string,
): Promise<void> {
const dataRoot = path.join(appDirFor(slug), 'data');
// GET /_rowboat/data?list=<dir> — non-recursive listing.
if (pathname === '/_rowboat/data' && req.method === 'GET') {
const listParam = typeof req.query.list === 'string' ? req.query.list : '';
const dirRel = listParam === '' || listParam === '.' ? '.' : listParam;
const abs = dirRel === '.' ? dataRoot : confinePath(dataRoot, dirRel);
if (!abs) return sendError(res, 403, 'forbidden_path', 'path escapes data/');
let entries: Array<{ path: string; kind: 'file' | 'dir'; size: number; mtime: string }> = [];
try {
if (realpathEscapes(dataRoot, abs)) return sendError(res, 403, 'forbidden_path', 'path escapes data/');
const dirents = await fsp.readdir(abs, { withFileTypes: true });
entries = await Promise.all(dirents.map(async (d) => {
const p = path.join(abs, d.name);
const stat = await fsp.stat(p).catch(() => null);
const rel = path.posix.join(dirRel === '.' ? '' : dirRel, d.name);
return {
path: rel,
kind: (d.isDirectory() ? 'dir' : 'file') as 'file' | 'dir',
size: stat?.size ?? 0,
mtime: stat ? new Date(stat.mtimeMs).toISOString() : '',
};
}));
} catch {
entries = []; // missing dir → empty, not error (§7.3)
}
res.json({ entries });
return;
}
// File operations: /_rowboat/data/<path>
const relRaw = pathname.slice('/_rowboat/data/'.length);
let rel: string;
try {
rel = decodeURIComponent(relRaw);
} catch {
return sendError(res, 400, 'bad_request', 'malformed path encoding');
}
const relNorm = path.posix.normalize(rel);
const abs = confinePath(dataRoot, relNorm);
if (!abs) return sendError(res, 403, 'forbidden_path', 'path escapes data/');
if (req.method === 'GET') {
try {
const stat = await fsp.stat(abs);
if (!stat.isFile()) return sendError(res, 404, 'not_found', 'no such file');
if (realpathEscapes(dataRoot, abs)) return sendError(res, 403, 'forbidden_path', 'symlink escapes data/');
res.status(200);
res.setHeader('Content-Type', MIME_TYPES[path.extname(abs).toLowerCase()] ?? 'application/octet-stream');
res.setHeader('Cache-Control', 'no-store');
fs.createReadStream(abs).pipe(res);
} catch {
sendError(res, 404, 'not_found', 'no such file');
}
return;
}
if (req.method === 'PUT') {
const body = await readBody(req, MAX_DATA_FILE_BYTES);
if (body === null) return sendError(res, 413, 'too_large', `body exceeds ${MAX_DATA_FILE_BYTES} bytes`);
const contract = contractFor(manifest, relNorm);
if (contract) {
let payload: unknown;
try {
payload = JSON.parse(body.toString('utf-8'));
} catch {
return sendError(res, 422, 'contract_violation', `${relNorm} has a data contract; body must be valid JSON`);
}
const violation = checkDataContract(contract, payload);
if (violation) {
return sendError(res, 422, 'contract_violation', `${relNorm}: ${violation}. Last-good data is untouched — do not retry with a different shape.`);
}
}
// Guard against writing through a symlinked parent that escapes data/.
const parent = path.dirname(abs);
await fsp.mkdir(parent, { recursive: true });
if (realpathEscapes(dataRoot, parent)) return sendError(res, 403, 'forbidden_path', 'path escapes data/');
const tmp = `${abs}.tmp-${crypto.randomBytes(4).toString('hex')}`;
await fsp.writeFile(tmp, body);
await fsp.rename(tmp, abs);
res.json({ ok: true, size: body.length });
return;
}
if (req.method === 'DELETE') {
try {
const stat = await fsp.stat(abs);
if (stat.isDirectory()) return sendError(res, 400, 'is_directory', 'V1 deletes files only');
await fsp.unlink(abs);
res.json({ ok: true });
} catch {
sendError(res, 404, 'not_found', 'no such file');
}
return;
}
sendError(res, 405, 'method_not_allowed', `${req.method} not supported on data paths`);
}
// ---------------------------------------------------------------------------
// Host API dispatch (§7)
// ---------------------------------------------------------------------------
// M2 endpoints (§7.47.7) register here (tools/fetch/llm/copilot).
type HostApiHandler = (
slug: string,
manifest: RowboatAppManifest,
req: express.Request,
res: express.Response,
) => Promise<void>;
const extraHostApiRoutes = new Map<string, HostApiHandler>();
/** Register an additional POST /_rowboat/<name> endpoint (used by M2 wiring). */
export function registerHostApiRoute(pathname: string, handler: HostApiHandler): void {
extraHostApiRoutes.set(pathname, handler);
}
async function handleHostApi(
slug: string,
manifest: RowboatAppManifest,
req: express.Request,
res: express.Response,
pathname: string,
): Promise<void> {
// Anti-CSRF (D17): every non-GET request needs the custom header AND a
// matching Origin. GETs are exempt (side-effect-free; EventSource cannot
// send custom headers).
if (req.method !== 'GET' && req.method !== 'HEAD') {
if (req.headers['x-rowboat-app'] === undefined) {
return sendError(res, 403, 'missing_app_header', 'non-GET /_rowboat requests must set X-Rowboat-App: 1');
}
const origin = req.headers.origin;
if (typeof origin !== 'string' || origin.toLowerCase() !== appOrigin(slug)) {
return sendError(res, 403, 'cross_origin_rejected', 'Origin must be present and equal to the app\'s own origin');
}
}
if (pathname === '/_rowboat/app' && req.method === 'GET') {
res.json({
name: manifest.name,
version: manifest.version,
folder: slug,
description: manifest.description,
theme: currentTheme,
});
return;
}
if (pathname === '/_rowboat/events' && req.method === 'GET') {
handleEventsRequest(slug, req, res);
return;
}
if (pathname === '/_rowboat/data' || pathname.startsWith('/_rowboat/data/')) {
await handleDataApi(slug, manifest, req, res, pathname);
return;
}
const extra = extraHostApiRoutes.get(pathname);
if (extra) {
await extra(slug, manifest, req, res);
return;
}
// Reserved paths (§7.8) and anything unknown.
sendError(res, 404, 'unknown_endpoint', `no such endpoint: ${pathname}`);
}
// ---------------------------------------------------------------------------
// Static serving (§6.3)
// ---------------------------------------------------------------------------
async function respondWithFile(res: express.Response, filePath: string, method: string): Promise<void> {
const extension = path.extname(filePath).toLowerCase();
const mimeType = MIME_TYPES[extension] || 'application/octet-stream';
const stats = await fsp.stat(filePath);
res.status(200);
res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Length', String(stats.size));
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
if (method === 'HEAD') {
res.end();
return;
}
if (TEXT_EXTENSIONS.has(extension)) {
let text = await fsp.readFile(filePath, 'utf8');
if (extension === '.html') text = injectBootstrap(text);
res.setHeader('Content-Length', String(Buffer.byteLength(text)));
res.end(text);
return;
}
res.end(await fsp.readFile(filePath));
}
async function handleStatic(
slug: string,
manifest: RowboatAppManifest,
req: express.Request,
res: express.Response,
pathname: string,
): Promise<void> {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return sendError(res, 405, 'method_not_allowed', 'static paths accept GET/HEAD only');
}
const distRoot = path.join(appDirFor(slug), 'dist');
if (!fs.existsSync(distRoot) || !fs.statSync(distRoot).isDirectory()) {
return html503(res, 'App has no built output', `${manifest.name}” has no dist/ directory yet. The copilot writes browser-ready files into dist/.`);
}
const entryRel = manifest.entry;
const requestPath = pathname === '/' ? `/${entryRel}` : pathname;
const resolved = confinePath(distRoot, decodeURIComponent(requestPath));
if (!resolved) return sendError(res, 400, 'bad_path', 'invalid path');
const serveChecked = async (p: string) => {
if (realpathEscapes(distRoot, p)) {
sendError(res, 403, 'forbidden_path', 'path escapes dist/');
return;
}
await respondWithFile(res, p, req.method);
};
if (fs.existsSync(resolved)) {
const stat = fs.statSync(resolved);
if (stat.isDirectory()) {
const indexPath = path.join(resolved, 'index.html');
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
await serveChecked(indexPath);
return;
}
} else if (stat.isFile()) {
await serveChecked(resolved);
return;
}
}
// Extensionless miss → SPA fallback to the manifest entry (§6.3).
if (!path.extname(resolved)) {
const entryAbs = confinePath(distRoot, `/${entryRel}`);
if (entryAbs && fs.existsSync(entryAbs) && fs.statSync(entryAbs).isFile()) {
await serveChecked(entryAbs);
return;
}
return html503(res, 'App entry not found', `dist/${entryRel} does not exist.`);
}
sendError(res, 404, 'not_found', 'asset not found');
}
// ---------------------------------------------------------------------------
// Router (§6.2)
// ---------------------------------------------------------------------------
function createApp(): express.Express {
const appServer = express();
appServer.disable('x-powered-by');
appServer.use((req, res) => {
void (async () => {
const rawHost = (req.headers.host ?? '').split(':')[0].toLowerCase();
const pathname = (req.url.split('?')[0] || '/');
// Control host (§6.4)
if (rawHost === CONTROL_HOST) {
if (pathname === '/health' && req.method === 'GET') {
res.json({ ok: true, appsDir: APPS_DIR, port: APPS_PORT });
return;
}
sendError(res, 404, 'not_found', 'control host serves no app content');
return;
}
// App hosts; anything else is the DNS-rebinding guard (§6.2 step 3).
const match = HOST_RE.exec(rawHost);
if (!match) {
res.status(421).json({ error: { code: 'misdirected', message: `unrecognized host: ${rawHost}` } });
return;
}
const slug = match[1];
if (!FOLDER_SLUG_RE.test(slug) || !fs.existsSync(appDirFor(slug))) {
sendError(res, 404, 'app_not_found', `no app folder named "${slug}"`);
return;
}
const { manifest, error } = loadManifest(slug);
if (!manifest) {
html503(res, 'Invalid app', `${slug}” has a missing or invalid rowboat-app.json: ${error ?? 'unknown error'}`);
return;
}
if (pathname === '/_rowboat' || pathname.startsWith('/_rowboat/')) {
await handleHostApi(slug, manifest, req, res, pathname);
return;
}
await handleStatic(slug, manifest, req, res, pathname);
})().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
if (!res.headersSent) sendError(res, 500, 'internal_error', message);
});
});
return appServer;
}
// ---------------------------------------------------------------------------
// Watcher (§6.5)
// ---------------------------------------------------------------------------
function slugFromAbsolutePath(absolutePath: string): { slug: string; rel: string } | null {
const relative = path.relative(APPS_DIR, absolutePath);
if (!relative || relative === '.' || relative.startsWith('..') || path.isAbsolute(relative)) return null;
const segments = relative.split(path.sep);
const slug = segments[0];
if (!slug || !FOLDER_SLUG_RE.test(slug)) return null;
return { slug, rel: segments.slice(1).join('/') };
}
async function startWatcher(): Promise<void> {
if (watcher) return;
const w = chokidar.watch(APPS_DIR, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 180, pollInterval: 50 },
});
w.on('all', (eventName, absolutePath) => {
if (!['add', 'addDir', 'change', 'unlink', 'unlinkDir'].includes(eventName)) return;
const hit = slugFromAbsolutePath(absolutePath);
if (!hit || hit.rel.endsWith('.tmp') || /\.tmp-[0-9a-f]+$/.test(hit.rel)) return;
const area: 'dist' | 'data' = hit.rel === 'data' || hit.rel.startsWith('data/') ? 'data' : 'dist';
scheduleChangeBroadcast(hit.slug, area, hit.rel);
});
w.on('error', (error: unknown) => {
console.error('[Apps] watcher error:', error);
});
watcher = w;
}
// ---------------------------------------------------------------------------
// Lifecycle (§6.1)
// ---------------------------------------------------------------------------
export function getServerStatus(): { running: boolean; error?: string } {
return server ? { running: true } : { running: false, ...(serverError ? { error: serverError } : {}) };
}
export async function init(): Promise<void> {
if (server) return;
if (startPromise) return startPromise;
startPromise = (async () => {
try {
await fsp.mkdir(APPS_DIR, { recursive: true });
await startWatcher();
await new Promise<void>((resolve, reject) => {
const s = createApp().listen(APPS_PORT, '127.0.0.1', () => {
server = s;
serverError = null;
console.log(`[Apps] server on 127.0.0.1:${APPS_PORT} (host suffix ${APPS_HOST_SUFFIX}), dir ${APPS_DIR}`);
resolve();
});
s.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
// Never scan for alternate ports (§6.1) — origins embed the port.
reject(new Error(`Port ${APPS_PORT} is already in use.`));
return;
}
reject(error);
});
});
} catch (error) {
serverError = error instanceof Error ? error.message : String(error);
await shutdown();
console.error('[Apps] server failed to start:', serverError);
}
})().finally(() => {
startPromise = null;
});
return startPromise;
}
export async function shutdown(): Promise<void> {
const w = watcher;
watcher = null;
if (w) await w.close();
for (const timer of reloadTimers.values()) clearTimeout(timer);
reloadTimers.clear();
for (const clients of eventClients.values()) {
for (const res of clients) {
try {
res.end();
} catch { /* ignore */ }
}
}
eventClients.clear();
const s = server;
server = null;
if (!s) return;
await new Promise<void>((resolve, reject) => {
s.close((error) => (error ? reject(error) : resolve()));
});
}

View file

@ -1,606 +0,0 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import type { Server } from 'node:http';
import chokidar, { type FSWatcher } from 'chokidar';
import express from 'express';
import { WorkDir } from '../config/config.js';
import { LOCAL_SITE_SCAFFOLD } from './templates.js';
export const LOCAL_SITES_PORT = 3210;
export const LOCAL_SITES_BASE_URL = `http://localhost:${LOCAL_SITES_PORT}`;
const LOCAL_SITES_DIR = path.join(WorkDir, 'sites');
const SITE_SLUG_RE = /^[a-z0-9][a-z0-9-_]*$/i;
const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height';
const SITE_RELOAD_MESSAGE = 'rowboat:site-changed';
const SITE_EVENTS_PATH = '__rowboat_events';
const SITE_RELOAD_DEBOUNCE_MS = 140;
const SITE_EVENTS_RETRY_MS = 1000;
const SITE_EVENTS_HEARTBEAT_MS = 15000;
const TEXT_EXTENSIONS = new Set([
'.css',
'.html',
'.js',
'.json',
'.map',
'.mjs',
'.svg',
'.txt',
'.xml',
]);
const MIME_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.wasm': 'application/wasm',
'.webp': 'image/webp',
'.xml': 'application/xml; charset=utf-8',
};
const IFRAME_AUTOSIZE_BOOTSTRAP = String.raw`<script>
(() => {
const SITE_CHANGED_MESSAGE = '__ROWBOAT_SITE_CHANGED_MESSAGE__';
const SITE_EVENTS_PATH = '__ROWBOAT_SITE_EVENTS_PATH__';
let reloadRequested = false;
let reloadSource = null;
const getSiteSlug = () => {
const match = window.location.pathname.match(/^\/sites\/([^/]+)/i);
return match ? decodeURIComponent(match[1]) : null;
};
const scheduleReload = () => {
if (reloadRequested) return;
reloadRequested = true;
try {
reloadSource?.close();
} catch {
// ignore close failures
}
window.setTimeout(() => {
window.location.reload();
}, 80);
};
const connectLiveReload = () => {
const siteSlug = getSiteSlug();
if (!siteSlug || typeof EventSource === 'undefined') return;
const streamUrl = new URL('/sites/' + encodeURIComponent(siteSlug) + '/' + SITE_EVENTS_PATH, window.location.origin);
const source = new EventSource(streamUrl.toString());
reloadSource = source;
source.addEventListener('message', (event) => {
try {
const payload = JSON.parse(event.data);
if (payload?.type === SITE_CHANGED_MESSAGE) {
scheduleReload();
}
} catch {
// ignore malformed payloads
}
});
window.addEventListener('beforeunload', () => {
try {
source.close();
} catch {
// ignore close failures
}
}, { once: true });
};
connectLiveReload();
if (window.parent === window || typeof window.parent?.postMessage !== 'function') return;
const MESSAGE_TYPE = '__ROWBOAT_IFRAME_HEIGHT_MESSAGE__';
const MIN_HEIGHT = 240;
let animationFrameId = 0;
let lastHeight = 0;
const applyEmbeddedStyles = () => {
const root = document.documentElement;
if (root) root.style.overflowY = 'hidden';
if (document.body) document.body.style.overflowY = 'hidden';
};
const measureHeight = () => {
const root = document.documentElement;
const body = document.body;
return Math.max(
root?.scrollHeight ?? 0,
root?.offsetHeight ?? 0,
root?.clientHeight ?? 0,
body?.scrollHeight ?? 0,
body?.offsetHeight ?? 0,
body?.clientHeight ?? 0,
);
};
const publishHeight = () => {
animationFrameId = 0;
applyEmbeddedStyles();
const nextHeight = Math.max(MIN_HEIGHT, Math.ceil(measureHeight()));
if (Math.abs(nextHeight - lastHeight) < 2) return;
lastHeight = nextHeight;
window.parent.postMessage({
type: MESSAGE_TYPE,
height: nextHeight,
href: window.location.href,
}, '*');
};
const schedulePublish = () => {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(publishHeight);
};
const resizeObserver = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(schedulePublish)
: null;
if (resizeObserver && document.documentElement) resizeObserver.observe(document.documentElement);
if (resizeObserver && document.body) resizeObserver.observe(document.body);
const mutationObserver = new MutationObserver(schedulePublish);
if (document.documentElement) {
mutationObserver.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
}
window.addEventListener('load', schedulePublish);
window.addEventListener('resize', schedulePublish);
if (document.fonts?.addEventListener) {
document.fonts.addEventListener('loadingdone', schedulePublish);
}
for (const delay of [0, 50, 150, 300, 600, 1200]) {
setTimeout(schedulePublish, delay);
}
schedulePublish();
})();
</script>`;
let localSitesServer: Server | null = null;
let startPromise: Promise<void> | null = null;
let localSitesWatcher: FSWatcher | null = null;
const siteEventClients = new Map<string, Set<express.Response>>();
const siteReloadTimers = new Map<string, NodeJS.Timeout>();
function isSafeSiteSlug(siteSlug: string): boolean {
return SITE_SLUG_RE.test(siteSlug);
}
function resolveSiteDir(siteSlug: string): string | null {
if (!isSafeSiteSlug(siteSlug)) return null;
return path.join(LOCAL_SITES_DIR, siteSlug);
}
function resolveRequestedPath(siteDir: string, requestPath: string): string | null {
const candidate = requestPath === '/' ? '/index.html' : requestPath;
const normalized = path.posix.normalize(candidate);
const relativePath = normalized.replace(/^\/+/, '');
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || relativePath.includes('\0')) {
return null;
}
const absolutePath = path.resolve(siteDir, relativePath);
if (!absolutePath.startsWith(siteDir + path.sep) && absolutePath !== siteDir) {
return null;
}
return absolutePath;
}
function getRequestPath(req: express.Request): string {
const rawPath = req.url.split('?')[0] || '/';
return rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
}
function listLocalSites(): Array<{ slug: string; url: string }> {
if (!fs.existsSync(LOCAL_SITES_DIR)) return [];
return fs.readdirSync(LOCAL_SITES_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && isSafeSiteSlug(entry.name))
.map((entry) => ({
slug: entry.name,
url: `${LOCAL_SITES_BASE_URL}/sites/${entry.name}/`,
}))
.sort((a, b) => a.slug.localeCompare(b.slug));
}
function isPathInsideRoot(rootPath: string, candidatePath: string): boolean {
return candidatePath === rootPath || candidatePath.startsWith(rootPath + path.sep);
}
async function writeIfMissing(filePath: string, content: string): Promise<void> {
try {
await fsp.access(filePath);
} catch {
await fsp.mkdir(path.dirname(filePath), { recursive: true });
await fsp.writeFile(filePath, content, 'utf8');
}
}
async function ensureLocalSiteScaffold(): Promise<void> {
await fsp.mkdir(LOCAL_SITES_DIR, { recursive: true });
await Promise.all(
Object.entries(LOCAL_SITE_SCAFFOLD).map(([relativePath, content]) =>
writeIfMissing(path.join(LOCAL_SITES_DIR, relativePath), content),
),
);
}
function injectIframeAutosizeBootstrap(html: string): string {
const bootstrap = IFRAME_AUTOSIZE_BOOTSTRAP
.replace('__ROWBOAT_IFRAME_HEIGHT_MESSAGE__', IFRAME_HEIGHT_MESSAGE)
.replace('__ROWBOAT_SITE_CHANGED_MESSAGE__', SITE_RELOAD_MESSAGE)
.replace('__ROWBOAT_SITE_EVENTS_PATH__', SITE_EVENTS_PATH)
if (/<\/body>/i.test(html)) {
return html.replace(/<\/body>/i, `${bootstrap}\n</body>`)
}
return `${html}\n${bootstrap}`
}
function getSiteSlugFromAbsolutePath(absolutePath: string): string | null {
const relativePath = path.relative(LOCAL_SITES_DIR, absolutePath);
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return null;
}
const [siteSlug] = relativePath.split(path.sep);
return siteSlug && isSafeSiteSlug(siteSlug) ? siteSlug : null;
}
function removeSiteEventClient(siteSlug: string, res: express.Response): void {
const clients = siteEventClients.get(siteSlug);
if (!clients) return;
clients.delete(res);
if (clients.size === 0) {
siteEventClients.delete(siteSlug);
}
}
function broadcastSiteReload(siteSlug: string, changedPath: string): void {
const clients = siteEventClients.get(siteSlug);
if (!clients || clients.size === 0) return;
const payload = JSON.stringify({
type: SITE_RELOAD_MESSAGE,
siteSlug,
changedPath,
at: Date.now(),
});
for (const res of Array.from(clients)) {
try {
res.write(`data: ${payload}\n\n`);
} catch {
removeSiteEventClient(siteSlug, res);
}
}
}
function scheduleSiteReload(siteSlug: string, changedPath: string): void {
const existingTimer = siteReloadTimers.get(siteSlug);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
siteReloadTimers.delete(siteSlug);
broadcastSiteReload(siteSlug, changedPath);
}, SITE_RELOAD_DEBOUNCE_MS);
siteReloadTimers.set(siteSlug, timer);
}
async function startSiteWatcher(): Promise<void> {
if (localSitesWatcher) return;
const watcher = chokidar.watch(LOCAL_SITES_DIR, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 180,
pollInterval: 50,
},
});
watcher
.on('all', (eventName, absolutePath) => {
if (!['add', 'addDir', 'change', 'unlink', 'unlinkDir'].includes(eventName)) return;
const siteSlug = getSiteSlugFromAbsolutePath(absolutePath);
if (!siteSlug) return;
const siteRoot = path.join(LOCAL_SITES_DIR, siteSlug);
const relativePath = path.relative(siteRoot, absolutePath);
const normalizedPath = !relativePath || relativePath === '.'
? '.'
: relativePath.split(path.sep).join('/');
scheduleSiteReload(siteSlug, normalizedPath);
})
.on('error', (error: unknown) => {
console.error('[LocalSites] Watcher error:', error);
});
localSitesWatcher = watcher;
}
function handleSiteEventsRequest(req: express.Request, res: express.Response): void {
const siteSlugParam = req.params.siteSlug;
const siteSlug = Array.isArray(siteSlugParam) ? siteSlugParam[0] : siteSlugParam;
if (!siteSlug || !isSafeSiteSlug(siteSlug)) {
res.status(400).json({ error: 'Invalid site slug' });
return;
}
const clients = siteEventClients.get(siteSlug) ?? new Set<express.Response>();
siteEventClients.set(siteSlug, clients);
clients.add(res);
res.status(200);
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
res.write(`retry: ${SITE_EVENTS_RETRY_MS}\n`);
res.write(`event: ready\ndata: {"ok":true}\n\n`);
const heartbeat = setInterval(() => {
try {
res.write(`: keepalive ${Date.now()}\n\n`);
} catch {
clearInterval(heartbeat);
removeSiteEventClient(siteSlug, res);
}
}, SITE_EVENTS_HEARTBEAT_MS);
const cleanup = () => {
clearInterval(heartbeat);
removeSiteEventClient(siteSlug, res);
};
req.on('close', cleanup);
res.on('close', cleanup);
}
async function respondWithFile(res: express.Response, filePath: string, method: string): Promise<void> {
const extension = path.extname(filePath).toLowerCase();
const mimeType = MIME_TYPES[extension] || 'application/octet-stream';
const stats = await fsp.stat(filePath);
res.status(200);
res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Length', String(stats.size));
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
if (method === 'HEAD') {
res.end();
return;
}
if (TEXT_EXTENSIONS.has(extension)) {
let text = await fsp.readFile(filePath, 'utf8');
if (extension === '.html') {
text = injectIframeAutosizeBootstrap(text);
}
res.setHeader('Content-Length', String(Buffer.byteLength(text)));
res.end(text);
return;
}
const data = await fsp.readFile(filePath);
res.end(data);
}
async function sendSiteResponse(req: express.Request, res: express.Response): Promise<void> {
const siteSlugParam = req.params.siteSlug;
const siteSlug = Array.isArray(siteSlugParam) ? siteSlugParam[0] : siteSlugParam;
const siteDir = siteSlug ? resolveSiteDir(siteSlug) : null;
if (!siteDir) {
res.status(400).json({ error: 'Invalid site slug' });
return;
}
if (!fs.existsSync(siteDir) || !fs.statSync(siteDir).isDirectory()) {
res.status(404).json({ error: 'Site not found' });
return;
}
const realSitesDir = fs.realpathSync(LOCAL_SITES_DIR);
const realSiteDir = fs.realpathSync(siteDir);
if (!isPathInsideRoot(realSitesDir, realSiteDir)) {
res.status(403).json({ error: 'Site path escapes sites directory' });
return;
}
const requestedPath = resolveRequestedPath(siteDir, getRequestPath(req));
if (!requestedPath) {
res.status(400).json({ error: 'Invalid site path' });
return;
}
const requestedExt = path.extname(requestedPath);
if (fs.existsSync(requestedPath)) {
const stat = fs.statSync(requestedPath);
if (stat.isDirectory()) {
const indexPath = path.join(requestedPath, 'index.html');
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
const realIndexPath = fs.realpathSync(indexPath);
if (!isPathInsideRoot(realSiteDir, realIndexPath)) {
res.status(403).json({ error: 'Site path escapes root' });
return;
}
await respondWithFile(res, indexPath, req.method);
return;
}
} else if (stat.isFile()) {
const realRequestedPath = fs.realpathSync(requestedPath);
if (!isPathInsideRoot(realSiteDir, realRequestedPath)) {
res.status(403).json({ error: 'Site path escapes root' });
return;
}
await respondWithFile(res, requestedPath, req.method);
return;
}
}
if (requestedExt) {
res.status(404).json({ error: 'Asset not found' });
return;
}
const spaFallback = path.join(siteDir, 'index.html');
if (!fs.existsSync(spaFallback) || !fs.statSync(spaFallback).isFile()) {
res.status(404).json({ error: 'Site entrypoint not found' });
return;
}
const realFallback = fs.realpathSync(spaFallback);
if (!isPathInsideRoot(realSiteDir, realFallback)) {
res.status(403).json({ error: 'Site path escapes root' });
return;
}
await respondWithFile(res, spaFallback, req.method);
}
function createLocalSitesApp(): express.Express {
const app = express();
app.get('/health', (_req, res) => {
res.json({
ok: true,
baseUrl: LOCAL_SITES_BASE_URL,
sitesDir: LOCAL_SITES_DIR,
});
});
app.get('/sites', (_req, res) => {
res.json({
sites: listLocalSites(),
});
});
app.get(`/sites/:siteSlug/${SITE_EVENTS_PATH}`, (req, res) => {
handleSiteEventsRequest(req, res);
});
app.use('/sites/:siteSlug', (req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.status(405).json({ error: 'Method not allowed' });
return;
}
void sendSiteResponse(req, res).catch((error: unknown) => {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
});
});
return app;
}
async function startServer(): Promise<void> {
if (localSitesServer) return;
const app = createLocalSitesApp();
await new Promise<void>((resolve, reject) => {
const server = app.listen(LOCAL_SITES_PORT, 'localhost', () => {
localSitesServer = server;
console.log('[LocalSites] Server starting.');
console.log(` Sites directory: ${LOCAL_SITES_DIR}`);
console.log(` Base URL: ${LOCAL_SITES_BASE_URL}`);
resolve();
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
reject(new Error(`Port ${LOCAL_SITES_PORT} is already in use.`));
return;
}
reject(error);
});
});
}
export async function init(): Promise<void> {
if (localSitesServer) return;
if (startPromise) return startPromise;
startPromise = (async () => {
try {
await ensureLocalSiteScaffold();
await startSiteWatcher();
await startServer();
} catch (error) {
await shutdown();
throw error;
}
})().finally(() => {
startPromise = null;
});
return startPromise;
}
export async function shutdown(): Promise<void> {
const watcher = localSitesWatcher;
localSitesWatcher = null;
if (watcher) {
await watcher.close();
}
for (const timer of siteReloadTimers.values()) {
clearTimeout(timer);
}
siteReloadTimers.clear();
for (const clients of siteEventClients.values()) {
for (const res of clients) {
try {
res.end();
} catch {
// ignore close failures
}
}
}
siteEventClients.clear();
const server = localSitesServer;
localSitesServer = null;
if (!server) return;
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}

View file

@ -1,625 +0,0 @@
export const LOCAL_SITE_SCAFFOLD: Record<string, string> = {
'README.md': `# Local Sites
Anything inside this folder is available at:
\`http://localhost:3210/sites/<slug>/\`
Examples:
- \`sites/example-dashboard/\` -> \`http://localhost:3210/sites/example-dashboard/\`
- \`sites/team-ops/\` -> \`http://localhost:3210/sites/team-ops/\`
You can embed a local site in a note with:
\`\`\`iframe
{"url":"http://localhost:3210/sites/example-dashboard/","title":"Signal Deck","height":640,"caption":"Local dashboard served from sites/example-dashboard"}
\`\`\`
Notes:
- The app serves each site with SPA-friendly routing, so client-side routers work
- Local HTML pages auto-expand inside Rowboat iframe blocks to fit their content height
- Put an \`index.html\` file at the site root
- Remote APIs still need to allow browser requests from a local page
`,
'example-dashboard/index.html': `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Signal Deck</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<header class="hero">
<div>
<p class="eyebrow">Local iframe sample · external APIs</p>
<h1>Signal Deck</h1>
<p class="lede">
A locally-served dashboard designed to live inside a Rowboat note. It fetches
live signals from public APIs and stays readable at note width.
</p>
</div>
<div class="hero-status" id="hero-status">Booting dashboard...</div>
</header>
<section class="metric-grid" id="metric-grid"></section>
<section class="board">
<article class="panel">
<div class="panel-header">
<div>
<p class="panel-kicker">Hacker News</p>
<h2>Live headlines</h2>
</div>
<span class="panel-chip">public API</span>
</div>
<div class="story-list" id="story-list"></div>
</article>
<article class="panel">
<div class="panel-header">
<div>
<p class="panel-kicker">GitHub</p>
<h2>Repo pulse</h2>
</div>
<span class="panel-chip">public API</span>
</div>
<div class="repo-list" id="repo-list"></div>
</article>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
`,
'example-dashboard/styles.css': `:root {
color-scheme: dark;
--bg: #090816;
--panel: rgba(18, 16, 39, 0.88);
--panel-strong: rgba(26, 23, 54, 0.96);
--line: rgba(255, 255, 255, 0.08);
--text: #f5f7ff;
--muted: rgba(230, 235, 255, 0.68);
--cyan: #66e2ff;
--lime: #b7ff6a;
--amber: #ffcb6b;
--pink: #ff7ed1;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.4);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top, rgba(74, 51, 175, 0.28), transparent 34%),
linear-gradient(180deg, #0c0b1d 0%, var(--bg) 100%);
}
.ambient {
position: fixed;
inset: auto;
width: 320px;
height: 320px;
border-radius: 999px;
filter: blur(70px);
pointer-events: none;
opacity: 0.35;
}
.ambient-one {
top: -80px;
right: -40px;
background: rgba(102, 226, 255, 0.22);
}
.ambient-two {
bottom: -120px;
left: -60px;
background: rgba(255, 126, 209, 0.18);
}
.shell {
position: relative;
max-width: 1180px;
margin: 0 auto;
padding: 32px 24px 40px;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.eyebrow,
.panel-kicker {
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 11px;
color: var(--cyan);
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: clamp(2rem, 5vw, 3.4rem);
line-height: 0.95;
letter-spacing: -0.05em;
}
.lede {
max-width: 620px;
margin-top: 12px;
color: var(--muted);
line-height: 1.55;
font-size: 15px;
}
.hero-status {
flex-shrink: 0;
min-width: 180px;
padding: 12px 14px;
border: 1px solid rgba(102, 226, 255, 0.18);
border-radius: 16px;
background: rgba(14, 17, 32, 0.62);
color: var(--muted);
font-size: 13px;
line-height: 1.4;
box-shadow: var(--shadow);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.metric-card,
.panel {
position: relative;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 22px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
var(--panel);
box-shadow: var(--shadow);
}
.metric-card {
padding: 18px;
min-height: 152px;
}
.metric-card::after,
.panel::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.07), transparent 40%);
pointer-events: none;
}
.metric-label {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.metric-value {
margin-top: 16px;
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: clamp(2rem, 4vw, 2.7rem);
line-height: 0.95;
letter-spacing: -0.06em;
}
.metric-detail {
margin-top: 12px;
color: var(--muted);
font-size: 13px;
}
.metric-spark {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 6px;
align-items: end;
height: 40px;
margin-top: 18px;
}
.metric-spark span {
display: block;
border-radius: 999px 999px 3px 3px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.1));
}
.board {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 18px;
}
.panel {
padding: 20px;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.panel-header h2 {
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: 1.3rem;
letter-spacing: -0.04em;
}
.panel-chip {
padding: 7px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
color: var(--muted);
font-size: 12px;
}
.story-list,
.repo-list {
display: grid;
gap: 12px;
}
.story-item,
.repo-item {
position: relative;
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 18px;
background: var(--panel-strong);
}
.story-rank {
position: absolute;
top: 14px;
right: 14px;
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: 1.2rem;
color: rgba(255, 255, 255, 0.18);
}
.story-item a,
.repo-item a {
color: var(--text);
text-decoration: none;
}
.story-item a:hover,
.repo-item a:hover {
color: var(--cyan);
}
.story-title,
.repo-name {
padding-right: 34px;
font-size: 15px;
font-weight: 600;
line-height: 1.35;
}
.story-meta,
.repo-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 12px;
}
.story-pill,
.repo-pill {
padding: 5px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
}
.repo-description {
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.empty-state {
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
color: var(--muted);
font-size: 14px;
}
@media (max-width: 940px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.board {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.shell {
padding: 22px 14px 28px;
}
.hero {
flex-direction: column;
}
.hero-status {
width: 100%;
}
.metric-grid {
grid-template-columns: 1fr;
}
.panel,
.metric-card {
border-radius: 18px;
}
}
`,
'example-dashboard/app.js': `const formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
});
const reposConfig = [
{
slug: 'rowboatlabs/rowboat',
label: 'Rowboat',
description: 'AI coworker with memory',
},
{
slug: 'openai/openai-cookbook',
label: 'OpenAI Cookbook',
description: 'Examples and guides for building with OpenAI APIs',
},
];
const fallbackStories = [
{ id: 1, title: 'AI product launches keep getting more opinionated', score: 182, descendants: 49, by: 'analyst', url: '#' },
{ id: 2, title: 'Designing dashboards that can survive a narrow iframe', score: 141, descendants: 26, by: 'maker', url: '#' },
{ id: 3, title: 'Why local mini-apps inside notes are underrated', score: 119, descendants: 18, by: 'builder', url: '#' },
{ id: 4, title: 'Teams want live data in docs, not screenshots', score: 97, descendants: 14, by: 'operator', url: '#' },
];
const fallbackRepos = [
{ ...reposConfig[0], stars: 1280, forks: 144, issues: 28, url: 'https://github.com/rowboatlabs/rowboat' },
{ ...reposConfig[1], stars: 71600, forks: 11300, issues: 52, url: 'https://github.com/openai/openai-cookbook' },
];
const metricGrid = document.getElementById('metric-grid');
const storyList = document.getElementById('story-list');
const repoList = document.getElementById('repo-list');
const heroStatus = document.getElementById('hero-status');
async function fetchJson(url) {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error('Request failed with status ' + response.status);
}
return response.json();
}
async function loadRepos() {
try {
const repos = await Promise.all(
reposConfig.map(async (repo) => {
const data = await fetchJson('https://api.github.com/repos/' + repo.slug);
return {
...repo,
stars: data.stargazers_count,
forks: data.forks_count,
issues: data.open_issues_count,
url: data.html_url,
};
}),
);
return repos;
} catch {
return fallbackRepos;
}
}
async function loadStories() {
try {
const ids = await fetchJson('https://hacker-news.firebaseio.com/v0/topstories.json');
const stories = await Promise.all(
ids.slice(0, 4).map((id) =>
fetchJson('https://hacker-news.firebaseio.com/v0/item/' + id + '.json'),
),
);
return stories
.filter(Boolean)
.map((story) => ({
id: story.id,
title: story.title,
score: story.score || 0,
descendants: story.descendants || 0,
by: story.by || 'unknown',
url: story.url || ('https://news.ycombinator.com/item?id=' + story.id),
}));
} catch {
return fallbackStories;
}
}
function metricSpark(values) {
const max = Math.max(...values, 1);
const bars = values.map((value) => {
const height = Math.max(18, Math.round((value / max) * 40));
return '<span style="height:' + height + 'px"></span>';
});
return '<div class="metric-spark">' + bars.join('') + '</div>';
}
function renderMetrics(repos, stories) {
const leadRepo = repos[0];
const companionRepo = repos[1];
const topStory = stories[0];
const averageScore = Math.round(
stories.reduce((sum, story) => sum + story.score, 0) / Math.max(stories.length, 1),
);
const metrics = [
{
label: 'Rowboat stars',
value: formatter.format(leadRepo.stars),
detail: formatter.format(leadRepo.forks) + ' forks · ' + leadRepo.issues + ' open issues',
spark: [leadRepo.stars * 0.58, leadRepo.stars * 0.71, leadRepo.stars * 0.88, leadRepo.stars],
accent: 'var(--cyan)',
},
{
label: 'Cookbook stars',
value: formatter.format(companionRepo.stars),
detail: formatter.format(companionRepo.forks) + ' forks · ' + companionRepo.issues + ' open issues',
spark: [companionRepo.stars * 0.76, companionRepo.stars * 0.81, companionRepo.stars * 0.93, companionRepo.stars],
accent: 'var(--lime)',
},
{
label: 'Top story score',
value: formatter.format(topStory.score),
detail: topStory.descendants + ' comments · by ' + topStory.by,
spark: stories.map((story) => story.score),
accent: 'var(--amber)',
},
{
label: 'Average HN score',
value: formatter.format(averageScore),
detail: stories.length + ' live stories in this panel',
spark: stories.map((story) => story.descendants + 10),
accent: 'var(--pink)',
},
];
metricGrid.innerHTML = metrics
.map((metric) => (
'<article class="metric-card" style="box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 24px 80px rgba(0,0,0,0.34), 0 0 0 1px color-mix(in srgb, ' + metric.accent + ' 16%, transparent);">' +
'<div class="metric-label">' + metric.label + '</div>' +
'<div class="metric-value">' + metric.value + '</div>' +
'<div class="metric-detail">' + metric.detail + '</div>' +
metricSpark(metric.spark) +
'</article>'
))
.join('');
}
function renderStories(stories) {
storyList.innerHTML = stories
.map((story, index) => (
'<article class="story-item">' +
'<div class="story-rank">0' + (index + 1) + '</div>' +
'<a class="story-title" href="' + story.url + '" target="_blank" rel="noreferrer">' + story.title + '</a>' +
'<div class="story-meta">' +
'<span class="story-pill">' + formatter.format(story.score) + ' pts</span>' +
'<span class="story-pill">' + story.descendants + ' comments</span>' +
'<span class="story-pill">by ' + story.by + '</span>' +
'</div>' +
'</article>'
))
.join('');
}
function renderRepos(repos) {
repoList.innerHTML = repos
.map((repo) => (
'<article class="repo-item">' +
'<a class="repo-name" href="' + repo.url + '" target="_blank" rel="noreferrer">' + repo.label + '</a>' +
'<p class="repo-description">' + repo.description + '</p>' +
'<div class="repo-meta">' +
'<span class="repo-pill">' + formatter.format(repo.stars) + ' stars</span>' +
'<span class="repo-pill">' + formatter.format(repo.forks) + ' forks</span>' +
'<span class="repo-pill">' + repo.issues + ' open issues</span>' +
'</div>' +
'</article>'
))
.join('');
}
function renderErrorState(message) {
metricGrid.innerHTML = '<div class="empty-state">' + message + '</div>';
storyList.innerHTML = '<div class="empty-state">No stories available.</div>';
repoList.innerHTML = '<div class="empty-state">No repositories available.</div>';
}
async function refresh() {
heroStatus.textContent = 'Refreshing live signals...';
try {
const [repos, stories] = await Promise.all([loadRepos(), loadStories()]);
if (!repos.length || !stories.length) {
renderErrorState('The sample site loaded, but the data sources returned no content.');
heroStatus.textContent = 'Loaded with empty data.';
return;
}
renderMetrics(repos, stories);
renderStories(stories);
renderRepos(repos);
heroStatus.textContent = 'Updated ' + new Date().toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
}) + ' · embedded from sites/example-dashboard';
} catch (error) {
renderErrorState('This site is running, but the live fetch failed. The local scaffold is still valid.');
heroStatus.textContent = error instanceof Error ? error.message : 'Refresh failed';
}
}
refresh();
setInterval(refresh, 120000);
`,
}