@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -3
- package/bin/stasis.js +375 -170
- package/package.json +44 -11
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +243 -59
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +95 -128
- package/src/cmd/bundle.js +877 -641
- package/src/cmd/diff.js +2 -19
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +4 -18
- package/src/diff.js +67 -124
- package/src/json-stream.js +283 -0
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +49 -116
- package/src/loaders/cargo.js +1699 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +339 -394
- package/src/loaders/rust.js +3254 -242
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -162
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +150 -0
- package/src/mock.js +57 -165
- package/src/parse.js +4 -16
- package/src/resolve-fields.js +331 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +427 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +64 -92
- package/src/scan.js +234 -156
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +193 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +606 -0
- package/src/vfs-bundle.js +74 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -44
- package/src/cmd/extract.js +0 -193
package/src/babel.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Per-file Babel pre-transforms for `stasis build`, serving two flags:
|
|
2
|
+
//
|
|
3
|
+
// --platform=hermes downlevels every code file to the dialect Hermes (React Native's
|
|
4
|
+
// engine) executes -- no classes, class fields, let/const, arrows, async generators or
|
|
5
|
+
// for-await. Two steps per file, then a constrained final bundle:
|
|
6
|
+
// 1. esbuild.transform lowers class fields / private members / static blocks / `using`
|
|
7
|
+
// into plain class syntax (stripping TS types and compiling JSX on the way), the
|
|
8
|
+
// shape Babel's class transform can consume.
|
|
9
|
+
// 2. A Babel worker pool applies transform-block-scoping + transform-classes -- the two
|
|
10
|
+
// lowerings esbuild refuses to do itself.
|
|
11
|
+
// 3. The final esbuild bundle runs with HERMES_UNSUPPORTED (arrow/async-generator/
|
|
12
|
+
// for-await lowered by esbuild; class and const-and-let declared unsupported both so
|
|
13
|
+
// esbuild's own helpers stay Hermes-safe and as a fail-closed check that none
|
|
14
|
+
// survived Babel).
|
|
15
|
+
//
|
|
16
|
+
// --babel instead runs each code file through the project's own babel.config.* verbatim --
|
|
17
|
+
// no esbuild pre-transform, Babel sees the original source exactly as the project's own
|
|
18
|
+
// toolchain (e.g. Metro) would hand it over.
|
|
19
|
+
//
|
|
20
|
+
// Babel is the PROJECT's dev dependency, not stasis's: @babel/core (and, for hermes, the
|
|
21
|
+
// two transform plugins) resolve from the project directory, so the project pins its own
|
|
22
|
+
// Babel toolchain and config semantics.
|
|
23
|
+
import { existsSync } from 'node:fs'
|
|
24
|
+
import { createRequire } from 'node:module'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url)
|
|
28
|
+
|
|
29
|
+
// esbuild `supported` map for the FINAL hermes bundle (spread into esbuild.build options).
|
|
30
|
+
export const HERMES_UNSUPPORTED = {
|
|
31
|
+
arrow: false,
|
|
32
|
+
class: false, // already lowered by Babel; unsupporting it doubles as a safeguard check
|
|
33
|
+
'async-generator': false,
|
|
34
|
+
'const-and-let': false, // ditto, and keeps esbuild-emitted helpers off let/const
|
|
35
|
+
'for-await': false,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Class-syntax extensions the hermes pre-transform lowers into plain classes for Babel,
|
|
39
|
+
// which handles none of them without extra plugins (and Hermes supports none of them either).
|
|
40
|
+
const PRE_TRANSFORM_UNSUPPORTED = {
|
|
41
|
+
'class-field': false,
|
|
42
|
+
'class-private-accessor': false,
|
|
43
|
+
'class-private-brand-check': false,
|
|
44
|
+
'class-private-field': false,
|
|
45
|
+
'class-private-method': false,
|
|
46
|
+
'class-private-static-accessor': false,
|
|
47
|
+
'class-private-static-field': false,
|
|
48
|
+
'class-private-static-method': false,
|
|
49
|
+
'class-static-blocks': false,
|
|
50
|
+
'class-static-field': false,
|
|
51
|
+
'import-attributes': false,
|
|
52
|
+
using: false,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const HERMES_BABEL_PLUGINS = ['@babel/plugin-transform-block-scoping', '@babel/plugin-transform-classes']
|
|
56
|
+
|
|
57
|
+
// Only real JS-family sources are transformed; anything else the plugin serves (json, or a
|
|
58
|
+
// --loader override like .svg:text) passes through untouched.
|
|
59
|
+
const TRANSFORMABLE_LOADERS = new Set(['js', 'ts', 'jsx', 'tsx'])
|
|
60
|
+
|
|
61
|
+
// Resolved up front on the main thread so a missing install fails with instructions, not a
|
|
62
|
+
// worker crash mid-build. Returns absolute paths: the worker loads @babel/core by path, and
|
|
63
|
+
// plugin paths bypass Babel's own name resolution (which would look in the wrong dir).
|
|
64
|
+
function resolveFromProject(projectDir, deps, flag) {
|
|
65
|
+
const projectRequire = createRequire(join(projectDir, 'noop.js'))
|
|
66
|
+
try {
|
|
67
|
+
return deps.map((dep) => projectRequire.resolve(dep))
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`stasis build: ${flag} needs ${deps.join(', ')} installed in the project ` +
|
|
71
|
+
`(e.g. \`npm i -D ${deps.join(' ')}\`); resolution failed from ${projectDir}`,
|
|
72
|
+
{ cause }
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The hermes per-file pre-transform, in StasisEsbuild's load-mode `transform` hook shape:
|
|
78
|
+
// (source, { path, loader }) => { contents, loader } | undefined.
|
|
79
|
+
export function createHermesTransform({ projectDir, esbuild }) {
|
|
80
|
+
const [babelPath, ...plugins] = resolveFromProject(
|
|
81
|
+
projectDir, ['@babel/core', ...HERMES_BABEL_PLUGINS], '--platform=hermes'
|
|
82
|
+
)
|
|
83
|
+
const { transformAsync } = require('./babel-worker.cjs')
|
|
84
|
+
return async (source, { path, loader }) => {
|
|
85
|
+
if (!TRANSFORMABLE_LOADERS.has(loader)) return undefined
|
|
86
|
+
const { code, warnings } = await esbuild.transform(source, {
|
|
87
|
+
sourcemap: 'inline',
|
|
88
|
+
sourcefile: path,
|
|
89
|
+
loader,
|
|
90
|
+
supported: PRE_TRANSFORM_UNSUPPORTED,
|
|
91
|
+
})
|
|
92
|
+
if (warnings.length > 0) {
|
|
93
|
+
const formatted = await esbuild.formatMessages(warnings, { kind: 'warning', color: false })
|
|
94
|
+
for (const line of formatted) process.stderr.write(line)
|
|
95
|
+
}
|
|
96
|
+
const result = await transformAsync(babelPath, code, {
|
|
97
|
+
compact: false,
|
|
98
|
+
babelrc: false,
|
|
99
|
+
configFile: false,
|
|
100
|
+
plugins,
|
|
101
|
+
})
|
|
102
|
+
// TS is stripped and JSX compiled by the pre-transform, so the result is always plain js.
|
|
103
|
+
return { contents: result.code, loader: 'js' }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The --babel per-file transform (same hook shape): the project's own babel.config.* over
|
|
108
|
+
// the original source. Its output must be esbuild-parseable js -- a config that leaves TS/JSX
|
|
109
|
+
// in place fails the build in Babel or esbuild, which is the project's config to fix.
|
|
110
|
+
export function createBabelConfigTransform({ projectDir }) {
|
|
111
|
+
const configFile = ['babel.config.js', 'babel.config.cjs', 'babel.config.mjs', 'babel.config.json']
|
|
112
|
+
.map((name) => join(projectDir, name))
|
|
113
|
+
.find((file) => existsSync(file))
|
|
114
|
+
if (!configFile) {
|
|
115
|
+
throw new Error(`stasis build: --babel found no babel.config.(js|cjs|mjs|json) in ${projectDir}`)
|
|
116
|
+
}
|
|
117
|
+
const [babelPath] = resolveFromProject(projectDir, ['@babel/core'], '--babel')
|
|
118
|
+
const { transformAsync } = require('./babel-worker.cjs')
|
|
119
|
+
return async (source, { path, loader }) => {
|
|
120
|
+
if (!TRANSFORMABLE_LOADERS.has(loader)) return undefined
|
|
121
|
+
// filename makes per-file config (overrides, .babelrc lookup) behave as in a real run.
|
|
122
|
+
const result = await transformAsync(babelPath, source, {
|
|
123
|
+
cwd: projectDir,
|
|
124
|
+
root: projectDir,
|
|
125
|
+
configFile,
|
|
126
|
+
filename: path,
|
|
127
|
+
compact: false,
|
|
128
|
+
})
|
|
129
|
+
return { contents: result.code, loader: 'js' }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -4,17 +4,12 @@ import { resolve } from 'node:path'
|
|
|
4
4
|
|
|
5
5
|
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
6
6
|
import { sha512integrity } from '@exodus/stasis-core/state-util'
|
|
7
|
-
import { moduleFileKey } from '@exodus/stasis-core/util'
|
|
7
|
+
import { moduleFileKey, narrowExecutable } from '@exodus/stasis-core/util'
|
|
8
8
|
|
|
9
|
-
// Reconstruct an in-memory Bundle from a lockfile
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// `stasis run --lock=frozen` replay: the build only ever sees bytes the lockfile
|
|
14
|
-
// vouches for. `root` is the project root the lockfile's paths are relative to.
|
|
15
|
-
//
|
|
16
|
-
// Directory captures (`stasis run --fs` readdir listings, format 'directory') are
|
|
17
|
-
// skipped: they key a listing at a directory's own path, not a file esbuild could load.
|
|
9
|
+
// Reconstruct an in-memory Bundle from a lockfile, reading every attested file from disk and
|
|
10
|
+
// verifying each against the lockfile's SRI digest -- fail closed on mismatch, so this is as
|
|
11
|
+
// trustworthy as a `stasis run --lock=frozen` replay. `root` is what the lockfile's paths are
|
|
12
|
+
// relative to. Directory captures (format 'directory') are skipped -- not files esbuild can load.
|
|
18
13
|
export function bundleFromLockfile(lockfile, { root }) {
|
|
19
14
|
if (!lockfile.imports) throw new Error('stasis: lockfile has no recorded import graph')
|
|
20
15
|
const formats = lockfile.formats ?? new Map()
|
|
@@ -32,18 +27,14 @@ export function bundleFromLockfile(lockfile, { root }) {
|
|
|
32
27
|
} catch (cause) {
|
|
33
28
|
throw new Error(`stasis: file attested by the lockfile is missing on disk: ${file}`, { cause })
|
|
34
29
|
}
|
|
35
|
-
// The lockfile digests raw on-disk bytes; verify
|
|
36
|
-
// the file. (Code and 'resource' files hash identically whether viewed as a
|
|
37
|
-
// string or bytes, since Node hashes a string as its UTF-8 bytes.)
|
|
30
|
+
// The lockfile digests raw on-disk bytes; verify before trusting the file.
|
|
38
31
|
const actual = sha512integrity(bytes)
|
|
39
32
|
if (actual !== integrity) {
|
|
40
33
|
throw new Error(`stasis: ${file} does not match the lockfile (expected ${integrity}, got ${actual})`)
|
|
41
34
|
}
|
|
42
|
-
// Store content
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// (U+FFFD), diverging the served content from the verified bytes -- mirror the
|
|
46
|
-
// capture side's isUtf8 guard and fail closed instead.
|
|
35
|
+
// Store content as a Bundle does: 'resource:base64' as base64, everything else as a UTF-8 string.
|
|
36
|
+
// A non-UTF-8 byte in a non-base64 entry would transcode lossily (U+FFFD), diverging served
|
|
37
|
+
// content from the verified bytes -- mirror the capture side's isUtf8 guard and fail closed.
|
|
47
38
|
if (format === 'resource:base64') {
|
|
48
39
|
out[rel] = bytes.toString('base64')
|
|
49
40
|
} else {
|
|
@@ -61,5 +52,8 @@ export function bundleFromLockfile(lockfile, { root }) {
|
|
|
61
52
|
modules,
|
|
62
53
|
imports: lockfile.imports,
|
|
63
54
|
formats,
|
|
55
|
+
// Narrowed to the files this bundle ended up carrying (a skipped `directory` capture drops out),
|
|
56
|
+
// keeping Bundle.parse's rules true for a hand-built lockfile too.
|
|
57
|
+
executable: narrowExecutable(lockfile.executable, { modules, formats, scope: lockfile.config.scope }),
|
|
64
58
|
})
|
|
65
59
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs'
|
|
2
|
+
import { pipeline } from 'node:stream/promises'
|
|
3
|
+
import { createBrotliDecompress } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
6
|
+
import { assert } from '@exodus/stasis-core/util'
|
|
7
|
+
|
|
8
|
+
import { JsonStreamParser } from './json-stream.js'
|
|
9
|
+
|
|
10
|
+
// Rejects as soon as `signal` aborts, without waiting for `promise` (onFile gets the signal to stop itself).
|
|
11
|
+
const untilAborted = (promise, signal) => (signal === undefined ? promise : new Promise((resolve, reject) => {
|
|
12
|
+
const abort = () => reject(signal.reason)
|
|
13
|
+
if (signal.aborted) abort()
|
|
14
|
+
else signal.addEventListener('abort', abort, { once: true })
|
|
15
|
+
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
// Left in place of each streamed file; fromJSON's contents-free mode requires one per file.
|
|
19
|
+
const STREAMED = Symbol('streamed')
|
|
20
|
+
|
|
21
|
+
// Bundle.parse(brotliDecompressSync(source)), without holding the decompressed bytes or the JSON text whole.
|
|
22
|
+
// onFile gets each file in stream order, wherever the bundle puts its files, and before the bundle is
|
|
23
|
+
// validated: what it receives (format included) is provisional until readBundle resolves. See doc/file-formats.md.
|
|
24
|
+
export async function readBundle(source, { onFile, signal } = {}) {
|
|
25
|
+
assert(onFile === undefined || typeof onFile === 'function', 'readBundle: onFile must be a function')
|
|
26
|
+
let input = source
|
|
27
|
+
if (typeof source === 'string' || source instanceof URL) input = createReadStream(source)
|
|
28
|
+
else if (source instanceof ArrayBuffer) input = [new Uint8Array(source)]
|
|
29
|
+
else if (ArrayBuffer.isView(source)) input = [new Uint8Array(source.buffer, source.byteOffset, source.byteLength)]
|
|
30
|
+
|
|
31
|
+
const formats = new Map() // as far as they have streamed: newer bundles put them before any file
|
|
32
|
+
const delivered = new Map() // file -> the format onFile was given
|
|
33
|
+
const pending = []
|
|
34
|
+
const parser = new JsonStreamParser(onFile && {
|
|
35
|
+
onString(contents, path) {
|
|
36
|
+
if (path.length === 2 && path[0] === 'formats') formats.set(path[1] === '' ? '.' : path[1], contents)
|
|
37
|
+
const file = Bundle.fileKeyAt(path)
|
|
38
|
+
if (file === undefined) return contents
|
|
39
|
+
assert(!delivered.has(file), `bundle carries file '${file}' twice`)
|
|
40
|
+
const format = formats.get(file)
|
|
41
|
+
delivered.set(file, format)
|
|
42
|
+
pending.push([file, contents, format])
|
|
43
|
+
return STREAMED
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Big chunks let zlib's thread pool decompress ahead of the parser; zlib's 16 KiB default runs about 2x slower.
|
|
48
|
+
const decompress = createBrotliDecompress({ chunkSize: 1024 * 1024, readableHighWaterMark: 4 * 1024 * 1024 })
|
|
49
|
+
let drained = false
|
|
50
|
+
await pipeline(input, decompress, async (chunks) => {
|
|
51
|
+
for await (const chunk of chunks) {
|
|
52
|
+
parser.write(chunk)
|
|
53
|
+
for (const [file, contents, format] of pending.splice(0)) {
|
|
54
|
+
signal?.throwIfAborted() // one chunk can queue many files
|
|
55
|
+
// eslint-disable-next-line no-await-in-loop -- in stream order, with backpressure on the decompressor
|
|
56
|
+
await untilAborted(onFile(file, contents, { signal, format }), signal)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
drained = true
|
|
60
|
+
}, { signal }).catch((error) => {
|
|
61
|
+
// Once drained, the stream is whole: pipeline reports unread bytes after it as an abort.
|
|
62
|
+
if (!drained || signal?.aborted) throw error
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const bundle = Bundle.fromJSON(parser.end(), { contents: !onFile })
|
|
66
|
+
if (onFile) {
|
|
67
|
+
// fromJSON required a placeholder per file, so onFile got each one; it must have got nothing else.
|
|
68
|
+
let files = 0
|
|
69
|
+
for (const [, info] of bundle.modules) files += Object.keys(info.files).length
|
|
70
|
+
assert(delivered.size === files, 'bundle carries file contents outside its file list')
|
|
71
|
+
// A later `formats` key replaces the one onFile was told about.
|
|
72
|
+
for (const [file, format] of delivered) {
|
|
73
|
+
if (format !== undefined && bundle.formats.get(file) !== format) assert(false, `bundle file '${file}' changed format after onFile got it`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return bundle
|
|
77
|
+
}
|