@enbox/agent 0.6.7 → 0.6.8

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.
@@ -115,12 +115,26 @@ 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;
124
138
 
125
139
  // ---------------------------------------------------------------------------
126
140
  // Types
@@ -690,45 +704,55 @@ async function createPermissionGrants(
690
704
  const dwnEndpointUrls = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
691
705
  logger.log(`Sending ${permissionGrants.length} permission grants to ${dwnEndpointUrls.length} DWN endpoint(s)...`);
692
706
 
693
- const messagePromises = permissionGrants.map(async (grant) => {
707
+ // Flatten (grant, endpoint) tuples into a single list of sends so that one
708
+ // global concurrency cap governs total in-flight requests during the
709
+ // connect flow — important when either dimension grows large.
710
+ const sendTasks = permissionGrants.flatMap((grant, grantIndex) => {
694
711
  const { encodedData, ...rawMessage } = grant.message;
695
712
  const data = Convert.base64Url(encodedData).toUint8Array();
713
+ return dwnEndpointUrls.map((dwnUrl) => ({ grantIndex, dwnUrl, rawMessage, data }));
714
+ });
696
715
 
697
- // The rawMessage is already signed by createGrant(), so we send it
698
- // directly to each endpoint without re-constructing.
699
- let atLeastOneSuccess = false;
700
- for (const dwnUrl of dwnEndpointUrls) {
701
- try {
702
- const reply = await agent.rpc.sendDwnRequest({
703
- dwnUrl,
704
- targetDid : selectedDid,
705
- message : rawMessage,
706
- data : new Blob([data as BlobPart]),
707
- });
716
+ const settled = await mapConcurrentSettled(
717
+ sendTasks,
718
+ CONNECT_FANOUT_CONCURRENCY,
719
+ async ({ grantIndex, dwnUrl, rawMessage, data }) => {
720
+ const reply = await agent.rpc.sendDwnRequest({
721
+ dwnUrl,
722
+ targetDid : selectedDid,
723
+ message : rawMessage,
724
+ data : new Blob([data as BlobPart]),
725
+ });
726
+ return { grantIndex, dwnUrl, reply };
727
+ },
728
+ );
708
729
 
709
- if (reply.status.code === 202 || reply.status.code === 409) {
710
- atLeastOneSuccess = true;
711
- } else {
712
- logger.error(`Grant send to ${dwnUrl} returned ${reply.status.code}: ${reply.status.detail}`);
713
- }
714
- } catch (error: any) {
715
- logger.error(`Grant send to ${dwnUrl} failed: ${error.message}`);
716
- }
730
+ // Aggregate results back per grant: each grant must have at least one
731
+ // endpoint accept it (status 202 or 409 — already-stored is acceptable).
732
+ const successPerGrant = new Array<boolean>(permissionGrants.length).fill(false);
733
+ for (let i = 0; i < settled.length; i++) {
734
+ const result = settled[i];
735
+ if (result.status === 'rejected') {
736
+ const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
737
+ logger.error(`Grant send to ${sendTasks[i].dwnUrl} failed: ${reason}`);
738
+ continue;
739
+ }
740
+ const { grantIndex, dwnUrl, reply } = result.value;
741
+ if (reply.status.code === 202 || reply.status.code === 409) {
742
+ successPerGrant[grantIndex] = true;
743
+ } else {
744
+ logger.error(`Grant send to ${dwnUrl} returned ${reply.status.code}: ${reply.status.detail}`);
717
745
  }
746
+ }
718
747
 
719
- if (!atLeastOneSuccess) {
748
+ for (let g = 0; g < permissionGrants.length; g++) {
749
+ if (!successPerGrant[g]) {
750
+ logger.error(`Error during batch-send of permission grants: grant ${g} reached no DWN endpoint.`);
720
751
  throw new Error('Could not send permission grant to any DWN endpoint.');
721
752
  }
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
753
  }
754
+
755
+ return permissionGrants.map((g) => g.message);
732
756
  }
733
757
 
734
758
  // ---------------------------------------------------------------------------
@@ -1243,46 +1267,61 @@ async function submitConnectResponse(
1243
1267
  // Snapshot the current length — revocation grants are appended to delegateGrants
1244
1268
  // below, but we must NOT iterate over them (they are meta-grants, not session grants).
1245
1269
  const sessionGrantCount = delegateGrants.length;
1246
- for (let i = 0; i < sessionGrantCount; i++) {
1247
- const grantMessage = delegateGrants[i];
1248
- const revGrant = await permissionsApi.createGrant({
1249
- delegated : true,
1250
- store : true,
1251
- grantedTo : delegateBearerDid.uri,
1252
- scope : {
1253
- interface : DwnInterfaceName.Records,
1254
- method : DwnMethodName.Write,
1255
- protocol : PermissionsProtocol.uri,
1256
- contextId : grantMessage.recordId,
1257
- },
1258
- dateExpires : '2040-06-25T16:09:16.693356Z',
1259
- author : selectedDid,
1260
- });
1270
+
1271
+ // Phase 1: create all revocation grants locally with bounded concurrency.
1272
+ // createGrant is local-only (storage + signing) so it's cheap, but we still
1273
+ // cap parallelism to avoid head-of-line blocking when sessionGrantCount is
1274
+ // large (e.g. dapp requesting many scopes at once).
1275
+ const revGrantResults = await mapConcurrent(
1276
+ delegateGrants.slice(0, sessionGrantCount),
1277
+ CONNECT_FANOUT_CONCURRENCY,
1278
+ (grantMessage) =>
1279
+ permissionsApi.createGrant({
1280
+ delegated : true,
1281
+ store : true,
1282
+ grantedTo : delegateBearerDid.uri,
1283
+ scope : {
1284
+ interface : DwnInterfaceName.Records,
1285
+ method : DwnMethodName.Write,
1286
+ protocol : PermissionsProtocol.uri,
1287
+ contextId : grantMessage.recordId,
1288
+ },
1289
+ dateExpires : '2040-06-25T16:09:16.693356Z',
1290
+ author : selectedDid,
1291
+ }).then((revGrant) => ({ grantMessage, revGrant })),
1292
+ );
1293
+
1294
+ // Phase 2: fan out every revocation grant to every owner DWN endpoint with
1295
+ // a single global concurrency cap so that (grants × endpoints) cannot blow
1296
+ // up. This is best-effort (sync delivers eventually) so individual failures
1297
+ // are tolerated by `mapConcurrentSettled`.
1298
+ const revSendTasks = revGrantResults.flatMap(({ grantMessage, revGrant }) => {
1261
1299
  sessionRevocations.push({
1262
1300
  grantId : grantMessage.recordId,
1263
1301
  revocationGrantId : revGrant.message.recordId,
1264
1302
  });
1265
1303
 
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
1304
  const { encodedData: revEncoded, ...revRawMessage } = revGrant.message;
1270
1305
  const revData = Convert.base64Url(revEncoded).toUint8Array();
1271
- for (const dwnUrl of revGrantEndpoints) {
1272
- try {
1273
- await agent.rpc.sendDwnRequest({
1306
+
1307
+ // Include the revocation grant in the delegate grants for distribution.
1308
+ delegateGrants.push(revGrant.message);
1309
+
1310
+ return revGrantEndpoints.map((dwnUrl) => ({ revRawMessage, revData, dwnUrl }));
1311
+ });
1312
+
1313
+ if (revSendTasks.length > 0) {
1314
+ await mapConcurrentSettled(
1315
+ revSendTasks,
1316
+ CONNECT_FANOUT_CONCURRENCY,
1317
+ ({ revRawMessage, revData, dwnUrl }) =>
1318
+ agent.rpc.sendDwnRequest({
1274
1319
  dwnUrl,
1275
1320
  targetDid : selectedDid,
1276
1321
  message : revRawMessage,
1277
1322
  data : new Blob([revData as BlobPart]),
1278
- });
1279
- } catch {
1280
- // Best-effort — sync will deliver eventually.
1281
- }
1282
- }
1283
-
1284
- // Include the revocation grant in the delegate grants for distribution
1285
- delegateGrants.push(revGrant.message);
1323
+ }),
1324
+ );
1286
1325
  }
1287
1326
 
1288
1327
  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
+ }