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