@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.10
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 +32 -8
- package/bin/stasis.js +386 -213
- package/package.json +52 -12
- package/src/audit-corrections.js +96 -0
- package/src/audit.js +289 -64
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +14 -22
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +96 -128
- package/src/cmd/bundle.js +1121 -654
- package/src/cmd/diff.js +2 -19
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +7 -19
- package/src/diff.js +80 -129
- package/src/installed-commits.js +182 -0
- package/src/json-stream.js +283 -0
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +49 -116
- package/src/loaders/cargo.js +1716 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +375 -402
- package/src/loaders/rust.js +3254 -242
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -162
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +150 -0
- package/src/metro-transformer.js +3 -2
- package/src/mock.js +57 -165
- package/src/parse.js +4 -16
- package/src/resolve-fields.js +370 -0
- package/src/resolve-node.js +419 -0
- package/src/resolve-typescript.js +541 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +120 -97
- package/src/scan.js +348 -180
- package/src/vfs-bundle/entries.js +144 -0
- package/src/vfs-bundle/github.js +202 -0
- package/src/vfs-bundle/prisma.js +439 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +647 -0
- package/src/vfs-bundle.js +80 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -44
- package/src/cmd/extract.js +0 -193
package/src/cmd/bundle.js
CHANGED
|
@@ -1,46 +1,55 @@
|
|
|
1
1
|
import { isUtf8 } from 'node:buffer'
|
|
2
|
-
import { existsSync,
|
|
3
|
-
import { dirname, extname,
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, extname, join, posix, relative, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
|
-
import {
|
|
5
|
+
import { brotliDecompressSync } from 'node:zlib'
|
|
6
6
|
|
|
7
|
-
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
7
|
+
import { Bundle, isValidRepoField } from '@exodus/stasis-core/bundle'
|
|
8
8
|
import { Lockfile } from '@exodus/stasis-core/lockfile'
|
|
9
|
-
import { scan } from '../scan.js'
|
|
9
|
+
import { importsTypescriptByOutputName, scan } from '../scan.js'
|
|
10
10
|
import { createFieldResolver, resolveConditions } from '../resolve-fields.js'
|
|
11
|
+
import { discoverTsconfig, isDir, loadTsconfigPaths, packageTsconfigPaths } from '../resolve-typescript.js'
|
|
12
|
+
import { createMetroResolver } from '../metro-resolver.js'
|
|
13
|
+
import { pinInstalledCommits } from '../installed-commits.js'
|
|
11
14
|
import { State } from '@exodus/stasis-core/state'
|
|
12
|
-
import { brotliOptions } from '@exodus/stasis-core/brotli'
|
|
13
15
|
import { sha512integrity } from '@exodus/stasis-core/state-util'
|
|
14
|
-
import {
|
|
16
|
+
import { checkoutCommit, detectRepo, findPackageMetadata, normalizeEntries, packageType, parseGithubRepository, readJson, readModuleManifest, readPackageJson, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util'
|
|
17
|
+
import { EMPTY_MODULE_PATH, RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, hasNodeModulesSegment, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPathWithin, isPodspec, isSkippedNativeWalkDir, moduleFileKey, moduleInfo, parseResourcesOption, posixPathEscapes, refineNativeCapture, relativeEscapes, splitNodeModulesPath, toPosix, withMetadataOf } from '@exodus/stasis-core/util'
|
|
18
|
+
import { diskHost } from '@exodus/stasis-core/host'
|
|
19
|
+
import { parseSoldeerLockfile } from '@preventive/lockfile/soldeer.js'
|
|
15
20
|
import {
|
|
21
|
+
SOLIDITY_PACKAGE_MANIFESTS,
|
|
22
|
+
SOLIDITY_ROOT_MANIFESTS,
|
|
16
23
|
buildSolidityTree,
|
|
17
24
|
collectSolidityFilesFromDisk,
|
|
18
|
-
|
|
25
|
+
discoverSolidityConfig,
|
|
26
|
+
expandSolidityEntries,
|
|
19
27
|
} from '../loaders/solidity.js'
|
|
28
|
+
import { decodeUtf8 } from '../loaders/solidity-ownership.js'
|
|
20
29
|
import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js'
|
|
21
|
-
import {
|
|
30
|
+
import { boundaryOf, collectRustBundle, withinRealDir } from '../loaders/rust.js'
|
|
31
|
+
import { createCargoContext } from '../loaders/cargo.js'
|
|
22
32
|
import {
|
|
23
33
|
bucketizePhpSources,
|
|
24
34
|
buildPhpTree,
|
|
25
35
|
collectPhpFilesFromDisk,
|
|
26
36
|
loadComposerAutoload,
|
|
37
|
+
loadComposerPackages,
|
|
27
38
|
loadLaravelProviderFiles,
|
|
28
39
|
} from '../loaders/php.js'
|
|
40
|
+
import { DEFAULT_BUNDLE_FILE, bundledSummary, packagesLabel, writeBundle, writeFile } from './output.js'
|
|
29
41
|
|
|
30
|
-
const JS_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
|
|
42
|
+
const JS_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.jsx', '.tsx'])
|
|
31
43
|
const BASH_EXTS = new Set(['.sh', '.bash'])
|
|
32
44
|
const RUST_EXTS = new Set(['.rs'])
|
|
33
45
|
|
|
34
|
-
// Fallback
|
|
35
|
-
// with a name+version can be found by walking up from any of the bundled
|
|
36
|
-
// files. Bundle.parse requires every workspace/module bucket to have
|
|
37
|
-
// both fields, so we have to attest something.
|
|
46
|
+
// Fallback identity for the workspace bucket; Bundle.parse requires every bucket to attest name+version.
|
|
38
47
|
const SOLIDITY_WORKSPACE_NAME = 'solidity-bundle'
|
|
39
48
|
const SOLIDITY_WORKSPACE_VERSION = '0.0.0'
|
|
40
49
|
const SOLIDITY_FORMAT = 'solidity'
|
|
41
50
|
const BASH_WORKSPACE_NAME = 'bash-bundle'
|
|
42
51
|
const BASH_WORKSPACE_VERSION = '0.0.0'
|
|
43
|
-
const
|
|
52
|
+
const SHELL_FORMAT = 'shell'
|
|
44
53
|
|
|
45
54
|
const RUST_WORKSPACE_NAME = 'rust-bundle'
|
|
46
55
|
const RUST_WORKSPACE_VERSION = '0.0.0'
|
|
@@ -50,50 +59,11 @@ const PHP_WORKSPACE_NAME = 'php-bundle'
|
|
|
50
59
|
const PHP_WORKSPACE_VERSION = '0.0.0'
|
|
51
60
|
const PHP_FORMAT = 'php'
|
|
52
61
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// (pkgDir relative to `baseDir`, "." for the project root) or null when no
|
|
56
|
-
// such package.json exists at or above the file.
|
|
57
|
-
function findPackageMetadata(baseDir, fileRelPath) {
|
|
58
|
-
let dir = dirname(fileRelPath)
|
|
59
|
-
while (true) {
|
|
60
|
-
const pkgPath = join(baseDir, dir, 'package.json')
|
|
61
|
-
if (existsSync(pkgPath)) {
|
|
62
|
-
try {
|
|
63
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
64
|
-
if (pkg.name && pkg.version) {
|
|
65
|
-
return { pkgDir: dir, name: pkg.name, version: pkg.version }
|
|
66
|
-
}
|
|
67
|
-
} catch { /* malformed — keep walking */ }
|
|
68
|
-
}
|
|
69
|
-
if (dir === '.' || dir === '/' || dir === '') return null
|
|
70
|
-
const parent = dirname(dir)
|
|
71
|
-
if (parent === dir) return null
|
|
72
|
-
dir = parent
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function normalizeEntries(entries, cwd) {
|
|
77
|
-
const baseDir = resolve(cwd)
|
|
78
|
-
return entries.map((e) => {
|
|
79
|
-
const abs = resolve(cwd, e)
|
|
80
|
-
const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
|
|
81
|
-
// On Windows, `path.relative()` returns an absolute path when `abs` is on
|
|
82
|
-
// a different drive than `baseDir` — that wouldn't start with `..` but
|
|
83
|
-
// still escapes, so reject both forms.
|
|
84
|
-
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error(`Entry escapes baseDir: ${e}`)
|
|
85
|
-
return rel.replace(/^\.\//u, '')
|
|
86
|
-
})
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Deepest directory that is a parent of every file in `paths`, expressed
|
|
90
|
-
// relative to `cwd`. `paths` may be POSIX-relative-to-cwd; entries that
|
|
91
|
-
// escape cwd (e.g. `../deps/X.sol` via remapping) push the result above
|
|
92
|
-
// cwd, in which case the returned path starts with `..`. Returns "." when
|
|
93
|
-
// the outermost directory IS cwd itself.
|
|
62
|
+
// Deepest common parent dir of `paths`, relative to `cwd`; starts with `..` when entries
|
|
63
|
+
// escape cwd (e.g. via remapping), or "." when it is cwd itself.
|
|
94
64
|
export function outermostDir(paths, cwd) {
|
|
95
65
|
if (paths.length === 0) return '.'
|
|
96
|
-
const cwdAbs = posix.resolve(cwd
|
|
66
|
+
const cwdAbs = posix.resolve(toPosix(cwd))
|
|
97
67
|
const absDirs = paths.map((p) => posix.dirname(posix.resolve(cwdAbs, p)))
|
|
98
68
|
const partsList = absDirs.map((d) => d.split('/'))
|
|
99
69
|
const common = []
|
|
@@ -107,205 +77,232 @@ export function outermostDir(paths, cwd) {
|
|
|
107
77
|
return rel === '' ? '.' : rel
|
|
108
78
|
}
|
|
109
79
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return JSON.parse(readFileSync(file, 'utf8'))
|
|
113
|
-
} catch {
|
|
114
|
-
return null
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Split a Soldeer dependency dir name (`<name>-<version>`) into its parts, e.g.
|
|
119
|
-
// `@openzeppelin-contracts-5.0.2` -> { name: '@openzeppelin-contracts',
|
|
120
|
-
// version: '5.0.2' }. Soldeer encodes the version into the directory name, so
|
|
121
|
-
// that's the authoritative identity for a Soldeer package. Falls back to
|
|
122
|
-
// version 0.0.0 when the dir doesn't end in a semver (every bucket must attest
|
|
123
|
-
// a version).
|
|
80
|
+
// Split a Soldeer dep dir `<name>-<version>` (its authoritative identity); falls back to
|
|
81
|
+
// version 0.0.0 when it doesn't end in a semver (every bucket must attest a version).
|
|
124
82
|
function parseSoldeerDir(seg) {
|
|
125
83
|
const m = /^(.+)-(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)$/u.exec(seg)
|
|
126
84
|
return m ? { name: m[1], version: m[2] } : { name: seg, version: '0.0.0' }
|
|
127
85
|
}
|
|
128
86
|
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
|
|
87
|
+
// A url as git fetches from a host: scp's `[user@]host:path`, with no scheme and a colon before its
|
|
88
|
+
// first slash, or a URL of a transport git has itself (`http`, `https`, `git`, `ssh` and its
|
|
89
|
+
// `git+ssh`/`ssh+git` aliases, lowercase, as git spells them), its authority, `[userinfo@]host[:port]`,
|
|
90
|
+
// ending at the first slash after it. Not, then, what a package.json spells GitHub's that git reads
|
|
91
|
+
// another way or not at all: `ssh://git@github.com:owner/name`, whose host is `github.com:owner` to
|
|
92
|
+
// git; `git+https://` or `HTTPS://`, transports git doesn't have; nor `owner/name`, a path, as is any
|
|
93
|
+
// url with no colon before its first slash.
|
|
94
|
+
const GIT_URL = /^(?:[^/]*:(?!\/\/)|(?:https?|git|ssh|git\+ssh|ssh\+git):\/\/(?:[^/@]*@)?[^/:@]*(?::\d*)?\/)/u
|
|
95
|
+
|
|
96
|
+
// The GitHub repository, `owner/name`, a .gitmodules `url` names (parseGithubRepository), else null:
|
|
97
|
+
// none for a host merely holding `github.com` (`https://notgithub.com/o/n`), nor for a url git
|
|
98
|
+
// doesn't fetch as one (GIT_URL), nor for npm's `github:owner/name`, the host `github`'s path to git,
|
|
99
|
+
// whatever an ssh alias or `insteadOf` makes of it, nor one with a `#`, which git keeps in the path
|
|
100
|
+
// it asks for (`owner/name.git#v1`) where npm takes a committish. git's `ssh+git://`, which a
|
|
101
|
+
// package.json doesn't spell, is its `ssh://`.
|
|
102
|
+
const submoduleGithub = (url) => (GIT_URL.test(url) && !/^github:/iu.test(url) && !url.includes('#') ? parseGithubRepository(url.replace(/^ssh\+git:/u, 'ssh:')) : null)
|
|
103
|
+
|
|
104
|
+
// The GitHub ones of `submodules` (readGitmodules), as Map<submodulePath, { name, branch }>.
|
|
105
|
+
function githubSubmodules(submodules) {
|
|
140
106
|
const byPath = new Map()
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const flush = () => {
|
|
145
|
-
if (cur?.path && cur?.url) {
|
|
146
|
-
const name = githubSlug(cur.url)
|
|
147
|
-
if (name) byPath.set(cur.path.replace(/\/+$/u, ''), { name, branch: cur.branch })
|
|
148
|
-
}
|
|
149
|
-
cur = null
|
|
107
|
+
for (const { path, url, branch } of submodules) {
|
|
108
|
+
const name = url && submoduleGithub(url)
|
|
109
|
+
if (name) byPath.set(path, { name, branch })
|
|
150
110
|
}
|
|
151
|
-
for (const raw of text.split('\n')) {
|
|
152
|
-
const line = raw.trim()
|
|
153
|
-
if (line.startsWith('[')) {
|
|
154
|
-
flush()
|
|
155
|
-
cur = line.startsWith('[submodule') ? {} : null
|
|
156
|
-
continue
|
|
157
|
-
}
|
|
158
|
-
if (!cur) continue
|
|
159
|
-
const eq = line.indexOf('=')
|
|
160
|
-
if (eq === -1) continue
|
|
161
|
-
const key = line.slice(0, eq).trim()
|
|
162
|
-
if (key === 'path' || key === 'url' || key === 'branch') cur[key] = line.slice(eq + 1).trim()
|
|
163
|
-
}
|
|
164
|
-
flush()
|
|
165
111
|
return byPath
|
|
166
112
|
}
|
|
167
113
|
|
|
168
|
-
|
|
114
|
+
// The root of the GitHub repository `github` at `commit`, where both are valid; else undefined.
|
|
115
|
+
const repoRootAt = (github, commit) => (isValidRepoField('github', github) && isValidRepoField('commit', commit) ? { github, directory: '', commit } : undefined)
|
|
116
|
+
|
|
117
|
+
// The text of `rel`, a file of the project root, read as `ownership` vouches for it; null where it
|
|
118
|
+
// refuses it, nothing is there, or it isn't UTF-8.
|
|
119
|
+
function ownedText(baseDir, rel, ownership, host) {
|
|
120
|
+
const { reason, real, outside } = ownership.of(rel)
|
|
121
|
+
if (reason || real === null || outside) return null
|
|
169
122
|
try {
|
|
170
|
-
|
|
123
|
+
const buf = readRegularFileOrNull(join(host.realpath(baseDir), real), rel, host)
|
|
124
|
+
return buf === null ? null : decodeUtf8(buf, rel)
|
|
171
125
|
} catch {
|
|
172
126
|
return null
|
|
173
127
|
}
|
|
174
128
|
}
|
|
175
129
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
130
|
+
// Each git dependency the root's soldeer.lock installs from GitHub, by its folder under
|
|
131
|
+
// `dependencies/` (`<name>-<version>`, as Soldeer names it): the repository's root at the lockfile's
|
|
132
|
+
// `rev`, which Soldeer checks out there. None where the lockfile can't be read or parsed: it is
|
|
133
|
+
// metadata, which never stops a build.
|
|
134
|
+
function soldeerGitRepos(baseDir, ownership, host) {
|
|
135
|
+
const text = ownedText(baseDir, 'soldeer.lock', ownership, host)
|
|
136
|
+
let lock = null
|
|
137
|
+
try {
|
|
138
|
+
lock = text === null ? null : parseSoldeerLockfile(text)
|
|
139
|
+
} catch {
|
|
140
|
+
// Not one Soldeer writes: nothing is recorded from it.
|
|
141
|
+
}
|
|
142
|
+
const repos = new Map()
|
|
143
|
+
for (const dep of Object.values(lock?.dependencies ?? {})) {
|
|
144
|
+
const repo = dep.type === 'git' ? repoRootAt(parseGithubRepository(dep.git), dep.rev) : undefined
|
|
145
|
+
if (repo !== undefined) repos.set(`${dep.name}-${dep.version}`, repo)
|
|
146
|
+
}
|
|
147
|
+
return repos
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Classify a Solidity file's dep bucket: Soldeer (`dependencies/<name>-<version>/`) or a
|
|
151
|
+
// github submodule (`lib/`, via the `.gitmodules` `ownership` read), else null to defer to the
|
|
152
|
+
// node_modules/workspace logic. `ownership.assert` vets a package.json path before it is read
|
|
153
|
+
// through `host`. A bucket's `repo` is the GitHub repository it is checked out from, at its root, at
|
|
154
|
+
// the commit its checkout's HEAD is at, as the bundle's own `repo` reads one: a submodule's, from
|
|
155
|
+
// the GitHub repository its .gitmodules url names; a Soldeer git dependency's, where it is its
|
|
156
|
+
// soldeer.lock `rev` (soldeerGitRepos), as Soldeer holds a checkout to the lockfile before taking it
|
|
157
|
+
// for installed: a folder left at another commit since the lockfile changed, or no checkout, records
|
|
158
|
+
// none.
|
|
159
|
+
function makeSolidityClassifier(baseDir, ownership, host) {
|
|
160
|
+
const submodules = githubSubmodules(ownership.submodules)
|
|
161
|
+
const check = ownership.assert
|
|
162
|
+
const versions = new Map() // a submodule's package.json version, read once
|
|
163
|
+
const commits = new Map() // a bucket's checkout's commit, read once
|
|
164
|
+
const commitOf = (dir) => {
|
|
165
|
+
if (!commits.has(dir)) commits.set(dir, checkoutCommit(join(baseDir, dir), host))
|
|
166
|
+
return commits.get(dir)
|
|
167
|
+
}
|
|
168
|
+
let soldeer // soldeerGitRepos, read at the first Soldeer file
|
|
185
169
|
return (path) => {
|
|
186
170
|
if (path.startsWith('dependencies/')) {
|
|
187
171
|
const seg = path.slice('dependencies/'.length).split('/')[0]
|
|
188
172
|
if (seg) {
|
|
189
173
|
const { name, version } = parseSoldeerDir(seg)
|
|
190
|
-
|
|
174
|
+
const bucketDir = `dependencies/${seg}`
|
|
175
|
+
soldeer ??= soldeerGitRepos(baseDir, ownership, host)
|
|
176
|
+
const locked = soldeer.get(seg)
|
|
177
|
+
const repo = locked !== undefined && commitOf(bucketDir) === locked.commit ? locked : undefined
|
|
178
|
+
return { bucketDir, name, version, ecosystem: 'soldeer', ...(repo === undefined ? {} : { repo }) }
|
|
191
179
|
}
|
|
192
180
|
}
|
|
193
181
|
for (const [sub, { name, branch }] of submodules) {
|
|
194
182
|
if (path === sub || path.startsWith(`${sub}/`)) {
|
|
195
|
-
|
|
196
|
-
|
|
183
|
+
if (!versions.has(sub)) versions.set(sub, readPackageJson(baseDir, moduleFileKey(sub, 'package.json'), { strict: true, check, host })?.version)
|
|
184
|
+
const repo = repoRootAt(name, commitOf(sub))
|
|
185
|
+
return { bucketDir: sub, name, version: versions.get(sub) ?? branch ?? '0.0.0', ecosystem: 'github', ...(repo === undefined ? {} : { repo }) }
|
|
197
186
|
}
|
|
198
187
|
}
|
|
199
188
|
return null
|
|
200
189
|
}
|
|
201
190
|
}
|
|
202
191
|
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (at !== -1) {
|
|
212
|
-
const rest = text.slice(at)
|
|
213
|
-
const next = rest.slice(1).search(/^\[/mu) // start of the table after [package]
|
|
214
|
-
body = next === -1 ? rest : rest.slice(0, next + 1)
|
|
215
|
-
}
|
|
216
|
-
const name = /^\s*name\s*=\s*"([^"]+)"/mu.exec(body)?.[1]
|
|
217
|
-
const version = /^\s*version\s*=\s*"([^"]+)"/mu.exec(body)?.[1]
|
|
218
|
-
return name ? { name, version: version ?? '0.0.0' } : null
|
|
192
|
+
// A vendored crate's ecosystem, by where `cargo vendor` copied it from (vendoredFrom): a registry's
|
|
193
|
+
// copy is `cargo`, which `stasis audit` asks OSV about as a crates.io crate's; a git checkout's is
|
|
194
|
+
// `cargo-git`, and a copy with no `.cargo-checksum.json`, which could be either, `cargo-unknown`.
|
|
195
|
+
// Neither is ever sent to a public advisory database: a private git dependency's name has no place
|
|
196
|
+
// there, and a crates.io crate of the same name isn't it.
|
|
197
|
+
const vendoredEcosystem = (from) => {
|
|
198
|
+
if (from === 'registry') return 'cargo'
|
|
199
|
+
return from === 'git' ? 'cargo-git' : 'cargo-unknown'
|
|
219
200
|
}
|
|
220
201
|
|
|
221
|
-
// Classify a
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
// workspace
|
|
225
|
-
|
|
202
|
+
// Classify a Rust file by the nearest Cargo.toml `[package]`: a `cargo vendor`ed crate under the
|
|
203
|
+
// vendor dir (`vendor/<dir>/`, or where .cargo/config.toml points) is a dependency (tagged
|
|
204
|
+
// `cargo`, `cargo-git` or `cargo-unknown`: see vendoredEcosystem); any other package (the crate
|
|
205
|
+
// itself, a workspace member reached through a `path` dependency) is first-party, so no
|
|
206
|
+
// ecosystem. Null (no manifest above the file) defers to the package.json/placeholder logic.
|
|
207
|
+
function makeRustClassifier(cargo) {
|
|
226
208
|
return (path) => {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
209
|
+
const pkg = cargo.packageInfo(path)
|
|
210
|
+
if (!pkg) return null
|
|
211
|
+
return { bucketDir: pkg.dir, name: pkg.name, version: pkg.version, ecosystem: cargo.isVendored(path) ? vendoredEcosystem(cargo.vendoredFrom(path)) : undefined }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A file's key inside its bucket: the inverse of moduleFileKey.
|
|
216
|
+
const fileInBucket = (bucketDir, path) => (bucketDir === '.' ? path : path.slice(bucketDir.length + 1))
|
|
217
|
+
|
|
218
|
+
// A file of the bundle root (`realBase` its real path), its bytes, by project-relative path, or
|
|
219
|
+
// null when it isn't a regular file there (a manifest the context saw may have gone since; a FIFO
|
|
220
|
+
// would hang a read) or, with `within` a directory, doesn't really lie in it (a vendored crate's
|
|
221
|
+
// manifest linked to a project file).
|
|
222
|
+
function readFileWithinBase(baseDir, realBase, rel, within) {
|
|
223
|
+
try {
|
|
224
|
+
assertRealPathWithinBase(realBase, baseDir, rel)
|
|
225
|
+
if (!withinRealDir(baseDir, rel, within, { who: 'loader.cargo' })) return null
|
|
226
|
+
if (!statSync(join(baseDir, rel)).isFile()) return null
|
|
227
|
+
return readFileSync(join(baseDir, rel))
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (err.code === 'ENOENT' || err.code === 'EISDIR') return null
|
|
230
|
+
throw err
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Project-relative paths in `sources` whose on-disk file carries a POSIX execute bit -- the
|
|
235
|
+
// `executable` list both artifacts record. The State-driven path derives this in addFile; the
|
|
236
|
+
// static builders never touch a State, so they stat here.
|
|
237
|
+
function executableSources(baseDir, sources, host) {
|
|
238
|
+
const executable = new Set()
|
|
239
|
+
for (const path of sources.keys()) {
|
|
240
|
+
if (isExecutableFile(join(baseDir, path), host)) executable.add(path)
|
|
241
|
+
}
|
|
242
|
+
return executable
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Bundles must be self-contained: throw on a missing entry or an unresolved `noun` (import, script,
|
|
246
|
+
// module) of a `lang` bundle -- `missing`'s { spec, from }, with the `reason` it was refused if given.
|
|
247
|
+
function assertSelfContained(lang, noun, entries, sources, missing) {
|
|
248
|
+
const issues = []
|
|
249
|
+
for (const entry of entries) {
|
|
250
|
+
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
251
|
+
}
|
|
252
|
+
for (const { spec, from, reason } of missing) {
|
|
253
|
+
issues.push(`Unresolved ${noun}: ${spec} from ${from}${reason ? ` (refused: ${reason})` : ''}`)
|
|
254
|
+
}
|
|
255
|
+
if (issues.length > 0) {
|
|
256
|
+
throw new Error(`${lang} bundle has unresolved ${noun}s:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
233
257
|
}
|
|
234
258
|
}
|
|
235
259
|
|
|
236
|
-
// Assemble a scope
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// a
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// with name+version: node_modules files into `node_modules/<pkg>` buckets,
|
|
245
|
-
// workspace files into their package dir, and anything with no discoverable
|
|
246
|
-
// package.json into the "." bucket with the given placeholder identity (the
|
|
247
|
-
// Bundle layout requires every bucket to attest a name+version). A file under
|
|
248
|
-
// node_modules whose nearest package.json is the workspace root is rejected —
|
|
249
|
-
// that hides a misconfigured dependency rather than silently mislabeling it.
|
|
250
|
-
//
|
|
251
|
-
// Unlike the wildcard "*" JS bundles use, these formats don't vary by Node
|
|
252
|
-
// resolution condition, so every edge lands under the one `conditionKey`.
|
|
253
|
-
//
|
|
254
|
-
// Dependency buckets (those under node_modules) are tagged `ecosystem: 'npm'`:
|
|
255
|
-
// node_modules is npm's install layout, so that's where the package came from,
|
|
256
|
-
// regardless of the bundle's language — a Solidity or Bash import resolved out
|
|
257
|
-
// of node_modules is an npm package all the same. This mirrors State's own
|
|
258
|
-
// node_modules tagging (see stasis-core state.js) and lets consumers tell deps
|
|
259
|
-
// apart from the workspace's own packages; the workspace/`.` bucket carries no
|
|
260
|
-
// ecosystem. (PHP's Composer `vendor/` deps are tagged `composer` separately, by
|
|
261
|
-
// the PHP bucketizer.)
|
|
262
|
-
//
|
|
263
|
-
// `classifyDep(path)` is an optional language-specific hook: when it returns a
|
|
264
|
-
// { bucketDir, name, version, ecosystem } record the file is placed there
|
|
265
|
-
// directly, which is how non-node_modules ecosystems are attributed (e.g.
|
|
266
|
-
// Solidity's Soldeer `dependencies/` and github submodules). Returning null (or
|
|
267
|
-
// omitting the hook) defers to the node_modules/package.json/workspace logic.
|
|
268
|
-
// `format` tags every file uniformly (the single-language non-JS callers); pass
|
|
269
|
-
// `formats` (a Map<path, format>) instead to tag per file, as the JS resolver path
|
|
270
|
-
// does (a graph mixes module / commonjs / *-typescript / json). `resolutions`
|
|
271
|
-
// values may be a target string (a flat edge) or a Map<platform, target> (a `--metro`
|
|
272
|
-
// edge that resolves differently per platform) -- both round-trip through the bundle's
|
|
273
|
-
// recursive serializer untouched.
|
|
260
|
+
// Assemble a full-scope code Bundle shared by the non-JS bundlers. Files are bucketed by
|
|
261
|
+
// nearest package.json (node_modules -> `npm`-tagged bucket, workspace -> its dir, none ->
|
|
262
|
+
// "." with the placeholder identity); a node_modules file whose nearest package.json is the
|
|
263
|
+
// workspace root is rejected, not mislabeled; `packageOf(path)` finds that package.json
|
|
264
|
+
// (packageLookup by default, which walks past a malformed one). `classifyDep(path)` optionally
|
|
265
|
+
// places a file directly (non-node_modules ecosystems like Soldeer/github); null defers. `format` tags
|
|
266
|
+
// every file; `formats` (Map<path,format>) overrides it per file. `resolutions` values are
|
|
267
|
+
// a flat target string or a Map<platform,target>; both round-trip untouched.
|
|
274
268
|
function assembleCodeBundle({
|
|
275
|
-
baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep,
|
|
269
|
+
baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep, host,
|
|
270
|
+
packageOf = packageLookup(baseDir, { host }),
|
|
276
271
|
}) {
|
|
277
272
|
const modules = new Map()
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
273
|
+
// `identity`'s name, version, ecosystem, repo and vendored (moduleInfo takes those alone); a later
|
|
274
|
+
// file's adds the vendored package it is in.
|
|
275
|
+
const ensureBucket = (dir, identity) => {
|
|
276
|
+
const bucket = modules.get(dir)
|
|
277
|
+
modules.set(dir, bucket === undefined ? moduleInfo({ ...identity, files: Object.create(null) }) : withMetadataOf(bucket, identity))
|
|
284
278
|
return modules.get(dir)
|
|
285
279
|
}
|
|
286
280
|
|
|
287
281
|
for (const [path, content] of sources) {
|
|
288
282
|
const dep = classifyDep?.(path)
|
|
289
283
|
if (dep) {
|
|
290
|
-
ensureBucket(dep.bucketDir, dep.
|
|
291
|
-
.files[path.slice(dep.bucketDir.length + 1)] = content
|
|
284
|
+
ensureBucket(dep.bucketDir, dep).files[fileInBucket(dep.bucketDir, path)] = content
|
|
292
285
|
continue
|
|
293
286
|
}
|
|
294
|
-
const meta =
|
|
287
|
+
const meta = packageOf(path)
|
|
295
288
|
const inNodeModules = splitNodeModulesPath(path) !== null
|
|
296
289
|
if (meta) {
|
|
297
|
-
if (inNodeModules && !meta.pkgDir
|
|
290
|
+
if (inNodeModules && !hasNodeModulesSegment(meta.pkgDir)) {
|
|
298
291
|
throw new Error(`No package.json with name+version found for ${path}`)
|
|
299
292
|
}
|
|
300
|
-
|
|
301
|
-
const bucketEcosystem = meta.pkgDir.includes('node_modules') ? 'npm' : undefined
|
|
302
|
-
ensureBucket(meta.pkgDir, meta.name, meta.version, bucketEcosystem).files[rel] = content
|
|
293
|
+
ensureBucket(meta.pkgDir, meta).files[fileInBucket(meta.pkgDir, path)] = content
|
|
303
294
|
} else {
|
|
304
295
|
if (inNodeModules) throw new Error(`No package.json with name+version found for ${path}`)
|
|
305
|
-
ensureBucket('.', workspaceName, workspaceVersion).files[path] = content
|
|
296
|
+
ensureBucket('.', { name: workspaceName, version: workspaceVersion }).files[path] = content
|
|
306
297
|
}
|
|
307
298
|
}
|
|
308
299
|
|
|
300
|
+
return codeBundle({ baseDir, entries, sources, modules, resolutions, format, formats, conditionKey, host })
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// A full-scope code Bundle of `sources`, bucketed as `modules`: `format` tags every file, `formats`
|
|
304
|
+
// (Map<path,format>) overrides it per file, and the `resolutions` edges are keyed under `conditionKey`.
|
|
305
|
+
function codeBundle({ baseDir, entries, sources, modules, resolutions, format, formats, conditionKey, host }) {
|
|
309
306
|
const formatsMap = new Map()
|
|
310
307
|
for (const path of sources.keys()) formatsMap.set(path, formats?.get(path) ?? format)
|
|
311
308
|
|
|
@@ -313,75 +310,163 @@ function assembleCodeBundle({
|
|
|
313
310
|
for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
|
|
314
311
|
const imports = new Map([[conditionKey, importsForKey]])
|
|
315
312
|
|
|
313
|
+
// Executable bits, straight off disk (a synthetic source with no file there is simply not
|
|
314
|
+
// executable). Shell bundles lean on this most: `stasis extract` puts the +x back on the scripts.
|
|
315
|
+
const executable = executableSources(baseDir, sources, host)
|
|
316
|
+
|
|
317
|
+
// Attribute files to the `bundle` consumer (static builders skip State's per-file tagging).
|
|
316
318
|
return new Bundle({
|
|
317
319
|
config: { scope: 'full' },
|
|
318
320
|
entries: new Set(entries),
|
|
319
321
|
modules,
|
|
320
322
|
formats: formatsMap,
|
|
321
323
|
imports,
|
|
322
|
-
|
|
324
|
+
executable,
|
|
325
|
+
}).withReason('bundle')
|
|
323
326
|
}
|
|
324
327
|
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
328
|
+
// Files never carried, whatever reads them: `.env` files, and Hardhat's config, which is code
|
|
329
|
+
// (both however they're cased).
|
|
330
|
+
const neverCarried = (rel) => isDotEnvFile(rel) || posix.basename(rel).toLowerCase().startsWith('hardhat.config.')
|
|
331
|
+
|
|
332
|
+
// findPackageMetadata (with `options`) once per directory, the only thing its answer depends on.
|
|
333
|
+
function packageLookup(baseDir, options) {
|
|
334
|
+
const byDir = new Map()
|
|
335
|
+
return (path) => {
|
|
336
|
+
const dir = posix.dirname(path)
|
|
337
|
+
if (!byDir.has(dir)) byDir.set(dir, findPackageMetadata(baseDir, path, options))
|
|
338
|
+
return byDir.get(dir)
|
|
332
339
|
}
|
|
333
|
-
|
|
334
|
-
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// The build-description files of a Solidity bundle (--manifests), as Map<path, text>, sorted:
|
|
343
|
+
// `configFiles` (what discoverSolidityConfig read, whatever they're called), each of which must be
|
|
344
|
+
// carried -- one outside the root, neverCarried, refused or gone is an error, as the bundle
|
|
345
|
+
// couldn't reproduce the resolution without it -- plus the SOLIDITY_*_MANIFESTS that exist, for the
|
|
346
|
+
// root and for each package dir `classifyDep`/`packageOf` places a bundled source in, but none
|
|
347
|
+
// whose path `ownership` refuses (see solidityOwnership). Carried as written: whatever they hold
|
|
348
|
+
// (an RPC URL with its API key, an Etherscan key, a URL's credentials) is in the bundle too, as
|
|
349
|
+
// with --package-json. Read through `host`.
|
|
350
|
+
function solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership, host }) {
|
|
351
|
+
const realBase = host.realpath(baseDir)
|
|
352
|
+
// `{ text }`, or `{ why }` it can't be carried (null: nothing is there). Read by the real path
|
|
353
|
+
// `ownership` resolved `rel` to, so what's carried is the file it vouched for.
|
|
354
|
+
const carry = (rel) => {
|
|
355
|
+
const { reason, real, outside } = ownership.of(rel)
|
|
356
|
+
if (reason) return { why: reason }
|
|
357
|
+
if (real === null) return { why: null }
|
|
358
|
+
if (outside) throw new Error(`Refusing to follow symlink escaping bundle root: ${rel} -> ${resolve(realBase, real)}`)
|
|
359
|
+
const buf = readRegularFileOrNull(join(realBase, real), rel, host)
|
|
360
|
+
if (buf === null) return { why: null } // a directory
|
|
361
|
+
return { text: decodeUtf8(buf, rel) }
|
|
362
|
+
}
|
|
363
|
+
const unreproducible = (rel, why) => new Error(`--manifests can't carry ${rel}, which the Solidity resolution read: ${why}`)
|
|
364
|
+
const out = new Map()
|
|
365
|
+
for (const rel of configFiles) {
|
|
366
|
+
// Absolute: the OS couldn't give its real path (see projectRelative).
|
|
367
|
+
if (posix.isAbsolute(rel)) throw unreproducible(rel, "its real path can't be resolved")
|
|
368
|
+
if (posixPathEscapes(rel)) throw unreproducible(rel, 'it lies outside the bundle root')
|
|
369
|
+
if (neverCarried(rel)) throw unreproducible(rel, '.env files and hardhat.config.* are never carried')
|
|
370
|
+
if (sources.has(rel)) continue
|
|
371
|
+
const { text, why } = carry(rel)
|
|
372
|
+
if (text === undefined) throw unreproducible(rel, why ?? 'it is gone')
|
|
373
|
+
out.set(rel, text)
|
|
374
|
+
}
|
|
375
|
+
const optional = new Set(SOLIDITY_ROOT_MANIFESTS)
|
|
376
|
+
for (const path of sources.keys()) {
|
|
377
|
+
const dirs = [classifyDep(path)?.bucketDir, packageOf(path)?.pkgDir].filter((d) => d !== undefined)
|
|
378
|
+
for (const dir of dirs) for (const name of SOLIDITY_PACKAGE_MANIFESTS) optional.add(moduleFileKey(dir, name))
|
|
379
|
+
}
|
|
380
|
+
for (const rel of optional) {
|
|
381
|
+
if (sources.has(rel) || out.has(rel)) continue
|
|
382
|
+
const { text, why } = carry(rel)
|
|
383
|
+
if (why) console.warn(`[stasis] Not carrying ${rel}: ${why}`)
|
|
384
|
+
if (text !== undefined) out.set(rel, text)
|
|
385
|
+
}
|
|
386
|
+
return new Map([...out.keys()].toSorted().map((rel) => [rel, out.get(rel)]))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// An entry `stasis bundle` takes for a directory: one that is, or an extensionless path that
|
|
390
|
+
// doesn't exist (a project without `script/` still bundles with `src test script`).
|
|
391
|
+
export const isDirEntry = (abs, host = diskHost) => isDir(abs, host) || (extname(abs) === '' && host.stat(abs) === null)
|
|
392
|
+
|
|
393
|
+
// Whether `entry` (resolved against `cwd`) is a Solidity bundle's: a .sol file or a directory entry.
|
|
394
|
+
export const isSolidityEntry = (entry, cwd = process.cwd(), host = diskHost) => entry.endsWith('.sol') || isDirEntry(resolve(cwd, entry), host)
|
|
395
|
+
|
|
396
|
+
// What's wrong with `entries`' directory entries (resolved against `cwd`), or null: a directory
|
|
397
|
+
// entry stands for the .sol files under it, so it goes with Solidity entries only; and entries
|
|
398
|
+
// that are all missing extensionless paths are a mistyped file, not a project without those dirs.
|
|
399
|
+
// `fetched` false: `host` is an empty tree standing in for one not fetched yet, which can't say
|
|
400
|
+
// what is missing.
|
|
401
|
+
export function directoryEntryError(entries, cwd = process.cwd(), host = diskHost, { fetched = true } = {}) {
|
|
402
|
+
const dirs = entries.filter((e) => isDirEntry(resolve(cwd, e), host))
|
|
403
|
+
const absent = fetched ? dirs.filter((e) => host.stat(resolve(cwd, e)) === null) : []
|
|
404
|
+
if (absent.length > 0 && absent.length === entries.length) return `no such file or directory: ${absent[0]}`
|
|
405
|
+
if (dirs.length === 0 || entries.every((e) => e.endsWith('.sol') || dirs.includes(e))) return null
|
|
406
|
+
return absent.length > 0
|
|
407
|
+
? `no such file or directory: ${absent[0]}`
|
|
408
|
+
: `a directory entry is only supported for Solidity bundles (it stands for the .sol files under it): ${dirs[0]}`
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Build an in-memory Bundle from entry .sol files and directories (a directory stands for the .sol
|
|
412
|
+
// files under it, as forge's src/test/script dirs and Hardhat's contracts dir do; a missing or
|
|
413
|
+
// empty one is skipped). Imports resolve through the remappings the project's build uses
|
|
414
|
+
// (discoverSolidityConfig: forge's discovery for a Foundry project); `mappingFile`
|
|
415
|
+
// (foundry.toml/remappings.txt) pins them to exactly what it lists. An import is refused when it
|
|
416
|
+
// reaches a non-.sol file or leaves the root, or when a dependency's reaches the project's own
|
|
417
|
+
// files. Config files are read, and bundled only with `manifests` (see solidityManifests). `env`
|
|
418
|
+
// supplies FOUNDRY_PROFILE / FOUNDRY_REMAPPINGS, reported when they apply. The project is read
|
|
419
|
+
// through `host`.
|
|
420
|
+
export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappingFile, manifests = false, env = process.env, host = diskHost } = {}) {
|
|
421
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
422
|
+
throw new Error('buildSolidityBundle: at least one entry .sol file or directory is required')
|
|
335
423
|
}
|
|
336
424
|
|
|
337
425
|
const baseDir = resolve(cwd)
|
|
338
|
-
const remappings = mappingFile ? await readRemappingsFile(resolve(cwd, mappingFile)) : []
|
|
339
426
|
const normalized = normalizeEntries(entries, cwd)
|
|
427
|
+
for (const e of normalized) {
|
|
428
|
+
if (!isSolidityEntry(e, baseDir, host)) throw new Error(`buildSolidityBundle: not a .sol file or directory: ${e}`)
|
|
429
|
+
}
|
|
430
|
+
const expanded = expandSolidityEntries(baseDir, normalized, host)
|
|
340
431
|
|
|
341
|
-
const
|
|
342
|
-
|
|
432
|
+
const { remappings, libs, ownership, files: configFiles, envUsed } = discoverSolidityConfig(baseDir, { mappingFile, env, host })
|
|
433
|
+
// The bundle doesn't record the environment, so say when it shaped the resolution.
|
|
434
|
+
if (envUsed.length > 0) console.warn(`[stasis] Solidity imports resolved with ${envUsed.join(', ')} from the environment`)
|
|
435
|
+
const sources = collectSolidityFilesFromDisk(baseDir, expanded, remappings, { libs, ownership, host })
|
|
436
|
+
const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir, libs, ownership, host })
|
|
343
437
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (issues.length > 0) {
|
|
356
|
-
throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
438
|
+
assertSelfContained('Solidity', 'import', expanded, sources, missing)
|
|
439
|
+
|
|
440
|
+
const classifyDep = makeSolidityClassifier(baseDir, ownership, host)
|
|
441
|
+
const packageOf = packageLookup(baseDir, { strict: true, check: ownership.assert, host })
|
|
442
|
+
const bundled = new Map(sources)
|
|
443
|
+
const formats = new Map()
|
|
444
|
+
if (manifests) {
|
|
445
|
+
for (const [path, text] of solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership, host })) {
|
|
446
|
+
bundled.set(path, text)
|
|
447
|
+
formats.set(path, posix.basename(path) === 'package.json' ? 'json' : 'resource')
|
|
448
|
+
}
|
|
357
449
|
}
|
|
358
450
|
|
|
359
451
|
return assembleCodeBundle({
|
|
360
452
|
baseDir,
|
|
361
|
-
entries:
|
|
362
|
-
sources,
|
|
453
|
+
entries: expanded,
|
|
454
|
+
sources: bundled,
|
|
363
455
|
resolutions,
|
|
364
456
|
workspaceName: SOLIDITY_WORKSPACE_NAME,
|
|
365
457
|
workspaceVersion: SOLIDITY_WORKSPACE_VERSION,
|
|
366
458
|
format: SOLIDITY_FORMAT,
|
|
459
|
+
formats,
|
|
367
460
|
conditionKey: 'solidity',
|
|
368
|
-
classifyDep
|
|
461
|
+
classifyDep,
|
|
462
|
+
packageOf,
|
|
463
|
+
host,
|
|
369
464
|
})
|
|
370
465
|
}
|
|
371
466
|
|
|
372
|
-
// Build
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
// - a .sh/.bash reference whose path lands INSIDE the bundle root
|
|
376
|
-
// (`source ./lib.sh`, `dir/x.sh`) but resolves to no bundled script is
|
|
377
|
-
// fatal — a self-contained bundle can't carry a dangling local source —
|
|
378
|
-
// as is a missing entry.
|
|
379
|
-
// - everything else is best-effort and dropped: PATH commands (`grep`,
|
|
380
|
-
// `node`), dynamic `$VAR` paths, extensionless sources, absolute system
|
|
381
|
-
// paths (`source /etc/profile.d/x.sh`), and `../` paths that escape the
|
|
382
|
-
// root — none can live in the bundle.
|
|
383
|
-
// - imports live under a dedicated "bash" condition key (like "solidity"),
|
|
384
|
-
// not the JS-bundle wildcard "*".
|
|
467
|
+
// Build an in-memory Bundle from entry .sh/.bash files by walking the source/exec graph.
|
|
468
|
+
// A local `source` inside the bundle root that resolves to no bundled script is fatal;
|
|
469
|
+
// external refs (PATH commands, `$VAR`, absolute, `../`-escaping) are dropped.
|
|
385
470
|
export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
|
|
386
471
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
387
472
|
throw new Error('buildBashBundle: at least one entry .sh/.bash file is required')
|
|
@@ -396,21 +481,8 @@ export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
396
481
|
const sources = await collectBashFilesFromDisk(baseDir, normalized)
|
|
397
482
|
const { resolutions, missing } = buildBashTree(sources)
|
|
398
483
|
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
// External/best-effort references (commands, $VAR paths, extensionless,
|
|
402
|
-
// absolute, or `../`-escaping sources) are left out by buildBashTree and
|
|
403
|
-
// never reach `missing`.
|
|
404
|
-
const issues = []
|
|
405
|
-
for (const entry of normalized) {
|
|
406
|
-
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
407
|
-
}
|
|
408
|
-
for (const { spec, from } of missing) {
|
|
409
|
-
issues.push(`Unresolved script: ${spec} from ${from}`)
|
|
410
|
-
}
|
|
411
|
-
if (issues.length > 0) {
|
|
412
|
-
throw new Error(`Bash bundle has unresolved scripts:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
413
|
-
}
|
|
484
|
+
// Self-contained (external refs never reach `missing`).
|
|
485
|
+
assertSelfContained('Bash', 'script', normalized, sources, missing)
|
|
414
486
|
|
|
415
487
|
return assembleCodeBundle({
|
|
416
488
|
baseDir,
|
|
@@ -419,20 +491,27 @@ export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
419
491
|
resolutions,
|
|
420
492
|
workspaceName: BASH_WORKSPACE_NAME,
|
|
421
493
|
workspaceVersion: BASH_WORKSPACE_VERSION,
|
|
422
|
-
format:
|
|
423
|
-
conditionKey:
|
|
494
|
+
format: SHELL_FORMAT,
|
|
495
|
+
conditionKey: SHELL_FORMAT,
|
|
424
496
|
})
|
|
425
497
|
}
|
|
426
498
|
|
|
427
|
-
// Build
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
499
|
+
// Build an in-memory Bundle from entry .rs files (crate roots) by walking `mod` declarations and
|
|
500
|
+
// references to in-tree crates (the package's own lib, Cargo `path` deps, `cargo vendor`ed
|
|
501
|
+
// crates). An unresolvable unconditional `mod` is fatal; path edges (`crate::`/`self::`/`super::`/
|
|
502
|
+
// relative `use`s) are recorded best-effort and never widen the file set. Registry deps that
|
|
503
|
+
// aren't vendored can't be bundled: they're reported, with a `cargo vendor` hint when there is no
|
|
504
|
+
// `vendor/` dir at all. Each crate's features are resolved from the manifests (Cargo.toml +
|
|
505
|
+
// Cargo.lock) the way `cargo build` of the entries' packages would, so `#[cfg(feature = …)]` code
|
|
506
|
+
// that is off stays out; `cargo` takes them from `cargo metadata` instead (opt-in: it runs cargo).
|
|
507
|
+
// `cargoFeatures` / `cargoNoDefaultFeatures` / `cargoAllFeatures` are cargo's `--features` /
|
|
508
|
+
// `--no-default-features` / `--all-features` for the entries' packages, in either mode.
|
|
509
|
+
// `cargoTarget` (a triple, or `host`) names the build's target: its cfgs, asked of rustc, decide
|
|
510
|
+
// `#[cfg(unix)]`-style code and target-specific dependency tables, which are otherwise all kept.
|
|
511
|
+
// `cargoManifests` also carries what describes each bundled package's build: its Cargo.toml (and
|
|
512
|
+
// the workspace's), its build script -- walked like a crate root, so its modules and the in-tree
|
|
513
|
+
// build-dependencies it reaches come along -- plus the root's Cargo.lock and .cargo/config.toml.
|
|
514
|
+
export async function buildRustBundle({ cwd = process.cwd(), entries, cargo = false, cargoFeatures = [], cargoNoDefaultFeatures = false, cargoAllFeatures = false, cargoTarget = null, cargoManifests = false } = {}) {
|
|
436
515
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
437
516
|
throw new Error('buildRustBundle: at least one entry .rs file is required')
|
|
438
517
|
}
|
|
@@ -443,18 +522,76 @@ export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
443
522
|
const baseDir = resolve(cwd)
|
|
444
523
|
const normalized = normalizeEntries(entries, cwd)
|
|
445
524
|
|
|
446
|
-
|
|
447
|
-
|
|
525
|
+
// One Cargo context for the walk, the edge pass and the bucketing: manifests are read once, and
|
|
526
|
+
// the walk's crate resolution and the tree's feature decisions agree.
|
|
527
|
+
const cargoCtx = createCargoContext(baseDir, {
|
|
528
|
+
entries: normalized,
|
|
529
|
+
cargo,
|
|
530
|
+
features: cargoFeatures,
|
|
531
|
+
noDefaultFeatures: cargoNoDefaultFeatures,
|
|
532
|
+
allFeatures: cargoAllFeatures,
|
|
533
|
+
target: cargoTarget,
|
|
534
|
+
})
|
|
535
|
+
// `include_str!` / `include_bytes!` assets ride along as resources: the walk records their
|
|
536
|
+
// format. With `cargoManifests`, each package's build script is walked too (collectRustBundle).
|
|
537
|
+
const { sources, formats, tree } = await collectRustBundle(baseDir, normalized, { cargo: cargoCtx, buildScripts: cargoManifests })
|
|
538
|
+
if (cargoManifests) {
|
|
539
|
+
// Each bundled package's manifests, and its workspace's lockfile and cargo config, carried as
|
|
540
|
+
// written: whatever they hold (a registry token, a `git` URL's credentials) is in the bundle
|
|
541
|
+
// too, as with --package-json. A file that isn't UTF-8 text is refused, not altered.
|
|
542
|
+
const files = new Set()
|
|
543
|
+
for (const path of sources.keys()) for (const f of cargoCtx.buildFilesFor(path)) files.add(f.path)
|
|
544
|
+
const realBase = realpathSync(baseDir)
|
|
545
|
+
for (const rel of files) {
|
|
546
|
+
if (sources.has(rel)) continue
|
|
547
|
+
const buf = readFileWithinBase(baseDir, realBase, rel, boundaryOf(rel, cargoCtx))
|
|
548
|
+
if (buf === null) continue
|
|
549
|
+
cargoCtx.checkVendoredFile(rel, buf)
|
|
550
|
+
if (!isUtf8(buf)) throw new Error(`Rust manifest is not valid UTF-8: ${rel}`)
|
|
551
|
+
sources.set(rel, buf.toString('utf8'))
|
|
552
|
+
formats.set(rel, 'resource')
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const { resolutions, missing, unresolvedCrates } = tree
|
|
448
556
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
557
|
+
assertSelfContained('Rust', 'module', normalized, sources, missing)
|
|
558
|
+
|
|
559
|
+
// Where the features the cfg decisions rest on come from: cargo's resolver, or -- something it
|
|
560
|
+
// takes missing -- a replay of the manifests, said so (a project without Cargo has neither).
|
|
561
|
+
const resolution = cargoCtx.resolution()
|
|
562
|
+
if (resolution.mode === 'replay' && normalized.some((e) => cargoCtx.packageInfo(e) !== null)) {
|
|
563
|
+
console.warn(`[stasis] Rust features from a replay of the manifests, not cargo's resolver: ${resolution.why}`)
|
|
452
564
|
}
|
|
453
|
-
|
|
454
|
-
|
|
565
|
+
// EXODUS_STASIS_DEBUG=1: show the feature resolution the cfg decisions came from, per package and
|
|
566
|
+
// context, so the replay, cargo's resolver and `cargo metadata` (which unifies dev/build deps
|
|
567
|
+
// like resolver 1) can be compared.
|
|
568
|
+
if (process.env.EXODUS_STASIS_DEBUG === '1' || process.env.EXODUS_STASIS_DEBUG === 'true') {
|
|
569
|
+
const mode = { cargo: "cargo's resolver", metadata: 'cargo metadata', replay: 'manifest replay' }[resolution.mode]
|
|
570
|
+
for (const context of ['target', 'host']) {
|
|
571
|
+
const resolved = [...cargoCtx.resolvedFeatures(context)].toSorted(([a], [b]) => (a < b ? -1 : 1))
|
|
572
|
+
console.warn(`[stasis] Rust features (${mode}, ${context}), ${resolved.length} package${resolved.length === 1 ? '' : 's'}:`)
|
|
573
|
+
for (const [dir, set] of resolved) {
|
|
574
|
+
const pkg = cargoCtx.packageInfo(moduleFileKey(dir, 'Cargo.toml'))
|
|
575
|
+
console.warn(`[stasis] ${pkg?.name ?? '?'}@${pkg?.version ?? '?'} (${dir}): ${[...set].toSorted().join(', ') || '(none)'}`)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
455
578
|
}
|
|
456
|
-
|
|
457
|
-
|
|
579
|
+
|
|
580
|
+
// A crate the code names that the bundle doesn't hold: a registry dependency that isn't vendored
|
|
581
|
+
// (or not in a version its requirement allows), a path or patch outside the bundle root (the
|
|
582
|
+
// loader warned of those), or a crate root the walk refused -- and any other dependency a bundled
|
|
583
|
+
// package's build links that nothing in-tree answers, however the code names it, if at all.
|
|
584
|
+
// Reported whatever the vendor dir holds; with none, the fix is one command.
|
|
585
|
+
const notLoaded = tree.wantedRoots.filter((r) => !sources.has(r)).map((r) => `${cargoCtx.packageInfo(r)?.name ?? r} (${r})`)
|
|
586
|
+
const bundledPackages = new Set([...sources.keys()].map((p) => cargoCtx.packageInfo(p)?.dir).filter((d) => d !== undefined))
|
|
587
|
+
const lacking = cargoCtx.lackingDependencies(bundledPackages, { buildScripts: cargoManifests })
|
|
588
|
+
.filter((d) => !unresolvedCrates.has(d.key))
|
|
589
|
+
.map((d) => `${d.name} (a dependency of ${d.from})`)
|
|
590
|
+
if (unresolvedCrates.size > 0 || notLoaded.length > 0 || lacking.length > 0) {
|
|
591
|
+
const names = [...new Set([...unresolvedCrates, ...notLoaded, ...lacking])].toSorted()
|
|
592
|
+
const shown = names.slice(0, 10).join(', ') + (names.length > 10 ? `, ... and ${names.length - 10} more` : '')
|
|
593
|
+
console.warn(`[stasis] ${names.length} crate${names.length === 1 ? '' : 's'} referenced but not in the bundle: ${shown}`)
|
|
594
|
+
if (!existsSync(join(baseDir, cargoCtx.vendorDir))) console.warn('[stasis] Registry dependencies are bundled only when vendored in-tree: run `cargo vendor` first.')
|
|
458
595
|
}
|
|
459
596
|
|
|
460
597
|
return assembleCodeBundle({
|
|
@@ -465,30 +602,16 @@ export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
465
602
|
workspaceName: RUST_WORKSPACE_NAME,
|
|
466
603
|
workspaceVersion: RUST_WORKSPACE_VERSION,
|
|
467
604
|
format: RUST_FORMAT,
|
|
605
|
+
formats,
|
|
468
606
|
conditionKey: 'rust',
|
|
469
|
-
classifyDep: makeRustClassifier(
|
|
607
|
+
classifyDep: makeRustClassifier(cargoCtx),
|
|
470
608
|
})
|
|
471
609
|
}
|
|
472
610
|
|
|
473
|
-
// Build
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
// bucketed per package.json, tagged with the `php` format, and edges are keyed
|
|
478
|
-
// under a dedicated "php" condition bucket.
|
|
479
|
-
//
|
|
480
|
-
// Composer autoloading is followed automatically when a composer.json (or
|
|
481
|
-
// generated vendor/composer/autoload_*.php map) is present: PSR-4/PSR-0/
|
|
482
|
-
// classmap/files config is read and the classes each file references (`use`
|
|
483
|
-
// imports, `new`/`extends`/`implements`, type hints, …) are resolved to their
|
|
484
|
-
// files the way Composer's ClassLoader would. That resolution is best-effort
|
|
485
|
-
// (unresolvable references are typically built-in or extension classes), so it
|
|
486
|
-
// never blocks the bundle; explicit `require`/`include` of a literal path is
|
|
487
|
-
// still strict.
|
|
488
|
-
//
|
|
489
|
-
// Refuses to write when an entry can't be loaded or any explicit include is
|
|
490
|
-
// unresolved -- a bundle with holes would silently operate on a partial set of
|
|
491
|
-
// sources.
|
|
611
|
+
// Build an in-memory Bundle from entry .php files by statically scanning the
|
|
612
|
+
// require/include graph (and Composer's class-autoload graph when present); no PHP is
|
|
613
|
+
// executed. Autoload resolution is best-effort; explicit require/include of a literal path
|
|
614
|
+
// is strict, and a missing entry or unresolved explicit include is fatal.
|
|
492
615
|
export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
|
|
493
616
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
494
617
|
throw new Error('buildPhpBundle: at least one entry .php file is required')
|
|
@@ -500,58 +623,56 @@ export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
500
623
|
const baseDir = resolve(cwd)
|
|
501
624
|
const normalized = normalizeEntries(entries, cwd)
|
|
502
625
|
const autoload = loadComposerAutoload(baseDir)
|
|
626
|
+
// The Composer packages, of composer.lock (vendor/composer/installed.json held identical to it
|
|
627
|
+
// where both are), or of installed.json alone: read once, before anything is scanned.
|
|
628
|
+
const packages = loadComposerPackages(baseDir)
|
|
503
629
|
|
|
504
|
-
// Laravel auto-discovers
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
const providerRoots = loadLaravelProviderFiles(baseDir, autoload)
|
|
630
|
+
// Laravel auto-discovers service providers rather than referencing them statically; seed
|
|
631
|
+
// them as extra roots so their config/route/view files get bundled.
|
|
632
|
+
const providerRoots = loadLaravelProviderFiles(baseDir, autoload, packages)
|
|
508
633
|
|
|
509
634
|
const sources = await collectPhpFilesFromDisk(baseDir, [...normalized, ...providerRoots], { autoload })
|
|
510
635
|
const { resolutions, missing } = buildPhpTree(sources, { baseDir, autoload })
|
|
511
636
|
|
|
512
|
-
|
|
513
|
-
for (const entry of normalized) {
|
|
514
|
-
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
515
|
-
}
|
|
516
|
-
for (const { spec, from } of missing) {
|
|
517
|
-
issues.push(`Unresolved import: ${spec} from ${from}`)
|
|
518
|
-
}
|
|
519
|
-
if (issues.length > 0) {
|
|
520
|
-
throw new Error(`PHP bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
521
|
-
}
|
|
637
|
+
assertSelfContained('PHP', 'import', normalized, sources, missing)
|
|
522
638
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
639
|
+
return codeBundle({
|
|
640
|
+
baseDir,
|
|
641
|
+
entries: normalized,
|
|
642
|
+
sources,
|
|
643
|
+
// Group per Composer package (vendor/<pkg>), not the node_modules bucketizer.
|
|
644
|
+
modules: bucketizePhpSources(baseDir, sources, PHP_WORKSPACE_NAME, PHP_WORKSPACE_VERSION, packages),
|
|
645
|
+
resolutions,
|
|
646
|
+
format: PHP_FORMAT,
|
|
647
|
+
// PHP includes don't vary by Node condition; key edges under "php", not the JS wildcard "*".
|
|
648
|
+
conditionKey: 'php',
|
|
649
|
+
})
|
|
650
|
+
}
|
|
529
651
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
652
|
+
// Render a scanned file URL for diagnostics: project-relative when inside `baseDir`, else absolute.
|
|
653
|
+
// The scan issue checks run on the LIVE scan, whose files/imports are keyed by absolute file: URLs
|
|
654
|
+
// (the resolver returns absolute paths); relativizing here matches the project-relative paths the
|
|
655
|
+
// bundle itself stores (toRel), rather than leaking the machine's absolute paths into the message.
|
|
656
|
+
function displayPath(url, baseDir) {
|
|
657
|
+
const abs = fileURLToPath(url)
|
|
658
|
+
const rel = relative(baseDir, abs)
|
|
659
|
+
return rel && !relativeEscapes(rel) ? rel : abs
|
|
660
|
+
}
|
|
535
661
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
imports,
|
|
542
|
-
})
|
|
662
|
+
// One unresolved scan edge for diagnostics, naming the file --typescript would resolve it to when
|
|
663
|
+
// the scan found one (Scan#typescriptCandidate).
|
|
664
|
+
function describeUnresolved(u, show) {
|
|
665
|
+
const typescript = u.typescriptURL ? `; resolves to ${show(u.typescriptURL)} under --typescript` : ''
|
|
666
|
+
return `${u.kind} ${u.spec ?? '<dynamic>'} from ${show(u.parentURL)} (${u.reason}${typescript})`
|
|
543
667
|
}
|
|
544
668
|
|
|
545
|
-
// Classify
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
//
|
|
552
|
-
// `import`/`export ... from` edges load eagerly, before any user code runs, so
|
|
553
|
-
// a failure there is uncatchable; below a require()/dynamic-import() boundary it
|
|
554
|
-
// surfaces as a catchable miss and only warns.
|
|
669
|
+
// Classify scanner unresolved edges + parse errors into fatal (broken/divergent at load)
|
|
670
|
+
// vs tolerated (a catchable runtime miss). Shared so the plain and --mainFields JS paths
|
|
671
|
+
// gate identically. `baseDir` relativizes the paths in the emitted messages (see displayPath).
|
|
672
|
+
function analyzeScanner(scanner, { baseDir }) {
|
|
673
|
+
const show = (url) => displayPath(url, baseDir)
|
|
674
|
+
// Static ESM import/export-from edges load eagerly before user code, so a failure there is
|
|
675
|
+
// uncatchable (fatal); misses below a require()/dynamic-import() boundary are catchable (warn).
|
|
555
676
|
const staticKinds = new Set(['import', 'export-from'])
|
|
556
677
|
const staticReachable = new Set(scanner.entries)
|
|
557
678
|
const walk = [...scanner.entries]
|
|
@@ -566,20 +687,19 @@ function analyzeScanner(scanner) {
|
|
|
566
687
|
const fatalUnresolved = (u) => staticKinds.has(u.kind) && staticReachable.has(u.parentURL)
|
|
567
688
|
const fatal = scanner.unresolved
|
|
568
689
|
.filter((u) => fatalUnresolved(u))
|
|
569
|
-
.map((u) => `unresolved ${u
|
|
570
|
-
//
|
|
571
|
-
// eagerly-linked set, or any file the parser couldn't process at all.
|
|
690
|
+
.map((u) => `unresolved ${describeUnresolved(u, show)}`)
|
|
691
|
+
// Fatal parse: an eagerly-linked module-family file, or any file the parser couldn't process.
|
|
572
692
|
const fatalParse = (p) => staticReachable.has(p.url) && (p.format?.startsWith('module') === true || !p.recovered)
|
|
573
693
|
for (const p of scanner.parseErrors) {
|
|
574
|
-
if (fatalParse(p)) fatal.push(`parse error in ${
|
|
694
|
+
if (fatalParse(p)) fatal.push(`parse error in ${show(p.url)}: ${p.message}`)
|
|
575
695
|
}
|
|
576
|
-
//
|
|
577
|
-
//
|
|
696
|
+
// Edge resolving to a file a source bundle can't carry (.node/.wasm/extensionless): scan
|
|
697
|
+
// records it but never queues the child, so load would die.
|
|
578
698
|
for (const [, byParent] of scanner.imports) {
|
|
579
699
|
for (const [parentURL, specMap] of byParent) {
|
|
580
700
|
for (const [spec, childURL] of specMap) {
|
|
581
701
|
if (!scanner.files.has(childURL)) {
|
|
582
|
-
fatal.push(`${spec} from ${
|
|
702
|
+
fatal.push(`${spec} from ${show(parentURL)} resolves to ${show(childURL)}, which a source bundle can't carry`)
|
|
583
703
|
}
|
|
584
704
|
}
|
|
585
705
|
}
|
|
@@ -589,69 +709,58 @@ function analyzeScanner(scanner) {
|
|
|
589
709
|
return { fatal, tolerated, toleratedParse }
|
|
590
710
|
}
|
|
591
711
|
|
|
592
|
-
// Throw on
|
|
593
|
-
//
|
|
594
|
-
function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = ''
|
|
712
|
+
// Throw on fatal scan issues; warn on tolerated ones. `label` tags the scan pass; `baseDir`
|
|
713
|
+
// relativizes the paths in the warnings (see displayPath), matching analyzeScanner's messages.
|
|
714
|
+
function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '', baseDir }) {
|
|
595
715
|
const where = label ? ` (${label})` : ''
|
|
716
|
+
const show = (url) => displayPath(url, baseDir)
|
|
717
|
+
// The first 10 of `items` as indented lines (`line` renders one), then how many more there are.
|
|
718
|
+
const listed = (items, line = (s) => s) => {
|
|
719
|
+
const more = items.length > 10 ? `\n ... and ${items.length - 10} more` : ''
|
|
720
|
+
return items.slice(0, 10).map((x) => ` ${line(x)}`).join('\n') + more
|
|
721
|
+
}
|
|
596
722
|
if (fatal.length > 0) {
|
|
597
|
-
|
|
598
|
-
const more = fatal.length > 10 ? `\n ... and ${fatal.length - 10} more` : ''
|
|
599
|
-
throw new Error(`JS bundle would be broken at load time${where}:\n${shown}${more}`)
|
|
723
|
+
throw new Error(`JS bundle would be broken at load time${where}:\n${listed(fatal)}`)
|
|
600
724
|
}
|
|
601
725
|
if (tolerated.length > 0) {
|
|
602
|
-
const summary = tolerated
|
|
603
|
-
|
|
604
|
-
.map((u) => ` ${u.kind} ${u.spec ?? '<dynamic>'} from ${fileURLToPath(u.parentURL)} (${u.reason})`)
|
|
605
|
-
.join('\n')
|
|
606
|
-
const more = tolerated.length > 10 ? `\n ... and ${tolerated.length - 10} more` : ''
|
|
607
|
-
console.warn(`[stasis] Bundle has ${tolerated.length} unresolved import(s)${where}; they will fall through at load time:\n${summary}${more}`)
|
|
726
|
+
const summary = listed(tolerated, (u) => describeUnresolved(u, show))
|
|
727
|
+
console.warn(`[stasis] Bundle has ${tolerated.length} unresolved import(s)${where}; they will fall through at load time:\n${summary}`)
|
|
608
728
|
}
|
|
609
729
|
if (toleratedParse.length > 0) {
|
|
610
|
-
const summary = toleratedParse
|
|
611
|
-
|
|
612
|
-
.map((p) => ` ${fileURLToPath(p.url)}: ${p.message}`)
|
|
613
|
-
.join('\n')
|
|
614
|
-
const more = toleratedParse.length > 10 ? `\n ... and ${toleratedParse.length - 10} more` : ''
|
|
615
|
-
console.warn(`[stasis] Bundle has ${toleratedParse.length} file(s) with parse errors${where}; their recorded imports may be incomplete:\n${summary}${more}`)
|
|
730
|
+
const summary = listed(toleratedParse, (p) => `${show(p.url)}: ${p.message}`)
|
|
731
|
+
console.warn(`[stasis] Bundle has ${toleratedParse.length} file(s) with parse errors${where}; their recorded imports may be incomplete:\n${summary}`)
|
|
616
732
|
}
|
|
617
733
|
}
|
|
618
734
|
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
// `
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
//
|
|
647
|
-
// NOTE: conditions are only one input to module resolution. On their own they do
|
|
648
|
-
// NOT honour legacy package `mainFields` (`react-native`/`browser`/`main`) or
|
|
649
|
-
// platform-specific file suffixes (`.ios`/`.android`/`.native`), neither of which
|
|
650
|
-
// the `exports` field expresses; `--mainFields` (see buildResolvedJsBundle) adds
|
|
651
|
-
// the former.
|
|
652
|
-
export async function buildJsBundle({ cwd = process.cwd(), entries, scope, conditions = [] } = {}) {
|
|
735
|
+
// Normalize conditions (trim, drop empties) so a sloppy programmatic caller can't push a bogus
|
|
736
|
+
// token into the resolver.
|
|
737
|
+
const cleanConditions = (conditions) => conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
|
|
738
|
+
|
|
739
|
+
// --typescript honours tsconfig `paths` aliases: an explicit --tsconfig's for every file (it must
|
|
740
|
+
// exist), else those of the tsconfig.json beside each importing file's package.json
|
|
741
|
+
// (packageTsconfigPaths, the project read as the State reads it, to `env`'s PROJECT_CWD; null
|
|
742
|
+
// matcher = no aliases). Without --typescript they only hint a miss (Scan#typescriptCandidate), so a
|
|
743
|
+
// config that doesn't load is none.
|
|
744
|
+
function typescriptPathsFor(typescript, baseDir, tsconfig, host, env) {
|
|
745
|
+
if (typescript && tsconfig !== undefined) return loadTsconfigPaths(discoverTsconfig(baseDir, tsconfig, host), host)
|
|
746
|
+
return packageTsconfigPaths(baseDir, { host, lenient: !typescript, projectCwd: env.PROJECT_CWD })
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Build a JS/TS Bundle (in-memory) by statically scanning the require/import graph; no
|
|
750
|
+
// user code is executed, and TS is stored verbatim (Node strips types at load). Scope comes
|
|
751
|
+
// from stasis.config.json / `EXODUS_STASIS_SCOPE` unless `scope` overrides. `conditions` are
|
|
752
|
+
// extra `exports`/`imports` conditions; on their own they don't honour legacy mainFields or
|
|
753
|
+
// platform suffixes (see `--mainFields` / buildResolvedJsBundle). `typescript` maps a failed
|
|
754
|
+
// resolution to its on-disk TS source (tsc's rules; see resolve-typescript.js), honouring the
|
|
755
|
+
// `paths` aliases of `tsconfig` (an explicit config path, default each package's tsconfig.json); it
|
|
756
|
+
// is taken as given where the entries import TS sources by output name (importsTypescriptByOutputName).
|
|
757
|
+
// Files are read through `host` (@exodus/stasis-core/host), the disk by default; EXODUS_STASIS_*
|
|
758
|
+
// settings from `env`. With `innermostRoot`, the State is rooted at the innermost package at or
|
|
759
|
+
// above cwd that holds every file the scan reaches (innermostRootOf), rather than at the project's
|
|
760
|
+
// root above it, where that holds no stasis file of its own.
|
|
761
|
+
export async function buildJsBundle({ cwd = process.cwd(), env = process.env, entries, scope, conditions = [], jsx = false, flow = false, typescript = false, tsconfig, resources = [], packageJSON = false, host = diskHost, innermostRoot = false } = {}) {
|
|
653
762
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
654
|
-
throw new Error('buildJsBundle: at least one entry .js/.cjs/.mjs/.ts/.cts/.mts file is required')
|
|
763
|
+
throw new Error('buildJsBundle: at least one entry .js/.cjs/.mjs/.ts/.cts/.mts/.jsx/.tsx file is required')
|
|
655
764
|
}
|
|
656
765
|
for (const e of entries) {
|
|
657
766
|
if (!JS_EXTS.has(extname(e))) throw new Error(`buildJsBundle: not a JS/TS file: ${e}`)
|
|
@@ -660,126 +769,243 @@ export async function buildJsBundle({ cwd = process.cwd(), entries, scope, condi
|
|
|
660
769
|
const baseDir = resolve(cwd)
|
|
661
770
|
const absEntries = entries.map((e) => resolve(baseDir, e))
|
|
662
771
|
|
|
663
|
-
|
|
664
|
-
//
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
772
|
+
const scanConditions = cleanConditions(conditions)
|
|
773
|
+
// --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
|
|
774
|
+
const resourceSet = parseResourcesOption('buildJsBundle', resources)
|
|
775
|
+
|
|
776
|
+
typescript ||= importsTypescriptByOutputName(absEntries, host)
|
|
777
|
+
const typescriptPaths = typescriptPathsFor(typescript, baseDir, tsconfig, host, env)
|
|
778
|
+
|
|
779
|
+
const scanner = scan(absEntries, { conditions: scanConditions, jsx, flow, typescript, typescriptPaths, resources: resourceSet, host })
|
|
780
|
+
|
|
781
|
+
// Fail closed where the bundle is guaranteed broken at load; warn on catchable misses (see analyzeScanner).
|
|
782
|
+
reportScanIssues(analyzeScanner(scanner, { baseDir }), { baseDir })
|
|
783
|
+
|
|
784
|
+
// Materialise via a non-preload State: addFile bucketizes + records sources/formats,
|
|
785
|
+
// addImport replays the edge map; serialize emits the runtime loader's v1 layout.
|
|
786
|
+
// bundle:'replace' skips reading any on-disk stasis.code.br (bundle:'add' would leak stale
|
|
787
|
+
// entries); lock:'ignore' tolerates a pre-existing lockfile without consuming it; and it is never
|
|
788
|
+
// written, so it claims no write target.
|
|
789
|
+
const stateOptions = { bundle: 'replace', lock: 'ignore', ...(scope ? { scope } : {}), host, claim: false }
|
|
790
|
+
let state = new State(baseDir, { ...stateOptions, env })
|
|
791
|
+
// A root holding a stasis file is the project's own choice, whose config the build takes.
|
|
792
|
+
if (innermostRoot && !STASIS_ROOT_FILES.some((file) => host.stat(join(state.root, file)) !== null)) {
|
|
793
|
+
const root = innermostRootOf(baseDir, [...scanner.files.keys()].map((url) => host.realpath(fileURLToPath(url))), state.root, host)
|
|
794
|
+
// The State's walk up from cwd stops at PROJECT_CWD, as it does where yarn sets it.
|
|
795
|
+
if (root !== state.root) state = new State(baseDir, { ...stateOptions, env: { ...env, PROJECT_CWD: root } })
|
|
796
|
+
}
|
|
797
|
+
// Named here rather than as the State's bare assertion: a stasis file in a directory below the
|
|
798
|
+
// project's root (a stray stasis.code.br there, say) roots the State at it, out of the files above.
|
|
799
|
+
for (const url of scanner.files.keys()) {
|
|
800
|
+
const file = fileURLToPath(url)
|
|
801
|
+
if (!relativeEscapes(relative(state.root, file))) continue
|
|
802
|
+
const markers = STASIS_ROOT_FILES.filter((name) => host.stat(join(state.root, name)) !== null)
|
|
803
|
+
const rootedBy = markers.length === 0 ? '' : ` (rooted there by its ${markers.join(' and ')}, which can be removed if stale)`
|
|
804
|
+
throw new Error(`buildJsBundle: ${relative(baseDir, file)} is outside the project root at ${relative(baseDir, state.root) || '.'}${rootedBy}`)
|
|
805
|
+
}
|
|
695
806
|
for (const [url, info] of scanner.files) {
|
|
807
|
+
// A resource carries bytes only: addFile derives resource vs resource:base64 from the content
|
|
808
|
+
// and stores it under `resources` (a resource can't be an entry, so no isEntry).
|
|
809
|
+
if (info.resource) {
|
|
810
|
+
state.addFile(url, { resource: true })
|
|
811
|
+
continue
|
|
812
|
+
}
|
|
696
813
|
const isEntry = scanner.entries.has(url)
|
|
697
814
|
state.addFile(url, { format: info.format, isEntry })
|
|
698
815
|
}
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
|
|
704
|
-
for (const [,
|
|
705
|
-
for (const [parentURL, specs] of
|
|
816
|
+
// Edges where every context agrees keep the wildcard '*' key (getImport's fallback when a
|
|
817
|
+
// condition lookup misses). Where the require()- and import()-context resolutions of one
|
|
818
|
+
// (parent, specifier) DIVERGE, each target keeps its real condition key -- one '*' entry
|
|
819
|
+
// would serve one context the other's file; an unmatched set fails closed via resolveBundled.
|
|
820
|
+
const byParent = new Map()
|
|
821
|
+
for (const [key, parents] of scanner.imports) {
|
|
822
|
+
for (const [parentURL, specs] of parents) {
|
|
823
|
+
if (!byParent.has(parentURL)) byParent.set(parentURL, new Map())
|
|
824
|
+
const bySpec = byParent.get(parentURL)
|
|
706
825
|
for (const [spec, childURL] of specs) {
|
|
707
|
-
|
|
826
|
+
if (!bySpec.has(spec)) bySpec.set(spec, new Map())
|
|
827
|
+
bySpec.get(spec).set(key, childURL)
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
for (const [parentURL, bySpec] of byParent) {
|
|
832
|
+
for (const [spec, byKey] of bySpec) {
|
|
833
|
+
const targets = new Set(byKey.values())
|
|
834
|
+
if (targets.size === 1) {
|
|
835
|
+
state.addImport(parentURL, spec, [...targets][0], { conditions: '*' })
|
|
836
|
+
} else {
|
|
837
|
+
for (const [key, childURL] of byKey) {
|
|
838
|
+
state.addImport(parentURL, spec, childURL, { conditions: key.split(', ') })
|
|
839
|
+
}
|
|
708
840
|
}
|
|
709
841
|
}
|
|
710
842
|
}
|
|
843
|
+
// --package-json: fold each bundled module's package.json into the State's buckets (idempotent --
|
|
844
|
+
// no-ops any manifest the scan already reached). State's bundle=replace makes writeBundle true, so addFile accepts them.
|
|
845
|
+
if (packageJSON) state.includePackageJson()
|
|
711
846
|
return state
|
|
712
847
|
}
|
|
713
848
|
|
|
714
|
-
//
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
849
|
+
// The files that root a State in the directory holding one (state.js's discovery).
|
|
850
|
+
const STASIS_ROOT_FILES = ['stasis.config.json', 'stasis.lock.json', DEFAULT_BUNDLE_FILE]
|
|
851
|
+
|
|
852
|
+
// The innermost directory at or above `baseDir`, up to `root`, holding every one of `files` (real
|
|
853
|
+
// paths) and a package.json with a name, which a State can be rooted at; `root` where none below it
|
|
854
|
+
// does. One without a name, such as a `{"type":"module"}` marker, is no root: the State takes such a
|
|
855
|
+
// file's package to be the named one above it, which it then must hold.
|
|
856
|
+
function innermostRootOf(baseDir, files, root, host) {
|
|
857
|
+
for (let dir = baseDir; dir !== root && dirname(dir) !== dir; dir = dirname(dir)) {
|
|
858
|
+
if (files.every((file) => isPathWithin(dir, file)) && readPackageJson(dir, 'package.json', { host })?.name !== undefined) return dir
|
|
859
|
+
}
|
|
860
|
+
return root
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Extensions probed when a resolved target names none, in Metro's default sourceExts order.
|
|
864
|
+
// Limited to what a source bundle can carry (scan's RESOLVABLE_EXTS) so the resolver never
|
|
865
|
+
// resolves a file the bundle would reject; an extensionless `import './Foo'` can land on
|
|
866
|
+
// Foo.jsx/Foo.tsx, as it does under Metro. Like Metro's default, this doesn't track a project's
|
|
867
|
+
// own sourceExts.
|
|
868
|
+
const SOURCE_EXTS = ['js', 'jsx', 'json', 'ts', 'tsx']
|
|
869
|
+
// React Native preset mainFields for `--metro` (which also sets the RN conditions + platform suffixes).
|
|
723
870
|
const METRO_MAIN_FIELDS = ['react-native', 'browser', 'main']
|
|
724
|
-
//
|
|
725
|
-
// `
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
871
|
+
// What esbuild and webpack 5 both assert on every platform besides `import`/`require` and
|
|
872
|
+
// `default`, and so `--mainFields` too: `module`, the bundler-only condition a package's ESM build
|
|
873
|
+
// sits under, from an import and a require alike. (esbuild drops it when given conditions of its
|
|
874
|
+
// own; webpack keeps it, as `--conditions` adds to it here.) Node and Metro never assert it.
|
|
875
|
+
const BUNDLER_CONDITIONS = ['module']
|
|
876
|
+
|
|
877
|
+
// Recursively collect the files under `dirAbs` whose name `keep` takes, skipping build output,
|
|
878
|
+
// symlinks (cycle/escape hazard) and, directly in `dirAbs`, the dirs `skipAtRoot` names. Absolute
|
|
879
|
+
// paths into `out`.
|
|
880
|
+
function walkNative(dirAbs, out, host, keep, skipAtRoot) {
|
|
881
|
+
let entries
|
|
882
|
+
try {
|
|
883
|
+
entries = host.readdir(dirAbs)
|
|
884
|
+
} catch {
|
|
885
|
+
return // absent -- nothing for this platform
|
|
886
|
+
}
|
|
887
|
+
for (const ent of entries) {
|
|
888
|
+
if (ent.isSymbolicLink()) continue
|
|
889
|
+
const full = join(dirAbs, ent.name)
|
|
890
|
+
if (ent.isDirectory()) {
|
|
891
|
+
if (!isSkippedNativeWalkDir(ent.name) && !skipAtRoot?.(ent.name)) walkNative(full, out, host, keep)
|
|
892
|
+
} else if (ent.isFile() && keep(ent.name)) {
|
|
893
|
+
out.push(full)
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// The files of a native ios/android dir: all but build artifacts.
|
|
899
|
+
const isNativeSource = (name) => !isNativeArtifact(name)
|
|
900
|
+
|
|
901
|
+
// Native source files a bundled RN dep contributes to the app's native build (podspecs +
|
|
902
|
+
// ios/android sources). Manifests are kept only when the package is actually native (podspec
|
|
903
|
+
// or ios/android dir), else a JS-only dep's package.json would be pulled in. Deduped absolute paths.
|
|
904
|
+
function nativeModuleFiles(pkgAbs, host) {
|
|
905
|
+
// Podspec-load manifests: RN's own podspecs live in scattered subdirs a root-only scan would miss,
|
|
906
|
+
// so recurse fully.
|
|
907
|
+
const manifests = []
|
|
908
|
+
walkNative(pkgAbs, manifests, host, isNativeManifest, isExcludedNativeDir)
|
|
909
|
+
const hasIos = host.stat(join(pkgAbs, 'ios')) !== null
|
|
910
|
+
const hasAndroid = host.stat(join(pkgAbs, 'android')) !== null
|
|
911
|
+
if (!hasIos && !hasAndroid && !manifests.some((f) => isPodspec(f))) return []
|
|
912
|
+
const out = [...manifests]
|
|
913
|
+
if (hasIos) walkNative(join(pkgAbs, 'ios'), out, host, isNativeSource)
|
|
914
|
+
if (hasAndroid) walkNative(join(pkgAbs, 'android'), out, host, isNativeSource)
|
|
915
|
+
return [...new Set(out)]
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// The built-in field/suffix resolver (resolve-fields.js) the legacy-field build resolves with on
|
|
919
|
+
// `platform` (null for --mainFields), its `mainFields` (Metro's under --metro), and the conditions
|
|
920
|
+
// it adds to the bundler base (`import`/`require` + `default`, never Node's `node`; see
|
|
921
|
+
// resolveConditions): --metro asserts the RN conditions (+ browser on web), Metro's own set;
|
|
922
|
+
// --mainFields asserts esbuild's and webpack's `module` (BUNDLER_CONDITIONS) and the user's --conditions.
|
|
923
|
+
export function fieldResolverFor(platform, { mainFields, metro = false, conditions = [], typescript = false, typescriptPaths = null, host = diskHost }) {
|
|
924
|
+
const extras = metro ? ['react-native', ...(platform === 'web' ? ['browser'] : [])] : [...new Set([...BUNDLER_CONDITIONS, ...conditions])]
|
|
925
|
+
const fields = metro ? METRO_MAIN_FIELDS : mainFields
|
|
926
|
+
const resolver = createFieldResolver({
|
|
927
|
+
mainFields: fields,
|
|
928
|
+
platform,
|
|
929
|
+
preferNative: platform !== null && platform !== 'web',
|
|
930
|
+
sourceExts: SOURCE_EXTS,
|
|
931
|
+
conditions: resolveConditions('commonjs', extras),
|
|
932
|
+
// Opt into Metro's package-entry browser-field quirks only on the --metro path.
|
|
933
|
+
metro,
|
|
934
|
+
// --typescript: tsc's mapping, inside the field resolver (the scanner's own fallback
|
|
935
|
+
// only backs the built-in resolver). Unreachable under --metro-resolver
|
|
936
|
+
// (classifyEntries rejects the combination -- metro-resolver can't substitute).
|
|
937
|
+
typescript,
|
|
938
|
+
typescriptPaths,
|
|
939
|
+
host,
|
|
940
|
+
})
|
|
941
|
+
return { extras, mainFields: fields, resolver }
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Build a JS/TS Bundle + companion Lockfile via the legacy-field resolver (`--mainFields`/
|
|
945
|
+
// `--metro`). Scanned once per platform; each edge is recorded flat when the platforms that
|
|
946
|
+
// have it agree, or as a `{ platform: target }` map where they diverge. Returns { bundle, lockfile }.
|
|
947
|
+
async function buildResolvedJsBundle({ cwd = process.cwd(), env = process.env, entries, mainFields, platforms, conditions = [], metro = false, metroResolver = false, jsx = false, flow = false, typescript = false, tsconfig, resources = [], packageJSON = false, host = diskHost }) {
|
|
741
948
|
const baseDir = resolve(cwd)
|
|
742
949
|
const absEntries = entries.map((e) => resolve(baseDir, e))
|
|
743
950
|
const normalized = normalizeEntries(entries, cwd)
|
|
744
951
|
|
|
952
|
+
// A resolved file arrives by its real path, an entry as spelled: under a cwd named through a link,
|
|
953
|
+
// the files the scan reaches lie below the root's real path, not below `baseDir` as spelled. Either
|
|
954
|
+
// is the root; a file below neither is outside it. (Realpathed only when needed, so a missing cwd
|
|
955
|
+
// is still the scan's missing entry.)
|
|
956
|
+
let realRoot
|
|
745
957
|
const toRel = (abs) => {
|
|
746
|
-
|
|
747
|
-
if (
|
|
958
|
+
let rel = toPosix(relative(baseDir, abs))
|
|
959
|
+
if (relativeEscapes(rel)) {
|
|
960
|
+
realRoot ??= host.realpath(baseDir)
|
|
961
|
+
rel = toPosix(relative(realRoot, abs))
|
|
962
|
+
}
|
|
963
|
+
if (relativeEscapes(rel)) {
|
|
748
964
|
throw new Error(`Bundle would reach a file outside the project root: ${abs}`)
|
|
749
965
|
}
|
|
750
966
|
return rel
|
|
751
967
|
}
|
|
752
968
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
969
|
+
const scanConditions = cleanConditions(conditions)
|
|
970
|
+
|
|
971
|
+
// Taken as given where the entries import TS sources by output name, but under --metro-resolver,
|
|
972
|
+
// which has no tsc mapping (classifyEntries rejects --typescript there).
|
|
973
|
+
if (!metroResolver) typescript ||= importsTypescriptByOutputName(absEntries, host)
|
|
974
|
+
// --typescript's tsconfig `paths` matcher, shared by every per-platform resolver below.
|
|
975
|
+
const typescriptPaths = typescriptPathsFor(typescript, baseDir, tsconfig, host, env)
|
|
976
|
+
// --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
|
|
977
|
+
const resourceSet = parseResourcesOption('buildResolvedJsBundle', resources)
|
|
756
978
|
|
|
757
979
|
const formatsByRel = new Map()
|
|
758
|
-
//
|
|
759
|
-
|
|
980
|
+
// Reached files the scanner tagged as resources (rel keys); read as bytes below, not UTF-8 source.
|
|
981
|
+
const resourceRels = new Set()
|
|
982
|
+
// parentRel -> specifier -> Map<platformKey, targetRel>; collapsed after all platforms scanned.
|
|
760
983
|
const edges = new Map()
|
|
761
|
-
const reached = new Set() // absolute paths
|
|
984
|
+
const reached = new Set() // absolute paths reached on any platform
|
|
762
985
|
let usesEmpty = false
|
|
763
986
|
|
|
764
987
|
for (const platform of platforms) {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const
|
|
777
|
-
|
|
988
|
+
const field = fieldResolverFor(platform, { mainFields, metro, conditions: scanConditions, typescript, typescriptPaths, host })
|
|
989
|
+
const { extras } = field
|
|
990
|
+
// --metro --metro-resolver delegates to the project's own metro-resolver for byte-for-byte Metro
|
|
991
|
+
// fidelity; otherwise the built-in field/suffix resolver reproduces it. metro-resolver derives
|
|
992
|
+
// default/require|import/platform conditions itself, so it takes only the extra `react-native`
|
|
993
|
+
// condition (browser comes from its per-platform map, keyed on `web`).
|
|
994
|
+
const resolver = metroResolver
|
|
995
|
+
? createMetroResolver({ projectDir: baseDir, platform, sourceExts: SOURCE_EXTS, mainFields, conditionNames: ['react-native'], host })
|
|
996
|
+
: field.resolver
|
|
997
|
+
// Without --typescript, the field resolver --typescript would build names the file a miss would
|
|
998
|
+
// land on: a hint in the unresolved report, never an edge (see Scan#typescriptCandidate).
|
|
999
|
+
const typescriptResolve = typescript || metroResolver ? null : fieldResolverFor(platform, { mainFields, metro, conditions: scanConditions, typescript: true, typescriptPaths, host }).resolver
|
|
1000
|
+
const scanner = scan(absEntries, { conditions: extras, resolve: resolver, typescriptResolve, jsx, flow, resources: resourceSet, host })
|
|
1001
|
+
reportScanIssues(analyzeScanner(scanner, { baseDir }), { baseDir, label: platform ?? 'mainFields' })
|
|
778
1002
|
|
|
779
1003
|
const platformKey = platform ?? '*' // '*' is a private placeholder for the single mainFields pass; it never unflattens
|
|
780
1004
|
for (const [url, info] of scanner.files) {
|
|
781
|
-
const
|
|
782
|
-
|
|
1005
|
+
const abs = fileURLToPath(url)
|
|
1006
|
+
const rel = toRel(abs)
|
|
1007
|
+
reached.add(abs)
|
|
1008
|
+
if (info.resource) resourceRels.add(rel)
|
|
783
1009
|
formatsByRel.set(rel, info.format)
|
|
784
1010
|
for (const e of info.edges) {
|
|
785
1011
|
if (e.builtin || e.dynamic) continue
|
|
@@ -795,38 +1021,187 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
795
1021
|
}
|
|
796
1022
|
}
|
|
797
1023
|
|
|
798
|
-
// Read
|
|
799
|
-
//
|
|
800
|
-
// source is UTF-8 text, so the bytes must BE valid UTF-8 -- otherwise toString('utf8')
|
|
801
|
-
// would lossily diverge from the hashed bytes.
|
|
1024
|
+
// Read each reached file once: bytes for the bundle, integrity for the lockfile. Source is
|
|
1025
|
+
// stored as UTF-8 text, so non-UTF-8 bytes would diverge from the hashed bytes -- reject them.
|
|
802
1026
|
const sources = new Map()
|
|
803
1027
|
const integrities = new Map()
|
|
804
|
-
|
|
1028
|
+
// Carry `rel`: `content` in the bundle as `format`, the integrity of its bytes `buf` in the lockfile.
|
|
1029
|
+
const put = (rel, content, format, buf) => {
|
|
1030
|
+
sources.set(rel, content)
|
|
1031
|
+
formatsByRel.set(rel, format)
|
|
1032
|
+
integrities.set(rel, sha512integrity(buf))
|
|
1033
|
+
}
|
|
1034
|
+
// A resource carries bytes, not source: UTF-8 verbatim or base64 when binary, tagged with the
|
|
1035
|
+
// byte-derived format (here and in the --metro native capture below).
|
|
1036
|
+
const putResource = (rel, buf) => (isUtf8(buf) ? put(rel, buf.toString('utf8'), 'resource', buf) : put(rel, buf.toString('base64'), 'resource:base64', buf))
|
|
1037
|
+
const realBase = host.realpath(baseDir)
|
|
805
1038
|
for (const abs of reached) {
|
|
806
1039
|
const rel = toRel(abs)
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
assertRealPathWithinBase(realBase, baseDir, rel)
|
|
812
|
-
const buf =
|
|
813
|
-
if (
|
|
814
|
-
|
|
815
|
-
|
|
1040
|
+
// Security: a resolved file arrives by its real path, which toRel already holds to the root,
|
|
1041
|
+
// but an entry is taken as spelled (and --metro-resolver keeps a path it can't realpath), so an
|
|
1042
|
+
// in-tree-named symlink escaping the root would slip past toRel's textual check -- realpath and
|
|
1043
|
+
// fail closed.
|
|
1044
|
+
assertRealPathWithinBase(realBase, baseDir, rel, host)
|
|
1045
|
+
const buf = host.readFile(abs)
|
|
1046
|
+
if (resourceRels.has(rel)) {
|
|
1047
|
+
putResource(rel, buf)
|
|
1048
|
+
} else {
|
|
1049
|
+
if (!isUtf8(buf)) throw new Error(`JS bundle source is not valid UTF-8: ${rel}`)
|
|
1050
|
+
// Its format is the scan's.
|
|
1051
|
+
put(rel, buf.toString('utf8'), formatsByRel.get(rel), buf)
|
|
1052
|
+
}
|
|
816
1053
|
}
|
|
817
1054
|
if (usesEmpty) {
|
|
818
|
-
//
|
|
819
|
-
// happens to sit at the reserved path rather than silently clobber it.
|
|
1055
|
+
// Refuse to shadow a real reached file sitting at the reserved empty-module path.
|
|
820
1056
|
if (formatsByRel.has(EMPTY_MODULE_PATH)) {
|
|
821
1057
|
throw new Error(`Bundle needs the reserved empty-module path ${EMPTY_MODULE_PATH}, but the project has a real file there`)
|
|
822
1058
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1059
|
+
put(EMPTY_MODULE_PATH, '', 'commonjs', Buffer.alloc(0))
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// The package a bundled file is in (findPackageMetadata: its pkgDir and identity, null where no
|
|
1063
|
+
// manifest names one), memoized per directory so a package's many files don't each re-walk to it.
|
|
1064
|
+
const packageOf = packageLookup(baseDir, { host })
|
|
1065
|
+
|
|
1066
|
+
// --metro also carries each bundled dependency's native build-input surface (ios/android
|
|
1067
|
+
// sources + podspecs), scoped to the packages in the bundle installed or linked into
|
|
1068
|
+
// node_modules. Native source is stored as code under a language tag; other assets as
|
|
1069
|
+
// 'resource'/'resource:base64'.
|
|
1070
|
+
if (metro) {
|
|
1071
|
+
const pkgDirs = new Map() // package dir -> its package's name
|
|
1072
|
+
// Follow the CODE/module graph only: a package reached solely for an asset (--resources) is
|
|
1073
|
+
// not a linked native dependency, so it must not drag in its ios/android surface.
|
|
1074
|
+
const isCode = (rel) => !resourceRels.has(rel)
|
|
1075
|
+
for (const abs of reached) {
|
|
1076
|
+
const rel = toRel(abs)
|
|
1077
|
+
const nm = isCode(rel) && splitNodeModulesPath(rel)
|
|
1078
|
+
if (nm) pkgDirs.set(nm.dir, nm.name)
|
|
1079
|
+
}
|
|
1080
|
+
// A package linked into node_modules from out of it (a workspace package) is reached by its real
|
|
1081
|
+
// path, out of node_modules, but is a dependency all the same, however an import reaches it (by
|
|
1082
|
+
// name or alias, through a browser/react-native map, by a relative path): one linked, under any
|
|
1083
|
+
// name, into a node_modules its importer finds packages in (the directories the resolver looks
|
|
1084
|
+
// in). One linked nowhere there is the project's own source.
|
|
1085
|
+
const linksIn = new Map() // directory -> Set of the real package dirs linked into a node_modules it finds packages in
|
|
1086
|
+
const linkedFrom = (dir) => {
|
|
1087
|
+
let links = linksIn.get(dir)
|
|
1088
|
+
if (links !== undefined) return links
|
|
1089
|
+
const parent = dirname(dir)
|
|
1090
|
+
links = new Set(parent === dir ? [] : linkedFrom(parent))
|
|
1091
|
+
const link = (abs) => {
|
|
1092
|
+
try {
|
|
1093
|
+
const rel = toRel(host.realpath(abs))
|
|
1094
|
+
if (!splitNodeModulesPath(rel)) links.add(rel)
|
|
1095
|
+
} catch { /* a dangling link, or one out of the root, whose files the scan pass refused already */ }
|
|
1096
|
+
}
|
|
1097
|
+
const listing = (abs) => {
|
|
1098
|
+
try {
|
|
1099
|
+
return host.readdir(abs)
|
|
1100
|
+
} catch {
|
|
1101
|
+
return []
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
// As locatePackage: a directory named node_modules holds no node_modules of its own to look in.
|
|
1105
|
+
if (basename(dir) !== 'node_modules') {
|
|
1106
|
+
const nodeModules = join(dir, 'node_modules')
|
|
1107
|
+
for (const ent of listing(nodeModules)) {
|
|
1108
|
+
const abs = join(nodeModules, ent.name)
|
|
1109
|
+
if (ent.name.startsWith('@')) {
|
|
1110
|
+
// A scope holds packages, a directory or a link to one (a whole scope linked, where
|
|
1111
|
+
// every package in it is reached through the link).
|
|
1112
|
+
for (const scoped of listing(abs)) {
|
|
1113
|
+
if (ent.isSymbolicLink() || scoped.isSymbolicLink()) link(join(abs, scoped.name))
|
|
1114
|
+
}
|
|
1115
|
+
} else if (ent.isSymbolicLink()) {
|
|
1116
|
+
link(abs)
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
linksIn.set(dir, links)
|
|
1121
|
+
return links
|
|
1122
|
+
}
|
|
1123
|
+
for (const [parent, bySpec] of edges) {
|
|
1124
|
+
for (const byPlatform of bySpec.values()) {
|
|
1125
|
+
for (const target of byPlatform.values()) {
|
|
1126
|
+
if (!isCode(target) || splitNodeModulesPath(target)) continue
|
|
1127
|
+
const links = linkedFrom(dirname(join(baseDir, parent)))
|
|
1128
|
+
// The linked root holding the target, nearest first: its entry may lie below a manifest of
|
|
1129
|
+
// its own (a named dist/package.json), as a node_modules path names the package it's in.
|
|
1130
|
+
let root = posix.dirname(target)
|
|
1131
|
+
while (root !== '.' && !links.has(root)) root = posix.dirname(root)
|
|
1132
|
+
// The project's own source isn't a dependency, nor is an import within the package itself.
|
|
1133
|
+
if (root === '.' || parent.startsWith(`${root}/`) || pkgDirs.has(root)) continue
|
|
1134
|
+
// Named by its own manifest, whatever name it's linked under.
|
|
1135
|
+
pkgDirs.set(root, readPackageJson(baseDir, `${root}/package.json`, { host })?.name)
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
for (const [pkgDir, name] of [...pkgDirs].toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
1140
|
+
const pkgAbs = join(baseDir, pkgDir)
|
|
1141
|
+
// react-native core isn't a Pod (config reports it via reactNativePath, not `dependencies`),
|
|
1142
|
+
// so walk its whole tree for native source (React/, ReactCommon/, ReactAndroid/, ...) the same
|
|
1143
|
+
// way a native dep's ios/android surface is walked; every other dep gets its ios/android + podspecs.
|
|
1144
|
+
const isRnCore = name === 'react-native'
|
|
1145
|
+
const files = isRnCore ? [] : nativeModuleFiles(pkgAbs, host)
|
|
1146
|
+
if (isRnCore) walkNative(pkgAbs, files, host, isNativeSource)
|
|
1147
|
+
for (const abs of files) {
|
|
1148
|
+
const rel = toRel(abs)
|
|
1149
|
+
if (sources.has(rel)) continue
|
|
1150
|
+
assertRealPathWithinBase(realBase, baseDir, rel, host)
|
|
1151
|
+
// classifyNativeCapture (shared with the StasisMetro plugin) returns action skip/code/resource with a format tag.
|
|
1152
|
+
const byName = classifyNativeCapture(rel)
|
|
1153
|
+
if (byName.action === 'skip') continue
|
|
1154
|
+
const buf = host.readFile(abs)
|
|
1155
|
+
// Byte-level rules (prebuilt binaries, binary plists) -- see refineNativeCapture.
|
|
1156
|
+
const { action, format } = refineNativeCapture(byName, rel, buf, resourceSet)
|
|
1157
|
+
if (action === 'skip') continue
|
|
1158
|
+
if (action === 'code') {
|
|
1159
|
+
if (!isUtf8(buf)) throw new Error(`native source is not valid UTF-8: ${rel}`)
|
|
1160
|
+
put(rel, buf.toString('utf8'), format, buf)
|
|
1161
|
+
} else {
|
|
1162
|
+
putResource(rel, buf)
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
// RN core's `.js` build scripts the classify loop skips (Metro-owned by extension, but never in
|
|
1166
|
+
// the graph): force-include as code so the podspec/Ruby that invokes them at pod-install resolves.
|
|
1167
|
+
if (isRnCore) {
|
|
1168
|
+
for (const file of RN_CORE_INCLUDE_FILES) {
|
|
1169
|
+
const abs = join(pkgAbs, file)
|
|
1170
|
+
if (host.stat(abs) === null) continue
|
|
1171
|
+
const rel = toRel(abs)
|
|
1172
|
+
if (sources.has(rel)) continue
|
|
1173
|
+
assertRealPathWithinBase(realBase, baseDir, rel, host)
|
|
1174
|
+
const buf = host.readFile(abs)
|
|
1175
|
+
if (!isUtf8(buf)) throw new Error(`native source is not valid UTF-8: ${rel}`)
|
|
1176
|
+
put(rel, buf.toString('utf8'), packageType(abs, host) === 'module' ? 'module' : 'commonjs', buf)
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// --package-json: fold each bundled module's package.json into `sources` (and its integrity into
|
|
1183
|
+
// the companion lockfile) even when the scan never reached it. Buckets are the ones
|
|
1184
|
+
// assembleCodeBundle derives, from the same `packageOf` (its pkgDir, else the '.' workspace
|
|
1185
|
+
// bucket). readModuleManifest applies the read/validate rules shared with the State path
|
|
1186
|
+
// (containment, UTF-8-aborts); no identity check here -- these buckets are all fresh from disk.
|
|
1187
|
+
if (packageJSON) {
|
|
1188
|
+
const pkgDirs = new Set()
|
|
1189
|
+
for (const abs of reached) {
|
|
1190
|
+
const rel = toRel(abs)
|
|
1191
|
+
const meta = packageOf(rel)
|
|
1192
|
+
if (meta) pkgDirs.add(meta.pkgDir)
|
|
1193
|
+
else if (!splitNodeModulesPath(rel)) pkgDirs.add('.')
|
|
1194
|
+
}
|
|
1195
|
+
for (const pkgDir of pkgDirs) {
|
|
1196
|
+
const rel = moduleFileKey(pkgDir, 'package.json')
|
|
1197
|
+
if (sources.has(rel)) continue
|
|
1198
|
+
const buf = readModuleManifest({ baseDir, realBase, rel, host })
|
|
1199
|
+
if (!buf) continue
|
|
1200
|
+
put(rel, buf.toString('utf8'), 'json', buf)
|
|
1201
|
+
}
|
|
826
1202
|
}
|
|
827
1203
|
|
|
828
|
-
// Collapse each edge: one distinct target across
|
|
829
|
-
// string; otherwise a Map<platform, target> (platform keys only, sorted).
|
|
1204
|
+
// Collapse each edge: one distinct target across platforms -> flat string; else a sorted Map<platform, target>.
|
|
830
1205
|
const resolutions = new Map()
|
|
831
1206
|
for (const [parent, bySpec] of edges) {
|
|
832
1207
|
const specMap = new Map()
|
|
@@ -841,7 +1216,7 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
841
1216
|
resolutions.set(parent, specMap)
|
|
842
1217
|
}
|
|
843
1218
|
|
|
844
|
-
const rootPkg = readJson(join(baseDir, 'package.json')) ?? {}
|
|
1219
|
+
const rootPkg = readJson(join(baseDir, 'package.json'), host) ?? {}
|
|
845
1220
|
const bundle = assembleCodeBundle({
|
|
846
1221
|
baseDir,
|
|
847
1222
|
entries: normalized,
|
|
@@ -851,16 +1226,16 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
851
1226
|
workspaceName: rootPkg.name ?? 'workspace',
|
|
852
1227
|
workspaceVersion: rootPkg.version ?? '0.0.0',
|
|
853
1228
|
conditionKey: '*',
|
|
1229
|
+
host,
|
|
1230
|
+
packageOf,
|
|
854
1231
|
})
|
|
855
1232
|
|
|
856
|
-
// The companion lockfile mirrors the bundle
|
|
857
|
-
// its integrity. modules/imports/formats/entries are shared shapes, so a frozen
|
|
858
|
-
// lockfile attests the same resolution graph the bundle carries.
|
|
1233
|
+
// The companion lockfile mirrors the bundle, swapping file content for its integrity.
|
|
859
1234
|
const lockModules = new Map()
|
|
860
1235
|
for (const [dir, m] of bundle.modules) {
|
|
861
1236
|
const files = Object.create(null)
|
|
862
1237
|
for (const rel of Object.keys(m.files)) files[rel] = integrities.get(moduleFileKey(dir, rel))
|
|
863
|
-
lockModules.set(dir, {
|
|
1238
|
+
lockModules.set(dir, moduleInfo({ ...m, files }))
|
|
864
1239
|
}
|
|
865
1240
|
const lockfile = new Lockfile({
|
|
866
1241
|
config: bundle.config,
|
|
@@ -868,52 +1243,91 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
868
1243
|
modules: lockModules,
|
|
869
1244
|
imports: bundle.imports,
|
|
870
1245
|
formats: bundle.formats,
|
|
1246
|
+
// Same file set as the bundle, so the same executable list attests it.
|
|
1247
|
+
executable: bundle.executable,
|
|
871
1248
|
})
|
|
872
1249
|
|
|
873
1250
|
return { bundle, lockfile }
|
|
874
1251
|
}
|
|
875
1252
|
|
|
876
|
-
// Classify entries into
|
|
877
|
-
//
|
|
878
|
-
|
|
879
|
-
function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditions, mainFields, platforms, metro }) {
|
|
1253
|
+
// Classify entries into their single shared language and check option applicability; `name` prefixes
|
|
1254
|
+
// errors. A directory entry (resolved against `cwd`, on `host`) stands for the .sol files under it: Solidity only.
|
|
1255
|
+
function classifyEntries(name, { cwd = process.cwd(), entries, mappingFile, manifests, scope, lockfile, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests, host = diskHost, fetched }) {
|
|
880
1256
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
881
1257
|
throw new Error(`${name}: at least one entry file is required`)
|
|
882
1258
|
}
|
|
1259
|
+
const dirError = directoryEntryError(entries, cwd, host, { fetched })
|
|
1260
|
+
if (dirError !== null) throw new Error(`${name}: ${dirError}`)
|
|
883
1261
|
let kind
|
|
884
|
-
if (entries.every((e) => e
|
|
1262
|
+
if (entries.every((e) => isSolidityEntry(e, cwd, host))) kind = 'sol'
|
|
885
1263
|
else if (entries.every((e) => e.endsWith('.php'))) kind = 'php'
|
|
886
1264
|
else if (entries.every((e) => JS_EXTS.has(extname(e)))) kind = 'js'
|
|
887
1265
|
else if (entries.every((e) => BASH_EXTS.has(extname(e)))) kind = 'bash'
|
|
888
1266
|
else if (entries.every((e) => RUST_EXTS.has(extname(e)))) kind = 'rust'
|
|
889
1267
|
else {
|
|
890
|
-
throw new Error(`${name}: entries must all be .sol, all be .php, all be .js/.cjs/.mjs/.ts/.cts/.mts, all be .sh/.bash, or all be .rs (no mixing)`)
|
|
1268
|
+
throw new Error(`${name}: entries must all be .sol, all be .php, all be .js/.cjs/.mjs/.ts/.cts/.mts/.jsx/.tsx, all be .sh/.bash, or all be .rs (no mixing)`)
|
|
891
1269
|
}
|
|
892
1270
|
if (mappingFile && kind !== 'sol') {
|
|
893
1271
|
throw new Error(`${name}: --mapping is only valid for .sol bundles`)
|
|
894
1272
|
}
|
|
1273
|
+
// --manifests carries the Solidity build's description files (foundry.toml, remappings.txt, ...).
|
|
1274
|
+
if (manifests && kind !== 'sol') {
|
|
1275
|
+
throw new Error(`${name}: --manifests is only valid for .sol bundles`)
|
|
1276
|
+
}
|
|
1277
|
+
// --cargo runs `cargo metadata` for the Rust feature/dependency resolution and the --cargo-*
|
|
1278
|
+
// flags steer that resolution; nothing else reads Cargo.
|
|
1279
|
+
if (kind !== 'rust') {
|
|
1280
|
+
const given = { cargo, 'cargo-features': Array.isArray(cargoFeatures) && cargoFeatures.length > 0, 'cargo-no-default-features': cargoNoDefaultFeatures, 'cargo-all-features': cargoAllFeatures, 'cargo-target': cargoTarget, 'cargo-manifests': cargoManifests }
|
|
1281
|
+
for (const [flag, on] of Object.entries(given)) if (on) throw new Error(`${name}: --${flag} is only valid for Rust bundles`)
|
|
1282
|
+
}
|
|
895
1283
|
if (scope !== undefined && kind !== 'js') {
|
|
896
1284
|
throw new Error(`${name}: --scope is only valid for JS bundles`)
|
|
897
1285
|
}
|
|
898
1286
|
if (lockfile !== undefined && kind !== 'js') {
|
|
899
1287
|
throw new Error(`${name}: --lockfile is only valid for JS bundles`)
|
|
900
1288
|
}
|
|
901
|
-
//
|
|
902
|
-
// languages' resolvers don't consult -- reject them rather than accept a flag
|
|
903
|
-
// that would silently do nothing.
|
|
1289
|
+
// Reject --conditions for non-JS: their resolvers don't consult exports/imports conditions, so it would silently do nothing.
|
|
904
1290
|
if (Array.isArray(conditions) && conditions.length > 0 && kind !== 'js') {
|
|
905
1291
|
throw new Error(`${name}: --conditions is only valid for JS bundles`)
|
|
906
1292
|
}
|
|
1293
|
+
// --flow strips Flow type syntax before the JS parser; the non-JS loaders never parse with oxc.
|
|
1294
|
+
if (flow && kind !== 'js') {
|
|
1295
|
+
throw new Error(`${name}: --flow is only valid for JS bundles`)
|
|
1296
|
+
}
|
|
1297
|
+
// --typescript maps JS-output specifiers to their on-disk TS sources; only the JS resolvers do it.
|
|
1298
|
+
if (typescript && kind !== 'js') {
|
|
1299
|
+
throw new Error(`${name}: --typescript is only valid for JS bundles`)
|
|
1300
|
+
}
|
|
1301
|
+
// --tsconfig names the config whose `paths` aliases --typescript honours; meaningless without it.
|
|
1302
|
+
if (tsconfig !== undefined && !typescript) {
|
|
1303
|
+
throw new Error(`${name}: --tsconfig is only valid with --typescript`)
|
|
1304
|
+
}
|
|
1305
|
+
// --resources carries allowlisted assets reached through the JS import graph; JS-only. The list
|
|
1306
|
+
// is validated (parseResourcesOption) in the builders, but reject a code extension here too so a
|
|
1307
|
+
// typo surfaces with the command name rather than deep in the scan.
|
|
1308
|
+
if (Array.isArray(resources) && resources.length > 0) {
|
|
1309
|
+
if (kind !== 'js') throw new Error(`${name}: --resources is only valid for JS bundles`)
|
|
1310
|
+
parseResourcesOption(name, resources)
|
|
1311
|
+
}
|
|
907
1312
|
// --mainFields and --metro/--platforms are legacy-/platform-resolution knobs; JS-only.
|
|
908
1313
|
if (mainFields !== undefined && kind !== 'js') {
|
|
909
1314
|
throw new Error(`${name}: --mainFields is only valid for JS bundles`)
|
|
910
1315
|
}
|
|
1316
|
+
// --jsx toggles the scanner's JSX parsing for the .js family (.jsx/.tsx need no flag); only JS
|
|
1317
|
+
// entries are scanned.
|
|
1318
|
+
if (jsx && kind !== 'js') {
|
|
1319
|
+
throw new Error(`${name}: --jsx is only valid for JS bundles`)
|
|
1320
|
+
}
|
|
1321
|
+
// --package-json folds each bundled module's npm package.json in; JS-only (the sol/php/bash/rust
|
|
1322
|
+
// bucketizers group by their own ecosystems' manifests, not an npm package.json).
|
|
1323
|
+
if (packageJSON && kind !== 'js') {
|
|
1324
|
+
throw new Error(`${name}: --package-json is only valid for JS bundles`)
|
|
1325
|
+
}
|
|
911
1326
|
if ((metro || (Array.isArray(platforms) && platforms.length > 0)) && kind !== 'js') {
|
|
912
1327
|
throw new Error(`${name}: --metro is only valid for JS bundles`)
|
|
913
1328
|
}
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
// --platforms to know which suffixes to resolve. `--platforms` is meaningless without it.
|
|
1329
|
+
// --metro presets conditions + mainFields + platform suffixes, so it can't combine with
|
|
1330
|
+
// explicit --conditions/--mainFields and requires --platforms; --platforms is meaningless without it.
|
|
917
1331
|
if (metro) {
|
|
918
1332
|
if (Array.isArray(conditions) && conditions.length > 0) {
|
|
919
1333
|
throw new Error(`${name}: --conditions can't be combined with --metro (it sets its own conditions)`)
|
|
@@ -924,9 +1338,8 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
|
|
|
924
1338
|
if (!Array.isArray(platforms) || platforms.length === 0) {
|
|
925
1339
|
throw new Error(`${name}: --metro requires --platforms (e.g. --platforms=ios,android)`)
|
|
926
1340
|
}
|
|
927
|
-
// A platform name becomes
|
|
928
|
-
//
|
|
929
|
-
// never emit an edge map the readers refuse -- a divergent-edge, data-dependent break.
|
|
1341
|
+
// A platform name becomes an edge key: reject '/' (Bundle/Lockfile parse refuse it) and
|
|
1342
|
+
// the reserved '*' placeholder, so the writer never emits a map the readers reject.
|
|
930
1343
|
for (const p of platforms) {
|
|
931
1344
|
if (typeof p !== 'string' || p.length === 0 || p === '*' || p.includes('/')) {
|
|
932
1345
|
throw new Error(`${name}: invalid platform '${p}' (a platform name can't be empty, contain '/', or be '*')`)
|
|
@@ -935,145 +1348,199 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
|
|
|
935
1348
|
} else if (Array.isArray(platforms) && platforms.length > 0) {
|
|
936
1349
|
throw new Error(`${name}: --platforms is only valid with --metro`)
|
|
937
1350
|
}
|
|
938
|
-
//
|
|
939
|
-
|
|
1351
|
+
// --metro-resolver swaps in the project's real metro-resolver; it only means something under --metro.
|
|
1352
|
+
if (metroResolver && !metro) {
|
|
1353
|
+
throw new Error(`${name}: --metro-resolver is only valid with --metro`)
|
|
1354
|
+
}
|
|
1355
|
+
// metro-resolver has no TS extension substitution, so --typescript would silently not apply -- reject it.
|
|
1356
|
+
if (typescript && metroResolver) {
|
|
1357
|
+
throw new Error(`${name}: --typescript is not supported with --metro-resolver (the project's metro-resolver doesn't substitute .js -> .ts)`)
|
|
1358
|
+
}
|
|
1359
|
+
// The field resolver always emits full-scope, so --scope with --mainFields/--metro would be silently ignored -- reject it.
|
|
940
1360
|
if (scope !== undefined && (mainFields !== undefined || metro)) {
|
|
941
1361
|
throw new Error(`${name}: --scope is not supported with --mainFields or --metro`)
|
|
942
1362
|
}
|
|
943
1363
|
return kind
|
|
944
1364
|
}
|
|
945
1365
|
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
//
|
|
950
|
-
//
|
|
951
|
-
|
|
952
|
-
// .sol (Solidity), .php (PHP), .js/.cjs/.mjs/.ts/.cts/.mts (JS/TS, via static
|
|
953
|
-
// scan), .sh/.bash (Bash), or .rs (Rust); mixing fails fast. `mappingFile` is
|
|
954
|
-
// the optional Solidity remappings file (foundry.toml or remappings.txt) and
|
|
955
|
-
// is only valid for .sol entries; `scope` overrides the configured bundle
|
|
956
|
-
// scope and is only valid for JS/TS entries; `conditions` are extra `exports`
|
|
957
|
-
// resolution conditions (e.g. `['react-native']`) and are JS/TS-only too.
|
|
958
|
-
//
|
|
959
|
-
// `mainFields` (an array of legacy package fields, e.g. `['react-native','browser','main']`)
|
|
960
|
-
// selects the legacy-field resolver -- see buildResolvedJsBundle. It resolves
|
|
961
|
-
// non-`exports` packages via those fields, including the browser-field object
|
|
962
|
-
// redirection, and composes with `conditions`. `metro: true` is instead a preset that
|
|
963
|
-
// sets its OWN React Native fields + conditions + platform suffixes (so it is not
|
|
964
|
-
// combined with `mainFields`/`conditions`) and requires `platforms` (e.g.
|
|
965
|
-
// `['ios','android']`); the edge graph is then per-platform. JS/TS-only.
|
|
966
|
-
export async function buildBundle({ cwd = process.cwd(), entries, mappingFile, scope, conditions, mainFields, platforms, metro } = {}) {
|
|
967
|
-
const kind = classifyEntries('buildBundle', { entries, mappingFile, scope, conditions, mainFields, platforms, metro })
|
|
968
|
-
if (kind === 'sol') return buildSolidityBundle({ cwd, entries, mappingFile })
|
|
969
|
-
if (kind === 'php') return buildPhpBundle({ cwd, entries })
|
|
970
|
-
if (kind === 'bash') return buildBashBundle({ cwd, entries })
|
|
971
|
-
if (kind === 'rust') return buildRustBundle({ cwd, entries })
|
|
1366
|
+
// A JS bundle as `stasis bundle` builds it: by the legacy-field resolver with `mainFields` or
|
|
1367
|
+
// `metro`, whose per-platform edges and synthetic empty module a State can't hold, else through a
|
|
1368
|
+
// State, which detects the bundle's repo itself. `root` is the directory the bundle's paths are
|
|
1369
|
+
// relative to: cwd for the resolver's, the State's root for the State's.
|
|
1370
|
+
// -> { bundle, lockfile: () => Lockfile, stateBuilt, root }
|
|
1371
|
+
async function buildJs({ mainFields, platforms, metro, metroResolver, innermostRoot, ...options }) {
|
|
972
1372
|
if (metro || mainFields !== undefined) {
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
metro: Boolean(metro),
|
|
980
|
-
})
|
|
981
|
-
return bundle
|
|
982
|
-
}
|
|
983
|
-
const state = await buildJsBundle({ cwd, entries, scope, conditions })
|
|
984
|
-
return state.sourceBundle
|
|
1373
|
+
const built = await buildResolvedJsBundle({ ...options, mainFields: metro ? METRO_MAIN_FIELDS : mainFields, platforms: metro ? platforms : [null], metro: Boolean(metro), metroResolver: Boolean(metroResolver) })
|
|
1374
|
+
return { bundle: built.bundle, lockfile: () => built.lockfile, stateBuilt: false, root: resolve(options.cwd) }
|
|
1375
|
+
}
|
|
1376
|
+
const state = await buildJsBundle({ ...options, innermostRoot })
|
|
1377
|
+
// Stamp the `bundle` consumer (the static build carries none).
|
|
1378
|
+
return { bundle: state.sourceBundle.withReason('bundle'), lockfile: () => state.lockfile, stateBuilt: true, root: state.root }
|
|
985
1379
|
}
|
|
986
1380
|
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1381
|
+
// The bundle of `kind` (classifyEntries') built from buildBundle's options, from disk: a JS bundle's
|
|
1382
|
+
// git dependencies at the commits their package managers' records name (pinInstalledCommits).
|
|
1383
|
+
// -> { bundle, lockfile: () => Lockfile, stateBuilt } (the last two of a JS bundle alone)
|
|
1384
|
+
async function buildOfKind(kind, { cwd, env, entries, mappingFile, manifests, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests }) {
|
|
1385
|
+
if (kind === 'sol') return { bundle: await buildSolidityBundle({ cwd, env, entries, mappingFile, manifests }) }
|
|
1386
|
+
if (kind === 'php') return { bundle: await buildPhpBundle({ cwd, entries }) }
|
|
1387
|
+
if (kind === 'bash') return { bundle: await buildBashBundle({ cwd, entries }) }
|
|
1388
|
+
if (kind === 'rust') return { bundle: await buildRustBundle({ cwd, entries, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests }) }
|
|
1389
|
+
const built = await buildJs({ cwd, env, entries, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON })
|
|
1390
|
+
pinInstalledCommits(built.bundle, built.root)
|
|
1391
|
+
return built
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// buildVfsBundle's checks of `options`, which hold before anything is fetched (`host` the
|
|
1395
|
+
// project's, or an empty tree's with `fetched: false`): no metro-resolver, which reads the disk;
|
|
1396
|
+
// `generate` only for JS.
|
|
1397
|
+
// -> the kind of bundle its entries make
|
|
1398
|
+
export function checkVfsOptions(name, options) {
|
|
1399
|
+
const kind = classifyEntries(name, options)
|
|
1400
|
+
if (options.metroResolver) throw new Error(`${name}: metroResolver is not supported`)
|
|
1401
|
+
const { generate = [] } = options
|
|
1402
|
+
if (!Array.isArray(generate) || generate.some((step) => step !== 'prisma')) throw new TypeError(`${name}: generate must be an array of 'prisma'`)
|
|
1403
|
+
if (generate.length > 0 && kind !== 'js') throw new Error(`${name}: --generate is only valid for JS bundles`)
|
|
1404
|
+
return kind
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// A bundle from the lockfile of the project held in `vfs` alone (@exodus/stasis/vfs-bundle), `cwd` a
|
|
1408
|
+
// path there: buildBundle's JS options, resolved through the node_modules 'pnpm', 'yarn1' or 'npm'
|
|
1409
|
+
// would install, or its Solidity options, through the dependencies folder 'soldeer' would install; with
|
|
1410
|
+
// nothing read from disk but tarballs and zips, nor from the environment: a Solidity bundle is
|
|
1411
|
+
// built with foundry.toml's default profile, whatever FOUNDRY_PROFILE or remappings one sets, and no
|
|
1412
|
+
// EXODUS_STASIS_* setting is read. `repo`, the informational `{ github, directory, commit }`,
|
|
1413
|
+
// is the Bundle's, over what is detected in the Vfs as `stasis bundle` detects it on disk. `os`,
|
|
1414
|
+
// `cpu` and `libc` are loadNodeModules'. Without a `packageManager`, it is the one whose lockfile
|
|
1415
|
+
// installs cwd, where only one's does. `innermostRoot` is buildJsBundle's, for a JS bundle built
|
|
1416
|
+
// through a State. `generate: ['prisma']` writes each project's Prisma Client into the laid-out
|
|
1417
|
+
// tree before the scan, leaving `vfs` as it is (vfs-bundle/prisma.js). `client`, a
|
|
1418
|
+
// @preventive/upstream/github.js client, is what a Soldeer git dependency is fetched through; without
|
|
1419
|
+
// one, a soldeer.lock holding one is refused, naming deptree's `github`, before anything is fetched.
|
|
1420
|
+
// `cache` is where npm packages' tarballs and version documents are kept (checkCache). Each
|
|
1421
|
+
// dependency is recorded at the commit the tree installs it from, where deptree names one
|
|
1422
|
+
// (pinCommits). `root` is the directory in the Vfs the bundle's paths are relative to: cwd, or for a
|
|
1423
|
+
// JS bundle built through a State, the State's root, which is at or above it.
|
|
1424
|
+
// -> { bundle: Bundle, lockfile: Lockfile (of a JS bundle), stats, packageManager, root }
|
|
1425
|
+
export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageManagerVersion, os, cpu, libc, repo, innermostRoot = false, generate = [], client, cache, ...options } = {}) {
|
|
1426
|
+
const { checkCache, checkKind, checkTarget, checkVfs, loadTree, packageManagerFor, packageManagerOf, pinCommits, treeHost, vfsHost } = await import('../vfs-bundle/tree.js')
|
|
1427
|
+
checkVfs('buildVfsBundle', vfs)
|
|
1428
|
+
checkTarget('buildVfsBundle', { os, cpu, libc })
|
|
1429
|
+
checkCache('buildVfsBundle', cache)
|
|
1430
|
+
// Checked as the Bundle checks it, before anything is fetched.
|
|
1431
|
+
if (repo !== undefined) repo = new Bundle({ repo }).repo
|
|
1432
|
+
const project = vfsHost(vfs)
|
|
1433
|
+
// A real path, as every file the scan reaches is.
|
|
1434
|
+
cwd = project.realpath(posix.resolve('/', cwd))
|
|
1435
|
+
packageManager = packageManagerFor('buildVfsBundle', project, cwd, { packageManager, packageManagerVersion, os })
|
|
1436
|
+
const pm = packageManagerOf('buildVfsBundle', packageManager)
|
|
1437
|
+
checkKind('buildVfsBundle', checkVfsOptions('buildVfsBundle', { ...options, generate, cwd, host: project }), [packageManager])
|
|
1438
|
+
// Checked before anything is fetched: an entry out of what the tree installs is in the project
|
|
1439
|
+
// already. (A Solidity entry that is no .sol file is a directory, skipped where it is missing.)
|
|
1440
|
+
for (const entry of options.entries) {
|
|
1441
|
+
const abs = posix.resolve(cwd, entry)
|
|
1442
|
+
if (pm.kind === 'sol' && !abs.endsWith('.sol')) continue
|
|
1443
|
+
if (!posix.relative(cwd, abs).split('/').includes(pm.installs) && project.stat(abs) === null) throw new Error(`entry not found: ${abs}`)
|
|
1444
|
+
}
|
|
1445
|
+
const tree = await loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, client, cache })
|
|
1446
|
+
let { host } = tree
|
|
1447
|
+
if (generate.includes('prisma')) {
|
|
1448
|
+
const { generatePrismaClients, withPrismaClients } = await import('../vfs-bundle/prisma.js')
|
|
1449
|
+
const outputs = await generatePrismaClients({ host, root: tree.root, projects: tree.projects })
|
|
1450
|
+
if (outputs.length > 0) host = treeHost(tree, vfsHost(withPrismaClients(vfs, outputs)))
|
|
1451
|
+
}
|
|
1452
|
+
const args = { ...options, cwd, host, env: {} }
|
|
1453
|
+
const { bundle, lockfile, stateBuilt, root } = pm.kind === 'sol' ? { bundle: await buildSolidityBundle(args), root: cwd } : await buildJs({ ...args, innermostRoot })
|
|
1454
|
+
if (repo !== undefined) bundle.repo = repo
|
|
1455
|
+
// Rooted at cwd, where `stasis bundle` detects its repo.
|
|
1456
|
+
else if (!stateBuilt) bundle.repo ??= detectRepo(cwd, host)
|
|
1457
|
+
pinCommits(bundle, root, tree)
|
|
1458
|
+
return { bundle, lockfile: lockfile?.(), stats: tree.stats, packageManager, root }
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Programmatic equivalent of `stasis bundle`: build and return an in-memory Bundle without
|
|
1462
|
+
// writing to disk. Files are attributed to the `bundle` consumer. Option applicability
|
|
1463
|
+
// (--mapping|--manifests/.sol, --scope|--conditions|--mainFields|--metro|--jsx|--flow|--typescript/JS) is enforced by classifyEntries.
|
|
1464
|
+
export async function buildBundle({ cwd = process.cwd(), env = process.env, entries, mappingFile, manifests = false, scope, conditions, mainFields, platforms, metro, metroResolver, jsx = false, flow = false, typescript = false, tsconfig, resources = [], packageJSON = false, cargo = false, cargoFeatures = [], cargoNoDefaultFeatures = false, cargoAllFeatures = false, cargoTarget = null, cargoManifests = false } = {}) {
|
|
1465
|
+
const options = { cwd, env, entries, mappingFile, manifests, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests }
|
|
1466
|
+
return (await buildOfKind(classifyEntries('buildBundle', options), options)).bundle
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// Run `stasis bundle`: build a brotli-compressed bundle and write it to `output`
|
|
1470
|
+
// (stasis.code.br by default, `-` for stdout; the summary goes to stderr so it never
|
|
1471
|
+
// interleaves with binary stdout). An optional JS `lockfile` attests every bundled file;
|
|
1472
|
+
// with `--conditions` it attests the conditions-selected graph, so a plain
|
|
1473
|
+
// `stasis run --lock=frozen` (which doesn't replay them) fails closed -- pair it with
|
|
1474
|
+
// `--bundle=load` or replay the conditions. `add` unions the fresh build into the bundle
|
|
1475
|
+
// already on disk (strict; a conflicting file throws) and can't target stdout.
|
|
1476
|
+
export async function bundleCommand({ cwd = process.cwd(), env = process.env, entries, mappingFile, manifests = false, output, scope, lockfile, conditions, mainFields, platforms, metro, metroResolver, jsx = false, flow = false, typescript = false, tsconfig, resources = [], packageJSON = false, cargo = false, cargoFeatures = [], cargoNoDefaultFeatures = false, cargoAllFeatures = false, cargoTarget = null, cargoManifests = false, brotliQuality, add = false } = {}) {
|
|
1477
|
+
const options = { cwd, env, entries, mappingFile, manifests, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests }
|
|
1478
|
+
const kind = classifyEntries('bundleCommand', { ...options, lockfile })
|
|
1479
|
+
|
|
1480
|
+
// --add has nothing to merge into on stdout (write-only).
|
|
1481
|
+
if (add && output === '-') {
|
|
1482
|
+
throw new Error('bundleCommand: --add cannot be combined with --output=- (nothing to merge into on stdout)')
|
|
1051
1483
|
}
|
|
1484
|
+
|
|
1485
|
+
const built = await buildOfKind(kind, options)
|
|
1486
|
+
let { bundle } = built
|
|
1487
|
+
// The directory the bundle's paths are relative to: a State-built JS bundle's root, which is the
|
|
1488
|
+
// project's at or above cwd (a workspace package's is the workspace root's), else cwd.
|
|
1489
|
+
const root = built.root ?? resolve(cwd)
|
|
1490
|
+
// By default the bundle goes there, where `stasis run --bundle=load` discovers it. In cwd below
|
|
1491
|
+
// the root it would load as rooted at cwd, and root the next `stasis bundle` there too, out of the
|
|
1492
|
+
// files above it.
|
|
1493
|
+
const target = output ?? relative(resolve(cwd), join(root, DEFAULT_BUNDLE_FILE))
|
|
1494
|
+
// Only a JS bundle has a lockfile (classifyEntries refuses one for the others).
|
|
1495
|
+
let lockData = lockfile ? built.lockfile().serialize() : undefined
|
|
1496
|
+
|
|
1497
|
+
// State-built bundles keep the State root's repo (no cwd fallback: their paths are relative to that root).
|
|
1498
|
+
if (!built.stateBuilt) bundle.repo ??= detectRepo(cwd)
|
|
1499
|
+
|
|
1500
|
+
// --add: union the fresh build into the existing on-disk bundle; a conflicting file throws. Skipped when nothing is on disk.
|
|
1501
|
+
const outAbs = target === '-' ? undefined : resolve(cwd, target)
|
|
1502
|
+
const lockAbs = lockData ? resolve(cwd, lockfile) : undefined
|
|
1503
|
+
// Files the pre-existing bundle carried; undefined when no merge happened (the summary's merged sentinel).
|
|
1504
|
+
let mergedFrom
|
|
1505
|
+
if (add && existsSync(outAbs)) {
|
|
1506
|
+
let existing
|
|
1507
|
+
try {
|
|
1508
|
+
existing = Bundle.parse(brotliDecompressSync(readFileSync(outAbs)).toString('utf8'))
|
|
1509
|
+
} catch (cause) {
|
|
1510
|
+
throw new Error(`bundleCommand: --add failed to read the existing bundle at ${target}`, { cause })
|
|
1511
|
+
}
|
|
1512
|
+
mergedFrom = existing.sources.size
|
|
1513
|
+
bundle = existing.merge(bundle)
|
|
1514
|
+
// Keep the companion lockfile consistent with the merged bundle: union an existing one.
|
|
1515
|
+
// With none on disk we can't attest the pre-existing bundle's files (their hashes live
|
|
1516
|
+
// only in that missing lockfile), so refuse rather than write an under-attesting lockfile.
|
|
1517
|
+
if (lockData) {
|
|
1518
|
+
if (!existsSync(lockAbs)) {
|
|
1519
|
+
throw new Error(`bundleCommand: --add can't write a complete lockfile at ${lockfile}: the bundle at ${target} already carries files a fresh lockfile wouldn't attest, and there's no existing lockfile to merge into. Write --lockfile alongside the bundle from the first build, or drop --lockfile.`)
|
|
1520
|
+
}
|
|
1521
|
+
let existingLock
|
|
1522
|
+
try {
|
|
1523
|
+
existingLock = Lockfile.parse(readFileSync(lockAbs, 'utf8'))
|
|
1524
|
+
} catch (cause) {
|
|
1525
|
+
throw new Error(`bundleCommand: --add failed to read the existing lockfile at ${lockfile}`, { cause })
|
|
1526
|
+
}
|
|
1527
|
+
lockData = existingLock.merge(Lockfile.parse(lockData)).serialize()
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1052
1531
|
const serialized = bundle.serialize()
|
|
1053
1532
|
const files = [...bundle.sources.keys()]
|
|
1054
1533
|
const modules = bundle.modules
|
|
1055
1534
|
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
//
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1535
|
+
const dest = writeBundle(cwd, target, serialized, brotliQuality)
|
|
1536
|
+
if (lockData) writeFile(lockAbs, lockData)
|
|
1537
|
+
// The bundle's paths are relative to its root; shown relative to cwd, as `dest` is.
|
|
1538
|
+
const fromDir = posix.join(toPosix(relative(resolve(cwd), root)), outermostDir(files, root))
|
|
1539
|
+
// On a merge, report newly-added files alongside the totals; a fresh write keeps the plain line.
|
|
1540
|
+
if (mergedFrom !== undefined) {
|
|
1541
|
+
const added = files.length - mergedFrom
|
|
1542
|
+
console.warn(`[stasis] Added ${added} file${added === 1 ? '' : 's'} (${files.length} total in ${packagesLabel(modules)}) from ${fromDir} to ${dest}`)
|
|
1062
1543
|
} else {
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
writeFileSync(outAbs, data)
|
|
1066
|
-
}
|
|
1067
|
-
if (lockData) {
|
|
1068
|
-
const lockAbs = resolve(cwd, lockfile)
|
|
1069
|
-
mkdirSync(dirname(lockAbs), { recursive: true })
|
|
1070
|
-
writeFileSync(lockAbs, lockData)
|
|
1071
|
-
}
|
|
1072
|
-
const fromDir = outermostDir(files, resolve(cwd))
|
|
1073
|
-
const dest = target === '-' ? '<stdout>' : target
|
|
1074
|
-
// Count packages: every non-empty bucket, with the project's own source (the
|
|
1075
|
-
// "." workspace bucket) counting as one package alongside each dependency.
|
|
1076
|
-
const packages = [...modules.values()].filter((m) => Object.keys(m.files).length > 0).length
|
|
1077
|
-
const pkgLabel = `${packages} package${packages === 1 ? '' : 's'}`
|
|
1078
|
-
console.warn(`[stasis] Bundled ${files.length} files in ${pkgLabel} from ${fromDir} to ${dest}`)
|
|
1544
|
+
console.warn(bundledSummary(files.length, modules, fromDir, dest))
|
|
1545
|
+
}
|
|
1079
1546
|
}
|