mirror of
				https://github.com/mozilla/gecko-dev.git
				synced 2025-11-04 10:18:41 +02:00 
			
		
		
		
	Depends on D205318 Differential Revision: https://phabricator.services.mozilla.com/D205328
		
			
				
	
	
		
			690 lines
		
	
	
	
		
			22 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			690 lines
		
	
	
	
		
			22 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/* This Source Code Form is subject to the terms of the Mozilla Public
 | 
						|
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 | 
						|
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 | 
						|
const lazy = {};
 | 
						|
 | 
						|
ChromeUtils.defineESModuleGetters(lazy, {
 | 
						|
  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
 | 
						|
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
 | 
						|
});
 | 
						|
 | 
						|
ChromeUtils.defineLazyGetter(lazy, "console", () => {
 | 
						|
  return console.createInstance({
 | 
						|
    maxLogLevelPref: "browser.ml.logLevel",
 | 
						|
    prefix: "ML",
 | 
						|
  });
 | 
						|
});
 | 
						|
 | 
						|
const ALLOWED_HUBS = [
 | 
						|
  "chrome://*",
 | 
						|
  "resource://*",
 | 
						|
  "http://localhost",
 | 
						|
  "https://localhost",
 | 
						|
  "https://model-hub.mozilla.org",
 | 
						|
];
 | 
						|
 | 
						|
const ALLOWED_HEADERS_KEYS = ["Content-Type", "ETag", "status"];
 | 
						|
const DEFAULT_URL_TEMPLATE =
 | 
						|
  "${organization}/${modelName}/resolve/${modelVersion}/${file}";
 | 
						|
 | 
						|
/**
 | 
						|
 * Checks if a given URL string corresponds to an allowed hub.
 | 
						|
 *
 | 
						|
 * This function validates a URL against a list of allowed hubs, ensuring that it:
 | 
						|
 * - Is well-formed according to the URL standard.
 | 
						|
 * - Does not include a username or password.
 | 
						|
 * - Matches the allowed scheme and hostname.
 | 
						|
 *
 | 
						|
 * @param {string} urlString The URL string to validate.
 | 
						|
 * @returns {boolean} True if the URL is allowed; false otherwise.
 | 
						|
 */
 | 
						|
function allowedHub(urlString) {
 | 
						|
  try {
 | 
						|
    const url = new URL(urlString);
 | 
						|
    // Check for username or password in the URL
 | 
						|
    if (url.username !== "" || url.password !== "") {
 | 
						|
      return false; // Reject URLs with username or password
 | 
						|
    }
 | 
						|
    const scheme = url.protocol;
 | 
						|
    const host = url.hostname;
 | 
						|
    const fullPrefix = `${scheme}//${host}`;
 | 
						|
 | 
						|
    return ALLOWED_HUBS.some(allowedHub => {
 | 
						|
      const [allowedScheme, allowedHost] = allowedHub.split("://");
 | 
						|
      if (allowedHost === "*") {
 | 
						|
        return `${allowedScheme}:` === scheme;
 | 
						|
      }
 | 
						|
      const allowedPrefix = `${allowedScheme}://${allowedHost}`;
 | 
						|
      return fullPrefix === allowedPrefix;
 | 
						|
    });
 | 
						|
  } catch (error) {
 | 
						|
    lazy.console.error("Error parsing URL:", error);
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
const NO_ETAG = "NO_ETAG";
 | 
						|
 | 
						|
/**
 | 
						|
 * Class for managing a cache stored in IndexedDB.
 | 
						|
 */
 | 
						|
export class IndexedDBCache {
 | 
						|
  /**
 | 
						|
   * Reference to the IndexedDB database.
 | 
						|
   *
 | 
						|
   * @type {IDBDatabase|null}
 | 
						|
   */
 | 
						|
  db = null;
 | 
						|
 | 
						|
  /**
 | 
						|
   * Version of the database. Null if not set.
 | 
						|
   *
 | 
						|
   * @type {number|null}
 | 
						|
   */
 | 
						|
  dbVersion = null;
 | 
						|
 | 
						|
  /**
 | 
						|
   * Total size of the files stored in the cache.
 | 
						|
   *
 | 
						|
   * @type {number}
 | 
						|
   */
 | 
						|
  totalSize = 0;
 | 
						|
 | 
						|
  /**
 | 
						|
   * Name of the database used by IndexedDB.
 | 
						|
   *
 | 
						|
   * @type {string}
 | 
						|
   */
 | 
						|
  dbName;
 | 
						|
 | 
						|
  /**
 | 
						|
   * Name of the object store for storing files.
 | 
						|
   *
 | 
						|
   * @type {string}
 | 
						|
   */
 | 
						|
  fileStoreName;
 | 
						|
 | 
						|
  /**
 | 
						|
   * Name of the object store for storing headers.
 | 
						|
   *
 | 
						|
   * @type {string}
 | 
						|
   */
 | 
						|
  headersStoreName;
 | 
						|
  /**
 | 
						|
   * Maximum size of the cache in bytes. Defaults to 1GB.
 | 
						|
   *
 | 
						|
   * @type {number}
 | 
						|
   */
 | 
						|
  #maxSize = 1_073_741_824; // 1GB in bytes
 | 
						|
 | 
						|
  /**
 | 
						|
   * Private constructor to prevent direct instantiation.
 | 
						|
   * Use IndexedDBCache.init to create an instance.
 | 
						|
   *
 | 
						|
   * @param {string} dbName - The name of the database file.
 | 
						|
   * @param {number} version - The version number of the database.
 | 
						|
   */
 | 
						|
  constructor(dbName = "modelFiles", version = 1) {
 | 
						|
    this.dbName = dbName;
 | 
						|
    this.dbVersion = version;
 | 
						|
    this.fileStoreName = "files";
 | 
						|
    this.headersStoreName = "headers";
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Static method to create and initialize an instance of IndexedDBCache.
 | 
						|
   *
 | 
						|
   * @param {string} [dbName="modelFiles"] - The name of the database.
 | 
						|
   * @param {number} [version=1] - The version number of the database.
 | 
						|
   * @returns {Promise<IndexedDBCache>} An initialized instance of IndexedDBCache.
 | 
						|
   */
 | 
						|
  static async init(dbName = "modelFiles", version = 1) {
 | 
						|
    const cacheInstance = new IndexedDBCache(dbName, version);
 | 
						|
    cacheInstance.db = await cacheInstance.#openDB();
 | 
						|
    const storedSize = await cacheInstance.#getData(
 | 
						|
      cacheInstance.headersStoreName,
 | 
						|
      "totalSize"
 | 
						|
    );
 | 
						|
    cacheInstance.totalSize = storedSize ? storedSize.size : 0;
 | 
						|
    return cacheInstance;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Called to close the DB connection and dispose the instance
 | 
						|
   *
 | 
						|
   */
 | 
						|
  async dispose() {
 | 
						|
    if (this.db) {
 | 
						|
      this.db.close();
 | 
						|
      this.db = null;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Opens or creates the IndexedDB database.
 | 
						|
   *
 | 
						|
   * @returns {Promise<IDBDatabase>}
 | 
						|
   */
 | 
						|
  async #openDB() {
 | 
						|
    return new Promise((resolve, reject) => {
 | 
						|
      const request = indexedDB.open(this.dbName, this.dbVersion);
 | 
						|
      request.onerror = event => reject(event.target.error);
 | 
						|
      request.onsuccess = event => resolve(event.target.result);
 | 
						|
      request.onupgradeneeded = event => {
 | 
						|
        const db = event.target.result;
 | 
						|
        if (!db.objectStoreNames.contains(this.fileStoreName)) {
 | 
						|
          db.createObjectStore(this.fileStoreName, { keyPath: "id" });
 | 
						|
        }
 | 
						|
        if (!db.objectStoreNames.contains(this.headersStoreName)) {
 | 
						|
          db.createObjectStore(this.headersStoreName, { keyPath: "id" });
 | 
						|
        }
 | 
						|
      };
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Generic method to get the data from a specified object store.
 | 
						|
   *
 | 
						|
   * @param {string} storeName - The name of the object store.
 | 
						|
   * @param {string} key - The key within the object store to retrieve the data from.
 | 
						|
   * @returns {Promise<any>}
 | 
						|
   */
 | 
						|
  async #getData(storeName, key) {
 | 
						|
    return new Promise((resolve, reject) => {
 | 
						|
      const transaction = this.db.transaction([storeName], "readonly");
 | 
						|
      const store = transaction.objectStore(storeName);
 | 
						|
      const request = store.get(key);
 | 
						|
      request.onerror = event => reject(event.target.error);
 | 
						|
      request.onsuccess = event => resolve(event.target.result);
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  // Used in tests
 | 
						|
  async _testGetData(storeName, key) {
 | 
						|
    return this.#getData(storeName, key);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Generic method to update data in a specified object store.
 | 
						|
   *
 | 
						|
   * @param {string} storeName - The name of the object store.
 | 
						|
   * @param {object} data - The data to store.
 | 
						|
   * @returns {Promise<void>}
 | 
						|
   */
 | 
						|
  async #updateData(storeName, data) {
 | 
						|
    return new Promise((resolve, reject) => {
 | 
						|
      const transaction = this.db.transaction([storeName], "readwrite");
 | 
						|
      const store = transaction.objectStore(storeName);
 | 
						|
      const request = store.put(data);
 | 
						|
      request.onerror = event => reject(event.target.error);
 | 
						|
      request.onsuccess = () => resolve();
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Deletes a specific cache entry.
 | 
						|
   *
 | 
						|
   * @param {string} storeName - The name of the object store.
 | 
						|
   * @param {string} key - The key of the entry to delete.
 | 
						|
   * @returns {Promise<void>}
 | 
						|
   */
 | 
						|
  async #deleteData(storeName, key) {
 | 
						|
    return new Promise((resolve, reject) => {
 | 
						|
      const transaction = this.db.transaction([storeName], "readwrite");
 | 
						|
      const store = transaction.objectStore(storeName);
 | 
						|
      const request = store.delete(key);
 | 
						|
      request.onerror = event => reject(event.target.error);
 | 
						|
      request.onsuccess = () => resolve();
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Retrieves the headers for a specific cache entry.
 | 
						|
   *
 | 
						|
   * @param {string} organization - The organization name.
 | 
						|
   * @param {string} modelName - The model name.
 | 
						|
   * @param {string} modelVersion - The model version.
 | 
						|
   * @param {string} file - The file name.
 | 
						|
   * @returns {Promise<object|null>} The headers or null if not found.
 | 
						|
   */
 | 
						|
  async getHeaders(organization, modelName, modelVersion, file) {
 | 
						|
    const headersKey = `${organization}/${modelName}/${modelVersion}`;
 | 
						|
    const cacheKey = `${organization}/${modelName}/${modelVersion}/${file}`;
 | 
						|
    const headers = await this.#getData(this.headersStoreName, headersKey);
 | 
						|
    if (headers && headers.files[cacheKey]) {
 | 
						|
      return headers.files[cacheKey];
 | 
						|
    }
 | 
						|
    return null; // Return null if no headers is found
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Retrieves the file for a specific cache entry.
 | 
						|
   *
 | 
						|
   * @param {string} organization - The organization name.
 | 
						|
   * @param {string} modelName - The model name.
 | 
						|
   * @param {string} modelVersion - The model version.
 | 
						|
   * @param {string} file - The file name.
 | 
						|
   * @returns {Promise<[ArrayBuffer, object]|null>} The file ArrayBuffer and its headers or null if not found.
 | 
						|
   */
 | 
						|
  async getFile(organization, modelName, modelVersion, file) {
 | 
						|
    const cacheKey = `${organization}/${modelName}/${modelVersion}/${file}`;
 | 
						|
    const stored = await this.#getData(this.fileStoreName, cacheKey);
 | 
						|
    if (stored) {
 | 
						|
      const headers = await this.getHeaders(
 | 
						|
        organization,
 | 
						|
        modelName,
 | 
						|
        modelVersion,
 | 
						|
        file
 | 
						|
      );
 | 
						|
      return [stored.data, headers];
 | 
						|
    }
 | 
						|
    return null; // Return null if no file is found
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Adds or updates a cache entry.
 | 
						|
   *
 | 
						|
   * @param {string} organization - The organization name.
 | 
						|
   * @param {string} modelName - The model name.
 | 
						|
   * @param {string} modelVersion - The model version.
 | 
						|
   * @param {string} file - The file name.
 | 
						|
   * @param {ArrayBuffer} arrayBuffer - The data to cache.
 | 
						|
   * @param {object} [headers] - The headers for the file.
 | 
						|
   * @returns {Promise<void>}
 | 
						|
   */
 | 
						|
  async put(
 | 
						|
    organization,
 | 
						|
    modelName,
 | 
						|
    modelVersion,
 | 
						|
    file,
 | 
						|
    arrayBuffer,
 | 
						|
    headers = {}
 | 
						|
  ) {
 | 
						|
    const cacheKey = `${organization}/${modelName}/${modelVersion}/${file}`;
 | 
						|
    const newSize = this.totalSize + arrayBuffer.byteLength;
 | 
						|
    if (newSize > this.#maxSize) {
 | 
						|
      throw new Error("Exceeding total cache size limit of 1GB");
 | 
						|
    }
 | 
						|
 | 
						|
    const headersKey = `${organization}/${modelName}/${modelVersion}`;
 | 
						|
    const data = { id: cacheKey, data: arrayBuffer };
 | 
						|
 | 
						|
    // Store the file data
 | 
						|
    await this.#updateData(this.fileStoreName, data);
 | 
						|
 | 
						|
    // Update headers store - whith defaults for ETag and Content-Type
 | 
						|
    headers = headers || {};
 | 
						|
    headers["Content-Type"] =
 | 
						|
      headers["Content-Type"] ?? "application/octet-stream";
 | 
						|
    headers.ETag = headers.ETag ?? NO_ETAG;
 | 
						|
 | 
						|
    // filter out any keys that are not allowed
 | 
						|
    headers = Object.keys(headers)
 | 
						|
      .filter(key => ALLOWED_HEADERS_KEYS.includes(key))
 | 
						|
      .reduce((obj, key) => {
 | 
						|
        obj[key] = headers[key];
 | 
						|
        return obj;
 | 
						|
      }, {});
 | 
						|
 | 
						|
    const headersStore = (await this.#getData(
 | 
						|
      this.headersStoreName,
 | 
						|
      headersKey
 | 
						|
    )) || {
 | 
						|
      id: headersKey,
 | 
						|
      files: {},
 | 
						|
    };
 | 
						|
    headersStore.files[cacheKey] = headers;
 | 
						|
    await this.#updateData(this.headersStoreName, headersStore);
 | 
						|
 | 
						|
    // Update size
 | 
						|
    await this.#updateTotalSize(arrayBuffer.byteLength);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Updates the total size of the cache.
 | 
						|
   *
 | 
						|
   * @param {number} sizeToAdd - The size to add to the total.
 | 
						|
   * @returns {Promise<void>}
 | 
						|
   */
 | 
						|
  async #updateTotalSize(sizeToAdd) {
 | 
						|
    this.totalSize += sizeToAdd;
 | 
						|
    await this.#updateData(this.headersStoreName, {
 | 
						|
      id: "totalSize",
 | 
						|
      size: this.totalSize,
 | 
						|
    });
 | 
						|
  }
 | 
						|
  /**
 | 
						|
   * Deletes all data related to a specific model.
 | 
						|
   *
 | 
						|
   * @param {string} organization - The organization name.
 | 
						|
   * @param {string} modelName - The model name.
 | 
						|
   * @param {string} modelVersion - The model version.
 | 
						|
   * @returns {Promise<void>}
 | 
						|
   */
 | 
						|
  async deleteModel(organization, modelName, modelVersion) {
 | 
						|
    const headersKey = `${organization}/${modelName}/${modelVersion}`;
 | 
						|
    const headers = await this.#getData(this.headersStoreName, headersKey);
 | 
						|
    if (headers) {
 | 
						|
      for (const fileKey in headers.files) {
 | 
						|
        await this.#deleteData(this.fileStoreName, fileKey);
 | 
						|
      }
 | 
						|
      await this.#deleteData(this.headersStoreName, headersKey); // Remove headers entry after files are deleted
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Lists all models stored in the cache.
 | 
						|
   *
 | 
						|
   * @returns {Promise<Array<string>>} An array of model identifiers.
 | 
						|
   */
 | 
						|
  async listModels() {
 | 
						|
    const models = [];
 | 
						|
    return new Promise((resolve, reject) => {
 | 
						|
      const transaction = this.db.transaction(
 | 
						|
        [this.headersStoreName],
 | 
						|
        "readonly"
 | 
						|
      );
 | 
						|
      const store = transaction.objectStore(this.headersStoreName);
 | 
						|
      const request = store.openCursor();
 | 
						|
      request.onerror = event => reject(event.target.error);
 | 
						|
      request.onsuccess = event => {
 | 
						|
        const cursor = event.target.result;
 | 
						|
        if (cursor) {
 | 
						|
          models.push(cursor.value.id); // Assuming id is the organization/modelName
 | 
						|
          cursor.continue();
 | 
						|
        } else {
 | 
						|
          resolve(models);
 | 
						|
        }
 | 
						|
      };
 | 
						|
    });
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
export class ModelHub {
 | 
						|
  constructor({ rootUrl, urlTemplate = DEFAULT_URL_TEMPLATE }) {
 | 
						|
    if (!allowedHub(rootUrl)) {
 | 
						|
      throw new Error(`Invalid model hub root url: ${rootUrl}`);
 | 
						|
    }
 | 
						|
    this.rootUrl = rootUrl;
 | 
						|
    this.cache = null;
 | 
						|
 | 
						|
    // Ensures the URL template is well-formed and does not contain any invalid characters.
 | 
						|
    const pattern = /^(?:\$\{\w+\}|\w+)(?:\/(?:\$\{\w+\}|\w+))*$/;
 | 
						|
    //               ^                                         $   Start and end of string
 | 
						|
    //                (?:\$\{\w+\}|\w+)                            Match a ${placeholder} or alphanumeric characters
 | 
						|
    //                                 (?:\/(?:\$\{\w+\}|\w+))*    Zero or more groups of a forward slash followed by a ${placeholder} or alphanumeric characters
 | 
						|
    if (!pattern.test(urlTemplate)) {
 | 
						|
      throw new Error(`Invalid URL template: ${urlTemplate}`);
 | 
						|
    }
 | 
						|
    this.urlTemplate = urlTemplate;
 | 
						|
  }
 | 
						|
 | 
						|
  async #initCache() {
 | 
						|
    if (this.cache) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
    this.cache = await IndexedDBCache.init();
 | 
						|
  }
 | 
						|
 | 
						|
  /** Creates the file URL from the organization, model, and version.
 | 
						|
   *
 | 
						|
   * @param {string} organization
 | 
						|
   * @param {string} modelName
 | 
						|
   * @param {string} modelVersion
 | 
						|
   * @param {string} file
 | 
						|
   * @returns {string} The full URL
 | 
						|
   */
 | 
						|
  #fileUrl(organization, modelName, modelVersion, file) {
 | 
						|
    const baseUrl = new URL(this.rootUrl);
 | 
						|
    if (!baseUrl.pathname.endsWith("/")) {
 | 
						|
      baseUrl.pathname += "/";
 | 
						|
    }
 | 
						|
 | 
						|
    // Replace placeholders in the URL template with the provided data.
 | 
						|
    // If some keys are missing in the data object, the placeholder is left as is.
 | 
						|
    // If the placeholder is not found in the data object, it is left as is.
 | 
						|
    const data = {
 | 
						|
      organization,
 | 
						|
      modelName,
 | 
						|
      modelVersion,
 | 
						|
      file,
 | 
						|
    };
 | 
						|
    const path = this.urlTemplate.replace(
 | 
						|
      /\$\{(\w+)\}/g,
 | 
						|
      (match, key) => data[key] || match
 | 
						|
    );
 | 
						|
    const fullPath = `${baseUrl.pathname}${
 | 
						|
      path.startsWith("/") ? path.slice(1) : path
 | 
						|
    }`;
 | 
						|
 | 
						|
    const urlObject = new URL(fullPath, baseUrl.origin);
 | 
						|
    urlObject.searchParams.append("download", "true");
 | 
						|
    return urlObject.toString();
 | 
						|
  }
 | 
						|
 | 
						|
  /** Checks the organization, model, and version inputs.
 | 
						|
   *
 | 
						|
   * @param { string } organization
 | 
						|
   * @param { string } modelName
 | 
						|
   * @param { string } modelVersion
 | 
						|
   * @param { string } file
 | 
						|
   * @returns { Error } The error instance(can be null)
 | 
						|
   */
 | 
						|
  #checkInput(organization, modelName, modelVersion, file) {
 | 
						|
    // Ensures string consists only of letters, digits, and hyphens without starting/ending
 | 
						|
    // with a hyphen or containing consecutive hyphens.
 | 
						|
    //
 | 
						|
    //                ^                                $   Start and end of string
 | 
						|
    //                 (?!-)                     (?<!-)    Negative lookahead/behind for not starting or ending with hyphen
 | 
						|
    //                      (?!.*--)                       Negative lookahead for not containing consecutive hyphens
 | 
						|
    //                              [A-Za-z0-9-]+          Alphanum characters or hyphens, one or more
 | 
						|
    const orgRegex = /^(?!-)(?!.*--)[A-Za-z0-9-]+(?<!-)$/;
 | 
						|
 | 
						|
    // Matches strings containing letters, digits, hyphens, underscores, or periods.
 | 
						|
    //                  ^               $   Start and end of string
 | 
						|
    //                   [A-Za-z0-9-_.]+    Alphanum characters, hyphens, underscores, or periods, one or more times
 | 
						|
    const modelRegex = /^[A-Za-z0-9-_.]+$/;
 | 
						|
 | 
						|
    // Matches strings consisting of alphanumeric characters, hyphens, or periods.
 | 
						|
    //
 | 
						|
    //                    ^               $   Start and end of string
 | 
						|
    //                     [A-Za-z0-9-.]+     Alphanum characters, hyphens, or periods, one or more times
 | 
						|
    const versionRegex = /^[A-Za-z0-9-.]+$/;
 | 
						|
 | 
						|
    // Matches filenames with subdirectories, starting with alphanumeric or underscore,
 | 
						|
    // and optionally ending with a dot followed by a 2-4 letter extension.
 | 
						|
    //
 | 
						|
    //                 ^                                    $   Start and end of string
 | 
						|
    //                  (?:\/)?                                  Optional leading slash (for absolute paths or root directory)
 | 
						|
    //                        (?!\/)                             Negative lookahead for not starting with a slash
 | 
						|
    //                              [A-Za-z0-9-_]+               First directory or filename
 | 
						|
    //                                           (?:            Begin non-capturing group for additional directories or file
 | 
						|
    //                                              \/              Directory separator
 | 
						|
    //                                                [A-Za-z0-9-_]+ Directory or file name
 | 
						|
    //                                                             )* Zero or more times
 | 
						|
    //                                                               (?:[.][A-Za-z]{2,4})?   Optional non-capturing group for file extension
 | 
						|
    const fileRegex =
 | 
						|
      /^(?:\/)?(?!\/)[A-Za-z0-9-_]+(?:\/[A-Za-z0-9-_]+)*(?:[.][A-Za-z]{2,4})?$/;
 | 
						|
 | 
						|
    if (!orgRegex.test(organization) || !isNaN(parseInt(organization))) {
 | 
						|
      return new Error(`Invalid organization name ${organization}`);
 | 
						|
    }
 | 
						|
 | 
						|
    if (!modelRegex.test(modelName)) {
 | 
						|
      return new Error("Invalid model name.");
 | 
						|
    }
 | 
						|
 | 
						|
    if (
 | 
						|
      !versionRegex.test(modelVersion) ||
 | 
						|
      modelVersion.includes(" ") ||
 | 
						|
      /[\^$]/.test(modelVersion)
 | 
						|
    ) {
 | 
						|
      return new Error("Invalid version identifier.");
 | 
						|
    }
 | 
						|
 | 
						|
    if (!fileRegex.test(file)) {
 | 
						|
      return new Error("Invalid file name");
 | 
						|
    }
 | 
						|
 | 
						|
    return null;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Returns the ETag value given an URL
 | 
						|
   *
 | 
						|
   * @param {string} url
 | 
						|
   * @param {number} timeout in ms. Default is 1000
 | 
						|
   * @returns {Promise<string>} ETag (can be null)
 | 
						|
   */
 | 
						|
  async #getETag(url, timeout = 1000) {
 | 
						|
    const controller = new AbortController();
 | 
						|
    const id = lazy.setTimeout(() => controller.abort(), timeout);
 | 
						|
 | 
						|
    try {
 | 
						|
      const headResponse = await fetch(url, {
 | 
						|
        method: "HEAD",
 | 
						|
        signal: controller.signal,
 | 
						|
      });
 | 
						|
      const currentEtag = headResponse.headers.get("ETag");
 | 
						|
      return currentEtag;
 | 
						|
    } catch (error) {
 | 
						|
      lazy.console.warn("An error occurred when calling HEAD:", error);
 | 
						|
      return null;
 | 
						|
    } finally {
 | 
						|
      lazy.clearTimeout(id);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Given an organization, model, and version, fetch a model file in the hub as a Response.
 | 
						|
   *
 | 
						|
   * @param {object} config
 | 
						|
   * @param {string} config.organization
 | 
						|
   * @param {string} config.modelName
 | 
						|
   * @param {string} config.modelVersion
 | 
						|
   * @param {string} config.file
 | 
						|
   * @returns {Promise<Response>} The file content
 | 
						|
   */
 | 
						|
  async getModelFileAsResponse({
 | 
						|
    organization,
 | 
						|
    modelName,
 | 
						|
    modelVersion,
 | 
						|
    file,
 | 
						|
  }) {
 | 
						|
    const [blob, headers] = await this.getModelFileAsBlob({
 | 
						|
      organization,
 | 
						|
      modelName,
 | 
						|
      modelVersion,
 | 
						|
      file,
 | 
						|
    });
 | 
						|
    return new Response(blob, { headers });
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Given an organization, model, and version, fetch a model file in the hub as an ArrayBuffer.
 | 
						|
   *
 | 
						|
   * @param {object} config
 | 
						|
   * @param {string} config.organization
 | 
						|
   * @param {string} config.modelName
 | 
						|
   * @param {string} config.modelVersion
 | 
						|
   * @param {string} config.file
 | 
						|
   * @returns {Promise<[ArrayBuffer, headers]>} The file content
 | 
						|
   */
 | 
						|
  async getModelFileAsArrayBuffer({
 | 
						|
    organization,
 | 
						|
    modelName,
 | 
						|
    modelVersion,
 | 
						|
    file,
 | 
						|
  }) {
 | 
						|
    const [blob, headers] = await this.getModelFileAsBlob({
 | 
						|
      organization,
 | 
						|
      modelName,
 | 
						|
      modelVersion,
 | 
						|
      file,
 | 
						|
    });
 | 
						|
    return [await blob.arrayBuffer(), headers];
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Given an organization, model, and version, fetch a model file in the hub as blob.
 | 
						|
   *
 | 
						|
   * @param {object} config
 | 
						|
   * @param {string} config.organization
 | 
						|
   * @param {string} config.modelName
 | 
						|
   * @param {string} config.modelVersion
 | 
						|
   * @param {string} config.file
 | 
						|
   * @returns {Promise<[Blob, object]>} The file content
 | 
						|
   */
 | 
						|
  async getModelFileAsBlob({ organization, modelName, modelVersion, file }) {
 | 
						|
    // Make sure inputs are clean. We don't sanitize them but throw an exception
 | 
						|
    let checkError = this.#checkInput(
 | 
						|
      organization,
 | 
						|
      modelName,
 | 
						|
      modelVersion,
 | 
						|
      file
 | 
						|
    );
 | 
						|
    if (checkError) {
 | 
						|
      throw checkError;
 | 
						|
    }
 | 
						|
 | 
						|
    const url = this.#fileUrl(organization, modelName, modelVersion, file);
 | 
						|
    lazy.console.debug(`Getting model file from ${url}`);
 | 
						|
 | 
						|
    await this.#initCache();
 | 
						|
 | 
						|
    // this can be null if no ETag was found or there were a network error
 | 
						|
    const hubETag = await this.#getETag(url);
 | 
						|
 | 
						|
    lazy.console.debug(
 | 
						|
      `Checking the cache for ${organization}/${modelName}/${modelVersion}/${file}`
 | 
						|
    );
 | 
						|
 | 
						|
    // storage lookup
 | 
						|
    const cachedHeaders = await this.cache.getHeaders(
 | 
						|
      organization,
 | 
						|
      modelName,
 | 
						|
      modelVersion,
 | 
						|
      file
 | 
						|
    );
 | 
						|
    const cachedEtag = cachedHeaders ? cachedHeaders.ETag : null;
 | 
						|
 | 
						|
    // If we have something in store, and the hub ETag is null or it matches the cached ETag, return the cached response
 | 
						|
    if (cachedEtag !== null && (hubETag === null || cachedEtag === hubETag)) {
 | 
						|
      lazy.console.debug(`Cache Hit`);
 | 
						|
      return await this.cache.getFile(
 | 
						|
        organization,
 | 
						|
        modelName,
 | 
						|
        modelVersion,
 | 
						|
        file
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    lazy.console.debug(`Fetching ${url}`);
 | 
						|
    try {
 | 
						|
      const response = await fetch(url);
 | 
						|
      if (response.ok) {
 | 
						|
        const clone = response.clone();
 | 
						|
        const headers = {
 | 
						|
          // We don't store the boundary or the charset, just the content type,
 | 
						|
          // so we drop what's after the semicolon.
 | 
						|
          "Content-Type": response.headers.get("Content-Type").split(";")[0],
 | 
						|
          ETag: hubETag,
 | 
						|
        };
 | 
						|
 | 
						|
        await this.cache.put(
 | 
						|
          organization,
 | 
						|
          modelName,
 | 
						|
          modelVersion,
 | 
						|
          file,
 | 
						|
          await clone.blob(),
 | 
						|
          headers
 | 
						|
        );
 | 
						|
        return [await response.blob(), headers];
 | 
						|
      }
 | 
						|
    } catch (error) {
 | 
						|
      lazy.console.error(`Failed to fetch ${url}:`, error);
 | 
						|
    }
 | 
						|
 | 
						|
    throw new Error(`Failed to fetch the model file: ${url}`);
 | 
						|
  }
 | 
						|
}
 |