Documentation
¶
Overview ¶
Package script runs Mirth Connect JavaScript.
The goal is that a channel's scripts move across untouched. That is a compatibility exercise, not a design exercise, so the shape of everything here is dictated by what Mirth exposes: msg and tmp as E4X trees, six maps with Java's Map methods, the $ family of lookup functions, logger, and a handful of utility objects whose method names and date-format strings are Java's.
Three decisions are ours rather than Mirth's, and each is a deliberate departure:
A script runs with a deadline and is interrupted when it passes. Mirth will let a script loop forever, which takes the channel with it; an interface that stops accepting admissions because of a typo in a transformer is not acceptable, so there is always a timeout.
Java interoperability is refused with an error naming the class, rather than returning undefined. A script reaching into Packages.java.sql cannot be made to work here, and the useful outcome is a message saying exactly that at the line where it happens, not a NullPointerException three lines later.
File and database access are off unless a channel turns them on. Mirth grants both to every script by default. A transformer that can read any file the process can read is a reasonable thing to want and an unreasonable default.
Index ¶
- Constants
- Variables
- func JavaClasses() []string
- type Context
- type Engine
- type FileRoots
- type Kind
- type Language
- type LogLine
- type Options
- type PathMessage
- type Permission
- type Result
- type RouteFunc
- type Script
- type SharedMap
- func (m *SharedMap) Clear()
- func (m *SharedMap) Get(key string) (any, bool)
- func (m *SharedMap) Keys() []string
- func (m *SharedMap) Len() int
- func (m *SharedMap) Put(key string, value any)
- func (m *SharedMap) Remove(key string) bool
- func (m *SharedMap) Snapshot() map[string]any
- func (m *SharedMap) String() string
Constants ¶
const DefaultLanguage = JavaScript
DefaultLanguage is what an unmarked script is.
Variables ¶
var ErrTimeout = errors.New("script timeout")
ErrTimeout marks a script that ran past its deadline.
Exported so the engine can count timeouts separately from ordinary errors. A script that is timing out is a different operational problem from one that throws: the first is usually load or an infinite loop and affects everything behind it, the second is usually one message.
var KnownLanguages = []Language{JavaScript, Lua, WASM}
KnownLanguages is every language a script may declare.
Functions ¶
func JavaClasses ¶ added in v0.1.17
func JavaClasses() []string
JavaClasses lists every Java class name Perfuse provides a JavaScript equivalent for, sorted. The Mirth importer uses it so it reports these calls as working rather than as needing a rewrite.
Types ¶
type Context ¶
type Context struct {
// Timeout, when positive, bounds this one execution instead of the engine's own timeout. A JavaScript destination
// has its own timeout setting and shares the channel's engine; without this the setting was read and then ignored.
Timeout time.Duration
// Message is bound to msg: the inbound message as a tree. Nil for a preprocessor, which runs
// before parsing and may be looking at something that does not parse at all.
Message *xtree.Node
// Raw is bound to message: the message as text. This is what a preprocessor reads and returns.
Raw string
//
// The path-addressed formats set this. Their scripts reach values through msg.get and msg.set, and this stage does no
// re-serialisation on purpose - each format's handler owns its own encoding, and a stage that re-encoded would have to
// learn three of them. So by the time a transformer runs, the declarative steps have already changed the message and the
// bytes that arrived no longer describe it.
//
// Given that, the choice is between binding text that is stale and binding nothing. Stale is worse: it would give a script
// two disagreeing views of one message, where msg.get returns the current value and message shows the old one, and
// nothing would report the disagreement. Binding it at the filter and withholding it at the transformer would be accurate
// but would make the same identifier mean different things two lines apart.
//
// So it is withheld throughout, and withheld rather than emptied. An empty string is the failure this project has hit
// repeatedly: a script computes from nothing, writes a plausible answer and reports success. An absent identifier fails on
// the first line that touches it, which is the outcome an author can act on. Raw text work belongs in a preprocessor,
// which runs before any of this and does receive it.
RawUnavailable bool
// Output is bound to tmp. When nil, tmp is the same tree as msg, which is
// what Mirth does for a channel with no outbound template and is how most
// transformers are written.
Output *xtree.Node
ChannelName string
ChannelMap *SharedMap
ConnectorMap *SharedMap
ResponseMap *SharedMap
SourceMap *SharedMap
// Log receives logger calls. When nil they are discarded.
Log func(level, message string)
}
Context is everything a script can see for one message.
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine compiles and runs scripts.
func (*Engine) Compile ¶
Compile prepares a script.
The user's code is wrapped in a function so that a bare "return" is legal, which is how every Mirth filter is written. Wrapping is done on one line so that reported line numbers still match what the author sees.
func (*Engine) CompileIn ¶
CompileIn compiles a script in a named language.
Compile is kept as the JavaScript-defaulting form rather than being changed to take a language, because every existing caller means JavaScript and changing the signature would make each of them assert something it had not thought about.
type FileRoots ¶
type FileRoots struct {
// contains filtered or unexported fields
}
FileRoots confines FileUtil to a set of directories.
This exists because "allow: [file]" used to mean the whole filesystem. Demonstrated rather than reasoned about: a channel created through the console read a file from one directory and wrote it to another, and the same script read Perfuse's own database - the file holding password hashes, the LDAP service account password and the OIDC client secret.
The severity is worth stating precisely. Creating a channel needs the editor role; starting one needs admin. So an editor cannot complete the escalation alone - they plant it, and an administrator starting the channel executes it. That is a confused deputy: the admin performs a routine action and grants somebody else's script arbitrary file access as the server process.
Confinement rather than removal, because reading a file from a script is a real need - Mirth sites do it constantly, usually to pick up a lookup table or write a reject file - and taking it away would push people to a worse workaround.
func NewFileRoots ¶
NewFileRoots builds a confinement set from configured directories.
Symlinks are resolved now rather than at each check, because a root that is itself a symlink would otherwise never match the resolved path of a file inside it, and the failure would look like the confinement being broken.
func (*FileRoots) Check ¶
Check returns the resolved path if it is inside a root, or an error naming the refusal.
The returned path is what a caller should open. Opening the original instead would reintroduce the traversal this resolves, because the check and the open would be looking at two different things - the classic race in which a path is validated and then swapped for a symlink before it is used.
type Kind ¶
type Kind int
Kind distinguishes the wrapper a script gets.
const ( // Filter must produce a boolean. Mirth requires a return statement. Filter Kind = iota // Transformer mutates the message and returns nothing. Transformer // Preprocessor runs before the message is parsed and returns the text to parse. // // It exists because a preprocessor is how sites repair messages that do not parse - a stray // character from a serial gateway, a segment terminator that arrived as a line feed, a field a // vendor pads with something that is not legal in the position they put it. Without it those // messages are simply rejected, and Perfuse would be unable to accept traffic that Mirth accepts // today. Its input is text rather than a tree for exactly that reason: there may be no tree. Preprocessor // Postprocessor runs after the message has been handled and cannot change it. // // The message and its outcome are visible, so it can notify, count or record. It cannot alter // what was sent, because by the time it runs the sending has already happened, and a script that // appeared to change a delivered message would be lying. Postprocessor // Writer is a destination script: it handles the message itself instead of something being sent. // // It needs both halves that no other kind has together - the message as a tree, because it is doing // a transformer's kind of work, and its return value captured, because it is reporting whether it // succeeded. Compiling one as a Transformer silently discards what it returned, which is how a // destination that refused a message reported success on the first attempt at this. Writer // Lifecycle runs once at start or stop, with no message in scope at all. // // Separate from Postprocessor because the difference is visible to the author: there is no // message, so a script that reaches for one should be told there is none rather than handed an // empty one and left to wonder why its lookup found nothing. Lifecycle // Reader is a JavaScript Reader source: a script that produces messages from nothing. // // It must return a string (one message) or an array of strings (many messages). Returning // nothing or an empty array means the poll produced no messages. Like Lifecycle, there is no // inbound message in scope. Reader )
type Language ¶
type Language string
Language is which scripting language a script is written in.
Why a second language at all ¶
JavaScript is here because Mirth's scripts are JavaScript and a site migrating has thousands of lines of it. That is a compatibility obligation rather than a preference, and it comes with E4X, a dead ECMAScript extension that has to be translated before goja will parse it.
Lua is here because it is a better language for the job when nobody is migrating: a smaller runtime, no prototype chain to misuse, integer-indexed tables that match segment and field addressing naturally, and a sandbox that is easier to make airtight because the standard library is small enough to enumerate. A site starting fresh should be able to choose it.
Why this is a language and not an engine ¶
The temptation is to call this an engine and give each one its own Compile, Run, Context and Result. That would produce two sets of semantics for the same feature: two answers to what a filter returning nothing means, two definitions of which globals exist, two places for the timeout to be enforced or forgotten.
So Context and Result are shared and only the execution is per language. A script's observable behaviour - what it can see, what it may do, how long it may take, what a filter verdict means - is defined once.
const ( // JavaScript is the default, for Mirth compatibility. // // Default because an unmarked script in an existing channel file is JavaScript: making Lua the default would silently // reinterpret every script written before this existed. JavaScript Language = "javascript" // Lua is the alternative. Lua Language = "lua" // WASM is a compiled module rather than a language. // // The value the field takes is still a language name because that is what the setting is called and what a person writing // the file expects to put there. What it selects is a runtime that does not care which language produced the module, which // is the point of offering it: a site with a transformation already written in Rust or Go can bring it rather than rewrite // it in one of ours. WASM Language = "wasm" )
func ParseLanguage ¶
ParseLanguage reads a language name.
Case-insensitive, and js is accepted for javascript because that is what people type. Lua has no common abbreviation, so there is nothing to accept for it.
type Options ¶
type Options struct {
// Timeout bounds a single execution. Zero means five seconds.
Timeout time.Duration
// Permissions granted to every script this engine runs.
Permissions []Permission
// FileRoots confines FileUtil to a set of directories.
//
// Required whenever PermFile is granted. Nil with PermFile granted means FileUtil refuses every call rather
// than reaching the whole filesystem, which is what it used to do.
FileRoots *FileRoots
// MaxVMs bounds the pooled runtimes. Zero means eight.
MaxVMs int
// Language is which runtime the library belongs to.
//
// Needed because the library is compiled once at construction and a runtime cannot compile another runtime's source. Before
// this field existed the library was always compiled as JavaScript, so a Lua library recorded a JavaScript syntax error that
// only the JavaScript path ever looked at - and the Lua path, which had no library support at all, ignored both the error and
// the library.
//
// Defaults to JavaScript, which is the zero value and the language every existing caller meant.
Language Language
// Library is source made available to every script this engine runs.
//
// This is Mirth's code templates. A site with forty channels usually has one library every channel
// calls, and without this a migration means copying it into forty files - which is not only work
// but forty places to fix the next time it changes.
Library string
// LibraryName is what the library is called in an error message. Defaults to a generic name.
LibraryName string
}
Options configures the engine.
type PathMessage ¶
type PathMessage[M any] struct { // Value is the message. Replaced rather than mutated when a script writes, because the format accessors are functional - // x12.Set returns a new message rather than editing one, which is what makes a failed step leave the original intact. Value M // Accessor resolves paths for this format. Accessor steps.Accessor[M] // Changed records the paths a script wrote, in order. // // Kept so the engine can report what a script did in the same terms a declarative step reports, and so a script that // wrote nothing can be told from one that wrote the same value back. The second matters for an audit trail: "the script // ran and changed nothing" and "the script ran and set three fields" are different facts. Changed []string }
PathMessage is a message a script addresses by path rather than as a tree.
Why these formats get paths and HL7 gets a tree ¶
HL7 v2 and v3 scripts see an XML tree because Mirth's scripts do. That is a compatibility obligation - a site migrating has thousands of lines written against E4X - not a judgement that a tree is the right shape.
X12, NCPDP and delimited have no such obligation, and for them a tree would be a worse fit invented for consistency's sake:
- The path is already the format's own vocabulary. CLM01 is how every X12 implementation guide addresses that element, D7 is what the NCPDP standard calls the product code, and a column name is what a CSV header says. A script written against msg.child("CLM").child("01") would be a translation of the real name into a shape borrowed from XML.
- The paths are the same ones the declarative filter and the transformation steps already use, so a rule can be moved between a step and a script without being rewritten, and a path in an error message means something to whoever reads it.
- Writes go through the format's own accessor, which knows the invariants. x12.Set re-pads a fixed-width ISA element and refuses one that is too long. A tree walker applying changes afterwards would have to learn all of that again, or lose it.
So the vocabulary differs by format because the formats differ. What does not differ is everything else: the verdict rules, the timeout, the logging, what a filter returning nothing means, what a neutralised write reports.
Both languages reach this binding. That is newer than the binding itself: JavaScript was refused here at first, on the grounds that a goja version would be a second implementation which would drift on exactly those shared questions. The objection was sound and the conclusion did not follow - the answers were living inside the Lua closures, which is what would have forced a second copy. They are methods on PathMessage now, so the bindings are translation only and there is one answer to each question by construction rather than by discipline.
func (*PathMessage[M]) Get ¶
func (pm *PathMessage[M]) Get(raw string) (string, bool, error)
Get reads a path. The second return is false when the field is absent, which every format here distinguishes from blank.
Why these three methods exist rather than living in the binding ¶
They were closures inside the Lua binding, which was fine while Lua was the only language. It is not fine with two: the interesting behaviour is not the reading, it is what happens on an ambiguous path and on a write the format neutralises, and two bindings each with their own copy would answer those differently within a release or two.
So the language bindings do translation only - an error becomes a thrown exception or a raised Lua error, an absent field becomes null or nil - and everything that decides *what happened* is here, once.
func (*PathMessage[M]) Has ¶
func (pm *PathMessage[M]) Has(raw string) (bool, error)
Has reports whether a path is present.
func (*PathMessage[M]) Set ¶
func (pm *PathMessage[M]) Set(raw, value string) error
Set writes a path, recording the change unless the format neutralised it.
type Permission ¶
type Permission string
Permission names a capability a script may be granted.
const ( // PermFile allows FileUtil to read and write files, within the directories named by Options.FileRoots. // // The confinement is not optional. Granting this alone used to mean the entire filesystem, which made an // editor-authored script able to read Perfuse's own database - password hashes, the LDAP service account // password, the OIDC client secret - as soon as an administrator started the channel. PermFile Permission = "file" // PermDatabase allows DatabaseConnectionFactory. PermDatabase Permission = "database" // PermRoute allows router.routeMessage to send to another channel. PermRoute Permission = "route" )
type Result ¶
type Result struct {
// Accept is the filter verdict. It is false for a transformer.
Accept bool
// Logs are the logger lines the script produced, in order.
Logs []LogLine
// Duration is how long the script ran.
Duration time.Duration
// Text is what a preprocessor returned, when it returned anything.
Text string
// Replaced says Text is meaningful. Without it an empty string returned deliberately could not be
// told apart from a script that returned nothing, and one of those means "discard the message".
Replaced bool
// Messages is what a Reader script returned: zero or more raw messages to feed into the channel.
Messages []string
}
Result reports what a script did.
func RunPathScript ¶
func RunPathScript[M any](e *Engine, s *Script, ctx *Context, msg M, accessor steps.Accessor[M]) (M, []string, Result, error)
RunPathScript runs a compiled script against a message addressed by path.
Returns the message as the script left it, the paths it wrote, and the result. The message is returned rather than mutated in place because the format accessors are functional, and because a caller that gets its original back on error can retry from what arrived.
type Script ¶
type Script struct {
Name string
// Source is what the author wrote, kept so the interface can show it.
Source string
// Rewritten is what actually runs, kept because a stack trace refers to it.
Rewritten string
// Notes record the E4X constructs that were translated, so the interface can
// say what happened to somebody's script instead of pretending nothing did.
Notes []e4x.Note
// contains filtered or unexported fields
}
Script is a compiled script.
func (*Script) Release ¶
Release frees anything a compiled script holds.
Only WebAssembly holds anything: goja and gopher-lua state is ordinary garbage, while a wazero module is mapped executable memory the collector does not account for. Expressed as an interface check rather than a language check so that a runtime added later cannot leak by omission - a compiled form that needs cleaning up says so, and this finds it.
Safe on a nil script and on one that holds nothing, because every caller is a shutdown path and a channel that failed to start should not fail differently while stopping.
type SharedMap ¶
type SharedMap struct {
// contains filtered or unexported fields
}
SharedMap is a concurrency-safe string-keyed map.
globalMap and globalChannelMap outlive a single message and are reachable from every channel at once, so they cannot be plain maps: two messages arriving together on different connectors would race. Mirth uses synchronised maps for the same reason.