From 952ac4d75dbda91023f4406ee3ced1b0e2dc1811 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Wed, 21 Oct 2026 13:34:15 +0300 Subject: [PATCH] Sunny: answer sendMessage and submitToolCallInput once Base44 accepts Base44 holds both requests open for the whole builder turn, so the proxy waited up to 120s and could report a working long build as a timeout. It now waits 3s for a refusal, which still reaches the browser as before, then answers { ok: true }. The request to Base44 keeps running under after(), since dropping it cancels the turn, and a later failure is logged. Mirrors Tiny Sunny's waitForRefusal. Also replaces the comments citing Vercel's 300s ceiling: this repo deploys to Netlify, where a synchronous function ends at 60s, counting after() work. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- src/app/api/base44/platform/route.ts | 53 ++++++++++++++++++++++++---- src/lib/base44Platform.ts | 4 +-- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4fa75dd..a1275be 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,8 @@ migrate production. [PREVIEW-DEPLOYS.md](PREVIEW-DEPLOYS.md) has the setup and w | `404 not_found` on an app-scoped action | The caller has no `AppOwnership` row for that app. Built before ownership was tracked? Insert the row | | `403` on deploy | The integration account isn't an Editor in the workspace | | Builds ignore your design system or skills | The token was created in the account's personal workspace. Create it in the white-label workspace | -| `sendMessage` times out at ~30s | Your own timeout, not Base44's. Build turns need ~120s | +| `sendMessage` times out at ~30s | Your own timeout, not Base44's. Base44 holds the request open for the whole turn: answer the browser once it's accepted, and keep the request to Base44 running (see `answerOnAccept`) | +| A build stops with "Processing was interrupted" | The request to Base44 was dropped mid-turn. Netlify ends a function at 60s, including work after the response | | A new app doesn't appear in the list | Its `AppOwnership` row was not recorded. The server logs this at create | ## Stack diff --git a/src/app/api/base44/platform/route.ts b/src/app/api/base44/platform/route.ts index 00db8fb..ea64260 100644 --- a/src/app/api/base44/platform/route.ts +++ b/src/app/api/base44/platform/route.ts @@ -25,7 +25,7 @@ * id, nor a credential *value* (see `APP_SECRETS`). */ -import { NextResponse, type NextRequest } from "next/server"; +import { NextResponse, after, type NextRequest } from "next/server"; import { requireSessionUser } from "@/lib/auth"; import { errorResponse, jsonError } from "@/lib/apiResponse"; @@ -56,6 +56,12 @@ type Op = { headers?: (p: Params) => Record; /** Override `DEFAULT_TIMEOUT_MS` for actions that wait on a builder turn. */ timeoutMs?: number; + /** + * Answer the browser once Base44 has accepted the request, not when the turn + * ends — see `answerOnAccept`. Only for actions whose response the browser + * doesn't read: it follows the turn over the live session instead. + */ + answersOnAccept?: boolean; }; /** Plenty for a CRUD call against the platform REST API. */ @@ -68,8 +74,10 @@ const DEFAULT_TIMEOUT_MS = 30_000; * measures at ~28s against a live app, so the 30s default aborted it * intermittently and reported it as an upstream fault. * - * Kept well under Vercel's 300s function ceiling so a genuinely hung upstream - * still fails rather than holding the function open to the platform limit. + * Netlify ends a synchronous function at 60s, counting work scheduled with + * `after()`, so a turn there is cut at 60s whatever this says. This ceiling is + * for hosts with no such limit (`next dev`, a Node server), so that a genuinely + * hung upstream still fails. */ const BUILDER_TIMEOUT_MS = 120_000; @@ -144,8 +152,9 @@ const OPS: Record = { method: "POST", path: (p) => `/api/apps/${str(p.appId)}/chat/message`, body: (p) => ({ content: p.content }), - // Blocks on a builder turn — see BUILDER_TIMEOUT_MS. + // Base44 holds this open for the whole builder turn — see BUILDER_TIMEOUT_MS. timeoutMs: BUILDER_TIMEOUT_MS, + answersOnAccept: true, }, getPreviewUrl: { method: "GET", @@ -174,8 +183,9 @@ const OPS: Record = { extra_user_input: p.extraUserInput ?? {}, message_id: p.messageId || undefined, }), - // Resumes the paused builder turn, so it waits on the same LLM work. + // Resumes the paused builder turn, so Base44 holds it open just as long. timeoutMs: BUILDER_TIMEOUT_MS, + answersOnAccept: true, // Stable per logical submit so a network-retried POST dedups instead of // resuming (and charging) the turn twice. headers: (p): Record => @@ -357,6 +367,36 @@ async function forward( } } +/** How long a refusal takes to come back. Past it, Base44 has accepted the turn. */ +const REFUSAL_WINDOW_MS = 3_000; + +/** + * `forward()` for a request Base44 holds open for a whole builder turn. A + * refusal comes back within `REFUSAL_WINDOW_MS` and reaches the browser exactly + * as `forward()` shapes it; past that, the browser gets `{ ok: true }` and the + * turn's progress over the live session. + * + * The request to Base44 keeps running after the response, under `after()`: + * dropping that connection cancels the turn. A failure from then on can only be + * logged. + */ +async function answerOnAccept(action: string, sent: Promise): Promise { + const accepted = Symbol("accepted"); + const refusalWindow = new Promise((resolve) => + setTimeout(resolve, REFUSAL_WINDOW_MS, accepted), + ); + const answer = await Promise.race([sent, refusalWindow]); + if (answer !== accepted) return answer; + + const finished = sent.catch((err: unknown) => { + const failed = `[base44/platform] ${action} failed after it was accepted`; + if (err instanceof UpstreamFailure) console.error(`${failed}: ${err.response.status}`); + else console.error(failed, err); + }); + after(() => finished); + return { ok: true }; +} + /** * The caller's apps, a page at a time. The workspace is shared by every builder, * so its listing is read and filtered by `AppOwnership` until the caller's page is @@ -454,7 +494,8 @@ export async function POST(req: NextRequest) { return jsonError(400, "invalid_request", `Invalid parameters for "${action}"`); } - const data = await forward(action, op, path, body, params, t0); + const sent = forward(action, op, path, body, params, t0); + const data = await (op.answersOnAccept ? answerOnAccept(action, sent) : sent); // Ownership is recorded in the same request that creates the app, and never // from the browser: a client-written row would let anyone claim any app. diff --git a/src/lib/base44Platform.ts b/src/lib/base44Platform.ts index 4a4ff01..293112e 100644 --- a/src/lib/base44Platform.ts +++ b/src/lib/base44Platform.ts @@ -137,8 +137,8 @@ export const openLiveSession = (appId: string) => call("openLiveSession", { appId }) as Promise; /** - * Sends a builder message. Base44 holds the request open while the turn runs; - * the chat follows the turn over the live session, not this response. + * Sends a builder message. The proxy answers once Base44 has accepted it, not + * when the turn ends; the chat follows the turn over the live session. */ export const sendMessage = (appId: string, content: string) => call("sendMessage", { appId, content });