mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
2c038fe518
commit
7f15f67273
24 changed files with 1567 additions and 2359 deletions
|
|
@ -56,7 +56,8 @@ import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, ge
|
|||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
|
||||
import * as composioHandler from './composio-handler.js';
|
||||
import * as miniAppsHandler from './mini-apps-handler.js';
|
||||
import * as appsIndexer from '@x/core/dist/apps/indexer.js';
|
||||
import * as appsServer from '@x/core/dist/apps/server.js';
|
||||
import { consumePendingDeepLink } from './deeplink.js';
|
||||
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
||||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||
|
|
@ -1314,18 +1315,31 @@ export function setupIpcHandlers() {
|
|||
'migration:check-composio-google': async () => {
|
||||
return qualifyAndDisconnectComposioGoogle();
|
||||
},
|
||||
// Mini Apps handlers
|
||||
'mini-apps:seed': async (_event, args) => {
|
||||
return miniAppsHandler.seedApps(args.apps);
|
||||
// Rowboat Apps handlers (spec §13)
|
||||
'apps:list': async () => {
|
||||
const status = appsServer.getServerStatus();
|
||||
return {
|
||||
serverRunning: status.running,
|
||||
...(status.error ? { serverError: status.error } : {}),
|
||||
apps: await appsIndexer.listApps(),
|
||||
};
|
||||
},
|
||||
'mini-apps:list': async () => {
|
||||
return miniAppsHandler.listApps();
|
||||
'apps:get': async (_event, args) => {
|
||||
const app = await appsIndexer.getApp(args.folder);
|
||||
if (!app) throw new Error(`no such app: ${args.folder}`);
|
||||
// readme + rollback are M3 concerns; shapes are stable now.
|
||||
return { app, rollbackAvailable: false };
|
||||
},
|
||||
'mini-apps:get-data': async (_event, args) => {
|
||||
return miniAppsHandler.getAppData(args.id);
|
||||
'apps:create': async (_event, args) => {
|
||||
return { app: await appsIndexer.createApp(args) };
|
||||
},
|
||||
'mini-apps:fetch': async (_event, args) => {
|
||||
return miniAppsHandler.proxyFetch(args);
|
||||
'apps:delete': async (_event, args) => {
|
||||
await appsIndexer.deleteApp(args.folder);
|
||||
return { ok: true as const };
|
||||
},
|
||||
'apps:setTheme': async (_event, args) => {
|
||||
appsServer.setAppsTheme(args.theme);
|
||||
return { ok: true as const };
|
||||
},
|
||||
// Agent schedule handlers
|
||||
'agent-schedule:getConfig': async () => {
|
||||
|
|
|
|||
|
|
@ -32,14 +32,13 @@ import { init as initEventProcessor, registerConsumer } from "@x/core/dist/event
|
|||
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
|
||||
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
|
||||
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
|
||||
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
|
||||
import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js";
|
||||
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
|
||||
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
|
||||
import { resolveMiniAppAsset, serveMiniAppAsset, initMiniAppsWatcher, MINIAPP_BRIDGE_JS } from "./mini-apps-handler.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
|
|
@ -167,28 +166,6 @@ function registerAppProtocol() {
|
|||
}
|
||||
}
|
||||
|
||||
// Mini App assets: app://miniapp/<id>/<rel-path> → ~/.rowboat/apps/<id>/dist/
|
||||
if (url.host === "miniapp") {
|
||||
try {
|
||||
const segments = decodeURIComponent(url.pathname).replace(/^\/+/, "").split("/");
|
||||
const id = segments.shift();
|
||||
if (!id) return new Response("Not Found", { status: 404 });
|
||||
// Canonical bridge shim shared by all apps.
|
||||
if (id === "__bridge__.js") {
|
||||
return new Response(MINIAPP_BRIDGE_JS, {
|
||||
headers: { "content-type": "text/javascript; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
const absPath = resolveMiniAppAsset(id, segments.join("/"));
|
||||
if (!absPath) return new Response("Forbidden", { status: 403 });
|
||||
// Direct disk read — bypasses Chromium's network service, which can
|
||||
// stall under load and blank every open app at once.
|
||||
return serveMiniAppAsset(absPath);
|
||||
} catch {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer SPA — existing logic
|
||||
let urlPath = url.pathname;
|
||||
if (urlPath === "/" || !path.extname(urlPath)) {
|
||||
|
|
@ -428,9 +405,6 @@ app.whenReady().then(async () => {
|
|||
// start bg-task scheduler (cron / window)
|
||||
initBackgroundTaskScheduler();
|
||||
|
||||
// watch ~/.rowboat/apps for data.json changes → live-refresh open Mini Apps
|
||||
initMiniAppsWatcher();
|
||||
|
||||
// register event consumers and start the shared event processor
|
||||
// (consumes $WorkDir/events/pending/, routes events to all consumers
|
||||
// concurrently for Pass-1, then fires each consumer's candidates in parallel)
|
||||
|
|
@ -479,9 +453,9 @@ app.whenReady().then(async () => {
|
|||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
// start local sites server for iframe dashboards and other mini apps
|
||||
initLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to start:', error);
|
||||
// start the Rowboat Apps server (per-app origins on 127.0.0.1:3210)
|
||||
initAppsServer().catch((error) => {
|
||||
console.error('[Apps] Failed to start:', error);
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
|
|
@ -510,8 +484,8 @@ app.on("before-quit", () => {
|
|||
}
|
||||
// Kill embedded terminal shells.
|
||||
disposeAllTerminals();
|
||||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
shutdownAppsServer().catch((error) => {
|
||||
console.error('[Apps] Failed to shut down cleanly:', error);
|
||||
});
|
||||
shutdownAnalytics().catch((error) => {
|
||||
console.error('[Analytics] Failed to flush on quit:', error);
|
||||
|
|
|
|||
|
|
@ -1,270 +0,0 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { WorkDir } from '@x/core/dist/config/config.js';
|
||||
import { MiniAppManifest } from '@x/shared/dist/mini-app.js';
|
||||
import type { z } from 'zod';
|
||||
|
||||
type Manifest = z.infer<typeof MiniAppManifest>;
|
||||
|
||||
// All Mini Apps live under ~/.rowboat/apps/<id>/.
|
||||
// manifest.json — MiniAppManifest
|
||||
// dist/ — static assets served via app://miniapp/<id>/
|
||||
// data.json — latest agent output (read by the host)
|
||||
const APPS_DIR = path.join(WorkDir, 'apps');
|
||||
|
||||
function appDir(id: string): string {
|
||||
return path.join(APPS_DIR, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical Mini App bridge shim, served at app://miniapp/__bridge__.js. Apps
|
||||
* include it with `<script src="/__bridge__.js"></script>` and code against
|
||||
* `window.rowboat`. This is the single source of truth for the bridge protocol
|
||||
* (kept in sync with the host in components/mini-app-frame.tsx + types.ts).
|
||||
*/
|
||||
export const MINIAPP_BRIDGE_JS = `
|
||||
(function () {
|
||||
var data = null, dataLoaded = false, state = null, theme = 'dark';
|
||||
var dataCbs = [], stateCbs = [], themeCbs = [];
|
||||
var pending = {}, seq = 0;
|
||||
function post(msg) { parent.postMessage(msg, '*'); }
|
||||
// Apply the host theme to <html> so app CSS can use html.dark / html.light
|
||||
// (and native controls via color-scheme).
|
||||
function applyTheme(t) {
|
||||
theme = t === 'light' ? 'light' : 'dark';
|
||||
var el = document.documentElement;
|
||||
el.classList.remove('light', 'dark'); el.classList.add(theme);
|
||||
el.setAttribute('data-theme', theme);
|
||||
el.style.colorScheme = theme;
|
||||
themeCbs.forEach(function (cb) { try { cb(theme); } catch (_) {} });
|
||||
}
|
||||
function rpc(method, params) {
|
||||
var id = 'r' + (++seq);
|
||||
return new Promise(function (resolve, reject) {
|
||||
pending[id] = { resolve: resolve, reject: reject };
|
||||
post({ type: 'rowboat:mini-app:rpc', id: id, method: method, params: params });
|
||||
});
|
||||
}
|
||||
// Apps are self-contained: data.json is a served sibling of index.html, loaded
|
||||
// via a relative fetch (same origin, no CORS). Rowboat does not inject it.
|
||||
function loadData() {
|
||||
return fetch('data.json', { cache: 'no-store' })
|
||||
.then(function (r) { return r && r.ok ? r.json() : null; })
|
||||
.then(function (d) { data = d; dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} }); return data; })
|
||||
.catch(function () { dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(null); } catch (_) {} }); return null; });
|
||||
}
|
||||
window.addEventListener('message', function (e) {
|
||||
var m = e.data;
|
||||
if (!m || typeof m !== 'object') return;
|
||||
if (m.type === 'rowboat:mini-app:state') {
|
||||
state = m.state;
|
||||
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
|
||||
} else if (m.type === 'rowboat:mini-app:theme') {
|
||||
applyTheme(m.theme);
|
||||
} else if (m.type === 'rowboat:mini-app:data-updated') {
|
||||
loadData();
|
||||
} else if (m.type === 'rowboat:mini-app:rpc-result') {
|
||||
var p = pending[m.id];
|
||||
if (p) { delete pending[m.id]; if (m.ok) p.resolve(m.result); else p.reject(new Error(m.error || 'request failed')); }
|
||||
}
|
||||
});
|
||||
window.rowboat = {
|
||||
getData: function () { return data; },
|
||||
getTheme: function () { return theme; },
|
||||
onTheme: function (cb) { themeCbs.push(cb); try { cb(theme); } catch (_) {} return function () { var i = themeCbs.indexOf(cb); if (i >= 0) themeCbs.splice(i, 1); }; },
|
||||
onData: function (cb) { dataCbs.push(cb); if (dataLoaded) { try { cb(data); } catch (_) {} } return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); }; },
|
||||
refreshData: function () { return loadData(); },
|
||||
getState: function () { return state; },
|
||||
onState: function (cb) { stateCbs.push(cb); if (state !== null) { try { cb(state); } catch (_) {} } return function () { var i = stateCbs.indexOf(cb); if (i >= 0) stateCbs.splice(i, 1); }; },
|
||||
setState: function (patch) { state = Object.assign({}, state || {}, patch); post({ type: 'rowboat:mini-app:setState', patch: patch }); stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); },
|
||||
callAction: function (scope, tool, args) { return rpc('callAction', { scope: scope, tool: tool, args: args }); },
|
||||
searchTools: function (scope, query) { return rpc('searchTools', { scope: scope, query: query }); },
|
||||
isConnected: function (scope) { return rpc('isConnected', { scope: scope }); },
|
||||
connect: function (scope) { return rpc('connect', { scope: scope }); },
|
||||
// CORS-safe HTTP via the main process. Resolves to { ok, status, text, json }
|
||||
// (json is the parsed body when it's valid JSON, else null).
|
||||
fetch: function (url, opts) {
|
||||
return rpc('fetch', { url: url, method: (opts && opts.method), headers: (opts && opts.headers), body: (opts && opts.body) })
|
||||
.then(function (r) { var j = null; try { j = JSON.parse(r.text); } catch (_) {} r.json = j; return r; });
|
||||
},
|
||||
ready: function () { post({ type: 'rowboat:mini-app:ready' }); },
|
||||
};
|
||||
loadData();
|
||||
})();
|
||||
`;
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed/refresh built-in apps. Manifest + dist are managed (always overwritten so
|
||||
* built-in updates propagate); data.json is written only if missing so a
|
||||
* background agent's output is never clobbered.
|
||||
*/
|
||||
export function seedApps(apps: Array<{ manifest: Manifest; html: string; data?: unknown }>): { seeded: string[] } {
|
||||
const seeded: string[] = [];
|
||||
for (const app of apps) {
|
||||
const manifest = MiniAppManifest.parse(app.manifest);
|
||||
const dir = appDir(manifest.id);
|
||||
const distDir = path.join(dir, 'dist');
|
||||
ensureDir(distDir);
|
||||
// Atomic writes so a concurrently-open app never reads a partial file.
|
||||
const writeAtomic = (p: string, content: string) => {
|
||||
fs.writeFileSync(`${p}.tmp`, content);
|
||||
fs.renameSync(`${p}.tmp`, p);
|
||||
};
|
||||
writeAtomic(path.join(distDir, 'index.html'), app.html);
|
||||
writeAtomic(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
const dataPath = path.join(dir, 'data.json');
|
||||
if (app.data !== undefined && !fs.existsSync(dataPath)) {
|
||||
fs.writeFileSync(dataPath, JSON.stringify(app.data, null, 2));
|
||||
}
|
||||
seeded.push(manifest.id);
|
||||
}
|
||||
return { seeded };
|
||||
}
|
||||
|
||||
/** List installed app manifests (skips folders without a valid manifest). */
|
||||
export function listApps(): { manifests: Manifest[] } {
|
||||
const manifests: Manifest[] = [];
|
||||
if (!fs.existsSync(APPS_DIR)) return { manifests };
|
||||
for (const entry of fs.readdirSync(APPS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const file = path.join(APPS_DIR, entry.name, 'manifest.json');
|
||||
try {
|
||||
const parsed = MiniAppManifest.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
|
||||
manifests.push(parsed);
|
||||
} catch {
|
||||
// skip folders without a valid manifest
|
||||
}
|
||||
}
|
||||
return { manifests };
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy an HTTP request through the main process so Mini Apps can call
|
||||
* third-party APIs that don't send CORS headers (the sandboxed app:// origin
|
||||
* can't fetch them directly). GET/POST over http(s) only.
|
||||
*/
|
||||
export async function proxyFetch(input: {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
}): Promise<{ ok: boolean; status: number; statusText: string; text: string; error?: string }> {
|
||||
try {
|
||||
const parsed = new URL(input.url);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return { ok: false, status: 0, statusText: '', text: '', error: 'Only http(s) URLs are allowed.' };
|
||||
}
|
||||
const method = (input.method || 'GET').toUpperCase();
|
||||
const res = await fetch(input.url, {
|
||||
method,
|
||||
headers: input.headers,
|
||||
body: method === 'GET' || method === 'HEAD' ? undefined : input.body,
|
||||
});
|
||||
const text = await res.text();
|
||||
return { ok: res.ok, status: res.status, statusText: res.statusText, text };
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, statusText: '', text: '', error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Read an app's latest data.json (agent output), or null if absent/invalid. */
|
||||
export function getAppData(id: string): { data: unknown | null } {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(appDir(id), 'data.json'), 'utf-8');
|
||||
return { data: JSON.parse(raw) };
|
||||
} catch {
|
||||
return { data: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch ~/.rowboat/apps for data.json changes and notify all renderer windows
|
||||
* (mini-apps:dataChanged) so an open app can reload its data live. Debounced
|
||||
* per app id (agents write via temp→rename, which fires multiple fs events).
|
||||
*/
|
||||
export function initMiniAppsWatcher(): void {
|
||||
fs.mkdirSync(APPS_DIR, { recursive: true });
|
||||
const pending = new Map<string, NodeJS.Timeout>();
|
||||
const notify = (channel: 'mini-apps:dataChanged' | 'mini-apps:appsChanged', id: string) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send(channel, { id });
|
||||
}
|
||||
}
|
||||
};
|
||||
const schedule = (channel: 'mini-apps:dataChanged' | 'mini-apps:appsChanged', id: string) => {
|
||||
const key = `${channel}:${id}`;
|
||||
const existing = pending.get(key);
|
||||
if (existing) clearTimeout(existing);
|
||||
pending.set(key, setTimeout(() => { pending.delete(key); notify(channel, id); }, 300));
|
||||
};
|
||||
try {
|
||||
fs.watch(APPS_DIR, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
// filename is relative to APPS_DIR, e.g. "<id>/dist/data.json"
|
||||
const id = filename.split(path.sep)[0];
|
||||
if (!id || id.startsWith('.') || filename.endsWith('.tmp')) return;
|
||||
if (filename.endsWith('data.json')) {
|
||||
schedule('mini-apps:dataChanged', id);
|
||||
} else if (filename.endsWith('manifest.json') || filename.endsWith('index.html')) {
|
||||
// install / update settled → gallery re-lists, open frames reload
|
||||
schedule('mini-apps:appsChanged', id);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[MiniApps] watcher failed to start:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
/**
|
||||
* Serve a mini-app asset by reading it directly from disk. Deliberately avoids
|
||||
* net.fetch(file://…): that routes through Chromium's shared network service,
|
||||
* which can stall under load and blank every open app at once. Logs slow serves
|
||||
* so stalls are visible in the terminal.
|
||||
*/
|
||||
export async function serveMiniAppAsset(absPath: string): Promise<Response> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const buf = await fs.promises.readFile(absPath);
|
||||
const mime = MIME_BY_EXT[path.extname(absPath).toLowerCase()] ?? 'application/octet-stream';
|
||||
const ms = Date.now() - started;
|
||||
if (ms > 250) console.warn(`[MiniApps] slow asset serve (${ms}ms): ${absPath}`);
|
||||
return new Response(new Uint8Array(buf), { headers: { 'content-type': mime } });
|
||||
} catch {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve app://miniapp/<id>/<rel> to an absolute file path under the app's dist
|
||||
* dir, guarding against path traversal. Returns null if outside the dist root.
|
||||
*/
|
||||
export function resolveMiniAppAsset(id: string, relPath: string): string | null {
|
||||
const distRoot = path.resolve(appDir(id), 'dist');
|
||||
const clean = relPath.replace(/^\/+/, '') || 'index.html';
|
||||
const abs = path.resolve(distRoot, clean);
|
||||
if (abs !== distRoot && !abs.startsWith(distRoot + path.sep)) return null;
|
||||
return abs;
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ import { SidebarContentPanel } from '@/components/sidebar-content';
|
|||
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
||||
import { LiveNotesView } from '@/components/live-notes-view';
|
||||
import { BgTasksView } from '@/components/bg-tasks-view';
|
||||
import { MiniAppsView } from '@/components/mini-apps-view';
|
||||
import { AppsView } from '@/components/apps/apps-view';
|
||||
import { EmailView } from '@/components/email-view';
|
||||
import { WorkspaceView } from '@/components/workspace-view';
|
||||
import { CodingRunBlock } from '@/components/coding-run';
|
||||
|
|
@ -4424,6 +4424,19 @@ function App() {
|
|||
return window.ipc.on('app:openUrl', ({ url }) => handle(url))
|
||||
}, [])
|
||||
|
||||
// Report the UI theme to the apps server (spec §7.1): apps read it from
|
||||
// GET /_rowboat/app and get live changes via the SSE theme event.
|
||||
useEffect(() => {
|
||||
const report = () => {
|
||||
const theme = document.documentElement.classList.contains('dark') ? 'dark' as const : 'light' as const
|
||||
void window.ipc.invoke('apps:setTheme', { theme }).catch(() => { /* server may be down */ })
|
||||
}
|
||||
report()
|
||||
const observer = new MutationObserver(report)
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Triggered by main when the user clicks a calendar-meeting notification.
|
||||
// Reuses the same flow as the in-app "Join meeting & take notes" button.
|
||||
// When `openMeeting` is true, also opens the meeting URL in the system browser.
|
||||
|
|
@ -6000,10 +6013,10 @@ function App() {
|
|||
</div>
|
||||
) : isAppsOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<MiniAppsView
|
||||
initialAppId={appInitialId}
|
||||
<AppsView
|
||||
initialAppFolder={appInitialId}
|
||||
initialVersion={appIdVersion}
|
||||
onNewApp={() => prefillChat('Build me a mini-app that ')}
|
||||
onNewApp={() => prefillChat('Build me an app that ')}
|
||||
/>
|
||||
</div>
|
||||
) : isEmailOpen ? (
|
||||
|
|
|
|||
52
apps/x/apps/renderer/src/components/apps/app-frame.tsx
Normal file
52
apps/x/apps/renderer/src/components/apps/app-frame.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useState } from 'react'
|
||||
import { ArrowLeft, ExternalLink, RotateCw } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
|
||||
// Full-height iframe on the app's own origin (spec §6.6). No sandbox attr —
|
||||
// per-app browser origins are the isolation boundary. Toolbar: back, reload,
|
||||
// open-in-browser.
|
||||
|
||||
export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: () => void }) {
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const title = app.manifest?.name ?? app.folder
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Apps
|
||||
</button>
|
||||
<span className="flex-1 truncate text-sm font-medium">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Reload"
|
||||
onClick={() => setReloadNonce((n) => n + 1)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RotateCw className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Open in browser"
|
||||
onClick={() => window.open(app.origin, '_blank')}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<iframe
|
||||
key={reloadNonce}
|
||||
title={title}
|
||||
src={`${app.origin}/`}
|
||||
className="h-full w-full border-0 bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ArrowLeft, Plus } from 'lucide-react'
|
||||
import { miniApp } from '@x/shared'
|
||||
import { MiniAppFrame } from '@/components/mini-app-frame'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { AppFrame } from '@/components/apps/app-frame'
|
||||
|
||||
// The "Mini Apps" section: a gallery of premium product tiles; click one to open
|
||||
// the app full-screen. Each card's accent theme and decorative pattern are
|
||||
// derived deterministically from the app id, so identity comes from colour +
|
||||
// typography rather than an icon.
|
||||
// Apps home (spec §14): "My apps" grid + Catalog placeholder (M3). Cards are
|
||||
// AppSummary-driven; click opens the app full-height on its own origin.
|
||||
|
||||
type Theme = { accent: string; glow: string }
|
||||
|
||||
|
|
@ -18,7 +16,6 @@ const THEMES: Theme[] = [
|
|||
{ accent: '#14B8A6', glow: 'rgba(20,184,166,0.40)' }, // Teal
|
||||
{ accent: '#EC4899', glow: 'rgba(236,72,153,0.42)' }, // Rose
|
||||
]
|
||||
|
||||
const PATTERNS = ['dots', 'grid', 'diagonal', 'radial', 'waves', 'mesh']
|
||||
|
||||
function hash(s: string): number {
|
||||
|
|
@ -26,22 +23,13 @@ function hash(s: string): number {
|
|||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
|
||||
return Math.abs(h)
|
||||
}
|
||||
// Spread accents across the grid by card position so adjacent cards differ and
|
||||
// the full palette is exercised. Pattern stays tied to id (stable per app).
|
||||
const themeForIndex = (i: number): Theme => THEMES[i % THEMES.length]
|
||||
const patternFor = (id: string): string => PATTERNS[hash(id + '·pat') % PATTERNS.length]
|
||||
|
||||
// Card styling lives here (precise gradients/glows/patterns are awkward in
|
||||
// Tailwind tokens). Injected once; per-card accent is passed via CSS variables.
|
||||
const CARD_CSS = `
|
||||
/* Light is the baseline; .dark (set on <html>) overrides the surface tokens.
|
||||
The accent system (badge/pill/glow/pattern via --accent) is identical in both.
|
||||
Surfaces are brushed-metal: a base gradient + a diagonal sheen + a hairline
|
||||
top highlight. Sizing responds to the PANE width via container queries. */
|
||||
.ma-page {
|
||||
container-type: inline-size;
|
||||
--ma-bg:#eceef1;
|
||||
/* metallic silver */
|
||||
--ma-card-from:#ffffff; --ma-card-mid:#f2f3f6; --ma-card-to:#e6e8ee;
|
||||
--ma-card-hover-from:#ffffff; --ma-card-hover-mid:#f5f6f9; --ma-card-hover-to:#eaecf1;
|
||||
--ma-sheen:rgba(255,255,255,0.55); --ma-top-highlight:rgba(255,255,255,0.9);
|
||||
|
|
@ -57,7 +45,6 @@ const CARD_CSS = `
|
|||
}
|
||||
.dark .ma-page {
|
||||
--ma-bg:#0b0b0d;
|
||||
/* metallic gunmetal */
|
||||
--ma-card-from:#262930; --ma-card-mid:#191b21; --ma-card-to:#101116;
|
||||
--ma-card-hover-from:#2b2e36; --ma-card-hover-mid:#1c1e25; --ma-card-hover-to:#131419;
|
||||
--ma-sheen:rgba(255,255,255,0.07); --ma-top-highlight:rgba(255,255,255,0.09);
|
||||
|
|
@ -72,10 +59,12 @@ const CARD_CSS = `
|
|||
}
|
||||
.ma-inner { max-width:1120px; margin:0 auto; padding:clamp(20px,3.5cqw,34px) clamp(16px,3cqw,30px) 48px; }
|
||||
.ma-h1 { font-size:clamp(19px,2.6cqw,24px); font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; }
|
||||
.ma-sub { font-size:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(18px,2.5cqw,28px); }
|
||||
/* Fluid columns: as many ~250px cards as fit the pane; single column when narrow. */
|
||||
.ma-sub { font-size:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(14px,2cqw,20px); }
|
||||
.ma-tabs { display:flex; gap:6px; margin-bottom:clamp(14px,2cqw,22px); }
|
||||
.ma-tab { border:1px solid var(--ma-border); background:transparent; color:var(--ma-sub); border-radius:999px; padding:5px 14px; font-size:13px; font-weight:600; cursor:pointer; }
|
||||
.ma-tab.on { color:var(--ma-title); border-color:var(--ma-border-hover); background:color-mix(in srgb, var(--ma-title) 6%, transparent); }
|
||||
.ma-banner { border:1px solid rgba(239,68,68,.4); background:rgba(239,68,68,.1); color:var(--ma-title); border-radius:12px; padding:10px 14px; font-size:13px; margin-bottom:16px; }
|
||||
.ma-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(100%,248px),1fr)); gap:clamp(14px,2cqw,24px); }
|
||||
|
||||
.ma-card {
|
||||
position:relative; min-height:clamp(190px,24cqw,244px); border-radius:18px;
|
||||
border:1px solid var(--ma-border);
|
||||
|
|
@ -86,7 +75,7 @@ const CARD_CSS = `
|
|||
padding:clamp(15px,2cqw,22px); text-align:left; cursor:pointer; overflow:hidden;
|
||||
display:flex; flex-direction:column; isolation:isolate;
|
||||
box-shadow: var(--ma-shadow), inset 0 1px 0 var(--ma-top-highlight), 0 8px 22px -20px var(--glow);
|
||||
transition: box-shadow .22s ease, border-color .22s ease, background .22s ease, transform .22s ease;
|
||||
transition: box-shadow .22s ease, border-color .22s ease, background .22s ease;
|
||||
}
|
||||
.ma-card:hover {
|
||||
border-color: var(--ma-border-hover);
|
||||
|
|
@ -95,33 +84,27 @@ const CARD_CSS = `
|
|||
linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint-hover), transparent) 0%, transparent 64%),
|
||||
linear-gradient(158deg, var(--ma-card-hover-from) 0%, var(--ma-card-hover-mid) 52%, var(--ma-card-hover-to) 100%);
|
||||
}
|
||||
/* decorative pattern layer (accent-tinted, very low opacity) */
|
||||
.ma-card::before {
|
||||
content:''; position:absolute; inset:0; z-index:-1; opacity:var(--ma-pat-opacity); pointer-events:none;
|
||||
}
|
||||
/* ambient glow blob, top-right */
|
||||
.ma-card::before { content:''; position:absolute; inset:0; z-index:-1; opacity:var(--ma-pat-opacity); pointer-events:none; }
|
||||
.ma-card::after {
|
||||
content:''; position:absolute; top:-45%; right:-25%; width:75%; height:75%; z-index:-1;
|
||||
background: radial-gradient(circle, var(--accent) 0%, transparent 70%);
|
||||
opacity:var(--ma-glow-opacity); filter: blur(18px); pointer-events:none; transition: opacity .22s ease;
|
||||
}
|
||||
.ma-card:hover::after { opacity:var(--ma-glow-hover-opacity); }
|
||||
|
||||
.ma-pat-dots::before { background-image: radial-gradient(var(--accent) 1px, transparent 1.4px); background-size:16px 16px; }
|
||||
.ma-pat-grid::before { background-image: linear-gradient(var(--accent) 1px, transparent 1px), linear-gradient(90deg, var(--accent) 1px, transparent 1px); background-size:26px 26px; }
|
||||
.ma-pat-diagonal::before { background-image: repeating-linear-gradient(45deg, var(--accent) 0 1px, transparent 1px 14px); }
|
||||
.ma-pat-radial::before { background-image: radial-gradient(circle at 78% 18%, var(--accent) 0%, transparent 55%); opacity:calc(var(--ma-pat-opacity) + 0.05); }
|
||||
.ma-pat-waves::before { background-image: repeating-radial-gradient(circle at 50% -30%, transparent 0 20px, var(--accent) 20px 21px); }
|
||||
.ma-pat-mesh::before { background-image: radial-gradient(circle at 12% 18%, var(--accent) 0%, transparent 42%), radial-gradient(circle at 88% 82%, var(--accent) 0%, transparent 42%); opacity:calc(var(--ma-pat-opacity) + 0.03); }
|
||||
|
||||
.ma-top { display:flex; justify-content:flex-end; }
|
||||
.ma-top { display:flex; justify-content:flex-end; gap:6px; }
|
||||
.ma-badge {
|
||||
display:inline-flex; align-items:center; height:22px; padding:0 10px; border-radius:999px;
|
||||
font-size:9.5px; font-weight:600; letter-spacing:0.07em;
|
||||
color: var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-badge-mix), transparent);
|
||||
}
|
||||
.ma-badge.off { color: var(--ma-off-fg); background: var(--ma-off-bg); }
|
||||
|
||||
.ma-badge.err { color:#ef4444; background:rgba(239,68,68,.14); }
|
||||
.ma-title { font-size:clamp(17px,2.3cqw,21px); font-weight:600; letter-spacing:-0.02em; color:var(--ma-title); margin:clamp(12px,2cqw,18px) 0 8px; }
|
||||
.ma-desc {
|
||||
font-size:clamp(13px,1.5cqw,14.5px); font-weight:400; line-height:1.45; color:var(--ma-desc); margin:0;
|
||||
|
|
@ -130,121 +113,119 @@ const CARD_CSS = `
|
|||
.ma-footer { margin-top:auto; padding-top:clamp(14px,2cqw,22px); display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.ma-source { font-size:11.5px; font-weight:600; color:var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-pill-mix), transparent); padding:5px 10px; border-radius:999px; white-space:nowrap; }
|
||||
.ma-lastrun { font-size:11.5px; color:var(--ma-lastrun); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
.ma-new {
|
||||
width:100%; font:inherit; min-height:clamp(190px,24cqw,244px); border-radius:18px; border:1px dashed var(--ma-new-border);
|
||||
background:transparent; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
gap:8px; color:var(--ma-new-title); cursor:pointer; transition: border-color .2s ease, color .2s ease, background .2s ease;
|
||||
gap:8px; color:var(--ma-new-title); cursor:pointer; transition: border-color .2s ease, background .2s ease;
|
||||
}
|
||||
.ma-new:hover { border-color:var(--ma-border-hover); background:color-mix(in srgb, var(--accent, #888) 6%, transparent); }
|
||||
.ma-new-title { font-size:14.5px; font-weight:600; color:var(--ma-new-title); }
|
||||
.ma-new-hint { font-size:12px; color:var(--ma-new-hint); text-align:center; padding:0 12px; }
|
||||
|
||||
/* Very narrow pane: tighten footer so source + last-run don't collide. */
|
||||
.ma-empty { padding:36px 8px; text-align:center; color:var(--ma-sub); font-size:14px; grid-column:1/-1; }
|
||||
@container (max-width: 380px) {
|
||||
.ma-footer { flex-direction:column; align-items:flex-start; gap:6px; }
|
||||
}
|
||||
`
|
||||
|
||||
function Card({ app, index, onOpen }: { app: miniApp.MiniAppManifest; index: number; onOpen: () => void }) {
|
||||
function Card({ app, index, onOpen }: { app: rowboatApp.AppSummary; index: number; onOpen: () => void }) {
|
||||
const theme = themeForIndex(index)
|
||||
const pattern = patternFor(app.id)
|
||||
const pattern = patternFor(app.folder)
|
||||
const invalid = app.status === 'invalid'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
title={invalid ? app.manifestError : undefined}
|
||||
className={`ma-card ma-pat-${pattern}`}
|
||||
style={{ '--accent': theme.accent, '--glow': theme.glow } as React.CSSProperties}
|
||||
>
|
||||
<div className="ma-top">
|
||||
<span className={`ma-badge${app.active ? '' : ' off'}`}>
|
||||
{app.active ? 'ACTIVE' : 'PAUSED'}
|
||||
{invalid && <span className="ma-badge err">INVALID</span>}
|
||||
<span className={`ma-badge${app.kind === 'installed' ? '' : ' off'}`}>
|
||||
{app.kind === 'installed' ? 'INSTALLED' : 'LOCAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ma-title">{app.title}</div>
|
||||
<div className="ma-desc">{app.description}</div>
|
||||
<div className="ma-title">{app.manifest?.name ?? app.folder}</div>
|
||||
<div className="ma-desc">{invalid ? (app.manifestError ?? 'Invalid manifest') : (app.manifest?.description || 'No description yet.')}</div>
|
||||
<div className="ma-footer">
|
||||
<span className="ma-source">{app.source}</span>
|
||||
<span className="ma-lastrun">Last run {app.lastRun}</span>
|
||||
<span className="ma-source">v{app.manifest?.version ?? '?'}</span>
|
||||
<span className="ma-lastrun">{app.folder}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function MiniAppsView({ initialAppId, initialVersion, onNewApp }: { initialAppId?: string | null; initialVersion?: number; onNewApp?: () => void } = {}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(initialAppId ?? null)
|
||||
const [manifests, setManifests] = useState<miniApp.MiniAppManifest[]>([])
|
||||
export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
||||
initialAppFolder?: string | null
|
||||
initialVersion?: number
|
||||
onNewApp?: () => void
|
||||
} = {}) {
|
||||
const [tab, setTab] = useState<'mine' | 'catalog'>('mine')
|
||||
const [selectedFolder, setSelectedFolder] = useState<string | null>(initialAppFolder ?? null)
|
||||
const [apps, setApps] = useState<rowboatApp.AppSummary[]>([])
|
||||
const [serverError, setServerError] = useState<string | null>(null)
|
||||
|
||||
// Open a specific app when asked from outside (app-navigation open-app).
|
||||
// Adjust-during-render pattern: react to a new request (version bump) without
|
||||
// an effect.
|
||||
const [appliedVersion, setAppliedVersion] = useState(initialVersion)
|
||||
if (initialVersion !== appliedVersion) {
|
||||
setAppliedVersion(initialVersion)
|
||||
if (initialAppId) setSelectedId(initialAppId)
|
||||
if (initialAppFolder) setSelectedFolder(initialAppFolder)
|
||||
}
|
||||
|
||||
// List apps installed under ~/.rowboat/apps (created by the copilot builder or
|
||||
// placed manually; none are bundled in the repo). Keeps the list live as apps
|
||||
// are installed/updated, and re-lists on each open-app request (the app may
|
||||
// have been installed after this view mounted).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('mini-apps:list', null)
|
||||
if (!cancelled) setManifests([...r.manifests].sort((a, b) => a.title.localeCompare(b.title)))
|
||||
} catch {
|
||||
if (!cancelled) setManifests([])
|
||||
const r = await window.ipc.invoke('apps:list', {})
|
||||
if (cancelled) return
|
||||
setApps(r.apps)
|
||||
setServerError(r.serverRunning ? null : (r.serverError ?? 'Apps server is not running.'))
|
||||
} catch (e) {
|
||||
if (!cancelled) setServerError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
void load()
|
||||
const off = window.ipc.on('mini-apps:appsChanged', () => { void load() })
|
||||
return () => { cancelled = true; off() }
|
||||
const interval = setInterval(load, 4000) // keep the grid fresh (copilot installs)
|
||||
return () => { cancelled = true; clearInterval(interval) }
|
||||
}, [initialVersion])
|
||||
|
||||
const selected = selectedId ? manifests.find((m) => m.id === selectedId) : undefined
|
||||
|
||||
const selected = selectedFolder ? apps.find((a) => a.folder === selectedFolder) : undefined
|
||||
if (selected) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(null)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Apps
|
||||
</button>
|
||||
<span className="text-sm font-medium">{selected.title}</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<MiniAppFrame manifest={selected} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <AppFrame app={selected} onBack={() => setSelectedFolder(null)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ma-page">
|
||||
<style>{CARD_CSS}</style>
|
||||
<div className="ma-inner">
|
||||
<h1 className="ma-h1">Mini Apps</h1>
|
||||
<p className="ma-sub">Little apps that live inside Rowboat, powered by your agents and integrations.</p>
|
||||
<h1 className="ma-h1">Apps</h1>
|
||||
<p className="ma-sub">Apps that live inside Rowboat, powered by your agents and integrations.</p>
|
||||
|
||||
<div className="ma-grid">
|
||||
{manifests.map((m, i) => (
|
||||
<Card key={m.id} app={m} index={i} onOpen={() => setSelectedId(m.id)} />
|
||||
))}
|
||||
|
||||
{/* Kick off the copilot builder with a pre-filled prompt. */}
|
||||
<button type="button" className="ma-new" onClick={onNewApp}>
|
||||
<Plus className="size-5" />
|
||||
<div className="ma-new-title">New app</div>
|
||||
<div className="ma-new-hint">Describe one to the copilot</div>
|
||||
</button>
|
||||
<div className="ma-tabs">
|
||||
<button type="button" className={`ma-tab${tab === 'mine' ? ' on' : ''}`} onClick={() => setTab('mine')}>My apps</button>
|
||||
<button type="button" className={`ma-tab${tab === 'catalog' ? ' on' : ''}`} onClick={() => setTab('catalog')}>Catalog</button>
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
<div className="ma-banner">
|
||||
<RefreshCw className="mr-1.5 inline size-3.5" /> Apps server unavailable: {serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'catalog' ? (
|
||||
<div className="ma-empty">The community catalog is coming soon.</div>
|
||||
) : (
|
||||
<div className="ma-grid">
|
||||
{apps.map((app, i) => (
|
||||
<Card key={app.folder} app={app} index={i} onOpen={() => setSelectedFolder(app.folder)} />
|
||||
))}
|
||||
<button type="button" className="ma-new" onClick={onNewApp}>
|
||||
<Plus className="size-5" />
|
||||
<div className="ma-new-title">New app</div>
|
||||
<div className="ma-new-hint">Describe one to the copilot</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { miniApp } from '@x/shared'
|
||||
import type { MiniAppOutboundMessage } from '@/mini-apps/types'
|
||||
import { MINI_APP_MESSAGE } from '@/mini-apps/types'
|
||||
|
||||
// Host side of the Mini App bridge. Loads the app's static assets from
|
||||
// app://miniapp/<id>/ (served from ~/.rowboat/apps/<id>/dist) and answers the
|
||||
// postMessage protocol in mini-apps/types.ts.
|
||||
//
|
||||
// - data: sourced from the app's on-disk data.json (agent output) via IPC.
|
||||
// - bridge rpc (callAction/searchTools/isConnected/connect): scope-checked
|
||||
// against the manifest, routed to Composio over IPC.
|
||||
|
||||
// app://miniapp/<id> is a distinct origin from the renderer, so allow-same-origin
|
||||
// here grants the app same-origin to its OWN origin only (lets it use fetch /
|
||||
// remote assets) while staying isolated from the renderer.
|
||||
const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads'
|
||||
|
||||
export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest }) {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
||||
const stateRef = useRef<Record<string, unknown>>({})
|
||||
const scope = manifest.scope
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = {}
|
||||
|
||||
function postToFrame(message: unknown) {
|
||||
iframeRef.current?.contentWindow?.postMessage(message, '*')
|
||||
}
|
||||
|
||||
const currentTheme = (): 'light' | 'dark' =>
|
||||
document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
|
||||
// Push host theme changes into the app so it can restyle live.
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
postToFrame({ type: MINI_APP_MESSAGE.theme, theme: currentTheme() })
|
||||
})
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
|
||||
async function handleRpc(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
// fetch is a network proxy, not toolkit-scoped — handle before the scope gate.
|
||||
if (method === 'fetch') {
|
||||
const url = typeof params.url === 'string' ? params.url : ''
|
||||
if (!url) throw new Error('No url specified.')
|
||||
return window.ipc.invoke('mini-apps:fetch', {
|
||||
url,
|
||||
method: typeof params.method === 'string' ? params.method : undefined,
|
||||
headers: (params.headers && typeof params.headers === 'object' ? params.headers : undefined) as Record<string, string> | undefined,
|
||||
body: typeof params.body === 'string' ? params.body : undefined,
|
||||
})
|
||||
}
|
||||
const s = typeof params.scope === 'string' ? params.scope : ''
|
||||
if (!s || !scope.includes(s)) {
|
||||
throw new Error(`This app is not allowed to use "${s || '(none)'}".`)
|
||||
}
|
||||
switch (method) {
|
||||
case 'isConnected': {
|
||||
const r = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: s })
|
||||
return r.isConnected
|
||||
}
|
||||
case 'connect': {
|
||||
const r = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: s })
|
||||
if (!r.success && r.error) throw new Error(r.error)
|
||||
return { started: r.success }
|
||||
}
|
||||
case 'searchTools': {
|
||||
const query = typeof params.query === 'string' ? params.query : ''
|
||||
const r = await window.ipc.invoke('composio:search-tools', { toolkitSlug: s, query })
|
||||
if (r.error) throw new Error(r.error)
|
||||
return r.tools
|
||||
}
|
||||
case 'callAction': {
|
||||
const tool = typeof params.tool === 'string' ? params.tool : ''
|
||||
if (!tool) throw new Error('No tool specified.')
|
||||
const args = (params.args && typeof params.args === 'object' ? params.args : {}) as Record<string, unknown>
|
||||
const r = await window.ipc.invoke('composio:execute-tool', { toolkitSlug: s, toolSlug: tool, arguments: args })
|
||||
if (!r.successful) throw new Error(r.error || 'Action failed.')
|
||||
return r.data
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown bridge method "${method}".`)
|
||||
}
|
||||
}
|
||||
|
||||
// Apps load their own data.json (served sibling) via a relative fetch; the
|
||||
// host provides per-app UI state and the current theme on ready.
|
||||
function handleReady() {
|
||||
postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current })
|
||||
postToFrame({ type: MINI_APP_MESSAGE.theme, theme: currentTheme() })
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
const frameWindow = iframeRef.current?.contentWindow
|
||||
if (!frameWindow || event.source !== frameWindow) return
|
||||
|
||||
const msg = event.data as MiniAppOutboundMessage
|
||||
if (!msg || typeof msg !== 'object') return
|
||||
|
||||
switch (msg.type) {
|
||||
case MINI_APP_MESSAGE.ready: {
|
||||
void handleReady()
|
||||
break
|
||||
}
|
||||
case MINI_APP_MESSAGE.rpc: {
|
||||
const { id, method, params } = msg
|
||||
handleRpc(method, (params ?? {}) as Record<string, unknown>)
|
||||
.then((result) => postToFrame({ type: MINI_APP_MESSAGE.rpcResult, id, ok: true, result }))
|
||||
.catch((err) => postToFrame({
|
||||
type: MINI_APP_MESSAGE.rpcResult,
|
||||
id,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}))
|
||||
break
|
||||
}
|
||||
case MINI_APP_MESSAGE.setState: {
|
||||
stateRef.current = { ...stateRef.current, ...(msg.patch as Record<string, unknown>) }
|
||||
postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
// Live refresh: when this app's data.json changes on disk (agent run),
|
||||
// tell the shim to re-fetch so onData fires with fresh data.
|
||||
const offDataChanged = window.ipc.on('mini-apps:dataChanged', ({ id }) => {
|
||||
if (id === manifest.id) postToFrame({ type: MINI_APP_MESSAGE.dataUpdated })
|
||||
})
|
||||
// If this app itself was just (re)installed, reload the frame — covers an
|
||||
// app opened mid-install that rendered a partial/blank page. Reset src
|
||||
// (contentWindow.location is cross-origin and would throw).
|
||||
const offAppsChanged = window.ipc.on('mini-apps:appsChanged', ({ id }) => {
|
||||
const frame = iframeRef.current
|
||||
if (id === manifest.id && frame) frame.src = frame.src // eslint-disable-line no-self-assign
|
||||
})
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
offDataChanged()
|
||||
offAppsChanged()
|
||||
themeObserver.disconnect()
|
||||
}
|
||||
}, [manifest.id, scope])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={manifest.title}
|
||||
src={`app://miniapp/${manifest.id}/index.html`}
|
||||
className="h-full w-full border-0 bg-neutral-950"
|
||||
sandbox={SANDBOX}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
// Mini Apps — app HTML scaffolding shared by every app.
|
||||
//
|
||||
// `buildMiniAppHtml` wraps an app's markup/JS in a single self-contained HTML
|
||||
// document and injects the `window.rowboat` bridge shim before the app runs.
|
||||
//
|
||||
// Phase 1 is deliberately dependency-free: NO remote CDNs and NO in-browser
|
||||
// transpile. Apps are plain HTML + CSS + JS so they render reliably inside the
|
||||
// sandboxed, opaque-origin iframe and work offline. Later phases can layer in a
|
||||
// locally-bundled React runtime (esbuild-at-save) without changing the bridge.
|
||||
|
||||
/**
|
||||
* The bridge shim, injected as a plain <script> before the app's script. It
|
||||
* defines `window.rowboat` and speaks the postMessage protocol in ./types.ts.
|
||||
* This is the only channel the app has to the host.
|
||||
*/
|
||||
const BRIDGE_SHIM = /* js */ `
|
||||
(function () {
|
||||
var data = null, dataLoaded = false, state = null, theme = 'dark';
|
||||
var dataCbs = [], stateCbs = [], themeCbs = [];
|
||||
var pending = {}, seq = 0;
|
||||
function post(msg) { parent.postMessage(msg, '*'); }
|
||||
function applyTheme(t) {
|
||||
theme = t === 'light' ? 'light' : 'dark';
|
||||
var el = document.documentElement;
|
||||
el.classList.remove('light', 'dark'); el.classList.add(theme);
|
||||
el.setAttribute('data-theme', theme);
|
||||
el.style.colorScheme = theme;
|
||||
themeCbs.forEach(function (cb) { try { cb(theme); } catch (_) {} });
|
||||
}
|
||||
function rpc(method, params) {
|
||||
var id = 'r' + (++seq);
|
||||
return new Promise(function (resolve, reject) {
|
||||
pending[id] = { resolve: resolve, reject: reject };
|
||||
post({ type: 'rowboat:mini-app:rpc', id: id, method: method, params: params });
|
||||
});
|
||||
}
|
||||
// data.json is a served sibling of index.html — apps load it themselves.
|
||||
function loadData() {
|
||||
return fetch('data.json', { cache: 'no-store' })
|
||||
.then(function (r) { return r && r.ok ? r.json() : null; })
|
||||
.then(function (d) { data = d; dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} }); return data; })
|
||||
.catch(function () { dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(null); } catch (_) {} }); return null; });
|
||||
}
|
||||
window.addEventListener('message', function (e) {
|
||||
var m = e.data;
|
||||
if (!m || typeof m !== 'object') return;
|
||||
if (m.type === 'rowboat:mini-app:state') {
|
||||
state = m.state;
|
||||
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
|
||||
} else if (m.type === 'rowboat:mini-app:theme') {
|
||||
applyTheme(m.theme);
|
||||
} else if (m.type === 'rowboat:mini-app:data-updated') {
|
||||
loadData();
|
||||
} else if (m.type === 'rowboat:mini-app:rpc-result') {
|
||||
var p = pending[m.id];
|
||||
if (p) {
|
||||
delete pending[m.id];
|
||||
if (m.ok) p.resolve(m.result); else p.reject(new Error(m.error || 'request failed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
window.rowboat = {
|
||||
getData: function () { return data; },
|
||||
refreshData: function () { return loadData(); },
|
||||
getTheme: function () { return theme; },
|
||||
onTheme: function (cb) { themeCbs.push(cb); try { cb(theme); } catch (_) {} return function () { var i = themeCbs.indexOf(cb); if (i >= 0) themeCbs.splice(i, 1); }; },
|
||||
onData: function (cb) {
|
||||
dataCbs.push(cb);
|
||||
if (dataLoaded) { try { cb(data); } catch (_) {} }
|
||||
return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); };
|
||||
},
|
||||
getState: function () { return state; },
|
||||
onState: function (cb) {
|
||||
stateCbs.push(cb);
|
||||
if (state !== null) { try { cb(state); } catch (_) {} }
|
||||
return function () { var i = stateCbs.indexOf(cb); if (i >= 0) stateCbs.splice(i, 1); };
|
||||
},
|
||||
setState: function (patch) {
|
||||
state = Object.assign({}, state || {}, patch);
|
||||
post({ type: 'rowboat:mini-app:setState', patch: patch });
|
||||
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
|
||||
},
|
||||
// Execute a Composio tool by slug within the app's scope. Resolves to the
|
||||
// tool result, rejects with an Error (e.g. not connected / out of scope).
|
||||
callAction: function (scope, tool, args) { return rpc('callAction', { scope: scope, tool: tool, args: args }); },
|
||||
// Find tool slugs within a toolkit. Resolves to [{ slug, name, description }].
|
||||
searchTools: function (scope, query) { return rpc('searchTools', { scope: scope, query: query }); },
|
||||
// Resolve to true/false whether the toolkit is connected.
|
||||
isConnected: function (scope) { return rpc('isConnected', { scope: scope }); },
|
||||
// Trigger the Composio OAuth flow for the toolkit. Resolves when started.
|
||||
connect: function (scope) { return rpc('connect', { scope: scope }); },
|
||||
// CORS-safe HTTP via the main process (for third-party APIs without CORS).
|
||||
// Resolves to { ok, status, text, json } (json = parsed body or null).
|
||||
fetch: function (url, opts) {
|
||||
return rpc('fetch', { url: url, method: (opts && opts.method), headers: (opts && opts.headers), body: (opts && opts.body) })
|
||||
.then(function (r) { var j = null; try { j = JSON.parse(r.text); } catch (_) {} r.json = j; return r; });
|
||||
},
|
||||
ready: function () { post({ type: 'rowboat:mini-app:ready' }); },
|
||||
};
|
||||
loadData();
|
||||
})();
|
||||
`
|
||||
|
||||
/**
|
||||
* Build a complete self-contained HTML document for a Mini App.
|
||||
* @param title Document title.
|
||||
* @param style App CSS (inlined into a <style> tag).
|
||||
* @param body Initial body markup (often just a mount node).
|
||||
* @param script App JS. Runs after the bridge shim; `window.rowboat` is ready.
|
||||
*/
|
||||
export function buildMiniAppHtml({
|
||||
title,
|
||||
style = '',
|
||||
body = '<div id="root"></div>',
|
||||
script,
|
||||
}: {
|
||||
title: string
|
||||
style?: string
|
||||
body?: string
|
||||
script: string
|
||||
}): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||
${style}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
<script>${BRIDGE_SHIM}</script>
|
||||
<script>
|
||||
${script}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
// Mini Apps — shared types and the host <-> iframe bridge protocol.
|
||||
//
|
||||
// Phase 1 is UI-only: apps are hardcoded in the renderer (see registry.ts) and
|
||||
// rendered in a sandboxed iframe (see components/mini-app-frame.tsx). The shapes
|
||||
// here intentionally mirror the eventual on-disk model (one self-contained folder
|
||||
// per app under ~/.rowboat/apps/<id>/) so later phases can slot in without a
|
||||
// rewrite.
|
||||
|
||||
/** A single Mini App. */
|
||||
export type MiniApp = {
|
||||
/** Stable slug; also the eventual on-disk folder name. The card's accent
|
||||
* theme and decorative pattern are derived deterministically from this. */
|
||||
id: string
|
||||
/** Display name shown on the card and in the open view. */
|
||||
name: string
|
||||
/** One-line description for the card (clamped to 2 lines). */
|
||||
description: string
|
||||
/** Primary integration shown in the card footer pill (e.g. 'Twitter'). */
|
||||
source: string
|
||||
/** Whether the app's agent is currently active (drives the status badge). */
|
||||
active: boolean
|
||||
/** Human last-run label for the card footer (e.g. '2m ago'). Static in V1. */
|
||||
lastRun: string
|
||||
/**
|
||||
* Composio integration scope this app is allowed to touch. Drives bridge
|
||||
* enforcement and the auth prompt in later phases; informational in Phase 1.
|
||||
*/
|
||||
scope: string[]
|
||||
/**
|
||||
* The app's frontend: a single self-contained HTML document (React + Tailwind
|
||||
* + Babel via CDN). Rendered via the iframe `srcdoc` attribute.
|
||||
*/
|
||||
html: string
|
||||
/**
|
||||
* The latest agent "backend" output. Static in Phase 1; produced by the agent
|
||||
* on a trigger in later phases. Delivered to the iframe via the bridge.
|
||||
*/
|
||||
data: unknown
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge protocol (host <-> iframe via postMessage).
|
||||
//
|
||||
// The app code inside the iframe talks to a small `window.rowboat` shim (injected
|
||||
// as part of the app HTML) which speaks these messages. This is both the product
|
||||
// surface the app codes against and the security boundary.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Host RPC methods the app can call (all scope-checked against the app's
|
||||
* declared integration scope by the host before they run):
|
||||
* - callAction: execute a Composio tool by slug params: { scope, tool, args? }
|
||||
* - searchTools: find tool slugs within a toolkit params: { scope, query }
|
||||
* - isConnected: is the toolkit connected? params: { scope }
|
||||
* - connect: trigger the Composio OAuth flow params: { scope }
|
||||
*/
|
||||
export type MiniAppRpcMethod = 'callAction' | 'searchTools' | 'isConnected' | 'connect' | 'fetch'
|
||||
|
||||
/** Messages sent from the iframe (app) up to the host (renderer). */
|
||||
export type MiniAppOutboundMessage =
|
||||
/** Handshake: app is mounted and wants its initial data + state. */
|
||||
| { type: 'rowboat:mini-app:ready' }
|
||||
/** A request/response call into the host, correlated by id. */
|
||||
| { type: 'rowboat:mini-app:rpc'; id: string; method: MiniAppRpcMethod; params?: unknown }
|
||||
/** App wants to persist a patch to its per-app state store. */
|
||||
| { type: 'rowboat:mini-app:setState'; patch: unknown }
|
||||
|
||||
/** Messages sent from the host (renderer) down to the iframe (app). */
|
||||
export type MiniAppInboundMessage =
|
||||
/** Latest agent data; sent on ready and whenever data refreshes. */
|
||||
| { type: 'rowboat:mini-app:data'; data: unknown }
|
||||
/** Current per-app state; sent on ready and after setState. */
|
||||
| { type: 'rowboat:mini-app:state'; state: unknown }
|
||||
/** Host theme; sent on ready and whenever the app theme changes. */
|
||||
| { type: 'rowboat:mini-app:theme'; theme: 'light' | 'dark' }
|
||||
/** data.json changed on disk — the shim re-fetches and fires onData. */
|
||||
| { type: 'rowboat:mini-app:data-updated' }
|
||||
/** Result of a previously requested rpc call, correlated by id. */
|
||||
| { type: 'rowboat:mini-app:rpc-result'; id: string; ok: boolean; result?: unknown; error?: string }
|
||||
|
||||
export const MINI_APP_MESSAGE = {
|
||||
ready: 'rowboat:mini-app:ready',
|
||||
rpc: 'rowboat:mini-app:rpc',
|
||||
setState: 'rowboat:mini-app:setState',
|
||||
data: 'rowboat:mini-app:data',
|
||||
state: 'rowboat:mini-app:state',
|
||||
theme: 'rowboat:mini-app:theme',
|
||||
dataUpdated: 'rowboat:mini-app:data-updated',
|
||||
rpcResult: 'rowboat:mini-app:rpc-result',
|
||||
} as const
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
43
apps/x/packages/core/src/apps/constants.ts
Normal file
43
apps/x/packages/core/src/apps/constants.ts
Normal 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}`;
|
||||
}
|
||||
189
apps/x/packages/core/src/apps/indexer.ts
Normal file
189
apps/x/packages/core/src/apps/indexer.ts
Normal 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 });
|
||||
}
|
||||
762
apps/x/packages/core/src/apps/server.ts
Normal file
762
apps/x/packages/core/src/apps/server.ts
Normal 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.4–7.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()));
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
`,
|
||||
}
|
||||
|
|
@ -33,6 +33,9 @@ export type BackgroundTask = {
|
|||
projectId?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
// Folder slug of the Rowboat App that installed this task (spec §8.2).
|
||||
// Runtime-managed; tasks with sourceApp are owned by the app lifecycle.
|
||||
sourceApp?: string;
|
||||
createdAt: string;
|
||||
// Runtime-managed — never hand-write. Mirrors live-note's flat-field
|
||||
// pattern: `lastAttemptAt` is bumped at every run start (backoff anchor),
|
||||
|
|
@ -53,6 +56,7 @@ export type BackgroundTaskSummary = {
|
|||
active: boolean;
|
||||
triggers?: Triggers;
|
||||
projectId?: string;
|
||||
sourceApp?: string;
|
||||
createdAt: string;
|
||||
lastAttemptAt?: string;
|
||||
lastRunId?: string;
|
||||
|
|
@ -71,6 +75,7 @@ export const BackgroundTaskSchema = z.object({
|
|||
projectId: z.string().optional().describe('When set, marks this as a coding task pinned to a registered code project (repo). The agent implements detected work via the launch-code-task tool, each launch in its own isolated worktree.'),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-task model override.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-task provider name override.'),
|
||||
sourceApp: z.string().optional().describe('Folder slug of the app that installed this task. Runtime-managed.'),
|
||||
createdAt: z.string().describe('ISO timestamp set once at create-time.'),
|
||||
lastAttemptAt: z.string().optional().describe('Runtime-managed — never write this yourself. Bumped at the start of every agent run; used by the scheduler for backoff so failures do not retry-storm.'),
|
||||
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself. The id of the most recent run (success or failure); used by the bg-task:stop handler.'),
|
||||
|
|
@ -86,6 +91,7 @@ export const BackgroundTaskSummarySchema = z.object({
|
|||
active: z.boolean(),
|
||||
triggers: TriggersSchema.optional(),
|
||||
projectId: z.string().optional(),
|
||||
sourceApp: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
lastAttemptAt: z.string().optional(),
|
||||
lastRunId: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -19,5 +19,5 @@ export * as browserControl from './browser-control.js';
|
|||
export * as billing from './billing.js';
|
||||
export * as notificationSettings from './notification-settings.js';
|
||||
export * as codeSessions from './code-sessions.js';
|
||||
export * as miniApp from './mini-app.js';
|
||||
export * as rowboatApp from './rowboat-app.js';
|
||||
export { PrefixLogger };
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
import { UserMessageContent } from './message.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
import { MiniAppManifest } from './mini-app.js';
|
||||
import { AppSummarySchema } from './rowboat-app.js';
|
||||
import { BrowserStateSchema } from './browser-control.js';
|
||||
import { BillingInfoSchema } from './billing.js';
|
||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||
|
|
@ -1008,60 +1008,34 @@ const ipcSchemas = {
|
|||
shouldShow: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Mini Apps: seed built-in apps to ~/.rowboat/apps/<id>/ (idempotent).
|
||||
'mini-apps:seed': {
|
||||
req: z.object({
|
||||
apps: z.array(z.object({
|
||||
manifest: MiniAppManifest,
|
||||
html: z.string(),
|
||||
data: z.unknown().optional(),
|
||||
})),
|
||||
}),
|
||||
// Rowboat Apps (spec §13) — M1 local channels.
|
||||
'apps:list': {
|
||||
req: z.object({}),
|
||||
res: z.object({
|
||||
seeded: z.array(z.string()),
|
||||
serverRunning: z.boolean(),
|
||||
serverError: z.string().optional(),
|
||||
apps: z.array(AppSummarySchema),
|
||||
}),
|
||||
},
|
||||
// Mini Apps: list installed app manifests from ~/.rowboat/apps/.
|
||||
'mini-apps:list': {
|
||||
req: z.null(),
|
||||
'apps:get': {
|
||||
req: z.object({ folder: z.string() }),
|
||||
res: z.object({
|
||||
manifests: z.array(MiniAppManifest),
|
||||
app: AppSummarySchema,
|
||||
readme: z.string().optional(),
|
||||
rollbackAvailable: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Mini Apps: read an app's latest data.json (agent output).
|
||||
'mini-apps:get-data': {
|
||||
req: z.object({ id: z.string() }),
|
||||
res: z.object({ data: z.unknown().nullable() }),
|
||||
'apps:create': {
|
||||
req: z.object({ folder: z.string(), name: z.string(), description: z.string() }),
|
||||
res: z.object({ app: AppSummarySchema }),
|
||||
},
|
||||
// Mini Apps: event pushed main→renderer when an app's data.json changes on
|
||||
// disk (agent refresh), so an open app can reload its data live.
|
||||
'mini-apps:dataChanged': {
|
||||
req: z.object({ id: z.string() }),
|
||||
res: z.null(),
|
||||
'apps:delete': {
|
||||
req: z.object({ folder: z.string() }),
|
||||
res: z.object({ ok: z.literal(true) }),
|
||||
},
|
||||
// Mini Apps: event pushed main→renderer when an app is installed/updated
|
||||
// (manifest or index.html changed), so the gallery re-lists and an open app
|
||||
// reloads once the install settles.
|
||||
'mini-apps:appsChanged': {
|
||||
req: z.object({ id: z.string() }),
|
||||
res: z.null(),
|
||||
},
|
||||
// Mini Apps: proxy an HTTP request through main (bypasses browser CORS for the
|
||||
// sandboxed app origin). GET/POST to http(s) only.
|
||||
'mini-apps:fetch': {
|
||||
req: z.object({
|
||||
url: z.string(),
|
||||
method: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
body: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
ok: z.boolean(),
|
||||
status: z.number(),
|
||||
statusText: z.string(),
|
||||
text: z.string(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
'apps:setTheme': {
|
||||
req: z.object({ theme: z.enum(['light', 'dark']) }),
|
||||
res: z.object({ ok: z.literal(true) }),
|
||||
},
|
||||
'composio:didConnect': {
|
||||
req: z.object({
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Manifest for a Mini App stored at ~/.rowboat/apps/<id>/manifest.json.
|
||||
*
|
||||
* Static assets are served from `<id>/dist/` via app://miniapp/<id>/. The
|
||||
* optional `agent` links a background task (bg-tasks engine) that writes
|
||||
* `<id>/data.json`, which the host reads and pushes to the app.
|
||||
*/
|
||||
export const MiniAppManifest = z.object({
|
||||
/** Stable slug; also the on-disk folder name and the app:// host path. */
|
||||
id: z.string(),
|
||||
/** Display name shown on the card and in the open view. */
|
||||
title: z.string(),
|
||||
/** One-line description for the card. */
|
||||
description: z.string().default(''),
|
||||
/** Primary integration shown in the card footer pill (e.g. 'GitHub'). */
|
||||
source: z.string().default(''),
|
||||
/** Composio toolkits this app may use; enforced host-side on bridge calls. */
|
||||
scope: z.array(z.string()).default([]),
|
||||
/** Whether the app's agent is active (drives the status badge). */
|
||||
active: z.boolean().default(true),
|
||||
/** Human last-run label for the card footer (e.g. '2m ago'). */
|
||||
lastRun: z.string().default(''),
|
||||
/** Entry file within the app folder, served via app://miniapp/<id>/. */
|
||||
entry: z.string().default('dist/index.html'),
|
||||
/** Optional associated background-task slug that produces data.json. */
|
||||
agent: z.string().optional(),
|
||||
/**
|
||||
* Optional guard on what may be written to data.json (via mini-app-set-data).
|
||||
* Prevents a stray/legacy task from corrupting the app with the wrong shape or
|
||||
* wiping good series with empty ones.
|
||||
*/
|
||||
dataContract: z.object({
|
||||
/** Top-level keys that must be present and non-null. */
|
||||
requiredKeys: z.array(z.string()).default([]),
|
||||
/** Top-level keys that must be non-empty arrays. */
|
||||
nonEmptyArrayKeys: z.array(z.string()).default([]),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type MiniAppManifest = z.infer<typeof MiniAppManifest>;
|
||||
113
apps/x/packages/shared/src/rowboat-app.ts
Normal file
113
apps/x/packages/shared/src/rowboat-app.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Rowboat Apps schemas (spec §4.2, §5.1, §9.1, §11.4, §12.2).
|
||||
|
||||
export const PACKAGE_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
export const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest — rowboat-app.json (§4.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RowboatAppManifestSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
name: z.string().min(3).max(64).regex(PACKAGE_NAME_RE)
|
||||
.describe('Package identity. Globally unique and immutable once published.'),
|
||||
version: z.string().regex(SEMVER_RE)
|
||||
.describe('Strict semver, no prerelease/build suffix in V1.'),
|
||||
description: z.string().max(500).default(''),
|
||||
icon: z.string().optional()
|
||||
.describe('Path relative to dist/. Same traversal rules as entry.'),
|
||||
entry: z.string().default('index.html')
|
||||
.describe('Path relative to dist/. Serves as app root and SPA fallback.'),
|
||||
agents: z.array(z.string().regex(/^[a-z0-9][a-z0-9-_]*\.yaml$/)).default([])
|
||||
.describe('Filenames under agents/. Each must exist in the package.'),
|
||||
capabilities: z.array(z.string()).default([])
|
||||
.describe('Capability identifiers this app may use (D7): Composio toolkit slugs for /_rowboat/tools/*, plus the reserved identifiers "llm" (§7.6) and "copilot" (§7.7). Empty = none.'),
|
||||
dataContracts: z.array(z.object({
|
||||
file: z.string(), // path relative to data/, e.g. "data.json"
|
||||
requiredKeys: z.array(z.string()).default([]),
|
||||
nonEmptyArrayKeys: z.array(z.string()).default([]),
|
||||
})).default([])
|
||||
.describe('Write guards for specific data/ files: required top-level keys and keys that must stay non-empty arrays. Enforced on writes (§7.3, §8.6) so a buggy agent run cannot corrupt the app\'s data shape or wipe good series with empties.'),
|
||||
// RESERVED — validated if present, ignored by V1 runtime:
|
||||
build: z.object({ command: z.string() }).optional()
|
||||
.describe('RESERVED. Rowboat MUST NOT execute this in V1.'),
|
||||
minRowboatVersion: z.string().regex(SEMVER_RE).optional()
|
||||
.describe('RESERVED. Minimum compatible Rowboat version; not enforced in V1.'),
|
||||
}).passthrough(); // unknown fields survive round-trips (forward compatibility)
|
||||
|
||||
export type RowboatAppManifest = z.infer<typeof RowboatAppManifestSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install record — .rowboat-install.json (§12.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AppInstallRecordSchema = z.object({
|
||||
name: z.string(),
|
||||
repo: z.string().optional(), // owner/repo at install time; absent only for non-GitHub URL installs (§12.5)
|
||||
sourceUrl: z.string().optional(), // present only for §12.5 URL installs
|
||||
version: z.string(),
|
||||
sha256: z.string(), // bundle checksum, pinned at install
|
||||
installedAt: z.string(),
|
||||
updatedAt: z.string().optional(),
|
||||
files: z.record(z.string(), z.string()), // relpath → sha256 of release-managed files
|
||||
previousVersion: z.string().optional(), // set while .previous/ exists
|
||||
});
|
||||
|
||||
export type AppInstallRecord = z.infer<typeof AppInstallRecordSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Publish record — .rowboat-publish.json (§11.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AppPublishRecordSchema = z.object({
|
||||
name: z.string(),
|
||||
login: z.string(), // publisher GitHub login
|
||||
repo: z.string(), // owner/repo
|
||||
lastPublishedVersion: z.string().optional(),
|
||||
lastSha256: z.string().optional(),
|
||||
pendingSteps: z.object({ // present only mid-publish (resume state)
|
||||
version: z.string(),
|
||||
completed: z.array(z.string()), // step names from §11.2
|
||||
releaseId: z.number().optional(),
|
||||
prUrl: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type AppPublishRecord = z.infer<typeof AppPublishRecordSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App summary — apps:list / apps:get (§5.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AppSummarySchema = z.object({
|
||||
folder: z.string(), // folder slug
|
||||
status: z.enum(['ok', 'invalid']),
|
||||
manifest: RowboatAppManifestSchema.optional(), // present when ok
|
||||
manifestError: z.string().optional(), // present when invalid
|
||||
origin: z.string(), // http://<folder>.apps.localhost:3210
|
||||
kind: z.enum(['local', 'installed']),
|
||||
install: AppInstallRecordSchema.optional(), // §12.2
|
||||
publish: AppPublishRecordSchema.optional(), // §11.4
|
||||
hasDist: z.boolean(),
|
||||
agentSlugs: z.array(z.string()), // materialized bg-task slugs (§8.3)
|
||||
});
|
||||
|
||||
export type AppSummary = z.infer<typeof AppSummarySchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry record — apps/<name>.json in the registry repo (§9.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RegistryRecordSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
name: z.string().min(3).max(64).regex(PACKAGE_NAME_RE),
|
||||
owner: z.string().min(1), // GitHub login of the publisher
|
||||
repo: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), // "owner/repo"
|
||||
description: z.string().max(500).default(''),
|
||||
iconUrl: z.string().url().optional(), // https URL for the catalog listing icon
|
||||
createdAt: z.string(), // ISO 8601
|
||||
}).strict();
|
||||
|
||||
export type RegistryRecord = z.infer<typeof RegistryRecordSchema>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue