Files
iptvnator/tools/packaging/asar-dependency-closure.mjs
T
4grayandClaude Opus 4.8 23ead63b1f fix(packaging): ship ms so the updater doesn't crash the app (#1103) (#1113)
* fix(packaging): ship `ms` so the updater doesn't crash the app (#1103)

The 0.22 AppImage crashed on launch with "Cannot find module 'ms'" after
the desktop updater landed (b1119189). electron-updater requires
`debug` -> `ms` unguarded at startup, but the packaged app.asar shipped
`debug` without `ms`.

Root cause: with pnpm's isolated node-linker, electron-builder 26 uses its
PnpmNodeModulesCollector, which builds the bundle from `pnpm list --json`.
pnpm deduplicates repeated packages there, so all but one `debug@4.4.3`
occurrence report empty `dependencies` — and the collector (unlike the npm
collector) has no implicit-dependency recovery, so it drops `ms` entirely.
It stayed latent because the only prior `debug` consumer (follow-redirects
via axios) guards its require in try/catch; electron-updater is the first
packaged module to hit it unguarded.

Fix: declare `ms` as a direct dependency so it becomes a top-level,
fully-expanded node in the collector's tree and is bundled. This keeps the
isolated pnpm layout intact — `node-linker=hoisted` was rejected because it
removes `node_modules/.pnpm`, which apps/electron-backend/build-embedded-mpv.js
scans to resolve @electron/node-gyp, breaking the native build on every
platform.

Also add a packaged-asar dependency-closure guard to
verify-electron-package-layout.mjs: it audits every package shipped in the
archive and fails if any non-optional dependency is missing, so this class
of regression is caught in CI. Logic is extracted to a unit-tested module.

Verified locally: repackaged app.asar now ships `ms`, the embedded-mpv
native build succeeds, and the closure guard reports 0 missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(packaging): harden asar dependency-closure guard from review findings

Addresses review feedback on #1113 plus an adversarial-review finding:

- Walk every ancestor directory in resolvePackagedDependency (not just
  node_modules boundaries) so a dependency hoisted to the archive root
  resolves for packages under app subdirectories, matching Node's real
  resolution (Codex review).
- Skip dependencies also declared in peerDependencies: host-provided
  peers (e.g. electron) listed in both fields are not packaging defects
  (Greptile review).
- Fix a silent no-op on Windows: @electron/asar lists entries and
  resolves extractFile paths with the host separator, so the posix-only
  matching audited zero packages on the Windows CI leg. Listings are now
  normalized to posix and lookup paths converted back to the host
  separator (pathSep is injectable for tests).
- Reject vacuous passes structurally: inspectPackagedDependencyClosure
  now reports packageCount and manifestReadFailures, and the verifier
  errors when the audit saw no packages or failed to read manifests,
  so the guard can never silently audit nothing again.

Verified: 27 packaging tests pass; the real app.asar audits 235 packages
with 0 missing under both posix and simulated win32 IO; hiding `ms` from
a win32-shaped listing correctly flags it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:58:21 +02:00

226 lines
7.8 KiB
JavaScript

/**
* Verifies that a packaged Electron `app.asar` ships a self-consistent set of
* runtime node_modules: every package inside the archive has its own
* non-optional dependencies shipped alongside it.
*
* electron-builder's pnpm dependency collector reads `pnpm list --json`, which
* deduplicates repeated packages and reports empty `dependencies` for all but
* one occurrence. That makes it silently drop deeply-nested transitive
* dependencies (for example `ms` under `debug`) from the packaged archive, which
* crashes the app on launch ("Cannot find module 'ms'", issue #1103).
*
* These helpers audit every package that is actually shipped inside the asar and
* report any non-optional dependency of a shipped package that is missing,
* resolving each one the way Node would inside the packaged tree. IO is injected
* so the core logic stays pure and unit-testable without a real archive.
*/
import path from 'node:path';
const PACKAGE_MANIFEST = 'package.json';
const NODE_MODULES_SEGMENT = '/node_modules/';
/**
* A genuine installed package lives directly under a node_modules directory as
* `<name>` or `@scope/<name>`. Nested folders that merely carry their own
* package.json (for example `fast-uri/benchmark` or `<pkg>/test`) are not
* packages and must not be treated as dependency sources.
*/
function isPackageRootDir(dir) {
const boundary = dir.lastIndexOf(NODE_MODULES_SEGMENT);
if (boundary === -1) {
return false;
}
const relative = dir.slice(boundary + NODE_MODULES_SEGMENT.length);
const segments = relative.split('/');
return segments[0].startsWith('@')
? segments.length === 2
: segments.length === 1;
}
/**
* Collects every genuine installed package directory in the archive (those that
* sit directly under a node_modules as `<name>` or `@scope/<name>`). Entry paths
* are leading-slash posix.
*/
export function collectAsarPackageDirs(asarEntries) {
const packageDirs = new Set();
for (const entry of asarEntries) {
if (!entry.endsWith(`/${PACKAGE_MANIFEST}`)) {
continue;
}
const dir = entry.slice(0, -(PACKAGE_MANIFEST.length + 1));
if (isPackageRootDir(dir)) {
packageDirs.add(dir);
}
}
return packageDirs;
}
/**
* Mirrors Node's upward node_modules resolution across the packaged tree, which
* is what electron-builder's hoisting produces. Walks every ancestor directory
* (not just node_modules boundaries) so a dependency hoisted to the archive
* root still resolves for a package that lives under an app subdirectory such
* as `/electron-backend/node_modules/foo`. Returns the resolved package
* directory or null when the dependency is absent.
*/
export function resolvePackagedDependency(fromDir, dependencyName, packageDirs) {
let current = fromDir;
while (true) {
// Node never looks inside `.../node_modules/node_modules`.
if (!current.endsWith(NODE_MODULES_SEGMENT.slice(0, -1))) {
const candidate = `${current}/node_modules/${dependencyName}`;
if (packageDirs.has(candidate)) {
return candidate;
}
}
if (current === '') {
return null;
}
current = current.slice(0, current.lastIndexOf('/'));
}
}
/**
* Checks that every package physically shipped inside the asar has its own
* non-optional dependencies shipped too (node-resolvable from its location).
* Returns the list of missing dependencies.
*
* The app root manifest ('') is intentionally skipped: it declares frontend-only
* packages (Angular, zone.js, ...) that are compiled into the web bundle rather
* than shipped as runtime node_modules, so requiring them here would be a false
* positive. Auditing what is actually bundled still catches the real regression
* (a shipped `debug` whose transitive `ms` was dropped — issue #1103).
*
* @param {Set<string>} packageDirs directories that contain a package.json
* @param {(packageDir: string) => object | null} readManifest manifest reader
* @returns {{ dependency: string, requiredBy: string }[]}
*/
export function findMissingPackagedDependencies(packageDirs, readManifest) {
const missing = [];
for (const packageDir of packageDirs) {
if (packageDir === '') {
continue;
}
const manifest = readManifest(packageDir);
if (!manifest) {
continue;
}
const optionalDependencies = new Set(
Object.keys(manifest.optionalDependencies ?? {})
);
// Packages sometimes list a host-provided peer (e.g. `electron`) in
// `dependencies` as well so package managers install it during
// development; at runtime the host supplies it, so its absence from
// the archive is not a packaging defect.
const peerDependencies = new Set(
Object.keys(manifest.peerDependencies ?? {})
);
for (const dependencyName of Object.keys(manifest.dependencies ?? {})) {
if (
optionalDependencies.has(dependencyName) ||
peerDependencies.has(dependencyName)
) {
continue;
}
if (
!resolvePackagedDependency(
packageDir,
dependencyName,
packageDirs
)
) {
missing.push({
dependency: dependencyName,
requiredBy: packageDir,
});
}
}
}
return missing;
}
/**
* Inspects a real `app.asar` for missing runtime dependencies. `listPackage` and
* `extractFile` are injected (normally from `@electron/asar`).
*
* `@electron/asar` builds listing entries and resolves lookup paths with the
* HOST separator: on Windows `listPackage` returns entries like
* `\node_modules\debug\package.json` and `extractFile` splits its path on
* `path.sep`. The pure helpers above are posix-only, so listings are normalized
* to posix and lookup paths converted back to the host separator (`pathSep` is
* injectable for tests). Without this the guard silently audited nothing on
* Windows.
*
* Returns `{ missing, packageCount, manifestReadFailures }` so callers can
* reject a vacuous pass: a packaged app always ships node_modules, so
* `packageCount === 0` or read failures indicate the guard itself is broken,
* not a healthy archive.
*/
export function inspectPackagedDependencyClosure(
asarPath,
{ listPackage, extractFile, pathSep = path.sep }
) {
const toPosix = (value) =>
pathSep === '\\' ? value.replaceAll('\\', '/') : value;
const toHostPath = (value) =>
pathSep === '\\' ? value.replaceAll('/', '\\') : value;
const packageDirs = collectAsarPackageDirs(
listPackage(asarPath).map(toPosix)
);
const manifestCache = new Map();
const manifestReadFailures = [];
const readManifest = (packageDir) => {
if (manifestCache.has(packageDir)) {
return manifestCache.get(packageDir);
}
const relativePath = toHostPath(
(packageDir === '' ? '' : `${packageDir.slice(1)}/`) +
PACKAGE_MANIFEST
);
let manifest = null;
try {
manifest = JSON.parse(
extractFile(asarPath, relativePath).toString('utf8')
);
} catch (error) {
manifestReadFailures.push({
packageDir,
message: error.message,
});
manifest = null;
}
manifestCache.set(packageDir, manifest);
return manifest;
};
const missing = findMissingPackagedDependencies(packageDirs, readManifest);
return {
missing,
packageCount: packageDirs.size,
manifestReadFailures,
};
}