@nyaruka/temba-components 0.170.0 → 0.171.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.
@@ -22,15 +22,26 @@ import {
22
22
  DirtyTrackable
23
23
  } from '../interfaces';
24
24
  import { RapidElement } from '../RapidElement';
25
- import { setRealtimeContext } from '../live/Realtime';
25
+ import {
26
+ RealtimeSubscription,
27
+ setRealtimeContext,
28
+ subscribeToOrganization
29
+ } from '../live/Realtime';
26
30
  import { lru } from 'tiny-lru';
27
31
  import { DateTime } from 'luxon';
28
32
  import { css, html } from 'lit';
29
33
  import { configureLocalization } from '@lit/localize';
30
34
  import { sourceLocale, targetLocales } from '../locales/locale-codes';
31
35
  import { getFullName } from '../display/TembaUser';
32
- import { AppState, zustand } from './AppState';
36
+ import {
37
+ AppState,
38
+ DependencyResolver,
39
+ getDependencyResolver,
40
+ setDependencyResolver,
41
+ zustand
42
+ } from './AppState';
33
43
  import { StoreApi } from 'zustand/vanilla';
44
+ import { normalizeUuid } from './identity';
34
45
 
35
46
  const { setLocale } = configureLocalization({
36
47
  sourceLocale,
@@ -42,6 +53,85 @@ export const getStore = () => {
42
53
  return document.querySelector('temba-store') as Store;
43
54
  };
44
55
 
56
+ export const STORE_ASSET_TYPES = [
57
+ 'channel',
58
+ 'contact',
59
+ 'field',
60
+ 'flow',
61
+ 'global',
62
+ 'group',
63
+ 'label',
64
+ 'llm',
65
+ 'optin',
66
+ 'template',
67
+ 'topic',
68
+ 'user'
69
+ ] as const;
70
+
71
+ export type StoreAssetType = (typeof STORE_ASSET_TYPES)[number];
72
+
73
+ export interface StoreAssetReference {
74
+ type: StoreAssetType;
75
+ uuid?: string;
76
+ key?: string;
77
+ }
78
+
79
+ export interface StoreAsset extends StoreAssetReference {
80
+ name: string;
81
+ }
82
+
83
+ export interface StoreAssetChangedEvent {
84
+ type: 'asset_changed';
85
+ asset: StoreAsset;
86
+ }
87
+
88
+ export type StoreAssetHandler = (event: StoreAssetChangedEvent | null) => void;
89
+
90
+ interface AssetWatcher {
91
+ interests: StoreAssetReference[];
92
+ onEvent: StoreAssetHandler;
93
+ }
94
+
95
+ const STORE_ASSET_TYPE_SET = new Set<string>(STORE_ASSET_TYPES);
96
+
97
+ // the endpoint rejects a request carrying more than this many identifiers
98
+ const ASSET_BATCH_SIZE = 100;
99
+
100
+ // long-lived pages (a flow list paged through many times) would otherwise
101
+ // accumulate an entry for every asset ever seen. Once an entry is evicted the
102
+ // component falls back to the name its own response carried, and the identity
103
+ // is fetched again next time it is asked for.
104
+ export const ASSET_CACHE_SIZE = 500;
105
+
106
+ /**
107
+ * Versions are held well above the asset cache because losing one doesn't just
108
+ * forget a name, it drops the in-flight guard for that identity - and `assets`
109
+ * is bumped by reads that never touch `assetVersions`, so the two ages drift
110
+ * apart. Entries are a single number, so the extra headroom is cheap.
111
+ */
112
+ export const ASSET_VERSION_CACHE_SIZE = ASSET_CACHE_SIZE * 8;
113
+
114
+ const isStoreAssetType = (type: string): type is StoreAssetType =>
115
+ STORE_ASSET_TYPE_SET.has(type);
116
+
117
+ const assetIdentity = (
118
+ asset: { type?: string; uuid?: string; key?: string } | null
119
+ ): string | null => {
120
+ if (!asset || !isStoreAssetType(asset.type)) {
121
+ return null;
122
+ }
123
+ const identity = asset.uuid ? normalizeUuid(asset.uuid) : asset.key;
124
+ return identity ? `${asset.type}:${identity}` : null;
125
+ };
126
+
127
+ const watchesAsset = (watcher: AssetWatcher, asset: StoreAsset): boolean => {
128
+ const identity = assetIdentity(asset);
129
+ return (
130
+ !!identity &&
131
+ watcher.interests.some((interest) => assetIdentity(interest) === identity)
132
+ );
133
+ };
134
+
45
135
  declare const __TEMBA_DEV_SERVER__: boolean;
46
136
 
47
137
  /**
@@ -100,6 +190,9 @@ export class Store extends RapidElement {
100
190
  @property({ type: String, attribute: 'groups' })
101
191
  groupsEndpoint: string;
102
192
 
193
+ @property({ type: String, attribute: 'assets' })
194
+ assetsEndpoint: string;
195
+
103
196
  @property({ type: String, attribute: 'globals' })
104
197
  globalsEndpoint: string;
105
198
 
@@ -136,6 +229,24 @@ export class Store extends RapidElement {
136
229
  private shortcuts: Shortcut[] = [];
137
230
  private workspace: Workspace;
138
231
  private featuredFields: ContactField[] = [];
232
+ private assetWatchers: AssetWatcher[] = [];
233
+ // canonical names, the identities we've already asked about (including ones
234
+ // the endpoint had no asset for) and their last-write versions, all bounded
235
+ // so a long-lived page doesn't keep every asset it has ever rendered
236
+ private assets = lru<StoreAsset>(ASSET_CACHE_SIZE);
237
+ private resolvedAssetIdentities = lru<boolean>(ASSET_CACHE_SIZE);
238
+ private assetVersions = lru<number>(ASSET_VERSION_CACHE_SIZE);
239
+ private pendingAssetRequests = new Map<string, Promise<void>>();
240
+ private assetVersion = 0;
241
+ // bumped by reset(), which only firstUpdated() calls today - the guard exists
242
+ // so a future caller that resets a live store can't have the cleared cache
243
+ // repopulated by a batch that was already in flight
244
+ private assetGeneration = 0;
245
+ private organizationWatch: RealtimeSubscription = null;
246
+ private organizationSubscribed = false;
247
+ private previousDependencyResolver: DependencyResolver = null;
248
+ private dependencyResolver: DependencyResolver = (dependencies) =>
249
+ this.resolveAssets(dependencies);
139
250
 
140
251
  // http promise to monitor for completeness
141
252
  public initialHttpComplete: Promise<void | WebResponse[]>;
@@ -198,6 +309,13 @@ export class Store extends RapidElement {
198
309
  this.clearCache();
199
310
  this.settings = JSON.parse(getCookie('settings') || '{}');
200
311
  zustand.setState({ brand: this.brand });
312
+ this.groups = {};
313
+ this.assets.clear();
314
+ this.resolvedAssetIdentities.clear();
315
+ this.pendingAssetRequests.clear();
316
+ this.assetVersions.clear();
317
+ this.assetVersion = 0;
318
+ this.assetGeneration++;
201
319
 
202
320
  /*
203
321
  // This will create a shorthand unit
@@ -238,10 +356,10 @@ export class Store extends RapidElement {
238
356
 
239
357
  if (this.groupsEndpoint) {
240
358
  fetches.push(
241
- getAssets(this.groupsEndpoint).then((groups: any[]) => {
242
- groups.forEach((group: any) => {
359
+ getAssets<ContactGroup>(this.groupsEndpoint).then((groups) => {
360
+ for (const group of groups) {
243
361
  this.groups[group.uuid] = group;
244
- });
362
+ }
245
363
  })
246
364
  );
247
365
  }
@@ -274,11 +392,61 @@ export class Store extends RapidElement {
274
392
  return this.shortcuts || [];
275
393
  }
276
394
 
395
+ public connectedCallback(): void {
396
+ super.connectedCallback();
397
+ // lit only runs firstUpdated once, so a store that is detached and
398
+ // re-attached has to reinstall its page hooks here
399
+ if (this.hasUpdated) {
400
+ this.installPageHooks();
401
+ }
402
+ }
403
+
277
404
  public firstUpdated() {
405
+ this.installPageHooks();
406
+ this.reset();
407
+ }
408
+
409
+ /**
410
+ * Installs the hooks this store owns on behalf of the page: the canonical
411
+ * name resolver and the workspace realtime subscription. Idempotent so it
412
+ * can run again when a store is re-attached.
413
+ */
414
+ private installPageHooks(): void {
415
+ if (getDependencyResolver() !== this.dependencyResolver) {
416
+ this.previousDependencyResolver = setDependencyResolver(
417
+ this.dependencyResolver
418
+ );
419
+ }
278
420
  if (this.org && this.user) {
279
421
  setRealtimeContext({ org: this.org, user: this.user });
422
+ if (!this.organizationWatch) {
423
+ this.organizationWatch = subscribeToOrganization(
424
+ (event) => this.handleOrganizationEvent(event),
425
+ () => {
426
+ if (this.organizationSubscribed) {
427
+ this.refreshAssetCache().catch((error) => {
428
+ console.error('failed to refresh store assets', error);
429
+ });
430
+ }
431
+ this.organizationSubscribed = true;
432
+ }
433
+ );
434
+ }
435
+ }
436
+ }
437
+
438
+ public disconnectedCallback(): void {
439
+ super.disconnectedCallback();
440
+ // only hand the resolver back if we're still the installed one, otherwise
441
+ // a store that never installed it would clear the live store's resolver
442
+ if (getDependencyResolver() === this.dependencyResolver) {
443
+ setDependencyResolver(this.previousDependencyResolver);
444
+ }
445
+ this.previousDependencyResolver = null;
446
+ if (this.organizationWatch) {
447
+ this.organizationWatch.unsubscribe();
448
+ this.organizationWatch = null;
280
449
  }
281
- this.reset();
282
450
  }
283
451
 
284
452
  public getLanguageCode() {
@@ -431,6 +599,327 @@ export class Store extends RapidElement {
431
599
  return this.featuredFields;
432
600
  }
433
601
 
602
+ public getAsset(type: string, identity: string): StoreAsset | null {
603
+ if (!isStoreAssetType(type) || !identity) {
604
+ return null;
605
+ }
606
+ // the identity may be a key (cached verbatim, and case-sensitive) or a uuid
607
+ // (cached under its canonical form), so try verbatim first — a canonical
608
+ // uuid already hits on that pass, and keys never reach the normalizing one
609
+ return (
610
+ this.assets.get(`${type}:${identity}`) ||
611
+ this.assets.get(`${type}:${normalizeUuid(identity)}`) ||
612
+ null
613
+ );
614
+ }
615
+
616
+ /** Adds server-authoritative assets to the page cache. Components whose
617
+ * own response already contains canonical names can seed the same cache
618
+ * without making another request. */
619
+ public cacheAssets(assets: StoreAsset[]): void {
620
+ for (const asset of assets) {
621
+ this.cacheAsset(asset);
622
+ }
623
+ }
624
+
625
+ /** Resolves every requested reference from the cache, fetching only the
626
+ * identities this page hasn't resolved before. Concurrent callers share
627
+ * each in-flight batch and absent assets are negatively cached. */
628
+ public async resolveAssets(
629
+ requested: { type: string; uuid?: string; key?: string }[],
630
+ force = false
631
+ ): Promise<StoreAsset[]> {
632
+ const references = new Map<string, StoreAssetReference>();
633
+ for (const candidate of requested || []) {
634
+ if (!isStoreAssetType(candidate.type)) {
635
+ continue;
636
+ }
637
+ const reference: StoreAssetReference = {
638
+ type: candidate.type,
639
+ ...(candidate.uuid ? { uuid: candidate.uuid } : {}),
640
+ ...(candidate.key ? { key: candidate.key } : {})
641
+ };
642
+ const identity = assetIdentity(reference);
643
+ if (identity) {
644
+ references.set(identity, reference);
645
+ }
646
+ }
647
+
648
+ const waits = new Set<Promise<void>>();
649
+ const missing: StoreAssetReference[] = [];
650
+ for (const [identity, reference] of references) {
651
+ const pending = this.pendingAssetRequests.get(identity);
652
+ if (pending) {
653
+ waits.add(pending);
654
+ } else if (force || !this.resolvedAssetIdentities.has(identity)) {
655
+ missing.push(reference);
656
+ }
657
+ }
658
+
659
+ if (this.assetsEndpoint) {
660
+ for (
661
+ let offset = 0;
662
+ offset < missing.length;
663
+ offset += ASSET_BATCH_SIZE
664
+ ) {
665
+ const batch = missing.slice(offset, offset + ASSET_BATCH_SIZE);
666
+ const pending = this.fetchAssetBatch(batch);
667
+ waits.add(pending);
668
+ for (const reference of batch) {
669
+ const identity = assetIdentity(reference);
670
+ if (identity) {
671
+ this.pendingAssetRequests.set(identity, pending);
672
+ }
673
+ }
674
+ const cleanup = () => {
675
+ for (const reference of batch) {
676
+ const identity = assetIdentity(reference);
677
+ if (
678
+ identity &&
679
+ this.pendingAssetRequests.get(identity) === pending
680
+ ) {
681
+ this.pendingAssetRequests.delete(identity);
682
+ }
683
+ }
684
+ };
685
+ void pending.then(cleanup, cleanup);
686
+ }
687
+ }
688
+
689
+ // one failed batch mustn't discard the names the others resolved
690
+ await Promise.all(
691
+ [...waits].map((wait) =>
692
+ wait.catch((error) => {
693
+ console.error('failed to resolve assets', error);
694
+ })
695
+ )
696
+ );
697
+
698
+ const resolved: StoreAsset[] = [];
699
+ for (const identity of references.keys()) {
700
+ const asset = this.assets.get(identity);
701
+ if (asset) {
702
+ resolved.push(asset);
703
+ }
704
+ }
705
+ return resolved;
706
+ }
707
+
708
+ /**
709
+ * Registers interest in workspace asset changes. An eventless delivery
710
+ * lets the watcher apply anything already cached and is repeated after a
711
+ * reconnect refresh; live changes carry the changed asset.
712
+ */
713
+ public watchAssets(
714
+ requested: { type: string; uuid?: string; key?: string }[],
715
+ onEvent: StoreAssetHandler
716
+ ): RealtimeSubscription {
717
+ const watcher: AssetWatcher = {
718
+ // an interest without an identifier is dropped rather than treated as a
719
+ // type wildcard, matching how resolveAssets ignores those references
720
+ interests: (requested || [])
721
+ .filter(
722
+ (interest) =>
723
+ isStoreAssetType(interest.type) && !!(interest.uuid || interest.key)
724
+ )
725
+ .map((interest) => ({
726
+ type: interest.type as StoreAssetType,
727
+ ...(interest.uuid ? { uuid: interest.uuid } : {}),
728
+ ...(interest.key ? { key: interest.key } : {})
729
+ })),
730
+ onEvent
731
+ };
732
+ this.assetWatchers.push(watcher);
733
+ Promise.resolve().then(() => {
734
+ if (this.assetWatchers.includes(watcher)) {
735
+ this.deliverAssetEvent(watcher, null);
736
+ }
737
+ });
738
+
739
+ return {
740
+ unsubscribe: () => {
741
+ const index = this.assetWatchers.indexOf(watcher);
742
+ if (index >= 0) {
743
+ this.assetWatchers.splice(index, 1);
744
+ }
745
+ }
746
+ };
747
+ }
748
+
749
+ /**
750
+ * Refetches the assets the page is currently displaying after a reconnect,
751
+ * when we may have missed changes. Only live interests are refreshed - the
752
+ * identities we resolved for content that has since scrolled away would
753
+ * otherwise fan out into a request per hundred for no visible benefit.
754
+ */
755
+ private async refreshAssetCache(): Promise<void> {
756
+ const interests = new Map<string, StoreAssetReference>();
757
+ for (const watcher of this.assetWatchers) {
758
+ for (const interest of watcher.interests) {
759
+ const identity = assetIdentity(interest);
760
+ if (identity) {
761
+ interests.set(identity, interest);
762
+ }
763
+ }
764
+ }
765
+ if (interests.size > 0) {
766
+ await this.resolveAssets([...interests.values()], true);
767
+ }
768
+ for (const watcher of this.assetWatchers) {
769
+ this.deliverAssetEvent(watcher, null);
770
+ }
771
+ }
772
+
773
+ private async fetchAssetBatch(
774
+ references: StoreAssetReference[]
775
+ ): Promise<void> {
776
+ if (!this.assetsEndpoint || references.length === 0) {
777
+ return;
778
+ }
779
+
780
+ const generation = this.assetGeneration;
781
+ // undefined means "no write recorded", which is not the same as version 0:
782
+ // assetVersions is bounded, so an evicted entry must not be read as a
783
+ // newer write and throw away a perfectly fresh name
784
+ const startVersions = new Map<string, number | undefined>();
785
+ const requested = new Set<string>();
786
+ const payload: Partial<Record<StoreAssetType, string[]>> = {};
787
+ for (const reference of references) {
788
+ const identity = assetIdentity(reference);
789
+ const value = reference.uuid || reference.key;
790
+ if (!identity || !value) {
791
+ continue;
792
+ }
793
+ requested.add(identity);
794
+ startVersions.set(identity, this.assetVersions.get(identity));
795
+ (payload[reference.type] ||= []).push(value);
796
+ }
797
+
798
+ const response = await postJSON(this.assetsEndpoint, payload);
799
+ if (generation !== this.assetGeneration) {
800
+ return;
801
+ }
802
+ // postJSON only rejects on a server error, so a 4xx arrives here as an
803
+ // empty body - bail before the bookkeeping below negatively caches every
804
+ // requested identity for the life of the page
805
+ if (response.status < 200 || response.status >= 300) {
806
+ console.warn(
807
+ `asset request failed with status ${response.status}`,
808
+ payload
809
+ );
810
+ return;
811
+ }
812
+
813
+ for (const candidate of response.json?.results || []) {
814
+ // the endpoint echoes normalized uuids, which assetIdentity matches back
815
+ // to whatever form the reference was embedded in
816
+ const identity = assetIdentity(candidate);
817
+ if (
818
+ identity &&
819
+ requested.has(identity) &&
820
+ typeof candidate.name === 'string' &&
821
+ this.isCurrentAssetVersion(identity, startVersions.get(identity))
822
+ ) {
823
+ this.cacheAsset(candidate as StoreAsset);
824
+ }
825
+ }
826
+ // Remember every requested identity, including assets the endpoint
827
+ // omitted, so deleted/missing references aren't repeatedly fetched.
828
+ for (const identity of requested) {
829
+ this.resolvedAssetIdentities.set(identity, true);
830
+ }
831
+ }
832
+
833
+ /**
834
+ * True when nothing has written this identity since the given version was
835
+ * read. A version we no longer have (evicted from the bounded map) can't
836
+ * prove a newer write, so the response is allowed through - keeping a name
837
+ * we know is stale forever is the worse failure.
838
+ *
839
+ * Note what that costs when it happens: the write it can't see may be a live
840
+ * socket rename that landed mid-batch, so this doesn't merely fail to protect
841
+ * a name we've forgotten, it can overwrite a fresher one we still hold. That
842
+ * needs an eviction inside a single batch's flight time, which is why
843
+ * ASSET_VERSION_CACHE_SIZE is kept well clear of the asset cache; the next
844
+ * socket event or reconnect refresh heals it either way.
845
+ */
846
+ private isCurrentAssetVersion(
847
+ identity: string,
848
+ startVersion: number | undefined
849
+ ): boolean {
850
+ const current = this.assetVersions.get(identity);
851
+ return current === undefined || current === startVersion;
852
+ }
853
+
854
+ private cacheAsset(asset: StoreAsset): void {
855
+ const identity = assetIdentity(asset);
856
+ if (!identity || typeof asset.name !== 'string') {
857
+ return;
858
+ }
859
+ this.assets.set(identity, { ...asset });
860
+ this.resolvedAssetIdentities.set(identity, true);
861
+ // every direct write is newer than any batch already in flight, so bump
862
+ // the version to discard a response that read an older name
863
+ this.assetVersions.set(identity, ++this.assetVersion);
864
+ }
865
+
866
+ private handleOrganizationEvent(event: any): void {
867
+ const asset = event?.asset as StoreAsset;
868
+ const identity = assetIdentity(asset);
869
+ if (
870
+ event?.type !== 'asset_changed' ||
871
+ !identity ||
872
+ typeof asset.name !== 'string'
873
+ ) {
874
+ return;
875
+ }
876
+
877
+ const changed = { ...asset };
878
+ const interested = this.assetWatchers.filter((watcher) =>
879
+ watchesAsset(watcher, changed)
880
+ );
881
+ const previouslyResolved = this.resolvedAssetIdentities.has(identity);
882
+ const pending = this.pendingAssetRequests.has(identity);
883
+ const legacyGroup =
884
+ changed.type === 'group' &&
885
+ changed.uuid &&
886
+ Object.prototype.hasOwnProperty.call(this.groups, changed.uuid);
887
+
888
+ if (legacyGroup) {
889
+ this.groups[changed.uuid] = {
890
+ ...this.groups[changed.uuid],
891
+ uuid: changed.uuid,
892
+ name: changed.name
893
+ };
894
+ }
895
+ if (!previouslyResolved && !pending && interested.length === 0) {
896
+ return;
897
+ }
898
+
899
+ // cacheAsset records the write version, discarding any batch in flight
900
+ // for this identity that read the name before this change
901
+ this.cacheAsset(changed);
902
+
903
+ const publication: StoreAssetChangedEvent = {
904
+ type: 'asset_changed',
905
+ asset: changed
906
+ };
907
+ for (const watcher of interested) {
908
+ this.deliverAssetEvent(watcher, publication);
909
+ }
910
+ }
911
+
912
+ private deliverAssetEvent(
913
+ watcher: AssetWatcher,
914
+ event: StoreAssetChangedEvent | null
915
+ ): void {
916
+ try {
917
+ watcher.onEvent(event);
918
+ } catch (error) {
919
+ console.error('store asset watcher failed', error);
920
+ }
921
+ }
922
+
434
923
  public isDynamicGroup(uuid: string): boolean {
435
924
  const group = this.groups[uuid];
436
925
  // we treat missing groups as dynamic since the
@@ -0,0 +1,28 @@
1
+ const UNHYPHENATED_UUID = /^[0-9a-f]{32}$/;
2
+
3
+ /**
4
+ * Canonicalizes a uuid the way the server does, so a reference embedded in a
5
+ * flow definition in some other form (uppercase, braced, unhyphenated) still
6
+ * matches the normalized uuid the endpoint echoes back.
7
+ *
8
+ * This lives in its own module with no imports so both the store's asset cache
9
+ * and the flow definition rewriter can share it - they sit on opposite sides of
10
+ * a `Store -> AppState -> dependencies` chain, so a shared helper in either one
11
+ * would close an import cycle.
12
+ */
13
+ export const normalizeUuid = (uuid: string): string => {
14
+ const trimmed = uuid
15
+ .trim()
16
+ .replace(/^\{|\}$/g, '')
17
+ .toLowerCase();
18
+ if (UNHYPHENATED_UUID.test(trimmed)) {
19
+ return [
20
+ trimmed.slice(0, 8),
21
+ trimmed.slice(8, 12),
22
+ trimmed.slice(12, 16),
23
+ trimmed.slice(16, 20),
24
+ trimmed.slice(20)
25
+ ].join('-');
26
+ }
27
+ return trimmed;
28
+ };
package/src/utils.ts CHANGED
@@ -47,8 +47,8 @@ interface KeyedAsset {
47
47
  key?: string;
48
48
  }
49
49
 
50
- interface AssetPage {
51
- assets: Asset[];
50
+ interface AssetPage<T = Asset> {
51
+ assets: T[];
52
52
  next: string;
53
53
  }
54
54
 
@@ -233,8 +233,10 @@ export const fetchResults = async (
233
233
  return results;
234
234
  };
235
235
 
236
- export const getAssetPage = (url: string): Promise<AssetPage> => {
237
- return new Promise<AssetPage>((resolve, reject) => {
236
+ /** Fetches one page of an asset endpoint. The caller names the shape it
237
+ * expects, since these endpoints serve everything from groups to shortcuts. */
238
+ export const getAssetPage = <T = Asset>(url: string): Promise<AssetPage<T>> => {
239
+ return new Promise<AssetPage<T>>((resolve, reject) => {
238
240
  getUrl(url)
239
241
  .then((response: WebResponse) => {
240
242
  if (response.status >= 200 && response.status < 300) {
@@ -250,15 +252,15 @@ export const getAssetPage = (url: string): Promise<AssetPage> => {
250
252
  });
251
253
  };
252
254
 
253
- export const getAssets = async (url: string): Promise<Asset[]> => {
255
+ export const getAssets = async <T = Asset>(url: string): Promise<T[]> => {
254
256
  if (!url) {
255
- return new Promise<Asset[]>((resolve) => resolve([]));
257
+ return new Promise<T[]>((resolve) => resolve([]));
256
258
  }
257
259
 
258
- let assets: Asset[] = [];
260
+ let assets: T[] = [];
259
261
  let pageUrl = url;
260
262
  while (pageUrl) {
261
- const assetPage = await getAssetPage(pageUrl);
263
+ const assetPage = await getAssetPage<T>(pageUrl);
262
264
  if (assetPage.assets) {
263
265
  assets = assets.concat(assetPage.assets);
264
266
  pageUrl = assetPage.next;