Skip to main content

turbopack_browser/ecmascript/
worker.rs

1use std::io::Write;
2
3use anyhow::Result;
4use indoc::writedoc;
5use turbo_rcstr::{RcStr, rcstr};
6use turbo_tasks::{ResolvedVc, ValueToString, Vc};
7use turbo_tasks_fs::{File, FileContent, FileSystemPath};
8use turbo_tasks_hash::hash_xxh3_hash64;
9use turbopack_core::{
10    asset::{Asset, AssetContent},
11    chunk::{ChunkingContext, MinifyType},
12    code_builder::{Code, CodeBuilder},
13    ident::AssetIdent,
14    output::{OutputAsset, OutputAssetsReference, OutputAssetsWithReferenced},
15    source_map::{GenerateSourceMap, SourceMapAsset},
16};
17use turbopack_ecmascript::minify::minify;
18
19use crate::BrowserChunkingContext;
20
21/// A pre-compiled worker entrypoint that bootstraps workers by reading config from URL params.
22///
23/// The worker receives a JSON array via URL params of the following structure:
24/// `[TURBOPACK_NEXT_CHUNK_URLS, ASSET_SUFFIX, WORKER_CHUNK_BASE_PATH, ...forwarded_global_values]`
25#[turbo_tasks::value(shared)]
26#[derive(ValueToString)]
27#[value_to_string("Ecmascript Browser Worker Entrypoint")]
28pub struct EcmascriptBrowserWorkerEntrypoint {
29    chunking_context: ResolvedVc<Box<dyn ChunkingContext>>,
30    /// Global variable names to forward from main thread to worker.
31    /// These are assigned to `self` in the worker scope before loading chunks.
32    /// Values are passed via URL params at indices 2+.
33    forwarded_globals: ResolvedVc<Vec<RcStr>>,
34}
35
36#[turbo_tasks::value_impl]
37impl EcmascriptBrowserWorkerEntrypoint {
38    #[turbo_tasks::function]
39    pub async fn new(
40        chunking_context: ResolvedVc<Box<dyn ChunkingContext>>,
41        forwarded_globals: Vc<Vec<RcStr>>,
42    ) -> Result<Vc<Self>> {
43        Ok(EcmascriptBrowserWorkerEntrypoint {
44            chunking_context,
45            forwarded_globals: forwarded_globals.to_resolved().await?,
46        }
47        .cell())
48    }
49
50    #[turbo_tasks::function]
51    async fn code(self: Vc<Self>) -> Result<Vc<Code>> {
52        let this = self.await?;
53
54        let source_maps = *this
55            .chunking_context
56            .reference_chunk_source_maps(Vc::upcast(self))
57            .await?;
58
59        let forwarded_globals = this.forwarded_globals.await?;
60        // The shared-runtime worker bootstrap loads a dedicated last `runtime.js`; without it the
61        // runtime is inlined into the module/evaluate chunks, so load the chunk list as-is.
62        let shared_runtime =
63            match ResolvedVc::try_downcast_type::<BrowserChunkingContext>(this.chunking_context) {
64                Some(browser_chunking_context) => {
65                    *browser_chunking_context.shared_runtime().await?
66                }
67                None => false,
68            };
69        let mut code = generate_worker_bootstrap_code(&forwarded_globals, shared_runtime)?;
70
71        if let MinifyType::Minify { mangle } = *this.chunking_context.minify_type().await? {
72            code = minify(code, source_maps, mangle)?;
73        }
74
75        Ok(code.cell())
76    }
77
78    #[turbo_tasks::function]
79    async fn ident_for_path(&self) -> Result<Vc<AssetIdent>> {
80        let chunk_root_path = self.chunking_context.chunk_root_path().owned().await?;
81        let forwarded_globals = self.forwarded_globals.await?;
82        let globals_hash = hash_xxh3_hash64(&*forwarded_globals);
83        let ident = AssetIdent::from_path(chunk_root_path)
84            .with_modifier(rcstr!("turbopack worker entrypoint"))
85            .with_modifier(format!("{globals_hash:08x}").into());
86        Ok(ident.into_vc())
87    }
88
89    #[turbo_tasks::function]
90    async fn source_map(self: Vc<Self>) -> Result<Vc<SourceMapAsset>> {
91        let this = self.await?;
92        Ok(SourceMapAsset::new(
93            *this.chunking_context,
94            self.ident_for_path(),
95            Vc::upcast(self),
96        ))
97    }
98}
99
100#[turbo_tasks::value_impl]
101impl OutputAssetsReference for EcmascriptBrowserWorkerEntrypoint {
102    #[turbo_tasks::function]
103    async fn references(self: Vc<Self>) -> Result<Vc<OutputAssetsWithReferenced>> {
104        Ok(OutputAssetsWithReferenced::from_assets(Vc::cell(vec![
105            ResolvedVc::upcast(self.source_map().to_resolved().await?),
106        ])))
107    }
108}
109
110#[turbo_tasks::value_impl]
111impl OutputAsset for EcmascriptBrowserWorkerEntrypoint {
112    #[turbo_tasks::function]
113    async fn path(self: Vc<Self>) -> Result<Vc<FileSystemPath>> {
114        let this = self.await?;
115        let ident = self.ident_for_path();
116        Ok(this.chunking_context.chunk_path(
117            Some(Vc::upcast(self)),
118            ident,
119            Some(rcstr!("turbopack-worker")),
120            rcstr!(".js"),
121        ))
122    }
123}
124
125#[turbo_tasks::value_impl]
126impl Asset for EcmascriptBrowserWorkerEntrypoint {
127    #[turbo_tasks::function]
128    async fn content(self: Vc<Self>) -> Result<Vc<AssetContent>> {
129        Ok(AssetContent::file(
130            FileContent::Content(File::from(
131                self.code()
132                    .to_rope_with_magic_comments(|| self.source_map())
133                    .await?,
134            ))
135            .cell(),
136        ))
137    }
138}
139
140#[turbo_tasks::value_impl]
141impl GenerateSourceMap for EcmascriptBrowserWorkerEntrypoint {
142    #[turbo_tasks::function]
143    fn generate_source_map(self: Vc<Self>) -> Vc<FileContent> {
144        self.code().generate_source_map()
145    }
146}
147
148/// Generates the worker bootstrap code as inline JavaScript.
149///
150/// The worker receives a JSON array via URL params of the following structure:
151/// `[TURBOPACK_NEXT_CHUNK_URLS, ASSET_SUFFIX, WORKER_CHUNK_BASE_PATH, ...forwarded_global_values]`
152fn generate_worker_bootstrap_code(
153    forwarded_globals: &[RcStr],
154    shared_runtime: bool,
155) -> Result<Code> {
156    let mut code: CodeBuilder = CodeBuilder::default();
157
158    // Generate the Object.assign properties for forwarded globals
159    // params[0] = chunk URLs, params[1] = ASSET_SUFFIX,
160    // params[2] = WORKER_CHUNK_BASE_PATH, params[3+] = forwarded globals
161    let mut global_assignments = vec![
162        "TURBOPACK_NEXT_CHUNK_URLS: chunkUrls".to_string(),
163        "TURBOPACK_ASSET_SUFFIX: param(1)".to_string(),
164        "TURBOPACK_CHUNK_BASE_PATH: param(2)".to_string(),
165    ];
166    for (i, name) in forwarded_globals.iter().enumerate() {
167        // Forwarded globals start at params[3]
168        global_assignments.push(format!("{name}: param({n})", n = i + 3));
169    }
170    let globals_js = global_assignments.join(",\n    ");
171
172    // This code is slightly paranoid to avoid being useful as an XSS gadget.
173    //
174    // First, it verifies that it is running in a worker environment, which
175    // guarantees that the requestor shares the same origin as the script
176    // itself.
177    //
178    // Additionally, the code only allows loading scripts from the same origin,
179    // mitigating the risk that the worker could be exploited to fetch or run
180    // scripts from cross-origin sources.
181    //
182    // The snippet also validates types for all parameters to prevent unexpected
183    // usage.
184
185    // Common preamble: validate the worker context and parse the URL params.
186    writedoc!(
187        code,
188        r##"
189        (function() {{
190        function abort(message) {{
191            console.error(message);
192            throw new Error(message);
193        }}
194        if (
195            typeof self["WorkerGlobalScope"] === "undefined" ||
196            !(self instanceof self["WorkerGlobalScope"])
197        ) {{
198            abort("Worker entrypoint must be loaded in a worker context");
199        }}
200
201        // Try querystring first (SharedWorker), then hash (regular Worker)
202        var url = new URL(location.href);
203        var paramsString = url.searchParams.get("params");
204        if (!paramsString && url.hash.startsWith("#params=")) {{
205            paramsString = decodeURIComponent(url.hash.slice("#params=".length));
206        }}
207
208        if (!paramsString) abort("Missing worker bootstrap config");
209
210        var params = JSON.parse(paramsString);
211        var param = (n) => typeof params[n] === 'string' ? params[n] : '';
212        var chunkUrls = Array.isArray(params[0]) ? params[0] : [];
213        "##,
214    )?;
215
216    if shared_runtime {
217        // With a shared runtime the runtime is a separate last chunk. Pull it off the front (the
218        // list is reversed by `createWorker`) so we can load it after the module chunks below.
219        writedoc!(
220            code,
221            r##"
222
223            // Chunks are relative to the origin; only allow loading same-origin scripts.
224            function sameOriginUrl(chunk) {{
225                var chunkUrl = new URL(chunk, location.origin);
226                if (chunkUrl.origin !== location.origin) {{
227                    abort("Refusing to load script from foreign origin: " + chunkUrl.origin);
228                }}
229                return chunkUrl.toString();
230            }}
231
232            // The Turbopack runtime is the last asset emitted by (see
233            // `BrowserChunkingContext::evaluated_chunk_group`). `createWorker`
234            // reverses the chunk list, so it is the first item in `chunkUrls`.
235            var runtimeUrl = chunkUrls.length > 0 ? chunkUrls.shift() : undefined;
236            "##,
237        )?;
238    }
239
240    writedoc!(
241        code,
242        r##"
243
244        Object.assign(self, {{
245            {0}
246        }});
247        "##,
248        globals_js
249    )?;
250
251    if shared_runtime {
252        writedoc!(
253            code,
254            r##"
255
256            if (chunkUrls.length > 0 || runtimeUrl) {{
257                var scriptsToLoad = [];
258                for (var i = 0; i < chunkUrls.length; i++) {{
259                    scriptsToLoad.push(sameOriginUrl(chunkUrls[i]));
260                }}
261
262                // As scripts are loaded, allow them to pop from the array
263                chunkUrls.reverse();
264
265                // Load the runtime last so it drains the registrations enqueued above.
266                if (runtimeUrl) {{
267                    scriptsToLoad.push(sameOriginUrl(runtimeUrl));
268                }}
269
270                importScripts.apply(self, scriptsToLoad);
271            }}
272            }})();
273            "##,
274        )?;
275    } else {
276        writedoc!(
277            code,
278            r##"
279
280            if (chunkUrls.length > 0) {{
281                var scriptsToLoad = [];
282                for (var i = 0; i < chunkUrls.length; i++) {{
283                    var chunk = chunkUrls[i];
284                    // Chunks are relative to the origin.
285                    var chunkUrl = new URL(chunk, location.origin);
286                    if (chunkUrl.origin !== location.origin) {{
287                        abort("Refusing to load script from foreign origin: " + chunkUrl.origin);
288                    }}
289                    scriptsToLoad.push(chunkUrl.toString());
290                }}
291
292                // As scripts are loaded, allow them to pop from the array
293                chunkUrls.reverse();
294                importScripts.apply(self, scriptsToLoad);
295            }}
296            }})();
297            "##,
298        )?;
299    }
300
301    Ok(code.build())
302}