Skip to content

Commit fcbe73e

Browse files
committed
🦋 POS pool synching between devices
SSE-based real-time synchronization for POS pools and session cart: - New SSE endpoint for orderTabs change stream (watches all pools for sidebar updates) - Shared pool-sse.ts utility with debounced invalidate (1s) to reduce redundant requests - Tab page: reactive SSE reconnection on pool switch via switchPoolSSE() - Split page: SSE connection for real-time payment/item updates - Session SSE: simplified cart events to trigger-only (client reloads via load()), added initial cart trigger on connect for reconnect consistency - Removed sending full cart data over SSE (was using formatCart), now uses invalidate() pattern matching the pool SSE approach Fixes issue #2364
1 parent 6a5c81c commit fcbe73e

7 files changed

Lines changed: 118 additions & 18 deletions

File tree

‎src/lib/utils/pool-sse.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { fetchEventSource } from '@microsoft/fetch-event-source';
2+
import { invalidate } from '$app/navigation';
3+
import { UrlDependency } from '$lib/types/UrlDependency';
4+
import { debounce } from '$lib/utils/debounce';
5+
6+
const SSE_DEBOUNCE_MS = 1000;
7+
8+
export function connectPoolSSE(slug: string, signal: AbortSignal): void {
9+
const debouncedInvalidate = debounce(
10+
() => invalidate(UrlDependency.orderTab(slug)),
11+
SSE_DEBOUNCE_MS
12+
);
13+
fetchEventSource(`/pos/touch/tab/${slug}/sse`, {
14+
signal,
15+
onmessage() {
16+
debouncedInvalidate();
17+
},
18+
onerror(err) {
19+
console.error(`Pool SSE error for tab ${slug}:`, err);
20+
}
21+
});
22+
}

‎src/routes/(app)/pos/session/+page.server.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { redirect } from '@sveltejs/kit';
33
import { formatCart, formatOrder } from './formatCartOrder.js';
44
import { runtimeConfig } from '$lib/server/runtime-config.js';
55

6-
export const load = async ({ locals }) => {
6+
export const load = async ({ locals, depends }) => {
77
if (!locals.user) {
88
throw redirect(303, '/admin/login');
99
}
1010

11+
depends('data:pos-session-cart');
12+
1113
const cart = await collections.carts.findOne(
1214
{ 'user.userId': locals.user._id },
1315
{ sort: { createdAt: -1 } }

‎src/routes/(app)/pos/session/+page.svelte‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { orderRemainingToPay } from '$lib/types/Order.js';
1010
import Trans from '$lib/components/Trans.svelte';
1111
import { computePriceInfo } from '$lib/cart.js';
12+
import { invalidate } from '$app/navigation';
1213
1314
interface CustomEventSource {
1415
onerror?: ((this: CustomEventSource, ev: Event) => unknown) | null;
@@ -22,7 +23,7 @@
2223
: 5_000;
2324
2425
let eventSourceInstance: CustomEventSource | void | null = null;
25-
let formattedCart = data.formattedCart;
26+
$: formattedCart = data.formattedCart;
2627
let order = data.order;
2728
2829
$: view =
@@ -39,9 +40,9 @@
3940
onmessage(ev) {
4041
if (ev.data) {
4142
try {
42-
const { eventType, cart: sseCart, order: sseOrder } = JSON.parse(ev.data);
43+
const { eventType, order: sseOrder } = JSON.parse(ev.data);
4344
if (eventType === 'cart') {
44-
formattedCart = sseCart;
45+
invalidate('data:pos-session-cart');
4546
} else if (eventType === 'order') {
4647
order = sseOrder;
4748
clearTimeout(currentTimeout);

‎src/routes/(app)/pos/session/sse/+server.ts‎

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Cart } from '$lib/types/Cart.js';
33
import type { Order } from '$lib/types/Order.js';
44
import { error } from '@sveltejs/kit';
55
import type { ChangeStream, ChangeStreamDocument, ObjectId } from 'mongodb';
6-
import { formatCart, formatOrder } from '../formatCartOrder.js';
6+
import { formatOrder } from '../formatCartOrder.js';
77

88
export async function GET({ locals }) {
99
const userId = locals.user?._id;
@@ -68,13 +68,9 @@ export async function GET({ locals }) {
6868
ownCartId = ownCart._id;
6969
}
7070
try {
71-
const formattedCart = await formatCart(ownCart || null, locals);
7271
await writer?.ready;
73-
await writer?.write(
74-
`data: ${JSON.stringify({ eventType: 'cart', cart: formattedCart })}\n\n`
75-
);
76-
} catch (error) {
77-
// Error writing to the client, assume it has disconnected
72+
await writer?.write(`data: ${JSON.stringify({ eventType: 'cart' })}\n\n`);
73+
} catch {
7874
cleanup();
7975
}
8076
});
@@ -125,17 +121,16 @@ export async function GET({ locals }) {
125121

126122
writer?.ready
127123
.then(async () => {
124+
// Cart: just get ID for delete tracking. Client already has data from load()
128125
const cart = await collections.carts.findOne(
129126
{ 'user.userId': userId },
130-
{ sort: { createdAt: -1 } }
127+
{ sort: { createdAt: -1 }, projection: { _id: 1 } }
131128
);
132-
if (cart) {
133-
ownCartId = cart._id;
134-
}
135-
const formattedCart = await formatCart(cart, locals);
129+
ownCartId = cart?._id ?? null;
136130

137-
writer?.write(`data: ${JSON.stringify({ eventType: 'cart', cart: formattedCart })}\n\n`);
131+
await writer?.write(`data: ${JSON.stringify({ eventType: 'cart' })}\n\n`);
138132

133+
// Order: keep initial push (client displays order directly from SSE data)
139134
const order = await collections.orders.findOne(
140135
{ 'user.userId': userId },
141136
{ sort: { createdAt: -1 } }

‎src/routes/(app)/pos/touch/tab/[orderTabSlug]/+page.svelte‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,29 @@
2323
import type { PrintHistoryEntry } from '$lib/types/PrintHistoryEntry';
2424
import MoveItemsModal from '$lib/components/MoveItemsModal.svelte';
2525
import { z } from 'zod';
26+
import { connectPoolSSE } from '$lib/utils/pool-sse';
27+
import { browser } from '$app/environment';
2628
2729
export let data;
2830
$: tabSlug = data.tabSlug;
31+
32+
let sseAbort: AbortController | null = null;
33+
let sseCurrentSlug: string | null = null;
34+
35+
function switchPoolSSE(slug: string) {
36+
if (sseCurrentSlug === slug) {
37+
return;
38+
}
39+
sseAbort?.abort();
40+
sseCurrentSlug = slug;
41+
sseAbort = new AbortController();
42+
connectPoolSSE(slug, sseAbort.signal);
43+
}
44+
45+
$: if (browser) {
46+
switchPoolSSE(tabSlug);
47+
}
48+
2949
$: next = Number($page.url.searchParams.get('skip')) || 0;
3050
$: picturesByProduct = groupBy(
3151
data.pictures.filter(
@@ -160,6 +180,8 @@
160180
window.addEventListener('resize', updatePaginationLimit);
161181
162182
return () => {
183+
sseAbort?.abort();
184+
sseCurrentSlug = null;
163185
window.removeEventListener('resize', updatePaginationLimit);
164186
window.removeEventListener('resize', checkMobileView);
165187
};

‎src/routes/(app)/pos/touch/tab/[orderTabSlug]/split/+page.svelte‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import PosPaymentMethodSelector, {
1717
type PaymentOption
1818
} from '$lib/components/PosPaymentMethodSelector.svelte';
19+
import { connectPoolSSE } from '$lib/utils/pool-sse';
1920
2021
const { t } = useI18n();
2122
@@ -170,10 +171,19 @@
170171
}
171172
}
172173
174+
let sseAbort: AbortController | null = null;
175+
173176
onMount(() => {
174177
checkMobileView();
175178
window.addEventListener('resize', checkMobileView);
176-
return () => window.removeEventListener('resize', checkMobileView);
179+
180+
sseAbort = new AbortController();
181+
connectPoolSSE(tabSlug, sseAbort.signal);
182+
183+
return () => {
184+
sseAbort?.abort();
185+
window.removeEventListener('resize', checkMobileView);
186+
};
177187
});
178188
179189
let fromCashInAll = false;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { collections } from '$lib/server/database.js';
2+
import type { OrderTab } from '$lib/types/OrderTab.js';
3+
import { error } from '@sveltejs/kit';
4+
import type { ChangeStream, ChangeStreamDocument } from 'mongodb';
5+
6+
export async function GET({ locals }) {
7+
if (!locals.user) {
8+
throw error(401, 'Unauthorized');
9+
}
10+
11+
const { readable, writable } = new TransformStream();
12+
let writer: WritableStreamDefaultWriter<unknown> | null = writable.getWriter();
13+
14+
function cleanup() {
15+
writer?.close();
16+
writer = null;
17+
changeStream?.close().catch(console.error);
18+
changeStream = null;
19+
}
20+
21+
let changeStream: ChangeStream<OrderTab, ChangeStreamDocument<OrderTab>> | null =
22+
collections.orderTabs.watch([]);
23+
24+
changeStream
25+
.on('change', async () => {
26+
if (!writer) {
27+
return;
28+
}
29+
try {
30+
await writer?.ready;
31+
await writer?.write(`data: {}\n\n`);
32+
} catch {
33+
cleanup();
34+
}
35+
})
36+
.on('error', () => {
37+
cleanup();
38+
});
39+
40+
return new Response(readable, {
41+
headers: {
42+
'Content-Type': 'text/event-stream',
43+
'Cache-Control': 'no-cache',
44+
Connection: 'keep-alive',
45+
'X-Accel-Buffering': 'no'
46+
}
47+
});
48+
}

0 commit comments

Comments
 (0)