@officexapp/vidfarm-devcli 0.21.36 → 0.21.37

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.
@@ -235,6 +235,24 @@ function serveAsset(res, pathname) {
235
235
  return true;
236
236
  }
237
237
  // ── reverse proxy to the cloud host ─────────────────────────────────────────
238
+ /** How long to wait for upstream RESPONSE HEADERS before giving up (504). */
239
+ const PROXY_HEADERS_TIMEOUT_MS = 30_000;
240
+ function errorText(error) {
241
+ return error instanceof Error ? error.message : String(error);
242
+ }
243
+ /** Stream a finished render file. Same rule as the proxy pipe: a disk read
244
+ * error (file deleted mid-download, bad sector) must fail THIS response, not
245
+ * raise an uncaught 'error' that takes the whole serve process down. */
246
+ function pipeFile(res, filePath) {
247
+ const stream = createReadStream(filePath);
248
+ stream.on("error", (error) => {
249
+ console.warn(`[vidfarm] serve: could not stream ${filePath} (${errorText(error)})`);
250
+ res.destroy();
251
+ });
252
+ res.on("close", () => stream.destroy());
253
+ res.on("error", () => stream.destroy());
254
+ stream.pipe(res);
255
+ }
238
256
  // Hop-by-hop headers are stripped so we forward a clean request/response.
239
257
  const HOP_BY_HOP = new Set([
240
258
  "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
@@ -267,15 +285,40 @@ async function proxyToCloud(req, res, host, pathWithSearch, auth, bodyOverride)
267
285
  const method = req.method ?? "GET";
268
286
  const body = method === "GET" || method === "HEAD" ? undefined : (bodyOverride ?? await readBody(req));
269
287
  let upstream;
288
+ // Bound the wait for RESPONSE HEADERS only. A cloud route that never answers
289
+ // used to hang the browser forever with no error — the page shell paints from
290
+ // disk, then a data call spins for good. The BODY stays unbounded on purpose:
291
+ // proxied SSE (/api/v1/editor-chat) and media downloads are long-lived by
292
+ // design, so a body deadline would cut healthy streams.
293
+ const headersAbort = new AbortController();
294
+ const headersTimer = setTimeout(() => headersAbort.abort(), PROXY_HEADERS_TIMEOUT_MS);
270
295
  try {
271
- upstream = await fetch(target, { method, headers, body: body, redirect: "manual" });
296
+ upstream = await fetch(target, {
297
+ method,
298
+ headers,
299
+ body: body,
300
+ redirect: "manual",
301
+ signal: headersAbort.signal
302
+ });
272
303
  }
273
304
  catch (error) {
274
- res.statusCode = 502;
305
+ const timedOut = headersAbort.signal.aborted;
306
+ res.statusCode = timedOut ? 504 : 502;
275
307
  res.setHeader("content-type", "application/json");
276
- res.end(JSON.stringify({ error: "upstream_unreachable", detail: error instanceof Error ? error.message : String(error), host }));
308
+ res.end(JSON.stringify({
309
+ error: timedOut ? "upstream_timeout" : "upstream_unreachable",
310
+ detail: timedOut
311
+ ? `${host} sent no response headers within ${PROXY_HEADERS_TIMEOUT_MS / 1000}s`
312
+ : error instanceof Error ? error.message : String(error),
313
+ host
314
+ }));
277
315
  return;
278
316
  }
317
+ finally {
318
+ // Headers are in (or the request failed) — from here the body streams for
319
+ // as long as it needs to.
320
+ clearTimeout(headersTimer);
321
+ }
279
322
  res.statusCode = upstream.status;
280
323
  upstream.headers.forEach((value, key) => {
281
324
  if (HOP_BY_HOP.has(key.toLowerCase()))
@@ -287,7 +330,30 @@ async function proxyToCloud(req, res, host, pathWithSearch, auth, bodyOverride)
287
330
  res.setHeader("x-vidfarm-upstream", host);
288
331
  if (upstream.body) {
289
332
  // Stream the response (covers SSE from /api/v1/editor-chat too).
290
- Readable.fromWeb(upstream.body).pipe(res);
333
+ //
334
+ // An upstream body can break AFTER the headers arrive: a CloudFront/Lambda
335
+ // connection reset, or undici's own body timeout on a proxied stream that
336
+ // idles. That surfaces as an 'error' on this stream — and an UNHANDLED
337
+ // 'error' is an uncaught exception, which killed the entire serve process.
338
+ // The visible symptom was the reported one: the editor page paints, the
339
+ // server dies mid-load, and every remaining request hangs forever. So both
340
+ // ends of the pipe get error handling, and each end tears the other down.
341
+ const stream = Readable.fromWeb(upstream.body);
342
+ stream.on("error", (error) => {
343
+ console.warn(`[vidfarm] serve: upstream stream broke for ${pathWithSearch} (${errorText(error)})`);
344
+ // Headers are already on the wire, so there is no status left to send —
345
+ // cut the response so the browser reports a failed request instead of
346
+ // waiting on a body that will never arrive.
347
+ res.destroy();
348
+ });
349
+ // The browser navigating away or cancelling must not leave us pulling the
350
+ // upstream body forever.
351
+ res.on("close", () => stream.destroy());
352
+ res.on("error", (error) => {
353
+ console.warn(`[vidfarm] serve: client stream broke for ${pathWithSearch} (${errorText(error)})`);
354
+ stream.destroy();
355
+ });
356
+ stream.pipe(res);
291
357
  }
292
358
  else {
293
359
  res.end();
@@ -709,8 +775,28 @@ function renderLoginRequiredPage(pathname) {
709
775
  </html>
710
776
  `;
711
777
  }
778
+ // Last-resort net. Every known stream path now handles its own errors, but a
779
+ // local dev server must never disappear on a stray async throw — the user sees
780
+ // only a page that stopped loading, with no clue the server is gone. Log it
781
+ // loudly and stay up; this process is a stateless proxy plus a static file
782
+ // server, so there is no shared state left corrupt by a failed request.
783
+ let crashGuardInstalled = false;
784
+ function installServeCrashGuard() {
785
+ if (crashGuardInstalled)
786
+ return;
787
+ crashGuardInstalled = true;
788
+ process.on("uncaughtException", (error) => {
789
+ console.error(`[vidfarm] serve: recovered from an uncaught error — ${errorText(error)}`);
790
+ if (error instanceof Error && error.stack)
791
+ console.error(error.stack);
792
+ });
793
+ process.on("unhandledRejection", (reason) => {
794
+ console.error(`[vidfarm] serve: recovered from an unhandled rejection — ${errorText(reason)}`);
795
+ });
796
+ }
712
797
  export function startLocalFrontendServer(opts) {
713
798
  const host = opts.host.replace(/\/+$/, "");
799
+ installServeCrashGuard();
714
800
  const server = createServer((req, res) => {
715
801
  void handleRequest(req, res, host, opts.auth).catch((error) => {
716
802
  if (!res.headersSent) {
@@ -816,7 +902,7 @@ async function handleRequest(req, res, host, auth) {
816
902
  res.setHeader("content-type", "video/mp4");
817
903
  res.setHeader("content-length", String(statSync(record.outputPath).size));
818
904
  res.setHeader("content-disposition", `inline; filename="vidfarm-${path.basename(record.outputPath)}"`);
819
- createReadStream(record.outputPath).pipe(res);
905
+ pipeFile(res, record.outputPath);
820
906
  return;
821
907
  }
822
908
  // 3b. Studio-contract render endpoints (the sealed StudioApp's Export
@@ -909,7 +995,7 @@ async function handleRequest(req, res, host, auth) {
909
995
  res.setHeader("content-type", STUDIO_RENDER_MIME[path.extname(job.outputPath)] ?? "video/mp4");
910
996
  res.setHeader("content-length", String(statSync(job.outputPath).size));
911
997
  res.setHeader("content-disposition", `inline; filename="${filename}"`);
912
- createReadStream(job.outputPath).pipe(res);
998
+ pipeFile(res, job.outputPath);
913
999
  return;
914
1000
  }
915
1001
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@officexapp/vidfarm-devcli",
3
- "version": "0.21.36",
3
+ "version": "0.21.37",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {