@exodus/stasis-plugins 1.0.0-beta.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.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/package.json +36 -0
- package/src/esbuild.js +224 -0
- package/src/metro-transformer.js +125 -0
- package/src/metro.js +290 -0
- package/src/plugins.js +204 -0
- package/src/webpack.js +547 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Exodus Movement
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# <img src="/stasis/logo.svg" alt="" width="39" height="39" valign="bottom" /> `@exodus/stasis-plugins`
|
|
2
|
+
|
|
3
|
+
Bundler plugins (webpack, esbuild, Metro) for `@exodus/stasis-core`.
|
|
4
|
+
|
|
5
|
+
See main package [GitHub](https://github.com/ExodusOSS/stasis/tree/main/stasis) or [npm](https://npmjs.com/package/@exodus/stasis) for full README.
|
|
6
|
+
|
|
7
|
+
## License
|
|
8
|
+
|
|
9
|
+
[MIT](./LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@exodus/stasis-plugins",
|
|
3
|
+
"version": "1.0.0-beta.0",
|
|
4
|
+
"description": "Bundler plugins (webpack, esbuild, Metro) for @exodus/stasis-core",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./webpack": "./src/webpack.js",
|
|
8
|
+
"./esbuild": "./src/esbuild.js",
|
|
9
|
+
"./metro": "./src/metro.js",
|
|
10
|
+
"./metro-transformer": "./src/metro-transformer.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src/esbuild.js",
|
|
14
|
+
"src/metro.js",
|
|
15
|
+
"src/metro-transformer.js",
|
|
16
|
+
"src/plugins.js",
|
|
17
|
+
"src/webpack.js"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=24.14.0"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/ExodusOSS/stasis.git",
|
|
25
|
+
"directory": "stasis-plugins"
|
|
26
|
+
},
|
|
27
|
+
"author": "Exodus Movement, Inc.",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/ExodusOSS/stasis/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/ExodusOSS/stasis#readme",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@exodus/stasis-core": "1.0.0-beta.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/esbuild.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { isUtf8 } from 'node:buffer'
|
|
2
|
+
import { isBuiltin } from 'node:module'
|
|
3
|
+
import { extname, resolve as resolvePath } from 'node:path'
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
|
|
7
|
+
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
8
|
+
import { resolvePluginState } from './plugins.js'
|
|
9
|
+
import { State } from '@exodus/stasis-core/state'
|
|
10
|
+
// Pre-patch snapshot, NOT `import { readFile } from 'node:fs/promises'`: under
|
|
11
|
+
// `stasis run --fs=async` that builtin is monkey-patched, and this plugin loads after
|
|
12
|
+
// the patch, so a direct import would route esbuild's own source reads through the
|
|
13
|
+
// --fs capture hook. The snapshot in state-util (taken during the --import preload) is
|
|
14
|
+
// the genuine reader.
|
|
15
|
+
import { realReadFile } from '@exodus/stasis-core/state-util'
|
|
16
|
+
import { classifyExtension } from '@exodus/stasis-core/util'
|
|
17
|
+
|
|
18
|
+
export class StasisEsbuild {
|
|
19
|
+
#seen = new Set()
|
|
20
|
+
#state
|
|
21
|
+
#resources
|
|
22
|
+
#loaders
|
|
23
|
+
|
|
24
|
+
constructor(options = {}, { loaders } = {}) {
|
|
25
|
+
// A caller that owns a State (e.g. `stasis build`) can pass it directly to drive the plugin, bypassing resolvePluginState; mode follows that State's config, and a foreign-copy State fails closed via the instanceof miss.
|
|
26
|
+
const state = options instanceof State
|
|
27
|
+
? options
|
|
28
|
+
: resolvePluginState('StasisEsbuild', options, process.cwd()).state
|
|
29
|
+
this.#state = state // null when plugin should be inert
|
|
30
|
+
// resources is a Config field now (validated + coordinated against any preload in
|
|
31
|
+
// resolvePluginState); cache the resolved Set for the per-file classify hot path.
|
|
32
|
+
this.#resources = state?.config.resources ?? new Set()
|
|
33
|
+
// Per-extension esbuild loader overrides (e.g. { '.js' -> 'jsx' } so a .js file
|
|
34
|
+
// carrying JSX -- the React Native convention -- parses; esbuild's default 'js'
|
|
35
|
+
// loader rejects JSX). Keyed by extname WITH the dot; falls back to the
|
|
36
|
+
// extension-derived loader (see #loaderFor) when an extension isn't overridden.
|
|
37
|
+
this.#loaders = loaders ?? new Map()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The esbuild loader for a served file: an explicit per-extension override if the
|
|
41
|
+
// caller configured one, else derived from the extension (.cjs/.mjs/.cts/.mts strip
|
|
42
|
+
// their leading module letter to js/ts; other code extensions map 1:1 to a loader).
|
|
43
|
+
#loaderFor(path) {
|
|
44
|
+
const ext = extname(path)
|
|
45
|
+
return this.#loaders.get(ext) ?? ext.replace(/^\.[cm]?/u, '')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get name() {
|
|
49
|
+
return 'stasis'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setup = ({ onResolve, onLoad, onEnd, resolve }) => {
|
|
53
|
+
if (!this.#state) return // noop plugin
|
|
54
|
+
|
|
55
|
+
// The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
|
|
56
|
+
// Standalone and sidecar States have no such hook, so the plugin writes them when the
|
|
57
|
+
// build finishes -- but only on a clean build, otherwise a partial bundle/lockfile
|
|
58
|
+
// would overwrite the user's good file.
|
|
59
|
+
if (this.#state !== State.preload) {
|
|
60
|
+
onEnd((result) => {
|
|
61
|
+
if (result.errors.length > 0) return
|
|
62
|
+
this.#state.write()
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Load mode: resolution comes from the bundle's import map, not esbuild's
|
|
67
|
+
// resolver. The on-disk file may not even exist -- the bundle is the source
|
|
68
|
+
// of truth (mirrors `stasis run --bundle=load`; see stasis-core/src/hooks.js).
|
|
69
|
+
// We keep `namespace: 'file'` with the original absolute path, even though the
|
|
70
|
+
// file isn't on disk: esbuild stats the path only when its own default resolver
|
|
71
|
+
// produced it; paths returned from a plugin's onResolve are trusted as-is. That
|
|
72
|
+
// gives the same output bytes as a capture-mode build -- esbuild's path-banner
|
|
73
|
+
// comments, asset names, and source maps all use the original paths.
|
|
74
|
+
onResolve({ filter: /$/ }, ({ path: specifier, importer, kind: resolveKind, with: attrs }) => {
|
|
75
|
+
if (!this.#state.config.loadBundle) return undefined
|
|
76
|
+
// Node built-ins (`constants`, `node:fs`, ...) are never carried in the bundle
|
|
77
|
+
// and never recorded as import edges -- the capture side (and the Node run
|
|
78
|
+
// loader, stasis-core/hooks.js) defer isBuiltin() specifiers before addImport
|
|
79
|
+
// sees them. Returning undefined hands the builtin back to esbuild's own
|
|
80
|
+
// resolution, which externalizes it under platform:'node' exactly as in a build
|
|
81
|
+
// without this plugin; state.getImport would instead throw ERR_MODULE_NOT_FOUND
|
|
82
|
+
// ("Cannot find module 'constants' imported from <file>"). Mirrors the webpack
|
|
83
|
+
// plugin's load-mode builtin guard.
|
|
84
|
+
if (isBuiltin(specifier)) return undefined
|
|
85
|
+
const isEntry = resolveKind === 'entry-point'
|
|
86
|
+
let url
|
|
87
|
+
if (isEntry) {
|
|
88
|
+
// esbuild's entryPoints are typically absolute, but a bare/relative entry
|
|
89
|
+
// is anchored to cwd just like the run loader does (resolvePath(cwd, ...))
|
|
90
|
+
// so the entry file doesn't need to be on disk.
|
|
91
|
+
url = specifier.startsWith('file:')
|
|
92
|
+
? specifier
|
|
93
|
+
: pathToFileURL(resolvePath(process.cwd(), specifier)).toString()
|
|
94
|
+
if (this.#state.config.full) this.#state.assertEntry(url)
|
|
95
|
+
} else {
|
|
96
|
+
const parentURL = pathToFileURL(importer).toString()
|
|
97
|
+
// Forward import attributes (e.g. `with { type: 'json' }`) so a
|
|
98
|
+
// plugin-captured bundle round-trips to plugin-load symmetrically: both
|
|
99
|
+
// sides key the edge under `* (with: ...)` (no conditions because the
|
|
100
|
+
// plugin doesn't know them). KNOWN GAP: a bundle captured by the Node
|
|
101
|
+
// run loader keys edges under specific conditions like `node, import,
|
|
102
|
+
// ... (with: ...)`; the plugin's `*`-keyed lookup misses that, and the
|
|
103
|
+
// wildcard fallback in state.getImport only falls back to `*` (without
|
|
104
|
+
// attrs), so a Node-loader-captured bundle with attributed imports
|
|
105
|
+
// won't reload via the esbuild plugin without further work
|
|
106
|
+
// (state.getImport would need a multi-conditions match scan, or the
|
|
107
|
+
// Node loader would need to mirror under '*' too).
|
|
108
|
+
try {
|
|
109
|
+
;({ url } = this.#state.getImport(parentURL, specifier, { importAttributes: attrs }))
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// No attested edge. getImport tried the recorded edge under every conditions
|
|
112
|
+
// bucket (resolveBundled), so a miss means the request names nothing the
|
|
113
|
+
// bundle carries -- at a successful capture it was an EXTERNAL (a user
|
|
114
|
+
// `external` entry; built-ins are handled above). Externals are never recorded
|
|
115
|
+
// as edges, so return undefined to hand it back to esbuild, which externalizes it as it would
|
|
116
|
+
// without this plugin. This relaxes only the resolve-time edge lookup:
|
|
117
|
+
// a genuinely missing in-scope FILE still fails closed at onLoad
|
|
118
|
+
// (state.getFile serves in-scope bytes from the bundle or throws), so no
|
|
119
|
+
// disk fallback is introduced. A non-MODULE_NOT_FOUND error is a real
|
|
120
|
+
// fault -- rethrow it.
|
|
121
|
+
if (err?.code === 'ERR_MODULE_NOT_FOUND') return undefined
|
|
122
|
+
throw err
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const format = this.#state.getFormat(url)
|
|
126
|
+
const kind = Bundle.isResourceFormat(format) ? 'resource' : 'code'
|
|
127
|
+
return { path: fileURLToPath(url), namespace: 'file', pluginData: { isEntry, kind } }
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
onResolve({ filter: /$/, namespace: 'file' }, async ({ path: specifier, with: attrs, ...args }) => {
|
|
131
|
+
const isEntry = !args.importer
|
|
132
|
+
|
|
133
|
+
// Recurse with a synthetic namespace so esbuild's default resolver runs without re-entering us
|
|
134
|
+
const res = await resolve(specifier, { ...args, with: attrs, namespace: 'stasis' })
|
|
135
|
+
if (res.errors.length > 0) return res
|
|
136
|
+
// Warnings from sibling plugins or esbuild itself (deprecation notices, etc.) are
|
|
137
|
+
// not our concern -- propagate them upward, don't crash.
|
|
138
|
+
// A non-empty `suffix` is set by sibling plugins that want to preserve a
|
|
139
|
+
// query-style suffix on the resolved path (some CSS-modules plugins do).
|
|
140
|
+
// stasis doesn't know how to persist suffixes to the bundle, so we refuse
|
|
141
|
+
// them explicitly rather than silently dropping the suffix at capture time.
|
|
142
|
+
assert.equal(res.suffix, '',
|
|
143
|
+
`StasisEsbuild: a sibling plugin set res.suffix='${res.suffix}' on '${specifier}'; ` +
|
|
144
|
+
`stasis can't persist suffixes to the bundle. Disable that plugin or drop suffix from its resolve result.`)
|
|
145
|
+
|
|
146
|
+
let kind = 'skip'
|
|
147
|
+
if (res.namespace === 'file' && !res.external) {
|
|
148
|
+
kind = classifyExtension(res.path, this.#resources)
|
|
149
|
+
if (kind === 'unknown') {
|
|
150
|
+
return {
|
|
151
|
+
errors: [{
|
|
152
|
+
text: `StasisEsbuild: unsupported extension for '${res.path}' -- add its extension or filename to the plugin's resources option or stop importing it`,
|
|
153
|
+
}],
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (kind === 'code' && !isEntry) {
|
|
157
|
+
const parentURL = pathToFileURL(args.importer).toString()
|
|
158
|
+
const url = pathToFileURL(res.path).toString()
|
|
159
|
+
// Forward import attributes (e.g. `with { type: 'json' }`) symmetrically
|
|
160
|
+
// with the load path: addImport stores under a conditions-with-attributes
|
|
161
|
+
// key when attributes are present, so getImport at load time can find
|
|
162
|
+
// the same edge.
|
|
163
|
+
this.#state.addImport(parentURL, specifier, url, { importAttributes: attrs })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { ...res, pluginData: { ...res.pluginData, isEntry, kind } }
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
|
|
171
|
+
const kind = pluginData?.kind ?? 'skip'
|
|
172
|
+
if (kind === 'skip') return undefined
|
|
173
|
+
// Accept the `type` import attribute (e.g. `with { type: 'json' }`); reject
|
|
174
|
+
// anything else loudly since saving import-attribute state to the lockfile
|
|
175
|
+
// isn't designed yet.
|
|
176
|
+
for (const k of Object.keys(attrs)) {
|
|
177
|
+
assert.equal(k, 'type', `unsupported import attribute: ${k}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Load mode: serve bytes the bundle attested. `getFile` verifies the hash
|
|
181
|
+
// against the lockfile (when one is loaded), and throws on a file the
|
|
182
|
+
// bundle doesn't carry -- a missing in-scope file is a hard error, not a
|
|
183
|
+
// silent disk fallback. Resources return `{ contents }` only; esbuild's
|
|
184
|
+
// loader config (file/dataurl/copy/...) interprets and emits them by
|
|
185
|
+
// extension, the same way capture mode lets esbuild's loader machinery
|
|
186
|
+
// handle resource emission.
|
|
187
|
+
if (this.#state.config.loadBundle) {
|
|
188
|
+
const { source } = this.#state.getFile(pathToFileURL(path).toString())
|
|
189
|
+
if (kind === 'resource') return { contents: source }
|
|
190
|
+
return { contents: source, loader: this.#loaderFor(path) }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const source = await realReadFile(path)
|
|
194
|
+
|
|
195
|
+
if (kind === 'resource') {
|
|
196
|
+
// Mark this file as a resource. State picks the encoding from its bytes:
|
|
197
|
+
// 'resource' (raw UTF-8 -- e.g. SVG stays human-readable in the bundle)
|
|
198
|
+
// for valid UTF-8 content, 'resource:base64' for binary. We do NOT return
|
|
199
|
+
// contents/loader: emission is esbuild's job via the build's own `loader`
|
|
200
|
+
// config (file, dataurl, copy, ...), the same way webpack's file-loader
|
|
201
|
+
// runs independently of stasis's afterResolve hook. An asset with no
|
|
202
|
+
// configured loader errors in esbuild, which
|
|
203
|
+
// is the user's responsibility (symmetric with file-loader being required).
|
|
204
|
+
if (!this.#seen.has(path)) {
|
|
205
|
+
this.#seen.add(path)
|
|
206
|
+
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource: true, reason: 'StasisEsbuild' })
|
|
207
|
+
}
|
|
208
|
+
return undefined
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Code-classified files must be UTF-8. A code extension with non-UTF-8 bytes
|
|
212
|
+
// is malformed input (e.g. a .json mis-saved as UTF-16); refuse it rather
|
|
213
|
+
// than silently encoding it as a resource and hiding the underlying problem.
|
|
214
|
+
assert.ok(isUtf8(source), `StasisEsbuild: code-classified file has non-UTF-8 bytes: ${path}`)
|
|
215
|
+
|
|
216
|
+
if (!this.#seen.has(path)) {
|
|
217
|
+
this.#seen.add(path)
|
|
218
|
+
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, reason: 'StasisEsbuild' })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { contents: source, loader: this.#loaderFor(path) }
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { resolve as resolvePath } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
|
|
6
|
+
import { State } from '@exodus/stasis-core/state'
|
|
7
|
+
|
|
8
|
+
// Companion to the StasisMetro serializer plugin: the LOAD half of stasis for Metro.
|
|
9
|
+
//
|
|
10
|
+
// WHY A TRANSFORMER (the asymmetry that makes this possible):
|
|
11
|
+
// Capture must MERGE every worker's observations into one lockfile/bundle, so it
|
|
12
|
+
// runs in the main process (the serializer -- see metro.js). Load is the opposite:
|
|
13
|
+
// it only ever READS an immutable bundle, and every worker reads the SAME bytes, so
|
|
14
|
+
// there is nothing to synchronize between them. That makes the per-worker transformer
|
|
15
|
+
// the natural seam for load -- each worker independently serves attested bytes, no IPC.
|
|
16
|
+
//
|
|
17
|
+
// HOW IT WIRES IN (metro.config.js, load mode only):
|
|
18
|
+
// module.exports = {
|
|
19
|
+
// transformer: {
|
|
20
|
+
// transformerPath: require.resolve('@exodus/stasis/metro-transformer'),
|
|
21
|
+
// },
|
|
22
|
+
// }
|
|
23
|
+
// // run with EXODUS_STASIS_BUNDLE=load (+ EXODUS_STASIS_BUNDLE_FILE / LOCK / SCOPE),
|
|
24
|
+
// // OR a stasis.config.json with "bundle":"load". Do NOT also wire the StasisMetro
|
|
25
|
+
// // serializer plugin in load mode -- capture and load are separate wirings.
|
|
26
|
+
//
|
|
27
|
+
// Metro calls `transformerPath`'s `transform(config, projectRoot, filename, data,
|
|
28
|
+
// options)` in each worker with `data` = the file's on-disk bytes (a Buffer) and
|
|
29
|
+
// `filename` project-relative. We wrap the user's real transformer (default
|
|
30
|
+
// `metro-transform-worker`, override via EXODUS_STASIS_METRO_BASE_TRANSFORMER) and,
|
|
31
|
+
// in load mode, replace `data` with the bundle's attested bytes before delegating --
|
|
32
|
+
// so the transform consumes bundle bytes, hash-verified, fail-closed.
|
|
33
|
+
//
|
|
34
|
+
// WHAT THIS DOES AND DOESN'T GUARANTEE:
|
|
35
|
+
// - The bytes that get TRANSFORMED are the bundle's, verified by State.getFile
|
|
36
|
+
// (against the lockfile when one is loaded; against the bundle's own bytes under
|
|
37
|
+
// lock=none). A tampered on-disk source is therefore ignored for the build output
|
|
38
|
+
// and, under a lockfile, rejected outright. An in-scope file the bundle doesn't
|
|
39
|
+
// carry throws -- never a silent disk fallback. Out-of-scope files pass their disk
|
|
40
|
+
// bytes through untouched (mirrors webpack/esbuild load scope handling).
|
|
41
|
+
// - KNOWN LIMITATION: this does NOT let Metro build with the sources fully absent
|
|
42
|
+
// from disk. Metro reads each file from disk in the worker (and hashes it in the
|
|
43
|
+
// main process for its transform cache) BEFORE this transformer runs, and resolves
|
|
44
|
+
// package.json via its own fs in the main process. Serving those reads from the
|
|
45
|
+
// bundle would need fs interception across the main + worker processes (stasis
|
|
46
|
+
// --mock territory), which is larger than this seam. So today's guarantee is
|
|
47
|
+
// "build the bundle's attested bytes, fail closed on disk drift," not "build from
|
|
48
|
+
// a bundle with no source tree." This mirrors the documented node_modules-crossing
|
|
49
|
+
// gap in the webpack load path.
|
|
50
|
+
|
|
51
|
+
const require = createRequire(import.meta.url)
|
|
52
|
+
|
|
53
|
+
// The real transformer we wrap. Default matches Metro's default; an app with a custom
|
|
54
|
+
// transformer (e.g. a TS/Babel preset) points this at theirs so we sit transparently
|
|
55
|
+
// in front of it. Resolved + required lazily (per worker process) so a non-load build
|
|
56
|
+
// pays nothing and we don't require Metro internals at import time.
|
|
57
|
+
const BASE_TRANSFORMER = process.env.EXODUS_STASIS_METRO_BASE_TRANSFORMER || 'metro-transform-worker'
|
|
58
|
+
|
|
59
|
+
let base
|
|
60
|
+
function getBase() {
|
|
61
|
+
base ??= require(BASE_TRANSFORMER)
|
|
62
|
+
return base
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Per-worker load State, built once. We construct a normal State (it reads the same
|
|
66
|
+
// EXODUS_STASIS_* env + stasis.config.json the rest of stasis does) and only act when
|
|
67
|
+
// it resolves to load mode; any other mode -> null -> this transformer is a transparent
|
|
68
|
+
// pass-through, so wiring it permanently in metro.config.js is safe.
|
|
69
|
+
let stateInited = false
|
|
70
|
+
let loadState = null
|
|
71
|
+
function getLoadState() {
|
|
72
|
+
if (!stateInited) {
|
|
73
|
+
stateInited = true
|
|
74
|
+
const state = new State(process.cwd())
|
|
75
|
+
loadState = state.config.loadBundle ? state : null
|
|
76
|
+
}
|
|
77
|
+
return loadState
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// True iff `absolute` is a path the bundle is supposed to cover under load mode.
|
|
81
|
+
// `state.relative` throws for paths outside the project root, which we treat as
|
|
82
|
+
// out-of-scope (system files, scratch dirs) and defer to disk. Mirrors the webpack
|
|
83
|
+
// plugin's load-mode inScope.
|
|
84
|
+
function inScope(state, absolute) {
|
|
85
|
+
try {
|
|
86
|
+
state.relative(absolute)
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
if (state.config.full) return true
|
|
91
|
+
return state.inNodeModules(pathToFileURL(absolute).toString())
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function transform(config, projectRoot, filename, data, options) {
|
|
95
|
+
const state = getLoadState()
|
|
96
|
+
if (state) {
|
|
97
|
+
// `filename` is project-relative in Metro's transformer contract; resolve against
|
|
98
|
+
// projectRoot (resolvePath ignores projectRoot when filename is already absolute).
|
|
99
|
+
const absolute = resolvePath(projectRoot, filename)
|
|
100
|
+
if (inScope(state, absolute)) {
|
|
101
|
+
// getFile re-verifies the hash against the lockfile on every read and throws on a
|
|
102
|
+
// file the bundle doesn't carry -- so an in-scope path the bundle is missing
|
|
103
|
+
// surfaces here as a hard build error, not a silent disk fallback. Code sources
|
|
104
|
+
// come back as strings; resources as Buffers (already decoded from base64).
|
|
105
|
+
const { source } = state.getFile(pathToFileURL(absolute).toString())
|
|
106
|
+
data = Buffer.isBuffer(source) ? source : Buffer.from(source)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return getBase().transform(config, projectRoot, filename, data, options)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function getCacheKey(config) {
|
|
113
|
+
// Fold the wrapped transformer's own cache key in (so changing the base transformer
|
|
114
|
+
// still invalidates), then add a stasis-load discriminator: load-mode transforms can
|
|
115
|
+
// produce different bytes than a disk build for the same file (and Metro hashes the
|
|
116
|
+
// on-disk bytes into its cache key BEFORE we substitute, so when disk == bundle the
|
|
117
|
+
// content hash alone wouldn't tell a load result apart from a plain build), so their
|
|
118
|
+
// cached results must not cross-pollinate. Derive the marker from the resolved load
|
|
119
|
+
// State -- the SAME source of truth as transform() -- not the env var, so load activated
|
|
120
|
+
// via stasis.config.json is keyed correctly too; the bundle path keys it to the bundle.
|
|
121
|
+
const baseKey = typeof getBase().getCacheKey === 'function' ? getBase().getCacheKey(config) : ''
|
|
122
|
+
const state = getLoadState()
|
|
123
|
+
const marker = state ? `load:${state.config.bundleFile || 'default'}` : 'off'
|
|
124
|
+
return `${baseKey}$stasis-metro-transformer:${marker}`
|
|
125
|
+
}
|