Add canvas artifact RPC foundation for A2UI

This commit is contained in:
William Valentin
2026-02-16 12:36:02 -08:00
parent fe8674e108
commit 8a0b4f3dbb
11 changed files with 497 additions and 3 deletions
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { CanvasStore } from './canvas-store.js';
describe('CanvasStore', () => {
it('stores and retrieves artifacts per session', () => {
const store = new CanvasStore();
const artifact = store.put('ws:1', {
id: 'a1',
type: 'note',
content: { text: 'hello' },
});
expect(artifact.id).toBe('a1');
expect(store.get('ws:1', 'a1')?.type).toBe('note');
expect(store.list('ws:1')).toHaveLength(1);
});
it('updates existing artifacts while preserving createdAt', async () => {
const store = new CanvasStore();
const first = store.put('ws:1', {
id: 'a1',
type: 'note',
content: { text: 'one' },
});
await new Promise((resolve) => setTimeout(resolve, 2));
const second = store.put('ws:1', {
id: 'a1',
type: 'note',
content: { text: 'two' },
});
expect(second.createdAt).toBe(first.createdAt);
expect(second.updatedAt).toBeGreaterThanOrEqual(first.updatedAt);
expect(store.get('ws:1', 'a1')?.content).toEqual({ text: 'two' });
});
it('deletes and clears session artifacts', () => {
const store = new CanvasStore();
store.put('ws:1', { id: 'a1', type: 'note', content: 'x' });
store.put('ws:1', { id: 'a2', type: 'note', content: 'y' });
expect(store.delete('ws:1', 'a1')).toBe(true);
expect(store.delete('ws:1', 'missing')).toBe(false);
expect(store.list('ws:1')).toHaveLength(1);
expect(store.clear('ws:1')).toBe(1);
expect(store.list('ws:1')).toEqual([]);
});
it('evicts oldest artifact when per-session cap is exceeded', () => {
const store = new CanvasStore(2);
store.put('ws:1', { id: 'a1', type: 'note', content: 1 });
store.put('ws:1', { id: 'a2', type: 'note', content: 2 });
store.put('ws:1', { id: 'a3', type: 'note', content: 3 });
expect(store.get('ws:1', 'a1')).toBeUndefined();
expect(store.list('ws:1').map((a) => a.id).sort()).toEqual(['a2', 'a3']);
});
});
+99
View File
@@ -0,0 +1,99 @@
export interface CanvasArtifact {
id: string;
type: string;
title?: string;
content: unknown;
metadata?: Record<string, unknown>;
createdAt: number;
updatedAt: number;
}
interface CanvasPutInput {
id?: string;
type: string;
title?: string;
content: unknown;
metadata?: Record<string, unknown>;
}
/**
* Ephemeral in-memory canvas store keyed by sessionId.
* Intended as a gateway runtime foundation for A2UI/canvas surfaces.
*/
export class CanvasStore {
private readonly sessions = new Map<string, Map<string, CanvasArtifact>>();
constructor(private readonly maxArtifactsPerSession = 200) {}
list(sessionId: string): CanvasArtifact[] {
const entries = this.sessions.get(sessionId);
if (!entries) {
return [];
}
return Array.from(entries.values()).sort((a, b) => b.updatedAt - a.updatedAt);
}
get(sessionId: string, artifactId: string): CanvasArtifact | undefined {
return this.sessions.get(sessionId)?.get(artifactId);
}
put(sessionId: string, input: CanvasPutInput): CanvasArtifact {
const id = sanitizeArtifactId(input.id);
const now = Date.now();
let entries = this.sessions.get(sessionId);
if (!entries) {
entries = new Map();
this.sessions.set(sessionId, entries);
}
const existing = entries.get(id);
const artifact: CanvasArtifact = {
id,
type: input.type.trim(),
title: input.title?.trim() || undefined,
content: input.content,
metadata: input.metadata,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
entries.set(id, artifact);
if (entries.size > this.maxArtifactsPerSession) {
const oldest = Array.from(entries.values()).sort((a, b) => a.updatedAt - b.updatedAt)[0];
if (oldest) {
entries.delete(oldest.id);
}
}
return artifact;
}
delete(sessionId: string, artifactId: string): boolean {
const entries = this.sessions.get(sessionId);
if (!entries) {
return false;
}
const removed = entries.delete(artifactId);
if (entries.size === 0) {
this.sessions.delete(sessionId);
}
return removed;
}
clear(sessionId: string): number {
const entries = this.sessions.get(sessionId);
if (!entries) {
return 0;
}
const count = entries.size;
this.sessions.delete(sessionId);
return count;
}
}
function sanitizeArtifactId(raw?: string): string {
const value = (raw ?? '').trim();
if (value) {
return value.slice(0, 128);
}
return `art_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
+89
View File
@@ -0,0 +1,89 @@
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
import { makeError, makeResponse, ErrorCode } from '../protocol.js';
import type { CanvasStore } from '../canvas-store.js';
export interface CanvasHandlerDeps {
store: CanvasStore;
}
export function createCanvasHandlers(deps: CanvasHandlerDeps) {
return {
'canvas.list': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string } | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
const artifacts = deps.store.list(params.sessionId);
return makeResponse(request.id, { artifacts });
},
'canvas.get': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string; artifactId?: string } | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
if (!params?.artifactId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'artifactId is required');
}
const artifact = deps.store.get(params.sessionId, params.artifactId);
if (!artifact) {
return makeError(request.id, ErrorCode.SessionNotFound, 'Canvas artifact not found');
}
return makeResponse(request.id, { artifact });
},
'canvas.put': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as {
sessionId?: string;
artifactId?: string;
type?: string;
title?: string;
content?: unknown;
metadata?: Record<string, unknown>;
} | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
if (!params?.type || typeof params.type !== 'string' || !params.type.trim()) {
return makeError(request.id, ErrorCode.InvalidRequest, 'type is required');
}
if (params.content === undefined) {
return makeError(request.id, ErrorCode.InvalidRequest, 'content is required');
}
const artifact = deps.store.put(params.sessionId, {
id: params.artifactId,
type: params.type,
title: params.title,
content: params.content,
metadata: params.metadata,
});
return makeResponse(request.id, { artifact, upserted: true });
},
'canvas.delete': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string; artifactId?: string } | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
if (!params?.artifactId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'artifactId is required');
}
const deleted = deps.store.delete(params.sessionId, params.artifactId);
return makeResponse(request.id, { deleted });
},
'canvas.clear': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string } | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
const cleared = deps.store.clear(params.sessionId);
return makeResponse(request.id, { cleared });
},
};
}
+78
View File
@@ -7,10 +7,12 @@ import { createAgentHandlers } from './agent.js';
import { createIntentHandlers } from './intents.js';
import { createRoutingHandlers } from './routing.js';
import { createHistoryHandlers } from './history.js';
import { createCanvasHandlers } from './canvas.js';
import { createConfigHandlers, redactConfig } from './config.js';
import { createPairingHandlers } from './pairing.js';
import { PairingManager } from '../../channels/pairing.js';
import { LaneQueue } from '../lane-queue.js';
import { CanvasStore } from '../canvas-store.js';
import { ErrorCode } from '../protocol.js';
import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js';
import { ComponentRegistry } from '../../intents/index.js';
@@ -350,6 +352,82 @@ describe('session handlers', () => {
});
});
describe('canvas handlers', () => {
it('supports put/get/list/delete/clear lifecycle', async () => {
const handlers = createCanvasHandlers({ store: new CanvasStore() });
const putReq: GatewayRequest = {
id: 1,
method: 'canvas.put',
params: {
sessionId: 'ws:abc',
artifactId: 'card-1',
type: 'note',
title: 'Draft',
content: { text: 'hello' },
},
};
const putRes = await handlers['canvas.put'](putReq) as GatewayResponse;
expect(getPath(putRes.result, 'artifact', 'id')).toBe('card-1');
const getRes = await handlers['canvas.get']({
id: 2,
method: 'canvas.get',
params: { sessionId: 'ws:abc', artifactId: 'card-1' },
}) as GatewayResponse;
expect(getPath(getRes.result, 'artifact', 'title')).toBe('Draft');
const listRes = await handlers['canvas.list']({
id: 3,
method: 'canvas.list',
params: { sessionId: 'ws:abc' },
}) as GatewayResponse;
expect((getPath(listRes.result, 'artifacts') as unknown[]).length).toBe(1);
const delRes = await handlers['canvas.delete']({
id: 4,
method: 'canvas.delete',
params: { sessionId: 'ws:abc', artifactId: 'card-1' },
}) as GatewayResponse;
expect(getPath(delRes.result, 'deleted')).toBe(true);
await handlers['canvas.put']({
id: 5,
method: 'canvas.put',
params: { sessionId: 'ws:abc', artifactId: 'card-2', type: 'note', content: 'a' },
});
await handlers['canvas.put']({
id: 6,
method: 'canvas.put',
params: { sessionId: 'ws:abc', artifactId: 'card-3', type: 'note', content: 'b' },
});
const clearRes = await handlers['canvas.clear']({
id: 7,
method: 'canvas.clear',
params: { sessionId: 'ws:abc' },
}) as GatewayResponse;
expect(getPath(clearRes.result, 'cleared')).toBe(2);
});
it('validates required params', async () => {
const handlers = createCanvasHandlers({ store: new CanvasStore() });
const missingSession = await handlers['canvas.list']({
id: 8,
method: 'canvas.list',
params: {},
}) as GatewayError;
expect(missingSession.error.code).toBe(ErrorCode.InvalidRequest);
const missingContent = await handlers['canvas.put']({
id: 9,
method: 'canvas.put',
params: { sessionId: 'ws:abc', type: 'note' },
}) as GatewayError;
expect(missingContent.error.code).toBe(ErrorCode.InvalidRequest);
});
});
describe('tool handlers', () => {
const mockTool = {
name: 'test.tool',
+2
View File
@@ -17,5 +17,7 @@ export { createRoutingHandlers } from './routing.js';
export type { RoutingHandlerDeps } from './routing.js';
export { createHistoryHandlers } from './history.js';
export type { HistoryHandlerDeps } from './history.js';
export { createCanvasHandlers } from './canvas.js';
export type { CanvasHandlerDeps } from './canvas.js';
export { createNodeHandlers } from './node.js';
export type { NodeHandlerDeps, NodeRegistration, NodeConnectionState, NodeLocation } from './node.js';
+40
View File
@@ -234,6 +234,46 @@ describe('GatewayServer integration', () => {
expect(methods).toContain('sessions.create');
expect(methods).toContain('tools.list');
expect(methods).toContain('tools.invoke');
expect(methods).toContain('canvas.put');
expect(methods).toContain('canvas.list');
});
it('supports canvas artifact lifecycle via gateway RPC', async () => {
if (!LISTEN_ALLOWED) {
return;
}
const ws = await createClient();
try {
const put = await sendAndReceive(ws, {
id: 31,
method: 'canvas.put',
params: {
sessionId: 'ws:test-canvas',
artifactId: 'a1',
type: 'note',
content: { text: 'draft' },
},
});
expect((put as GatewayResponse).id).toBe(31);
const list = await sendAndReceive(ws, {
id: 32,
method: 'canvas.list',
params: { sessionId: 'ws:test-canvas' },
});
const artifacts = ((list as GatewayResponse).result as { artifacts: Array<{ id: string }> }).artifacts;
expect(artifacts).toHaveLength(1);
expect(artifacts[0]?.id).toBe('a1');
const clear = await sendAndReceive(ws, {
id: 33,
method: 'canvas.clear',
params: { sessionId: 'ws:test-canvas' },
});
expect(((clear as GatewayResponse).result as { cleared: number }).cleared).toBe(1);
} finally {
ws.close();
}
});
// ── HTTP static file serving tests ────────────────────────────
+11
View File
@@ -7,6 +7,7 @@ import { SessionBridge } from './session-bridge.js';
import type { SessionBridgeConfig } from './session-bridge.js';
import { LaneQueue } from './lane-queue.js';
import type { LaneQueueConfig } from './lane-queue.js';
import { CanvasStore } from './canvas-store.js';
import { MetricsCollector } from './metrics.js';
import { authenticateRequest, authorizeNodeMethod } from './auth.js';
import type { AuthConfig } from './auth.js';
@@ -28,6 +29,7 @@ import {
createIntentHandlers,
createRoutingHandlers,
createHistoryHandlers,
createCanvasHandlers,
createNodeHandlers,
} from './handlers/index.js';
import { discoverServices } from './handlers/services.js';
@@ -133,6 +135,7 @@ export class GatewayServer {
private router: Router;
private sessionBridge: SessionBridge;
private laneQueue: LaneQueue;
private canvasStore: CanvasStore;
private metrics: MetricsCollector;
private discoveryHandle: GatewayDiscoveryHandle | null = null;
private connectionMap: Map<WebSocket, string> = new Map();
@@ -160,6 +163,7 @@ export class GatewayServer {
});
this.laneQueue = new LaneQueue(config.queue);
this.canvasStore = new CanvasStore();
this.metrics = new MetricsCollector({
getQueueDepth: () => this.laneQueue.totalPending(),
});
@@ -235,6 +239,10 @@ export class GatewayServer {
sessionManager: this.config.sessionManager,
});
const canvasHandlers = createCanvasHandlers({
store: this.canvasStore,
});
const toolHandlers = createToolHandlers({
toolRegistry: this.config.toolRegistry,
toolExecutor: this.config.toolExecutor,
@@ -365,6 +373,9 @@ export class GatewayServer {
for (const [method, handler] of Object.entries(historyHandlers)) {
this.router.register(method, handler);
}
for (const [method, handler] of Object.entries(canvasHandlers)) {
this.router.register(method, handler);
}
for (const [method, handler] of Object.entries(toolHandlers)) {
this.router.register(method, handler);
}