@exodus/stasis-plugins 1.0.0-beta.12 → 1.0.0-beta.14
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 +25 -22
- package/src/metro-babel-transformer.js +87 -23
- package/src/metro-native.js +180 -42
- package/src/metro-transformer.js +28 -35
- package/src/metro.js +223 -58
- package/src/plugins.js +48 -51
- package/src/rollup.js +11 -24
- package/src/webpack.js +11 -27
package/src/metro-transformer.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, isAbsolute, resolve as resolvePath } from 'node:path'
|
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
5
5
|
|
|
6
6
|
import { State } from '@exodus/stasis-core/state'
|
|
7
|
+
import { servedByBundle } from '@exodus/stasis-core/state-util'
|
|
7
8
|
import { transformContext } from './metro-babel-transformer.js'
|
|
8
9
|
|
|
9
10
|
// Companion to the StasisMetro serializer, which withStasis() wires as Metro's `transformerPath`
|
|
@@ -11,7 +12,8 @@ import { transformContext } from './metro-babel-transformer.js'
|
|
|
11
12
|
// wraps the project's own transformer, and does two things in each worker:
|
|
12
13
|
// - keeps each import as written for the serializer, through the Babel transformer it swaps in
|
|
13
14
|
// (./metro-babel-transformer.js): a dependency Babel rewrote (e.g. through
|
|
14
|
-
// babel-plugin-module-resolver) carries the specifiers written in the source as `stasisAsWritten
|
|
15
|
+
// babel-plugin-module-resolver) carries the specifiers written in the source as `stasisAsWritten`,
|
|
16
|
+
// and the output, the ones of the imports a Babel plugin consumed as `stasisConsumed`;
|
|
15
17
|
// - is the LOAD half of stasis for Metro (under load the serializer only asserts edges). Load only
|
|
16
18
|
// READS an immutable bundle and every worker reads the same bytes (no IPC), so a per-worker
|
|
17
19
|
// transformer is the natural seam: it replaces Metro's on-disk `data` with the bundle's
|
|
@@ -48,35 +50,18 @@ function baseConfig({ stasisBaseTransformerPath: _, ...config } = {}) {
|
|
|
48
50
|
// -> transparent pass-through, so wiring it permanently is safe. A construction failure is cached
|
|
49
51
|
// and rethrown on every call -- never throw-once-then-silently-pass-through. It never writes, so it
|
|
50
52
|
// claims no write path: a capturing worker's own preload State already holds them.
|
|
51
|
-
let
|
|
52
|
-
let loadState = null
|
|
53
|
-
let stateError = null
|
|
53
|
+
let loaded
|
|
54
54
|
function getLoadState() {
|
|
55
|
-
if (
|
|
55
|
+
if (loaded === undefined) {
|
|
56
56
|
try {
|
|
57
57
|
const state = new State(process.cwd(), { claim: false })
|
|
58
|
-
|
|
59
|
-
} catch (
|
|
60
|
-
|
|
61
|
-
throw err
|
|
62
|
-
} finally {
|
|
63
|
-
stateInited = true
|
|
58
|
+
loaded = { state: state.config.loadBundle ? state : null }
|
|
59
|
+
} catch (error) {
|
|
60
|
+
loaded = { error }
|
|
64
61
|
}
|
|
65
62
|
}
|
|
66
|
-
if (
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// True iff `absolute` is in scope for load mode. state.relative throws outside the project root
|
|
71
|
-
// (out-of-scope: system files, scratch) -> defer to disk. Mirrors the webpack plugin's inScope.
|
|
72
|
-
function inScope(state, absolute) {
|
|
73
|
-
try {
|
|
74
|
-
state.relative(absolute)
|
|
75
|
-
} catch {
|
|
76
|
-
return false
|
|
77
|
-
}
|
|
78
|
-
if (state.config.full) return true
|
|
79
|
-
return state.inNodeModules(pathToFileURL(absolute).toString())
|
|
63
|
+
if ('error' in loaded) throw loaded.error
|
|
64
|
+
return loaded.state
|
|
80
65
|
}
|
|
81
66
|
|
|
82
67
|
// The Babel transformer swapped in for the project's, by path as metro-transform-worker requires it.
|
|
@@ -88,17 +73,25 @@ function resolveBabelTransformer(base, path) {
|
|
|
88
73
|
return isAbsolute(path) ? path : createRequire(base.file).resolve(path)
|
|
89
74
|
}
|
|
90
75
|
|
|
91
|
-
// Each dependency named by a specifier Babel rewrote
|
|
92
|
-
// Metro dependency can stand for several, written apart and rewritten
|
|
93
|
-
// `./src/abc`)
|
|
94
|
-
|
|
95
|
-
|
|
76
|
+
// Each dependency as the source wrote it. One named by a specifier Babel rewrote carries the ones
|
|
77
|
+
// written in the source: one Metro dependency can stand for several, written apart and rewritten
|
|
78
|
+
// alike (`#local/abc` and `./src/abc`). One no specifier in the source names carries
|
|
79
|
+
// `stasisInserted`: Babel added it (a helper from @babel/runtime, the JSX runtime), or Metro did (its
|
|
80
|
+
// asyncRequire for an `import()`, an asset's registry). A dependency written as Metro names it is
|
|
81
|
+
// left as is. The imports a Babel plugin consumed, read and inlined in their stead
|
|
82
|
+
// (`require('./messages.po')`, @exodus/i18n's plugin-inline-po), no dependency of Metro's, ride on
|
|
83
|
+
// the output Metro keeps for the module, its first entry's data, as `stasisConsumed`.
|
|
84
|
+
function withAsWritten(result, { asWritten, consumed }) {
|
|
85
|
+
if (!Array.isArray(result?.dependencies)) return result
|
|
96
86
|
const dependencies = result.dependencies.map((dep) => {
|
|
97
87
|
const written = asWritten.get(dep.name)
|
|
98
|
-
if (written === undefined
|
|
88
|
+
if (written === undefined) return { ...dep, stasisInserted: true }
|
|
89
|
+
if (written.size === 1 && written.has(dep.name)) return dep
|
|
99
90
|
return { ...dep, stasisAsWritten: [...written] }
|
|
100
91
|
})
|
|
101
|
-
return { ...result, dependencies }
|
|
92
|
+
if (consumed.size === 0 || !Array.isArray(result.output) || result.output.length === 0) return { ...result, dependencies }
|
|
93
|
+
const [first, ...rest] = result.output
|
|
94
|
+
return { ...result, dependencies, output: [{ ...first, data: { ...first.data, stasisConsumed: [...consumed] } }, ...rest] }
|
|
102
95
|
}
|
|
103
96
|
|
|
104
97
|
export async function transform(config, projectRoot, filename, data, options) {
|
|
@@ -108,7 +101,7 @@ export async function transform(config, projectRoot, filename, data, options) {
|
|
|
108
101
|
if (state) {
|
|
109
102
|
// `filename` is project-relative in Metro's contract; resolve against projectRoot.
|
|
110
103
|
const absolute = resolvePath(projectRoot, filename)
|
|
111
|
-
if (
|
|
104
|
+
if (servedByBundle(state, absolute)) {
|
|
112
105
|
// getFile re-verifies the hash and throws on a file the bundle doesn't carry (fail-closed,
|
|
113
106
|
// no disk fallback). Code sources come back as strings; resources as Buffers.
|
|
114
107
|
const { source } = state.getFile(pathToFileURL(absolute).toString())
|
|
@@ -116,10 +109,10 @@ export async function transform(config, projectRoot, filename, data, options) {
|
|
|
116
109
|
}
|
|
117
110
|
}
|
|
118
111
|
if (typeof config.babelTransformerPath !== 'string') return base.transformer.transform(config, projectRoot, filename, data, options)
|
|
119
|
-
const context = { babelTransformerPath: resolveBabelTransformer(base, config.babelTransformerPath), asWritten: new Map() }
|
|
112
|
+
const context = { babelTransformerPath: resolveBabelTransformer(base, config.babelTransformerPath), asWritten: new Map(), consumed: new Set() }
|
|
120
113
|
const result = await transformContext.run(context, () =>
|
|
121
114
|
base.transformer.transform({ ...config, babelTransformerPath: BABEL_TRANSFORMER }, projectRoot, filename, data, options))
|
|
122
|
-
return withAsWritten(result, context
|
|
115
|
+
return withAsWritten(result, context)
|
|
123
116
|
}
|
|
124
117
|
|
|
125
118
|
export function getCacheKey(config, opts) {
|
package/src/metro.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import { isUtf8 } from 'node:buffer'
|
|
2
|
+
import { Buffer, isUtf8 } from 'node:buffer'
|
|
3
3
|
import { spawnSync } from 'node:child_process'
|
|
4
4
|
import { existsSync } from 'node:fs'
|
|
5
5
|
import { createRequire } from 'node:module'
|
|
6
|
-
import { isAbsolute, join } from 'node:path'
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
8
8
|
|
|
9
|
-
import { resolvePluginState } from './plugins.js'
|
|
10
|
-
import { looksNative, nativeInputs } from './metro-native.js'
|
|
9
|
+
import { resolvePluginState, unsupportedExtension } from './plugins.js'
|
|
10
|
+
import { AUTO_INCLUDES, FILE_STAT, RN_CORE_INCLUDE_FILES, bundleDirHost, looksNative, metroAssetFiles, nativeInputs } from './metro-native.js'
|
|
11
11
|
import { diskHost } from '@exodus/stasis-core/host'
|
|
12
12
|
import { State } from '@exodus/stasis-core/state'
|
|
13
|
-
import { realReadFileSync } from '@exodus/stasis-core/state-util'
|
|
14
|
-
import {
|
|
13
|
+
import { inScope, realReadFileSync } from '@exodus/stasis-core/state-util'
|
|
14
|
+
import { classifyExtension, classifyFormat, isPathWithin, nodeExtFormat, splitNodeModulesPath } from '@exodus/stasis-core/util'
|
|
15
15
|
|
|
16
16
|
const require = createRequire(import.meta.url)
|
|
17
17
|
|
|
@@ -33,12 +33,22 @@ function entrySet(graph) {
|
|
|
33
33
|
return new Set()
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
'
|
|
41
|
-
|
|
36
|
+
// Metro's own transform plugins, as its transform worker names them for its cache key: the file
|
|
37
|
+
// `metro-transform-plugins` resolves to from the project, else through metro and its transform
|
|
38
|
+
// worker, which depend on it (a pnpm install links it only beside them). -> [] where none resolves
|
|
39
|
+
function metroTransformPluginFiles(projectDir) {
|
|
40
|
+
for (const chain of [['metro-transform-plugins'], ['metro', 'metro-transform-worker', 'metro-transform-plugins']]) {
|
|
41
|
+
let file
|
|
42
|
+
try {
|
|
43
|
+
for (const name of chain) file = require.resolve(name, { paths: [file === undefined ? projectDir : dirname(file)] })
|
|
44
|
+
} catch {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
const { getTransformPluginCacheKeyFiles } = require(file)
|
|
48
|
+
return typeof getTransformPluginCacheKeyFiles === 'function' ? getTransformPluginCacheKeyFiles() : []
|
|
49
|
+
}
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
42
52
|
|
|
43
53
|
// The worker-side toolchain that only Metro's transform workers load, named in both the
|
|
44
54
|
// --child-process assert and the transform-cache warning so the two can't describe different sets.
|
|
@@ -135,6 +145,10 @@ function metroDefaultSerializer() {
|
|
|
135
145
|
// transformerPath -- out of stasis's hands (see withStasis).
|
|
136
146
|
export class StasisMetro {
|
|
137
147
|
#seen = new Set()
|
|
148
|
+
// The project root's real path, once a consumed resource needs it (#consumedResources).
|
|
149
|
+
#realRoot
|
|
150
|
+
// Under load, the bundle's files as a directory listing, once an asset needs them (#bundleDirHost).
|
|
151
|
+
#bundleDirs
|
|
138
152
|
#state
|
|
139
153
|
#resources
|
|
140
154
|
|
|
@@ -245,28 +259,29 @@ export class StasisMetro {
|
|
|
245
259
|
|
|
246
260
|
#capture(graph, preModules) {
|
|
247
261
|
const entries = entrySet(graph)
|
|
262
|
+
const platform = graph.transformOptions?.platform ?? null
|
|
248
263
|
// preModules first (Metro's prepended polyfills/runtime; synthetic ones like __prelude__ are
|
|
249
264
|
// skipped by the existsSync guard below), then the app graph.
|
|
250
|
-
if (preModules) this.#captureModules(preModules, entries)
|
|
251
|
-
this.#captureModules(graph.dependencies.values(), entries)
|
|
252
|
-
// AFTER the graph so #seen already holds graph modules -- an auto-include that landed in the
|
|
253
|
-
// graph (asyncRequire, when the app uses dynamic import()) is deduped, not re-recorded.
|
|
265
|
+
if (preModules) this.#captureModules(preModules, entries, platform)
|
|
266
|
+
this.#captureModules(graph.dependencies.values(), entries, platform)
|
|
254
267
|
this.#captureAutoIncludes()
|
|
268
|
+
this.#captureTransformPlugins()
|
|
255
269
|
// AFTER the graph + auto-includes (same dedupe reason): the native pass only adds the native
|
|
256
270
|
// build-input surface (podspec/gradle/sources) that no module graph carries.
|
|
257
271
|
this.#captureNativeModules()
|
|
258
272
|
}
|
|
259
273
|
|
|
260
|
-
// Shared epilogue for the classify-driven capture paths: record under the given format/resource
|
|
261
|
-
// `format` null/undefined lets addFile pick (loader format for JS unless
|
|
262
|
-
// byte-derived for a resource); addFile already asserts UTF-8 for a
|
|
263
|
-
// no guard is needed here.
|
|
264
|
-
#recordCapture(full, source, { format, resource, inferFormat = true }) {
|
|
265
|
-
this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, inferFormat, reason
|
|
274
|
+
// Shared epilogue for the classify-driven capture paths: record under the given format/resource,
|
|
275
|
+
// attributed to `reason`. `format` null/undefined lets addFile pick (loader format for JS unless
|
|
276
|
+
// `inferFormat` is false, byte-derived for a resource); addFile already asserts UTF-8 for a
|
|
277
|
+
// non-resource (code) source, so no guard is needed here.
|
|
278
|
+
#recordCapture(full, source, { format, resource, inferFormat = true, reason = 'metro' }) {
|
|
279
|
+
this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, inferFormat, reason })
|
|
266
280
|
}
|
|
267
281
|
|
|
268
|
-
// Attest the AUTO_INCLUDES list.
|
|
269
|
-
// outside the project root (keys are root-relative).
|
|
282
|
+
// Attest the AUTO_INCLUDES list (./metro-native.js, shared with `stasis bundle --metro`). Per entry,
|
|
283
|
+
// skip: unresolvable, already-seen, or resolving outside the project root (keys are root-relative).
|
|
284
|
+
// Attested entries ride the normal addFile path.
|
|
270
285
|
#captureAutoIncludes() {
|
|
271
286
|
for (const specifier of AUTO_INCLUDES) {
|
|
272
287
|
let modPath
|
|
@@ -275,21 +290,35 @@ export class StasisMetro {
|
|
|
275
290
|
} catch {
|
|
276
291
|
continue
|
|
277
292
|
}
|
|
278
|
-
if (this.#seen.has(modPath)) continue
|
|
279
|
-
try {
|
|
280
|
-
this.#state.relative(modPath)
|
|
281
|
-
} catch {
|
|
282
|
-
continue
|
|
283
|
-
}
|
|
293
|
+
if (this.#seen.has(modPath) || !this.#within(modPath)) continue
|
|
284
294
|
this.#seen.add(modPath)
|
|
285
295
|
// Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
|
|
286
296
|
const source = realReadFileSync(modPath)
|
|
287
297
|
// Tag from the ONE shared classifier so an auto-include agrees with any other path meeting
|
|
288
298
|
// the same file (native walk, --fs): setup_env.sh -> 'shell', not a forced 'resource' that
|
|
289
299
|
// would collide at the format noupsert. Unrecognized -> resource (vetted list, so no allowlist).
|
|
290
|
-
// JS (asyncRequire.js) is code Metro builds, so it takes no format, as the graph's doesn't.
|
|
291
300
|
const format = classifyFormat(modPath, { content: source })
|
|
292
|
-
this.#recordCapture(modPath, source, { format, resource: format === undefined, inferFormat: false })
|
|
301
|
+
this.#recordCapture(modPath, source, { format, resource: format === undefined, inferFormat: false, reason: 'run' })
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Attest each of Metro's own transform plugins (metroTransformPluginFiles). Its transform worker
|
|
306
|
+
// hashes every one into its cache key, but loads one only as the build's options call for it
|
|
307
|
+
// (import-export-plugin under `experimentalImportSupport`, inline-requires-plugin under
|
|
308
|
+
// `inlineRequires`, constant-folding-plugin outside dev, normalizePseudoGlobals when minifying), so
|
|
309
|
+
// a capture loads them all, as Metro would: under `stasis run` the loader then records each with
|
|
310
|
+
// what it imports, so a bundle builds under any of those options. Each is recorded besides, as the
|
|
311
|
+
// Node module it is, where no loader records it, attributed to `run` as one Metro loads is: the
|
|
312
|
+
// build's toolchain, none of it the app's. One outside the project root is passed over.
|
|
313
|
+
#captureTransformPlugins() {
|
|
314
|
+
for (const file of metroTransformPluginFiles(this.#projectDir)) {
|
|
315
|
+
if (this.#seen.has(file) || !this.#within(file)) continue
|
|
316
|
+
this.#seen.add(file)
|
|
317
|
+
try {
|
|
318
|
+
require(file)
|
|
319
|
+
} catch { /* one that can't load can't run under Metro either: its bytes are still recorded */ }
|
|
320
|
+
// Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
|
|
321
|
+
this.#recordCapture(file, realReadFileSync(file), { reason: 'run' })
|
|
293
322
|
}
|
|
294
323
|
}
|
|
295
324
|
|
|
@@ -310,23 +339,12 @@ export class StasisMetro {
|
|
|
310
339
|
// RN CORE isn't a `dependencies` entry (config reports only reactNativePath) and autolinking
|
|
311
340
|
// never lists it, but its native source (React/, ReactCommon/, ReactAndroid/, sdks/, ...) is a
|
|
312
341
|
// build input like any other dep's -- walk it in full, the same way.
|
|
313
|
-
const rnPath = config.reactNativePath
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
this.#state.relative(root) // asserts the package lives inside the project root
|
|
318
|
-
} catch {
|
|
319
|
-
continue
|
|
320
|
-
}
|
|
321
|
-
this.#captureNativeTree(root)
|
|
322
|
-
}
|
|
342
|
+
const rnPath = typeof config.reactNativePath === 'string' && isAbsolute(config.reactNativePath) ? config.reactNativePath : null
|
|
343
|
+
if (rnPath !== null) roots.add(rnPath)
|
|
344
|
+
// One outside the project root is unattestable.
|
|
345
|
+
for (const root of roots) if (this.#within(root)) this.#captureNativeTree(root)
|
|
323
346
|
// RN core's `.js` build scripts the walk skips (Metro-owned by extension, but never in the graph).
|
|
324
|
-
if (
|
|
325
|
-
try {
|
|
326
|
-
this.#state.relative(rnPath)
|
|
327
|
-
for (const file of RN_CORE_INCLUDE_FILES) this.#captureNativeFile(join(rnPath, file))
|
|
328
|
-
} catch { /* react-native resolved outside the project root -- unattestable */ }
|
|
329
|
-
}
|
|
347
|
+
if (rnPath !== null && this.#within(rnPath)) for (const file of RN_CORE_INCLUDE_FILES) this.#captureNativeFile(join(rnPath, file))
|
|
330
348
|
}
|
|
331
349
|
|
|
332
350
|
// Attest a single vetted file (RN_CORE_INCLUDE_FILES) the tree walk skips; absent -> skipped.
|
|
@@ -358,12 +376,13 @@ export class StasisMetro {
|
|
|
358
376
|
}
|
|
359
377
|
|
|
360
378
|
// Attest the native build inputs of the package at `root` (nativeInputs, shared with `stasis bundle
|
|
361
|
-
// --metro`), but what is already captured as a graph module or auto-include
|
|
362
|
-
//
|
|
379
|
+
// --metro`), but what is already captured as a graph module or auto-include, each in the format it
|
|
380
|
+
// gives (none for a codegen source, as Metro's code has none, so none is inferred). Real readers:
|
|
381
|
+
// plugin bookkeeping, not re-recorded by --fs.
|
|
363
382
|
#captureNativeTree(root) {
|
|
364
383
|
for (const { file, source, action, format } of nativeInputs(root, diskHost, { resources: this.#resources, skip: (f) => this.#seen.has(f) })) {
|
|
365
384
|
this.#seen.add(file)
|
|
366
|
-
this.#recordCapture(file, source, { format, resource: action === 'resource' })
|
|
385
|
+
this.#recordCapture(file, source, { format, resource: action === 'resource', inferFormat: false })
|
|
367
386
|
}
|
|
368
387
|
}
|
|
369
388
|
|
|
@@ -379,6 +398,8 @@ export class StasisMetro {
|
|
|
379
398
|
if (modPath === undefined) continue
|
|
380
399
|
const url = pathToFileURL(modPath).toString()
|
|
381
400
|
for (const [specifier, target] of this.#edges(module)) this.#state.assertImport(url, specifier, target, { platform })
|
|
401
|
+
this.#assertConsumed(module, modPath)
|
|
402
|
+
this.#assertAssetFiles(module, modPath, platform ?? null)
|
|
382
403
|
}
|
|
383
404
|
}
|
|
384
405
|
|
|
@@ -386,9 +407,13 @@ export class StasisMetro {
|
|
|
386
407
|
// (`dep.data.name`) by the specifier Babel left; where a plugin rewrote it, the stasis transformer
|
|
387
408
|
// (./metro-transformer.js) carries the ones written in the source. Code and resource targets alike,
|
|
388
409
|
// as `stasis bundle --metro` and the esbuild plugin record them: an asset is resolved as code is.
|
|
410
|
+
// One the source doesn't write (`stasisInserted`: a Babel helper, the JSX runtime, Metro's
|
|
411
|
+
// asyncRequire) is no edge, as `stasis bundle --metro` has none: its file is carried, as every
|
|
412
|
+
// module of the graph is, and under load Metro resolves it unasserted.
|
|
389
413
|
#edges(module) {
|
|
390
414
|
const edges = []
|
|
391
415
|
for (const dep of module.dependencies?.values?.() ?? []) {
|
|
416
|
+
if (dep?.data?.stasisInserted === true) continue
|
|
392
417
|
const target = dep?.absolutePath
|
|
393
418
|
if (typeof target !== 'string' || !isAbsolute(target) || !existsSync(target)) continue
|
|
394
419
|
if (classifyExtension(target, this.#resources) === 'unknown') continue
|
|
@@ -399,17 +424,14 @@ export class StasisMetro {
|
|
|
399
424
|
return edges
|
|
400
425
|
}
|
|
401
426
|
|
|
402
|
-
#captureModules(modules, entries) {
|
|
427
|
+
#captureModules(modules, entries, platform) {
|
|
403
428
|
for (const module of modules) {
|
|
404
429
|
const modPath = diskPath(module)
|
|
405
430
|
if (modPath === undefined) continue
|
|
406
431
|
|
|
407
432
|
const kind = classifyExtension(modPath, this.#resources)
|
|
408
433
|
if (kind === 'unknown') {
|
|
409
|
-
throw new Error(
|
|
410
|
-
`StasisMetro: unsupported extension for '${modPath}' -- ` +
|
|
411
|
-
`add its extension or filename to the plugin's resources option or stop importing it`
|
|
412
|
-
)
|
|
434
|
+
throw new Error(unsupportedExtension('StasisMetro', `'${modPath}'`))
|
|
413
435
|
}
|
|
414
436
|
const url = pathToFileURL(modPath).toString()
|
|
415
437
|
for (const [specifier, target] of this.#edges(module)) this.#state.addImport(url, specifier, target)
|
|
@@ -425,9 +447,152 @@ export class StasisMetro {
|
|
|
425
447
|
assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
|
|
426
448
|
}
|
|
427
449
|
// No format for a code file: Metro transforms it from source, so Node's module/commonjs isn't
|
|
428
|
-
// what runs
|
|
429
|
-
|
|
450
|
+
// what runs. But the one its name alone gives it (.json, .mjs, .cjs: nodeExtFormat), as Node
|
|
451
|
+
// loads it too, which a --fs read of it records besides (a resource still takes its
|
|
452
|
+
// byte-derived format).
|
|
453
|
+
const format = kind === 'code' ? nodeExtFormat(modPath) : undefined
|
|
454
|
+
this.#state.addFile(url, { source, format, isEntry: entries.has(modPath), resource: kind === 'resource', inferFormat: false, reason: 'metro' })
|
|
455
|
+
this.#captureConsumed(module, modPath)
|
|
456
|
+
this.#captureAssetFiles(module, modPath, platform)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Whether `path` lies within the project root, as the State's root-relative keys need.
|
|
461
|
+
#within(path) {
|
|
462
|
+
return isPathWithin(this.#state.root, path)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// `path`'s real path, where it lies within the project root (not linked out of it), else null.
|
|
466
|
+
#realWithin(path) {
|
|
467
|
+
let real
|
|
468
|
+
try {
|
|
469
|
+
real = diskHost.realpath(path)
|
|
470
|
+
this.#realRoot ??= diskHost.realpath(this.#state.root)
|
|
471
|
+
} catch {
|
|
472
|
+
return null
|
|
430
473
|
}
|
|
474
|
+
return this.#within(real) && isPathWithin(this.#realRoot, real) ? real : null
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// The resources a Babel plugin consumed in transforming `module`, read and inlined in their imports'
|
|
478
|
+
// stead (`require('./messages.po')`, @exodus/i18n's plugin-inline-po), as the stasis transformer
|
|
479
|
+
// carries them (`stasisConsumed`): no module of Metro's, so no edge, but the build's input all the
|
|
480
|
+
// same. One named by path, as such a plugin reads it, from the module's dir, by its real path, as
|
|
481
|
+
// Metro names its modules. One naming code (an import a plugin split into others, as
|
|
482
|
+
// babel-plugin-lodash does), no file, or one outside the project root is passed over; one the
|
|
483
|
+
// resources don't carry is refused. -> each as { file: its real path, read: the path read }
|
|
484
|
+
* #consumedResources(module, modPath) {
|
|
485
|
+
for (const specifier of module.output?.[0]?.data?.stasisConsumed ?? []) {
|
|
486
|
+
if (typeof specifier !== 'string' || !(isAbsolute(specifier) || /^\.\.?\//u.test(specifier))) continue
|
|
487
|
+
const read = resolve(dirname(modPath), specifier)
|
|
488
|
+
const file = diskHost.stat(read)?.isFile() ? this.#realWithin(read) : null
|
|
489
|
+
if (file === null) continue
|
|
490
|
+
const kind = classifyExtension(file, this.#resources)
|
|
491
|
+
if (kind === 'code') continue
|
|
492
|
+
if (kind === 'unknown') {
|
|
493
|
+
throw new Error(unsupportedExtension('StasisMetro', `'${file}', which a Babel plugin inlines into '${modPath}'`))
|
|
494
|
+
}
|
|
495
|
+
yield { file, read }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Capture: each, attributed to `metro`, as `stasis bundle --metro` records them under `bundle:metro`.
|
|
500
|
+
#captureConsumed(module, modPath) {
|
|
501
|
+
for (const { file } of this.#consumedResources(module, modPath)) this.#captureRead(file, 'resource')
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Capture `path`, a file Metro's build reads that its graph doesn't carry, once, attributed to
|
|
505
|
+
// `metro`, as the `kind` classifyExtension gives it.
|
|
506
|
+
#captureRead(path, kind) {
|
|
507
|
+
if (this.#seen.has(path)) return
|
|
508
|
+
this.#seen.add(path)
|
|
509
|
+
// Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
|
|
510
|
+
this.#recordCapture(path, realReadFileSync(path), { format: kind === 'code' ? nodeExtFormat(path) : undefined, resource: kind === 'resource', inferFormat: false })
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Load: the Babel plugin reads each from disk, past the stasis transformer (#assertOnDisk).
|
|
514
|
+
#assertConsumed(module, modPath) {
|
|
515
|
+
for (const files of this.#consumedResources(module, modPath)) {
|
|
516
|
+
this.#assertOnDisk(files, { what: `which a Babel plugin inlines into '${modPath}'`, servedBy: ['sync', 'async'] })
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// The files of the asset module `module` Metro builds, by the names it reads them by (#assetDirHost):
|
|
521
|
+
// as Metro reads them to build it (its `scales` and `hash`) and copies them out with it, it and its
|
|
522
|
+
// other scales (`logo@2x.png`, `logo@3x.png`; metroAssetFiles, Metro's own rule for `platform`). No
|
|
523
|
+
// import names one, so no edge. -> their paths, none for a module no asset
|
|
524
|
+
#assetFiles(module, modPath, platform) {
|
|
525
|
+
return module.output?.[0]?.type === 'js/module/asset' ? metroAssetFiles(modPath, platform, this.#assetDirHost()) : []
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// The directory listing Metro finds an asset's files in (fs.promises.readdir): the disk's, but under
|
|
529
|
+
// load with --fs=async, which serves it, the bundle's where it has one (a file added on disk since
|
|
530
|
+
// is no file of the asset Metro builds), each name in it a file where it is one on disk or the bundle
|
|
531
|
+
// serves one (gone from disk, it holds no drift).
|
|
532
|
+
#assetDirHost() {
|
|
533
|
+
const state = this.#state
|
|
534
|
+
if (!state.config.loadBundle || state.config.fs !== 'async') return diskHost
|
|
535
|
+
return {
|
|
536
|
+
readdir: (dir) => state.getFsDirFamily(pathToFileURL(dir).toString())?.map((name) => ({ name })) ?? diskHost.readdir(dir),
|
|
537
|
+
stat: (path) => diskHost.stat(path) ?? (state.getFsStatFamily(pathToFileURL(path).toString()) === 'file' ? FILE_STAT : null),
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The files the bundle carries, as a directory listing of them (`metroAssetFiles`' host): an asset's
|
|
542
|
+
// files as captured.
|
|
543
|
+
#bundleDirHost() {
|
|
544
|
+
this.#bundleDirs ??= bundleDirHost(this.#state)
|
|
545
|
+
return this.#bundleDirs
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Capture: each but the module itself, attributed to `metro`, as `stasis bundle --metro` records them:
|
|
549
|
+
// by the name Metro reads it by, with the bytes it reads there (one linked in, its target's), so a
|
|
550
|
+
// load holds that name to them. One linked from outside the project is passed over.
|
|
551
|
+
#captureAssetFiles(module, modPath, platform) {
|
|
552
|
+
for (const read of this.#assetFiles(module, modPath, platform)) {
|
|
553
|
+
const kind = classifyExtension(read, this.#resources)
|
|
554
|
+
// One of a kind unknown, as the module itself, which the graph capture refused.
|
|
555
|
+
if (kind !== 'unknown' && this.#realWithin(read) !== null) this.#captureRead(read, kind)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Load: Metro's asset transformer reads each of an asset's files from disk (fs.promises), the module's
|
|
560
|
+
// own too, past the stasis transformer, which serves the module's `data` that it never reads. Each it
|
|
561
|
+
// reads on disk must be the bundle's under the name it reads it by (#assertOnDisk: a scale added since
|
|
562
|
+
// the capture, or one a symlink redirects, reads bytes the bundle doesn't carry there); and each the
|
|
563
|
+
// bundle carries must be among those it reads (one gone builds another asset, its `scales` and `hash`,
|
|
564
|
+
// from what remains) -- unless --fs=async serves them.
|
|
565
|
+
#assertAssetFiles(module, modPath, platform) {
|
|
566
|
+
const read = this.#assetFiles(module, modPath, platform)
|
|
567
|
+
if (read.length === 0) return
|
|
568
|
+
const what = `a file of the asset '${modPath}' Metro builds`
|
|
569
|
+
for (const path of read) {
|
|
570
|
+
if (diskHost.stat(path)?.isFile()) this.#assertOnDisk({ file: path, read: path }, { what, servedBy: ['async'] })
|
|
571
|
+
}
|
|
572
|
+
if (!inScope(this.#state, pathToFileURL(modPath).toString())) return
|
|
573
|
+
for (const file of metroAssetFiles(modPath, platform, this.#bundleDirHost())) {
|
|
574
|
+
if (read.includes(file)) continue
|
|
575
|
+
throw new Error(`StasisMetro: '${file}', ${what} as the bundle carries it, is gone -- Metro would build the asset without it under load; restore it`)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Load: a file Metro's build reads from disk by `read`, past the stasis transformer, which serves only
|
|
580
|
+
// the module it transforms. One in scope (as the transformer's: any under scope=full, else one in
|
|
581
|
+
// node_modules) is held to the bundle's bytes for `file` (getFile throws on one it doesn't carry),
|
|
582
|
+
// fail closed on drift -- unless --fs, in a mode of `servedBy` (async patches fs.promises too), serves
|
|
583
|
+
// the read from the bundle itself, which it does by the path read (not a symlink to the file the
|
|
584
|
+
// bundle carries).
|
|
585
|
+
#assertOnDisk({ file, read }, { what, servedBy }) {
|
|
586
|
+
const state = this.#state
|
|
587
|
+
const url = pathToFileURL(file).toString()
|
|
588
|
+
if (!inScope(state, url)) return
|
|
589
|
+
if (servedBy.includes(state.config.fs) && state.getFsFileFamily(pathToFileURL(read).toString()) !== undefined) return
|
|
590
|
+
const { source } = state.getFile(url)
|
|
591
|
+
if (realReadFileSync(file).equals(Buffer.isBuffer(source) ? source : Buffer.from(source))) return
|
|
592
|
+
throw new Error(
|
|
593
|
+
`StasisMetro: '${file}', ${what}, differs on disk from the bundle -- ` +
|
|
594
|
+
'Metro\'s build reads it from disk under load; restore it, or run with --fs to serve it from the bundle'
|
|
595
|
+
)
|
|
431
596
|
}
|
|
432
597
|
}
|
|
433
598
|
|
package/src/plugins.js
CHANGED
|
@@ -22,7 +22,7 @@ import { extSetsEqual, parseResourcesOption } from '@exodus/stasis-core/util'
|
|
|
22
22
|
// 7. lock='none' AND bundle='none' with no preload is a no-op (env-controlled-off framework plugins).
|
|
23
23
|
export function resolvePluginState(label, options, cwd) {
|
|
24
24
|
validatePluginOptions(label, options)
|
|
25
|
-
const {
|
|
25
|
+
const { lock, bundle, bundleFile, resourcesBundleFile, resources } = options
|
|
26
26
|
// Same-instance fast path: loader installed but lazy initState hasn't fired (webpack builds plugins
|
|
27
27
|
// before user code). Force-init so the plugin lands in the ambient branch. NOT for a separate
|
|
28
28
|
// stasis-core copy -- that would mint a phantom State.preload racing the real loader's write.
|
|
@@ -69,21 +69,13 @@ export function resolvePluginState(label, options, cwd) {
|
|
|
69
69
|
|
|
70
70
|
// Preload active: the lockfile is necessarily the ambient's. Enforce field coordination, then reuse or sidecar.
|
|
71
71
|
const pc = ambient.config
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (debug !== undefined && debug !== pc.debug) {
|
|
80
|
-
throw new Error(`${label}: debug=${debug} conflicts with active preload debug=${pc.debug}`)
|
|
81
|
-
}
|
|
82
|
-
if (childProcess !== undefined && childProcess !== pc.childProcess) {
|
|
83
|
-
throw new Error(`${label}: childProcess=${childProcess} conflicts with active preload childProcess=${pc.childProcess}`)
|
|
84
|
-
}
|
|
85
|
-
if (packageJSON !== undefined && packageJSON !== pc.packageJSON) {
|
|
86
|
-
throw new Error(`${label}: packageJSON=${packageJSON} conflicts with active preload packageJSON=${pc.packageJSON}`)
|
|
72
|
+
// lock/scope/debug/childProcess/packageJSON must agree with the preload (reuse adopts it, sidecar
|
|
73
|
+
// copies it). The string modes are quoted, the boolean toggles not (validatePluginOptions typed them).
|
|
74
|
+
const shown = (value) => (typeof value === 'string' ? `'${value}'` : value)
|
|
75
|
+
for (const key of ['lock', 'scope', 'debug', 'childProcess', 'packageJSON']) {
|
|
76
|
+
if (options[key] !== undefined && options[key] !== pc[key]) {
|
|
77
|
+
throw new Error(`${label}: ${key}=${shown(options[key])} conflicts with active preload ${key}=${shown(pc[key])}`)
|
|
78
|
+
}
|
|
87
79
|
}
|
|
88
80
|
// resources is process-wide, so a plugin's allowlist must agree with the preload's -- compare
|
|
89
81
|
// parsed sets (order/dupes/case-insensitive) to prevent one plugin silently widening the asset set.
|
|
@@ -100,13 +92,27 @@ export function resolvePluginState(label, options, cwd) {
|
|
|
100
92
|
// fs-READ during the build from genuine run dependencies recorded before any plugin existed.
|
|
101
93
|
ambient.markPluginAttached()
|
|
102
94
|
|
|
95
|
+
// A State of its own bundle (`mode`, at bundleFile) beside the preload, whose lockfile it shares
|
|
96
|
+
// and whose other settings it carries over (sidecars skip stasis.config.json discovery).
|
|
97
|
+
const sidecar = (mode) => ({
|
|
98
|
+
state: new State(cwd, {
|
|
99
|
+
parent: ambient,
|
|
100
|
+
scope: pc.scope,
|
|
101
|
+
lock: pc.lock,
|
|
102
|
+
bundle: mode,
|
|
103
|
+
bundleFile,
|
|
104
|
+
resourcesBundleFile,
|
|
105
|
+
debug: pc.debug,
|
|
106
|
+
childProcess: pc.childProcess,
|
|
107
|
+
packageJSON: pc.packageJSON,
|
|
108
|
+
resources: [...pc.resources],
|
|
109
|
+
brotliQuality: pc.brotliQuality,
|
|
110
|
+
}),
|
|
111
|
+
isNoop: false,
|
|
112
|
+
})
|
|
113
|
+
|
|
103
114
|
if (pc.bundle) {
|
|
104
|
-
// Preload has bundle on -- plugin can't disable, must match mode.
|
|
105
|
-
if (bundle === 'none' || bundle === 'ignore') {
|
|
106
|
-
throw new Error(
|
|
107
|
-
`${label}: bundle='${bundle}' conflicts with active preload bundle='${pc.bundleMode}'`
|
|
108
|
-
)
|
|
109
|
-
}
|
|
115
|
+
// Preload has bundle on -- plugin can't disable (pc.bundleMode is neither 'none' nor 'ignore'), must match mode.
|
|
110
116
|
if (bundle !== undefined && bundle !== pc.bundleMode) {
|
|
111
117
|
throw new Error(
|
|
112
118
|
`${label}: bundle='${bundle}' conflicts with active preload bundle='${pc.bundleMode}'`
|
|
@@ -118,21 +124,7 @@ export function resolvePluginState(label, options, cwd) {
|
|
|
118
124
|
return { state: ambient, isNoop: false }
|
|
119
125
|
}
|
|
120
126
|
// Rule 6: different path -- sidecar inherits preload's modes, overrides bundleFile.
|
|
121
|
-
|
|
122
|
-
parent: ambient,
|
|
123
|
-
scope: pc.scope,
|
|
124
|
-
lock: pc.lock,
|
|
125
|
-
bundle: pc.bundleMode,
|
|
126
|
-
bundleFile,
|
|
127
|
-
resourcesBundleFile,
|
|
128
|
-
debug: pc.debug,
|
|
129
|
-
childProcess: pc.childProcess,
|
|
130
|
-
packageJSON: pc.packageJSON,
|
|
131
|
-
resources: [...pc.resources],
|
|
132
|
-
// carried over: sidecars skip stasis.config.json discovery
|
|
133
|
-
brotliQuality: pc.brotliQuality,
|
|
134
|
-
})
|
|
135
|
-
return { state: sidecar, isNoop: false }
|
|
127
|
+
return sidecar(pc.bundleMode)
|
|
136
128
|
}
|
|
137
129
|
|
|
138
130
|
// Preload exists but has no bundle.
|
|
@@ -152,18 +144,23 @@ export function resolvePluginState(label, options, cwd) {
|
|
|
152
144
|
`${label}: bundle='${bundle}' alongside a preload without bundle requires an explicit bundleFile`
|
|
153
145
|
)
|
|
154
146
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
147
|
+
return sidecar(bundle)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// What a plugin (`label`) says of a file it is given (`what`, as it names it) whose extension it
|
|
151
|
+
// neither bundles as code nor is allowed as a resource.
|
|
152
|
+
export const unsupportedExtension = (label, what) =>
|
|
153
|
+
`${label}: unsupported extension for ${what} -- add its extension or filename to the plugin's resources option or stop importing it`
|
|
154
|
+
|
|
155
|
+
// `state.getImport(parentURL, specifier, options)`, or undefined where the bundle attests no such edge:
|
|
156
|
+
// at a successful capture that's an EXTERNAL (never recorded as an edge), the bundler's to resolve as
|
|
157
|
+
// without us. File BYTES still fail closed where the plugin loads them; any other error is a real
|
|
158
|
+
// fault, thrown.
|
|
159
|
+
export function attestedImport(state, parentURL, specifier, options) {
|
|
160
|
+
try {
|
|
161
|
+
return state.getImport(parentURL, specifier, options)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err?.code === 'ERR_MODULE_NOT_FOUND') return undefined
|
|
164
|
+
throw err
|
|
165
|
+
}
|
|
169
166
|
}
|