@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 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;
@@ -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 };
@@ -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
+ }