void-snippets-monorepo 0.1.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/README.md +2261 -0
- package/package.json +18 -0
- package/packages/client/package.json +47 -0
- package/packages/client/src/configure.ts +34 -0
- package/packages/client/src/index.ts +4 -0
- package/packages/client/src/services/base-api.service.ts +26 -0
- package/packages/client/src/services/resource-api.service.ts +117 -0
- package/packages/client/src/utils/handle-api-error.ts +20 -0
- package/packages/client/tsconfig.json +13 -0
- package/packages/client/tsup.config.ts +10 -0
- package/packages/core/package.json +41 -0
- package/packages/core/src/id.ts +19 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/string-to-id.ts +22 -0
- package/packages/core/src/types/index.ts +86 -0
- package/packages/core/src/utils/catch-error.ts +20 -0
- package/packages/core/tsconfig.json +13 -0
- package/packages/core/tsup.config.ts +9 -0
- package/packages/react/package.json +80 -0
- package/packages/react/src/hooks/createResourceHooks.ts +872 -0
- package/packages/react/src/hooks/useAlertMessage.ts +45 -0
- package/packages/react/src/hooks/useAsyncState.ts +110 -0
- package/packages/react/src/hooks/useCallTimer.ts +37 -0
- package/packages/react/src/hooks/useModal.ts +71 -0
- package/packages/react/src/hooks/usePagination.ts +57 -0
- package/packages/react/src/index.ts +43 -0
- package/packages/react/src/routing/createRouteContract.ts +483 -0
- package/packages/react/src/socket/createSocketHooks.ts +351 -0
- package/packages/react/tsconfig.json +14 -0
- package/packages/react/tsup.config.ts +10 -0
- package/pnpm-workspace.yaml +2 -0
- package/tsconfig.base.json +12 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { Socket } from "socket.io-client";
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// INTERNAL TYPE UTILITIES
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
// All runtime parameters of an event handler
|
|
9
|
+
type EventParams<TEvents, K extends keyof TEvents> =
|
|
10
|
+
TEvents[K] extends (...args: infer P) => void ? P : never;
|
|
11
|
+
|
|
12
|
+
// True when the last element of a tuple is callable (an ACK callback)
|
|
13
|
+
type LastIsCallback<P extends readonly unknown[]> =
|
|
14
|
+
P extends [...infer _Rest, infer Last]
|
|
15
|
+
? Last extends (...args: any[]) => void
|
|
16
|
+
? true
|
|
17
|
+
: false
|
|
18
|
+
: false;
|
|
19
|
+
|
|
20
|
+
// Params with the trailing ACK callback stripped off.
|
|
21
|
+
// For events that have no callback, all params are returned unchanged.
|
|
22
|
+
// join-room(roomId: string) → [string]
|
|
23
|
+
// send-message(msg: { text; roomId }) → [{ text; roomId }]
|
|
24
|
+
// update-profile(name: string, cb: (r) => void) → [string]
|
|
25
|
+
type NoAckArgs<TEvents, K extends keyof TEvents> =
|
|
26
|
+
EventParams<TEvents, K> extends [...infer Rest, infer Last]
|
|
27
|
+
? Last extends (...args: any[]) => void
|
|
28
|
+
? Rest
|
|
29
|
+
: EventParams<TEvents, K>
|
|
30
|
+
: EventParams<TEvents, K>;
|
|
31
|
+
|
|
32
|
+
// Keys of TClientEvents whose last param is an ACK callback.
|
|
33
|
+
// TypeScript will error at compile time if emitWithAck is called on any other key.
|
|
34
|
+
type AckEventKeys<TEvents> = {
|
|
35
|
+
[K in keyof TEvents]: LastIsCallback<
|
|
36
|
+
TEvents[K] extends (...args: infer P) => void ? P : never
|
|
37
|
+
> extends true
|
|
38
|
+
? K
|
|
39
|
+
: never;
|
|
40
|
+
}[keyof TEvents];
|
|
41
|
+
|
|
42
|
+
// The first argument of the ACK callback — what the server sends back.
|
|
43
|
+
// update-profile(name, (r: { status: "ok" | "error" }) => void)
|
|
44
|
+
// → AckResponseType = { status: "ok" | "error" }
|
|
45
|
+
type AckResponseType<TEvents, K extends keyof TEvents> =
|
|
46
|
+
EventParams<TEvents, K> extends [...infer _Rest, infer Last]
|
|
47
|
+
? Last extends (arg: infer R, ...rest: any[]) => void
|
|
48
|
+
? R
|
|
49
|
+
: never
|
|
50
|
+
: never;
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// PUBLIC RETURN TYPES
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
export interface VSSocketConnectionReturn {
|
|
57
|
+
/** True when the socket has an active, confirmed connection. */
|
|
58
|
+
isConnected: boolean;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* True while a connection or reconnection attempt is in progress.
|
|
62
|
+
* Resets to false when `connect` or `connect_error` fires.
|
|
63
|
+
*/
|
|
64
|
+
isConnecting: boolean;
|
|
65
|
+
|
|
66
|
+
/** The socket ID assigned by the server. Undefined when disconnected. */
|
|
67
|
+
socketId: string | undefined;
|
|
68
|
+
|
|
69
|
+
/** The error from the last failed connection attempt. Null on success or before any attempt. */
|
|
70
|
+
error: Error | null;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Initiates a connection. No-op if already connected.
|
|
74
|
+
* Sets `isConnecting: true` until `connect` or `connect_error` fires.
|
|
75
|
+
*/
|
|
76
|
+
connect: () => void;
|
|
77
|
+
|
|
78
|
+
/** Gracefully closes the connection and stops all reconnection attempts. */
|
|
79
|
+
disconnect: () => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// FACTORY
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Creates three type-safe Socket.IO hooks bound to a specific socket instance.
|
|
88
|
+
*
|
|
89
|
+
* Call once at module level — the returned hooks close over the socket and both
|
|
90
|
+
* event-map generics, so no type parameters are needed at individual call sites.
|
|
91
|
+
*
|
|
92
|
+
* Requires socket.io-client ≥4.6.0 (for native `socket.emitWithAck` support).
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* // socket-hooks.ts (create once, export and use everywhere)
|
|
96
|
+
* import { createSocketHooks } from '@void-snippets/react';
|
|
97
|
+
* import { io } from 'socket.io-client';
|
|
98
|
+
*
|
|
99
|
+
* const socket = io(process.env.SOCKET_URL, { autoConnect: false });
|
|
100
|
+
*
|
|
101
|
+
* export const { useSocketEmit, useSocketListener, useSocketConnection } =
|
|
102
|
+
* createSocketHooks<IClientToServerEvents, IServerToClientEvents>(socket);
|
|
103
|
+
*/
|
|
104
|
+
export function createSocketHooks<
|
|
105
|
+
TClientEvents extends Record<string, (...args: any[]) => void>,
|
|
106
|
+
TServerEvents extends Record<string, (...args: any[]) => void>,
|
|
107
|
+
>(socket: Socket<TServerEvents, TClientEvents>) {
|
|
108
|
+
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
// useSocketEmit
|
|
111
|
+
// -------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns two functions for type-safe event emission.
|
|
115
|
+
*
|
|
116
|
+
* **`emit(event, ...args)`** — fire and forget, no acknowledgement.
|
|
117
|
+
* Callable on any event regardless of its signature. Throws synchronously
|
|
118
|
+
* if the socket is not connected.
|
|
119
|
+
*
|
|
120
|
+
* **`emitWithAck(event, ...args)`** — emits and returns a `Promise` that
|
|
121
|
+
* resolves with the server's acknowledgement response. TypeScript will error
|
|
122
|
+
* at compile time if called on an event whose type has no callback.
|
|
123
|
+
* Returns a rejected `Promise` if the socket is not connected.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* const { emit, emitWithAck } = useSocketEmit();
|
|
127
|
+
*
|
|
128
|
+
* emit('join-room', roomId);
|
|
129
|
+
*
|
|
130
|
+
* const result = await emitWithAck('update-profile', name);
|
|
131
|
+
* // result is { status: "ok" | "error" } — inferred from the event type
|
|
132
|
+
*/
|
|
133
|
+
function useSocketEmit() {
|
|
134
|
+
const emit = useCallback(
|
|
135
|
+
<K extends keyof TClientEvents>(
|
|
136
|
+
event: K,
|
|
137
|
+
...args: NoAckArgs<TClientEvents, K>
|
|
138
|
+
): void => {
|
|
139
|
+
if (!socket.connected) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`[@void-snippets/react] Cannot emit "${String(event)}" — socket is not connected.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
// Cast required: TypeScript cannot spread-infer our stripped tuple
|
|
145
|
+
// against socket.emit's overloaded generic signature.
|
|
146
|
+
// The call is equivalent at runtime.
|
|
147
|
+
(socket as any).emit(event, ...(args as unknown[]));
|
|
148
|
+
},
|
|
149
|
+
[],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const emitWithAck = useCallback(
|
|
153
|
+
<K extends AckEventKeys<TClientEvents>>(
|
|
154
|
+
event: K,
|
|
155
|
+
...args: NoAckArgs<TClientEvents, K>
|
|
156
|
+
): Promise<AckResponseType<TClientEvents, K>> => {
|
|
157
|
+
if (!socket.connected) {
|
|
158
|
+
return Promise.reject(
|
|
159
|
+
new Error(
|
|
160
|
+
`[@void-snippets/react] Cannot emit "${String(event)}" — socket is not connected.`,
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
// socket.emitWithAck is available from socket.io-client ≥4.6.
|
|
165
|
+
return (socket as any).emitWithAck(
|
|
166
|
+
event,
|
|
167
|
+
...(args as unknown[]),
|
|
168
|
+
) as Promise<AckResponseType<TClientEvents, K>>;
|
|
169
|
+
},
|
|
170
|
+
[],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
return { emit, emitWithAck };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
// useSocketListener
|
|
178
|
+
// -------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Subscribes to a server event for the lifetime of the calling component.
|
|
182
|
+
*
|
|
183
|
+
* A ref pattern ensures the **latest** version of `handler` is always invoked
|
|
184
|
+
* without re-registering the listener on every render. This means inline arrow
|
|
185
|
+
* functions are safe — no `useCallback` needed at the call site.
|
|
186
|
+
*
|
|
187
|
+
* @param event The server event name to listen for.
|
|
188
|
+
* @param handler Called whenever the event fires. Always reflects the latest reference.
|
|
189
|
+
* @param options
|
|
190
|
+
* `enabled` (default `true`) — when `false`, the listener is not attached.
|
|
191
|
+
* Flip dynamically to activate/deactivate without unmounting the component.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* useSocketListener('new-message', (data) => {
|
|
195
|
+
* setMessages(prev => [...prev, data]);
|
|
196
|
+
* });
|
|
197
|
+
*
|
|
198
|
+
* // Conditional — only listen when a room is selected
|
|
199
|
+
* useSocketListener('user-joined', (userId) => { ... }, { enabled: !!roomId });
|
|
200
|
+
*/
|
|
201
|
+
function useSocketListener<K extends keyof TServerEvents>(
|
|
202
|
+
event: K,
|
|
203
|
+
handler: TServerEvents[K],
|
|
204
|
+
options?: { enabled?: boolean },
|
|
205
|
+
): void {
|
|
206
|
+
const enabled = options?.enabled ?? true;
|
|
207
|
+
|
|
208
|
+
// Tracks the latest handler without triggering re-registration in the listener effect
|
|
209
|
+
const savedHandler = useRef<TServerEvents[K]>(handler);
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
savedHandler.current = handler;
|
|
212
|
+
}, [handler]);
|
|
213
|
+
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (!enabled) return;
|
|
216
|
+
|
|
217
|
+
const listener = ((...args: Parameters<TServerEvents[K]>) => {
|
|
218
|
+
(
|
|
219
|
+
savedHandler.current as (
|
|
220
|
+
...a: Parameters<TServerEvents[K]>
|
|
221
|
+
) => void
|
|
222
|
+
)(...args);
|
|
223
|
+
}) as TServerEvents[K];
|
|
224
|
+
|
|
225
|
+
(socket as any).on(event, listener);
|
|
226
|
+
|
|
227
|
+
return () => {
|
|
228
|
+
(socket as any).off(event, listener);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// `handler` intentionally excluded: the ref update effect handles
|
|
232
|
+
// staleness without re-registering the listener on every render.
|
|
233
|
+
}, [event, enabled]);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// -------------------------------------------------------------------------
|
|
237
|
+
// useSocketConnection
|
|
238
|
+
// -------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Reactively tracks socket connection state and exposes connect/disconnect controls.
|
|
242
|
+
*
|
|
243
|
+
* Safe to mount from multiple components simultaneously — each instance
|
|
244
|
+
* independently subscribes to the same underlying socket events and reflects
|
|
245
|
+
* the same connection state via local React state.
|
|
246
|
+
*
|
|
247
|
+
* Listens to `connect`, `disconnect`, `connect_error` on the socket, and
|
|
248
|
+
* `reconnect_attempt` / `reconnect_failed` on the Manager (`socket.io`).
|
|
249
|
+
* All listeners are removed on unmount.
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* function AppShell() {
|
|
253
|
+
* const { isConnected, isConnecting, error, connect, disconnect } =
|
|
254
|
+
* useSocketConnection();
|
|
255
|
+
*
|
|
256
|
+
* useEffect(() => { connect(); }, []);
|
|
257
|
+
*
|
|
258
|
+
* return (
|
|
259
|
+
* <>
|
|
260
|
+
* {isConnecting && <Banner>Connecting…</Banner>}
|
|
261
|
+
* {error && <Banner type="error">{error.message}</Banner>}
|
|
262
|
+
* <YourApp />
|
|
263
|
+
* </>
|
|
264
|
+
* );
|
|
265
|
+
* }
|
|
266
|
+
*/
|
|
267
|
+
function useSocketConnection(): VSSocketConnectionReturn {
|
|
268
|
+
const [isConnected, setIsConnected] = useState<boolean>(socket.connected);
|
|
269
|
+
const [isConnecting, setIsConnecting] = useState<boolean>(false);
|
|
270
|
+
const [socketId, setSocketId] = useState<string | undefined>(socket.id);
|
|
271
|
+
const [error, setError] = useState<Error | null>(null);
|
|
272
|
+
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
function onConnect() {
|
|
275
|
+
setIsConnected(true);
|
|
276
|
+
setIsConnecting(false);
|
|
277
|
+
setSocketId(socket.id);
|
|
278
|
+
setError(null);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function onDisconnect() {
|
|
282
|
+
setIsConnected(false);
|
|
283
|
+
setIsConnecting(false);
|
|
284
|
+
setSocketId(undefined);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function onConnectError(err: Error) {
|
|
288
|
+
setIsConnected(false);
|
|
289
|
+
setIsConnecting(false);
|
|
290
|
+
setError(err);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Manager-level events — socket.io is the underlying Manager instance.
|
|
294
|
+
// These fire during reconnection cycles managed by socket.io-client internally.
|
|
295
|
+
function onReconnectAttempt() {
|
|
296
|
+
setIsConnecting(true);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function onReconnectFailed() {
|
|
300
|
+
setIsConnecting(false);
|
|
301
|
+
setError(
|
|
302
|
+
new Error(
|
|
303
|
+
"[@void-snippets/react] Socket reconnection failed — maximum attempts exceeded.",
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
socket.on("connect", onConnect);
|
|
309
|
+
socket.on("disconnect", onDisconnect);
|
|
310
|
+
socket.on("connect_error", onConnectError);
|
|
311
|
+
|
|
312
|
+
// Cast: Manager event types vary across socket.io-client minor versions.
|
|
313
|
+
(socket.io as any).on("reconnect_attempt", onReconnectAttempt);
|
|
314
|
+
(socket.io as any).on("reconnect_failed", onReconnectFailed);
|
|
315
|
+
|
|
316
|
+
return () => {
|
|
317
|
+
socket.off("connect", onConnect);
|
|
318
|
+
socket.off("disconnect", onDisconnect);
|
|
319
|
+
socket.off("connect_error", onConnectError);
|
|
320
|
+
(socket.io as any).off("reconnect_attempt", onReconnectAttempt);
|
|
321
|
+
(socket.io as any).off("reconnect_failed", onReconnectFailed);
|
|
322
|
+
};
|
|
323
|
+
}, []);
|
|
324
|
+
|
|
325
|
+
const connect = useCallback((): void => {
|
|
326
|
+
if (!socket.connected) {
|
|
327
|
+
setIsConnecting(true);
|
|
328
|
+
socket.connect();
|
|
329
|
+
}
|
|
330
|
+
}, []);
|
|
331
|
+
|
|
332
|
+
const disconnect = useCallback((): void => {
|
|
333
|
+
socket.disconnect();
|
|
334
|
+
}, []);
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
isConnected,
|
|
338
|
+
isConnecting,
|
|
339
|
+
socketId,
|
|
340
|
+
error,
|
|
341
|
+
connect,
|
|
342
|
+
disconnect,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
useSocketEmit,
|
|
348
|
+
useSocketListener,
|
|
349
|
+
useSocketConnection,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2019",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"declarationMap": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineConfig } from "tsup";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ["src/index.ts"],
|
|
5
|
+
format: ["cjs", "esm"],
|
|
6
|
+
dts: true,
|
|
7
|
+
clean: true,
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
external: ["react", "@void-snippets/core", "@void-snippets/client", "@tanstack/react-query"],
|
|
10
|
+
});
|