/* __LC_ALLOW_ENTRYPOINT_SIDE_EFFECTS__ */
'use client';
import type { Command, Config } from '@langchain/langgraph-sdk';
import type {
BagTemplate,
GetConfigurableType,
GetUpdateType,
UseStreamTransport,
} from './types';
export type IpcRendererLike = {
on: (
channel: string,
listener: (event: unknown, payload: unknown) => void,
) => void;
off?: (
channel: string,
listener: (event: unknown, payload: unknown) => void,
) => void;
send?: (channel: string, payload: unknown) => void;
invoke?: (channel: string, payload: unknown) => Promise;
};
export type IpcStreamTransportOptions = {
/**
* Channel to request starting a stream.
* The main process should start producing events on the provided event channel.
*/
requestChannel: string;
/**
* Base name for the dedicated per-run event channel.
* The renderer will listen on `${eventsBaseChannel}:${runId}`.
*/
eventsBaseChannel: string;
/**
* Optional channel to request cancellation of an in-flight stream.
* The renderer will send `{ runId }` when the AbortSignal triggers.
*/
cancelChannel?: string;
/**
* IPC surface exposed from preload.
*/
ipc: IpcRendererLike;
/**
* When using `send` (fire-and-forget), set a sentinel event name that indicates
* completion of the stream so the generator can finish and clean up.
* If `invoke` is available, this is not required (completion is inferred when
* the invoke handler resolves).
* Defaults to `"__complete__"`.
*/
completeEventName?: string;
/**
* Optional callback when thread metadata is received
*/
onThreadId?: (threadId: string) => void;
};
/**
* IPC transport for useStreamCustom that streams events over Electron IPC instead of HTTP/SSE.
*
* Expected main-process behavior:
* - Handle `requestChannel` with payload `{ runId, eventChannel, input, context, command, config }`
* - Emit stream events on `eventChannel` with shape `{ event, data, id? }`
* - Optionally handle `cancelChannel` with payload `{ runId }` to cancel
* - If using fire-and-forget `send`, emit a final sentinel event named `completeEventName`
* (default `"__complete__"`) so the generator can complete.
*
* This class does not depend on Electron directly; pass a preload-exposed `ipc` adapter.
*/
export class IpcStreamTransport<
StateType extends Record = Record,
Bag extends BagTemplate = BagTemplate,
> implements UseStreamTransport
{
// eslint-disable-next-line no-useless-constructor, no-empty-function
constructor(private readonly options: IpcStreamTransportOptions) {}
async stream(payload: {
input: GetUpdateType | null | undefined;
context: GetConfigurableType | undefined;
command: Command | undefined;
config: (Config & { configurable?: GetConfigurableType }) | undefined;
signal: AbortSignal;
}): Promise> {
const runId = crypto.randomUUID();
const eventChannel = `${this.options.eventsBaseChannel}:${runId}`;
const completeEventName = this.options.completeEventName ?? '__complete__';
// Internal event queue for the async generator bridge
const queue: Array<{ id?: string; event: string; data: unknown }> = [];
let nextResolver:
| ((
value: IteratorResult<{ id?: string; event: string; data: unknown }>,
) => void)
| null = null;
let done = false;
const resolveNextIfWaiting = () => {
if (nextResolver && queue.length) {
const r = nextResolver;
nextResolver = null;
r({ value: queue.shift()!, done: false });
}
};
const pushEvent = (evt: { id?: string; event: string; data: unknown }) => {
queue.push(evt);
resolveNextIfWaiting();
};
let removeListener = () => {};
const finish = () => {
if (done) return;
done = true;
try {
removeListener();
} catch {
// ignore
}
if (nextResolver) {
const r = nextResolver;
nextResolver = null;
r({ value: undefined as never, done: true });
}
};
const listener = (_ipcEvent: unknown, raw: unknown) => {
// Expecting payload shape `{ event: string, data: unknown, id?: string }`
const msg = raw as { id?: string; event?: unknown; data?: unknown };
if (!msg || typeof msg.event !== 'string') return;
// Intercept completion sentinel for fire-and-forget mode
if (msg.event === completeEventName) {
finish();
return;
}
// Capture thread_id from thread or metadata events
if (this.options.onThreadId) {
if (msg.event === 'thread' && msg.data) {
const threadId = (msg.data as { thread_id?: string }).thread_id;
if (threadId) {
this.options.onThreadId(threadId);
}
} else if (msg.event === 'metadata' && msg.data) {
const threadId = (msg.data as { thread_id?: string }).thread_id;
if (threadId) {
this.options.onThreadId(threadId);
}
}
}
pushEvent({ id: msg.id, event: msg.event, data: msg.data });
};
const getErrorName = (e: unknown, fallback: string): string => {
if (e && typeof e === 'object' && 'name' in e) {
const n = (e as { name?: unknown }).name;
if (typeof n === 'string') return n;
}
return fallback;
};
const getErrorMessage = (e: unknown, fallback: string): string => {
if (e && typeof e === 'object' && 'message' in e) {
const m = (e as { message?: unknown }).message;
if (typeof m === 'string') return m;
}
return fallback;
};
const abort = () => {
if (done) return;
// Request cancellation upstream first
if (this.options.cancelChannel) {
const cancelPayload = { runId };
if (this.options.ipc.invoke) {
// eslint-disable-next-line no-void
void this.options.ipc.invoke(
this.options.cancelChannel,
cancelPayload,
);
} else {
this.options.ipc.send?.(this.options.cancelChannel, cancelPayload);
}
}
finish();
};
if (payload.signal.aborted) {
abort();
} else {
payload.signal.addEventListener('abort', abort, { once: true });
}
// Subscribe to the dedicated event channel
this.options.ipc.on(eventChannel, listener);
removeListener = () => this.options.ipc.off?.(eventChannel, listener);
// Start the stream in main: prefer invoke so we can infer completion when it resolves
const startPayload = {
runId,
eventChannel,
input: payload.input,
context: payload.context,
command: payload.command,
config: payload.config,
};
try {
if (this.options.ipc.invoke) {
// eslint-disable-next-line no-void
void this.options.ipc
.invoke(this.options.requestChannel, startPayload)
.catch((error: unknown) => {
// Surface as stream error event to consumers
pushEvent({
event: 'error',
data: {
error: getErrorName(error, 'InvokeError'),
message: getErrorMessage(error, 'Stream invocation failed'),
},
});
})
.finally(() => {
// Completion of invoke means the stream is finished
finish();
});
} else {
// Fire-and-forget start; rely on completion sentinel event
this.options.ipc.send?.(this.options.requestChannel, startPayload);
}
} catch (error) {
pushEvent({
event: 'error',
data: {
error: getErrorName(error, 'StartError'),
message: getErrorMessage(error, 'Failed to start stream'),
},
});
// If starting failed synchronously, finish the generator
finish();
}
const waitForNext = () =>
new Promise<
IteratorResult<{ id?: string; event: string; data: unknown }>
>((resolve) => {
nextResolver = resolve;
});
async function* generator() {
try {
// Stream until completion
// eslint-disable-next-line no-constant-condition
while (true) {
if (done) return;
if (queue.length) {
yield queue.shift()!;
// eslint-disable-next-line no-continue
continue;
}
// eslint-disable-next-line no-await-in-loop
const next = await waitForNext();
if (next.done) return;
yield next.value!;
}
} finally {
// Ensure cleanup if consumer breaks early
finish();
}
}
return generator();
}
}
/**
* Minimal usage example (renderer):
*
* const [threadId, setThreadId] = useState(null);
*
* const transport = new IpcStreamTransport({
* ipc: window.electron.ipc, // preload-exposed
* requestChannel: "lg/stream/start",
* eventsBaseChannel: "lg/stream/events",
* cancelChannel: "lg/stream/cancel",
* onThreadId: (newThreadId) => setThreadId(newThreadId),
* // completeEventName: "__complete__", // only for fire-and-forget send mode
* });
*
* const { submit, values, messages, isLoading, stop } = useStream({
* transport,
* messagesKey: "messages",
* initialValues: { messages: [] },
* });
*
* // Use threadId in context when submitting messages
* await submit({ messages: [userMessage] }, { context: { threadId } });
*/