/* 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/. */ /** * This class manages the communications to the translations engine via MessagePort. */ export class Translator { /** * The port through with to communicate with the Translations Engine. * * @type {MessagePort} */ #port; /** * True if the current #port is closed, otherwise false. * * @type {boolean} */ #portClosed = true; /** * A promise that resolves when the Translator has successfully established communication with * the translations engine, or rejects if communication was not successfully established. * * @type {Promise} */ #ready = Promise.reject; /** * The BCP-47 language tag for the from-language. * * @type {string} */ #fromLanguage; /** * The BCP-47 language tag for the to-language. * * @type {string} */ #toLanguage; /** * The callback function to request a new port, provided at construction time * by the caller. * * @type {Function} */ #requestTranslationsPort; /** * An id for each message sent. This is used to match up the request and response. * * @type {number} */ #nextMessageId = 0; /** * Tie together a message id to a resolved response. * * @type {Map} */ #requests = new Map(); /** * Initializes a new Translator. * * Prefer using the Translator.create() function. * * @see Translator.create * * @param {string} fromLanguage - The BCP-47 from-language tag. * @param {string} toLanguage - The BCP-47 to-language tag. * @param {Function} requestTranslationsPort - A callback function to request a new MessagePort. */ constructor(fromLanguage, toLanguage, requestTranslationsPort) { this.#fromLanguage = fromLanguage; this.#toLanguage = toLanguage; this.#requestTranslationsPort = requestTranslationsPort; } /** * @returns {Promise} A promise that indicates if the Translator is ready to translate. */ get ready() { return this.#ready; } /** * @returns {boolean} True if the translation port is closed, false otherwise. */ get portClosed() { return this.#portClosed; } /** * @returns {string} The BCP-47 language tag of the from-language. */ get fromLanguage() { return this.#fromLanguage; } /** * @returns {string} The BCP-47 language tag of the to-language. */ get toLanguage() { return this.#toLanguage; } /** * Opens up a port and creates a new translator. * * @param {string} fromLanguage - The BCP-47 language tag of the from-language. * @param {string} toLanguage - The BCP-47 language tag of the to-language. * @param {object} data - Data for creating a translator. * @param {Function} [data.requestTranslationsPort] * - A function to request a translations port for communication with the Translations engine. * This is required in all cases except if allowSameLanguage is true and the fromLanguage * is the same as the toLanguage. * @param {boolean} [data.allowSameLanguage] * - Whether to allow or disallow the creation of a PassthroughTranslator in the event * that the fromLanguage and the toLanguage are the same language. * * @returns {Promise} */ static async create( fromLanguage, toLanguage, { requestTranslationsPort, allowSameLanguage } ) { if (!fromLanguage || !toLanguage) { throw new Error( "Attempt to create Translator with missing language tags." ); } if (fromLanguage === toLanguage) { if (!allowSameLanguage) { throw new Error("Attempt to create disallowed PassthroughTranslator"); } return new PassthroughTranslator(fromLanguage, toLanguage); } if (!requestTranslationsPort) { throw new Error( "Attempt to create Translator without a requestTranslationsPort function" ); } const translator = new Translator( fromLanguage, toLanguage, requestTranslationsPort ); await translator.#createNewPortIfClosed(); return translator; } /** * Creates a new translation port if the current one is closed. * * @returns {Promise} - Whether the Translator is ready to translate. */ async #createNewPortIfClosed() { if (!this.#portClosed) { return; } this.#port = await this.#requestTranslationsPort( this.#fromLanguage, this.#toLanguage ); this.#portClosed = false; // Create a promise that will be resolved when the engine is ready. const { promise, resolve, reject } = Promise.withResolvers(); // Match up a response on the port to message that was sent. this.#port.onmessage = ({ data }) => { switch (data.type) { case "TranslationsPort:TranslationResponse": { const { targetText, messageId } = data; // A request may not match match a messageId if there is a race during the pausing // and discarding of the queue. this.#requests.get(messageId)?.resolve(targetText); break; } case "TranslationsPort:GetEngineStatusResponse": { if (data.status === "ready") { resolve(); } else { this.#portClosed = true; reject(); } break; } case "TranslationsPort:EngineTerminated": { this.#portClosed = true; break; } default: break; } }; this.#ready = promise; this.#port.postMessage({ type: "TranslationsPort:GetEngineStatusRequest" }); } /** * Send a request to translate text to the Translations Engine. If it returns `null` * then the request is stale. A rejection means there was an error in the translation. * This request may be queued. * * @param {string} sourceText * @returns {Promise} */ async translate(sourceText, isHTML = false) { await this.#createNewPortIfClosed(); await this.#ready; const { promise, resolve, reject } = Promise.withResolvers(); const messageId = this.#nextMessageId++; // Store the "resolve" for the promise. It will be matched back up with the // `messageId` in #handlePortMessage. this.#requests.set(messageId, { sourceText, isHTML, resolve, reject, }); this.#port.postMessage({ type: "TranslationsPort:TranslationRequest", messageId, sourceText, isHTML, }); return promise; } /** * Close the port and remove any pending or queued requests. */ destroy() { this.#port.close(); this.#portClosed = true; this.#ready = Promise.reject; } } /** * The PassthroughTranslator class mimics the same API as the Translator class, * but it does not create any message ports for actual translation. This class * may only be constructed with the same fromLanguage and toLanguage value, and * instead of translating, it just passes through the source text as the translated * text. * * The Translator class may return a PassthroughTranslator instance if the fromLanguage * and toLanguage passed to the create() method are the same. * * @see Translator.create */ class PassthroughTranslator { /** * The BCP-47 language tag for the from-language and the to-language. * * @type {string} */ #language; /** * @returns {Promise} A promise that indicates if the Translator is ready to translate. */ get ready() { return Promise.resolve; } /** * @returns {boolean} Always false for PassthroughTranslator because there is no port. */ get portClosed() { return false; } /** * @returns {string} The BCP-47 language tag of the from-language. */ get fromLanguage() { return this.#language; } /** * @returns {string} The BCP-47 language tag of the to-language. */ get toLanguage() { return this.#language; } /** * Initializes a new PassthroughTranslator. * * Prefer using the Translator.create() function. * * @see Translator.create * * @param {string} fromLanguage - The BCP-47 from-language tag. * @param {string} toLanguage - The BCP-47 to-language tag. */ constructor(fromLanguage, toLanguage) { if (fromLanguage !== toLanguage) { throw new Error( "Attempt to create PassthroughTranslator with different fromLanguage and toLanguage." ); } this.#language = fromLanguage; } /** * Passes through the source text as if it was translated. * * @returns {Promise} */ async translate(sourceText) { return Promise.resolve(sourceText); } /** * There is nothing to destroy in the PassthroughTranslator class. * This function is implemented to maintain the same API surface as * the Translator class. * * @see Translator */ destroy() {} }