fix(design): bound requests and prevent mutation replay
This commit is contained in:
@@ -27,7 +27,12 @@ import type {
|
||||
import WebSocket from 'ws';
|
||||
import { designAssetContentPath } from '../../shared/image-workspace';
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import {
|
||||
fetchWithDeadline,
|
||||
proxyAwareFetch,
|
||||
RequestDeadlineExceededError,
|
||||
runWithDeadline,
|
||||
} from '../utils/proxy-fetch';
|
||||
import { getValidWorksSquareAccessToken } from '../services/works-square-session';
|
||||
import {
|
||||
DesignWorkspaceModuleError,
|
||||
@@ -169,6 +174,7 @@ type WorksSquareDesignWorkspaceOptions = {
|
||||
apiBaseUrl?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
webSocketFactory?: AgentWebSocketFactory;
|
||||
requestTimeoutMs?: number;
|
||||
};
|
||||
|
||||
type ServerAgentStreamTicket = {
|
||||
@@ -269,6 +275,7 @@ type AgentCommandChannel = {
|
||||
};
|
||||
|
||||
const AGENT_WEBSOCKET_OPEN = 1;
|
||||
const DESIGN_WORKSPACE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const AGENT_WEBSOCKET_OPEN_TIMEOUT_MS = 10_000;
|
||||
const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000;
|
||||
const AGENT_COMMAND_ACK_TIMEOUT_MS = 5_000;
|
||||
@@ -850,10 +857,19 @@ function isStaleAgentSessionError(error: unknown): error is DesignWorkspaceModul
|
||||
|| error.code === 'agent_session_closed');
|
||||
}
|
||||
|
||||
function designRequestTimeoutError(): DesignWorkspaceModuleError {
|
||||
return new DesignWorkspaceModuleError(
|
||||
504,
|
||||
'DESIGN_WORKSPACE_REQUEST_TIMEOUT',
|
||||
'AI 设计服务响应超时,请重试',
|
||||
);
|
||||
}
|
||||
|
||||
export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
private readonly apiBaseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly webSocketFactory: AgentWebSocketFactory;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly conversationSessionIds = new Map<string, string>();
|
||||
private readonly eventSubscriptionClosers = new Map<string, Set<() => void>>();
|
||||
private readonly activeRunEventStreams = new Map<string, number>();
|
||||
@@ -868,6 +884,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
|
||||
this.webSocketFactory = options.webSocketFactory ?? defaultAgentWebSocketFactory;
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? DESIGN_WORKSPACE_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<DesignWorkspaceBootstrap> {
|
||||
@@ -1505,59 +1522,89 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
}
|
||||
|
||||
private async requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await this.authorizedFetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init.body && !(init.body instanceof FormData)
|
||||
? { 'Content-Type': 'application/json' }
|
||||
: {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) {
|
||||
const detail = asErrorDetail(payload);
|
||||
const code = typeof detail.code === 'string'
|
||||
? detail.code
|
||||
: 'DESIGN_WORKSPACE_REQUEST_FAILED';
|
||||
throw new DesignWorkspaceModuleError(
|
||||
response.status,
|
||||
code,
|
||||
userFacingErrorMessage(code),
|
||||
);
|
||||
try {
|
||||
return await runWithDeadline(async (signal) => {
|
||||
const response = await this.authorizedFetch(path, {
|
||||
...init,
|
||||
signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init.body && !(init.body instanceof FormData)
|
||||
? { 'Content-Type': 'application/json' }
|
||||
: {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) {
|
||||
const detail = asErrorDetail(payload);
|
||||
const code = typeof detail.code === 'string'
|
||||
? detail.code
|
||||
: 'DESIGN_WORKSPACE_REQUEST_FAILED';
|
||||
throw new DesignWorkspaceModuleError(
|
||||
response.status,
|
||||
code,
|
||||
userFacingErrorMessage(code),
|
||||
);
|
||||
}
|
||||
return payload as T;
|
||||
}, this.requestTimeoutMs, init.signal);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestDeadlineExceededError) {
|
||||
throw designRequestTimeoutError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
private async authorizedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
let token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl });
|
||||
if (!token) {
|
||||
throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计');
|
||||
}
|
||||
try {
|
||||
let token = await getValidWorksSquareAccessToken({
|
||||
fetchImpl: this.fetchImpl,
|
||||
requestTimeoutMs: this.requestTimeoutMs,
|
||||
});
|
||||
if (!token) {
|
||||
throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计');
|
||||
}
|
||||
|
||||
let response = await this.fetchWithToken(path, token, init);
|
||||
if (response.status !== 401) return response;
|
||||
let response = await this.fetchWithToken(path, token, init);
|
||||
if (response.status !== 401) return response;
|
||||
|
||||
token = await getValidWorksSquareAccessToken({
|
||||
fetchImpl: this.fetchImpl,
|
||||
forceRefresh: true,
|
||||
});
|
||||
if (!token) {
|
||||
throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录');
|
||||
token = await getValidWorksSquareAccessToken({
|
||||
fetchImpl: this.fetchImpl,
|
||||
forceRefresh: true,
|
||||
requestTimeoutMs: this.requestTimeoutMs,
|
||||
});
|
||||
if (!token) {
|
||||
throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录');
|
||||
}
|
||||
response = await this.fetchWithToken(path, token, init);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof RequestDeadlineExceededError) {
|
||||
throw designRequestTimeoutError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
response = await this.fetchWithToken(path, token, init);
|
||||
return response;
|
||||
}
|
||||
|
||||
private fetchWithToken(path: string, token: string, init: RequestInit): Promise<Response> {
|
||||
return this.fetchImpl(`${this.apiBaseUrl}${path}`, {
|
||||
const requestInit: RequestInit = {
|
||||
...init,
|
||||
headers: {
|
||||
...init.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
if (init.signal) {
|
||||
return this.fetchImpl(`${this.apiBaseUrl}${path}`, requestInit);
|
||||
}
|
||||
return fetchWithDeadline(
|
||||
this.fetchImpl,
|
||||
`${this.apiBaseUrl}${path}`,
|
||||
requestInit,
|
||||
this.requestTimeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
private conversationKey(workspaceId: string, conversationId: string): string {
|
||||
|
||||
Reference in New Issue
Block a user