From 0145976a9f3bc0f6c23755a47754f63bc3af4007 Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 15 Mar 2026 17:07:33 +0000 Subject: [PATCH] feat(backend): wire feed renderers into API Add FeedRenderer and FeedRendererProvider to support server-side rendering of feed items via ?render=json-render query param. - FeedRenderer maps sourceId to FeedItemRenderer, renders matching items and drops the rest - FeedRendererProvider mirrors FeedSourceProvider pattern for per-user renderer construction - UserSession exposes renderer, handler converts JrxNode to Spec - Returns 400 for unknown render format, 500 if renderer missing Co-authored-by: Ona --- apps/aelis-backend/package.json | 1 + .../src/caldav/renderer-provider.ts | 7 + apps/aelis-backend/src/engine/http.test.ts | 184 +++++++++++++++++- apps/aelis-backend/src/engine/http.ts | 26 +++ apps/aelis-backend/src/server.ts | 14 +- .../src/session/feed-renderer.ts | 30 +++ apps/aelis-backend/src/session/index.ts | 2 + .../src/session/renderer-provider.ts | 5 + .../src/session/user-session-manager.ts | 10 +- .../src/session/user-session.test.ts | 110 +++++++++-- .../aelis-backend/src/session/user-session.ts | 15 +- .../src/tfl/renderer-provider.ts | 7 + bun.lock | 1 + 13 files changed, 394 insertions(+), 18 deletions(-) create mode 100644 apps/aelis-backend/src/caldav/renderer-provider.ts create mode 100644 apps/aelis-backend/src/session/feed-renderer.ts create mode 100644 apps/aelis-backend/src/session/renderer-provider.ts create mode 100644 apps/aelis-backend/src/tfl/renderer-provider.ts diff --git a/apps/aelis-backend/package.json b/apps/aelis-backend/package.json index ac5b313..8f2ca25 100644 --- a/apps/aelis-backend/package.json +++ b/apps/aelis-backend/package.json @@ -15,6 +15,7 @@ "@aelis/source-location": "workspace:*", "@aelis/source-tfl": "workspace:*", "@aelis/source-weatherkit": "workspace:*", + "@nym.sh/jrx": "^0.2.0", "@openrouter/sdk": "^0.9.11", "arktype": "^2.1.29", "better-auth": "^1", diff --git a/apps/aelis-backend/src/caldav/renderer-provider.ts b/apps/aelis-backend/src/caldav/renderer-provider.ts new file mode 100644 index 0000000..4cab1b7 --- /dev/null +++ b/apps/aelis-backend/src/caldav/renderer-provider.ts @@ -0,0 +1,7 @@ +import type { FeedItemRenderer } from "@aelis/core" + +import { renderCalDavFeedItem } from "@aelis/source-caldav" + +export const CALDAV_SOURCE_ID = "aelis.caldav" + +export const calDavRenderer: FeedItemRenderer = renderCalDavFeedItem as FeedItemRenderer diff --git a/apps/aelis-backend/src/engine/http.test.ts b/apps/aelis-backend/src/engine/http.test.ts index 21644ec..4fc6208 100644 --- a/apps/aelis-backend/src/engine/http.test.ts +++ b/apps/aelis-backend/src/engine/http.test.ts @@ -1,10 +1,19 @@ -import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core" +import type { + ActionDefinition, + ContextEntry, + FeedItem, + FeedItemRenderer, + FeedSource, +} from "@aelis/core" +import type { Spec } from "@json-render/core" import { contextKey } from "@aelis/core" +import { JRX_NODE } from "@nym.sh/jrx" import { describe, expect, test } from "bun:test" import { Hono } from "hono" import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts" +import { FeedRenderer } from "../session/feed-renderer.ts" import { UserSessionManager } from "../session/index.ts" import { registerFeedHttpHandlers } from "./http.ts" @@ -19,6 +28,17 @@ interface FeedResponse { errors: Array<{ sourceId: string; error: string }> } +interface RenderedFeedResponse { + items: Array<{ + id: string + type: string + timestamp: string + data: Record + ui: Spec + }> + errors: Array<{ sourceId: string; error: string }> +} + function createStubSource( id: string, items: FeedItem[] = [], @@ -150,6 +170,168 @@ describe("GET /api/feed", () => { }) }) +describe("GET /api/feed?render=json-render", () => { + const stubRenderer: FeedItemRenderer = (item) => ({ + $$typeof: JRX_NODE, + type: "FeedCard", + props: {}, + children: [ + { + $$typeof: JRX_NODE, + type: "SansSerifText", + props: { content: `Rendered: ${item.data.value}` }, + children: [], + key: undefined, + visible: undefined, + on: undefined, + repeat: undefined, + watch: undefined, + }, + ], + key: undefined, + visible: undefined, + on: undefined, + repeat: undefined, + watch: undefined, + }) + + const rendererProvider = { + feedRendererForUser: () => new FeedRenderer({ "test-source": stubRenderer }), + } + + test("returns rendered items with ui field as Spec", async () => { + const items: FeedItem[] = [ + { + id: "item-1", + sourceId: "test-source", + type: "renderable", + timestamp: new Date("2025-01-01T00:00:00.000Z"), + data: { value: "hello" }, + }, + ] + const manager = new UserSessionManager({ + providers: [() => createStubSource("test", items)], + rendererProvider, + }) + const app = buildTestApp(manager, "user-1") + + const res = await app.request("/api/feed?render=json-render") + + expect(res.status).toBe(200) + const body = (await res.json()) as RenderedFeedResponse + expect(body.items).toHaveLength(1) + expect(body.items[0]!.id).toBe("item-1") + expect(body.items[0]!.ui).toBeDefined() + expect(body.items[0]!.ui.root).toBeDefined() + expect(body.items[0]!.ui.elements).toBeDefined() + }) + + test("drops items without a renderer", async () => { + const items: FeedItem[] = [ + { + id: "renderable-1", + sourceId: "test-source", + type: "renderable", + timestamp: new Date("2025-01-01T00:00:00.000Z"), + data: { value: "yes" }, + }, + { + id: "unrenderable-1", + sourceId: "other-source", + type: "no-renderer", + timestamp: new Date("2025-01-01T00:00:00.000Z"), + data: { value: "no" }, + }, + ] + const manager = new UserSessionManager({ + providers: [() => createStubSource("test", items)], + rendererProvider, + }) + const app = buildTestApp(manager, "user-1") + + const res = await app.request("/api/feed?render=json-render") + + expect(res.status).toBe(200) + const body = (await res.json()) as RenderedFeedResponse + expect(body.items).toHaveLength(1) + expect(body.items[0]!.id).toBe("renderable-1") + }) + + test("returns empty items when no renderers match", async () => { + const items: FeedItem[] = [ + { + id: "item-1", + sourceId: "other-source", + type: "no-renderer", + timestamp: new Date("2025-01-01T00:00:00.000Z"), + data: { value: 42 }, + }, + ] + const manager = new UserSessionManager({ + providers: [() => createStubSource("test", items)], + rendererProvider, + }) + const app = buildTestApp(manager, "user-1") + + const res = await app.request("/api/feed?render=json-render") + + expect(res.status).toBe(200) + const body = (await res.json()) as RenderedFeedResponse + expect(body.items).toHaveLength(0) + }) + + test("returns 400 for unknown render format", async () => { + const manager = new UserSessionManager({ + providers: [() => createStubSource("test")], + rendererProvider, + }) + const app = buildTestApp(manager, "user-1") + + const res = await app.request("/api/feed?render=unknown") + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("unknown") + }) + + test("returns 500 when renderer is not available", async () => { + const manager = new UserSessionManager({ + providers: [() => createStubSource("test")], + }) + const app = buildTestApp(manager, "user-1") + + const res = await app.request("/api/feed?render=json-render") + + expect(res.status).toBe(500) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("not available") + }) + + test("without render param returns raw items (no ui field)", async () => { + const items: FeedItem[] = [ + { + id: "item-1", + sourceId: "test-source", + type: "renderable", + timestamp: new Date("2025-01-01T00:00:00.000Z"), + data: { value: 42 }, + }, + ] + const manager = new UserSessionManager({ + providers: [() => createStubSource("test", items)], + rendererProvider, + }) + const app = buildTestApp(manager, "user-1") + + const res = await app.request("/api/feed") + + expect(res.status).toBe(200) + const body = (await res.json()) as FeedResponse + expect(body.items).toHaveLength(1) + expect(body.items[0]!).not.toHaveProperty("ui") + }) +}) + describe("GET /api/context", () => { const weatherKey = contextKey("aelis.weather", "weather") const weatherData = { temperature: 20, condition: "Clear" } diff --git a/apps/aelis-backend/src/engine/http.ts b/apps/aelis-backend/src/engine/http.ts index af1a851..3d0e50a 100644 --- a/apps/aelis-backend/src/engine/http.ts +++ b/apps/aelis-backend/src/engine/http.ts @@ -1,6 +1,7 @@ import type { Context, Hono } from "hono" import { contextKey } from "@aelis/core" +import { render } from "@nym.sh/jrx" import { createMiddleware } from "hono/factory" import type { AuthSessionMiddleware } from "../auth/session-middleware.ts" @@ -37,6 +38,31 @@ async function handleGetFeed(c: Context) { const feed = await session.feed() + const renderParam = c.req.query("render") + + if (renderParam !== undefined) { + if (renderParam !== "json-render") { + return c.json({ error: `Unknown render format: "${renderParam}"` }, 400) + } + + if (!session.renderer) { + return c.json({ error: "Rendering is not available" }, 500) + } + + const renderedItems = session.renderer.render(feed.items).map((item) => ({ + ...item, + ui: render(item.ui), + })) + + return c.json({ + items: renderedItems, + errors: feed.errors.map((e) => ({ + sourceId: e.sourceId, + error: e.error.message, + })), + }) + } + return c.json({ items: feed.items, errors: feed.errors.map((e) => ({ diff --git a/apps/aelis-backend/src/server.ts b/apps/aelis-backend/src/server.ts index 8cf89ab..7d2addf 100644 --- a/apps/aelis-backend/src/server.ts +++ b/apps/aelis-backend/src/server.ts @@ -3,11 +3,13 @@ import { Hono } from "hono" import { registerAuthHandlers } from "./auth/http.ts" import { mockAuthSessionMiddleware, requireSession } from "./auth/session-middleware.ts" +import { CALDAV_SOURCE_ID, calDavRenderer } from "./caldav/renderer-provider.ts" +import { registerFeedHttpHandlers } from "./engine/http.ts" import { createFeedEnhancer } from "./enhancement/enhance-feed.ts" import { createLlmClient } from "./enhancement/llm-client.ts" -import { registerFeedHttpHandlers } from "./engine/http.ts" import { registerLocationHttpHandlers } from "./location/http.ts" -import { UserSessionManager } from "./session/index.ts" +import { FeedRenderer, UserSessionManager } from "./session/index.ts" +import { TFL_SOURCE_ID, tflRenderer } from "./tfl/renderer-provider.ts" import { WeatherSourceProvider } from "./weather/provider.ts" function main() { @@ -24,6 +26,11 @@ function main() { console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled") } + const allRenderers = { + [TFL_SOURCE_ID]: tflRenderer, + [CALDAV_SOURCE_ID]: calDavRenderer, + } + const sessionManager = new UserSessionManager({ providers: [ () => new LocationSource(), @@ -36,6 +43,9 @@ function main() { }, }), ], + rendererProvider: { + feedRendererForUser: (_userId) => new FeedRenderer(allRenderers), + }, feedEnhancer, }) diff --git a/apps/aelis-backend/src/session/feed-renderer.ts b/apps/aelis-backend/src/session/feed-renderer.ts new file mode 100644 index 0000000..919ad9c --- /dev/null +++ b/apps/aelis-backend/src/session/feed-renderer.ts @@ -0,0 +1,30 @@ +import type { FeedItem, FeedItemRenderer, RenderedFeedItem } from "@aelis/core" + +/** + * Renders feed items using registered renderers. + * + * Constructed with a map of source ID to renderer function. + * Items whose source has no renderer are silently dropped. + */ +export class FeedRenderer { + private readonly renderers: Map + + constructor(renderers: Record) { + this.renderers = new Map(Object.entries(renderers)) + } + + /** + * Renders an array of feed items. Items whose sourceId has no + * registered renderer are silently dropped from the result. + */ + render(items: FeedItem[]): RenderedFeedItem[] { + const result: RenderedFeedItem[] = [] + for (const item of items) { + const renderer = this.renderers.get(item.sourceId) + if (renderer) { + result.push({ ...item, ui: renderer(item) }) + } + } + return result + } +} diff --git a/apps/aelis-backend/src/session/index.ts b/apps/aelis-backend/src/session/index.ts index 6f77741..a8a0366 100644 --- a/apps/aelis-backend/src/session/index.ts +++ b/apps/aelis-backend/src/session/index.ts @@ -3,5 +3,7 @@ export type { FeedSourceProviderFn, FeedSourceProviderInput, } from "./feed-source-provider.ts" +export { FeedRenderer } from "./feed-renderer.ts" +export type { FeedRendererProvider } from "./renderer-provider.ts" export { UserSession } from "./user-session.ts" export { UserSessionManager } from "./user-session-manager.ts" diff --git a/apps/aelis-backend/src/session/renderer-provider.ts b/apps/aelis-backend/src/session/renderer-provider.ts new file mode 100644 index 0000000..5df3450 --- /dev/null +++ b/apps/aelis-backend/src/session/renderer-provider.ts @@ -0,0 +1,5 @@ +import type { FeedRenderer } from "./feed-renderer.ts" + +export interface FeedRendererProvider { + feedRendererForUser(userId: string): FeedRenderer +} diff --git a/apps/aelis-backend/src/session/user-session-manager.ts b/apps/aelis-backend/src/session/user-session-manager.ts index 58dac4d..1a4b043 100644 --- a/apps/aelis-backend/src/session/user-session-manager.ts +++ b/apps/aelis-backend/src/session/user-session-manager.ts @@ -1,20 +1,24 @@ import type { FeedEnhancer } from "../enhancement/enhance-feed.ts" import type { FeedSourceProviderInput } from "./feed-source-provider.ts" +import type { FeedRendererProvider } from "./renderer-provider.ts" import { UserSession } from "./user-session.ts" export interface UserSessionManagerConfig { providers: FeedSourceProviderInput[] + rendererProvider?: FeedRendererProvider | null feedEnhancer?: FeedEnhancer | null } export class UserSessionManager { private sessions = new Map() private readonly providers: FeedSourceProviderInput[] + private readonly rendererProvider: FeedRendererProvider | null private readonly feedEnhancer: FeedEnhancer | null constructor(config: UserSessionManagerConfig) { this.providers = config.providers + this.rendererProvider = config.rendererProvider ?? null this.feedEnhancer = config.feedEnhancer ?? null } @@ -24,7 +28,11 @@ export class UserSessionManager { const sources = this.providers.map((p) => typeof p === "function" ? p(userId) : p.feedSourceForUser(userId), ) - session = new UserSession(sources, this.feedEnhancer) + session = new UserSession({ + sources, + enhancer: this.feedEnhancer, + renderer: this.rendererProvider?.feedRendererForUser(userId) ?? null, + }) this.sessions.set(userId, session) } return session diff --git a/apps/aelis-backend/src/session/user-session.test.ts b/apps/aelis-backend/src/session/user-session.test.ts index 4d0a374..14d7a67 100644 --- a/apps/aelis-backend/src/session/user-session.test.ts +++ b/apps/aelis-backend/src/session/user-session.test.ts @@ -1,8 +1,16 @@ -import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core" +import type { + ActionDefinition, + ContextEntry, + FeedItem, + FeedItemRenderer, + FeedSource, +} from "@aelis/core" import { LocationSource } from "@aelis/source-location" +import { JRX_NODE } from "@nym.sh/jrx" import { describe, expect, test } from "bun:test" +import { FeedRenderer } from "./feed-renderer.ts" import { UserSession } from "./user-session.ts" function createStubSource(id: string, items: FeedItem[] = []): FeedSource { @@ -25,7 +33,9 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource { describe("UserSession", () => { test("registers sources and starts engine", async () => { - const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")]) + const session = new UserSession({ + sources: [createStubSource("test-a"), createStubSource("test-b")], + }) const result = await session.engine.refresh() @@ -34,7 +44,7 @@ describe("UserSession", () => { test("getSource returns registered source", () => { const location = new LocationSource() - const session = new UserSession([location]) + const session = new UserSession({ sources: [location] }) const result = session.getSource("aelis.location") @@ -42,13 +52,13 @@ describe("UserSession", () => { }) test("getSource returns undefined for unknown source", () => { - const session = new UserSession([createStubSource("test")]) + const session = new UserSession({ sources: [createStubSource("test")] }) expect(session.getSource("unknown")).toBeUndefined() }) test("destroy stops engine and clears sources", () => { - const session = new UserSession([createStubSource("test")]) + const session = new UserSession({ sources: [createStubSource("test")] }) session.destroy() @@ -57,7 +67,7 @@ describe("UserSession", () => { test("engine.executeAction routes to correct source", async () => { const location = new LocationSource() - const session = new UserSession([location]) + const session = new UserSession({ sources: [location] }) await session.engine.executeAction("aelis.location", "update-location", { lat: 51.5, @@ -82,7 +92,7 @@ describe("UserSession.feed", () => { data: { value: 42 }, }, ] - const session = new UserSession([createStubSource("test", items)]) + const session = new UserSession({ sources: [createStubSource("test", items)] }) const result = await session.feed() @@ -103,7 +113,7 @@ describe("UserSession.feed", () => { const enhancer = async (feedItems: FeedItem[]) => feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } })) - const session = new UserSession([createStubSource("test", items)], enhancer) + const session = new UserSession({ sources: [createStubSource("test", items)], enhancer }) const result = await session.feed() @@ -127,7 +137,7 @@ describe("UserSession.feed", () => { return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } })) } - const session = new UserSession([createStubSource("test", items)], enhancer) + const session = new UserSession({ sources: [createStubSource("test", items)], enhancer }) const result1 = await session.feed() expect(result1.items[0]!.data.enhanced).toBe(true) @@ -162,7 +172,7 @@ describe("UserSession.feed", () => { })) } - const session = new UserSession([source], enhancer) + const session = new UserSession({ sources: [source], enhancer }) // First feed triggers refresh + enhancement const result1 = await session.feed() @@ -205,7 +215,7 @@ describe("UserSession.feed", () => { throw new Error("enhancement exploded") } - const session = new UserSession([createStubSource("test", items)], enhancer) + const session = new UserSession({ sources: [createStubSource("test", items)], enhancer }) const result = await session.feed() @@ -214,3 +224,81 @@ describe("UserSession.feed", () => { expect(result.items[0]!.data.value).toBe(42) }) }) + +describe("FeedRenderer", () => { + const stubRenderer: FeedItemRenderer = (item) => ({ + $$typeof: JRX_NODE, + type: "FeedCard", + props: { content: item.data.value }, + children: [], + key: undefined, + visible: undefined, + on: undefined, + repeat: undefined, + watch: undefined, + }) + + function makeItem(sourceId: string): FeedItem { + return { + id: `item-${sourceId}`, + sourceId, + type: "some-type", + timestamp: new Date("2025-01-01T00:00:00.000Z"), + data: { value: 42 }, + } + } + + test("renders items with matching sourceId", () => { + const renderer = new FeedRenderer({ "test-source": stubRenderer }) + + const result = renderer.render([makeItem("test-source")]) + + expect(result).toHaveLength(1) + expect(result[0]!.ui).toBeDefined() + expect(result[0]!.id).toBe("item-test-source") + }) + + test("drops items without a matching renderer", () => { + const renderer = new FeedRenderer({ "test-source": stubRenderer }) + + const result = renderer.render([makeItem("unknown-source")]) + + expect(result).toHaveLength(0) + }) + + test("filters mixed items", () => { + const renderer = new FeedRenderer({ "test-source": stubRenderer }) + + const result = renderer.render([makeItem("test-source"), makeItem("unknown-source")]) + + expect(result).toHaveLength(1) + expect(result[0]!.id).toBe("item-test-source") + }) + + test("renders empty array for empty input", () => { + const renderer = new FeedRenderer({ "test-source": stubRenderer }) + + expect(renderer.render([])).toHaveLength(0) + }) + + test("renders with no renderers registered", () => { + const renderer = new FeedRenderer({}) + + expect(renderer.render([makeItem("test-source")])).toHaveLength(0) + }) +}) + +describe("UserSession.renderer", () => { + test("exposes renderer when provided", () => { + const renderer = new FeedRenderer({}) + const session = new UserSession({ sources: [createStubSource("test")], renderer }) + + expect(session.renderer).toBe(renderer) + }) + + test("renderer is null when not provided", () => { + const session = new UserSession({ sources: [createStubSource("test")] }) + + expect(session.renderer).toBeNull() + }) +}) diff --git a/apps/aelis-backend/src/session/user-session.ts b/apps/aelis-backend/src/session/user-session.ts index 31633f5..310ed80 100644 --- a/apps/aelis-backend/src/session/user-session.ts +++ b/apps/aelis-backend/src/session/user-session.ts @@ -1,9 +1,17 @@ import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aelis/core" import type { FeedEnhancer } from "../enhancement/enhance-feed.ts" +import type { FeedRenderer } from "./feed-renderer.ts" + +export interface UserSessionOptions { + sources: FeedSource[] + enhancer?: FeedEnhancer | null + renderer?: FeedRenderer | null +} export class UserSession { readonly engine: FeedEngine + readonly renderer: FeedRenderer | null private sources = new Map() private readonly enhancer: FeedEnhancer | null private enhancedItems: FeedItem[] | null = null @@ -12,10 +20,11 @@ export class UserSession { private enhancingPromise: Promise | null = null private unsubscribe: (() => void) | null = null - constructor(sources: FeedSource[], enhancer?: FeedEnhancer | null) { + constructor(options: UserSessionOptions) { this.engine = new FeedEngine() - this.enhancer = enhancer ?? null - for (const source of sources) { + this.enhancer = options.enhancer ?? null + this.renderer = options.renderer ?? null + for (const source of options.sources) { this.sources.set(source.id, source) this.engine.register(source) } diff --git a/apps/aelis-backend/src/tfl/renderer-provider.ts b/apps/aelis-backend/src/tfl/renderer-provider.ts new file mode 100644 index 0000000..4657a80 --- /dev/null +++ b/apps/aelis-backend/src/tfl/renderer-provider.ts @@ -0,0 +1,7 @@ +import type { FeedItemRenderer } from "@aelis/core" + +import { renderTflAlert } from "@aelis/source-tfl" + +export const TFL_SOURCE_ID = "aelis.tfl" + +export const tflRenderer: FeedItemRenderer = renderTflAlert as FeedItemRenderer diff --git a/bun.lock b/bun.lock index 6dd9301..d2747f4 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,7 @@ "@aelis/source-location": "workspace:*", "@aelis/source-tfl": "workspace:*", "@aelis/source-weatherkit": "workspace:*", + "@nym.sh/jrx": "^0.2.0", "@openrouter/sdk": "^0.9.11", "arktype": "^2.1.29", "better-auth": "^1",