@couch-kit/client 0.3.0 → 0.4.1
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/CHANGELOG.md +76 -0
- package/README.md +2 -2
- package/dist/index.js +186 -43
- package/package.json +26 -2
- package/src/assets.ts +78 -21
- package/src/client.ts +116 -44
- package/src/time-sync.ts +14 -4
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
1
1
|
# @couch-kit/client
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e763b56: Performance and dependency/bundle improvements
|
|
8
|
+
|
|
9
|
+
**Dependency cleanup:**
|
|
10
|
+
- Remove unused root-level dependencies (expo-\*, @react-native/assets-registry, js-sha1)
|
|
11
|
+
- Move `buffer` from root to @couch-kit/host where it's actually used
|
|
12
|
+
- Move `react-native-nitro-modules` to peerDependencies in @couch-kit/host
|
|
13
|
+
- Remove unused `chalk` dependency from @couch-kit/cli
|
|
14
|
+
- Replace `fs-extra`, `ora`, and `ws` with built-in alternatives in @couch-kit/cli
|
|
15
|
+
|
|
16
|
+
**Bundle & tree-shaking:**
|
|
17
|
+
- Add `sideEffects: false` to all library packages for better tree-shaking
|
|
18
|
+
- Add modern `exports` field to all package.json files
|
|
19
|
+
- Fix @couch-kit/core build target from `node` to `browser` (it's environment-agnostic)
|
|
20
|
+
|
|
21
|
+
**Runtime performance:**
|
|
22
|
+
- Throttle state broadcasts to ~30fps to reduce serialization overhead for fast-updating games
|
|
23
|
+
- Replace per-event `Buffer.concat` with a growing buffer strategy in WebSocket server to reduce GC pressure
|
|
24
|
+
- Replace deprecated `Buffer.slice()` with `Buffer.subarray()`
|
|
25
|
+
|
|
26
|
+
**CLI improvements:**
|
|
27
|
+
- Lazy-load CLI commands via dynamic `import()` for faster startup
|
|
28
|
+
- Replace `ws` with Bun's native WebSocket
|
|
29
|
+
- Replace `fs-extra` with `node:fs` built-in APIs
|
|
30
|
+
- Replace `ora` with simple console output
|
|
31
|
+
|
|
32
|
+
- Updated dependencies [e763b56]
|
|
33
|
+
- @couch-kit/core@0.3.1
|
|
34
|
+
|
|
35
|
+
## 0.4.0
|
|
36
|
+
|
|
37
|
+
### Minor Changes
|
|
38
|
+
|
|
39
|
+
- e31e980: Comprehensive code audit: security, correctness, performance, and type safety improvements
|
|
40
|
+
|
|
41
|
+
**Core:**
|
|
42
|
+
- Add shared constants module (ports, timeouts, frame limits, reconnection defaults)
|
|
43
|
+
- Add `generateId()` utility using cryptographic randomness instead of `Math.random()`
|
|
44
|
+
- Add `toErrorMessage()` for safe error extraction from unknown caught values
|
|
45
|
+
- Add `InternalAction` type union and `InternalActionTypes` constants for `__HYDRATE__`, `__PLAYER_JOINED__`, `__PLAYER_LEFT__`
|
|
46
|
+
- Update `createGameReducer()` to handle all internal actions with proper types (no more double-casts)
|
|
47
|
+
- Expand reducer tests from 2 to 7 cases
|
|
48
|
+
|
|
49
|
+
**Host:**
|
|
50
|
+
- Rewrite `EventEmitter` as a generic class with full type safety on `on`/`off`/`once`/`emit`
|
|
51
|
+
- Add WebSocket `maxFrameSize` enforcement to prevent OOM attacks
|
|
52
|
+
- Add server-side keepalive pings to detect dead connections
|
|
53
|
+
- Use `ManagedSocket` interface instead of mutating raw socket objects
|
|
54
|
+
- Add graceful `stop()` with WebSocket close frames
|
|
55
|
+
- Safe `broadcast()` and `send()` with per-socket error handling
|
|
56
|
+
- Add client message validation and internal action injection prevention in provider
|
|
57
|
+
- Fix WELCOME race condition using `queueMicrotask()`
|
|
58
|
+
- Memoize context value to prevent unnecessary consumer re-renders
|
|
59
|
+
- Add `loading` state to server hook
|
|
60
|
+
|
|
61
|
+
**Client:**
|
|
62
|
+
- Add configurable reconnection with `maxRetries`, `baseDelay`, `maxDelay` props
|
|
63
|
+
- Fix stale closures via `configRef` pattern
|
|
64
|
+
- Add `disconnect()` and `reconnect()` methods
|
|
65
|
+
- Respect WebSocket close codes (1008, 1011 skip reconnection)
|
|
66
|
+
- Add ping map TTL cleanup to prevent unbounded growth in time sync
|
|
67
|
+
- Fix `usePreload` error swallowing — track and report `failedAssets`
|
|
68
|
+
- Replace `JSON.stringify(assets)` dependency with stable ref comparison
|
|
69
|
+
|
|
70
|
+
**CLI:**
|
|
71
|
+
- Replace unsafe `(e as Error).message` casts with `toErrorMessage()`
|
|
72
|
+
- Fix interval leak in simulate command — clear intervals on SIGINT
|
|
73
|
+
|
|
74
|
+
### Patch Changes
|
|
75
|
+
|
|
76
|
+
- Updated dependencies [e31e980]
|
|
77
|
+
- @couch-kit/core@0.3.0
|
|
78
|
+
|
|
3
79
|
## 0.3.0
|
|
4
80
|
|
|
5
81
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -136,7 +136,7 @@ import { usePreload } from "@couch-kit/client";
|
|
|
136
136
|
const ASSETS = ["/images/avatar_1.png", "/sounds/buzz.mp3"];
|
|
137
137
|
|
|
138
138
|
function App() {
|
|
139
|
-
const { loaded, progress } = usePreload(ASSETS);
|
|
139
|
+
const { loaded, progress, failedAssets } = usePreload(ASSETS);
|
|
140
140
|
|
|
141
141
|
if (!loaded) {
|
|
142
142
|
return <div>Loading... {Math.round(progress)}%</div>;
|
|
@@ -148,4 +148,4 @@ function App() {
|
|
|
148
148
|
|
|
149
149
|
## Notes / Limitations
|
|
150
150
|
|
|
151
|
-
- `usePreload()` preloads images via `Image()` and uses `fetch()` for other URLs; it does not currently send the protocol `ASSETS_LOADED` message.
|
|
151
|
+
- `usePreload()` preloads images via `Image()` and uses `fetch()` for other URLs; it does not currently send the protocol `ASSETS_LOADED` message. Failed assets are available via `failedAssets`.
|
package/dist/index.js
CHANGED
|
@@ -1831,14 +1831,51 @@ var require_react = __commonJS((exports, module) => {
|
|
|
1831
1831
|
var import_react2 = __toESM(require_react(), 1);
|
|
1832
1832
|
|
|
1833
1833
|
// ../core/src/types.ts
|
|
1834
|
+
var InternalActionTypes = {
|
|
1835
|
+
HYDRATE: "__HYDRATE__",
|
|
1836
|
+
PLAYER_JOINED: "__PLAYER_JOINED__",
|
|
1837
|
+
PLAYER_LEFT: "__PLAYER_LEFT__"
|
|
1838
|
+
};
|
|
1834
1839
|
function createGameReducer(reducer) {
|
|
1835
1840
|
return (state, action) => {
|
|
1836
|
-
|
|
1837
|
-
|
|
1841
|
+
switch (action.type) {
|
|
1842
|
+
case InternalActionTypes.HYDRATE:
|
|
1843
|
+
return action.payload;
|
|
1844
|
+
case InternalActionTypes.PLAYER_JOINED: {
|
|
1845
|
+
const { id, name, avatar } = action.payload;
|
|
1846
|
+
return {
|
|
1847
|
+
...state,
|
|
1848
|
+
players: {
|
|
1849
|
+
...state.players,
|
|
1850
|
+
[id]: {
|
|
1851
|
+
id,
|
|
1852
|
+
name,
|
|
1853
|
+
avatar,
|
|
1854
|
+
isHost: false,
|
|
1855
|
+
connected: true
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
case InternalActionTypes.PLAYER_LEFT: {
|
|
1861
|
+
const { playerId } = action.payload;
|
|
1862
|
+
const player = state.players[playerId];
|
|
1863
|
+
if (!player)
|
|
1864
|
+
return state;
|
|
1865
|
+
return {
|
|
1866
|
+
...state,
|
|
1867
|
+
players: {
|
|
1868
|
+
...state.players,
|
|
1869
|
+
[playerId]: { ...player, connected: false }
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
default:
|
|
1874
|
+
return reducer(state, action);
|
|
1838
1875
|
}
|
|
1839
|
-
return reducer(state, action);
|
|
1840
1876
|
};
|
|
1841
1877
|
}
|
|
1878
|
+
|
|
1842
1879
|
// ../core/src/protocol.ts
|
|
1843
1880
|
var MessageTypes = {
|
|
1844
1881
|
JOIN: "JOIN",
|
|
@@ -1851,6 +1888,29 @@ var MessageTypes = {
|
|
|
1851
1888
|
RECONNECTED: "RECONNECTED",
|
|
1852
1889
|
ERROR: "ERROR"
|
|
1853
1890
|
};
|
|
1891
|
+
|
|
1892
|
+
// ../core/src/constants.ts
|
|
1893
|
+
var DEFAULT_WS_PORT_OFFSET = 2;
|
|
1894
|
+
var MAX_FRAME_SIZE = 1024 * 1024;
|
|
1895
|
+
var DEFAULT_MAX_RETRIES = 5;
|
|
1896
|
+
var DEFAULT_BASE_DELAY = 1000;
|
|
1897
|
+
var DEFAULT_MAX_DELAY = 1e4;
|
|
1898
|
+
var DEFAULT_SYNC_INTERVAL = 5000;
|
|
1899
|
+
var MAX_PENDING_PINGS = 50;
|
|
1900
|
+
function generateId() {
|
|
1901
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
1902
|
+
return crypto.randomUUID();
|
|
1903
|
+
}
|
|
1904
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
1905
|
+
const bytes = new Uint8Array(16);
|
|
1906
|
+
crypto.getRandomValues(bytes);
|
|
1907
|
+
return Array.from(bytes).map((b2) => b2.toString(16).padStart(2, "0")).join("");
|
|
1908
|
+
}
|
|
1909
|
+
const a = Math.random().toString(36).substring(2, 15);
|
|
1910
|
+
const b = Math.random().toString(36).substring(2, 10);
|
|
1911
|
+
return a + b;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1854
1914
|
// src/time-sync.ts
|
|
1855
1915
|
var import_react = __toESM(require_react(), 1);
|
|
1856
1916
|
function calculateTimeSync(clientSendTime, clientReceiveTime, serverTime) {
|
|
@@ -1882,7 +1942,12 @@ function useServerTime(socket) {
|
|
|
1882
1942
|
if (!socket || socket.readyState !== WebSocket.OPEN)
|
|
1883
1943
|
return;
|
|
1884
1944
|
const sync = () => {
|
|
1885
|
-
|
|
1945
|
+
if (pings.current.size >= MAX_PENDING_PINGS) {
|
|
1946
|
+
const oldest = pings.current.keys().next().value;
|
|
1947
|
+
if (oldest !== undefined)
|
|
1948
|
+
pings.current.delete(oldest);
|
|
1949
|
+
}
|
|
1950
|
+
const id = generateId();
|
|
1886
1951
|
const timestamp = Date.now();
|
|
1887
1952
|
pings.current.set(id, timestamp);
|
|
1888
1953
|
socket.send(JSON.stringify({
|
|
@@ -1891,15 +1956,13 @@ function useServerTime(socket) {
|
|
|
1891
1956
|
}));
|
|
1892
1957
|
};
|
|
1893
1958
|
sync();
|
|
1894
|
-
const interval = setInterval(sync,
|
|
1959
|
+
const interval = setInterval(sync, DEFAULT_SYNC_INTERVAL);
|
|
1895
1960
|
return () => clearInterval(interval);
|
|
1896
1961
|
}, [socket]);
|
|
1897
1962
|
return { getServerTime, rtt: timeSync.rtt, handlePong };
|
|
1898
1963
|
}
|
|
1899
1964
|
|
|
1900
1965
|
// src/client.ts
|
|
1901
|
-
var MAX_RETRIES = 5;
|
|
1902
|
-
var BASE_DELAY = 1000;
|
|
1903
1966
|
function useGameClient(config) {
|
|
1904
1967
|
const [status, setStatus] = import_react2.useState("disconnected");
|
|
1905
1968
|
const [playerId, setPlayerId] = import_react2.useState(null);
|
|
@@ -1907,45 +1970,61 @@ function useGameClient(config) {
|
|
|
1907
1970
|
const socketRef = import_react2.useRef(null);
|
|
1908
1971
|
const reconnectAttempts = import_react2.useRef(0);
|
|
1909
1972
|
const reconnectTimer = import_react2.useRef(null);
|
|
1910
|
-
const
|
|
1973
|
+
const intentionalClose = import_react2.useRef(false);
|
|
1974
|
+
const configRef = import_react2.useRef(config);
|
|
1975
|
+
import_react2.useEffect(() => {
|
|
1976
|
+
configRef.current = config;
|
|
1977
|
+
});
|
|
1978
|
+
const { getServerTime, rtt, handlePong } = useServerTime(socketRef.current);
|
|
1979
|
+
const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
1980
|
+
const baseDelay = config.baseDelay ?? DEFAULT_BASE_DELAY;
|
|
1981
|
+
const maxDelay = config.maxDelay ?? DEFAULT_MAX_DELAY;
|
|
1911
1982
|
const connect = import_react2.useCallback(() => {
|
|
1912
|
-
|
|
1983
|
+
const cfg = configRef.current;
|
|
1984
|
+
intentionalClose.current = false;
|
|
1985
|
+
let wsUrl = cfg.url;
|
|
1913
1986
|
if (!wsUrl && typeof window !== "undefined") {
|
|
1914
|
-
const
|
|
1987
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
1915
1988
|
const host = window.location.hostname;
|
|
1916
1989
|
const httpPort = parseInt(window.location.port, 10) || 80;
|
|
1917
|
-
const wsPort =
|
|
1918
|
-
wsUrl = `${
|
|
1990
|
+
const wsPort = cfg.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
|
|
1991
|
+
wsUrl = `${protocol}//${host}:${wsPort}`;
|
|
1919
1992
|
}
|
|
1920
1993
|
if (!wsUrl)
|
|
1921
1994
|
return;
|
|
1922
|
-
if (
|
|
1995
|
+
if (cfg.debug)
|
|
1923
1996
|
console.log(`[GameClient] Connecting to ${wsUrl}`);
|
|
1924
1997
|
setStatus("connecting");
|
|
1925
1998
|
const ws = new WebSocket(wsUrl);
|
|
1926
1999
|
socketRef.current = ws;
|
|
1927
2000
|
ws.onopen = () => {
|
|
2001
|
+
const currentCfg = configRef.current;
|
|
1928
2002
|
setStatus("connected");
|
|
1929
2003
|
reconnectAttempts.current = 0;
|
|
1930
|
-
|
|
2004
|
+
currentCfg.onConnect?.();
|
|
1931
2005
|
let secret = null;
|
|
1932
2006
|
try {
|
|
1933
2007
|
secret = localStorage.getItem("ck_secret");
|
|
1934
2008
|
if (!secret) {
|
|
1935
|
-
secret =
|
|
2009
|
+
secret = generateId();
|
|
1936
2010
|
localStorage.setItem("ck_secret", secret);
|
|
1937
2011
|
}
|
|
1938
2012
|
} catch {
|
|
1939
|
-
secret =
|
|
2013
|
+
secret = generateId();
|
|
2014
|
+
}
|
|
2015
|
+
try {
|
|
2016
|
+
ws.send(JSON.stringify({
|
|
2017
|
+
type: MessageTypes.JOIN,
|
|
2018
|
+
payload: {
|
|
2019
|
+
name: currentCfg.name || "Player",
|
|
2020
|
+
avatar: currentCfg.avatar || "\uD83D\uDE00",
|
|
2021
|
+
secret
|
|
2022
|
+
}
|
|
2023
|
+
}));
|
|
2024
|
+
} catch (e) {
|
|
2025
|
+
if (currentCfg.debug)
|
|
2026
|
+
console.error("[GameClient] Failed to send JOIN:", e);
|
|
1940
2027
|
}
|
|
1941
|
-
ws.send(JSON.stringify({
|
|
1942
|
-
type: MessageTypes.JOIN,
|
|
1943
|
-
payload: {
|
|
1944
|
-
name: config.name || "Player",
|
|
1945
|
-
avatar: config.avatar || "\uD83D\uDE00",
|
|
1946
|
-
secret
|
|
1947
|
-
}
|
|
1948
|
-
}));
|
|
1949
2028
|
};
|
|
1950
2029
|
ws.onmessage = (event) => {
|
|
1951
2030
|
try {
|
|
@@ -1953,10 +2032,16 @@ function useGameClient(config) {
|
|
|
1953
2032
|
switch (msg.type) {
|
|
1954
2033
|
case MessageTypes.WELCOME:
|
|
1955
2034
|
setPlayerId(msg.payload.playerId);
|
|
1956
|
-
dispatchLocal({
|
|
2035
|
+
dispatchLocal({
|
|
2036
|
+
type: InternalActionTypes.HYDRATE,
|
|
2037
|
+
payload: msg.payload.state
|
|
2038
|
+
});
|
|
1957
2039
|
break;
|
|
1958
2040
|
case MessageTypes.STATE_UPDATE:
|
|
1959
|
-
dispatchLocal({
|
|
2041
|
+
dispatchLocal({
|
|
2042
|
+
type: InternalActionTypes.HYDRATE,
|
|
2043
|
+
payload: msg.payload.newState
|
|
2044
|
+
});
|
|
1960
2045
|
break;
|
|
1961
2046
|
case MessageTypes.PONG:
|
|
1962
2047
|
handlePong(msg.payload);
|
|
@@ -1966,13 +2051,17 @@ function useGameClient(config) {
|
|
|
1966
2051
|
console.error("Failed to parse message", e);
|
|
1967
2052
|
}
|
|
1968
2053
|
};
|
|
1969
|
-
ws.onclose = () => {
|
|
2054
|
+
ws.onclose = (event) => {
|
|
1970
2055
|
setStatus("disconnected");
|
|
1971
|
-
|
|
1972
|
-
if (
|
|
1973
|
-
|
|
2056
|
+
configRef.current.onDisconnect?.();
|
|
2057
|
+
if (intentionalClose.current)
|
|
2058
|
+
return;
|
|
2059
|
+
if (event.code === 1008 || event.code === 1011)
|
|
2060
|
+
return;
|
|
2061
|
+
if (reconnectAttempts.current < maxRetries) {
|
|
2062
|
+
const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts.current), maxDelay);
|
|
1974
2063
|
reconnectAttempts.current++;
|
|
1975
|
-
if (
|
|
2064
|
+
if (configRef.current.debug)
|
|
1976
2065
|
console.log(`[GameClient] Reconnecting in ${delay}ms...`);
|
|
1977
2066
|
reconnectTimer.current = setTimeout(() => {
|
|
1978
2067
|
connect();
|
|
@@ -1980,20 +2069,38 @@ function useGameClient(config) {
|
|
|
1980
2069
|
}
|
|
1981
2070
|
};
|
|
1982
2071
|
ws.onerror = (e) => {
|
|
1983
|
-
if (
|
|
2072
|
+
if (configRef.current.debug)
|
|
1984
2073
|
console.error("[GameClient] Error", e);
|
|
1985
2074
|
setStatus("error");
|
|
1986
2075
|
};
|
|
1987
|
-
}, [config.url, config.wsPort,
|
|
2076
|
+
}, [config.url, config.wsPort, maxRetries, baseDelay, maxDelay]);
|
|
1988
2077
|
import_react2.useEffect(() => {
|
|
1989
2078
|
connect();
|
|
1990
2079
|
return () => {
|
|
2080
|
+
intentionalClose.current = true;
|
|
1991
2081
|
if (socketRef.current)
|
|
1992
2082
|
socketRef.current.close();
|
|
1993
2083
|
if (reconnectTimer.current)
|
|
1994
2084
|
clearTimeout(reconnectTimer.current);
|
|
1995
2085
|
};
|
|
1996
2086
|
}, [connect]);
|
|
2087
|
+
const disconnect = import_react2.useCallback(() => {
|
|
2088
|
+
intentionalClose.current = true;
|
|
2089
|
+
if (reconnectTimer.current) {
|
|
2090
|
+
clearTimeout(reconnectTimer.current);
|
|
2091
|
+
reconnectTimer.current = null;
|
|
2092
|
+
}
|
|
2093
|
+
if (socketRef.current) {
|
|
2094
|
+
socketRef.current.close();
|
|
2095
|
+
socketRef.current = null;
|
|
2096
|
+
}
|
|
2097
|
+
setStatus("disconnected");
|
|
2098
|
+
}, []);
|
|
2099
|
+
const reconnect = import_react2.useCallback(() => {
|
|
2100
|
+
disconnect();
|
|
2101
|
+
reconnectAttempts.current = 0;
|
|
2102
|
+
setTimeout(() => connect(), 50);
|
|
2103
|
+
}, [disconnect, connect]);
|
|
1997
2104
|
const sendAction = import_react2.useCallback((action) => {
|
|
1998
2105
|
dispatchLocal(action);
|
|
1999
2106
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
|
@@ -2008,41 +2115,77 @@ function useGameClient(config) {
|
|
|
2008
2115
|
state,
|
|
2009
2116
|
playerId,
|
|
2010
2117
|
sendAction,
|
|
2011
|
-
getServerTime
|
|
2118
|
+
getServerTime,
|
|
2119
|
+
rtt,
|
|
2120
|
+
disconnect,
|
|
2121
|
+
reconnect
|
|
2012
2122
|
};
|
|
2013
2123
|
}
|
|
2014
2124
|
// src/assets.ts
|
|
2015
2125
|
var import_react3 = __toESM(require_react(), 1);
|
|
2126
|
+
function arraysEqual(a, b) {
|
|
2127
|
+
if (a.length !== b.length)
|
|
2128
|
+
return false;
|
|
2129
|
+
for (let i = 0;i < a.length; i++) {
|
|
2130
|
+
if (a[i] !== b[i])
|
|
2131
|
+
return false;
|
|
2132
|
+
}
|
|
2133
|
+
return true;
|
|
2134
|
+
}
|
|
2016
2135
|
function usePreload(assets) {
|
|
2017
2136
|
const [loaded, setLoaded] = import_react3.useState(false);
|
|
2018
2137
|
const [progress, setProgress] = import_react3.useState(0);
|
|
2138
|
+
const [failedAssets, setFailedAssets] = import_react3.useState([]);
|
|
2139
|
+
const prevAssets = import_react3.useRef(assets);
|
|
2140
|
+
if (!arraysEqual(prevAssets.current, assets)) {
|
|
2141
|
+
prevAssets.current = assets;
|
|
2142
|
+
}
|
|
2143
|
+
const stableAssets = prevAssets.current;
|
|
2019
2144
|
import_react3.useEffect(() => {
|
|
2020
|
-
if (
|
|
2145
|
+
if (stableAssets.length === 0) {
|
|
2021
2146
|
setLoaded(true);
|
|
2022
2147
|
setProgress(100);
|
|
2148
|
+
setFailedAssets([]);
|
|
2023
2149
|
return;
|
|
2024
2150
|
}
|
|
2025
2151
|
let loadedCount = 0;
|
|
2026
|
-
const total =
|
|
2027
|
-
const
|
|
2152
|
+
const total = stableAssets.length;
|
|
2153
|
+
const failed = [];
|
|
2154
|
+
let cancelled = false;
|
|
2155
|
+
const tick = () => {
|
|
2156
|
+
if (cancelled)
|
|
2157
|
+
return;
|
|
2028
2158
|
loadedCount++;
|
|
2029
2159
|
setProgress(Math.round(loadedCount / total * 100));
|
|
2030
2160
|
if (loadedCount === total) {
|
|
2161
|
+
setFailedAssets([...failed]);
|
|
2031
2162
|
setLoaded(true);
|
|
2032
2163
|
}
|
|
2033
2164
|
};
|
|
2034
|
-
|
|
2165
|
+
setLoaded(false);
|
|
2166
|
+
setProgress(0);
|
|
2167
|
+
setFailedAssets([]);
|
|
2168
|
+
stableAssets.forEach((src) => {
|
|
2035
2169
|
if (src.match(/\.(jpeg|jpg|gif|png|webp)$/)) {
|
|
2036
2170
|
const img = new Image;
|
|
2037
|
-
img.onload =
|
|
2038
|
-
img.onerror =
|
|
2171
|
+
img.onload = tick;
|
|
2172
|
+
img.onerror = () => {
|
|
2173
|
+
failed.push(src);
|
|
2174
|
+
tick();
|
|
2175
|
+
};
|
|
2039
2176
|
img.src = src;
|
|
2040
2177
|
} else {
|
|
2041
|
-
fetch(src).then(
|
|
2178
|
+
fetch(src).then(tick).catch(() => {
|
|
2179
|
+
failed.push(src);
|
|
2180
|
+
tick();
|
|
2181
|
+
});
|
|
2042
2182
|
}
|
|
2043
2183
|
});
|
|
2044
|
-
|
|
2045
|
-
|
|
2184
|
+
return () => {
|
|
2185
|
+
cancelled = true;
|
|
2186
|
+
};
|
|
2187
|
+
}, [stableAssets]);
|
|
2188
|
+
return { loaded, progress, failedAssets };
|
|
2046
2189
|
}
|
|
2047
2190
|
export {
|
|
2048
2191
|
useServerTime,
|
package/package.json
CHANGED
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@couch-kit/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "React hooks for phone controllers in Couch Kit party games — WebSocket client and action dispatch",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/faluciano/react-native-couch-kit.git",
|
|
9
|
+
"directory": "packages/client"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/faluciano/react-native-couch-kit#readme",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react",
|
|
14
|
+
"couch-kit",
|
|
15
|
+
"party-game",
|
|
16
|
+
"websocket",
|
|
17
|
+
"game-client",
|
|
18
|
+
"controller"
|
|
19
|
+
],
|
|
4
20
|
"main": "./src/index.ts",
|
|
5
21
|
"module": "./src/index.ts",
|
|
6
22
|
"types": "./src/index.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./src/index.ts",
|
|
26
|
+
"import": "./src/index.ts",
|
|
27
|
+
"default": "./src/index.ts"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"sideEffects": false,
|
|
7
31
|
"scripts": {
|
|
8
32
|
"build": "bun build ./src/index.ts --outdir ./dist --target browser",
|
|
9
33
|
"test": "bun test",
|
|
@@ -12,7 +36,7 @@
|
|
|
12
36
|
"clean": "rm -rf dist"
|
|
13
37
|
},
|
|
14
38
|
"dependencies": {
|
|
15
|
-
"@couch-kit/core": "0.
|
|
39
|
+
"@couch-kit/core": "0.3.1"
|
|
16
40
|
},
|
|
17
41
|
"devDependencies": {
|
|
18
42
|
"react": "^18.2.0",
|
package/src/assets.ts
CHANGED
|
@@ -1,43 +1,100 @@
|
|
|
1
|
-
import { useState, useEffect } from
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export interface PreloadResult {
|
|
4
|
+
/** Whether all assets have finished loading (including failures). */
|
|
5
|
+
loaded: boolean;
|
|
6
|
+
/** Loading progress as a percentage (0-100). */
|
|
7
|
+
progress: number;
|
|
8
|
+
/** Asset URLs that failed to load. */
|
|
9
|
+
failedAssets: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shallow-compare two string arrays by length and element identity.
|
|
14
|
+
* Returns `true` when the arrays are considered equal.
|
|
15
|
+
*/
|
|
16
|
+
function arraysEqual(a: string[], b: string[]): boolean {
|
|
17
|
+
if (a.length !== b.length) return false;
|
|
18
|
+
for (let i = 0; i < a.length; i++) {
|
|
19
|
+
if (a[i] !== b[i]) return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Preloads a list of asset URLs (images via `Image()`, others via `fetch()`).
|
|
26
|
+
*
|
|
27
|
+
* Returns `loaded` (all done), `progress` (0-100), and `failedAssets` (URLs
|
|
28
|
+
* that failed to load). Failed assets still count toward progress so the hook
|
|
29
|
+
* always reaches 100 % — check `failedAssets.length` to decide how to react.
|
|
30
|
+
*/
|
|
31
|
+
export function usePreload(assets: string[]): PreloadResult {
|
|
6
32
|
const [loaded, setLoaded] = useState(false);
|
|
7
33
|
const [progress, setProgress] = useState(0);
|
|
34
|
+
const [failedAssets, setFailedAssets] = useState<string[]>([]);
|
|
35
|
+
|
|
36
|
+
// Stable reference to the last asset list so we don't depend on
|
|
37
|
+
// `JSON.stringify(assets)` — which creates a new string every render.
|
|
38
|
+
const prevAssets = useRef<string[]>(assets);
|
|
39
|
+
|
|
40
|
+
// Only update the ref (and re-trigger the effect) when the content changes.
|
|
41
|
+
if (!arraysEqual(prevAssets.current, assets)) {
|
|
42
|
+
prevAssets.current = assets;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const stableAssets = prevAssets.current;
|
|
8
46
|
|
|
9
47
|
useEffect(() => {
|
|
10
|
-
if (
|
|
48
|
+
if (stableAssets.length === 0) {
|
|
11
49
|
setLoaded(true);
|
|
12
50
|
setProgress(100);
|
|
51
|
+
setFailedAssets([]);
|
|
13
52
|
return;
|
|
14
53
|
}
|
|
15
54
|
|
|
16
55
|
let loadedCount = 0;
|
|
17
|
-
const total =
|
|
56
|
+
const total = stableAssets.length;
|
|
57
|
+
const failed: string[] = [];
|
|
58
|
+
let cancelled = false;
|
|
18
59
|
|
|
19
|
-
const
|
|
60
|
+
const tick = () => {
|
|
61
|
+
if (cancelled) return;
|
|
20
62
|
loadedCount++;
|
|
21
63
|
setProgress(Math.round((loadedCount / total) * 100));
|
|
22
64
|
if (loadedCount === total) {
|
|
65
|
+
setFailedAssets([...failed]);
|
|
23
66
|
setLoaded(true);
|
|
24
67
|
}
|
|
25
68
|
};
|
|
26
69
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
70
|
+
// Reset state for a fresh load cycle
|
|
71
|
+
setLoaded(false);
|
|
72
|
+
setProgress(0);
|
|
73
|
+
setFailedAssets([]);
|
|
74
|
+
|
|
75
|
+
stableAssets.forEach((src) => {
|
|
76
|
+
if (src.match(/\.(jpeg|jpg|gif|png|webp)$/)) {
|
|
77
|
+
const img = new Image();
|
|
78
|
+
img.onload = tick;
|
|
79
|
+
img.onerror = () => {
|
|
80
|
+
failed.push(src);
|
|
81
|
+
tick();
|
|
82
|
+
};
|
|
83
|
+
img.src = src;
|
|
84
|
+
} else {
|
|
85
|
+
fetch(src)
|
|
86
|
+
.then(tick)
|
|
87
|
+
.catch(() => {
|
|
88
|
+
failed.push(src);
|
|
89
|
+
tick();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
39
92
|
});
|
|
40
|
-
}, [JSON.stringify(assets)]);
|
|
41
93
|
|
|
42
|
-
|
|
94
|
+
return () => {
|
|
95
|
+
cancelled = true;
|
|
96
|
+
};
|
|
97
|
+
}, [stableAssets]);
|
|
98
|
+
|
|
99
|
+
return { loaded, progress, failedAssets };
|
|
43
100
|
}
|