@exodus/stasis-plugins 1.0.0-beta.9 → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ import { isUtf8 } from 'node:buffer'
2
+ import { basename, dirname, join, posix, relative, sep } from 'node:path'
3
+
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, upsert } from '@exodus/stasis-core/util'
7
+
8
+ // The native build inputs of a React Native app's packages, shared by the Metro plugin's capture and `stasis bundle
9
+ // --metro` so the two carry the same files. `fs` is a host: readdir(dir) -> Dirent[] (throws where `dir` isn't one),
10
+ // readFile(file) -> Buffer, stat(path) -> Stats, or null where nothing is there.
11
+
12
+ // Files a React Native build requires that neither Metro's graph nor a run's loader carries, resolved from the project
13
+ // root: setup_env.sh, which the React Native CLI runs as every command starts (`react-native config` included).
14
+ export const AUTO_INCLUDES = ['@react-native-community/cli/setup_env.sh']
15
+
16
+ export function listing(dir, fs) {
17
+ try {
18
+ return fs.readdir(dir)
19
+ } catch {
20
+ return []
21
+ }
22
+ }
23
+
24
+ // RN core build scripts the native walk would defer to Metro as `.js`, but Metro never sees them (RN's podspecs
25
+ // invoke them at pod-install). Project-relative, so react-native's `exports` can't hide them.
26
+ export const RN_CORE_INCLUDE_FILES = ['sdks/hermes-engine/utils/replace_hermes_version.js']
27
+
28
+ // By name: RN scatters podspecs in subdirs `react-native config` misses.
29
+ const isPodspec = (name) => name.endsWith('.podspec') || name.endsWith('.podspec.json')
30
+
31
+ // The off-platform top-level dir of a native package, which no build on this host compiles.
32
+ export function isExcludedNativeDir(name, { win32 = process.platform === 'win32' } = {}) {
33
+ return !win32 && name === 'windows'
34
+ }
35
+
36
+ // Nested packages (attested separately) and regenerated build output, on top of the shared set.
37
+ const NATIVE_BUILD_OUTPUT_DIRS = new Set(['node_modules', 'build', '.gradle', '.cxx', 'Pods', 'DerivedData'])
38
+ const isSkippedNativeWalkDir = (name) => NATIVE_BUILD_OUTPUT_DIRS.has(name) || isAutoExcludedDir(name) || isNativeArtifact(name)
39
+
40
+ // A compiled tool (Hermes' `hermesc`), never source.
41
+ const isExtensionlessBinary = (name, content) => pathExt(name) === '' && Buffer.isBuffer(content) && !isUtf8(content)
42
+
43
+ // A native capture's view of a file by name: { action: 'code', format } for a native build input, 'skip' for noise
44
+ // or a JS-family file by extension (Metro owns those), else 'resource'.
45
+ export function classifyNativeCapture(name, { win32 = process.platform === 'win32', content } = {}) {
46
+ // `.env` files carry secrets: an automated capture must never sweep them in.
47
+ if (isExcludedNativeFile(name, { win32 }) || isDotEnvFile(name)) return { action: 'skip' }
48
+ // Name-matched code is a native build input even where its tag is a Node format the JS graph would own.
49
+ const byName = nameFormat(name)
50
+ if (byName !== undefined) return { action: 'code', format: byName }
51
+ if (CODE_EXTENSIONS.has(pathExt(name))) return { action: 'skip' }
52
+ const format = classifyFormat(name, { content })
53
+ return format === undefined ? { action: 'resource' } : { action: 'code', format }
54
+ }
55
+
56
+ // classifyNativeCapture refined by the bytes, which only ever demote; a 'resource' gets its format from its bytes.
57
+ export function refineNativeCapture(classified, name, content, resources = new Set()) {
58
+ if (isExtensionlessBinary(name, content)) return { action: 'skip' }
59
+ if (isBinaryPlist(name, content)) return { action: resources.has('plist') ? 'resource' : 'skip' }
60
+ return classified
61
+ }
62
+
63
+ // A file name as Metro's AssetPaths reads it (parsePlatformFilePath, then parseBaseName): its asset, `${name}.${type}`
64
+ // less any scale (`a@2x.png` is `a.png`'s), its scale (`resolution`, 1 where it names none), and its platform where it
65
+ // names `platform` (`a.ios.png`; `a.android.png` is an asset of its own). -> null for a name with no type
66
+ const METRO_PLATFORM_FILE = /^(.+?)(?:\.([^.]+))?\.([^.]+)$/u
67
+ const METRO_ASSET_SCALE = /^(.+?)(?:@([\d.]+)x)?$/u
68
+ export function parseMetroAssetName(name, platform) {
69
+ const match = METRO_PLATFORM_FILE.exec(name)
70
+ if (match === null) return null
71
+ const [, base, suffix, type] = match
72
+ const ofPlatform = suffix !== undefined && suffix === platform
73
+ const [, root, scale] = METRO_ASSET_SCALE.exec(suffix === undefined || ofPlatform ? base : `${base}.${suffix}`)
74
+ const resolution = Number.parseFloat(scale)
75
+ return { asset: `${root}.${type}`, name: root, type, resolution: Number.isNaN(resolution) ? 1 : resolution, platform: ofPlatform ? platform : null }
76
+ }
77
+
78
+ // A file's stat for metroAssetFiles, where a listing names one the disk lacks.
79
+ export const FILE_STAT = { isFile: () => true }
80
+
81
+ // The files `state` (a loaded State) carries, as a directory listing of them (metroAssetFiles' host).
82
+ export function bundleDirHost(state) {
83
+ const dirs = Map.groupBy([...state.sources.keys(), ...state.resources.keys()], (rel) => posix.dirname(rel))
84
+ return {
85
+ readdir: (dir) => (dirs.get(toPosix(state.relative(dir)) || '.') ?? []).map((rel) => ({ name: posix.basename(rel) })),
86
+ stat: () => FILE_STAT,
87
+ }
88
+ }
89
+
90
+ // Metro's record of the asset module `path` (getAbsoluteAssetRecord): the files it reads to build it and copies out
91
+ // with it, each of the asset's name and type at any scale in its directory, those for `platform` where it has any,
92
+ // else those for none. -> absolute paths
93
+ export function metroAssetFiles(path, platform = null, host = diskHost) {
94
+ const own = parseMetroAssetName(basename(path), platform)
95
+ if (own === null) return []
96
+ const dir = dirname(path)
97
+ const byPlatform = new Map()
98
+ for (const { name } of host.readdir(dir)) {
99
+ const parsed = parseMetroAssetName(name, platform)
100
+ if (parsed?.asset !== own.asset || host.stat(join(dir, name))?.isFile() !== true) continue
101
+ upsert(byPlatform, parsed.platform, () => []).push(join(dir, name))
102
+ }
103
+ return (platform === null ? undefined : byPlatform.get(platform)) ?? byPlatform.get(null) ?? []
104
+ }
105
+
106
+ // Whether the package at `root` ships native code: an ios/ or android/ dir, or a podspec under it.
107
+ export function looksNative(root, fs) {
108
+ return fs.stat(join(root, 'ios')) !== null || fs.stat(join(root, 'android')) !== null || hasPodspec(root, fs)
109
+ }
110
+
111
+ function hasPodspec(dir, fs) {
112
+ return listing(dir, fs).some((ent) => !ent.isSymbolicLink() &&
113
+ (ent.isFile() ? isPodspec(ent.name) : ent.isDirectory() && !isSkippedNativeWalkDir(ent.name) && hasPodspec(join(dir, ent.name), fs)))
114
+ }
115
+
116
+ // A spec React Native's codegen parses (its filterJSFile): a JS or TS module named `Native…` or `…NativeComponent`,
117
+ // but a type declaration.
118
+ const isCodegenSpec = (name) => /^(?:Native.+|.+NativeComponent)/u.test(name) && /\.(?:jsx?|tsx?)$/u.test(name) && !name.endsWith('.d.ts')
119
+
120
+ // Each native build input of the package at `root`, walked whole (a podspec may compile `cpp/` beside `ios/`) but
121
+ // build output, prebuilt binaries, an off-platform top-level dir, symlinks and what the shared classifier skips; and
122
+ // its codegen specs, as code with no format. -> yields { file, source, action: 'code' | 'resource', format }
123
+ export function* nativeInputs(root, fs, { resources = new Set(), skip = () => false } = {}) {
124
+ yield* walk(root, true)
125
+ for (const dir of codegenSources(root, fs)) yield* sources(dir)
126
+ function* sources(dir) {
127
+ for (const ent of listing(dir, fs)) {
128
+ // As codegen skips a dot, node_modules or tests directory.
129
+ if (ent.isSymbolicLink() || ent.name.startsWith('.') || ent.name === 'node_modules' || ent.name.startsWith('__tests')) continue
130
+ const file = join(dir, ent.name)
131
+ if (ent.isDirectory()) yield* sources(file)
132
+ else if (ent.isFile() && isCodegenSpec(ent.name) && !skip(file)) yield { file, source: fs.readFile(file), action: 'code', format: undefined }
133
+ }
134
+ }
135
+ function* walk(dir, atRoot) {
136
+ for (const ent of listing(dir, fs)) {
137
+ if (ent.isSymbolicLink()) continue
138
+ const file = join(dir, ent.name)
139
+ if (ent.isDirectory()) {
140
+ if (!isSkippedNativeWalkDir(ent.name) && !(atRoot && isExcludedNativeDir(ent.name))) yield* walk(file, false)
141
+ continue
142
+ }
143
+ if (!ent.isFile() || isNativeArtifact(ent.name) || skip(file)) continue
144
+ const byName = classifyNativeCapture(ent.name)
145
+ if (byName.action === 'skip') continue
146
+ const source = fs.readFile(file)
147
+ const { action, format } = refineNativeCapture(byName, ent.name, source, resources)
148
+ if (action !== 'skip') yield { file, source, action, format }
149
+ }
150
+ }
151
+ }
152
+
153
+ // The dirs the package's `codegenConfig` names as its JS sources (`jsSrcsDir`, or each of its `libraries`'), each
154
+ // reached through no symlink, as the walk follows none: codegen parses the specs there for the native code a build
155
+ // compiles, and `react-native config` reads them for the components they declare. -> the absolute dirs
156
+ function codegenSources(root, fs) {
157
+ const config = readJson(join(root, 'package.json'), fs)?.codegenConfig
158
+ const named = [config?.jsSrcsDir, ...(Array.isArray(config?.libraries) ? config.libraries.map((library) => library?.jsSrcsDir) : [])]
159
+ return named.filter((dir) => typeof dir === 'string' && dir !== '').map((dir) => packageDir(root, dir, fs)).filter((dir) => dir !== null)
160
+ }
161
+
162
+ // The directory `rel` names in the package at `root`, each step a directory entry (not a link, not `..`), else null.
163
+ function packageDir(root, rel, fs) {
164
+ let dir = root
165
+ for (const name of relative(root, join(root, rel)).split(sep)) {
166
+ if (name === '') continue
167
+ if (!listing(dir, fs).some((ent) => ent.name === name && ent.isDirectory())) return null
168
+ dir = join(dir, name)
169
+ }
170
+ return dir
171
+ }
172
+
173
+ // A package name as npm takes one (`name` or `@scope/name`), which a path can't climb out with.
174
+ const PACKAGE_NAME = /^(?:@[^/\\@.][^/\\]*\/)?[^/\\@.][^/\\]*$/u
175
+
176
+ // The app's packages `react-native config` reads, as data rather than by running the CLI: each name its package.json
177
+ // lists under dependencies, peerDependencies or devDependencies, in the nearest node_modules up from `projectDir` that
178
+ // holds it. What a react-native.config.js changes, being code, isn't seen. -> the package dirs
179
+ export function linkedPackages(projectDir, fs) {
180
+ const manifest = readJson(join(projectDir, 'package.json'), fs)
181
+ const listed = (field) => (manifest?.[field] && typeof manifest[field] === 'object' ? Object.keys(manifest[field]) : [])
182
+ const names = new Set([...listed('dependencies'), ...listed('peerDependencies'), ...listed('devDependencies')])
183
+ const dirs = []
184
+ for (const name of names) {
185
+ if (!PACKAGE_NAME.test(name)) continue
186
+ for (let dir = projectDir; ; dir = dirname(dir)) {
187
+ const found = join(dir, 'node_modules', name)
188
+ if (fs.stat(found)?.isDirectory()) {
189
+ dirs.push(found)
190
+ break
191
+ }
192
+ if (dirname(dir) === dir) break
193
+ }
194
+ }
195
+ return dirs
196
+ }
197
+
198
+ // Those of linkedPackages a native build links: those that ship native code.
199
+ export function linkedNativePackages(projectDir, fs) {
200
+ return linkedPackages(projectDir, fs).filter((dir) => looksNative(dir, fs))
201
+ }
@@ -1,85 +1,98 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import { createRequire } from 'node:module'
3
- import { resolve as resolvePath } from 'node:path'
4
- import { pathToFileURL } from 'node:url'
3
+ import { dirname, isAbsolute, resolve as resolvePath } from 'node:path'
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'
8
+ import { transformContext } from './metro-babel-transformer.js'
7
9
 
8
- // Companion to the StasisMetro serializer: the LOAD half of stasis for Metro. Load only READS an
9
- // immutable bundle and every worker reads the same bytes (no IPC), so a per-worker transformer is
10
- // the natural seam. Wire it permanently in metro.config.js (transformerPath); outside load mode it
11
- // is a transparent pass-through, and under load the serializer is too -- the mode picks the active
12
- // half. In load mode, wrap the user's real transformer and replace Metro's on-disk `data` with the
13
- // bundle's hash-verified bytes (fail-closed; out-of-scope files pass their disk bytes through).
14
- // KNOWN LIMITATION: does NOT build with sources absent from disk -- Metro reads + hashes each file
15
- // before this runs, so the guarantee is "build attested bytes, fail closed on disk drift."
10
+ // The worker half of StasisMetro, wired by withStasis() as `transformerPath` around the project's transformer. It
11
+ // keeps each import as written for the serializer (./metro-babel-transformer.js), and is the LOAD half: a per-worker
12
+ // seam, as load only reads an immutable bundle, replacing Metro's `data` with the bundle's hash-verified bytes. Metro
13
+ // reads and hashes each file before this runs, so sources must stay on disk: attested bytes, fail closed on drift.
16
14
 
17
15
  const require = createRequire(import.meta.url)
18
16
 
19
- // The real transformer we wrap (default matches Metro's; override for a custom TS/Babel preset).
20
- // Required lazily per worker so a non-load build pays nothing.
21
- const BASE_TRANSFORMER = process.env.EXODUS_STASIS_METRO_BASE_TRANSFORMER || 'metro-transform-worker'
22
-
23
- let base
24
- function getBase() {
25
- base ??= require(BASE_TRANSFORMER)
26
- return base
17
+ // The project's transformer: the `transformerPath` withStasis() replaced, else EXODUS_STASIS_METRO_BASE_TRANSFORMER,
18
+ // else Metro's default, a name resolved from the project root first, as Metro requires it from its own dir. This
19
+ // transformer itself (wired by hand as well) means Metro's default.
20
+ const bases = new Map()
21
+ function baseOf(config, projectRoot = process.cwd()) {
22
+ const path = config?.stasisBaseTransformerPath ?? (process.env.EXODUS_STASIS_METRO_BASE_TRANSFORMER || 'metro-transform-worker')
23
+ const key = `${projectRoot}\0${path}`
24
+ if (!bases.has(key)) {
25
+ const resolve = (name) => require.resolve(name, { paths: [projectRoot, dirname(fileURLToPath(import.meta.url))] })
26
+ let file = resolve(path)
27
+ if (require(file).transform === transform) file = resolve('metro-transform-worker')
28
+ bases.set(key, { file, transformer: require(file) })
29
+ }
30
+ return bases.get(key)
27
31
  }
28
32
 
29
- // Per-worker load State, built once from the usual env + stasis.config.json. Non-load mode -> null
30
- // -> transparent pass-through, so wiring it permanently is safe. A construction failure is cached
31
- // and rethrown on every call -- never throw-once-then-silently-pass-through.
32
- let stateInited = false
33
- let loadState = null
34
- let stateError = null
33
+ const baseConfig = ({ stasisBaseTransformerPath: _, ...config } = {}) => config
34
+
35
+ // The worker's load State, null outside load (a pass-through, so wiring this permanently is safe). A failure is
36
+ // cached and rethrown on every call, never passed through after the first. It never writes, so claims no write path.
37
+ let loaded
35
38
  function getLoadState() {
36
- if (!stateInited) {
39
+ if (loaded === undefined) {
37
40
  try {
38
- const state = new State(process.cwd())
39
- loadState = state.config.loadBundle ? state : null
40
- } catch (err) {
41
- stateError = err
42
- throw err
43
- } finally {
44
- stateInited = true
41
+ const state = new State(process.cwd(), { claim: false })
42
+ loaded = { state: state.config.loadBundle ? state : null }
43
+ } catch (error) {
44
+ loaded = { error }
45
45
  }
46
46
  }
47
- if (stateError) throw stateError
48
- return loadState
47
+ if ('error' in loaded) throw loaded.error
48
+ return loaded.state
49
49
  }
50
50
 
51
- // True iff `absolute` is in scope for load mode. state.relative throws outside the project root
52
- // (out-of-scope: system files, scratch) -> defer to disk. Mirrors the webpack plugin's inScope.
53
- function inScope(state, absolute) {
54
- try {
55
- state.relative(absolute)
56
- } catch {
57
- return false
58
- }
59
- if (state.config.full) return true
60
- return state.inNodeModules(pathToFileURL(absolute).toString())
51
+ const BABEL_TRANSFORMER = fileURLToPath(new URL('./metro-babel-transformer.js', import.meta.url))
52
+
53
+ // Metro resolves a configured Babel transformer; its default stays a name, which the base requires from its own dir.
54
+ const resolveBabelTransformer = (base, path) => (isAbsolute(path) ? path : createRequire(base.file).resolve(path))
55
+
56
+ // Each dependency as the source wrote it: `stasisAsWritten` where Babel rewrote its specifier (several written apart
57
+ // may be rewritten alike), `stasisInserted` where no specifier in the source names it (a Babel helper, Metro's
58
+ // asyncRequire). The imports a Babel plugin consumed ride on the module's first output's data, as `stasisConsumed`.
59
+ function withAsWritten(result, { asWritten, consumed }) {
60
+ if (!Array.isArray(result?.dependencies)) return result
61
+ const dependencies = result.dependencies.map((dep) => {
62
+ const written = asWritten.get(dep.name)
63
+ if (written === undefined) return { ...dep, stasisInserted: true }
64
+ if (written.size === 1 && written.has(dep.name)) return dep
65
+ return { ...dep, stasisAsWritten: [...written] }
66
+ })
67
+ if (consumed.size === 0 || !Array.isArray(result.output) || result.output.length === 0) return { ...result, dependencies }
68
+ const [first, ...rest] = result.output
69
+ return { ...result, dependencies, output: [{ ...first, data: { ...first.data, stasisConsumed: [...consumed] } }, ...rest] }
61
70
  }
62
71
 
63
- export function transform(config, projectRoot, filename, data, options) {
72
+ export async function transform(config, projectRoot, filename, data, options) {
73
+ const base = baseOf(config, projectRoot)
74
+ config = baseConfig(config)
64
75
  const state = getLoadState()
65
76
  if (state) {
66
- // `filename` is project-relative in Metro's contract; resolve against projectRoot.
77
+ // `filename` is project-relative in Metro's contract.
67
78
  const absolute = resolvePath(projectRoot, filename)
68
- if (inScope(state, absolute)) {
69
- // getFile re-verifies the hash and throws on a file the bundle doesn't carry (fail-closed,
70
- // no disk fallback). Code sources come back as strings; resources as Buffers.
79
+ // getFile re-verifies the hash and throws on a file the bundle doesn't carry: no disk fallback.
80
+ if (servedByBundle(state, absolute)) {
71
81
  const { source } = state.getFile(pathToFileURL(absolute).toString())
72
82
  data = Buffer.isBuffer(source) ? source : Buffer.from(source)
73
83
  }
74
84
  }
75
- return getBase().transform(config, projectRoot, filename, data, options)
85
+ if (typeof config.babelTransformerPath !== 'string') return base.transformer.transform(config, projectRoot, filename, data, options)
86
+ const context = { babelTransformerPath: resolveBabelTransformer(base, config.babelTransformerPath), asWritten: new Map(), consumed: new Set() }
87
+ const result = await transformContext.run(context, () =>
88
+ base.transformer.transform({ ...config, babelTransformerPath: BABEL_TRANSFORMER }, projectRoot, filename, data, options))
89
+ return withAsWritten(result, context)
76
90
  }
77
91
 
78
- export function getCacheKey(config) {
79
- // Fold in the base transformer's cache key, then add a stasis-load discriminator so load results
80
- // (which can differ from a disk build even when disk == bundle) don't cross-pollinate the cache.
81
- // Derive the marker from the resolved load State (not the env) so config.json activation keys too.
82
- const baseKey = typeof getBase().getCacheKey === 'function' ? getBase().getCacheKey(config) : ''
92
+ // The base's key, marked by the load State transform() goes by, so a load result never shares a disk build's entry.
93
+ export function getCacheKey(config, opts) {
94
+ const { transformer } = baseOf(config, opts?.projectRoot)
95
+ const baseKey = typeof transformer.getCacheKey === 'function' ? transformer.getCacheKey(baseConfig(config), opts) : ''
83
96
  const state = getLoadState()
84
97
  const marker = state ? `load:${state.config.bundleFile || 'default'}` : 'off'
85
98
  return `${baseKey}$stasis-metro-transformer:${marker}`