Packages
phoenix_kit
1.7.145
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit/known_packages.ex
defmodule PhoenixKit.KnownPackages do
@moduledoc """
Live catalog of known external PhoenixKit packages fetched from Hex.pm.
Fetched on demand and cached in an ETS table for 10 minutes. On Hex
failure, returns stale cached data when within the 24h max-stale-age
cap, otherwise returns `extra_known_packages` config entries only.
Cache uses an ETS named table (`:phoenix_kit_known_packages_cache`) with
`read_concurrency: true` rather than `:persistent_term`, to avoid
global-GC write amplification on multi-node cold deploys.
## Public API
* `list/0` and `list/1` — return the catalog (cached).
* `clear_cache/0` — wipes the cache (for tests).
`list/1` accepts opts (intended for tests, not production):
* `:ttl_ms` — cache TTL (default `:timer.minutes(10)`)
* `:max_stale_age_ms` — how long stale data may be served on Hex
failure (default `:timer.hours(24)`)
* `:req_options` — pass-through to `Req.get/2`
## Operational signals
Hex outages emit Logger entries at three levels — each maps to a
distinct degradation state operators can alert on:
* `:warning` "Hex fetch failed … serving stale data" — cache hit
within `max_stale_age_ms`. Catalog still works; Hex briefly
unavailable. Transient.
* `:warning` "Hex fetch failed … no cached data" — Hex unreachable
AND cache empty. Catalog shows config extras only. Visible
degradation, but not a crisis (admin Modules page is the only
consumer).
* `:error` "Hex fetch failed … exceeds max stale age" — Hex
unreachable for over `max_stale_age_ms`. Cached entries
dropped, catalog falls back to config extras. The escalation
level: page on this if it persists.
"""
require Logger
@table :phoenix_kit_known_packages_cache
@cache_key :cache
@hex_search_url "https://hex.pm/api/packages"
@icon_marker_re ~r/\bhex_docs_icon_name:\s*([a-z0-9-]+)/
@default_icon "hero-puzzle-piece"
@skip_packages ["phoenix_kit"]
@ttl_ms :timer.minutes(10)
@max_stale_age_ms :timer.hours(24)
@default_req_options [receive_timeout: 3000]
@beamlab_org "BeamLabEU"
@doc """
Returns the list of known external PhoenixKit packages.
Each entry is a map with keys: `key`, `module`, `package`,
`hex_package`, `name`, `description`, `icon`, `hex_url`, `github_url`,
`latest_version`, `source`.
See module doc for `opts`.
"""
@spec list(keyword()) :: [map()]
def list(opts \\ []) do
ensure_table()
now = System.monotonic_time(:millisecond)
ttl = Keyword.get(opts, :ttl_ms, @ttl_ms)
case lookup() do
{fetched_at, cached} when now - fetched_at < ttl ->
cached
_ ->
fetch_and_cache(now, opts)
end
end
@doc "Clears the in-process cache. Intended for tests."
@spec clear_cache() :: :ok
def clear_cache do
ensure_table()
:ets.delete(@table, @cache_key)
:ok
end
# ---------------------------------------------------------------------------
# Private — cache mechanics
# ---------------------------------------------------------------------------
defp ensure_table do
case :ets.whereis(@table) do
:undefined ->
:ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
_ ->
:ok
end
rescue
# Race: another process called :ets.new/2 between our :ets.whereis/1
# check and our :ets.new/2. The named table now exists; nothing
# more to do.
ArgumentError -> :ok
end
defp lookup do
case :ets.lookup(@table, @cache_key) do
[{@cache_key, fetched_at, list}] -> {fetched_at, list}
[] -> nil
end
end
defp fetch_and_cache(now, opts) do
case fetch_from_hex(opts) do
{:ok, hex_list} ->
merged = merge_extras(hex_list)
:ets.insert(@table, {@cache_key, now, merged})
merged
{:error, reason} ->
handle_hex_failure(reason, now, opts)
end
end
defp handle_hex_failure(reason, now, opts) do
max_stale = Keyword.get(opts, :max_stale_age_ms, @max_stale_age_ms)
case lookup() do
{fetched_at, stale} when now - fetched_at <= max_stale ->
age_minutes = div(now - fetched_at, :timer.minutes(1))
Logger.warning(
"PhoenixKit.KnownPackages: Hex fetch failed (#{inspect(reason)}) — " <>
"serving stale data (age=#{age_minutes}m)"
)
stale
{fetched_at, _stale} ->
age_hours = div(now - fetched_at, :timer.hours(1))
Logger.error(
"PhoenixKit.KnownPackages: Hex fetch failed (#{inspect(reason)}) and " <>
"cached data exceeds max stale age (#{age_hours}h) — returning extras only"
)
:ets.delete(@table, @cache_key)
merge_extras([])
nil ->
Logger.warning(
"PhoenixKit.KnownPackages: Hex fetch failed (#{inspect(reason)}) and " <>
"no cached data — returning extras only"
)
merge_extras([])
end
end
# ---------------------------------------------------------------------------
# Private — Hex fetch
# ---------------------------------------------------------------------------
defp fetch_from_hex(opts) do
url = @hex_search_url <> "?search=phoenix_kit_&sort=name"
req_options = Keyword.get(opts, :req_options, @default_req_options)
fetch_hex_page(url, [], req_options, 1)
end
# `page_count` cap (`@max_pages`) bounds the recursion: a malformed
# `Link` header (next URL pointing back to the same page, or hex
# returning many more results than expected) can't loop forever.
# 100 packages/page * 20 pages = 2000 packages — comfortable
# headroom for the `phoenix_kit_*` namespace today.
@max_pages 20
defp fetch_hex_page(_url, acc, _opts, page) when page > @max_pages do
Logger.warning(
"PhoenixKit.KnownPackages: Hex pagination exceeded #{@max_pages} pages — " <>
"stopping (returning #{length(acc)} entries collected so far)"
)
{:ok, acc}
end
defp fetch_hex_page(nil, acc, _opts, _page), do: {:ok, acc}
defp fetch_hex_page(url, acc, req_options, page) do
case Req.get(url, req_options) do
{:ok, %{status: 200, body: packages, headers: headers}} when is_list(packages) ->
valid = packages |> Enum.reject(&skip_package?/1) |> Enum.map(&shape_entry/1)
next_url = parse_next_link(headers)
fetch_hex_page(next_url, acc ++ valid, req_options, page + 1)
{:ok, %{status: 200}} ->
{:error, :malformed_response}
{:ok, %{status: status}} ->
{:error, {:http_error, status}}
{:error, reason} ->
{:error, reason}
end
rescue
e -> {:error, e}
end
defp skip_package?(%{"name" => name}), do: name in @skip_packages
defp skip_package?(_), do: false
defp parse_next_link(headers) when is_map(headers) do
link = headers |> Map.get("link", []) |> List.first()
with binary when is_binary(binary) <- link,
[_, url] <- Regex.run(~r/<([^>]+)>;\s*rel="next"/, binary) do
url
else
_ -> nil
end
end
# ---------------------------------------------------------------------------
# Private — entry shape (kept backward-compatible with the previous
# hardcoded list: includes module/hex_package/github_url/latest_version)
# ---------------------------------------------------------------------------
defp shape_entry(pkg) do
package = pkg["name"]
key = String.replace_prefix(package, "phoenix_kit_", "")
raw_description = get_in(pkg, ["meta", "description"]) || ""
{stripped_description, icon} = parse_marker(raw_description)
%{
key: key,
module: derive_module_atom(package),
package: package,
hex_package: package,
name: humanize_key(key),
description: stripped_description,
icon: icon,
hex_url: "https://hex.pm/packages/#{package}",
github_url: github_url_for(pkg, package),
latest_version: pkg["latest_version"],
source: "hex"
}
end
defp derive_module_atom(package) do
package
|> String.split("_")
|> Enum.map_join("", &String.capitalize/1)
|> then(&("Elixir." <> &1))
|> String.to_atom()
end
defp github_url_for(pkg, package) do
links = get_in(pkg, ["meta", "links"]) || %{}
github = links["GitHub"] || links["Github"] || links["github"]
if is_binary(github) and String.contains?(github, "github.com") do
github
else
"https://github.com/#{@beamlab_org}/#{package}"
end
end
defp humanize_key(key) do
key |> String.split("_") |> Enum.map_join(" ", &String.capitalize/1)
end
defp parse_marker(description) do
case Regex.run(@icon_marker_re, description) do
[full_match, icon_name] ->
stripped = description |> String.replace(full_match, "") |> String.trim()
{stripped, icon_name}
nil ->
{String.trim(description), @default_icon}
end
end
# ---------------------------------------------------------------------------
# Private — config extras (parent-app-provided private/forked packages)
# ---------------------------------------------------------------------------
defp merge_extras(hex_list) do
extras =
:phoenix_kit
|> Application.get_env(:extra_known_packages, [])
|> Enum.map(&normalize_config_entry/1)
# Config wins: concat config first, then hex; uniq_by keeps the first
(extras ++ hex_list) |> Enum.uniq_by(& &1.package)
end
defp normalize_config_entry(entry) do
package = Map.get(entry, :package)
entry
|> Map.put(:source, "config")
|> Map.put_new(:hex_package, package)
|> Map.put_new(:module, nil)
|> Map.put_new(:github_url, nil)
|> Map.put_new(:latest_version, nil)
end
end