From c5237a237a72052f070dbbfc07a486b77b0864e0 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 21 Jul 2026 17:37:41 +0530 Subject: [PATCH] =?UTF-8?q?feat(messaging-ui):=20KernelClientAdapter=20?= =?UTF-8?q?=E2=80=94=20direct-IIOS=20transport=20for=20the=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements MessagingAdapter over @insignia/iios-kernel-client (browser → IIOS, token-in): listConversations/openThread/history/send/subscribe/typing/markRead /react, mapping kernel Message/ThreadSummary/annotations onto the SDK's neutral types. currentActorId comes from the host's session (senderId space), never inferred from history — the exact bug the conformance suite kills. - lives in adapters/ (transport boundary intact — core stays transport-free) - ships from the ./adapters/kernel-client subpath (kernel-client is an optional peer dep; excluded from the main bundle) - connectKernelAdapter({serviceUrl, token, currentUserId}) convenience - passes the shared adapter conformance suite (12 tests) over the real MessageSocket facade with only the socket.io layer faked - media/attachments deferred (kernel-client has no presign yet) Co-Authored-By: Claude Opus 4.8 --- packages/iios-messaging-ui/package.json | 13 +- .../src/adapters/kernel-client.test.ts | 124 +++++++++ .../src/adapters/kernel-client.ts | 257 ++++++++++++++++++ packages/iios-messaging-ui/tsup.config.ts | 4 +- pnpm-lock.yaml | 25 +- 5 files changed, 409 insertions(+), 14 deletions(-) create mode 100644 packages/iios-messaging-ui/src/adapters/kernel-client.test.ts create mode 100644 packages/iios-messaging-ui/src/adapters/kernel-client.ts diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json index 652c6e7..4c1bd3f 100644 --- a/packages/iios-messaging-ui/package.json +++ b/packages/iios-messaging-ui/package.json @@ -14,6 +14,10 @@ "types": "./dist/adapters/mock.d.ts", "import": "./dist/adapters/mock.js" }, + "./adapters/kernel-client": { + "types": "./dist/adapters/kernel-client.d.ts", + "import": "./dist/adapters/kernel-client.js" + }, "./conformance": { "types": "./dist/conformance.d.ts", "import": "./dist/conformance.js" @@ -29,9 +33,16 @@ "test": "vitest run" }, "peerDependencies": { - "react": ">=18" + "react": ">=18", + "@insignia/iios-kernel-client": "*" + }, + "peerDependenciesMeta": { + "@insignia/iios-kernel-client": { + "optional": true + } }, "devDependencies": { + "@insignia/iios-kernel-client": "workspace:*", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.1.0", "@types/react": "^19.0.0", diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts new file mode 100644 index 0000000..961be41 --- /dev/null +++ b/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts @@ -0,0 +1,124 @@ +// Runs the shared adapter conformance suite against KernelClientAdapter — proving it satisfies +// the same contract as the mock, over the REAL MessageSocket facade. Only the lowest socket.io +// layer is faked (via kernel-client's own SocketLike seam), so the facade's wire mapping is +// exercised for real. No live IIOS required. + +import { MessageSocket } from '@insignia/iios-kernel-client'; +import type { Message as KernelMessage, SocketLike } from '@insignia/iios-kernel-client'; +import { runAdapterConformance } from '../conformance'; +import { KernelClientAdapter, type RestPort } from './kernel-client'; + +const ME = 'me'; +const SEEDED = 'th_seed'; + +/** An in-memory stand-in for the /message socket.io namespace: stores messages, echoes 'message'. */ +function makeFakeSocket(): SocketLike { + const threads = new Map([ + [ + SEEDED, + [ + { + id: 'seed_1', + threadId: SEEDED, + senderActorId: 'actor_other', + senderId: 'pp_other', + senderName: 'Other', + content: 'seeded message', + createdAt: new Date(0).toISOString(), + }, + ], + ], + ]); + const handlers = new Map void>>(); + let seq = 1; + + const fire = (event: string, payload: unknown): void => handlers.get(event)?.forEach((h) => h(payload)); + + return { + on(event, handler) { + if (!handlers.has(event)) handlers.set(event, new Set()); + handlers.get(event)!.add(handler); + return undefined; + }, + off(event, handler) { + handlers.get(event)?.delete(handler); + return undefined; + }, + emit() { + return undefined; // typing/focus — no echo needed for conformance + }, + async emitWithAck(event, payload) { + const p = (payload ?? {}) as { + threadId: string; + content?: string; + parentInteractionId?: string; + interactionId?: string; + type?: string; + value?: string; + }; + if (event === 'open_thread') { + if (!threads.has(p.threadId)) threads.set(p.threadId, []); + return { threadId: p.threadId, status: 'OPEN', history: [...threads.get(p.threadId)!] }; + } + if (event === 'send_message') { + const msg: KernelMessage = { + id: `m_${seq++}`, + threadId: p.threadId, + senderActorId: `actor_${ME}`, + senderId: ME, + senderName: ME, + content: p.content ?? '', + createdAt: new Date().toISOString(), + ...(p.parentInteractionId ? { parentInteractionId: p.parentInteractionId } : {}), + }; + if (!threads.has(p.threadId)) threads.set(p.threadId, []); + threads.get(p.threadId)!.push(msg); + fire('message', msg); + return msg; + } + if (event === 'read') return { ok: true }; + if (event === 'annotate') { + fire('annotation', { + threadId: p.threadId, + interactionId: p.interactionId, + type: p.type, + value: p.value, + op: 'add', + users: [ME], + userId: ME, + }); + return {}; + } + return {}; + }, + connect() { + return undefined; + }, + disconnect() { + return undefined; + }, + connected: true, + }; +} + +function makeFakeRest(): RestPort { + let seq = 1; + return { + async listThreads() { + return []; + }, + async createThread() { + return { threadId: `th_new_${seq++}` }; + }, + async addParticipant() { + /* governed server-side; a fake always allows */ + }, + }; +} + +function makeAdapter(): KernelClientAdapter { + const socket = new MessageSocket({ serviceUrl: 'http://iios.test', token: 'tok', autoConnect: false }, makeFakeSocket()); + return new KernelClientAdapter({ currentUserId: ME, socket, rest: makeFakeRest() }); +} + +runAdapterConformance({ makeAdapter, seededThreadId: SEEDED, openWith: ['pp_a'] }); diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.ts new file mode 100644 index 0000000..3f3541e --- /dev/null +++ b/packages/iios-messaging-ui/src/adapters/kernel-client.ts @@ -0,0 +1,257 @@ +// Transport adapter: implements MessagingAdapter over @insignia/iios-kernel-client +// (browser → IIOS directly, token-in). This is the "plug into any app with a token" +// path — the same transport chat-web and support-sdk use. +// +// It lives in adapters/ (the ONLY layer allowed to import a transport) and ships from +// its own subpath, so core UI never pulls socket code it can't use. + +import { MessageSocket, RestClient } from '@insignia/iios-kernel-client'; +import type { + AnnotationEvent, + Message as KernelMessage, + MessageEvents, + OpenThreadResult, + ReceiptEvent, + ThreadSummary, + TypingEvent, +} from '@insignia/iios-kernel-client'; +import type { MessagingAdapter } from '../adapter'; +import type { + Conversation, + Membership, + Message, + MessageEvent, + Reaction, + SendOpts, + Unsubscribe, +} from '../types'; + +/** The slice of MessageSocket the adapter needs — the real facade satisfies it; tests inject a fake. */ +export interface SocketPort { + openThread(threadId?: string, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise; + sendMessage(threadId: string, content: string, opts?: { parentInteractionId?: string; mentions?: string[] }): Promise; + react(threadId: string, interactionId: string, value: string): Promise; + markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }>; + typing(threadId: string): void; + on(event: E, handler: MessageEvents[E]): () => void; +} + +/** The slice of RestClient the adapter needs. */ +export interface RestPort { + listThreads(filter?: { metadata?: Record }): Promise; + createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record }): Promise<{ threadId: string }>; + addParticipant(threadId: string, userId: string): Promise; +} + +export interface KernelClientAdapterConfig { + /** + * The current user's id in IIOS's `senderId` space (email/username), from the host's auth — + * NEVER inferred from message history. This is exactly `currentActorId()`, and it's the bug the + * conformance suite kills: identity comes from the session, not from a message you happened to send. + */ + currentUserId: string; + socket: SocketPort; + rest: RestPort; + /** Optional opaque metadata filter for the conversation list (e.g. { source: 'crm-messenger' }). */ + threadFilter?: Record; +} + +const REACTION = 'reaction'; + +export class KernelClientAdapter implements MessagingAdapter { + private readonly me: string; + private readonly socket: SocketPort; + private readonly rest: RestPort; + private readonly threadFilter?: Record; + + /** Per-thread UI subscribers. The socket fans server events in; these fan them out. */ + private readonly listeners = new Map void>>(); + /** Reaction users per message (messageId → emoji → userSet), so an annotation delta becomes a full set. */ + private readonly reactions = new Map>>(); + private readonly joined = new Set(); + private readonly offs: Array<() => void> = []; + + constructor(cfg: KernelClientAdapterConfig) { + this.me = cfg.currentUserId; + this.socket = cfg.socket; + this.rest = cfg.rest; + this.threadFilter = cfg.threadFilter; + + this.offs.push( + this.socket.on('message', (m: KernelMessage) => { + this.ingestReactions(m); + this.emit(m.threadId, { kind: 'message', message: this.toMessage(m) }); + }), + ); + this.offs.push( + this.socket.on('typing', (e: TypingEvent) => this.emit(e.threadId, { kind: 'typing', userId: e.userId })), + ); + this.offs.push( + // Receipts carry no threadId, so fan to every open thread; the UI filters by messageId. + this.socket.on('receipt', (e: ReceiptEvent) => this.broadcast({ kind: 'receipt', messageId: e.interactionId, actorId: e.actorId })), + ); + this.offs.push( + this.socket.on('annotation', (e: AnnotationEvent) => { + if (e.type !== REACTION) return; + this.setReactionUsers(e.interactionId, e.value, e.users); + this.emit(e.threadId, { kind: 'reaction', messageId: e.interactionId, reactions: this.reactionsOf(e.interactionId) }); + }), + ); + } + + currentActorId(): string { + return this.me; + } + + async listConversations(): Promise { + const threads = await this.rest.listThreads(this.threadFilter ? { metadata: this.threadFilter } : undefined); + return threads.map((t) => this.toConversation(t)); + } + + async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { + const membership: Membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group'); + const { threadId } = await this.rest.createThread({ + membership, + creatorRole: membership === 'group' ? 'ADMIN' : 'MEMBER', + ...(p.subject ? { subject: p.subject } : {}), + }); + // Governance (DM cap, roles) is enforced server-side by IIOS/OPA; a rejected add surfaces up there. + for (const id of p.participantIds) await this.rest.addParticipant(threadId, id).catch(() => undefined); + return { threadId }; + } + + async history(threadId: string): Promise { + const res = await this.socket.openThread(threadId); // joins the thread, so live events start flowing + this.joined.add(threadId); + return res.history.map((m) => { + this.ingestReactions(m); + return this.toMessage(m); + }); + } + + async send(threadId: string, content: string, opts?: SendOpts): Promise { + // Attachments are intentionally not forwarded here: kernel-client exposes no media/presign yet, + // and the SDK Attachment carries a display `url`, not a storage `contentRef`. Media is a follow-up + // (kernel-client media methods + a contentRef on Attachment). Text + reply threading work today. + const m = await this.socket.sendMessage( + threadId, + content, + opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : undefined, + ); + return this.toMessage(m); + } + + async react(threadId: string, messageId: string, emoji: string): Promise { + await this.socket.react(threadId, messageId, emoji); + } + + subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe { + if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set()); + this.listeners.get(threadId)!.add(cb); + if (!this.joined.has(threadId)) { + this.joined.add(threadId); + void this.socket.openThread(threadId).catch(() => this.joined.delete(threadId)); + } + return () => { + this.listeners.get(threadId)?.delete(cb); + }; + } + + sendTyping(threadId: string): void { + this.socket.typing(threadId); + } + + async markRead(threadId: string, messageId: string): Promise { + await this.socket.markRead(threadId, messageId); + } + + /** Detach socket handlers. Not part of the contract — call on teardown to avoid leaks. */ + close(): void { + for (const off of this.offs) off(); + this.offs.length = 0; + this.listeners.clear(); + } + + // ── mapping ──────────────────────────────────────────────────── + private toMessage(m: KernelMessage): Message { + return { + id: m.id, + // `senderId` (email/username), NOT the actor id — it matches currentActorId() and is the + // reliable "is this mine?" field. Never inferred from history. + actorId: m.senderId ?? null, + text: m.content ?? '', + at: m.createdAt, + parentInteractionId: m.parentInteractionId ?? null, + reactions: this.reactionsOf(m.id), + }; + } + + private toConversation(t: ThreadSummary): Conversation { + const others = t.participants.filter((p) => p !== this.me); + const membership: Membership | null = t.membership === 'dm' || t.membership === 'group' ? t.membership : null; + return { + threadId: t.threadId, + title: t.subject?.trim() || others.join(', ') || 'Conversation', + subject: t.subject, + membership, + participants: [...t.participants], + unread: t.unread, + ...(t.lastMessage ? { lastMessage: t.lastMessage } : {}), + ...(t.lastAt ? { lastAt: t.lastAt } : {}), + }; + } + + // ── reaction state ───────────────────────────────────────────── + private ingestReactions(m: KernelMessage): void { + for (const a of m.annotations ?? []) { + if (a.type === REACTION) this.setReactionUsers(m.id, a.value, a.users); + } + } + + private setReactionUsers(messageId: string, emoji: string, users: string[]): void { + let byEmoji = this.reactions.get(messageId); + if (!byEmoji) { + byEmoji = new Map(); + this.reactions.set(messageId, byEmoji); + } + if (users.length === 0) byEmoji.delete(emoji); + else byEmoji.set(emoji, new Set(users)); + } + + private reactionsOf(messageId: string): Reaction[] { + const byEmoji = this.reactions.get(messageId); + if (!byEmoji) return []; + const out: Reaction[] = []; + for (const [emoji, users] of byEmoji) { + if (users.size > 0) out.push({ emoji, count: users.size, mine: users.has(this.me) }); + } + return out; + } + + // ── event fan-out ────────────────────────────────────────────── + private emit(threadId: string, e: MessageEvent): void { + this.listeners.get(threadId)?.forEach((cb) => cb(e)); + } + + private broadcast(e: MessageEvent): void { + for (const set of this.listeners.values()) set.forEach((cb) => cb(e)); + } +} + +/** Convenience: build an adapter that talks straight to IIOS with a token. */ +export function connectKernelAdapter(opts: { + serviceUrl: string; + token: string; + currentUserId: string; + threadFilter?: Record; + autoConnect?: boolean; +}): KernelClientAdapter { + const rest = new RestClient({ serviceUrl: opts.serviceUrl, token: opts.token }); + const socket = new MessageSocket({ serviceUrl: opts.serviceUrl, token: opts.token, autoConnect: opts.autoConnect ?? true }); + return new KernelClientAdapter({ + currentUserId: opts.currentUserId, + socket, + rest, + ...(opts.threadFilter ? { threadFilter: opts.threadFilter } : {}), + }); +} diff --git a/packages/iios-messaging-ui/tsup.config.ts b/packages/iios-messaging-ui/tsup.config.ts index 3baff55..aea0692 100644 --- a/packages/iios-messaging-ui/tsup.config.ts +++ b/packages/iios-messaging-ui/tsup.config.ts @@ -6,9 +6,9 @@ export default defineConfig({ // entry, never reachable from `index`, because it imports vitest, and bundling // that into the main barrel would drag a test runner into every consumer's // production build. - entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/conformance.ts'], + entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'], format: ['esm'], dts: true, clean: true, - external: ['react', 'react-dom', 'vitest'], + external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdaaecf..faeced5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,6 +328,9 @@ importers: packages/iios-messaging-ui: devDependencies: + '@insignia/iios-kernel-client': + specifier: workspace:* + version: link:../iios-kernel-client '@testing-library/dom': specifier: ^10.4.0 version: 10.4.1 @@ -520,6 +523,9 @@ packages: resolution: {integrity: sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@aws-sdk/checksums@3.1000.18': resolution: {integrity: sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==} engines: {node: '>=20.0.0'} @@ -592,9 +598,6 @@ packages: resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} - '@asamuzakjp/css-color@3.2.0': - resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -3677,6 +3680,14 @@ snapshots: transitivePeerDependencies: - chokidar + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@aws-sdk/checksums@3.1000.18': dependencies: '@aws-sdk/core': 3.975.3 @@ -3842,14 +3853,6 @@ snapshots: '@aws/lambda-invoke-store@0.3.0': {} - '@asamuzakjp/css-color@3.2.0': - dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 10.4.3 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7