用同源 blob 包一層 importScripts 建立跨源 Worker

問題:Micro Frontend(Module Federation)下,頁面在 host 的 origin、worker 的 JS chunk 在 remote 的 originnew Worker('https://remote/xxx.worker.js') 會被同源政策擋掉。解法是用一個同源的 blob 當殼,在殼裡用 importScripts() 把跨源的真正程式碼拉進來。

constructor(url: URL, options?: WorkerOptions) {
  if (url.origin === location.origin) {
    this.worker = new Worker(url, options);   // 同源就不用繞
    return;
  }
  const publicPath = url.href.substring(0, url.href.lastIndexOf('/') + 1);
  const blob = new Blob(
    [`self.__webpack_public_path__ = ${JSON.stringify(publicPath)}; importScripts(${JSON.stringify(url.href)});`],
    { type: 'application/javascript' },
  );
  this.worker = new Worker(URL.createObjectURL(blob), options);
}

三個前提

1. new Worker() 要的是「一個 JS 檔案的網址」

new Worker('https://example.com/foo.js');   // 瀏覽器下載 foo.js,開一條執行緒跑它

所以要開 Worker,就必須有一個 JS 檔案掛在某個網址上。

2. 那個檔案可以當場捏一個

Blob 就是記憶體裡的一個假檔案URL.createObjectURL() 給它一個網址;對瀏覽器來說它跟真的檔案沒兩樣。

const blob = new Blob(['console.log("hi")'], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);   // 只是產生網址,什麼都還沒執行
// blob:http://localhost:3000/8f2a-...
new Worker(url);                          // ← 這行才開執行緒並執行那段程式碼

關鍵在網址開頭:blob:http://localhost:3000/… —— 它算是 :3000 的東西,跟頁面同源,所以不會被擋。

3. importScripts() 可以跨源

它是 Worker 專用的指令,意思是「下載這個 JS 檔、就地執行」,而且允許跨源(只要對方送了正確的 CORS header,dev server 與 CDN 本來就有送)。

所以整件事是

手寫一個只有兩行的 JS 檔放在記憶體裡,用它開 Worker,再讓它把真正的程式碼拉進來:

blob(同源 → 允許建立 Worker)
  └─ importScripts(允許跨源)
       └─ 真正的 worker 程式碼從 remote 載進來執行

用一個同源的殼,把跨源的內容拉進來。

那個假檔案的實際內容

程式碼裡是用字串拼的(網址要執行時才知道,不能寫死):

`self.__webpack_public_path__ = ${JSON.stringify(publicPath)}; importScripts(${JSON.stringify(url.href)});`

${...} 填完之後,這個字串的內容——也就是那個假檔案的全部內容——是:

self.__webpack_public_path__ = "http://localhost:3002/"; importScripts("http://localhost:3002/xxx.worker.js");

把它存成 shim.js 放到伺服器上效果一模一樣;只是我們沒辦法在 host 的伺服器上放檔案,所以在瀏覽器裡當場捏一個。

selfWorker 裡的全域物件(頁面裡叫 window),所以第一行等於在這條 worker 執行緒裡宣告一個全域變數,給第二行拉進來的 webpack 程式碼讀。

執行時序(順序不能反)

importScripts同步的:執行到那一行會停住,把檔案抓下來跑完才繼續。

new Worker(blob 網址)
  → 開一條執行緒
  → 執行第 1 句:設好全域變數 __webpack_public_path__
  → 執行第 2 句:同步下載 remote 的真正程式碼並執行
       → 掛上 self.onmessage
       → 之後要抓 .wasm 時,讀第 1 句設的變數,去 remote 抓
  → worker 就緒,開始等主執行緒的訊息

所以兩句的順序不能反:必須設好 public path,拉真正的程式碼——否則那份程式碼一啟動就去找 .wasm,變數還沒設好,就會跑去 host 的 origin 找而 404。

__webpack_public_path__ 那行為什麼必要

webpack 產出的程式碼載入資源時是用 __webpack_public_path__ + 檔名 組網址的,不設就會用預設值(通常是 /),於是跑去 host 的 origin 抓——檔案不在那裡,404。

publicPath 的算法就是把 URL 砍到最後一個 /

https://remote.example.com/app/xxx.worker.a1b2c3.js
                              ↑ lastIndexOf('/') + 1
→ https://remote.example.com/app/

JSON.stringify 做的事只有一件:把 http://localhost:3002/ 變成 "http://localhost:3002/"(加引號並跳脫)。因為我們在產生程式碼,程式碼裡的網址必須是字串。

沒加引號的話會壞得很有趣:// 之後全被當成註解,整行只剩 self.__webpack_public_path__ = http:,直接 SyntaxError——而且錯在 worker 內部,主執行緒只會收到一個 error event。

為什麼一定要事先判斷同源,不能失敗再退回

跨源 Worker 被擋的方式很討厭:不是丟例外,是非同步觸發一個 error eventtry/catch 包不住,只能靠 worker.onerror 才知道失敗——所以只能在建立前就判斷 url.origin === location.origin

限制與副作用

  • 只對 classic worker 有效。改成 { type: 'module' } 之後 importScripts 就不能用了,整套要換成動態 import()
  • URL.createObjectURL() 產生的 blob URL 若沒有 revokeObjectURL 會留到分頁關閉(只建立一次的話影響可忽略)
  • ⚠️ 儲存歸屬會跟著 origin 走:blob 繼承 host 的 origin,所以 worker 裡的 OPFS / IndexedDB 會落在 host 底下,不是提供 worker 的 remote。多個 remote 會共用同一個儲存空間 → 見 OPFS sync access handle 讓 SQLite 能在瀏覽器只讀用得到的 page

包裝而不是繼承

這種類別通常寫成包住一個 Worker(而不是 extends Worker),所以要另外提供一個方法把內部實例取出來。原因是 constructor 裡必須先判斷同源與否才決定怎麼建,而繼承的話 super() 必須在最前面呼叫,沒有先做判斷的空間。

它本身也不是 singleton——new 幾次就建幾個 Worker。共用一份 Worker 是呼叫端(module-scoped 變數)的責任,兩邊職責分開:這個類別只知道 URL 與同源政策,呼叫端只知道應用需要共用一份。

相關:打包器靠 AST 形狀認出 worker,改寫法會靜默失效Web Worker 不需要註冊,Service Worker 才需要