@enbox/agent 0.6.7 → 0.7.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/dist/browser.mjs +9 -9
- package/dist/browser.mjs.map +3 -3
- package/dist/esm/enbox-connect-protocol.js +162 -105
- package/dist/esm/enbox-connect-protocol.js.map +1 -1
- package/dist/esm/utils.js +61 -0
- package/dist/esm/utils.js.map +1 -1
- package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
- package/dist/types/utils.d.ts +22 -0
- package/dist/types/utils.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/enbox-connect-protocol.ts +190 -103
- package/src/utils.ts +69 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enbox/agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/esm/index.js",
|
|
6
6
|
"module": "./dist/esm/index.js",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@scure/bip39": "1.2.2",
|
|
75
|
-
"@enbox/dwn-clients": "0.
|
|
75
|
+
"@enbox/dwn-clients": "0.4.0",
|
|
76
76
|
"@enbox/dwn-sdk-js": "0.3.5",
|
|
77
77
|
"@enbox/common": "0.1.0",
|
|
78
78
|
"@enbox/crypto": "0.1.0",
|
|
@@ -115,12 +115,41 @@ import {
|
|
|
115
115
|
import { DwnInterfaceName, DwnMethodName, HdKey, KeyDerivationScheme, PermissionsProtocol } from '@enbox/dwn-sdk-js';
|
|
116
116
|
|
|
117
117
|
import { AgentPermissionsApi } from './permissions-api.js';
|
|
118
|
-
import { concatenateUrl } from './utils.js';
|
|
119
118
|
import { DwnInterface } from './types/dwn.js';
|
|
120
119
|
import { getEncryptionKeyInfo } from './dwn-encryption.js';
|
|
121
120
|
import { isMultiPartyContext } from './protocol-utils.js';
|
|
122
121
|
import { isRecordPermissionScope } from './dwn-api.js';
|
|
123
122
|
import { KeyDeliveryProtocolDefinition } from './store-data-protocols.js';
|
|
123
|
+
import { concatenateUrl, mapConcurrent, mapConcurrentSettled } from './utils.js';
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Tunables
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Maximum number of in-flight DWN-endpoint sends issued by the connect flow
|
|
131
|
+
* (permission grants + revocation grants). Caps total concurrency across all
|
|
132
|
+
* `(grant, endpoint)` pairs so that a request with many permissions and/or a
|
|
133
|
+
* tenant with many DWN endpoints cannot stampede the network. Tuned to be
|
|
134
|
+
* generous enough to hide endpoint latency while staying well under typical
|
|
135
|
+
* per-host browser connection limits and server-side rate limits.
|
|
136
|
+
*/
|
|
137
|
+
const CONNECT_FANOUT_CONCURRENCY = 8;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Per-request abort budget applied to every DWN-endpoint `sendDwnRequest`
|
|
141
|
+
* issued during the connect flow. The HttpDwnRpcClient's default per-attempt
|
|
142
|
+
* timeout is 30 s with 3 retries (~120 s worst-case per request) — that
|
|
143
|
+
* scales unacceptably when bounded fan-out has to wait for every settled
|
|
144
|
+
* task. With this budget, an unhealthy / cold endpoint short-circuits the
|
|
145
|
+
* retry loop within a few seconds (AbortError is non-retryable), keeping
|
|
146
|
+
* the user-visible "Authorizing…" wait bounded even when one of N DWN
|
|
147
|
+
* endpoints is misbehaving.
|
|
148
|
+
*
|
|
149
|
+
* Sync delivers any missed copies eventually, so aborting fast is safe:
|
|
150
|
+
* the connect-flow fan-outs are best-effort and tolerate per-task failure.
|
|
151
|
+
*/
|
|
152
|
+
const CONNECT_REQUEST_TIMEOUT_MS = 10_000;
|
|
124
153
|
|
|
125
154
|
// ---------------------------------------------------------------------------
|
|
126
155
|
// Types
|
|
@@ -690,45 +719,56 @@ async function createPermissionGrants(
|
|
|
690
719
|
const dwnEndpointUrls = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
|
|
691
720
|
logger.log(`Sending ${permissionGrants.length} permission grants to ${dwnEndpointUrls.length} DWN endpoint(s)...`);
|
|
692
721
|
|
|
693
|
-
|
|
722
|
+
// Flatten (grant, endpoint) tuples into a single list of sends so that one
|
|
723
|
+
// global concurrency cap governs total in-flight requests during the
|
|
724
|
+
// connect flow — important when either dimension grows large.
|
|
725
|
+
const sendTasks = permissionGrants.flatMap((grant, grantIndex) => {
|
|
694
726
|
const { encodedData, ...rawMessage } = grant.message;
|
|
695
727
|
const data = Convert.base64Url(encodedData).toUint8Array();
|
|
728
|
+
return dwnEndpointUrls.map((dwnUrl) => ({ grantIndex, dwnUrl, rawMessage, data }));
|
|
729
|
+
});
|
|
696
730
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
731
|
+
const settled = await mapConcurrentSettled(
|
|
732
|
+
sendTasks,
|
|
733
|
+
CONNECT_FANOUT_CONCURRENCY,
|
|
734
|
+
async ({ grantIndex, dwnUrl, rawMessage, data }) => {
|
|
735
|
+
const reply = await agent.rpc.sendDwnRequest({
|
|
736
|
+
dwnUrl,
|
|
737
|
+
targetDid : selectedDid,
|
|
738
|
+
message : rawMessage,
|
|
739
|
+
data : new Blob([data as BlobPart]),
|
|
740
|
+
signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
|
|
741
|
+
});
|
|
742
|
+
return { grantIndex, dwnUrl, reply };
|
|
743
|
+
},
|
|
744
|
+
);
|
|
708
745
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}
|
|
746
|
+
// Aggregate results back per grant: each grant must have at least one
|
|
747
|
+
// endpoint accept it (status 202 or 409 — already-stored is acceptable).
|
|
748
|
+
const successPerGrant = new Array<boolean>(permissionGrants.length).fill(false);
|
|
749
|
+
for (let i = 0; i < settled.length; i++) {
|
|
750
|
+
const result = settled[i];
|
|
751
|
+
if (result.status === 'rejected') {
|
|
752
|
+
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
753
|
+
logger.error(`Grant send to ${sendTasks[i].dwnUrl} failed: ${reason}`);
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
const { grantIndex, dwnUrl, reply } = result.value;
|
|
757
|
+
if (reply.status.code === 202 || reply.status.code === 409) {
|
|
758
|
+
successPerGrant[grantIndex] = true;
|
|
759
|
+
} else {
|
|
760
|
+
logger.error(`Grant send to ${dwnUrl} returned ${reply.status.code}: ${reply.status.detail}`);
|
|
717
761
|
}
|
|
762
|
+
}
|
|
718
763
|
|
|
719
|
-
|
|
764
|
+
for (let g = 0; g < permissionGrants.length; g++) {
|
|
765
|
+
if (!successPerGrant[g]) {
|
|
766
|
+
logger.error(`Error during batch-send of permission grants: grant ${g} reached no DWN endpoint.`);
|
|
720
767
|
throw new Error('Could not send permission grant to any DWN endpoint.');
|
|
721
768
|
}
|
|
722
|
-
|
|
723
|
-
return grant.message;
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
try {
|
|
727
|
-
return await Promise.all(messagePromises);
|
|
728
|
-
} catch (error) {
|
|
729
|
-
logger.error(`Error during batch-send of permission grants: ${error}`);
|
|
730
|
-
throw error;
|
|
731
769
|
}
|
|
770
|
+
|
|
771
|
+
return permissionGrants.map((g) => g.message);
|
|
732
772
|
}
|
|
733
773
|
|
|
734
774
|
// ---------------------------------------------------------------------------
|
|
@@ -736,24 +776,31 @@ async function createPermissionGrants(
|
|
|
736
776
|
// ---------------------------------------------------------------------------
|
|
737
777
|
|
|
738
778
|
/**
|
|
739
|
-
*
|
|
740
|
-
*
|
|
779
|
+
* Ensures the protocol is installed on the provider's local DWN so that the
|
|
780
|
+
* agent can sign and (when applicable) encrypt grants for it during
|
|
781
|
+
* `submitConnectResponse`.
|
|
741
782
|
*
|
|
742
|
-
*
|
|
743
|
-
* the
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
783
|
+
* Remote installation (push to every owner DWN endpoint) is the
|
|
784
|
+
* responsibility of the calling client (the wallet's own `prepareProtocol`
|
|
785
|
+
* runs *before* `submitConnectResponse` and fans out to every endpoint in
|
|
786
|
+
* parallel). When the protocol already exists locally — the common case —
|
|
787
|
+
* this function performs a single local `ProtocolsQuery` and returns: there
|
|
788
|
+
* is no remote send, so a slow/unhealthy DWN endpoint cannot block the
|
|
789
|
+
* "Authorizing…" hot path.
|
|
790
|
+
*
|
|
791
|
+
* When the protocol is *not* installed locally — a safety fallback for
|
|
792
|
+
* callers that did not pre-install — the protocol is configured locally
|
|
793
|
+
* (with `encryption: true` when any type declares `encryptionRequired: true`,
|
|
794
|
+
* so the agent injects `$encryption` keys derived from the owner's X25519
|
|
795
|
+
* root key) and then fanned out to every owner DWN endpoint with bounded
|
|
796
|
+
* concurrency and a short per-request budget. Endpoint failures are
|
|
797
|
+
* non-fatal — sync delivers any missing copies eventually.
|
|
747
798
|
*/
|
|
748
799
|
async function prepareProtocol(
|
|
749
800
|
selectedDid: string,
|
|
750
801
|
agent: EnboxPlatformAgent,
|
|
751
802
|
protocolDefinition: DwnProtocolDefinition
|
|
752
803
|
): Promise<void> {
|
|
753
|
-
// Detect whether any type in the protocol requires encryption.
|
|
754
|
-
const needsEncryption = Object.values(protocolDefinition.types ?? {})
|
|
755
|
-
.some((type: any) => type?.encryptionRequired === true);
|
|
756
|
-
|
|
757
804
|
const queryMessage = await agent.processDwnRequest({
|
|
758
805
|
author : selectedDid,
|
|
759
806
|
messageType : DwnInterface.ProtocolsQuery,
|
|
@@ -763,42 +810,66 @@ async function prepareProtocol(
|
|
|
763
810
|
|
|
764
811
|
if (queryMessage.reply.status.code !== 200) {
|
|
765
812
|
throw new Error(`Could not fetch protocol: ${queryMessage.reply.status.detail}`);
|
|
766
|
-
}
|
|
767
|
-
logger.log(`Protocol does not exist, creating: ${protocolDefinition.protocol}`);
|
|
768
|
-
|
|
769
|
-
const { reply: sendReply, message: configureMessage } = await agent.sendDwnRequest({
|
|
770
|
-
author : selectedDid,
|
|
771
|
-
target : selectedDid,
|
|
772
|
-
messageType : DwnInterface.ProtocolsConfigure,
|
|
773
|
-
messageParams : { definition: protocolDefinition },
|
|
774
|
-
encryption : needsEncryption || undefined,
|
|
775
|
-
});
|
|
813
|
+
}
|
|
776
814
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
815
|
+
const isInstalledLocally = queryMessage.reply.entries !== undefined
|
|
816
|
+
&& queryMessage.reply.entries.length > 0;
|
|
817
|
+
|
|
818
|
+
if (isInstalledLocally) {
|
|
819
|
+
// Already installed locally. The wallet's pre-call `prepareProtocol`
|
|
820
|
+
// is responsible for fanning the protocol out to every owner DWN
|
|
821
|
+
// endpoint; sync delivers any missing copies eventually. Skipping the
|
|
822
|
+
// remote send here turns this hot path into a single local DB read
|
|
823
|
+
// (~10 ms) instead of a sequential per-endpoint network round-trip
|
|
824
|
+
// with retries — the latter could take minutes if any endpoint was
|
|
825
|
+
// slow or unreachable.
|
|
826
|
+
logger.log(`Protocol already installed locally: ${protocolDefinition.protocol}`);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
780
829
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
logger.log(`Protocol already exists: ${protocolDefinition.protocol}`);
|
|
789
|
-
|
|
790
|
-
const configureMessage = queryMessage.reply.entries![0];
|
|
791
|
-
const { reply: sendReply } = await agent.sendDwnRequest({
|
|
792
|
-
author : selectedDid,
|
|
793
|
-
target : selectedDid,
|
|
794
|
-
messageType : DwnInterface.ProtocolsConfigure,
|
|
795
|
-
rawMessage : configureMessage,
|
|
796
|
-
});
|
|
830
|
+
// Safety fallback — protocol is missing locally, so the caller did not
|
|
831
|
+
// pre-install. Configure it locally (with encryption derivation if any
|
|
832
|
+
// type requires it) so the agent can sign/encrypt grants, then push to
|
|
833
|
+
// every owner DWN endpoint in parallel with a short per-request budget.
|
|
834
|
+
logger.log(`Protocol not installed, configuring locally: ${protocolDefinition.protocol}`);
|
|
835
|
+
const needsEncryption = Object.values(protocolDefinition.types ?? {})
|
|
836
|
+
.some((type: any) => type?.encryptionRequired === true);
|
|
797
837
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
838
|
+
const { reply: configureReply, message: configureMessage } = await agent.processDwnRequest({
|
|
839
|
+
author : selectedDid,
|
|
840
|
+
target : selectedDid,
|
|
841
|
+
messageType : DwnInterface.ProtocolsConfigure,
|
|
842
|
+
messageParams : { definition: protocolDefinition },
|
|
843
|
+
encryption : needsEncryption || undefined,
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
if (configureReply.status.code !== 202 && configureReply.status.code !== 409) {
|
|
847
|
+
throw new Error(`Could not configure protocol locally: ${configureReply.status.detail}`);
|
|
801
848
|
}
|
|
849
|
+
|
|
850
|
+
let dwnEndpointUrls: string[] = [];
|
|
851
|
+
try {
|
|
852
|
+
dwnEndpointUrls = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
|
|
853
|
+
} catch {
|
|
854
|
+
// Endpoint resolution failure — protocol stays local-only until sync.
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (dwnEndpointUrls.length === 0) {
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Best-effort remote fan-out with bounded concurrency and a per-request
|
|
862
|
+
// abort signal. Failures are tolerated (sync delivers eventually).
|
|
863
|
+
await mapConcurrentSettled(
|
|
864
|
+
dwnEndpointUrls,
|
|
865
|
+
CONNECT_FANOUT_CONCURRENCY,
|
|
866
|
+
(dwnUrl) => agent.rpc.sendDwnRequest({
|
|
867
|
+
dwnUrl,
|
|
868
|
+
targetDid : selectedDid,
|
|
869
|
+
message : configureMessage!,
|
|
870
|
+
signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
|
|
871
|
+
}),
|
|
872
|
+
);
|
|
802
873
|
}
|
|
803
874
|
|
|
804
875
|
/**
|
|
@@ -1243,46 +1314,62 @@ async function submitConnectResponse(
|
|
|
1243
1314
|
// Snapshot the current length — revocation grants are appended to delegateGrants
|
|
1244
1315
|
// below, but we must NOT iterate over them (they are meta-grants, not session grants).
|
|
1245
1316
|
const sessionGrantCount = delegateGrants.length;
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1317
|
+
|
|
1318
|
+
// Phase 1: create all revocation grants locally with bounded concurrency.
|
|
1319
|
+
// createGrant is local-only (storage + signing) so it's cheap, but we still
|
|
1320
|
+
// cap parallelism to avoid head-of-line blocking when sessionGrantCount is
|
|
1321
|
+
// large (e.g. dapp requesting many scopes at once).
|
|
1322
|
+
const revGrantResults = await mapConcurrent(
|
|
1323
|
+
delegateGrants.slice(0, sessionGrantCount),
|
|
1324
|
+
CONNECT_FANOUT_CONCURRENCY,
|
|
1325
|
+
(grantMessage) =>
|
|
1326
|
+
permissionsApi.createGrant({
|
|
1327
|
+
delegated : true,
|
|
1328
|
+
store : true,
|
|
1329
|
+
grantedTo : delegateBearerDid.uri,
|
|
1330
|
+
scope : {
|
|
1331
|
+
interface : DwnInterfaceName.Records,
|
|
1332
|
+
method : DwnMethodName.Write,
|
|
1333
|
+
protocol : PermissionsProtocol.uri,
|
|
1334
|
+
contextId : grantMessage.recordId,
|
|
1335
|
+
},
|
|
1336
|
+
dateExpires : '2040-06-25T16:09:16.693356Z',
|
|
1337
|
+
author : selectedDid,
|
|
1338
|
+
}).then((revGrant) => ({ grantMessage, revGrant })),
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
// Phase 2: fan out every revocation grant to every owner DWN endpoint with
|
|
1342
|
+
// a single global concurrency cap so that (grants × endpoints) cannot blow
|
|
1343
|
+
// up. This is best-effort (sync delivers eventually) so individual failures
|
|
1344
|
+
// are tolerated by `mapConcurrentSettled`.
|
|
1345
|
+
const revSendTasks = revGrantResults.flatMap(({ grantMessage, revGrant }) => {
|
|
1261
1346
|
sessionRevocations.push({
|
|
1262
1347
|
grantId : grantMessage.recordId,
|
|
1263
1348
|
revocationGrantId : revGrant.message.recordId,
|
|
1264
1349
|
});
|
|
1265
1350
|
|
|
1266
|
-
// Fan out the revocation grant to all owner DWN endpoints (same
|
|
1267
|
-
// as session grants) so that immediate disconnect can send a
|
|
1268
|
-
// delegated revocation to a remote DWN that recognises the grant.
|
|
1269
1351
|
const { encodedData: revEncoded, ...revRawMessage } = revGrant.message;
|
|
1270
1352
|
const revData = Convert.base64Url(revEncoded).toUint8Array();
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1353
|
+
|
|
1354
|
+
// Include the revocation grant in the delegate grants for distribution.
|
|
1355
|
+
delegateGrants.push(revGrant.message);
|
|
1356
|
+
|
|
1357
|
+
return revGrantEndpoints.map((dwnUrl) => ({ revRawMessage, revData, dwnUrl }));
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
if (revSendTasks.length > 0) {
|
|
1361
|
+
await mapConcurrentSettled(
|
|
1362
|
+
revSendTasks,
|
|
1363
|
+
CONNECT_FANOUT_CONCURRENCY,
|
|
1364
|
+
({ revRawMessage, revData, dwnUrl }) =>
|
|
1365
|
+
agent.rpc.sendDwnRequest({
|
|
1274
1366
|
dwnUrl,
|
|
1275
1367
|
targetDid : selectedDid,
|
|
1276
1368
|
message : revRawMessage,
|
|
1277
1369
|
data : new Blob([revData as BlobPart]),
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
// Include the revocation grant in the delegate grants for distribution
|
|
1285
|
-
delegateGrants.push(revGrant.message);
|
|
1370
|
+
signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
|
|
1371
|
+
}),
|
|
1372
|
+
);
|
|
1286
1373
|
}
|
|
1287
1374
|
|
|
1288
1375
|
logger.log('Building connect response...');
|
package/src/utils.ts
CHANGED
|
@@ -169,3 +169,72 @@ export function concatenateUrl(baseUrl: string, path: string): string {
|
|
|
169
169
|
|
|
170
170
|
return `${baseUrl}/${path}`;
|
|
171
171
|
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Map over an array with bounded concurrency, preserving input order in the
|
|
175
|
+
* output array. Uses a sliding-window pool of workers so the next item is
|
|
176
|
+
* picked up as soon as any in-flight task settles — strictly better than a
|
|
177
|
+
* fixed chunked-batch pattern (no stalling on the slowest item per batch).
|
|
178
|
+
*
|
|
179
|
+
* Semantics match `Promise.all`: the returned promise rejects on the first
|
|
180
|
+
* task rejection. Use {@link mapConcurrentSettled} when individual failures
|
|
181
|
+
* should not abort the whole batch (e.g. best-effort fan-out).
|
|
182
|
+
*
|
|
183
|
+
* @param items Input items to map over.
|
|
184
|
+
* @param concurrency Maximum number of in-flight tasks. Must be >= 1.
|
|
185
|
+
* @param fn Per-item async function. Receives the item and its index.
|
|
186
|
+
* @returns An array of results in the same order as `items`.
|
|
187
|
+
*/
|
|
188
|
+
export async function mapConcurrent<T, R>(
|
|
189
|
+
items: readonly T[],
|
|
190
|
+
concurrency: number,
|
|
191
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
192
|
+
): Promise<R[]> {
|
|
193
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
194
|
+
throw new Error(`mapConcurrent: concurrency must be a positive integer, got ${concurrency}`);
|
|
195
|
+
}
|
|
196
|
+
if (items.length === 0) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const results = new Array<R>(items.length);
|
|
201
|
+
let next = 0;
|
|
202
|
+
|
|
203
|
+
const worker = async (): Promise<void> => {
|
|
204
|
+
while (true) {
|
|
205
|
+
const i = next++;
|
|
206
|
+
if (i >= items.length) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
results[i] = await fn(items[i], i);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const workerCount = Math.min(concurrency, items.length);
|
|
214
|
+
const workers: Promise<void>[] = [];
|
|
215
|
+
for (let w = 0; w < workerCount; w++) {
|
|
216
|
+
workers.push(worker());
|
|
217
|
+
}
|
|
218
|
+
await Promise.all(workers);
|
|
219
|
+
return results;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Settled variant of {@link mapConcurrent}: never rejects — every task's
|
|
224
|
+
* outcome is captured as a `PromiseSettledResult`. Use this for best-effort
|
|
225
|
+
* fan-outs where partial success is acceptable.
|
|
226
|
+
*/
|
|
227
|
+
export async function mapConcurrentSettled<T, R>(
|
|
228
|
+
items: readonly T[],
|
|
229
|
+
concurrency: number,
|
|
230
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
231
|
+
): Promise<PromiseSettledResult<R>[]> {
|
|
232
|
+
return mapConcurrent(items, concurrency, async (item, index) => {
|
|
233
|
+
try {
|
|
234
|
+
const value = await fn(item, index);
|
|
235
|
+
return { status: 'fulfilled', value } as PromiseFulfilledResult<R>;
|
|
236
|
+
} catch (reason) {
|
|
237
|
+
return { status: 'rejected', reason } as PromiseRejectedResult;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|