Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,45 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Experimental `ts_npm_module` rule: provides an npm package to Deno's own
`npm:` resolution. The sha256-pinned tarball is extracted by the build into a
slice of a Deno npm cache that targets merge into their `DENO_DIR`; no
`node_modules`, no lockfile. CommonJS packages, subpath imports and packages
with only an `exports` map work as Deno resolves them. Dependencies resolve
automatically (`resolve_transitive`, on by default, strictly and verified
against the registry's integrity data, with several versions kept side by side
when dependents need different ones), or can be declared as separate pinned
`ts_npm_module` targets with `resolve_transitive = False`. Type checks and
tests run with `--cached-only`, so a missing module fails instead of being
downloaded.
- Dependency resolution is reproducible without a lockfile: only versions
published by the end of the day the root version was published are considered,
for both `ts_npm_module` and `ts_module`, so the version pinned by its hash
fixes the result and nothing needs configuring.
- `please_ts unpack --npm-cache`, and the `npmcache`, `registry` and `semver`
packages behind it.
- Fixtures for `debug` (CommonJS, transitive dependency), `highlight.js`
(CommonJS, subpaths) and `@codemirror/legacy-modes` (exports map only),
automatic and fully pinned.

### Fixed

- `ts_module`'s automatic dependency resolution (`resolve_transitive`) is now
strict and verified. It used to fall back to the `latest` version when no
version satisfied a range, verify no download against the registry's integrity
data, keep the first version of a package silently when a second dependent
needed an incompatible one, and only print a warning, leaving an incomplete
tree, when resolution failed. A range that no version satisfies, an integrity
mismatch, a tarball that holds another package and conflicting versions are
now build errors; unresolvable peer dependencies stay warnings. Version ranges
are interpreted completely (`||`, hyphen and x-ranges, prereleases) instead of
only `^`, `~` and `>=`. **This can make builds fail that used to pass with an
incomplete or inconsistent tree.**

## [0.6.0] - 2026-10-03

### Added
Expand Down
91 changes: 90 additions & 1 deletion build_defs/ts/ts.build_defs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,17 @@ def ts_module(
registry: str = "",
visibility: list = None,
labels: list = None):
"""Downloads an npm package tarball, extracts it, resolves transitive dependencies, and creates metadata for downstream targets."""
"""Downloads an npm package tarball, extracts it, resolves transitive dependencies, and creates metadata for downstream targets.

Only the root tarball is pinned (`hashes`). Dependencies are resolved from the registry at
build time, strictly: a range that no version satisfies, a download that does not match
the registry's integrity data, or two dependents that need incompatible versions of one
package (this layout holds one) fails the build.

Resolution is reproducible: only dependency versions published by the end of the day the
root version was published are considered, so the pinned root fixes the result and nothing
needs configuring. Bump the root version to move the dependencies forward.
"""
pkg = package or name
ver = version

Expand Down Expand Up @@ -219,6 +229,85 @@ def ts_module(
building_description = f"Unpacking npm module {pkg}@{ver}...",
)

def ts_npm_module(
name: str,
package: str = "",
version: str = "",
hashes: list = None,
url: str = "",
deps: list = None,
resolve_transitive: bool = True,
registry: str = "",
visibility: list = None,
labels: list = None):
"""Provides one npm package to Deno's own npm resolution, hermetically and offline once built.

The tarball is pinned by `hashes` (sha256, checked by Please). The build step extracts it
into a slice of a Deno npm cache; targets that depend on it get the slices merged into
their DENO_DIR and import the package through an `npm:` specifier. Deno then resolves
`exports`, CommonJS and subpaths itself: there is no node_modules directory and no
lockfile, and a package that Deno cannot resolve from the cache fails the build rather
than reaching for the network.

Dependencies are handled in one of two ways:

* `resolve_transitive = True` (the default, like ts_module): the build resolves the
dependencies from the registry, each verified against the registry's integrity data,
and keeps several versions of a package when dependents need different ones. This needs
network access during the build (so no sandbox) and trusts the registry for everything
but the root tarball. Resolution is reproducible without a lockfile: only versions
published by the end of the day the root version was published are considered, so the
pinned root fixes the result (bump its version to move the dependencies forward). Dependencies listed in `deps` are used as they are, not
resolved again.
* `resolve_transitive = False`: nothing is resolved. Every dependency (and theirs) must
be its own ts_npm_module, listed in `deps`, each pinned by its own sha256; the build
needs no network access and fails naming any dependency that is missing.

Args:
name: Name of the rule.
package: npm package name (defaults to `name`), e.g. "debug" or "@scope/pkg".
version: Exact package version.
hashes: sha256 of the package tarball.
url: Tarball URL (defaults to the npm registry).
deps: ts_npm_module targets that provide dependencies explicitly.
resolve_transitive: Resolve the dependencies from the registry at build time.
registry: npm registry URL used to resolve dependencies.
visibility: Visibility of the rule.
labels: Labels for the rule.
"""
pkg = package or name
if not url:
pkg_base = pkg.split("/")[-1] if "/" in pkg else pkg
url = f"https://registry.npmjs.org/{pkg}/-/{pkg_base}-{version}.tgz"

dl_target = f"_{name}#download"
remote_file(
name = dl_target,
url = url,
hashes = hashes or [],
out = f"{name}.tgz",
)

tools = {"TOOL": [_ts_tool()]}
resolve_flag = "--resolve-transitive" if resolve_transitive else ""
registry_flag = f'--registry "{registry}"' if registry else ""
return build_rule(
name = name,
srcs = [f":{dl_target}"],
deps = deps or [],
exported_deps = deps or [],
outs = [name],
cmd = f'$TOOLS_TOOL unpack --npm-cache --tarball "$SRCS" --out "$OUT" --name "{pkg}" {resolve_flag} {registry_flag}',
# Resolving needs the registry. Without it the step only reads local files and follows
# the [sandbox] setting like every other rule.
sandbox = False if resolve_transitive else None,
needs_transitive_deps = True,
tools = tools,
visibility = visibility or ["PUBLIC"],
labels = labels or ["ts", "module", "npm", "npm_cache"],
building_description = f"Building Deno npm cache slice for {pkg}@{version}...",
)

def ts_library(
name: str,
srcs: list,
Expand Down
82 changes: 82 additions & 0 deletions docs/ts/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,85 @@ branch (`BRDA`) records that `coverage.xml` and `coverage.json` do not:
`tools/common/lcov` package. Do not merge branch records of the Deno and
Vitest runners for the same file: they number the arms of a branch
differently.

---

## npm Packages Through Deno's Own Resolution (`ts_npm_module`, experimental)

`ts_module` unpacks a package and describes it with a single entry file, which
cannot express CommonJS packages, subpath imports (`highlight.js/lib/core`) or
packages that only have an `exports` map. `ts_npm_module` takes a different
route: Deno resolves the package itself through an `npm:` specifier, and the
rule only has to make the package available **offline**. List the package you
import, pinned by the hash of its tarball:

```starlark
ts_npm_module(
name = "debug",
hashes = ["c803a8ca9b835b7a75f7150ee52f7f640675515bafbe8f5da78fcc0ae12914ac"],
version = "4.3.7",
)

ts_library(
name = "humanize",
srcs = ["humanize.ts"],
module_name = "@app/humanize",
deps = [":debug"],
)

ts_test(
name = "humanize_test",
srcs = ["humanize_test.ts"],
deps = [":debug", ":humanize"], # list the npm module itself, as with ts_module
)
```

```typescript
import createDebug from "debug"; // CommonJS, and it needs the package "ms"
```

- **Dependencies resolve automatically** (`resolve_transitive`, on by default,
as for `ts_module`): `debug` needs `ms`, and nothing else has to be declared.
The build resolves dependencies from the registry, strictly: a range that no
version satisfies, a download that does not match the registry's integrity
data, or a tarball that holds another package fails the build. Dependents that
need different versions of one package each get theirs (Deno resolves per
dependent), which `ts_module`'s single `.deps` directory cannot do. Optional
and peer dependencies are not fetched.
- **Reproducible without a lockfile**: only dependency versions published by the
end of the day the root version was published are considered, so the version
you pin by hash fixes the result and nothing needs configuring. Bump the root
version to move the dependencies forward. If the registry does not know the
root version's publish time (a private registry, a tarball from another URL),
the build says so and does not pin.
- **Cost of automatic resolution**: the build needs network access (so no
sandbox for these targets) and trusts the registry for everything except the
root tarball.
- **Fully pinned alternative**: with `resolve_transitive = False` nothing is
resolved. Every dependency, and theirs, is its own `ts_npm_module` in `deps`,
each with its own tarball hash, and the build needs no network access. The
build fails and names any dependency that is missing. Dependencies you do list
in `deps` are always used as they are, never resolved again, so the two modes
can be mixed.
- **Offline afterwards**: everything that runs after the build (type checks,
tests) uses only the extracted cache, with no `node_modules` directory and no
lockfile. Targets run with `--cached-only`, so a module missing from a
target's `deps` fails at once with `npm package not found in cache`; Deno does
not download it.
- **Resolution is Deno's**: `exports` maps, CommonJS, subpaths and a package's
own types work as they do for `npm:` specifiers. Imports use the plain package
name.
- **Not wired yet**: `ts_bundle` and `ts_binary`, the Vitest and browser
runners, and a helper that prints pinned declarations. The cache layout
(`registry.json`, including its `_deno.packumentFormat` key) is internal to
Deno, so it is tied to the Deno version the plugin pins.

`ts_module` resolves its dependencies by the same rules (strictly, verified, as
of the root's publish day), with the limit of one version per package: two
dependents that need incompatible versions fail the build and name each other.

The fixtures in `test/ts/npm_cache` cover a CommonJS package with a transitive
dependency (`debug`), a CommonJS package imported through subpaths
(`highlight.js`), and an ES module package that only has an `exports` map and no
`main` (`@codemirror/legacy-modes`), each with automatic resolution and, for the
last two, with every dependency pinned explicitly.
133 changes: 133 additions & 0 deletions test/ts/npm_cache/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
subinclude("//build_defs/ts:ts")

# A CommonJS package and its transitive dependency, each pinned by the sha256 of its tarball.
ts_npm_module(
name = "ms",
hashes = ["f6616e15e530ed552f9daa2d3ce71963947c6bc7c98c9b64fd3e673fd02622c6"],
version = "2.1.3",
resolve_transitive = False,
)

ts_npm_module(
name = "debug",
hashes = ["c803a8ca9b835b7a75f7150ee52f7f640675515bafbe8f5da78fcc0ae12914ac"],
version = "4.3.7",
resolve_transitive = False,
deps = [":ms"],
)

ts_library(
name = "humanize",
srcs = ["humanize.ts"],
module_name = "@test/humanize",
deps = [":debug"],
)

ts_test(
name = "humanize_test",
srcs = ["humanize_test.ts"],
deps = [
":debug",
":humanize",
],
)

# highlight.js is CommonJS and is imported through subpaths (highlight.js/lib/core).
ts_library(
name = "highlight",
srcs = ["highlight.ts"],
module_name = "@test/highlight",
deps = ["//test/ts/npm_cache/modules:highlight_js"],
)

ts_test(
name = "highlight_test",
srcs = ["highlight_test.ts"],
deps = [
":highlight",
"//test/ts/npm_cache/modules:highlight_js",
],
)

# @codemirror/legacy-modes is ESM with only an exports map (./mode/*) and no main or module.
ts_library(
name = "modes",
srcs = ["modes.ts"],
module_name = "@test/modes",
deps = [
"//test/ts/npm_cache/modules:codemirror_language",
"//test/ts/npm_cache/modules:codemirror_legacy_modes",
],
)

ts_test(
name = "modes_test",
srcs = ["modes_test.ts"],
deps = [
":modes",
"//test/ts/npm_cache/modules:codemirror_language",
"//test/ts/npm_cache/modules:codemirror_legacy_modes",
],
)

# Automatic resolution: only the root tarball is pinned. The dependencies are resolved from
# the registry when the module is built, as of the day the root version was published, and
# verified against the registry's integrity data.
ts_npm_module(
name = "debug_auto",
package = "debug",
hashes = ["c803a8ca9b835b7a75f7150ee52f7f640675515bafbe8f5da78fcc0ae12914ac"],
version = "4.3.7",
)

ts_library(
name = "humanize_auto",
srcs = ["humanize.ts"],
module_name = "@test/humanize_auto",
deps = [":debug_auto"],
)

ts_test(
name = "humanize_auto_test",
srcs = ["humanize_auto_test.ts"],
deps = [
":debug_auto",
":humanize_auto",
],
)

# The same exports-only package as modes_test, with its eleven dependencies resolved
# automatically instead of declared.
ts_npm_module(
name = "legacy_modes_auto",
package = "@codemirror/legacy-modes",
hashes = ["61143cdb9c375dc1521895e4e536d75036baaf6e9df6b4431691f7ea5d273463"],
version = "6.5.1",
)

ts_npm_module(
name = "codemirror_language_auto",
package = "@codemirror/language",
hashes = ["5e49acf55fde65ce9848068f0f06478be9ec71f818e91de6eca00824e9152226"],
version = "6.12.4",
)

ts_library(
name = "modes_auto",
srcs = ["modes.ts"],
module_name = "@test/modes_auto",
deps = [
":codemirror_language_auto",
":legacy_modes_auto",
],
)

ts_test(
name = "modes_auto_test",
srcs = ["modes_auto_test.ts"],
deps = [
":codemirror_language_auto",
":legacy_modes_auto",
":modes_auto",
],
)
10 changes: 10 additions & 0 deletions test/ts/npm_cache/highlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import core from "highlight.js/lib/core";
import go from "highlight.js/lib/languages/go";

const hljs: any = core;
hljs.registerLanguage("go", go);

/** Highlights Go source as HTML. */
export function highlight(source: string): string {
return hljs.highlight(source, { language: "go" }).value;
}
8 changes: 8 additions & 0 deletions test/ts/npm_cache/highlight_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { highlight } from "@test/highlight";

Deno.test("highlight.js: subpath imports of a CommonJS package", () => {
const html = highlight("package main");
if (!html.includes("hljs-keyword")) {
throw new Error(`expected a highlighted keyword, got ${html}`);
}
});
6 changes: 6 additions & 0 deletions test/ts/npm_cache/humanize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import createDebug from "debug";

/** Formats a duration in milliseconds with the `ms` package, which `debug` re-exports. */
export function humanize(milliseconds: number): string {
return (createDebug as any).humanize(milliseconds);
}
Loading
Loading