@loaders.gl/core
Version:
The core API for working with loaders.gl loaders and writers
319 lines (274 loc) • 10.5 kB
text/typescript
// loaders.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
import type {LoaderContext, LoaderOptions, Loader} from '@loaders.gl/loader-utils';
import {compareArrayBuffers, path, log} from '@loaders.gl/loader-utils';
import {normalizeLoader} from '../loader-utils/normalize-loader';
import {getResourceUrl, getResourceMIMEType} from '../utils/resource-utils';
import {compareMIMETypes} from '../utils/mime-type-utils';
import {getRegisteredLoaders} from './register-loaders';
import {isBlob} from '../../javascript-utils/is-type';
import {stripQueryString} from '../utils/url-utils';
import {TypedArray} from '@loaders.gl/schema';
const EXT_PATTERN = /\.([^.]+)$/;
// TODO - Need a variant that peeks at streams for parseInBatches
// TODO - Detect multiple matching loaders? Use heuristics to grade matches?
// TODO - Allow apps to pass context to disambiguate between multiple matches (e.g. multiple .json formats)?
/**
* Find a loader that matches file extension and/or initial file content
* Search the loaders array argument for a loader that matches url extension or initial data
* Returns: a normalized loader
* @param data data to assist
* @param loaders
* @param options
* @param context used internally, applications should not provide this parameter
*/
export async function selectLoader(
data: Response | Blob | ArrayBuffer | string,
loaders: Loader[] | Loader = [],
options?: LoaderOptions,
context?: LoaderContext
): Promise<Loader | null> {
if (!validHTTPResponse(data)) {
return null;
}
// First make a sync attempt, disabling exceptions
let loader = selectLoaderSync(data, loaders, {...options, nothrow: true}, context);
if (loader) {
return loader;
}
// For Blobs and Files, try to asynchronously read a small initial slice and test again with that
// to see if we can detect by initial content
if (isBlob(data)) {
data = await (data as Blob).slice(0, 10).arrayBuffer();
loader = selectLoaderSync(data, loaders, options, context);
}
// no loader available
if (!loader && !options?.nothrow) {
throw new Error(getNoValidLoaderMessage(data));
}
return loader;
}
/**
* Find a loader that matches file extension and/or initial file content
* Search the loaders array argument for a loader that matches url extension or initial data
* Returns: a normalized loader
* @param data data to assist
* @param loaders
* @param options
* @param context used internally, applications should not provide this parameter
*/
export function selectLoaderSync(
data: Response | Blob | ArrayBuffer | string,
loaders: Loader[] | Loader = [],
options?: LoaderOptions,
context?: LoaderContext
): Loader | null {
if (!validHTTPResponse(data)) {
return null;
}
// eslint-disable-next-line complexity
// if only a single loader was provided (not as array), force its use
// TODO - Should this behavior be kept and documented?
if (loaders && !Array.isArray(loaders)) {
// TODO - remove support for legacy loaders
return normalizeLoader(loaders);
}
// Build list of candidate loaders that will be searched in order for a match
let candidateLoaders: Loader[] = [];
// First search supplied loaders
if (loaders) {
candidateLoaders = candidateLoaders.concat(loaders);
}
// Then fall back to registered loaders
if (!options?.ignoreRegisteredLoaders) {
candidateLoaders.push(...getRegisteredLoaders());
}
// TODO - remove support for legacy loaders
normalizeLoaders(candidateLoaders);
const loader = selectLoaderInternal(data, candidateLoaders, options, context);
// no loader available
if (!loader && !options?.nothrow) {
throw new Error(getNoValidLoaderMessage(data));
}
return loader;
}
/** Implements loaders selection logic */
// eslint-disable-next-line complexity
function selectLoaderInternal(
data: Response | Blob | ArrayBuffer | string,
loaders: Loader[],
options?: LoaderOptions,
context?: LoaderContext
) {
const url = getResourceUrl(data);
const type = getResourceMIMEType(data);
const testUrl = stripQueryString(url) || context?.url;
let loader: Loader | null = null;
let reason: string = '';
// if options.mimeType is supplied, it takes precedence
if (options?.mimeType) {
loader = findLoaderByMIMEType(loaders, options?.mimeType);
reason = `match forced by supplied MIME type ${options?.mimeType}`;
}
// Look up loader by url
loader = loader || findLoaderByUrl(loaders, testUrl);
reason = reason || (loader ? `matched url ${testUrl}` : '');
// Look up loader by mime type
loader = loader || findLoaderByMIMEType(loaders, type);
reason = reason || (loader ? `matched MIME type ${type}` : '');
// Look for loader via initial bytes (Note: not always accessible (e.g. Response, stream, async iterator)
// @ts-ignore Blob | Response
loader = loader || findLoaderByInitialBytes(loaders, data);
// @ts-ignore Blob | Response
reason = reason || (loader ? `matched initial data ${getFirstCharacters(data)}` : '');
// Look up loader by fallback mime type
if (options?.fallbackMimeType) {
loader = loader || findLoaderByMIMEType(loaders, options?.fallbackMimeType);
reason = reason || (loader ? `matched fallback MIME type ${type}` : '');
}
if (reason) {
log.log(1, `selectLoader selected ${loader?.name}: ${reason}.`);
}
return loader;
}
/** Check HTTP Response */
function validHTTPResponse(data: unknown): boolean {
// HANDLE HTTP status
if (data instanceof Response) {
// 204 - NO CONTENT. This handles cases where e.g. a tile server responds with 204 for a missing tile
if (data.status === 204) {
return false;
}
}
return true;
}
/** Generate a helpful message to help explain why loader selection failed. */
function getNoValidLoaderMessage(data: string | ArrayBuffer | Response | Blob): string {
const url = getResourceUrl(data);
const type = getResourceMIMEType(data);
let message = 'No valid loader found (';
message += url ? `${path.filename(url)}, ` : 'no url provided, ';
message += `MIME type: ${type ? `"${type}"` : 'not provided'}, `;
// First characters are only accessible when called on data (string or arrayBuffer).
// @ts-ignore Blob | Response
const firstCharacters: string = data ? getFirstCharacters(data) : '';
message += firstCharacters ? ` first bytes: "${firstCharacters}"` : 'first bytes: not available';
message += ')';
return message;
}
function normalizeLoaders(loaders: Loader[]): void {
for (const loader of loaders) {
normalizeLoader(loader);
}
}
// TODO - Would be nice to support http://example.com/file.glb?parameter=1
// E.g: x = new URL('http://example.com/file.glb?load=1'; x.pathname
function findLoaderByUrl(loaders: Loader[], url?: string): Loader | null {
// Get extension
const match = url && EXT_PATTERN.exec(url);
const extension = match && match[1];
return extension ? findLoaderByExtension(loaders, extension) : null;
}
function findLoaderByExtension(loaders: Loader[], extension: string): Loader | null {
extension = extension.toLowerCase();
for (const loader of loaders) {
for (const loaderExtension of loader.extensions) {
if (loaderExtension.toLowerCase() === extension) {
return loader;
}
}
}
return null;
}
function findLoaderByMIMEType(loaders: Loader[], mimeType: string): Loader | null {
for (const loader of loaders) {
if (loader.mimeTypes?.some((mimeType1) => compareMIMETypes(mimeType, mimeType1))) {
return loader;
}
// Support referring to loaders using the "unregistered tree"
// https://en.wikipedia.org/wiki/Media_type#Unregistered_tree
if (compareMIMETypes(mimeType, `application/x.${loader.id}`)) {
return loader;
}
}
return null;
}
function findLoaderByInitialBytes(loaders: Loader[], data: string | ArrayBuffer): Loader | null {
if (!data) {
return null;
}
for (const loader of loaders) {
if (typeof data === 'string') {
if (testDataAgainstText(data, loader)) {
return loader;
}
} else if (ArrayBuffer.isView(data)) {
// Typed Arrays can have offsets into underlying buffer
if (testDataAgainstBinary(data.buffer, data.byteOffset, loader)) {
return loader;
}
} else if (data instanceof ArrayBuffer) {
const byteOffset = 0;
if (testDataAgainstBinary(data, byteOffset, loader)) {
return loader;
}
}
// TODO Handle streaming case (requires creating a new AsyncIterator)
}
return null;
}
function testDataAgainstText(data: string, loader: Loader): boolean {
if (loader.testText) {
return loader.testText(data);
}
const tests = Array.isArray(loader.tests) ? loader.tests : [loader.tests];
return tests.some((test) => data.startsWith(test as string));
}
function testDataAgainstBinary(data: ArrayBuffer, byteOffset: number, loader: Loader): boolean {
const tests = Array.isArray(loader.tests) ? loader.tests : [loader.tests];
return tests.some((test) => testBinary(data, byteOffset, loader, test));
}
function testBinary(
data: ArrayBuffer,
byteOffset: number,
loader: Loader,
test?: ArrayBuffer | string | ((b: ArrayBuffer) => boolean)
): boolean {
if (test instanceof ArrayBuffer) {
return compareArrayBuffers(test, data, test.byteLength);
}
switch (typeof test) {
case 'function':
return test(data);
case 'string':
// Magic bytes check: If `test` is a string, check if binary data starts with that strings
const magic = getMagicString(data, byteOffset, test.length);
return test === magic;
default:
return false;
}
}
function getFirstCharacters(data: string | ArrayBuffer | TypedArray, length: number = 5) {
if (typeof data === 'string') {
return data.slice(0, length);
} else if (ArrayBuffer.isView(data)) {
// Typed Arrays can have offsets into underlying buffer
return getMagicString(data.buffer, data.byteOffset, length);
} else if (data instanceof ArrayBuffer) {
const byteOffset = 0;
return getMagicString(data, byteOffset, length);
}
return '';
}
function getMagicString(arrayBuffer: ArrayBuffer, byteOffset: number, length: number): string {
if (arrayBuffer.byteLength < byteOffset + length) {
return '';
}
const dataView = new DataView(arrayBuffer);
let magic = '';
for (let i = 0; i < length; i++) {
magic += String.fromCharCode(dataView.getUint8(byteOffset + i));
}
return magic;
}