@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/src/state.js DELETED
@@ -1,440 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import { isUtf8 } from 'node:buffer'
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4
- import { join, resolve, relative, basename, dirname, extname } from 'node:path'
5
- import { fileURLToPath, pathToFileURL } from 'node:url'
6
- import { findPackageJSON } from 'node:module'
7
- import { brotliCompressSync, brotliDecompressSync } from 'node:zlib'
8
-
9
- import { Config } from './config.js'
10
- import { Bundle } from './bundle.js'
11
- import { Lockfile } from './lockfile.js'
12
- import { sha512integrity, readFileSyncMaybe, noupsert } from './state.util.js'
13
- import { splitNodeModulesPath } from './util.js'
14
-
15
- const FILE_CONFIG = 'stasis.config.json'
16
- const FILE_LOCK = 'stasis.lock.json'
17
- const FILE_CODE = 'stasis.code.br'
18
- const FILE_RESOURCES = 'stasis.resources.br'
19
-
20
- function readPackageJSON(pkgAbsolute) {
21
- const buf = readFileSync(pkgAbsolute)
22
- assert.ok(isUtf8(buf))
23
- return JSON.parse(buf.toString())
24
- }
25
-
26
- // TODO: stricter format validation
27
-
28
- let preload
29
- const liveStates = new Set()
30
-
31
- export class State {
32
- hashes = new Map()
33
- entries = new Set()
34
- sources = new Map()
35
- resources = new Map()
36
- formats = new Map()
37
- modules = new Map()
38
- imports = new Map()
39
- config
40
- root
41
-
42
- // options.preload (boolean) -- mark this instance as the unique preload State, exposed via
43
- // State.preload. Only one preload State may exist at a time. Non-preload States can be
44
- // constructed freely (e.g. a bundler plugin that emits a sidecar bundle in a process that
45
- // already has a preload-owned lockfile). All other keys forward to Config.
46
- constructor(root, options = {}) {
47
- const { preload: isPreload = false, ...configOptions } = options
48
- this.config = new Config(configOptions)
49
- // Check the preload-uniqueness invariant up front so two simultaneous preload attempts
50
- // can't both pass through the constructor; the actual `preload = this` registration
51
- // happens after construction succeeds so a throw in setup can't leave a half-built
52
- // preload reference behind.
53
- if (isPreload) assert.ok(!preload, 'Only one preload Stasis instance is supported')
54
- const potentialRoots = []
55
- let cursor = root
56
- while (cursor) {
57
- if (existsSync(join(cursor, 'package.json'))) {
58
- potentialRoots.push(cursor)
59
- } else if (
60
- existsSync(join(cursor, FILE_CONFIG)) ||
61
- existsSync(join(cursor, FILE_LOCK)) ||
62
- existsSync(join(cursor, FILE_CODE)) ||
63
- existsSync(join(cursor, FILE_RESOURCES))) {
64
- throw new Error('Unexpected stasis config without package.json')
65
- }
66
-
67
- if (cursor === process.env.PROJECT_CWD) break // e.g. yarn sets this
68
- if (existsSync(join(cursor, '.git'))) break // don't go higher than the repo root
69
- if (existsSync(join(cursor, 'pnpm-workspace.yaml'))) break // pnpm workspace root
70
- const parent = dirname(cursor)
71
- if (!parent || parent === cursor) break
72
- cursor = parent
73
- }
74
-
75
- // default root is top-level package.json, to opt-in to per-dir create stasis.config.json
76
- this.root = potentialRoots.at(-1)
77
-
78
- let loaded = false
79
- for (const rootDir of potentialRoots) {
80
- const config = readFileSyncMaybe(rootDir, FILE_CONFIG, 'utf-8')
81
- const lock = readFileSyncMaybe(rootDir, FILE_LOCK, 'utf-8')
82
- const sourcesPath = this.config.bundleFile || join(rootDir, FILE_CODE)
83
- const sources = readFileSyncMaybe(dirname(sourcesPath), basename(sourcesPath))
84
- const resources = readFileSyncMaybe(rootDir, FILE_RESOURCES)
85
- if (config !== null || lock !== null || sources !== null || resources !== null) {
86
- if (loaded) throw new Error('Stasis config already loaded')
87
- loaded = true
88
- this.root = rootDir
89
-
90
- if (config) this.config.loadConfig(config)
91
-
92
- if (lock && !this.config.useLockfile && !this.config.ignoreLockfile) {
93
- throw new Error(`Unexpected ${join(rootDir, FILE_LOCK)} with config.lock = 'none'`)
94
- }
95
- if (sources && !this.config.writeBundle && !this.config.loadBundle && !this.config.ignoreBundle) {
96
- throw new Error(`Unexpected ${sourcesPath} with config.bundle = 'none'`)
97
- }
98
- if (resources && !this.config.writeBundle && !this.config.loadBundle && !this.config.ignoreBundle) {
99
- throw new Error(`Unexpected ${join(rootDir, FILE_RESOURCES)} with config.bundle = 'none'`)
100
- }
101
-
102
- if (sources && !lock && this.config.useLockfile && !this.config.replaceLockfile) {
103
- throw new Error('stasis.lock.json missing, can not use sources')
104
- }
105
-
106
- if (this.config.frozen) assert.ok(lock, 'No lockfile, but attempting to run in frozen mode')
107
- let lockfileLoaded = false
108
- if (lock && this.config.useLockfile && !this.config.replaceLockfile) {
109
- const lockfile = Lockfile.parse(lock)
110
- if (this.config.frozen) assert.equal(lockfile.config.scope, this.config.scope)
111
-
112
- const includeSources = lockfile.config.scope === 'full' && this.config.full
113
- for (const [dir, info] of lockfile.modules) {
114
- if (!dir.includes('node_modules') && !includeSources) continue
115
- this.modules.set(dir, info)
116
- }
117
- if (includeSources) this.entries = lockfile.entries
118
-
119
- for (const [dir, { files }] of this.modules) {
120
- for (const [name, hash] of Object.entries(files)) {
121
- noupsert(this.hashes, join(dir, name), hash)
122
- }
123
- }
124
- lockfileLoaded = true
125
- }
126
-
127
- if (sources && (this.config.writeBundle || this.config.loadBundle) && !this.config.replaceBundle) {
128
- const bundle = Bundle.parseCode(brotliDecompressSync(sources).toString('utf-8'))
129
- assert.equal(bundle.config.scope, this.config.scope)
130
- this.#mergeBundleMetadata(bundle, { lockfileLoaded })
131
- this.sources = bundle.sources
132
- this.formats = bundle.formats
133
- this.imports = bundle.imports
134
- }
135
-
136
- if (resources && (this.config.writeBundle || this.config.loadBundle) && !this.config.replaceBundle) {
137
- const bundle = Bundle.parseResources(brotliDecompressSync(resources).toString('utf-8'))
138
- // v0 resource bundles didn't record scope; only enforce on v1 where it's reliable.
139
- if (bundle.version === Bundle.VERSION) {
140
- assert.equal(bundle.config.scope, this.config.scope)
141
- }
142
- this.#mergeBundleMetadata(bundle, { lockfileLoaded })
143
- this.resources = bundle.sources
144
- }
145
- }
146
- }
147
-
148
- // Register only after every fallible step succeeds, so a thrown error during config
149
- // discovery / lockfile / bundle parsing leaves the static registry untouched.
150
- if (isPreload) preload = this
151
- liveStates.add(this)
152
- }
153
-
154
- // Cross-check bundle metadata (entries/modules) with what the lockfile already
155
- // loaded, or absorb it as the source of truth when no lockfile is present.
156
- // v0 bundles infer `name` from path for node_modules buckets but carry no
157
- // version; the workspace "." bucket has no inferable name. We skip entries
158
- // we can't cross-check (no name with lockfile, or no version when populating
159
- // state.modules — addFile later asserts strict name+version equality against
160
- // disk's package.json, which would clash with a null we stashed earlier).
161
- // Note: the path-inferred name for v0 will disagree with the lockfile when a
162
- // dep is installed under an alias (`foo: npm:bar` writes `node_modules/foo`
163
- // with `name: "bar"` in package.json); the strict equality below will fail
164
- // in that case, which is the right call — the v0 bundle has no way to
165
- // attest the real package identity, so we refuse to silently mismatch.
166
- #mergeBundleMetadata(bundle, { lockfileLoaded }) {
167
- if (lockfileLoaded) {
168
- if (bundle.entries.size > 0) {
169
- assert.deepStrictEqual(
170
- [...bundle.entries].toSorted(),
171
- [...this.entries].toSorted(),
172
- 'bundle/lockfile entries mismatch'
173
- )
174
- }
175
- for (const [dir, info] of bundle.modules) {
176
- if (!info.name) continue // workspace bucket or fallback: no inferable name
177
- assert.ok(this.modules.has(dir), `bundle module ${dir} missing in lockfile`)
178
- const lockModule = this.modules.get(dir)
179
- assert.equal(info.name, lockModule.name)
180
- if (info.version) assert.equal(info.version, lockModule.version)
181
- for (const rel of Object.keys(info.files)) {
182
- assert.ok(Object.hasOwn(lockModule.files, rel), `bundle file ${dir}/${rel} missing in lockfile`)
183
- }
184
- }
185
- } else {
186
- if (bundle.entries.size > 0) this.entries = new Set([...this.entries, ...bundle.entries])
187
- for (const [dir, info] of bundle.modules) {
188
- if (!info.name || !info.version) continue // partial metadata
189
- if (this.modules.has(dir)) {
190
- // Code and resources bundles both populate `state.modules` when no lockfile
191
- // is loaded. They must agree on name/version for the same dir.
192
- const existing = this.modules.get(dir)
193
- assert.equal(info.name, existing.name, `bundle ${dir} name mismatch`)
194
- assert.equal(info.version, existing.version, `bundle ${dir} version mismatch`)
195
- } else {
196
- this.modules.set(dir, { name: info.name, version: info.version, files: Object.create(null) })
197
- }
198
- }
199
- }
200
- }
201
-
202
- assertEntry(url) {
203
- // Skipped silently when no entries info is available (v0 bundle + lock=none/ignore).
204
- // v1 bundles in full scope are required to declare at least one entry at parse time,
205
- // so this fallback only ever triggers for the legacy path.
206
- if (this.entries.size === 0) return
207
- const file = this.relative(this.absolute(url))
208
- assert.ok(this.entries.has(file), `Unknown entry point: ${file}`)
209
- }
210
-
211
- static get preload() {
212
- return preload
213
- }
214
-
215
- // Back-compat accessor: returns the preload State if one exists, else the sole live State.
216
- // Throws when several non-preload States coexist -- callers in that situation must reach
217
- // for State.preload (or hold their own reference). Returns undefined when no State is live.
218
- static get instance() {
219
- if (preload) return preload
220
- if (liveStates.size === 0) return undefined
221
- if (liveStates.size > 1) throw new Error('Multiple Stasis instances; use State.preload')
222
- return [...liveStates][0]
223
- }
224
-
225
- absolute(url) {
226
- const absolute = fileURLToPath(url)
227
- assert.equal(pathToFileURL(absolute).toString(), url)
228
- assert.equal(absolute, resolve(absolute))
229
- return absolute
230
- }
231
-
232
- relative(absolute) {
233
- assert.ok(absolute)
234
- const file = relative(this.root, absolute)
235
- assert.ok(!file.startsWith('..'))
236
- return file
237
- }
238
-
239
- addFile(url, { source, format, isEntry, isBinary } = {}) {
240
- const absolute = this.absolute(url)
241
- assert.ok(existsSync(absolute))
242
- const file = this.relative(absolute)
243
-
244
- const closestPkgAbsolute = findPackageJSON(url)
245
- const closestPkg = readPackageJSON(closestPkgAbsolute)
246
-
247
- const closestType = closestPkg.type ?? 'commonjs'
248
- assert.ok(closestType === 'module' || closestType === 'commonjs')
249
- const extToFormat = { __proto__: null, '.json': 'json', '.mjs': 'module', '.cjs': 'commonjs', '.js': closestType }
250
- const inferredFormat = extToFormat[extname(file)]
251
- if (inferredFormat !== undefined) {
252
- if (format != null) assert.equal(format, inferredFormat)
253
- else format = inferredFormat
254
- }
255
-
256
- // findPackageJSON may land on a `{"type":"module"}`-style sub-bucket
257
- // marker that lacks name/version.
258
- const nmRoot = splitNodeModulesPath(file)?.dir
259
- let pkgAbsolute, name, version
260
- if (nmRoot) {
261
- pkgAbsolute = resolve(this.root, nmRoot, 'package.json')
262
- const rootPkg = pkgAbsolute === closestPkgAbsolute ? closestPkg : readPackageJSON(pkgAbsolute)
263
- ;({ name, version } = rootPkg)
264
- assert.ok(name, `Missing name in ${this.relative(pkgAbsolute)}`)
265
- assert.ok(version, `Missing version in ${this.relative(pkgAbsolute)}`)
266
- if (closestPkgAbsolute !== pkgAbsolute) {
267
- if (closestPkg.name !== undefined) assert.equal(closestPkg.name, name)
268
- if (closestPkg.version !== undefined) assert.equal(closestPkg.version, version)
269
- }
270
- } else {
271
- pkgAbsolute = closestPkgAbsolute
272
- let json = closestPkg
273
- while (true) {
274
- if (json.name !== undefined && json.version !== undefined) {
275
- ;({ name, version } = json)
276
- break
277
- }
278
- assert.ok(Object.keys(json).every((k) => k === 'type'))
279
- const next = findPackageJSON('..', pathToFileURL(pkgAbsolute))
280
- assert.ok(
281
- next && !relative(this.root, next).startsWith('..'),
282
- `No package.json with name+version found for ${file}`
283
- )
284
- pkgAbsolute = next
285
- json = readPackageJSON(pkgAbsolute)
286
- }
287
- }
288
- const pkg = this.relative(pkgAbsolute)
289
- assert.ok(pkg === 'package.json' || pkg.endsWith('/package.json'))
290
- assert.equal(basename(pkg), 'package.json')
291
- const dir = dirname(pkg)
292
- if (nmRoot) assert.equal(dir, nmRoot)
293
- if (!this.modules.has(dir)) this.modules.set(dir, { name, version, files: Object.create(null) })
294
- const module = this.modules.get(dir)
295
- assert.equal(module.name, name)
296
- assert.equal(module.version, version)
297
-
298
- if (typeof source === 'string') {
299
- assert.ok(source.isWellFormed())
300
- } else {
301
- if (source === undefined || source === null) source = readFileSync(absolute)
302
- assert.ok(Buffer.isBuffer(source))
303
- if (!isBinary) assert.ok(isUtf8(source), `File is not UTF-8: ${file}`)
304
- }
305
-
306
- const buf = typeof source === 'string' ? Buffer.from(source) : source
307
- assert.deepStrictEqual(readFileSync(absolute), buf)
308
-
309
- if (isEntry) this.entries.add(file)
310
- const integrity = sha512integrity(source)
311
- noupsert(this.hashes, file, integrity)
312
- const rel = relative(dir, file)
313
- assert.ok(!rel.startsWith('..'))
314
-
315
- if (this.config.frozen && (dir.includes('node_modules') || this.config.full)) {
316
- assert.ok(Object.hasOwn(module.files, rel))
317
- }
318
-
319
- if (!Object.hasOwn(module.files, rel)) module.files[rel] = integrity
320
- assert.equal(module.files[rel], integrity)
321
-
322
- if (this.config.bundle) {
323
- if (isBinary) {
324
- noupsert(this.resources, file, buf.toString('base64'))
325
- } else {
326
- noupsert(this.sources, file, typeof source === 'string' ? source : source.toString())
327
- }
328
- }
329
-
330
- if (format) noupsert(this.formats, file, format)
331
- }
332
-
333
- getFile(url) {
334
- const file = this.relative(this.absolute(url))
335
- const source = this.sources.get(file)
336
- const format = this.formats.get(file) // might be undefined e.g. for some bundlers
337
- assert.ok(source !== undefined)
338
- // Without a lockfile the bundle is self-attesting: hashes would be derived
339
- // from the same source bytes we'd then verify, which is a tautology.
340
- if (this.config.useLockfile) assert.equal(this.hashes.get(file), sha512integrity(source))
341
- return { source, format }
342
- }
343
-
344
- getFormat(url) {
345
- return this.formats.get(this.relative(this.absolute(url)))
346
- }
347
-
348
- // Different conditions / import attributes can yield different URLs/formats for the same parent+specifier
349
- #conditionsKey(conditions, importAttributes) {
350
- const cond = conditions === '*' ? '*' : conditions.join(', ')
351
- assert.ok(!cond.includes('(') && !cond.includes(')'), 'conditions must not contain "(" or ")"')
352
- const attrs = importAttributes ? Object.entries(importAttributes) : []
353
- if (attrs.length === 0) return cond
354
- const sorted = Object.fromEntries(attrs.toSorted(([a], [b]) => (a < b ? -1 : 1)))
355
- return `${cond} (with: ${JSON.stringify(sorted)})`
356
- }
357
-
358
- addImport(parentURL, specifier, url, { conditions = '*', format, importAttributes } = {}) {
359
- if (conditions !== '*') assert.ok(Array.isArray(conditions))
360
- assert.ok(parentURL, 'addImport requires a parent (entries go through addFile)')
361
- const parent = this.relative(this.absolute(parentURL))
362
- const file = this.relative(this.absolute(url))
363
- const key = this.#conditionsKey(conditions, importAttributes)
364
-
365
- if (!this.imports.has(key)) this.imports.set(key, new Map())
366
- const imports = this.imports.get(key)
367
- if (!imports.has(parent)) imports.set(parent, new Map())
368
- const specifiers = imports.get(parent)
369
- noupsert(specifiers, specifier, file)
370
- if (format) noupsert(this.formats, file, format)
371
- }
372
-
373
- getImport(parentURL, specifier, { conditions = '*', importAttributes } = {}) {
374
- if (conditions !== '*') assert.ok(Array.isArray(conditions))
375
- const parent = this.relative(this.absolute(parentURL))
376
- const key = this.#conditionsKey(conditions, importAttributes)
377
- const file = this.imports.get(key)?.get(parent)?.get(specifier)
378
- assert.ok(file)
379
- const url = pathToFileURL(resolve(this.root, file)).toString()
380
- const format = this.formats.get(file) // might be undefined e.g. for some bundlers
381
- return { url, format }
382
- }
383
-
384
- get lockData() {
385
- return new Lockfile({
386
- config: this.config.values,
387
- entries: this.entries,
388
- modules: this.modules,
389
- }).serialize()
390
- }
391
-
392
- // Pair each module's recorded file list with the corresponding content from
393
- // perFile (state.sources for code, state.resources for resources), dropping
394
- // modules with no content. Bundle.serialize* then groups them into sources/
395
- // modules buckets and writes the lockfile-shaped output.
396
- #bundleModules(perFile) {
397
- const modules = new Map()
398
- for (const [dir, info] of this.modules) {
399
- const files = {}
400
- for (const rel of Object.keys(info.files)) {
401
- const file = dir === '.' ? rel : `${dir}/${rel}`
402
- const content = perFile.get(file)
403
- if (content !== undefined) files[rel] = content
404
- }
405
- if (Object.keys(files).length > 0) {
406
- modules.set(dir, { name: info.name, version: info.version, files })
407
- }
408
- }
409
- return modules
410
- }
411
-
412
- get sourceData() {
413
- return new Bundle({
414
- config: this.config.values,
415
- entries: this.entries,
416
- modules: this.#bundleModules(this.sources),
417
- formats: this.formats,
418
- imports: this.imports,
419
- }).serializeCode()
420
- }
421
-
422
- get resourceData() {
423
- return new Bundle({
424
- config: this.config.values,
425
- modules: this.#bundleModules(this.resources),
426
- }).serializeResources()
427
- }
428
-
429
- write() {
430
- // writeFileSync(join(this.root, FILE_CONFIG), this.config.json)
431
- if (this.config.writeLockfile) {
432
- writeFileSync(join(this.root, FILE_LOCK), this.lockData)
433
- }
434
- if (this.config.writeBundle) {
435
- const sourcesPath = this.config.bundleFile || join(this.root, FILE_CODE)
436
- mkdirSync(dirname(sourcesPath), { recursive: true })
437
- writeFileSync(sourcesPath, brotliCompressSync(this.sourceData))
438
- }
439
- }
440
- }
package/src/state.util.js DELETED
@@ -1,25 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import { hash } from 'node:crypto'
3
- import { readFileSync } from 'node:fs'
4
- import { join, sep } from 'node:path'
5
-
6
- assert.equal(sep, '/', 'Not tested on Windows')
7
-
8
- export const sha512integrity = (x) => `sha512-${hash('sha512', x, 'base64')}`
9
-
10
- export function readFileSyncMaybe(dir, file, encoding) {
11
- try {
12
- return readFileSync(join(dir, file), encoding)
13
- } catch (err) {
14
- if (err.code === 'ENOENT') return null
15
- throw err
16
- }
17
- }
18
-
19
- export function noupsert(map, key, value) {
20
- if (map.has(key)) {
21
- assert.deepStrictEqual(map.get(key), value)
22
- } else {
23
- map.set(key, value)
24
- }
25
- }
package/src/util.js DELETED
@@ -1,60 +0,0 @@
1
- const sep = '/'
2
-
3
- export function assert(condition, msg) {
4
- if (!condition) throw new Error(msg)
5
- }
6
-
7
- export function sortPaths(a, b) {
8
- const [al, bl] = [a.split(sep), b.split(sep)]
9
- while (al.length > 0 && al[0] === bl[0]) {
10
- al.shift()
11
- bl.shift()
12
- }
13
- if (al.length === 0 && bl.length === 0) return 0
14
-
15
- // First process each file in dir, then subdirs
16
- if (al.length < 2) return bl.length < 2 && al > bl ? 1 : -1
17
- if (bl.length < 2) return 1
18
-
19
- // * comes first
20
- if (al[0] === '*') return -1
21
- if (bl[0] === '*') return 1
22
-
23
- // node_modules comes last
24
- if (al[0] === 'node_modules') return 1
25
- if (bl[0] === 'node_modules') return -1
26
-
27
- // Prefer example/ over example-something/
28
- const [an, bn] = [al, bl].map((list) => list.join(String.fromCodePoint(0)))
29
- if (an < bn) return -1
30
- if (an > bn) return 1
31
- throw new Error('Unreachable')
32
- }
33
-
34
- export const isPlainObject = (x) => x && [null, Object.prototype].includes(Object.getPrototypeOf(x))
35
-
36
- export const fromEntries = (entries) => Object.setPrototypeOf(Object.fromEntries(entries), null)
37
-
38
- export const fileSetToObject = (set) => [...set].toSorted((a, b) => sortPaths(a, b))
39
-
40
- export const fileMapToObject = (map) => fromEntries(
41
- [...map]
42
- .toSorted((a, b) => sortPaths(a[0], b[0]))
43
- .map(([k, v]) => [k, v instanceof Map ? fileMapToObject(v) : v])
44
- )
45
-
46
- export const objectToMaps = (obj) => new Map(
47
- Object.entries(obj).map(([k, v]) => [k, isPlainObject(v) ? objectToMaps(v) : v])
48
- )
49
-
50
- export function splitNodeModulesPath(path) {
51
- const marker = 'node_modules/'
52
- const idx = path.lastIndexOf(marker)
53
- if (idx === -1) return null
54
- const after = idx + marker.length
55
- const parts = path.slice(after).split('/')
56
- const pkgLen = parts[0].startsWith('@') ? 2 : 1
57
- if (parts.length <= pkgLen || parts.slice(0, pkgLen).some((p) => !p)) return null
58
- const name = parts.slice(0, pkgLen).join('/')
59
- return { dir: path.slice(0, after) + name, rel: parts.slice(pkgLen).join('/'), name }
60
- }