@mevdragon/vidfarm-devcli 0.3.0 → 0.3.2

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
@@ -254,17 +254,20 @@ For agents that operate Vidfarm headlessly, the typical loop is:
254
254
 
255
255
  Always send a stable `tracer` on publish to make retries idempotent.
256
256
 
257
- ## Local dev loop with `vidfarm dev-serve`
257
+ ## Local dev loop with `vidfarm-devcli`
258
258
 
259
- For iterating on a composition on disk (e.g. Claude Code editing HTML directly), skip the cloud entirely:
259
+ For iterating on a composition on disk (e.g. Claude Code editing HTML directly), pair the hosted editor with a local composition directory:
260
260
 
261
261
  ```bash
262
- npm install -g @mevdragon/vidfarm-devcli
263
- vidfarm dev-serve --dir ./my-fork --port 4321
264
- # ./my-fork must contain composition.html; composition.json + manifest.json optional
262
+ npx -y @mevdragon/vidfarm-devcli <template_id>
263
+ # downloads composition.html/json + manifest.json into .vidfarm/<template_id>/
264
+ # starts dev-serve on http://localhost:4321
265
+ # → prints https://vidfarm.cc/editor/<template_id>?fork=<forkId>&dev=http%3A%2F%2Flocalhost%3A4321
265
266
  ```
266
267
 
267
- Then open the Trackpad Editor with `?dev=1` appended to the URL (or `?dev=host:port` / `?dev=<full-url>`). An orange **LOCAL DEV MODE** banner pins to the top with an Exit button. All fork API fetches route to the devcli instead of the deployed API; file saves on disk push an SSE reload event and the iframe re-fetches automatically.
268
+ Open the printed URL in your browser. An orange **LOCAL DEV MODE** banner pins to the top with an Exit button. All fork API fetches now route to your local dir instead of the deployed API; disk edits push an SSE reload event and the editor re-fetches automatically.
269
+
270
+ Flags: `--port`, `--dir`, `--host`, `--fork`, `--api-key` (or set `VIDFARM_API_KEY`), `--share`, `--refetch`. For a fork that already has composition files on disk (or that you've populated manually), use `vidfarm-devcli dev-serve --dir ./my-fork --port 4321` to skip the fetch step.
268
271
 
269
272
  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.
270
273
 
@@ -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.0",
3
+ "version": "0.3.2",
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": {