feat(sds): initial commit for sds demo

add legend explaining sds concepts
add store queries for missed messages
add history visualization partitioned and ordered by lamport timestamp
matchmaking and message sending scenarios
add a theme
better theme
support more than 2 participants
This commit is contained in:
Arseniy Klempner 2025-03-17 18:59:31 -07:00
parent 233623b136
commit f6d477d534
No known key found for this signature in database
GPG Key ID: 51653F18863BD24B
57 changed files with 13906 additions and 0 deletions

25
examples/sds-demo/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
node_modules
.cursorrules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
examples/sds-demo/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

View File

@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

View File

@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

View File

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@ -0,0 +1,39 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
ignores: ['eslint.config.js', 'svelte.config.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

9250
examples/sds-demo/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,75 @@
{
"name": "sds-demo",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.6",
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@storybook/addon-essentials": "^8.6.11",
"@storybook/addon-interactions": "^8.6.11",
"@storybook/addon-links": "^8.6.11",
"@storybook/addon-svelte-csf": "^5.0.0-next.0",
"@storybook/blocks": "^8.6.11",
"@storybook/experimental-addon-test": "^8.6.11",
"@storybook/svelte": "^8.6.11",
"@storybook/sveltekit": "^8.6.11",
"@storybook/test": "^8.6.11",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/protobufjs": "^6.0.0",
"@vitest/browser": "^3.1.1",
"@vitest/coverage-v8": "^3.1.1",
"@waku/interfaces": "0.0.31-006cd41.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-storybook": "^0.12.0",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"playwright": "^1.51.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"storybook": "^8.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.0",
"vitest": "^3.1.1"
},
"dependencies": {
"@noble/hashes": "^1.7.1",
"@tanstack/svelte-virtual": "^3.13.5",
"@types/identicon.js": "^2.3.5",
"@waku/message-hash": "0.1.20-006cd41.0",
"@waku/sdk": "0.0.32-006cd41.0",
"@waku/sds": "0.0.4-006cd41.0",
"@waku/utils": "0.0.24-006cd41.0",
"effect": "^3.14.1",
"identicon.js": "^2.3.3",
"protobufjs": "^7.4.0",
"svelte-sonner": "^0.3.28"
},
"eslintConfig": {
"extends": [
"plugin:storybook/recommended"
]
}
}

View File

@ -0,0 +1 @@
@import 'tailwindcss';

13
examples/sds-demo/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -0,0 +1,247 @@
<script lang="ts">
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 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 startSending() {
match = getMatch();
if (!match) {
startSendingHistory();
} else {
const channel = getOrCreateChannel(match.matchId);
send(channel, randomBytes(32));
}
}
function sweep() {
sweepIn();
sweepOut();
}
// 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 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;
}
}
}
}
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">
{#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;
}
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;
}
.listen-button {
background-color: #10b981;
color: white;
}
.listen-button:hover:not(:disabled) {
background-color: #059669;
}
.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>

View File

@ -0,0 +1,41 @@
<script lang="ts">
import ActionButtons from './ActionButtons.svelte';
import { onMount, onDestroy } from 'svelte';
import { processQueue } from '$lib/sds.svelte';
import { connectionState } from '../waku/waku.svelte';
let isComponentMounted = false;
async function runProcessQueue() {
if (!isComponentMounted) return;
await processQueue();
// Schedule the next run if component is still mounted
if (isComponentMounted) {
setTimeout(runProcessQueue, 1000);
}
}
onMount(() => {
isComponentMounted = true;
runProcessQueue();
});
onDestroy(() => {
isComponentMounted = false;
});
</script>
{#if $connectionState.status === "connected"}
<div class="compact-action-module">
<ActionButtons />
</div>
{/if}
<style>
.compact-action-module {
display: flex;
margin-right: 1rem;
}
</style>

View File

@ -0,0 +1,19 @@
<script lang="ts">
export let message: string;
export let linkText: string = "";
export let linkHref: string = "";
export let marginTop: string = "mt-6";
export let messageMarginBottom: string = "mb-2";
export let useSlot: boolean = false;
</script>
<div class="{marginTop} pt-6 border-t border-gray-200 text-center w-full">
<p class="text-gray-600 {messageMarginBottom}">{message}</p>
{#if useSlot}
<slot />
{:else}
<a href={linkHref} class="text-blue-600 hover:text-blue-800 font-medium"
>{linkText}</a
>
{/if}
</div>

View File

@ -0,0 +1,141 @@
<script lang="ts">
import { startWaku, connectionState } from '../waku/waku.svelte';
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);
}
}
// 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="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.large button {
font-size: 1.1rem;
}
.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;
}
.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;
}
.status {
padding: 0.5rem 1rem;
border-radius: 4px;
background-color: #e5e7eb;
color: #374151;
}
.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;
}
.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;
}
}
</style>

View File

@ -0,0 +1,243 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { connectionState, startWaku } from "../waku/waku.svelte";
import { HealthStatus } from "@waku/sdk";
import { health, unregisterHealthListener } from "../waku/waku.svelte";
import { page } from '$app/state';
import { listening } from "../waku/pingpong.svelte";
let healthStatus = $state(HealthStatus.Unhealthy);
let isHomePage = $state(false);
let shouldShowConnectButton = $state(false);
function startHealthCheck() {
health((health: HealthStatus) => {
healthStatus = health;
});
}
function stopHealthCheck() {
unregisterHealthListener((health: HealthStatus) => {
healthStatus = health;
});
}
$effect(() => {
if ($connectionState.status === "connected") {
startHealthCheck();
} else {
stopHealthCheck();
}
});
onMount(() => {
if ($connectionState.status === "connected") {
startHealthCheck();
}
});
onDestroy(() => {
stopHealthCheck();
});
function getHealthColor() {
const status = listening.listening ? HealthStatus.SufficientlyHealthy : HealthStatus.Unhealthy;
if ($connectionState.status !== "connected") {
return "gray";
}
switch (status) {
case HealthStatus.SufficientlyHealthy:
return "green";
// case HealthStatus.MinimallyHealthy:
// return "goldenrod";
case HealthStatus.Unhealthy:
default:
return "red";
}
}
function getHealthText(status: HealthStatus) {
if ($connectionState.status !== "connected") {
return "Node is not connected";
}
switch (status) {
case HealthStatus.SufficientlyHealthy:
return "Node is healthy";
case HealthStatus.MinimallyHealthy:
return "Node is minimally healthy";
case HealthStatus.Unhealthy:
default:
return "Node is unhealthy";
}
}
async function handleConnect() {
try {
await startWaku();
} catch (error) {
console.error("Connection error in header:", error);
}
}
// Check if current route is not the home page
$effect(() => {
isHomePage = page.url.pathname ? page.url.pathname === "/" : true;
updateButtonVisibility();
});
$effect(() => {
updateButtonVisibility();
});
function updateButtonVisibility() {
shouldShowConnectButton = !isHomePage && $connectionState.status !== "connected";
}
</script>
<div class="connection-status">
{#if shouldShowConnectButton}
<div class="header-connection-ui">
{#if $connectionState.status === "disconnected"}
<button
on:click={handleConnect}
class="connect-button px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600 transition-colors duration-200 mr-2"
>
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 === "error"}
<div class="error-container">
<button
on:click={handleConnect}
class="connect-button px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors duration-200 mr-2"
>
Retry
</button>
</div>
{/if}
</div>
{/if}
<div class="status-wrapper">
<div
class="health-indicator"
style="background-color: {getHealthColor()}"
>
<span class="tooltip">{getHealthText(healthStatus)}</span>
</div>
</div>
</div>
<style>
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-wrapper {
position: relative;
margin-right: 1rem;
}
.health-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
transition: background-color 0.3s ease;
cursor: help;
position: relative;
}
.tooltip {
visibility: hidden;
position: absolute;
background-color: #333;
color: white;
text-align: center;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
white-space: nowrap;
/* Position the tooltip below */
top: 100%;
right: 0; /* Align to the right instead of center since we're near screen edge */
transform: translateX(0); /* Remove horizontal centering */
margin-top: 8px;
/* Ensure tooltip stays in viewport */
max-width: calc(100vw - 2rem); /* Leave 1rem padding on each side */
overflow: hidden;
text-overflow: ellipsis;
/* Add a small triangle pointer */
&::before {
content: "";
position: absolute;
bottom: 100%;
right: 2px; /* Align arrow with the indicator */
transform: translateX(0);
border-width: 4px;
border-style: solid;
border-color: transparent transparent #333 transparent;
}
}
/* Add animation for smooth appearance */
.health-indicator:hover .tooltip {
visibility: visible;
animation: fadeIn 0.2s ease-in-out;
}
.header-connection-ui {
display: flex;
align-items: center;
}
.connect-button {
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
}
.status {
font-size: 0.75rem;
padding: 0.35rem 0.7rem;
border-radius: 4px;
background-color: #e5e7eb;
color: #374151;
white-space: nowrap;
margin-right: 0.5rem;
}
.error-container {
display: flex;
align-items: center;
}
.animated-dots {
animation: dotAnimation 1.5s infinite;
}
@keyframes dotAnimation {
0% { opacity: 0.3; }
50% { opacity: 1; }
100% { opacity: 0.3; }
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,19 @@
<script lang="ts">
// Simple header component that accepts a slot
</script>
<div class="header">
<slot />
</div>
<style>
.header {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 50;
display: flex;
align-items: center;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,317 @@
<script lang="ts">
import { subscribeToAllEventsStream, subscribeToChannelEvents } 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 HistoryItem from './HistoryItem.svelte';
import LegendModal from './LegendModal.svelte';
import { matchesIdFilter, currentIdFilter } from '$lib/utils/event.svelte';
import { hash } from '$lib/utils/hash';
// Store for history items
let history: Array<MessageChannelEventObject> = $state([]);
let identicon: any = $state(null);
let currentFilter: string = $state('all');
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,
syncSent: MessageChannelEvent.SyncSent,
syncReceived: MessageChannelEvent.SyncReceived
};
// Calculate counts of each event type
let eventCounts = $derived({
all: history.length,
sent: history.filter((event) => event.type === MessageChannelEvent.MessageSent).length,
received: history.filter((event) => event.type === MessageChannelEvent.MessageReceived).length,
delivered: history.filter((event) => event.type === MessageChannelEvent.MessageDelivered)
.length,
acknowledged: history.filter((event) => event.type === MessageChannelEvent.MessageAcknowledged)
.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
let filteredHistory = $derived(
(() => {
// First filter by type
let result =
currentFilter === 'all'
? [...history]
: history.filter((event) => event.type === filterMap[currentFilter]);
// Then filter by ID if present
if (currentIdFilter.id) {
result = result.filter(matchesIdFilter);
}
return result;
})()
);
let identicons = $derived(
identicon &&
filteredHistory.map((event: MessageChannelEventObject) => {
const id = getMessageId(event);
// Handle the case where id could be null
return new identicon(id || '', { size: 40, format: 'svg' }).toString();
})
);
// Unsubscribe function
let unsubscribe: (() => void) | null = $state(null);
// Handle filter change
function handleFilterChange(event: Event) {
const select = event.target as HTMLSelectElement;
currentFilter = select.value;
}
// Handle event item click to filter by ID
function handleEventClick(id: string | null) {
if (id !== null) {
currentIdFilter.id = id;
}
}
// Handle dependency click to filter by dependency messageId
function handleDependencyClick(messageId: string, event: Event) {
// Stop event propagation so it doesn't trigger parent click handler
event.stopPropagation();
currentIdFilter.id = messageId;
}
// Clear ID filter
function clearIdFilter() {
currentIdFilter.id = null;
}
// Toggle legend display
function toggleLegend() {
showLegend = !showLegend;
}
function eventStreamCallback(event: MessageChannelEventObject) {
// Add event to history with newest at the top
if (event.type === MessageChannelEvent.MissedMessages) {
return;
}
if (event.type === MessageChannelEvent.SyncSent || event.type === MessageChannelEvent.SyncReceived) {
event.payload.messageId = hash(event.payload.messageId + event.payload.causalHistory[0].messageId)
}
history = [event, ...history];
}
onMount(async () => {
identicon = await getIdenticon();
// Subscribe to the event stream and collect events
unsubscribe = channelId
? subscribeToChannelEvents(channelId, eventStreamCallback)
: subscribeToAllEventsStream(eventStreamCallback);
});
onDestroy(() => {
// Clean up the subscription when component is destroyed
if (unsubscribe) {
unsubscribe();
}
});
</script>
<div class="history-container">
<div class="header">
<button class="help-button" onclick={toggleLegend}>?</button>
<select class="item-filter" onchange={handleFilterChange} value={currentFilter}>
<option value="all">All ({eventCounts.all})</option>
<option value="sent">Sent ({eventCounts.sent})</option>
<option value="received">Received ({eventCounts.received})</option>
<option value="delivered">Delivered ({eventCounts.delivered})</option>
<option value="acknowledged">Acknowledged ({eventCounts.acknowledged})</option>
<option value="syncSent">Sync Sent ({eventCounts.syncSent})</option>
<option value="syncReceived">Sync Received ({eventCounts.syncReceived})</option>
</select>
</div>
{#if currentIdFilter.id }
<div class="id-filter-badge">
<span class="id-label">ID: {currentIdFilter.id}</span>
<button class="clear-filter-btn" onclick={clearIdFilter}>×</button>
</div>
{/if}
<div class="history-items-container">
{#each filteredHistory as event, index}
<HistoryItem
{event}
identicon={identicons[index]}
currentIdFilter={currentIdFilter.id}
onEventClick={handleEventClick}
onDependencyClick={handleDependencyClick}
/>
{/each}
</div>
<div class="bottom-fade"></div>
<LegendModal bind:isOpen={showLegend} />
</div>
<style>
.history-container {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
min-width: 400px;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
background-color: #ffffff;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
position: relative;
border: 1px solid #e0ddd4;
padding: 12px;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.history-container::-webkit-scrollbar {
display: none;
}
.history-items-container {
flex: 1;
position: relative;
padding-bottom: 30px; /* Add space for the fade effect */
width: 100%;
overflow-x: hidden;
}
.bottom-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1));
pointer-events: none; /* Allows interaction with elements underneath */
z-index: 1;
}
.virtualizer-container {
flex: 1;
overflow: auto;
padding: 10px;
}
.header {
display: flex;
align-items: center;
padding: 8px 8px 16px 8px;
border-bottom: 1px solid #e0ddd4;
margin-bottom: 12px;
position: relative;
}
.help-button {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: #f5f2e8;
border: 1px solid #e0ddd4;
color: #333333;
font-weight: bold;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 12px;
transition: all 0.2s;
}
.help-button:hover {
background-color: #e8e5db;
}
.item-filter {
flex: 1;
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #e0ddd4;
background-color: white;
font-size: 14px;
color: #333333;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
transition: all 0.2s;
}
.item-filter:hover, .item-filter:focus {
border-color: #ccc9c2;
outline: none;
}
.id-filter-badge {
display: flex;
align-items: center;
background-color: #f5f2e8;
border-radius: 4px;
padding: 6px 12px;
margin: 0 8px 12px 8px;
max-width: fit-content;
font-size: 12px;
color: #333333;
border: 1px solid #e0ddd4;
}
.id-label {
font-family: monospace;
margin-right: 8px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: bold;
}
.clear-filter-btn {
background: rgba(0, 0, 0, 0.1);
border: none;
color: #333333;
font-size: 16px;
font-weight: bold;
cursor: pointer;
padding: 0 6px;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.clear-filter-btn:hover {
background: rgba(0, 0, 0, 0.2);
}
/* Ensure all child elements don't cause horizontal overflow */
.id-filter-badge, .header, .item-filter {
max-width: 100%;
box-sizing: border-box;
}
</style>

View File

@ -0,0 +1,230 @@
<script lang="ts">
import { MessageChannelEvent } from '@waku/sds';
import type { MessageChannelEventObject } from '$lib/sds/stream';
import { getMessageId } from '$lib/sds/message';
import { eventColors, eventNames } from '$lib/utils/event.svelte';
export let event: MessageChannelEventObject | undefined = undefined;
export let identicon: string = '';
export let currentIdFilter: string | null = null;
export let onEventClick: (id: string | null) => void = () => {};
export let onDependencyClick: (messageId: string, event: Event) => void = () => {};
export let width: number = 340;
export let height: number = 178;
export let overflow: boolean = true;
$: id = event ? getMessageId(event) : null;
$: color = event ? eventColors[event.type] || '#888' : '#f0f0f0';
$: name = event ? eventNames[event.type] || event.type : '';
$: matchesFilter = currentIdFilter && id === currentIdFilter;
function handleEventClick() {
if (event && id) {
onEventClick(id);
}
}
function handleDependencyClick(messageId: string, e: Event) {
onDependencyClick(messageId, e);
}
</script>
<div
class="history-item {!event ? 'empty' : ''}"
style="width: 100%;"
on:click={event ? handleEventClick : undefined}
>
{#if event}
<div class="item-container">
<div class="event-box {matchesFilter ? 'highlight' : ''}" style="background-color: {color};">
<div class="identicon">
<img src="data:image/svg+xml;base64,{identicon}" alt="Identicon" />
</div>
<div class="event-info" style="overflow: {overflow ? 'visible' : 'hidden'};">
<div class="event-type">
{name}
</div>
<div class="event-id">
{id}
</div>
</div>
{#if event.type === MessageChannelEvent.MessageSent || event.type === MessageChannelEvent.MessageReceived || event.type === MessageChannelEvent.SyncSent || event.type === MessageChannelEvent.SyncReceived}
<div class="lamport-timestamp">
{event.payload.lamportTimestamp}
</div>
{/if}
</div>
{#if event.type === MessageChannelEvent.MessageSent || event.type === MessageChannelEvent.MessageReceived || event.type === MessageChannelEvent.SyncSent || event.type === MessageChannelEvent.SyncReceived}
{#each event.payload.causalHistory as dependency}
{@const dependencyMatchesFilter =
currentIdFilter && dependency.messageId === currentIdFilter}
<div
class="dependency-box {dependencyMatchesFilter ? 'highlight' : ''}"
style="background-color: {color};"
on:click={(e) => handleDependencyClick(dependency.messageId, e)}
>
{dependency.messageId}
</div>
{/each}
{/if}
</div>
{/if}
</div>
<style>
.history-item {
padding: 8px;
box-sizing: border-box;
transition: transform 0.2s ease;
width: 100%;
max-width: 100%;
}
.history-item:not(.empty):hover {
transform: translateX(2px);
}
.history-item:not(.empty) {
cursor: pointer;
}
.empty {
border: 1px dashed rgba(0, 0, 0, 0.1);
border-radius: 4px;
background-color: #ffffff;
}
.item-container {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
width: 100%;
height: 100%;
overflow: hidden;
}
.event-box {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
border-radius: 4px;
width: 100%;
min-height: 60px;
color: white;
padding: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
position: relative;
transition: all 0.2s ease;
border: none;
box-sizing: border-box;
overflow: hidden;
}
.dependency-box {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
width: auto;
max-width: 80%;
min-height: 30px;
font-size: 11px;
font-family: monospace;
opacity: 0.9;
padding: 6px 10px;
border-radius: 4px;
color: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
}
.highlight {
border-left: 2px solid #db8d43;
border-right: 2px solid #db8d43;
}
.highlight .event-type {
font-size: 15px;
color: white;
font-weight: bold;
}
.highlight .event-id,
.dependency-box.highlight {
font-weight: bold;
}
.dependency-box.highlight {
font-size: 12px;
}
.identicon {
width: 36px;
height: 36px;
border-radius: 4px;
overflow: hidden;
margin-right: 12px;
position: relative;
}
.identicon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.event-info {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.event-type {
font-size: 14px;
font-weight: bold;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-id {
font-family: monospace;
font-size: 11px;
color: rgba(255, 255, 255, 0.8);
max-width: 220px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.lamport-timestamp {
position: absolute;
top: 12px;
right: 14px;
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
background-color: rgba(0, 0, 0, 0.1);
padding: 2px 6px;
border-radius: 4px;
}
.sent-or-received {
position: absolute;
top: 12px;
right: 14px;
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
background-color: rgba(0, 0, 0, 0.1);
padding: 2px 6px;
border-radius: 4px;
}
</style>

View File

@ -0,0 +1,64 @@
<script lang="ts">
import LegendModal from './LegendModal.svelte';
let showLegend = $state(false);
function toggleLegend() {
showLegend = !showLegend;
}
</script>
<div class="legend-demo">
<h2>Message Events Visualization</h2>
<div class="actions">
<button class="info-button" on:click={toggleLegend}>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
Show Event Types Legend
</button>
</div>
<LegendModal bind:isOpen={showLegend} />
</div>
<style>
.legend-demo {
padding: 20px;
}
h2 {
margin-bottom: 20px;
}
.actions {
margin-bottom: 20px;
}
.info-button {
display: flex;
align-items: center;
gap: 8px;
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
color: #374151;
transition: all 0.2s;
}
.info-button:hover {
background-color: #f3f4f6;
border-color: #d1d5db;
}
.info-button svg {
width: 16px;
height: 16px;
}
</style>

View File

@ -0,0 +1,371 @@
<script lang="ts">
import { historyJson } from "$lib/data/history_sample";
import type { MessageChannelEventObject } from "$lib/sds/stream";
import { MessageChannelEvent } from "@waku/sds";
import HistoryItem from "$lib/components/HistoryItem.svelte";
import { onMount, onDestroy } from 'svelte';
import { createTooltip } from '$lib/utils/tooltipUtils';
// Parse history data
const history: MessageChannelEventObject[] = JSON.parse(historyJson);
// Create sample identicons (using a placeholder)
const placeholderIdenticon = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNkZGQiLz48L3N2Zz4=";
// Sample handler functions - they don't need to do anything in the legend
function handleItemClick(id: string | null) {
// No action needed for the legend
}
function handleDependencyClick(messageId: string, event: Event) {
// Prevent event bubbling
event.stopPropagation();
// No action needed for the legend
}
// Get one sample of each event type for the legend
const eventTypes = [
MessageChannelEvent.MessageSent,
MessageChannelEvent.MessageDelivered,
MessageChannelEvent.MessageReceived,
MessageChannelEvent.MessageAcknowledged,
MessageChannelEvent.PartialAcknowledgement,
MessageChannelEvent.MissedMessages
];
// Find one event of each type to display in the legend
let legendItems = history.slice(2, 6);
// Simple tooltip text
const leftTooltipText = "Messages are sent between peers in the network. Each message has a unique ID and can depend on other messages. Events like Sent, Received, Delivered and Acknowledged show the status of messages.";
const rightTooltipText = "Dependencies represent messages that a current message depends on. They appear as smaller boxes below the main message.";
const lamportTooltipText = "<b>Lamport timestamps</b> provide a way to order events in a distributed system. They increment with each message, ensuring a consistent ordering across all peers in the network, even without synchronized clocks.";
const eventIdTooltipText = "Unique <b>Message ID</b> assigned to each message.";
const dependencyContainerTooltipText = "Each message comes with <b>causal history</b> attached, containing the last two message IDs from the local history of the sender.";
// Custom highlight classes
const tooltipHighlightClass = "tooltip-highlight";
console.log(legendItems);
// Make isOpen bindable so parent can track when modal is closed
export let isOpen = false;
// Reference elements for tooltips
let modalElement: HTMLElement;
let leftAnchorElement: HTMLElement;
let rightAnchorElement: HTMLElement;
let lamportTimestampElement: HTMLElement | null = null;
let eventIdElement: HTMLElement | null = null;
let dependencyContainerElement: HTMLElement | null = null;
// let leftTooltipRef: ReturnType<typeof createTooltip>;
// let rightTooltipRef: ReturnType<typeof createTooltip>;
let lamportTooltipRef: ReturnType<typeof createTooltip>;
let eventIdTooltipRef: ReturnType<typeof createTooltip>;
let dependencyContainerTooltipRef: ReturnType<typeof createTooltip>;
// Handles closing the modal and updates the parent via bindable prop
function closeModal() {
isOpen = false;
}
// Cleanup tooltips when component is destroyed
function cleanupTooltips() {
// leftTooltipRef?.destroy();
// rightTooltipRef?.destroy();
lamportTooltipRef?.destroy();
eventIdTooltipRef?.destroy();
dependencyContainerTooltipRef?.destroy();
}
// Create tooltips once elements are mounted and modal is open
function setupTooltips() {
// Clean up any existing tooltips first
cleanupTooltips();
if (!isOpen || !leftAnchorElement || !rightAnchorElement) return;
// Create left tooltip
// leftTooltipRef = createTooltip(leftAnchorElement, {
// position: 'left',
// content: leftTooltipText,
// width: 200,
// showOnHover: false,
// visible: true,
// offset: 20
// });
// Create right tooltip
// rightTooltipRef = createTooltip(rightAnchorElement, {
// position: 'right',
// content: rightTooltipText,
// width: 200,
// showOnHover: false,
// visible: true,
// offset: 20
// });
// Wait a moment to find and add tooltip to the specific elements
setTimeout(() => {
// Find the first lamport timestamp element
lamportTimestampElement = document.querySelector('.legend-content .lamport-timestamp');
if (lamportTimestampElement) {
// Create lamport timestamp tooltip
lamportTooltipRef = createTooltip(lamportTimestampElement, {
position: 'right',
content: lamportTooltipText,
width: 220,
showOnHover: false,
visible: true,
offset: 10,
verticalOffset: -100,
highlightTarget: true,
highlightClass: tooltipHighlightClass
});
}
// Find the first event-id element
eventIdElement = document.querySelector('.legend-content .event-id');
if (eventIdElement) {
// Create event-id tooltip
eventIdTooltipRef = createTooltip(eventIdElement, {
position: 'left',
content: eventIdTooltipText,
width: 180,
showOnHover: false,
visible: true,
offset: 10,
highlightTarget: true,
highlightClass: tooltipHighlightClass
});
}
// Find the first dependency-container element
dependencyContainerElement = document.querySelector('.legend-content .dependency-box');
if (dependencyContainerElement) {
// Create dependency-container tooltip
dependencyContainerTooltipRef = createTooltip(dependencyContainerElement, {
position: 'left',
content: dependencyContainerTooltipText,
width: 250,
showOnHover: false,
visible: true,
offset: 20,
verticalOffset: 40,
highlightTarget: true,
highlightClass: tooltipHighlightClass
});
}
}, 100);
}
onMount(() => {
if (isOpen) {
// Need to wait for the DOM to update
setTimeout(setupTooltips, 0);
}
});
onDestroy(() => {
cleanupTooltips();
});
// Watch for changes to isOpen state
$: if (isOpen) {
// Need to wait for the DOM to update
setTimeout(setupTooltips, 0);
} else {
cleanupTooltips();
}
</script>
{#if isOpen}
<div class="legend-modal-backdrop" on:click={closeModal}>
<div class="legend-container">
<!-- Main Modal (Center) -->
<div class="legend-modal" bind:this={modalElement} on:click|stopPropagation>
<!-- Tooltip anchor elements -->
<div class="tooltip-anchor left-anchor" bind:this={leftAnchorElement}></div>
<div class="tooltip-anchor right-anchor" bind:this={rightAnchorElement}></div>
<div class="legend-header">
<h2>Legend</h2>
<button class="close-button" on:click={closeModal}>×</button>
</div>
<div class="legend-content">
{#each legendItems as event}
<div class="legend-item">
<HistoryItem
{event}
identicon={placeholderIdenticon}
onEventClick={handleItemClick}
onDependencyClick={handleDependencyClick}
/>
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<style>
.legend-modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.legend-container {
position: relative;
display: flex;
justify-content: center;
align-items: flex-start;
}
.legend-modal {
position: relative;
background-color: white;
border-radius: 8px;
width: 600px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
z-index: 2;
}
.tooltip-anchor {
position: absolute;
width: 1px;
height: 40%;
top: 30%;
}
.left-anchor {
left: 0;
}
.right-anchor {
right: 0;
}
.legend-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #eee;
}
.legend-header h2 {
margin: 0;
font-size: 18px;
}
.close-button {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
.close-button:hover {
color: #333;
}
.legend-content {
padding: 16px;
}
.legend-item {
margin-bottom: 12px;
}
/* Custom highlight for lamport timestamp */
:global(.tooltip-highlight) {
background-color: rgba(253, 230, 138, 0.9) !important;
border-radius: 4px !important;
box-shadow: 0 0 0 2px #f59e0b !important;
color: #000 !important;
font-weight: bold !important;
animation: pulse 1.5s infinite !important;
max-width: none !important;
}
/* Custom highlight for event ID */
:global(.event-id-highlight) {
background-color: rgba(191, 219, 254, 0.9) !important;
border-radius: 4px !important;
box-shadow: 0 0 0 2px #3b82f6 !important;
color: #000 !important;
font-weight: bold !important;
animation: pulseCyan 1.5s infinite !important;
}
/* Custom highlight for dependency container */
:global(.dependency-container-highlight) {
background-color: rgba(220, 252, 231, 0.7) !important;
border-radius: 4px !important;
box-shadow: 0 0 0 2px #10b981 !important;
padding: 4px !important;
animation: pulseGreen 1.5s infinite !important;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
}
}
@keyframes pulseCyan {
0% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(59, 130, 246, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
}
}
@keyframes pulseGreen {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
/* Responsive adjustments */
@media (max-width: 1200px) {
.tooltip-anchor {
display: none;
}
}
</style>

View File

@ -0,0 +1,9 @@
<script lang="ts">
import type { Message } from "@waku/sds";
const { message }: { message: Message } = $props();
</script>
<div class="message-details">
<h1>{message.messageId}</h1>
</div>

View File

@ -0,0 +1,461 @@
<script lang="ts">
import {
subscribeToAllEventsStream,
subscribeToChannelEvents,
subscribeToMissingMessageStream
} from '$lib/sds/stream.svelte';
import { MessageChannelEvent, type HistoryEntry } 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 HistoryItem from './HistoryItem.svelte';
import LegendModal from './LegendModal.svelte';
import { currentIdFilter, eventColors } from '$lib/utils/event.svelte';
import { hash } from '$lib/utils/hash';
import { encodeBase64 } from 'effect/Encoding';
// Store for history items
let history: HistoryEntry[] = $state([]);
let identicon: any = $state(null);
let currentFilter: string = $state('all');
let showLegend: boolean = $state(false);
let { channelId = null }: { channelId: string | null } = $props();
// Filtered history based on selected filter and ID filter
let filteredHistory = $derived(
(() => {
// Then filter by ID if present
if (currentIdFilter.id) {
return history.filter((event) => event.messageId === currentIdFilter.id);
}
return history;
})()
);
let identicons = $derived(
identicon &&
filteredHistory.map((event: HistoryEntry) => {
const id = event.messageId;
// Handle the case where id could be null
return new identicon(id || '', { size: 40, format: 'svg' }).toString();
})
);
// Unsubscribe function
let unsubscribe: (() => void) | null = $state(null);
// Handle filter change
function handleFilterChange(event: Event) {
const select = event.target as HTMLSelectElement;
currentFilter = select.value;
}
// Handle event item click to filter by ID
function handleEventClick(id: string | null) {
if (id !== null) {
currentIdFilter.id = id;
}
}
// Handle dependency click to filter by dependency messageId
function handleDependencyClick(messageId: string, event: Event) {
// Stop event propagation so it doesn't trigger parent click handler
event.stopPropagation();
currentIdFilter.id = messageId;
}
// Clear ID filter
function clearIdFilter() {
currentIdFilter.id = null;
}
// Toggle legend display
function toggleLegend() {
showLegend = !showLegend;
}
const active: { [messageId: string]: boolean } = $state({});
const log: Set<string> = $state(new Set());
function eventStreamCallback(event: MessageChannelEventObject) {
if (event.type !== MessageChannelEvent.MissedMessages) {
return;
}
console.log('missed messages', event);
event.payload.forEach((message) => {
if (!log.has(message.messageId)) {
history.push(message);
log.add(message.messageId);
}
});
// history = event.payload;
}
onMount(async () => {
identicon = await getIdenticon();
// Subscribe to the event stream and collect events
unsubscribe = channelId
? subscribeToChannelEvents(channelId, eventStreamCallback)
: subscribeToMissingMessageStream(eventStreamCallback);
});
onDestroy(() => {
// Clean up the subscription when component is destroyed
if (unsubscribe) {
unsubscribe();
}
});
const color = eventColors[MessageChannelEvent.MissedMessages];
</script>
<div class="history-container">
{#if currentIdFilter.id}
<div class="id-filter-badge">
<span class="id-label">ID: {currentIdFilter.id}</span>
<button class="clear-filter-btn" onclick={clearIdFilter}>×</button>
</div>
{/if}
<div class="history-items-container">
{#each filteredHistory as event, index}
<div class="history-item {!event ? 'empty' : ''}" style="width: 100%; height: 100px;">
{#if event}
<div class="item-container">
<div class="event-box" style="background-color: {color};">
<div class="identicon">
<img src="data:image/svg+xml;base64,{identicons[index]}" alt="Identicon" />
</div>
<div class="event-info">
<div class="event-type"></div>
<div class="event-id">
{event.messageId}
</div>
</div>
</div>
<!-- {#if event.retrievalHint}
<div class="dependency-box" style="background-color: {color};">
{encodeBase64(event.retrievalHint)}
</div>
{/if} -->
</div>
{/if}
</div>
{/each}
</div>
<div class="bottom-fade"></div>
<LegendModal bind:isOpen={showLegend} />
</div>
<style>
.history-container {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
min-width: 400px;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
background-color: #ffffff;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
position: relative;
border: 1px solid #e0ddd4;
padding: 12px;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.history-container::-webkit-scrollbar {
display: none;
}
.history-items-container {
flex: 1;
position: relative;
padding-bottom: 30px; /* Add space for the fade effect */
width: 100%;
overflow-x: hidden;
}
.bottom-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1));
pointer-events: none; /* Allows interaction with elements underneath */
z-index: 1;
}
.virtualizer-container {
flex: 1;
overflow: auto;
padding: 10px;
}
.header {
display: flex;
align-items: center;
padding: 8px 8px 16px 8px;
border-bottom: 1px solid #e0ddd4;
margin-bottom: 12px;
position: relative;
}
.help-button {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: #f5f2e8;
border: 1px solid #e0ddd4;
color: #333333;
font-weight: bold;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 12px;
transition: all 0.2s;
}
.help-button:hover {
background-color: #e8e5db;
}
.item-filter {
flex: 1;
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #e0ddd4;
background-color: white;
font-size: 14px;
color: #333333;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
transition: all 0.2s;
}
.item-filter:hover,
.item-filter:focus {
border-color: #ccc9c2;
outline: none;
}
.id-filter-badge {
display: flex;
align-items: center;
background-color: #f5f2e8;
border-radius: 4px;
padding: 6px 12px;
margin: 0 8px 12px 8px;
max-width: fit-content;
font-size: 12px;
color: #333333;
border: 1px solid #e0ddd4;
}
.id-label {
font-family: monospace;
margin-right: 8px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: bold;
}
.clear-filter-btn {
background: rgba(0, 0, 0, 0.1);
border: none;
color: #333333;
font-size: 16px;
font-weight: bold;
cursor: pointer;
padding: 0 6px;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.clear-filter-btn:hover {
background: rgba(0, 0, 0, 0.2);
}
/* Ensure all child elements don't cause horizontal overflow */
.id-filter-badge,
.header,
.item-filter {
max-width: 100%;
box-sizing: border-box;
}
.history-item {
padding: 8px;
box-sizing: border-box;
transition: transform 0.2s ease;
width: 100%;
max-width: 100%;
}
.history-item:not(.empty):hover {
transform: translateX(2px);
}
.history-item:not(.empty) {
cursor: pointer;
}
.empty {
border: 1px dashed rgba(0, 0, 0, 0.1);
border-radius: 4px;
background-color: #ffffff;
}
.item-container {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
width: 100%;
height: 100%;
overflow: hidden;
}
.event-box {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
border-radius: 4px;
width: 100%;
min-height: 60px;
color: white;
padding: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
position: relative;
transition: all 0.2s ease;
border: none;
box-sizing: border-box;
overflow: hidden;
}
.dependency-box {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
width: auto;
max-width: 80%;
min-height: 30px;
font-size: 11px;
font-family: monospace;
opacity: 0.9;
padding: 6px 10px;
border-radius: 4px;
color: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
}
.highlight {
border-left: 2px solid #db8d43;
border-right: 2px solid #db8d43;
}
.highlight .event-type {
font-size: 15px;
color: white;
font-weight: bold;
}
.highlight .event-id,
.dependency-box.highlight {
font-weight: bold;
}
.dependency-box.highlight {
font-size: 12px;
}
.identicon {
width: 36px;
height: 36px;
border-radius: 4px;
overflow: hidden;
margin-right: 12px;
position: relative;
}
.identicon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.event-info {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.event-type {
font-size: 14px;
font-weight: bold;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-id {
font-family: monospace;
font-size: 11px;
color: rgba(255, 255, 255, 0.8);
max-width: 220px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.lamport-timestamp {
position: absolute;
top: 12px;
right: 14px;
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
background-color: rgba(0, 0, 0, 0.1);
padding: 2px 6px;
border-radius: 4px;
}
.sent-or-received {
position: absolute;
top: 12px;
right: 14px;
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
background-color: rgba(0, 0, 0, 0.1);
padding: 2px 6px;
border-radius: 4px;
}
</style>

View File

@ -0,0 +1,34 @@
<script lang="ts">
import { fade } from "svelte/transition";
type MaxWidthOption = "sm" | "md" | "lg" | "xl" | "2xl";
export let maxWidth: MaxWidthOption = "md";
export let padding = "p-8";
export let margin = "sm:my-12 my-4";
export let title = "";
const maxWidthClasses: Record<MaxWidthOption, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
"2xl": "max-w-2xl",
};
</script>
<div class="relative min-h-screen">
<div
class="{maxWidthClasses[maxWidth]} mx-auto {padding} bg-white rounded-lg sm:shadow-md absolute top-0 left-0 right-0 {margin}"
in:fade={{ duration: 300 }}
out:fade={{ duration: 300 }}
>
{#if title}
<h1 class="text-2xl font-bold text-gray-800 mb-6 tracking-tight text-center">
{title}
</h1>
{/if}
<slot />
</div>
</div>

View File

@ -0,0 +1,165 @@
<script lang="ts">
import { onMount, onDestroy } from '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, currentIdFilter, matchesIdFilter } from '$lib/utils/event.svelte';
import type { MessageChannelEventObject } from '$lib/sds/stream';
import { hash } from '$lib/utils/hash';
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.payload.messageId = hash(event.payload.messageId + event.payload.causalHistory[0].messageId)
}
console.log('updating virtual grid', event);
update_virtual_grid(event);
}
onMount(async () => {
unsubscribe = channelId
? subscribeToChannelEvents(channelId, eventStreamCallback)
: subscribeToAllEventsStream(eventStreamCallback);
});
onDestroy(() => {
if (unsubscribe) {
unsubscribe();
}
});
</script>
<div class="summary-grid">
{#each actual_grid as row}
{@const length = row.columns.filter((c) => c !== null).length}
{@const empty = 4 - length}
{@const isEmptyColumn = length === 0}
<div class="column mr-2 mb-4 p-4 state-column {isEmptyColumn ? 'empty-column' : ''}">
<div class="lamport-timestamp">
<span>{row.lamportTimestamp}</span>
</div>
<div class="queue-container">
{#each row.columns as cell}
{@const filtered = currentIdFilter.id && cell && matchesIdFilter(cell)}
{#if 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>
</div>
{/each}
</div>
<style>
.summary-grid {
display: inline-flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
gap: 16px;
}
.lamport-timestamp {
text-align: center;
margin-bottom: 12px;
font-weight: bold;
font-size: 14px;
color: #333333;
}
.queue-container {
background-color: #f5f5f5;
border-radius: 4px;
padding: 8px;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.cell {
min-width: 100px;
min-height: 40px;
border: none;
margin: 2px 0;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
position: relative;
}
.filtered {
box-shadow: 0 0 0 2px #DB8D43;
}
.filtered-out {
opacity: 0.35;
}
.empty-cell {
border: 1px dashed rgba(0, 0, 0, 0.1);
background-color: rgba(240, 240, 240, 0.5);
box-shadow: none;
}
.column {
max-width: 280px;
min-width: 200px;
background-color: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border-radius: 4px;
position: relative;
border: 1px solid #e0ddd4;
}
.state-column {
transition: transform 0.2s;
}
.state-column:hover {
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
}
.cell-text {
font-size: 12px;
text-align: center;
vertical-align: middle;
text-transform: uppercase;
letter-spacing: 0.05em;
color: white;
font-weight: bold;
}
.empty-column {
background-color: rgba(0, 0, 0, 0.02);
opacity: 0.7;
}
.empty-column .lamport-timestamp {
color: rgba(0, 0, 0, 0.4);
}
.empty-column .empty-cell {
border: 1px dashed rgba(0, 0, 0, 0.05);
background-color: rgba(240, 240, 240, 0.3);
}
</style>

View File

@ -0,0 +1,129 @@
<script lang="ts">
import { onMount } from 'svelte';
export let targetElement: HTMLElement | null = null;
export let position: 'left' | 'right' = 'right';
export let content: string = '';
export let offset: number = 20;
export let verticalOffset: number = 0;
export let width: number = 200;
export let showOnHover: boolean = true;
export let visible: boolean = false;
export let highlightTarget: boolean = false;
export let highlightClass: string = 'tooltip-target-highlight';
let tooltipElement: HTMLElement;
let originalTargetClasses: string = '';
// Position the tooltip relative to the target element
function positionTooltip() {
if (!targetElement || !tooltipElement) return;
const targetRect = targetElement.getBoundingClientRect();
// Calculate vertical center alignment with optional vertical offset
const top = targetRect.top + (targetRect.height / 2) + verticalOffset;
// Position horizontally based on the selected position
if (position === 'left') {
tooltipElement.style.right = `${window.innerWidth - targetRect.left + offset}px`;
} else {
tooltipElement.style.left = `${targetRect.right + offset}px`;
}
tooltipElement.style.top = `${top}px`;
tooltipElement.style.transform = 'translateY(-50%)';
}
// Reposition on window resize
function handleResize() {
if (visible) {
positionTooltip();
}
}
function showTooltip() {
visible = true;
// Position after becoming visible
setTimeout(positionTooltip, 0);
}
function hideTooltip() {
visible = false;
}
function applyHighlight() {
if (targetElement && highlightTarget) {
// Store original classes before adding highlight
originalTargetClasses = targetElement.className;
targetElement.classList.add(highlightClass);
}
}
function removeHighlight() {
if (targetElement && highlightTarget) {
// If we stored original classes, restore them
if (originalTargetClasses) {
// Remove highlight class
targetElement.classList.remove(highlightClass);
}
}
}
onMount(() => {
applyHighlight();
if (visible) {
positionTooltip();
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
// Ensure we remove highlight when component is destroyed
removeHighlight();
};
});
</script>
{#if visible}
<div
class="tooltip {position}-tooltip"
bind:this={tooltipElement}
style="width: {width}px;"
>
{@html content}
</div>
{/if}
<style>
.tooltip {
position: fixed;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
padding: 16px;
font-size: 14px;
line-height: 1.5;
color: #4b5563;
z-index: 1000;
max-width: 300px;
opacity: 0;
animation: fadeIn 0.2s forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
:global(.tooltip-target-highlight) {
outline: 2px solid rgba(252, 211, 77, 0.8) !important;
outline-offset: 2px !important;
transition: outline-color 0.2s ease !important;
z-index: 2 !important;
position: relative !important;
}
</style>

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { wakuConnection } from "../connectionUtils";
export let onClick: () => void;
export let label: string = "Action";
export let extraCondition: boolean = true;
export let className: string = "";
export let requireConnection: boolean = true;
// Whether to check connection status for this button
$: buttonProps = requireConnection
? $wakuConnection.getButtonProps(extraCondition)
: {
disabled: !extraCondition,
'aria-disabled': !extraCondition,
title: !extraCondition ? 'This action requires additional conditions to be met' : '',
class: !extraCondition ? 'opacity-50 cursor-not-allowed' : ''
};
function handleClick() {
if (!buttonProps.disabled) {
onClick();
}
}
</script>
<button
class="px-4 py-2 rounded bg-blue-500 text-white hover:bg-blue-600 {buttonProps.class} {className}"
on:click={handleClick}
disabled={buttonProps.disabled}
aria-disabled={buttonProps.disabled}
title={buttonProps.title}
>
<slot>{label}</slot>
</button>

View File

@ -0,0 +1,45 @@
import { derived } from 'svelte/store';
import { connectionState } from './waku/waku.svelte';
// A derived store that provides both connection state and utility functions
export const wakuConnection = derived(
connectionState,
($connectionState) => {
const isConnected = $connectionState.status === 'connected';
const isConnecting = $connectionState.status === 'connecting' || $connectionState.status === 'waiting_for_peers' || $connectionState.status === 'setting_up_subscriptions';
const hasError = $connectionState.status === 'error';
// const errorMessage = $connectionState.error;
return {
// Current connection state
status: $connectionState.status,
error: $connectionState.error,
// Utility getters
isConnected,
isConnecting,
hasError,
// Utility functions
disableIfNotConnected: (extraCondition = true) => {
return !isConnected || !extraCondition;
},
// Function to get button attributes based on connection state
getButtonProps: (extraDisableCondition = true) => {
const disabled = !isConnected || !extraDisableCondition;
return {
disabled,
title: disabled && !extraDisableCondition
? 'This action requires additional conditions to be met'
: disabled
? 'This action requires a connected Waku node'
: '',
'aria-disabled': disabled,
class: disabled ? 'opacity-50 cursor-not-allowed' : ''
};
}
};
}
);

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,51 @@
import { MessageChannelEvent } from '@waku/sds';
// Sample history with different event types for the legend
export const historyJson = JSON.stringify([
{
type: MessageChannelEvent.MessageSent,
payload: {
messageId: "db7ce7bff8734cc868da5bd8d880b58765ed9e0481f0c2f6b0ec86258322a3fa",
channelId: "channel-id",
lamportTimestamp: 6,
causalHistory: [],
bloomFilter: { 0: 0, 1: 0, 2: 0, 3: 0 },
content: { 0: 131, 1: 244 }
}
},
{
type: MessageChannelEvent.MessageDelivered,
payload: {
messageId: "bc8701dd8eacca44f01a177a2d7e2ac879dd189b1f8ea2b57b10bbdb82042bc0",
sentOrReceived: "received"
}
},
{
type: MessageChannelEvent.MessageReceived,
payload: {
messageId: "bc8701dd8eacca44f01a177a2d7e2ac879dd189b1f8ea2b57b10bbdb82042bc0",
channelId: "channel-id",
causalHistory: [],
lamportTimestamp: 4,
bloomFilter: { 0: 0, 1: 0, 2: 0, 3: 0 },
content: { 0: 226, 1: 83 }
}
},
{
type: MessageChannelEvent.MessageAcknowledged,
payload: "217e647921f9a6fc8ecfc480e207db828e18c5868d229cf5c5bf59be89dc70ff"
},
{
type: MessageChannelEvent.PartialAcknowledgement,
payload: {
messageId: "d81b2617e63162843413eea8c62dada058ac7e6c8f8463fd5ef1171939cd9415",
acknowledgementBitmask: new Uint8Array([1, 0, 1])
}
},
{
type: MessageChannelEvent.MissedMessages,
payload: {
messageIds: ["58cf44867e529152f4095aa6a951500a6a45571a062dc0bddb06aef48c97f85b"]
}
}
]);

View File

@ -0,0 +1,14 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let Identicon: any = $state(null);
export async function loadIdenticon() {
const identiconModule = await import("identicon.js");
Identicon = identiconModule.default;
}
export async function getIdenticon() {
if (!Identicon) {
await loadIdenticon();
}
return Identicon;
}

View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -0,0 +1,87 @@
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() {
wakuNode.subscribeToFilter((message) => {
const sdsMessage = decodeMessage(message.payload) as unknown as Message;
channel.receiveMessage(sdsMessage);
});
}
export async function processQueue() {
await channel.processTasks();
}
export function startSending() {
const randomHash = new Uint8Array(32);
crypto.getRandomValues(randomHash);
send(randomHash);
}
export function sweepOut() {
return channel.sweepOutgoingBuffer();
}
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!);
if (messageHashes.length === 0) {
return;
}
const query = wakuNode.queryStore(messageHashes);
if (!query) {
console.error('no query');
return;
}
console.log('query', query);
// Process all batches of promises from the AsyncGenerator
for await (const promises of query) {
// Resolve all promises in the batch
const messages = await Promise.all(promises);
console.log('messages', messages);
// Process each message
for (const msg of messages) {
if (msg?.payload) {
const sdsMessage = decodeMessage(msg.payload) as unknown as Message;
_channel.receiveMessage(sdsMessage);
}
}
}
}
async function send(payload: Uint8Array): Promise<void> {
await 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);
}
return {
success: true,
retrievalHint: hash
};
});
}
export const history: {
messageId: string;
lamportTimestamp?: number;
event: MessageChannelEvent;
}[] = $state([]);

View File

@ -0,0 +1,19 @@
import { MessageChannel } from '@waku/sds';
const channelId = 'channel-id';
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

@ -0,0 +1,23 @@
import { MessageChannelEvent } from "@waku/sds";
import type { MessageChannelEventObject } from "./stream";
export function getMessageId(event: MessageChannelEventObject) {
if(event.type === MessageChannelEvent.MessageSent) {
return event.payload.messageId;
} else if(event.type === MessageChannelEvent.MessageDelivered) {
return event.payload.messageId;
} else if(event.type === MessageChannelEvent.MessageReceived) {
return event.payload.messageId;
} else if(event.type === MessageChannelEvent.MessageAcknowledged) {
return event.payload;
} else if(event.type === MessageChannelEvent.PartialAcknowledgement) {
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

@ -0,0 +1,56 @@
import { getChannel, getOrCreateChannel } from './channel.svelte';
import { toEventStream, filterByEventType } from './stream';
import { Effect, Stream, pipe } from 'effect';
import type { MessageChannelEventObject } from './stream';
import { MessageChannelEvent } from '@waku/sds';
const eventStream = $state(toEventStream(getChannel()));
const missingMessageEventStream = $derived(
pipe(eventStream, filterByEventType(MessageChannelEvent.MissedMessages))
);
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 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,65 @@
import { Stream, pipe } from 'effect';
import { MessageChannel, MessageChannelEvent, type MessageChannelEvents } from '@waku/sds';
/**
* Extract payload types from MessageChannelEvents
*/
type EventPayloadMap = {
[K in keyof MessageChannelEvents]: MessageChannelEvents[K] extends CustomEvent<infer T>
? T
: never;
};
/**
* Union type for all possible event objects
*/
export type MessageChannelEventObject = {
[E in MessageChannelEvent]: { type: E; payload: EventPayloadMap[E] };
}[MessageChannelEvent];
/**
* Helper function to create a stream for a specific event type
*/
const fromMessageChannelEvent = <E extends MessageChannelEvent>(
channel: MessageChannel,
eventType: E
): Stream.Stream<MessageChannelEventObject> => {
return Stream.map(
Stream.fromEventListener(channel, eventType, { passive: true }),
(event: Event) => {
const customEvent = event as CustomEvent<EventPayloadMap[E]>;
return {
type: eventType,
payload: customEvent.detail
} as MessageChannelEventObject;
}
);
};
/**
* Creates an Effect Stream from a MessageChannel's events
*/
export const toEventStream = (
channel: MessageChannel
): Stream.Stream<MessageChannelEventObject> => {
return Stream.mergeAll(
Object.values(MessageChannelEvent).map((eventType) =>
fromMessageChannelEvent(channel, eventType as MessageChannelEvent)
),
{ concurrency: 'unbounded' }
);
};
// Add some convenience filtering methods
export const filterByEventType =
<E extends MessageChannelEvent>(eventType: E) =>
<R, E2>(
stream: Stream.Stream<MessageChannelEventObject, E2, R>
): Stream.Stream<Extract<MessageChannelEventObject, { type: E }>, E2, R> =>
pipe(
stream,
Stream.filter(
(event): event is Extract<MessageChannelEventObject, { type: E }> =>
event.type === eventType
)
);

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]: '#4482CF', // blue similar to LIFO
[MessageChannelEvent.MessageDelivered]: '#45A676', // green similar to FIFO
[MessageChannelEvent.MessageReceived]: '#8D6AB3', // purple similar to priority+RED
[MessageChannelEvent.MessageAcknowledged]: '#2E7B58', // darker green
[MessageChannelEvent.PartialAcknowledgement]: '#6A4A96', // darker purple
[MessageChannelEvent.MissedMessages]: '#E05252', // error red
[MessageChannelEvent.SyncSent]: '#DB8D43', // orange/brown similar to priority
[MessageChannelEvent.SyncReceived]: '#C47A35' // darker orange/brown
};
// 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

@ -0,0 +1,38 @@
import { Schedule } from "effect";
import { Ref } from "effect";
import { Effect } from "effect";
export function frequency() {
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(Effect.sync(() => {}), 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('2000 millis')),
// // periodically switch off filter to miss messages
Effect.repeat(
Ref.update(listenCondition, (listening) => !listening),
Schedule.spaced('2000 millis')
)
],
{
concurrency: 'unbounded'
}
);
})
);
}

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

@ -0,0 +1,115 @@
import { getMessageId } from '$lib/sds/message';
import { type MessageChannelEventObject } from '$lib/sds/stream';
import { MessageChannelEvent } from '@waku/sds';
const lamportTimestamp = $state(0);
let maxLamportTimestamp = $state(0);
export const initializeGrid = (_maxLamportTimestamp: number) => {
maxLamportTimestamp = _maxLamportTimestamp;
const rows = maxLamportTimestamp;
const columns = maxLamportTimestamp;
return createGrid(rows, columns);
};
export const addItems = (items: Array<MessageChannelEventObject>, _lamportTimestamp?: number) => {
if (!_lamportTimestamp) {
_lamportTimestamp = lamportTimestamp;
}
grid[_lamportTimestamp] = items;
};
export const createGrid = (
rows: number,
columns: number
): Array<Array<MessageChannelEventObject | null>> => {
return Array(rows)
.fill(null)
.map(() => Array(columns).fill(null));
};
const x_window = 100;
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;
}
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 ||
event.type === MessageChannelEvent.MessageReceived ||
event.type === MessageChannelEvent.SyncSent ||
event.type === MessageChannelEvent.SyncReceived
) {
lamportTimestamp = event.payload.lamportTimestamp;
if (!lamportTimestamp) {
return;
}
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 = (
message: MessageChannelEventObject,
_grid?: Array<Array<MessageChannelEventObject | null>>
) => {
if (!_grid) {
_grid = grid;
}
let lamportTimestamp = null;
if (
message.type === MessageChannelEvent.MessageSent ||
message.type === MessageChannelEvent.MessageReceived ||
message.type === MessageChannelEvent.SyncSent ||
message.type === MessageChannelEvent.SyncReceived
) {
lamportTimestamp = message.payload.lamportTimestamp;
if (!lamportTimestamp) {
return;
}
} else {
lamportTimestamp = longestTimestamp;
}
const messages = messagesPerLamportTimestamp.get(lamportTimestamp) || [];
messages.push(message);
messagesPerLamportTimestamp.set(lamportTimestamp, messages);
if (lamportTimestamp > longestTimestamp) {
longestTimestamp = lamportTimestamp;
}
const firstFill = _grid[lamportTimestamp].findIndex((item) => item !== null);
if (firstFill === -1) {
_grid[lamportTimestamp][Math.floor(_grid[lamportTimestamp].length / 2)] = message;
} else {
const lastFill = _grid[lamportTimestamp].findLastIndex((item) => item !== null);
if (firstFill > _grid[lamportTimestamp].length - lastFill) {
_grid[lamportTimestamp][firstFill - 1] = message;
} else {
_grid[lamportTimestamp][lastFill + 1] = message;
}
}
};

View File

@ -0,0 +1,91 @@
import { mount, unmount } from 'svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
interface TooltipOptions {
position?: 'left' | 'right';
content: string;
offset?: number;
verticalOffset?: number;
width?: number;
showOnHover?: boolean;
visible?: boolean;
highlightTarget?: boolean;
highlightClass?: string;
}
/**
* Creates a tooltip positioned relative to a target element
* @param targetElement - The element to attach the tooltip to
* @param options - Configuration options for the tooltip
* @returns An object with methods to control the tooltip
*/
export function createTooltip(targetElement: HTMLElement, options: TooltipOptions) {
const {
position = 'right',
content,
offset = 20,
width = 200,
showOnHover = true,
visible = false,
highlightTarget = false,
highlightClass = 'tooltip-target-highlight',
verticalOffset = 0
} = options;
// Store current visibility state
let isVisible = visible;
// Create a container for the tooltip
const container = document.createElement('div');
document.body.appendChild(container);
// Initialize the tooltip component using Svelte's mount API
const tooltipInstance = mount(Tooltip, {
target: container,
props: {
targetElement,
position,
content,
offset,
width,
verticalOffset,
showOnHover,
visible: isVisible,
highlightTarget,
highlightClass
}
});
// Return methods to control the tooltip
return {
destroy: () => {
// In Svelte 5, we use the unmount function instead of $destroy
unmount(tooltipInstance);
container.remove();
},
updatePosition: () => {
tooltipInstance.$set({ targetElement });
},
updateContent: (newContent: string) => {
tooltipInstance.$set({ content: newContent });
},
show: () => {
isVisible = true;
tooltipInstance.$set({ visible: true });
},
hide: () => {
isVisible = false;
tooltipInstance.$set({ visible: false });
},
toggle: () => {
isVisible = !isVisible;
tooltipInstance.$set({ visible: isVisible });
},
updateOptions: (newOptions: Partial<TooltipOptions>) => {
if (newOptions.visible !== undefined) {
isVisible = newOptions.visible;
}
tooltipInstance.$set(newOptions);
}
};
}

View File

@ -0,0 +1,233 @@
import { Effect, Option, pipe, Stream } from 'effect';
import type { MatchParams } from './waku.svelte';
// 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',
Ongoing = 'ongoing'
}
enum LobbyEvent {
GotPing = 'got_ping',
GotRequest = 'got_request',
SentRequest = 'sent_request',
GotAccept = 'got_accept',
SentAccept = 'sent_accept',
GotMatch = 'got_match',
SentMatch = 'sent_match',
Ongoing = 'ongoing'
}
export type LobbyMessage = {
messageType: LobbyMessageType;
timestamp: Date;
expiry?: Date;
from: string;
to?: string;
match?: MatchParams;
};
export enum PeerState {
None = 'none',
Found = 'found',
RequestTo = 'request_to',
RequestFrom = 'request_from',
AcceptTo = 'accept_to',
AcceptFrom = 'accept_from',
Success = 'success',
Failure = 'failure',
Ongoing = 'ongoing'
}
// 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
},
[LobbyEvent.Ongoing]: {
[PeerState.None]: PeerState.Ongoing
}
};
// 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;
} else if (message.messageType === LobbyMessageType.Ongoing) {
console.log(`Received ongoing match`);
event = LobbyEvent.Ongoing;
}
// Get next state from transition table
if (event === null) {
console.warn(`Invalid message type: ${message.messageType}`);
return Option.none();
}
const nextStateValue =
message.messageType === LobbyMessageType.Ongoing
? PeerState.Ongoing
: 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,231 @@
import { decoder, encoder, lobbyEncoder, 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 { SDKProtocolResult, 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';
import { LobbyMessageType, type LobbyMessage } from './lobby.svelte';
export const listening = $state({ listening: false });
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);
}
return {
success: true,
retrievalHint: hash
};
});
}
const broadcastMatchToLobby = (params: MatchParams) =>
Effect.async<SDKProtocolResult, Error>((resume) => {
const m: LobbyMessage = {
messageType: LobbyMessageType.Ongoing,
timestamp: new Date(),
from: params.myPeerId,
to: params.otherPeerId,
match: params
};
wakuNode.node?.lightPush
.send(lobbyEncoder, {
payload: new TextEncoder().encode(JSON.stringify(m)),
timestamp: new Date()
})
.then((result) => resume(Effect.succeed(result)))
.catch((error) => resume(Effect.fail(new Error(error as string))));
});
export function start(params: MatchParams, joined: boolean = false) {
const { matchId } = params;
const first =
params.myPeerId.localeCompare(params.otherPeerId + (joined ? Math.random() : '')) < 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 + (joined ? Math.random() : 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);
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* () {
const result = yield* sendQueue.take;
return yield* sendEffect(result);
});
const validateMessage = (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 {
wakuNode.node?.filter
.subscribe([decoder], async (message) => {
const listening = await Effect.runPromise(listenCondition.get);
if (!listening) {
return;
}
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) => {
if (new Date().getTime() - lastMessage.getTime() < sinkTimeout + Math.random() * 2000) {
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);
}
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('2000 millis')),
// periodically switch off filter to miss messages
Effect.repeat(
Ref.update(listenCondition, (_listening) => {
listening.listening = !_listening;
return !_listening;
}),
Schedule.spaced(first ? '4500 millis' : `8000 millis`)
),
Effect.repeat(
broadcastMatchToLobby(params),
Schedule.spaced('2000 millis')
)
],
{
concurrency: 'unbounded'
}
);
})
);
}

View File

@ -0,0 +1,390 @@
import {
createLightNode,
DecodedMessage,
type LightNode,
Protocols,
HealthStatus,
HealthStatusChangeEvents,
type ISubscription,
type SDKProtocolResult,
createDecoder,
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,
pubsubTopicShardInfo: { clusterId: 42, shard: 0 }
});
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);
async setNode(node: LightNode) {
this.node = node;
}
public subscription: ISubscription | undefined;
public async subscribeToFilter(callback: (message: DecodedMessage) => void) {
if (!node) {
throw new Error('Waku node not started');
}
const result = await node.filter.subscribe([decoder], (message) => {
callback(message);
});
if (result.error) {
console.error('Error subscribing to filter:', result.error);
throw new Error('Failed to subscribe to filter');
}
// At this point TypeScript knows we have a SubscriptionSuccess
this.subscription = result.subscription;
if (result.results.failures.length > 0 || result.results.successes.length === 0) {
throw new Error('Failed to subscribe to filter: No successful peer connections');
}
}
public async unsubscribe() {
if (!node) {
throw new Error('Waku node not started');
}
await this.subscription?.unsubscribe([decoder.contentTopic]);
}
public async sendWithLightPush(payload: Uint8Array, timestamp: Date): Promise<SDKProtocolResult> {
if (!node) {
throw new Error('Waku node not started');
}
return await node.lightPush.send(encoder, {
payload: payload,
timestamp: timestamp
});
}
public queryStore(messageHashes: Uint8Array[]) {
return node?.store.queryGenerator([decoder], {
includeData: true,
messageHashes
});
}
}
let node = $state<LightNode | undefined>(undefined);
export const connectionState = writable({
status: 'disconnected' as
| 'error'
| 'disconnected'
| 'connecting'
| 'waiting_for_peers'
| 'setting_up_subscriptions'
| 'connected',
error: null as string | null
});
export const wakuNode = new WakuNode();
export async function startWaku(): Promise<void> {
connectionState.update((state) => ({
...state,
status: 'connecting',
error: null
}));
try {
node = await createLightNode({
defaultBootstrap: false,
networkConfig: {
clusterId: 42,
shards: [0]
},
libp2p: {
connectionGater: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
denyDialMultiaddr: async (_) => {
return false;
}
},
filterMultiaddrs: false
}
});
await node.start();
await wakuNode.setNode(node);
// Connect to peers
const peers = [
// '/ip4/127.0.0.1/tcp/8000/ws/p2p/16Uiu2HAm3TLea2NVs4dAqYM2gAgoV9CMKGeD1BkP3RAvmk7HBAbU',
'/dns4/waku-test.bloxy.one/tcp/8095/wss/p2p/16Uiu2HAmSZbDB7CusdRhgkD81VssRjQV5ZH13FbzCGcdnbbh6VwZ',
'/dns4/waku.fryorcraken.xyz/tcp/8000/wss/p2p/16Uiu2HAmMRvhDHrtiHft1FTUYnn6cVA8AWVrTyLUayJJ3MWpUZDB',
'/dns4/ivansete.xyz/tcp/8000/wss/p2p/16Uiu2HAmDAHuJ8w9zgxVnhtFe8otWNJdCewPAerJJPbXJcn8tu4r'
];
for (const peer of peers) {
try {
await node.dial(peer);
} catch (error) {
console.error(`Error dialing peer ${peer}:`, error);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).waku = node;
connectionState.update((state) => ({
...state,
status: 'waiting_for_peers'
}));
// Wait for peer connections
try {
await node.waitForPeers([Protocols.LightPush, Protocols.Filter, Protocols.Store]);
connectionState.update((state) => ({
...state,
status: 'setting_up_subscriptions'
}));
} catch (error) {
console.error('Error waiting for peers:', error);
}
connectionState.update((state) => ({
...state,
status: 'connected'
}));
} catch (error) {
console.error('Error starting Waku node:', error);
connectionState.update((state) => ({
...state,
status: 'error',
error: error instanceof Error ? error.message : String(error)
}));
throw error;
}
}
export function health(callback: (health: HealthStatus) => void): void {
if (!node) {
return;
}
node.health.addEventListener(
HealthStatusChangeEvents.StatusChange,
(health: CustomEvent<HealthStatus>) => {
callback(health.detail);
}
);
}
export function unregisterHealthListener(callback: (health: HealthStatus) => void): void {
if (!node) {
return;
}
node.health.removeEventListener(
HealthStatusChangeEvents.StatusChange,
(health: CustomEvent<HealthStatus>) => {
callback(health.detail);
}
);
}
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.Ongoing ||
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));
} else if (event.newState === PeerState.Ongoing) {
console.log(`Match ongoing with ${event.peerId}`);
// 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]);
}
if (event.message.match) {
const params: MatchParams = event.message.match;
resume(Effect.succeed(params));
} else {
resume(Effect.fail(new Error('No match found')));
}
}
};
fiber = subscribeToStateTransitionStream(stateTransitionStream, handleStateTransition);
setTimeout(() => {
pingFiber = Effect.runFork(Effect.repeat(ping, pingSchedule));
}, 2000);
});
return Effect.runPromise(getMatchParams);
}

View File

@ -0,0 +1,27 @@
<script lang="ts">
// import "../tailwind.css";
import "../app.css";
import Header from "$lib/components/Header.svelte";
import ConnectionIndicator from "$lib/components/ConnectionIndicator.svelte";
import ActionModule from "$lib/components/ActionModule.svelte";
import { Toaster } from 'svelte-sonner';
import { onMount } from 'svelte';
import { loadIdenticon } from '$lib/identicon.svelte';
onMount(async () => {
await loadIdenticon();
});
</script>
<div id="layout-container" class="fixed inset-0 overflow-hidden flex flex-col">
<Header>
<!-- <ActionModule /> -->
<ConnectionIndicator />
</Header>
<div class="flex-1 overflow-auto my-1">
<slot />
</div>
</div>
<Toaster />

View File

@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;

View File

@ -0,0 +1,60 @@
<script lang="ts">
import ConnectionButton from "$lib/components/ConnectionButton.svelte";
import { connectionState } from "$lib/waku/waku.svelte";
import PageLayout from "$lib/components/PageLayout.svelte";
import CallToAction from "$lib/components/CallToAction.svelte";
import { goto } from "$app/navigation";
// Redirect to history page when connected
$effect(() => {
if ($connectionState.status === "connected") {
goto('/state-graph');
}
});
</script>
<PageLayout title="Scalable Data Sync" maxWidth="md">
<div class="flex justify-center">
<div class="w-64 h-64 overflow-hidden flex items-center justify-center perspective-500">
<img
src="/waku-mark-primary-black.svg"
alt="Waku Logo"
class="w-full h-full transform scale-125 {$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="w-full flex justify-center">
<ConnectionButton size="large" />
</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

@ -0,0 +1,38 @@
<script lang="ts">
import { connectionState } from "$lib/waku/waku.svelte";
import History from "$lib/components/History.svelte";
import Missing from "$lib/components/Missing.svelte";
import PageLayout from "$lib/components/PageLayout.svelte";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
// Redirect to home page if not connected
onMount(() => {
if ($connectionState.status !== "connected") {
goto('/');
}
});
// Also watch for disconnection
$effect(() => {
if ($connectionState.status !== "connected") {
goto('/');
}
});
</script>
<div class="flex flex-col sm:flex-row gap-4 sm:gap-6 px-4">
<div class="flex-1 w-full">
<PageLayout title="History" maxWidth="2xl" padding="sm:p-6 px-4" margin="sm:my-6 my-4">
<p class="text-gray-600 text-sm mb-4 leading-relaxed">Log of all events as they are emitted by the message channel.</p>
<History />
</PageLayout>
</div>
<div class="flex-1 w-full">
<PageLayout title="Missing" maxWidth="2xl" padding="sm:p-6 px-4" margin="sm:my-6 my-4">
<p class="text-gray-600 text-sm mb-4 leading-relaxed">List of messages that are currently known to be missing from the channel.</p>
<Missing />
</PageLayout>
</div>
</div>

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

@ -0,0 +1,125 @@
<script lang="ts">
import History from '$lib/components/History.svelte';
import StateGraphSummary from '$lib/components/StateGraphSummary.svelte';
import Missing from '$lib/components/Missing.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>
{#if match}
<div class="main-container">
<!-- History Sidebar -->
<div class="state-graph-panel">
<div class="panel-header">
<h2>Event History</h2>
</div>
<History channelId={match?.matchId ?? null} />
</div>
<div class="state-container">
<!-- Summary State Graph -->
<div class="state-graph-panel">
<div class="panel-header">
<h2>Events by Lamport Timestamp</h2>
</div>
<StateGraphSummary channelId={match?.matchId ?? null} />
</div>
</div>
<div class="state-graph-panel">
<div class="panel-header">
<h2>Missed Messages</h2>
</div>
<Missing channelId={match?.matchId ?? null} />
</div>
</div>
{/if}
<style>
.main-container {
display: flex;
flex-direction: row;
height: 100%;
padding: 16px;
gap: 20px;
overflow: hidden;
background-color: #f5f2e8; /* Light beige background similar to example */
}
.history-panel {
flex: 0 0 400px;
display: flex;
}
.state-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
scrollbar-width: none;
}
.state-graph-panel {
height: 100%;
background-color: #ffffff;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 24px;
overflow: auto;
position: relative;
border: 1px solid #e0ddd4;
scrollbar-width: none;
}
.panel-header {
margin-bottom: 24px;
text-align: center;
position: relative;
}
.panel-header h2 {
font-size: 20px;
color: #333333;
font-weight: 600;
margin: 0;
padding: 0 0 10px 0;
position: relative;
display: inline-block;
}
.panel-header h2::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: #e0ddd4;
}
@media (max-width: 1200px) {
.main-container {
flex-direction: column;
}
.history-panel {
flex: 0 0 300px;
}
.state-container {
flex: 1;
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;

View File

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View File

@ -0,0 +1,7 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});