@exodus/stasis-plugins 1.0.0-beta.2 → 1.0.0-beta.3
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/package.json +2 -2
- package/src/esbuild.js +62 -112
- package/src/metro-transformer.js +32 -72
- package/src/metro.js +315 -253
- package/src/plugins.js +49 -87
- package/src/webpack.js +84 -294
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
|
-
//
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
|
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
|
|
43
|
-
//
|
|
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 (
|
|
82
|
-
//
|
|
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
|
|
89
|
-
//
|
|
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
|
-
//
|
|
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
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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
|
|
157
|
-
//
|
|
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
|
-
//
|
|
168
|
-
// ambient preload
|
|
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
|
-
//
|
|
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
|
|
187
|
-
//
|
|
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
|
-
//
|
|
197
|
-
//
|
|
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
|
-
//
|
|
212
|
-
//
|
|
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
|
-
//
|
|
221
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
264
|
-
//
|
|
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
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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;
|
|
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
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
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
|
-
//
|
|
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
|
|
348
|
-
//
|
|
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:
|
|
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
|
|
377
|
-
//
|
|
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
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
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
|
|
406
|
-
//
|
|
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
|
-
//
|
|
413
|
-
// data
|
|
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
|
|
435
|
-
//
|
|
436
|
-
//
|
|
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
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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: '
|
|
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
|
|
488
|
-
//
|
|
489
|
-
//
|
|
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
|
|
507
|
-
//
|
|
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
|
-
//
|
|
514
|
-
//
|
|
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
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
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;
|
|
553
|
-
this.#state.write() //
|
|
554
|
-
this.#deferredWriteDirty = false //
|
|
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
|
|
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
|
}
|