Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ isEligible.evaluateSync({
```

### Async enrichment
#### Isolated by default

```ts
import { bonsai } from 'bonsai-js'
Expand All @@ -124,6 +125,21 @@ expr.addFunction('lookupTier', async (userId) => {

await expr.evaluate('lookupTier(userId) == "pro"', { userId: 'u_123' })
```
#### With context access if enabled

```ts
import { bonsai } from 'bonsai-js'

const expr = bonsai()

function lookupCurrentUserTier (this: { context: { currentUserId: string} } ) {
const row = await db.users.findById(this.context.currentUserId)
return row?.tier ?? 'free'
}
expr.addFunction('lookupCurrentUserTier', lookupCurrentUserTier, { allowContextAccess: true })

await expr.evaluate('lookupCurrentUserTier() == "pro"', { currentUserId: 'u_123' })
```

### Editor validation

Expand Down Expand Up @@ -364,7 +380,7 @@ evaluateExpression<number>('x * 2', { x: 21 }) // 42
interface BonsaiInstance {
use(plugin: BonsaiPlugin): this
addTransform(name: string, fn: TransformFn): this
addFunction(name: string, fn: FunctionFn): this
addFunction(name: string, fn: FunctionFn, addFunctionOptions?: {allowContextAccess: boolean}): this
removeTransform(name: string): boolean
removeFunction(name: string): boolean
hasTransform(name: string): boolean
Expand Down Expand Up @@ -540,6 +556,7 @@ What Bonsai does:
- validates method call receivers against a safe allowlist of types (string, number, array, plain object) — array methods include `filter`, `map`, `find`, `some`, `every`, `includes`, `indexOf`, `slice`, `at`
- automatically bypasses allow/deny lists for canonical numeric array indices (e.g., `items[0]`)
- rejects `Promise` values in `evaluateSync()` with actionable errors that name the offending function/transform/method and suggest using `evaluate()` instead
- runs custom user-defined functions with isolation by default, although they can be bound to a given context if explicitly allowed

Important operational caveats:

Expand Down
2 changes: 1 addition & 1 deletion src/eval-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function resolveTransform(name: string, transforms: Record<string, Transf
return transforms[name]
}

export function resolveFunction(name: string, functions: Record<string, FunctionFn>): FunctionFn {
export function resolveFunction(name: string, functions: Record<string, { allowCtx: boolean, f: FunctionFn }>): { allowCtx: boolean, f: FunctionFn } {
if (!Object.hasOwn(functions, name)) {
const suggestion = suggest(name, Object.keys(functions))
throw new BonsaiReferenceError('function', name, suggestion)
Expand Down
6 changes: 3 additions & 3 deletions src/evaluator-async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export async function evaluateAsync(
node: ASTNode,
context: Record<string, unknown>,
transforms: Record<string, TransformFn>,
functions: Record<string, FunctionFn>,
functions: Record<string, { f: FunctionFn, allowCtx: boolean }>,
guard: ExecutionContext,
source?: string,
): Promise<unknown> {
Expand Down Expand Up @@ -214,12 +214,12 @@ async function evalCallExpressionAsync(

if (node.callee.type === 'Identifier') {
try {
const func = resolveFunction(node.callee.name, fn)
const resolved = resolveFunction(node.callee.name, fn)
const args: unknown[] = []
for (const arg of node.args) {
await pushCallArgumentAsync(args, arg, env)
}
const result = await func(...args)
const result = await resolved.f(...args)
g.checkTimeout()
return result
} catch (e) {
Expand Down
12 changes: 8 additions & 4 deletions src/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function rejectPromise(value: unknown, kind: 'function' | 'method' | 'transform'
export interface EvalEnv {
ctx: Record<string, unknown>
tr: Record<string, TransformFn>
fn: Record<string, FunctionFn>
fn: Record<string, { f: FunctionFn, allowCtx: boolean }>
g: ExecutionContext
s?: string
}
Expand All @@ -37,7 +37,7 @@ export function evaluate(
node: ASTNode,
context: Record<string, unknown>,
transforms: Record<string, TransformFn>,
functions: Record<string, FunctionFn>,
functions: Record<string, { f: FunctionFn, allowCtx: boolean }>,
guard: ExecutionContext,
source?: string,
): unknown {
Expand Down Expand Up @@ -207,12 +207,16 @@ function evalCallExpression(node: Extract<ASTNode, { type: 'CallExpression' }>,

if (node.callee.type === 'Identifier') {
try {
const func = resolveFunction(node.callee.name, fn)
const resolved = resolveFunction(node.callee.name, fn)
const args: unknown[] = []
for (const arg of node.args) {
pushCallArgument(args, arg, env)
}
return rejectPromise(func(...args), 'function', node.callee.name)
let func = resolved.f;
if (resolved.allowCtx) {
func = func.bind({ context: env.ctx })
}
return rejectPromise(func(...args), "function", node.callee.name);
} catch (e) {
if (s) attachLocation(e, s, node.start, node.end)
throw e
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export function bonsai(options: BonsaiOptions = {}): BonsaiInstance {
const instance: BonsaiInstance = {
use(plugin) { plugin(instance); return instance },
addTransform(name, fn) { registry.addTransform(name, fn); return instance },
addFunction(name, fn) { registry.addFunction(name, fn); return instance },
addFunction(name, fn, addFunctionOptions?: {allowContextAccess: boolean}) { registry.addFunction(name, fn, addFunctionOptions?.allowContextAccess ?? false); return instance },
removeTransform(name) { return registry.removeTransform(name) },
removeFunction(name) { return registry.removeFunction(name) },
hasTransform(name) { return registry.getTransform(name) !== undefined },
Expand Down
15 changes: 9 additions & 6 deletions src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,34 @@ import type { TransformFn, FunctionFn } from './types.js'

export interface PluginRegistry {
addTransform(name: string, fn: TransformFn): void
addFunction(name: string, fn: FunctionFn): void
addFunction(name: string, fn: FunctionFn, allowCtx?: boolean): void
removeTransform(name: string): boolean
removeFunction(name: string): boolean
getTransform(name: string): TransformFn | undefined
getFunction(name: string): FunctionFn | undefined
getFunction(name: string): { f: FunctionFn, allowCtx: boolean } | undefined
getTransformNames(): string[]
getFunctionNames(): string[]
use(plugin: (registry: PluginRegistry) => void): void
readonly transforms: Record<string, TransformFn>
readonly functions: Record<string, FunctionFn>
readonly functions: Record<string, { f: FunctionFn, allowCtx: boolean }>
}

export function createPluginRegistry(): PluginRegistry {
const transformMap = new Map<string, TransformFn>()
const functionMap = new Map<string, FunctionFn>()
const functionMap = new Map<string, { f: FunctionFn, allowCtx: boolean }>()

// Cached snapshots — rebuilt only when registry changes
let transformsCache: Record<string, TransformFn> = {}
let functionsCache: Record<string, FunctionFn> = {}
let functionsCache: Record<string, { f: FunctionFn, allowCtx: boolean }> = {}
let transformsDirty = false
let functionsDirty = false

const registry: PluginRegistry = {
addTransform(name, fn) { transformMap.set(name, fn); transformsDirty = true },
addFunction(name, fn) { functionMap.set(name, fn); functionsDirty = true },
addFunction(name, fn, allowContextAccess = false) {
functionMap.set(name, { f: fn, allowCtx: allowContextAccess });
functionsDirty = true;
},
removeTransform(name) { const r = transformMap.delete(name); if (r) transformsDirty = true; return r },
removeFunction(name) { const r = functionMap.delete(name); if (r) functionsDirty = true; return r },
getTransform(name) { return transformMap.get(name) },
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export interface BonsaiInstance {
/** Register a named transform for use with the pipe operator (`|>`). */
addTransform(name: string, fn: TransformFn): this
/** Register a named function callable as `name(args)` in expressions. */
addFunction(name: string, fn: FunctionFn): this
addFunction(name: string, fn: FunctionFn, addFunctionOptions?: {allowContextAccess: boolean}): this
/** Remove a previously registered transform. Returns true if it existed. */
removeTransform(name: string): boolean
/** Remove a previously registered function. Returns true if it existed. */
Expand Down
27 changes: 27 additions & 0 deletions tests/bonsai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@ describe('bonsai()', () => {
expect(expr.evaluateSync('greet("Dan")')).toBe('Hello, Dan!')
})

it("allows access to context from inside functions when specified", () => {
const expr = bonsai()
function functionWithContext (this: { context: { name: string} } ) {
return `Hello, ${this.context.name}!`
}
expr.addFunction("greet", functionWithContext, { allowContextAccess: true });
expect(expr.evaluateSync("greet()", { name: "Dan" })).toBe("Hello, Dan!")
})

it("denies access to context from inside functions when specified", () => {
const expr = bonsai()
function functionWithContext (this: { context: { name: string} } ) {
return `Hello, ${this?.context?.name}!`
}
expr.addFunction("greet", functionWithContext, { allowContextAccess: false });
expect(expr.evaluateSync("greet()", { name: "Dan" })).toBe("Hello, undefined!")
})

it("denies access to context from inside functions by default", () => {
const expr = bonsai()
function functionWithContext (this: { context: {name:string} }) {
return `Hello, ${this?.context?.name}!`
}
expr.addFunction("greet", functionWithContext)
expect(expr.evaluateSync("greet()", { name: "Dan" })).toBe("Hello, undefined!")
})

it('should apply plugins via use()', () => {
const expr = bonsai()
expr.use((e) => {
Expand Down
7 changes: 6 additions & 1 deletion tests/evaluator-transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ function run(
) {
const ast = parse(expr)
const ec = new ExecutionContext(new SecurityPolicy())
return evaluate(ast, context, transforms, functions, ec)
const mappedFunctions = Object.fromEntries(
Object.entries(functions).map(([key, value]) => {
return [key, {allowCtx:false, f: value}];
})
)
return evaluate(ast, context, transforms, mappedFunctions, ec)
}

describe('evaluator - functions', () => {
Expand Down