@mevdragon/vidfarm-devcli 0.3.1 → 0.3.3

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/SKILL.director.md CHANGED
@@ -142,6 +142,8 @@ Two modes:
142
142
  - **`smart`** — uses the caller's Gemini/OpenAI/OpenRouter key. Samples frames, extracts scenes with labels and viral notes, extracts on-screen captions with positions, and detects viral DNA (hook, retention, payoff). Takes 30-60 seconds. Auto-fires GhostCut for subtitle removal in the background.
143
143
  - **`time-slice`** — deterministic equal-duration split. Instant. No AI provider needed.
144
144
 
145
+ **Source length cap.** Vidfarm caps auto-decompose (and all inspiration ingest) at **120 seconds** of source video. If the source is longer, the API responds with `400 { ok: false, code: "source_too_long", duration_seconds, max_duration_seconds }` and no scenes are written. Trim the source before forking / ingesting.
146
+
145
147
  Response:
146
148
 
147
149
  ```
@@ -150,6 +152,8 @@ Response:
150
152
 
151
153
  If `ghostcut_pending: true`, poll `POST /api/v1/compositions/:forkId/ghostcut-poll` until status transitions to `done` or `failed`.
152
154
 
155
+ Read-only state (does **not** advance the job or bill): `GET /api/v1/compositions/:forkId/ghostcut`. Returns `{ status, task_id, original_source_url, mirrored_url, submitted_at_ms, completed_at_ms, failure_reason }`. Use this when you want to know both video URLs without triggering another poll — the AI copilot uses it to pick between the raw upload and the caption-free mirror before feeding a video into a primitive route (image extract, dedupe, trim, ai video edit, etc.). Prefer `mirrored_url` when `status === "done"` and the downstream call should be caption-free; prefer `original_source_url` when the user explicitly wants the raw upload or when `status !== "done"`.
156
+
153
157
  ## Publish
154
158
 
155
159
  Publishing renders the fork's current working state to an MP4 using shared Remotion Lambda.
@@ -271,6 +275,11 @@ Flags: `--port`, `--dir`, `--host`, `--fork`, `--api-key` (or set `VIDFARM_API_K
271
275
 
272
276
  The dev source is persisted in `localStorage.VIDFARM_DEV_SOURCE`, so you don't need to keep the `?dev=` param in the URL after the first load.
273
277
 
278
+ `dev-serve` (`vidfarm-devcli` 0.3.2+) is realtime-safe:
279
+ - Writes coming through `PUT /composition.html` / `PATCH /composition.json` **do not loop back** as reload events — the server suppresses filesystem notifications for the just-written file for 500ms so the editor's own save never triggers a self-reload.
280
+ - The SSE `hello` event carries an `instance` id + a monotonic `cursor`. If the client reconnects after a server restart the browser detects the new instance and fires a `vidfarm:dev-reload` so the editor resyncs from disk. A 20s heartbeat keeps the SSE connection alive through intermediate proxies.
281
+ - Port collisions surface as a clear `[dev-serve] port <n> is already in use` message instead of a raw stack trace. Request bodies are capped at 8MB and time out after 15s so a stuck client can't hang the server. File paths are canonicalised so `../etc/passwd`-style paths cannot escape `--dir`.
282
+
274
283
  ## What NOT to do
275
284
 
276
285
  - Do **not** manipulate composition HTML by string concatenation. Always parse, edit, and re-serialize the DOM. The editor and runtime rely on `data-start`, `data-duration`, `data-track-index`, `data-hf-id`, `data-composition-id` attributes being well-formed.
package/SKILL.platform.md CHANGED
@@ -161,6 +161,7 @@ All three files (`composition.html`, `composition.json`, `manifest.json`) are tr
161
161
  - **Player adapter** — exposes `window.__player = { play(), pause(), seek(t), setTime(t), duration, playing, playbackRate, previewMuted, refreshClips() }` for the editor to control the iframe
162
162
  - **Media coordination** — respects `data-playback-start`, `data-volume`, and clip-specific mute
163
163
  - **Selection sync** — listens for `postMessage({ source: "hf-parent", ... })` from the parent editor to highlight/deselect clips
164
+ - **Origin-scoped messaging** — the runtime rejects inbound `message` events unless `event.source === window.parent`, so sibling iframes / popups cannot spoof play/pause/seek by forging `data.source`. Outbound `window.parent.postMessage(...)` calls are pinned to the parent's origin (seeded from `document.referrer` and refreshed from the first accepted control message) instead of `"*"`, so preview frame coordinates and layer ids no longer leak to any embedding third-party page.
164
165
 
165
166
  The same runtime is used inside the Trackpad Editor iframe AND when a published fork is embedded elsewhere. It is agnostic to whether it's being edited or viewed.
166
167
 
@@ -201,6 +202,8 @@ The Remotion Lambda is **shared across all templates and forks** — it's an inf
201
202
 
202
203
  Both modes update the composition.html and composition.json in place, and create a fork version snapshot with `reason: "manual"`.
203
204
 
205
+ **Source-duration guardrail.** `probeMediaAsset()` in `src/services/media-processing.ts` enforces a hard cap of `MAX_PROBE_DURATION_SECONDS` (120s). If the probed source exceeds it, the probe throws `MediaDurationExceededError` and the route responds with `400 { code: "source_too_long", duration_seconds, max_duration_seconds }`. The same cap applies to inspiration ingest (`ensureInspirationReadyThenFork`) — over-long submissions land as `InspirationRecord { status: "failed", errorMessage }` without ever creating a Template or fork. This is baked into the probe (not the route) so every consumer — auto-decompose, ingest, `video_probe` / `audio_probe` primitives — is protected from repeated probes of long videos being used to burn Lambda / ffmpeg / vision-API budget.
206
+
204
207
  ## GhostCut integration
205
208
 
206
209
  GhostCut is an external AI subtitle-removal service. Vidfarm proxies to it via `src/services/ghostcut.ts`:
@@ -211,6 +214,8 @@ GhostCut is an external AI subtitle-removal service. Vidfarm proxies to it via `
211
214
 
212
215
  State is persisted per fork at `compositions/forks/<forkId>/working/ghostcut.json`. The client polls `POST /api/v1/compositions/:forkId/ghostcut-poll` every few seconds during smart decompose.
213
216
 
217
+ Read-only companion: `GET /api/v1/compositions/:forkId/ghostcut` returns `{ status, task_id, original_source_url, mirrored_url, submitted_at_ms, completed_at_ms, failure_reason }` without touching GhostCut. Non-billing. Exposed for two reasons: (1) the editor chat harness / AI copilot uses it to look up both video URLs on demand so downstream primitive calls can pick raw-upload vs caption-free-mirror without hardcoding either in chat context; (2) it's `http_request`-allowlisted under `COMPOSITIONS_PREFIX` in `src/app.ts`, which is why the editor chat system prompt (`src/editor-chat.ts`) directs the model to call it before feeding a video into a primitive route. The AI is instructed **not** to call `POST /ghostcut-poll` unless the user explicitly asks to advance subtitle removal.
218
+
214
219
  Configuration: `GHOSTCUT_KEY` and `GHOSTCUT_SECRET` env vars per stage. Pass-through cost: `GHOSTCUT_USD_PER_30S` (default 0.10).
215
220
 
216
221
  ## Editor Chat
@@ -363,10 +368,13 @@ vidfarm-devcli dev-serve --dir ./my-fork --port 4321
363
368
 
364
369
  Edit flags: `--port`, `--dir`, `--host`, `--fork`, `--api-key` (or `VIDFARM_API_KEY`), `--share`, `--refetch`.
365
370
 
366
- `dev-serve` behavior:
371
+ `dev-serve` behavior (`vidfarm-devcli` 0.3.2+):
367
372
  - Serves `composition.html` / `composition.json` / `manifest.json` (and `/versions/{n}/…`) with CORS `*`.
368
373
  - Accepts `PUT /composition.html` and `PATCH /composition.json`.
369
- - Watches the dir with `fs.watch`, coalesces bursts, fans out reload events via `GET /_events` SSE.
374
+ - Watches the dir with `fs.watch`, coalesces bursts (80ms), fans out reload events via `GET /_events` SSE.
375
+ - Reload loop prevention: any successful `PUT` / `PATCH` marks the target file "self-write suppressed" for 500ms so the fs.watch event fired by the write we just performed does NOT round-trip back to the editor as a reload event.
376
+ - SSE `hello` includes `{ at, instance, cursor }`. `instance` changes on every dev-serve restart so a reconnecting client can detect a restart and force a resync; `cursor` is a monotonic counter that bumps on every broadcast reload. Heartbeat `: heartbeat <ts>` every 20s keeps intermediate proxies from timing out the connection.
377
+ - Safety rails: request bodies capped at 8MB with a 15s body-read timeout; path resolution runs through `path.relative(rootDir, ...)` so `versions/../../etc/passwd`-style paths cannot escape the served directory; `EADDRINUSE` produces a plain-English error instead of a raw stack; broadcast iterates a snapshot of the client set so a mid-broadcast disconnect can't skip healthy clients; `SIGINT` + `SIGTERM` both close cleanly with a 2s hard-exit fallback.
370
378
 
371
- The editor side lives in `demo/src/dev-mode.ts` — a self-installing module imported at the top of `demo/src/main.tsx`. It reads `?dev=<url>` (persisted to `localStorage.VIDFARM_DEV_SOURCE`), monkey-patches `window.fetch` to rewrite `/api/v1/compositions/{forkId}/*` → `${devSource}/*`, injects a persistent orange banner with an Exit button, and dispatches `vidfarm:dev-reload` on SSE reload events. `HyperframesStudioEditor.tsx` listens for that event and re-runs the composition fetch effect with `cache: "reload"`.
379
+ The editor side lives in `demo/src/dev-mode.ts` — a self-installing module imported at the top of `demo/src/main.tsx`. It reads `?dev=<url>` (persisted to `localStorage.VIDFARM_DEV_SOURCE`), monkey-patches `window.fetch` to rewrite `/api/v1/compositions/{forkId}/*` → `${devSource}/*`, injects a persistent orange banner with an Exit button, and dispatches `vidfarm:dev-reload` on SSE reload events. It also caches the `hello.instance` and re-dispatches `vidfarm:dev-reload` with `{ reason: "server-restart" }` when a reconnect sees a different instance. `HyperframesStudioEditor.tsx` listens for that event and re-runs the composition fetch effect with `cache: "reload"`.
372
380
  - Lambdas — `infra/lambda/*.ts`
@@ -1,5 +1,5 @@
1
1
  import { createServer } from "node:http";
2
- import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  // vidfarm dev-serve — a tiny HTTP layer that lets the browser editor point at
@@ -14,6 +14,17 @@ const CORS_HEADERS = {
14
14
  "access-control-allow-headers": "content-type,authorization",
15
15
  "access-control-max-age": "600"
16
16
  };
17
+ // Cap request bodies at 8MB so a runaway or malicious client can't OOM the
18
+ // dev server. A composition.html + inlined asset is normally <1MB.
19
+ const MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
20
+ // Ignore filesystem events for this long after we write from an HTTP handler,
21
+ // so the editor's own PUT/PATCH doesn't trigger a self-reload loop. `fs.watch`
22
+ // on macOS fires 1–3 events per rename, plus a tail event from the editor's
23
+ // auto-format step.
24
+ const SELF_WRITE_SUPPRESS_MS = 500;
25
+ // Bump on every new dev-serve process so a reconnecting SSE client can tell
26
+ // whether the server restarted (and therefore whether it must resync files).
27
+ const SERVER_INSTANCE_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
17
28
  export async function runDevServeCommand(argv) {
18
29
  const parsed = parseArgs({
19
30
  args: argv,
@@ -24,14 +35,22 @@ export async function runDevServeCommand(argv) {
24
35
  });
25
36
  const rootDir = path.resolve(process.cwd(), parsed.values.dir);
26
37
  const port = Number(parsed.values.port);
38
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
39
+ throw new Error(`Invalid --port: ${parsed.values.port}`);
40
+ }
27
41
  if (!existsSync(rootDir)) {
28
42
  mkdirSync(rootDir, { recursive: true });
29
43
  }
30
44
  let nextClientId = 1;
45
+ let mutationCursor = 0;
31
46
  const clients = new Set();
47
+ const selfWriteSuppressUntil = new Map();
32
48
  function broadcast(event, data) {
33
49
  const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
34
- for (const client of clients) {
50
+ // Snapshot the client set before iterating: `client.res.write` can
51
+ // synchronously destroy the response (broken pipe), which mutates the
52
+ // Set mid-iteration and can skip healthy clients.
53
+ for (const client of [...clients]) {
35
54
  try {
36
55
  client.res.write(payload);
37
56
  }
@@ -40,16 +59,45 @@ export async function runDevServeCommand(argv) {
40
59
  }
41
60
  }
42
61
  }
62
+ function isSelfWriteSuppressed(filename) {
63
+ if (!filename)
64
+ return false;
65
+ const until = selfWriteSuppressUntil.get(filename);
66
+ if (!until)
67
+ return false;
68
+ if (Date.now() > until) {
69
+ selfWriteSuppressUntil.delete(filename);
70
+ return false;
71
+ }
72
+ return true;
73
+ }
74
+ function suppressReloadFromSelfWrite(filePath) {
75
+ const rel = path.relative(rootDir, filePath);
76
+ if (!rel || rel.startsWith(".."))
77
+ return;
78
+ // fs.watch reports filenames relative to the watched root — match both the
79
+ // basename and the relative path, since macOS emits either form.
80
+ selfWriteSuppressUntil.set(rel, Date.now() + SELF_WRITE_SUPPRESS_MS);
81
+ selfWriteSuppressUntil.set(path.basename(filePath), Date.now() + SELF_WRITE_SUPPRESS_MS);
82
+ }
43
83
  // fs.watch fires on any child change. Coalesce bursts so a single save
44
- // doesn't spam the editor with 3 reload events.
84
+ // doesn't spam the editor with 3 reload events. Also drop events for files
85
+ // we JUST wrote ourselves so PUT/PATCH doesn't loop into a client reload.
45
86
  let reloadTimer = null;
87
+ let pendingReloadFile = null;
46
88
  const scheduleReload = (filename) => {
89
+ if (isSelfWriteSuppressed(filename))
90
+ return;
91
+ pendingReloadFile = filename ?? pendingReloadFile;
47
92
  if (reloadTimer)
48
93
  clearTimeout(reloadTimer);
49
94
  reloadTimer = setTimeout(() => {
50
95
  reloadTimer = null;
51
- broadcast("reload", { file: filename, at: Date.now() });
52
- console.log(`[dev-serve] reload → ${filename ?? "(unknown)"}`);
96
+ const file = pendingReloadFile;
97
+ pendingReloadFile = null;
98
+ mutationCursor += 1;
99
+ broadcast("reload", { file, at: Date.now(), cursor: mutationCursor });
100
+ console.log(`[dev-serve] reload → ${file ?? "(unknown)"}`);
53
101
  }, 80);
54
102
  };
55
103
  try {
@@ -59,19 +107,50 @@ export async function runDevServeCommand(argv) {
59
107
  }
60
108
  catch (error) {
61
109
  console.warn(`[dev-serve] watch failed: ${error instanceof Error ? error.message : String(error)}`);
110
+ console.warn("[dev-serve] file changes will NOT trigger editor reloads. On Linux, install inotify-tools or run inside a supported filesystem.");
62
111
  }
63
- const server = createServer((req, res) => handleRequest(req, res, rootDir, clients, nextClientId++));
112
+ const server = createServer((req, res) => handleRequest(req, res, {
113
+ rootDir,
114
+ clients,
115
+ clientId: nextClientId++,
116
+ mutationCursor: () => mutationCursor,
117
+ suppressReloadFromSelfWrite
118
+ }));
119
+ server.on("error", (error) => {
120
+ if (error.code === "EADDRINUSE") {
121
+ console.error(`[dev-serve] port ${port} is already in use.`);
122
+ console.error(`[dev-serve] pass --port <n> to pick a different port, or stop the process bound to ${port}.`);
123
+ process.exit(1);
124
+ }
125
+ console.error(`[dev-serve] server error: ${error.message}`);
126
+ process.exit(1);
127
+ });
64
128
  server.listen(port, () => {
65
129
  console.log(`[dev-serve] serving ${rootDir}`);
66
130
  console.log(`[dev-serve] listening on http://localhost:${port}`);
67
131
  console.log(`[dev-serve] editor: open with ?dev=${encodeURIComponent(`http://localhost:${port}`)}`);
68
132
  });
69
- process.on("SIGINT", () => {
133
+ let shuttingDown = false;
134
+ const shutdown = () => {
135
+ if (shuttingDown)
136
+ return;
137
+ shuttingDown = true;
70
138
  console.log("\n[dev-serve] shutting down");
139
+ for (const client of clients) {
140
+ try {
141
+ client.res.end();
142
+ }
143
+ catch { }
144
+ }
145
+ clients.clear();
71
146
  server.close(() => process.exit(0));
72
- });
147
+ // Fallback: if in-flight sockets keep the server open, force-exit after 2s.
148
+ setTimeout(() => process.exit(0), 2000).unref();
149
+ };
150
+ process.on("SIGINT", shutdown);
151
+ process.on("SIGTERM", shutdown);
73
152
  }
74
- function handleRequest(req, res, rootDir, clients, clientId) {
153
+ function handleRequest(req, res, ctx) {
75
154
  const method = (req.method ?? "GET").toUpperCase();
76
155
  const url = new URL(req.url ?? "/", `http://localhost`);
77
156
  const pathname = url.pathname;
@@ -85,15 +164,35 @@ function handleRequest(req, res, rootDir, clients, clientId) {
85
164
  ...CORS_HEADERS,
86
165
  "content-type": "text/event-stream",
87
166
  "cache-control": "no-cache",
88
- connection: "keep-alive"
167
+ connection: "keep-alive",
168
+ "x-accel-buffering": "no"
89
169
  });
90
- res.write(`event: hello\ndata: ${JSON.stringify({ at: Date.now() })}\n\n`);
91
- const client = { res, id: clientId };
92
- clients.add(client);
93
- req.on("close", () => clients.delete(client));
170
+ // Include instance id + current cursor so a reconnecting client can tell
171
+ // if the server restarted (missed events) or if it just needs to catch up.
172
+ res.write(`event: hello\ndata: ${JSON.stringify({
173
+ at: Date.now(),
174
+ instance: SERVER_INSTANCE_ID,
175
+ cursor: ctx.mutationCursor()
176
+ })}\n\n`);
177
+ const client = { res, id: ctx.clientId };
178
+ ctx.clients.add(client);
179
+ // Heartbeat keeps intermediate proxies (and iOS Safari) from timing out
180
+ // the long-lived SSE connection.
181
+ const heartbeat = setInterval(() => {
182
+ try {
183
+ res.write(`: heartbeat ${Date.now()}\n\n`);
184
+ }
185
+ catch { }
186
+ }, 20_000);
187
+ const cleanup = () => {
188
+ clearInterval(heartbeat);
189
+ ctx.clients.delete(client);
190
+ };
191
+ req.on("close", cleanup);
192
+ req.on("error", cleanup);
94
193
  return;
95
194
  }
96
- const filePath = resolveFilePath(rootDir, pathname);
195
+ const filePath = resolveFilePath(ctx.rootDir, pathname);
97
196
  if (!filePath) {
98
197
  respondJson(res, 404, { ok: false, error: `Not found: ${pathname}` });
99
198
  return;
@@ -103,10 +202,21 @@ function handleRequest(req, res, rootDir, clients, clientId) {
103
202
  respondJson(res, 404, { ok: false, error: `Missing ${path.basename(filePath)}` });
104
203
  return;
105
204
  }
106
- const body = readFileSync(filePath);
107
- const contentType = pathname.endsWith(".html") ? "text/html; charset=utf-8" : "application/json";
108
- res.writeHead(200, { ...CORS_HEADERS, "content-type": contentType });
109
- res.end(body);
205
+ try {
206
+ const body = readFileSync(filePath);
207
+ const stat = statSync(filePath);
208
+ const contentType = pathname.endsWith(".html") ? "text/html; charset=utf-8" : "application/json";
209
+ res.writeHead(200, {
210
+ ...CORS_HEADERS,
211
+ "content-type": contentType,
212
+ "cache-control": "no-store",
213
+ etag: `"${stat.size}-${stat.mtimeMs}"`
214
+ });
215
+ res.end(body);
216
+ }
217
+ catch (error) {
218
+ respondJson(res, 500, { ok: false, error: `Read failed: ${error instanceof Error ? error.message : String(error)}` });
219
+ }
110
220
  return;
111
221
  }
112
222
  if (method === "PUT" && pathname.endsWith("/composition.html")) {
@@ -116,27 +226,41 @@ function handleRequest(req, res, rootDir, clients, clientId) {
116
226
  respondJson(res, 400, { ok: false, error: "Composition HTML is missing data-composition-id" });
117
227
  return;
118
228
  }
119
- mkdirSync(path.dirname(filePath), { recursive: true });
120
- writeFileSync(filePath, html, "utf8");
121
- respondJson(res, 200, { ok: true, bytes: html.length });
122
- }).catch((error) => respondJson(res, 500, { ok: false, error: String(error) }));
229
+ try {
230
+ mkdirSync(path.dirname(filePath), { recursive: true });
231
+ ctx.suppressReloadFromSelfWrite(filePath);
232
+ writeFileSync(filePath, html, "utf8");
233
+ respondJson(res, 200, { ok: true, bytes: html.length });
234
+ }
235
+ catch (error) {
236
+ respondJson(res, 500, { ok: false, error: `Write failed: ${error instanceof Error ? error.message : String(error)}` });
237
+ }
238
+ }).catch((error) => respondJson(res, 400, { ok: false, error: String(error) }));
123
239
  return;
124
240
  }
125
241
  if (method === "PATCH" && pathname.endsWith("/composition.json")) {
126
242
  readBody(req).then((body) => {
127
- const patch = JSON.parse(body.toString("utf8") || "{}");
128
- const existing = existsSync(filePath) ? JSON.parse(readFileSync(filePath, "utf8") || "{}") : {};
129
- const merged = { ...existing, ...patch };
130
- mkdirSync(path.dirname(filePath), { recursive: true });
131
- writeFileSync(filePath, JSON.stringify(merged, null, 2), "utf8");
132
- respondJson(res, 200, merged);
133
- }).catch((error) => respondJson(res, 500, { ok: false, error: String(error) }));
243
+ try {
244
+ const patch = JSON.parse(body.toString("utf8") || "{}");
245
+ const existing = existsSync(filePath) ? JSON.parse(readFileSync(filePath, "utf8") || "{}") : {};
246
+ const merged = { ...existing, ...patch };
247
+ mkdirSync(path.dirname(filePath), { recursive: true });
248
+ ctx.suppressReloadFromSelfWrite(filePath);
249
+ writeFileSync(filePath, JSON.stringify(merged, null, 2), "utf8");
250
+ respondJson(res, 200, merged);
251
+ }
252
+ catch (error) {
253
+ respondJson(res, 400, { ok: false, error: `PATCH failed: ${error instanceof Error ? error.message : String(error)}` });
254
+ }
255
+ }).catch((error) => respondJson(res, 400, { ok: false, error: String(error) }));
134
256
  return;
135
257
  }
136
258
  respondJson(res, 405, { ok: false, error: `Method ${method} not allowed on ${pathname}` });
137
259
  }
138
260
  // Restrict served paths to the working/version composition files. Anything
139
- // else 404s so this server isn't a random static host.
261
+ // else 404s so this server isn't a random static host. Also canonicalise the
262
+ // resolved path so `..` segments cannot escape rootDir even through a future
263
+ // refactor of the allowlist.
140
264
  function resolveFilePath(rootDir, pathname) {
141
265
  const clean = pathname.replace(/^\/+/, "");
142
266
  const parts = clean.split("/").filter(Boolean);
@@ -146,13 +270,22 @@ function resolveFilePath(rootDir, pathname) {
146
270
  const allowed = ["composition.html", "composition.json", "manifest.json"];
147
271
  if (!allowed.includes(filename))
148
272
  return null;
273
+ let candidate;
149
274
  // Allow: `composition.html`, `versions/1/composition.html`.
150
275
  if (parts.length === 1)
151
- return path.join(rootDir, filename);
152
- if (parts.length === 3 && parts[0] === "versions" && /^\d+$/.test(parts[1])) {
153
- return path.join(rootDir, "versions", parts[1], filename);
276
+ candidate = path.join(rootDir, filename);
277
+ else if (parts.length === 3 && parts[0] === "versions" && /^\d+$/.test(parts[1])) {
278
+ candidate = path.join(rootDir, "versions", parts[1], filename);
154
279
  }
155
- return null;
280
+ else {
281
+ return null;
282
+ }
283
+ const resolved = path.resolve(candidate);
284
+ const rootResolved = path.resolve(rootDir);
285
+ const rel = path.relative(rootResolved, resolved);
286
+ if (rel.startsWith("..") || path.isAbsolute(rel))
287
+ return null;
288
+ return resolved;
156
289
  }
157
290
  function respondJson(res, status, body) {
158
291
  res.writeHead(status, { ...CORS_HEADERS, "content-type": "application/json" });
@@ -161,9 +294,36 @@ function respondJson(res, status, body) {
161
294
  function readBody(req) {
162
295
  return new Promise((resolve, reject) => {
163
296
  const chunks = [];
164
- req.on("data", (chunk) => chunks.push(chunk));
165
- req.on("end", () => resolve(Buffer.concat(chunks)));
166
- req.on("error", reject);
297
+ let total = 0;
298
+ let settled = false;
299
+ // Guard against a slow client stringing the connection open forever.
300
+ const timeout = setTimeout(() => {
301
+ if (settled)
302
+ return;
303
+ settled = true;
304
+ req.destroy();
305
+ reject(new Error("Request body read timed out after 15s."));
306
+ }, 15_000);
307
+ const done = (fn) => {
308
+ if (settled)
309
+ return;
310
+ settled = true;
311
+ clearTimeout(timeout);
312
+ fn();
313
+ };
314
+ req.on("data", (chunk) => {
315
+ if (settled)
316
+ return;
317
+ total += chunk.length;
318
+ if (total > MAX_REQUEST_BODY_BYTES) {
319
+ done(() => reject(new Error(`Request body exceeds ${MAX_REQUEST_BODY_BYTES} bytes.`)));
320
+ req.destroy();
321
+ return;
322
+ }
323
+ chunks.push(chunk);
324
+ });
325
+ req.on("end", () => done(() => resolve(Buffer.concat(chunks))));
326
+ req.on("error", (error) => done(() => reject(error)));
167
327
  });
168
328
  }
169
329
  //# sourceMappingURL=dev-serve.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. Point it at a template id, edit composition.html on disk (Claude Code, Codex, etc.), preview live in the browser at https://vidfarm.cc/editor/.",
5
5
  "type": "module",
6
6
  "bin": {