@exodus/stasis-plugins 1.0.0-beta.13 → 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 +61 -20
- package/src/metro-native.js +180 -42
- package/src/metro-transformer.js +20 -31
- package/src/metro.js +177 -59
- 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 &&
|
|
@@ -47,16 +53,43 @@ function specifierOf(node) {
|
|
|
47
53
|
return constantString(node.type === 'CallExpression' ? node.arguments[0] : node.source)
|
|
48
54
|
}
|
|
49
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 }
|
|
79
|
+
}
|
|
80
|
+
|
|
50
81
|
function asWrittenPlugin({ types: t }) {
|
|
51
82
|
return {
|
|
52
83
|
name: 'stasis-as-written',
|
|
53
84
|
pre(file) {
|
|
54
|
-
const
|
|
85
|
+
const context = transformContext.getStore()
|
|
86
|
+
context.types = t
|
|
87
|
+
const { slots } = context
|
|
55
88
|
let evaluate = false
|
|
56
89
|
t.traverseFast(file.ast, (node) => {
|
|
57
|
-
if (
|
|
90
|
+
if (!isDependency(node)) return
|
|
58
91
|
const written = specifierOf(node)
|
|
59
|
-
if (written !== undefined) slots.push({ node, written })
|
|
92
|
+
if (written !== undefined) slots.push({ node, written, types: node.type !== 'CallExpression' && typesOnly(node) })
|
|
60
93
|
else if (node.type === 'CallExpression' && node.arguments.length > 0) evaluate = true
|
|
61
94
|
})
|
|
62
95
|
// Metro takes any argument Babel evaluates to a string with confidence, one bound to a constant
|
|
@@ -64,18 +97,17 @@ function asWrittenPlugin({ types: t }) {
|
|
|
64
97
|
if (!evaluate) return
|
|
65
98
|
file.path.traverse({
|
|
66
99
|
CallExpression(path) {
|
|
67
|
-
if (!
|
|
100
|
+
if (!isDependency(path.node) || specifierOf(path.node) !== undefined || path.node.arguments.length === 0) return
|
|
68
101
|
const { confident, value } = path.get('arguments.0').evaluate()
|
|
69
102
|
if (confident && typeof value === 'string') slots.push({ node: path.node, written: value })
|
|
70
103
|
},
|
|
71
104
|
})
|
|
72
105
|
},
|
|
73
106
|
visitor: {
|
|
74
|
-
// Babel exits a node only if no plugin removed it on the way in, so a declaration
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// @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.
|
|
79
111
|
'ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration': {
|
|
80
112
|
exit(path) {
|
|
81
113
|
transformContext.getStore().kept.add(path.node)
|
|
@@ -91,15 +123,24 @@ export function transform(args) {
|
|
|
91
123
|
const slots = []
|
|
92
124
|
const kept = new Set()
|
|
93
125
|
Object.assign(context, { slots, kept })
|
|
94
|
-
// 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.
|
|
95
132
|
const collect = (result) => {
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
98
136
|
// One evaluated through its scope stays as written: no plugin rewrites what isn't a literal.
|
|
99
|
-
const
|
|
100
|
-
if (left
|
|
101
|
-
|
|
102
|
-
|
|
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)
|
|
103
144
|
}
|
|
104
145
|
return result
|
|
105
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
|
+
}
|
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.
|
|
@@ -93,8 +78,10 @@ function resolveBabelTransformer(base, path) {
|
|
|
93
78
|
// alike (`#local/abc` and `./src/abc`). One no specifier in the source names carries
|
|
94
79
|
// `stasisInserted`: Babel added it (a helper from @babel/runtime, the JSX runtime), or Metro did (its
|
|
95
80
|
// asyncRequire for an `import()`, an asset's registry). A dependency written as Metro names it is
|
|
96
|
-
// left as is.
|
|
97
|
-
|
|
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 }) {
|
|
98
85
|
if (!Array.isArray(result?.dependencies)) return result
|
|
99
86
|
const dependencies = result.dependencies.map((dep) => {
|
|
100
87
|
const written = asWritten.get(dep.name)
|
|
@@ -102,7 +89,9 @@ function withAsWritten(result, asWritten) {
|
|
|
102
89
|
if (written.size === 1 && written.has(dep.name)) return dep
|
|
103
90
|
return { ...dep, stasisAsWritten: [...written] }
|
|
104
91
|
})
|
|
105
|
-
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] }
|
|
106
95
|
}
|
|
107
96
|
|
|
108
97
|
export async function transform(config, projectRoot, filename, data, options) {
|
|
@@ -112,7 +101,7 @@ export async function transform(config, projectRoot, filename, data, options) {
|
|
|
112
101
|
if (state) {
|
|
113
102
|
// `filename` is project-relative in Metro's contract; resolve against projectRoot.
|
|
114
103
|
const absolute = resolvePath(projectRoot, filename)
|
|
115
|
-
if (
|
|
104
|
+
if (servedByBundle(state, absolute)) {
|
|
116
105
|
// getFile re-verifies the hash and throws on a file the bundle doesn't carry (fail-closed,
|
|
117
106
|
// no disk fallback). Code sources come back as strings; resources as Buffers.
|
|
118
107
|
const { source } = state.getFile(pathToFileURL(absolute).toString())
|
|
@@ -120,10 +109,10 @@ export async function transform(config, projectRoot, filename, data, options) {
|
|
|
120
109
|
}
|
|
121
110
|
}
|
|
122
111
|
if (typeof config.babelTransformerPath !== 'string') return base.transformer.transform(config, projectRoot, filename, data, options)
|
|
123
|
-
const context = { babelTransformerPath: resolveBabelTransformer(base, config.babelTransformerPath), asWritten: new Map() }
|
|
112
|
+
const context = { babelTransformerPath: resolveBabelTransformer(base, config.babelTransformerPath), asWritten: new Map(), consumed: new Set() }
|
|
124
113
|
const result = await transformContext.run(context, () =>
|
|
125
114
|
base.transformer.transform({ ...config, babelTransformerPath: BABEL_TRANSFORMER }, projectRoot, filename, data, options))
|
|
126
|
-
return withAsWritten(result, context
|
|
115
|
+
return withAsWritten(result, context)
|
|
127
116
|
}
|
|
128
117
|
|
|
129
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 { dirname, 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,13 +33,6 @@ function entrySet(graph) {
|
|
|
33
33
|
return new Set()
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// Files a React Native build requires that Metro's module graph never carries; attested (tagged by
|
|
37
|
-
// the shared classifier, see #captureAutoIncludes) when they resolve from the project.
|
|
38
|
-
const AUTO_INCLUDES = [
|
|
39
|
-
'metro-runtime/src/modules/asyncRequire.js',
|
|
40
|
-
'@react-native-community/cli/setup_env.sh',
|
|
41
|
-
]
|
|
42
|
-
|
|
43
36
|
// Metro's own transform plugins, as its transform worker names them for its cache key: the file
|
|
44
37
|
// `metro-transform-plugins` resolves to from the project, else through metro and its transform
|
|
45
38
|
// worker, which depend on it (a pnpm install links it only beside them). -> [] where none resolves
|
|
@@ -152,6 +145,10 @@ function metroDefaultSerializer() {
|
|
|
152
145
|
// transformerPath -- out of stasis's hands (see withStasis).
|
|
153
146
|
export class StasisMetro {
|
|
154
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
|
|
155
152
|
#state
|
|
156
153
|
#resources
|
|
157
154
|
|
|
@@ -262,12 +259,11 @@ export class StasisMetro {
|
|
|
262
259
|
|
|
263
260
|
#capture(graph, preModules) {
|
|
264
261
|
const entries = entrySet(graph)
|
|
262
|
+
const platform = graph.transformOptions?.platform ?? null
|
|
265
263
|
// preModules first (Metro's prepended polyfills/runtime; synthetic ones like __prelude__ are
|
|
266
264
|
// skipped by the existsSync guard below), then the app graph.
|
|
267
|
-
if (preModules) this.#captureModules(preModules, entries)
|
|
268
|
-
this.#captureModules(graph.dependencies.values(), entries)
|
|
269
|
-
// AFTER the graph so #seen already holds graph modules -- an auto-include that landed in the
|
|
270
|
-
// 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)
|
|
271
267
|
this.#captureAutoIncludes()
|
|
272
268
|
this.#captureTransformPlugins()
|
|
273
269
|
// AFTER the graph + auto-includes (same dedupe reason): the native pass only adds the native
|
|
@@ -283,8 +279,9 @@ export class StasisMetro {
|
|
|
283
279
|
this.#state.addFile(pathToFileURL(full).toString(), { source, format: format ?? undefined, resource, inferFormat, reason })
|
|
284
280
|
}
|
|
285
281
|
|
|
286
|
-
// Attest the AUTO_INCLUDES list.
|
|
287
|
-
// 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.
|
|
288
285
|
#captureAutoIncludes() {
|
|
289
286
|
for (const specifier of AUTO_INCLUDES) {
|
|
290
287
|
let modPath
|
|
@@ -293,21 +290,15 @@ export class StasisMetro {
|
|
|
293
290
|
} catch {
|
|
294
291
|
continue
|
|
295
292
|
}
|
|
296
|
-
if (this.#seen.has(modPath)) continue
|
|
297
|
-
try {
|
|
298
|
-
this.#state.relative(modPath)
|
|
299
|
-
} catch {
|
|
300
|
-
continue
|
|
301
|
-
}
|
|
293
|
+
if (this.#seen.has(modPath) || !this.#within(modPath)) continue
|
|
302
294
|
this.#seen.add(modPath)
|
|
303
295
|
// Real reader: plugin bookkeeping, not a program fs read -- must not be re-recorded by --fs.
|
|
304
296
|
const source = realReadFileSync(modPath)
|
|
305
297
|
// Tag from the ONE shared classifier so an auto-include agrees with any other path meeting
|
|
306
298
|
// the same file (native walk, --fs): setup_env.sh -> 'shell', not a forced 'resource' that
|
|
307
299
|
// would collide at the format noupsert. Unrecognized -> resource (vetted list, so no allowlist).
|
|
308
|
-
// JS (asyncRequire.js) is code Metro builds, so it takes no format, as the graph's doesn't.
|
|
309
300
|
const format = classifyFormat(modPath, { content: source })
|
|
310
|
-
this.#recordCapture(modPath, source, { format, resource: format === undefined, inferFormat: false })
|
|
301
|
+
this.#recordCapture(modPath, source, { format, resource: format === undefined, inferFormat: false, reason: 'run' })
|
|
311
302
|
}
|
|
312
303
|
}
|
|
313
304
|
|
|
@@ -321,12 +312,7 @@ export class StasisMetro {
|
|
|
321
312
|
// build's toolchain, none of it the app's. One outside the project root is passed over.
|
|
322
313
|
#captureTransformPlugins() {
|
|
323
314
|
for (const file of metroTransformPluginFiles(this.#projectDir)) {
|
|
324
|
-
if (this.#seen.has(file)) continue
|
|
325
|
-
try {
|
|
326
|
-
this.#state.relative(file)
|
|
327
|
-
} catch {
|
|
328
|
-
continue
|
|
329
|
-
}
|
|
315
|
+
if (this.#seen.has(file) || !this.#within(file)) continue
|
|
330
316
|
this.#seen.add(file)
|
|
331
317
|
try {
|
|
332
318
|
require(file)
|
|
@@ -353,23 +339,12 @@ export class StasisMetro {
|
|
|
353
339
|
// RN CORE isn't a `dependencies` entry (config reports only reactNativePath) and autolinking
|
|
354
340
|
// never lists it, but its native source (React/, ReactCommon/, ReactAndroid/, sdks/, ...) is a
|
|
355
341
|
// build input like any other dep's -- walk it in full, the same way.
|
|
356
|
-
const rnPath = config.reactNativePath
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
this.#state.relative(root) // asserts the package lives inside the project root
|
|
361
|
-
} catch {
|
|
362
|
-
continue
|
|
363
|
-
}
|
|
364
|
-
this.#captureNativeTree(root)
|
|
365
|
-
}
|
|
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)
|
|
366
346
|
// RN core's `.js` build scripts the walk skips (Metro-owned by extension, but never in the graph).
|
|
367
|
-
if (
|
|
368
|
-
try {
|
|
369
|
-
this.#state.relative(rnPath)
|
|
370
|
-
for (const file of RN_CORE_INCLUDE_FILES) this.#captureNativeFile(join(rnPath, file))
|
|
371
|
-
} catch { /* react-native resolved outside the project root -- unattestable */ }
|
|
372
|
-
}
|
|
347
|
+
if (rnPath !== null && this.#within(rnPath)) for (const file of RN_CORE_INCLUDE_FILES) this.#captureNativeFile(join(rnPath, file))
|
|
373
348
|
}
|
|
374
349
|
|
|
375
350
|
// Attest a single vetted file (RN_CORE_INCLUDE_FILES) the tree walk skips; absent -> skipped.
|
|
@@ -401,12 +376,13 @@ export class StasisMetro {
|
|
|
401
376
|
}
|
|
402
377
|
|
|
403
378
|
// Attest the native build inputs of the package at `root` (nativeInputs, shared with `stasis bundle
|
|
404
|
-
// --metro`), but what is already captured as a graph module or auto-include
|
|
405
|
-
//
|
|
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.
|
|
406
382
|
#captureNativeTree(root) {
|
|
407
383
|
for (const { file, source, action, format } of nativeInputs(root, diskHost, { resources: this.#resources, skip: (f) => this.#seen.has(f) })) {
|
|
408
384
|
this.#seen.add(file)
|
|
409
|
-
this.#recordCapture(file, source, { format, resource: action === 'resource' })
|
|
385
|
+
this.#recordCapture(file, source, { format, resource: action === 'resource', inferFormat: false })
|
|
410
386
|
}
|
|
411
387
|
}
|
|
412
388
|
|
|
@@ -422,6 +398,8 @@ export class StasisMetro {
|
|
|
422
398
|
if (modPath === undefined) continue
|
|
423
399
|
const url = pathToFileURL(modPath).toString()
|
|
424
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)
|
|
425
403
|
}
|
|
426
404
|
}
|
|
427
405
|
|
|
@@ -446,17 +424,14 @@ export class StasisMetro {
|
|
|
446
424
|
return edges
|
|
447
425
|
}
|
|
448
426
|
|
|
449
|
-
#captureModules(modules, entries) {
|
|
427
|
+
#captureModules(modules, entries, platform) {
|
|
450
428
|
for (const module of modules) {
|
|
451
429
|
const modPath = diskPath(module)
|
|
452
430
|
if (modPath === undefined) continue
|
|
453
431
|
|
|
454
432
|
const kind = classifyExtension(modPath, this.#resources)
|
|
455
433
|
if (kind === 'unknown') {
|
|
456
|
-
throw new Error(
|
|
457
|
-
`StasisMetro: unsupported extension for '${modPath}' -- ` +
|
|
458
|
-
`add its extension or filename to the plugin's resources option or stop importing it`
|
|
459
|
-
)
|
|
434
|
+
throw new Error(unsupportedExtension('StasisMetro', `'${modPath}'`))
|
|
460
435
|
}
|
|
461
436
|
const url = pathToFileURL(modPath).toString()
|
|
462
437
|
for (const [specifier, target] of this.#edges(module)) this.#state.addImport(url, specifier, target)
|
|
@@ -472,10 +447,153 @@ export class StasisMetro {
|
|
|
472
447
|
assert.ok(isUtf8(source), `StasisMetro: code-classified file has non-UTF-8 bytes: ${modPath}`)
|
|
473
448
|
}
|
|
474
449
|
// No format for a code file: Metro transforms it from source, so Node's module/commonjs isn't
|
|
475
|
-
// what runs
|
|
476
|
-
|
|
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
|
|
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),
|
|
477
538
|
}
|
|
478
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
|
+
)
|
|
596
|
+
}
|
|
479
597
|
}
|
|
480
598
|
|
|
481
599
|
// Idiomatic Metro-config wrapper: returns a new config with `serializer.customSerializer` wired
|
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
|
}
|
package/src/rollup.js
CHANGED
|
@@ -6,12 +6,12 @@ import { dirname, isAbsolute, resolve as resolvePath } from 'node:path'
|
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
7
7
|
|
|
8
8
|
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
9
|
-
import { resolvePluginState } from './plugins.js'
|
|
9
|
+
import { attestedImport, resolvePluginState, unsupportedExtension } from './plugins.js'
|
|
10
10
|
import { State } from '@exodus/stasis-core/state'
|
|
11
11
|
// Pre-patch snapshot, not `import { readFile }`: under --fs=async that builtin is patched, and
|
|
12
12
|
// this plugin loads after, so a direct import would route rollup's reads through the capture hook.
|
|
13
|
-
import { realReadFile } from '@exodus/stasis-core/state-util'
|
|
14
|
-
import { classifyExtension } from '@exodus/stasis-core/util'
|
|
13
|
+
import { inScope, realReadFile } from '@exodus/stasis-core/state-util'
|
|
14
|
+
import { classifyExtension, isPathWithin } from '@exodus/stasis-core/util'
|
|
15
15
|
|
|
16
16
|
// Rollup chains resolveId/load hooks first-non-null-wins, so put this plugin FIRST in `plugins`:
|
|
17
17
|
// listed later, a sibling resolver (node-resolve, commonjs) answers before stasis sees the edge --
|
|
@@ -145,20 +145,13 @@ export class StasisRollup {
|
|
|
145
145
|
#classifyOrThrow(id) {
|
|
146
146
|
const kind = classifyExtension(id, this.#resources)
|
|
147
147
|
if (kind === 'unknown') {
|
|
148
|
-
throw new Error(
|
|
149
|
-
`StasisRollup: unsupported extension for '${id}' -- add its extension or filename to the plugin's resources option or stop importing it`
|
|
150
|
-
)
|
|
148
|
+
throw new Error(unsupportedExtension('StasisRollup', `'${id}'`))
|
|
151
149
|
}
|
|
152
150
|
return kind
|
|
153
151
|
}
|
|
154
152
|
|
|
155
153
|
#inRoot(path) {
|
|
156
|
-
|
|
157
|
-
this.#state.relative(path)
|
|
158
|
-
return true
|
|
159
|
-
} catch {
|
|
160
|
-
return false
|
|
161
|
-
}
|
|
154
|
+
return isPathWithin(this.#state.root, path)
|
|
162
155
|
}
|
|
163
156
|
|
|
164
157
|
async #resolveId(ctx, source, importer, opts) {
|
|
@@ -191,21 +184,15 @@ export class StasisRollup {
|
|
|
191
184
|
this.#assertPersistableAttributes(attrs)
|
|
192
185
|
// Forward import attributes (`with { type: 'json' }`) so plugin-capture round-trips to
|
|
193
186
|
// plugin-load, both keyed under `* (with: ...)`.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
// No attested edge -- at a successful capture that's an EXTERNAL (never recorded as an
|
|
199
|
-
// edge), so hand it back to rollup. File BYTES still fail closed at the load hook.
|
|
200
|
-
// A non-MODULE_NOT_FOUND error is a real fault -- rethrow.
|
|
201
|
-
if (err?.code === 'ERR_MODULE_NOT_FOUND') return null
|
|
202
|
-
throw err
|
|
203
|
-
}
|
|
187
|
+
// No attested edge: rollup's. File BYTES still fail closed at the load hook.
|
|
188
|
+
const attested = attestedImport(this.#state, parentURL, source, { importAttributes: attrs })
|
|
189
|
+
if (attested === undefined) return null
|
|
190
|
+
const { url, format } = attested
|
|
204
191
|
const path = fileURLToPath(url)
|
|
205
192
|
// Refuse a resource only when load mode would actually serve it: an out-of-scope workspace
|
|
206
193
|
// resource under node_modules scope comes from disk via the user's asset plugin, exactly
|
|
207
194
|
// like a capture build (the load hook defers it with the same scope gate).
|
|
208
|
-
if (Bundle.isResourceFormat(format) && (this.#state
|
|
195
|
+
if (Bundle.isResourceFormat(format) && inScope(this.#state, url)) {
|
|
209
196
|
this.#refuseResourceLoad(path)
|
|
210
197
|
}
|
|
211
198
|
// KNOWN GAP: sibling resolution metadata (moduleSideEffects, syntheticNamedExports, meta)
|
|
@@ -253,7 +240,7 @@ export class StasisRollup {
|
|
|
253
240
|
// a sibling's bare resolve() probe carries isEntry too but never loads, so only genuine
|
|
254
241
|
// roots reach this gate.
|
|
255
242
|
if (this.#state.config.full && ctx.getModuleInfo(id)?.isEntry) this.#state.assertEntry(url)
|
|
256
|
-
if (!this.#state
|
|
243
|
+
if (!inScope(this.#state, url)) return null
|
|
257
244
|
const { source, format } = this.#state.getFile(url)
|
|
258
245
|
if (Bundle.isResourceFormat(format)) this.#refuseResourceLoad(id)
|
|
259
246
|
// Code is stored as UTF-8 text, exactly what rollup's parser expects.
|
package/src/webpack.js
CHANGED
|
@@ -5,9 +5,9 @@ import { createRequire, isBuiltin } from 'node:module'
|
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
7
7
|
|
|
8
|
-
import { resolvePluginState } from './plugins.js'
|
|
8
|
+
import { attestedImport, resolvePluginState, unsupportedExtension } from './plugins.js'
|
|
9
9
|
import { State } from '@exodus/stasis-core/state'
|
|
10
|
-
import { realReadFileSync } from '@exodus/stasis-core/state-util'
|
|
10
|
+
import { realReadFileSync, servedByBundle } from '@exodus/stasis-core/state-util'
|
|
11
11
|
import { classifyExtension } from '@exodus/stasis-core/util'
|
|
12
12
|
|
|
13
13
|
// CJS require anchored here, used only to locate the PROJECT's babel-plugin-module-resolver below.
|
|
@@ -95,14 +95,8 @@ export class StasisWebpack {
|
|
|
95
95
|
const state = this.#state
|
|
96
96
|
const orig = compiler.inputFileSystem
|
|
97
97
|
|
|
98
|
-
// True iff `p` is
|
|
99
|
-
|
|
100
|
-
const inScope = (p) => {
|
|
101
|
-
if (typeof p !== 'string') return false
|
|
102
|
-
try { state.relative(p) } catch { return false }
|
|
103
|
-
if (state.config.full) return true
|
|
104
|
-
return state.inNodeModules(pathToFileURL(p).toString())
|
|
105
|
-
}
|
|
98
|
+
// True iff `p` is served from the bundle under load (servedByBundle).
|
|
99
|
+
const served = (p) => typeof p === 'string' && p !== '' && servedByBundle(state, p)
|
|
106
100
|
|
|
107
101
|
// Family = the sidecar's own app-graph bundle first, then its parent (loaders + resolution-path
|
|
108
102
|
// dirs the ambient preload captured). Both share one lockfile; no parent -> just [state].
|
|
@@ -121,7 +115,7 @@ export class StasisWebpack {
|
|
|
121
115
|
// must keep `this` = orig; we override only readFile and stat.
|
|
122
116
|
const wrappedReadFile = (p, options, callback) => {
|
|
123
117
|
if (typeof options === 'function') { callback = options; options = undefined }
|
|
124
|
-
if (
|
|
118
|
+
if (served(p)) {
|
|
125
119
|
try {
|
|
126
120
|
const src = serveFromBundle(p)
|
|
127
121
|
const buf = Buffer.isBuffer(src) ? src : Buffer.from(src)
|
|
@@ -136,7 +130,7 @@ export class StasisWebpack {
|
|
|
136
130
|
return orig.readFile(p, options, callback)
|
|
137
131
|
}
|
|
138
132
|
const wrappedStat = (p, callback) => {
|
|
139
|
-
if (
|
|
133
|
+
if (served(p)) {
|
|
140
134
|
// Directories have no bundle content, so serve a synthetic dir Stats from the family's
|
|
141
135
|
// existence record (getFsStat) -- webpack's resolver stat()s intermediate dirs while walking.
|
|
142
136
|
const url = pathToFileURL(p).toString()
|
|
@@ -168,21 +162,14 @@ export class StasisWebpack {
|
|
|
168
162
|
const issuer = data.contextInfo?.issuer
|
|
169
163
|
if (!issuer) return // entries: already absolute, inputFileSystem covers them
|
|
170
164
|
// Out-of-scope parents were never the bundle's responsibility -- defer to webpack's resolver.
|
|
171
|
-
if (!
|
|
165
|
+
if (!served(issuer)) return
|
|
172
166
|
// Built-ins are never bundled or recorded as edges; skip up front. SECURITY: never consult
|
|
173
167
|
// getImport for a builtin name, so a tampered bundle can't redirect `fs` to an arbitrary file.
|
|
174
168
|
if (isBuiltin(data.request)) return
|
|
175
169
|
const parentURL = pathToFileURL(issuer).toString()
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
} catch (err) {
|
|
180
|
-
// No attested edge -- at a successful capture that's an EXTERNAL (never recorded as an
|
|
181
|
-
// edge), so defer to webpack. File BYTES still fail closed at the inputFileSystem wrapper.
|
|
182
|
-
// A non-MODULE_NOT_FOUND error is a real fault -- rethrow.
|
|
183
|
-
if (err?.code === 'ERR_MODULE_NOT_FOUND') return
|
|
184
|
-
throw err
|
|
185
|
-
}
|
|
170
|
+
// No attested edge: defer to webpack. File BYTES still fail closed at the inputFileSystem wrapper.
|
|
171
|
+
const resolved = attestedImport(state, parentURL, data.request)
|
|
172
|
+
if (resolved === undefined) return
|
|
186
173
|
// Redirect the resolver to the absolute bundle path; the stat wrapper then reports it exists.
|
|
187
174
|
data.request = fileURLToPath(resolved.url)
|
|
188
175
|
})
|
|
@@ -267,10 +254,7 @@ export class StasisWebpack {
|
|
|
267
254
|
|
|
268
255
|
const kind = classifyExtension(filePath, this.#resources)
|
|
269
256
|
if (kind === 'unknown') {
|
|
270
|
-
throw new Error(
|
|
271
|
-
`StasisWebpack: unsupported extension for '${filePath}' -- ` +
|
|
272
|
-
`add its extension or filename to the plugin's resources option or stop importing it`
|
|
273
|
-
)
|
|
257
|
+
throw new Error(unsupportedExtension('StasisWebpack', `'${filePath}'`))
|
|
274
258
|
}
|
|
275
259
|
|
|
276
260
|
const url = pathToFileURL(filePath).toString()
|