@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 +9 -6
- package/dist/src/dev-serve.js +198 -38
- package/package.json +1 -1
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
|
|
257
|
+
## Local dev loop with `vidfarm-devcli`
|
|
258
258
|
|
|
259
|
-
For iterating on a composition on disk (e.g. Claude Code editing HTML directly),
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
#
|
|
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
|
-
|
|
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
|
|
package/dist/src/dev-serve.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
152
|
-
if (parts.length === 3 && parts[0] === "versions" && /^\d+$/.test(parts[1])) {
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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.
|
|
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": {
|