diff --git a/kwa-ui/README.md b/kwa-ui/README.md index 635f545..8a31d09 100644 --- a/kwa-ui/README.md +++ b/kwa-ui/README.md @@ -2,6 +2,92 @@ This template should help get you started developing with Vue 3 in Vite. +## Backend JSON Bridge + +The UI exposes a JSON bridge intended for integration inside a native host such as a C++ application. + +### Available bridge entry points + +- `window.kwaUiBridge.receiveFromBackend(message)` lets the host inject a JSON string or already parsed message object into the UI. +- `window.kwaUiBridge.sendRequest(topic, payload)` sends a `requestMessage` to the host and resolves with the matching `responseMessage.payload`. +- `window.kwaUiBridge.emitEvent(topic, payload)` sends a one-way `eventMessage` to the host. +- Outgoing messages are also mirrored as a browser event named `kwa-ui:outgoing-message` whose `detail` contains the serialized JSON. +- Incoming messages can also be injected by dispatching `kwa-ui:incoming-message` with the raw JSON string in `detail`. + +### Message envelopes + +Every message is JSON and uses one of the following envelope shapes. + +```json +{ + "kind": "requestMessage", + "id": "3f4f58aa-a49f-49b1-a43a-3ca3bff8469f", + "timestamp": "2026-05-22T15:28:00.000Z" + "topic": "requirements.list", + "payload": {}, +} +``` + +```json +{ + "kind": "responseMessage", + "id": "7e7e0cba-d4b8-4d8f-a0cb-31f4f3110db5", + "requestId": "3f4f58aa-a49f-49b1-a43a-3ca3bff8469f", + "timestamp": "2026-05-22T15:28:00.010Z" + "topic": "requirements.list", + "ok": true, + "payload": { + "requirements": [] + }, +} +``` + +```json +{ + "kind": "eventMessage", + "id": "94f2b4d4-6f16-4f14-8dc7-869f6d3e1a55", + "timestamp": "2026-05-22T15:28:00.020Z" + "topic": "requirements.updated", + "payload": { + "requirement": { + "id": 101 + } + }, +} +``` + +### Native host transport + +The bridge will send outgoing JSON to the first available transport below: + +- `window.kwaBackendHost.postMessage(string)` +- `window.chrome.webview.postMessage(string)` +- `window.webkit.messageHandlers.kwaUiBridge.postMessage(string)` + +If none of these are available, the UI still emits `kwa-ui:outgoing-message`, which is useful for testing or for a wrapper script. + +### Registered topics + +- `requirements.list`: request the full requirements collection and current selection. +- `requirements.get`: request payload `{ "id": number }`, returns one requirement or `null`. +- `requirements.select`: request or event payload `{ "id": number }`, selects the matching requirement. +- `requirements.update`: request payload `{ "requirement": { ... } }`, updates a requirement and returns the saved value. +- `requirements.replaceAll`: event payload `{ "requirements": [ ... ] }`, replaces the current collection in the UI. + +### Events pushed by the backend to the UI + +The backend can push an `eventMessage` at any time by calling `window.kwaUiBridge.receiveFromBackend(json)`. The following topics are handled: + +- `requirements.replaceAll`: event payload `{ "requirements": [ ... ] }`, replaces the full requirements collection in the UI. +- `requirements.select`: event payload `{ "id": number }`, selects the matching requirement in the UI. + +### Events emitted by the UI + +- `ui.ready`: emitted once the bridge is installed. +- `requirements.selectionChanged`: emitted whenever the selected requirement changes. +- `requirements.updated`: emitted after a requirement update initiated in the UI. +- `requirements.replaced`: emitted after the full requirements collection is replaced. + ## Recommended IDE Setup [VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). diff --git a/kwa-ui/env.d.ts b/kwa-ui/env.d.ts index 11f02fe..01d1b96 100644 --- a/kwa-ui/env.d.ts +++ b/kwa-ui/env.d.ts @@ -1 +1,30 @@ /// + +import type { BridgeMessage, UiBridgeApi } from './src/bridge/backendBridge' + +declare global { + interface Window { + kwaBackendHost?: { + postMessage: (message: string) => void + } + kwaBridgeConfig?: { + webkitHandlerNames?: string[] + } + kwaUiBridge?: UiBridgeApi + chrome?: { + webview?: { + postMessage: (message: string) => void + } + } + webkit?: { + messageHandlers?: Record void }> + } + } + + interface WindowEventMap { + 'kwa-ui:incoming-message': CustomEvent + 'kwa-ui:outgoing-message': CustomEvent + } +} + +export {} diff --git a/kwa-ui/index.html b/kwa-ui/index.html index f9c2c57..6e8b9a9 100644 --- a/kwa-ui/index.html +++ b/kwa-ui/index.html @@ -1,5 +1,5 @@ - + diff --git a/kwa-ui/src/bridge/backendBridge.ts b/kwa-ui/src/bridge/backendBridge.ts new file mode 100644 index 0000000..3d169b9 --- /dev/null +++ b/kwa-ui/src/bridge/backendBridge.ts @@ -0,0 +1,394 @@ +const OUTGOING_EVENT_NAME = 'kwa-ui:outgoing-message' +const INCOMING_EVENT_NAME = 'kwa-ui:incoming-message' +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000 + +export type BridgeMessageKind = 'requestMessage' | 'responseMessage' | 'eventMessage' + +export interface BridgeMessageBase { + kind: TKind + id: string + timestamp: string + topic: string + payload: TPayload +} + +export interface RequestMessage + extends BridgeMessageBase<'requestMessage', TPayload> {} + +export interface ResponseMessage + extends BridgeMessageBase<'responseMessage', TPayload> { + requestId: string + ok: boolean + error?: string +} + +export interface EventMessage + extends BridgeMessageBase<'eventMessage', TPayload> {} + +export type BridgeMessage = + | RequestMessage + | ResponseMessage + | EventMessage + +export interface RequestOptions { + timeoutMs?: number +} + +export interface UiBridgeApi { + receiveFromBackend: (message: string | BridgeMessage) => boolean + sendRequest: ( + topic: string, + payload: TPayload, + options?: RequestOptions, + ) => Promise + emitEvent: (topic: string, payload: TPayload) => void +} + +type RequestHandler = ( + message: RequestMessage, +) => Promise | TResponse + +type EventHandler = ( + message: EventMessage, +) => Promise | void + +interface PendingRequest { + resolve: (payload: unknown) => void + reject: (reason?: unknown) => void + timeoutHandle: ReturnType +} + +interface OutboundHost { + postMessage: (message: string) => void +} + +interface WebkitMessageHandler { + postMessage: (message: string) => void +} + +const DEFAULT_WEBKIT_HANDLER_NAMES = ['ui_bridge_handler'] as const + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null +} + +const isBridgeMessageKind = (value: unknown): value is BridgeMessageKind => { + return value === 'requestMessage' || value === 'responseMessage' || value === 'eventMessage' +} + +const isBridgeMessage = (value: unknown): value is BridgeMessage => { + if (!isRecord(value)) { + return false + } + + if (!isBridgeMessageKind(value.kind)) { + return false + } + + return ( + typeof value.id === 'string' && + typeof value.topic === 'string' && + typeof value.timestamp === 'string' && + 'payload' in value + ) +} + +const parseBridgeMessage = (value: string | BridgeMessage | unknown): BridgeMessage | null => { + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) as unknown + return isBridgeMessage(parsed) ? parsed : null + } catch { + return null + } + } + + return isBridgeMessage(value) ? value : null +} + +const createMessageId = () => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + + return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}` +} + +const normalizeError = (error: unknown) => { + if (error instanceof Error) { + return error.message + } + + return typeof error === 'string' ? error : 'Unknown bridge error' +} + +const isWebkitMessageHandler = (value: unknown): value is WebkitMessageHandler => { + return isRecord(value) && typeof value.postMessage === 'function' +} + +const resolveWebkitHandlers = (): OutboundHost[] => { + const messageHandlers = window.webkit?.messageHandlers + + if (!isRecord(messageHandlers)) { + return [] + } + + const handlers: OutboundHost[] = [] + + for (const handlerName of DEFAULT_WEBKIT_HANDLER_NAMES) { + const handler = (messageHandlers as Record)[handlerName] + + if (!isWebkitMessageHandler(handler)) { + continue + } + + handlers.push({ + postMessage: (message: string) => handler.postMessage(message), + }) + } + + return handlers +} + +const resolveOutboundHosts = (): OutboundHost[] => { + const hosts: OutboundHost[] = [] + + if (window.kwaBackendHost?.postMessage) { + hosts.push(window.kwaBackendHost) + } + + if (window.chrome?.webview?.postMessage) { + hosts.push({ + postMessage: (message: string) => window.chrome?.webview?.postMessage(message), + }) + } + + hosts.push(...resolveWebkitHandlers()) + + const legacyExternal = (window as Window & { external?: { notify?: (message: string) => void } }).external + if (legacyExternal?.notify) { + hosts.push({ + postMessage: (message: string) => legacyExternal.notify?.(message), + }) + } + + if (window.parent && window.parent !== window) { + hosts.push({ + postMessage: (message: string) => window.parent.postMessage(message, '*'), + }) + } + + return hosts +} + +class BackendBridge { + private readonly requestHandlers = new Map() + private readonly eventHandlers = new Map>() + private readonly pendingRequests = new Map() + + constructor() { + window.addEventListener('message', this.handleWindowMessage) + window.addEventListener(INCOMING_EVENT_NAME, this.handleCustomIncoming as EventListener) + } + + sendRequest = ( + topic: string, + payload: TPayload, + options?: RequestOptions, + ): Promise => { + const requestMessage: RequestMessage = { + kind: 'requestMessage', + id: createMessageId(), + timestamp: new Date().toISOString(), + topic, + payload, + } + + return new Promise((resolve, reject) => { + const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS + const timeoutHandle = window.setTimeout(() => { + this.pendingRequests.delete(requestMessage.id) + reject(new Error(`Bridge request timed out for topic "${topic}".`)) + }, timeoutMs) + + this.pendingRequests.set(requestMessage.id, { + resolve: (responsePayload) => resolve(responsePayload as TResponse), + reject, + timeoutHandle, + }) + + this.postMessage(requestMessage) + }) + } + + sendResponse = ( + requestId: string, + topic: string, + payload: TPayload, + ok = true, + error?: string, + ) => { + const responseMessage: ResponseMessage = { + kind: 'responseMessage', + id: createMessageId(), + requestId, + topic, + payload, + ok, + error, + timestamp: new Date().toISOString(), + } + + this.postMessage(responseMessage) + } + + emitEvent = (topic: string, payload: TPayload) => { + const eventMessage: EventMessage = { + kind: 'eventMessage', + id: createMessageId(), + timestamp: new Date().toISOString(), + topic, + payload, + } + + this.postMessage(eventMessage) + } + + onRequest = ( + topic: string, + handler: RequestHandler, + ) => { + this.requestHandlers.set(topic, handler as RequestHandler) + + return () => { + this.requestHandlers.delete(topic) + } + } + + onEvent = (topic: string, handler: EventHandler) => { + const handlers = this.eventHandlers.get(topic) ?? new Set() + handlers.add(handler as EventHandler) + this.eventHandlers.set(topic, handlers) + + return () => { + handlers.delete(handler as EventHandler) + + if (handlers.size === 0) { + this.eventHandlers.delete(topic) + } + } + } + + receiveFromBackend = (message: string | BridgeMessage) => { + const parsedMessage = parseBridgeMessage(message) + + if (!parsedMessage) { + return false + } + + void this.dispatchIncoming(parsedMessage) + return true + } + + private readonly handleWindowMessage = (event: MessageEvent) => { + this.receiveFromBackend(event.data) + } + + private readonly handleCustomIncoming = (event: Event) => { + const customEvent = event as CustomEvent + this.receiveFromBackend(customEvent.detail) + } + + private postMessage(message: BridgeMessage) { + const serializedMessage = JSON.stringify(message) + + for (const host of resolveOutboundHosts()) { + try { + host.postMessage(serializedMessage) + } catch { + // Best effort transport fan-out. + } + } + + window.dispatchEvent( + new CustomEvent(OUTGOING_EVENT_NAME, { + detail: serializedMessage, + }), + ) + } + + private async dispatchIncoming(message: BridgeMessage) { + if (message.kind === 'responseMessage') { + this.resolvePendingRequest(message) + return + } + + if (message.kind === 'requestMessage') { + await this.handleRequestMessage(message) + return + } + + await this.handleEventMessage(message) + } + + private resolvePendingRequest(message: ResponseMessage) { + const pendingRequest = this.pendingRequests.get(message.requestId) + + if (!pendingRequest) { + return + } + + window.clearTimeout(pendingRequest.timeoutHandle) + this.pendingRequests.delete(message.requestId) + + if (!message.ok) { + pendingRequest.reject(new Error(message.error ?? `Bridge request failed for topic "${message.topic}".`)) + return + } + + pendingRequest.resolve(message.payload) + } + + private async handleRequestMessage(message: RequestMessage) { + const handler = this.requestHandlers.get(message.topic) + + if (!handler) { + this.sendResponse(message.id, message.topic, null, false, `No request handler for topic "${message.topic}".`) + return + } + + try { + const payload = await handler(message) + this.sendResponse(message.id, message.topic, payload, true) + } catch (error) { + this.sendResponse(message.id, message.topic, null, false, normalizeError(error)) + } + } + + private async handleEventMessage(message: EventMessage) { + const handlers = this.eventHandlers.get(message.topic) + + if (!handlers || handlers.size === 0) { + return + } + + for (const handler of handlers) { + await handler(message) + } + } +} + +export const backendBridge = new BackendBridge() + +export const uiBridgeApi: UiBridgeApi = { + receiveFromBackend: backendBridge.receiveFromBackend, + sendRequest: backendBridge.sendRequest, + emitEvent: backendBridge.emitEvent, +} + +window.kwaUiBridge = uiBridgeApi + +export const bridgeEventNames = { + incoming: INCOMING_EVENT_NAME, + outgoing: OUTGOING_EVENT_NAME, +} \ No newline at end of file diff --git a/kwa-ui/src/bridge/requirementsBridge.ts b/kwa-ui/src/bridge/requirementsBridge.ts new file mode 100644 index 0000000..284a5e9 --- /dev/null +++ b/kwa-ui/src/bridge/requirementsBridge.ts @@ -0,0 +1,80 @@ +import { watch } from 'vue' +import { backendBridge } from './backendBridge' +import { useRequirementsStore, type Requirement } from '../stores/requirements' + +interface RequirementSelectionPayload { + id: number +} + +interface RequirementUpdatePayload { + requirement: Requirement +} + +interface RequirementReplacePayload { + requirements: Requirement[] +} + +export const installRequirementsBridge = (store: ReturnType) => { + backendBridge.onRequest('requirements.list', () => { + return { + requirements: store.requirements, + selectedId: store.selectedId, + } + }) + + backendBridge.onRequest('requirements.get', ({ payload }) => { + return { + requirement: store.getRequirementById(payload.id), + } + }) + + backendBridge.onRequest('requirements.select', ({ payload }) => { + return { + requirement: store.selectRequirement(payload.id), + } + }) + + backendBridge.onRequest('requirements.update', ({ payload }) => { + return { + requirement: store.updateRequirement(payload.requirement), + } + }) + + backendBridge.onEvent('requirements.replaceAll', ({ payload }) => { + store.setRequirements(payload.requirements) + }) + + backendBridge.onEvent('requirements.select', ({ payload }) => { + store.selectRequirement(payload.id) + }) + + store.$onAction(({ name, after }) => { + after((result) => { + if (name === 'updateRequirement' && result) { + backendBridge.emitEvent('requirements.updated', { + requirement: result, + }) + } + + if (name === 'setRequirements') { + backendBridge.emitEvent('requirements.replaced', { + requirements: store.requirements, + selectedId: store.selectedId, + }) + } + }) + }) + + watch( + () => store.selectedId, + (selectedId) => { + backendBridge.emitEvent('requirements.selectionChanged', { + selectedId, + requirement: store.getRequirementById(selectedId), + }) + }, + { immediate: false }, + ) + + backendBridge.emitEvent('ui.ready', {}) +} \ No newline at end of file diff --git a/kwa-ui/src/main.ts b/kwa-ui/src/main.ts index ae0dd07..755ffc7 100644 --- a/kwa-ui/src/main.ts +++ b/kwa-ui/src/main.ts @@ -1,7 +1,3 @@ -import '@fontsource/source-sans-3/latin-400.css' -import '@fontsource/source-sans-3/latin-500.css' -import '@fontsource/source-sans-3/latin-600.css' -import '@fontsource/source-sans-3/latin-700.css' import './assets/main.css' import { createApp } from 'vue' @@ -12,10 +8,22 @@ import Aura from '@primeuix/themes/aura' import 'primeicons/primeicons.css' import App from './App.vue' import router from './router' +import { installRequirementsBridge } from './bridge/requirementsBridge' +import { useRequirementsStore } from './stores/requirements' + +const loadEmbeddedFonts = async () => { + await Promise.allSettled([ + import('@fontsource/source-sans-3/latin-400.css'), + import('@fontsource/source-sans-3/latin-500.css'), + import('@fontsource/source-sans-3/latin-600.css'), + import('@fontsource/source-sans-3/latin-700.css'), + ]) +} const app = createApp(App) +const pinia = createPinia() -app.use(createPinia()) +app.use(pinia) app.use(router) app.use(PrimeVue, { @@ -27,4 +35,9 @@ app.use(PrimeVue, { }, }) +installRequirementsBridge(useRequirementsStore(pinia)) + app.mount('#app') + +// Do not block bridge initialization on font asset loading in embedded hosts. +void loadEmbeddedFonts() diff --git a/kwa-ui/src/stores/requirements.ts b/kwa-ui/src/stores/requirements.ts index e384fe2..891a930 100644 --- a/kwa-ui/src/stores/requirements.ts +++ b/kwa-ui/src/stores/requirements.ts @@ -25,6 +25,16 @@ export interface Requirement { tolerances: string // Acceptable limits for deviations from the requirement (e.g., "Maximum 5% of requirements can have incomplete artifact links") } +const cloneRequirement = (requirement: Requirement): Requirement => { + return { + ...requirement, + acceptanceCriteria: [...requirement.acceptanceCriteria], + impactedModules: [...requirement.impactedModules], + blockers: [...requirement.blockers], + stakeholders: [...requirement.stakeholders], + } +} + export const useRequirementsStore = defineStore('requirements', () => { const requirements = ref([ { @@ -157,22 +167,47 @@ export const useRequirementsStore = defineStore('requirements', () => { return { total, approvedCount, blockedCount, criticalCount } }) + const getRequirementById = (id: number) => { + return requirements.value.find((requirement) => requirement.id === id) ?? null + } + + const selectRequirement = (nextId: number) => { + const nextRequirement = getRequirementById(nextId) + + if (!nextRequirement) { + return null + } + + selectedId.value = nextId + return nextRequirement + } + + const setRequirements = (nextRequirements: Requirement[]) => { + requirements.value = nextRequirements.map(cloneRequirement) + + if (requirements.value.length === 0) { + return [] + } + + if (!getRequirementById(selectedId.value)) { + selectedId.value = requirements.value[0]!.id + } + + return requirements.value + } + const updateRequirement = (updatedRequirement: Requirement) => { const index = requirements.value.findIndex( (requirement) => requirement.id === updatedRequirement.id ) if (index === -1) { - return + return null } - requirements.value[index] = { - ...updatedRequirement, - acceptanceCriteria: [...updatedRequirement.acceptanceCriteria], - impactedModules: [...updatedRequirement.impactedModules], - blockers: [...updatedRequirement.blockers], - stakeholders: [...updatedRequirement.stakeholders], - } + requirements.value[index] = cloneRequirement(updatedRequirement) + + return requirements.value[index] } return { @@ -181,6 +216,9 @@ export const useRequirementsStore = defineStore('requirements', () => { searchQuery, selectedRequirement, stats, + getRequirementById, + selectRequirement, + setRequirements, updateRequirement, } }) diff --git a/kwa-ui/src/views/RequirementsView.vue b/kwa-ui/src/views/RequirementsView.vue index a2b1b88..7cdae4e 100644 --- a/kwa-ui/src/views/RequirementsView.vue +++ b/kwa-ui/src/views/RequirementsView.vue @@ -1,6 +1,7 @@