DEV Community

Cover image for Creating your own NPM package
Deepak Kumar
Deepak Kumar

Posted on

Creating your own NPM package

What is a npm package

NPM stands for Node package manager which is a software registry for Open source software where users can publish packages for public and private uses alike.

Managing packages inside a npm package

A npm package is normally a basic application which uses other packages to manage and use basic functionalities. But in normal case scenario we use yarn or npm to install these packages but while creating a npm package we need a global way to store and manage packages across the entire project. So for this I used LERNA, the official documentation for lerna can be found here.

This is the basic folder structure for managing lerna projects lerna folder structure

The folder has following dependencies :-

  • cli (Managing the command line interface)
  • local-API (The back-end API built upon express)
  • local-client (The UI interface uses React, redux and bulma for styling)

Let us first look at CLI

For CLI, I used a package called commander in which you write code to describe your command line interface. Commander looks after parsing the arguments into options and command-arguments, displays usage errors for problems, and implements a help system for unrecognized options it displays an error.

The official documentation to commander can be found Here.

The commander takes in a command and some options, in this case the command is serve and option is a port number where this program runs which by default is 4005.

const serveCommand = new Command()
  .command('serve [filename]')
  .description('Open a file for editing')
  .option('-p --port <number>', 'port to run server on', '4005')
  .action(async (filename = 'notebook.js', options: { port: string }) => {
    try {
      const dir = path.join(process.cwd(), path.dirname(filename));
      await serve(
        parseInt(options.port),
        path.basename(filename),
        dir,
        !isProduction
      );
      console.log(
        `Opened ${filename}. Navigate to http://localhost:${options.port} to edit the file.`
      );
    } catch (error: any) {
      if (error.code === 'EADDRINUSE') {
        console.error('Port is already in use please try another port');
      } else {
        console.log(error.message);
      }
      process.exit(1);
    }
  });

Enter fullscreen mode Exit fullscreen mode

Besides this the following dependencies are also used in the cli package

cli package

In the local api directory, all the routes are defined, it basically has two routes:-

  • A get route to /cells (This endpoint returns the existing cell data from the notebook file)
router.get('/cells', async (req, res) => {
    try {
      const result = await fs.readFile(fullPath, { encoding: 'utf-8' });
      res.send(JSON.parse(result));
    } catch (error: any) {
      if (error.code === 'ENOENT') {
        await fs.writeFile(fullPath, '[]', 'utf-8');
        res.send([]);
      } else {
        throw error;
      }
    }
  });
Enter fullscreen mode Exit fullscreen mode

At first we are trying to read the existing contents of the file using the file system(fs) inbuilt module and since the data is in JSON format we are parsing it and sending it back.

Wrapping the entire code in a try-catch block makes it easier to send errors rather than crashing the app.

  • A post route to /cells (This endpoint sends the existing cell data to be saved into notebook file)
router.post('/cells', async (req, res) => {
    const { cells }: { cells: Cell[] } = req.body;
    await fs.writeFile(fullPath, JSON.stringify(cells), 'utf-8');
    res.send({ status: 'ok' });
  });
Enter fullscreen mode Exit fullscreen mode

Similarly in the post route we are getting the data from the client converting it into a JSON string and writing it back using the same file system(fs) module.

You can find more about FS modules here.

Finally comes the client module which is built using React, redux, typescript, bulma and monaco editor.

For this the main challenges were:-

  • Building a markdown editor
  • Building a solution for writing & compiling code online in the browser itself.
  • Building a bundler for compilation.

For the markdown editor I ended up using @uiw/react-md-editor.

import { useState, useEffect, useRef } from 'react';
import MDEditor from '@uiw/react-md-editor';
import './css/text-editor.css';
import { Cell } from '../state';
import { useActions } from '../hooks/use-actions';

interface TextEditorProps {
  cell: Cell;
}

const TextEditor: React.FC<TextEditorProps> = ({ cell }) => {
  const [editing, setEditing] = useState(false);
  const ref = useRef<HTMLDivElement | null>(null);
  const { updateCell } = useActions();

  useEffect(() => {
    const listener = (event: MouseEvent) => {
      if (
        ref.current &&
        event.target &&
        ref.current.contains(event.target as Node)
      )
        return;
      setEditing(false);
    };

    document.addEventListener('click', listener, { capture: true });

    return () => {
      document.removeEventListener('click', listener, { capture: true });
    };
  }, []);

  if (editing) {
    return (
      <div className="text-editor" ref={ref}>
        <MDEditor
          value={cell.content}
          onChange={(v) => updateCell(cell.id, v || '')}
        />
      </div>
    );
  }

  return (
    <div className="text-editor card" onClick={() => setEditing(true)}>
      <div className="card-content">
        <MDEditor.Markdown source={cell.content || 'Click to edit'} />
      </div>
    </div>
  );
};

export default TextEditor;

Enter fullscreen mode Exit fullscreen mode

To read more about @uiw/react-md-editor you can go here.

Now for writing and compiling code online I needed a code editor which looks and feels like VS-Code and so ended up using monaco editor which is created by microsoft itself and powers VS code also.

This is the configuration i used for my editor component:-

<MonacoEditor
  editorDidMount={onEditorMount}
  value={initialValue}
  height="100%"
  language="javascript"
  theme="dark"
  options={{
    wordWrap: 'on',
    matchBrackets: 'always',
    minimap: { enabled: false },
    showUnused: false,
    folding: false,
    lineNumbersMinChars: 3,
    fontSize: 18,
    scrollBeyondLastLine: false,
    automaticLayout: true,
  }}
/>
Enter fullscreen mode Exit fullscreen mode

Now after creating the editor there were 2 more issues:-

  1. The code was not formatted properly.
  2. And there were some highlighting issues.

To fix code formatting i created a button which calls the prettier package to format code.

 const onFormatClick = () => {
    const unFormatted = editorRef.current.getModel().getValue();
    const formatted = prettier
      .format(unFormatted, {
        parser: 'babel',
        plugins: [parser],
        useTabs: false,
        semi: true,
        singleQuote: true,
      })
      .replace(/\n$/, '');

    editorRef.current.setValue(formatted);
  };

<button onClick={onFormatClick}>
  Format
</button>
Enter fullscreen mode Exit fullscreen mode

Then for code highlightiong i used jscodeshift and monaco-jsx-highlighterand created a mount component which ran when editor mounted:-

 const onEditorMount: EditorDidMount = (getValue, monacoEditor) => {
    editorRef.current = monacoEditor;
    monacoEditor.onDidChangeModelContent(() => {
      onChange(getValue());
    });

    monacoEditor.getModel()?.updateOptions({ tabSize: 2 });

    const highlighter = new Highlighter(
      // @ts-ignore
      window.monaco,
      codeshift,
      monacoEditor
    );
    highlighter.highLightOnDidChangeModelContent(
      () => {},
      () => {},
      undefined,
      () => {}
    );
  };

Enter fullscreen mode Exit fullscreen mode

Then comes the most important part The bundler:-

For bundling, the basic use-case is we need to get the code, compile it and then show the output. Now what if an user imports some packages from npm registry?
For that reason we would need a bundler and in my case i used unpkg and created a bundler service.


import * as esbuild from 'esbuild-wasm';
import { fetchPlugin } from './plugins/fetch-plugin';
import { unpkgPathPlugin } from './plugins/unpkg-path-plugin';

let service: esbuild.Service;
const bundle = async (rawCode: string) => {
  if (!service) {
    service = await esbuild.startService({
      worker: true,
      wasmURL: 'https://unpkg.com/esbuild-wasm@0.8.27/esbuild.wasm',
    });
  }

  try {
    const result = await service.build({
      entryPoints: ['index.js'],
      bundle: true,
      write: false,
      plugins: [unpkgPathPlugin(), fetchPlugin(rawCode)],
      define: {
        'process.env.NODE_ENV': '"production"',
        global: 'window',
      },
      jsxFactory: '_React.createElement',
      jsxFragment: '_React.Fragment',
    });

    return { code: result.outputFiles[0].text, err: '' };
  } catch (err) {
    return { code: '', err: (err as Error).message };
  }
};

export default bundle;

Enter fullscreen mode Exit fullscreen mode

Putting it all together

After this its time to deploy it to npm registry, now for that we would need to create a npm account which is pretty much straight forward and can be easily done by going to npm website and signing up.

Now we need to make some changes in our package.json file.

We need to add the main, types(if its a typescript file) and license (mostly MIT for OSS)

first step

Now add the publishConfig to be public or private and the entry folder from where npm serves.
step2

That's it you are good to go...
Here is the entire source code for the project.

Do check it out and leave a star..

Top comments (0)