UNPKG

jinaga

Version:

Data management for web and mobile applications.

203 lines (185 loc) 8.52 kB
import { WebSocketServer, WebSocket } from "ws"; import { Authorization } from "../authorization/authorization"; import { Specification } from "../specification/specification"; import { invertSpecification } from "../specification/inverse"; import { serializeGraph } from "../http/serializer"; import { FactEnvelope, FactReference, ProjectedResult, ReferencesByName } from "../storage"; import { UserIdentity } from "../user-identity"; import { InverseSpecificationEngine } from "./inverse-specification-engine"; import { BookmarkManager } from "./bookmark-manager"; import { SpecificationListener } from "../observable/observable"; import { DistributionEngine } from "../distribution/distribution-engine"; import { Trace } from "../util/trace"; export type FeedResolver = (feed: string) => Specification; export type FeedInfoResolver = (feed: string) => { specification: Specification; namedStart: ReferencesByName }; type Subscription = { feed: string; listeners: SpecificationListener[]; }; export class AuthorizationWebSocketHandler { private readonly subscriptions = new Map<string, Subscription>(); private readonly buffers = new WeakMap<WebSocket, string>(); constructor( private readonly authorization: Authorization, private readonly resolveFeed: FeedResolver, private readonly inverseEngine: InverseSpecificationEngine, private readonly bookmarks: BookmarkManager, private readonly distributionEngine?: DistributionEngine, private readonly resolveFeedInfo?: FeedInfoResolver ) {} handleConnection(socket: WebSocket, userIdentity: UserIdentity | null) { this.buffers.set(socket, ""); socket.on("message", async (data: any) => { const text = typeof data === "string" ? data : String(data); await this.pushChunk(socket, userIdentity, text); }); socket.on("close", () => { // Cleanup all listeners on disconnect for (const sub of this.subscriptions.values()) { for (const token of sub.listeners) { this.inverseEngine.removeSpecificationListener(token); } } this.subscriptions.clear(); }); } private async pushChunk(socket: WebSocket, userIdentity: UserIdentity | null, chunk: string) { // Append to per-socket buffer and attempt to parse complete frames const existing = this.buffers.get(socket) ?? ""; let buffer = existing + chunk; const parts = buffer.split(/\r?\n/); buffer = parts.pop() ?? ""; // remainder without trailing newline let i = 0; while (i < parts.length) { const line = parts[i]; if (line === "SUB" || line === "UNSUB") { const keyword = line; i++; const payload: string[] = []; while (i < parts.length) { const next = parts[i]; if (next === "") { break; } payload.push(next); i++; } // If we have a blank line terminator, ensure we have enough payload lines; otherwise treat as incomplete if (i >= parts.length || parts[i] !== "") { // No terminator present; reconstruct remainder and exit // Preserve line break so next chunk starts on a new line const remainder = [keyword, ...payload].join("\n") + "\n"; buffer = remainder + (buffer ? buffer : ""); break; } const required = keyword === "SUB" ? 2 : 1; if (payload.length < required) { // Not enough payload yet; push back without consuming terminator. // Preserve line break so the next incoming payload line does not concatenate with the keyword or prior payload. const remainder = [keyword, ...payload].join("\n") + "\n"; buffer = remainder + (buffer ? buffer : ""); break; } // Consume blank terminator i++; try { if (keyword === "SUB") { const feed = JSON.parse(payload[0] || '""'); const bookmark = JSON.parse(payload[1] || '""'); await this.handleSub(socket, userIdentity, feed, bookmark); } else { const feed = JSON.parse(payload[0] || '""'); this.handleUnsub(feed); } } catch { // Ignore malformed frame } continue; } // Unknown line; ignore i++; } // Save updated buffer this.buffers.set(socket, buffer); } private async handleSub(socket: WebSocket, userIdentity: UserIdentity | null, feed: string, bookmark: string) { try { const specification = this.resolveFeed(feed); const start: FactReference[] = []; // Optional distribution enforcement: if engine and resolver provided, validate access if (this.distributionEngine && this.resolveFeedInfo) { try { const { specification: feedSpec, namedStart } = this.resolveFeedInfo(feed); let userRef: FactReference | null = null; if (userIdentity) { const userFact = await this.authorization.getOrCreateUserFact(userIdentity); userRef = { type: userFact.type, hash: userFact.hash }; } const result = await this.distributionEngine.canDistributeToAll([feedSpec], namedStart, userRef); if (result.type === "failure") { const message = `Not authorized: ${result.reason}`; socket.send(`ERR\n${JSON.stringify(feed)}\n${JSON.stringify(message)}\n\n`); return; // Do not proceed with subscription } } catch (e: any) { const message = e && e.message ? e.message : String(e); socket.send(`ERR\n${JSON.stringify(feed)}\n${JSON.stringify(message)}\n\n`); return; } } // If server already has a more recent bookmark for this feed, sync it to client const serverKnown = this.bookmarks.syncBookmarkIfMismatch(feed, bookmark); if (serverKnown) { socket.send(`BOOK\n${JSON.stringify(feed)}\n${JSON.stringify(serverKnown)}\n\n`); } const factFeed = await this.authorization.feed(userIdentity, specification, start, bookmark); if (factFeed.tuples.length > 0) { const references: FactReference[] = factFeed.tuples.flatMap(t => t.facts); const envelopes: FactEnvelope[] = await this.authorization.load(userIdentity, references); socket.send(serializeGraph(envelopes)); } // Set initial bookmark if changed const nextBookmark = factFeed.bookmark || bookmark; if (nextBookmark && nextBookmark !== bookmark) { this.bookmarks.setBookmark(feed, nextBookmark); socket.send(`BOOK\n${JSON.stringify(feed)}\n${JSON.stringify(nextBookmark)}\n\n`); } // Register inverse specification listeners for reactive updates const inverses = invertSpecification(specification); const listenerTokens: SpecificationListener[] = []; for (const inv of inverses) { const token = this.inverseEngine.addSpecificationListener(inv.inverseSpecification, async (results: ProjectedResult[]) => { if (inv.operation === "add") { const refs: FactReference[] = results.flatMap(r => Object.values(r.tuple)); if (refs.length > 0) { const envs = await this.authorization.load(userIdentity, refs); socket.send(serializeGraph(envs)); } const advanced = await this.bookmarks.advanceBookmark(feed); socket.send(`BOOK\n${JSON.stringify(feed)}\n${JSON.stringify(advanced)}\n\n`); } else if (inv.operation === "remove") { // No facts to send; just advance bookmark to signal change const advanced = await this.bookmarks.advanceBookmark(feed); socket.send(`BOOK\n${JSON.stringify(feed)}\n${JSON.stringify(advanced)}\n\n`); } }); listenerTokens.push(token); } this.subscriptions.set(feed, { feed, listeners: listenerTokens }); // Send ACK to confirm subscription is active socket.send(`ACK\n${JSON.stringify(feed)}\n\n`); } catch (e: any) { const message = e && e.message ? e.message : String(e); socket.send(`ERR\n${JSON.stringify(feed)}\n${JSON.stringify(message)}\n\n`); } } private handleUnsub(feed: string) { const sub = this.subscriptions.get(feed); if (sub) { for (const token of sub.listeners) { this.inverseEngine.removeSpecificationListener(token); } this.subscriptions.delete(feed); } } }