fix(preview): stop spec generation timing out behind the edge proxy
All checks were successful
Deploy to Production / deploy (push) Successful in 50s

The /v1/servers/preview route ran claude-opus-4-7 synchronously; full spec
generation routinely exceeded Cloudflare's ~100s proxy cap, so the browser
received a headerless 524 and reported it as a CORS failure.

- preview now uses claude-sonnet-4-6 with a 45s per-attempt timeout and one
  retry — comfortably inside the proxy budget
- generateSpec maps an exhausted timeout to SpecTimeoutError; the route
  returns a clean 504 (with CORS headers) instead of a stalled connection
- analyze step: live elapsed-seconds counter as freeze-proof, plus a
  reduced-motion exception so the loading spinner keeps spinning (a status
  indicator, which WCAG exempts from reduced-motion)
- textarea resize grip restyled to dark theme (light hatch on dark square)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi
2026-05-21 23:52:48 +02:00
parent 5d0d5668d8
commit e198d44e1e
4 changed files with 163 additions and 76 deletions

View File

@@ -1,25 +1,36 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, builds, buildLogs, createDb, desc, eq, mcpServers, secrets, sql, templates } from '@bmm/db';
import { getSession } from '@bmm/auth';
import { stopContainer } from '../lib/docker.js';
import {
CreateServerInput,
IterateServerInput,
and,
buildLogs,
builds,
createDb,
desc,
eq,
mcpServers,
secrets,
sql,
templates,
} from '@bmm/db';
import { BannedPatternError, SpecTimeoutError, SpecValidationError, generateSpec } from '@bmm/llm';
import {
BuildEvent,
PreviewInput,
CreateServerInput,
GeneratorSpec,
IterateServerInput,
PreviewInput,
type SpecEdit,
} from '@bmm/types';
import { generateSpec, SpecValidationError, BannedPatternError } from '@bmm/llm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { encryptSecret } from '../lib/crypto.js';
import { stopContainer } from '../lib/docker.js';
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
import { requireAuth } from '../plugins/session.js';
import { getBuildQueue } from '../lib/queue.js';
import { buildChannel, getSubscriber } from '../lib/redis.js';
import { encryptSecret } from '../lib/crypto.js';
import { audit } from '../lib/audit.js';
import { requireAuth } from '../plugins/session.js';
import { getForkRefTemplate } from './templates.js';
import { config } from '../config.js';
const db = createDb();
@@ -42,7 +53,11 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
try {
const { spec, source } = await generateSpec(parsed.data.prompt, {
apiKey: config.ANTHROPIC_API_KEY,
model: 'claude-opus-4-7',
// Sonnet 4.6 drafts the spec well inside Cloudflare's ~100s proxy cap;
// Opus routinely exceeded it, which reached the browser as a CORS error.
model: 'claude-sonnet-4-6',
timeoutMs: 45_000,
maxRetries: 1,
});
const previewId = await cacheSpec(spec);
return reply.send({
@@ -67,6 +82,12 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
if (err instanceof BannedPatternError) {
return reply.code(422).send({ error: 'banned_pattern', detail: err.message });
}
if (err instanceof SpecTimeoutError) {
return reply.code(504).send({
error: 'preview_timeout',
detail: 'Spec generation took too long. Try a shorter, more specific prompt.',
});
}
app.log.error(err);
return reply.code(500).send({ error: 'preview_failed', detail: (err as Error).message });
}
@@ -78,7 +99,15 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
}
const { name, slug, prompt, secrets: secretValues, previewId, specEdit, templateId } = parsed.data;
const {
name,
slug,
prompt,
secrets: secretValues,
previewId,
specEdit,
templateId,
} = parsed.data;
// ---- Template-fork validation ----
// templateId is user-controlled. To prevent fork_count manipulation + garbage
@@ -401,7 +430,10 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const result = await stopContainer(server.containerId);
containerStopped = result.ok;
if (!result.ok) {
app.log.warn({ containerId: server.containerId, detail: result.detail }, 'delete: stop failed');
app.log.warn(
{ containerId: server.containerId, detail: result.detail },
'delete: stop failed',
);
}
}
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));