Repository navigation
ts: use CommonJS and entry-less npm packages (bundle an npm package into a ts_library) #61
Description
Activity
Analysis and proposed design
Short version: don't fix this with more resolution rules or a bundling wrapper. Let Deno resolve npm packages itself (
npm:specifiers), and feed it a cache built offline from hash-pinned tarballs. That needs nonode_modules, no lockfile, and noexportsreimplementation, and it keeps the plugin's stated principles ("Zeronode_modules", "Zero Foreign Lockfiles", offline--no-remote).Root causes in the code
importmap.RegisterRootModulesmapspkg/to the entry's directory, not the package root, hencehighlight.js/lib/corebecoming.../lib/lib/core.- The
ts_bundlealiases (bundle.go) mappkgto the entry file, and esbuild appends a subpath to an alias, hence<entry file>/core.js. package.jsonexportsis parsed intoPackageJSON.Exportsand never used, and a package with onlyexportshas noentryat all.- Fixing each of these means reimplementing Node resolution (patterns, conditions, CommonJS interop) in Go.
Spike results (Deno 2.9.7, the plugin's pinned default; scratch folder, nothing in the repo changed)
Deno resolves both failing shapes natively. With no
node_modulesdirectory and an import map"highlight.js/": "npm:/highlight.js@11.12.0/":Case deno checkdeno rundeno bundlehighlight.js/lib/coreand language subpaths (CommonJS)passes works 60 KB @codemirror/legacy-modes/mode/go(nomain/module, onlyexports: ./mode/*)passes works bundles The cache can be built offline from verified tarballs. Deno's npm cache is
npm/registry.npmjs.org/<pkg>/<version>/(the extracted package) plus a smallregistry.json. I built it from tarballs whose sha512 I checked, with a packument ofname,dist-tags, a version entry (dist, anddependencies/peerDependenciestaken from the verified tarball's ownpackage.json) and_deno.packumentFormat: "full". With the proxy pointed at a dead port:Check Result debug@4.3.7(CommonJS) with transitivemsdeno checkpasses,deno runprints the right outputdeno bundleworks; Deno downloads an esbuild binary (not an npm package) to $DENO_DIR/dl/esbuild-0.25.5-1/esbuild-linux-x64(10 MB), so pre-placing that exact file makes bundling offline; the bundle then runs with no cache at allWithout _deno.packumentFormatinregistry.jsonDeno tries to download the packument Deno trusts the cache and does not verify it:
Scenario Result Wrong integritystring inregistry.json, package dir presentworks, ignored A file inside the extracted package dir edited the edited code runs Package dir, transitive slice or esbuild binary missing Deno tries the network and, with it unreachable, hangs until a timeout. run --cached-onlyfails instantly with a clear message;check --cached-onlystill hung on a missing transitive sliceToday's hermeticity (for comparison)
ts_modulepins the root tarball by sha256 (remote_file), but withresolve_transitive = True(the default) the unpack step runs unsandboxed and picks transitive dependencies from the registry by semver range, unpinned.vitest_toolchainruns an unpinneddeno cachetoo. So the proposed model is stricter than the current one, not looser.Proposed design
- One target per npm package, transitive ones included: a
ts_module-style rule withremote_file+ sha256, and its dependencies listed indeps, like Please's pip rules. The hashes live in BUILD files; no separate lockfile. A small helper can print the BUILD snippets for a package and its closure. - The build step runs offline. It extracts the verified tarball into Deno's cache layout and writes the
registry.json. It fails at build time if anydependenciesentry has no matching declared dependency (the dependency closure check), instead of letting Deno fail at runtime. - The import map maps
nameandname/to thenpm:forms for npm packages. First-partymodule_namealiases are unchanged. - The runners merge the dependencies' cache slices into the per-run
DENO_DIRthey already create, and run with--no-remote(and--cached-onlyforrun/test). - Bundling: pre-place the pinned esbuild binary under
dl/and usedeno bundle, or keep esbuild as the hash-pinnedBundlerTool(the plugin already supports it).deno bundleprints "experimental and subject to changes", which is a risk to weigh.
What this does to the proposals in this issue
- Honor
exports, fix the esbuild alias, and CommonJS: replaced by Deno's own resolution. js_libraryandts_npm_bundle: not needed for npm packages. The bundle-and-wrap pattern stays a documented fallback.
Costs and risks
- The
_deno.packumentFormatkey and thedl/esbuild-<version>-<n>path are Deno-internal. They tie the format to the pinned Deno version, so every Deno bump needs a CI test (the fixtures below). - Integrity is enforced by Please's sha256 on the tarball and nowhere else; the cache must be treated as trusted input.
- Network isolation is not guaranteed by
--no-remote/--cached-onlyalone; real isolation needs Please's sandbox, whichts_testdoes not enable by default. - Not verified yet: a cache built through Please rules, merging slices from several targets, Vitest and browser runners, platforms other than
linux-x64(the esbuild binary is per platform). - Behaviour change for existing users: ship behind a config flag for one release.
Plan
- Prototype: a rule that builds a cache slice from a pinned tarball, the import map entries for it, and the runner merging slices (Deno runner and type check first).
- Fixtures first: a CommonJS package with a transitive dependency, plus
highlight.jsandlegacy-modesfor the two shapes in this issue, with a CI smoke test. - Bundling, the helper that prints BUILD snippets, the flag, docs.
- Flip the default and remove the npm part of the import map, the semver resolution in
unpack, and the esbuild alias code.
- added 2 commits that reference this issue
on Oct 4, 2026 Done: the prototype (#62) and the wiring (#77) are merged and released in
ts-v0.7.0.ts_npm_moduleprovides an npm package through Deno's own resolution (CommonJS, entry-less and exports-only packages, subpaths), pinned by sha256, with strict and reproducible automatic dependency resolution. No lockfile.- It works in type checks,
ts_test(Deno and Vitest),ts_browser_test,ts_bundleandts_binary. Bundles usedeno bundlewith a pinned esbuild (esbuild_toolchain);ts_binaryembeds the packages. - Not done, as noted in docs/ts/usage.md: the helper that prints pinned declarations (automatic resolution makes it much less necessary), pinning
deno compile'sdenortdownload, and the non-npmts_bundlepath still fetches esbuild at run time.
Summary
Some npm packages cannot be used from a
ts_library,ts_testorts_bundletoday: CommonJS packages, and pure-ESM packages that have nomain/moduleentry. There is a pattern that makes them usable without changing the plugin (bundle the package withts_bundle, wrap the bundle in ats_library), shown below. This issue records the problem, what failed, the working pattern and its rough edges, and proposes making it automatic.Why packages fail
ts_moduleunpacks the tarball and records a singleentry(frommodule,mainor anindex.*file). The import map then:pkgto that entry andpkg/to the entry's directory;exportsfield ofpackage.json(it is parsed intoPackageJSON.Exportsand never used);ts_bundle) the same entries as--alias:pkg=<entry file>, and esbuild appends a subpath to the alias, sopkg/sub.jsbecomes<entry file>/sub.js.What was tried (all with
highlight.js11.12.0 and similar)import hljs from "highlight.js/lib/core".../highlightjs/lib/lib/core: subpaths are relative to the entry directory (lib/)import "highlight.js/core.js"(with the extension)module.exports = x):TS1192/TS2497"no default export" / "can only be referenced with ECMAScript imports"// @ts-ignoreon the importsTS2307on the extensionlessrequire("./html_renderer")inside the packageflags = ["--unstable-sloppy-imports"]on thets_library(this option already exists)TS2303 Circular definition of import alias 'hljs': the package's ownlib/core.d.tsdoesimport hljs from "highlight.js", which the import map points back at the same package@highlightjs/cdn-assets(ESM builds)main/module, so no entry:Import "..." not a dependency and not in import map@codemirror/legacy-modes(pure ESM,exports: {"./mode/*": ...})exportsis ignoredts_bundledirectly onimport "highlight.js/core.js"Could not resolve "./third_party/ts/highlightjs/lib/index.js/core.js"(the alias-appends-subpath behaviour above)What works today (no plugin change)
Two targets: bundle the package with esbuild (which understands CommonJS), then wrap the bundle in a
ts_library.third_party/ts/highlight/entry.ts(imports the unpacked module by relative path, which esbuild resolves because the module is laid out next to the entry in the bundle's working directory):third_party/ts/highlight/highlight.d.ts(hand-written):third_party/ts/highlight/BUILD:Consumers
import { highlightToHtml } from "@veritas/highlight"and list//third_party/ts/highlightindeps(also forts_testandts_bundle, like any other module). Verified:deno checkpasses, ats_testwithrunner = "vitest"runs it, and the finalts_bundleincludes it.Result for 12 languages: 76 KB minified, 24 KB gzipped; the consumer's bundle went from 375 KB to 442 KB.
Rough edges
../highlightjs/lib/core.js): it depends on how the bundle sandbox lays out dependencies..d.ts: nothing generates it, and the package's own types cannot be used (see the circular alias above).minify = Trueis required: the bundle keeps JSDoc such as@typedef { import("./html_renderer").Renderer }, whichdeno checkof the wrappingts_librarythen tries to resolve (TS2307). Minifying strips the comments.deps.Proposal: make it automatic
Any subset of these would help; they are independent.
ts_npm_bundle(name, module, imports = [...], exports = [...], types = ..., minify = True): generates the entry from a list of specifiers and exported names, resolves them through the unpackedts_module(no hand-written relative paths), bundles with esbuild, and wraps the result in ats_library. Types: accept a.d.ts, or emit a permissive stub, or generate declarations when the package ships ESM types.js_libraryrule: plain JavaScript plus an optional.d.ts, published with the module metadata sots_library/ts_test/ts_bundlecan import it, without type-checking the JavaScript. This is what the wrapper above is, spelled directly.exportsin the import map: record it ints_module.json, expand subpath patterns (./mode/*) and conditions (import,default), and useexports["."]as the entry when there is nomain/module. That makes pure-ESM packages such as@codemirror/legacy-modesand many modern libraries work directly, with no bundling.pkg/subresolves ints_bundle.module.exportscase without bundling) is the hardest and probably not worth supporting directly; bundling covers it.Acceptance criteria
ts_library,ts_testandts_bundle, without hand-written relative pathsexportsmap and nomaincan be imported (thelegacy-modescase)ts_library, run ats_teston it)Reference
ts_moduleunpack and import map code intools/please_ts/unpackandtools/please_ts/importmap.