From 49699fe6efdd03f64c33fe4268e023532342c060 Mon Sep 17 00:00:00 2001 From: DemonMartin Date: Tue, 28 Jul 2026 12:30:09 +0000 Subject: [PATCH] fix: unblock route handlers, frame lookup, and worker/persona integrity Six defects found while driving a hardened Chromium build through rustwright. Each was reproduced first, then fixed: evaluate: `looks_like_function` searched the whole source for `=>` and accepted the expression when the text before it started with `(`. Every IIFE starts with `(`, so one containing an arrow anywhere in its body was read as a function literal, wrapped as `const __rw_fn = ()` and called again -- the IIFE had already produced its value, so the call failed with "__rw_fn is not a function". The head of the expression is now parsed instead: the arrow must bind the leading parameter list, and the scan skips string literals, template literals, comments and regular expressions so `(a = ")") => a` and `(p = /[)]/) => p` still resolve. Keyword prefixes no longer match, so `functionality()` is not a function literal. frame tree: `refresh_page_frame_tree` gave every session the caller's entire remaining deadline for what the function itself treats as optional -- a session that does not answer is skipped and the cached tree returned anyway. A renderer does not answer `Page.getFrameTree` while a Fetch interception is paused on its document request, which is exactly the state a route handler runs in, so reading `Request.frame` there burned one full deadline per session and stalled the navigation that request belonged to. With the timeout disabled, where `command_timeout` maps 0 to 24 hours, it never returned. The refresh is now capped independently of the caller's budget, and the Python shim no longer re-issues the same command over a raw session at `CDPSession.send`'s fixed 30s once per frame walked. workers: dedicated workers were given their identity by replacing the page's `Worker` constructor with one that builds a blob wrapping `importScripts(url)`. That moves every worker off its own script URL, changing its `location` and origin, and breaks pages whose workers rely on either. Dedicated workers are now auto-attached like service workers already were, and the identity script is installed over the worker's own session while it is paused, so the worker keeps its real URL. Verified: a worker's `navigator.userAgent`, `platform` and `language` still match the page, and `location.href` is now the script's own URL rather than a `blob:`. stealth locale: the user-agent override pinned `acceptLanguage` to `en-US,en`, overriding `--accept-lang`/`--lang`. A browser launched for one region reported that region's timezone next to an `en-US` locale. Omitting the field leaves the browser's own value, which keeps the documented default for a stock launch (verified `en-US,en;q=0.9`) while honouring an explicit one. stealth webdriver: the init script removed `navigator.webdriver` from browsers already reporting `false` -- the value every real Chrome reports -- trading a correct answer for a missing property. It now guards only that block, so the rest of the script still runs on an ordinary launch, where the default args already clear the automation flag. frame enumeration: child frames came from a `querySelectorAll('iframe,frame')` result, with the protocol's children used only to enrich it by index. A frame inside a shadow root is invisible to that query while being an ordinary child of the tree, so it was dropped entirely, and pairing the two lists by position gave a light-DOM frame the identity of a shadow frame ahead of it. The protocol now decides which frames exist and the two are correlated by identity; the DOM query still orders and names them. This adds shadow DOM support to `page.frames` and `frame.child_frames`. Adds thirteen Rust unit tests covering the expression classifier, the refresh budget, and the scope of the webdriver guard. --- CHANGELOG.md | 7 + python/rustwright/sync_api.py | 51 +++- src/lib.rs | 477 ++++++++++++++++++++++++---------- 3 files changed, 396 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3952bc..b5bcb8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable user-facing changes to Rustwright are documented in this file. - Added native pending-file-chooser events and mirror-profile MCP file upload and cancellation with workspace-confined paths. - Added native physical element dragging and the mirror-profile `browser_drag` MCP tool. - Added alpha bindings for Go, Java, C#/.NET, Ruby, and PHP, plus a native Rust API, backed by the shared Rust engine. +- Added shadow DOM support to frame enumeration: iframes attached inside a shadow root now appear in `page.frames` and `frame.child_frames`, so widgets that mount their cross-origin iframe that way (Cloudflare Turnstile, hCaptcha, and embedded payment fields among them) are reachable. ### Changed @@ -26,6 +27,12 @@ All notable user-facing changes to Rustwright are documented in this file. ### Fixed +- Fixed `page.evaluate()` treating an already-invoked IIFE as a function literal when its body contained an arrow function anywhere, which wrapped and re-called the value it had returned and failed with `__rw_fn is not a function`. +- Fixed the best-effort frame-tree refresh spending the caller's whole timeout per session, which made `Request.frame` inside a route handler stall the navigation it belonged to until that timeout expired, and hang indefinitely when the timeout was disabled. +- Fixed the stealth user-agent override pinning `Accept-Language` to `en-US,en`, which silently overrode `--accept-lang`/`--lang` and left a browser configured for one region reporting that region's timezone alongside an `en-US` locale. +- Fixed dedicated workers being given their identity by rewriting the page's `Worker` constructor to load a generated blob that `importScripts` the real script, which moved every worker off its own URL and changed its `location` and origin; worker identity is now installed over the worker's own CDP session, as it already was for service workers, and the worker keeps its real script URL. +- Fixed the stealth init script removing `navigator.webdriver` from browsers that already report `false`, replacing a value every real Chrome exposes with a missing property. +- Fixed child-frame enumeration pairing the DOM query and the protocol's children by position, which gave a light-DOM frame the identity of a shadow-root frame that preceded it; the two are now correlated by frame identity. - Fixed locator waits so they re-arm after mid-wait navigation against the original timeout instead of surfacing execution-context errors. - Fixed remote-CDP actionability probes so they receive the full remaining action budget rather than a short per-probe cap. - Fixed Node.js evaluation decoding for special numeric values, BigInt, and regular expressions; Go/C-ABI and native Rust now use the core's canonical wire decoder. diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index 780d5da..965e689 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -13386,10 +13386,32 @@ def _child_frame_entries(self) -> list[dict[str, Any]]: if isinstance(child, dict) ] if isinstance(entries, list): - for index, entry in enumerate(entries): - if not isinstance(entry, dict) or index >= len(cdp_children): + # The DOM query orders and names the frames it can see; the protocol decides which + # exist. A frame in a shadow root is an ordinary child of the tree and invisible to + # `querySelectorAll`, so pairing the two by position would hand a light-DOM frame a + # shadow frame's identity. Match on URL, fall back to order, keep what was missed. + available = [child for child in cdp_children if isinstance(child, dict)] + claimed: set[int] = set() + + def claim(entry: dict[str, Any]) -> Optional[dict[str, Any]]: + entry_url = str(entry.get("url") or "") + if entry_url: + for position, child in enumerate(available): + if position not in claimed and str(child.get("url") or "") == entry_url: + claimed.add(position) + return child + for position, child in enumerate(available): + if position not in claimed: + claimed.add(position) + return child + return None + + for entry in entries: + if not isinstance(entry, dict): + continue + cdp_frame = claim(entry) + if cdp_frame is None: continue - cdp_frame = cdp_children[index] frame_id = cdp_frame.get("id") if frame_id: entry["id"] = str(frame_id) @@ -13397,6 +13419,17 @@ def _child_frame_entries(self) -> list[dict[str, Any]]: entry["url"] = str(cdp_frame.get("url") or "") if not entry.get("name") and cdp_frame.get("name"): entry["name"] = str(cdp_frame.get("name") or "") + for position, cdp_frame in enumerate(available): + if position in claimed: + continue + entries.append( + { + "id": cdp_frame.get("id"), + "name": cdp_frame.get("name") or "", + "url": cdp_frame.get("url") or "", + "frame_index": len(entries), + } + ) return entries if isinstance(entries, list) else [] def _wrap_spec(self, spec: Dict[str, Any]) -> Dict[str, Any]: @@ -14849,13 +14882,17 @@ def _frame_from_spec(self, frame_spec: Dict[str, Any]) -> Frame: def _cdp_frame_tree_root(self) -> Optional[dict[str, Any]]: frame_tree = getattr(self._core, "frame_tree", None) if frame_tree is not None: + # The core owns this tree: it answers from the frame state it maintains from protocol + # events and refreshes it on a bounded best-effort budget. An empty tree is an answer + # -- a document whose request is still paused has not committed a frame tree yet -- + # so re-asking over a raw session here would only re-run the round trip the core just + # bounded, at `CDPSession.send`'s fixed 30s, once per frame walked. try: payload = json.loads(_call(frame_tree, self._default_timeout)) - root = payload.get("frameTree") if isinstance(payload, dict) else None - if isinstance(root, dict): - return root except Exception: - pass + return None + root = payload.get("frameTree") if isinstance(payload, dict) else None + return root if isinstance(root, dict) else None try: session = CDPSession(_call(self._core.cdp_session)) payload = session.send("Page.getFrameTree") diff --git a/src/lib.rs b/src/lib.rs index 23c9111..9277150 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1310,6 +1310,15 @@ impl std::fmt::Display for ActionTimeoutError { impl std::error::Error for ActionTimeoutError {} const MAX_FRAME_TREE_DEPTH: usize = 256; +/// Longest a single `Page.getFrameTree` may block while refreshing cached frame state. +/// +/// The refresh is best effort — a session that does not answer is skipped and the cached tree is +/// returned anyway — so it must never spend the caller's whole timeout. A renderer will not answer +/// this command while a `Fetch` interception is paused on its document request, which is exactly +/// the state a route handler runs in: reading `Request.frame` there gave every session the full +/// remaining deadline, turning a frame walk into one deadline burn per frame (measured at 38s for +/// a 4s default timeout) and, with the timeout disabled, a 24-hour wait. +const FRAME_TREE_REFRESH_BUDGET: Duration = Duration::from_millis(1_000); /// A terminal element state observed by an auto-waiting physical action. /// @@ -3718,6 +3727,152 @@ multiline-compatible = """4.5.6""" assert_eq!(wrapped, r#"(0, eval)("const s = \"a\\tb\";\ns;")"#); } + #[test] + fn iife_containing_an_arrow_is_not_treated_as_a_function() { + // `looks_like_function` scanned for the first `=>` anywhere in the source and accepted + // the expression when the text before it started with `(`. An IIFE starts with `(`, so + // any IIFE whose body happens to contain an arrow was misread as a function literal, + // wrapped as `const __rw_fn = ()` and called again. The IIFE has already run by + // then, so `__rw_fn` holds its result and the call fails with + // "__rw_fn is not a function". + let script = "(function () { return [1, 2].map((value) => value * 2); })()"; + assert!(!looks_like_function(script)); + let wrapped = make_evaluate_expression(script, None); + assert!( + !wrapped.contains("__rw_fn"), + "an already-invoked IIFE must not be wrapped and called again: {wrapped}" + ); + } + + #[test] + fn parenthesised_expression_containing_an_arrow_is_not_a_function() { + // Same root cause without an IIFE: any parenthesised expression that mentions an arrow + // deeper inside is not itself an arrow function. + assert!(!looks_like_function("(items.map((value) => value))")); + assert!(!looks_like_function("(a === 1 ? b : (value) => value)()")); + } + + #[test] + fn genuine_arrow_and_function_literals_are_still_detected() { + // The guard must not over-correct: these are the shapes callers legitimately pass. + assert!(looks_like_function("() => 1")); + assert!(looks_like_function("(x) => x + 1")); + assert!(looks_like_function("(a, b) => a + b")); + assert!(looks_like_function("async (x) => x")); + assert!(looks_like_function("x => x")); + assert!(looks_like_function("async x => x")); + assert!(looks_like_function("function () { return 1; }")); + assert!(looks_like_function("async function () { return 1; }")); + assert!(looks_like_function( + "(first, second) => {\n return first + second;\n}" + )); + } + + #[test] + fn webdriver_guard_does_not_short_circuit_the_rest_of_the_stealth_script() { + // Returning out of the script instead would drop the chrome object, console history and + // worker identity that follow it, on every default launch: the launch args already clear + // the automation flag, so `navigator.webdriver` is `false` on the common path. + let script = stealth_init_script(); + let guard = script + .find("if (navigator.webdriver !== false)") + .expect("webdriver handling is guarded"); + let chrome_object = script + .find("const chromeObject") + .expect("stealth script installs the chrome object"); + assert!(guard < chrome_object); + assert!( + !script.contains("return;"), + "an early return would skip the rest of the script for every default launch" + ); + + // The guarded block has to close before the chrome object, or that setup sits inside it. + let mut depth = 0usize; + let open = script[guard..].find('{').expect("guard opens a block") + guard; + let mut index = open; + for character in script[open..].chars() { + match character { + '{' => depth += 1, + '}' => depth -= 1, + _ => {} + } + if depth == 0 { + break; + } + index += character.len_utf8(); + } + assert!( + index < chrome_object, + "webdriver guard swallows the chrome object setup" + ); + } + + #[test] + fn frame_tree_refresh_is_bounded_regardless_of_the_caller_timeout() { + // The refresh is best effort: a session that does not answer is skipped and the cached + // tree is returned anyway, so it must never spend the caller's whole budget. A renderer + // does not answer `Page.getFrameTree` while a Fetch interception is paused on its + // document request, which is the state a route handler runs in -- reading `Request.frame` + // there used to burn one full deadline per session walked. + assert_eq!( + frame_tree_refresh_timeout(Duration::from_secs(30)), + FRAME_TREE_REFRESH_BUDGET + ); + // `command_timeout` maps a disabled timeout to 24 hours; that must not become a 24-hour + // wait for an optional refresh. + assert_eq!( + frame_tree_refresh_timeout(BrowserInner::command_timeout(Some(0.0))), + FRAME_TREE_REFRESH_BUDGET + ); + // A caller asking for less than the budget still gets their shorter deadline. + assert_eq!( + frame_tree_refresh_timeout(Duration::from_millis(50)), + Duration::from_millis(50) + ); + } + + #[test] + fn keyword_prefixed_identifiers_are_not_function_literals() { + // `functionality()` and `asyncWork()` start with the keyword's letters but are calls. + assert!(!looks_like_function("functionality()")); + assert!(!looks_like_function("asyncWork()")); + assert!(!looks_like_function("functions.map(f => f())")); + } + + #[test] + fn parameter_list_scan_ignores_literals_and_comments() { + // The arrow binding the parameter list is found by matching the opening parenthesis, so + // parentheses inside strings, template literals and comments must not close it early. + assert!(looks_like_function(r#"(a = ")") => a"#)); + assert!(looks_like_function("(a = '(') => a")); + assert!(looks_like_function("(a = `)`) => a")); + assert!(looks_like_function("(a /* ) */) => a")); + assert!(looks_like_function("(a // )\n) => a")); + // ...and an unterminated literal must not be read as a function either. + assert!(!looks_like_function("(a = \") => a")); + } + + #[test] + fn regex_literals_in_the_parameter_list_do_not_end_it() { + // A `)` inside a regular expression is not the end of the parameter list. + assert!(looks_like_function( + "(pattern = /[)]/) => pattern.test(')')" + )); + assert!(looks_like_function("(pattern = /a\\/b)/) => pattern")); + // Division after a value is still division, not a literal opening. + assert!(looks_like_function("(a, b = x / y) => a + b")); + // ...and a call whose argument holds a regex is still not a function literal. + assert!(!looks_like_function("wrap(/[)]/)")); + } + + #[test] + fn nested_parameter_lists_still_resolve_to_the_binding_arrow() { + assert!(looks_like_function("({ a, b: [c] }) => a + c")); + assert!(looks_like_function("(a = (1 + 2)) => a")); + // A call whose argument is an arrow is not itself a function literal. + assert!(!looks_like_function("wrap((value) => value)")); + } + #[test] fn function_and_arg_scripts_are_not_double_wrapped_in_eval() { // Functions and arg-bearing calls keep their IIFE wrapping, which @@ -20118,8 +20273,13 @@ mod native_network_record_tests { } } +/// How long a best-effort frame-tree refresh may take, given the caller's timeout. +fn frame_tree_refresh_timeout(timeout: Duration) -> Duration { + timeout.min(FRAME_TREE_REFRESH_BUDGET) +} + async fn refresh_page_frame_tree(page: &Arc, timeout: Duration) -> RwResult<()> { - let deadline = OperationDeadline::new(timeout); + let deadline = OperationDeadline::new(frame_tree_refresh_timeout(timeout)); let sessions = page.frame_state.lock().unwrap().session_ids(); for session_id in sessions { let Ok(remaining) = deadline.remaining() else { @@ -20128,7 +20288,12 @@ async fn refresh_page_frame_tree(page: &Arc, timeout: Duration) -> Rw let result = page .browser .client - .send("Page.getFrameTree", json!({}), Some(&session_id), remaining) + .send( + "Page.getFrameTree", + json!({}), + Some(&session_id), + frame_tree_refresh_timeout(remaining), + ) .await; let Ok(tree) = result else { continue; @@ -20204,9 +20369,13 @@ async fn install_stealth_defaults(browser: &BrowserInner, session_id: &str) -> R .map(|user_agent| { let user_agent = user_agent.replace("HeadlessChrome/", "Chrome/"); let user_agent_metadata = stealth_user_agent_metadata(&user_agent, None); + // No `acceptLanguage`: omitting it leaves whatever the browser was configured + // with, which is the only value that can be coherent with its other geo signals. + // Pinning one here silently overrode `--accept-lang`/`--lang`, so a browser + // launched for one region reported that region's timezone alongside `en-US` -- + // an incoherence anti-bot checks read as a spoofed environment. json!({ "userAgent": user_agent, - "acceptLanguage": "en-US,en", "userAgentMetadata": user_agent_metadata, }) }) @@ -20289,7 +20458,7 @@ fn start_service_worker_stealth_auto_attach_cancelable( "filter": [ { "type": "page", "exclude": true }, { "type": "iframe", "exclude": true }, - { "type": "worker", "exclude": true }, + { "type": "worker", "exclude": false }, { "type": "shared_worker", "exclude": true }, { "type": "background_page", "exclude": true }, { "type": "service_worker", "exclude": false }, @@ -20318,7 +20487,12 @@ fn start_service_worker_stealth_auto_attach_cancelable( else { continue; }; - if info.get("type").and_then(Value::as_str) != Some("service_worker") { + // Dedicated workers get the identity script the same way service workers do: + // over their own session, while they are still paused. Rewriting the page's + // `Worker` constructor to load a blob shim instead moved the worker off its real + // script URL, which changes its `location` and origin. + let target_type = info.get("type").and_then(Value::as_str); + if !matches!(target_type, Some("service_worker") | Some("worker")) { let _ = client .send( "Runtime.runIfWaitingForDebugger", @@ -20460,26 +20634,31 @@ fn worker_stealth_init_script() -> String { const STEALTH_INIT_SCRIPT_TEMPLATE: &str = r#" (() => { - try { - delete Navigator.prototype.webdriver; - } catch (_) { - try { - delete navigator.webdriver; - } catch (_) {} - } - if ('webdriver' in navigator) { + // A browser already reporting `false` gives the answer every real Chrome gives; removing the + // property there trades a correct answer for a missing one. Guards only this block: the + // default launch args clear the flag, so the rest still has to run on an ordinary launch. + if (navigator.webdriver !== false) { try { - Object.defineProperty(Navigator.prototype, 'webdriver', { - get: () => undefined, - configurable: true - }); + delete Navigator.prototype.webdriver; } catch (_) { try { - Object.defineProperty(navigator, 'webdriver', { + delete navigator.webdriver; + } catch (_) {} + } + if ('webdriver' in navigator) { + try { + Object.defineProperty(Navigator.prototype, 'webdriver', { get: () => undefined, configurable: true }); - } catch (_) {} + } catch (_) { + try { + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, + configurable: true + }); + } catch (_) {} + } } } try { @@ -20743,104 +20922,6 @@ const STEALTH_INIT_SCRIPT_TEMPLATE: &str = r#" window.addEventListener('error', errorHandler, true); window.addEventListener('unhandledrejection', rejectionHandler, true); } catch (_) {} - try { - const workerMarker = Symbol.for('nativeWorkerIdentityWrapped'); - const NativeWorker = window.Worker; - if (typeof NativeWorker === 'function' && !NativeWorker[workerMarker]) { - const makeWorkerIdentitySource = () => { - const ua = String(navigator.userAgent || ''); - const appVersion = String(navigator.appVersion || ''); - const platform = String(navigator.platform || ''); - const language = String(navigator.language || 'en-US'); - const languages = Array.from(navigator.languages || [language]).map(String); - const chromeFullVersion = (ua.match(/Chrome\/([^\s]+)/) || [])[1] || ''; - const fullVersionForBrand = brand => { - const name = String(brand.brand || ''); - const version = String(brand.version || ''); - if ((name === 'Chromium' || name === 'Google Chrome') && chromeFullVersion) return chromeFullVersion; - if (name === 'Not A(Brand') return '24.0.0.0'; - return version; - }; - const mapBrand = brand => ({ - brand: String(brand.brand || ''), - version: String(brand.version || '') - }); - const mapFullVersionBrand = brand => ({ - brand: String(brand.brand || ''), - version: fullVersionForBrand(brand) - }); - const uaBrands = navigator.userAgentData ? Array.from(navigator.userAgentData.brands || []).map(mapBrand) : []; - const uaData = navigator.userAgentData ? { - brands: uaBrands, - fullVersionList: uaBrands.map(mapFullVersionBrand), - mobile: !!navigator.userAgentData.mobile, - platform: String(navigator.userAgentData.platform || platform), - architecture: '__UA_ARCHITECTURE__' - } : null; - const uaDataJson = JSON.stringify(uaData); - return [ - '(() => {', - ' const defineNavigatorValue = (name, value) => {', - ' try { Object.defineProperty(Object.getPrototypeOf(navigator), name, { get: () => value, configurable: true }); } catch (_) {}', - ' try { Object.defineProperty(navigator, name, { get: () => value, configurable: true }); } catch (_) {}', - ' };', - ` defineNavigatorValue('userAgent', ${JSON.stringify(ua)});`, - ` defineNavigatorValue('appVersion', ${JSON.stringify(appVersion)});`, - ` defineNavigatorValue('platform', ${JSON.stringify(platform)});`, - ` defineNavigatorValue('language', ${JSON.stringify(language)});`, - ` defineNavigatorValue('languages', ${JSON.stringify(languages)});`, - ' try { delete Object.getPrototypeOf(navigator).webdriver; } catch (_) {}', - ' try { delete navigator.webdriver; } catch (_) {}', - ` const uaData = ${uaDataJson};`, - ' if (uaData) {', - ' const data = {', - ' brands: uaData.brands,', - ' mobile: uaData.mobile,', - ' platform: uaData.platform,', - ' getHighEntropyValues: async hints => {', - ' const values = { brands: uaData.brands, mobile: uaData.mobile, platform: uaData.platform };', - ' for (const hint of hints || []) {', - " if (hint === 'fullVersionList') values.fullVersionList = uaData.fullVersionList;", - " if (hint === 'architecture') values.architecture = uaData.architecture;", - " if (hint === 'bitness') values.bitness = '64';", - " if (hint === 'model') values.model = '';", - " if (hint === 'platformVersion') values.platformVersion = '';", - ' }', - ' return values;', - ' },', - ' toJSON: () => ({ brands: uaData.brands, mobile: uaData.mobile, platform: uaData.platform })', - ' };', - " defineNavigatorValue('userAgentData', data);", - ' }', - '})();' - ].join('\n'); - }; - const WrappedWorker = function(scriptURL, options) { - try { - const workerOptions = options || {}; - const absoluteUrl = new URL(String(scriptURL), location.href).href; - const identitySource = makeWorkerIdentitySource(); - const source = workerOptions.type === 'module' - ? `${identitySource}\nimport ${JSON.stringify(absoluteUrl)};` - : `${identitySource}\nimportScripts(${JSON.stringify(absoluteUrl)});`; - const blobUrl = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); - return new NativeWorker(blobUrl, workerOptions); - } catch (_) { - return new NativeWorker(scriptURL, options); - } - }; - WrappedWorker.prototype = NativeWorker.prototype; - try { Object.setPrototypeOf(WrappedWorker, NativeWorker); } catch (_) {} - try { Object.defineProperty(WrappedWorker, 'name', { value: 'Worker', configurable: true }); } catch (_) {} - try { Object.defineProperty(WrappedWorker, 'toString', { value: () => 'function Worker() { [native code] }', configurable: true }); } catch (_) {} - try { Object.defineProperty(WrappedWorker, workerMarker, { value: true }); } catch (_) {} - Object.defineProperty(window, 'Worker', { - value: WrappedWorker, - configurable: true, - writable: true - }); - } - } catch (_) {} })(); "#; @@ -24807,23 +24888,155 @@ fn parse_js_identifier_prefix(value: &str) -> Option { Some(identifier) } +/// Whether `expression` is itself a function literal, and so should be wrapped and called +/// rather than evaluated for its value. +/// +/// This only inspects the head of the expression. Searching the whole source for `=>` misreads +/// any parenthesised expression that merely *contains* an arrow somewhere inside it — an IIFE +/// such as `(function () { return [1].map((v) => v); })()` has already produced its value, and +/// calling that value fails with "__rw_fn is not a function". fn looks_like_function(expression: &str) -> bool { - expression.starts_with("function") - || expression.starts_with("async function") - || expression - .find("=>") - .map(|index| { - let before_arrow = expression[..index].trim(); - if before_arrow.starts_with('(') { - return true; - } - if let Some(parameter) = before_arrow.strip_prefix("async ") { - let parameter = parameter.trim(); - return parameter.starts_with('(') || is_js_identifier(parameter); + let expression = expression.trim_start(); + if starts_with_keyword(expression, "function") { + return true; + } + if let Some(rest) = strip_keyword(expression, "async") { + return starts_with_keyword(rest, "function") || starts_with_arrow_head(rest); + } + starts_with_arrow_head(expression) +} + +/// Whether `expression` opens with an arrow function's parameter list and the arrow that binds +/// it: either `(params) =>` or a single `identifier =>`. +fn starts_with_arrow_head(expression: &str) -> bool { + if expression.starts_with('(') { + let Some(close) = matching_parenthesis(expression) else { + return false; + }; + return expression[close + 1..].trim_start().starts_with("=>"); + } + let identifier_end = expression + .find(|ch: char| !is_js_identifier_continue(ch)) + .unwrap_or(expression.len()); + let (identifier, rest) = expression.split_at(identifier_end); + is_js_identifier(identifier) && rest.trim_start().starts_with("=>") +} + +/// The byte index of the `)` matching the `(` that `expression` opens with. +/// +/// Parentheses inside string and template literals, comments, and nested groups do not count, +/// or `("(") => 1` and `(a /* ) */) => 1` would be misread. +fn matching_parenthesis(expression: &str) -> Option { + let bytes = expression.as_bytes(); + let mut depth = 0usize; + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + return Some(index); } - is_js_identifier(before_arrow) - }) - .unwrap_or(false) + } + quote @ (b'\'' | b'"' | b'`') => { + index = skip_string_literal(bytes, index, quote)?; + } + // A `/` that can only begin a value starts a regular expression, and `)` inside one + // does not close the parameter list: `(pattern = /[)]/) => pattern` is a function. + b'/' if regex_literal_can_start_at(bytes, index) => { + index = skip_regex_literal(bytes, index)?; + } + b'/' if bytes.get(index + 1) == Some(&b'/') => { + index = match expression[index..].find('\n') { + Some(offset) => index + offset, + None => return None, + }; + } + b'/' if bytes.get(index + 1) == Some(&b'*') => { + let offset = expression[index + 2..].find("*/")?; + index = index + 2 + offset + 1; + } + _ => {} + } + index += 1; + } + None +} + +/// Whether the `/` at `index` opens a regular expression rather than a comment or a division. +/// +/// The preceding token settles it: a `/` after a value is division, and after `=`, `,`, `(` or +/// `[` it can only open a literal. +fn regex_literal_can_start_at(bytes: &[u8], index: usize) -> bool { + if matches!(bytes.get(index + 1), Some(b'/') | Some(b'*')) { + return false; + } + let mut before = index; + while before > 0 { + before -= 1; + if !bytes[before].is_ascii_whitespace() { + return matches!( + bytes[before], + b'=' | b',' | b'(' | b'[' | b'{' | b'!' | b'&' | b'|' | b'?' | b':' | b';' + ); + } + } + false +} + +/// The byte index of the `/` closing the regular expression starting at `start`. +/// +/// A character class can contain an unescaped `/`, so the scan tracks whether it is inside one. +fn skip_regex_literal(bytes: &[u8], start: usize) -> Option { + let mut index = start + 1; + let mut in_class = false; + while index < bytes.len() { + match bytes[index] { + b'\\' => index += 1, + b'[' => in_class = true, + b']' => in_class = false, + b'\n' => return None, + b'/' if !in_class => return Some(index), + _ => {} + } + index += 1; + } + None +} + +/// The byte index of the closing `quote` for the literal starting at `start`. +/// +/// Template literals are treated as opaque: a `${...}` substitution cannot close the literal, +/// and any parenthesis inside one is already ignored by virtue of being inside it. +fn skip_string_literal(bytes: &[u8], start: usize, quote: u8) -> Option { + let mut index = start + 1; + while index < bytes.len() { + match bytes[index] { + b'\\' => index += 1, + byte if byte == quote => return Some(index), + _ => {} + } + index += 1; + } + None +} + +/// Whether `expression` begins with `keyword` as a whole word rather than as the prefix of a +/// longer identifier, so `functionality()` is not mistaken for a function literal. +fn starts_with_keyword(expression: &str, keyword: &str) -> bool { + expression + .strip_prefix(keyword) + .is_some_and(|rest| !rest.starts_with(is_js_identifier_continue)) +} + +/// `expression` with a leading `keyword` word and the whitespace after it removed. +fn strip_keyword<'a>(expression: &'a str, keyword: &str) -> Option<&'a str> { + let rest = expression.strip_prefix(keyword)?; + if !rest.starts_with(char::is_whitespace) { + return None; + } + Some(rest.trim_start()) } fn is_js_identifier(value: &str) -> bool {