Spaces:
Paused
Paused
Download server.mjs from shootstuff/flux-image-space: direct link, hf CLI and curl.
- Browser
- Download file 17.1 kB
-
https://huggingface.co/spaces/shootstuff/flux-image-space/resolve/main/server.mjs
- Command line
-
hf download hf://spaces/shootstuff/flux-image-space/server.mjs
-
curl -L -o server.mjs https://huggingface.co/spaces/shootstuff/flux-image-space/resolve/main/server.mjs
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); | |
| } | |