@exodus/stasis 1.0.0-beta.8 → 1.0.0-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/stasis.js +106 -177
- package/package.json +6 -5
- package/src/cmd/bundle.js +194 -47
- package/src/installed-commits.js +182 -0
- package/src/loaders/php.js +43 -16
- package/src/resolve-fields.js +62 -30
- package/src/resolve-node.js +9 -6
- package/src/scan.js +8 -3
- package/src/vfs-bundle/entries.js +9 -5
- package/src/vfs-bundle/github.js +4 -1
- package/src/vfs-bundle/tree.js +49 -15
- package/src/vfs-bundle.js +13 -8
package/src/loaders/php.js
CHANGED
|
@@ -9,6 +9,8 @@ import { readFile } from 'node:fs/promises'
|
|
|
9
9
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
10
10
|
import { isDeepStrictEqual } from 'node:util'
|
|
11
11
|
|
|
12
|
+
import { isValidRepoField } from '@exodus/stasis-core/bundle'
|
|
13
|
+
import { checkoutCommit, parseGithubRepository } from '@exodus/stasis-core/bundle-util'
|
|
12
14
|
import { assertRealPathWithinBase, isPlainObject, relativeEscapes, toPosix } from '@exodus/stasis-core/util'
|
|
13
15
|
import { LockfileError, parseComposerLock } from '@preventive/lockfile/composer.js'
|
|
14
16
|
import { isDir, isFile } from '../resolve-typescript.js'
|
|
@@ -532,20 +534,40 @@ function assertInstalledAsLocked(lockText, installedText, { installedFile, vendo
|
|
|
532
534
|
return installed
|
|
533
535
|
}
|
|
534
536
|
|
|
535
|
-
// The
|
|
537
|
+
// The GitHub repository a Composer package `p` (composer.lock's, or installed.json's) is cloned
|
|
538
|
+
// from, at the commit it is installed at: its git `source` on GitHub, at its `reference` where that
|
|
539
|
+
// is a full commit and what is installed at `at` (its directory, or null for none) is at it.
|
|
540
|
+
// Installed from its source -- as installed.json's `installation-source`, `from`, says, or as it
|
|
541
|
+
// must be with no dist, or as a git checkout there shows -- it is a clone Composer checked out at
|
|
542
|
+
// the reference, which may have been moved since: so only where that checkout's HEAD is it. Else
|
|
543
|
+
// from its dist, which Composer installs by default: only one at the same reference, as one with
|
|
544
|
+
// none, or another, is an archive no commit is known of. Packagist takes a package from the root
|
|
545
|
+
// of its repository, so `directory` is ''. Undefined otherwise.
|
|
546
|
+
function composerRepo({ source, dist }, from, at) {
|
|
547
|
+
if (source?.type !== 'git') return undefined
|
|
548
|
+
const fromSource = from === 'source' || dist == null || (at !== null && existsSync(join(at, '.git')))
|
|
549
|
+
if (fromSource ? at === null || checkoutCommit(at) !== source.reference : dist.reference !== source.reference) return undefined
|
|
550
|
+
const github = parseGithubRepository(source.url)
|
|
551
|
+
return github !== null && isValidRepoField('commit', source.reference) ? { github, directory: '', commit: source.reference } : undefined
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// The Composer packages of the project at `baseDir`, `[{ name, version, dir, extra, repo }]`, `dir` where
|
|
536
555
|
// the package is installed, baseDir-relative (null for nowhere in baseDir, as a metapackage). Of
|
|
537
556
|
// its composer.lock where there is one, read by @preventive/lockfile beside composer.json as
|
|
538
557
|
// `composer install` reads them -- one it refuses stops the build -- each package where Composer
|
|
539
558
|
// installs it (lockedDir), so no install is needed; else of vendor/composer/installed.json, as
|
|
540
559
|
// it is. Where both are, installed.json must be the lockfile's install (assertInstalledAsLocked):
|
|
541
|
-
// its packages, each where installed.json says.
|
|
560
|
+
// its packages, each where installed.json says. `repo` is composerRepo's.
|
|
542
561
|
export function loadComposerPackages(baseDir) {
|
|
543
562
|
const { vendorDir } = readComposerJson(baseDir)
|
|
544
563
|
const lockText = readUtf8OrNull(join(baseDir, 'composer.lock'), 'composer.lock')
|
|
545
564
|
if (lockText === null) {
|
|
546
565
|
return readInstalledPackages(baseDir, vendorDir)
|
|
547
566
|
.filter((p) => p?.name && p.version)
|
|
548
|
-
.map((p) =>
|
|
567
|
+
.map((p) => {
|
|
568
|
+
const dir = installedDir(baseDir, vendorDir, p)
|
|
569
|
+
return { name: p.name, version: p.version, dir, extra: p.extra, repo: composerRepo(p, p['installation-source'], dir == null ? null : join(baseDir, dir)) }
|
|
570
|
+
})
|
|
549
571
|
}
|
|
550
572
|
|
|
551
573
|
const composerJson = readUtf8OrNull(join(baseDir, 'composer.json'), 'composer.json')
|
|
@@ -554,9 +576,13 @@ export function loadComposerPackages(baseDir) {
|
|
|
554
576
|
const installedText = readUtf8OrNull(join(baseDir, installedFile), installedFile)
|
|
555
577
|
const installed = installedText === null ? null : assertInstalledAsLocked(lockText, installedText, { installedFile, vendorDir })
|
|
556
578
|
const dirs = installed === null ? null : new Map(installed.packages.map((p) => [p.name, installedDir(baseDir, vendorDir, p)]))
|
|
579
|
+
const from = new Map(installed?.packages.map((p) => [p.name, p['installation-source']]))
|
|
557
580
|
return Object.values(lock.packages)
|
|
558
581
|
.filter((p) => dirs === null || dirs.has(p.name))
|
|
559
|
-
.map((p) =>
|
|
582
|
+
.map((p) => {
|
|
583
|
+
const dir = dirs === null ? lockedDir(baseDir, vendorDir, p) : dirs.get(p.name)
|
|
584
|
+
return { name: p.name, version: p.version, dir, extra: p.extra, repo: composerRepo(p, from.get(p.name), dir == null ? null : join(baseDir, dir)) }
|
|
585
|
+
})
|
|
560
586
|
}
|
|
561
587
|
|
|
562
588
|
// Build the autoload config for `baseDir`, merging the root composer.json
|
|
@@ -667,28 +693,28 @@ function matchingPrefixes(map, name) {
|
|
|
667
693
|
return [...map].filter(([prefix]) => name.startsWith(prefix)).toSorted((a, b) => b[0].length - a[0].length)
|
|
668
694
|
}
|
|
669
695
|
|
|
670
|
-
// The versions of the Composer `packages` (loadComposerPackages: the authoritative source;
|
|
671
|
-
// package's own composer.json usually omits `version`), by where each is installed and by name.
|
|
696
|
+
// The versions and repos of the Composer `packages` (loadComposerPackages: the authoritative source;
|
|
697
|
+
// a package's own composer.json usually omits `version`), by where each is installed and by name.
|
|
672
698
|
function packageVersions(packages) {
|
|
673
699
|
const byDir = new Map()
|
|
674
700
|
const byName = new Map()
|
|
675
|
-
for (const { name, version, dir } of packages) {
|
|
676
|
-
byName.set(name, version)
|
|
677
|
-
if (dir) byDir.set(dir, version)
|
|
701
|
+
for (const { name, version, dir, repo } of packages) {
|
|
702
|
+
byName.set(name, { version, repo })
|
|
703
|
+
if (dir) byDir.set(dir, { version, repo })
|
|
678
704
|
}
|
|
679
705
|
return { byDir, byName }
|
|
680
706
|
}
|
|
681
707
|
|
|
682
708
|
// Walk up from a bundled file to the nearest named composer.json, returning
|
|
683
|
-
// { pkgDir, name, version } (pkgDir "." for the root; version from composer.lock or
|
|
709
|
+
// { pkgDir, name, version, repo } (pkgDir "." for the root; version and repo from composer.lock or
|
|
684
710
|
// installed.json by dir/name, else composer.json `version`, else `fallbackVersion`). Null if none.
|
|
685
711
|
function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
|
|
686
712
|
let dir = dirname(fileRelPath)
|
|
687
713
|
for (;;) {
|
|
688
714
|
const cj = readJsonIfExists(join(baseDir, dir, 'composer.json'))
|
|
689
715
|
if (cj?.name) {
|
|
690
|
-
const
|
|
691
|
-
return { pkgDir: dir, name: cj.name, version }
|
|
716
|
+
const known = installed.byDir.get(dir) ?? installed.byName.get(cj.name)
|
|
717
|
+
return { pkgDir: dir, name: cj.name, version: known?.version ?? cj.version ?? fallbackVersion, repo: known?.repo }
|
|
692
718
|
}
|
|
693
719
|
if (dir === '.' || dir === '/' || dir === '') return null
|
|
694
720
|
const parent = dirname(dir)
|
|
@@ -700,16 +726,17 @@ function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
|
|
|
700
726
|
// Group bundled PHP sources into per-package buckets keyed by the nearest
|
|
701
727
|
// composer.json's directory (vendor deps get their own; workspace files and
|
|
702
728
|
// orphans -> the root "." bucket with the placeholder identity). Returns a
|
|
703
|
-
// Map<dir, { name, version, files }
|
|
729
|
+
// Map<dir, { name, version, ecosystem?, repo?, files }>, a dependency's `repo` where composerRepo
|
|
730
|
+
// gives one. `packages`: loadComposerPackages(baseDir).
|
|
704
731
|
export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVersion, packages = loadComposerPackages(baseDir)) {
|
|
705
732
|
const installed = packageVersions(packages)
|
|
706
733
|
|
|
707
734
|
const modules = new Map()
|
|
708
|
-
const ensureBucket = (dir, name, version, ecosystem) => {
|
|
735
|
+
const ensureBucket = (dir, name, version, ecosystem, repo) => {
|
|
709
736
|
if (!modules.has(dir)) {
|
|
710
737
|
modules.set(dir, ecosystem === undefined
|
|
711
738
|
? { name, version, files: Object.create(null) }
|
|
712
|
-
: { name, version, ecosystem, files: Object.create(null) })
|
|
739
|
+
: { name, version, ecosystem, ...(repo === undefined ? {} : { repo }), files: Object.create(null) })
|
|
713
740
|
}
|
|
714
741
|
return modules.get(dir)
|
|
715
742
|
}
|
|
@@ -721,7 +748,7 @@ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVers
|
|
|
721
748
|
// Below the root package = an installed Composer dependency, tagged
|
|
722
749
|
// `composer`; the root "." bucket is workspace code and stays ecosystem-less.
|
|
723
750
|
const ecosystem = pkg.pkgDir === '.' ? undefined : 'composer'
|
|
724
|
-
ensureBucket(pkg.pkgDir, pkg.name, pkg.version, ecosystem).files[rel] = content
|
|
751
|
+
ensureBucket(pkg.pkgDir, pkg.name, pkg.version, ecosystem, pkg.repo).files[rel] = content
|
|
725
752
|
} else {
|
|
726
753
|
ensureBucket('.', fallbackName, fallbackVersion).files[path] = content
|
|
727
754
|
}
|
package/src/resolve-fields.js
CHANGED
|
@@ -14,17 +14,25 @@ import {
|
|
|
14
14
|
typescriptSiblings,
|
|
15
15
|
} from './resolve-typescript.js'
|
|
16
16
|
import { diskHost } from '@exodus/stasis-core/host'
|
|
17
|
+
import { createNodeResolver } from './resolve-node.js'
|
|
17
18
|
|
|
18
19
|
// Static module resolver for legacy package fields (`react-native`/`browser`/`main` + browser-spec
|
|
19
20
|
// redirect maps) and platform suffixes (`.ios`/`.android`/`.native`), reproducing Metro/React-Native
|
|
20
21
|
// resolution that Node's own resolver (used by scan.js) can't. `exports`-bearing packages are
|
|
21
|
-
// delegated to Node
|
|
22
|
+
// delegated to Node's algorithm, under a bundler's conditions rather than Node's (see
|
|
23
|
+
// resolveConditions). No user code is executed (only package.json is stat/read).
|
|
22
24
|
//
|
|
23
25
|
// Returns, for each specifier:
|
|
24
|
-
// { url } resolved to a real
|
|
26
|
+
// { url } resolved to a file, by its real path (file: URL string; see fileResolution)
|
|
25
27
|
// { empty } a browser/react-native field mapped it to `false` (empty module)
|
|
26
|
-
// { builtin } a Node builtin
|
|
28
|
+
// { builtin } a Node builtin: a `node:` specifier, or a builtin name no installed package resolves
|
|
27
29
|
// null unresolved
|
|
30
|
+
//
|
|
31
|
+
// Browser and React Native targets have no Node builtins, so a bare builtin name (`buffer`,
|
|
32
|
+
// `events`, `util`, `process`, ...) resolves to the installed npm package of that name, as esbuild
|
|
33
|
+
// (platform browser), webpack 5 (target web) and Metro bundle it. Where nothing is installed it
|
|
34
|
+
// stays a builtin, as the --metro-resolver adapter reports Metro's miss on one (metro-resolver.js),
|
|
35
|
+
// rather than an unresolved edge: Metro and webpack fail the build there.
|
|
28
36
|
|
|
29
37
|
// Metro divergence toggle. When a package's browser map maps its OWN entry to `false` (under
|
|
30
38
|
// Metro's matching rules, which include bare keys like {"buf": false} for main "./buf.js"),
|
|
@@ -106,7 +114,21 @@ function resolveEntryThroughMap(map, main, opts) {
|
|
|
106
114
|
return { entry: main }
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
|
|
117
|
+
// A resolved file by its REAL path (through `host`, so a Vfs host's links are followed too), as Node,
|
|
118
|
+
// Metro and esbuild record one: under pnpm's isolated layout `node_modules/debug` links into
|
|
119
|
+
// `node_modules/.pnpm/debug@x/node_modules/debug`, whose own imports (a sibling `ms`) resolve only
|
|
120
|
+
// from where it really lies. Probing stays on the path as reached, so a candidate and the package
|
|
121
|
+
// scope read from it (resolveSourceFile's metro redirect) are one spelling; only the hit is
|
|
122
|
+
// realpathed. null where the file has no real path (gone since it was probed).
|
|
123
|
+
function fileResolution(path, host) {
|
|
124
|
+
let real
|
|
125
|
+
try {
|
|
126
|
+
real = host.realpath(path)
|
|
127
|
+
} catch {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
return { url: pathToFileURL(real).toString() }
|
|
131
|
+
}
|
|
110
132
|
|
|
111
133
|
// Match a package-relative path (`./x`) against the redirect map, trying `.js`/`.json` appended.
|
|
112
134
|
function matchRedirect(map, relPath) {
|
|
@@ -199,7 +221,7 @@ function resolveSourceFile(base, opts) {
|
|
|
199
221
|
// bare package-name imports, not to a path landing on a directory.
|
|
200
222
|
function resolveFileOrDir(base, opts) {
|
|
201
223
|
// resolveSourceFile yields a path, { empty: true } (a metro candidate-redirect hit), or null.
|
|
202
|
-
const asResolution = (hit) => (hit == null ? null : typeof hit === 'string' ? fileResolution(hit) : hit)
|
|
224
|
+
const asResolution = (hit) => (hit == null ? null : typeof hit === 'string' ? fileResolution(hit, opts.host) : hit)
|
|
203
225
|
const file = asResolution(resolveSourceFile(base, opts))
|
|
204
226
|
if (file) return file
|
|
205
227
|
if (isDir(base, opts.host)) {
|
|
@@ -219,8 +241,9 @@ function resolveFileOrDir(base, opts) {
|
|
|
219
241
|
return null
|
|
220
242
|
}
|
|
221
243
|
|
|
222
|
-
// Build a resolver bound to options: `conditions` gates `exports`
|
|
223
|
-
// `mainFields`/`platform`/`preferNative`/`sourceExts`
|
|
244
|
+
// Build a resolver bound to options: `conditions` (a resolveConditions set) gates `exports`
|
|
245
|
+
// packages (delegated to Node's algorithm), `mainFields`/`platform`/`preferNative`/`sourceExts`
|
|
246
|
+
// drive the legacy-field + suffix probing.
|
|
224
247
|
// `metro` opts into Metro's package-entry + candidate-redirect semantics (see
|
|
225
248
|
// resolveEntryThroughMap and resolveSourceFile); leave it off for the esbuild-parity
|
|
226
249
|
// `--mainFields` path. `typescript` adds tsc's extension substitution (a missing `x.js` probes
|
|
@@ -244,6 +267,9 @@ export function createFieldResolver({
|
|
|
244
267
|
const opts = { platform, preferNative, sourceExts, mainFields, metro, typescript, metroKeepEntryOnBrowserFalse, host }
|
|
245
268
|
// An importer in node_modules gets no --typescript mapping (inNodeModules), wherever its import lands.
|
|
246
269
|
const fromNodeModules = { ...opts, typescript: false }
|
|
270
|
+
// Node answers a builtin name with the builtin itself, so an `exports`-bearing package named like
|
|
271
|
+
// one (see the header) resolves through this instead, with builtins off; built on first use.
|
|
272
|
+
let packageResolver
|
|
247
273
|
// `callConditions` (from scan) is the parent's format-driven condition set, so `exports`
|
|
248
274
|
// delegation matches Node resolving from THAT file; falls back to configured `conditions`.
|
|
249
275
|
const resolve = function resolve(parentFile, specifier, callConditions) {
|
|
@@ -251,11 +277,28 @@ export function createFieldResolver({
|
|
|
251
277
|
const conds = new Set(callConditions ?? conditions)
|
|
252
278
|
const viaNode = (spec) => {
|
|
253
279
|
try {
|
|
254
|
-
return fileResolution(host.resolve(parentFile, spec, conds))
|
|
280
|
+
if (!isBuiltin(spec)) return fileResolution(host.resolve(parentFile, spec, conds), host)
|
|
281
|
+
packageResolver ??= createNodeResolver(host)
|
|
282
|
+
return fileResolution(packageResolver.resolve(parentFile, spec, conds, { builtins: false }), host)
|
|
255
283
|
} catch {
|
|
256
284
|
return null
|
|
257
285
|
}
|
|
258
286
|
}
|
|
287
|
+
// A bare specifier (`mod`, `mod/sub`) through node_modules.
|
|
288
|
+
const viaPackage = (spec) => {
|
|
289
|
+
const loc = locatePackage(dirname(parentFile), spec, host)
|
|
290
|
+
if (!loc) return null
|
|
291
|
+
const pkg = readJson(join(loc.pkgDir, 'package.json'), host) ?? {}
|
|
292
|
+
// `exports` wins over mainFields; Node's algorithm resolves it (with conditions) correctly.
|
|
293
|
+
if (pkg.exports != null) return viaNode(spec)
|
|
294
|
+
// A bare package import resolves its directory via the same dir algorithm as any other.
|
|
295
|
+
if (loc.subpath === '') return resolveFileOrDir(loc.pkgDir, fileOpts)
|
|
296
|
+
const sub = `./${loc.subpath}`
|
|
297
|
+
const r = matchRedirect(mergeRedirectMap(pkg, mainFields), sub)
|
|
298
|
+
if (r === false) return { empty: true }
|
|
299
|
+
const target = typeof r === 'string' ? r : sub
|
|
300
|
+
return resolveFileOrDir(join(loc.pkgDir, target), fileOpts)
|
|
301
|
+
}
|
|
259
302
|
// `#name` subpath imports use the `imports` field + conditions; Node's algorithm handles them.
|
|
260
303
|
if (specifier.startsWith('#')) return viaNode(specifier)
|
|
261
304
|
|
|
@@ -283,26 +326,15 @@ export function createFieldResolver({
|
|
|
283
326
|
}
|
|
284
327
|
}
|
|
285
328
|
|
|
286
|
-
// A builtin the browser map did not remap resolves to the builtin itself.
|
|
287
|
-
if (isBuiltin(spec)) return { builtin: true }
|
|
288
|
-
|
|
289
329
|
if (spec.startsWith('.') || isAbsolute(spec)) {
|
|
290
330
|
const base = isAbsolute(spec) ? spec : resolvePath(dirname(parentFile), spec)
|
|
291
331
|
return resolveFileOrDir(base, fileOpts)
|
|
292
332
|
}
|
|
293
333
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (pkg.exports != null) return viaNode(spec)
|
|
299
|
-
// A bare package import resolves its directory via the same dir algorithm as any other.
|
|
300
|
-
if (loc.subpath === '') return resolveFileOrDir(loc.pkgDir, fileOpts)
|
|
301
|
-
const sub = `./${loc.subpath}`
|
|
302
|
-
const r = matchRedirect(mergeRedirectMap(pkg, mainFields), sub)
|
|
303
|
-
if (r === false) return { empty: true }
|
|
304
|
-
const target = typeof r === 'string' ? r : sub
|
|
305
|
-
return resolveFileOrDir(join(loc.pkgDir, target), fileOpts)
|
|
334
|
+
// A builtin the browser map did not remap: its `node:` form names the builtin itself; a bare
|
|
335
|
+
// name is the installed package of that name (see the header), the builtin where none resolves.
|
|
336
|
+
if (isBuiltin(spec)) return spec.startsWith('node:') ? { builtin: true } : (viaPackage(spec) ?? { builtin: true })
|
|
337
|
+
return viaPackage(spec)
|
|
306
338
|
}
|
|
307
339
|
if (!typescript) return resolve
|
|
308
340
|
// --typescript: when the whole field flow leaves the specifier unresolved, give tsc's mapping
|
|
@@ -319,7 +351,7 @@ export function createFieldResolver({
|
|
|
319
351
|
paths: typescriptPaths,
|
|
320
352
|
host,
|
|
321
353
|
})
|
|
322
|
-
return hit == null ? null : fileResolution(hit)
|
|
354
|
+
return hit == null ? null : fileResolution(hit, host)
|
|
323
355
|
}
|
|
324
356
|
}
|
|
325
357
|
|
|
@@ -328,11 +360,11 @@ function isModuleFormat(format) {
|
|
|
328
360
|
return format === 'module' || format === 'module-typescript'
|
|
329
361
|
}
|
|
330
362
|
|
|
331
|
-
// Condition set for a parent of the given format
|
|
332
|
-
//
|
|
363
|
+
// Condition set for a parent of the given format, as scan's #conditionSet gives a custom resolver:
|
|
364
|
+
// `import` or `require` by format, `default` (which Node's resolver matches anyway), plus extras.
|
|
365
|
+
// A bundler's base, not Node's: esbuild, webpack and Metro assert `import`/`require` and
|
|
366
|
+
// `default`, never `node`, so an `exports` map listing `node` first can't win over the extras.
|
|
367
|
+
// Order is irrelevant (Node membership-tests against exports key order).
|
|
333
368
|
export function resolveConditions(format, extras = []) {
|
|
334
|
-
|
|
335
|
-
? ['node', 'import', 'module-sync', 'node-addons']
|
|
336
|
-
: ['node', 'require', 'module-sync', 'node-addons']
|
|
337
|
-
return [...new Set([...base, ...extras])]
|
|
369
|
+
return [...new Set([isModuleFormat(format) ? 'import' : 'require', 'default', ...extras])]
|
|
338
370
|
}
|
package/src/resolve-node.js
CHANGED
|
@@ -385,9 +385,10 @@ export function createNodeResolver(host) {
|
|
|
385
385
|
|
|
386
386
|
const notFound = (request, parentFile) => Object.assign(codedError('MODULE_NOT_FOUND', `Cannot find module '${request}'\nRequire stack:\n- ${parentFile}`), { requireStack: [parentFile] })
|
|
387
387
|
|
|
388
|
-
// Module._resolveFilename: -> the file's real path, or a builtin's id.
|
|
389
|
-
|
|
390
|
-
|
|
388
|
+
// Module._resolveFilename: -> the file's real path, or a builtin's id. Without `builtins`, a
|
|
389
|
+
// request naming a builtin is looked up like any bare name (see resolve).
|
|
390
|
+
const resolveFilename = (parentFile, request, conditions, builtins) => {
|
|
391
|
+
if (builtins && isBuiltin(request)) return request
|
|
391
392
|
if (request === '') throw notFound(request, parentFile)
|
|
392
393
|
if (request[0] === '#') {
|
|
393
394
|
const pkg = readPackageScope(parentFile)
|
|
@@ -404,12 +405,14 @@ export function createNodeResolver(host) {
|
|
|
404
405
|
|
|
405
406
|
return {
|
|
406
407
|
// `conditions`: `require.resolve`'s own by default. A builtin returns its id, as it does.
|
|
407
|
-
|
|
408
|
+
// `builtins: false` resolves for a target with no Node builtins: a bare builtin name (`buffer`)
|
|
409
|
+
// is the installed package of that name, through its `exports` like any other.
|
|
410
|
+
resolve(parentFile, request, conditions = REQUIRE_CONDITIONS, { builtins = true } = {}) {
|
|
408
411
|
parentFile = resolve(parentFile)
|
|
409
412
|
if (!(conditions instanceof Set)) conditions = new Set(conditions)
|
|
410
|
-
const key = `${dirname(parentFile)}\0${request}\0${[...conditions].join(',')}`
|
|
413
|
+
const key = `${dirname(parentFile)}\0${request}\0${[...conditions].join(',')}${builtins ? '' : '\0nobuiltins'}`
|
|
411
414
|
let hit = memo.get(key)
|
|
412
|
-
if (hit === undefined) memo.set(key, hit = resolveFilename(parentFile, request, conditions))
|
|
415
|
+
if (hit === undefined) memo.set(key, hit = resolveFilename(parentFile, request, conditions, builtins))
|
|
413
416
|
return hit
|
|
414
417
|
},
|
|
415
418
|
}
|
package/src/scan.js
CHANGED
|
@@ -237,10 +237,15 @@ export class Scan {
|
|
|
237
237
|
return this
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
//
|
|
241
|
-
// packages to a different file) AND its order: the recorded key must equal the
|
|
242
|
-
// hooks' string for exact lookups. Extras go where Node puts user conditions in each set.
|
|
240
|
+
// Node's resolver: must match the runtime resolver's condition set (omitting one resolves
|
|
241
|
+
// `exports`-gated packages to a different file) AND its order: the recorded key must equal the
|
|
242
|
+
// runtime hooks' string for exact lookups. Extras go where Node puts user conditions in each set.
|
|
243
|
+
// A custom resolver (--mainFields/--metro) resolves for a bundler instead, so it gets what
|
|
244
|
+
// esbuild, webpack and Metro all assert: `import`/`require`, `default` (which Node's resolver
|
|
245
|
+
// matches anyway) and the extras, never `node`, `node-addons` or `module-sync`; the same set as
|
|
246
|
+
// resolve-fields.js's resolveConditions.
|
|
243
247
|
#conditionSet(context) {
|
|
248
|
+
if (this.customResolve) return new Set([context, 'default', ...this.extraConditions])
|
|
244
249
|
return context === 'import'
|
|
245
250
|
? new Set(['node', 'import', 'module-sync', 'node-addons', ...this.extraConditions])
|
|
246
251
|
: new Set(['require', 'node', 'node-addons', ...this.extraConditions, 'module-sync'])
|
|
@@ -6,12 +6,15 @@ import { isAutoExcludedDir, isPlainObject, posixPathEscapes, relativeEscapes } f
|
|
|
6
6
|
import { Vfs } from '@preventive/vfs'
|
|
7
7
|
import { checkVfsOptions, fieldResolverFor } from '../cmd/bundle.js'
|
|
8
8
|
import { foundrySourceDir } from '../loaders/foundry.js'
|
|
9
|
+
import { resolveConditions } from '../resolve-fields.js'
|
|
9
10
|
import { resolveTypescriptFallback, typescriptExportsTarget } from '../resolve-typescript.js'
|
|
10
11
|
import { checkKind, checkVersion, packageManagerOf, vfsHost } from './tree.js'
|
|
11
12
|
|
|
12
13
|
const JS = /\.(?:[cm]?[jt]s|[jt]sx)$/u
|
|
13
|
-
// Node's own conditions for require() and for import, which the build's are added to
|
|
14
|
+
// Node's own conditions for require() and for import, which the build's are added to; with
|
|
15
|
+
// `mainFields` or `metro`, the field resolver's bundler ones instead (no `node`).
|
|
14
16
|
const NODE_CONDITIONS = [['require', 'node', 'node-addons', 'module-sync'], ['import', 'node', 'node-addons', 'module-sync']]
|
|
17
|
+
const FIELD_CONDITIONS = ['commonjs', 'module'].map((format) => resolveConditions(format))
|
|
15
18
|
|
|
16
19
|
// The entry points the package.json in `dir` names, as paths from `dir`, resolved as the JS build
|
|
17
20
|
// with the given options resolves: as Node does, `conditions` added, or with `mainFields` or
|
|
@@ -19,8 +22,9 @@ const NODE_CONDITIONS = [['require', 'node', 'node-addons', 'module-sync'], ['im
|
|
|
19
22
|
// that misses mapped as tsc maps it under `typescript`, as the build's fallback maps it. In
|
|
20
23
|
// that order: its own entry, as the build resolves `./` there (`main`, or the first of the main
|
|
21
24
|
// fields, else index); each subpath `exports` holds (but a pattern), as the package's name resolves
|
|
22
|
-
// for require() and for import, with the conditions the build adds (the RN ones under `metro`)
|
|
23
|
-
//
|
|
25
|
+
// for require() and for import, with the conditions the build adds (the RN ones under `metro`) to
|
|
26
|
+
// Node's, or with `mainFields` or `metro` to the field resolver's; and each `bin`. Only JS files in
|
|
27
|
+
// `dir` that are there, named from within it; none without a package.json.
|
|
24
28
|
function packageEntries(host, dir, { conditions = [], mainFields, metro = false, platforms = [], typescript = false } = {}) {
|
|
25
29
|
const real = host.realpath(dir)
|
|
26
30
|
const manifest = join(real, 'package.json')
|
|
@@ -62,8 +66,8 @@ function packageEntries(host, dir, { conditions = [], mainFields, metro = false,
|
|
|
62
66
|
for (const subpath of typeof name === 'string' ? subpaths : []) {
|
|
63
67
|
if (!subpath.startsWith('.') || subpath.includes('*') || subpath.endsWith('/')) continue
|
|
64
68
|
const specifier = subpath === '.' ? name : `${name}${subpath.slice(1)}`
|
|
65
|
-
for (const { extras } of passes) {
|
|
66
|
-
for (const names of NODE_CONDITIONS) add(viaNode(specifier, names, extras, (set) => typescriptExportsTarget(real, exports, subpath, { conditions: set, host })))
|
|
69
|
+
for (const { extras, resolver } of passes) {
|
|
70
|
+
for (const names of resolver === undefined ? NODE_CONDITIONS : FIELD_CONDITIONS) add(viaNode(specifier, names, extras, (set) => typescriptExportsTarget(real, exports, subpath, { conditions: set, host })))
|
|
67
71
|
}
|
|
68
72
|
}
|
|
69
73
|
const bins = typeof pkg.bin === 'string' ? [pkg.bin] : pkg.bin !== null && typeof pkg.bin === 'object' ? Object.values(pkg.bin) : []
|
package/src/vfs-bundle/github.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createClient } from '@preventive/upstream/github.js'
|
|
|
8
8
|
import { vfsFromEntries } from '@preventive/vfs'
|
|
9
9
|
import { buildVfsBundle } from '../cmd/bundle.js'
|
|
10
10
|
import { KINDS, checkAhead } from './entries.js'
|
|
11
|
-
import { checkTarget, detectPackageManager, installedAlone, lockfileOf, lockfilesListed, noLockfile, packageManagerOf, vfsHost } from './tree.js'
|
|
11
|
+
import { checkCache, checkTarget, detectPackageManager, installedAlone, lockfileOf, lockfilesListed, noLockfile, packageManagerOf, vfsHost } from './tree.js'
|
|
12
12
|
|
|
13
13
|
// As upstream's tree verification bounds a tarball's unpacked size.
|
|
14
14
|
const MAX_TAR_BYTES = 2 ** 30
|
|
@@ -172,8 +172,11 @@ async function repoTree(name, { github, sha, tag, directory, client, packageMana
|
|
|
172
172
|
// one is outside the others; `repo` names that directory and the commit. Nothing is read from disk:
|
|
173
173
|
// the tree's bytes come from GitHub, or from the cache setCacheDir names, held to the git tree id
|
|
174
174
|
// either way (@preventive/upstream), and are unpacked into a Vfs that buildVfsBundle reads alone.
|
|
175
|
+
// `cache` is buildVfsBundle's, for the npm packages the tree installs: the repo's tree is cached
|
|
176
|
+
// where setCacheDir says, whatever it is.
|
|
175
177
|
export async function buildGitHubBundle({ github, sha, tag, directory, client, packageManager, ...options } = {}) {
|
|
176
178
|
checkTarget('buildGitHubBundle', options)
|
|
179
|
+
checkCache('buildGitHubBundle', options.cache)
|
|
177
180
|
// Its own, for a Soldeer git dependency too.
|
|
178
181
|
client ??= createClient({ token: null })
|
|
179
182
|
const tree = await repoTree('buildGitHubBundle', { github, sha, tag, directory, client, packageManager }, options)
|
package/src/vfs-bundle/tree.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { constants } from 'node:fs'
|
|
2
|
-
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
2
|
+
import { basename, dirname, join, posix, relative, resolve, sep } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { isValidRepoField } from '@exodus/stasis-core/bundle'
|
|
5
|
+
import { NO_ENTRY, packageJSONStat, packageJSONText, parseGithubRepository, readJson } from '@exodus/stasis-core/bundle-util'
|
|
5
6
|
import { byName } from '@exodus/stasis-core/host'
|
|
6
7
|
import { hasNodeModulesSegment, isPlainObject } from '@exodus/stasis-core/util'
|
|
7
8
|
import { buildNpmTree, findNpmWorkspaces } from '@preventive/deptree/npm.js'
|
|
@@ -114,6 +115,14 @@ export function checkTarget(name, { os, cpu, libc }) {
|
|
|
114
115
|
if (libc !== undefined && !LIBCS.has(libc)) throw new TypeError(`${name}: libc must be one of 'glibc', 'musl', 'unknown'`)
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
// `cache`, where npm packages' tarballs and version documents are kept, as @preventive/upstream's
|
|
119
|
+
// CacheOptions take it: left out, in setCacheDir's cache; a store with `read` and `write`, in place
|
|
120
|
+
// of it; or false, nowhere. Checked as deptree checks it, before anything is fetched.
|
|
121
|
+
export function checkCache(name, cache) {
|
|
122
|
+
if (cache === undefined || cache === false || (typeof cache?.read === 'function' && typeof cache?.write === 'function')) return
|
|
123
|
+
throw new TypeError(`${name}: cache must be false, or a store with read and write, or left out`)
|
|
124
|
+
}
|
|
125
|
+
|
|
117
126
|
// yarn installs a workspace from the root that declares it, whatever yarn.lock is nearer, and any
|
|
118
127
|
// other package from the nearest yarn.lock.
|
|
119
128
|
function yarn1Root(host, cwd) {
|
|
@@ -191,7 +200,7 @@ const PACKAGE_MANAGERS = {
|
|
|
191
200
|
alone: async (names, above) => !(await above()).some((dir) => dir.includes('pnpm-workspace.yaml')),
|
|
192
201
|
projects: (view, pnpm) => findPnpmProjects({ project: view, host: { pnpm } }),
|
|
193
202
|
installs: 'node_modules',
|
|
194
|
-
build: (view, pnpm, _file, given) => buildPnpmTree({ project: view, host: { pnpm, ...target(given) } }),
|
|
203
|
+
build: (view, pnpm, _file, given) => buildPnpmTree({ project: view, host: { pnpm, ...target(given) }, cache: given?.cache }),
|
|
195
204
|
},
|
|
196
205
|
yarn1: {
|
|
197
206
|
kind: 'js',
|
|
@@ -205,7 +214,7 @@ const PACKAGE_MANAGERS = {
|
|
|
205
214
|
installs: 'node_modules',
|
|
206
215
|
build: (view, yarn, file, given) => {
|
|
207
216
|
const { libc: _, ...host } = target({ ...given, libc: 'unknown' }) // yarn 1 matches no libc
|
|
208
|
-
return naming(file, buildYarn1Tree({ project: view, host: { yarn, ...host } }))
|
|
217
|
+
return naming(file, buildYarn1Tree({ project: view, host: { yarn, ...host }, cache: given?.cache }))
|
|
209
218
|
},
|
|
210
219
|
},
|
|
211
220
|
npm: {
|
|
@@ -218,7 +227,7 @@ const PACKAGE_MANAGERS = {
|
|
|
218
227
|
alone: async (names, above) => !(await above()).some((dir) => dir.includes('package.json')),
|
|
219
228
|
projects: (view, _npm, given) => findNpmWorkspaces({ project: view, os: target(given).os }),
|
|
220
229
|
installs: 'node_modules',
|
|
221
|
-
build: (view, npm, file, given) => naming(file, buildNpmTree({ project: view, host: npmHost(npm, given) })),
|
|
230
|
+
build: (view, npm, file, given) => naming(file, buildNpmTree({ project: view, host: npmHost(npm, given), cache: given?.cache })),
|
|
222
231
|
},
|
|
223
232
|
soldeer: {
|
|
224
233
|
kind: 'sol',
|
|
@@ -343,15 +352,17 @@ export function checkVfs(name, vfs) {
|
|
|
343
352
|
}
|
|
344
353
|
}
|
|
345
354
|
|
|
346
|
-
// -> { root, vfs, projects, stats, packageManager, packageManagerVersion }, of the project
|
|
347
|
-
// holds that `cwd` is in, as `packageManager` installs it: the directory it installs from,
|
|
348
|
-
// path; a new Vfs holding the tree, rooted there; the directories of the projects it finds,
|
|
349
|
-
// there; deptree's counts
|
|
350
|
-
//
|
|
351
|
-
// view of `project`, which nothing is
|
|
352
|
-
// client, is what a Soldeer git
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
+
// -> { root, vfs, projects, stats, installed, packageManager, packageManagerVersion }, of the project
|
|
356
|
+
// `project` holds that `cwd` is in, as `packageManager` installs it: the directory it installs from,
|
|
357
|
+
// as a real path; a new Vfs holding the tree, rooted there; the directories of the projects it finds,
|
|
358
|
+
// from there; deptree's counts, and what it installs, each by its `path` from there; and the version
|
|
359
|
+
// reproduced, `packageManagerVersion` if given, else the one the root package.json's packageManager
|
|
360
|
+
// pins, else the default. deptree reads the project through a view of `project`, which nothing is
|
|
361
|
+
// written through. `client`, a @preventive/upstream/github.js client, is what a Soldeer git
|
|
362
|
+
// dependency is fetched through (deptree's `github`); without one, a lockfile holding one is refused
|
|
363
|
+
// before anything is fetched. `cache` (checkCache) is where npm packages' tarballs and version
|
|
364
|
+
// documents are kept, which deptree fetches for pnpm, yarn1 and npm.
|
|
365
|
+
async function layOutTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, client, cache }) {
|
|
355
366
|
const pm = PACKAGE_MANAGERS[packageManager]
|
|
356
367
|
const found = pm.root(project, cwd, os)
|
|
357
368
|
if (found === null) throw new Error(`no ${pm.lockfile} found in ${cwd} or any parent directory`)
|
|
@@ -364,17 +375,40 @@ async function layOutTree({ project, packageManager, cwd, packageManagerVersion,
|
|
|
364
375
|
const projects = new Set(pm.projects(view, version, { os, cpu, libc }))
|
|
365
376
|
const other = pm.kind === 'js' ? outsider(project, found, cwd, projects) : null
|
|
366
377
|
if (other !== null) throw new Error(`${file} does not install ${other}: it is none of the lockfile's projects`)
|
|
367
|
-
const { vfs, stats } = await pm.build(view, version, file, { os, cpu, libc, client })
|
|
378
|
+
const { vfs, stats, installed } = await pm.build(view, version, file, { os, cpu, libc, client, cache })
|
|
368
379
|
return {
|
|
369
380
|
root: project.realpath(found),
|
|
370
381
|
vfs,
|
|
371
382
|
projects,
|
|
372
383
|
stats,
|
|
384
|
+
installed,
|
|
373
385
|
packageManager,
|
|
374
386
|
packageManagerVersion: version ?? (String(pinned).startsWith(`${pm.pin}@`) ? /^[^@]+@([^+]+)/u.exec(pinned)[1] : undefined),
|
|
375
387
|
}
|
|
376
388
|
}
|
|
377
389
|
|
|
390
|
+
// The root of the GitHub repository a git URL names, or undefined for another host's.
|
|
391
|
+
const githubRoot = (url) => {
|
|
392
|
+
const github = parseGithubRepository(url)
|
|
393
|
+
return github === null ? undefined : { github, directory: '' }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Each dependency of `bundle`, its paths from `root`, at the commit the tree (layOutTree's) installs
|
|
397
|
+
// it from, where deptree names a full one: an npm package at its gitHead, which the registry's
|
|
398
|
+
// version document names, a commit of the repository its own package.json names, so taken only
|
|
399
|
+
// where the bundle records that one; a Soldeer git dependency at its lockfile's `rev`, the root of
|
|
400
|
+
// the repository it is fetched from, where that is a GitHub one. Metadata, as `repo` is: the
|
|
401
|
+
// publisher's word, held to no repository.
|
|
402
|
+
export function pinCommits(bundle, root, { root: from, installed }) {
|
|
403
|
+
const byDir = new Map(installed.map((pkg) => [posix.relative(root, posix.join(from, pkg.path)), pkg]))
|
|
404
|
+
for (const [dir, info] of bundle.modules) {
|
|
405
|
+
const pkg = byDir.get(dir)
|
|
406
|
+
if (!isValidRepoField('commit', pkg?.commit)) continue
|
|
407
|
+
const repo = info.repo ?? githubRoot(pkg.git)
|
|
408
|
+
if (repo !== undefined) bundle.modules.set(dir, { ...info, repo: { ...repo, commit: pkg.commit } })
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
378
412
|
// fs.Stats#mode carries the file type above the permission bits a Vfs stat holds.
|
|
379
413
|
const TYPE_BITS = { file: constants.S_IFREG, directory: constants.S_IFDIR, symlink: constants.S_IFLNK }
|
|
380
414
|
|
package/src/vfs-bundle.js
CHANGED
|
@@ -3,7 +3,7 @@ import { posix } from 'node:path'
|
|
|
3
3
|
import { Vfs } from '@preventive/vfs'
|
|
4
4
|
import { KINDS, checkAhead } from './vfs-bundle/entries.js'
|
|
5
5
|
import { suggestedRepoEntries } from './vfs-bundle/github.js'
|
|
6
|
-
import { NODE_MODULES_MANAGERS, checkTarget, checkVfs, detectPackageManager, loadTree, packageManagerFor, vfsHost } from './vfs-bundle/tree.js'
|
|
6
|
+
import { NODE_MODULES_MANAGERS, checkCache, checkTarget, checkVfs, detectPackageManager, loadTree, packageManagerFor, vfsHost } from './vfs-bundle/tree.js'
|
|
7
7
|
|
|
8
8
|
// @exodus/stasis/vfs-bundle: static bundles from a project's lockfile alone, through the
|
|
9
9
|
// dependencies its package manager would install (`packageManager`: 'pnpm', pnpm 9, 10, 11 or 12;
|
|
@@ -30,10 +30,12 @@ export function createVfsHost(vfs) {
|
|
|
30
30
|
return vfsHost(vfs, { cache: false })
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// -> { root, vfs, projects, host, stats, packageManager, packageManagerVersion }, as
|
|
34
|
-
// 'yarn1' or 'npm' installs node_modules: the directory in the project's Vfs it installs cwd
|
|
35
|
-
// which holds the lockfile; the Vfs the tree is laid out into, rooted there; the projects'
|
|
36
|
-
// directories from there; the host reading the project through the tree; deptree's counts
|
|
33
|
+
// -> { root, vfs, projects, host, stats, installed, packageManager, packageManagerVersion }, as
|
|
34
|
+
// 'pnpm', 'yarn1' or 'npm' installs node_modules: the directory in the project's Vfs it installs cwd
|
|
35
|
+
// from, which holds the lockfile; the Vfs the tree is laid out into, rooted there; the projects'
|
|
36
|
+
// directories from there; the host reading the project through the tree; deptree's counts, and the
|
|
37
|
+
// packages it installs (each by its `path` from there, with the `commit` its version document
|
|
38
|
+
// names, where it names a full one); and the
|
|
37
39
|
// package manager reproduced, at `packageManagerVersion` if given, else the one the root
|
|
38
40
|
// package.json's packageManager pins, else pnpm 10.33.4, yarn 1.22.22 or npm 11.21.0 (which nothing
|
|
39
41
|
// pins: npm reads no packageManager). The host caches what it reads, of the project's Vfs too, so
|
|
@@ -41,15 +43,18 @@ export function createVfsHost(vfs) {
|
|
|
41
43
|
// `os`, `cpu` and `libc` ('glibc', 'musl' or 'unknown', pnpm's and npm's, which takes 'unknown' for
|
|
42
44
|
// none) are the machine packages are matched against: this one's but for what is given (for another
|
|
43
45
|
// os, libc defaults to 'unknown'). Without a `packageManager`, it is the one of the three whose
|
|
44
|
-
// lockfile installs cwd, where only one's does.
|
|
45
|
-
|
|
46
|
+
// lockfile installs cwd, where only one's does. `cache` is where the packages' tarballs and version
|
|
47
|
+
// documents are kept, as @preventive/upstream's CacheOptions take it: left out, in setCacheDir's
|
|
48
|
+
// cache; a store with `read` and `write`, in place of it; or false, nowhere.
|
|
49
|
+
export async function loadNodeModules({ vfs, packageManager, cwd = '/', packageManagerVersion, os, cpu, libc, cache } = {}) {
|
|
46
50
|
checkVfs('loadNodeModules', vfs)
|
|
47
51
|
checkTarget('loadNodeModules', { os, cpu, libc })
|
|
52
|
+
checkCache('loadNodeModules', cache)
|
|
48
53
|
const project = vfsHost(vfs)
|
|
49
54
|
// A real path, as detection and the layout take it.
|
|
50
55
|
cwd = project.realpath(posix.resolve('/', cwd))
|
|
51
56
|
packageManager = packageManagerFor('loadNodeModules', project, cwd, { packageManager, packageManagerVersion, os }, NODE_MODULES_MANAGERS)
|
|
52
|
-
return loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc })
|
|
57
|
+
return loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, cache })
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
// -> the entries buildGitHubBundle takes where none are given, as paths from the project's
|