2

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.

Koushik R
  • 33
  • 6

3 Answers3

3

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 wasBlob = src instanceof Blob;
      const blob = wasBlob ? src : await new Response(src).blob();
      const reader = new FileReader();
      return new Promise(resolve => {
        reader.onload = () => resolve([
          reader.result,
          blob.type,
          wasBlob,
        ]);
        reader.readAsDataURL(blob);
      });
    }
    
  • background script, inside onMessage listener:

    const [body, type] = deserialize(message.body);
    fetch(message.url, {
      body,
      headers: {
        'Content-Type': type, 
      },
    }).then(/*........*/);
    function deserialize([base64, type, wasBlob]) {
      const str = atob(base64.slice(base64.indexOf(',') + 1));
      const len = str.length;
      const arr = new Uint8Array(len);
      for (let i = 0; i < len; i += 1) arr[i] = str.charCodeAt(i);
      if (!wasBlob) {
        type = base64.match(/^data:(.+?);base64/)[1].replace(/(boundary=)[^;]+/,
          (_, p1) => p1 + String.fromCharCode(...arr.slice(2, arr.indexOf(13))));
      }
      return [arr, type];
    }
    

Workaround 2.

Use an iframe for 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. FormData is not clonable, but you can pass it as [...obj] and then assemble in new FormData() object.

It can also pass the data directly to the background script via navigator.serviceWorker messaging.

Example: see "Web messaging (two-way MessagePort)" in that answer.

wOxxOm
  • 65,848
  • 11
  • 132
  • 136
  • Thank you so much for this help, Workaround 1 worked very well for me :) I understand the risk at Workaround 2 and am not opting for it. – Koushik R Aug 11 '21 at 12:32
  • @KoushikR could you also use the IndexDB local database proposed by Stainz42 ? Alternatively look into File Access API (will display alert asking for user permission) or File Api? – Matthew C Nov 01 '22 at 11:03
  • 1
    @mukunda, well, it works, which means you're wrong (maybe you've copied the code incorrectly or there is another mistake). I've just verified again that a file is transferred successfully. – wOxxOm Jan 27 '23 at 08:43
  • thanks for confirming. I already implemented workaround 1, but I want to try workaround 2 again to see what I messed up – mukunda Jan 28 '23 at 02:03
  • I was trying to pass the data through the "transfer" parameter before. fixed that. now I'm getting a very weird error net::ERR_H2_OR_QUIC_REQUIRED when trying to post the file upload - this is if passing a File. if I try to pass formData in the init section, I get "FormData object could not be cloned." – mukunda Jan 28 '23 at 02:59
  • and weirdly, I can't reproduce that issue in an isolated test case. this is such a headache lol – mukunda Jan 28 '23 at 03:30
  • i think there might be a problem when passing from a site's content script to the background iframe. was workaround #2 tested successfully from an extension running on a 3rd party page to pass a File? – mukunda Jan 28 '23 at 04:57
  • Yes, I've tested it on a live site. As for FormData, indeed it's a bug in all browsers, so you can pass [...formData] via messaging, then assemble it into a new FormData object. – wOxxOm Jan 28 '23 at 09:08
  • I only have a problem with passing to service context. It works OK to postMessage without extensions involved. Seems like a chrome bug with drag and drop, and I'm sticking with workaround #1 for now. Fair warning to any brave soul who tries #2 - repro video: https://mukunda.com/2023-01-28%2001-43-10-vc.mp4 – mukunda Jan 29 '23 at 01:36
1

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!
    }
  }
});
Stainz42
  • 963
  • 1
  • 6
  • 8
0

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;
});
malininss
  • 190
  • 1
  • 4