Packages
Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.
Current section
Files
Jump to
Current section
Files
lib/raxol/core/runtime/subscription.ex
defmodule Raxol.Core.Runtime.Subscription do
@moduledoc """
Provides a way to subscribe to recurring updates and external events.
Subscriptions allow applications to receive messages over time without
explicitly requesting them. This is useful for:
* Timer-based updates (animation, polling)
* System events (window resize, focus change)
* External data streams (file changes, network events)
## Types of Subscriptions
* `:interval` - Regular time-based updates
* `:events` - System or component events
* `:file_watch` - File system changes
* `:custom` - Custom event sources
## Examples
# Update every second
Subscription.interval(1000, :tick)
# Listen for specific events
Subscription.events([:key_press, :mouse_click])
# Watch a file for changes
Subscription.file_watch("config.json", [:modify, :delete])
# Custom subscription
Subscription.custom(MyEventSource, :start_listening)
"""
@type t :: %__MODULE__{
type: :interval | :events | :file_watch | :custom,
data: term()
}
defstruct [:type, :data]
@doc """
Creates a new subscription. This is the low-level constructor, prefer using
the specific subscription constructors unless you need custom behavior.
"""
def new(type, data) do
%__MODULE__{type: type, data: data}
end
@doc """
Creates a subscription that will send a message at regular intervals.
## Options
* `:start_immediately` - Send first message immediately (default: false)
* `:jitter` - Add random jitter to interval (default: 0)
"""
def interval(interval_ms, msg, opts \\ [])
def interval(interval_ms, msg, opts)
when is_integer(interval_ms) and interval_ms > 0 do
data = %{
interval: interval_ms,
message: msg,
start_immediately: Keyword.get(opts, :start_immediately, false),
jitter: Keyword.get(opts, :jitter, 0)
}
new(:interval, data)
end
def interval(_interval_ms, _msg, _opts) do
{:error, :invalid_interval}
end
@doc """
Creates a subscription for system or component events.
## Event Types
* `:key_press` - Keyboard events
* `:mouse_click` - Mouse click events
* `:mouse_move` - Mouse movement events
* `:window_resize` - Terminal window resize
* `:focus_change` - Terminal focus change
* `:component` - Component-specific events
"""
def events(event_types) when is_list(event_types) do
new(:events, event_types)
end
def events(_event_types) do
{:error, :invalid_events}
end
@doc """
Creates a subscription that watches for file system changes.
## Event Types
* `:modify` - File content changes
* `:delete` - File deletion
* `:create` - File creation
* `:rename` - File rename
* `:attrib` - Attribute changes
Returns `{:error, :invalid_file_watch_args}` if event_types is not a list.
"""
def file_watch(path, event_types \\ [:modify])
def file_watch(path, event_types) when is_list(event_types) do
data = %{
path: path,
events: event_types
}
new(:file_watch, data)
end
def file_watch(_path, _event_types), do: {:error, :invalid_file_watch_args}
@doc """
Creates a custom subscription using a provided event source.
The event source should implement the `Raxol.Core.Runtime.EventSource`
behaviour.
"""
def custom(source_module, _init_args) when not is_atom(source_module) do
{:error, :invalid_module}
end
def custom(_source_module, init_args) when not is_map(init_args) do
{:error, :invalid_args}
end
def custom(source_module, init_args) do
data = %{module: source_module, args: init_args}
new(:custom, data)
end
@doc """
Starts a subscription within the given context. This is used by the runtime
system and should not be called directly by applications.
Returns `{:ok, subscription_id}` or `{:error, reason}`.
"""
def start(%__MODULE__{} = subscription, context) do
# Validate context has required pid
case Map.has_key?(context, :pid) do
false ->
{:error, :invalid_context}
true ->
case subscription do
%{type: :interval, data: data} ->
start_interval(data, context)
%{type: :events, data: event_types} ->
start_event_subscription(event_types, context)
%{type: :file_watch, data: data} ->
start_file_watch(data, context)
%{type: :custom, data: data} ->
start_custom_subscription(data, context)
%{type: _invalid_type, data: _data} ->
{:error, :invalid_subscription_type}
end
end
end
@doc """
Stops a subscription. This is used by the runtime system and should not
be called directly by applications.
"""
def stop(subscription_id) do
stop_subscription(subscription_id)
end
@spec stop_subscription(any()) :: any()
defp stop_subscription({:interval, timer_ref}), do: stop_interval(timer_ref)
@spec stop_subscription(any()) :: any()
defp stop_subscription({:events, actual_id}), do: stop_events(actual_id)
@spec stop_subscription(any()) :: any()
defp stop_subscription({:file_watch, watcher_pid}),
do: stop_file_watch(watcher_pid)
@spec stop_subscription(any()) :: any()
defp stop_subscription({:custom, source_pid}), do: stop_custom(source_pid)
@spec stop_subscription(any()) :: any()
defp stop_subscription(_), do: {:error, :invalid_subscription}
@spec stop_interval(reference()) :: any()
defp stop_interval(timer_ref) do
case :timer.cancel(timer_ref) do
{:ok, :cancel} -> :ok
{:error, :badarg} -> {:error, :subscription_not_found}
{:error, reason} -> {:error, {:timer_cancel_error, reason}}
end
end
@spec stop_events(String.t() | integer()) :: any()
defp stop_events(actual_id)
when is_integer(actual_id) or is_reference(actual_id) do
Raxol.Core.Events.EventManager.unsubscribe(actual_id)
end
@spec stop_events(String.t() | integer()) :: any()
defp stop_events(_actual_id) do
{:error, :invalid_subscription_id}
end
@spec stop_file_watch(String.t() | integer()) :: any()
defp stop_file_watch(watcher_pid) do
case Process.alive?(watcher_pid) do
true ->
Process.exit(watcher_pid, :normal)
:ok
false ->
{:error, :subscription_not_found}
end
end
@spec stop_custom(String.t() | integer()) :: any()
defp stop_custom(source_pid) do
case Process.alive?(source_pid) do
true ->
Process.exit(source_pid, :normal)
:ok
false ->
{:error, :subscription_not_found}
end
end
# Private helpers for starting different types of subscriptions
@spec start_interval(any(), any()) :: any()
defp start_interval(data, context) do
%{
interval: interval,
message: msg,
start_immediately: immediate,
jitter: jitter
} = data
case immediate do
true -> send(context.pid, {:subscription, msg})
false -> :ok
end
# Calculate jitter safely, ensuring we don't call :rand.uniform with 0
jitter_ms =
case jitter > 0 do
true -> :rand.uniform(jitter)
false -> 0
end
# Add error handling for timer creation
case :timer.send_interval(
interval + jitter_ms,
context.pid,
{:subscription, msg}
) do
{:ok, timer_ref} ->
{:ok, {:interval, timer_ref}}
{:error, reason} ->
{:error, {:timer_creation_error, reason}}
end
end
@spec start_event_subscription(any(), any()) :: any()
defp start_event_subscription(event_types, _context) do
case Raxol.Core.Events.EventManager.subscribe(event_types, []) do
{:ok, subscription_id} ->
{:ok, {:events, subscription_id}}
{:error, reason} ->
{:error, reason}
end
end
@spec start_file_watch(any(), any()) :: any()
defp start_file_watch(data, context) do
%{path: path, events: events} = data
# Check if file exists before starting watch
case File.exists?(path) do
false ->
{:error, :invalid_file_path}
true ->
{:ok, pid} =
Task.start(fn ->
watch_file(path, events, context.pid)
end)
{:ok, {:file_watch, pid}}
end
end
@spec start_custom_subscription(any(), any()) :: any()
defp start_custom_subscription(data, context) do
%{module: module, args: args} = data
case module.start_link(args, context) do
{:ok, pid} -> {:ok, {:custom, pid}}
error -> error
end
end
# File watching helper
@spec watch_file(String.t(), any(), String.t() | integer()) :: any()
defp watch_file(path, events, target_pid) do
case FileSystem.start_link(dirs: [path]) do
{:ok, watcher_pid} ->
:ok = FileSystem.subscribe(watcher_pid)
receive do
{_watcher_pid, {:file_event, path, file_events}} ->
case Enum.any?(file_events, &(&1 in events)) do
true ->
send(
target_pid,
{:subscription, {:file_change, path, file_events}}
)
false ->
:ok
end
after
5000 ->
# Timeout after 5 seconds if no file events are received
send(target_pid, {:subscription, {:file_watch_timeout, path}})
end
# Continue watching
watch_file(path, events, target_pid)
{:error, reason} ->
send(
target_pid,
{:subscription, {:file_watch_error, {:start_error, reason}}}
)
end
end
end