@exodus/stasis-plugins 1.0.0-beta.9 → 1.0.0-rc.0

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/webpack.js CHANGED
@@ -1,57 +1,31 @@
1
- import assert from 'node:assert/strict'
2
- import { isUtf8 } from 'node:buffer'
3
1
  import { existsSync } from 'node:fs'
4
2
  import { createRequire, isBuiltin } from 'node:module'
5
3
  import path from 'node:path'
6
4
  import { fileURLToPath, pathToFileURL } from 'node:url'
7
5
 
8
- import { resolvePluginState } from './plugins.js'
6
+ import { assertCodeUtf8, attestedImport, classifyOrThrow, resolvePluginState } from './plugins.js'
9
7
  import { State } from '@exodus/stasis-core/state'
10
- import { realReadFileSync } from '@exodus/stasis-core/state-util'
11
- import { classifyExtension } from '@exodus/stasis-core/util'
8
+ import { realReadFileSync, servedByBundle } from '@exodus/stasis-core/state-util'
12
9
 
13
- // CJS require anchored here, used only to locate the PROJECT's babel-plugin-module-resolver below.
14
10
  const require = createRequire(import.meta.url)
15
11
 
16
- // Recovered package.json "imports" subpaths ('#app/abc.js'), keyed by (importerAbsolute, NUL,
17
- // rewrittenRequest) -> array of original '#'-specifier(s) (several aliases can collide on one key,
18
- // so afterResolve records an edge for each). Module-level so the single patch and every instance's
19
- // lookup share it; never cleared (keys deterministic, values deduped).
12
+ // The package.json "imports" subpaths ('#app/abc.js') babel-plugin-module-resolver rewrote, by (importer, rewritten
13
+ // request) -> the '#'-specifiers (several aliases can collide on one key). Shared by the one patch and every instance.
20
14
  const subpathOriginals = new Map()
21
15
  const edgeKey = (importerAbsolute, request) => `${importerAbsolute}\u0000${request}`
22
16
 
23
- // Marks an already-wrapped chokepoint so we don't double-wrap babel-plugin-module-resolver.
24
17
  const PATCHED = Symbol('stasis.modresolver.patched')
25
18
 
26
- // Synthetic stat for bundle-served files in load mode: webpack's resolver only needs existence +
27
- // size; mtime/mode aren't security-relevant (the lockfile attests content via hash).
19
+ // For a bundle-served file under load: webpack's resolver needs only existence and size, the hash attests content.
28
20
  function bundleStat(size, isDir = false) {
29
21
  return {
30
22
  isFile: () => !isDir,
31
23
  isDirectory: () => isDir,
32
- isSymbolicLink: () => false,
33
- isBlockDevice: () => false,
34
- isCharacterDevice: () => false,
35
- isFIFO: () => false,
36
- isSocket: () => false,
24
+ ...Object.fromEntries(['isSymbolicLink', 'isBlockDevice', 'isCharacterDevice', 'isFIFO', 'isSocket'].map((name) => [name, () => false])),
37
25
  size,
38
- mtime: new Date(0),
39
- mtimeMs: 0,
40
- atime: new Date(0),
41
- atimeMs: 0,
42
- ctime: new Date(0),
43
- ctimeMs: 0,
44
- birthtime: new Date(0),
45
- birthtimeMs: 0,
26
+ ...Object.fromEntries(['mtime', 'atime', 'ctime', 'birthtime'].flatMap((name) => [[name, new Date(0)], [`${name}Ms`, 0]])),
46
27
  mode: isDir ? 0o040755 : 0o100644,
47
- uid: 0,
48
- gid: 0,
49
- ino: 0,
50
- nlink: 1,
51
- rdev: 0,
52
- blksize: 4096,
53
- blocks: 0,
54
- dev: 0,
28
+ uid: 0, gid: 0, ino: 0, nlink: 1, rdev: 0, blksize: 4096, blocks: 0, dev: 0,
55
29
  }
56
30
  }
57
31
 
@@ -59,101 +33,66 @@ export class StasisWebpack {
59
33
  #seen = new Set()
60
34
  #state
61
35
  #resources
62
-
63
- // Number of compilers this instance drives (final by the first `done`): 1 for a lone compiler,
64
- // N for a MultiCompiler. Only the multi case accumulates across N `done`s and defers its write.
36
+ // The compilers this instance drives, final by the first `done`: N for a MultiCompiler, whose write is deferred.
65
37
  #captureApplyCount = 0
66
-
67
- // Deferred-write bookkeeping for the multi-compiler case (see #deferWrite). #armed: the single
68
- // flush pair is registered; #dirty: a clean build completed since the last flush.
69
38
  #deferredWriteArmed = false
70
39
  #deferredWriteDirty = false
71
40
 
72
41
  constructor(options = {}) {
73
42
  const { state } = resolvePluginState('StasisWebpack', options, process.cwd())
74
- this.#state = state // null when plugin should be inert
75
- // Cache the resolved resources Set for the per-file classify hot path.
43
+ this.#state = state
76
44
  this.#resources = state?.config.resources ?? new Set()
77
45
  }
78
46
 
79
47
  apply(compiler) {
80
48
  if (!this.#state) return
81
- if (this.#state.config.loadBundle) {
82
- this.#applyLoadMode(compiler)
83
- return
84
- }
85
- this.#applyCaptureMode(compiler)
49
+ if (this.#state.config.loadBundle) this.#applyLoadMode(compiler)
50
+ else this.#applyCaptureMode(compiler)
86
51
  }
87
52
 
88
- // Load mode: mirrors `stasis run --bundle=load`. In-scope paths MUST come from the bundle
89
- // (getFile re-verifies the hash); a missing in-scope file is a hard error, never a silent disk
90
- // fallback. In scope = full (everything under state.root) or node_modules-only per scope.
91
- // "The bundle" is the FAMILY -- this State plus its parent when running as a sidecar (both under
92
- // one shared lockfile). KNOWN LIMITATION: package.json the resolver reads via fs.readFile
93
- // (DescriptionFilePlugin) that no family bundle carries still fails closed -- see tests/webpack.test.js TODO.
53
+ // As `stasis run --bundle=load`: an in-scope path comes from the bundle (getFile re-verifies the hash), never from
54
+ // disk. The bundle is the FAMILY: this State, then its parent where it is a sidecar (one shared lockfile). KNOWN
55
+ // LIMITATION: a package.json the resolver reads that no family bundle carries fails closed (tests/webpack.test.js).
94
56
  #applyLoadMode(compiler) {
95
57
  const state = this.#state
96
58
  const orig = compiler.inputFileSystem
97
-
98
- // True iff `p` is in scope for load mode. state.relative throws outside state.root (treated
99
- // as out-of-scope: system files, scratch).
100
- const inScope = (p) => {
101
- if (typeof p !== 'string') return false
102
- try { state.relative(p) } catch { return false }
103
- if (state.config.full) return true
104
- return state.inNodeModules(pathToFileURL(p).toString())
105
- }
106
-
107
- // Family = the sidecar's own app-graph bundle first, then its parent (loaders + resolution-path
108
- // dirs the ambient preload captured). Both share one lockfile; no parent -> just [state].
59
+ const served = (p) => typeof p === 'string' && p !== '' && servedByBundle(state, p)
109
60
  const family = state.parent ? [state, state.parent] : [state]
110
- // First family State that attests `url` (file or dir), else undefined. Pure existence check.
111
61
  const ownerOf = (url) => family.find((s) => s.getFsStat(url) !== undefined)
112
-
113
- // Serve from whichever family bundle carries the path; getFile re-verifies the hash and throws
114
- // on a miss (the fail-closed gate -- never a silent disk fallback).
62
+ // Throws on a miss: the fail-closed gate.
115
63
  const serveFromBundle = (p) => {
116
64
  const url = pathToFileURL(p).toString()
117
65
  return (ownerOf(url) ?? state).getFile(url).source
118
66
  }
119
67
 
120
- // Proxy (not Object.assign): CachedInputFileSystem stores state on `this`, so forwarded calls
121
- // must keep `this` = orig; we override only readFile and stat.
122
68
  const wrappedReadFile = (p, options, callback) => {
123
69
  if (typeof options === 'function') { callback = options; options = undefined }
124
- if (inScope(p)) {
70
+ if (served(p)) {
125
71
  try {
126
72
  const src = serveFromBundle(p)
127
- const buf = Buffer.isBuffer(src) ? src : Buffer.from(src)
128
- return callback(null, buf)
73
+ return callback(null, Buffer.isBuffer(src) ? src : Buffer.from(src))
129
74
  } catch (err) {
130
75
  return callback(err)
131
76
  }
132
77
  }
133
- // Forward `options` only when provided: CachedInputFileSystem.readFile is (path, callback);
134
- // an undefined middle arg lands in the callback slot and later crashes Storage.finished.
135
- if (options === undefined) return orig.readFile(p, callback)
136
- return orig.readFile(p, options, callback)
78
+ // CachedInputFileSystem.readFile is (path, callback): an undefined middle arg would land in the callback slot.
79
+ return options === undefined ? orig.readFile(p, callback) : orig.readFile(p, options, callback)
137
80
  }
138
81
  const wrappedStat = (p, callback) => {
139
- if (inScope(p)) {
140
- // Directories have no bundle content, so serve a synthetic dir Stats from the family's
141
- // existence record (getFsStat) -- webpack's resolver stat()s intermediate dirs while walking.
82
+ if (served(p)) {
83
+ // The resolver stat()s the dirs it walks, which have no bundle content but an existence record.
142
84
  const url = pathToFileURL(p).toString()
143
- if (ownerOf(url)?.getFsStat(url) === 'directory') {
144
- return callback(null, bundleStat(0, true))
145
- }
85
+ if (ownerOf(url)?.getFsStat(url) === 'directory') return callback(null, bundleStat(0, true))
146
86
  try {
147
87
  const src = serveFromBundle(p)
148
- // Byte length, not String.length -- a multibyte-UTF-8 source has fewer JS chars than bytes.
149
- const size = typeof src === 'string' ? Buffer.byteLength(src) : src.length
150
- return callback(null, bundleStat(size))
88
+ return callback(null, bundleStat(typeof src === 'string' ? Buffer.byteLength(src) : src.length))
151
89
  } catch (err) {
152
90
  return callback(err)
153
91
  }
154
92
  }
155
93
  return orig.stat(p, callback)
156
94
  }
95
+ // A Proxy, not Object.assign: CachedInputFileSystem keeps its state on `this`, so forwarded calls keep orig.
157
96
  compiler.inputFileSystem = new Proxy(orig, {
158
97
  get(target, prop) {
159
98
  if (prop === 'readFile') return wrappedReadFile
@@ -166,48 +105,29 @@ export class StasisWebpack {
166
105
  compiler.hooks.normalModuleFactory.tap('Stasis', (nmf) => {
167
106
  nmf.hooks.beforeResolve.tap('Stasis', (data) => {
168
107
  const issuer = data.contextInfo?.issuer
169
- if (!issuer) return // entries: already absolute, inputFileSystem covers them
170
- // Out-of-scope parents were never the bundle's responsibility -- defer to webpack's resolver.
171
- if (!inScope(issuer)) return
172
- // Built-ins are never bundled or recorded as edges; skip up front. SECURITY: never consult
173
- // getImport for a builtin name, so a tampered bundle can't redirect `fs` to an arbitrary file.
108
+ // An entry is absolute already, and an out-of-scope parent webpack's to resolve.
109
+ if (!issuer || !served(issuer)) return
110
+ // SECURITY: never consult getImport for a builtin, so a tampered bundle can't redirect `fs`.
174
111
  if (isBuiltin(data.request)) return
175
- const parentURL = pathToFileURL(issuer).toString()
176
- let resolved
177
- try {
178
- resolved = state.getImport(parentURL, data.request)
179
- } catch (err) {
180
- // No attested edge -- at a successful capture that's an EXTERNAL (never recorded as an
181
- // edge), so defer to webpack. File BYTES still fail closed at the inputFileSystem wrapper.
182
- // A non-MODULE_NOT_FOUND error is a real fault -- rethrow.
183
- if (err?.code === 'ERR_MODULE_NOT_FOUND') return
184
- throw err
185
- }
186
- // Redirect the resolver to the absolute bundle path; the stat wrapper then reports it exists.
112
+ const resolved = attestedImport(state, pathToFileURL(issuer).toString(), data.request)
113
+ if (resolved === undefined) return
114
+ // The stat wrapper then reports it exists.
187
115
  data.request = fileURLToPath(resolved.url)
188
116
  })
189
117
  })
190
118
  }
191
119
 
192
- // webpack 4's enhanced-resolve has no imports-field support, so babel-plugin-module-resolver
193
- // rewrites '#app/abc.js' subpaths in a loader before the parser -- by afterResolve the original
194
- // '#'-specifier is gone. Wrap the plugin's rewrite chokepoint (v5 lib/mapPath default export, or
195
- // v3/v4 lib/utils.mapPathString) to record (importer, rewritten) -> original, so afterResolve can
196
- // key the edge by the portable original. Best-effort, side-effect-only passthrough; absent package
197
- // skips the patch; PATCHED guards against re-wrapping.
120
+ // webpack 4's enhanced-resolve has no imports field, so babel-plugin-module-resolver rewrites '#app/abc.js' before
121
+ // the parser and afterResolve sees only the rewrite. Wrapping its rewrite chokepoint (v5's lib/mapPath default, or
122
+ // v3/v4's lib/utils.mapPathString) records the original, so the edge is keyed by the portable specifier.
198
123
  #patchModuleResolver() {
199
124
  const paths = [this.#state.root, process.cwd()]
200
- // Deep /lib/... requires resolve (no "exports" map). Try v5 then v3/v4; wrap every shape present.
201
- const candidates = [
202
- { sub: 'babel-plugin-module-resolver/lib/mapPath', prop: 'default' },
203
- { sub: 'babel-plugin-module-resolver/lib/utils', prop: 'mapPathString' },
204
- ]
205
- for (const { sub, prop } of candidates) {
125
+ for (const [sub, prop] of [['babel-plugin-module-resolver/lib/mapPath', 'default'], ['babel-plugin-module-resolver/lib/utils', 'mapPathString']]) {
206
126
  let mod
207
127
  try {
208
128
  mod = require(require.resolve(sub, { paths }))
209
129
  } catch {
210
- continue // package absent, or this major lacks this submodule
130
+ continue // absent, or a major without this submodule
211
131
  }
212
132
  const original = mod?.[prop]
213
133
  if (typeof original !== 'function' || original[PATCHED]) continue
@@ -216,21 +136,15 @@ export class StasisWebpack {
216
136
  const result = original.apply(this, arguments)
217
137
  try {
218
138
  const after = nodePath?.node?.value
219
- // Only '#'-prefixed subpaths are recovered (other aliases keep webpack's rawRequest);
220
- // an unchanged node records nothing.
139
+ const importer = state?.file?.opts?.filename
221
140
  if (typeof before === 'string' && before.startsWith('#') &&
222
- typeof after === 'string' && after !== before) {
223
- const importer = state?.file?.opts?.filename
224
- if (typeof importer === 'string') {
225
- // Append (deduped), don't overwrite: aliases colliding on one key each need an edge.
226
- const key = edgeKey(path.resolve(importer), after)
227
- const originals = subpathOriginals.get(key)
228
- if (originals === undefined) subpathOriginals.set(key, [before])
229
- else if (!originals.includes(before)) originals.push(before)
230
- }
141
+ typeof after === 'string' && after !== before && typeof importer === 'string') {
142
+ const key = edgeKey(path.resolve(importer), after)
143
+ const originals = subpathOriginals.get(key) ?? []
144
+ if (!originals.includes(before)) subpathOriginals.set(key, [...originals, before])
231
145
  }
232
146
  } catch {
233
- // A recorder fault must never break the build -- the rewrite already ran above.
147
+ // A recorder fault must never break the build: the rewrite already ran.
234
148
  }
235
149
  return result
236
150
  }
@@ -241,14 +155,11 @@ export class StasisWebpack {
241
155
 
242
156
  #applyCaptureMode(compiler) {
243
157
  this.#captureApplyCount += 1
244
- // Install the module-resolver hook now (apply() runs at webpack() construction, before
245
- // compiler.run()), so the recorder is populated before afterResolve looks an edge up.
158
+ // apply() runs at webpack() construction, before compiler.run(), so before any afterResolve.
246
159
  this.#patchModuleResolver()
247
160
  compiler.hooks.normalModuleFactory.tap('Stasis', (nmf) => {
248
- // webpack 4's resolver reuses rrd.context.issuer across factory invocations, so by
249
- // afterResolve it can hold an EARLIER resolution's issuer (repro: webpack@4.15.1/4.47.0, see
250
- // tests/fixtures/webpack-stale-issuer-repro/NOTES.md). beforeResolve's contextInfo.issuer is
251
- // fresh, so capture it there in a WeakMap keyed by dep[0] (the same ref flows through create()).
161
+ // webpack 4 reuses rrd.context.issuer across factory calls, so by afterResolve it can hold an earlier
162
+ // resolution's (tests/fixtures/webpack-stale-issuer-repro/NOTES.md); beforeResolve's is fresh, by dep[0].
252
163
  const issuerByDep = new WeakMap()
253
164
  nmf.hooks.beforeResolve.tap('Stasis', (data) => {
254
165
  const dep = data?.dependencies?.[0]
@@ -257,61 +168,35 @@ export class StasisWebpack {
257
168
  })
258
169
 
259
170
  nmf.hooks.afterResolve.tap('Stasis', (data) => {
260
- // Cross-version unwrap: webpack 5 nests rawRequest/resourceResolveData under createData;
261
- // webpack 4 hangs them off data. Fall through to data when createData is absent.
171
+ // webpack 5 nests rawRequest/resourceResolveData under createData.
262
172
  const cd = data.createData ?? data
263
- // Track the resolved on-disk path (resourceResolveData.path, no query/fragment). Silently
264
- // skip data: URIs, null-loader stubs, and any resolution with no on-disk file to hash.
173
+ // No on-disk file to hash: data: URIs, null-loader stubs.
265
174
  const filePath = cd.resourceResolveData?.path
266
175
  if (!filePath || !path.isAbsolute(filePath) || !existsSync(filePath)) return
267
-
268
- const kind = classifyExtension(filePath, this.#resources)
269
- if (kind === 'unknown') {
270
- throw new Error(
271
- `StasisWebpack: unsupported extension for '${filePath}' -- ` +
272
- `add its extension or filename to the plugin's resources option or stop importing it`
273
- )
274
- }
275
-
176
+ const kind = classifyOrThrow('StasisWebpack', filePath, this.#resources)
276
177
  const url = pathToFileURL(filePath).toString()
277
- // Issuer attribution, most reliable first: (1) beforeResolve WeakMap (dodges the webpack 4
278
- // stale-issuer bug), (2) contextInfo.issuer (fresh on webpack 5), (3) rrd.context.issuer
279
- // (webpack 4 only; enhanced-resolve 5 removed it).
178
+ // rrd.context.issuer is webpack 4's alone, removed in enhanced-resolve 5.
280
179
  const dep = data?.dependencies?.[0]
281
- const issuer = (dep && issuerByDep.get(dep))
282
- ?? data.contextInfo?.issuer
283
- ?? cd.resourceResolveData?.context?.issuer
180
+ const issuer = (dep && issuerByDep.get(dep)) ?? data.contextInfo?.issuer ?? cd.resourceResolveData?.context?.issuer
284
181
  const isEntry = !issuer
285
182
  const rawRequest = cd.rawRequest ?? data.rawRequest
286
-
287
183
  if (kind === 'code' && !isEntry) {
288
184
  const issuerAbsolute = path.resolve(issuer)
289
185
  const parentURL = pathToFileURL(issuerAbsolute).toString()
290
- // If babel-plugin-module-resolver rewrote '#' subpaths into this rawRequest, key the edge
291
- // by each recorded ORIGINAL (portable, round-trips at load) instead of the relative path;
292
- // no rewrite -> fall back to rawRequest.
293
- const originals = subpathOriginals.get(edgeKey(issuerAbsolute, rawRequest))
294
- for (const specifier of originals ?? [rawRequest]) {
186
+ for (const specifier of subpathOriginals.get(edgeKey(issuerAbsolute, rawRequest)) ?? [rawRequest]) {
295
187
  this.#state.addImport(parentURL, specifier, url)
296
188
  }
297
189
  }
298
-
299
190
  if (!this.#seen.has(filePath)) {
300
191
  this.#seen.add(filePath)
301
- // Real reader (state-util.js): plugin bookkeeping, must not be re-recorded under --fs.
302
192
  const source = realReadFileSync(filePath)
303
- // Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
304
- if (kind === 'code') {
305
- assert.ok(isUtf8(source), `StasisWebpack: code-classified file has non-UTF-8 bytes: ${filePath}`)
306
- }
193
+ if (kind === 'code') assertCodeUtf8('StasisWebpack', filePath, source)
307
194
  this.#state.addFile(url, { source, isEntry, resource: kind === 'resource', reason: 'webpack' })
308
195
  }
309
196
  })
310
197
  })
311
198
 
312
- // Watch-mode capture is unsupported: dedupe is keyed by PATH not content, so a rebuild with
313
- // changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. watchRun fires
314
- // only under compiler.watch(), before the first build, so throwing here refuses the whole session.
199
+ // watchRun fires only under compiler.watch(), before the first build, so this refuses the whole session.
315
200
  compiler.hooks.watchRun.tap('Stasis', () => {
316
201
  throw new Error(
317
202
  'StasisWebpack: watch mode is not supported for capture -- ' +
@@ -319,33 +204,28 @@ export class StasisWebpack {
319
204
  )
320
205
  })
321
206
 
322
- // The preload writes itself (hooks.js); standalone/sidecar States are written on compiler
323
- // finish -- only on a clean build (a partial one would overwrite a good lockfile).
207
+ // The preload writes itself; a failed build mustn't overwrite a good lockfile.
324
208
  if (this.#state !== State.preload) {
325
209
  compiler.hooks.done.tap('Stasis', (stats) => {
326
210
  if (stats.hasErrors()) return
327
- // Single compiler: write now (one `done`). Multi-compiler: defer -- `done` fires per child,
328
- // so writing each time pays N super-linear brotli passes over a growing bundle; coalesce to one.
211
+ // `done` fires per child of a MultiCompiler: one write, not N brotli passes over a growing bundle.
329
212
  if (this.#captureApplyCount === 1) this.#state.write()
330
213
  else this.#deferWrite()
331
214
  })
332
215
  }
333
216
  }
334
217
 
335
- // Mark dirty and register a single end-of-process flush so a MultiCompiler's per-child `done`
336
- // events coalesce into one write of the final bundle. KNOWN LIMITATION: the flush runs at
337
- // beforeExit/exit, so a signal death (SIGINT/SIGTERM/SIGKILL) bypasses it -- same capture-then-exit
338
- // assumption as the preload's write (hooks.js).
218
+ // A single flush at beforeExit, with exit as the sync backstop for process.exit(). KNOWN LIMITATION: a signal death
219
+ // bypasses it, as it does the preload's write.
339
220
  #deferWrite() {
340
221
  this.#deferredWriteDirty = true
341
222
  if (this.#deferredWriteArmed) return
342
223
  this.#deferredWriteArmed = true
343
224
  const flush = () => {
344
- if (!this.#deferredWriteDirty) return // already flushed; exit backstop is a no-op
345
- this.#state.write() // sync write; on throw, dirty stays set so exit retries
346
- this.#deferredWriteDirty = false // clear ONLY after a successful write
225
+ if (!this.#deferredWriteDirty) return
226
+ this.#state.write() // on a throw, dirty stays set so exit retries
227
+ this.#deferredWriteDirty = false
347
228
  }
348
- // beforeExit for the natural drain; exit is the sync backstop for process.exit() (write is sync).
349
229
  process.on('beforeExit', flush)
350
230
  process.on('exit', flush)
351
231
  }