@speckle/objectloader 2.1.1 → 2.4.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.
@@ -1 +1,24 @@
1
- // NOTE: This lib is not working in node, because node-fetch returns node-native readable streams - we need a workaround first.
1
+ // Since Node v<18 does not provide fetch, we need to pass it in the options object. Note that fetch must return a WHATWG compliant stream, so cross-fetch won't work, but node/undici's implementation will.
2
+
3
+ import { fetch } from 'undici'
4
+ import ObjectLoader from '../../index.js'
5
+
6
+ let loader = new ObjectLoader({
7
+ serverUrl:"https://latest.speckle.dev",
8
+ streamId:"3ed8357f29",
9
+ objectId:"0408ab9caaa2ebefb2dd7f1f671e7555",
10
+ options:{ enableCaching: false, excludeProps:[], fetch }
11
+ })
12
+
13
+
14
+ let loadData = async function loadData() {
15
+
16
+ let obj = await loader.getAndConstructObject((e) =>{
17
+ console.log(e) // log progress!
18
+ })
19
+
20
+ console.log('Done!')
21
+ console.log( obj )
22
+ }
23
+
24
+ loadData()
package/index.js CHANGED
@@ -1,34 +1,39 @@
1
1
  /**
2
- * Simple client that streams object info from a Speckle Server.
2
+ * Simple client that streams object info from a Speckle Server.
3
3
  * TODO: Object construction progress reporting is weird.
4
4
  */
5
5
 
6
6
 
7
-
8
7
  export default class ObjectLoader {
9
8
 
10
9
  /**
11
10
  * Creates a new object loader instance.
12
- * @param {*} param0
11
+ * @param {*} param0
13
12
  */
14
- constructor( { serverUrl, streamId, token, objectId, options = { fullyTraverseArrays: false, excludeProps: [ ] } } ) {
13
+ constructor( { serverUrl, streamId, token, objectId, options = { enableCaching: true, fullyTraverseArrays: false, excludeProps: [ ], fetch:null } } ) {
15
14
  this.INTERVAL_MS = 20
16
15
  this.TIMEOUT_MS = 180000 // three mins
17
16
 
18
17
  this.serverUrl = serverUrl || window.location.origin
19
18
  this.streamId = streamId
20
19
  this.objectId = objectId
21
- this.token = token || localStorage.getItem( 'AuthToken' )
20
+ console.log('Object loader constructor called!')
21
+ try {
22
+ this.token = token || localStorage.getItem( 'AuthToken' )
23
+ } catch (error) {
24
+ // Accessing localStorage may throw when executing on sandboxed document, ignore.
25
+ }
22
26
 
23
27
  this.headers = {
24
28
  'Accept': 'text/plain'
25
29
  }
26
30
 
27
- if( token ) {
31
+ if( this.token ) {
28
32
  this.headers['Authorization'] = `Bearer ${this.token}`
29
33
  }
30
34
 
31
- this.requestUrl = `${this.serverUrl}/objects/${this.streamId}/${this.objectId}`
35
+ this.requestUrlRootObj = `${this.serverUrl}/objects/${this.streamId}/${this.objectId}/single`
36
+ this.requestUrlChildren = `${this.serverUrl}/api/getobjects/${this.streamId}`
32
37
  this.promises = []
33
38
  this.intervals = {}
34
39
  this.buffer = []
@@ -36,6 +41,35 @@ export default class ObjectLoader {
36
41
  this.totalChildrenCount = 0
37
42
  this.traversedReferencesCount = 0
38
43
  this.options = options
44
+ this.options.numConnections = this.options.numConnections || 4
45
+
46
+ this.cacheDB = null
47
+
48
+ this.lastAsyncPause = Date.now()
49
+ this.existingAsyncPause = null
50
+
51
+ // we can't simply bind fetch to this.fetch, so instead we have to do some acrobatics: https://stackoverflow.com/questions/69337187/uncaught-in-promise-typeerror-failed-to-execute-fetch-on-workerglobalscope#comment124731316_69337187
52
+ this.preferredFetch = options.fetch
53
+ this.fetch = function(...args) {
54
+ let currentFetch = this.preferredFetch || fetch
55
+ return currentFetch(...args)
56
+ }
57
+
58
+ }
59
+
60
+ async asyncPause() {
61
+ // Don't freeze the UI
62
+ // while ( this.existingAsyncPause ) {
63
+ // await this.existingAsyncPause
64
+ // }
65
+ if ( Date.now() - this.lastAsyncPause >= 100 ) {
66
+ this.lastAsyncPause = Date.now()
67
+ this.existingAsyncPause = new Promise( resolve => setTimeout( resolve, 0 ) )
68
+ await this.existingAsyncPause
69
+ this.existingAsyncPause = null
70
+ if (Date.now() - this.lastAsyncPause > 500) console.log("Loader Event loop lag: ", Date.now() - this.lastAsyncPause)
71
+ }
72
+
39
73
  }
40
74
 
41
75
  dispose() {
@@ -45,25 +79,25 @@ export default class ObjectLoader {
45
79
 
46
80
  /**
47
81
  * Use this method to receive and construct the object. It will return the full, de-referenced and de-chunked original object.
48
- * @param {*} onProgress
49
- * @returns
82
+ * @param {*} onProgress
83
+ * @returns
50
84
  */
51
85
  async getAndConstructObject( onProgress ) {
52
-
86
+
53
87
  ;( await this.downloadObjectsInBuffer( onProgress ) ) // Fire and forget; PS: semicolon of doom
54
-
88
+
55
89
  let rootObject = await this.getObject( this.objectId )
56
90
  return this.traverseAndConstruct( rootObject, onProgress )
57
91
  }
58
92
 
59
93
  /**
60
94
  * Internal function used to download all the objects in a local buffer.
61
- * @param {*} onProgress
95
+ * @param {*} onProgress
62
96
  */
63
97
  async downloadObjectsInBuffer( onProgress ) {
64
98
  let first = true
65
99
  let downloadNum = 0
66
-
100
+
67
101
  for await ( let obj of this.getObjectIterator() ) {
68
102
  if( first ) {
69
103
  this.totalChildrenCount = obj.totalChildrenCount
@@ -78,9 +112,9 @@ export default class ObjectLoader {
78
112
 
79
113
  /**
80
114
  * Internal function used to recursively traverse an object and populate its references and dechunk any arrays.
81
- * @param {*} obj
82
- * @param {*} onProgress
83
- * @returns
115
+ * @param {*} obj
116
+ * @param {*} onProgress
117
+ * @returns
84
118
  */
85
119
  async traverseAndConstruct( obj, onProgress ) {
86
120
  if( !obj ) return
@@ -91,20 +125,20 @@ export default class ObjectLoader {
91
125
  let arr = []
92
126
  for ( let element of obj ) {
93
127
  if ( typeof element !== 'object' && ! this.options.fullyTraverseArrays ) return obj
94
-
128
+
95
129
  // Dereference element if needed
96
130
  let deRef = element.referencedId ? await this.getObject( element.referencedId ) : element
97
- if( element.referencedId && onProgress ) onProgress( { stage: 'construction', current: ++this.traversedReferencesCount > this.totalChildrenCount ? this.totalChildrenCount : this.traversedReferencesCount, total: this.totalChildrenCount } )
98
-
131
+ if( element.referencedId && onProgress ) onProgress( { stage: 'construction', current: ++this.traversedReferencesCount > this.totalChildrenCount ? this.totalChildrenCount : this.traversedReferencesCount, total: this.totalChildrenCount } )
132
+
99
133
  // Push the traversed object in the array
100
134
  arr.push( await this.traverseAndConstruct( deRef, onProgress ) )
101
135
  }
102
-
136
+
103
137
  // De-chunk
104
- if( arr[0]?.speckle_type?.toLowerCase().includes('datachunk') ) {
138
+ if( arr[0]?.speckle_type?.toLowerCase().includes('datachunk') ) {
105
139
  return arr.reduce( ( prev, curr ) => prev.concat( curr.data ), [] )
106
140
  }
107
-
141
+
108
142
  return arr
109
143
  }
110
144
 
@@ -113,11 +147,11 @@ export default class ObjectLoader {
113
147
  for( let ignoredProp of this.options.excludeProps ) {
114
148
  delete obj[ ignoredProp ]
115
149
  }
116
-
150
+
117
151
  // 2) Iterate through obj
118
- for( let prop in obj ) {
119
- if( typeof obj[prop] !== 'object' ) continue // leave alone primitive props
120
-
152
+ for( let prop in obj ) {
153
+ if( typeof obj[prop] !== 'object' || obj[prop] === null ) continue // leave alone primitive props
154
+
121
155
  if( obj[prop].referencedId ) {
122
156
  obj[prop] = await this.getObject( obj[prop].referencedId )
123
157
  if( onProgress ) onProgress( { stage: 'construction', current: ++this.traversedReferencesCount > this.totalChildrenCount ? this.totalChildrenCount : this.traversedReferencesCount, total: this.totalChildrenCount } )
@@ -131,8 +165,8 @@ export default class ObjectLoader {
131
165
 
132
166
  /**
133
167
  * Internal function. Returns a promise that is resolved when the object id is loaded into the internal buffer.
134
- * @param {*} id
135
- * @returns
168
+ * @param {*} id
169
+ * @returns
136
170
  */
137
171
  async getObject( id ){
138
172
  if ( this.buffer[id] ) return this.buffer[id]
@@ -172,11 +206,15 @@ export default class ObjectLoader {
172
206
  }
173
207
 
174
208
  async * getObjectIterator( ) {
209
+ let t0 = Date.now()
210
+ let count = 0
175
211
  for await ( let line of this.getRawObjectIterator() ) {
176
212
  let { id, obj } = this.processLine( line )
177
213
  this.buffer[ id ] = obj
214
+ count += 1
178
215
  yield obj
179
216
  }
217
+ console.log(`Loaded ${count} objects in: ${(Date.now() - t0) / 1000}`)
180
218
  }
181
219
 
182
220
  processLine( chunk ) {
@@ -185,31 +223,258 @@ export default class ObjectLoader {
185
223
  }
186
224
 
187
225
  async * getRawObjectIterator() {
188
- const decoder = new TextDecoder()
189
- const response = await fetch( this.requestUrl, { headers: this.headers } )
190
- const reader = response.body.getReader()
191
- let { value: chunk, done: readerDone } = await reader.read()
192
- chunk = chunk ? decoder.decode( chunk ) : ''
226
+ let tSTART = Date.now()
227
+
228
+ if ( this.options.enableCaching && window.indexedDB && this.cacheDB === null) {
229
+ await safariFix()
230
+ let idbOpenRequest = indexedDB.open('speckle-object-cache', 1)
231
+ idbOpenRequest.onupgradeneeded = () => idbOpenRequest.result.createObjectStore('objects');
232
+ this.cacheDB = await this.promisifyIdbRequest( idbOpenRequest )
233
+ }
234
+
235
+ const rootObjJson = await this.getRawRootObject()
236
+ // console.log("Root in: ", Date.now() - tSTART)
237
+
238
+ yield `${this.objectId}\t${rootObjJson}`
239
+
240
+ const rootObj = JSON.parse(rootObjJson)
241
+ if ( !rootObj.__closure ) return
242
+
243
+ let childrenIds = Object.keys(rootObj.__closure).sort( (a, b) => rootObj.__closure[a] - rootObj.__closure[b] )
244
+ if ( childrenIds.length === 0 ) return
245
+
246
+ let splitHttpRequests = []
247
+
248
+ if ( childrenIds.length > 50 ) {
249
+ // split into 5%, 15%, 40%, 40% (5% for the high priority children: the ones with lower minDepth)
250
+ let splitBeforeCacheCheck = [ [], [], [], [] ]
251
+ let crtChildIndex = 0
252
+
253
+ for ( ; crtChildIndex < 0.05 * childrenIds.length; crtChildIndex++ ) {
254
+ splitBeforeCacheCheck[0].push( childrenIds[ crtChildIndex ] )
255
+ }
256
+ for ( ; crtChildIndex < 0.2 * childrenIds.length; crtChildIndex++ ) {
257
+ splitBeforeCacheCheck[1].push( childrenIds[ crtChildIndex ] )
258
+ }
259
+ for ( ; crtChildIndex < 0.6 * childrenIds.length; crtChildIndex++ ) {
260
+ splitBeforeCacheCheck[2].push( childrenIds[ crtChildIndex ] )
261
+ }
262
+ for ( ; crtChildIndex < childrenIds.length; crtChildIndex++ ) {
263
+ splitBeforeCacheCheck[3].push( childrenIds[ crtChildIndex ] )
264
+ }
265
+
266
+
267
+ console.log("Cache check for: ", splitBeforeCacheCheck)
268
+
269
+ let newChildren = []
270
+ let nextCachePromise = this.cacheGetObjects( splitBeforeCacheCheck[ 0 ] )
271
+
272
+ for ( let i = 0; i < 4; i++ ) {
273
+ let cachedObjects = await nextCachePromise
274
+ if ( i < 3 ) nextCachePromise = this.cacheGetObjects( splitBeforeCacheCheck[ i + 1 ] )
275
+
276
+ let sortedCachedKeys = Object.keys(cachedObjects).sort( (a, b) => rootObj.__closure[a] - rootObj.__closure[b] )
277
+ for ( let id of sortedCachedKeys ) {
278
+ yield `${id}\t${cachedObjects[ id ]}`
279
+ }
280
+ let newChildrenForBatch = splitBeforeCacheCheck[i].filter( id => !( id in cachedObjects ) )
281
+ newChildren.push( ...newChildrenForBatch )
282
+ }
283
+
284
+ if ( newChildren.length === 0 ) return
285
+
286
+ if ( newChildren.length <= 50 ) {
287
+ // we have almost all of children in the cache. do only 1 requests for the remaining new children
288
+ splitHttpRequests.push( newChildren )
289
+ } else {
290
+ // we now set up the batches for 4 http requests, starting from `newChildren` (already sorted by priority)
291
+ splitHttpRequests = [ [], [], [], [] ]
292
+ crtChildIndex = 0
293
+
294
+ for ( ; crtChildIndex < 0.05 * newChildren.length; crtChildIndex++ ) {
295
+ splitHttpRequests[0].push( newChildren[ crtChildIndex ] )
296
+ }
297
+ for ( ; crtChildIndex < 0.2 * newChildren.length; crtChildIndex++ ) {
298
+ splitHttpRequests[1].push( newChildren[ crtChildIndex ] )
299
+ }
300
+ for ( ; crtChildIndex < 0.6 * newChildren.length; crtChildIndex++ ) {
301
+ splitHttpRequests[2].push( newChildren[ crtChildIndex ] )
302
+ }
303
+ for ( ; crtChildIndex < newChildren.length; crtChildIndex++ ) {
304
+ splitHttpRequests[3].push( newChildren[ crtChildIndex ] )
305
+ }
306
+ }
307
+
308
+ } else {
309
+ // small object with <= 50 children. check cache and make only 1 request
310
+ const cachedObjects = await this.cacheGetObjects( childrenIds )
311
+ let sortedCachedKeys = Object.keys(cachedObjects).sort( (a, b) => rootObj.__closure[a] - rootObj.__closure[b] )
312
+ for ( let id of sortedCachedKeys ) {
313
+ yield `${id}\t${cachedObjects[ id ]}`
314
+ }
315
+ childrenIds = childrenIds.filter(id => !( id in cachedObjects ) )
316
+ if ( childrenIds.length === 0 ) return
317
+
318
+ // only 1 http request with the remaining children ( <= 50 )
319
+ splitHttpRequests.push( childrenIds )
320
+ }
321
+
322
+ // Starting http requests for batches in `splitHttpRequests`
193
323
 
194
- let re = /\r\n|\n|\r/gm
195
- let startIndex = 0
324
+ const decoders = []
325
+ const readers = []
326
+ const readPromisses = []
327
+ const startIndexes = []
328
+ const readBuffers = []
329
+ const finishedRequests = []
330
+
331
+ for (let i = 0; i < splitHttpRequests.length; i++) {
332
+ decoders.push(new TextDecoder())
333
+ readers.push( null )
334
+ readPromisses.push( null )
335
+ startIndexes.push( 0 )
336
+ readBuffers.push( '' )
337
+ finishedRequests.push( false )
338
+
339
+ this.fetch(
340
+ this.requestUrlChildren,
341
+ {
342
+ method: 'POST',
343
+ headers: { ...this.headers, 'Content-Type': 'application/json' },
344
+ body: JSON.stringify( { objects: JSON.stringify( splitHttpRequests[i] ) } )
345
+ }
346
+ ).then( crtResponse => {
347
+ let crtReader = crtResponse.body.getReader()
348
+ readers[i] = crtReader
349
+ let crtReadPromise = crtReader.read().then(x => { x.reqId = i; return x })
350
+ readPromisses[i] = crtReadPromise
351
+ })
352
+ }
196
353
 
197
354
  while ( true ) {
198
- let result = re.exec( chunk )
199
- if ( !result ) {
200
- if ( readerDone ) break
201
- let remainder = chunk.substr( startIndex )
202
- ;( { value: chunk, done: readerDone } = await reader.read() ) // PS: semicolon of doom
203
- chunk = remainder + ( chunk ? decoder.decode( chunk ) : '' )
204
- startIndex = re.lastIndex = 0
355
+ let validReadPromises = readPromisses.filter(x => x != null)
356
+ if ( validReadPromises.length === 0 ) {
357
+ // Check if all requests finished
358
+ if ( finishedRequests.every(x => x) ) {
359
+ break
360
+ }
361
+ // Sleep 10 ms
362
+ await new Promise( ( resolve ) => {
363
+ setTimeout( resolve, 10 )
364
+ } )
365
+ continue
366
+ }
367
+
368
+ // Wait for data on any running request
369
+ let data = await Promise.any( validReadPromises )
370
+ let { value: crtDataChunk, done: readerDone, reqId } = data
371
+ finishedRequests[ reqId ] = readerDone
372
+
373
+ // Replace read promise on this request with a new `read` call
374
+ if ( !readerDone ) {
375
+ let crtReadPromise = readers[ reqId ].read().then(x => { x.reqId = reqId; return x })
376
+ readPromisses[ reqId ] = crtReadPromise
377
+ } else {
378
+ // This request finished. "Flush any non-newline-terminated text"
379
+ if ( readBuffers[ reqId ].length > 0 ) {
380
+ yield readBuffers[ reqId ]
381
+ readBuffers[ reqId ] = ''
382
+ }
383
+ // no other read calls for this request
384
+ readPromisses[ reqId ] = null
385
+ }
386
+
387
+ if ( !crtDataChunk )
205
388
  continue
389
+
390
+ crtDataChunk = decoders[ reqId ].decode( crtDataChunk )
391
+ let unprocessedText = readBuffers[ reqId ] + crtDataChunk
392
+ let unprocessedLines = unprocessedText.split(/\r\n|\n|\r/)
393
+ let remainderText = unprocessedLines.pop()
394
+ readBuffers[ reqId ] = remainderText
395
+
396
+ for ( let line of unprocessedLines ) {
397
+ yield line
206
398
  }
207
- yield chunk.substring( startIndex, result.index )
208
- startIndex = re.lastIndex
399
+ this.cacheStoreObjects(unprocessedLines)
209
400
  }
401
+ }
210
402
 
211
- if ( startIndex < chunk.length ) {
212
- yield chunk.substr( startIndex )
403
+ async getRawRootObject() {
404
+ const cachedRootObject = await this.cacheGetObjects( [ this.objectId ] )
405
+ if ( cachedRootObject[ this.objectId ] )
406
+ return cachedRootObject[ this.objectId ]
407
+ const response = await this.fetch( this.requestUrlRootObj, { headers: this.headers } )
408
+ const responseText = await response.text()
409
+ this.cacheStoreObjects( [ `${this.objectId}\t${responseText}` ] )
410
+ return responseText
411
+ }
412
+
413
+ promisifyIdbRequest(request) {
414
+ return new Promise((resolve, reject) => {
415
+ request.oncomplete = request.onsuccess = () => resolve(request.result);
416
+ request.onabort = request.onerror = () => reject(request.error);
417
+ })
418
+ }
419
+
420
+ async cacheGetObjects(ids) {
421
+ if ( !this.options.enableCaching || !window.indexedDB ) {
422
+ return {}
213
423
  }
424
+
425
+ let ret = {}
426
+
427
+ for (let i = 0; i < ids.length; i += 500) {
428
+ let idsChunk = ids.slice(i, i + 500)
429
+ let t0 = Date.now()
430
+
431
+ let store = this.cacheDB.transaction('objects', 'readonly').objectStore('objects')
432
+ let idbChildrenPromises = idsChunk.map( id => this.promisifyIdbRequest( store.get( id ) ).then( data => ( { id, data } ) ) )
433
+ let cachedData = await Promise.all(idbChildrenPromises)
434
+
435
+ // console.log("Cache check for : ", idsChunk.length, Date.now() - t0)
436
+
437
+ for ( let cachedObj of cachedData ) {
438
+ if ( !cachedObj.data ) // non-existent objects are retrieved with `undefined` data
439
+ continue
440
+ ret[ cachedObj.id ] = cachedObj.data
441
+ }
442
+ }
443
+
444
+ return ret
214
445
  }
446
+
447
+ cacheStoreObjects(objects) {
448
+ if ( !this.options.enableCaching || !window.indexedDB ) {
449
+ return {}
450
+ }
451
+
452
+ let store = this.cacheDB.transaction('objects', 'readwrite').objectStore('objects')
453
+ for ( let obj of objects ) {
454
+ let idAndData = obj.split( '\t' )
455
+ store.put(idAndData[1], idAndData[0])
456
+ }
457
+
458
+ return this.promisifyIdbRequest( store.transaction )
459
+ }
460
+ }
461
+
462
+
463
+ // Credits and more info: https://github.com/jakearchibald/safari-14-idb-fix
464
+ function safariFix() {
465
+ const isSafari =
466
+ !navigator.userAgentData &&
467
+ /Safari\//.test(navigator.userAgent) &&
468
+ !/Chrom(e|ium)\//.test(navigator.userAgent)
469
+
470
+ // No point putting other browsers or older versions of Safari through this mess.
471
+ if (!isSafari || !indexedDB.databases) return Promise.resolve()
472
+
473
+ let intervalId
474
+
475
+ return new Promise( ( resolve ) => {
476
+ const tryIdb = () => indexedDB.databases().finally(resolve)
477
+ intervalId = setInterval(tryIdb, 100)
478
+ tryIdb()
479
+ }).finally( () => clearInterval(intervalId) )
215
480
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@speckle/objectloader",
3
- "version": "2.1.1",
3
+ "version": "2.4.0",
4
4
  "description": "Simple API helper to stream in objects from the Speckle Server.",
5
5
  "main": "index.js",
6
6
  "homepage": "https://speckle.systems",
@@ -15,5 +15,8 @@
15
15
  },
16
16
  "keywords": ["speckle", "aec", "speckle api"],
17
17
  "author": "AEC Systems",
18
- "license": "Apache-2.0"
18
+ "license": "Apache-2.0",
19
+ "devDependencies": {
20
+ "undici": "^4.14.1"
21
+ }
19
22
  }
package/readme.md CHANGED
@@ -10,9 +10,11 @@ Comprehensive developer and user documentation can be found in our:
10
10
 
11
11
  ## Getting started
12
12
 
13
- This is a small utility class that helps you stream an object and all its sub-components from the Speckle Server API. It is inteded to be used in contexts where you want to "download" the whole object, or iteratively traverse its whole tree.
13
+ This is a small utility class that helps you stream an object and all its sub-components from the Speckle Server API. It is intended to be used in contexts where you want to "download" the whole object, or iteratively traverse its whole tree.
14
14
 
15
- Here's a sample way on how to use it, pfilfered from the [3d viewer package](../viewer):
15
+ ### In the browser
16
+
17
+ Here's a sample way on how to use it, pilfered from the [3d viewer package](../viewer):
16
18
 
17
19
  ```js
18
20
 
@@ -52,6 +54,19 @@ let loader = new ObjectLoader( {
52
54
 
53
55
  let obj = await loader.getAndConstructObject( ( e ) => console.log( 'Progress', e ) )
54
56
 
57
+ ### On the server
58
+
59
+ Since Node.js does not yet support the [`fetch API`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch), you'll need to provide your own `fetch` function in the options object. Note that `fetch` must return a [Web Stream](https://nodejs.org/api/webstreams.html), so [node-fetch](https://github.com/node-fetch/node-fetch) won't work, but [node/undici's](https://undici.nodejs.org/) implementation will.
60
+
61
+ ```js
62
+ import { fetch } from 'undici'
63
+
64
+ let loader = new ObjectLoader({
65
+ serverUrl: 'https://latest.speckle.dev',
66
+ streamId: '3ed8357f29',
67
+ objectId: '0408ab9caaa2ebefb2dd7f1f671e7555',
68
+ options: { enableCaching: false, excludeProps: [], fetch },
69
+ })
55
70
  ```
56
71
 
57
72
  ## Community