2026-09-29 14:47:06 +00:00
2026-09-29 14:47:06 +00:00
2026-09-29 14:47:06 +00:00
2026-09-29 14:47:06 +00:00
2026-09-29 14:09:07 +00:00

@git.zone/tsdeno

A smart wrapper around deno compile that temporarily removes devDependencies from package.json during compilation — preventing dev-only packages from inflating your binary by hundreds of megabytes.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

# One-off CLI usage
pnpm dlx @git.zone/tsdeno compile --help

# Project dev dependency
pnpm add --save-dev @git.zone/tsdeno

🔥 The Problem

When you run deno compile in a project that has a package.json, Deno resolves every dependency listed — including devDependencies. It does not distinguish between dependencies and devDependencies. This means build tools like:

  • 📦 rspack (~110 MB of native binaries)
  • 📦 rolldown (~40 MB of native binaries)
  • 📦 esbuild (~11 MB)
  • 📦 typescript (~23 MB)
  • 📦 tswatch, tsbundle, and their entire transitive trees

...all get bundled into your compiled executable, even though they're never imported at runtime.

A real-world example: a Deno server binary went from 596 MB to 1022 MB — with 426 MB of pure dead weight from dev-only build tools.

The root cause: Deno reads package.json and resolves the full dependency graph into its global cache, then embeds everything when compiling. There is no --omit=dev flag, no config to skip devDependencies, and --node-modules-dir=none alone doesn't help if package.json is present.

✅ The Solution

tsdeno compile wraps deno compile with a simple but effective strategy:

  1. Temporarily rewrites package.json without devDependencies
  2. Adds --node-modules-dir=none automatically (uses Deno's global cache instead of local node_modules)
  3. Applies the project's pnpm release-age policy to Deno when the project sets no Deno policy of its own, so Deno waits for and exempts the same packages pnpm does (see Dependency age)
  4. Leaves out embedded npm files the target cannot load — native binaries and platform packages built for other systems — and prints a size report (see Lean binaries)
  5. Runs deno compile with all your arguments passed through, using the Deno you select (see Build guards)
  6. Checks the binary against its size floor and budget and, optionally, runs it once
  7. Prunes marked self-extracting staging generations after successful --self-extracting compiles
  8. Restores the original package.json (and Deno config) content — guaranteed, even if compilation fails (try/finally)

With a runtime-only package.json, Deno can resolve normal package dependencies without pulling in dev-only build and test tooling.

Usage

Reproducible runtime dependency locks

Generate the dependency lock with the same temporary runtime-only manifest that compilation uses. Deno's --prod can exclude development packages while retaining their workspace metadata in the lock; that metadata conflicts with a frozen compile under the sanitized manifest. tsdeno install owns the same manifest lock, interrupted-run recovery and exact restoration as tsdeno compile.

tsdeno install --entrypoint --lockfile-only --frozen=false --lock=deno.lock mod.ts
tsdeno compile --allow-all --frozen --lock=deno.lock --output dist/myapp mod.ts

Install flags pass through to deno install; --node-modules-dir=none is added unless explicitly supplied. The programmatic equivalent is await new TsDeno().install([...args]). A failed install restores the original manifest before propagating Deno's exit code. Review and commit the generated lock; ordinary builds should use --frozen.

CLI — Passthrough Mode

Drop-in replacement — just swap deno compile for tsdeno compile:

# Before (bloated binary):
deno compile --allow-all --no-check --output myapp mod.ts

# After (lean binary):
tsdeno compile --allow-all --no-check --output myapp mod.ts

All deno compile flags are passed through untouched. Cross-compilation works the same way:

tsdeno compile --allow-all --no-check \
  --output dist/myapp-linux-x64 \
  --target x86_64-unknown-linux-gnu \
  mod.ts

tsdeno compile --allow-all --no-check \
  --output dist/myapp-macos-arm64 \
  --target aarch64-apple-darwin \
  mod.ts

CLI — Config Mode (.smartconfig.json)

For projects with multiple compile targets, define them in .smartconfig.json instead of writing long CLI commands. Just run tsdeno compile with no arguments:

tsdeno compile

tsdeno reads compile targets from the @git.zone/tsdeno key in your .smartconfig.json:

{
  "@git.zone/tsdeno": {
    "keepStaging": false,
    "compileTargets": [
      {
        "name": "myapp-linux-x64",
        "entryPoint": "mod.ts",
        "outDir": "./dist",
        "target": "x86_64-unknown-linux-gnu",
        "permissions": ["--allow-all"],
        "noCheck": true,
        "selfExtracting": true,
        "keepStaging": false,
        "v8Flags": ["--max-old-space-size=512"]
      },
      {
        "name": "myapp-macos-arm64",
        "entryPoint": "mod.ts",
        "outDir": "./dist",
        "target": "aarch64-apple-darwin",
        "permissions": ["--allow-all"],
        "noCheck": true
      }
    ]
  }
}

Each compile target supports these fields:

Field Type Required Description
name string ✅ Output binary name (combined with outDir for path)
entryPoint string ✅ Path to the entry TypeScript file
outDir string ✅ Directory for the compiled output
target string ✅ Deno compile target triple (e.g. x86_64-unknown-linux-gnu)
permissions string[] ❌ Deno permission flags (e.g. ["--allow-all"])
noCheck boolean ❌ Skip type checking (--no-check)
selfExtracting boolean ❌ Extract embedded files to disk at runtime (--self-extracting)
keepStaging boolean ❌ Keep Deno self-extracting staging directories for debugging
v8Flags string[] ❌ V8 flags baked into the binary (e.g. ["--max-old-space-size=512"]). Compiled Deno binaries ignore NODE_OPTIONS/DENO_V8_FLAGS at runtime, so compile-time flags are the only way to apply V8 settings such as heap limits
excludeUnusedNpm boolean ❌ Embed only npm packages the module graph reaches (--exclude-unused-npm); list packages loaded by computed imports in include
include string[] ❌ Extra module roots or files to embed (--include), e.g. npm:some-plugin
pruneNatives boolean ❌ Leave out native binaries and platform packages the target cannot load (default true)
pruneExclude string[] ❌ Package-qualified globs to leave out, e.g. @scope/pkg/dist_debug/**
pruneKeep string[] ❌ Package-qualified globs native pruning must keep
maxSize number | string ❌ Fail the compile when the binary is larger, e.g. "350MiB"
minSize number | string ❌ Fail the compile when the binary is smaller, e.g. "100MiB" (see Build guards)
smoke object ❌ Run the binary once after compiling it (see Build guards)
thirdPartyNotices boolean ❌ Write third-party notices next to this binary; overrides the top-level thirdPartyNotices.enabled (see Third-party notices)
thirdPartyNoticesInclude string[] ❌ Notices files of bundles this binary embeds, merged into its notices

In config mode, package.json is sanitized once for the entire batch — all targets compile in sequence with a single sanitize/restore cycle.

Top-level thirdPartyNotices ({ enabled, supplements }) turns on third-party notices for every target and supplies license texts. Top-level denoExecutable and denoVersion select the Deno for every target (see Build guards); top-level dependencyAge chooses where Deno's minimum dependency age comes from (see Dependency age).

Top-level keepStaging: true keeps staging for every target. Target-level keepStaging overrides it for a single compile target. You can also set TSDENO_KEEP_STAGING=true for one-off debugging.

Lean binaries

deno compile embeds the whole folder of every npm package it resolves. Packages that ship prebuilt native binaries for every platform therefore put all of them into every binary, although each target can load only its own. tsdeno plans a trim before each compile and hands it to Deno as --exclude paths.

Native pruning (on by default)

For the compile --target (or the host when none is given), tsdeno leaves out:

Reason What is left out Evidence
platform-package A whole package whose npm os/cpu fields exclude the target and that Deno still embeds, e.g. a regular (not optional) dependency built for another system; Deno drops only optional ones npm's own os/cpu install rules
foreign-native An ELF, Mach-O or PE file built for another system or architecture the file header (format, OS/ABI, machine; every slice of a universal Mach-O)
foreign-native-sidecar A data file named after a pruned native, e.g. engine_macos_arm64.sha256 the pruned sibling
foreign-platform-dir Data files in a folder named for another platform (prebuilds/darwin-arm64/) that holds pruned natives and nothing the target can load the header evidence inside it

The sidecar and platform-folder rules never remove code or module files (.js, .mjs, .cjs, .jsx, .ts, .mts, .cts, .tsx, .json, .wasm, .node); only proven-foreign natives and their data files go.

It never removes anything the target could load:

  • A file name alone never prunes a file. A native goes only when its header shows another OS or architecture; a whole package only when its npm os/cpu fields exclude the target; a data file only beside, or in a platform folder with, a native pruned that way. The sidecar and platform-folder rules never prune code files (.js, .json, .wasm, .node, …); a whole foreign platform package goes with all of its files, and a .node addon goes when its own header is foreign.
  • libc never prunes anything: a package marked libc: ["musl"] is not pruned as a whole but checked file by file, because static musl executables run on glibc hosts and loaders such as @lossless.org/nosqldb choose them there on purpose.
  • Architectures the target OS runs through compatibility layers are kept: x64 on Apple Silicon (Rosetta), ia32 on x64 Windows and Linux, x64/ia32/arm on Windows on Arm, 32-bit arm on arm64 Linux.
  • Files it cannot classify are kept and, when their name points to another platform, listed in the report.

Explicit rules

Rules are package-qualified globs: <package name>/<path inside the package>. A rule that names a folder covers everything below it. Globs follow Node's path.matchesGlob; * and ** skip dot-files unless the pattern names them.

{
  "@git.zone/tsdeno": {
    "compileTargets": [
      {
        "name": "spark-linux-x64",
        "entryPoint": "mod.ts",
        "outDir": "dist/binaries",
        "target": "x86_64-unknown-linux-gnu",
        "permissions": ["--allow-all"],
        "excludeUnusedNpm": true,
        "pruneExclude": [
          "@lossless.org/nosqldb/dist_rust",
          "@lossless.org/nosqldb/dist_ts_debugserver"
        ],
        "pruneKeep": ["some-tool/bin/helper.exe"],
        "maxSize": "300MiB"
      }
    ]
  }
}

The same options exist as CLI flags, placed before the entry point:

tsdeno compile --allow-all --frozen --lock=deno.lock \
  --prune-exclude '@lossless.org/nosqldb/dist_rust' \
  --prune-keep 'some-tool/bin/helper.exe' \
  --max-size 300MiB \
  --output dist/app --target x86_64-unknown-linux-gnu mod.ts

--no-prune-natives (config: pruneNatives: false) switches native pruning off; explicit rules still apply.

Rules fail loudly instead of drifting silently:

  • an exclude or keep rule that matches no embedded file fails with PRUNE_RULE_UNMATCHED;
  • a file matched by both an exclude and a keep rule fails with PRUNE_RULE_CONFLICT;
  • a binary above maxSize fails with SIZE_BUDGET_EXCEEDED (the binary stays on disk for inspection).

pruneKeep protects files from native pruning, for example a Windows helper a Linux program ships to other machines as data.

Reachability: --exclude-unused-npm

Deno's --exclude-unused-npm (config: excludeUnusedNpm: true) embeds only the npm packages reachable from the module graph instead of the whole resolution snapshot. Packages reached only through computed dynamic imports (await import(name)) are then missing at runtime, which no compile-time check can detect. tsdeno therefore leaves it off by default; turn it on per target and list such packages with include: ["npm:<package>"] (--include npm:<package>). tsdeno models Deno's embedded set in both modes (including Deno's os/cpu filter for optional packages), so the report and rules refer to the packages Deno embeds for that target.

Size report

Each compile prints its plan and result:

tsdeno: using Deno 2.9.4 (deno from PATH)
tsdeno: npm payload for x86_64-unknown-linux-gnu: 318 MiB in 510 package(s) -> 223 MiB (pruned 95.0 MiB via 37 --exclude path(s))
tsdeno:   foreign-native: 95.0 MiB in 37 file(s)
tsdeno:   largest pruned files:
tsdeno:       20.8 MiB  @lossless.org/nosqldb@10.5.1/dist_rust/rustdb_linux_arm64  [foreign-native]
tsdeno:   largest embedded packages:
tsdeno:       68.1 MiB  @lossless.org/nosqldb@10.5.1  (pruned 79.5 MiB)
tsdeno:   native binaries kept:
tsdeno:       22.2 MiB  @lossless.org/nosqldb@10.5.1/dist_rust/rustdb_linux_amd64  [target: elf x64, interpreter /lib64/ld-linux-x86-64.so.2]
tsdeno:       21.5 MiB  @lossless.org/nosqldb@10.5.1/dist_rust/rustdb_linux_amd64_musl  [target: elf x64, no interpreter]
tsdeno: /work/spark/dist/binaries/spark-linux-x64 is 324 MiB (Deno 2.9.4, floor 200 MiB, budget 300 MiB)
tsdeno: smoke check passed: /work/spark/dist/binaries/spark-linux-x64 --version exited 0 in 212 ms

"native binaries kept" shows what the target still carries, including libc variants, so you can decide on explicit rules with the package's loader in view.

Measured (Deno 2.9.4, linux-x64)

Binary Before Native pruning + explicit rules
Spark 34.0.0 (--exclude-unused-npm already on) 439.0 MB 339.3 MB (−22.7 %) 285.6 MB (−34.9 %) with @lossless.org/nosqldb/dist_rust, …/dist_ts_debugserver, …/dist_ts_debugui (Spark runs its own verified engine)
Spark, rules above + **/*.map, **/*.d.ts 220.5 MB (−49.8 %)
Onebox 32.16.0 (--self-extracting) 1106.0 MB 906.1 MB (−18.1 %), verify:binary passes

Source maps (**/*.map) are optional: compiled binaries use them to map npm stack traces back to their sources, so leaving them out trades readable traces for size.

Constraints

  • tsdeno models Deno's managed npm embedding (--node-modules-dir=none, the default it adds, or auto). With a manual node_modules directory (BYONM) or --bundle, default native pruning is skipped and the compile prints tsdeno: native pruning not applied: manual node_modules (or --bundle embedding); explicit prune rules fail there with PRUNE_UNSUPPORTED. maxSize works in every mode.
  • On SIGINT or SIGTERM, tsdeno forwards the signal to the running Deno process, waits for it to exit (SIGKILL after 10 seconds), restores package.json, and exits with 130 or 143.
  • Rules that match many single files (such as **/*.map) become many --exclude arguments; beyond the system's argument limit the compile fails with ARGUMENT_LIST_TOO_LONG. Prefer folder rules.
  • The plan reads the module graph with deno info --json using the compile's resolution flags (--config, --lock, --frozen, --node-modules-dir, --import-map, ...), so --frozen compiles stay reproducible.

Dependency age

Deno 2.9 skips npm and JSR versions published less than 24 hours ago (minimumDependencyAge). pnpm has its own policy, minimumReleaseAge with minimumReleaseAgeExclude, typically exempting your own scopes so a release you just published can be consumed at once. Left alone, the two disagree: pnpm installs @your-scope/pkg@1.2.3 an hour after its release, while deno compile refuses it (newer than the specified minimum dependency date) or quietly resolves an older version.

tsdeno therefore applies the release-age policy pnpm uses in the project to every Deno command it runs (install, the deno info of the dependency check and the prune plan, and compile), so the lockfile and the binary resolve what pnpm resolves, unless the project sets Deno's policy itself.

A Deno policy of the project's own wins

When the Deno config file the run loads sets minimumDependencyAge, tsdeno leaves it exactly as it is and does not run pnpm at all:

tsdeno: dependency age: deno.json sets minimumDependencyAge, left unchanged; pnpm's policy is not applied

This mirrors pnpm's own precedence, where a pnpm-workspace.yaml wins over the global config: a policy committed with the project applies on every machine, whatever the build host's pnpm is configured with. The config file is the one Deno reads minimumDependencyAge from, found the way Deno finds it:

  • a --config file other than the one its directory loads anyway (deno.json, else deno.jsonc), such as tools/deno.build.json, is read on its own: Deno does no workspace discovery for it, so the policy lives in that file and nowhere else;
  • otherwise the run's config folder is the --config file's directory, else the nearest directory, starting at the project, that holds a deno.json, deno.jsonc or package.json (a project with a package.json therefore never inherits a parent's deno.json unless that parent is its workspace root);
  • when the nearest directory from there upwards that declares a workspace (deno.json workspace, or package.json workspaces) lists the config folder as a member, the policy lives in that workspace root's deno.json or deno.jsonc. Deno ignores minimumDependencyAge in a member ("minimumDependencyAge" field can only be specified in the workspace root deno.json file), and so does tsdeno: next to a root policy the member's setting is named in a notice, and without one the run fails with DEPENDENCY_AGE_UNSUPPORTED, because Deno would apply its own default instead. Members are matched as Deno 2.9 matches them: plain paths relative to the root, *, ? and ** patterns (case-insensitive, never matching a leading dot), and ! negations that exclude pattern matches wherever they stand. A member entry with brackets fails with DEPENDENCY_AGE_UNSUPPORTED, since Deno matches it neither as a character class nor literally;
  • otherwise the config folder's own deno.json or deno.jsonc applies.

--minimum-dependency-age (or --min-dep-age) on a tsdeno command line is Deno's own setting too; it applies as given, with the same kind of notice. With --no-config Deno loads no config file, so no committed policy applies to that run.

tsdeno reads Deno config files as JSON with comments and trailing commas, like Deno. Deno also accepts unquoted property names; tsdeno does not read those and fails with DEPENDENCY_AGE_UNSUPPORTED, because it cannot tell which minimumDependencyAge Deno applies (quote the names, or set dependencyAge to "deno"). A file that holds no JSON object, or starts with a UTF-8 byte order mark (which Deno 2.9 rejects too), fails the same way.

Otherwise the policy comes from pnpm

  1. tsdeno asks pnpm for the effective values in the project directory: pnpm config get minimumReleaseAge, minimumReleaseAgeExclude and minimumReleaseAgeStrict. pnpm merges them the usual way: command line, pnpm_config_* environment variables, pnpm-workspace.yaml (found in the project or a parent), then the global config.yaml; a list in pnpm-workspace.yaml replaces the global list.
  2. It translates them to Deno's object form: the age in minutes (0 turns the check off), @scope/* and other trailing-wildcard patterns to npm:@scope/*, exact names to npm:name. * exempts everything, which becomes age 0.
  3. It writes that minimumDependencyAge into the Deno config file (the workspace root's for a member) for the run and restores the file afterwards, byte for byte, exactly like package.json (during the run the file is plain JSON, without its comments). Without a config file, tsdeno creates a deno.json for the run and removes it afterwards. After a crash, the next tsdeno run in the project (with either dependencyAge setting) restores or removes the file before it does anything else, so a policy left behind by a killed run is never taken for the project's own. Deno reads exclusions only from its config file: --minimum-dependency-age replaces the whole setting, exclusions included.
tsdeno: dependency age from pnpm: 10080 minutes, 62 npm exclusion(s) (via deno.json)
tsdeno: temporarily adding the pnpm release-age policy to deno.json
...
tsdeno: restored deno.json

Rules that keep the two from drifting apart silently:

  • pnpm entries Deno cannot express (version-specific entries such as nx@21.6.5, negations such as !pkg, and wildcards before the end such as @scope/*-cli) are left out with a notice. Their packages keep the age check in Deno, which is stricter than pnpm, never looser; Deno then names any version it refuses.
  • When pnpm configures no minimumReleaseAge, Deno keeps its own default of 24 hours, the same as pnpm's built-in default; pnpm's exclusions still apply.
  • pnpm falls back to a younger version when minimumReleaseAgeStrict is off and no old-enough version satisfies a range; Deno has no such fallback and fails. tsdeno prints a notice when pnpm is lenient (its default without a configured minimumReleaseAge).
  • The policy applies to JSR packages as well, which pnpm does not install; exclusions cover npm packages only.
  • With --no-config, the age is passed as --minimum-dependency-age; when the policy has exclusions, the run fails with DEPENDENCY_AGE_UNSUPPORTED.
  • The run fails with DEPENDENCY_AGE_UNSUPPORTED when the Deno is older than 2.9.2 (the first release that applies npm:@scope/* exclusions) or when the config Deno reads the setting from is in a parent directory of the project and sets no minimumDependencyAge (tsdeno does not edit files outside the project). For a workspace member that is the workspace root: the pnpm policy would have to be written there, so the run fails and names the root's config; set minimumDependencyAge in it, or set dependencyAge to "deno". It fails with PNPM_UNAVAILABLE when pnpm cannot be run or does not answer within 60 seconds, and with PNPM_POLICY_INVALID when pnpm reports a value of the wrong type. These checks run only when tsdeno derives the policy from pnpm, and all of them fail before package.json or the Deno config file is changed; only the recovery of an earlier interrupted run may have run.

Opting out

A committed minimumDependencyAge needs no opt-out. To keep pnpm out of Deno's dependency age entirely, for example to rely on .npmrc min-release-age or on Deno's default, opt out; tsdeno then neither reads pnpm nor reads or changes the Deno config:

{
  "@git.zone/tsdeno": {
    "dependencyAge": "deno"
  }
}

On the command line, --dependency-age deno comes first, like --deno-executable; in code, new TsDeno(cwd, { dependencyAge: 'deno' }). Constructor and CLI values take precedence over .smartconfig.json.

Third-party notices

A compiled binary contains the Deno runtime (denort: V8, ICU, hundreds of Rust crates, Deno's own JavaScript) and every npm package Deno embeds. Their licenses require shipping their notices with the binary. With third-party notices on, every compile writes two files next to the binary:

File Content
<binary>.third-party-notices.txt Part 1: where the complete corresponding source of the Deno runtime is obtained, then the components of the Deno runtime with their license expressions and notice references; Part 2: one section per embedded npm package with the license and notice files it ships; Part 3: every runtime notice text, named by its SHA-256
<binary>.third-party-notices.json { generator, binary, target, runtime: { denoVersion, inventory, inventorySha256, source, lgpl?, components, texts }, packages }; packages has the shape of tsbundle's notices

Turn them on for the project, and optionally per target or per command line:

{
  "@git.zone/tsdeno": {
    "denoVersion": "2.9.7",
    "thirdPartyNotices": {
      "enabled": true,
      "supplements": [
        { "packages": ["@epic-web/invariant@1.0.0"], "license": "MIT", "licenseFile": "./notices/invariant-license.txt" }
      ]
    },
    "compileTargets": [
      {
        "name": "app-linux-x64",
        "entryPoint": "mod.ts",
        "outDir": "dist",
        "target": "x86_64-unknown-linux-gnu",
        "thirdPartyNoticesInclude": ["dist_serve/bundle.js.third-party-notices.json"]
      }
    ]
  }
}
tsdeno compile --third-party-notices --third-party-notices-include dist_serve/bundle.js.third-party-notices.json \
  --allow-all --output dist/app mod.ts
tsdeno compile --no-third-party-notices --output dist/app mod.ts   # off for this run; removes stale notices

Notices need --output. They are off unless enabled. Every compile with --output first removes the pair an earlier compile left next to the binary, so a failed compile (a refused package, a size or smoke check, Deno's own error) never leaves notices of another build behind; both files are written through temporary files and renamed into place together. Ship both files with the binary, for example as @git.zone/tspack bundle files:

{ "source": "dist/app-linux-x64.third-party-notices.txt", "path": "third-party-notices.txt" }

npm packages

The notices list exactly the packages Deno embeds for the target, as tsdeno models them for native pruning: the resolution snapshot (or the graph-reachable part with --exclude-unused-npm), without optional packages for other platforms and without packages every file of which is pruned. A package for the target that the cache does not hold yet, such as an optional platform package of another architecture on a cross-target compile, is downloaded first the way deno compile --target does it: deno install --entrypoint --os <os> --arch <cpu> with the compile's resolution flags. When Deno cannot provide it, the compile fails with NOTICES_PACKAGE_UNAVAILABLE instead of leaving it out. Their license and notice files (LICENSE, LICENCE, COPYING, NOTICE, THIRD-PARTY-NOTICES, with any extension) are read from Deno's npm cache, with the resolver of @git.zone/tsbundle/notices, so the rules match tsbundle's:

  • A package without any license text needs a supplement, or the compile fails with NOTICES_LICENSE_MISSING before deno compile runs, naming every such package.
  • The project supplies texts under "@git.zone/tsdeno".thirdPartyNotices.supplements (packages as name or name@version, license equal to the package's declared license, licenseFile relative to the project).
  • A library supplies the text of its license-less dependencies in its own .smartconfig.json under "@git.zone/tsbundle".thirdPartyNotices.supplements; it applies to every bundle and every binary that embeds the library.

Code Deno embeds as files, such as a web bundle added with --include or compile.include, is not an npm package of the graph: list the notices of such bundles with thirdPartyNoticesInclude (--third-party-notices-include). Their packages are merged by name@version; the same package listed with two different licenses fails the compile.

Notices cover npm code only. A module graph with remote or JSR modules (https: imports) fails with NOTICES_UNSUPPORTED, and so do --bundle and a manual node_modules directory, whose embedding tsdeno does not model.

The Deno runtime

tsdeno ships a reviewed inventory of the Deno runtime's notices for each Deno release and target it covers, in assets/deno-runtime-notices, each pinned by the SHA-256 of its file in tsdeno's code:

Deno Targets Inventory
2.9.7 x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu deno-2.9.7-linux-gnu.json: 791 components (741 Rust crates of the runtime, Deno, V8 15.0.245.2 with the Rust code Chromium builds into it, Deno's embedded JavaScript, the Rust 1.95.0 standard library)

A compile for any other Deno release or target fails with NOTICES_RUNTIME_UNCOVERED, naming the covered ones; an inventory that does not match its pin or its text digests fails with NOTICES_RUNTIME_INVALID. Pin the Deno with denoVersion so that a Deno upgrade and the tsdeno release covering it go together.

Each inventory pins where the complete corresponding source of its runtime is obtained, and both notices files carry these pins (runtime.source in the JSON, "Corresponding source" in the text): Deno's release commit, its deno_src.tar.gz URL and SHA-256 (the archive's Cargo.lock pins every Rust crate of the runtime by checksum), the V8 version with its source repository and commit, the Rust toolchain, and the cargo-about version the crate inventory was taken with. The pins are part of the inventory file, so its digest covers them, and an inventory without them, with a source archive URL other than Deno's for its release, or with a V8 commit its native V8 notices were not taken at, fails with NOTICES_RUNTIME_INVALID.

When a component's license expression names an SPDX LGPL identifier (in 2.9.7: v8-native, whose glibc math sources are LGPL-2.1-or-later), the notices add a statement (runtime.lgpl in the JSON, { components, statement }, and the same text in Part 1): the components are statically linked into the runtime the executable contains; the complete corresponding source of the runtime, including them, is available at the pinned locations; deno compile appends the application to a copy of the runtime as data, and uses a rebuilt runtime given in DENORT_BIN; a recipient may obtain the source, modify the components and rebuild the runtime.

The components keep the license expressions their upstream inventories publish. The 2.9.7 inventory is the reviewed Deno runtime section of the serve.zone Pallet control notices, and regenerates byte for byte from Deno's pinned deno_src.tar.gz and cargo-about 0.9.1 (see below).

Maintaining the runtime inventories

pnpm run runtime-notices in the tsdeno repository (scripts/runtime-notices.ts and scripts/runtimenotices.carry.ts; not part of the published package) carries an inventory to another Deno release from pinned sources. It needs the target Deno on PATH (or --deno-executable), cargo-about at the inventory's version, tar, and network access to GitHub and crates.io:

# Regenerate the newest inventory and compare it with the committed one (writes nothing)
pnpm run runtime-notices -- --check

# Carry it to a new release: the tag's commit and the SHA-256 of its deno_src.tar.gz are the pins
git ls-remote https://github.com/denoland/deno 'refs/tags/v2.9.8*'   # the ^{} line for an annotated tag
pnpm run runtime-notices -- --to 2.9.8 --commit <commit> --source-sha256 <sha256>

The command downloads deno_src.tar.gz of both releases and checks them against their pins, runs cargo about generate --format json over cli/rt for the inventory's targets, and then:

  • keeps the entry of every registry crate the inventory already names, and writes new crates and Deno's workspace crates from their own legal files (workspace crates with Deno's LICENSE.md); a new crate without a legal file stops it;
  • carries the v8 crate's native V8 notices and cites rusty_v8's LICENSE at the crate's commit;
  • reads the legal comments of Deno's embedded JavaScript again at the new release, finds every carried excerpt exactly once, and stops when a source file outside the list gained legal comments;
  • writes the release's deno_src.tar.gz URL and pins, and carries the V8 source repository and commit with the native V8 notices;
  • stops when the release runs another V8 than the inventory's V8 notices, or builds with another Rust toolchain than its standard-library notices; those parts need a review first (a V8 refresh also updates source.v8Repository and source.v8Commit).

It writes assets/deno-runtime-notices/deno-<version>-<targets>.json and its pin in ts/tsdeno.runtimenotices.pins.ts. Review the diff, then commit and release tsdeno.

Build guards

deno compile can exit 0 and still leave a binary that does not work: when its npm package downloads fail mid-compile, Deno 2.9 has written a binary a fraction of the normal size that dies at startup, and reported success. And a build that runs deno from PATH compiles with whichever Deno comes first there. tsdeno checks both.

Unresolved npm dependencies

Deno 2.9 fails deno compile for an npm import it cannot resolve only when the import is static. Behind a dynamic import (a CLI entry that runs await import('./dist_ts/index.js')), it exits 0 and leaves the package out: the binary fails once it reaches the import, with Could not find constraint '<name>@<range>' in the list of packages, and a smoke check such as --version that never gets there passes. The usual cause is the minimum dependency age: every version in the range was published too recently.

tsdeno reads the module graph with deno info --json and the run's resolution flags before every compile with an entry point and after every tsdeno install --entrypoint, and fails with UNRESOLVED_DEPENDENCY when Deno reports an npm import it could not resolve:

tsdeno: Deno cannot resolve 1 npm package(s) in the module graph of binary/app.ts; deno compile would exit 0 when they sit behind a dynamic import and leave them out of the binary:
  - @scope/pkg@^32.0.3: minimum dependency age (every version in the range is younger than the minimum dependency age; wait, or exempt the package (Deno minimumDependencyAge.exclude, pnpm minimumReleaseAgeExclude)); reported on npm:@scope/pkg@^32.0.3; Deno: Could not find npm package '@scope/pkg' matching '^32.0.3'. A newer matching version was found, but it was not used because it was newer than the specified minimum dependency date of ... [UNRESOLVED_DEPENDENCY]

The cause is one of minimum dependency age, no matching version in the registry, not in the registry, and registry unreachable or offline. A compile fails before deno compile runs; an install fails after Deno wrote its lock, which lacks the named packages. The check follows Deno's graph: a graph without npm imports passes, whatever package.json declares. Deno resolves the graph's npm imports together with the package.json dependencies, so a declared dependency that does not resolve fails any graph that imports npm packages, as deno compile does for a static import.

Size floor: minSize

minSize (--min-size) is the lower counterpart of maxSize, with the same syntax (bytes, or a number with B, KB/MB/GB or KiB/MiB/GiB). A binary smaller than the floor fails the compile with SIZE_BELOW_FLOOR; a floor above the budget is rejected as INVALID_ARGUMENT before anything runs. A compile that exits 0 without writing the binary at all fails with BINARY_MISSING.

Smoke check: smoke

After the size checks, tsdeno can run the new binary once, without a shell:

{
  "@git.zone/tsdeno": {
    "compileTargets": [
      {
        "name": "spark-linux-x64",
        "entryPoint": "mod.ts",
        "outDir": "dist/binaries",
        "target": "x86_64-unknown-linux-gnu",
        "minSize": "200MiB",
        "maxSize": "300MiB",
        "smoke": {
          "args": ["--version"],
          "expectExitCode": 0,
          "expectStdoutPattern": "^spark \\d+\\.\\d+\\.\\d+$",
          "timeoutMs": 30000,
          "cleanEnv": true
        }
      }
    ]
  }
}
Field Default Meaning
args — Arguments for the binary
expectExitCode 0 The exit code it must return
expectStdout — Text stdout must contain
expectStdoutPattern — A JavaScript regular expression the trimmed stdout must match
timeoutMs 30000 After this long the binary is killed (SIGKILL) and the check fails
cleanEnv false Run with an empty environment instead of tsdeno's; recommended in CI

The binary runs in the project directory. Any mismatch fails the compile with SMOKE_FAILED, and the message names every reason, the exit code, the signal and the end of stdout and stderr:

tsdeno: smoke check failed: /work/app/dist/app --version: exit code 3, expected 0 (exit code 3, signal none; stdout "starting"; stderr "boom: missing asset") [SMOKE_FAILED]

A binary built for another system cannot run here, so for any target other than the host's the check is skipped with tsdeno: smoke skipped: foreign target aarch64-apple-darwin (host x86_64-unknown-linux-gnu). The binary stays on disk after a failed check for inspection.

On Linux and macOS the binary starts in its own process group; when the check ends, passed or failed, whatever is left in that group is killed (SIGKILL). A process that moves itself into another process group or session (for example with setsid, or a Node child spawned detached) is outside the group and is not reached; tsdeno stops reading its output, so the check cannot hang. When the binary has exited but a process it started still holds its output open, the check waits until timeoutMs, then kills the group and fails with output still open after N ms, even if the exit code was as expected. On SIGINT or SIGTERM, tsdeno forwards the signal to the group, SIGKILLs what is left of it after 10 seconds, restores package.json and exits. Windows has no process groups: there only the binary itself is killed, and processes it started keep running.

Without cleanEnv, the binary inherits tsdeno's environment, and a failure message quotes the end of its stdout and stderr, which then may contain values it read from that environment, such as tokens. Set cleanEnv: true in CI, where build logs are shared.

CLI flags, placed before the entry point; any of them turns the check on:

tsdeno compile --allow-all --min-size 200MiB --max-size 300MiB \
  --smoke-arg --version --smoke-stdout-match '^spark \d+' --smoke-clean-env \
  --output dist/spark mod.ts

--smoke (run without arguments), --smoke-arg <arg> (repeatable), --smoke-exit-code <n>, --smoke-stdout <text>, --smoke-stdout-match <regex>, --smoke-timeout <ms>, --smoke-clean-env.

Deno selection: denoExecutable and denoVersion

tsdeno starts deno from PATH unless told otherwise. denoExecutable names the Deno it runs for every command (install, the deno info of the dependency check and the prune plan, and compile); denoVersion refuses any other version with DENO_VERSION_MISMATCH before package.json is touched. 2.9.4 requires exactly that release (a prerelease or build such as 2.9.4-rc.1 does not match); 2.9 or 2 accept any version that starts with those numbers, prereleases included. denoVersion must be a string: as a JSON number, 2.10 would read as 2.1, so tsdeno rejects numbers with INVALID_ARGUMENT.

{
  "@git.zone/tsdeno": {
    "denoExecutable": "./.nogit/deno/bin/deno",
    "denoVersion": "2.9.4",
    "compileTargets": []
  }
}

A value containing a path separator is a path (relative paths resolve against the project directory); a bare name is looked up on PATH. On the CLI both come first, before any other argument:

tsdeno compile --deno-executable /opt/deno-2.9.4/deno --deno-version 2.9.4 --output dist/app mod.ts
tsdeno install --deno-executable /opt/deno-2.9.4/deno --entrypoint --lockfile-only --frozen=false --lock=deno.lock mod.ts
tsdeno compile --deno-executable /opt/deno-2.9.4/deno   # config mode

Code passes them to the constructor: new TsDeno(cwd, { denoExecutable, denoVersion }). Constructor and CLI values take precedence over .smartconfig.json. Every run prints the Deno it uses (tsdeno: using Deno 2.9.4 (/opt/deno-2.9.4/deno)), and the size line of each binary names that version. An executable that cannot be started or does not answer --version like Deno fails with DENO_UNAVAILABLE.

Programmatic API

You can also use tsdeno as a library in your build scripts:

import { TsDeno } from '@git.zone/tsdeno';

const tsDeno = new TsDeno(); // uses process.cwd()
// or: new TsDeno('/path/to/project')
// or: new TsDeno('/path/to/project', { denoExecutable: '/opt/deno-2.9.4/deno', denoVersion: '2.9.4' })
// or: new TsDeno('/path/to/project', { dependencyAge: 'deno' })

// Passthrough mode — pass args directly
await tsDeno.compile([
  '--allow-all',
  '--no-check',
  '--output', 'dist/myapp',
  '--target', 'x86_64-unknown-linux-gnu',
  'mod.ts',
]);

// Config mode — reads compile targets from .smartconfig.json
await tsDeno.compileFromConfig();

The TsDeno class handles the full package.json sanitize/restore lifecycle automatically.

CI/CD Integration

Example Gitea/GitHub Actions workflow:

steps:
  - name: Set up Deno
    uses: denoland/setup-deno@v1
    with:
      deno-version: v2.x

  - name: Set up Node.js
    uses: actions/setup-node@v4
    with:
      node-version: '22'

  - name: Enable pnpm
    run: corepack enable pnpm

  - name: Compile binary
    run: pnpm dlx @git.zone/tsdeno compile --allow-all --no-check --output myapp mod.ts

🧠 How It Works — Deep Dive

Why package.json Causes Bloat

Deno projects often have both deno.json (with npm: import specifiers for runtime deps) and a package.json (for npm publishing, scripts like tswatch/tsbundle, etc.). When deno compile runs:

  1. Deno discovers package.json and resolves all listed packages (deps + devDeps)
  2. These get cached in Deno's global npm cache
  3. deno compile embeds everything it resolved — the full transitive closure
  4. Your binary now contains build tools, linters, test frameworks, etc.

What tsdeno Does Differently

By rewriting package.json without devDependencies during compilation, Deno sees only runtime dependency sections while still being able to resolve normal package imports. Combined with --node-modules-dir=none (which prevents Deno from creating/reading a local node_modules), the result is a clean binary without dev-only tooling.

For deno compile --self-extracting, Deno can leave hidden staging generations next to the output binary, for example dist/.myapp/<hash>/.deno_compile_node_modules. tsdeno marks the expected sidecar root with .gitzone-tool-cache.json and removes marked staging generations after a successful compile once the final binary exists and is non-empty. Existing unmarked non-empty sidecar roots are not claimed or deleted.

Safety Guarantees

  • Atomic restore: package.json and the Deno config file carrying the dependency age are restored in a finally block — they are put back even if deno compile crashes, and an interrupted run is recovered by the next one
  • No package.json: only the package.json step is skipped; the dependency age still applies (see Dependency age) unless dependencyAge is "deno"
  • Exit code passthrough: If deno compile fails, tsdeno exits with the same code
  • Marker-gated staging cleanup: Self-extracting staging cleanup only removes directories under a tsdeno-marked sidecar root
  • Evidence-based pruning: Native files are only left out when their header or npm os/cpu fields prove the target cannot load them; unmatched or conflicting rules and exceeded size budgets fail the compile with a named error
  • Checked output: A missing binary, one below its size floor, or one that fails its smoke check fails the compile with a named error instead of passing as a success
  • Known Deno: The Deno in use is printed on every run and can be pinned by path and version
  • Transparent: All output from deno compile is streamed through to your terminal

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
3.6 MiB
Languages
TypeScript 99.9%