@eco-incorp/sauce-compiler
v2.5.0
Published
The SauceScript compiler as WebAssembly. Compiles to bytecode for the Sauce EVM and SVM engines, in the browser and in Node.
Readme
@eco-incorp/sauce-compiler
The SauceScript compiler, compiled to WebAssembly. A thin wasm-bindgen marshalling layer over the
Rust compiler core, with no compiler logic of its own. The core is I/O-free, so the JS host supplies a
synchronous resolver — in Node, backed by fs.
Compiles to both targets — EVM and SVM (Solana) — and runs in both environments, Node and the browser. One package ships both builds; see JS API.
Documentation
sauce.eco.com — the Sauce overview and integration quickstart. This README documents the package's JS API.
Installable by anyone; not open source. Licensed under the Eco Proprietary License 1.0, which ships in the package as
LICENSE.md. Publishing to the public registry means there is no token to configure — it grants no use rights beyond that license. The source repository is private.
Versioning is semver, and which bump a breaking change gets depends on the version you are on. On
1.xand later, a breaking change to the JS API or accepted SauceScript syntax is a major bump, new language features, options and exports are minor, and fixes are patch. On a0.xversion the API is not stable — semver says anything may change — and this package's rule there is that a breaking change is a minor bump, so pin exactly. Either way, a change to what SauceScript compiles to is a compiler change rather than an API change: read the release notes for bytecode, not the version.
Install
npm install @eco-incorp/sauce-compilerCreate src/main.ts:
function main(): Uint256 { return 6n * 7n; }Then compile it in Node:
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
const { compile } = require("@eco-incorp/sauce-compiler");
const root = "./src";
const { bytecode } = compile({
entry: "main.ts",
target: "evm",
// `path` is the specifier as the source wrote it, so join it against a root.
resolve: (path) => {
try {
return readFileSync(join(root, path));
} catch (error) {
if (error.code === "ENOENT" || error.code === "ENOTDIR") return undefined;
throw error; // an unreadable file is a failure, not an absent target arm
}
},
});JS API
The package ships two builds of the same wasm and picks between them by exports condition, so
the import is the same either way — what differs is whether you have to initialize it.
Node (require, or import from ESM) resolves to the CommonJS build, which loads the wasm off
disk itself. Nothing to await:
const { compile } = require("@eco-incorp/sauce-compiler");Browsers and bundlers resolve to the ESM build, whose default export initializes the wasm. Call
it once before the first compile:
import init, { compile } from "@eco-incorp/sauce-compiler";
await init(); // fetches the wasm next to the module; or pass bytes/a URL explicitlyEither environment, if you would rather not care which one you are in — ready() initializes
where that is needed and is a no-op where it is not:
import { ready } from "@eco-incorp/sauce-compiler/ready";
const { compile } = await ready();Prefer this in code that runs in both, such as an app that compiles in the browser and in its Node
tests. It saves branching on which build you got: TypeScript resolves only one exports condition,
so the alternative is describing the other build's shape by hand and casting into it. Call it as often
as you like — initialization happens once, and concurrent callers share the one attempt.
The package ships its own TypeScript types — CompileOptions, CompileResult,
AccountManifest, AccountEntry, AccountRole, SauceTarget, ResolveModule, ResolvePackage,
FnCacheStats — so a consumer does not declare them. That is deliberate: an entry point typed
(options: any) => any pushes every consumer into hand-writing the option and result shapes to get
checking back, and two consumers then hold two copies of one contract that drift. Import the types
from here instead:
import { compile, type CompileOptions } from "@eco-incorp/sauce-compiler";Either way compile is the same function. The following illustrates all options; supply your own
filesystem root, package mapper and ABI JSON:
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
const { compile } = require("@eco-incorp/sauce-compiler");
const { bytecode, manifest, isaRevision, entryEncoding, entrySchema } = compile({
entry: "main.ts", // optional, default "main.js"
target: "evm", // "evm" | "svm"
resolve(path) {
// required: return the module's bytes as a Uint8Array (a Node Buffer is one).
// `path` arrives AS THE SOURCE SPELLED IT — `"./token.json"` keeps its `./`, and
// nothing is normalized for you. Join it against a root, or strip the `./` before
// a map lookup; a map keyed by bare filename silently answers "absent".
// Return undefined when there is nothing at `path` — that is how the per-target
// arm probe (`token.evm.ts` before `token.ts`) learns an arm does not exist.
// Throw only when the path names something you could not read: a throw is a hard
// failure and will NOT fall back to the neutral arm.
try {
return readFileSync(join(root, path));
} catch (error) {
if (error.code === "ENOENT" || error.code === "ENOTDIR") return undefined;
throw error;
}
},
resolvePackage(specifier, importer) {
// optional: map a bare specifier ("@sauce/token") to a path resolve() accepts.
// Return undefined to decline — the specifier is then used as a path unchanged.
// Throw if the package is genuinely not installed.
return mapPackage(specifier, importer);
},
contracts: [["ERC20", "./out/ERC20.json"]], // optional: [name, path] ABI pairs
inlineContracts: [["Token", tokenAbiJson]], // optional: [name, abi] pairs, ABI JSON inline
ambient: ["@sauce/token"], // optional: modules broadcast into every module, no import line
defines: [["FEE_BPS", "30"]], // optional: [name, value] compile-time consts, value as a string
optimize: true, // optional, default true
compactArgs: false, // optional, default false — ABI entry arguments
cache: true, // optional, default true — see Caching
chain: "polygon", // optional: the chainProfiles key the entry program runs on
chainProfiles: { polygon: { name: "Polygon", builtins: { "ctx.blobHash": "unsupported" } } }, // optional
});Options
| Option | Type | Notes |
| ---------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| target | "evm" \| "svm" | Required. |
| resolve | (path) => Uint8Array \| undefined | Required. path is the specifier as written — a relative import keeps its ./, and nothing is normalized — so join it against a root rather than keying a map by bare filename. undefined/null means "nothing at this path". Anything else is a resolve error, never silently-empty source. A .json path may be either a contract ABI or a document the source imports as data (with { type: "json" }) — both are bytes here, so serve any .json the program names. |
| resolvePackage | (specifier, importer) => string \| undefined | Optional. undefined/null declines, and the specifier is then used as a path unchanged. |
| entry | string | Optional, default "main.js". |
| contracts | [name, path][] | Optional. Contracts the host declares by path; each path is passed to resolve. Emits the same bytes as a bare import Name from "./path.json" in the source, which is the spelling to prefer when the ABI is a file the resolver already serves; the same specifier under with { type: "json" } reads the file as data instead. |
| inlineContracts | [name, abi][] | Optional. Contracts whose ABI JSON is given directly rather than a path resolve must fetch, for a caller that already holds an ABI in memory. Entry module only, exactly like contracts; a name colliding with either is a compile error. |
| ambient | string[] | Optional. Module specifiers broadcast into every module — their exports are callable/readable with no import line. Each resolves as an import of it would. Ambient always loses to user code: a name the module declares, imports, or binds as a contract, a JSON data import or a named type wins silently. A module has one namespace, so an occupied name blocks the broadcast whether the two are both functions, both bindings, or one of each. |
| defines | [name, value][] | Optional. Compile-time parameters injected into every module as module-scope consts. Each value is a decimal or 0x-hex integer string (a define is a 256-bit word, which JS number cannot carry), with an optional trailing n and _ separators. Precedence is the opposite of ambient: a define overrides the module's own const of that name, on both channels, so an import of it reads the defined value too. It does not override a module-scope let, a declared function, a lookup table, a bound contract, a JSON data import's binding, or an import's local alias — each is a compile error naming the define — and a function-local or parameter shadows it. A name imported under its own name is left to its import, which already carries the defined value; a data import is refused instead, because a document read from a file has no declaration for the define to stand in for and applying it nowhere would leave the option a silent no-op. The value must be assignable to the declaration's type (only 0/1 reaches a boolean) and the name must be one a source file could spell. A repeated name is an error, not a last-wins override. The name is program-wide — "every module" includes packages the host did not write, so a dependency's own const of that name is replaced with no diagnostic, and one that binds the name to anything else fails the compile; pick a name nobody else would choose. |
| optimize | boolean | Optional, default true. Enables optimizations including constant folding/propagation, bounded loop unrolling, scalar expression reuse, invariant tuple-read reuse and scalar helper inlining. Validation still runs when false. |
| compactArgs | boolean | Optional, default false. Uses compact-v1 entry arguments instead of ABI encoding. Encode arguments using the returned entrySchema; ordinary ABI-encoded arguments cannot be substituted. |
| cache | boolean | Optional, default true. The per-function IR-lowering cache — see Caching. Set false for a guaranteed-fresh compile. |
| chain | string | Optional. The chain the entry program runs on: a key of chainProfiles, whose profile its builtins are compiled against. Absent with no chainProfiles, no profile applies; absent with them is a compile error, since a host that assembled a profile registry and missed its chain lookup would otherwise get the most permissive compile. A chain with no profile is a compile error; give a chain whose builtins all behave as the target's an empty profile. |
| chainProfiles | Record<string, ChainProfile> | Optional. Every chain's profile of the builtins whose behaviour differs by chain, keyed by an opaque chain key (a chain id or a name). A ChainProfile is { name?: string, builtins?: { [builtin]: "unsupported" \| { constant: string } } }, keyed by a target-gated builtin's source spelling ("ctx.blobHash", "evm.create2"). "unsupported" makes a use of the builtin a compile error naming the chain (`ctx.blobHash()` is not available on Polygon); a constant (decimal or 0x-hex, as a string) compiles the call to that literal, so folding sees through it, and is only for a builtin that takes no arguments. A builtin the profile does not name compiles as it does without one, and the target is asked first. A name that is not a builtin a profile can configure is a compile error, and every profile is checked whether or not the compile uses it. The entry is compiled against chain's profile. (A program a compile lowers for another chain is to be compiled against that chain's; no construct spells that yet, which is why the option is a map.) The cache keys each function on what its chain's profile says about the builtins it reads, so a lowering is reused between two chains only where their profiles answer alike. See Chain profiles. |
Result
| Field | Type | Meaning |
| --- | --- | --- |
| bytecode | Uint8Array | Compiled program, without the caller's argument values. |
| isaRevision | "stack-compact-v2" | Instruction-set compatibility identifier; independent of the npm package version. |
| entryEncoding | "abi-v1" \| "compact-v1" | Encoding the program expects for entry arguments. |
| entrySchema | Uint8Array \| undefined | Schema for encoding compact arguments; absent with default ABI encoding. |
| manifest | AccountManifest \| undefined | SVM account slots and required roles, plus a remaining-accounts marker when used. undefined on EVM. |
| chain | string \| undefined | The chain the bytecode was compiled for, echoed back from chain; absent when the compile named none. The same source compiles to different bytecode per chain once a profile gives a builtin a constant or refuses it, so this is what distinguishes an artifact built for one chain from one built for another. |
How a .json import is read
A bare import Name from "./x.json" binds a contract ABI. The same specifier under
with { type: "json" } reads the file as data instead. Both arrive at resolve as bytes, so a
resolver serves any .json the program names and does not need to tell the two apart.
with { type: "json" } is the only attribute accepted: an unknown key or value, an empty
with {}, more than one attribute, an assert { … } clause, and the attribute on a .js/.ts
specifier are each a compile error naming what was wrong. A config file imported without the
attribute reports that it is valid JSON but not a contract ABI, rather than a missing abi field.
Caching
compile memoizes function lowerings, on by default. The key is a function's source text, the
target, and a fingerprint of every external binding its lowering read — so recompiling one program
under many defines reuses the functions a changed define does not move. That is the workload it
exists for; a single compile pays a little and gains nothing.
The store lives in the WASM instance and persists across compile() calls. It is bounded and
automatically empties when full. clearFunctionCache() empties it explicitly and resets the counters:
const { compile, clearFunctionCache, functionCacheStats } = require("@eco-incorp/sauce-compiler");
const options = {
target: "evm",
resolve: (path) => path === "main.js"
? new TextEncoder().encode("function main(): Uint256 { return 42n; }")
: undefined,
};
compile(options);
functionCacheStats(); // { hits, misses, inserts } — this instance's counters, not the process's
// Release cached lowerings and reset the counters, for example before measuring a cold compile.
clearFunctionCache();Changed source or consulted bindings invalidate reuse automatically. If your resolver caches file
contents, refresh that cache yourself; clearFunctionCache() cannot change the bytes it returns.
cache: false bypasses lowering-cache lookup and insertion for that compile.
Errors
A validation, resolve or compile failure throws a CompileError — a JS Error whose message
is the rendered report: the rule that was broken, the source line, and a caret under the span.
Option and resolver errors may have no source span. A host callback's own message is threaded into
the chain, so a resolver that failed says so rather than surfacing as a generic compile error.
error.diagnostics places each source error the report carries, in source order, so a host can
point at the source without parsing message:
| Field | Type | Meaning |
| ----------- | ------------------ | ------- |
| path | string | The module the error is in, as resolve was asked for it — an error in an imported module names that module, not the entry. |
| message | string | The error, as the rendered snippet titles it. |
| start | SourcePosition | The first character of the span. |
| end | SourcePosition | One past the span's last character. |
| secondary | DiagnosticLabel? | A second location with its own note, such as where a redeclared name was first declared. |
A SourcePosition is { offset, line, column }, counted in UTF-16 code units — the unit a
JavaScript string indexes in — so source.slice(d.start.offset, d.end.offset) is the span. line
and column are 1-based. diagnostics is an empty array for an error with no source location.
column counts UTF-16 code units; the --> path:line:col header inside message counts characters, so the two differ on a line containing an astral character. Drive an editor from diagnostics, which is in the unit String.prototype.slice uses.
try {
compile({ target: "evm", resolve });
} catch (error) {
for (const d of error.diagnostics) {
console.log(`${d.path}:${d.start.line}:${d.start.column}: ${d.message}`);
}
}