13 KiB
4NK Chat via iframe (ihm_client) and postMessage
This document specifies a chat feature embedded as an iframe of ihm_client, communicating with the host application through postMessage. It maximizes reuse of existing MessageType where appropriate and introduces a minimal, versioned CHAT_* message family for chat-specific flows.
Scope and roles
- Host app: embeds
ihm_clientin an iframe, uses theskeletonMessageBus(or host-native bus) to exchange messages. - ihm_client (iframe): renders the chat UI, persists data, validates tokens, and routes messages.
- sdk_common: unchanged. No modification required for the chat feature.
- sdk_client: unchanged. No modification required; chat can be driven via
skeletonMessageBus. - skeleton: integrates the iframe and routes messages via
iframe.contentWindow.postMessagewith strictorigin.
Reused MessageType and rationale
Use the existing MessageType values from ihm_client/src/models/process.model.ts as-is, where they semantically fit:
LISTENING: iframe readiness ping sent byihm_clientat startup. The host treats this as the chat iframe being ready.ERROR: generic error channel for any failure (validation, auth, unsupported message). Includecode,reason, andrefMessageIdin the payload.VALIDATE_TOKENandRENEW_TOKEN: reuse token validation/refresh flows for gated chat operations.GET_PAIRING_ID: allows host to retrieve the user/device pairing id to correlate chat identity if needed.
These are intentionally reused to avoid duplicating platform primitives. All chat-specific operations are namespaced under CHAT_* below.
Message envelope (shared)
type: one of existingMessageTypeor aCHAT_*value (see below)messageId: string (uuid v4), unique per messagecorrelationId: string (optional), set on replies to refer to the originalmessageIdts: ISO 8601 timestamporigin: implicit frompostMessagecontext; the receiver must validateevent.originpayload: object, schema depends ontypeversion: string, e.g.chat/1.0
Validation: implemented locally (no sdk_common changes). Add lightweight runtime guards in ihm_client and optional helpers in sdk_client/skeleton.
Single generic message type for chat
Introduce only one new MessageType value: CHANNEL_MESSAGE.
All chat operations are multiplexed through CHANNEL_MESSAGE using an action field inside the payload. This minimizes new types while reusing existing primitives (LISTENING, ERROR, VALIDATE_TOKEN, RENEW_TOKEN, GET_PAIRING_ID).
CHANNEL_MESSAGE payload schema:
action: one of'SUBSCRIBE' | 'UNSUBSCRIBE''SEND'(send a message)'EVENT'(deliver an event from iframe to host)'TYPING''READ''HISTORY_REQ' | 'HISTORY_RES''CHANNEL_LIST_REQ' | 'CHANNEL_LIST_RES'
channelId?: string (required for channel-scoped actions)correlationId?: string (used to pair req/res)data?: object; action-specific payload:SUBSCRIBE/UNSUBSCRIBE:{}SEND:{ content: string, contentType?: 'text/markdown'|'text/plain', attachments?: Attachment[] }EVENT:{ event: 'MESSAGE'|'DELIVERED'|'ERROR'|'PRESENCE', payload: any }TYPING:{ isTyping: boolean }READ:{ messageIds: string[] }HISTORY_REQ:{ beforeTs?: string, limit?: number }HISTORY_RES:{ messages: ChatMessage[], nextBeforeTs?: string }CHANNEL_LIST_REQ:{}CHANNEL_LIST_RES:{ channels: ChatChannel[] }
Types used above:
Attachment:{ id?: string, name: string, mime: string, size?: number, dataRef?: string }ChatMessage: same structure asCHAT_MESSAGE.payloadChatChannel:{ channelId: string, title?: string, unread?: number, lastMessageTs?: string }
Security and origin validation
- The host must send a
CHAT_HANDSHAKEimmediately upon receivingLISTENINGfrom the iframe. - The iframe must validate
event.originagainstallowedOriginsand respond withCHAT_ACKorERROR. - All operations that require authentication should reuse
VALIDATE_TOKEN/RENEW_TOKENbefore proceeding; the iframe already has token flows. - Do not place secrets in clear within
postMessagepayloads; if encryption is needed later, wrapcontentwith a ciphertext field (out of scope for v1).
Persistence and offline (ihm_client)
- Reuse the service worker + IndexedDB layer (
database.service.tsandservice-workers/database.worker.js). - New stores:
channelsandmessages(indexed bychannelId,serverTs,messageId). - Batch writes can reuse existing
BATCH_WRITINGpath; single writes viaADD_OBJECT. - Outbox: queue unsent
CHAT_MESSAGE_SENDfor retry; deliverCHAT_ACKonly when accepted to the outbox; emit deliveryCHAT_MESSAGEwhen persisted/confirmed.
Integration points (code)
ihm_client/src/router.ts:- Add handlers in
registerAllListeners()switch for the newCHAT_*types (keep existingLISTENING,ERROR, token flows). - Use
window.parent.postMessagefor responses, preservingmessageId/correlationId.
- Add handlers in
skeleton/src/sdk/MessageBus.ts:- Reuse existing
postMessageto iframe; add a registry for pending requests keyed bymessageIdto resolve promises oncorrelationId. - Filter by strict
origin.
- Reuse existing
sdk_common:- No changes.
Note:
sdk_clientremains unchanged. If desired later, a thin optional wrapper can live outsidesdk_client.
- No changes.
Note:
State flows
-
Ready
- iframe → host:
LISTENING(déjà existant) - host may optionally validate tokens using
VALIDATE_TOKEN/RENEW_TOKEN.
- iframe → host:
-
Send message
- host → iframe:
CHANNEL_MESSAGE { action: 'SEND', channelId, data: { content, ... } } - iframe → host:
CHANNEL_MESSAGE { action: 'EVENT', data: { event: 'DELIVERED', payload: { refMessageId } } } - iframe → host:
CHANNEL_MESSAGE { action: 'EVENT', data: { event: 'MESSAGE', payload: ChatMessage } }
- host → iframe:
-
Typing / Read
- host ↔ iframe:
CHANNEL_MESSAGE { action: 'TYPING', channelId, data: { isTyping } } - host → iframe:
CHANNEL_MESSAGE { action: 'READ', channelId, data: { messageIds } }
- host ↔ iframe:
-
History / Channels
- host → iframe:
CHANNEL_MESSAGE { action: 'HISTORY_REQ', channelId, data: { beforeTs?, limit? } } - iframe → host:
CHANNEL_MESSAGE { action: 'HISTORY_RES', channelId, data: { messages, nextBeforeTs? } } - host → iframe:
CHANNEL_MESSAGE { action: 'CHANNEL_LIST_REQ' } - iframe → host:
CHANNEL_MESSAGE { action: 'CHANNEL_LIST_RES', data: { channels } }
- host → iframe:
Error handling
- Use existing
ERRORtype across all failures. Payload:{ code: 'UNAUTHORIZED'|'INVALID_PAYLOAD'|'UNSUPPORTED_VERSION'|'ORIGIN_FORBIDDEN'|'INTERNAL', reason: string, details?: any, refMessageId?: string }
Versioning
version: 'chat/1.0'in allCHAT_*envelopes.- If version mismatch, iframe must respond with
ERROR { code: 'UNSUPPORTED_VERSION' }.
Telemetry (optional)
- Log key events: handshake_ok/fail, subscribe/unsubscribe, send/recv, history_page, ack_timeout.
- Avoid logging payload content; log sizes and identifiers only.
Minimal host-side example
// skeleton host pseudo-code
const bus = new MessageBus(iframe, { origin });
bus.on('message', (env) => {/* route CHAT_MESSAGE, ACK, ERROR */});
// Wait for LISTENING from iframe, then you can subscribe/send with CHANNEL_MESSAGE:
bus.send({
type: 'CHANNEL_MESSAGE',
messageId: uuid(),
payload: { action: 'SUBSCRIBE', channelId: 'default' }
});
Minimal iframe-side example
// inside ihm_client router handlers
if (event.data.type === 'CHANNEL_MESSAGE' && event.data.payload?.action === 'SEND') {
// validate origin + token, persist, then
window.parent.postMessage({ type: 'CHANNEL_MESSAGE', messageId: uuid(), payload: { action: 'EVENT', data: { event: 'DELIVERED', payload: { refMessageId: event.data.messageId } } } }, event.origin);
window.parent.postMessage({ type: 'CHANNEL_MESSAGE', messageId: uuid(), payload: { action: 'EVENT', data: { event: 'MESSAGE', payload: {/* delivered message */} } } }, event.origin);
}
Functional host chat UI (pseudocode)
// Host-side pseudo-UI integrating ihm_client via iframe and MessageBus
import { MessageBus } from 'skeleton/src/sdk/MessageBus';
type ChatMessage = { channelId: string; messageId: string; content: string; from: string; clientTs: string; serverTs?: string };
class ChatHostApp {
private iframe: HTMLIFrameElement;
private bus: MessageBus;
private origin: string;
private currentChannelId: string = 'default';
private messages: Record<string, ChatMessage[]> = {};
constructor(container: HTMLElement, iframeSrc: string, origin: string) {
this.origin = origin;
this.iframe = document.createElement('iframe');
this.iframe.src = iframeSrc;
this.iframe.style.width = '100%';
this.iframe.style.height = '100%';
this.iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
container.appendChild(this.iframe);
this.bus = new MessageBus(this.iframe, { origin: this.origin });
this.bus.on('message', (env: any) => this.handleMessage(env));
}
async init() {
await this.waitForListening();
await this.subscribe(this.currentChannelId);
await this.loadHistory(this.currentChannelId);
}
private waitForListening(): Promise<void> {
return new Promise((resolve) => {
const off = this.bus.on('message', (env: any) => {
if (env?.type === 'LISTENING') { off(); resolve(); }
});
});
}
private send(payload: any) {
return this.bus.send({ type: 'CHANNEL_MESSAGE', messageId: uuid(), payload });
}
async subscribe(channelId: string) {
await this.send({ action: 'SUBSCRIBE', channelId });
}
async sendMessage(channelId: string, content: string) {
await this.send({ action: 'SEND', channelId, data: { content, contentType: 'text/plain' } });
}
async setTyping(channelId: string, isTyping: boolean) {
await this.send({ action: 'TYPING', channelId, data: { isTyping } });
}
async markRead(channelId: string, messageIds: string[]) {
await this.send({ action: 'READ', channelId, data: { messageIds } });
}
async loadHistory(channelId: string, beforeTs?: string) {
const correlationId = uuid();
this.bus.expect(correlationId, (env: any) => env?.type === 'CHANNEL_MESSAGE' && env?.payload?.action === 'HISTORY_RES');
this.bus.send({ type: 'CHANNEL_MESSAGE', messageId: correlationId, payload: { action: 'HISTORY_REQ', channelId, data: { beforeTs, limit: 50 } } });
}
private handleMessage(env: any) {
// Generic ERROR
if (env?.type === 'ERROR') { console.error('[chat]', env); return; }
// CHANNEL_MESSAGE events
if (env?.type === 'CHANNEL_MESSAGE') {
const { action, data } = env.payload || {};
if (action === 'EVENT') {
switch (data?.event) {
case 'MESSAGE': {
const msg = data.payload as ChatMessage;
const list = this.messages[msg.channelId] || (this.messages[msg.channelId] = []);
list.push(msg);
this.renderThread(msg.channelId);
break;
}
case 'DELIVERED': {
// Optionally update UI state for delivery
break;
}
case 'ERROR': {
console.warn('[chat-event-error]', data?.payload);
break;
}
}
} else if (action === 'HISTORY_RES') {
const { channelId, messages } = data;
this.messages[channelId] = messages;
this.renderThread(channelId);
}
}
}
// Very simple rendering hooks (pseudo-DOM updates)
renderThread(channelId: string) {
const ul = document.querySelector('#messages') as HTMLUListElement;
if (!ul) return;
ul.innerHTML = '';
for (const m of this.messages[channelId] || []) {
const li = document.createElement('li');
li.textContent = `${m.from}: ${m.content}`;
ul.appendChild(li);
}
}
}
// Usage
const app = new ChatHostApp(document.getElementById('chat-root')!, 'https://ihm.example.com/', 'https://ihm.example.com');
app.init();
// Bind UI controls
document.getElementById('sendBtn')?.addEventListener('click', () => {
const input = document.getElementById('composer') as HTMLInputElement;
app.sendMessage('default', input.value);
input.value = '';
});
document.getElementById('composer')?.addEventListener('input', (e) => {
const typing = (e.target as HTMLInputElement).value.length > 0;
app.setTyping('default', typing);
});
Delivery plan (no changes to sdk_common or sdk_client)
skeleton: enhanceMessageBusfor request/response correlation and strict origin filtering (host-side only).ihm_client: implementCHANNEL_MESSAGEhandlers inregisterAllListeners()and a basic chat UI (list, thread, composer).- Persistence: reuse service worker/IndexedDB; add
channels/messagesstores and outbox logic withinihm_client. - Tests: unit (router handlers, runtime guards), integration (subscribe, send/recv, history, typing/read), E2E (Playwright, mocked backend).