diff --git a/packages/iios-kernel-client/src/reconnect.test.ts b/packages/iios-kernel-client/src/reconnect.test.ts new file mode 100644 index 0000000..7e7dcf7 --- /dev/null +++ b/packages/iios-kernel-client/src/reconnect.test.ts @@ -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 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 { + 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' }); + }); +}); diff --git a/packages/iios-message-web/package.json b/packages/iios-message-web/package.json new file mode 100644 index 0000000..a8da0a8 --- /dev/null +++ b/packages/iios-message-web/package.json @@ -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" + } +} diff --git a/packages/iios-message-web/src/index.ts b/packages/iios-message-web/src/index.ts new file mode 100644 index 0000000..2092c8b --- /dev/null +++ b/packages/iios-message-web/src/index.ts @@ -0,0 +1,2 @@ +export { MessageProvider, useThread, useMessages } from './react'; +export type { Message } from '@insignia/iios-kernel-client'; diff --git a/packages/iios-message-web/src/react.tsx b/packages/iios-message-web/src/react.tsx new file mode 100644 index 0000000..5652cc2 --- /dev/null +++ b/packages/iios-message-web/src/react.tsx @@ -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(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 {children}; +} + +function useSocket(): MessageSocket { + const ctx = useContext(MessageContext); + if (!ctx) throw new Error('useThread/useMessages must be used within '); + 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; +} { + const socket = useSocket(); + const [threadId, setThreadId] = useState(null); + const [status, setStatus] = useState('OPEN'); + + const open = async (id?: string): Promise => { + 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; + markRead: (interactionId: string) => Promise; + typing: () => void; + typingUsers: string[]; +} { + const socket = useSocket(); + const [messages, setMessages] = useState([]); + const [typingUsers, setTypingUsers] = useState([]); + const seen = useRef>(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 => { + if (threadId) await socket.sendMessage(threadId, content, opts); + }; + const markRead = async (interactionId: string): Promise => { + if (threadId) await socket.markRead(threadId, interactionId); + }; + const typing = (): void => { + if (threadId) socket.typing(threadId); + }; + + return { messages, send, markRead, typing, typingUsers }; +} diff --git a/packages/iios-message-web/tsconfig.json b/packages/iios-message-web/tsconfig.json new file mode 100644 index 0000000..4a4d5c4 --- /dev/null +++ b/packages/iios-message-web/tsconfig.json @@ -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"] +} diff --git a/packages/iios-message-web/tsup.config.ts b/packages/iios-message-web/tsup.config.ts new file mode 100644 index 0000000..1cf9cbf --- /dev/null +++ b/packages/iios-message-web/tsup.config.ts @@ -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'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d0886f..ca806b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/check-import-boundary.mjs b/scripts/check-import-boundary.mjs index 4324bbc..481679e 100644 --- a/scripts/check-import-boundary.mjs +++ b/scripts/check-import-boundary.mjs @@ -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'); }