@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/stasis-plugins",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.14",
|
|
4
4
|
"description": "Bundler plugins (webpack, esbuild, Rollup, Metro) for @exodus/stasis-core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -36,6 +36,6 @@
|
|
|
36
36
|
},
|
|
37
37
|
"homepage": "https://github.com/PreventiveMeasures/stasis#readme",
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@exodus/stasis-core": "1.0.0-beta.
|
|
39
|
+
"@exodus/stasis-core": "1.0.0-beta.14"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/esbuild.js
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
4
4
|
import assert from 'node:assert/strict'
|
|
5
5
|
|
|
6
6
|
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
7
|
-
import { resolvePluginState } from './plugins.js'
|
|
7
|
+
import { attestedImport, resolvePluginState, unsupportedExtension } from './plugins.js'
|
|
8
8
|
import { State } from '@exodus/stasis-core/state'
|
|
9
9
|
// Pre-patch snapshot, not `import { readFile }`: under --fs=async that builtin is patched, and
|
|
10
10
|
// this plugin loads after, so a direct import would route esbuild's reads through the capture hook.
|
|
@@ -71,6 +71,8 @@ export class StasisEsbuild {
|
|
|
71
71
|
#resources
|
|
72
72
|
#loaders
|
|
73
73
|
#transform
|
|
74
|
+
#resource
|
|
75
|
+
#platform
|
|
74
76
|
#emptyModule // URL of the empty module disabled imports point at, once capture recorded it
|
|
75
77
|
#browserScopes = new Map() // dir -> Promise<[{ dir, keys }]>: `false` keys of it and its ancestors
|
|
76
78
|
#probes = new Map() // [kind, resolveDir, specifier] -> Promise<disabled path | null>
|
|
@@ -79,7 +81,7 @@ export class StasisEsbuild {
|
|
|
79
81
|
// the second is refused in onStart.
|
|
80
82
|
#captureBuildCount = 0
|
|
81
83
|
|
|
82
|
-
constructor(options = {}, { loaders, transform } = {}) {
|
|
84
|
+
constructor(options = {}, { loaders, transform, resource, platform } = {}) {
|
|
83
85
|
// A caller that owns a State (e.g. `stasis build`) can pass it directly; a foreign-copy State
|
|
84
86
|
// fails closed via the instanceof miss.
|
|
85
87
|
const state = options instanceof State
|
|
@@ -95,6 +97,12 @@ export class StasisEsbuild {
|
|
|
95
97
|
// (source, { path, loader }) => { contents, loader } | undefined, may be async. Never
|
|
96
98
|
// runs at capture -- capture attests the file's raw bytes.
|
|
97
99
|
this.#transform = transform
|
|
100
|
+
// Load-mode-only module builder for a resource no loader override names (e.g. `stasis build
|
|
101
|
+
// --metro`'s asset module): (source, { path }) => { contents, loader } | undefined, may be async.
|
|
102
|
+
this.#resource = resource
|
|
103
|
+
// Load mode: the platform whose target a --metro per-platform edge resolves to (none fails
|
|
104
|
+
// closed on one, as plain bundle=load does).
|
|
105
|
+
this.#platform = platform
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
// esbuild loader for a served file: configured override, else derived from extension
|
|
@@ -223,16 +231,11 @@ export class StasisEsbuild {
|
|
|
223
231
|
// keys attributed edges under specific conditions the plugin's `*` lookup won't match.
|
|
224
232
|
// The edge is looked up even for a built-in's name: a browser build records `fs` when a
|
|
225
233
|
// `browser` field disables it (an edge to the empty module) or a package by that name serves it.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// (built-ins under platform:'node'). File BYTES still fail closed at onLoad.
|
|
232
|
-
// A non-MODULE_NOT_FOUND error is a real fault -- rethrow.
|
|
233
|
-
if (err?.code === 'ERR_MODULE_NOT_FOUND') return undefined
|
|
234
|
-
throw err
|
|
235
|
-
}
|
|
234
|
+
// No attested edge (a built-in, or another external): esbuild's, which externalizes it as
|
|
235
|
+
// without us (built-ins under platform:'node'). File BYTES still fail closed at onLoad.
|
|
236
|
+
const attested = attestedImport(this.#state, parentURL, specifier, { importAttributes: attrs, platform: this.#platform })
|
|
237
|
+
if (attested === undefined) return undefined
|
|
238
|
+
;({ url } = attested)
|
|
236
239
|
}
|
|
237
240
|
const format = this.#state.getFormat(url)
|
|
238
241
|
const isEmpty = url === pathToFileURL(resolvePath(this.#state.root, EMPTY_MODULE_PATH)).toString()
|
|
@@ -268,11 +271,7 @@ export class StasisEsbuild {
|
|
|
268
271
|
if (res.namespace === 'file' && !res.external) {
|
|
269
272
|
kind = classifyExtension(res.path, this.#resources)
|
|
270
273
|
if (kind === 'unknown') {
|
|
271
|
-
return {
|
|
272
|
-
errors: [{
|
|
273
|
-
text: `StasisEsbuild: unsupported extension for '${res.path}' -- add its extension or filename to the plugin's resources option or stop importing it`,
|
|
274
|
-
}],
|
|
275
|
-
}
|
|
274
|
+
return { errors: [{ text: unsupportedExtension('StasisEsbuild', `'${res.path}'`) }] }
|
|
276
275
|
}
|
|
277
276
|
// Record the edge for BOTH code and resource targets (never entries): load mode resolves
|
|
278
277
|
// every specifier through this map, so a resource with no edge would miss at bundle=load and
|
|
@@ -305,11 +304,15 @@ export class StasisEsbuild {
|
|
|
305
304
|
// one a CSS @import, `composes` or url() accepts, so one empty module serves JS and CSS importers.
|
|
306
305
|
if (kind === 'empty') return { contents: source, loader: 'empty' }
|
|
307
306
|
if (kind === 'resource') {
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
307
|
+
// A loader override for its extension (`stasis build --loader=.png:dataurl`) wins; else the
|
|
308
|
+
// resource hook's module, where it builds one.
|
|
309
|
+
const override = this.#loaders.get(extname(path))
|
|
310
|
+
if (override) return { contents: source, loader: override }
|
|
311
|
+
const built = await this.#resource?.(source, { path })
|
|
312
|
+
if (built) return built
|
|
313
|
+
// Else the loader esbuild's own load path takes (the build's `loader` option, else its default
|
|
314
|
+
// for the extension), so a resource none loads fails as at capture, not parsed as js.
|
|
315
|
+
return { contents: source, loader: 'default' }
|
|
313
316
|
}
|
|
314
317
|
const loader = this.#loaderFor(path)
|
|
315
318
|
// A transform that declines (undefined) serves the attested bytes unchanged.
|
|
@@ -8,13 +8,16 @@ import { createRequire } from 'node:module'
|
|
|
8
8
|
// babel-plugin-module-resolver mapping `#local/abc` to `./src/abc`) leaves the serializer only the
|
|
9
9
|
// rewrite. This adds one plugin that records every specifier in its `pre` -- Babel runs every
|
|
10
10
|
// plugin's `pre` before any plugin's visitor, so nothing has rewritten one yet -- and reads each
|
|
11
|
-
// again once Babel is done, before Metro's own passes replace them in the AST,
|
|
12
|
-
//
|
|
11
|
+
// again once Babel is done, before Metro's own passes replace them in the AST, but one importing
|
|
12
|
+
// types alone that Babel erased. One a plugin consumed, gone from Babel's output with nothing left
|
|
13
|
+
// naming its module (`require('./messages.po')`, which @exodus/i18n's plugin-inline-po reads and
|
|
14
|
+
// inlines), it keeps apart. The real transformer runs as it would; its output is untouched.
|
|
13
15
|
|
|
14
16
|
const require = createRequire(import.meta.url)
|
|
15
17
|
|
|
16
18
|
// One per worker transform, run by ./metro-transformer.js: `babelTransformerPath` is the project's
|
|
17
|
-
// (absolute),
|
|
19
|
+
// (absolute), `asWritten` maps each specifier Babel left to the ones written there, and `consumed`
|
|
20
|
+
// holds each one a plugin consumed, as Babel last left it.
|
|
18
21
|
export const transformContext = new AsyncLocalStorage()
|
|
19
22
|
|
|
20
23
|
// The nodes Metro's collectDependencies takes a specifier from: these declarations, and the calls
|
|
@@ -22,7 +25,10 @@ export const transformContext = new AsyncLocalStorage()
|
|
|
22
25
|
const DECLARATIONS = new Set(['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'])
|
|
23
26
|
const REQUIRE_MEMBERS = new Set(['context', 'resolveWeak', 'unstable_importMaybeSync'])
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
// Whether `node` is one of them.
|
|
29
|
+
function isDependency(node) {
|
|
30
|
+
if (node.type !== 'CallExpression') return DECLARATIONS.has(node.type)
|
|
31
|
+
const { callee } = node
|
|
26
32
|
if (callee.type === 'Import') return true
|
|
27
33
|
if (callee.type === 'Identifier') return callee.name === 'require' || callee.name === '__prefetchImport'
|
|
28
34
|
return callee.type === 'MemberExpression' && !callee.computed &&
|
|
@@ -30,30 +36,78 @@ function isDependencyCall(callee) {
|
|
|
30
36
|
callee.property.type === 'Identifier' && REQUIRE_MEMBERS.has(callee.property.name)
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
// The
|
|
34
|
-
//
|
|
39
|
+
// The string `node` holds as a constant: a literal, a template with no substitutions, or a `+` of
|
|
40
|
+
// such, which Metro's collectDependencies evaluates to the specifier as it does a literal.
|
|
41
|
+
function constantString(node) {
|
|
42
|
+
if (node?.type === 'StringLiteral') return node.value
|
|
43
|
+
if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0].value.cooked ?? undefined
|
|
44
|
+
if (node?.type !== 'BinaryExpression' || node.operator !== '+') return undefined
|
|
45
|
+
const left = constantString(node.left)
|
|
46
|
+
const right = left === undefined ? undefined : constantString(node.right)
|
|
47
|
+
return right === undefined ? undefined : left + right
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The specifier a dependency node holds as a constant: an import/export's source, a call's first
|
|
51
|
+
// argument. A plugin rewriting it replaces the literal, never the node holding it.
|
|
35
52
|
function specifierOf(node) {
|
|
36
|
-
|
|
37
|
-
|
|
53
|
+
return constantString(node.type === 'CallExpression' ? node.arguments[0] : node.source)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TYPE_KINDS = new Set(['type', 'typeof'])
|
|
57
|
+
|
|
58
|
+
// A declaration of types alone, which Babel may erase, and then it names no module Metro depends on:
|
|
59
|
+
// Flow's `import type`/`import typeof`, TypeScript's `import type`, `export type ... from`, and one
|
|
60
|
+
// whose every specifier is a type (`import { type A }`, which TypeScript's `onlyRemoveTypeImports`
|
|
61
|
+
// keeps as `import {} from`).
|
|
62
|
+
function typesOnly(node) {
|
|
63
|
+
if (TYPE_KINDS.has(node.importKind ?? node.exportKind)) return true
|
|
64
|
+
const specifiers = node.specifiers ?? []
|
|
65
|
+
return specifiers.length > 0 && specifiers.every((specifier) => TYPE_KINDS.has(specifier.importKind ?? specifier.exportKind))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The dependency nodes in Babel's output, and the specifiers they hold as constants.
|
|
69
|
+
function dependenciesLeft(t, ast) {
|
|
70
|
+
const nodes = new Set()
|
|
71
|
+
const specifiers = new Set()
|
|
72
|
+
t.traverseFast(ast, (node) => {
|
|
73
|
+
if (!isDependency(node)) return
|
|
74
|
+
nodes.add(node)
|
|
75
|
+
const specifier = specifierOf(node)
|
|
76
|
+
if (specifier !== undefined) specifiers.add(specifier)
|
|
77
|
+
})
|
|
78
|
+
return { nodes, specifiers }
|
|
38
79
|
}
|
|
39
80
|
|
|
40
81
|
function asWrittenPlugin({ types: t }) {
|
|
41
82
|
return {
|
|
42
83
|
name: 'stasis-as-written',
|
|
43
84
|
pre(file) {
|
|
44
|
-
const
|
|
85
|
+
const context = transformContext.getStore()
|
|
86
|
+
context.types = t
|
|
87
|
+
const { slots } = context
|
|
88
|
+
let evaluate = false
|
|
45
89
|
t.traverseFast(file.ast, (node) => {
|
|
46
|
-
if (
|
|
90
|
+
if (!isDependency(node)) return
|
|
47
91
|
const written = specifierOf(node)
|
|
48
|
-
if (written !== undefined) slots.push({ node, written })
|
|
92
|
+
if (written !== undefined) slots.push({ node, written, types: node.type !== 'CallExpression' && typesOnly(node) })
|
|
93
|
+
else if (node.type === 'CallExpression' && node.arguments.length > 0) evaluate = true
|
|
94
|
+
})
|
|
95
|
+
// Metro takes any argument Babel evaluates to a string with confidence, one bound to a constant
|
|
96
|
+
// too (`const name = './x'; require(name)`), which only a path, and its scope, can evaluate.
|
|
97
|
+
if (!evaluate) return
|
|
98
|
+
file.path.traverse({
|
|
99
|
+
CallExpression(path) {
|
|
100
|
+
if (!isDependency(path.node) || specifierOf(path.node) !== undefined || path.node.arguments.length === 0) return
|
|
101
|
+
const { confident, value } = path.get('arguments.0').evaluate()
|
|
102
|
+
if (confident && typeof value === 'string') slots.push({ node: path.node, written: value })
|
|
103
|
+
},
|
|
49
104
|
})
|
|
50
105
|
},
|
|
51
106
|
visitor: {
|
|
52
|
-
// Babel exits a node only if no plugin removed it on the way in, so a declaration
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// @babel/plugin-transform-modules-commonjs takes the declarations away as the program exits.
|
|
107
|
+
// Babel exits a node only if no plugin removed it on the way in, so a declaration of types
|
|
108
|
+
// alone that Babel erased never exits: flow-strip-types removes it as it enters it, the TS
|
|
109
|
+
// plugin before the traversal gets there. One it kept (`import {} from` under
|
|
110
|
+
// `onlyRemoveTypeImports`) exits, still naming its module.
|
|
57
111
|
'ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration': {
|
|
58
112
|
exit(path) {
|
|
59
113
|
transformContext.getStore().kept.add(path.node)
|
|
@@ -69,14 +123,24 @@ export function transform(args) {
|
|
|
69
123
|
const slots = []
|
|
70
124
|
const kept = new Set()
|
|
71
125
|
Object.assign(context, { slots, kept })
|
|
72
|
-
// Once Babel is done: each specifier as it left it, from the node that held it as written
|
|
126
|
+
// Once Babel is done: each specifier as it left it, from the node that held it as written, but a
|
|
127
|
+
// declaration of types alone Babel erased. One a plugin replaced keeps it too: lowered to a require
|
|
128
|
+
// (the commonjs transform), or split in two (`export * as ns from` into an import and an export,
|
|
129
|
+
// the export-namespace-from transform), it names the same module, its specifier as rewritten
|
|
130
|
+
// before (module-resolver rewrites every one as the program enters). One gone from the output,
|
|
131
|
+
// with no node there naming its module, a plugin consumed in its stead.
|
|
73
132
|
const collect = (result) => {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
133
|
+
const left = result?.ast && context.types ? dependenciesLeft(context.types, result.ast) : null
|
|
134
|
+
for (const { node, written, types } of slots) {
|
|
135
|
+
if (types && !kept.has(node)) continue
|
|
136
|
+
// One evaluated through its scope stays as written: no plugin rewrites what isn't a literal.
|
|
137
|
+
const specifier = specifierOf(node) ?? written
|
|
138
|
+
if (left !== null && !left.nodes.has(node) && !left.specifiers.has(specifier)) {
|
|
139
|
+
context.consumed.add(specifier)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (!context.asWritten.has(specifier)) context.asWritten.set(specifier, new Set())
|
|
143
|
+
context.asWritten.get(specifier).add(written)
|
|
80
144
|
}
|
|
81
145
|
return result
|
|
82
146
|
}
|
package/src/metro-native.js
CHANGED
|
@@ -1,54 +1,168 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isUtf8 } from 'node:buffer'
|
|
2
|
+
import { basename, dirname, join, posix, relative, sep } from 'node:path'
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import { readJson } from '@exodus/stasis-core/bundle-util'
|
|
5
|
+
import { diskHost } from '@exodus/stasis-core/host'
|
|
6
|
+
import { CODE_EXTENSIONS, classifyFormat, isAutoExcludedDir, isBinaryPlist, isDotEnvFile, isExcludedNativeFile, isNativeArtifact, nameFormat, pathExt, toPosix } from '@exodus/stasis-core/util'
|
|
4
7
|
|
|
5
8
|
// The native build inputs of a React Native app's packages, shared by the Metro plugin's capture and
|
|
6
9
|
// `stasis bundle --metro` so the two carry the same files. Both read through `fs`:
|
|
7
10
|
// readdir(dir) -> Dirent[] (throws where `dir` isn't one), readFile(file) -> Buffer,
|
|
8
11
|
// stat(path) -> Stats, or null where nothing is there.
|
|
9
12
|
|
|
13
|
+
// Files a React Native build requires that neither Metro's module graph nor a run's loader carries,
|
|
14
|
+
// each as a require() of it from the project root resolves it (one that doesn't, or lies outside the
|
|
15
|
+
// root, is passed over), tagged by the shared classifier: setup_env.sh, which the React Native CLI runs
|
|
16
|
+
// (`execFileSync`) as every command of it starts, `react-native config` the Metro plugin runs included.
|
|
17
|
+
// The run's toolchain, none of it the app's: the Metro plugin records each under `run`, `stasis bundle
|
|
18
|
+
// --metro` as its own (`bundle`).
|
|
19
|
+
export const AUTO_INCLUDES = ['@react-native-community/cli/setup_env.sh']
|
|
20
|
+
|
|
21
|
+
// The entries of the directory `dir`, none where it's missing or no directory.
|
|
22
|
+
export function listing(dir, fs) {
|
|
23
|
+
try {
|
|
24
|
+
return fs.readdir(dir)
|
|
25
|
+
} catch {
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// RN core build scripts the native walk would defer to Metro as `.js`, but Metro never sees them (RN's
|
|
31
|
+
// podspecs invoke them at pod-install). Project-relative, so react-native's `exports` can't hide them.
|
|
32
|
+
export const RN_CORE_INCLUDE_FILES = ['sdks/hermes-engine/utils/replace_hermes_version.js']
|
|
33
|
+
|
|
34
|
+
// Discovered by name: RN scatters podspecs in subdirs `react-native config` misses.
|
|
35
|
+
function isPodspec(name) {
|
|
36
|
+
return name.endsWith('.podspec') || name.endsWith('.podspec.json')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The off-platform top-level dir of a native package, which no build on this host compiles.
|
|
40
|
+
export function isExcludedNativeDir(name, { win32 = process.platform === 'win32' } = {}) {
|
|
41
|
+
return !win32 && name === 'windows'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Dirs the NATIVE walks (Metro plugin + the static --metro bundler) skip, on top of the shared set:
|
|
45
|
+
// nested packages (attested separately) and regenerated build output -- none is a build input.
|
|
46
|
+
const NATIVE_BUILD_OUTPUT_DIRS = new Set(['node_modules', 'build', '.gradle', '.cxx', 'Pods', 'DerivedData'])
|
|
47
|
+
function isSkippedNativeWalkDir(name) {
|
|
48
|
+
return NATIVE_BUILD_OUTPUT_DIRS.has(name) || isAutoExcludedDir(name) || isNativeArtifact(name)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// An extensionless non-UTF-8 file is a compiled tool (Hermes' `hermesc`), never source -- skipped.
|
|
52
|
+
function isExtensionlessBinary(name, content) {
|
|
53
|
+
return pathExt(name) === '' && Buffer.isBuffer(content) && !isUtf8(content)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The Metro native capture's policy view. Returns { action, format }: 'code' (a native build input),
|
|
57
|
+
// 'skip' (excluded noise, or a JS-family file matched BY EXTENSION -- Metro owns those), else 'resource'.
|
|
58
|
+
export function classifyNativeCapture(name, { win32 = process.platform === 'win32', content } = {}) {
|
|
59
|
+
// `.env` files carry secrets: an automated capture must never sweep them in.
|
|
60
|
+
if (isExcludedNativeFile(name, { win32 }) || isDotEnvFile(name)) return { action: 'skip' }
|
|
61
|
+
// Name-matched code is always a native build input, even when its tag is a Node format the JS
|
|
62
|
+
// graph would otherwise own.
|
|
63
|
+
const byName = nameFormat(name)
|
|
64
|
+
if (byName !== undefined) return { action: 'code', format: byName }
|
|
65
|
+
if (CODE_EXTENSIONS.has(pathExt(name))) return { action: 'skip' }
|
|
66
|
+
const format = classifyFormat(name, { content })
|
|
67
|
+
return format === undefined ? { action: 'resource' } : { action: 'code', format }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The byte-level half of the native classification, refining what classifyNativeCapture derived from the
|
|
71
|
+
// NAME. Both rules only ever demote. A 'resource' carries no format: storage derives base64 from bytes.
|
|
72
|
+
export function refineNativeCapture(classified, name, content, resources = new Set()) {
|
|
73
|
+
if (isExtensionlessBinary(name, content)) return { action: 'skip' }
|
|
74
|
+
if (isBinaryPlist(name, content)) return { action: resources.has('plist') ? 'resource' : 'skip' }
|
|
75
|
+
return classified
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A file name as Metro's AssetPaths reads it (parsePlatformFilePath, then parseBaseName): its asset,
|
|
79
|
+
// `${name}.${type}` less any scale (`a@2x.png` is `a.png`'s), its scale (`resolution`, 1 where it
|
|
80
|
+
// names none), and its platform where it names `platform` (`a.ios.png`; another's, `a.android.png`,
|
|
81
|
+
// is an asset of its own). -> { asset, name, type, resolution, platform }, or null for a name with no type
|
|
82
|
+
const METRO_PLATFORM_FILE = /^(.+?)(?:\.([^.]+))?\.([^.]+)$/u
|
|
83
|
+
const METRO_ASSET_SCALE = /^(.+?)(?:@([\d.]+)x)?$/u
|
|
84
|
+
export function parseMetroAssetName(name, platform) {
|
|
85
|
+
const match = METRO_PLATFORM_FILE.exec(name)
|
|
86
|
+
if (match === null) return null
|
|
87
|
+
const [, base, suffix, type] = match
|
|
88
|
+
const ofPlatform = suffix !== undefined && suffix === platform
|
|
89
|
+
const [, root, scale] = METRO_ASSET_SCALE.exec(suffix === undefined || ofPlatform ? base : `${base}.${suffix}`)
|
|
90
|
+
const resolution = Number.parseFloat(scale)
|
|
91
|
+
return { asset: `${root}.${type}`, name: root, type, resolution: Number.isNaN(resolution) ? 1 : resolution, platform: ofPlatform ? platform : null }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// What a file's stat answers to metroAssetFiles, for a file a listing names but the disk lacks.
|
|
95
|
+
export const FILE_STAT = { isFile: () => true }
|
|
96
|
+
|
|
97
|
+
// The files `state` (a loaded State) carries, as a directory listing of them (metroAssetFiles' host):
|
|
98
|
+
// an asset's files as captured.
|
|
99
|
+
export function bundleDirHost(state) {
|
|
100
|
+
const dirs = Map.groupBy([...state.sources.keys(), ...state.resources.keys()], (rel) => posix.dirname(rel))
|
|
101
|
+
return {
|
|
102
|
+
readdir: (dir) => (dirs.get(toPosix(state.relative(dir)) || '.') ?? []).map((rel) => ({ name: posix.basename(rel) })),
|
|
103
|
+
stat: () => FILE_STAT,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Metro's record of the asset module `path` (its Assets' getAbsoluteAssetRecord): the files it reads
|
|
108
|
+
// to build the module (its `scales` and `hash`) and copies out with it. In `path`'s directory, each of
|
|
109
|
+
// the asset's name and type at any scale (`a.png`, `a@2x.png`, `a@3x.png`): those for `platform`
|
|
110
|
+
// (`a.ios.png`, `a@3x.ios.png`) where it has any, else those for none. -> absolute paths
|
|
111
|
+
export function metroAssetFiles(path, platform = null, host = diskHost) {
|
|
112
|
+
const own = parseMetroAssetName(basename(path), platform)
|
|
113
|
+
if (own === null) return []
|
|
114
|
+
const dir = dirname(path)
|
|
115
|
+
const byPlatform = new Map()
|
|
116
|
+
for (const { name } of host.readdir(dir)) {
|
|
117
|
+
const parsed = parseMetroAssetName(name, platform)
|
|
118
|
+
if (parsed?.asset !== own.asset || host.stat(join(dir, name))?.isFile() !== true) continue
|
|
119
|
+
if (!byPlatform.has(parsed.platform)) byPlatform.set(parsed.platform, [])
|
|
120
|
+
byPlatform.get(parsed.platform).push(join(dir, name))
|
|
121
|
+
}
|
|
122
|
+
return (platform === null ? undefined : byPlatform.get(platform)) ?? byPlatform.get(null) ?? []
|
|
123
|
+
}
|
|
124
|
+
|
|
10
125
|
// True if the package at `root` ships native code: an ios/ or android/ dir, or a podspec under it.
|
|
11
126
|
export function looksNative(root, fs) {
|
|
12
|
-
|
|
13
|
-
return hasPodspec(root, fs)
|
|
127
|
+
return fs.stat(join(root, 'ios')) !== null || fs.stat(join(root, 'android')) !== null || hasPodspec(root, fs)
|
|
14
128
|
}
|
|
15
129
|
|
|
16
130
|
// True if any *.podspec exists under `dir`, pruning the same subtrees the native walk skips.
|
|
17
131
|
function hasPodspec(dir, fs) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
entries = fs.readdir(dir)
|
|
21
|
-
} catch {
|
|
22
|
-
return false
|
|
23
|
-
}
|
|
24
|
-
for (const ent of entries) {
|
|
25
|
-
if (ent.isSymbolicLink()) continue
|
|
26
|
-
if (ent.isFile()) {
|
|
27
|
-
if (isPodspec(ent.name)) return true
|
|
28
|
-
} else if (ent.isDirectory() && !isSkippedNativeWalkDir(ent.name)) {
|
|
29
|
-
if (hasPodspec(join(dir, ent.name), fs)) return true
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return false
|
|
132
|
+
return listing(dir, fs).some((ent) => !ent.isSymbolicLink() &&
|
|
133
|
+
(ent.isFile() ? isPodspec(ent.name) : ent.isDirectory() && !isSkippedNativeWalkDir(ent.name) && hasPodspec(join(dir, ent.name), fs)))
|
|
33
134
|
}
|
|
34
135
|
|
|
136
|
+
// The specs React Native's codegen parses in a package's codegen sources (codegenSources), as its own
|
|
137
|
+
// filter takes them (filterJSFile): a JS or TS module named `Native…` or `…NativeComponent` (a platform's
|
|
138
|
+
// `.ios`/`.android` one too), but a type declaration.
|
|
139
|
+
const isCodegenSpec = (name) => /^(?:Native.+|.+NativeComponent)/u.test(name) && /\.(?:jsx?|tsx?)$/u.test(name) && !name.endsWith('.d.ts')
|
|
140
|
+
|
|
35
141
|
// Each native build input in the package at `root`, walked whole, a native build reading sources
|
|
36
142
|
// from anywhere in it (a podspec compiling `cpp/` beside `ios/`): every file but build output and
|
|
37
143
|
// prebuilt binaries (isSkippedNativeWalkDir, isNativeArtifact), an off-platform top-level dir
|
|
38
144
|
// (isExcludedNativeDir), what the shared classifier skips (the JS and TS Metro's graph owns, docs,
|
|
39
|
-
// secrets) and symlinks (a cycle or escape hazard).
|
|
40
|
-
//
|
|
41
|
-
//
|
|
145
|
+
// secrets) and symlinks (a cycle or escape hazard). And the specs of its codegen sources
|
|
146
|
+
// (codegenSources, isCodegenSpec), as code with no format, as Metro's code has none: none of the
|
|
147
|
+
// other JS there (a react-native.config.js, the package's build, where `jsSrcsDir` is its root), which
|
|
148
|
+
// codegen never reads. A file `skip(file)` holds is passed over unread. `resources` is the allowlist
|
|
149
|
+
// the byte-level rules consult.
|
|
150
|
+
// -> yields { file, source, action, format }, `action` 'code' (under the source-language `format`,
|
|
151
|
+
// none for JS and TS) or 'resource'
|
|
42
152
|
export function* nativeInputs(root, fs, { resources = new Set(), skip = () => false } = {}) {
|
|
43
153
|
yield* walk(root, true)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
154
|
+
for (const dir of codegenSources(root, fs)) yield* sources(dir)
|
|
155
|
+
function* sources(dir) {
|
|
156
|
+
for (const ent of listing(dir, fs)) {
|
|
157
|
+
// None in a dot, node_modules or tests directory, as codegen skips one.
|
|
158
|
+
if (ent.isSymbolicLink() || ent.name.startsWith('.') || ent.name === 'node_modules' || ent.name.startsWith('__tests')) continue
|
|
159
|
+
const file = join(dir, ent.name)
|
|
160
|
+
if (ent.isDirectory()) yield* sources(file)
|
|
161
|
+
else if (ent.isFile() && isCodegenSpec(ent.name) && !skip(file)) yield { file, source: fs.readFile(file), action: 'code', format: undefined }
|
|
50
162
|
}
|
|
51
|
-
|
|
163
|
+
}
|
|
164
|
+
function* walk(dir, atRoot) {
|
|
165
|
+
for (const ent of listing(dir, fs)) {
|
|
52
166
|
if (ent.isSymbolicLink()) continue
|
|
53
167
|
const file = join(dir, ent.name)
|
|
54
168
|
if (ent.isDirectory()) {
|
|
@@ -66,21 +180,39 @@ export function* nativeInputs(root, fs, { resources = new Set(), skip = () => fa
|
|
|
66
180
|
}
|
|
67
181
|
}
|
|
68
182
|
|
|
183
|
+
// The directories of the package at `root` its package.json's `codegenConfig` names as its JS sources
|
|
184
|
+
// (`jsSrcsDir`, or each of its `libraries`', as React Native's own names `src`), each reached within it
|
|
185
|
+
// through no symlink, as the walk follows none: React Native's codegen parses the specs there to
|
|
186
|
+
// generate the native code a build compiles (a missing one fails it), and `react-native config` reads
|
|
187
|
+
// them for the components they declare (`codegenNativeComponent`, in a `…NativeComponent` spec).
|
|
188
|
+
// -> the absolute dirs
|
|
189
|
+
function codegenSources(root, fs) {
|
|
190
|
+
const config = readJson(join(root, 'package.json'), fs)?.codegenConfig
|
|
191
|
+
const named = [config?.jsSrcsDir, ...(Array.isArray(config?.libraries) ? config.libraries.map((library) => library?.jsSrcsDir) : [])]
|
|
192
|
+
return named.filter((dir) => typeof dir === 'string' && dir !== '').map((dir) => packageDir(root, dir, fs)).filter((dir) => dir !== null)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The directory `rel` names in the package at `root`, each step of it one (a link's entry is none, and
|
|
196
|
+
// none is `..`). -> its absolute path, or null
|
|
197
|
+
function packageDir(root, rel, fs) {
|
|
198
|
+
let dir = root
|
|
199
|
+
for (const name of relative(root, join(root, rel)).split(sep)) {
|
|
200
|
+
if (name === '') continue
|
|
201
|
+
if (!listing(dir, fs).some((ent) => ent.name === name && ent.isDirectory())) return null
|
|
202
|
+
dir = join(dir, name)
|
|
203
|
+
}
|
|
204
|
+
return dir
|
|
205
|
+
}
|
|
206
|
+
|
|
69
207
|
// A package name as npm takes one (`name` or `@scope/name`), which a path can't climb out with.
|
|
70
208
|
const PACKAGE_NAME = /^(?:@[^/\\@.][^/\\]*\/)?[^/\\@.][^/\\]*$/u
|
|
71
209
|
|
|
72
|
-
// The app's packages `react-native config`
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
let manifest
|
|
79
|
-
try {
|
|
80
|
-
manifest = JSON.parse(fs.readFile(join(projectDir, 'package.json')).toString('utf8'))
|
|
81
|
-
} catch {
|
|
82
|
-
return []
|
|
83
|
-
}
|
|
210
|
+
// The app's packages `react-native config` reads, read as data rather than by running the CLI: each
|
|
211
|
+
// name the app's package.json lists under dependencies, peerDependencies or devDependencies (the
|
|
212
|
+
// CLI's list), in the nearest node_modules up from `projectDir` that holds it (as the CLI looks). What
|
|
213
|
+
// a react-native.config.js changes, being code, isn't seen. -> the package dirs, as found
|
|
214
|
+
export function linkedPackages(projectDir, fs) {
|
|
215
|
+
const manifest = readJson(join(projectDir, 'package.json'), fs)
|
|
84
216
|
const listed = (field) => (manifest?.[field] && typeof manifest[field] === 'object' ? Object.keys(manifest[field]) : [])
|
|
85
217
|
const names = new Set([...listed('dependencies'), ...listed('peerDependencies'), ...listed('devDependencies')])
|
|
86
218
|
const dirs = []
|
|
@@ -89,7 +221,7 @@ export function linkedNativePackages(projectDir, fs) {
|
|
|
89
221
|
for (let dir = projectDir; ; dir = dirname(dir)) {
|
|
90
222
|
const found = join(dir, 'node_modules', name)
|
|
91
223
|
if (fs.stat(found)?.isDirectory()) {
|
|
92
|
-
|
|
224
|
+
dirs.push(found)
|
|
93
225
|
break
|
|
94
226
|
}
|
|
95
227
|
if (dirname(dir) === dir) break
|
|
@@ -97,3 +229,9 @@ export function linkedNativePackages(projectDir, fs) {
|
|
|
97
229
|
}
|
|
98
230
|
return dirs
|
|
99
231
|
}
|
|
232
|
+
|
|
233
|
+
// Of those, the ones `react-native config` links, as a native build would: those that ship native
|
|
234
|
+
// code (looksNative). -> the package dirs, as found
|
|
235
|
+
export function linkedNativePackages(projectDir, fs) {
|
|
236
|
+
return linkedPackages(projectDir, fs).filter((dir) => looksNative(dir, fs))
|
|
237
|
+
}
|