UNPKG

@kitn.ai/chat

Version:

Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.

260 lines (236 loc) 10.5 kB
// src/remote/provider-runtime.ts // The provider iframe-side bridge: handshake responder + CardHost impl + auto-height. // Security-critical, stateful, vanilla DOM ONLY — NO SolidJS imports (ships in the // SolidJS-free `@kitn.ai/chat/provider` bundle). See the iframe-transport design spec // (Provider iframe runtime surface + addendum H-A/H-D/H-E/H-J/H-H). import { type CardEnvelope, type CardContext, type CardEvent, type CardHost, } from '../primitives/card-contract'; import { createPacker, isCardWireFrame, type WireMessage } from './wire'; import { assertOrigin, redactFrame } from './origin'; import { negotiateVersion } from './version'; import { hasPollutionKey } from './validate'; import { observeContentHeight } from '../primitives/use-resize-observer'; import { validateAgainstSchema, type JsonSchema } from '../primitives/card-validate'; /** A provider-side renderer for one card `type`. Renamed (was CardRenderer) per H-K * to avoid colliding with the exported Solid `<CardRenderer>` component. */ export interface RemoteCardRenderer { /** The card `type` this renderer handles. */ type: string; /** Optional JSON Schema for this type's `data`; validated best-effort before mount. */ schema?: JsonSchema; /** Mount into `root`, given the envelope + a CardHost. Return a disposer. */ mount(root: HTMLElement, envelope: CardEnvelope, host: CardHost): () => void; } export interface CreateCardBridgeOptions { /** Element the card mounts into + whose height is observed. */ root: HTMLElement; /** Renderers by card type. Unknown type → inline "unsupported card" + event{error}. */ renderers: RemoteCardRenderer[]; /** Versions this runtime supports (default ['1']). */ supportedVersions?: string[]; } export interface CardBridge { /** Begin: listen for `hello`, complete handshake, render on `render`. */ start(): void; /** Stop observers + listeners + dispose current card. */ stop(): void; } const DEFAULT_VERSIONS = ['1']; /** A minimal complete-enough default so `CardHost.context()` never returns undefined * (the contract's return is non-optional) before the first `context` frame. */ function defaultContext(): CardContext { return { theme: { mode: 'light' }, locale: 'en' }; } export function createCardBridge(options: CreateCardBridgeOptions): CardBridge { const { root, renderers } = options; const hostVersions = options.supportedVersions ?? DEFAULT_VERSIONS; const rendererByType = new Map(renderers.map((r) => [r.type, r] as const)); // ── Locked-after-hello bridge identity (H-A/H-C). null until the first valid hello. ── let lockedSource: unknown = null; let lockedOrigin: string | null = null; let nonce: string | null = null; let negotiated: string | null = null; let packer: ReturnType<typeof createPacker> | null = null; let stopped = false; // tombstone: rejects all inbound once the version is unsupported / stopped // ── Render state (H-E). One card at a time in v1. ── let context: CardContext | null = null; let currentId: string | null = null; let currentEnvelope: CardEnvelope | null = null; let dispose: (() => void) | null = null; let stopObserver: (() => void) | null = null; // The theme key applied at the current mount. A `context` push only forces a // dispose+remount when the theme actually changes (renderers apply theme // imperatively at mount); token/locale/a11y refreshes are read on-demand via // host.context() and must NOT wipe in-progress card state. let appliedThemeKey: string | null = null; const themeKey = (c: CardContext | null) => JSON.stringify(c?.theme ?? null); function warnDrop(data: unknown): void { try { console.warn('[kc-remote]', redactFrame(data)); } catch { /* best-effort */ } } function postUp(message: WireMessage): void { if (!packer || !lockedOrigin) return; try { (parent as Window).postMessage(packer(message), lockedOrigin); } catch { /* best-effort */ } } const cardHost: CardHost = { context: () => context ?? defaultContext(), // HOST CONTRACT: the host MUST validate inbound event.cardId against the active card, // because the provider cannot fence a replaced/stale renderer's host.emit calls. emit: (event: CardEvent) => postUp({ dir: 'up', kind: 'event', event }), }; function disposeCurrent(): void { if (dispose) { try { dispose(); } catch { /* renderer disposer threw — best-effort */ } } dispose = null; currentId = null; currentEnvelope = null; appliedThemeKey = null; } function startObserver(): void { // Tear down any existing observer so observeContentHeight's internal `last` baseline // re-initializes to -1 and the newly-mounted card always emits its initial height. stopObserver?.(); stopObserver = null; stopObserver = observeContentHeight(root, (height) => { if (!currentId) return; cardHost.emit({ kind: 'resize', cardId: currentId, height }); }); } function renderUnsupported(envelope: CardEnvelope, message: string): void { disposeCurrent(); root.replaceChildren(); const el = document.createElement('div'); el.setAttribute('role', 'alert'); el.textContent = `Unsupported card: ${envelope.type}`; root.appendChild(el); currentId = envelope.id; startObserver(); // placeholder has a height the host must know about cardHost.emit({ kind: 'error', cardId: envelope.id, message }); } function handleRender(envelope: CardEnvelope): void { // Prototype-pollution guard (H-D): nested data is forwarded to app handlers. if (hasPollutionKey(envelope.data)) { renderUnsupported(envelope, 'rejected: payload contains a prohibited key'); return; } const renderer = rendererByType.get(envelope.type); if (!renderer) { renderUnsupported(envelope, `no renderer registered for type "${envelope.type}"`); return; } // Best-effort schema validation (H-D). On failure: placeholder + event{error}. if (renderer.schema) { const result = validateAgainstSchema(renderer.schema, envelope.data); if (!result.valid) { renderUnsupported(envelope, `invalid card data: ${result.errors.join('; ')}`); return; } } // Re-render rules (H-E): same id OR different id → dispose current, clear, mount new // (v1 has no in-place renderer update path). disposeCurrent(); root.replaceChildren(); try { dispose = renderer.mount(root, envelope, cardHost) ?? (() => {}); currentId = envelope.id; currentEnvelope = envelope; appliedThemeKey = themeKey(context); } catch { // NON-REFLECTIVE fault (H-H): never echo envelope/context content. dispose = null; currentId = envelope.id; // onError needs a cardId even on mount failure postUp({ dir: 'up', kind: 'fault', code: 'render-failed', message: 'card failed to render' }); return; } startObserver(); } function onMessage(event: MessageEvent): void { if (stopped) return; const data = event.data; // ── Pre-lock: only the FIRST valid down `hello` from window.parent locks the bridge. ── if (lockedSource === null) { if (!isCardWireFrame(data, 'down')) return; if (data.message.kind !== 'hello') return; if (event.source !== window.parent) return; // host source must be the parent window const supported = (data.message as { supportedVersions: string[] }).supportedVersions; const picked = negotiateVersion(supported, hostVersions); const echoedNonce = data.nonce; const origin = event.origin; if (picked === null) { // No common version → fault with the host's first supported version + received nonce. try { (parent as Window).postMessage( createPacker(hostVersions[0], echoedNonce)({ dir: 'up', kind: 'fault', code: 'version-unsupported', message: 'no compatible protocol version' }), origin, ); } catch { /* best-effort */ } stopped = true; // refuse further frames return; } // Lock identity. lockedSource = event.source; lockedOrigin = origin; nonce = echoedNonce; negotiated = picked; packer = createPacker(negotiated, nonce); postUp({ dir: 'up', kind: 'ready', acceptedVersion: negotiated, capabilities: { types: renderers.map((r) => r.type) } }); return; } // ── Post-lock: ALL gates must pass (H-A source + origin + H-D direction + nonce + version). ── if (event.source !== lockedSource) { warnDrop(data); return; } if (!assertOrigin(event.origin, lockedOrigin!)) { warnDrop(data); return; } if (!isCardWireFrame(data, 'down')) { warnDrop(data); return; } if (data.nonce !== nonce) { warnDrop(data); return; } if (data.version !== negotiated) { warnDrop(data); return; } try { const message = data.message; switch (message.kind) { case 'hello': // Re-hello after lock is ignored (bridge identity is fixed for its generation). return; case 'context': { const next = (message as { context: CardContext }).context; context = next; // Live context push. Renderers read host.context() on-demand, so silent // token/locale/a11y refreshes need NO remount — storing the new context is // enough. Only a THEME change requires a dispose+remount, since renderers // apply theme imperatively at mount (v1 has no in-place update hook). if (currentEnvelope && themeKey(next) !== appliedThemeKey) { handleRender(currentEnvelope); // handleRender resets appliedThemeKey } return; } case 'render': handleRender((message as { envelope: CardEnvelope }).envelope); return; case 'teardown': teardown(); return; default: warnDrop(data); return; } } catch (e) { // No throw may escape the message handler — the listener must survive. warnDrop(data); } } function teardown(): void { disposeCurrent(); if (stopObserver) { stopObserver(); stopObserver = null; } root.replaceChildren(); } return { start() { window.addEventListener('message', onMessage); }, stop() { stopped = true; window.removeEventListener('message', onMessage); teardown(); }, }; }