diff --git a/bun.lock b/bun.lock index 73d21f08..cc6f00b6 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/cre-sdk": { "name": "@chainlink/cre-sdk", - "version": "1.0.9", + "version": "1.1.2", "bin": { "cre-compile": "bin/cre-compile.ts", }, @@ -51,20 +51,17 @@ }, "packages/cre-sdk-examples": { "name": "@chainlink/cre-sdk-examples", - "version": "1.0.9", + "version": "1.1.2", "dependencies": { "@bufbuild/protobuf": "2.6.3", "@chainlink/cre-sdk": "workspace:*", "viem": "2.34.0", "zod": "3.25.76", }, - "devDependencies": { - "@types/bun": "1.3.8", - }, }, "packages/cre-sdk-javy-plugin": { "name": "@chainlink/cre-sdk-javy-plugin", - "version": "1.0.9", + "version": "1.1.1", "bin": { "cre-setup": "bin/setup.ts", "cre-compile-workflow": "bin/compile-workflow.ts", diff --git a/packages/cre-sdk-examples/package.json b/packages/cre-sdk-examples/package.json index e4626e05..fbf719f6 100644 --- a/packages/cre-sdk-examples/package.json +++ b/packages/cre-sdk-examples/package.json @@ -20,9 +20,7 @@ "viem": "2.34.0", "zod": "3.25.76" }, - "devDependencies": { - "@types/bun": "1.3.8" - }, + "devDependencies": {}, "engines": { "bun": ">=1.2.21" } diff --git a/packages/cre-sdk-examples/src/restricted-apis-example.ts b/packages/cre-sdk-examples/src/restricted-apis-example.ts new file mode 100644 index 00000000..dc823bfd --- /dev/null +++ b/packages/cre-sdk-examples/src/restricted-apis-example.ts @@ -0,0 +1,30 @@ +/** + * This example shows how CRE workflows mark restricted APIs as deprecated in TS. + * + * The restricted APIs covered in this example are: + * - fetch + * - setTimeout + * - setInterval + * + * Other unsupported globals/modules are enforced by cre-compile runtime checks. + * There are also NodeJS APIs that do work with the QuickJS runtime, like console.log. + */ + +export const testFetch = async () => { + // @ts-expect-error - fetch is not available in the CRE SDK + fetch('https://api.chain.link/v1/price?symbol=ETH/USD') +} + +export const testSetTimeout = async () => { + // @ts-expect-error - setTimeout is not available in the CRE SDK + setTimeout(() => { + console.log('Hello, world!') + }, 1000) +} + +export const testSetInterval = async () => { + // @ts-expect-error - setInterval is not available in the CRE SDK + setInterval(() => { + console.log('Hello, world!') + }, 1000) +} diff --git a/packages/cre-sdk-examples/src/restricted-node-modules-example.ts b/packages/cre-sdk-examples/src/restricted-node-modules-example.ts new file mode 100644 index 00000000..a97ac831 --- /dev/null +++ b/packages/cre-sdk-examples/src/restricted-node-modules-example.ts @@ -0,0 +1,132 @@ +/** + * This example shows how CRE workflows mark restricted Node.js modules as `never` in TS. + * + * CRE workflows run on QuickJS (via Javy/WASM), not full Node.js. + * All exports from restricted modules are typed as `never`, so any usage + * produces a clear TypeScript error at the call site. + * + * The restricted modules covered in this example are: + * - node:crypto + * - node:fs + * - node:fs/promises + * - node:net + * - node:http + * - node:https + * - node:child_process + * - node:os + * - node:stream + * - node:worker_threads + * - node:dns + * - node:zlib + * + * For HTTP requests, use cre.capabilities.HTTPClient instead of node:http/node:https/node:net. + * + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ + +import { exec } from 'node:child_process' +import { createHash, randomBytes } from 'node:crypto' +import { lookup } from 'node:dns' +import { readFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { request as httpRequest } from 'node:http' +import { request as httpsRequest } from 'node:https' +import { createConnection } from 'node:net' +import { cpus, hostname } from 'node:os' +import { Readable } from 'node:stream' +import { Worker } from 'node:worker_threads' +import { createGzip } from 'node:zlib' + +// --- node:crypto --- + +export const testCryptoRandomBytes = () => { + // @ts-expect-error - node:crypto is not available in CRE WASM workflows + randomBytes(32) +} + +export const testCryptoCreateHash = () => { + // @ts-expect-error - node:crypto is not available in CRE WASM workflows + createHash('sha256') +} + +// --- node:fs --- + +export const testFsReadFileSync = () => { + // @ts-expect-error - node:fs is not available in CRE WASM workflows + readFileSync('/etc/passwd', 'utf-8') +} + +// --- node:fs/promises --- + +export const testFsPromisesReadFile = async () => { + // @ts-expect-error - node:fs/promises is not available in CRE WASM workflows + await readFile('/etc/passwd', 'utf-8') +} + +// --- node:net --- + +export const testNetCreateConnection = () => { + // @ts-expect-error - node:net is not available in CRE WASM workflows + createConnection({ host: 'localhost', port: 8080 }) +} + +// --- node:http --- + +export const testHttpRequest = () => { + // @ts-expect-error - node:http is not available in CRE WASM workflows + httpRequest('http://example.com') +} + +// --- node:https --- + +export const testHttpsRequest = () => { + // @ts-expect-error - node:https is not available in CRE WASM workflows + httpsRequest('https://example.com') +} + +// --- node:child_process --- + +export const testChildProcessExec = () => { + // @ts-expect-error - node:child_process is not available in CRE WASM workflows + exec('ls -la') +} + +// --- node:os --- + +export const testOsHostname = () => { + // @ts-expect-error - node:os is not available in CRE WASM workflows + hostname() +} + +export const testOsCpus = () => { + // @ts-expect-error - node:os is not available in CRE WASM workflows + cpus() +} + +// --- node:stream --- + +export const testStreamReadable = () => { + // @ts-expect-error - node:stream is not available in CRE WASM workflows + new Readable() +} + +// --- node:worker_threads --- + +export const testWorkerThreads = () => { + // @ts-expect-error - node:worker_threads is not available in CRE WASM workflows + new Worker('./worker.js') +} + +// --- node:dns --- + +export const testDnsLookup = () => { + // @ts-expect-error - node:dns is not available in CRE WASM workflows + lookup('example.com', () => {}) +} + +// --- node:zlib --- + +export const testZlibCreateGzip = () => { + // @ts-expect-error - node:zlib is not available in CRE WASM workflows + createGzip() +} diff --git a/packages/cre-sdk-examples/src/workflows/http-fetch-no-sugar/index.ts b/packages/cre-sdk-examples/src/workflows/http-fetch-no-sugar/index.ts index 5579ce2e..293987ad 100644 --- a/packages/cre-sdk-examples/src/workflows/http-fetch-no-sugar/index.ts +++ b/packages/cre-sdk-examples/src/workflows/http-fetch-no-sugar/index.ts @@ -6,6 +6,7 @@ import { type NodeRuntime, Runner, type Runtime, + text, } from '@chainlink/cre-sdk' import { z } from 'zod' @@ -38,8 +39,8 @@ const fetchMathResult = (nodeRuntime: NodeRuntime): bigint => { const resp = httpClient.sendRequest(nodeRuntime, req).result() // The mathjs.org API returns the result as a raw string in the body. // We need to parse it into a bigint. - const bodyText = new TextDecoder().decode(resp.body) - const val = BigInt(bodyText.trim()) + const bodyText = text(resp) + const val = BigInt(bodyText) return val } diff --git a/packages/cre-sdk-examples/src/workflows/proof-of-reserve/index.ts b/packages/cre-sdk-examples/src/workflows/proof-of-reserve/index.ts index 1ac90d2c..6135bc2f 100644 --- a/packages/cre-sdk-examples/src/workflows/proof-of-reserve/index.ts +++ b/packages/cre-sdk-examples/src/workflows/proof-of-reserve/index.ts @@ -19,6 +19,7 @@ import { Runner, type Runtime, TxStatus, + text, } from '@chainlink/cre-sdk' import { type Address, decodeFunctionResult, encodeFunctionData, zeroAddress } from 'viem' import { z } from 'zod' @@ -66,7 +67,7 @@ const fetchReserveInfo = (sendRequester: HTTPSendRequester, config: Config): Res throw new Error(`HTTP request failed with status: ${response.statusCode}`) } - const responseText = Buffer.from(response.body).toString('utf-8') + const responseText = text(response) const porResp: PORResponse = JSON.parse(responseText) if (porResp.ripcord) { diff --git a/packages/cre-sdk-examples/tsconfig.json b/packages/cre-sdk-examples/tsconfig.json index d8cb82fa..4905e437 100644 --- a/packages/cre-sdk-examples/tsconfig.json +++ b/packages/cre-sdk-examples/tsconfig.json @@ -21,6 +21,9 @@ "skipLibCheck": true, "noFallthroughCasesInSwitch": true, + // Do not auto-include @types/* โ€” mirrors the customer cre cli initialized environment + "types": [], + // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, diff --git a/packages/cre-sdk/README.md b/packages/cre-sdk/README.md index 95ad9648..c4fb8771 100644 --- a/packages/cre-sdk/README.md +++ b/packages/cre-sdk/README.md @@ -8,6 +8,7 @@ The Chainlink Runtime Environment (CRE) SDK for TypeScript enables developers to - [Examples](#examples) - [Simulate locally with CRE CLI](#simulate-locally-with-cre-cli) - [Installation](#installation) +- [Runtime Compatibility Constraints](#runtime-compatibility-constraints) - [Core Concepts](#core-concepts) - [Workflows](#workflows) - [Runtime Modes](#runtime-modes) @@ -47,10 +48,20 @@ This package must be used along with the [CRE CLI tool](https://github.com/smart ## Prerequisites -1. the [bun runtime](https://bun.com/). The wasm compilation currently is only supported by the bun runtime which has near-complete NodeJS compatibility. +1. the [bun runtime](https://bun.com/) for local tooling and workflow compilation. 2. the [CRE CLI tool](https://github.com/smartcontractkit/cre-cli) installed. +## Runtime Compatibility Constraints + +CRE workflows are compiled to WASM and executed through Javy (QuickJS). This is **not** a full Node.js runtime. + +- Node built-ins like `node:fs`, `node:crypto`, `node:http`, `node:net`, etc. are not supported in workflows. +- Browser globals like `fetch`, `window`, and `setTimeout` are also not available in workflow runtime. +- `cre compile:workflow` / `cre-compile` now validates workflow source and fails fast when unsupported APIs are used. + +Use CRE capabilities (for example, `cre.capabilities.HTTPClient`) instead of direct Node/browser APIs. + ## Getting Started We recommend you consult the [getting started docs](https://docs.chain.link/cre/getting-started/cli-installation) and install the CRE CLI. diff --git a/packages/cre-sdk/bin/cre-compile.ts b/packages/cre-sdk/bin/cre-compile.ts index 5e23ca18..38a34a19 100755 --- a/packages/cre-sdk/bin/cre-compile.ts +++ b/packages/cre-sdk/bin/cre-compile.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { main as compileWorkflow } from "../scripts/src/compile-workflow"; +import { WorkflowRuntimeCompatibilityError } from "../scripts/src/validate-workflow-runtime-compat"; const main = async () => { const cliArgs = process.argv.slice(2); @@ -26,6 +27,10 @@ const main = async () => { // CLI entry point main().catch((e) => { - console.error(e); + if (e instanceof WorkflowRuntimeCompatibilityError) { + console.error(`\nโŒ ${e.message}`); + } else { + console.error(e); + } process.exit(1); }); diff --git a/packages/cre-sdk/scripts/run.ts b/packages/cre-sdk/scripts/run.ts index a4a1afd3..11c801a6 100644 --- a/packages/cre-sdk/scripts/run.ts +++ b/packages/cre-sdk/scripts/run.ts @@ -37,7 +37,11 @@ const main = async () => { process.exit(1) } } catch (error) { - console.error(`Failed to load script ${scriptName}:`, error) + if (error instanceof Error && error.name === 'WorkflowRuntimeCompatibilityError') { + console.error(error.message) + } else { + console.error(`Failed to run script ${scriptName}:`, error) + } process.exit(1) } } diff --git a/packages/cre-sdk/scripts/src/build-types.ts b/packages/cre-sdk/scripts/src/build-types.ts index 4172cefd..082d80e0 100644 --- a/packages/cre-sdk/scripts/src/build-types.ts +++ b/packages/cre-sdk/scripts/src/build-types.ts @@ -1,5 +1,5 @@ import { glob } from 'fast-glob' -import { copyFile, mkdir } from 'fs/promises' +import { copyFile, mkdir, readFile, writeFile } from 'fs/promises' import { join } from 'path' const buildTypes = async () => { @@ -28,6 +28,36 @@ const buildTypes = async () => { } console.log(`โœ… Copied ${typeFiles.length} type definition file(s) to dist/sdk/types`) + + // Prepend triple-slash references to dist/index.d.ts so consumers pick up + // global type augmentations (e.g. restricted-apis.d.ts) automatically. + // tsc strips these from the emitted .d.ts, so we add them back here. + const indexDts = join(packageRoot, 'dist/index.d.ts') + const sourceIndex = join(packageRoot, 'src/index.ts') + const sourceContent = await readFile(sourceIndex, 'utf-8') + + const refsFromSource = sourceContent + .split('\n') + .filter((line) => line.startsWith('/// '] + + const tripleSlashRefs = [...refsFromSource, ...consumerOnlyRefs].join('\n') + + if (tripleSlashRefs) { + const indexContent = await readFile(indexDts, 'utf-8') + // Strip any existing triple-slash references from the top of the file + // so that re-running build-types is idempotent. + const withoutExistingRefs = indexContent + .split('\n') + .filter((line) => !line.startsWith('/// { @@ -19,6 +20,7 @@ export const main = async (tsFilePath?: string, outputFilePath?: string) => { } const resolvedInput = path.resolve(inputPath) + assertWorkflowRuntimeCompatibility(resolvedInput) console.info(`๐Ÿ“ Using input file: ${resolvedInput}`) // If no explicit output path โ†’ same dir, swap extension to .js diff --git a/packages/cre-sdk/scripts/src/compile-workflow.ts b/packages/cre-sdk/scripts/src/compile-workflow.ts index ebe6775b..7e11eab9 100644 --- a/packages/cre-sdk/scripts/src/compile-workflow.ts +++ b/packages/cre-sdk/scripts/src/compile-workflow.ts @@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises' import path from 'node:path' import { main as compileToJs } from './compile-to-js' import { main as compileToWasm } from './compile-to-wasm' +import { WorkflowRuntimeCompatibilityError } from './validate-workflow-runtime-compat' export const main = async (inputFile?: string, outputWasmFile?: string) => { const cliArgs = process.argv.slice(3) @@ -41,9 +42,7 @@ export const main = async (inputFile?: string, outputWasmFile?: string) => { await mkdir(path.dirname(resolvedJsOutput), { recursive: true }) console.info(`๐Ÿš€ Compiling workflow`) - console.info(`๐Ÿ“ Input: ${resolvedInput}`) - console.info(`๐Ÿงช JS out: ${resolvedJsOutput}`) - console.info(`๐ŸŽฏ WASM out:${resolvedWasmOutput}\n`) + console.info(`๐Ÿ“ Input: ${resolvedInput}\n`) // Step 1: TS/JS โ†’ JS (bundled) console.info('๐Ÿ“ฆ Step 1: Compiling JS...') @@ -60,7 +59,11 @@ export const main = async (inputFile?: string, outputWasmFile?: string) => { // Optional: allow direct CLI usage if (import.meta.main) { main().catch((e) => { - console.error(e) + if (e instanceof WorkflowRuntimeCompatibilityError) { + console.error(`\nโŒ ${e.message}`) + } else { + console.error(e) + } process.exit(1) }) } diff --git a/packages/cre-sdk/scripts/src/validate-workflow-runtime-compat.test.ts b/packages/cre-sdk/scripts/src/validate-workflow-runtime-compat.test.ts new file mode 100644 index 00000000..b1cb92a7 --- /dev/null +++ b/packages/cre-sdk/scripts/src/validate-workflow-runtime-compat.test.ts @@ -0,0 +1,412 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { + assertWorkflowRuntimeCompatibility, + WorkflowRuntimeCompatibilityError, +} from './validate-workflow-runtime-compat' + +let tempDir: string + +beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'cre-validate-test-')) +}) + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) +}) + +/** Write a file in the temp directory and return its absolute path. */ +const writeTemp = (filename: string, content: string): string => { + const filePath = path.join(tempDir, filename) + const dir = path.dirname(filePath) + mkdirSync(dir, { recursive: true }) + writeFileSync(filePath, content, 'utf-8') + return filePath +} + +/** Assert that the validator throws with violations matching the given patterns. */ +const expectViolations = (entryPath: string, expectedPatterns: (string | RegExp)[]) => { + try { + assertWorkflowRuntimeCompatibility(entryPath) + throw new Error('Expected WorkflowRuntimeCompatibilityError but none was thrown') + } catch (error) { + expect(error).toBeInstanceOf(WorkflowRuntimeCompatibilityError) + const message = (error as Error).message + for (const pattern of expectedPatterns) { + if (typeof pattern === 'string') { + expect(message).toContain(pattern) + } else { + expect(message).toMatch(pattern) + } + } + } +} + +/** Assert that the validator does NOT throw. */ +const expectNoViolations = (entryPath: string) => { + expect(() => assertWorkflowRuntimeCompatibility(entryPath)).not.toThrow() +} + +// --------------------------------------------------------------------------- +// Pass 1: Module import analysis +// --------------------------------------------------------------------------- + +describe('module import analysis', () => { + test("detects import ... from 'node:fs'", () => { + const entry = writeTemp('workflow.ts', `import { readFileSync } from 'node:fs';\n`) + expectViolations(entry, ["'node:fs' is not available"]) + }) + + test("detects bare module specifier 'fs' (without node: prefix)", () => { + const entry = writeTemp('workflow.ts', `import { readFileSync } from 'fs';\n`) + expectViolations(entry, ["'fs' is not available"]) + }) + + test('detects export ... from restricted module', () => { + const entry = writeTemp('workflow.ts', `export { createHash } from 'node:crypto';\n`) + expectViolations(entry, ["'node:crypto' is not available"]) + }) + + test('detects import = require() syntax', () => { + const entry = writeTemp('workflow.ts', `import fs = require('node:fs');\n`) + expectViolations(entry, ["'node:fs' is not available"]) + }) + + test('detects require() call', () => { + const entry = writeTemp('workflow.js', `const fs = require('node:fs');\n`) + expectViolations(entry, ["'node:fs' is not available"]) + }) + + test('detects dynamic import()', () => { + const entry = writeTemp('workflow.ts', `const fs = await import('node:fs');\n`) + expectViolations(entry, ["'node:fs' is not available"]) + }) + + test('detects all restricted modules in a single file', () => { + const modules = [ + 'crypto', + 'node:crypto', + 'fs', + 'node:fs', + 'fs/promises', + 'node:fs/promises', + 'net', + 'node:net', + 'http', + 'node:http', + 'https', + 'node:https', + 'child_process', + 'node:child_process', + 'os', + 'node:os', + 'stream', + 'node:stream', + 'worker_threads', + 'node:worker_threads', + 'dns', + 'node:dns', + 'zlib', + 'node:zlib', + ] + + const imports = modules.map((mod, i) => `import m${i} from '${mod}';`).join('\n') + const entry = writeTemp('workflow.ts', `${imports}\n`) + expectViolations( + entry, + modules.map((mod) => `'${mod}' is not available`), + ) + }) + + test('does NOT flag allowed third-party modules', () => { + const entry = writeTemp( + 'workflow.ts', + `import { something } from '@chainlink/cre-sdk';\nimport lodash from 'lodash';\n`, + ) + expectNoViolations(entry) + }) + + test('does NOT flag relative imports themselves', () => { + const helper = writeTemp('helper.ts', `export const add = (a: number, b: number) => a + b;\n`) + const entry = writeTemp( + 'workflow.ts', + `import { add } from './helper';\nconsole.log(add(1, 2));\n`, + ) + expectNoViolations(entry) + }) + + test('follows relative imports transitively and detects violations in them', () => { + writeTemp('deep.ts', `import { readFileSync } from 'node:fs';\nexport const x = 1;\n`) + writeTemp('middle.ts', `import { x } from './deep';\nexport const y = x;\n`) + const entry = writeTemp('workflow.ts', `import { y } from './middle';\nconsole.log(y);\n`) + expectViolations(entry, ["'node:fs' is not available"]) + }) + + test('handles circular relative imports without infinite loop', () => { + writeTemp('a.ts', `import { b } from './b';\nexport const a = 'a';\n`) + writeTemp('b.ts', `import { a } from './a';\nexport const b = 'b';\n`) + const entry = writeTemp('workflow.ts', `import { a } from './a';\n`) + expectNoViolations(entry) + }) + + test('resolves imports without file extension', () => { + writeTemp('utils.ts', `import { cpus } from 'node:os';\nexport const x = 1;\n`) + const entry = writeTemp('workflow.ts', `import { x } from './utils';\nconsole.log(x);\n`) + expectViolations(entry, ["'node:os' is not available"]) + }) + + test('resolves index file imports', () => { + writeTemp('lib/index.ts', `import { hostname } from 'node:os';\nexport const name = 'test';\n`) + const entry = writeTemp('workflow.ts', `import { name } from './lib';\nconsole.log(name);\n`) + expectViolations(entry, ["'node:os' is not available"]) + }) + + test('reports multiple violations from multiple files', () => { + writeTemp('helper.ts', `import { exec } from 'node:child_process';\nexport const run = exec;\n`) + const entry = writeTemp( + 'workflow.ts', + `import { run } from './helper';\nimport { readFileSync } from 'node:fs';\n`, + ) + expectViolations(entry, ["'node:child_process' is not available", "'node:fs' is not available"]) + }) +}) + +// --------------------------------------------------------------------------- +// Pass 2: Global API analysis +// --------------------------------------------------------------------------- + +describe('global API analysis', () => { + test('detects bare fetch() usage', () => { + const entry = writeTemp('workflow.ts', `const res = fetch('https://example.com');\n`) + expectViolations(entry, ["'fetch' is not available"]) + }) + + test('detects setTimeout usage', () => { + const entry = writeTemp('workflow.ts', `setTimeout(() => {}, 1000);\n`) + expectViolations(entry, ["'setTimeout' is not available"]) + }) + + test('detects setInterval usage', () => { + const entry = writeTemp('workflow.ts', `setInterval(() => {}, 1000);\n`) + expectViolations(entry, ["'setInterval' is not available"]) + }) + + test('detects window reference', () => { + const entry = writeTemp('workflow.ts', `const w = window;\n`) + expectViolations(entry, ["'window' is not available"]) + }) + + test('detects document reference', () => { + const entry = writeTemp('workflow.ts', `const el = document.getElementById('app');\n`) + expectViolations(entry, ["'document' is not available"]) + }) + + test('detects XMLHttpRequest usage', () => { + const entry = writeTemp('workflow.ts', `const xhr = new XMLHttpRequest();\n`) + expectViolations(entry, ["'XMLHttpRequest' is not available"]) + }) + + test('detects localStorage usage', () => { + const entry = writeTemp('workflow.ts', `localStorage.setItem('key', 'value');\n`) + expectViolations(entry, ["'localStorage' is not available"]) + }) + + test('detects sessionStorage usage', () => { + const entry = writeTemp('workflow.ts', `sessionStorage.getItem('key');\n`) + expectViolations(entry, ["'sessionStorage' is not available"]) + }) + + test('detects globalThis.fetch access', () => { + const entry = writeTemp('workflow.ts', `const res = globalThis.fetch('https://example.com');\n`) + expectViolations(entry, ["'globalThis.fetch' is not available"]) + }) + + test('detects globalThis.setTimeout access', () => { + const entry = writeTemp('workflow.ts', `globalThis.setTimeout(() => {}, 100);\n`) + expectViolations(entry, ["'globalThis.setTimeout' is not available"]) + }) + + test('does NOT flag user-defined variable named fetch', () => { + const entry = writeTemp( + 'workflow.ts', + `export const fetch = (url: string) => url;\nconst result = fetch('test');\n`, + ) + expectNoViolations(entry) + }) + + test('does NOT flag user-defined function named fetch', () => { + const entry = writeTemp( + 'workflow.ts', + `export function fetch(url: string) { return url; }\nconst result = fetch('test');\n`, + ) + expectNoViolations(entry) + }) + + test('does NOT flag function parameter named fetch', () => { + const entry = writeTemp( + 'workflow.ts', + `export function doRequest(fetch: (url: string) => void) { fetch('test'); }\n`, + ) + expectNoViolations(entry) + }) + + test('does NOT flag property access obj.fetch', () => { + const entry = writeTemp('workflow.ts', `const obj = { fetch: () => {} };\nobj.fetch();\n`) + expectNoViolations(entry) + }) + + test('does NOT flag interface property named fetch', () => { + const entry = writeTemp('workflow.ts', `interface Client { fetch: (url: string) => void; }\n`) + expectNoViolations(entry) + }) + + test('does NOT flag destructured property named fetch from local object', () => { + const entry = writeTemp( + 'workflow.ts', + `const capabilities = { fetch: (url: string) => url };\nconst { fetch } = capabilities;\nexport const result = fetch('test');\n`, + ) + expectNoViolations(entry) + }) + + test('does NOT flag class method named fetch', () => { + const entry = writeTemp( + 'workflow.ts', + `class HttpClient {\n fetch(url: string) { return url; }\n}\nnew HttpClient().fetch('test');\n`, + ) + expectNoViolations(entry) + }) + + test('detects global APIs in transitively imported files', () => { + writeTemp('helper.ts', `export const doFetch = () => fetch('https://example.com');\n`) + const entry = writeTemp('workflow.ts', `import { doFetch } from './helper';\ndoFetch();\n`) + expectViolations(entry, ["'fetch' is not available"]) + }) +}) + +// --------------------------------------------------------------------------- +// Combined / integration tests +// --------------------------------------------------------------------------- + +describe('integration', () => { + test('clean workflow passes validation', () => { + const entry = writeTemp( + 'workflow.ts', + ` +import { Runner, cre } from '@chainlink/cre-sdk'; + +export async function main() { + const runner = await Runner.newRunner(); + console.log('Hello from CRE'); +} +`, + ) + expectNoViolations(entry) + }) + + test('detects both module and global API violations in same file', () => { + const entry = writeTemp( + 'workflow.ts', + ` +import { readFileSync } from 'node:fs'; +const data = readFileSync('/tmp/data.json', 'utf-8'); +const res = fetch('https://api.example.com'); +`, + ) + expectViolations(entry, ["'node:fs' is not available", "'fetch' is not available"]) + }) + + test('error message includes file path and line/column info', () => { + const entry = writeTemp('workflow.ts', `import { readFileSync } from 'node:fs';\n`) + try { + assertWorkflowRuntimeCompatibility(entry) + throw new Error('Expected error') + } catch (error) { + expect(error).toBeInstanceOf(WorkflowRuntimeCompatibilityError) + const msg = (error as Error).message + // Should contain relative or absolute path to the file + expect(msg).toContain('workflow.ts') + // Should contain line:column format + expect(msg).toMatch(/:\d+:\d+/) + } + }) + + test('error message includes docs link', () => { + const entry = writeTemp('workflow.ts', `import { readFileSync } from 'node:fs';\n`) + try { + assertWorkflowRuntimeCompatibility(entry) + throw new Error('Expected error') + } catch (error) { + const msg = (error as Error).message + expect(msg).toContain('https://docs.chain.link/cre/concepts/typescript-wasm-runtime') + } + }) + + test('handles .js files', () => { + const entry = writeTemp('workflow.js', `const fs = require('node:fs');\n`) + expectViolations(entry, ["'node:fs' is not available"]) + }) + + test('handles .mjs files', () => { + const entry = writeTemp('workflow.mjs', `import { readFileSync } from 'node:fs';\n`) + expectViolations(entry, ["'node:fs' is not available"]) + }) + + test('handles .cjs files', () => { + const entry = writeTemp('workflow.cjs', `const fs = require('node:fs');\n`) + expectViolations(entry, ["'node:fs' is not available"]) + }) + + test('violations are sorted by file path, then line, then column', () => { + writeTemp( + 'b-helper.ts', + `import { exec } from 'node:child_process';\nexport const run = exec;\n`, + ) + const entry = writeTemp( + 'a-workflow.ts', + `import { run } from './b-helper';\nimport { readFileSync } from 'node:fs';\nimport { cpus } from 'node:os';\n`, + ) + try { + assertWorkflowRuntimeCompatibility(entry) + throw new Error('Expected error') + } catch (error) { + const msg = (error as Error).message + const violationLines = msg.split('\n').filter((line) => line.startsWith('- ')) + + // Should have 3 violations minimum + expect(violationLines.length).toBeGreaterThanOrEqual(3) + + // Extract file paths from violation lines + const filePaths = violationLines.map((line) => line.split(':')[0].replace('- ', '')) + + // Verify sorted order: a-workflow.ts violations before b-helper.ts + const aIndexes = filePaths + .map((f, i) => (f.includes('a-workflow') ? i : -1)) + .filter((i) => i >= 0) + const bIndexes = filePaths + .map((f, i) => (f.includes('b-helper') ? i : -1)) + .filter((i) => i >= 0) + + if (aIndexes.length > 0 && bIndexes.length > 0) { + expect(Math.max(...aIndexes)).toBeLessThan(Math.min(...bIndexes)) + } + } + }) + + test('empty file passes validation', () => { + const entry = writeTemp('workflow.ts', '') + expectNoViolations(entry) + }) + + test('file with only comments passes validation', () => { + const entry = writeTemp('workflow.ts', `// This is a comment\n/* Block comment */\n`) + expectNoViolations(entry) + }) + + test('non-existent entry file does not throw', () => { + const nonExistent = path.join(tempDir, 'does-not-exist.ts') + // Should not throw since the file doesn't exist - it just won't find violations + expectNoViolations(nonExistent) + }) +}) diff --git a/packages/cre-sdk/scripts/src/validate-workflow-runtime-compat.ts b/packages/cre-sdk/scripts/src/validate-workflow-runtime-compat.ts new file mode 100644 index 00000000..9f2bee32 --- /dev/null +++ b/packages/cre-sdk/scripts/src/validate-workflow-runtime-compat.ts @@ -0,0 +1,573 @@ +/** + * Workflow Runtime Compatibility Validator + * + * CRE (Compute Runtime Environment) workflows are compiled from TypeScript to + * WebAssembly and executed inside a Javy/QuickJS sandbox โ€” NOT a full Node.js + * runtime. This means many APIs that developers take for granted (filesystem, + * network sockets, crypto, child processes, etc.) are simply not available at + * runtime and will silently fail or crash. + * + * This module performs **static analysis** on workflow source code to catch + * these issues at build time, before the workflow is compiled to WASM. It + * operates in two passes: + * + * 1. **Module import analysis** โ€” walks the AST of every reachable source file + * (starting from the workflow entry point) and flags imports from restricted + * Node.js built-in modules (e.g. `node:fs`, `node:crypto`, `node:http`). + * This catches `import`, `export ... from`, `require()`, and dynamic + * `import()` syntax. + * + * 2. **Global API analysis** โ€” uses the TypeScript type-checker to detect + * references to browser/Node globals that don't exist in QuickJS (e.g. + * `fetch`, `setTimeout`, `window`, `document`). Only flags identifiers + * that resolve to non-local declarations, so user-defined variables with + * the same name (e.g. `const fetch = cre.capabilities.HTTPClient`) are + * not flagged. + * + * The validator follows relative imports transitively so that violations in + * helper files reachable from the entry point are also caught. + * + * ## How it's used + * + * This validator runs automatically as part of the `cre-compile` build pipeline: + * + * ``` + * cre-compile [path/to/output.wasm] + * ``` + * + * The pipeline is: `cre-compile` (CLI) -> `compile-workflow` -> `compile-to-js` + * -> **`assertWorkflowRuntimeCompatibility()`** -> bundle -> compile to WASM. + * + * The validation happens before any bundling or WASM compilation, so developers + * get fast, actionable error messages pointing to exact file:line:column + * locations instead of cryptic WASM runtime failures. + * + * ## Layers of protection + * + * This validator is one of two complementary mechanisms that prevent usage of + * unavailable APIs: + * + * 1. **Compile-time types** (`restricted-apis.d.ts` and `restricted-node-modules.d.ts`) + * โ€” mark restricted APIs as `never` so the TypeScript compiler flags them + * with red squiggles in the IDE. This gives instant feedback while coding. + * + * 2. **Build-time validation** (this module) โ€” performs AST-level static + * analysis during `cre-compile`. This catches cases that type-level + * restrictions can't cover, such as `require()` calls, dynamic `import()`, + * and usage inside plain `.js` files that don't go through `tsc`. + * + * @example + * ```ts + * import { assertWorkflowRuntimeCompatibility } from './validate-workflow-runtime-compat' + * + * // Throws WorkflowRuntimeCompatibilityError if violations are found + * assertWorkflowRuntimeCompatibility('./src/workflow.ts') + * ``` + * + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ + +import { existsSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import * as ts from 'typescript' + +/** + * A single detected violation: a location in the source code where a + * restricted API is referenced. + */ +type Violation = { + /** Absolute path to the file containing the violation. */ + filePath: string + /** 1-based line number. */ + line: number + /** 1-based column number. */ + column: number + /** Human-readable description of the violation. */ + message: string +} + +/** + * Node.js built-in module specifiers that are not available in the QuickJS + * runtime. Both bare (`fs`) and prefixed (`node:fs`) forms are included + * because TypeScript/bundlers accept either. + */ +const restrictedModuleSpecifiers = new Set([ + 'crypto', + 'node:crypto', + 'fs', + 'node:fs', + 'fs/promises', + 'node:fs/promises', + 'net', + 'node:net', + 'http', + 'node:http', + 'https', + 'node:https', + 'child_process', + 'node:child_process', + 'os', + 'node:os', + 'stream', + 'node:stream', + 'worker_threads', + 'node:worker_threads', + 'dns', + 'node:dns', + 'zlib', + 'node:zlib', +]) + +/** + * Global identifiers (browser and Node.js) that do not exist in the QuickJS + * runtime. For network requests, workflows should use `cre.capabilities.HTTPClient`; + * for scheduling, `cre.capabilities.CronCapability`. + */ +const restrictedGlobalApis = new Set([ + 'fetch', + 'window', + 'document', + 'XMLHttpRequest', + 'localStorage', + 'sessionStorage', + 'setTimeout', + 'setInterval', +]) + +/** File extensions treated as scannable source code. */ +const sourceExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'] + +/** + * Error thrown when one or more runtime-incompatible API usages are detected. + * The message includes a docs link and a formatted list of every violation + * with file path, line, column, and description. + */ +class WorkflowRuntimeCompatibilityError extends Error { + constructor(violations: Violation[]) { + const sortedViolations = [...violations].sort((a, b) => { + if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath) + if (a.line !== b.line) return a.line - b.line + return a.column - b.column + }) + + const formattedViolations = sortedViolations + .map((violation) => { + const relativePath = path.relative(process.cwd(), violation.filePath) + return `- ${relativePath}:${violation.line}:${violation.column} ${violation.message}` + }) + .join('\n') + + super( + `Unsupported API usage found in workflow source. +CRE workflows run on Javy (QuickJS), not full Node.js. +Use CRE capabilities instead (for example, HTTPClient instead of fetch/node:http). +See https://docs.chain.link/cre/concepts/typescript-wasm-runtime + +${formattedViolations}`, + ) + this.name = 'WorkflowRuntimeCompatibilityError' + } +} + +/** Resolves a file path to an absolute path using the current working directory. */ +const toAbsolutePath = (filePath: string) => path.resolve(filePath) + +/** + * Maps a file extension to the appropriate TypeScript {@link ts.ScriptKind} + * so the parser handles JSX, CommonJS, and ESM files correctly. + */ +const getScriptKind = (filePath: string): ts.ScriptKind => { + switch (path.extname(filePath).toLowerCase()) { + case '.js': + return ts.ScriptKind.JS + case '.jsx': + return ts.ScriptKind.JSX + case '.mjs': + return ts.ScriptKind.JS + case '.cjs': + return ts.ScriptKind.JS + case '.tsx': + return ts.ScriptKind.TSX + case '.mts': + return ts.ScriptKind.TS + case '.cts': + return ts.ScriptKind.TS + default: + return ts.ScriptKind.TS + } +} + +/** + * Creates a {@link Violation} with 1-based line and column numbers derived + * from a character position in the source file. + */ +const createViolation = ( + filePath: string, + pos: number, + sourceFile: ts.SourceFile, + message: string, +): Violation => { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(pos) + return { + filePath: toAbsolutePath(filePath), + line: line + 1, + column: character + 1, + message, + } +} + +/** Returns `true` if the specifier looks like a relative or absolute file path. */ +const isRelativeImport = (specifier: string) => { + return specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/') +} + +/** + * Attempts to resolve a relative import specifier to an absolute file path. + * Tries the path as-is first, then appends each known source extension, then + * looks for an index file inside the directory. Returns `null` if nothing is + * found on disk. + */ +const resolveRelativeImport = (fromFilePath: string, specifier: string): string | null => { + const basePath = specifier.startsWith('/') + ? path.resolve(specifier) + : path.resolve(path.dirname(fromFilePath), specifier) + + if (existsSync(basePath) && statSync(basePath).isFile()) { + return toAbsolutePath(basePath) + } + + for (const extension of sourceExtensions) { + const withExtension = `${basePath}${extension}` + if (existsSync(withExtension)) { + return toAbsolutePath(withExtension) + } + } + + for (const extension of sourceExtensions) { + const asIndex = path.join(basePath, `index${extension}`) + if (existsSync(asIndex)) { + return toAbsolutePath(asIndex) + } + } + + return null +} + +/** + * Extracts a string literal from the first argument of a call expression. + * Used for `require('node:fs')` and `import('node:fs')` patterns. + * Returns `null` if the first argument is not a static string literal. + */ +const getStringLiteralFromCall = (node: ts.CallExpression): string | null => { + const [firstArg] = node.arguments + if (!firstArg || !ts.isStringLiteral(firstArg)) return null + return firstArg.text +} + +/** + * **Pass 1 โ€” Module import analysis.** + * + * Walks the AST of a single source file and: + * - Flags any import/export/require/dynamic-import of a restricted module. + * - Enqueues relative imports for recursive scanning so the validator + * transitively covers the entire local dependency graph. + * + * Handles all module import syntaxes: + * - `import ... from 'node:fs'` + * - `export ... from 'node:fs'` + * - `import fs = require('node:fs')` + * - `require('node:fs')` + * - `import('node:fs')` + */ +const collectModuleUsage = ( + sourceFile: ts.SourceFile, + filePath: string, + violations: Violation[], + enqueueFile: (nextFile: string) => void, +) => { + const checkModuleSpecifier = (specifier: string, pos: number) => { + if (restrictedModuleSpecifiers.has(specifier)) { + violations.push( + createViolation( + filePath, + pos, + sourceFile, + `'${specifier}' is not available in CRE workflow runtime.`, + ), + ) + } + + if (!isRelativeImport(specifier)) return + const resolved = resolveRelativeImport(filePath, specifier) + if (resolved) { + enqueueFile(resolved) + } + } + + const visit = (node: ts.Node) => { + // import ... from 'specifier' + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + checkModuleSpecifier(node.moduleSpecifier.text, node.moduleSpecifier.getStart(sourceFile)) + } + + // export ... from 'specifier' + if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + checkModuleSpecifier(node.moduleSpecifier.text, node.moduleSpecifier.getStart(sourceFile)) + } + + // import fs = require('specifier') + if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) && + node.moduleReference.expression && + ts.isStringLiteral(node.moduleReference.expression) + ) { + checkModuleSpecifier( + node.moduleReference.expression.text, + node.moduleReference.expression.getStart(sourceFile), + ) + } + + if (ts.isCallExpression(node)) { + // require('specifier') + if (ts.isIdentifier(node.expression) && node.expression.text === 'require') { + const requiredModule = getStringLiteralFromCall(node) + if (requiredModule) { + checkModuleSpecifier(requiredModule, node.arguments[0].getStart(sourceFile)) + } + } + + // import('specifier') + if (node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const importedModule = getStringLiteralFromCall(node) + if (importedModule) { + checkModuleSpecifier(importedModule, node.arguments[0].getStart(sourceFile)) + } + } + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) +} + +/** + * Checks whether an identifier AST node is the **name being declared** (as + * opposed to a reference/usage). For example, in `const fetch = ...` the + * `fetch` token is a declaration name, while in `fetch(url)` it is a usage. + * + * This distinction is critical so that user-defined variables that shadow + * restricted global names are not flagged as violations. + */ +const isDeclarationName = (identifier: ts.Identifier): boolean => { + const parent = identifier.parent + + // Variable, function, class, interface, type alias, enum, module, + // type parameter, parameter, binding element, import names, enum member, + // property/method declarations, property assignments, and labels. + if ( + (ts.isFunctionDeclaration(parent) && parent.name === identifier) || + (ts.isFunctionExpression(parent) && parent.name === identifier) || + (ts.isClassDeclaration(parent) && parent.name === identifier) || + (ts.isClassExpression(parent) && parent.name === identifier) || + (ts.isInterfaceDeclaration(parent) && parent.name === identifier) || + (ts.isTypeAliasDeclaration(parent) && parent.name === identifier) || + (ts.isEnumDeclaration(parent) && parent.name === identifier) || + (ts.isModuleDeclaration(parent) && parent.name === identifier) || + (ts.isTypeParameterDeclaration(parent) && parent.name === identifier) || + (ts.isVariableDeclaration(parent) && parent.name === identifier) || + (ts.isParameter(parent) && parent.name === identifier) || + (ts.isBindingElement(parent) && parent.name === identifier) || + (ts.isImportClause(parent) && parent.name === identifier) || + (ts.isImportSpecifier(parent) && parent.name === identifier) || + (ts.isNamespaceImport(parent) && parent.name === identifier) || + (ts.isImportEqualsDeclaration(parent) && parent.name === identifier) || + (ts.isNamespaceExport(parent) && parent.name === identifier) || + (ts.isEnumMember(parent) && parent.name === identifier) || + (ts.isPropertyDeclaration(parent) && parent.name === identifier) || + (ts.isPropertySignature(parent) && parent.name === identifier) || + (ts.isMethodDeclaration(parent) && parent.name === identifier) || + (ts.isMethodSignature(parent) && parent.name === identifier) || + (ts.isGetAccessorDeclaration(parent) && parent.name === identifier) || + (ts.isSetAccessorDeclaration(parent) && parent.name === identifier) || + (ts.isPropertyAssignment(parent) && parent.name === identifier) || + (ts.isShorthandPropertyAssignment(parent) && parent.name === identifier) || + (ts.isLabeledStatement(parent) && parent.label === identifier) + ) { + return true + } + + // Property access (obj.fetch), qualified names (Ns.fetch), and type + // references (SomeType) โ€” the right-hand identifier is not a standalone + // usage of the global name. + if ( + (ts.isPropertyAccessExpression(parent) && parent.name === identifier) || + (ts.isQualifiedName(parent) && parent.right === identifier) || + (ts.isTypeReferenceNode(parent) && parent.typeName === identifier) + ) { + return true + } + + return false +} + +/** + * **Pass 2 โ€” Global API analysis.** + * + * Uses the TypeScript type-checker to find references to restricted global + * identifiers (e.g. `fetch`, `setTimeout`, `window`). An identifier is only + * flagged if: + * - It matches a name in {@link restrictedGlobalApis}. + * - It is **not** a declaration name (see {@link isDeclarationName}). + * - Its symbol resolves to a declaration outside the local source files, + * meaning it comes from the global scope rather than user code. + * + * This also catches `globalThis.fetch`-style access patterns. + */ +const collectGlobalApiUsage = ( + program: ts.Program, + localSourceFiles: Set, + violations: Violation[], +) => { + const checker = program.getTypeChecker() + + for (const sourceFile of program.getSourceFiles()) { + const resolvedSourcePath = toAbsolutePath(sourceFile.fileName) + if (!localSourceFiles.has(resolvedSourcePath)) continue + + const visit = (node: ts.Node) => { + // Direct usage: fetch(...), setTimeout(...) + if ( + ts.isIdentifier(node) && + restrictedGlobalApis.has(node.text) && + !isDeclarationName(node) + ) { + const symbol = checker.getSymbolAtLocation(node) + const hasLocalDeclaration = + symbol?.declarations?.some((declaration) => + localSourceFiles.has(toAbsolutePath(declaration.getSourceFile().fileName)), + ) ?? false + + if (!hasLocalDeclaration) { + violations.push( + createViolation( + resolvedSourcePath, + node.getStart(sourceFile), + sourceFile, + `'${node.text}' is not available in CRE workflow runtime.`, + ), + ) + } + } + + // Property access on globalThis: globalThis.fetch(...) + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'globalThis' && + restrictedGlobalApis.has(node.name.text) + ) { + violations.push( + createViolation( + resolvedSourcePath, + node.name.getStart(sourceFile), + sourceFile, + `'globalThis.${node.name.text}' is not available in CRE workflow runtime.`, + ), + ) + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + } +} + +/** + * Validates that a workflow entry file (and all local files it transitively + * imports) only uses APIs available in the CRE QuickJS/WASM runtime. + * + * The check runs in two passes: + * + * 1. **Module import scan** โ€” starting from `entryFilePath`, recursively + * parses every reachable local source file and flags imports from + * restricted Node.js built-in modules. + * + * 2. **Global API scan** โ€” creates a TypeScript program from the collected + * source files and uses the type-checker to flag references to restricted + * global identifiers that resolve to non-local (i.e. global) declarations. + * + * @param entryFilePath - Path to the workflow entry file (absolute or relative). + * @throws {WorkflowRuntimeCompatibilityError} If any violations are found. + * The error message includes a link to the CRE runtime docs and a formatted + * list of every violation with file:line:column and description. + * + * @example + * ```ts + * // During the cre-compile build step: + * assertWorkflowRuntimeCompatibility('./src/workflow.ts') + * // Throws if the workflow (or any file it imports) uses fetch, node:fs, etc. + * ``` + * + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +export const assertWorkflowRuntimeCompatibility = (entryFilePath: string) => { + const rootFile = toAbsolutePath(entryFilePath) + const filesToScan = [rootFile] + const scannedFiles = new Set() + const localSourceFiles = new Set() + const violations: Violation[] = [] + + // Pass 1: Walk the local import graph and collect module-level violations. + while (filesToScan.length > 0) { + const currentFile = filesToScan.pop() + if (!currentFile || scannedFiles.has(currentFile)) continue + scannedFiles.add(currentFile) + + if (!existsSync(currentFile)) continue + localSourceFiles.add(currentFile) + + const fileContents = readFileSync(currentFile, 'utf-8') + const sourceFile = ts.createSourceFile( + currentFile, + fileContents, + ts.ScriptTarget.Latest, + true, + getScriptKind(currentFile), + ) + + collectModuleUsage(sourceFile, currentFile, violations, (nextFile) => { + if (!scannedFiles.has(nextFile)) { + filesToScan.push(nextFile) + } + }) + } + + // Pass 2: Use the type-checker to detect restricted global API usage. + const program = ts.createProgram({ + rootNames: [...localSourceFiles], + options: { + allowJs: true, + checkJs: true, + noEmit: true, + skipLibCheck: true, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + }, + }) + + collectGlobalApiUsage(program, localSourceFiles, violations) + + if (violations.length > 0) { + throw new WorkflowRuntimeCompatibilityError(violations) + } +} + +export { WorkflowRuntimeCompatibilityError } diff --git a/packages/cre-sdk/src/sdk/types/global.d.ts b/packages/cre-sdk/src/sdk/types/global.d.ts index e8036a46..a93c895c 100644 --- a/packages/cre-sdk/src/sdk/types/global.d.ts +++ b/packages/cre-sdk/src/sdk/types/global.d.ts @@ -1,5 +1,8 @@ -// Global type declarations for the CRE SDK runtime -// Those are the methods that the Host exposes to the Guest. +// Global type declarations for the CRE SDK runtime. +// These are the methods and globals exposed by the Host to the Guest. + +type ExistingGlobal = + typeof globalThis extends Record ? T : Fallback /** * Host functions exposed by the CRE runtime to WASM guests @@ -78,6 +81,106 @@ declare global { * @returns Unix timestamp in milliseconds */ function now(): number + + /** + * Console API available in the QuickJS runtime + */ + type CreConsole = { + log(...args: unknown[]): void + warn(...args: unknown[]): void + error(...args: unknown[]): void + info(...args: unknown[]): void + debug(...args: unknown[]): void + } + var console: ExistingGlobal<'console', CreConsole> + + /** + * TextEncoder/TextDecoder APIs available via Javy's text_encoding support + */ + interface CreTextEncoderEncodeIntoResult { + read: number + written: number + } + + interface CreTextEncoder { + readonly encoding: string + encode(input?: string): Uint8Array + encodeInto(input: string, dest: Uint8Array): CreTextEncoderEncodeIntoResult + } + var TextEncoder: ExistingGlobal< + 'TextEncoder', + { prototype: CreTextEncoder; new (): CreTextEncoder } + > + + interface CreTextDecoder { + readonly encoding: string + readonly fatal: boolean + readonly ignoreBOM: boolean + decode(input?: ArrayBuffer | ArrayBufferView, options?: { stream?: boolean }): string + } + var TextDecoder: ExistingGlobal< + 'TextDecoder', + { + prototype: CreTextDecoder + new (label?: string, options?: { fatal?: boolean; ignoreBOM?: boolean }): CreTextDecoder + } + > + + /** + * Base64 encoding/decoding โ€” exposed via prepareRuntime() from node:buffer + */ + function atob(encodedData: string): string + function btoa(stringToEncode: string): string + + /** + * URL and URLSearchParams โ€” exposed via prepareRuntime() from node:url + */ + interface CreURLSearchParams { + append(name: string, value: string): void + delete(name: string): void + get(name: string): string | null + getAll(name: string): string[] + has(name: string): boolean + set(name: string, value: string): void + sort(): void + toString(): string + forEach(callback: (value: string, key: string, parent: CreURLSearchParams) => void): void + entries(): IterableIterator<[string, string]> + keys(): IterableIterator + values(): IterableIterator + [Symbol.iterator](): IterableIterator<[string, string]> + readonly size: number + } + var URLSearchParams: ExistingGlobal< + 'URLSearchParams', + { + prototype: CreURLSearchParams + new ( + init?: string | Record | [string, string][] | CreURLSearchParams, + ): CreURLSearchParams + } + > + + interface CreURL { + hash: string + host: string + hostname: string + href: string + readonly origin: string + password: string + pathname: string + port: string + protocol: string + search: string + readonly searchParams: CreURLSearchParams + username: string + toString(): string + toJSON(): string + } + var URL: ExistingGlobal< + 'URL', + { prototype: CreURL; new (url: string, base?: string | CreURL): CreURL } + > } export {} diff --git a/packages/cre-sdk/src/sdk/types/restricted-apis.d.ts b/packages/cre-sdk/src/sdk/types/restricted-apis.d.ts index 91196ec7..434d3e01 100644 --- a/packages/cre-sdk/src/sdk/types/restricted-apis.d.ts +++ b/packages/cre-sdk/src/sdk/types/restricted-apis.d.ts @@ -1,27 +1,22 @@ declare global { - /** @deprecated fetch is not available in CRE WASM workflows. Use cre.capabilities.HTTPClient instead. */ - const fetch: never - - /** @deprecated window is not available in CRE WASM workflows. */ - const window: never - - /** @deprecated document is not available in CRE WASM workflows. */ - const document: never - - /** @deprecated XMLHttpRequest is not available in CRE WASM workflows. Use cre.capabilities.HTTPClient instead. */ - const XMLHttpRequest: never - - /** @deprecated localStorage is not available in CRE WASM workflows. */ - const localStorage: never - - /** @deprecated sessionStorage is not available in CRE WASM workflows. */ - const sessionStorage: never - - /** @deprecated setTimeout is not available in CRE WASM workflows. Use cre.capabilities.CronCapability for scheduling. */ - const setTimeout: never - - /** @deprecated setInterval is not available in CRE WASM workflows. Use cre.capabilities.CronCapability for scheduling. */ - const setInterval: never + /** + * @deprecated fetch is not available in CRE WASM workflows. + * Use cre.capabilities.HTTPClient instead. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ + function fetch(_notAvailable: never): never + + /** + * @deprecated setTimeout is not available in CRE WASM workflows. Use cre.capabilities.CronCapability for scheduling. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ + function setTimeout(_notAvailable: never, ..._args: never[]): never + + /** + * @deprecated setInterval is not available in CRE WASM workflows. Use cre.capabilities.CronCapability for scheduling. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ + function setInterval(_notAvailable: never, ..._args: never[]): never } export {} diff --git a/packages/cre-sdk/src/sdk/types/restricted-node-modules.d.ts b/packages/cre-sdk/src/sdk/types/restricted-node-modules.d.ts new file mode 100644 index 00000000..83538a4d --- /dev/null +++ b/packages/cre-sdk/src/sdk/types/restricted-node-modules.d.ts @@ -0,0 +1,405 @@ +// Restricted Node.js modules that are not available in CRE WASM workflows. +// These modules require native bindings or system access that cannot run in WebAssembly. +// Importing from these modules is allowed by TypeScript, but all exports are typed as +// `never` so any usage produces a clear error at the call site. +// See https://docs.chain.link/cre/concepts/typescript-wasm-runtime + +/** + * @deprecated node:crypto is not available in CRE WASM workflows. It requires native bindings that cannot run in WebAssembly. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:crypto' { + export const randomBytes: never + export const randomUUID: never + export const randomInt: never + export const randomFillSync: never + export const randomFill: never + export const createHash: never + export const createHmac: never + export const createCipheriv: never + export const createDecipheriv: never + export const createSign: never + export const createVerify: never + export const createDiffieHellman: never + export const createDiffieHellmanGroup: never + export const createECDH: never + export const generateKey: never + export const generateKeySync: never + export const generateKeyPair: never + export const generateKeyPairSync: never + export const createPrivateKey: never + export const createPublicKey: never + export const createSecretKey: never + export const pbkdf2: never + export const pbkdf2Sync: never + export const scrypt: never + export const scryptSync: never + export const timingSafeEqual: never + export const publicEncrypt: never + export const publicDecrypt: never + export const privateDecrypt: never + export const privateEncrypt: never + export const getCiphers: never + export const getHashes: never + export const getCurves: never + export const getFips: never + export const setFips: never + export const getRandomValues: never + export const Hash: never + export const Hmac: never + export const Sign: never + export const Verify: never + export const KeyObject: never + export const Certificate: never + export const ECDH: never + export const DiffieHellman: never + export const DiffieHellmanGroup: never + export const Cipheriv: never + export const Decipheriv: never + export const webcrypto: never + export const subtle: never + export const crypto: never + export const fips: never + export const constants: never +} + +/** + * @deprecated node:fs is not available in CRE WASM workflows. It requires filesystem access that is not available in WebAssembly. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:fs' { + export const readFile: never + export const readFileSync: never + export const writeFile: never + export const writeFileSync: never + export const appendFile: never + export const appendFileSync: never + export const readdir: never + export const readdirSync: never + export const mkdir: never + export const mkdirSync: never + export const mkdtemp: never + export const mkdtempSync: never + export const rm: never + export const rmSync: never + export const rmdir: never + export const rmdirSync: never + export const unlink: never + export const unlinkSync: never + export const stat: never + export const statSync: never + export const lstat: never + export const lstatSync: never + export const fstat: never + export const fstatSync: never + export const statfs: never + export const statfsSync: never + export const exists: never + export const existsSync: never + export const copyFile: never + export const copyFileSync: never + export const cp: never + export const cpSync: never + export const rename: never + export const renameSync: never + export const readlink: never + export const readlinkSync: never + export const symlink: never + export const symlinkSync: never + export const link: never + export const linkSync: never + export const open: never + export const openSync: never + export const close: never + export const closeSync: never + export const read: never + export const readSync: never + export const write: never + export const writeSync: never + export const truncate: never + export const truncateSync: never + export const ftruncate: never + export const ftruncateSync: never + export const chmod: never + export const chmodSync: never + export const chown: never + export const chownSync: never + export const utimes: never + export const utimesSync: never + export const access: never + export const accessSync: never + export const createReadStream: never + export const createWriteStream: never + export const watch: never + export const watchFile: never + export const unwatchFile: never + export const realpath: never + export const realpathSync: never + export const promises: never + export const constants: never + export const Dir: never + export const Dirent: never + export const Stats: never + export const ReadStream: never + export const WriteStream: never + export const FileHandle: never + export const FSWatcher: never +} + +/** + * @deprecated node:fs/promises is not available in CRE WASM workflows. It requires filesystem access that is not available in WebAssembly. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:fs/promises' { + export const readFile: never + export const writeFile: never + export const appendFile: never + export const readdir: never + export const mkdir: never + export const mkdtemp: never + export const rm: never + export const rmdir: never + export const unlink: never + export const stat: never + export const lstat: never + export const statfs: never + export const copyFile: never + export const cp: never + export const rename: never + export const readlink: never + export const symlink: never + export const link: never + export const open: never + export const truncate: never + export const chmod: never + export const chown: never + export const utimes: never + export const access: never + export const realpath: never + export const watch: never + export const constants: never + export const FileHandle: never +} + +/** + * @deprecated node:net is not available in CRE WASM workflows. It requires network access. Use cre.capabilities.HTTPClient instead. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:net' { + export const createServer: never + export const createConnection: never + export const connect: never + export const isIP: never + export const isIPv4: never + export const isIPv6: never + export const getDefaultAutoSelectFamily: never + export const setDefaultAutoSelectFamily: never + export const Socket: never + export const Server: never + export const BlockList: never + export const SocketAddress: never +} + +/** + * @deprecated node:http is not available in CRE WASM workflows. It requires network access. Use cre.capabilities.HTTPClient instead. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:http' { + export const createServer: never + export const request: never + export const get: never + export const validateHeaderName: never + export const validateHeaderValue: never + export const setMaxIdleHTTPParsers: never + export const Server: never + export const ClientRequest: never + export const IncomingMessage: never + export const ServerResponse: never + export const OutgoingMessage: never + export const Agent: never + export const globalAgent: never + export const METHODS: never + export const STATUS_CODES: never + export const maxHeaderSize: never +} + +/** + * @deprecated node:https is not available in CRE WASM workflows. It requires network access. Use cre.capabilities.HTTPClient instead. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:https' { + export const createServer: never + export const request: never + export const get: never + export const Server: never + export const Agent: never + export const globalAgent: never +} + +/** + * @deprecated node:child_process is not available in CRE WASM workflows. It requires OS process spawning that is not available in WebAssembly. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:child_process' { + export const spawn: never + export const spawnSync: never + export const exec: never + export const execSync: never + export const execFile: never + export const execFileSync: never + export const fork: never + export const ChildProcess: never +} + +/** + * @deprecated node:os is not available in CRE WASM workflows. It requires OS access that is not available in WebAssembly. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:os' { + export const hostname: never + export const platform: never + export const arch: never + export const type: never + export const release: never + export const version: never + export const machine: never + export const cpus: never + export const availableParallelism: never + export const freemem: never + export const totalmem: never + export const uptime: never + export const loadavg: never + export const homedir: never + export const tmpdir: never + export const userInfo: never + export const networkInterfaces: never + export const endianness: never + export const getPriority: never + export const setPriority: never + export const EOL: never + export const devNull: never + export const constants: never +} + +/** + * @deprecated node:stream is not available in CRE WASM workflows. It requires native bindings that cannot run in WebAssembly. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:stream' { + export const Readable: never + export const Writable: never + export const Duplex: never + export const Transform: never + export const PassThrough: never + export const Stream: never + export const pipeline: never + export const finished: never + export const promises: never + export const addAbortSignal: never + export const compose: never + export const isErrored: never + export const isReadable: never +} + +/** + * @deprecated node:worker_threads is not available in CRE WASM workflows. It requires threading support that is not available in WebAssembly. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:worker_threads' { + export const Worker: never + export const MessageChannel: never + export const MessagePort: never + export const BroadcastChannel: never + export const isMainThread: never + export const isInternalThread: never + export const parentPort: never + export const workerData: never + export const threadId: never + export const resourceLimits: never + export const SHARE_ENV: never + export const receiveMessageOnPort: never + export const moveMessagePortToContext: never + export const getEnvironmentData: never + export const setEnvironmentData: never + export const markAsUntransferable: never + export const markAsUncloneable: never + export const isMarkedAsUntransferable: never + export const postMessageToThread: never +} + +/** + * @deprecated node:dns is not available in CRE WASM workflows. It requires network access that is not available in WebAssembly. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:dns' { + export const lookup: never + export const lookupService: never + export const resolve: never + export const resolve4: never + export const resolve6: never + export const resolveCname: never + export const resolveMx: never + export const resolveNs: never + export const resolvePtr: never + export const resolveSrv: never + export const resolveTxt: never + export const resolveNaptr: never + export const resolveSoa: never + export const resolveAny: never + export const reverse: never + export const setServers: never + export const getServers: never + export const setDefaultResultOrder: never + export const getDefaultResultOrder: never + export const promises: never + export const Resolver: never +} + +/** + * @deprecated node:zlib is not available in CRE WASM workflows. It requires native compression bindings that cannot run in WebAssembly. + * @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime + */ +declare module 'node:zlib' { + export const createGzip: never + export const createGunzip: never + export const createDeflate: never + export const createInflate: never + export const createDeflateRaw: never + export const createInflateRaw: never + export const createUnzip: never + export const createBrotliCompress: never + export const createBrotliDecompress: never + export const createZstdCompress: never + export const createZstdDecompress: never + export const gzip: never + export const gzipSync: never + export const gunzip: never + export const gunzipSync: never + export const deflate: never + export const deflateSync: never + export const inflate: never + export const inflateSync: never + export const deflateRaw: never + export const deflateRawSync: never + export const inflateRaw: never + export const inflateRawSync: never + export const unzip: never + export const unzipSync: never + export const brotliCompress: never + export const brotliCompressSync: never + export const brotliDecompress: never + export const brotliDecompressSync: never + export const crc32: never + export const constants: never + export const Gzip: never + export const Gunzip: never + export const Deflate: never + export const Inflate: never + export const DeflateRaw: never + export const InflateRaw: never + export const Unzip: never + export const BrotliCompress: never + export const BrotliDecompress: never + export const ZstdCompress: never + export const ZstdDecompress: never +} diff --git a/packages/cre-sdk/src/sdk/utils/prepare-runtime.ts b/packages/cre-sdk/src/sdk/utils/prepare-runtime.ts index dbb8faba..f48841fb 100644 --- a/packages/cre-sdk/src/sdk/utils/prepare-runtime.ts +++ b/packages/cre-sdk/src/sdk/utils/prepare-runtime.ts @@ -1,10 +1,17 @@ -import { Buffer } from 'node:buffer' +import { atob, Buffer, btoa } from 'node:buffer' +import { URL, URLSearchParams } from 'node:url' /** * This function is used to prepare the runtime for the SDK to work. * It should be called as a part of SDK initialization. - * It exposes NodeJS Buffer in global namespace, so it can be bundled and used in workflow code. + * It exposes Node.js APIs in global namespace, so they can be bundled and used in workflow code. */ export const prepareRuntime = () => { - globalThis.Buffer = Buffer as any + globalThis.Buffer = Buffer + globalThis.atob = atob + globalThis.btoa = btoa + // node:url constructor types are slightly narrower than lib.dom/global types. + // Runtime behavior is compatible; cast to the global constructor shapes. + globalThis.URL = URL as unknown as typeof globalThis.URL + globalThis.URLSearchParams = URLSearchParams as unknown as typeof globalThis.URLSearchParams } diff --git a/packages/cre-sdk/tsconfig.json b/packages/cre-sdk/tsconfig.json index 1a5841ff..49fc05c0 100644 --- a/packages/cre-sdk/tsconfig.json +++ b/packages/cre-sdk/tsconfig.json @@ -34,5 +34,10 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true }, - "include": ["src/**/*", "scripts/**/*.ts", "bin/**/*.ts"] + "include": ["src/**/*", "scripts/**/*.ts", "bin/**/*.ts"], + // restricted-node-modules.d.ts is excluded from the SDK's own compilation + // because the SDK's build scripts legitimately use Node.js modules. It is + // injected into dist/index.d.ts by build-types.ts so workflow consumers + // still receive the restrictions. + "exclude": ["src/sdk/types/restricted-node-modules.d.ts"] }