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_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}
|
||||
|
||||
Reference in New Issue
Block a user