Skip to content

How to write my own loader? #7010

@rsmvdl

Description

@rsmvdl

What do you want to do with Hls.js?

Hey,

I wanted to build my own hlsjs loader for AES-128 key exchange (No real DRM system in use), therefor I created a piece of Wasm code I now want to utilize in a custom loader for hls.js, but for some reason the player never reaches the point of actually trying to fetch a key from my key server and I don't understand why that happens ...

CustomLoader.ts:

/* global sessionStorage, console, window, XMLHttpRequest */

/** For config passed from Hls.js to the loader constructor */
interface HlsLoaderConfig {
  debug?: boolean;
}

/** Fields for the context that Hls.js passes to load(...) */
interface LoaderContext {
  type: string;  // e.g. "key", "manifest", "level", "audioTrack", ...
  url: string;   // e.g. "kms://someKeyId" or "https://..."
  method?: string;
  responseType?: XMLHttpRequestResponseType;
  headers?: Record<string, string>;
  body?: Document | BodyInit | null;
}

/** The callbacks Hls.js uses for success/failure */
interface LoaderCallbacks {
  onSuccess(
    response: { data: any; url: string },
    stats: LoaderStats,
    context: LoaderContext,
    networkDetails: any
  ): void;
  onError(
    error: { code: number; text: string },
    context: LoaderContext,
    networkDetails: any
  ): void;
}

/** Stats object used by Hls.js */
interface LoaderStats {
  trequest: number;
  tfirst: number;
  tload: number;
  tparsed: number;
  loaded: number;
  total: number;
  aborted: boolean;
  retry: number;
  chunkCount: number;
  parsing: {
    start: number;
    end: number;
  };
}

/**
 * CustomKeyLoader:
 *  - If (type==='key') or URL starts with 'kms://', we call `KeyMerchant.create_media_session`
 *  - Otherwise do fallback XHR
 */
export class CustomKeyLoader {
  private config: HlsLoaderConfig;
  private keyCache: Record<string, ArrayBuffer>;
  private loadTimeout: number;

  constructor(config: HlsLoaderConfig) {
    console.log('[CustomKeyLoader] constructor =>', config);
    this.config = config;
    this.keyCache = {};
    this.loadTimeout = 10000;
  }

  public load(
    context: LoaderContext,
    hlsConfig: any,
    callbacks: LoaderCallbacks
  ): void {
    console.log(
      '[CustomKeyLoader] load => type=%s, url=%s',
      context.type,
      context.url
    );

    // If it's a key request, handle it via create_media_session
    const isKeyRequest =
      context.type === 'key' || (context.url && context.url.startsWith('kms://'));

    if (isKeyRequest) {
      console.log('[CustomKeyLoader] => KEY REQUEST detected =>', context.url);
      // Force arraybuffer for the key
      context.responseType = 'arraybuffer';
      this._handleKmsKey(context, callbacks);
    } else {
      console.log('[CustomKeyLoader] => normal request => fallback XHR =>', context.url);
      this._fallbackXhr(context, callbacks);
    }
  }

  public abort(): void {
    console.log('[CustomKeyLoader] abort() => not implemented');
  }

  private _handleKmsKey(
    context: LoaderContext,
    callbacks: LoaderCallbacks
  ): void {
    const startTime = performance.now();
    const stats: LoaderStats = {
      trequest: startTime,
      tfirst: startTime,
      tload: startTime,
      tparsed: startTime,
      loaded: 0,
      total: 0,
      aborted: false,
      retry: 0,
      chunkCount: 1,
      parsing: {
        start: startTime,
        end: startTime
      }
    };

    const url = context.url;
    const keyId = url.replace('kms://', '');
    console.log('[CustomKeyLoader] _handleKmsKey => keyId=', keyId);

    // Check cache
    if (this.keyCache[keyId]) {
      console.log('[CustomKeyLoader] => key in cache =>', keyId);
      const cachedKey = this.keyCache[keyId];
      stats.loaded = cachedKey.byteLength;
      stats.total = cachedKey.byteLength;
      const now = performance.now();
      stats.tload = now;
      stats.tparsed = now;
      stats.parsing.start = stats.trequest;
      stats.parsing.end = now;

      callbacks.onSuccess({ data: cachedKey, url }, stats, context, null);
      return;
    }

    // Check token
    const kmsToken = sessionStorage.getItem('kmst') || '';
    if (!kmsToken) {
      console.error('[CustomKeyLoader] => No KMS token => "kmst"');
      callbacks.onError({ code: 401, text: 'Missing kmst token' }, context, null);
      return;
    }

    (async () => {
      try {
        console.log('[CustomKeyLoader] => calling create_media_session =>', keyId);
        const keyData = await window.KeyMerchant.create_media_session(kmsToken, keyId);
        if (!keyData) {
          throw new Error('No data from create_media_session');
        }

        const keyBuffer = (keyData as Uint8Array).buffer || keyData;
        this.keyCache[keyId] = keyBuffer as ArrayBuffer;

        console.log('[CustomKeyLoader] => key retrieved =>', keyId);

        const endTime = performance.now();
        stats.loaded = (keyBuffer as ArrayBuffer).byteLength;
        stats.total = stats.loaded;
        stats.tload = endTime;
        stats.tparsed = endTime;
        stats.parsing.start = startTime;
        stats.parsing.end = endTime;

        callbacks.onSuccess({ data: keyBuffer, url }, stats, context, null);
      } catch (err: any) {
        console.error('[CustomKeyLoader] => create_media_session error =>', err);
        callbacks.onError(
          { code: 500, text: err.message || 'Key retrieval error' },
          context,
          null
        );
      }
    })();
  }

  private _fallbackXhr(context: LoaderContext, callbacks: LoaderCallbacks): void {
    const startTime = performance.now();
    const stats: LoaderStats = {
      trequest: startTime,
      tfirst: startTime,
      tload: startTime,
      tparsed: startTime,
      loaded: 0,
      total: 0,
      aborted: false,
      retry: 0,
      chunkCount: 1,
      parsing: {
        start: startTime,
        end: startTime
      }
    };

    console.log('[CustomKeyLoader] => _fallbackXhr =>', context.url);

    const xhr = new XMLHttpRequest();
    xhr.open(context.method || 'GET', context.url, true);

    // For manifests, Hls.js typically sets responseType='text'
    // For segments, it may set 'arraybuffer'
    xhr.responseType = context.responseType || 'text';

    // Attach optional CDN token
    const cdnToken = sessionStorage.getItem('cdnt') || '';
    if (cdnToken) {
      console.log('[CustomKeyLoader] => attaching cdnt =>', cdnToken);
      xhr.setRequestHeader('Authorization', cdnToken);
    }

    xhr.withCredentials = true;
    xhr.timeout = this.loadTimeout;

    if (context.headers) {
      console.log('[CustomKeyLoader] => additional headers =>', context.headers);
      for (const h in context.headers) {
        xhr.setRequestHeader(h, context.headers[h]);
      }
    }

    xhr.onload = () => {
      const endTime = performance.now();
      stats.tload = endTime;
      stats.tparsed = endTime;

      let loaded = 0;
      if (xhr.response instanceof ArrayBuffer) {
        loaded = xhr.response.byteLength;
      } else if (typeof xhr.response === 'string') {
        loaded = xhr.response.length;
      }
      stats.loaded = loaded;
      stats.total = loaded;
      stats.parsing.start = startTime;
      stats.parsing.end = endTime;

      console.log('[CustomKeyLoader] => fallback XHR success =>', context.url);
      callbacks.onSuccess({ data: xhr.response, url: context.url }, stats, context, null);
    };

    xhr.onerror = () => {
      console.error('[CustomKeyLoader] => fallback XHR error =>', xhr.status, xhr.statusText);
      callbacks.onError({ code: xhr.status, text: xhr.statusText }, context, null);
    };

    console.log('[CustomKeyLoader] => sending fallback XHR =>', context.url);
    xhr.send((context.body as Document | XMLHttpRequestBodyInit | null) || null);
  }
}

// Attach to window if needed:
(window as any).CustomKeyLoader = CustomKeyLoader;

The line that does not work, but which I expect to work, is this:

const isKeyRequest =
      context.type === 'key' || (context.url && context.url.startsWith('kms://'));

    if (isKeyRequest) { ...

I never ever drop into context.type === 'key' for what ever reason. Can please somebody tell me why it's not working, how can I intercept the key fetching of the player ???

I want to accomplish the following scenario:

-> Payer sees "kms://keyId" in HLS playlist/manifest -> calls KeyMerchant.create_media_session(kmsToken, keyId) -> get's back the AES-128 key as Uint8Array

For the rest of the code, at least so far, I can confirm that it's working, e.g. "normal" media segments are getting fetched fine using the right token from my browsers' session storage (cdnt) ... But just this part with context.type === 'key' isnt working as it seems. I also double checked that enableSoftwareAES is enabled

Thanks in advance.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions