@effing/ffs 0.5.0 → 0.6.0

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 CHANGED
@@ -74,12 +74,8 @@ The server uses an internal HTTP proxy for video/audio URLs to ensure reliable D
74
74
  | `FFS_TRANSIENT_STORE_SECRET_KEY` | S3 secret access key |
75
75
  | `FFS_TRANSIENT_STORE_LOCAL_DIR` | Local storage directory (when not using S3) |
76
76
  | `FFS_SOURCE_CACHE_TTL_MS` | TTL for cached sources in ms (default: 60 min) |
77
- | `FFS_JOB_METADATA_TTL_MS` | TTL for job metadata in ms (default: 8 hours) |
77
+ | `FFS_JOB_DATA_TTL_MS` | TTL for job data in ms (default: 8 hours) |
78
78
  | `FFS_WARMUP_CONCURRENCY` | Concurrent source fetches during warmup (default: 4) |
79
- | `FFS_WARMUP_BACKEND_BASE_URL` | Separate backend for warmup (see Backend Separation) |
80
- | `FFS_RENDER_BACKEND_BASE_URL` | Separate backend for render (see Backend Separation) |
81
- | `FFS_WARMUP_BACKEND_API_KEY` | API key for authenticating to the warmup backend |
82
- | `FFS_RENDER_BACKEND_API_KEY` | API key for authenticating to the render backend |
83
79
 
84
80
  When `FFS_TRANSIENT_STORE_BUCKET` is not set, FFS uses the local filesystem for storage (default: system temp directory). Local files are automatically cleaned up after the TTL expires.
85
81
 
@@ -343,27 +339,47 @@ events.addEventListener("render:complete", (e) => {
343
339
 
344
340
  ## Backend Separation
345
341
 
346
- FFS supports running warmup and render on separate backends, useful for scaling or resource isolation. When backend URLs are configured, the cache storage must be shared between services (e.g., using S3).
342
+ FFS supports running warmup and render on separate backends via resolver callbacks.
343
+ When backends are configured, the transient storage must be shared between services (e.g., using S3).
347
344
 
348
- **Environment variables:**
345
+ ### Setup
349
346
 
350
- - `FFS_WARMUP_BACKEND_BASE_URL` Base URL for warmup backend (e.g., `https://warmup.your.app`)
351
- - `FFS_RENDER_BACKEND_BASE_URL` — Base URL for render backend (e.g., `https://render.your.app`)
347
+ Pass resolvers to `createServerContext`:
352
348
 
353
- **Behavior when set:**
349
+ ```typescript
350
+ import { createServerContext } from "@effing/ffs/handlers";
351
+ import type {
352
+ RenderBackendResolver,
353
+ WarmupBackendResolver,
354
+ } from "@effing/ffs/handlers";
355
+
356
+ const renderBackendResolver: RenderBackendResolver = (effie, metadata) => ({
357
+ baseUrl: "https://render.your.app",
358
+ apiKey: "secret",
359
+ });
360
+
361
+ const warmupBackendResolver: WarmupBackendResolver = (sources, metadata) => ({
362
+ baseUrl: "https://warmup.your.app",
363
+ apiKey: "secret",
364
+ });
365
+
366
+ const ctx = await createServerContext({
367
+ renderBackendResolver,
368
+ warmupBackendResolver,
369
+ });
370
+ ```
371
+
372
+ The render resolver receives the effie data; the warmup resolver receives the source list.
373
+ Both receive optional metadata (passed via handler options). Return `null` to handle locally.
354
374
 
355
- | Endpoint | Effect |
356
- | ---------------------------- | ---------------------------------------------------- |
357
- | `POST /warmup` | Returns URL pointing to local server (orchestrator) |
358
- | `GET /warmup/:id` | Proxies SSE from warmup backend |
359
- | `POST /render` | Returns URL pointing to local server (orchestrator) |
360
- | `GET /render/:id` | Proxies from render backend (SSE or video stream) |
361
- | `POST /warmup-and-render` | Returns URL pointing to local server (orchestrator) |
362
- | `GET /warmup-and-render/:id` | Proxies SSE from warmup backend, then render backend |
375
+ ### Job metadata
363
376
 
364
- All GET endpoints proxy requests to the configured backend, keeping backend URLs hidden from clients. This ensures compatibility with EventSource (which doesn't follow redirects) and simplifies CORS configuration since only the orchestrator needs to be publicly accessible.
377
+ Pass server-side metadata to be stored with the job and forwarded to the resolver:
365
378
 
366
- If the backends have `FFS_API_KEY` set, configure `FFS_WARMUP_BACKEND_API_KEY` and/or `FFS_RENDER_BACKEND_API_KEY` on the orchestrator so it can authenticate when proxying requests. The orchestrator sends these as `Authorization: Bearer <key>` headers.
379
+ ```typescript
380
+ createRenderJob(req, res, ctx, { metadata: { tenantId: "abc" } });
381
+ createWarmupJob(req, res, ctx, { metadata: { tenantId: "abc" } });
382
+ ```
367
383
 
368
384
  ## Examples
369
385
 
@@ -35,13 +35,13 @@ import path from "path";
35
35
  import os from "os";
36
36
  import crypto from "crypto";
37
37
  var DEFAULT_SOURCE_TTL_MS = 60 * 60 * 1e3;
38
- var DEFAULT_JOB_METADATA_TTL_MS = 8 * 60 * 60 * 1e3;
38
+ var DEFAULT_JOB_DATA_TTL_MS = 8 * 60 * 60 * 1e3;
39
39
  var S3TransientStore = class {
40
40
  client;
41
41
  bucket;
42
42
  prefix;
43
43
  sourceTtlMs;
44
- jobMetadataTtlMs;
44
+ jobDataTtlMs;
45
45
  constructor(options) {
46
46
  this.client = new S3Client({
47
47
  endpoint: options.endpoint,
@@ -55,7 +55,7 @@ var S3TransientStore = class {
55
55
  this.bucket = options.bucket;
56
56
  this.prefix = options.prefix ?? "";
57
57
  this.sourceTtlMs = options.sourceTtlMs ?? DEFAULT_SOURCE_TTL_MS;
58
- this.jobMetadataTtlMs = options.jobMetadataTtlMs ?? DEFAULT_JOB_METADATA_TTL_MS;
58
+ this.jobDataTtlMs = options.jobDataTtlMs ?? DEFAULT_JOB_DATA_TTL_MS;
59
59
  }
60
60
  getExpires(ttlMs) {
61
61
  return new Date(Date.now() + ttlMs);
@@ -138,7 +138,7 @@ var S3TransientStore = class {
138
138
  Key: this.getFullKey(key),
139
139
  Body: JSON.stringify(data),
140
140
  ContentType: "application/json",
141
- Expires: this.getExpires(ttlMs ?? this.jobMetadataTtlMs)
141
+ Expires: this.getExpires(ttlMs ?? this.jobDataTtlMs)
142
142
  })
143
143
  );
144
144
  }
@@ -169,14 +169,14 @@ var LocalTransientStore = class {
169
169
  initialized = false;
170
170
  cleanupInterval;
171
171
  sourceTtlMs;
172
- jobMetadataTtlMs;
172
+ jobDataTtlMs;
173
173
  /** For cleanup, use the longer of the two TTLs */
174
174
  maxTtlMs;
175
175
  constructor(options) {
176
176
  this.baseDir = options?.baseDir ?? path.join(os.tmpdir(), "ffs-transient");
177
177
  this.sourceTtlMs = options?.sourceTtlMs ?? DEFAULT_SOURCE_TTL_MS;
178
- this.jobMetadataTtlMs = options?.jobMetadataTtlMs ?? DEFAULT_JOB_METADATA_TTL_MS;
179
- this.maxTtlMs = Math.max(this.sourceTtlMs, this.jobMetadataTtlMs);
178
+ this.jobDataTtlMs = options?.jobDataTtlMs ?? DEFAULT_JOB_DATA_TTL_MS;
179
+ this.maxTtlMs = Math.max(this.sourceTtlMs, this.jobDataTtlMs);
180
180
  this.cleanupInterval = setInterval(() => {
181
181
  this.cleanupExpired().catch(console.error);
182
182
  }, 3e5);
@@ -292,7 +292,7 @@ var LocalTransientStore = class {
292
292
  };
293
293
  function createTransientStore() {
294
294
  const sourceTtlMs = process.env.FFS_SOURCE_CACHE_TTL_MS ? parseInt(process.env.FFS_SOURCE_CACHE_TTL_MS, 10) : DEFAULT_SOURCE_TTL_MS;
295
- const jobMetadataTtlMs = process.env.FFS_JOB_METADATA_TTL_MS ? parseInt(process.env.FFS_JOB_METADATA_TTL_MS, 10) : DEFAULT_JOB_METADATA_TTL_MS;
295
+ const jobDataTtlMs = process.env.FFS_JOB_DATA_TTL_MS ? parseInt(process.env.FFS_JOB_DATA_TTL_MS, 10) : DEFAULT_JOB_DATA_TTL_MS;
296
296
  if (process.env.FFS_TRANSIENT_STORE_BUCKET) {
297
297
  return new S3TransientStore({
298
298
  endpoint: process.env.FFS_TRANSIENT_STORE_ENDPOINT,
@@ -302,13 +302,13 @@ function createTransientStore() {
302
302
  accessKeyId: process.env.FFS_TRANSIENT_STORE_ACCESS_KEY,
303
303
  secretAccessKey: process.env.FFS_TRANSIENT_STORE_SECRET_KEY,
304
304
  sourceTtlMs,
305
- jobMetadataTtlMs
305
+ jobDataTtlMs
306
306
  });
307
307
  }
308
308
  return new LocalTransientStore({
309
309
  baseDir: process.env.FFS_TRANSIENT_STORE_LOCAL_DIR,
310
310
  sourceTtlMs,
311
- jobMetadataTtlMs
311
+ jobDataTtlMs
312
312
  });
313
313
  }
314
314
  function hashUrl(url) {
@@ -338,4 +338,4 @@ export {
338
338
  createTransientStore,
339
339
  storeKeys
340
340
  };
341
- //# sourceMappingURL=chunk-5SGOYTM2.js.map
341
+ //# sourceMappingURL=chunk-4N2GLGC5.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/fetch.ts","../src/storage.ts"],"sourcesContent":["import { fetch, Agent, type Response, type BodyInit } from \"undici\";\n\n/**\n * Options for ffsFetch function\n */\nexport type FfsFetchOptions = {\n /** HTTP method */\n method?: \"GET\" | \"POST\" | \"PUT\" | \"DELETE\" | \"PATCH\" | \"HEAD\" | \"OPTIONS\";\n /** Request body */\n body?: BodyInit;\n /** Headers to send (merged with default User-Agent) */\n headers?: Record<string, string>;\n /** Timeout for receiving response headers in ms. @default 300000 (5 min) */\n headersTimeout?: number;\n /** Timeout between body data chunks in ms. 0 = no timeout. @default 300000 (5 min) */\n bodyTimeout?: number;\n};\n\n/**\n * Fetch with default User-Agent and configurable timeouts.\n *\n * @example\n * // Simple GET\n * const response = await ffsFetch(\"https://example.com/data.json\");\n *\n * @example\n * // Large file with infinite body timeout\n * const response = await ffsFetch(\"https://example.com/video.mp4\", {\n * bodyTimeout: 0,\n * });\n *\n * @example\n * // PUT upload\n * const response = await ffsFetch(\"https://s3.example.com/video.mp4\", {\n * method: \"PUT\",\n * body: videoBuffer,\n * bodyTimeout: 0,\n * headers: { \"Content-Type\": \"video/mp4\" },\n * });\n */\nexport async function ffsFetch(\n url: string,\n options?: FfsFetchOptions,\n): Promise<Response> {\n const {\n method,\n body,\n headers,\n headersTimeout = 300000, // 5 minutes\n bodyTimeout = 300000, // 5 minutes\n } = options ?? {};\n\n const agent = new Agent({ headersTimeout, bodyTimeout });\n\n return fetch(url, {\n method,\n body,\n headers: { \"User-Agent\": \"FFS (+https://effing.dev/ffs)\", ...headers },\n dispatcher: agent,\n });\n}\n","import {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n HeadObjectCommand,\n DeleteObjectCommand,\n} from \"@aws-sdk/client-s3\";\nimport { Upload } from \"@aws-sdk/lib-storage\";\nimport fs from \"fs/promises\";\nimport { createReadStream, createWriteStream, existsSync } from \"fs\";\nimport { pipeline } from \"stream/promises\";\nimport path from \"path\";\nimport os from \"os\";\nimport crypto from \"crypto\";\nimport type { Readable } from \"stream\";\n\n/** Default TTL for sources: 60 minutes */\nconst DEFAULT_SOURCE_TTL_MS = 60 * 60 * 1000;\n/** Default TTL for job data: 8 hours */\nconst DEFAULT_JOB_DATA_TTL_MS = 8 * 60 * 60 * 1000;\n\n/**\n * Transient store interface for caching sources and storing ephemeral job data.\n */\nexport interface TransientStore {\n /** TTL for cached sources in milliseconds */\n readonly sourceTtlMs: number;\n /** TTL for job data in milliseconds */\n readonly jobDataTtlMs: number;\n /** Store a stream with the given key and optional TTL override */\n put(key: string, stream: Readable, ttlMs?: number): Promise<void>;\n /** Get a stream for the given key, or null if not found */\n getStream(key: string): Promise<Readable | null>;\n /** Check if a key exists */\n exists(key: string): Promise<boolean>;\n /** Check if multiple keys exist (batch operation) */\n existsMany(keys: string[]): Promise<Map<string, boolean>>;\n /** Delete a key */\n delete(key: string): Promise<void>;\n /** Store JSON data with optional TTL override */\n putJson(key: string, data: object, ttlMs?: number): Promise<void>;\n /** Get JSON data, or null if not found */\n getJson<T>(key: string): Promise<T | null>;\n /** Close and cleanup resources */\n close(): void;\n}\n\n/**\n * S3-compatible transient store implementation\n */\nexport class S3TransientStore implements TransientStore {\n private client: S3Client;\n private bucket: string;\n private prefix: string;\n public readonly sourceTtlMs: number;\n public readonly jobDataTtlMs: number;\n\n constructor(options: {\n endpoint?: string;\n region?: string;\n bucket: string;\n prefix?: string;\n accessKeyId?: string;\n secretAccessKey?: string;\n sourceTtlMs?: number;\n jobDataTtlMs?: number;\n }) {\n this.client = new S3Client({\n endpoint: options.endpoint,\n region: options.region ?? \"auto\",\n credentials: options.accessKeyId\n ? {\n accessKeyId: options.accessKeyId,\n secretAccessKey: options.secretAccessKey!,\n }\n : undefined,\n forcePathStyle: !!options.endpoint,\n });\n this.bucket = options.bucket;\n this.prefix = options.prefix ?? \"\";\n this.sourceTtlMs = options.sourceTtlMs ?? DEFAULT_SOURCE_TTL_MS;\n this.jobDataTtlMs = options.jobDataTtlMs ?? DEFAULT_JOB_DATA_TTL_MS;\n }\n\n private getExpires(ttlMs: number): Date {\n return new Date(Date.now() + ttlMs);\n }\n\n private getFullKey(key: string): string {\n return `${this.prefix}${key}`;\n }\n\n async put(key: string, stream: Readable, ttlMs?: number): Promise<void> {\n const upload = new Upload({\n client: this.client,\n params: {\n Bucket: this.bucket,\n Key: this.getFullKey(key),\n Body: stream,\n Expires: this.getExpires(ttlMs ?? this.sourceTtlMs),\n },\n });\n await upload.done();\n }\n\n async getStream(key: string): Promise<Readable | null> {\n try {\n const response = await this.client.send(\n new GetObjectCommand({\n Bucket: this.bucket,\n Key: this.getFullKey(key),\n }),\n );\n return response.Body as Readable;\n } catch (err: unknown) {\n const error = err as {\n name?: string;\n $metadata?: { httpStatusCode?: number };\n };\n if (\n error.name === \"NoSuchKey\" ||\n error.$metadata?.httpStatusCode === 404\n ) {\n return null;\n }\n throw err;\n }\n }\n\n async exists(key: string): Promise<boolean> {\n try {\n await this.client.send(\n new HeadObjectCommand({\n Bucket: this.bucket,\n Key: this.getFullKey(key),\n }),\n );\n return true;\n } catch (err: unknown) {\n const error = err as {\n name?: string;\n $metadata?: { httpStatusCode?: number };\n };\n if (\n error.name === \"NotFound\" ||\n error.$metadata?.httpStatusCode === 404\n ) {\n return false;\n }\n throw err;\n }\n }\n\n async existsMany(keys: string[]): Promise<Map<string, boolean>> {\n const results = await Promise.all(\n keys.map(async (key) => [key, await this.exists(key)] as const),\n );\n return new Map(results);\n }\n\n async delete(key: string): Promise<void> {\n try {\n await this.client.send(\n new DeleteObjectCommand({\n Bucket: this.bucket,\n Key: this.getFullKey(key),\n }),\n );\n } catch (err: unknown) {\n const error = err as {\n name?: string;\n $metadata?: { httpStatusCode?: number };\n };\n if (\n error.name === \"NoSuchKey\" ||\n error.$metadata?.httpStatusCode === 404\n ) {\n return;\n }\n throw err;\n }\n }\n\n async putJson(key: string, data: object, ttlMs?: number): Promise<void> {\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: this.getFullKey(key),\n Body: JSON.stringify(data),\n ContentType: \"application/json\",\n Expires: this.getExpires(ttlMs ?? this.jobDataTtlMs),\n }),\n );\n }\n\n async getJson<T>(key: string): Promise<T | null> {\n try {\n const response = await this.client.send(\n new GetObjectCommand({\n Bucket: this.bucket,\n Key: this.getFullKey(key),\n }),\n );\n const body = await response.Body?.transformToString();\n if (!body) return null;\n return JSON.parse(body) as T;\n } catch (err: unknown) {\n const error = err as {\n name?: string;\n $metadata?: { httpStatusCode?: number };\n };\n if (\n error.name === \"NoSuchKey\" ||\n error.$metadata?.httpStatusCode === 404\n ) {\n return null;\n }\n throw err;\n }\n }\n\n close(): void {\n // nothing to do here\n }\n}\n\n/**\n * Local filesystem transient store implementation\n */\nexport class LocalTransientStore implements TransientStore {\n private baseDir: string;\n private initialized = false;\n private cleanupInterval?: ReturnType<typeof setInterval>;\n public readonly sourceTtlMs: number;\n public readonly jobDataTtlMs: number;\n /** For cleanup, use the longer of the two TTLs */\n private maxTtlMs: number;\n\n constructor(options?: {\n baseDir?: string;\n sourceTtlMs?: number;\n jobDataTtlMs?: number;\n }) {\n this.baseDir = options?.baseDir ?? path.join(os.tmpdir(), \"ffs-transient\");\n this.sourceTtlMs = options?.sourceTtlMs ?? DEFAULT_SOURCE_TTL_MS;\n this.jobDataTtlMs = options?.jobDataTtlMs ?? DEFAULT_JOB_DATA_TTL_MS;\n this.maxTtlMs = Math.max(this.sourceTtlMs, this.jobDataTtlMs);\n\n // Cleanup expired files every 5 minutes\n this.cleanupInterval = setInterval(() => {\n this.cleanupExpired().catch(console.error);\n }, 300_000);\n }\n\n /**\n * Remove files older than max TTL\n */\n public async cleanupExpired(): Promise<void> {\n if (!this.initialized) return;\n\n const now = Date.now();\n await this.cleanupDir(this.baseDir, now);\n }\n\n private async cleanupDir(dir: string, now: number): Promise<void> {\n let entries;\n try {\n entries = await fs.readdir(dir, { withFileTypes: true });\n } catch {\n return; // Directory doesn't exist or can't be read\n }\n\n for (const entry of entries) {\n const fullPath = path.join(dir, entry.name);\n\n if (entry.isDirectory()) {\n await this.cleanupDir(fullPath, now);\n // Remove empty directories\n try {\n await fs.rmdir(fullPath);\n } catch {\n // Directory not empty or other error, ignore\n }\n } else if (entry.isFile()) {\n try {\n const stat = await fs.stat(fullPath);\n if (now - stat.mtimeMs > this.maxTtlMs) {\n await fs.rm(fullPath, { force: true });\n }\n } catch {\n // File may have been deleted, ignore\n }\n }\n }\n }\n\n private async ensureDir(filePath: string): Promise<void> {\n await fs.mkdir(path.dirname(filePath), { recursive: true });\n this.initialized = true;\n }\n\n private filePath(key: string): string {\n return path.join(this.baseDir, key);\n }\n\n private tmpPathFor(finalPath: string): string {\n const rand = crypto.randomBytes(8).toString(\"hex\");\n // Keep tmp file in the same directory so rename stays atomic on POSIX filesystems.\n return `${finalPath}.tmp-${process.pid}-${rand}`;\n }\n\n async put(key: string, stream: Readable, _ttlMs?: number): Promise<void> {\n // Note: TTL is not used for local storage; cleanup uses file mtime\n const fp = this.filePath(key);\n await this.ensureDir(fp);\n\n // Write to temp file, then rename for atomicity (no partial reads).\n const tmpPath = this.tmpPathFor(fp);\n try {\n const writeStream = createWriteStream(tmpPath);\n await pipeline(stream, writeStream);\n await fs.rename(tmpPath, fp);\n } catch (err) {\n await fs.rm(tmpPath, { force: true }).catch(() => {});\n throw err;\n }\n }\n\n async getStream(key: string): Promise<Readable | null> {\n const fp = this.filePath(key);\n if (!existsSync(fp)) return null;\n return createReadStream(fp);\n }\n\n async exists(key: string): Promise<boolean> {\n try {\n await fs.access(this.filePath(key));\n return true;\n } catch {\n return false;\n }\n }\n\n async existsMany(keys: string[]): Promise<Map<string, boolean>> {\n const results = await Promise.all(\n keys.map(async (key) => [key, await this.exists(key)] as const),\n );\n return new Map(results);\n }\n\n async delete(key: string): Promise<void> {\n await fs.rm(this.filePath(key), { force: true });\n }\n\n async putJson(key: string, data: object, _ttlMs?: number): Promise<void> {\n // Note: TTL is not used for local storage; cleanup uses file mtime\n const fp = this.filePath(key);\n await this.ensureDir(fp);\n\n // Write to temp file, then rename for atomicity (no partial reads).\n const tmpPath = this.tmpPathFor(fp);\n try {\n await fs.writeFile(tmpPath, JSON.stringify(data));\n await fs.rename(tmpPath, fp);\n } catch (err) {\n await fs.rm(tmpPath, { force: true }).catch(() => {});\n throw err;\n }\n }\n\n async getJson<T>(key: string): Promise<T | null> {\n try {\n const content = await fs.readFile(this.filePath(key), \"utf-8\");\n return JSON.parse(content) as T;\n } catch {\n return null;\n }\n }\n\n close(): void {\n // Stop the cleanup interval\n if (this.cleanupInterval) {\n clearInterval(this.cleanupInterval);\n this.cleanupInterval = undefined;\n }\n }\n}\n\n/**\n * Create a transient store instance based on environment variables.\n * Uses S3 if FFS_TRANSIENT_STORE_BUCKET is set, otherwise uses local filesystem.\n */\nexport function createTransientStore(): TransientStore {\n // Parse TTLs from env\n const sourceTtlMs = process.env.FFS_SOURCE_CACHE_TTL_MS\n ? parseInt(process.env.FFS_SOURCE_CACHE_TTL_MS, 10)\n : DEFAULT_SOURCE_TTL_MS;\n const jobDataTtlMs = process.env.FFS_JOB_DATA_TTL_MS\n ? parseInt(process.env.FFS_JOB_DATA_TTL_MS, 10)\n : DEFAULT_JOB_DATA_TTL_MS;\n\n if (process.env.FFS_TRANSIENT_STORE_BUCKET) {\n return new S3TransientStore({\n endpoint: process.env.FFS_TRANSIENT_STORE_ENDPOINT,\n region: process.env.FFS_TRANSIENT_STORE_REGION ?? \"auto\",\n bucket: process.env.FFS_TRANSIENT_STORE_BUCKET,\n prefix: process.env.FFS_TRANSIENT_STORE_PREFIX,\n accessKeyId: process.env.FFS_TRANSIENT_STORE_ACCESS_KEY,\n secretAccessKey: process.env.FFS_TRANSIENT_STORE_SECRET_KEY,\n sourceTtlMs,\n jobDataTtlMs,\n });\n }\n\n return new LocalTransientStore({\n baseDir: process.env.FFS_TRANSIENT_STORE_LOCAL_DIR,\n sourceTtlMs,\n jobDataTtlMs,\n });\n}\n\nexport function hashUrl(url: string): string {\n return crypto.createHash(\"sha256\").update(url).digest(\"hex\").slice(0, 16);\n}\n\nexport type SourceStoreKey = `sources/${string}`;\nexport type WarmupJobStoreKey = `jobs/warmup/${string}.json`;\nexport type RenderJobStoreKey = `jobs/render/${string}.json`;\nexport type WarmupAndRenderJobStoreKey =\n `jobs/warmup-and-render/${string}.json`;\n\n/**\n * Build the store key for a source URL (hashing is handled internally).\n */\nexport function sourceStoreKey(url: string): SourceStoreKey {\n return `sources/${hashUrl(url)}`;\n}\n\nexport function warmupJobStoreKey(jobId: string): WarmupJobStoreKey {\n return `jobs/warmup/${jobId}.json`;\n}\n\nexport function renderJobStoreKey(jobId: string): RenderJobStoreKey {\n return `jobs/render/${jobId}.json`;\n}\n\nexport function warmupAndRenderJobStoreKey(\n jobId: string,\n): WarmupAndRenderJobStoreKey {\n return `jobs/warmup-and-render/${jobId}.json`;\n}\n\n/**\n * Centralized store key builders for known namespaces.\n * Prefer using these helpers over manual string interpolation.\n */\nexport const storeKeys = {\n source: sourceStoreKey,\n warmupJob: warmupJobStoreKey,\n renderJob: renderJobStoreKey,\n warmupAndRenderJob: warmupAndRenderJobStoreKey,\n} as const;\n"],"mappings":"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","names":[]}
@@ -2,7 +2,7 @@ import {
2
2
  createTransientStore,
3
3
  ffsFetch,
4
4
  storeKeys
5
- } from "./chunk-5SGOYTM2.js";
5
+ } from "./chunk-4N2GLGC5.js";
6
6
 
7
7
  // src/handlers/shared.ts
8
8
  import "express";
@@ -131,11 +131,11 @@ var HttpProxy = class {
131
131
 
132
132
  // src/handlers/shared.ts
133
133
  import { effieDataSchema } from "@effing/effie";
134
- async function createServerContext() {
134
+ async function createServerContext(options) {
135
135
  const port = process.env.FFS_PORT || process.env.PORT || 2e3;
136
- const renderBackendBaseUrl = process.env.FFS_RENDER_BACKEND_BASE_URL;
136
+ const enableHttpProxy = options?.httpProxy ?? !options?.renderBackendResolver;
137
137
  let httpProxy;
138
- if (!renderBackendBaseUrl) {
138
+ if (enableHttpProxy) {
139
139
  httpProxy = new HttpProxy();
140
140
  await httpProxy.start();
141
141
  }
@@ -145,10 +145,8 @@ async function createServerContext() {
145
145
  baseUrl: process.env.FFS_BASE_URL || `http://localhost:${port}`,
146
146
  skipValidation: !!process.env.FFS_SKIP_VALIDATION && process.env.FFS_SKIP_VALIDATION !== "false",
147
147
  warmupConcurrency: parseInt(process.env.FFS_WARMUP_CONCURRENCY || "4", 10),
148
- warmupBackendBaseUrl: process.env.FFS_WARMUP_BACKEND_BASE_URL,
149
- renderBackendBaseUrl: process.env.FFS_RENDER_BACKEND_BASE_URL,
150
- warmupBackendApiKey: process.env.FFS_WARMUP_BACKEND_API_KEY,
151
- renderBackendApiKey: process.env.FFS_RENDER_BACKEND_API_KEY
148
+ warmupBackendResolver: options?.warmupBackendResolver,
149
+ renderBackendResolver: options?.renderBackendResolver
152
150
  };
153
151
  }
154
152
  function parseEffieData(body, skipValidation) {
@@ -211,16 +209,16 @@ import { extractEffieSourcesWithTypes, effieDataSchema as effieDataSchema3 } fro
211
209
  import "express";
212
210
  import { randomUUID } from "crypto";
213
211
  import { effieDataSchema as effieDataSchema2 } from "@effing/effie";
214
- async function createRenderJob(req, res, ctx) {
212
+ async function createRenderJob(req, res, ctx, options) {
215
213
  try {
216
214
  const isWrapped = "effie" in req.body;
217
215
  let rawEffieData;
218
216
  let scale;
219
217
  let upload;
220
218
  if (isWrapped) {
221
- const options = req.body;
222
- if (typeof options.effie === "string") {
223
- const response = await ffsFetch(options.effie);
219
+ const options2 = req.body;
220
+ if (typeof options2.effie === "string") {
221
+ const response = await ffsFetch(options2.effie);
224
222
  if (!response.ok) {
225
223
  throw new Error(
226
224
  `Failed to fetch Effie data: ${response.status} ${response.statusText}`
@@ -228,10 +226,10 @@ async function createRenderJob(req, res, ctx) {
228
226
  }
229
227
  rawEffieData = await response.json();
230
228
  } else {
231
- rawEffieData = options.effie;
229
+ rawEffieData = options2.effie;
232
230
  }
233
- scale = options.scale ?? 1;
234
- upload = options.upload;
231
+ scale = options2.scale ?? 1;
232
+ upload = options2.upload;
235
233
  } else {
236
234
  rawEffieData = req.body;
237
235
  scale = parseFloat(req.query.scale?.toString() || "1");
@@ -263,12 +261,13 @@ async function createRenderJob(req, res, ctx) {
263
261
  effie,
264
262
  scale,
265
263
  upload,
266
- createdAt: Date.now()
264
+ createdAt: Date.now(),
265
+ metadata: options?.metadata
267
266
  };
268
267
  await ctx.transientStore.putJson(
269
268
  storeKeys.renderJob(jobId),
270
269
  job,
271
- ctx.transientStore.jobMetadataTtlMs
270
+ ctx.transientStore.jobDataTtlMs
272
271
  );
273
272
  res.json({
274
273
  id: jobId,
@@ -283,17 +282,20 @@ async function streamRenderJob(req, res, ctx) {
283
282
  try {
284
283
  setupCORSHeaders(res);
285
284
  const jobId = req.params.id;
286
- if (ctx.renderBackendBaseUrl) {
287
- await proxyRenderFromBackend(res, jobId, ctx);
288
- return;
289
- }
290
- const jobCacheKey = storeKeys.renderJob(jobId);
291
- const job = await ctx.transientStore.getJson(jobCacheKey);
292
- ctx.transientStore.delete(jobCacheKey);
285
+ const jobStoreKey = storeKeys.renderJob(jobId);
286
+ const job = await ctx.transientStore.getJson(jobStoreKey);
293
287
  if (!job) {
294
288
  res.status(404).json({ error: "Job not found or expired" });
295
289
  return;
296
290
  }
291
+ if (ctx.renderBackendResolver) {
292
+ const backend = ctx.renderBackendResolver(job.effie, job.metadata);
293
+ if (backend) {
294
+ await proxyRenderFromBackend(res, jobId, backend);
295
+ return;
296
+ }
297
+ }
298
+ ctx.transientStore.delete(jobStoreKey);
297
299
  if (job.upload) {
298
300
  await streamRenderWithUpload(res, job, ctx);
299
301
  } else {
@@ -309,7 +311,7 @@ async function streamRenderJob(req, res, ctx) {
309
311
  }
310
312
  }
311
313
  async function streamRenderDirect(res, job, ctx) {
312
- const { EffieRenderer } = await import("./render-NEDCS65O.js");
314
+ const { EffieRenderer } = await import("./render-IKGZZOBP.js");
313
315
  const renderer = new EffieRenderer(job.effie, {
314
316
  transientStore: ctx.transientStore,
315
317
  httpProxy: ctx.httpProxy
@@ -374,7 +376,7 @@ async function renderAndUploadInternal(effie, scale, upload, sendEvent, ctx) {
374
376
  timings.uploadCoverTime = Date.now() - uploadCoverStartTime;
375
377
  }
376
378
  const renderStartTime = Date.now();
377
- const { EffieRenderer } = await import("./render-NEDCS65O.js");
379
+ const { EffieRenderer } = await import("./render-IKGZZOBP.js");
378
380
  const renderer = new EffieRenderer(effie, {
379
381
  transientStore: ctx.transientStore,
380
382
  httpProxy: ctx.httpProxy
@@ -404,10 +406,10 @@ async function renderAndUploadInternal(effie, scale, upload, sendEvent, ctx) {
404
406
  timings.uploadTime = Date.now() - uploadStartTime;
405
407
  return timings;
406
408
  }
407
- async function proxyRenderFromBackend(res, jobId, ctx) {
408
- const backendUrl = `${ctx.renderBackendBaseUrl}/render/${jobId}`;
409
+ async function proxyRenderFromBackend(res, jobId, backend) {
410
+ const backendUrl = `${backend.baseUrl}/render/${jobId}`;
409
411
  const response = await ffsFetch(backendUrl, {
410
- headers: ctx.renderBackendApiKey ? { Authorization: `Bearer ${ctx.renderBackendApiKey}` } : void 0
412
+ headers: backend.apiKey ? { Authorization: `Bearer ${backend.apiKey}` } : void 0
411
413
  });
412
414
  if (!response.ok) {
413
415
  res.status(response.status).json({ error: "Backend render failed" });
@@ -464,12 +466,12 @@ async function proxyRenderFromBackend(res, jobId, ctx) {
464
466
  }
465
467
 
466
468
  // src/handlers/orchestrating.ts
467
- async function createWarmupAndRenderJob(req, res, ctx) {
469
+ async function createWarmupAndRenderJob(req, res, ctx, options) {
468
470
  try {
469
- const options = req.body;
471
+ const body = req.body;
470
472
  let rawEffieData;
471
- if (typeof options.effie === "string") {
472
- const response = await ffsFetch(options.effie);
473
+ if (typeof body.effie === "string") {
474
+ const response = await ffsFetch(body.effie);
473
475
  if (!response.ok) {
474
476
  throw new Error(
475
477
  `Failed to fetch Effie data: ${response.status} ${response.statusText}`
@@ -477,7 +479,7 @@ async function createWarmupAndRenderJob(req, res, ctx) {
477
479
  }
478
480
  rawEffieData = await response.json();
479
481
  } else {
480
- rawEffieData = options.effie;
482
+ rawEffieData = body.effie;
481
483
  }
482
484
  let effie;
483
485
  if (!ctx.skipValidation) {
@@ -502,8 +504,8 @@ async function createWarmupAndRenderJob(req, res, ctx) {
502
504
  effie = data;
503
505
  }
504
506
  const sources = extractEffieSourcesWithTypes(effie);
505
- const scale = options.scale ?? 1;
506
- const upload = options.upload;
507
+ const scale = body.scale ?? 1;
508
+ const upload = body.upload;
507
509
  const jobId = randomUUID2();
508
510
  const warmupJobId = randomUUID2();
509
511
  const renderJobId = randomUUID2();
@@ -514,17 +516,18 @@ async function createWarmupAndRenderJob(req, res, ctx) {
514
516
  upload,
515
517
  warmupJobId,
516
518
  renderJobId,
517
- createdAt: Date.now()
519
+ createdAt: Date.now(),
520
+ metadata: options?.metadata
518
521
  };
519
522
  await ctx.transientStore.putJson(
520
523
  storeKeys.warmupAndRenderJob(jobId),
521
524
  job,
522
- ctx.transientStore.jobMetadataTtlMs
525
+ ctx.transientStore.jobDataTtlMs
523
526
  );
524
527
  await ctx.transientStore.putJson(
525
528
  storeKeys.warmupJob(warmupJobId),
526
- { sources },
527
- ctx.transientStore.jobMetadataTtlMs
529
+ { sources, metadata: options?.metadata },
530
+ ctx.transientStore.jobDataTtlMs
528
531
  );
529
532
  await ctx.transientStore.putJson(
530
533
  storeKeys.renderJob(renderJobId),
@@ -532,9 +535,10 @@ async function createWarmupAndRenderJob(req, res, ctx) {
532
535
  effie,
533
536
  scale,
534
537
  upload,
535
- createdAt: Date.now()
538
+ createdAt: Date.now(),
539
+ metadata: options?.metadata
536
540
  },
537
- ctx.transientStore.jobMetadataTtlMs
541
+ ctx.transientStore.jobDataTtlMs
538
542
  );
539
543
  res.json({
540
544
  id: jobId,
@@ -549,13 +553,15 @@ async function streamWarmupAndRenderJob(req, res, ctx) {
549
553
  try {
550
554
  setupCORSHeaders(res);
551
555
  const jobId = req.params.id;
552
- const jobCacheKey = storeKeys.warmupAndRenderJob(jobId);
553
- const job = await ctx.transientStore.getJson(jobCacheKey);
554
- ctx.transientStore.delete(jobCacheKey);
556
+ const jobStoreKey = storeKeys.warmupAndRenderJob(jobId);
557
+ const job = await ctx.transientStore.getJson(jobStoreKey);
558
+ ctx.transientStore.delete(jobStoreKey);
555
559
  if (!job) {
556
560
  res.status(404).json({ error: "Job not found" });
557
561
  return;
558
562
  }
563
+ const warmupBackend = ctx.warmupBackendResolver ? ctx.warmupBackendResolver(job.sources, job.metadata) : null;
564
+ const renderBackend = ctx.renderBackendResolver ? ctx.renderBackendResolver(job.effie, job.metadata) : null;
559
565
  setupSSEResponse(res);
560
566
  const sendEvent = createSSEEventSender(res);
561
567
  let keepalivePhase = "warmup";
@@ -563,13 +569,13 @@ async function streamWarmupAndRenderJob(req, res, ctx) {
563
569
  sendEvent("keepalive", { phase: keepalivePhase });
564
570
  }, 25e3);
565
571
  try {
566
- if (ctx.warmupBackendBaseUrl) {
572
+ if (warmupBackend) {
567
573
  await proxyRemoteSSE(
568
- `${ctx.warmupBackendBaseUrl}/warmup/${job.warmupJobId}`,
574
+ `${warmupBackend.baseUrl}/warmup/${job.warmupJobId}`,
569
575
  sendEvent,
570
576
  "warmup:",
571
577
  res,
572
- ctx.warmupBackendApiKey ? { Authorization: `Bearer ${ctx.warmupBackendApiKey}` } : void 0
578
+ warmupBackend.apiKey ? { Authorization: `Bearer ${warmupBackend.apiKey}` } : void 0
573
579
  );
574
580
  } else {
575
581
  const warmupSender = prefixEventSender(sendEvent, "warmup:");
@@ -577,13 +583,13 @@ async function streamWarmupAndRenderJob(req, res, ctx) {
577
583
  warmupSender("complete", { status: "ready" });
578
584
  }
579
585
  keepalivePhase = "render";
580
- if (ctx.renderBackendBaseUrl) {
586
+ if (renderBackend) {
581
587
  await proxyRemoteSSE(
582
- `${ctx.renderBackendBaseUrl}/render/${job.renderJobId}`,
588
+ `${renderBackend.baseUrl}/render/${job.renderJobId}`,
583
589
  sendEvent,
584
590
  "render:",
585
591
  res,
586
- ctx.renderBackendApiKey ? { Authorization: `Bearer ${ctx.renderBackendApiKey}` } : void 0
592
+ renderBackend.apiKey ? { Authorization: `Bearer ${renderBackend.apiKey}` } : void 0
587
593
  );
588
594
  } else {
589
595
  const renderSender = prefixEventSender(sendEvent, "render:");
@@ -602,7 +608,7 @@ async function streamWarmupAndRenderJob(req, res, ctx) {
602
608
  sendEvent("complete", { status: "ready", videoUrl });
603
609
  }
604
610
  }
605
- if (job.upload && !ctx.renderBackendBaseUrl) {
611
+ if (job.upload && !renderBackend) {
606
612
  sendEvent("complete", { status: "done" });
607
613
  }
608
614
  } catch (error) {
@@ -707,7 +713,7 @@ function shouldSkipWarmup(source) {
707
713
  return source.type === "video" || source.type === "audio";
708
714
  }
709
715
  var inFlightFetches = /* @__PURE__ */ new Map();
710
- async function createWarmupJob(req, res, ctx) {
716
+ async function createWarmupJob(req, res, ctx, options) {
711
717
  try {
712
718
  const parseResult = parseEffieData(req.body, ctx.skipValidation);
713
719
  if ("error" in parseResult) {
@@ -716,10 +722,11 @@ async function createWarmupJob(req, res, ctx) {
716
722
  }
717
723
  const sources = extractEffieSourcesWithTypes2(parseResult.effie);
718
724
  const jobId = randomUUID3();
725
+ const job = { sources, metadata: options?.metadata };
719
726
  await ctx.transientStore.putJson(
720
727
  storeKeys.warmupJob(jobId),
721
- { sources },
722
- ctx.transientStore.jobMetadataTtlMs
728
+ job,
729
+ ctx.transientStore.jobDataTtlMs
723
730
  );
724
731
  res.json({
725
732
  id: jobId,
@@ -734,29 +741,32 @@ async function streamWarmupJob(req, res, ctx) {
734
741
  try {
735
742
  setupCORSHeaders(res);
736
743
  const jobId = req.params.id;
737
- if (ctx.warmupBackendBaseUrl) {
738
- setupSSEResponse(res);
739
- const sendEvent2 = createSSEEventSender(res);
740
- try {
741
- await proxyRemoteSSE(
742
- `${ctx.warmupBackendBaseUrl}/warmup/${jobId}`,
743
- sendEvent2,
744
- "",
745
- res,
746
- ctx.warmupBackendApiKey ? { Authorization: `Bearer ${ctx.warmupBackendApiKey}` } : void 0
747
- );
748
- } finally {
749
- res.end();
750
- }
751
- return;
752
- }
753
- const jobCacheKey = storeKeys.warmupJob(jobId);
754
- const job = await ctx.transientStore.getJson(jobCacheKey);
755
- ctx.transientStore.delete(jobCacheKey);
744
+ const jobStoreKey = storeKeys.warmupJob(jobId);
745
+ const job = await ctx.transientStore.getJson(jobStoreKey);
756
746
  if (!job) {
757
747
  res.status(404).json({ error: "Job not found" });
758
748
  return;
759
749
  }
750
+ if (ctx.warmupBackendResolver) {
751
+ const backend = ctx.warmupBackendResolver(job.sources, job.metadata);
752
+ if (backend) {
753
+ setupSSEResponse(res);
754
+ const sendEvent2 = createSSEEventSender(res);
755
+ try {
756
+ await proxyRemoteSSE(
757
+ `${backend.baseUrl}/warmup/${jobId}`,
758
+ sendEvent2,
759
+ "",
760
+ res,
761
+ backend.apiKey ? { Authorization: `Bearer ${backend.apiKey}` } : void 0
762
+ );
763
+ } finally {
764
+ res.end();
765
+ }
766
+ return;
767
+ }
768
+ }
769
+ ctx.transientStore.delete(jobStoreKey);
760
770
  setupSSEResponse(res);
761
771
  const sendEvent = createSSEEventSender(res);
762
772
  try {
@@ -945,4 +955,4 @@ export {
945
955
  streamWarmupJob,
946
956
  purgeCache
947
957
  };
948
- //# sourceMappingURL=chunk-ZERUSI5T.js.map
958
+ //# sourceMappingURL=chunk-7KHGAMSG.js.map