defmodule Mix.Tasks.Alva.Codegen do @shortdoc "Generates TypeScript bindings for LiveVue events" @moduledoc since: "0.1.0" @moduledoc """ Generates TypeScript bindings for LiveVue events defined in Ash resources. Reads the host application's configured `ash_domains`. Outputs generated files to `:alva, :output_dir` (defaults to `assets/js/alva`). ## Generated Files * `types.ts` - TypeScript interfaces for all Ash resources and filter types * `events.ts` - Type-safe event map with input/output types * `signals.ts` - Type-safe signal map with payload types * `composables/useAlva.ts` - Main Vue composable with domain-scoped methods * `composables/useAlvaForm.ts` - Form handling composable * `composables/useAlvaQuery.ts` - Reactive query composable with debouncing * `composables/useAlvaUpload.ts` - File upload composable * `index.ts` - Public exports ## Usage mix alva.codegen See `Alva.Codegen.DtoGenerator`, `Alva.Codegen.InputContract`, and `Alva.Codegen.TypeMapper` for the individual code generation modules. """ use Mix.Task alias Alva.Codegen.DtoGenerator alias Alva.Codegen.InputContract alias Ash.Resource.Info @impl true @doc """ Executes the code generation pipeline. """ @doc since: "0.1.0" def run(_args) do Mix.Task.run("compile") Mix.Task.run("app.start") app = Mix.Project.config()[:app] Application.ensure_all_started(app) output_dir = Application.get_env(:alva, :output_dir, "assets/js/alva") if Ash.Info.domains(app) == [] do Mix.shell().info( "No Ash domains found. Please configure config :#{app}, ash_domains: [...]" ) else composables_dir = Path.join(output_dir, "composables") File.mkdir_p!(composables_dir) File.mkdir_p!(output_dir) events_map = Alva.Registry.event_map(app) signal_map = Alva.Registry.registry(app).signal_map resources = events_map |> Map.values() |> Enum.map(fn {resource, _event} -> resource end) |> Enum.uniq() generate_types_ts(resources, events_map, output_dir) generate_events_ts(events_map, output_dir) generate_signals_ts(signal_map, events_map, output_dir) generate_use_alva_ts(events_map, signal_map, composables_dir) generate_use_alva_form_ts(composables_dir) generate_use_alva_query_ts(composables_dir) generate_use_alva_paginated_query_ts(composables_dir) generate_use_alva_upload_ts(composables_dir) generate_use_alva_assigns_ts(composables_dir) generate_testing_utils_ts(events_map, output_dir) generate_index_ts(output_dir) Mix.shell().info("Successfully generated TypeScript SDK in #{output_dir}") end end defp generate_types_ts(resources, events_map, output_dir) do content = DtoGenerator.generate_types_ts(resources, events_map) write_file!(output_dir, "types.ts", content) end defp generate_events_ts(events_map, output_dir) do events_content = events_map |> Enum.map_join("\n", fn {event_name, {resource, event_def}} -> output_type = DtoGenerator.output_type(resource, event_def) action = Info.action(resource, event_def.action) input_shape = InputContract.generate_input_shape( resource, event_def, action, " " ) """ "#{event_name}": { input: #{input_shape}; output: AlvaResult; }; """ end) content = """ // This file is auto-generated by Alva. // Do not edit manually. import type { AlvaResult } from "./types"; import type * as Types from "./types"; export type AlvaEvents = { #{events_content} }; """ write_file!(output_dir, "events.ts", content) end defp generate_signals_ts(signal_map, events_map, output_dir) do subs_content = signal_map |> Enum.map_join("\n", fn {_key, {resource, signal_def}} -> payload_prop = generate_signal_payload_prop(signal_def, events_map, resource) """ "#{signal_def.name}": { #{payload_prop} }; """ end) content = """ // This file is auto-generated by Alva. // Do not edit manually. import type * as Types from "./types"; export type AlvaSignals = { #{subs_content} }; """ write_file!(output_dir, "signals.ts", content) end defp generate_signal_payload_prop(_signal_def, _events_map, resource) do output_type = resource |> Module.split() |> List.last() "payload: Types.#{output_type};" end defp generate_use_alva_ts(events_map, signal_map, composables_dir) do all_keys = Map.keys(events_map) ++ Map.keys(signal_map) domains = all_keys |> Enum.filter(&String.contains?(&1, ".")) |> Enum.map(&(String.split(&1, ".") |> List.first())) |> Enum.uniq() domain_objects = Enum.map_join(domains, ",\n", fn domain -> domain_prefix = "#{domain}." domain_events = generate_domain_events(events_map, domain, domain_prefix) domain_signals = generate_domain_signals(signal_map, domain, domain_prefix) fields = [domain_events, domain_signals] |> Enum.reject(&(&1 == "")) |> Enum.join(",\n") """ #{domain}: { #{fields} } """ end) return_entries = [ domain_objects, """ use_upload: (name: string, options?: import("./useAlvaUpload").AlvaUploadOptions) => { return useAlvaUpload(name, options); } """ ] |> Enum.reject(&(String.trim(&1) == "")) |> Enum.join(",\n") content = """ // This file is auto-generated by Alva. // Do not edit manually. import { useLiveVue, useLiveEvent } from "live_vue"; import { ref, computed, onUnmounted } from "vue"; import { useAlvaForm, type AlvaFormOptions } from "./useAlvaForm"; import { useAlvaQuery } from "./useAlvaQuery"; import { useAlvaUpload } from "./useAlvaUpload"; import type { AlvaEvents } from "../events"; import type { AlvaSignals } from "../signals"; import type { AlvaError } from "../types"; export type { AlvaError }; export interface AlvaConfig { onError?: (error: AlvaError, event: string) => void; onSuccess?: (data: unknown, event: string) => void; } export function useAlva(config?: AlvaConfig) { let live: any = null; try { live = useLiveVue(); } catch (_e) { live = null; } const safePushEvent = (event: string, payload: any, callback: any) => { if (live && typeof live.pushEvent === "function") { return live.pushEvent(event, payload, callback); } callback?.({ ok: false, error: { type: "unmounted", message: "LiveVue context not available" } }); }; return { #{return_entries} }; } """ write_file!(composables_dir, "useAlva.ts", content) end defp generate_use_alva_form_ts(composables_dir) do content = """ // This file is auto-generated by Alva. // Do not edit manually. import { reactive } from "vue"; import { useLiveForm, type Form, type UseLiveFormReturn } from "live_vue"; import type { AlvaEvents } from "../events"; export type AlvaFormOptions = { initialValues: FormValues; validateEvent?: EventKeys; debounceMs?: number; uploads?: Record string[] }>; onOptimisticSubmit?: (formData: FormValues) => (() => void) | void; }; export type AlvaFormReturn = Omit, "submit"> & { values: FormValues; submit: () => Promise; reset: (newValues?: Partial) => void; }; function clearFormErrors(errors: Record) { for (const key of Object.keys(errors)) { delete errors[key]; } } export function useAlvaForm< K extends keyof AlvaEvents, FormValues extends object = AlvaEvents[K]["input"], >( submitEvent: K, options: AlvaFormOptions, ): AlvaFormReturn { const pseudoForm = reactive({ name: submitEvent as string, values: JSON.parse(JSON.stringify(options.initialValues)), errors: {} as Record, valid: true, }) as Form; const liveForm = useLiveForm(pseudoForm, { submitEvent: submitEvent as string, changeEvent: (options.validateEvent as unknown as string) || null, debounceInMiliseconds: options.debounceMs || 300, prepareData: (data: any) => { if (options.uploads) { for (const [key, upload] of Object.entries(options.uploads)) { data[key] = upload.getFileReferences(); } } return data; } }); const originalSubmit = liveForm.submit; const reset = (newValues?: Partial) => { const target = newValues ? { ...options.initialValues, ...newValues } : options.initialValues; Object.assign(pseudoForm.values, JSON.parse(JSON.stringify(target))); clearFormErrors(pseudoForm.errors as any); pseudoForm.valid = true; }; const submit = async (): Promise => { for (const key of Object.keys(pseudoForm.values as object)) { try { const fieldRef = liveForm.field(key as any); if (fieldRef && fieldRef.value && fieldRef.value.value !== undefined) { (pseudoForm.values as any)[key] = fieldRef.value.value; } } catch (_e) {} } const rollbackFn = options.onOptimisticSubmit?.(pseudoForm.values); let result: any; try { result = await originalSubmit(); } catch (e: any) { result = { ok: false, error: { type: "unknown", message: e.message || String(e) } }; } if (!result || !result.ok) { if (rollbackFn) rollbackFn(); if (result?.error?.fields) { clearFormErrors(pseudoForm.errors as any); Object.assign(pseudoForm.errors, result.error.fields); } } else { clearFormErrors(pseudoForm.errors as any); } return result; }; return { ...liveForm, values: pseudoForm.values, submit, reset, } as AlvaFormReturn; } """ write_file!(composables_dir, "useAlvaForm.ts", content) end defp generate_use_alva_query_ts(composables_dir) do content = """ // This file is auto-generated by Alva. // Do not edit manually. import { ref, watch, onUnmounted, type Ref, type WatchSource } from "vue"; import { useLiveVue, useLiveEvent } from "live_vue"; import type { AlvaEvents } from "../events"; import type { AlvaError } from "./useAlva"; export interface AlvaQueryOptions { debounceMs?: number; pollIntervalMs?: number | Ref | (() => number); autoRefreshOnSignal?: string | string[]; } export interface AlvaQueryReturn { data: Ref; loading: Ref; error: Ref; refetch: () => Promise; } export function useAlvaQuery< K extends keyof AlvaEvents, Input = AlvaEvents[K]["input"], Output = Extract extends { data: infer D } ? D : never >( queryEvent: K, inputGetter: WatchSource, options: AlvaQueryOptions = {} ): AlvaQueryReturn { let live: any = null; try { live = useLiveVue(); } catch (_e) { live = null; } const data = ref(null) as Ref; const loading = ref(false); const error = ref(null); const debounceMs = options.debounceMs ?? 0; let timeoutId: any; let currentRequestId = 0; let pollTimer: any; const fetchQuery = async (currentInput: Input) => { const requestId = ++currentRequestId; return new Promise((resolve) => { if (!live || typeof live.pushEvent !== "function") { loading.value = false; return resolve(); } live.pushEvent(queryEvent as string, currentInput as any, (reply: any) => { if (requestId !== currentRequestId) return resolve(); loading.value = false; if (reply && reply.ok) { data.value = reply.data; error.value = null; } else if (reply && !reply.ok) { error.value = reply.error || { type: "error", message: "Query failed" }; } resolve(); }); }); }; const getCurrentInput = (): Input => { return typeof inputGetter === "function" ? (inputGetter as any)() : (inputGetter as any).value; }; const debouncedFetch = (currentInput: Input) => { loading.value = true; if (debounceMs > 0) { clearTimeout(timeoutId); timeoutId = setTimeout(() => { fetchQuery(currentInput); }, debounceMs); } else { fetchQuery(currentInput); } }; watch(inputGetter, (newInput) => { debouncedFetch(newInput); }, { deep: true, immediate: true }); const getPollInterval = (): number => { if (!options.pollIntervalMs) return 0; if (typeof options.pollIntervalMs === "function") return options.pollIntervalMs(); if (typeof options.pollIntervalMs === "object" && options.pollIntervalMs !== null && "value" in options.pollIntervalMs) { return (options.pollIntervalMs as Ref).value; } return Number(options.pollIntervalMs) || 0; }; const setupPollTimer = () => { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } const interval = getPollInterval(); if (interval > 0) { pollTimer = setInterval(() => { fetchQuery(getCurrentInput()); }, interval); } }; if (typeof options.pollIntervalMs === "function" || (typeof options.pollIntervalMs === "object" && options.pollIntervalMs !== null && "value" in options.pollIntervalMs)) { watch(() => getPollInterval(), () => { setupPollTimer(); }, { immediate: true }); } else { setupPollTimer(); } if (options.autoRefreshOnSignal) { const signals = Array.isArray(options.autoRefreshOnSignal) ? options.autoRefreshOnSignal : [options.autoRefreshOnSignal]; for (const sig of signals) { useLiveEvent(sig, () => { fetchQuery(getCurrentInput()); }); } } onUnmounted(() => { if (pollTimer) clearInterval(pollTimer); }); return { data, loading, error, refetch: async () => { loading.value = true; await fetchQuery(getCurrentInput()); } }; } """ write_file!(composables_dir, "useAlvaQuery.ts", content) end defp generate_use_alva_upload_ts(composables_dir) do content = """ // This file is auto-generated by Alva. // Do not edit manually. import { computed, ref, watch } from "vue"; import { useLiveUpload, useLiveVue } from "live_vue"; export type UploadEntry = { ref: string; client_name?: string; client_size?: number; client_type?: string; name?: string; size?: number; type?: string; progress: number; error?: string; }; export type AlvaUploadOptions = { maxFiles?: number; maxSize?: number; }; export type AlvaUploadDispatchContext = { files: UploadEntry[]; references: string[]; primaryReference: string; }; const ALVA_UPLOAD_CHANGE_EVENT = "alva.validate_upload"; const ALVA_UPLOAD_SUBMIT_EVENT = "alva.save_upload"; type UploadConfigShape = { ref: string; name: string; accept: string | false; max_entries: number; auto_upload: boolean; entries: UploadEntry[]; errors: unknown[]; }; export function useAlvaUpload(name: string, options?: AlvaUploadOptions) { let live: any; try { live = useLiveVue(); } catch (_e) { return missingUpload(name); } const uploadProps = live?.vue?.props as Record | null | undefined; const getUploadConfig = (): UploadConfigShape | null => { const directConfig = uploadProps?.[name]; if (isUploadConfig(directConfig)) return directConfig; const nested = uploadProps?.uploads; if (nested && typeof nested === "object" && !Array.isArray(nested)) { const nestedConfig = (nested as Record)[name]; if (isUploadConfig(nestedConfig)) return nestedConfig; } return null; }; const config = getUploadConfig(); if (!config) { return missingUpload(name); } const upload = useLiveUpload(getUploadConfig as any, { changeEvent: ALVA_UPLOAD_CHANGE_EVENT, submitEvent: ALVA_UPLOAD_SUBMIT_EVENT, ...(options || {}), }) as any; const files = computed(() => upload.entries?.value || []); const errors = computed(() => upload.errors?.value || []); const progress = computed(() => { if (files.value.length === 0) return 0; let totalSize = 0; let totalUploaded = 0; for (const file of files.value) { const size = file.client_size ?? file.size ?? 0; totalSize += size; totalUploaded += size * ((file.progress || 0) / 100); } return totalSize === 0 ? 0 : Math.round((totalUploaded / totalSize) * 100); }); const enforceLimits = () => { if (!options || !upload.cancel) return; if (options.maxFiles && files.value.length > options.maxFiles) { files.value.slice(options.maxFiles).forEach((f) => upload.cancel(f.ref)); } if (options.maxSize) { files.value.forEach((f) => { const size = f.client_size ?? f.size ?? 0; if (size > options.maxSize!) upload.cancel(f.ref); }); } }; watch(files, enforceLimits, { deep: true, immediate: true }); const getFileReferences = () => files.value.map((f) => f.ref); const waitForCompletion = (): Promise => { return new Promise((resolve, reject) => { let settled = false; let stop: (() => void) | undefined; const settle = (cb: () => void) => { if (settled) return; settled = true; stop?.(); cb(); }; const tryComplete = () => { if (files.value.length === 0 || progress.value !== 100) return; const refs = getFileReferences(); const primary = refs[0]; if (!primary) { settle(() => reject(new Error("No uploaded file reference was produced."))); return; } settle(() => resolve({ files: [...files.value], references: refs, primaryReference: primary })); }; tryComplete(); if (settled) return; stop = watch([files, progress], tryComplete, { deep: true }); }); }; const dispatch = async ( submit: (ctx: AlvaUploadDispatchContext) => Promise, opts: { clear?: boolean } = {}, ) => { try { const ctx = await waitForCompletion(); return await submit(ctx); } finally { if (opts.clear !== false) upload.clear?.(); } }; return { ...upload, files, errors, progress, getFileReferences, dispatch }; } function isUploadConfig(value: unknown): value is UploadConfigShape { return Boolean( value && typeof value === "object" && typeof (value as UploadConfigShape).ref === "string" && typeof (value as UploadConfigShape).name === "string" && Array.isArray((value as UploadConfigShape).entries) && Array.isArray((value as UploadConfigShape).errors) ); } function missingUpload(name: string) { const files = ref([]); const errors = computed(() => []); const progress = computed(() => 0); const warn = () => { console.warn( `[alva/useAlvaUpload] Missing LiveView upload config for "${name}". ` + `Pass the matching upload prop to the LiveVue component.` ); }; return { name, entries: files, files, errors, progress, showFilePicker: warn, cancel: () => {}, clear: () => {}, getFileReferences: () => [], dispatch: async () => { warn(); throw new Error("Missing upload config"); }, }; } """ write_file!(composables_dir, "useAlvaUpload.ts", content) end defp generate_use_alva_assigns_ts(composables_dir) do content = """ // This file is auto-generated by Alva. // Do not edit manually. import { computed, type ComputedRef } from "vue"; import { useLiveVue } from "live_vue"; export function useAlvaAssigns = Record>(): ComputedRef { const live = useLiveVue(); return computed(() => { const props = (live.vue?.props || {}) as Record; return (props.assigns || props) as T; }); } """ write_file!(composables_dir, "useAlvaAssigns.ts", content) end defp generate_use_alva_paginated_query_ts(composables_dir) do content = """ // This file is auto-generated by Alva. // Do not edit manually. import { ref, watch } from "vue"; import { useAlvaQuery, type AlvaQueryOptions } from "./useAlvaQuery"; import type { AlvaEvents } from "../events"; export function useAlvaPaginatedQuery( eventName: K, inputGetter: () => AlvaEvents[K]["input"], options?: AlvaQueryOptions & { pageSize?: number } ) { const page = ref(1); const items = ref([]); const hasMore = ref(true); const loadingMore = ref(false); const pageSize = options?.pageSize || 10; const queryInput = () => { const baseInput = (inputGetter() || {}) as Record; return { ...baseInput, page: { offset: (page.value - 1) * pageSize, limit: pageSize } } as AlvaEvents[K]["input"]; }; const query = useAlvaQuery(eventName, queryInput as any, options); watch(query.data, (newData) => { if (Array.isArray(newData)) { if (page.value === 1) { items.value = newData; } else { items.value = [...items.value, ...newData]; } hasMore.value = newData.length >= pageSize; } }, { immediate: true }); const loadNextPage = async () => { if (!hasMore.value || loadingMore.value) return; loadingMore.value = true; page.value += 1; await query.refetch(); loadingMore.value = false; }; const reset = () => { page.value = 1; items.value = []; hasMore.value = true; query.refetch(); }; return { ...query, items, page, hasMore, loadingMore, loadNextPage, reset }; } """ write_file!(composables_dir, "useAlvaPaginatedQuery.ts", content) end defp generate_testing_utils_ts(_events_map, output_dir) do testing_dir = Path.join(output_dir, "testing") File.mkdir_p!(testing_dir) content = """ // This file is auto-generated by Alva. // Do not edit manually. import { ref } from "vue"; import { vi } from "vitest"; export function createMockAlvaClient(initialData: Record = {}) { const createMockQuery = (key: string, defaultVal: any) => { const data = ref(initialData[key] ?? defaultVal); const loading = ref(false); const error = ref(null); return { data, loading, error, refetch: async () => {} }; }; const createMockFn = (defaultReturn: any = { ok: true }) => { return vi.fn((input: any) => Promise.resolve(defaultReturn)); }; return { catalog: { use_list_products_query: (getter: any) => createMockQuery("products", []), use_get_product_by_id: (id: any) => createMockQuery("product", null), adjust_stock: createMockFn(), upload_media: createMockFn(), }, sales: { use_list_orders_query: (getter: any) => createMockQuery("orders", []), create_order: createMockFn(), begin_processing: createMockFn(), fulfill: createMockFn(), }, support: { use_list_conversations_query: (getter: any) => createMockQuery("conversations", []), use_list_messages_query: (getter: any) => createMockQuery("messages", []), create: createMockFn(), send_message: createMockFn(), }, demo_chat: { use_list_messages_query: (getter: any) => createMockQuery("chat_messages", []), send_message: createMockFn(), }, demo_notifications: { send: createMockFn(), } }; } """ write_file!(testing_dir, "index.ts", content) end defp generate_index_ts(output_dir) do content = """ // This file is auto-generated by Alva. // Do not edit manually. export { useAlva } from "./composables/useAlva"; export { useAlvaAssigns } from "./composables/useAlvaAssigns"; export { useAlvaPaginatedQuery } from "./composables/useAlvaPaginatedQuery"; export { createMockAlvaClient } from "./testing"; export type { AlvaConfig, AlvaError } from "./composables/useAlva"; export type { AlvaFormOptions } from "./composables/useAlvaForm"; export type { AlvaQueryOptions, AlvaQueryReturn } from "./composables/useAlvaQuery"; export type { AlvaUploadOptions, AlvaUploadDispatchContext, UploadEntry } from "./composables/useAlvaUpload"; export type { AlvaEvents } from "./events"; export type { AlvaSignals } from "./signals"; """ write_file!(output_dir, "index.ts", content) end defp write_file!(output_dir, filename, content) do file_path = Path.join(output_dir, filename) File.write!(file_path, content) end defp generate_domain_events(events_map, domain, domain_prefix) do events_map |> Enum.filter(fn {event_name, _} -> String.starts_with?(event_name, domain_prefix) or event_name == domain end) |> Enum.map_join(",\n", fn {event_name, {resource, event_def}} -> action_name = if String.starts_with?(event_name, domain_prefix), do: String.replace_prefix(event_name, domain_prefix, ""), else: event_name is_read = case Ash.Resource.Info.action(resource, event_def.action) do %{type: :read} -> true _ -> false end lookup_field = Map.get(event_def, :lookup) || :id by_id_ts = if is_read do """ , use_#{action_name}_by_#{lookup_field}: ( idGetter: import("vue").WatchSource, options?: import("./useAlvaQuery").AlvaQueryOptions ) => { const inputGetter = () => { const id = typeof idGetter === "function" ? idGetter() : (idGetter as any)?.value; return id ? ({ #{lookup_field}: id } as any) : null; }; return useAlvaQuery("#{event_name}", inputGetter, options); } """ else "" end query_ts = if is_read do """ , use_#{action_name}_query: ( inputGetter: () => AlvaEvents["#{event_name}"]["input"], options?: import("./useAlvaQuery").AlvaQueryOptions ) => { return useAlvaQuery("#{event_name}", inputGetter, options); }#{by_id_ts} """ else "" end """ #{action_name}: (payload: AlvaEvents["#{event_name}"]["input"]): Promise => { return new Promise((resolve) => { const startTime = performance.now(); safePushEvent("#{event_name}", payload, (reply: AlvaEvents["#{event_name}"]["output"]) => { const durationMs = Math.round(performance.now() - startTime); if (typeof window !== "undefined" && (window as any).__ALVA_RECORD_TELEMETRY__) { (window as any).__ALVA_RECORD_TELEMETRY__("#{event_name}", durationMs, reply?.ok); } if (reply?.ok) { config?.onSuccess?.(reply.data, "#{event_name}"); } else { config?.onError?.(reply?.error, "#{event_name}"); } resolve(reply); }); }); }, use_#{action_name}_form: ( options: Extract extends never ? never : AlvaFormOptions, keyof AlvaEvents> ) => { return useAlvaForm("#{event_name}", options); }, use_#{action_name}_upload: (options?: import("./useAlvaUpload").AlvaUploadOptions) => { return useAlvaUpload("#{event_name}", options); }#{query_ts} """ end) end defp generate_domain_signals(signal_map, domain, domain_prefix) do signal_map |> Enum.filter(fn {_k, {_, sig}} -> String.starts_with?(sig.name, domain_prefix) or sig.name == domain end) |> Enum.map_join(",\n", fn {_k, {_, sig}} -> signal_name = if String.starts_with?(sig.name, domain_prefix), do: String.replace_prefix(sig.name, domain_prefix, ""), else: sig.name """ on_#{signal_name}: ( input: AlvaSignals["#{sig.name}"] extends { input: infer I } ? I : Record, callback: (payload: AlvaSignals["#{sig.name}"]["payload"]) => void ) => { if (live) live.pushEvent("alva:subscribe_signal", { name: "#{sig.name}", input }, () => {}); useLiveEvent("#{sig.name}", callback); onUnmounted(() => { if (live) live.pushEvent("alva:unsubscribe_signal", { name: "#{sig.name}", input }, () => {}); }); }, use_#{signal_name}_state: ( input: AlvaSignals["#{sig.name}"] extends { input: infer I } ? I : Record = {} as any ) => { const data = ref([]); const latest = ref(null); const count = computed(() => data.value.length); const clear = () => { data.value = []; latest.value = null; }; if (live) live.pushEvent("alva:subscribe_signal", { name: "#{sig.name}", input }, () => {}); useLiveEvent("#{sig.name}", (payload: any) => { latest.value = payload; data.value.unshift(payload); }); onUnmounted(() => { if (live) live.pushEvent("alva:unsubscribe_signal", { name: "#{sig.name}", input }, () => {}); }); return { data, latest, count, clear }; } """ end) end end