diff --git a/.gitignore b/.gitignore index 4461e47..2481ecb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ erl_crash.dump #Added automatically by Lustre Dev Tools /.lustre /dist + +# Generated from SHEETS_DATA_BASE_URL and SHEETS_SOURCE_URL +/assets/runtime-config.js diff --git a/README.md b/README.md index c0cb0c2..db88734 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,17 @@ Further documentation can be found at . ## Development +Set the runtime URLs in your environment, generate the ignored browser +configuration asset, and start Lustre: + ```sh -gleam run # Run the project -gleam test # Run the tests +export SHEETS_DATA_BASE_URL="" +export SHEETS_SOURCE_URL="" +bun dev/configure.mjs +gleam run -m lustre/dev start ``` + +`SHEETS_DATA_BASE_URL` must point at a directory containing `races.json` and +`backgrounds.json`. The generator normalizes its trailing slash. Run it before +both development and production builds; no source URL is committed to the +application bundle. diff --git a/assets/iroh/sheets_peer_bg.wasm b/assets/iroh/sheets_peer_bg.wasm index 200bde4d7fc4389da70a4fb8a4c94c03ddbbbcbe..c2bdb4f27ff1b8427f456715427a4d309884e349 100644 GIT binary patch delta 234 zcmdmX_Any|H?%OeFtsqZu(YtYu(hzaaI|o?aJ6u^@U-x@@U`%_2($>c2(<{eh_r~d zh_#5fNVG_{NVQ0}$h63|$hF9~D6}ZHD77fJsI;iIsI{nX(Kx+>(QEse9U6<-7~8iO z?ANeobTndMV9;P-V6fUPz`(`8z@Wy!z>tWk9_FWAb! xz`%$kEZJ^wP@~=8pk}+lL9KR!gWBx|2X)#F4(hfW9Mo$!IHc2(<{eh_r~d zh_#5fNVG_{NVQ0}$h63|$hF9~D6}ZHD77fJsI;iIsI{nX(Kx+>(Rurs9U6<-80)qd z?ANeobkt*DV9;P-V6fUPz`(`8z@W&$z>tWk9_FIdCC xz`%$kEY@ytP@~=8pk}+lL9KR!gWBx|2X)#F4(hfW9Mo$!IH Result { - let node = chat_shared::ChatNode::spawn(None).await.map_err(to_js_error)?; + let node = chat_shared::ChatNode::spawn(None) + .await + .map_err(to_js_error)?; Ok(Self(node)) } @@ -35,7 +40,6 @@ impl SheetNode { self.join_inner(ticket, nickname).await } - pub async fn shutdown(&self) { self.0.shutdown().await; } @@ -51,7 +55,10 @@ impl SheetNode { if let Ok(event) = &event { match event { chat_shared::Event::Joined { neighbors } => { - tracked_neighbors.lock().unwrap().extend(neighbors.iter().copied()); + tracked_neighbors + .lock() + .unwrap() + .extend(neighbors.iter().copied()); } chat_shared::Event::NeighborUp { endpoint_id } => { tracked_neighbors.lock().unwrap().insert(*endpoint_id); @@ -107,7 +114,9 @@ impl SheetChannel { pub fn ticket(&self) -> String { let mut ticket = ChatTicket::new(self.topic_id); ticket.bootstrap.extend(self.bootstrap.iter().copied()); - ticket.bootstrap.extend(self.neighbors.lock().unwrap().iter().copied()); + ticket + .bootstrap + .extend(self.neighbors.lock().unwrap().iter().copied()); ticket.bootstrap.insert(self.endpoint_id); ticket.serialize() } diff --git a/src/sheets.gleam b/src/sheets.gleam index 0d7bdda..d20d668 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -83,7 +83,9 @@ pub type Message { UserClickedLeave PeerSessionReady(String) PeerSessionFailed(String) - PeerEventReceived(String, String) + PeerSheetReceived(String) + PeerPresenceReceived + PeerCountChanged(String) } type ClassChoice { @@ -166,31 +168,34 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { } DataReturnedSpecies(result) -> case result { - Ok(species) -> - #(Model(..model, species:, species_loaded: True), effect.none()) - Error(_) -> - #( - Model(..model, species_loaded: True, catalog_error: True), - effect.none(), - ) + Ok(species) -> #( + Model(..model, species:, species_loaded: True), + effect.none(), + ) + Error(_) -> #( + Model(..model, species_loaded: True, catalog_error: True), + effect.none(), + ) } DataReturnedBackgrounds(result) -> case result { - Ok(backgrounds) -> - #( - Model(..model, backgrounds:, backgrounds_loaded: True), - effect.none(), - ) - Error(_) -> - #( - Model(..model, backgrounds_loaded: True, catalog_error: True), - effect.none(), - ) + Ok(backgrounds) -> #( + Model(..model, backgrounds:, backgrounds_loaded: True), + effect.none(), + ) + Error(_) -> #( + Model(..model, backgrounds_loaded: True, catalog_error: True), + effect.none(), + ) } - UserChangedPeerName(name) -> - #(Model(..model, peer_name: name), effect.none()) - UserChangedJoinCode(code) -> - #(Model(..model, join_code: code), effect.none()) + UserChangedPeerName(name) -> #( + Model(..model, peer_name: name), + effect.none(), + ) + UserChangedJoinCode(code) -> #( + Model(..model, join_code: code), + effect.none(), + ) UserClickedHost -> { let nickname = peer_display_name(model, "Game Master") #( @@ -202,7 +207,9 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { peer.host( nickname, PeerSessionReady, - PeerEventReceived, + PeerSheetReceived, + fn() { PeerPresenceReceived }, + PeerCountChanged, PeerSessionFailed, ), ) @@ -228,22 +235,18 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ticket, nickname, PeerSessionReady, - PeerEventReceived, + PeerSheetReceived, + fn() { PeerPresenceReceived }, + PeerCountChanged, PeerSessionFailed, ), ) } } - UserClickedLeave -> - #( - Model( - ..model, - connection: Disconnected, - join_code: "", - remote_sheets: [], - ), - peer.leave(), - ) + UserClickedLeave -> #( + Model(..model, connection: Disconnected, join_code: "", remote_sheets: []), + peer.leave(), + ) PeerSessionReady(ticket) -> { let role = case model.connection { Connecting(role:) -> role @@ -251,45 +254,38 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Disconnected -> Player ConnectionFailed(reason: _) -> Player } - let connected = Model( - ..model, - connection: Connected(role:, ticket:, peer_count: 0), - join_code: ticket, - ) + let connected = + Model( + ..model, + connection: Connected(role:, ticket:, peer_count: 0), + join_code: ticket, + ) #(connected, peer.broadcast(to_snapshot(connected))) } - PeerSessionFailed(reason) -> - #( - Model(..model, connection: ConnectionFailed(reason:)), - effect.none(), - ) - PeerEventReceived(kind, payload) -> - case kind { - "sheet" -> - case peer.parse_snapshot(payload) { - Ok(snapshot) -> #( - Model( - ..model, - remote_sheets: upsert_snapshot(model.remote_sheets, snapshot), - ), - effect.none(), - ) - Error(_) -> #(model, effect.none()) - } - "peers" -> - case int.parse(payload), model.connection { - Ok(peer_count), Connected(role:, ticket:, ..) -> #( - Model( - ..model, - connection: Connected(role:, ticket:, peer_count:), - ), - effect.none(), - ) - _, _ -> #(model, effect.none()) - } - "presence" -> #(model, peer.broadcast(to_snapshot(model))) - _ -> #(model, effect.none()) + PeerSessionFailed(reason) -> #( + Model(..model, connection: ConnectionFailed(reason:)), + effect.none(), + ) + PeerSheetReceived(payload) -> + case peer.parse_snapshot(payload) { + Ok(snapshot) -> #( + Model( + ..model, + remote_sheets: upsert_snapshot(model.remote_sheets, snapshot), + ), + effect.none(), + ) + Error(_) -> #(model, effect.none()) + } + PeerCountChanged(payload) -> + case int.parse(payload), model.connection { + Ok(peer_count), Connected(role:, ticket:, ..) -> #( + Model(..model, connection: Connected(role:, ticket:, peer_count:)), + effect.none(), + ) + _, _ -> #(model, effect.none()) } + PeerPresenceReceived -> #(model, peer.broadcast(to_snapshot(model))) } } @@ -313,12 +309,13 @@ fn peer_display_name(model: Model, fallback: String) -> String { } } -fn upsert_snapshot(snapshots: List(Snapshot), incoming: Snapshot) -> List(Snapshot) { +fn upsert_snapshot( + snapshots: List(Snapshot), + incoming: Snapshot, +) -> List(Snapshot) { [ incoming, - ..list.filter(snapshots, fn(snapshot) { - snapshot.owner != incoming.owner - }) + ..list.filter(snapshots, fn(snapshot) { snapshot.owner != incoming.owner }) ] } @@ -405,7 +402,8 @@ fn view_collaboration(model: Model) -> Element(Message) { ]), html.small([], [ html.text( - int.to_string(peer_count) <> case peer_count { + int.to_string(peer_count) + <> case peer_count { 1 -> " peer connected" _ -> " peers connected" }, @@ -492,53 +490,64 @@ fn view_connect_form(model: Model, error: String) -> Element(Message) { ]), ]), case error { - "" -> html.span([attribute.class("peer-note")], [ - html.text( - "Browser peers are relay-connected and remain end-to-end encrypted.", - ), - ]) - reason -> html.p([attribute.class("connection-error")], [html.text(reason)]) + "" -> + html.span([attribute.class("peer-note")], [ + html.text( + "Iroh browser peers use a relay while remaining end-to-end encrypted.", + ), + ]) + reason -> + html.p([attribute.class("connection-error")], [html.text(reason)]) }, ]) } fn view_remote_sheets(snapshots: List(Snapshot)) -> Element(Message) { case snapshots { - [] -> html.p([attribute.class("empty-roster")], [ - html.text("Connected character sheets will appear here."), - ]) - snapshots -> html.div([attribute.class("remote-roster")], [ - html.span([attribute.class("roster-label")], [ - html.text("TABLE CHARACTERS"), - ]), - html.div( - [attribute.class("remote-grid")], - list.map(snapshots, fn(snapshot) { - let name = case string.trim(snapshot.character_name) { - "" -> "Unnamed Adventurer" - name -> name - } - html.article([attribute.class("remote-card")], [ - html.div([attribute.class("remote-avatar")], [ - html.text(string.slice(name, at_index: 0, length: 1)), - ]), - html.div([], [ - html.strong([], [html.text(name)]), - html.span([], [ - html.text( - snapshot.owner <> " · L" <> int.to_string(snapshot.level) - <> " " <> snapshot.class_name, - ), + [] -> + html.p([attribute.class("empty-roster")], [ + html.text("Connected character sheets will appear here."), + ]) + snapshots -> + html.div([attribute.class("remote-roster")], [ + html.span([attribute.class("roster-label")], [ + html.text("TABLE CHARACTERS"), + ]), + html.div( + [attribute.class("remote-grid")], + list.map(snapshots, fn(snapshot) { + let name = case string.trim(snapshot.character_name) { + "" -> "Unnamed Adventurer" + name -> name + } + html.article([attribute.class("remote-card")], [ + html.div([attribute.class("remote-avatar")], [ + html.text(string.slice(name, at_index: 0, length: 1)), ]), - ]), - html.div([attribute.class("remote-vitals")], [ - html.span([], [html.text("AC " <> int.to_string(snapshot.armor_class))]), - html.span([], [html.text("HP " <> int.to_string(snapshot.hit_points))]), - ]), - ]) - }), - ), - ]) + html.div([], [ + html.strong([], [html.text(name)]), + html.span([], [ + html.text( + snapshot.owner + <> " · L" + <> int.to_string(snapshot.level) + <> " " + <> snapshot.class_name, + ), + ]), + ]), + html.div([attribute.class("remote-vitals")], [ + html.span([], [ + html.text("AC " <> int.to_string(snapshot.armor_class)), + ]), + html.span([], [ + html.text("HP " <> int.to_string(snapshot.hit_points)), + ]), + ]), + ]) + }), + ), + ]) } } @@ -655,7 +664,10 @@ fn view_skills(model: Model) -> Element(Message) { let selected = list.contains(model.skills, skill.name) let total = modifier(ability_score(model.abilities, skill.ability)) - + case selected { True -> proficiency_bonus(model.level) False -> 0 } + + case selected { + True -> proficiency_bonus(model.level) + False -> 0 + } html.label( [attribute.classes([#("skill-row", True), #("selected", selected)])], [ @@ -683,7 +695,8 @@ fn view_sheet(model: Model) -> Element(Message) { let armor_class = character_armor_class(model) let initiative = modifier(model.abilities.dexterity) let passive_perception = - 10 + modifier(model.abilities.wisdom) + 10 + + modifier(model.abilities.wisdom) + case list.contains(model.skills, "Perception") { True -> proficiency_bonus(model.level) False -> 0 @@ -696,7 +709,9 @@ fn view_sheet(model: Model) -> Element(Message) { html.aside([attribute.class("sheet")], [ html.div([attribute.class("sheet-accent")], []), html.div([attribute.class("portrait")], [ - html.span([], [html.text(string.slice(display_name, at_index: 0, length: 1))]), + html.span([], [ + html.text(string.slice(display_name, at_index: 0, length: 1)), + ]), html.div([attribute.class("level-badge")], [ html.small([], [html.text("LVL")]), html.strong([], [html.text(int.to_string(model.level))]), @@ -707,7 +722,10 @@ fn view_sheet(model: Model) -> Element(Message) { html.h2([], [html.text(display_name)]), html.p([], [ html.text( - model.species_name <> " · " <> model.background_name <> " " + model.species_name + <> " · " + <> model.background_name + <> " " <> model.class_name, ), ]), @@ -731,13 +749,17 @@ fn view_sheet(model: Model) -> Element(Message) { [attribute.class("saving-throws")], list.map(ability_list(), fn(ability) { let proficient = class_save_proficient(model.class_name, ability) - let value = modifier(ability_score(model.abilities, ability)) + let value = + modifier(ability_score(model.abilities, ability)) + case proficient { True -> proficiency_bonus(model.level) False -> 0 } html.div([], [ - html.span([attribute.classes([#("proficiency", True), #("on", proficient)])], []), + html.span( + [attribute.classes([#("proficiency", True), #("on", proficient)])], + [], + ), html.span([], [html.text(ability_name(ability))]), html.strong([], [html.text(signed(value))]), ]) @@ -766,7 +788,7 @@ fn view_sheet(model: Model) -> Element(Message) { ]), html.a( [ - attribute.href(data.source_url), + attribute.href(data.source_url()), attribute.target("_blank"), attribute.rel("noreferrer"), attribute.aria_label("Open 5e.tools data source"), @@ -784,10 +806,17 @@ fn view_footer() -> Element(Message) { ]) } -fn section_heading(number: String, title: String, description: String) -> Element(Message) { +fn section_heading( + number: String, + title: String, + description: String, +) -> Element(Message) { html.div([attribute.class("section-heading")], [ html.span([attribute.class("section-number")], [html.text(number)]), - html.div([], [html.h2([], [html.text(title)]), html.p([], [html.text(description)])]), + html.div([], [ + html.h2([], [html.text(title)]), + html.p([], [html.text(description)]), + ]), ]) } @@ -840,8 +869,10 @@ fn character_armor_class(model: Model) -> Int { fn character_hit_points(model: Model) -> Int { let hit_die = class_hit_die(model.class_name) let constitution = modifier(model.abilities.constitution) - hit_die + constitution - + { model.level - 1 } * { hit_die_average(hit_die) + constitution } + hit_die + + constitution + + { model.level - 1 } + * { hit_die_average(hit_die) + constitution } } fn stat_block(label: String, value: String, note: String) -> Element(Message) { @@ -877,7 +908,12 @@ pub fn proficiency_bonus(level: Int) -> Int { } } -fn parse_bounded(value: String, fallback: Int, minimum: Int, maximum: Int) -> Int { +fn parse_bounded( + value: String, + fallback: Int, + minimum: Int, + maximum: Int, +) -> Int { case int.parse(value) { Ok(number) if number < minimum -> minimum Ok(number) if number > maximum -> maximum @@ -886,7 +922,11 @@ fn parse_bounded(value: String, fallback: Int, minimum: Int, maximum: Int) -> In } } -fn change_ability(abilities: Abilities, ability: Ability, change: Int) -> Abilities { +fn change_ability( + abilities: Abilities, + ability: Ability, + change: Int, +) -> Abilities { let score = ability_score(abilities, ability) let next = parse_bounded(int.to_string(score + change), score, 3, 20) case ability { @@ -937,10 +977,7 @@ fn ability_list() -> List(Ability) { } fn levels() -> List(Int) { - [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, - 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, - ] + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] } fn signed(number: Int) -> String { diff --git a/src/sheets/config.gleam b/src/sheets/config.gleam new file mode 100644 index 0000000..162fced --- /dev/null +++ b/src/sheets/config.gleam @@ -0,0 +1,13 @@ +pub fn data_base_url() -> String { + runtime_data_base_url() +} + +pub fn source_url() -> String { + runtime_source_url() +} + +@external(javascript, "./runtime_config.mjs", "dataBaseUrl") +fn runtime_data_base_url() -> String + +@external(javascript, "./runtime_config.mjs", "sourceUrl") +fn runtime_source_url() -> String diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index dc1facb..e2bc7c0 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -2,29 +2,40 @@ import gleam/dynamic/decode import gleam/list import lustre/effect.{type Effect} import rsvp +import sheets/config -pub const source_url = - "https://github.com/5etools-mirror-3/5etools-src" - -const data_base_url = - "https://cdn.jsdelivr.net/gh/5etools-mirror-3/5etools-src@master/data/" +pub fn source_url() -> String { + config.source_url() +} pub type Choice { Choice(name: String, source: String) } -pub fn species(handler: fn(Result(List(Choice), rsvp.Error(String))) -> message) -> Effect(message) { - rsvp.get( - data_base_url <> "races.json", - rsvp.expect_json(catalog_decoder("race"), handler), - ) +pub fn species( + handler: fn(Result(List(Choice), rsvp.Error(String))) -> message, +) -> Effect(message) { + case config.data_base_url() { + "" -> effect.none() + base_url -> + rsvp.get( + base_url <> "races.json", + rsvp.expect_json(catalog_decoder("race"), handler), + ) + } } -pub fn backgrounds(handler: fn(Result(List(Choice), rsvp.Error(String))) -> message) -> Effect(message) { - rsvp.get( - data_base_url <> "backgrounds.json", - rsvp.expect_json(catalog_decoder("background"), handler), - ) +pub fn backgrounds( + handler: fn(Result(List(Choice), rsvp.Error(String))) -> message, +) -> Effect(message) { + case config.data_base_url() { + "" -> effect.none() + base_url -> + rsvp.get( + base_url <> "backgrounds.json", + rsvp.expect_json(catalog_decoder("background"), handler), + ) + } } pub fn fallback_species() -> List(Choice) { diff --git a/src/sheets/peer.gleam b/src/sheets/peer.gleam index 3a59bcf..3e0c5b3 100644 --- a/src/sheets/peer.gleam +++ b/src/sheets/peer.gleam @@ -18,14 +18,18 @@ pub type Snapshot { pub fn host( nickname: String, ready: fn(String) -> message, - received: fn(String, String) -> message, + sheet_received: fn(String) -> message, + presence_received: fn() -> message, + peers_changed: fn(String) -> message, failed: fn(String) -> message, ) -> Effect(message) { effect.from(fn(dispatch) { create_session( nickname, fn(ticket) { dispatch(ready(ticket)) }, - fn(kind, payload) { dispatch(received(kind, payload)) }, + fn(payload) { dispatch(sheet_received(payload)) }, + fn() { dispatch(presence_received()) }, + fn(count) { dispatch(peers_changed(count)) }, fn(reason) { dispatch(failed(reason)) }, ) }) @@ -35,7 +39,9 @@ pub fn join( ticket: String, nickname: String, ready: fn(String) -> message, - received: fn(String, String) -> message, + sheet_received: fn(String) -> message, + presence_received: fn() -> message, + peers_changed: fn(String) -> message, failed: fn(String) -> message, ) -> Effect(message) { effect.from(fn(dispatch) { @@ -43,7 +49,9 @@ pub fn join( ticket, nickname, fn(updated_ticket) { dispatch(ready(updated_ticket)) }, - fn(kind, payload) { dispatch(received(kind, payload)) }, + fn(payload) { dispatch(sheet_received(payload)) }, + fn() { dispatch(presence_received()) }, + fn(count) { dispatch(peers_changed(count)) }, fn(reason) { dispatch(failed(reason)) }, ) }) @@ -101,7 +109,9 @@ fn snapshot_decoder() -> decode.Decoder(Snapshot) { fn create_session( nickname: String, ready: fn(String) -> Nil, - received: fn(String, String) -> Nil, + sheet_received: fn(String) -> Nil, + presence_received: fn() -> Nil, + peers_changed: fn(String) -> Nil, failed: fn(String) -> Nil, ) -> Nil @@ -110,7 +120,9 @@ fn join_session( ticket: String, nickname: String, ready: fn(String) -> Nil, - received: fn(String, String) -> Nil, + sheet_received: fn(String) -> Nil, + presence_received: fn() -> Nil, + peers_changed: fn(String) -> Nil, failed: fn(String) -> Nil, ) -> Nil diff --git a/src/sheets/peer_ffi.mjs b/src/sheets/peer_ffi.mjs index a1ef308..6c14758 100644 --- a/src/sheets/peer_ffi.mjs +++ b/src/sheets/peer_ffi.mjs @@ -1,11 +1,13 @@ let node = null; let channel = null; +let sender = null; let reader = null; let loadPromise = null; -let eventHandler = null; +const pendingSends = new Set(); +let sheetHandler = null; +let presenceHandler = null; +let peersHandler = null; let errorHandler = null; -const trace = []; -globalThis.__sheetsPeerTrace = trace; async function loadIroh() { if (!loadPromise) { @@ -25,13 +27,12 @@ async function readEvents() { const { done, value } = await reader.read(); if (done) break; const type = value?.type ?? "unknown"; - trace.push({ direction: "received", type }); if (type === "messageReceived") { - eventHandler("sheet", value.text ?? ""); + sheetHandler(String(value.text ?? "")); } else if (type === "presence") { - eventHandler("presence", value.nickname ?? "Player"); + presenceHandler(); } else if (type === "neighborUp" || type === "neighborDown" || type === "joined") { - eventHandler("peers", String(channel.peer_count())); + peersHandler(String(channel.peer_count())); } } } catch (error) { @@ -39,36 +40,69 @@ async function readEvents() { } } -async function connect(mode, ticket, nickname, onReady, onEvent, onError) { +async function connect( + mode, + ticket, + nickname, + onReady, + onSheet, + onPresence, + onPeers, + onError, +) { try { await leaveSession(); const { SheetNode } = await loadIroh(); node = await SheetNode.spawn(); - eventHandler = onEvent; + sheetHandler = onSheet; + presenceHandler = onPresence; + peersHandler = onPeers; errorHandler = onError; channel = mode === "host" ? await node.create(nickname) : await node.join(ticket.trim(), nickname); - onReady(channel.ticket()); - trace.push({ direction: "connected", mode, ticket: channel.ticket() }); + sender = channel.sender; void readEvents(); + onReady(channel.ticket()); } catch (error) { onError(String(error)); } } -export function createSession(nickname, onReady, onEvent, onError) { - void connect("host", "", nickname, onReady, onEvent, onError); +export function createSession( + nickname, + onReady, + onSheet, + onPresence, + onPeers, + onError, +) { + void connect("host", "", nickname, onReady, onSheet, onPresence, onPeers, onError); } -export function joinSession(ticket, nickname, onReady, onEvent, onError) { - void connect("join", ticket, nickname, onReady, onEvent, onError); +export function joinSession( + ticket, + nickname, + onReady, + onSheet, + onPresence, + onPeers, + onError, +) { + void connect("join", ticket, nickname, onReady, onSheet, onPresence, onPeers, onError); } export function sendPayload(payload) { - if (channel) { - trace.push({ direction: "sent", payload }); - void channel.sender.broadcast(payload).catch((error) => errorHandler(String(error))); + const activeChannel = channel; + const activeSender = sender; + if (activeChannel && activeSender) { + const sending = activeSender.broadcast(payload); + pendingSends.add(sending); + void sending + .catch((error) => { + if (channel === activeChannel) errorHandler(String(error)); + }) + .finally(() => pendingSends.delete(sending)); } } @@ -76,6 +110,7 @@ export async function leaveSession() { const previousReader = reader; reader = null; channel = null; + sender = null; if (previousReader) { try { await previousReader.cancel(); } catch (_) { /* already closed */ } } diff --git a/src/sheets/runtime_config.mjs b/src/sheets/runtime_config.mjs new file mode 100644 index 0000000..d1b4a12 --- /dev/null +++ b/src/sheets/runtime_config.mjs @@ -0,0 +1,12 @@ +function config() { + return globalThis.SHEETS_CONFIG ?? {}; +} + +export function dataBaseUrl() { + const base = config().dataBaseUrl ?? ""; + return base === "" ? "" : `${base.replace(/\/+$/, "")}/`; +} + +export function sourceUrl() { + return config().sourceUrl ?? ""; +}