@exodus/stasis-plugins 1.0.0-beta.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 ADDED
@@ -0,0 +1,547 @@
1
+ import assert from 'node:assert/strict'
2
+ import { isUtf8 } from 'node:buffer'
3
+ import { existsSync } from 'node:fs'
4
+ import { createRequire, isBuiltin } from 'node:module'
5
+ import path from 'node:path'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+
8
+ import { resolvePluginState } from './plugins.js'
9
+ import { State } from '@exodus/stasis-core/state'
10
+ import { realReadFileSync } from '@exodus/stasis-core/state-util'
11
+ import { classifyExtension } from '@exodus/stasis-core/util'
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.
17
+ const require = createRequire(import.meta.url)
18
+
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.
35
+ const subpathOriginals = new Map()
36
+ const edgeKey = (importerAbsolute, request) => `${importerAbsolute}\u0000${request}`
37
+
38
+ // Marks an already-wrapped chokepoint so watch rebuilds / multiple plugin instances
39
+ // don't double-wrap babel-plugin-module-resolver.
40
+ const PATCHED = Symbol('stasis.modresolver.patched')
41
+
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).
46
+ function bundleStat(size, isDir = false) {
47
+ return {
48
+ isFile: () => !isDir,
49
+ isDirectory: () => isDir,
50
+ isSymbolicLink: () => false,
51
+ isBlockDevice: () => false,
52
+ isCharacterDevice: () => false,
53
+ isFIFO: () => false,
54
+ isSocket: () => false,
55
+ size,
56
+ mtime: new Date(0),
57
+ mtimeMs: 0,
58
+ atime: new Date(0),
59
+ atimeMs: 0,
60
+ ctime: new Date(0),
61
+ ctimeMs: 0,
62
+ birthtime: new Date(0),
63
+ birthtimeMs: 0,
64
+ mode: isDir ? 0o040755 : 0o100644,
65
+ uid: 0,
66
+ gid: 0,
67
+ ino: 0,
68
+ nlink: 1,
69
+ rdev: 0,
70
+ blksize: 4096,
71
+ blocks: 0,
72
+ dev: 0,
73
+ }
74
+ }
75
+
76
+ export class StasisWebpack {
77
+ #seen = new Set()
78
+ #state
79
+ #resources
80
+
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).
86
+ #captureApplyCount = 0
87
+
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.
92
+ #deferredWriteArmed = false
93
+ #deferredWriteDirty = false
94
+
95
+ constructor(options = {}) {
96
+ const { state } = resolvePluginState('StasisWebpack', options, process.cwd())
97
+ 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.
100
+ this.#resources = state?.config.resources ?? new Set()
101
+ }
102
+
103
+ apply(compiler) {
104
+ if (!this.#state) return
105
+ if (this.#state.config.loadBundle) {
106
+ this.#applyLoadMode(compiler)
107
+ return
108
+ }
109
+ this.#applyCaptureMode(compiler)
110
+ }
111
+
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.
152
+ #applyLoadMode(compiler) {
153
+ const state = this.#state
154
+ const orig = compiler.inputFileSystem
155
+
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.
160
+ const inScope = (p) => {
161
+ if (typeof p !== 'string') return false
162
+ try { state.relative(p) } catch { return false }
163
+ if (state.config.full) return true
164
+ return state.inNodeModules(pathToFileURL(p).toString())
165
+ }
166
+
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.
180
+ 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.
184
+ const ownerOf = (url) => family.find((s) => s.getFsStat(url) !== undefined)
185
+
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.
191
+ const serveFromBundle = (p) => {
192
+ const url = pathToFileURL(p).toString()
193
+ return (ownerOf(url) ?? state).getFile(url).source
194
+ }
195
+
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.
200
+ const wrappedReadFile = (p, options, callback) => {
201
+ if (typeof options === 'function') { callback = options; options = undefined }
202
+ if (inScope(p)) {
203
+ try {
204
+ const src = serveFromBundle(p)
205
+ const buf = Buffer.isBuffer(src) ? src : Buffer.from(src)
206
+ return callback(null, buf)
207
+ } catch (err) {
208
+ return callback(err)
209
+ }
210
+ }
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(...)`.
215
+ if (options === undefined) return orig.readFile(p, callback)
216
+ return orig.readFile(p, options, callback)
217
+ }
218
+ const wrappedStat = (p, callback) => {
219
+ 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.
228
+ const url = pathToFileURL(p).toString()
229
+ if (ownerOf(url)?.getFsStat(url) === 'directory') {
230
+ return callback(null, bundleStat(0, true))
231
+ }
232
+ try {
233
+ 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.
238
+ const size = typeof src === 'string' ? Buffer.byteLength(src) : src.length
239
+ return callback(null, bundleStat(size))
240
+ } catch (err) {
241
+ return callback(err)
242
+ }
243
+ }
244
+ return orig.stat(p, callback)
245
+ }
246
+ compiler.inputFileSystem = new Proxy(orig, {
247
+ get(target, prop) {
248
+ if (prop === 'readFile') return wrappedReadFile
249
+ if (prop === 'stat') return wrappedStat
250
+ const v = Reflect.get(target, prop, target)
251
+ return typeof v === 'function' ? v.bind(target) : v
252
+ },
253
+ })
254
+
255
+ compiler.hooks.normalModuleFactory.tap('Stasis', (nmf) => {
256
+ nmf.hooks.beforeResolve.tap('Stasis', (data) => {
257
+ const issuer = data.contextInfo?.issuer
258
+ 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.
262
+ 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.
270
+ if (isBuiltin(data.request)) return
271
+ const parentURL = pathToFileURL(issuer).toString()
272
+ let resolved
273
+ try {
274
+ resolved = state.getImport(parentURL, data.request)
275
+ } 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.
289
+ if (err?.code === 'ERR_MODULE_NOT_FOUND') return
290
+ throw err
291
+ }
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.
295
+ data.request = fileURLToPath(resolved.url)
296
+ })
297
+ })
298
+ }
299
+
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.
324
+ #patchModuleResolver() {
325
+ 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).
329
+ const candidates = [
330
+ { sub: 'babel-plugin-module-resolver/lib/mapPath', prop: 'default' },
331
+ { sub: 'babel-plugin-module-resolver/lib/utils', prop: 'mapPathString' },
332
+ ]
333
+ for (const { sub, prop } of candidates) {
334
+ let mod
335
+ try {
336
+ mod = require(require.resolve(sub, { paths }))
337
+ } catch {
338
+ continue // package absent, or this major lacks this submodule
339
+ }
340
+ const original = mod?.[prop]
341
+ if (typeof original !== 'function' || original[PATCHED]) continue
342
+ const wrapped = function stasisModuleResolverHook(nodePath, state) {
343
+ const before = nodePath?.node?.value
344
+ const result = original.apply(this, arguments)
345
+ try {
346
+ 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.
350
+ if (typeof before === 'string' && before.startsWith('#') &&
351
+ typeof after === 'string' && after !== before) {
352
+ const importer = state?.file?.opts?.filename
353
+ 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.
357
+ const key = edgeKey(path.resolve(importer), after)
358
+ const originals = subpathOriginals.get(key)
359
+ if (originals === undefined) subpathOriginals.set(key, [before])
360
+ else if (!originals.includes(before)) originals.push(before)
361
+ }
362
+ }
363
+ } catch {
364
+ // A recorder fault must never break the build -- the rewrite already ran above.
365
+ }
366
+ return result
367
+ }
368
+ wrapped[PATCHED] = true
369
+ mod[prop] = wrapped
370
+ }
371
+ }
372
+
373
+ #applyCaptureMode(compiler) {
374
+ // Count compilers this instance drives (final by the first `done`; see the field comment).
375
+ 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.
379
+ this.#patchModuleResolver()
380
+ 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.
397
+ const issuerByDep = new WeakMap()
398
+ nmf.hooks.beforeResolve.tap('Stasis', (data) => {
399
+ const dep = data?.dependencies?.[0]
400
+ const issuer = data?.contextInfo?.issuer
401
+ if (dep && issuer) issuerByDep.set(dep, issuer)
402
+ })
403
+
404
+ 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.
411
+ 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.
422
+ const filePath = cd.resourceResolveData?.path
423
+ if (!filePath || !path.isAbsolute(filePath) || !existsSync(filePath)) return
424
+
425
+ const kind = classifyExtension(filePath, this.#resources)
426
+ if (kind === 'unknown') {
427
+ throw new Error(
428
+ `StasisWebpack: unsupported extension for '${filePath}' -- ` +
429
+ `add its extension or filename to the plugin's resources option or stop importing it`
430
+ )
431
+ }
432
+
433
+ 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.)
444
+ const dep = data?.dependencies?.[0]
445
+ const issuer = (dep && issuerByDep.get(dep))
446
+ ?? data.contextInfo?.issuer
447
+ ?? cd.resourceResolveData?.context?.issuer
448
+ const isEntry = !issuer
449
+ const rawRequest = cd.rawRequest ?? data.rawRequest
450
+
451
+ if (kind === 'code' && !isEntry) {
452
+ const issuerAbsolute = path.resolve(issuer)
453
+ 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.
462
+ const originals = subpathOriginals.get(edgeKey(issuerAbsolute, rawRequest))
463
+ for (const specifier of originals ?? [rawRequest]) {
464
+ this.#state.addImport(parentURL, specifier, url)
465
+ }
466
+ }
467
+
468
+ if (!this.#seen.has(filePath)) {
469
+ 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.
473
+ 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.
479
+ if (kind === 'code') {
480
+ assert.ok(isUtf8(source), `StasisWebpack: code-classified file has non-UTF-8 bytes: ${filePath}`)
481
+ }
482
+ this.#state.addFile(url, { source, isEntry, resource: kind === 'resource', reason: 'StasisWebpack' })
483
+ }
484
+ })
485
+ })
486
+
487
+ // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
488
+ // Standalone and sidecar States have no such hook, so the plugin writes them when the
489
+ // compiler finishes -- but only on a clean build. A failed compilation has incomplete
490
+ // state, and lock=replace would otherwise overwrite a good lockfile with a partial one.
491
+ if (this.#state !== State.preload) {
492
+ // Watch-mode detection. compiler.hooks.watchRun fires ONLY under compiler.watch() (both
493
+ // webpack 4 and 5) and always before that build's `done`, so the flag is set in time.
494
+ // The flag is per-compiler (this closure), which is what we want: each compiler reports
495
+ // its own run kind even when one shared plugin instance drives several.
496
+ let watching = false
497
+ compiler.hooks.watchRun.tap('Stasis', () => { watching = true })
498
+ compiler.hooks.done.tap('Stasis', (stats) => {
499
+ if (stats.hasErrors()) return
500
+ // Write immediately when the bundle cannot grow across `done`s:
501
+ // - watch mode: a watcher keeps the event loop alive, so a deferred flush would never
502
+ // fire; per-rebuild freshness is the point, and State.write's compare-and-skip keeps
503
+ // an unchanged rebuild a ~no-op; and
504
+ // - a single compiler: only one `done`, so there is no N-write amplification and no
505
+ // reason to give up the old write-by-`done` durability (the bundle is on disk by the
506
+ // time compiler.run()'s callback resolves).
507
+ // Defer ONLY the multi-compiler one-shot case. compiler.hooks.done fires once PER CHILD
508
+ // COMPILER, so a MultiCompiler with N targets sharing this one State's growing bundle
509
+ // would otherwise pay N brotli + writeFileSync passes over an ever-larger payload (the
510
+ // dominant, super-linear cost). Deferral collapses those into ONE write of the final
511
+ // bundle at process exit -- mirroring how the preload writes (hooks.js). esbuild / metro
512
+ // are deliberately NOT changed: their write hook fires once per build (no per-child
513
+ // amplification), and stasis build reads esbuild output in-process, so their synchronous
514
+ // write is load-bearing in a way webpack's per-child `done` write is not.
515
+ if (watching || this.#captureApplyCount === 1) this.#state.write()
516
+ else this.#deferWrite()
517
+ })
518
+ }
519
+ }
520
+
521
+ // Mark the State dirty and ensure a single end-of-process flush is registered, so a
522
+ // MultiCompiler's per-child-compiler `done` events coalesce into one write of the final
523
+ // bundle rather than re-serializing + brotli-compressing (quality 11, super-linear in size)
524
+ // an ever-growing bundle on every target's completion.
525
+ //
526
+ // KNOWN LIMITATION: the flush runs at process exit, so a multi-compiler one-shot build inside
527
+ // a process that never exits normally -- one that stays alive after building and is then
528
+ // killed by a signal (SIGINT/SIGTERM/SIGKILL), which bypass both beforeExit and exit -- would
529
+ // not flush. That is the same capture-then-exit assumption the preload already relies on
530
+ // (hooks.js) and metro documents; the common build-and-exit and watch (`--watch`, dev-server)
531
+ // flows are unaffected, and a single-compiler build never reaches here (it writes on `done`).
532
+ #deferWrite() {
533
+ this.#deferredWriteDirty = true
534
+ if (this.#deferredWriteArmed) return
535
+ this.#deferredWriteArmed = true
536
+ const flush = () => {
537
+ if (!this.#deferredWriteDirty) return // already flushed; the exit backstop is then a no-op
538
+ this.#state.write() // synchronous; if it throws, dirty stays set so the
539
+ this.#deferredWriteDirty = false // exit backstop retries -- clear ONLY after success
540
+ }
541
+ // beforeExit handles the natural event-loop drain; exit is the synchronous backstop for an
542
+ // explicit process.exit(). state.write() is fully synchronous (writeFileSync/brotliCompressSync),
543
+ // so it is safe in the exit handler, and the dirty guard makes the second of the two a no-op.
544
+ process.on('beforeExit', flush)
545
+ process.on('exit', flush)
546
+ }
547
+ }