@langgraph-js/sdk
Version:
The UI SDK for LangGraph - seamlessly integrate your AI agents with frontend interfaces
177 lines (146 loc) • 5.51 kB
text/typescript
/** copied from https://github.com/langchain-ai/langgraphjs/tree/main/libs/sdk/src/utils */
const CR = "\r".charCodeAt(0);
const LF = "\n".charCodeAt(0);
const NULL = "\0".charCodeAt(0);
const COLON = ":".charCodeAt(0);
const SPACE = " ".charCodeAt(0);
const TRAILING_NEWLINE = [CR, LF];
export function BytesLineDecoder() {
let buffer: Uint8Array[] = [];
let trailingCr = false;
return new TransformStream<Uint8Array, Uint8Array>({
start() {
buffer = [];
trailingCr = false;
},
transform(chunk, controller) {
// See https://docs.python.org/3/glossary.html#term-universal-newlines
let text = chunk;
// Handle trailing CR from previous chunk
if (trailingCr) {
text = joinArrays([[CR], text]);
trailingCr = false;
}
// Check for trailing CR in current chunk
if (text.length > 0 && text.at(-1) === CR) {
trailingCr = true;
text = text.subarray(0, -1);
}
if (!text.length) return;
const trailingNewline = TRAILING_NEWLINE.includes(text.at(-1)!);
const lastIdx = text.length - 1;
const { lines } = text.reduce<{ lines: Uint8Array[]; from: number }>(
(acc, cur, idx) => {
if (acc.from > idx) return acc;
if (cur === CR || cur === LF) {
acc.lines.push(text.subarray(acc.from, idx));
if (cur === CR && text[idx + 1] === LF) {
acc.from = idx + 2;
} else {
acc.from = idx + 1;
}
}
if (idx === lastIdx && acc.from <= lastIdx) {
acc.lines.push(text.subarray(acc.from));
}
return acc;
},
{ lines: [], from: 0 }
);
if (lines.length === 1 && !trailingNewline) {
buffer.push(lines[0]);
return;
}
if (buffer.length) {
// Include existing buffer in first line
buffer.push(lines[0]);
lines[0] = joinArrays(buffer);
buffer = [];
}
if (!trailingNewline) {
// If the last segment is not newline terminated,
// buffer it for the next chunk
if (lines.length) buffer = [lines.pop()!];
}
// Enqueue complete lines
for (const line of lines) {
controller.enqueue(line);
}
},
flush(controller) {
if (buffer.length) {
controller.enqueue(joinArrays(buffer));
}
},
});
}
interface StreamPart {
id: string | undefined;
event: string;
data: unknown;
}
export function SSEDecoder() {
let event = "";
let data: Uint8Array[] = [];
let lastEventId = "";
let retry: number | null = null;
const decoder = new TextDecoder();
return new TransformStream<Uint8Array, StreamPart>({
transform(chunk, controller) {
// Handle empty line case
if (!chunk.length) {
if (!event && !data.length && !lastEventId && retry == null) return;
const sse = {
id: lastEventId || undefined,
event,
data: data.length ? decodeArraysToJson(decoder, data) : null,
};
// NOTE: as per the SSE spec, do not reset lastEventId
event = "";
data = [];
retry = null;
controller.enqueue(sse);
return;
}
// Ignore comments
if (chunk[0] === COLON) return;
const sepIdx = chunk.indexOf(COLON);
if (sepIdx === -1) return;
const fieldName = decoder.decode(chunk.subarray(0, sepIdx));
let value = chunk.subarray(sepIdx + 1);
if (value[0] === SPACE) value = value.subarray(1);
if (fieldName === "event") {
event = decoder.decode(value);
} else if (fieldName === "data") {
data.push(value);
} else if (fieldName === "id") {
if (value.indexOf(NULL) === -1) lastEventId = decoder.decode(value);
} else if (fieldName === "retry") {
const retryNum = Number.parseInt(decoder.decode(value), 10);
if (!Number.isNaN(retryNum)) retry = retryNum;
}
},
flush(controller) {
if (event) {
controller.enqueue({
id: lastEventId || undefined,
event,
data: data.length ? decodeArraysToJson(decoder, data) : null,
});
}
},
});
}
function joinArrays(data: ArrayLike<number>[]) {
const totalLength = data.reduce((acc, curr) => acc + curr.length, 0);
const merged = new Uint8Array(totalLength);
let offset = 0;
for (const c of data) {
merged.set(c, offset);
offset += c.length;
}
return merged;
}
function decodeArraysToJson(decoder: TextDecoder, data: ArrayLike<number>[]) {
return JSON.parse(decoder.decode(joinArrays(data)));
}