feat(sdk): P2.5 @insignia/iios-kernel-client — REST + socket facade
MessageSocket (open/send/read/typing via emitWithAck, reconnect re-opens thread, no socket.io types leak) + RestClient (ingest/getThreadMessages/sendMessage). tsup ESM+dts. Facade unit test with injectable fake socket (3 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@insignia/iios-kernel-client",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@insignia/iios-contracts": "workspace:*",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './types';
|
||||
export { MessageSocket, type MessageSocketConfig } from './message-socket';
|
||||
export { RestClient, type RestConfig } from './rest';
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MessageSocket } from './message-socket';
|
||||
import type { SocketLike } from './types';
|
||||
|
||||
class FakeSocket implements SocketLike {
|
||||
handlers = new Map<string, Array<(...a: unknown[]) => void>>();
|
||||
acks: Array<{ event: string; payload: unknown }> = [];
|
||||
ackReturn: unknown = { threadId: 't1', status: 'OPEN', history: [] };
|
||||
|
||||
on(event: string, handler: (...a: unknown[]) => void): unknown {
|
||||
const list = this.handlers.get(event) ?? [];
|
||||
list.push(handler);
|
||||
this.handlers.set(event, list);
|
||||
return this;
|
||||
}
|
||||
off(event: string, handler: (...a: unknown[]) => void): unknown {
|
||||
this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== handler));
|
||||
return this;
|
||||
}
|
||||
emit(_event: string, ..._args: unknown[]): unknown {
|
||||
return this;
|
||||
}
|
||||
async emitWithAck(event: string, ...args: unknown[]): Promise<unknown> {
|
||||
this.acks.push({ event, payload: args[0] });
|
||||
return this.ackReturn;
|
||||
}
|
||||
connect(): unknown {
|
||||
return this;
|
||||
}
|
||||
disconnect(): unknown {
|
||||
return this;
|
||||
}
|
||||
trigger(event: string, ...args: unknown[]): void {
|
||||
(this.handlers.get(event) ?? []).forEach((h) => h(...args));
|
||||
}
|
||||
listenerCount(event: string): number {
|
||||
return (this.handlers.get(event) ?? []).length;
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = { serviceUrl: 'http://localhost:3200', token: 'tok' };
|
||||
|
||||
describe('MessageSocket facade', () => {
|
||||
it('on() registers a handler and the returned fn unsubscribes', () => {
|
||||
const fake = new FakeSocket();
|
||||
const ms = new MessageSocket(cfg, fake);
|
||||
const off = ms.on('message', () => {});
|
||||
expect(fake.listenerCount('message')).toBe(1);
|
||||
off();
|
||||
expect(fake.listenerCount('message')).toBe(0);
|
||||
});
|
||||
|
||||
it('sendMessage invokes emitWithAck("send_message", payload)', async () => {
|
||||
const fake = new FakeSocket();
|
||||
fake.ackReturn = { id: 'm1', threadId: 't1', content: 'hello' };
|
||||
const ms = new MessageSocket(cfg, fake);
|
||||
await ms.sendMessage('t1', 'hello', { contentRef: 'sas://x' });
|
||||
const call = fake.acks.find((a) => a.event === 'send_message');
|
||||
expect(call?.payload).toMatchObject({ threadId: 't1', content: 'hello', contentRef: 'sas://x' });
|
||||
});
|
||||
|
||||
it('openThread returns the ack and tracks the thread', async () => {
|
||||
const fake = new FakeSocket();
|
||||
fake.ackReturn = { threadId: 't9', status: 'OPEN', history: [] };
|
||||
const ms = new MessageSocket(cfg, fake);
|
||||
const r = await ms.openThread();
|
||||
expect(r.threadId).toBe('t9');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { io } from 'socket.io-client';
|
||||
import type { Message, OpenThreadResult, MessageEvents, SocketLike } from './types';
|
||||
|
||||
export interface MessageSocketConfig {
|
||||
serviceUrl: string;
|
||||
token: string;
|
||||
autoConnect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-agnostic facade over the `/message` Socket.io namespace (ports the
|
||||
* support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck.
|
||||
* On reconnect it re-opens the current thread so subscriptions resume with no
|
||||
* lost messages (the docs' disconnect/reconnect requirement).
|
||||
*/
|
||||
export class MessageSocket {
|
||||
private readonly socket: SocketLike;
|
||||
private currentThreadId: string | null = null;
|
||||
|
||||
constructor(config: MessageSocketConfig, socket?: SocketLike) {
|
||||
this.socket =
|
||||
socket ??
|
||||
(io(`${config.serviceUrl.replace(/\/$/, '')}/message`, {
|
||||
auth: { token: config.token },
|
||||
transports: ['websocket'],
|
||||
autoConnect: config.autoConnect ?? true,
|
||||
}) as unknown as SocketLike);
|
||||
|
||||
// Re-open the active thread after a reconnect.
|
||||
this.socket.on('connect', () => {
|
||||
if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId });
|
||||
});
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.socket.connect();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
/** Subscribe to a server event; returns an unsubscribe fn. */
|
||||
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
|
||||
const fn = handler as (...args: unknown[]) => void;
|
||||
this.socket.on(event, fn);
|
||||
return () => this.socket.off(event, fn);
|
||||
}
|
||||
|
||||
async openThread(threadId?: string): Promise<OpenThreadResult> {
|
||||
const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult;
|
||||
this.currentThreadId = result.threadId;
|
||||
return result;
|
||||
}
|
||||
|
||||
async sendMessage(threadId: string, content: string, opts?: { contentRef?: string }): Promise<Message> {
|
||||
return (await this.socket.emitWithAck('send_message', {
|
||||
threadId,
|
||||
content,
|
||||
contentRef: opts?.contentRef,
|
||||
})) as Message;
|
||||
}
|
||||
|
||||
async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> {
|
||||
return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean };
|
||||
}
|
||||
|
||||
typing(threadId: string): void {
|
||||
this.socket.emit('typing', { threadId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||
import type { Message } from './types';
|
||||
|
||||
export interface RestConfig {
|
||||
serviceUrl: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/** REST/polling client for kernel reads and the native-send fallback. */
|
||||
export class RestClient {
|
||||
constructor(private readonly config: RestConfig) {}
|
||||
|
||||
private url(path: string): string {
|
||||
return `${this.config.serviceUrl.replace(/\/$/, '')}${path}`;
|
||||
}
|
||||
|
||||
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
||||
const h: Record<string, string> = { 'content-type': 'application/json', ...extra };
|
||||
if (this.config.token) h.authorization = `Bearer ${this.config.token}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async getThreadMessages(threadId: string): Promise<{ threadId: string; messages: unknown[] }> {
|
||||
const r = await fetch(this.url(`/v1/threads/${threadId}/messages`), { headers: this.headers() });
|
||||
if (!r.ok) throw new Error(`getThreadMessages ${r.status}`);
|
||||
return (await r.json()) as { threadId: string; messages: unknown[] };
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
threadId: string,
|
||||
content: string,
|
||||
opts?: { contentRef?: string; idempotencyKey?: string },
|
||||
): Promise<Message> {
|
||||
const r = await fetch(this.url(`/v1/threads/${threadId}/messages`), {
|
||||
method: 'POST',
|
||||
headers: this.headers(opts?.idempotencyKey ? { 'idempotency-key': opts.idempotencyKey } : {}),
|
||||
body: JSON.stringify({ content, contentRef: opts?.contentRef }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`sendMessage ${r.status}`);
|
||||
return (await r.json()) as Message;
|
||||
}
|
||||
|
||||
async ingest(body: IngestInteractionRequest, idempotencyKey: string): Promise<unknown> {
|
||||
const r = await fetch(this.url('/v1/interactions/ingest'), {
|
||||
method: 'POST',
|
||||
headers: this.headers({ 'idempotency-key': idempotencyKey }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`ingest ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Wire shapes the kernel emits over socket / returns over REST (align with service). */
|
||||
export interface Message {
|
||||
id: string;
|
||||
threadId: string;
|
||||
senderActorId: string;
|
||||
content: string;
|
||||
contentRef?: string;
|
||||
traceId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface OpenThreadResult {
|
||||
threadId: string;
|
||||
status: string;
|
||||
history: Message[];
|
||||
}
|
||||
|
||||
export interface ReceiptEvent {
|
||||
interactionId: string;
|
||||
actorId: string;
|
||||
kind: 'READ' | 'DELIVERED';
|
||||
}
|
||||
|
||||
export interface TypingEvent {
|
||||
threadId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface MessageEvents {
|
||||
message: (m: Message) => void;
|
||||
receipt: (e: ReceiptEvent) => void;
|
||||
typing: (e: TypingEvent) => void;
|
||||
}
|
||||
|
||||
/** Minimal socket surface so the facade can be unit-tested with a fake. */
|
||||
export interface SocketLike {
|
||||
on(event: string, handler: (...args: unknown[]) => void): unknown;
|
||||
off(event: string, handler: (...args: unknown[]) => void): unknown;
|
||||
emit(event: string, ...args: unknown[]): unknown;
|
||||
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
|
||||
connect(): unknown;
|
||||
disconnect(): unknown;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
external: ['react'],
|
||||
});
|
||||
Generated
+532
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user