Open20

Workerのmodulepreloadについて

bokkenbokken

はじめに

Workerのmodulepreloadの仕様化の状況と、WPTが通っているFirefoxの実装状況について調べてみる。

TL;DR

  • Early HintsのようにLinkヘッダによるmodulepreloadの指定は2024/01/23現在では未定義
  • Firefoxは↑の仕様を参考にして実装しているが、提案されている仕様にも不足がありそうに感じていそうなコメントをしている

はじまり

というポストと、Early HintsでWorkerで使うJSをmodulepreloadするWPTがFirefoxが通っているという事実ついて、 nhiroki さんから下記のようにコメントをもらったので、仕様化の状況と、Firefoxの実装が気になったので調べてみる。

https://x.com/nhiroki_/status/1747459220897927449?s=20

bokkenbokken

まずは、現状実装が難しいというコメントについて振り返る。発端となったのは下記のコメントだ。

Comment 3 by lingqi@google.com on Mon, Dec 5, 2022, 2:59 PM GMT+9 Project Member
I can reproduce this issue with the attached link.
Let me ask some questions based on my understanding:

  1. For a worker, does it respect the corresponding document's module map??
  2. The specification for modulepreload[1] says:
    The modulepreload keyword is a specialized alternative to the preload keyword, with a processing model geared toward preloading module scripts. In particular, it uses the specific fetch behavior for module scripts (including, e.g., a different interpretation of the crossorigin attribute), and places the result into the appropriate module map for later evaluation. In contrast, a similar external resource link using the preload keyword would place the result in the preload cache, without affecting the document's module map. Which one should be the "appropriate module map" in this case? I think it should be the document's?

[1] https://html.spec.whatwg.org/multipage/links.html#link-type-modulepreload
翻訳:
私の理解に基づいていくつか質問させてください:

  1. ワーカーは対応するドキュメントのモジュールマップを尊重しますか?
  2. modulepreload[1] の仕様にはこうあります:
    modulepreload キーワードは、モジュールスクリプトのプリロードに特化した処理モデルで、preload キーワードに代わるものです。特に、モジュールスクリプト特有のフェッチ動作(crossorigin属性の異なる解釈を含む)を使用し、後の評価のために結果を適切なモジュールマップに配置します。対照的に、preloadキーワードを使用した同様の外部リソースリンクは、ドキュメントのモジュールマップに影響を与えることなく、結果をpreloadキャッシュに置きます。この場合、「適切なモジュール・マップ」はどちらになるのでしょうか?文書のものであるべきではないでしょうか?

Documentと、Workerにはそれぞれmodule mapと呼ばれる、文字通り読み込むmoduleのmapが存在している。modulepreloadの仕様上は"appropriate module map"にmodulepreloadで読み込むmoduleを追加するようになっているが、"appropriate module map"は何か?という質問がある。

これに対して、nhiroki さんは下記のように回答している。

Comment 4 by nhiroki@chromium.org on Mon, Dec 5, 2022, 3:17 PM GMT+9 Project Member

I think this is working as intended for now. To support workers, we need more clarifications in the HTML spec side.

#c3:

Regarding 1, each worker has its own module map. It's isolated from their owner document's module map:
https://html.spec.whatwg.org/multipage/workers.html#the-workerglobalscope-common-interface:module-map

Regarding 2, yes, that should be the document's module map. The module map is associated with WorkerGlobalScope, so in the current spec, we cannot populate the worker's module map with preloaded scripts before creating a worker:
https://html.spec.whatwg.org/multipage/webappapis.html#concept-settings-object-module-map

翻訳:
今のところ、意図したとおりに機能していると思う。ワーカーをサポートするためには、HTML仕様側でもっと明確化する必要がある。
#c3:
1について、ワーカーはそれぞれ独自のモジュールマップを持っています。オーナードキュメントのモジュールマップとは分離されています:
https://html.spec.whatwg.org/multipage/workers.html#the-workerglobalscope-common-interface:module-map
2について、そうです、それは文書のモジュールマップであるべきです。モジュールマップは WorkerGlobalScope に関連付けられているので、現在の仕様では、ワーカーを作成する前にワーカーのモジュールマップにあらかじめロードされたスクリプトを入れることはできません:
https://html.spec.whatwg.org/multipage/webappapis.html#concept-settings-object-module-map

つまり、modulepreloadで読み込むモジュールについては、Documentのmodule mapに保持されるはずだと。なぜなら、modulepreloadのタイミングではまだWorkerが作成されていないので、Workerのmodule mapが無いから。

また、documentのmodule mapからWorkerのmodule mapにpopulate(追加/投入)することはできない(=仕様にはない)。

そして、それは現在のHTMLの仕様上そうなっているとのことだった。

bokkenbokken

たしかに、Worker以前にEarly Hintsとmodulepreloadについてのissueがまだopenな状態だ。

https://github.com/whatwg/html/issues/7854

スペックを修正するPRもある程度レビューが進んでいるものの途中で止まっているようだ。

https://github.com/whatwg/html/pull/7862

こちらも関連issueのようだ。
https://github.com/whatwg/html/issues/9274

bokkenbokken

現状例にはあるがフェッチが2回走っている。

Linkヘッダによるmodulepreloadかlink要素によるmodulepreloadかによらずに未対応である。

bokkenbokken

現状の処理がどうなっているのか追ってみる。

今回のWPTがEarly Hintsから始まっているので、Early Hintsの処理を追いかける。

具体的なレスポンスヘッダの処理はTo process early hint headersから。

To process early hint headers

順番に考えていく。まずは最初の文とNote。

To process early hint headers given a response response and an environment reservedEnvironment:

Note: Early-hint Link headers are always processed before Link headers from the final response, followed by link elements. This is equivalent to prepending the contents of the early and final Link headers to the Document's head element, in respective order.

Early HintのLinkヘッダは、最終レスポンスのLinkよりも前に処理されて、link要素がそれに続く形。
それぞれ、下記と同じような状態。

<head>
    <link rel="modulepreload" href="early-hints-link-header-modulepreload.js" />
    <link rel="modulepreload" href="final-response-link-header-modulepreload.js" />
    <link rel="modulepreload" href="normal-link-element-modulepreload.js" />
</head>
  1. Let earlyPolicyContainer be the result of creating a policy container from a fetch response given response and reservedEnvironment.
  • Note: This allows the early hint response to include a Content Security Policy which would be enforced when fetching the early hint request.
  1. Let links be the result of extracting links from response's header list.
  2. Let earlyHints be an empty list.
  3. For each linkObject in links:
    • Note: The moment we receive the early hint link header, we begin fetching earlyRequest. If it comes back before the Document is created, we set earlyResponse to the response of that fetch and once the Document is created we commit it (by making it available in the map of preloaded resources as if it was a link element). If the Document is created first, the response is committed as soon as it becomes available.
    1. Let rel be linkObject["relation_type"].
    2. Let options be a new link processing options with
    3. Let attribs be linkObject["target_attributes"].
      • Note: Only the as, crossorigin, integrity, and type attributes are handled as part of early hint processing. The other ones, in particular blocking, imagesrcset, imagesizes, and media are only applicable once a Document is created.
    4. Apply link options from parsed header attributes to options given attribs.
    5. Run the process a link header steps for rel given options.
    6. Append options to earlyHints.

ここで大事なのはStep4かな。Step4では、Linkヘッダを抽出して、process a link headerに進む。オプションはearlyHintsにappendする。

次にprocess a link headerの処理は次のようになっている。

4.2.4.4 Processing Link headers
All link types that can be external resource links define a process a link header algorithm, which takes a link processing options. This algorithm defines whether and how they react to appearing in an HTTP Link response header.
Note: For most link types, this algorithm does nothing. The summary table is a good reference to quickly know whether a link type has defined process a link header steps.

ここで、だいたいのlink typesでは、ここのalgorithmでは何もしないとある。summary tableにアルゴリズムがあるかどうかが書かれているので下記に抜粋すると

Has Link processingが-なので特に何もしない?かと思ったが、4.6.7.11 Link type "modulepreload"にアルゴリズムが記載されているので、これを参照するべきだと思われる。長くなってきたので、これは次のコメントで詳細を追っていくことにする。 (【追記】これは、link要素のtypeの話かも。別のcommitでLinkヘッダでpreloadとpreconnectがきたときのhas Link processingをyesにしている)
(【更に追記】"The process a link header steps for this type of linked resource are to do nothing." という記載がmodulepreloadの場所にあったので、やっぱり何もしないっぽい)

あと、気になることとしては、Brief descriptionには

fetch the module script and store it in the document's module map

とあるので、module scriptはdocumentのmodule mapに保存するようだ。なので、ここでmodulescriptはDocumentのmodule mapに保存されることしか現状は想定されていないことが伺える

To process early hint headers の最後のステップに戻る

最後に To process early hint headers の Step5 に戻ってくるが、特に今回のことに関係ありそうな処理はしていなさそうだ。

  1. Return the following substeps given Document doc: for each options in earlyHints:
    1. If options's on document ready is null, then set options's document to docs.
    2. Otherwise, call options's on document ready with doc.
bokkenbokken

"has Link processing"がYesなpreconectは下記のような記載がある。

The process a link header step for this type of linked resource given a link processing options options are to preconnect given options.
To preconnect given a link processing options options:

  1. If options's href is an empty string, returns.
    ...

modulepreloadは下記だった模様。

The process a link header steps for this type of linked resource are to do nothing.

ちなみにpreloadは次のようになっていて、

The process a link header step for this type of link given a link processing options options is to preload options.

https://html.spec.whatwg.org/multipage/links.html#preload のpreloadのアルゴリズムを実行する形になりそう。

bokkenbokken

つまり、現状ではmodulepreloadをLinkヘッダに指定したときに処理する仕様は未定義。ということが分かった。

WPTではEarly HintsでLinkヘッダでmodulepreloadを指定していたので、現状未定義なので落ちていても仕方ない。

「Linkヘッダで指定されたmodulepreloadを処理する仕様が未定義」という問題の他に、そもそも普通にlink要素にWorkerで使うmoduleを指定しても、Workerでは使えないよねという問題もある。これについては↓以降の、普通にmodulepreloadを処理したときの仕様を追えば良さそう。

bokkenbokken

【追記】ここからは、仮にmodulepreloadがlink要素として挿入存在しているときのことを考える。

では 4.6.7.11 Link type "modulepreload" で具体的な処理の内容を見ていく。

ここからはコメント#3でも引用されていたところだ。

The modulepreload keyword may be used with link elements. This keyword creates an external resource link. This keyword is body-ok.

The modulepreload keyword is a specialized alternative to the preload keyword, with a processing model geared toward preloading module scripts. In particular, it uses the specific fetch behavior for module scripts (including, e.g., a different interpretation of the crossorigin attribute), and places the result into the appropriate module map for later evaluation. In contrast, a similar external resource link using the preload keyword would place the result in the preload cache, without affecting the document's module map.

Additionally, implementations can take advantage of the fact that module scripts declare their dependencies in order to fetch the specified module's dependency as well. This is intended as an optimization opportunity, since the user agent knows that, in all likelihood, those dependencies will also be needed later. It will not generally be observable without using technology such as service workers, or monitoring on the server side. Notably, the appropriate load or error events will occur after the specified module is fetched, and will not wait for any dependencies.

特筆すべきは引用されていた

places the result into the appropriate module map for later evaluation. In contrast, a similar external resource link using the preload keyword would place the result in the preload cache, without affecting the document's module map.

のあたりだろうか。やはり、適切なmodule map(ここではDocument)にロードされるようだ。一方でpreloadの場合はmodule mapではなく、preload cacheに行くので少し勝手が違う。

一旦、このまま読み進める。

The appropriate times to fetch and process the linked resource for such a link are:

fetch and process the linked resourceする適切な時間は次のようなときだ、

  • When the external resource link is created on a link element that is already browsing-context connected.
  • When the external resource link's link element becomes browsing-context connected.
  • When the href attribute of the link element of an external resource link that is already browsing-context connected is changed.
  • external resource linkがすでにブラウジングコンテキストに接続されているlink要素上に作られたとき
  • external resource linkのlink要素がブラウジングコンテキストに接続されたとき
  • すでに接続されているexternal resource linkのlink要素のhref attributeが変更されたとき

一旦、普通にlink要素がある前提なので、link要素はブラウジングコンテキストに接続されていると考えて良さそう。なので、

"The fetch and process the linked resource algorithm for modulepreload links, given a link element el, is as follows:" 以降を見ていく。

bokkenbokken

The fetch and process the linked resource algorithm for modulepreload links, given a link element el, is as follows:

  1. If el's href attribute's value is the empty string, then return.
  2. Let destination be the current state of el's as attribute (a destination), or "script" if it is in no state.
  3. If destination is not script-like, then queue an element task on the networking task source given el to fire an event named error at el, and return.
  4. Let url be the result of encoding-parsing a URL given el's href attribute's value, relative to el's node document.
  5. If url is failure, then return.
  6. Let settings object be el's node document's relevant settings object.
  7. Let credentials mode be the CORS settings attribute credentials mode for el's crossorigin attribute.
  8. Let cryptographic nonce be el.[[CryptographicNonce]].
  9. Let integrity metadata be the value of el's integrity attribute, if it is specified, or the empty string otherwise.
  10. Let referrer policy be the current state of el's referrerpolicy attribute.
  11. Let fetch priority be the current state of el's fetchpriority attribute.
  12. Let options be a script fetch options whose cryptographic nonce is cryptographic nonce, integrity metadata is integrity metadata, parser metadata is "not-parser-inserted", credentials mode is credentials mode, referrer policy is referrer policy, and fetch priority is fetch priority.
  13. Fetch a modulepreload module script graph given url, destination, settings object, options, and with the following steps given result:
  14. If result is null, then fire an event named error at el, and return.
  15. Fire an event named load at el.

基本的には前処理のような感じなので、関係ありそうなのは13の部分っぽいので、この先を見ていく。

Fetch a modulepreload module script graph given url, destination, settings object, options, and with the following steps given result:

bokkenbokken

To fetch a modulepreload module script graph

To fetch a modulepreload module script graph given a URL url, a destination destination, an environment settings object settingsObject, a script fetch options options, and an algorithm onComplete, run these steps. onComplete must be an algorithm accepting null (on failure) or a module script (on success).

  1. Disallow further import maps given settingsObject.
  2. Fetch a single module script given url, settingsObject, destination, options, settingsObject, "client", true, and with the following steps given result:
  3. Run onComplete given result.
  4. If result is not null, optionally fetch the descendants of and link result given settingsObject, destination, and an empty algorithm.

Note: Generally, performing this step will be beneficial for performance, as it allows pre-loading the modules that will invariably be requested later, via algorithms such as fetch an external module script graph that fetch the entire graph. However, user agents might wish to skip them in bandwidth-constrained situations, or situations where the relevant fetches are already in flight.

ここのStep1でDisallow further import mapsで使われるgiven settingsObjectは、一つ前のstep6で設定されていた"Let settings object be el's node document's relevant settings object."なので、Documentっぽい。

一旦このまま、Step2の"Fetch a single module script"に進む。

bokkenbokken

To fetch a single module script

To fetch a single module script, given a URL url, an environment settings object fetchClient, a destination destination, a script fetch options options, an environment settings object settingsObject, a referrer referrer, an optional ModuleRequest Record moduleRequest, a boolean isTopLevel, an algorithm onComplete, and an optional perform the fetch hook performFetch, run these steps. onComplete must be an algorithm accepting null (on failure) or a module script (on success).

  1. Let moduleType be "javascript".
  2. If moduleRequest was given, then set moduleType to the result of running the module type from module request steps given moduleRequest.
  3. Assert: the result of running the module type allowed steps given moduleType and settingsObject is true. Otherwise we would not have reached this point because a failure would have been raised when inspecting moduleRequest.[[Attributes]] in create a JavaScript module script or fetch a single imported module script.
  4. Let moduleMap be settingsObject's module map.

ここでsettingsObject's module mapのmodule mapを使う形になっていて、これはsetingsObjectがDocumentなので、Documentのmodule mapが以降で使われる。

  1. If moduleMap[(url, moduleType)] is "fetching", wait in parallel until that entry's value changes, then queue a task on the networking task source to proceed with running the following steps.
  2. If moduleMap[(url, moduleType)] exists, run onComplete given moduleMap[(url, moduleType)], and return.
  3. Set moduleMap[(url, moduleType)] to "fetching".
  4. Let request be a new request whose URL is url, mode is "cors", referrer is referrer, and client is fetchClient.
  5. Set request's destination to the result of running the fetch destination from module type steps given destination and moduleType.
  6. If destination is "worker", "sharedworker", or "serviceworker", and isTopLevel is true, then set request's mode to "same-origin".

ここで一応modeについては、各種workerのときのケースも考慮はされていそう?

  1. Set request's initiator type to "script".

  2. Set up the module script request given request and options.

  3. If performFetch was given, run performFetch with request, isTopLevel, and with processResponseConsumeBody as defined below.

    Otherwise, fetch request with processResponseConsumeBody set to processResponseConsumeBody as defined below.
    In both cases, let processResponseConsumeBody given response response and null, failure, or a byte sequence bodyBytes be the following algorithm:
    Note: response is always CORS-same-origin.

    1. If any of the following are true:

      • bodyBytes is null or failure; or
      • response's status is not an ok status,

      then set moduleMap[(url, moduleType)] to null, run onComplete given null, and abort these steps.

    2. Let sourceText be the result of UTF-8 decoding bodyBytes.

    3. Let mimeType be the result of extracting a MIME type from response's header list.

    4. Let moduleScript be null.

    5. Let referrerPolicy be the result of parsing the Referrer-Policy header given response. [REFERRERPOLICY]

    6. If referrerPolicy is not the empty string, set options's referrer policy to referrerPolicy.

    7. If mimeType is a JavaScript MIME type and moduleType is "javascript", then set moduleScript to the result of creating a JavaScript module script given sourceText, settingsObject, response's URL, and options.

    8. If the MIME type essence of mimeType is "text/css" and moduleType is "css", then set moduleScript to the result of creating a CSS module script given sourceText and settingsObject.

    9. If mimeType is a JSON MIME type and moduleType is "json", then set moduleScript to the result of creating a JSON module script given sourceText and settingsObject.

    10. Set moduleMap[(url, moduleType)] to moduleScript, and run onComplete given moduleScript.

    Note: It is intentional that the module map is keyed by the request URL, whereas the base URL for the module script is set to the response URL. The former is used to deduplicate fetches, while the latter is used for URL resolution.

Step10の "Set moduleMap[(url, moduleType)] to moduleScript, and run onComplete given moduleScript." では、Documentのmodule mapに設定されていそう。

bokkenbokken

ここまでで一旦、module mapはDocumentに設定されるというのはその通り。

では、workerはいつ作られて、どうやってscriptを処理するのか。

bokkenbokken

多分関係していそうな10. Web workersについて読んでいく。

Web workersのmodule mapの定義はここ

10.1.3.1 Creating a dedicated workerで、Workerを作成する例がある。

10.2.1.1 The WorkerGlobalScope common interfaceには、module mapについては特に触れられていないが、owner setは下記のように作られたときにpopulatedされるとある。

A WorkerGlobalScope object has an associated owner set (a set of Document and WorkerGlobalScope objects). It is initially empty and populated when the worker is created or obtained.

おそらくこういった、処理がmodulepreloadでフェッチした際には必要になってくると思われる。

A WorkerGlobalScope object has an associated module map. It is a module map, initially empty.

module mapは現状、最初は空という記述のみ。

そして、このWorkerGlobalScope派生のobjectが作られるのは10.2.4 Processing modelのStep5。この後もとくにmodule mapをmodulepreloadしたscriptをpopulateするといった記述はなさそう。

When a user agent is to run a worker for a script with Worker or SharedWorker object worker, URL url, environment settings object outside settings, MessagePort outside port, and a WorkerOptions dictionary options, it must run the following steps.

  1. Let is shared be true if worker is a SharedWorker object, and false otherwise.
  2. Let owner be the relevant owner to add given outside settings.
  3. Let unsafeWorkerCreationTime be the unsafe shared current time.
  4. Let agent be the result of obtaining a dedicated/shared worker agent given outside settings and is shared. Run the rest of these steps in that agent.
  5. Let realm execution context be the result of creating a new realm given agent and the following customizations:
    • For the global object, if is shared is true, create a new SharedWorkerGlobalScope object. Otherwise, create a new DedicatedWorkerGlobalScope object.
  6. Let worker global scope be the global object of realm execution context's Realm component.

なので、現状はmodulepreloadをWorkerで利用するときの仕様は未定義っぽい。

bokkenbokken

ではFirefoxはどのような実装で実現しているのか?調べていく。

Firefoxの機能実装はここ: https://phabricator.services.mozilla.com/D180020
GitHub版はこちら: https://github.com/mozilla/gecko-dev/commit/b6b6fe577b87687a39b91e70201bb75e09b0c8e9

下記のコメントを見るに仕様への理解については提案されているものを参考に作られている様子。

Sorry for the delay.
From what I can tell from the proposed spec, the destination doesn't enforce what ends up using the > module since all modules requested by a document end up in the same module map. The only effects the "as" values have are on the request's mode or erroring the preload if it is not script-like.
--- https://phabricator.services.mozilla.com/D180020#5995558

ここで述べられているproposed specは https://github.com/whatwg/html/pull/7862 のことで、2024年1月27日現在ではmergeされていないのと、rebaseして作り直してくれない?という話が出ている。

@domenic commented on Jul 24, 2023
@noamr would you be up for rebasing or recreating this? Sorry we let it fall through the cracks. The script-fetching stuff should be much nicer these days though.

bokkenbokken

Firefoxがどういう挙動をしているのか、コードを追ってもう少し調べてみる。

bokkenbokken

今更だが、modulepreloadの処理ステップは次のようになっているようだ。

https://docs.google.com/document/d/1WebH4IOCswACUbaczx5cGQPVl5mnqcieOd4MRJM2syk/edit

bokkenbokken
  • compileのステップで何してるのか調べる
  • module map populationまでのステップでは起点になるscriptを読んでいて(without recursive fetch)、fetchdepsは依存関係にあるscriptを再帰的にfetchしてる(recursive fetch)ということなのかな
  • ちゃんと理解するにはV8の挙動を理解する必要があるのかも(TODO)
bokkenbokken

忘れないように現状メモ:

現状、Workerのスクリプトのmodulepreloadのときにmodule mapのpopulationに関する仕様がない。
現状ではDocumentに紐づいてしまう。HTML仕様的にはmodule mapはper Document or workerなので、それぞれ必要としている主体のmodule mapに保存されるべき。

https://github.com/whatwg/html/pull/7862 では、Early Hintsを使ってmodulepreloadを使えるようにする仕様の整理が行われている。これは、Early Hintsであってもlink要素のどちらをトリガーにしてもmodule preloadが使えるようにされている。

しかし、このPRでは、module mapのpopulationに関する変更はない。

  • https://github.com/whatwg/html/pull/7862 で Early Hintsの際のmodulepreload時の挙動を追加する際にmodule mapのpopulationの仕様を明確化する
  • Early Hintsに関する変更はおいておいてmodule mapのpopulationの仕様を明確化する
    • そうするとlink要素を使ったときのmodulepreloadは仕様上整理されたといえる
    • Early Hints (link HTTPヘッダ)を使ったときの挙動は未定義当状態になる

ということ。