New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@mintlify/package-builder

Package Overview
Dependencies
Maintainers
6
Versions
44
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mintlify/package-builder

Installs, bundles and server-renders npm package islands for Mintlify docs

Source
npmnpm
Version
0.0.30
Version published
Weekly downloads
1.3K
Maintainers
6
Weekly downloads
 
Created
Source

@mintlify/package-builder

Builds npm package islands for docs that set a packages key in docs.json. The same code runs in the Cloudflare Sandbox build container and in mint dev.

packages.isolation: "frame" is not implemented yet. A build that sets it fails with isolation-frame-not-implemented instead of running the islands in the page.

Steps:

  • Validates the lockfile (package-lock.json v2/v3 or pnpm-lock.yaml v9, which pnpm 9 and 10 write; the build worker reads no other pnpm format): every entry needs integrity, and every npm entry except bundled ones needs resolved (npm leaves it out under omit-lockfile-registry-resolved); git, file:, link: and http: sources are rejected; https URLs must be on registry.npmjs.org or on the scope's registry from packages.registries. A registry in packages.registries must be one the build worker can reach: no port other than 443, no IP address, a host name with a dot, and nothing under localhost, local, internal, home.arpa, cloudflare.com, cloudflarestorage.com, r2.dev, workers.dev or pages.dev. The worker also refuses Mintlify's own domains; that list lives in the server and is not repeated here. package.json dependencies, overrides and pnpm overrides/extensions must be registry versions, ranges or dist-tags (no npm: aliases, git, file:, link:, workspace:, catalog:, URLs or paths), pnpm.patchedDependencies is rejected, and the lockfile root must match package.json. npm entries marked inBundle cannot carry their own resolved, and pnpm resolutions may only have integrity and tarball. For npm-ci and prepared, package.json and the lockfile must be regular files, not symbolic links, and parse errors report only a line and column.
  • Installs. npm-ci copies only package.json and the lockfile into a temp dir, writes its own .npmrc (deleted as soon as the install exits, since it can hold registry tokens; registries and tokens containing ${ are rejected because npm fills them from environment variables) and runs npm ci --ignore-scripts --omit=dev. It retries once with --legacy-peer-deps on ERESOLVE. On Windows it never spawns a .cmd shim or a shell: it runs npm-cli.js (or pnpm's pnpm.cjs/pnpm.mjs/pnpm.js) with the current Node, or pnpm.exe, found from npm_execpath, PATH or the Node install, and fails with package-manager-missing otherwise. local (for mint dev) uses the docs folder's existing node_modules and turns lockfile, package.json and registry host errors into warnings. prepared is for a caller that installed packages itself, like the build worker: it uses the existing node_modules but keeps every check an error, including installed versions that differ from the lockfile, and never reads node_modules folders above the docs folder. The copied package.json drops packageManager and devEngines, Corepack is told not to switch versions, and pnpm runs with package-import-method=copy; packageManagers.pnpm maps a pnpm major to an installed binary, so a project asking for that major gets it and any other major gets the default pnpm with a warning. registryTokens is keyed by registry URL: a token is written only for scopes whose registry in packages.registries is that URL. npm-ci and prepared builds reject hard-linked files in packages and docs files.
  • Bundles every island in one esbuild build with code splitting. Imports cannot leave the docs folder or climb out of a package with .., packages.css entries must be package paths, and packages.provider must be a docs path that starts with /, such as /snippets/provider.jsx (without the / the entry would import it as an npm package). A docs or island import()/require() that builds a ./, ../ or / path at runtime (a template literal with ${} or string concatenation) fails with import-runtime-path before installing, because only statically imported docs files are hashed and copied; dynamic imports of package paths are left alone. Snippets and the provider cannot import a package manager file at the docs root (package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, pnpm-workspace.yaml, .pnpmfile.cjs, .pnpmfile.mjs, yarn.lock, .yarnrc, .yarnrc.yml) or any .npmrc, because the build worker rejects jobs that send them (local-file-package-manager, a warning in local); package.json can still be imported. Every file esbuild reads, from any importer (packages, their CSS @import and url(), glob imports, asset loaders), must have a real path (links and Windows 8.3 short names resolved on both sides, compared case-insensitively on Windows) inside the install root's node_modules, the job's docs files or the builder runtime, so a package or a symlink cannot pull in other files; local also accepts node_modules folders above the docs folder, where workspaces hoist packages. The metafile is checked again before anything is written. react, react-dom and their subpaths are aliased to the project's single copy. A separate Node build is used for server rendering.
  • Renders each island in its own child process with renderToString, using identifierPrefix: mi-<id>- and a 3 s timeout per island, then sanitizes the HTML (256 KB cap). A fresh process per island means module-level counters (lodash uniqueId, recharts clip paths, react-select instance ids) start over for every island, so two islands on a page could render the same ids. When the HTML has an id, the process renders the island a second time; every id that differs between the two renders (all ids when the second render throws or runs out of time) becomes mi-<island id>-<old id>, together with its references in url(#...) values, href/xlink:href="#..." and id-list attributes such as for and aria-labelledby. Ids from useId already carry the prefix, and ids that are the same in both renders, such as a gradient id written in the source, keep their names, so elements React mounts after hydration still find them. React keeps server attributes when it hydrates, so renamed ids stay in the page; the browser's own counter ids never collide with them. Code that runs after hydration only knows the browser's counter values, so an element mounted later that points at a renamed id (a recharts ReferenceArea with ifOverflow="hidden" shown after load) or a getElementById call for one finds nothing; without renaming, that only worked in the first island to hydrate. Islands render one at a time and a process only ever loads one island, so its code can only affect its own result. The parent accepts start and then one well-formed done (followed by the HTML and the second render's HTML or null) or error for that island's id, then kills the process; any other message fails that island, except that a malformed or oversized message after the HTML only drops the second render. On Node 20+ the render process runs under --permission with read access only to the server bundle and the render script, so an island cannot read other files, write to disk (including rewriting another island's bundle), or spawn a process or worker while it renders. On Windows the builder first renders a probe island under the same flags and drops them if it fails, so a Node permission model that rejects Windows paths degrades to an unsandboxed render instead of failing every island. Results come back as JSON lines on a plain pipe that the parent parses itself, capped at 1M characters per message and 16M characters of HTML, with log entries clipped to 20 of 2,000 characters, and each island's HTML is sanitized before the next island renders, so the builder holds one raw result at a time; Node IPC is not used because its own message handling throws in the parent on crafted input. Anything an island writes to the pipe fails only that island rather than crashing the build. Network egress is not restricted here; the build container blocks it at the container layer, and mint dev / mint packages check run the project's own code on the developer's machine. node:sqlite, which the permission model does not cover, is turned off with --no-experimental-sqlite. On Linux the parent stops a render whose resident memory passes renderMemoryLimitBytes (2 GiB by default). npm-ci and prepared builds never render without the permission model; local builds warn and render. An island that throws, times out, runs out of memory or cannot be sandboxed is reported as a warning and renders in the browser only.
  • Writes manifest.json with sha384 integrity and gzip sizes.

Shared helpers

Use the focused entry points to share build fingerprints, HTML sanitization and types with hosted services:

import {
  computeBuildId,
  computeDepsHash,
  computeIslandsHash,
} from '@mintlify/package-builder/hashing';
import {
  MAX_CLIENT_FILE_BYTES,
  MAX_CLIENT_FILES,
  MAX_CLIENT_TOTAL_BYTES,
} from '@mintlify/package-builder/limits';
import { MAX_ISLAND_HTML_BYTES, sanitizeIslandHtml } from '@mintlify/package-builder/sanitize';
import type {
  IslandSpec,
  PackageBuildManifest,
  PackagesConfig,
} from '@mintlify/package-builder/types';

The limits entry point has no runtime imports. The sanitizer entry point uses entities/decode and standard JavaScript APIs. The hashing entry point uses node:crypto and node:zlib, so Cloudflare Workers consuming it need nodejs_compat. These imports avoid loading the build, installation, bundling and rendering modules exported by the package root. Type-only imports do not load runtime code.

Pin the helper dependency and the build container to the same published package version. A hosted service must still validate and sanitize untrusted container output before publishing it, even when both use the same implementation.

The root import, CLI and existing @mintlify/package-builder/dist/* imports remain available. The server and Worker can migrate their copied helpers to these entry points after the package is published.

Build hashes

computeDepsHash and computeIslandsHash in src/hashing.ts are the one definition of the build keys. The server and the package-builder worker carry ports, and all three must pass __test__/fixtures/hash-vectors.json (copied byte for byte into both server tests).

H(x)        = lowercase hex sha256 of x (strings as UTF-8)
canonical() = JSON, no whitespace, object keys sorted by UTF-16 code unit, undefined keys dropped
depsHash    = H(canonical({ algorithm: 'mint-pkg-deps/1', builderVersion, lockfileKind,
                            lockfile: H(lockfileBytes), dependencies, registries ?? {} }))
islandsHash = H(canonical({ algorithm: 'mint-pkg-islands/1',
                            islands: [{ id, kind, module: H(module) }] sorted by id,
                            snippets: { [path]: H(source) }, provider ?? null, css ?? [],
                            ssr ?? true, isolation ?? 'page' }))
buildId     = H(depsHash + islandsHash).slice(0, 32)

snippets holds the job's snippetFiles plus every docs file that they, packages.provider or an island reach through relative or / imports, read from docsRoot (collectLocalFiles). The bundle reads docs files only from that set, so editing an imported helper, JSON or CSS file always changes the hash. A job that already sends every imported file, as the server does, gets the same hash. A snippetFiles string is hashed and bundled as its UTF-8 bytes, so it must be the file's exact text, byte order mark and CRLF line endings included. A file whose bytes are not valid UTF-8 (images, fonts, Latin-1 or UTF-16 text) stays out of snippetFiles and is read from docsRoot as bytes. The server splits files that way: text whose bytes survive a UTF-8 round trip goes in snippetFiles, everything else in assetFiles, which the worker writes into the docs folder.

A build job can pass expected: { depsHash, islandsHash }. The manifest then carries those values, and the build fails with deps-hash-mismatch or islands-hash-mismatch if the builder computes different ones, so a job keyed under one hash never publishes output built from different inputs.

Lockfile vectors

The build worker checks lockfiles with its own parser. __test__/fixtures/lockfile-vectors.json lists lockfiles and packages.registries values with the verdict both must reach: ok: true means validateRegistries, validateRegistryHosts and validateLockfile report no error here and the worker's parseLockfile succeeds. Error codes differ, so only the verdict is shared. Copy the file byte for byte into the worker's tests, as with hash-vectors.json.

CLI

mint-package-builder build --input input.json --out dist [--installer local|npm-ci|prepared] [--json]

input.json is a BuildInput without outDir. The exit code is 1 when the build has errors.

Output

<out>/manifest.json
<out>/client/islands/<id>.js   exports hydrate(el, { identifierPrefix, props?, theme? })
<out>/client/*.js              shared chunks, one React
<out>/client/styles.css        packages.css plus CSS imported by the provider
<out>/client/styles/<id>.css   CSS imported by one island
<out>/client/assets/<hash>.*   images and fonts, named by content
<out>/server/                  server build (not served)

The build fails with client-too-many-files above 1,000 client files, client-file-too-large for a client file over 8 MB, client-output-too-large above 40 MB of client files and client-bundle-too-large above 5 MB gzipped. The first three are the hosted build worker's output limits, so mint dev reports a package that would fail to deploy. A package entry that lazy-loads a whole set of modules with import(), such as a dynamic icon map, emits one file per module and can go over the file limit.

manifest.css lists the global styles.css, with packages.css entries in docs.json order followed by CSS imported by packages.provider. Each manifest.islands[id].css lists that island's stylesheet. Island styles are walked depth first in the order esbuild uses for CSS imported from JS (static imports in source order, then import() and require()). Each island keeps its own import order, and omits styles already loaded by the global stylesheet. A package import() or require() whose path is built at runtime, such as import(`./themes/${name}.css`), adds every file it matches at its place, sorted by path as esbuild sorts them, and JS files it matches are walked the same way. A ?query or #hash suffix on a stylesheet import is dropped, and an import such as ./button.css that resolves to a JS file (button.css.js) stays JS. Repeated JS imports keep their first place; repeated CSS @imports keep their last place, as in esbuild and the browser. A stylesheet that JS loads in a way the walk cannot place, such as a runtime path that matches a symlink to a file elsewhere in the package, fails the build with css-not-collected. CSS modules (.module.css) fail the build too, whether JS imports them (including code that only the server build loads), a stylesheet @imports them or packages.css lists them, since the manifest loads plain CSS and cannot map the generated class names. A stylesheet that loads a remote URL (an @import, url() or image-set() string starting with // or a scheme other than data:) fails the build with css-remote-url, so a package cannot keep a stylesheet, font or image on a host it controls. data: URLs, #fragment references and @namespace URIs are kept. Each emitted stylesheet is scanned with the browser's CSS tokenizer rules, so a malformed string or url() cannot hide a later URL from the scan.

manifest.clientAccess has { version: 1, files: { [clientPath]: islandIds }, globalFiles }. Every file in manifest.files has an entry. An island owns its entry, every nonexternal static or dynamic output import, and its stylesheet and CSS assets. Files shared by several islands have all those owners, including identical assets emitted from separate builds. Each island entry has only its own owner. Only the output closure of the explicit global stylesheet is in globalFiles; an empty owner list does not imply public access. Hosted serving must keep this immutable map outside the served client directory and authorize each requested artifact using its owners or explicit global status.

JavaScript imports of images and fonts resolve their hashed file-loader paths against import.meta.url, keeping the page namespace in the resulting root-relative URL. The server build emits the internal /_mintlify/pkg-asset/assets/<hash>.<ext> marker instead. The page compiler replaces that prefix with the page-scoped package base plus /client/ before injecting sanitized island HTML. CSS asset URLs remain relative to their stylesheet.

Package snippets can read the docs theme with import { useMintTheme } from '@mintlify/islands'.

React rules

CheckResult
react and react-dom differ on 19.x, or majors differerror
react and react-dom differ on 18.xwarning
more than one react or react-dom in a bundleerror
an island throws, logs an error or times out during SSRwarning
the alias replaced a package's own Reactwarning
a peer range excludes the installed Reactwarning
findDOMNode, ReactDOM.render or .defaultProps = on React 19warning

Keywords

mintlify

FAQs

Package last updated on 15 Oct 2026

Related posts