stent
English | 中文
Stent/Mixin-style extension layer over Orchestrion-JS for trusted Cordis plugins. The service is opt-in: nothing in the default host composition mounts it, and patches register through trusted code.
What it does
A trusted plugin (A) can change the behavior of another plugin's function (B) without editing B's source, by registering a Stent patch against B's module, file, and function:
| Operation | What the handler can do |
|---|---|
before |
Mutate the call arguments before the original body runs. |
after |
Observe or replace the successful result (including async results, after settlement). |
around |
Decide whether the original body runs and optionally replace its result (call invoke() to delegate). |
replace |
Own the call entirely; the original body only runs if the handler calls invoke(). |
The source is layered inside the three packages rather than adding another package. stent/src/transform owns platform-neutral instrumentation configuration and AST rewriting; src/node owns Node hooks, module identity, and loader-thread wire transport; src/browser owns browser transforms and runtime bundle serving; src/hmr owns HMR generation ownership and Node cache re-transformation; src/testing owns child-process fixtures. stent-api/src/compat separates the cooperative contract, instrumentation builder, and service. The companion integration package's src/host, src/browser, and src/bootstrap entries provide host facades, browser services, and profile assembly. Its catalog adapter is mounted by that companion package, so the pure Stent service has no catalog dependency.
Installation and bootstrap
import { bootstrapStent, StentService } from '@oh-my-dsh/stent'
import type { Context } from 'cordis'
declare const ctx: Context
const disposeHooks = bootstrapStent([])
await ctx.plugin(StentService)
disposeHooks()
bootstrapStent validates the patches, builds their Orchestrion instrumentations, and installs the transformation hooks. In the host, a stent composition row carrying static descriptors under config.stent.patches (id/target/operation — handlers are trusted code bound at registration) is bootstrapped automatically during boot() preparation, before any config-tree entry mounts. installStentHooks is the lower-level form when instrumentations are already built.
A patch may set required: true: once the application boots and every target module has been imported, checkRequiredPatches(patches) fails loud, naming the patch id and its target, when a required patch's transform never rewrote anything — the filePath may be the wrong launch form (src/index.ts vs lib/index.js) or the function may have moved. The host runs this check automatically after boot() completes. Several launch forms under one patch id are covered either by a RegExp filePath (e.g. /^(src\/index\.ts|lib\/index\.js)$/) or by the filePaths array convenience (each entry expands into its own instrumentation under the same id, one binding record per matched file). The load-time bindings the check is built on are recorded per transformed file and visible through ctx.stent.bindings(id?) and each list() entry.
# User overlay: keep the pure service row as the descriptor carrier. Its
# package root has no Loader `apply`; enable the host integration row separately.
- id: stent
disabled: true
config:
stent:
patches:
- id: vendor/rewrite-greeting
target:
module: '@example/target-package'
versionRange: '^1.0.0'
filePath: 'lib/index.js'
functionQuery: { functionName: 'greet', kind: 'Sync' }
operation: 'before'
- id: stent-dsh
disabled: false
The host integration row mounts the Host facades. The core package's browser
half (./client, implemented by src/browser/client) is a separate client
artifact that installs ctx.stent when its browser entry materializes; it does
not turn the package root into a Loader plugin.
The hooks must be installed before the target module's first evaluation; a patch registered after that point only takes effect for modules transformed later. The registerHooks API has no unregister, so the returned disposer deactivates the installation's state rather than removing the hooks.
Registering a patch
import type { Context } from 'cordis'
import type { StentCall, StentService } from '@oh-my-dsh/stent'
export const inject = ['stent']
export function apply(ctx: Context & { stent: StentService }): void {
ctx.stent.register({
id: 'my-vendor/rewrite-greeting',
target: {
module: '@example/target-package',
versionRange: '^1.0.0',
filePath: 'lib/index.js',
functionQuery: { functionName: 'greet', kind: 'Sync' },
},
operation: 'before',
handler(call: StentCall) {
call.arguments[0] = String(call.arguments[0]).toUpperCase()
},
})
}
The registration is a fiber effect owned by the registering plugin: disposing the plugin disables and removes the patch, and a patch id is exclusive to one owner — a different plugin claiming an already-registered id fails loud instead of silently overwriting the incumbent's hook. Every registration attaches its own disposal to the registering fiber, and the disposer only removes the entry while that fiber still owns it: a hot reload's new generation takes its plugin's patches back (same owner, transfer), so the old generation's unload becomes a no-op instead of unregistering the new generation's hooks. ctx.stent.list() returns an ordered diagnostic snapshot whose entries carry the patch's recorded load-time bindings; ctx.stent.bindings(id?) returns the binding records directly; ctx.stent.disable(id) / ctx.stent.enable(id, handler) toggle a patch without removing it, and ctx.stent.remove(id) removes it entirely. Plugins that cannot declare the optional service mount it through getStent(ctx) — mount-aware: it reuses an existing registration and returns the context's view of the registry.
Security and trust model
- Patch handlers are trusted code bound at registration time; executable handlers are never deserialized from YAML or model input.
- Transformed code has process-level authority inside the target module.
cordis_mounttemporary plugins and repository plugins must not receive Stent capability without an explicit grant. - Ids must match
[A-Za-z0-9._:/+-]{1,120}(they are embedded in diagnostics and generated code). - Target validation is fail-loud: a malformed target (bad id, module, version range, file, operation, selector, or index) throws at registration instead of installing a config that never matches. A well-formed target that matches nothing — different installed version, different file layout — silently leaves the module untransformed; the matcher only rewrites what its selectors pick.
- A selector that picks several functions in one file rewrites every match by default (the upstream first-match-only default is flipped:
index: null); pass a zero-basedindex(target.indexfor a rawastQuery,functionQuery.indexfor a name query) to rewrite a single match. Constructor targets are rejected loudly at transformation time — a moved constructor body cannot carrysuper()ornew.target— so patch a method or factory instead.
Platform support
- Node Host (ESM + CommonJS): supported via synchronous
module.registerHooks(Node ≥ 22.22.3 / ≥ 24.11.1) and the CJS_compilepath. Module identity resolves through the npm-layout parser first and falls back to the nearestpackage.json(nodePackageResolver) — Node realpaths workspace links, so a workspace package's loaded URL has nonode_modulesboundary for the layout parser to name, while the nearest manifest always can. This is what lets patches target first-party workspace packages (e.g. a host tool bundle) at their real paths.registerHooksexists from 22.19.0, but before 22.22.3 / 24.11.1 its synchronous load chain returns no source for CommonJS modules when loader-thread hooks (module.register, e.g. tsx on those versions) are also present, which crashes Node's load validation; those versions therefore use the asyncmodule.registerfallback through thenode/hook-entryloader-thread module. The entry is registered once and reads a shared configuration file (rewritten by the main thread on every installation and disposal) on each load, so re-transformation, disposal, and concurrent installations behave the same on both paths. - Browser/Web: the bundle-time rewrite (
createWatchedBrowserTransform(orcreateBrowserTransformfor a static set) +repoSourceResolver, wired throughclientBundle(id, libEntry, { transform })) rewrites client plugin functions, and the package's own client half (./client, implemented bysrc/browser/client) installs the bridge and mountsctx.stentin the browser Cordis tree. Client bundles fall back to the original body until that entry materializes, so patches take effect for calls after the browser Stent runtime is up. The web roster rowstentis disabled by default (opt-in).
Browser build usage
The host build seam (clientBundle) is owned by the host version selected by the profile; this package only provides the transform. A host integration wires the transform into its bundle step:
import { createWatchedBrowserTransform, repoSourceResolver } from '@oh-my-dsh/stent'
const stent = createWatchedBrowserTransform(
new URL('./stent.patches.json', import.meta.url).pathname,
repoSourceResolver('@example/client-my-plugin', new URL('..', import.meta.url).pathname, '0.0.1'),
)
The patches file holds a JSON array of static patch stubs (the same shape the profile row's config.stent.patches carries; JSON cannot express a RegExp filePath, so file paths are strings), and a malformed file fails the build loudly. The transform registers the file in the bundler's watch graph on every module, so under tsdown --watch (pnpm run dev:web) an edit rebuilds the bundle with the new patch set — the build trigger — and the client-hmr chain (stat poll, rebuilt frame, invalidate/prefetch/fiber swap) delivers it to the browser. A static in-memory patch set can still use createBrowserTransform directly.
The resolver maps the package's own source tree to its package identity; the upstream adapter is not used because it requires a node_modules boundary that repository source builds do not have. TypeScript sources are stripped to plain JavaScript before transformation (the transformer parses emitted JavaScript).
Runtime bundle serving
When the target bundle cannot be transformed at build time (its build is owned by another package), serveBrowserTransform(ctx, options) serves a transformed copy at runtime: it registers an EXACT webserver route (the exact table wins before longest-prefix, so it outranks the module host's /plugins route without a conflict), resolves the patches' module package through the Loader composition anchor (ctx.baseUrl) rather than Stent's dependency tree, applies the patch rewrites per request under a source-content cache, answers 405 for non-GET and 404 for an unreadable bundle, and is loud by default when any selector rewrites nothing (500 naming every unbound patch id) — degrading to the raw bundle only with fallback: 'raw'. A missing composition anchor or unresolvable target package fails at registration. patch accepts one descriptor or an array: several patches stack on the same file exactly like Node-side patches (ascending priority wraps outermost), so several plugins can enhance the same bundle without owning it — the route stays single-owned, the rewrites stack. The route is a fiber effect; the returned disposer removes it immediately.
Testing patches
The transformation hooks cannot be unregistered and transformed modules stay cached, so every patch scenario needs a fresh process. runPatchFixture({ patches, entry, args }) from stent/test/testkit makes that mechanical: it spawns a child that bootstraps the patches, imports entry (whose default export runs with args), and returns { bindings, result, error, exitCode } — the thrown error's message travels verbatim (the enriched-error assertions of a node-half spec need no hand-rolled child runner), and each patch's load-time binding records make an unbound patch visible in the same call.
Model Experience
None, as this package is host-side load-time transformation and patch registry machinery; patches register through code, never through model-written configuration.
KV Cache effect
None; the package neither assembles nor sends a provider request.
Known Limitations and Deferred Work
- Hooks stay for the process lifetime, state does not.
registerHookshooks compose and stay registered; the disposer removes the installation's state (hooks become pass-through, cached transformers are freed). Each installation captures its own state and transforms through its own matcher, so concurrent installations are isolated; the shared CommonJS_compilewrapper chains every active installation in installation order (mirroring the sync hook chain), and disposing an earlier one leaves later ones intact. The asyncmodule.registerfallback reaches the same semantics through its shared configuration file: the single loader-thread entry reads the current installation stack on every load, so a disposed installation stops transforming ESM on the next evaluation. The pid-scoped configuration file is removed on process exit. - CommonJS and ESM modules re-transform on both hook paths. An already-evaluated module can be re-evaluated under the current installation stack:
retransformCommonJs(filename)drops therequire.cacheentry (and the same file's Node-internalloadCacheentry, so both graphs observe the fresh evaluation) and seen marks, andretransformEsm(url)evicts the module's Node-internalloadCacheentry (the same mechanism the vendored Loader's HMR uses) — the nextrequire()/import()runs the hooks again with the current installation stack (the sync hooks read the main-thread stack; the async entry reads the shared configuration). An HMR cycle replaces an old installation by disposing it before re-evaluating, so the fresh module carries only the new instrumentation; the old exports object keeps the old transformation. A failed ESM re-import restores the evicted entry, so the previous instance survives instead of leaving the URL unevaluatable. ESM re-transformation requires Node ≥ 22 (the internal module loader); the asyncmodule.registerfallback supports it too, since the loader thread re-reads the configuration on the re-import. - Multiple patches on one function stack by priority. Instrumentations apply in ascending priority order, so a higher-priority handler runs first (the outermost layer); equal priorities keep installation order (the later instrumentation wraps the outermost layer, so its handler runs first). Across installations, nesting follows installation order on every hook path — the later installation wraps outermost regardless of priority — because the sync hooks, the CJS
_compilewrapper, and the async loader-thread entry all chain transforms per installation. Tworeplacepatches on the same target are rejected at registration. - Arrow targets support every parameter pattern (identifiers, rest, defaults, and destructuring — the patterns bind their names before the injected statements run), and a body referencing the enclosing
argumentsobject is preserved by capturing it first. An arrow whose parameter is literally namedarguments(it would shadow that capture) is skipped. Generator functions transform through delegation: the traced generator isyield*-delegated on the no-handler andbefore/around-invoke paths, so iteration semantics survive; a handler-supplied replacement that is not iterable is returned directly.afterobserves the generator object before iteration (the operation cannot intercept between yields). - Node load-time transformation requires precompiled JavaScript. The loader parses emitted JS;
.tssources passed raw to the Node load hook fail loudly. The browser build path strips TypeScript annotations (and JSX) before transformation.