@exodus/stasis 1.0.0-beta.3 → 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 +7 -1
- package/bin/stasis.js +236 -17
- package/package.json +36 -9
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +153 -108
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +3 -5
- package/src/cmd/bundle.js +571 -376
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +3 -11
- package/src/diff.js +6 -6
- package/src/json-stream.js +283 -0
- package/src/loaders/bash.js +17 -36
- 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 +208 -124
- package/src/loaders/rust.js +3234 -166
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -137
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +8 -23
- package/src/mock.js +3 -5
- 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 +32 -18
- package/src/scan.js +79 -43
- 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 -42
package/src/cmd/bundle.js
CHANGED
|
@@ -1,33 +1,41 @@
|
|
|
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'
|
|
11
12
|
import { createMetroResolver } from '../metro-resolver.js'
|
|
12
13
|
import { State } from '@exodus/stasis-core/state'
|
|
13
|
-
import { brotliOptions } from '@exodus/stasis-core/brotli'
|
|
14
14
|
import { sha512integrity } from '@exodus/stasis-core/state-util'
|
|
15
|
-
import { findPackageMetadata, normalizeEntries, packageType, readJson, readModuleManifest } from '@exodus/stasis-core/bundle-util'
|
|
16
|
-
import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util'
|
|
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'
|
|
17
18
|
import {
|
|
19
|
+
SOLIDITY_PACKAGE_MANIFESTS,
|
|
20
|
+
SOLIDITY_ROOT_MANIFESTS,
|
|
18
21
|
buildSolidityTree,
|
|
19
22
|
collectSolidityFilesFromDisk,
|
|
20
|
-
|
|
23
|
+
discoverSolidityConfig,
|
|
24
|
+
expandSolidityEntries,
|
|
21
25
|
} from '../loaders/solidity.js'
|
|
26
|
+
import { decodeUtf8 } from '../loaders/solidity-ownership.js'
|
|
22
27
|
import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js'
|
|
23
|
-
import {
|
|
28
|
+
import { boundaryOf, collectRustBundle, withinRealDir } from '../loaders/rust.js'
|
|
29
|
+
import { createCargoContext } from '../loaders/cargo.js'
|
|
24
30
|
import {
|
|
25
31
|
bucketizePhpSources,
|
|
26
32
|
buildPhpTree,
|
|
27
33
|
collectPhpFilesFromDisk,
|
|
28
34
|
loadComposerAutoload,
|
|
35
|
+
loadComposerPackages,
|
|
29
36
|
loadLaravelProviderFiles,
|
|
30
37
|
} from '../loaders/php.js'
|
|
38
|
+
import { DEFAULT_BUNDLE_FILE, bundledSummary, packagesLabel, writeBundle, writeFile } from './output.js'
|
|
31
39
|
|
|
32
40
|
const JS_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
|
|
33
41
|
const BASH_EXTS = new Set(['.sh', '.bash'])
|
|
@@ -53,7 +61,7 @@ const PHP_FORMAT = 'php'
|
|
|
53
61
|
// escape cwd (e.g. via remapping), or "." when it is cwd itself.
|
|
54
62
|
export function outermostDir(paths, cwd) {
|
|
55
63
|
if (paths.length === 0) return '.'
|
|
56
|
-
const cwdAbs = posix.resolve(cwd
|
|
64
|
+
const cwdAbs = posix.resolve(toPosix(cwd))
|
|
57
65
|
const absDirs = paths.map((p) => posix.dirname(posix.resolve(cwdAbs, p)))
|
|
58
66
|
const partsList = absDirs.map((d) => d.split('/'))
|
|
59
67
|
const common = []
|
|
@@ -80,50 +88,24 @@ function githubSlug(url) {
|
|
|
80
88
|
return m ? `${m[1]}/${m[2]}` : null
|
|
81
89
|
}
|
|
82
90
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
function parseGithubSubmodules(baseDir) {
|
|
91
|
+
// The github.com ones of `submodules` (readGitmodules), as Map<submodulePath, { name, branch }>.
|
|
92
|
+
function githubSubmodules(submodules) {
|
|
86
93
|
const byPath = new Map()
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const flush = () => {
|
|
91
|
-
if (cur?.path && cur?.url) {
|
|
92
|
-
const name = githubSlug(cur.url)
|
|
93
|
-
if (name) byPath.set(cur.path.replace(/\/+$/u, ''), { name, branch: cur.branch })
|
|
94
|
-
}
|
|
95
|
-
cur = null
|
|
94
|
+
for (const { path, url, branch } of submodules) {
|
|
95
|
+
const name = url && githubSlug(url)
|
|
96
|
+
if (name) byPath.set(path, { name, branch })
|
|
96
97
|
}
|
|
97
|
-
for (const raw of text.split('\n')) {
|
|
98
|
-
const line = raw.trim()
|
|
99
|
-
if (line.startsWith('[')) {
|
|
100
|
-
flush()
|
|
101
|
-
cur = line.startsWith('[submodule') ? {} : null
|
|
102
|
-
continue
|
|
103
|
-
}
|
|
104
|
-
if (!cur) continue
|
|
105
|
-
const eq = line.indexOf('=')
|
|
106
|
-
if (eq === -1) continue
|
|
107
|
-
const key = line.slice(0, eq).trim()
|
|
108
|
-
if (key === 'path' || key === 'url' || key === 'branch') cur[key] = line.slice(eq + 1).trim()
|
|
109
|
-
}
|
|
110
|
-
flush()
|
|
111
98
|
return byPath
|
|
112
99
|
}
|
|
113
100
|
|
|
114
|
-
function readFileSyncOrNull(file) {
|
|
115
|
-
try {
|
|
116
|
-
return readFileSync(file, 'utf8')
|
|
117
|
-
} catch {
|
|
118
|
-
return null
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
101
|
// Classify a Solidity file's dep bucket: Soldeer (`dependencies/<name>-<version>/`) or a
|
|
123
|
-
// github submodule (`lib/`, via `.gitmodules`), else null to defer to the
|
|
124
|
-
// workspace logic.
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
127
109
|
return (path) => {
|
|
128
110
|
if (path.startsWith('dependencies/')) {
|
|
129
111
|
const seg = path.slice('dependencies/'.length).split('/')[0]
|
|
@@ -134,63 +116,83 @@ function makeSolidityClassifier(baseDir) {
|
|
|
134
116
|
}
|
|
135
117
|
for (const [sub, { name, branch }] of submodules) {
|
|
136
118
|
if (path === sub || path.startsWith(`${sub}/`)) {
|
|
137
|
-
|
|
138
|
-
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' }
|
|
139
121
|
}
|
|
140
122
|
}
|
|
141
123
|
return null
|
|
142
124
|
}
|
|
143
125
|
}
|
|
144
126
|
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
const name = /^\s*name\s*=\s*"([^"]+)"/mu.exec(body)?.[1]
|
|
157
|
-
const version = /^\s*version\s*=\s*"([^"]+)"/mu.exec(body)?.[1]
|
|
158
|
-
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
|
+
}
|
|
159
138
|
}
|
|
160
139
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
171
156
|
}
|
|
172
157
|
}
|
|
173
158
|
|
|
174
159
|
// Project-relative paths in `sources` whose on-disk file carries a POSIX execute bit -- the
|
|
175
160
|
// `executable` list both artifacts record. The State-driven path derives this in addFile; the
|
|
176
161
|
// static builders never touch a State, so they stat here.
|
|
177
|
-
function executableSources(baseDir, sources) {
|
|
162
|
+
function executableSources(baseDir, sources, host) {
|
|
178
163
|
const executable = new Set()
|
|
179
164
|
for (const path of sources.keys()) {
|
|
180
|
-
if (isExecutableFile(join(baseDir, path))) executable.add(path)
|
|
165
|
+
if (isExecutableFile(join(baseDir, path), host)) executable.add(path)
|
|
181
166
|
}
|
|
182
167
|
return executable
|
|
183
168
|
}
|
|
184
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')}`)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
185
|
// Assemble a full-scope code Bundle shared by the non-JS bundlers. Files are bucketed by
|
|
186
186
|
// nearest package.json (node_modules -> `npm`-tagged bucket, workspace -> its dir, none ->
|
|
187
187
|
// "." with the placeholder identity); a node_modules file whose nearest package.json is the
|
|
188
|
-
// workspace root is rejected, not mislabeled
|
|
189
|
-
//
|
|
190
|
-
//
|
|
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
|
|
191
192
|
// a flat target string or a Map<platform,target>; both round-trip untouched.
|
|
192
193
|
function assembleCodeBundle({
|
|
193
|
-
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 }),
|
|
194
196
|
}) {
|
|
195
197
|
const modules = new Map()
|
|
196
198
|
const ensureBucket = (dir, name, version, bucketEcosystem) => {
|
|
@@ -205,25 +207,29 @@ function assembleCodeBundle({
|
|
|
205
207
|
for (const [path, content] of sources) {
|
|
206
208
|
const dep = classifyDep?.(path)
|
|
207
209
|
if (dep) {
|
|
208
|
-
ensureBucket(dep.bucketDir, dep.name, dep.version, dep.ecosystem)
|
|
209
|
-
.files[path.slice(dep.bucketDir.length + 1)] = content
|
|
210
|
+
ensureBucket(dep.bucketDir, dep.name, dep.version, dep.ecosystem).files[fileInBucket(dep.bucketDir, path)] = content
|
|
210
211
|
continue
|
|
211
212
|
}
|
|
212
|
-
const meta =
|
|
213
|
+
const meta = packageOf(path)
|
|
213
214
|
const inNodeModules = splitNodeModulesPath(path) !== null
|
|
214
215
|
if (meta) {
|
|
215
|
-
if (inNodeModules && !meta.pkgDir
|
|
216
|
+
if (inNodeModules && !hasNodeModulesSegment(meta.pkgDir)) {
|
|
216
217
|
throw new Error(`No package.json with name+version found for ${path}`)
|
|
217
218
|
}
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
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
|
|
221
221
|
} else {
|
|
222
222
|
if (inNodeModules) throw new Error(`No package.json with name+version found for ${path}`)
|
|
223
223
|
ensureBucket('.', workspaceName, workspaceVersion).files[path] = content
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
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 }) {
|
|
227
233
|
const formatsMap = new Map()
|
|
228
234
|
for (const path of sources.keys()) formatsMap.set(path, formats?.get(path) ?? format)
|
|
229
235
|
|
|
@@ -233,7 +239,7 @@ function assembleCodeBundle({
|
|
|
233
239
|
|
|
234
240
|
// Executable bits, straight off disk (a synthetic source with no file there is simply not
|
|
235
241
|
// executable). Shell bundles lean on this most: `stasis extract` puts the +x back on the scripts.
|
|
236
|
-
const executable = executableSources(baseDir, sources)
|
|
242
|
+
const executable = executableSources(baseDir, sources, host)
|
|
237
243
|
|
|
238
244
|
// Attribute files to the `bundle` consumer (static builders skip State's per-file tagging).
|
|
239
245
|
return new Bundle({
|
|
@@ -246,45 +252,142 @@ function assembleCodeBundle({
|
|
|
246
252
|
}).withReason('bundle')
|
|
247
253
|
}
|
|
248
254
|
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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)
|
|
254
266
|
}
|
|
255
|
-
|
|
256
|
-
|
|
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')
|
|
257
350
|
}
|
|
258
351
|
|
|
259
352
|
const baseDir = resolve(cwd)
|
|
260
|
-
const remappings = mappingFile ? await readRemappingsFile(resolve(cwd, mappingFile)) : []
|
|
261
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)
|
|
262
358
|
|
|
263
|
-
const
|
|
264
|
-
|
|
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 })
|
|
265
364
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
}
|
|
276
376
|
}
|
|
277
377
|
|
|
278
378
|
return assembleCodeBundle({
|
|
279
379
|
baseDir,
|
|
280
|
-
entries:
|
|
281
|
-
sources,
|
|
380
|
+
entries: expanded,
|
|
381
|
+
sources: bundled,
|
|
282
382
|
resolutions,
|
|
283
383
|
workspaceName: SOLIDITY_WORKSPACE_NAME,
|
|
284
384
|
workspaceVersion: SOLIDITY_WORKSPACE_VERSION,
|
|
285
385
|
format: SOLIDITY_FORMAT,
|
|
386
|
+
formats,
|
|
286
387
|
conditionKey: 'solidity',
|
|
287
|
-
classifyDep
|
|
388
|
+
classifyDep,
|
|
389
|
+
packageOf,
|
|
390
|
+
host,
|
|
288
391
|
})
|
|
289
392
|
}
|
|
290
393
|
|
|
@@ -305,17 +408,8 @@ export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
305
408
|
const sources = await collectBashFilesFromDisk(baseDir, normalized)
|
|
306
409
|
const { resolutions, missing } = buildBashTree(sources)
|
|
307
410
|
|
|
308
|
-
// Self-contained
|
|
309
|
-
|
|
310
|
-
for (const entry of normalized) {
|
|
311
|
-
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
312
|
-
}
|
|
313
|
-
for (const { spec, from } of missing) {
|
|
314
|
-
issues.push(`Unresolved script: ${spec} from ${from}`)
|
|
315
|
-
}
|
|
316
|
-
if (issues.length > 0) {
|
|
317
|
-
throw new Error(`Bash bundle has unresolved scripts:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
318
|
-
}
|
|
411
|
+
// Self-contained (external refs never reach `missing`).
|
|
412
|
+
assertSelfContained('Bash', 'script', normalized, sources, missing)
|
|
319
413
|
|
|
320
414
|
return assembleCodeBundle({
|
|
321
415
|
baseDir,
|
|
@@ -329,10 +423,22 @@ export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
329
423
|
})
|
|
330
424
|
}
|
|
331
425
|
|
|
332
|
-
// Build an in-memory Bundle from entry .rs files by walking `mod` declarations
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
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 } = {}) {
|
|
336
442
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
337
443
|
throw new Error('buildRustBundle: at least one entry .rs file is required')
|
|
338
444
|
}
|
|
@@ -343,18 +449,76 @@ export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
343
449
|
const baseDir = resolve(cwd)
|
|
344
450
|
const normalized = normalizeEntries(entries, cwd)
|
|
345
451
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
}
|
|
352
481
|
}
|
|
353
|
-
|
|
354
|
-
|
|
482
|
+
const { resolutions, missing, unresolvedCrates } = tree
|
|
483
|
+
|
|
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}`)
|
|
491
|
+
}
|
|
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
|
+
}
|
|
355
505
|
}
|
|
356
|
-
|
|
357
|
-
|
|
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.')
|
|
358
522
|
}
|
|
359
523
|
|
|
360
524
|
return assembleCodeBundle({
|
|
@@ -365,8 +529,9 @@ export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
365
529
|
workspaceName: RUST_WORKSPACE_NAME,
|
|
366
530
|
workspaceVersion: RUST_WORKSPACE_VERSION,
|
|
367
531
|
format: RUST_FORMAT,
|
|
532
|
+
formats,
|
|
368
533
|
conditionKey: 'rust',
|
|
369
|
-
classifyDep: makeRustClassifier(
|
|
534
|
+
classifyDep: makeRustClassifier(cargoCtx),
|
|
370
535
|
})
|
|
371
536
|
}
|
|
372
537
|
|
|
@@ -385,45 +550,30 @@ export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
385
550
|
const baseDir = resolve(cwd)
|
|
386
551
|
const normalized = normalizeEntries(entries, cwd)
|
|
387
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)
|
|
388
556
|
|
|
389
557
|
// Laravel auto-discovers service providers rather than referencing them statically; seed
|
|
390
558
|
// them as extra roots so their config/route/view files get bundled.
|
|
391
|
-
const providerRoots = loadLaravelProviderFiles(baseDir, autoload)
|
|
559
|
+
const providerRoots = loadLaravelProviderFiles(baseDir, autoload, packages)
|
|
392
560
|
|
|
393
561
|
const sources = await collectPhpFilesFromDisk(baseDir, [...normalized, ...providerRoots], { autoload })
|
|
394
562
|
const { resolutions, missing } = buildPhpTree(sources, { baseDir, autoload })
|
|
395
563
|
|
|
396
|
-
|
|
397
|
-
for (const entry of normalized) {
|
|
398
|
-
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
399
|
-
}
|
|
400
|
-
for (const { spec, from } of missing) {
|
|
401
|
-
issues.push(`Unresolved import: ${spec} from ${from}`)
|
|
402
|
-
}
|
|
403
|
-
if (issues.length > 0) {
|
|
404
|
-
throw new Error(`PHP bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Group per Composer package (vendor/<pkg>), not the node_modules bucketizer.
|
|
408
|
-
const modules = bucketizePhpSources(baseDir, sources, PHP_WORKSPACE_NAME, PHP_WORKSPACE_VERSION)
|
|
409
|
-
|
|
410
|
-
const formats = new Map()
|
|
411
|
-
for (const path of sources.keys()) formats.set(path, PHP_FORMAT)
|
|
412
|
-
|
|
413
|
-
// PHP includes don't vary by Node condition; key edges under "php", not the JS wildcard "*".
|
|
414
|
-
const importsForKey = new Map()
|
|
415
|
-
for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
|
|
416
|
-
const imports = new Map([['php', importsForKey]])
|
|
564
|
+
assertSelfContained('PHP', 'import', normalized, sources, missing)
|
|
417
565
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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',
|
|
576
|
+
})
|
|
427
577
|
}
|
|
428
578
|
|
|
429
579
|
// Render a scanned file URL for diagnostics: project-relative when inside `baseDir`, else absolute.
|
|
@@ -432,15 +582,14 @@ export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
432
582
|
// bundle itself stores (toRel), rather than leaking the machine's absolute paths into the message.
|
|
433
583
|
function displayPath(url, baseDir) {
|
|
434
584
|
const abs = fileURLToPath(url)
|
|
435
|
-
if (!baseDir) return abs
|
|
436
585
|
const rel = relative(baseDir, abs)
|
|
437
|
-
return rel && !
|
|
586
|
+
return rel && !relativeEscapes(rel) ? rel : abs
|
|
438
587
|
}
|
|
439
588
|
|
|
440
589
|
// Classify scanner unresolved edges + parse errors into fatal (broken/divergent at load)
|
|
441
590
|
// vs tolerated (a catchable runtime miss). Shared so the plain and --mainFields JS paths
|
|
442
591
|
// gate identically. `baseDir` relativizes the paths in the emitted messages (see displayPath).
|
|
443
|
-
function analyzeScanner(scanner, { baseDir }
|
|
592
|
+
function analyzeScanner(scanner, { baseDir }) {
|
|
444
593
|
const show = (url) => displayPath(url, baseDir)
|
|
445
594
|
// Static ESM import/export-from edges load eagerly before user code, so a failure there is
|
|
446
595
|
// uncatchable (fatal); misses below a require()/dynamic-import() boundary are catchable (warn).
|
|
@@ -482,38 +631,45 @@ function analyzeScanner(scanner, { baseDir } = {}) {
|
|
|
482
631
|
|
|
483
632
|
// Throw on fatal scan issues; warn on tolerated ones. `label` tags the scan pass; `baseDir`
|
|
484
633
|
// relativizes the paths in the warnings (see displayPath), matching analyzeScanner's messages.
|
|
485
|
-
function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '', baseDir }
|
|
634
|
+
function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '', baseDir }) {
|
|
486
635
|
const where = label ? ` (${label})` : ''
|
|
487
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
|
+
}
|
|
488
642
|
if (fatal.length > 0) {
|
|
489
|
-
|
|
490
|
-
const more = fatal.length > 10 ? `\n ... and ${fatal.length - 10} more` : ''
|
|
491
|
-
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)}`)
|
|
492
644
|
}
|
|
493
645
|
if (tolerated.length > 0) {
|
|
494
|
-
const summary = tolerated
|
|
495
|
-
|
|
496
|
-
.map((u) => ` ${u.kind} ${u.spec ?? '<dynamic>'} from ${show(u.parentURL)} (${u.reason})`)
|
|
497
|
-
.join('\n')
|
|
498
|
-
const more = tolerated.length > 10 ? `\n ... and ${tolerated.length - 10} more` : ''
|
|
499
|
-
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}`)
|
|
500
648
|
}
|
|
501
649
|
if (toleratedParse.length > 0) {
|
|
502
|
-
const summary = toleratedParse
|
|
503
|
-
|
|
504
|
-
.map((p) => ` ${show(p.url)}: ${p.message}`)
|
|
505
|
-
.join('\n')
|
|
506
|
-
const more = toleratedParse.length > 10 ? `\n ... and ${toleratedParse.length - 10} more` : ''
|
|
507
|
-
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}`)
|
|
508
652
|
}
|
|
509
653
|
}
|
|
510
654
|
|
|
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
|
+
|
|
511
663
|
// Build a JS/TS Bundle (in-memory) by statically scanning the require/import graph; no
|
|
512
664
|
// user code is executed, and TS is stored verbatim (Node strips types at load). Scope comes
|
|
513
665
|
// from stasis.config.json / `EXODUS_STASIS_SCOPE` unless `scope` overrides. `conditions` are
|
|
514
666
|
// extra `exports`/`imports` conditions; on their own they don't honour legacy mainFields or
|
|
515
|
-
// platform suffixes (see `--mainFields` / buildResolvedJsBundle).
|
|
516
|
-
|
|
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 } = {}) {
|
|
517
673
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
518
674
|
throw new Error('buildJsBundle: at least one entry .js/.cjs/.mjs/.ts/.cts/.mts file is required')
|
|
519
675
|
}
|
|
@@ -524,13 +680,13 @@ export async function buildJsBundle({ cwd = process.cwd(), entries, scope, condi
|
|
|
524
680
|
const baseDir = resolve(cwd)
|
|
525
681
|
const absEntries = entries.map((e) => resolve(baseDir, e))
|
|
526
682
|
|
|
527
|
-
|
|
528
|
-
// bogus token into the resolver.
|
|
529
|
-
const scanConditions = conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
|
|
683
|
+
const scanConditions = cleanConditions(conditions)
|
|
530
684
|
// --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
|
|
531
685
|
const resourceSet = parseResourcesOption('buildJsBundle', resources)
|
|
532
686
|
|
|
533
|
-
const
|
|
687
|
+
const typescriptPaths = typescriptPathsFor(typescript, baseDir, tsconfig, host)
|
|
688
|
+
|
|
689
|
+
const scanner = scan(absEntries, { conditions: scanConditions, jsx, flow, typescript, typescriptPaths, resources: resourceSet, host })
|
|
534
690
|
|
|
535
691
|
// Fail closed where the bundle is guaranteed broken at load; warn on catchable misses (see analyzeScanner).
|
|
536
692
|
reportScanIssues(analyzeScanner(scanner, { baseDir }), { baseDir })
|
|
@@ -538,8 +694,9 @@ export async function buildJsBundle({ cwd = process.cwd(), entries, scope, condi
|
|
|
538
694
|
// Materialise via a non-preload State: addFile bucketizes + records sources/formats,
|
|
539
695
|
// addImport replays the edge map; serialize emits the runtime loader's v1 layout.
|
|
540
696
|
// bundle:'replace' skips reading any on-disk stasis.code.br (bundle:'add' would leak stale
|
|
541
|
-
// entries); lock:'ignore' tolerates a pre-existing lockfile without consuming it
|
|
542
|
-
|
|
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 })
|
|
543
700
|
for (const [url, info] of scanner.files) {
|
|
544
701
|
// A resource carries bytes only: addFile derives resource vs resource:base64 from the content
|
|
545
702
|
// and stores it under `resources` (a resource can't be an entry, so no isEntry).
|
|
@@ -597,12 +754,13 @@ const METRO_MAIN_FIELDS = ['react-native', 'browser', 'main']
|
|
|
597
754
|
// carried as a real empty CJS file so the edge points at attestable bytes.
|
|
598
755
|
const EMPTY_MODULE_PATH = '.stasis/empty-module.js'
|
|
599
756
|
|
|
600
|
-
// Recursively collect files under
|
|
601
|
-
// (cycle/escape hazard)
|
|
602
|
-
|
|
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) {
|
|
603
761
|
let entries
|
|
604
762
|
try {
|
|
605
|
-
entries =
|
|
763
|
+
entries = host.readdir(dirAbs)
|
|
606
764
|
} catch {
|
|
607
765
|
return // absent -- nothing for this platform
|
|
608
766
|
}
|
|
@@ -610,69 +768,81 @@ function walkNativeDir(dirAbs, out) {
|
|
|
610
768
|
if (ent.isSymbolicLink()) continue
|
|
611
769
|
const full = join(dirAbs, ent.name)
|
|
612
770
|
if (ent.isDirectory()) {
|
|
613
|
-
if (!isSkippedNativeWalkDir(ent.name))
|
|
614
|
-
} else if (ent.isFile() &&
|
|
771
|
+
if (!isSkippedNativeWalkDir(ent.name) && !skipAtRoot?.(ent.name)) walkNative(full, out, host, keep)
|
|
772
|
+
} else if (ent.isFile() && keep(ent.name)) {
|
|
615
773
|
out.push(full)
|
|
616
774
|
}
|
|
617
775
|
}
|
|
618
776
|
}
|
|
619
777
|
|
|
620
|
-
//
|
|
621
|
-
|
|
622
|
-
function collectNativeManifests(dirAbs, out, atRoot = false) {
|
|
623
|
-
let entries
|
|
624
|
-
try {
|
|
625
|
-
entries = readdirSync(dirAbs, { withFileTypes: true })
|
|
626
|
-
} catch {
|
|
627
|
-
return
|
|
628
|
-
}
|
|
629
|
-
for (const ent of entries) {
|
|
630
|
-
if (ent.isSymbolicLink()) continue
|
|
631
|
-
const full = join(dirAbs, ent.name)
|
|
632
|
-
if (ent.isDirectory()) {
|
|
633
|
-
if (!isSkippedNativeWalkDir(ent.name) && !(atRoot && isExcludedNativeDir(ent.name))) collectNativeManifests(full, out)
|
|
634
|
-
} else if (ent.isFile() && isNativeManifest(ent.name)) {
|
|
635
|
-
out.push(full)
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
}
|
|
778
|
+
// The files of a native ios/android dir: all but build artifacts.
|
|
779
|
+
const isNativeSource = (name) => !isNativeArtifact(name)
|
|
639
780
|
|
|
640
781
|
// Native source files a bundled RN dep contributes to the app's native build (podspecs +
|
|
641
782
|
// ios/android sources). Manifests are kept only when the package is actually native (podspec
|
|
642
783
|
// or ios/android dir), else a JS-only dep's package.json would be pulled in. Deduped absolute paths.
|
|
643
|
-
function nativeModuleFiles(pkgAbs) {
|
|
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.
|
|
644
787
|
const manifests = []
|
|
645
|
-
|
|
646
|
-
const hasIos =
|
|
647
|
-
const hasAndroid =
|
|
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
|
|
648
791
|
if (!hasIos && !hasAndroid && !manifests.some((f) => isPodspec(f))) return []
|
|
649
792
|
const out = [...manifests]
|
|
650
|
-
if (hasIos)
|
|
651
|
-
if (hasAndroid)
|
|
793
|
+
if (hasIos) walkNative(join(pkgAbs, 'ios'), out, host, isNativeSource)
|
|
794
|
+
if (hasAndroid) walkNative(join(pkgAbs, 'android'), out, host, isNativeSource)
|
|
652
795
|
return [...new Set(out)]
|
|
653
796
|
}
|
|
654
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
|
+
|
|
655
824
|
// Build a JS/TS Bundle + companion Lockfile via the legacy-field resolver (`--mainFields`/
|
|
656
825
|
// `--metro`). Scanned once per platform; each edge is recorded flat when the platforms that
|
|
657
826
|
// have it agree, or as a `{ platform: target }` map where they diverge. Returns { bundle, lockfile }.
|
|
658
|
-
async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields, platforms, conditions = [], metro = false, metroResolver = false, jsx = false, flow = false, resources = [], packageJSON = false }) {
|
|
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 }) {
|
|
659
828
|
const baseDir = resolve(cwd)
|
|
660
829
|
const absEntries = entries.map((e) => resolve(baseDir, e))
|
|
661
830
|
const normalized = normalizeEntries(entries, cwd)
|
|
662
831
|
|
|
663
832
|
const toRel = (abs) => {
|
|
664
|
-
const rel = relative(baseDir, abs)
|
|
665
|
-
if (
|
|
833
|
+
const rel = toPosix(relative(baseDir, abs))
|
|
834
|
+
if (relativeEscapes(rel)) {
|
|
666
835
|
throw new Error(`Bundle would reach a file outside the project root: ${abs}`)
|
|
667
836
|
}
|
|
668
837
|
return rel
|
|
669
838
|
}
|
|
670
839
|
|
|
671
|
-
|
|
672
|
-
const scanConditions = conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
|
|
840
|
+
const scanConditions = cleanConditions(conditions)
|
|
673
841
|
|
|
674
842
|
// Under --jsx the resolver probes .jsx/.tsx too, matching scan's jsx-widened carryable set.
|
|
675
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)
|
|
676
846
|
// --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
|
|
677
847
|
const resourceSet = parseResourcesOption('buildResolvedJsBundle', resources)
|
|
678
848
|
|
|
@@ -685,30 +855,23 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
685
855
|
let usesEmpty = false
|
|
686
856
|
|
|
687
857
|
for (const platform of platforms) {
|
|
688
|
-
|
|
689
|
-
const extras =
|
|
858
|
+
const field = fieldResolverFor(platform, { mainFields, metro, conditions: scanConditions, jsx, typescript, typescriptPaths, host })
|
|
859
|
+
const { extras } = field
|
|
690
860
|
// --metro --metro-resolver delegates to the project's own metro-resolver for byte-for-byte Metro
|
|
691
|
-
// fidelity; otherwise the built-in field/suffix resolver
|
|
692
|
-
//
|
|
693
|
-
//
|
|
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`).
|
|
694
864
|
const resolver = metroResolver
|
|
695
|
-
? createMetroResolver({ projectDir: baseDir, platform, sourceExts, mainFields, conditionNames: ['react-native'] })
|
|
696
|
-
:
|
|
697
|
-
|
|
698
|
-
platform,
|
|
699
|
-
preferNative: platform !== null && platform !== 'web',
|
|
700
|
-
sourceExts,
|
|
701
|
-
conditions: resolveConditions('commonjs', extras),
|
|
702
|
-
// Opt into Metro's package-entry browser-field quirks only on the --metro path.
|
|
703
|
-
metro,
|
|
704
|
-
})
|
|
705
|
-
const scanner = scan(absEntries, { conditions: extras, resolve: resolver, jsx, flow, resources: resourceSet })
|
|
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 })
|
|
706
868
|
reportScanIssues(analyzeScanner(scanner, { baseDir }), { baseDir, label: platform ?? 'mainFields' })
|
|
707
869
|
|
|
708
870
|
const platformKey = platform ?? '*' // '*' is a private placeholder for the single mainFields pass; it never unflattens
|
|
709
871
|
for (const [url, info] of scanner.files) {
|
|
710
|
-
const
|
|
711
|
-
|
|
872
|
+
const abs = fileURLToPath(url)
|
|
873
|
+
const rel = toRel(abs)
|
|
874
|
+
reached.add(abs)
|
|
712
875
|
if (info.resource) resourceRels.add(rel)
|
|
713
876
|
formatsByRel.set(rel, info.format)
|
|
714
877
|
for (const e of info.edges) {
|
|
@@ -729,33 +892,36 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
729
892
|
// stored as UTF-8 text, so non-UTF-8 bytes would diverge from the hashed bytes -- reject them.
|
|
730
893
|
const sources = new Map()
|
|
731
894
|
const integrities = new Map()
|
|
732
|
-
|
|
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)
|
|
733
905
|
for (const abs of reached) {
|
|
734
906
|
const rel = toRel(abs)
|
|
735
907
|
// Security: the field resolver returns the lexical path, so an in-tree-named symlink
|
|
736
908
|
// escaping the root would slip past toRel's textual check -- realpath and fail closed.
|
|
737
|
-
assertRealPathWithinBase(realBase, baseDir, rel)
|
|
738
|
-
const buf =
|
|
909
|
+
assertRealPathWithinBase(realBase, baseDir, rel, host)
|
|
910
|
+
const buf = host.readFile(abs)
|
|
739
911
|
if (resourceRels.has(rel)) {
|
|
740
|
-
|
|
741
|
-
// tag the byte-derived format (same shape as the --metro native capture below).
|
|
742
|
-
const utf8 = isUtf8(buf)
|
|
743
|
-
sources.set(rel, utf8 ? buf.toString('utf8') : buf.toString('base64'))
|
|
744
|
-
formatsByRel.set(rel, utf8 ? 'resource' : 'resource:base64')
|
|
912
|
+
putResource(rel, buf)
|
|
745
913
|
} else {
|
|
746
914
|
if (!isUtf8(buf)) throw new Error(`JS bundle source is not valid UTF-8: ${rel}`)
|
|
747
|
-
|
|
915
|
+
// Its format is the scan's.
|
|
916
|
+
put(rel, buf.toString('utf8'), formatsByRel.get(rel), buf)
|
|
748
917
|
}
|
|
749
|
-
integrities.set(rel, sha512integrity(buf))
|
|
750
918
|
}
|
|
751
919
|
if (usesEmpty) {
|
|
752
920
|
// Refuse to shadow a real reached file sitting at the reserved empty-module path.
|
|
753
921
|
if (formatsByRel.has(EMPTY_MODULE_PATH)) {
|
|
754
922
|
throw new Error(`Bundle needs the reserved empty-module path ${EMPTY_MODULE_PATH}, but the project has a real file there`)
|
|
755
923
|
}
|
|
756
|
-
|
|
757
|
-
formatsByRel.set(EMPTY_MODULE_PATH, 'commonjs')
|
|
758
|
-
integrities.set(EMPTY_MODULE_PATH, sha512integrity(Buffer.alloc(0)))
|
|
924
|
+
put(EMPTY_MODULE_PATH, '', 'commonjs', Buffer.alloc(0))
|
|
759
925
|
}
|
|
760
926
|
|
|
761
927
|
// --metro also carries each bundled dependency's native build-input surface (ios/android
|
|
@@ -766,8 +932,9 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
766
932
|
for (const abs of reached) {
|
|
767
933
|
// Follow the CODE/module graph only: a package reached solely for an asset (--resources) is
|
|
768
934
|
// not a linked native dependency, so it must not drag in its ios/android surface.
|
|
769
|
-
|
|
770
|
-
|
|
935
|
+
const rel = toRel(abs)
|
|
936
|
+
if (resourceRels.has(rel)) continue
|
|
937
|
+
const nm = splitNodeModulesPath(rel)
|
|
771
938
|
if (nm) pkgDirs.add(nm.dir)
|
|
772
939
|
}
|
|
773
940
|
for (const pkgDir of [...pkgDirs].toSorted()) {
|
|
@@ -776,74 +943,65 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
776
943
|
// so walk its whole tree for native source (React/, ReactCommon/, ReactAndroid/, ...) the same
|
|
777
944
|
// way a native dep's ios/android surface is walked; every other dep gets its ios/android + podspecs.
|
|
778
945
|
const isRnCore = pkgDir.slice(pkgDir.lastIndexOf('node_modules/') + 'node_modules/'.length) === 'react-native'
|
|
779
|
-
const files = isRnCore ? [] : nativeModuleFiles(pkgAbs)
|
|
780
|
-
if (isRnCore)
|
|
946
|
+
const files = isRnCore ? [] : nativeModuleFiles(pkgAbs, host)
|
|
947
|
+
if (isRnCore) walkNative(pkgAbs, files, host, isNativeSource)
|
|
781
948
|
for (const abs of files) {
|
|
782
949
|
const rel = toRel(abs)
|
|
783
950
|
if (sources.has(rel)) continue
|
|
784
|
-
assertRealPathWithinBase(realBase, baseDir, rel)
|
|
951
|
+
assertRealPathWithinBase(realBase, baseDir, rel, host)
|
|
785
952
|
// classifyNativeCapture (shared with the StasisMetro plugin) returns action skip/code/resource with a format tag.
|
|
786
953
|
const byName = classifyNativeCapture(rel)
|
|
787
954
|
if (byName.action === 'skip') continue
|
|
788
|
-
const buf =
|
|
955
|
+
const buf = host.readFile(abs)
|
|
789
956
|
// Byte-level rules (prebuilt binaries, binary plists) -- see refineNativeCapture.
|
|
790
957
|
const { action, format } = refineNativeCapture(byName, rel, buf, resourceSet)
|
|
791
958
|
if (action === 'skip') continue
|
|
792
|
-
const utf8 = isUtf8(buf)
|
|
793
959
|
if (action === 'code') {
|
|
794
|
-
if (!
|
|
795
|
-
|
|
796
|
-
formatsByRel.set(rel, format)
|
|
960
|
+
if (!isUtf8(buf)) throw new Error(`native source is not valid UTF-8: ${rel}`)
|
|
961
|
+
put(rel, buf.toString('utf8'), format, buf)
|
|
797
962
|
} else {
|
|
798
|
-
|
|
799
|
-
formatsByRel.set(rel, utf8 ? 'resource' : 'resource:base64')
|
|
963
|
+
putResource(rel, buf)
|
|
800
964
|
}
|
|
801
|
-
integrities.set(rel, sha512integrity(buf))
|
|
802
965
|
}
|
|
803
966
|
// RN core's `.js` build scripts the classify loop skips (Metro-owned by extension, but never in
|
|
804
967
|
// the graph): force-include as code so the podspec/Ruby that invokes them at pod-install resolves.
|
|
805
968
|
if (isRnCore) {
|
|
806
969
|
for (const file of RN_CORE_INCLUDE_FILES) {
|
|
807
970
|
const abs = join(pkgAbs, file)
|
|
808
|
-
if (
|
|
971
|
+
if (host.stat(abs) === null) continue
|
|
809
972
|
const rel = toRel(abs)
|
|
810
973
|
if (sources.has(rel)) continue
|
|
811
|
-
assertRealPathWithinBase(realBase, baseDir, rel)
|
|
812
|
-
const buf =
|
|
974
|
+
assertRealPathWithinBase(realBase, baseDir, rel, host)
|
|
975
|
+
const buf = host.readFile(abs)
|
|
813
976
|
if (!isUtf8(buf)) throw new Error(`native source is not valid UTF-8: ${rel}`)
|
|
814
|
-
|
|
815
|
-
formatsByRel.set(rel, packageType(abs) === 'module' ? 'module' : 'commonjs')
|
|
816
|
-
integrities.set(rel, sha512integrity(buf))
|
|
977
|
+
put(rel, buf.toString('utf8'), packageType(abs, host) === 'module' ? 'module' : 'commonjs', buf)
|
|
817
978
|
}
|
|
818
979
|
}
|
|
819
980
|
}
|
|
820
981
|
}
|
|
821
982
|
|
|
822
983
|
// --package-json: fold each bundled module's package.json into `sources` (and its integrity into
|
|
823
|
-
// the companion lockfile) even when the scan never reached it. Buckets are the
|
|
824
|
-
// assembleCodeBundle derives (findPackageMetadata -> pkgDir, else the
|
|
825
|
-
//
|
|
826
|
-
// same manifest. readModuleManifest applies the read/validate rules shared
|
|
827
|
-
// (containment, UTF-8-aborts); no identity check here -- these buckets are
|
|
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 })
|
|
828
991
|
if (packageJSON) {
|
|
829
|
-
const metaByDir = new Map()
|
|
830
992
|
const pkgDirs = new Set()
|
|
831
993
|
for (const abs of reached) {
|
|
832
994
|
const rel = toRel(abs)
|
|
833
|
-
const
|
|
834
|
-
if (!metaByDir.has(dir)) metaByDir.set(dir, findPackageMetadata(baseDir, rel))
|
|
835
|
-
const meta = metaByDir.get(dir)
|
|
995
|
+
const meta = packageOf(rel)
|
|
836
996
|
if (meta) pkgDirs.add(meta.pkgDir)
|
|
837
997
|
else if (!splitNodeModulesPath(rel)) pkgDirs.add('.')
|
|
838
998
|
}
|
|
839
999
|
for (const pkgDir of pkgDirs) {
|
|
840
1000
|
const rel = moduleFileKey(pkgDir, 'package.json')
|
|
841
1001
|
if (sources.has(rel)) continue
|
|
842
|
-
const buf = readModuleManifest({ baseDir, realBase, rel })
|
|
1002
|
+
const buf = readModuleManifest({ baseDir, realBase, rel, host })
|
|
843
1003
|
if (!buf) continue
|
|
844
|
-
|
|
845
|
-
formatsByRel.set(rel, 'json')
|
|
846
|
-
integrities.set(rel, sha512integrity(buf))
|
|
1004
|
+
put(rel, buf.toString('utf8'), 'json', buf)
|
|
847
1005
|
}
|
|
848
1006
|
}
|
|
849
1007
|
|
|
@@ -862,7 +1020,7 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
862
1020
|
resolutions.set(parent, specMap)
|
|
863
1021
|
}
|
|
864
1022
|
|
|
865
|
-
const rootPkg = readJson(join(baseDir, 'package.json')) ?? {}
|
|
1023
|
+
const rootPkg = readJson(join(baseDir, 'package.json'), host) ?? {}
|
|
866
1024
|
const bundle = assembleCodeBundle({
|
|
867
1025
|
baseDir,
|
|
868
1026
|
entries: normalized,
|
|
@@ -872,6 +1030,8 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
872
1030
|
workspaceName: rootPkg.name ?? 'workspace',
|
|
873
1031
|
workspaceVersion: rootPkg.version ?? '0.0.0',
|
|
874
1032
|
conditionKey: '*',
|
|
1033
|
+
host,
|
|
1034
|
+
packageOf,
|
|
875
1035
|
})
|
|
876
1036
|
|
|
877
1037
|
// The companion lockfile mirrors the bundle, swapping file content for its integrity.
|
|
@@ -894,13 +1054,16 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
894
1054
|
return { bundle, lockfile }
|
|
895
1055
|
}
|
|
896
1056
|
|
|
897
|
-
// Classify entries into their single shared language and check option applicability; `name` prefixes
|
|
898
|
-
|
|
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 }) {
|
|
899
1060
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
900
1061
|
throw new Error(`${name}: at least one entry file is required`)
|
|
901
1062
|
}
|
|
1063
|
+
const dirError = directoryEntryError(entries, cwd, host, { fetched })
|
|
1064
|
+
if (dirError !== null) throw new Error(`${name}: ${dirError}`)
|
|
902
1065
|
let kind
|
|
903
|
-
if (entries.every((e) => e
|
|
1066
|
+
if (entries.every((e) => isSolidityEntry(e, cwd, host))) kind = 'sol'
|
|
904
1067
|
else if (entries.every((e) => e.endsWith('.php'))) kind = 'php'
|
|
905
1068
|
else if (entries.every((e) => JS_EXTS.has(extname(e)))) kind = 'js'
|
|
906
1069
|
else if (entries.every((e) => BASH_EXTS.has(extname(e)))) kind = 'bash'
|
|
@@ -911,6 +1074,16 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
|
|
|
911
1074
|
if (mappingFile && kind !== 'sol') {
|
|
912
1075
|
throw new Error(`${name}: --mapping is only valid for .sol bundles`)
|
|
913
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
|
+
}
|
|
914
1087
|
if (scope !== undefined && kind !== 'js') {
|
|
915
1088
|
throw new Error(`${name}: --scope is only valid for JS bundles`)
|
|
916
1089
|
}
|
|
@@ -925,6 +1098,14 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
|
|
|
925
1098
|
if (flow && kind !== 'js') {
|
|
926
1099
|
throw new Error(`${name}: --flow is only valid for JS bundles`)
|
|
927
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
|
+
}
|
|
928
1109
|
// --resources carries allowlisted assets reached through the JS import graph; JS-only. The list
|
|
929
1110
|
// is validated (parseResourcesOption) in the builders, but reject a code extension here too so a
|
|
930
1111
|
// typo surfaces with the command name rather than deep in the scan.
|
|
@@ -974,6 +1155,10 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
|
|
|
974
1155
|
if (metroResolver && !metro) {
|
|
975
1156
|
throw new Error(`${name}: --metro-resolver is only valid with --metro`)
|
|
976
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
|
+
}
|
|
977
1162
|
// The field resolver always emits full-scope, so --scope with --mainFields/--metro would be silently ignored -- reject it.
|
|
978
1163
|
if (scope !== undefined && (mainFields !== undefined || metro)) {
|
|
979
1164
|
throw new Error(`${name}: --scope is not supported with --mainFields or --metro`)
|
|
@@ -981,38 +1166,83 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
|
|
|
981
1166
|
return kind
|
|
982
1167
|
}
|
|
983
1168
|
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
|
|
988
|
-
const kind = classifyEntries('buildBundle', { entries, mappingFile, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, resources, packageJSON })
|
|
989
|
-
if (kind === 'sol') return buildSolidityBundle({ cwd, entries, mappingFile })
|
|
990
|
-
if (kind === 'php') return buildPhpBundle({ cwd, entries })
|
|
991
|
-
if (kind === 'bash') return buildBashBundle({ cwd, entries })
|
|
992
|
-
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 }) {
|
|
993
1173
|
if (metro || mainFields !== undefined) {
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
platforms: metro ? platforms : [null],
|
|
999
|
-
conditions,
|
|
1000
|
-
metro: Boolean(metro),
|
|
1001
|
-
metroResolver: Boolean(metroResolver),
|
|
1002
|
-
jsx,
|
|
1003
|
-
flow,
|
|
1004
|
-
resources,
|
|
1005
|
-
packageJSON,
|
|
1006
|
-
})
|
|
1007
|
-
return bundle
|
|
1008
|
-
}
|
|
1009
|
-
const state = await buildJsBundle({ cwd, entries, scope, conditions, jsx, flow, resources, packageJSON })
|
|
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)
|
|
1010
1178
|
// Stamp the `bundle` consumer (the static build carries none).
|
|
1011
|
-
return state.sourceBundle.withReason('bundle')
|
|
1179
|
+
return { bundle: state.sourceBundle.withReason('bundle'), lockfile: () => state.lockfile, stateBuilt: true }
|
|
1180
|
+
}
|
|
1181
|
+
|
|
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 }
|
|
1012
1237
|
}
|
|
1013
1238
|
|
|
1014
|
-
//
|
|
1015
|
-
|
|
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
|
+
}
|
|
1016
1246
|
|
|
1017
1247
|
// Run `stasis bundle`: build a brotli-compressed bundle and write it to `output`
|
|
1018
1248
|
// (stasis.code.br by default, `-` for stdout; the summary goes to stderr so it never
|
|
@@ -1021,8 +1251,9 @@ const DEFAULT_BUNDLE_FILE = 'stasis.code.br'
|
|
|
1021
1251
|
// `stasis run --lock=frozen` (which doesn't replay them) fails closed -- pair it with
|
|
1022
1252
|
// `--bundle=load` or replay the conditions. `add` unions the fresh build into the bundle
|
|
1023
1253
|
// already on disk (strict; a conflicting file throws) and can't target stdout.
|
|
1024
|
-
export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile, output, scope, lockfile, conditions, mainFields, platforms, metro, metroResolver, jsx = false, flow = false, resources = [], packageJSON = false, brotliQuality, add = false } = {}) {
|
|
1025
|
-
const
|
|
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 })
|
|
1026
1257
|
|
|
1027
1258
|
const target = output ?? DEFAULT_BUNDLE_FILE
|
|
1028
1259
|
// --add has nothing to merge into on stdout (write-only).
|
|
@@ -1030,38 +1261,17 @@ export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile,
|
|
|
1030
1261
|
throw new Error('bundleCommand: --add cannot be combined with --output=- (nothing to merge into on stdout)')
|
|
1031
1262
|
}
|
|
1032
1263
|
|
|
1033
|
-
|
|
1034
|
-
let
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
entries,
|
|
1041
|
-
mainFields: metro ? METRO_MAIN_FIELDS : mainFields,
|
|
1042
|
-
platforms: metro ? platforms : [null],
|
|
1043
|
-
conditions,
|
|
1044
|
-
metro: Boolean(metro),
|
|
1045
|
-
metroResolver: Boolean(metroResolver),
|
|
1046
|
-
jsx,
|
|
1047
|
-
flow,
|
|
1048
|
-
resources,
|
|
1049
|
-
packageJSON,
|
|
1050
|
-
})
|
|
1051
|
-
bundle = built.bundle
|
|
1052
|
-
if (lockfile) lockData = built.lockfile.serialize()
|
|
1053
|
-
} else if (kind === 'js' && lockfile) {
|
|
1054
|
-
// Keep the State: only it carries the file hashes the companion lockfile needs (a Bundle holds sources, not digests).
|
|
1055
|
-
const state = await buildJsBundle({ cwd, entries, scope, conditions, jsx, flow, resources, packageJSON })
|
|
1056
|
-
// Stamp the `bundle` consumer (this branch bypasses buildBundle to keep the State).
|
|
1057
|
-
bundle = state.sourceBundle.withReason('bundle')
|
|
1058
|
-
lockData = state.lockData
|
|
1059
|
-
} else {
|
|
1060
|
-
bundle = await buildBundle({ cwd, entries, mappingFile, scope, conditions, jsx, flow, resources, packageJSON })
|
|
1061
|
-
}
|
|
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)
|
|
1062
1271
|
|
|
1063
1272
|
// --add: union the fresh build into the existing on-disk bundle; a conflicting file throws. Skipped when nothing is on disk.
|
|
1064
1273
|
const outAbs = target === '-' ? undefined : resolve(cwd, target)
|
|
1274
|
+
const lockAbs = lockData ? resolve(cwd, lockfile) : undefined
|
|
1065
1275
|
// Files the pre-existing bundle carried; undefined when no merge happened (the summary's merged sentinel).
|
|
1066
1276
|
let mergedFrom
|
|
1067
1277
|
if (add && existsSync(outAbs)) {
|
|
@@ -1076,8 +1286,7 @@ export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile,
|
|
|
1076
1286
|
// Keep the companion lockfile consistent with the merged bundle: union an existing one.
|
|
1077
1287
|
// With none on disk we can't attest the pre-existing bundle's files (their hashes live
|
|
1078
1288
|
// only in that missing lockfile), so refuse rather than write an under-attesting lockfile.
|
|
1079
|
-
if (lockData
|
|
1080
|
-
const lockAbs = resolve(cwd, lockfile)
|
|
1289
|
+
if (lockData) {
|
|
1081
1290
|
if (!existsSync(lockAbs)) {
|
|
1082
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.`)
|
|
1083
1292
|
}
|
|
@@ -1095,28 +1304,14 @@ export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile,
|
|
|
1095
1304
|
const files = [...bundle.sources.keys()]
|
|
1096
1305
|
const modules = bundle.modules
|
|
1097
1306
|
|
|
1098
|
-
const
|
|
1099
|
-
if (
|
|
1100
|
-
process.stdout.write(data)
|
|
1101
|
-
} else {
|
|
1102
|
-
mkdirSync(dirname(outAbs), { recursive: true })
|
|
1103
|
-
writeFileSync(outAbs, data)
|
|
1104
|
-
}
|
|
1105
|
-
if (lockData) {
|
|
1106
|
-
const lockAbs = resolve(cwd, lockfile)
|
|
1107
|
-
mkdirSync(dirname(lockAbs), { recursive: true })
|
|
1108
|
-
writeFileSync(lockAbs, lockData)
|
|
1109
|
-
}
|
|
1307
|
+
const dest = writeBundle(cwd, target, serialized, brotliQuality)
|
|
1308
|
+
if (lockData) writeFile(lockAbs, lockData)
|
|
1110
1309
|
const fromDir = outermostDir(files, resolve(cwd))
|
|
1111
|
-
const dest = target === '-' ? '<stdout>' : target
|
|
1112
|
-
// Count packages: every non-empty bucket (the "." workspace bucket counts as one).
|
|
1113
|
-
const packages = [...modules.values()].filter((m) => Object.keys(m.files).length > 0).length
|
|
1114
|
-
const pkgLabel = `${packages} package${packages === 1 ? '' : 's'}`
|
|
1115
1310
|
// On a merge, report newly-added files alongside the totals; a fresh write keeps the plain line.
|
|
1116
1311
|
if (mergedFrom !== undefined) {
|
|
1117
1312
|
const added = files.length - mergedFrom
|
|
1118
|
-
console.warn(`[stasis] Added ${added} file${added === 1 ? '' : 's'} (${files.length} total in ${
|
|
1313
|
+
console.warn(`[stasis] Added ${added} file${added === 1 ? '' : 's'} (${files.length} total in ${packagesLabel(modules)}) from ${fromDir} to ${dest}`)
|
|
1119
1314
|
} else {
|
|
1120
|
-
console.warn(
|
|
1315
|
+
console.warn(bundledSummary(files.length, modules, fromDir, dest))
|
|
1121
1316
|
}
|
|
1122
1317
|
}
|