@oxy.so/core 1.6.1 → 1.7.1
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/LICENSE +675 -201
- package/NOTICE +7 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +146 -23
- package/dist/cjs/crypto/registrationPow.js +53 -0
- package/dist/cjs/mixins/OxyServices.auth.js +6 -0
- package/dist/cjs/mixins/OxyServices.user.js +8 -1
- package/dist/cjs/utils/asyncUtils.js +50 -9
- package/dist/cjs/utils/errorUtils.js +53 -3
- package/dist/cjs/utils/requestUtils.js +96 -14
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +147 -24
- package/dist/esm/crypto/registrationPow.js +50 -0
- package/dist/esm/mixins/OxyServices.auth.js +8 -2
- package/dist/esm/mixins/OxyServices.identityBackup.js +1 -1
- package/dist/esm/mixins/OxyServices.user.js +8 -1
- package/dist/esm/utils/asyncUtils.js +50 -9
- package/dist/esm/utils/errorUtils.js +51 -3
- package/dist/esm/utils/requestUtils.js +96 -14
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +19 -0
- package/dist/types/crypto/registrationPow.d.ts +22 -0
- package/dist/types/mixins/OxyServices.user.d.ts +10 -2
- package/dist/types/utils/asyncUtils.d.ts +30 -1
- package/dist/types/utils/errorUtils.d.ts +36 -0
- package/dist/types/utils/requestUtils.d.ts +2 -2
- package/package.json +2 -2
- package/src/HttpService.ts +181 -31
- package/src/__tests__/fixEsmImports.test.ts +132 -0
- package/src/__tests__/httpServiceAbort.test.ts +308 -0
- package/src/crypto/__tests__/registrationPow.test.ts +61 -0
- package/src/crypto/registrationPow.ts +60 -0
- package/src/mixins/OxyServices.auth.ts +7 -0
- package/src/mixins/OxyServices.user.ts +12 -1
- package/src/utils/__tests__/asyncUtils.test.ts +93 -1
- package/src/utils/__tests__/requestUtilsAbort.test.ts +155 -0
- package/src/utils/asyncUtils.ts +97 -9
- package/src/utils/errorUtils.ts +78 -3
- package/src/utils/requestUtils.ts +104 -13
|
@@ -36,6 +36,25 @@ export interface RequestOptions {
|
|
|
36
36
|
deduplicate?: boolean;
|
|
37
37
|
retry?: boolean;
|
|
38
38
|
maxRetries?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Retry an attempt that hit {@link timeout}. Default `false`.
|
|
41
|
+
*
|
|
42
|
+
* Off by default because a timeout usually means the SERVER is slow, so more
|
|
43
|
+
* attempts add load to the thing already struggling — and because retrying
|
|
44
|
+
* one behind a short per-attempt timeout is how a single slow endpoint used
|
|
45
|
+
* to cost ~28s of wall clock. Turn it on for an idempotent read where a
|
|
46
|
+
* retry is genuinely likely to help, and pair it with {@link deadline}.
|
|
47
|
+
*/
|
|
48
|
+
retryOnTimeout?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Wall-clock budget in ms for the WHOLE call, retries and backoff included.
|
|
51
|
+
*
|
|
52
|
+
* {@link timeout} bounds one attempt; this bounds the call. Without it the
|
|
53
|
+
* total is `attempts x timeout + backoff`, which is arithmetic nobody does
|
|
54
|
+
* at the call site — so a caller who wants "never more than 8 seconds" can
|
|
55
|
+
* say that instead of deriving it.
|
|
56
|
+
*/
|
|
57
|
+
deadline?: number;
|
|
39
58
|
timeout?: number;
|
|
40
59
|
signal?: AbortSignal;
|
|
41
60
|
headers?: Record<string, string>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration proof-of-work — the CLIENT half of the anti-automation nonce
|
|
3
|
+
* `register()` (`mixins/OxyServices.auth.ts`) submits alongside the
|
|
4
|
+
* registration signature. The message format, difficulty and the full design
|
|
5
|
+
* rationale (why 16 bits, why sequential nonces are fine, what this is and
|
|
6
|
+
* isn't a defense against) live in `@oxy.so/protocol`'s
|
|
7
|
+
* `auth/registrationPow.ts` — this file is only the solve loop.
|
|
8
|
+
*/
|
|
9
|
+
import './polyfill';
|
|
10
|
+
/**
|
|
11
|
+
* Grind a nonce for `publicKey`/`timestamp` that clears
|
|
12
|
+
* {@link REGISTRATION_POW_DIFFICULTY_BITS}, returned as a decimal counter.
|
|
13
|
+
*
|
|
14
|
+
* Hashes with `@noble/hashes/sha256` — pure JS, synchronous, identical output
|
|
15
|
+
* on web/Node/React Native with no native module and no per-attempt bridge
|
|
16
|
+
* crossing (see the difficulty constant's own doc for why that distinction is
|
|
17
|
+
* the whole point). Nonces are sequential (0, 1, 2, …) rather than random: the
|
|
18
|
+
* candidate space a hash function spreads solutions across is dense either
|
|
19
|
+
* way, so a counter finds one exactly as reliably as randomness would, without
|
|
20
|
+
* needing a randomness source at all.
|
|
21
|
+
*/
|
|
22
|
+
export declare function solveRegistrationPow(publicKey: string, timestamp: number): Promise<string>;
|
|
@@ -127,8 +127,16 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
127
127
|
}>;
|
|
128
128
|
/**
|
|
129
129
|
* Search user profiles
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
*
|
|
131
|
+
* Takes an `AbortSignal` because people search is typed into: without one
|
|
132
|
+
* every keystroke started a search that ran to completion and had its
|
|
133
|
+
* result discarded, while holding one of the request queue's slots. It was
|
|
134
|
+
* the only search lane in Mention that could not be cancelled, purely
|
|
135
|
+
* because this signature had no way to say so.
|
|
136
|
+
*/
|
|
137
|
+
searchProfiles(query: string, pagination?: PaginationParams, options?: {
|
|
138
|
+
signal?: AbortSignal;
|
|
139
|
+
}): Promise<SearchProfilesResponse>;
|
|
132
140
|
/**
|
|
133
141
|
* Resolve a fediverse handle to an Oxy user profile.
|
|
134
142
|
* Performs WebFinger discovery and returns the user, or null if not found.
|
|
@@ -20,7 +20,36 @@ export declare function parallelWithErrorHandling<T>(operations: (() => Promise<
|
|
|
20
20
|
*
|
|
21
21
|
* Use the `shouldRetry` callback to customize retry behavior.
|
|
22
22
|
*/
|
|
23
|
-
export
|
|
23
|
+
export interface RetryOptions {
|
|
24
|
+
maxRetries?: number;
|
|
25
|
+
baseDelay?: number;
|
|
26
|
+
shouldRetry?: (error: unknown) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Retry a request that hit its own per-attempt timeout. Default `false`.
|
|
29
|
+
*
|
|
30
|
+
* Retrying a timeout is defensible for an idempotent GET and indefensible
|
|
31
|
+
* with the old defaults: a 5s per-attempt timeout with 3 retries and
|
|
32
|
+
* exponential backoff spends ~28s before reporting a failure, all of it after
|
|
33
|
+
* the caller stopped waiting, while holding a request-queue slot throughout.
|
|
34
|
+
* A timeout also usually means the SERVER is slow, so three more attempts add
|
|
35
|
+
* load to the thing already struggling.
|
|
36
|
+
*
|
|
37
|
+
* Opt in per call, and pair it with {@link deadline} so the total is bounded
|
|
38
|
+
* by wall clock rather than by attempts x timeout + backoff.
|
|
39
|
+
*/
|
|
40
|
+
retryOnTimeout?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Absolute wall-clock budget for ALL attempts, as an epoch milliseconds
|
|
43
|
+
* timestamp (`Date.now() + budget`). Once passed, no further attempt starts
|
|
44
|
+
* and the last error is thrown.
|
|
45
|
+
*
|
|
46
|
+
* This is what makes a pathological total unreachable BY CONSTRUCTION rather
|
|
47
|
+
* than by arithmetic on the other knobs: a deadline per call, a timeout per
|
|
48
|
+
* attempt, and jittered retries inside the deadline.
|
|
49
|
+
*/
|
|
50
|
+
deadline?: number;
|
|
51
|
+
}
|
|
52
|
+
export declare function retryAsync<T>(operation: () => Promise<T>, maxRetriesOrOptions?: number | RetryOptions, baseDelayArg?: number, shouldRetryArg?: (error: any) => boolean): Promise<T>;
|
|
24
53
|
/**
|
|
25
54
|
* Debounce async function calls
|
|
26
55
|
*/
|
|
@@ -22,6 +22,19 @@ export declare const ErrorCodes: {
|
|
|
22
22
|
readonly TIMEOUT: "TIMEOUT";
|
|
23
23
|
readonly NETWORK_ERROR: "NETWORK_ERROR";
|
|
24
24
|
readonly CONNECTION_FAILED: "CONNECTION_FAILED";
|
|
25
|
+
/**
|
|
26
|
+
* The CALLER cancelled this request — it did not fail.
|
|
27
|
+
*
|
|
28
|
+
* Distinct from {@link ErrorCodes.TIMEOUT} on purpose, and the distinction is
|
|
29
|
+
* load-bearing rather than cosmetic. Both arrive from `fetch` as an
|
|
30
|
+
* `AbortError` carrying no status, so for a long time both collapsed to
|
|
31
|
+
* `TIMEOUT` with `status: 0` — and since `status: 0` is not 4xx, the retry
|
|
32
|
+
* predicate treated a cancellation as a transient failure and re-issued the
|
|
33
|
+
* request the caller had just abandoned. A cancellation is an INSTRUCTION;
|
|
34
|
+
* nothing may retry it. See `retryAsync`'s default predicate, which refuses
|
|
35
|
+
* this code specifically.
|
|
36
|
+
*/
|
|
37
|
+
readonly CANCELLED: "CANCELLED";
|
|
25
38
|
};
|
|
26
39
|
/**
|
|
27
40
|
* The `Error` shape the SDK rejects with when an HTTP request fails.
|
|
@@ -94,6 +107,29 @@ export declare function parseHttpErrorBody(body: unknown): ParsedHttpErrorBody;
|
|
|
94
107
|
* Create a standardized API error
|
|
95
108
|
*/
|
|
96
109
|
export declare function createApiError(message: string, code?: string, status?: number, details?: Record<string, unknown>): ApiError;
|
|
110
|
+
/**
|
|
111
|
+
* The error a CALLER-cancelled request rejects with.
|
|
112
|
+
*
|
|
113
|
+
* Carries both identities on purpose, because consumers check for
|
|
114
|
+
* cancellation two different ways and both are legitimate:
|
|
115
|
+
*
|
|
116
|
+
* - `name: 'AbortError'` is what every `fetch`-era consumer tests, and what
|
|
117
|
+
* React Query and friends recognise. Dropping it would silently turn
|
|
118
|
+
* cancellations into errors in every app that already handles aborts.
|
|
119
|
+
* - `code: ErrorCodes.CANCELLED` / `cancelled: true` is what code inside this
|
|
120
|
+
* SDK tests, because `handleHttpError` returns PLAIN objects (not `Error`s)
|
|
121
|
+
* for most failures, so `name` is not a reliable discriminator across the
|
|
122
|
+
* whole surface.
|
|
123
|
+
*
|
|
124
|
+
* It is an `Error` rather than a bare `ApiError` object so a stack survives and
|
|
125
|
+
* `instanceof Error` holds — the shape the abort path always looked like it
|
|
126
|
+
* produced, and never did.
|
|
127
|
+
*/
|
|
128
|
+
export type CancelledError = Error & ApiError & {
|
|
129
|
+
cancelled: true;
|
|
130
|
+
};
|
|
131
|
+
export declare function createCancelledError(message?: string): CancelledError;
|
|
132
|
+
export declare function isCancelledError(error: unknown): boolean;
|
|
97
133
|
/**
|
|
98
134
|
* Handle common HTTP errors and convert to ApiError
|
|
99
135
|
*/
|
|
@@ -27,7 +27,7 @@ export declare class RequestDeduplicator {
|
|
|
27
27
|
* @param requestFn Function that returns a promise
|
|
28
28
|
* @returns Promise that will be shared if key already exists
|
|
29
29
|
*/
|
|
30
|
-
deduplicate<T>(key: string, requestFn: () => Promise<T
|
|
30
|
+
deduplicate<T>(key: string, requestFn: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
31
31
|
/**
|
|
32
32
|
* Clear all pending requests
|
|
33
33
|
*/
|
|
@@ -69,7 +69,7 @@ export declare class RequestQueue {
|
|
|
69
69
|
* @param requestFn Function that returns a promise
|
|
70
70
|
* @returns Promise that resolves when request completes
|
|
71
71
|
*/
|
|
72
|
-
enqueue<T>(requestFn: () => Promise<T
|
|
72
|
+
enqueue<T>(requestFn: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
73
73
|
/**
|
|
74
74
|
* Process queued requests
|
|
75
75
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"directory": "packages/core"
|
|
81
81
|
},
|
|
82
82
|
"author": "OxyHQ",
|
|
83
|
-
"license": "
|
|
83
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
84
84
|
"homepage": "https://oxy.so",
|
|
85
85
|
"engines": {
|
|
86
86
|
"node": ">=18.0.0"
|
package/src/HttpService.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { TTLCache, registerCacheForCleanup } from './utils/cache';
|
|
17
17
|
import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/requestUtils';
|
|
18
18
|
import { retryAsync } from './utils/asyncUtils';
|
|
19
|
-
import { handleHttpError, parseHttpErrorBody } from './utils/errorUtils';
|
|
19
|
+
import { createCancelledError, ErrorCodes, handleHttpError, isCancelledError, parseHttpErrorBody } from './utils/errorUtils';
|
|
20
20
|
import { jwtDecode } from 'jwt-decode';
|
|
21
21
|
import { isNative, getPlatformOS } from './utils/platform';
|
|
22
22
|
import { isReactNative } from '@oxy.so/protocol';
|
|
@@ -85,6 +85,25 @@ export interface RequestOptions {
|
|
|
85
85
|
deduplicate?: boolean;
|
|
86
86
|
retry?: boolean;
|
|
87
87
|
maxRetries?: number;
|
|
88
|
+
/**
|
|
89
|
+
* Retry an attempt that hit {@link timeout}. Default `false`.
|
|
90
|
+
*
|
|
91
|
+
* Off by default because a timeout usually means the SERVER is slow, so more
|
|
92
|
+
* attempts add load to the thing already struggling — and because retrying
|
|
93
|
+
* one behind a short per-attempt timeout is how a single slow endpoint used
|
|
94
|
+
* to cost ~28s of wall clock. Turn it on for an idempotent read where a
|
|
95
|
+
* retry is genuinely likely to help, and pair it with {@link deadline}.
|
|
96
|
+
*/
|
|
97
|
+
retryOnTimeout?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Wall-clock budget in ms for the WHOLE call, retries and backoff included.
|
|
100
|
+
*
|
|
101
|
+
* {@link timeout} bounds one attempt; this bounds the call. Without it the
|
|
102
|
+
* total is `attempts x timeout + backoff`, which is arithmetic nobody does
|
|
103
|
+
* at the call site — so a caller who wants "never more than 8 seconds" can
|
|
104
|
+
* say that instead of deriving it.
|
|
105
|
+
*/
|
|
106
|
+
deadline?: number;
|
|
88
107
|
timeout?: number;
|
|
89
108
|
signal?: AbortSignal;
|
|
90
109
|
headers?: Record<string, string>;
|
|
@@ -288,9 +307,48 @@ class TokenStore {
|
|
|
288
307
|
}
|
|
289
308
|
}
|
|
290
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Propagate `parent`'s abort to `child`, returning a disposer.
|
|
312
|
+
*
|
|
313
|
+
* Three things a bare `parent.addEventListener('abort', ...)` gets wrong, all
|
|
314
|
+
* of which this exists to stop repeating:
|
|
315
|
+
*
|
|
316
|
+
* 1. **An already-aborted parent never fires again.** `abort` is a once-only
|
|
317
|
+
* event, so linking to a signal that has already fired silently links to
|
|
318
|
+
* nothing — which is how a cancelled request got re-issued on every retry.
|
|
319
|
+
* The `aborted` pre-check is the whole point.
|
|
320
|
+
* 2. **The listener outlives the request.** A long-lived caller signal (one
|
|
321
|
+
* React Query owns for a query's lifetime, say) accumulates one listener per
|
|
322
|
+
* attempt per request with nothing ever removing them.
|
|
323
|
+
* 3. **The reason is dropped**, so the child cannot tell WHY it was aborted.
|
|
324
|
+
*
|
|
325
|
+
* `AbortSignal.any` does all this natively and is not used: Hermes support is
|
|
326
|
+
* not safe to assume, and this SDK ships to React Native. The pattern is the
|
|
327
|
+
* one `uploadViaXHR` already had right; this is that code, shared.
|
|
328
|
+
*/
|
|
329
|
+
function isAbortLike(error: unknown): boolean {
|
|
330
|
+
return (
|
|
331
|
+
typeof error === 'object' &&
|
|
332
|
+
error !== null &&
|
|
333
|
+
(error as { name?: unknown }).name === 'AbortError'
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function linkAbort(parent: AbortSignal, child: AbortController): () => void {
|
|
338
|
+
if (parent.aborted) {
|
|
339
|
+
child.abort((parent as AbortSignal & { reason?: unknown }).reason);
|
|
340
|
+
return () => { /* nothing was ever attached */ };
|
|
341
|
+
}
|
|
342
|
+
const onAbort = (): void => {
|
|
343
|
+
child.abort((parent as AbortSignal & { reason?: unknown }).reason);
|
|
344
|
+
};
|
|
345
|
+
parent.addEventListener('abort', onAbort, { once: true });
|
|
346
|
+
return () => parent.removeEventListener('abort', onAbort);
|
|
347
|
+
}
|
|
348
|
+
|
|
291
349
|
/**
|
|
292
350
|
* Unified HTTP Service
|
|
293
|
-
*
|
|
351
|
+
*
|
|
294
352
|
* Consolidates HttpClient + RequestManager into a single efficient class.
|
|
295
353
|
* Uses native fetch instead of axios for smaller bundle size.
|
|
296
354
|
*/
|
|
@@ -543,9 +601,58 @@ export class HttpService {
|
|
|
543
601
|
const edgeRegionHeader = await getBrowserEdgeRegionHeader();
|
|
544
602
|
const activityIdHeader = getBrowserActivityIdHeader();
|
|
545
603
|
|
|
604
|
+
// A request the caller has ALREADY abandoned takes no queue slot and makes
|
|
605
|
+
// no network call. Checked here, before enqueue, because a slot is the
|
|
606
|
+
// scarce resource: ten of them, and a dead request holding one is a live
|
|
607
|
+
// request waiting for nothing.
|
|
608
|
+
if (signal?.aborted) {
|
|
609
|
+
throw createCancelledError('Request cancelled before it was sent');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ONE abort domain for the whole call, linked to the caller's signal ONCE.
|
|
613
|
+
//
|
|
614
|
+
// The bug this replaces: the controller used to be built INSIDE `requestFn`
|
|
615
|
+
// — the function `retryAsync` re-invokes — and the caller's signal was
|
|
616
|
+
// linked to each new controller with a bare `addEventListener`. An `abort`
|
|
617
|
+
// event fires once, so on attempt 2 the caller's already-fired signal could
|
|
618
|
+
// not abort the freshly built controller, and the request the caller had
|
|
619
|
+
// cancelled went out for real. Hoisting the linkage means a cancellation
|
|
620
|
+
// arrives once and is then true for every attempt, including ones not yet
|
|
621
|
+
// started.
|
|
622
|
+
const callController = new AbortController();
|
|
623
|
+
const disposeCallerLink = signal ? linkAbort(signal, callController) : undefined;
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Which of our own timers fired, so a timeout can be told apart from a
|
|
627
|
+
* cancellation. Both reach us from `fetch` as an indistinguishable
|
|
628
|
+
* `AbortError`, and collapsing them (as this used to) is what let the retry
|
|
629
|
+
* predicate treat an abandoned request as a transient failure.
|
|
630
|
+
*/
|
|
631
|
+
let timedOut = false;
|
|
632
|
+
|
|
546
633
|
// Request function
|
|
547
634
|
const requestFn = async (): Promise<T> => {
|
|
548
635
|
const startTime = Date.now();
|
|
636
|
+
// Re-checked per attempt so no retry can issue a network call after the
|
|
637
|
+
// caller gave up — belt to `retryAsync`'s braces, and the one that holds
|
|
638
|
+
// if a custom `shouldRetry` ever says yes to a cancellation.
|
|
639
|
+
if (callController.signal.aborted) {
|
|
640
|
+
throw createCancelledError('Request cancelled');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// This attempt's controller carries its own timeout and inherits the
|
|
644
|
+
// call-wide abort domain. Declared OUTSIDE the `try` so the `finally`
|
|
645
|
+
// that releases them can see them — the timer and the listener are
|
|
646
|
+
// exactly what leaked when they lived inside it.
|
|
647
|
+
const controller = new AbortController();
|
|
648
|
+
const disposeCallLink = linkAbort(callController.signal, controller);
|
|
649
|
+
const timeoutId = timeout
|
|
650
|
+
? setTimeout(() => {
|
|
651
|
+
timedOut = true;
|
|
652
|
+
controller.abort();
|
|
653
|
+
}, timeout)
|
|
654
|
+
: null;
|
|
655
|
+
|
|
549
656
|
try {
|
|
550
657
|
// Build URL with params
|
|
551
658
|
const fullUrl = this.buildURL(url, params);
|
|
@@ -554,14 +661,6 @@ export class HttpService {
|
|
|
554
661
|
const isFormData = this.isFormData(data);
|
|
555
662
|
const isUrlEncoded = this.isUrlSearchParams(data);
|
|
556
663
|
|
|
557
|
-
// Make fetch request
|
|
558
|
-
const controller = new AbortController();
|
|
559
|
-
const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
|
|
560
|
-
|
|
561
|
-
if (signal) {
|
|
562
|
-
signal.addEventListener('abort', () => controller.abort());
|
|
563
|
-
}
|
|
564
|
-
|
|
565
664
|
// Build headers - start with defaults
|
|
566
665
|
const headers: Record<string, string> = {
|
|
567
666
|
'Accept': 'application/json',
|
|
@@ -657,6 +756,8 @@ export class HttpService {
|
|
|
657
756
|
credentials: this.getCredentialsMode(fullUrl),
|
|
658
757
|
});
|
|
659
758
|
|
|
759
|
+
// Cleared as early as possible; the `finally` below is what GUARANTEES
|
|
760
|
+
// it, including on every throw between here and there.
|
|
660
761
|
if (timeoutId) clearTimeout(timeoutId);
|
|
661
762
|
|
|
662
763
|
// Handle response
|
|
@@ -787,41 +888,90 @@ export class HttpService {
|
|
|
787
888
|
this.updateMetrics(false, duration);
|
|
788
889
|
this.config.onRequestEnd?.(url, method, duration, false);
|
|
789
890
|
this.config.onRequestError?.(url, method, error instanceof Error ? error : new Error(String(error)));
|
|
790
|
-
|
|
791
|
-
//
|
|
792
|
-
|
|
793
|
-
|
|
891
|
+
|
|
892
|
+
// An abort is the one failure whose CAUSE the error cannot carry on its
|
|
893
|
+
// own: `fetch` reports a caller cancellation and our own timeout as the
|
|
894
|
+
// same `AbortError`. `timedOut` is set by the timer callback, so it is
|
|
895
|
+
// the only thing here that knows which happened — and getting this
|
|
896
|
+
// wrong is not cosmetic. Both used to collapse to `TIMEOUT` with
|
|
897
|
+
// `status: 0`, and because `status: 0` is not 4xx the retry predicate
|
|
898
|
+
// read every cancellation as a transient failure worth another try.
|
|
899
|
+
// Tested by NAME, not `instanceof Error`. `fetch` rejects an abort with a
|
|
900
|
+
// `DOMException`, and `instanceof` is realm-bound: the exception can be
|
|
901
|
+
// constructed in a different realm from the `Error` this module closes
|
|
902
|
+
// over (a Jest VM context, a worker, an iframe), in which case the check
|
|
903
|
+
// silently fails and the abort is misreported as a generic 500. That was
|
|
904
|
+
// happening; a structural check cannot be fooled that way.
|
|
905
|
+
if (isAbortLike(error)) {
|
|
906
|
+
if (timedOut) {
|
|
907
|
+
const timeoutError = new Error(`Request timed out after ${timeout}ms`) as Error & {
|
|
908
|
+
status?: number;
|
|
909
|
+
code?: string;
|
|
910
|
+
timeout?: boolean;
|
|
911
|
+
};
|
|
912
|
+
timeoutError.status = 0;
|
|
913
|
+
timeoutError.code = ErrorCodes.TIMEOUT;
|
|
914
|
+
timeoutError.timeout = true;
|
|
915
|
+
throw timeoutError;
|
|
916
|
+
}
|
|
917
|
+
throw createCancelledError('Request cancelled');
|
|
794
918
|
}
|
|
795
|
-
|
|
919
|
+
|
|
796
920
|
throw handleHttpError(error);
|
|
921
|
+
} finally {
|
|
922
|
+
// Both of these leaked before: `clearTimeout` sat on the success path
|
|
923
|
+
// only, so any throw between the fetch and the return (a 404, a parse
|
|
924
|
+
// failure) left the timer armed; and nothing ever removed the abort
|
|
925
|
+
// listener, so a long-lived caller signal collected one per attempt.
|
|
926
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
927
|
+
disposeCallLink();
|
|
797
928
|
}
|
|
798
929
|
};
|
|
799
930
|
|
|
800
|
-
// Wrap with retry if enabled
|
|
931
|
+
// Wrap with retry if enabled.
|
|
932
|
+
//
|
|
933
|
+
// `retryOnTimeout` defaults to false in `retryAsync`, which is what makes
|
|
934
|
+
// the pathological total unreachable: a timeout now costs ONE attempt
|
|
935
|
+
// instead of four-plus-backoff (~28s at the 5s default), and a cancellation
|
|
936
|
+
// costs none. `deadline` bounds whatever retries do happen by wall clock
|
|
937
|
+
// rather than by attempts x timeout + backoff.
|
|
801
938
|
const requestWithRetry = retry
|
|
802
|
-
? () => retryAsync(requestFn,
|
|
939
|
+
? () => retryAsync(requestFn, {
|
|
940
|
+
maxRetries,
|
|
941
|
+
baseDelay: this.config.retryDelay || 1000,
|
|
942
|
+
retryOnTimeout: config.retryOnTimeout ?? false,
|
|
943
|
+
deadline: config.deadline !== undefined ? Date.now() + config.deadline : undefined,
|
|
944
|
+
})
|
|
803
945
|
: requestFn;
|
|
804
946
|
|
|
805
947
|
// Wrap with deduplication if enabled (use optimized key generation)
|
|
806
948
|
const dedupeKey = deduplicate ? this.generateCacheKey(method, url, data || params) : null;
|
|
807
949
|
const finalRequest = dedupeKey
|
|
808
|
-
? () => this.deduplicator.deduplicate(dedupeKey, requestWithRetry)
|
|
950
|
+
? () => this.deduplicator.deduplicate(dedupeKey, requestWithRetry, callController.signal)
|
|
809
951
|
: requestWithRetry;
|
|
810
952
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
953
|
+
try {
|
|
954
|
+
// Execute the request. Control-plane calls the auth lane depends on
|
|
955
|
+
// (`bypassQueue`, e.g. the device-secret mint) run DIRECTLY — a queued mint
|
|
956
|
+
// could never acquire a slot when every slot is parked awaiting it.
|
|
957
|
+
const result = config.bypassQueue
|
|
958
|
+
? await finalRequest()
|
|
959
|
+
: await this.requestQueue.enqueue(finalRequest, callController.signal);
|
|
960
|
+
|
|
961
|
+
// Cache the result if caching is enabled
|
|
962
|
+
if (cache && cacheKey && result) {
|
|
963
|
+
this.cache.set(cacheKey, result, cacheTTL);
|
|
964
|
+
this.warnIfCacheOversized();
|
|
965
|
+
}
|
|
823
966
|
|
|
824
|
-
|
|
967
|
+
return result;
|
|
968
|
+
} finally {
|
|
969
|
+
// The call-wide link to the CALLER's signal, released once the call is
|
|
970
|
+
// over however it ended. Without this a caller signal that outlives the
|
|
971
|
+
// request — the normal case for a React Query query signal — accrues one
|
|
972
|
+
// listener per request for its whole lifetime.
|
|
973
|
+
disposeCallerLink?.();
|
|
974
|
+
}
|
|
825
975
|
}
|
|
826
976
|
|
|
827
977
|
/**
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scripts/fix-esm-imports.mjs` must rewrite DYNAMIC relative imports too.
|
|
3
|
+
*
|
|
4
|
+
* ## The failure this exists for, which happened
|
|
5
|
+
*
|
|
6
|
+
* This package writes relative specifiers without an extension — legal under
|
|
7
|
+
* `moduleResolution: bundler`, illegal in Node's ESM resolver — and the
|
|
8
|
+
* post-build script adds the extension back. For its first life the script's
|
|
9
|
+
* pattern was `(?:from|import)\s+['"]`, which cannot match `import('./x')`:
|
|
10
|
+
* the keyword is followed by a parenthesis, not whitespace. Every dynamic
|
|
11
|
+
* import therefore shipped extensionless in `dist/esm`.
|
|
12
|
+
*
|
|
13
|
+
* `OxyServices.getServiceToken()` loads `../server/workloadIdentity` that way
|
|
14
|
+
* to decide whether this process can attest its workload identity (ADR 0026),
|
|
15
|
+
* and it does so inside a `try/catch` that reads any failure as "cannot attest
|
|
16
|
+
* here". So in every ESM consumer of the BUILT package the ADR 0026 fallback
|
|
17
|
+
* was dead, and a service whose task role attests perfectly well was told
|
|
18
|
+
* `Service credentials not provided` — which is what a deployment sees the
|
|
19
|
+
* moment its key pair is removed, and the reason `mention-mcp` and `alia` could
|
|
20
|
+
* not complete the migration while `mention`, `allo` and `alia-integrations`
|
|
21
|
+
* (all CommonJS, where `require()` supplies the extension) already had.
|
|
22
|
+
*
|
|
23
|
+
* ## Why this spawns the real script
|
|
24
|
+
*
|
|
25
|
+
* The defect is in the build OUTPUT, and core's CI job runs jest without
|
|
26
|
+
* building the package, so a test that reads `dist/esm` would pass by finding
|
|
27
|
+
* nothing. `serviceTokenWorkloadFallback.test.ts` cannot catch it either: jest
|
|
28
|
+
* resolves extensionless specifiers, and it mocks that module by name. The one
|
|
29
|
+
* thing that measures the real behaviour without a build is to run the script
|
|
30
|
+
* over a fixture and read what it wrote.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { tmpdir } from 'node:os';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
|
|
38
|
+
const script = path.resolve(__dirname, '../../scripts/fix-esm-imports.mjs');
|
|
39
|
+
|
|
40
|
+
function runOnFixture(files: Record<string, string>): Record<string, string> {
|
|
41
|
+
const root = mkdtempSync(path.join(tmpdir(), 'oxy-core-esm-fix-'));
|
|
42
|
+
try {
|
|
43
|
+
for (const [name, source] of Object.entries(files)) {
|
|
44
|
+
const full = path.join(root, name);
|
|
45
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
46
|
+
writeFileSync(full, source);
|
|
47
|
+
}
|
|
48
|
+
const result = spawnSync(process.execPath, [script, root], { encoding: 'utf8' });
|
|
49
|
+
expect(result.status).toBe(0);
|
|
50
|
+
const out: Record<string, string> = {};
|
|
51
|
+
for (const name of Object.keys(files)) {
|
|
52
|
+
out[name] = readFileSync(path.join(root, name), 'utf8');
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(root, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('fix-esm-imports', () => {
|
|
61
|
+
it('adds .js to a dynamic relative import', () => {
|
|
62
|
+
const out = runOnFixture({
|
|
63
|
+
'server/workloadIdentity.js': 'export const canAttestWorkloadIdentity = () => true;\n',
|
|
64
|
+
'mixins/auth.js': [
|
|
65
|
+
'export async function canUse() {',
|
|
66
|
+
" const { canAttestWorkloadIdentity } = await import('../server/workloadIdentity');",
|
|
67
|
+
' return canAttestWorkloadIdentity();',
|
|
68
|
+
'}',
|
|
69
|
+
'',
|
|
70
|
+
].join('\n'),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(out['mixins/auth.js']).toContain("await import('../server/workloadIdentity.js')");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rewrites every dynamic import in a file, not only the first', () => {
|
|
77
|
+
const out = runOnFixture({
|
|
78
|
+
'server/workloadIdentity.js': 'export const a = 1;\nexport const b = 2;\n',
|
|
79
|
+
'mixins/auth.js': [
|
|
80
|
+
"const first = await import('../server/workloadIdentity');",
|
|
81
|
+
"const second = await import('../server/workloadIdentity');",
|
|
82
|
+
'',
|
|
83
|
+
].join('\n'),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const matches = out['mixins/auth.js'].match(/workloadIdentity\.js/g) ?? [];
|
|
87
|
+
expect(matches).toHaveLength(2);
|
|
88
|
+
// And nothing was left extensionless.
|
|
89
|
+
expect(out['mixins/auth.js']).not.toMatch(/workloadIdentity'/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('tolerates whitespace between the keyword and the parenthesis', () => {
|
|
93
|
+
const out = runOnFixture({
|
|
94
|
+
'server/workloadIdentity.js': 'export const a = 1;\n',
|
|
95
|
+
'mixins/auth.js': "const m = await import(\n '../server/workloadIdentity'\n);\n",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(out['mixins/auth.js']).toContain("'../server/workloadIdentity.js'");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('resolves a dynamic directory import to its index', () => {
|
|
102
|
+
const out = runOnFixture({
|
|
103
|
+
'server/index.js': 'export const a = 1;\n',
|
|
104
|
+
'mixins/auth.js': "const m = await import('../server');\n",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(out['mixins/auth.js']).toContain("'../server/index.js'");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('still rewrites the static forms, and leaves bare and already-suffixed specifiers alone', () => {
|
|
111
|
+
const out = runOnFixture({
|
|
112
|
+
'server/workloadIdentity.js': 'export const a = 1;\n',
|
|
113
|
+
'mixins/auth.js': [
|
|
114
|
+
"import { a } from '../server/workloadIdentity';",
|
|
115
|
+
"import '../server/workloadIdentity';",
|
|
116
|
+
"export { a as b } from '../server/workloadIdentity';",
|
|
117
|
+
"import { createHash } from 'node:crypto';",
|
|
118
|
+
"import { z } from 'zod';",
|
|
119
|
+
"const late = await import('./sibling.js');",
|
|
120
|
+
'',
|
|
121
|
+
].join('\n'),
|
|
122
|
+
'mixins/sibling.js': 'export const s = 1;\n',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const source = out['mixins/auth.js'];
|
|
126
|
+
expect(source.match(/workloadIdentity\.js/g) ?? []).toHaveLength(3);
|
|
127
|
+
expect(source).toContain("import { createHash } from 'node:crypto';");
|
|
128
|
+
expect(source).toContain("import { z } from 'zod';");
|
|
129
|
+
expect(source).toContain("await import('./sibling.js')");
|
|
130
|
+
expect(source).not.toContain('sibling.js.js');
|
|
131
|
+
});
|
|
132
|
+
});
|