Native Vite support for Deno imports (jsr:, npm:, https://).
Use Deno for the frontend and enjoy development without the hassle of node_modules
and package managers!
-
Enable Deno for the whole workspace
-
Adjust your Vite config to use this plugin
-
Enjoy development without
node_modules
and package managers
This plugin injects a custom rollup resolver at the earliest stage possible (even before the builtin fs loader) which catches nearly every import.
Instead of letting Vite resolve the imports the plugin consults the Deno CLI (deno info --json
). This ensures that all
imports link to exactly the same files Deno would use (including import mapping).
Additionally the plugin contains a Node.js/NPM compatible resolver (including probing and package.json exports), because
deno info
only outputs the resolved npm package versions, not the exact files.
A custom loader is able to locate the source code for all those new specifier schemes (for example in the global deno
cache). file:
URLs are mapped back to ordinary paths to make HMR work (Vite has no clue about file:
URLs). The
source code of npm:
imports is transformed from CJS to ESM if necessary.
./foo.ts
: relative importshttps://deno.land/x/[email protected]/mod.ts
: HTTPS importsjsr:@scope/foo
: JSRnpm:foo@^1.0.0
: NPM with version rangefoo
: Mapped imports (deno.json
>imports
)
... and maybe imports that are not even invented yet, none of the imports above is explicitly handled by the plugin,
deno info
tells us everything that is needed to get the module source code.
npm:[email protected]
: resolved NPM imports (including subpath imports)
The following imports are only used internally, but you may get in contact with them in the DevTools. You cannot use
them in your source code because Deno wont be able to resolve them, but you may specify them in
extra_import_map
npm-data:[email protected]/bar.js
: This references an internal file of the package tarballnpm-probe:[email protected]/bar
: Same as above, but with probing. Will be resolved to anpm-data:
URL
Currently only build script configurations are supported because vite.config.ts
will always be run with Node :-(. It
is not that complicated as it sounds, you just have to use the JS API of Vite.
import { pluginDeno } from "@deno-plc/vite-plugin-deno";
import type { InlineConfig } from "vite";
export const config: InlineConfig = {
configFile: false, // configuration is inlined here
server: {
port: 80,
},
plugins: [
pluginDeno({
// see configuration docs
}),
],
};
import { createServer } from "vite";
import { config } from "./vite.ts";
const server = await createServer(config);
await server.listen();
server.printUrls();
import { build } from "vite";
import { config } from "./vite.ts";
await build(config);
Include the DOM in the TS compiler options and define the build tasks
{
"tasks": {
"dev": "deno run -A scripts/dev.ts",
"release": "deno run -A scripts/release.ts"
},
"compilerOptions": {
"lib": [
"deno.window",
"deno.ns",
"ESNext",
"DOM",
"DOM.Iterable",
"DOM.AsyncIterable",
"webworker"
]
}
}
Override the locations of deno.json
and deno.lock
. Passed to deno --config ...
and deno --lock ...
Set to "deno"
if you are bundling for Deno. This enables node:
imports.
Set to "browser"
(default) if you are bundling for Browsers. This prefers NPM browser
exports.
Those packages can always be imported, even when they don't show up in the deno module graph. This can be used to resolve packages that are imported by injected code like HMR.
This can be a anything the Map
constructor supports. (yes, we use a Map for the import map)
This import map can be used to polyfill node:
(for details see here) or do the same as
undeclared_npm_imports
on a more granular level.
Sometimes it might be required to add #standalone
to the replaced import, otherwise you will get errors because the
replaced import is (of course) not reported by deno info
. The #standalone
instructs the plugin to treat the import
like an independent entrypoint.
Those imports wont be touched. RegExps are preferred (strings are converted to RegExps anyway)
This plugin works without node_modules
most of the time, but in some cases this directory is required to make thins
work.
There are various reasons why a dependency has to reside in node_modules
. The most popular reasons are the Babel and
PostCSS plugin loaders (they depend on node_modules
to find the plugins) and dependency pre-bundling.
There are good news: Deno supports node_modules
natively! (= you don't even need Node.js and NPM)
Just create a package.json
and add items to the dependencies section. The next time Deno runs, it will create a
node_modules
dir and symlink all packages to the global deno cache. Now all the plugin resolvers are happy!
Sometimes it might be required to use node_modules
for a regular dependency of you app. This might be required for
packages with a lot of files (like lodash) or if the package does crazy things with CommonJS (in this case the
plugin fails to import it, because it is unable to transform it to ESM).
After adding the dependency to the dependencies
section of package.json
(do this manually, not using the npm CLI),
you can exclude
it from this plugin. This
re-enables Vite's module resolution.
This plugin does not automatically polyfill node:
in browsers, but you can easily do so by setting extra_import_map
.
Unfortunately most polyfill packages do crazy things with exports (I tested buffer
and util
, both didn't worked out
of the box for different reasons). This is why it is not as straightforward as mapping node:buffer
to npm:buffer
- Select an appropriate polyfill package (likely on NPM)
- Look up its most recent version
- link it in
extra_import_map
:"node:buffer", "https://cdn.jsdelivr.net/npm/[email protected]/+esm#standalone"
We use a https:// import to get rid of CommonJS issues, but in the end it is just a Deno remote import (=Deno downloads the file, no CDN import)
Make sure to add #standalone
to the replaced import.
In case you don't want to polyfill a module and instead let the import fail, redirect it to virtual:node:null
. This
makes Vite happy but any attempt to load the module will fail with an error (It resolves to a file that just contains a
throw
statement). This is useful if a package does feature detection: It tries to dynamically import node:fs
(or any
other module), it it succeeds it uses it and if it fails it doesn't do anything filesystem-related.
Currently React is unsupported.
- The Deno LSP has problems with React. It is about missing JSXRuntime types...
react-dom
does some extremely ugly things with cjs exports (like exporting inside an if statement ...). For this reason it cannot be transformed to ESM correctly At the same time it needs to be linked by JSX which makes it extremely difficult to use it via thenode_modules
fallback, but this is not the only problem.
I personally only use Preact, so this is not top priority.
Until this is supported out of the box you could use the Preact configuration. If you are doing this, all react imports are redirected to preact and the whole application is run with the react compatibility layer... (this works without any problems 🤯) Read more: https://preactjs.com/guide/v10/switching-to-preact.
If you really need React, please file an issue.
Although @preact/preset-vite
works when the respective Babel plugins are linked via node_modules
, I do recommend
against using it.
With a only few lines of configuration you can do exactly the same. By the way: this speeds up the development server a lot, because it uses ESBuild instead of Babel
Just update your Vite config to set up prefresh (the Preact HMR Engine) and ESBuild for JSX transformation:
import { pluginDeno } from "@deno-plc/vite-plugin-deno";
import prefresh from "@prefresh/vite"; // HMR
import type { InlineConfig, Plugin } from "vite";
export const config: InlineConfig = {
plugins: [
pluginDeno({
env: "browser",
undeclared_npm_imports: [
// injected by JSX transform
"preact/jsx-runtime",
"preact/jsx-dev-runtime",
// injected by HMR
"@prefresh/core",
"@prefresh/utils",
// injected by react compat
"@preact/compat",
],
extra_import_map: new Map([
// react compat
["react", "@preact/compat"],
["react-dom", "@preact/compat"],
]),
}),
// HMR Plugin
prefresh({
// `node_modules` is excluded internally, lets do the same
exclude: [/^npm/, /registry.npmjs.org/, /^jsr/, /^https?/],
}) as Plugin,
],
// JSX transform
esbuild: {
jsx: "automatic",
jsxImportSource: "preact",
},
};
And your deno.json
:
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
}
If you want to use the Preact DevTools, follow the instructions there: https://preactjs.github.io/preact-devtools/ (it's one import)
We need the prefresh exclude rule to replicate the internal exclude of all paths containing node_modules
. Otherwise
prefresh would inject HMR helpers into libraries and the code that powers HMR, which causes very strange errors.
Just set the env
option to deno
, everything should work out of the box! (even with node:
imports)
This can replace deno bundle
.
If you want a lightweight solution, check out esbuild_deno_loader, which is exactly the same for esbuild.
See Usage with React
For other packages it might be required to use the node_modules
fallback
The classic vite.config.ts
file would be executed using Node.js instead of Deno. Just use scripts as shown above.
Unsupported because dependency optimization relies on node_modules
. If you really need it (lodash), see
node_modules
section
Some other plugins require Babel and Babel plugins. The Babel plugin loader depends on node_modules
, see
node_modules
section. In order to get the best DX possible, you should avoid Babel based plugins (for
most setups Babel isn't really needed, see Usage wit Preact. Using builtin esbuild is usually way faster).
tailwindcss
currently needs to be installed in node_modules
, see node_modules
section
The recommended way is to use Tailwind Play CDN during development and Tailwind CLI for release build.
Until denoland/deno#24899 has been resolved, you need to include the following snippet in
order to achieve the correct behavior when node:fs.stat()
is called with an invalid file path. Otherwise you get
errors like [vite] Pre-transform error: EINVAL: invalid argument, stat
.
const deno_stat = Deno.stat;
Deno.stat = (...args) =>
deno_stat(...args).catch((err) => {
if (String(err.message).startsWith(`The filename, directory name, or volume label syntax is incorrect.`)) {
return Deno.stat("./not-existing");
} else {
throw err;
}
});
esbuild_deno_loader does exactly the same for esbuild. The basic principle of operation is the same.
resolve.exports helped a lot, it handles all the package.json
fields.
Copyright (C) 2024 Hans Schallmoser
This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA or see https://www.gnu.org/licenses/