assistant-stream 0.3.13 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +39 -0
  2. package/dist/core/AssistantStreamChunk.d.ts +2 -0
  3. package/dist/core/AssistantStreamChunk.d.ts.map +1 -1
  4. package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
  5. package/dist/core/accumulators/assistant-message-accumulator.js +3 -0
  6. package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
  7. package/dist/core/modules/tool-call.d.ts.map +1 -1
  8. package/dist/core/modules/tool-call.js +3 -0
  9. package/dist/core/modules/tool-call.js.map +1 -1
  10. package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
  11. package/dist/core/tool/ToolExecutionStream.js +3 -0
  12. package/dist/core/tool/ToolExecutionStream.js.map +1 -1
  13. package/dist/core/tool/ToolResponse.d.ts +3 -0
  14. package/dist/core/tool/ToolResponse.d.ts.map +1 -1
  15. package/dist/core/tool/ToolResponse.js +4 -0
  16. package/dist/core/tool/ToolResponse.js.map +1 -1
  17. package/dist/core/tool/tool-types.d.ts +17 -0
  18. package/dist/core/tool/tool-types.d.ts.map +1 -1
  19. package/dist/core/tool/toolResultStream.d.ts.map +1 -1
  20. package/dist/core/tool/toolResultStream.js +26 -1
  21. package/dist/core/tool/toolResultStream.js.map +1 -1
  22. package/dist/core/utils/types.d.ts +4 -0
  23. package/dist/core/utils/types.d.ts.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/resumable/ResumableStreamContext.d.ts +27 -0
  28. package/dist/resumable/ResumableStreamContext.d.ts.map +1 -0
  29. package/dist/resumable/ResumableStreamContext.js +121 -0
  30. package/dist/resumable/ResumableStreamContext.js.map +1 -0
  31. package/dist/resumable/constants.d.ts +2 -0
  32. package/dist/resumable/constants.d.ts.map +1 -0
  33. package/dist/resumable/constants.js +2 -0
  34. package/dist/resumable/constants.js.map +1 -0
  35. package/dist/resumable/createResumableAssistantStreamResponse.d.ts +24 -0
  36. package/dist/resumable/createResumableAssistantStreamResponse.d.ts.map +1 -0
  37. package/dist/resumable/createResumableAssistantStreamResponse.js +40 -0
  38. package/dist/resumable/createResumableAssistantStreamResponse.js.map +1 -0
  39. package/dist/resumable/errors.d.ts +7 -0
  40. package/dist/resumable/errors.d.ts.map +1 -0
  41. package/dist/resumable/errors.js +15 -0
  42. package/dist/resumable/errors.js.map +1 -0
  43. package/dist/resumable/index.d.ts +7 -0
  44. package/dist/resumable/index.d.ts.map +1 -0
  45. package/dist/resumable/index.js +5 -0
  46. package/dist/resumable/index.js.map +1 -0
  47. package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts +13 -0
  48. package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts.map +1 -0
  49. package/dist/resumable/stores/InMemoryResumableStreamStore.js +199 -0
  50. package/dist/resumable/stores/InMemoryResumableStreamStore.js.map +1 -0
  51. package/dist/resumable/stores/ioredis.d.ts +10 -0
  52. package/dist/resumable/stores/ioredis.d.ts.map +1 -0
  53. package/dist/resumable/stores/ioredis.js +95 -0
  54. package/dist/resumable/stores/ioredis.js.map +1 -0
  55. package/dist/resumable/stores/redis-impl.d.ts +60 -0
  56. package/dist/resumable/stores/redis-impl.d.ts.map +1 -0
  57. package/dist/resumable/stores/redis-impl.js +198 -0
  58. package/dist/resumable/stores/redis-impl.js.map +1 -0
  59. package/dist/resumable/stores/redis.d.ts +39 -0
  60. package/dist/resumable/stores/redis.d.ts.map +1 -0
  61. package/dist/resumable/stores/redis.js +113 -0
  62. package/dist/resumable/stores/redis.js.map +1 -0
  63. package/dist/resumable/types.d.ts +30 -0
  64. package/dist/resumable/types.d.ts.map +1 -0
  65. package/dist/resumable/types.js +2 -0
  66. package/dist/resumable/types.js.map +1 -0
  67. package/package.json +28 -3
  68. package/src/core/AssistantStreamChunk.ts +2 -0
  69. package/src/core/accumulators/assistant-message-accumulator.ts +3 -0
  70. package/src/core/modules/tool-call.ts +3 -0
  71. package/src/core/tool/ToolExecutionStream.ts +3 -0
  72. package/src/core/tool/ToolResponse.ts +6 -0
  73. package/src/core/tool/tool-types.ts +23 -0
  74. package/src/core/tool/toolResultStream.test.ts +360 -2
  75. package/src/core/tool/toolResultStream.ts +30 -1
  76. package/src/core/utils/types.ts +4 -0
  77. package/src/index.ts +5 -1
  78. package/src/resumable/ResumableStreamContext.test.ts +274 -0
  79. package/src/resumable/ResumableStreamContext.ts +187 -0
  80. package/src/resumable/__tests__/integration.test.ts +159 -0
  81. package/src/resumable/constants.ts +1 -0
  82. package/src/resumable/createResumableAssistantStreamResponse.test.ts +243 -0
  83. package/src/resumable/createResumableAssistantStreamResponse.ts +80 -0
  84. package/src/resumable/errors.ts +26 -0
  85. package/src/resumable/index.ts +36 -0
  86. package/src/resumable/stores/InMemoryResumableStreamStore.test.ts +285 -0
  87. package/src/resumable/stores/InMemoryResumableStreamStore.ts +237 -0
  88. package/src/resumable/stores/ioredis.ts +123 -0
  89. package/src/resumable/stores/redis-impl.ts +304 -0
  90. package/src/resumable/stores/redis.test.ts +265 -0
  91. package/src/resumable/stores/redis.ts +171 -0
  92. package/src/resumable/types.ts +49 -0
@@ -0,0 +1,237 @@
1
+ import { DEFAULT_TTL_MS } from "../constants";
2
+ import { ResumableStreamError, validateStreamId } from "../errors";
3
+ import type {
4
+ ResumableStreamEntry,
5
+ ResumableStreamStatus,
6
+ ResumableStreamStore,
7
+ } from "../types";
8
+
9
+ type FinalizeMarker = { kind: "done" } | { kind: "error"; error: string };
10
+
11
+ type StreamState = {
12
+ entries: ResumableStreamEntry[];
13
+ nextSeq: number;
14
+ expiresAt: number;
15
+ ttlMs: number;
16
+ final: FinalizeMarker | undefined;
17
+ waiters: Array<() => void>;
18
+ };
19
+
20
+ const cursorOf = (seq: number): string => seq.toString(36);
21
+ const seqFromCursor = (cursor: string): number => {
22
+ if (cursor === "") return 0;
23
+ const parsed = Number.parseInt(cursor, 36);
24
+ return Number.isNaN(parsed) ? 0 : parsed;
25
+ };
26
+
27
+ export type InMemoryResumableStreamStoreOptions = {
28
+ readonly defaultTtlMs?: number;
29
+ readonly now?: () => number;
30
+ readonly maxChunkBytes?: number;
31
+ readonly maxEntriesPerStream?: number;
32
+ readonly maxStreams?: number;
33
+ readonly gcIntervalMs?: number;
34
+ };
35
+
36
+ export function createInMemoryResumableStreamStore(
37
+ options: InMemoryResumableStreamStoreOptions = {},
38
+ ): ResumableStreamStore & { dispose: () => void } {
39
+ const streams = new Map<string, StreamState>();
40
+ const defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS;
41
+ const now = options.now ?? Date.now;
42
+ const maxChunkBytes = options.maxChunkBytes;
43
+ const maxEntriesPerStream = options.maxEntriesPerStream;
44
+ const maxStreams = options.maxStreams;
45
+
46
+ const evictExpired = (): void => {
47
+ const t = now();
48
+ for (const [id, state] of streams) {
49
+ if (state.expiresAt > t) continue;
50
+ streams.delete(id);
51
+ state.final ??= { kind: "error", error: "Stream expired" };
52
+ notify(state);
53
+ }
54
+ };
55
+
56
+ const notify = (state: StreamState): void => {
57
+ const waiters = state.waiters;
58
+ state.waiters = [];
59
+ for (const wake of waiters) wake();
60
+ };
61
+
62
+ const findStartIndex = (state: StreamState, cursor: string): number => {
63
+ if (cursor === "") return 0;
64
+ const after = seqFromCursor(cursor);
65
+ let lo = 0;
66
+ let hi = state.entries.length;
67
+ while (lo < hi) {
68
+ const mid = (lo + hi) >>> 1;
69
+ const seq = seqFromCursor(state.entries[mid]!.cursor);
70
+ if (seq <= after) lo = mid + 1;
71
+ else hi = mid;
72
+ }
73
+ return lo;
74
+ };
75
+
76
+ const waitForUpdate = (
77
+ state: StreamState,
78
+ signal: AbortSignal,
79
+ wakeBy?: number,
80
+ ): Promise<void> =>
81
+ new Promise<void>((resolve) => {
82
+ let settled = false;
83
+ let timer: ReturnType<typeof setTimeout> | undefined;
84
+ const wake = () => {
85
+ if (settled) return;
86
+ settled = true;
87
+ if (timer !== undefined) clearTimeout(timer);
88
+ signal.removeEventListener("abort", wake);
89
+ const idx = state.waiters.indexOf(wake);
90
+ if (idx !== -1) state.waiters.splice(idx, 1);
91
+ resolve();
92
+ };
93
+ if (signal.aborted) {
94
+ wake();
95
+ return;
96
+ }
97
+ state.waiters.push(wake);
98
+ signal.addEventListener("abort", wake, { once: true });
99
+ if (wakeBy !== undefined) {
100
+ if (wakeBy > 0) {
101
+ timer = setTimeout(wake, wakeBy);
102
+ } else {
103
+ // already past the deadline; resolve so the caller can re-check
104
+ // expiration without waiting for an external notify.
105
+ wake();
106
+ }
107
+ }
108
+ });
109
+
110
+ const requireActive = (streamId: string): StreamState => {
111
+ evictExpired();
112
+ const state = streams.get(streamId);
113
+ if (!state) throw new Error(`Stream not found: ${streamId}`);
114
+ if (state.final) {
115
+ throw new ResumableStreamError(
116
+ "finalized",
117
+ `Stream already finalized: ${streamId}`,
118
+ );
119
+ }
120
+ return state;
121
+ };
122
+
123
+ const gcTimer =
124
+ options.gcIntervalMs !== undefined
125
+ ? setInterval(evictExpired, options.gcIntervalMs)
126
+ : undefined;
127
+ gcTimer?.unref?.();
128
+
129
+ return {
130
+ async acquire(streamId, acquireOptions) {
131
+ validateStreamId(streamId);
132
+ evictExpired();
133
+ const existing = streams.get(streamId);
134
+ if (existing) return "consumer";
135
+
136
+ if (maxStreams !== undefined && streams.size >= maxStreams) {
137
+ throw new Error("maxStreams exceeded");
138
+ }
139
+
140
+ const ttlMs = acquireOptions?.ttlMs ?? defaultTtlMs;
141
+ streams.set(streamId, {
142
+ entries: [],
143
+ nextSeq: 1,
144
+ expiresAt: now() + ttlMs,
145
+ ttlMs,
146
+ final: undefined,
147
+ waiters: [],
148
+ });
149
+ return "producer";
150
+ },
151
+
152
+ async append(streamId, chunk) {
153
+ validateStreamId(streamId);
154
+ if (maxChunkBytes !== undefined && chunk.byteLength > maxChunkBytes) {
155
+ throw new Error(`Chunk exceeds maxChunkBytes: ${chunk.byteLength}`);
156
+ }
157
+ const state = requireActive(streamId);
158
+ if (
159
+ maxEntriesPerStream !== undefined &&
160
+ state.entries.length >= maxEntriesPerStream
161
+ ) {
162
+ throw new Error(`Stream exceeded maxEntriesPerStream: ${streamId}`);
163
+ }
164
+ const seq = state.nextSeq;
165
+ state.nextSeq += 1;
166
+ state.entries.push({ cursor: cursorOf(seq), chunk });
167
+ state.expiresAt = now() + state.ttlMs;
168
+ notify(state);
169
+ },
170
+
171
+ async finalize(streamId, status, error) {
172
+ validateStreamId(streamId);
173
+ evictExpired();
174
+ const state = streams.get(streamId);
175
+ if (!state) throw new Error(`Stream not found: ${streamId}`);
176
+ if (state.final) return;
177
+ state.final =
178
+ status === "done"
179
+ ? { kind: "done" }
180
+ : { kind: "error", error: error ?? "Stream errored" };
181
+ state.expiresAt = now() + state.ttlMs;
182
+ notify(state);
183
+ },
184
+
185
+ async *read(streamId, cursor, signal) {
186
+ validateStreamId(streamId);
187
+ evictExpired();
188
+ const state = streams.get(streamId);
189
+ if (!state) throw new Error(`Stream not found: ${streamId}`);
190
+
191
+ let idx = findStartIndex(state, cursor);
192
+
193
+ while (true) {
194
+ if (signal.aborted) return;
195
+
196
+ while (idx < state.entries.length) {
197
+ if (signal.aborted) return;
198
+ yield state.entries[idx]!;
199
+ idx += 1;
200
+ }
201
+
202
+ if (state.final) {
203
+ if (state.final.kind === "error") {
204
+ throw new Error(state.final.error);
205
+ }
206
+ return;
207
+ }
208
+
209
+ const wakeBy = state.expiresAt - now();
210
+ await waitForUpdate(state, signal, wakeBy);
211
+ evictExpired();
212
+ }
213
+ },
214
+
215
+ async status(streamId): Promise<ResumableStreamStatus> {
216
+ validateStreamId(streamId);
217
+ evictExpired();
218
+ const state = streams.get(streamId);
219
+ if (!state) return "missing";
220
+ if (!state.final) return "streaming";
221
+ return state.final.kind === "error" ? "error" : "done";
222
+ },
223
+
224
+ async delete(streamId) {
225
+ validateStreamId(streamId);
226
+ const state = streams.get(streamId);
227
+ if (!state) return;
228
+ streams.delete(streamId);
229
+ state.final ??= { kind: "done" };
230
+ notify(state);
231
+ },
232
+
233
+ dispose() {
234
+ if (gcTimer !== undefined) clearInterval(gcTimer);
235
+ },
236
+ };
237
+ }
@@ -0,0 +1,123 @@
1
+ import type {
2
+ ChainableCommander,
3
+ Cluster as IoRedisCluster,
4
+ Redis as IoRedis,
5
+ } from "ioredis";
6
+ import {
7
+ RedisResumableStreamStore,
8
+ type PipelineCommand,
9
+ type RedisLikeClient,
10
+ type RedisResumableStreamStoreOptions,
11
+ } from "./redis-impl";
12
+ import type { ResumableStreamStore } from "../types";
13
+
14
+ export type IoRedisLike = IoRedis | IoRedisCluster;
15
+
16
+ /**
17
+ * Resumable stream store backed by [`ioredis`](https://www.npmjs.com/package/ioredis)
18
+ * v5. Accepts a `Redis` or `Cluster` instance.
19
+ */
20
+ export function createIoredisResumableStreamStore(
21
+ client: IoRedisLike,
22
+ options?: RedisResumableStreamStoreOptions,
23
+ ): ResumableStreamStore {
24
+ return new RedisResumableStreamStore(adapt(client), options);
25
+ }
26
+
27
+ function adapt(client: IoRedisLike): RedisLikeClient {
28
+ return {
29
+ async setNX(key, value, ttlSec) {
30
+ const result = await client.set(key, value, "EX", ttlSec, "NX");
31
+ return result === "OK";
32
+ },
33
+ async set(key, value, ttlSec) {
34
+ await client.set(key, value, "EX", ttlSec);
35
+ },
36
+ async get(key) {
37
+ return client.get(key);
38
+ },
39
+ async expire(key, ttlSec) {
40
+ await client.expire(key, ttlSec);
41
+ },
42
+ async exists(key) {
43
+ const result = await client.exists(key);
44
+ return result > 0;
45
+ },
46
+ async del(keys) {
47
+ if (keys.length === 0) return;
48
+ await client.del(...keys);
49
+ },
50
+ async xAdd(key, fields) {
51
+ const id = await client.xadd(key, "*", ...toFieldArgs(fields));
52
+ return id ?? "";
53
+ },
54
+ async xRange(key, start, end) {
55
+ const entries = await client.xrangeBuffer(key, start, end);
56
+ return entries.map(([idBuf, fieldArray]) => ({
57
+ id: idBuf.toString("utf8"),
58
+ fields: bufferFieldsToRecord(fieldArray),
59
+ }));
60
+ },
61
+ async pipeline(commands) {
62
+ if (commands.length === 0) return;
63
+ const pipe = client.pipeline();
64
+ for (const cmd of commands) {
65
+ applyPipelineCommand(pipe, cmd);
66
+ }
67
+ const results = (await pipe.exec()) ?? [];
68
+ for (const [err] of results) {
69
+ if (err) throw err;
70
+ }
71
+ },
72
+ };
73
+ }
74
+
75
+ function applyPipelineCommand(
76
+ pipe: ChainableCommander,
77
+ cmd: PipelineCommand,
78
+ ): void {
79
+ switch (cmd.type) {
80
+ case "xAdd":
81
+ pipe.xadd(cmd.key, "*", ...toFieldArgs(cmd.fields));
82
+ return;
83
+ case "expire":
84
+ pipe.expire(cmd.key, cmd.ttlSec);
85
+ return;
86
+ case "set":
87
+ pipe.set(cmd.key, cmd.value, "EX", cmd.ttlSec);
88
+ return;
89
+ }
90
+ }
91
+
92
+ function toFieldArgs(
93
+ fields: Record<string, string | Uint8Array>,
94
+ ): Array<string | Buffer> {
95
+ const args: Array<string | Buffer> = [];
96
+ for (const [k, v] of Object.entries(fields)) {
97
+ args.push(k, typeof v === "string" ? v : toBuffer(v));
98
+ }
99
+ return args;
100
+ }
101
+
102
+ function toBuffer(bytes: Uint8Array): Buffer {
103
+ if (Buffer.isBuffer(bytes)) return bytes;
104
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
105
+ }
106
+
107
+ function bufferFieldsToRecord(
108
+ fields: Buffer[],
109
+ ): Record<string, string | Uint8Array> {
110
+ const out: Record<string, string | Uint8Array> = {};
111
+ for (let i = 0; i + 1 < fields.length; i += 2) {
112
+ const key = fields[i]?.toString("utf8");
113
+ const value = fields[i + 1];
114
+ if (key !== undefined && value !== undefined) {
115
+ out[key] = new Uint8Array(
116
+ value.buffer,
117
+ value.byteOffset,
118
+ value.byteLength,
119
+ );
120
+ }
121
+ }
122
+ return out;
123
+ }
@@ -0,0 +1,304 @@
1
+ import { DEFAULT_TTL_MS } from "../constants";
2
+ import { ResumableStreamError, validateStreamId } from "../errors";
3
+ import type {
4
+ ResumableStreamAcquireOptions,
5
+ ResumableStreamEntry,
6
+ ResumableStreamRole,
7
+ ResumableStreamStatus,
8
+ ResumableStreamStore,
9
+ } from "../types";
10
+
11
+ const DEFAULT_POLL_INTERVAL_MS = 100;
12
+ const DEFAULT_KEY_PREFIX = "aui:resumable";
13
+
14
+ const FIELD_CHUNK = "c";
15
+ const FIELD_FIN = "fin";
16
+ const FIELD_ERROR = "error";
17
+
18
+ const FIN_DONE = "done";
19
+ const FIN_ERROR = "error";
20
+
21
+ const STREAM_START_ID = "0-0";
22
+
23
+ export type PipelineCommand =
24
+ | {
25
+ readonly type: "xAdd";
26
+ readonly key: string;
27
+ readonly fields: Record<string, string | Uint8Array>;
28
+ }
29
+ | { readonly type: "expire"; readonly key: string; readonly ttlSec: number }
30
+ | {
31
+ readonly type: "set";
32
+ readonly key: string;
33
+ readonly value: string;
34
+ readonly ttlSec: number;
35
+ };
36
+
37
+ /**
38
+ * Structural Redis-client interface. The bundled `redis` and `ioredis`
39
+ * adapters wrap their respective clients to satisfy it; custom or proxied
40
+ * clients can implement it directly.
41
+ */
42
+ export interface RedisLikeClient {
43
+ setNX(key: string, value: string, ttlSec: number): Promise<boolean>;
44
+ set(key: string, value: string, ttlSec: number): Promise<void>;
45
+ get(key: string): Promise<string | null>;
46
+ expire(key: string, ttlSec: number): Promise<void>;
47
+ exists(key: string): Promise<boolean>;
48
+ del(keys: string[]): Promise<void>;
49
+ xAdd(
50
+ key: string,
51
+ fields: Record<string, string | Uint8Array>,
52
+ ): Promise<string>;
53
+ xRange(
54
+ key: string,
55
+ start: string,
56
+ end: string,
57
+ ): Promise<
58
+ Array<{ id: string; fields: Record<string, string | Uint8Array> }>
59
+ >;
60
+ /** Executes the commands as a single pipeline batch (one round trip). */
61
+ pipeline(commands: readonly PipelineCommand[]): Promise<void>;
62
+ }
63
+
64
+ export type RedisResumableStreamStoreOptions = {
65
+ readonly keyPrefix?: string;
66
+ readonly defaultTtlMs?: number;
67
+ /** Defaults to 100ms. Lower values reduce read latency, raise traffic. */
68
+ readonly pollIntervalMs?: number;
69
+ readonly maxChunkBytes?: number;
70
+ };
71
+
72
+ export class RedisResumableStreamStore implements ResumableStreamStore {
73
+ private readonly client: RedisLikeClient;
74
+ private readonly keyPrefix: string;
75
+ private readonly defaultTtlMs: number;
76
+ private readonly pollIntervalMs: number;
77
+ private readonly maxChunkBytes: number | undefined;
78
+
79
+ constructor(
80
+ client: RedisLikeClient,
81
+ options: RedisResumableStreamStoreOptions = {},
82
+ ) {
83
+ this.client = client;
84
+ this.keyPrefix = options.keyPrefix ?? DEFAULT_KEY_PREFIX;
85
+ this.defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS;
86
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
87
+ this.maxChunkBytes = options.maxChunkBytes;
88
+ }
89
+
90
+ async acquire(
91
+ streamId: string,
92
+ options?: ResumableStreamAcquireOptions,
93
+ ): Promise<ResumableStreamRole> {
94
+ validateStreamId(streamId);
95
+ const ttlSec = msToSec(options?.ttlMs ?? this.defaultTtlMs);
96
+ const meta = JSON.stringify({ status: "streaming", ttlSec });
97
+ const acquired = await this.client.setNX(
98
+ this.metaKey(streamId),
99
+ meta,
100
+ ttlSec,
101
+ );
102
+ if (acquired) {
103
+ // a prior producer's data key may outlive its expired meta key.
104
+ await this.client.del([this.dataKey(streamId)]);
105
+ return "producer";
106
+ }
107
+ return "consumer";
108
+ }
109
+
110
+ async append(streamId: string, chunk: Uint8Array): Promise<void> {
111
+ validateStreamId(streamId);
112
+ if (
113
+ this.maxChunkBytes !== undefined &&
114
+ chunk.byteLength > this.maxChunkBytes
115
+ ) {
116
+ throw new Error(
117
+ `Chunk exceeds maxChunkBytes (${chunk.byteLength} > ${this.maxChunkBytes})`,
118
+ );
119
+ }
120
+ const dataKey = this.dataKey(streamId);
121
+ const metaKey = this.metaKey(streamId);
122
+ const meta = await this.readMeta(streamId);
123
+ if (!meta) {
124
+ throw new Error(`Stream not found: ${streamId}`);
125
+ }
126
+ if (meta.status !== "streaming") {
127
+ throw new ResumableStreamError(
128
+ "finalized",
129
+ `Stream already finalized: ${streamId}`,
130
+ );
131
+ }
132
+ const ttlSec = meta.ttlSec ?? msToSec(this.defaultTtlMs);
133
+ await this.client.pipeline([
134
+ { type: "xAdd", key: dataKey, fields: { [FIELD_CHUNK]: chunk } },
135
+ { type: "expire", key: dataKey, ttlSec },
136
+ { type: "expire", key: metaKey, ttlSec },
137
+ ]);
138
+ }
139
+
140
+ async finalize(
141
+ streamId: string,
142
+ status: "done" | "error",
143
+ error?: string,
144
+ ): Promise<void> {
145
+ validateStreamId(streamId);
146
+ const dataKey = this.dataKey(streamId);
147
+ const metaKey = this.metaKey(streamId);
148
+ const existing = await this.readMeta(streamId);
149
+ if (!existing) {
150
+ throw new Error(`Stream not found: ${streamId}`);
151
+ }
152
+ // a second finalize must not append a duplicate FIN entry.
153
+ if (existing.status !== "streaming") return;
154
+ const ttlSec = existing.ttlSec ?? msToSec(this.defaultTtlMs);
155
+ const meta = JSON.stringify(
156
+ status === "error"
157
+ ? { status: "error", error: error ?? "Stream errored", ttlSec }
158
+ : { status: "done", ttlSec },
159
+ );
160
+ const fields: Record<string, string> = {
161
+ [FIELD_FIN]: status === "error" ? FIN_ERROR : FIN_DONE,
162
+ };
163
+ if (status === "error") {
164
+ fields[FIELD_ERROR] = error ?? "Stream errored";
165
+ }
166
+ await this.client.pipeline([
167
+ { type: "set", key: metaKey, value: meta, ttlSec },
168
+ { type: "xAdd", key: dataKey, fields },
169
+ { type: "expire", key: dataKey, ttlSec },
170
+ ]);
171
+ }
172
+
173
+ async *read(
174
+ streamId: string,
175
+ cursor: string,
176
+ signal: AbortSignal,
177
+ ): AsyncIterable<ResumableStreamEntry> {
178
+ validateStreamId(streamId);
179
+ const dataKey = this.dataKey(streamId);
180
+ const metaKey = this.metaKey(streamId);
181
+ const initialMeta = await this.client.get(metaKey);
182
+ if (initialMeta === null) {
183
+ throw new Error(`Stream not found: ${streamId}`);
184
+ }
185
+
186
+ let lastId = cursor === "" ? STREAM_START_ID : cursor;
187
+
188
+ while (true) {
189
+ if (signal.aborted) return;
190
+
191
+ const start = lastId === STREAM_START_ID ? "-" : `(${lastId}`;
192
+ const entries = await this.client.xRange(dataKey, start, "+");
193
+
194
+ for (const entry of entries) {
195
+ if (signal.aborted) return;
196
+ lastId = entry.id;
197
+
198
+ const fin = readString(entry.fields[FIELD_FIN]);
199
+ if (fin === FIN_DONE) return;
200
+ if (fin === FIN_ERROR) {
201
+ throw new Error(
202
+ readString(entry.fields[FIELD_ERROR]) ?? "Stream errored",
203
+ );
204
+ }
205
+
206
+ const raw = entry.fields[FIELD_CHUNK];
207
+ if (raw === undefined) continue;
208
+ yield { cursor: entry.id, chunk: toBytes(raw) };
209
+ }
210
+
211
+ if (entries.length > 0) continue;
212
+
213
+ const stillExists = await this.client.exists(metaKey);
214
+ if (!stillExists) return;
215
+
216
+ await sleep(this.pollIntervalMs, signal);
217
+ }
218
+ }
219
+
220
+ async status(streamId: string): Promise<ResumableStreamStatus> {
221
+ validateStreamId(streamId);
222
+ const meta = await this.client.get(this.metaKey(streamId));
223
+ if (meta === null) return "missing";
224
+ const parsed = parseMeta(meta);
225
+ if (parsed?.status === "streaming") return "streaming";
226
+ if (parsed?.status === "done") return "done";
227
+ if (parsed?.status === "error") return "error";
228
+ return "missing";
229
+ }
230
+
231
+ async delete(streamId: string): Promise<void> {
232
+ validateStreamId(streamId);
233
+ await this.client.del([this.metaKey(streamId), this.dataKey(streamId)]);
234
+ }
235
+
236
+ private async readMeta(streamId: string): Promise<ParsedMeta | undefined> {
237
+ const raw = await this.client.get(this.metaKey(streamId));
238
+ if (raw === null) return undefined;
239
+ return parseMeta(raw);
240
+ }
241
+
242
+ // {streamId} is a Redis Cluster hash tag so both keys live on the same
243
+ // shard; multi-key DEL and same-stream pipelines stay single-slot.
244
+ private metaKey(streamId: string): string {
245
+ return `${this.keyPrefix}:{${streamId}}:meta`;
246
+ }
247
+
248
+ private dataKey(streamId: string): string {
249
+ return `${this.keyPrefix}:{${streamId}}:data`;
250
+ }
251
+ }
252
+
253
+ type ParsedMeta = {
254
+ status?: string;
255
+ error?: string;
256
+ ttlSec?: number;
257
+ };
258
+
259
+ function parseMeta(value: string): ParsedMeta | undefined {
260
+ try {
261
+ const parsed = JSON.parse(value) as ParsedMeta;
262
+ return parsed && typeof parsed === "object" ? parsed : undefined;
263
+ } catch {
264
+ return undefined;
265
+ }
266
+ }
267
+
268
+ function msToSec(ms: number): number {
269
+ return Math.max(1, Math.ceil(ms / 1000));
270
+ }
271
+
272
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
273
+ return new Promise<void>((resolve) => {
274
+ if (signal.aborted) {
275
+ resolve();
276
+ return;
277
+ }
278
+ const timer = setTimeout(() => {
279
+ signal.removeEventListener("abort", onAbort);
280
+ resolve();
281
+ }, ms);
282
+ const onAbort = () => {
283
+ clearTimeout(timer);
284
+ resolve();
285
+ };
286
+ signal.addEventListener("abort", onAbort, { once: true });
287
+ });
288
+ }
289
+
290
+ const SHARED_DECODER = new TextDecoder();
291
+ const SHARED_ENCODER = new TextEncoder();
292
+
293
+ function readString(
294
+ value: string | Uint8Array | undefined,
295
+ ): string | undefined {
296
+ if (value === undefined) return undefined;
297
+ if (typeof value === "string") return value;
298
+ return SHARED_DECODER.decode(value);
299
+ }
300
+
301
+ function toBytes(value: string | Uint8Array): Uint8Array {
302
+ if (value instanceof Uint8Array) return value;
303
+ return SHARED_ENCODER.encode(value);
304
+ }