2026-03-05 02:01:30 +00:00
|
|
|
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aris/core"
|
|
|
|
|
|
|
|
|
|
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
2026-02-18 00:41:20 +00:00
|
|
|
|
|
|
|
|
export class UserSession {
|
|
|
|
|
readonly engine: FeedEngine
|
|
|
|
|
private sources = new Map<string, FeedSource>()
|
2026-03-05 02:01:30 +00:00
|
|
|
private readonly enhancer: FeedEnhancer | null
|
|
|
|
|
private enhancedItems: FeedItem[] | null = null
|
|
|
|
|
/** The FeedResult that enhancedItems was derived from. */
|
|
|
|
|
private enhancedSource: FeedResult | null = null
|
|
|
|
|
private enhancingPromise: Promise<void> | null = null
|
|
|
|
|
private unsubscribe: (() => void) | null = null
|
2026-02-18 00:41:20 +00:00
|
|
|
|
2026-03-05 02:01:30 +00:00
|
|
|
constructor(sources: FeedSource[], enhancer?: FeedEnhancer | null) {
|
2026-02-18 00:41:20 +00:00
|
|
|
this.engine = new FeedEngine()
|
2026-03-05 02:01:30 +00:00
|
|
|
this.enhancer = enhancer ?? null
|
2026-02-18 00:41:20 +00:00
|
|
|
for (const source of sources) {
|
|
|
|
|
this.sources.set(source.id, source)
|
|
|
|
|
this.engine.register(source)
|
|
|
|
|
}
|
2026-03-05 02:01:30 +00:00
|
|
|
|
|
|
|
|
if (this.enhancer) {
|
|
|
|
|
this.unsubscribe = this.engine.subscribe((result) => {
|
|
|
|
|
this.invalidateEnhancement()
|
|
|
|
|
this.runEnhancement(result)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 00:41:20 +00:00
|
|
|
this.engine.start()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 02:01:30 +00:00
|
|
|
/**
|
|
|
|
|
* Returns the current feed, refreshing if the engine cache expired.
|
|
|
|
|
* Enhancement runs eagerly on engine updates; this method awaits
|
|
|
|
|
* any in-flight enhancement or triggers one if needed.
|
|
|
|
|
*/
|
|
|
|
|
async feed(): Promise<FeedResult> {
|
|
|
|
|
const cached = this.engine.lastFeed()
|
|
|
|
|
const result = cached ?? (await this.engine.refresh())
|
|
|
|
|
|
|
|
|
|
if (!this.enhancer) {
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait for any in-flight background enhancement to finish
|
|
|
|
|
if (this.enhancingPromise) {
|
|
|
|
|
await this.enhancingPromise
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Serve cached enhancement only if it matches the current engine result
|
|
|
|
|
if (this.enhancedItems && this.enhancedSource === result) {
|
|
|
|
|
return { ...result, items: this.enhancedItems }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stale or missing — re-enhance
|
|
|
|
|
await this.runEnhancement(result)
|
|
|
|
|
|
|
|
|
|
if (this.enhancedItems) {
|
|
|
|
|
return { ...result, items: this.enhancedItems }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 00:41:20 +00:00
|
|
|
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
|
|
|
|
return this.sources.get(sourceId) as T | undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
destroy(): void {
|
2026-03-05 02:01:30 +00:00
|
|
|
this.unsubscribe?.()
|
|
|
|
|
this.unsubscribe = null
|
2026-02-18 00:41:20 +00:00
|
|
|
this.engine.stop()
|
|
|
|
|
this.sources.clear()
|
2026-03-05 02:01:30 +00:00
|
|
|
this.invalidateEnhancement()
|
|
|
|
|
this.enhancingPromise = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private invalidateEnhancement(): void {
|
|
|
|
|
this.enhancedItems = null
|
|
|
|
|
this.enhancedSource = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private runEnhancement(result: FeedResult): Promise<void> {
|
|
|
|
|
const promise = this.enhance(result)
|
|
|
|
|
this.enhancingPromise = promise
|
|
|
|
|
promise.finally(() => {
|
|
|
|
|
if (this.enhancingPromise === promise) {
|
|
|
|
|
this.enhancingPromise = null
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return promise
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async enhance(result: FeedResult): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
this.enhancedItems = await this.enhancer!(result.items)
|
|
|
|
|
this.enhancedSource = result
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[enhancement] Unexpected error:", err)
|
|
|
|
|
this.invalidateEnhancement()
|
|
|
|
|
}
|
2026-02-18 00:41:20 +00:00
|
|
|
}
|
|
|
|
|
}
|