assistant-stream
Version:
Streaming utilities for AI assistants
281 lines (253 loc) • 8.31 kB
text/typescript
import { DEFAULT_TTL_MS } from "../constants";
import { ResumableStreamError, validateStreamId } from "../errors";
import type {
ResumableStreamEntry,
ResumableStreamAcquireOptions,
ResumableStreamAcquisition,
ResumableStreamLease,
ResumableStreamStatus,
ResumableStreamStore,
} from "../types";
type FinalizeMarker = { kind: "done" } | { kind: "error"; error: string };
type StreamState = {
token: string;
entries: ResumableStreamEntry[];
nextSeq: number;
expiresAt: number;
ttlMs: number;
final: FinalizeMarker | undefined;
waiters: Array<() => void>;
};
const cursorOf = (seq: number): string => seq.toString(36);
const seqFromCursor = (cursor: string): number => {
if (cursor === "") return 0;
const parsed = Number.parseInt(cursor, 36);
return Number.isNaN(parsed) ? 0 : parsed;
};
export type InMemoryResumableStreamStoreOptions = {
readonly defaultTtlMs?: number;
readonly now?: () => number;
readonly maxChunkBytes?: number;
readonly maxEntriesPerStream?: number;
readonly maxStreams?: number;
readonly gcIntervalMs?: number;
};
export function createInMemoryResumableStreamStore(
options: InMemoryResumableStreamStoreOptions = {},
): ResumableStreamStore & { dispose: () => void } {
let nextToken = 0;
const streams = new Map<string, StreamState>();
const defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS;
const now = options.now ?? Date.now;
const maxChunkBytes = options.maxChunkBytes;
const maxEntriesPerStream = options.maxEntriesPerStream;
const maxStreams = options.maxStreams;
// TTL refreshes can leave this lower bound earlier than the next actual expiry.
let nextExpiry = Infinity;
const evictExpired = (): void => {
const t = now();
if (t < nextExpiry) return;
nextExpiry = Infinity;
for (const [id, state] of streams) {
if (state.expiresAt > t) {
nextExpiry = Math.min(nextExpiry, state.expiresAt);
continue;
}
streams.delete(id);
state.final ??= { kind: "error", error: "Stream expired" };
notify(state);
}
};
const notify = (state: StreamState): void => {
const waiters = state.waiters;
state.waiters = [];
for (const wake of waiters) wake();
};
const findStartIndex = (state: StreamState, cursor: string): number => {
if (cursor === "") return 0;
const after = seqFromCursor(cursor);
let lo = 0;
let hi = state.entries.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
const seq = seqFromCursor(state.entries[mid]!.cursor);
if (seq <= after) lo = mid + 1;
else hi = mid;
}
return lo;
};
const waitForUpdate = (
state: StreamState,
signal: AbortSignal,
wakeBy?: number,
): Promise<void> =>
new Promise<void>((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const wake = () => {
if (settled) return;
settled = true;
if (timer !== undefined) clearTimeout(timer);
signal.removeEventListener("abort", wake);
const idx = state.waiters.indexOf(wake);
if (idx !== -1) state.waiters.splice(idx, 1);
resolve();
};
if (signal.aborted) {
wake();
return;
}
state.waiters.push(wake);
signal.addEventListener("abort", wake, { once: true });
if (wakeBy !== undefined) {
if (wakeBy > 0) {
timer = setTimeout(wake, Math.min(wakeBy, 2_147_483_647));
} else {
// already past the deadline; resolve so the caller can re-check
// expiration without waiting for an external notify.
wake();
}
}
});
const requireActive = (
streamId: string,
lease: ResumableStreamLease | undefined,
): StreamState => {
evictExpired();
const state = streams.get(streamId);
if (!state) throw new Error(`Stream not found: ${streamId}`);
if (lease && state.token !== lease.token) {
throw new ResumableStreamError(
"missing",
`Stream superseded by a new acquisition: ${streamId}`,
);
}
if (state.final) {
throw new ResumableStreamError(
"finalized",
`Stream already finalized: ${streamId}`,
);
}
return state;
};
const gcTimer =
options.gcIntervalMs !== undefined
? setInterval(evictExpired, options.gcIntervalMs)
: undefined;
gcTimer?.unref?.();
const acquireLease = async (
streamId: string,
acquireOptions?: ResumableStreamAcquireOptions,
): Promise<ResumableStreamAcquisition> => {
validateStreamId(streamId);
evictExpired();
const existing = streams.get(streamId);
if (existing) return { role: "consumer" };
if (maxStreams !== undefined && streams.size >= maxStreams) {
throw new Error("maxStreams exceeded");
}
const ttlMs = acquireOptions?.ttlMs ?? defaultTtlMs;
const token = String(++nextToken);
const expiresAt = now() + ttlMs;
nextExpiry = Math.min(nextExpiry, expiresAt);
streams.set(streamId, {
token,
entries: [],
nextSeq: 1,
expiresAt,
ttlMs,
final: undefined,
waiters: [],
});
return { role: "producer", lease: { token } };
};
return {
async acquire(streamId, acquireOptions) {
return (await acquireLease(streamId, acquireOptions)).role;
},
acquireLease,
async append(streamId, chunk, lease) {
validateStreamId(streamId);
if (maxChunkBytes !== undefined && chunk.byteLength > maxChunkBytes) {
throw new Error(`Chunk exceeds maxChunkBytes: ${chunk.byteLength}`);
}
const state = requireActive(streamId, lease);
if (
maxEntriesPerStream !== undefined &&
state.entries.length >= maxEntriesPerStream
) {
throw new Error(`Stream exceeded maxEntriesPerStream: ${streamId}`);
}
const seq = state.nextSeq;
state.nextSeq += 1;
state.entries.push({
cursor: cursorOf(seq),
chunk: new Uint8Array(chunk),
});
state.expiresAt = now() + state.ttlMs;
nextExpiry = Math.min(nextExpiry, state.expiresAt);
notify(state);
},
async finalize(streamId, status, error, lease) {
validateStreamId(streamId);
evictExpired();
const state = streams.get(streamId);
if (!state) throw new Error(`Stream not found: ${streamId}`);
if (lease && state.token !== lease.token) return false;
if (state.final) return false;
state.final =
status === "done"
? { kind: "done" }
: { kind: "error", error: error ?? "Stream errored" };
state.expiresAt = now() + state.ttlMs;
nextExpiry = Math.min(nextExpiry, state.expiresAt);
notify(state);
return true;
},
async *read(streamId, cursor, signal) {
validateStreamId(streamId);
evictExpired();
const state = streams.get(streamId);
if (!state) throw new Error(`Stream not found: ${streamId}`);
let idx = findStartIndex(state, cursor);
while (true) {
if (signal.aborted) return;
while (idx < state.entries.length) {
if (signal.aborted) return;
const entry = state.entries[idx]!;
yield { ...entry, chunk: new Uint8Array(entry.chunk) };
idx += 1;
}
if (signal.aborted) return;
if (state.final) {
if (state.final.kind === "error") {
throw new Error(state.final.error);
}
return;
}
const wakeBy = state.expiresAt - now();
await waitForUpdate(state, signal, wakeBy);
evictExpired();
}
},
async status(streamId): Promise<ResumableStreamStatus> {
validateStreamId(streamId);
evictExpired();
const state = streams.get(streamId);
if (!state) return "missing";
if (!state.final) return "streaming";
return state.final.kind === "error" ? "error" : "done";
},
async delete(streamId) {
validateStreamId(streamId);
const state = streams.get(streamId);
if (!state) return;
streams.delete(streamId);
state.final ??= { kind: "done" };
notify(state);
},
dispose() {
if (gcTimer !== undefined) clearInterval(gcTimer);
},
};
}