@pylonsync/react 0.2.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/package.json +24 -0
- package/src/db.ts +218 -0
- package/src/hooks.ts +922 -0
- package/src/index.ts +610 -0
- package/src/typed.ts +149 -0
- package/src/useRoom.ts +231 -0
- package/src/useSession.ts +71 -0
- package/src/useShard.ts +299 -0
- package/tsconfig.json +7 -0
package/src/typed.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed database client — narrow pylon's untyped `db` object using your
|
|
3
|
+
* generated `AppSchema` (from `pylon codegen client`).
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* // src/pylon.client.ts (generated by `pylon codegen client`)
|
|
8
|
+
* export interface AppSchema {
|
|
9
|
+
* entities: { Todo: Todo; User: User };
|
|
10
|
+
* functions: {
|
|
11
|
+
* placeBid: { input: { lotId: string; amount: number }; output: { accepted: boolean } };
|
|
12
|
+
* };
|
|
13
|
+
* queries: {};
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* // src/db.ts
|
|
17
|
+
* import { createTypedDb } from "@pylonsync/react";
|
|
18
|
+
* import type { AppSchema } from "./pylon.client";
|
|
19
|
+
* export const db = createTypedDb<AppSchema>();
|
|
20
|
+
*
|
|
21
|
+
* // Components get full type safety:
|
|
22
|
+
* const { data } = db.useQuery("Todo"); // data: Todo[]
|
|
23
|
+
* const { data: user } = db.useQueryOne("User", "u_1"); // user: User | null
|
|
24
|
+
* const bid = db.useMutation("placeBid"); // typed args + result
|
|
25
|
+
* await bid.mutate({ lotId: "x", amount: 150 }); // TS checks input type
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { Row } from "@pylonsync/sync";
|
|
30
|
+
import { db as untypedDb } from "./db";
|
|
31
|
+
import type {
|
|
32
|
+
QueryOptions,
|
|
33
|
+
UseQueryReturn,
|
|
34
|
+
UseQueryOneReturn,
|
|
35
|
+
UseMutationReturn,
|
|
36
|
+
UseInfiniteQueryReturn,
|
|
37
|
+
} from "./hooks";
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Schema shape
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Shape of the generated `AppSchema` interface.
|
|
45
|
+
*
|
|
46
|
+
* The CLI's `client_codegen` emits a type matching this shape. Consumers
|
|
47
|
+
* never construct `AppSchema` manually.
|
|
48
|
+
*/
|
|
49
|
+
export interface AgentDBSchema {
|
|
50
|
+
entities: Record<string, unknown>;
|
|
51
|
+
functions: Record<string, { input: unknown; output: unknown }>;
|
|
52
|
+
queries: Record<string, { input: unknown; output: unknown }>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// TypedDb — mirrors `db` but with generics keyed on the schema
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export interface TypedDb<S extends AgentDBSchema> {
|
|
60
|
+
/** Live query with type inferred from the schema. */
|
|
61
|
+
useQuery<K extends keyof S["entities"]>(
|
|
62
|
+
entity: K,
|
|
63
|
+
options?: QueryOptions
|
|
64
|
+
): UseQueryReturn<S["entities"][K]>;
|
|
65
|
+
|
|
66
|
+
/** Live single-row query. */
|
|
67
|
+
useQueryOne<K extends keyof S["entities"]>(
|
|
68
|
+
entity: K,
|
|
69
|
+
id: string
|
|
70
|
+
): UseQueryOneReturn<S["entities"][K]>;
|
|
71
|
+
|
|
72
|
+
/** Server-side function call with typed input/output. */
|
|
73
|
+
useMutation<K extends keyof S["functions"]>(
|
|
74
|
+
fnName: K
|
|
75
|
+
): UseMutationReturn<S["functions"][K]["input"], S["functions"][K]["output"]>;
|
|
76
|
+
|
|
77
|
+
/** Paginated live query. */
|
|
78
|
+
useInfiniteQuery<K extends keyof S["entities"]>(
|
|
79
|
+
entity: K,
|
|
80
|
+
options?: { pageSize?: number }
|
|
81
|
+
): UseInfiniteQueryReturn<S["entities"][K]>;
|
|
82
|
+
|
|
83
|
+
/** Call a server-side function directly (outside React). */
|
|
84
|
+
fn<K extends keyof S["functions"]>(
|
|
85
|
+
name: K,
|
|
86
|
+
args: S["functions"][K]["input"]
|
|
87
|
+
): Promise<S["functions"][K]["output"]>;
|
|
88
|
+
|
|
89
|
+
/** Insert a row (optimistic). */
|
|
90
|
+
insert<K extends keyof S["entities"]>(
|
|
91
|
+
entity: K,
|
|
92
|
+
data: Partial<S["entities"][K]>
|
|
93
|
+
): unknown;
|
|
94
|
+
|
|
95
|
+
/** Update a row (optimistic). */
|
|
96
|
+
update<K extends keyof S["entities"]>(
|
|
97
|
+
entity: K,
|
|
98
|
+
id: string,
|
|
99
|
+
data: Partial<S["entities"][K]>
|
|
100
|
+
): unknown;
|
|
101
|
+
|
|
102
|
+
/** Delete a row (optimistic). */
|
|
103
|
+
delete<K extends keyof S["entities"]>(entity: K, id: string): unknown;
|
|
104
|
+
|
|
105
|
+
/** Underlying untyped db (escape hatch). */
|
|
106
|
+
readonly untyped: typeof untypedDb;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create a typed client from a generated `AppSchema`.
|
|
111
|
+
*
|
|
112
|
+
* At runtime this is literally the same object as `db`; types are narrowed
|
|
113
|
+
* at compile time via generics.
|
|
114
|
+
*/
|
|
115
|
+
export function createTypedDb<S extends AgentDBSchema>(): TypedDb<S> {
|
|
116
|
+
const d = untypedDb as unknown as {
|
|
117
|
+
useQuery: <T>(entity: string, options?: QueryOptions) => UseQueryReturn<T>;
|
|
118
|
+
useQueryOne: <T>(entity: string, id: string) => UseQueryOneReturn<T>;
|
|
119
|
+
useMutation: <A, R>(fn: string) => UseMutationReturn<A, R>;
|
|
120
|
+
useInfiniteQuery: <T>(
|
|
121
|
+
entity: string,
|
|
122
|
+
options?: { pageSize?: number }
|
|
123
|
+
) => UseInfiniteQueryReturn<T>;
|
|
124
|
+
fn: <R>(name: string, args?: Record<string, unknown>) => Promise<R>;
|
|
125
|
+
insert: (entity: string, data: Row) => unknown;
|
|
126
|
+
update: (entity: string, id: string, data: Partial<Row>) => unknown;
|
|
127
|
+
delete: (entity: string, id: string) => unknown;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
useQuery: ((entity, options) =>
|
|
132
|
+
d.useQuery(entity as string, options)) as TypedDb<S>["useQuery"],
|
|
133
|
+
useQueryOne: ((entity, id) =>
|
|
134
|
+
d.useQueryOne(entity as string, id)) as TypedDb<S>["useQueryOne"],
|
|
135
|
+
useMutation: ((fnName) =>
|
|
136
|
+
d.useMutation(fnName as string)) as TypedDb<S>["useMutation"],
|
|
137
|
+
useInfiniteQuery: ((entity, options) =>
|
|
138
|
+
d.useInfiniteQuery(entity as string, options)) as TypedDb<S>["useInfiniteQuery"],
|
|
139
|
+
fn: ((name, args) =>
|
|
140
|
+
d.fn(name as string, args as Record<string, unknown>)) as TypedDb<S>["fn"],
|
|
141
|
+
insert: ((entity, data) =>
|
|
142
|
+
d.insert(entity as string, data as Row)) as TypedDb<S>["insert"],
|
|
143
|
+
update: ((entity, id, data) =>
|
|
144
|
+
d.update(entity as string, id, data as Partial<Row>)) as TypedDb<S>["update"],
|
|
145
|
+
delete: ((entity, id) =>
|
|
146
|
+
d.delete(entity as string, id)) as TypedDb<S>["delete"],
|
|
147
|
+
untyped: untypedDb,
|
|
148
|
+
};
|
|
149
|
+
}
|
package/src/useRoom.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { getBaseUrl, getReactStorage, storageKey } from './index';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Room types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface RoomPeer {
|
|
9
|
+
user_id: string;
|
|
10
|
+
data: any;
|
|
11
|
+
joined_at: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RoomSnapshot {
|
|
15
|
+
room: string;
|
|
16
|
+
peers: RoomPeer[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseRoomOptions {
|
|
20
|
+
/** Base URL of the pylon server. */
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
/** Auth token for API requests. */
|
|
23
|
+
token?: string;
|
|
24
|
+
/** Initial presence data sent on join. */
|
|
25
|
+
initialPresence?: Record<string, any>;
|
|
26
|
+
/** How often to poll for peer updates (ms). Defaults to 5 000. */
|
|
27
|
+
heartbeatInterval?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UseRoomReturn {
|
|
31
|
+
/** Current peers in the room (excluding self). */
|
|
32
|
+
peers: RoomPeer[];
|
|
33
|
+
/** Whether currently connected to the room. */
|
|
34
|
+
isConnected: boolean;
|
|
35
|
+
/** Update your presence data (e.g. cursor position, typing status). */
|
|
36
|
+
setPresence: (data: Record<string, any>) => void;
|
|
37
|
+
/** Broadcast a message to the room on a given topic. */
|
|
38
|
+
broadcast: (topic: string, data: any) => void;
|
|
39
|
+
/** Leave the room manually. */
|
|
40
|
+
leave: () => void;
|
|
41
|
+
/** Error message, if any. */
|
|
42
|
+
error: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Hook
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Subscribe to a real-time room. Joins on mount, leaves on unmount, and
|
|
51
|
+
* polls for peer updates on a configurable interval.
|
|
52
|
+
*
|
|
53
|
+
* ```tsx
|
|
54
|
+
* const { peers, isConnected, setPresence, broadcast, leave, error } = useRoom(
|
|
55
|
+
* "project-42",
|
|
56
|
+
* currentUser.id,
|
|
57
|
+
* { baseUrl: "http://localhost:4321", token }
|
|
58
|
+
* );
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* Read the current pylon token from the configured storage adapter
|
|
63
|
+
* (default: localStorage on web, AsyncStorage on RN, etc). Keeps the
|
|
64
|
+
* hook working even when the caller doesn't explicitly thread a token
|
|
65
|
+
* — otherwise every useRoom request hits the server as anonymous and
|
|
66
|
+
* 401s under any authenticated room policy.
|
|
67
|
+
*/
|
|
68
|
+
function readStoredToken(): string | undefined {
|
|
69
|
+
return getReactStorage().get(storageKey('token')) ?? undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function useRoom(
|
|
73
|
+
roomId: string,
|
|
74
|
+
userId: string,
|
|
75
|
+
options: UseRoomOptions = {},
|
|
76
|
+
): UseRoomReturn {
|
|
77
|
+
const {
|
|
78
|
+
// Fall back to the globally configured baseUrl so room requests don't
|
|
79
|
+
// land on the Vite dev origin (localhost:5173) and 404 when the caller
|
|
80
|
+
// forgets to pass one.
|
|
81
|
+
baseUrl = getBaseUrl(),
|
|
82
|
+
token: explicitToken,
|
|
83
|
+
initialPresence = {},
|
|
84
|
+
heartbeatInterval = 5_000,
|
|
85
|
+
} = options;
|
|
86
|
+
// Resolve at render time rather than hook-creation time so the room
|
|
87
|
+
// reconnects with a fresh token after login.
|
|
88
|
+
const token = explicitToken ?? readStoredToken();
|
|
89
|
+
|
|
90
|
+
const [peers, setPeers] = useState<RoomPeer[]>([]);
|
|
91
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
92
|
+
const [error, setError] = useState<string | null>(null);
|
|
93
|
+
|
|
94
|
+
const presenceRef = useRef(initialPresence);
|
|
95
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
96
|
+
|
|
97
|
+
// Stable header builder -- only changes when `token` changes.
|
|
98
|
+
const headers = useCallback((): Record<string, string> => {
|
|
99
|
+
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
100
|
+
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
101
|
+
return h;
|
|
102
|
+
}, [token]);
|
|
103
|
+
|
|
104
|
+
// ------- lifecycle: join / heartbeat / leave -------
|
|
105
|
+
//
|
|
106
|
+
// React StrictMode double-mounts every effect in dev: mount → unmount →
|
|
107
|
+
// re-mount. The `joined` ref tracks whether the join() call actually
|
|
108
|
+
// landed before the cleanup ran. If join hadn't completed yet, the
|
|
109
|
+
// cleanup skips leave entirely — there's nothing to leave on the
|
|
110
|
+
// server's side. If join did land, leave fires normally. Server-side
|
|
111
|
+
// leave is also idempotent now (200 with was_present:false on
|
|
112
|
+
// duplicate), so even a race on this can't error in the network tab.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
let mounted = true;
|
|
115
|
+
let joined = false;
|
|
116
|
+
|
|
117
|
+
const join = async () => {
|
|
118
|
+
try {
|
|
119
|
+
const res = await fetch(`${baseUrl}/api/rooms/join`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: headers(),
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
room: roomId,
|
|
124
|
+
user_id: userId,
|
|
125
|
+
data: presenceRef.current,
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
const body = await res.json();
|
|
129
|
+
if (!mounted) return;
|
|
130
|
+
|
|
131
|
+
if (res.ok) {
|
|
132
|
+
joined = true;
|
|
133
|
+
setIsConnected(true);
|
|
134
|
+
setError(null);
|
|
135
|
+
if (body.snapshot?.peers) {
|
|
136
|
+
setPeers(
|
|
137
|
+
(body.snapshot.peers as RoomPeer[]).filter(
|
|
138
|
+
(p) => p.user_id !== userId,
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
setError(body.error?.message || 'Failed to join room');
|
|
144
|
+
}
|
|
145
|
+
} catch (e: any) {
|
|
146
|
+
if (mounted) setError(e.message);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
join();
|
|
151
|
+
|
|
152
|
+
// Poll for peer list updates.
|
|
153
|
+
intervalRef.current = setInterval(async () => {
|
|
154
|
+
if (!mounted) return;
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch(
|
|
157
|
+
`${baseUrl}/api/rooms/${encodeURIComponent(roomId)}`,
|
|
158
|
+
{ headers: headers() },
|
|
159
|
+
);
|
|
160
|
+
if (res.ok) {
|
|
161
|
+
const body = await res.json();
|
|
162
|
+
if (mounted) {
|
|
163
|
+
setPeers(
|
|
164
|
+
((body.members ?? []) as RoomPeer[]).filter(
|
|
165
|
+
(p) => p.user_id !== userId,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// Swallow -- next heartbeat will retry.
|
|
172
|
+
}
|
|
173
|
+
}, heartbeatInterval);
|
|
174
|
+
|
|
175
|
+
return () => {
|
|
176
|
+
mounted = false;
|
|
177
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
178
|
+
|
|
179
|
+
// Skip the leave call when join never completed — fixes the
|
|
180
|
+
// StrictMode double-mount race that produced spurious "user
|
|
181
|
+
// not in this room" errors. Server leave is also idempotent so
|
|
182
|
+
// a stray duplicate would 200 anyway, but we save the round trip.
|
|
183
|
+
if (joined) {
|
|
184
|
+
fetch(`${baseUrl}/api/rooms/leave`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: headers(),
|
|
187
|
+
body: JSON.stringify({ room: roomId, user_id: userId }),
|
|
188
|
+
}).catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
// Re-run the entire lifecycle when identity or connection details change.
|
|
192
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
193
|
+
}, [roomId, userId, baseUrl, token, heartbeatInterval]);
|
|
194
|
+
|
|
195
|
+
// ------- actions -------
|
|
196
|
+
|
|
197
|
+
const setPresence = useCallback(
|
|
198
|
+
(data: Record<string, any>) => {
|
|
199
|
+
presenceRef.current = data;
|
|
200
|
+
fetch(`${baseUrl}/api/rooms/presence`, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: headers(),
|
|
203
|
+
body: JSON.stringify({ room: roomId, user_id: userId, data }),
|
|
204
|
+
}).catch(() => {});
|
|
205
|
+
},
|
|
206
|
+
[roomId, userId, baseUrl, headers],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const broadcast = useCallback(
|
|
210
|
+
(topic: string, data: any) => {
|
|
211
|
+
fetch(`${baseUrl}/api/rooms/broadcast`, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: headers(),
|
|
214
|
+
body: JSON.stringify({ room: roomId, user_id: userId, topic, data }),
|
|
215
|
+
}).catch(() => {});
|
|
216
|
+
},
|
|
217
|
+
[roomId, userId, baseUrl, headers],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const leave = useCallback(() => {
|
|
221
|
+
fetch(`${baseUrl}/api/rooms/leave`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: headers(),
|
|
224
|
+
body: JSON.stringify({ room: roomId, user_id: userId }),
|
|
225
|
+
}).catch(() => {});
|
|
226
|
+
setIsConnected(false);
|
|
227
|
+
setPeers([]);
|
|
228
|
+
}, [roomId, userId, baseUrl, headers]);
|
|
229
|
+
|
|
230
|
+
return { peers, isConnected, setPresence, broadcast, leave, error };
|
|
231
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { SyncEngine, type ResolvedSession } from "@pylonsync/sync";
|
|
2
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
3
|
+
|
|
4
|
+
export type { ResolvedSession };
|
|
5
|
+
|
|
6
|
+
export interface UseSessionReturn {
|
|
7
|
+
/** Server-resolved session. `userId=null` means anonymous. */
|
|
8
|
+
session: ResolvedSession;
|
|
9
|
+
/** Convenience accessors. */
|
|
10
|
+
userId: string | null;
|
|
11
|
+
tenantId: string | null;
|
|
12
|
+
isAdmin: boolean;
|
|
13
|
+
isAuthenticated: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Force a refresh of the cached session. Call after sign-in, sign-out,
|
|
16
|
+
* or `/api/auth/select-org` so the UI and sync engine pick up the
|
|
17
|
+
* change before the next pull/reconnect.
|
|
18
|
+
*/
|
|
19
|
+
refresh: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Subscribe to the server-resolved session held by the sync engine.
|
|
24
|
+
*
|
|
25
|
+
* The engine fetches `/api/auth/me` on start and on token flips, caches
|
|
26
|
+
* the result, and notifies the store on change — so this hook is
|
|
27
|
+
* purely a reader. Mutations (login/logout/select-org) are still the
|
|
28
|
+
* caller's responsibility; after them, invoke `refresh()` (or
|
|
29
|
+
* `engine.notifySessionChanged()`) to pull the new session immediately.
|
|
30
|
+
*/
|
|
31
|
+
export function useSession(sync: SyncEngine): UseSessionReturn {
|
|
32
|
+
const session = useSyncExternalStore(
|
|
33
|
+
(cb) => sync.store.subscribe(cb),
|
|
34
|
+
() => sync.resolvedSession(),
|
|
35
|
+
() => sync.resolvedSession(),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Watch the localStorage token key. If another tab signs in/out, or the
|
|
39
|
+
// app writes a new token without going through `notifySessionChanged`,
|
|
40
|
+
// our cached session still matches the old identity until the next
|
|
41
|
+
// pull. A `storage` event covers the cross-tab case; a mount-time
|
|
42
|
+
// refresh covers the same-tab write-then-mount race.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
void sync.notifySessionChanged();
|
|
45
|
+
const onStorage = (e: StorageEvent) => {
|
|
46
|
+
// Only refresh when the key that actually changed looks like a
|
|
47
|
+
// pylon token. Skip unrelated keys so we don't generate an
|
|
48
|
+
// /api/auth/me flood from noisy apps.
|
|
49
|
+
if (!e.key) return;
|
|
50
|
+
if (e.key.startsWith("pylon_") || e.key.startsWith("pylon:")) {
|
|
51
|
+
if (e.key.endsWith("token") || e.key.endsWith(":token")) {
|
|
52
|
+
void sync.notifySessionChanged();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
if (typeof window !== "undefined") {
|
|
57
|
+
window.addEventListener("storage", onStorage);
|
|
58
|
+
return () => window.removeEventListener("storage", onStorage);
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}, [sync]);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
session,
|
|
65
|
+
userId: session.userId,
|
|
66
|
+
tenantId: session.tenantId,
|
|
67
|
+
isAdmin: session.isAdmin,
|
|
68
|
+
isAuthenticated: session.userId != null,
|
|
69
|
+
refresh: () => sync.notifySessionChanged(),
|
|
70
|
+
};
|
|
71
|
+
}
|