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:
401
src/lib/elements/element_websocket_v3.svelte
Normal file
401
src/lib/elements/element_websocket_v3.svelte
Normal 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>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
import Launcher_cfg from '../launcher_cfg.svelte';
|
import Launcher_cfg from '../launcher_cfg.svelte';
|
||||||
import Launcher_menu from '../launcher_menu.svelte';
|
import Launcher_menu from '../launcher_menu.svelte';
|
||||||
import Launcher_session_view from '../launcher_session_view.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
|
// *** Set initial variables
|
||||||
// NOTE: Derived from data.account_id (prop) instead of $slct.account_id (store)
|
// 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) {
|
function handle_ws_recv(ws_recv_status: any) {
|
||||||
if (log_lvl) console.log('*** handle_ws_recv() ***', ws_recv_status);
|
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;
|
let cmd = ws_recv_status.cmd;
|
||||||
if ($events_loc.launcher.controller != 'remote') return;
|
if ($events_loc.launcher.controller != 'remote') return;
|
||||||
|
|
||||||
@@ -1001,15 +1002,16 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{#if $events_loc.launcher.controller_group_code && $events_loc.launcher.ws_connect}
|
{#if $events_loc.launcher.controller_group_code && $events_loc.launcher.ws_connect}
|
||||||
<Element_websocket_v2
|
<Element_websocket_v3
|
||||||
{log_lvl}
|
{log_lvl}
|
||||||
bind:ws_connect={$events_loc.launcher.ws_connect}
|
bind:ws_connect={$events_loc.launcher.ws_connect}
|
||||||
bind:ws_connect_status={$events_sess.launcher.ws_connect_status}
|
bind:ws_connect_status={$events_sess.launcher.ws_connect_status}
|
||||||
ws_server={$ae_api.fqdn}
|
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:group_id={$events_loc.launcher.controller_group_code}
|
||||||
bind:client_id={$events_loc.launcher.controller_client_id}
|
bind:client_id={$events_loc.launcher.controller_client_id}
|
||||||
bind:cmd={$events_sess.launcher.controller_cmd}
|
bind:cmd={$events_sess.launcher.controller_cmd}
|
||||||
type={'cmd'}
|
|
||||||
bind:trigger_send={$events_sess.launcher.controller_trigger_send}
|
bind:trigger_send={$events_sess.launcher.controller_trigger_send}
|
||||||
bind:trigger_connect={$events_sess.launcher.trigger__ws_connect}
|
bind:trigger_connect={$events_sess.launcher.trigger__ws_connect}
|
||||||
bind:trigger_disconnect={$events_sess.launcher.trigger__ws_disconnect}
|
bind:trigger_disconnect={$events_sess.launcher.trigger__ws_disconnect}
|
||||||
|
|||||||
Reference in New Issue
Block a user