@speckle/objectloader 2.1.1 → 2.2.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.
Files changed (3) hide show
  1. package/index.js +303 -45
  2. package/package.json +1 -1
  3. package/readme.md +1 -1
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: [ ] } } ) {
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,28 @@ 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
+ }
52
+
53
+ async asyncPause() {
54
+ // Don't freeze the UI
55
+ // while ( this.existingAsyncPause ) {
56
+ // await this.existingAsyncPause
57
+ // }
58
+ if ( Date.now() - this.lastAsyncPause >= 100 ) {
59
+ this.lastAsyncPause = Date.now()
60
+ this.existingAsyncPause = new Promise( resolve => setTimeout( resolve, 0 ) )
61
+ await this.existingAsyncPause
62
+ this.existingAsyncPause = null
63
+ if (Date.now() - this.lastAsyncPause > 500) console.log("Loader Event loop lag: ", Date.now() - this.lastAsyncPause)
64
+ }
65
+
39
66
  }
40
67
 
41
68
  dispose() {
@@ -45,25 +72,25 @@ export default class ObjectLoader {
45
72
 
46
73
  /**
47
74
  * 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
75
+ * @param {*} onProgress
76
+ * @returns
50
77
  */
51
78
  async getAndConstructObject( onProgress ) {
52
-
79
+
53
80
  ;( await this.downloadObjectsInBuffer( onProgress ) ) // Fire and forget; PS: semicolon of doom
54
-
81
+
55
82
  let rootObject = await this.getObject( this.objectId )
56
83
  return this.traverseAndConstruct( rootObject, onProgress )
57
84
  }
58
85
 
59
86
  /**
60
87
  * Internal function used to download all the objects in a local buffer.
61
- * @param {*} onProgress
88
+ * @param {*} onProgress
62
89
  */
63
90
  async downloadObjectsInBuffer( onProgress ) {
64
91
  let first = true
65
92
  let downloadNum = 0
66
-
93
+
67
94
  for await ( let obj of this.getObjectIterator() ) {
68
95
  if( first ) {
69
96
  this.totalChildrenCount = obj.totalChildrenCount
@@ -78,9 +105,9 @@ export default class ObjectLoader {
78
105
 
79
106
  /**
80
107
  * Internal function used to recursively traverse an object and populate its references and dechunk any arrays.
81
- * @param {*} obj
82
- * @param {*} onProgress
83
- * @returns
108
+ * @param {*} obj
109
+ * @param {*} onProgress
110
+ * @returns
84
111
  */
85
112
  async traverseAndConstruct( obj, onProgress ) {
86
113
  if( !obj ) return
@@ -91,20 +118,20 @@ export default class ObjectLoader {
91
118
  let arr = []
92
119
  for ( let element of obj ) {
93
120
  if ( typeof element !== 'object' && ! this.options.fullyTraverseArrays ) return obj
94
-
121
+
95
122
  // Dereference element if needed
96
123
  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
-
124
+ if( element.referencedId && onProgress ) onProgress( { stage: 'construction', current: ++this.traversedReferencesCount > this.totalChildrenCount ? this.totalChildrenCount : this.traversedReferencesCount, total: this.totalChildrenCount } )
125
+
99
126
  // Push the traversed object in the array
100
127
  arr.push( await this.traverseAndConstruct( deRef, onProgress ) )
101
128
  }
102
-
129
+
103
130
  // De-chunk
104
- if( arr[0]?.speckle_type?.toLowerCase().includes('datachunk') ) {
131
+ if( arr[0]?.speckle_type?.toLowerCase().includes('datachunk') ) {
105
132
  return arr.reduce( ( prev, curr ) => prev.concat( curr.data ), [] )
106
133
  }
107
-
134
+
108
135
  return arr
109
136
  }
110
137
 
@@ -113,11 +140,11 @@ export default class ObjectLoader {
113
140
  for( let ignoredProp of this.options.excludeProps ) {
114
141
  delete obj[ ignoredProp ]
115
142
  }
116
-
143
+
117
144
  // 2) Iterate through obj
118
- for( let prop in obj ) {
145
+ for( let prop in obj ) {
119
146
  if( typeof obj[prop] !== 'object' ) continue // leave alone primitive props
120
-
147
+
121
148
  if( obj[prop].referencedId ) {
122
149
  obj[prop] = await this.getObject( obj[prop].referencedId )
123
150
  if( onProgress ) onProgress( { stage: 'construction', current: ++this.traversedReferencesCount > this.totalChildrenCount ? this.totalChildrenCount : this.traversedReferencesCount, total: this.totalChildrenCount } )
@@ -131,8 +158,8 @@ export default class ObjectLoader {
131
158
 
132
159
  /**
133
160
  * Internal function. Returns a promise that is resolved when the object id is loaded into the internal buffer.
134
- * @param {*} id
135
- * @returns
161
+ * @param {*} id
162
+ * @returns
136
163
  */
137
164
  async getObject( id ){
138
165
  if ( this.buffer[id] ) return this.buffer[id]
@@ -172,11 +199,15 @@ export default class ObjectLoader {
172
199
  }
173
200
 
174
201
  async * getObjectIterator( ) {
202
+ let t0 = Date.now()
203
+ let count = 0
175
204
  for await ( let line of this.getRawObjectIterator() ) {
176
205
  let { id, obj } = this.processLine( line )
177
206
  this.buffer[ id ] = obj
207
+ count += 1
178
208
  yield obj
179
209
  }
210
+ console.log(`Loaded ${count} objects in: ${(Date.now() - t0) / 1000}`)
180
211
  }
181
212
 
182
213
  processLine( chunk ) {
@@ -185,31 +216,258 @@ export default class ObjectLoader {
185
216
  }
186
217
 
187
218
  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 ) : ''
219
+ let tSTART = Date.now()
220
+
221
+ if ( this.options.enableCaching && window.indexedDB && this.cacheDB === null) {
222
+ await safariFix()
223
+ let idbOpenRequest = indexedDB.open('speckle-object-cache', 1)
224
+ idbOpenRequest.onupgradeneeded = () => idbOpenRequest.result.createObjectStore('objects');
225
+ this.cacheDB = await this.promisifyIdbRequest( idbOpenRequest )
226
+ }
227
+
228
+ const rootObjJson = await this.getRawRootObject()
229
+ // console.log("Root in: ", Date.now() - tSTART)
230
+
231
+ yield `${this.objectId}\t${rootObjJson}`
232
+
233
+ const rootObj = JSON.parse(rootObjJson)
234
+ if ( !rootObj.__closure ) return
235
+
236
+ let childrenIds = Object.keys(rootObj.__closure).sort( (a, b) => rootObj.__closure[a] - rootObj.__closure[b] )
237
+ if ( childrenIds.length === 0 ) return
238
+
239
+ let splitHttpRequests = []
240
+
241
+ if ( childrenIds.length > 50 ) {
242
+ // split into 5%, 15%, 40%, 40% (5% for the high priority children: the ones with lower minDepth)
243
+ let splitBeforeCacheCheck = [ [], [], [], [] ]
244
+ let crtChildIndex = 0
245
+
246
+ for ( ; crtChildIndex < 0.05 * childrenIds.length; crtChildIndex++ ) {
247
+ splitBeforeCacheCheck[0].push( childrenIds[ crtChildIndex ] )
248
+ }
249
+ for ( ; crtChildIndex < 0.2 * childrenIds.length; crtChildIndex++ ) {
250
+ splitBeforeCacheCheck[1].push( childrenIds[ crtChildIndex ] )
251
+ }
252
+ for ( ; crtChildIndex < 0.6 * childrenIds.length; crtChildIndex++ ) {
253
+ splitBeforeCacheCheck[2].push( childrenIds[ crtChildIndex ] )
254
+ }
255
+ for ( ; crtChildIndex < childrenIds.length; crtChildIndex++ ) {
256
+ splitBeforeCacheCheck[3].push( childrenIds[ crtChildIndex ] )
257
+ }
258
+
259
+
260
+ console.log("Cache check for: ", splitBeforeCacheCheck)
261
+
262
+ let newChildren = []
263
+ let nextCachePromise = this.cacheGetObjects( splitBeforeCacheCheck[ 0 ] )
264
+
265
+ for ( let i = 0; i < 4; i++ ) {
266
+ let cachedObjects = await nextCachePromise
267
+ if ( i < 3 ) nextCachePromise = this.cacheGetObjects( splitBeforeCacheCheck[ i + 1 ] )
268
+
269
+ let sortedCachedKeys = Object.keys(cachedObjects).sort( (a, b) => rootObj.__closure[a] - rootObj.__closure[b] )
270
+ for ( let id of sortedCachedKeys ) {
271
+ yield `${id}\t${cachedObjects[ id ]}`
272
+ }
273
+ let newChildrenForBatch = splitBeforeCacheCheck[i].filter( id => !( id in cachedObjects ) )
274
+ newChildren.push( ...newChildrenForBatch )
275
+ }
276
+
277
+ if ( newChildren.length === 0 ) return
193
278
 
194
- let re = /\r\n|\n|\r/gm
195
- let startIndex = 0
279
+ if ( newChildren.length <= 50 ) {
280
+ // we have almost all of children in the cache. do only 1 requests for the remaining new children
281
+ splitHttpRequests.push( newChildren )
282
+ } else {
283
+ // we now set up the batches for 4 http requests, starting from `newChildren` (already sorted by priority)
284
+ splitHttpRequests = [ [], [], [], [] ]
285
+ crtChildIndex = 0
286
+
287
+ for ( ; crtChildIndex < 0.05 * newChildren.length; crtChildIndex++ ) {
288
+ splitHttpRequests[0].push( newChildren[ crtChildIndex ] )
289
+ }
290
+ for ( ; crtChildIndex < 0.2 * newChildren.length; crtChildIndex++ ) {
291
+ splitHttpRequests[1].push( newChildren[ crtChildIndex ] )
292
+ }
293
+ for ( ; crtChildIndex < 0.6 * newChildren.length; crtChildIndex++ ) {
294
+ splitHttpRequests[2].push( newChildren[ crtChildIndex ] )
295
+ }
296
+ for ( ; crtChildIndex < newChildren.length; crtChildIndex++ ) {
297
+ splitHttpRequests[3].push( newChildren[ crtChildIndex ] )
298
+ }
299
+ }
300
+
301
+ } else {
302
+ // small object with <= 50 children. check cache and make only 1 request
303
+ const cachedObjects = await this.cacheGetObjects( childrenIds )
304
+ let sortedCachedKeys = Object.keys(cachedObjects).sort( (a, b) => rootObj.__closure[a] - rootObj.__closure[b] )
305
+ for ( let id of sortedCachedKeys ) {
306
+ yield `${id}\t${cachedObjects[ id ]}`
307
+ }
308
+ childrenIds = childrenIds.filter(id => !( id in cachedObjects ) )
309
+ if ( childrenIds.length === 0 ) return
310
+
311
+ // only 1 http request with the remaining children ( <= 50 )
312
+ splitHttpRequests.push( childrenIds )
313
+ }
314
+
315
+ // Starting http requests for batches in `splitHttpRequests`
316
+
317
+ const decoders = []
318
+ const readers = []
319
+ const readPromisses = []
320
+ const startIndexes = []
321
+ const readBuffers = []
322
+ const finishedRequests = []
196
323
 
324
+ for (let i = 0; i < splitHttpRequests.length; i++) {
325
+ decoders.push(new TextDecoder())
326
+ readers.push( null )
327
+ readPromisses.push( null )
328
+ startIndexes.push( 0 )
329
+ readBuffers.push( '' )
330
+ finishedRequests.push( false )
331
+
332
+ fetch(
333
+ this.requestUrlChildren,
334
+ {
335
+ method: 'POST',
336
+ headers: { ...this.headers, 'Content-Type': 'application/json' },
337
+ body: JSON.stringify( { objects: JSON.stringify( splitHttpRequests[i] ) } )
338
+ }
339
+ ).then( crtResponse => {
340
+ let crtReader = crtResponse.body.getReader()
341
+ readers[i] = crtReader
342
+ let crtReadPromise = crtReader.read().then(x => { x.reqId = i; return x })
343
+ readPromisses[i] = crtReadPromise
344
+ })
345
+ }
346
+
197
347
  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
348
+ let validReadPromises = readPromisses.filter(x => x != null)
349
+ if ( validReadPromises.length === 0 ) {
350
+ // Check if all requests finished
351
+ if ( finishedRequests.every(x => x) ) {
352
+ break
353
+ }
354
+ // Sleep 10 ms
355
+ await new Promise( ( resolve ) => {
356
+ setTimeout( resolve, 10 )
357
+ } )
205
358
  continue
206
359
  }
207
- yield chunk.substring( startIndex, result.index )
208
- startIndex = re.lastIndex
360
+
361
+ // Wait for data on any running request
362
+ let data = await Promise.any( validReadPromises )
363
+ let { value: crtDataChunk, done: readerDone, reqId } = data
364
+ finishedRequests[ reqId ] = readerDone
365
+
366
+ // Replace read promise on this request with a new `read` call
367
+ if ( !readerDone ) {
368
+ let crtReadPromise = readers[ reqId ].read().then(x => { x.reqId = reqId; return x })
369
+ readPromisses[ reqId ] = crtReadPromise
370
+ } else {
371
+ // This request finished. "Flush any non-newline-terminated text"
372
+ if ( readBuffers[ reqId ].length > 0 ) {
373
+ yield readBuffers[ reqId ]
374
+ readBuffers[ reqId ] = ''
375
+ }
376
+ // no other read calls for this request
377
+ readPromisses[ reqId ] = null
378
+ }
379
+
380
+ if ( !crtDataChunk )
381
+ continue
382
+
383
+ crtDataChunk = decoders[ reqId ].decode( crtDataChunk )
384
+ let unprocessedText = readBuffers[ reqId ] + crtDataChunk
385
+ let unprocessedLines = unprocessedText.split(/\r\n|\n|\r/)
386
+ let remainderText = unprocessedLines.pop()
387
+ readBuffers[ reqId ] = remainderText
388
+
389
+ for ( let line of unprocessedLines ) {
390
+ yield line
391
+ }
392
+ this.cacheStoreObjects(unprocessedLines)
209
393
  }
394
+ }
210
395
 
211
- if ( startIndex < chunk.length ) {
212
- yield chunk.substr( startIndex )
396
+ async getRawRootObject() {
397
+ const cachedRootObject = await this.cacheGetObjects( [ this.objectId ] )
398
+ if ( cachedRootObject[ this.objectId ] )
399
+ return cachedRootObject[ this.objectId ]
400
+ const response = await fetch( this.requestUrlRootObj, { headers: this.headers } )
401
+ const responseText = await response.text()
402
+ this.cacheStoreObjects( [ `${this.objectId}\t${responseText}` ] )
403
+ return responseText
404
+ }
405
+
406
+ promisifyIdbRequest(request) {
407
+ return new Promise((resolve, reject) => {
408
+ request.oncomplete = request.onsuccess = () => resolve(request.result);
409
+ request.onabort = request.onerror = () => reject(request.error);
410
+ })
411
+ }
412
+
413
+ async cacheGetObjects(ids) {
414
+ if ( !this.options.enableCaching || !window.indexedDB ) {
415
+ return {}
416
+ }
417
+
418
+ let ret = {}
419
+
420
+ for (let i = 0; i < ids.length; i += 500) {
421
+ let idsChunk = ids.slice(i, i + 500)
422
+ let t0 = Date.now()
423
+
424
+ let store = this.cacheDB.transaction('objects', 'readonly').objectStore('objects')
425
+ let idbChildrenPromises = idsChunk.map( id => this.promisifyIdbRequest( store.get( id ) ).then( data => ( { id, data } ) ) )
426
+ let cachedData = await Promise.all(idbChildrenPromises)
427
+
428
+ // console.log("Cache check for : ", idsChunk.length, Date.now() - t0)
429
+
430
+ for ( let cachedObj of cachedData ) {
431
+ if ( !cachedObj.data ) // non-existent objects are retrieved with `undefined` data
432
+ continue
433
+ ret[ cachedObj.id ] = cachedObj.data
434
+ }
435
+ }
436
+
437
+ return ret
438
+ }
439
+
440
+ cacheStoreObjects(objects) {
441
+ if ( !this.options.enableCaching || !window.indexedDB ) {
442
+ return {}
443
+ }
444
+
445
+ let store = this.cacheDB.transaction('objects', 'readwrite').objectStore('objects')
446
+ for ( let obj of objects ) {
447
+ let idAndData = obj.split( '\t' )
448
+ store.put(idAndData[1], idAndData[0])
213
449
  }
450
+
451
+ return this.promisifyIdbRequest( store.transaction )
214
452
  }
215
453
  }
454
+
455
+
456
+ // Credits and more info: https://github.com/jakearchibald/safari-14-idb-fix
457
+ function safariFix() {
458
+ const isSafari =
459
+ !navigator.userAgentData &&
460
+ /Safari\//.test(navigator.userAgent) &&
461
+ !/Chrom(e|ium)\//.test(navigator.userAgent)
462
+
463
+ // No point putting other browsers or older versions of Safari through this mess.
464
+ if (!isSafari || !indexedDB.databases) return Promise.resolve()
465
+
466
+ let intervalId
467
+
468
+ return new Promise( ( resolve ) => {
469
+ const tryIdb = () => indexedDB.databases().finally(resolve)
470
+ intervalId = setInterval(tryIdb, 100)
471
+ tryIdb()
472
+ }).finally( () => clearInterval(intervalId) )
473
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@speckle/objectloader",
3
- "version": "2.1.1",
3
+ "version": "2.2.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",
package/readme.md CHANGED
@@ -10,7 +10,7 @@ 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
15
  Here's a sample way on how to use it, pfilfered from the [3d viewer package](../viewer):
16
16