Skip to content

Commit 8a0d242

Browse files
authored
feat: add polyfills build option to enable or disable polyfills (#496)
1 parent 72ca1c4 commit 8a0d242

21 files changed

Lines changed: 533 additions & 12 deletions

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,46 @@ The module is transformed and type checked like the test files are and it is not
200200
included in the npm package. It is loaded once for each of the emitted script
201201
and ESM output, before any test file.
202202

203+
### Polyfills
204+
205+
dnt adds polyfills for language features that may not exist in the environment
206+
implied by `compilerOptions.target`. If you're targeting a runtime that already
207+
supports a feature, use the `polyfills` build option to opt out and keep the
208+
extra code out of your package:
209+
210+
```ts
211+
await build({
212+
// ...etc...
213+
polyfills: {
214+
importMeta: false,
215+
},
216+
});
217+
```
218+
219+
Pass `true` or `false` instead of an object to enable or disable every polyfill
220+
at once. Anything you don't specify continues to be decided by the target, so
221+
`polyfills` overrides the target rather than replacing it.
222+
223+
The supported names are `arrayFindLast`, `arrayFromAsync`, `errorCause`,
224+
`importMeta`, `objectHasOwn`, `promiseWithResolvers`, and `stringReplaceAll`.
225+
226+
Note that the polyfills also provide the type declarations for the features they
227+
polyfill, so opting out of one means relying on your `compilerOptions.lib`
228+
declarations for it instead.
229+
230+
The `importMeta` polyfill can only be disabled when the `scriptModule` build
231+
option is `false`, because `import.meta` is not valid CommonJS:
232+
233+
```ts
234+
await build({
235+
// ...etc...
236+
scriptModule: false,
237+
polyfills: {
238+
importMeta: false,
239+
},
240+
});
241+
```
242+
203243
### Shims
204244

205245
dnt will shim the globals specified in the build options. For example, if you

deno.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"tests/node_types_project/npm",
5252
"tests/package_mappings_project/npm",
5353
"tests/polyfill_array_find_last_project/npm",
54+
"tests/polyfill_disabled_project/npm",
5455
"tests/polyfill_project/npm",
5556
"tests/shim_project/npm",
5657
"tests/test_project/npm",

lib/polyfills.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright 2018-2024 the Deno authors. MIT license.
2+
3+
import { assertEquals, assertThrows } from "@std/assert";
4+
import {
5+
resolvePolyfillOptions,
6+
resolveUseImportMetaPolyfill,
7+
} from "./polyfills.ts";
8+
9+
Deno.test("resolvePolyfillOptions - undefined leaves everything to the target", () => {
10+
assertEquals(resolvePolyfillOptions(undefined), {});
11+
});
12+
13+
Deno.test("resolvePolyfillOptions - boolean applies to every polyfill", () => {
14+
assertEquals(resolvePolyfillOptions(false), {
15+
arrayFindLast: false,
16+
arrayFromAsync: false,
17+
errorCause: false,
18+
importMeta: false,
19+
objectHasOwn: false,
20+
promiseWithResolvers: false,
21+
stringReplaceAll: false,
22+
});
23+
assertEquals(
24+
Object.values(resolvePolyfillOptions(true)).every((v) => v),
25+
true,
26+
);
27+
});
28+
29+
Deno.test("resolvePolyfillOptions - object only includes what's specified", () => {
30+
assertEquals(
31+
resolvePolyfillOptions({ importMeta: false, errorCause: true }),
32+
{ importMeta: false, errorCause: true },
33+
);
34+
});
35+
36+
Deno.test("resolvePolyfillOptions - undefined values fall back to the target", () => {
37+
assertEquals(
38+
resolvePolyfillOptions({ importMeta: undefined, errorCause: true }),
39+
{ errorCause: true },
40+
);
41+
});
42+
43+
Deno.test("resolvePolyfillOptions - throws for an unknown polyfill", () => {
44+
assertThrows(
45+
() =>
46+
resolvePolyfillOptions(
47+
{ importMata: false } as Record<string, boolean>,
48+
),
49+
Error,
50+
"Unknown polyfill 'importMata'",
51+
);
52+
});
53+
54+
Deno.test("resolveUseImportMetaPolyfill - required when emitting a script module", () => {
55+
assertEquals(
56+
resolveUseImportMetaPolyfill({
57+
polyfills: {},
58+
target: "Latest",
59+
emitScriptModule: true,
60+
}),
61+
true,
62+
);
63+
});
64+
65+
Deno.test("resolveUseImportMetaPolyfill - throws when disabled with a script module", () => {
66+
assertThrows(
67+
() =>
68+
resolveUseImportMetaPolyfill({
69+
polyfills: { importMeta: false },
70+
target: "ES2021",
71+
emitScriptModule: true,
72+
}),
73+
Error,
74+
"cannot be disabled when emitting a script module",
75+
);
76+
});
77+
78+
Deno.test("resolveUseImportMetaPolyfill - esm only follows the target by default", () => {
79+
assertEquals(
80+
resolveUseImportMetaPolyfill({
81+
polyfills: {},
82+
target: "ES2021",
83+
emitScriptModule: false,
84+
}),
85+
true,
86+
);
87+
assertEquals(
88+
resolveUseImportMetaPolyfill({
89+
polyfills: {},
90+
target: "Latest",
91+
emitScriptModule: false,
92+
}),
93+
false,
94+
);
95+
});
96+
97+
Deno.test("resolveUseImportMetaPolyfill - esm only respects an explicit override", () => {
98+
assertEquals(
99+
resolveUseImportMetaPolyfill({
100+
polyfills: { importMeta: false },
101+
target: "ES2021",
102+
emitScriptModule: false,
103+
}),
104+
false,
105+
);
106+
assertEquals(
107+
resolveUseImportMetaPolyfill({
108+
polyfills: { importMeta: true },
109+
target: "Latest",
110+
emitScriptModule: false,
111+
}),
112+
true,
113+
);
114+
});

lib/polyfills.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright 2018-2024 the Deno authors. MIT license.
2+
3+
import type { PolyfillName, PolyfillOptions, ScriptTarget } from "./types.ts";
4+
5+
/** Every polyfill dnt knows how to apply. */
6+
export const polyfillNames: readonly PolyfillName[] = [
7+
"arrayFindLast",
8+
"arrayFromAsync",
9+
"errorCause",
10+
"importMeta",
11+
"objectHasOwn",
12+
"promiseWithResolvers",
13+
"stringReplaceAll",
14+
];
15+
16+
/** Resolves the user provided polyfill options into an explicit
17+
* enabled/disabled value per polyfill.
18+
*
19+
* Polyfills that the user didn't specify are left out of the result so that
20+
* the script target continues to decide whether they're used.
21+
*/
22+
export function resolvePolyfillOptions(
23+
options: PolyfillOptions | undefined,
24+
): Record<string, boolean> {
25+
if (options == null) {
26+
return {};
27+
}
28+
if (typeof options === "boolean") {
29+
return Object.fromEntries(polyfillNames.map((name) => [name, options]));
30+
}
31+
32+
const resolved: Record<string, boolean> = {};
33+
for (const [name, enabled] of Object.entries(options)) {
34+
if (enabled == null) {
35+
continue;
36+
}
37+
if (!polyfillNames.includes(name as PolyfillName)) {
38+
throw new Error(
39+
`Unknown polyfill '${name}' specified in the 'polyfills' option. ` +
40+
`Supported polyfills: ${polyfillNames.join(", ")}`,
41+
);
42+
}
43+
resolved[name] = enabled;
44+
}
45+
return resolved;
46+
}
47+
48+
/** Whether the `import.meta` polyfill applies, given the resolved polyfill
49+
* overrides and the rest of the build options.
50+
*
51+
* `import.meta` is a syntax error in CommonJS, so the polyfill is required
52+
* whenever a script module is emitted regardless of the target.
53+
*/
54+
export function resolveUseImportMetaPolyfill(options: {
55+
polyfills: Record<string, boolean>;
56+
target: ScriptTarget;
57+
emitScriptModule: boolean;
58+
}): boolean {
59+
const explicit = options.polyfills["importMeta"];
60+
if (options.emitScriptModule) {
61+
if (explicit === false) {
62+
throw new Error(
63+
"The 'importMeta' polyfill cannot be disabled when emitting a script " +
64+
"module because `import.meta` is not valid CommonJS. Set the " +
65+
"'scriptModule' build option to false to distribute an ES module only.",
66+
);
67+
}
68+
return true;
69+
}
70+
return explicit ?? options.target !== "Latest";
71+
}

lib/types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,30 @@ export interface PackageJson {
5252
[propertyName: string]: any;
5353
}
5454

55+
// NOTICE: make sure to update the `name` of each polyfill in the rust code
56+
// when changing the names on this
57+
// todo(dsherret): code generate this from the Rust code to prevent out of sync issues
58+
59+
/** Name of a polyfill that dnt may add to the output. */
60+
export type PolyfillName =
61+
| "arrayFindLast"
62+
| "arrayFromAsync"
63+
| "errorCause"
64+
| "importMeta"
65+
| "objectHasOwn"
66+
| "promiseWithResolvers"
67+
| "stringReplaceAll";
68+
69+
/** Explicitly enables or disables polyfills by name.
70+
*
71+
* Provide `true` or `false` to enable or disable all polyfills, or an object
72+
* to control them individually. Polyfills that aren't specified fall back to
73+
* what the script target implies.
74+
*/
75+
export type PolyfillOptions =
76+
| boolean
77+
| Partial<Record<PolyfillName, boolean>>;
78+
5579
// NOTICE: make sure to update `ScriptTarget` in the rust code when changing the names on this
5680
// todo(dsherret): code generate this from the Rust code to prevent out of sync issues
5781

mod.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@ import {
1818
} from "./lib/compiler.ts";
1919
import { type ShimOptions, shimOptionsToTransformShims } from "./lib/shims.ts";
2020
import { getNpmIgnoreText } from "./lib/npm_ignore.ts";
21-
import type { PackageJson, ScriptTarget } from "./lib/types.ts";
21+
import {
22+
resolvePolyfillOptions,
23+
resolveUseImportMetaPolyfill,
24+
} from "./lib/polyfills.ts";
25+
import type {
26+
PackageJson,
27+
PolyfillOptions,
28+
ScriptTarget,
29+
} from "./lib/types.ts";
2230
import { glob, runNpmCommand, standardizePath } from "./lib/utils.ts";
2331
import {
2432
type SpecifierMappings,
@@ -30,7 +38,11 @@ import { getPackageJson } from "./lib/package_json.ts";
3038
import { getTestRunnerCode } from "./lib/test_runner/get_test_runner_code.ts";
3139

3240
export { emptyDir } from "@std/fs/empty-dir";
33-
export type { PackageJson } from "./lib/types.ts";
41+
export type {
42+
PackageJson,
43+
PolyfillName,
44+
PolyfillOptions,
45+
} from "./lib/types.ts";
3446
export type { JsxEmit, LibName, SourceMapOptions } from "./lib/compiler.ts";
3547
export type { ShimOptions } from "./lib/shims.ts";
3648

@@ -88,6 +100,20 @@ export interface BuildOptions {
88100
* @default true
89101
*/
90102
esModule?: boolean;
103+
/** Explicitly enables or disables polyfills, overriding what
104+
* `compilerOptions.target` implies.
105+
*
106+
* Provide `true` or `false` to enable or disable all polyfills, or an object
107+
* to control them individually:
108+
*
109+
* ```ts
110+
* polyfills: { importMeta: false }
111+
* ```
112+
*
113+
* @remarks The `importMeta` polyfill cannot be disabled unless `scriptModule`
114+
* is `false`, because `import.meta` is not valid CommonJS.
115+
*/
116+
polyfills?: PolyfillOptions;
91117
/** Skip running `npm install`.
92118
* @default false
93119
*/
@@ -250,6 +276,14 @@ export async function build(options: BuildOptions): Promise<void> {
250276
(!!options.declaration && !options.skipSourceOutput);
251277
const packageManager = options.packageManager ?? "npm";
252278
const scriptTarget = options.compilerOptions?.target ?? "ES2021";
279+
const polyfills = resolvePolyfillOptions(options.polyfills);
280+
// `import.meta` call sites are rewritten by the TypeScript compiler rather
281+
// than the transform, so resolve this up front and keep both sides in sync
282+
polyfills["importMeta"] = resolveUseImportMetaPolyfill({
283+
polyfills,
284+
target: scriptTarget,
285+
emitScriptModule: options.scriptModule !== false,
286+
});
253287
const entryPoints: EntryPoint[] = options.entryPoints.map((e, i) => {
254288
if (typeof e === "string") {
255289
return {
@@ -433,7 +467,9 @@ export async function build(options: BuildOptions): Promise<void> {
433467
program = project.createProgram();
434468
emit({
435469
transformers: {
436-
before: [compilerTransforms.transformImportMeta],
470+
before: polyfills["importMeta"]
471+
? [compilerTransforms.transformImportMeta]
472+
: [],
437473
},
438474
});
439475
writeFile(
@@ -458,7 +494,9 @@ export async function build(options: BuildOptions): Promise<void> {
458494
program = getProgramAndMaybeTypeCheck("script");
459495
emit({
460496
transformers: {
461-
before: [compilerTransforms.transformImportMeta],
497+
before: polyfills["importMeta"]
498+
? [compilerTransforms.transformImportMeta]
499+
: [],
462500
},
463501
});
464502
writeFile(
@@ -634,6 +672,7 @@ export async function build(options: BuildOptions): Promise<void> {
634672
testShims,
635673
mappings: options.mappings,
636674
target: scriptTarget,
675+
polyfills,
637676
importMap: options.importMap,
638677
configFile: options.configFile,
639678
cwd: path.toFileUrl(cwd).toString(),

0 commit comments

Comments
 (0)