简体   繁体   中英

Passing FormData/File Object from content script to background script in chrome extension with Manifest V3

I'm building a chrome extension where I get a file as input from the user and pass it to my background.js (service worker in case of manifest v3) to save it to my backend. Since making cross-origin requests are blocked from content scripts I have to pass the same to my background.js and use FETCH API to save the file. When I pass the FormData or File Object to the chrome.runtime.sendMessage API it uses JSON Serialization and what I receive in my background.js is an empty object. Refer to the below snippet.

//content-script.js

attachFile(event) {
 let file = event.target.files[0];

 // file has `File` object uploaded by the user with required contents. 
 chrome.runtime.sendMessage({ message: 'saveAttachment', attachment: file }); 
}

//background.js

chrome.runtime.onMessage.addListener((request, sender) => {
 if (request.message === 'saveAttachment') {
   let file = request.attachment; //here the value will be a plain object  {}
 }
});

The same happens even when we pass the FormData from the content script.

I referred to multiple solutions suggested by the old StackOverflow questions, to use URL.createObjectURL(myfile); and pass the URL to my background.js and fetch the same file. Whereas FETCH API does not support blob URL to fetch and also XMLHttpRequest is not supported in service worker as recommended here . Can someone help me in solving this? Am so blocked with this behaviour.

Currently only Firefox can transfer such types directly. Chrome might be able to do it in the future .

Workaround 1.

Serialize the object's contents manually to a string , send it, possibly in several messages if the length exceeds 64MB message size limit, then rebuild the object in the background script. Below is a simplified example without splitting, adapted from Violentmonkey . It's rather slow (encoding and decoding of 50MB takes several seconds) so you may want to write your own version that builds a multipart/form-data string in the content script and send it directly in the background script's fetch .

  • content script:

     async function serialize(src) { const cls = Object.prototype.toString.call(src).slice(8, -1); switch (cls) { case 'FormData': { return { cls, value: await Promise.all(Array.from(src.keys(), async key => [ key, await Promise.all(src.getAll(key).map(serialize)), ])), }; } case 'Blob': case 'File': return new Promise(resolve => { const { name, type, lastModified } = src; const reader = new FileReader(); reader.onload = () => resolve({ cls, name, type, lastModified, value: reader.result.slice(reader.result.indexOf(',') + 1), }); reader.readAsDataURL(src); }); default: return src == null ? undefined : { cls: 'json', value: JSON.stringify(src), }; } }
  • background script:

     function deserialize(src) { switch (src.cls) { case 'FormData': { const fd = new FormData(); for (const [key, items] of src.value) { for (const item of items) { fd.append(key, deserialize(item)); } } return fd; } case 'Blob': case 'File': { const { type, name, lastModified } = src; const binStr = atob(src.value); const arr = new Uint8Array(binStr.length); for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i); const data = [arr.buffer]; return src.cls === 'file' ? new File(data, name, {type, lastModified}) : new Blob(data, {type}); } case 'json': return JSON.parse(src.value); } }

Workaround 2.

Use an iframe that points to an html file in your extension exposed via web_accessible_resources. The iframe will be able to do everything an extension can, like making a CORS request. The File/Blob and other cloneable types can be transferred directly from the content script via postMessage . These messages are exposed to any script running in the page so we'll have to add authorization of the request using chrome.runtime messaging, which is safe (until someone finds a method of compromising a content script via side-channel attacks like Spectre).

Warning! The site (or another extension) can delete the iframe at any time.

  • manifest.json:

     { "web_accessible_resources": [{ "resources": ["sender.html"], "matches": ["<all_urls>"], "use_dynamic_url": true }], "host_permissions": [ "https://your.backend.api.host/" ],

    Note that use_dynamic_url isn't implemented as of yet.

  • content.js:

     var iframe; /** * @param {string} url * @param {'text'|'blob'|'json'|'arrayBuffer'|'formData'} [type] * @param {FetchEventInit} [init] */ async function makeRequest(url, type = 'text', init) { if (!iframe || !document.contains(iframe)) { iframe = document.createElement('iframe'); iframe.src = chrome.runtime.getURL('sender.html'); iframe.style.cssText = 'display: none !important'; document.body.appendChild(iframe); await new Promise(resolve => (iframe.onload = resolve)); } const id = `${Math.random}.${performance.now()}`; const fWnd = iframe.contentWindow; const fOrigin = new URL(iframe.src).origin; fWnd.postMessage('authorize', fOrigin); await new Promise(resolve => { chrome.runtime.onMessage.addListener(function _(msg, sender, respond) { if (msg === 'authorizeRequest') { chrome.runtime.onMessage.removeListener(_); respond({id, url}); resolve(); } }); }); fWnd.postMessage({id, type, init}, fOrigin); return new Promise(resolve => { window.addEventListener('message', function onMessage(e) { if (e.source === fWnd && e.data?.id === id) { window.removeEventListener('message', onMessage); resolve(e.data.result); } }); }); }
  • sender.html:

     <script src="sender.js"></script>
  • sender.js:

     const authorizedRequests = new Map(); window.onmessage = async e => { if (e.source !== parent) return; if (e.data === 'authorize') { chrome.tabs.getCurrent(tab => { chrome.tabs.sendMessage(tab.id, 'authorizeRequest', r => { authorizedRequests.set(r.id, r.url); setTimeout(() => authorizedRequests.delete(r.id), 60e3); }); }); } else if (e.data?.id) { const {id, type, init} = e.data; const url = authorizedRequests.get(id); if (url) { authorizedRequests.delete(id); const result = await (await fetch(url, init))[type]; parent.postMessage({id, result}, '*', type === 'arrayBuffer' ? [result] : []); } } };

I have a better solution: you can actually store Blob in the IndexedDB.

 // client side (browser action or any page) import { openDB } from 'idb'; const db = await openDB('upload', 1, { upgrade(openedDB) { openedDB.createObjectStore('files', { keyPath: 'id', autoIncrement: true, }); }, }); await db.clear('files'); const fileID = await db.add('files', { uploadURL: 'https://yours3bucketendpoint', blob: file, }); navigator.serviceWorker.controller.postMessage({ type: 'UPLOAD_MY_FILE_PLEASE', payload: { fileID } }); // Background Service worker addEventListener('message', async (messageEvent) => { if (messageEvent.data?.type === 'UPLOAD_MY_FILE_PLEASE') { const db = await openDB('upload', 1); const file = await db.get('files', messageEvent.data?.payload?.fileID); const blob = file.blob; const uploadURL = file.uploadURL; // it's important here to use self.fetch // so the service worker stays alive as long as the request is not finished const response = await self.fetch(uploadURL, { method: 'put', body: blob, }); if (response.ok) { // Bravo! } } });

I found another way to pass files from a content page (or from a popup page) to a service worker. But, probably, it is not suitable for all situations,

You can intercept a fetch request sent from a content or popup page in a service worker. Then you can send this request through the service-worker, it can also be modified somehow

popup.js:

// simple fetch, but with a header indicating that the request should be intercepted
fetch(url, {
    headers: {
        'Some-Marker': 'true',
    },
});

background.js:

self.addEventListener('fetch', (event) => {
    // You can check that the request should be intercepted in other ways, for example, by the request URL
    if (event.request.headers.get('Some-Marker')) {
        event.respondWith((async () => {
            // event.request contains data from the original fetch that was sent from the content or popup page.
            // Here we make a request already in the background.js (service-worker page) and then we send the response to the content page, if it is still active
            // Also here you can modify the request hoy you want
            const result = await self.fetch(event.request);
            return result;
        })());
    }
    return null;
});

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM