@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/stasis",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.3",
|
|
4
4
|
"description": "File-level source bundles and lockfiles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -25,12 +25,13 @@
|
|
|
25
25
|
"src/apis/npm/index.js",
|
|
26
26
|
"src/apis/npm/semver.cjs",
|
|
27
27
|
"src/audit.js",
|
|
28
|
+
"src/babel.js",
|
|
29
|
+
"src/babel-worker.cjs",
|
|
28
30
|
"src/bundle.js",
|
|
29
31
|
"src/bundle-from-lockfile.js",
|
|
30
32
|
"src/cmd/bundle.js",
|
|
31
33
|
"src/cmd/build.js",
|
|
32
34
|
"src/cmd/diff.js",
|
|
33
|
-
"src/cmd/extract.js",
|
|
34
35
|
"src/cmd/sbom.js",
|
|
35
36
|
"src/diff.js",
|
|
36
37
|
"src/esbuild.js",
|
|
@@ -42,6 +43,7 @@
|
|
|
42
43
|
"src/loaders/php.js",
|
|
43
44
|
"src/lockfile.js",
|
|
44
45
|
"src/metro.js",
|
|
46
|
+
"src/metro-resolver.js",
|
|
45
47
|
"src/metro-transformer.js",
|
|
46
48
|
"src/mock.js",
|
|
47
49
|
"src/parse.js",
|
|
@@ -68,16 +70,20 @@
|
|
|
68
70
|
},
|
|
69
71
|
"homepage": "https://github.com/ExodusOSS/stasis#readme",
|
|
70
72
|
"dependencies": {
|
|
71
|
-
"@exodus/stasis-core": "1.0.0-beta.
|
|
72
|
-
"@exodus/stasis-plugins": "1.0.0-beta.
|
|
73
|
+
"@exodus/stasis-core": "1.0.0-beta.3",
|
|
74
|
+
"@exodus/stasis-plugins": "1.0.0-beta.3",
|
|
73
75
|
"oxc-parser": "^0.94.0"
|
|
74
76
|
},
|
|
75
77
|
"peerDependencies": {
|
|
76
|
-
"esbuild": "*"
|
|
78
|
+
"esbuild": "*",
|
|
79
|
+
"flow-remove-types": "*"
|
|
77
80
|
},
|
|
78
81
|
"peerDependenciesMeta": {
|
|
79
82
|
"esbuild": {
|
|
80
83
|
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"flow-remove-types": {
|
|
86
|
+
"optional": true
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
}
|
package/src/apis/npm/semver.cjs
CHANGED
|
@@ -10,11 +10,9 @@ const argv0 = process.argv[0] // not process.argv0 which lacks path
|
|
|
10
10
|
function getSemver() {
|
|
11
11
|
if (semver) return semver
|
|
12
12
|
|
|
13
|
-
// Coherence check that we got Node.js path there
|
|
14
13
|
assert(['node', 'node.exe'].includes(basename(argv0)))
|
|
15
14
|
assert.equal(basename(argv0), basename(process.argv0))
|
|
16
15
|
|
|
17
|
-
// Load semver from npm bundled with Node.js
|
|
18
16
|
const semverDir = resolve(dirname(argv0), '../lib/node_modules/npm/node_modules/semver')
|
|
19
17
|
semver = require(semverDir)
|
|
20
18
|
return semver
|
package/src/audit.js
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
|
+
import { moduleFileKey } from '@exodus/stasis-core/util'
|
|
1
2
|
import { advisories } from './apis/npm/index.js'
|
|
3
|
+
import { isEvidenceFile } from './audit-corrections.js'
|
|
2
4
|
import semver from './apis/npm/semver.cjs'
|
|
3
5
|
import { parseFile } from './parse.js'
|
|
6
|
+
import { collectWhy } from './why.js'
|
|
4
7
|
|
|
5
|
-
// Only audit installed dependencies;
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
+
// Only audit installed dependencies; first-party packages live under non-`node_modules` keys and
|
|
9
|
+
// must not be sent to the public registry (leaks names, adds noise).
|
|
10
|
+
//
|
|
11
|
+
// A package counts as present only when its REAL code is, and recorded = present:
|
|
12
|
+
// an artifact records exactly the files it ships or attested -- imported, entry,
|
|
13
|
+
// or `add`-ed (the last two have no in-edge in the resolution graph, so presence
|
|
14
|
+
// must NOT be derived from edges). The package is audited only if some recorded
|
|
15
|
+
// file is code evidence (see audit-corrections.js) -- not a corrected file (ws's
|
|
16
|
+
// noop browser.js stub), not a package.json manifest (recorded for resolver /
|
|
17
|
+
// metadata reads). So a ws shipped only as browser.js (+ manifest) is skipped,
|
|
18
|
+
// while one whose real code was bundled stays. Which consumers and import EDGES
|
|
19
|
+
// reach that code is the reason column's concern -- collectReasons and why.js
|
|
20
|
+
// apply the same evidence rule per file/edge there.
|
|
8
21
|
export function collectPackagesFromFile(file) {
|
|
9
22
|
const out = []
|
|
10
|
-
for (const [dir, { name, version }] of parseFile(file).modules) {
|
|
23
|
+
for (const [dir, { name, version, files }] of parseFile(file).modules) {
|
|
11
24
|
if (!dir.includes('node_modules')) continue
|
|
12
|
-
if (name
|
|
25
|
+
if (!name || !version) continue
|
|
26
|
+
if (!Object.keys(files).some((rel) => isEvidenceFile(name, version, rel))) continue
|
|
27
|
+
out.push({ name, version })
|
|
13
28
|
}
|
|
14
29
|
return out
|
|
15
30
|
}
|
|
@@ -28,9 +43,80 @@ export function collectPackages(files) {
|
|
|
28
43
|
return out.toSorted((a, b) => a.name.localeCompare(b.name) || semver.compare(a.version, b.version))
|
|
29
44
|
}
|
|
30
45
|
|
|
46
|
+
// Map each audited node_modules package (`name@version`) to the bundle consumers ("reasons") that
|
|
47
|
+
// recorded its files. Only bundles carry a reason map ({ consumer: [file, ...] }); lockfiles omit
|
|
48
|
+
// it. Each file is resolved back to its owning package via the module file listing. Only evidence
|
|
49
|
+
// files attribute (see audit-corrections.js): a consumer that recorded nothing of a package but a
|
|
50
|
+
// corrected file or its package.json manifest carries none of its real code and is NOT a reason --
|
|
51
|
+
// e.g. webpack shipping only ws's noop browser.js stub, while `run` bundles the real ws.
|
|
52
|
+
export function collectReasons(files) {
|
|
53
|
+
const byPkg = new Map()
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
const artifact = parseFile(file)
|
|
56
|
+
const reason = artifact.reason
|
|
57
|
+
if (!reason) continue
|
|
58
|
+
const fileToPkg = new Map()
|
|
59
|
+
for (const [dir, { name, version, files: modFiles }] of artifact.modules) {
|
|
60
|
+
if (!dir.includes('node_modules') || !name || !version) continue
|
|
61
|
+
for (const rel of Object.keys(modFiles)) {
|
|
62
|
+
if (!isEvidenceFile(name, version, rel)) continue
|
|
63
|
+
fileToPkg.set(moduleFileKey(dir, rel), `${name}@${version}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const [consumer, list] of Object.entries(reason)) {
|
|
67
|
+
if (!Array.isArray(list)) continue
|
|
68
|
+
for (const f of list) {
|
|
69
|
+
const key = fileToPkg.get(f)
|
|
70
|
+
if (key === undefined) continue
|
|
71
|
+
let set = byPkg.get(key)
|
|
72
|
+
if (set === undefined) byPkg.set(key, (set = new Set()))
|
|
73
|
+
set.add(consumer)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return byPkg
|
|
78
|
+
}
|
|
79
|
+
|
|
31
80
|
const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, low: 3, info: 4, none: 5 }
|
|
32
81
|
|
|
33
|
-
|
|
82
|
+
// Consumer display order: bundler plugins (metro, webpack, esbuild, ...) first,
|
|
83
|
+
// then `run`, then `add`. Ties broken alphabetically.
|
|
84
|
+
const CONSUMER_RANK = { run: 1, add: 2 }
|
|
85
|
+
const consumerRank = (c) => CONSUMER_RANK[c] ?? 0
|
|
86
|
+
const byConsumerOrder = (a, b) => consumerRank(a) - consumerRank(b) || a.localeCompare(b)
|
|
87
|
+
|
|
88
|
+
// Build a row's `reason` cell by unioning over exactly the affected versions it
|
|
89
|
+
// covers. Without `--why` that's the `, `-joined set of bundle consumers; with
|
|
90
|
+
// `--why` it's the newline-joined `consumer: a -> b -> c` import-path lines
|
|
91
|
+
// (which REPLACE the consumer list). Consumers are ordered plugins -> run -> add.
|
|
92
|
+
// `reasonFilter`, when set, narrows the cell to a single consumer: the `--why`
|
|
93
|
+
// chains are already filtered upstream (see collectWhy), so only the consumer
|
|
94
|
+
// list needs pruning here.
|
|
95
|
+
function reasonCell(pkg, affected, reasonsByPkg, whyByPkg, reasonFilter) {
|
|
96
|
+
const parts = new Set()
|
|
97
|
+
const source = whyByPkg ?? reasonsByPkg
|
|
98
|
+
for (const v of affected) {
|
|
99
|
+
for (const p of source.get(`${pkg}@${v}`) ?? []) parts.add(p)
|
|
100
|
+
}
|
|
101
|
+
// --why: group `consumer: path` lines by consumer and order the groups
|
|
102
|
+
// plugins -> run -> add (this also re-unites a consumer's lines when they were
|
|
103
|
+
// split across affected versions); within a group collectWhy's compressed order
|
|
104
|
+
// is preserved. Otherwise: the `, `-joined consumer set in the same order.
|
|
105
|
+
if (whyByPkg) {
|
|
106
|
+
const byConsumer = new Map()
|
|
107
|
+
for (const line of parts) {
|
|
108
|
+
const i = line.indexOf(': ')
|
|
109
|
+
const consumer = i === -1 ? '' : line.slice(0, i)
|
|
110
|
+
if (!byConsumer.has(consumer)) byConsumer.set(consumer, [])
|
|
111
|
+
byConsumer.get(consumer).push(line)
|
|
112
|
+
}
|
|
113
|
+
return [...byConsumer.keys()].toSorted(byConsumerOrder).flatMap((c) => byConsumer.get(c)).join('\n')
|
|
114
|
+
}
|
|
115
|
+
const consumers = reasonFilter ? [...parts].filter((c) => c === reasonFilter) : [...parts]
|
|
116
|
+
return consumers.toSorted(byConsumerOrder).join(', ')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function flattenAdvisories(result, packages = [], reasonsByPkg = new Map(), whyByPkg = null, reasonFilter = null) {
|
|
34
120
|
const installed = new Map()
|
|
35
121
|
for (const { name, version } of packages) {
|
|
36
122
|
if (!installed.has(name)) installed.set(name, [])
|
|
@@ -45,10 +131,13 @@ export function flattenAdvisories(result, packages = []) {
|
|
|
45
131
|
const affected = range
|
|
46
132
|
? installedVersions.filter((v) => semver.satisfies(v, range))
|
|
47
133
|
: installedVersions
|
|
48
|
-
// npm
|
|
49
|
-
//
|
|
50
|
-
// code) reflect only real hits.
|
|
134
|
+
// npm sometimes returns advisories whose range matches none of the versions we submitted; drop
|
|
135
|
+
// them so the table and exit code reflect only real hits.
|
|
51
136
|
if (installedVersions.length > 0 && affected.length === 0) continue
|
|
137
|
+
const reason = reasonCell(pkg, affected, reasonsByPkg, whyByPkg, reasonFilter)
|
|
138
|
+
// --reason keeps only advisories tied to that consumer: once the cell is
|
|
139
|
+
// narrowed to it, an empty cell means this package isn't related to it.
|
|
140
|
+
if (reasonFilter && reason === '') continue
|
|
52
141
|
rows.push({
|
|
53
142
|
package: pkg,
|
|
54
143
|
installed: affected.join(', '),
|
|
@@ -56,6 +145,7 @@ export function flattenAdvisories(result, packages = []) {
|
|
|
56
145
|
severity: adv.severity ?? '',
|
|
57
146
|
title: adv.title ?? '',
|
|
58
147
|
url: adv.url ?? '',
|
|
148
|
+
reason,
|
|
59
149
|
})
|
|
60
150
|
}
|
|
61
151
|
}
|
|
@@ -69,36 +159,77 @@ export function flattenAdvisories(result, packages = []) {
|
|
|
69
159
|
return rows
|
|
70
160
|
}
|
|
71
161
|
|
|
72
|
-
//
|
|
73
|
-
// Covers LF, bare CR (and CRLF), and U+2028 / U+2029 line/paragraph separators.
|
|
162
|
+
// Strip line breaks (LF/CR/CRLF, U+2028/U+2029) from advisory titles so they can't break the box layout.
|
|
74
163
|
const cell = (v) => String(v ?? '').replace(/[\r\n\u2028\u2029]+/gu, ' ')
|
|
75
164
|
|
|
76
|
-
|
|
165
|
+
// Split a value into the physical lines a cell occupies. Non-multiline columns
|
|
166
|
+
// collapse every line break to a space via `cell` (one line). A column named in
|
|
167
|
+
// `multiline` (the `--why` reason column) keeps intentional `\n` breaks -- one
|
|
168
|
+
// physical line per entry -- while still flattening stray CR/separators in each.
|
|
169
|
+
const cellLines = (v, multiline) =>
|
|
170
|
+
multiline ? String(v ?? '').split('\n').map((s) => cell(s)) : [cell(v)]
|
|
171
|
+
|
|
172
|
+
export function formatTable(rows, columns, { multiline = [] } = {}) {
|
|
77
173
|
if (rows.length === 0) return ''
|
|
78
|
-
const
|
|
174
|
+
const ml = new Set(multiline)
|
|
175
|
+
const linesOf = (obj) => columns.map((c) => cellLines(obj[c], ml.has(c)))
|
|
176
|
+
const header = columns.map((c) => [c])
|
|
177
|
+
const body = rows.map((r) => linesOf(r))
|
|
178
|
+
const widths = columns.map((c, i) =>
|
|
179
|
+
Math.max(c.length, ...body.map((cells) => Math.max(0, ...cells[i].map((l) => l.length))))
|
|
180
|
+
)
|
|
79
181
|
const pad = (s, w) => s.padEnd(w)
|
|
80
182
|
const line = (l, m, r, fill) => l + widths.map((w) => fill.repeat(w + 2)).join(m) + r
|
|
81
|
-
|
|
183
|
+
// A row spans as many physical lines as its tallest cell; shorter cells pad
|
|
184
|
+
// out with blanks so the borders stay aligned.
|
|
185
|
+
const render = (cells) => {
|
|
186
|
+
const height = Math.max(...cells.map((c) => c.length))
|
|
187
|
+
const out = []
|
|
188
|
+
for (let i = 0; i < height; i++) {
|
|
189
|
+
out.push('│ ' + cells.map((c, j) => pad(c[i] ?? '', widths[j])).join(' │ ') + ' │')
|
|
190
|
+
}
|
|
191
|
+
return out.join('\n')
|
|
192
|
+
}
|
|
82
193
|
return [
|
|
83
194
|
line('┌', '┬', '┐', '─'),
|
|
84
|
-
|
|
195
|
+
render(header),
|
|
85
196
|
line('├', '┼', '┤', '─'),
|
|
86
|
-
...
|
|
197
|
+
...body.map(render),
|
|
87
198
|
line('└', '┴', '┘', '─'),
|
|
88
199
|
].join('\n')
|
|
89
200
|
}
|
|
90
201
|
|
|
91
|
-
export async function audit(files) {
|
|
202
|
+
export async function audit(files, { why = false, whyDeep = false, whyFull = false, reason = null } = {}) {
|
|
203
|
+
// --why-deep and --why-full imply --why. Deep keeps every chain instead of the
|
|
204
|
+
// default pruning of chains whose full suffix is already a chain (see
|
|
205
|
+
// collectWhy/dropSuffixed); full spells chains out with no `...` collapse.
|
|
206
|
+
why = why || whyDeep || whyFull
|
|
92
207
|
const packages = collectPackages(files)
|
|
93
208
|
if (packages.length === 0) {
|
|
94
|
-
return { packages, advisories: {}, rows: [] }
|
|
209
|
+
return { packages, advisories: {}, rows: [], why }
|
|
95
210
|
}
|
|
96
211
|
const result = await advisories(packages)
|
|
97
|
-
|
|
98
|
-
|
|
212
|
+
// `--why` REPLACES the consumer list with per-consumer import paths, so only
|
|
213
|
+
// one of the two is computed. Restrict the (potentially expensive) path search
|
|
214
|
+
// to the packages that actually carry an advisory. `reason` (--reason) narrows
|
|
215
|
+
// both the paths (in collectWhy) and the consumer list (in flattenAdvisories)
|
|
216
|
+
// to a single consumer, dropping advisories unrelated to it.
|
|
217
|
+
let rows
|
|
218
|
+
if (why) {
|
|
219
|
+
const vulnerable = new Set(
|
|
220
|
+
Object.entries(result).filter(([, v]) => Array.isArray(v) && v.length > 0).map(([name]) => name)
|
|
221
|
+
)
|
|
222
|
+
const targetKeys = new Set(
|
|
223
|
+
packages.filter((p) => vulnerable.has(p.name)).map((p) => `${p.name}@${p.version}`)
|
|
224
|
+
)
|
|
225
|
+
rows = flattenAdvisories(result, packages, undefined, collectWhy(files, targetKeys, reason, { deep: whyDeep, full: whyFull }), reason)
|
|
226
|
+
} else {
|
|
227
|
+
rows = flattenAdvisories(result, packages, collectReasons(files), null, reason)
|
|
228
|
+
}
|
|
229
|
+
return { packages, advisories: result, rows, why, whyDeep, whyFull, reason }
|
|
99
230
|
}
|
|
100
231
|
|
|
101
|
-
export function printAuditReport({ packages, rows }, { out = process.stdout, err = process.stderr } = {}) {
|
|
232
|
+
export function printAuditReport({ packages, rows, why = false, reason = null }, { out = process.stdout, err = process.stderr } = {}) {
|
|
102
233
|
err.write(`Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}\n`)
|
|
103
234
|
if (packages.length === 0) {
|
|
104
235
|
err.write('No node_modules entries found in the input files\n')
|
|
@@ -108,5 +239,13 @@ export function printAuditReport({ packages, rows }, { out = process.stdout, err
|
|
|
108
239
|
err.write('No advisories found\n')
|
|
109
240
|
return
|
|
110
241
|
}
|
|
111
|
-
|
|
242
|
+
const columns = ['severity', 'package', 'installed', 'vulnerable', 'title', 'url']
|
|
243
|
+
// Surface the reason column only when some advisory has provenance -- bundle
|
|
244
|
+
// consumers, or (with --why) import paths. Under --reason WITHOUT --why every
|
|
245
|
+
// cell is just the filter value repeated, so drop the column then; --why still
|
|
246
|
+
// earns it (the import chains differ per row).
|
|
247
|
+
if (rows.some((r) => r.reason) && !(reason && !why)) columns.splice(3, 0, 'reason')
|
|
248
|
+
// Under --why the reason cell is a list of `consumer: path` lines; let it span
|
|
249
|
+
// multiple physical rows instead of collapsing to one.
|
|
250
|
+
out.write(formatTable(rows, columns, { multiline: why ? ['reason'] : [] }) + '\n')
|
|
112
251
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Worker, MessageChannel, isMainThread, parentPort, workerData } = require('node:worker_threads')
|
|
4
|
+
const { once } = require('node:events')
|
|
5
|
+
const { availableParallelism } = require('node:os')
|
|
6
|
+
|
|
7
|
+
// Babel transforms are CPU-bound and `stasis build` fires many concurrently (esbuild loads
|
|
8
|
+
// files in parallel), so they run on a small worker-thread pool off the main thread. Workers
|
|
9
|
+
// are unref()ed: a pending transform keeps the process alive through its open MessagePort,
|
|
10
|
+
// but an idle pool never holds the process open after the build finishes.
|
|
11
|
+
//
|
|
12
|
+
// Babel is the project's dependency, not stasis's, so each pool is keyed by the resolved
|
|
13
|
+
// @babel/core entry path and the worker loads that exact copy (passed via workerData).
|
|
14
|
+
if (isMainThread) {
|
|
15
|
+
const maxWorkers = availableParallelism() >= 4 ? 2 : 1
|
|
16
|
+
const pools = new Map() // resolved @babel/core path -> [{ worker, busy }]
|
|
17
|
+
|
|
18
|
+
const getWorker = (babelPath) => {
|
|
19
|
+
if (!pools.has(babelPath)) pools.set(babelPath, [])
|
|
20
|
+
const workers = pools.get(babelPath)
|
|
21
|
+
|
|
22
|
+
const idle = workers.find((info) => info.busy === 0)
|
|
23
|
+
if (idle) return idle
|
|
24
|
+
|
|
25
|
+
if (workers.length < maxWorkers) {
|
|
26
|
+
const worker = new Worker(__filename, { workerData: { babelPath } })
|
|
27
|
+
worker.unref()
|
|
28
|
+
// unhandled top-level errors crash the process automatically, which is the desired
|
|
29
|
+
// behavior -- no need to listen to 'error'
|
|
30
|
+
workers.unshift({ worker, busy: 0 })
|
|
31
|
+
} else if (workers.length > 1) {
|
|
32
|
+
workers.sort((a, b) => a.busy - b.busy)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return workers[0]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const transformAsync = async (babelPath, code, options) => {
|
|
39
|
+
const info = getWorker(babelPath)
|
|
40
|
+
info.busy++
|
|
41
|
+
const channel = new MessageChannel()
|
|
42
|
+
info.worker.postMessage({ port: channel.port1, code, options }, [channel.port1])
|
|
43
|
+
const [{ result, error }] = await once(channel.port2, 'message')
|
|
44
|
+
info.busy--
|
|
45
|
+
if (error) throw error
|
|
46
|
+
return result
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { transformAsync }
|
|
50
|
+
} else {
|
|
51
|
+
const babel = require(workerData.babelPath)
|
|
52
|
+
|
|
53
|
+
parentPort.on('message', ({ port, code: input, options }) => {
|
|
54
|
+
try {
|
|
55
|
+
// transformSync: the worker is already off the main thread, async only adds overhead
|
|
56
|
+
const { code, map } = babel.transformSync(input, options)
|
|
57
|
+
// only plain data crosses the port -- a full Babel result carries non-cloneable state
|
|
58
|
+
port.postMessage({ result: { code, map } })
|
|
59
|
+
} catch (error) {
|
|
60
|
+
port.postMessage({ error })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
port.close()
|
|
64
|
+
})
|
|
65
|
+
}
|
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
|
}
|