matchmaking and message sending scenarios

This commit is contained in:
Arseniy Klempner 2025-04-03 18:32:19 -07:00
parent 283a0a389e
commit 03d92e9e7c
No known key found for this signature in database
GPG Key ID: 51653F18863BD24B
21 changed files with 1321 additions and 645 deletions

View File

@ -1,213 +1,247 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { startSending, subscribe, sweepIn, sweepOut } from '$lib/sds.svelte';
import { wakuNode } from '$lib/waku/waku.svelte';
import { onDestroy, onMount } from 'svelte';
import {
startSending as startSendingHistory,
subscribe,
sweepIn,
sweepOut
} from '$lib/sds.svelte';
import { wakuNode } from '$lib/waku/waku.svelte';
import { getOrCreateChannel } from '$lib/sds/channel.svelte';
import { send } from '$lib/waku/pingpong.svelte';
import { randomBytes } from '@noble/hashes/utils';
import { getMatch } from '$lib/utils/match.svelte';
import type { MatchParams } from '$lib/waku/waku.svelte';
import { page } from '$app/state';
let sendingInterval: NodeJS.Timeout | null = null;
let isSending = false;
let isListening = false;
let messageOption = '1'; // Options: '1', '5', '10', 'continuous'
let showDropdown = false;
let sweepInterval: NodeJS.Timeout | null = null;
let match = $state<MatchParams | undefined>(undefined);
let sendingInterval: NodeJS.Timeout | null = null;
let isSending = false;
let isListening = false;
let messageOption = '1'; // Options: '1', '5', '10', 'continuous'
let showDropdown = false;
let sweepInterval: NodeJS.Timeout | null = null;
function sweep() {
sweepIn();
sweepOut();
}
function startSending() {
match = getMatch();
if (!match) {
startSendingHistory();
} else {
const channel = getOrCreateChannel(match.matchId);
send(channel, randomBytes(32));
}
}
// Set up periodic sweep on component mount
onMount(() => {
sweepInterval = setInterval(sweep, 5000); // Call sweep every 5 seconds
return () => {
if (sweepInterval) clearInterval(sweepInterval);
};
});
function sweep() {
sweepIn();
sweepOut();
}
function toggleSending() {
if (isSending) {
// Stop sending
if (sendingInterval) {
clearInterval(sendingInterval);
sendingInterval = null;
}
isSending = false;
} else {
// Handle different message count options
if (messageOption === 'continuous') {
// Start sending periodically (every 2 seconds)
startSending(); // Send immediately once
sendingInterval = setInterval(() => {
startSending();
}, 2000);
isSending = true;
} else {
// Send a specific number of messages
const count = parseInt(messageOption);
let sent = 0;
const sendBatch = () => {
startSending();
sent++;
if (sent >= count) {
clearInterval(sendingInterval!);
sendingInterval = null;
isSending = false;
}
};
sendBatch(); // Send first message immediately
if (count > 1) {
sendingInterval = setInterval(sendBatch, 2000);
isSending = true;
}
}
}
}
// Set up periodic sweep on component mount
onMount(() => {
page
match = getMatch();
sweepInterval = setInterval(sweep, 5000); // Call sweep every 5 seconds
return () => {
if (sweepInterval) clearInterval(sweepInterval);
};
});
function selectOption(option: string) {
messageOption = option;
showDropdown = false;
}
function toggleSending() {
if (isSending) {
// Stop sending
if (sendingInterval) {
clearInterval(sendingInterval);
sendingInterval = null;
}
isSending = false;
} else {
// Handle different message count options
if (messageOption === 'continuous') {
// Start sending periodically (every 2 seconds)
startSending(); // Send immediately once
sendingInterval = setInterval(() => {
startSending();
}, 2000);
isSending = true;
} else {
// Send a specific number of messages
const count = parseInt(messageOption);
let sent = 0;
function toggleListening() {
if (!isListening) {
subscribe();
isListening = true;
} else {
wakuNode.unsubscribe();
isListening = false;
}
}
const sendBatch = () => {
startSending();
sent++;
onDestroy(() => {
// Clean up interval when component is destroyed
if (sendingInterval) {
clearInterval(sendingInterval);
}
// Clean up sweep interval
if (sweepInterval) {
clearInterval(sweepInterval);
}
// Ensure we unsubscribe when component is destroyed
if (isListening) {
wakuNode.unsubscribe();
}
});
if (sent >= count) {
clearInterval(sendingInterval!);
sendingInterval = null;
isSending = false;
}
};
sendBatch(); // Send first message immediately
if (count > 1) {
sendingInterval = setInterval(sendBatch, 2000);
isSending = true;
}
}
}
}
function selectOption(option: string) {
messageOption = option;
showDropdown = false;
}
function toggleListening() {
match = getMatch();
if (match) {
if (listening.listening) {
listening.listening = false;
}
return;
} else {
if (!isListening) {
subscribe();
isListening = true;
} else {
wakuNode.unsubscribe();
isListening = false;
}
}
}
onDestroy(() => {
// Clean up interval when component is destroyed
if (sendingInterval) {
clearInterval(sendingInterval);
}
// Clean up sweep interval
if (sweepInterval) {
clearInterval(sweepInterval);
}
// Ensure we unsubscribe when component is destroyed
if (isListening) {
wakuNode.unsubscribe();
}
});
</script>
<div class="action-buttons">
<button class="listen-button" on:click={toggleListening}>
{isListening ? 'Stop Listening' : 'Listen'}
</button>
<div class="send-dropdown-container">
<button class="send-button" on:click={toggleSending}>
{isSending ? 'Stop Sending' : 'Send'}
</button>
<button class="dropdown-toggle" on:click={() => showDropdown = !showDropdown}>
{messageOption === 'continuous' ? '∞' : messageOption}
<span class="arrow"></span>
</button>
{#if showDropdown}
<div class="dropdown-menu">
<div class="dropdown-item" on:click={() => selectOption('1')}>1</div>
<div class="dropdown-item" on:click={() => selectOption('5')}>5</div>
<div class="dropdown-item" on:click={() => selectOption('10')}>10</div>
<div class="dropdown-item" on:click={() => selectOption('continuous')}>∞</div>
</div>
{/if}
</div>
{#if !match}
<button class="listen-button" on:click={toggleListening}>
{isListening ? 'Stop Listening' : 'Listen'}
</button>
{/if}
<div class="send-dropdown-container">
<button class="send-button" on:click={toggleSending}>
{isSending ? 'Stop Sending' : 'Send'}
</button>
<button class="dropdown-toggle" on:click={() => (showDropdown = !showDropdown)}>
{messageOption === 'continuous' ? '∞' : messageOption}
<span class="arrow"></span>
</button>
{#if showDropdown}
<div class="dropdown-menu">
<div class="dropdown-item" on:click={() => selectOption('1')}>1</div>
<div class="dropdown-item" on:click={() => selectOption('5')}>5</div>
<div class="dropdown-item" on:click={() => selectOption('10')}>10</div>
<div class="dropdown-item" on:click={() => selectOption('continuous')}>∞</div>
</div>
{/if}
</div>
</div>
<style>
.action-buttons {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.action-buttons {
display: flex;
gap: 0.5rem;
justify-content: center;
}
button {
padding: 0.4rem 1rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.2s ease;
min-width: 80px;
}
button {
padding: 0.4rem 1rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.2s ease;
min-width: 80px;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.listen-button {
background-color: #10B981;
color: white;
}
.listen-button {
background-color: #10b981;
color: white;
}
.listen-button:hover:not(:disabled) {
background-color: #059669;
}
.listen-button:hover:not(:disabled) {
background-color: #059669;
}
.send-button {
background-color: #3B82F6;
color: white;
}
.send-button {
background-color: #3b82f6;
color: white;
}
.send-button:hover {
background-color: #2563EB;
}
.send-dropdown-container {
position: relative;
display: flex;
}
.dropdown-toggle {
background-color: #2563EB;
color: white;
border-radius: 0 4px 4px 0;
border-left: 1px solid rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
padding: 0.4rem 0.6rem;
min-width: auto;
}
.dropdown-toggle .arrow {
font-size: 0.7rem;
margin-left: 4px;
}
.send-button {
border-radius: 4px 0 0 4px;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 10;
min-width: 120px;
margin-top: 4px;
}
.dropdown-item {
padding: 0.5rem 1rem;
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.2s;
}
.dropdown-item:hover {
background-color: #f0f9ff;
}
</style>
.send-button:hover {
background-color: #2563eb;
}
.send-dropdown-container {
position: relative;
display: flex;
}
.dropdown-toggle {
background-color: #2563eb;
color: white;
border-radius: 0 4px 4px 0;
border-left: 1px solid rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
padding: 0.4rem 0.6rem;
min-width: auto;
}
.dropdown-toggle .arrow {
font-size: 0.7rem;
margin-left: 4px;
}
.send-button {
border-radius: 4px 0 0 4px;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 10;
min-width: 120px;
margin-top: 4px;
}
.dropdown-item {
padding: 0.5rem 1rem;
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.2s;
}
.dropdown-item:hover {
background-color: #f0f9ff;
}
</style>

View File

@ -1,112 +1,141 @@
<script lang="ts">
import { startWaku, connectionState } from "../waku/waku.svelte";
import { startWaku, connectionState } from '../waku/waku.svelte';
export let size = "normal"; // can be "normal" or "large"
let {
size = 'normal',
afterConnect
}: {
size?: 'normal' | 'large';
afterConnect: (afterConnectState: (state: string) => void) => void;
} = $props();
let afterConnectState = $state('');
async function handleConnect() {
try {
await startWaku();
} catch (error) {
// Error is already handled in startWaku function
console.error("Connection error in component:", error);
}
}
async function handleConnect() {
try {
await startWaku();
} catch (error) {
// Error is already handled in startWaku function
console.error('Connection error in component:', error);
}
}
// Call afterConnect when status changes to connected
$effect(() => {
if ($connectionState.status === 'connected') {
if (afterConnect) {
afterConnect((state: string) => {
afterConnectState = state;
});
}
}
});
</script>
<div class="connection-ui {size}">
{#if $connectionState.status === "disconnected"}
<button
on:click={handleConnect}
class="text-blue-600 hover:text-blue-800 font-medium">Connect →</button
>
{:else if $connectionState.status === "connecting"}
<span class="status animated-dots">Starting node . . .</span>
{:else if $connectionState.status === "waiting_for_peers"}
<span class="status animated-dots">Waiting for peers . . .</span>
{:else if $connectionState.status === "setting_up_subscriptions"}
<span class="status animated-dots">Setting up subscriptions . . .</span>
{:else if $connectionState.status === "connected"}
<span class="status connected">Connected</span>
{:else if $connectionState.status === "error"}
<div class="error-container">
<span class="error">Error: {$connectionState.error}</span>
<button on:click={handleConnect} class="text-blue-600 hover:text-blue-800 font-medium">Retry</button>
</div>
{/if}
{#if $connectionState.status === 'disconnected'}
<button on:click={handleConnect} class="font-medium text-blue-600 hover:text-blue-800"
>Connect →</button
>
{:else if $connectionState.status === 'connecting'}
<span class="status animated-dots">Starting node . . .</span>
{:else if $connectionState.status === 'waiting_for_peers'}
<span class="status animated-dots">Waiting for peers . . .</span>
{:else if $connectionState.status === 'setting_up_subscriptions'}
<span class="status animated-dots">Setting up subscriptions . . .</span>
{:else if $connectionState.status === 'connected'}
{#if afterConnectState !== ''}
<span class="status animated-dots">{afterConnectState}</span>
{:else}
<span class="status connected">Connected</span>
{/if}
{:else if $connectionState.status === 'error'}
<div class="error-container">
<span class="error">Error: {$connectionState.error}</span>
<button on:click={handleConnect} class="font-medium text-blue-600 hover:text-blue-800"
>Retry</button
>
</div>
{/if}
</div>
<style>
.connection-ui {
display: flex;
align-items: center;
}
.connection-ui {
display: flex;
align-items: center;
}
.connection-ui.large button {
font-size: 1.1rem;
}
.connection-ui.large button {
font-size: 1.1rem;
}
.connection-ui.large .status {
font-size: 1.1rem;
padding: 0.75rem 1.5rem;
}
.connection-ui.large .status {
font-size: 1.1rem;
padding: 0.75rem 1.5rem;
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.connection-ui.normal .error-container {
align-items: flex-end;
}
.connection-ui.normal .error-container {
align-items: flex-end;
}
button {
background: none;
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
font-weight: 500;
padding: 0;
}
button {
background: none;
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
font-weight: 500;
padding: 0;
}
.status {
padding: 0.5rem 1rem;
border-radius: 4px;
background-color: #e5e7eb;
color: #374151;
}
.status {
padding: 0.5rem 1rem;
border-radius: 4px;
background-color: #e5e7eb;
color: #374151;
}
.status.connected {
background-color: #d1fae5;
color: #065f46;
}
.status.connected {
background-color: #d1fae5;
color: #065f46;
}
.error {
color: #b91c1c;
background-color: #fee2e2;
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.error {
color: #b91c1c;
background-color: #fee2e2;
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.connection-ui.large .error {
font-size: 1rem;
max-width: 600px;
}
.connection-ui.large .error {
font-size: 1rem;
max-width: 600px;
}
.animated-dots {
animation: dotAnimation 1.5s infinite;
}
@keyframes dotAnimation {
0% { opacity: 0.3; }
50% { opacity: 1; }
100% { opacity: 0.3; }
}
.animated-dots {
animation: dotAnimation 1.5s infinite;
}
@keyframes dotAnimation {
0% {
opacity: 0.3;
}
50% {
opacity: 1;
}
100% {
opacity: 0.3;
}
}
</style>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { subscribeToAllEventsStream } from '$lib/sds/stream.svelte';
import { subscribeToAllEventsStream, subscribeToChannelEvents } from '$lib/sds/stream.svelte';
import { MessageChannelEvent } from '@waku/sds';
import { onMount, onDestroy } from 'svelte';
import { getIdenticon } from '$lib/identicon.svelte';
@ -7,41 +7,25 @@
import type { MessageChannelEventObject } from '$lib/sds/stream';
import HistoryItem from './HistoryItem.svelte';
import LegendModal from './LegendModal.svelte';
// Map event types to colors using index signature
const eventColors: { [key in string]: string } = {
[MessageChannelEvent.MessageSent]: '#3B82F6', // blue
[MessageChannelEvent.MessageDelivered]: '#10B981', // green
[MessageChannelEvent.MessageReceived]: '#8B5CF6', // purple
[MessageChannelEvent.MessageAcknowledged]: '#059669', // dark green
[MessageChannelEvent.PartialAcknowledgement]: '#6D28D9', // dark purple
[MessageChannelEvent.MissedMessages]: '#EF4444' // red
};
// Event type to display name using index signature
const eventNames: { [key in string]: string } = {
[MessageChannelEvent.MessageSent]: 'Sent',
[MessageChannelEvent.MessageDelivered]: 'Delivered',
[MessageChannelEvent.MessageReceived]: 'Received',
[MessageChannelEvent.MessageAcknowledged]: 'Acknowledged',
[MessageChannelEvent.PartialAcknowledgement]: 'Partially Acknowledged',
[MessageChannelEvent.MissedMessages]: 'Missed'
};
import { matchesIdFilter, currentIdFilter } from '$lib/utils/event.svelte';
// Store for history items
let history: Array<MessageChannelEventObject> = $state([]);
let identicon: any = $state(null);
let currentFilter: string = $state('all');
let currentIdFilter: string | null = $state(null);
let showLegend: boolean = $state(false);
let { channelId = null }: { channelId: string | null } = $props();
// Map of filter values to event types
const filterMap: { [key: string]: string | null } = {
all: null,
sent: MessageChannelEvent.MessageSent,
received: MessageChannelEvent.MessageReceived,
delivered: MessageChannelEvent.MessageDelivered,
acknowledged: MessageChannelEvent.MessageAcknowledged
acknowledged: MessageChannelEvent.MessageAcknowledged,
syncSent: MessageChannelEvent.SyncSent,
syncReceived: MessageChannelEvent.SyncReceived
};
// Calculate counts of each event type
@ -52,7 +36,9 @@
delivered: history.filter((event) => event.type === MessageChannelEvent.MessageDelivered)
.length,
acknowledged: history.filter((event) => event.type === MessageChannelEvent.MessageAcknowledged)
.length
.length,
syncSent: history.filter((event) => event.type === MessageChannelEvent.SyncSent).length,
syncReceived: history.filter((event) => event.type === MessageChannelEvent.SyncReceived).length
});
// Filtered history based on selected filter and ID filter
@ -65,28 +51,8 @@
: history.filter((event) => event.type === filterMap[currentFilter]);
// Then filter by ID if present
if (currentIdFilter) {
result = result.filter((event) => {
const id = getMessageId(event);
// Check direct ID match
if (id === currentIdFilter) {
return true;
}
// Check causal history for ID match
if (
(event.type === MessageChannelEvent.MessageSent ||
event.type === MessageChannelEvent.MessageReceived) &&
event.payload.causalHistory
) {
return event.payload.causalHistory.some(
(dependency) => dependency.messageId === currentIdFilter
);
}
return false;
});
if (currentIdFilter.id) {
result = result.filter(matchesIdFilter);
}
return result;
@ -114,7 +80,7 @@
// Handle event item click to filter by ID
function handleEventClick(id: string | null) {
if (id !== null) {
currentIdFilter = id;
currentIdFilter.id = id;
}
}
@ -122,12 +88,12 @@
function handleDependencyClick(messageId: string, event: Event) {
// Stop event propagation so it doesn't trigger parent click handler
event.stopPropagation();
currentIdFilter = messageId;
currentIdFilter.id = messageId;
}
// Clear ID filter
function clearIdFilter() {
currentIdFilter = null;
currentIdFilter.id = null;
}
// Toggle legend display
@ -135,17 +101,20 @@
showLegend = !showLegend;
}
function eventStreamCallback(event: MessageChannelEventObject) {
// Add event to history with newest at the top
if (event.type === MessageChannelEvent.MissedMessages) {
return;
}
history = [event, ...history];
}
onMount(async () => {
identicon = await getIdenticon();
// Subscribe to the event stream and collect events
unsubscribe = subscribeToAllEventsStream((event) => {
// Add event to history with newest at the top
if (event.type === MessageChannelEvent.MissedMessages) {
return;
}
history = [event, ...history];
});
(window as any).saveHistory = saveHistory;
unsubscribe = channelId
? subscribeToChannelEvents(channelId, eventStreamCallback)
: subscribeToAllEventsStream(eventStreamCallback);
});
onDestroy(() => {
@ -154,19 +123,6 @@
unsubscribe();
}
});
const saveHistory = () => {
const sampleHistory = history.map((event) => {
if((event.payload as any).bloomFilter) {
(event.payload as any).bloomFilter = new Uint8Array([0, 0, 0, 0]);
}
return {
type: event.type,
payload: event.payload
};
});
localStorage.setItem('history', JSON.stringify(sampleHistory));
};
</script>
<div class="history-container">
@ -181,18 +137,18 @@
</select>
</div>
{#if currentIdFilter}
{#if currentIdFilter.id }
<div class="id-filter-badge">
<span class="id-label">ID: {currentIdFilter}</span>
<span class="id-label">ID: {currentIdFilter.id}</span>
<button class="clear-filter-btn" onclick={clearIdFilter}>×</button>
</div>
{/if}
{#each filteredHistory as event, index}
<HistoryItem
<HistoryItem
{event}
identicon={identicons[index]}
currentIdFilter={currentIdFilter}
currentIdFilter={currentIdFilter.id}
onEventClick={handleEventClick}
onDependencyClick={handleDependencyClick}
/>

View File

@ -20,7 +20,9 @@
[MessageChannelEvent.MessageReceived]: '#8B5CF6', // purple
[MessageChannelEvent.MessageAcknowledged]: '#059669', // dark green
[MessageChannelEvent.PartialAcknowledgement]: '#6D28D9', // dark purple
[MessageChannelEvent.MissedMessages]: '#EF4444' // red
[MessageChannelEvent.MissedMessages]: '#EF4444', // red
[MessageChannelEvent.SyncSent]: '#F59E0B', // orange
[MessageChannelEvent.SyncReceived]: '#F59E0B' // dark orange
};
// Event type to display name using index signature
@ -30,7 +32,9 @@
[MessageChannelEvent.MessageReceived]: 'Received',
[MessageChannelEvent.MessageAcknowledged]: 'Acknowledged',
[MessageChannelEvent.PartialAcknowledgement]: 'Partially Acknowledged',
[MessageChannelEvent.MissedMessages]: 'Missed'
[MessageChannelEvent.MissedMessages]: 'Missed',
[MessageChannelEvent.SyncSent]: 'Sync Sent',
[MessageChannelEvent.SyncReceived]: 'Sync Received'
};
$: id = event ? getMessageId(event) : null;

View File

@ -1,143 +0,0 @@
<script lang="ts">
import { subscribeToAllEventsStream } from '$lib/sds/stream.svelte';
import { MessageChannelEvent } from '@waku/sds';
import { onMount, onDestroy } from 'svelte';
import { getIdenticon } from '$lib/identicon.svelte';
import { getMessageId } from '$lib/sds/message';
import type { MessageChannelEventObject } from '$lib/sds/stream';
import { grid, recordMessage } from '$lib/utils/stateGraph.svelte';
import { eventColors, eventNames } from '$lib/utils/event';
import HistoryItem from './HistoryItem.svelte';
// Store for history items
let history: Array<MessageChannelEventObject> = $state([]);
let identicons: {[messageId: string]: string} = $state({});
let identicon: any = $state(null);
const minHeight = 32;
const aspectRatio = 5.74;
let containerWidth: number;
let containerHeight: number;
const historyMutex = $state(false);
$effect(() => {
// Update container dimensions when grid changes
if (grid) {
containerWidth = grid[0]?.length || 0;
containerHeight = grid.length || 0;
}
});
// Unsubscribe function
let unsubscribe: (() => void) | null = $state(null);
// let identicons = $derived(
// identicon &&
// history.map((event: MessageChannelEventObject) => {
// const id = getMessageId(event);
// return new identicon(id || '', { size: 40, format: 'svg' }).toString();
// })
// );
onMount(async () => {
identicon = await getIdenticon();
// Subscribe to the event stream and collect events
unsubscribe = subscribeToAllEventsStream((event) => {
if (event.type === MessageChannelEvent.MissedMessages) {
return;
}
history = [event, ...history];
recordMessage(event);
const id = getMessageId(event);
if (!id) {
return;
}
if(identicons[id] === undefined) {
identicons[id] = new identicon(id, { size: 40, format: 'svg' }).toString();
}
});
});
onDestroy(() => {
if (unsubscribe) {
unsubscribe();
}
});
</script>
<div class="state-graph-container">
{#if grid}
<div class="grid-wrapper">
<div
class="grid"
style="--cell-height: {minHeight}px; --cell-width: {minHeight *
aspectRatio}px; --cols: {containerWidth}; --rows: {containerHeight}"
>
{#each grid as row}
<div class="row">
{#each row as cell}
<!-- {#if cell}
<div class="cell" style="background-color: {eventColors[cell.type]};">
<div class="cell-content" color={eventColors[cell.type]}>
{eventNames[cell.type]}
</div>
</div>
{/if} -->
{#if cell}
{@const id = getMessageId(cell)}
{#if id && identicons[id]}
<HistoryItem overflow={false} height={minHeight} width={minHeight * aspectRatio} identicon={identicons[id]} event={cell} />
{/if}
{/if}
{/each}
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.state-graph-container {
display: flex;
height: 100%;
width: 100%;
overflow: auto;
justify-content: center;
}
.grid-wrapper {
max-width: 100%;
max-height: 100%;
}
.grid {
display: flex;
grid-template-columns: repeat(var(--cols), var(--cell-width));
width: fit-content;
margin: auto;
}
.cell {
/* height: var(--cell-height);
width: var(--cell-width); */
max-width: var(--cell-width);
max-height: var(--cell-height);
border: 1px solid black;
background-color: rgba(170, 165, 209, 0.79);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.cell-content {
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 4px;
}
/* Remove unused styles */
</style>

View File

@ -1,27 +1,30 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
actual_grid,
update_virtual_grid,
shift_actual_grid
} from '$lib/utils/stateGraph.svelte';
import { subscribeToAllEventsStream } from '$lib/sds/stream.svelte';
import { actual_grid, update_virtual_grid } from '$lib/utils/stateGraph.svelte';
import { subscribeToAllEventsStream, subscribeToChannelEvents } from '$lib/sds/stream.svelte';
import { MessageChannelEvent } from '@waku/sds';
import { eventColors, eventNames } from '$lib/utils/event';
onMount(() => {
console.log('StateGraphSummary mounted');
});
import { eventColors, eventNames, currentIdFilter, matchesIdFilter } from '$lib/utils/event.svelte';
import type { MessageChannelEventObject } from '$lib/sds/stream';
let { channelId = null }: { channelId: string | null } = $props();
let unsubscribe: (() => void) | null = $state(null);
function eventStreamCallback(event: MessageChannelEventObject) {
if (event.type === MessageChannelEvent.MissedMessages) {
return;
}
if (event.type === MessageChannelEvent.SyncSent || event.type === MessageChannelEvent.SyncReceived) {
event
return;
}
console.log('updating virtual grid', event);
update_virtual_grid(event);
}
onMount(async () => {
unsubscribe = subscribeToAllEventsStream((event) => {
if (event.type === MessageChannelEvent.MissedMessages) {
return;
}
update_virtual_grid(event);
// shift_actual_grid();
});
unsubscribe = channelId
? subscribeToChannelEvents(channelId, eventStreamCallback)
: subscribeToAllEventsStream(eventStreamCallback);
});
onDestroy(() => {
@ -33,15 +36,32 @@
<div class="summary-grid">
{#each actual_grid as row}
<div class="column mw-200 mr-2 rounded-lg bg-none p-5 sm:shadow-md">
<p>{row.lamportTimestamp}</p>
{@const length = row.columns.filter((c) => c !== null).length}
{@const empty = 4 - length}
<div class="column mw-200 mr-2 mb-4 rounded-lg bg-none p-5 pt-0 sm:shadow-md">
<div class="flex flex-row items-center justify-between">
<p>{row.lamportTimestamp}</p>
<!-- <div class="flex items-center">
<div class="checkmark-large mr-1"></div>
<div class="checkmark-small"></div>
</div> -->
</div>
{#each row.columns as cell}
{@const filtered = currentIdFilter.id && cell && matchesIdFilter(cell)}
{#if cell?.type}
<div class="cell" style="background-color: {eventColors[cell.type]};">
<div
class={`cell ${currentIdFilter.id ? (filtered ? 'filtered' : 'filtered-out') : ''}`}
style="background-color: {eventColors[cell.type]};"
>
<p class="cell-text">{eventNames[cell.type]}</p>
</div>
{/if}
{/each}
{#if empty > 0}
{#each Array(empty) as _}
<div class="cell empty-cell"></div>
{/each}
{/if}
</div>
{/each}
</div>
@ -61,9 +81,16 @@
align-content: center;
}
.filtered {
}
.filtered-out {
opacity: 0.3;
}
.empty-cell {
/* border: 1px solid black; */
border: none !important;
/* border: none !important; */
}
.column {
@ -81,4 +108,16 @@
letter-spacing: 0.1em;
color: white;
}
.checkmark-large {
font-size: 24px;
color: transparent;
-webkit-text-stroke: 1px black;
}
.checkmark-small {
font-size: 18px;
color: transparent;
-webkit-text-stroke: 1px black;
}
</style>

View File

@ -2,7 +2,7 @@ import { encoder, wakuNode } from '$lib/waku/waku.svelte';
import { type Message, MessageChannelEvent, encodeMessage, decodeMessage } from '@waku/sds';
import { getChannel } from '$lib/sds/channel.svelte';
import { messageHash } from '@waku/message-hash';
import type { MessageChannel } from '@waku/sds';
const channel = getChannel();
export function subscribe() {
@ -23,16 +23,17 @@ export function startSending() {
}
export function sweepOut() {
channel.sweepOutgoingBuffer();
return channel.sweepOutgoingBuffer();
}
export async function sweepIn() {
const missedMessages = channel.sweepIncomingBuffer();
console.log('missedMessages', missedMessages);
export async function sweepIn(_channel?: MessageChannel) {
if (!_channel) {
_channel = channel;
}
const missedMessages = _channel.sweepIncomingBuffer();
const messageHashes = missedMessages
.filter((message) => message.retrievalHint !== undefined)
.map((message) => message.retrievalHint!);
console.log('messageHashes', messageHashes);
if (messageHashes.length === 0) {
return;
}
@ -53,7 +54,7 @@ export async function sweepIn() {
for (const msg of messages) {
if (msg?.payload) {
const sdsMessage = decodeMessage(msg.payload) as unknown as Message;
channel.receiveMessage(sdsMessage);
_channel.receiveMessage(sdsMessage);
}
}
}

View File

@ -6,4 +6,14 @@ const channel = $state(new MessageChannel(channelId));
export function getChannel() {
return channel;
}
}
const channels = $state<Record<string, MessageChannel>>({});
export function getOrCreateChannel(channelId: string) {
if (channels[channelId]) {
return channels[channelId];
}
channels[channelId] = new MessageChannel(channelId);
return channels[channelId];
}

View File

@ -14,6 +14,10 @@ export function getMessageId(event: MessageChannelEventObject) {
return event.payload.messageId;
} else if(event.type === MessageChannelEvent.MissedMessages) {
return event.payload[0].messageId;
} else if(event.type === MessageChannelEvent.SyncSent) {
return event.payload.messageId;
} else if(event.type === MessageChannelEvent.SyncReceived) {
return event.payload.messageId;
}
return null;
}

View File

@ -1,4 +1,4 @@
import { getChannel } from './channel.svelte';
import { getChannel, getOrCreateChannel } from './channel.svelte';
import { toEventStream, filterByEventType } from './stream';
import { Effect, Stream, pipe } from 'effect';
import type { MessageChannelEventObject } from './stream';
@ -7,38 +7,50 @@ import { MessageChannelEvent } from '@waku/sds';
const eventStream = $state(toEventStream(getChannel()));
const missingMessageEventStream = $derived(
pipe(
eventStream,
filterByEventType(MessageChannelEvent.MissedMessages)
)
pipe(eventStream, filterByEventType(MessageChannelEvent.MissedMessages))
);
export function subscribeToMissingMessageStream(onEvent: (event: Extract<MessageChannelEventObject, { type: MessageChannelEvent.MissedMessages }>) => void): () => void {
return subscribeToEventStream(missingMessageEventStream, onEvent);
export function subscribeToMissingMessageStream(
onEvent: (
event: Extract<MessageChannelEventObject, { type: MessageChannelEvent.MissedMessages }>
) => void
): () => void {
return subscribeToEventStream(missingMessageEventStream, onEvent);
}
export function subscribeToAllEventsStream(onEvent: (event: MessageChannelEventObject) => void): () => void {
return subscribeToEventStream(eventStream, onEvent);
export function subscribeToAllEventsStream(
onEvent: (event: MessageChannelEventObject) => void
): () => void {
return subscribeToEventStream(eventStream, onEvent);
}
function subscribeToEventStream<A extends MessageChannelEventObject>(stream: Stream.Stream<A>, onEvent: (event: A) => void): () => void {
const fiber = Effect.runFork(
pipe(
stream,
Stream.tap((event) =>
Effect.sync(() => {
onEvent(event);
})
),
Stream.runDrain
)
);
return () => {
Effect.runFork(
Effect.sync(() => {
(fiber as unknown as { interrupt: () => void }).interrupt();
})
);
};
export function subscribeToChannelEvents(
channelId: string,
onEvent: (event: MessageChannelEventObject) => void
): () => void {
return subscribeToEventStream(toEventStream(getOrCreateChannel(channelId)), onEvent);
}
function subscribeToEventStream<A extends MessageChannelEventObject>(
stream: Stream.Stream<A>,
onEvent: (event: A) => void
): () => void {
const fiber = Effect.runFork(
pipe(
stream,
Stream.tap((event) =>
Effect.sync(() => {
onEvent(event);
})
),
Stream.runDrain
)
);
return () => {
Effect.runFork(
Effect.sync(() => {
(fiber as unknown as { interrupt: () => void }).interrupt();
})
);
};
}

View File

@ -0,0 +1,52 @@
import { getMessageId } from '$lib/sds/message';
import { MessageChannelEvent } from '@waku/sds';
import type { MessageChannelEventObject } from '$lib/sds/stream';
export const eventColors: { [key in string]: string } = {
[MessageChannelEvent.MessageSent]: '#3B82F6', // blue
[MessageChannelEvent.MessageDelivered]: '#10B981', // green
[MessageChannelEvent.MessageReceived]: '#8B5CF6', // purple
[MessageChannelEvent.MessageAcknowledged]: '#059669', // dark green
[MessageChannelEvent.PartialAcknowledgement]: '#6D28D9', // dark purple
[MessageChannelEvent.MissedMessages]: '#EF4444', // red
[MessageChannelEvent.SyncSent]: '#F59E0B', // orange
[MessageChannelEvent.SyncReceived]: '#F59E0B' // dark orange
};
// Event type to display name using index signature
export const eventNames: { [key in string]: string } = {
[MessageChannelEvent.MessageSent]: 'Sent',
[MessageChannelEvent.MessageDelivered]: 'Delivered',
[MessageChannelEvent.MessageReceived]: 'Received',
[MessageChannelEvent.MessageAcknowledged]: 'Acknowledged',
[MessageChannelEvent.PartialAcknowledgement]: 'Partially Acknowledged',
[MessageChannelEvent.MissedMessages]: 'Missed',
[MessageChannelEvent.SyncSent]: 'Sync Sent',
[MessageChannelEvent.SyncReceived]: 'Sync Received'
};
export const currentIdFilter: { id: string | null } = $state({ id: null });
export const matchesIdFilter = (event: MessageChannelEventObject) => {
if (currentIdFilter) {
const id = getMessageId(event);
// Check direct ID match
if (id === currentIdFilter.id) {
return true;
}
// Check causal history for ID match
if (
(event.type === MessageChannelEvent.MessageSent ||
event.type === MessageChannelEvent.MessageReceived) &&
event.payload.causalHistory
) {
return event.payload.causalHistory.some(
(dependency: { messageId: string }) => dependency.messageId === currentIdFilter.id
);
}
return false;
}
};

View File

@ -1,20 +0,0 @@
import { MessageChannelEvent } from '@waku/sds';
export const eventColors: { [key in string]: string } = {
[MessageChannelEvent.MessageSent]: '#3B82F6', // blue
[MessageChannelEvent.MessageDelivered]: '#10B981', // green
[MessageChannelEvent.MessageReceived]: '#8B5CF6', // purple
[MessageChannelEvent.MessageAcknowledged]: '#059669', // dark green
[MessageChannelEvent.PartialAcknowledgement]: '#6D28D9', // dark purple
[MessageChannelEvent.MissedMessages]: '#EF4444' // red
};
// Event type to display name using index signature
export const eventNames: { [key in string]: string } = {
[MessageChannelEvent.MessageSent]: 'Sent',
[MessageChannelEvent.MessageDelivered]: 'Delivered',
[MessageChannelEvent.MessageReceived]: 'Received',
[MessageChannelEvent.MessageAcknowledged]: 'Acknowledged',
[MessageChannelEvent.PartialAcknowledgement]: 'Partially Acknowledged',
[MessageChannelEvent.MissedMessages]: 'Missed'
};

View File

@ -0,0 +1,7 @@
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
export function hash(data: string) {
return bytesToHex(sha256(data));
}

View File

@ -0,0 +1,11 @@
import type { MatchParams } from "$lib/waku/waku.svelte";
let match = $state<MatchParams | undefined>(undefined);
export function setMatch(params: MatchParams) {
match = params;
}
export function getMatch() {
return match;
}

View File

@ -1,3 +1,4 @@
import { getMessageId } from '$lib/sds/message';
import { type MessageChannelEventObject } from '$lib/sds/stream';
import { MessageChannelEvent } from '@waku/sds';
@ -27,43 +28,23 @@ export const createGrid = (
.map(() => Array(columns).fill(null));
};
type GridItem = {
lamportTimestamp: number;
events: Array<MessageChannelEventObject>;
};
const x_start = $state(0);
const x_window = 100;
const x_threshold = 0;
const virtual_grid: Map<number, GridItem> = $state(new Map());
export const actual_grid = $state(createGrid(x_window, 10).map((row, index) => ({lamportTimestamp: index, columns: row})));
export const actual_grid = $state(
createGrid(x_window, 10).map((row, index) => ({ lamportTimestamp: index + 1, columns: row }))
);
export const update_virtual_grid = (event: MessageChannelEventObject) => {
const lamportTimestamp = getLamportTimestamp(event);
if (!lamportTimestamp) {
return;
}
const events = virtual_grid.get(lamportTimestamp)?.events || [];
events.push(event);
virtual_grid.set(lamportTimestamp, { lamportTimestamp, events });
if(lamportTimestamp > x_start + x_window - x_threshold) {
shift_actual_grid(0 ,lamportTimestamp - x_window - x_threshold);
} else if (x_start <= lamportTimestamp && lamportTimestamp <= x_start + x_window) {
actual_grid[lamportTimestamp % x_window].columns.push(event);
}
}
export const shift_actual_grid = (amount: number = 1, _x_start?: number) => {
if (!_x_start) {
_x_start = x_start;
}
for(let i = _x_start + amount; i < _x_start + x_window + amount; i++) {
const events = virtual_grid.get(i)?.events || [];
actual_grid[i % x_window] = {lamportTimestamp: i, columns: events};
}
}
actual_grid[lamportTimestamp - (1 % x_window)].columns.push(event);
};
export const grid = $state(createGrid(50, 10));
const messageIdToLamportTimestamp = $state(new Map<string, number>());
const getLamportTimestamp = (event: MessageChannelEventObject) => {
const messageId = getMessageId(event);
let lamportTimestamp = null;
if (
event.type === MessageChannelEvent.MessageSent ||
@ -75,11 +56,22 @@ const getLamportTimestamp = (event: MessageChannelEventObject) => {
if (!lamportTimestamp) {
return;
}
} else {
lamportTimestamp = longestTimestamp;
if (messageId) {
messageIdToLamportTimestamp.set(messageId, lamportTimestamp);
}
} else if (
event.type === MessageChannelEvent.MessageDelivered ||
event.type === MessageChannelEvent.MessageAcknowledged
) {
if (messageId) {
lamportTimestamp = messageIdToLamportTimestamp.get(messageId) || null;
}
if (!lamportTimestamp) {
lamportTimestamp = longestTimestamp;
}
}
return lamportTimestamp;
}
};
const messagesPerLamportTimestamp = $state(new Map<number, Array<MessageChannelEventObject>>());
let longestTimestamp = $state(0);
export const recordMessage = (

View File

@ -0,0 +1,216 @@
import { Effect, Option, pipe, Stream } from 'effect';
// Define the type for state transition events
export interface StateTransitionDetail {
peerId: string;
prevState: PeerState;
newState: PeerState;
message: LobbyMessage;
}
export type StateTransitionEvent = CustomEvent<StateTransitionDetail>;
// Callback type for state transitions
export type StateTransitionCallback = (event: StateTransitionEvent) => void;
// Events emitted by the lobby
export type LobbyEvents = {
'state-transition': StateTransitionEvent;
};
export enum LobbyMessageType {
Ping = 'ping',
Request = 'request',
Accept = 'accept',
Match = 'match'
}
enum LobbyEvent {
GotPing = 'got_ping',
GotRequest = 'got_request',
SentRequest = 'sent_request',
GotAccept = 'got_accept',
SentAccept = 'sent_accept',
GotMatch = 'got_match',
SentMatch = 'sent_match'
}
export type LobbyMessage = {
messageType: LobbyMessageType;
timestamp: Date;
expiry?: Date;
from: string;
to?: string;
};
export enum PeerState {
None = 'none',
Found = 'found',
RequestTo = 'request_to',
RequestFrom = 'request_from',
AcceptTo = 'accept_to',
AcceptFrom = 'accept_from',
Success = 'success',
Failure = 'failure'
}
// Create a class that extends EventTarget for native event handling
class LobbyState extends EventTarget {
// The peer state map
peerState = $state(new Map<string, { state: PeerState; messages: LobbyMessage[] }>());
// Method to update the state of a peer
updatePeerState(peerId: string, newState: PeerState, message: LobbyMessage): void {
const prevState = this.peerState.get(peerId)?.state || PeerState.None;
const messages = this.peerState.get(peerId)?.messages || [];
// Update the state
this.peerState.set(peerId, {
state: newState,
messages: [...messages, message]
});
// Create and dispatch the event using native event handling
const event = new CustomEvent<StateTransitionDetail>('state-transition', {
detail: {
peerId,
prevState,
newState,
message
}
});
this.dispatchEvent(event);
}
}
// Create the singleton instance
export const lobbyState = $state(new LobbyState());
// Type helpers to narrow message types
type MessageOfType<T extends LobbyMessageType> = Omit<LobbyMessage, 'messageType'> & {
messageType: T;
};
// Define transition handlers for each message type and state
// Allow the state machine to define only valid transitions
type TransitionTable = {
[LE in LobbyEvent]: {
[PS in PeerState]?: PeerState;
};
};
// State transition table - defines the next state based on message type and current state
const stateMachine: TransitionTable = {
[LobbyEvent.GotPing]: {
[PeerState.None]: PeerState.Found,
[PeerState.Found]: PeerState.Found,
},
[LobbyEvent.SentRequest]: {
[PeerState.Found]: PeerState.RequestTo,
},
[LobbyEvent.GotRequest]: {
[PeerState.None]: PeerState.RequestFrom,
[PeerState.Found]: PeerState.RequestFrom,
},
[LobbyEvent.SentAccept]: {
[PeerState.RequestFrom]: PeerState.AcceptTo,
},
[LobbyEvent.GotAccept]: {
[PeerState.RequestTo]: PeerState.AcceptFrom,
},
[LobbyEvent.SentMatch]: {
[PeerState.AcceptFrom]: PeerState.Success,
},
[LobbyEvent.GotMatch]: {
[PeerState.AcceptTo]: PeerState.Success,
}
};
// Example of a function that uses the type-narrowed message
function processMessage<MT extends LobbyMessageType>(
message: MessageOfType<MT>,
currentState: PeerState,
sent: boolean
): Option.Option<PeerState> {
// Here we know the exact message type at compile time
// We can do specific processing based on message type
let event: LobbyEvent | null = null;
if (message.messageType === LobbyMessageType.Ping) {
console.log(`Received ping from ${message.from}`);
if (sent) {
throw `Don't track sent pings`;
}
event = LobbyEvent.GotPing;
} else if (
message.messageType === LobbyMessageType.Request
) {
console.log(`Received request from ${message.from} to ${message.to || 'everyone'}`);
event = sent ? LobbyEvent.SentRequest : LobbyEvent.GotRequest;
} else if (
message.messageType === LobbyMessageType.Accept
) {
console.log(`Received accept from ${message.from} to ${message.to || 'unknown'}`);
event = sent ? LobbyEvent.SentAccept : LobbyEvent.GotAccept;
} else if (message.messageType === LobbyMessageType.Match) {
console.log(`Received match between peers`);
event = sent ? LobbyEvent.SentMatch : LobbyEvent.GotMatch;
}
// Get next state from transition table
if (event === null) {
console.warn(`Invalid message type: ${message.messageType}`);
return Option.none();
}
const nextStateValue = stateMachine[event][currentState];
if (nextStateValue === undefined) {
// Handle invalid transitions - throw error or return current state
console.warn(`Invalid transition: ${event} from ${currentState}`);
return Option.none();
}
return Option.some(nextStateValue);
}
export async function processUpdates(updates: { peerId: string; message: LobbyMessage, sent: boolean }[]) {
for (const update of updates) {
const { peerId, message, sent } = update;
const currentState = lobbyState.peerState.get(peerId)?.state || PeerState.None;
const result = processMessage(message, currentState, sent);
Option.match(result, {
onNone: () =>
console.warn(
`Invalid state transition: ${message.messageType} from ${currentState} for peer ${peerId}`
),
onSome: (newState) => {
lobbyState.updatePeerState(peerId, newState, message);
}
});
}
}
// Create a typed stream from the events
export const stateTransitionStream = $state(Stream.map(
Stream.fromEventListener(lobbyState, 'state-transition', { passive: true }),
(event: Event) => event as CustomEvent<StateTransitionDetail>
));
export function subscribeToStateTransitionStream<A>(stream: Stream.Stream<CustomEvent<A>>, onEvent: (event: A) => void): () => void {
const fiber = Effect.runFork(
pipe(
stream,
Stream.tap((event) =>
Effect.sync(() => {
onEvent(event.detail);
})
),
Stream.runDrain
)
);
return () => {
Effect.runFork(
Effect.sync(() => {
(fiber as unknown as { interrupt: () => void }).interrupt();
})
);
};
}

View File

@ -0,0 +1,210 @@
import { decoder, encoder, wakuNode } from '$lib/waku/waku.svelte';
import { type Message, encodeMessage, decodeMessage } from '@waku/sds';
import type { MatchParams } from './waku.svelte';
import { getOrCreateChannel } from '$lib/sds/channel.svelte';
import { Effect, Schedule, Stream, Option, Queue, Chunk, Ref } from 'effect';
import type { SubscribeResult } from '@waku/sdk';
import { messageHash } from '@waku/message-hash';
import { hash } from '$lib/utils/hash';
import { encodeBase64 } from 'effect/Encoding';
import type { MessageChannel } from '@waku/sds';
import { sweepIn, sweepOut } from '$lib/sds.svelte';
export function send(channel: MessageChannel, payload: Uint8Array) {
return channel.sendMessage(payload, async (message: Message) => {
const encodedMessage = encodeMessage(message);
const timestamp = new Date();
const protoMessage = await encoder.toProtoObj({
payload: encodedMessage,
timestamp
});
const hash = messageHash(encoder.pubsubTopic, protoMessage);
const result = await wakuNode.sendWithLightPush(encodedMessage, timestamp);
if (result.failures.length > 0) {
console.error('error sending message', result.failures);
}
console.log('sent message over waku', message);
return {
success: true,
retrievalHint: hash
};
});
}
export function start(params: MatchParams) {
console.log('starting pingpong', params);
const { matchId } = params;
const first = params.myPeerId.localeCompare(params.otherPeerId) < 0 ? true : false;
let lastMessage = new Date();
const sinkTimeout = 10_000;
const channel = getOrCreateChannel(matchId);
// Create a stream of the messages scheduled based on the strategy
// we can predetermine the list of messages we expect to send
const payloadsStream = Stream.make(
...Array.from({ length: params.messages }, (_, i) => {
return new TextEncoder().encode(hash(matchId + params.myPeerId + i));
})
);
const skips = Array.from({ length: params.messages * 2 }, (_, i) =>
i % 2 === 0 ? first : !first
);
const skipsStream = Stream.fromIterable(skips);
const payloadsWithSkips = Effect.gen(function* () {
const skips = yield* Stream.runCollect(skipsStream);
let payloads = yield* Stream.runCollect(payloadsStream);
return Chunk.map(skips, (skip): Option.Option<Uint8Array> => {
if (skip) {
return Option.none();
}
const head = Chunk.takeRight(1)(payloads);
const result = Chunk.get(head, 0);
return Option.match(result, {
onNone: () => Option.none(),
onSome: (value) => {
payloads = Chunk.dropRight(1)(payloads);
return Option.some(value);
}
});
});
});
const sendEffect = (payload: Option.Option<Uint8Array>) =>
Effect.async<void, Error>((resume) => {
if (Option.isNone(payload)) {
return resume(Effect.succeed(undefined));
}
send(channel, Option.getOrThrow(payload))
.then((result) => resume(Effect.succeed(result)))
.catch((error) => resume(Effect.fail(new Error(error as string))));
});
// Creating a bounded queue with a capacity of 100
// Initialize the queue with the payloads based on strategy
const initializeQueue = Effect.gen(function* () {
const sendQueue = Queue.bounded<Option.Option<Uint8Array>>(100);
console.log('initializing queue');
const q = yield* sendQueue;
const result = yield* q.offerAll(yield* payloadsWithSkips);
console.log('queue initialized', result);
return q;
});
const takeAndSend = (sendQueue: Queue.Queue<Option.Option<Uint8Array>>) =>
Effect.gen(function* () {
console.log('taking and sending');
const result = yield* sendQueue.take;
console.log('result', result);
return yield* sendEffect(result);
});
const validateMessage = (message: Message) => {
console.log('validating message', message);
if (message.channelId !== channel.channelId) {
console.error('Message is not for this match');
return false;
}
return true;
};
const subscribe = (listenCondition: Ref.Ref<boolean>) =>
Effect.async<SubscribeResult, Error>((resume) => {
try {
console.log('subscribing to filter');
wakuNode.node?.filter
.subscribe([decoder], async (message) => {
const listening = await Effect.runPromise(listenCondition.get);
console.log('listening', listening);
if (!listening) {
return;
}
console.log('received filter message', message);
const hash = encodeBase64(messageHash(encoder.pubsubTopic, message));
const sdsMessage = decodeMessage(message.payload) as unknown as Message;
if (validateMessage(sdsMessage) && !sent.has(hash)) {
channel.receiveMessage(sdsMessage);
lastMessage = new Date();
}
})
.then((result) => {
Effect.runPromise(Ref.set(listenCondition, true));
resume(Effect.succeed(result));
});
} catch (error) {
resume(Effect.fail(new Error(error as string)));
}
});
const sent = new Map<string, boolean>();
const sendSync = Effect.async<boolean, Error>((resume) => {
console.log('sending sync', new Date().getTime() - lastMessage.getTime());
if (new Date().getTime() - lastMessage.getTime() < sinkTimeout + Math.random() * 2000) {
console.log('sink timeout', new Date().getTime() - lastMessage.getTime());
return resume(Effect.succeed(false));
} else {
channel
.sendSyncMessage(async (message: Message) => {
const encodedMessage = encodeMessage(message);
const timestamp = new Date();
const protoMessage = await encoder.toProtoObj({
payload: encodedMessage,
timestamp,
});
const hash = encodeBase64(messageHash(encoder.pubsubTopic, protoMessage));
sent.set(hash, true);
const result = await wakuNode.sendWithLightPush(encodedMessage, timestamp);
if (result.failures.length > 0) {
console.error('error sending message', result.failures);
}
console.log('sent message over waku', message);
lastMessage = new Date();
return true;
})
.then((result) => resume(Effect.succeed(result)))
.catch((error) => resume(Effect.fail(new Error(error as string))));
}
});
const processQueue = Effect.sync(async () => {
await channel.processTasks();
});
const sweep = Effect.sync(async () => {
await sweepIn(channel);
const result = sweepOut();
console.log('unacknowledged ', result.unacknowledged.length);
});
return Effect.runPromise(
Effect.gen(function* () {
const listenCondition = yield* Ref.make(false);
const queue = yield* initializeQueue;
yield* Effect.all(
[
// setup filter
subscribe(listenCondition),
// send messages
Effect.repeat(takeAndSend(queue), Schedule.spaced('2000 millis')),
// Effect.repeat(takeAndSend, Schedule.spaced('2000 millis')),
// periodic sync
Effect.repeat(sendSync, Schedule.spaced('10000 millis')),
// periodically process queue
Effect.repeat(processQueue, Schedule.spaced('200 millis')),
// periodically sweep buffers
Effect.repeat(sweep, Schedule.spaced('500 millis')),
// periodically switch off filter to miss messages
Effect.repeat(
Ref.update(listenCondition, (listening) => !listening),
Schedule.spaced(first ? '5000 millis' : `${5000 * 2} millis`)
)
],
{
concurrency: 'unbounded'
}
);
})
);
}

View File

@ -8,11 +8,18 @@ import {
type ISubscription,
type SDKProtocolResult,
createDecoder,
createEncoder
createEncoder,
type SubscribeResult
} from '@waku/sdk';
import { Effect, Schedule, Fiber } from 'effect';
import { writable } from 'svelte/store';
import { PeerState, stateTransitionStream, subscribeToStateTransitionStream } from './lobby.svelte';
import type { LobbyMessage, StateTransitionDetail } from './lobby.svelte';
import { LobbyMessageType, processUpdates } from './lobby.svelte';
import { hash } from '../utils/hash';
import type { RuntimeFiber } from 'effect/Fiber';
const contentTopic = '/sds-demo/1/messages/proto';
const lobbyContentTopic = '/sds-demo/1/lobby/proto';
export const encoder = createEncoder({
contentTopic,
@ -21,6 +28,12 @@ export const encoder = createEncoder({
export const decoder = createDecoder(contentTopic, { clusterId: 42, shard: 0 });
export const lobbyEncoder = createEncoder({
contentTopic: lobbyContentTopic,
pubsubTopicShardInfo: { clusterId: 42, shard: 0 }
});
export const lobbyDecoder = createDecoder(lobbyContentTopic, { clusterId: 42, shard: 0 });
export class WakuNode {
public node = $state<LightNode | undefined>(undefined);
@ -65,14 +78,14 @@ export class WakuNode {
}
return await node.lightPush.send(encoder, {
payload: payload,
timestamp: timestamp
timestamp: timestamp,
});
}
public queryStore(messageHashes: Uint8Array[]) {
return node?.store.queryGenerator([decoder], {
includeData: true,
messageHashes,
messageHashes
});
}
}
@ -122,7 +135,7 @@ export async function startWaku(): Promise<void> {
// Connect to peers
await node.dial(
// '/dns4/waku-test.bloxy.one/tcp/8095/wss/p2p/16Uiu2HAmSZbDB7CusdRhgkD81VssRjQV5ZH13FbzCGcdnbbh6VwZ'
"/ip4/127.0.0.1/tcp/8000/ws/p2p/16Uiu2HAm3TLea2NVs4dAqYM2gAgoV9CMKGeD1BkP3RAvmk7HBAbU"
'/ip4/127.0.0.1/tcp/8000/ws/p2p/16Uiu2HAm3TLea2NVs4dAqYM2gAgoV9CMKGeD1BkP3RAvmk7HBAbU'
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).waku = node;
@ -180,3 +193,165 @@ export function unregisterHealthListener(callback: (health: HealthStatus) => voi
}
);
}
export enum Strategy {
PingPong = 'ping-pong',
Linear = 'linear'
}
export type MatchParams = {
matchId: string;
myPeerId: string;
otherPeerId: string;
startTime: Date;
messages: number;
strategy: Strategy;
};
export async function joinLobby(): Promise<MatchParams> {
if (!node) {
throw new Error('Waku node not started');
}
// Setup id and peer state
const id = node?.libp2p.peerId.toString();
if (!id) {
throw new Error('Peer ID not found');
}
let lastMessage: number = 0;
const subscribeToLobby = Effect.async<SubscribeResult, Error>((resume) => {
try {
node?.filter
.subscribe([lobbyDecoder], (message) => {
const messageData: LobbyMessage = JSON.parse(new TextDecoder().decode(message.payload));
if (messageData.from === id) {
return;
}
if (messageData.messageType === LobbyMessageType.Ping || messageData.to === id) {
processUpdates([{ peerId: messageData.from, message: messageData, sent: false }]);
}
console.log('Received lobby message:', messageData);
lastMessage = Date.now();
})
.then((result) => resume(Effect.succeed(result)));
} catch (error) {
resume(Effect.fail(new Error(error as string)));
}
});
const sendMessage = (message: LobbyMessage) =>
Effect.async<SDKProtocolResult, Error>((resume) => {
node?.lightPush
.send(lobbyEncoder, {
payload: new TextEncoder().encode(JSON.stringify(message)),
timestamp: new Date()
})
.then((result) => resume(Effect.succeed(result)))
.catch((error) => resume(Effect.fail(new Error(error as string))));
});
const policy = Schedule.spaced('1000 millis');
const repeated = Effect.retry(subscribeToLobby, policy);
let subscription: ISubscription;
Effect.runPromise(repeated).then((sub) => {
if (sub.subscription) {
subscription = sub.subscription;
}
});
const getMatchParams = Effect.async<MatchParams, Error>((resume) => {
let fiber: () => void = () => {};
let pingFiber: RuntimeFiber<number, Error> | null = null;
const pingSchedule = Schedule.spaced('1000 millis');
const ping = Effect.async<number, Error>((resume) => {
if (lastMessage + 2000 < Date.now()) {
Effect.runFork(
sendMessage({
messageType: LobbyMessageType.Ping,
from: id,
timestamp: new Date()
})
);
}
resume(Effect.succeed(0));
});
const handleStateTransition = async (event: StateTransitionDetail) => {
if (event.newState === PeerState.Found) {
// Found a peer, send a request to match
// or ignore
console.log(`Found peer ${event.peerId}`);
const message = {
messageType: LobbyMessageType.Request,
from: id,
to: event.peerId,
timestamp: new Date()
};
console.log('Sending request to match:', message);
await processUpdates([{ peerId: event.peerId, message, sent: true }]);
await Effect.runPromise(sendMessage(message));
} else if (event.newState === PeerState.RequestFrom) {
// Received a request to match, send an accept
// or ignore
const message = {
messageType: LobbyMessageType.Accept,
from: id,
to: event.peerId,
timestamp: new Date()
};
console.log('Sending accept to match:', message);
await Effect.runPromise(sendMessage(message));
await processUpdates([{ peerId: event.peerId, message, sent: true }]);
} else if (event.newState === PeerState.AcceptFrom) {
console.log(`Accepted match from ${event.peerId}`);
// We received an accept to match, start match
// or ignore
const message = {
messageType: LobbyMessageType.Match,
from: id,
to: event.peerId,
timestamp: new Date()
};
console.log('Sending match:', message);
await Effect.runPromise(sendMessage(message));
await processUpdates([{ peerId: event.peerId, message, sent: true }]);
} else if (event.newState === PeerState.Success) {
console.log(`Match started with ${event.peerId}`);
const matchId = hash(
id.localeCompare(event.peerId) < 0 ? `${id}-${event.peerId}` : `${event.peerId}-${id}`
);
console.log(event.message);
console.log(event.message.timestamp);
const params: MatchParams = {
matchId,
myPeerId: id,
otherPeerId: event.peerId,
startTime: new Date(new Date(event.message.timestamp).getTime() + 10_000),
messages: 20,
strategy: Strategy.PingPong
};
// if we sent a match, then start the simulation
// stop responding to lobby messages, as we should now be in a match
if (fiber) {
fiber();
}
if (pingFiber) {
Effect.runFork(Fiber.interrupt(pingFiber));
}
if (subscription) {
subscription.unsubscribe([lobbyDecoder.contentTopic]);
}
resume(Effect.succeed(params));
}
};
fiber = subscribeToStateTransitionStream(stateTransitionStream, handleStateTransition);
setTimeout(() => {
pingFiber = Effect.runFork(Effect.repeat(ping, pingSchedule));
}, 2000);
});
return Effect.runPromise(getMatchParams);
}

View File

@ -15,7 +15,7 @@
<div id="layout-container" class="fixed inset-0 overflow-hidden flex flex-col">
<Header>
<ActionModule />
<!-- <ActionModule /> -->
<ConnectionIndicator />
</Header>
<div class="flex-1 overflow-auto my-1">

View File

@ -0,0 +1,81 @@
<script lang="ts">
import ConnectionButton from '$lib/components/ConnectionButton.svelte';
import { connectionState, joinLobby } from '$lib/waku/waku.svelte';
import PageLayout from '$lib/components/PageLayout.svelte';
import CallToAction from '$lib/components/CallToAction.svelte';
import { goto } from '$app/navigation';
import { setMatch } from '$lib/utils/match.svelte';
import { getOrCreateChannel } from '$lib/sds/channel.svelte';
// Redirect to history page when connected
let matchFound = $state(false);
$effect(() => {
if ($connectionState.status === 'connected' && matchFound) {
goto('/state-graph');
}
});
const afterConnect = (status: (state: string) => void) => {
status('Finding match...');
joinLobby()
.then((params) => {
getOrCreateChannel(params.matchId);
setMatch(params);
matchFound = true;
status('Match found!');
})
.catch((error) => {
status('Error finding match ' + error);
});
};
</script>
<PageLayout title="Scalable Data Sync" maxWidth="md">
<div class="flex justify-center">
<div class="perspective-500 flex h-64 w-64 items-center justify-center overflow-hidden">
<img
src="/waku-mark-primary-black.svg"
alt="Waku Logo"
class="h-full w-full scale-125 transform {$connectionState.status === 'connecting' ||
$connectionState.status === 'waiting_for_peers' ||
$connectionState.status === 'setting_up_subscriptions' ||
$connectionState.status === 'connected'
? 'animate-spin-y'
: ''}"
/>
</div>
</div>
<CallToAction
message="Connect to the Waku network to get started"
useSlot={true}
marginTop="sm:mt-0 mt-10"
messageMarginBottom="mb-4"
>
<div class="flex w-full justify-center">
<ConnectionButton size="large" afterConnect={afterConnect} />
</div>
</CallToAction>
</PageLayout>
<style>
.perspective-500 {
perspective: 500px;
}
@keyframes spin-y {
0% {
transform: scale(1.25) rotateY(0deg);
}
50% {
transform: scale(1.25) rotateY(180deg);
}
100% {
transform: scale(1.25) rotateY(360deg);
}
}
.animate-spin-y {
animation: spin-y 10s infinite linear;
transform-style: preserve-3d;
}
</style>

View File

@ -1,42 +1,48 @@
<script lang="ts">
import History from '$lib/components/History.svelte';
import StateGraphDashboard from '$lib/components/StateGraphDashboard.svelte';
import StateGraphSummary from '$lib/components/StateGraphSummary.svelte';
import { getMatch } from '$lib/utils/match.svelte';
import { goto } from '$app/navigation';
import type { MatchParams } from '$lib/waku/waku.svelte';
import { onMount } from 'svelte';
import { start } from '$lib/waku/pingpong.svelte';
let match = $state<MatchParams | undefined>(undefined);
onMount(() => {
match = getMatch();
if (!match) {
goto('/lobby');
} else {
start(match);
}
});
</script>
<div class="mx-1 flex h-full flex-row" style="overflow: hidden;">
<!-- History Sidebar -->
<div
class="top-0 right-0 left-0 mr-1 rounded-lg bg-white sm:shadow-md"
style="
{#if match}
<div class="mx-1 flex h-full flex-row" style="overflow: hidden;">
<!-- History Sidebar -->
<div
class="top-0 right-0 left-0 mr-1 rounded-lg bg-white sm:shadow-md"
style="
background-color: rgb(201 201 201);
display: flex;
"
>
<History />
</div>
>
<History channelId={match?.matchId ?? null} />
</div>
<div class="flex w-full flex-col overflow-hidden">
<!-- Summary State Graph -->
<div
class="top-0 right-0 left-0 mb-1 h-full w-full flex-5 basis-3/4 rounded-lg bg-white p-8 sm:shadow-md"
style="
<div class="flex w-full flex-col overflow-hidden">
<!-- Summary State Graph -->
<div
class="top-0 right-0 left-0 mb-1 h-full w-full flex-5 basis-3/4 rounded-lg bg-white p-8 sm:shadow-md"
style="
background-color: rgb(182 195 206);
align-content: center;
overflow: auto;
"
>
<StateGraphSummary />
>
<StateGraphSummary channelId={match?.matchId ?? null} />
</div>
</div>
<!-- Detailed State Graph
<div
class="top-0 right-0 left-0 basis-1/4 rounded-lg bg-white p-1 sm:shadow-md"
style="
background-color: rgb(151 174 194);
width: 100%;
"
>
<StateGraphDashboard />
</div> -->
</div>
</div>
{/if}