• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / loaders.gl / 25070233425

28 Apr 2026 06:20PM UTC coverage: 59.434% (+0.01%) from 59.423%
25070233425

push

github

web-flow
website: Add tabs for navigating between format docs (#3407)

11310 of 20887 branches covered (54.15%)

Branch coverage included in aggregate %.

89 of 136 new or added lines in 13 files covered. (65.44%)

1742 existing lines in 132 files now uncovered.

23500 of 37682 relevant lines covered (62.36%)

16296.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.06
/modules/core/src/lib/api/select-loader.ts
1
// loaders.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import type {LoaderContext, LoaderOptions, Loader, DataType} from '@loaders.gl/loader-utils';
6
import {
7
  compareArrayBuffers,
8
  path,
9
  log,
10
  isBlob,
11
  ensureArrayBuffer,
12
  isArrayBufferLike,
13
  isSourceLoader
14
} from '@loaders.gl/loader-utils';
15
import {TypedArray} from '@loaders.gl/schema';
16
import {normalizeLoader} from '../loader-utils/normalize-loader';
17
import {normalizeLoaderOptions} from '../loader-utils/option-utils';
18
import {getResourceUrl, getResourceMIMEType} from '../utils/resource-utils';
19
import {compareMIMETypes} from '../utils/mime-type-utils';
20
import {getRegisteredLoaders} from './register-loaders';
21
import {stripQueryString} from '../utils/url-utils';
22

23
const EXT_PATTERN = /\.([^.]+)$/;
358✔
24

25
// TODO - Need a variant that peeks at streams for parseInBatches
26
// TODO - Detect multiple matching loaders? Use heuristics to grade matches?
27
// TODO - Allow apps to pass context to disambiguate between multiple matches (e.g. multiple .json formats)?
28

29
/**
30
 * Find a loader that matches file extension and/or initial file content
31
 * Search the loaders array argument for a loader that matches url extension or initial data
32
 * Returns: a normalized loader
33
 * @param data data to assist
34
 * @param loaders
35
 * @param options
36
 * @param context used internally, applications should not provide this parameter
37
 */
38
export async function selectLoader(
39
  data: DataType,
40
  loaders: Loader[] | Loader = [],
3,515✔
41
  options?: LoaderOptions,
42
  context?: LoaderContext
43
): Promise<Loader | null> {
44
  if (!validHTTPResponse(data)) {
3,515!
45
    return null;
×
46
  }
47

48
  const normalizedOptions = normalizeLoaderOptions(options || {});
3,515✔
49
  normalizedOptions.core ||= {};
3,515✔
50

51
  if (data instanceof Response && mayContainText(data)) {
3,515✔
52
    const text = await data.clone().text();
190✔
53
    const textLoader = selectLoaderSync(
190✔
54
      text,
55
      loaders,
56
      {...normalizedOptions, core: {...normalizedOptions.core, nothrow: true}},
57
      context
58
    );
59
    if (textLoader) {
190✔
60
      return textLoader;
187✔
61
    }
62
  }
63

64
  // First make a sync attempt, disabling exceptions
65
  let loader = selectLoaderSync(
3,328✔
66
    data,
67
    loaders,
68
    {...normalizedOptions, core: {...normalizedOptions.core, nothrow: true}},
69
    context
70
  );
71
  if (loader) {
3,328✔
72
    return loader;
3,307✔
73
  }
74

75
  // For Blobs and Files, try to asynchronously read a small initial slice and test again with that
76
  // to see if we can detect by initial content
77
  if (isBlob(data)) {
21✔
UNCOV
78
    data = await data.slice(0, 10).arrayBuffer();
1✔
UNCOV
79
    loader = selectLoaderSync(data, loaders, normalizedOptions, context);
1✔
80
  }
81

82
  if (!loader && data instanceof Response && mayContainText(data)) {
21✔
83
    const text = await data.clone().text();
2✔
84
    loader = selectLoaderSync(text, loaders, normalizedOptions, context);
2✔
85
  }
86

87
  // no loader available
88
  if (!loader && !normalizedOptions.core.nothrow) {
19✔
89
    throw new Error(getNoValidLoaderMessage(data));
10✔
90
  }
91

92
  return loader;
9✔
93
}
94

95
function mayContainText(response: Response): boolean {
96
  const mimeType = getResourceMIMEType(response);
1,527✔
97
  return Boolean(
1,527✔
98
    mimeType &&
2,244✔
99
      (mimeType.startsWith('text/') ||
100
        mimeType === 'application/json' ||
101
        mimeType.endsWith('+json'))
102
  );
103
}
104

105
/**
106
 * Find a loader that matches file extension and/or initial file content
107
 * Search the loaders array argument for a loader that matches url extension or initial data
108
 * Returns: a normalized loader
109
 * @param data data to assist
110
 * @param loaders
111
 * @param options
112
 * @param context used internally, applications should not provide this parameter
113
 */
114
export function selectLoaderSync(
115
  data: DataType,
116
  loaders: Loader[] | Loader = [],
163,976✔
117
  options?: LoaderOptions,
118
  context?: LoaderContext
119
): Loader | null {
120
  if (!validHTTPResponse(data)) {
163,976!
121
    return null;
×
122
  }
123

124
  const normalizedOptions = normalizeLoaderOptions(options || {});
163,976✔
125
  normalizedOptions.core ||= {};
163,976✔
126

127
  // eslint-disable-next-line complexity
128
  // if only a single loader was provided (not as array), force its use
129
  // TODO - Should this behavior be kept and documented?
130
  if (loaders && !Array.isArray(loaders)) {
163,976✔
131
    // TODO - remove support for legacy loaders
132
    return normalizeLoader(loaders);
163,614✔
133
  }
134

135
  // Build list of candidate loaders that will be searched in order for a match
136
  let candidateLoaders: Loader[] = [];
362✔
137
  // First search supplied loaders
138
  if (loaders) {
362✔
139
    candidateLoaders = candidateLoaders.concat(loaders);
358✔
140
  }
141
  // Then fall back to registered loaders
142
  if (!normalizedOptions.core.ignoreRegisteredLoaders) {
362!
143
    candidateLoaders.push(...getRegisteredLoaders());
362✔
144
  }
145

146
  // TODO - remove support for legacy loaders
147
  normalizeLoaders(candidateLoaders);
362✔
148

149
  const loader = selectLoaderInternal(data, candidateLoaders, normalizedOptions, context);
362✔
150

151
  // no loader available
152
  if (!loader && !normalizedOptions.core.nothrow) {
362✔
153
    throw new Error(getNoValidLoaderMessage(data));
6✔
154
  }
155

156
  return loader;
356✔
157
}
158

159
/** Implements loaders selection logic */
160
// eslint-disable-next-line complexity
161
function selectLoaderInternal(
162
  data: DataType,
163
  loaders: Loader[],
164
  options?: LoaderOptions,
165
  context?: LoaderContext
166
) {
167
  const url = getResourceUrl(data);
362✔
168
  const type = getResourceMIMEType(data);
362✔
169

170
  const testUrl = stripQueryString(url) || context?.url;
362✔
171

172
  let loader: Loader | null = null;
362✔
173
  let reason: string = '';
362✔
174

175
  // if options.mimeType is supplied, it takes precedence
176
  const sourceType =
177
    options?.core && 'type' in options.core ? (options.core.type as string | undefined) : undefined;
362✔
178
  if (sourceType && sourceType !== 'auto') {
362!
179
    loader = findSourceLoaderByType(loaders, sourceType);
×
180
    reason = loader ? `match forced by supplied source type ${sourceType}` : '';
×
181
  }
182

183
  // if options.mimeType is supplied, it takes precedence
184
  if (options?.core?.mimeType) {
362✔
185
    loader = findLoaderByMIMEType(loaders, options?.core?.mimeType);
159✔
186
    reason = `match forced by supplied MIME type ${options?.core?.mimeType}`;
159✔
187
  }
188

189
  loader = loader || findSourceLoaderByTestURL(loaders, testUrl);
362✔
190
  reason = reason || (loader ? `matched source url ${testUrl}` : '');
362✔
191

192
  // Look up loader by url
193
  loader = loader || findLoaderByUrl(loaders, testUrl);
362✔
194
  reason = reason || (loader ? `matched url ${testUrl}` : '');
362✔
195

196
  // Look up loader by mime type
197
  loader = loader || findLoaderByMIMEType(loaders, type);
362✔
198
  reason = reason || (loader ? `matched MIME type ${type}` : '');
362✔
199

200
  // Look for loader via initial bytes (Note: not always accessible (e.g. Response, stream, async iterator)
201
  // @ts-ignore Blob | Response
202
  loader = loader || findLoaderByInitialBytes(loaders, data);
362✔
203
  // @ts-ignore Blob | Response
204
  reason = reason || (loader ? `matched initial data ${getFirstCharacters(data)}` : '');
362✔
205

206
  if (!loader && isBlob(data)) {
362✔
UNCOV
207
    loader = findSourceLoaderByTestData(loaders, data);
2✔
UNCOV
208
    reason = reason || (loader ? 'matched source testData' : '');
2!
209
  }
210

211
  // Look up loader by fallback mime type
212
  if (options?.core?.fallbackMimeType) {
362✔
213
    loader = loader || findLoaderByMIMEType(loaders, options?.core?.fallbackMimeType);
4✔
214
    reason = reason || (loader ? `matched fallback MIME type ${type}` : '');
4!
215
  }
216

217
  if (reason) {
362✔
218
    log.log(1, `selectLoader selected ${loader?.name}: ${reason}.`);
328✔
219
  }
220

221
  return loader;
362✔
222
}
223

224
/** Check HTTP Response */
225
function validHTTPResponse(data: unknown): boolean {
226
  // HANDLE HTTP status
227
  if (data instanceof Response) {
167,491✔
228
    // 204 - NO CONTENT. This handles cases where e.g. a tile server responds with 204 for a missing tile
229
    if (data.status === 204) {
2,865!
230
      return false;
×
231
    }
232
  }
233
  return true;
167,491✔
234
}
235

236
/** Generate a helpful message to help explain why loader selection failed. */
237
function getNoValidLoaderMessage(data: DataType): string {
238
  const url = getResourceUrl(data);
16✔
239
  const type = getResourceMIMEType(data);
16✔
240

241
  let message = 'No valid loader found (';
16✔
242
  message += url ? `${path.filename(url)}, ` : 'no url provided, ';
16✔
243
  message += `MIME type: ${type ? `"${type}"` : 'not provided'}, `;
16!
244
  // First characters are only accessible when called on data (string or arrayBuffer).
245
  // @ts-ignore Blob | Response
246
  const firstCharacters: string = data ? getFirstCharacters(data) : '';
16✔
247
  message += firstCharacters ? ` first bytes: "${firstCharacters}"` : 'first bytes: not available';
16✔
248
  message += ')';
16✔
249
  return message;
16✔
250
}
251

252
function normalizeLoaders(loaders: Loader[]): void {
253
  for (const loader of loaders) {
362✔
254
    normalizeLoader(loader);
714✔
255
  }
256
}
257

258
// TODO - Would be nice to support http://example.com/file.glb?parameter=1
259
// E.g: x = new URL('http://example.com/file.glb?load=1'; x.pathname
260
function findLoaderByUrl(loaders: Loader[], url?: string): Loader | null {
261
  // Get extension
262
  const match = url && EXT_PATTERN.exec(url);
201✔
263
  const extension = match && match[1];
201✔
264
  return extension ? findLoaderByExtension(loaders, extension) : null;
201✔
265
}
266

267
function findLoaderByExtension(loaders: Loader[], extension: string): Loader | null {
268
  extension = extension.toLowerCase();
154✔
269

270
  for (const loader of loaders) {
154✔
271
    for (const loaderExtension of loader.extensions) {
226✔
272
      if (loaderExtension.toLowerCase() === extension) {
457✔
273
        return loader;
130✔
274
      }
275
    }
276
  }
277
  return null;
24✔
278
}
279

280
function findSourceLoaderByType(loaders: Loader[], type: string): Loader | null {
281
  for (const loader of loaders) {
×
282
    if (isSourceLoader(loader) && loader.type === type) {
×
283
      return loader;
×
284
    }
285
  }
286
  return null;
×
287
}
288

289
function findSourceLoaderByTestURL(loaders: Loader[], url?: string): Loader | null {
290
  if (!url) {
210✔
291
    return null;
31✔
292
  }
293

294
  for (const loader of loaders) {
179✔
295
    if (isSourceLoader(loader) && loader.testURL(url)) {
295✔
296
      return loader;
9✔
297
    }
298
  }
299
  return null;
170✔
300
}
301

302
function findSourceLoaderByTestData(loaders: Loader[], data: Blob): Loader | null {
UNCOV
303
  for (const loader of loaders) {
2✔
UNCOV
304
    if (isSourceLoader(loader)) {
6!
305
      if (loader.testData?.(data)) {
×
306
        return loader;
×
307
      }
308
    }
309
  }
UNCOV
310
  return null;
2✔
311
}
312

313
function findLoaderByMIMEType(loaders: Loader[], mimeType: string): Loader | null {
314
  for (const loader of loaders) {
234✔
315
    if (loader.mimeTypes?.some(mimeType1 => compareMIMETypes(mimeType, mimeType1))) {
664✔
316
      return loader;
158✔
317
    }
318

319
    // Support referring to loaders using the "unregistered tree"
320
    // https://en.wikipedia.org/wiki/Media_type#Unregistered_tree
321
    if (compareMIMETypes(mimeType, `application/x.${loader.id}`)) {
135✔
322
      return loader;
5✔
323
    }
324
  }
325
  return null;
71✔
326
}
327

328
function findLoaderByInitialBytes(loaders: Loader[], data: string | ArrayBuffer): Loader | null {
329
  if (!data) {
64✔
330
    return null;
4✔
331
  }
332

333
  for (const loader of loaders) {
60✔
334
    if (typeof data === 'string') {
97✔
335
      if (testDataAgainstText(data, loader)) {
62✔
336
        return loader;
8✔
337
      }
338
    } else if (ArrayBuffer.isView(data)) {
35✔
339
      // Typed Arrays can have offsets into underlying buffer
340
      if (testDataAgainstBinary(data.buffer, data.byteOffset, loader)) {
2!
341
        return loader;
2✔
342
      }
343
    } else if (data instanceof ArrayBuffer) {
33✔
344
      const byteOffset = 0;
27✔
345
      if (testDataAgainstBinary(data, byteOffset, loader)) {
27✔
346
        return loader;
16✔
347
      }
348
    }
349
    // TODO Handle streaming case (requires creating a new AsyncIterator)
350
  }
351
  return null;
34✔
352
}
353

354
function testDataAgainstText(data: string, loader: Loader): boolean {
355
  if (loader.testText) {
62✔
356
    return loader.testText(data);
21✔
357
  }
358

359
  const tests = Array.isArray(loader.tests) ? loader.tests : [loader.tests];
41✔
360
  return tests.some(test => data.startsWith(test as string));
65✔
361
}
362

363
function testDataAgainstBinary(data: ArrayBufferLike, byteOffset: number, loader: Loader): boolean {
364
  const tests = Array.isArray(loader.tests) ? loader.tests : [loader.tests];
29!
365
  return tests.some(test => testBinary(data, byteOffset, loader, test));
52✔
366
}
367

368
function testBinary(
369
  data: ArrayBufferLike,
370
  byteOffset: number,
371
  loader: Loader,
372
  test?: ArrayBuffer | string | ((b: ArrayBuffer) => boolean)
373
): boolean {
374
  if (isArrayBufferLike(test)) {
52✔
375
    return compareArrayBuffers(test, data, test.byteLength);
2✔
376
  }
377
  switch (typeof test) {
50!
378
    case 'function':
UNCOV
379
      return test(ensureArrayBuffer(data));
7✔
380

381
    case 'string':
382
      // Magic bytes check: If `test` is a string, check if binary data starts with that strings
383
      const magic = getMagicString(data, byteOffset, test.length);
43✔
384
      return test === magic;
43✔
385

386
    default:
387
      return false;
×
388
  }
389
}
390

391
function getFirstCharacters(data: string | ArrayBuffer | TypedArray, length: number = 5) {
31✔
392
  if (typeof data === 'string') {
31✔
393
    return data.slice(0, length);
16✔
394
  } else if (ArrayBuffer.isView(data)) {
15✔
395
    // Typed Arrays can have offsets into underlying buffer
396
    return getMagicString(data.buffer, data.byteOffset, length);
2✔
397
  } else if (data instanceof ArrayBuffer) {
13!
398
    const byteOffset = 0;
13✔
399
    return getMagicString(data, byteOffset, length);
13✔
400
  }
401
  return '';
×
402
}
403

404
function getMagicString(arrayBuffer: ArrayBufferLike, byteOffset: number, length: number): string {
405
  if (arrayBuffer.byteLength < byteOffset + length) {
58!
406
    return '';
×
407
  }
408
  const dataView = new DataView(arrayBuffer);
58✔
409
  let magic = '';
58✔
410
  for (let i = 0; i < length; i++) {
58✔
411
    magic += String.fromCharCode(dataView.getUint8(byteOffset + i));
252✔
412
  }
413
  return magic;
58✔
414
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc