@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.
Files changed (39) hide show
  1. package/LICENSE +675 -201
  2. package/NOTICE +7 -2
  3. package/dist/cjs/.tsbuildinfo +1 -1
  4. package/dist/cjs/HttpService.js +146 -23
  5. package/dist/cjs/crypto/registrationPow.js +53 -0
  6. package/dist/cjs/mixins/OxyServices.auth.js +6 -0
  7. package/dist/cjs/mixins/OxyServices.user.js +8 -1
  8. package/dist/cjs/utils/asyncUtils.js +50 -9
  9. package/dist/cjs/utils/errorUtils.js +53 -3
  10. package/dist/cjs/utils/requestUtils.js +96 -14
  11. package/dist/esm/.tsbuildinfo +1 -1
  12. package/dist/esm/HttpService.js +147 -24
  13. package/dist/esm/crypto/registrationPow.js +50 -0
  14. package/dist/esm/mixins/OxyServices.auth.js +8 -2
  15. package/dist/esm/mixins/OxyServices.identityBackup.js +1 -1
  16. package/dist/esm/mixins/OxyServices.user.js +8 -1
  17. package/dist/esm/utils/asyncUtils.js +50 -9
  18. package/dist/esm/utils/errorUtils.js +51 -3
  19. package/dist/esm/utils/requestUtils.js +96 -14
  20. package/dist/types/.tsbuildinfo +1 -1
  21. package/dist/types/HttpService.d.ts +19 -0
  22. package/dist/types/crypto/registrationPow.d.ts +22 -0
  23. package/dist/types/mixins/OxyServices.user.d.ts +10 -2
  24. package/dist/types/utils/asyncUtils.d.ts +30 -1
  25. package/dist/types/utils/errorUtils.d.ts +36 -0
  26. package/dist/types/utils/requestUtils.d.ts +2 -2
  27. package/package.json +2 -2
  28. package/src/HttpService.ts +181 -31
  29. package/src/__tests__/fixEsmImports.test.ts +132 -0
  30. package/src/__tests__/httpServiceAbort.test.ts +308 -0
  31. package/src/crypto/__tests__/registrationPow.test.ts +61 -0
  32. package/src/crypto/registrationPow.ts +60 -0
  33. package/src/mixins/OxyServices.auth.ts +7 -0
  34. package/src/mixins/OxyServices.user.ts +12 -1
  35. package/src/utils/__tests__/asyncUtils.test.ts +93 -1
  36. package/src/utils/__tests__/requestUtilsAbort.test.ts +155 -0
  37. package/src/utils/asyncUtils.ts +97 -9
  38. package/src/utils/errorUtils.ts +78 -3
  39. package/src/utils/requestUtils.ts +104 -13
@@ -3,6 +3,26 @@
3
3
  *
4
4
  * Provides reusable components for request deduplication, queuing, and logging
5
5
  */
6
+ import { createCancelledError } from './errorUtils.js';
7
+ /**
8
+ * The error to reject an aborted request with.
9
+ *
10
+ * ALWAYS the SDK's own cancellation error, with the signal's `reason` preserved
11
+ * as `cause` rather than substituted for it. The tempting version —
12
+ * `reason ?? createCancelledError()` — is wrong twice: `controller.abort()`
13
+ * always populates `reason` with a default `DOMException`, so the fallback
14
+ * would never run, and that `DOMException` carries none of the fields the retry
15
+ * predicates and consumers key off (`code: 'CANCELLED'`, `cancelled: true`).
16
+ * A cancellation that is not recognisable as one is the whole defect.
17
+ */
18
+ function abortReason(signal) {
19
+ const reason = signal.reason;
20
+ const error = createCancelledError('Request cancelled');
21
+ if (reason !== undefined) {
22
+ error.cause = reason;
23
+ }
24
+ return error;
25
+ }
6
26
  /**
7
27
  * Request deduplication - prevents duplicate concurrent requests
8
28
  *
@@ -29,17 +49,39 @@ export class RequestDeduplicator {
29
49
  * @param requestFn Function that returns a promise
30
50
  * @returns Promise that will be shared if key already exists
31
51
  */
32
- async deduplicate(key, requestFn) {
33
- const existing = this.pendingRequests.get(key);
34
- if (existing) {
35
- return existing;
52
+ async deduplicate(key, requestFn, signal) {
53
+ if (signal?.aborted) {
54
+ throw abortReason(signal);
55
+ }
56
+ let promise = this.pendingRequests.get(key);
57
+ if (!promise) {
58
+ promise = requestFn().finally(() => {
59
+ this.pendingRequests.delete(key);
60
+ });
61
+ this.pendingRequests.set(key, promise);
36
62
  }
37
- const promise = requestFn()
38
- .finally(() => {
39
- this.pendingRequests.delete(key);
63
+ // Callers SHARE the work but own their cancellation separately.
64
+ //
65
+ // Returning the shared promise directly meant one caller's abort rejected
66
+ // every other caller on the same key — including ones that never cancelled
67
+ // anything, and which had no way to tell that the failure was not theirs.
68
+ // Racing each caller's own signal against the shared work keeps the single
69
+ // in-flight request (the point of deduplication) while making cancellation
70
+ // per-caller. The shared promise is left running: another caller may still
71
+ // want it, and if nobody does its own abort domain ends it.
72
+ if (!signal) {
73
+ return promise;
74
+ }
75
+ // `promise` is already owned by the map's `finally`, so attach a no-op
76
+ // catch to the copy we race: without it, a rejection settled by the race's
77
+ // loser surfaces as an unhandled rejection.
78
+ const shared = promise;
79
+ shared.catch(() => { });
80
+ return new Promise((resolve, reject) => {
81
+ const onAbort = () => reject(abortReason(signal));
82
+ signal.addEventListener('abort', onAbort, { once: true });
83
+ shared.then((value) => { signal.removeEventListener('abort', onAbort); resolve(value); }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); });
40
84
  });
41
- this.pendingRequests.set(key, promise);
42
- return promise;
43
85
  }
44
86
  /**
45
87
  * Clear all pending requests
@@ -87,20 +129,60 @@ export class RequestQueue {
87
129
  * @param requestFn Function that returns a promise
88
130
  * @returns Promise that resolves when request completes
89
131
  */
90
- async enqueue(requestFn) {
132
+ async enqueue(requestFn, signal) {
91
133
  if (this.queue.length >= this.maxQueueSize) {
92
134
  throw new Error('Request queue is full');
93
135
  }
136
+ // A slot is the scarce resource — ten by default — so a request whose
137
+ // caller has already given up must never consume one. Without this, a
138
+ // burst of cancelled work (a search box being typed into, say) keeps every
139
+ // slot busy on results nobody will read, and the ONE request the user is
140
+ // waiting on queues behind them. This is the change that actually returns
141
+ // the slots; refusing to retry a cancellation only stops making more.
142
+ if (signal?.aborted) {
143
+ throw abortReason(signal);
144
+ }
94
145
  return new Promise((resolve, reject) => {
95
- this.queue.push(async () => {
146
+ let settled = false;
147
+ const finish = (fn) => {
148
+ if (settled)
149
+ return;
150
+ settled = true;
151
+ signal?.removeEventListener('abort', onAbort);
152
+ fn();
153
+ };
154
+ const entry = async () => {
155
+ // Re-checked at the moment of execution, not just at enqueue: a request
156
+ // can sit in the queue for as long as the slots stay busy, and the
157
+ // caller may well have moved on in the meantime.
158
+ if (signal?.aborted) {
159
+ finish(() => reject(abortReason(signal)));
160
+ return;
161
+ }
96
162
  try {
97
163
  const result = await requestFn();
98
- resolve(result);
164
+ finish(() => resolve(result));
99
165
  }
100
166
  catch (error) {
101
- reject(error);
167
+ finish(() => reject(error));
102
168
  }
103
- });
169
+ };
170
+ const abortingSignal = signal;
171
+ function onAbort() {
172
+ if (!abortingSignal)
173
+ return;
174
+ // Drop it from the queue if it has not started. An entry already
175
+ // running is left to its own abort domain — the queue must still see it
176
+ // finish, or `running` never decrements and the slot leaks for good.
177
+ const index = queueRef.indexOf(entry);
178
+ if (index !== -1) {
179
+ queueRef.splice(index, 1);
180
+ }
181
+ finish(() => reject(abortReason(abortingSignal)));
182
+ }
183
+ const queueRef = this.queue;
184
+ signal?.addEventListener('abort', onAbort, { once: true });
185
+ this.queue.push(entry);
104
186
  this.process();
105
187
  });
106
188
  }