feat: upgrade launcher WebSocket to V3 protocol

- New element_websocket_v3.svelte:
  - URL path /v3/ws/group/{id}/client/{id}
  - Auth via ?api_key=...&jwt=... query params (browsers can't set WS headers)
  - Strict WS_Message_V3 schema: msg_type + target fields
  - 45s heartbeat to maintain Redis presence TTL
  - Self-echo detection via from_id (server-stamped)
  - Target 'group' (was 'all'), 'direct' (was 'dm')
- launcher/+layout.svelte:
  - Swap Element_websocket_v2 -> Element_websocket_v3
  - Add api_key and jwt props for V3 auth
  - Fix handle_ws_recv: type -> msg_type (V3 schema)
  - Remove unused 'type' prop passed to element
This commit is contained in:
Scott Idem
2026-03-11 17:50:08 -04:00
parent 3f5f554595
commit 5db66bc888
2 changed files with 407 additions and 4 deletions

View File

@@ -0,0 +1,401 @@
<script lang="ts">
/**
* element_websocket_v3.svelte — Aether WebSocket V3 Client
*
* PURPOSE:
* Drop-in replacement for element_websocket_v2.svelte using the V3 protocol:
* - URL: /v3/ws/group/{group_id}/client/{client_id}?api_key=...&jwt=...
* - Strict WS_Message_V3 schema: { msg_type, target, cmd?, msg?, payload? }
* - Redis-granular routing (group / direct / broadcast / echo)
* - Automatic 45s heartbeat to maintain Redis presence TTL
* - Auth via query params (browsers cannot set WS headers)
*
* EXTERNAL INTERFACE:
* Intentionally prop-compatible with V2 so callers need minimal changes.
* New V3-only props: api_key, jwt, x_account_id.
*
* SCHEMA CHANGES FROM V2:
* - `type` field renamed to `msg_type` ('cmd'|'msg'|'heartbeat'|'presence')
* - `target` values: 'group' (was 'all'), 'direct' (was 'dm'), 'echo', 'broadcast'
* - `from_id` (server-set) replaces self-echo check via `client_id` in payload
* - Do NOT send `client_id`, `group_id`, or `from_id` in message body; server fills them.
*/
interface Props {
log_lvl?: number;
// Connection control
ws_connect?: boolean;
ws_connect_status?: null | string;
ws_server?: string;
ws_retry_delay?: number;
ws_retry_count?: number;
// V3 Auth (passed as query params — browsers cannot set WS headers)
api_key?: string | null;
jwt?: string | null;
x_account_id?: string | null;
// Identity
group_id?: string;
client_id?: any;
// Outbound message content
cmd?: null | string;
msg?: null | string;
trigger_send?: any;
trigger_connect?: boolean;
trigger_disconnect?: boolean;
// Visibility flags (for debug panel)
hide__ws_element?: boolean;
hide__ws_form?: boolean;
hide__ws_messages?: boolean;
hide__ws_commands?: boolean;
// Output status bindings
ws_conn_status?: any;
ws_recv_status?: any;
ws_sent_status?: any;
}
let {
log_lvl = 0,
ws_connect = $bindable(false),
ws_connect_status = $bindable(null),
ws_server = 'dev-api.oneskyit.com',
ws_retry_delay = 3500,
ws_retry_count = 0,
api_key = null,
jwt = null,
x_account_id = null,
group_id = $bindable('ae-grp-99'),
client_id = $bindable(Date.now()),
cmd = $bindable(null),
msg = $bindable(null),
trigger_send = $bindable(null),
trigger_connect = $bindable(false),
trigger_disconnect = $bindable(false),
hide__ws_element = $bindable(false),
hide__ws_form = $bindable(true),
hide__ws_messages = $bindable(false),
hide__ws_commands = $bindable(false),
ws_conn_status = $bindable(null),
ws_recv_status = $bindable(null),
ws_sent_status = $bindable(null)
}: Props = $props();
// *** Internal state
let ws_received_list_cmd: any[] = $state([]);
let ws_received_list_other: any[] = $state([]);
let ws_group: WebSocket | null = $state(null);
let heartbeat_interval: ReturnType<typeof setInterval> | null = null;
// *** Build V3 URL with auth query params
function build_ws_url(group: string, client: any): string {
// Use wss:// in production (HTTPS), ws:// for local dev (HTTP)
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const params = new URLSearchParams();
if (api_key) params.set('api_key', api_key);
if (jwt) params.set('jwt', jwt);
if (x_account_id) params.set('x_account_id', x_account_id);
const qs = params.toString();
return `${protocol}://${ws_server}/v3/ws/group/${group}/client/${client}${qs ? '?' + qs : ''}`;
}
// *** Heartbeat — keeps Redis presence TTL alive (V3 requirement)
function start_heartbeat(conn: WebSocket) {
stop_heartbeat();
heartbeat_interval = setInterval(() => {
if (conn.readyState === WebSocket.OPEN) {
conn.send(JSON.stringify({ msg_type: 'heartbeat', target: 'echo' }));
if (log_lvl) console.log('WS V3: heartbeat sent');
}
}, 45_000);
}
function stop_heartbeat() {
if (heartbeat_interval !== null) {
clearInterval(heartbeat_interval);
heartbeat_interval = null;
}
}
// *** Core connection
function ws_connect_group_id({ group, client }: { group: string; client: any }): WebSocket {
if (!group) {
group = 'ae-grp-99';
console.warn('WS V3: No group_id — using default:', group);
}
if (!client) {
client = Date.now();
console.warn('WS V3: No client_id — using timestamp:', client);
}
const url = build_ws_url(group, client);
if (log_lvl) console.log('WS V3 connect URL:', url);
const conn = new WebSocket(url);
conn.onopen = () => {
if (log_lvl) console.log('WS V3: connected');
ws_connect_status = 'connected';
ws_conn_status = 'connected';
ws_retry_count = 0;
// Announce presence to the group
conn.send(JSON.stringify({
msg_type: 'msg',
target: 'group',
msg: `Client ${String(client).slice(-5)} connected`
}));
start_heartbeat(conn);
};
conn.onmessage = (event) => {
let data: any;
try {
data = JSON.parse(event.data);
} catch {
console.warn('WS V3: non-JSON message ignored', event.data);
return;
}
if (log_lvl) console.log('WS V3: received', data);
// Ignore messages we sent ourselves (server stamps from_id)
if (data.from_id && String(data.from_id) === String(client)) {
if (log_lvl) console.log('WS V3: self-echo ignored');
return;
}
// Ignore heartbeat echoes silently
if (data.msg_type === 'heartbeat') return;
if (data.msg_type === 'cmd') {
ws_received_list_cmd = [data, ...ws_received_list_cmd];
} else {
ws_received_list_other = [data, ...ws_received_list_other];
}
ws_recv_status = data;
};
conn.onclose = () => {
if (log_lvl) console.log('WS V3: connection closed');
stop_heartbeat();
ws_connect_status = 'disconnected';
ws_conn_status = 'disconnected';
ws_received_list_other = [
{
msg_type: 'msg',
target: 'local',
msg: `LOCAL: client ${client} disconnected`
},
...ws_received_list_other
];
// Auto-reconnect while ws_connect is still true
if (ws_connect) {
if (ws_retry_count >= 10) {
ws_retry_delay = Math.min(ws_retry_delay + 4999, 120_000);
console.warn(`WS V3: retry limit reached, delay=${ws_retry_delay}ms`);
}
setTimeout(() => {
ws_retry_count += 1;
ws_group = ws_connect_group_id({ group, client });
}, ws_retry_delay);
}
};
conn.onerror = () => {
console.warn('WS V3: connection error');
};
return conn;
}
// *** Send helper
function handle_send() {
if (!ws_group || ws_group.readyState !== WebSocket.OPEN) {
console.warn('WS V3: send attempted with no open connection');
return;
}
const payload: Record<string, any> = {
msg_type: 'cmd',
target: 'group'
};
if (cmd) payload.cmd = cmd;
if (msg) payload.msg = msg;
if (log_lvl) console.log('WS V3: sending', payload);
ws_group.send(JSON.stringify(payload));
ws_sent_status = { ...payload, src: client_id, group_id: group_id };
cmd = '';
msg = '';
}
// *** Reactive effects
// Connect / disconnect based on ws_connect flag
$effect(() => {
if (ws_connect && group_id) {
ws_group = ws_connect_group_id({ group: group_id, client: client_id });
} else {
stop_heartbeat();
ws_group?.close();
ws_group = null;
ws_connect_status = 'disconnected';
}
});
// Trigger: send command
$effect(() => {
if (trigger_send && cmd) {
trigger_send = null;
handle_send();
}
});
// Trigger: connect
$effect(() => {
if (trigger_connect) {
trigger_connect = false;
if (!ws_connect) ws_connect = true;
}
});
// Trigger: disconnect
$effect(() => {
if (trigger_disconnect) {
trigger_disconnect = false;
stop_heartbeat();
if (ws_connect) ws_connect = false;
ws_group?.close();
ws_group = null;
ws_connect_status = 'disconnected';
}
});
function prevent_default<T extends Event>(fn: (event: T) => void) {
return function (event: T) {
event.preventDefault();
fn(event);
};
}
</script>
<!-- Debug panel — only visible when ws_connect=true and hide__ws_element=false -->
<section
class:hidden={!ws_connect || hide__ws_element}
class="ae_element__websocket_v3 container p-1 bg-pink-100 text-xs mx-auto pb-16 mt-32 mb-32 relative"
>
<span class="absolute top-0 right-0 flex flex-col gap-1">
<button
type="button"
onclick={() => { hide__ws_form = !hide__ws_form; }}
class="btn btn-sm text-xs hover:preset-filled-tertiary-500"
class:preset-tonal-tertiary={hide__ws_form}
class:preset-filled-tertiary-500={!hide__ws_form}
>
{hide__ws_form ? 'Show Form' : 'Hide Form?'}
</button>
<button
type="button"
onclick={() => { hide__ws_messages = !hide__ws_messages; }}
class="btn btn-sm text-xs hover:preset-filled-tertiary-500"
class:preset-tonal-tertiary={hide__ws_messages}
class:preset-filled-tertiary-500={!hide__ws_messages}
>
{hide__ws_messages ? 'Show Messages' : 'Hide Messages?'}
</button>
<button
type="button"
onclick={() => { hide__ws_commands = !hide__ws_commands; }}
class="btn btn-sm text-xs hover:preset-filled-tertiary-500"
class:preset-tonal-tertiary={hide__ws_commands}
class:preset-filled-tertiary-500={!hide__ws_commands}
>
{hide__ws_commands ? 'Show Commands' : 'Hide Commands?'}
</button>
</span>
<header>
<h1 class="h6 text-center">WebSocket V3 — {ws_connect_status ?? 'not connected'}</h1>
<p class="text-center opacity-60">Group: {group_id} · Client: {String(client_id).slice(-8)}</p>
</header>
{#if !hide__ws_form}
<form onsubmit={prevent_default(handle_send)} class="flex flex-row gap-1 flex-wrap mt-2">
<input
type="text"
bind:value={group_id}
placeholder="Group ID"
class="input text-sm w-36"
/>
<input
type="text"
bind:value={cmd}
placeholder="Command"
class="input text-sm w-36"
/>
<input
type="text"
bind:value={msg}
placeholder="Message"
class="input text-sm w-36"
/>
<button
type="submit"
class="btn btn-sm preset-tonal-primary"
disabled={!ws_connect || ws_connect_status !== 'connected'}
>
Send
</button>
</form>
{/if}
{#if !hide__ws_commands}
<div class="mt-2">
<h2 class="font-bold">Commands received:</h2>
<ul class="max-h-32 overflow-y-auto">
{#each ws_received_list_cmd as item}
<li class="border-b border-pink-200 py-0.5 font-mono">
<span class="text-red-700 font-bold">{item.cmd}</span>
{#if item.from_id}<span class="opacity-50 ml-1">from {item.from_id}</span>{/if}
</li>
{/each}
</ul>
</div>
{/if}
{#if !hide__ws_messages}
<div class="mt-2">
<h2 class="font-bold">Messages received:</h2>
<ul class="max-h-32 overflow-y-auto">
{#each ws_received_list_other as item}
<li class="border-b border-pink-200 py-0.5 font-mono">
<span class="opacity-50">[{item.msg_type}]</span>
{item.msg ?? ''}
{#if item.from_id}<span class="opacity-50 ml-1">from {item.from_id}</span>{/if}
</li>
{/each}
</ul>
</div>
{/if}
</section>

View File

@@ -54,7 +54,7 @@
import Launcher_cfg from '../launcher_cfg.svelte';
import Launcher_menu from '../launcher_menu.svelte';
import Launcher_session_view from '../launcher_session_view.svelte';
import Element_websocket_v2 from '$lib/elements/element_websocket_v2.svelte';
import Element_websocket_v3 from '$lib/elements/element_websocket_v3.svelte';
// *** Set initial variables
// NOTE: Derived from data.account_id (prop) instead of $slct.account_id (store)
@@ -288,7 +288,8 @@
function handle_ws_recv(ws_recv_status: any) {
if (log_lvl) console.log('*** handle_ws_recv() ***', ws_recv_status);
if (ws_recv_status.type == 'cmd' && ws_recv_status.cmd) {
// V3 schema uses msg_type instead of type
if (ws_recv_status.msg_type == 'cmd' && ws_recv_status.cmd) {
let cmd = ws_recv_status.cmd;
if ($events_loc.launcher.controller != 'remote') return;
@@ -1001,15 +1002,16 @@
</Modal>
{#if $events_loc.launcher.controller_group_code && $events_loc.launcher.ws_connect}
<Element_websocket_v2
<Element_websocket_v3
{log_lvl}
bind:ws_connect={$events_loc.launcher.ws_connect}
bind:ws_connect_status={$events_sess.launcher.ws_connect_status}
ws_server={$ae_api.fqdn}
api_key={$ae_api.api_secret_key}
jwt={$ae_loc.jwt}
bind:group_id={$events_loc.launcher.controller_group_code}
bind:client_id={$events_loc.launcher.controller_client_id}
bind:cmd={$events_sess.launcher.controller_cmd}
type={'cmd'}
bind:trigger_send={$events_sess.launcher.controller_trigger_send}
bind:trigger_connect={$events_sess.launcher.trigger__ws_connect}
bind:trigger_disconnect={$events_sess.launcher.trigger__ws_disconnect}