@khanglvm/relay 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/docs/AGENT.md +35 -3
- package/package.json +2 -2
- package/src/cli.js +41 -0
- package/src/open.js +12 -0
- package/src/server.js +193 -0
- package/src/spec.js +126 -8
- package/src/store.js +4 -0
- package/src/ui/app.js +295 -31
- package/src/ui/blocks.css +144 -2
- package/src/ui/blocks.js +407 -60
- package/src/ui/style.css +47 -1
package/README.md
CHANGED
|
@@ -45,6 +45,7 @@ refreshes the skill in one step, leaving any boards you have open untouched.
|
|
|
45
45
|
| "Option B is the one with caching (see my last message)" | Each answer option carries its own image / chart / diagram — pick by looking |
|
|
46
46
|
| ASCII architecture art | Mermaid, Graphviz, PlantUML — zoomable, full-screen, even user-editable |
|
|
47
47
|
| Numbers buried in prose | Charts and sortable tables; screenshots and HTML prototypes in a sandbox |
|
|
48
|
+
| "Here's the diff — paste it in your editor" | Side-by-side **diff** blocks, syntax-highlighted **code**, **video** walkthroughs, and **file paths you click to open** in the default app |
|
|
48
49
|
| "Type *done* when finished reviewing" | A Submit button; answers, notes, and inline comments returned as JSON |
|
|
49
50
|
| Feedback = another wall of text | Click any chart point, diagram node, table cell, or sentence to comment — the agent replies and the thread grows on the board |
|
|
50
51
|
|
|
@@ -71,6 +72,34 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
|
|
|
71
72
|
|
|
72
73
|
## Changelog
|
|
73
74
|
|
|
75
|
+
### 0.10.0 — open files, richer code, diffs & video
|
|
76
|
+
- **Clickable local file-links.** Write a path in any markdown (`~/clip.mp4`,
|
|
77
|
+
`./src/app.ts`, `/abs/report.pdf`, a `file://` URL, a backtick-wrapped path,
|
|
78
|
+
or `[label](path)`) and it renders as a link that opens the file in the OS
|
|
79
|
+
default app — guarded by a same-origin check + an allowlist of paths the
|
|
80
|
+
board actually references. `RLY_OPEN_CMD` overrides the opener.
|
|
81
|
+
- **`code` blocks leveled up** — syntax highlighting for ~20 languages (js, ts,
|
|
82
|
+
py, go, rust, java, c, cpp, csharp, ruby, php, swift, kotlin, sql, yaml, json,
|
|
83
|
+
sh, css, html…), a line-number gutter, a filename/lang header, and `codeFile`
|
|
84
|
+
to load source straight from a local file.
|
|
85
|
+
- **`diff` block** — render a unified git diff as a colored, line-numbered
|
|
86
|
+
comparison with a live **Unified ⇄ Split (side-by-side)** toggle. No git
|
|
87
|
+
required; the agent supplies the diff text (`diff`/`diffFile`, `view`).
|
|
88
|
+
- **`video` block** — YouTube/Vimeo embeds, a direct media URL, or a local
|
|
89
|
+
video file streamed from the server with HTTP Range (seekable), never
|
|
90
|
+
embedded in the payload.
|
|
91
|
+
- **Durable drafts / rescue** — every autosave mirrors to `localStorage`; a
|
|
92
|
+
board whose connection drops blocks further input instead of losing it, and
|
|
93
|
+
`rly rescue <id>` re-serves on the same port so an open tab reconnects.
|
|
94
|
+
- Still **zero runtime dependencies**, offline, and cross-platform.
|
|
95
|
+
|
|
96
|
+
### 0.9.1
|
|
97
|
+
- The board **intro renders as markdown** (bold/italic/code/links/lists).
|
|
98
|
+
|
|
99
|
+
### 0.9.0 — interactive visual annotations
|
|
100
|
+
- Drag/zoom/full-screen viewer, per-element **and** whole-block comments, chart
|
|
101
|
+
data-point comment badges, and inline-SVG PlantUML rendering.
|
|
102
|
+
|
|
74
103
|
### 0.8.1
|
|
75
104
|
- `rly install` adds **OpenCode** (`~/.config/opencode/AGENTS.md`) and **Droid /
|
|
76
105
|
Factory** (`~/.factory/AGENTS.md`) targets.
|
package/docs/AGENT.md
CHANGED
|
@@ -249,8 +249,28 @@ Rules of thumb:
|
|
|
249
249
|
}
|
|
250
250
|
// columns may also be plain string array; rows may be parallel arrays [[val,val],...]
|
|
251
251
|
|
|
252
|
-
// Code —
|
|
253
|
-
|
|
252
|
+
// Code — syntax-highlighted + line-numbered. Inline "code" or load a local
|
|
253
|
+
// file with "codeFile" (lang then defaults from the extension). "filename"
|
|
254
|
+
// shows a header label. Highlighted langs: js ts py go rust java c cpp csharp
|
|
255
|
+
// ruby php swift kotlin sql yaml json sh css html (+ aliases) — others plain.
|
|
256
|
+
{ "type": "code", "lang": "js", "code": "const x = 1 + 2;", "filename": "demo.js" }
|
|
257
|
+
{ "type": "code", "codeFile": "src/server.js" }
|
|
258
|
+
|
|
259
|
+
// Diff — a unified diff (git diff / `diff -u` output) rendered as a colored,
|
|
260
|
+
// line-numbered comparison: +added / −removed / context, file & hunk headers.
|
|
261
|
+
// No git needed — just write/paste the diff text. "lang" tints each code line;
|
|
262
|
+
// "diffFile" loads it from a local file. "view":"split" starts side-by-side
|
|
263
|
+
// (old vs new); the viewer has a live Unified⇄Split toggle either way.
|
|
264
|
+
{
|
|
265
|
+
"type": "diff", "lang": "js", "filename": "src/auth.js", "view": "split",
|
|
266
|
+
"diff": "@@ -1,3 +1,3 @@\n function login(u) {\n- return check(u)\n+ return check(u.trim())\n }"
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Video — a YouTube/Vimeo URL embeds a player; an http(s) media URL or a local
|
|
270
|
+
// video file (mp4/webm/ogv/mov/mkv/m4v) plays inline. Local files STREAM from
|
|
271
|
+
// the server (Range-enabled, seekable) and are never embedded in the payload.
|
|
272
|
+
{ "type": "video", "src": "https://youtu.be/dQw4w9WgXcQ", "title": "Demo walkthrough" }
|
|
273
|
+
{ "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
|
|
254
274
|
|
|
255
275
|
// HTML — sandboxed iframe; default height 360
|
|
256
276
|
{ "type": "html", "html": "<h1>Hello</h1>", "height": 360 }
|
|
@@ -262,6 +282,16 @@ Rules of thumb:
|
|
|
262
282
|
{ "type": "image", "src": "https://example.com/mock.png" }
|
|
263
283
|
```
|
|
264
284
|
|
|
285
|
+
### Local file links — clickable, open in the default app
|
|
286
|
+
|
|
287
|
+
Inside any **markdown** (the intro or a `markdown` block) just write a local
|
|
288
|
+
file path — `~/clip.mp4`, `./src/app.ts`, `/abs/report.pdf`, a `file://` URL,
|
|
289
|
+
or a backtick-wrapped path — and it renders as a click-to-open link. Clicking it
|
|
290
|
+
asks relay to open that file in the user's OS default app (video player, editor,
|
|
291
|
+
viewer, …); a `[label](~/path)` link works too. Only paths you actually wrote on
|
|
292
|
+
the board can be opened (same-origin + allowlist guarded), so prefer surfacing a
|
|
293
|
+
real path over telling the user to paste it into a terminal.
|
|
294
|
+
|
|
265
295
|
### When to use which block
|
|
266
296
|
|
|
267
297
|
| Block | Best for |
|
|
@@ -272,7 +302,9 @@ Rules of thumb:
|
|
|
272
302
|
| `chart` | numbers, trends, comparisons, metrics |
|
|
273
303
|
| `table` | structured comparisons, option matrices, data grids — **use this for any tabular data**: it's sortable and every cell is commentable, unlike a markdown pipe table |
|
|
274
304
|
| `markdown` | prose context, background, instructions, section headings (renders GFM pipe tables too, but reach for a `table` block for real data) |
|
|
275
|
-
| `code` | code snippets, config examples, command output |
|
|
305
|
+
| `code` | code snippets, config examples, command output — syntax-highlighted + line-numbered; load from a file with `codeFile` |
|
|
306
|
+
| `diff` | proposed code changes / before-after — a unified diff rendered as a colored git-style comparison (no git needed) |
|
|
307
|
+
| `video` | demos, screen recordings, walkthroughs — YouTube/Vimeo embeds, a media URL, or a local video file (streamed) |
|
|
276
308
|
| `image` | screenshots, mockup exports, photos — local files embed and work offline |
|
|
277
309
|
| `html` | anything else — pixel-perfect mockups, custom widgets, embeds |
|
|
278
310
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Browser-based question boards with rich blocks (markdown, charts, mermaid, tables, code, sandboxed HTML) and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, wait for submit, read answers as JSON.",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Browser-based question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, wait for submit, read answers as JSON.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
7
7
|
"claude-code",
|
package/src/cli.js
CHANGED
|
@@ -295,6 +295,42 @@ async function cmdReopen(args) {
|
|
|
295
295
|
return runOrDetach(record, args);
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
// Rescue a board whose browser tab is still open but disconnected (its server
|
|
299
|
+
// died / the machine slept). Re-serves the SAME board on the SAME port it last
|
|
300
|
+
// used, so the open tab's relative /api/* fetches reconnect on their own — the
|
|
301
|
+
// page's recovery loop lifts its "connection lost" block and re-flushes the
|
|
302
|
+
// draft (incl. anything the user mirrored to localStorage during the outage)
|
|
303
|
+
// with zero action from the user. Defaults to NOT opening a new browser tab
|
|
304
|
+
// (the point is the existing one); pass --open to also open a fresh tab.
|
|
305
|
+
async function cmdRescue(args) {
|
|
306
|
+
const record = mustLoad(args._[0]);
|
|
307
|
+
const running = loadRunning(record.id);
|
|
308
|
+
if (running && isAlive(running.pid)) {
|
|
309
|
+
if (args.open) openUrl(running.url);
|
|
310
|
+
printJson({
|
|
311
|
+
status: 'open',
|
|
312
|
+
boardId: record.id,
|
|
313
|
+
url: running.url,
|
|
314
|
+
port: running.port,
|
|
315
|
+
note: 'already running — the open tab should be connected; reload it if not',
|
|
316
|
+
});
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
if (!record.lastPort) {
|
|
320
|
+
throw new CliError(
|
|
321
|
+
`board "${record.id}" has no known port to reuse (never served in this version). Use \`rly reopen ${record.id}\` instead.`,
|
|
322
|
+
5
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
// Force the original port so the disconnected tab can reconnect; default to
|
|
326
|
+
// not opening a second tab. seedAgentReplies parity with reopen if provided.
|
|
327
|
+
if (args.replies !== undefined) {
|
|
328
|
+
const replies = parseJson(readFileOrThrow(args.replies), args.replies);
|
|
329
|
+
seedAgentReplies(record, replies);
|
|
330
|
+
}
|
|
331
|
+
return runOrDetach(record, { ...args, port: record.lastPort, open: args.open === true });
|
|
332
|
+
}
|
|
333
|
+
|
|
298
334
|
async function cmdReuse(args) {
|
|
299
335
|
const src = mustLoad(args._[0]);
|
|
300
336
|
if (args.dump) {
|
|
@@ -1141,6 +1177,9 @@ USAGE
|
|
|
1141
1177
|
rly open [id] re-open the browser tab of a running board
|
|
1142
1178
|
rly reopen <id> [--replies f.json] serve a saved board again, prefilled with saved answers
|
|
1143
1179
|
(--replies [{annotationId,text}] = agent answers to element comments)
|
|
1180
|
+
rly rescue <id> [--open] re-serve a board on its ORIGINAL port so a still-open but
|
|
1181
|
+
disconnected browser tab auto-reconnects & re-saves (no new tab
|
|
1182
|
+
unless --open). Use when a tab shows "connection lost".
|
|
1144
1183
|
rly reuse <id> [--dump] re-run a past board as a new board (--dump prints its spec)
|
|
1145
1184
|
rly update <id> --file spec.json live-mutate a RUNNING board (or --title/--intro/-q); page reloads
|
|
1146
1185
|
rly stop <id> | --all stop running board(s) (status: cancelled, draft preserved)
|
|
@@ -1196,6 +1235,8 @@ export async function main(argv) {
|
|
|
1196
1235
|
return await cmdAsk(parseArgs(rest), 'show');
|
|
1197
1236
|
case 'reopen':
|
|
1198
1237
|
return await cmdReopen(parseArgs(rest));
|
|
1238
|
+
case 'rescue':
|
|
1239
|
+
return await cmdRescue(parseArgs(rest));
|
|
1199
1240
|
case 'reuse':
|
|
1200
1241
|
return await cmdReuse(parseArgs(rest));
|
|
1201
1242
|
case 'update':
|
package/src/open.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
|
|
3
|
+
// Opens a URL OR a local file/folder path in the OS default handler (browser
|
|
4
|
+
// for http(s), the registered app for a file). On success the file opens in
|
|
5
|
+
// whatever the user set as default (video player, editor, image viewer, …).
|
|
6
|
+
//
|
|
7
|
+
// RLY_OPEN_CMD overrides the platform opener with a custom command — the target
|
|
8
|
+
// is passed as its sole argument ("$1"). Power users can point it at a chooser;
|
|
9
|
+
// the test suite points it at a no-op so opening a file launches nothing.
|
|
3
10
|
export function openUrl(url) {
|
|
4
11
|
try {
|
|
12
|
+
const custom = process.env.RLY_OPEN_CMD;
|
|
13
|
+
if (custom && custom.trim()) {
|
|
14
|
+
spawn('/bin/sh', ['-c', `${custom} "$1"`, 'sh', url], { stdio: 'ignore', detached: true }).unref();
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
5
17
|
const p = process.platform;
|
|
6
18
|
const [cmd, args] =
|
|
7
19
|
p === 'darwin' ? ['open', [url]]
|
package/src/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import crypto from 'node:crypto';
|
|
5
6
|
import { spawn } from 'node:child_process';
|
|
@@ -47,6 +48,11 @@ function clientBlock(b) {
|
|
|
47
48
|
if (b && b.type === 'image' && typeof b.src === 'string' && b.src.startsWith('data:')) {
|
|
48
49
|
return { id: b.id, type: 'image', alt: b.alt, height: b.height, hasData: true };
|
|
49
50
|
}
|
|
51
|
+
// Local video: the absolute file path stays server-side; the client gets a
|
|
52
|
+
// flag + mime and loads the bytes (Range-streamed) from /video/b/<id>.
|
|
53
|
+
if (b && b.type === 'video' && typeof b.file === 'string') {
|
|
54
|
+
return { id: b.id, type: 'video', title: b.title, height: b.height, mime: b.mime, hasFile: true };
|
|
55
|
+
}
|
|
50
56
|
return b;
|
|
51
57
|
}
|
|
52
58
|
|
|
@@ -113,6 +119,10 @@ function buildPage(record, rev) {
|
|
|
113
119
|
notes: record.draft.notes || {},
|
|
114
120
|
annotations: record.draft.annotations || [],
|
|
115
121
|
blockEdits: record.draft.blockEdits || {},
|
|
122
|
+
// The server draft's save time, so the client can pick the NEWER of this
|
|
123
|
+
// vs. its localStorage mirror (a tab that kept typing while the server
|
|
124
|
+
// was unreachable holds fresher input than the last server save).
|
|
125
|
+
updatedAt: record.draft.updatedAt || null,
|
|
116
126
|
}
|
|
117
127
|
: null;
|
|
118
128
|
const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev };
|
|
@@ -211,6 +221,103 @@ function firstQuestionHtml(q) {
|
|
|
211
221
|
return (q.blocks || []).find((b) => b && b.type === 'html');
|
|
212
222
|
}
|
|
213
223
|
|
|
224
|
+
// ---------- local-file links (POST /api/open) ----------
|
|
225
|
+
// The markdown renderer turns file paths an agent writes (~/x, ./x, /abs/x,
|
|
226
|
+
// file://…) into click-to-open links. Clicking POSTs the raw path here; the
|
|
227
|
+
// server resolves it against the board's authoring cwd and opens it in the OS
|
|
228
|
+
// default app — BUT only if the path is one the board actually references
|
|
229
|
+
// (allowlist below). That keeps a cross-site/blind POST from opening arbitrary
|
|
230
|
+
// files: the only openable paths are ones the agent already put on the board.
|
|
231
|
+
//
|
|
232
|
+
// FILE_PATH_RE / looksLikeLocalPath MUST stay in sync with the same logic in
|
|
233
|
+
// ui/blocks.js, so the set the server allows matches the set the page links.
|
|
234
|
+
const FILE_PATH_RE =
|
|
235
|
+
/(?<![\w@:./])(?:file:\/\/\/?[^\s)<>"'`*]+|~\/[^\s)<>"'`*]+|\.{1,2}\/[^\s)<>"'`*]+|\/[^\s)<>"'`*]+|[A-Za-z]:[\\/][^\s)<>"'`*]+)/g;
|
|
236
|
+
|
|
237
|
+
function looksLikeLocalPath(s) {
|
|
238
|
+
if (typeof s !== 'string') return false;
|
|
239
|
+
const t = s.trim();
|
|
240
|
+
if (!t || /\s/.test(t)) return false;
|
|
241
|
+
if (/^file:\/\//i.test(t)) return true;
|
|
242
|
+
if (/^[A-Za-z]:[\\/]/.test(t)) return true; // windows drive
|
|
243
|
+
if (t === '~' || /^~\//.test(t)) return true;
|
|
244
|
+
if (/^\.\.?\//.test(t)) return true; // ./ or ../
|
|
245
|
+
if (t.startsWith('/')) {
|
|
246
|
+
// a lone "/" or a one-segment "/word" is more likely punctuation/URL — only
|
|
247
|
+
// treat as a file when it has ≥2 segments or a file extension.
|
|
248
|
+
return /\/[^/]+\/[^/]/.test(t) || /\.[A-Za-z0-9]{1,8}$/.test(t);
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Expands ~ / file:// and resolves a (possibly relative) path to an absolute,
|
|
254
|
+
// normalized one against the board's authoring cwd. null on a malformed URL.
|
|
255
|
+
function resolveLocalPath(raw, baseCwd) {
|
|
256
|
+
let p = String(raw || '').trim();
|
|
257
|
+
if (!p) return null;
|
|
258
|
+
if (/^file:\/\//i.test(p)) {
|
|
259
|
+
try {
|
|
260
|
+
p = fileURLToPath(p);
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
} else if (p === '~' || p.startsWith('~/')) {
|
|
265
|
+
p = path.join(os.homedir(), p.slice(1));
|
|
266
|
+
}
|
|
267
|
+
if (!path.isAbsolute(p)) p = path.resolve(baseCwd || process.cwd(), p);
|
|
268
|
+
return path.normalize(p);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Every markdown source the page runs through its inline renderer (intro + any
|
|
272
|
+
// markdown block, board / question / option scoped). These are the only places
|
|
273
|
+
// file paths become clickable, so they define the open allowlist.
|
|
274
|
+
function collectMarkdownSources(spec) {
|
|
275
|
+
const out = [];
|
|
276
|
+
if (typeof spec.intro === 'string') out.push(spec.intro);
|
|
277
|
+
const addBlocks = (blocks) => {
|
|
278
|
+
for (const b of Array.isArray(blocks) ? blocks : []) {
|
|
279
|
+
if (b && b.type === 'markdown' && typeof b.md === 'string') out.push(b.md);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
addBlocks(spec.blocks);
|
|
283
|
+
for (const q of spec.questions || []) {
|
|
284
|
+
addBlocks(q.blocks);
|
|
285
|
+
for (const o of Array.isArray(q.options) ? q.options : []) {
|
|
286
|
+
if (o) addBlocks(o.blocks);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The set of absolute paths the board references and is therefore allowed to
|
|
293
|
+
// open. Rebuilt per request (specs are small) so it tracks live `rly update`s.
|
|
294
|
+
function buildOpenAllowlist(spec, baseCwd) {
|
|
295
|
+
const set = new Set();
|
|
296
|
+
const text = collectMarkdownSources(spec).join('\n');
|
|
297
|
+
const re = new RegExp(FILE_PATH_RE.source, 'g');
|
|
298
|
+
let m;
|
|
299
|
+
while ((m = re.exec(text))) {
|
|
300
|
+
if (!looksLikeLocalPath(m[0])) continue;
|
|
301
|
+
const abs = resolveLocalPath(m[0], baseCwd);
|
|
302
|
+
if (abs) set.add(abs);
|
|
303
|
+
}
|
|
304
|
+
return set;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// True when an Origin header (if present) belongs to this board's own server.
|
|
308
|
+
// Same-origin fetches send no Origin or our own; a foreign Origin is a
|
|
309
|
+
// cross-site POST and must not be allowed to open a local file.
|
|
310
|
+
function sameOrigin(req, port) {
|
|
311
|
+
const origin = req.headers.origin;
|
|
312
|
+
if (!origin) return true;
|
|
313
|
+
try {
|
|
314
|
+
const h = new URL(origin).host;
|
|
315
|
+
return h === `127.0.0.1:${port}` || h === `localhost:${port}`;
|
|
316
|
+
} catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
214
321
|
function sendJson(res, code, obj) {
|
|
215
322
|
if (res.headersSent) return;
|
|
216
323
|
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
@@ -243,6 +350,57 @@ function sendFromDir(res, dir, name, contentType) {
|
|
|
243
350
|
return true;
|
|
244
351
|
}
|
|
245
352
|
|
|
353
|
+
// Streams a file with HTTP Range support so a <video>/<audio> element can seek
|
|
354
|
+
// and the browser can request byte ranges instead of the whole clip. Honors a
|
|
355
|
+
// single "bytes=start-end" range; falls back to the full body otherwise. Safe
|
|
356
|
+
// for a HEAD probe (sends headers, no body).
|
|
357
|
+
function streamFile(req, res, filePath, contentType) {
|
|
358
|
+
let stat;
|
|
359
|
+
try {
|
|
360
|
+
stat = fs.statSync(filePath);
|
|
361
|
+
} catch {
|
|
362
|
+
return sendJson(res, 404, { error: 'file not found' });
|
|
363
|
+
}
|
|
364
|
+
const total = stat.size;
|
|
365
|
+
const range = req.headers.range;
|
|
366
|
+
const baseHeaders = {
|
|
367
|
+
'content-type': contentType,
|
|
368
|
+
'accept-ranges': 'bytes',
|
|
369
|
+
'cache-control': 'no-store',
|
|
370
|
+
};
|
|
371
|
+
let start = 0;
|
|
372
|
+
let end = total - 1;
|
|
373
|
+
let status = 200;
|
|
374
|
+
if (range) {
|
|
375
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
|
|
376
|
+
if (m) {
|
|
377
|
+
if (m[1] === '' && m[2] === '') {
|
|
378
|
+
// "bytes=-" — unsatisfiable
|
|
379
|
+
} else if (m[1] === '') {
|
|
380
|
+
start = Math.max(0, total - Number(m[2])); // suffix range
|
|
381
|
+
} else {
|
|
382
|
+
start = Number(m[1]);
|
|
383
|
+
if (m[2] !== '') end = Math.min(end, Number(m[2]));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (start > end || start >= total) {
|
|
387
|
+
res.writeHead(416, { 'content-range': `bytes */${total}`, 'cache-control': 'no-store' });
|
|
388
|
+
return res.end();
|
|
389
|
+
}
|
|
390
|
+
status = 206;
|
|
391
|
+
baseHeaders['content-range'] = `bytes ${start}-${end}/${total}`;
|
|
392
|
+
}
|
|
393
|
+
baseHeaders['content-length'] = String(end - start + 1);
|
|
394
|
+
res.writeHead(status, baseHeaders);
|
|
395
|
+
if (req.method === 'HEAD') return res.end();
|
|
396
|
+
const stream = fs.createReadStream(filePath, { start, end });
|
|
397
|
+
stream.on('error', () => {
|
|
398
|
+
if (!res.headersSent) sendJson(res, 500, { error: 'stream error' });
|
|
399
|
+
else res.destroy();
|
|
400
|
+
});
|
|
401
|
+
stream.pipe(res);
|
|
402
|
+
}
|
|
403
|
+
|
|
246
404
|
// Loaded into every custom-HTML iframe so users can hover any element to leave a
|
|
247
405
|
// comment (relayKit.annotate.auto). Idempotent with an author-added /kit.js, and
|
|
248
406
|
// a no-op when the author opts out via data-relay-annotate="off".
|
|
@@ -450,6 +608,12 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
450
608
|
if (!m) return sendJson(res, 404, { error: `no embedded image block "${blockId}"` });
|
|
451
609
|
res.writeHead(200, { 'content-type': m[1], 'cache-control': 'no-store' });
|
|
452
610
|
res.end(Buffer.from(m[2], 'base64'));
|
|
611
|
+
} else if ((req.method === 'GET' || req.method === 'HEAD') && pathname.startsWith('/video/b/')) {
|
|
612
|
+
// Local video bytes, Range-streamed so the <video> element can seek.
|
|
613
|
+
const blockId = decodeURIComponent(pathname.slice('/video/b/'.length));
|
|
614
|
+
const block = findBlock(record.spec, blockId, 'video');
|
|
615
|
+
if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local video block "${blockId}"` });
|
|
616
|
+
streamFile(req, res, block.file, block.mime || 'application/octet-stream');
|
|
453
617
|
} else if (req.method === 'GET' && pathname === '/html/board') {
|
|
454
618
|
// Legacy alias → the board's first html block.
|
|
455
619
|
const block = firstBoardHtml(record.spec);
|
|
@@ -464,6 +628,28 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
464
628
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
465
629
|
if (['auto', 'light', 'dark'].includes(body.theme)) savePref({ theme: body.theme });
|
|
466
630
|
sendJson(res, 200, { ok: true });
|
|
631
|
+
} else if (req.method === 'POST' && pathname === '/api/open') {
|
|
632
|
+
// Open a board-referenced local file in the OS default app. Guarded by
|
|
633
|
+
// a same-origin check + an allowlist of paths the board actually links.
|
|
634
|
+
if (!sameOrigin(req, actualPort)) return sendJson(res, 403, { error: 'cross-origin requests cannot open files' });
|
|
635
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
636
|
+
const raw = typeof body.path === 'string' ? body.path : '';
|
|
637
|
+
if (!raw.trim()) return sendJson(res, 400, { error: 'missing "path"' });
|
|
638
|
+
const baseCwd = record.cwd || process.cwd();
|
|
639
|
+
const target = resolveLocalPath(raw, baseCwd);
|
|
640
|
+
if (!target) return sendJson(res, 400, { error: 'invalid path' });
|
|
641
|
+
if (!buildOpenAllowlist(record.spec, baseCwd).has(target)) {
|
|
642
|
+
return sendJson(res, 403, { error: 'this path is not referenced on the board' });
|
|
643
|
+
}
|
|
644
|
+
let stat = null;
|
|
645
|
+
try {
|
|
646
|
+
stat = fs.statSync(target);
|
|
647
|
+
} catch {
|
|
648
|
+
stat = null;
|
|
649
|
+
}
|
|
650
|
+
if (!stat) return sendJson(res, 404, { error: 'file not found', path: target });
|
|
651
|
+
if (!openUrl(target)) return sendJson(res, 500, { error: 'could not open the file' });
|
|
652
|
+
sendJson(res, 200, { ok: true, path: target, name: path.basename(target) });
|
|
467
653
|
} else if (req.method === 'POST' && pathname === '/api/draft') {
|
|
468
654
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
469
655
|
record.draft = {
|
|
@@ -505,6 +691,13 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
505
691
|
});
|
|
506
692
|
const actualPort = server.address().port;
|
|
507
693
|
const url = `http://127.0.0.1:${actualPort}/`;
|
|
694
|
+
// Remember the port this board last bound, so `rly rescue <id>` can re-serve
|
|
695
|
+
// on the SAME port — letting a still-open (but disconnected) browser tab
|
|
696
|
+
// reconnect to its relative /api/* URLs without the user touching anything.
|
|
697
|
+
if (record.lastPort !== actualPort) {
|
|
698
|
+
record.lastPort = actualPort;
|
|
699
|
+
saveBoard(record);
|
|
700
|
+
}
|
|
508
701
|
saveRunning({
|
|
509
702
|
id: record.id,
|
|
510
703
|
pid: process.pid,
|
package/src/spec.js
CHANGED
|
@@ -25,9 +25,22 @@ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 3
|
|
|
25
25
|
|
|
26
26
|
// Block heights clamp to the same window; defaults vary per block type.
|
|
27
27
|
const BLOCK_HEIGHT = { min: 100, max: 2400 };
|
|
28
|
-
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'html', 'image'];
|
|
28
|
+
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'html', 'image'];
|
|
29
29
|
const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
|
|
30
30
|
|
|
31
|
+
// code/diff blocks may load their text from a local file (like htmlFile). Caps
|
|
32
|
+
// keep a runaway file from bloating the board payload.
|
|
33
|
+
const TEXT_FILE_MAX_BYTES = 512 * 1024;
|
|
34
|
+
|
|
35
|
+
// video blocks: a YouTube/Vimeo link embeds via iframe; an http(s) media URL or
|
|
36
|
+
// a local file plays in a <video> element (local files stream from the server,
|
|
37
|
+
// never embedded, so large clips don't bloat the board).
|
|
38
|
+
const VIDEO_MIMES = {
|
|
39
|
+
mp4: 'video/mp4', m4v: 'video/x-m4v', webm: 'video/webm', ogv: 'video/ogg',
|
|
40
|
+
ogg: 'video/ogg', mov: 'video/quicktime', mkv: 'video/x-matroska',
|
|
41
|
+
};
|
|
42
|
+
const VIDEO_MAX_BYTES = 512 * 1024 * 1024;
|
|
43
|
+
|
|
31
44
|
// image blocks: local files are embedded as data URIs at spec time (the page
|
|
32
45
|
// then loads them via /img/b/<id>), so boards stay self-contained offline.
|
|
33
46
|
const IMAGE_MIMES = {
|
|
@@ -71,6 +84,45 @@ function readBlockHtml(block, cwd, where) {
|
|
|
71
84
|
return '';
|
|
72
85
|
}
|
|
73
86
|
|
|
87
|
+
// Reads a code/diff block body from an inline string field or a local file.
|
|
88
|
+
// `inlineKey` is the inline field (e.g. "code"/"diff"); `fileKey` its file
|
|
89
|
+
// twin (e.g. "codeFile"/"diffFile"). Returns '' when neither is present.
|
|
90
|
+
function readTextSource(block, inlineKey, fileKey, cwd, where) {
|
|
91
|
+
if (typeof block[inlineKey] === 'string' && block[inlineKey] !== '') return block[inlineKey];
|
|
92
|
+
if (typeof block[fileKey] === 'string' && block[fileKey].trim()) {
|
|
93
|
+
const p = path.resolve(cwd, block[fileKey]);
|
|
94
|
+
let buf;
|
|
95
|
+
try {
|
|
96
|
+
buf = fs.readFileSync(p);
|
|
97
|
+
} catch {
|
|
98
|
+
throw new CliError(`${where}: cannot read ${fileKey} "${block[fileKey]}" (resolved: ${p})`);
|
|
99
|
+
}
|
|
100
|
+
if (buf.length > TEXT_FILE_MAX_BYTES) {
|
|
101
|
+
throw new CliError(`${where}: ${fileKey} "${block[fileKey]}" is ${(buf.length / 1024).toFixed(0)}KB — max ${TEXT_FILE_MAX_BYTES / 1024}KB.`);
|
|
102
|
+
}
|
|
103
|
+
return buf.toString('utf8');
|
|
104
|
+
}
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Recognizes a YouTube / Vimeo URL (or a bare YouTube id) and returns
|
|
109
|
+
// {provider, videoId, start} for an iframe embed, else null. Cross-platform —
|
|
110
|
+
// pure string parsing, no URL host assumptions beyond the known providers.
|
|
111
|
+
function parseVideoEmbed(src) {
|
|
112
|
+
const s = String(src).trim();
|
|
113
|
+
// bare 11-char YouTube id
|
|
114
|
+
if (/^[\w-]{11}$/.test(s)) return { provider: 'youtube', videoId: s, start: 0 };
|
|
115
|
+
let m;
|
|
116
|
+
if ((m = s.match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|v\/)|youtu\.be\/)([\w-]{11})/i))) {
|
|
117
|
+
const t = s.match(/[?&](?:t|start)=(\d+)/);
|
|
118
|
+
return { provider: 'youtube', videoId: m[1], start: t ? Number(t[1]) : 0 };
|
|
119
|
+
}
|
|
120
|
+
if ((m = s.match(/vimeo\.com\/(?:video\/)?(\d+)/i))) {
|
|
121
|
+
return { provider: 'vimeo', videoId: m[1], start: 0 };
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
74
126
|
// Normalizes one block object. `id` is the already-assigned block id.
|
|
75
127
|
// Returns the normalized block (with a guaranteed string `type` + `id`).
|
|
76
128
|
function normalizeBlock(rawBlock, id, cwd, where) {
|
|
@@ -126,14 +178,75 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
126
178
|
}
|
|
127
179
|
|
|
128
180
|
if (type === 'code') {
|
|
129
|
-
const code =
|
|
130
|
-
if (!code) throw new CliError(`${where}: code block needs a "code" string.`);
|
|
181
|
+
const code = readTextSource(rawBlock, 'code', 'codeFile', cwd, where);
|
|
182
|
+
if (!code) throw new CliError(`${where}: code block needs a "code" string or a readable "codeFile".`);
|
|
131
183
|
const block = { id, type: 'code', code };
|
|
132
184
|
if (rawBlock.lang !== undefined) block.lang = asStr(rawBlock.lang);
|
|
185
|
+
// lang defaults from the codeFile extension when not given explicitly.
|
|
186
|
+
else if (typeof rawBlock.codeFile === 'string' && rawBlock.codeFile.trim()) {
|
|
187
|
+
const ext = path.extname(rawBlock.codeFile).slice(1).toLowerCase();
|
|
188
|
+
if (ext) block.lang = ext;
|
|
189
|
+
}
|
|
190
|
+
if (rawBlock.filename !== undefined) block.filename = asStr(rawBlock.filename);
|
|
133
191
|
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
134
192
|
return block;
|
|
135
193
|
}
|
|
136
194
|
|
|
195
|
+
if (type === 'diff') {
|
|
196
|
+
const diff = readTextSource(rawBlock, 'diff', 'diffFile', cwd, where);
|
|
197
|
+
if (!diff.trim()) throw new CliError(`${where}: diff block needs a non-empty "diff" string (unified diff) or a readable "diffFile".`);
|
|
198
|
+
const block = { id, type: 'diff', diff };
|
|
199
|
+
if (rawBlock.lang !== undefined) block.lang = asStr(rawBlock.lang);
|
|
200
|
+
if (rawBlock.filename !== undefined) block.filename = asStr(rawBlock.filename);
|
|
201
|
+
const view = asStr(rawBlock.view).trim().toLowerCase();
|
|
202
|
+
if (view === 'split' || view === 'unified') block.view = view;
|
|
203
|
+
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
204
|
+
return block;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (type === 'video') {
|
|
208
|
+
const src = asStr(rawBlock.src ?? rawBlock.file ?? rawBlock.url).trim();
|
|
209
|
+
if (!src) throw new CliError(`${where}: video block needs a "src" (YouTube/Vimeo URL, http(s) media URL, or local file path).`);
|
|
210
|
+
const block = { id, type: 'video' };
|
|
211
|
+
if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
|
|
212
|
+
if (rawBlock.alt !== undefined) block.title = asStr(rawBlock.alt);
|
|
213
|
+
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
214
|
+
const embed = parseVideoEmbed(src);
|
|
215
|
+
if (embed) {
|
|
216
|
+
block.provider = embed.provider;
|
|
217
|
+
block.videoId = embed.videoId;
|
|
218
|
+
if (embed.start) block.start = embed.start;
|
|
219
|
+
return block;
|
|
220
|
+
}
|
|
221
|
+
if (/^https?:/i.test(src)) {
|
|
222
|
+
block.src = src;
|
|
223
|
+
const ext = path.extname(src.split(/[?#]/)[0]).slice(1).toLowerCase();
|
|
224
|
+
if (VIDEO_MIMES[ext]) block.mime = VIDEO_MIMES[ext];
|
|
225
|
+
return block;
|
|
226
|
+
}
|
|
227
|
+
// local file — kept as an absolute path the server streams (never embedded
|
|
228
|
+
// in the payload). The client only learns a flag + mime via /video/b/<id>.
|
|
229
|
+
const p = path.resolve(cwd, src);
|
|
230
|
+
const ext = path.extname(p).slice(1).toLowerCase();
|
|
231
|
+
const mime = VIDEO_MIMES[ext];
|
|
232
|
+
if (!mime) {
|
|
233
|
+
throw new CliError(`${where}: unsupported video extension ".${ext}" — use ${Object.keys(VIDEO_MIMES).join('/')}, a YouTube/Vimeo URL, or an http(s) media URL.`);
|
|
234
|
+
}
|
|
235
|
+
let stat;
|
|
236
|
+
try {
|
|
237
|
+
stat = fs.statSync(p);
|
|
238
|
+
} catch {
|
|
239
|
+
throw new CliError(`${where}: cannot read video "${src}" (resolved: ${p})`);
|
|
240
|
+
}
|
|
241
|
+
if (stat.size > VIDEO_MAX_BYTES) {
|
|
242
|
+
throw new CliError(`${where}: video "${src}" is ${(stat.size / 1024 / 1024).toFixed(0)}MB — max ${VIDEO_MAX_BYTES / 1024 / 1024}MB.`);
|
|
243
|
+
}
|
|
244
|
+
block.file = p;
|
|
245
|
+
block.mime = mime;
|
|
246
|
+
if (!block.title) block.title = path.basename(p);
|
|
247
|
+
return block;
|
|
248
|
+
}
|
|
249
|
+
|
|
137
250
|
if (type === 'chart') {
|
|
138
251
|
const hasConfig = rawBlock.config && typeof rawBlock.config === 'object' && !Array.isArray(rawBlock.config);
|
|
139
252
|
const hasShorthand =
|
|
@@ -412,11 +525,16 @@ const BLOCK_SCHEMA = {
|
|
|
412
525
|
properties: {
|
|
413
526
|
type: { type: 'string', enum: BLOCK_TYPES },
|
|
414
527
|
md: { type: 'string', description: 'markdown: built-in mini renderer (no external library) — headings, lists, code, quotes, links, and GFM pipe tables. Text selections are commentable. For real tabular data prefer a "table" block (sortable + per-cell comments).' },
|
|
415
|
-
code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display.' },
|
|
528
|
+
code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display (syntax-highlighted with line numbers).' },
|
|
529
|
+
codeFile: { type: 'string', description: 'code: path to a local source file to load + display instead of inline "code". Resolved against the CWD; lang defaults from the file extension.' },
|
|
530
|
+
filename: { type: 'string', description: 'code/diff: optional file name/path shown as a header label above the block.' },
|
|
416
531
|
editable: { type: 'boolean', description: 'mermaid: when true, render an "Edit diagram" toggle so the user can edit the diagram source live. The edited source is returned in result.blockEdits[<blockId>].' },
|
|
417
532
|
dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
|
|
418
533
|
server: { type: 'string', description: 'plantuml: PlantUML server base URL (http(s)). Defaults to https://www.plantuml.com/plantuml. Diagrams render via this server (needs network).' },
|
|
419
|
-
lang: { type: 'string', description: 'code block: language hint for
|
|
534
|
+
lang: { type: 'string', description: 'code/diff block: language hint for syntax highlighting (js, ts, py, go, rust, java, c, cpp, csharp, ruby, php, swift, kotlin, sql, yaml, json, sh, css, html, …).' },
|
|
535
|
+
diff: { type: 'string', description: 'diff: a unified diff (git diff / diff -u output) — rendered as a colored, line-numbered comparison with +added / −removed / context rows and file/hunk headers. No git needed; just write/paste the diff text.' },
|
|
536
|
+
diffFile: { type: 'string', description: 'diff: path to a local file containing a unified diff (alternative to "diff"). Resolved against the CWD.' },
|
|
537
|
+
view: { type: 'string', enum: ['unified', 'split'], description: 'diff: initial layout — "unified" (default, one column) or "split" (side-by-side old vs new). The viewer also has a live toggle either way.' },
|
|
420
538
|
config: { type: 'object', description: 'chart: a full Chart.js config object.' },
|
|
421
539
|
kind: { type: 'string', enum: CHART_KINDS, description: 'chart shorthand: chart kind (alternative to "config").' },
|
|
422
540
|
labels: { type: 'array', description: 'chart shorthand: x-axis / category labels.' },
|
|
@@ -425,7 +543,7 @@ const BLOCK_SCHEMA = {
|
|
|
425
543
|
description: 'chart shorthand: [{label, data:[...], color?}]. Chart data points are individually commentable.',
|
|
426
544
|
items: { type: 'object', properties: { label: { type: 'string' }, data: { type: 'array' }, color: { type: 'string' } } },
|
|
427
545
|
},
|
|
428
|
-
title: { type: 'string', description: 'chart shorthand: chart title.' },
|
|
546
|
+
title: { type: 'string', description: 'chart shorthand: chart title. video: title/caption shown under the player.' },
|
|
429
547
|
columns: {
|
|
430
548
|
type: 'array',
|
|
431
549
|
description: 'table: strings, or {key, label, align?}. Cells are commentable.',
|
|
@@ -435,8 +553,8 @@ const BLOCK_SCHEMA = {
|
|
|
435
553
|
sortable: { type: 'boolean', description: 'table: enable click-to-sort headers.' },
|
|
436
554
|
html: { type: 'string', description: 'html: custom markup rendered in a sandboxed iframe.' },
|
|
437
555
|
htmlFile: { type: 'string', description: 'html: path to an HTML file (alternative to "html").' },
|
|
438
|
-
src: { type: 'string', description: 'image: http(s)/data URL, or a local file path (png/jpg/gif/webp/svg/avif/bmp — embedded at spec time, served offline).' },
|
|
439
|
-
alt: { type: 'string', description: 'image: alt text / annotation label.' },
|
|
556
|
+
src: { type: 'string', description: 'image: http(s)/data URL, or a local file path (png/jpg/gif/webp/svg/avif/bmp — embedded at spec time, served offline). video: a YouTube/Vimeo URL (embeds an iframe player), an http(s) media URL, or a local video file (mp4/webm/ogv/mov/mkv/m4v — streamed from the server, never embedded).' },
|
|
557
|
+
alt: { type: 'string', description: 'image: alt text / annotation label. video: accessible title for the player.' },
|
|
440
558
|
height: { type: 'integer', minimum: BLOCK_HEIGHT.min, maximum: BLOCK_HEIGHT.max, description: 'Block height in px. Defaults: chart 320, html 360; markdown/table/code flow naturally; mermaid/graphviz/plantuml/image natural (max 1200, scrolls).' },
|
|
441
559
|
},
|
|
442
560
|
},
|
package/src/store.js
CHANGED
|
@@ -28,6 +28,10 @@ export function createBoard(spec) {
|
|
|
28
28
|
id: newId(),
|
|
29
29
|
createdAt: new Date().toISOString(),
|
|
30
30
|
title: spec.title,
|
|
31
|
+
// The directory the board was authored in. Relative file paths an agent
|
|
32
|
+
// writes into the spec (markdown links, ~/… paths) resolve against this, so
|
|
33
|
+
// the live server can open them in the user's default app (POST /api/open).
|
|
34
|
+
cwd: process.cwd(),
|
|
31
35
|
spec,
|
|
32
36
|
draft: null,
|
|
33
37
|
result: null,
|