FN-103: support large request payloads
Allow large chat logs and workspace file-editor saves to pass through the dashboard without payload-too-large failures. - Increase request-body handling capacity for large text endpoints. - Document the payload limits and add integration coverage for chat and workspace-file APIs. - Add the FN-103 release changeset. Files changed: .changeset/fn-103-large-text-payloads.md | 7 + packages/dashboard/README.md | 15 +- .../app/api/__tests__/chat-rooms-api.test.ts | 11 ++ .../app/api/__tests__/legacy-chat-stream.test.ts | 19 ++ .../app/api/__tests__/workspace-files-api.test.ts | 30 ++++ .../large-text-body-parser-integration.test.ts | 195 +++++++++++++++++++++ packages/dashboard/src/routes/README.md | 21 +++ packages/dashboard/src/server.ts | 29 ++- 8 files changed, 325 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-103 Fusion-Task-Lineage: 05cb2642-1f55-4939-8a28-78badf9b7caa Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-103-large-text-payloads.md
Normal file
7
.changeset/fn-103-large-text-payloads.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Allow larger chat logs and supported file-editor saves without payload errors.
|
||||
category: fix
|
||||
dev: Adds finite route-scoped 2 MiB chat and escaped-file JSON parsers while retaining default limits.
|
||||
@@ -459,12 +459,25 @@ Browse and edit task worktree files directly from the task detail modal:
|
||||
- **Safety Features**:
|
||||
- Path traversal prevention (blocks `..` patterns)
|
||||
- Binary file detection (prevents editing images, executables, etc.)
|
||||
- 1MB file size limit
|
||||
- 1MB UTF-8 file-content size limit
|
||||
- File-save JSON requests allow up to 6,292,480 bytes so a supported 1MB control-character file
|
||||
can survive JSON escaping; this transport envelope does not increase the file-content limit
|
||||
- Unsaved change indicators
|
||||
- **Keyboard Shortcuts**:
|
||||
- `Ctrl/Cmd+S` to save
|
||||
- `Escape` to close
|
||||
|
||||
### Large chat and file requests
|
||||
|
||||
Pasted Direct, Planner, and Room Chat text sent as JSON can be up to 2 MiB per request. The limit
|
||||
is a transport limit, not a model-context promise: provider context also includes conversation
|
||||
history, instructions, tool input, reasoning, and output. Chat attachment uploads remain multipart
|
||||
with their existing file count and size limits.
|
||||
|
||||
File-editor saves have an approximately 6 MiB JSON transport envelope only to accommodate JSON
|
||||
escaping of the unchanged 1 MiB UTF-8 file-content maximum. File-browser operations such as mkdir,
|
||||
copy, move, delete, and rename, plus unrelated JSON endpoints, retain the 100 KiB request limit.
|
||||
|
||||
### Activity Log
|
||||
View a centralized timeline of all task lifecycle events. Click the history icon in the header to open the Activity Log modal.
|
||||
|
||||
|
||||
@@ -71,6 +71,17 @@ describe("chat room legacy API client", () => {
|
||||
expect((fetchMock.mock.calls[2] as [string])[0]).toContain("/api/chat/rooms/room-1/members/agent-2?projectId=proj-2");
|
||||
});
|
||||
|
||||
it("serializes a large room log byte-for-byte", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ message: {} }));
|
||||
const log = "2026-08-21T04:35:00Z INFO repeated room line\n".repeat(3_000);
|
||||
|
||||
await postChatRoomMessage("room-1", { content: log }, "proj-3");
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe(JSON.stringify({ content: log }));
|
||||
expect(JSON.parse(String(init.body)).content).toBe(log);
|
||||
});
|
||||
|
||||
it("builds message endpoints", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true }));
|
||||
|
||||
|
||||
@@ -51,6 +51,25 @@ describe("streamChatResponse SSE parser", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves a large no-attachment log as JSON while attachments remain multipart", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"m-large\"}\n\n"]), { status: 200 }),
|
||||
);
|
||||
const log = "2026-08-21T04:35:00Z INFO repeated log line\n".repeat(3_000);
|
||||
|
||||
streamChatResponse("s-1", log, { onDone: vi.fn(), onError: vi.fn() });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
const jsonRequest = fetchMock.mock.calls[0]?.[1];
|
||||
expect(jsonRequest?.body).toBe(JSON.stringify({ content: log }));
|
||||
expect(JSON.parse(String(jsonRequest?.body)).content).toBe(log);
|
||||
|
||||
streamChatResponse("s-1", log, { onDone: vi.fn(), onError: vi.fn() }, [new File(["file"], "note.txt")]);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
const multipartBody = fetchMock.mock.calls[1]?.[1]?.body as FormData;
|
||||
expect(multipartBody).toBeInstanceOf(FormData);
|
||||
expect(multipartBody.get("content")).toBe(log);
|
||||
});
|
||||
|
||||
it("serializes replacement identity in JSON and multipart requests", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"m-1\"}\n\n"]), { status: 200 }),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { saveFileContent, saveWorkspaceFileContent } from "../projects/workspace-files";
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:LargeTextPayloads 2026-08-21-04:35:
|
||||
* File-editor clients must keep canonical JSON serialization so the server's finite escaped-file
|
||||
* envelope covers every shared editor without introducing client-specific truncation.
|
||||
*/
|
||||
describe("workspace file save API", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("serializes large workspace and compatibility task-file content exactly", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true, mtime: "now", size: 1 }));
|
||||
const content = "2026-08-21T04:35:00Z INFO repeated file log\n".repeat(3_000);
|
||||
|
||||
await saveWorkspaceFileContent("project", "logs/large.log", content, "project-1");
|
||||
await saveFileContent("task-1", "logs/large.log", content, "project-1");
|
||||
|
||||
const [workspaceUrl, workspaceInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(workspaceUrl).toContain("/api/files/logs%2Flarge.log?workspace=project&projectId=project-1");
|
||||
expect(workspaceInit.body).toBe(JSON.stringify({ content }));
|
||||
const [taskUrl, taskInit] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||
expect(taskUrl).toContain("/api/tasks/task-1/files/logs%2Flarge.log?projectId=project-1");
|
||||
expect(taskInit.body).toBe(JSON.stringify({ content }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Settings, TaskStore } from "@fusion/core";
|
||||
|
||||
const scopedChatManager = vi.hoisted(() => ({ current: undefined as never }));
|
||||
vi.mock("../chat-project-services.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../chat-project-services.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getOrCreateScopedChatManager: () => scopedChatManager.current,
|
||||
createProjectScopedChatManager: async () => scopedChatManager.current,
|
||||
};
|
||||
});
|
||||
|
||||
import { createServer } from "../server.js";
|
||||
import { MAX_FILE_SIZE } from "../file-service.js";
|
||||
import { chatStreamManager } from "../chat.js";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
const LARGE_LOG = "2026-08-21T04:35:00Z INFO repeated log line\n".repeat(3_000).trimEnd();
|
||||
const largeJson = (content = LARGE_LOG) => JSON.stringify({ content });
|
||||
|
||||
/** Minimal production-stack store: route tests intentionally use createServer, not a router. */
|
||||
class LargeTextParserStore extends EventEmitter {
|
||||
constructor(private readonly rootDir: string) { super(); }
|
||||
getRootDir() { return this.rootDir; }
|
||||
getFusionDir() { return join(this.rootDir, ".fusion"); }
|
||||
getSettings = vi.fn(async (): Promise<Settings> => ({} as Settings));
|
||||
getSettingsFast = this.getSettings;
|
||||
getGlobalSettingsStore = () => ({ getSettings: async () => ({}) });
|
||||
getAsyncLayer = vi.fn(() => ({ db: { update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn(async () => []) })) })) })) } }));
|
||||
getProjectScopedPluginMcpServers = vi.fn().mockResolvedValue([]);
|
||||
getTask = vi.fn(async () => ({ worktree: this.rootDir }));
|
||||
getTaskWorkflowSelection = vi.fn();
|
||||
getWorkflowDefinition = vi.fn(async () => undefined);
|
||||
getWorkflowSettingValues = vi.fn(() => ({}));
|
||||
getWorkflowSettingsProjectId = vi.fn(() => "default");
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
chatStreamManager.reset();
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function app() {
|
||||
const root = await mkdtemp(join(tmpdir(), "fusion-large-text-"));
|
||||
roots.push(root);
|
||||
const store = new LargeTextParserStore(root);
|
||||
const chatStore = {
|
||||
getSession: vi.fn(async (id: string) => {
|
||||
if (id === "session-1") return { id, agentId: "__fn_agent__", projectId: null };
|
||||
if (id === "planner-session") return { id, agentId: "task-planner:task-1", projectId: null };
|
||||
return undefined;
|
||||
}),
|
||||
getRoom: vi.fn(async (id: string) => id === "room-1" ? { id, projectId: null } : undefined),
|
||||
};
|
||||
const chatManager = {
|
||||
prepareReplacement: vi.fn(async () => ({ generationId: 1 })),
|
||||
beginGeneration: vi.fn(() => ({ generationId: 2, abortController: new AbortController() })),
|
||||
isGenerating: vi.fn(() => false),
|
||||
sendMessage: vi.fn(async (sessionId: string, content: string, _provider: unknown, _model: unknown, _attachments: unknown, options: { generationId?: number }) => {
|
||||
chatStreamManager.broadcast(sessionId, { type: "done", data: { messageId: "assistant-1" } }, { generationId: options.generationId });
|
||||
}),
|
||||
sendRoomMessage: vi.fn(async (_roomId: string, content: string) => ({ userMessage: { id: "room-message-1", content } })),
|
||||
};
|
||||
scopedChatManager.current = chatManager as never;
|
||||
return {
|
||||
root,
|
||||
chatManager,
|
||||
app: createServer(store as unknown as TaskStore, {
|
||||
noAuth: true,
|
||||
chatStore: chatStore as never,
|
||||
chatManager: chatManager as never,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:LargeTextPayloads 2026-08-21-04:35:
|
||||
* These requests boot the complete server middleware stack because registrar tests cannot prove
|
||||
* that the central 100 KiB parser admits only the finite chat and file-save envelopes.
|
||||
*/
|
||||
describe("large-text body parser integration", () => {
|
||||
it("delivers an exact over-100-KiB log once through direct, planner, and room chat", async () => {
|
||||
const server = await app();
|
||||
const body = largeJson();
|
||||
expect(Buffer.byteLength(body)).toBeGreaterThan(100 * 1024);
|
||||
expect(Buffer.byteLength(body)).toBeLessThanOrEqual(2 * 1024 * 1024);
|
||||
|
||||
const direct = await request(server.app, "POST", "/api/chat/sessions/session-1/messages", body, { "content-type": "application/json" });
|
||||
const planner = await request(server.app, "POST", "/api/chat/sessions/planner-session/messages", JSON.stringify({ content: LARGE_LOG, taskId: "task-1" }), { "content-type": "application/json" });
|
||||
const room = await request(server.app, "POST", "/api/chat/rooms/room-1/messages/", body, { "content-type": "application/json" });
|
||||
|
||||
expect(direct.status).toBe(200);
|
||||
expect(planner.status).toBe(200);
|
||||
expect(room.status).toBe(201);
|
||||
expect(server.chatManager.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(server.chatManager.sendMessage).toHaveBeenNthCalledWith(1, "session-1", LARGE_LOG, undefined, undefined, undefined, { generationId: 2 });
|
||||
expect(server.chatManager.sendMessage).toHaveBeenNthCalledWith(2, "planner-session", LARGE_LOG, undefined, undefined, undefined, { generationId: 2 });
|
||||
expect(server.chatManager.sendRoomMessage).toHaveBeenCalledTimes(1);
|
||||
expect(server.chatManager.sendRoomMessage).toHaveBeenCalledWith("room-1", LARGE_LOG, undefined);
|
||||
expect((room.body as { message: { content: string } }).message.content).toBe(LARGE_LOG);
|
||||
});
|
||||
|
||||
it("persists over-100-KiB workspace saves exactly, including encoded paths and operation-named files", async () => {
|
||||
const server = await app();
|
||||
const body = largeJson();
|
||||
await mkdir(join(server.root, "src"));
|
||||
const encodedPath = await request(server.app, "POST", "/api/files/src%2Fcopy?workspace=project", body, { "content-type": "application/json" });
|
||||
const operationNamedFile = await request(server.app, "POST", "/api/files/copy?workspace=project", body, { "content-type": "application/json" });
|
||||
|
||||
expect(encodedPath.status).toBe(200);
|
||||
expect(operationNamedFile.status).toBe(200);
|
||||
expect(await readFile(join(server.root, "src", "copy"), "utf8")).toBe(LARGE_LOG);
|
||||
expect(await readFile(join(server.root, "copy"), "utf8")).toBe(LARGE_LOG);
|
||||
});
|
||||
|
||||
it("persists the worst-case escaped 1 MiB content through workspace and task saves", async () => {
|
||||
const server = await app();
|
||||
const content = "\0".repeat(MAX_FILE_SIZE);
|
||||
const body = JSON.stringify({ content });
|
||||
const fileLimit = 6 * MAX_FILE_SIZE + 1024;
|
||||
expect(Buffer.byteLength(content, "utf8")).toBe(MAX_FILE_SIZE);
|
||||
expect(Buffer.byteLength(body)).toBeGreaterThan(2 * 1024 * 1024);
|
||||
expect(Buffer.byteLength(body)).toBeLessThanOrEqual(fileLimit);
|
||||
|
||||
const workspace = await request(server.app, "POST", "/api/files/escaped.txt?workspace=project", body, { "content-type": "application/json" });
|
||||
const taskFile = await request(server.app, "POST", "/api/tasks/task-1/files/escaped-task.txt", body, { "content-type": "application/json" });
|
||||
expect(workspace.status).toBe(200);
|
||||
expect(taskFile.status).toBe(200);
|
||||
expect(await readFile(join(server.root, "escaped.txt"))).toEqual(Buffer.from(content));
|
||||
expect(await readFile(join(server.root, "escaped-task.txt"))).toEqual(Buffer.from(content));
|
||||
});
|
||||
|
||||
it("keeps the 1 MiB file-service cap and finite transport envelope separate", async () => {
|
||||
const server = await app();
|
||||
const applicationTooLarge = "x".repeat(MAX_FILE_SIZE + 1);
|
||||
const applicationBody = JSON.stringify({ content: applicationTooLarge });
|
||||
const transportLimit = 6 * MAX_FILE_SIZE + 1024;
|
||||
expect(Buffer.byteLength(applicationBody)).toBeLessThanOrEqual(transportLimit);
|
||||
|
||||
const applicationResponse = await request(server.app, "POST", "/api/files/too-large.txt?workspace=project", applicationBody, { "content-type": "application/json" });
|
||||
expect(applicationResponse.status).toBe(413);
|
||||
|
||||
const transportBody = JSON.stringify({ content: "", ignoredPadding: "x".repeat(transportLimit) });
|
||||
expect(Buffer.byteLength(transportBody)).toBeGreaterThan(transportLimit);
|
||||
const transportResponse = await request(server.app, "POST", "/api/files/transport-too-large.txt?workspace=project", transportBody, { "content-type": "application/json" });
|
||||
expect(transportResponse.status).toBe(413);
|
||||
expect(transportResponse.body).toEqual({ error: "payload-too-large" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/api/files/mkdir",
|
||||
"/api/files/mkdir/",
|
||||
"/api/files/src%2Findex.ts/copy",
|
||||
"/api/files/src%2Findex.ts/move/",
|
||||
"/api/files/src%2Findex.ts/delete",
|
||||
"/api/files/src%2Findex.ts/rename/",
|
||||
"/api/files",
|
||||
"/api/files-extra/save",
|
||||
"/api/chat/sessions/session-1/messages-extra",
|
||||
])("keeps %s on the default 100-KiB parser", async (path) => {
|
||||
const server = await app();
|
||||
const response = await request(server.app, "POST", path, largeJson(), { "content-type": "application/json" });
|
||||
expect(response.status).toBe(413);
|
||||
expect(response.body).toEqual({ error: "payload-too-large" });
|
||||
});
|
||||
|
||||
it("keeps non-POST chat and file lookalikes on the default parser", async () => {
|
||||
const server = await app();
|
||||
for (const path of ["/api/chat/sessions/session-1/messages", "/api/files/large.txt"]) {
|
||||
const response = await request(server.app, "PUT", path, largeJson(), { "content-type": "application/json" });
|
||||
expect(response.status).toBe(413);
|
||||
expect(response.body).toEqual({ error: "payload-too-large" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oversized replacement chat bodies before generation or rewind work", async () => {
|
||||
const server = await app();
|
||||
const body = JSON.stringify({ content: "x".repeat(2 * 1024 * 1024), replacementMessageId: "message-1" });
|
||||
expect(Buffer.byteLength(body)).toBeGreaterThan(2 * 1024 * 1024);
|
||||
const response = await request(server.app, "POST", "/api/chat/sessions/session-1/messages", body, { "content-type": "application/json" });
|
||||
expect(response.status).toBe(413);
|
||||
expect(response.body).toEqual({ error: "payload-too-large" });
|
||||
expect(server.chatManager.prepareReplacement).not.toHaveBeenCalled();
|
||||
expect(server.chatManager.beginGeneration).not.toHaveBeenCalled();
|
||||
expect(server.chatManager.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -180,3 +180,24 @@ return 404. Download returns 202 with queued/downloading state; poll status for
|
||||
|
||||
`server.ts` excludes only `/api/voice/transcribe` from its global 100 KiB JSON parser so the
|
||||
route's 2 MiB parser can return JSON 413/400 errors; other routes retain raw-body HMAC capture.
|
||||
|
||||
## Large text JSON parsing
|
||||
|
||||
`server.ts` preserves raw bodies and selects a finite parser before metrics, authentication, and
|
||||
all API registrars. The default remains 100 KiB. Only `POST /api/chat/sessions/:id/messages` and
|
||||
`POST /api/chat/rooms/:id/messages` (including optional trailing slashes) receive a 2 MiB JSON
|
||||
limit. Multipart requests on those paths are not parsed by `express.json()` and continue to Multer.
|
||||
|
||||
Only non-empty `POST /api/tasks/:id/files/{*filepath}` and generic
|
||||
`POST /api/files/{*filepath}` saves receive `6 * MAX_FILE_SIZE + 1024` bytes (6,292,480 bytes).
|
||||
A supported 1 MiB UTF-8 string can expand to six bytes per control character in canonical JSON;
|
||||
the extra 1 KiB covers object framing. `/api/files/mkdir` and literal terminal `/copy`, `/move`,
|
||||
`/delete`, and `/rename` operation paths retain 100 KiB, including optional trailing slashes. The
|
||||
selector uses query-free, undecoded `req.path`, so an encoded filepath such as `src%2Fcopy` remains
|
||||
a generic save rather than an operation.
|
||||
|
||||
The roughly 6 MiB file envelope is a deliberate exact-route trade-off: Express warns that larger
|
||||
bodies consume memory and can add latency. It is not derived from model context windows, because
|
||||
HTTP parsing precedes model selection, bytes are not tokens, and model context also includes
|
||||
history, system/tool input, reasoning, and output. GitHub raw webhook parsing stays first;
|
||||
Voice keeps its route-owned 2 MiB parser and Planning keeps its route-owned 5 MiB parser.
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
setOnProjectFirstCreated,
|
||||
} from "./project-store-resolver.js";
|
||||
import { getOrCreateScopedChatStore } from "./chat-project-services.js";
|
||||
import { MAX_FILE_SIZE } from "./file-service.js";
|
||||
import { TerminalViewportRegistry } from "./terminal-viewport.js";
|
||||
import { getTerminalService, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
@@ -1018,13 +1019,39 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
};
|
||||
const jsonParser = express.json({ verify: preserveRawBody });
|
||||
const planningImageCaptureParser = express.json({ limit: "5mb", verify: preserveRawBody });
|
||||
const chatMessageParser = express.json({ limit: 2 * 1024 * 1024, verify: preserveRawBody });
|
||||
const fileSaveParser = express.json({ limit: 6 * MAX_FILE_SIZE + 1024, verify: preserveRawBody });
|
||||
|
||||
/*
|
||||
FNXC:LargeTextPayloads 2026-08-21-04:35:
|
||||
Large pasted logs must reach only production chat-message endpoints within a finite 2 MiB JSON
|
||||
envelope, while generic workspace and task-file saves receive 6 * MAX_FILE_SIZE + 1 KiB. A
|
||||
supported 1 MiB UTF-8 file can serialize each control byte as six JSON bytes; 1 KiB covers the
|
||||
canonical object framing. Express warns that large bodies increase memory and latency, so the
|
||||
approximately 6 MiB parser is limited to exact save routes: mkdir and literal copy/move/delete/
|
||||
rename operations retain the 100 KiB default. Model context windows cannot define HTTP bytes:
|
||||
parsing precedes model resolution, bytes are not tokens, and context is shared with history,
|
||||
system/tool input, reasoning, and output.
|
||||
*/
|
||||
const isChatMessagePath = (path: string): boolean =>
|
||||
/^\/api\/chat\/(?:sessions|rooms)\/[^/]+\/messages\/?$/.test(path);
|
||||
const isTaskFileSavePath = (path: string): boolean =>
|
||||
/^\/api\/tasks\/[^/]+\/files\/.+\/?$/.test(path);
|
||||
const isWorkspaceFileSavePath = (path: string): boolean => {
|
||||
if (!/^\/api\/files\/.+/.test(path) || /^\/api\/files\/mkdir\/?$/.test(path)) return false;
|
||||
return !/^\/api\/files\/.+\/(?:copy|move|delete|rename)\/?$/.test(path);
|
||||
};
|
||||
app.use((req, res, next) => {
|
||||
// Express treats trailing slashes as equivalent, so parser boundaries must do the same;
|
||||
// no broader prefix is exempted from the global rawBody-preserving parser.
|
||||
if (req.path === "/api/voice/transcribe" || req.path === "/api/voice/transcribe/") return next();
|
||||
const parser = req.path === "/api/planning/start-streaming" || req.path === "/api/planning/start-streaming/"
|
||||
? planningImageCaptureParser
|
||||
: jsonParser;
|
||||
: req.method === "POST" && isChatMessagePath(req.path)
|
||||
? chatMessageParser
|
||||
: req.method === "POST" && (isTaskFileSavePath(req.path) || isWorkspaceFileSavePath(req.path))
|
||||
? fileSaveParser
|
||||
: jsonParser;
|
||||
return parser(req, res, (error) => {
|
||||
// Keep the established global and route-specific size rejections observable as 413 instead
|
||||
// of allowing Express's parser error to fall through to the generic 500 handler.
|
||||
|
||||
Reference in New Issue
Block a user