perf(studio): stream nitro repair progress over SSE and allow cancelling
CI / check (push) Successful in 4m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m43s

This commit is contained in:
openhands committed 2026-09-19 13:12:45 +02:00
1 parent ba81d16f00
commit 9d571e0c29
4 files changed
+249 -26

No files matched your search

@@ -3,9 +3,25 @@ import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { clearFurniImportCache } from "@/lib/services/furni-import-cache";
import { repairBrokenNitros } from "@/lib/services/nitro-cleanup";
import {
type NitroRepairProgress,
repairBrokenNitros,
} from "@/lib/services/nitro-cleanup";
/** Re-download the given broken .nitro bundles from the configured sources. */
type RepairSummary = {
repaired: number;
failed: number;
errors?: string[];
};
/**
* Re-download the given broken .nitro bundles from the configured nitro
* sources. Bundles are pulled in parallel server-side; when the client asks
* for `text/event-stream` a `progress` event is streamed after every bundle
* settles plus a final `result` event, mirroring the scan endpoint so a large
* repair batch cannot be cut off by a reverse-proxy timeout and can be
* cancelled client-side.
*/
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
@@ -23,8 +39,22 @@ export const POST = withAdmin(
if (fileNames.length === 0) {
return apiError("fileNames[] is required", 400);
}
try {
const result = await repairBrokenNitros(fileNames);
const summarize = (result: {
repaired: number;
failed: number;
errors: string[];
}): RepairSummary => ({
repaired: result.repaired,
failed: result.failed,
errors: result.errors.length > 0 ? result.errors : undefined,
});
const finish = async (result: {
repaired: number;
failed: number;
errors: string[];
}) => {
clearFurniImportCache();
if (result.repaired > 0) {
logAudit({
@@ -39,10 +69,54 @@ export const POST = withAdmin(
},
});
}
return apiOk({
repaired: result.repaired,
failed: result.failed,
errors: result.errors.length > 0 ? result.errors : undefined,
return summarize(result);
};
const wantsSse = request.headers
.get("accept")
?.includes("text/event-stream");
try {
if (!wantsSse) {
return apiOk(await finish(await repairBrokenNitros(fileNames)));
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
);
} catch {
/* stream closed by client */
}
};
try {
const result = await repairBrokenNitros(fileNames, {
onProgress: (progress: NitroRepairProgress) =>
send({ type: "progress", ...progress }),
});
send({ type: "result", ...(await finish(result)) });
} catch (err) {
send({
type: "error",
message: `Nitro repair failed: ${(err as Error).message}`,
});
}
try {
controller.close();
} catch {
/* ignore */
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (err) {
return apiError(`Nitro repair failed: ${(err as Error).message}`, 500);