Compare commits

..

8 Commits

Author SHA1 Message Date
941acb826c feat: add GET /api/feed endpoint
Expose the user's current feed via GET /api/feed. Returns
cached feed from engine.lastFeed(), falling back to
engine.refresh() when no cache exists.

Auth middleware is injected as a dependency to allow test
substitution via mockAuthSessionMiddleware.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-24 22:30:13 +00:00
3d492a5d56 Merge pull request #35 from kennethnym/feat/feed-engine-cache
feat: add caching to FeedEngine
2026-02-24 01:21:20 +00:00
08dd437952 feat: add caching to FeedEngine
Add lastFeed() method that returns cached FeedResult within
a configurable TTL (default 5 min). refresh() always fetches
fresh data and updates the cache. Periodic auto-refresh via
recursive setTimeout when engine is started. Reactive updates
reset the timer to avoid redundant fetches.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-24 01:13:41 +00:00
2fc20759dd Merge pull request #34 from kennethnym/refactor/remove-trpc
refactor: remove tRPC, use plain Hono routes
2026-02-22 21:10:16 +00:00
963bf073d1 fix: handle malformed JSON in location handler
Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 21:03:58 +00:00
c0b3db0e11 Update apps/aris-backend/src/location/http.ts
Co-authored-by: ona-patrol <ona@nym.sh>
2026-02-22 21:02:44 +00:00
ca4a337dcd refactor: remove tRPC, use plain Hono routes
Replace tRPC location.update mutation with POST /api/location
using Hono route + requireSession middleware. Extract auth
types (AuthUser, AuthToken) into auth/session.ts. Inject
sessionManager via Hono context local to location handlers.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 20:59:19 +00:00
769e2d4eb0 Merge pull request #33 from kennethnym/feat/source-serif-4-font
Add Source Serif 4 font to aris-client
2026-02-22 19:45:19 +00:00
16 changed files with 648 additions and 125 deletions

View File

@@ -13,8 +13,6 @@
"@aris/source-location": "workspace:*",
"@aris/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*",
"@hono/trpc-server": "^0.3",
"@trpc/server": "^11",
"arktype": "^2.1.29",
"better-auth": "^1",
"hono": "^4",

View File

@@ -1,13 +1,20 @@
import type { Context, Next } from "hono"
import type { Context, MiddlewareHandler, Next } from "hono"
import type { AuthSession, AuthUser } from "./session.ts"
import { auth } from "./index.ts"
type SessionUser = typeof auth.$Infer.Session.user
type Session = typeof auth.$Infer.Session.session
export interface SessionVariables {
user: SessionUser | null
session: Session | null
user: AuthUser | null
session: AuthSession | null
}
export type AuthSessionEnv = { Variables: SessionVariables }
export type AuthSessionMiddleware = MiddlewareHandler<AuthSessionEnv>
declare module "hono" {
interface ContextVariableMap extends SessionVariables {}
}
/**
@@ -48,7 +55,22 @@ export async function requireSession(c: Context, next: Next): Promise<Response |
*/
export async function getSessionFromHeaders(
headers: Headers,
): Promise<{ user: SessionUser; session: Session } | null> {
): Promise<{ user: AuthUser; session: AuthSession } | null> {
const session = await auth.api.getSession({ headers })
return session
}
/**
* Test-only middleware that injects a fake user and session.
* Pass userId to simulate an authenticated request, or omit to get 401.
*/
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
return async (c: Context, next: Next): Promise<Response | void> => {
if (!userId) {
return c.json({ error: "Unauthorized" }, 401)
}
c.set("user", { id: userId } as AuthUser)
c.set("session", { id: "mock-session" } as AuthSession)
await next()
}
}

View File

@@ -0,0 +1,4 @@
import type { auth } from "./index.ts"
export type AuthUser = typeof auth.$Infer.Session.user
export type AuthSession = typeof auth.$Infer.Session.session

View File

@@ -0,0 +1,140 @@
import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/core"
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { UserSessionManager } from "../session/index.ts"
import { registerFeedHttpHandlers } from "./http.ts"
interface FeedResponse {
items: Array<{
id: string
type: string
priority: number
timestamp: string
data: Record<string, unknown>
}>
errors: Array<{ sourceId: string; error: string }>
}
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<Partial<Context> | null> {
return null
},
async fetchItems() {
return items
},
}
}
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
const app = new Hono()
registerFeedHttpHandlers(app, {
sessionManager,
authSessionMiddleware: mockAuthSessionMiddleware(userId),
})
return app
}
describe("GET /api/feed", () => {
test("returns 401 without auth", async () => {
const manager = new UserSessionManager([])
const app = buildTestApp(manager)
const res = await app.request("/api/feed")
expect(res.status).toBe(401)
})
test("returns cached feed when available", async () => {
const items: FeedItem[] = [
{
id: "item-1",
type: "test",
priority: 0.8,
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const manager = new UserSessionManager([() => createStubSource("test", items)])
const app = buildTestApp(manager, "user-1")
// Prime the cache
const session = manager.getOrCreate("user-1")
await session.engine.refresh()
expect(session.engine.lastFeed()).not.toBeNull()
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]!.id).toBe("item-1")
expect(body.items[0]!.type).toBe("test")
expect(body.items[0]!.priority).toBe(0.8)
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
expect(body.errors).toHaveLength(0)
})
test("forces refresh when no cached feed", async () => {
const items: FeedItem[] = [
{
id: "fresh-1",
type: "test",
priority: 0.5,
timestamp: new Date("2025-06-01T12:00:00.000Z"),
data: { fresh: true },
},
]
const manager = new UserSessionManager([() => createStubSource("test", items)])
const app = buildTestApp(manager, "user-1")
// No prior refresh — lastFeed() returns null, handler should call refresh()
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]!.id).toBe("fresh-1")
expect(body.items[0]!.data.fresh).toBe(true)
expect(body.errors).toHaveLength(0)
})
test("serializes source errors as message strings", async () => {
const failingSource: FeedSource = {
id: "failing",
async listActions() {
return {}
},
async executeAction() {
return undefined
},
async fetchContext() {
return null
},
async fetchItems() {
throw new Error("connection timeout")
},
}
const manager = new UserSessionManager([() => failingSource])
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(0)
expect(body.errors).toHaveLength(1)
expect(body.errors[0]!.sourceId).toBe("failing")
expect(body.errors[0]!.error).toBe("connection timeout")
})
})

View File

@@ -0,0 +1,41 @@
import type { Context, Hono } from "hono"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { UserSessionManager } from "../session/index.ts"
type Env = { Variables: { sessionManager: UserSessionManager } }
interface FeedHttpHandlersDeps {
sessionManager: UserSessionManager
authSessionMiddleware: AuthSessionMiddleware
}
export function registerFeedHttpHandlers(
app: Hono,
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager)
await next()
})
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
}
async function handleGetFeed(c: Context<Env>) {
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
const session = sessionManager.getOrCreate(user.id)
const feed = session.engine.lastFeed() ?? (await session.engine.refresh())
return c.json({
items: feed.items,
errors: feed.errors.map((e) => ({
sourceId: e.sourceId,
error: e.error.message,
})),
})
}

View File

@@ -0,0 +1,56 @@
import type { Context, Hono } from "hono"
import { type } from "arktype"
import { createMiddleware } from "hono/factory"
import type { UserSessionManager } from "../session/index.ts"
import { requireSession } from "../auth/session-middleware.ts"
type Env = { Variables: { sessionManager: UserSessionManager } }
const locationInput = type({
lat: "number",
lng: "number",
accuracy: "number",
timestamp: "string.date.iso",
})
export function registerLocationHttpHandlers(
app: Hono,
{ sessionManager }: { sessionManager: UserSessionManager },
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager)
await next()
})
app.post("/api/location", inject, requireSession, handleUpdateLocation)
}
async function handleUpdateLocation(c: Context<Env>) {
let body: unknown
try {
body = await c.req.json()
} catch {
return c.json({ error: "Invalid JSON" }, 400)
}
const result = locationInput(body)
if (result instanceof type.errors) {
return c.json({ error: result.summary }, 400)
}
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
const session = sessionManager.getOrCreate(user.id)
await session.engine.executeAction("aris.location", "update-location", {
lat: result.lat,
lng: result.lng,
accuracy: result.accuracy,
timestamp: new Date(result.timestamp),
})
return c.body(null, 204)
}

View File

@@ -1,28 +0,0 @@
import { type } from "arktype"
import type { UserSessionManager } from "../session/index.ts"
import type { TRPC } from "../trpc/router.ts"
const locationInput = type({
lat: "number",
lng: "number",
accuracy: "number",
timestamp: "Date",
})
export function createLocationRouter(
t: TRPC,
{ sessionManager }: { sessionManager: UserSessionManager },
) {
return t.router({
update: t.procedure.input(locationInput).mutation(async ({ input, ctx }) => {
const session = sessionManager.getOrCreate(ctx.user.id)
await session.engine.executeAction("aris.location", "update-location", {
lat: input.lat,
lng: input.lng,
accuracy: input.accuracy,
timestamp: input.timestamp,
})
}),
})
}

View File

@@ -1,12 +1,12 @@
import { LocationSource } from "@aris/source-location"
import { trpcServer } from "@hono/trpc-server"
import { Hono } from "hono"
import { registerAuthHandlers } from "./auth/http.ts"
import { requireSession } from "./auth/session-middleware.ts"
import { registerFeedHttpHandlers } from "./feed/http.ts"
import { registerLocationHttpHandlers } from "./location/http.ts"
import { UserSessionManager } from "./session/index.ts"
import { WeatherSourceProvider } from "./weather/provider.ts"
import { createContext } from "./trpc/context.ts"
import { createTRPCRouter } from "./trpc/router.ts"
function main() {
const sessionManager = new UserSessionManager([
@@ -21,21 +21,13 @@ function main() {
}),
])
const trpcRouter = createTRPCRouter({ sessionManager })
const app = new Hono()
app.get("/health", (c) => c.json({ status: "ok" }))
registerAuthHandlers(app)
app.use(
"/trpc/*",
trpcServer({
router: trpcRouter,
createContext,
}),
)
registerFeedHttpHandlers(app, { sessionManager, authSessionMiddleware: requireSession })
registerLocationHttpHandlers(app, { sessionManager })
return app
}

View File

@@ -1,11 +1,11 @@
import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit"
import { LocationSource } from "@aris/source-location"
import { describe, expect, mock, test } from "bun:test"
import { WeatherSourceProvider } from "../weather/provider.ts"
import { UserSessionManager } from "./user-session-manager.ts"
import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit"
const mockWeatherClient: WeatherKitClient = {
fetch: async () => ({}) as WeatherKitResponse,
}

View File

@@ -1,4 +1,5 @@
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
import { UserSession } from "./user-session.ts"
export class UserSessionManager {

View File

@@ -1,14 +0,0 @@
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"
import { auth } from "../auth/index.ts"
export async function createContext(opts: FetchCreateContextFnOptions) {
const session = await auth.api.getSession({ headers: opts.req.headers })
return {
user: session?.user ?? null,
session: session?.session ?? null,
}
}
export type Context = Awaited<ReturnType<typeof createContext>>

View File

@@ -1,44 +0,0 @@
import { initTRPC, TRPCError } from "@trpc/server"
import type { UserSessionManager } from "../session/index.ts"
import type { Context } from "./context.ts"
import { createLocationRouter } from "../location/router.ts"
export type TRPC = ReturnType<typeof createTRPC>
export interface TRPCRouterDeps {
sessionManager: UserSessionManager
}
export function createTRPCRouter({ sessionManager }: TRPCRouterDeps) {
const t = createTRPC()
return t.router({
location: createLocationRouter(t, { sessionManager }),
})
}
export type TRPCRouter = ReturnType<typeof createTRPCRouter>
function createTRPC() {
const t = initTRPC.context<Context>().create()
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user || !ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED" })
}
return next({
ctx: {
user: ctx.user,
session: ctx.session,
},
})
})
return {
router: t.router,
procedure: t.procedure.use(isAuthed),
}
}

View File

@@ -21,8 +21,6 @@
"@aris/source-location": "workspace:*",
"@aris/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*",
"@hono/trpc-server": "^0.3",
"@trpc/server": "^11",
"arktype": "^2.1.29",
"better-auth": "^1",
"hono": "^4",
@@ -468,8 +466,6 @@
"@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="],
"@hono/trpc-server": ["@hono/trpc-server@0.3.4", "", { "peerDependencies": { "@trpc/server": "^10.10.0 || >11.0.0-rc", "hono": ">=4.*" } }, "sha512-xFOPjUPnII70FgicDzOJy1ufIoBTu8eF578zGiDOrYOrYN8CJe140s9buzuPkX+SwJRYK8LjEBHywqZtxdm8aA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -686,8 +682,6 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@trpc/server": ["@trpc/server@11.10.0", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-zZjTrR6He61e5TiT7e/bQqab/jRcXBZM8Fg78Yoo8uh5pz60dzzbYuONNUCOkafv5ppXVMms4NHYfNZgzw50vg=="],
"@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],

View File

@@ -638,4 +638,290 @@ describe("FeedEngine", () => {
)
})
})
describe("lastFeed", () => {
test("returns null before any refresh", () => {
const engine = new FeedEngine()
expect(engine.lastFeed()).toBeNull()
})
test("returns cached result after refresh", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource()
const engine = new FeedEngine().register(location).register(weather)
const refreshResult = await engine.refresh()
const cached = engine.lastFeed()
expect(cached).not.toBeNull()
expect(cached!.items).toEqual(refreshResult.items)
expect(cached!.context).toEqual(refreshResult.context)
})
test("returns null after TTL expires", async () => {
const engine = new FeedEngine({ cacheTtlMs: 50 })
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
engine.register(location)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
await new Promise((resolve) => setTimeout(resolve, 60))
expect(engine.lastFeed()).toBeNull()
})
test("defaults to 5 minute TTL", async () => {
const engine = new FeedEngine()
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
engine.register(location)
await engine.refresh()
// Should still be cached immediately
expect(engine.lastFeed()).not.toBeNull()
})
test("refresh always fetches from sources", async () => {
let fetchCount = 0
const source: FeedSource = {
id: "counter",
...noActions,
async fetchContext() {
fetchCount++
return null
},
}
const engine = new FeedEngine().register(source)
await engine.refresh()
await engine.refresh()
await engine.refresh()
expect(fetchCount).toBe(3)
})
test("reactive context update refreshes cache", async () => {
const location = createLocationSource()
const weather = createWeatherSource()
const engine = new FeedEngine({ cacheTtlMs: 5000 }).register(location).register(weather)
engine.start()
// Simulate location update which triggers reactive refresh
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
// Wait for async reactive refresh to complete
await new Promise((resolve) => setTimeout(resolve, 50))
const cached = engine.lastFeed()
expect(cached).not.toBeNull()
expect(cached!.items.length).toBeGreaterThan(0)
engine.stop()
})
test("reactive item update refreshes cache", async () => {
let itemUpdateCallback: (() => void) | null = null
const source: FeedSource = {
id: "reactive-items",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [
{
id: "item-1",
type: "test",
priority: 0.5,
timestamp: new Date(),
data: {},
},
]
},
onItemsUpdate(callback) {
itemUpdateCallback = callback
return () => {
itemUpdateCallback = null
}
},
}
const engine = new FeedEngine().register(source)
engine.start()
// Trigger item update
itemUpdateCallback!()
// Wait for async refresh
await new Promise((resolve) => setTimeout(resolve, 50))
const cached = engine.lastFeed()
expect(cached).not.toBeNull()
expect(cached!.items).toHaveLength(1)
engine.stop()
})
test("TTL resets after reactive update", async () => {
const location = createLocationSource()
const weather = createWeatherSource()
const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather)
engine.start()
// Initial reactive update
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(engine.lastFeed()).not.toBeNull()
// Wait 70ms (total 120ms from first update, past original TTL)
// but trigger another update at 50ms to reset TTL
location.simulateUpdate({ lat: 52.0, lng: -0.2 })
await new Promise((resolve) => setTimeout(resolve, 50))
// Should still be cached because TTL was reset by second update
expect(engine.lastFeed()).not.toBeNull()
engine.stop()
})
test("cacheTtlMs is configurable", async () => {
const engine = new FeedEngine({ cacheTtlMs: 30 })
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
engine.register(location)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
await new Promise((resolve) => setTimeout(resolve, 40))
expect(engine.lastFeed()).toBeNull()
})
test("auto-refreshes on TTL interval after start", async () => {
let fetchCount = 0
const source: FeedSource = {
id: "counter",
...noActions,
async fetchContext() {
fetchCount++
return null
},
async fetchItems() {
return [
{
id: `item-${fetchCount}`,
type: "test",
priority: 0.5,
timestamp: new Date(),
data: {},
},
]
},
}
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
engine.start()
// Wait for two TTL intervals to elapse
await new Promise((resolve) => setTimeout(resolve, 120))
// Should have auto-refreshed at least twice
expect(fetchCount).toBeGreaterThanOrEqual(2)
expect(engine.lastFeed()).not.toBeNull()
engine.stop()
})
test("stop cancels periodic refresh", async () => {
let fetchCount = 0
const source: FeedSource = {
id: "counter",
...noActions,
async fetchContext() {
fetchCount++
return null
},
}
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
engine.start()
engine.stop()
const countAfterStop = fetchCount
// Wait past TTL
await new Promise((resolve) => setTimeout(resolve, 80))
// No additional fetches after stop
expect(fetchCount).toBe(countAfterStop)
})
test("reactive update resets periodic refresh timer", async () => {
let fetchCount = 0
const location = createLocationSource()
const countingWeather: FeedSource<WeatherFeedItem> = {
id: "weather",
dependencies: ["location"],
...noActions,
async fetchContext(ctx) {
fetchCount++
const loc = contextValue(ctx, LocationKey)
if (!loc) return null
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
},
async fetchItems(ctx) {
const weather = contextValue(ctx, WeatherKey)
if (!weather) return []
return [
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temperature: weather.temperature, condition: weather.condition },
},
]
},
}
const engine = new FeedEngine({ cacheTtlMs: 100 })
.register(location)
.register(countingWeather)
engine.start()
// At 40ms, push a reactive update — this resets the timer
await new Promise((resolve) => setTimeout(resolve, 40))
const countBeforeUpdate = fetchCount
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
await new Promise((resolve) => setTimeout(resolve, 20))
// Reactive update triggered a fetch
expect(fetchCount).toBeGreaterThan(countBeforeUpdate)
const countAfterUpdate = fetchCount
// At 100ms from start (60ms after reactive update), the original
// timer would have fired, but it was reset. No extra fetch yet.
await new Promise((resolve) => setTimeout(resolve, 40))
expect(fetchCount).toBe(countAfterUpdate)
engine.stop()
})
})
})

View File

@@ -16,6 +16,14 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
const DEFAULT_CACHE_TTL_MS = 300_000 // 5 minutes
const MIN_CACHE_TTL_MS = 10 // prevent spin from zero/negative values
export interface FeedEngineConfig {
/** Cache TTL in milliseconds. Default: 300_000 (5 minutes). Minimum: 10. */
cacheTtlMs?: number
}
interface SourceGraph {
sources: Map<string, FeedSource>
sorted: FeedSource[]
@@ -59,6 +67,29 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
private cleanups: Array<() => void> = []
private started = false
private readonly cacheTtlMs: number
private cachedResult: FeedResult<TItems> | null = null
private cachedAt: number | null = null
private refreshTimer: ReturnType<typeof setTimeout> | null = null
constructor(config?: FeedEngineConfig) {
this.cacheTtlMs = Math.max(config?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS, MIN_CACHE_TTL_MS)
}
/**
* Returns the cached FeedResult if available and not expired.
* Returns null if no refresh has completed or the cache TTL has elapsed.
*/
lastFeed(): FeedResult<TItems> | null {
if (this.cachedResult === null || this.cachedAt === null) {
return null
}
if (Date.now() - this.cachedAt > this.cacheTtlMs) {
return null
}
return this.cachedResult
}
/**
* Registers a FeedSource. Invalidates the cached graph.
*/
@@ -124,7 +155,10 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
this.context = context
return { context, items: items as TItems[], errors }
const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
this.updateCache(result)
return result
}
/**
@@ -138,7 +172,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
}
/**
* Starts reactive subscriptions on all sources.
* Starts reactive subscriptions on all sources and begins periodic refresh.
* Sources with onContextUpdate will trigger re-computation of dependents.
*/
start(): void {
@@ -168,13 +202,16 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
this.cleanups.push(cleanup)
}
}
this.scheduleNextRefresh()
}
/**
* Stops all reactive subscriptions.
* Stops all reactive subscriptions and the periodic refresh timer.
*/
stop(): void {
this.started = false
this.cancelScheduledRefresh()
for (const cleanup of this.cleanups) {
cleanup()
}
@@ -279,11 +316,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
items.sort((a, b) => b.priority - a.priority)
this.notifySubscribers({
const result: FeedResult<TItems> = {
context: this.context,
items: items as TItems[],
errors,
})
}
this.updateCache(result)
this.notifySubscribers(result)
}
private collectDependents(sourceId: string, graph: SourceGraph): string[] {
@@ -307,11 +347,46 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return graph.sorted.filter((s) => result.includes(s.id)).map((s) => s.id)
}
private updateCache(result: FeedResult<TItems>): void {
this.cachedResult = result
this.cachedAt = Date.now()
if (this.started) {
this.scheduleNextRefresh()
}
}
private scheduleNextRefresh(): void {
this.cancelScheduledRefresh()
this.refreshTimer = setTimeout(() => {
this.refresh()
.then((result) => {
this.notifySubscribers(result)
})
.catch(() => {
// Periodic refresh errors are non-fatal; schedule next attempt
if (this.started) {
this.scheduleNextRefresh()
}
})
}, this.cacheTtlMs)
}
private cancelScheduledRefresh(): void {
if (this.refreshTimer !== null) {
clearTimeout(this.refreshTimer)
this.refreshTimer = null
}
}
private scheduleRefresh(): void {
// Simple immediate refresh for now - could add debouncing later
this.refresh().then((result) => {
this.refresh()
.then((result) => {
this.notifySubscribers(result)
})
.catch(() => {
// Reactive refresh errors are non-fatal
})
}
private notifySubscribers(result: FeedResult<TItems>): void {

View File

@@ -13,7 +13,7 @@ export type { FeedItem } from "./feed"
export type { FeedSource } from "./feed-source"
// Feed Engine
export type { FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export { FeedEngine } from "./feed-engine"
// =============================================================================