@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Exodus Movement
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/bin/stasis.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
import { once } from 'node:events'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { basename, resolve } from 'node:path'
|
|
8
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import pkg from '../package.json' with { type: 'json' }
|
|
11
|
+
|
|
12
|
+
const argv = [...process.argv]
|
|
13
|
+
assert(['node', 'node.exe'].includes(basename(argv.shift())))
|
|
14
|
+
assert(['node', 'node.exe'].includes(basename(process.argv0)))
|
|
15
|
+
|
|
16
|
+
const jsname = argv.shift()
|
|
17
|
+
const pathsEqual = (a, b) => a === b || (existsSync(a) && realpathSync(a) === b) // resolve symlinks
|
|
18
|
+
assert(basename(jsname) === 'stasis' || pathsEqual(jsname, fileURLToPath(import.meta.url)))
|
|
19
|
+
|
|
20
|
+
function usage(prefix = '') {
|
|
21
|
+
console.error(`${prefix}\nUsage:
|
|
22
|
+
stasis run --lock=(add|replace|frozen|ignore) [--bundle=(add|replace|load|ignore)] [--bundle-file=path/to/bundle.br] [--full] path/to/file.js ...
|
|
23
|
+
stasis bundle create path/to/lockfile
|
|
24
|
+
stasis bundle verify path/to/lockfile
|
|
25
|
+
stasis prune [path/to/project]
|
|
26
|
+
stasis audit path/to/file ...
|
|
27
|
+
`.trim())
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setEnv(name, value) {
|
|
32
|
+
const env = process.env[name]
|
|
33
|
+
if (env && env !== value) throw new Error(`env conflict: ${name}="${env}", effective: "${value}"`)
|
|
34
|
+
process.env[name] = value === undefined ? '' : value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const command = argv.shift()
|
|
38
|
+
|
|
39
|
+
if (command === '-v' || command === '--version') {
|
|
40
|
+
console.log(`v${pkg.version}`)
|
|
41
|
+
process.exit(0)
|
|
42
|
+
} else if (command === 'run') {
|
|
43
|
+
const flags = []
|
|
44
|
+
const valueFlags = new Set(['--bundle', '--bundle-file', '--lock'])
|
|
45
|
+
while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
|
|
46
|
+
flags.push(argv.shift())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const options = {
|
|
50
|
+
lock: { type: 'string', default: 'none' },
|
|
51
|
+
bundle: { type: 'string', default: 'none' },
|
|
52
|
+
'bundle-file': { type: 'string' },
|
|
53
|
+
debug: { type: 'boolean' },
|
|
54
|
+
full: { type: 'boolean' },
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { values } = parseArgs({ args: flags, options })
|
|
58
|
+
if (argv.length === 0) usage('Nothing to run: no path to file given')
|
|
59
|
+
if (!['none', 'ignore', 'add', 'replace', 'frozen'].includes(values.lock)) usage('Error: invalid --lock value')
|
|
60
|
+
const lock = values.lock
|
|
61
|
+
const scope = values.full ? 'full' : 'node_modules'
|
|
62
|
+
const bundle = values.bundle
|
|
63
|
+
const bundleFile = values['bundle-file'] ? resolve(values['bundle-file']) : ''
|
|
64
|
+
const debug = values.debug ? '1' : ''
|
|
65
|
+
if (!['none', 'ignore', 'add', 'replace', 'load'].includes(bundle)) usage('Error: invalid --bundle value')
|
|
66
|
+
if (bundleFile && bundle === 'none') usage('Error: --bundle-file requires --bundle=(add|replace|load|ignore)')
|
|
67
|
+
if (bundle === 'load' && lock !== 'frozen' && lock !== 'none' && lock !== 'ignore') usage('Error: --bundle=load requires --lock=(frozen|none|ignore)')
|
|
68
|
+
if (lock === 'none' && bundle === 'none') usage('Error: --lock=none requires --bundle=(add|replace|load|ignore)')
|
|
69
|
+
console.warn('[stasis] Running stasis with config:', { lock, scope, bundle, ...(bundleFile && { bundleFile }) })
|
|
70
|
+
if (debug) console.warn(`[stasis] Warning: stasis debug mode active`)
|
|
71
|
+
setEnv('EXODUS_STASIS_LOCK', lock)
|
|
72
|
+
setEnv('EXODUS_STASIS_SCOPE', scope)
|
|
73
|
+
setEnv('EXODUS_STASIS_BUNDLE', bundle)
|
|
74
|
+
setEnv('EXODUS_STASIS_BUNDLE_FILE', bundleFile)
|
|
75
|
+
setEnv('EXODUS_STASIS_DEBUG', debug)
|
|
76
|
+
const child = spawn('node', ['--import', import.meta.resolve('../src/loader.js'), ...argv], { stdio: 'inherit' })
|
|
77
|
+
const [code] = await once(child, 'close')
|
|
78
|
+
process.exitCode = code
|
|
79
|
+
} else if (command === 'bundle') {
|
|
80
|
+
usage('bundle command is not implemented yet')
|
|
81
|
+
} else if (command === 'prune') {
|
|
82
|
+
if (argv.length > 1) usage('Error: prune takes at most one path argument')
|
|
83
|
+
const root = argv[0] ? resolve(argv[0]) : process.cwd()
|
|
84
|
+
const { prune } = await import('../src/prune.js')
|
|
85
|
+
const { removed, validated } = prune({ root })
|
|
86
|
+
console.warn(`[stasis] prune: validated ${validated.length} file(s), removed ${removed.length} file(s)`)
|
|
87
|
+
} else if (command === 'audit') {
|
|
88
|
+
if (argv.length === 0) usage('Nothing to audit: no path to file given')
|
|
89
|
+
const { audit, printAuditReport } = await import('../src/audit.js')
|
|
90
|
+
const files = argv.map((f) => resolve(f))
|
|
91
|
+
const report = await audit(files)
|
|
92
|
+
printAuditReport(report)
|
|
93
|
+
process.exitCode = report.rows.length === 0 ? 0 : 1
|
|
94
|
+
} else {
|
|
95
|
+
usage()
|
|
96
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@exodus/stasis",
|
|
3
|
+
"version": "1.0.0-alpha.1",
|
|
4
|
+
"description": "A research project",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./esbuild": "./src/esbuild.js",
|
|
8
|
+
"./webpack": "./src/webpack.js",
|
|
9
|
+
"./loader": "./src/loader.js",
|
|
10
|
+
"./bundle": "./src/bundle.js",
|
|
11
|
+
"./lockfile": "./src/lockfile.js",
|
|
12
|
+
"./prune": "./src/prune.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/stasis.js",
|
|
16
|
+
"src/apis/npm/index.js",
|
|
17
|
+
"src/apis/npm/semver.cjs",
|
|
18
|
+
"src/bundle.js",
|
|
19
|
+
"src/config.js",
|
|
20
|
+
"src/esbuild.js",
|
|
21
|
+
"src/loader.js",
|
|
22
|
+
"src/lockfile.js",
|
|
23
|
+
"src/prune.js",
|
|
24
|
+
"src/state.js",
|
|
25
|
+
"src/state.util.js",
|
|
26
|
+
"src/util.js",
|
|
27
|
+
"src/webpack.js"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=24.14.0"
|
|
31
|
+
},
|
|
32
|
+
"bin": {
|
|
33
|
+
"stasis": "bin/stasis.js"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"lint": "oxlint",
|
|
37
|
+
"test": "node --run test:coherence",
|
|
38
|
+
"test:coherence": "node --test"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/ExodusMovement/stasis.git"
|
|
43
|
+
},
|
|
44
|
+
"author": "Exodus Movement, Inc.",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/ExodusMovement/stasis/issues"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/ExodusMovement/stasis#readme",
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@exodus/bytes": "^1.15.0",
|
|
52
|
+
"esbuild": "^0.27.3",
|
|
53
|
+
"oxlint": "^1.52.0",
|
|
54
|
+
"string-replace-loader": "^2.3.0",
|
|
55
|
+
"webpack": "^4.47.0"
|
|
56
|
+
},
|
|
57
|
+
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
|
58
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import semver from './semver.cjs'
|
|
4
|
+
|
|
5
|
+
const packageNameRegex = /^(@[\da-z-]+\/)?[\w-]+(\.[\w-]+)*$/u
|
|
6
|
+
|
|
7
|
+
export async function advisories(list, { signal = AbortSignal.timeout(30_000) } = {}) {
|
|
8
|
+
const groups = new Map()
|
|
9
|
+
for (const { name, version } of list) {
|
|
10
|
+
assert(typeof name === 'string' && typeof version === 'string')
|
|
11
|
+
assert(packageNameRegex.test(name), `Unexpected package name: ${name}`)
|
|
12
|
+
assert(semver.valid(version), `Invalid version: ${version}`)
|
|
13
|
+
if (!groups.has(name)) groups.set(name, new Set())
|
|
14
|
+
groups.get(name).add(version)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const entries = [...groups].map(([k, v]) => [k, [...v].toSorted((a, b) => semver.compare(a, b))])
|
|
18
|
+
const body = Object.fromEntries(entries.toSorted((a, b) => a[0] < b[0] ? -1 : 1))
|
|
19
|
+
|
|
20
|
+
let res
|
|
21
|
+
try {
|
|
22
|
+
res = await fetch('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
signal,
|
|
27
|
+
})
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
throw new Error(`npm advisories request failed: ${cause.message}`, { cause })
|
|
30
|
+
}
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const text = await res.text().catch(() => '')
|
|
33
|
+
throw new Error(`npm advisories request failed: ${res.status} ${res.statusText}${text ? ` — ${text.slice(0, 200)}` : ''}`)
|
|
34
|
+
}
|
|
35
|
+
return res.json()
|
|
36
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const { basename, dirname, resolve } = require('node:path')
|
|
5
|
+
|
|
6
|
+
let semver
|
|
7
|
+
|
|
8
|
+
const argv0 = process.argv[0] // not process.argv0 which lacks path
|
|
9
|
+
|
|
10
|
+
function getSemver() {
|
|
11
|
+
if (semver) return semver
|
|
12
|
+
|
|
13
|
+
// Coherence check that we got Node.js path there
|
|
14
|
+
assert(['node', 'node.exe'].includes(basename(argv0)))
|
|
15
|
+
assert.equal(basename(argv0), basename(process.argv0))
|
|
16
|
+
|
|
17
|
+
// Load semver from npm bundled with Node.js
|
|
18
|
+
const semverDir = resolve(dirname(argv0), '../lib/node_modules/npm/node_modules/semver')
|
|
19
|
+
semver = require(semverDir)
|
|
20
|
+
return semver
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
exports.compare = (...a) => getSemver().compare(...a)
|
|
24
|
+
exports.gtr = (...a) => getSemver().gtr(...a)
|
|
25
|
+
exports.lt = (...a) => getSemver().lt(...a)
|
|
26
|
+
exports.lte = (...a) => getSemver().lte(...a)
|
|
27
|
+
exports.ltr = (...a) => getSemver().ltr(...a)
|
|
28
|
+
exports.satisfies = (...a) => getSemver().satisfies(...a)
|
|
29
|
+
exports.validRange = (...a) => getSemver().validRange(...a)
|
|
30
|
+
|
|
31
|
+
exports.valid = (version, options) => {
|
|
32
|
+
// Fast path for certainly valid versions
|
|
33
|
+
if (
|
|
34
|
+
options === undefined &&
|
|
35
|
+
typeof version === 'string' &&
|
|
36
|
+
version.length <= 256 &&
|
|
37
|
+
// 1-15 chars because it must fit in Number.MAX_SAFE_INTEGER
|
|
38
|
+
/^(0|[1-9]\d{0,14})\.(0|[1-9]\d{0,14})\.(0|[1-9]\d{0,14})$/iu.test(version)
|
|
39
|
+
) {
|
|
40
|
+
return version
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return getSemver().valid(version, options)
|
|
44
|
+
}
|
package/src/bundle.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assert,
|
|
3
|
+
fileMapToObject,
|
|
4
|
+
fileSetToObject,
|
|
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
|
+
}
|