diff --git a/.changeset/global-polyfills.md b/.changeset/global-polyfills.md new file mode 100644 index 00000000000..15cf1d0b13b --- /dev/null +++ b/.changeset/global-polyfills.md @@ -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, + }, + }, +}; +``` diff --git a/.changeset/remove-default-node-polyfills.md b/.changeset/remove-default-node-polyfills.md new file mode 100644 index 00000000000..a222b9b14d3 --- /dev/null +++ b/.changeset/remove-default-node-polyfills.md @@ -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, + }, +}; +``` diff --git a/.changeset/remove-default-server-node-polyfills.md b/.changeset/remove-default-server-node-polyfills.md deleted file mode 100644 index ec8b7462563..00000000000 --- a/.changeset/remove-default-server-node-polyfills.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@remix-run/dev": major ---- - -Remove default Node.js polyfills from the server build when targeting non-Node.js platforms. - -Any Node.js polyfills that are required for your server code to run on non-Node.js platforms must be manually specified in `remix.config.js` using the `serverNodeBuiltinsPolyfill` option. - -```js -exports.serverNodeBuiltinsPolyfill = { - modules: { - path: true, // Provide a JSPM polyfill - fs: "empty", // Provide an empty polyfill - }, -}; -``` diff --git a/.changeset/restart-dev-server-on-remix-config-change.md b/.changeset/restart-dev-server-on-remix-config-change.md new file mode 100644 index 00000000000..70d3bcfbcb2 --- /dev/null +++ b/.changeset/restart-dev-server-on-remix-config-change.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +Restart dev server when Remix config changes diff --git a/docs/file-conventions/remix-config.md b/docs/file-conventions/remix-config.md index 292a8c75168..b8f8b62d9d0 100644 --- a/docs/file-conventions/remix-config.md +++ b/docs/file-conventions/remix-config.md @@ -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, @@ -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 @@ -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 diff --git a/docs/guides/v2.md b/docs/guides/v2.md index 1ee9bf7dbc7..51257185a68 100644 --- a/docs/guides/v2.md +++ b/docs/guides/v2.md @@ -719,11 +719,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 /** @type {import('@remix-run/dev').AppConfig} */ @@ -741,9 +771,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, + }, }, }; ``` diff --git a/integration/compiler-test.ts b/integration/compiler-test.ts index 116e94abde7..013bb5e4e99 100644 --- a/integration/compiler-test.ts +++ b/integration/compiler-test.ts @@ -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` diff --git a/integration/upload-test.ts b/integration/upload-test.ts index 422d26f7a4e..1918b7c299b 100644 --- a/integration/upload-test.ts +++ b/integration/upload-test.ts @@ -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"; diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index 62d3cee196a..e969fe07b20 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -28,6 +28,7 @@ describe("readConfig", () => { Object { "appDirectory": Any, "assetsBuildDirectory": Any, + "browserNodeBuiltinsPolyfill": undefined, "cacheDirectory": Any, "dev": Object {}, "entryClientFile": "entry.client.tsx", diff --git a/packages/remix-dev/compiler/compiler.ts b/packages/remix-dev/compiler/compiler.ts index 8a8ff227774..8859d27183b 100644 --- a/packages/remix-dev/compiler/compiler.ts +++ b/packages/remix-dev/compiler/compiler.ts @@ -62,6 +62,7 @@ export let create = async (ctx: Context): Promise => { // keep track of manually written artifacts let writes: { + js?: Promise; cssBundle?: Promise; manifest?: Promise; server?: Promise; @@ -100,7 +101,8 @@ export let create = async (ctx: Context): Promise => { // 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); // artifacts/manifest let manifest = await createManifest({ diff --git a/packages/remix-dev/compiler/js/compiler.ts b/packages/remix-dev/compiler/js/compiler.ts index 5fcb4b3c98e..1a5ea772af5 100644 --- a/packages/remix-dev/compiler/js/compiler.ts +++ b/packages/remix-dev/compiler/js/compiler.ts @@ -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"; @@ -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"; @@ -27,6 +27,7 @@ type Compiler = { // produce ./public/build/ compile: () => Promise<{ metafile: esbuild.Metafile; + outputFiles: esbuild.OutputFile[]; hmr?: Manifest["hmr"]; }>; cancel: () => Promise; @@ -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 }), + browserNodeBuiltinsPolyfillPlugin(ctx), ]; if (ctx.options.mode === "development") { @@ -156,11 +149,12 @@ export const create = async ( ): Promise => { let compiler = await esbuild.context({ ...createEsbuildConfig(ctx, refs), + write: false, 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; @@ -181,7 +175,7 @@ export const create = async ( }; } - return { metafile, hmr }; + return { metafile, hmr, outputFiles }; }; return { diff --git a/packages/remix-dev/compiler/js/index.ts b/packages/remix-dev/compiler/js/index.ts index 2e2121911b2..c67a1d153e8 100644 --- a/packages/remix-dev/compiler/js/index.ts +++ b/packages/remix-dev/compiler/js/index.ts @@ -1 +1,2 @@ export { create as createCompiler } from "./compiler"; +export { write } from "./write"; diff --git a/packages/remix-dev/compiler/js/plugins/browserNodeBuiltinsPolyfill.ts b/packages/remix-dev/compiler/js/plugins/browserNodeBuiltinsPolyfill.ts new file mode 100644 index 00000000000..f655a19d807 --- /dev/null +++ b/packages/remix-dev/compiler/js/plugins/browserNodeBuiltinsPolyfill.ts @@ -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", + formatError({ moduleName, importer, polyfillExists }) { + 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(""), + }; + }, + }); diff --git a/packages/remix-dev/compiler/js/write.ts b/packages/remix-dev/compiler/js/write.ts new file mode 100644 index 00000000000..58a9c81e647 --- /dev/null +++ b/packages/remix-dev/compiler/js/write.ts @@ -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); + } +} diff --git a/packages/remix-dev/compiler/server/compiler.ts b/packages/remix-dev/compiler/server/compiler.ts index 651a7064512..3280b32092d 100644 --- a/packages/remix-dev/compiler/server/compiler.ts +++ b/packages/remix-dev/compiler/server/compiler.ts @@ -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"; @@ -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"; @@ -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 { diff --git a/packages/remix-dev/compiler/server/plugins/serverNodeBuiltinsPolyfill.ts b/packages/remix-dev/compiler/server/plugins/serverNodeBuiltinsPolyfill.ts new file mode 100644 index 00000000000..6165ea4d62e --- /dev/null +++ b/packages/remix-dev/compiler/server/plugins/serverNodeBuiltinsPolyfill.ts @@ -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", + }); diff --git a/packages/remix-dev/compiler/watch.ts b/packages/remix-dev/compiler/watch.ts index cd961ace8e8..157891066a0 100644 --- a/packages/remix-dev/compiler/watch.ts +++ b/packages/remix-dev/compiler/watch.ts @@ -11,8 +11,10 @@ import { normalizeSlashes } from "../config/routes"; import type { Manifest } from "../manifest"; function isEntryPoint(config: RemixConfig, file: string): boolean { + let configFile = path.join(config.rootDirectory, "remix.config.js"); let appFile = path.relative(config.appDirectory, file); let entryPoints = [ + configFile, config.entryClientFile, config.entryServerFile, ...Object.values(config.routes).map((route) => route.file), @@ -98,7 +100,8 @@ export async function watch( onBuildFinish?.(ctx, Date.now() - start, manifest !== undefined); }, 100); - let toWatch = [ctx.config.appDirectory]; + let remixConfigPath = path.join(ctx.config.rootDirectory, "remix.config.js"); + let toWatch = [remixConfigPath, ctx.config.appDirectory]; // WARNING: Chokidar returns different paths in change events depending on // whether the path provided to the watcher is absolute or relative. If the @@ -127,7 +130,7 @@ export async function watch( .on("change", async (file) => { if (shouldIgnore(file)) return; onFileChanged?.(file); - await rebuild(); + await (file === remixConfigPath ? restart : rebuild)(); }) .on("add", async (file) => { if (shouldIgnore(file)) return; diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index e5598999a59..00d43bd88f5 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -35,9 +35,9 @@ type Dev = { interface FutureConfig {} -type ServerNodeBuiltinsPolyfillOptions = Pick< +type NodeBuiltinsPolyfillOptions = Pick< EsbuildPluginsNodeModulesPolyfillOptions, - "modules" + "modules" | "globals" >; /** @@ -145,7 +145,12 @@ export interface AppConfig { * The Node.js polyfills to include in the server build when targeting * non-Node.js server platforms. */ - serverNodeBuiltinsPolyfill?: ServerNodeBuiltinsPolyfillOptions; + serverNodeBuiltinsPolyfill?: NodeBuiltinsPolyfillOptions; + + /** + * The Node.js polyfills to include in the browser build. + */ + browserNodeBuiltinsPolyfill?: NodeBuiltinsPolyfillOptions; /** * The platform the server build is targeting. Defaults to "node". @@ -313,7 +318,12 @@ export interface RemixConfig { * The Node.js polyfills to include in the server build when targeting * non-Node.js server platforms. */ - serverNodeBuiltinsPolyfill?: ServerNodeBuiltinsPolyfillOptions; + serverNodeBuiltinsPolyfill?: NodeBuiltinsPolyfillOptions; + + /** + * The Node.js polyfills to include in the browser build. + */ + browserNodeBuiltinsPolyfill?: NodeBuiltinsPolyfillOptions; /** * The platform the server build is targeting. Defaults to "node". @@ -370,7 +380,10 @@ export async function readConfig( // https://github.com/nodejs/node/issues/35889 appConfigModule = require(configFile); } else { - appConfigModule = await import(pathToFileURL(configFile).href); + let stat = fse.statSync(configFile); + appConfigModule = await import( + pathToFileURL(configFile).href + "?t=" + stat.mtimeMs + ); } appConfig = appConfigModule?.default || appConfigModule; } catch (error: unknown) { @@ -400,6 +413,7 @@ export async function readConfig( serverMinify ??= false; let serverNodeBuiltinsPolyfill = appConfig.serverNodeBuiltinsPolyfill; + let browserNodeBuiltinsPolyfill = appConfig.browserNodeBuiltinsPolyfill; let mdx = appConfig.mdx; let postcss = appConfig.postcss ?? true; let tailwind = appConfig.tailwind ?? true; @@ -595,6 +609,7 @@ export async function readConfig( serverMode, serverModuleFormat, serverNodeBuiltinsPolyfill, + browserNodeBuiltinsPolyfill, serverPlatform, mdx, postcss, diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 88c1790c4dc..98e3f7a3299 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -36,7 +36,7 @@ "chokidar": "^3.5.1", "dotenv": "^16.0.0", "esbuild": "0.17.6", - "esbuild-plugins-node-modules-polyfill": "^1.4.0", + "esbuild-plugins-node-modules-polyfill": "^1.6.0", "execa": "5.1.1", "exit-hook": "2.2.1", "express": "^4.17.1", diff --git a/yarn.lock b/yarn.lock index dbe9fe2fb08..b17ab108158 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5102,10 +5102,10 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -esbuild-plugins-node-modules-polyfill@^1.4.0: - version "1.4.0" - resolved "https://registry.npmjs.org/esbuild-plugins-node-modules-polyfill/-/esbuild-plugins-node-modules-polyfill-1.4.0.tgz#2382a164fc07afc94ea4aef1d7ed46bfb4b02f1b" - integrity sha512-B+VD+hXhxbMCbIBsK9SEOrVIannCUefCNE1m4Fu0lrqK0NZKg+sMkmPZZJIg4cOXWt/RUv7AIB+VeVUlgVO8nw== +esbuild-plugins-node-modules-polyfill@^1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/esbuild-plugins-node-modules-polyfill/-/esbuild-plugins-node-modules-polyfill-1.6.0.tgz#450b219936fb51b3875d9df4dc2fcc9cd23c85df" + integrity sha512-A3sM7mrGWMFb3jSjO5AujKRVPmC6m0vTCN1JsNVkRIYSyKaPaAln/Q+AF5ZWrjWwxFmUZ9YoS0yk1pDEmb6E5A== dependencies: "@jspm/core" "^2.0.1" local-pkg "^0.4.3"