@exodus/stasis 1.0.0-alpha.3 → 1.0.0-beta.1
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 +67 -3
- package/bin/stasis.js +326 -19
- package/logo.svg +12 -3
- package/package.json +38 -18
- package/src/audit.js +1 -38
- package/src/bundle-from-lockfile.js +65 -0
- package/src/bundle.js +4 -217
- package/src/cmd/build.js +321 -0
- package/src/cmd/bundle.js +949 -58
- package/src/cmd/diff.js +36 -0
- package/src/cmd/extract.js +193 -0
- package/src/cmd/sbom.js +35 -0
- package/src/diff.js +333 -0
- package/src/esbuild.js +5 -56
- package/src/loader-mock.js +19 -0
- package/src/loader.js +6 -132
- package/src/loaders/bash.js +257 -0
- package/src/loaders/php.js +1058 -0
- package/src/loaders/rust.js +336 -0
- package/src/loaders/solidity.js +26 -1
- package/src/lockfile.js +4 -80
- package/src/metro-transformer.js +6 -0
- package/src/metro.js +5 -0
- package/src/mock.js +310 -0
- package/src/parse.js +53 -0
- package/src/prune.js +4 -162
- package/src/sbom.js +266 -0
- package/src/scan.js +373 -0
- package/src/webpack.js +5 -48
- package/src/config.js +0 -205
- package/src/state.js +0 -440
- package/src/state.util.js +0 -25
- package/src/util.js +0 -60
package/src/audit.js
CHANGED
|
@@ -1,43 +1,6 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
2
|
-
import { brotliDecompressSync } from 'node:zlib'
|
|
3
|
-
|
|
4
1
|
import { advisories } from './apis/npm/index.js'
|
|
5
2
|
import semver from './apis/npm/semver.cjs'
|
|
6
|
-
import {
|
|
7
|
-
import { Lockfile } from './lockfile.js'
|
|
8
|
-
|
|
9
|
-
function parseFile(file) {
|
|
10
|
-
let buf
|
|
11
|
-
try {
|
|
12
|
-
buf = readFileSync(file)
|
|
13
|
-
} catch (cause) {
|
|
14
|
-
if (cause.code === 'ENOENT') throw new Error(`File not found: ${file}`, { cause })
|
|
15
|
-
throw new Error(`Failed to read ${file}: ${cause.message}`, { cause })
|
|
16
|
-
}
|
|
17
|
-
// Lockfiles are JSON text starting with `{`; code/resource bundles are
|
|
18
|
-
// brotli-compressed JSON. Sniff to route to the right parser so its
|
|
19
|
-
// diagnostic surfaces on failure.
|
|
20
|
-
if (buf[0] === 0x7b /* '{' */) {
|
|
21
|
-
try {
|
|
22
|
-
return Lockfile.parse(buf.toString('utf8'))
|
|
23
|
-
} catch (cause) {
|
|
24
|
-
throw new Error(`Failed to parse stasis lockfile: ${file}`, { cause })
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
let text
|
|
28
|
-
try {
|
|
29
|
-
text = brotliDecompressSync(buf).toString('utf8')
|
|
30
|
-
} catch (cause) {
|
|
31
|
-
throw new Error(`Failed to read ${file} as a stasis bundle (not brotli)`, { cause })
|
|
32
|
-
}
|
|
33
|
-
// Code bundles carry `formats`/`imports`; resource bundles don't. Route on shape.
|
|
34
|
-
try {
|
|
35
|
-
const isCode = JSON.parse(text).formats !== undefined
|
|
36
|
-
return isCode ? Bundle.parseCode(text) : Bundle.parseResources(text)
|
|
37
|
-
} catch (cause) {
|
|
38
|
-
throw new Error(`Failed to parse stasis bundle: ${file}`, { cause })
|
|
39
|
-
}
|
|
40
|
-
}
|
|
3
|
+
import { parseFile } from './parse.js'
|
|
41
4
|
|
|
42
5
|
// Only audit installed dependencies; workspace/first-party packages live under
|
|
43
6
|
// non-`node_modules` keys in .modules (Lockfile/Bundle merge sources in) and
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { isUtf8 } from 'node:buffer'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
6
|
+
import { sha512integrity } from '@exodus/stasis-core/state-util'
|
|
7
|
+
import { moduleFileKey } from '@exodus/stasis-core/util'
|
|
8
|
+
|
|
9
|
+
// Reconstruct an in-memory Bundle from a lockfile by reading every file it attests
|
|
10
|
+
// from disk and verifying each against the lockfile's SRI digest -- the exact inverse
|
|
11
|
+
// of `extract`'s lockfileFromBundle. A changed or tampered file fails closed here (the
|
|
12
|
+
// digest won't match), so building from a lockfile is as trustworthy as a
|
|
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.
|
|
18
|
+
export function bundleFromLockfile(lockfile, { root }) {
|
|
19
|
+
if (!lockfile.imports) throw new Error('stasis: lockfile has no recorded import graph')
|
|
20
|
+
const formats = lockfile.formats ?? new Map()
|
|
21
|
+
const modules = new Map()
|
|
22
|
+
for (const [dir, { name, version, ecosystem, files }] of lockfile.modules) {
|
|
23
|
+
const out = Object.create(null)
|
|
24
|
+
for (const [rel, integrity] of Object.entries(files)) {
|
|
25
|
+
const file = moduleFileKey(dir, rel)
|
|
26
|
+
const format = formats.get(file)
|
|
27
|
+
if (format === 'directory') continue
|
|
28
|
+
const abs = resolve(root, file)
|
|
29
|
+
let bytes
|
|
30
|
+
try {
|
|
31
|
+
bytes = readFileSync(abs)
|
|
32
|
+
} catch (cause) {
|
|
33
|
+
throw new Error(`stasis: file attested by the lockfile is missing on disk: ${file}`, { cause })
|
|
34
|
+
}
|
|
35
|
+
// The lockfile digests raw on-disk bytes; verify against them before trusting
|
|
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.)
|
|
38
|
+
const actual = sha512integrity(bytes)
|
|
39
|
+
if (actual !== integrity) {
|
|
40
|
+
throw new Error(`stasis: ${file} does not match the lockfile (expected ${integrity}, got ${actual})`)
|
|
41
|
+
}
|
|
42
|
+
// Store content the way a Bundle does: 'resource:base64' as base64 text,
|
|
43
|
+
// everything else (code, 'resource') as a raw UTF-8 string. A non-base64 entry is
|
|
44
|
+
// digested AND served as UTF-8 text, so a non-UTF-8 byte would transcode lossily
|
|
45
|
+
// (U+FFFD), diverging the served content from the verified bytes -- mirror the
|
|
46
|
+
// capture side's isUtf8 guard and fail closed instead.
|
|
47
|
+
if (format === 'resource:base64') {
|
|
48
|
+
out[rel] = bytes.toString('base64')
|
|
49
|
+
} else {
|
|
50
|
+
if (!isUtf8(bytes)) throw new Error(`stasis: ${file} is recorded as '${format ?? 'code'}' but is not valid UTF-8`)
|
|
51
|
+
out[rel] = bytes.toString('utf8')
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
modules.set(dir, ecosystem === undefined
|
|
55
|
+
? { name, version, files: out }
|
|
56
|
+
: { name, version, ecosystem, files: out })
|
|
57
|
+
}
|
|
58
|
+
return new Bundle({
|
|
59
|
+
config: lockfile.config,
|
|
60
|
+
entries: lockfile.entries,
|
|
61
|
+
modules,
|
|
62
|
+
imports: lockfile.imports,
|
|
63
|
+
formats,
|
|
64
|
+
})
|
|
65
|
+
}
|
package/src/bundle.js
CHANGED
|
@@ -1,217 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
fromEntries,
|
|
6
|
-
objectToMaps,
|
|
7
|
-
sortPaths,
|
|
8
|
-
splitNodeModulesPath,
|
|
9
|
-
} from './util.js'
|
|
10
|
-
|
|
11
|
-
const VERSION = 1
|
|
12
|
-
const LEGACY_VERSION = 0
|
|
13
|
-
|
|
14
|
-
const normalize = ({ name, version, files }) =>
|
|
15
|
-
({ name, version, files: fromEntries(Object.entries(files)) })
|
|
16
|
-
|
|
17
|
-
const inferModuleDir = (path) =>
|
|
18
|
-
splitNodeModulesPath(path) ?? { dir: '.', rel: path, name: null }
|
|
19
|
-
|
|
20
|
-
// Bundle handles the JSON shape of stasis.code.br / stasis.resources.br;
|
|
21
|
-
// callers (State) are responsible for the brotli wrapper around the JSON
|
|
22
|
-
// text. On-disk version:
|
|
23
|
-
// v0 — legacy flat layout: top-level `sources` (or `resources`) keyed by
|
|
24
|
-
// project-relative path. No entries/modules metadata.
|
|
25
|
-
// v1 — current layout, mirrors stasis.lock.json: per-package buckets under
|
|
26
|
-
// `sources` (workspace) and `modules` (node_modules) with name+version+files,
|
|
27
|
-
// plus `entries`. File payloads carry bytes (UTF-8 source strings or base64
|
|
28
|
-
// resource blobs) instead of SRI digests.
|
|
29
|
-
// We load both v0 and v1; we always serialize v1. v0 is converted on load to
|
|
30
|
-
// the same in-memory shape — paths are regrouped by package dir via
|
|
31
|
-
// `inferModuleDir` (best-effort, last-wins). name/version stay null since v0
|
|
32
|
-
// records neither, which State uses as a marker to skip metadata cross-checks.
|
|
33
|
-
export class Bundle {
|
|
34
|
-
static VERSION = VERSION
|
|
35
|
-
|
|
36
|
-
// The on-disk version this instance represents. Defaults to VERSION; parse*
|
|
37
|
-
// sets it to whatever the file declared (LEGACY_VERSION for v0 input).
|
|
38
|
-
// Serialization always writes the current VERSION regardless — loading v0 and
|
|
39
|
-
// re-saving promotes the bundle to v1.
|
|
40
|
-
version = VERSION
|
|
41
|
-
config
|
|
42
|
-
entries
|
|
43
|
-
modules
|
|
44
|
-
formats
|
|
45
|
-
imports
|
|
46
|
-
|
|
47
|
-
constructor({ config = { scope: 'full' }, entries, modules, formats, imports, version = VERSION } = {}) {
|
|
48
|
-
assert([LEGACY_VERSION, VERSION].includes(version))
|
|
49
|
-
assert(['node_modules', 'full'].includes(config.scope))
|
|
50
|
-
this.version = version
|
|
51
|
-
this.config = config
|
|
52
|
-
this.entries = entries ?? new Set()
|
|
53
|
-
this.modules = modules ?? new Map()
|
|
54
|
-
this.formats = formats ?? new Map()
|
|
55
|
-
this.imports = imports ?? new Map()
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Flat project-relative view of file contents collected from this.modules.
|
|
59
|
-
// Values are UTF-8 source strings for code bundles, base64 for resource bundles.
|
|
60
|
-
get sources() {
|
|
61
|
-
const m = new Map()
|
|
62
|
-
for (const [dir, { files }] of this.modules) {
|
|
63
|
-
for (const [rel, content] of Object.entries(files)) {
|
|
64
|
-
m.set(dir === '.' ? rel : `${dir}/${rel}`, content)
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return m
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
static parseCode(text) {
|
|
71
|
-
const json = JSON.parse(text)
|
|
72
|
-
assert(json.version === VERSION || json.version === LEGACY_VERSION)
|
|
73
|
-
assert(['node_modules', 'full'].includes(json.config?.scope))
|
|
74
|
-
assert(json.formats)
|
|
75
|
-
assert(json.imports)
|
|
76
|
-
|
|
77
|
-
const modules = new Map()
|
|
78
|
-
let entries = new Set()
|
|
79
|
-
|
|
80
|
-
if (json.version === VERSION) {
|
|
81
|
-
const full = json.config.scope === 'full'
|
|
82
|
-
if (json.modules !== undefined) {
|
|
83
|
-
assert(typeof json.modules === 'object' && json.modules !== null)
|
|
84
|
-
for (const [dir, info] of Object.entries(json.modules)) {
|
|
85
|
-
assert(dir.includes('node_modules'))
|
|
86
|
-
assert(!dir.startsWith('..'))
|
|
87
|
-
assert(info?.name && info.version && info.files)
|
|
88
|
-
modules.set(dir, normalize(info))
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
if (full) {
|
|
92
|
-
assert(Array.isArray(json.entries))
|
|
93
|
-
// Empty entries in v1 + full would let `state.assertEntry` skip its check
|
|
94
|
-
// (it short-circuits on entries.size === 0 for v0+no-lockfile compatibility).
|
|
95
|
-
// Forbid the shape at parse time so tampered bundles can't exploit that path.
|
|
96
|
-
assert(json.entries.length > 0, 'v1 bundle with scope=full must have at least one entry')
|
|
97
|
-
assert(json.sources && typeof json.sources === 'object')
|
|
98
|
-
entries = new Set(json.entries)
|
|
99
|
-
for (const [dir, info] of Object.entries(json.sources)) {
|
|
100
|
-
assert(!dir.includes('node_modules'))
|
|
101
|
-
assert(!dir.startsWith('..'))
|
|
102
|
-
assert(info?.name && info.version && info.files)
|
|
103
|
-
modules.set(dir, normalize(info))
|
|
104
|
-
}
|
|
105
|
-
} else {
|
|
106
|
-
assert(json.entries === undefined)
|
|
107
|
-
assert(json.sources === undefined)
|
|
108
|
-
}
|
|
109
|
-
for (const [, { files }] of modules) {
|
|
110
|
-
for (const rel of Object.keys(files)) assert(!rel.startsWith('..'))
|
|
111
|
-
}
|
|
112
|
-
} else {
|
|
113
|
-
// v0 legacy: flat sources at top level, regrouped by inferred module dir.
|
|
114
|
-
assert(json.sources)
|
|
115
|
-
for (const [path, content] of Object.entries(json.sources)) {
|
|
116
|
-
assert(!path.startsWith('..'))
|
|
117
|
-
const { dir, rel, name } = inferModuleDir(path)
|
|
118
|
-
assert(!dir.startsWith('..') && !rel.startsWith('..'))
|
|
119
|
-
if (!modules.has(dir)) modules.set(dir, { name, version: null, files: Object.create(null) })
|
|
120
|
-
modules.get(dir).files[rel] = content
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return new Bundle({
|
|
125
|
-
version: json.version,
|
|
126
|
-
config: json.config,
|
|
127
|
-
entries,
|
|
128
|
-
modules,
|
|
129
|
-
formats: new Map(Object.entries(json.formats)),
|
|
130
|
-
imports: objectToMaps(json.imports),
|
|
131
|
-
})
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
static parseResources(text) {
|
|
135
|
-
const json = JSON.parse(text)
|
|
136
|
-
assert(json.version === VERSION || json.version === LEGACY_VERSION)
|
|
137
|
-
|
|
138
|
-
const modules = new Map()
|
|
139
|
-
let config = { scope: 'full' }
|
|
140
|
-
|
|
141
|
-
if (json.version === VERSION) {
|
|
142
|
-
assert(['node_modules', 'full'].includes(json.config?.scope))
|
|
143
|
-
config = json.config
|
|
144
|
-
const full = config.scope === 'full'
|
|
145
|
-
if (json.modules !== undefined) {
|
|
146
|
-
assert(typeof json.modules === 'object' && json.modules !== null)
|
|
147
|
-
for (const [dir, info] of Object.entries(json.modules)) {
|
|
148
|
-
assert(dir.includes('node_modules'))
|
|
149
|
-
assert(!dir.startsWith('..'))
|
|
150
|
-
assert(info?.name && info.version && info.files)
|
|
151
|
-
modules.set(dir, normalize(info))
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (full && json.sources !== undefined) {
|
|
155
|
-
assert(typeof json.sources === 'object' && json.sources !== null)
|
|
156
|
-
for (const [dir, info] of Object.entries(json.sources)) {
|
|
157
|
-
assert(!dir.includes('node_modules'))
|
|
158
|
-
assert(!dir.startsWith('..'))
|
|
159
|
-
assert(info?.name && info.version && info.files)
|
|
160
|
-
modules.set(dir, normalize(info))
|
|
161
|
-
}
|
|
162
|
-
} else if (!full) {
|
|
163
|
-
assert(json.sources === undefined)
|
|
164
|
-
}
|
|
165
|
-
for (const [, { files }] of modules) {
|
|
166
|
-
for (const rel of Object.keys(files)) assert(!rel.startsWith('..'))
|
|
167
|
-
}
|
|
168
|
-
} else {
|
|
169
|
-
// v0 legacy: flat resources at top level, regrouped by inferred module dir.
|
|
170
|
-
assert(json.resources)
|
|
171
|
-
for (const [path, content] of Object.entries(json.resources)) {
|
|
172
|
-
assert(!path.startsWith('..'))
|
|
173
|
-
const { dir, rel, name } = inferModuleDir(path)
|
|
174
|
-
assert(!dir.startsWith('..') && !rel.startsWith('..'))
|
|
175
|
-
if (!modules.has(dir)) modules.set(dir, { name, version: null, files: Object.create(null) })
|
|
176
|
-
modules.get(dir).files[rel] = content
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return new Bundle({ version: json.version, config, modules })
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
#groupedFromModules() {
|
|
184
|
-
const moduleEntries = []
|
|
185
|
-
const sourceEntries = []
|
|
186
|
-
for (const [dir, { name, version, files }] of this.modules) {
|
|
187
|
-
if (Object.keys(files).length === 0) continue
|
|
188
|
-
const inNodeModules = dir.includes('node_modules')
|
|
189
|
-
if (inNodeModules) assert(name && version && files)
|
|
190
|
-
const sorted = fromEntries(Object.entries(files).toSorted((a, b) => sortPaths(a[0], b[0])))
|
|
191
|
-
const target = inNodeModules ? moduleEntries : sourceEntries
|
|
192
|
-
target.push([dir, { name, version, files: sorted }])
|
|
193
|
-
}
|
|
194
|
-
moduleEntries.sort((a, b) => sortPaths(a[0], b[0]))
|
|
195
|
-
sourceEntries.sort((a, b) => sortPaths(a[0], b[0]))
|
|
196
|
-
return { modules: fromEntries(moduleEntries), sources: fromEntries(sourceEntries) }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
serializeCode() {
|
|
200
|
-
const entries = fileSetToObject(this.entries)
|
|
201
|
-
const { modules, sources } = this.#groupedFromModules()
|
|
202
|
-
const formats = fileMapToObject(this.formats)
|
|
203
|
-
const imports = fileMapToObject(this.imports)
|
|
204
|
-
const data = { version: VERSION, config: this.config }
|
|
205
|
-
if (this.config.scope === 'full') Object.assign(data, { entries, sources })
|
|
206
|
-
Object.assign(data, { modules, formats, imports })
|
|
207
|
-
return JSON.stringify(data, undefined, 2)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
serializeResources() {
|
|
211
|
-
const { modules, sources } = this.#groupedFromModules()
|
|
212
|
-
const data = { version: VERSION, config: this.config }
|
|
213
|
-
if (this.config.scope === 'full') Object.assign(data, { sources })
|
|
214
|
-
Object.assign(data, { modules })
|
|
215
|
-
return JSON.stringify(data, undefined, 2)
|
|
216
|
-
}
|
|
217
|
-
}
|
|
1
|
+
// Public-export adapter: backs `@exodus/stasis/bundle`. The implementation lives in
|
|
2
|
+
// @exodus/stasis-core; internal tooling and tests import `@exodus/stasis-core/bundle`
|
|
3
|
+
// directly, so this file exists only to back the package's `exports` map.
|
|
4
|
+
export * from '@exodus/stasis-core/bundle'
|
package/src/cmd/build.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
|
|
7
|
+
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
8
|
+
import { State } from '@exodus/stasis-core/state'
|
|
9
|
+
import { StasisEsbuild } from '@exodus/stasis-plugins/esbuild'
|
|
10
|
+
import { bundleFromLockfile } from '../bundle-from-lockfile.js'
|
|
11
|
+
import { parseFileWithKind } from '../parse.js'
|
|
12
|
+
|
|
13
|
+
// Re-exported so `@exodus/stasis/cmd/build` keeps surfacing the lockfile->bundle
|
|
14
|
+
// reconstruction alongside the command; the implementation lives in its own module.
|
|
15
|
+
export { bundleFromLockfile }
|
|
16
|
+
|
|
17
|
+
// JS/TS entry extensions esbuild can build. esbuild handles JSX/TSX natively (loader
|
|
18
|
+
// 'jsx'/'tsx', derived from the extension by the load plugin), and stasis-core already
|
|
19
|
+
// classifies .jsx/.tsx as code (CODE_EXTENSIONS), so a bundle/lockfile captured by the
|
|
20
|
+
// esbuild/webpack plugins can carry them as entries.
|
|
21
|
+
const JS_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.jsx', '.tsx'])
|
|
22
|
+
|
|
23
|
+
// Allowlist (NOT a blocklist) of per-file formats `stasis build` accepts in an artifact:
|
|
24
|
+
// the JS/TS/JSON code formats esbuild builds, plus the non-code payload formats
|
|
25
|
+
// (resource/directory) that are inert unless the SELECTED entry imports them. An allowlist
|
|
26
|
+
// so a new language format added to `stasis bundle` later (e.g. a future `go`) is rejected
|
|
27
|
+
// here by default, rather than slipping through to a cryptic esbuild failure in the build.
|
|
28
|
+
const BUILDABLE_FORMATS = new Set([
|
|
29
|
+
'module', 'commonjs', 'json', 'module-typescript', 'commonjs-typescript',
|
|
30
|
+
'resource', 'resource:base64', 'directory',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
// esbuild is an OPTIONAL peer dependency: `stasis build` is the only command that
|
|
34
|
+
// needs a bundler, so we don't force every stasis install to pull one in. Resolve it
|
|
35
|
+
// from the local install first (a peer dep hoisted into the project's node_modules, or
|
|
36
|
+
// an npx-provided copy), then fall back to a global install, with an actionable error
|
|
37
|
+
// if neither is present.
|
|
38
|
+
async function importEsbuild() {
|
|
39
|
+
let mod
|
|
40
|
+
try {
|
|
41
|
+
mod = await import('esbuild')
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
// Only a genuine "not installed" miss falls through to the global lookup; a
|
|
44
|
+
// different failure (a corrupt install, a syntax error in esbuild) is surfaced.
|
|
45
|
+
if (cause?.code !== 'ERR_MODULE_NOT_FOUND' && cause?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
|
|
46
|
+
throw cause
|
|
47
|
+
}
|
|
48
|
+
const globalMain = resolveGlobalEsbuild()
|
|
49
|
+
if (!globalMain) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
'stasis build requires esbuild, which could not be found.\n' +
|
|
52
|
+
'Install it as a dev dependency (npm i -D esbuild / pnpm add -D esbuild / yarn add -D esbuild) ' +
|
|
53
|
+
'or globally (npm i -g esbuild).',
|
|
54
|
+
{ cause }
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
mod = await import(pathToFileURL(globalMain).href)
|
|
58
|
+
}
|
|
59
|
+
// esbuild ships a CJS module exposing named exports (build, context, ...); Node's
|
|
60
|
+
// interop surfaces them directly. A globally-imported CJS copy may instead land its
|
|
61
|
+
// exports on `default` -- accept both shapes.
|
|
62
|
+
return typeof mod.build === 'function' ? mod : mod.default
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Locate a globally-installed esbuild via the package managers' "global root" commands.
|
|
66
|
+
// Returns the absolute path to esbuild's entry, or undefined when none is installed.
|
|
67
|
+
function resolveGlobalEsbuild() {
|
|
68
|
+
const probes = [
|
|
69
|
+
['npm', ['root', '-g']],
|
|
70
|
+
['pnpm', ['root', '-g']],
|
|
71
|
+
['yarn', ['global', 'dir']],
|
|
72
|
+
]
|
|
73
|
+
for (const [cmd, args] of probes) {
|
|
74
|
+
let out
|
|
75
|
+
try {
|
|
76
|
+
out = spawnSync(cmd, args, { encoding: 'utf8' })
|
|
77
|
+
} catch {
|
|
78
|
+
continue // package manager not installed
|
|
79
|
+
}
|
|
80
|
+
if (!out || out.status !== 0 || !out.stdout) continue
|
|
81
|
+
const base = out.stdout.trim()
|
|
82
|
+
if (!base) continue
|
|
83
|
+
// `npm/pnpm root -g` print the global node_modules dir; `yarn global dir` prints a
|
|
84
|
+
// dir whose node_modules holds the packages. Try both shapes.
|
|
85
|
+
for (const nm of [base, join(base, 'node_modules')]) {
|
|
86
|
+
if (!existsSync(join(nm, 'esbuild', 'package.json'))) continue
|
|
87
|
+
try {
|
|
88
|
+
return createRequire(join(nm, 'noop.js')).resolve('esbuild')
|
|
89
|
+
} catch {
|
|
90
|
+
// keep probing
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Validate that an artifact is something esbuild can build: full scope (node_modules
|
|
98
|
+
// scope omits the entry and workspace code, so it can't be built on its own), at least
|
|
99
|
+
// one entry, all entries JS/TS, and only buildable per-file formats (see BUILDABLE_FORMATS).
|
|
100
|
+
function assertBuildable(artifact, label) {
|
|
101
|
+
if (artifact.config.scope !== 'full') {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`stasis build requires a full-scope artifact; ${label} is scope='${artifact.config.scope}'. ` +
|
|
104
|
+
`node_modules-scope artifacts omit the entry and workspace code, so there is nothing to build.`
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
const entries = [...artifact.entries]
|
|
108
|
+
if (entries.length === 0) {
|
|
109
|
+
throw new Error(`stasis build: ${label} declares no entries to build`)
|
|
110
|
+
}
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (!JS_EXTS.has(extname(entry))) {
|
|
113
|
+
throw new Error(`stasis build only builds JavaScript/TypeScript entries (esbuild); got non-JS entry '${entry}' in ${label}`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// artifact.formats is a Map (Bundle) or may be null on a legacy lockfile (an absent map
|
|
117
|
+
// is fine -- nothing to check). Reject any format outside the allowlist: the non-JS
|
|
118
|
+
// LANGUAGE bundlers' formats (Solidity/PHP/Bash/Rust) and anything added later.
|
|
119
|
+
//
|
|
120
|
+
// NOTE: resource formats are IN the allowlist -- deliberately NOT rejected here. A resource
|
|
121
|
+
// is recorded as a FILE but never as an import EDGE, so whether a build hits one depends on
|
|
122
|
+
// the SELECTED entry's own source, not the artifact as a whole -- a code-only entry must
|
|
123
|
+
// build even when the artifact carries assets for OTHER entries. An entry that does import
|
|
124
|
+
// an asset fails at build time (esbuild can't resolve the un-graphed resource).
|
|
125
|
+
for (const [file, format] of artifact.formats ?? new Map()) {
|
|
126
|
+
if (!BUILDABLE_FORMATS.has(format)) {
|
|
127
|
+
throw new Error(`stasis build only supports JavaScript/TypeScript bundles (esbuild); ${label} carries a '${format}' file (${file})`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Pick which recorded entry to build. The build produces exactly one entry's bundle;
|
|
133
|
+
// `entryArg` selects it. With a single recorded entry the selector is optional (that
|
|
134
|
+
// entry is used); with several it is required, and an absent or unknown one is rejected
|
|
135
|
+
// with the available list. The selector is matched against the artifact's recorded
|
|
136
|
+
// (project-relative) entries -- only those are attested as graph roots, and the load
|
|
137
|
+
// plugin's assertEntry rejects anything else -- accepting the recorded path verbatim, a
|
|
138
|
+
// leading `./` stripped, or a cwd-relative path normalized into the project root.
|
|
139
|
+
function selectEntry(entryArg, entries, { cwd, root, label }) {
|
|
140
|
+
const recorded = [...entries]
|
|
141
|
+
const list = recorded.map((e) => ` ${e}`).join('\n')
|
|
142
|
+
if (entryArg === undefined) {
|
|
143
|
+
if (recorded.length === 1) return recorded[0]
|
|
144
|
+
// length === 0 is impossible here (assertBuildable required >= 1 entry).
|
|
145
|
+
throw new Error(`stasis build: ${label} has ${recorded.length} entry points; pass one as the second argument:\n${list}`)
|
|
146
|
+
}
|
|
147
|
+
const candidates = new Set([
|
|
148
|
+
entryArg,
|
|
149
|
+
entryArg.replace(/^\.\//u, ''),
|
|
150
|
+
relative(root, resolve(cwd, entryArg)).split(sep).join('/'),
|
|
151
|
+
])
|
|
152
|
+
const match = recorded.find((e) => candidates.has(e))
|
|
153
|
+
if (match === undefined) {
|
|
154
|
+
throw new Error(`stasis build: '${entryArg}' is not an entry point of ${label}; available:\n${list}`)
|
|
155
|
+
}
|
|
156
|
+
return match
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Build a bundle-load State that serves an in-memory Bundle -- the same shape StasisEsbuild's
|
|
160
|
+
// load path reads from (state.sources / state.resources / state.formats / state.imports /
|
|
161
|
+
// state.entries). skipDiscovery anchors the State at `root` and does NO filesystem discovery:
|
|
162
|
+
// no package.json walk, no on-disk config, no stray stasis.code.br read. bundle=load +
|
|
163
|
+
// lock=ignore serve the bundle's own bytes (self-attesting -- the lockfile path already
|
|
164
|
+
// SHA-512-verified them in bundleFromLockfile). We mirror State's on-disk load (split
|
|
165
|
+
// sources/resources by format) and fill the maps from `bundle`.
|
|
166
|
+
function loadStateForBundle(root, bundle) {
|
|
167
|
+
const state = new State(root, { bundle: 'load', lock: 'ignore', scope: bundle.config.scope, skipDiscovery: true })
|
|
168
|
+
const sources = new Map()
|
|
169
|
+
const resources = new Map()
|
|
170
|
+
for (const [file, content] of bundle.sources) {
|
|
171
|
+
if (Bundle.isResourceFormat(bundle.formats.get(file))) resources.set(file, content)
|
|
172
|
+
else sources.set(file, content)
|
|
173
|
+
}
|
|
174
|
+
state.sources = sources
|
|
175
|
+
state.resources = resources
|
|
176
|
+
state.formats = bundle.formats
|
|
177
|
+
state.imports = bundle.imports
|
|
178
|
+
state.entries = new Set(bundle.entries)
|
|
179
|
+
// Not state.modules: the load path (getImport/getFile/getFormat/assertEntry) never reads
|
|
180
|
+
// it -- it's only written by the capture paths. The State's empty-Map default stands.
|
|
181
|
+
return state
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Run the `stasis build` CLI command end-to-end: take a stasis artifact (a `stasis.code.br`
|
|
185
|
+
// bundle, or a `stasis.lock.json` lockfile whose attested files are read + verified from
|
|
186
|
+
// disk) and an entry point, and run esbuild over that one entry, following the artifact's
|
|
187
|
+
// recorded import graph EXACTLY rather than esbuild's own resolver.
|
|
188
|
+
//
|
|
189
|
+
// `entry` selects which recorded entry to build (see selectEntry): optional when the
|
|
190
|
+
// artifact records one, required when it records several. `output` is required: a path
|
|
191
|
+
// ending in `.js`/`.cjs`/`.mjs` writes a single file, anything else a directory.
|
|
192
|
+
//
|
|
193
|
+
// The build runs entirely in memory off the artifact -- no temp files, and no package.json
|
|
194
|
+
// required. The artifact's relative paths anchor at the artifact's OWN directory: a bundle is
|
|
195
|
+
// self-contained (root is just a keying prefix; nothing is read from disk), and a lockfile
|
|
196
|
+
// records its paths relative to where it sits (its project root), so its sources are read +
|
|
197
|
+
// verified from there. A project stasis.config.json is not consulted -- the build is driven
|
|
198
|
+
// by the artifact, not by `stasis run` config.
|
|
199
|
+
//
|
|
200
|
+
// The recorded graph is forced via the stasis esbuild plugin in bundle-load mode: each import
|
|
201
|
+
// resolves to the edge stasis recorded (a specifier the artifact doesn't record is handed back
|
|
202
|
+
// to esbuild, which externalizes it like a built-in/`external`), and each module's bytes come
|
|
203
|
+
// from the artifact -- so the output reflects what stasis attested, not what's on disk.
|
|
204
|
+
//
|
|
205
|
+
// `format` (esm/cjs/iife, default esm), `platform` (node/browser/neutral, default node),
|
|
206
|
+
// `minify`, `sourcemap`, `define` (an esbuild define map), `external` (an array of esbuild
|
|
207
|
+
// external patterns), and `loader` (an esbuild extension->loader map, e.g. { '.js': 'jsx' }
|
|
208
|
+
// to parse JSX in .js files) are forwarded to esbuild; the import graph is fixed by the artifact.
|
|
209
|
+
//
|
|
210
|
+
// `external` is esbuild's native option: it only steers specifiers esbuild itself would
|
|
211
|
+
// resolve -- i.e. the ones the bundle does NOT record (handed back to esbuild as a miss,
|
|
212
|
+
// typically the externals that existed at capture time). It re-declares those so esbuild
|
|
213
|
+
// leaves them as runtime imports instead of trying (and failing) to resolve them on disk.
|
|
214
|
+
// A specifier the artifact DOES record is still followed from the artifact -- the recorded
|
|
215
|
+
// graph wins, so `external` never silently un-inlines an attested edge.
|
|
216
|
+
export async function buildCommand({
|
|
217
|
+
cwd = process.cwd(),
|
|
218
|
+
artifact,
|
|
219
|
+
entry,
|
|
220
|
+
output,
|
|
221
|
+
format = 'esm',
|
|
222
|
+
platform = 'node',
|
|
223
|
+
minify = false,
|
|
224
|
+
sourcemap = false,
|
|
225
|
+
define = {},
|
|
226
|
+
external = [],
|
|
227
|
+
loader = {},
|
|
228
|
+
} = {}) {
|
|
229
|
+
if (!artifact) throw new Error('stasis build: an artifact (bundle or lockfile) is required')
|
|
230
|
+
if (!output) throw new Error('stasis build: --output is required (a .js/.cjs/.mjs file, or a directory)')
|
|
231
|
+
if (!Array.isArray(external)) throw new Error('stasis build: external must be an array of esbuild patterns')
|
|
232
|
+
const artifactAbs = resolve(cwd, artifact)
|
|
233
|
+
const { kind, artifact: parsed } = parseFileWithKind(artifactAbs)
|
|
234
|
+
assertBuildable(parsed, artifact)
|
|
235
|
+
|
|
236
|
+
// Anchor the artifact's relative paths at the artifact's OWN directory. A bundle is
|
|
237
|
+
// self-contained (root is just a keying prefix; nothing is read from disk), and a lockfile
|
|
238
|
+
// records its paths relative to where it sits -- its project root -- so its sources are
|
|
239
|
+
// read + verified from there. No package.json discovery, no project stasis.config.json.
|
|
240
|
+
const root = dirname(artifactAbs)
|
|
241
|
+
const selectedEntry = selectEntry(entry, parsed.entries, { cwd, root, label: artifact })
|
|
242
|
+
|
|
243
|
+
// The Bundle the load State serves, entirely in memory. A bundle artifact is already a
|
|
244
|
+
// parsed Bundle; a lockfile is reconstructed into one by reading + SHA-512-verifying its
|
|
245
|
+
// attested files from disk (bundleFromLockfile). The plugin drives off the in-memory State.
|
|
246
|
+
const bundle = kind === 'lockfile' ? bundleFromLockfile(parsed, { root }) : parsed
|
|
247
|
+
const state = loadStateForBundle(root, bundle)
|
|
248
|
+
// The selected entry is project-relative in the artifact; make it absolute against the
|
|
249
|
+
// State's root so esbuild's entry resolution is independent of the process cwd.
|
|
250
|
+
const entryPoint = resolve(state.root, selectedEntry)
|
|
251
|
+
|
|
252
|
+
const esbuild = await importEsbuild()
|
|
253
|
+
const outAbs = resolve(cwd, output)
|
|
254
|
+
// A `.js`/`.cjs`/`.mjs` --output names a single output file; anything else is a directory.
|
|
255
|
+
const asFile = /\.[cm]?js$/u.test(output)
|
|
256
|
+
// Absolute paths the artifact attests -- esbuild must NEVER overwrite one. A stray
|
|
257
|
+
// `--output=src/x.js` (or `--output=src`/`.` whose computed output basename collides with a
|
|
258
|
+
// recorded source) would otherwise destroy an attested file: esbuild's own "refusing to
|
|
259
|
+
// overwrite input file" check fires only AFTER it has written, and for a lockfile that
|
|
260
|
+
// permanently breaks the file's hash. So we build with write:false and write the outputs
|
|
261
|
+
// ourselves, refusing -- before touching disk -- to clobber any recorded file.
|
|
262
|
+
const attested = new Set([...state.sources.keys(), ...state.resources.keys()].map((f) => resolve(state.root, f)))
|
|
263
|
+
|
|
264
|
+
let result
|
|
265
|
+
try {
|
|
266
|
+
result = await esbuild.build({
|
|
267
|
+
entryPoints: [entryPoint],
|
|
268
|
+
absWorkingDir: state.root,
|
|
269
|
+
bundle: true,
|
|
270
|
+
write: false,
|
|
271
|
+
platform,
|
|
272
|
+
format,
|
|
273
|
+
logLevel: 'silent',
|
|
274
|
+
...(minify ? { minify: true } : {}),
|
|
275
|
+
...(sourcemap ? { sourcemap: true } : {}),
|
|
276
|
+
...(external.length > 0 ? { external } : {}),
|
|
277
|
+
...(Object.keys(define).length > 0 ? { define } : {}),
|
|
278
|
+
...(asFile ? { outfile: outAbs } : { outdir: outAbs }),
|
|
279
|
+
// Per-extension loader overrides drive the stasis plugin's load-mode loader choice
|
|
280
|
+
// (the plugin serves and labels every in-bundle file, so esbuild's own `loader`
|
|
281
|
+
// option never sees them). e.g. { '.js': 'jsx' } to parse JSX in .js files.
|
|
282
|
+
plugins: [new StasisEsbuild(state, { loaders: new Map(Object.entries(loader)) })],
|
|
283
|
+
})
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// esbuild rejects with a BuildFailure carrying a formatted `.errors` array under
|
|
286
|
+
// logLevel:'silent'. Surface those (rather than the bare wrapper) so the user sees the
|
|
287
|
+
// actual resolve/load failure -- including an entry that imports an un-buildable asset
|
|
288
|
+
// -- then fail the command.
|
|
289
|
+
if (Array.isArray(err?.errors) && err.errors.length > 0) {
|
|
290
|
+
const formatted = await esbuild.formatMessages(err.errors, { kind: 'error', color: false })
|
|
291
|
+
for (const line of formatted) process.stderr.write(line)
|
|
292
|
+
throw new Error(`stasis build failed: esbuild reported ${err.errors.length} error(s)`, { cause: err })
|
|
293
|
+
}
|
|
294
|
+
throw err
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The build succeeded. Refuse to clobber an attested source -- checked against esbuild's
|
|
298
|
+
// ACTUAL output paths (so it covers both `--output` file and directory), BEFORE any write,
|
|
299
|
+
// so a rejected build leaves every recorded file intact.
|
|
300
|
+
for (const file of result.outputFiles) {
|
|
301
|
+
if (attested.has(file.path)) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`stasis build: refusing to write '${relative(cwd, file.path)}' -- it is a source the artifact records; ` +
|
|
304
|
+
`choose an --output path outside the artifact's own files`
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (result.warnings.length > 0) {
|
|
310
|
+
const formatted = await esbuild.formatMessages(result.warnings, { kind: 'warning', color: false })
|
|
311
|
+
for (const line of formatted) process.stderr.write(line)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const file of result.outputFiles) {
|
|
315
|
+
mkdirSync(dirname(file.path), { recursive: true })
|
|
316
|
+
writeFileSync(file.path, file.contents)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
console.warn(`[stasis] Built ${selectedEntry} from ${kind} ${artifact} to ${output}`)
|
|
320
|
+
return { entry: selectedEntry, dir: asFile ? undefined : outAbs, outfile: asFile ? outAbs : undefined }
|
|
321
|
+
}
|