diff --git a/.changeset/long-starfishes-mate.md b/.changeset/long-starfishes-mate.md new file mode 100644 index 000000000000..712bf11bf8b8 --- /dev/null +++ b/.changeset/long-starfishes-mate.md @@ -0,0 +1,19 @@ +--- +"wrangler": minor +--- + +feat: resolve npm exports for file imports + +Previously, when using wasm (or other static files) from an npm package, you would have to import the file like so: + +```js +import wasm from "../../node_modules/svg2png-wasm/svg2png_wasm_bg.wasm"; +``` + +This update now allows you to import the file like so, assuming it's exposed and available in the package's `exports` field: + +```js +import wasm from "svg2png-wasm/svg2png_wasm_bg.wasm"; +``` + +This will look at the package's `exports` field in `package.json` and resolve the file using [`resolve.exports`](https://www.npmjs.com/package/resolve.exports). diff --git a/fixtures/import-wasm-example/.gitignore b/fixtures/import-wasm-example/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/fixtures/import-wasm-example/.gitignore @@ -0,0 +1 @@ +dist diff --git a/fixtures/import-wasm-example/README.md b/fixtures/import-wasm-example/README.md new file mode 100644 index 000000000000..a8a57e937250 --- /dev/null +++ b/fixtures/import-wasm-example/README.md @@ -0,0 +1,3 @@ +# import-wasm-example + +`import-wasm-example` is a test fixture that imports a `wasm` file from `import-wasm-static`, testing npm module resolution with wrangler imports. diff --git a/fixtures/import-wasm-example/package.json b/fixtures/import-wasm-example/package.json new file mode 100644 index 000000000000..2e937b5c3cfc --- /dev/null +++ b/fixtures/import-wasm-example/package.json @@ -0,0 +1,23 @@ +{ + "name": "import-wasm-example", + "version": "1.0.1", + "private": true, + "description": "", + "author": "", + "main": "src/index.js", + "scripts": { + "check:type": "tsc", + "test": "npx vitest run", + "test:ci": "npx vitest run", + "test:watch": "npx vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "undici": "^5.9.1", + "wrangler": "workspace:*", + "@cloudflare/workers-tsconfig": "workspace:^" + }, + "dependencies": { + "import-wasm-static": "workspace:^" + } +} diff --git a/fixtures/import-wasm-example/src/index.js b/fixtures/import-wasm-example/src/index.js new file mode 100644 index 000000000000..300683601a95 --- /dev/null +++ b/fixtures/import-wasm-example/src/index.js @@ -0,0 +1,12 @@ +// this is from the `import-wasm-static` fixture defined above +// and setup inside package.json to mimic an npm package +import multiply from "import-wasm-static/multiply.wasm"; + +export default { + async fetch(request) { + // just instantiate and return something + // we're really just testing the import at the top of this file + const multiplyModule = await WebAssembly.instantiate(multiply); + return new Response(`${multiplyModule.exports.multiply(7, 3)}`); + }, +}; diff --git a/fixtures/import-wasm-example/tests/index.test.ts b/fixtures/import-wasm-example/tests/index.test.ts new file mode 100644 index 000000000000..d62c5f60d69e --- /dev/null +++ b/fixtures/import-wasm-example/tests/index.test.ts @@ -0,0 +1,25 @@ +import { resolve } from "path"; +import { fetch } from "undici"; +import { describe, it, beforeAll, afterAll } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("wrangler correctly imports wasm files with npm resolution", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ + "--port=0", + ])); + }); + + afterAll(async () => { + await stop?.(); + }); + + // if the worker compiles, is running, and returns 21 (7 * 3) we can assume that the wasm module was imported correctly + it("responds", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(text).toBe("21"); + }); +}); diff --git a/fixtures/import-wasm-example/tests/tsconfig.json b/fixtures/import-wasm-example/tests/tsconfig.json new file mode 100644 index 000000000000..d2ce7f144694 --- /dev/null +++ b/fixtures/import-wasm-example/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts", "../../../node-types.d.ts"] +} diff --git a/fixtures/import-wasm-example/tsconfig.json b/fixtures/import-wasm-example/tsconfig.json new file mode 100644 index 000000000000..6eb14e3584b7 --- /dev/null +++ b/fixtures/import-wasm-example/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "CommonJS", + "lib": ["ES2020"], + "types": ["node"], + "moduleResolution": "node", + "noEmit": true + }, + "include": ["tests", "../../node-types.d.ts"] +} diff --git a/fixtures/import-wasm-example/wrangler.toml b/fixtures/import-wasm-example/wrangler.toml new file mode 100644 index 000000000000..c0d8aaef0a82 --- /dev/null +++ b/fixtures/import-wasm-example/wrangler.toml @@ -0,0 +1,4 @@ +name = "import-wasm-example" +compatibility_date = "2023-10-02" + +main = "src/index.js" diff --git a/fixtures/import-wasm-static/README.md b/fixtures/import-wasm-static/README.md new file mode 100644 index 000000000000..2ac75f70c4a6 --- /dev/null +++ b/fixtures/import-wasm-static/README.md @@ -0,0 +1,3 @@ +# import-wasm-static + +`import-wasm-static` is a fixture that simply exports a `wasm` file via `package.json` exports to be used and imported in other fixtures, to test npm module resolution. diff --git a/fixtures/import-wasm-static/package.json b/fixtures/import-wasm-static/package.json new file mode 100644 index 000000000000..18b0c67cf7a7 --- /dev/null +++ b/fixtures/import-wasm-static/package.json @@ -0,0 +1,9 @@ +{ + "name": "import-wasm-static", + "version": "0.0.1", + "private": true, + "sideEffects": false, + "exports": { + "./multiply.wasm": "./wasm/multiply.wasm" + } +} diff --git a/fixtures/import-wasm-static/wasm/multiply.wasm b/fixtures/import-wasm-static/wasm/multiply.wasm new file mode 100644 index 000000000000..0fbf9272721f Binary files /dev/null and b/fixtures/import-wasm-static/wasm/multiply.wasm differ diff --git a/fixtures/import-wasm-static/wasm/multiply.wat b/fixtures/import-wasm-static/wasm/multiply.wat new file mode 100644 index 000000000000..064fbe7eda08 --- /dev/null +++ b/fixtures/import-wasm-static/wasm/multiply.wat @@ -0,0 +1,7 @@ +(module + (func $multiply (param $p1 i32) (param $p2 i32) (result i32) + local.get $p1 + local.get $p2 + i32.mul) + (export "multiply" (func $multiply)) +) diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index ffe8e9595065..8758637ba71e 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -110,6 +110,7 @@ "miniflare": "3.20231016.0", "nanoid": "^3.3.3", "path-to-regexp": "^6.2.0", + "resolve.exports": "^2.0.2", "selfsigned": "^2.0.1", "source-map": "0.6.1", "source-map-support": "0.5.21", diff --git a/packages/wrangler/src/__tests__/module-collection.test.ts b/packages/wrangler/src/__tests__/module-collection.test.ts new file mode 100644 index 000000000000..b12b0ea4ad2a --- /dev/null +++ b/packages/wrangler/src/__tests__/module-collection.test.ts @@ -0,0 +1,20 @@ +import { extractPackageName } from "../deployment-bundle/module-collection"; + +describe("Module Collection", () => { + describe("extractPackageName", () => { + test.each` + importString | packageName + ${"wrangler"} | ${"wrangler"} + ${"wrangler/example"} | ${"wrangler"} + ${"wrangler/example.wasm"} | ${"wrangler"} + ${"@cloudflare/wrangler"} | ${"@cloudflare/wrangler"} + ${"@cloudflare/wrangler/example"} | ${"@cloudflare/wrangler"} + ${"@cloudflare/wrangler/example.wasm"} | ${"@cloudflare/wrangler"} + ${"./some/file"} | ${null} + ${"../some/file"} | ${null} + ${"/some/file"} | ${null} + `("$importString --> $packageName", ({ importString, packageName }) => { + expect(extractPackageName(importString)).toBe(packageName); + }); + }); +}); diff --git a/packages/wrangler/src/deployment-bundle/bundle.ts b/packages/wrangler/src/deployment-bundle/bundle.ts index 8f0c9762cfb7..43809a7c83db 100644 --- a/packages/wrangler/src/deployment-bundle/bundle.ts +++ b/packages/wrangler/src/deployment-bundle/bundle.ts @@ -31,6 +31,9 @@ export const COMMON_ESBUILD_OPTIONS = { loader: { ".js": "jsx", ".mjs": "jsx", ".cjs": "jsx" }, } as const; +// build conditions used by esbuild, and when resolving custom `import` calls +export const BUILD_CONDITIONS = ["workerd", "worker", "browser"]; + /** * Information about Wrangler's bundling process that needs passed through * for DevTools sourcemap transformation @@ -310,7 +313,7 @@ export async function bundleWorker( sourceRoot: destination, minify, metafile: true, - conditions: ["workerd", "worker", "browser"], + conditions: BUILD_CONDITIONS, ...(process.env.NODE_ENV && { define: { // use process.env["NODE_ENV" + ""] so that esbuild doesn't replace it diff --git a/packages/wrangler/src/deployment-bundle/module-collection.ts b/packages/wrangler/src/deployment-bundle/module-collection.ts index a950068ecf85..3ce49ece5221 100644 --- a/packages/wrangler/src/deployment-bundle/module-collection.ts +++ b/packages/wrangler/src/deployment-bundle/module-collection.ts @@ -3,7 +3,9 @@ import { readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import globToRegExp from "glob-to-regexp"; +import { exports as resolveExports } from "resolve.exports"; import { logger } from "../logger"; +import { BUILD_CONDITIONS } from "./bundle"; import { findAdditionalModules, findAdditionalModuleWatchDirs, @@ -64,6 +66,23 @@ export const noopModuleCollector: ModuleCollector = { }, }; +// Extracts a package name from a string that may be a file path +// or a package name. Returns null if the string is not a valid +// Handles `wrangler`, `wrangler/example`, `wrangler/example.wasm`, +// `@cloudflare/wrangler`, `@cloudflare/wrangler/example`, etc. +export function extractPackageName(packagePath: string) { + if (packagePath.startsWith(".")) return null; + + const match = packagePath.match(/^(@[^/]+\/)?([^/]+)/); + + if (match) { + const scoped = match[1] || ""; + const packageName = match[2]; + return `${scoped}${packageName}`; + } + return null; +} + export function createModuleCollector(props: { entry: Entry; findAdditionalModules: boolean; @@ -237,7 +256,7 @@ export function createModuleCollector(props: { // take the file and massage it to a // transportable/manageable format - const filePath = path.join(args.resolveDir, args.path); + let filePath = path.join(args.resolveDir, args.path); // If this was a found additional module, mark it as external. // Note, there's no need to watch the file here as we already @@ -251,6 +270,51 @@ export function createModuleCollector(props: { // it to `esbuild` to bundle it. if (isJavaScriptModuleRule(rule)) return; + // Check if this file is possibly from an npm package + // and if so, validate the import against the package.json exports + // and resolve the file path to the correct file. + if (args.path.includes("/") && !args.path.startsWith(".")) { + // get npm package name from string, taking into account scoped packages + const packageName = extractPackageName(args.path); + if (!packageName) { + throw new Error( + `Unable to extract npm package name from ${args.path}` + ); + } + const packageJsonPath = path.join( + process.cwd(), + "node_modules", + packageName, + "package.json" + ); + // Try and read the npm package's package.json + // and then resolve the import against the package's exports + // and then finally override filePath if we find a match. + try { + const packageJson = JSON.parse( + await readFile(packageJsonPath, "utf8") + ); + const testResolved = resolveExports( + packageJson, + args.path.replace(`${packageName}/`, ""), + { + conditions: BUILD_CONDITIONS, + } + ); + if (testResolved) { + filePath = path.join( + process.cwd(), + "node_modules", + packageName, + testResolved[0] + ); + } + } catch (e) { + // We tried, now it'll just fall-through to the previous behaviour + // and ENOENT if the absolute file path doesn't exist. + } + } + const fileContent = await readFile(filePath); const fileHash = crypto .createHash("sha1") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 491208bd0326..127df3302f4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,7 +124,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: ^4.20221111.1 - version: registry.npmjs.org/@cloudflare/workers-types@4.20230914.0 + version: 4.20230914.0 concurrently: specifier: ^8.2.1 version: 8.2.1 @@ -135,6 +135,24 @@ importers: specifier: workspace:* version: link:../../packages/wrangler + fixtures/import-wasm-example: + dependencies: + import-wasm-static: + specifier: workspace:^ + version: link:../import-wasm-static + devDependencies: + '@cloudflare/workers-tsconfig': + specifier: workspace:^ + version: link:../../packages/workers-tsconfig + undici: + specifier: ^5.9.1 + version: 5.23.0 + wrangler: + specifier: workspace:* + version: link:../../packages/wrangler + + fixtures/import-wasm-static: {} + fixtures/isomorphic-random-example: {} fixtures/legacy-site-app: {} @@ -1052,6 +1070,9 @@ importers: path-to-regexp: specifier: ^6.2.0 version: 6.2.0 + resolve.exports: + specifier: ^2.0.2 + version: 2.0.2 selfsigned: specifier: ^2.0.1 version: 2.1.1 @@ -3458,6 +3479,46 @@ packages: marked: 0.3.19 dev: false + /@cloudflare/workerd-darwin-64@1.20231016.0: + resolution: {integrity: sha512-rPAnF8Q25+eHEsAopihWeftPW/P0QapY9d7qaUmtOXztWdd6YPQ7JuiWVj4Nvjphge1BleehxAbo4I3Z4L2H1g==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + requiresBuild: true + optional: true + + /@cloudflare/workerd-darwin-arm64@1.20231016.0: + resolution: {integrity: sha512-MvydDdiLXt+jy57vrVZ2lU6EQwCdpieyZoN8uBXSWzfG3zR/6dxU1+okvPQPlHN0jtlufqPeHrpJyAqqgLHUKA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optional: true + + /@cloudflare/workerd-linux-64@1.20231016.0: + resolution: {integrity: sha512-y6Sj37yTzM8QbAghG9LRqoSBrsREnQz8NkcmpjSxeK6KMc2g0L5A/OemCdugNlIiv+zRv9BYX1aosaoxY5JbeQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + + /@cloudflare/workerd-linux-arm64@1.20231016.0: + resolution: {integrity: sha512-LqMIRUHD1YeRg2TPIfIQEhapSKMFSq561RypvJoXZvTwSbaROxGdW6Ku+PvButqTkEvuAtfzN/kGje7fvfQMHg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + + /@cloudflare/workerd-windows-64@1.20231016.0: + resolution: {integrity: sha512-96ojBwIHyiUAbsWlzBqo9P/cvH8xUh8SuBboFXtwAeXcJ6/urwKN2AqPa/QzOGUTCdsurWYiieARHT5WWWPhKw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + requiresBuild: true + optional: true + /@cloudflare/workers-types@3.18.0: resolution: {integrity: sha512-ehKOJVLMeR+tZkYhWEaLYQxl0TaIZu/kE86HF3/RidR8Xv5LuQxpbh+XXAoKVqsaphWLhIgBhgnlN5HGdheXSQ==} @@ -15097,7 +15158,6 @@ packages: /resolve.exports@2.0.2: resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} engines: {node: '>=10'} - dev: true /resolve@1.17.0: resolution: {integrity: sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==} @@ -17483,11 +17543,11 @@ packages: hasBin: true requiresBuild: true optionalDependencies: - '@cloudflare/workerd-darwin-64': registry.npmjs.org/@cloudflare/workerd-darwin-64@1.20231016.0 - '@cloudflare/workerd-darwin-arm64': registry.npmjs.org/@cloudflare/workerd-darwin-arm64@1.20231016.0 - '@cloudflare/workerd-linux-64': registry.npmjs.org/@cloudflare/workerd-linux-64@1.20231016.0 - '@cloudflare/workerd-linux-arm64': registry.npmjs.org/@cloudflare/workerd-linux-arm64@1.20231016.0 - '@cloudflare/workerd-windows-64': registry.npmjs.org/@cloudflare/workerd-windows-64@1.20231016.0 + '@cloudflare/workerd-darwin-64': 1.20231016.0 + '@cloudflare/workerd-darwin-arm64': 1.20231016.0 + '@cloudflare/workerd-linux-64': 1.20231016.0 + '@cloudflare/workerd-linux-arm64': 1.20231016.0 + '@cloudflare/workerd-windows-64': 1.20231016.0 /wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} @@ -17775,59 +17835,3 @@ packages: name: yoga-layout version: 2.0.0-beta.1 dev: true - - registry.npmjs.org/@cloudflare/workerd-darwin-64@1.20231016.0: - resolution: {integrity: sha512-rPAnF8Q25+eHEsAopihWeftPW/P0QapY9d7qaUmtOXztWdd6YPQ7JuiWVj4Nvjphge1BleehxAbo4I3Z4L2H1g==, registry: https://registry-gateway.cloudflare-ui.workers.dev/, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20231016.0.tgz} - name: '@cloudflare/workerd-darwin-64' - version: 1.20231016.0 - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - requiresBuild: true - optional: true - - registry.npmjs.org/@cloudflare/workerd-darwin-arm64@1.20231016.0: - resolution: {integrity: sha512-MvydDdiLXt+jy57vrVZ2lU6EQwCdpieyZoN8uBXSWzfG3zR/6dxU1+okvPQPlHN0jtlufqPeHrpJyAqqgLHUKA==, registry: https://registry-gateway.cloudflare-ui.workers.dev/, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20231016.0.tgz} - name: '@cloudflare/workerd-darwin-arm64' - version: 1.20231016.0 - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - optional: true - - registry.npmjs.org/@cloudflare/workerd-linux-64@1.20231016.0: - resolution: {integrity: sha512-y6Sj37yTzM8QbAghG9LRqoSBrsREnQz8NkcmpjSxeK6KMc2g0L5A/OemCdugNlIiv+zRv9BYX1aosaoxY5JbeQ==, registry: https://registry-gateway.cloudflare-ui.workers.dev/, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20231016.0.tgz} - name: '@cloudflare/workerd-linux-64' - version: 1.20231016.0 - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - requiresBuild: true - optional: true - - registry.npmjs.org/@cloudflare/workerd-linux-arm64@1.20231016.0: - resolution: {integrity: sha512-LqMIRUHD1YeRg2TPIfIQEhapSKMFSq561RypvJoXZvTwSbaROxGdW6Ku+PvButqTkEvuAtfzN/kGje7fvfQMHg==, registry: https://registry-gateway.cloudflare-ui.workers.dev/, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20231016.0.tgz} - name: '@cloudflare/workerd-linux-arm64' - version: 1.20231016.0 - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - requiresBuild: true - optional: true - - registry.npmjs.org/@cloudflare/workerd-windows-64@1.20231016.0: - resolution: {integrity: sha512-96ojBwIHyiUAbsWlzBqo9P/cvH8xUh8SuBboFXtwAeXcJ6/urwKN2AqPa/QzOGUTCdsurWYiieARHT5WWWPhKw==, registry: https://registry-gateway.cloudflare-ui.workers.dev/, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20231016.0.tgz} - name: '@cloudflare/workerd-windows-64' - version: 1.20231016.0 - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - requiresBuild: true - optional: true - - registry.npmjs.org/@cloudflare/workers-types@4.20230914.0: - resolution: {integrity: sha512-OVeN4lFVu1O0PJGZ2d0FwpK8lelFcr33qYOgCh77ErEYmEBO4adwnIxcIsdQbFbhF0ffN6joiVcljD4zakdaeQ==, registry: https://registry-gateway.cloudflare-ui.workers.dev/, tarball: https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20230914.0.tgz} - name: '@cloudflare/workers-types' - version: 4.20230914.0 - dev: true