mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
## What
Adds an opt-in `frozenStore` / `--frozen-store` setting (default `false`) that lets `pnpm install --offline --frozen-lockfile` run against a package store that lives on a **read-only filesystem** — a Nix store, a read-only bind mount, an OCI layer.
## Why
A normal install fails against such a store **not** because it writes package content, but because it unconditionally:
1. opens the SQLite `index.db` in WAL mode, which needs to create `-shm`/`-wal` sidecars in the store directory; and
2. writes a project-registry entry under the store.
Both fail with `attempt to write a readonly database` / `EROFS` on a read-only store directory, even when the store is complete and the lockfile is frozen. This blocks any deployment that wants an immutable, content-addressed store.
## How
When `frozenStore` is enabled, pnpm opens `index.db` through the SQLite **`immutable=1`** URI — which tells SQLite the file cannot change underneath it, so it bypasses the WAL/`-shm` sidecar machinery entirely and reads the raw file with zero sidecar creation — and suppresses every store-write path. Pair it with `--offline --frozen-lockfile` against a fully-populated store. It is incompatible with two settings that would write into the store, and each throws a clear config-conflict error before any network or store access: **`--force`** (which bypasses the no-write-on-hit skip → `ERR_PNPM_CONFIG_CONFLICT_FROZEN_STORE_WITH_FORCE`) and a configured **pnpr server** (which would fetch and write packages into the store → `ERR_PNPM_FROZEN_STORE_INCOMPATIBLE_WITH_PNPR`).
> Plain `SQLITE_OPEN_READ_ONLY` is **not** sufficient: opening a WAL-mode db read-only still tries to create the `-shm` sidecar, which fails on a read-only *directory*. `immutable=1` is the load-bearing piece.
> **Node.js requirement:** `node:sqlite` only passes `SQLITE_OPEN_URI` to SQLite (so the `immutable=1` query is honored rather than treated as part of a literal filename) starting in **v22.15.0** (22.x line), **v23.11.0**, and every **v24+**. pnpm's `engines` floor is `>=22.13`, so on a runtime older than that the frozen open is detected up front and fails with a clear `ERR_PNPM_FROZEN_STORE_UNSUPPORTED_NODE` instead of SQLite's cryptic "unable to open database file". (pacquet uses rusqlite with an explicit `SQLITE_OPEN_URI` flag, so it has no such floor.)
> The `immutable=1` URI path is also percent-encoded (`%`→`%25`, `?`→`%3f`, `#`→`%23`, in that order, leaving `/` literal) so a store path containing those characters doesn't truncate the path or inject a spurious query parameter — applied identically in both stacks.
### Build backstop under the global virtual store
Under the global virtual store (default), a package's directory lives **inside** the store (`{storeDir}/links/...`). Applying a patch or running an allowlisted lifecycle script writes into that directory — so on a frozen store it would crash mid-build with a raw `EROFS`. A fully-seeded store never reaches the build step (patched/built packages are imported from the side-effects cache and filtered out by the `isBuilt` gate), so any residual build candidate means the seed is **missing that package's build output**.
`buildModules` now refuses up front with `ERR_PNPM_FROZEN_STORE_NEEDS_BUILD` and actionable guidance ("rebuild the seed with their scripts enabled, or remove them from `onlyBuiltDependencies`") instead of failing cryptically once a script starts. The check is gated on the global virtual store — under the isolated linker, slot directories live in the writable project-local store, so builds there are fine. Non-allowlisted scripts never run, so they are not treated as a blocking write.
Bin-linking has its own read-only-store edge under the global virtual store. On a **warm** checkout (the project's `.bin/<name>` already points at the seed target) `linkBin` returns before touching the store, so it is write-free. But on a **fresh** checkout it (re)creates the bin and calls `fixBin`, whose `chmod` targets the bin's **source file inside the store** (`{storeDir}/links/...`) — which is refused with `EPERM`/`EACCES` on a read-only store, even though a complete seed already ships that bin executable (so the `chmod` is redundant). `@pnpm/bins.linker` now wraps that call in `ensureExecutable`: it swallows the refusal when the target is already executable and rethrows otherwise, so bin-linking is write-free against a frozen store on a cold checkout too, while a genuinely non-executable bin (a broken seed) still surfaces as an error.
The blocking predicate distinguishes the two write kinds: a **patch** is applied regardless of `ignoreScripts`, so a patched package is always blocked; a **lifecycle script** is suppressed under `ignoreScripts`, so an allowlisted build-requiring package is *not* blocked when scripts are off (it would write nothing). This avoids falsely rejecting a valid `--ignore-scripts` frozen install. **Optional dependencies are exempt**: a build or patch failure on an optional dependency is non-fatal at runtime, so a seed missing an optional package's build output skips that build (emitting the `skipped-optional-dependency` log) instead of blocking the install — in both stacks.
### Both stacks (parity rule)
**TypeScript pnpm CLI**
- Config plumbing (`@pnpm/config.reader`): `frozen-store` type, config-file key, default, `Config.frozenStore`.
- The read-only open branch (`@pnpm/store.index`): `immutable=1`, read-only statements, throwing mutators.
- Wiring through `@pnpm/store.controller` and `@pnpm/store.connection-manager` to the sole `StoreIndex` construction site.
- Gating the project-registry write (`@pnpm/installing.context`).
- The `--force` / pnpr-server conflict guards (`@pnpm/installing.deps-installer`) and CLI surface (`@pnpm/installing.commands`).
- **After-install rebuild** (`@pnpm/building.after-install`): the post-install rebuild opens its `StoreIndex` immutably under the flag, so re-reading the store for a rebuild never attempts a writable open against the frozen store.
- **Worker fix:** `@pnpm/worker` opens its *own* writable `StoreIndex` on every `readPkgFromCafs` cache hit, so a pure read crashed on a frozen store. `frozenStore` is threaded through to `getStoreIndex` and keyed into its connection cache.
- **Build backstop** (`@pnpm/building.during-install`): `buildModules` throws `ERR_PNPM_FROZEN_STORE_NEEDS_BUILD` for a GVS slot that would build/patch on a frozen store, honoring `ignoreScripts`; threaded from `@pnpm/installing.deps-installer`.
- **Bin-linking on a read-only store** (`@pnpm/bins.linker`): `linkBin` wraps the `fixBin` chmod in `ensureExecutable`, which tolerates `EPERM`/`EACCES` when the bin's store-resident source is already executable (a complete seed) and rethrows otherwise — so a fresh checkout against a frozen store links bins without crashing on the redundant chmod. Catch-on-failure keeps the writable hot path at zero added syscalls.
**Rust pacquet**
- A dedicated `open_immutable` / `shared_immutable_in` opens via `immutable=1`, selected only under the flag. Plain `open_readonly` keeps the ordinary `SQLITE_OPEN_READ_ONLY` open (WAL locking intact) because normal installs read the index while the same process's `StoreIndexWriter` writes it concurrently — an immutable connection skips all locking and change detection, so a concurrent writer would make those reads undefined.
- `--frozen-store` CLI flag + `frozenStore` workspace-yaml setting.
- The store-index writer is replaced with a drain-and-drop stub (`spawn_disabled`) and `init_store_dir_best_effort` is skipped under the flag.
- **Build backstop:** `build_modules` returns `BuildModulesError::FrozenStoreNeedsBuild` (`ERR_PNPM_FROZEN_STORE_NEEDS_BUILD`) under the same GVS + frozen-store condition, threaded from `config.frozen_store`. The gate keys off `should_run_scripts` (which already folds the allow-build policy), so it is correct without an explicit ignore-scripts branch — pacquet has no configurable ignore-scripts mode yet.
pacquet already separated read-only index access (`shared_readonly_in`, or `shared_immutable_in` under the flag) from writes, so it never had the worker-conflation bug; the flag makes the "no writes attempted" contract explicit and gates the remaining best-effort write attempts.
## Testing
- **TS:** `store.index` frozen-mode-on-`0555`-directory test (reads work, writes throw `ERR_PNPM_FROZEN_STORE_WRITE`) plus a path-with-`?` open test — both gated on the runtime's immutable-URI support, with a complementary test asserting `ERR_PNPM_FROZEN_STORE_UNSUPPORTED_NODE` fires where that support is absent (the CI Node 22.13.0 path); `config.reader` round-trip; `deps-installer` `--force` and pnpr-server conflict guards; `worker`/`package-requester` unit tests. End-to-end on a `chmod -R 0555` store: install succeeds, `node_modules` materializes, no `-shm`/`-wal`/`-journal` sidecars; negative control without the flag fails as expected; incomplete store → clean offline error.
- **pacquet:** `open_immutable_reads_wal_db_on_readonly_directory` unit test plus the `immutable_sqlite_uri` encoding test and a path-with-`?` open test; yaml + CLI fold tests; **integration test** `frozen_store_installs_against_a_read_only_store` — primes a store, `chmod 0555` the tree, runs `install --frozen-lockfile --frozen-store --offline`, asserts success + materialized `node_modules` + zero sidecars. Confirmed load-bearing by reverting the `immutable=1` fix (test then fails).
- **Build backstop (both stacks):** `building/during-install` unit tests — approved-build-not-cached and patched-not-cached refuse; cached, non-allowlisted, and non-GVS cases pass through; **approved-build-under-`ignoreScripts` passes through while patched-under-`ignoreScripts` still refuses** — and the matching pacquet `build_modules` tests (`frozen_store_gvs_patch_not_seeded_refuses` + GVS-off / frozen-off controls). Each confirmed load-bearing by disabling the relevant guard and watching the corresponding test fail.
- **Bin-linking (`bins/linker`):** `ensureExecutable` tests with `fixBin` mocked to reject with `EPERM` — an already-executable bin source resolves (and `fixBin` is asserted called, so it isn't the warm skip-guard passing), a non-executable one rethrows `EPERM`. Confirmed end-to-end by running the built `linkBins` against a real `chflags uchg`-immutable store: the executable-seed case resolves and links the bin, the non-executable-seed control throws `EPERM`.
A changeset is included with `"pnpm": minor` and `"@pnpm/bins.linker": patch` (the read-only-store bin-linking fix).
501 lines
16 KiB
TypeScript
501 lines
16 KiB
TypeScript
import crypto from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import util from 'node:util'
|
|
import { parentPort } from 'node:worker_threads'
|
|
|
|
import { pkgRequiresBuild } from '@pnpm/building.pkg-requires-build'
|
|
import { formatIntegrity, parseIntegrity } from '@pnpm/crypto.integrity'
|
|
import { PnpmError } from '@pnpm/error'
|
|
import { hardLinkDir } from '@pnpm/fs.hard-link-dir'
|
|
import { symlinkDependencySync } from '@pnpm/fs.symlink-dependency'
|
|
import {
|
|
buildFileMapsFromIndex,
|
|
type CafsFunctions,
|
|
checkPkgFilesIntegrity,
|
|
createCafs,
|
|
type FilesIndex,
|
|
HASH_ALGORITHM,
|
|
normalizeBundledManifest,
|
|
type PackageFilesIndex,
|
|
type VerifyResult,
|
|
} from '@pnpm/store.cafs'
|
|
import type { Cafs, FilesMap, PackageFiles, SideEffectsDiff } from '@pnpm/store.cafs-types'
|
|
import { createCafsStore } from '@pnpm/store.create-cafs-store'
|
|
import { packForStorage, ReadOnlyStoreIndex, StoreIndex } from '@pnpm/store.index'
|
|
import type { BundledManifest, DependencyManifest } from '@pnpm/types'
|
|
|
|
import { equalOrSemverEqual } from './equalOrSemverEqual.js'
|
|
import type {
|
|
AddDirToStoreMessage,
|
|
HardLinkDirMessage,
|
|
InitStoreMessage,
|
|
LinkPkgMessage,
|
|
ReadPkgFromCafsMessage,
|
|
SymlinkAllModulesMessage,
|
|
TarballExtractMessage,
|
|
} from './types.js'
|
|
|
|
export function startWorker (): void {
|
|
process.on('uncaughtException', (err) => {
|
|
console.error(err)
|
|
})
|
|
parentPort!.on('message', handleMessage)
|
|
}
|
|
|
|
const cafsCache = new Map<string, CafsFunctions>()
|
|
const cafsStoreCache = new Map<string, Cafs>()
|
|
const cafsLocker = new Map<string, number>()
|
|
const storeIndexCache = new Map<string, StoreIndex>()
|
|
|
|
function getStoreIndex (storeDir: string, frozen = false): StoreIndex {
|
|
// A frozen store is opened immutable (read-only), so it cannot share a
|
|
// cached handle with a writable open of the same directory. Key on both.
|
|
const cacheKey = frozen ? `${storeDir}\0frozen` : storeDir
|
|
if (!storeIndexCache.has(cacheKey)) {
|
|
storeIndexCache.set(cacheKey, frozen ? new ReadOnlyStoreIndex(storeDir) : new StoreIndex(storeDir))
|
|
}
|
|
return storeIndexCache.get(cacheKey)!
|
|
}
|
|
|
|
async function handleMessage (
|
|
message:
|
|
| TarballExtractMessage
|
|
| LinkPkgMessage
|
|
| AddDirToStoreMessage
|
|
| ReadPkgFromCafsMessage
|
|
| SymlinkAllModulesMessage
|
|
| HardLinkDirMessage
|
|
| InitStoreMessage
|
|
| false
|
|
): Promise<void> {
|
|
if (message === false) {
|
|
parentPort!.off('message', handleMessage)
|
|
// Explicitly close cached SQLite connections before exiting.
|
|
// process.exit() in a worker thread may not run C++ destructors,
|
|
// which would leave file descriptors and mmap regions open.
|
|
for (const idx of storeIndexCache.values()) {
|
|
idx.close()
|
|
}
|
|
storeIndexCache.clear()
|
|
process.exit(0)
|
|
}
|
|
try {
|
|
switch (message.type) {
|
|
case 'extract': {
|
|
parentPort!.postMessage(addTarballToStore(message))
|
|
break
|
|
}
|
|
case 'link': {
|
|
parentPort!.postMessage(importPackage(message))
|
|
break
|
|
}
|
|
case 'add-dir': {
|
|
parentPort!.postMessage(addFilesFromDir(message))
|
|
break
|
|
}
|
|
case 'init-store': {
|
|
parentPort!.postMessage(initStore(message))
|
|
break
|
|
}
|
|
case 'readPkgFromCafs': {
|
|
const { storeDir, filesIndexFile, verifyStoreIntegrity, expectedPkg, strictStorePkgContentCheck, frozenStore } = message
|
|
const pkgFilesIndex = getStoreIndex(storeDir, frozenStore).get(filesIndexFile) as PackageFilesIndex | undefined
|
|
if (!pkgFilesIndex) {
|
|
parentPort!.postMessage({
|
|
status: 'success',
|
|
value: {
|
|
verified: false,
|
|
pkgFilesIndex: null,
|
|
},
|
|
})
|
|
return
|
|
}
|
|
const warnings: string[] = []
|
|
if (expectedPkg) {
|
|
if (
|
|
(
|
|
pkgFilesIndex.manifest?.name != null &&
|
|
expectedPkg.name != null &&
|
|
pkgFilesIndex.manifest.name.toLowerCase() !== expectedPkg.name.toLowerCase()
|
|
) ||
|
|
(
|
|
pkgFilesIndex.manifest?.version != null &&
|
|
expectedPkg.version != null &&
|
|
!equalOrSemverEqual(pkgFilesIndex.manifest.version, expectedPkg.version)
|
|
)
|
|
) {
|
|
const msg = 'Package name or version mismatch found while reading from the store.'
|
|
const hint = `This means that either the lockfile is broken or the package metadata (name and version) inside the package's package.json file doesn't match the metadata in the registry. Expected package: ${expectedPkg.name}@${expectedPkg.version}. Actual package in the store: ${pkgFilesIndex.manifest?.name}@${pkgFilesIndex.manifest?.version}.`
|
|
if (strictStorePkgContentCheck ?? true) {
|
|
throw new PnpmError('UNEXPECTED_PKG_CONTENT_IN_STORE', msg, {
|
|
hint: `${hint}\n\nIf you want to ignore this issue, set strictStorePkgContentCheck to false in your configuration`,
|
|
})
|
|
} else {
|
|
warnings.push(`${msg} ${hint}`)
|
|
}
|
|
}
|
|
}
|
|
let verifyResult: VerifyResult
|
|
if (verifyStoreIntegrity) {
|
|
verifyResult = checkPkgFilesIntegrity(storeDir, pkgFilesIndex)
|
|
} else {
|
|
verifyResult = buildFileMapsFromIndex(storeDir, pkgFilesIndex)
|
|
}
|
|
const bundledManifest = pkgFilesIndex.manifest
|
|
const requiresBuild = pkgFilesIndex.requiresBuild ?? pkgRequiresBuild(bundledManifest, verifyResult.filesMap)
|
|
|
|
parentPort!.postMessage({
|
|
status: 'success',
|
|
warnings,
|
|
value: {
|
|
verified: verifyResult.passed,
|
|
bundledManifest,
|
|
files: {
|
|
filesMap: verifyResult.filesMap,
|
|
sideEffectsMaps: verifyResult.sideEffectsMaps,
|
|
resolvedFrom: 'store',
|
|
requiresBuild,
|
|
},
|
|
},
|
|
})
|
|
break
|
|
}
|
|
case 'symlinkAllModules': {
|
|
parentPort!.postMessage(symlinkAllModules(message))
|
|
break
|
|
}
|
|
case 'hardLinkDir': {
|
|
hardLinkDir(message.src, message.destDirs)
|
|
parentPort!.postMessage({ status: 'success' })
|
|
break
|
|
}
|
|
}
|
|
} catch (e: any) { // eslint-disable-line
|
|
parentPort!.postMessage({
|
|
status: 'error',
|
|
error: {
|
|
code: e.code,
|
|
message: e.message ?? e.toString(),
|
|
hint: e.hint,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
function addTarballToStore ({ buffer, storeDir, integrity, filesIndexFile, appendManifest, ignoreFilePattern }: TarballExtractMessage) {
|
|
if (integrity) {
|
|
const { algorithm, hexDigest } = parseIntegrity(integrity)
|
|
const calculatedHash: string = crypto.hash(algorithm, buffer, 'hex')
|
|
if (calculatedHash !== hexDigest) {
|
|
return {
|
|
status: 'error',
|
|
error: {
|
|
type: 'integrity_validation_failed',
|
|
algorithm,
|
|
expected: integrity,
|
|
found: formatIntegrity(algorithm, calculatedHash),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
if (!cafsCache.has(storeDir)) {
|
|
cafsCache.set(storeDir, createCafs(storeDir))
|
|
}
|
|
const cafs = cafsCache.get(storeDir)!
|
|
const ignore = ignoreFilePattern ? makeIgnoreFromPattern(ignoreFilePattern) : undefined
|
|
let { filesIndex, manifest } = cafs.addFilesFromTarball(buffer, true, ignore)
|
|
if (appendManifest && manifest == null) {
|
|
manifest = appendManifest
|
|
addManifestToCafs(cafs, filesIndex, appendManifest)
|
|
} else if (!filesIndex.has('package.json')) {
|
|
addPlaceholderPackageJsonToCafs(cafs, filesIndex)
|
|
}
|
|
const { filesIntegrity, filesMap } = processFilesIndex(filesIndex)
|
|
const bundledManifest = manifest != null ? normalizeBundledManifest(manifest) : undefined
|
|
const requiresBuild = pkgRequiresBuild(bundledManifest, filesIntegrity)
|
|
const pkgFilesIndex: PackageFilesIndex = {
|
|
requiresBuild,
|
|
manifest: bundledManifest,
|
|
algo: HASH_ALGORITHM,
|
|
files: filesIntegrity,
|
|
}
|
|
return {
|
|
status: 'success',
|
|
value: {
|
|
filesMap,
|
|
manifest: bundledManifest,
|
|
requiresBuild,
|
|
integrity: integrity ?? calcIntegrity(buffer),
|
|
},
|
|
indexWrites: [{ key: filesIndexFile, buffer: packToShared(pkgFilesIndex) }],
|
|
}
|
|
}
|
|
|
|
function calcIntegrity (buffer: Buffer): string {
|
|
const calculatedHash: string = crypto.hash('sha512', buffer, 'hex')
|
|
return formatIntegrity('sha512', calculatedHash)
|
|
}
|
|
|
|
function makeIgnoreFromPattern (pattern: string): (filename: string) => boolean {
|
|
// `ignoreFilePattern` is a public field on FetchOptions, so callers that don't go
|
|
// through the binary-fetcher's validated `archiveFilters` path could still supply a
|
|
// bad regex. Convert the SyntaxError into a PnpmError with a stable code so it's
|
|
// actionable for users.
|
|
let regex: RegExp
|
|
try {
|
|
regex = new RegExp(pattern)
|
|
} catch (err: unknown) {
|
|
const detail = util.types.isNativeError(err) ? `: ${err.message}` : ''
|
|
throw new PnpmError(
|
|
'INVALID_IGNORE_FILE_PATTERN',
|
|
`Invalid ignoreFilePattern regex${detail}: ${pattern}`
|
|
)
|
|
}
|
|
return (filename) => regex.test(filename)
|
|
}
|
|
|
|
function packToShared (data: unknown): Uint8Array {
|
|
const packed = packForStorage(data)
|
|
const shared = new SharedArrayBuffer(packed.byteLength)
|
|
const view = new Uint8Array(shared)
|
|
view.set(packed)
|
|
return view
|
|
}
|
|
|
|
interface IndexWrite {
|
|
key: string
|
|
buffer: Uint8Array
|
|
}
|
|
|
|
interface AddFilesFromDirResult {
|
|
status: string
|
|
value: {
|
|
filesMap: FilesMap
|
|
manifest?: BundledManifest
|
|
requiresBuild: boolean
|
|
}
|
|
indexWrites?: IndexWrite[]
|
|
}
|
|
|
|
function initStore ({ storeDir }: InitStoreMessage): { status: string } {
|
|
fs.mkdirSync(storeDir, { recursive: true })
|
|
const hexChars = '0123456789abcdef'.split('')
|
|
// Only create subdirectories for files/ — index/ is now managed by SQLite
|
|
const filesDirPath = path.join(storeDir, 'files')
|
|
try {
|
|
fs.mkdirSync(filesDirPath)
|
|
} catch {
|
|
// If a parallel process has already started creating the directories in the store,
|
|
// ignore if it already exists.
|
|
}
|
|
for (const hex1 of hexChars) {
|
|
for (const hex2 of hexChars) {
|
|
try {
|
|
fs.mkdirSync(path.join(filesDirPath, `${hex1}${hex2}`))
|
|
} catch {
|
|
// If a parallel process has already started creating the directories in the store,
|
|
// ignore if it already exists.
|
|
}
|
|
}
|
|
}
|
|
// The SQLite index database will be initialized lazily by getStoreIndex()
|
|
// on the first operation that needs it (e.g., readPkgFromCafs, addFilesFromDir).
|
|
// Eagerly opening it here races with the main thread's StoreIndex constructor,
|
|
// which can cause SQLITE_CANTOPEN on Windows due to mandatory file locking.
|
|
return { status: 'success' }
|
|
}
|
|
|
|
function addFilesFromDir (
|
|
{
|
|
appendManifest,
|
|
dir,
|
|
files,
|
|
filesIndexFile,
|
|
includeNodeModules,
|
|
sideEffectsCacheKey,
|
|
storeDir,
|
|
}: AddDirToStoreMessage
|
|
): AddFilesFromDirResult {
|
|
if (!cafsCache.has(storeDir)) {
|
|
cafsCache.set(storeDir, createCafs(storeDir))
|
|
}
|
|
const cafs = cafsCache.get(storeDir)!
|
|
let { filesIndex, manifest } = cafs.addFilesFromDir(dir, {
|
|
files,
|
|
includeNodeModules,
|
|
readManifest: true,
|
|
})
|
|
if (appendManifest && manifest == null) {
|
|
manifest = appendManifest
|
|
addManifestToCafs(cafs, filesIndex, appendManifest)
|
|
} else if (!filesIndex.has('package.json')) {
|
|
addPlaceholderPackageJsonToCafs(cafs, filesIndex)
|
|
}
|
|
const { filesIntegrity, filesMap } = processFilesIndex(filesIndex)
|
|
const bundledManifest = manifest != null ? normalizeBundledManifest(manifest) : undefined
|
|
let requiresBuild: boolean
|
|
let indexWrites: IndexWrite[] | undefined
|
|
if (sideEffectsCacheKey) {
|
|
const existingFilesIndex = getStoreIndex(storeDir).get(filesIndexFile) as PackageFilesIndex | undefined
|
|
if (!existingFilesIndex) {
|
|
// If there is no existing index file, then we cannot store the side effects.
|
|
return {
|
|
status: 'success',
|
|
value: {
|
|
filesMap,
|
|
manifest: bundledManifest,
|
|
requiresBuild: pkgRequiresBuild(manifest, filesMap),
|
|
},
|
|
}
|
|
}
|
|
if (!existingFilesIndex.sideEffects) {
|
|
existingFilesIndex.sideEffects = new Map()
|
|
}
|
|
// Ensure side effects use the same algorithm as the original package
|
|
if (existingFilesIndex.algo !== HASH_ALGORITHM) {
|
|
throw new PnpmError(
|
|
'ALGO_MISMATCH',
|
|
`Algorithm mismatch: package index uses "${existingFilesIndex.algo}" but side effects were computed with "${HASH_ALGORITHM}"`
|
|
)
|
|
}
|
|
existingFilesIndex.sideEffects.set(sideEffectsCacheKey, calculateDiff(existingFilesIndex.files, filesIntegrity))
|
|
if (existingFilesIndex.requiresBuild == null) {
|
|
requiresBuild = pkgRequiresBuild(manifest, filesMap)
|
|
} else {
|
|
requiresBuild = existingFilesIndex.requiresBuild
|
|
}
|
|
indexWrites = [{ key: filesIndexFile, buffer: packToShared(existingFilesIndex) }]
|
|
} else {
|
|
requiresBuild = pkgRequiresBuild(bundledManifest, filesIntegrity)
|
|
const pkgFilesIndex: PackageFilesIndex = {
|
|
requiresBuild,
|
|
manifest: bundledManifest,
|
|
algo: HASH_ALGORITHM,
|
|
files: filesIntegrity,
|
|
}
|
|
indexWrites = [{ key: filesIndexFile, buffer: packToShared(pkgFilesIndex) }]
|
|
}
|
|
return { status: 'success', value: { filesMap, manifest: bundledManifest, requiresBuild }, indexWrites }
|
|
}
|
|
|
|
function addManifestToCafs (cafs: CafsFunctions, filesIndex: FilesIndex, manifest: DependencyManifest): void {
|
|
const fileBuffer = Buffer.from(JSON.stringify(manifest, null, 2), 'utf8')
|
|
const mode = 0o644
|
|
filesIndex.set('package.json', {
|
|
mode,
|
|
size: fileBuffer.length,
|
|
...cafs.addFile(fileBuffer, mode),
|
|
})
|
|
}
|
|
|
|
const PLACEHOLDER_PACKAGE_JSON = Buffer.from(JSON.stringify({ _pnpmPlaceholder: 'This file was generated by pnpm. The original package did not contain a package.json.' }), 'utf8')
|
|
|
|
// Packages that lack a package.json (e.g. injected packages in a Bit
|
|
// workspace) get a synthetic one so that package.json can serve as a
|
|
// universal completion marker for the indexed package importer.
|
|
// The _pnpmPlaceholder field tells the package requester to ignore it
|
|
// when reading the manifest.
|
|
function addPlaceholderPackageJsonToCafs (cafs: CafsFunctions, filesIndex: FilesIndex): void {
|
|
const mode = 0o644
|
|
filesIndex.set('package.json', {
|
|
mode,
|
|
size: PLACEHOLDER_PACKAGE_JSON.length,
|
|
...cafs.addFile(PLACEHOLDER_PACKAGE_JSON, mode),
|
|
})
|
|
}
|
|
|
|
function calculateDiff (baseFiles: PackageFiles, sideEffectsFiles: PackageFiles): SideEffectsDiff {
|
|
const deleted: string[] = []
|
|
const added: PackageFiles = new Map()
|
|
const allFiles = new Set([...baseFiles.keys(), ...sideEffectsFiles.keys()])
|
|
for (const file of allFiles) {
|
|
if (!sideEffectsFiles.has(file)) {
|
|
deleted.push(file)
|
|
} else if (
|
|
!baseFiles.has(file) ||
|
|
baseFiles.get(file)!.digest !== sideEffectsFiles.get(file)!.digest ||
|
|
baseFiles.get(file)!.mode !== sideEffectsFiles.get(file)!.mode
|
|
) {
|
|
added.set(file, sideEffectsFiles.get(file)!)
|
|
}
|
|
}
|
|
const diff: SideEffectsDiff = {}
|
|
if (deleted.length > 0) {
|
|
diff.deleted = deleted
|
|
}
|
|
if (added.size > 0) {
|
|
diff.added = added
|
|
}
|
|
return diff
|
|
}
|
|
|
|
interface ProcessFilesIndexResult {
|
|
filesIntegrity: PackageFiles
|
|
filesMap: FilesMap
|
|
}
|
|
|
|
function processFilesIndex (filesIndex: FilesIndex): ProcessFilesIndexResult {
|
|
const filesIntegrity: PackageFiles = new Map()
|
|
const filesMap: FilesMap = new Map()
|
|
for (const [k, { checkedAt, filePath, digest, mode, size }] of filesIndex) {
|
|
filesIntegrity.set(k, {
|
|
checkedAt,
|
|
digest,
|
|
mode,
|
|
size,
|
|
})
|
|
filesMap.set(k, filePath)
|
|
}
|
|
return { filesIntegrity, filesMap }
|
|
}
|
|
|
|
interface ImportPackageResult {
|
|
status: string
|
|
value: {
|
|
isBuilt: boolean
|
|
importMethod?: string
|
|
}
|
|
}
|
|
|
|
function importPackage ({
|
|
storeDir,
|
|
packageImportMethod,
|
|
filesResponse,
|
|
sideEffectsCacheKey,
|
|
targetDir,
|
|
requiresBuild,
|
|
force,
|
|
keepModulesDir,
|
|
disableRelinkLocalDirDeps,
|
|
safeToSkip,
|
|
}: LinkPkgMessage): ImportPackageResult {
|
|
const cacheKey = JSON.stringify({ storeDir, packageImportMethod })
|
|
if (!cafsStoreCache.has(cacheKey)) {
|
|
cafsStoreCache.set(cacheKey, createCafsStore(storeDir, { packageImportMethod, cafsLocker }))
|
|
}
|
|
const cafsStore = cafsStoreCache.get(cacheKey)!
|
|
const { importMethod, isBuilt } = cafsStore.importPackage(targetDir, {
|
|
filesResponse,
|
|
force,
|
|
disableRelinkLocalDirDeps,
|
|
requiresBuild,
|
|
sideEffectsCacheKey,
|
|
keepModulesDir,
|
|
safeToSkip,
|
|
})
|
|
return { status: 'success', value: { isBuilt, importMethod } }
|
|
}
|
|
|
|
function symlinkAllModules (opts: SymlinkAllModulesMessage): { status: 'success' } {
|
|
for (const dep of opts.deps) {
|
|
for (const [alias, pkgDir] of Object.entries(dep.children)) {
|
|
if (alias !== dep.name) {
|
|
symlinkDependencySync(pkgDir, dep.modules, alias)
|
|
}
|
|
}
|
|
}
|
|
return { status: 'success' }
|
|
}
|
|
|