@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 +9 -0
- package/SKILL.platform.md +11 -3
- package/dist/src/dev-serve.js +198 -38
- package/package.json +1 -1
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`
|
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.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": {
|