Repository: denoland/deno
Pull request: fix(ext/node): improve worker_threads path validation and error handling

## Summary

- Implement `ERR_WORKER_PATH` error class for proper Node.js-compatible path validation in `worker_threads.Worker`
- Reject string URL specifiers (`file://`, `data:`, `http://`, `https://`) with `ERR_WORKER_PATH` (must use `new URL()`)
- Reject relative paths that don't start with `./` or `../` (or `.\`/`..\` on Windows)
- Throw `ERR_INVALID_URL_SCHEME` for URL objects with non-file/data protocols
- Validate `eval: true` requires a string specifier, not a URL object
- Preserve native error constructor (`SyntaxError`, `TypeError`, etc.) on worker error events so `err.constructor` matches
- Fix spec tests to use `new URL()` instead of `import.meta.resolve()` strings

## Newly passing Node.js compat tests
- `test-worker-unsupported-path.js`
- `test-worker-unsupported-eval-on-url.mjs`
- `test-worker-syntax-error.js`
- `test-worker-syntax-error-file.js`

## Test plan
- [x] All 4 newly enabled Node.js compat tests pass
- [x] `message_port` and `message_port_transfer` spec tests pass
- [x] Existing worker_threads unit tests pass
- [x] Format and lint clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/node/polyfills/internal/errors.ts
patch: @@ -5,7 +5,6 @@
  * ERR_MANIFEST_ASSERT_INTEGRITY
  * ERR_QUICSESSION_VERSION_NEGOTIATION
  * ERR_REQUIRE_ESM
- * ERR_WORKER_PATH
  * ERR_QUIC_ERROR
  * ERR_SYSTEM_ERROR //System error, shouldn't ever happen inside Deno
  * ERR_TTY_INIT_FAILED //System error, shouldn't ever happen inside Deno
@@ -2414,6 +2413,26 @@ export class ERR_WORKER_INIT_FAILED extends NodeError {
     super("ERR_WORKER_INIT_FAILED", `Worker initialization failure: ${x}`);
   }
 }
+export class ERR_WORKER_PATH extends NodeTypeError {
+  constructor(filename: string) {
+    const base =
+      "The worker script or module filename must be an absolute path or a relative path starting with './' or '../'.";
+    let detail = "";
+    if (
+      typeof filename === "string" &&
+      (StringPrototypeStartsWith(filename, "file://") ||
+        StringPrototypeStartsWith(filename, "File://"))
+    ) {
+      detail = " Wrap file:// URLs with `new URL`.";
+    } else if (
+      typeof filename === "string" &&
+      StringPrototypeStartsWith(filename, "data:")
+    ) {
+      detail = " Wrap data: URLs with `new URL`.";
+    }
+    super("ERR_WORKER_PATH", base + detail);
+  }
+}
 export class ERR_WORKER_NOT_RUNNING extends NodeError {
   constructor() {
     super("ERR_WORKER_NOT_RUNNING", `Worker instance not running`);
@@ -3318,6 +3337,7 @@ export default {
   ERR_WORKER_INVALID_EXEC_ARGV,
   ERR_WORKER_NOT_RUNNING,
   ERR_WORKER_OUT_OF_MEMORY,
+  ERR_WORKER_PATH,
   ERR_WORKER_UNSERIALIZABLE_ERROR,
   ERR_WORKER_UNSUPPORTED_EXTENSION,
   ERR_WORKER_UNSUPPORTED_OPERATION,

filename: ext/node/polyfills/worker_threads.ts
patch: @@ -31,9 +31,11 @@ import * as webidl from "ext:deno_webidl/00_webidl.js";
 import { notImplemented } from "ext:deno_node/_utils.ts";
 import {
   ERR_INVALID_ARG_TYPE,
+  ERR_INVALID_URL_SCHEME,
   ERR_OUT_OF_RANGE,
   ERR_WORKER_INVALID_EXEC_ARGV,
   ERR_WORKER_NOT_RUNNING,
+  ERR_WORKER_PATH,
 } from "ext:deno_node/internal/errors.ts";
 import {
   validateArray,
@@ -53,6 +55,7 @@ import { createRequire } from "node:module";
 const {
   ArrayIsArray,
   Error,
+  EvalError,
   FunctionPrototypeCall,
   NumberIsFinite,
   ObjectHasOwn,
@@ -70,15 +73,32 @@ const {
   StringPrototypeSplit,
   StringPrototypeStartsWith,
   StringPrototypeTrim,
+  SyntaxError,
   Symbol,
   SymbolAsyncDispose,
   SymbolFor,
   SymbolIterator,
   TypeError,
+  URIError,
+  RangeError,
+  ReferenceError,
   Float64Array,
   FunctionPrototypeBind,
 } = primordials;
 
+// Map error names to native constructors so that worker error events
+// preserve err.constructor (e.g. SyntaxError, TypeError).
+const nativeErrorConstructors: Record<string, ErrorConstructor> = {
+  __proto__: null as unknown as ErrorConstructor,
+  Error,
+  EvalError,
+  RangeError,
+  ReferenceError,
+  SyntaxError,
+  TypeError,
+  URIError,
+};
+
 const workerCpuUsageBuffer = new Float64Array(2);
 
 const debugWorkerThreads = false;
@@ -269,13 +289,39 @@ class NodeWorker extends EventEmitter {
       }
     }
 
-    if (
-      typeof specifier === "object" &&
-      !(specifier.protocol === "data:" || specifier.protocol === "file:")
-    ) {
-      throw new TypeError(
-        "node:worker_threads support only 'file:' and 'data:' URLs",
-      );
+    if (typeof specifier === "object") {
+      if (
+        !(specifier.protocol === "data:" || specifier.protocol === "file:")
+      ) {
+        throw new ERR_INVALID_URL_SCHEME(["file", "data"]);
+      }
+    } else if (typeof specifier === "string" && !options?.eval) {
+      // Node.js requires string specifiers to be absolute paths or
+      // relative paths starting with './' or '../'. URLs passed as
+      // strings must be wrapped with `new URL`.
+      if (
+        StringPrototypeStartsWith(specifier, "file://") ||
+        StringPrototypeStartsWith(specifier, "data:") ||
+        StringPrototypeStartsWith(specifier, "http://") ||
+        StringPrototypeStartsWith(specifier, "https://")
+      ) {
+        throw new ERR_WORKER_PATH(specifier);
+      }
+      const path = specifier;
+      if (
+        !StringPrototypeStartsWith(path, "/") &&
+        !StringPrototypeStartsWith(path, "./") &&
+        !StringPrototypeStartsWith(path, "../") &&
+        !StringPrototypeStartsWith(path, ".\\") &&
+        !StringPrototypeStartsWith(path, "..\\")
+      ) {
+        // On Windows, also allow drive-letter absolute paths (e.g. C:\...)
+        const isWindowsAbsolute = path.length >= 3 && path[1] === ":" &&
+          (path[2] === "\\" || path[2] === "/");
+        if (!isWindowsAbsolute) {
+          throw new ERR_WORKER_PATH(specifier);
+        }
+      }
     }
 
     // Serialize workerData before resolving the filename so that
@@ -363,10 +409,12 @@ class NodeWorker extends EventEmitter {
     let hasSourceCode = false;
 
     if (options?.eval) {
-      const code = typeof specifier === "string"
-        ? specifier
-        // deno-lint-ignore prefer-primordials
-        : specifier.toString();
+      if (typeof specifier !== "string") {
+        throw new TypeError(
+          "The property 'options.eval' must be false when 'filename' is not a string.",
+        );
+      }
+      const code = specifier;
       // Node.js runs eval workers as CJS (sloppy mode).
       // Pass as source code for execute_script (sloppy mode).
       // `require` is already available from the Node worker bootstrap.
@@ -514,10 +562,10 @@ class NodeWorker extends EventEmitter {
               err.code = errName;
               err.name = "Error";
             } else {
-              err = new Error(errMsg);
-              if (errName) {
-                err.name = errName;
-              }
+              // Use the correct native error constructor so that
+              // err.constructor matches (e.g. SyntaxError, TypeError).
+              const Ctor = nativeErrorConstructors[errName] ?? Error;
+              err = new Ctor(errMsg);
             }
             // Stack is unavailable from the worker context (e.g. prepareStackTrace
             // may have thrown). Match Node.js behavior of setting stack to undefined.

filename: tests/node_compat/config.jsonc
patch: @@ -2358,6 +2358,8 @@
     "parallel/test-worker-stdio-flush.js": {},
     "parallel/test-worker-sharedarraybuffer-from-worker-thread.js": {},
     "parallel/test-worker-stdio.js": {},
+    "parallel/test-worker-syntax-error.js": {},
+    "parallel/test-worker-syntax-error-file.js": {},
     "parallel/test-worker-terminate-microtask-loop.js": {},
     "parallel/test-worker-terminate-nested.js": {},
     "parallel/test-worker-terminate-ref-public-port.js": {},
@@ -2367,6 +2369,8 @@
     "parallel/test-worker-uncaught-exception.js": {},
     "parallel/test-worker-uncaught-exception-async.js": {},
     "parallel/test-worker-unref-from-message-during-exit.js": {},
+    "parallel/test-worker-unsupported-eval-on-url.mjs": {},
+    "parallel/test-worker-unsupported-path.js": {},
     "parallel/test-worker-thread-name.js": {},
     "parallel/test-worker-voluntarily-exit-followed-by-addition.js": {},
     "parallel/test-worker-voluntarily-exit-followed-by-throw.js": {},

filename: tests/specs/node/worker_threads/message_port.mjs
patch: @@ -7,7 +7,7 @@ const { port1: mainPort, port2: workerPort } = new workerThreads
 const deferred = createDeferred();
 
 const worker = new workerThreads.Worker(
-  import.meta.resolve("./message_port_1.cjs"),
+  new URL("./message_port_1.cjs", import.meta.url),
   {
     workerData: workerPort,
     transferList: [workerPort],

filename: tests/specs/node/worker_threads/message_port_transfer.mjs
patch: @@ -2,7 +2,7 @@ import { MessageChannel, Worker } from "node:worker_threads";
 
 const { port1, port2 } = new MessageChannel();
 const worker = new Worker(
-  import.meta.resolve("./message_port_transfer1.mjs"),
+  new URL("./message_port_transfer1.mjs", import.meta.url),
 );
 // Send the port directly after the worker is created
 worker.postMessage(port2, [port2]);