@exodus/stasis 1.0.0-alpha.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/LICENSE +21 -0
- package/README.md +11 -0
- package/bin/stasis.js +96 -0
- package/package.json +58 -0
- package/src/apis/npm/index.js +36 -0
- package/src/apis/npm/semver.cjs +44 -0
- package/src/bundle.js +217 -0
- package/src/config.js +205 -0
- package/src/esbuild.js +56 -0
- package/src/loader.js +132 -0
- package/src/lockfile.js +80 -0
- package/src/prune.js +162 -0
- package/src/state.js +440 -0
- package/src/state.util.js +25 -0
- package/src/util.js +60 -0
- package/src/webpack.js +48 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
const VALID_SCOPE = new Set(['node_modules', 'full'])
|
|
4
|
+
const VALID_LOCK = new Set(['none', 'ignore', 'add', 'replace', 'frozen'])
|
|
5
|
+
const VALID_BUNDLE = new Set(['none', 'ignore', 'add', 'replace', 'load'])
|
|
6
|
+
|
|
7
|
+
const envDebugBool = (value) => Boolean(value && value !== '0')
|
|
8
|
+
|
|
9
|
+
const OPTION_KEYS = ['scope', 'lock', 'bundle', 'bundleFile', 'debug']
|
|
10
|
+
|
|
11
|
+
// Plugins accept the same options as Config but want to validate without constructing one,
|
|
12
|
+
// since constructing State has side effects. Mirror Config's per-field validation.
|
|
13
|
+
export function validatePluginOptions(label, options) {
|
|
14
|
+
const rest = { ...options }
|
|
15
|
+
for (const key of OPTION_KEYS) delete rest[key]
|
|
16
|
+
assert.equal(Object.keys(rest).length, 0, `Unknown ${label} options: ${Object.keys(rest).join(', ')}`)
|
|
17
|
+
const { scope, lock, bundle, bundleFile, debug } = options
|
|
18
|
+
if (scope !== undefined) assert.ok(VALID_SCOPE.has(scope), `Invalid scope: ${scope}`)
|
|
19
|
+
if (lock !== undefined) assert.ok(VALID_LOCK.has(lock), `Invalid lock: ${lock}`)
|
|
20
|
+
if (bundle !== undefined) assert.ok(VALID_BUNDLE.has(bundle), `Invalid bundle: ${bundle}`)
|
|
21
|
+
if (bundleFile !== undefined) assert.equal(typeof bundleFile, 'string', 'bundleFile must be a string')
|
|
22
|
+
if (debug !== undefined) assert.equal(typeof debug, 'boolean', 'debug must be a boolean')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// When a plugin runs against a State that already exists (preload path), the active Config
|
|
26
|
+
// is authoritative. Any options the plugin was given must agree with it.
|
|
27
|
+
export function assertOptionsMatchConfig(config, options) {
|
|
28
|
+
const { scope, lock, bundle, bundleFile, debug } = options
|
|
29
|
+
try {
|
|
30
|
+
if (scope !== undefined) assert.equal(config.scope, scope)
|
|
31
|
+
if (lock !== undefined) assert.equal(config.lock, lock)
|
|
32
|
+
if (bundle !== undefined) assert.equal(config.bundleMode, bundle)
|
|
33
|
+
if (bundleFile !== undefined) assert.equal(config.bundleFile, bundleFile)
|
|
34
|
+
if (debug !== undefined) assert.equal(config.debug, debug)
|
|
35
|
+
} catch (cause) {
|
|
36
|
+
throw new Error('Plugin options conflict with active stasis state', { cause })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class Config {
|
|
41
|
+
#env
|
|
42
|
+
#scope
|
|
43
|
+
#lock
|
|
44
|
+
#bundle
|
|
45
|
+
#bundleFile
|
|
46
|
+
#debug
|
|
47
|
+
|
|
48
|
+
// Options match the CLI flags (lock/bundle/bundleFile/scope/debug). Env vars take effect at
|
|
49
|
+
// construction time; if both env and an option are set they must agree.
|
|
50
|
+
constructor(options = {}) {
|
|
51
|
+
const { scope, lock, bundle, bundleFile, debug, ...rest } = options
|
|
52
|
+
assert.equal(Object.keys(rest).length, 0, `Unknown Config options: ${Object.keys(rest).join(', ')}`)
|
|
53
|
+
|
|
54
|
+
this.#env = {
|
|
55
|
+
scope: process.env.EXODUS_STASIS_SCOPE || undefined,
|
|
56
|
+
lock: process.env.EXODUS_STASIS_LOCK || undefined,
|
|
57
|
+
bundle: process.env.EXODUS_STASIS_BUNDLE || undefined,
|
|
58
|
+
bundleFile: process.env.EXODUS_STASIS_BUNDLE_FILE || undefined,
|
|
59
|
+
debug: process.env.EXODUS_STASIS_DEBUG || undefined,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
if (this.#env.scope !== undefined && scope !== undefined) assert.equal(this.#env.scope, scope)
|
|
64
|
+
if (this.#env.lock !== undefined && lock !== undefined) assert.equal(this.#env.lock, lock)
|
|
65
|
+
if (this.#env.bundle !== undefined && bundle !== undefined) assert.equal(this.#env.bundle, bundle)
|
|
66
|
+
if (this.#env.bundleFile !== undefined && bundleFile !== undefined) {
|
|
67
|
+
assert.equal(this.#env.bundleFile, bundleFile)
|
|
68
|
+
}
|
|
69
|
+
if (this.#env.debug !== undefined && debug !== undefined) {
|
|
70
|
+
assert.equal(envDebugBool(this.#env.debug), debug)
|
|
71
|
+
}
|
|
72
|
+
} catch (cause) {
|
|
73
|
+
throw new Error('Config options can not override stasis env', { cause })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.#scope = this.#env.scope || scope || 'full'
|
|
77
|
+
this.#lock = this.#env.lock || lock || 'add'
|
|
78
|
+
this.#bundle = this.#env.bundle || bundle || 'none'
|
|
79
|
+
this.#bundleFile = this.#env.bundleFile || bundleFile || undefined
|
|
80
|
+
this.#debug = this.#env.debug !== undefined ? envDebugBool(this.#env.debug) : (debug ?? false)
|
|
81
|
+
|
|
82
|
+
this.#checkInvariants()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Per-field validation + cross-field invariants. Constructor (options path) and
|
|
86
|
+
// loadConfig (disk path) both call this so the rules live in one place.
|
|
87
|
+
#checkInvariants() {
|
|
88
|
+
assert.ok(VALID_SCOPE.has(this.#scope), `Invalid scope: ${this.#scope}`)
|
|
89
|
+
assert.ok(VALID_LOCK.has(this.#lock), `Invalid lock: ${this.#lock}`)
|
|
90
|
+
assert.ok(VALID_BUNDLE.has(this.#bundle), `Invalid bundle: ${this.#bundle}`)
|
|
91
|
+
assert.equal(typeof this.#debug, 'boolean', 'debug must be a boolean')
|
|
92
|
+
if (this.#bundleFile !== undefined) assert.equal(typeof this.#bundleFile, 'string', 'bundleFile must be a string')
|
|
93
|
+
|
|
94
|
+
// bundle=load needs a trust root for source bytes: frozen pins each file's sha512 in
|
|
95
|
+
// the lockfile and we cross-check it on load; otherwise the bundle is itself
|
|
96
|
+
// authoritative -- lock=none with no lockfile on disk, or lock=ignore with a lockfile
|
|
97
|
+
// deliberately bypassed.
|
|
98
|
+
// WARNING: lockfile attests only to source BYTES, not bundle metadata.
|
|
99
|
+
if (this.#bundle === 'load' && this.#lock !== 'frozen' && this.#lock !== 'none' && this.#lock !== 'ignore') {
|
|
100
|
+
throw new RangeError('bundle=load requires lock=(frozen|none|ignore)')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (this.#lock === 'none' && this.#bundle === 'none') {
|
|
104
|
+
throw new RangeError('lock=none requires bundle=(add|replace|load|ignore)')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
loadConfig(json) {
|
|
109
|
+
const {
|
|
110
|
+
scope = this.#scope,
|
|
111
|
+
lock = this.#lock,
|
|
112
|
+
bundle = this.#bundle,
|
|
113
|
+
debug = this.#debug,
|
|
114
|
+
...rest
|
|
115
|
+
} = JSON.parse(json)
|
|
116
|
+
assert.equal(Object.keys(rest).length, 0)
|
|
117
|
+
this.#scope = scope
|
|
118
|
+
this.#lock = lock
|
|
119
|
+
this.#bundle = bundle
|
|
120
|
+
this.#debug = debug
|
|
121
|
+
this.#checkInvariants()
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
if (this.#env.scope !== undefined) assert.equal(this.#scope, this.#env.scope)
|
|
125
|
+
if (this.#env.lock !== undefined) assert.equal(this.#lock, this.#env.lock)
|
|
126
|
+
if (this.#env.bundle !== undefined) assert.equal(this.#bundle, this.#env.bundle)
|
|
127
|
+
if (this.#env.debug !== undefined) assert.equal(this.#debug, envDebugBool(this.#env.debug))
|
|
128
|
+
} catch (cause) {
|
|
129
|
+
throw new Error('Flags/env can not override stasis.config.json', { cause })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get bundleFile() {
|
|
134
|
+
return this.#bundleFile
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get debug() {
|
|
138
|
+
return this.#debug
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
get values() {
|
|
142
|
+
return { scope: this.#scope }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get bundle() {
|
|
146
|
+
return this.#bundle !== 'none' && this.#bundle !== 'ignore'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get bundleMode() {
|
|
150
|
+
return this.#bundle
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get ignoreBundle() {
|
|
154
|
+
return this.#bundle === 'ignore'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get writeBundle() {
|
|
158
|
+
return this.#bundle === 'add' || this.#bundle === 'replace'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get replaceBundle() {
|
|
162
|
+
return this.#bundle === 'replace'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get loadBundle() {
|
|
166
|
+
return this.#bundle === 'load'
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get full() {
|
|
170
|
+
return this.#scope === 'full'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
get frozen() {
|
|
174
|
+
return this.#lock === 'frozen'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get useLockfile() {
|
|
178
|
+
return this.#lock !== 'none' && this.#lock !== 'ignore'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get ignoreLockfile() {
|
|
182
|
+
return this.#lock === 'ignore'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get writeLockfile() {
|
|
186
|
+
return this.#lock === 'add' || this.#lock === 'replace'
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get replaceLockfile() {
|
|
190
|
+
return this.#lock === 'replace'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get scope() {
|
|
194
|
+
return this.#scope
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
get lock() {
|
|
198
|
+
return this.#lock
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
get json() {
|
|
202
|
+
const data = { scope: this.#scope, lock: this.#lock, bundle: this.#bundle }
|
|
203
|
+
return JSON.stringify(data, undefined, 2) + '\n'
|
|
204
|
+
}
|
|
205
|
+
}
|
package/src/esbuild.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { isUtf8 } from 'node:buffer'
|
|
3
|
+
import { extname } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
|
|
7
|
+
import { State } from './state.js'
|
|
8
|
+
|
|
9
|
+
const state = State.instance
|
|
10
|
+
assert.ok(state, 'Stasis preload is not active')
|
|
11
|
+
|
|
12
|
+
export class StasisEsbuild {
|
|
13
|
+
#seen = new Set()
|
|
14
|
+
|
|
15
|
+
get name() {
|
|
16
|
+
return 'stasis'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
setup = ({ onResolve, onLoad, resolve }) => {
|
|
20
|
+
onResolve({ filter: /$/, namespace: 'file' }, async ({ path: specifier, ...args }) => {
|
|
21
|
+
// Recurse with a synthetic namespace so esbuild's default resolver runs without re-entering us
|
|
22
|
+
const res = await resolve(specifier, { ...args, namespace: 'stasis' })
|
|
23
|
+
if (res.errors.length > 0) return res
|
|
24
|
+
assert.equal(res.warnings.length, 0)
|
|
25
|
+
assert.equal(res.suffix, '')
|
|
26
|
+
|
|
27
|
+
const isEntry = !args.importer
|
|
28
|
+
if (res.namespace === 'file' && !res.external) {
|
|
29
|
+
if (!isEntry) {
|
|
30
|
+
const parentURL = pathToFileURL(args.importer).toString()
|
|
31
|
+
const url = pathToFileURL(res.path).toString()
|
|
32
|
+
state.addImport(parentURL, specifier, url)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { ...res, pluginData: { ...res.pluginData, isEntry } }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
|
|
40
|
+
assert.equal(Object.keys(attrs).length, 0) // TODO: 'with' statements
|
|
41
|
+
const source = await readFile(path)
|
|
42
|
+
const isBinary = !isUtf8(source)
|
|
43
|
+
|
|
44
|
+
if (!this.#seen.has(path)) {
|
|
45
|
+
this.#seen.add(path)
|
|
46
|
+
state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, isBinary })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (isBinary) return { contents: source, loader: 'binary' }
|
|
50
|
+
|
|
51
|
+
const loader = extname(path).replace(/^\.[cm]?/, '')
|
|
52
|
+
assert(['js', 'ts', 'jsx', 'tsx', 'json'].includes(loader))
|
|
53
|
+
return { contents: source, loader }
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/loader.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { registerHooks, findPackageJSON, isBuiltin } from 'node:module'
|
|
2
|
+
import { basename, dirname, extname, resolve as resolvePath } from 'node:path'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
|
|
7
|
+
import { State } from './state.js'
|
|
8
|
+
|
|
9
|
+
// Warning: only covers code imports, not file reads
|
|
10
|
+
|
|
11
|
+
let state
|
|
12
|
+
let saved = false
|
|
13
|
+
|
|
14
|
+
function initState(root) {
|
|
15
|
+
state = new State(root, { preload: true })
|
|
16
|
+
|
|
17
|
+
const save = () => {
|
|
18
|
+
if (saved) return
|
|
19
|
+
saved = true
|
|
20
|
+
state.write()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
process.on('beforeExit', save)
|
|
24
|
+
process.on('exit', save)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function load(url, context, nextLoad) {
|
|
28
|
+
assert.equal(typeof url, 'string')
|
|
29
|
+
|
|
30
|
+
if (url.startsWith('node:')) {
|
|
31
|
+
const result = nextLoad(url, context)
|
|
32
|
+
assert.equal(result.format, 'builtin')
|
|
33
|
+
return result
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (state && state.config.loadBundle) {
|
|
37
|
+
// node_modules scope only bundles node_modules sources; non-tracked files are read
|
|
38
|
+
// from disk through Node's default loader (state.getFile is never called for them).
|
|
39
|
+
if (!state.config.full && !url.includes('/node_modules/')) {
|
|
40
|
+
return nextLoad(url, context)
|
|
41
|
+
}
|
|
42
|
+
const { source, format } = state.getFile(url)
|
|
43
|
+
// context.format may be null when the resolve chain didn't set it (e.g. node_modules
|
|
44
|
+
// scope, where non-nm parents go through nextResolve and Node's default doesn't
|
|
45
|
+
// populate format for the load hook). Only cross-check when the chain provided one.
|
|
46
|
+
// TODO: format here is sourced from the bundle's `formats` map, which the lockfile
|
|
47
|
+
// does not currently cover; a tampered bundle can flip module<->commonjs for a
|
|
48
|
+
// hash-valid file. Derive format from extension + module.type (recorded in the
|
|
49
|
+
// lockfile) and reject the bundle's claim when it disagrees.
|
|
50
|
+
if (context.format != null) assert.equal(format, context.format)
|
|
51
|
+
assert.ok(['module', 'commonjs', 'json'].includes(format))
|
|
52
|
+
return { source, format, shortCircuit: true }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const result = nextLoad(url, context)
|
|
56
|
+
let { source } = result
|
|
57
|
+
const { format } = result
|
|
58
|
+
assert.notEqual(format, 'builtin')
|
|
59
|
+
|
|
60
|
+
// Node's CJS loader may return source=null and read from disk itself; capture it for the bundle
|
|
61
|
+
if (source == null && format === 'commonjs') source = readFileSync(fileURLToPath(url))
|
|
62
|
+
|
|
63
|
+
const isEntry = !state
|
|
64
|
+
if (!state) {
|
|
65
|
+
const pkg = findPackageJSON(url)
|
|
66
|
+
assert.equal(basename(pkg), 'package.json')
|
|
67
|
+
initState(dirname(pkg))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
assert.equal(saved, false)
|
|
71
|
+
state.addFile(url, { source, format, isEntry })
|
|
72
|
+
|
|
73
|
+
return result
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolve(specifier, context, nextResolve) {
|
|
77
|
+
if (isBuiltin(specifier)) {
|
|
78
|
+
const res = nextResolve(specifier)
|
|
79
|
+
assert.equal(res.url, specifier.startsWith('node:') ? specifier : `node:${specifier}`)
|
|
80
|
+
return res
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (context.importAttributes !== undefined) {
|
|
84
|
+
const expectedAttrs = Object.create(null) // Saving is not supported yet, so we ensure expected ones
|
|
85
|
+
if (extname(specifier) === '.json' && context.importAttributes?.type) expectedAttrs.type = 'json'
|
|
86
|
+
assert.deepStrictEqual(context.importAttributes, expectedAttrs)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { parentURL, conditions, importAttributes } = context
|
|
90
|
+
|
|
91
|
+
// In full-scope load mode, the entry point is served from the bundle without touching
|
|
92
|
+
// disk, so state must be initialised before nextResolve runs. node_modules scope reads
|
|
93
|
+
// non-nm sources (including the entry) from disk, so state is initialised later in load().
|
|
94
|
+
if (!state && !parentURL && process.env.EXODUS_STASIS_BUNDLE === 'load'
|
|
95
|
+
&& process.env.EXODUS_STASIS_SCOPE !== 'node_modules') {
|
|
96
|
+
initState(process.cwd())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (state && state.config.loadBundle) {
|
|
100
|
+
// node_modules scope defers resolution of non-nm parents to Node. Relative imports
|
|
101
|
+
// between non-nm sources then resolve to local files, and bare specifiers resolve via
|
|
102
|
+
// the on-disk node_modules layout -- but the load() hook still serves the resulting
|
|
103
|
+
// node_modules target from the bundle, so package contents need not be on disk.
|
|
104
|
+
if (!state.config.full && !parentURL?.includes('/node_modules/')) {
|
|
105
|
+
return nextResolve(specifier)
|
|
106
|
+
}
|
|
107
|
+
// Bare specifier with a parent is the regular ESM import: look up the recorded
|
|
108
|
+
// import map. Otherwise we have either a pre-resolved file URL (CLI entries and
|
|
109
|
+
// ESM-to-CJS translator require() targets both arrive that way) or a bare entry
|
|
110
|
+
// specifier we anchor to cwd -- either way, skip nextResolve so the entry file
|
|
111
|
+
// doesn't need to exist on disk.
|
|
112
|
+
if (parentURL && !specifier.startsWith('file:')) {
|
|
113
|
+
const { url, format } = state.getImport(parentURL, specifier, { conditions, importAttributes })
|
|
114
|
+
return { url, format, importAttributes: undefined, shortCircuit: true }
|
|
115
|
+
}
|
|
116
|
+
const url = specifier.startsWith('file:')
|
|
117
|
+
? specifier
|
|
118
|
+
: pathToFileURL(resolvePath(process.cwd(), specifier)).toString()
|
|
119
|
+
if (!parentURL && state.config.full) state.assertEntry(url)
|
|
120
|
+
return { url, format: state.getFormat(url), importAttributes: undefined, shortCircuit: true }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const res = nextResolve(specifier)
|
|
124
|
+
const { url, format } = res
|
|
125
|
+
|
|
126
|
+
assert.equal(res.importAttributes, undefined) // unsupported yet
|
|
127
|
+
if (parentURL) assert.ok(state)
|
|
128
|
+
if (state) state.addImport(parentURL, specifier, url, { conditions, format, importAttributes })
|
|
129
|
+
return res
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
registerHooks({ load, resolve })
|
package/src/lockfile.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { assert, fileSetToObject, fromEntries, sortPaths } from './util.js'
|
|
2
|
+
|
|
3
|
+
const VERSION = 0
|
|
4
|
+
|
|
5
|
+
const normalize = ({ name, version, files }) =>
|
|
6
|
+
({ name, version, files: fromEntries(Object.entries(files)) })
|
|
7
|
+
|
|
8
|
+
export class Lockfile {
|
|
9
|
+
static VERSION = VERSION
|
|
10
|
+
|
|
11
|
+
version = VERSION
|
|
12
|
+
config
|
|
13
|
+
entries
|
|
14
|
+
modules
|
|
15
|
+
|
|
16
|
+
constructor({ config = { scope: 'full' }, entries, modules } = {}) {
|
|
17
|
+
assert(['node_modules', 'full'].includes(config.scope))
|
|
18
|
+
this.config = config
|
|
19
|
+
this.entries = entries ?? new Set()
|
|
20
|
+
this.modules = modules ?? new Map()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static parse(text) {
|
|
24
|
+
const json = JSON.parse(text)
|
|
25
|
+
assert(json.version === VERSION)
|
|
26
|
+
assert(['node_modules', 'full'].includes(json.config?.scope))
|
|
27
|
+
|
|
28
|
+
const full = json.config.scope === 'full'
|
|
29
|
+
assert(!!json.entries === full)
|
|
30
|
+
assert(!!json.sources === full)
|
|
31
|
+
assert(json.modules)
|
|
32
|
+
|
|
33
|
+
const modules = new Map()
|
|
34
|
+
for (const [dir, info] of Object.entries(json.modules)) {
|
|
35
|
+
assert(dir.includes('node_modules'))
|
|
36
|
+
assert(info?.name && info.version && info.files)
|
|
37
|
+
modules.set(dir, normalize(info))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let entries = new Set()
|
|
41
|
+
if (full) {
|
|
42
|
+
assert(Array.isArray(json.entries))
|
|
43
|
+
entries = new Set(json.entries)
|
|
44
|
+
for (const [dir, info] of Object.entries(json.sources)) {
|
|
45
|
+
assert(!dir.includes('node_modules'))
|
|
46
|
+
modules.set(dir, normalize(info))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const [dir, { files }] of modules) {
|
|
51
|
+
assert(!dir.startsWith('..'))
|
|
52
|
+
assert(files)
|
|
53
|
+
for (const name of Object.keys(files)) {
|
|
54
|
+
assert(!name.startsWith('..'))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return new Lockfile({ config: json.config, entries, modules })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
serialize() {
|
|
62
|
+
const entries = fileSetToObject(this.entries)
|
|
63
|
+
const modules = []
|
|
64
|
+
const sources = []
|
|
65
|
+
for (const [dir, { name, version, files }] of this.modules) {
|
|
66
|
+
const inNodeModules = dir.includes('node_modules')
|
|
67
|
+
if (inNodeModules) assert(name && version && files)
|
|
68
|
+
const type = inNodeModules ? modules : sources
|
|
69
|
+
const sorted = fromEntries(Object.entries(files).toSorted((a, b) => sortPaths(a[0], b[0])))
|
|
70
|
+
type.push([dir, { name, version, files: sorted }])
|
|
71
|
+
}
|
|
72
|
+
modules.sort((a, b) => sortPaths(a[0], b[0]))
|
|
73
|
+
sources.sort((a, b) => sortPaths(a[0], b[0]))
|
|
74
|
+
|
|
75
|
+
const store = { version: this.version, config: this.config }
|
|
76
|
+
if (this.config.scope === 'full') Object.assign(store, { entries, sources: fromEntries(sources) })
|
|
77
|
+
Object.assign(store, { modules: fromEntries(modules) })
|
|
78
|
+
return JSON.stringify(store, undefined, 2) + '\n'
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/prune.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
rmdirSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
10
|
+
|
|
11
|
+
import { Lockfile } from './lockfile.js'
|
|
12
|
+
import { sha512integrity } from './state.util.js' // also runs the posix-sep assertion
|
|
13
|
+
|
|
14
|
+
const LOCKFILE = 'stasis.lock.json'
|
|
15
|
+
|
|
16
|
+
function assertGlobalVirtualStoreDisabled() {
|
|
17
|
+
// pnpm exports its settings as `npm_config_*` env vars to hooks and
|
|
18
|
+
// lifecycle scripts. When the global virtual store is enabled, node_modules
|
|
19
|
+
// is dominated by symlinks into a shared store; pruning would walk past
|
|
20
|
+
// most files (we skip symlinks) and the lockfile-vs-disk comparison stops
|
|
21
|
+
// being meaningful. Reject before touching anything.
|
|
22
|
+
const env = process.env.npm_config_enable_global_virtual_store
|
|
23
|
+
if (env !== undefined && env !== 'false' && env !== '') {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`stasis prune: enableGlobalVirtualStore must be false, got ${JSON.stringify(env)}`,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadLockfile(root) {
|
|
31
|
+
const path = join(root, LOCKFILE)
|
|
32
|
+
assert.ok(existsSync(path), `stasis prune: ${LOCKFILE} not found at ${path}`)
|
|
33
|
+
return Lockfile.parse(readFileSync(path, 'utf8'))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildExpected(lockfile) {
|
|
37
|
+
const expected = new Map()
|
|
38
|
+
const knownDirs = new Set()
|
|
39
|
+
for (const [dir, { files }] of lockfile.modules) {
|
|
40
|
+
if (!dir.includes('node_modules')) continue // workspace sources, not pnpm-managed
|
|
41
|
+
knownDirs.add(dir)
|
|
42
|
+
for (const [rel, hash] of Object.entries(files)) {
|
|
43
|
+
expected.set(`${dir}/${rel}`, hash)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { expected, knownDirs }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function* walkFiles(nodeModules) {
|
|
50
|
+
if (!existsSync(nodeModules)) return
|
|
51
|
+
const stack = [nodeModules]
|
|
52
|
+
while (stack.length > 0) {
|
|
53
|
+
const dir = stack.pop()
|
|
54
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
55
|
+
const full = join(dir, entry.name)
|
|
56
|
+
// Skip symlinks: pnpm's flat node_modules symlinks into .pnpm; the file
|
|
57
|
+
// bytes are reachable via the symlink targets which we also walk
|
|
58
|
+
// directly when we descend into .pnpm.
|
|
59
|
+
if (entry.isSymbolicLink()) continue
|
|
60
|
+
if (entry.isDirectory()) stack.push(full)
|
|
61
|
+
else if (entry.isFile()) yield full
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pruneEmptyDirs(dir, stopAt) {
|
|
67
|
+
while (dir.startsWith(stopAt) && dir !== stopAt && existsSync(dir)) {
|
|
68
|
+
let entries
|
|
69
|
+
try {
|
|
70
|
+
entries = readdirSync(dir)
|
|
71
|
+
} catch {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (entries.length > 0) return
|
|
75
|
+
try {
|
|
76
|
+
rmdirSync(dir)
|
|
77
|
+
} catch {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
dir = dirname(dir)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function prune({ root = process.cwd() } = {}) {
|
|
85
|
+
assertGlobalVirtualStoreDisabled()
|
|
86
|
+
root = resolve(root)
|
|
87
|
+
const nodeModules = join(root, 'node_modules')
|
|
88
|
+
const lockfile = loadLockfile(root)
|
|
89
|
+
const { expected, knownDirs } = buildExpected(lockfile)
|
|
90
|
+
|
|
91
|
+
// Plan first, mutate later: walk the tree, validate every tracked file,
|
|
92
|
+
// collect the deletion list, and verify nothing in the lockfile is
|
|
93
|
+
// missing. Bail with a thrown error before touching disk on any failure.
|
|
94
|
+
const toRemove = []
|
|
95
|
+
const validated = []
|
|
96
|
+
const kept = []
|
|
97
|
+
const seen = new Set()
|
|
98
|
+
const mismatches = []
|
|
99
|
+
|
|
100
|
+
for (const full of walkFiles(nodeModules)) {
|
|
101
|
+
const rel = relative(root, full)
|
|
102
|
+
const expectedHash = expected.get(rel)
|
|
103
|
+
if (expectedHash !== undefined) {
|
|
104
|
+
seen.add(rel)
|
|
105
|
+
const actual = sha512integrity(readFileSync(full))
|
|
106
|
+
if (actual === expectedHash) {
|
|
107
|
+
validated.push(rel)
|
|
108
|
+
kept.push(rel)
|
|
109
|
+
} else {
|
|
110
|
+
mismatches.push({ rel, expected: expectedHash, actual })
|
|
111
|
+
}
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Package.json files are kept (without hash validation, since they may
|
|
116
|
+
// not be enumerated in the lockfile) only when their containing dir is
|
|
117
|
+
// a module recorded in the lockfile. Stray package.jsons under
|
|
118
|
+
// node_modules dirs that the lockfile doesn't know about get pruned
|
|
119
|
+
// along with the rest of those modules.
|
|
120
|
+
if (basename(rel) === 'package.json' && knownDirs.has(dirname(rel))) {
|
|
121
|
+
kept.push(rel)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Defense-in-depth: the path must resolve inside node_modules (no
|
|
126
|
+
// symlink escape, no `..` traversal) before we queue it for deletion.
|
|
127
|
+
assert.ok(
|
|
128
|
+
full === nodeModules || full.startsWith(`${nodeModules}${sep}`),
|
|
129
|
+
`refusing to remove path outside node_modules: ${full}`,
|
|
130
|
+
)
|
|
131
|
+
toRemove.push(full)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const missing = []
|
|
135
|
+
for (const [rel] of expected) {
|
|
136
|
+
if (!seen.has(rel)) missing.push(rel)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (mismatches.length > 0) {
|
|
140
|
+
const lines = mismatches.map(({ rel, expected: exp, actual }) =>
|
|
141
|
+
` ${rel}: expected ${exp}, got ${actual}`).join('\n')
|
|
142
|
+
throw new Error(`stasis prune: hash mismatch for ${mismatches.length} file(s):\n${lines}`)
|
|
143
|
+
}
|
|
144
|
+
if (missing.length > 0) {
|
|
145
|
+
const sample = missing.slice(0, 5).join(', ')
|
|
146
|
+
const suffix = missing.length > 5 ? `, ... (${missing.length} total)` : ''
|
|
147
|
+
throw new Error(`stasis prune: files listed in lockfile are missing on disk: ${sample}${suffix}`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const removed = []
|
|
151
|
+
const touchedDirs = new Set()
|
|
152
|
+
for (const full of toRemove) {
|
|
153
|
+
unlinkSync(full)
|
|
154
|
+
removed.push(relative(root, full))
|
|
155
|
+
touchedDirs.add(dirname(full))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const sortedDirs = [...touchedDirs].toSorted((a, b) => b.length - a.length)
|
|
159
|
+
for (const d of sortedDirs) pruneEmptyDirs(d, nodeModules)
|
|
160
|
+
|
|
161
|
+
return { removed, validated, kept }
|
|
162
|
+
}
|