執行環境套件管理器打包器測試執行器程式碼檢查格式化指南參考部落格意見回饋

ModuleGraph

使用 Bun.ModuleGraph 在單個行程中運行同一應用的多個例項。每個例項都有全新的模組狀態以及自己的計時器和 I/O;編譯後的程式碼則會共享。

Bun.ModuleGraph 會再次將模組載入到當前全域性環境中。每個圖都有自己的模組例項、自己的 require.cache,以及你傳入 globals 的名稱所對應的值。已解析的程式碼和位元組碼會在圖之間共享;ES 模組也會共享 JIT 編譯後的程式碼。圖通過 import() 匯入的第一個模組是該圖的主模組:其中 import.meta.main 和 require.main === module 為 true,而圖中的其他模組則不會。

host.ts
// 租戶自己的 `process`:它的模組程式碼看到的是這個物件。
const tenantProcess = Object.create(process, { env: { value: { TENANT: "a" }, enumerable: true } });

const graph = new Bun.ModuleGraph({
  globals: { process: tenantProcess }, // 圖中模組的自由識別符號
  onError(error, kind) {
    console.error("tenant failed", kind, error);
  },
});

const app = await graph.import("./app.ts");
await graph.run(() => app.handle(new Request("http://localhost/")));

graph.dispose();

圖開啟的資源歸圖所有

圖有自己的計時器和 I/O 上下文。它的程式碼開啟的一切資源都歸圖所有:

  • 計時器
  • Bun.serve 和 Bun.listen 伺服器、套接字、fetch() 請求、WebSocket
  • 監聽器
  • 子行程,包括 Bun.$
  • worker
  • 資料庫連線
  • 通過 Bun.file().writer()、bun:sqlite 和 node:sqlite 開啟的檔案
  • 它的程式碼建立的圖

此上下文會像 AsyncLocalStorage 一樣,隨程式碼非同步傳遞:經過 await、計時器、套接字處理程序和事件監聽器時都會如此。

graph.dispose() 會關閉所有這些資源。從那時起,該圖將不再接收任何事件,就像已終止的 worker 一樣:

  • 不會呼叫任何 close 處理程序、onExit 或 'error' 事件。
  • 正在進行中的 fetch() 或連線不會被拒絕。
  • 等待請求、子行程退出、檔案讀取、壓縮、crypto.subtle、DNS 查詢、Bun.build 或流的 promise 永遠不會完成。
  • 仍會呼叫 FinalizationRegistry 清理回撥。

圖已排隊的微任務和 process.nextTick 回撥仍會執行一次。無論這些程式碼啟動什麼操作,都不會真正啟動:不會撥號建立連線,不會繫結 UDP 套接字,也不會有人能夠連線到它監聽的伺服器。它的 promise 會保持 pending 狀態,而不是被拒絕,因此在失敗時重試的程式碼會停在那裡。

宿主對圖物件的引用也會失效。dispose() 之後不要使用圖的套接字、worker、子行程、流或 Response。對這些物件進行操作可能會失敗,也可能永遠無法完成:await worker.terminate()、server.close(callback) 或等待子行程的 'exit' 都可能永遠等待。對於圖開啟的資料庫,宿主會收到“Database has closed”。已緩衝但尚未寫入的內容會被丟棄。

dispose() 會釋放圖持有的資源,但它不是沙箱:

  • 遺留程式碼通過 Bun.spawn 或 child_process.spawn() 啟動的行程會先啟動,然後再被終止,因此非常短的命令可能會先執行完畢。
  • Bun.spawnSync 和 fs.writeFileSync 等同步呼叫會執行至完成。
  • node:http 或 node:https 請求會通過 Agent 傳送,而 Agent 開啟的資源歸建立該 Agent 的指令碼所有。沒有 agent 的請求會通過共享的 http.globalAgent 傳送(參見共享的內容)。這類請求不歸圖所有:遺留程式碼發出的請求仍會發出,正在進行中的請求不會被中止,其 'response' 和 'error' 回撥在 dispose() 之後仍會被呼叫。希望請求隨圖停止的程式碼應傳入自己建立的 Agent。
  • node:quic 目前尚未涵蓋:圖的程式碼開啟的端點在 dispose() 之後仍會保持開啟狀態,其處理程序也會繼續執行。開啟端點的程式碼應在圖被銷燬之前關閉它。
  • 指令碼持有的檔案描述符由指令碼自行管理:包括通過 fs.openSync() 開啟的檔案描述符,以及 FileHandle 或 node:fs 流內部的檔案描述符。dispose() 不會關閉它們,就像終止 worker 不會關閉它們一樣。開啟檔案描述符的程式碼應在圖被銷燬之前將其關閉。未關閉的 FileHandle 被垃圾回收時會關閉,並像在任何程式中一樣報告:Node 的 ERR_INVALID_STATE(“A FileHandle object was closed during garbage collection”)會作為未捕獲異常報告給行程。

原生外掛會共享,但它們的非同步工作也歸圖所有。Node-API 非同步工作項或執行緒安全函式會在建立它的程式碼所屬圖的上下文中完成。dispose() 之後,外掛的完成回撥仍會執行,因此可以釋放它分配的資源。但它不能呼叫 JavaScript:napi_call_function 等呼叫會傳回 napi_cannot_run_js,就像 worker 正在終止時一樣。它為已銷燬圖完成的 promise 會保持 pending 狀態。它為宿主或其他圖完成的 promise 則會正常完成。

執行緒安全函式只會在建立它的圖存續期間保持行程執行,無論是哪個指令碼請求的(napi_ref_threadsafe_function)。在該圖銷燬後獲取的引用會一直保持有效,直到它被釋放。它的呼叫始終會在建立它的圖的上下文中執行。如果外掛為所有人保留一個執行緒安全函式,並通過它呼叫你的 JavaScript 回撥,請先在宿主中載入該外掛,這樣該函式就屬於宿主。

銷燬

graph.dispose() 也會丟棄圖的模組登錄檔和 require.cache。仍被引用的圖程式碼會繼續工作,而仍在其上下文中執行的程式碼(例如它已排隊的 tick)發生錯誤時,仍會傳遞給 onError。從那時起:

  • graph.import()、graph.run() 和圖的 require() 會以 ERR_INVALID_STATE 失敗。
  • 宿主呼叫圖中某個函式時,該函式中的 import() 也會以相同方式失敗。
  • 已銷燬圖的遺留程式碼執行的 import() 會保持 pending 狀態。
  • 圖中尚未執行的模組將永遠不會執行。

dispose() 不會使任何 promise 完成。尚未完成的 import() 可能會被拒絕,也可能永遠無法完成:模組正在等待的資源(例如計時器或請求)已隨圖一起銷燬。可能會在匯入期間銷燬圖的宿主,應像下面所示那樣,對匯入操作進行競速處理,就像處理呼叫一樣。

呼叫圖中的程式碼

無論函式由哪個圖定義,它都會在呼叫者的上下文中執行。宿主直接呼叫的程式碼會在宿主的上下文中執行,而一個圖的程式碼呼叫另一個圖的函式時,該函式會在另一個圖的上下文中執行。使用 run() 進入圖自身的上下文:

TypeScript
app.start(); // 它開啟的資源歸宿主所有
graph.run(() => app.start()); // 它開啟的資源歸圖所有

run() 傳回的 promise 歸圖所有:如果圖在它完成之前被銷燬,它就永遠不會完成。當宿主等待圖的非同步函式,且期間可能銷燬該圖時,應將其與宿主自己的 promise 競速:

TypeScript
const disposed = Promise.withResolvers<never>();
const response = await Promise.race([graph.run(() => app.handle(request)), disposed.promise]);
// 其他地方:graph.dispose(); disposed.reject(new Error("tenant was disposed"));

Bun.ModuleGraph.current 是呼叫程式碼當前執行所在的圖;如果呼叫程式碼在宿主中執行,則其值為 undefined。通過 globals 由多個圖共享的宿主函式可以使用它來區分呼叫者。

因此,圖呼叫的宿主函式(例如通過 globals 傳入的函式)會在該圖的上下文中執行。它啟動的非同步工作歸該圖所有,並會在 dispose() 時隨圖一起被丟棄。對於無論呼叫者發生什麼情況都必須完成的工作(例如審計日誌或計費),請使用在宿主中獲取的快照離開圖的上下文:

TypeScript
import { AsyncLocalStorage } from "node:async_hooks";

const asHost = AsyncLocalStorage.snapshot(); // 在宿主中、任何圖之外獲取
const graph = new Bun.ModuleGraph({
  globals: { record: (entry: string) => asHost(() => appendToAuditLog(entry)) },
});

錯誤

未捕獲的異常和未處理的拒絕會傳遞給發生異常或拒絕的圖的 onError,而不是行程級處理程序。此上下文與擁有程式碼所開啟資源的上下文相同:圖的模組及其啟動的一切都會在圖的上下文中執行,宿主通過 run() 呼叫的程式碼也一樣。

丟擲錯誤的是誰並不重要,重要的是錯誤發生在哪裡。宿主直接呼叫圖的函式時,該函式會在宿主的上下文中執行,因此它之後丟擲的錯誤歸宿主所有;通過 run() 呼叫它,錯誤才會歸圖所有。圖的程式碼呼叫宿主函式時,該函式會在圖的上下文中執行,因此它在那裡丟擲的錯誤歸圖所有。

run() 會將函式丟擲的錯誤重新拋給呼叫者。如果呼叫者也沒有捕獲該錯誤,它仍歸圖所有:該錯誤是在圖的上下文中丟擲的,無論之後它經過了什麼呼叫棧。

沒有任何程式碼主動丟擲的錯誤歸發生錯誤的資源的所有者所有。圖開啟的套接字在沒有 error 處理程序的情況下發生錯誤時,會報告給圖的 onError。

graph.import() 會將 promise 傳回給呼叫者。如果模組在求值時丟擲錯誤,該 promise 會被拒絕;如果沒有人處理該 promise,它就會成為呼叫者未處理的拒絕,就像無法解析的說明符或對已銷燬圖執行 import() 一樣。

onError 本身會在建立該圖的上下文中執行:對於宿主建立的圖,該上下文是宿主;對於由某個圖的程式碼建立的圖,該上下文則是外層圖。它丟擲、拒絕或啟動的內容歸該上下文所有,因此處理程序中未捕獲的錯誤會繼續傳遞給宿主,而不會回到同一個處理程序。

沒有指定 onError 的圖會將錯誤交給建立它時所在上下文的圖的 onError,並依此向上直到宿主。某個圖的程式碼建立了該圖,或者該圖的程式碼呼叫的宿主函式建立了該圖時,該圖就是在此圖的上下文中建立的。

共享的內容

全域性物件、globalThis 屬性、外掛和原生外掛由所有圖和宿主共享。除非通過 globals 替換 process,否則 process 也會共享。圖在宿主擁有的物件上註冊的監聽器屬於該物件,因此在 dispose() 之後仍會存在。這包括共享 process 上的訊號、process.stdin 和 'exit' 監聽器,以及宿主傳入的事件發射器上的監聽器。ModuleGraph 用於相互隔離你信任的應用例項,但它不是安全沙箱。

http.globalAgent 和 https.globalAgent 也會共享。連線歸開啟它的 Agent 所有,而 Agent 歸建立它的指令碼所有,因此圖在沒有 agent 的情況下發出的請求會使用不屬於該圖的連線:dispose() 不會關閉它,該連線會像宿主的任何連線一樣使行程保持執行。希望連線隨圖一起銷燬的程式碼應傳入自己建立的 Agent。

bun test 中的 mock.module() 也是共享資源之一:它會影響圖之後載入的模組。已經載入該模組的圖會繼續保留已有例項。