@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.3
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 +16 -2
- package/bin/stasis.js +173 -161
- package/package.json +11 -5
- package/src/apis/npm/semver.cjs +0 -2
- package/src/audit.js +161 -22
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/cmd/build.js +93 -123
- package/src/cmd/bundle.js +470 -427
- package/src/cmd/diff.js +2 -19
- package/src/cmd/sbom.js +1 -7
- package/src/diff.js +61 -118
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +33 -81
- package/src/loaders/php.js +143 -282
- package/src/loaders/rust.js +37 -93
- package/src/loaders/solidity.js +27 -52
- package/src/metro-resolver.js +165 -0
- package/src/metro-transformer.js +3 -2
- package/src/mock.js +54 -160
- package/src/parse.js +4 -16
- package/src/sbom.js +37 -79
- package/src/scan.js +166 -124
- package/src/cmd/extract.js +0 -193
package/src/cmd/bundle.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { isUtf8 } from 'node:buffer'
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
|
-
import { brotliCompressSync } from 'node:zlib'
|
|
5
|
+
import { brotliCompressSync, 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 { createMetroResolver } from '../metro-resolver.js'
|
|
11
12
|
import { State } from '@exodus/stasis-core/state'
|
|
12
13
|
import { brotliOptions } from '@exodus/stasis-core/brotli'
|
|
13
14
|
import { sha512integrity } from '@exodus/stasis-core/state-util'
|
|
14
|
-
import {
|
|
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
17
|
import {
|
|
16
18
|
buildSolidityTree,
|
|
17
19
|
collectSolidityFilesFromDisk,
|
|
@@ -31,16 +33,13 @@ const JS_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
|
|
|
31
33
|
const BASH_EXTS = new Set(['.sh', '.bash'])
|
|
32
34
|
const RUST_EXTS = new Set(['.rs'])
|
|
33
35
|
|
|
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.
|
|
36
|
+
// Fallback identity for the workspace bucket; Bundle.parse requires every bucket to attest name+version.
|
|
38
37
|
const SOLIDITY_WORKSPACE_NAME = 'solidity-bundle'
|
|
39
38
|
const SOLIDITY_WORKSPACE_VERSION = '0.0.0'
|
|
40
39
|
const SOLIDITY_FORMAT = 'solidity'
|
|
41
40
|
const BASH_WORKSPACE_NAME = 'bash-bundle'
|
|
42
41
|
const BASH_WORKSPACE_VERSION = '0.0.0'
|
|
43
|
-
const
|
|
42
|
+
const SHELL_FORMAT = 'shell'
|
|
44
43
|
|
|
45
44
|
const RUST_WORKSPACE_NAME = 'rust-bundle'
|
|
46
45
|
const RUST_WORKSPACE_VERSION = '0.0.0'
|
|
@@ -50,47 +49,8 @@ const PHP_WORKSPACE_NAME = 'php-bundle'
|
|
|
50
49
|
const PHP_WORKSPACE_VERSION = '0.0.0'
|
|
51
50
|
const PHP_FORMAT = 'php'
|
|
52
51
|
|
|
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.
|
|
52
|
+
// Deepest common parent dir of `paths`, relative to `cwd`; starts with `..` when entries
|
|
53
|
+
// escape cwd (e.g. via remapping), or "." when it is cwd itself.
|
|
94
54
|
export function outermostDir(paths, cwd) {
|
|
95
55
|
if (paths.length === 0) return '.'
|
|
96
56
|
const cwdAbs = posix.resolve(cwd.replaceAll(/\\/gu, '/'))
|
|
@@ -107,35 +67,21 @@ export function outermostDir(paths, cwd) {
|
|
|
107
67
|
return rel === '' ? '.' : rel
|
|
108
68
|
}
|
|
109
69
|
|
|
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).
|
|
70
|
+
// Split a Soldeer dep dir `<name>-<version>` (its authoritative identity); falls back to
|
|
71
|
+
// version 0.0.0 when it doesn't end in a semver (every bucket must attest a version).
|
|
124
72
|
function parseSoldeerDir(seg) {
|
|
125
73
|
const m = /^(.+)-(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)$/u.exec(seg)
|
|
126
74
|
return m ? { name: m[1], version: m[2] } : { name: seg, version: '0.0.0' }
|
|
127
75
|
}
|
|
128
76
|
|
|
129
|
-
// Extract `owner/repo` from a github.com remote (https
|
|
130
|
-
// dropping a trailing `.git`. Returns null for non-github hosts.
|
|
77
|
+
// Extract `owner/repo` from a github.com remote (https/ssh/scp); null for non-github hosts.
|
|
131
78
|
function githubSlug(url) {
|
|
132
79
|
const m = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/iu.exec(url)
|
|
133
80
|
return m ? `${m[1]}/${m[2]}` : null
|
|
134
81
|
}
|
|
135
82
|
|
|
136
|
-
// Parse `.gitmodules` into Map<submodulePath, { name, branch }>,
|
|
137
|
-
// github.com submodules
|
|
138
|
-
// is git-config INI: `[submodule "<n>"]` sections with `path`/`url`/`branch`.
|
|
83
|
+
// Parse `.gitmodules` (git-config INI) into Map<submodulePath, { name, branch }>,
|
|
84
|
+
// github.com submodules only.
|
|
139
85
|
function parseGithubSubmodules(baseDir) {
|
|
140
86
|
const byPath = new Map()
|
|
141
87
|
const text = readFileSyncOrNull(join(baseDir, '.gitmodules'))
|
|
@@ -173,13 +119,9 @@ function readFileSyncOrNull(file) {
|
|
|
173
119
|
}
|
|
174
120
|
}
|
|
175
121
|
|
|
176
|
-
// Classify a
|
|
177
|
-
//
|
|
178
|
-
//
|
|
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.
|
|
122
|
+
// 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 node_modules/
|
|
124
|
+
// workspace logic.
|
|
183
125
|
function makeSolidityClassifier(baseDir) {
|
|
184
126
|
const submodules = parseGithubSubmodules(baseDir)
|
|
185
127
|
return (path) => {
|
|
@@ -200,9 +142,7 @@ function makeSolidityClassifier(baseDir) {
|
|
|
200
142
|
}
|
|
201
143
|
}
|
|
202
144
|
|
|
203
|
-
// Minimal Cargo.toml reader:
|
|
204
|
-
// A full TOML parse isn't warranted — those two string keys are all we need to
|
|
205
|
-
// identify a vendored crate. Returns null when there's no readable package name.
|
|
145
|
+
// Minimal Cargo.toml reader: name/version from the `[package]` table; null when no name.
|
|
206
146
|
function parseCargoToml(file) {
|
|
207
147
|
const text = readFileSyncOrNull(file)
|
|
208
148
|
if (!text) return null
|
|
@@ -210,7 +150,7 @@ function parseCargoToml(file) {
|
|
|
210
150
|
let body = text
|
|
211
151
|
if (at !== -1) {
|
|
212
152
|
const rest = text.slice(at)
|
|
213
|
-
const next = rest.slice(1).search(/^\[/mu)
|
|
153
|
+
const next = rest.slice(1).search(/^\[/mu)
|
|
214
154
|
body = next === -1 ? rest : rest.slice(0, next + 1)
|
|
215
155
|
}
|
|
216
156
|
const name = /^\s*name\s*=\s*"([^"]+)"/mu.exec(body)?.[1]
|
|
@@ -218,10 +158,8 @@ function parseCargoToml(file) {
|
|
|
218
158
|
return name ? { name, version: version ?? '0.0.0' } : null
|
|
219
159
|
}
|
|
220
160
|
|
|
221
|
-
// Classify a
|
|
222
|
-
//
|
|
223
|
-
// under `vendor/<dir>` and tag it `cargo` (Package-URL `pkg:cargo/<name>`). The
|
|
224
|
-
// workspace's own code and path/workspace members defer to the workspace logic.
|
|
161
|
+
// Classify a Rust file: `cargo vendor` crates under `vendor/<dir>/` (tagged `cargo`), else
|
|
162
|
+
// null to defer to the workspace logic.
|
|
225
163
|
function makeRustClassifier(baseDir) {
|
|
226
164
|
return (path) => {
|
|
227
165
|
if (!path.startsWith('vendor/')) return null
|
|
@@ -233,44 +171,24 @@ function makeRustClassifier(baseDir) {
|
|
|
233
171
|
}
|
|
234
172
|
}
|
|
235
173
|
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
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.
|
|
174
|
+
// Project-relative paths in `sources` whose on-disk file carries a POSIX execute bit -- the
|
|
175
|
+
// `executable` list both artifacts record. The State-driven path derives this in addFile; the
|
|
176
|
+
// static builders never touch a State, so they stat here.
|
|
177
|
+
function executableSources(baseDir, sources) {
|
|
178
|
+
const executable = new Set()
|
|
179
|
+
for (const path of sources.keys()) {
|
|
180
|
+
if (isExecutableFile(join(baseDir, path))) executable.add(path)
|
|
181
|
+
}
|
|
182
|
+
return executable
|
|
183
|
+
}
|
|
184
|
+
|
|
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. `classifyDep(path)` optionally places a file
|
|
189
|
+
// directly (non-node_modules ecosystems like Soldeer/github); null defers. `format` tags
|
|
190
|
+
// every file, or pass `formats` (Map<path,format>) to tag per file. `resolutions` values are
|
|
191
|
+
// a flat target string or a Map<platform,target>; both round-trip untouched.
|
|
274
192
|
function assembleCodeBundle({
|
|
275
193
|
baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep,
|
|
276
194
|
}) {
|
|
@@ -313,19 +231,23 @@ function assembleCodeBundle({
|
|
|
313
231
|
for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
|
|
314
232
|
const imports = new Map([[conditionKey, importsForKey]])
|
|
315
233
|
|
|
234
|
+
// Executable bits, straight off disk (a synthetic source with no file there is simply not
|
|
235
|
+
// executable). Shell bundles lean on this most: `stasis extract` puts the +x back on the scripts.
|
|
236
|
+
const executable = executableSources(baseDir, sources)
|
|
237
|
+
|
|
238
|
+
// Attribute files to the `bundle` consumer (static builders skip State's per-file tagging).
|
|
316
239
|
return new Bundle({
|
|
317
240
|
config: { scope: 'full' },
|
|
318
241
|
entries: new Set(entries),
|
|
319
242
|
modules,
|
|
320
243
|
formats: formatsMap,
|
|
321
244
|
imports,
|
|
322
|
-
|
|
245
|
+
executable,
|
|
246
|
+
}).withReason('bundle')
|
|
323
247
|
}
|
|
324
248
|
|
|
325
|
-
// Build
|
|
326
|
-
//
|
|
327
|
-
// remappings.txt) is parsed for remappings but NOT included in the
|
|
328
|
-
// bundle's sources. Returns the constructed `Bundle`.
|
|
249
|
+
// Build an in-memory Bundle from entry .sol files; `mappingFile` (foundry.toml/remappings.txt)
|
|
250
|
+
// is parsed for remappings but not bundled.
|
|
329
251
|
export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappingFile } = {}) {
|
|
330
252
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
331
253
|
throw new Error('buildSolidityBundle: at least one entry .sol file is required')
|
|
@@ -341,10 +263,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin
|
|
|
341
263
|
const sources = await collectSolidityFilesFromDisk(baseDir, normalized, remappings)
|
|
342
264
|
const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir })
|
|
343
265
|
|
|
344
|
-
// Bundles must be self-contained
|
|
345
|
-
// can't be loaded from disk, or when any in-bundle file has an import
|
|
346
|
-
// we couldn't resolve — otherwise downstream consumers would silently
|
|
347
|
-
// operate on a partial set of sources.
|
|
266
|
+
// Bundles must be self-contained: fail on a missing entry or unresolved import.
|
|
348
267
|
const issues = []
|
|
349
268
|
for (const entry of normalized) {
|
|
350
269
|
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
@@ -369,19 +288,9 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin
|
|
|
369
288
|
})
|
|
370
289
|
}
|
|
371
290
|
|
|
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 "*".
|
|
291
|
+
// Build an in-memory Bundle from entry .sh/.bash files by walking the source/exec graph.
|
|
292
|
+
// A local `source` inside the bundle root that resolves to no bundled script is fatal;
|
|
293
|
+
// external refs (PATH commands, `$VAR`, absolute, `../`-escaping) are dropped.
|
|
385
294
|
export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
|
|
386
295
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
387
296
|
throw new Error('buildBashBundle: at least one entry .sh/.bash file is required')
|
|
@@ -396,11 +305,7 @@ export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
396
305
|
const sources = await collectBashFilesFromDisk(baseDir, normalized)
|
|
397
306
|
const { resolutions, missing } = buildBashTree(sources)
|
|
398
307
|
|
|
399
|
-
//
|
|
400
|
-
// reference pointing inside the bundle root must resolve to a bundled script.
|
|
401
|
-
// External/best-effort references (commands, $VAR paths, extensionless,
|
|
402
|
-
// absolute, or `../`-escaping sources) are left out by buildBashTree and
|
|
403
|
-
// never reach `missing`.
|
|
308
|
+
// Self-contained: fail on a missing entry or unresolved in-root script (external refs never reach `missing`).
|
|
404
309
|
const issues = []
|
|
405
310
|
for (const entry of normalized) {
|
|
406
311
|
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
@@ -419,19 +324,14 @@ export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
419
324
|
resolutions,
|
|
420
325
|
workspaceName: BASH_WORKSPACE_NAME,
|
|
421
326
|
workspaceVersion: BASH_WORKSPACE_VERSION,
|
|
422
|
-
format:
|
|
423
|
-
conditionKey:
|
|
327
|
+
format: SHELL_FORMAT,
|
|
328
|
+
conditionKey: SHELL_FORMAT,
|
|
424
329
|
})
|
|
425
330
|
}
|
|
426
331
|
|
|
427
|
-
// Build
|
|
428
|
-
// `mod`
|
|
429
|
-
//
|
|
430
|
-
// (no backing file) is fatal, as is a missing entry. `use crate::` edges are
|
|
431
|
-
// recorded in the import graph but stay best-effort — they never widen the
|
|
432
|
-
// file set, and their resolution is approximate (item-vs-module, re-exports),
|
|
433
|
-
// so they aren't gated; external crates and std are ignored outright. Imports
|
|
434
|
-
// live under a dedicated "rust" condition key.
|
|
332
|
+
// Build an in-memory Bundle from entry .rs files by walking `mod` declarations. An
|
|
333
|
+
// unresolvable `mod` is fatal; `use crate::` edges are recorded best-effort (never widen
|
|
334
|
+
// the file set, not gated).
|
|
435
335
|
export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
|
|
436
336
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
437
337
|
throw new Error('buildRustBundle: at least one entry .rs file is required')
|
|
@@ -470,25 +370,10 @@ export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
470
370
|
})
|
|
471
371
|
}
|
|
472
372
|
|
|
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.
|
|
373
|
+
// Build an in-memory Bundle from entry .php files by statically scanning the
|
|
374
|
+
// require/include graph (and Composer's class-autoload graph when present); no PHP is
|
|
375
|
+
// executed. Autoload resolution is best-effort; explicit require/include of a literal path
|
|
376
|
+
// is strict, and a missing entry or unresolved explicit include is fatal.
|
|
492
377
|
export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
|
|
493
378
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
494
379
|
throw new Error('buildPhpBundle: at least one entry .php file is required')
|
|
@@ -501,9 +386,8 @@ export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
501
386
|
const normalized = normalizeEntries(entries, cwd)
|
|
502
387
|
const autoload = loadComposerAutoload(baseDir)
|
|
503
388
|
|
|
504
|
-
// Laravel auto-discovers
|
|
505
|
-
//
|
|
506
|
-
// them as extra roots so the config/route/view files they pull in get bundled.
|
|
389
|
+
// Laravel auto-discovers service providers rather than referencing them statically; seed
|
|
390
|
+
// them as extra roots so their config/route/view files get bundled.
|
|
507
391
|
const providerRoots = loadLaravelProviderFiles(baseDir, autoload)
|
|
508
392
|
|
|
509
393
|
const sources = await collectPhpFilesFromDisk(baseDir, [...normalized, ...providerRoots], { autoload })
|
|
@@ -520,38 +404,46 @@ export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
|
|
|
520
404
|
throw new Error(`PHP bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
521
405
|
}
|
|
522
406
|
|
|
523
|
-
// Group per Composer package (vendor/<pkg>
|
|
524
|
-
// composer.json / installed.json), not the node_modules-based bucketizer.
|
|
407
|
+
// Group per Composer package (vendor/<pkg>), not the node_modules bucketizer.
|
|
525
408
|
const modules = bucketizePhpSources(baseDir, sources, PHP_WORKSPACE_NAME, PHP_WORKSPACE_VERSION)
|
|
526
409
|
|
|
527
410
|
const formats = new Map()
|
|
528
411
|
for (const path of sources.keys()) formats.set(path, PHP_FORMAT)
|
|
529
412
|
|
|
530
|
-
// PHP includes don't
|
|
531
|
-
// "php" bucket rather than the wildcard "*" used for JS code bundles.
|
|
413
|
+
// PHP includes don't vary by Node condition; key edges under "php", not the JS wildcard "*".
|
|
532
414
|
const importsForKey = new Map()
|
|
533
415
|
for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
|
|
534
416
|
const imports = new Map([['php', importsForKey]])
|
|
535
417
|
|
|
418
|
+
// Attribute to the `bundle` consumer, like the other static builders.
|
|
536
419
|
return new Bundle({
|
|
537
420
|
config: { scope: 'full' },
|
|
538
421
|
entries: new Set(normalized),
|
|
539
422
|
modules,
|
|
540
423
|
formats,
|
|
541
424
|
imports,
|
|
542
|
-
|
|
425
|
+
executable: executableSources(baseDir, sources),
|
|
426
|
+
}).withReason('bundle')
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Render a scanned file URL for diagnostics: project-relative when inside `baseDir`, else absolute.
|
|
430
|
+
// The scan issue checks run on the LIVE scan, whose files/imports are keyed by absolute file: URLs
|
|
431
|
+
// (the resolver returns absolute paths); relativizing here matches the project-relative paths the
|
|
432
|
+
// bundle itself stores (toRel), rather than leaking the machine's absolute paths into the message.
|
|
433
|
+
function displayPath(url, baseDir) {
|
|
434
|
+
const abs = fileURLToPath(url)
|
|
435
|
+
if (!baseDir) return abs
|
|
436
|
+
const rel = relative(baseDir, abs)
|
|
437
|
+
return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : abs
|
|
543
438
|
}
|
|
544
439
|
|
|
545
|
-
// Classify
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
//
|
|
552
|
-
// `import`/`export ... from` edges load eagerly, before any user code runs, so
|
|
553
|
-
// a failure there is uncatchable; below a require()/dynamic-import() boundary it
|
|
554
|
-
// surfaces as a catchable miss and only warns.
|
|
440
|
+
// Classify scanner unresolved edges + parse errors into fatal (broken/divergent at load)
|
|
441
|
+
// vs tolerated (a catchable runtime miss). Shared so the plain and --mainFields JS paths
|
|
442
|
+
// gate identically. `baseDir` relativizes the paths in the emitted messages (see displayPath).
|
|
443
|
+
function analyzeScanner(scanner, { baseDir } = {}) {
|
|
444
|
+
const show = (url) => displayPath(url, baseDir)
|
|
445
|
+
// Static ESM import/export-from edges load eagerly before user code, so a failure there is
|
|
446
|
+
// uncatchable (fatal); misses below a require()/dynamic-import() boundary are catchable (warn).
|
|
555
447
|
const staticKinds = new Set(['import', 'export-from'])
|
|
556
448
|
const staticReachable = new Set(scanner.entries)
|
|
557
449
|
const walk = [...scanner.entries]
|
|
@@ -566,20 +458,19 @@ function analyzeScanner(scanner) {
|
|
|
566
458
|
const fatalUnresolved = (u) => staticKinds.has(u.kind) && staticReachable.has(u.parentURL)
|
|
567
459
|
const fatal = scanner.unresolved
|
|
568
460
|
.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.
|
|
461
|
+
.map((u) => `unresolved ${u.kind} ${u.spec} from ${show(u.parentURL)} (${u.reason})`)
|
|
462
|
+
// Fatal parse: an eagerly-linked module-family file, or any file the parser couldn't process.
|
|
572
463
|
const fatalParse = (p) => staticReachable.has(p.url) && (p.format?.startsWith('module') === true || !p.recovered)
|
|
573
464
|
for (const p of scanner.parseErrors) {
|
|
574
|
-
if (fatalParse(p)) fatal.push(`parse error in ${
|
|
465
|
+
if (fatalParse(p)) fatal.push(`parse error in ${show(p.url)}: ${p.message}`)
|
|
575
466
|
}
|
|
576
|
-
//
|
|
577
|
-
//
|
|
467
|
+
// Edge resolving to a file a source bundle can't carry (.node/.wasm/extensionless): scan
|
|
468
|
+
// records it but never queues the child, so load would die.
|
|
578
469
|
for (const [, byParent] of scanner.imports) {
|
|
579
470
|
for (const [parentURL, specMap] of byParent) {
|
|
580
471
|
for (const [spec, childURL] of specMap) {
|
|
581
472
|
if (!scanner.files.has(childURL)) {
|
|
582
|
-
fatal.push(`${spec} from ${
|
|
473
|
+
fatal.push(`${spec} from ${show(parentURL)} resolves to ${show(childURL)}, which a source bundle can't carry`)
|
|
583
474
|
}
|
|
584
475
|
}
|
|
585
476
|
}
|
|
@@ -589,10 +480,11 @@ function analyzeScanner(scanner) {
|
|
|
589
480
|
return { fatal, tolerated, toleratedParse }
|
|
590
481
|
}
|
|
591
482
|
|
|
592
|
-
// Throw on
|
|
593
|
-
//
|
|
594
|
-
function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '' } = {}) {
|
|
483
|
+
// Throw on fatal scan issues; warn on tolerated ones. `label` tags the scan pass; `baseDir`
|
|
484
|
+
// relativizes the paths in the warnings (see displayPath), matching analyzeScanner's messages.
|
|
485
|
+
function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '', baseDir } = {}) {
|
|
595
486
|
const where = label ? ` (${label})` : ''
|
|
487
|
+
const show = (url) => displayPath(url, baseDir)
|
|
596
488
|
if (fatal.length > 0) {
|
|
597
489
|
const shown = fatal.slice(0, 10).map((s) => ` ${s}`).join('\n')
|
|
598
490
|
const more = fatal.length > 10 ? `\n ... and ${fatal.length - 10} more` : ''
|
|
@@ -601,7 +493,7 @@ function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '' } =
|
|
|
601
493
|
if (tolerated.length > 0) {
|
|
602
494
|
const summary = tolerated
|
|
603
495
|
.slice(0, 10)
|
|
604
|
-
.map((u) => ` ${u.kind} ${u.spec ?? '<dynamic>'} from ${
|
|
496
|
+
.map((u) => ` ${u.kind} ${u.spec ?? '<dynamic>'} from ${show(u.parentURL)} (${u.reason})`)
|
|
605
497
|
.join('\n')
|
|
606
498
|
const more = tolerated.length > 10 ? `\n ... and ${tolerated.length - 10} more` : ''
|
|
607
499
|
console.warn(`[stasis] Bundle has ${tolerated.length} unresolved import(s)${where}; they will fall through at load time:\n${summary}${more}`)
|
|
@@ -609,47 +501,19 @@ function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '' } =
|
|
|
609
501
|
if (toleratedParse.length > 0) {
|
|
610
502
|
const summary = toleratedParse
|
|
611
503
|
.slice(0, 10)
|
|
612
|
-
.map((p) => ` ${
|
|
504
|
+
.map((p) => ` ${show(p.url)}: ${p.message}`)
|
|
613
505
|
.join('\n')
|
|
614
506
|
const more = toleratedParse.length > 10 ? `\n ... and ${toleratedParse.length - 10} more` : ''
|
|
615
507
|
console.warn(`[stasis] Bundle has ${toleratedParse.length} file(s) with parse errors${where}; their recorded imports may be incomplete:\n${summary}${more}`)
|
|
616
508
|
}
|
|
617
509
|
}
|
|
618
510
|
|
|
619
|
-
// Build a
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
// records at runtime, so the result loads via `stasis run --bundle=load`
|
|
626
|
-
// interchangeably with a runtime-produced one.
|
|
627
|
-
//
|
|
628
|
-
// Refuses to write when the bundle is guaranteed broken (or silently divergent
|
|
629
|
-
// from plain node) at load time: an unresolved static ESM `import`/`export ...
|
|
630
|
-
// from` edge reachable from an entry through static edges alone (link-time,
|
|
631
|
-
// uncatchable), a module file that can't be parsed (its static edges are
|
|
632
|
-
// unknown), or an edge resolving to a file a source bundle can't carry
|
|
633
|
-
// (extensionless, .node, .wasm). Failures that runtime code can catch --
|
|
634
|
-
// unresolved require()/dynamic import() edges, including entire subtrees
|
|
635
|
-
// behind such a boundary -- only warn (see below).
|
|
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 = [] } = {}) {
|
|
511
|
+
// Build a JS/TS Bundle (in-memory) by statically scanning the require/import graph; no
|
|
512
|
+
// user code is executed, and TS is stored verbatim (Node strips types at load). Scope comes
|
|
513
|
+
// from stasis.config.json / `EXODUS_STASIS_SCOPE` unless `scope` overrides. `conditions` are
|
|
514
|
+
// extra `exports`/`imports` conditions; on their own they don't honour legacy mainFields or
|
|
515
|
+
// platform suffixes (see `--mainFields` / buildResolvedJsBundle).
|
|
516
|
+
export async function buildJsBundle({ cwd = process.cwd(), entries, scope, conditions = [], jsx = false, flow = false, resources = [], packageJSON = false } = {}) {
|
|
653
517
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
654
518
|
throw new Error('buildJsBundle: at least one entry .js/.cjs/.mjs/.ts/.cts/.mts file is required')
|
|
655
519
|
}
|
|
@@ -660,84 +524,138 @@ export async function buildJsBundle({ cwd = process.cwd(), entries, scope, condi
|
|
|
660
524
|
const baseDir = resolve(cwd)
|
|
661
525
|
const absEntries = entries.map((e) => resolve(baseDir, e))
|
|
662
526
|
|
|
663
|
-
// Normalize conditions
|
|
664
|
-
//
|
|
665
|
-
// A '' or whitespace-only condition never matches an `exports` key, so dropping it
|
|
666
|
-
// is a no-op for clean input -- this just stops a bogus token reaching the resolver.
|
|
527
|
+
// Normalize conditions (trim, drop empties) so a sloppy programmatic caller can't push a
|
|
528
|
+
// bogus token into the resolver.
|
|
667
529
|
const scanConditions = conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
|
|
530
|
+
// --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
|
|
531
|
+
const resourceSet = parseResourcesOption('buildJsBundle', resources)
|
|
668
532
|
|
|
669
|
-
const scanner = scan(absEntries, { conditions: scanConditions })
|
|
670
|
-
|
|
671
|
-
// Fail closed
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
// state.getImport throws the same catchable MODULE_NOT_FOUND at load. Edges
|
|
679
|
-
// resolving to a file a source bundle can't carry are always fatal. (See
|
|
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`.
|
|
533
|
+
const scanner = scan(absEntries, { conditions: scanConditions, jsx, flow, resources: resourceSet })
|
|
534
|
+
|
|
535
|
+
// Fail closed where the bundle is guaranteed broken at load; warn on catchable misses (see analyzeScanner).
|
|
536
|
+
reportScanIssues(analyzeScanner(scanner, { baseDir }), { baseDir })
|
|
537
|
+
|
|
538
|
+
// Materialise via a non-preload State: addFile bucketizes + records sources/formats,
|
|
539
|
+
// addImport replays the edge map; serialize emits the runtime loader's v1 layout.
|
|
540
|
+
// 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.
|
|
694
542
|
const state = new State(baseDir, { bundle: 'replace', lock: 'ignore', ...(scope ? { scope } : {}) })
|
|
695
543
|
for (const [url, info] of scanner.files) {
|
|
544
|
+
// A resource carries bytes only: addFile derives resource vs resource:base64 from the content
|
|
545
|
+
// and stores it under `resources` (a resource can't be an entry, so no isEntry).
|
|
546
|
+
if (info.resource) {
|
|
547
|
+
state.addFile(url, { resource: true })
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
696
550
|
const isEntry = scanner.entries.has(url)
|
|
697
551
|
state.addFile(url, { format: info.format, isEntry })
|
|
698
552
|
}
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
|
|
704
|
-
for (const [,
|
|
705
|
-
for (const [parentURL, specs] of
|
|
553
|
+
// Edges where every context agrees keep the wildcard '*' key (getImport's fallback when a
|
|
554
|
+
// condition lookup misses). Where the require()- and import()-context resolutions of one
|
|
555
|
+
// (parent, specifier) DIVERGE, each target keeps its real condition key -- one '*' entry
|
|
556
|
+
// would serve one context the other's file; an unmatched set fails closed via resolveBundled.
|
|
557
|
+
const byParent = new Map()
|
|
558
|
+
for (const [key, parents] of scanner.imports) {
|
|
559
|
+
for (const [parentURL, specs] of parents) {
|
|
560
|
+
if (!byParent.has(parentURL)) byParent.set(parentURL, new Map())
|
|
561
|
+
const bySpec = byParent.get(parentURL)
|
|
706
562
|
for (const [spec, childURL] of specs) {
|
|
707
|
-
|
|
563
|
+
if (!bySpec.has(spec)) bySpec.set(spec, new Map())
|
|
564
|
+
bySpec.get(spec).set(key, childURL)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
for (const [parentURL, bySpec] of byParent) {
|
|
569
|
+
for (const [spec, byKey] of bySpec) {
|
|
570
|
+
const targets = new Set(byKey.values())
|
|
571
|
+
if (targets.size === 1) {
|
|
572
|
+
state.addImport(parentURL, spec, [...targets][0], { conditions: '*' })
|
|
573
|
+
} else {
|
|
574
|
+
for (const [key, childURL] of byKey) {
|
|
575
|
+
state.addImport(parentURL, spec, childURL, { conditions: key.split(', ') })
|
|
576
|
+
}
|
|
708
577
|
}
|
|
709
578
|
}
|
|
710
579
|
}
|
|
580
|
+
// --package-json: fold each bundled module's package.json into the State's buckets (idempotent --
|
|
581
|
+
// no-ops any manifest the scan already reached). State's bundle=replace makes writeBundle true, so addFile accepts them.
|
|
582
|
+
if (packageJSON) state.includePackageJson()
|
|
711
583
|
return state
|
|
712
584
|
}
|
|
713
585
|
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
586
|
+
// Extensions probed when a resolved target names none. Limited to what a source bundle can
|
|
587
|
+
// carry (scan's RESOLVABLE_EXTS) so the resolver never resolves a file the bundle would reject.
|
|
588
|
+
// --jsx widens this to the JSX/TSX extensions (mirroring scan's jsx-gated RESOLVABLE_EXTS) so an
|
|
589
|
+
// extensionless `import './Foo'` can land on Foo.jsx/Foo.tsx, as Metro's sourceExts do. `.js`/`.ts`
|
|
590
|
+
// keep their existing precedence (base order preserved as a subsequence); collisions across the
|
|
591
|
+
// added extensions are rare and, like the base list, don't track the project's real Metro order.
|
|
719
592
|
const SOURCE_EXTS = ['js', 'json', 'ts']
|
|
720
|
-
|
|
721
|
-
//
|
|
722
|
-
// --mainFields nor --conditions alongside it).
|
|
593
|
+
const SOURCE_EXTS_JSX = ['js', 'jsx', 'json', 'ts', 'tsx']
|
|
594
|
+
// React Native preset mainFields for `--metro` (which also sets the RN conditions + platform suffixes).
|
|
723
595
|
const METRO_MAIN_FIELDS = ['react-native', 'browser', 'main']
|
|
724
|
-
// Synthetic
|
|
725
|
-
//
|
|
726
|
-
// so the edge points at attestable bytes rather than a sentinel.
|
|
596
|
+
// Synthetic path for the empty module a browser/react-native `false` redirect resolves to,
|
|
597
|
+
// carried as a real empty CJS file so the edge points at attestable bytes.
|
|
727
598
|
const EMPTY_MODULE_PATH = '.stasis/empty-module.js'
|
|
728
599
|
|
|
729
|
-
//
|
|
730
|
-
// (
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
//
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
600
|
+
// Recursively collect files under a native ios/android dir, skipping build output and symlinks
|
|
601
|
+
// (cycle/escape hazard). Absolute paths into `out`.
|
|
602
|
+
function walkNativeDir(dirAbs, out) {
|
|
603
|
+
let entries
|
|
604
|
+
try {
|
|
605
|
+
entries = readdirSync(dirAbs, { withFileTypes: true })
|
|
606
|
+
} catch {
|
|
607
|
+
return // absent -- nothing for this platform
|
|
608
|
+
}
|
|
609
|
+
for (const ent of entries) {
|
|
610
|
+
if (ent.isSymbolicLink()) continue
|
|
611
|
+
const full = join(dirAbs, ent.name)
|
|
612
|
+
if (ent.isDirectory()) {
|
|
613
|
+
if (!isSkippedNativeWalkDir(ent.name)) walkNativeDir(full, out)
|
|
614
|
+
} else if (ent.isFile() && !isNativeArtifact(ent.name)) {
|
|
615
|
+
out.push(full)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Recursively collect podspec-load manifests (isNativeManifest) under `dirAbs`. RN's own
|
|
621
|
+
// podspecs live in scattered subdirs a root-only scan would miss, so recurse fully.
|
|
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
|
+
}
|
|
639
|
+
|
|
640
|
+
// Native source files a bundled RN dep contributes to the app's native build (podspecs +
|
|
641
|
+
// ios/android sources). Manifests are kept only when the package is actually native (podspec
|
|
642
|
+
// or ios/android dir), else a JS-only dep's package.json would be pulled in. Deduped absolute paths.
|
|
643
|
+
function nativeModuleFiles(pkgAbs) {
|
|
644
|
+
const manifests = []
|
|
645
|
+
collectNativeManifests(pkgAbs, manifests, true)
|
|
646
|
+
const hasIos = existsSync(join(pkgAbs, 'ios'))
|
|
647
|
+
const hasAndroid = existsSync(join(pkgAbs, 'android'))
|
|
648
|
+
if (!hasIos && !hasAndroid && !manifests.some((f) => isPodspec(f))) return []
|
|
649
|
+
const out = [...manifests]
|
|
650
|
+
if (hasIos) walkNativeDir(join(pkgAbs, 'ios'), out)
|
|
651
|
+
if (hasAndroid) walkNativeDir(join(pkgAbs, 'android'), out)
|
|
652
|
+
return [...new Set(out)]
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Build a JS/TS Bundle + companion Lockfile via the legacy-field resolver (`--mainFields`/
|
|
656
|
+
// `--metro`). Scanned once per platform; each edge is recorded flat when the platforms that
|
|
657
|
+
// 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 }) {
|
|
741
659
|
const baseDir = resolve(cwd)
|
|
742
660
|
const absEntries = entries.map((e) => resolve(baseDir, e))
|
|
743
661
|
const normalized = normalizeEntries(entries, cwd)
|
|
@@ -750,36 +668,48 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
750
668
|
return rel
|
|
751
669
|
}
|
|
752
670
|
|
|
753
|
-
// Normalize conditions
|
|
754
|
-
// (stray '' / whitespace) can't push a bogus token into the resolver.
|
|
671
|
+
// Normalize conditions (drop stray ''/whitespace) so a sloppy caller can't push a bogus token into the resolver.
|
|
755
672
|
const scanConditions = conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
|
|
756
673
|
|
|
674
|
+
// Under --jsx the resolver probes .jsx/.tsx too, matching scan's jsx-widened carryable set.
|
|
675
|
+
const sourceExts = jsx ? SOURCE_EXTS_JSX : SOURCE_EXTS
|
|
676
|
+
// --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
|
|
677
|
+
const resourceSet = parseResourcesOption('buildResolvedJsBundle', resources)
|
|
678
|
+
|
|
757
679
|
const formatsByRel = new Map()
|
|
758
|
-
//
|
|
759
|
-
|
|
680
|
+
// Reached files the scanner tagged as resources (rel keys); read as bytes below, not UTF-8 source.
|
|
681
|
+
const resourceRels = new Set()
|
|
682
|
+
// parentRel -> specifier -> Map<platformKey, targetRel>; collapsed after all platforms scanned.
|
|
760
683
|
const edges = new Map()
|
|
761
|
-
const reached = new Set() // absolute paths
|
|
684
|
+
const reached = new Set() // absolute paths reached on any platform
|
|
762
685
|
let usesEmpty = false
|
|
763
686
|
|
|
764
687
|
for (const platform of platforms) {
|
|
765
|
-
//
|
|
766
|
-
// browser for the web platform); `--mainFields` (platform === null) carries the
|
|
767
|
-
// user's own `--conditions`. scan layers these on the format-derived base.
|
|
688
|
+
// --metro asserts the RN conditions (+ browser on web); --mainFields (platform null) carries the user's --conditions.
|
|
768
689
|
const extras = metro ? ['react-native', ...(platform === 'web' ? ['browser'] : [])] : scanConditions
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
690
|
+
// --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 (resolve-fields.js) reproduces it.
|
|
692
|
+
// metro-resolver derives default/require|import/platform conditions itself, so it takes only the
|
|
693
|
+
// extra `react-native` condition (browser comes from its per-platform map, keyed on `web`).
|
|
694
|
+
const resolver = metroResolver
|
|
695
|
+
? createMetroResolver({ projectDir: baseDir, platform, sourceExts, mainFields, conditionNames: ['react-native'] })
|
|
696
|
+
: createFieldResolver({
|
|
697
|
+
mainFields,
|
|
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 })
|
|
706
|
+
reportScanIssues(analyzeScanner(scanner, { baseDir }), { baseDir, label: platform ?? 'mainFields' })
|
|
778
707
|
|
|
779
708
|
const platformKey = platform ?? '*' // '*' is a private placeholder for the single mainFields pass; it never unflattens
|
|
780
709
|
for (const [url, info] of scanner.files) {
|
|
781
710
|
const rel = toRel(fileURLToPath(url))
|
|
782
711
|
reached.add(fileURLToPath(url))
|
|
712
|
+
if (info.resource) resourceRels.add(rel)
|
|
783
713
|
formatsByRel.set(rel, info.format)
|
|
784
714
|
for (const e of info.edges) {
|
|
785
715
|
if (e.builtin || e.dynamic) continue
|
|
@@ -795,28 +725,31 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
795
725
|
}
|
|
796
726
|
}
|
|
797
727
|
|
|
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.
|
|
728
|
+
// Read each reached file once: bytes for the bundle, integrity for the lockfile. Source is
|
|
729
|
+
// stored as UTF-8 text, so non-UTF-8 bytes would diverge from the hashed bytes -- reject them.
|
|
802
730
|
const sources = new Map()
|
|
803
731
|
const integrities = new Map()
|
|
804
732
|
const realBase = realpathSync(baseDir)
|
|
805
733
|
for (const abs of reached) {
|
|
806
734
|
const rel = toRel(abs)
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
// project root would slip past toRel's textual check -- realpath it and fail closed,
|
|
810
|
-
// the same way the State-based JS path and the non-JS loaders do.
|
|
735
|
+
// Security: the field resolver returns the lexical path, so an in-tree-named symlink
|
|
736
|
+
// escaping the root would slip past toRel's textual check -- realpath and fail closed.
|
|
811
737
|
assertRealPathWithinBase(realBase, baseDir, rel)
|
|
812
738
|
const buf = readFileSync(abs)
|
|
813
|
-
if (
|
|
814
|
-
|
|
739
|
+
if (resourceRels.has(rel)) {
|
|
740
|
+
// A resource carries bytes, not JS source: store UTF-8 verbatim or base64 when binary, and
|
|
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')
|
|
745
|
+
} else {
|
|
746
|
+
if (!isUtf8(buf)) throw new Error(`JS bundle source is not valid UTF-8: ${rel}`)
|
|
747
|
+
sources.set(rel, buf.toString('utf8'))
|
|
748
|
+
}
|
|
815
749
|
integrities.set(rel, sha512integrity(buf))
|
|
816
750
|
}
|
|
817
751
|
if (usesEmpty) {
|
|
818
|
-
//
|
|
819
|
-
// happens to sit at the reserved path rather than silently clobber it.
|
|
752
|
+
// Refuse to shadow a real reached file sitting at the reserved empty-module path.
|
|
820
753
|
if (formatsByRel.has(EMPTY_MODULE_PATH)) {
|
|
821
754
|
throw new Error(`Bundle needs the reserved empty-module path ${EMPTY_MODULE_PATH}, but the project has a real file there`)
|
|
822
755
|
}
|
|
@@ -825,8 +758,96 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
825
758
|
integrities.set(EMPTY_MODULE_PATH, sha512integrity(Buffer.alloc(0)))
|
|
826
759
|
}
|
|
827
760
|
|
|
828
|
-
//
|
|
829
|
-
//
|
|
761
|
+
// --metro also carries each bundled dependency's native build-input surface (ios/android
|
|
762
|
+
// sources + podspecs), scoped to the node_modules packages actually in the bundle. Native
|
|
763
|
+
// source is stored as code under a language tag; other assets as 'resource'/'resource:base64'.
|
|
764
|
+
if (metro) {
|
|
765
|
+
const pkgDirs = new Set()
|
|
766
|
+
for (const abs of reached) {
|
|
767
|
+
// Follow the CODE/module graph only: a package reached solely for an asset (--resources) is
|
|
768
|
+
// not a linked native dependency, so it must not drag in its ios/android surface.
|
|
769
|
+
if (resourceRels.has(toRel(abs))) continue
|
|
770
|
+
const nm = splitNodeModulesPath(toRel(abs))
|
|
771
|
+
if (nm) pkgDirs.add(nm.dir)
|
|
772
|
+
}
|
|
773
|
+
for (const pkgDir of [...pkgDirs].toSorted()) {
|
|
774
|
+
const pkgAbs = join(baseDir, pkgDir)
|
|
775
|
+
// react-native core isn't a Pod (config reports it via reactNativePath, not `dependencies`),
|
|
776
|
+
// so walk its whole tree for native source (React/, ReactCommon/, ReactAndroid/, ...) the same
|
|
777
|
+
// way a native dep's ios/android surface is walked; every other dep gets its ios/android + podspecs.
|
|
778
|
+
const isRnCore = pkgDir.slice(pkgDir.lastIndexOf('node_modules/') + 'node_modules/'.length) === 'react-native'
|
|
779
|
+
const files = isRnCore ? [] : nativeModuleFiles(pkgAbs)
|
|
780
|
+
if (isRnCore) walkNativeDir(pkgAbs, files)
|
|
781
|
+
for (const abs of files) {
|
|
782
|
+
const rel = toRel(abs)
|
|
783
|
+
if (sources.has(rel)) continue
|
|
784
|
+
assertRealPathWithinBase(realBase, baseDir, rel)
|
|
785
|
+
// classifyNativeCapture (shared with the StasisMetro plugin) returns action skip/code/resource with a format tag.
|
|
786
|
+
const byName = classifyNativeCapture(rel)
|
|
787
|
+
if (byName.action === 'skip') continue
|
|
788
|
+
const buf = readFileSync(abs)
|
|
789
|
+
// Byte-level rules (prebuilt binaries, binary plists) -- see refineNativeCapture.
|
|
790
|
+
const { action, format } = refineNativeCapture(byName, rel, buf, resourceSet)
|
|
791
|
+
if (action === 'skip') continue
|
|
792
|
+
const utf8 = isUtf8(buf)
|
|
793
|
+
if (action === 'code') {
|
|
794
|
+
if (!utf8) throw new Error(`native source is not valid UTF-8: ${rel}`)
|
|
795
|
+
sources.set(rel, buf.toString('utf8'))
|
|
796
|
+
formatsByRel.set(rel, format)
|
|
797
|
+
} else {
|
|
798
|
+
sources.set(rel, utf8 ? buf.toString('utf8') : buf.toString('base64'))
|
|
799
|
+
formatsByRel.set(rel, utf8 ? 'resource' : 'resource:base64')
|
|
800
|
+
}
|
|
801
|
+
integrities.set(rel, sha512integrity(buf))
|
|
802
|
+
}
|
|
803
|
+
// RN core's `.js` build scripts the classify loop skips (Metro-owned by extension, but never in
|
|
804
|
+
// the graph): force-include as code so the podspec/Ruby that invokes them at pod-install resolves.
|
|
805
|
+
if (isRnCore) {
|
|
806
|
+
for (const file of RN_CORE_INCLUDE_FILES) {
|
|
807
|
+
const abs = join(pkgAbs, file)
|
|
808
|
+
if (!existsSync(abs)) continue
|
|
809
|
+
const rel = toRel(abs)
|
|
810
|
+
if (sources.has(rel)) continue
|
|
811
|
+
assertRealPathWithinBase(realBase, baseDir, rel)
|
|
812
|
+
const buf = readFileSync(abs)
|
|
813
|
+
if (!isUtf8(buf)) throw new Error(`native source is not valid UTF-8: ${rel}`)
|
|
814
|
+
sources.set(rel, buf.toString('utf8'))
|
|
815
|
+
formatsByRel.set(rel, packageType(abs) === 'module' ? 'module' : 'commonjs')
|
|
816
|
+
integrities.set(rel, sha512integrity(buf))
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// --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 same ones
|
|
824
|
+
// assembleCodeBundle derives (findPackageMetadata -> pkgDir, else the '.' workspace bucket);
|
|
825
|
+
// findPackageMetadata is memoized per directory so a package's many files don't each re-walk to the
|
|
826
|
+
// same manifest. readModuleManifest applies the read/validate rules shared with the State path
|
|
827
|
+
// (containment, UTF-8-aborts); no identity check here -- these buckets are all fresh from disk.
|
|
828
|
+
if (packageJSON) {
|
|
829
|
+
const metaByDir = new Map()
|
|
830
|
+
const pkgDirs = new Set()
|
|
831
|
+
for (const abs of reached) {
|
|
832
|
+
const rel = toRel(abs)
|
|
833
|
+
const dir = dirname(rel)
|
|
834
|
+
if (!metaByDir.has(dir)) metaByDir.set(dir, findPackageMetadata(baseDir, rel))
|
|
835
|
+
const meta = metaByDir.get(dir)
|
|
836
|
+
if (meta) pkgDirs.add(meta.pkgDir)
|
|
837
|
+
else if (!splitNodeModulesPath(rel)) pkgDirs.add('.')
|
|
838
|
+
}
|
|
839
|
+
for (const pkgDir of pkgDirs) {
|
|
840
|
+
const rel = moduleFileKey(pkgDir, 'package.json')
|
|
841
|
+
if (sources.has(rel)) continue
|
|
842
|
+
const buf = readModuleManifest({ baseDir, realBase, rel })
|
|
843
|
+
if (!buf) continue
|
|
844
|
+
sources.set(rel, buf.toString('utf8'))
|
|
845
|
+
formatsByRel.set(rel, 'json')
|
|
846
|
+
integrities.set(rel, sha512integrity(buf))
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Collapse each edge: one distinct target across platforms -> flat string; else a sorted Map<platform, target>.
|
|
830
851
|
const resolutions = new Map()
|
|
831
852
|
for (const [parent, bySpec] of edges) {
|
|
832
853
|
const specMap = new Map()
|
|
@@ -853,9 +874,7 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
853
874
|
conditionKey: '*',
|
|
854
875
|
})
|
|
855
876
|
|
|
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.
|
|
877
|
+
// The companion lockfile mirrors the bundle, swapping file content for its integrity.
|
|
859
878
|
const lockModules = new Map()
|
|
860
879
|
for (const [dir, m] of bundle.modules) {
|
|
861
880
|
const files = Object.create(null)
|
|
@@ -868,15 +887,15 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
|
|
|
868
887
|
modules: lockModules,
|
|
869
888
|
imports: bundle.imports,
|
|
870
889
|
formats: bundle.formats,
|
|
890
|
+
// Same file set as the bundle, so the same executable list attests it.
|
|
891
|
+
executable: bundle.executable,
|
|
871
892
|
})
|
|
872
893
|
|
|
873
894
|
return { bundle, lockfile }
|
|
874
895
|
}
|
|
875
896
|
|
|
876
|
-
// Classify entries into
|
|
877
|
-
|
|
878
|
-
// prefixes error messages with the caller.
|
|
879
|
-
function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditions, mainFields, platforms, metro }) {
|
|
897
|
+
// Classify entries into their single shared language and check option applicability; `name` prefixes errors.
|
|
898
|
+
function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, resources, packageJSON }) {
|
|
880
899
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
881
900
|
throw new Error(`${name}: at least one entry file is required`)
|
|
882
901
|
}
|
|
@@ -898,22 +917,39 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
|
|
|
898
917
|
if (lockfile !== undefined && kind !== 'js') {
|
|
899
918
|
throw new Error(`${name}: --lockfile is only valid for JS bundles`)
|
|
900
919
|
}
|
|
901
|
-
//
|
|
902
|
-
// languages' resolvers don't consult -- reject them rather than accept a flag
|
|
903
|
-
// that would silently do nothing.
|
|
920
|
+
// Reject --conditions for non-JS: their resolvers don't consult exports/imports conditions, so it would silently do nothing.
|
|
904
921
|
if (Array.isArray(conditions) && conditions.length > 0 && kind !== 'js') {
|
|
905
922
|
throw new Error(`${name}: --conditions is only valid for JS bundles`)
|
|
906
923
|
}
|
|
924
|
+
// --flow strips Flow type syntax before the JS parser; the non-JS loaders never parse with oxc.
|
|
925
|
+
if (flow && kind !== 'js') {
|
|
926
|
+
throw new Error(`${name}: --flow is only valid for JS bundles`)
|
|
927
|
+
}
|
|
928
|
+
// --resources carries allowlisted assets reached through the JS import graph; JS-only. The list
|
|
929
|
+
// is validated (parseResourcesOption) in the builders, but reject a code extension here too so a
|
|
930
|
+
// typo surfaces with the command name rather than deep in the scan.
|
|
931
|
+
if (Array.isArray(resources) && resources.length > 0) {
|
|
932
|
+
if (kind !== 'js') throw new Error(`${name}: --resources is only valid for JS bundles`)
|
|
933
|
+
parseResourcesOption(name, resources)
|
|
934
|
+
}
|
|
907
935
|
// --mainFields and --metro/--platforms are legacy-/platform-resolution knobs; JS-only.
|
|
908
936
|
if (mainFields !== undefined && kind !== 'js') {
|
|
909
937
|
throw new Error(`${name}: --mainFields is only valid for JS bundles`)
|
|
910
938
|
}
|
|
939
|
+
// --jsx toggles the scanner's JSX parsing for the .js family; only JS entries are scanned.
|
|
940
|
+
if (jsx && kind !== 'js') {
|
|
941
|
+
throw new Error(`${name}: --jsx is only valid for JS bundles`)
|
|
942
|
+
}
|
|
943
|
+
// --package-json folds each bundled module's npm package.json in; JS-only (the sol/php/bash/rust
|
|
944
|
+
// bucketizers group by their own ecosystems' manifests, not an npm package.json).
|
|
945
|
+
if (packageJSON && kind !== 'js') {
|
|
946
|
+
throw new Error(`${name}: --package-json is only valid for JS bundles`)
|
|
947
|
+
}
|
|
911
948
|
if ((metro || (Array.isArray(platforms) && platforms.length > 0)) && kind !== 'js') {
|
|
912
949
|
throw new Error(`${name}: --metro is only valid for JS bundles`)
|
|
913
950
|
}
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
// --platforms to know which suffixes to resolve. `--platforms` is meaningless without it.
|
|
951
|
+
// --metro presets conditions + mainFields + platform suffixes, so it can't combine with
|
|
952
|
+
// explicit --conditions/--mainFields and requires --platforms; --platforms is meaningless without it.
|
|
917
953
|
if (metro) {
|
|
918
954
|
if (Array.isArray(conditions) && conditions.length > 0) {
|
|
919
955
|
throw new Error(`${name}: --conditions can't be combined with --metro (it sets its own conditions)`)
|
|
@@ -924,9 +960,8 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
|
|
|
924
960
|
if (!Array.isArray(platforms) || platforms.length === 0) {
|
|
925
961
|
throw new Error(`${name}: --metro requires --platforms (e.g. --platforms=ios,android)`)
|
|
926
962
|
}
|
|
927
|
-
// A platform name becomes
|
|
928
|
-
//
|
|
929
|
-
// never emit an edge map the readers refuse -- a divergent-edge, data-dependent break.
|
|
963
|
+
// A platform name becomes an edge key: reject '/' (Bundle/Lockfile parse refuse it) and
|
|
964
|
+
// the reserved '*' placeholder, so the writer never emits a map the readers reject.
|
|
930
965
|
for (const p of platforms) {
|
|
931
966
|
if (typeof p !== 'string' || p.length === 0 || p === '*' || p.includes('/')) {
|
|
932
967
|
throw new Error(`${name}: invalid platform '${p}' (a platform name can't be empty, contain '/', or be '*')`)
|
|
@@ -935,36 +970,22 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
|
|
|
935
970
|
} else if (Array.isArray(platforms) && platforms.length > 0) {
|
|
936
971
|
throw new Error(`${name}: --platforms is only valid with --metro`)
|
|
937
972
|
}
|
|
938
|
-
//
|
|
939
|
-
|
|
973
|
+
// --metro-resolver swaps in the project's real metro-resolver; it only means something under --metro.
|
|
974
|
+
if (metroResolver && !metro) {
|
|
975
|
+
throw new Error(`${name}: --metro-resolver is only valid with --metro`)
|
|
976
|
+
}
|
|
977
|
+
// The field resolver always emits full-scope, so --scope with --mainFields/--metro would be silently ignored -- reject it.
|
|
940
978
|
if (scope !== undefined && (mainFields !== undefined || metro)) {
|
|
941
979
|
throw new Error(`${name}: --scope is not supported with --mainFields or --metro`)
|
|
942
980
|
}
|
|
943
981
|
return kind
|
|
944
982
|
}
|
|
945
983
|
|
|
946
|
-
// Programmatic equivalent of `stasis bundle`: build and return an in-memory
|
|
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 })
|
|
984
|
+
// Programmatic equivalent of `stasis bundle`: build and return an in-memory Bundle without
|
|
985
|
+
// writing to disk. Files are attributed to the `bundle` consumer. Option applicability
|
|
986
|
+
// (--mapping/.sol, --scope|--conditions|--mainFields|--metro|--jsx|--flow/JS) is enforced by classifyEntries.
|
|
987
|
+
export async function buildBundle({ cwd = process.cwd(), entries, mappingFile, scope, conditions, mainFields, platforms, metro, metroResolver, jsx = false, flow = false, resources = [], packageJSON = false } = {}) {
|
|
988
|
+
const kind = classifyEntries('buildBundle', { entries, mappingFile, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, resources, packageJSON })
|
|
968
989
|
if (kind === 'sol') return buildSolidityBundle({ cwd, entries, mappingFile })
|
|
969
990
|
if (kind === 'php') return buildPhpBundle({ cwd, entries })
|
|
970
991
|
if (kind === 'bash') return buildBashBundle({ cwd, entries })
|
|
@@ -977,59 +998,43 @@ export async function buildBundle({ cwd = process.cwd(), entries, mappingFile, s
|
|
|
977
998
|
platforms: metro ? platforms : [null],
|
|
978
999
|
conditions,
|
|
979
1000
|
metro: Boolean(metro),
|
|
1001
|
+
metroResolver: Boolean(metroResolver),
|
|
1002
|
+
jsx,
|
|
1003
|
+
flow,
|
|
1004
|
+
resources,
|
|
1005
|
+
packageJSON,
|
|
980
1006
|
})
|
|
981
1007
|
return bundle
|
|
982
1008
|
}
|
|
983
|
-
const state = await buildJsBundle({ cwd, entries, scope, conditions })
|
|
984
|
-
|
|
1009
|
+
const state = await buildJsBundle({ cwd, entries, scope, conditions, jsx, flow, resources, packageJSON })
|
|
1010
|
+
// Stamp the `bundle` consumer (the static build carries none).
|
|
1011
|
+
return state.sourceBundle.withReason('bundle')
|
|
985
1012
|
}
|
|
986
1013
|
|
|
987
|
-
//
|
|
988
|
-
// same name `stasis run --bundle=load` discovers by default, so the two commands
|
|
989
|
-
// round-trip with no flags. A file (not stdout) is the common case; pass
|
|
990
|
-
// --output=- to stream the raw brotli bytes to stdout.
|
|
1014
|
+
// Default output: stasis.code.br, the same name `stasis run --bundle=load` discovers, so the two round-trip with no flags.
|
|
991
1015
|
const DEFAULT_BUNDLE_FILE = 'stasis.code.br'
|
|
992
1016
|
|
|
993
|
-
// Run
|
|
994
|
-
// stasis
|
|
995
|
-
//
|
|
996
|
-
//
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
//
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
// affect `exports`/`imports` condition matching -- not legacy mainFields -- so on
|
|
1009
|
-
// their own they don't honour a package's legacy entry fields. See buildJsBundle.
|
|
1010
|
-
//
|
|
1011
|
-
// For JS bundles, an optional `lockfile` path writes a stasis.lock.json that
|
|
1012
|
-
// attests every file in the bundle. The static scan walks both branches of
|
|
1013
|
-
// conditional requires (e.g. debug/src/index.js' `if (browser) require('./browser')
|
|
1014
|
-
// else require('./node')`), so the runtime lockfile from `stasis run --lock=add`
|
|
1015
|
-
// won't attest the unused branch -- if the user wants `lock=frozen` against a
|
|
1016
|
-
// statically-built bundle, they need this companion lockfile.
|
|
1017
|
-
//
|
|
1018
|
-
// With `--conditions`, that companion lockfile attests the conditions-SELECTED
|
|
1019
|
-
// resolution graph. A plain `stasis run --lock=frozen` doesn't replay `--conditions`,
|
|
1020
|
-
// so it resolves different files on disk and fails closed (correctly -- the lockfile
|
|
1021
|
-
// genuinely attests a different graph). Pair such a lockfile with `--bundle=load`, or
|
|
1022
|
-
// replay the same `--conditions` at run time, so the attested and observed graphs agree.
|
|
1023
|
-
export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile, output, scope, lockfile, conditions, mainFields, platforms, metro } = {}) {
|
|
1024
|
-
const kind = classifyEntries('bundleCommand', { entries, mappingFile, scope, lockfile, conditions, mainFields, platforms, metro })
|
|
1017
|
+
// Run `stasis bundle`: build a brotli-compressed bundle and write it to `output`
|
|
1018
|
+
// (stasis.code.br by default, `-` for stdout; the summary goes to stderr so it never
|
|
1019
|
+
// interleaves with binary stdout). An optional JS `lockfile` attests every bundled file;
|
|
1020
|
+
// with `--conditions` it attests the conditions-selected graph, so a plain
|
|
1021
|
+
// `stasis run --lock=frozen` (which doesn't replay them) fails closed -- pair it with
|
|
1022
|
+
// `--bundle=load` or replay the conditions. `add` unions the fresh build into the bundle
|
|
1023
|
+
// 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 kind = classifyEntries('bundleCommand', { entries, mappingFile, scope, lockfile, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, resources, packageJSON })
|
|
1026
|
+
|
|
1027
|
+
const target = output ?? DEFAULT_BUNDLE_FILE
|
|
1028
|
+
// --add has nothing to merge into on stdout (write-only).
|
|
1029
|
+
if (add && target === '-') {
|
|
1030
|
+
throw new Error('bundleCommand: --add cannot be combined with --output=- (nothing to merge into on stdout)')
|
|
1031
|
+
}
|
|
1025
1032
|
|
|
1026
1033
|
let bundle
|
|
1027
1034
|
let lockData
|
|
1028
1035
|
if (kind === 'js' && (metro || mainFields !== undefined)) {
|
|
1029
|
-
// The legacy-field resolver
|
|
1030
|
-
//
|
|
1031
|
-
// browser/react-native `false` redirect) that State's disk-reading addFile can't
|
|
1032
|
-
// represent.
|
|
1036
|
+
// The legacy-field resolver builds Bundle + Lockfile directly (per-platform edges + a
|
|
1037
|
+
// synthetic empty module State's addFile can't represent).
|
|
1033
1038
|
const built = await buildResolvedJsBundle({
|
|
1034
1039
|
cwd,
|
|
1035
1040
|
entries,
|
|
@@ -1037,30 +1042,63 @@ export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile,
|
|
|
1037
1042
|
platforms: metro ? platforms : [null],
|
|
1038
1043
|
conditions,
|
|
1039
1044
|
metro: Boolean(metro),
|
|
1045
|
+
metroResolver: Boolean(metroResolver),
|
|
1046
|
+
jsx,
|
|
1047
|
+
flow,
|
|
1048
|
+
resources,
|
|
1049
|
+
packageJSON,
|
|
1040
1050
|
})
|
|
1041
1051
|
bundle = built.bundle
|
|
1042
1052
|
if (lockfile) lockData = built.lockfile.serialize()
|
|
1043
1053
|
} else if (kind === 'js' && lockfile) {
|
|
1044
|
-
//
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
bundle = state.sourceBundle
|
|
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')
|
|
1048
1058
|
lockData = state.lockData
|
|
1049
1059
|
} else {
|
|
1050
|
-
bundle = await buildBundle({ cwd, entries, mappingFile, scope, conditions })
|
|
1060
|
+
bundle = await buildBundle({ cwd, entries, mappingFile, scope, conditions, jsx, flow, resources, packageJSON })
|
|
1051
1061
|
}
|
|
1062
|
+
|
|
1063
|
+
// --add: union the fresh build into the existing on-disk bundle; a conflicting file throws. Skipped when nothing is on disk.
|
|
1064
|
+
const outAbs = target === '-' ? undefined : resolve(cwd, target)
|
|
1065
|
+
// Files the pre-existing bundle carried; undefined when no merge happened (the summary's merged sentinel).
|
|
1066
|
+
let mergedFrom
|
|
1067
|
+
if (add && existsSync(outAbs)) {
|
|
1068
|
+
let existing
|
|
1069
|
+
try {
|
|
1070
|
+
existing = Bundle.parse(brotliDecompressSync(readFileSync(outAbs)).toString('utf8'))
|
|
1071
|
+
} catch (cause) {
|
|
1072
|
+
throw new Error(`bundleCommand: --add failed to read the existing bundle at ${target}`, { cause })
|
|
1073
|
+
}
|
|
1074
|
+
mergedFrom = existing.sources.size
|
|
1075
|
+
bundle = existing.merge(bundle)
|
|
1076
|
+
// Keep the companion lockfile consistent with the merged bundle: union an existing one.
|
|
1077
|
+
// With none on disk we can't attest the pre-existing bundle's files (their hashes live
|
|
1078
|
+
// only in that missing lockfile), so refuse rather than write an under-attesting lockfile.
|
|
1079
|
+
if (lockData && lockfile) {
|
|
1080
|
+
const lockAbs = resolve(cwd, lockfile)
|
|
1081
|
+
if (!existsSync(lockAbs)) {
|
|
1082
|
+
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
|
+
}
|
|
1084
|
+
let existingLock
|
|
1085
|
+
try {
|
|
1086
|
+
existingLock = Lockfile.parse(readFileSync(lockAbs, 'utf8'))
|
|
1087
|
+
} catch (cause) {
|
|
1088
|
+
throw new Error(`bundleCommand: --add failed to read the existing lockfile at ${lockfile}`, { cause })
|
|
1089
|
+
}
|
|
1090
|
+
lockData = existingLock.merge(Lockfile.parse(lockData)).serialize()
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1052
1094
|
const serialized = bundle.serialize()
|
|
1053
1095
|
const files = [...bundle.sources.keys()]
|
|
1054
1096
|
const modules = bundle.modules
|
|
1055
1097
|
|
|
1056
|
-
const data = brotliCompressSync(serialized, brotliOptions())
|
|
1057
|
-
// Default to writing stasis.code.br in cwd; `-` is the conventional opt-in
|
|
1058
|
-
// for streaming the raw brotli bytes to stdout (e.g. to pipe somewhere else).
|
|
1059
|
-
const target = output ?? DEFAULT_BUNDLE_FILE
|
|
1098
|
+
const data = brotliCompressSync(serialized, brotliOptions(brotliQuality))
|
|
1060
1099
|
if (target === '-') {
|
|
1061
1100
|
process.stdout.write(data)
|
|
1062
1101
|
} else {
|
|
1063
|
-
const outAbs = resolve(cwd, target)
|
|
1064
1102
|
mkdirSync(dirname(outAbs), { recursive: true })
|
|
1065
1103
|
writeFileSync(outAbs, data)
|
|
1066
1104
|
}
|
|
@@ -1071,9 +1109,14 @@ export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile,
|
|
|
1071
1109
|
}
|
|
1072
1110
|
const fromDir = outermostDir(files, resolve(cwd))
|
|
1073
1111
|
const dest = target === '-' ? '<stdout>' : target
|
|
1074
|
-
// Count packages: every non-empty bucket
|
|
1075
|
-
// "." workspace bucket) counting as one package alongside each dependency.
|
|
1112
|
+
// Count packages: every non-empty bucket (the "." workspace bucket counts as one).
|
|
1076
1113
|
const packages = [...modules.values()].filter((m) => Object.keys(m.files).length > 0).length
|
|
1077
1114
|
const pkgLabel = `${packages} package${packages === 1 ? '' : 's'}`
|
|
1078
|
-
|
|
1115
|
+
// On a merge, report newly-added files alongside the totals; a fresh write keeps the plain line.
|
|
1116
|
+
if (mergedFrom !== undefined) {
|
|
1117
|
+
const added = files.length - mergedFrom
|
|
1118
|
+
console.warn(`[stasis] Added ${added} file${added === 1 ? '' : 's'} (${files.length} total in ${pkgLabel}) from ${fromDir} to ${dest}`)
|
|
1119
|
+
} else {
|
|
1120
|
+
console.warn(`[stasis] Bundled ${files.length} files in ${pkgLabel} from ${fromDir} to ${dest}`)
|
|
1121
|
+
}
|
|
1079
1122
|
}
|