2026-03-15 22:57:19 +00:00
|
|
|
import type { FeedSource } from "@aelis/core"
|
|
|
|
|
|
2026-03-05 02:01:30 +00:00
|
|
|
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
2026-03-19 23:32:29 +00:00
|
|
|
import type { FeedSourceProvider } from "./feed-source-provider.ts"
|
2026-02-22 20:59:19 +00:00
|
|
|
|
2026-02-18 00:41:20 +00:00
|
|
|
import { UserSession } from "./user-session.ts"
|
|
|
|
|
|
2026-03-05 02:01:30 +00:00
|
|
|
export interface UserSessionManagerConfig {
|
2026-03-19 23:32:29 +00:00
|
|
|
providers: FeedSourceProvider[]
|
2026-03-05 02:01:30 +00:00
|
|
|
feedEnhancer?: FeedEnhancer | null
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 00:41:20 +00:00
|
|
|
export class UserSessionManager {
|
2026-03-19 23:32:29 +00:00
|
|
|
private sessions = new Map<string, { userId: string; session: UserSession }>()
|
2026-03-15 22:57:19 +00:00
|
|
|
private pending = new Map<string, Promise<UserSession>>()
|
2026-03-19 23:32:29 +00:00
|
|
|
private readonly providers = new Map<string, FeedSourceProvider>()
|
2026-03-05 02:01:30 +00:00
|
|
|
private readonly feedEnhancer: FeedEnhancer | null
|
2026-02-18 00:41:20 +00:00
|
|
|
|
2026-03-05 02:01:30 +00:00
|
|
|
constructor(config: UserSessionManagerConfig) {
|
2026-03-19 23:32:29 +00:00
|
|
|
for (const provider of config.providers) {
|
|
|
|
|
this.providers.set(provider.sourceId, provider)
|
|
|
|
|
}
|
2026-03-05 02:01:30 +00:00
|
|
|
this.feedEnhancer = config.feedEnhancer ?? null
|
2026-02-18 00:41:20 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-15 22:57:19 +00:00
|
|
|
async getOrCreate(userId: string): Promise<UserSession> {
|
|
|
|
|
const existing = this.sessions.get(userId)
|
2026-03-19 23:32:29 +00:00
|
|
|
if (existing) return existing.session
|
2026-03-15 22:57:19 +00:00
|
|
|
|
|
|
|
|
const inflight = this.pending.get(userId)
|
|
|
|
|
if (inflight) return inflight
|
|
|
|
|
|
|
|
|
|
const promise = this.createSession(userId)
|
|
|
|
|
this.pending.set(userId, promise)
|
|
|
|
|
try {
|
|
|
|
|
const session = await promise
|
|
|
|
|
// If remove() was called while we were awaiting, it clears the
|
|
|
|
|
// pending entry. Detect that and destroy the session immediately.
|
|
|
|
|
if (!this.pending.has(userId)) {
|
|
|
|
|
session.destroy()
|
|
|
|
|
throw new Error(`Session for user ${userId} was removed during creation`)
|
|
|
|
|
}
|
2026-03-19 23:32:29 +00:00
|
|
|
this.sessions.set(userId, { userId, session })
|
2026-03-15 22:57:19 +00:00
|
|
|
return session
|
|
|
|
|
} finally {
|
|
|
|
|
this.pending.delete(userId)
|
2026-02-18 00:41:20 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
remove(userId: string): void {
|
2026-03-19 23:32:29 +00:00
|
|
|
const entry = this.sessions.get(userId)
|
|
|
|
|
if (entry) {
|
|
|
|
|
entry.session.destroy()
|
2026-02-18 00:41:20 +00:00
|
|
|
this.sessions.delete(userId)
|
|
|
|
|
}
|
2026-03-15 22:57:19 +00:00
|
|
|
// Cancel any in-flight creation so getOrCreate won't store the session
|
|
|
|
|
this.pending.delete(userId)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 23:32:29 +00:00
|
|
|
/**
|
|
|
|
|
* Replaces a provider and updates all active sessions.
|
|
|
|
|
* The new provider must have the same sourceId as an existing one.
|
|
|
|
|
* For each active session, resolves a new source from the provider.
|
|
|
|
|
* If the provider fails for a user, the old source is removed from that session.
|
|
|
|
|
*/
|
|
|
|
|
async replaceProvider(provider: FeedSourceProvider): Promise<void> {
|
|
|
|
|
if (!this.providers.has(provider.sourceId)) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Cannot replace provider "${provider.sourceId}": no existing provider with that sourceId`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.providers.set(provider.sourceId, provider)
|
|
|
|
|
|
|
|
|
|
const updates: Promise<void>[] = []
|
|
|
|
|
|
|
|
|
|
for (const [, { userId, session }] of this.sessions) {
|
|
|
|
|
updates.push(this.updateSessionSource(provider, userId, session))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Also update sessions that are currently being created so they
|
|
|
|
|
// don't land in this.sessions with a stale source.
|
|
|
|
|
for (const [userId, pendingPromise] of this.pending) {
|
|
|
|
|
updates.push(
|
|
|
|
|
pendingPromise
|
|
|
|
|
.then((session) => this.updateSessionSource(provider, userId, session))
|
|
|
|
|
.catch(() => {
|
|
|
|
|
// Session creation itself failed — nothing to update.
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await Promise.all(updates)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async updateSessionSource(
|
|
|
|
|
provider: FeedSourceProvider,
|
|
|
|
|
userId: string,
|
|
|
|
|
session: UserSession,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const newSource = await provider.feedSourceForUser(userId)
|
|
|
|
|
session.replaceSource(provider.sourceId, newSource)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(
|
|
|
|
|
`[UserSessionManager] replaceProvider("${provider.sourceId}") failed for user ${userId}:`,
|
|
|
|
|
err,
|
|
|
|
|
)
|
|
|
|
|
session.removeSource(provider.sourceId)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 22:57:19 +00:00
|
|
|
private async createSession(userId: string): Promise<UserSession> {
|
|
|
|
|
const results = await Promise.allSettled(
|
2026-03-19 23:32:29 +00:00
|
|
|
Array.from(this.providers.values()).map((p) => p.feedSourceForUser(userId)),
|
2026-03-15 22:57:19 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const sources: FeedSource[] = []
|
|
|
|
|
const errors: unknown[] = []
|
|
|
|
|
|
|
|
|
|
for (const result of results) {
|
|
|
|
|
if (result.status === "fulfilled") {
|
|
|
|
|
sources.push(result.value)
|
|
|
|
|
} else {
|
|
|
|
|
errors.push(result.reason)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sources.length === 0 && errors.length > 0) {
|
|
|
|
|
throw new AggregateError(errors, "All feed source providers failed")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const error of errors) {
|
|
|
|
|
console.error("[UserSessionManager] Feed source provider failed:", error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new UserSession(sources, this.feedEnhancer)
|
2026-02-18 00:41:20 +00:00
|
|
|
}
|
|
|
|
|
}
|