mirror of
https://github.com/logos-messaging/lab.waku.org.git
synced 2026-01-02 13:53:09 +00:00
matchmaking and message sending scenarios
This commit is contained in:
parent
283a0a389e
commit
03d92e9e7c
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
52
examples/sds-demo/src/lib/utils/event.svelte.ts
Normal file
52
examples/sds-demo/src/lib/utils/event.svelte.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -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'
|
||||
};
|
||||
7
examples/sds-demo/src/lib/utils/hash.ts
Normal file
7
examples/sds-demo/src/lib/utils/hash.ts
Normal 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));
|
||||
}
|
||||
|
||||
11
examples/sds-demo/src/lib/utils/match.svelte.ts
Normal file
11
examples/sds-demo/src/lib/utils/match.svelte.ts
Normal 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;
|
||||
}
|
||||
@ -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 = (
|
||||
|
||||
216
examples/sds-demo/src/lib/waku/lobby.svelte.ts
Normal file
216
examples/sds-demo/src/lib/waku/lobby.svelte.ts
Normal 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();
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
210
examples/sds-demo/src/lib/waku/pingpong.svelte.ts
Normal file
210
examples/sds-demo/src/lib/waku/pingpong.svelte.ts
Normal 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'
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
81
examples/sds-demo/src/routes/lobby/+page.svelte
Normal file
81
examples/sds-demo/src/routes/lobby/+page.svelte
Normal 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>
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user