Skip to content

Commit 2b17b23

Browse files
committed
feat(function): add local data conversion APIs
1 parent 6d42e7d commit 2b17b23

12 files changed

Lines changed: 371 additions & 49 deletions

File tree

apps/docs/content/docs/workflows/blocks/function.mdx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,15 +148,42 @@ workspace path instead, or keep the secret out of the output.
148148

149149
## Language
150150

151-
JavaScript without imports runs in a fast local sandbox. JavaScript with `import` or `require`, Python, and Shell run in the configured remote sandbox provider.
151+
JavaScript without imports, mounted files, or a selected sandbox runs in a fast local sandbox. JavaScript with `import` or `require`, Python, and Shell run in the configured remote sandbox provider.
152152

153153
| Feature | JavaScript | Python | Shell |
154154
| --- | --- | --- | --- |
155-
| **Execution** | Local when there are no imports; remote with imports | Always remote | Always remote |
155+
| **Execution** | Local by default; remote with imports, mounted files, or a selected sandbox | Always remote | Always remote |
156156
| **Return a value** | `return { … }` | Assign `__sim_result__ = { … }` | Print `__SIM_RESULT__={…}` |
157157
| **HTTP requests** | `fetch()` built in | `requests` or `httpx` | `curl` or an installed CLI |
158158
| **Best for** | quick transforms and JSON | scripts, data science, charts, complex math | CLI workflows and system utilities |
159159

160+
### Local JavaScript data APIs
161+
162+
`Buffer`, `atob`, `btoa`, `TextEncoder`, and `TextDecoder` are available without
163+
imports. For example, encode a JSON payload as UTF-8 base64:
164+
165+
```javascript
166+
const payload = { message: 'Hello 🌍' }
167+
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
168+
```
169+
170+
`Buffer` is a Uint8Array-backed implementation of common Node Buffer operations,
171+
including UTF-8, base64, hex, concatenation, and numeric reads and writes. It does
172+
not provide every API from the latest Node release; import `node:buffer` to use
173+
the remote runtime's native implementation. All local Buffer allocations,
174+
including `allocUnsafe`, are zero-filled and count toward the isolate's memory
175+
limit.
176+
177+
`TextEncoder` encodes UTF-8; `TextDecoder` supports encoding labels, streaming
178+
decoding, and fatal decoding errors. `atob` and `btoa` operate on binary strings,
179+
not Unicode text. Use `Buffer` for Unicode base64 conversion.
180+
181+
These data APIs do not add filesystem access, Node modules, timers, streams, or
182+
`FormData` to the local runtime. Import the required Node module or select a
183+
remote sandbox when those capabilities are needed.
184+
185+
### Remote runtimes
186+
160187
<Callout type="info">
161188
Python and Shell require a remote sandbox. They are enabled by default on sim.ai;
162189
on a self-hosted instance, build and configure the provider's dedicated
@@ -236,8 +263,8 @@ Then open the block's advanced options and choose the sandbox under **Sandbox**.
236263

237264
The default and custom behavior is intentionally explicit:
238265

239-
- **JavaScript without imports** stays in the local isolated runtime for speed and
240-
ignores the sandbox selection.
266+
- **JavaScript without imports** stays in the local isolated runtime for speed
267+
unless a sandbox is selected or files are mounted.
241268
- **JavaScript with `import` or `require`** runs remotely. With no selection it
242269
uses the Function base; with a sandbox it gets that sandbox's npm packages and
243270
system packages and managed CLI tools.

apps/sim/blocks/blocks/function.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const FunctionBlock: BlockConfig<CodeExecutionOutput> = {
1313
'This is a core workflow block. Execute custom JavaScript, Python, or Shell code within your workflow. JavaScript without imports runs locally for fast execution, while code with imports, Python, and Shell run in a remote sandbox.',
1414
bestPractices: `
1515
- JavaScript code without external imports runs in a local VM for fastest execution.
16+
- Local JavaScript includes Buffer, atob, btoa, TextEncoder, and TextDecoder for data conversion without imports. Use Buffer.from(text, 'utf8').toString('base64') for Unicode text; btoa only accepts binary strings.
1617
- JavaScript code with import/require statements runs in a remote sandbox.
1718
- Python code always runs in a remote sandbox.
1819
- Shell code runs CLI commands in a remote sandbox.
@@ -63,7 +64,7 @@ IMPORTANT FORMATTING RULES:
6364
1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. In JavaScript and Python, prefer the unquoted form when the placeholder is the complete expression (for example, 'const apiKey = {{SERVICE_API_KEY}};'). Quoted and embedded string forms such as '"Bearer {{SERVICE_API_KEY}}"', template literals, and JavaScript regex literals are also supported. In Shell, prefer '"{{SERVICE_API_KEY}}"' when the secret should be one scalar argument; use a bare placeholder only when Bash word-splitting or pattern semantics are intentional. Sim binds the resolved value separately from the source at execution time, preserving its exact string contents.
6465
2. Reference Input Parameters/Workflow Variables: Use the exact syntax <variable_name>. Do NOT wrap it in quotes (e.g., use 'userId = <userId>;' not 'userId = "<userId>";'). This includes parameters defined in the block's schema and outputs from previous blocks.
6566
3. Function Body ONLY: Do NOT include the function signature (e.g., 'async function myFunction() {' or the surrounding '}').
66-
4. Imports: Standard Node.js built-in modules (e.g., 'crypto', 'fs') are always available. Third-party packages are available ONLY when the block has a sandbox selected — the sandbox's package list is appended below when one is. Never import a package that is not on that list.
67+
4. Runtime APIs: Buffer, atob, btoa, TextEncoder, and TextDecoder are available without imports. Use Buffer.from(text, 'utf8').toString('base64') for Unicode text; btoa only accepts binary strings. Importing Node.js built-in modules (e.g., 'crypto', 'fs') runs the code in a remote sandbox. Third-party packages are available ONLY when the block has a sandbox selected — the sandbox's package list is appended below when one is. Never import a package that is not on that list.
6768
5. Output: Ensure the code returns a value if the function is expected to produce output. Use 'return'.
6869
6. Clarity: Write clean, readable code.
6970
7. No Explanations: Do NOT include markdown formatting, comments explaining the rules, or any text other than the raw JavaScript code for the function body.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Exercises the actual worker with SIM_HELPERS_SMOKE=1 and a compatible Node build.
5+
*/
6+
import { describe, expect, it } from 'vitest'
7+
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
8+
9+
function run(code: string, timeoutMs = 5000) {
10+
return executeInIsolatedVM({
11+
code,
12+
params: {},
13+
envVars: {},
14+
contextVariables: {},
15+
timeoutMs,
16+
requestId: 'function-globals-smoke',
17+
})
18+
}
19+
20+
describe.skipIf(process.env.SIM_HELPERS_SMOKE !== '1')('Function globals in a real isolate', () => {
21+
it('uses Buffer and text codecs without imports or a remote sandbox', async () => {
22+
const result = await run(`
23+
const payload = { text: 'Hello 🌍' }
24+
const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
25+
const decoded = new TextDecoder().decode(new TextEncoder().encode(payload.text))
26+
return { encoded, decoded, binary: atob(btoa('hello')), zero: Buffer.allocUnsafe(32).every(b => b === 0) }
27+
`)
28+
expect(result.error).toBeUndefined()
29+
expect(result.result).toEqual({
30+
encoded: Buffer.from(JSON.stringify({ text: 'Hello 🌍' }), 'utf8').toString('base64'),
31+
decoded: 'Hello 🌍',
32+
binary: 'hello',
33+
zero: true,
34+
})
35+
})
36+
37+
it('keeps prototypes and host capabilities isolated between executions', async () => {
38+
const first = await run(`
39+
Buffer.prototype.polluted = true
40+
TextEncoder.prototype.polluted = true
41+
return Buffer.from.constructor('return typeof process + ":" + typeof require')()
42+
`)
43+
expect(first.error).toBeUndefined()
44+
expect(first.result).toBe('undefined:undefined')
45+
const next = await run(
46+
'return [Buffer.prototype.polluted === undefined, TextEncoder.prototype.polluted === undefined]'
47+
)
48+
expect(next.error).toBeUndefined()
49+
expect(next.result).toEqual([true, true])
50+
})
51+
52+
it('preserves user-code error locations and catches invalid conversions', async () => {
53+
const result = await run("const data = Buffer.from('hello')\nthrow new Error('expected error')")
54+
expect(result.error).toMatchObject({
55+
line: 2,
56+
lineContent: "throw new Error('expected error')",
57+
})
58+
const caught = await run("try { btoa('🌍'); return false } catch { return true }")
59+
expect(caught.error).toBeUndefined()
60+
expect(caught.result).toBe(true)
61+
})
62+
63+
it('retains timeout enforcement and allows subsequent executions', async () => {
64+
const result = await run('while (true) { new TextEncoder().encode("hello") }', 100)
65+
expect(result.error).toBeDefined()
66+
expect(result.termination).toBe('timeout')
67+
const next = await run('return Buffer.from("ok").toString()')
68+
expect(next.error).toBeUndefined()
69+
expect(next.result).toBe('ok')
70+
})
71+
72+
it('charges Buffer allocations to the existing isolate memory limit', async () => {
73+
const result = await run(
74+
'const buffers = []; while (true) { buffers.push(Buffer.alloc(16 * 1024 * 1024)) }'
75+
)
76+
expect(result.error).toBeDefined()
77+
expect(result.error?.message).toMatch(
78+
/memory|disposed|cancelled|Array buffer allocation failed/i
79+
)
80+
const next = await run('return Buffer.alloc(4).length')
81+
expect(next.error).toBeUndefined()
82+
expect(next.result).toBe(4)
83+
})
84+
})

apps/sim/lib/execution/isolated-vm-worker.cjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const MAX_FETCH_OPTIONS_JSON_CHARS =
2020

2121
const SANDBOX_BUNDLE_DIR = path.join(__dirname, 'sandbox', 'bundles')
2222
const SANDBOX_BUNDLE_FILES = {
23+
'function-globals': 'function-globals.cjs',
2324
pptxgenjs: 'pptxgenjs.cjs',
2425
docx: 'docx.cjs',
2526
'pdf-lib': 'pdf-lib.cjs',
@@ -199,6 +200,7 @@ async function executeCode(request, executionId) {
199200

200201
let context = null
201202
let bootstrapScript = null
203+
let globalsScript = null
202204
let runtimeBindingsScript = null
203205
let userScript = null
204206
let logCallback = null
@@ -215,6 +217,10 @@ async function executeCode(request, executionId) {
215217

216218
await jail.set('global', jail.derefInto())
217219

220+
/** Evaluate pure JavaScript inside this isolate; never share host constructors. */
221+
globalsScript = await isolate.compileScript(getBundleSource('function-globals').source)
222+
await globalsScript.run(context, { timeout: timeoutMs })
223+
218224
logCallback = new ivm.Callback((...args) => {
219225
const message = args.map((arg) => stringifyLogValue(arg)).join(' ')
220226
appendStdout(`${message}\n`)
@@ -567,6 +573,7 @@ async function executeCode(request, executionId) {
567573
userScript,
568574
runtimeBindingsScript,
569575
bootstrapScript,
576+
globalsScript,
570577
...externalCopies,
571578
fetchCallback,
572579
brokerCallback,

apps/sim/lib/execution/sandbox/bundles/build.ts

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
#!/usr/bin/env bun
22
/**
3-
* Builds isolate-compatible bundles for the document-generation libraries.
3+
* Builds isolate-compatible bundles for Function globals and document libraries.
44
*
5-
* Each library is bundled with `target=browser, format=iife` so it can be
6-
* evaluated inside a V8 isolate that has no Node APIs (`require`, `process`,
7-
* `fs`). The emitted files attach their exports to `globalThis.__bundles[name]`
8-
* and are checked in so production images don't need the bundler at runtime.
5+
* Document libraries target browsers and register on `globalThis.__bundles`.
6+
* Function globals use neutral resolution so dependencies provide pure JavaScript
7+
* fallbacks instead of assuming native browser codecs. Both emit IIFEs checked
8+
* in so production images don't need the bundler at runtime.
99
*
1010
* Every bundle is evaluated in a bare context before it is written: the
1111
* bundler can emit a reference to a runtime helper it never defines (Bun does
@@ -19,7 +19,11 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
1919
import { dirname, join } from 'node:path'
2020
import { fileURLToPath } from 'node:url'
2121
import { createLogger } from '@sim/logger'
22-
import { evaluateSandboxBundle } from '@/lib/execution/sandbox/bundles/verify'
22+
import { build } from 'esbuild'
23+
import {
24+
evaluateFunctionGlobals,
25+
evaluateSandboxBundle,
26+
} from '@/lib/execution/sandbox/bundles/verify'
2327
import type { SandboxBundleName } from '@/lib/execution/sandbox/types'
2428

2529
const logger = createLogger('SandboxBundleBuild')
@@ -45,11 +49,11 @@ const ENTRIES_DIR = join(HERE, '.entries')
4549
const APP_SIM_ROOT = join(HERE, '..', '..', '..', '..')
4650

4751
interface BundleSpec {
48-
/** Key on `globalThis.__bundles`. */
49-
name: SandboxBundleName
52+
/** Bundle identity; document libraries register on `globalThis.__bundles`. */
53+
name: SandboxBundleName | 'function-globals'
5054
/** Short filename written under `bundles/<file>.cjs`. */
5155
outFile: string
52-
/** Source of the entry file bun will bundle. */
56+
/** Source of the entry file to bundle. */
5357
entry: string
5458
}
5559

@@ -65,6 +69,17 @@ if (typeof globalThis.process === 'undefined') globalThis.process = __processPol
6569
`
6670

6771
const BUNDLES: ReadonlyArray<BundleSpec> = [
72+
{
73+
name: 'function-globals',
74+
outFile: 'function-globals.cjs',
75+
entry: `
76+
import { Buffer } from 'buffer/'
77+
import { TextEncoder, TextDecoder } from '@exodus/bytes/encoding.js'
78+
import atob from 'core-js-pure/actual/atob'
79+
import btoa from 'core-js-pure/actual/btoa'
80+
Object.assign(globalThis, { Buffer, TextEncoder, TextDecoder, atob, btoa })
81+
`,
82+
},
6883
{
6984
name: 'pdf-lib',
7085
outFile: 'pdf-lib.cjs',
@@ -106,31 +121,52 @@ async function main(): Promise<void> {
106121
const entryPath = join(ENTRIES_DIR, `${spec.name}.entry.ts`)
107122
writeFileSync(entryPath, spec.entry, 'utf-8')
108123

109-
const result = await Bun.build({
110-
entrypoints: [entryPath],
111-
target: 'browser',
112-
format: 'iife',
113-
minify: true,
114-
sourcemap: 'none',
115-
root: APP_SIM_ROOT,
116-
})
117-
118-
if (!result.success) {
119-
for (const log of result.logs) {
120-
logger.error(String(log))
124+
let code: string
125+
if (spec.name === 'function-globals') {
126+
const result = await build({
127+
entryPoints: [entryPath],
128+
platform: 'neutral',
129+
mainFields: ['module', 'main'],
130+
format: 'iife',
131+
bundle: true,
132+
minify: true,
133+
write: false,
134+
legalComments: 'eof',
135+
})
136+
if (result.outputFiles.length !== 1) {
137+
throw new Error('Expected one Function globals bundle')
121138
}
122-
throw new Error(`Failed to build sandbox bundle: ${spec.name}`)
123-
}
139+
code = result.outputFiles[0].text
140+
} else {
141+
const result = await Bun.build({
142+
entrypoints: [entryPath],
143+
target: 'browser',
144+
format: 'iife',
145+
minify: true,
146+
sourcemap: 'none',
147+
root: APP_SIM_ROOT,
148+
})
124149

125-
if (result.outputs.length === 0) {
126-
throw new Error(`No output produced for sandbox bundle: ${spec.name}`)
150+
if (!result.success) {
151+
for (const log of result.logs) {
152+
logger.error(String(log))
153+
}
154+
throw new Error(`Failed to build sandbox bundle: ${spec.name}`)
155+
}
156+
if (result.outputs.length === 0) {
157+
throw new Error(`No output produced for sandbox bundle: ${spec.name}`)
158+
}
159+
code = await result.outputs[0].text()
127160
}
128161

129-
const code = await result.outputs[0].text()
130162
const banner = `// sandbox bundle: ${spec.name}\n// generated by apps/sim/lib/execution/sandbox/bundles/build.ts\n// do not edit by hand. run \`bun run build:sandbox-bundles\` to regenerate.\n`
131163
const output = banner + code
132164
try {
133-
evaluateSandboxBundle(output, spec.name)
165+
if (spec.name === 'function-globals') {
166+
evaluateFunctionGlobals(output)
167+
} else {
168+
evaluateSandboxBundle(output, spec.name)
169+
}
134170
} catch (error) {
135171
throw new Error(
136172
`Sandbox bundle ${spec.name} does not evaluate in a bare isolate context: ${String(error)}`

apps/sim/lib/execution/sandbox/bundles/function-globals.cjs

Lines changed: 17 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)