diff --git a/apps/kamp-us/package.json b/apps/kamp-us/package.json index 2a2de0a..5a8827f 100644 --- a/apps/kamp-us/package.json +++ b/apps/kamp-us/package.json @@ -17,6 +17,7 @@ "dependencies": { "@base-ui/react": "catalog:", "@radix-ui/colors": "catalog:", + "graphql-ws": "catalog:", "react": "^19.1.1", "react-dom": "^19.1.1", "react-relay": "catalog:", diff --git a/apps/kamp-us/src/auth/AuthContext.tsx b/apps/kamp-us/src/auth/AuthContext.tsx index cfafedf..3677608 100644 --- a/apps/kamp-us/src/auth/AuthContext.tsx +++ b/apps/kamp-us/src/auth/AuthContext.tsx @@ -1,20 +1,21 @@ -import {createContext, useContext, useState, useCallback, type ReactNode} from "react"; +import {createContext, type ReactNode, useCallback, useContext, useState} from "react"; +import {resetSubscriptionClient} from "../relay/environment"; interface User { - id: string; - email: string; - name?: string; + id: string; + email: string; + name?: string; } interface AuthState { - user: User | null; - token: string | null; + user: User | null; + token: string | null; } interface AuthContextValue extends AuthState { - login: (user: User, token: string) => void; - logout: () => void; - isAuthenticated: boolean; + login: (user: User, token: string) => void; + logout: () => void; + isAuthenticated: boolean; } const AuthContext = createContext(null); @@ -22,66 +23,80 @@ const AuthContext = createContext(null); const STORAGE_KEY = "kampus_auth"; function loadAuthState(): AuthState { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - return JSON.parse(stored); - } - } catch { - // Ignore parse errors - } - return {user: null, token: null}; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch { + // Ignore parse errors + } + return {user: null, token: null}; } function saveAuthState(state: AuthState) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } function clearAuthState() { - localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); } export function AuthProvider({children}: {children: ReactNode}) { - const [authState, setAuthState] = useState(loadAuthState); - - const login = useCallback((user: User, token: string) => { - const newState = {user, token}; - setAuthState(newState); - saveAuthState(newState); - }, []); - - const logout = useCallback(() => { - setAuthState({user: null, token: null}); - clearAuthState(); - }, []); - - const value: AuthContextValue = { - ...authState, - login, - logout, - isAuthenticated: !!authState.token, - }; - - return {children}; + const [authState, setAuthState] = useState(loadAuthState); + + const login = useCallback((user: User, token: string) => { + const newState = {user, token}; + setAuthState(newState); + saveAuthState(newState); + }, []); + + const logout = useCallback(() => { + setAuthState({user: null, token: null}); + clearAuthState(); + resetSubscriptionClient(); + }, []); + + const value: AuthContextValue = { + ...authState, + login, + logout, + isAuthenticated: !!authState.token, + }; + + return {children}; } export function useAuth() { - const context = useContext(AuthContext); - if (!context) { - throw new Error("useAuth must be used within an AuthProvider"); - } - return context; + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; } export function getStoredToken(): string | null { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored); - return parsed.token || null; - } - } catch { - // Ignore - } - return null; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return parsed.token || null; + } + } catch { + // Ignore + } + return null; +} + +export function getStoredUserId(): string | null { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return parsed.user?.id || null; + } + } catch { + // Ignore + } + return null; } diff --git a/apps/kamp-us/src/lib/websocket.ts b/apps/kamp-us/src/lib/websocket.ts new file mode 100644 index 0000000..f9319f1 --- /dev/null +++ b/apps/kamp-us/src/lib/websocket.ts @@ -0,0 +1,24 @@ +import {getStoredUserId} from "../auth/AuthContext"; + +/** + * Constructs the WebSocket URL for GraphQL subscriptions. + * + * In development, connects directly to the backend worker on port 8787. + * In production, uses the same host (proxied through kamp-us worker). + * + * SECURITY: Token is passed via connectionParams (in connection_init message), + * NOT in the URL. The user ID in the URL is used for routing only (not secret). + * The backend validates the token from connectionParams matches the routed user. + */ +export function getWebSocketUrl(): string { + const userId = getStoredUserId(); + // User ID is used for routing only - token is validated in connectionParams + const userParam = userId ? `?userId=${encodeURIComponent(userId)}` : ""; + + if (import.meta.env.DEV) { + return `ws://localhost:8787/graphql${userParam}`; + } + + // Always use WSS in production for security + return `wss://${window.location.host}/graphql${userParam}`; +} diff --git a/apps/kamp-us/src/pages/Library.tsx b/apps/kamp-us/src/pages/Library.tsx index aabbf7e..f3faec4 100644 --- a/apps/kamp-us/src/pages/Library.tsx +++ b/apps/kamp-us/src/pages/Library.tsx @@ -42,11 +42,251 @@ import {Menu} from "../design/Menu"; import {TagChip} from "../design/TagChip"; import {type Tag, TagInput} from "../design/TagInput"; import {Textarea} from "../design/Textarea"; -import {updateConnectionCount} from "../relay/updateConnectionCount"; +import {getSubscriptionClient} from "../relay/environment"; import styles from "./Library.module.css"; const DEFAULT_PAGE_SIZE = 20; +// --- Library Channel Subscription Hook --- + +interface LibraryChangeEvent { + type: "library:change"; + totalStories: number; + totalTags: number; +} + +interface StoryPayload { + id: string; + url: string; + title: string; + description: string | null; + createdAt: string; +} + +interface StoryCreateEvent { + type: "story:create"; + story: StoryPayload; +} + +interface StoryDeleteEvent { + type: "story:delete"; + deletedStoryId: string; +} + +interface TagPayload { + id: string; + name: string; + color: string; + createdAt: string; +} + +interface StoryTagEvent { + type: "story:tag"; + storyId: string; + tags: TagPayload[]; +} + +interface StoryUntagEvent { + type: "story:untag"; + storyId: string; + tagIds: string[]; +} + +interface LibraryEvent { + type: string; + [key: string]: unknown; +} + +/** + * Helper to safely update the Relay store with error handling. + */ +function safeStoreUpdate( + environment: ReturnType, + eventType: string, + updater: (store: Parameters[0]>[0]) => void, +): void { + try { + environment.commitUpdate((store) => { + try { + updater(store); + } catch (error) { + console.error(`[Subscription] Failed to update store for ${eventType}:`, error); + } + }); + } catch (error) { + console.error(`[Subscription] commitUpdate failed for ${eventType}:`, error); + } +} + +/** + * Hook to subscribe to library channel events and update the Relay store. + * Uses the singleton graphql-ws client from environment.ts. + */ +function useLibrarySubscription(connectionId: string | null) { + const environment = useRelayEnvironment(); + + useEffect(() => { + if (!connectionId) return; + + // Track if component is mounted to prevent stale updates + let mounted = true; + + // Use the singleton client - don't create a new one per component + const client = getSubscriptionClient(); + + // Subscribe to library channel + // The query format matches what UserChannel DO expects + const unsubscribe = client.subscribe( + { + query: 'subscription { channel(name: "library") { type } }', + }, + { + next: (result) => { + // Guard against stale updates after unmount + if (!mounted) return; + + const event = (result.data as {channel: LibraryEvent} | undefined)?.channel; + if (!event) return; + + // Handle library:change event - update totalCount in Relay store + if (event.type === "library:change") { + const changeEvent = event as LibraryChangeEvent; + safeStoreUpdate(environment, "library:change", (store) => { + const connection = store.get(connectionId); + if (connection) { + connection.setValue(changeEvent.totalStories, "totalCount"); + } + }); + } + + // Handle story:create event - add story to connection + if (event.type === "story:create") { + const createEvent = event as StoryCreateEvent; + // NOTE: story.id is a global ID (base64-encoded "Story:story_xxx") + // This matches the format used by Relay for the `id` field + const globalId = createEvent.story.id; + safeStoreUpdate(environment, "story:create", (store) => { + const connection = store.get(connectionId); + if (!connection) return; + + // Check if story already exists (avoid duplicates from own mutation) + // Compare using both getDataID() and id field for robustness + const edges = connection.getLinkedRecords("edges") || []; + const exists = edges.some((edge) => { + const node = edge?.getLinkedRecord("node"); + if (!node) return false; + const nodeId = node.getValue("id"); + return node.getDataID() === globalId || nodeId === globalId; + }); + if (exists) return; + + // Create story record using global ID as data ID + const storyRecord = store.create(globalId, "Story"); + storyRecord.setValue(globalId, "id"); + storyRecord.setValue(createEvent.story.url, "url"); + storyRecord.setValue(createEvent.story.title, "title"); + storyRecord.setValue(createEvent.story.description, "description"); + storyRecord.setValue(createEvent.story.createdAt, "createdAt"); + storyRecord.setLinkedRecords([], "tags"); + + // Create edge and prepend to connection + const edgeId = `client:edge:${globalId}`; + const edge = store.create(edgeId, "StoryEdge"); + edge.setLinkedRecord(storyRecord, "node"); + edge.setValue(globalId, "cursor"); + + const newEdges = [edge, ...edges]; + connection.setLinkedRecords(newEdges, "edges"); + }); + } + + // Handle story:delete event - remove story from connection + if (event.type === "story:delete") { + const deleteEvent = event as StoryDeleteEvent; + // NOTE: deletedStoryId is a global ID (base64-encoded "Story:story_xxx") + const globalId = deleteEvent.deletedStoryId; + safeStoreUpdate(environment, "story:delete", (store) => { + const connection = store.get(connectionId); + if (!connection) return; + + // Filter out the deleted story, comparing both dataID and id field + const edges = connection.getLinkedRecords("edges") || []; + const newEdges = edges.filter((edge) => { + const node = edge?.getLinkedRecord("node"); + if (!node) return true; + const nodeId = node.getValue("id"); + return node.getDataID() !== globalId && nodeId !== globalId; + }); + connection.setLinkedRecords(newEdges, "edges"); + }); + } + + // Handle story:tag event - add tags to story + if (event.type === "story:tag") { + const tagEvent = event as StoryTagEvent; + safeStoreUpdate(environment, "story:tag", (store) => { + // Find the story record by global ID + const storyRecord = store.get(tagEvent.storyId); + if (!storyRecord) return; + + // Get current tags + const currentTags = storyRecord.getLinkedRecords("tags") || []; + const currentTagIds = new Set(currentTags.map((t) => t.getDataID()).filter(Boolean)); + + // Create or get records for new tags and merge + const newTagRecords = tagEvent.tags + .filter((t) => !currentTagIds.has(t.id)) + .map((tag) => { + let tagRecord = store.get(tag.id); + if (!tagRecord) { + tagRecord = store.create(tag.id, "Tag"); + tagRecord.setValue(tag.id, "id"); + tagRecord.setValue(tag.name, "name"); + tagRecord.setValue(tag.color, "color"); + } + return tagRecord; + }); + + // Merge existing and new tags + storyRecord.setLinkedRecords([...currentTags, ...newTagRecords], "tags"); + }); + } + + // Handle story:untag event - remove tags from story + if (event.type === "story:untag") { + const untagEvent = event as StoryUntagEvent; + safeStoreUpdate(environment, "story:untag", (store) => { + const storyRecord = store.get(untagEvent.storyId); + if (!storyRecord) return; + + const currentTags = storyRecord.getLinkedRecords("tags") || []; + const tagIdsToRemove = new Set(untagEvent.tagIds); + + // Filter out removed tags + const remainingTags = currentTags.filter((t) => { + const tagId = t.getDataID(); + return tagId && !tagIdsToRemove.has(tagId); + }); + + storyRecord.setLinkedRecords(remainingTags, "tags"); + }); + } + }, + error: (error) => { + console.error("[Library Subscription] Error:", error); + }, + complete: () => {}, + }, + ); + + // Cleanup: mark as unmounted and unsubscribe + return () => { + mounted = false; + unsubscribe(); + }; + }, [connectionId, environment]); +} + const StoryFragment = graphql` fragment LibraryStoryFragment on Story @refetchable(queryName: "LibraryStoryRefetchQuery") { id @@ -628,9 +868,6 @@ function CreateStoryForm({ tagIds, connections: [connectionId], }, - updater: (store) => { - updateConnectionCount(store, connectionId, 1); - }, onCompleted: (response) => { if (response.createStory.story) { setUrl(""); @@ -987,9 +1224,6 @@ function StoryRow({ setError(null); commitDelete({ variables: {id: story.id, connections: [connectionId]}, - updater: (store) => { - updateConnectionCount(store, connectionId, -1); - }, onCompleted: (response) => { setDeleteDialogOpen(false); if (response.deleteStory.error) { @@ -1173,6 +1407,9 @@ function AuthenticatedLibrary() { const {activeTag, clearFilter} = useTagFilter(); const {tags: availableTags, addTag} = useAvailableTags(); + // Subscribe to library channel for real-time updates + useLibrarySubscription(connectionId); + // Find tag details for the active filter const activeTagDetails = activeTag ? (availableTags.find((t) => t.name === activeTag) ?? null) diff --git a/apps/kamp-us/src/relay/environment.ts b/apps/kamp-us/src/relay/environment.ts index e0e5b12..4ef44fb 100644 --- a/apps/kamp-us/src/relay/environment.ts +++ b/apps/kamp-us/src/relay/environment.ts @@ -1,41 +1,131 @@ +import {type Client, type CloseCode, createClient} from "graphql-ws"; import { - Environment, - Network, - RecordSource, - Store, - type FetchFunction, - type GraphQLResponse, + Environment, + type FetchFunction, + type GraphQLResponse, + Network, + Observable, + RecordSource, + Store, + type SubscribeFunction, } from "relay-runtime"; import {getStoredToken} from "../auth/AuthContext"; +import {getWebSocketUrl} from "../lib/websocket"; const fetchQuery: FetchFunction = async (operation, variables) => { - const token = getStoredToken(); - - const headers: Record = { - "Content-Type": "application/json", - }; - - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - const response = await fetch("/graphql", { - method: "POST", - headers, - body: JSON.stringify({ - query: operation.text, - variables, - }), - }); - - return (await response.json()) as GraphQLResponse; + const token = getStoredToken(); + + const headers: Record = { + "Content-Type": "application/json", + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch("/graphql", { + method: "POST", + headers, + body: JSON.stringify({ + query: operation.text, + variables, + }), + }); + + return (await response.json()) as GraphQLResponse; +}; + +/** + * Close codes that indicate auth/permission errors - don't retry these. + */ +const NON_RETRYABLE_CLOSE_CODES: CloseCode[] = [ + 4401, // Unauthorized + 4403, // Forbidden + 4400, // Bad Request +]; + +function createSubscriptionClient(): Client { + return createClient({ + url: getWebSocketUrl(), + // Pass token via connectionParams (encrypted in WSS) instead of URL + connectionParams: () => { + const token = getStoredToken(); + return token ? {token} : {}; + }, + retryAttempts: Infinity, + shouldRetry: (closeEvent) => { + // Don't retry on auth/permission errors + if (closeEvent && "code" in closeEvent) { + const code = (closeEvent as CloseEvent).code as CloseCode; + if (NON_RETRYABLE_CLOSE_CODES.includes(code)) { + console.warn(`[Subscription] Not retrying due to close code ${code}`); + return false; + } + } + return true; + }, + retryWait: (retryCount) => { + // Exponential backoff: 1s, 2s, 4s, 8s, max 30s + const delay = Math.min(1000 * 2 ** retryCount, 30000); + return new Promise((resolve) => setTimeout(resolve, delay)); + }, + on: { + connected: () => console.log("[Subscription] Connected"), + closed: () => console.log("[Subscription] Closed"), + error: (error) => console.error("[Subscription] Error:", error), + }, + }); +} + +let subscriptionClient: Client | null = null; + +/** + * Get the singleton subscription client. + * Creates one if it doesn't exist. + */ +export function getSubscriptionClient(): Client { + if (!subscriptionClient) { + subscriptionClient = createSubscriptionClient(); + } + return subscriptionClient; +} + +/** + * Reset the subscription client (call on logout) + */ +export function resetSubscriptionClient(): void { + if (subscriptionClient) { + subscriptionClient.dispose(); + subscriptionClient = null; + } +} + +const subscribe: SubscribeFunction = (operation, variables) => { + return Observable.create((sink) => { + const client = getSubscriptionClient(); + + const dispose = client.subscribe( + { + operationName: operation.name, + query: operation.text!, + variables, + }, + { + next: (value) => sink.next(value as GraphQLResponse), + error: sink.error, + complete: sink.complete, + }, + ); + + return dispose; + }); }; export function createRelayEnvironment() { - return new Environment({ - network: Network.create(fetchQuery), - store: new Store(new RecordSource()), - }); + return new Environment({ + network: Network.create(fetchQuery, subscribe), + store: new Store(new RecordSource()), + }); } export const environment = createRelayEnvironment(); diff --git a/apps/worker/src/features/library/Library.ts b/apps/worker/src/features/library/Library.ts index bf4e29d..88ecd9a 100644 --- a/apps/worker/src/features/library/Library.ts +++ b/apps/worker/src/features/library/Library.ts @@ -2,6 +2,7 @@ import {DurableObject} from "cloudflare:workers"; import {and, desc, eq, inArray, lt, sql} from "drizzle-orm"; import {drizzle} from "drizzle-orm/durable-sqlite"; import {migrate} from "drizzle-orm/durable-sqlite/migrator"; +import {encodeGlobalId, NodeType} from "../../graphql/relay"; import * as schema from "./drizzle/drizzle.schema"; import migrations from "./drizzle/migrations/migrations"; import {getNormalizedUrl} from "./getNormalizedUrl"; @@ -12,25 +13,122 @@ import { TagNameExistsError, validateTagName, } from "./schema"; +import type {LibraryEvent, StoryPayload, TagPayload} from "./subscription-types"; + +/** Maximum number of items per page for pagination */ +const MAX_PAGE_SIZE = 100; + +/** Maximum length for story fields to prevent oversized WebSocket payloads */ +const MAX_URL_LENGTH = 2000; +const MAX_TITLE_LENGTH = 500; +const MAX_DESCRIPTION_LENGTH = 2000; // keyed by user id export class Library extends DurableObject { db = drizzle(this.ctx.storage, {schema}); + private ownerId: string | undefined = undefined; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); this.ctx.blockConcurrencyWhile(async () => { await migrate(this.db, migrations); + this.ownerId = await this.ctx.storage.get("owner"); }); } async init(owner: string) { + // Idempotent - skip if already initialized + if (this.ownerId) return; + this.ownerId = owner; await this.ctx.storage.put("owner", owner); } + // --- Subscription helpers --- + + private getUserChannel() { + if (!this.ownerId) { + throw new Error("Library has no owner"); + } + const channelId = this.env.USER_CHANNEL.idFromName(this.ownerId); + return this.env.USER_CHANNEL.get(channelId); + } + + private async publishToLibrary(event: LibraryEvent): Promise { + try { + const userChannel = this.getUserChannel(); + await userChannel.publish("library", event); + } catch (error) { + // Log but don't throw - mutation succeeded, broadcast is best-effort + console.error("Failed to publish event:", error); + } + } + + private async publishLibraryChange(counts?: {stories?: number; tags?: number}): Promise { + // Use provided counts or query fresh counts + const totalStories = counts?.stories ?? (await this.getStoryCount()); + const totalTags = counts?.tags ?? (await this.getTagCount()); + + await this.publishToLibrary({ + type: "library:change", + totalStories, + totalTags, + }); + } + + private async getStoryCount(): Promise { + const result = await this.db.select({count: sql`count(*)`}).from(schema.story); + return result[0]?.count ?? 0; + } + + private async getTagCount(): Promise { + const result = await this.db.select({count: sql`count(*)`}).from(schema.tag); + return result[0]?.count ?? 0; + } + + private toStoryPayload(story: { + id: string; + url: string; + title: string; + description?: string | null; + createdAt: string; + }): StoryPayload { + return { + id: encodeGlobalId(NodeType.Story, story.id), + url: story.url, + title: story.title, + description: story.description ?? null, + createdAt: story.createdAt, + }; + } + + private toTagPayload(tag: { + id: string; + name: string; + color: string; + createdAt: Date; + }): TagPayload { + return { + id: encodeGlobalId(NodeType.Tag, tag.id), + name: tag.name, + color: tag.color, + createdAt: tag.createdAt.toISOString(), + }; + } + async createStory(options: {url: string; title: string; description?: string}) { const {url, title, description} = options; + // Validate field lengths to prevent oversized payloads + if (url.length > MAX_URL_LENGTH) { + throw new Error("URL too long"); + } + if (title.length > MAX_TITLE_LENGTH) { + throw new Error("Title too long"); + } + if (description && description.length > MAX_DESCRIPTION_LENGTH) { + throw new Error("Description too long"); + } + // Validate URL format try { new URL(url); @@ -38,21 +136,39 @@ export class Library extends DurableObject { throw new Error("Invalid URL format"); } - const [story] = await this.db - .insert(schema.story) - .values({url, normalizedUrl: getNormalizedUrl(url), title, description}) - .returning(); + // Use transaction to ensure insert and count are atomic + const result = await this.db.transaction(async (tx) => { + const [story] = await tx + .insert(schema.story) + .values({url, normalizedUrl: getNormalizedUrl(url), title, description}) + .returning(); - return { - ...story, - createdAt: story.createdAt.toISOString(), + // Get count within same transaction + const countResult = await tx.select({count: sql`count(*)`}).from(schema.story); + const totalStories = countResult[0]?.count ?? 0; + + return {story, totalStories}; + }); + + const storyResult = { + ...result.story, + createdAt: result.story.createdAt.toISOString(), }; + + // Publish events with accurate count + await this.publishToLibrary({ + type: "story:create", + story: this.toStoryPayload(storyResult), + }); + await this.publishLibraryChange({stories: result.totalStories}); + + return storyResult; } // Story CRUD methods async listStories(options?: {first?: number; after?: string}) { - const limit = options?.first ?? 20; + const limit = Math.min(options?.first ?? 20, MAX_PAGE_SIZE); // Build base query - order by ID (ULIDx IDs are time-sortable) let query = this.db.select().from(schema.story).orderBy(desc(schema.story.id)); @@ -100,7 +216,15 @@ export class Library extends DurableObject { return await this.getStory(id); } - return await this.db.transaction(async (tx) => { + // Validate field lengths + if (updates.title !== undefined && updates.title.length > MAX_TITLE_LENGTH) { + throw new Error("Title too long"); + } + if (updates.description && updates.description.length > MAX_DESCRIPTION_LENGTH) { + throw new Error("Description too long"); + } + + const storyResult = await this.db.transaction(async (tx) => { const existing = await tx.select().from(schema.story).where(eq(schema.story.id, id)).get(); if (!existing) return null; @@ -121,20 +245,45 @@ export class Library extends DurableObject { createdAt: story.createdAt.toISOString(), }; }); + + // Publish event if story was updated + if (storyResult) { + await this.publishToLibrary({ + type: "story:update", + story: this.toStoryPayload(storyResult), + }); + } + + return storyResult; } async deleteStory(id: string) { - return await this.db.transaction(async (tx) => { + const result = await this.db.transaction(async (tx) => { const existing = await tx.select().from(schema.story).where(eq(schema.story.id, id)).get(); - if (!existing) return false; + if (!existing) return {deleted: false, totalStories: 0}; // Delete tag associations first (cascade) await tx.delete(schema.storyTag).where(eq(schema.storyTag.storyId, id)); // Then delete story await tx.delete(schema.story).where(eq(schema.story.id, id)); - return true; + // Get count within same transaction + const countResult = await tx.select({count: sql`count(*)`}).from(schema.story); + const totalStories = countResult[0]?.count ?? 0; + + return {deleted: true, totalStories}; }); + + // Publish events if story was deleted + if (result.deleted) { + await this.publishToLibrary({ + type: "story:delete", + deletedStoryId: encodeGlobalId(NodeType.Story, id), + }); + await this.publishLibraryChange({stories: result.totalStories}); + } + + return result.deleted; } // Tag CRUD methods @@ -167,6 +316,13 @@ export class Library extends DurableObject { .values({name, color: color.toLowerCase()}) .returning(); + // Publish events + await this.publishToLibrary({ + type: "tag:create", + tag: this.toTagPayload(tag), + }); + await this.publishLibraryChange(); + return tag; } @@ -232,6 +388,12 @@ export class Library extends DurableObject { .where(eq(schema.tag.id, id)) .returning(); + // Publish event + await this.publishToLibrary({ + type: "tag:update", + tag: this.toTagPayload(tag), + }); + return tag; } @@ -244,6 +406,13 @@ export class Library extends DurableObject { // FK cascade will automatically delete storyTag associations await this.db.delete(schema.tag).where(eq(schema.tag.id, id)); + + // Publish events + await this.publishToLibrary({ + type: "tag:delete", + deletedTagId: encodeGlobalId(NodeType.Tag, id), + }); + await this.publishLibraryChange(); } // Story-Tag relationship methods @@ -264,15 +433,16 @@ export class Library extends DurableObject { return; } - // Verify all tags exist before creating associations + // Verify all tags exist before creating associations - fetch full tag data const existingTags = await this.db - .select({id: schema.tag.id}) + .select() .from(schema.tag) .where(inArray(schema.tag.id, tagIds)) .all(); const existingTagIds = new Set(existingTags.map((t) => t.id)); const validTagIds = tagIds.filter((id) => existingTagIds.has(id)); + const validTags = existingTags.filter((t) => validTagIds.includes(t.id)); // Batch insert with ON CONFLICT DO NOTHING for idempotency if (validTagIds.length > 0) { @@ -280,6 +450,13 @@ export class Library extends DurableObject { .insert(schema.storyTag) .values(validTagIds.map((tagId) => ({storyId, tagId}))) .onConflictDoNothing(); + + // Publish event with full tag data + await this.publishToLibrary({ + type: "story:tag", + storyId: encodeGlobalId(NodeType.Story, storyId), + tags: validTags.map((t) => this.toTagPayload(t)), + }); } } @@ -289,6 +466,13 @@ export class Library extends DurableObject { await this.db .delete(schema.storyTag) .where(and(eq(schema.storyTag.storyId, storyId), inArray(schema.storyTag.tagId, tagIds))); + + // Publish event + await this.publishToLibrary({ + type: "story:untag", + storyId: encodeGlobalId(NodeType.Story, storyId), + tagIds: tagIds.map((id) => encodeGlobalId(NodeType.Tag, id)), + }); } async getTagsForStory(storyId: string) { @@ -326,7 +510,7 @@ export class Library extends DurableObject { } async getStoriesByTagName(tagName: string, options?: {first?: number; after?: string}) { - const limit = options?.first ?? 20; + const limit = Math.min(options?.first ?? 20, MAX_PAGE_SIZE); // Find tag by name (case-insensitive) const tag = await this.db diff --git a/apps/worker/src/features/library/subscription-types.ts b/apps/worker/src/features/library/subscription-types.ts new file mode 100644 index 0000000..2b87f0a --- /dev/null +++ b/apps/worker/src/features/library/subscription-types.ts @@ -0,0 +1,30 @@ +/** + * Event types for the "library" channel. + * These are published by Library DO and received by subscribed clients. + */ + +export interface StoryPayload { + id: string; // Global ID + url: string; + title: string; + description: string | null; + createdAt: string; +} + +export interface TagPayload { + id: string; // Global ID + name: string; + color: string; + createdAt: string; +} + +export type LibraryEvent = + | {type: "story:create"; story: StoryPayload} + | {type: "story:update"; story: StoryPayload} + | {type: "story:delete"; deletedStoryId: string} + | {type: "tag:create"; tag: TagPayload} + | {type: "tag:update"; tag: TagPayload} + | {type: "tag:delete"; deletedTagId: string} + | {type: "story:tag"; storyId: string; tags: TagPayload[]} + | {type: "story:untag"; storyId: string; tagIds: string[]} + | {type: "library:change"; totalStories: number; totalTags: number}; diff --git a/apps/worker/src/features/pasaport/pasaport.ts b/apps/worker/src/features/pasaport/pasaport.ts index 77596aa..dbbe437 100644 --- a/apps/worker/src/features/pasaport/pasaport.ts +++ b/apps/worker/src/features/pasaport/pasaport.ts @@ -80,4 +80,21 @@ export class Pasaport extends DurableObject { return null; } } + + async validateBearerToken(token: string) { + try { + const headers = new Headers(); + headers.set("Authorization", `Bearer ${token}`); + const session = await this.auth.api.getSession({headers}); + + if (!session?.user) { + return null; + } + + return session; + } catch (error) { + console.error("Better Auth validateBearerToken failed:", error); + return null; + } + } } diff --git a/apps/worker/src/features/user-channel/UserChannel.ts b/apps/worker/src/features/user-channel/UserChannel.ts new file mode 100644 index 0000000..6591e9c --- /dev/null +++ b/apps/worker/src/features/user-channel/UserChannel.ts @@ -0,0 +1,389 @@ +import {DurableObject} from "cloudflare:workers"; +import type { + ChannelEvent, + ClientMessage, + CompleteMessage, + ConnectionState, + RateLimitState, + ReadyState, + SubscribeMessage, +} from "./types"; + +/** + * Allowed channel names for subscriptions. + * Add new channels here as features are added. + */ +const ALLOWED_CHANNELS = new Set(["library", "notifications"]); + +/** + * Rate limiting configuration. + */ +const RATE_LIMIT = { + /** Time window in milliseconds */ + WINDOW_MS: 60_000, + /** Maximum messages per window */ + MAX_MESSAGES: 100, +} as const; + +/** + * Maximum channel name length to prevent abuse. + */ +const MAX_CHANNEL_NAME_LENGTH = 64; + +/** + * UserChannel is a per-user Durable Object that manages WebSocket connections + * and channel subscriptions. It implements the graphql-ws protocol and provides + * a publish(channel, event) RPC method for other DOs to broadcast events. + * + * Key features: + * - Hibernatable WebSockets for cost efficiency + * - Channel-based pub/sub (e.g., "library", "notifications") + * - graphql-ws protocol support + */ +export class UserChannel extends DurableObject { + private ownerId: string | undefined = undefined; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.ctx.blockConcurrencyWhile(async () => { + this.ownerId = await this.ctx.storage.get("owner"); + }); + } + + /** + * Set the owner of this channel (called once when user is created). + * Idempotent - skips if already initialized. + */ + async setOwner(userId: string): Promise { + if (this.ownerId) return; + this.ownerId = userId; + await this.ctx.storage.put("owner", userId); + } + + /** + * Handle WebSocket upgrade requests. + */ + async fetch(request: Request): Promise { + const upgradeHeader = request.headers.get("Upgrade"); + + if (upgradeHeader?.toLowerCase() !== "websocket") { + return new Response("Expected WebSocket", {status: 426}); + } + + const protocol = request.headers.get("Sec-WebSocket-Protocol"); + if (protocol !== "graphql-transport-ws") { + return new Response("Unsupported WebSocket Protocol", {status: 400}); + } + + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + this.ctx.acceptWebSocket(server); + + const now = Date.now(); + server.serializeAttachment({ + state: "awaiting_init", + connectedAt: now, + rateLimit: {windowStart: now, messageCount: 0}, + } satisfies ConnectionState); + + return new Response(null, { + status: 101, + headers: { + "Sec-WebSocket-Protocol": "graphql-transport-ws", + }, + webSocket: client, + }); + } + + /** + * Handle incoming WebSocket messages (graphql-ws protocol). + */ + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + if (typeof message !== "string") { + this.closeWithError(ws, 4400, "Binary messages not supported"); + return; + } + + let parsed: ClientMessage; + try { + parsed = JSON.parse(message); + } catch { + this.closeWithError(ws, 4400, "Invalid JSON"); + return; + } + + const state = ws.deserializeAttachment() as ConnectionState; + + // Rate limiting check + const updatedRateLimit = this.checkRateLimit(state.rateLimit); + if (!updatedRateLimit) { + this.closeWithError(ws, 4429, "Rate limit exceeded"); + return; + } + + // Update rate limit state + ws.serializeAttachment({...state, rateLimit: updatedRateLimit}); + + switch (parsed.type) { + case "connection_init": + await this.handleConnectionInit(ws, state, parsed.payload); + break; + + case "subscribe": + await this.handleSubscribe(ws, parsed, state); + break; + + case "complete": + await this.handleComplete(ws, parsed, state); + break; + + case "ping": + ws.send(JSON.stringify({type: "pong"})); + break; + + default: + this.closeWithError(ws, 4400, "Unknown message type"); + } + } + + /** + * Handle WebSocket close. + */ + async webSocketClose( + ws: WebSocket, + code: number, + reason: string, + _wasClean: boolean, + ): Promise { + ws.close(code, reason); + } + + /** + * Handle WebSocket error. + */ + async webSocketError(_ws: WebSocket, error: unknown): Promise { + console.error("[UserChannel] WebSocket error:", error); + } + + /** + * Publish event to all subscribers of a channel. + * Called by other DOs (Library, Notifications, etc.) + * Best-effort delivery - errors are logged but don't fail the call. + */ + async publish(channel: string, event: ChannelEvent): Promise { + const webSockets = this.ctx.getWebSockets(); + + for (const ws of webSockets) { + try { + const state = ws.deserializeAttachment() as ConnectionState; + + if (state.state === "ready") { + const subscriptionId = state.subscriptions[channel]; + if (subscriptionId) { + ws.send( + JSON.stringify({ + id: subscriptionId, + type: "next", + payload: { + data: { + channel: event, + }, + }, + }), + ); + } + } + } catch (error) { + console.error("[UserChannel] Failed to send to WebSocket:", error); + } + } + } + + /** + * Get count of active WebSocket connections. + */ + async getConnectionCount(): Promise { + return this.ctx.getWebSockets().length; + } + + /** + * Get count of subscribers for a specific channel. + */ + async getSubscriberCount(channel: string): Promise { + const webSockets = this.ctx.getWebSockets(); + let count = 0; + + for (const ws of webSockets) { + const state = ws.deserializeAttachment() as ConnectionState; + if (state.state === "ready" && state.subscriptions[channel]) { + count++; + } + } + + return count; + } + + // --- Private methods --- + + private async handleConnectionInit( + ws: WebSocket, + state: ConnectionState, + payload?: Record, + ): Promise { + if (state.state !== "awaiting_init") { + this.closeWithError(ws, 4429, "Too many initialisation requests"); + return; + } + + // Check connection init timeout (10 seconds) + if (Date.now() - state.connectedAt > 10_000) { + this.closeWithError(ws, 4408, "Connection initialisation timeout"); + return; + } + + if (!this.ownerId) { + this.closeWithError(ws, 4403, "Forbidden"); + return; + } + + // Validate token from connectionParams + const token = payload?.token; + if (typeof token !== "string" || !token) { + this.closeWithError(ws, 4401, "Unauthorized: token required"); + return; + } + + // Validate the token and verify it matches this channel's owner + const pasaport = this.env.PASAPORT.getByName("kampus"); + const sessionData = await pasaport.validateBearerToken(token); + + if (!sessionData?.user?.id) { + this.closeWithError(ws, 4401, "Unauthorized: invalid token"); + return; + } + + if (sessionData.user.id !== this.ownerId) { + this.closeWithError(ws, 4403, "Forbidden: token does not match channel owner"); + return; + } + + ws.serializeAttachment({ + state: "ready", + userId: this.ownerId, + subscriptions: {}, + rateLimit: state.rateLimit, + } satisfies ReadyState); + + ws.send(JSON.stringify({type: "connection_ack"})); + } + + private async handleSubscribe( + ws: WebSocket, + message: SubscribeMessage, + state: ConnectionState, + ): Promise { + if (state.state !== "ready") { + this.closeWithError(ws, 4401, "Unauthorized"); + return; + } + + // Extract channel name from subscription query + // Expected format: subscription { channel(name: "library") { ... } } + const channelMatch = message.payload.query.match(/channel\s*\(\s*name\s*:\s*"([^"]+)"\s*\)/); + if (!channelMatch) { + ws.send( + JSON.stringify({ + id: message.id, + type: "error", + payload: [{message: 'Invalid subscription: must specify channel(name: "...")'}], + }), + ); + return; + } + + const channelName = channelMatch[1]; + + // Validate channel name length + if (channelName.length > MAX_CHANNEL_NAME_LENGTH) { + ws.send( + JSON.stringify({ + id: message.id, + type: "error", + payload: [{message: "Channel name too long"}], + }), + ); + return; + } + + // Validate against allowed channels + if (!ALLOWED_CHANNELS.has(channelName)) { + ws.send( + JSON.stringify({ + id: message.id, + type: "error", + payload: [{message: `Unknown channel: ${channelName}`}], + }), + ); + return; + } + + // Register subscription + const newSubscriptions = {...state.subscriptions, [channelName]: message.id}; + ws.serializeAttachment({ + ...state, + subscriptions: newSubscriptions, + } satisfies ReadyState); + } + + private async handleComplete( + ws: WebSocket, + message: CompleteMessage, + state: ConnectionState, + ): Promise { + if (state.state !== "ready") return; + + // Find and remove the subscription by ID + const newSubscriptions = {...state.subscriptions}; + for (const [channel, subId] of Object.entries(newSubscriptions)) { + if (subId === message.id) { + delete newSubscriptions[channel]; + break; + } + } + + ws.serializeAttachment({ + ...state, + subscriptions: newSubscriptions, + } satisfies ReadyState); + + ws.send(JSON.stringify({id: message.id, type: "complete"})); + } + + /** + * Check rate limit and return updated state, or null if limit exceeded. + */ + private checkRateLimit(rateLimit: RateLimitState): RateLimitState | null { + const now = Date.now(); + + // Reset window if expired + if (now - rateLimit.windowStart >= RATE_LIMIT.WINDOW_MS) { + return {windowStart: now, messageCount: 1}; + } + + // Check if limit exceeded + if (rateLimit.messageCount >= RATE_LIMIT.MAX_MESSAGES) { + return null; + } + + // Increment counter + return { + windowStart: rateLimit.windowStart, + messageCount: rateLimit.messageCount + 1, + }; + } + + private closeWithError(ws: WebSocket, code: number, reason: string): void { + ws.close(code, reason); + } +} diff --git a/apps/worker/src/features/user-channel/types.ts b/apps/worker/src/features/user-channel/types.ts new file mode 100644 index 0000000..2492182 --- /dev/null +++ b/apps/worker/src/features/user-channel/types.ts @@ -0,0 +1,73 @@ +/** + * Connection state for WebSocket attachments. + * Stored via serializeAttachment() and survives DO hibernation. + */ + +/** + * Rate limiting state for a connection. + * Uses a sliding window approach with message count. + */ +export interface RateLimitState { + /** Timestamp of the start of the current window */ + windowStart: number; + /** Number of messages in the current window */ + messageCount: number; +} + +export interface AwaitingInitState { + state: "awaiting_init"; + connectedAt: number; + rateLimit: RateLimitState; +} + +export interface ReadyState { + state: "ready"; + userId: string; + /** Map of channel name -> subscription ID (from graphql-ws Subscribe message) */ + subscriptions: Record; + rateLimit: RateLimitState; +} + +export type ConnectionState = AwaitingInitState | ReadyState; + +/** + * Generic event that can be published to any channel. + * Channel-specific event types (e.g., LibraryEvent) extend this. + */ +export interface ChannelEvent { + type: string; + [key: string]: unknown; +} + +/** + * graphql-ws protocol message types (client to server) + */ +export interface ConnectionInitMessage { + type: "connection_init"; + payload?: Record; +} + +export interface SubscribeMessage { + type: "subscribe"; + id: string; + payload: { + query: string; + operationName?: string; + variables?: Record; + }; +} + +export interface CompleteMessage { + type: "complete"; + id: string; +} + +export interface PingMessage { + type: "ping"; +} + +export type ClientMessage = + | ConnectionInitMessage + | SubscribeMessage + | CompleteMessage + | PingMessage; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index c9689ad..b153450 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -21,6 +21,7 @@ import {decodeGlobalId, encodeGlobalId, NodeType} from "./graphql/relay"; export {Library} from "./features/library/Library"; export {Pasaport} from "./features/pasaport/pasaport"; +export {UserChannel} from "./features/user-channel/UserChannel"; export {WebPageParser} from "./features/web-page-parser/WebPageParser"; const standard = Schema.standardSchemaV1; @@ -242,6 +243,17 @@ const UrlMetadata = Schema.Struct({ // --- Library Resolvers --- +/** + * Helper to get a user's Library DO and ensure it's initialized with the owner ID. + * This is needed so the Library can publish events to the user's UserChannel. + */ +async function getLibrary(ctx: GQLContext, userId: string) { + const libraryId = ctx.env.LIBRARY.idFromName(userId); + const lib = ctx.env.LIBRARY.get(libraryId); + await lib.init(userId); + return lib; +} + const libraryResolver = resolver.of(standard(Library), { stories: field(standard(StoryConnection)) .input({ @@ -259,8 +271,7 @@ const libraryResolver = resolver.of(standard(Library), { afterLocalId = decoded?.id; } - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); const result = await lib.listStories({ first: input.first ?? 20, after: afterLocalId, @@ -298,8 +309,7 @@ const libraryResolver = resolver.of(standard(Library), { afterLocalId = decoded?.id; } - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); const result = await lib.getStoriesByTagName(input.tagName, { first: input.first ?? 20, after: afterLocalId, @@ -324,8 +334,7 @@ const libraryResolver = resolver.of(standard(Library), { const ctx = useContext(); if (!ctx.pasaport.user?.id) throw new Error("Unauthorized"); - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); const tags = await lib.listTags(); return tags.map(toTagNode); @@ -353,8 +362,7 @@ const storyResolver = resolver.of(standard(Story), { const decoded = decodeGlobalId(story.id); if (!decoded) return []; - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); const tags = await lib.getTagsForStory(decoded.id); return tags.map(toTagNode); @@ -371,8 +379,7 @@ const storyResolver = resolver.of(standard(Story), { const ctx = useContext(); if (!ctx.pasaport.user?.id) throw new Error("Unauthorized"); - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); const story = await lib.createStory({url, title, description: description ?? undefined}); // Tag the story if tagIds provided @@ -413,8 +420,7 @@ const storyResolver = resolver.of(standard(Story), { }; } - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); // null means "don't change", empty string means "clear", other string means "set to value" const story = await lib.updateStory(decoded.id, { title: title ?? undefined, @@ -469,8 +475,7 @@ const storyResolver = resolver.of(standard(Story), { }; } - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); const deleted = await lib.deleteStory(decoded.id); if (!deleted) { @@ -526,8 +531,7 @@ const tagResolver = resolver.of(standard(Tag), { afterLocalId = decoded?.id; } - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); const result = await lib.getStoriesByTagName(tag.name, { first: input.first ?? 0, after: afterLocalId, @@ -560,8 +564,7 @@ const tagResolver = resolver.of(standard(Tag), { const ctx = useContext(); if (!ctx.pasaport.user?.id) throw new Error("Unauthorized"); - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); try { const tag = await lib.createTag(name, color); @@ -615,8 +618,7 @@ const tagResolver = resolver.of(standard(Tag), { }; } - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); try { const tag = await lib.updateTag(decoded.id, { @@ -684,8 +686,7 @@ const tagResolver = resolver.of(standard(Tag), { }; } - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); const existing = await lib.getTag(decoded.id); if (!existing) { @@ -859,8 +860,7 @@ const nodeResolver = resolver({ } // Get user's library - const libraryId = ctx.env.LIBRARY.idFromName(ctx.pasaport.user.id); - const lib = ctx.env.LIBRARY.get(libraryId); + const lib = await getLibrary(ctx, ctx.pasaport.user.id); // Route to appropriate fetcher based on type switch (decoded.type) { @@ -921,6 +921,42 @@ app.get("/graphql/schema", (c) => { return c.text(schemaText); }); +// WebSocket upgrade routing - must be before GraphQL Yoga +app.use("/graphql", async (c, next) => { + const upgradeHeader = c.req.header("Upgrade"); + if (upgradeHeader?.toLowerCase() === "websocket") { + const url = new URL(c.req.url); + + // Get userId from URL for routing (token is validated in DO via connectionParams) + // This is safe because the DO validates the token matches the expected user + let userId = url.searchParams.get("userId"); + + // Fallback: try cookie-based session for same-origin connections + if (!userId) { + const pasaport = c.env.PASAPORT.getByName("kampus"); + const forwardedHeaders = new Headers(c.req.raw.headers); + const sessionData = await pasaport.validateSession(forwardedHeaders); + userId = sessionData?.user?.id ?? null; + } + + if (!userId) { + return new Response("Unauthorized: userId required", {status: 401}); + } + + // Route to user's UserChannel DO + // The DO will validate the token from connectionParams + const channelId = c.env.USER_CHANNEL.idFromName(userId); + const userChannel = c.env.USER_CHANNEL.get(channelId); + + // Ensure owner is set + await userChannel.setOwner(userId); + + return userChannel.fetch(c.req.raw); + } + + return next(); +}); + app.use("/graphql", async (c) => { const forwardedHeaders = new Headers(c.req.raw.headers); forwardedHeaders.delete("Content-Type"); diff --git a/apps/worker/worker-configuration.d.ts b/apps/worker/worker-configuration.d.ts index 103479b..543a378 100644 --- a/apps/worker/worker-configuration.d.ts +++ b/apps/worker/worker-configuration.d.ts @@ -1,10 +1,10 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 375353e15bf54827057746ec0b8add79) +// Generated by Wrangler by running `wrangler types` (hash: d8c2f0ea5bdfce0a4433030d767633f1) // Runtime types generated with workerd@1.20251217.0 2025-12-18 nodejs_compat declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); - durableNamespaces: "Pasaport" | "WebPageParser" | "Library"; + durableNamespaces: "Pasaport" | "WebPageParser" | "Library" | "UserChannel"; } interface Env { ENVIRONMENT: "development"; @@ -12,6 +12,7 @@ declare namespace Cloudflare { PASAPORT: DurableObjectNamespace; WEB_PAGE_PARSER: DurableObjectNamespace; LIBRARY: DurableObjectNamespace; + USER_CHANNEL: DurableObjectNamespace; } } interface Env extends Cloudflare.Env {} diff --git a/apps/worker/wrangler.jsonc b/apps/worker/wrangler.jsonc index 807e504..2753d6e 100644 --- a/apps/worker/wrangler.jsonc +++ b/apps/worker/wrangler.jsonc @@ -24,6 +24,10 @@ { "name": "LIBRARY", "class_name": "Library" + }, + { + "name": "USER_CHANNEL", + "class_name": "UserChannel" } ] }, @@ -38,6 +42,10 @@ { "tag": "v1", "new_sqlite_classes": ["Pasaport", "WebPageParser", "Library"] + }, + { + "tag": "v2", + "new_classes": ["UserChannel"] } ], "vars": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f49da5..311a360 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ catalogs: graphql: specifier: ^16.12.0 version: 16.12.0 + graphql-ws: + specifier: ^5.16.0 + version: 5.16.2 graphql-yoga: specifier: ^5.18.0 version: 5.18.0 @@ -174,6 +177,9 @@ importers: '@radix-ui/colors': specifier: 'catalog:' version: 3.0.0 + graphql-ws: + specifier: 'catalog:' + version: 5.16.2(graphql@16.12.0) react: specifier: ^19.1.1 version: 19.2.3 @@ -2684,6 +2690,12 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} + graphql-ws@5.16.2: + resolution: {integrity: sha512-E1uccsZxt/96jH/OwmLPuXMACILs76pKF2i3W861LpKBCYtGIyPQGtWLuBLkND4ox1KHns70e83PS4te50nvPQ==} + engines: {node: '>=10'} + peerDependencies: + graphql: '>=0.11 <=16' + graphql-yoga@5.18.0: resolution: {integrity: sha512-xFt1DVXS1BZ3AvjnawAGc5OYieSe56WuQuyk3iEpBwJ3QDZJWQGLmU9z/L5NUZ+pUcyprsz/bOwkYIV96fXt/g==} engines: {node: '>=18.0.0'} @@ -5536,6 +5548,10 @@ snapshots: globals@16.5.0: {} + graphql-ws@5.16.2(graphql@16.12.0): + dependencies: + graphql: 16.12.0 + graphql-yoga@5.18.0(graphql@16.12.0): dependencies: '@envelop/core': 5.4.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9e5ed0d..87b66f4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,6 +30,7 @@ catalog: drizzle-orm: 0.45.1 effect: ^3.19.13 graphql: ^16.12.0 + graphql-ws: ^5.16.0 graphql-yoga: ^5.18.0 hono: ^4.11.1 normalize-url: ^8.1.0 diff --git a/specs/README.md b/specs/README.md index 6354b49..7db9d48 100644 --- a/specs/README.md +++ b/specs/README.md @@ -12,3 +12,4 @@ Feature directory with completion status. Each feature follows the [spec-driven - [x] **[frontend-tag-management](./frontend-tag-management/)** - Rename, recolor, delete tags - [ ] **[fetch-title-from-url](./fetch-title-from-url/)** - Auto-fetch title and description from URL when submitting stories - [x] **[relay-pagination](./relay-pagination/)** - Implement usePaginationFragment for Library "Load More" functionality +- [ ] **[library-realtime-subscriptions](./library-realtime-subscriptions/)** - Real-time Library updates via GraphQL subscriptions diff --git a/specs/library-realtime-subscriptions/blog-post.md b/specs/library-realtime-subscriptions/blog-post.md new file mode 100644 index 0000000..2d288be --- /dev/null +++ b/specs/library-realtime-subscriptions/blog-post.md @@ -0,0 +1,1006 @@ +# Building Real-Time Subscriptions with Cloudflare Durable Objects and GraphQL + +A deep dive into implementing WebSocket-based real-time updates using the actor model, hibernatable WebSockets, and Relay. + +--- + +## Table of Contents + +1. [Introduction](#introduction) +2. [The Problem](#the-problem) +3. [Architecture Overview](#architecture-overview) +4. [Understanding the Actor Model](#understanding-the-actor-model) +5. [The UserChannel Durable Object](#the-userchannel-durable-object) +6. [Implementing the graphql-ws Protocol](#implementing-the-graphql-ws-protocol) +7. [Hibernatable WebSockets](#hibernatable-websockets) +8. [The Pub/Sub Pattern](#the-pubsub-pattern) +9. [Frontend Integration with Relay](#frontend-integration-with-relay) +10. [Security Considerations](#security-considerations) +11. [Performance Optimizations](#performance-optimizations) +12. [Lessons Learned](#lessons-learned) +13. [Conclusion](#conclusion) + +--- + +## Introduction + +Real-time features are table stakes for modern web applications. Users expect to see updates instantly—whether it's a new message, a live collaboration edit, or a notification. This blog post documents how we implemented real-time subscriptions for the Library feature in our application using Cloudflare Workers, Durable Objects, and GraphQL subscriptions. + +What makes this implementation interesting is the constraint: we're running on Cloudflare's edge infrastructure, which is stateless by default. Traditional WebSocket servers maintain long-lived connections in memory, but edge workers are ephemeral. We'll explore how Durable Objects solve this problem elegantly using the actor model. + +--- + +## The Problem + +Our Library feature allows users to save and organize stories (URLs with metadata). The initial implementation was request-response based: + +``` +User A creates story → API returns success → User A sees new story +``` + +But what about User A's other browser tabs? Or User B viewing the same library? They wouldn't see the update until they refreshed the page. + +**Requirements:** +1. When a story is created, all connected clients should see it immediately +2. When a story is deleted, it should disappear from all clients +3. The `totalCount` should update in real-time across all clients +4. The solution must work on Cloudflare's edge infrastructure + +--- + +## Architecture Overview + +Here's the high-level architecture we arrived at: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Browser Tabs │ +├─────────────┬─────────────┬─────────────┬─────────────┬────────┤ +│ Tab A │ Tab B │ Tab C │ Tab D │ ... │ +│ (User 1) │ (User 1) │ (User 2) │ (User 2) │ │ +└──────┬──────┴──────┬──────┴──────┬──────┴──────┬──────┴────────┘ + │ │ │ │ + │ WebSocket │ WebSocket │ WebSocket │ WebSocket + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Cloudflare Worker │ +│ (WebSocket Upgrade) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ┌────────────────┴────────────────┐ + │ Route to user's UserChannel DO │ + └────────────────┬────────────────┘ + │ + ┌───────────────────┴───────────────────┐ + ▼ ▼ +┌──────────────────┐ ┌──────────────────┐ +│ UserChannel DO │ │ UserChannel DO │ +│ (User 1) │ │ (User 2) │ +│ │ │ │ +│ ┌────────────┐ │ │ ┌────────────┐ │ +│ │ WebSocket │ │ │ │ WebSocket │ │ +│ │ (Tab A) │ │ │ │ (Tab C) │ │ +│ └────────────┘ │ │ └────────────┘ │ +│ ┌────────────┐ │ │ ┌────────────┐ │ +│ │ WebSocket │ │ │ │ WebSocket │ │ +│ │ (Tab B) │ │ │ │ (Tab D) │ │ +│ └────────────┘ │ │ └────────────┘ │ +└────────▲─────────┘ └────────▲─────────┘ + │ │ + │ publish(channel, event) │ + │ │ +┌────────┴─────────┐ ┌────────┴─────────┐ +│ Library DO │ │ Library DO │ +│ (User 1) │ │ (User 2) │ +│ │ │ │ +│ ┌────────────┐ │ │ ┌────────────┐ │ +│ │ SQLite │ │ │ │ SQLite │ │ +│ │ Stories │ │ │ │ Stories │ │ +│ └────────────┘ │ │ └────────────┘ │ +└──────────────────┘ └──────────────────┘ +``` + +**Key insight:** Each user gets their own `UserChannel` Durable Object that manages all their WebSocket connections. When the `Library` DO modifies data, it calls the `UserChannel` to broadcast events. + +--- + +## Understanding the Actor Model + +Before diving into implementation, let's understand the actor model—the foundation of Durable Objects. + +### What is an Actor? + +An actor is a computational entity that: +1. **Has private state** - No other actor can directly access it +2. **Processes messages sequentially** - One message at a time, no concurrency within an actor +3. **Communicates via message passing** - Actors don't share memory; they send messages + +### Durable Objects as Actors + +Cloudflare Durable Objects implement the actor model: + +```typescript +export class UserChannel extends DurableObject { + // Private state - only this instance can access + private ownerId: string | undefined = undefined; + + // Messages are processed sequentially + async webSocketMessage(ws: WebSocket, message: string) { + // Only one message processed at a time + // No locks needed! + } + + // Communication via RPC (message passing) + async publish(channel: string, event: ChannelEvent) { + // Called by other DOs + } +} +``` + +### Why Actors for WebSockets? + +The actor model is perfect for WebSocket management because: + +1. **No race conditions** - Sequential message processing means no data races +2. **Isolation** - Each user's connections are isolated in their own actor +3. **Location transparency** - Cloudflare routes requests to the right instance automatically +4. **Fault isolation** - One user's crashed connection doesn't affect others + +### Routing to Actors + +Durable Objects use `idFromName()` for deterministic routing: + +```typescript +// Same userId always routes to same DO instance +const channelId = env.USER_CHANNEL.idFromName(userId); +const channel = env.USER_CHANNEL.get(channelId); +``` + +This is crucial—it ensures all of a user's WebSocket connections go to the same actor instance. + +--- + +## The UserChannel Durable Object + +The `UserChannel` DO is the heart of our real-time system. Let's examine its structure: + +### State Management + +```typescript +export class UserChannel extends DurableObject { + private ownerId: string | undefined = undefined; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + // Load persistent state on construction + this.ctx.blockConcurrencyWhile(async () => { + this.ownerId = await this.ctx.storage.get("owner"); + }); + } + + async setOwner(userId: string): Promise { + // Idempotent - prevents race conditions + if (this.ownerId) return; + this.ownerId = userId; + await this.ctx.storage.put("owner", userId); + } +} +``` + +**Key patterns:** + +1. **`blockConcurrencyWhile()`** - Ensures initialization completes before any requests +2. **Idempotent setters** - `setOwner()` checks if already set, preventing race conditions +3. **Persistent storage** - Owner ID survives DO hibernation/eviction + +### Connection State + +Each WebSocket connection has attached state that survives hibernation: + +```typescript +interface AwaitingInitState { + state: "awaiting_init"; + connectedAt: number; + rateLimit: RateLimitState; +} + +interface ReadyState { + state: "ready"; + userId: string; + subscriptions: Record; // channel -> subscriptionId + rateLimit: RateLimitState; +} + +type ConnectionState = AwaitingInitState | ReadyState; +``` + +State is attached to WebSockets using serialization: + +```typescript +// Attach state +ws.serializeAttachment({ + state: "awaiting_init", + connectedAt: Date.now(), + rateLimit: { windowStart: Date.now(), messageCount: 0 }, +}); + +// Read state +const state = ws.deserializeAttachment() as ConnectionState; +``` + +--- + +## Implementing the graphql-ws Protocol + +We implement the [graphql-ws](https://github.com/enisdenjo/graphql-ws) protocol, the modern standard for GraphQL over WebSocket. + +### Protocol Flow + +``` +Client Server + │ │ + │──── connection_init ───────────────────▶│ + │ │ + │◀─── connection_ack ────────────────────│ + │ │ + │──── subscribe { id, query } ───────────▶│ + │ │ + │◀─── next { id, payload } ──────────────│ + │◀─── next { id, payload } ──────────────│ + │ │ + │──── complete { id } ───────────────────▶│ + │ │ + │◀─── complete { id } ───────────────────│ +``` + +### Message Handler + +```typescript +async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { + // Only accept string messages + if (typeof message !== "string") { + this.closeWithError(ws, 4400, "Binary messages not supported"); + return; + } + + // Parse JSON + let parsed: ClientMessage; + try { + parsed = JSON.parse(message); + } catch { + this.closeWithError(ws, 4400, "Invalid JSON"); + return; + } + + // Rate limiting + const state = ws.deserializeAttachment() as ConnectionState; + const updatedRateLimit = this.checkRateLimit(state.rateLimit); + if (!updatedRateLimit) { + this.closeWithError(ws, 4429, "Rate limit exceeded"); + return; + } + ws.serializeAttachment({ ...state, rateLimit: updatedRateLimit }); + + // Route by message type + switch (parsed.type) { + case "connection_init": + await this.handleConnectionInit(ws, state); + break; + case "subscribe": + await this.handleSubscribe(ws, parsed, state); + break; + case "complete": + await this.handleComplete(ws, parsed, state); + break; + case "ping": + ws.send(JSON.stringify({ type: "pong" })); + break; + default: + this.closeWithError(ws, 4400, "Unknown message type"); + } +} +``` + +### Connection Initialization + +The `connection_init` message establishes the connection: + +```typescript +private async handleConnectionInit(ws: WebSocket, state: ConnectionState) { + // Must be in awaiting_init state + if (state.state !== "awaiting_init") { + this.closeWithError(ws, 4429, "Too many initialisation requests"); + return; + } + + // Check timeout (10 seconds to init) + if (Date.now() - state.connectedAt > 10_000) { + this.closeWithError(ws, 4408, "Connection initialisation timeout"); + return; + } + + // Verify owner is set (authentication happened during upgrade) + if (!this.ownerId) { + this.closeWithError(ws, 4403, "Forbidden"); + return; + } + + // Transition to ready state + ws.serializeAttachment({ + state: "ready", + userId: this.ownerId, + subscriptions: {}, + rateLimit: state.rateLimit, + }); + + ws.send(JSON.stringify({ type: "connection_ack" })); +} +``` + +### Subscription Handling + +Subscriptions register interest in a channel: + +```typescript +private async handleSubscribe( + ws: WebSocket, + message: SubscribeMessage, + state: ConnectionState +) { + if (state.state !== "ready") { + this.closeWithError(ws, 4401, "Unauthorized"); + return; + } + + // Parse channel from GraphQL query + // Expected: subscription { channel(name: "library") { ... } } + const channelMatch = message.payload.query.match( + /channel\s*\(\s*name\s*:\s*"([^"]+)"\s*\)/ + ); + if (!channelMatch) { + ws.send(JSON.stringify({ + id: message.id, + type: "error", + payload: [{ message: 'Invalid subscription format' }], + })); + return; + } + + const channelName = channelMatch[1]; + + // Validate channel name + if (channelName.length > 64) { + ws.send(JSON.stringify({ + id: message.id, + type: "error", + payload: [{ message: "Channel name too long" }], + })); + return; + } + + // Whitelist check + if (!ALLOWED_CHANNELS.has(channelName)) { + ws.send(JSON.stringify({ + id: message.id, + type: "error", + payload: [{ message: `Unknown channel: ${channelName}` }], + })); + return; + } + + // Register subscription + const newSubscriptions = { ...state.subscriptions, [channelName]: message.id }; + ws.serializeAttachment({ + ...state, + subscriptions: newSubscriptions, + }); +} +``` + +--- + +## Hibernatable WebSockets + +Cloudflare's Hibernatable WebSocket API is crucial for cost efficiency. + +### The Problem with Traditional WebSockets + +Traditional WebSocket servers keep connections alive in memory: + +``` +Connection opened → Server allocates memory → Server holds memory forever + (even if idle for hours) +``` + +This is expensive at scale. If you have 10,000 connected users but only 100 are active at any moment, you're paying for 10,000 connections worth of compute. + +### Hibernation to the Rescue + +Hibernatable WebSockets allow the DO to "sleep" while maintaining connections: + +```typescript +// Accept with hibernation support +async fetch(request: Request) { + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + // Accept with hibernation - key difference! + this.ctx.acceptWebSocket(server); + + // State survives hibernation via serialization + server.serializeAttachment({ + state: "awaiting_init", + connectedAt: Date.now(), + rateLimit: { windowStart: Date.now(), messageCount: 0 }, + }); + + return new Response(null, { + status: 101, + webSocket: client, + }); +} +``` + +When the DO hibernates: +1. **Memory is freed** - Cloudflare reclaims the DO's memory +2. **Connections remain open** - WebSocket connections stay alive +3. **State is preserved** - Serialized attachment survives hibernation +4. **Wake on message** - When a message arrives, the DO wakes up + +### Handler Methods + +Instead of event listeners, use handler methods: + +```typescript +// Called when message arrives (wakes DO if hibernating) +async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { + // Handle message +} + +// Called when connection closes +async webSocketClose(ws: WebSocket, code: number, reason: string) { + // Cleanup if needed +} + +// Called on WebSocket error +async webSocketError(ws: WebSocket, error: unknown) { + console.error("[UserChannel] WebSocket error:", error); +} +``` + +### Retrieving All Connections + +The DO can get all connected WebSockets: + +```typescript +async publish(channel: string, event: ChannelEvent) { + // Get all WebSockets connected to this DO + const webSockets = this.ctx.getWebSockets(); + + for (const ws of webSockets) { + const state = ws.deserializeAttachment() as ConnectionState; + if (state.state === "ready") { + const subscriptionId = state.subscriptions[channel]; + if (subscriptionId) { + ws.send(JSON.stringify({ + id: subscriptionId, + type: "next", + payload: { data: { channel: event } }, + })); + } + } + } +} +``` + +--- + +## The Pub/Sub Pattern + +Our implementation follows a publish-subscribe pattern with clear separation of concerns. + +### Publisher: Library DO + +The Library DO publishes events when data changes: + +```typescript +export class Library extends DurableObject { + async createStory(options: { url: string; title: string; description?: string }) { + // 1. Validate input + if (options.title.length > MAX_TITLE_LENGTH) { + throw new Error("Title too long"); + } + + // 2. Insert with atomic count + const result = await this.db.transaction(async (tx) => { + const [story] = await tx + .insert(schema.story) + .values({ url, title, description }) + .returning(); + + const countResult = await tx + .select({ count: sql`count(*)` }) + .from(schema.story); + + return { story, totalStories: countResult[0]?.count ?? 0 }; + }); + + // 3. Publish events (best-effort) + await this.publishToLibrary({ + type: "story:create", + story: this.toStoryPayload(result.story), + }); + await this.publishLibraryChange({ stories: result.totalStories }); + + return result.story; + } + + private async publishToLibrary(event: LibraryEvent) { + try { + const userChannel = this.getUserChannel(); + await userChannel.publish("library", event); + } catch (error) { + // Log but don't throw - mutation succeeded, broadcast is best-effort + console.error("Failed to publish event:", error); + } + } +} +``` + +**Key patterns:** + +1. **Transaction for accuracy** - Insert and count in same transaction ensures correct `totalCount` +2. **Best-effort delivery** - Publish errors don't fail the mutation +3. **Typed events** - Each event has a discriminated type field + +### Event Types + +Events are strongly typed: + +```typescript +interface StoryCreateEvent { + type: "story:create"; + story: StoryPayload; +} + +interface StoryDeleteEvent { + type: "story:delete"; + deletedStoryId: string; // Global ID +} + +interface LibraryChangeEvent { + type: "library:change"; + totalStories: number; + totalTags: number; +} + +type LibraryEvent = StoryCreateEvent | StoryDeleteEvent | LibraryChangeEvent; +``` + +### Subscriber: UserChannel DO + +The UserChannel receives and routes events: + +```typescript +async publish(channel: string, event: ChannelEvent) { + const webSockets = this.ctx.getWebSockets(); + + for (const ws of webSockets) { + try { + const state = ws.deserializeAttachment() as ConnectionState; + + if (state.state === "ready") { + const subscriptionId = state.subscriptions[channel]; + if (subscriptionId) { + ws.send(JSON.stringify({ + id: subscriptionId, + type: "next", + payload: { + data: { channel: event }, + }, + })); + } + } + } catch (error) { + console.error("[UserChannel] Failed to send:", error); + } + } +} +``` + +--- + +## Frontend Integration with Relay + +The frontend uses Relay for state management and the graphql-ws client for subscriptions. + +### Singleton Client + +We use a singleton WebSocket client to avoid multiple connections: + +```typescript +// environment.ts +let subscriptionClient: Client | null = null; + +export function getSubscriptionClient(): Client { + if (!subscriptionClient) { + subscriptionClient = createClient({ + url: getWebSocketUrl(), + retryAttempts: Infinity, + shouldRetry: () => true, + retryWait: (retryCount) => { + // Exponential backoff: 1s, 2s, 4s, 8s, max 30s + const delay = Math.min(1000 * 2 ** retryCount, 30000); + return new Promise((resolve) => setTimeout(resolve, delay)); + }, + }); + } + return subscriptionClient; +} + +export function resetSubscriptionClient() { + if (subscriptionClient) { + subscriptionClient.dispose(); + subscriptionClient = null; + } +} +``` + +### Subscription Hook + +The subscription hook listens for events and updates Relay's store: + +```typescript +function useLibrarySubscription(connectionId: string | null) { + const environment = useRelayEnvironment(); + + useEffect(() => { + if (!connectionId) return; + + const client = getSubscriptionClient(); + + const unsubscribe = client.subscribe( + { + query: 'subscription { channel(name: "library") { type } }', + }, + { + next: (result) => { + const event = result.data?.channel; + if (!event) return; + + // Handle different event types + if (event.type === "library:change") { + handleLibraryChange(environment, connectionId, event); + } + if (event.type === "story:create") { + handleStoryCreate(environment, connectionId, event); + } + if (event.type === "story:delete") { + handleStoryDelete(environment, connectionId, event); + } + }, + error: (error) => { + console.error("[Library Subscription] Error:", error); + }, + } + ); + + return () => unsubscribe(); + }, [connectionId, environment]); +} +``` + +### Updating the Relay Store + +Relay's store is updated imperatively for subscription events: + +```typescript +function handleStoryCreate( + environment: Environment, + connectionId: string, + event: StoryCreateEvent +) { + const globalId = event.story.id; + + environment.commitUpdate((store) => { + const connection = store.get(connectionId); + if (!connection) return; + + // Check for duplicates (own mutation may have already added it) + const edges = connection.getLinkedRecords("edges") || []; + const exists = edges.some((edge) => { + const node = edge?.getLinkedRecord("node"); + if (!node) return false; + return node.getDataID() === globalId || node.getValue("id") === globalId; + }); + if (exists) return; + + // Create story record + const storyRecord = store.create(globalId, "Story"); + storyRecord.setValue(globalId, "id"); + storyRecord.setValue(event.story.url, "url"); + storyRecord.setValue(event.story.title, "title"); + storyRecord.setValue(event.story.description, "description"); + storyRecord.setValue(event.story.createdAt, "createdAt"); + storyRecord.setLinkedRecords([], "tags"); + + // Create edge and prepend + const edgeId = `client:edge:${globalId}`; + const edge = store.create(edgeId, "StoryEdge"); + edge.setLinkedRecord(storyRecord, "node"); + edge.setValue(globalId, "cursor"); + + connection.setLinkedRecords([edge, ...edges], "edges"); + }); +} +``` + +### Duplicate Prevention + +When a user creates a story, both the mutation response and the subscription event will try to add it. We handle this by checking for duplicates: + +```typescript +// Check both getDataID() and id field for robustness +const exists = edges.some((edge) => { + const node = edge?.getLinkedRecord("node"); + if (!node) return false; + const nodeId = node.getValue("id"); + return node.getDataID() === globalId || nodeId === globalId; +}); +if (exists) return; // Skip if already exists +``` + +--- + +## Security Considerations + +Real-time systems introduce unique security challenges. + +### Authentication + +WebSocket connections authenticate via URL token (cookies don't work cross-origin): + +```typescript +// Frontend: Add token to URL +function getWebSocketUrl(): string { + const token = getStoredToken(); + const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ""; + + if (import.meta.env.DEV) { + return `ws://localhost:8787/graphql${tokenParam}`; + } + return `wss://${window.location.host}/graphql${tokenParam}`; +} + +// Backend: Validate token on upgrade +app.use("/graphql", async (c, next) => { + if (isWebSocketUpgrade(c.req)) { + const token = new URL(c.req.url).searchParams.get("token"); + if (token) { + const session = await pasaport.validateBearerToken(token); + if (session) { + userId = session.user.id; + } + } + // Route to UserChannel... + } +}); +``` + +**Security note:** Tokens in URLs can be logged. Mitigations: +- Use short-lived tokens +- Always use WSS (TLS) in production +- Tokens are single-purpose (can't be used for API calls) + +### Rate Limiting + +Per-connection rate limiting prevents abuse: + +```typescript +const RATE_LIMIT = { + WINDOW_MS: 60_000, // 1 minute window + MAX_MESSAGES: 100, // Max messages per window +}; + +private checkRateLimit(rateLimit: RateLimitState): RateLimitState | null { + const now = Date.now(); + + // Reset window if expired + if (now - rateLimit.windowStart >= RATE_LIMIT.WINDOW_MS) { + return { windowStart: now, messageCount: 1 }; + } + + // Check limit + if (rateLimit.messageCount >= RATE_LIMIT.MAX_MESSAGES) { + return null; // Limit exceeded + } + + return { + windowStart: rateLimit.windowStart, + messageCount: rateLimit.messageCount + 1, + }; +} +``` + +### Channel Whitelisting + +Only allowed channels can be subscribed to: + +```typescript +const ALLOWED_CHANNELS = new Set(["library", "notifications"]); + +// In handleSubscribe: +if (!ALLOWED_CHANNELS.has(channelName)) { + ws.send(JSON.stringify({ + id: message.id, + type: "error", + payload: [{ message: `Unknown channel: ${channelName}` }], + })); + return; +} +``` + +### Input Validation + +Payload sizes are limited to prevent abuse: + +```typescript +const MAX_URL_LENGTH = 2000; +const MAX_TITLE_LENGTH = 500; +const MAX_DESCRIPTION_LENGTH = 2000; + +async createStory(options: { url: string; title: string; description?: string }) { + if (options.url.length > MAX_URL_LENGTH) { + throw new Error("URL too long"); + } + if (options.title.length > MAX_TITLE_LENGTH) { + throw new Error("Title too long"); + } + if (options.description && options.description.length > MAX_DESCRIPTION_LENGTH) { + throw new Error("Description too long"); + } + // ... +} +``` + +### Logout Cleanup + +WebSocket connections are closed on logout: + +```typescript +const logout = useCallback(() => { + setAuthState({ user: null, token: null }); + clearAuthState(); + resetSubscriptionClient(); // Close WebSocket +}, []); +``` + +--- + +## Performance Optimizations + +### Atomic Transactions + +Using transactions ensures accurate counts without race conditions: + +```typescript +const result = await this.db.transaction(async (tx) => { + const [story] = await tx.insert(schema.story).values(data).returning(); + + // Count is accurate because it's in the same transaction + const countResult = await tx + .select({ count: sql`count(*)` }) + .from(schema.story); + + return { story, totalStories: countResult[0]?.count ?? 0 }; +}); +``` + +### Pagination Limits + +Unbounded queries are prevented: + +```typescript +const MAX_PAGE_SIZE = 100; + +async listStories(options?: { first?: number; after?: string }) { + const limit = Math.min(options?.first ?? 20, MAX_PAGE_SIZE); + // ... +} +``` + +### Idempotent Operations + +Initialization methods are idempotent to prevent unnecessary writes: + +```typescript +async init(owner: string) { + if (this.ownerId) return; // Already initialized + this.ownerId = owner; + await this.ctx.storage.put("owner", owner); +} + +async setOwner(userId: string) { + if (this.ownerId) return; // Already set + this.ownerId = userId; + await this.ctx.storage.put("owner", userId); +} +``` + +### Best-Effort Delivery + +Subscription broadcasts don't block mutations: + +```typescript +private async publishToLibrary(event: LibraryEvent) { + try { + const userChannel = this.getUserChannel(); + await userChannel.publish("library", event); + } catch (error) { + // Log but don't throw - mutation already succeeded + console.error("Failed to publish event:", error); + } +} +``` + +--- + +## Lessons Learned + +### 1. Actor Model Simplifies Concurrency + +The actor model eliminated entire classes of bugs. No locks, no race conditions in message handlers, no shared state between users. + +### 2. Hibernation is Essential for Scale + +Without hibernatable WebSockets, we'd pay for idle connections. Hibernation makes real-time features economically viable at scale. + +### 3. Idempotency Everywhere + +Every initialization and setup method should be idempotent. Multiple WebSocket connections, retries, and race conditions are common in distributed systems. + +### 4. Best-Effort is Often Good Enough + +Real-time updates don't need guaranteed delivery for most use cases. If a subscription event is missed, the data is still consistent—the user just needs to refresh. + +### 5. Security at Every Layer + +- Authentication on WebSocket upgrade +- Rate limiting per connection +- Channel whitelisting +- Input validation +- Cleanup on logout + +### 6. Global IDs for Deduplication + +Using global IDs (base64-encoded type + local ID) makes deduplication reliable across different sources (mutations, subscriptions, cache). + +### 7. Singleton Clients Prevent Connection Bloat + +Creating one WebSocket client per component leads to connection explosion. A singleton with proper cleanup is essential. + +--- + +## Conclusion + +Building real-time subscriptions on Cloudflare's edge infrastructure required rethinking traditional WebSocket patterns. The actor model, implemented via Durable Objects, provided a clean abstraction for managing per-user state. Hibernatable WebSockets made the solution cost-effective at scale. + +Key takeaways: +- **Use Durable Objects as actors** - One DO per user for WebSocket management +- **Embrace hibernation** - Design for wake-on-message patterns +- **Separate concerns** - Data DOs (Library) publish events, connection DOs (UserChannel) manage delivery +- **Be defensive** - Idempotency, rate limiting, input validation +- **Best-effort is fine** - Don't let subscription failures block mutations + +The resulting architecture is simple, scalable, and maintainable. It handles the real-time requirements without sacrificing the benefits of edge deployment. + +--- + +## Further Reading + +- [Cloudflare Durable Objects Documentation](https://developers.cloudflare.com/durable-objects/) +- [Hibernatable WebSockets](https://developers.cloudflare.com/durable-objects/api/websockets/) +- [graphql-ws Protocol](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md) +- [Relay Store Updates](https://relay.dev/docs/guided-tour/updating-data/graphql-subscriptions/) +- [Actor Model Explained](https://en.wikipedia.org/wiki/Actor_model) diff --git a/specs/library-realtime-subscriptions/design.md b/specs/library-realtime-subscriptions/design.md new file mode 100644 index 0000000..957e8bb --- /dev/null +++ b/specs/library-realtime-subscriptions/design.md @@ -0,0 +1,1206 @@ +# Library Realtime Subscriptions - Technical Design + +## 1. Architecture Overview + +### Key Architectural Decision: UserChannel DO + +Instead of adding WebSocket handling directly to Library DO, we introduce a dedicated **UserChannel DO** that: + +- Is user-scoped (one per user, keyed by userId) +- Handles all WebSocket connections for that user +- Implements the graphql-ws protocol +- Manages channel subscriptions (e.g., "library", "notifications") +- Exposes a `publish(channel, event)` RPC method for other DOs + +This design enables reuse across future features (notifications, presence, collaborative editing) without duplicating WebSocket infrastructure. + +### High-Level Component Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Browser │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ React App │ │ +│ │ ┌─────────────────┐ ┌─────────────────────────────────────┐ │ │ +│ │ │ Relay Store │◄─┤ graphql-ws Client │ │ │ +│ │ │ - Stories │ │ - ConnectionInit (with auth token) │ │ │ +│ │ │ - Tags │ │ - Subscribe to "library" channel │ │ │ +│ │ │ - totalCount │ │ - Reconnect with exp. backoff │ │ │ +│ │ └─────────────────┘ └───────────────────┬─────────────────┘ │ │ +│ └───────────────────────────────────────────┼─────────────────────┘ │ +└──────────────────────────────────────────────┼──────────────────────────┘ + │ WebSocket + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Cloudflare Edge │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Worker (apps/worker/src/index.ts) │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ Hono Router │ │ │ +│ │ │ /graphql │ │ │ +│ │ │ ├─ POST/GET → GraphQL Yoga (queries/mutations) │ │ │ +│ │ │ └─ WebSocket Upgrade → Route to UserChannel DO │ │ │ +│ │ └────────────────────────────────────┬────────────────────┘ │ │ +│ └───────────────────────────────────────┼────────────────────────┘ │ +│ │ Route by user ID │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ UserChannel DO (per-user) │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ WebSocket Manager │ │ │ +│ │ │ - ctx.acceptWebSocket() - webSocketMessage() │ │ │ +│ │ │ - ctx.getWebSockets() - webSocketClose() │ │ │ +│ │ │ - serializeAttachment() - graphql-ws protocol │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ Channel Subscriptions (in-memory, from attachments) │ │ │ +│ │ │ - "library" → [ws1, ws2, ws3] │ │ │ +│ │ │ - "notifications" → [ws1, ws4] (future) │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ RPC Methods: │ │ +│ │ └─ publish(channel, event) ← Called by other DOs │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ ▲ │ +│ │ userChannel.publish("library", event) │ +│ │ │ +│ ┌──────────────────────┴──────────────────────────────────────────┐ │ +│ │ Library DO (per-user) │ │ +│ │ ┌───────────────────┐ │ │ +│ │ │ SQLite (Drizzle) │ RPC Methods: │ │ +│ │ │ - story table │ ├─ createStory() → publish story:create │ │ +│ │ │ - tag table │ ├─ updateStory() → publish story:update │ │ +│ │ │ - story_tag │ ├─ deleteStory() → publish story:delete │ │ +│ │ └───────────────────┘ └─ ... │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Data Flow: Subscription Establishment + +``` +1. Browser initiates WebSocket connection + ┌────────────┐ ┌────────────┐ ┌──────────────┐ + │ Browser │──WebSocket Upgrade──►│ Worker │───Forward────►│ UserChannel │ + │ │ Sec-WebSocket- │ │ to DO │ DO (user_123)│ + │ │ Protocol: graphql- │ │ by userId │ │ + └────────────┘ transport-ws └────────────┘ └──────────────┘ + +2. UserChannel DO accepts and awaits ConnectionInit + ┌────────────┐ ┌──────────────┐ + │ Browser │ │ UserChannel │ + │ │──ConnectionInit {payload: {token: "..."}}─────────►│ │ + │ │ │ Validate │ + │ │◄─ConnectionAck {}─────────────────────────────────│ │ + └────────────┘ └──────────────┘ + +3. Client subscribes to "library" channel + ┌────────────┐ ┌──────────────┐ + │ Browser │──Subscribe {id:"1", query:"subscription │ UserChannel │ + │ │ LibraryChanges { channel(name:"library") }"}───►│ │ + │ │ │ Register ws │ + │ │ │ for "library"│ + └────────────┘ └──────────────┘ +``` + +### Data Flow: Event Broadcasting + +``` +1. Mutation in Library DO triggers publish to UserChannel DO + ┌──────────────────────────────────────────────────────────────────────────┐ + │ Library DO │ + │ │ + │ createStory(url, title) │ + │ │ │ + │ ▼ │ + │ [Insert into SQLite] │ + │ │ │ + │ ▼ │ + │ const userChannel = this.env.USER_CHANNEL.get( │ + │ this.env.USER_CHANNEL.idFromName(userId) │ + │ ); │ + │ await userChannel.publish("library", { │ + │ type: "story:create", │ + │ story: { id, url, title, ... } │ + │ }); │ + └──────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────────────────────────────┐ + │ UserChannel DO │ + │ │ + │ publish(channel: "library", event) │ + │ │ │ + │ ▼ │ + │ ctx.getWebSockets().forEach(ws => { │ + │ const state = ws.deserializeAttachment(); │ + │ if (state.channels.includes("library")) { │ + │ ws.send(JSON.stringify({ │ + │ id: state.subscriptionIds["library"], │ + │ type: "next", │ + │ payload: { data: { channel: event } } │ + │ })); │ + │ } │ + │ }); │ + └──────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. UserChannel DO Design + +### Responsibilities + +1. **WebSocket Connection Management** + - Accept WebSocket upgrades with hibernation support + - Handle graphql-ws protocol (ConnectionInit, Subscribe, Complete, Ping/Pong) + - Manage connection lifecycle and cleanup + +2. **Channel Subscription Management** + - Track which WebSocket connections are subscribed to which channels + - Support multiple channel subscriptions per connection + - Handle subscribe/unsubscribe operations + +3. **Event Publishing** + - Expose `publish(channel, event)` RPC method for other DOs + - Broadcast events only to WebSockets subscribed to that channel + - Handle serialization and graphql-ws message formatting + +### Connection State Structure + +```typescript +// apps/worker/src/features/user-channel/types.ts + +export interface AwaitingInitState { + state: "awaiting_init"; + connectedAt: number; +} + +export interface ReadyState { + state: "ready"; + userId: string; + // Map of channel name -> subscription ID (from graphql-ws Subscribe message) + subscriptions: Record; +} + +export type ConnectionState = AwaitingInitState | ReadyState; +``` + +### RPC Interface + +```typescript +// apps/worker/src/features/user-channel/UserChannel.ts + +export class UserChannel extends DurableObject { + /** + * Publish an event to all WebSocket connections subscribed to the channel. + * Called by other DOs (Library, Notifications, etc.) + */ + async publish(channel: string, event: ChannelEvent): Promise; + + /** + * Get count of active connections (for monitoring/debugging) + */ + async getConnectionCount(): Promise; + + /** + * Get count of subscribers for a specific channel + */ + async getSubscriberCount(channel: string): Promise; +} +``` + +### Implementation + +```typescript +// apps/worker/src/features/user-channel/UserChannel.ts + +import {DurableObject} from "cloudflare:workers"; +import type {ConnectionState, ReadyState, ChannelEvent} from "./types"; + +export class UserChannel extends DurableObject { + private ownerId: string | null = null; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + // Load owner ID on construction + this.ctx.blockConcurrencyWhile(async () => { + this.ownerId = await this.ctx.storage.get("owner"); + }); + } + + /** + * Set the owner of this channel (called once when user is created) + */ + async setOwner(userId: string): Promise { + this.ownerId = userId; + await this.ctx.storage.put("owner", userId); + } + + /** + * Handle WebSocket upgrade requests + */ + async fetch(request: Request): Promise { + const upgradeHeader = request.headers.get("Upgrade"); + + if (upgradeHeader?.toLowerCase() !== "websocket") { + return new Response("Expected WebSocket", {status: 426}); + } + + const protocol = request.headers.get("Sec-WebSocket-Protocol"); + if (protocol !== "graphql-transport-ws") { + return new Response("Unsupported WebSocket Protocol", {status: 400}); + } + + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + this.ctx.acceptWebSocket(server); + + server.serializeAttachment({ + state: "awaiting_init", + connectedAt: Date.now(), + } satisfies ConnectionState); + + return new Response(null, { + status: 101, + headers: { + "Sec-WebSocket-Protocol": "graphql-transport-ws", + }, + webSocket: client, + }); + } + + /** + * Handle incoming WebSocket messages (graphql-ws protocol) + */ + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + if (typeof message !== "string") { + this.closeWithError(ws, 4400, "Binary messages not supported"); + return; + } + + let parsed: ClientMessage; + try { + parsed = JSON.parse(message); + } catch { + this.closeWithError(ws, 4400, "Invalid JSON"); + return; + } + + const state = ws.deserializeAttachment() as ConnectionState; + + switch (parsed.type) { + case "connection_init": + await this.handleConnectionInit(ws, state); + break; + + case "subscribe": + await this.handleSubscribe(ws, parsed, state); + break; + + case "complete": + await this.handleComplete(ws, parsed, state); + break; + + case "ping": + ws.send(JSON.stringify({type: "pong"})); + break; + + default: + this.closeWithError(ws, 4400, "Unknown message type"); + } + } + + /** + * Handle WebSocket close + */ + async webSocketClose( + ws: WebSocket, + code: number, + reason: string, + wasClean: boolean + ): Promise { + console.log(`WebSocket closed: code=${code}, reason=${reason}, clean=${wasClean}`); + } + + /** + * Publish event to all subscribers of a channel + */ + async publish(channel: string, event: ChannelEvent): Promise { + const webSockets = this.ctx.getWebSockets(); + + for (const ws of webSockets) { + try { + const state = ws.deserializeAttachment() as ConnectionState; + + if (state.state === "ready") { + const subscriptionId = state.subscriptions[channel]; + if (subscriptionId) { + ws.send(JSON.stringify({ + id: subscriptionId, + type: "next", + payload: { + data: { + channel: event, + }, + }, + })); + } + } + } catch (error) { + console.error("Failed to send to WebSocket:", error); + } + } + } + + async getConnectionCount(): Promise { + return this.ctx.getWebSockets().length; + } + + async getSubscriberCount(channel: string): Promise { + const webSockets = this.ctx.getWebSockets(); + let count = 0; + + for (const ws of webSockets) { + const state = ws.deserializeAttachment() as ConnectionState; + if (state.state === "ready" && state.subscriptions[channel]) { + count++; + } + } + + return count; + } + + // --- Private methods --- + + private async handleConnectionInit(ws: WebSocket, state: ConnectionState): Promise { + if (state.state !== "awaiting_init") { + this.closeWithError(ws, 4429, "Too many initialisation requests"); + return; + } + + if (Date.now() - state.connectedAt > 10_000) { + this.closeWithError(ws, 4408, "Connection initialisation timeout"); + return; + } + + if (!this.ownerId) { + this.closeWithError(ws, 4403, "Forbidden"); + return; + } + + ws.serializeAttachment({ + state: "ready", + userId: this.ownerId, + subscriptions: {}, + } satisfies ReadyState); + + ws.send(JSON.stringify({type: "connection_ack"})); + } + + private async handleSubscribe( + ws: WebSocket, + message: SubscribeMessage, + state: ConnectionState + ): Promise { + if (state.state !== "ready") { + this.closeWithError(ws, 4401, "Unauthorized"); + return; + } + + // Extract channel name from subscription query + // Expected format: subscription { channel(name: "library") { ... } } + const channelMatch = message.payload.query.match(/channel\s*\(\s*name\s*:\s*"([^"]+)"\s*\)/); + if (!channelMatch) { + ws.send(JSON.stringify({ + id: message.id, + type: "error", + payload: [{message: "Invalid subscription: must specify channel(name: \"...\")"}], + })); + return; + } + + const channelName = channelMatch[1]; + + // Register subscription + const newSubscriptions = {...state.subscriptions, [channelName]: message.id}; + ws.serializeAttachment({ + ...state, + subscriptions: newSubscriptions, + } satisfies ReadyState); + } + + private async handleComplete( + ws: WebSocket, + message: CompleteMessage, + state: ConnectionState + ): Promise { + if (state.state !== "ready") return; + + // Find and remove the subscription by ID + const newSubscriptions = {...state.subscriptions}; + for (const [channel, subId] of Object.entries(newSubscriptions)) { + if (subId === message.id) { + delete newSubscriptions[channel]; + break; + } + } + + ws.serializeAttachment({ + ...state, + subscriptions: newSubscriptions, + } satisfies ReadyState); + + ws.send(JSON.stringify({id: message.id, type: "complete"})); + } + + private closeWithError(ws: WebSocket, code: number, reason: string): void { + ws.close(code, reason); + } +} + +// --- Message Types --- + +interface ConnectionInitMessage { + type: "connection_init"; + payload?: Record; +} + +interface SubscribeMessage { + type: "subscribe"; + id: string; + payload: { + query: string; + operationName?: string; + variables?: Record; + }; +} + +interface CompleteMessage { + type: "complete"; + id: string; +} + +interface PingMessage { + type: "ping"; +} + +type ClientMessage = ConnectionInitMessage | SubscribeMessage | CompleteMessage | PingMessage; +``` + +--- + +## 3. WebSocket Connection Routing + +### Worker Entry Point + +```typescript +// apps/worker/src/index.ts + +// Before GraphQL Yoga middleware, check for WebSocket upgrade +app.use("/graphql", async (c, next) => { + const upgradeHeader = c.req.header("Upgrade"); + if (upgradeHeader?.toLowerCase() === "websocket") { + // Validate session + const pasaport = c.env.PASAPORT.getByName("kampus"); + const sessionData = await pasaport.validateSession(c.req.raw.headers); + + if (!sessionData?.user?.id) { + return new Response("Unauthorized", {status: 401}); + } + + // Route to user's UserChannel DO + const channelId = c.env.USER_CHANNEL.idFromName(sessionData.user.id); + const userChannel = c.env.USER_CHANNEL.get(channelId); + + return userChannel.fetch(c.req.raw); + } + + return next(); +}); +``` + +### Wrangler Configuration + +```jsonc +// apps/worker/wrangler.jsonc +{ + "durable_objects": { + "bindings": [ + // ... existing bindings ... + { + "name": "USER_CHANNEL", + "class_name": "UserChannel" + } + ] + } +} +``` + +--- + +## 4. Library DO Integration + +### Publishing Events + +```typescript +// apps/worker/src/features/library/Library.ts + +import {encodeGlobalId, NodeType} from "../../graphql/relay"; +import type {LibraryEvent} from "./subscription-types"; + +export class Library extends DurableObject { + private ownerId: string | null = null; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.ctx.blockConcurrencyWhile(async () => { + await migrate(this.db, migrations); + this.ownerId = await this.ctx.storage.get("owner"); + }); + } + + // --- Helper to get UserChannel for this user --- + + private getUserChannel() { + if (!this.ownerId) { + throw new Error("Library has no owner"); + } + const channelId = this.env.USER_CHANNEL.idFromName(this.ownerId); + return this.env.USER_CHANNEL.get(channelId); + } + + private async publishToLibrary(event: LibraryEvent): Promise { + try { + const userChannel = this.getUserChannel(); + await userChannel.publish("library", event); + } catch (error) { + // Log but don't fail - mutation succeeded, broadcast is best-effort + console.error("Failed to publish event:", error); + } + } + + private async publishLibraryChange(): Promise { + const totalStories = await this.getStoryCount(); + const totalTags = await this.getTagCount(); + + await this.publishToLibrary({ + type: "library:change", + totalStories, + totalTags, + }); + } + + // --- Modified CRUD methods --- + + async createStory(options: {url: string; title: string; description?: string}) { + const {url, title, description} = options; + + // Existing insert logic... + const [story] = await this.db + .insert(schema.story) + .values({url, normalizedUrl: getNormalizedUrl(url), title, description}) + .returning(); + + const storyResult = { + ...story, + createdAt: story.createdAt.toISOString(), + }; + + // Publish events + await this.publishToLibrary({ + type: "story:create", + story: { + id: encodeGlobalId(NodeType.Story, story.id), + url: story.url, + title: story.title, + description: story.description ?? null, + createdAt: storyResult.createdAt, + }, + }); + await this.publishLibraryChange(); + + return storyResult; + } + + async updateStory(id: string, updates: {title?: string; description?: string | null}) { + const story = await this.db.transaction(async (tx) => { + // ... existing transaction code ... + }); + + if (story) { + await this.publishToLibrary({ + type: "story:update", + story: { + id: encodeGlobalId(NodeType.Story, story.id), + url: story.url, + title: story.title, + description: story.description ?? null, + createdAt: story.createdAt, + }, + }); + } + + return story; + } + + async deleteStory(id: string) { + const deleted = await this.db.transaction(async (tx) => { + // ... existing delete logic ... + }); + + if (deleted) { + await this.publishToLibrary({ + type: "story:delete", + deletedStoryId: encodeGlobalId(NodeType.Story, id), + }); + await this.publishLibraryChange(); + } + + return deleted; + } + + // Similar patterns for tag operations... + async createTag(name: string, color: string) { + // ... existing insert ... + await this.publishToLibrary({type: "tag:create", tag: {...}}); + await this.publishLibraryChange(); + } + + async updateTag(id: string, updates: {...}) { + // ... existing update ... + await this.publishToLibrary({type: "tag:update", tag: {...}}); + } + + async deleteTag(id: string) { + // ... existing delete ... + await this.publishToLibrary({type: "tag:delete", deletedTagId: ...}); + await this.publishLibraryChange(); + } + + async tagStory(storyId: string, tagIds: string[]) { + // ... existing logic ... + await this.publishToLibrary({type: "story:tag", storyId: ..., tagIds: [...]}); + } + + async untagStory(storyId: string, tagIds: string[]) { + // ... existing logic ... + await this.publishToLibrary({type: "story:untag", storyId: ..., tagIds: [...]}); + } + + // --- Helper methods --- + + private async getStoryCount(): Promise { + const result = await this.db.select({count: sql`count(*)`}).from(schema.story); + return result[0]?.count ?? 0; + } + + private async getTagCount(): Promise { + const result = await this.db.select({count: sql`count(*)`}).from(schema.tag); + return result[0]?.count ?? 0; + } +} +``` + +--- + +## 5. Event Types + +### Library Channel Events + +```typescript +// apps/worker/src/features/library/subscription-types.ts + +export interface StoryPayload { + id: string; // Global ID + url: string; + title: string; + description: string | null; + createdAt: string; +} + +export interface TagPayload { + id: string; // Global ID + name: string; + color: string; + createdAt: string; +} + +export type LibraryEvent = + | {type: "story:create"; story: StoryPayload} + | {type: "story:update"; story: StoryPayload} + | {type: "story:delete"; deletedStoryId: string} + | {type: "tag:create"; tag: TagPayload} + | {type: "tag:update"; tag: TagPayload} + | {type: "tag:delete"; deletedTagId: string} + | {type: "story:tag"; storyId: string; tagIds: string[]} + | {type: "story:untag"; storyId: string; tagIds: string[]} + | {type: "library:change"; totalStories: number; totalTags: number}; +``` + +### Generic Channel Event (for UserChannel DO) + +```typescript +// apps/worker/src/features/user-channel/types.ts + +// Generic event that can be published to any channel +export interface ChannelEvent { + type: string; + [key: string]: unknown; +} +``` + +--- + +## 6. GraphQL Schema Design + +### Subscription Type Definition + +```typescript +// apps/worker/src/index.ts (additions) + +// Event payload types +const StoryPayloadType = Schema.Struct({ + id: Schema.String, + url: Schema.String, + title: Schema.String, + description: Schema.NullOr(Schema.String), + createdAt: Schema.String, +}).annotations({title: "StoryPayload"}); + +const TagPayloadType = Schema.Struct({ + id: Schema.String, + name: Schema.String, + color: Schema.String, + createdAt: Schema.String, +}).annotations({title: "TagPayload"}); + +// Library channel event union +const LibraryChannelEvent = Schema.Union( + Schema.Struct({ + type: Schema.Literal("story:create"), + story: StoryPayloadType, + }), + Schema.Struct({ + type: Schema.Literal("story:update"), + story: StoryPayloadType, + }), + Schema.Struct({ + type: Schema.Literal("story:delete"), + deletedStoryId: Schema.String, + }), + Schema.Struct({ + type: Schema.Literal("tag:create"), + tag: TagPayloadType, + }), + Schema.Struct({ + type: Schema.Literal("tag:update"), + tag: TagPayloadType, + }), + Schema.Struct({ + type: Schema.Literal("tag:delete"), + deletedTagId: Schema.String, + }), + Schema.Struct({ + type: Schema.Literal("story:tag"), + storyId: Schema.String, + tagIds: Schema.Array(Schema.String), + }), + Schema.Struct({ + type: Schema.Literal("story:untag"), + storyId: Schema.String, + tagIds: Schema.Array(Schema.String), + }), + Schema.Struct({ + type: Schema.Literal("library:change"), + totalStories: Schema.Number, + totalTags: Schema.Number, + }), +).annotations({title: "LibraryChannelEvent"}); +``` + +### Generated GraphQL Schema + +```graphql +type Subscription { + channel(name: String!): ChannelEvent! +} + +union ChannelEvent = LibraryChannelEvent | NotificationEvent | ... + +union LibraryChannelEvent = + | StoryCreateEvent + | StoryUpdateEvent + | StoryDeleteEvent + | TagCreateEvent + | TagUpdateEvent + | TagDeleteEvent + | StoryTagEvent + | StoryUntagEvent + | LibraryChangeEvent + +type StoryCreateEvent { + type: String! # "story:create" + story: StoryPayload! +} + +type StoryUpdateEvent { + type: String! # "story:update" + story: StoryPayload! +} + +type StoryDeleteEvent { + type: String! # "story:delete" + deletedStoryId: String! +} + +type LibraryChangeEvent { + type: String! # "library:change" + totalStories: Int! + totalTags: Int! +} + +# ... other event types ... +``` + +--- + +## 7. Frontend Integration + +### Relay Environment Modifications + +```typescript +// apps/kamp-us/src/relay/environment.ts + +import { + Environment, + Network, + Observable, + RecordSource, + Store, + type FetchFunction, + type GraphQLResponse, + type SubscribeFunction, +} from "relay-runtime"; +import {createClient} from "graphql-ws"; + +const fetchQuery: FetchFunction = async (operation, variables) => { + // ... existing implementation ... +}; + +function createSubscriptionClient() { + return createClient({ + url: `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/graphql`, + retryAttempts: Infinity, + shouldRetry: () => true, + retryWait: (retryCount) => { + const delay = Math.min(1000 * Math.pow(2, retryCount), 30000); + return new Promise(resolve => setTimeout(resolve, delay)); + }, + on: { + connected: () => console.log("Subscription connected"), + closed: () => console.log("Subscription closed"), + error: (error) => console.error("Subscription error:", error), + }, + }); +} + +let subscriptionClient: ReturnType | null = null; + +function getSubscriptionClient() { + if (!subscriptionClient) { + subscriptionClient = createSubscriptionClient(); + } + return subscriptionClient; +} + +export function resetSubscriptionClient() { + if (subscriptionClient) { + subscriptionClient.dispose(); + subscriptionClient = null; + } +} + +const subscribe: SubscribeFunction = (operation, variables) => { + return Observable.create((sink) => { + const client = getSubscriptionClient(); + + return client.subscribe( + { + operationName: operation.name, + query: operation.text!, + variables, + }, + { + next: (value) => sink.next(value as GraphQLResponse), + error: sink.error, + complete: sink.complete, + } + ); + }); +}; + +export function createRelayEnvironment() { + return new Environment({ + network: Network.create(fetchQuery, subscribe), + store: new Store(new RecordSource()), + }); +} + +export const environment = createRelayEnvironment(); +``` + +### Subscription Hook Usage + +```typescript +// apps/kamp-us/src/pages/Library.tsx + +import {useSubscription, graphql, useRelayEnvironment} from "react-relay"; +import {useMemo} from "react"; + +const LibraryChannelSubscription = graphql` + subscription LibraryChannelSubscription { + channel(name: "library") { + ... on StoryCreateEvent { + type + story { id url title description createdAt } + } + ... on StoryUpdateEvent { + type + story { id url title description createdAt } + } + ... on StoryDeleteEvent { + type + deletedStoryId + } + ... on TagCreateEvent { + type + tag { id name color createdAt } + } + ... on TagUpdateEvent { + type + tag { id name color createdAt } + } + ... on TagDeleteEvent { + type + deletedTagId + } + ... on StoryTagEvent { + type + storyId + tagIds + } + ... on StoryUntagEvent { + type + storyId + tagIds + } + ... on LibraryChangeEvent { + type + totalStories + totalTags + } + } + } +`; + +function useLibrarySubscription(connectionId: string | null) { + useSubscription( + useMemo( + () => ({ + subscription: LibraryChannelSubscription, + variables: {}, + updater: (store, data) => { + const event = data?.channel; + if (!event) return; + + if (event.type === "library:change" && connectionId) { + const connection = store.get(connectionId); + if (connection) { + connection.setValue(event.totalStories, "totalCount"); + } + } + }, + onError: (error) => { + console.error("Subscription error:", error); + }, + }), + [connectionId] + ) + ); +} +``` + +--- + +## 8. graphql-ws Protocol Handling + +### Message Flow + +``` +Client Server (UserChannel DO) + │ │ + │──────── WebSocket Upgrade ─────────────────────────────► + │ │ + │◄─────── 101 Switching Protocols ───────────────────────│ + │ │ + │──────── {"type":"connection_init","payload":{}} ──────►│ + │ │ Validate + │◄─────── {"type":"connection_ack"} ─────────────────────│ + │ │ + │──────── {"type":"subscribe","id":"1", │ + │ "payload":{"query":"subscription { │ + │ channel(name:\"library\") {...} │ + │ }"}} ────────────────────────────────────────►│ + │ │ Register for + │ │ "library" channel + │ [Library DO calls publish("library", event)] │ + │ │ + │◄─────── {"type":"next","id":"1", │ + │ "payload":{"data":{"channel":{...}}}} ────────│ + │ │ + │──────── {"type":"complete","id":"1"} ─────────────────►│ + │ │ + │◄─────── {"type":"complete","id":"1"} ──────────────────│ +``` + +### Error Codes + +| Code | Meaning | When Used | +| ---- | ------------------------ | -------------------------------------- | +| 4400 | Bad Request | Invalid JSON, binary message, unknown type | +| 4401 | Unauthorized | Subscribe before ConnectionAck | +| 4403 | Forbidden | No owner set for channel | +| 4408 | Connection Init Timeout | No ConnectionInit within 10s | +| 4429 | Too Many Requests | Multiple ConnectionInit messages | + +--- + +## 9. Critical Implementation Details + +### Authentication Flow + +``` +1. HTTP Request with Upgrade header arrives at Worker +2. Worker validates session via Pasaport +3. If valid: Route to UserChannel DO via idFromName(userId) +4. If invalid: Return HTTP 401 before upgrade +5. UserChannel DO trusts the routing (no re-validation needed) +``` + +### Error Handling Patterns + +```typescript +// Library DO: Publish errors should not break mutations +private async publishToLibrary(event: LibraryEvent): Promise { + try { + const userChannel = this.getUserChannel(); + await userChannel.publish("library", event); + } catch (error) { + // Log but don't throw - mutation succeeded, broadcast is best-effort + console.error("Failed to publish event:", error); + } +} + +// UserChannel DO: Broadcast errors should not affect other connections +async publish(channel: string, event: ChannelEvent): Promise { + for (const ws of this.ctx.getWebSockets()) { + try { + // ... send logic ... + } catch (error) { + console.error("Failed to send to WebSocket:", error); + } + } +} +``` + +### Hibernation Considerations + +1. **State Persistence** + - Use `serializeAttachment()` for connection state + - State survives DO hibernation (limit: 2KB per connection) + +2. **Wake Behavior** + - `webSocketMessage()` is called when DO wakes + - All WebSockets from `getWebSockets()` are still valid + +3. **No Alarms Needed** + - graphql-ws client handles ping/pong + - DO hibernates naturally when idle + +--- + +## 10. File Changes Summary + +### New Files + +| File | Description | +| ------------------------------------------------------- | ---------------------------------------- | +| `apps/worker/src/features/user-channel/UserChannel.ts` | UserChannel DO implementation | +| `apps/worker/src/features/user-channel/types.ts` | TypeScript types for connection state | +| `apps/worker/src/features/library/subscription-types.ts` | Library event type definitions | +| `apps/worker/test/user-channel.spec.ts` | Tests for UserChannel DO | + +### Modified Files + +| File | Changes | +| ----------------------------------------------- | ------------------------------------------------ | +| `apps/worker/src/features/library/Library.ts` | Add `publishToLibrary()` calls to CRUD methods | +| `apps/worker/src/index.ts` | Add WebSocket upgrade routing; Export UserChannel | +| `apps/worker/wrangler.jsonc` | Add USER_CHANNEL DO binding | +| `apps/kamp-us/src/relay/environment.ts` | Add graphql-ws client and subscribe function | +| `apps/kamp-us/src/pages/Library.tsx` | Add useLibrarySubscription hook | +| `apps/kamp-us/src/auth/AuthContext.tsx` | Call resetSubscriptionClient() on logout | +| `apps/kamp-us/package.json` | Add graphql-ws dependency | + +### Dependencies to Add + +```json +// apps/kamp-us/package.json +{ + "dependencies": { + "graphql-ws": "^5.16.0" + } +} +``` + +--- + +## 11. Migration Path + +### Phase 1: UserChannel DO +1. Create `UserChannel` DO with WebSocket handling +2. Add DO binding to wrangler.jsonc +3. Export from index.ts +4. Add WebSocket upgrade routing in worker +5. Test WebSocket connections work + +### Phase 2: Library Integration +1. Add subscription types to Library +2. Add `publishToLibrary()` helper +3. Add publish calls to CRUD methods +4. Test events are received via WebSocket + +### Phase 3: Frontend +1. Add graphql-ws dependency +2. Modify Relay environment +3. Add subscription hook to Library page +4. Handle store updates for totalCount + +### Phase 4: Polish +1. Add error handling +2. Implement reconnection UI feedback +3. Add tests +4. Performance tuning + +--- + +## Critical Files for Implementation + +- `apps/worker/src/features/user-channel/UserChannel.ts` - New DO for WebSocket/channel management +- `apps/worker/src/features/library/Library.ts` - Add publish calls to existing CRUD methods +- `apps/worker/src/index.ts` - WebSocket upgrade routing, export UserChannel +- `apps/worker/wrangler.jsonc` - Add USER_CHANNEL binding +- `apps/kamp-us/src/relay/environment.ts` - graphql-ws client integration +- `apps/kamp-us/src/pages/Library.tsx` - Subscription hook diff --git a/specs/library-realtime-subscriptions/graphql-ws-integration-research.md b/specs/library-realtime-subscriptions/graphql-ws-integration-research.md new file mode 100644 index 0000000..9b26327 --- /dev/null +++ b/specs/library-realtime-subscriptions/graphql-ws-integration-research.md @@ -0,0 +1,914 @@ +# Research: graphql-ws Integration with Cloudflare Durable Objects + +## Executive Summary + +This document explores how to integrate the `graphql-ws` library with Cloudflare Durable Objects while maintaining hibernation support. The goal is to reuse as much battle-tested code as possible from `graphql-ws` without compromising the cost and scalability benefits of hibernatable WebSockets. + +**Key Finding:** We can reuse ~60% of graphql-ws (message types, parsing, validation, close codes) while implementing our own state management layer that supports hibernation. + +--- + +## Table of Contents + +1. [The Challenge](#the-challenge) +2. [graphql-ws Architecture Analysis](#graphql-ws-architecture-analysis) +3. [What We Can Reuse](#what-we-can-reuse) +4. [What We Must Implement](#what-we-must-implement) +5. [Proposed Architecture](#proposed-architecture) +6. [Implementation Sketch](#implementation-sketch) +7. [Migration Path](#migration-path) +8. [Trade-offs](#trade-offs) + +--- + +## The Challenge + +### Hibernatable WebSockets + +Cloudflare's Hibernatable WebSocket API allows Durable Objects to "sleep" while maintaining WebSocket connections: + +```typescript +// Traditional WebSocket (stays in memory) +socket.on('message', (data) => { /* handle */ }); + +// Hibernatable WebSocket (handler method, DO can sleep between calls) +async webSocketMessage(ws: WebSocket, message: string) { + // DO wakes up, handles message, can sleep again +} +``` + +**Benefits:** +- Significantly reduced costs for idle connections +- Better resource utilization +- Automatic scaling + +**Constraint:** +- State must be externalized (can't live in memory) +- We use `ws.serializeAttachment()` / `ws.deserializeAttachment()` for per-connection state + +### graphql-ws State Management + +The `makeServer` function from graphql-ws maintains internal state per connection: + +```typescript +// Inside makeServer's opened() method +const ctx = { + connectionInitReceived: false, // Has client sent connection_init? + acknowledged: false, // Have we sent connection_ack? + subscriptions: {}, // Active subscriptions: id -> AsyncIterator + extra, // User-provided context +}; +``` + +This state lives in JavaScript memory and is lost when the DO hibernates. + +### The Incompatibility + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ graphql-ws makeServer │ +├─────────────────────────────────────────────────────────────────┤ +│ ✓ Protocol-compliant message handling │ +│ ✓ Built-in validation │ +│ ✓ Subscription lifecycle management │ +│ ✗ State stored in memory (lost on hibernation) │ +│ ✗ Expects persistent event listeners (socket.on) │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ Cloudflare Hibernatable WebSockets │ +├─────────────────────────────────────────────────────────────────┤ +│ ✓ Cost-efficient for idle connections │ +│ ✓ State survives via serializeAttachment() │ +│ ✓ Handler methods (webSocketMessage, webSocketClose) │ +│ ✗ No persistent event loop │ +│ ✗ DO instance may be different between messages │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## graphql-ws Architecture Analysis + +### Exports We Can Use + +```typescript +// From 'graphql-ws' +import { + // Enums + MessageType, // 'connection_init', 'subscribe', 'next', etc. + CloseCode, // 4400, 4401, 4408, 4429, etc. + + // Utilities + parseMessage, // Parse incoming WebSocket data + stringifyMessage, // Serialize outgoing messages + validateMessage, // Validate message structure + + // Types + ConnectionInitMessage, + ConnectionAckMessage, + SubscribeMessage, + NextMessage, + ErrorMessage, + CompleteMessage, + PingMessage, + PongMessage, + Message, // Union of all message types + SubscribePayload, +} from 'graphql-ws'; +``` + +### MessageType Enum + +```typescript +export enum MessageType { + ConnectionInit = 'connection_init', // Client → Server + ConnectionAck = 'connection_ack', // Server → Client + Ping = 'ping', // Bidirectional + Pong = 'pong', // Bidirectional + Subscribe = 'subscribe', // Client → Server + Next = 'next', // Server → Client + Error = 'error', // Server → Client + Complete = 'complete', // Bidirectional +} +``` + +### CloseCode Enum + +```typescript +export enum CloseCode { + InternalServerError = 4500, + InternalClientError = 4005, + BadRequest = 4400, + BadResponse = 4004, + Unauthorized = 4401, + Forbidden = 4403, + SubprotocolNotAcceptable = 4406, + ConnectionInitialisationTimeout = 4408, + ConnectionAcknowledgementTimeout = 4504, + SubscriberAlreadyExists = 4409, + TooManyInitialisationRequests = 4429, +} +``` + +### Message Parsing + +```typescript +// parseMessage handles JSON parsing and basic validation +const message = parseMessage(rawData); + +// stringifyMessage handles serialization +const data = stringifyMessage({ type: MessageType.ConnectionAck }); + +// validateMessage provides detailed validation +validateMessage(message); // throws if invalid +``` + +--- + +## What We Can Reuse + +### 1. Message Types and Enums (100% reusable) + +Replace our hand-written types with graphql-ws exports: + +```typescript +// Before (our code) +interface SubscribeMessage { + type: "subscribe"; + id: string; + payload: { + query: string; + operationName?: string; + variables?: Record; + }; +} + +// After (from graphql-ws) +import type { SubscribeMessage } from 'graphql-ws'; +``` + +### 2. Message Parsing and Validation (100% reusable) + +Replace our JSON.parse with graphql-ws utilities: + +```typescript +// Before (our code) +let parsed: ClientMessage; +try { + parsed = JSON.parse(message); +} catch { + this.closeWithError(ws, 4400, "Invalid JSON"); + return; +} + +// After (using graphql-ws) +import { parseMessage, CloseCode } from 'graphql-ws'; + +let parsed: Message; +try { + parsed = parseMessage(message); +} catch (err) { + ws.close(CloseCode.BadRequest, err.message); + return; +} +``` + +### 3. Close Codes (100% reusable) + +Replace magic numbers with semantic constants: + +```typescript +// Before +ws.close(4429, "Too many initialisation requests"); +ws.close(4408, "Connection initialisation timeout"); +ws.close(4401, "Unauthorized"); + +// After +import { CloseCode } from 'graphql-ws'; + +ws.close(CloseCode.TooManyInitialisationRequests, "Too many initialisation requests"); +ws.close(CloseCode.ConnectionInitialisationTimeout, "Connection initialisation timeout"); +ws.close(CloseCode.Unauthorized, "Unauthorized"); +``` + +### 4. Message Serialization (100% reusable) + +```typescript +// Before +ws.send(JSON.stringify({ type: "connection_ack" })); +ws.send(JSON.stringify({ + id: subscriptionId, + type: "next", + payload: { data: { channel: event } }, +})); + +// After +import { stringifyMessage, MessageType } from 'graphql-ws'; + +ws.send(stringifyMessage({ type: MessageType.ConnectionAck })); +ws.send(stringifyMessage({ + id: subscriptionId, + type: MessageType.Next, + payload: { data: { channel: event } }, +})); +``` + +--- + +## What We Must Implement + +### 1. Hibernation-Compatible State Management + +The connection state must be serializable and stored via `serializeAttachment()`: + +```typescript +import type { RateLimitState } from './types'; + +// State that survives hibernation +interface HibernatableConnectionState { + // Protocol state + phase: 'awaiting_init' | 'ready'; + connectedAt: number; + + // User identity (set after connection_init) + userId?: string; + + // Active subscriptions: channel name -> subscription ID + subscriptions: Record; + + // Rate limiting + rateLimit: RateLimitState; +} +``` + +### 2. Message Router + +Route incoming messages to handlers based on type: + +```typescript +import { parseMessage, MessageType, CloseCode } from 'graphql-ws'; + +async webSocketMessage(ws: WebSocket, rawMessage: string | ArrayBuffer) { + if (typeof rawMessage !== 'string') { + ws.close(CloseCode.BadRequest, 'Binary messages not supported'); + return; + } + + let message: Message; + try { + message = parseMessage(rawMessage); + } catch (err) { + ws.close(CloseCode.BadRequest, err.message); + return; + } + + const state = ws.deserializeAttachment() as HibernatableConnectionState; + + switch (message.type) { + case MessageType.ConnectionInit: + return this.handleConnectionInit(ws, message, state); + case MessageType.Subscribe: + return this.handleSubscribe(ws, message, state); + case MessageType.Complete: + return this.handleComplete(ws, message, state); + case MessageType.Ping: + ws.send(stringifyMessage({ type: MessageType.Pong })); + return; + case MessageType.Pong: + // Client responded to our ping, clear timeout if any + return; + default: + ws.close(CloseCode.BadRequest, 'Unknown message type'); + } +} +``` + +### 3. Channel-Based Subscription Handler + +Instead of executing GraphQL operations (which would require a schema), we handle channel subscriptions: + +```typescript +import { + MessageType, + CloseCode, + stringifyMessage, + type SubscribeMessage +} from 'graphql-ws'; + +private handleSubscribe( + ws: WebSocket, + message: SubscribeMessage, + state: HibernatableConnectionState +) { + if (state.phase !== 'ready') { + ws.close(CloseCode.Unauthorized, 'Connection not acknowledged'); + return; + } + + // Extract channel from query (our custom convention) + const channelName = this.extractChannelName(message.payload.query); + if (!channelName) { + ws.send(stringifyMessage({ + id: message.id, + type: MessageType.Error, + payload: [{ message: 'Invalid subscription: must specify channel' }], + })); + return; + } + + // Validate channel + if (!ALLOWED_CHANNELS.has(channelName)) { + ws.send(stringifyMessage({ + id: message.id, + type: MessageType.Error, + payload: [{ message: `Unknown channel: ${channelName}` }], + })); + return; + } + + // Register subscription + const newState: HibernatableConnectionState = { + ...state, + subscriptions: { ...state.subscriptions, [channelName]: message.id }, + }; + ws.serializeAttachment(newState); +} +``` + +### 4. Event Publishing + +Publishing uses graphql-ws message types: + +```typescript +import { stringifyMessage, MessageType } from 'graphql-ws'; + +async publish(channel: string, event: ChannelEvent) { + const webSockets = this.ctx.getWebSockets(); + + for (const ws of webSockets) { + try { + const state = ws.deserializeAttachment() as HibernatableConnectionState; + + if (state.phase === 'ready') { + const subscriptionId = state.subscriptions[channel]; + if (subscriptionId) { + ws.send(stringifyMessage({ + id: subscriptionId, + type: MessageType.Next, + payload: { data: { channel: event } }, + })); + } + } + } catch (error) { + console.error('[UserChannel] Failed to send:', error); + } + } +} +``` + +--- + +## Proposed Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ graphql-ws (npm package) │ +├─────────────────────────────────────────────────────────────────┤ +│ MessageType CloseCode parseMessage stringifyMessage │ +│ Message types SubscribePayload validateMessage │ +└───────────────────────────┬─────────────────────────────────────┘ + │ imports + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ HibernatableGraphQLWSAdapter │ +│ (our implementation) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ State Management Layer │ │ +│ │ • serializeAttachment() / deserializeAttachment() │ │ +│ │ • HibernatableConnectionState type │ │ +│ │ • Rate limiting state │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Message Router │ │ +│ │ • webSocketMessage() handler │ │ +│ │ • Uses parseMessage() from graphql-ws │ │ +│ │ • Routes to protocol handlers │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Protocol Handlers │ │ +│ │ • handleConnectionInit() │ │ +│ │ • handleSubscribe() │ │ +│ │ • handleComplete() │ │ +│ │ • Uses stringifyMessage() from graphql-ws │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Channel Pub/Sub │ │ +│ │ • publish(channel, event) │ │ +│ │ • Channel validation │ │ +│ │ • Subscription registry (in attachment state) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Cloudflare Durable Object │ +│ (UserChannel) │ +├─────────────────────────────────────────────────────────────────┤ +│ • Hibernatable WebSocket handlers │ +│ • Per-user isolation via idFromName() │ +│ • RPC methods for publishing │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Implementation Sketch + +### File Structure + +``` +apps/worker/src/features/user-channel/ +├── UserChannel.ts # Durable Object class +├── protocol.ts # graphql-ws integration layer +├── state.ts # Hibernatable state types +├── handlers/ +│ ├── connection-init.ts # connection_init handler +│ ├── subscribe.ts # subscribe handler +│ └── complete.ts # complete handler +└── types.ts # Channel event types +``` + +### protocol.ts + +```typescript +import { + parseMessage, + stringifyMessage, + MessageType, + CloseCode, + type Message, + type ConnectionInitMessage, + type SubscribeMessage, + type CompleteMessage, +} from 'graphql-ws'; + +import type { HibernatableConnectionState } from './state'; + +export { MessageType, CloseCode, stringifyMessage }; + +/** + * Parse and validate an incoming WebSocket message. + * Uses graphql-ws parseMessage for protocol compliance. + */ +export function parseClientMessage(rawMessage: string): Message { + return parseMessage(rawMessage); +} + +/** + * Create initial connection state for a new WebSocket. + */ +export function createInitialState(): HibernatableConnectionState { + const now = Date.now(); + return { + phase: 'awaiting_init', + connectedAt: now, + subscriptions: {}, + rateLimit: { windowStart: now, messageCount: 0 }, + }; +} + +/** + * Check if connection_init was received within timeout. + */ +export function isConnectionInitTimedOut( + state: HibernatableConnectionState, + timeoutMs: number = 10_000 +): boolean { + return state.phase === 'awaiting_init' && + Date.now() - state.connectedAt > timeoutMs; +} + +/** + * Send a protocol-compliant error and close the connection. + */ +export function closeWithError( + ws: WebSocket, + code: CloseCode, + reason: string +): void { + ws.close(code, reason); +} + +/** + * Send a subscription error message. + */ +export function sendSubscriptionError( + ws: WebSocket, + id: string, + message: string +): void { + ws.send(stringifyMessage({ + id, + type: MessageType.Error, + payload: [{ message }], + })); +} + +/** + * Send a subscription data message. + */ +export function sendNext( + ws: WebSocket, + id: string, + data: T +): void { + ws.send(stringifyMessage({ + id, + type: MessageType.Next, + payload: { data }, + })); +} + +/** + * Send connection acknowledgement. + */ +export function sendConnectionAck(ws: WebSocket): void { + ws.send(stringifyMessage({ type: MessageType.ConnectionAck })); +} + +/** + * Send pong in response to ping. + */ +export function sendPong(ws: WebSocket): void { + ws.send(stringifyMessage({ type: MessageType.Pong })); +} +``` + +### state.ts + +```typescript +/** + * Rate limiting state for a connection. + */ +export interface RateLimitState { + windowStart: number; + messageCount: number; +} + +/** + * Connection state that survives hibernation. + * Stored via ws.serializeAttachment(). + */ +export interface HibernatableConnectionState { + /** Protocol phase */ + phase: 'awaiting_init' | 'ready'; + + /** Timestamp when connection was established */ + connectedAt: number; + + /** User ID (set after successful connection_init) */ + userId?: string; + + /** Active subscriptions: channel name -> subscription ID */ + subscriptions: Record; + + /** Rate limiting state */ + rateLimit: RateLimitState; +} + +/** + * Serialize state to WebSocket attachment. + */ +export function saveState(ws: WebSocket, state: HibernatableConnectionState): void { + ws.serializeAttachment(state); +} + +/** + * Deserialize state from WebSocket attachment. + */ +export function loadState(ws: WebSocket): HibernatableConnectionState { + return ws.deserializeAttachment() as HibernatableConnectionState; +} +``` + +### UserChannel.ts (Updated) + +```typescript +import { DurableObject } from 'cloudflare:workers'; +import { MessageType, CloseCode } from 'graphql-ws'; +import { + parseClientMessage, + createInitialState, + isConnectionInitTimedOut, + closeWithError, + sendConnectionAck, + sendPong, + sendNext, + sendSubscriptionError, +} from './protocol'; +import { + type HibernatableConnectionState, + saveState, + loadState, +} from './state'; +import { handleSubscribe } from './handlers/subscribe'; +import { handleComplete } from './handlers/complete'; +import type { ChannelEvent } from './types'; + +const ALLOWED_CHANNELS = new Set(['library', 'notifications']); +const RATE_LIMIT = { WINDOW_MS: 60_000, MAX_MESSAGES: 100 }; + +export class UserChannel extends DurableObject { + private ownerId: string | undefined; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.ctx.blockConcurrencyWhile(async () => { + this.ownerId = await this.ctx.storage.get('owner'); + }); + } + + async setOwner(userId: string): Promise { + if (this.ownerId) return; + this.ownerId = userId; + await this.ctx.storage.put('owner', userId); + } + + async fetch(request: Request): Promise { + const upgradeHeader = request.headers.get('Upgrade'); + if (upgradeHeader?.toLowerCase() !== 'websocket') { + return new Response('Expected WebSocket', { status: 426 }); + } + + const protocol = request.headers.get('Sec-WebSocket-Protocol'); + if (protocol !== 'graphql-transport-ws') { + return new Response('Unsupported protocol', { status: 400 }); + } + + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + this.ctx.acceptWebSocket(server); + saveState(server, createInitialState()); + + return new Response(null, { + status: 101, + headers: { 'Sec-WebSocket-Protocol': 'graphql-transport-ws' }, + webSocket: client, + }); + } + + async webSocketMessage(ws: WebSocket, rawMessage: string | ArrayBuffer): Promise { + if (typeof rawMessage !== 'string') { + closeWithError(ws, CloseCode.BadRequest, 'Binary messages not supported'); + return; + } + + // Parse message using graphql-ws + let message; + try { + message = parseClientMessage(rawMessage); + } catch (err) { + closeWithError(ws, CloseCode.BadRequest, (err as Error).message); + return; + } + + const state = loadState(ws); + + // Rate limiting + const updatedRateLimit = this.checkRateLimit(state.rateLimit); + if (!updatedRateLimit) { + closeWithError(ws, CloseCode.TooManyInitialisationRequests, 'Rate limit exceeded'); + return; + } + saveState(ws, { ...state, rateLimit: updatedRateLimit }); + + // Route by message type + switch (message.type) { + case MessageType.ConnectionInit: + await this.handleConnectionInit(ws, state); + break; + + case MessageType.Subscribe: + handleSubscribe(ws, message, state, ALLOWED_CHANNELS); + break; + + case MessageType.Complete: + handleComplete(ws, message, state); + break; + + case MessageType.Ping: + sendPong(ws); + break; + + case MessageType.Pong: + // Client pong received - could clear timeout if we implement ping + break; + + default: + closeWithError(ws, CloseCode.BadRequest, 'Unknown message type'); + } + } + + async webSocketClose(): Promise { + // Cleanup handled automatically by Cloudflare + } + + async webSocketError(_ws: WebSocket, error: unknown): Promise { + console.error('[UserChannel] WebSocket error:', error); + } + + private async handleConnectionInit( + ws: WebSocket, + state: HibernatableConnectionState + ): Promise { + if (state.phase !== 'awaiting_init') { + closeWithError(ws, CloseCode.TooManyInitialisationRequests, 'Already initialized'); + return; + } + + if (isConnectionInitTimedOut(state)) { + closeWithError(ws, CloseCode.ConnectionInitialisationTimeout, 'Init timeout'); + return; + } + + if (!this.ownerId) { + closeWithError(ws, CloseCode.Forbidden, 'Forbidden'); + return; + } + + saveState(ws, { + ...state, + phase: 'ready', + userId: this.ownerId, + }); + + sendConnectionAck(ws); + } + + async publish(channel: string, event: ChannelEvent): Promise { + const webSockets = this.ctx.getWebSockets(); + + for (const ws of webSockets) { + try { + const state = loadState(ws); + if (state.phase === 'ready') { + const subscriptionId = state.subscriptions[channel]; + if (subscriptionId) { + sendNext(ws, subscriptionId, { channel: event }); + } + } + } catch (error) { + console.error('[UserChannel] Failed to send:', error); + } + } + } + + private checkRateLimit(rateLimit: RateLimitState): RateLimitState | null { + const now = Date.now(); + if (now - rateLimit.windowStart >= RATE_LIMIT.WINDOW_MS) { + return { windowStart: now, messageCount: 1 }; + } + if (rateLimit.messageCount >= RATE_LIMIT.MAX_MESSAGES) { + return null; + } + return { + windowStart: rateLimit.windowStart, + messageCount: rateLimit.messageCount + 1, + }; + } +} +``` + +--- + +## Migration Path + +### Phase 1: Add graphql-ws as Dependency + +```bash +pnpm --filter worker add graphql-ws +``` + +### Phase 2: Replace Types + +1. Remove our hand-written message types +2. Import from graphql-ws +3. Update type references + +### Phase 3: Replace Parsing/Serialization + +1. Replace `JSON.parse()` with `parseMessage()` +2. Replace `JSON.stringify()` with `stringifyMessage()` +3. Replace magic close codes with `CloseCode` enum + +### Phase 4: Refactor to Modular Structure + +1. Extract protocol utilities to `protocol.ts` +2. Extract state management to `state.ts` +3. Extract handlers to separate files + +### Phase 5: Testing + +1. Verify protocol compliance with graphql-ws client +2. Test hibernation behavior +3. Test rate limiting +4. Test error handling + +--- + +## Trade-offs + +### What We Gain + +| Benefit | Description | +|---------|-------------| +| **Protocol compliance** | Using official types and utilities ensures spec compliance | +| **Reduced maintenance** | graphql-ws handles protocol updates | +| **Better error messages** | Built-in validation provides clear errors | +| **Type safety** | Strong TypeScript types for all messages | +| **Community tested** | Battle-tested parsing and validation | + +### What We Keep + +| Feature | Description | +|---------|-------------| +| **Hibernation support** | Externalized state via serializeAttachment() | +| **Cost efficiency** | DO sleeps between messages | +| **Rate limiting** | Per-connection rate limiting | +| **Channel model** | Our pub/sub abstraction on top of graphql-ws | + +### What We Don't Use + +| graphql-ws Feature | Reason | +|--------------------|--------| +| `makeServer()` | Requires in-memory state | +| `useServer()` | Designed for ws/uWebSockets | +| GraphQL execution | We use channels, not GraphQL operations | +| `onSubscribe` hooks | We handle subscription logic ourselves | + +--- + +## Conclusion + +We can achieve the best of both worlds: + +1. **Use graphql-ws** for protocol compliance (types, parsing, codes) +2. **Implement our own state layer** for hibernation support +3. **Keep our channel-based pub/sub** for simplicity + +This approach gives us: +- ✅ Protocol-compliant implementation +- ✅ Hibernation support (cost efficiency) +- ✅ Battle-tested message handling +- ✅ Clean separation of concerns +- ✅ Future upgrade path (if graphql-ws adds hibernation support) + +The implementation effort is moderate (~2-4 hours) and the result is cleaner, more maintainable code that leverages the community's work while respecting Cloudflare's unique constraints. diff --git a/specs/library-realtime-subscriptions/instructions.md b/specs/library-realtime-subscriptions/instructions.md new file mode 100644 index 0000000..3f0cee5 --- /dev/null +++ b/specs/library-realtime-subscriptions/instructions.md @@ -0,0 +1,103 @@ +# Library Realtime Subscriptions + +## Feature Overview + +Implement real-time updates for the Library object via GraphQL subscriptions. When any change occurs in a Library (story CRUD, tag operations, associations), connected clients should receive updates automatically without polling. + +**Why:** Currently, the Library uses a pull-based model where clients must refetch to see changes. This creates stale UI states and requires manual refresh or optimistic updates. Real-time subscriptions provide instant feedback and enable future collaborative features. + +## User Stories + +### As a library user: +- When I create a story in one browser tab, I want other tabs/devices to show the updated story list and count immediately +- When I delete a story, I want the totalCount to update in real-time across all connected clients +- When I add/remove tags from a story, I want the tag associations to reflect immediately +- When I create/update/delete tags, I want the tag list to update in real-time + +### As a developer: +- I want a consistent pattern for implementing subscriptions on other features +- I want subscriptions to work with Cloudflare Workers' hibernatable WebSocket API for cost efficiency + +## Acceptance Criteria + +### Core Functionality +- [ ] GraphQL subscription endpoint available at `/graphql` alongside queries/mutations +- [ ] Clients can subscribe to Library changes using standard `graphql-ws` protocol +- [ ] Subscriptions are Library-scoped (each Library has its own subscription channel) + +### Event Coverage +- [ ] `story:create` - Fires when a new story is added to the Library +- [ ] `story:update` - Fires when a story's title, description, or URL changes +- [ ] `story:delete` - Fires when a story is removed from the Library +- [ ] `tag:create` - Fires when a new tag is created +- [ ] `tag:update` - Fires when a tag's name or color changes +- [ ] `tag:delete` - Fires when a tag is removed +- [ ] `story:tag` - Fires when tags are added to a story +- [ ] `story:untag` - Fires when tags are removed from a story +- [ ] `library:change` - Meta event with updated counts (totalStories, totalTags) + +### Frontend Integration +- [ ] Relay environment configured with WebSocket transport for subscriptions +- [ ] Library component subscribes to `library:change` for count updates +- [ ] Subscription automatically reconnects on connection loss + +### Test Case (Primary) +- [ ] When a new story is created, `totalCount` updates in real-time on all subscribed clients without refetching + +## Constraints + +### Technical +- Must use Cloudflare Workers' Hibernatable WebSocket API for cost efficiency (DOs sleep during idle connections) +- Must work with existing GraphQL Yoga setup +- Must maintain compatibility with current Relay frontend patterns +- Library DO is the "atom of coordination" - subscriptions should be managed per-Library instance + +### Protocol +- Use `graphql-ws` protocol (not legacy `subscriptions-transport-ws`) +- WebSocket endpoint should be the same `/graphql` path with protocol upgrade + +## Dependencies + +### Existing Infrastructure +- GraphQL Yoga v5.18.0 (already installed) +- `@graphql-yoga/subscription` v5.0.5 (installed but not used) +- Library Durable Object (per-user isolation) +- React Relay frontend + +### New Dependencies (Likely) +- `graphql-ws` - Client-side WebSocket transport for Relay +- Possible: `@graphql-yoga/plugin-defer-stream` for streaming support + +## Out of Scope + +- Cross-Library subscriptions (e.g., global feed of all users' stories) +- Presence indicators (who is currently viewing the Library) +- Collaborative editing (real-time co-editing of story metadata) +- Offline support / subscription replay +- Rate limiting subscription events +- Subscription authentication beyond existing session auth + +## Architecture Notes + +### Current State +- Library DO uses RPC methods for all operations +- No WebSocket handlers exist in any DO +- GraphQL Yoga configured for queries/mutations only +- Frontend uses Relay without subscription transport + +### Proposed Flow: UserChannel DO +A dedicated per-user **UserChannel DO** handles all WebSocket connections: + +``` +Browser WebSocket → Worker → UserChannel DO (WebSocket handler) + ├─ Accepts connection + ├─ Manages channel subscriptions + └─ Exposes publish(channel, event) RPC + +Library.createStory() → SQLite insert → userChannel.publish("library", event) +``` + +### Key Design Decisions (Resolved) +1. **Separate UserChannel DO** - Not Library DO. Enables reuse for notifications, presence, etc. +2. **Channel-based pub/sub** - Clients subscribe to named channels (e.g., "library") +3. **Authentication at Worker level** - Validate session before routing to UserChannel DO diff --git a/specs/library-realtime-subscriptions/plan.md b/specs/library-realtime-subscriptions/plan.md new file mode 100644 index 0000000..2755f11 --- /dev/null +++ b/specs/library-realtime-subscriptions/plan.md @@ -0,0 +1,371 @@ +# Library Realtime Subscriptions - Implementation Plan + +## Overview + +This plan implements GraphQL subscriptions for the Library feature using a dedicated **UserChannel DO** for WebSocket management. + +**Primary Test Case:** When a new story is created, `totalCount` updates in real-time on all subscribed clients. + +--- + +## Phase 1: UserChannel DO Infrastructure + +### 1.1 Create UserChannel DO Types + +**File:** `apps/worker/src/features/user-channel/types.ts` + +```typescript +// Connection state types +export interface AwaitingInitState { + state: "awaiting_init"; + connectedAt: number; +} + +export interface ReadyState { + state: "ready"; + userId: string; + subscriptions: Record; // channel -> subscriptionId +} + +export type ConnectionState = AwaitingInitState | ReadyState; + +// Generic channel event +export interface ChannelEvent { + type: string; + [key: string]: unknown; +} +``` + +- [ ] Create `types.ts` with connection state interfaces +- [ ] Export `ConnectionState`, `ChannelEvent` types + +### 1.2 Create UserChannel DO + +**File:** `apps/worker/src/features/user-channel/UserChannel.ts` + +- [ ] Create `UserChannel` class extending `DurableObject` +- [ ] Implement `setOwner(userId)` method +- [ ] Implement `fetch()` for WebSocket upgrade + - Check `Upgrade: websocket` header + - Validate `Sec-WebSocket-Protocol: graphql-transport-ws` + - Create `WebSocketPair` + - Call `ctx.acceptWebSocket(server)` + - Set initial state via `serializeAttachment()` + - Return 101 response with client WebSocket +- [ ] Implement `webSocketMessage()` handler + - Parse JSON message + - Handle `connection_init` → validate, send `connection_ack` + - Handle `subscribe` → extract channel name, register subscription + - Handle `complete` → remove subscription + - Handle `ping` → send `pong` +- [ ] Implement `webSocketClose()` handler (logging only) +- [ ] Implement `publish(channel, event)` RPC method + - Get all WebSockets via `ctx.getWebSockets()` + - Filter by channel subscription + - Send `next` message to each +- [ ] Implement `getConnectionCount()` helper +- [ ] Implement `getSubscriberCount(channel)` helper + +### 1.3 Configure UserChannel DO Binding + +**File:** `apps/worker/wrangler.jsonc` + +- [ ] Add `USER_CHANNEL` binding to `durable_objects.bindings` + +```jsonc +{ + "name": "USER_CHANNEL", + "class_name": "UserChannel" +} +``` + +### 1.4 Export UserChannel from Worker + +**File:** `apps/worker/src/index.ts` + +- [ ] Import `UserChannel` class +- [ ] Add to exports: `export { UserChannel }` + +### 1.5 Add WebSocket Upgrade Routing + +**File:** `apps/worker/src/index.ts` + +- [ ] Add middleware before GraphQL Yoga for `/graphql` route +- [ ] Check for `Upgrade: websocket` header +- [ ] Validate session via Pasaport +- [ ] If unauthenticated, return 401 +- [ ] Route to `USER_CHANNEL.get(idFromName(userId))` +- [ ] Forward request via `userChannel.fetch(request)` + +### 1.6 Update Env Type + +**File:** `apps/worker/src/index.ts` or `worker-configuration.d.ts` + +- [ ] Add `USER_CHANNEL: DurableObjectNamespace` to Env interface + +--- + +## Phase 2: Library DO Integration + +### 2.1 Create Library Event Types + +**File:** `apps/worker/src/features/library/subscription-types.ts` + +```typescript +export interface StoryPayload { + id: string; + url: string; + title: string; + description: string | null; + createdAt: string; +} + +export interface TagPayload { + id: string; + name: string; + color: string; + createdAt: string; +} + +export type LibraryEvent = + | {type: "story:create"; story: StoryPayload} + | {type: "story:update"; story: StoryPayload} + | {type: "story:delete"; deletedStoryId: string} + | {type: "tag:create"; tag: TagPayload} + | {type: "tag:update"; tag: TagPayload} + | {type: "tag:delete"; deletedTagId: string} + | {type: "story:tag"; storyId: string; tagIds: string[]} + | {type: "story:untag"; storyId: string; tagIds: string[]} + | {type: "library:change"; totalStories: number; totalTags: number}; +``` + +- [ ] Create `subscription-types.ts` with event types +- [ ] Export `LibraryEvent`, `StoryPayload`, `TagPayload` + +### 2.2 Add Publish Helpers to Library DO + +**File:** `apps/worker/src/features/library/Library.ts` + +- [ ] Add `ownerId` private field (load in constructor) +- [ ] Add `getUserChannel()` helper method +- [ ] Add `publishToLibrary(event)` helper method (try/catch, best-effort) +- [ ] Add `publishLibraryChange()` helper (counts query + publish) +- [ ] Add `toStoryPayload()` helper for serialization +- [ ] Add `toTagPayload()` helper for serialization + +### 2.3 Add Publish Calls to Story Methods + +**File:** `apps/worker/src/features/library/Library.ts` + +- [ ] `createStory()` - Add `publishToLibrary({type: "story:create", ...})` + `publishLibraryChange()` +- [ ] `updateStory()` - Add `publishToLibrary({type: "story:update", ...})` +- [ ] `deleteStory()` - Add `publishToLibrary({type: "story:delete", ...})` + `publishLibraryChange()` + +### 2.4 Add Publish Calls to Tag Methods + +**File:** `apps/worker/src/features/library/Library.ts` + +- [ ] `createTag()` - Add `publishToLibrary({type: "tag:create", ...})` + `publishLibraryChange()` +- [ ] `updateTag()` - Add `publishToLibrary({type: "tag:update", ...})` +- [ ] `deleteTag()` - Add `publishToLibrary({type: "tag:delete", ...})` + `publishLibraryChange()` + +### 2.5 Add Publish Calls to Tagging Methods + +**File:** `apps/worker/src/features/library/Library.ts` + +- [ ] `tagStory()` - Add `publishToLibrary({type: "story:tag", ...})` +- [ ] `untagStory()` - Add `publishToLibrary({type: "story:untag", ...})` +- [ ] `setStoryTags()` - Add publish calls for added/removed tags + +--- + +## Phase 3: Frontend Integration + +### 3.1 Add graphql-ws Dependency + +**File:** `apps/kamp-us/package.json` + +- [ ] Add `"graphql-ws": "^5.16.0"` to dependencies +- [ ] Run `pnpm install` + +### 3.2 Create Subscription Client + +**File:** `apps/kamp-us/src/relay/environment.ts` + +- [ ] Import `createClient` from `graphql-ws` +- [ ] Create `createSubscriptionClient()` function + - Build WebSocket URL from `window.location` + - Configure `retryAttempts: Infinity` + - Configure exponential backoff in `retryWait` + - Add connection event logging +- [ ] Create `getSubscriptionClient()` singleton getter +- [ ] Create `resetSubscriptionClient()` for auth changes + +### 3.3 Add Subscribe Function to Relay Network + +**File:** `apps/kamp-us/src/relay/environment.ts` + +- [ ] Create `subscribe: SubscribeFunction` + - Return `Observable.create()` + - Use `client.subscribe()` from graphql-ws + - Map responses to Relay format +- [ ] Update `Network.create(fetchQuery, subscribe)` to include subscribe + +### 3.4 Reset Subscription on Logout + +**File:** `apps/kamp-us/src/auth/AuthContext.tsx` + +- [ ] Import `resetSubscriptionClient` from environment +- [ ] Call `resetSubscriptionClient()` in logout handler + +### 3.5 Create Library Subscription Hook + +**File:** `apps/kamp-us/src/pages/Library.tsx` + +- [ ] Add `LibraryChannelSubscription` GraphQL subscription + ```graphql + subscription LibraryChannelSubscription { + channel(name: "library") { + ... on LibraryChangeEvent { + type + totalStories + totalTags + } + } + } + ``` +- [ ] Create `useLibrarySubscription(connectionId)` hook + - Use `useSubscription` from react-relay + - Handle `library:change` events in `updater` + - Update `totalCount` on connection record + +### 3.6 Integrate Subscription in Library Page + +**File:** `apps/kamp-us/src/pages/Library.tsx` + +- [ ] Get `__id` from stories connection for `connectionId` +- [ ] Call `useLibrarySubscription(connectionId)` in `AuthenticatedLibrary` + +### 3.7 Run Relay Compiler + +- [ ] Run `pnpm --filter kamp-us run relay` to generate subscription artifacts + +--- + +## Phase 4: GraphQL Schema (Optional) + +> Note: The subscription schema is optional since events flow through UserChannel DO directly, not through GraphQL Yoga. However, defining the schema enables introspection and Relay compiler validation. + +### 4.1 Add Subscription Schema Types + +**File:** `apps/worker/src/index.ts` + +- [ ] Define `StoryPayloadType` Effect Schema +- [ ] Define `TagPayloadType` Effect Schema +- [ ] Define event types (StoryCreateEvent, etc.) +- [ ] Define `LibraryChannelEvent` union +- [ ] Create subscription resolver placeholder + +--- + +## Phase 5: Testing & Polish + +### 5.1 Manual Testing + +- [ ] Start dev servers (`turbo run dev`) +- [ ] Open Library page in two browser tabs +- [ ] Create story in Tab A +- [ ] Verify Tab B shows updated `totalCount` without refresh +- [ ] Check browser Network tab for WebSocket messages +- [ ] Test reconnection by stopping/starting worker + +### 5.2 Add Unit Tests + +**File:** `apps/worker/test/user-channel.spec.ts` + +- [ ] Test: WebSocket upgrade with valid auth returns 101 +- [ ] Test: WebSocket upgrade without auth returns 401 +- [ ] Test: Invalid protocol returns 400 +- [ ] Test: ConnectionInit → ConnectionAck flow +- [ ] Test: Subscribe registers channel +- [ ] Test: Publish sends to subscribers only + +### 5.3 Error Handling + +- [ ] Verify publish errors don't break mutations +- [ ] Verify WebSocket errors close connection gracefully +- [ ] Verify reconnection works after disconnect + +### 5.4 Documentation + +- [ ] Update CLAUDE.md with UserChannel patterns if needed +- [ ] Mark feature as complete in `specs/README.md` + +--- + +## File Checklist + +### New Files +- [ ] `apps/worker/src/features/user-channel/UserChannel.ts` +- [ ] `apps/worker/src/features/user-channel/types.ts` +- [ ] `apps/worker/src/features/library/subscription-types.ts` +- [ ] `apps/worker/test/user-channel.spec.ts` + +### Modified Files +- [ ] `apps/worker/wrangler.jsonc` - Add USER_CHANNEL binding +- [ ] `apps/worker/src/index.ts` - Export UserChannel, add WebSocket routing +- [ ] `apps/worker/src/features/library/Library.ts` - Add publish calls +- [ ] `apps/kamp-us/package.json` - Add graphql-ws +- [ ] `apps/kamp-us/src/relay/environment.ts` - Add subscription client +- [ ] `apps/kamp-us/src/pages/Library.tsx` - Add subscription hook +- [ ] `apps/kamp-us/src/auth/AuthContext.tsx` - Reset subscription on logout + +--- + +## Implementation Order + +``` +Phase 1: UserChannel DO Infrastructure + 1.1 types.ts + 1.2 UserChannel.ts + 1.3 wrangler.jsonc + 1.4 Export from index.ts + 1.5 WebSocket routing + 1.6 Env type + +Phase 2: Library Integration + 2.1 subscription-types.ts + 2.2 Publish helpers + 2.3-2.5 Publish calls in methods + +Phase 3: Frontend + 3.1 graphql-ws dependency + 3.2-3.3 Subscription client + 3.4 Reset on logout + 3.5-3.6 Subscription hook + 3.7 Relay compiler + +Phase 4: Schema (optional) + 4.1 Subscription schema types + +Phase 5: Testing + 5.1 Manual testing + 5.2 Unit tests + 5.3 Error handling + 5.4 Documentation +``` + +--- + +## Success Criteria + +- [ ] WebSocket connection established at `/graphql` +- [ ] `graphql-ws` protocol handshake succeeds +- [ ] Unauthenticated requests rejected with 401 +- [ ] Subscribe to "library" channel works +- [ ] `story:create` event received on story creation +- [ ] `library:change` event includes updated counts +- [ ] `totalCount` updates in Relay store without refetch +- [ ] Two tabs show synced `totalCount` after mutation +- [ ] Reconnection works after disconnect +- [ ] DO hibernates with idle connections diff --git a/specs/library-realtime-subscriptions/requirements.md b/specs/library-realtime-subscriptions/requirements.md new file mode 100644 index 0000000..a8beda2 --- /dev/null +++ b/specs/library-realtime-subscriptions/requirements.md @@ -0,0 +1,376 @@ +# Library Realtime Subscriptions - Requirements + +## 1. Overview + +This document specifies the requirements for implementing real-time GraphQL subscriptions for the Library feature. The system shall enable clients to receive automatic updates when Library data changes, eliminating the need for polling or manual refresh. + +**Reference:** [instructions.md](./instructions.md) + +## 2. Functional Requirements + +### 2.1 Subscription Establishment + +| ID | Requirement | Priority | +| ------ | ---------------------------------------------------------------------------------------- | -------- | +| FR-1.1 | System SHALL accept WebSocket connections at `/graphql` endpoint via protocol upgrade | Must | +| FR-1.2 | System SHALL support the `graphql-ws` subprotocol (NOT legacy `subscriptions-transport-ws`) | Must | +| FR-1.3 | System SHALL route subscription connections to the appropriate Library DO based on authenticated user | Must | +| FR-1.4 | System SHALL reject subscription connections from unauthenticated clients | Must | +| FR-1.5 | System SHALL support multiple concurrent WebSocket connections per Library | Must | +| FR-1.6 | System SHALL maintain connection state that survives DO hibernation | Must | + +### 2.2 Subscription Lifecycle + +| ID | Requirement | Priority | +| ------ | ------------------------------------------------------------------------ | -------- | +| FR-2.1 | System SHALL process `ConnectionInit` message and validate authentication | Must | +| FR-2.2 | System SHALL respond with `ConnectionAck` on successful authentication | Must | +| FR-2.3 | System SHALL respond with `ConnectionError` on authentication failure | Must | +| FR-2.4 | System SHALL process `Subscribe` messages to register subscription interest | Must | +| FR-2.5 | System SHALL process `Complete` messages to unsubscribe from events | Must | +| FR-2.6 | System SHALL gracefully handle `Ping`/`Pong` keep-alive messages | Should | +| FR-2.7 | System SHALL clean up subscription state on WebSocket close | Must | + +### 2.3 Event Types and Payloads + +The system SHALL emit the following subscription events: + +| ID | Event Type | Trigger | Payload | +| ------ | ---------------- | ------------------------------------ | ------------------------------------ | +| FR-3.1 | `story:create` | New story added to Library | Story node with all fields | +| FR-3.2 | `story:update` | Story title, description, or URL changes | Updated Story node | +| FR-3.3 | `story:delete` | Story removed from Library | Deleted story global ID | +| FR-3.4 | `tag:create` | New tag created in Library | Tag node with all fields | +| FR-3.5 | `tag:update` | Tag name or color changes | Updated Tag node | +| FR-3.6 | `tag:delete` | Tag removed from Library | Deleted tag global ID | +| FR-3.7 | `story:tag` | Tags added to a story | Story ID, added tag IDs | +| FR-3.8 | `story:untag` | Tags removed from a story | Story ID, removed tag IDs | +| FR-3.9 | `library:change` | Any of the above events | Updated counts (totalStories, totalTags) | + +### 2.4 Library-Scoped Channels + +| ID | Requirement | Priority | +| ------ | ------------------------------------------------------------------ | -------- | +| FR-4.1 | Subscriptions SHALL be scoped to individual Library instances | Must | +| FR-4.2 | Events SHALL only broadcast to subscribers of the affected Library | Must | +| FR-4.3 | System SHALL NOT leak events across different users' Libraries | Must | +| FR-4.4 | Library DO SHALL manage its own subscriber connections directly | Must | + +### 2.5 GraphQL Subscription Schema + +| ID | Requirement | Priority | +| ------ | ------------------------------------------------------------------------------ | -------- | +| FR-5.1 | Schema SHALL define `Subscription` type with Library event fields | Must | +| FR-5.2 | Schema SHALL define union types for event payloads | Must | +| FR-5.3 | All subscription event nodes SHALL use global IDs consistent with queries/mutations | Must | +| FR-5.4 | Subscription operations SHALL be compilable by Relay compiler | Must | + +### 2.6 Frontend Integration + +| ID | Requirement | Priority | +| ------ | ---------------------------------------------------------------------------- | -------- | +| FR-6.1 | Relay environment SHALL support WebSocket transport for subscriptions | Must | +| FR-6.2 | Frontend SHALL establish subscription on Library page mount | Must | +| FR-6.3 | Frontend SHALL handle subscription events to update Relay store | Must | +| FR-6.4 | Frontend SHALL display updated `totalCount` without manual refetch | Must | +| FR-6.5 | Frontend SHALL automatically reconnect on connection loss | Must | +| FR-6.6 | Frontend SHALL maintain subscription across browser tabs (per-tab connection) | Should | + +## 3. Non-Functional Requirements + +### 3.1 Performance + +| ID | Requirement | Target | Priority | +| ------- | ------------------------------------------------------------ | ----------------- | -------- | +| NFR-1.1 | Event delivery latency from mutation to subscriber | < 100ms | Must | +| NFR-1.2 | WebSocket connection establishment time | < 500ms | Must | +| NFR-1.3 | Memory overhead per idle WebSocket connection | < 1KB (hibernated) | Must | +| NFR-1.4 | Subscriber notification SHALL be non-blocking for mutations | - | Must | + +### 3.2 Reliability + +| ID | Requirement | Priority | +| ------- | ----------------------------------------------------------------------------- | -------- | +| NFR-2.1 | System SHALL deliver events at-least-once to connected subscribers | Must | +| NFR-2.2 | System SHALL NOT guarantee message ordering across concurrent mutations | - | +| NFR-2.3 | System SHALL handle DO wake-from-hibernation without event loss for new events | Must | +| NFR-2.4 | System SHALL survive DO relocation without client-visible disruption | Should | +| NFR-2.5 | Frontend SHALL implement exponential backoff for reconnection attempts | Must | + +### 3.3 Cost Efficiency + +| ID | Requirement | Priority | +| ------- | ------------------------------------------------------------------------------ | -------- | +| NFR-3.1 | System SHALL use Cloudflare Hibernatable WebSocket API | Must | +| NFR-3.2 | DOs SHALL hibernate during idle periods with no active messages | Must | +| NFR-3.3 | System SHALL NOT poll or keep-alive more frequently than protocol requires | Must | +| NFR-3.4 | Connection metadata SHALL be stored via `serializeAttachment` to survive hibernation | Must | + +### 3.4 Security + +| ID | Requirement | Priority | +| ------- | -------------------------------------------------------------------- | -------- | +| NFR-4.1 | Authentication SHALL be validated on WebSocket upgrade request | Must | +| NFR-4.2 | Session validation SHALL use existing Pasaport authentication | Must | +| NFR-4.3 | Invalid/expired sessions SHALL result in connection termination | Must | +| NFR-4.4 | Subscription data SHALL only include data user is authorized to see | Must | +| NFR-4.5 | System SHALL NOT expose internal IDs in subscription payloads | Must | + +### 3.5 Scalability + +| ID | Requirement | Priority | +| ------- | ------------------------------------------------------------------------ | -------- | +| NFR-5.1 | System SHALL support up to 100 concurrent WebSocket connections per Library | Must | +| NFR-5.2 | Broadcast operations SHALL scale linearly with subscriber count | Must | +| NFR-5.3 | System SHALL NOT create global singleton DOs for subscription routing | Must | + +## 4. Technical Requirements + +### 4.1 Protocol Specification + +| ID | Requirement | Details | +| ------ | ----------------------- | -------------------------------- | +| TR-1.1 | WebSocket subprotocol | `graphql-transport-ws` | +| TR-1.2 | Message format | JSON per graphql-ws specification | +| TR-1.3 | Connection init timeout | 10 seconds | +| TR-1.4 | Ping/pong interval | 30 seconds (optional) | + +### 4.2 graphql-ws Message Types + +The system SHALL support the following message types: + +**Client to Server:** +- `ConnectionInit` - Initial authentication payload +- `Subscribe` - Start subscription with operation and variables +- `Complete` - Stop subscription +- `Ping` - Keep-alive ping + +**Server to Client:** +- `ConnectionAck` - Connection accepted +- `Next` - Subscription data event +- `Error` - Subscription error +- `Complete` - Subscription ended +- `Pong` - Keep-alive pong + +### 4.3 Integration Points + +| Component | Integration Requirement | +| ----------------- | ------------------------------------------------------ | +| UserChannel DO | New DO: Handle WebSocket connections and channel subscriptions | +| UserChannel DO | Implement `fetch()` handler for WebSocket upgrade | +| UserChannel DO | Implement `webSocketMessage()` for graphql-ws protocol | +| UserChannel DO | Implement `webSocketClose()` for cleanup | +| UserChannel DO | Expose `publish(channel, event)` RPC for other DOs | +| Library DO | Call `userChannel.publish("library", event)` on mutations | +| Worker entry | Route WebSocket upgrades to UserChannel DO | +| Relay environment | Add `subscriptionFunction` to Network.create | +| graphql-ws client | Establish connection with auth token | + +### 4.4 Data Format Specifications + +#### Subscription Query Format + +```graphql +subscription LibraryChanges { + libraryChanged { + __typename + ... on StoryCreatedEvent { + story { id title url description createdAt } + } + ... on StoryUpdatedEvent { + story { id title url description } + } + ... on StoryDeletedEvent { + deletedStoryId + } + ... on TagCreatedEvent { + tag { id name color } + } + ... on TagUpdatedEvent { + tag { id name color } + } + ... on TagDeletedEvent { + deletedTagId + } + ... on StoryTaggedEvent { + storyId + tagIds + } + ... on StoryUntaggedEvent { + storyId + tagIds + } + ... on LibraryMetaChangedEvent { + totalStories + totalTags + } + } +} +``` + +#### Event Payload Example (JSON over WebSocket) + +```json +{ + "id": "subscription-1", + "type": "next", + "payload": { + "data": { + "libraryChanged": { + "__typename": "StoryCreatedEvent", + "story": { + "id": "U3Rvcnk6c3RvcnlfMTIz", + "title": "New Story", + "url": "https://example.com", + "description": null, + "createdAt": "2025-01-15T10:30:00Z" + } + } + } + } +} +``` + +### 4.5 Connection State (Serialized Attachment) + +```typescript +interface SubscriptionConnectionState { + userId: string; // Authenticated user ID + subscriptionId: string; // Client-provided subscription ID + subscribedAt: number; // Timestamp for debugging +} +``` + +## 5. Constraints + +### 5.1 Technical Constraints + +| ID | Constraint | +| --- | --------------------------------------------------------------------- | +| C-1 | MUST use Cloudflare Workers runtime (no Node.js APIs) | +| C-2 | MUST use Hibernatable WebSocket API for cost efficiency | +| C-3 | MUST integrate with existing GraphQL Yoga setup | +| C-4 | MUST maintain Relay compatibility for frontend | +| C-5 | UserChannel DO handles WebSocket connections (per-user, reusable) | +| C-6 | Library DO publishes events via UserChannel.publish() RPC | +| C-7 | WebSocket connections are tied to UserChannel DO instance | + +### 5.2 Protocol Constraints + +| ID | Constraint | +| ---- | ------------------------------------------------------------- | +| C-8 | MUST use `graphql-ws` protocol (NOT `subscriptions-transport-ws`) | +| C-9 | WebSocket endpoint MUST be same `/graphql` path with upgrade | +| C-10 | MUST support protocol upgrade negotiation | + +### 5.3 Scope Constraints (Out of Scope) + +| ID | Explicitly Excluded | +| ----- | ----------------------------------------------- | +| OOS-1 | Cross-Library subscriptions (global feeds) | +| OOS-2 | Presence indicators (who is viewing) | +| OOS-3 | Collaborative editing (real-time co-editing) | +| OOS-4 | Offline support / subscription replay | +| OOS-5 | Rate limiting subscription events | +| OOS-6 | Subscription authentication beyond session auth | + +## 6. Assumptions + +| ID | Assumption | +| --- | ---------------------------------------------------------------------- | +| A-1 | Users have stable internet connections (no offline-first requirements) | +| A-2 | Session tokens can be validated via existing Pasaport flow | +| A-3 | GraphQL Yoga's subscription primitives work with Cloudflare Workers | +| A-4 | graphql-ws client library is compatible with browser WebSocket API | +| A-5 | Relay compiler supports subscription operations in current version | +| A-6 | WebSocket connections are per-browser-tab (not shared) | + +## 7. Dependencies + +### 7.1 Existing Infrastructure + +| Dependency | Version | Usage | +| ---------------------------- | ------- | ----------------------------------------- | +| GraphQL Yoga | 5.18.0 | GraphQL runtime with subscription support | +| @graphql-yoga/subscription | 5.0.5 | PubSub primitives (installed, unused) | +| Library DO | - | Per-user state management | +| Pasaport DO | - | Session validation | +| React Relay | 20.1.1 | Frontend GraphQL client | + +### 7.2 New Dependencies (Required) + +| Dependency | Purpose | +| ------------ | ----------------------------------------- | +| graphql-ws | Client-side WebSocket transport for Relay | + +### 7.3 Cloudflare APIs + +| API | Purpose | +| ---------------------------- | -------------------------------------- | +| WebSocketPair | Create client/server WebSocket pair | +| ctx.acceptWebSocket() | Accept hibernatable WebSocket | +| ctx.getWebSockets() | Get all connected WebSockets | +| ws.serializeAttachment() | Store connection state for hibernation | +| ws.deserializeAttachment() | Restore connection state after wake | +| webSocketMessage() handler | Process messages after hibernation | +| webSocketClose() handler | Clean up on disconnect | + +## 8. Acceptance Criteria + +### 8.1 Primary Test Case + +**Scenario:** Real-time totalCount update across clients + +**Given:** +- User has two browser tabs open on Library page +- Both tabs have active subscription connections + +**When:** +- User creates a new story in Tab A + +**Then:** +- Tab A shows incremented totalCount (via mutation response) +- Tab B shows incremented totalCount (via subscription event) +- No manual refresh required in Tab B +- Latency from creation to Tab B update < 200ms + +### 8.2 Validation Checklist + +- [ ] WebSocket upgrade works at `/graphql` endpoint +- [ ] `graphql-ws` protocol handshake succeeds +- [ ] Unauthenticated connections are rejected +- [ ] `story:create` event fires on story creation +- [ ] `story:update` event fires on story modification +- [ ] `story:delete` event fires on story deletion +- [ ] `tag:create/update/delete` events fire appropriately +- [ ] `story:tag/untag` events fire on tag associations +- [ ] `library:change` event includes updated counts +- [ ] Events only reach subscribers of affected Library +- [ ] DO hibernates with idle connections +- [ ] DO wakes correctly on new messages +- [ ] Frontend reconnects automatically after disconnect +- [ ] Relay store updates from subscription events + +## 9. Traceability + +| Requirement | User Story | +| ----------- | ------------------------------------------------------------------ | +| FR-1.x | "I want other tabs/devices to show updated story list immediately" | +| FR-3.1-3.3 | Story CRUD events for real-time updates | +| FR-3.4-3.6 | Tag CRUD events for real-time updates | +| FR-3.7-3.8 | Tag association events | +| FR-3.9 | totalCount updates in real-time | +| FR-6.5 | "Subscription automatically reconnects on connection loss" | +| NFR-3.x | "Hibernatable WebSocket API for cost efficiency" | +| TR-1.x | "`graphql-ws` protocol (not legacy)" | + +--- + +## Critical Files for Implementation + +- `apps/worker/src/features/user-channel/UserChannel.ts` - New DO for WebSocket/channel management +- `apps/worker/src/features/library/Library.ts` - Add publish calls to existing CRUD methods +- `apps/worker/src/index.ts` - WebSocket upgrade routing, export UserChannel +- `apps/worker/wrangler.jsonc` - Add USER_CHANNEL DO binding +- `apps/kamp-us/src/relay/environment.ts` - graphql-ws client integration +- `apps/kamp-us/src/pages/Library.tsx` - Subscription hook