feat(sdk): P2.6 @insignia/iios-message-web (React hooks) + allowlist boundary

MessageProvider/useThread/useMessages over the kernel-client socket (live append,
typing, dedupe). Reconnect test (re-opens active thread). Rewrote import-boundary
as an allowlist so SDK<->service sibling imports are forbidden both ways. 28 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 01:29:25 +05:30
parent 5a590c7fda
commit eff3c615d5
8 changed files with 262 additions and 19 deletions
@@ -0,0 +1,51 @@
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: 't5', 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(): unknown {
return this;
}
emit(): 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));
}
}
describe('MessageSocket reconnect resilience', () => {
it('re-opens the active thread when the socket reconnects (no lost subscription)', async () => {
const fake = new FakeSocket();
const ms = new MessageSocket({ serviceUrl: 'http://localhost:3200', token: 'tok' }, fake);
await ms.openThread('t5');
const before = fake.acks.filter((a) => a.event === 'open_thread').length;
fake.trigger('connect'); // simulate a reconnect
const opens = fake.acks.filter((a) => a.event === 'open_thread');
expect(opens.length).toBe(before + 1);
expect(opens.at(-1)?.payload).toMatchObject({ threadId: 't5' });
});
});
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@insignia/iios-message-web",
"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-kernel-client": "workspace:*"
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"@types/react": "^19.0.0",
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
}
}
+2
View File
@@ -0,0 +1,2 @@
export { MessageProvider, useThread, useMessages } from './react';
export type { Message } from '@insignia/iios-kernel-client';
+101
View File
@@ -0,0 +1,101 @@
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { MessageSocket, type Message } from '@insignia/iios-kernel-client';
interface MessageContextValue {
socket: MessageSocket;
}
const MessageContext = createContext<MessageContextValue | null>(null);
/** Wraps a MessageSocket and provides it to the message hooks. */
export function MessageProvider({
serviceUrl,
token,
children,
}: {
serviceUrl: string;
token: string;
children: React.ReactNode;
}): React.ReactElement {
const socket = useMemo(() => new MessageSocket({ serviceUrl, token }), [serviceUrl, token]);
useEffect(() => () => socket.disconnect(), [socket]);
return <MessageContext.Provider value={{ socket }}>{children}</MessageContext.Provider>;
}
function useSocket(): MessageSocket {
const ctx = useContext(MessageContext);
if (!ctx) throw new Error('useThread/useMessages must be used within <MessageProvider>');
return ctx.socket;
}
/** Open or create a thread; returns the resolved thread id + status. */
export function useThread(): {
threadId: string | null;
status: string;
open: (id?: string) => Promise<string>;
} {
const socket = useSocket();
const [threadId, setThreadId] = useState<string | null>(null);
const [status, setStatus] = useState('OPEN');
const open = async (id?: string): Promise<string> => {
const r = await socket.openThread(id);
setThreadId(r.threadId);
setStatus(r.status);
return r.threadId;
};
return { threadId, status, open };
}
/** Live messages for a thread + send/read/typing actions. */
export function useMessages(threadId: string | null): {
messages: Message[];
send: (content: string, opts?: { contentRef?: string }) => Promise<void>;
markRead: (interactionId: string) => Promise<void>;
typing: () => void;
typingUsers: string[];
} {
const socket = useSocket();
const [messages, setMessages] = useState<Message[]>([]);
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const seen = useRef<Set<string>>(new Set());
useEffect(() => {
if (!threadId) return;
seen.current = new Set();
void socket.openThread(threadId).then((r) => {
seen.current = new Set(r.history.map((m) => m.id));
setMessages(r.history);
});
const offMessage = socket.on('message', (m) => {
if (m.threadId !== threadId || seen.current.has(m.id)) return;
seen.current.add(m.id);
setMessages((prev) => [...prev, m]);
});
const offTyping = socket.on('typing', (e) => {
if (e.threadId !== threadId) return;
setTypingUsers((u) => (u.includes(e.userId) ? u : [...u, e.userId]));
setTimeout(() => setTypingUsers((u) => u.filter((x) => x !== e.userId)), 2500);
});
return () => {
offMessage();
offTyping();
};
}, [socket, threadId]);
const send = async (content: string, opts?: { contentRef?: string }): Promise<void> => {
if (threadId) await socket.sendMessage(threadId, content, opts);
};
const markRead = async (interactionId: string): Promise<void> => {
if (threadId) await socket.markRead(threadId, interactionId);
};
const typing = (): void => {
if (threadId) socket.typing(threadId);
};
return { messages, send, markRead, typing, typingUsers };
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"types": ["react"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
external: ['react', 'react-dom', '@insignia/iios-kernel-client'],
});
+37
View File
@@ -36,6 +36,25 @@ importers:
specifier: ^5.7.3
version: 5.9.3
packages/iios-message-web:
dependencies:
'@insignia/iios-kernel-client':
specifier: workspace:*
version: link:../iios-kernel-client
devDependencies:
'@types/react':
specifier: ^19.0.0
version: 19.2.17
react:
specifier: ^19.0.0
version: 19.2.7
tsup:
specifier: ^8.3.5
version: 8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3)
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/iios-service:
dependencies:
'@insignia/iios-contracts':
@@ -929,6 +948,9 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
@@ -1318,6 +1340,9 @@ packages:
typescript:
optional: true
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1992,6 +2017,10 @@ packages:
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
react@19.2.7:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@@ -3184,6 +3213,10 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/react@19.2.17':
dependencies:
csstype: 3.2.3
'@types/send@1.2.1':
dependencies:
'@types/node': 26.0.1
@@ -3600,6 +3633,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
csstype@3.2.3: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -4300,6 +4335,8 @@ snapshots:
defu: 6.1.7
destr: 2.0.5
react@19.2.7: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
+21 -19
View File
@@ -1,9 +1,10 @@
/**
* Import-boundary law (Bottom-Up §06 dependency law; Critics §12).
*
* Layers, low → high: contracts < testkit < service. A LOWER layer must never
* import a HIGHER one. This guards the "no specialization leaks into the kernel"
* rule from day one. Runnable as `pnpm boundary` (CI) and imported by a test.
* Each package declares exactly which other @insignia/iios-* packages it MAY
* import. Anything else is a violation. This is an allowlist, not a ladder —
* it correctly forbids sibling imports (e.g. the SDK must never import the
* backend service, and vice-versa). Runnable as `pnpm boundary` (CI) + a test.
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
@@ -11,12 +12,15 @@ import { dirname, join, resolve } from 'node:path';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/** Lower rank = lower layer. A package may only import packages of <= its rank. */
const RANK = {
'@insignia/iios-contracts': 0,
'@insignia/iios-testkit': 1,
'@insignia/iios-service': 2,
/** package -> the @insignia/iios-* packages it is allowed to import. */
const ALLOWED = {
'@insignia/iios-contracts': [],
'@insignia/iios-testkit': ['@insignia/iios-contracts'],
'@insignia/iios-kernel-client': ['@insignia/iios-contracts'],
'@insignia/iios-service': ['@insignia/iios-contracts', '@insignia/iios-testkit'],
'@insignia/iios-message-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'],
};
const KNOWN = Object.keys(ALLOWED);
function listTsFiles(dir) {
if (!existsSync(dir)) return [];
@@ -25,13 +29,13 @@ function listTsFiles(dir) {
if (entry === 'node_modules' || entry === 'dist') continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...listTsFiles(full));
else if (full.endsWith('.ts')) out.push(full);
else if (full.endsWith('.ts') || full.endsWith('.tsx')) out.push(full);
}
return out;
}
function packageOf(importPath) {
return Object.keys(RANK).find((k) => importPath === k || importPath.startsWith(k + '/'));
return KNOWN.find((k) => importPath === k || importPath.startsWith(k + '/'));
}
export function findBoundaryViolations() {
@@ -43,11 +47,9 @@ export function findBoundaryViolations() {
const pkgJsonPath = join(pkgsDir, pkg, 'package.json');
if (!existsSync(pkgJsonPath)) continue;
const name = JSON.parse(readFileSync(pkgJsonPath, 'utf8')).name;
const ownRank = RANK[name];
if (ownRank === undefined) continue;
const allowed = ALLOWED[name];
if (!allowed) continue; // not a governed package
// Catch every module-specifier form: `from '…'`, side-effect `import '…'`,
// dynamic `import('…')`, and `require('…')`.
const patterns = [
/from\s*['"](@insignia\/iios-[^'"]+)['"]/g,
/import\s*['"](@insignia\/iios-[^'"]+)['"]/g,
@@ -59,9 +61,9 @@ export function findBoundaryViolations() {
for (const re of patterns) {
let m;
while ((m = re.exec(content))) {
const targetPkg = packageOf(m[1]);
if (targetPkg && RANK[targetPkg] > ownRank) {
violations.push({ file: file.replace(ROOT + '/', ''), from: name, imports: targetPkg });
const target = packageOf(m[1]);
if (target && target !== name && !allowed.includes(target)) {
violations.push({ file: file.replace(ROOT + '/', ''), from: name, imports: target });
}
}
}
@@ -73,9 +75,9 @@ export function findBoundaryViolations() {
if (import.meta.url === `file://${process.argv[1]}`) {
const violations = findBoundaryViolations();
if (violations.length > 0) {
console.error('✗ import-boundary violations (a lower layer imports a higher one):');
console.error('✗ import-boundary violations (a package imports a forbidden dependency):');
for (const v of violations) console.error(` ${v.file}: ${v.from}${v.imports}`);
process.exit(1);
}
console.log('✓ import-boundary: OK (no lower→higher imports)');
console.log('✓ import-boundary: OK');
}