@exodus/stasis-plugins 1.0.0-beta.14 → 1.0.0-beta.16

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