@irisrun/client-sdk 0.1.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.
@@ -0,0 +1,112 @@
1
+ import type { Json } from "@irisrun/core";
2
+ export declare const PACKAGE = "@irisrun/client-sdk";
3
+ /** What a durable session is addressed by: the channel's id + the current token. */
4
+ export interface SessionHandle {
5
+ sessionId: string;
6
+ continuationToken: string;
7
+ }
8
+ export interface IrisClientOptions {
9
+ /** e.g. "http://127.0.0.1:8787" (a trailing slash is trimmed). */
10
+ baseUrl: string;
11
+ /** injected for tests; default = globalThis.fetch. */
12
+ fetchImpl?: typeof fetch;
13
+ /** default = globalThis.WebSocket (WS is reserved — see SendOptions.transport). */
14
+ WebSocketImpl?: typeof WebSocket;
15
+ /** RESUME: bind to an existing session (same running serve channel owns the token). */
16
+ handle?: SessionHandle;
17
+ }
18
+ /**
19
+ * The SDK's OWN request type (NOT a @irisrun/core type). The server's `bodyToInput`
20
+ * normalizes a missing/empty `messages`, so the SDK keeps it optional; `@irisrun/core`'s
21
+ * `HarnessInput` requires it.
22
+ */
23
+ export interface TurnInput {
24
+ messages?: {
25
+ role: string;
26
+ content: string;
27
+ }[];
28
+ }
29
+ /** EXACTLY the server's TurnOutcome statuses (no client-invented values). */
30
+ export type TurnStatus = "finished" | "parked" | "contended" | "aborted";
31
+ /**
32
+ * The wire event union — defined LOCALLY (pure data mirroring channel-rest/events.ts),
33
+ * deliberately NOT imported from @irisrun/channel-rest (keeps the SDK node:-free).
34
+ */
35
+ export type StreamEvent = {
36
+ type: "record";
37
+ record: Json;
38
+ } | {
39
+ type: "delta";
40
+ text: string;
41
+ } | {
42
+ type: "outcome";
43
+ sessionId: string;
44
+ status: TurnStatus;
45
+ output?: Json;
46
+ wait?: Json;
47
+ current?: number;
48
+ continuationToken?: string;
49
+ } | {
50
+ type: "error";
51
+ message: string;
52
+ };
53
+ export interface TurnResult {
54
+ sessionId: string;
55
+ continuationToken?: string;
56
+ status: TurnStatus;
57
+ output?: Json;
58
+ wait?: Json;
59
+ current?: number;
60
+ /** the concatenation of streamed `delta`s (streaming turns only). */
61
+ text?: string;
62
+ }
63
+ export interface StreamCallbacks {
64
+ onRecord?(record: Json): void;
65
+ onDelta?(text: string): void;
66
+ /** a mid-stream `error` event (after the stream already opened). */
67
+ onError?(message: string): void;
68
+ }
69
+ export interface SendOptions {
70
+ stream?: boolean;
71
+ /** "sse" (default when streaming) or "ws" (reserved — throws until implemented). */
72
+ transport?: "sse" | "ws";
73
+ callbacks?: StreamCallbacks;
74
+ }
75
+ /** A loud, structured error — never a silent resolve. */
76
+ export declare class IrisError extends Error {
77
+ readonly code: string;
78
+ readonly status?: number;
79
+ constructor(message: string, code: string, status?: number);
80
+ }
81
+ /**
82
+ * Parse complete SSE frames out of `buffer`, returning the parsed events and the
83
+ * trailing PARTIAL frame (`rest`) to carry into the next read. Frames are separated
84
+ * by a blank line (`\n\n`); each frame's `data:` line(s) carry the full StreamEvent
85
+ * JSON (the `event:` line is redundant — the JSON has its own `type`). A frame whose
86
+ * data is not valid JSON is skipped defensively (the server emits valid JSON).
87
+ */
88
+ export declare function parseSseFrames(buffer: string): {
89
+ events: StreamEvent[];
90
+ rest: string;
91
+ };
92
+ /** Decide whether a client with this stored handle should START fresh or RESUME. */
93
+ export declare function decideStartOrResume(handle: SessionHandle | null): "start" | "resume";
94
+ export declare class IrisClient {
95
+ private readonly baseUrl;
96
+ private readonly fetchImpl;
97
+ private readonly WebSocketImpl?;
98
+ private session;
99
+ constructor(opts: IrisClientOptions);
100
+ /** The current {sessionId, continuationToken}, or null before the first start. */
101
+ get handle(): SessionHandle | null;
102
+ /** START a new session — POST /v1/session. */
103
+ start(input?: TurnInput, opts?: SendOptions): Promise<TurnResult>;
104
+ /** CONTINUE the held session — POST /v1/session/<id>/message (presents the token).
105
+ * `async` so the no-session guard surfaces as a rejected promise, never a sync throw. */
106
+ send(input?: TurnInput, opts?: SendOptions): Promise<TurnResult>;
107
+ private adopt;
108
+ private turn;
109
+ private bufferedTurn;
110
+ private sseTurn;
111
+ private httpError;
112
+ }
package/dist/index.js ADDED
@@ -0,0 +1,198 @@
1
+ export const PACKAGE = "@irisrun/client-sdk";
2
+ /** A loud, structured error — never a silent resolve. */
3
+ export class IrisError extends Error {
4
+ code;
5
+ status;
6
+ constructor(message, code, status) {
7
+ super(message);
8
+ this.name = "IrisError";
9
+ this.code = code;
10
+ this.status = status;
11
+ }
12
+ }
13
+ // --- pure, unit-testable helpers (no IO) -------------------------------------
14
+ /**
15
+ * Parse complete SSE frames out of `buffer`, returning the parsed events and the
16
+ * trailing PARTIAL frame (`rest`) to carry into the next read. Frames are separated
17
+ * by a blank line (`\n\n`); each frame's `data:` line(s) carry the full StreamEvent
18
+ * JSON (the `event:` line is redundant — the JSON has its own `type`). A frame whose
19
+ * data is not valid JSON is skipped defensively (the server emits valid JSON).
20
+ */
21
+ export function parseSseFrames(buffer) {
22
+ const events = [];
23
+ let rest = buffer;
24
+ let sep = rest.indexOf("\n\n");
25
+ while (sep !== -1) {
26
+ const frame = rest.slice(0, sep);
27
+ rest = rest.slice(sep + 2);
28
+ const data = frame
29
+ .split("\n")
30
+ .filter((l) => l.startsWith("data:"))
31
+ .map((l) => l.slice(5).replace(/^ /, ""))
32
+ .join("\n");
33
+ if (data !== "") {
34
+ try {
35
+ events.push(JSON.parse(data));
36
+ }
37
+ catch {
38
+ // Surface a corrupt frame LOUDLY (no silent skip) as an `error` event — the
39
+ // consumer turns it into onError() + a rejected turn. The server only emits
40
+ // valid JSON, so this is defensive (e.g. a proxy mangled the stream).
41
+ events.push({ type: "error", message: `malformed SSE data frame: ${data.slice(0, 120)}` });
42
+ }
43
+ }
44
+ sep = rest.indexOf("\n\n");
45
+ }
46
+ return { events, rest };
47
+ }
48
+ /** Decide whether a client with this stored handle should START fresh or RESUME. */
49
+ export function decideStartOrResume(handle) {
50
+ return handle === null ? "start" : "resume";
51
+ }
52
+ // --- the client --------------------------------------------------------------
53
+ export class IrisClient {
54
+ baseUrl;
55
+ fetchImpl;
56
+ WebSocketImpl;
57
+ session;
58
+ constructor(opts) {
59
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
60
+ const f = opts.fetchImpl ?? globalThis.fetch;
61
+ if (typeof f !== "function") {
62
+ throw new IrisError("no fetch implementation available (pass fetchImpl)", "no-fetch");
63
+ }
64
+ this.fetchImpl = f;
65
+ this.WebSocketImpl = opts.WebSocketImpl ?? globalThis.WebSocket;
66
+ this.session = opts.handle ?? null;
67
+ }
68
+ /** The current {sessionId, continuationToken}, or null before the first start. */
69
+ get handle() {
70
+ return this.session;
71
+ }
72
+ /** START a new session — POST /v1/session. */
73
+ async start(input = {}, opts = {}) {
74
+ return this.turn(`${this.baseUrl}/v1/session`, input, null, opts);
75
+ }
76
+ /** CONTINUE the held session — POST /v1/session/<id>/message (presents the token).
77
+ * `async` so the no-session guard surfaces as a rejected promise, never a sync throw. */
78
+ async send(input = {}, opts = {}) {
79
+ if (this.session === null) {
80
+ throw new IrisError("send() with no session — call start() first or construct the client with a handle", "no-session");
81
+ }
82
+ const url = `${this.baseUrl}/v1/session/${encodeURIComponent(this.session.sessionId)}/message`;
83
+ return this.turn(url, input, this.session.continuationToken, opts);
84
+ }
85
+ // Adopt the (possibly rotated) token. The server always issues one on a committed
86
+ // turn; on `contended` it returns the UNCHANGED prior token. Keep the prior token
87
+ // if the server omitted one (defensive — mirrors toOutcomeEvent's optional token).
88
+ adopt(sessionId, token) {
89
+ const continuationToken = token ?? this.session?.continuationToken;
90
+ this.session = continuationToken === undefined ? null : { sessionId, continuationToken };
91
+ }
92
+ async turn(url, input, token, opts) {
93
+ if (opts.transport === "ws") {
94
+ // The serve WS server is connection-scoped (a fresh connection can only START;
95
+ // continuation needs the SAME held connection). A correct client is a
96
+ // held-connection model — reserved. Refuse LOUDLY rather than fake it. The
97
+ // edge/web paths are SSE-only anyway (edge declares websockets:false).
98
+ throw new IrisError("ws transport is not yet implemented in client-sdk; use SSE (stream:true). The edge/web paths are SSE-only.", "ws-unsupported");
99
+ }
100
+ const body = { ...input };
101
+ if (token !== null)
102
+ body.continuationToken = token;
103
+ return opts.stream ? this.sseTurn(url, body, opts) : this.bufferedTurn(url, body);
104
+ }
105
+ async bufferedTurn(url, body) {
106
+ const res = await this.fetchImpl(url, {
107
+ method: "POST",
108
+ headers: { "content-type": "application/json" },
109
+ body: JSON.stringify(body),
110
+ });
111
+ if (!res.ok)
112
+ throw await this.httpError(res);
113
+ const data = (await res.json());
114
+ this.adopt(data.sessionId, data.continuationToken);
115
+ return {
116
+ sessionId: data.sessionId,
117
+ continuationToken: data.continuationToken,
118
+ status: data.status,
119
+ output: data.output,
120
+ wait: data.wait,
121
+ current: data.current,
122
+ };
123
+ }
124
+ async sseTurn(url, body, opts) {
125
+ const res = await this.fetchImpl(url, {
126
+ method: "POST",
127
+ headers: { "content-type": "application/json", accept: "text/event-stream" },
128
+ body: JSON.stringify(body),
129
+ });
130
+ // A loud-4xx refusal (stale/missing token, in-flight) is a JSON error BEFORE the
131
+ // stream opens — surface it the same as a buffered error.
132
+ if (!res.ok)
133
+ throw await this.httpError(res);
134
+ if (res.body === null)
135
+ throw new IrisError("streaming response had no body", "no-body");
136
+ const reader = res.body.getReader();
137
+ const decoder = new TextDecoder();
138
+ let buffer = "";
139
+ const deltas = [];
140
+ let outcome = null;
141
+ let streamError = null;
142
+ const consume = (events) => {
143
+ for (const ev of events) {
144
+ if (ev.type === "delta") {
145
+ deltas.push(ev.text);
146
+ opts.callbacks?.onDelta?.(ev.text);
147
+ }
148
+ else if (ev.type === "record") {
149
+ opts.callbacks?.onRecord?.(ev.record);
150
+ }
151
+ else if (ev.type === "error") {
152
+ streamError = ev.message;
153
+ opts.callbacks?.onError?.(ev.message);
154
+ }
155
+ else {
156
+ outcome = ev;
157
+ }
158
+ }
159
+ };
160
+ for (;;) {
161
+ const { value, done } = await reader.read();
162
+ if (value !== undefined)
163
+ buffer += decoder.decode(value, { stream: true });
164
+ const parsed = parseSseFrames(buffer);
165
+ buffer = parsed.rest;
166
+ consume(parsed.events);
167
+ if (done)
168
+ break;
169
+ }
170
+ if (outcome === null) {
171
+ // The channel emits `error` then ends WITHOUT an outcome on a post-open throw.
172
+ throw new IrisError(streamError ?? "stream ended without an outcome", streamError !== null ? "stream-error" : "no-outcome");
173
+ }
174
+ const oc = outcome;
175
+ this.adopt(oc.sessionId, oc.continuationToken);
176
+ return {
177
+ sessionId: oc.sessionId,
178
+ continuationToken: oc.continuationToken,
179
+ status: oc.status,
180
+ output: oc.output,
181
+ wait: oc.wait,
182
+ current: oc.current,
183
+ text: deltas.join(""),
184
+ };
185
+ }
186
+ async httpError(res) {
187
+ let message = `HTTP ${res.status}`;
188
+ try {
189
+ const j = (await res.json());
190
+ if (j !== null && typeof j.error === "string")
191
+ message = j.error;
192
+ }
193
+ catch {
194
+ /* non-JSON error body — keep the status line */
195
+ }
196
+ return new IrisError(message, "http-error", res.status);
197
+ }
198
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@irisrun/client-sdk",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Iris client SDK — a thin, isomorphic (browser + Node 24) client over the `iris serve` two-identifier protocol (sessionId minted by the channel; single-use continuationToken owned/rotated by the channel). Buffered + streaming (SSE). Zero runtime deps: uses only global fetch / TextDecoder / ReadableStream. Defines its OWN local StreamEvent wire union (no @irisrun/channel-rest import — that pulls node:http). WS transport is reserved (held-connection model; throws loudly until implemented).",
6
+ "exports": {
7
+ ".": {
8
+ "iris-src": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "dependencies": {
14
+ "@irisrun/core": "^0.1.0"
15
+ },
16
+ "license": "MIT",
17
+ "engines": {
18
+ "node": ">=24"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/xoai/iris.git",
26
+ "directory": "packages/client-sdk"
27
+ },
28
+ "homepage": "https://github.com/xoai/iris#readme",
29
+ "files": [
30
+ "dist"
31
+ ]
32
+ }