@effing/ffs 0.6.1 → 0.7.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/README.md +85 -176
- package/dist/{chunk-PERB3C4S.js → chunk-AUH73KG2.js} +14 -25
- package/dist/{chunk-O7Z6DV2I.js → chunk-CSKH34HX.js} +2 -2
- package/dist/{chunk-WD7MF3GH.js → chunk-D3QX3EOA.js} +345 -464
- package/dist/chunk-D3QX3EOA.js.map +1 -0
- package/dist/{chunk-4N2GLGC5.js → chunk-UN34ESVZ.js} +15 -26
- package/dist/chunk-UN34ESVZ.js.map +1 -0
- package/dist/handlers/index.d.ts +25 -36
- package/dist/handlers/index.js +8 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/{proxy-CsZ5h2Ya.d.ts → proxy-BNr00n_4.d.ts} +2 -4
- package/dist/render-6HNZT5UH.js +8 -0
- package/dist/{render-MUKKTCF6.js → render-E3U44GOC.js} +1 -1
- package/dist/server.js +349 -467
- package/dist/server.js.map +1 -1
- package/package.json +3 -3
- package/dist/chunk-4N2GLGC5.js.map +0 -1
- package/dist/chunk-WD7MF3GH.js.map +0 -1
- package/dist/render-IKGZZOBP.js +0 -8
- /package/dist/{chunk-O7Z6DV2I.js.map → chunk-CSKH34HX.js.map} +0 -0
- /package/dist/{render-IKGZZOBP.js.map → render-6HNZT5UH.js.map} +0 -0
package/dist/server.js
CHANGED
|
@@ -3,10 +3,10 @@ import {
|
|
|
3
3
|
createTransientStore,
|
|
4
4
|
ffsFetch,
|
|
5
5
|
storeKeys
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-AUH73KG2.js";
|
|
7
7
|
|
|
8
8
|
// src/server.ts
|
|
9
|
-
import
|
|
9
|
+
import express4 from "express";
|
|
10
10
|
import bodyParser from "body-parser";
|
|
11
11
|
|
|
12
12
|
// src/handlers/shared.ts
|
|
@@ -195,446 +195,6 @@ data: ${JSON.stringify(data)}
|
|
|
195
195
|
`);
|
|
196
196
|
};
|
|
197
197
|
}
|
|
198
|
-
|
|
199
|
-
// src/handlers/caching.ts
|
|
200
|
-
import "express";
|
|
201
|
-
import { Readable as Readable2, Transform } from "stream";
|
|
202
|
-
import { randomUUID as randomUUID3 } from "crypto";
|
|
203
|
-
import {
|
|
204
|
-
extractEffieSources,
|
|
205
|
-
extractEffieSourcesWithTypes as extractEffieSourcesWithTypes2
|
|
206
|
-
} from "@effing/effie";
|
|
207
|
-
|
|
208
|
-
// src/handlers/orchestrating.ts
|
|
209
|
-
import "express";
|
|
210
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
211
|
-
import { extractEffieSourcesWithTypes, effieDataSchema as effieDataSchema3 } from "@effing/effie";
|
|
212
|
-
|
|
213
|
-
// src/handlers/rendering.ts
|
|
214
|
-
import "express";
|
|
215
|
-
import { randomUUID } from "crypto";
|
|
216
|
-
import { effieDataSchema as effieDataSchema2 } from "@effing/effie";
|
|
217
|
-
async function createRenderJob(req, res, ctx2, options) {
|
|
218
|
-
try {
|
|
219
|
-
const isWrapped = "effie" in req.body;
|
|
220
|
-
let rawEffieData;
|
|
221
|
-
let scale;
|
|
222
|
-
let upload;
|
|
223
|
-
if (isWrapped) {
|
|
224
|
-
const options2 = req.body;
|
|
225
|
-
if (typeof options2.effie === "string") {
|
|
226
|
-
const response = await ffsFetch(options2.effie);
|
|
227
|
-
if (!response.ok) {
|
|
228
|
-
throw new Error(
|
|
229
|
-
`Failed to fetch Effie data: ${response.status} ${response.statusText}`
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
rawEffieData = await response.json();
|
|
233
|
-
} else {
|
|
234
|
-
rawEffieData = options2.effie;
|
|
235
|
-
}
|
|
236
|
-
scale = options2.scale ?? 1;
|
|
237
|
-
upload = options2.upload;
|
|
238
|
-
} else {
|
|
239
|
-
rawEffieData = req.body;
|
|
240
|
-
scale = parseFloat(req.query.scale?.toString() || "1");
|
|
241
|
-
}
|
|
242
|
-
let effie;
|
|
243
|
-
if (!ctx2.skipValidation) {
|
|
244
|
-
const result = effieDataSchema2.safeParse(rawEffieData);
|
|
245
|
-
if (!result.success) {
|
|
246
|
-
res.status(400).json({
|
|
247
|
-
error: "Invalid effie data",
|
|
248
|
-
issues: result.error.issues.map((issue) => ({
|
|
249
|
-
path: issue.path.join("."),
|
|
250
|
-
message: issue.message
|
|
251
|
-
}))
|
|
252
|
-
});
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
effie = result.data;
|
|
256
|
-
} else {
|
|
257
|
-
const data = rawEffieData;
|
|
258
|
-
if (!data?.segments) {
|
|
259
|
-
res.status(400).json({ error: "Invalid effie data: missing segments" });
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
effie = data;
|
|
263
|
-
}
|
|
264
|
-
const jobId = randomUUID();
|
|
265
|
-
const job = {
|
|
266
|
-
effie,
|
|
267
|
-
scale,
|
|
268
|
-
upload,
|
|
269
|
-
createdAt: Date.now(),
|
|
270
|
-
metadata: options?.metadata
|
|
271
|
-
};
|
|
272
|
-
await ctx2.transientStore.putJson(
|
|
273
|
-
storeKeys.renderJob(jobId),
|
|
274
|
-
job,
|
|
275
|
-
ctx2.transientStore.jobDataTtlMs
|
|
276
|
-
);
|
|
277
|
-
res.json({
|
|
278
|
-
id: jobId,
|
|
279
|
-
url: `${ctx2.baseUrl}/render/${jobId}`
|
|
280
|
-
});
|
|
281
|
-
} catch (error) {
|
|
282
|
-
console.error("Error creating render job:", error);
|
|
283
|
-
res.status(500).json({ error: "Failed to create render job" });
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
async function streamRenderJob(req, res, ctx2) {
|
|
287
|
-
try {
|
|
288
|
-
setupCORSHeaders(res);
|
|
289
|
-
const jobId = req.params.id;
|
|
290
|
-
const jobStoreKey = storeKeys.renderJob(jobId);
|
|
291
|
-
const job = await ctx2.transientStore.getJson(jobStoreKey);
|
|
292
|
-
if (!job) {
|
|
293
|
-
res.status(404).json({ error: "Job not found or expired" });
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
if (ctx2.renderBackendResolver) {
|
|
297
|
-
const backend = ctx2.renderBackendResolver(job.effie, job.metadata);
|
|
298
|
-
if (backend) {
|
|
299
|
-
await proxyRenderFromBackend(res, jobId, backend);
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
ctx2.transientStore.delete(jobStoreKey);
|
|
304
|
-
if (job.upload) {
|
|
305
|
-
await streamRenderWithUpload(res, job, ctx2);
|
|
306
|
-
} else {
|
|
307
|
-
await streamRenderDirect(res, job, ctx2);
|
|
308
|
-
}
|
|
309
|
-
} catch (error) {
|
|
310
|
-
console.error("Error in render:", error);
|
|
311
|
-
if (!res.headersSent) {
|
|
312
|
-
res.status(500).json({ error: "Rendering failed" });
|
|
313
|
-
} else {
|
|
314
|
-
res.end();
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
async function streamRenderDirect(res, job, ctx2) {
|
|
319
|
-
const { EffieRenderer } = await import("./render-MUKKTCF6.js");
|
|
320
|
-
const renderer = new EffieRenderer(job.effie, {
|
|
321
|
-
transientStore: ctx2.transientStore,
|
|
322
|
-
httpProxy: ctx2.httpProxy
|
|
323
|
-
});
|
|
324
|
-
const videoStream = await renderer.render(job.scale);
|
|
325
|
-
res.on("close", () => {
|
|
326
|
-
videoStream.destroy();
|
|
327
|
-
renderer.close();
|
|
328
|
-
});
|
|
329
|
-
res.set("Content-Type", "video/mp4");
|
|
330
|
-
res.set("Cache-Control", "public, immutable, max-age=86400");
|
|
331
|
-
videoStream.pipe(res);
|
|
332
|
-
}
|
|
333
|
-
async function streamRenderWithUpload(res, job, ctx2) {
|
|
334
|
-
setupSSEResponse(res);
|
|
335
|
-
const sendEvent = createSSEEventSender(res);
|
|
336
|
-
const keepalive = setInterval(() => {
|
|
337
|
-
sendEvent("keepalive", { status: "rendering" });
|
|
338
|
-
}, 25e3);
|
|
339
|
-
try {
|
|
340
|
-
sendEvent("started", { status: "rendering" });
|
|
341
|
-
const timings = await renderAndUploadInternal(
|
|
342
|
-
job.effie,
|
|
343
|
-
job.scale,
|
|
344
|
-
job.upload,
|
|
345
|
-
sendEvent,
|
|
346
|
-
ctx2
|
|
347
|
-
);
|
|
348
|
-
sendEvent("complete", { status: "uploaded", timings });
|
|
349
|
-
} catch (error) {
|
|
350
|
-
sendEvent("error", { message: String(error) });
|
|
351
|
-
} finally {
|
|
352
|
-
clearInterval(keepalive);
|
|
353
|
-
res.end();
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
async function renderAndUploadInternal(effie, scale, upload, sendEvent, ctx2) {
|
|
357
|
-
const timings = {};
|
|
358
|
-
if (upload.coverUrl) {
|
|
359
|
-
const fetchCoverStartTime = Date.now();
|
|
360
|
-
const coverFetchResponse = await ffsFetch(effie.cover);
|
|
361
|
-
if (!coverFetchResponse.ok) {
|
|
362
|
-
throw new Error(
|
|
363
|
-
`Failed to fetch cover image: ${coverFetchResponse.status} ${coverFetchResponse.statusText}`
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
const coverBuffer = Buffer.from(await coverFetchResponse.arrayBuffer());
|
|
367
|
-
timings.fetchCoverTime = Date.now() - fetchCoverStartTime;
|
|
368
|
-
const uploadCoverStartTime = Date.now();
|
|
369
|
-
const uploadCoverResponse = await ffsFetch(upload.coverUrl, {
|
|
370
|
-
method: "PUT",
|
|
371
|
-
body: coverBuffer,
|
|
372
|
-
headers: {
|
|
373
|
-
"Content-Type": "image/png",
|
|
374
|
-
"Content-Length": coverBuffer.length.toString()
|
|
375
|
-
}
|
|
376
|
-
});
|
|
377
|
-
if (!uploadCoverResponse.ok) {
|
|
378
|
-
throw new Error(
|
|
379
|
-
`Failed to upload cover: ${uploadCoverResponse.status} ${uploadCoverResponse.statusText}`
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
timings.uploadCoverTime = Date.now() - uploadCoverStartTime;
|
|
383
|
-
}
|
|
384
|
-
const renderStartTime = Date.now();
|
|
385
|
-
const { EffieRenderer } = await import("./render-MUKKTCF6.js");
|
|
386
|
-
const renderer = new EffieRenderer(effie, {
|
|
387
|
-
transientStore: ctx2.transientStore,
|
|
388
|
-
httpProxy: ctx2.httpProxy
|
|
389
|
-
});
|
|
390
|
-
const videoStream = await renderer.render(scale);
|
|
391
|
-
const chunks = [];
|
|
392
|
-
for await (const chunk of videoStream) {
|
|
393
|
-
chunks.push(Buffer.from(chunk));
|
|
394
|
-
}
|
|
395
|
-
const videoBuffer = Buffer.concat(chunks);
|
|
396
|
-
timings.renderTime = Date.now() - renderStartTime;
|
|
397
|
-
sendEvent("keepalive", { status: "uploading" });
|
|
398
|
-
const uploadStartTime = Date.now();
|
|
399
|
-
const uploadResponse = await ffsFetch(upload.videoUrl, {
|
|
400
|
-
method: "PUT",
|
|
401
|
-
body: videoBuffer,
|
|
402
|
-
headers: {
|
|
403
|
-
"Content-Type": "video/mp4",
|
|
404
|
-
"Content-Length": videoBuffer.length.toString()
|
|
405
|
-
}
|
|
406
|
-
});
|
|
407
|
-
if (!uploadResponse.ok) {
|
|
408
|
-
throw new Error(
|
|
409
|
-
`Failed to upload rendered video: ${uploadResponse.status} ${uploadResponse.statusText}`
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
timings.uploadTime = Date.now() - uploadStartTime;
|
|
413
|
-
return timings;
|
|
414
|
-
}
|
|
415
|
-
async function proxyRenderFromBackend(res, jobId, backend) {
|
|
416
|
-
const backendUrl = `${backend.baseUrl}/render/${jobId}`;
|
|
417
|
-
const response = await ffsFetch(backendUrl, {
|
|
418
|
-
headers: backend.apiKey ? { Authorization: `Bearer ${backend.apiKey}` } : void 0
|
|
419
|
-
});
|
|
420
|
-
if (!response.ok) {
|
|
421
|
-
res.status(response.status).json({ error: "Backend render failed" });
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
const contentType = response.headers.get("content-type") || "";
|
|
425
|
-
if (contentType.includes("text/event-stream")) {
|
|
426
|
-
setupSSEResponse(res);
|
|
427
|
-
const sendEvent = createSSEEventSender(res);
|
|
428
|
-
const reader = response.body?.getReader();
|
|
429
|
-
if (!reader) {
|
|
430
|
-
sendEvent("error", { message: "No response body from backend" });
|
|
431
|
-
res.end();
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
const decoder = new TextDecoder();
|
|
435
|
-
let buffer = "";
|
|
436
|
-
try {
|
|
437
|
-
while (true) {
|
|
438
|
-
const { done, value } = await reader.read();
|
|
439
|
-
if (done) break;
|
|
440
|
-
if (res.destroyed) {
|
|
441
|
-
reader.cancel();
|
|
442
|
-
break;
|
|
443
|
-
}
|
|
444
|
-
buffer += decoder.decode(value, { stream: true });
|
|
445
|
-
const lines = buffer.split("\n");
|
|
446
|
-
buffer = lines.pop() || "";
|
|
447
|
-
let currentEvent = "";
|
|
448
|
-
let currentData = "";
|
|
449
|
-
for (const line of lines) {
|
|
450
|
-
if (line.startsWith("event: ")) {
|
|
451
|
-
currentEvent = line.slice(7);
|
|
452
|
-
} else if (line.startsWith("data: ")) {
|
|
453
|
-
currentData = line.slice(6);
|
|
454
|
-
} else if (line === "" && currentEvent && currentData) {
|
|
455
|
-
try {
|
|
456
|
-
const data = JSON.parse(currentData);
|
|
457
|
-
sendEvent(currentEvent, data);
|
|
458
|
-
} catch {
|
|
459
|
-
}
|
|
460
|
-
currentEvent = "";
|
|
461
|
-
currentData = "";
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
} finally {
|
|
466
|
-
reader.releaseLock();
|
|
467
|
-
res.end();
|
|
468
|
-
}
|
|
469
|
-
} else {
|
|
470
|
-
await proxyBinaryStream(response, res);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// src/handlers/orchestrating.ts
|
|
475
|
-
async function createWarmupAndRenderJob(req, res, ctx2, options) {
|
|
476
|
-
try {
|
|
477
|
-
const body = req.body;
|
|
478
|
-
let rawEffieData;
|
|
479
|
-
if (typeof body.effie === "string") {
|
|
480
|
-
const response = await ffsFetch(body.effie);
|
|
481
|
-
if (!response.ok) {
|
|
482
|
-
throw new Error(
|
|
483
|
-
`Failed to fetch Effie data: ${response.status} ${response.statusText}`
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
rawEffieData = await response.json();
|
|
487
|
-
} else {
|
|
488
|
-
rawEffieData = body.effie;
|
|
489
|
-
}
|
|
490
|
-
let effie;
|
|
491
|
-
if (!ctx2.skipValidation) {
|
|
492
|
-
const result = effieDataSchema3.safeParse(rawEffieData);
|
|
493
|
-
if (!result.success) {
|
|
494
|
-
res.status(400).json({
|
|
495
|
-
error: "Invalid effie data",
|
|
496
|
-
issues: result.error.issues.map((issue) => ({
|
|
497
|
-
path: issue.path.join("."),
|
|
498
|
-
message: issue.message
|
|
499
|
-
}))
|
|
500
|
-
});
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
effie = result.data;
|
|
504
|
-
} else {
|
|
505
|
-
const data = rawEffieData;
|
|
506
|
-
if (!data?.segments) {
|
|
507
|
-
res.status(400).json({ error: "Invalid effie data: missing segments" });
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
effie = data;
|
|
511
|
-
}
|
|
512
|
-
const sources = extractEffieSourcesWithTypes(effie);
|
|
513
|
-
const scale = body.scale ?? 1;
|
|
514
|
-
const upload = body.upload;
|
|
515
|
-
const jobId = randomUUID2();
|
|
516
|
-
const warmupJobId = randomUUID2();
|
|
517
|
-
const renderJobId = randomUUID2();
|
|
518
|
-
const job = {
|
|
519
|
-
effie,
|
|
520
|
-
sources,
|
|
521
|
-
scale,
|
|
522
|
-
upload,
|
|
523
|
-
warmupJobId,
|
|
524
|
-
renderJobId,
|
|
525
|
-
createdAt: Date.now(),
|
|
526
|
-
metadata: options?.metadata
|
|
527
|
-
};
|
|
528
|
-
await ctx2.transientStore.putJson(
|
|
529
|
-
storeKeys.warmupAndRenderJob(jobId),
|
|
530
|
-
job,
|
|
531
|
-
ctx2.transientStore.jobDataTtlMs
|
|
532
|
-
);
|
|
533
|
-
await ctx2.transientStore.putJson(
|
|
534
|
-
storeKeys.warmupJob(warmupJobId),
|
|
535
|
-
{ sources, metadata: options?.metadata },
|
|
536
|
-
ctx2.transientStore.jobDataTtlMs
|
|
537
|
-
);
|
|
538
|
-
await ctx2.transientStore.putJson(
|
|
539
|
-
storeKeys.renderJob(renderJobId),
|
|
540
|
-
{
|
|
541
|
-
effie,
|
|
542
|
-
scale,
|
|
543
|
-
upload,
|
|
544
|
-
createdAt: Date.now(),
|
|
545
|
-
metadata: options?.metadata
|
|
546
|
-
},
|
|
547
|
-
ctx2.transientStore.jobDataTtlMs
|
|
548
|
-
);
|
|
549
|
-
res.json({
|
|
550
|
-
id: jobId,
|
|
551
|
-
url: `${ctx2.baseUrl}/warmup-and-render/${jobId}`
|
|
552
|
-
});
|
|
553
|
-
} catch (error) {
|
|
554
|
-
console.error("Error creating warmup-and-render job:", error);
|
|
555
|
-
res.status(500).json({ error: "Failed to create warmup-and-render job" });
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
async function streamWarmupAndRenderJob(req, res, ctx2) {
|
|
559
|
-
try {
|
|
560
|
-
setupCORSHeaders(res);
|
|
561
|
-
const jobId = req.params.id;
|
|
562
|
-
const jobStoreKey = storeKeys.warmupAndRenderJob(jobId);
|
|
563
|
-
const job = await ctx2.transientStore.getJson(jobStoreKey);
|
|
564
|
-
ctx2.transientStore.delete(jobStoreKey);
|
|
565
|
-
if (!job) {
|
|
566
|
-
res.status(404).json({ error: "Job not found" });
|
|
567
|
-
return;
|
|
568
|
-
}
|
|
569
|
-
const warmupBackend = ctx2.warmupBackendResolver ? ctx2.warmupBackendResolver(job.sources, job.metadata) : null;
|
|
570
|
-
const renderBackend = ctx2.renderBackendResolver ? ctx2.renderBackendResolver(job.effie, job.metadata) : null;
|
|
571
|
-
setupSSEResponse(res);
|
|
572
|
-
const sendEvent = createSSEEventSender(res);
|
|
573
|
-
let keepalivePhase = "warmup";
|
|
574
|
-
const keepalive = setInterval(() => {
|
|
575
|
-
sendEvent("keepalive", { phase: keepalivePhase });
|
|
576
|
-
}, 25e3);
|
|
577
|
-
try {
|
|
578
|
-
if (warmupBackend) {
|
|
579
|
-
await proxyRemoteSSE(
|
|
580
|
-
`${warmupBackend.baseUrl}/warmup/${job.warmupJobId}`,
|
|
581
|
-
sendEvent,
|
|
582
|
-
"warmup:",
|
|
583
|
-
res,
|
|
584
|
-
warmupBackend.apiKey ? { Authorization: `Bearer ${warmupBackend.apiKey}` } : void 0
|
|
585
|
-
);
|
|
586
|
-
} else {
|
|
587
|
-
const warmupSender = prefixEventSender(sendEvent, "warmup:");
|
|
588
|
-
await warmupSources(job.sources, warmupSender, ctx2);
|
|
589
|
-
warmupSender("complete", { status: "ready" });
|
|
590
|
-
}
|
|
591
|
-
keepalivePhase = "render";
|
|
592
|
-
if (renderBackend) {
|
|
593
|
-
await proxyRemoteSSE(
|
|
594
|
-
`${renderBackend.baseUrl}/render/${job.renderJobId}`,
|
|
595
|
-
sendEvent,
|
|
596
|
-
"render:",
|
|
597
|
-
res,
|
|
598
|
-
renderBackend.apiKey ? { Authorization: `Bearer ${renderBackend.apiKey}` } : void 0
|
|
599
|
-
);
|
|
600
|
-
} else {
|
|
601
|
-
const renderSender = prefixEventSender(sendEvent, "render:");
|
|
602
|
-
if (job.upload) {
|
|
603
|
-
renderSender("started", { status: "rendering" });
|
|
604
|
-
const timings = await renderAndUploadInternal(
|
|
605
|
-
job.effie,
|
|
606
|
-
job.scale,
|
|
607
|
-
job.upload,
|
|
608
|
-
renderSender,
|
|
609
|
-
ctx2
|
|
610
|
-
);
|
|
611
|
-
renderSender("complete", { status: "uploaded", timings });
|
|
612
|
-
} else {
|
|
613
|
-
const videoUrl = `${ctx2.baseUrl}/render/${job.renderJobId}`;
|
|
614
|
-
sendEvent("complete", { status: "ready", videoUrl });
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
if (job.upload && !renderBackend) {
|
|
618
|
-
sendEvent("complete", { status: "done" });
|
|
619
|
-
}
|
|
620
|
-
} catch (error) {
|
|
621
|
-
sendEvent("error", {
|
|
622
|
-
phase: keepalivePhase,
|
|
623
|
-
message: String(error)
|
|
624
|
-
});
|
|
625
|
-
} finally {
|
|
626
|
-
clearInterval(keepalive);
|
|
627
|
-
res.end();
|
|
628
|
-
}
|
|
629
|
-
} catch (error) {
|
|
630
|
-
console.error("Error in warmup-and-render streaming:", error);
|
|
631
|
-
if (!res.headersSent) {
|
|
632
|
-
res.status(500).json({ error: "Warmup-and-render streaming failed" });
|
|
633
|
-
} else {
|
|
634
|
-
res.end();
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
198
|
function prefixEventSender(sendEvent, prefix) {
|
|
639
199
|
return (event, data) => {
|
|
640
200
|
sendEvent(`${prefix}${event}`, data);
|
|
@@ -715,6 +275,13 @@ async function proxyBinaryStream(response, res) {
|
|
|
715
275
|
}
|
|
716
276
|
|
|
717
277
|
// src/handlers/caching.ts
|
|
278
|
+
import "express";
|
|
279
|
+
import { Readable as Readable2, Transform } from "stream";
|
|
280
|
+
import { randomUUID } from "crypto";
|
|
281
|
+
import {
|
|
282
|
+
extractEffieSources,
|
|
283
|
+
extractEffieSourcesWithTypes
|
|
284
|
+
} from "@effing/effie";
|
|
718
285
|
function shouldSkipWarmup(source) {
|
|
719
286
|
return source.type === "video" || source.type === "audio";
|
|
720
287
|
}
|
|
@@ -726,24 +293,24 @@ async function createWarmupJob(req, res, ctx2, options) {
|
|
|
726
293
|
res.status(400).json(parseResult);
|
|
727
294
|
return;
|
|
728
295
|
}
|
|
729
|
-
const sources =
|
|
730
|
-
const jobId =
|
|
296
|
+
const sources = extractEffieSourcesWithTypes(parseResult.effie);
|
|
297
|
+
const jobId = randomUUID();
|
|
731
298
|
const job = { sources, metadata: options?.metadata };
|
|
732
299
|
await ctx2.transientStore.putJson(
|
|
733
300
|
storeKeys.warmupJob(jobId),
|
|
734
301
|
job,
|
|
735
|
-
ctx2.transientStore.
|
|
302
|
+
ctx2.transientStore.ttlMs
|
|
736
303
|
);
|
|
737
304
|
res.json({
|
|
738
305
|
id: jobId,
|
|
739
|
-
|
|
306
|
+
progressUrl: `${ctx2.baseUrl}/warmup/${jobId}/progress`
|
|
740
307
|
});
|
|
741
308
|
} catch (error) {
|
|
742
309
|
console.error("Error creating warmup job:", error);
|
|
743
310
|
res.status(500).json({ error: "Failed to create warmup job" });
|
|
744
311
|
}
|
|
745
312
|
}
|
|
746
|
-
async function
|
|
313
|
+
async function streamWarmupProgress(req, res, ctx2) {
|
|
747
314
|
try {
|
|
748
315
|
setupCORSHeaders(res);
|
|
749
316
|
const jobId = req.params.id;
|
|
@@ -760,7 +327,7 @@ async function streamWarmupJob(req, res, ctx2) {
|
|
|
760
327
|
const sendEvent2 = createSSEEventSender(res);
|
|
761
328
|
try {
|
|
762
329
|
await proxyRemoteSSE(
|
|
763
|
-
`${backend.baseUrl}/warmup/${jobId}`,
|
|
330
|
+
`${backend.baseUrl}/warmup/${jobId}/progress`,
|
|
764
331
|
sendEvent2,
|
|
765
332
|
"",
|
|
766
333
|
res,
|
|
@@ -792,6 +359,17 @@ async function streamWarmupJob(req, res, ctx2) {
|
|
|
792
359
|
}
|
|
793
360
|
}
|
|
794
361
|
}
|
|
362
|
+
async function purgeCachedSources(urls, store) {
|
|
363
|
+
let purged = 0;
|
|
364
|
+
for (const url of urls) {
|
|
365
|
+
const ck = storeKeys.source(url);
|
|
366
|
+
if (await store.exists(ck)) {
|
|
367
|
+
await store.delete(ck);
|
|
368
|
+
purged++;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { purged, total: urls.length };
|
|
372
|
+
}
|
|
795
373
|
async function purgeCache(req, res, ctx2) {
|
|
796
374
|
try {
|
|
797
375
|
const parseResult = parseEffieData(req.body, ctx2.skipValidation);
|
|
@@ -800,15 +378,8 @@ async function purgeCache(req, res, ctx2) {
|
|
|
800
378
|
return;
|
|
801
379
|
}
|
|
802
380
|
const sources = extractEffieSources(parseResult.effie);
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
const ck = storeKeys.source(url);
|
|
806
|
-
if (await ctx2.transientStore.exists(ck)) {
|
|
807
|
-
await ctx2.transientStore.delete(ck);
|
|
808
|
-
purged++;
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
res.json({ purged, total: sources.length });
|
|
381
|
+
const result = await purgeCachedSources(sources, ctx2.transientStore);
|
|
382
|
+
res.json(result);
|
|
812
383
|
} catch (error) {
|
|
813
384
|
console.error("Error purging cache:", error);
|
|
814
385
|
res.status(500).json({ error: "Failed to purge cache" });
|
|
@@ -945,12 +516,324 @@ async function fetchAndCache(url, cacheKey, sendEvent, ctx2) {
|
|
|
945
516
|
await ctx2.transientStore.put(
|
|
946
517
|
cacheKey,
|
|
947
518
|
trackedStream,
|
|
948
|
-
ctx2.transientStore.
|
|
519
|
+
ctx2.transientStore.ttlMs
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/handlers/rendering.ts
|
|
524
|
+
import "express";
|
|
525
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
526
|
+
import {
|
|
527
|
+
extractEffieSourcesWithTypes as extractEffieSourcesWithTypes2,
|
|
528
|
+
extractEffieSources as extractEffieSources2,
|
|
529
|
+
effieDataSchema as effieDataSchema2
|
|
530
|
+
} from "@effing/effie";
|
|
531
|
+
async function createRenderJob(req, res, ctx2, options) {
|
|
532
|
+
try {
|
|
533
|
+
const body = req.body;
|
|
534
|
+
let rawEffieData;
|
|
535
|
+
if (typeof body.effie === "string") {
|
|
536
|
+
const response = await ffsFetch(body.effie);
|
|
537
|
+
if (!response.ok) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
`Failed to fetch Effie data: ${response.status} ${response.statusText}`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
rawEffieData = await response.json();
|
|
543
|
+
} else {
|
|
544
|
+
rawEffieData = body.effie;
|
|
545
|
+
}
|
|
546
|
+
let effie;
|
|
547
|
+
if (!ctx2.skipValidation) {
|
|
548
|
+
const result = effieDataSchema2.safeParse(rawEffieData);
|
|
549
|
+
if (!result.success) {
|
|
550
|
+
res.status(400).json({
|
|
551
|
+
error: "Invalid effie data",
|
|
552
|
+
issues: result.error.issues.map((issue) => ({
|
|
553
|
+
path: issue.path.join("."),
|
|
554
|
+
message: issue.message
|
|
555
|
+
}))
|
|
556
|
+
});
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
effie = result.data;
|
|
560
|
+
} else {
|
|
561
|
+
const data = rawEffieData;
|
|
562
|
+
if (!data?.segments) {
|
|
563
|
+
res.status(400).json({ error: "Invalid effie data: missing segments" });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
effie = data;
|
|
567
|
+
}
|
|
568
|
+
const sources = extractEffieSourcesWithTypes2(effie);
|
|
569
|
+
const scale = body.scale ?? 1;
|
|
570
|
+
const upload = body.upload;
|
|
571
|
+
const purge = body.purge;
|
|
572
|
+
const jobId = randomUUID2();
|
|
573
|
+
const warmupJobId = randomUUID2();
|
|
574
|
+
const job = {
|
|
575
|
+
effie,
|
|
576
|
+
sources,
|
|
577
|
+
scale,
|
|
578
|
+
upload,
|
|
579
|
+
purge,
|
|
580
|
+
warmupJobId,
|
|
581
|
+
createdAt: Date.now(),
|
|
582
|
+
metadata: options?.metadata
|
|
583
|
+
};
|
|
584
|
+
await ctx2.transientStore.putJson(
|
|
585
|
+
storeKeys.renderJob(jobId),
|
|
586
|
+
job,
|
|
587
|
+
ctx2.transientStore.ttlMs
|
|
588
|
+
);
|
|
589
|
+
await ctx2.transientStore.putJson(
|
|
590
|
+
storeKeys.warmupJob(warmupJobId),
|
|
591
|
+
{ sources, metadata: options?.metadata },
|
|
592
|
+
ctx2.transientStore.ttlMs
|
|
593
|
+
);
|
|
594
|
+
res.json({
|
|
595
|
+
id: jobId,
|
|
596
|
+
progressUrl: `${ctx2.baseUrl}/render/${jobId}/progress`
|
|
597
|
+
});
|
|
598
|
+
} catch (error) {
|
|
599
|
+
console.error("Error creating render job:", error);
|
|
600
|
+
res.status(500).json({ error: "Failed to create render job" });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
async function streamRenderProgress(req, res, ctx2) {
|
|
604
|
+
try {
|
|
605
|
+
setupCORSHeaders(res);
|
|
606
|
+
const jobId = req.params.id;
|
|
607
|
+
const jobStoreKey = storeKeys.renderJob(jobId);
|
|
608
|
+
const job = await ctx2.transientStore.getJson(jobStoreKey);
|
|
609
|
+
ctx2.transientStore.delete(jobStoreKey);
|
|
610
|
+
if (!job) {
|
|
611
|
+
res.status(404).json({ error: "Job not found" });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const warmupBackend = ctx2.warmupBackendResolver ? ctx2.warmupBackendResolver(job.sources, job.metadata) : null;
|
|
615
|
+
const renderBackend = ctx2.renderBackendResolver ? ctx2.renderBackendResolver(job.effie, job.metadata) : null;
|
|
616
|
+
setupSSEResponse(res);
|
|
617
|
+
const sendEvent = createSSEEventSender(res);
|
|
618
|
+
let keepalivePhase = "warmup";
|
|
619
|
+
const keepalive = setInterval(() => {
|
|
620
|
+
sendEvent("keepalive", { phase: keepalivePhase });
|
|
621
|
+
}, 25e3);
|
|
622
|
+
try {
|
|
623
|
+
if (job.purge) {
|
|
624
|
+
const sourceUrls = extractEffieSources2(job.effie);
|
|
625
|
+
const purgeResult = await purgeCachedSources(
|
|
626
|
+
sourceUrls,
|
|
627
|
+
ctx2.transientStore
|
|
628
|
+
);
|
|
629
|
+
sendEvent("purge:complete", purgeResult);
|
|
630
|
+
}
|
|
631
|
+
if (warmupBackend) {
|
|
632
|
+
await proxyRemoteSSE(
|
|
633
|
+
`${warmupBackend.baseUrl}/warmup/${job.warmupJobId}/progress`,
|
|
634
|
+
sendEvent,
|
|
635
|
+
"warmup:",
|
|
636
|
+
res,
|
|
637
|
+
warmupBackend.apiKey ? { Authorization: `Bearer ${warmupBackend.apiKey}` } : void 0
|
|
638
|
+
);
|
|
639
|
+
} else {
|
|
640
|
+
const warmupSender = prefixEventSender(sendEvent, "warmup:");
|
|
641
|
+
await warmupSources(job.sources, warmupSender, ctx2);
|
|
642
|
+
warmupSender("complete", { status: "ready" });
|
|
643
|
+
}
|
|
644
|
+
keepalivePhase = "render";
|
|
645
|
+
if (job.upload) {
|
|
646
|
+
if (renderBackend) {
|
|
647
|
+
const videoJob = {
|
|
648
|
+
effie: job.effie,
|
|
649
|
+
scale: job.scale
|
|
650
|
+
};
|
|
651
|
+
await ctx2.transientStore.putJson(
|
|
652
|
+
storeKeys.videoJob(jobId),
|
|
653
|
+
videoJob,
|
|
654
|
+
ctx2.transientStore.ttlMs
|
|
655
|
+
);
|
|
656
|
+
const backendUrl = `${renderBackend.baseUrl}/render/${jobId}/video`;
|
|
657
|
+
const response = await ffsFetch(backendUrl, {
|
|
658
|
+
headers: renderBackend.apiKey ? { Authorization: `Bearer ${renderBackend.apiKey}` } : void 0
|
|
659
|
+
});
|
|
660
|
+
if (!response.ok) {
|
|
661
|
+
throw new Error(`Backend render failed: ${response.status}`);
|
|
662
|
+
}
|
|
663
|
+
const videoBuffer = Buffer.from(await response.arrayBuffer());
|
|
664
|
+
const timings = await uploadRenderedVideo(
|
|
665
|
+
videoBuffer,
|
|
666
|
+
job.effie,
|
|
667
|
+
job.upload,
|
|
668
|
+
sendEvent
|
|
669
|
+
);
|
|
670
|
+
sendEvent("render:complete", timings);
|
|
671
|
+
} else {
|
|
672
|
+
const timings = await renderAndUploadInternal(
|
|
673
|
+
job.effie,
|
|
674
|
+
job.scale,
|
|
675
|
+
job.upload,
|
|
676
|
+
sendEvent,
|
|
677
|
+
ctx2
|
|
678
|
+
);
|
|
679
|
+
sendEvent("render:complete", timings);
|
|
680
|
+
}
|
|
681
|
+
sendEvent("complete", { status: "done" });
|
|
682
|
+
} else {
|
|
683
|
+
const videoJob = {
|
|
684
|
+
effie: job.effie,
|
|
685
|
+
scale: job.scale
|
|
686
|
+
};
|
|
687
|
+
await ctx2.transientStore.putJson(
|
|
688
|
+
storeKeys.videoJob(jobId),
|
|
689
|
+
videoJob,
|
|
690
|
+
ctx2.transientStore.ttlMs
|
|
691
|
+
);
|
|
692
|
+
const videoUrl = `${ctx2.baseUrl}/render/${jobId}/video`;
|
|
693
|
+
sendEvent("ready", { videoUrl });
|
|
694
|
+
}
|
|
695
|
+
} catch (error) {
|
|
696
|
+
sendEvent("error", {
|
|
697
|
+
phase: keepalivePhase,
|
|
698
|
+
message: String(error)
|
|
699
|
+
});
|
|
700
|
+
} finally {
|
|
701
|
+
clearInterval(keepalive);
|
|
702
|
+
res.end();
|
|
703
|
+
}
|
|
704
|
+
} catch (error) {
|
|
705
|
+
console.error("Error in render progress streaming:", error);
|
|
706
|
+
if (!res.headersSent) {
|
|
707
|
+
res.status(500).json({ error: "Render progress streaming failed" });
|
|
708
|
+
} else {
|
|
709
|
+
res.end();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
async function streamRenderVideo(req, res, ctx2) {
|
|
714
|
+
try {
|
|
715
|
+
setupCORSHeaders(res);
|
|
716
|
+
const jobId = req.params.id;
|
|
717
|
+
const videoJobKey = storeKeys.videoJob(jobId);
|
|
718
|
+
const videoJob = await ctx2.transientStore.getJson(videoJobKey);
|
|
719
|
+
if (!videoJob) {
|
|
720
|
+
res.status(404).json({ error: "Video not found or expired" });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (ctx2.renderBackendResolver) {
|
|
724
|
+
const backend = ctx2.renderBackendResolver(videoJob.effie);
|
|
725
|
+
if (backend) {
|
|
726
|
+
const backendUrl = `${backend.baseUrl}/render/${jobId}/video`;
|
|
727
|
+
const response = await ffsFetch(backendUrl, {
|
|
728
|
+
headers: backend.apiKey ? { Authorization: `Bearer ${backend.apiKey}` } : void 0
|
|
729
|
+
});
|
|
730
|
+
if (!response.ok) {
|
|
731
|
+
res.status(response.status).json({ error: "Backend render failed" });
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
await proxyBinaryStream(response, res);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
ctx2.transientStore.delete(videoJobKey);
|
|
739
|
+
await streamRenderDirect(res, videoJob, ctx2);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
console.error("Error streaming video:", error);
|
|
742
|
+
if (!res.headersSent) {
|
|
743
|
+
res.status(500).json({ error: "Video streaming failed" });
|
|
744
|
+
} else {
|
|
745
|
+
res.end();
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
async function streamRenderDirect(res, job, ctx2) {
|
|
750
|
+
const { EffieRenderer } = await import("./render-E3U44GOC.js");
|
|
751
|
+
const renderer = new EffieRenderer(job.effie, {
|
|
752
|
+
transientStore: ctx2.transientStore,
|
|
753
|
+
httpProxy: ctx2.httpProxy
|
|
754
|
+
});
|
|
755
|
+
const videoStream = await renderer.render(job.scale);
|
|
756
|
+
res.on("close", () => {
|
|
757
|
+
videoStream.destroy();
|
|
758
|
+
renderer.close();
|
|
759
|
+
});
|
|
760
|
+
res.set("Content-Type", "video/mp4");
|
|
761
|
+
res.set("Cache-Control", "public, immutable, max-age=86400");
|
|
762
|
+
videoStream.pipe(res);
|
|
763
|
+
}
|
|
764
|
+
async function uploadRenderedVideo(videoBuffer, effie, upload, sendEvent) {
|
|
765
|
+
const timings = {};
|
|
766
|
+
if (upload.coverUrl) {
|
|
767
|
+
const fetchCoverStartTime = Date.now();
|
|
768
|
+
const coverFetchResponse = await ffsFetch(effie.cover);
|
|
769
|
+
if (!coverFetchResponse.ok) {
|
|
770
|
+
throw new Error(
|
|
771
|
+
`Failed to fetch cover image: ${coverFetchResponse.status} ${coverFetchResponse.statusText}`
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
const coverBuffer = Buffer.from(await coverFetchResponse.arrayBuffer());
|
|
775
|
+
timings.fetchCoverTime = Date.now() - fetchCoverStartTime;
|
|
776
|
+
const uploadCoverStartTime = Date.now();
|
|
777
|
+
const uploadCoverResponse = await ffsFetch(upload.coverUrl, {
|
|
778
|
+
method: "PUT",
|
|
779
|
+
body: coverBuffer,
|
|
780
|
+
headers: {
|
|
781
|
+
"Content-Type": "image/png",
|
|
782
|
+
"Content-Length": coverBuffer.length.toString()
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
if (!uploadCoverResponse.ok) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
`Failed to upload cover: ${uploadCoverResponse.status} ${uploadCoverResponse.statusText}`
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
timings.uploadCoverTime = Date.now() - uploadCoverStartTime;
|
|
791
|
+
}
|
|
792
|
+
sendEvent("keepalive", { status: "uploading" });
|
|
793
|
+
const uploadStartTime = Date.now();
|
|
794
|
+
const uploadResponse = await ffsFetch(upload.videoUrl, {
|
|
795
|
+
method: "PUT",
|
|
796
|
+
body: videoBuffer,
|
|
797
|
+
headers: {
|
|
798
|
+
"Content-Type": "video/mp4",
|
|
799
|
+
"Content-Length": videoBuffer.length.toString()
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
if (!uploadResponse.ok) {
|
|
803
|
+
throw new Error(
|
|
804
|
+
`Failed to upload rendered video: ${uploadResponse.status} ${uploadResponse.statusText}`
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
timings.uploadTime = Date.now() - uploadStartTime;
|
|
808
|
+
return timings;
|
|
809
|
+
}
|
|
810
|
+
async function renderAndUploadInternal(effie, scale, upload, sendEvent, ctx2) {
|
|
811
|
+
const renderStartTime = Date.now();
|
|
812
|
+
const { EffieRenderer } = await import("./render-E3U44GOC.js");
|
|
813
|
+
const renderer = new EffieRenderer(effie, {
|
|
814
|
+
transientStore: ctx2.transientStore,
|
|
815
|
+
httpProxy: ctx2.httpProxy
|
|
816
|
+
});
|
|
817
|
+
const videoStream = await renderer.render(scale);
|
|
818
|
+
const chunks = [];
|
|
819
|
+
for await (const chunk of videoStream) {
|
|
820
|
+
chunks.push(Buffer.from(chunk));
|
|
821
|
+
}
|
|
822
|
+
const videoBuffer = Buffer.concat(chunks);
|
|
823
|
+
const renderTime = Date.now() - renderStartTime;
|
|
824
|
+
const timings = await uploadRenderedVideo(
|
|
825
|
+
videoBuffer,
|
|
826
|
+
effie,
|
|
827
|
+
upload,
|
|
828
|
+
sendEvent
|
|
949
829
|
);
|
|
830
|
+
timings.renderTime = renderTime;
|
|
831
|
+
return timings;
|
|
950
832
|
}
|
|
951
833
|
|
|
952
834
|
// src/server.ts
|
|
953
|
-
var app =
|
|
835
|
+
var app = express4();
|
|
836
|
+
app.disable("x-powered-by");
|
|
954
837
|
app.use(bodyParser.json({ limit: "50mb" }));
|
|
955
838
|
var ctx = await createServerContext();
|
|
956
839
|
if (ctx.httpProxy) {
|
|
@@ -978,16 +861,15 @@ app.post("/render", (req, res) => {
|
|
|
978
861
|
if (!validateAuth(req, res)) return;
|
|
979
862
|
createRenderJob(req, res, ctx);
|
|
980
863
|
});
|
|
981
|
-
app.post("/warmup-and-render", (req, res) => {
|
|
982
|
-
if (!validateAuth(req, res)) return;
|
|
983
|
-
createWarmupAndRenderJob(req, res, ctx);
|
|
984
|
-
});
|
|
985
|
-
app.get("/warmup/:id", (req, res) => streamWarmupJob(req, res, ctx));
|
|
986
|
-
app.get("/render/:id", (req, res) => streamRenderJob(req, res, ctx));
|
|
987
864
|
app.get(
|
|
988
|
-
"/warmup
|
|
989
|
-
(req, res) =>
|
|
865
|
+
"/warmup/:id/progress",
|
|
866
|
+
(req, res) => streamWarmupProgress(req, res, ctx)
|
|
867
|
+
);
|
|
868
|
+
app.get(
|
|
869
|
+
"/render/:id/progress",
|
|
870
|
+
(req, res) => streamRenderProgress(req, res, ctx)
|
|
990
871
|
);
|
|
872
|
+
app.get("/render/:id/video", (req, res) => streamRenderVideo(req, res, ctx));
|
|
991
873
|
var port = process.env.FFS_PORT || process.env.PORT || 2e3;
|
|
992
874
|
var server = app.listen(port, () => {
|
|
993
875
|
console.log(`FFS server listening on port ${port}`);
|