diff --git a/libs/sdk-js/package.json b/libs/sdk-js/package.json index c6d271dd7..499579f45 100644 --- a/libs/sdk-js/package.json +++ b/libs/sdk-js/package.json @@ -1,6 +1,6 @@ { "name": "@langchain/langgraph-sdk", - "version": "0.0.9", + "version": "0.0.10", "description": "Client library for interacting with the LangGraph API", "type": "module", "packageManager": "yarn@1.22.19", @@ -17,7 +17,6 @@ "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", - "eventsource-parser": "^1.1.2", "p-queue": "^6.6.2", "p-retry": "4", "uuid": "^9.0.0" diff --git a/libs/sdk-js/scripts/create-entrypoints.js b/libs/sdk-js/scripts/create-entrypoints.js index 63ddd2ce9..bde694f58 100644 --- a/libs/sdk-js/scripts/create-entrypoints.js +++ b/libs/sdk-js/scripts/create-entrypoints.js @@ -22,8 +22,8 @@ const generateFiles = () => { const compiledPath = `${relativePath}dist/${value}`; return [ [`${key}.cjs`, `module.exports = require('${compiledPath}.cjs');`], - [`${key}.js`, `export * from '${compiledPath}.mjs'`], - [`${key}.d.ts`, `export * from '${compiledPath}.mjs'`], + [`${key}.js`, `export * from '${compiledPath}.js'`], + [`${key}.d.ts`, `export * from '${compiledPath}.js'`], [`${key}.d.cts`, `export * from '${compiledPath}.cjs'`], ]; }, diff --git a/libs/sdk-js/src/client.mts b/libs/sdk-js/src/client.ts similarity index 99% rename from libs/sdk-js/src/client.mts rename to libs/sdk-js/src/client.ts index 9081749af..76473a531 100644 --- a/libs/sdk-js/src/client.mts +++ b/libs/sdk-js/src/client.ts @@ -10,9 +10,12 @@ import { ThreadState, Cron, } from "./schema.js"; -import { AsyncCaller, AsyncCallerParams } from "./utils/async_caller.mjs"; -import { EventSourceParser, createParser } from "eventsource-parser"; -import { IterableReadableStream } from "./utils/stream.mjs"; +import { AsyncCaller, AsyncCallerParams } from "./utils/async_caller.js"; +import { + EventSourceParser, + createParser, +} from "./utils/eventsource-parser/index.js"; +import { IterableReadableStream } from "./utils/stream.js"; import { RunsCreatePayload, RunsStreamPayload, @@ -20,7 +23,7 @@ import { StreamEvent, CronsCreatePayload, OnConflictBehavior, -} from "./types.mjs"; +} from "./types.js"; interface ClientConfig { apiUrl?: string; diff --git a/libs/sdk-js/src/index.mts b/libs/sdk-js/src/index.ts similarity index 80% rename from libs/sdk-js/src/index.mts rename to libs/sdk-js/src/index.ts index 1198d17d9..793f8d4f7 100644 --- a/libs/sdk-js/src/index.mts +++ b/libs/sdk-js/src/index.ts @@ -1,4 +1,4 @@ -export { Client } from "./client.mjs"; +export { Client } from "./client.js"; export type { Assistant, diff --git a/libs/sdk-js/src/types.mts b/libs/sdk-js/src/types.ts similarity index 100% rename from libs/sdk-js/src/types.mts rename to libs/sdk-js/src/types.ts diff --git a/libs/sdk-js/src/utils/async_caller.mts b/libs/sdk-js/src/utils/async_caller.ts similarity index 100% rename from libs/sdk-js/src/utils/async_caller.mts rename to libs/sdk-js/src/utils/async_caller.ts diff --git a/libs/sdk-js/src/utils/eventsource-parser/LICENSE b/libs/sdk-js/src/utils/eventsource-parser/LICENSE new file mode 100644 index 000000000..ccee9a392 --- /dev/null +++ b/libs/sdk-js/src/utils/eventsource-parser/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Espen Hovlandsdal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/libs/sdk-js/src/utils/eventsource-parser/index.ts b/libs/sdk-js/src/utils/eventsource-parser/index.ts new file mode 100644 index 000000000..d20acf7a1 --- /dev/null +++ b/libs/sdk-js/src/utils/eventsource-parser/index.ts @@ -0,0 +1,11 @@ +// From https://github.com/rexxars/eventsource-parser +// Inlined due to CJS import issues + +export { createParser } from "./parse.js"; +export type { + EventSourceParseCallback, + EventSourceParser, + ParsedEvent, + ParseEvent, + ReconnectInterval, +} from "./types.js"; diff --git a/libs/sdk-js/src/utils/eventsource-parser/parse.ts b/libs/sdk-js/src/utils/eventsource-parser/parse.ts new file mode 100644 index 000000000..d2a141906 --- /dev/null +++ b/libs/sdk-js/src/utils/eventsource-parser/parse.ts @@ -0,0 +1,185 @@ +/** + * EventSource/Server-Sent Events parser + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html + * + * Based on code from the {@link https://github.com/EventSource/eventsource | EventSource module}, + * which is licensed under the MIT license. And copyrighted the EventSource GitHub organisation. + */ +import type { EventSourceParseCallback, EventSourceParser } from "./types.js"; + +/** + * Creates a new EventSource parser. + * + * @param onParse - Callback to invoke when a new event is parsed, or a new reconnection interval + * has been sent from the server + * + * @returns A new EventSource parser, with `parse` and `reset` methods. + * @public + */ +export function createParser( + onParse: EventSourceParseCallback, +): EventSourceParser { + // Processing state + let isFirstChunk: boolean; + let buffer: string; + let startingPosition: number; + let startingFieldLength: number; + + // Event state + let eventId: string | undefined; + let eventName: string | undefined; + let data: string; + + reset(); + return { feed, reset }; + + function reset(): void { + isFirstChunk = true; + buffer = ""; + startingPosition = 0; + startingFieldLength = -1; + + eventId = undefined; + eventName = undefined; + data = ""; + } + + function feed(chunk: string): void { + buffer = buffer ? buffer + chunk : chunk; + + // Strip any UTF8 byte order mark (BOM) at the start of the stream. + // Note that we do not strip any non - UTF8 BOM, as eventsource streams are + // always decoded as UTF8 as per the specification. + if (isFirstChunk && hasBom(buffer)) { + buffer = buffer.slice(BOM.length); + } + + isFirstChunk = false; + + // Set up chunk-specific processing state + const length = buffer.length; + let position = 0; + let discardTrailingNewline = false; + + // Read the current buffer byte by byte + while (position < length) { + // EventSource allows for carriage return + line feed, which means we + // need to ignore a linefeed character if the previous character was a + // carriage return + // @todo refactor to reduce nesting, consider checking previous byte? + // @todo but consider multiple chunks etc + if (discardTrailingNewline) { + if (buffer[position] === "\n") { + ++position; + } + discardTrailingNewline = false; + } + + let lineLength = -1; + let fieldLength = startingFieldLength; + let character: string; + + for ( + let index = startingPosition; + lineLength < 0 && index < length; + ++index + ) { + character = buffer[index]; + if (character === ":" && fieldLength < 0) { + fieldLength = index - position; + } else if (character === "\r") { + discardTrailingNewline = true; + lineLength = index - position; + } else if (character === "\n") { + lineLength = index - position; + } + } + + if (lineLength < 0) { + startingPosition = length - position; + startingFieldLength = fieldLength; + break; + } else { + startingPosition = 0; + startingFieldLength = -1; + } + + parseEventStreamLine(buffer, position, fieldLength, lineLength); + + position += lineLength + 1; + } + + if (position === length) { + // If we consumed the entire buffer to read the event, reset the buffer + buffer = ""; + } else if (position > 0) { + // If there are bytes left to process, set the buffer to the unprocessed + // portion of the buffer only + buffer = buffer.slice(position); + } + } + + function parseEventStreamLine( + lineBuffer: string, + index: number, + fieldLength: number, + lineLength: number, + ) { + if (lineLength === 0) { + // We reached the last line of this event + if (data.length > 0) { + onParse({ + type: "event", + id: eventId, + event: eventName || undefined, + data: data.slice(0, -1), // remove trailing newline + }); + + data = ""; + eventId = undefined; + } + eventName = undefined; + return; + } + + const noValue = fieldLength < 0; + const field = lineBuffer.slice( + index, + index + (noValue ? lineLength : fieldLength), + ); + let step = 0; + + if (noValue) { + step = lineLength; + } else if (lineBuffer[index + fieldLength + 1] === " ") { + step = fieldLength + 2; + } else { + step = fieldLength + 1; + } + + const position = index + step; + const valueLength = lineLength - step; + const value = lineBuffer.slice(position, position + valueLength).toString(); + + if (field === "data") { + data += value ? `${value}\n` : "\n"; + } else if (field === "event") { + eventName = value; + } else if (field === "id" && !value.includes("\u0000")) { + eventId = value; + } else if (field === "retry") { + const retry = parseInt(value, 10); + if (!Number.isNaN(retry)) { + onParse({ type: "reconnect-interval", value: retry }); + } + } + } +} + +const BOM = [239, 187, 191]; + +function hasBom(buffer: string) { + return BOM.every( + (charCode: number, index: number) => buffer.charCodeAt(index) === charCode, + ); +} diff --git a/libs/sdk-js/src/utils/eventsource-parser/stream.ts b/libs/sdk-js/src/utils/eventsource-parser/stream.ts new file mode 100644 index 000000000..ea63bb96d --- /dev/null +++ b/libs/sdk-js/src/utils/eventsource-parser/stream.ts @@ -0,0 +1,38 @@ +import { createParser } from "./parse.js"; +import type { EventSourceParser, ParsedEvent } from "./types.js"; + +/** + * A TransformStream that ingests a stream of strings and produces a stream of ParsedEvents. + * + * @example + * ``` + * const eventStream = + * response.body + * .pipeThrough(new TextDecoderStream()) + * .pipeThrough(new EventSourceParserStream()) + * ``` + * @public + */ +export class EventSourceParserStream extends TransformStream< + string, + ParsedEvent +> { + constructor() { + let parser!: EventSourceParser; + + super({ + start(controller) { + parser = createParser((event: any) => { + if (event.type === "event") { + controller.enqueue(event); + } + }); + }, + transform(chunk) { + parser.feed(chunk); + }, + }); + } +} + +export type { ParsedEvent } from "./types.js"; diff --git a/libs/sdk-js/src/utils/eventsource-parser/types.ts b/libs/sdk-js/src/utils/eventsource-parser/types.ts new file mode 100644 index 000000000..028662cb0 --- /dev/null +++ b/libs/sdk-js/src/utils/eventsource-parser/types.ts @@ -0,0 +1,90 @@ +/** + * EventSource parser instance. + * + * Needs to be reset between reconnections/when switching data source, using the `reset()` method. + * + * @public + */ +export interface EventSourceParser { + /** + * Feeds the parser another chunk. The method _does not_ return a parsed message. + * Instead, if the chunk was a complete message (or completed a previously incomplete message), + * it will invoke the `onParse` callback used to create the parsers. + * + * @param chunk - The chunk to parse. Can be a partial, eg in the case of streaming messages. + * @public + */ + feed(chunk: string): void; + + /** + * Resets the parser state. This is required when you have a new stream of messages - + * for instance in the case of a client being disconnected and reconnecting. + * + * @public + */ + reset(): void; +} + +/** + * A parsed EventSource event + * + * @public + */ +export interface ParsedEvent { + /** + * Differentiates the type from reconnection intervals and other types of messages + * Not to be confused with `event`. + */ + type: "event"; + + /** + * The event type sent from the server. Note that this differs from the browser `EventSource` + * implementation in that browsers will default this to `message`, whereas this parser will + * leave this as `undefined` if not explicitly declared. + */ + event?: string; + + /** + * ID of the message, if any was provided by the server. Can be used by clients to keep the + * last received message ID in sync when reconnecting. + */ + id?: string; + + /** + * The data received for this message + */ + data: string; +} + +/** + * An event emitted from the parser when the server sends a value in the `retry` field, + * indicating how many seconds the client should wait before attempting to reconnect. + * + * @public + */ +export interface ReconnectInterval { + /** + * Differentiates the type from `event` and other types of messages + */ + type: "reconnect-interval"; + + /** + * Number of seconds to wait before reconnecting. Note that the parser does not care about + * this value at all - it only emits the value for clients to use. + */ + value: number; +} + +/** + * The different types of messages the parsed can emit to the `onParse` callback + * + * @public + */ +export type ParseEvent = ParsedEvent | ReconnectInterval; + +/** + * Callback passed as the `onParse` callback to a parser + * + * @public + */ +export type EventSourceParseCallback = (event: ParseEvent) => void; diff --git a/libs/sdk-js/src/utils/stream.mts b/libs/sdk-js/src/utils/stream.ts similarity index 94% rename from libs/sdk-js/src/utils/stream.mts rename to libs/sdk-js/src/utils/stream.ts index c921ddf51..510204992 100644 --- a/libs/sdk-js/src/utils/stream.mts +++ b/libs/sdk-js/src/utils/stream.ts @@ -61,6 +61,12 @@ export class IterableReadableStream throw e; } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Not present in Node 18 types, required in latest Node 22 + async [Symbol.asyncDispose]() { + await this.return(); + } + [Symbol.asyncIterator]() { return this; } diff --git a/libs/sdk-js/tsconfig.json b/libs/sdk-js/tsconfig.json index 850ef87e9..f72c108ed 100644 --- a/libs/sdk-js/tsconfig.json +++ b/libs/sdk-js/tsconfig.json @@ -32,7 +32,7 @@ "includeVersion": true, "typedocOptions": { "entryPoints": [ - "src/client.mts" + "src/client.ts" ], "readme": "none", "out": "docs", diff --git a/libs/sdk-js/yarn.lock b/libs/sdk-js/yarn.lock index b4e6f252a..2937faf64 100644 --- a/libs/sdk-js/yarn.lock +++ b/libs/sdk-js/yarn.lock @@ -537,11 +537,6 @@ eventemitter3@^4.0.4: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== -eventsource-parser@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-1.1.2.tgz#ed6154a4e3dbe7cda9278e5e35d2ffc58b309f89" - integrity sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA== - extend@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"