Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/zotero/zotero-connectors/llms.txt

Use this file to discover all available pages before exploring further.

Injected content scripts and the background process run in completely separate JavaScript contexts. Browser APIs (Chrome/Firefox/Safari) provide a message-passing channel between them, but the raw API is cumbersome: callers must serialize arguments, route messages by string name, and manually wire up response callbacks. Zotero Connectors eliminate this boilerplate with a transparent monkey-patching approach: every method that an inject script needs to call on the background is replaced at runtime with a proxy that sends the call over the message channel and returns a promise that resolves with the response — making remote calls look identical to local ones.

The MESSAGES Registry

src/common/messages.js is the single source of truth for every proxied method. It exports a two-level object: the top level is a namespace (Translators, Connector, Prefs, etc.) and the second level is the method name. The value for each method controls the RPC behavior:
ValueMeaning
trueCall is forwarded to the background; a response is expected
falseFire-and-forget; no response is expected
object with hook functionsResponse expected, with pre/post processing on either side
const MESSAGE_SEPARATOR = ".";

var MESSAGES = {
  Translators: {
    updateFromRemote: true,
    get: {
      background: {
        preSend: async function(translators) {
          return Zotero.Translators.serialize(translators, TRANSLATOR_PASSING_PROPERTIES);
        }
      },
      inject: {
        postReceive: async function(translator) {
          return new Zotero.Translator(translator);
        }
      }
    },
    getWebTranslatorsForLocation: {
      background: {
        preSend: async function(data) {
          return [Zotero.Translators.serialize(data[0], TRANSLATOR_PASSING_PROPERTIES), data[1]];
        }
      },
      inject: {
        postReceive: async function(data) {
          data[0] = data[0].map((translator) => new Zotero.Translator(translator));
          data[1] = data[1].map((proxy) => proxy && new Zotero.Proxy(proxy));
          return [data[0], data[1]];
        }
      }
    },
    // …
  },
  Connector: {
    checkIsOnline: true,
    callMethod: true,
    callMethodWithCookies: true,
    saveSingleFile: {
      inject: {
        preSend: async function(args) {
          if (Zotero.isChromium) {
            args[1].snapshotContent =
              await Zotero.Messaging.sendAsChunks(args[1].snapshotContent);
          }
          return args;
        },
      },
      background: {
        postReceive: async function(args) {
          if (Zotero.isChromium) {
            args[1].snapshotContent =
              Zotero.Messaging.getChunkedPayload(args[1].snapshotContent);
          }
          return args;
        }
      }
    },
    // …
  },
  Prefs: {
    set: false,       // fire-and-forget
    getAll: true,
    getAsync: true,
    // …
  },
  // … many more namespaces …
};
The MESSAGE_SEPARATOR constant is ".". A call to Zotero.Translators.getWebTranslatorsForLocation becomes the message name "Translators.getWebTranslatorsForLocation".

Hook Functions

Each method entry in MESSAGES can carry up to four hooks. They are applied in the order shown below:
HookSideWhen calledPurpose
inject.preSendInjectBefore the message is sentTransform arguments before serialization (e.g. strip un-serializable objects, chunk large payloads)
background.postReceiveBackgroundAfter the message arrivesTransform or augment arguments before calling the real function (e.g. inject the sender tab object)
background.preSendBackgroundAfter the function returns, before sending responseTransform the return value before serialization (e.g. strip translator code, serialize class instances)
inject.postReceiveInjectAfter the response arrivesRe-hydrate the response into proper class instances (e.g. new Zotero.Translator(data))

The Message Flow Step-by-Step

The following sequence applies to BrowserExt (Chrome, Firefox, Edge). Safari uses a similar pattern with a request ID for response correlation.
1

Inject calls a proxied method

The inject script calls Zotero.Translators.getWebTranslatorsForLocation(url, rootURL) as if it were a local function.
2

Monkey-patched stub fires

messaging_inject.js has replaced that function with a generated stub. The stub:
  1. Runs inject.preSend(args) if configured.
  2. Calls browser.runtime.sendMessage(["Translators.getWebTranslatorsForLocation", newArgs]).
3

Background receives the message

Zotero.Messaging.init() has registered a browser.runtime.onMessage listener. It calls receiveMessage("Translators.getWebTranslatorsForLocation", args, sender.tab, sender.frameId).
4

Background executes the real function

receiveMessage resolves Zotero["Translators"]["getWebTranslatorsForLocation"], runs background.postReceive(args, tab, frameId) if present (which by default appends tab and frameId to args), then calls the real function.
5

Background runs preSend on the response

The function’s return value (or resolved promise) is passed to background.preSend(response) if configured. For getWebTranslatorsForLocation this serializes the Zotero.Translator objects to plain JSON.
6

Background returns the response

The onMessage listener returns the processed response, which the browser routes back to the inject-side browser.runtime.sendMessage promise.
7

Inject runs postReceive and resolves

The stub receives the response and runs inject.postReceive(response), which re-hydrates the plain objects back into Zotero.Translator and Zotero.Proxy instances. The original await in the inject script resolves with the fully reconstructed value.

Monkey-Patching in messaging_inject.js

src/browserExt/messaging_inject.js iterates over every entry in MESSAGES and installs the proxy at Zotero.Messaging.init() time:
this.init = function() {
  for (var ns in MESSAGES) {
    if (!Zotero[ns]) Zotero[ns] = {};
    for (var meth in MESSAGES[ns]) {
      Zotero[ns][meth] = new function() {
        var messageName = ns + MESSAGE_SEPARATOR + meth;
        var messageConfig = MESSAGES[ns][meth];
        return async function() {
          // see if last argument is a callback
          var callback, callbackArg = null;
          if (messageConfig) {
            callbackArg = (messageConfig.callbackArg
              ? messageConfig.callbackArg : arguments.length - 1);
            callback = arguments[callbackArg];
            if (typeof callback !== "function") {
              callbackArg = null;
            }
          }

          // copy arguments to newArgs
          var newArgs = new Array(arguments.length);
          for (var i = 0; i < arguments.length; i++) {
            newArgs[i] = i === callbackArg ? undefined : arguments[i];
          }
          if (messageConfig.inject && messageConfig.inject.preSend) {
            newArgs = await messageConfig.inject.preSend(newArgs);
          }

          // MV3 Chromium messaging has a limit of 64MB payload, so we
          // use an alternative method
          if (Zotero.isChromium && messageConfig.largePayload) {
            return Zotero.Messaging._sendViaIframeServiceWorkerPort(messageName, newArgs);
          }

          // send message
          return browser.runtime.sendMessage([messageName, newArgs])
            .then(async function(response) {
              if (response && response[0] == 'error') {
                // Re-throw serialized errors from the background
                response[1] = JSON.parse(response[1]);
                let e = new Error(response[1].message);
                for (let key in response[1]) e[key] = response[1][key];
                throw e;
              }
              if (messageConfig.inject && messageConfig.inject.postReceive) {
                response = await messageConfig.inject.postReceive(response);
              }
              if (callbackArg !== null) callback(response);
              return response;
            });
        };
      };
    }
  }

  // Also register the inject-side onMessage listener for messages pushed FROM background
  // NOTE: Do not convert to `browser.` API — see source for details on Firefox behavior
  chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
    if (typeof request !== "object" || !request.length
        || !_messageListeners[request[0]]) return;
    (async function messageListener() {
      var result;
      try {
        result = await _messageListeners[request[0]](request[1]);
      } catch (err) {
        result = ['error', JSON.stringify(Object.assign({
          name: err.name, message: err.message, stack: err.stack
        }, err))];
      }
      sendResponse(result);
    })();
    return true; // keep channel open for async sendResponse
  });
};

Registering Custom Message Listeners

Both sides expose Zotero.Messaging.addMessageListener(name, handler) for ad-hoc messages that don’t fit the MESSAGES registry pattern:
// In inject scripts (inject.jsx)
Zotero.Messaging.addMessageListener("ping", function () {
  return 'pong';
});

Zotero.Messaging.addMessageListener("translate", function(data) {
  if (data.shift() !== instanceID) return;
  return Zotero.PageSaving.onTranslate(...data);
});
The background can send messages to a specific tab’s inject scripts using Zotero.Messaging.sendMessage(name, args, tab, frameId):
// In background.js
this.saveWithTranslator = function(tab, i, options={}) {
  let tabInfo = this.getTabInfo(tab.id);
  var translator = tabInfo.translators[i];

  // Set frameId to null - send message to all frames.
  // There is code to figure out which frame should translate with instanceID.
  return Zotero.Messaging.sendMessage(
    "translate",
    [
      tabInfo.instanceID,
      translator.translatorID,
      options
    ],
    tab,
    null
  );
};

Concrete Example: Zotero.Connector.callMethod in Inject

In an inject script, calling Zotero.Connector.callMethod("saveItems", data) looks like a direct HTTP call. In reality:
  1. The monkey-patched stub sends ["Connector.callMethod", ["saveItems", data]] via browser.runtime.sendMessage.
  2. The background’s receiveMessage resolves Zotero.Connector.callMethod — the real implementation in src/common/connector.js.
  3. The real implementation constructs an HTTP request to http://127.0.0.1:23119/connector/saveItems using Zotero.HTTP.request().
  4. The HTTP response is serialized and returned through the message channel back to the inject script.
This indirection is invisible to the caller — translators and item-saving code use Zotero.Connector.callMethod identically whether they run in the inject context or (hypothetically) in the background.

Large-Payload Chunking (Chromium)

Chrome’s extension message-passing protocol has a practical payload limit. When large blobs of HTML (e.g. SingleFile snapshots) must be sent from inject to background, messaging_inject.js provides sendAsChunks / getChunkedPayload:
// In messaging_inject.js (inject side)
this.sendAsChunks = async function(payload) {
  if (!Zotero.isChromium)
    throw new Error("Messaging.sendAsChunks is only required on Chromium");
  const MAX_CHUNK_SIZE = 8 * (1024 * 1024); // 8 MB per chunk
  const id = Zotero.Utilities.randomString();
  const numChunks = Math.ceil(payload.length / MAX_CHUNK_SIZE);
  for (let i = 0; i < numChunks; i++) {
    await Zotero.Messaging.receiveChunk(
      id,
      payload.slice(i * MAX_CHUNK_SIZE, (i + 1) * MAX_CHUNK_SIZE)
    );
  }
  return id; // the background later calls getChunkedPayload(id) to reassemble
};
In src/common/messaging.js the background stores incoming chunks and reassembles them on demand:
this.receiveChunk = function(id, payload) {
  _chunkedPayloads[id] = _chunkedPayloads[id] || "";
  _chunkedPayloads[id] += payload;
  // Auto-expire after 30s to prevent memory leaks
  setTimeout(() => { delete _chunkedPayloads[id]; }, 30000);
};

this.getChunkedPayload = function(id) {
  const payload = _chunkedPayloads[id];
  delete _chunkedPayloads[id];
  return payload;
};
The saveSingleFile and ItemSaver.saveAttachmentToServer entries in MESSAGES use this pattern in their inject.preSend and background.postReceive hooks respectively.

ArrayBuffer Packing/Unpacking

Chrome’s extension messaging cannot pass ArrayBuffer objects (binary data for PDF attachments, images, etc.) directly. messages.js defines packArrayBuffer / unpackArrayBuffer utilities that adapt the transfer format per-platform:
PlatformTransfer format
FirefoxNative ArrayBuffer (supported natively)
SafariBase64-encoded string
Chrome MV3Array.from(new Uint8Array(arrayBuffer)) — plain byte array (with 8 MB cap)
Chrome MV2URL.createObjectURL(new Blob([arrayBuffer])) — blob URL
The API.uploadAttachment and COHTTP.request entries in MESSAGES wire these through their inject.preSend and inject.postReceive hooks:
API: {
  uploadAttachment: {
    inject: {
      preSend: async function(args) {
        args[0].data = packArrayBuffer(args[0].data);
        return args;
      }
    },
    background: {
      postReceive: async function(args) {
        args[0].data = await unpackArrayBuffer(args[0].data);
        return args;
      }
    }
  }
}
For very large payloads on Chrome MV3 (e.g. large SingleFile HTML captures), a MESSAGES entry can set largePayload: true. When this flag is present, the standard browser.runtime.sendMessage path is bypassed and Zotero.Messaging._sendViaIframeServiceWorkerPort is called instead. That function creates a hidden <iframe> loaded from chromeMessageIframe/messageIframe.html which has direct port access to the service worker, allowing arbitrarily large payloads to be transferred.

Summary of the MESSAGES Namespaces

updateFromRemote, get, getAllForType, getWebTranslatorsForLocation, getCodeForTranslator. Most entries have preSend hooks that strip full translator code (which can be hundreds of KB) before sending over the wire.
checkIsOnline, callMethod, callMethodWithCookies, saveSingleFile, getClientVersion, reportActiveURL, getPref. callMethod is the workhorse — it proxies any arbitrary Zotero connector HTTP endpoint.
onSelect, onPageLoad, onTranslators, onZoteroButtonElementClick, injectScripts, injectSingleFile, isIncognito, isTabFocused, newerVersionRequiredPrompt, openTab, openConfigEditor, openPreferences, bringToFront. These are called by inject scripts to trigger UI changes and state queries that only the background can perform.
saveAttachmentToZotero, saveStandaloneAttachmentToZotero, saveAttachmentToServer. The saveAttachmentToServer entry uses chunked payload transfer for HTML snapshot blobs.
authorize, onAuthorizationComplete, clearCredentials, getUserInfo, run, uploadAttachment. uploadAttachment uses ArrayBuffer packing for binary attachment data.
set, getAll, getDefault, getAsync, removeAllCachedTranslators, clear. set is fire-and-forget (false); all read operations return values.
sendMessage (inject calls background to relay a message back to itself/another frame) and receiveChunk (chunked payload assembly). The sendMessage entry’s background.postReceive hook injects the correct tab and frameId before forwarding.
Additional namespaces cover Google Docs integration (GoogleDocs_API.onAuthComplete, run, getDocument, batchUpdateDocument), cross-origin HTTP requests (COHTTP.request), debug logging (Debug.log, Debug.get, Debug.bgInit, Debug.clear, Debug.setStore), error reporting (Errors.getErrors, Errors.getSystemInfo), proxy management (Proxies.loadPrefs, Proxies.validate, Proxies.save, Proxies.remove, Proxies.toggleRedirectLoopPrevention), connector debug counters (Connector_Debug.storing, Connector_Debug.get, Connector_Debug.count), and more.

Build docs developers (and LLMs) love