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

visgl / loaders.gl / 23748981362

30 Mar 2026 02:05PM UTC coverage: 35.2% (-0.05%) from 35.25%
23748981362

push

github

web-flow
feat(textures) Add composite texture loaders (#3328)

1208 of 2027 branches covered (59.6%)

Branch coverage included in aggregate %.

375 of 1137 new or added lines in 21 files covered. (32.98%)

13 existing lines in 4 files now uncovered.

38504 of 110792 relevant lines covered (34.75%)

0.79 hits per line

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

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

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

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

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

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

2✔
47
  const normalizedOptions = normalizeLoaderOptions(options || {});
2!
48
  normalizedOptions.core ||= {};
2✔
49

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

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

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

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

×
UNCOV
86
  // no loader available
×
87
  if (!loader && !normalizedOptions.core.nothrow) {
2!
88
    throw new Error(getNoValidLoaderMessage(data));
×
89
  }
×
90

×
91
  return loader;
×
92
}
×
93

1✔
NEW
94
function mayContainText(response: Response): boolean {
×
NEW
95
  const mimeType = getResourceMIMEType(response);
×
NEW
96
  return Boolean(
×
NEW
97
    mimeType &&
×
NEW
98
    (mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType.endsWith('+json'))
×
NEW
99
  );
×
NEW
100
}
×
101

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

2✔
121
  const normalizedOptions = normalizeLoaderOptions(options || {});
2!
122
  normalizedOptions.core ||= {};
2✔
123

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

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

×
143
  // TODO - remove support for legacy loaders
×
144
  normalizeLoaders(candidateLoaders);
×
145

×
146
  const loader = selectLoaderInternal(data, candidateLoaders, normalizedOptions, context);
×
147

×
148
  // no loader available
×
149
  if (!loader && !normalizedOptions.core.nothrow) {
2!
150
    throw new Error(getNoValidLoaderMessage(data));
×
151
  }
×
152

×
153
  return loader;
×
154
}
×
155

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

×
167
  const testUrl = stripQueryString(url) || context?.url;
×
168

×
169
  let loader: Loader | null = null;
×
170
  let reason: string = '';
×
171

×
172
  // if options.mimeType is supplied, it takes precedence
×
173
  if (options?.core?.mimeType) {
×
174
    loader = findLoaderByMIMEType(loaders, options?.core?.mimeType);
×
175
    reason = `match forced by supplied MIME type ${options?.core?.mimeType}`;
×
176
  }
×
177

×
178
  // Look up loader by url
×
179
  loader = loader || findLoaderByUrl(loaders, testUrl);
×
180
  reason = reason || (loader ? `matched url ${testUrl}` : '');
×
181

×
182
  // Look up loader by mime type
×
183
  loader = loader || findLoaderByMIMEType(loaders, type);
×
184
  reason = reason || (loader ? `matched MIME type ${type}` : '');
×
185

×
186
  // Look for loader via initial bytes (Note: not always accessible (e.g. Response, stream, async iterator)
×
187
  // @ts-ignore Blob | Response
×
188
  loader = loader || findLoaderByInitialBytes(loaders, data);
×
189
  // @ts-ignore Blob | Response
×
190
  reason = reason || (loader ? `matched initial data ${getFirstCharacters(data)}` : '');
×
191

×
192
  // Look up loader by fallback mime type
×
193
  if (options?.core?.fallbackMimeType) {
×
194
    loader = loader || findLoaderByMIMEType(loaders, options?.core?.fallbackMimeType);
×
195
    reason = reason || (loader ? `matched fallback MIME type ${type}` : '');
×
196
  }
×
197

×
198
  if (reason) {
×
199
    log.log(1, `selectLoader selected ${loader?.name}: ${reason}.`);
×
200
  }
×
201

×
202
  return loader;
×
203
}
×
204

1✔
205
/** Check HTTP Response */
1✔
206
function validHTTPResponse(data: unknown): boolean {
4✔
207
  // HANDLE HTTP status
4✔
208
  if (data instanceof Response) {
4!
209
    // 204 - NO CONTENT. This handles cases where e.g. a tile server responds with 204 for a missing tile
×
210
    if (data.status === 204) {
×
211
      return false;
×
212
    }
×
213
  }
×
214
  return true;
4✔
215
}
4✔
216

1✔
217
/** Generate a helpful message to help explain why loader selection failed. */
1✔
218
function getNoValidLoaderMessage(data: DataType): string {
×
219
  const url = getResourceUrl(data);
×
220
  const type = getResourceMIMEType(data);
×
221

×
222
  let message = 'No valid loader found (';
×
223
  message += url ? `${path.filename(url)}, ` : 'no url provided, ';
×
224
  message += `MIME type: ${type ? `"${type}"` : 'not provided'}, `;
×
225
  // First characters are only accessible when called on data (string or arrayBuffer).
×
226
  // @ts-ignore Blob | Response
×
227
  const firstCharacters: string = data ? getFirstCharacters(data) : '';
×
228
  message += firstCharacters ? ` first bytes: "${firstCharacters}"` : 'first bytes: not available';
×
229
  message += ')';
×
230
  return message;
×
231
}
×
232

1✔
233
function normalizeLoaders(loaders: Loader[]): void {
×
234
  for (const loader of loaders) {
×
235
    normalizeLoader(loader);
×
236
  }
×
237
}
×
238

1✔
239
// TODO - Would be nice to support http://example.com/file.glb?parameter=1
1✔
240
// E.g: x = new URL('http://example.com/file.glb?load=1'; x.pathname
1✔
241
function findLoaderByUrl(loaders: Loader[], url?: string): Loader | null {
×
242
  // Get extension
×
243
  const match = url && EXT_PATTERN.exec(url);
×
244
  const extension = match && match[1];
×
245
  return extension ? findLoaderByExtension(loaders, extension) : null;
×
246
}
×
247

1✔
248
function findLoaderByExtension(loaders: Loader[], extension: string): Loader | null {
×
249
  extension = extension.toLowerCase();
×
250

×
251
  for (const loader of loaders) {
×
252
    for (const loaderExtension of loader.extensions) {
×
253
      if (loaderExtension.toLowerCase() === extension) {
×
254
        return loader;
×
255
      }
×
256
    }
×
257
  }
×
258
  return null;
×
259
}
×
260

1✔
261
function findLoaderByMIMEType(loaders: Loader[], mimeType: string): Loader | null {
×
262
  for (const loader of loaders) {
×
263
    if (loader.mimeTypes?.some((mimeType1) => compareMIMETypes(mimeType, mimeType1))) {
×
264
      return loader;
×
265
    }
×
266

×
267
    // Support referring to loaders using the "unregistered tree"
×
268
    // https://en.wikipedia.org/wiki/Media_type#Unregistered_tree
×
269
    if (compareMIMETypes(mimeType, `application/x.${loader.id}`)) {
×
270
      return loader;
×
271
    }
×
272
  }
×
273
  return null;
×
274
}
×
275

1✔
276
function findLoaderByInitialBytes(loaders: Loader[], data: string | ArrayBuffer): Loader | null {
×
277
  if (!data) {
×
278
    return null;
×
279
  }
×
280

×
281
  for (const loader of loaders) {
×
282
    if (typeof data === 'string') {
×
283
      if (testDataAgainstText(data, loader)) {
×
284
        return loader;
×
285
      }
×
286
    } else if (ArrayBuffer.isView(data)) {
×
287
      // Typed Arrays can have offsets into underlying buffer
×
288
      if (testDataAgainstBinary(data.buffer, data.byteOffset, loader)) {
×
289
        return loader;
×
290
      }
×
291
    } else if (data instanceof ArrayBuffer) {
×
292
      const byteOffset = 0;
×
293
      if (testDataAgainstBinary(data, byteOffset, loader)) {
×
294
        return loader;
×
295
      }
×
296
    }
×
297
    // TODO Handle streaming case (requires creating a new AsyncIterator)
×
298
  }
×
299
  return null;
×
300
}
×
301

1✔
302
function testDataAgainstText(data: string, loader: Loader): boolean {
×
303
  if (loader.testText) {
×
304
    return loader.testText(data);
×
305
  }
×
306

×
307
  const tests = Array.isArray(loader.tests) ? loader.tests : [loader.tests];
×
308
  return tests.some((test) => data.startsWith(test as string));
×
309
}
×
310

1✔
311
function testDataAgainstBinary(data: ArrayBufferLike, byteOffset: number, loader: Loader): boolean {
×
312
  const tests = Array.isArray(loader.tests) ? loader.tests : [loader.tests];
×
313
  return tests.some((test) => testBinary(data, byteOffset, loader, test));
×
314
}
×
315

1✔
316
function testBinary(
×
317
  data: ArrayBufferLike,
×
318
  byteOffset: number,
×
319
  loader: Loader,
×
320
  test?: ArrayBuffer | string | ((b: ArrayBuffer) => boolean)
×
321
): boolean {
×
322
  if (isArrayBufferLike(test)) {
×
323
    return compareArrayBuffers(test, data, test.byteLength);
×
324
  }
×
325
  switch (typeof test) {
×
326
    case 'function':
×
327
      return test(ensureArrayBuffer(data));
×
328

×
329
    case 'string':
×
330
      // Magic bytes check: If `test` is a string, check if binary data starts with that strings
×
331
      const magic = getMagicString(data, byteOffset, test.length);
×
332
      return test === magic;
×
333

×
334
    default:
×
335
      return false;
×
336
  }
×
337
}
×
338

1✔
339
function getFirstCharacters(data: string | ArrayBuffer | TypedArray, length: number = 5) {
×
340
  if (typeof data === 'string') {
×
341
    return data.slice(0, length);
×
342
  } else if (ArrayBuffer.isView(data)) {
×
343
    // Typed Arrays can have offsets into underlying buffer
×
344
    return getMagicString(data.buffer, data.byteOffset, length);
×
345
  } else if (data instanceof ArrayBuffer) {
×
346
    const byteOffset = 0;
×
347
    return getMagicString(data, byteOffset, length);
×
348
  }
×
349
  return '';
×
350
}
×
351

1✔
352
function getMagicString(arrayBuffer: ArrayBufferLike, byteOffset: number, length: number): string {
×
353
  if (arrayBuffer.byteLength < byteOffset + length) {
×
354
    return '';
×
355
  }
×
356
  const dataView = new DataView(arrayBuffer);
×
357
  let magic = '';
×
358
  for (let i = 0; i < length; i++) {
×
359
    magic += String.fromCharCode(dataView.getUint8(byteOffset + i));
×
360
  }
×
361
  return magic;
×
362
}
×
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