Skip to content

Latest commit

 

History

History
540 lines (412 loc) · 15.3 KB

README.md

File metadata and controls

540 lines (412 loc) · 15.3 KB

dnt - Deno to Node Transform

deno doc

Deno to npm package build tool.

This tool is under active early development and hasn't been tested in a lot of scenarios. Examine its output thoroughly before publishing. If you encounter any problems or challenges, please open an issue to help us improve it.

What does this do?

Takes a Deno module and creates an npm package for use in Node.js.

There are several steps done in a pipeline:

  1. Transforms Deno code to Node/canonical TypeScript including files found by deno test.
    • Rewrites module specifiers.
    • Injects shims for any Deno namespace or other global name usages as specified.
    • Rewrites Skypack and esm.sh specifiers to bare specifiers and includes these dependencies in a package.json.
    • When remote modules cannot be resolved to an npm package, it downloads them and rewrites specifiers to make them local.
    • Allows mapping any specifier to an npm package.
  2. Type checks the output.
  3. Emits ESM, CommonJS, and TypeScript declaration files along with a package.json file.
  4. Runs the final output in Node.js through a test runner calling all Deno.test calls.

Setup

  1. Create a build script file:

    // ex. scripts/build_npm.ts
    import { build } from "https://deno.land/x/dnt/mod.ts";
    
    await build({
      entryPoints: ["./mod.ts"],
      outDir: "./npm",
      shims: {
        // see JS docs for overview and more options
        deno: true,
      },
      package: {
        // package.json properties
        name: "your-package",
        version: Deno.args[0],
        description: "Your package.",
        license: "MIT",
        repository: {
          type: "git",
          url: "git+https://github.com/username/package.git",
        },
        bugs: {
          url: "https://github.com/username/package/issues",
        },
      },
    });
    
    // post build steps
    Deno.copyFileSync("LICENSE", "npm/LICENSE");
    Deno.copyFileSync("README.md", "npm/README.md");
  2. Ignore the output directory with your source control if you desire (ex. add npm/ to .gitignore).

  3. Run it and npm publish:

    # run script
    deno run -A scripts/build_npm.ts 0.1.0
    
    # go to output directory and publish
    cd npm
    npm publish

Example Build Logs

[dnt] Transforming...
[dnt] Running npm install...
[dnt] Building project...
[dnt] Type checking...
[dnt] Emitting declaration files...
[dnt] Emitting ESM package...
[dnt] Emitting CommonJS package...
[dnt] Running tests...

> test
> node test_runner.js

Running tests in ./umd/mod.test.js...

test escapeWithinString ... ok
test escapeChar ... ok

Running tests in ./esm/mod.test.js...

test escapeWithinString ... ok
test escapeChar ... ok
[dnt] Complete!

Docs

Disabling Type Checking, Testing, Declaration Emit, or CommonJS Output

Use the following options to disable any one of these, which are enabled by default:

await build({
  // ...etc...
  typeCheck: false,
  test: false,
  declaration: false,
  cjs: false,
});

Top Level Await

Top level await doesn't work in CommonJS and dnt will error if a top level await is used and you are outputting CommonJS code. If you want to output a CommonJS package then you'll have to restructure your code to not use any top level awaits. Otherwise, set the cjs build option to false:

await build({
  // ...etc...
  cjs: false,
});

Shims

dnt will shim the globals specified in the build options. For example, if you specify the following build options:

await build({
  // ...etc...
  shims: {
    deno: true,
  },
});

Then write a statement like so...

Deno.readTextFileSync(...);

...dnt will create a shim file in the output, re-exporting the @deno/shim-deno npm shim package and change the Deno global be used as a property of this object.

import * as dntShim from "./_dnt.shims.js";

dntShim.Deno.readTextFileSync(...);

Test-Only Shimming

If you want a shim to only be used in your test code as a dev dependency, then specify "dev" for the option.

For example, to use the Deno namespace only for development and the setTimeout and setInterval browser/Deno compatible shims in the distributed code, you would do:

await build({
  // ...etc...
  shims: {
    deno: "dev",
    timers: true,
  },
});

Preventing Shimming

To prevent shimming in specific instances, add a // dnt-shim-ignore comment:

// dnt-shim-ignore
Deno.readTextFileSync(...);

...which will now output that code as-is.

Built-In Shims

Set any of these properties to true (distribution and test) or "dev" (test only) to use them.

  • deno - Shim the Deno namespace.
  • timers - Shim the global setTimeout and setInterval functions with Deno and browser compatible versions.
  • prompts - Shim the global confirm, alert, and prompt functions.
  • blob - Shim the Blob global with the one from the "buffer" module.
  • crypto - Shim the crypto global.
  • undici - Shim fetch, File, FormData, Headers, Request, and Response by using the "undici" package (https://www.npmjs.com/package/undici).

Custom Shims (Advanced)

In addition to the pre-defined shim options, you may specify your own custom packages to use to shim globals.

For example:

await build({
  cjs: false, // node-fetch 3+ only supports ESM
  // ...etc...
  shims: {
    // ...etc...
    custom: [{
      package: {
        name: "node-fetch",
        version: "~3.1.0",
      },
      globalNames: [{
        // for the `fetch` global...
        name: "fetch",
        // use the default export of node-fetch
        exportName: "default",
      }, {
        name: "RequestInit",
        typeOnly: true, // only used in type declarations
      }],
    }, {
      // this is what `blob: true` does internally
      package: {
        name: "buffer", // uses node's "buffer" module
      },
      globalNames: ["Blob"],
    }],
    // shims to only use in the tests
    customDev: [{
      // this is what `timers: "dev"` does internally
      package: {
        name: "@deno/shim-timers",
        version: "~0.1.0",
      },
      globalNames: ["setTimeout", "setInterval"],
    }],
  },
});

Specifier to Npm Package Mappings

In most cases, dnt won't know about an npm package being available for one of your dependencies and will download remote modules to include in your package. There are scenarios though where an npm package may exist and you want to use it instead. This can be done by providing a specifier to npm package mapping.

For example:

await build({
  // ...etc...
  mappings: {
    "https://deno.land/x/code_block_writer@11.0.0/mod.ts": {
      name: "code-block-writer",
      version: "^11.0.0",
    },
  },
});

This will:

  1. Change all "https://deno.land/x/code_block_writer@11.0.0/mod.ts" specifiers to "code-block-writer"
  2. Add a package.json dependency for "code-block-writer": "^11.0.0".

Note that dnt will error if you specify a mapping and it is not found in the code. This is done to prevent the scenario where a remote specifier's version is bumped and the mapping isn't updated.

Mapping specifier to npm package subpath

Say an npm package called example had a subpath at sub_path.js and you wanted to map https://deno.land/x/example@0.1.0/sub_path.ts to that subpath. To specify this, you would do the following:

await build({
  // ...etc...
  mappings: {
    "https://deno.land/x/example@0.1.0/sub_path.ts": {
      name: "example",
      version: "^0.1.0",
      subPath: "sub_path.js", // note this
    },
  },
});

This would cause the following:

import * as mod from "https://deno.land/x/example@0.1.0/sub_path.ts";

...to go to...

import * as mod from "example/sub_path.js";

...with a dependency on "example": "^0.1.0".

Multiple Entry Points

To do this, specify multiple entry points like so (ex. an entry point at . and another at ./internal):

await build({
  entryPoints: ["mod.ts", {
    name: "./internal",
    path: "internal.ts",
  }],
  // ...etc...
});

This will create a package.json with these as exports:

{
  "name": "your-package",
  // etc...
  "main": "./umd/mod.js",
  "module": "./esm/mod.js",
  "types": "./types/mod.d.ts",
  "exports": {
    ".": {
      "import": "./esm/mod.js",
      "require": "./umd/mod.js",
      "types": "./types/mod.d.ts"
    },
    "./internal": {
      "import": "./esm/internal.js",
      "require": "./umd/internal.js",
      "types": "./types/internal.d.ts"
    }
  }
}

Now these entry points could be imported like import * as main from "your-package" and import * as internal from "your-package/internal";.

Bin/CLI Packages

To publish an npm bin package similar to deno install, add a kind: "bin" entry point:

await build({
  entryPoints: [{
    kind: "bin",
    name: "my_binary", // command name
    path: "./cli.ts",
  }],
  // ...etc...
});

This will add a "bin" entry to the package.json and add #!/usr/bin/env node to the top of the specified entry point.

Node and Deno Specific Code

You may find yourself in a scenario where you want to run certain code based on whether someone is in Deno or if someone is in Node and feature testing is not possible. For example, say you want to run the deno executable when the code is running in Deno and the node executable when it's running in Node.

which_runtime

One option to handle this, is to use the which_runtime deno.land/x module which provides some exports saying if the code is running in Deno or Node.

Node and Deno Specific Modules

Another option is to create node and deno specific modules. This can be done by specifying a redirect:

await build({
  // ...etc...
  redirects: {
    "./file.deno.ts": "./file.node.ts",
  },
});

Then within the file, use // dnt-shim-ignore directives to disable shimming if you desire.

Pre & Post Build Steps

Since the file you're calling is a script, simply add statements before and after the await build({ ... }) statement:

// run pre-build steps here

// ex. maybe consider deleting the output directory before build
await Deno.remove("npm", { recursive: true }).catch((_) => {});

await build({
  // ...etc..
});

// run post-build steps here
await Deno.copyFile("LICENSE", "npm/LICENSE");
await Deno.copyFile("README.md", "npm/README.md");

Including Test Data Files

Your Deno tests might rely on test data files. One way of handling this is to copy these files to be in the output directory at the same relative path your Deno tests run with.

For example:

import { copy } from "https://deno.land/std@x.x.x/fs/mod.ts";

await Deno.remove("npm", { recursive: true }).catch((_) => {});
await copy("testdata", "npm/esm/testdata", { overwrite: true });
await copy("testdata", "npm/umd/testdata", { overwrite: true });

await build({
  // ...etc...
});

// ensure the test data is ignored in the `.npmignore` file
// so it doesn't get published with your npm package
await Deno.writeTextFile(
  "npm/.npmignore",
  "esm/testdata/\numd/testdata/\n",
  { append: true },
);

Alternatively, you could also use the which_runtime module and use a different directory path when the tests are running in Node. This is probably more ideal if you have a lot of test data.

Test File Matching

By default, dnt uses the same search pattern that deno test uses to find test files. To override this, provide a testPattern and/or rootTestDir option:

await build({
  // ...etc...
  testPattern: "**/*.test.{ts,tsx,js,mjs,jsx}",
  // and/or provide a directory to start searching for test
  // files from, which defaults to the current working directory
  rootTestDir: "./tests",
});

GitHub Actions - Npm Publish on Tag

  1. Ensure your build script accepts a version as a CLI argument and sets that in the package.json object. For example:

    await build({
      // ...etc...
      package: {
        version: Deno.args[0],
        // ...etc...
      },
    });

    Note: You may wish to remove the leading v in the tag name if it exists (ex. Deno.args[0]?.replace(/^v/, ""))

  2. In your npm settings, create an automation access token (see Creating and Viewing Access Tokens).

  3. In your GitHub repo or organization, add a secret for NPM_TOKEN with the value created in the previous step (see Creating Encrypted Secrets for a Repository).

  4. In your GitHub Actions workflow, get the tag name, setup node, run your build script, then publish to npm.

    # ...setup deno and run `deno test` here as you normally would...
    
    - name: Get tag version
      if: startsWith(github.ref, 'refs/tags/')
      id: get_tag_version
      run: echo ::set-output name=TAG_VERSION::${GITHUB_REF/refs\/tags\//}
    - uses: actions/setup-node@v2
      with:
        node-version: '16.x'
        registry-url: 'https://registry.npmjs.org'
    - name: npm build
      run: deno run -A ./scripts/build_npm.ts ${{steps.get_tag_version.outputs.TAG_VERSION}}
    - name: npm publish
      if: startsWith(github.ref, 'refs/tags/')
      env:
        NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
      run: |
        cd npm
        npm publish

    Note that the build script always runs even when not publishing. This is to ensure your build and tests pass on each commit.

  5. Ensure the workflow will run on tag creation. For example, see Trigger GitHub Action Only on New Tags).

Using Another Package Manager

For some reasons you may want to use another Node.js package manager, such as Yarn or pnpm. You can override the packageManager option in build options. Default value is npm.

For example:

await build({
  // ...etc...
  packageManager: "yarn", // or "pnpm"
});

You can even specify an absolute path to the executable file of package manager:

await build({
  // ...etc...
  packageManager: "/usr/bin/pnpm",
});

JS API Example

For only the Deno to canonical TypeScript transform which may be useful for bundlers, use the following:

// docs: https://doc.deno.land/https/deno.land/x/dnt/transform.ts
import { transform } from "https://deno.land/x/dnt/transform.ts";

const outputResult = await transform({
  entryPoints: ["./mod.ts"],
  testEntryPoints: ["./mod.test.ts"],
  shims: [],
  testShims: [],
  // mappings: {}, // optional specifier mappings
});

Rust API Example

use std::path::PathBuf;

use deno_node_transform::ModuleSpecifier;
use deno_node_transform::transform;
use deno_node_transform::TransformOptions;

let output_result = transform(TransformOptions {
  entry_points: vec![ModuleSpecifier::from_file_path(PathBuf::from("./mod.ts")).unwrap()],
  test_entry_points: vec![ModuleSpecifier::from_file_path(PathBuf::from("./mod.test.ts")).unwrap()],
  shims: vec![],
  test_shims: vec![],
  loader: None, // use the default loader
  specifier_mappings: None,
}).await?;