@alepha/react 0.11.11 → 0.12.0
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 +1 -183
- package/dist/auth/index.browser.js +1460 -0
- package/dist/auth/index.browser.js.map +1 -0
- package/dist/auth/index.cjs +3647 -0
- package/dist/auth/index.cjs.map +1 -0
- package/dist/auth/index.d.cts +564 -0
- package/dist/auth/index.d.cts.map +1 -0
- package/dist/auth/index.d.ts +564 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +3615 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/{index.browser.js → core/index.browser.js} +36 -35
- package/dist/core/index.browser.js.map +1 -0
- package/dist/{index.cjs → core/index.cjs} +141 -140
- package/dist/core/index.cjs.map +1 -0
- package/dist/{index.d.cts → core/index.d.cts} +68 -68
- package/dist/core/index.d.cts.map +1 -0
- package/dist/{index.d.ts → core/index.d.ts} +68 -68
- package/dist/core/index.d.ts.map +1 -0
- package/dist/{index.js → core/index.js} +39 -38
- package/dist/core/index.js.map +1 -0
- package/dist/form/index.cjs +2054 -0
- package/dist/form/index.cjs.map +1 -0
- package/dist/form/index.d.cts +211 -0
- package/dist/form/index.d.cts.map +1 -0
- package/dist/form/index.d.ts +211 -0
- package/dist/form/index.d.ts.map +1 -0
- package/dist/form/index.js +2026 -0
- package/dist/form/index.js.map +1 -0
- package/dist/head/index.browser.js +1503 -0
- package/dist/head/index.browser.js.map +1 -0
- package/dist/head/index.cjs +1908 -0
- package/dist/head/index.cjs.map +1 -0
- package/dist/head/index.d.cts +595 -0
- package/dist/head/index.d.cts.map +1 -0
- package/dist/head/index.d.ts +601 -0
- package/dist/head/index.d.ts.map +1 -0
- package/dist/head/index.js +1880 -0
- package/dist/head/index.js.map +1 -0
- package/dist/i18n/index.cjs +1886 -0
- package/dist/i18n/index.cjs.map +1 -0
- package/dist/i18n/index.d.cts +168 -0
- package/dist/i18n/index.d.cts.map +1 -0
- package/dist/i18n/index.d.ts +168 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +1857 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/websocket/index.cjs +1774 -0
- package/dist/websocket/index.cjs.map +1 -0
- package/dist/websocket/index.d.cts +118 -0
- package/dist/websocket/index.d.cts.map +1 -0
- package/dist/websocket/index.d.ts +118 -0
- package/dist/websocket/index.d.ts.map +1 -0
- package/dist/websocket/index.js +1750 -0
- package/dist/websocket/index.js.map +1 -0
- package/package.json +89 -67
- package/src/auth/descriptors/$auth.ts +436 -0
- package/src/auth/descriptors/$authApple.ts +8 -0
- package/src/auth/descriptors/$authGithub.ts +81 -0
- package/src/auth/descriptors/$authGoogle.ts +38 -0
- package/src/auth/errors/SessionExpiredError.ts +6 -0
- package/src/auth/hooks/useAuth.ts +31 -0
- package/src/auth/index.browser.ts +16 -0
- package/src/auth/index.shared.ts +3 -0
- package/src/auth/index.ts +47 -0
- package/src/auth/providers/ReactAuthProvider.ts +629 -0
- package/src/auth/schemas/tokenResponseSchema.ts +11 -0
- package/src/auth/schemas/tokensSchema.ts +21 -0
- package/src/auth/schemas/userinfoResponseSchema.ts +10 -0
- package/src/auth/services/ReactAuth.ts +124 -0
- package/src/{components → core/components}/ErrorViewer.tsx +3 -2
- package/src/{components → core/components}/NestedView.tsx +1 -1
- package/src/{contexts → core/contexts}/AlephaContext.ts +1 -1
- package/src/{descriptors → core/descriptors}/$page.ts +4 -4
- package/src/{hooks → core/hooks}/useAction.ts +1 -1
- package/src/{hooks → core/hooks}/useAlepha.ts +1 -1
- package/src/{hooks → core/hooks}/useClient.ts +1 -1
- package/src/{hooks → core/hooks}/useEvents.ts +1 -1
- package/src/{hooks → core/hooks}/useInject.ts +1 -1
- package/src/{hooks → core/hooks}/useQueryParams.ts +1 -1
- package/src/{hooks → core/hooks}/useRouterState.ts +1 -1
- package/src/{hooks → core/hooks}/useSchema.ts +3 -3
- package/src/{hooks → core/hooks}/useStore.ts +2 -2
- package/src/{index.browser.ts → core/index.browser.ts} +4 -4
- package/src/{index.ts → core/index.ts} +6 -6
- package/src/{providers → core/providers}/ReactBrowserProvider.ts +6 -6
- package/src/{providers → core/providers}/ReactBrowserRendererProvider.ts +2 -2
- package/src/{providers → core/providers}/ReactBrowserRouterProvider.ts +3 -3
- package/src/{providers → core/providers}/ReactPageProvider.ts +3 -3
- package/src/{providers → core/providers}/ReactServerProvider.ts +7 -7
- package/src/{services → core/services}/ReactPageServerService.ts +2 -2
- package/src/{services → core/services}/ReactPageService.ts +1 -1
- package/src/{services → core/services}/ReactRouter.ts +1 -1
- package/src/form/components/FormState.tsx +17 -0
- package/src/form/hooks/useForm.ts +47 -0
- package/src/form/hooks/useFormState.ts +130 -0
- package/src/form/index.ts +38 -0
- package/src/form/services/FormModel.ts +548 -0
- package/src/head/descriptors/$head.ts +25 -0
- package/src/head/hooks/useHead.ts +62 -0
- package/src/head/index.browser.ts +25 -0
- package/src/head/index.ts +47 -0
- package/src/head/interfaces/Head.ts +46 -0
- package/src/head/providers/BrowserHeadProvider.ts +105 -0
- package/src/head/providers/HeadProvider.ts +73 -0
- package/src/head/providers/ServerHeadProvider.ts +109 -0
- package/src/i18n/README.md +76 -0
- package/src/i18n/components/Localize.tsx +35 -0
- package/src/i18n/descriptors/$dictionary.ts +65 -0
- package/src/i18n/hooks/useI18n.ts +18 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/providers/I18nProvider.ts +277 -0
- package/src/websocket/hooks/useRoom.tsx +223 -0
- package/src/websocket/index.ts +7 -0
- package/dist/index.browser.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- /package/src/{components → core/components}/ClientOnly.tsx +0 -0
- /package/src/{components → core/components}/ErrorBoundary.tsx +0 -0
- /package/src/{components → core/components}/Link.tsx +0 -0
- /package/src/{components → core/components}/NotFound.tsx +0 -0
- /package/src/{contexts → core/contexts}/RouterLayerContext.ts +0 -0
- /package/src/{errors → core/errors}/Redirection.ts +0 -0
- /package/src/{hooks → core/hooks}/useActive.ts +0 -0
- /package/src/{hooks → core/hooks}/useRouter.ts +0 -0
- /package/src/{index.shared.ts → core/index.shared.ts} +0 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { $hook, $inject, Alepha, TypeBoxError, TypeProvider, t } from "alepha";
|
|
2
|
+
import { type DateTime, DateTimeProvider } from "alepha/datetime";
|
|
3
|
+
import { $logger } from "alepha/logger";
|
|
4
|
+
import { $cookie } from "alepha/server/cookies";
|
|
5
|
+
import type { ServiceDictionary } from "../hooks/useI18n.ts";
|
|
6
|
+
|
|
7
|
+
export class I18nProvider<
|
|
8
|
+
S extends object,
|
|
9
|
+
K extends keyof ServiceDictionary<S>,
|
|
10
|
+
> {
|
|
11
|
+
protected logger = $logger();
|
|
12
|
+
protected alepha = $inject(Alepha);
|
|
13
|
+
protected dateTimeProvider = $inject(DateTimeProvider);
|
|
14
|
+
|
|
15
|
+
protected cookie = $cookie({
|
|
16
|
+
name: "lang",
|
|
17
|
+
schema: t.text(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
public readonly registry: Array<{
|
|
21
|
+
name: string;
|
|
22
|
+
lang: string;
|
|
23
|
+
loader: () => Promise<Record<string, string>>;
|
|
24
|
+
translations: Record<string, string>;
|
|
25
|
+
}> = [];
|
|
26
|
+
|
|
27
|
+
options = {
|
|
28
|
+
fallbackLang: "en",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
public dateFormat: { format: (value: Date) => string } =
|
|
32
|
+
new Intl.DateTimeFormat(this.lang);
|
|
33
|
+
|
|
34
|
+
public numberFormat: { format: (value: number) => string } =
|
|
35
|
+
new Intl.NumberFormat(this.lang);
|
|
36
|
+
|
|
37
|
+
public get languages() {
|
|
38
|
+
const languages = new Set<string>();
|
|
39
|
+
|
|
40
|
+
for (const item of this.registry) {
|
|
41
|
+
languages.add(item.lang);
|
|
42
|
+
}
|
|
43
|
+
languages.add(this.options.fallbackLang);
|
|
44
|
+
|
|
45
|
+
return Array.from(languages);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
constructor() {
|
|
49
|
+
this.refreshLocale();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
protected readonly onRender = $hook({
|
|
53
|
+
on: "server:onRequest",
|
|
54
|
+
priority: "last",
|
|
55
|
+
handler: async ({ request }) => {
|
|
56
|
+
this.alepha.state.set("alepha.react.i18n.lang", this.cookie.get(request));
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
protected readonly onStart = $hook({
|
|
61
|
+
on: "start",
|
|
62
|
+
handler: async () => {
|
|
63
|
+
if (this.alepha.isBrowser()) {
|
|
64
|
+
// get cookie lang
|
|
65
|
+
const cookieLang = this.cookie.get();
|
|
66
|
+
if (cookieLang) {
|
|
67
|
+
this.alepha.state.set("alepha.react.i18n.lang", cookieLang);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const item of this.registry) {
|
|
71
|
+
if (
|
|
72
|
+
item.lang === this.lang ||
|
|
73
|
+
item.lang === this.options.fallbackLang
|
|
74
|
+
) {
|
|
75
|
+
item.translations = await item.loader();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const item of this.registry) {
|
|
82
|
+
item.translations = await item.loader();
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
protected refreshLocale() {
|
|
88
|
+
this.numberFormat = new Intl.NumberFormat(this.lang);
|
|
89
|
+
this.dateFormat = new Intl.DateTimeFormat(this.lang);
|
|
90
|
+
this.dateTimeProvider.setLocale(this.lang);
|
|
91
|
+
TypeProvider.setLocale(this.lang);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public setLang = async (lang: string) => {
|
|
95
|
+
if (this.alepha.isBrowser()) {
|
|
96
|
+
for (const item of this.registry) {
|
|
97
|
+
if (lang === item.lang) {
|
|
98
|
+
if (Object.keys(item.translations).length > 0) {
|
|
99
|
+
continue; // already loaded
|
|
100
|
+
}
|
|
101
|
+
item.translations = await item.loader();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
this.cookie.set(lang);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.alepha.state.set("alepha.react.i18n.lang", lang);
|
|
108
|
+
this.refreshLocale();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
protected readonly mutate = $hook({
|
|
112
|
+
on: "state:mutate",
|
|
113
|
+
handler: async ({ key, value }) => {
|
|
114
|
+
if (key === "alepha.react.i18n.lang" && this.alepha.isBrowser()) {
|
|
115
|
+
let hasChanged = false;
|
|
116
|
+
for (const item of this.registry) {
|
|
117
|
+
if (value === item.lang) {
|
|
118
|
+
if (Object.keys(item.translations).length > 0) {
|
|
119
|
+
continue; // already loaded
|
|
120
|
+
}
|
|
121
|
+
item.translations = await item.loader();
|
|
122
|
+
hasChanged = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.refreshLocale();
|
|
127
|
+
|
|
128
|
+
if (hasChanged) {
|
|
129
|
+
this.alepha.state.set("alepha.react.i18n.lang", value);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
public get lang(): string {
|
|
136
|
+
return (
|
|
137
|
+
this.alepha.state.get("alepha.react.i18n.lang") ||
|
|
138
|
+
this.options.fallbackLang
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public translate = (key: string, args: string[] = []) => {
|
|
143
|
+
for (const item of this.registry) {
|
|
144
|
+
if (item.lang === this.lang) {
|
|
145
|
+
if (item.translations[key]) {
|
|
146
|
+
return this.render(item.translations[key], args); // append lang for fallback
|
|
147
|
+
} else {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const item of this.registry) {
|
|
154
|
+
if (item.lang === this.options.fallbackLang) {
|
|
155
|
+
if (item.translations[key]) {
|
|
156
|
+
return this.render(item.translations[key], args); // append lang for fallback
|
|
157
|
+
} else {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return key; // fallback to the key itself if not found
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
public readonly l = (
|
|
167
|
+
value: I18nLocalizeType,
|
|
168
|
+
options: I18nLocalizeOptions = {},
|
|
169
|
+
) => {
|
|
170
|
+
// Handle numbers
|
|
171
|
+
if (typeof value === "number") {
|
|
172
|
+
if (options.number) {
|
|
173
|
+
return new Intl.NumberFormat(this.lang, options.number).format(value);
|
|
174
|
+
}
|
|
175
|
+
return this.numberFormat.format(value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Handle dates
|
|
179
|
+
if (
|
|
180
|
+
value instanceof Date ||
|
|
181
|
+
this.dateTimeProvider.isDateTime(value) ||
|
|
182
|
+
(typeof value === "string" && options.date)
|
|
183
|
+
) {
|
|
184
|
+
// convert to DateTime with locale applied
|
|
185
|
+
let dt = this.dateTimeProvider.of(value);
|
|
186
|
+
|
|
187
|
+
// apply timezone if specified
|
|
188
|
+
if (options.timezone) {
|
|
189
|
+
dt = dt.tz(options.timezone);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// format using dayjs format string
|
|
193
|
+
if (typeof options.date === "string") {
|
|
194
|
+
if (options.date === "fromNow") {
|
|
195
|
+
return dt.locale(this.lang).fromNow();
|
|
196
|
+
}
|
|
197
|
+
return dt.locale(this.lang).format(options.date);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// format using Intl.DateTimeFormatOptions
|
|
201
|
+
if (options.date) {
|
|
202
|
+
return new Intl.DateTimeFormat(
|
|
203
|
+
this.lang,
|
|
204
|
+
options.timezone
|
|
205
|
+
? { ...options.date, timeZone: options.timezone }
|
|
206
|
+
: options.date,
|
|
207
|
+
).format(dt.toDate());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// default formatting with timezone
|
|
211
|
+
if (options.timezone) {
|
|
212
|
+
return new Intl.DateTimeFormat(this.lang, {
|
|
213
|
+
timeZone: options.timezone,
|
|
214
|
+
}).format(dt.toDate());
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// default formatting
|
|
218
|
+
return this.dateFormat.format(dt.toDate());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// handle TypeBox errors
|
|
222
|
+
if (value instanceof TypeBoxError) {
|
|
223
|
+
return TypeProvider.translateError(value, this.lang);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// return string values as-is
|
|
227
|
+
return value;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
public readonly tr = (
|
|
231
|
+
key: keyof ServiceDictionary<S>[K],
|
|
232
|
+
options: {
|
|
233
|
+
args?: string[];
|
|
234
|
+
default?: string;
|
|
235
|
+
} = {},
|
|
236
|
+
) => {
|
|
237
|
+
const translation = this.translate(key as string, options.args || []);
|
|
238
|
+
if (translation === key && options.default) {
|
|
239
|
+
return options.default;
|
|
240
|
+
}
|
|
241
|
+
return translation;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
protected render(item: string, args: string[]): string {
|
|
245
|
+
let result = item;
|
|
246
|
+
for (let i = 0; i < args.length; i++) {
|
|
247
|
+
result = result.replace(`$${i + 1}`, args[i]);
|
|
248
|
+
}
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export type I18nLocalizeType = string | number | Date | DateTime | TypeBoxError;
|
|
254
|
+
|
|
255
|
+
export interface I18nLocalizeOptions {
|
|
256
|
+
/**
|
|
257
|
+
* Options for number formatting (when value is a number)
|
|
258
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat
|
|
259
|
+
*/
|
|
260
|
+
number?: Intl.NumberFormatOptions;
|
|
261
|
+
/**
|
|
262
|
+
* Options for date formatting (when value is a Date or DateTime)
|
|
263
|
+
* Can be:
|
|
264
|
+
* - A dayjs format string (e.g., "LLL", "YYYY-MM-DD", "dddd, MMMM D YYYY")
|
|
265
|
+
* - "fromNow" for relative time (e.g., "2 hours ago")
|
|
266
|
+
* - Intl.DateTimeFormatOptions for native formatting
|
|
267
|
+
* @see https://day.js.org/docs/en/display/format
|
|
268
|
+
* @see https://day.js.org/docs/en/display/from-now
|
|
269
|
+
*/
|
|
270
|
+
date?: string | "fromNow" | Intl.DateTimeFormatOptions;
|
|
271
|
+
/**
|
|
272
|
+
* Timezone to display dates in (when value is a Date or DateTime)
|
|
273
|
+
* Uses IANA timezone names (e.g., "America/New_York", "Europe/Paris", "Asia/Tokyo")
|
|
274
|
+
* @see https://day.js.org/docs/en/timezone/timezone
|
|
275
|
+
*/
|
|
276
|
+
timezone?: string;
|
|
277
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useInject } from "@alepha/react";
|
|
2
|
+
import type { Static } from "alepha";
|
|
3
|
+
import type { ChannelDescriptor, TWSObject } from "alepha/websocket";
|
|
4
|
+
import { WebSocketClient } from "alepha/websocket";
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* UseRoom options
|
|
9
|
+
*/
|
|
10
|
+
export interface UseRoomOptions<
|
|
11
|
+
TClient extends TWSObject,
|
|
12
|
+
TServer extends TWSObject,
|
|
13
|
+
> {
|
|
14
|
+
/**
|
|
15
|
+
* Room ID to connect to
|
|
16
|
+
*/
|
|
17
|
+
roomId: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Channel descriptor defining the schemas
|
|
21
|
+
*/
|
|
22
|
+
channel: ChannelDescriptor<TClient, TServer>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Handler for incoming messages from the server
|
|
26
|
+
*/
|
|
27
|
+
handler: (message: Static<TClient>) => void;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Optional WebSocket URL override
|
|
31
|
+
* Defaults to auto-detected URL based on window.location
|
|
32
|
+
*/
|
|
33
|
+
url?: string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Enable automatic reconnection on disconnect
|
|
37
|
+
* @default true
|
|
38
|
+
*/
|
|
39
|
+
autoReconnect?: boolean;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reconnection interval in milliseconds
|
|
43
|
+
* @default 3000
|
|
44
|
+
*/
|
|
45
|
+
reconnectInterval?: number;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Maximum reconnection attempts (-1 for infinite)
|
|
49
|
+
* @default 10
|
|
50
|
+
*/
|
|
51
|
+
maxReconnectAttempts?: number;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Called when connection is established
|
|
55
|
+
*/
|
|
56
|
+
onConnect?: () => void;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Called when connection is closed
|
|
60
|
+
*/
|
|
61
|
+
onDisconnect?: () => void;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Called on connection error
|
|
65
|
+
*/
|
|
66
|
+
onError?: (error: Error) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* UseRoom return value
|
|
71
|
+
*/
|
|
72
|
+
export interface UseRoomReturn<TServer extends TWSObject> {
|
|
73
|
+
/**
|
|
74
|
+
* Send a message to the server
|
|
75
|
+
*/
|
|
76
|
+
send: (message: Static<TServer>) => Promise<void>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether the connection is established
|
|
80
|
+
*/
|
|
81
|
+
isConnected: boolean;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether the connection is in progress
|
|
85
|
+
*/
|
|
86
|
+
isConnecting: boolean;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether there was an error
|
|
90
|
+
*/
|
|
91
|
+
isError: boolean;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The error object if any
|
|
95
|
+
*/
|
|
96
|
+
error?: Error;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Manually reconnect
|
|
100
|
+
*/
|
|
101
|
+
reconnect: () => void;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Manually disconnect
|
|
105
|
+
*/
|
|
106
|
+
disconnect: () => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* React hook for WebSocket room communication
|
|
111
|
+
*
|
|
112
|
+
* Provides automatic connection management, reconnection, and type-safe messaging
|
|
113
|
+
* for WebSocket rooms using the injected WebSocketClient service.
|
|
114
|
+
*
|
|
115
|
+
* Multiple useRoom hooks on the same channel will share a single WebSocket connection.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```tsx
|
|
119
|
+
* const chat = useRoom({
|
|
120
|
+
* roomId: "room-123",
|
|
121
|
+
* channel: chatChannel,
|
|
122
|
+
* handler: (message) => {
|
|
123
|
+
* if (message.type === "append") {
|
|
124
|
+
* setMessages(prev => [...prev, message]);
|
|
125
|
+
* }
|
|
126
|
+
* }
|
|
127
|
+
* }, [roomId]);
|
|
128
|
+
*
|
|
129
|
+
* const sendMessage = async () => {
|
|
130
|
+
* await chat.send({
|
|
131
|
+
* content: "Hello, world!"
|
|
132
|
+
* });
|
|
133
|
+
* };
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export const useRoom = <TClient extends TWSObject, TServer extends TWSObject>(
|
|
137
|
+
options: UseRoomOptions<TClient, TServer>,
|
|
138
|
+
deps: unknown[],
|
|
139
|
+
): UseRoomReturn<TServer> => {
|
|
140
|
+
const webSocketClient = useInject(WebSocketClient);
|
|
141
|
+
const unsubscribeRef = useRef<(() => void) | null>(null);
|
|
142
|
+
|
|
143
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
144
|
+
const [isConnecting, setIsConnecting] = useState(false);
|
|
145
|
+
const [isError, setIsError] = useState(false);
|
|
146
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
147
|
+
|
|
148
|
+
const {
|
|
149
|
+
roomId,
|
|
150
|
+
channel,
|
|
151
|
+
handler,
|
|
152
|
+
url,
|
|
153
|
+
autoReconnect,
|
|
154
|
+
reconnectInterval,
|
|
155
|
+
maxReconnectAttempts,
|
|
156
|
+
onConnect,
|
|
157
|
+
onDisconnect,
|
|
158
|
+
onError,
|
|
159
|
+
} = options;
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
// Subscribe to room
|
|
163
|
+
const unsubscribe = webSocketClient.subscribe(roomId, channel, handler, {
|
|
164
|
+
url,
|
|
165
|
+
autoReconnect,
|
|
166
|
+
reconnectInterval,
|
|
167
|
+
maxReconnectAttempts,
|
|
168
|
+
onConnect: () => {
|
|
169
|
+
setIsConnected(true);
|
|
170
|
+
setIsConnecting(false);
|
|
171
|
+
setIsError(false);
|
|
172
|
+
setError(undefined);
|
|
173
|
+
onConnect?.();
|
|
174
|
+
},
|
|
175
|
+
onDisconnect: () => {
|
|
176
|
+
setIsConnected(false);
|
|
177
|
+
setIsConnecting(false);
|
|
178
|
+
onDisconnect?.();
|
|
179
|
+
},
|
|
180
|
+
onError: (err) => {
|
|
181
|
+
setIsError(true);
|
|
182
|
+
setError(err);
|
|
183
|
+
setIsConnecting(false);
|
|
184
|
+
onError?.(err);
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
unsubscribeRef.current = unsubscribe;
|
|
189
|
+
|
|
190
|
+
// Get initial state from connection
|
|
191
|
+
const connection = webSocketClient.getConnection(channel);
|
|
192
|
+
if (connection) {
|
|
193
|
+
setIsConnected(connection.isConnected);
|
|
194
|
+
setIsConnecting(connection.isConnecting);
|
|
195
|
+
setIsError(connection.isError);
|
|
196
|
+
setError(connection.error);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Cleanup on unmount or deps change
|
|
200
|
+
return () => {
|
|
201
|
+
unsubscribe();
|
|
202
|
+
unsubscribeRef.current = null;
|
|
203
|
+
};
|
|
204
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
205
|
+
}, deps);
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
send: async (message: Static<TServer>) => {
|
|
209
|
+
await webSocketClient.send(roomId, channel, message);
|
|
210
|
+
},
|
|
211
|
+
isConnected,
|
|
212
|
+
isConnecting,
|
|
213
|
+
isError,
|
|
214
|
+
error,
|
|
215
|
+
reconnect: () => {
|
|
216
|
+
const connection = webSocketClient.getConnection(channel);
|
|
217
|
+
connection?.reconnect();
|
|
218
|
+
},
|
|
219
|
+
disconnect: () => {
|
|
220
|
+
unsubscribeRef.current?.();
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
};
|