@exodus/stasis-plugins 1.0.0-beta.2 → 1.0.0-beta.3
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 +62 -112
- package/src/metro-transformer.js +32 -72
- package/src/metro.js +315 -253
- package/src/plugins.js +49 -87
- package/src/webpack.js +84 -294
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.3",
|
|
4
4
|
"description": "Bundler plugins (webpack, esbuild, Metro) for @exodus/stasis-core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
},
|
|
32
32
|
"homepage": "https://github.com/ExodusOSS/stasis#readme",
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@exodus/stasis-core": "1.0.0-beta.
|
|
34
|
+
"@exodus/stasis-core": "1.0.0-beta.3"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/esbuild.js
CHANGED
|
@@ -7,11 +7,8 @@ import assert from 'node:assert/strict'
|
|
|
7
7
|
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
8
8
|
import { resolvePluginState } from './plugins.js'
|
|
9
9
|
import { State } from '@exodus/stasis-core/state'
|
|
10
|
-
// Pre-patch snapshot,
|
|
11
|
-
//
|
|
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.
|
|
10
|
+
// Pre-patch snapshot, not `import { readFile }`: under --fs=async that builtin is patched, and
|
|
11
|
+
// this plugin loads after, so a direct import would route esbuild's reads through the capture hook.
|
|
15
12
|
import { realReadFile } from '@exodus/stasis-core/state-util'
|
|
16
13
|
import { classifyExtension } from '@exodus/stasis-core/util'
|
|
17
14
|
|
|
@@ -20,31 +17,32 @@ export class StasisEsbuild {
|
|
|
20
17
|
#state
|
|
21
18
|
#resources
|
|
22
19
|
#loaders
|
|
20
|
+
#transform
|
|
23
21
|
|
|
24
|
-
// Build starts observed by this
|
|
25
|
-
//
|
|
26
|
-
// the onStart hook in setup().
|
|
22
|
+
// Build starts observed by this instance (across rebuilds and separate build()/context() calls);
|
|
23
|
+
// the second is refused in onStart.
|
|
27
24
|
#captureBuildCount = 0
|
|
28
25
|
|
|
29
|
-
constructor(options = {}, { loaders } = {}) {
|
|
30
|
-
// A caller that owns a State (e.g. `stasis build`) can pass it directly
|
|
26
|
+
constructor(options = {}, { loaders, transform } = {}) {
|
|
27
|
+
// A caller that owns a State (e.g. `stasis build`) can pass it directly; a foreign-copy State
|
|
28
|
+
// fails closed via the instanceof miss.
|
|
31
29
|
const state = options instanceof State
|
|
32
30
|
? options
|
|
33
31
|
: resolvePluginState('StasisEsbuild', options, process.cwd()).state
|
|
34
32
|
this.#state = state // null when plugin should be inert
|
|
35
|
-
//
|
|
36
|
-
// resolvePluginState); cache the resolved Set for the per-file classify hot path.
|
|
33
|
+
// Cache the resolved resources Set for the per-file classify hot path.
|
|
37
34
|
this.#resources = state?.config.resources ?? new Set()
|
|
38
|
-
// Per-extension esbuild loader overrides (e.g. { '.js' -> 'jsx' }
|
|
39
|
-
//
|
|
40
|
-
// loader rejects JSX). Keyed by extname WITH the dot; falls back to the
|
|
41
|
-
// extension-derived loader (see #loaderFor) when an extension isn't overridden.
|
|
35
|
+
// Per-extension esbuild loader overrides (e.g. { '.js' -> 'jsx' } for RN's JSX-in-.js),
|
|
36
|
+
// keyed by extname with the dot; falls back to #loaderFor.
|
|
42
37
|
this.#loaders = loaders ?? new Map()
|
|
38
|
+
// Load-mode-only code rewrite hook (e.g. `stasis build --platform=hermes` downleveling):
|
|
39
|
+
// (source, { path, loader }) => { contents, loader } | undefined, may be async. Never
|
|
40
|
+
// runs at capture -- capture attests the file's raw bytes.
|
|
41
|
+
this.#transform = transform
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// their leading module letter to js/ts; other code extensions map 1:1 to a loader).
|
|
44
|
+
// esbuild loader for a served file: configured override, else derived from extension
|
|
45
|
+
// (.cjs/.mjs/.cts/.mts strip their module letter to js/ts).
|
|
48
46
|
#loaderFor(path) {
|
|
49
47
|
const ext = extname(path)
|
|
50
48
|
return this.#loaders.get(ext) ?? ext.replace(/^\.[cm]?/u, '')
|
|
@@ -54,21 +52,12 @@ export class StasisEsbuild {
|
|
|
54
52
|
return 'stasis'
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
setup = ({ onResolve, onLoad, onStart, onEnd, resolve }) => {
|
|
55
|
+
setup = ({ onResolve, onLoad, onStart, onEnd, resolve, initialOptions }) => {
|
|
58
56
|
if (!this.#state) return // noop plugin
|
|
59
57
|
|
|
60
|
-
// Watch/rebuild capture is
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// plugin would skip re-capture: the emitted output would use the new bytes while the
|
|
64
|
-
// bundle/lockfile silently kept attesting the OLD ones. Rather than track content changes
|
|
65
|
-
// across rebuilds, refuse them outright. esbuild rebuilds (watch mode, context.rebuild())
|
|
66
|
-
// re-fire onStart/onEnd against the SAME setup registrations, so an instance-level counter
|
|
67
|
-
// sees every start; it also covers reusing one plugin instance across two esbuild.build()
|
|
68
|
-
// calls, which has the same staleness hazard. Returning errors from onStart is the
|
|
69
|
-
// esbuild-idiomatic hard failure (the build reports them and onEnd's clean-build gate then
|
|
70
|
-
// skips the write). Load mode registers nothing here: it serves immutable attested bytes,
|
|
71
|
-
// so rebuilds have no drift hazard.
|
|
58
|
+
// Watch/rebuild capture is unsupported: dedupe is keyed by PATH not content, so a rebuild with
|
|
59
|
+
// changed bytes would emit new bytes while the bundle/lockfile keep the OLD ones. Rebuilds re-fire
|
|
60
|
+
// onStart against the same registrations, so an instance counter catches the second and errors.
|
|
72
61
|
if (!this.#state.config.loadBundle) {
|
|
73
62
|
onStart(() => {
|
|
74
63
|
this.#captureBuildCount += 1
|
|
@@ -81,10 +70,8 @@ export class StasisEsbuild {
|
|
|
81
70
|
})
|
|
82
71
|
}
|
|
83
72
|
|
|
84
|
-
// The
|
|
85
|
-
//
|
|
86
|
-
// build finishes -- but only on a clean build, otherwise a partial bundle/lockfile
|
|
87
|
-
// would overwrite the user's good file.
|
|
73
|
+
// The preload writes itself (hooks.js); standalone/sidecar States are written on build finish --
|
|
74
|
+
// only on a clean build (a partial one would overwrite the user's good file).
|
|
88
75
|
if (this.#state !== State.preload) {
|
|
89
76
|
onEnd((result) => {
|
|
90
77
|
if (result.errors.length > 0) return
|
|
@@ -92,61 +79,33 @@ export class StasisEsbuild {
|
|
|
92
79
|
})
|
|
93
80
|
}
|
|
94
81
|
|
|
95
|
-
// Load mode: resolution comes from the bundle's import map, not esbuild's
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// We keep `namespace: 'file'` with the original absolute path, even though the
|
|
99
|
-
// file isn't on disk: esbuild stats the path only when its own default resolver
|
|
100
|
-
// produced it; paths returned from a plugin's onResolve are trusted as-is. That
|
|
101
|
-
// gives the same output bytes as a capture-mode build -- esbuild's path-banner
|
|
102
|
-
// comments, asset names, and source maps all use the original paths.
|
|
82
|
+
// Load mode: resolution comes from the bundle's import map, not esbuild's resolver -- the file
|
|
83
|
+
// may not be on disk. Keep namespace:'file' with the original absolute path (esbuild doesn't stat
|
|
84
|
+
// plugin-returned paths) so output bytes -- banners, asset names, source maps -- match a capture build.
|
|
103
85
|
onResolve({ filter: /$/ }, ({ path: specifier, importer, kind: resolveKind, with: attrs }) => {
|
|
104
86
|
if (!this.#state.config.loadBundle) return undefined
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// loader, stasis-core/hooks.js) defer isBuiltin() specifiers before addImport
|
|
108
|
-
// sees them. Returning undefined hands the builtin back to esbuild's own
|
|
109
|
-
// resolution, which externalizes it under platform:'node' exactly as in a build
|
|
110
|
-
// without this plugin; state.getImport would instead throw ERR_MODULE_NOT_FOUND
|
|
111
|
-
// ("Cannot find module 'constants' imported from <file>"). Mirrors the webpack
|
|
112
|
-
// plugin's load-mode builtin guard.
|
|
87
|
+
// Built-ins are never bundled or recorded as edges; hand them back to esbuild (undefined)
|
|
88
|
+
// so it externalizes under platform:'node', rather than getImport throwing ERR_MODULE_NOT_FOUND.
|
|
113
89
|
if (isBuiltin(specifier)) return undefined
|
|
114
90
|
const isEntry = resolveKind === 'entry-point'
|
|
115
91
|
let url
|
|
116
92
|
if (isEntry) {
|
|
117
|
-
//
|
|
118
|
-
// is anchored to cwd just like the run loader does (resolvePath(cwd, ...))
|
|
119
|
-
// so the entry file doesn't need to be on disk.
|
|
93
|
+
// Bare/relative entries are anchored to cwd (like the run loader), so the file needn't be on disk.
|
|
120
94
|
url = specifier.startsWith('file:')
|
|
121
95
|
? specifier
|
|
122
96
|
: pathToFileURL(resolvePath(process.cwd(), specifier)).toString()
|
|
123
97
|
if (this.#state.config.full) this.#state.assertEntry(url)
|
|
124
98
|
} else {
|
|
125
99
|
const parentURL = pathToFileURL(importer).toString()
|
|
126
|
-
// Forward import attributes (
|
|
127
|
-
// plugin-
|
|
128
|
-
//
|
|
129
|
-
// plugin doesn't know them). KNOWN GAP: a bundle captured by the Node
|
|
130
|
-
// run loader keys edges under specific conditions like `node, import,
|
|
131
|
-
// ... (with: ...)`; the plugin's `*`-keyed lookup misses that, and the
|
|
132
|
-
// wildcard fallback in state.getImport only falls back to `*` (without
|
|
133
|
-
// attrs), so a Node-loader-captured bundle with attributed imports
|
|
134
|
-
// won't reload via the esbuild plugin without further work
|
|
135
|
-
// (state.getImport would need a multi-conditions match scan, or the
|
|
136
|
-
// Node loader would need to mirror under '*' too).
|
|
100
|
+
// Forward import attributes (`with { type: 'json' }`) so plugin-capture round-trips to
|
|
101
|
+
// plugin-load, both keyed under `* (with: ...)`. KNOWN GAP: a Node-loader-captured bundle
|
|
102
|
+
// keys attributed edges under specific conditions the plugin's `*` lookup won't match.
|
|
137
103
|
try {
|
|
138
104
|
;({ url } = this.#state.getImport(parentURL, specifier, { importAttributes: attrs }))
|
|
139
105
|
} catch (err) {
|
|
140
|
-
// No attested edge
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// `external` entry; built-ins are handled above). Externals are never recorded
|
|
144
|
-
// as edges, so return undefined to hand it back to esbuild, which externalizes it as it would
|
|
145
|
-
// without this plugin. This relaxes only the resolve-time edge lookup:
|
|
146
|
-
// a genuinely missing in-scope FILE still fails closed at onLoad
|
|
147
|
-
// (state.getFile serves in-scope bytes from the bundle or throws), so no
|
|
148
|
-
// disk fallback is introduced. A non-MODULE_NOT_FOUND error is a real
|
|
149
|
-
// fault -- rethrow it.
|
|
106
|
+
// No attested edge -- at a successful capture that's an EXTERNAL (never recorded as an
|
|
107
|
+
// edge), so hand it back to esbuild. File BYTES still fail closed at onLoad.
|
|
108
|
+
// A non-MODULE_NOT_FOUND error is a real fault -- rethrow.
|
|
150
109
|
if (err?.code === 'ERR_MODULE_NOT_FOUND') return undefined
|
|
151
110
|
throw err
|
|
152
111
|
}
|
|
@@ -162,12 +121,8 @@ export class StasisEsbuild {
|
|
|
162
121
|
// Recurse with a synthetic namespace so esbuild's default resolver runs without re-entering us
|
|
163
122
|
const res = await resolve(specifier, { ...args, with: attrs, namespace: 'stasis' })
|
|
164
123
|
if (res.errors.length > 0) return res
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
// A non-empty `suffix` is set by sibling plugins that want to preserve a
|
|
168
|
-
// query-style suffix on the resolved path (some CSS-modules plugins do).
|
|
169
|
-
// stasis doesn't know how to persist suffixes to the bundle, so we refuse
|
|
170
|
-
// them explicitly rather than silently dropping the suffix at capture time.
|
|
124
|
+
// A non-empty `suffix` (some CSS-modules plugins set one) can't be persisted to the bundle,
|
|
125
|
+
// so refuse it rather than silently dropping it at capture.
|
|
171
126
|
assert.equal(res.suffix, '',
|
|
172
127
|
`StasisEsbuild: a sibling plugin set res.suffix='${res.suffix}' on '${specifier}'; ` +
|
|
173
128
|
`stasis can't persist suffixes to the bundle. Disable that plugin or drop suffix from its resolve result.`)
|
|
@@ -182,13 +137,14 @@ export class StasisEsbuild {
|
|
|
182
137
|
}],
|
|
183
138
|
}
|
|
184
139
|
}
|
|
185
|
-
|
|
140
|
+
// Record the edge for BOTH code and resource targets (never entries): load mode resolves
|
|
141
|
+
// every specifier through this map, so a resource with no edge would miss at bundle=load and
|
|
142
|
+
// fall through to esbuild's disk resolver -- serving the asset UNATTESTED. kind is code|resource here.
|
|
143
|
+
if (!isEntry) {
|
|
186
144
|
const parentURL = pathToFileURL(args.importer).toString()
|
|
187
145
|
const url = pathToFileURL(res.path).toString()
|
|
188
|
-
// Forward import attributes
|
|
189
|
-
//
|
|
190
|
-
// key when attributes are present, so getImport at load time can find
|
|
191
|
-
// the same edge.
|
|
146
|
+
// Forward import attributes symmetrically with load: addImport keys by attributes so
|
|
147
|
+
// getImport finds the same edge.
|
|
192
148
|
this.#state.addImport(parentURL, specifier, url, { importAttributes: attrs })
|
|
193
149
|
}
|
|
194
150
|
}
|
|
@@ -199,52 +155,46 @@ export class StasisEsbuild {
|
|
|
199
155
|
onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
|
|
200
156
|
const kind = pluginData?.kind ?? 'skip'
|
|
201
157
|
if (kind === 'skip') return undefined
|
|
202
|
-
// Accept the `type` import attribute
|
|
203
|
-
// anything else loudly since saving import-attribute state to the lockfile
|
|
204
|
-
// isn't designed yet.
|
|
158
|
+
// Accept only the `type` import attribute; other attributes aren't persistable to the lockfile yet.
|
|
205
159
|
for (const k of Object.keys(attrs)) {
|
|
206
160
|
assert.equal(k, 'type', `unsupported import attribute: ${k}`)
|
|
207
161
|
}
|
|
208
162
|
|
|
209
|
-
// Load mode: serve bytes the bundle attested.
|
|
210
|
-
//
|
|
211
|
-
// bundle doesn't carry -- a missing in-scope file is a hard error, not a
|
|
212
|
-
// silent disk fallback. Resources return `{ contents }` only; esbuild's
|
|
213
|
-
// loader config (file/dataurl/copy/...) interprets and emits them by
|
|
214
|
-
// extension, the same way capture mode lets esbuild's loader machinery
|
|
215
|
-
// handle resource emission.
|
|
163
|
+
// Load mode: serve bytes the bundle attested. getFile verifies the hash and throws on a file
|
|
164
|
+
// the bundle doesn't carry -- a missing in-scope file is a hard error, not a disk fallback.
|
|
216
165
|
if (this.#state.config.loadBundle) {
|
|
217
166
|
const { source } = this.#state.getFile(pathToFileURL(path).toString())
|
|
218
|
-
if (kind === 'resource')
|
|
219
|
-
|
|
167
|
+
if (kind === 'resource') {
|
|
168
|
+
// Plugin-provided contents with no `loader` default to `js` (esbuild applies the build's
|
|
169
|
+
// per-extension loader only on its own load path), so replay the configured loader from
|
|
170
|
+
// initialOptions.loader. No configured loader fails symmetrically with a capture build.
|
|
171
|
+
const loader = initialOptions.loader?.[extname(path)]
|
|
172
|
+
return loader ? { contents: source, loader } : { contents: source }
|
|
173
|
+
}
|
|
174
|
+
const loader = this.#loaderFor(path)
|
|
175
|
+
// A transform that declines (undefined) serves the attested bytes unchanged.
|
|
176
|
+
const transformed = await this.#transform?.(source, { path, loader })
|
|
177
|
+
return transformed ?? { contents: source, loader }
|
|
220
178
|
}
|
|
221
179
|
|
|
222
180
|
const source = await realReadFile(path)
|
|
223
181
|
|
|
224
182
|
if (kind === 'resource') {
|
|
225
|
-
// Mark
|
|
226
|
-
//
|
|
227
|
-
// for valid UTF-8 content, 'resource:base64' for binary. We do NOT return
|
|
228
|
-
// contents/loader: emission is esbuild's job via the build's own `loader`
|
|
229
|
-
// config (file, dataurl, copy, ...), the same way webpack's file-loader
|
|
230
|
-
// runs independently of stasis's afterResolve hook. An asset with no
|
|
231
|
-
// configured loader errors in esbuild, which
|
|
232
|
-
// is the user's responsibility (symmetric with file-loader being required).
|
|
183
|
+
// Mark as a resource (State picks 'resource' vs 'resource:base64' from bytes). Return no
|
|
184
|
+
// contents/loader: emission is esbuild's job via the build's own `loader` config.
|
|
233
185
|
if (!this.#seen.has(path)) {
|
|
234
186
|
this.#seen.add(path)
|
|
235
|
-
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource: true, reason: '
|
|
187
|
+
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, resource: true, reason: 'esbuild' })
|
|
236
188
|
}
|
|
237
189
|
return undefined
|
|
238
190
|
}
|
|
239
191
|
|
|
240
|
-
// Code-classified files must be UTF-8
|
|
241
|
-
// is malformed input (e.g. a .json mis-saved as UTF-16); refuse it rather
|
|
242
|
-
// than silently encoding it as a resource and hiding the underlying problem.
|
|
192
|
+
// Code-classified files must be UTF-8; refuse non-UTF-8 rather than silently encode as a resource.
|
|
243
193
|
assert.ok(isUtf8(source), `StasisEsbuild: code-classified file has non-UTF-8 bytes: ${path}`)
|
|
244
194
|
|
|
245
195
|
if (!this.#seen.has(path)) {
|
|
246
196
|
this.#seen.add(path)
|
|
247
|
-
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, reason: '
|
|
197
|
+
this.#state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, reason: 'esbuild' })
|
|
248
198
|
}
|
|
249
199
|
|
|
250
200
|
return { contents: source, loader: this.#loaderFor(path) }
|
package/src/metro-transformer.js
CHANGED
|
@@ -5,56 +5,19 @@ import { pathToFileURL } from 'node:url'
|
|
|
5
5
|
|
|
6
6
|
import { State } from '@exodus/stasis-core/state'
|
|
7
7
|
|
|
8
|
-
// Companion to the StasisMetro serializer
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// HOW IT WIRES IN (metro.config.js) -- wire it PERMANENTLY, alongside the serializer half:
|
|
18
|
-
// module.exports = {
|
|
19
|
-
// transformerPath: require.resolve('@exodus/stasis/metro-transformer'), // top-level, a sibling of `transformer`
|
|
20
|
-
// }
|
|
21
|
-
// // run with EXODUS_STASIS_BUNDLE=load (+ EXODUS_STASIS_BUNDLE_FILE / LOCK / SCOPE),
|
|
22
|
-
// // OR a stasis.config.json with "bundle":"load". Outside load mode this transformer is
|
|
23
|
-
// // a transparent pass-through (see getLoadState), and under load the StasisMetro
|
|
24
|
-
// // serializer is one too -- one committed metro.config.js carries both halves, the
|
|
25
|
-
// // mode picks the active one (the config is itself attested at capture, so a load
|
|
26
|
-
// // run must execute it unedited).
|
|
27
|
-
//
|
|
28
|
-
// Metro calls `transformerPath`'s `transform(config, projectRoot, filename, data,
|
|
29
|
-
// options)` in each worker with `data` = the file's on-disk bytes (a Buffer) and
|
|
30
|
-
// `filename` project-relative. We wrap the user's real transformer (default
|
|
31
|
-
// `metro-transform-worker`, override via EXODUS_STASIS_METRO_BASE_TRANSFORMER) and,
|
|
32
|
-
// in load mode, replace `data` with the bundle's attested bytes before delegating --
|
|
33
|
-
// so the transform consumes bundle bytes, hash-verified, fail-closed.
|
|
34
|
-
//
|
|
35
|
-
// WHAT THIS DOES AND DOESN'T GUARANTEE:
|
|
36
|
-
// - The bytes that get TRANSFORMED are the bundle's, verified by State.getFile
|
|
37
|
-
// (against the lockfile when one is loaded; against the bundle's own bytes under
|
|
38
|
-
// lock=none). A tampered on-disk source is therefore ignored for the build output
|
|
39
|
-
// and, under a lockfile, rejected outright. An in-scope file the bundle doesn't
|
|
40
|
-
// carry throws -- never a silent disk fallback. Out-of-scope files pass their disk
|
|
41
|
-
// bytes through untouched (mirrors webpack/esbuild load scope handling).
|
|
42
|
-
// - KNOWN LIMITATION: this does NOT let Metro build with the sources fully absent
|
|
43
|
-
// from disk. Metro reads each file from disk in the worker (and hashes it in the
|
|
44
|
-
// main process for its transform cache) BEFORE this transformer runs, and resolves
|
|
45
|
-
// package.json via its own fs in the main process. Serving those reads from the
|
|
46
|
-
// bundle would need fs interception across the main + worker processes (stasis
|
|
47
|
-
// --mock territory), which is larger than this seam. So today's guarantee is
|
|
48
|
-
// "build the bundle's attested bytes, fail closed on disk drift," not "build from
|
|
49
|
-
// a bundle with no source tree." This mirrors the documented node_modules-crossing
|
|
50
|
-
// gap in the webpack load path.
|
|
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."
|
|
51
16
|
|
|
52
17
|
const require = createRequire(import.meta.url)
|
|
53
18
|
|
|
54
|
-
// The real transformer we wrap
|
|
55
|
-
//
|
|
56
|
-
// in front of it. Resolved + required lazily (per worker process) so a non-load build
|
|
57
|
-
// pays nothing and we don't require Metro internals at import time.
|
|
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.
|
|
58
21
|
const BASE_TRANSFORMER = process.env.EXODUS_STASIS_METRO_BASE_TRANSFORMER || 'metro-transform-worker'
|
|
59
22
|
|
|
60
23
|
let base
|
|
@@ -63,25 +26,30 @@ function getBase() {
|
|
|
63
26
|
return base
|
|
64
27
|
}
|
|
65
28
|
|
|
66
|
-
// Per-worker load State, built once
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// pass-through, so wiring it permanently in metro.config.js is safe.
|
|
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.
|
|
70
32
|
let stateInited = false
|
|
71
33
|
let loadState = null
|
|
34
|
+
let stateError = null
|
|
72
35
|
function getLoadState() {
|
|
73
36
|
if (!stateInited) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
37
|
+
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
|
|
45
|
+
}
|
|
77
46
|
}
|
|
47
|
+
if (stateError) throw stateError
|
|
78
48
|
return loadState
|
|
79
49
|
}
|
|
80
50
|
|
|
81
|
-
// True iff `absolute` is
|
|
82
|
-
//
|
|
83
|
-
// out-of-scope (system files, scratch dirs) and defer to disk. Mirrors the webpack
|
|
84
|
-
// plugin's load-mode inScope.
|
|
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.
|
|
85
53
|
function inScope(state, absolute) {
|
|
86
54
|
try {
|
|
87
55
|
state.relative(absolute)
|
|
@@ -95,14 +63,11 @@ function inScope(state, absolute) {
|
|
|
95
63
|
export function transform(config, projectRoot, filename, data, options) {
|
|
96
64
|
const state = getLoadState()
|
|
97
65
|
if (state) {
|
|
98
|
-
// `filename` is project-relative in Metro's
|
|
99
|
-
// projectRoot (resolvePath ignores projectRoot when filename is already absolute).
|
|
66
|
+
// `filename` is project-relative in Metro's contract; resolve against projectRoot.
|
|
100
67
|
const absolute = resolvePath(projectRoot, filename)
|
|
101
68
|
if (inScope(state, absolute)) {
|
|
102
|
-
// getFile re-verifies the hash
|
|
103
|
-
//
|
|
104
|
-
// surfaces here as a hard build error, not a silent disk fallback. Code sources
|
|
105
|
-
// come back as strings; resources as Buffers (already decoded from base64).
|
|
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.
|
|
106
71
|
const { source } = state.getFile(pathToFileURL(absolute).toString())
|
|
107
72
|
data = Buffer.isBuffer(source) ? source : Buffer.from(source)
|
|
108
73
|
}
|
|
@@ -111,14 +76,9 @@ export function transform(config, projectRoot, filename, data, options) {
|
|
|
111
76
|
}
|
|
112
77
|
|
|
113
78
|
export function getCacheKey(config) {
|
|
114
|
-
// Fold the
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// on-disk bytes into its cache key BEFORE we substitute, so when disk == bundle the
|
|
118
|
-
// content hash alone wouldn't tell a load result apart from a plain build), so their
|
|
119
|
-
// cached results must not cross-pollinate. Derive the marker from the resolved load
|
|
120
|
-
// State -- the SAME source of truth as transform() -- not the env var, so load activated
|
|
121
|
-
// via stasis.config.json is keyed correctly too; the bundle path keys it to the bundle.
|
|
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.
|
|
122
82
|
const baseKey = typeof getBase().getCacheKey === 'function' ? getBase().getCacheKey(config) : ''
|
|
123
83
|
const state = getLoadState()
|
|
124
84
|
const marker = state ? `load:${state.config.bundleFile || 'default'}` : 'off'
|