@mevdragon/vidfarm-devcli 0.4.1 → 0.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
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": {
@@ -9,7 +9,6 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/src/cli.js",
12
- "dist/src/dev-serve.js",
13
12
  "README.md",
14
13
  "SKILL.director.md",
15
14
  "SKILL.platform.md",
@@ -64,9 +63,6 @@
64
63
  "@fontsource/tiktok-sans": "^5.2.4",
65
64
  "@hono/node-server": "^1.14.4",
66
65
  "@hyperframes/aws-lambda": "^0.7.21",
67
- "@remotion/bundler": "4.0.355",
68
- "@remotion/lambda": "4.0.355",
69
- "@remotion/renderer": "4.0.355",
70
66
  "@sentry/aws-serverless": "^10.56.0",
71
67
  "@sentry/hono": "^10.56.0",
72
68
  "@sentry/node": "^10.56.0",
@@ -82,7 +78,6 @@
82
78
  "linkedom": "^0.18.12",
83
79
  "react": "^18.3.1",
84
80
  "react-dom": "^18.3.1",
85
- "remotion": "4.0.355",
86
81
  "sharp": "^0.34.5",
87
82
  "zod": "^3.25.28"
88
83
  },
@@ -1,340 +0,0 @@
1
- import { createServer } from "node:http";
2
- import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync } from "node:fs";
3
- import path from "node:path";
4
- import { parseArgs } from "node:util";
5
- // vidfarm dev-serve — a tiny HTTP layer that lets the browser editor point at
6
- // a local directory of composition files instead of the deployed API. The
7
- // editor detects dev mode from `?dev=<url>` (or localStorage) and rewrites
8
- // its fork API calls to hit this server. File changes fan out via SSE so the
9
- // editor can reload the composition on save-from-disk (e.g. Claude Code
10
- // edits the file directly).
11
- const CORS_HEADERS = {
12
- "access-control-allow-origin": "*",
13
- "access-control-allow-methods": "GET,PUT,PATCH,POST,OPTIONS",
14
- // The editor page (vidfarm.cc) fetches us cross-origin and its Sentry
15
- // instrumentation attaches distributed-tracing headers (sentry-trace,
16
- // baggage). List them so the preflight passes; the OPTIONS handler also
17
- // echoes whatever the browser actually asks for, which is the real guard.
18
- "access-control-allow-headers": "content-type,authorization,vidfarm-api-key,vidfarm-share-token,sentry-trace,baggage",
19
- "access-control-max-age": "600"
20
- };
21
- // Cap request bodies at 8MB so a runaway or malicious client can't OOM the
22
- // dev server. A composition.html + inlined asset is normally <1MB.
23
- const MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
24
- // Ignore filesystem events for this long after we write from an HTTP handler,
25
- // so the editor's own PUT/PATCH doesn't trigger a self-reload loop. `fs.watch`
26
- // on macOS fires 1–3 events per rename, plus a tail event from the editor's
27
- // auto-format step.
28
- const SELF_WRITE_SUPPRESS_MS = 500;
29
- // Bump on every new dev-serve process so a reconnecting SSE client can tell
30
- // whether the server restarted (and therefore whether it must resync files).
31
- const SERVER_INSTANCE_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
32
- export async function runDevServeCommand(argv) {
33
- const parsed = parseArgs({
34
- args: argv,
35
- options: {
36
- dir: { type: "string", default: "." },
37
- port: { type: "string", default: "4321" }
38
- }
39
- });
40
- const rootDir = path.resolve(process.cwd(), parsed.values.dir);
41
- const port = Number(parsed.values.port);
42
- if (!Number.isFinite(port) || port <= 0 || port > 65535) {
43
- throw new Error(`Invalid --port: ${parsed.values.port}`);
44
- }
45
- if (!existsSync(rootDir)) {
46
- mkdirSync(rootDir, { recursive: true });
47
- }
48
- let nextClientId = 1;
49
- let mutationCursor = 0;
50
- const clients = new Set();
51
- const selfWriteSuppressUntil = new Map();
52
- function broadcast(event, data) {
53
- const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
54
- // Snapshot the client set before iterating: `client.res.write` can
55
- // synchronously destroy the response (broken pipe), which mutates the
56
- // Set mid-iteration and can skip healthy clients.
57
- for (const client of [...clients]) {
58
- try {
59
- client.res.write(payload);
60
- }
61
- catch {
62
- clients.delete(client);
63
- }
64
- }
65
- }
66
- function isSelfWriteSuppressed(filename) {
67
- if (!filename)
68
- return false;
69
- const until = selfWriteSuppressUntil.get(filename);
70
- if (!until)
71
- return false;
72
- if (Date.now() > until) {
73
- selfWriteSuppressUntil.delete(filename);
74
- return false;
75
- }
76
- return true;
77
- }
78
- function suppressReloadFromSelfWrite(filePath) {
79
- const rel = path.relative(rootDir, filePath);
80
- if (!rel || rel.startsWith(".."))
81
- return;
82
- // fs.watch reports filenames relative to the watched root — match both the
83
- // basename and the relative path, since macOS emits either form.
84
- selfWriteSuppressUntil.set(rel, Date.now() + SELF_WRITE_SUPPRESS_MS);
85
- selfWriteSuppressUntil.set(path.basename(filePath), Date.now() + SELF_WRITE_SUPPRESS_MS);
86
- }
87
- // fs.watch fires on any child change. Coalesce bursts so a single save
88
- // doesn't spam the editor with 3 reload events. Also drop events for files
89
- // we JUST wrote ourselves so PUT/PATCH doesn't loop into a client reload.
90
- let reloadTimer = null;
91
- let pendingReloadFile = null;
92
- const scheduleReload = (filename) => {
93
- if (isSelfWriteSuppressed(filename))
94
- return;
95
- pendingReloadFile = filename ?? pendingReloadFile;
96
- if (reloadTimer)
97
- clearTimeout(reloadTimer);
98
- reloadTimer = setTimeout(() => {
99
- reloadTimer = null;
100
- const file = pendingReloadFile;
101
- pendingReloadFile = null;
102
- mutationCursor += 1;
103
- broadcast("reload", { file, at: Date.now(), cursor: mutationCursor });
104
- console.log(`[dev-serve] reload → ${file ?? "(unknown)"}`);
105
- }, 80);
106
- };
107
- try {
108
- watch(rootDir, { recursive: true }, (_type, filename) => {
109
- scheduleReload(filename ?? null);
110
- });
111
- }
112
- catch (error) {
113
- console.warn(`[dev-serve] watch failed: ${error instanceof Error ? error.message : String(error)}`);
114
- console.warn("[dev-serve] file changes will NOT trigger editor reloads. On Linux, install inotify-tools or run inside a supported filesystem.");
115
- }
116
- const server = createServer((req, res) => handleRequest(req, res, {
117
- rootDir,
118
- clients,
119
- clientId: nextClientId++,
120
- mutationCursor: () => mutationCursor,
121
- suppressReloadFromSelfWrite
122
- }));
123
- server.on("error", (error) => {
124
- if (error.code === "EADDRINUSE") {
125
- console.error(`[dev-serve] port ${port} is already in use.`);
126
- console.error(`[dev-serve] pass --port <n> to pick a different port, or stop the process bound to ${port}.`);
127
- process.exit(1);
128
- }
129
- console.error(`[dev-serve] server error: ${error.message}`);
130
- process.exit(1);
131
- });
132
- server.listen(port, () => {
133
- console.log(`[dev-serve] serving ${rootDir}`);
134
- console.log(`[dev-serve] listening on http://localhost:${port}`);
135
- console.log(`[dev-serve] editor: open with ?dev=${encodeURIComponent(`http://localhost:${port}`)}`);
136
- });
137
- let shuttingDown = false;
138
- const shutdown = () => {
139
- if (shuttingDown)
140
- return;
141
- shuttingDown = true;
142
- console.log("\n[dev-serve] shutting down");
143
- for (const client of clients) {
144
- try {
145
- client.res.end();
146
- }
147
- catch { }
148
- }
149
- clients.clear();
150
- server.close(() => process.exit(0));
151
- // Fallback: if in-flight sockets keep the server open, force-exit after 2s.
152
- setTimeout(() => process.exit(0), 2000).unref();
153
- };
154
- process.on("SIGINT", shutdown);
155
- process.on("SIGTERM", shutdown);
156
- }
157
- function handleRequest(req, res, ctx) {
158
- const method = (req.method ?? "GET").toUpperCase();
159
- const url = new URL(req.url ?? "/", `http://localhost`);
160
- const pathname = url.pathname;
161
- if (method === "OPTIONS") {
162
- // Echo the exact headers the browser requested so a preflight never fails
163
- // over an unexpected header (Sentry tracing, etc.). This is a local dev
164
- // tool, so permissive CORS is fine.
165
- const requested = req.headers["access-control-request-headers"];
166
- res.writeHead(204, {
167
- ...CORS_HEADERS,
168
- "access-control-allow-headers": typeof requested === "string" && requested ? requested : CORS_HEADERS["access-control-allow-headers"]
169
- });
170
- res.end();
171
- return;
172
- }
173
- if (pathname === "/_events" && method === "GET") {
174
- res.writeHead(200, {
175
- ...CORS_HEADERS,
176
- "content-type": "text/event-stream",
177
- "cache-control": "no-cache",
178
- connection: "keep-alive",
179
- "x-accel-buffering": "no"
180
- });
181
- // Include instance id + current cursor so a reconnecting client can tell
182
- // if the server restarted (missed events) or if it just needs to catch up.
183
- res.write(`event: hello\ndata: ${JSON.stringify({
184
- at: Date.now(),
185
- instance: SERVER_INSTANCE_ID,
186
- cursor: ctx.mutationCursor()
187
- })}\n\n`);
188
- const client = { res, id: ctx.clientId };
189
- ctx.clients.add(client);
190
- // Heartbeat keeps intermediate proxies (and iOS Safari) from timing out
191
- // the long-lived SSE connection.
192
- const heartbeat = setInterval(() => {
193
- try {
194
- res.write(`: heartbeat ${Date.now()}\n\n`);
195
- }
196
- catch { }
197
- }, 20_000);
198
- const cleanup = () => {
199
- clearInterval(heartbeat);
200
- ctx.clients.delete(client);
201
- };
202
- req.on("close", cleanup);
203
- req.on("error", cleanup);
204
- return;
205
- }
206
- const filePath = resolveFilePath(ctx.rootDir, pathname);
207
- if (!filePath) {
208
- respondJson(res, 404, { ok: false, error: `Not found: ${pathname}` });
209
- return;
210
- }
211
- if (method === "GET") {
212
- if (!existsSync(filePath)) {
213
- respondJson(res, 404, { ok: false, error: `Missing ${path.basename(filePath)}` });
214
- return;
215
- }
216
- try {
217
- const body = readFileSync(filePath);
218
- const stat = statSync(filePath);
219
- const contentType = pathname.endsWith(".html") ? "text/html; charset=utf-8" : "application/json";
220
- res.writeHead(200, {
221
- ...CORS_HEADERS,
222
- "content-type": contentType,
223
- "cache-control": "no-store",
224
- etag: `"${stat.size}-${stat.mtimeMs}"`
225
- });
226
- res.end(body);
227
- }
228
- catch (error) {
229
- respondJson(res, 500, { ok: false, error: `Read failed: ${error instanceof Error ? error.message : String(error)}` });
230
- }
231
- return;
232
- }
233
- if (method === "PUT" && pathname.endsWith("/composition.html")) {
234
- readBody(req).then((body) => {
235
- const html = body.toString("utf8");
236
- if (!html.includes("data-composition-id=")) {
237
- respondJson(res, 400, { ok: false, error: "Composition HTML is missing data-composition-id" });
238
- return;
239
- }
240
- try {
241
- mkdirSync(path.dirname(filePath), { recursive: true });
242
- ctx.suppressReloadFromSelfWrite(filePath);
243
- writeFileSync(filePath, html, "utf8");
244
- respondJson(res, 200, { ok: true, bytes: html.length });
245
- }
246
- catch (error) {
247
- respondJson(res, 500, { ok: false, error: `Write failed: ${error instanceof Error ? error.message : String(error)}` });
248
- }
249
- }).catch((error) => respondJson(res, 400, { ok: false, error: String(error) }));
250
- return;
251
- }
252
- if (method === "PATCH" && pathname.endsWith("/composition.json")) {
253
- readBody(req).then((body) => {
254
- try {
255
- const patch = JSON.parse(body.toString("utf8") || "{}");
256
- const existing = existsSync(filePath) ? JSON.parse(readFileSync(filePath, "utf8") || "{}") : {};
257
- const merged = { ...existing, ...patch };
258
- mkdirSync(path.dirname(filePath), { recursive: true });
259
- ctx.suppressReloadFromSelfWrite(filePath);
260
- writeFileSync(filePath, JSON.stringify(merged, null, 2), "utf8");
261
- respondJson(res, 200, merged);
262
- }
263
- catch (error) {
264
- respondJson(res, 400, { ok: false, error: `PATCH failed: ${error instanceof Error ? error.message : String(error)}` });
265
- }
266
- }).catch((error) => respondJson(res, 400, { ok: false, error: String(error) }));
267
- return;
268
- }
269
- respondJson(res, 405, { ok: false, error: `Method ${method} not allowed on ${pathname}` });
270
- }
271
- // Restrict served paths to the working/version composition files. Anything
272
- // else 404s so this server isn't a random static host. Also canonicalise the
273
- // resolved path so `..` segments cannot escape rootDir even through a future
274
- // refactor of the allowlist.
275
- function resolveFilePath(rootDir, pathname) {
276
- const clean = pathname.replace(/^\/+/, "");
277
- const parts = clean.split("/").filter(Boolean);
278
- if (parts.length === 0)
279
- return null;
280
- const filename = parts[parts.length - 1];
281
- const allowed = ["composition.html", "composition.json", "manifest.json"];
282
- if (!allowed.includes(filename))
283
- return null;
284
- let candidate;
285
- // Allow: `composition.html`, `versions/1/composition.html`.
286
- if (parts.length === 1)
287
- candidate = path.join(rootDir, filename);
288
- else if (parts.length === 3 && parts[0] === "versions" && /^\d+$/.test(parts[1])) {
289
- candidate = path.join(rootDir, "versions", parts[1], filename);
290
- }
291
- else {
292
- return null;
293
- }
294
- const resolved = path.resolve(candidate);
295
- const rootResolved = path.resolve(rootDir);
296
- const rel = path.relative(rootResolved, resolved);
297
- if (rel.startsWith("..") || path.isAbsolute(rel))
298
- return null;
299
- return resolved;
300
- }
301
- function respondJson(res, status, body) {
302
- res.writeHead(status, { ...CORS_HEADERS, "content-type": "application/json" });
303
- res.end(JSON.stringify(body));
304
- }
305
- function readBody(req) {
306
- return new Promise((resolve, reject) => {
307
- const chunks = [];
308
- let total = 0;
309
- let settled = false;
310
- // Guard against a slow client stringing the connection open forever.
311
- const timeout = setTimeout(() => {
312
- if (settled)
313
- return;
314
- settled = true;
315
- req.destroy();
316
- reject(new Error("Request body read timed out after 15s."));
317
- }, 15_000);
318
- const done = (fn) => {
319
- if (settled)
320
- return;
321
- settled = true;
322
- clearTimeout(timeout);
323
- fn();
324
- };
325
- req.on("data", (chunk) => {
326
- if (settled)
327
- return;
328
- total += chunk.length;
329
- if (total > MAX_REQUEST_BODY_BYTES) {
330
- done(() => reject(new Error(`Request body exceeds ${MAX_REQUEST_BODY_BYTES} bytes.`)));
331
- req.destroy();
332
- return;
333
- }
334
- chunks.push(chunk);
335
- });
336
- req.on("end", () => done(() => resolve(Buffer.concat(chunks))));
337
- req.on("error", (error) => done(() => reject(error)));
338
- });
339
- }
340
- //# sourceMappingURL=dev-serve.js.map