diff --git a/fluxer_marketing/src/fluxer_marketing.gleam b/fluxer_marketing/src/fluxer_marketing.gleam index da12f56b..21ac5798 100644 --- a/fluxer_marketing/src/fluxer_marketing.gleam +++ b/fluxer_marketing/src/fluxer_marketing.gleam @@ -47,11 +47,21 @@ pub fn main() { rpc_secret: cfg.gateway_rpc_secret, )) - let badge_cache = badge_proxy.start_cache() + let badge_featured_cache = + badge_proxy.start_cache(badge_proxy.product_hunt_featured_url) + let badge_top_post_cache = + badge_proxy.start_cache(badge_proxy.product_hunt_top_post_url) let assert Ok(_) = wisp_mist.handler( - handle_request(_, i18n_db, cfg, slots_cache, badge_cache), + handle_request( + _, + i18n_db, + cfg, + slots_cache, + badge_featured_cache, + badge_top_post_cache, + ), cfg.secret_key_base, ) |> mist.new @@ -67,7 +77,8 @@ fn handle_request( i18n_db, cfg: config.Config, slots_cache: visionary_slots.Cache, - badge_cache: badge_proxy.Cache, + badge_featured_cache: badge_proxy.Cache, + badge_top_post_cache: badge_proxy.Cache, ) -> wisp.Response { let locale = get_request_locale(req) @@ -100,7 +111,8 @@ fn handle_request( release_channel: cfg.release_channel, visionary_slots: visionary_slots.current(slots_cache), metrics_endpoint: cfg.metrics_endpoint, - badge_cache: badge_cache, + badge_featured_cache: badge_featured_cache, + badge_top_post_cache: badge_top_post_cache, ) use <- wisp.log_request(req) diff --git a/fluxer_marketing/src/fluxer_marketing/badge_proxy.gleam b/fluxer_marketing/src/fluxer_marketing/badge_proxy.gleam index 5b64c983..7d834bd2 100644 --- a/fluxer_marketing/src/fluxer_marketing/badge_proxy.gleam +++ b/fluxer_marketing/src/fluxer_marketing/badge_proxy.gleam @@ -21,7 +21,9 @@ import gleam/httpc import gleam/option.{type Option} import wisp.{type Response} -const product_hunt_url = "https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light" +pub const product_hunt_featured_url = "https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light" + +pub const product_hunt_top_post_url = "https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1057558&theme=light&period=daily&t=1767529639613" const stale_after_ms = 300_000 @@ -46,45 +48,46 @@ type State { State(cache: Option(CacheEntry), is_refreshing: Bool) } -pub fn start_cache() -> Cache { +pub fn start_cache(url: String) -> Cache { let started = process.new_subject() - let _ = process.spawn_unlinked(fn() { run(started) }) + let _ = process.spawn_unlinked(fn() { run(started, url) }) let assert Ok(subject) = process.receive(started, within: 1000) Cache(subject: subject) } -fn run(started: process.Subject(process.Subject(ServerMessage))) { +fn run(started: process.Subject(process.Subject(ServerMessage)), url: String) { let subject = process.new_subject() process.send(started, subject) let initial = State(cache: option.None, is_refreshing: False) - loop(subject, initial) + loop(subject, url, initial) } -fn loop(subject: process.Subject(ServerMessage), state: State) { +fn loop(subject: process.Subject(ServerMessage), url: String, state: State) { let new_state = case process.receive(subject, within: stale_after_ms) { - Ok(Get(reply_to)) -> handle_get(subject, reply_to, state) + Ok(Get(reply_to)) -> handle_get(subject, reply_to, url, state) Ok(RefreshDone(fetched_at, svg)) -> handle_refresh_done(fetched_at, svg, state) - Error(_) -> maybe_refresh_in_background(subject, state) + Error(_) -> maybe_refresh_in_background(subject, url, state) } - loop(subject, new_state) + loop(subject, url, new_state) } fn handle_get( subject: process.Subject(ServerMessage), reply_to: process.Subject(Option(String)), + url: String, state: State, ) -> State { let now = monotonic_time_ms() case state.cache { option.None -> { - let svg = fetch_badge_svg() + let svg = fetch_badge_svg(url) process.send(reply_to, svg) let new_cache = case svg { @@ -103,7 +106,7 @@ fn handle_get( case is_stale && !state.is_refreshing { True -> { - spawn_refresh(subject) + spawn_refresh(subject, url) State(..state, is_refreshing: True) } False -> state @@ -128,13 +131,14 @@ fn handle_refresh_done( fn maybe_refresh_in_background( subject: process.Subject(ServerMessage), + url: String, state: State, ) -> State { let now = monotonic_time_ms() case state.cache, state.is_refreshing { option.Some(entry), False if now - entry.fetched_at > stale_after_ms -> { - spawn_refresh(subject) + spawn_refresh(subject, url) State(..state, is_refreshing: True) } @@ -142,11 +146,11 @@ fn maybe_refresh_in_background( } } -fn spawn_refresh(subject: process.Subject(ServerMessage)) { +fn spawn_refresh(subject: process.Subject(ServerMessage), url: String) { let _ = process.spawn_unlinked(fn() { let fetched_at = monotonic_time_ms() - let svg = fetch_badge_svg() + let svg = fetch_badge_svg(url) process.send(subject, RefreshDone(fetched_at, svg)) }) @@ -185,8 +189,8 @@ pub fn product_hunt(cache: Cache) -> Response { } } -fn fetch_badge_svg() -> Option(String) { - let assert Ok(req0) = request.to(product_hunt_url) +fn fetch_badge_svg(url: String) -> Option(String) { + let assert Ok(req0) = request.to(url) let req = req0 |> request.prepend_header("accept", "image/svg+xml") diff --git a/fluxer_marketing/src/fluxer_marketing/components/hero.gleam b/fluxer_marketing/src/fluxer_marketing/components/hero.gleam index 2f3a42f2..38ba8da9 100644 --- a/fluxer_marketing/src/fluxer_marketing/components/hero.gleam +++ b/fluxer_marketing/src/fluxer_marketing/components/hero.gleam @@ -84,27 +84,59 @@ pub fn render(ctx: Context) -> Element(a) { ], ), hackernews_banner.render(ctx), - html.div([attribute.class("mt-6 flex justify-center")], [ - html.a( - [ - attribute.href( - "https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-fluxer", - ), - attribute.target("_blank"), - attribute.attribute("rel", "noopener noreferrer"), - ], - [ - html.img([ - attribute.alt( - "Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt", + html.div( + [ + attribute.class( + "mt-6 flex flex-wrap items-center justify-center gap-4", + ), + ], + [ + html.a( + [ + attribute.href( + "https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-fluxer", ), - attribute.attribute("width", "250"), - attribute.attribute("height", "54"), - attribute.src(prepend_base_path(ctx, "/api/badges/product-hunt")), - ]), - ], - ), - ]), + attribute.target("_blank"), + attribute.attribute("rel", "noopener noreferrer"), + ], + [ + html.img([ + attribute.alt( + "Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt", + ), + attribute.attribute("width", "250"), + attribute.attribute("height", "54"), + attribute.src(prepend_base_path( + ctx, + "/api/badges/product-hunt", + )), + ]), + ], + ), + html.a( + [ + attribute.href( + "https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-top-post-badge&utm_medium=badge&utm_campaign=badge-fluxer", + ), + attribute.target("_blank"), + attribute.attribute("rel", "noopener noreferrer"), + ], + [ + html.img([ + attribute.alt( + "Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt Top Post", + ), + attribute.attribute("width", "250"), + attribute.attribute("height", "54"), + attribute.src(prepend_base_path( + ctx, + "/api/badges/product-hunt-top-post", + )), + ]), + ], + ), + ], + ), ]), html.div( [ diff --git a/fluxer_marketing/src/fluxer_marketing/router.gleam b/fluxer_marketing/src/fluxer_marketing/router.gleam index c45bf050..0ad6577e 100644 --- a/fluxer_marketing/src/fluxer_marketing/router.gleam +++ b/fluxer_marketing/src/fluxer_marketing/router.gleam @@ -51,7 +51,10 @@ pub fn handle_request(req: Request, ctx: Context) -> Response { ["_debug", "geoip"] -> handle_geoip_debug(req, ctx) ["api", "badges", "product-hunt"] -> - badge_proxy.product_hunt(ctx.badge_cache) + badge_proxy.product_hunt(ctx.badge_featured_cache) + + ["api", "badges", "product-hunt-top-post"] -> + badge_proxy.product_hunt(ctx.badge_top_post_cache) ["robots.txt"] -> handle_robots_txt() ["sitemap.xml"] -> handle_sitemap(ctx) diff --git a/fluxer_marketing/src/fluxer_marketing/visionary_slots.gleam b/fluxer_marketing/src/fluxer_marketing/visionary_slots.gleam index cb724d2b..e73a5d03 100644 --- a/fluxer_marketing/src/fluxer_marketing/visionary_slots.gleam +++ b/fluxer_marketing/src/fluxer_marketing/visionary_slots.gleam @@ -22,6 +22,7 @@ import gleam/http/request import gleam/httpc import gleam/int import gleam/json +import gleam/option import gleam/result import gleam/string import wisp @@ -40,9 +41,10 @@ pub opaque type Cache { type ServerMessage { Get(process.Subject(VisionarySlots)) + RefreshDone(fetched_at: Int, slots: option.Option(VisionarySlots)) } -const refresh_interval_ms = 300_000 +const stale_after_ms = 300_000 const receive_timeout_ms = 200 @@ -60,30 +62,124 @@ pub fn start(settings: Settings) -> Cache { Cache(name: name) } +type CacheEntry { + CacheEntry(slots: VisionarySlots, fetched_at: Int) +} + +type State { + State(cache: option.Option(CacheEntry), is_refreshing: Bool) +} + fn run(name: process.Name(ServerMessage), settings: Settings) { let _ = process.register(process.self(), name) let subject = process.named_subject(name) - let initial = fetch_slots(settings) |> result.unwrap(default_slots()) - loop(subject, settings, initial) + let initial_slots = fetch_slots(settings) |> result.unwrap(default_slots()) + let now = monotonic_time_ms() + let initial_state = + State( + cache: option.Some(CacheEntry(slots: initial_slots, fetched_at: now)), + is_refreshing: False, + ) + + loop(subject, settings, initial_state) } fn loop( subject: process.Subject(ServerMessage), settings: Settings, - state: VisionarySlots, + state: State, ) { - case process.receive(subject, within: refresh_interval_ms) { - Ok(Get(reply_to)) -> { - process.send(reply_to, state) - loop(subject, settings, state) + let new_state = case process.receive(subject, within: stale_after_ms) { + Ok(Get(reply_to)) -> handle_get(subject, reply_to, settings, state) + + Ok(RefreshDone(fetched_at, slots)) -> + handle_refresh_done(fetched_at, slots, state) + + Error(_) -> maybe_refresh_in_background(subject, settings, state) + } + + loop(subject, settings, new_state) +} + +fn handle_get( + subject: process.Subject(ServerMessage), + reply_to: process.Subject(VisionarySlots), + settings: Settings, + state: State, +) -> State { + let now = monotonic_time_ms() + + case state.cache { + option.None -> { + let slots = fetch_slots(settings) |> result.unwrap(default_slots()) + process.send(reply_to, slots) + + let entry = CacheEntry(slots: slots, fetched_at: now) + + State(cache: option.Some(entry), is_refreshing: False) } - Error(_) -> { - let updated = fetch_slots(settings) |> result.unwrap(state) - loop(subject, settings, updated) + + option.Some(entry) -> { + let is_stale = now - entry.fetched_at > stale_after_ms + process.send(reply_to, entry.slots) + + case is_stale && !state.is_refreshing { + True -> { + spawn_refresh(subject, settings) + State(..state, is_refreshing: True) + } + False -> state + } } } } +fn handle_refresh_done( + fetched_at: Int, + slots: option.Option(VisionarySlots), + state: State, +) -> State { + let new_cache = case slots { + option.Some(data) -> + option.Some(CacheEntry(slots: data, fetched_at: fetched_at)) + option.None -> state.cache + } + + State(cache: new_cache, is_refreshing: False) +} + +fn maybe_refresh_in_background( + subject: process.Subject(ServerMessage), + settings: Settings, + state: State, +) -> State { + let now = monotonic_time_ms() + + case state.cache, state.is_refreshing { + option.Some(entry), False if now - entry.fetched_at > stale_after_ms -> { + spawn_refresh(subject, settings) + State(..state, is_refreshing: True) + } + + _, _ -> state + } +} + +fn spawn_refresh(subject: process.Subject(ServerMessage), settings: Settings) { + let _ = + process.spawn_unlinked(fn() { + let fetched_at = monotonic_time_ms() + let result = fetch_slots(settings) + let payload = case result { + Ok(slots) -> option.Some(slots) + Error(_) -> option.None + } + process.send(subject, RefreshDone(fetched_at, payload)) + }) + + Nil +} + pub fn current(cache: Cache) -> VisionarySlots { let subject = process.named_subject(cache.name) let reply_to = process.new_subject() @@ -368,3 +464,14 @@ fn rpc_url(api_host: String) -> String { fn default_slots() -> VisionarySlots { VisionarySlots(total: 0, bought: 0, remaining: 0) } + +type TimeUnit { + Millisecond +} + +@external(erlang, "erlang", "monotonic_time") +fn erlang_monotonic_time(unit: TimeUnit) -> Int + +fn monotonic_time_ms() -> Int { + erlang_monotonic_time(Millisecond) +} diff --git a/fluxer_marketing/src/fluxer_marketing/web.gleam b/fluxer_marketing/src/fluxer_marketing/web.gleam index 1114313f..a46ad9d6 100644 --- a/fluxer_marketing/src/fluxer_marketing/web.gleam +++ b/fluxer_marketing/src/fluxer_marketing/web.gleam @@ -56,7 +56,8 @@ pub type Context { release_channel: String, visionary_slots: VisionarySlots, metrics_endpoint: Option(String), - badge_cache: badge_proxy.Cache, + badge_featured_cache: badge_proxy.Cache, + badge_top_post_cache: badge_proxy.Cache, ) }