openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
225 lines (224 loc) • 8.65 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js";
import { t as createSubsystemLogger } from "./subsystem-BzXSmsuh.js";
import { i as getRuntimeConfig } from "./io-Gi7-pyU-.js";
import { n as authorizeOperatorScopesForMethod } from "./method-scopes-BtdN3y7s.js";
import { t as loadSessionStore } from "./store-load-C4uq6Lrq.js";
import "./sessions-D6zZvoxX.js";
import { n as onSessionTranscriptUpdate } from "./transcript-events-DTn-thXR.js";
import { a as readRecentSessionMessagesWithStatsAsync, d as readSessionMessagesAsync } from "./session-utils.fs-D_8-X4jl.js";
import { a as resolveSessionTranscriptCandidates } from "./session-transcript-files.fs-CM11t-8l.js";
import { g as resolveGatewaySessionStoreTarget, m as resolveFreshestSessionEntryFromStoreKeys } from "./session-utils-CHNCuY8a.js";
import { a as sendJson, i as sendInvalidRequest, l as setSseHeaders, o as sendMethodNotAllowed } from "./http-common-DunL6val.js";
import { a as getHeader, d as resolveSharedSecretHttpOperatorScopes, n as authorizeScopedGatewayHttpRequestOrReply, r as checkGatewayHttpRequestAuth } from "./http-auth-utils-Du8mRZIw.js";
import "./http-utils-leZWdpma.js";
import { t as DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS } from "./chat-display-projection-Bv2HD4Rr.js";
import { n as buildSessionHistorySnapshot, r as resolveSessionHistoryTailReadOptions, t as SessionHistorySseState } from "./session-history-state-B5plVodV.js";
import { t as resolveTranscriptPathForComparison } from "./session-transcript-path-p3PNGZXC.js";
//#region src/gateway/sessions-history-http.ts
const log = createSubsystemLogger("gateway/sessions-history-sse");
const MAX_SESSION_HISTORY_LIMIT = 1e3;
function resolveSessionHistoryPath(req) {
const match = new URL(req.url ?? "/", "http://localhost").pathname.match(/^\/sessions\/([^/]+)\/history$/);
if (!match) return null;
try {
return normalizeOptionalString(decodeURIComponent(match[1] ?? "")) ?? null;
} catch {
return "";
}
}
function shouldStreamSse(req) {
return normalizeLowercaseStringOrEmpty(getHeader(req, "accept")).includes("text/event-stream");
}
function getRequestUrl(req) {
return new URL(req.url ?? "/", "http://localhost");
}
function resolveLimit(req) {
const raw = getRequestUrl(req).searchParams.get("limit");
if (raw == null || raw.trim() === "") return;
const trimmed = raw.trim();
const value = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
if (!Number.isSafeInteger(value) || value < 1) return 1;
return Math.min(MAX_SESSION_HISTORY_LIMIT, Math.max(1, value));
}
function sseWrite(res, event, payload) {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
}
/** Handle `/sessions/:sessionKey/history` JSON/SSE requests. */
async function handleSessionHistoryHttpRequest(req, res, opts) {
const sessionKey = resolveSessionHistoryPath(req);
if (sessionKey === null) return false;
if (!sessionKey) {
sendInvalidRequest(res, "invalid session key");
return true;
}
if (req.method !== "GET") {
sendMethodNotAllowed(res, "GET");
return true;
}
const authResult = await authorizeScopedGatewayHttpRequestOrReply({
req,
res,
auth: opts.auth,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
operatorMethod: "chat.history",
resolveOperatorScopes: resolveSharedSecretHttpOperatorScopes
});
if (!authResult) return true;
const { cfg } = authResult;
const target = resolveGatewaySessionStoreTarget({
cfg,
key: sessionKey
});
const entry = resolveFreshestSessionEntryFromStoreKeys(loadSessionStore(target.storePath), target.storeKeys);
if (!entry?.sessionId) {
sendJson(res, 404, {
ok: false,
error: {
type: "not_found",
message: `Session not found: ${sessionKey}`
}
});
return true;
}
const limit = resolveLimit(req);
const cursor = normalizeOptionalString(getRequestUrl(req).searchParams.get("cursor"));
const effectiveMaxChars = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS;
const boundedSnapshot = cursor === void 0 && typeof limit === "number" ? await readRecentSessionMessagesWithStatsAsync(entry.sessionId, target.storePath, entry.sessionFile, resolveSessionHistoryTailReadOptions(limit)) : void 0;
const rawSnapshot = boundedSnapshot?.messages ?? (entry?.sessionId ? await readSessionMessagesAsync(entry.sessionId, target.storePath, entry.sessionFile, {
mode: "full",
reason: "session history cursor pagination"
}) : []);
const history = buildSessionHistorySnapshot({
rawMessages: rawSnapshot,
maxChars: effectiveMaxChars,
limit,
cursor,
rawTranscriptSeq: boundedSnapshot?.totalMessages,
totalRawMessages: boundedSnapshot?.totalMessages
}).history;
if (!shouldStreamSse(req)) {
sendJson(res, 200, {
sessionKey: target.canonicalKey,
...history
});
return true;
}
const transcriptCandidates = entry?.sessionId ? new Set(resolveSessionTranscriptCandidates(entry.sessionId, target.storePath, entry.sessionFile, target.agentId).map((candidate) => resolveTranscriptPathForComparison(candidate)).filter((candidate) => typeof candidate === "string")) : /* @__PURE__ */ new Set();
let sentHistory = history;
const sseState = SessionHistorySseState.fromRawSnapshot({
target: {
sessionId: entry.sessionId,
storePath: target.storePath,
sessionFile: entry.sessionFile
},
rawMessages: rawSnapshot,
rawTranscriptSeq: boundedSnapshot?.totalMessages,
totalRawMessages: boundedSnapshot?.totalMessages,
maxChars: effectiveMaxChars,
limit,
cursor
});
sentHistory = sseState.snapshot();
setSseHeaders(res);
res.write("retry: 1000\n\n");
sseWrite(res, "history", {
sessionKey: target.canonicalKey,
...sentHistory
});
let cleanedUp = false;
let streamQueue = Promise.resolve();
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
if (heartbeat) clearInterval(heartbeat);
if (unsubscribe) unsubscribe();
};
const closeStream = () => {
cleanup();
if (!res.writableEnded) res.end();
};
const queueStreamWork = (work) => {
streamQueue = streamQueue.then(async () => {
if (cleanedUp || res.writableEnded) return;
await work();
}).catch((error) => {
log.warn("session history SSE stream work failed; closing stream", { error });
closeStream();
});
};
const isStreamStillAuthorized = async () => {
const cfgLocal = getRuntimeConfig();
const currentRequestAuth = await checkGatewayHttpRequestAuth({
req,
auth: opts.getResolvedAuth?.() ?? opts.auth,
trustedProxies: cfgLocal.gateway?.trustedProxies,
allowRealIpFallback: cfgLocal.gateway?.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
cfg: cfgLocal
});
if (!currentRequestAuth.ok) return false;
return authorizeOperatorScopesForMethod("chat.history", resolveSharedSecretHttpOperatorScopes(req, currentRequestAuth.requestAuth)).allowed;
};
const heartbeat = setInterval(() => {
queueStreamWork(async () => {
if (!await isStreamStillAuthorized()) {
closeStream();
return;
}
if (!res.writableEnded) res.write(": keepalive\n\n");
});
}, 15e3);
const unsubscribe = onSessionTranscriptUpdate((update) => {
if (!entry?.sessionId) return;
const updatePath = resolveTranscriptPathForComparison(update.sessionFile);
if (!updatePath || !transcriptCandidates.has(updatePath)) return;
queueStreamWork(async () => {
if (res.writableEnded) return;
if (!await isStreamStillAuthorized()) {
closeStream();
return;
}
if (update.message !== void 0) {
if (limit === void 0 && cursor === void 0) {
const nextEvent = sseState.appendInlineMessage({
message: update.message,
messageId: update.messageId,
messageSeq: update.messageSeq
});
if (!nextEvent) return;
if (nextEvent.shouldRefresh) {
sentHistory = await sseState.refreshAsync();
sseWrite(res, "history", {
sessionKey: target.canonicalKey,
...sentHistory
});
return;
}
if (nextEvent.message === void 0) return;
sentHistory = sseState.snapshot();
sseWrite(res, "message", {
sessionKey: target.canonicalKey,
message: nextEvent.message,
...typeof update.messageId === "string" ? { messageId: update.messageId } : {},
messageSeq: nextEvent.messageSeq
});
return;
}
}
sentHistory = await sseState.refreshAsync();
sseWrite(res, "history", {
sessionKey: target.canonicalKey,
...sentHistory
});
});
});
req.on("close", cleanup);
res.on("close", cleanup);
res.on("finish", cleanup);
return true;
}
//#endregion
export { handleSessionHistoryHttpRequest };