@vincemakes/kiso-client 0.41.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kiso contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @vincemakes/kiso-client
2
+
3
+ The typed client for a hosted kiso session, over the wire protocol. Run,
4
+ resume, abort, approve, the snapshot, and a stream that reconnects with
5
+ `Last-Event-ID` and never yields an event twice. Browser and Node: global
6
+ `fetch`, no EventSource (it cannot send headers), and nothing from the
7
+ runtime — this package depends on `@vincemakes/kiso-protocol` alone.
8
+
9
+ ```ts
10
+ import { createClient } from "@vincemakes/kiso-client";
11
+
12
+ const client = createClient({ baseUrl: "https://api.example.com/v1/sessions", headers: () => ({ authorization: `Bearer ${token}` }) });
13
+ const session = client.session("s1");
14
+
15
+ const { runId } = await session.run("hello"); // ClientError on in_flight / open_run / draining / forbidden
16
+ for await (const ev of session.events({ after: -1, until: (e) => e.kind === "event" && e.event.type === "terminal" })) {
17
+ if (ev.kind === "event") render(ev.event); // a WireEvent under its seq
18
+ else if (ev.event === "billing") bill(ev.data); // a product frame beside it
19
+ }
20
+ const outcome = await session.abort(); // idle | parked (a reply, not an error) | stopped
21
+ await session.approve(decisionId, true); // → { needsResume }
22
+ ```
23
+
24
+ `events()` keeps the stream alive: a dropped connection reconnects with
25
+ the last seq it delivered, with exponential backoff, and the seam is
26
+ deduplicated by seq — the transport's replay-from-seq guarantee,
27
+ consumed. `runStream()` is one turn on one response (`/run?stream=1`)
28
+ with no reconnect; a dropped connection there is the cue to `events()`.
29
+
30
+ See the repository README for the framework overview.
@@ -0,0 +1,90 @@
1
+ import type { AbortReply, ApproveReply, ResolveUncertainReply, RunReply, SessionState, WireError, WireErrorCode, WireEvent, WireInput, WireSource } from "@vincemakes/kiso-protocol";
2
+ /**
3
+ * The typed client for a hosted kiso session, over the wire protocol.
4
+ *
5
+ * It knows the routes the transport answers under a prefix, the one error
6
+ * shape, and how to keep a stream alive: `events()` reconnects with the
7
+ * last seq it delivered as `Last-Event-ID`, so a client that loses its
8
+ * connection mid-run picks up exactly where it was — the transport's
9
+ * replay-from-seq guarantee, consumed. Browser and Node: global `fetch`,
10
+ * no EventSource, nothing from the runtime.
11
+ */
12
+ export interface ClientOptions {
13
+ /** The mount point, e.g. `https://api.example.com/v1/sessions`. */
14
+ readonly baseUrl: string;
15
+ /** Per-request headers — the host's auth. Called on every request. */
16
+ readonly headers?: () => Record<string, string> | Promise<Record<string, string>>;
17
+ /** Injected for tests or non-global environments. Default: globalThis.fetch. */
18
+ readonly fetch?: typeof fetch;
19
+ /** Reconnect backoff for `events()`: first wait and cap, in ms. */
20
+ readonly reconnect?: {
21
+ readonly initialMs?: number;
22
+ readonly maxMs?: number;
23
+ };
24
+ }
25
+ /** A non-2xx reply, carrying the wire's error shape. */
26
+ export declare class ClientError extends Error {
27
+ readonly status: number;
28
+ readonly code: WireErrorCode;
29
+ readonly runId: string | undefined;
30
+ constructor(status: number, error: WireError);
31
+ }
32
+ /** What `events()` yields: a wire event under its seq, or a product frame beside one. */
33
+ export type ClientEvent = {
34
+ readonly kind: "event";
35
+ readonly event: WireEvent;
36
+ } | {
37
+ readonly kind: "frame";
38
+ readonly event: string;
39
+ readonly data: unknown;
40
+ };
41
+ export interface EventsOptions {
42
+ /** The last seq already seen; −1 (the default) for everything. */
43
+ readonly after?: number;
44
+ /** Stop the stream. */
45
+ readonly signal?: AbortSignal;
46
+ /** End the stream after this event (e.g. the terminal) — otherwise it
47
+ * stays open, reconnecting, until the signal fires. */
48
+ readonly until?: (event: ClientEvent) => boolean;
49
+ /** Called before each reconnect with the attempt number and the wait. */
50
+ readonly onReconnect?: (attempt: number, waitMs: number) => void;
51
+ }
52
+ export interface RunStreamOptions extends Omit<EventsOptions, "until"> {
53
+ readonly source?: WireSource;
54
+ readonly resumeFirst?: boolean;
55
+ }
56
+ export declare class SessionClient {
57
+ #private;
58
+ readonly id: string;
59
+ constructor(options: ClientOptions, sessionId: string);
60
+ /** Start a turn; refusals arrive as ClientError (in_flight, open_run, draining, forbidden). */
61
+ run(input: WireInput, options?: {
62
+ source?: WireSource;
63
+ resumeFirst?: boolean;
64
+ }): Promise<RunReply>;
65
+ resume(): Promise<RunReply>;
66
+ /** Stop the run in flight. A parked run is a REPLY (kind "parked"), not an error. */
67
+ abort(options?: {
68
+ force?: boolean;
69
+ }): Promise<AbortReply>;
70
+ approve(decisionId: string, allow: boolean, reason?: string): Promise<ApproveReply>;
71
+ resolveUncertain(executionId: string, resolution: "rerun" | "abandoned"): Promise<ResolveUncertainReply>;
72
+ state(): Promise<SessionState>;
73
+ replay(): Promise<{
74
+ readonly events: readonly WireEvent[];
75
+ readonly state: SessionState;
76
+ }>;
77
+ /** One turn on one response (`/run?stream=1`): its events, then the end.
78
+ * No reconnect — a dropped connection is the caller's cue to `events()`. */
79
+ runStream(input: WireInput, options?: RunStreamOptions): AsyncGenerator<ClientEvent, void, undefined>;
80
+ /** Every event after `after`, live, reconnecting with `Last-Event-ID` on
81
+ * a dropped connection and never yielding a seq twice. Runs until the
82
+ * signal fires or `until` says so. */
83
+ events(options?: EventsOptions): AsyncGenerator<ClientEvent, void, undefined>;
84
+ }
85
+ export declare class KisoClient {
86
+ #private;
87
+ constructor(options: ClientOptions);
88
+ session(sessionId: string): SessionClient;
89
+ }
90
+ export declare function createClient(options: ClientOptions): KisoClient;
package/dist/client.js ADDED
@@ -0,0 +1,189 @@
1
+ import { readSse } from "./sse.js";
2
+ /** A non-2xx reply, carrying the wire's error shape. */
3
+ export class ClientError extends Error {
4
+ status;
5
+ code;
6
+ runId;
7
+ constructor(status, error) {
8
+ super(error.message);
9
+ this.name = "ClientError";
10
+ this.status = status;
11
+ this.code = error.code;
12
+ this.runId = error.runId;
13
+ }
14
+ }
15
+ export class SessionClient {
16
+ #options;
17
+ id;
18
+ constructor(options, sessionId) {
19
+ this.#options = options;
20
+ this.id = sessionId;
21
+ }
22
+ #url(action, query) {
23
+ const base = `${this.#options.baseUrl.replace(/\/+$/, "")}/${encodeURIComponent(this.id)}${action === "" ? "" : `/${action}`}`;
24
+ if (query === undefined || Object.keys(query).length === 0)
25
+ return base;
26
+ return `${base}?${new URLSearchParams(query).toString()}`;
27
+ }
28
+ async #request(method, action, body, extra = {}) {
29
+ const doFetch = this.#options.fetch ?? globalThis.fetch;
30
+ const headers = { ...(await this.#options.headers?.()), ...extra.headers };
31
+ if (body !== undefined)
32
+ headers["content-type"] = "application/json";
33
+ return doFetch(this.#url(action, extra.query), {
34
+ method,
35
+ headers,
36
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
37
+ ...(extra.signal !== undefined ? { signal: extra.signal } : {}),
38
+ });
39
+ }
40
+ async #json(res, okStatuses = [200, 202]) {
41
+ const text = await res.text();
42
+ let parsed = null;
43
+ try {
44
+ parsed = text === "" ? null : JSON.parse(text);
45
+ }
46
+ catch {
47
+ parsed = null;
48
+ }
49
+ if (okStatuses.includes(res.status))
50
+ return parsed;
51
+ const error = isWireError(parsed) ? parsed : { code: "internal", message: text || `HTTP ${res.status}` };
52
+ throw new ClientError(res.status, error);
53
+ }
54
+ /** Start a turn; refusals arrive as ClientError (in_flight, open_run, draining, forbidden). */
55
+ async run(input, options = {}) {
56
+ return this.#json(await this.#request("POST", "run", { input, ...options }));
57
+ }
58
+ async resume() {
59
+ return this.#json(await this.#request("POST", "resume", {}));
60
+ }
61
+ /** Stop the run in flight. A parked run is a REPLY (kind "parked"), not an error. */
62
+ async abort(options = {}) {
63
+ return this.#json(await this.#request("POST", "abort", options), [200, 409]);
64
+ }
65
+ async approve(decisionId, allow, reason) {
66
+ return this.#json(await this.#request("POST", "approve", { decisionId, allow, ...(reason !== undefined ? { reason } : {}) }));
67
+ }
68
+ async resolveUncertain(executionId, resolution) {
69
+ return this.#json(await this.#request("POST", "uncertain", { executionId, resolution }));
70
+ }
71
+ async state() {
72
+ return this.#json(await this.#request("GET", "state"));
73
+ }
74
+ async replay() {
75
+ return this.#json(await this.#request("GET", "replay"));
76
+ }
77
+ /** One turn on one response (`/run?stream=1`): its events, then the end.
78
+ * No reconnect — a dropped connection is the caller's cue to `events()`. */
79
+ async *runStream(input, options = {}) {
80
+ const { source, resumeFirst, after, signal } = options;
81
+ const res = await this.#request("POST", "run", { input, ...(source !== undefined ? { source } : {}), ...(resumeFirst !== undefined ? { resumeFirst } : {}), after: after ?? -1 }, { query: { stream: "1" }, ...(signal !== undefined ? { signal } : {}) });
82
+ if (res.status !== 200 || res.body === null)
83
+ await this.#json(res, []); // throws the wire error
84
+ for await (const frame of readSse(res.body)) {
85
+ const ev = toClientEvent(frame);
86
+ if (ev !== null)
87
+ yield ev;
88
+ }
89
+ }
90
+ /** Every event after `after`, live, reconnecting with `Last-Event-ID` on
91
+ * a dropped connection and never yielding a seq twice. Runs until the
92
+ * signal fires or `until` says so. */
93
+ async *events(options = {}) {
94
+ let lastSeq = options.after ?? -1;
95
+ let attempt = 0;
96
+ const initial = this.#options.reconnect?.initialMs ?? 250;
97
+ const cap = this.#options.reconnect?.maxMs ?? 5_000;
98
+ for (;;) {
99
+ if (options.signal?.aborted)
100
+ return;
101
+ let res;
102
+ try {
103
+ res = await this.#request("GET", "events", undefined, {
104
+ headers: { accept: "text/event-stream", "last-event-id": String(lastSeq) },
105
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
106
+ });
107
+ }
108
+ catch (err) {
109
+ if (options.signal?.aborted)
110
+ return;
111
+ res = null;
112
+ void err;
113
+ }
114
+ if (res !== null) {
115
+ if (res.status !== 200 || res.body === null)
116
+ await this.#json(res, []); // a refusal is final: forbidden, not_found
117
+ attempt = 0;
118
+ try {
119
+ for await (const frame of readSse(res.body)) {
120
+ if (options.signal?.aborted)
121
+ return;
122
+ const ev = toClientEvent(frame);
123
+ if (ev === null)
124
+ continue;
125
+ if (ev.kind === "event") {
126
+ if (ev.event.seq <= lastSeq)
127
+ continue; // the seam: never twice
128
+ lastSeq = ev.event.seq;
129
+ }
130
+ yield ev;
131
+ if (options.until?.(ev) === true)
132
+ return;
133
+ }
134
+ }
135
+ catch {
136
+ // the connection dropped mid-stream — fall through to reconnect
137
+ }
138
+ }
139
+ if (options.signal?.aborted)
140
+ return;
141
+ attempt += 1;
142
+ const wait = Math.min(cap, initial * 2 ** (attempt - 1));
143
+ options.onReconnect?.(attempt, wait);
144
+ await sleep(wait, options.signal);
145
+ }
146
+ }
147
+ }
148
+ export class KisoClient {
149
+ #options;
150
+ constructor(options) {
151
+ this.#options = options;
152
+ }
153
+ session(sessionId) {
154
+ return new SessionClient(this.#options, sessionId);
155
+ }
156
+ }
157
+ export function createClient(options) {
158
+ return new KisoClient(options);
159
+ }
160
+ function toClientEvent(frame) {
161
+ if (frame.data === undefined)
162
+ return null; // a comment: open / keepalive
163
+ let data;
164
+ try {
165
+ data = JSON.parse(frame.data);
166
+ }
167
+ catch {
168
+ return null;
169
+ }
170
+ if (frame.id !== undefined)
171
+ return { kind: "event", event: data };
172
+ return { kind: "frame", event: frame.event ?? "message", data };
173
+ }
174
+ function isWireError(value) {
175
+ return typeof value === "object" && value !== null && typeof value.code === "string" && typeof value.message === "string";
176
+ }
177
+ function sleep(ms, signal) {
178
+ return new Promise((resolve) => {
179
+ if (signal?.aborted)
180
+ return resolve();
181
+ const timer = setTimeout(done, ms);
182
+ function done() {
183
+ clearTimeout(timer);
184
+ signal?.removeEventListener("abort", done);
185
+ resolve();
186
+ }
187
+ signal?.addEventListener("abort", done, { once: true });
188
+ });
189
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @vincemakes/kiso-client — the typed client for a hosted kiso session.
3
+ *
4
+ * Over the wire protocol only: run, resume, abort, approve, the snapshot,
5
+ * and a stream that reconnects with Last-Event-ID and never repeats an
6
+ * event. Browser and Node; depends on `@vincemakes/kiso-protocol` alone.
7
+ */
8
+ export { ClientError, createClient, KisoClient, SessionClient } from "./client.js";
9
+ export type { ClientEvent, ClientOptions, EventsOptions, RunStreamOptions } from "./client.js";
10
+ export { parseSseBlock, readSse } from "./sse.js";
11
+ export type { SseFrame } from "./sse.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @vincemakes/kiso-client — the typed client for a hosted kiso session.
3
+ *
4
+ * Over the wire protocol only: run, resume, abort, approve, the snapshot,
5
+ * and a stream that reconnects with Last-Event-ID and never repeats an
6
+ * event. Browser and Node; depends on `@vincemakes/kiso-protocol` alone.
7
+ */
8
+ export { ClientError, createClient, KisoClient, SessionClient } from "./client.js";
9
+ export { parseSseBlock, readSse } from "./sse.js";
package/dist/sse.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * An incremental Server-Sent Events parser over a byte stream.
3
+ *
4
+ * Standards-shaped and small: frames are separated by a blank line; `id:`,
5
+ * `event:` and `data:` lines are read (multi-line data joins with "\n");
6
+ * comment lines (`:`) are reported as keepalives so a consumer can tell a
7
+ * quiet stream from a dead one. No EventSource: a fetch-based reader can
8
+ * send `Last-Event-ID` as a header, which the browser API cannot.
9
+ */
10
+ export interface SseFrame {
11
+ readonly id?: string;
12
+ readonly event?: string;
13
+ readonly data?: string;
14
+ /** A comment line — the transport's `: open` / `: keepalive`. */
15
+ readonly comment?: string;
16
+ }
17
+ export declare function parseSseBlock(block: string): SseFrame | null;
18
+ /** Read a body as SSE frames, as they complete. Ends when the body ends. */
19
+ export declare function readSse(body: ReadableStream<Uint8Array>): AsyncGenerator<SseFrame, void, undefined>;
package/dist/sse.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * An incremental Server-Sent Events parser over a byte stream.
3
+ *
4
+ * Standards-shaped and small: frames are separated by a blank line; `id:`,
5
+ * `event:` and `data:` lines are read (multi-line data joins with "\n");
6
+ * comment lines (`:`) are reported as keepalives so a consumer can tell a
7
+ * quiet stream from a dead one. No EventSource: a fetch-based reader can
8
+ * send `Last-Event-ID` as a header, which the browser API cannot.
9
+ */
10
+ export function parseSseBlock(block) {
11
+ let id;
12
+ let event;
13
+ const data = [];
14
+ let comment;
15
+ for (const raw of block.split("\n")) {
16
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
17
+ if (line === "")
18
+ continue;
19
+ if (line.startsWith(":")) {
20
+ comment = line.slice(1).trim();
21
+ continue;
22
+ }
23
+ const colon = line.indexOf(":");
24
+ const field = colon === -1 ? line : line.slice(0, colon);
25
+ let value = colon === -1 ? "" : line.slice(colon + 1);
26
+ if (value.startsWith(" "))
27
+ value = value.slice(1);
28
+ if (field === "id")
29
+ id = value;
30
+ else if (field === "event")
31
+ event = value;
32
+ else if (field === "data")
33
+ data.push(value);
34
+ }
35
+ if (id === undefined && event === undefined && data.length === 0 && comment === undefined)
36
+ return null;
37
+ return {
38
+ ...(id !== undefined ? { id } : {}),
39
+ ...(event !== undefined ? { event } : {}),
40
+ ...(data.length > 0 ? { data: data.join("\n") } : {}),
41
+ ...(comment !== undefined ? { comment } : {}),
42
+ };
43
+ }
44
+ /** Read a body as SSE frames, as they complete. Ends when the body ends. */
45
+ export async function* readSse(body) {
46
+ const reader = body.getReader();
47
+ const decoder = new TextDecoder();
48
+ let buffer = "";
49
+ try {
50
+ for (;;) {
51
+ const { value, done } = await reader.read();
52
+ if (done)
53
+ break;
54
+ // CRLF is normalised on the whole buffer so a "\r" that ends one
55
+ // chunk meets its "\n" from the next before the split
56
+ buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, "\n");
57
+ let boundary = buffer.indexOf("\n\n");
58
+ while (boundary !== -1) {
59
+ const block = buffer.slice(0, boundary);
60
+ buffer = buffer.slice(boundary + 2);
61
+ const frame = parseSseBlock(block);
62
+ if (frame !== null)
63
+ yield frame;
64
+ boundary = buffer.indexOf("\n\n");
65
+ }
66
+ }
67
+ const last = parseSseBlock(buffer);
68
+ if (last !== null)
69
+ yield last;
70
+ }
71
+ finally {
72
+ await reader.cancel().catch(() => { });
73
+ }
74
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@vincemakes/kiso-client",
3
+ "version": "0.41.0",
4
+ "description": "kiso client — the typed client for a hosted kiso session over the wire protocol: run, resume, abort, approve, the snapshot, and a stream that reconnects with Last-Event-ID and never repeats an event. Browser and Node; depends on the protocol package only.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "typecheck": "tsc -p tsconfig.json",
21
+ "test": "vitest run"
22
+ },
23
+ "dependencies": {
24
+ "@vincemakes/kiso-protocol": "0.41.0"
25
+ },
26
+ "devDependencies": {
27
+ "@vincemakes/kiso-core": "0.41.0",
28
+ "@vincemakes/kiso-evals": "0.41.0",
29
+ "@vincemakes/kiso-runtime": "0.41.0",
30
+ "@vincemakes/kiso-server": "0.41.0",
31
+ "@types/node": "^26.1.2",
32
+ "typescript": "^5.7.2",
33
+ "vitest": "^3.0.0"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/vincemakes/kiso.git",
38
+ "directory": "packages/client"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/vincemakes/kiso/issues"
42
+ },
43
+ "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/client#readme"
44
+ }