@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 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
+ }