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 <no-reply@ona.com>
This commit is contained in:
2026-03-15 17:07:33 +00:00
parent 4b824c66ce
commit 0145976a9f
13 changed files with 394 additions and 18 deletions

View File

@@ -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<string, unknown>
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" }