flux-image-space / server.mjs
Z User
Flux Studio — Zero GPU: Hugging Face Docker Space (Express proxy to BFL FLUX API)
c9996a9
Raw History Blame Contribute Delete
17.1 kB
/**
* Flux Studio — Zero GPU
* ------------------------------------------------------------------
* A CPU-only web app (Hugging Face Docker Space) that generates
* images by proxying to the Black Forest Labs (BFL) FLUX API.
*
* The BFL_API_KEY never reaches the browser: every generation call
* goes through this server, which attaches the key server-side.
*
* Configuration (env vars):
* BFL_API_KEY Black Forest Labs API key (secret).
* Without it the UI still loads and reports
* its config state, but generation is refused.
* BFL_MODEL_ID Preferred model (default: flux-2-pro-preview)
* BFL_FALLBACK_MODEL_ID Fallback model (default: flux-2-klein-9b)
* BFL_API_BASE_URL API base URL (default: https://api.bfl.ai)
* PORT Listen port (default: 7860, HF Spaces)
*/
import express from 'express';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import crypto from 'node:crypto';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/* ------------------------------------------------------------------ */
/* Configuration */
/* ------------------------------------------------------------------ */
const PORT = Number(process.env.PORT || 7860, 10);
const HOST = process.env.HOST || '0.0.0.0';
const BFL_API_KEY = (process.env.BFL_API_KEY || '').trim();
const BFL_MODEL_ID = (process.env.BFL_MODEL_ID || 'flux-2-pro-preview').trim();
const BFL_FALLBACK_MODEL_ID = (process.env.BFL_FALLBACK_MODEL_ID || 'flux-2-klein-9b').trim();
const BFL_API_BASE_URL = (process.env.BFL_API_BASE_URL || 'https://api.bfl.ai').trim().replace(/\/+$/, '');
const POLL_INTERVAL_MS = 1000;
const POLL_TIMEOUT_MS = 300_000; // give a single generation up to 5 minutes
const JOB_MAX = 100;
const JOB_TTL_MS = 30 * 60 * 1000;
/** A key that is obviously a placeholder must not be treated as configured. */
function isPlaceholderKey(key) {
return !key || /placeholder|replace.?me|dummy|example|changeme|xxx|todo/i.test(key);
}
const apiKeyPresent = Boolean(BFL_API_KEY);
const apiKeyConfigured = apiKeyPresent && !isPlaceholderKey(BFL_API_KEY);
const config = {
appName: 'Flux Studio — Zero GPU',
port: PORT,
apiKeyPresent,
apiKeyConfigured,
placeholderKey: apiKeyPresent && !apiKeyConfigured,
modelId: BFL_MODEL_ID,
fallbackModelId: BFL_FALLBACK_MODEL_ID,
apiBaseHost: (() => {
try {
return new URL(BFL_API_BASE_URL).host;
} catch {
return BFL_API_BASE_URL;
}
})(),
ready: apiKeyConfigured,
};
/* ------------------------------------------------------------------ */
/* BFL API client helpers */
/* ------------------------------------------------------------------ */
function bflHeaders() {
return {
accept: 'application/json',
'content-type': 'application/json',
'x-key': BFL_API_KEY,
authorization: `Bearer ${BFL_API_KEY}`,
};
}
/**
* Submit a generation task to a BFL model endpoint.
* Returns { id, polling_url }.
*/
async function bflSubmit(model, payload) {
const url = `${BFL_API_BASE_URL}/v1/${encodeURIComponent(model)}`;
let res;
try {
res = await fetch(url, {
method: 'POST',
headers: bflHeaders(),
body: JSON.stringify(payload),
signal: AbortSignal.timeout(60_000),
});
} catch (err) {
const e = new Error(`Could not reach the BFL API at ${config.apiBaseHost}: ${err.message}`);
e.code = 'UPSTREAM_UNREACHABLE';
throw e;
}
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
/* non-JSON body */
}
if (!res.ok) {
const detail =
(data && (data.detail || data.error || (Array.isArray(data.errors) && data.errors.join('; ')) || JSON.stringify(data))) ||
text.slice(0, 300) ||
res.statusText;
const e = new Error(`BFL API rejected the request (HTTP ${res.status}) for model "${model}": ${detail}`);
e.code = res.status === 401 || res.status === 403 ? 'AUTH' : `HTTP_${res.status}`;
throw e;
}
if (!data || typeof data.polling_url !== 'string') {
const e = new Error(`Unexpected response from BFL API for model "${model}" (missing polling_url): ${JSON.stringify(data).slice(0, 300)}`);
e.code = 'BAD_RESPONSE';
throw e;
}
return { id: data.id, pollingUrl: data.polling_url };
}
/** Poll a BFL polling_url until Ready / terminal error / timeout. */
async function bflPoll(pollingUrl) {
const deadline = Date.now() + POLL_TIMEOUT_MS;
for (;;) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
let res;
try {
res = await fetch(pollingUrl, {
headers: { accept: 'application/json', 'x-key': BFL_API_KEY, authorization: `Bearer ${BFL_API_KEY}` },
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
const e = new Error(`Lost contact with the BFL API while polling: ${err.message}`);
e.code = 'UPSTREAM_UNREACHABLE';
throw e;
}
let data = null;
try {
data = await res.json();
} catch {
/* transient bad body — keep polling until deadline */
}
if (data) {
const status = data.status;
if (status === 'Ready') {
const sample = data?.result?.sample || data?.sample;
if (typeof sample !== 'string') {
const e = new Error(`Generation reported Ready but no image URL was returned: ${JSON.stringify(data).slice(0, 300)}`);
e.code = 'BAD_RESPONSE';
throw e;
}
return { imageUrl: sample, result: data.result || data };
}
if (status === 'Error' || status === 'Failed' || status === 'Cancelled' || status === 'Content Moderated') {
const e = new Error(
`Generation failed with status "${status}": ${JSON.stringify(data?.result || data).slice(0, 300)}`
);
e.code = status === 'Content Moderated' ? 'MODERATED' : 'GENERATION_FAILED';
throw e;
}
if (typeof status === 'string') {
// e.g. "Queue", "Pending", "Generating", "Requesting" — keep going
if (typeof bflPoll.onProgress === 'function') bflPoll.onProgress(status);
}
}
if (Date.now() > deadline) {
const e = new Error(`Generation timed out after ${POLL_TIMEOUT_MS / 1000}s of polling.`);
e.code = 'TIMEOUT';
throw e;
}
}
}
/* ------------------------------------------------------------------ */
/* In-memory job store (single process — enough for a demo Space) */
/* ------------------------------------------------------------------ */
const jobs = new Map();
function pruneJobs() {
const now = Date.now();
// Drop jobs that finished (or were created) too long ago.
for (const [id, job] of jobs) {
if (now - (job.finishedAt || job.createdAt) > JOB_TTL_MS) jobs.delete(id);
}
// If still over capacity, evict the oldest finished jobs first.
if (jobs.size > JOB_MAX) {
const finished = [...jobs.values()]
.filter((j) => j.finishedAt)
.sort((a, b) => a.finishedAt - b.finishedAt);
for (const j of finished) {
if (jobs.size <= JOB_MAX) break;
jobs.delete(j.id);
}
}
}
function publicJob(job) {
return {
id: job.id,
status: job.status,
detail: job.detail || null,
model: job.model || null,
fallbackUsed: Boolean(job.fallbackUsed),
prompt: job.prompt,
params: job.params,
imageUrl: job.imageUrl || null,
error: job.error || null,
code: job.code || null,
createdAt: job.createdAt,
finishedAt: job.finishedAt || null,
};
}
/* ------------------------------------------------------------------ */
/* Request validation */
/* ------------------------------------------------------------------ */
const ASPECT_PRESETS = {
'1:1': [1024, 1024],
'16:9': [1344, 768],
'9:16': [768, 1344],
'4:3': [1152, 896],
'3:4': [896, 1152],
'3:2': [1216, 832],
'2:3': [832, 1216],
};
function coerceInt(v, fallback) {
if (v === undefined || v === null || v === '') return fallback;
const n = Number(v);
return Number.isFinite(n) && Math.trunc(n) === n ? Math.trunc(n) : NaN;
}
function validateBody(body) {
const errors = [];
const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
if (!prompt) errors.push('prompt is required');
if (prompt.length > 2000) errors.push('prompt must be at most 2000 characters');
let width = coerceInt(body?.width, 1024);
let height = coerceInt(body?.height, 1024);
const aspect = typeof body?.aspectRatio === 'string' ? body.aspectRatio : null;
if (aspect) {
if (!ASPECT_PRESETS[aspect]) {
errors.push(`aspectRatio must be one of ${Object.keys(ASPECT_PRESETS).join(', ')}`);
} else {
[width, height] = ASPECT_PRESETS[aspect];
}
}
if (!Number.isInteger(width) || width < 256 || width > 2048) errors.push('width must be an integer between 256 and 2048');
if (!Number.isInteger(height) || height < 256 || height > 2048) errors.push('height must be an integer between 256 and 2048');
const seed = body?.seed === undefined || body?.seed === null || body?.seed === '' ? undefined : coerceInt(body.seed, NaN);
if (seed !== undefined && (!Number.isInteger(seed) || seed < 0 || seed > 4294967295)) {
errors.push('seed must be an integer between 0 and 4294967295');
}
const outputFormat = body?.outputFormat || 'jpeg';
if (!['jpeg', 'png'].includes(outputFormat)) errors.push('outputFormat must be "jpeg" or "png"');
if (errors.length) {
const e = new Error(`Invalid request: ${errors.join('; ')}`);
e.code = 'INVALID_REQUEST';
throw e;
}
return { prompt, width, height, seed, outputFormat };
}
/* ------------------------------------------------------------------ */
/* Generation pipeline: primary model, then fallback */
/* ------------------------------------------------------------------ */
function buildPayload(p) {
const payload = { prompt: p.prompt, width: p.width, height: p.height, output_format: p.outputFormat };
if (p.seed !== undefined) payload.seed = p.seed;
return payload;
}
async function runGeneration(job) {
const models = [BFL_MODEL_ID, BFL_FALLBACK_MODEL_ID].filter((m, i, a) => m && a.indexOf(m) === i);
const failures = [];
for (let i = 0; i < models.length; i++) {
const model = models[i];
try {
job.detail = `Submitting to ${model}…`;
const { pollingUrl } = await bflSubmit(model, buildPayload(job.params));
job.model = model;
job.fallbackUsed = i > 0;
job.status = 'processing';
bflPoll.onProgress = (status) => {
job.detail = `${model}: ${status}…`;
};
const { imageUrl } = await bflPoll(pollingUrl);
job.status = 'succeeded';
job.imageUrl = imageUrl;
job.detail = `Done via ${model}`;
job.finishedAt = Date.now();
return;
} catch (err) {
failures.push({ model, message: err.message, code: err.code || 'UNKNOWN' });
job.detail = `${model} failed — ${failures.length < models.length ? 'trying fallback model…' : 'no more models to try'}`;
// An auth failure will fail on every model — stop early.
if (err.code === 'AUTH') break;
} finally {
bflPoll.onProgress = null;
}
}
job.status = 'failed';
job.finishedAt = Date.now();
const first = failures[0];
job.error = failures.map((f) => `[${f.model}] ${f.message}`).join(' | ');
job.code = first ? first.code : 'UNKNOWN';
if (job.code === 'AUTH') {
job.error =
'The BFL API rejected the API key (HTTP 401/403). If this Space uses a placeholder, set a real BFL_API_KEY under Settings → Secrets and restart. Underlying detail: ' +
job.error;
}
}
/* ------------------------------------------------------------------ */
/* Express app */
/* ------------------------------------------------------------------ */
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '64kb' }));
/* --- API: health & config ----------------------------------------- */
app.get('/api/health', (_req, res) => {
res.json({ ok: true, uptime: process.uptime(), ts: Date.now() });
});
app.get('/api/config', (_req, res) => {
res.json({
appName: config.appName,
modelId: config.modelId,
fallbackModelId: config.fallbackModelId,
apiBaseHost: config.apiBaseHost,
apiKeyPresent: config.apiKeyPresent,
apiKeyConfigured: config.apiKeyConfigured,
placeholderKey: config.placeholderKey,
ready: config.ready,
});
});
/* --- API: generate (async job) ------------------------------------ */
app.post('/api/generate', (req, res) => {
if (!config.apiKeyConfigured) {
const msg = config.apiKeyPresent
? 'Generation is disabled: BFL_API_KEY is set but is a placeholder. Replace it with a real Black Forest Labs key under Space Settings → Secrets, then restart the Space.'
: 'Generation is disabled: BFL_API_KEY is not configured. Add it under Space Settings → Secrets, then restart the Space.';
return res.status(503).json({ error: msg, code: 'API_KEY_NOT_CONFIGURED' });
}
let params;
try {
params = validateBody(req.body);
} catch (err) {
return res.status(400).json({ error: err.message, code: err.code });
}
pruneJobs();
const id = crypto.randomUUID();
const job = {
id,
status: 'submitting',
detail: 'Queued…',
prompt: params.prompt,
params,
createdAt: Date.now(),
};
jobs.set(id, job);
runGeneration(job).catch((err) => {
job.status = 'failed';
job.error = err.message;
job.code = 'INTERNAL';
job.finishedAt = Date.now();
});
res.status(202).json({ jobId: id, job: publicJob(job) });
});
app.get('/api/jobs/:id', (req, res) => {
const job = jobs.get(req.params.id);
if (!job) return res.status(404).json({ error: 'Unknown job id.', code: 'NOT_FOUND' });
res.json(publicJob(job));
});
/* --- API: image proxy (restricted to BFL delivery hosts) ---------- */
const DELIVERY_HOST_SUFFIXES = ['.bfl.ai', '.blob.core.windows.net', '.cdn.bfl.ml'];
function isAllowedDeliveryHost(host) {
const h = (host || '').toLowerCase();
return h === 'bfl.ai' || h === 'api.bfl.ai' || DELIVERY_HOST_SUFFIXES.some((s) => h.endsWith(s));
}
app.get('/api/image', async (req, res) => {
const raw = String(req.query.u || '');
let target;
try {
target = new URL(raw);
} catch {
return res.status(400).json({ error: 'Invalid url parameter.', code: 'INVALID_REQUEST' });
}
if (!['http:', 'https:'].includes(target.protocol) || !isAllowedDeliveryHost(target.host)) {
return res.status(400).json({ error: 'Only BFL delivery URLs may be proxied.', code: 'FORBIDDEN_URL' });
}
try {
const upstream = await fetch(target, { signal: AbortSignal.timeout(60_000) });
if (!upstream.ok || !upstream.body) {
return res.status(502).json({ error: `Upstream returned HTTP ${upstream.status}.`, code: 'UPSTREAM' });
}
res.status(upstream.status);
const ct = upstream.headers.get('content-type');
if (ct) res.setHeader('content-type', ct);
res.setHeader('cache-control', 'public, max-age=3600');
upstream.body.pipe(res);
} catch (err) {
res.status(502).json({ error: `Proxy fetch failed: ${err.message}`, code: 'UPSTREAM' });
}
});
/* --- Static client ------------------------------------------------- */
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
app.get(/^\/(?!api\/).*/, (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.use('/api', (_req, res) => res.status(404).json({ error: 'Unknown API route.', code: 'NOT_FOUND' }));
/* eslint-disable no-unused-vars */
app.use((err, _req, res, _next) => {
const status = err.type === 'entity.parse.failed' || err.type === 'entity.too.large' ? 400 : 500;
res.status(status).json({ error: err.message || 'Internal server error', code: 'INTERNAL' });
});
/* ------------------------------------------------------------------ */
/* Boot (only when run as the main module) */
/* ------------------------------------------------------------------ */
export { app, config, isPlaceholderKey, validateBody, buildPayload, ASPECT_PRESETS, isAllowedDeliveryHost };
const isMain =
process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
if (isMain) {
const server = app.listen(PORT, HOST, () => {
console.log(`[flux-studio] listening on http://${HOST}:${PORT}`);
console.log(
`[flux-studio] model=${config.modelId} fallback=${config.fallbackModelId} base=${config.apiBaseHost} keyConfigured=${config.apiKeyConfigured}${config.placeholderKey ? ' (placeholder detected)' : ''}`
);
});
const shutdown = () => server.close(() => process.exit(0));
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}