DEV Community

Valeria
Valeria

Posted on • Updated on • Originally published at valeriavg.dev

How to import files in NodeJS without Webpack

There are quite a few cases where you can benefit from importing an arbitrary file directly: in universal apps, tests, or simply to avoid boilerplate. Good news: you don't need Babel, Webpack, or anything else for it.

For this tutorial, we'll make a server that will render a static HTML page with CSS styles, loaded as modules.

Create a CSS file:

echo 'html{background:teal;color:white;}' >> styles.css
Enter fullscreen mode Exit fullscreen mode

An HTML template:

echo '<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Example</title>
  </head>
  <body>
    Is background teal?
  </body>
</html>' >> template.html
Enter fullscreen mode Exit fullscreen mode

And a server to render the result:

// index.js
const http = require("http");
const template = require("./template.html");
const css = require("./styles.css");
const html = template.replace("</head>", "<style>" + css + "</style></head>");

const server = new http.Server((_req, res) => {
  res.setHeader("content-type", "text/html");
  res.end(html);
});

server.listen(0, () => {
  console.info(
    `Server is listening on http://localhost:${server.address().port}`
  );
});
Enter fullscreen mode Exit fullscreen mode

If you try running this file now you'll get an error:

node index
template.html:1
<!DOCTYPE html>
^

SyntaxError: Unexpected token '<'
Enter fullscreen mode Exit fullscreen mode

To fix it we are going to tell NodeJS how to handle these extensions. Prepare to be amazed because all the code we need is:

// hook.js
const Module = require("module");
const fs = require("fs");

const resolveTextFile = function (module, path) {
  const content = fs.readFileSync(path).toString();
  module.exports = content;
};

Module._extensions[".html"] = resolveTextFile;
Module._extensions[".css"] = resolveTextFile;
Enter fullscreen mode Exit fullscreen mode

Now we can start the server like this:

node -r ./hook index
# Server is listening on http://localhost:<random_port>
Enter fullscreen mode Exit fullscreen mode

Follow the link and you should see the HTML page with proper styles:

HTML page saying "Is background teal?" with white letters on a teal background

Note: If you have any troubles running this example, try using NodeJS v14.5.0.

By the way, you can add require('./hook') directly at the beginning of index.js instead of using -r or --require command-line argument.

What about ECMAScript Modules?

Great question! ECMAScript modules support in NodeJS is still unstable, meaning that it might drastically change in the future, but as for February 2021 we can load custom modules with node --experimental-loader <filename>.

My ECMAScript server module looks like this:

// index.mjs
import http from "http";
import template from "./template.html";
import css from "./styles.css";

const html = template.replace("</head>", "<style>" + css + "</style></head>");

const server = new http.Server((_req, res) => {
  res.setHeader("content-type", "text/html");
  res.end(html);
});

server.listen(0, () => {
  console.info(
    `Server module is listening on http://localhost:${server.address().port}`
  );
});
Enter fullscreen mode Exit fullscreen mode

And the experimental loader is as follows:

// loader.mjs
import { URL, pathToFileURL } from "url";

const baseURL = pathToFileURL(`${process.cwd()}/`).href;

// css styles or html files
const extensionsRegex = /\.(html|css)$/;

export function resolve(specifier, context, defaultResolve) {
  const { parentURL = baseURL } = context;

  // Node.js normally errors on unknown file extensions, so return a URL for
  // specifiers ending in the specified file extensions.
  if (extensionsRegex.test(specifier)) {
    return {
      url: new URL(specifier, parentURL).href,
    };
  }
  // Let Node.js handle all other specifiers.
  return defaultResolve(specifier, context, defaultResolve);
}

export function getFormat(url, context, defaultGetFormat) {
  // Now that we patched resolve to let new file types through, we need to
  // tell Node.js what format such URLs should be interpreted as.
  if (extensionsRegex.test(url)) {
    return {
      format: "module",
    };
  }
  // Let Node.js handle all other URLs.
  return defaultGetFormat(url, context, defaultGetFormat);
}

export function transformSource(source, context, defaultTransformSource) {
  const { url } = context;
  if (extensionsRegex.test(url)) {
    return {
      source: `export default ${JSON.stringify(source.toString())}`,
    };
  }

  // Let Node.js handle all other sources.
  return defaultTransformSource(source, context, defaultTransformSource);
}

Enter fullscreen mode Exit fullscreen mode

Don't forget to use .mjs extension for ES modules or otherwise enable them (e.g. set "type":"module" in package.json).

And run it with:

node --experimental-loader ./loader.mjs index.mjs
# (node:14706) ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time
# (Use `node --trace-warnings ...` to show where the warning was created)
# ESM Server is listening on http://localhost:<random_port>
Enter fullscreen mode Exit fullscreen mode

What about TypeScript?

Yet another great question! It's actually easy: we can use CommonJS approach.

Let's prepare TypeScript project:

npm init -y &&
npm install typescript @types/node ts-node --save-dev &&
echo '{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "typeRoots": ["node_modules/@types", "typings"]
  },
  "exclude": ["node_modules"]
}
'>>tsconfig.json

Enter fullscreen mode Exit fullscreen mode

I've set esModuleInterop to true to keep hook.js intact, otherwise, we'd need to change module.exports=content to module.exports.default=content.

My typed version of the infamous server:

// index.ts
import { Server } from "http";
import template from "./template.html";
import css from "./styles.css";
import { AddressInfo } from "net";

const html = template.replace("</head>", "<style>" + css + "</style></head>");

const server = new Server((_req, res) => {
  res.setHeader("content-type", "text/html");
  res.end(html);
});

server.listen(0, () => {
  console.info(
    `TS Server is listening on http://localhost:${
      (server.address() as AddressInfo).port
    }`
  );
});

Enter fullscreen mode Exit fullscreen mode

Once again, if we try running it now, it'll fail:

./node_modules/.bin/ts-node -r ./hook index.ts

# TSError: ⨯ Unable to compile TypeScript:
# index.ts:2:22 - error TS2307: Cannot find module './template.html' or its corresponding type declarations.
Enter fullscreen mode Exit fullscreen mode

To fix it we, of course, need to provide typings for our modules. Since we'll be using ts-node the easiest way is to create a folder with the following structure:

mkdir -p "typings/*.css" &&
mkdir "typings/*.html" &&
echo 'declare module "*.css" {
  const content: string;
  export default content;
}' >> "typings/*.css/index.d.ts" &&
echo 'declare module "*.html" {
  const content: string;
  export default content;
}' >> "typings/*.html/index.d.ts" 
Enter fullscreen mode Exit fullscreen mode

We've already included typings folder in tsconfig.json, but you can call it anything you want as long as it's referenced:

{
  "compilerOptions": {
    // ...
    "typeRoots": ["node_modules/@types", "typings"]
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Run again and enjoy refreshing teal background:

./node_modules/.bin/ts-node -r ./hook index.ts
# TS Server is listening on http://localhost:<random_port>
Enter fullscreen mode Exit fullscreen mode

Nice, what's next?

You could:

  • Add pre- or post-processing step for the styles (e.g. use sass,less or postcss) or some template engine for HTML (e.g. liquid, haml or pug.
  • Make a GraphQL server using .graphql files directly.
  • Write unit tests for your front-end JavaScript with lightweight or custom test runners.
  • Make your own code transpiler/bundler

Thank you for reading! Hope you've enjoyed it!

Top comments (6)

Collapse
 
grahamthedev profile image
GrahamTheDev • Edited

Thanks for sharing this with me, really useful to know and I am always a fan of anything that removes the dependency on compiling or build steps etc.

Plus this has a special place in my heart as you inline your critical CSS (which I believe to be one real use case for this if you also added a minification and caching step and pull the file from there!).

As Node "poker arounder" rather than an expert though, I am aware I could have missed some more specific problems it solves! ❤🦄

Collapse
 
valeriavg profile image
Valeria

Thank you! You pretty much covered it, though there may be use cases I don't know yet either 😉

Collapse
 
afeld profile image
Aidan Feldman

I was able to use this to test Cloudflare Worker code that imports assets with mocha. Thanks!

github.com/afeld/jsonp/pull/203/co...

Collapse
 
valeriavg profile image
Valeria

Awesome! Thank you for sharing ☺️

Collapse
 
ap13p profile image
Afief S

cmiiw but with nodejs version 14, you don't need to pass a flag to enable esm in nodejs, you only need to check one of this condition:

  • either name the file with .mjs
  • add type field with value modules inside package.json

reference: nodejs.org/dist/latest-v14.x/docs/...

Collapse
 
valeriavg profile image
Valeria • Edited

Yes that's correct, but I don't think I contradicted that in the article.

Don't forget to use .mjs extension for ES modules or otherwise enable them (e.g. set "type":"module" in package.json).