Skip to content

Commit

Permalink
change item polling to use server sent events (#98)
Browse files Browse the repository at this point in the history
  • Loading branch information
cmintey authored Jan 20, 2024
1 parent c4a5d2b commit 61b8ad8
Show file tree
Hide file tree
Showing 10 changed files with 200 additions and 50 deletions.
8 changes: 8 additions & 0 deletions src/lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@ export enum Role {
ADMIN,
GROUP_MANAGER
}

export const SSEvents = {
item: {
update: "item_update",
create: "item_create",
delete: "item_delete"
}
};
3 changes: 3 additions & 0 deletions src/lib/server/events/emitters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { EventEmitter } from "node:events";

export const itemEmitter = new EventEmitter();
36 changes: 36 additions & 0 deletions src/lib/server/events/sse.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
}
4 changes: 3 additions & 1 deletion src/routes/api/items/+server.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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({
Expand Down
27 changes: 27 additions & 0 deletions src/routes/api/items/[itemId]/+server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -66,6 +68,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
id: parseInt(params.itemId)
},
select: {
id: true,
addedBy: {
select: {
username: true
Expand All @@ -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);
}
Expand Down Expand Up @@ -136,13 +141,35 @@ 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
});

if (deleteOldImage && item.image_url) {
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");
Expand Down
17 changes: 17 additions & 0 deletions src/routes/api/items/updates/+server.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 1 addition & 3 deletions src/routes/wishlists/[username]/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
77 changes: 44 additions & 33 deletions src/routes/wishlists/[username]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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),
Expand All @@ -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];
};
</script>

{#if data.approvals.length > 0}
Expand All @@ -80,7 +91,7 @@
<hr class="pb-2" />
{/if}

{#if data.items.length === 0}
{#if items.length === 0}
<div class="flex flex-col items-center justify-center space-y-4 pt-4">
<img class="w-3/4 md:w-1/3" alt="Two people looking in an empty box" src={empty} />
<p class="text-2xl">No wishes yet</p>
Expand All @@ -97,20 +108,20 @@
<!-- items -->
<div class="flex flex-col space-y-4">
{#if data.listOwner.isMe}
{#each data.items as item (item.id)}
{#each items as item (item.id)}
<div in:receive={{ key: item.id }} out:send|local={{ key: item.id }} animate:flip={{ duration: 200 }}>
<ItemCard {item} showClaimedName={data.showClaimedName} user={data.user} />
</div>
{/each}
{:else}
<!-- unclaimed-->
{#each data.items.filter((item) => !item.pledgedById) as item (item.id)}
{#each items.filter((item) => !item.pledgedById) as item (item.id)}
<div in:receive={{ key: item.id }} out:send|local={{ key: item.id }} animate:flip={{ duration: 200 }}>
<ItemCard {item} showClaimedName={data.showClaimedName} user={data.user} />
</div>
{/each}
<!-- claimed -->
{#each data.items.filter((item) => item.pledgedById) as item (item.id)}
{#each items.filter((item) => item.pledgedById) as item (item.id)}
<div in:receive={{ key: item.id }} out:send|local={{ key: item.id }} animate:flip={{ duration: 200 }}>
<ItemCard {item} showClaimedName={data.showClaimedName} user={data.user} />
</div>
Expand Down
26 changes: 25 additions & 1 deletion src/routes/wishlists/[username]/edit/[itemId]/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { client } from "$lib/server/prisma";
import { getConfig } from "$lib/server/config";
import { getActiveMembership } from "$lib/server/group-membership";
import { createImage, tryDeleteImage } from "$lib/server/image-util";
import { itemEmitter } from "$lib/server/events/emitters";
import { SSEvents } from "$lib/schema";

export const load: PageServerLoad = async ({ locals, params }) => {
const session = await locals.validate();
Expand Down Expand Up @@ -80,7 +82,7 @@ export const actions: Actions = {
}
});

await client.item.update({
const updatedItem = await client.item.update({
where: {
id: parseInt(params.itemId)
},
Expand All @@ -90,9 +92,31 @@ export const actions: Actions = {
url,
image_url: filename || image_url,
note
},
include: {
addedBy: {
select: {
username: true,
name: true
}
},
pledgedBy: {
select: {
username: true,
name: true
}
},
user: {
select: {
username: true,
name: true
}
}
}
});

itemEmitter.emit(SSEvents.item.update, updatedItem);

if (filename && item.image_url && item.image_url !== filename) {
await tryDeleteImage(item.image_url);
}
Expand Down
Loading

0 comments on commit 61b8ad8

Please sign in to comment.