Skip to content

Commit 5321826

Browse files
authored
feat: support npm resolution for file imports (#4135)
* feat: support npm resolution for wasm (etc.) imports * chore: add changeset * chore: only import `exports` from * fix: handle scoped npm packages * fix: improve npm package name detection * fix: set resolve.exports conditions to match other esbuild conditions * fix: add tests * chore: rebuild pnpm-lock * fix: prettier on package.json files * chore: rebuild pnpm-lock
1 parent 775fb07 commit 5321826

File tree

18 files changed

+282
-65
lines changed

18 files changed

+282
-65
lines changed

.changeset/long-starfishes-mate.md

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
feat: resolve npm exports for file imports
6+
7+
Previously, when using wasm (or other static files) from an npm package, you would have to import the file like so:
8+
9+
```js
10+
import wasm from "../../node_modules/svg2png-wasm/svg2png_wasm_bg.wasm";
11+
```
12+
13+
This update now allows you to import the file like so, assuming it's exposed and available in the package's `exports` field:
14+
15+
```js
16+
import wasm from "svg2png-wasm/svg2png_wasm_bg.wasm";
17+
```
18+
19+
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).
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# import-wasm-example
2+
3+
`import-wasm-example` is a test fixture that imports a `wasm` file from `import-wasm-static`, testing npm module resolution with wrangler imports.
+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "import-wasm-example",
3+
"version": "1.0.1",
4+
"private": true,
5+
"description": "",
6+
"author": "",
7+
"main": "src/index.js",
8+
"scripts": {
9+
"check:type": "tsc",
10+
"test": "npx vitest run",
11+
"test:ci": "npx vitest run",
12+
"test:watch": "npx vitest",
13+
"type:tests": "tsc -p ./tests/tsconfig.json"
14+
},
15+
"devDependencies": {
16+
"undici": "^5.9.1",
17+
"wrangler": "workspace:*",
18+
"@cloudflare/workers-tsconfig": "workspace:^"
19+
},
20+
"dependencies": {
21+
"import-wasm-static": "workspace:^"
22+
}
23+
}
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// this is from the `import-wasm-static` fixture defined above
2+
// and setup inside package.json to mimic an npm package
3+
import multiply from "import-wasm-static/multiply.wasm";
4+
5+
export default {
6+
async fetch(request) {
7+
// just instantiate and return something
8+
// we're really just testing the import at the top of this file
9+
const multiplyModule = await WebAssembly.instantiate(multiply);
10+
return new Response(`${multiplyModule.exports.multiply(7, 3)}`);
11+
},
12+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { resolve } from "path";
2+
import { fetch } from "undici";
3+
import { describe, it, beforeAll, afterAll } from "vitest";
4+
import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived";
5+
6+
describe("wrangler correctly imports wasm files with npm resolution", () => {
7+
let ip: string, port: number, stop: (() => Promise<unknown>) | undefined;
8+
9+
beforeAll(async () => {
10+
({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [
11+
"--port=0",
12+
]));
13+
});
14+
15+
afterAll(async () => {
16+
await stop?.();
17+
});
18+
19+
// if the worker compiles, is running, and returns 21 (7 * 3) we can assume that the wasm module was imported correctly
20+
it("responds", async ({ expect }) => {
21+
const response = await fetch(`http://${ip}:${port}/`);
22+
const text = await response.text();
23+
expect(text).toBe("21");
24+
});
25+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
3+
"compilerOptions": {
4+
"types": ["node"]
5+
},
6+
"include": ["**/*.ts", "../../../node-types.d.ts"]
7+
}
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"esModuleInterop": true,
5+
"module": "CommonJS",
6+
"lib": ["ES2020"],
7+
"types": ["node"],
8+
"moduleResolution": "node",
9+
"noEmit": true
10+
},
11+
"include": ["tests", "../../node-types.d.ts"]
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
name = "import-wasm-example"
2+
compatibility_date = "2023-10-02"
3+
4+
main = "src/index.js"

fixtures/import-wasm-static/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# import-wasm-static
2+
3+
`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.
+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "import-wasm-static",
3+
"version": "0.0.1",
4+
"private": true,
5+
"sideEffects": false,
6+
"exports": {
7+
"./multiply.wasm": "./wasm/multiply.wasm"
8+
}
9+
}
46 Bytes
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
(module
2+
(func $multiply (param $p1 i32) (param $p2 i32) (result i32)
3+
local.get $p1
4+
local.get $p2
5+
i32.mul)
6+
(export "multiply" (func $multiply))
7+
)

packages/wrangler/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
"miniflare": "3.20231016.0",
111111
"nanoid": "^3.3.3",
112112
"path-to-regexp": "^6.2.0",
113+
"resolve.exports": "^2.0.2",
113114
"selfsigned": "^2.0.1",
114115
"source-map": "0.6.1",
115116
"source-map-support": "0.5.21",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { extractPackageName } from "../deployment-bundle/module-collection";
2+
3+
describe("Module Collection", () => {
4+
describe("extractPackageName", () => {
5+
test.each`
6+
importString | packageName
7+
${"wrangler"} | ${"wrangler"}
8+
${"wrangler/example"} | ${"wrangler"}
9+
${"wrangler/example.wasm"} | ${"wrangler"}
10+
${"@cloudflare/wrangler"} | ${"@cloudflare/wrangler"}
11+
${"@cloudflare/wrangler/example"} | ${"@cloudflare/wrangler"}
12+
${"@cloudflare/wrangler/example.wasm"} | ${"@cloudflare/wrangler"}
13+
${"./some/file"} | ${null}
14+
${"../some/file"} | ${null}
15+
${"/some/file"} | ${null}
16+
`("$importString --> $packageName", ({ importString, packageName }) => {
17+
expect(extractPackageName(importString)).toBe(packageName);
18+
});
19+
});
20+
});

packages/wrangler/src/deployment-bundle/bundle.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export const COMMON_ESBUILD_OPTIONS = {
3131
loader: { ".js": "jsx", ".mjs": "jsx", ".cjs": "jsx" },
3232
} as const;
3333

34+
// build conditions used by esbuild, and when resolving custom `import` calls
35+
export const BUILD_CONDITIONS = ["workerd", "worker", "browser"];
36+
3437
/**
3538
* Information about Wrangler's bundling process that needs passed through
3639
* for DevTools sourcemap transformation
@@ -310,7 +313,7 @@ export async function bundleWorker(
310313
sourceRoot: destination,
311314
minify,
312315
metafile: true,
313-
conditions: ["workerd", "worker", "browser"],
316+
conditions: BUILD_CONDITIONS,
314317
...(process.env.NODE_ENV && {
315318
define: {
316319
// use process.env["NODE_ENV" + ""] so that esbuild doesn't replace it

packages/wrangler/src/deployment-bundle/module-collection.ts

+65-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { readdirSync } from "node:fs";
33
import { readFile } from "node:fs/promises";
44
import path from "node:path";
55
import globToRegExp from "glob-to-regexp";
6+
import { exports as resolveExports } from "resolve.exports";
67
import { logger } from "../logger";
8+
import { BUILD_CONDITIONS } from "./bundle";
79
import {
810
findAdditionalModules,
911
findAdditionalModuleWatchDirs,
@@ -64,6 +66,23 @@ export const noopModuleCollector: ModuleCollector = {
6466
},
6567
};
6668

69+
// Extracts a package name from a string that may be a file path
70+
// or a package name. Returns null if the string is not a valid
71+
// Handles `wrangler`, `wrangler/example`, `wrangler/example.wasm`,
72+
// `@cloudflare/wrangler`, `@cloudflare/wrangler/example`, etc.
73+
export function extractPackageName(packagePath: string) {
74+
if (packagePath.startsWith(".")) return null;
75+
76+
const match = packagePath.match(/^(@[^/]+\/)?([^/]+)/);
77+
78+
if (match) {
79+
const scoped = match[1] || "";
80+
const packageName = match[2];
81+
return `${scoped}${packageName}`;
82+
}
83+
return null;
84+
}
85+
6786
export function createModuleCollector(props: {
6887
entry: Entry;
6988
findAdditionalModules: boolean;
@@ -237,7 +256,7 @@ export function createModuleCollector(props: {
237256
// take the file and massage it to a
238257
// transportable/manageable format
239258

240-
const filePath = path.join(args.resolveDir, args.path);
259+
let filePath = path.join(args.resolveDir, args.path);
241260

242261
// If this was a found additional module, mark it as external.
243262
// Note, there's no need to watch the file here as we already
@@ -251,6 +270,51 @@ export function createModuleCollector(props: {
251270
// it to `esbuild` to bundle it.
252271
if (isJavaScriptModuleRule(rule)) return;
253272

273+
// Check if this file is possibly from an npm package
274+
// and if so, validate the import against the package.json exports
275+
// and resolve the file path to the correct file.
276+
if (args.path.includes("/") && !args.path.startsWith(".")) {
277+
// get npm package name from string, taking into account scoped packages
278+
const packageName = extractPackageName(args.path);
279+
if (!packageName) {
280+
throw new Error(
281+
`Unable to extract npm package name from ${args.path}`
282+
);
283+
}
284+
const packageJsonPath = path.join(
285+
process.cwd(),
286+
"node_modules",
287+
packageName,
288+
"package.json"
289+
);
290+
// Try and read the npm package's package.json
291+
// and then resolve the import against the package's exports
292+
// and then finally override filePath if we find a match.
293+
try {
294+
const packageJson = JSON.parse(
295+
await readFile(packageJsonPath, "utf8")
296+
);
297+
const testResolved = resolveExports(
298+
packageJson,
299+
args.path.replace(`${packageName}/`, ""),
300+
{
301+
conditions: BUILD_CONDITIONS,
302+
}
303+
);
304+
if (testResolved) {
305+
filePath = path.join(
306+
process.cwd(),
307+
"node_modules",
308+
packageName,
309+
testResolved[0]
310+
);
311+
}
312+
} catch (e) {
313+
// We tried, now it'll just fall-through to the previous behaviour
314+
// and ENOENT if the absolute file path doesn't exist.
315+
}
316+
}
317+
254318
const fileContent = await readFile(filePath);
255319
const fileHash = crypto
256320
.createHash("sha1")

0 commit comments

Comments
 (0)