@exodus/stasis-plugins 1.0.0-beta.2 → 1.0.0-beta.5

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
@@ -10,39 +10,21 @@ import { State } from '@exodus/stasis-core/state'
10
10
  import { realReadFileSync } from '@exodus/stasis-core/state-util'
11
11
  import { classifyExtension } from '@exodus/stasis-core/util'
12
12
 
13
- // A CJS require anchored at this ESM module, used only to optionally locate the
14
- // PROJECT's babel-plugin-module-resolver for the capture-time monkey-patch below.
15
- // stasis-plugins takes no dependency on it: the package is never imported statically, and
16
- // a project without it just skips the patch.
13
+ // CJS require anchored here, used only to locate the PROJECT's babel-plugin-module-resolver below.
17
14
  const require = createRequire(import.meta.url)
18
15
 
19
- // Original package.json "imports" subpath specifiers ('#app/abc.js') recovered by the
20
- // babel-plugin-module-resolver monkey-patch (see StasisWebpack#patchModuleResolver),
21
- // keyed by `${importerAbsolute}\0${rewrittenRequest}` -> the original '#'-specifier(s).
22
- // The value is an ARRAY: usually one, but more than one when several subpath aliases map to the
23
- // SAME file from the same importer (they all rewrite to the identical relative request, colliding
24
- // on one key). afterResolve records an edge for EACH, so no subpath import is silently dropped.
25
- // Module-level (not per-instance) so the single patch -- installed by whichever plugin
26
- // instance applies first -- and every instance's afterResolve lookup share one map; the
27
- // patch wrapper and the lookup both live in THIS module, so the closure reference agrees.
28
- // Intentionally never cleared: keys are deterministic (absolute importer + rewritten request)
29
- // and values are deduped, so a watch rebuild RE-RECORDS the same entries idempotently rather
30
- // than accumulating -- bounded by the project's '#'-edge count. Not scoped per build because a
31
- // parallel MultiCompiler would race a per-`done` clear,
32
- // and entries are tiny (~a path + a short specifier). The only unbounded case is a long-lived
33
- // process building MANY DISTINCT graphs (e.g. a build server with per-run temp dirs); that's
34
- // outside stasis's per-`stasis run`-process model, so the simplicity wins.
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).
35
20
  const subpathOriginals = new Map()
36
21
  const edgeKey = (importerAbsolute, request) => `${importerAbsolute}\u0000${request}`
37
22
 
38
- // Marks an already-wrapped chokepoint so watch rebuilds / multiple plugin instances
39
- // don't double-wrap babel-plugin-module-resolver.
23
+ // Marks an already-wrapped chokepoint so we don't double-wrap babel-plugin-module-resolver.
40
24
  const PATCHED = Symbol('stasis.modresolver.patched')
41
25
 
42
- // Synthetic stat for files served from the bundle in load mode. Webpack's resolver
43
- // + module factory call inputFileSystem.stat() to check existence and read size;
44
- // we don't keep the real mtime/mode/etc., they're not security-relevant here (the
45
- // lockfile already attests content via hash; getFile re-verifies on every read).
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).
46
28
  function bundleStat(size, isDir = false) {
47
29
  return {
48
30
  isFile: () => !isDir,
@@ -78,25 +60,19 @@ export class StasisWebpack {
78
60
  #state
79
61
  #resources
80
62
 
81
- // Number of compilers this (capture-mode) instance is applied to. webpack constructs every
82
- // compiler -- and runs every plugin's apply() -- before any build starts, so by the first
83
- // `done` this is final: 1 for a lone compiler, N when one shared instance drives a
84
- // MultiCompiler's N child compilers. Only the latter accumulates files into one State across
85
- // N `done`s, which is the case worth deferring (see the done hook).
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.
86
65
  #captureApplyCount = 0
87
66
 
88
- // Deferred-write bookkeeping for the MULTI-compiler one-shot case, see #deferWrite. #armed:
89
- // whether the single beforeExit/exit flush pair has been registered (idempotent across the
90
- // N child-compiler `done`s a MultiCompiler fires). #dirty: a clean build completed since the
91
- // last flush, so there is something to write.
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.
92
69
  #deferredWriteArmed = false
93
70
  #deferredWriteDirty = false
94
71
 
95
72
  constructor(options = {}) {
96
73
  const { state } = resolvePluginState('StasisWebpack', options, process.cwd())
97
74
  this.#state = state // null when plugin should be inert
98
- // resources is a Config field now (validated + coordinated against any preload in
99
- // resolvePluginState); cache the resolved Set for the per-file classify hot path.
75
+ // Cache the resolved resources Set for the per-file classify hot path.
100
76
  this.#resources = state?.config.resources ?? new Set()
101
77
  }
102
78
 
@@ -109,54 +85,18 @@ export class StasisWebpack {
109
85
  this.#applyCaptureMode(compiler)
110
86
  }
111
87
 
112
- // Load mode: mirrors `stasis run --bundle=load` for the webpack plugin context.
113
- // The decision "serve from bundle vs defer to disk" depends solely on bundle
114
- // mode + scope, never on whether the path happens to live in the bundle -- a
115
- // missing in-scope file is a hard error, not a silent disk fallback.
116
- // In scope:
117
- // - full scope: every path under state.root is in scope; everything outside
118
- // the project root is genuinely external (system files, OS paths) and
119
- // defers to disk
120
- // - node_modules scope: node_modules paths under state.root only; app
121
- // sources are deliberately disk-served
122
- // Wiring:
123
- // - inputFileSystem.readFile / stat: in-scope paths MUST come from the
124
- // bundle (getFile re-verifies the hash against the lockfile); a path no
125
- // bundle carries throws rather than falling back to disk. "The bundle"
126
- // here is the FAMILY -- this State plus its parent when the plugin runs
127
- // as a sidecar (its app-graph bundle alongside the ambient preload's
128
- // build-closure bundle, both under the one shared lockfile). stat serves
129
- // a synthetic directory Stats for any dir the family records (explicit
130
- // capture or one implied by a bundled descendant) so the resolver's walk
131
- // over intermediate dirs short-circuits without touching disk
132
- // - normalModuleFactory.hooks.beforeResolve: an in-scope (issuer, request)
133
- // edge the bundle attests is redirected to its absolute bundle path. A
134
- // request the bundle records no edge for is not a file the bundle carries
135
- // -- at a successful capture it was EXTERNALIZED (a Node built-in,
136
- // `electron` under an electron target, a user `externals` entry); those
137
- // are never recorded as edges, so we defer to webpack's resolver/externals
138
- // handling. This relaxes only the resolve-time EDGE lookup; the fail-closed
139
- // gate for in-scope file BYTES stays the inputFileSystem wrapper above,
140
- // which still serves every in-scope read from the bundle or throws.
141
- // - Out-of-scope paths defer to disk
142
- //
143
- // KNOWN LIMITATION: every path the resolver touches must be attested by some
144
- // family bundle. Intermediate-directory stats are covered (getFsStat over the
145
- // family, including dirs implied by a parent-bundle file -- e.g. a webpack
146
- // loader's directory). The residual gap is the package.json files webpack's
147
- // resolver reads via fs.readFile through DescriptionFilePlugin: the Node run
148
- // loader captures modules Node IMPORTS, not arbitrary resolver fs.readFile
149
- // calls, so a package.json neither bundle carries still fails closed here.
150
- // Capturing those (or a description-file shortcut) is the remaining work; see
151
- // `tests/webpack.test.js` and its skipped TODO.
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.
152
94
  #applyLoadMode(compiler) {
153
95
  const state = this.#state
154
96
  const orig = compiler.inputFileSystem
155
97
 
156
- // True iff `p` is a path the bundle is supposed to cover under load mode.
157
- // `state.relative` throws for paths outside `state.root`, which we treat as
158
- // out-of-scope (system files, /tmp scratch, ...). state.inNodeModules is
159
- // itself tolerant of un-canonicalizable URLs.
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).
160
100
  const inScope = (p) => {
161
101
  if (typeof p !== 'string') return false
162
102
  try { state.relative(p) } catch { return false }
@@ -164,39 +104,21 @@ export class StasisWebpack {
164
104
  return state.inNodeModules(pathToFileURL(p).toString())
165
105
  }
166
106
 
167
- // The plugin's State may be a SIDECAR: when its bundleFile differs from the
168
- // ambient preload's, resolvePluginState (Rule 6) gives the plugin its own
169
- // State carrying the webpack app graph, parented to the ambient. The webpack
170
- // app graph lives in the sidecar's bundle, but build-time machinery the
171
- // resolver must see -- webpack loaders (babel-loader, ts-loader, ...) and the
172
- // directories/package.json along their resolution path -- lives in the PARENT
173
- // (ambient) bundle, captured when `stasis run` drove the build. Both bundles
174
- // validate against the SAME shared lockfile (the sidecar inherits the parent's
175
- // #lockImports/#lockFormats), so the attested set the build may touch is the
176
- // whole family: this State plus its parent. Consult them in order -- the
177
- // sidecar's own app graph first -- so a path either bundle attests is served
178
- // from the bundle that carries it. With no parent (the reuse case: one unified
179
- // bundle) the family is just `state`, and behavior is unchanged.
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].
180
109
  const family = state.parent ? [state, state.parent] : [state]
181
- // The first family State that attests `url` (as a file or directory), else
182
- // undefined. getFsStat is a pure existence check over each bundle's recorded
183
- // files/resources/dirs; it never reads bytes or touches disk.
110
+ // First family State that attests `url` (file or dir), else undefined. Pure existence check.
184
111
  const ownerOf = (url) => family.find((s) => s.getFsStat(url) !== undefined)
185
112
 
186
- // getFile re-verifies the hash against the lockfile on every read (tampered
187
- // bundle fails closed). Serve from whichever family bundle carries the path;
188
- // a path NO family bundle attests falls through to state.getFile, whose throw
189
- // is the canonical fail-closed gate -- an in-scope miss surfaces here, never
190
- // a silent disk fallback.
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).
191
115
  const serveFromBundle = (p) => {
192
116
  const url = pathToFileURL(p).toString()
193
117
  return (ownerOf(url) ?? state).getFile(url).source
194
118
  }
195
119
 
196
- // Wrap inputFileSystem. CachedInputFileSystem stores internal state on `this`,
197
- // so a naive Object.create or Object.assign would strand methods like readdir
198
- // / lstat / purge that aren't intercepted; a Proxy keeps `this` pointing at
199
- // orig for forwarded calls and only overrides readFile and stat.
120
+ // Proxy (not Object.assign): CachedInputFileSystem stores state on `this`, so forwarded calls
121
+ // must keep `this` = orig; we override only readFile and stat.
200
122
  const wrappedReadFile = (p, options, callback) => {
201
123
  if (typeof options === 'function') { callback = options; options = undefined }
202
124
  if (inScope(p)) {
@@ -208,33 +130,22 @@ export class StasisWebpack {
208
130
  return callback(err)
209
131
  }
210
132
  }
211
- // Only forward `options` when caller actually provided one. webpack's
212
- // CachedInputFileSystem.readFile is `(path, callback)`-shaped (no options
213
- // slot); passing `undefined` in the middle would land in its callback
214
- // position and Storage.finished would later try to invoke `undefined(...)`.
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.
215
135
  if (options === undefined) return orig.readFile(p, callback)
216
136
  return orig.readFile(p, options, callback)
217
137
  }
218
138
  const wrappedStat = (p, callback) => {
219
139
  if (inScope(p)) {
220
- // A directory has no bundle content, so serveFromBundle/getFile (which only
221
- // attests files) throws "file not attested" on it. webpack's resolver stat()s
222
- // intermediate directories (`<root>`, `<root>/node_modules`, `.../<pkg>`) while
223
- // walking, so serve a directory Stats from the family's existence record
224
- // (getFsStat covers explicit `directory` captures AND dirs implied by a bundled
225
- // file under them -- e.g. `node_modules/<loader>` implied by the loader's files
226
- // in the PARENT bundle). This is what the async resolver tripped on; the sync
227
- // path happened to work only because the Proxy wraps stat but not statSync.
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.
228
142
  const url = pathToFileURL(p).toString()
229
143
  if (ownerOf(url)?.getFsStat(url) === 'directory') {
230
144
  return callback(null, bundleStat(0, true))
231
145
  }
232
146
  try {
233
147
  const src = serveFromBundle(p)
234
- // Use byte length, not String.length -- a multibyte-UTF-8 code source
235
- // (sources are strings; see state.getFile) has fewer JS-chars than
236
- // bytes-on-disk. Webpack 4 only uses stat.size for display, but the
237
- // discrepancy would still be wrong if any downstream consumer slices.
148
+ // Byte length, not String.length -- a multibyte-UTF-8 source has fewer JS chars than bytes.
238
149
  const size = typeof src === 'string' ? Buffer.byteLength(src) : src.length
239
150
  return callback(null, bundleStat(size))
240
151
  } catch (err) {
@@ -256,76 +167,37 @@ export class StasisWebpack {
256
167
  nmf.hooks.beforeResolve.tap('Stasis', (data) => {
257
168
  const issuer = data.contextInfo?.issuer
258
169
  if (!issuer) return // entries: already absolute, inputFileSystem covers them
259
- // Out-of-scope parents (nm-scope app code, webpack-internal loader
260
- // resolution) were never the bundle's responsibility to attest -- defer
261
- // to webpack's resolver.
170
+ // Out-of-scope parents were never the bundle's responsibility -- defer to webpack's resolver.
262
171
  if (!inScope(issuer)) return
263
- // Node built-ins (`constants`, `fs`, `node:path`, ...) are never bundled and
264
- // never recorded as edges: the capture-side resolve hook and CJS resolution
265
- // shim (stasis-core/hooks.js) short-circuit isBuiltin() specifiers to Node
266
- // before addImport sees them. Skip them up front -- defer to webpack
267
- // (target:'node' externalizes them, a browser target polyfills them) and,
268
- // crucially, NEVER consult getImport for a builtin name, so a tampered
269
- // bundle can't smuggle in an edge that redirects `fs` to an arbitrary file.
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.
270
174
  if (isBuiltin(data.request)) return
271
175
  const parentURL = pathToFileURL(issuer).toString()
272
176
  let resolved
273
177
  try {
274
178
  resolved = state.getImport(parentURL, data.request)
275
179
  } catch (err) {
276
- // No attested edge. getImport tried the recorded edge under every conditions
277
- // bucket (resolveBundled), so a miss means the request names nothing the
278
- // bundle carries. At a SUCCESSFUL capture that is an EXTERNAL -- a module
279
- // webpack externalized instead of bundling: `electron` under an electron
280
- // target, anything the target's
281
- // externals preset covers, or a user `externals` entry. Externals are
282
- // never recorded as edges (afterResolve only records resolutions with an
283
- // on-disk path; see #applyCaptureMode), exactly like built-ins -- so defer
284
- // to webpack's resolver/externals handling rather than failing the build.
285
- // This relaxes ONLY the resolve-time edge lookup: a genuinely missing
286
- // in-scope FILE still fails closed at the inputFileSystem wrapper (it
287
- // serves in-scope bytes from the bundle or throws), so no disk fallback
288
- // is introduced. A non-MODULE_NOT_FOUND error is a real fault -- rethrow.
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.
289
183
  if (err?.code === 'ERR_MODULE_NOT_FOUND') return
290
184
  throw err
291
185
  }
292
- // Redirect the resolver to the absolute bundle path; our stat wrapper
293
- // then reports it exists, so the resolver short-circuits without
294
- // walking node_modules or trying extension fallbacks.
186
+ // Redirect the resolver to the absolute bundle path; the stat wrapper then reports it exists.
295
187
  data.request = fileURLToPath(resolved.url)
296
188
  })
297
189
  })
298
190
  }
299
191
 
300
- // babel-plugin-module-resolver is the babel plugin webpack-4 builds use to make
301
- // package.json "imports" subpaths resolvable: webpack 4's enhanced-resolve has no
302
- // imports-field support, so a '#app/abc.js' specifier MUST be rewritten in source
303
- // (to an importer-relative '../../abc.js') before webpack's parser sees it. That
304
- // rewrite happens in a LOADER, before the parser builds the dependency, so by
305
- // afterResolve webpack's rawRequest is the resolved relative path and the original
306
- // '#'-specifier is gone from every field. To record the ORIGINAL specifier as the
307
- // import key -- it's portable (resolves within the importer's OWN package regardless
308
- // of layout) and is what every other capture backend records (the Node loader's
309
- // resolve hook and Metro both key edges by the as-written specifier) -- hook the
310
- // rewrite at its source. The plugin has a single rewrite chokepoint that reads the
311
- // original `nodePath.node.value`, resolves it, then replaceWith()s the result;
312
- // across majors it is `lib/mapPath.js`'s default export (v5) or `lib/utils.js`'s
313
- // `mapPathString` (v3/v4). Wrap whichever the PROJECT'S copy exposes and record
314
- // (importer, rewritten) -> original for each '#'-rewrite; afterResolve then keys the
315
- // edge by the original instead of the resolved relative rawRequest.
316
- //
317
- // Best-effort and side-effect-only: a project without babel-plugin-module-resolver
318
- // (no '#'-rewriting in play) skips the patch entirely and capture is unchanged. The
319
- // wrapper is a transparent passthrough -- it never alters the rewrite, only observes
320
- // it -- so leaving it installed across watch rebuilds is harmless; PATCHED guards
321
- // against re-wrapping. stasis-plugins takes no dependency on it: it is located
322
- // via the project's own node_modules (createRequire) and absent-package errors are
323
- // swallowed.
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.
324
198
  #patchModuleResolver() {
325
199
  const paths = [this.#state.root, process.cwd()]
326
- // No `"exports"` map in any major, so deep `/lib/...` requires resolve fine. Try the
327
- // v5 chokepoint first, then the v3/v4 one; wrap every shape the copy actually exposes
328
- // (a stale-but-uninvoked export is never called, so a double-wrap can't double-record).
200
+ // Deep /lib/... requires resolve (no "exports" map). Try v5 then v3/v4; wrap every shape present.
329
201
  const candidates = [
330
202
  { sub: 'babel-plugin-module-resolver/lib/mapPath', prop: 'default' },
331
203
  { sub: 'babel-plugin-module-resolver/lib/utils', prop: 'mapPathString' },
@@ -344,16 +216,13 @@ export class StasisWebpack {
344
216
  const result = original.apply(this, arguments)
345
217
  try {
346
218
  const after = nodePath?.node?.value
347
- // Only package.json "imports" subpaths ('#'-prefixed) are recovered; any other
348
- // module-resolver alias keeps webpack's rawRequest. A node that didn't actually
349
- // change (after === before) records nothing.
219
+ // Only '#'-prefixed subpaths are recovered (other aliases keep webpack's rawRequest);
220
+ // an unchanged node records nothing.
350
221
  if (typeof before === 'string' && before.startsWith('#') &&
351
222
  typeof after === 'string' && after !== before) {
352
223
  const importer = state?.file?.opts?.filename
353
224
  if (typeof importer === 'string') {
354
- // Append, don't overwrite: two distinct subpath aliases that resolve to the same
355
- // file from this importer rewrite to the identical `after`, so they share one key.
356
- // Keeping both (deduped) lets afterResolve record an edge for each.
225
+ // Append (deduped), don't overwrite: aliases colliding on one key each need an edge.
357
226
  const key = edgeKey(path.resolve(importer), after)
358
227
  const originals = subpathOriginals.get(key)
359
228
  if (originals === undefined) subpathOriginals.set(key, [before])
@@ -371,29 +240,15 @@ export class StasisWebpack {
371
240
  }
372
241
 
373
242
  #applyCaptureMode(compiler) {
374
- // Count compilers this instance drives (final by the first `done`; see the field comment).
375
243
  this.#captureApplyCount += 1
376
- // Install the babel-plugin-module-resolver hook before the build runs (apply() is
377
- // called synchronously at webpack() construction, before compiler.run()), so the
378
- // recorder is populated by the loader pass before afterResolve looks an edge up.
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.
379
246
  this.#patchModuleResolver()
380
247
  compiler.hooks.normalModuleFactory.tap('Stasis', (nmf) => {
381
- // webpack 4's resolver reuses `resourceResolveData.context.issuer` across
382
- // factory invocations -- by the time `afterResolve` fires for a given
383
- // dependency, that field can hold the issuer from an EARLIER resolution.
384
- // The resolved path (rrd.path) is still correct, so webpack's bundle is
385
- // fine, but a plugin reading rrd.context.issuer for parent attribution
386
- // would mis-bucket the edge. Reproduced under webpack@4.15.1 and 4.47.0
387
- // (see tests/fixtures/webpack-stale-issuer-repro/NOTES.md for the
388
- // BEFORE-vs-AFTER trace evidence).
389
- //
390
- // beforeResolve's `data.contextInfo.issuer` is the fresh value passed
391
- // into create() for this exact dependency, so capture it there keyed
392
- // by the dependency object and look it up in afterResolve. The same
393
- // `data.dependencies[0]` reference flows through the create() pipeline
394
- // unchanged (NormalModuleFactory passes the array through to factory()
395
- // -> resolver -> afterResolve), so a WeakMap keyed on dep[0] keeps the
396
- // mapping correct without holding refs after the dep is GC'd.
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()).
397
252
  const issuerByDep = new WeakMap()
398
253
  nmf.hooks.beforeResolve.tap('Stasis', (data) => {
399
254
  const dep = data?.dependencies?.[0]
@@ -402,23 +257,11 @@ export class StasisWebpack {
402
257
  })
403
258
 
404
259
  nmf.hooks.afterResolve.tap('Stasis', (data) => {
405
- // Cross-version field unwrap: webpack 5 wraps `rawRequest` and
406
- // `resourceResolveData` inside `data.createData` (the bundle of fields
407
- // that will populate the NormalModule), whereas webpack 4 hangs them off
408
- // `data` directly. Same field names underneath, just one level of
409
- // indirection -- fall through to `data` when `createData` is absent so
410
- // both shapes flow through the same code path below.
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.
411
262
  const cd = data.createData ?? data
412
- // resourceResolveData.path is the resolved on-disk path without query/fragment;
413
- // data.resource may carry a `?query` (inline-loader, asset import suffixes) or
414
- // be a synthetic resource (data:, null-loader). We track the resolved file only.
415
- //
416
- // Silent skip: data: URIs, null-loader stubs, and any resolution that
417
- // produced no on-disk path bypass capture entirely. If the same underlying
418
- // file is reachable through a non-null-loader path elsewhere in the graph
419
- // it'll get captured then; otherwise it stays unattested. This matches
420
- // webpack's view -- those resolutions don't have a real source file we
421
- // could hash.
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.
422
265
  const filePath = cd.resourceResolveData?.path
423
266
  if (!filePath || !path.isAbsolute(filePath) || !existsSync(filePath)) return
424
267
 
@@ -431,16 +274,9 @@ export class StasisWebpack {
431
274
  }
432
275
 
433
276
  const url = pathToFileURL(filePath).toString()
434
- // Issuer attribution -- tier the sources from most reliable to least:
435
- // 1. beforeResolve-captured value (works on both webpack 4 and 5;
436
- // necessary on webpack 4 to dodge the stale rrd.context.issuer bug)
437
- // 2. data.contextInfo.issuer (fresh in webpack 5's afterResolve, often
438
- // undefined in webpack 4's, but harmless when set)
439
- // 3. createData.resourceResolveData.context.issuer (webpack 4 only --
440
- // enhanced-resolve 5 removed `context.issuer` from ResolveRequest, so
441
- // tier 3 is structurally dead under webpack 5. Kept as a defensive
442
- // last resort for any stacked plugin that reached afterResolve
443
- // without firing beforeResolve on webpack 4.)
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).
444
280
  const dep = data?.dependencies?.[0]
445
281
  const issuer = (dep && issuerByDep.get(dep))
446
282
  ?? data.contextInfo?.issuer
@@ -451,14 +287,9 @@ export class StasisWebpack {
451
287
  if (kind === 'code' && !isEntry) {
452
288
  const issuerAbsolute = path.resolve(issuer)
453
289
  const parentURL = pathToFileURL(issuerAbsolute).toString()
454
- // When babel-plugin-module-resolver rewrote one or more package.json "imports"
455
- // subpaths ('#app/abc.js') into this resolved-relative rawRequest, the monkey-patch
456
- // (#patchModuleResolver) recorded the original specifier(s) for this exact
457
- // (importer, rewritten-request) edge. Key the edge by each ORIGINAL instead of the
458
- // position-coupled relative path: it round-trips at load (where the same subpath
459
- // reaches beforeResolve) and activates state.js's resolveBundled '#'-handling. Several
460
- // aliases can collapse onto one rawRequest, so record an edge per original. No rewrite
461
- // seen -> fall back to webpack's rawRequest unchanged.
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.
462
293
  const originals = subpathOriginals.get(edgeKey(issuerAbsolute, rawRequest))
463
294
  for (const specifier of originals ?? [rawRequest]) {
464
295
  this.#state.addImport(parentURL, specifier, url)
@@ -467,35 +298,20 @@ export class StasisWebpack {
467
298
 
468
299
  if (!this.#seen.has(filePath)) {
469
300
  this.#seen.add(filePath)
470
- // Read through the REAL reader: this capture read is the plugin's own bookkeeping,
471
- // not a program fs read, so under `stasis run --fs` it must not be recorded into
472
- // the preload/main bundle (it belongs to THIS plugin's bundle). See state-util.js.
301
+ // Real reader (state-util.js): plugin bookkeeping, must not be re-recorded under --fs.
473
302
  const source = realReadFileSync(filePath)
474
- // Code-classified files must be UTF-8. A code extension with non-UTF-8
475
- // bytes is malformed input (e.g. a .json mis-saved as UTF-16); refuse
476
- // it rather than silently encoding it as a resource. Resource-classified
477
- // files are passed through as-is -- State picks 'resource' vs
478
- // 'resource:base64' from content.
303
+ // Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
479
304
  if (kind === 'code') {
480
305
  assert.ok(isUtf8(source), `StasisWebpack: code-classified file has non-UTF-8 bytes: ${filePath}`)
481
306
  }
482
- this.#state.addFile(url, { source, isEntry, resource: kind === 'resource', reason: 'StasisWebpack' })
307
+ this.#state.addFile(url, { source, isEntry, resource: kind === 'resource', reason: 'webpack' })
483
308
  }
484
309
  })
485
310
  })
486
311
 
487
- // Watch-mode capture is EXPLICITLY UNSUPPORTED -- refuse it loudly before anything is
488
- // captured. Capture's dedupe is keyed by PATH, not content (#seen above, plus State.write's
489
- // compare-and-skip caches), so on a watch rebuild where a file's bytes changed the plugin
490
- // would skip re-capture: the emitted output would use the new bytes while the bundle /
491
- // lockfile silently kept attesting the OLD ones. Rather than track content changes across
492
- // rebuilds, refuse watch outright. compiler.hooks.watchRun fires ONLY under
493
- // compiler.watch() (both webpack 4 and 5) and BEFORE the first watch build's compilation,
494
- // so this rejects the whole watch session up front, not just the second build. watchRun is
495
- // an AsyncSeriesHook: a sync `.tap` callback that throws propagates through the hook's call
496
- // chain and fails the watcher -- exactly the loud refusal we want, so keep it a plain tap.
497
- // Load mode is deliberately untouched (see #applyLoadMode): it serves immutable attested
498
- // bytes back into the build, so a dev-server rebuild has no drift hazard there.
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.
499
315
  compiler.hooks.watchRun.tap('Stasis', () => {
500
316
  throw new Error(
501
317
  'StasisWebpack: watch mode is not supported for capture -- ' +
@@ -503,59 +319,33 @@ export class StasisWebpack {
503
319
  )
504
320
  })
505
321
 
506
- // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
507
- // Standalone and sidecar States have no such hook, so the plugin writes them when the
508
- // compiler finishes -- but only on a clean build. A failed compilation has incomplete
509
- // state, and lock=replace would otherwise overwrite a good lockfile with a partial one.
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).
510
324
  if (this.#state !== State.preload) {
511
325
  compiler.hooks.done.tap('Stasis', (stats) => {
512
326
  if (stats.hasErrors()) return
513
- // Write immediately for a single compiler: only one `done`, so there is no N-write
514
- // amplification and no reason to give up the old write-by-`done` durability (the
515
- // bundle is on disk by the time compiler.run()'s callback resolves). Defer ONLY the
516
- // multi-compiler one-shot case. compiler.hooks.done fires once PER CHILD COMPILER, so
517
- // a MultiCompiler with N targets sharing this one State's growing bundle would
518
- // otherwise pay N brotli + writeFileSync passes over an ever-larger payload (the
519
- // dominant, super-linear cost). Deferral collapses those into ONE write of the final
520
- // bundle at process exit -- mirroring how the preload writes (hooks.js). Watch mode
521
- // never reaches this hook: capture refuses it at watchRun above. esbuild / metro
522
- // are deliberately NOT changed: their write hook fires once per build (no per-child
523
- // amplification), and stasis build reads esbuild output in-process, so their synchronous
524
- // write is load-bearing in a way webpack's per-child `done` write is not.
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.
525
329
  if (this.#captureApplyCount === 1) this.#state.write()
526
330
  else this.#deferWrite()
527
331
  })
528
332
  }
529
333
  }
530
334
 
531
- // Mark the State dirty and ensure a single end-of-process flush is registered, so a
532
- // MultiCompiler's per-child-compiler `done` events coalesce into one write of the final
533
- // bundle rather than re-serializing + brotli-compressing (quality 11, super-linear in size)
534
- // an ever-growing bundle on every target's completion.
535
- //
536
- // KNOWN LIMITATION: the flush runs at process exit, so a multi-compiler one-shot build inside
537
- // a process that never exits normally -- one that stays alive after building and is then
538
- // killed by a signal (SIGINT/SIGTERM/SIGKILL), which bypass both beforeExit and exit -- would
539
- // not flush. That is the same capture-then-exit assumption the preload's ROOT write relies
540
- // on (hooks.js). NB: the SIGTERM shard flush metro opts its workers into
541
- // (EXODUS_STASIS_SHARD_SIGNAL_FLUSH, hooks.js) does NOT help here and setting it wouldn't
542
- // either -- it covers a capturing CHILD's shard forward, while this deferred write is the
543
- // MAIN process's State.write via THIS plugin's own beforeExit/exit listeners, which a signal
544
- // death (or the loader's re-delivered SIGTERM) still bypasses. The common build-and-exit
545
- // flow is unaffected, a single-compiler build never reaches here (it writes on `done`), and
546
- // watch (`--watch`, dev-server) capture is refused outright at watchRun (see #applyCaptureMode).
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).
547
339
  #deferWrite() {
548
340
  this.#deferredWriteDirty = true
549
341
  if (this.#deferredWriteArmed) return
550
342
  this.#deferredWriteArmed = true
551
343
  const flush = () => {
552
- if (!this.#deferredWriteDirty) return // already flushed; the exit backstop is then a no-op
553
- this.#state.write() // synchronous; if it throws, dirty stays set so the
554
- this.#deferredWriteDirty = false // exit backstop retries -- clear ONLY after success
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
555
347
  }
556
- // beforeExit handles the natural event-loop drain; exit is the synchronous backstop for an
557
- // explicit process.exit(). state.write() is fully synchronous (writeFileSync/brotliCompressSync),
558
- // so it is safe in the exit handler, and the dirty guard makes the second of the two a no-op.
348
+ // beforeExit for the natural drain; exit is the sync backstop for process.exit() (write is sync).
559
349
  process.on('beforeExit', flush)
560
350
  process.on('exit', flush)
561
351
  }