@oxyhq/core 1.11.12 → 1.11.13
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/CrossDomainAuth.js +3 -1
- package/dist/cjs/HttpService.js +214 -33
- package/dist/cjs/OxyServices.base.js +9 -0
- package/dist/cjs/OxyServices.js +8 -3
- package/dist/cjs/crypto/index.js +3 -1
- package/dist/cjs/crypto/keyManager.js +476 -172
- package/dist/cjs/crypto/polyfill.js +14 -65
- package/dist/cjs/crypto/recoveryPhrase.js +30 -11
- package/dist/cjs/crypto/signatureService.js +25 -60
- package/dist/cjs/i18n/locales/en-US.json +46 -1
- package/dist/cjs/i18n/locales/es-ES.json +46 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +46 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +46 -1
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/mixins/OxyServices.assets.js +9 -4
- package/dist/cjs/mixins/OxyServices.auth.js +27 -0
- package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
- package/dist/cjs/mixins/OxyServices.features.js +0 -11
- package/dist/cjs/mixins/OxyServices.fedcm.js +4 -3
- package/dist/cjs/mixins/OxyServices.language.js +5 -36
- package/dist/cjs/mixins/OxyServices.redirect.js +6 -2
- package/dist/cjs/mixins/OxyServices.security.js +13 -2
- package/dist/cjs/mixins/OxyServices.user.js +59 -38
- package/dist/cjs/mixins/OxyServices.utility.js +19 -43
- package/dist/cjs/mixins/index.js +11 -3
- package/dist/cjs/utils/accountUtils.js +71 -2
- package/dist/cjs/utils/deviceManager.js +5 -36
- package/dist/cjs/utils/platformCrypto.js +165 -0
- package/dist/cjs/utils/platformCrypto.native.js +123 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/CrossDomainAuth.js +3 -1
- package/dist/esm/HttpService.js +215 -34
- package/dist/esm/OxyServices.base.js +9 -0
- package/dist/esm/OxyServices.js +8 -3
- package/dist/esm/crypto/index.js +1 -1
- package/dist/esm/crypto/keyManager.js +473 -138
- package/dist/esm/crypto/polyfill.js +14 -32
- package/dist/esm/crypto/recoveryPhrase.js +30 -11
- package/dist/esm/crypto/signatureService.js +25 -27
- package/dist/esm/i18n/locales/en-US.json +46 -1
- package/dist/esm/i18n/locales/es-ES.json +46 -1
- package/dist/esm/i18n/locales/locales/en-US.json +46 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +46 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/mixins/OxyServices.assets.js +9 -4
- package/dist/esm/mixins/OxyServices.auth.js +27 -0
- package/dist/esm/mixins/OxyServices.contacts.js +47 -0
- package/dist/esm/mixins/OxyServices.features.js +0 -11
- package/dist/esm/mixins/OxyServices.fedcm.js +4 -3
- package/dist/esm/mixins/OxyServices.language.js +5 -3
- package/dist/esm/mixins/OxyServices.redirect.js +6 -2
- package/dist/esm/mixins/OxyServices.security.js +13 -2
- package/dist/esm/mixins/OxyServices.user.js +59 -38
- package/dist/esm/mixins/OxyServices.utility.js +19 -10
- package/dist/esm/mixins/index.js +11 -3
- package/dist/esm/utils/accountUtils.js +67 -1
- package/dist/esm/utils/deviceManager.js +5 -3
- package/dist/esm/utils/platformCrypto.js +125 -0
- package/dist/esm/utils/platformCrypto.native.js +80 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +47 -3
- package/dist/types/OxyServices.base.d.ts +7 -0
- package/dist/types/OxyServices.d.ts +36 -3
- package/dist/types/crypto/index.d.ts +1 -1
- package/dist/types/crypto/keyManager.d.ts +110 -9
- package/dist/types/crypto/polyfill.d.ts +3 -1
- package/dist/types/crypto/recoveryPhrase.d.ts +31 -7
- package/dist/types/crypto/signatureService.d.ts +4 -0
- package/dist/types/index.d.ts +4 -3
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +6 -10
- package/dist/types/mixins/OxyServices.auth.d.ts +16 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +99 -0
- package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +2 -7
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +28 -11
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/dist/types/mixins/index.d.ts +52 -4
- package/dist/types/models/interfaces.d.ts +62 -3
- package/dist/types/utils/accountUtils.d.ts +41 -1
- package/dist/types/utils/platformCrypto.d.ts +87 -0
- package/dist/types/utils/platformCrypto.native.d.ts +54 -0
- package/package.json +28 -1
- package/src/CrossDomainAuth.ts +12 -10
- package/src/HttpService.ts +251 -40
- package/src/OxyServices.base.ts +10 -0
- package/src/OxyServices.ts +9 -4
- package/src/crypto/__tests__/keyManager.test.ts +336 -0
- package/src/crypto/index.ts +6 -1
- package/src/crypto/keyManager.ts +529 -151
- package/src/crypto/polyfill.ts +14 -34
- package/src/crypto/recoveryPhrase.ts +56 -17
- package/src/crypto/signatureService.ts +25 -30
- package/src/i18n/locales/en-US.json +46 -1
- package/src/i18n/locales/es-ES.json +46 -1
- package/src/index.ts +16 -3
- package/src/mixins/OxyServices.assets.ts +15 -11
- package/src/mixins/OxyServices.auth.ts +28 -0
- package/src/mixins/OxyServices.contacts.ts +73 -0
- package/src/mixins/OxyServices.features.ts +2 -12
- package/src/mixins/OxyServices.fedcm.ts +4 -3
- package/src/mixins/OxyServices.language.ts +6 -4
- package/src/mixins/OxyServices.redirect.ts +6 -2
- package/src/mixins/OxyServices.security.ts +18 -8
- package/src/mixins/OxyServices.user.ts +72 -49
- package/src/mixins/OxyServices.utility.ts +19 -10
- package/src/mixins/index.ts +58 -7
- package/src/models/interfaces.ts +65 -3
- package/src/utils/accountUtils.ts +82 -2
- package/src/utils/deviceManager.ts +7 -4
- package/src/utils/platformCrypto.native.ts +101 -0
- package/src/utils/platformCrypto.ts +145 -0
package/src/CrossDomainAuth.ts
CHANGED
|
@@ -130,7 +130,7 @@ export class CrossDomainAuth {
|
|
|
130
130
|
* Best method - browser-native, no popups, Google-like experience
|
|
131
131
|
*/
|
|
132
132
|
async signInWithFedCM(options: CrossDomainAuthOptions = {}): Promise<SessionLoginResponse> {
|
|
133
|
-
return
|
|
133
|
+
return this.oxyServices.signInWithFedCM({
|
|
134
134
|
context: options.isSignup ? 'signup' : 'signin',
|
|
135
135
|
});
|
|
136
136
|
}
|
|
@@ -141,7 +141,7 @@ export class CrossDomainAuth {
|
|
|
141
141
|
* Good method - preserves app state, no full page reload
|
|
142
142
|
*/
|
|
143
143
|
async signInWithPopup(options: CrossDomainAuthOptions = {}): Promise<SessionLoginResponse> {
|
|
144
|
-
return
|
|
144
|
+
return this.oxyServices.signInWithPopup({
|
|
145
145
|
mode: options.isSignup ? 'signup' : 'login',
|
|
146
146
|
width: options.popupDimensions?.width,
|
|
147
147
|
height: options.popupDimensions?.height,
|
|
@@ -154,7 +154,7 @@ export class CrossDomainAuth {
|
|
|
154
154
|
* Fallback method - works everywhere but loses app state
|
|
155
155
|
*/
|
|
156
156
|
signInWithRedirect(options: CrossDomainAuthOptions = {}): void {
|
|
157
|
-
|
|
157
|
+
this.oxyServices.signInWithRedirect({
|
|
158
158
|
redirectUri: options.redirectUri,
|
|
159
159
|
mode: options.isSignup ? 'signup' : 'login',
|
|
160
160
|
});
|
|
@@ -166,7 +166,7 @@ export class CrossDomainAuth {
|
|
|
166
166
|
* Call this on app startup to check if we're returning from auth redirect
|
|
167
167
|
*/
|
|
168
168
|
handleRedirectCallback(): SessionLoginResponse | null {
|
|
169
|
-
return
|
|
169
|
+
return this.oxyServices.handleAuthCallback();
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
/**
|
|
@@ -181,7 +181,7 @@ export class CrossDomainAuth {
|
|
|
181
181
|
// Try FedCM silent sign-in first (if supported)
|
|
182
182
|
if (this.isFedCMSupported()) {
|
|
183
183
|
try {
|
|
184
|
-
const session = await
|
|
184
|
+
const session = await this.oxyServices.silentSignInWithFedCM();
|
|
185
185
|
if (session) {
|
|
186
186
|
return session;
|
|
187
187
|
}
|
|
@@ -192,7 +192,7 @@ export class CrossDomainAuth {
|
|
|
192
192
|
|
|
193
193
|
// Fallback to iframe-based silent auth
|
|
194
194
|
try {
|
|
195
|
-
return await
|
|
195
|
+
return await this.oxyServices.silentSignIn();
|
|
196
196
|
} catch (error) {
|
|
197
197
|
console.warn('[CrossDomainAuth] Silent sign-in failed:', error);
|
|
198
198
|
return null;
|
|
@@ -205,14 +205,16 @@ export class CrossDomainAuth {
|
|
|
205
205
|
* For redirect method - restores previously authenticated session from localStorage
|
|
206
206
|
*/
|
|
207
207
|
restoreSession(): boolean {
|
|
208
|
-
return
|
|
208
|
+
return this.oxyServices.restoreSession?.() || false;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
/**
|
|
212
212
|
* Check if FedCM is supported in current browser
|
|
213
213
|
*/
|
|
214
214
|
isFedCMSupported(): boolean {
|
|
215
|
-
|
|
215
|
+
// FedCM support is exposed both as a static and an instance method on
|
|
216
|
+
// OxyServices; the instance method is reliable across mixin composition.
|
|
217
|
+
return this.oxyServices.isFedCMSupported?.() || false;
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
/**
|
|
@@ -263,10 +265,10 @@ export class CrossDomainAuth {
|
|
|
263
265
|
if (restored) {
|
|
264
266
|
// Verify session is still valid by fetching user
|
|
265
267
|
try {
|
|
266
|
-
const user = await
|
|
268
|
+
const user = await this.oxyServices.getCurrentUser();
|
|
267
269
|
if (user) {
|
|
268
270
|
return {
|
|
269
|
-
sessionId:
|
|
271
|
+
sessionId: this.oxyServices.getStoredSessionId?.() || '',
|
|
270
272
|
deviceId: '',
|
|
271
273
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
272
274
|
user,
|
package/src/HttpService.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/request
|
|
|
18
18
|
import { retryAsync } from './utils/asyncUtils';
|
|
19
19
|
import { handleHttpError } from './utils/errorUtils';
|
|
20
20
|
import { jwtDecode } from 'jwt-decode';
|
|
21
|
-
import { isNative, getPlatformOS } from './utils/platform';
|
|
21
|
+
import { isNative, isReactNative, getPlatformOS } from './utils/platform';
|
|
22
22
|
import type { OxyConfig } from './models/interfaces';
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -35,6 +35,52 @@ interface JwtPayload {
|
|
|
35
35
|
[key: string]: any;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Structural type that captures the multipart-write surface every supported
|
|
40
|
+
* FormData implementation exposes (browser, React Native, Node `form-data`
|
|
41
|
+
* polyfill, jsdom, undici, etc). We type-narrow against this in
|
|
42
|
+
* `isFormData()` so callers don't have to know which runtime produced the
|
|
43
|
+
* value.
|
|
44
|
+
*
|
|
45
|
+
* Deliberately mirrored from the lib.dom `FormData` interface — kept as a
|
|
46
|
+
* local type because @types/node and @types/react-native model FormData
|
|
47
|
+
* differently and a single import wouldn't be safe in both bundles.
|
|
48
|
+
*/
|
|
49
|
+
interface FormDataLike {
|
|
50
|
+
append(name: string, value: unknown, fileName?: string): void;
|
|
51
|
+
delete(name: string): void;
|
|
52
|
+
get(name: string): unknown;
|
|
53
|
+
getAll(name: string): unknown[];
|
|
54
|
+
has(name: string): boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* FNV-1a 32-bit non-cryptographic hash.
|
|
59
|
+
*
|
|
60
|
+
* Used by the cache-key generator for large payloads where full JSON
|
|
61
|
+
* inclusion would balloon the cache map keys. Content-addressed: every
|
|
62
|
+
* byte of the input contributes to the digest, so two payloads with the
|
|
63
|
+
* same top-level shape but different field values produce different keys
|
|
64
|
+
* (the previous `keys + length` heuristic collided on these).
|
|
65
|
+
*
|
|
66
|
+
* Trade-offs:
|
|
67
|
+
* - 32 bits is ample for an in-process cache (collision risk negligible
|
|
68
|
+
* at our key counts; we also prefix with method + url which further
|
|
69
|
+
* partitions the keyspace).
|
|
70
|
+
* - Not cryptographically secure — never use for security decisions.
|
|
71
|
+
* - Zero dependencies, branch-free hot loop, ~1 GiB/s on V8.
|
|
72
|
+
*/
|
|
73
|
+
function fnv1a32(str: string): string {
|
|
74
|
+
let h = 0x811c9dc5;
|
|
75
|
+
for (let i = 0; i < str.length; i++) {
|
|
76
|
+
h ^= str.charCodeAt(i);
|
|
77
|
+
// h * 16777619 mod 2^32, written as shift-and-add for portability and
|
|
78
|
+
// to avoid 53-bit JS number truncation in the intermediate multiply.
|
|
79
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
|
80
|
+
}
|
|
81
|
+
return h.toString(16).padStart(8, '0');
|
|
82
|
+
}
|
|
83
|
+
|
|
38
84
|
export interface RequestOptions {
|
|
39
85
|
cache?: boolean;
|
|
40
86
|
cacheTTL?: number;
|
|
@@ -165,35 +211,59 @@ export class HttpService {
|
|
|
165
211
|
}
|
|
166
212
|
|
|
167
213
|
/**
|
|
168
|
-
* Robust FormData detection that works in browser
|
|
169
|
-
*
|
|
214
|
+
* Robust FormData detection that works in browser, React Native, and
|
|
215
|
+
* Node.js polyfill environments.
|
|
216
|
+
*
|
|
217
|
+
* Why we don't use `instanceof FormData` alone:
|
|
218
|
+
* - React Native's FormData is a separate class, not the browser one —
|
|
219
|
+
* `instanceof FormData` is true only inside the JS runtime that
|
|
220
|
+
* instantiated the value (browser-side polyfills also have their own).
|
|
221
|
+
* - The Node.js `form-data` polyfill ships its own constructor.
|
|
222
|
+
*
|
|
223
|
+
* Why we explicitly reject `URLSearchParams`:
|
|
224
|
+
* - `URLSearchParams` ALSO exposes `append` / `get` / `has`, so the
|
|
225
|
+
* duck-type fallback below would have misidentified it as FormData.
|
|
226
|
+
* - We want urlencoded payloads to take the JSON-stringify path so the
|
|
227
|
+
* server receives them as `application/x-www-form-urlencoded` instead
|
|
228
|
+
* of an empty multipart body.
|
|
170
229
|
*/
|
|
171
|
-
private isFormData(data: unknown):
|
|
172
|
-
if (!data) {
|
|
230
|
+
private isFormData(data: unknown): data is FormDataLike {
|
|
231
|
+
if (!data || typeof data !== 'object') {
|
|
173
232
|
return false;
|
|
174
233
|
}
|
|
175
234
|
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
235
|
+
// Reject URLSearchParams up front: it shares the duck-typed surface
|
|
236
|
+
// (append / get / has) but is a fundamentally different content type.
|
|
237
|
+
// The caller routes URLSearchParams through the regular body path.
|
|
238
|
+
if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) {
|
|
239
|
+
return false;
|
|
179
240
|
}
|
|
180
241
|
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
242
|
+
// Primary check: instanceof FormData. Works whenever the value was
|
|
243
|
+
// constructed by the same runtime/realm that exposes `FormData`.
|
|
244
|
+
if (typeof FormData !== 'undefined' && data instanceof FormData) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
187
247
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
248
|
+
// Fallback: detect Node / RN polyfills by constructor name. Limited to
|
|
249
|
+
// the small handful of known names so we don't accept arbitrary
|
|
250
|
+
// user-supplied objects with a coincidental `name`.
|
|
251
|
+
const constructorName = data.constructor?.name;
|
|
252
|
+
if (constructorName === 'FormData' || constructorName === 'FormDataImpl') {
|
|
253
|
+
return true;
|
|
194
254
|
}
|
|
195
255
|
|
|
196
|
-
|
|
256
|
+
// Last-resort duck typing — require the full FormData write surface
|
|
257
|
+
// (`append`, `get`, `has`, `getAll`, `delete`) so plain objects with
|
|
258
|
+
// an `append` method don't accidentally match.
|
|
259
|
+
const candidate = data as Partial<Record<keyof FormDataLike, unknown>>;
|
|
260
|
+
return (
|
|
261
|
+
typeof candidate.append === 'function' &&
|
|
262
|
+
typeof candidate.get === 'function' &&
|
|
263
|
+
typeof candidate.has === 'function' &&
|
|
264
|
+
typeof candidate.getAll === 'function' &&
|
|
265
|
+
typeof candidate.delete === 'function'
|
|
266
|
+
);
|
|
197
267
|
}
|
|
198
268
|
|
|
199
269
|
/**
|
|
@@ -314,17 +384,27 @@ export class HttpService {
|
|
|
314
384
|
});
|
|
315
385
|
}
|
|
316
386
|
|
|
317
|
-
const bodyValue = method !== 'GET' && data
|
|
318
|
-
? (isFormData ? data : JSON.stringify(data))
|
|
387
|
+
const bodyValue = method !== 'GET' && data
|
|
388
|
+
? (isFormData ? data : JSON.stringify(data))
|
|
319
389
|
: undefined;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
390
|
+
|
|
391
|
+
// React Native FormData workaround:
|
|
392
|
+
// Expo SDK 56's "winter fetch" rejects RN file descriptors `{uri, type, name}`
|
|
393
|
+
// in FormDataPart conversion (`Unsupported FormDataPart implementation`).
|
|
394
|
+
// RN's native XMLHttpRequest handles those descriptors correctly, so we
|
|
395
|
+
// route multipart uploads through XHR on RN only. JSON, text, etc. still
|
|
396
|
+
// use fetch on every platform.
|
|
397
|
+
const useXhrForUpload = isFormData && isReactNative() && typeof XMLHttpRequest !== 'undefined';
|
|
398
|
+
|
|
399
|
+
const response = useXhrForUpload
|
|
400
|
+
? await this.uploadViaXHR(fullUrl, method, headers, bodyValue as FormData, controller.signal, timeout)
|
|
401
|
+
: await fetch(fullUrl, {
|
|
402
|
+
method,
|
|
403
|
+
headers,
|
|
404
|
+
body: bodyValue as BodyInit | null | undefined,
|
|
405
|
+
signal: controller.signal,
|
|
406
|
+
credentials: 'include', // Include cookies for cross-origin requests (CSRF, session)
|
|
407
|
+
});
|
|
328
408
|
|
|
329
409
|
if (timeoutId) clearTimeout(timeoutId);
|
|
330
410
|
|
|
@@ -473,28 +553,140 @@ export class HttpService {
|
|
|
473
553
|
return result;
|
|
474
554
|
}
|
|
475
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Upload via XMLHttpRequest (React Native FormData workaround).
|
|
558
|
+
*
|
|
559
|
+
* Expo SDK 56's "winter fetch" cannot serialize RN file descriptors
|
|
560
|
+
* (`{uri, type, name}`) — `convertFormDataAsync` rejects them as
|
|
561
|
+
* `Unsupported FormDataPart implementation`. RN's native XHR streams
|
|
562
|
+
* the file from disk correctly, so multipart uploads go through XHR
|
|
563
|
+
* on RN only.
|
|
564
|
+
*
|
|
565
|
+
* Returns a standard `Response` so downstream parsing in `request()`
|
|
566
|
+
* (status checks, 401/403 retries, JSON/blob/text parsing) is identical
|
|
567
|
+
* to the fetch path.
|
|
568
|
+
*/
|
|
569
|
+
private uploadViaXHR(
|
|
570
|
+
url: string,
|
|
571
|
+
method: string,
|
|
572
|
+
headers: Record<string, string>,
|
|
573
|
+
body: FormData,
|
|
574
|
+
abortSignal: AbortSignal,
|
|
575
|
+
timeout: number,
|
|
576
|
+
): Promise<Response> {
|
|
577
|
+
return new Promise<Response>((resolve, reject) => {
|
|
578
|
+
const xhr = new XMLHttpRequest();
|
|
579
|
+
xhr.open(method, url, true);
|
|
580
|
+
// withCredentials mirrors fetch's `credentials: 'include'` so the
|
|
581
|
+
// session cookie and CSRF cookie continue to flow.
|
|
582
|
+
xhr.withCredentials = true;
|
|
583
|
+
|
|
584
|
+
// Forward headers but skip Content-Type — XHR sets the multipart
|
|
585
|
+
// boundary automatically and overriding it breaks the upload.
|
|
586
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
587
|
+
if (key.toLowerCase() === 'content-type') continue;
|
|
588
|
+
try {
|
|
589
|
+
xhr.setRequestHeader(key, value);
|
|
590
|
+
} catch (headerError) {
|
|
591
|
+
// Some headers (e.g. forbidden header names) cannot be set —
|
|
592
|
+
// log and continue rather than failing the whole upload.
|
|
593
|
+
this.logger.warn('XHR setRequestHeader failed:', key, headerError);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
xhr.responseType = 'text';
|
|
598
|
+
if (timeout > 0) {
|
|
599
|
+
xhr.timeout = timeout;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const onAbort = (): void => {
|
|
603
|
+
try { xhr.abort(); } catch { /* xhr already finished */ }
|
|
604
|
+
};
|
|
605
|
+
if (abortSignal.aborted) {
|
|
606
|
+
reject(new DOMException('The user aborted a request.', 'AbortError'));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
abortSignal.addEventListener('abort', onAbort);
|
|
610
|
+
|
|
611
|
+
const cleanup = (): void => {
|
|
612
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
xhr.onload = (): void => {
|
|
616
|
+
cleanup();
|
|
617
|
+
const responseHeaders = HttpService.parseXHRHeaders(xhr.getAllResponseHeaders());
|
|
618
|
+
resolve(new Response(xhr.responseText, {
|
|
619
|
+
status: xhr.status,
|
|
620
|
+
statusText: xhr.statusText,
|
|
621
|
+
headers: responseHeaders,
|
|
622
|
+
}));
|
|
623
|
+
};
|
|
624
|
+
xhr.onerror = (): void => {
|
|
625
|
+
cleanup();
|
|
626
|
+
reject(new TypeError('Network request failed'));
|
|
627
|
+
};
|
|
628
|
+
xhr.ontimeout = (): void => {
|
|
629
|
+
cleanup();
|
|
630
|
+
reject(new DOMException('The request timed out.', 'TimeoutError'));
|
|
631
|
+
};
|
|
632
|
+
xhr.onabort = (): void => {
|
|
633
|
+
cleanup();
|
|
634
|
+
reject(new DOMException('The user aborted a request.', 'AbortError'));
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
xhr.send(body);
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Parse raw header string from `XMLHttpRequest.getAllResponseHeaders()`
|
|
643
|
+
* into a `Headers`-compatible object.
|
|
644
|
+
*/
|
|
645
|
+
private static parseXHRHeaders(rawHeaders: string): Headers {
|
|
646
|
+
const headers = new Headers();
|
|
647
|
+
if (!rawHeaders) return headers;
|
|
648
|
+
// RFC 7230 line terminator is CRLF; some XHR implementations use LF only.
|
|
649
|
+
const lines = rawHeaders.trim().split(/\r?\n/);
|
|
650
|
+
for (const line of lines) {
|
|
651
|
+
const colonIndex = line.indexOf(':');
|
|
652
|
+
if (colonIndex <= 0) continue;
|
|
653
|
+
const key = line.slice(0, colonIndex).trim();
|
|
654
|
+
const value = line.slice(colonIndex + 1).trim();
|
|
655
|
+
if (key) {
|
|
656
|
+
try {
|
|
657
|
+
headers.append(key, value);
|
|
658
|
+
} catch {
|
|
659
|
+
// Invalid header name/value — skip.
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return headers;
|
|
664
|
+
}
|
|
665
|
+
|
|
476
666
|
/**
|
|
477
667
|
* Generate cache key efficiently
|
|
478
|
-
* Uses
|
|
668
|
+
* Uses a content-addressed hash for large payloads so two requests with
|
|
669
|
+
* the same shape but different values never collide on the same key
|
|
670
|
+
* (which would silently serve stale data — e.g. paginated search results,
|
|
671
|
+
* large object updates).
|
|
479
672
|
*/
|
|
480
673
|
private generateCacheKey(method: string, url: string, data?: unknown): string {
|
|
481
674
|
if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) {
|
|
482
675
|
return `${method}:${url}`;
|
|
483
676
|
}
|
|
484
677
|
|
|
485
|
-
// For small objects,
|
|
678
|
+
// For small objects, the full serialization IS the key — fastest and
|
|
679
|
+
// guaranteed to be content-addressed.
|
|
486
680
|
const dataStr = JSON.stringify(data);
|
|
487
681
|
if (dataStr.length < 1000) {
|
|
488
682
|
return `${method}:${url}:${dataStr}`;
|
|
489
683
|
}
|
|
490
684
|
|
|
491
|
-
// For large
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
return `${method}:${url}:${hash}`;
|
|
685
|
+
// For large payloads, hash the full serialized string so the key remains
|
|
686
|
+
// content-addressed (any byte change yields a different hash). Previous
|
|
687
|
+
// implementation hashed `keys + length` which collided for any two
|
|
688
|
+
// payloads with the same top-level keys and serialized length.
|
|
689
|
+
return `${method}:${url}:${fnv1a32(dataStr)}`;
|
|
498
690
|
}
|
|
499
691
|
|
|
500
692
|
/**
|
|
@@ -760,6 +952,25 @@ export class HttpService {
|
|
|
760
952
|
this.cache.delete(key);
|
|
761
953
|
}
|
|
762
954
|
|
|
955
|
+
/**
|
|
956
|
+
* Delete every cache entry whose key starts with `prefix`.
|
|
957
|
+
*
|
|
958
|
+
* Used by mutations that don't know the exact downstream cache keys —
|
|
959
|
+
* e.g. `updateProfile` invalidating all `GET:/session/user/*` entries
|
|
960
|
+
* without having to track every active session ID. Returns the number of
|
|
961
|
+
* deleted entries (for observability in tests).
|
|
962
|
+
*/
|
|
963
|
+
clearCacheByPrefix(prefix: string): number {
|
|
964
|
+
let removed = 0;
|
|
965
|
+
for (const key of this.cache.keys()) {
|
|
966
|
+
if (key.startsWith(prefix)) {
|
|
967
|
+
this.cache.delete(key);
|
|
968
|
+
removed++;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return removed;
|
|
972
|
+
}
|
|
973
|
+
|
|
763
974
|
getCacheStats() {
|
|
764
975
|
const cacheStats = this.cache.getStats();
|
|
765
976
|
const total = this.requestMetrics.cacheHits + this.requestMetrics.cacheMisses;
|
package/src/OxyServices.base.ts
CHANGED
|
@@ -106,6 +106,16 @@ export class OxyServicesBase {
|
|
|
106
106
|
this.httpService.clearCacheEntry(key);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Clear every cache entry whose key starts with `prefix`.
|
|
111
|
+
* Useful for mutations that invalidate a family of GET responses
|
|
112
|
+
* without enumerating each one (e.g. all session-user lookups after
|
|
113
|
+
* a profile update).
|
|
114
|
+
*/
|
|
115
|
+
public clearCacheByPrefix(prefix: string): number {
|
|
116
|
+
return this.httpService.clearCacheByPrefix(prefix);
|
|
117
|
+
}
|
|
118
|
+
|
|
109
119
|
/**
|
|
110
120
|
* Get cache statistics
|
|
111
121
|
*/
|
package/src/OxyServices.ts
CHANGED
|
@@ -99,11 +99,16 @@ import { composeOxyServices } from './mixins';
|
|
|
99
99
|
* });
|
|
100
100
|
* ```
|
|
101
101
|
*/
|
|
102
|
-
// Compose all mixins into the final OxyServices class
|
|
102
|
+
// Compose all mixins into the final OxyServices class. The composed runtime
|
|
103
|
+
// class augments OxyServicesBase with every mixin's methods (see mixins/index.ts).
|
|
104
|
+
// Statically, TypeScript sees this as a constructor producing OxyServicesBase;
|
|
105
|
+
// the additional methods are exposed via interface merging on `OxyServices` below.
|
|
103
106
|
const OxyServicesComposed = composeOxyServices();
|
|
104
107
|
|
|
105
|
-
// Export as a named class to avoid TypeScript issues with anonymous class types
|
|
106
|
-
|
|
108
|
+
// Export as a named class to avoid TypeScript issues with anonymous class types.
|
|
109
|
+
// We extend the composed constructor directly — its public surface is broadened
|
|
110
|
+
// to the full mixin set via the interface declaration that follows.
|
|
111
|
+
export class OxyServices extends OxyServicesComposed {
|
|
107
112
|
constructor(config: OxyConfig) {
|
|
108
113
|
super(config);
|
|
109
114
|
}
|
|
@@ -150,7 +155,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
150
155
|
export { OxyAuthenticationError, OxyAuthenticationTimeoutError };
|
|
151
156
|
|
|
152
157
|
/**
|
|
153
|
-
*
|
|
158
|
+
* Default Oxy Cloud URL — used when no `cloudURL` is provided to OxyServices.
|
|
154
159
|
*/
|
|
155
160
|
export const OXY_CLOUD_URL = 'https://cloud.oxy.so';
|
|
156
161
|
|