@razakalpha/convngx 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/convngx",
4
+ "lib": {
5
+ "entryFile": "src/public-api.ts"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
- {
2
- "name": "@razakalpha/convngx",
3
- "version": "0.2.3",
4
- "peerDependencies": {
5
- "@angular/common": "^20.1.0",
6
- "@angular/core": "^20.1.0",
7
- "convex": "^1.25.0",
8
- "@convex-dev/better-auth": "^0.7.0",
9
- "better-auth": "^1.3.0"
10
- },
11
- "dependencies": {
12
- "tslib": "^2.3.0"
13
- },
14
- "sideEffects": false,
15
- "module": "fesm2022/razakalpha-convngx.mjs",
16
- "typings": "index.d.ts",
17
- "exports": {
18
- "./package.json": {
19
- "default": "./package.json"
20
- },
21
- ".": {
22
- "types": "./index.d.ts",
23
- "default": "./fesm2022/razakalpha-convngx.mjs"
24
- }
25
- }
26
- }
1
+ {
2
+ "name": "@razakalpha/convngx",
3
+ "version": "0.2.4",
4
+ "peerDependencies": {
5
+ "@angular/common": "^20.1.0",
6
+ "@angular/core": "^20.1.0",
7
+ "convex": "^1.25.0",
8
+ "@convex-dev/better-auth": "^0.7.0",
9
+ "better-auth": "^1.3.0"
10
+ },
11
+ "dependencies": {
12
+ "tslib": "^2.3.0"
13
+ },
14
+ "sideEffects": false,
15
+ "module": "fesm2022/razakalpha-convngx.mjs",
16
+ "typings": "index.d.ts",
17
+ "exports": {
18
+ "./package.json": {
19
+ "default": "./package.json"
20
+ },
21
+ ".": {
22
+ "types": "./index.d.ts",
23
+ "default": "./fesm2022/razakalpha-convngx.mjs"
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,35 @@
1
+ import { InjectionToken, Provider } from '@angular/core';
2
+ import { createAuthClient } from 'better-auth/client';
3
+ import { convexClient, crossDomainClient } from '@convex-dev/better-auth/client/plugins';
4
+
5
+ type AuthConfig = {
6
+ baseURL: string;
7
+ plugins: [ReturnType<typeof convexClient>, ReturnType<typeof crossDomainClient>];
8
+ fetchOptions: { credentials: 'include' };
9
+ };
10
+
11
+ export type AuthClient = ReturnType<typeof createAuthClient<AuthConfig>>;
12
+
13
+ export interface ProvideAuthClientOptions {
14
+ baseURL: string;
15
+ fetchOptions?: RequestInit;
16
+ }
17
+
18
+ /**
19
+ * Un-typed DI token. We don't re-export types; callers who create the client
20
+ * get full type safety from better-auth directly.
21
+ */
22
+ export const AUTH_CLIENT = new InjectionToken<AuthClient>('AUTH_CLIENT');
23
+
24
+ /** Provide a configured Better Auth client via DI (default convex+crossDomain plugins) */
25
+ export function provideAuthClient(opts: ProvideAuthClientOptions): Provider {
26
+ return {
27
+ provide: AUTH_CLIENT,
28
+ useFactory: () =>
29
+ createAuthClient({
30
+ baseURL: opts.baseURL,
31
+ plugins: [convexClient(), crossDomainClient()],
32
+ fetchOptions: { credentials: 'include', ...(opts.fetchOptions ?? {}) },
33
+ }),
34
+ };
35
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Convex + Better Auth providers.
3
+ * - provideConvexBetterAuth: Registers a ConvexAngularClient in DI and wires Better Auth token fetching.
4
+ * - provideBetterAuthOttBootstrap: Optional environment initializer to handle cross-domain one-time-token.
5
+ *
6
+ * No functional changes—cleaned comments and added docs.
7
+ */
8
+ import { inject, provideEnvironmentInitializer, Provider } from '@angular/core';
9
+ import { AUTH_CLIENT } from './auth-client.provider';
10
+ import { CONVEX } from '../core/inject-convex.token';
11
+ import { ConvexAngularClient, FetchAccessToken } from '../core/convex-angular-client';
12
+
13
+ /** Minimal surface this library relies on from Better Auth client */
14
+ interface AuthClientRequired {
15
+ convex: { token: () => Promise<{ data?: { token?: string } | null }> };
16
+ crossDomain: {
17
+ oneTimeToken: {
18
+ verify: (args: { token: string }) => Promise<{ data?: { session?: { token?: string } } }>;
19
+ };
20
+ };
21
+ getSession: (o?: { fetchOptions?: RequestInit }) => Promise<unknown>;
22
+ updateSession: () => void;
23
+ }
24
+ export interface ConvexBetterAuthOptions {
25
+ /** Convex deployment URL, e.g. https://xxx.convex.cloud */
26
+ convexUrl: string;
27
+ /** Milliseconds before JWT expiry to refresh (default 45s in this provider) */
28
+ authSkewMs?: number;
29
+
30
+ authClient?: AuthClientRequired; // Optional user-provided Better Auth client
31
+ }
32
+
33
+ /**
34
+ * Registers the Convex client in DI and connects it to Better Auth for JWT retrieval.
35
+ * Consumers still need to provide the Better Auth HTTP client via provideAuthClient().
36
+ */
37
+ export function provideConvexBetterAuth(opts: ConvexBetterAuthOptions): Provider[] {
38
+ return [
39
+ {
40
+ provide: CONVEX,
41
+ useFactory: () => {
42
+ const client = new ConvexAngularClient(opts.convexUrl, {
43
+ authSkewMs: opts.authSkewMs ?? 45_000,
44
+ });
45
+
46
+ const auth = inject(AUTH_CLIENT) as AuthClientRequired;
47
+
48
+ const fetchAccessToken: FetchAccessToken = async ({ forceRefreshToken }) => {
49
+ if (!forceRefreshToken) return null;
50
+
51
+ // Retry logic for transient failures (e.g., session not fully propagated)
52
+ let lastError: any;
53
+ for (let attempt = 0; attempt < 3; attempt++) {
54
+ try {
55
+ if (attempt > 0) {
56
+ // Wait before retry: 50ms, then 100ms
57
+ const delay = attempt * 50;
58
+ await new Promise((resolve) => setTimeout(resolve, delay));
59
+ }
60
+
61
+ const { data } = await auth.convex.token();
62
+ return data?.token ?? null;
63
+ } catch (err: any) {
64
+ lastError = err;
65
+ // Only retry on "Failed to fetch" errors
66
+ if (attempt < 2 && err?.message === 'Failed to fetch') {
67
+ continue;
68
+ }
69
+ throw err;
70
+ }
71
+ }
72
+ throw lastError;
73
+ };
74
+
75
+ client.setAuth(fetchAccessToken);
76
+ void client.warmAuth();
77
+ return client;
78
+ },
79
+ },
80
+ ];
81
+ }
82
+
83
+ /**
84
+ * Optional environment initializer to handle OTT (?ott=...) once and upgrade to a cookie session.
85
+ * Keep separate from main provider for explicit opt-in.
86
+ */
87
+ export function provideBetterAuthOttBootstrap() {
88
+ return provideEnvironmentInitializer(() => {
89
+ (async () => {
90
+ const auth = inject(AUTH_CLIENT) as AuthClientRequired;
91
+ const url = new URL(window.location.href);
92
+ const ott = url.searchParams.get('ott');
93
+ if (!ott) return;
94
+
95
+ const result = await auth.crossDomain.oneTimeToken.verify({ token: ott });
96
+ const session = result?.data?.session;
97
+ if (session?.token) {
98
+ await auth.getSession({
99
+ fetchOptions: {
100
+ credentials: 'include',
101
+ headers: { Authorization: `Bearer ${session.token}` },
102
+ },
103
+ });
104
+ auth.updateSession();
105
+ }
106
+ url.searchParams.delete('ott');
107
+ window.history.replaceState({}, '', url.toString());
108
+ })();
109
+ });
110
+ }
@@ -0,0 +1,23 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { ConvexAngular } from './convex-angular';
4
+
5
+ describe('ConvexAngular', () => {
6
+ let component: ConvexAngular;
7
+ let fixture: ComponentFixture<ConvexAngular>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ imports: [ConvexAngular]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(ConvexAngular);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
@@ -0,0 +1,15 @@
1
+ import { Component } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'lib-convex-angular',
5
+ imports: [],
6
+ template: `
7
+ <p>
8
+ convNGX works!
9
+ </p>
10
+ `,
11
+ styles: ``
12
+ })
13
+ export class ConvexAngular {
14
+
15
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * ConvexAngularClient
3
+ * - Wraps Convex BaseConvexClient + ConvexHttpClient
4
+ * - Integrates Better Auth via pluggable fetcher (setAuth)
5
+ * - Caches JWT in sessionStorage with BroadcastChannel sync
6
+ * - Auto-refreshes auth token ahead of expiry with jitter
7
+ * - Provides:
8
+ * - watchQuery: live subscription with localQueryResult + onUpdate
9
+ * - query: HTTP one-shot with in-flight de-dupe + 401 retry
10
+ * - mutation: supports optimisticUpdate passthrough
11
+ * - action: HTTP call with 401 retry
12
+ * - Does not change any runtime behavior – documentation and structure only.
13
+ */
14
+ // src/app/convex-angular-client.ts
15
+ import {
16
+ BaseConvexClient,
17
+ type BaseConvexClientOptions,
18
+ type OptimisticUpdate,
19
+ ConvexHttpClient,
20
+ } from 'convex/browser';
21
+ import {
22
+ FunctionReference,
23
+ FunctionArgs,
24
+ FunctionReturnType,
25
+ getFunctionName,
26
+ } from 'convex/server';
27
+ import type { Value } from 'convex/values';
28
+ import type { FetchAccessToken, AuthSnapshot } from './types';
29
+ export type { FetchAccessToken, AuthSnapshot } from './types';
30
+ import { AUTH_KEY, AUTH_CH, jwtExpMs, stableStringify, safeSession } from './helpers';
31
+
32
+ type QueryToken = string;
33
+
34
+ type WatchHandle<Q extends FunctionReference<'query'>> = {
35
+ localQueryResult(): FunctionReturnType<Q> | undefined;
36
+ onUpdate(cb: () => void): () => void;
37
+ unsubscribe(): void;
38
+ };
39
+
40
+ type Entry = {
41
+ name: string;
42
+ args: Record<string, Value>;
43
+ listeners: Set<() => void>;
44
+ unsubscribe: () => void;
45
+ };
46
+
47
+ type StoredToken = { value: string; exp: number };
48
+
49
+ /* helpers moved to ./helpers (no behavior change) */
50
+
51
+
52
+ const bc =
53
+ typeof BroadcastChannel !== 'undefined'
54
+ ? new BroadcastChannel(AUTH_CH)
55
+ : (null as BroadcastChannel | null);
56
+
57
+ export class ConvexAngularClient {
58
+ private authListeners = new Set<(s: AuthSnapshot) => void>();
59
+ private lastSnap?: AuthSnapshot;
60
+ // ==== internals ====
61
+ private emitAuth() {
62
+ const snap = this.getAuthSnapshot();
63
+ // de-dupe emissions
64
+ if (
65
+ this.lastSnap &&
66
+ this.lastSnap.isAuthenticated === snap.isAuthenticated &&
67
+ this.lastSnap.token === snap.token
68
+ ) {
69
+ return;
70
+ }
71
+ this.lastSnap = snap;
72
+ for (const cb of this.authListeners) cb(snap);
73
+ }
74
+
75
+ private base: BaseConvexClient;
76
+ private http: ConvexHttpClient;
77
+ private byToken = new Map<QueryToken, Entry>();
78
+
79
+ private fetchToken?: FetchAccessToken;
80
+ private inflightToken?: Promise<string | null>;
81
+ private token?: StoredToken;
82
+
83
+ private refreshTimer?: number;
84
+ private inflightHttp = new Map<string, Promise<any>>();
85
+ private authLocked = false;
86
+
87
+ private readonly skewMs: number;
88
+
89
+ constructor(url: string, opts?: BaseConvexClientOptions & { authSkewMs?: number }) {
90
+ this.skewMs = opts?.authSkewMs ?? 30_000;
91
+
92
+ this.base = new BaseConvexClient(
93
+ url,
94
+ (updatedTokens: QueryToken[]) => {
95
+ for (const t of updatedTokens) {
96
+ const e = this.byToken.get(t);
97
+ if (!e) continue;
98
+ for (const cb of e.listeners) cb();
99
+ }
100
+ },
101
+ opts,
102
+ );
103
+
104
+ this.http = new ConvexHttpClient(url);
105
+
106
+ // pick up cached token
107
+ const raw = safeSession.get(AUTH_KEY);
108
+ if (raw) {
109
+ try {
110
+ const t: StoredToken = JSON.parse(raw);
111
+ if (t?.value && t?.exp && Date.now() < t.exp - this.skewMs) {
112
+ this.token = t;
113
+ this.http.setAuth(t.value);
114
+ this.scheduleRefresh();
115
+ }
116
+ } catch {}
117
+ }
118
+ // 3) BroadcastChannel: keep as-is (clear only on 'clear', set on 'set')
119
+ bc?.addEventListener('message', (ev: MessageEvent) => {
120
+ const d: any = ev.data;
121
+ if (d?.type === 'set') {
122
+ if (this.authLocked) return; // ignore while logging out
123
+ this.applyToken(d.token?.value ?? null);
124
+ } else if (d?.type === 'clear') {
125
+ this.applyToken(null);
126
+ }
127
+ });
128
+
129
+ // visibility/network pokes
130
+ const poke = () => {
131
+ if (!this.freshTokenInCache()) void this.getToken(true);
132
+ };
133
+ if (typeof window !== 'undefined') {
134
+ window.addEventListener('online', poke);
135
+ document.addEventListener?.('visibilitychange', () => {
136
+ if (document.visibilityState === 'visible') poke();
137
+ });
138
+ }
139
+ }
140
+
141
+ setAuth(fetcher: FetchAccessToken) {
142
+ this.fetchToken = fetcher;
143
+ this.base.setAuth(
144
+ async ({ forceRefreshToken }) => (this.authLocked ? null : this.getToken(forceRefreshToken)),
145
+ () => {
146
+ // identity changed (connect/reconnect). Do NOT clear token here.
147
+ // If we're truly logged out, next HTTP/WS use will 401 and we clear then.
148
+ this.inflightToken = undefined;
149
+ this.emitAuth(); // soft notify, no flip to false
150
+ },
151
+ );
152
+ }
153
+
154
+ /** Optional: call once on app start */
155
+ async warmAuth(): Promise<void> {
156
+ await this.getToken(true);
157
+ }
158
+
159
+ private freshTokenInCache(): string | null {
160
+ if (!this.token) return null;
161
+ return Date.now() < this.token.exp - this.skewMs ? this.token.value : null;
162
+ }
163
+
164
+ // ==== public auth helpers ====
165
+ onAuth(cb: (s: AuthSnapshot) => void): () => void {
166
+ cb(this.getAuthSnapshot());
167
+ this.authListeners.add(cb);
168
+ return () => this.authListeners.delete(cb);
169
+ }
170
+
171
+ logoutLocal(lock = true) {
172
+ if (lock) this.authLocked = true;
173
+ this.inflightToken = undefined;
174
+ this.applyToken(null); // this is the ONLY place we hard clear locally
175
+ }
176
+
177
+ // 5) snapshot uses token presence (not freshness)
178
+ getAuthSnapshot() {
179
+ return {
180
+ isAuthenticated: !!this.token?.value,
181
+ token: this.token?.value ?? null,
182
+ exp: this.token?.exp,
183
+ };
184
+ }
185
+
186
+ /** Allow re-auth then fetch a fresh token (use after successful sign-in) */
187
+ async refreshAuth(): Promise<void> {
188
+ this.authLocked = false;
189
+ await this.getToken(true);
190
+ }
191
+
192
+ // 4) applyToken: always emit after mutation (you already fixed this)
193
+ private applyToken(token: string | null) {
194
+ if (!token) {
195
+ this.token = undefined;
196
+ this.http.clearAuth();
197
+ safeSession.del(AUTH_KEY);
198
+ bc?.postMessage({ type: 'clear' });
199
+ if (this.refreshTimer) window.clearTimeout(this.refreshTimer);
200
+ } else {
201
+ if (this.authLocked) {
202
+ this.emitAuth();
203
+ return;
204
+ }
205
+ const exp = jwtExpMs(token);
206
+ this.token = { value: token, exp };
207
+ this.http.setAuth(token);
208
+ safeSession.set(AUTH_KEY, JSON.stringify(this.token));
209
+ bc?.postMessage({ type: 'set', token: this.token });
210
+ this.scheduleRefresh();
211
+ }
212
+ this.emitAuth();
213
+ }
214
+
215
+ private scheduleRefresh() {
216
+ if (!this.token) return;
217
+ if (this.refreshTimer) window.clearTimeout(this.refreshTimer);
218
+ const jitter = 2_000 + Math.floor(Math.random() * 2_000);
219
+ const due = Math.max(0, this.token.exp - this.skewMs - Date.now() - jitter);
220
+ this.refreshTimer = window.setTimeout(() => {
221
+ void this.getToken(true);
222
+ }, due);
223
+ }
224
+
225
+ private async getToken(force: boolean): Promise<string | null> {
226
+ if (!this.fetchToken || this.authLocked) return null; // 🔒 deny any token
227
+ const cached = this.freshTokenInCache();
228
+ if (!force && cached) return cached;
229
+
230
+ if (!this.inflightToken) {
231
+ this.inflightToken = (async () => {
232
+ const t = await this.fetchToken!({ forceRefreshToken: true });
233
+ // ignore token if we got locked while waiting
234
+ if (this.authLocked) {
235
+ this.emitAuth(); // ensure listeners see current (likely false)
236
+ return null;
237
+ }
238
+ this.applyToken(t ?? null);
239
+ return t ?? null;
240
+ })().finally(() => setTimeout(() => (this.inflightToken = undefined), 0));
241
+ }
242
+ return await this.inflightToken;
243
+ }
244
+
245
+ private async ensureHttpAuth(): Promise<void> {
246
+ await this.getToken(false);
247
+ }
248
+
249
+ // ——— live query ———
250
+ watchQuery<Q extends FunctionReference<'query'>>(q: Q, args: FunctionArgs<Q>): WatchHandle<Q> {
251
+ const name = getFunctionName(q);
252
+ const valueArgs = (args ?? {}) as unknown as Record<string, Value>;
253
+ const { queryToken, unsubscribe } = this.base.subscribe(name, valueArgs);
254
+
255
+ const entry: Entry = { name, args: valueArgs, listeners: new Set(), unsubscribe };
256
+ this.byToken.set(queryToken, entry);
257
+
258
+ return {
259
+ localQueryResult: () =>
260
+ this.base.localQueryResult(name, valueArgs) as FunctionReturnType<Q> | undefined,
261
+ onUpdate: (cb) => {
262
+ entry.listeners.add(cb);
263
+ return () => entry.listeners.delete(cb);
264
+ },
265
+ unsubscribe: () => {
266
+ entry.unsubscribe();
267
+ this.byToken.delete(queryToken);
268
+ },
269
+ };
270
+ }
271
+
272
+ // ——— mutation ———
273
+ async mutation<M extends FunctionReference<'mutation'>>(
274
+ m: M,
275
+ args: FunctionArgs<M>,
276
+ opts?: { optimisticUpdate?: OptimisticUpdate<FunctionArgs<M>> },
277
+ ): Promise<FunctionReturnType<M>> {
278
+ const name = getFunctionName(m);
279
+ return this.base.mutation(
280
+ name,
281
+ args as any,
282
+ opts?.optimisticUpdate ? { optimisticUpdate: opts.optimisticUpdate as any } : undefined,
283
+ );
284
+ }
285
+ // ——— action (no dedupe) ———
286
+ async action<A extends FunctionReference<'action'> & { _args: Record<string, never> }>(
287
+ a: A,
288
+ ): Promise<FunctionReturnType<A>>;
289
+ async action<A extends FunctionReference<'action'>>(
290
+ a: A,
291
+ args: FunctionArgs<A>,
292
+ ): Promise<FunctionReturnType<A>>;
293
+ async action<A extends FunctionReference<'action'>>(
294
+ a: A,
295
+ args?: FunctionArgs<A>,
296
+ ): Promise<FunctionReturnType<A>> {
297
+ await this.ensureHttpAuth();
298
+
299
+ const call = () => this.http.action(a, ...(args ? ([args] as any) : []));
300
+
301
+ try {
302
+ return await call();
303
+ } catch (e: any) {
304
+ const status = e?.status ?? e?.response?.status;
305
+ if (this.fetchToken && status === 401) {
306
+ await this.getToken(true);
307
+ return await call();
308
+ }
309
+ throw e;
310
+ }
311
+ }
312
+
313
+ // ——— one-shot query (HTTP with de-dupe) ———
314
+ async query<Q extends FunctionReference<'query'> & { _args: Record<string, never> }>(
315
+ q: Q,
316
+ ): Promise<FunctionReturnType<Q>>;
317
+ async query<Q extends FunctionReference<'query'>>(
318
+ q: Q,
319
+ args: FunctionArgs<Q>,
320
+ ): Promise<FunctionReturnType<Q>>;
321
+ async query<Q extends FunctionReference<'query'>>(
322
+ q: Q,
323
+ args?: FunctionArgs<Q>,
324
+ ): Promise<FunctionReturnType<Q>> {
325
+ await this.ensureHttpAuth();
326
+
327
+ const name = getFunctionName(q);
328
+ const key = name + ':' + (args ? stableStringify(args) : '');
329
+
330
+ if (!this.inflightHttp.has(key)) {
331
+ this.inflightHttp.set(
332
+ key,
333
+ (async () => {
334
+ try {
335
+ return await this.http.query(q, ...(args ? ([args] as any) : []));
336
+ } catch (e: any) {
337
+ const status = e?.status ?? e?.response?.status;
338
+ if (this.fetchToken && status === 401) {
339
+ await this.getToken(true);
340
+ return await this.http.query(q, ...(args ? ([args] as any) : []));
341
+ }
342
+ throw e;
343
+ } finally {
344
+ setTimeout(() => this.inflightHttp.delete(key), 0);
345
+ }
346
+ })(),
347
+ );
348
+ }
349
+ return this.inflightHttp.get(key)!;
350
+ }
351
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Internal helpers for ConvexAngularClient.
3
+ * NOTE: Pure refactor; functionality unchanged.
4
+ */
5
+
6
+ export const AUTH_KEY = 'convex:jwt';
7
+ export const AUTH_CH = 'convex-auth';
8
+
9
+ /** Decode JWT exp claim (ms). Returns 0 if invalid. */
10
+ export function jwtExpMs(jwt: string): number {
11
+ try {
12
+ const payload = JSON.parse(atob(jwt.split('.')[1] || ''));
13
+ return typeof payload?.exp === 'number' ? payload.exp * 1000 : 0;
14
+ } catch {
15
+ return 0;
16
+ }
17
+ }
18
+
19
+ /** Canonical JSON stringify for stable de-dupe keys. */
20
+ export function stableStringify(input: unknown): string {
21
+ const seen = new WeakSet<object>();
22
+ const norm = (v: any): any => {
23
+ if (v === null || typeof v !== 'object') return v;
24
+ if (seen.has(v)) return v;
25
+ seen.add(v);
26
+ if (Array.isArray(v)) return v.map(norm);
27
+ return Object.keys(v)
28
+ .sort()
29
+ .reduce((acc: any, k) => {
30
+ acc[k] = norm(v[k]);
31
+ return acc;
32
+ }, {});
33
+ };
34
+ return JSON.stringify(norm(input));
35
+ }
36
+
37
+ /** sessionStorage helpers (some browsers may throw on access) */
38
+ export const safeSession = {
39
+ get: (k: string): string | null => {
40
+ try {
41
+ return typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(k) : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ },
46
+ set: (k: string, v: string) => {
47
+ try {
48
+ if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(k, v);
49
+ } catch {
50
+ /* no-op */
51
+ }
52
+ },
53
+ del: (k: string) => {
54
+ try {
55
+ if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem(k);
56
+ } catch {
57
+ /* no-op */
58
+ }
59
+ },
60
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Injection token and helper for accessing the Convex client from DI.
3
+ * Keep this tiny and stable — many helpers rely on it.
4
+ */
5
+ import { inject, InjectionToken } from '@angular/core';
6
+ import { ConvexAngularClient } from './convex-angular-client';
7
+
8
+ /** DI token for the configured ConvexAngularClient instance */
9
+ export const CONVEX = new InjectionToken<ConvexAngularClient>('CONVEX');
10
+
11
+ /** Convenience helper to inject the Convex client */
12
+ export const injectConvex = () => inject(CONVEX);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared types for Convex Angular core.
3
+ * Pure extraction for readability; no behavior changes.
4
+ */
5
+
6
+ /** Auth token fetcher used by ConvexAngularClient.setAuth */
7
+ export type FetchAccessToken = (o: { forceRefreshToken: boolean }) => Promise<string | null>;
8
+
9
+ /** Snapshot of current auth state (token presence-based) */
10
+ export type AuthSnapshot = {
11
+ isAuthenticated: boolean;
12
+ token: string | null;
13
+ exp?: number;
14
+ };