@four-leaf-studios/rl-overlay 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs.js CHANGED
@@ -22,62 +22,160 @@ function _interopNamespaceDefault(e) {
22
22
 
23
23
  var React__namespace = /*#__PURE__*/_interopNamespaceDefault(React);
24
24
 
25
- const RLContext = React.createContext(null);
26
- const RLProvider = ({ children, url = "ws://localhost:49122", }) => {
27
- // Strongly-typed storage of known events
28
- const dataRef = React.useRef({});
29
- const subscribersRef = React.useRef({});
25
+ // useRocketLeagueSocket.ts
26
+ const reducer = (state, action) => ({
27
+ ...state,
28
+ [action.type]: action.payload,
29
+ });
30
+ const normalizeEvent = (raw) => {
31
+ if (typeof raw === "object" && raw !== null) {
32
+ // { event: "name", data: {...} }
33
+ if (typeof raw.event === "string" && "data" in raw) {
34
+ return [[raw.event, raw.data]];
35
+ }
36
+ // { "evt1": {...}, "evt2": {...} }
37
+ return Object.entries(raw);
38
+ }
39
+ return [];
40
+ };
41
+ function useRocketLeagueSocket(url = "ws://localhost:49122", options) {
42
+ const { maxRetries = 5, heartbeatIntervalMs = 30_000 } = options || {};
43
+ // Reducer state holds the map of eventName → payload
44
+ const [events, dispatch] = React.useReducer(reducer, {});
45
+ const [readyState, setReadyState] = React.useState(WebSocket.CLOSED);
46
+ const [error, setError] = React.useState(null);
47
+ // Refs for the socket, retry count, and heartbeat timer
48
+ const socketRef = React.useRef(null);
49
+ const retryRef = React.useRef(0);
50
+ const heartbeatRef = React.useRef(undefined);
51
+ // Expose a send() helper
52
+ const send = React.useCallback((event, data) => {
53
+ const sock = socketRef.current;
54
+ if (sock?.readyState === WebSocket.OPEN) {
55
+ sock.send(JSON.stringify({ event, data }));
56
+ }
57
+ else {
58
+ console.warn("Cannot send, socket not open:", event);
59
+ }
60
+ }, []);
30
61
  React.useEffect(() => {
31
- const socket = new WebSocket(url);
32
- socket.onmessage = (e) => {
33
- try {
34
- const raw = JSON.parse(e.data);
35
- const entries = Array.isArray(raw)
36
- ? raw
37
- : raw.event && raw.data
38
- ? [[raw.event, raw.data]]
39
- : Object.entries(raw);
40
- for (const [eventName, payload] of entries) {
41
- // store payload
42
- dataRef.current[eventName] = payload;
43
- // notify subscribers
44
- (subscribersRef.current[eventName] ?? []).forEach((cb) => cb());
62
+ let isMounted = true;
63
+ const connect = () => {
64
+ const ws = new WebSocket(url);
65
+ socketRef.current = ws;
66
+ setReadyState(ws.readyState);
67
+ ws.onopen = () => {
68
+ if (!isMounted)
69
+ return;
70
+ retryRef.current = 0;
71
+ setReadyState(ws.readyState);
72
+ // Start heartbeat to keep alive
73
+ heartbeatRef.current = window.setInterval(() => {
74
+ ws.send(JSON.stringify({ event: "ping" }));
75
+ }, heartbeatIntervalMs);
76
+ };
77
+ ws.onmessage = (e) => {
78
+ if (!isMounted)
79
+ return;
80
+ let raw;
81
+ try {
82
+ raw = JSON.parse(e.data);
45
83
  }
46
- }
47
- catch {
48
- console.error("Invalid WS data");
49
- }
84
+ catch {
85
+ console.error("Invalid JSON from server:", e.data);
86
+ return;
87
+ }
88
+ // ignore pong replies
89
+ if (raw.event === "pong")
90
+ return;
91
+ // dispatch each normalized event
92
+ for (const [evt, payload] of normalizeEvent(raw)) {
93
+ dispatch({ type: evt, payload });
94
+ }
95
+ };
96
+ ws.onerror = (ev) => {
97
+ if (!isMounted)
98
+ return;
99
+ console.error("WebSocket error", ev);
100
+ setError(ev);
101
+ };
102
+ ws.onclose = () => {
103
+ if (!isMounted)
104
+ return;
105
+ setReadyState(WebSocket.CLOSED);
106
+ if (heartbeatRef.current !== undefined) {
107
+ clearInterval(heartbeatRef.current);
108
+ }
109
+ // attempt reconnect
110
+ if (retryRef.current < maxRetries) {
111
+ const backoff = 2 ** retryRef.current * 1000;
112
+ retryRef.current += 1;
113
+ setTimeout(connect, backoff);
114
+ }
115
+ else {
116
+ console.warn("Reached max WebSocket retries");
117
+ }
118
+ };
50
119
  };
120
+ connect();
51
121
  return () => {
52
- socket.close();
122
+ isMounted = false;
123
+ if (heartbeatRef.current !== undefined) {
124
+ clearInterval(heartbeatRef.current);
125
+ }
126
+ socketRef.current?.close();
53
127
  };
54
- }, [url]);
55
- // Expose stable store API
128
+ }, [url, maxRetries, heartbeatIntervalMs]);
129
+ return { events, readyState, error, send };
130
+ }
131
+
132
+ const RLContext = React.createContext(null);
133
+ const RLProvider = ({ url = "ws://localhost:49122", children }) => {
134
+ // 1) pull in every event & helpers from the single socket hook
135
+ const { events: allEvents, send, readyState, error, } = useRocketLeagueSocket(url);
136
+ // 2) Use a loose-typed ref to avoid union-vs-intersection complaints
137
+ const dataRef = React.useRef({});
138
+ const subsRef = React.useRef({});
139
+ // 3) Sync incoming allEvents into dataRef and notify subscribers
140
+ React.useEffect(() => {
141
+ // Force evt to be keyof PayloadStorage for type-narrowing
142
+ const keys = Object.keys(allEvents);
143
+ keys.forEach((evt) => {
144
+ const payload = allEvents[evt];
145
+ // write into our record (string index)
146
+ dataRef.current[evt] = payload;
147
+ // notify anyone subscribed to this key
148
+ (subsRef.current[evt] || []).forEach((cb) => cb());
149
+ });
150
+ }, [allEvents]);
151
+ // 4) Build the same Store API, casting when reading back
56
152
  const store = React.useMemo(() => ({
57
- getSnapshot: (eventName) => dataRef.current[eventName],
153
+ getSnapshot: (eventName) =>
154
+ // cast from any back to the correct PayloadStorage type
155
+ dataRef.current[eventName],
58
156
  subscribe: (eventName, callback) => {
59
- const subs = (subscribersRef.current[eventName] ??= []);
60
- subs.push(callback);
157
+ const arr = (subsRef.current[eventName] ??= []);
158
+ arr.push(callback);
61
159
  return () => {
62
- subscribersRef.current[eventName] = subs.filter((c) => c !== callback);
160
+ subsRef.current[eventName] = arr.filter((c) => c !== callback);
63
161
  };
64
162
  },
65
- }), []);
163
+ send,
164
+ readyState,
165
+ error,
166
+ }), [send, readyState, error]);
66
167
  return jsxRuntime.jsx(RLContext.Provider, { value: store, children: children });
67
168
  };
68
169
  /**
69
- * Subscribe to the full event payload. Returns `PayloadStorage[E] | undefined`.
170
+ * Subscribe to the full payload of an event.
70
171
  */
71
172
  function useEvent(eventName) {
72
173
  const store = React.useContext(RLContext);
73
174
  if (!store)
74
175
  throw new Error("useEvent must be inside <RLProvider>");
75
- // 2) (optional) explicitly tell TS what snapshot type is
76
176
  return React.useSyncExternalStore((cb) => store.subscribe(eventName, cb), () => store.getSnapshot(eventName));
77
177
  }
78
- /**
79
- * Tiny deep-equal for JavaScript values
80
- */
178
+ /** Tiny deep-equal for selector comparisons */
81
179
  function deepEqual(a, b) {
82
180
  if (Object.is(a, b))
83
181
  return true;
@@ -87,34 +185,21 @@ function deepEqual(a, b) {
87
185
  b === null) {
88
186
  return false;
89
187
  }
90
- const aKeys = Object.keys(a);
91
- const bKeys = Object.keys(b);
188
+ const aKeys = Object.keys(a), bKeys = Object.keys(b);
92
189
  if (aKeys.length !== bKeys.length)
93
190
  return false;
94
- for (const key of aKeys) {
95
- if (!deepEqual(a[key], b[key]))
96
- return false;
97
- }
98
- return true;
191
+ return aKeys.every((key) => deepEqual(a[key], b[key]));
99
192
  }
100
193
  /**
101
- * Subscribe to a selected slice of the event payload. Only re-renders when that slice changes.
102
- * Uses a built-in deep-equality check by default (no lodash).
194
+ * Subscribe to a selected slice of the event payload. Re-renders only on actual changes.
103
195
  */
104
- function useEventSelector(
105
- /** Event key to subscribe to */
106
- eventName,
107
- /** Selector that picks a slice of the payload */
108
- selector,
109
- /** Equality function to compare selector results; defaults to deepEqual */
110
- isEqual = deepEqual) {
196
+ function useEventSelector(eventName, selector, isEqual = deepEqual) {
111
197
  const store = React.useContext(RLContext);
112
198
  const lastRef = React.useRef({});
113
199
  const subscribe = React.useCallback((cb) => store.subscribe(eventName, cb), [eventName, store]);
114
200
  const getSnapshot = React.useCallback(() => {
115
201
  const raw = store.getSnapshot(eventName);
116
202
  const next = selector(raw);
117
- // deep-equal by default
118
203
  if (lastRef.current.val !== undefined &&
119
204
  isEqual(next, lastRef.current.val)) {
120
205
  return lastRef.current.val;
@@ -125,51 +210,16 @@ isEqual = deepEqual) {
125
210
  return React.useSyncExternalStore(subscribe, getSnapshot);
126
211
  }
127
212
 
128
- const reducer = (state, action) => ({
129
- ...state,
130
- [action.type]: action.payload,
131
- });
132
- const normalizeEvent = (raw) => {
133
- if (typeof raw === "object") {
134
- // Format 1: { event: "game:update_state", data: { ... } }
135
- if (typeof raw.event === "string" && "data" in raw) {
136
- return [[raw.event, raw.data]];
137
- }
138
- // Format 2: { "game:update_state": { ... }, "game:goal_scored": { ... } }
139
- return Object.entries(raw);
140
- }
141
- return [];
142
- };
143
- const useRocketLeagueSocket = (url = "ws://localhost:49122") => {
144
- const [state, dispatch] = React.useReducer(reducer, {});
145
- React.useEffect(() => {
146
- const socket = new WebSocket(url);
147
- socket.onmessage = (event) => {
148
- try {
149
- const rawData = JSON.parse(event.data);
150
- const events = normalizeEvent(rawData);
151
- events.forEach(([eventName, payload]) => {
152
- dispatch({ type: eventName, payload });
153
- });
154
- }
155
- catch (err) {
156
- console.error("WebSocket error: Invalid JSON", err);
157
- }
158
- };
159
- return () => socket.close();
160
- }, [url]);
161
- return state;
162
- };
163
-
213
+ const STATE_LABELS = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"];
164
214
  const WebsocketData = () => {
165
- const data = useRocketLeagueSocket();
166
- return (jsxRuntime.jsx(jsxRuntime.Fragment, { children: jsxRuntime.jsxs("div", { style: { fontFamily: "monospace", padding: "1rem" }, children: [jsxRuntime.jsx("h1", { children: "Rocket League Live Events" }), Object.entries(data).map(([event, payload]) => (jsxRuntime.jsxs("div", { style: {
167
- border: "1px solid #ccc",
168
- marginBottom: "1rem",
169
- padding: "0.5rem",
170
- borderRadius: "4px",
171
- backgroundColor: "#f9f9f9",
172
- }, children: [jsxRuntime.jsx("h2", { children: event }), jsxRuntime.jsx("pre", { style: { whiteSpace: "pre-wrap", wordBreak: "break-word" }, children: JSON.stringify(payload, null, 2) })] }, event)))] }) }));
215
+ const { events, readyState, error } = useRocketLeagueSocket();
216
+ return (jsxRuntime.jsxs("div", { style: { fontFamily: "monospace", padding: "1rem" }, children: [jsxRuntime.jsx("h1", { children: "Rocket League Live Events" }), jsxRuntime.jsxs("p", { children: [jsxRuntime.jsx("strong", { children: "Connection:" }), " ", STATE_LABELS[readyState] ?? readyState] }), error && (jsxRuntime.jsxs("p", { style: { color: "red" }, children: [jsxRuntime.jsx("strong", { children: "Error:" }), " ", String(error)] })), Object.entries(events).map(([event, payload]) => (jsxRuntime.jsxs("div", { style: {
217
+ border: "1px solid #ccc",
218
+ marginBottom: "1rem",
219
+ padding: "0.5rem",
220
+ borderRadius: "4px",
221
+ backgroundColor: "#f9f9f9",
222
+ }, children: [jsxRuntime.jsx("h2", { children: event }), jsxRuntime.jsx("pre", { style: { whiteSpace: "pre-wrap", wordBreak: "break-word" }, children: JSON.stringify(payload, null, 2) })] }, event))), Object.keys(events).length === 0 && jsxRuntime.jsx("p", { children: "No events received yet." })] }));
173
223
  };
174
224
 
175
225
  var BroadcastContext = React.createContext(undefined);
@@ -10321,9 +10371,9 @@ var ScoreboardSeriesBoxComponent = function (_a) {
10321
10371
  var teamSeriesScore = Number(team.series_score) || 0;
10322
10372
  var pointsCount = Math.ceil(bestOf / 2);
10323
10373
  var points = Array.from({ length: pointsCount }, function (_, index) { return index; });
10324
- var isLefTeam = team.id === 0;
10325
- var modifier = isLefTeam ? "left" : "right";
10326
- var teamColor = ((_b = team.color) === null || _b === void 0 ? void 0 : _b.primary_color) || (isLefTeam ? "#0052cc" : "#ff6600");
10374
+ var isLeftTeam = team.id === "0";
10375
+ var modifier = isLeftTeam ? "left" : "right";
10376
+ var teamColor = ((_b = team.color) === null || _b === void 0 ? void 0 : _b.primary_color) || (isLeftTeam ? "#0052cc" : "#ff6600");
10327
10377
  return (React.createElement("div", { className: "series_box ".concat(modifier, "_series_box"), style: { "--team-color": teamColor } }, points.map(function (index) {
10328
10378
  var pointClass = index < teamSeriesScore
10329
10379
  ? "series_score_box_point ".concat(modifier, "_series_score_box_point")