@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/LICENSE +21 -0
- package/README.md +9 -0
- package/package.json +36 -0
- package/src/esbuild.js +224 -0
- package/src/metro-transformer.js +125 -0
- package/src/metro.js +290 -0
- package/src/plugins.js +204 -0
- package/src/webpack.js +547 -0
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
|
+
}
|