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));

View File

@ -1,14 +1,14 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { apiFetch } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input, Label, Textarea } from '@/components/input';
import { StreamingLogs } from '@/components/streaming-logs';
import { InstallSnippets } from '@/components/install-snippets';
import { CodeBlock } from '@/components/code-block';
import { Input, Label, Textarea } from '@/components/input';
import { InstallSnippets } from '@/components/install-snippets';
import { StreamingLogs } from '@/components/streaming-logs';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2, RotateCcw, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
const EXAMPLE_PROMPTS = [
{
@ -85,6 +85,7 @@ function specToEditable(spec: PreviewResponse['spec']): EditableSpec {
function NewServerPageInner() {
const router = useRouter();
const [step, setStep] = useState<Step>('prompt');
const [elapsedSec, setElapsedSec] = useState(0);
const [prompt, setPrompt] = useState('');
const [name, setName] = useState('');
@ -105,7 +106,11 @@ function NewServerPageInner() {
const templateSlug = searchParams.get('template');
const trySlug = (n: string) =>
n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
n
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32);
// Fork-from-template flow: skip Step 1, jump straight to Step 2 with the template's spec
useEffect(() => {
@ -189,6 +194,19 @@ function NewServerPageInner() {
}
}, [preview, editable]);
// Live elapsed counter for the analyze step — a value that ticks every
// second is unambiguous proof the page is alive, even when CSS animation is
// suppressed (e.g. the OS "reduce motion" setting).
useEffect(() => {
if (step !== 'analyzing') return;
setElapsedSec(0);
const startedAt = Date.now();
const id = setInterval(() => {
setElapsedSec(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
return () => clearInterval(id);
}, [step]);
async function analyze() {
setError(null);
if (prompt.trim().length < 10) {
@ -323,9 +341,7 @@ function NewServerPageInner() {
};
try {
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>(
'/v1/servers',
{
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>('/v1/servers', {
method: 'POST',
body: JSON.stringify({
name,
@ -337,8 +353,7 @@ function NewServerPageInner() {
// are already in the Redis cache. Edits would invalidate the impls.
...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }),
}),
},
);
});
setBuildId(res.build.id);
setServerId(res.server.id);
setStep('building');
@ -412,7 +427,9 @@ function NewServerPageInner() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="slug" hint="becomes subdomain / id">Slug</Label>
<Label htmlFor="slug" hint="becomes subdomain / id">
Slug
</Label>
<Input
id="slug"
value={slug}
@ -437,10 +454,13 @@ function NewServerPageInner() {
{step === 'analyzing' && (
<div className="mt-10 panel p-8 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
<Loader2 className="mx-auto animate-spin text-[--color-accent]" size={22} />
<p className="mt-4 text-[13px]">Analyzing your prompt</p>
<p className="mt-1 text-[12px] text-[--color-fg-subtle]">
Claude Opus 4.7 is parsing the spec. Usually 2040 seconds.
Claude Sonnet 4.6 is drafting the tool spec. Usually 2040 seconds.
</p>
<p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]">
{elapsedSec}s elapsed
</p>
</div>
)}
@ -490,7 +510,9 @@ function NewServerPageInner() {
)}
<div className="panel p-4">
<div className="flex items-baseline justify-between">
<h2 className="text-[14px] font-semibold tracking-tight">Confirm what we&apos;ll build</h2>
<h2 className="text-[14px] font-semibold tracking-tight">
Confirm what we&apos;ll build
</h2>
<div className="flex items-center gap-3">
{editsDirty && (
<button
@ -508,9 +530,9 @@ function NewServerPageInner() {
</div>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{preview.spec.description}</p>
<p className="mt-3 text-[11.5px] leading-relaxed text-[--color-fg-subtle]">
Edit tool names, descriptions or input schemas inline. Renaming parameters may
require an <span className="mono">Iterate</span> after build to update the
implementation the existing impl references the original names.
Edit tool names, descriptions or input schemas inline. Renaming parameters may require
an <span className="mono">Iterate</span> after build to update the implementation
the existing impl references the original names.
</p>
</div>
@ -560,9 +582,7 @@ function NewServerPageInner() {
</div>
<div>
<h3 className="text-[13px] font-semibold tracking-tight">
Credentials we need
</h3>
<h3 className="text-[13px] font-semibold tracking-tight">Credentials we need</h3>
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
AES-256-GCM encrypted at rest, injected as env vars at runtime. Remove if your
implementation doesn&apos;t actually use one.
@ -625,12 +645,7 @@ function NewServerPageInner() {
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
Back
</Button>
<Button
variant="primary"
size="md"
onClick={build}
disabled={Boolean(hasSchemaErrors)}
>
<Button variant="primary" size="md" onClick={build} disabled={Boolean(hasSchemaErrors)}>
Build server
</Button>
</div>
@ -754,9 +769,7 @@ function SharePanel({
}) {
const [share, setShare] = useState(true);
const [category, setCategory] = useState('other');
const [shortDescription, setShortDescription] = useState(
defaultShortDescription.slice(0, 280),
);
const [shortDescription, setShortDescription] = useState(defaultShortDescription.slice(0, 280));
const [hints, setHints] = useState<Record<string, string>>(() =>
Object.fromEntries(secretKeys.map((k) => [k, ''])),
);
@ -832,8 +845,8 @@ function SharePanel({
</div>
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
Your secrets stay private they are never copied into a template. But your{' '}
<span className="text-[--color-fg]">generated code becomes publicly viewable</span>{' '}
so others can audit it before forking. Unshare anytime.
<span className="text-[--color-fg]">generated code becomes publicly viewable</span> so
others can audit it before forking. Unshare anytime.
</p>
</div>
</label>
@ -868,9 +881,7 @@ function SharePanel({
{secretKeys.length > 0 && (
<div className="space-y-1.5">
<Label hint="optional — helps forkers know what to paste">
Credential hints
</Label>
<Label hint="optional — helps forkers know what to paste">Credential hints</Label>
{secretKeys.map((k) => (
<div key={k} className="grid grid-cols-[180px_1fr] gap-2">
<div className="mono flex h-8 items-center rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[12px] text-[--color-fg-muted]">
@ -892,12 +903,7 @@ function SharePanel({
<p className="text-[11px] text-[--color-fg-subtle]">
Published code is re-scanned for banned patterns and hardcoded secrets.
</p>
<Button
variant="primary"
size="md"
onClick={publish}
disabled={state === 'submitting'}
>
<Button variant="primary" size="md" onClick={publish} disabled={state === 'submitting'}>
{state === 'submitting' ? 'Publishing…' : 'Publish to marketplace'}
</Button>
</div>

View File

@ -87,6 +87,17 @@
::-webkit-scrollbar-thumb:hover {
background: var(--color-fg-subtle);
}
/* Textarea resize grip light hatching on a dark square so it matches the
theme instead of the OS default (light square with dark hatching). */
textarea::-webkit-resizer {
background-color: var(--color-bg);
background-image: repeating-linear-gradient(
-45deg,
var(--color-fg-muted) 0 1px,
transparent 1px 4px
);
border-bottom-right-radius: var(--radius-md);
}
}
@layer components {
@ -115,6 +126,16 @@
transition-duration: 0.001ms !important;
}
}
/* A loading spinner reports system status WCAG treats status indicators as
an exception to reduced-motion, so keep it spinning after the rule above
has damped every decorative animation. Same layer + higher specificity
than the universal selector, so this !important wins. */
@media (prefers-reduced-motion: reduce) {
.animate-spin {
animation-duration: 1s !important;
animation-iteration-count: infinite !important;
}
}
}
@keyframes pulse-dot {

View File

@ -54,18 +54,42 @@ export interface GenerateOptions {
apiKey?: string;
model?: string;
maxTokens?: number;
/** Per-attempt request timeout in ms. Omit to use the SDK default. */
timeoutMs?: number;
/** SDK retry count. Omit to use the SDK default. */
maxRetries?: number;
}
export async function generateSpec(prompt: string, opts: GenerateOptions = {}): Promise<GenerationResult> {
export async function generateSpec(
prompt: string,
opts: GenerateOptions = {},
): Promise<GenerationResult> {
if (!opts.apiKey) {
return { spec: mockSpec(prompt), source: 'mock' };
}
const client = new Anthropic({ apiKey: opts.apiKey });
const response = await client.messages.create({
const requestOptions: { timeout?: number; maxRetries?: number } = {};
if (opts.timeoutMs !== undefined) requestOptions.timeout = opts.timeoutMs;
if (opts.maxRetries !== undefined) requestOptions.maxRetries = opts.maxRetries;
const response = await client.messages
.create(
{
model: opts.model ?? 'claude-opus-4-7',
max_tokens: opts.maxTokens ?? 8192,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: prompt }],
},
requestOptions,
)
.catch((err: unknown) => {
// A per-attempt timeout surfaces as APIConnectionTimeoutError once the
// SDK exhausts retries. Map it to a typed error so the API layer returns
// a clean 504 instead of letting the edge proxy time out headerless.
if (err instanceof Anthropic.APIConnectionTimeoutError) {
throw new SpecTimeoutError('spec generation exceeded the time budget');
}
throw err;
});
const text = response.content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
@ -88,6 +112,10 @@ export class BannedPatternError extends Error {
override readonly name = 'BannedPatternError';
}
export class SpecTimeoutError extends Error {
override readonly name = 'SpecTimeoutError';
}
function extractJson(text: string): unknown {
const trimmed = text.trim();
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);