@poly-x/react 0.1.0-alpha.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 +51 -0
- package/dist/index.cjs +396 -0
- package/dist/index.d.cts +220 -0
- package/dist/index.d.mts +220 -0
- package/dist/index.mjs +378 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @poly-x/react
|
|
2
|
+
|
|
3
|
+
PolyX authentication for any React app — a provider, hooks, and drop-in sign-in
|
|
4
|
+
components. On brand, secure by default, with cross-tab session sync and silent
|
|
5
|
+
refresh handled for you.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @poly-x/react
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
Wrap your app with the provider (a `pk_…` publishable key is all it needs):
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { PolyXProvider } from "@poly-x/react";
|
|
19
|
+
|
|
20
|
+
export function Root({ children }) {
|
|
21
|
+
return <PolyXProvider publishableKey={import.meta.env.VITE_POLYX_PUBLISHABLE_KEY}>{children}</PolyXProvider>;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Drop in sign-in and gate content:
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { SignIn, Protected, useAuth, useUser } from "@poly-x/react";
|
|
29
|
+
|
|
30
|
+
function Header() {
|
|
31
|
+
const { isSignedIn, signOut } = useAuth();
|
|
32
|
+
const user = useUser();
|
|
33
|
+
return isSignedIn ? <button onClick={() => signOut()}>Sign out {user?.userId}</button> : <SignIn />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Dashboard() {
|
|
37
|
+
return <Protected fallback={<SignIn />} loading={<Spinner />}><SecretStuff /></Protected>;
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Add a callback route (`/auth/callback`) that renders `<AuthCallback />` to
|
|
42
|
+
complete sign-in.
|
|
43
|
+
|
|
44
|
+
## API
|
|
45
|
+
|
|
46
|
+
`PolyXProvider` · `useAuth` (`isLoaded`, `isSignedIn`, `signIn`, `signOut`,
|
|
47
|
+
`getToken`) · `useUser` · `useSession` · `<SignIn>` · `<Protected>` ·
|
|
48
|
+
`<AuthCallback>`. Sign-in defaults to a popup; pass `mode="redirect"` for a
|
|
49
|
+
full-page flow.
|
|
50
|
+
|
|
51
|
+
MIT licensed.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
let _poly_x_core = require("@poly-x/core");
|
|
4
|
+
let react = require("react");
|
|
5
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
6
|
+
//#region src/auth-channel.ts
|
|
7
|
+
var BroadcastAuthChannel = class {
|
|
8
|
+
supported;
|
|
9
|
+
channel;
|
|
10
|
+
listeners = /* @__PURE__ */ new Set();
|
|
11
|
+
constructor(name = "poly-x.auth") {
|
|
12
|
+
this.supported = typeof BroadcastChannel !== "undefined";
|
|
13
|
+
if (this.supported) {
|
|
14
|
+
this.channel = new BroadcastChannel(name);
|
|
15
|
+
this.channel.onmessage = (event) => {
|
|
16
|
+
const data = event.data;
|
|
17
|
+
if (data?.type === "auth-callback") for (const listener of [...this.listeners]) listener(data);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
post(message) {
|
|
22
|
+
this.channel?.postMessage(message);
|
|
23
|
+
}
|
|
24
|
+
subscribe(listener) {
|
|
25
|
+
this.listeners.add(listener);
|
|
26
|
+
return () => this.listeners.delete(listener);
|
|
27
|
+
}
|
|
28
|
+
close() {
|
|
29
|
+
this.channel?.close();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/browser-bridge.ts
|
|
34
|
+
var WindowBrowserBridge = class {
|
|
35
|
+
openPopup(url, name = "polyx-auth") {
|
|
36
|
+
const win = window.open(url, name, "width=480,height=680");
|
|
37
|
+
if (!win) return null;
|
|
38
|
+
return {
|
|
39
|
+
get closed() {
|
|
40
|
+
return win.closed;
|
|
41
|
+
},
|
|
42
|
+
close: () => win.close()
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
redirect(url) {
|
|
46
|
+
window.location.assign(url);
|
|
47
|
+
}
|
|
48
|
+
isPopupContext() {
|
|
49
|
+
return typeof window !== "undefined" && window.opener != null && window.opener !== window;
|
|
50
|
+
}
|
|
51
|
+
readCallbackParams() {
|
|
52
|
+
const params = new URLSearchParams(window.location.search);
|
|
53
|
+
return {
|
|
54
|
+
code: params.get("code") ?? void 0,
|
|
55
|
+
state: params.get("state") ?? void 0
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
currentUrl() {
|
|
59
|
+
return window.location.href;
|
|
60
|
+
}
|
|
61
|
+
currentOrigin() {
|
|
62
|
+
return window.location.origin;
|
|
63
|
+
}
|
|
64
|
+
replaceUrl(url) {
|
|
65
|
+
window.history.replaceState({}, "", url);
|
|
66
|
+
}
|
|
67
|
+
closeSelf() {
|
|
68
|
+
window.close();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/context.ts
|
|
73
|
+
const PolyXContext = (0, react.createContext)(null);
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/login-controller.ts
|
|
76
|
+
/**
|
|
77
|
+
* Sign-in orchestration (F006), framework-agnostic within the browser: it
|
|
78
|
+
* composes the auth client, PKCE store, browser bridge, and auth channel to run
|
|
79
|
+
* both delivery modes (popup default + redirect, D2b). All decision logic lives
|
|
80
|
+
* here; the `window` specifics are behind the BrowserBridge seam.
|
|
81
|
+
*/
|
|
82
|
+
var PopupCancelledError = class extends Error {
|
|
83
|
+
constructor() {
|
|
84
|
+
super("Sign-in was cancelled before it completed.");
|
|
85
|
+
this.name = "PopupCancelledError";
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
function createLoginController(deps) {
|
|
89
|
+
const { engine, authClient, pkceStore, bridge, channel } = deps;
|
|
90
|
+
const pollMs = deps.popupPollMs ?? 300;
|
|
91
|
+
async function complete(code, state) {
|
|
92
|
+
const entry = await pkceStore.take(state);
|
|
93
|
+
if (!entry) throw new Error("Sign-in state did not match a pending request (expired or replayed). Please retry.");
|
|
94
|
+
const stored = await authClient.exchangeCode({
|
|
95
|
+
code,
|
|
96
|
+
codeVerifier: entry.verifier,
|
|
97
|
+
redirectUri: entry.redirectUri
|
|
98
|
+
});
|
|
99
|
+
await engine.establishSession(stored);
|
|
100
|
+
}
|
|
101
|
+
async function signIn(options = {}) {
|
|
102
|
+
const mode = options.mode ?? "popup";
|
|
103
|
+
const redirectUri = options.redirectUrl ?? `${bridge.currentOrigin()}/auth/callback`;
|
|
104
|
+
const { verifier, challenge } = await (0, _poly_x_core.generatePkce)();
|
|
105
|
+
const state = (0, _poly_x_core.randomState)();
|
|
106
|
+
await pkceStore.save({
|
|
107
|
+
state,
|
|
108
|
+
verifier,
|
|
109
|
+
redirectUri,
|
|
110
|
+
createdAt: Date.now()
|
|
111
|
+
});
|
|
112
|
+
const url = authClient.buildAuthorizeUrl({
|
|
113
|
+
redirectUri,
|
|
114
|
+
state,
|
|
115
|
+
challenge,
|
|
116
|
+
organizationCode: options.organizationCode
|
|
117
|
+
});
|
|
118
|
+
if (mode === "redirect") {
|
|
119
|
+
bridge.redirect(url);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!channel.supported) throw new Error("Popup sign-in requires BroadcastChannel; use mode: \"redirect\" instead.");
|
|
123
|
+
const popup = bridge.openPopup(url);
|
|
124
|
+
if (!popup) throw new Error("The sign-in popup was blocked. Use mode: \"redirect\" instead.");
|
|
125
|
+
await new Promise((resolve, reject) => {
|
|
126
|
+
let settled = false;
|
|
127
|
+
const finish = (fn) => {
|
|
128
|
+
if (settled) return;
|
|
129
|
+
settled = true;
|
|
130
|
+
unsubscribe();
|
|
131
|
+
clearInterval(poll);
|
|
132
|
+
fn();
|
|
133
|
+
};
|
|
134
|
+
const unsubscribe = channel.subscribe((message) => {
|
|
135
|
+
if (message.state !== state) return;
|
|
136
|
+
complete(message.code, message.state).then(() => finish(() => {
|
|
137
|
+
popup.close();
|
|
138
|
+
resolve();
|
|
139
|
+
}), (error) => finish(() => reject(error)));
|
|
140
|
+
});
|
|
141
|
+
const poll = setInterval(() => {
|
|
142
|
+
if (popup.closed) finish(() => reject(new PopupCancelledError()));
|
|
143
|
+
}, pollMs);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function handleCallback() {
|
|
147
|
+
const { code, state } = bridge.readCallbackParams();
|
|
148
|
+
if (!code || !state) return;
|
|
149
|
+
if (bridge.isPopupContext()) {
|
|
150
|
+
channel.post({
|
|
151
|
+
type: "auth-callback",
|
|
152
|
+
code,
|
|
153
|
+
state
|
|
154
|
+
});
|
|
155
|
+
bridge.closeSelf();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await complete(code, state);
|
|
159
|
+
const url = new URL(bridge.currentUrl());
|
|
160
|
+
url.searchParams.delete("code");
|
|
161
|
+
url.searchParams.delete("state");
|
|
162
|
+
bridge.replaceUrl(url.pathname + url.search + url.hash);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
signIn,
|
|
166
|
+
handleCallback
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/seams/broadcast-channel.ts
|
|
171
|
+
/**
|
|
172
|
+
* Cross-tab session channel over `BroadcastChannel` (ADR-0005). Delivers to
|
|
173
|
+
* other tabs only (never echoes to the poster — the correct cross-tab
|
|
174
|
+
* semantic). Falls back to core's in-memory channel where `BroadcastChannel`
|
|
175
|
+
* is unavailable (SSR, older engines) so the provider never crashes.
|
|
176
|
+
*/
|
|
177
|
+
var BroadcastChannelSessionChannel = class {
|
|
178
|
+
channel;
|
|
179
|
+
fallback;
|
|
180
|
+
listeners = /* @__PURE__ */ new Set();
|
|
181
|
+
constructor(name = "poly-x.session") {
|
|
182
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
183
|
+
this.channel = new BroadcastChannel(name);
|
|
184
|
+
this.channel.onmessage = (event) => {
|
|
185
|
+
for (const listener of [...this.listeners]) listener(event.data);
|
|
186
|
+
};
|
|
187
|
+
} else this.fallback = new _poly_x_core.InMemorySessionChannel();
|
|
188
|
+
}
|
|
189
|
+
publish(event) {
|
|
190
|
+
if (this.channel) this.channel.postMessage(event);
|
|
191
|
+
else this.fallback?.publish(event);
|
|
192
|
+
}
|
|
193
|
+
subscribe(listener) {
|
|
194
|
+
if (this.fallback) return this.fallback.subscribe(listener);
|
|
195
|
+
this.listeners.add(listener);
|
|
196
|
+
return () => this.listeners.delete(listener);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/seams/session-storage-pkce.ts
|
|
201
|
+
const KEY_PREFIX = "poly-x.pkce.";
|
|
202
|
+
function hasSessionStorage() {
|
|
203
|
+
try {
|
|
204
|
+
return typeof sessionStorage !== "undefined";
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* PKCE-transaction custody in `sessionStorage` so the verifier + redirect
|
|
211
|
+
* survive a full-page redirect to the PolyX login and back (ADR-0005). Falls
|
|
212
|
+
* back to in-memory where `sessionStorage` is unavailable — the transaction
|
|
213
|
+
* then only survives an in-page popup flow, which is the SPA default anyway.
|
|
214
|
+
*/
|
|
215
|
+
var SessionStoragePkceStore = class {
|
|
216
|
+
fallback;
|
|
217
|
+
constructor() {
|
|
218
|
+
if (!hasSessionStorage()) this.fallback = new _poly_x_core.InMemoryPkceStore();
|
|
219
|
+
}
|
|
220
|
+
save(entry) {
|
|
221
|
+
if (this.fallback) return this.fallback.save(entry);
|
|
222
|
+
sessionStorage.setItem(KEY_PREFIX + entry.state, JSON.stringify(entry));
|
|
223
|
+
return Promise.resolve();
|
|
224
|
+
}
|
|
225
|
+
take(state) {
|
|
226
|
+
if (this.fallback) return this.fallback.take(state);
|
|
227
|
+
const raw = sessionStorage.getItem(KEY_PREFIX + state);
|
|
228
|
+
if (raw === null) return Promise.resolve(null);
|
|
229
|
+
sessionStorage.removeItem(KEY_PREFIX + state);
|
|
230
|
+
try {
|
|
231
|
+
return Promise.resolve(JSON.parse(raw));
|
|
232
|
+
} catch {
|
|
233
|
+
return Promise.resolve(null);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/seams/web-locks.ts
|
|
239
|
+
function hasWebLocks() {
|
|
240
|
+
return typeof navigator !== "undefined" && typeof navigator.locks?.request === "function";
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Single-flight refresh coordination over the Web Locks API (ADR-0005), so a
|
|
244
|
+
* refresh is serialized across tabs, not just within one. Falls back to core's
|
|
245
|
+
* in-memory lock where `navigator.locks` is absent (Node/happy-dom, older
|
|
246
|
+
* Safari) — serialization within the tab is preserved.
|
|
247
|
+
*/
|
|
248
|
+
var WebLocksLock = class {
|
|
249
|
+
fallback;
|
|
250
|
+
constructor() {
|
|
251
|
+
if (!hasWebLocks()) this.fallback = new _poly_x_core.InMemoryLock();
|
|
252
|
+
}
|
|
253
|
+
runExclusive(key, fn) {
|
|
254
|
+
if (this.fallback) return this.fallback.runExclusive(key, fn);
|
|
255
|
+
return navigator.locks.request(`poly-x.${key}`, () => fn());
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/provider.tsx
|
|
260
|
+
function PolyXProvider({ publishableKey, baseUrl, children }) {
|
|
261
|
+
const value = (0, react.useMemo)(() => {
|
|
262
|
+
const authClient = new _poly_x_core.AuthClient({
|
|
263
|
+
config: (0, _poly_x_core.resolveConfig)({
|
|
264
|
+
key: publishableKey,
|
|
265
|
+
baseUrl,
|
|
266
|
+
context: "browser"
|
|
267
|
+
}),
|
|
268
|
+
transport: new _poly_x_core.FetchTransport()
|
|
269
|
+
});
|
|
270
|
+
const engine = (0, _poly_x_core.createSessionEngine)({
|
|
271
|
+
refreshTokens: authClient.createRefresher(),
|
|
272
|
+
store: new _poly_x_core.InMemorySessionStore(),
|
|
273
|
+
channel: new BroadcastChannelSessionChannel(),
|
|
274
|
+
lock: new WebLocksLock()
|
|
275
|
+
});
|
|
276
|
+
return {
|
|
277
|
+
engine,
|
|
278
|
+
authClient,
|
|
279
|
+
controller: createLoginController({
|
|
280
|
+
engine,
|
|
281
|
+
authClient,
|
|
282
|
+
pkceStore: new SessionStoragePkceStore(),
|
|
283
|
+
bridge: new WindowBrowserBridge(),
|
|
284
|
+
channel: new BroadcastAuthChannel()
|
|
285
|
+
})
|
|
286
|
+
};
|
|
287
|
+
}, [publishableKey, baseUrl]);
|
|
288
|
+
(0, react.useEffect)(() => {
|
|
289
|
+
value.engine.hydrate();
|
|
290
|
+
return () => value.engine.dispose();
|
|
291
|
+
}, [value]);
|
|
292
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PolyXContext.Provider, {
|
|
293
|
+
value,
|
|
294
|
+
children
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region src/hooks.ts
|
|
299
|
+
function usePolyX() {
|
|
300
|
+
const ctx = (0, react.useContext)(PolyXContext);
|
|
301
|
+
if (!ctx) throw new Error("PolyX hooks (useAuth/useUser/useSession) must be used within a <PolyXProvider>.");
|
|
302
|
+
return ctx;
|
|
303
|
+
}
|
|
304
|
+
/** The raw session state machine value — tearing-free under concurrent React. */
|
|
305
|
+
function useSession() {
|
|
306
|
+
const { engine } = usePolyX();
|
|
307
|
+
const subscribe = (0, react.useCallback)((onChange) => engine.subscribe(onChange), [engine]);
|
|
308
|
+
const getSnapshot = (0, react.useCallback)(() => engine.getState(), [engine]);
|
|
309
|
+
return (0, react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
|
|
310
|
+
}
|
|
311
|
+
function useAuth() {
|
|
312
|
+
const { engine, controller } = usePolyX();
|
|
313
|
+
const state = useSession();
|
|
314
|
+
const signIn = (0, react.useCallback)((options) => controller.signIn(options), [controller]);
|
|
315
|
+
const signOut = (0, react.useCallback)(() => engine.signOut(), [engine]);
|
|
316
|
+
const getToken = (0, react.useCallback)(async () => {
|
|
317
|
+
try {
|
|
318
|
+
return await engine.getAccessToken();
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}, [engine]);
|
|
323
|
+
return {
|
|
324
|
+
isLoaded: state.status !== "resolving",
|
|
325
|
+
isSignedIn: state.status === "authenticated" || state.status === "refreshing",
|
|
326
|
+
signIn,
|
|
327
|
+
signOut,
|
|
328
|
+
getToken
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
|
|
332
|
+
function useAuthCallback() {
|
|
333
|
+
const { controller } = usePolyX();
|
|
334
|
+
return (0, react.useCallback)(() => controller.handleCallback(), [controller]);
|
|
335
|
+
}
|
|
336
|
+
function useUser() {
|
|
337
|
+
const state = useSession();
|
|
338
|
+
return state.status === "authenticated" || state.status === "refreshing" ? state.session.claims : null;
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
341
|
+
//#region src/components/protected.tsx
|
|
342
|
+
/** Gate content on authentication state without flashing the wrong branch. */
|
|
343
|
+
function Protected({ children, fallback = null, loading = null }) {
|
|
344
|
+
const { isLoaded, isSignedIn } = useAuth();
|
|
345
|
+
if (!isLoaded) return loading;
|
|
346
|
+
return isSignedIn ? children : fallback;
|
|
347
|
+
}
|
|
348
|
+
//#endregion
|
|
349
|
+
//#region src/components/sign-in.tsx
|
|
350
|
+
/** Minimal sign-in trigger — popup by default. Style via `className`/children. */
|
|
351
|
+
function SignIn({ children, className, ...options }) {
|
|
352
|
+
const { signIn } = useAuth();
|
|
353
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
354
|
+
type: "button",
|
|
355
|
+
className,
|
|
356
|
+
onClick: () => void signIn(options),
|
|
357
|
+
children: children ?? "Sign in"
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/components/auth-callback.tsx
|
|
362
|
+
/** Drop on your `/auth/callback` route: completes the sign-in exchange on mount. */
|
|
363
|
+
function AuthCallback({ children = null }) {
|
|
364
|
+
const handleCallback = useAuthCallback();
|
|
365
|
+
(0, react.useEffect)(() => {
|
|
366
|
+
handleCallback();
|
|
367
|
+
}, [handleCallback]);
|
|
368
|
+
return children;
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/index.ts
|
|
372
|
+
/**
|
|
373
|
+
* @poly-x/react — PolyX SDK for React SPAs. Sign-in UI + orchestration land in
|
|
374
|
+
* F006; this entry is the state foundation (provider, hooks, browser seams).
|
|
375
|
+
*/
|
|
376
|
+
const PACKAGE_NAME = "@poly-x/react";
|
|
377
|
+
const version = "0.0.0";
|
|
378
|
+
//#endregion
|
|
379
|
+
exports.AuthCallback = AuthCallback;
|
|
380
|
+
exports.BroadcastAuthChannel = BroadcastAuthChannel;
|
|
381
|
+
exports.BroadcastChannelSessionChannel = BroadcastChannelSessionChannel;
|
|
382
|
+
exports.PACKAGE_NAME = PACKAGE_NAME;
|
|
383
|
+
exports.PolyXContext = PolyXContext;
|
|
384
|
+
exports.PolyXProvider = PolyXProvider;
|
|
385
|
+
exports.PopupCancelledError = PopupCancelledError;
|
|
386
|
+
exports.Protected = Protected;
|
|
387
|
+
exports.SessionStoragePkceStore = SessionStoragePkceStore;
|
|
388
|
+
exports.SignIn = SignIn;
|
|
389
|
+
exports.WebLocksLock = WebLocksLock;
|
|
390
|
+
exports.WindowBrowserBridge = WindowBrowserBridge;
|
|
391
|
+
exports.createLoginController = createLoginController;
|
|
392
|
+
exports.useAuth = useAuth;
|
|
393
|
+
exports.useAuthCallback = useAuthCallback;
|
|
394
|
+
exports.useSession = useSession;
|
|
395
|
+
exports.useUser = useUser;
|
|
396
|
+
exports.version = version;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { AuthClient, Lock, PkceEntry, PkceStore, SessionChannel, SessionChannelEvent, SessionClaims, SessionEngine, SessionState } from "@poly-x/core";
|
|
3
|
+
|
|
4
|
+
//#region src/provider.d.ts
|
|
5
|
+
interface PolyXProviderProps {
|
|
6
|
+
/** The `pk_…` publishable key for this app instance. */
|
|
7
|
+
publishableKey: string;
|
|
8
|
+
/** Override the key-embedded deployment host (proxies, container-internal). */
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
declare function PolyXProvider({
|
|
13
|
+
publishableKey,
|
|
14
|
+
baseUrl,
|
|
15
|
+
children
|
|
16
|
+
}: PolyXProviderProps): ReactNode;
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/auth-channel.d.ts
|
|
19
|
+
/**
|
|
20
|
+
* Same-origin popup→opener handoff of the authorization `code` (F006). The
|
|
21
|
+
* popup posts the short-lived code here and closes; the opener does the token
|
|
22
|
+
* exchange, so tokens never transit the channel. Falls back to unsupported
|
|
23
|
+
* (redirect mode still works) where `BroadcastChannel` is absent.
|
|
24
|
+
*/
|
|
25
|
+
interface AuthCallbackMessage {
|
|
26
|
+
type: "auth-callback";
|
|
27
|
+
code: string;
|
|
28
|
+
state: string;
|
|
29
|
+
}
|
|
30
|
+
interface AuthChannel {
|
|
31
|
+
readonly supported: boolean;
|
|
32
|
+
post(message: AuthCallbackMessage): void;
|
|
33
|
+
subscribe(listener: (message: AuthCallbackMessage) => void): () => void;
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|
|
36
|
+
declare class BroadcastAuthChannel implements AuthChannel {
|
|
37
|
+
readonly supported: boolean;
|
|
38
|
+
private readonly channel?;
|
|
39
|
+
private readonly listeners;
|
|
40
|
+
constructor(name?: string);
|
|
41
|
+
post(message: AuthCallbackMessage): void;
|
|
42
|
+
subscribe(listener: (message: AuthCallbackMessage) => void): () => void;
|
|
43
|
+
close(): void;
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/browser-bridge.d.ts
|
|
47
|
+
/**
|
|
48
|
+
* The irreducibly browser-specific surface of sign-in behind one seam, so the
|
|
49
|
+
* login controller's decision logic is unit-testable with a fake and the real
|
|
50
|
+
* `window` adapter stays a thin, un-unit-tested shim (F006 / ADR-0005).
|
|
51
|
+
*/
|
|
52
|
+
interface PopupHandle {
|
|
53
|
+
readonly closed: boolean;
|
|
54
|
+
close(): void;
|
|
55
|
+
}
|
|
56
|
+
interface CallbackParams {
|
|
57
|
+
code?: string;
|
|
58
|
+
state?: string;
|
|
59
|
+
}
|
|
60
|
+
interface BrowserBridge {
|
|
61
|
+
openPopup(url: string, name?: string): PopupHandle | null;
|
|
62
|
+
redirect(url: string): void;
|
|
63
|
+
/** True when running inside a popup opened by this SDK (has a distinct opener). */
|
|
64
|
+
isPopupContext(): boolean;
|
|
65
|
+
readCallbackParams(): CallbackParams;
|
|
66
|
+
currentUrl(): string;
|
|
67
|
+
currentOrigin(): string;
|
|
68
|
+
replaceUrl(url: string): void;
|
|
69
|
+
closeSelf(): void;
|
|
70
|
+
}
|
|
71
|
+
declare class WindowBrowserBridge implements BrowserBridge {
|
|
72
|
+
openPopup(url: string, name?: string): PopupHandle | null;
|
|
73
|
+
redirect(url: string): void;
|
|
74
|
+
isPopupContext(): boolean;
|
|
75
|
+
readCallbackParams(): CallbackParams;
|
|
76
|
+
currentUrl(): string;
|
|
77
|
+
currentOrigin(): string;
|
|
78
|
+
replaceUrl(url: string): void;
|
|
79
|
+
closeSelf(): void;
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/login-controller.d.ts
|
|
83
|
+
interface SignInOptions {
|
|
84
|
+
mode?: "popup" | "redirect";
|
|
85
|
+
organizationCode?: string;
|
|
86
|
+
/** Defaults to `${origin}/auth/callback`. */
|
|
87
|
+
redirectUrl?: string;
|
|
88
|
+
}
|
|
89
|
+
interface LoginController {
|
|
90
|
+
signIn(options?: SignInOptions): Promise<void>;
|
|
91
|
+
handleCallback(): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
interface LoginControllerDeps {
|
|
94
|
+
engine: SessionEngine;
|
|
95
|
+
authClient: AuthClient;
|
|
96
|
+
pkceStore: PkceStore;
|
|
97
|
+
bridge: BrowserBridge;
|
|
98
|
+
channel: AuthChannel;
|
|
99
|
+
/** Poll interval for popup-closed detection (ms). */
|
|
100
|
+
popupPollMs?: number;
|
|
101
|
+
}
|
|
102
|
+
declare class PopupCancelledError extends Error {
|
|
103
|
+
constructor();
|
|
104
|
+
}
|
|
105
|
+
declare function createLoginController(deps: LoginControllerDeps): LoginController;
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/hooks.d.ts
|
|
108
|
+
/** The raw session state machine value — tearing-free under concurrent React. */
|
|
109
|
+
declare function useSession(): SessionState;
|
|
110
|
+
interface UseAuth {
|
|
111
|
+
/** False only while the initial session resolves (no-flash gate). */
|
|
112
|
+
isLoaded: boolean;
|
|
113
|
+
isSignedIn: boolean;
|
|
114
|
+
signIn: (options?: SignInOptions) => Promise<void>;
|
|
115
|
+
signOut: () => Promise<void>;
|
|
116
|
+
/** Current access token, refreshing if needed; null when signed out. */
|
|
117
|
+
getToken: () => Promise<string | null>;
|
|
118
|
+
}
|
|
119
|
+
declare function useAuth(): UseAuth;
|
|
120
|
+
/** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
|
|
121
|
+
declare function useAuthCallback(): () => Promise<void>;
|
|
122
|
+
declare function useUser(): SessionClaims | null;
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/context.d.ts
|
|
125
|
+
interface PolyXContextValue {
|
|
126
|
+
engine: SessionEngine;
|
|
127
|
+
authClient: AuthClient;
|
|
128
|
+
controller: LoginController;
|
|
129
|
+
}
|
|
130
|
+
declare const PolyXContext: import("react").Context<PolyXContextValue | null>;
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/seams/broadcast-channel.d.ts
|
|
133
|
+
/**
|
|
134
|
+
* Cross-tab session channel over `BroadcastChannel` (ADR-0005). Delivers to
|
|
135
|
+
* other tabs only (never echoes to the poster — the correct cross-tab
|
|
136
|
+
* semantic). Falls back to core's in-memory channel where `BroadcastChannel`
|
|
137
|
+
* is unavailable (SSR, older engines) so the provider never crashes.
|
|
138
|
+
*/
|
|
139
|
+
declare class BroadcastChannelSessionChannel implements SessionChannel {
|
|
140
|
+
private readonly channel?;
|
|
141
|
+
private readonly fallback?;
|
|
142
|
+
private readonly listeners;
|
|
143
|
+
constructor(name?: string);
|
|
144
|
+
publish(event: SessionChannelEvent): void;
|
|
145
|
+
subscribe(listener: (event: SessionChannelEvent) => void): () => void;
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/seams/web-locks.d.ts
|
|
149
|
+
/**
|
|
150
|
+
* Single-flight refresh coordination over the Web Locks API (ADR-0005), so a
|
|
151
|
+
* refresh is serialized across tabs, not just within one. Falls back to core's
|
|
152
|
+
* in-memory lock where `navigator.locks` is absent (Node/happy-dom, older
|
|
153
|
+
* Safari) — serialization within the tab is preserved.
|
|
154
|
+
*/
|
|
155
|
+
declare class WebLocksLock implements Lock {
|
|
156
|
+
private readonly fallback?;
|
|
157
|
+
constructor();
|
|
158
|
+
runExclusive<T>(key: string, fn: () => Promise<T>): Promise<T>;
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/seams/session-storage-pkce.d.ts
|
|
162
|
+
/**
|
|
163
|
+
* PKCE-transaction custody in `sessionStorage` so the verifier + redirect
|
|
164
|
+
* survive a full-page redirect to the PolyX login and back (ADR-0005). Falls
|
|
165
|
+
* back to in-memory where `sessionStorage` is unavailable — the transaction
|
|
166
|
+
* then only survives an in-page popup flow, which is the SPA default anyway.
|
|
167
|
+
*/
|
|
168
|
+
declare class SessionStoragePkceStore implements PkceStore {
|
|
169
|
+
private readonly fallback?;
|
|
170
|
+
constructor();
|
|
171
|
+
save(entry: PkceEntry): Promise<void>;
|
|
172
|
+
take(state: string): Promise<PkceEntry | null>;
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/components/protected.d.ts
|
|
176
|
+
interface ProtectedProps {
|
|
177
|
+
children: ReactNode;
|
|
178
|
+
/** Rendered when signed out. */
|
|
179
|
+
fallback?: ReactNode;
|
|
180
|
+
/** Rendered while the initial session resolves (no-flash gate). */
|
|
181
|
+
loading?: ReactNode;
|
|
182
|
+
}
|
|
183
|
+
/** Gate content on authentication state without flashing the wrong branch. */
|
|
184
|
+
declare function Protected({
|
|
185
|
+
children,
|
|
186
|
+
fallback,
|
|
187
|
+
loading
|
|
188
|
+
}: ProtectedProps): ReactNode;
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/components/sign-in.d.ts
|
|
191
|
+
interface SignInProps extends SignInOptions {
|
|
192
|
+
children?: ReactNode;
|
|
193
|
+
className?: string;
|
|
194
|
+
}
|
|
195
|
+
/** Minimal sign-in trigger — popup by default. Style via `className`/children. */
|
|
196
|
+
declare function SignIn({
|
|
197
|
+
children,
|
|
198
|
+
className,
|
|
199
|
+
...options
|
|
200
|
+
}: SignInProps): ReactNode;
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/components/auth-callback.d.ts
|
|
203
|
+
interface AuthCallbackProps {
|
|
204
|
+
/** Rendered while the exchange completes (e.g. a spinner). */
|
|
205
|
+
children?: ReactNode;
|
|
206
|
+
}
|
|
207
|
+
/** Drop on your `/auth/callback` route: completes the sign-in exchange on mount. */
|
|
208
|
+
declare function AuthCallback({
|
|
209
|
+
children
|
|
210
|
+
}: AuthCallbackProps): ReactNode;
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/index.d.ts
|
|
213
|
+
/**
|
|
214
|
+
* @poly-x/react — PolyX SDK for React SPAs. Sign-in UI + orchestration land in
|
|
215
|
+
* F006; this entry is the state foundation (provider, hooks, browser seams).
|
|
216
|
+
*/
|
|
217
|
+
declare const PACKAGE_NAME = "@poly-x/react";
|
|
218
|
+
declare const version = "0.0.0";
|
|
219
|
+
//#endregion
|
|
220
|
+
export { AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, SessionStoragePkceStore, SignIn, type SignInOptions, type SignInProps, type UseAuth, WebLocksLock, WindowBrowserBridge, createLoginController, useAuth, useAuthCallback, useSession, useUser, version };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { AuthClient, Lock, PkceEntry, PkceStore, SessionChannel, SessionChannelEvent, SessionClaims, SessionEngine, SessionState } from "@poly-x/core";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/provider.d.ts
|
|
5
|
+
interface PolyXProviderProps {
|
|
6
|
+
/** The `pk_…` publishable key for this app instance. */
|
|
7
|
+
publishableKey: string;
|
|
8
|
+
/** Override the key-embedded deployment host (proxies, container-internal). */
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
declare function PolyXProvider({
|
|
13
|
+
publishableKey,
|
|
14
|
+
baseUrl,
|
|
15
|
+
children
|
|
16
|
+
}: PolyXProviderProps): ReactNode;
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/auth-channel.d.ts
|
|
19
|
+
/**
|
|
20
|
+
* Same-origin popup→opener handoff of the authorization `code` (F006). The
|
|
21
|
+
* popup posts the short-lived code here and closes; the opener does the token
|
|
22
|
+
* exchange, so tokens never transit the channel. Falls back to unsupported
|
|
23
|
+
* (redirect mode still works) where `BroadcastChannel` is absent.
|
|
24
|
+
*/
|
|
25
|
+
interface AuthCallbackMessage {
|
|
26
|
+
type: "auth-callback";
|
|
27
|
+
code: string;
|
|
28
|
+
state: string;
|
|
29
|
+
}
|
|
30
|
+
interface AuthChannel {
|
|
31
|
+
readonly supported: boolean;
|
|
32
|
+
post(message: AuthCallbackMessage): void;
|
|
33
|
+
subscribe(listener: (message: AuthCallbackMessage) => void): () => void;
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|
|
36
|
+
declare class BroadcastAuthChannel implements AuthChannel {
|
|
37
|
+
readonly supported: boolean;
|
|
38
|
+
private readonly channel?;
|
|
39
|
+
private readonly listeners;
|
|
40
|
+
constructor(name?: string);
|
|
41
|
+
post(message: AuthCallbackMessage): void;
|
|
42
|
+
subscribe(listener: (message: AuthCallbackMessage) => void): () => void;
|
|
43
|
+
close(): void;
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/browser-bridge.d.ts
|
|
47
|
+
/**
|
|
48
|
+
* The irreducibly browser-specific surface of sign-in behind one seam, so the
|
|
49
|
+
* login controller's decision logic is unit-testable with a fake and the real
|
|
50
|
+
* `window` adapter stays a thin, un-unit-tested shim (F006 / ADR-0005).
|
|
51
|
+
*/
|
|
52
|
+
interface PopupHandle {
|
|
53
|
+
readonly closed: boolean;
|
|
54
|
+
close(): void;
|
|
55
|
+
}
|
|
56
|
+
interface CallbackParams {
|
|
57
|
+
code?: string;
|
|
58
|
+
state?: string;
|
|
59
|
+
}
|
|
60
|
+
interface BrowserBridge {
|
|
61
|
+
openPopup(url: string, name?: string): PopupHandle | null;
|
|
62
|
+
redirect(url: string): void;
|
|
63
|
+
/** True when running inside a popup opened by this SDK (has a distinct opener). */
|
|
64
|
+
isPopupContext(): boolean;
|
|
65
|
+
readCallbackParams(): CallbackParams;
|
|
66
|
+
currentUrl(): string;
|
|
67
|
+
currentOrigin(): string;
|
|
68
|
+
replaceUrl(url: string): void;
|
|
69
|
+
closeSelf(): void;
|
|
70
|
+
}
|
|
71
|
+
declare class WindowBrowserBridge implements BrowserBridge {
|
|
72
|
+
openPopup(url: string, name?: string): PopupHandle | null;
|
|
73
|
+
redirect(url: string): void;
|
|
74
|
+
isPopupContext(): boolean;
|
|
75
|
+
readCallbackParams(): CallbackParams;
|
|
76
|
+
currentUrl(): string;
|
|
77
|
+
currentOrigin(): string;
|
|
78
|
+
replaceUrl(url: string): void;
|
|
79
|
+
closeSelf(): void;
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/login-controller.d.ts
|
|
83
|
+
interface SignInOptions {
|
|
84
|
+
mode?: "popup" | "redirect";
|
|
85
|
+
organizationCode?: string;
|
|
86
|
+
/** Defaults to `${origin}/auth/callback`. */
|
|
87
|
+
redirectUrl?: string;
|
|
88
|
+
}
|
|
89
|
+
interface LoginController {
|
|
90
|
+
signIn(options?: SignInOptions): Promise<void>;
|
|
91
|
+
handleCallback(): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
interface LoginControllerDeps {
|
|
94
|
+
engine: SessionEngine;
|
|
95
|
+
authClient: AuthClient;
|
|
96
|
+
pkceStore: PkceStore;
|
|
97
|
+
bridge: BrowserBridge;
|
|
98
|
+
channel: AuthChannel;
|
|
99
|
+
/** Poll interval for popup-closed detection (ms). */
|
|
100
|
+
popupPollMs?: number;
|
|
101
|
+
}
|
|
102
|
+
declare class PopupCancelledError extends Error {
|
|
103
|
+
constructor();
|
|
104
|
+
}
|
|
105
|
+
declare function createLoginController(deps: LoginControllerDeps): LoginController;
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/hooks.d.ts
|
|
108
|
+
/** The raw session state machine value — tearing-free under concurrent React. */
|
|
109
|
+
declare function useSession(): SessionState;
|
|
110
|
+
interface UseAuth {
|
|
111
|
+
/** False only while the initial session resolves (no-flash gate). */
|
|
112
|
+
isLoaded: boolean;
|
|
113
|
+
isSignedIn: boolean;
|
|
114
|
+
signIn: (options?: SignInOptions) => Promise<void>;
|
|
115
|
+
signOut: () => Promise<void>;
|
|
116
|
+
/** Current access token, refreshing if needed; null when signed out. */
|
|
117
|
+
getToken: () => Promise<string | null>;
|
|
118
|
+
}
|
|
119
|
+
declare function useAuth(): UseAuth;
|
|
120
|
+
/** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
|
|
121
|
+
declare function useAuthCallback(): () => Promise<void>;
|
|
122
|
+
declare function useUser(): SessionClaims | null;
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/context.d.ts
|
|
125
|
+
interface PolyXContextValue {
|
|
126
|
+
engine: SessionEngine;
|
|
127
|
+
authClient: AuthClient;
|
|
128
|
+
controller: LoginController;
|
|
129
|
+
}
|
|
130
|
+
declare const PolyXContext: import("react").Context<PolyXContextValue | null>;
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/seams/broadcast-channel.d.ts
|
|
133
|
+
/**
|
|
134
|
+
* Cross-tab session channel over `BroadcastChannel` (ADR-0005). Delivers to
|
|
135
|
+
* other tabs only (never echoes to the poster — the correct cross-tab
|
|
136
|
+
* semantic). Falls back to core's in-memory channel where `BroadcastChannel`
|
|
137
|
+
* is unavailable (SSR, older engines) so the provider never crashes.
|
|
138
|
+
*/
|
|
139
|
+
declare class BroadcastChannelSessionChannel implements SessionChannel {
|
|
140
|
+
private readonly channel?;
|
|
141
|
+
private readonly fallback?;
|
|
142
|
+
private readonly listeners;
|
|
143
|
+
constructor(name?: string);
|
|
144
|
+
publish(event: SessionChannelEvent): void;
|
|
145
|
+
subscribe(listener: (event: SessionChannelEvent) => void): () => void;
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/seams/web-locks.d.ts
|
|
149
|
+
/**
|
|
150
|
+
* Single-flight refresh coordination over the Web Locks API (ADR-0005), so a
|
|
151
|
+
* refresh is serialized across tabs, not just within one. Falls back to core's
|
|
152
|
+
* in-memory lock where `navigator.locks` is absent (Node/happy-dom, older
|
|
153
|
+
* Safari) — serialization within the tab is preserved.
|
|
154
|
+
*/
|
|
155
|
+
declare class WebLocksLock implements Lock {
|
|
156
|
+
private readonly fallback?;
|
|
157
|
+
constructor();
|
|
158
|
+
runExclusive<T>(key: string, fn: () => Promise<T>): Promise<T>;
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/seams/session-storage-pkce.d.ts
|
|
162
|
+
/**
|
|
163
|
+
* PKCE-transaction custody in `sessionStorage` so the verifier + redirect
|
|
164
|
+
* survive a full-page redirect to the PolyX login and back (ADR-0005). Falls
|
|
165
|
+
* back to in-memory where `sessionStorage` is unavailable — the transaction
|
|
166
|
+
* then only survives an in-page popup flow, which is the SPA default anyway.
|
|
167
|
+
*/
|
|
168
|
+
declare class SessionStoragePkceStore implements PkceStore {
|
|
169
|
+
private readonly fallback?;
|
|
170
|
+
constructor();
|
|
171
|
+
save(entry: PkceEntry): Promise<void>;
|
|
172
|
+
take(state: string): Promise<PkceEntry | null>;
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/components/protected.d.ts
|
|
176
|
+
interface ProtectedProps {
|
|
177
|
+
children: ReactNode;
|
|
178
|
+
/** Rendered when signed out. */
|
|
179
|
+
fallback?: ReactNode;
|
|
180
|
+
/** Rendered while the initial session resolves (no-flash gate). */
|
|
181
|
+
loading?: ReactNode;
|
|
182
|
+
}
|
|
183
|
+
/** Gate content on authentication state without flashing the wrong branch. */
|
|
184
|
+
declare function Protected({
|
|
185
|
+
children,
|
|
186
|
+
fallback,
|
|
187
|
+
loading
|
|
188
|
+
}: ProtectedProps): ReactNode;
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/components/sign-in.d.ts
|
|
191
|
+
interface SignInProps extends SignInOptions {
|
|
192
|
+
children?: ReactNode;
|
|
193
|
+
className?: string;
|
|
194
|
+
}
|
|
195
|
+
/** Minimal sign-in trigger — popup by default. Style via `className`/children. */
|
|
196
|
+
declare function SignIn({
|
|
197
|
+
children,
|
|
198
|
+
className,
|
|
199
|
+
...options
|
|
200
|
+
}: SignInProps): ReactNode;
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/components/auth-callback.d.ts
|
|
203
|
+
interface AuthCallbackProps {
|
|
204
|
+
/** Rendered while the exchange completes (e.g. a spinner). */
|
|
205
|
+
children?: ReactNode;
|
|
206
|
+
}
|
|
207
|
+
/** Drop on your `/auth/callback` route: completes the sign-in exchange on mount. */
|
|
208
|
+
declare function AuthCallback({
|
|
209
|
+
children
|
|
210
|
+
}: AuthCallbackProps): ReactNode;
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/index.d.ts
|
|
213
|
+
/**
|
|
214
|
+
* @poly-x/react — PolyX SDK for React SPAs. Sign-in UI + orchestration land in
|
|
215
|
+
* F006; this entry is the state foundation (provider, hooks, browser seams).
|
|
216
|
+
*/
|
|
217
|
+
declare const PACKAGE_NAME = "@poly-x/react";
|
|
218
|
+
declare const version = "0.0.0";
|
|
219
|
+
//#endregion
|
|
220
|
+
export { AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, SessionStoragePkceStore, SignIn, type SignInOptions, type SignInProps, type UseAuth, WebLocksLock, WindowBrowserBridge, createLoginController, useAuth, useAuthCallback, useSession, useUser, version };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { AuthClient, FetchTransport, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, createSessionEngine, generatePkce, randomState, resolveConfig } from "@poly-x/core";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
//#region src/auth-channel.ts
|
|
6
|
+
var BroadcastAuthChannel = class {
|
|
7
|
+
supported;
|
|
8
|
+
channel;
|
|
9
|
+
listeners = /* @__PURE__ */ new Set();
|
|
10
|
+
constructor(name = "poly-x.auth") {
|
|
11
|
+
this.supported = typeof BroadcastChannel !== "undefined";
|
|
12
|
+
if (this.supported) {
|
|
13
|
+
this.channel = new BroadcastChannel(name);
|
|
14
|
+
this.channel.onmessage = (event) => {
|
|
15
|
+
const data = event.data;
|
|
16
|
+
if (data?.type === "auth-callback") for (const listener of [...this.listeners]) listener(data);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
post(message) {
|
|
21
|
+
this.channel?.postMessage(message);
|
|
22
|
+
}
|
|
23
|
+
subscribe(listener) {
|
|
24
|
+
this.listeners.add(listener);
|
|
25
|
+
return () => this.listeners.delete(listener);
|
|
26
|
+
}
|
|
27
|
+
close() {
|
|
28
|
+
this.channel?.close();
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/browser-bridge.ts
|
|
33
|
+
var WindowBrowserBridge = class {
|
|
34
|
+
openPopup(url, name = "polyx-auth") {
|
|
35
|
+
const win = window.open(url, name, "width=480,height=680");
|
|
36
|
+
if (!win) return null;
|
|
37
|
+
return {
|
|
38
|
+
get closed() {
|
|
39
|
+
return win.closed;
|
|
40
|
+
},
|
|
41
|
+
close: () => win.close()
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
redirect(url) {
|
|
45
|
+
window.location.assign(url);
|
|
46
|
+
}
|
|
47
|
+
isPopupContext() {
|
|
48
|
+
return typeof window !== "undefined" && window.opener != null && window.opener !== window;
|
|
49
|
+
}
|
|
50
|
+
readCallbackParams() {
|
|
51
|
+
const params = new URLSearchParams(window.location.search);
|
|
52
|
+
return {
|
|
53
|
+
code: params.get("code") ?? void 0,
|
|
54
|
+
state: params.get("state") ?? void 0
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
currentUrl() {
|
|
58
|
+
return window.location.href;
|
|
59
|
+
}
|
|
60
|
+
currentOrigin() {
|
|
61
|
+
return window.location.origin;
|
|
62
|
+
}
|
|
63
|
+
replaceUrl(url) {
|
|
64
|
+
window.history.replaceState({}, "", url);
|
|
65
|
+
}
|
|
66
|
+
closeSelf() {
|
|
67
|
+
window.close();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/context.ts
|
|
72
|
+
const PolyXContext = createContext(null);
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/login-controller.ts
|
|
75
|
+
/**
|
|
76
|
+
* Sign-in orchestration (F006), framework-agnostic within the browser: it
|
|
77
|
+
* composes the auth client, PKCE store, browser bridge, and auth channel to run
|
|
78
|
+
* both delivery modes (popup default + redirect, D2b). All decision logic lives
|
|
79
|
+
* here; the `window` specifics are behind the BrowserBridge seam.
|
|
80
|
+
*/
|
|
81
|
+
var PopupCancelledError = class extends Error {
|
|
82
|
+
constructor() {
|
|
83
|
+
super("Sign-in was cancelled before it completed.");
|
|
84
|
+
this.name = "PopupCancelledError";
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
function createLoginController(deps) {
|
|
88
|
+
const { engine, authClient, pkceStore, bridge, channel } = deps;
|
|
89
|
+
const pollMs = deps.popupPollMs ?? 300;
|
|
90
|
+
async function complete(code, state) {
|
|
91
|
+
const entry = await pkceStore.take(state);
|
|
92
|
+
if (!entry) throw new Error("Sign-in state did not match a pending request (expired or replayed). Please retry.");
|
|
93
|
+
const stored = await authClient.exchangeCode({
|
|
94
|
+
code,
|
|
95
|
+
codeVerifier: entry.verifier,
|
|
96
|
+
redirectUri: entry.redirectUri
|
|
97
|
+
});
|
|
98
|
+
await engine.establishSession(stored);
|
|
99
|
+
}
|
|
100
|
+
async function signIn(options = {}) {
|
|
101
|
+
const mode = options.mode ?? "popup";
|
|
102
|
+
const redirectUri = options.redirectUrl ?? `${bridge.currentOrigin()}/auth/callback`;
|
|
103
|
+
const { verifier, challenge } = await generatePkce();
|
|
104
|
+
const state = randomState();
|
|
105
|
+
await pkceStore.save({
|
|
106
|
+
state,
|
|
107
|
+
verifier,
|
|
108
|
+
redirectUri,
|
|
109
|
+
createdAt: Date.now()
|
|
110
|
+
});
|
|
111
|
+
const url = authClient.buildAuthorizeUrl({
|
|
112
|
+
redirectUri,
|
|
113
|
+
state,
|
|
114
|
+
challenge,
|
|
115
|
+
organizationCode: options.organizationCode
|
|
116
|
+
});
|
|
117
|
+
if (mode === "redirect") {
|
|
118
|
+
bridge.redirect(url);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!channel.supported) throw new Error("Popup sign-in requires BroadcastChannel; use mode: \"redirect\" instead.");
|
|
122
|
+
const popup = bridge.openPopup(url);
|
|
123
|
+
if (!popup) throw new Error("The sign-in popup was blocked. Use mode: \"redirect\" instead.");
|
|
124
|
+
await new Promise((resolve, reject) => {
|
|
125
|
+
let settled = false;
|
|
126
|
+
const finish = (fn) => {
|
|
127
|
+
if (settled) return;
|
|
128
|
+
settled = true;
|
|
129
|
+
unsubscribe();
|
|
130
|
+
clearInterval(poll);
|
|
131
|
+
fn();
|
|
132
|
+
};
|
|
133
|
+
const unsubscribe = channel.subscribe((message) => {
|
|
134
|
+
if (message.state !== state) return;
|
|
135
|
+
complete(message.code, message.state).then(() => finish(() => {
|
|
136
|
+
popup.close();
|
|
137
|
+
resolve();
|
|
138
|
+
}), (error) => finish(() => reject(error)));
|
|
139
|
+
});
|
|
140
|
+
const poll = setInterval(() => {
|
|
141
|
+
if (popup.closed) finish(() => reject(new PopupCancelledError()));
|
|
142
|
+
}, pollMs);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
async function handleCallback() {
|
|
146
|
+
const { code, state } = bridge.readCallbackParams();
|
|
147
|
+
if (!code || !state) return;
|
|
148
|
+
if (bridge.isPopupContext()) {
|
|
149
|
+
channel.post({
|
|
150
|
+
type: "auth-callback",
|
|
151
|
+
code,
|
|
152
|
+
state
|
|
153
|
+
});
|
|
154
|
+
bridge.closeSelf();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
await complete(code, state);
|
|
158
|
+
const url = new URL(bridge.currentUrl());
|
|
159
|
+
url.searchParams.delete("code");
|
|
160
|
+
url.searchParams.delete("state");
|
|
161
|
+
bridge.replaceUrl(url.pathname + url.search + url.hash);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
signIn,
|
|
165
|
+
handleCallback
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/seams/broadcast-channel.ts
|
|
170
|
+
/**
|
|
171
|
+
* Cross-tab session channel over `BroadcastChannel` (ADR-0005). Delivers to
|
|
172
|
+
* other tabs only (never echoes to the poster — the correct cross-tab
|
|
173
|
+
* semantic). Falls back to core's in-memory channel where `BroadcastChannel`
|
|
174
|
+
* is unavailable (SSR, older engines) so the provider never crashes.
|
|
175
|
+
*/
|
|
176
|
+
var BroadcastChannelSessionChannel = class {
|
|
177
|
+
channel;
|
|
178
|
+
fallback;
|
|
179
|
+
listeners = /* @__PURE__ */ new Set();
|
|
180
|
+
constructor(name = "poly-x.session") {
|
|
181
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
182
|
+
this.channel = new BroadcastChannel(name);
|
|
183
|
+
this.channel.onmessage = (event) => {
|
|
184
|
+
for (const listener of [...this.listeners]) listener(event.data);
|
|
185
|
+
};
|
|
186
|
+
} else this.fallback = new InMemorySessionChannel();
|
|
187
|
+
}
|
|
188
|
+
publish(event) {
|
|
189
|
+
if (this.channel) this.channel.postMessage(event);
|
|
190
|
+
else this.fallback?.publish(event);
|
|
191
|
+
}
|
|
192
|
+
subscribe(listener) {
|
|
193
|
+
if (this.fallback) return this.fallback.subscribe(listener);
|
|
194
|
+
this.listeners.add(listener);
|
|
195
|
+
return () => this.listeners.delete(listener);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/seams/session-storage-pkce.ts
|
|
200
|
+
const KEY_PREFIX = "poly-x.pkce.";
|
|
201
|
+
function hasSessionStorage() {
|
|
202
|
+
try {
|
|
203
|
+
return typeof sessionStorage !== "undefined";
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* PKCE-transaction custody in `sessionStorage` so the verifier + redirect
|
|
210
|
+
* survive a full-page redirect to the PolyX login and back (ADR-0005). Falls
|
|
211
|
+
* back to in-memory where `sessionStorage` is unavailable — the transaction
|
|
212
|
+
* then only survives an in-page popup flow, which is the SPA default anyway.
|
|
213
|
+
*/
|
|
214
|
+
var SessionStoragePkceStore = class {
|
|
215
|
+
fallback;
|
|
216
|
+
constructor() {
|
|
217
|
+
if (!hasSessionStorage()) this.fallback = new InMemoryPkceStore();
|
|
218
|
+
}
|
|
219
|
+
save(entry) {
|
|
220
|
+
if (this.fallback) return this.fallback.save(entry);
|
|
221
|
+
sessionStorage.setItem(KEY_PREFIX + entry.state, JSON.stringify(entry));
|
|
222
|
+
return Promise.resolve();
|
|
223
|
+
}
|
|
224
|
+
take(state) {
|
|
225
|
+
if (this.fallback) return this.fallback.take(state);
|
|
226
|
+
const raw = sessionStorage.getItem(KEY_PREFIX + state);
|
|
227
|
+
if (raw === null) return Promise.resolve(null);
|
|
228
|
+
sessionStorage.removeItem(KEY_PREFIX + state);
|
|
229
|
+
try {
|
|
230
|
+
return Promise.resolve(JSON.parse(raw));
|
|
231
|
+
} catch {
|
|
232
|
+
return Promise.resolve(null);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/seams/web-locks.ts
|
|
238
|
+
function hasWebLocks() {
|
|
239
|
+
return typeof navigator !== "undefined" && typeof navigator.locks?.request === "function";
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Single-flight refresh coordination over the Web Locks API (ADR-0005), so a
|
|
243
|
+
* refresh is serialized across tabs, not just within one. Falls back to core's
|
|
244
|
+
* in-memory lock where `navigator.locks` is absent (Node/happy-dom, older
|
|
245
|
+
* Safari) — serialization within the tab is preserved.
|
|
246
|
+
*/
|
|
247
|
+
var WebLocksLock = class {
|
|
248
|
+
fallback;
|
|
249
|
+
constructor() {
|
|
250
|
+
if (!hasWebLocks()) this.fallback = new InMemoryLock();
|
|
251
|
+
}
|
|
252
|
+
runExclusive(key, fn) {
|
|
253
|
+
if (this.fallback) return this.fallback.runExclusive(key, fn);
|
|
254
|
+
return navigator.locks.request(`poly-x.${key}`, () => fn());
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/provider.tsx
|
|
259
|
+
function PolyXProvider({ publishableKey, baseUrl, children }) {
|
|
260
|
+
const value = useMemo(() => {
|
|
261
|
+
const authClient = new AuthClient({
|
|
262
|
+
config: resolveConfig({
|
|
263
|
+
key: publishableKey,
|
|
264
|
+
baseUrl,
|
|
265
|
+
context: "browser"
|
|
266
|
+
}),
|
|
267
|
+
transport: new FetchTransport()
|
|
268
|
+
});
|
|
269
|
+
const engine = createSessionEngine({
|
|
270
|
+
refreshTokens: authClient.createRefresher(),
|
|
271
|
+
store: new InMemorySessionStore(),
|
|
272
|
+
channel: new BroadcastChannelSessionChannel(),
|
|
273
|
+
lock: new WebLocksLock()
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
engine,
|
|
277
|
+
authClient,
|
|
278
|
+
controller: createLoginController({
|
|
279
|
+
engine,
|
|
280
|
+
authClient,
|
|
281
|
+
pkceStore: new SessionStoragePkceStore(),
|
|
282
|
+
bridge: new WindowBrowserBridge(),
|
|
283
|
+
channel: new BroadcastAuthChannel()
|
|
284
|
+
})
|
|
285
|
+
};
|
|
286
|
+
}, [publishableKey, baseUrl]);
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
value.engine.hydrate();
|
|
289
|
+
return () => value.engine.dispose();
|
|
290
|
+
}, [value]);
|
|
291
|
+
return /* @__PURE__ */ jsx(PolyXContext.Provider, {
|
|
292
|
+
value,
|
|
293
|
+
children
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/hooks.ts
|
|
298
|
+
function usePolyX() {
|
|
299
|
+
const ctx = useContext(PolyXContext);
|
|
300
|
+
if (!ctx) throw new Error("PolyX hooks (useAuth/useUser/useSession) must be used within a <PolyXProvider>.");
|
|
301
|
+
return ctx;
|
|
302
|
+
}
|
|
303
|
+
/** The raw session state machine value — tearing-free under concurrent React. */
|
|
304
|
+
function useSession() {
|
|
305
|
+
const { engine } = usePolyX();
|
|
306
|
+
const subscribe = useCallback((onChange) => engine.subscribe(onChange), [engine]);
|
|
307
|
+
const getSnapshot = useCallback(() => engine.getState(), [engine]);
|
|
308
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
309
|
+
}
|
|
310
|
+
function useAuth() {
|
|
311
|
+
const { engine, controller } = usePolyX();
|
|
312
|
+
const state = useSession();
|
|
313
|
+
const signIn = useCallback((options) => controller.signIn(options), [controller]);
|
|
314
|
+
const signOut = useCallback(() => engine.signOut(), [engine]);
|
|
315
|
+
const getToken = useCallback(async () => {
|
|
316
|
+
try {
|
|
317
|
+
return await engine.getAccessToken();
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}, [engine]);
|
|
322
|
+
return {
|
|
323
|
+
isLoaded: state.status !== "resolving",
|
|
324
|
+
isSignedIn: state.status === "authenticated" || state.status === "refreshing",
|
|
325
|
+
signIn,
|
|
326
|
+
signOut,
|
|
327
|
+
getToken
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
|
|
331
|
+
function useAuthCallback() {
|
|
332
|
+
const { controller } = usePolyX();
|
|
333
|
+
return useCallback(() => controller.handleCallback(), [controller]);
|
|
334
|
+
}
|
|
335
|
+
function useUser() {
|
|
336
|
+
const state = useSession();
|
|
337
|
+
return state.status === "authenticated" || state.status === "refreshing" ? state.session.claims : null;
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/components/protected.tsx
|
|
341
|
+
/** Gate content on authentication state without flashing the wrong branch. */
|
|
342
|
+
function Protected({ children, fallback = null, loading = null }) {
|
|
343
|
+
const { isLoaded, isSignedIn } = useAuth();
|
|
344
|
+
if (!isLoaded) return loading;
|
|
345
|
+
return isSignedIn ? children : fallback;
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/components/sign-in.tsx
|
|
349
|
+
/** Minimal sign-in trigger — popup by default. Style via `className`/children. */
|
|
350
|
+
function SignIn({ children, className, ...options }) {
|
|
351
|
+
const { signIn } = useAuth();
|
|
352
|
+
return /* @__PURE__ */ jsx("button", {
|
|
353
|
+
type: "button",
|
|
354
|
+
className,
|
|
355
|
+
onClick: () => void signIn(options),
|
|
356
|
+
children: children ?? "Sign in"
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/components/auth-callback.tsx
|
|
361
|
+
/** Drop on your `/auth/callback` route: completes the sign-in exchange on mount. */
|
|
362
|
+
function AuthCallback({ children = null }) {
|
|
363
|
+
const handleCallback = useAuthCallback();
|
|
364
|
+
useEffect(() => {
|
|
365
|
+
handleCallback();
|
|
366
|
+
}, [handleCallback]);
|
|
367
|
+
return children;
|
|
368
|
+
}
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region src/index.ts
|
|
371
|
+
/**
|
|
372
|
+
* @poly-x/react — PolyX SDK for React SPAs. Sign-in UI + orchestration land in
|
|
373
|
+
* F006; this entry is the state foundation (provider, hooks, browser seams).
|
|
374
|
+
*/
|
|
375
|
+
const PACKAGE_NAME = "@poly-x/react";
|
|
376
|
+
const version = "0.0.0";
|
|
377
|
+
//#endregion
|
|
378
|
+
export { AuthCallback, BroadcastAuthChannel, BroadcastChannelSessionChannel, PACKAGE_NAME, PolyXContext, PolyXProvider, PopupCancelledError, Protected, SessionStoragePkceStore, SignIn, WebLocksLock, WindowBrowserBridge, createLoginController, useAuth, useAuthCallback, useSession, useUser, version };
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@poly-x/react",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "PolyX SDK for React SPAs - provider, hooks, drop-in auth UI",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": {
|
|
14
|
+
"types": "./dist/index.d.mts",
|
|
15
|
+
"default": "./dist/index.mjs"
|
|
16
|
+
},
|
|
17
|
+
"require": {
|
|
18
|
+
"types": "./dist/index.d.cts",
|
|
19
|
+
"default": "./dist/index.cjs"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"main": "./dist/index.cjs",
|
|
24
|
+
"module": "./dist/index.mjs",
|
|
25
|
+
"types": "./dist/index.d.cts",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@poly-x/core": "0.1.0-alpha.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": ">=18",
|
|
31
|
+
"react-dom": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://dev.azure.com/waiindustries/wAI%20Engineering/_git/poly-x-sdk"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@testing-library/dom": "^10.4.1",
|
|
45
|
+
"@testing-library/react": "^16.3.2",
|
|
46
|
+
"@types/react": "^19.2.17",
|
|
47
|
+
"@types/react-dom": "^19.2.3",
|
|
48
|
+
"@vitejs/plugin-react": "^6.0.3",
|
|
49
|
+
"happy-dom": "^20.10.6",
|
|
50
|
+
"react": "^19.2.7",
|
|
51
|
+
"react-dom": "^19.2.7"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsdown",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"lint": "eslint src"
|
|
57
|
+
}
|
|
58
|
+
}
|