close
Documentation content

npm Dependencies

Programs can import npm packages the same way they do under Node.js. scriptc compiles each package your program uses to native code along with your own code. The executable doesn't read node_modules at runtime.

Package support is partial. Packages that scriptc can't compile are reported at build time with a diagnostic, so check new dependencies with scriptc coverage before relying on them.

Use a package

Create tool.ts:

tool.ts
import { Command } from "commander";

const program = new Command();

program
  .name("greet")
  .argument("<name>", "who to greet")
  .option("-u, --upper", "use uppercase")
  .action((name: string, opts: { upper?: boolean }) => {
    const text = `hello, ${name}`;
    console.log(opts.upper ? text.toUpperCase() : text);
  });

program.parse();

Install the dependency, then build and run the executable:

$ npm install commander
$ scriptc build tool.ts -o greet >/dev/null
$ ./greet ada --upper
HELLO, ADA

Your callback and the commander implementation both compile to native code. No extra flags are needed.

Package resolution

scriptc finds packages in node_modules with Node.js's resolution rules, including the export conditions in package.json, and compiles their dependencies too. TypeScript workspace packages compile as part of your program, including .js import specifiers that resolve to .ts sources.

Type-only imports follow Node.js's type stripping. import type and export type … from statements are erased, so a package or module named only by them supplies types and isn't compiled or loaded; it can even be a package with no JavaScript, such as type-fest. This holds wherever the declarations live: an installed package, a workspace package linked into node_modules, an @types package, an ambient declare module, or a declaration file of your own project. Their interfaces and type aliases type the records your program builds, and data cast to them is checked as for a type declared in a .ts file. A value still needs an implementation: a class to construct, an enum object, a constant or a function that only a declaration file declares is refused when you build, or, where Node.js can't find the module either, the program ends at start as it does under Node.js. A clause whose specifiers are all marked type, such as import { type Options } from "./config.ts", still loads its module, as it does under Node.js.

A project that tsc compiles follows tsc instead. When the nearest tsconfig.json above a TypeScript file doesn't set verbatimModuleSyntax, scriptc drops the imports and exports that tsc drops for that file: an import whose bindings are never used as values, a clause of type specifiers only, and a re-export of a type. A module imported only in those ways isn't loaded, exactly as in the JavaScript tsc emits, so it doesn't run and doesn't take part in an import cycle. isolatedModules and preserveConstEnums decide whether a use of a const enum keeps its import, as they do for tsc. An export of a const enum stays a value export even where tsc's output has no value for the name, because a file that imports the export and uses a member as a value keeps that use; the importing file's build warns with SC6008. With verbatimModuleSyntax: true, or with no tsconfig.json, the type-stripping rule applies; a plain import of a type then fails when the program starts, as it does under Node.js, and the build warns about it with SC6201.

Which packages compile

scriptc compiles a package automatically when it:

  • ships its own type declarations, or is typed by its DefinitelyTyped @types/ package, or is a dependency of another package that has no declarations at all, and
  • ships JavaScript that isn't a webpack bundle. Minified JavaScript compiles like unminified JavaScript.

The declarations check your program; the package's JavaScript compiles with the types scriptc infers from it. A program that doesn't typecheck against the inferred types, for example because an @types/ package describes another release, fails the build at that use.

A package that doesn't meet these requirements, or that uses something scriptc can't compile, gets a package-specific diagnostic that explains why. The usual fixes are to switch to a version or alternative package that compiles, or to replace the call with code you write yourself.

graceful-fs is the exception: it compiles as node:fs instead of from its JavaScript, with one documented difference.

Explicit package selection

Some packages your program imports directly ship no declarations, so their types can only be inferred from their JavaScript. To try compiling one of them, name it with --npm-static:

$ scriptc build app.ts --npm-static=three -o app >/dev/null

Naming a package skips the declaration requirement above, but the package still has to compile. Add a trailing ... to select a package together with every package it depends on, for example --npm-static=semver.... --npm-static=auto uses the default automatic selection. The option accepts a comma-separated list and can be repeated.

Built-in packages

scriptc implements a few packages itself and compiles that implementation in place of the package's published JavaScript. Which implementation compiles depends on the version your program has installed, so an import resolves the same way it does under Node.js: a package that isn't installed is still a missing package, and a version scriptc doesn't implement compiles from its own files as usual.

undici 5.x and 7.x are built in. The implementation covers request(), Dispatcher (including subclasses that override dispatch), Client, Pool with its stats, Agent with per-origin factory functions and connect options, buildConnector, the errors classes and their UND_ERR_* codes, setGlobalDispatcher and getGlobalDispatcher, fetch and Headers, and the request events undici publishes on node:diagnostics_channel. With 5.x, ProxyAgent is built in too: every request travels through an HTTP CONNECT tunnel that an http: or https: proxy opens, with the uri, token, auth, headers, requestTls and proxyTls options. Requests use HTTP/1.1 with keep-alive connection reuse and undici's headers, body and idle timeouts, and each version keeps its own behavior where 5.x and 7.x differ, such as the error an aborted request rejects with. The package's other exports aren't declared, so using one is a type error at build time. Known differences:

  • A response body supports the readable-stream members undici's body mixin uses: on, once, pause, resume, destroy, text(), json(), arrayBuffer(), bytes(), blob() and dump(). It can't be piped or iterated with for await.
  • Diagnostics channel messages carry the request but not the socket or connector.
  • fetch with a dispatcher returns a response whose url is empty and whose redirected is false, and offers only gzip, deflate encodings. Without a dispatcher it is the built-in fetch. Its init can be undici's RequestInit or the global one, and it sends string and byte bodies (a string, an ArrayBuffer or one of its views); a stream, Blob, FormData or URLSearchParams body rejects with a NotSupportedError. The init members undici acts on and this implementation doesn't (integrity, cache, mode, referrer, referrerPolicy, credentials, duplex, keepalive, priority, window) are taken only with a value that asks for nothing, such as an empty integrity or mode: "cors"; any other value rejects with a NotSupportedError that names the member, so a digest is never accepted and then not checked.
  • The global fetch called with an init whose dispatcher is an object of this package (an Agent, a ProxyAgent, an instance of a class that extends Dispatcher) sends the request through that dispatcher, with the same limits as the package's own fetch. An object that implements dispatch() itself is refused: when the program is built if it is written in the init, otherwise where the call runs, with a NotSupportedError that says so.
  • ProxyAgent dials the proxy once for each tunnel instead of through a pooled client, doesn't take clientFactory, and isn't declared for 7.x. Of requestTls it applies servername, rejectUnauthorized and ca, of proxyTls those and timeout, maxCachedSessions and lookup; another member of either, and a host, content-length or connection header for the tunnel, throws a NotSupportedError when the agent is made. It doesn't read HTTP_PROXY or its relatives, as in undici: the program passes the proxy.
  • Request bodies are strings and bytes; stream, iterable and form-data bodies, pipelining above 1, CONNECT requests and Unix socket paths aren't supported.

fs-extra 8.x through 11.x is built in, and its declarations replace @types/fs-extra. The default import, a namespace import and named imports all read the same exports. fs's members are re-exported, and fs-extra's wrapped methods follow its rule: with a callback as the last argument they call back, otherwise they return a promise. The extra methods cover copy, copySync, move, moveSync, remove, removeSync, emptyDir, ensureDir, mkdirs, mkdirp, ensureFile, pathExists, outputFile, readJson, readJsonSync, writeJson, writeJsonSync, outputJson and their aliases, with fs-extra's error messages. Known differences:

  • ensureLink, ensureSymlink, createLink, createSymlink, gracefulify, the promise forms of the descriptor-based methods, and the callback forms of stat, lstat, readdir, mkdtemp, realpath and readJson aren't declared, so using one is a type error at build time. Paths are strings, text is UTF-8, and write flags, string modes and copyFile mode flags aren't accepted.
  • graceful-fs's retry queue, which fs-extra uses under Node.js, isn't part of the implementation, so a program that runs out of file descriptors fails at the first EMFILE error instead of retrying.
  • An ES module that imports fs-extra can use any of its exports by name. Node.js only exposes the names its CommonJS lexer finds in fs-extra's source, so a name like readFile throws a SyntaxError there.

@hono/node-server 2.x is built in. serve, createAdaptorServer, getRequestListener and RequestError serve a fetch handler such as Hono's app.fetch over node:http with native Request and Response objects, and write each response the way the package does under Node.js: the same status line, header names, header order and content length, for buffered and streamed bodies, including the package's shortcut for a response that is still exactly what its constructor received. Request bodies stream to the handler as it reads them, and a body the handler leaves unread is drained after the response, up to 64 MiB or 500 ms, as the package does. Known differences:

  • Only HTTP/1.1 servers are declared: the createServer and serverOptions options for node:https and node:http2, the websocket option and upgradeWebSocket aren't declared, so using one is a type error at build time. Subpath modules such as @hono/node-server/serve-static compile from the package's own files.
  • The fetch handler must return a Response or a promise of one; the handler's second argument holds incoming and outgoing.
  • The global Request and Response stay the built-in classes, so overrideGlobalObjects has no effect, and a Response reads its default text content type as text/plain;charset=UTF-8 where the package's replacement reads text/plain; charset=UTF-8. Written responses carry the package's spelling.
  • A streamed response is written without waiting for drain, and a failed handler answers 504 only for an error whose name is TimeoutError.

Small polyfill packages whose module is exactly a Node.js built-in on Node.js 24 compile as that built-in, at the require() that loads them, from your program's CommonJS files, from compiled packages, and from esbuild bundles whose module paths name the package and its version (as pnpm installs do). The package's own files aren't compiled.

PackageVersionsCompiles as
isarray0.x, 1.x, 2.xArray.isArray
safe-buffer5.xrequire("buffer")
process-nextick-args2.xprocess
inherits2.xrequire("util").inherits
util-deprecate1.xrequire("util").deprecate
readable-stream2.x, 3.x, 4.xrequire("stream") and its classes

readable-stream compiles as Node.js's built-in streams, which is its own READABLE_STREAM=disable mode for 2.x and 4.x. That can differ from its default behavior under Node.js; see limitations.

Optional dependencies that never load

Some packages load an optional dependency inside try/catch and work without it, such as node-fetch with encoding. To keep such a dependency out of the native build even when it is installed, mark it unloaded in your entry's package.json:

package.json
{
  "scriptc": {
    "packages": {
      "encoding": "unloaded"
    }
  }
}

A require() of an unloaded package throws the same MODULE_NOT_FOUND error Node.js throws when the package isn't installed, and the package isn't compiled.

CommonJS packages

CommonJS packages compile with Node.js's module semantics. Each require() evaluates its module at the call, once, and later calls return the cached exports. A package can assign module.exports and run code before it requires its dependencies. If that code reads a const or let require binding before its require runs, the read throws the same ReferenceError as Node.js. A var binding that earlier code can reach is reported at build time. A require() of a JSON file, such as a package's own package.json, returns one object parsed at build time, shared by every module that requires it. A require cycle compiles when each of its modules assigns its exports before requiring anything, as semver's classes and esbuild's per-file output do, and uses the other modules only inside functions that its top-level code can't call. Bundles built by esbuild compile with their bundled CommonJS modules and lazily initialized ES modules, each evaluated once, at its first use, as under Node.js. Other require cycles are reported at build time. The limitations page lists the supported require forms.

Some package entries choose a module by an environment variable, as React's index.js requires its production or development build by NODE_ENV. Build such a program with --define, for example --define='process.env.NODE_ENV="production"': the entry compiles only the module the value selects. Without the flag, that conditional module.exports assignment is unsupported.

Unsupported code inside a package

A JavaScript package can contain operations scriptc can't compile, often in functions your program never calls. These don't fail the build: each one compiles to code that throws a runtime error if it ever runs. Coverage reports list them as deferred sites, so review the list and test the parts of the package your program uses. Sites in a module's top-level code are listed under runs at startup: that code runs when the module loads, at startup for a module the program imports.

When a deferred operation runs, the error it throws can be caught by your code or by a framework's error handler. So that a caught error doesn't look like an application bug, the program writes to stderr the first time each such site runs, naming the operation, its diagnostic code, and its source position when the site has one. A program that calls v5 from uuid 14.0.3 prints:

scriptc: this program reached code that scriptc could not compile; each such operation throws where it runs and is reported here once ('scriptc coverage' lists them, SCRIPTC_DEFERRED_REPORT=0 silences these lines)
scriptc: not compiled: 'unescape' is part of the standard library types but has no scriptc lowering yet [SC2020]

Set SCRIPTC_DEFERRED_REPORT=0 in the program's environment to silence these lines. A program with no deferred operations never prints them.

A deferred operation that sits in the try block of a try/catch in the same function is different: the program wrote a handler for exactly that statement, as in try { optional() } catch {}. The program doesn't print anything for such a site when it runs, so its stderr stays what it is under Node.js. The build names the site instead, with warning SC6012 at the statement: the catch clause runs even where the statement would have completed under Node.js, so the program's output can differ from there on. Set SCRIPTC_DEFERRED_REPORT=all to have the program report these sites at run time too. A site in a function that the try block calls, in a catch or finally block, or in a try that has only a finally, is reported at run time like any other. A library built with scriptc build --lib doesn't print them either: a deferred operation in a library throws its error, and nothing is written to the host process's stderr.

Fix a package's types

scriptc trusts declared types: a package's .d.ts files, its @types/ package, and the JSDoc types in its JavaScript. A value that contradicts its declared type throws a TypeError instead of flowing on, so a package whose declarations are wrong can stop a program that runs under Node.js. A common case is a member declared string or number that is sometimes undefined.

When the type comes from a package's declarations, the error names the declaration and the patch that would admit the value:

Uncaught TypeError: expected string at $, got undefined
    profilekit@3.1.0 declares Profile.email as string (index.d.ts:7)
    if that declaration is wrong, patch it: scriptc types patch profilekit "Profile.email" "| undefined"

scriptc coverage lists the declarations your program trusts before it runs, each with the command that patches it; see type claims.

Fix such a declaration in your project with a type patch. scriptc types patch shows what a declaration path matches in the installed package and adds the patch to your package.json:

$ scriptc types patch commander "HelpContext.helpWidth" "| undefined"
commander 15.0.0 (node_modules/commander)
  lib/command.js:2481  number  ->  number | undefined
wrote package.json scriptc.typePatches["commander@^15.0.0"].declarations["HelpContext.helpWidth"]

The patch it writes looks like this:

package.json
{
  "scriptc": {
    "typePatches": {
      "commander@^15.0.0": {
        "declarations": {
          "HelpContext.helpWidth": "| undefined"
        }
      }
    }
  }
}

Each key names a package and a semver range of the installed versions the patch describes. declarations maps a declaration to a type:

"| undefined"
A type that starts with | widens the declared type, here to number | undefined.
"string | null"
Any other type replaces the declared type.

A declaration is named by its path: Options.name for a member of a type, parse() for a function's return type, parse(text) for a parameter, list()[] for an array's elements, and Env[string] for an index signature. export=() names the return type of the function the module exports, default its default export, and a file prefix such as index.js:parse() names a declaration inside one of the package's files. One patch applies to the package's own declarations, its JSDoc, and its @types/ package. The patched type then holds everywhere, so your code has to handle the undefined that a widened declaration allows. An optional "note" string can link the upstream issue.

For larger fixes, replace a whole declaration file, or give a package without declarations its own:

package.json
{
  "scriptc": {
    "typePatches": {
      "some-client@^3.2.0": {
        "files": { "dist/types/options.d.ts": "./type-patches/options.d.ts" }
      },
      "untyped-helper@1.x": {
        "types": "./type-patches/untyped-helper.d.ts"
      }
    }
  }
}

files maps a declaration file inside the package, or inside its @types/ package written as @types/<name>/<path>, to a local .d.ts file. types supplies the package's declaration entry. Paths of local files are relative to the package.json, and a replaced file can import the package's other declaration files with the same relative paths as the file it replaces.

Patches change only declarations, never the package's code, and values are still checked against the patched types. A build warns when a patch doesn't apply as written: when no range covers the installed version, when a declaration path matches nothing, when the declaration already has the type, or when a path is ambiguous. Report wrong declarations to the package too, so the patch can be removed later.

Provenance sources (experimental)

--provenance-sources compiles a package from the source at the commit its npm provenance attestation names, instead of from its published JavaScript. This lets packages written in TypeScript compile from their original TypeScript. Packages without a usable attestation compile from their installed files as usual.

Upgrading from scriptc 0.2

scriptc 0.2 had a --dynamic mode that embedded a JavaScript engine in the executable to run code that didn't compile, including packages that failed native compilation. scriptc 0.3 removed --dynamic and the embedded engine, so all application and package code now compiles to native code.

If you built with --dynamic, remove the flag and run scriptc coverage on your entry file. The report lists the operations and packages that need a change; rewrite those sites or replace the package. If you link native program objects into your own builds, use the runtime pack that matches the 0.3 compiler.