This document explains the internal architecture of @stackables/bridge for contributors who want to understand how the pieces fit together.
The Bridge is a declarative dataflow engine for GraphQL. Instead of resolvers, you write .bridge files that describe what data is needed and where it comes from. The engine reads those instructions and resolves fields on demand — only fetching what the client actually asked for.
The pipeline has two phases:
.bridge text ──► [Lexer] ──► [Parser] ──► BridgeDocument
│
GraphQL request ──► [bridgeTransform] ──► [ExecutionTree] ──► response
packages/bridge/src/
├── index.ts Public API — re-exports the stable surface
├── types.ts All shared types (NodeRef, Wire, Bridge, ToolDef, …)
├── parser/
│ ├── index.ts Thin entry point — exposes parseBridge + diagnostics API
│ ├── lexer.ts Chevrotain tokens: keywords, operators, literals
│ └── parser.ts Chevrotain CstParser + CST→AST visitor (toBridgeAst)
├── bridge-format.ts Round-trip serializer: BridgeDocument → .bridge text
├── bridge-transform.ts GraphQL schema transformer — wraps field resolvers
├── ExecutionTree.ts Pull-based execution engine (the core runtime)
├── utils.ts parsePath helper ("a.b[0].c" → ["a","b","0","c"])
└── tools/
├── index.ts builtinTools bundle + std namespace exports
├── http-call.ts createHttpCall (REST API tool with LRU caching)
├── upper-case.ts std.str.toUpperCase
├── lower-case.ts std.str.toLowerCase
├── find-object.ts std.arr.find (array search by predicate)
├── pick-first.ts std.arr.first (head of array, optional strict)
└── to-array.ts std.arr.toArray (wrap scalar in array)
The lexer tokenizes .bridge source text using Chevrotain. Key design points:
- Keywords (
tool,bridge,with,on, …) are defined withlonger_alt: Identifierso they don't conflict with user-defined names that start with the same characters - Whitespace, newlines, and
#comments are put onLexer.SKIPPED— the parser never sees them - Operator tokens (
<-,||,??) are ordered from longest to shortest so Chevrotain matches the right one - The
forcekeyword is defined withlonger_alt: Identifierlike other keywords
// Adding a new keyword — always set longer_alt to avoid stealing identifiers:
export const MyKw = createToken({
name: "MyKw",
pattern: /my/i,
longer_alt: Identifier,
});The parser is a Chevrotain CstParser (Concrete Syntax Tree). The grammar is defined as methods on the BridgeParser class (starts ~line 90). Each method corresponds to a grammar rule, using Chevrotain primitives (this.CONSUME, this.SUBRULE, this.OPTION, this.MANY, this.OR).
The parser produces a CST — a tree of named child arrays — which is intentionally untyped. The visitor (toBridgeAst, ~line 820) converts the CST into a typed BridgeDocument containing Instruction[] AST nodes.
Key grammar entry points:
| Rule | What it parses |
|---|---|
program |
Top-level — a sequence of version + blocks |
bridgeBlock |
A full bridge Type.field { … } block |
toolBlock |
A tool name from fn { … } block |
constBlock |
A const name = value declaration |
defineBlock |
A define name { … } reusable sub-graph |
bridgeWithDecl |
A with X as Y handle declaration |
wireDecl |
A wire line: o.field <- source or o.field = "value" |
The output of parsing is a BridgeDocument:
interface BridgeDocument {
version?: string; // from `version X.Y` header
instructions: Instruction[];
}
type Instruction = Bridge | ToolDef | ConstDef | DefineDef;The most important types are:
NodeRef — identifies a single data point in the execution graph:
type NodeRef = {
module: string; // "myApi", "sendgrid", "_" (SELF_MODULE = bridge's own type)
type: string; // GraphQL type name or "Tools"
field: string; // field or function name
instance?: number; // disambiguates multiple uses of the same tool in one bridge
element?: boolean; // true when inside an array mapping block
path: string[]; // drill-down: ["items", "0", "position", "lat"]
};ToolContext — communication channel from engine to every tool function:
type ToolContext = {
logger: {
debug?: (...args: any[]) => void;
info?: (...args: any[]) => void;
warn?: (...args: any[]) => void;
error?: (...args: any[]) => void;
};
};Constructed by callTool() from BridgeOptions.logger and passed as the second argument to every tool function. Tools that need logging (like std.audit) read context.logger.info instead of requiring factory injection.
ToolCallFn — the function signature for all tools:
type ToolCallFn = (
input: Record<string, any>,
context?: ToolContext,
) => Promise<Record<string, any>>;Wire — a directed data connection:
type Wire =
| {
from: NodeRef;
to: NodeRef;
pipe?: true;
nullFallback?: string;
fallback?: string;
fallbackRef?: NodeRef;
}
| { value: string; to: NodeRef }; // constant wire: valueBridge — wires one GraphQL field to its data sources:
type Bridge = {
kind: "bridge";
type: string; // "Query" | "Mutation"
field: string; // GraphQL field name
handles: HandleBinding[]; // declared sources (tools, input, output, context)
wires: Wire[];
forces?: Array<{ // force statements — eagerly scheduled tools
handle: string;
module: string;
type: string;
field: string;
instance?: number;
catchError?: true; // true = fire-and-forget (force handle catch null)
}>;
arrayIterators?: Record<string, string>; // for array mapping blocks
pipeHandles?: Array<{ key: string; handle: string; baseTrunk: … }>;
passthrough?: string; // set when using shorthand: bridge Type.field with tool
};The engine is pull-based: resolution starts from a demanded GraphQL field and works backward through wire declarations to find its data sources.
bridgeTransform (in bridge-transform.ts) wraps every field resolver in the GraphQL schema. When a request arrives for a bridge-powered field, it creates an ExecutionTree for that field and calls tree.pull(outputRefs).
ExecutionTree.pullSingle(ref) is the recursive heart of the engine:
- Check the in-memory cache — if the trunk's result is already being computed, return the same
Promise(deduplication) - Find all
Wireentries whosetomatchesref(by module/type/field/instance) - Group wires by their target path
- For each group, resolve sources concurrently with
resolveWires - Build the tool input object from all resolved values
- Call the tool function (or return the constant value)
- Cache the result, navigate the path into the result, return the value
When a bridge field has multiple sources (overdefinition, || falsy coalesce, ?? nullish gate, catch error boundary), the engine sorts candidates by inferred cost:
- Cost 0:
inputarguments,context,const— already in memory - Cost 1: tool calls — require a network or compute call
It evaluates cost-0 sources first. If they resolve, it short-circuits and never makes the expensive call. This is how you get field-level caching for free.
When a field has [] as iter { } in the bridge, the engine detects the outer array wire, fetches the array, then creates a shadow tree for each element. The shadow tree inherits all non-element wires from its parent and resolves element-specific wires against the array element.
When options.trace is set to "basic" or "full", each tool call is recorded by a TraceCollector. The full trace is retrievable via useBridgeTracing(context) inside a resolver. At "full" level, inputs and outputs are captured too.
serializeBridge(document) converts a BridgeDocument back to .bridge text. This is used by developer tooling (auto-format, VS Code extension). The serializer:
- Calls
buildHandleMapto map canonical trunk keys back to human-readable handle names - Serializes each
Bridgeblock with itswithdeclarations and wire body - Converts
Wireentries back to<-,=syntax and emitsforcestatements (force handlefor critical,force handle catch nullfor fire-and-forget) - Handles pipe notation, array mapping blocks, fallback chains
bridgeTransform(schema, document, options?) uses @graphql-tools/utils/mapSchema to walk every field in the schema and wrap its resolver. The wrapper:
- At the root field (no
path.prev): checks if aBridgeinstruction exists for this field. If not, falls through to the original resolver — hand-written resolvers coexist fine. - Creates an
ExecutionTreewith the active document, tools, and context - Calls
tree.pull(outputRefs)— the engine does the rest - Returns the result as an
ExecutionTreeso nested fields can continue pulling from the same shared state
Child fields receive the parent ExecutionTree as their source and call source.pull(ref) to get their data.
- Create
src/tools/my-tool.ts:
import type { ToolContext } from "../types.ts";
export function myTool(
input: Record<string, any>,
context?: ToolContext,
): Promise<Record<string, any>> {
const { thing } = input;
// Tools can access the engine logger via context:
// context?.logger?.info?.("myTool called", input);
return Promise.resolve({ result: String(thing).toUpperCase() });
}Every tool receives (input, context?). The context.logger is the engine’s logger from BridgeOptions.logger. If you don’t need logging, ignore the second argument.
- Export from
src/tools/index.tsand add to thestdobject:
export { myTool } from "./my-tool.js";
export const std = {
upperCase,
lowerCase,
// ...
myTool,
};- Add tests in
test/builtin-tools.test.ts:
test("std.myTool", async () => {
const result = await execute(
`
version 1.5
tool t from std.myTool
bridge Query.result {
with t
with output as o
o.value <- t.result
}
`,
{ tools: builtinTools },
);
assert.equal(result.data.result.value, "HELLO");
});- Update the builtin tools example in
examples/builtin-tools/.
Tests use node:test and node:assert. No test framework needed.
test/_gateway.ts exports createGateway({ bridgeText, typeDefs, tools?, options? }) which wires up a full graphql-yoga server. Tests call execute(gql, variables?) to query it.
import { createGateway } from "./_gateway.js";
const { execute } = createGateway({
typeDefs: `type Query { hello: String }`,
bridgeText: `
version 1.5
bridge Query.hello {
with const as c
with output as o
o._value = "world"
}
`,
});
test("hello returns world", async () => {
const result = await execute("{ hello }");
assert.equal(result.data?.hello, "world");
});test/parser-compat.test.ts uses snapshot-style compat(description, bridgeText) tests that parse the text and verify it round-trips through the serializer identically. Add one for every new syntax construct.
A separate package providing:
- TextMate grammar for
.bridgefiles (syntax highlighting) - Language configuration (bracket matching, comment toggling)
- A language server (hover, diagnostics via
parseBridgeDiagnostics)
When you add a new keyword to the lexer, also update the grammar in packages/bridge-syntax-highlight/syntaxes/bridge.tmLanguage.json.
Everything is ESM ("type": "module"). Import paths inside src/ must use the .js extension (TypeScript convention for ESM — .js resolves to .ts during development via tsx, and to the compiled .js at runtime). Never use .ts extensions in imports.
See changesets