diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 10b6a70..0c8ea52 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -3,3 +3,11 @@ export enum Role { ADMIN, GROUP_MANAGER } + +export const SSEvents = { + item: { + update: "item_update", + create: "item_create", + delete: "item_delete" + } +}; diff --git a/src/lib/server/events/emitters.ts b/src/lib/server/events/emitters.ts new file mode 100644 index 0000000..325a6a0 --- /dev/null +++ b/src/lib/server/events/emitters.ts @@ -0,0 +1,3 @@ +import { EventEmitter } from "node:events"; + +export const itemEmitter = new EventEmitter(); diff --git a/src/lib/server/events/sse.ts b/src/lib/server/events/sse.ts new file mode 100644 index 0000000..b4bcc4a --- /dev/null +++ b/src/lib/server/events/sse.ts @@ -0,0 +1,36 @@ +// https://github.com/sanrafa/sveltekit-sse-example/tree/main +import type EventEmitter from "node:events"; + +export function createSSE(retry = 0) { + const { readable, writable } = new TransformStream({ + start(ctr) { + if (retry > 0) ctr.enqueue(`retry: ${retry}\n\n`); + }, + transform({ event, data }, ctr) { + let msg = data?.id ? `id: ${String(data.id)}\n` : ": hi\n\n"; + if (event) msg += `event: ${event}\n`; + if (typeof data === "string") { + msg += "data: " + data.trim().replace(/\n+/gm, "\ndata: ") + "\n\n"; + } else { + msg += `data: ${JSON.stringify(data)}\n\n`; + } + ctr.enqueue(msg); + } + }); + + const writer = writable.getWriter(); + + return { + readable, + async subscribeToEvent(emitter: EventEmitter, event: string) { + function listener(data: any) { + writer.write({ event, data }); + } + emitter.on(event, listener); + await writer.closed.catch((e) => { + if (e) console.error(e); + }); + emitter.off(event, listener); + } + }; +} diff --git a/src/routes/api/items/+server.ts b/src/routes/api/items/+server.ts index 776ccf8..75144f8 100644 --- a/src/routes/api/items/+server.ts +++ b/src/routes/api/items/+server.ts @@ -1,9 +1,10 @@ -import { Role } from "$lib/schema"; +import { Role, SSEvents } from "$lib/schema"; import { client } from "$lib/server/prisma"; import { error } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; import { _authCheck } from "../groups/[groupId]/auth"; import { tryDeleteImage } from "$lib/server/image-util"; +import { itemEmitter } from "$lib/server/events/emitters"; export const DELETE: RequestHandler = async ({ locals, request }) => { const groupId = new URL(request.url).searchParams.get("groupId"); @@ -39,6 +40,7 @@ export const DELETE: RequestHandler = async ({ locals, request }) => { if (item.image_url) { await tryDeleteImage(item.image_url); } + itemEmitter.emit(SSEvents.item.delete, item); } const deletedItems = await client.item.deleteMany({ diff --git a/src/routes/api/items/[itemId]/+server.ts b/src/routes/api/items/[itemId]/+server.ts index fcbec4e..d5c1a17 100644 --- a/src/routes/api/items/[itemId]/+server.ts +++ b/src/routes/api/items/[itemId]/+server.ts @@ -1,4 +1,6 @@ +import { SSEvents } from "$lib/schema"; import { getConfig } from "$lib/server/config"; +import { itemEmitter } from "$lib/server/events/emitters"; import { getActiveMembership } from "$lib/server/group-membership"; import { tryDeleteImage } from "$lib/server/image-util"; import { client } from "$lib/server/prisma"; @@ -66,6 +68,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { id: parseInt(params.itemId) }, select: { + id: true, addedBy: { select: { username: true @@ -75,6 +78,8 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { } }); + itemEmitter.emit(SSEvents.item.delete, item); + if (item.image_url) { await tryDeleteImage(item.image_url); } @@ -136,6 +141,26 @@ export const PATCH: RequestHandler = async ({ params, locals, request }) => { // @ts-expect-error params.itemId is checked in a previous function id: parseInt(params.itemId) }, + include: { + addedBy: { + select: { + username: true, + name: true + } + }, + pledgedBy: { + select: { + username: true, + name: true + } + }, + user: { + select: { + username: true, + name: true + } + } + }, data }); @@ -143,6 +168,8 @@ export const PATCH: RequestHandler = async ({ params, locals, request }) => { await tryDeleteImage(item.image_url); } + itemEmitter.emit(SSEvents.item.update, updatedItem); + return new Response(JSON.stringify(updatedItem), { status: 200 }); } catch (e) { error(404, "item id not found"); diff --git a/src/routes/api/items/updates/+server.ts b/src/routes/api/items/updates/+server.ts new file mode 100644 index 0000000..ba2cf62 --- /dev/null +++ b/src/routes/api/items/updates/+server.ts @@ -0,0 +1,17 @@ +import { SSEvents } from "$lib/schema"; +import { itemEmitter } from "$lib/server/events/emitters"; +import { createSSE } from "$lib/server/events/sse"; +import type { RequestHandler } from "./$types"; + +export const GET = (async () => { + const { readable, subscribeToEvent } = createSSE(); + subscribeToEvent(itemEmitter, SSEvents.item.update); + subscribeToEvent(itemEmitter, SSEvents.item.create); + subscribeToEvent(itemEmitter, SSEvents.item.delete); + return new Response(readable, { + headers: { + "cache-control": "no-cache", + "content-type": "text/event-stream" + } + }); +}) satisfies RequestHandler; diff --git a/src/routes/wishlists/[username]/+page.server.ts b/src/routes/wishlists/[username]/+page.server.ts index 5b25b9d..4993175 100644 --- a/src/routes/wishlists/[username]/+page.server.ts +++ b/src/routes/wishlists/[username]/+page.server.ts @@ -6,14 +6,12 @@ import type { Prisma } from "@prisma/client"; import { getConfig } from "$lib/server/config"; import { getActiveMembership } from "$lib/server/group-membership"; -export const load: PageServerLoad = async ({ locals, params, depends, url }) => { +export const load: PageServerLoad = async ({ locals, params, url }) => { const session = await locals.validate(); if (!session) { redirect(302, `/login?ref=/wishlists/${params.username}`); } - depends("list:poll"); - const activeMembership = await getActiveMembership(session.user); const config = await getConfig(activeMembership.groupId); diff --git a/src/routes/wishlists/[username]/+page.svelte b/src/routes/wishlists/[username]/+page.svelte index bafc2ac..c2dc399 100644 --- a/src/routes/wishlists/[username]/+page.svelte +++ b/src/routes/wishlists/[username]/+page.svelte @@ -2,9 +2,8 @@ import type { PageData } from "./$types"; import ItemCard from "$lib/components/wishlists/ItemCard/ItemCard.svelte"; import ClaimFilterChip from "$lib/components/wishlists/chips/ClaimFilter.svelte"; - import { goto, invalidate } from "$app/navigation"; + import { goto } from "$app/navigation"; import { page } from "$app/stores"; - import { listen, idle } from "$lib/stores/idle"; import { onDestroy, onMount } from "svelte"; import { flip } from "svelte/animate"; import { quintOut } from "svelte/easing"; @@ -13,8 +12,11 @@ import empty from "$lib/assets/no_wishes.svg"; import SortBy from "$lib/components/wishlists/chips/SortBy.svelte"; import { hash, hashItems, viewedItems } from "$lib/stores/viewed-items"; + import { SSEvents } from "$lib/schema"; export let data: PageData; + type Item = PageData["items"][0]; + let items: Item[] = data.items; const [send, receive] = crossfade({ duration: (d) => Math.sqrt(d * 200), @@ -34,38 +36,47 @@ } }); - // Poll for updates - listen({ - timer: 5 * 60 * 1000 // 5 minutes - }); - - let polling = true; - let pollTimeout: number; + let eventSource: EventSource; + onMount(async () => { + const userHash = await hash(data.listOwner.id + data.groupId); + $viewedItems[userHash] = await hashItems(items); - const pollUpdate = () => { - if ($idle) { - polling = false; - return; - } + subscribeToEvents(); + }); + onDestroy(() => eventSource?.close()); + + const subscribeToEvents = () => { + eventSource = new EventSource("/api/items/updates"); + eventSource.addEventListener(SSEvents.item.update, (e) => { + const message = JSON.parse(e.data) as Item; + updateItems(message); + }); + eventSource.addEventListener(SSEvents.item.delete, (e) => { + const message = JSON.parse(e.data) as Item; + removeItem(message); + }); + eventSource.addEventListener(SSEvents.item.create, (e) => { + const message = JSON.parse(e.data) as Item; + addItem(message); + }); + }; - //@ts-expect-error setTimeout returns number in web - pollTimeout = setTimeout(async () => { - await invalidate("list:poll"); - pollUpdate(); - }, 5000); + const updateItems = (updatedItem: Item) => { + items = items.map((item) => { + if (item.id === updatedItem.id) { + return { ...item, ...updatedItem }; + } + return item; + }); }; - onMount(async () => { - const userHash = await hash(data.listOwner.id + data.groupId); - $viewedItems[userHash] = await hashItems(data.items); - pollUpdate(); - }); - onDestroy(() => clearTimeout(pollTimeout)); + const removeItem = (removedItem: Item) => { + items = items.filter((item) => item.id !== removedItem.id); + }; - $: if (!$idle && !polling) { - polling = true; - pollUpdate(); - } + const addItem = (addedItem: Item) => { + items = [...items, addedItem]; + }; {#if data.approvals.length > 0} @@ -80,7 +91,7 @@
No wishes yet
@@ -97,20 +108,20 @@