Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
*~
node_modules
!tests/fixtures/*/node_modules
!tests/fixtures/**/node_modules
package-lock.json
coverage
doc
Expand Down
28 changes: 25 additions & 3 deletions bin/stasis.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ assert(basename(jsname) === 'stasis' || pathsEqual(jsname, fileURLToPath(import.
function usage(prefix = '') {
console.error(`${prefix}\nUsage:
stasis run --lock=(add|replace|frozen|ignore) [--bundle=(add|replace|load|ignore)] [--bundle-file=path/to/bundle.br] [--full] path/to/file.js ...
stasis bundle create path/to/lockfile
stasis bundle verify path/to/lockfile
stasis bundle [--mapping=path/to/remappings(.txt|.toml)] [--output=path/to/out.stasis.code.br] path/to/file.sol ...
stasis prune [path/to/project]
stasis audit path/to/file ...
`.trim())
Expand Down Expand Up @@ -77,7 +76,30 @@ if (command === '-v' || command === '--version') {
const [code] = await once(child, 'close')
process.exitCode = code
} else if (command === 'bundle') {
usage('bundle command is not implemented yet')
const flags = []
const valueFlags = new Set(['--mapping', '--output', '-o'])
while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
flags.push(argv.shift())
}
const options = {
mapping: { type: 'string' },
output: { type: 'string', short: 'o' },
}
let values
try {
({ values } = parseArgs({ args: flags, options }))
} catch (cause) {
usage(`Error: ${cause.message}`)
}
if (argv.length === 0) usage('Nothing to bundle: no .sol file given')
if (!argv.every((f) => f.endsWith('.sol'))) usage('Error: bundle only accepts .sol files')
const { bundleCommand } = await import('../src/cmd/bundle.js')
await bundleCommand({
cwd: process.cwd(),
entries: argv,
mappingFile: values.mapping,
output: values.output,
})
} else if (command === 'prune') {
if (argv.length > 1) usage('Error: prune takes at most one path argument')
const root = argv[0] ? resolve(argv[0]) : process.cwd()
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
"src/apis/npm/index.js",
"src/apis/npm/semver.cjs",
"src/bundle.js",
"src/cmd/bundle.js",
"src/config.js",
"src/esbuild.js",
"src/loader.js",
"src/loaders/solidity.js",
"src/lockfile.js",
"src/prune.js",
"src/state.js",
Expand Down
188 changes: 188 additions & 0 deletions src/cmd/bundle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'
import { brotliCompressSync } from 'node:zlib'

import { Bundle } from '../bundle.js'
import { splitNodeModulesPath } from '../util.js'
import {
buildSolidityTree,
collectSolidityFilesFromDisk,
readRemappingsFile,
} from '../loaders/solidity.js'

// Fallback package identity for the workspace bucket when no package.json
// with a name+version can be found by walking up from any of the bundled
// files. Bundle.parseCode requires every workspace/module bucket to have
// both fields, so we have to attest something.
const SOLIDITY_WORKSPACE_NAME = 'solidity-bundle'
const SOLIDITY_WORKSPACE_VERSION = '0.0.0'
const SOLIDITY_FORMAT = 'solidity'

// Walk up from the file's directory looking for the nearest package.json
// with both `name` and `version`. Returns { pkgDir, name, version }
// (pkgDir relative to `baseDir`, "." for the project root) or null when no
// such package.json exists at or above the file.
function findPackageMetadata(baseDir, fileRelPath) {
let dir = dirname(fileRelPath)
while (true) {
const pkgPath = join(baseDir, dir, 'package.json')
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
if (pkg.name && pkg.version) {
return { pkgDir: dir, name: pkg.name, version: pkg.version }
}
} catch { /* malformed — keep walking */ }
}
if (dir === '.' || dir === '/' || dir === '') return null
const parent = dirname(dir)
if (parent === dir) return null
dir = parent
}
}

function normalizeEntries(entries, cwd) {
const baseDir = resolve(cwd)
return entries.map((e) => {
const abs = resolve(cwd, e)
const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
// On Windows, `path.relative()` returns an absolute path when `abs` is on
// a different drive than `baseDir` — that wouldn't start with `..` but
// still escapes, so reject both forms.
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error(`Entry escapes baseDir: ${e}`)
return rel.replace(/^\.\//u, '')
})
}

// Deepest directory that is a parent of every file in `paths`, expressed
// relative to `cwd`. `paths` may be POSIX-relative-to-cwd; entries that
// escape cwd (e.g. `../deps/X.sol` via remapping) push the result above
// cwd, in which case the returned path starts with `..`. Returns "." when
// the outermost directory IS cwd itself.
export function outermostDir(paths, cwd) {
if (paths.length === 0) return '.'
const cwdAbs = posix.resolve(cwd.replaceAll(/\\/gu, '/'))
const absDirs = paths.map((p) => posix.dirname(posix.resolve(cwdAbs, p)))
const partsList = absDirs.map((d) => d.split('/'))
const common = []
for (let i = 0; i < partsList[0].length; i++) {
const c = partsList[0][i]
if (!partsList.every((parts) => parts[i] === c)) break
common.push(c)
}
const absCommon = common.join('/') || '/'
const rel = posix.relative(cwdAbs, absCommon)
return rel === '' ? '.' : rel
}

// Build a stasis Bundle (in-memory) from a list of entry .sol files and
// an optional mapping file. The mapping file (foundry.toml or
// remappings.txt) is parsed for remappings but NOT included in the
// bundle's sources. Returns the constructed `Bundle`.
export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappingFile } = {}) {
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error('buildSolidityBundle: at least one entry .sol file is required')
}
for (const e of entries) {
if (!e.endsWith('.sol')) throw new Error(`buildSolidityBundle: not a .sol file: ${e}`)
}

const baseDir = resolve(cwd)
const remappings = mappingFile ? await readRemappingsFile(resolve(cwd, mappingFile)) : []
const normalized = normalizeEntries(entries, cwd)

const sources = await collectSolidityFilesFromDisk(baseDir, normalized, remappings)
const { resolutions, missing } = buildSolidityTree(sources, { remappings })

// Bundles must be self-contained. Refuse to write one when an entry
// can't be loaded from disk, or when any in-bundle file has an import
// we couldn't resolve — otherwise downstream consumers would silently
// operate on a partial set of sources.
const issues = []
for (const entry of normalized) {
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
}
for (const { spec, from } of missing) {
issues.push(`Unresolved import: ${spec} from ${from}`)
}
if (issues.length > 0) {
throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
}

// Partition every loaded .sol file into a per-package bucket. The
// bucket is the directory holding the nearest package.json with a
// name+version. node_modules files land in `node_modules/<pkg>` buckets
// (or deeper, when a nested package.json claims the file); workspace
// files land in their nearest workspace package.json's dir, or in the
// fallback "." bucket with placeholder metadata when no package.json
// could be found.
const modules = new Map()
const ensureBucket = (dir, name, version) => {
if (!modules.has(dir)) modules.set(dir, { name, version, files: Object.create(null) })
return modules.get(dir)
}

for (const [path, content] of sources) {
const meta = findPackageMetadata(baseDir, path)
const inNodeModules = splitNodeModulesPath(path) !== null
if (meta) {
// A file inside node_modules whose nearest package.json is the
// workspace root would silently land in the workspace bucket and
// hide the misconfigured dependency — refuse instead.
if (inNodeModules && !meta.pkgDir.includes('node_modules')) {
throw new Error(`No package.json with name+version found for ${path}`)
}
const rel = meta.pkgDir === '.' ? path : path.slice(meta.pkgDir.length + 1)
ensureBucket(meta.pkgDir, meta.name, meta.version).files[rel] = content
} else {
// Files under node_modules without any discoverable package.json
// would otherwise leak into the workspace bucket; that's a project
// misconfiguration, surface it loudly.
if (inNodeModules) throw new Error(`No package.json with name+version found for ${path}`)
ensureBucket('.', SOLIDITY_WORKSPACE_NAME, SOLIDITY_WORKSPACE_VERSION).files[path] = content
}
}

const formats = new Map()
for (const path of sources.keys()) formats.set(path, SOLIDITY_FORMAT)

// Solidity imports don't depend on Node conditions; key them under a
// dedicated "solidity" bucket rather than the wildcard "*" used for
// JS code bundles.
const importsForKey = new Map()
for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
const imports = new Map([['solidity', importsForKey]])

return new Bundle({
config: { scope: 'full' },
entries: new Set(normalized),
modules,
formats,
imports,
})
}

// Run the bundle CLI command end-to-end. Always produces a brotli-compressed
// stasis bundle (matching the on-disk format of `stasis.code.br`). When
// `output` is provided, writes to that path; otherwise writes to stdout.
// Prints a one-line `[stasis] Bundled <n> files from <dir> to <dest>` summary
// to stderr so it doesn't interleave with the binary output on stdout.
export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile, output } = {}) {
const bundle = await buildSolidityBundle({ cwd, entries, mappingFile })
const data = brotliCompressSync(bundle.serializeCode())
if (output) {
const outAbs = resolve(cwd, output)
mkdirSync(dirname(outAbs), { recursive: true })
writeFileSync(outAbs, data)
} else {
process.stdout.write(data)
}
// Bundle.sources flattens every per-package bucket into project-relative
// paths, so the summary reflects every file we wrote — workspace and
// node_modules alike — not just the workspace bucket.
const files = [...bundle.sources.keys()]
const fromDir = outermostDir(files, resolve(cwd))
const dest = output ?? '<stdout>'
console.warn(`[stasis] Bundled ${files.length} files from ${fromDir} to ${dest}`)
return bundle
}
Loading