Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dev): make browser Node polyfills opt-in #7269

Merged
merged 5 commits into from
Aug 29, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/global-polyfills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@remix-run/dev": minor
---

The `serverNodeBuiltinsPolyfill` option (along with the newly added `browserNodeBuiltinsPolyfill`) now supports defining global polyfills in addition to module polyfills.

For example, to polyfill Node's `Buffer` global:

```js
module.exports = {
serverNodeBuiltinsPolyfill: {
globals: {
Buffer: true,
},
// You'll probably need to polyfill the "buffer" module
// too since the global polyfill imports this:
modules: {
buffer: true,
},
},
};
```
33 changes: 33 additions & 0 deletions .changeset/remove-default-node-polyfills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@remix-run/dev": major
---

Remove default Node.js polyfills.

Any Node.js polyfills (or empty polyfills) that are required for your browser code must be configured via the `browserNodeBuiltinsPolyfill` option in `remix.config.js`.

```js
exports.browserNodeBuiltinsPolyfill = {
modules: {
buffer: true,
fs: "empty",
},
globals: {
Buffer: true,
},
};
```

If you're targeting a non-Node.js server platform, any Node.js polyfills (or empty polyfills) that are required for your server code must be configured via the `serverNodeBuiltinsPolyfill` option in `remix.config.js`.

```js
exports.serverNodeBuiltinsPolyfill = {
modules: {
buffer: true,
fs: "empty",
},
globals: {
Buffer: true,
},
};
```
16 changes: 0 additions & 16 deletions .changeset/remove-default-server-node-polyfills.md

This file was deleted.

27 changes: 26 additions & 1 deletion docs/file-conventions/remix-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ exports.appDirectory = "./elsewhere";
The path to the browser build, relative to remix.config.js. Defaults to
"public/build". Should be deployed to static hosting.

## browserNodeBuiltinsPolyfill

The Node.js polyfills to include in the browser build. Polyfills are provided by [JSPM][jspm] and configured via [esbuild-plugins-node-modules-polyfill].

```js filename=remix.config.js
exports.browserNodeBuiltinsPolyfill = {
modules: {
buffer: true, // Provide a JSPM polyfill
fs: "empty", // Provide an empty polyfill
},
globals: {
Buffer: true,
},
};
```

When using this option and targeting non-Node.js server platforms, you may also want to configure Node.js polyfills for the server via [`serverNodeBuiltinsPolyfill`][server-node-builtins-polyfill].

## cacheDirectory

The path to a directory Remix can use for caching things in development,
Expand Down Expand Up @@ -168,12 +186,17 @@ The Node.js polyfills to include in the server build when targeting non-Node.js
```js filename=remix.config.js
exports.serverNodeBuiltinsPolyfill = {
modules: {
path: true, // Provide a JSPM polyfill
buffer: true, // Provide a JSPM polyfill
fs: "empty", // Provide an empty polyfill
},
globals: {
Buffer: true,
},
};
```

When using this option, you may also want to configure Node.js polyfills for the browser via [`browserNodeBuiltinsPolyfill`][browser-node-builtins-polyfill].

## serverPlatform

The platform the server build is targeting, which can either be `"neutral"` or
Expand Down Expand Up @@ -232,3 +255,5 @@ There are a few conventions that Remix uses you should be aware of.
[jspm]: https://github.com/jspm/jspm-core
[esbuild-plugins-node-modules-polyfill]: https://www.npmjs.com/package/esbuild-plugins-node-modules-polyfill
[port]: ../other-api/dev-v2#options-1
[browser-node-builtins-polyfill]: #browsernodebuiltinspolyfill
[server-node-builtins-polyfill]: #servernodebuiltinspolyfill
37 changes: 35 additions & 2 deletions docs/guides/v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -685,11 +685,41 @@ The default server module output format will be changing from `cjs` to `esm`.

In your `remix.config.js`, you should specify either `serverModuleFormat: "cjs"` to retain existing behavior, or `serverModuleFormat: "esm"`, to opt into the future behavior.

## `browserNodeBuiltinsPolyfill`

Polyfills for Node.js built-in modules will no longer be provided by default for the browser. In Remix v2 you'll need to explicitly reintroduce any polyfills (or blank polyfills) as required:

```js filename=remix.config.js
module.exports = {
browserNodeBuiltinsPolyfill: {
modules: {
buffer: true,
fs: "empty",
},
globals: {
Buffer: true,
},
},
};
```

Even though we recommend being explicit about which polyfills are allowed in your browser bundle, especially since some polyfills can be quite large, you can quickly reinstate the full set of polyfills from Remix v1 with the following configuration:

```js filename=remix.config.js
const { builtinModules } = require("node:module");

module.exports = {
browserNodeBuiltinsPolyfill: {
modules: builtinModules,
},
};
```

## `serverNodeBuiltinsPolyfill`

Polyfills for Node.js built-in modules will no longer be provided by default for non-Node.js server platforms.

If you are targeting a non-Node.js server platform and want to opt into the future default behavior, in `remix.config.js` you should first remove all server polyfills by providing an empty object for `serverNodeBuiltinsPolyfill.modules`:
If you are targeting a non-Node.js server platform and want to opt into the future default behavior in v1, in `remix.config.js` you should first remove all server polyfills by explicitly providing an empty object for `serverNodeBuiltinsPolyfill.modules`:

```js filename=remix.config.js
module.exports = {
Expand All @@ -705,9 +735,12 @@ You can then reintroduce any polyfills (or blank polyfills) as required.
module.exports = {
serverNodeBuiltinsPolyfill: {
modules: {
path: true,
buffer: true,
fs: "empty",
},
globals: {
Buffer: true,
},
},
};
```
Expand Down
5 changes: 5 additions & 0 deletions integration/compiler-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ test.describe("compiler", () => {
"esm-only-single-export",
...getDependenciesToBundle("esm-only-exports-pkg"),
],
browserNodeBuiltinsPolyfill: {
modules: {
path: true,
},
},
};
`,
"app/fake.server.ts": js`
Expand Down
7 changes: 7 additions & 0 deletions integration/upload-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ const __dirname = url.fileURLToPath(new URL(".", import.meta.url));

test.beforeAll(async () => {
fixture = await createFixture({
config: {
browserNodeBuiltinsPolyfill: {
modules: {
url: true,
},
},
},
files: {
"app/routes/file-upload-handler.tsx": js`
import * as path from "node:path";
Expand Down
1 change: 1 addition & 0 deletions packages/remix-dev/__tests__/readConfig-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("readConfig", () => {
Object {
"appDirectory": Any<String>,
"assetsBuildDirectory": Any<String>,
"browserNodeBuiltinsPolyfill": undefined,
"cacheDirectory": Any<String>,
"dev": Object {},
"entryClientFile": "entry.client.tsx",
Expand Down
4 changes: 3 additions & 1 deletion packages/remix-dev/compiler/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export let create = async (ctx: Context): Promise<Compiler> => {

// keep track of manually written artifacts
let writes: {
js?: Promise<void>;
cssBundle?: Promise<void>;
manifest?: Promise<void>;
server?: Promise<void>;
Expand Down Expand Up @@ -100,7 +101,8 @@ export let create = async (ctx: Context): Promise<Compiler> => {
// js compilation (implicitly writes artifacts/js)
let js = await tasks.js;
if (!js.ok) throw error ?? js.error;
let { metafile, hmr } = js.value;
let { metafile, outputFiles, hmr } = js.value;
writes.js = JS.write(ctx.config, outputFiles);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As explained in my other comment, we now need to manage writing the output of the JS build to disk.


// artifacts/manifest
let manifest = await createManifest({
Expand Down
18 changes: 6 additions & 12 deletions packages/remix-dev/compiler/js/compiler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as path from "node:path";
import { builtinModules as nodeBuiltins } from "node:module";
import * as esbuild from "esbuild";
import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill";

import type { RemixConfig } from "../../config";
import { type Manifest } from "../../manifest";
Expand All @@ -13,6 +12,7 @@ import { absoluteCssUrlsPlugin } from "../plugins/absoluteCssUrlsPlugin";
import { emptyModulesPlugin } from "../plugins/emptyModules";
import { mdxPlugin } from "../plugins/mdx";
import { externalPlugin } from "../plugins/external";
import { browserNodeBuiltinsPolyfillPlugin } from "./plugins/browserNodeBuiltinsPolyfill";
import { cssBundlePlugin } from "../plugins/cssBundlePlugin";
import { cssModulesPlugin } from "../plugins/cssModuleImports";
import { cssSideEffectImportsPlugin } from "../plugins/cssSideEffectImports";
Expand All @@ -27,6 +27,7 @@ type Compiler = {
// produce ./public/build/
compile: () => Promise<{
metafile: esbuild.Metafile;
outputFiles: esbuild.OutputFile[];
hmr?: Manifest["hmr"];
}>;
cancel: () => Promise<void>;
Expand Down Expand Up @@ -94,15 +95,7 @@ const createEsbuildConfig = (
emptyModulesPlugin(ctx, /^@remix-run\/(deno|cloudflare|node)(\/.*)?$/, {
includeNodeModules: true,
}),
nodeModulesPolyfillPlugin({
// For the browser build, we replace any Node built-ins that don't have a
// polyfill with an empty module. This ensures the build can pass without
// having to mark all Node built-ins as external which can result in other
// issues, e.g. https://github.com/remix-run/remix/issues/5521. We then
// rely on tree-shaking to remove all unused polyfills and fallbacks.
fallback: "empty",
}),
externalPlugin(/^node:.*/, { sideEffects: false }),
Copy link
Member Author

@markdalgleish markdalgleish Aug 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since esbuild-plugins-node-modules-polyfill is configured to manage fallbacks, this usage of externalPlugin was redundant.

browserNodeBuiltinsPolyfillPlugin(ctx),
];

if (ctx.options.mode === "development") {
Expand Down Expand Up @@ -156,11 +149,12 @@ export const create = async (
): Promise<Compiler> => {
let compiler = await esbuild.context({
...createEsbuildConfig(ctx, refs),
write: false,
Copy link
Member Author

@markdalgleish markdalgleish Aug 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to manually write the result of the browser build to disk so that esbuild-plugins-node-modules-polyfill can check the build output in memory for any missing/unconfigured polyfills after tree-shaking has occured. This is important in the context of Remix since route files often depend on Node builtins regardless of whether they're used in browser code. If write is enabled, the outputFiles are not kept in memory and are not passed to the onEnd callback of the esbuild plugin.

If you're interested, here's the PR where I implemented this: imranbarbhuiya/esbuild-plugins-node-modules-polyfill#152

metafile: true,
});

let compile = async () => {
let { metafile } = await compiler.rebuild();
let { metafile, outputFiles } = await compiler.rebuild();
writeMetafile(ctx, "metafile.js.json", metafile);

let hmr: Manifest["hmr"] | undefined = undefined;
Expand All @@ -181,7 +175,7 @@ export const create = async (
};
}

return { metafile, hmr };
return { metafile, hmr, outputFiles };
};

return {
Expand Down
1 change: 1 addition & 0 deletions packages/remix-dev/compiler/js/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { create as createCompiler } from "./compiler";
export { write } from "./write";
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill";

import type { Context } from "../../context";

export const browserNodeBuiltinsPolyfillPlugin = (ctx: Context) =>
nodeModulesPolyfillPlugin({
// Rename plugin to improve error message attribution
name: "browser-node-builtins-polyfill-plugin",
// Only pass through the "modules" and "globals" options to ensure we
// don't leak the full plugin API to Remix consumers.
modules: ctx.config.browserNodeBuiltinsPolyfill?.modules ?? {},
globals: ctx.config.browserNodeBuiltinsPolyfill?.globals ?? {},
// Mark any unpolyfilled Node builtins in the build output as errors.
fallback: "error",
Copy link
Member Author

@markdalgleish markdalgleish Aug 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added support for built-time errors for unpolyfilled/unconfigured modules here: imranbarbhuiya/esbuild-plugins-node-modules-polyfill#152

formatError({ moduleName, importer, polyfillExists }) {
Copy link
Member Author

@markdalgleish markdalgleish Aug 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So that we could customise errors for Remix consumers who don't have direct access to the esbuild plugin, I added support for custom error formatting here: imranbarbhuiya/esbuild-plugins-node-modules-polyfill#153.

let normalizedModuleName = moduleName.replace("node:", "");
let modulesConfigKey = /^[a-z_]+$/.test(normalizedModuleName)
? normalizedModuleName
: JSON.stringify(normalizedModuleName);

return {
text: (polyfillExists
? [
`Node builtin "${moduleName}" (imported by "${importer}") must be polyfilled for the browser. `,
`You can enable this polyfill in your Remix config, `,
`e.g. \`browserNodeBuiltinsPolyfill: { modules: { ${modulesConfigKey}: true } }\``,
]
: [
`Node builtin "${moduleName}" (imported by "${importer}") doesn't have a browser polyfill available. `,
`You can stub it out with an empty object in your Remix config `,
`e.g. \`browserNodeBuiltinsPolyfill: { modules: { ${modulesConfigKey}: "empty" } }\` `,
"but note that this may cause runtime errors if the module is used in your browser code.",
]
).join(""),
};
},
});
14 changes: 14 additions & 0 deletions packages/remix-dev/compiler/js/write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as path from "node:path";
import type { OutputFile } from "esbuild";
import fse from "fs-extra";

import type { RemixConfig } from "../../config";

export async function write(config: RemixConfig, outputFiles: OutputFile[]) {
await fse.ensureDir(path.dirname(config.assetsBuildDirectory));

for (let file of outputFiles) {
await fse.ensureDir(path.dirname(file.path));
await fse.writeFile(file.path, file.contents);
}
}
9 changes: 2 additions & 7 deletions packages/remix-dev/compiler/server/compiler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as esbuild from "esbuild";
import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill";

import { type Manifest } from "../../manifest";
import { loaders } from "../utils/loaders";
Expand All @@ -9,6 +8,7 @@ import { vanillaExtractPlugin } from "../plugins/vanillaExtract";
import { cssFilePlugin } from "../plugins/cssImports";
import { absoluteCssUrlsPlugin } from "../plugins/absoluteCssUrlsPlugin";
import { emptyModulesPlugin } from "../plugins/emptyModules";
import { serverNodeBuiltinsPolyfillPlugin } from "./plugins/serverNodeBuiltinsPolyfill";
import { mdxPlugin } from "../plugins/mdx";
import { serverAssetsManifestPlugin } from "./plugins/manifest";
import { serverBareModulesPlugin } from "./plugins/bareImports";
Expand Down Expand Up @@ -66,12 +66,7 @@ const createEsbuildConfig = (
];

if (ctx.config.serverNodeBuiltinsPolyfill) {
plugins.unshift(
nodeModulesPolyfillPlugin({
// Ensure only "modules" option is passed to the plugin
modules: ctx.config.serverNodeBuiltinsPolyfill.modules,
})
);
plugins.unshift(serverNodeBuiltinsPolyfillPlugin(ctx));
}

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill";

import type { Context } from "../../context";

export const serverNodeBuiltinsPolyfillPlugin = (ctx: Context) =>
nodeModulesPolyfillPlugin({
// Rename plugin to improve error message attribution
name: "server-node-builtins-polyfill-plugin",
// Only pass through the "modules" and "globals" options to ensure we
// don't leak the full plugin API to Remix consumers.
modules: ctx.config.serverNodeBuiltinsPolyfill?.modules ?? {},
globals: ctx.config.serverNodeBuiltinsPolyfill?.globals ?? {},
// Since the server environment may provide its own Node polyfills,
// we don't define any fallback behavior here and allow all Node
// builtins to be marked as external
fallback: "none",
MichaelDeBoey marked this conversation as resolved.
Show resolved Hide resolved
});
Loading