diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dac9c75c..e8e10fea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,3 +138,9 @@ jobs: env: CRE_API_KEY: ${{ secrets.CRE_CLI_API_KEY }} run: ./scripts/e2e/simulate-log-trigger.sh + + # Rust extension examples: build cre-rust-inject-alpha, compile workflows, simulate. + - name: E2E - Simulate rust-inject workflows + env: + CRE_API_KEY: ${{ secrets.CRE_CLI_API_KEY }} + run: ./scripts/e2e/simulate-rust-inject.sh diff --git a/bun.lock b/bun.lock index 15147f60..31f64b70 100644 --- a/bun.lock +++ b/bun.lock @@ -24,9 +24,46 @@ "@types/bun": "1.3.8", }, }, + "packages/cre-rust-inject-alpha": { + "name": "@chainlink/cre-rust-inject-alpha", + "version": "0.1.0", + "peerDependencies": { + "@chainlink/cre-sdk-javy-plugin": ">=1.0.0", + "zod": ">=3.0.0", + }, + }, + "packages/cre-rust-inject-beta": { + "name": "@chainlink/cre-rust-inject-beta", + "version": "0.1.0", + "peerDependencies": { + "@chainlink/cre-sdk-javy-plugin": ">=1.0.0", + "zod": ">=3.0.0", + }, + }, + "packages/cre-rust-prebuilt-plugin-example": { + "name": "@chainlink/cre-rust-prebuilt-plugin-example", + "version": "0.0.0", + "dependencies": { + "@chainlink/cre-rust-inject-alpha": "workspace:*", + "@chainlink/cre-sdk": "workspace:*", + "@chainlink/cre-sdk-javy-plugin": "workspace:*", + "zod": "3.25.76", + }, + }, + "packages/cre-rust-source-extensions-example": { + "name": "@chainlink/cre-rust-source-extensions-example", + "version": "0.0.0", + "dependencies": { + "@chainlink/cre-rust-inject-alpha": "workspace:*", + "@chainlink/cre-rust-inject-beta": "workspace:*", + "@chainlink/cre-sdk": "workspace:*", + "@chainlink/cre-sdk-javy-plugin": "workspace:*", + "zod": "3.25.76", + }, + }, "packages/cre-sdk": { "name": "@chainlink/cre-sdk", - "version": "1.3.0", + "version": "1.4.0", "bin": { "cre-compile": "bin/cre-compile.ts", }, @@ -51,7 +88,7 @@ }, "packages/cre-sdk-examples": { "name": "@chainlink/cre-sdk-examples", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@bufbuild/protobuf": "2.6.3", "@chainlink/cre-sdk": "workspace:*", @@ -66,6 +103,12 @@ "cre-setup": "bin/setup.ts", "cre-compile-workflow": "bin/compile-workflow.ts", }, + "peerDependencies": { + "zod": ">=3.0.0", + }, + "optionalPeers": [ + "zod", + ], }, }, "packages": { @@ -113,6 +156,14 @@ "@chainlink/cre-http-trigger": ["@chainlink/cre-http-trigger@workspace:packages/cre-http-trigger"], + "@chainlink/cre-rust-inject-alpha": ["@chainlink/cre-rust-inject-alpha@workspace:packages/cre-rust-inject-alpha"], + + "@chainlink/cre-rust-inject-beta": ["@chainlink/cre-rust-inject-beta@workspace:packages/cre-rust-inject-beta"], + + "@chainlink/cre-rust-prebuilt-plugin-example": ["@chainlink/cre-rust-prebuilt-plugin-example@workspace:packages/cre-rust-prebuilt-plugin-example"], + + "@chainlink/cre-rust-source-extensions-example": ["@chainlink/cre-rust-source-extensions-example@workspace:packages/cre-rust-source-extensions-example"], + "@chainlink/cre-sdk": ["@chainlink/cre-sdk@workspace:packages/cre-sdk"], "@chainlink/cre-sdk-examples": ["@chainlink/cre-sdk-examples@workspace:packages/cre-sdk-examples"], @@ -141,9 +192,9 @@ "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], - "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - "@typescript/vfs": ["@typescript/vfs@1.6.2", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g=="], + "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], "abitype": ["abitype@1.1.0", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A=="], @@ -227,7 +278,7 @@ "ox": ["ox@0.9.6", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.9", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-8SuCbHPvv2eZLYXrNmC0EC12rdzXQLdhnOMlHDW2wiCPLxBrOOJwX5L5E61by+UjTPOryqQiRSnjIKCI+GykKg=="], - "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -261,7 +312,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], diff --git a/packages/cre-rust-inject-alpha/.gitignore b/packages/cre-rust-inject-alpha/.gitignore new file mode 100644 index 00000000..bc41b29e --- /dev/null +++ b/packages/cre-rust-inject-alpha/.gitignore @@ -0,0 +1,3 @@ +dist/ +target/ +Cargo.lock diff --git a/packages/cre-rust-inject-alpha/Cargo.toml b/packages/cre-rust-inject-alpha/Cargo.toml new file mode 100644 index 00000000..655b5536 --- /dev/null +++ b/packages/cre-rust-inject-alpha/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "alpha" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["lib"] + +[dependencies] +cre_wasm_exports = { path = "../cre-sdk-javy-plugin/src/cre_wasm_exports" } +javy-plugin-api = "6.0.0" diff --git a/packages/cre-rust-inject-alpha/Makefile b/packages/cre-rust-inject-alpha/Makefile new file mode 100644 index 00000000..86aa9f25 --- /dev/null +++ b/packages/cre-rust-inject-alpha/Makefile @@ -0,0 +1,15 @@ +# Build alpha.plugin.wasm using the workspace @chainlink/cre-sdk-javy-plugin. +JAVY_PLUGIN := $(abspath ../cre-sdk-javy-plugin) + +.PHONY: build clean + +build: dist/alpha.plugin.wasm + +dist/alpha.plugin.wasm: Cargo.toml src/lib.rs + mkdir -p dist + CRE_SDK_JAVY_PLUGIN_HOME="$(JAVY_PLUGIN)" bun "$(JAVY_PLUGIN)/scripts/build-plugin.ts" \ + --cre-exports . \ + -o ./dist/alpha.plugin.wasm + +clean: + rm -rf dist diff --git a/packages/cre-rust-inject-alpha/biome.json b/packages/cre-rust-inject-alpha/biome.json new file mode 100644 index 00000000..ab256239 --- /dev/null +++ b/packages/cre-rust-inject-alpha/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": ["**/*.ts", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineWidth": 100 + }, + "assist": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedVariables": "error" + }, + "suspicious": { + "noExplicitAny": "warn" + }, + "style": { + "useConst": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + } +} diff --git a/packages/cre-rust-inject-alpha/index.ts b/packages/cre-rust-inject-alpha/index.ts new file mode 100644 index 00000000..bc5287e8 --- /dev/null +++ b/packages/cre-rust-inject-alpha/index.ts @@ -0,0 +1,15 @@ +import { createExtensionAccessor } from '@chainlink/cre-sdk-javy-plugin/runtime/validate-extension' +import { z } from 'zod' + +const rustAlphaSchema = z.object({ + greet: z.function().args().returns(z.string()), +}) + +export type RustAlpha = z.infer + +declare global { + var rustAlpha: RustAlpha +} + +// biome-ignore lint/suspicious/noRedeclare: global augmentation declares rustAlpha; this export is the validated accessor +export const rustAlpha = createExtensionAccessor('rustAlpha', rustAlphaSchema) diff --git a/packages/cre-rust-inject-alpha/package.json b/packages/cre-rust-inject-alpha/package.json new file mode 100644 index 00000000..19dd92ce --- /dev/null +++ b/packages/cre-rust-inject-alpha/package.json @@ -0,0 +1,24 @@ +{ + "name": "@chainlink/cre-rust-inject-alpha", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Example Rust extension (alpha) for CRE rust-inject demos — packaged plugin wasm + crate source.", + "scripts": { + "check": "biome check --write ${BIOME_PATHS:-.}", + "check:ci": "biome ci .", + "typecheck": "tsc" + }, + "files": [ + "dist", + "src", + "Cargo.toml", + "index.ts" + ], + "keywords": [], + "license": "BUSL-1.1", + "peerDependencies": { + "@chainlink/cre-sdk-javy-plugin": ">=1.0.0", + "zod": ">=3.0.0" + } +} diff --git a/packages/cre-rust-inject-alpha/src/lib.rs b/packages/cre-rust-inject-alpha/src/lib.rs new file mode 100644 index 00000000..8c9da405 --- /dev/null +++ b/packages/cre-rust-inject-alpha/src/lib.rs @@ -0,0 +1,13 @@ +use cre_wasm_exports::extend_wasm_exports; +use javy_plugin_api::javy::quickjs::prelude::*; +use javy_plugin_api::javy::quickjs::{Ctx, Object}; + +pub fn register(ctx: &Ctx<'_>) { + let obj = Object::new(ctx.clone()).unwrap(); + obj.set( + "greet", + Func::from(|| -> String { "Hello from alpha".to_string() }), + ) + .unwrap(); + extend_wasm_exports(ctx, "rustAlpha", obj); +} diff --git a/packages/cre-rust-inject-alpha/tsconfig.json b/packages/cre-rust-inject-alpha/tsconfig.json new file mode 100644 index 00000000..77e43b44 --- /dev/null +++ b/packages/cre-rust-inject-alpha/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["index.ts"] +} diff --git a/packages/cre-rust-inject-beta/.gitignore b/packages/cre-rust-inject-beta/.gitignore new file mode 100644 index 00000000..2c96eb1b --- /dev/null +++ b/packages/cre-rust-inject-beta/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/packages/cre-rust-inject-beta/Cargo.toml b/packages/cre-rust-inject-beta/Cargo.toml new file mode 100644 index 00000000..338ae9e1 --- /dev/null +++ b/packages/cre-rust-inject-beta/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lib_beta" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["lib"] + +[dependencies] +cre_wasm_exports = { path = "../cre-sdk-javy-plugin/src/cre_wasm_exports" } +javy-plugin-api = "6.0.0" diff --git a/packages/cre-rust-inject-beta/biome.json b/packages/cre-rust-inject-beta/biome.json new file mode 100644 index 00000000..ab256239 --- /dev/null +++ b/packages/cre-rust-inject-beta/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": ["**/*.ts", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineWidth": 100 + }, + "assist": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedVariables": "error" + }, + "suspicious": { + "noExplicitAny": "warn" + }, + "style": { + "useConst": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + } +} diff --git a/packages/cre-rust-inject-beta/index.ts b/packages/cre-rust-inject-beta/index.ts new file mode 100644 index 00000000..4fe57efc --- /dev/null +++ b/packages/cre-rust-inject-beta/index.ts @@ -0,0 +1,15 @@ +import { createExtensionAccessor } from '@chainlink/cre-sdk-javy-plugin/runtime/validate-extension' +import { z } from 'zod' + +const rustBetaSchema = z.object({ + greet: z.function().args().returns(z.string()), +}) + +export type RustBeta = z.infer + +declare global { + var rustBeta: RustBeta +} + +// biome-ignore lint/suspicious/noRedeclare: global augmentation declares rustBeta; this export is the validated accessor +export const rustBeta = createExtensionAccessor('rustBeta', rustBetaSchema) diff --git a/packages/cre-rust-inject-beta/package.json b/packages/cre-rust-inject-beta/package.json new file mode 100644 index 00000000..ccc547b4 --- /dev/null +++ b/packages/cre-rust-inject-beta/package.json @@ -0,0 +1,23 @@ +{ + "name": "@chainlink/cre-rust-inject-beta", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Example Rust extension (beta) for CRE rust-inject demos — crate source.", + "scripts": { + "check": "biome check --write ${BIOME_PATHS:-.}", + "check:ci": "biome ci .", + "typecheck": "tsc" + }, + "files": [ + "src", + "Cargo.toml", + "index.ts" + ], + "keywords": [], + "license": "BUSL-1.1", + "peerDependencies": { + "@chainlink/cre-sdk-javy-plugin": ">=1.0.0", + "zod": ">=3.0.0" + } +} diff --git a/packages/cre-rust-inject-beta/src/lib.rs b/packages/cre-rust-inject-beta/src/lib.rs new file mode 100644 index 00000000..47747fa6 --- /dev/null +++ b/packages/cre-rust-inject-beta/src/lib.rs @@ -0,0 +1,13 @@ +use cre_wasm_exports::extend_wasm_exports; +use javy_plugin_api::javy::quickjs::prelude::*; +use javy_plugin_api::javy::quickjs::{Ctx, Object}; + +pub fn register(ctx: &Ctx<'_>) { + let obj = Object::new(ctx.clone()).unwrap(); + obj.set( + "greet", + Func::from(|| -> String { "Hello from beta".to_string() }), + ) + .unwrap(); + extend_wasm_exports(ctx, "rustBeta", obj); +} diff --git a/packages/cre-rust-inject-beta/tsconfig.json b/packages/cre-rust-inject-beta/tsconfig.json new file mode 100644 index 00000000..77e43b44 --- /dev/null +++ b/packages/cre-rust-inject-beta/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["index.ts"] +} diff --git a/packages/cre-rust-prebuilt-plugin-example/.gitignore b/packages/cre-rust-prebuilt-plugin-example/.gitignore new file mode 100644 index 00000000..d3a7eb3c --- /dev/null +++ b/packages/cre-rust-prebuilt-plugin-example/.gitignore @@ -0,0 +1,2 @@ +wasm/ +.env diff --git a/packages/cre-rust-prebuilt-plugin-example/Makefile b/packages/cre-rust-prebuilt-plugin-example/Makefile new file mode 100644 index 00000000..2052b95f --- /dev/null +++ b/packages/cre-rust-prebuilt-plugin-example/Makefile @@ -0,0 +1,16 @@ +# Uses pre-built alpha.plugin.wasm from @chainlink/cre-rust-inject-alpha via --plugin. +JAVY_PLUGIN := $(abspath ../cre-sdk-javy-plugin) +ALPHA_PKG := $(abspath ../cre-rust-inject-alpha) + +.PHONY: build clean + +build: + mkdir -p wasm + @test -f "$(ALPHA_PKG)/dist/alpha.plugin.wasm" || (echo "❌ cre-rust-inject-alpha not built — run 'make build' in ../cre-rust-inject-alpha first" && exit 1) + CRE_SDK_JAVY_PLUGIN_HOME="$(JAVY_PLUGIN)" bun cre-compile \ + --plugin $(ALPHA_PKG)/dist/alpha.plugin.wasm \ + ./index.ts \ + ./wasm/workflow.wasm + +clean: + rm -rf wasm diff --git a/packages/cre-rust-prebuilt-plugin-example/biome.json b/packages/cre-rust-prebuilt-plugin-example/biome.json new file mode 100644 index 00000000..ab256239 --- /dev/null +++ b/packages/cre-rust-prebuilt-plugin-example/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": ["**/*.ts", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineWidth": 100 + }, + "assist": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedVariables": "error" + }, + "suspicious": { + "noExplicitAny": "warn" + }, + "style": { + "useConst": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + } +} diff --git a/packages/cre-rust-prebuilt-plugin-example/config.json b/packages/cre-rust-prebuilt-plugin-example/config.json new file mode 100644 index 00000000..4281b2b0 --- /dev/null +++ b/packages/cre-rust-prebuilt-plugin-example/config.json @@ -0,0 +1 @@ +{ "schedule": "*/30 * * * * *" } diff --git a/packages/cre-rust-prebuilt-plugin-example/index.ts b/packages/cre-rust-prebuilt-plugin-example/index.ts new file mode 100644 index 00000000..c9d69b0b --- /dev/null +++ b/packages/cre-rust-prebuilt-plugin-example/index.ts @@ -0,0 +1,24 @@ +import { rustAlpha } from '@chainlink/cre-rust-inject-alpha' +import { CronCapability, handler, Runner, type Runtime } from '@chainlink/cre-sdk' +import { z } from 'zod' + +const configSchema = z.object({ + schedule: z.string(), +}) + +type Config = z.infer + +const onCronTrigger = (_runtime: Runtime) => { + const alpha = rustAlpha().greet() + return JSON.stringify({ alpha }) +} + +const initWorkflow = (config: Config) => { + const cron = new CronCapability() + return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)] +} + +export async function main() { + const runner = await Runner.newRunner({ configSchema }) + await runner.run(initWorkflow) +} diff --git a/packages/cre-rust-prebuilt-plugin-example/package.json b/packages/cre-rust-prebuilt-plugin-example/package.json new file mode 100644 index 00000000..f9b43a67 --- /dev/null +++ b/packages/cre-rust-prebuilt-plugin-example/package.json @@ -0,0 +1,18 @@ +{ + "name": "@chainlink/cre-rust-prebuilt-plugin-example", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Example: installs @chainlink/cre-rust-inject-alpha and uses its pre-built Javy plugin via --plugin.", + "scripts": { + "check": "biome check --write ${BIOME_PATHS:-.}", + "check:ci": "biome ci .", + "typecheck": "tsc" + }, + "dependencies": { + "@chainlink/cre-sdk": "workspace:*", + "@chainlink/cre-sdk-javy-plugin": "workspace:*", + "@chainlink/cre-rust-inject-alpha": "workspace:*", + "zod": "3.25.76" + } +} diff --git a/packages/cre-rust-prebuilt-plugin-example/project.yaml b/packages/cre-rust-prebuilt-plugin-example/project.yaml new file mode 100644 index 00000000..7752bff3 --- /dev/null +++ b/packages/cre-rust-prebuilt-plugin-example/project.yaml @@ -0,0 +1,5 @@ +# CRE project settings — only local-simulation is needed for this example. +local-simulation: + rpcs: + - chain-name: ethereum-testnet-sepolia + url: https://por.bcy-p.metalhosts.com/cre-alpha/MvqtrdftrbxcP3ZgGBJb3bK5/ethereum/sepolia diff --git a/packages/cre-rust-prebuilt-plugin-example/tsconfig.json b/packages/cre-rust-prebuilt-plugin-example/tsconfig.json new file mode 100644 index 00000000..77e43b44 --- /dev/null +++ b/packages/cre-rust-prebuilt-plugin-example/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["index.ts"] +} diff --git a/packages/cre-rust-prebuilt-plugin-example/workflow.yaml b/packages/cre-rust-prebuilt-plugin-example/workflow.yaml new file mode 100644 index 00000000..f89c9eb7 --- /dev/null +++ b/packages/cre-rust-prebuilt-plugin-example/workflow.yaml @@ -0,0 +1,8 @@ +# Example 2: prebuilt-plugin — uses --plugin pointing to pre-built .plugin.wasm (lib_alpha) +local-simulation: + user-workflow: + workflow-owner-address: "(optional)" + workflow-name: "rust-inject-prebuilt-plugin" + workflow-artifacts: + workflow-path: "./wasm/workflow.wasm" + config-path: "./config.json" diff --git a/packages/cre-rust-source-extensions-example/.gitignore b/packages/cre-rust-source-extensions-example/.gitignore new file mode 100644 index 00000000..d3a7eb3c --- /dev/null +++ b/packages/cre-rust-source-extensions-example/.gitignore @@ -0,0 +1,2 @@ +wasm/ +.env diff --git a/packages/cre-rust-source-extensions-example/Makefile b/packages/cre-rust-source-extensions-example/Makefile new file mode 100644 index 00000000..1c509c65 --- /dev/null +++ b/packages/cre-rust-source-extensions-example/Makefile @@ -0,0 +1,17 @@ +# Compiles alpha (from workspace) + beta (from workspace) as source extensions via --cre-exports. +JAVY_PLUGIN := $(abspath ../cre-sdk-javy-plugin) +ALPHA_PKG := $(abspath ../cre-rust-inject-alpha) +BETA_PKG := $(abspath ../cre-rust-inject-beta) + +.PHONY: build clean + +build: + mkdir -p wasm + CRE_SDK_JAVY_PLUGIN_HOME="$(JAVY_PLUGIN)" bun cre-compile \ + --cre-exports $(ALPHA_PKG) \ + --cre-exports $(BETA_PKG) \ + ./index.ts \ + ./wasm/workflow.wasm + +clean: + rm -rf wasm diff --git a/packages/cre-rust-source-extensions-example/biome.json b/packages/cre-rust-source-extensions-example/biome.json new file mode 100644 index 00000000..ab256239 --- /dev/null +++ b/packages/cre-rust-source-extensions-example/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": ["**/*.ts", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineWidth": 100 + }, + "assist": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedVariables": "error" + }, + "suspicious": { + "noExplicitAny": "warn" + }, + "style": { + "useConst": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + } +} diff --git a/packages/cre-rust-source-extensions-example/config.json b/packages/cre-rust-source-extensions-example/config.json new file mode 100644 index 00000000..4281b2b0 --- /dev/null +++ b/packages/cre-rust-source-extensions-example/config.json @@ -0,0 +1 @@ +{ "schedule": "*/30 * * * * *" } diff --git a/packages/cre-rust-source-extensions-example/index.ts b/packages/cre-rust-source-extensions-example/index.ts new file mode 100644 index 00000000..c4becd24 --- /dev/null +++ b/packages/cre-rust-source-extensions-example/index.ts @@ -0,0 +1,26 @@ +import { rustAlpha } from '@chainlink/cre-rust-inject-alpha' +import { rustBeta } from '@chainlink/cre-rust-inject-beta' +import { CronCapability, handler, Runner, type Runtime } from '@chainlink/cre-sdk' +import { z } from 'zod' + +const configSchema = z.object({ + schedule: z.string(), +}) + +type Config = z.infer + +const onCronTrigger = (_runtime: Runtime) => { + const alpha = rustAlpha().greet() + const beta = rustBeta().greet() + return JSON.stringify({ alpha, beta }) +} + +const initWorkflow = (config: Config) => { + const cron = new CronCapability() + return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)] +} + +export async function main() { + const runner = await Runner.newRunner({ configSchema }) + await runner.run(initWorkflow) +} diff --git a/packages/cre-rust-source-extensions-example/package.json b/packages/cre-rust-source-extensions-example/package.json new file mode 100644 index 00000000..ee790295 --- /dev/null +++ b/packages/cre-rust-source-extensions-example/package.json @@ -0,0 +1,19 @@ +{ + "name": "@chainlink/cre-rust-source-extensions-example", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Example: installs @chainlink/cre-rust-inject-alpha from npm and compiles its Rust source alongside a local lib_beta extension.", + "scripts": { + "check": "biome check --write ${BIOME_PATHS:-.}", + "check:ci": "biome ci .", + "typecheck": "tsc" + }, + "dependencies": { + "@chainlink/cre-sdk": "workspace:*", + "@chainlink/cre-sdk-javy-plugin": "workspace:*", + "@chainlink/cre-rust-inject-alpha": "workspace:*", + "@chainlink/cre-rust-inject-beta": "workspace:*", + "zod": "3.25.76" + } +} diff --git a/packages/cre-rust-source-extensions-example/project.yaml b/packages/cre-rust-source-extensions-example/project.yaml new file mode 100644 index 00000000..7752bff3 --- /dev/null +++ b/packages/cre-rust-source-extensions-example/project.yaml @@ -0,0 +1,5 @@ +# CRE project settings — only local-simulation is needed for this example. +local-simulation: + rpcs: + - chain-name: ethereum-testnet-sepolia + url: https://por.bcy-p.metalhosts.com/cre-alpha/MvqtrdftrbxcP3ZgGBJb3bK5/ethereum/sepolia diff --git a/packages/cre-rust-source-extensions-example/tsconfig.json b/packages/cre-rust-source-extensions-example/tsconfig.json new file mode 100644 index 00000000..77e43b44 --- /dev/null +++ b/packages/cre-rust-source-extensions-example/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["index.ts"] +} diff --git a/packages/cre-rust-source-extensions-example/workflow.yaml b/packages/cre-rust-source-extensions-example/workflow.yaml new file mode 100644 index 00000000..3293c01f --- /dev/null +++ b/packages/cre-rust-source-extensions-example/workflow.yaml @@ -0,0 +1,8 @@ +# Example 1: source-extensions — both lib_alpha and lib_beta via --cre-exports +local-simulation: + user-workflow: + workflow-owner-address: "(optional)" + workflow-name: "rust-inject-source-extensions" + workflow-artifacts: + workflow-path: "./wasm/workflow.wasm" + config-path: "./config.json" diff --git a/packages/cre-sdk-javy-plugin/.gitignore b/packages/cre-sdk-javy-plugin/.gitignore index d0aa7636..32b57bc4 100644 --- a/packages/cre-sdk-javy-plugin/.gitignore +++ b/packages/cre-sdk-javy-plugin/.gitignore @@ -1,3 +1,7 @@ node_modules src/javy_chainlink_sdk/target/ -.turbo \ No newline at end of file +.cargo-target/ +.turbo + +# Temporary: uninitialized plugin wasm (only needed as input to `javy init-plugin` to produce the initialized plugin). If present under dist/, ignore — we do not ship it. +dist/javy_chainlink_sdk.wasm \ No newline at end of file diff --git a/packages/cre-sdk-javy-plugin/.npmignore b/packages/cre-sdk-javy-plugin/.npmignore new file mode 100644 index 00000000..22d926d2 --- /dev/null +++ b/packages/cre-sdk-javy-plugin/.npmignore @@ -0,0 +1,2 @@ +src/javy_chainlink_sdk/target +src/cre_wasm_exports/target diff --git a/packages/cre-sdk-javy-plugin/Dockerfile b/packages/cre-sdk-javy-plugin/Dockerfile index e5072de0..0057f154 100644 --- a/packages/cre-sdk-javy-plugin/Dockerfile +++ b/packages/cre-sdk-javy-plugin/Dockerfile @@ -1,11 +1,11 @@ -# Deterministic build of the Chainlink Javy plugin WASMs (uninitialized + initialized). -# Javy CLI: same code path as locally — Bun runs ensureJavy (GitHub release + checksum). -# Only javy_chainlink_sdk is built with cargo; then javy init-plugin --deterministic. +# syntax=docker/dockerfile:1 +# Deterministic build: wasm32-wasip1 cdylib, then javy init-plugin --deterministic. +# Exports to dist: initialized .plugin.wasm only (uninit cdylib stays in builder target/ only). # # Usage: # ./scripts/build-plugin-docker.sh -# --- Javy CLI via ensureJavy (identical to SKIP_DOCKER_IMAGE / build-plugin-local.sh) --- +# --- Javy CLI + host crate generation (same code path as user builds) --- FROM oven/bun:slim AS javy-cli ARG CRE_JAVY_VERSION=v8.1.0 @@ -13,7 +13,7 @@ ENV CRE_JAVY_VERSION=${CRE_JAVY_VERSION} ENV CRE_SDK_JAVY_LOG_STDERR=1 WORKDIR /w -COPY scripts/ensure-javy.ts scripts/print-javy-path-for-build.ts scripts/ +COPY scripts/ensure-javy.ts scripts/print-javy-path-for-build.ts scripts/generate-host-crate.ts scripts/ RUN set -eu; \ JAVY_BIN="$(bun scripts/print-javy-path-for-build.ts | tr -d '\r\n')"; \ @@ -21,32 +21,50 @@ RUN set -eu; \ cp "$JAVY_BIN" /w/javy; \ chmod +x /w/javy -# --- Chainlink plugin (Rust) + init-plugin --- -# Pin image tag to match rust-toolchain.toml (avoid `slim-bookworm` tag drift changing WASM bytes). -FROM rust:1.85.0-slim-bookworm AS plugin-builder +# Generate host crate via generateHostCrate (zero extensions). +# Layout at /build/src/... mirrors the Rust builder so absolute paths in the +# generated Cargo.toml resolve in both stages. +COPY src/javy_chainlink_sdk/rust-toolchain.toml /build/src/javy_chainlink_sdk/rust-toolchain.toml +RUN bun -e "import{generateHostCrate}from'./scripts/generate-host-crate.ts';generateHostCrate('/build/cre_generated_host','/build',[])" + +# --- Chainlink plugin (Rust) via generated host crate + init-plugin --- +# Keep in sync with src/javy_chainlink_sdk/rust-toolchain.toml channel. +# Mismatch = rustup silently downloads the pinned channel on every build (non-deterministic, slow). +FROM rust:1.87.0-slim-bookworm AS plugin-builder COPY --from=javy-cli /w/javy /usr/local/bin/javy -# javy → rquickjs-sys: curl for WASI SDK fetch; clang/libclang for bindgen. RUN apt-get update && apt-get install -y --no-install-recommends \ curl ca-certificates clang libclang-dev llvm-dev \ && rm -rf /var/lib/apt/lists/* -WORKDIR /plugin -COPY src/javy_chainlink_sdk/rust-toolchain.toml src/javy_chainlink_sdk/Cargo.toml src/javy_chainlink_sdk/Cargo.lock ./ -RUN mkdir src && echo '' > src/lib.rs +WORKDIR /build + +# Dependency warm-up: copy manifests + stub sources, then build to cache deps +COPY src/cre_wasm_exports/Cargo.toml /build/src/cre_wasm_exports/ +RUN mkdir -p /build/src/cre_wasm_exports/src && echo '' > /build/src/cre_wasm_exports/src/lib.rs + +COPY src/javy_chainlink_sdk/rust-toolchain.toml src/javy_chainlink_sdk/Cargo.toml src/javy_chainlink_sdk/Cargo.lock /build/src/javy_chainlink_sdk/ +RUN mkdir -p /build/src/javy_chainlink_sdk/src && echo '' > /build/src/javy_chainlink_sdk/src/lib.rs + +COPY --from=javy-cli /build/cre_generated_host /build/cre_generated_host + +WORKDIR /build/cre_generated_host RUN --mount=type=cache,target=/usr/local/cargo/registry \ cargo build --target wasm32-wasip1 --release 2>/dev/null || true -COPY src/javy_chainlink_sdk/src ./src -RUN touch src/lib.rs +COPY src/cre_wasm_exports/src /build/src/cre_wasm_exports/src +RUN touch /build/src/cre_wasm_exports/src/lib.rs + +COPY src/javy_chainlink_sdk/src /build/src/javy_chainlink_sdk/src +RUN touch /build/src/javy_chainlink_sdk/src/lib.rs + RUN --mount=type=cache,target=/usr/local/cargo/registry \ cargo build --target wasm32-wasip1 --release RUN javy init-plugin --deterministic \ - target/wasm32-wasip1/release/javy_chainlink_sdk.wasm \ + target/wasm32-wasip1/release/cre_generated_host.wasm \ -o /javy-chainlink-sdk.plugin.wasm FROM scratch -COPY --from=plugin-builder /plugin/target/wasm32-wasip1/release/javy_chainlink_sdk.wasm /javy_chainlink_sdk.wasm COPY --from=plugin-builder /javy-chainlink-sdk.plugin.wasm /javy-chainlink-sdk.plugin.wasm diff --git a/packages/cre-sdk-javy-plugin/README.md b/packages/cre-sdk-javy-plugin/README.md index 91f2c7b2..7a9b69af 100644 --- a/packages/cre-sdk-javy-plugin/README.md +++ b/packages/cre-sdk-javy-plugin/README.md @@ -124,9 +124,13 @@ cargo build --target wasm32-wasip1 --release After building, you'll find: -- `dist/javy_chainlink_sdk.wasm` - The compiled plugin +- `dist/javy-chainlink-sdk.plugin.wasm` - Initialized plugin (for `--plugin` / default compile) - `dist/workflow.wit` - WebAssembly Interface Types definitions +`--cre-exports` workflow builds link **`javy_chainlink_sdk` and `cre_wasm_exports` from source** via path dependencies (`src/javy_chainlink_sdk/`, `src/cre_wasm_exports/`); those directories are included in the published npm package. + +The **uninitialized** `javy_chainlink_sdk.wasm` from `cargo` exists only under `target/` during the build and is used as input to `javy init-plugin`; it is not shipped in `dist/`. + ### Deterministic initialized plugin (`build:plugin-wasm`) `bun run build:plugin-wasm` (via `scripts/build-plugin-docker.sh`) produces the **initialized** plugin WASM to match `Dockerfile` (deterministic `javy init-plugin`). diff --git a/packages/cre-sdk-javy-plugin/bin/compile-workflow.test.ts b/packages/cre-sdk-javy-plugin/bin/compile-workflow.test.ts new file mode 100644 index 00000000..05eab714 --- /dev/null +++ b/packages/cre-sdk-javy-plugin/bin/compile-workflow.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' + +/** + * Tests for compile-workflow.ts argument parsing and mode selection. + * The actual compilation is tested via E2E; these tests verify CLI logic. + */ +describe('compile-workflow parseArgs', () => { + // We can't easily unit test the main() without spawning, so we test the + // parseArgs logic by importing and running it. The parseArgs is not exported. + // Instead we test the mutual exclusivity and default-plugin behavior via + // a small script that invokes the compiler with different args. + + test('--plugin and --cre-exports mutual exclusivity exits with error', async () => { + const proc = Bun.spawn({ + cmd: [ + 'bun', + import.meta.dir + '/compile-workflow.ts', + '--plugin', + '/tmp/fake.plugin.wasm', + '--cre-exports', + '/tmp/fake', + '/dev/null', + '/tmp/out.wasm', + ], + stdout: 'pipe', + stderr: 'pipe', + cwd: import.meta.dir + '/..', + }) + const exitCode = await proc.exited + expect(exitCode).not.toBe(0) + const stderr = await new Response(proc.stderr).text() + expect(stderr).toContain('mutually exclusive') + }) + + test('default plugin mode when neither --plugin nor --cre-exports', async () => { + // Create a minimal JS file + const tmpJs = `/tmp/cre-test-${Date.now()}.js` + await Bun.write(tmpJs, 'export async function main() { return "ok"; }') + const tmpWasm = `/tmp/cre-test-${Date.now()}.wasm` + + const proc = Bun.spawn({ + cmd: ['bun', import.meta.dir + '/compile-workflow.ts', tmpJs, tmpWasm], + stdout: 'pipe', + stderr: 'pipe', + cwd: import.meta.dir + '/..', + }) + const exitCode = await proc.exited + // May succeed (if dist plugin exists) or fail (plugin not found) + // We just verify it doesn't fail with "mutually exclusive" + const stderr = await new Response(proc.stderr).text() + expect(stderr).not.toContain('mutually exclusive') + }) +}) diff --git a/packages/cre-sdk-javy-plugin/bin/compile-workflow.ts b/packages/cre-sdk-javy-plugin/bin/compile-workflow.ts index 3e63e458..b3d256d4 100755 --- a/packages/cre-sdk-javy-plugin/bin/compile-workflow.ts +++ b/packages/cre-sdk-javy-plugin/bin/compile-workflow.ts @@ -1,51 +1,136 @@ #!/usr/bin/env bun -import { spawn } from 'node:child_process' -import { existsSync } from 'node:fs' -import { dirname, resolve } from 'node:path' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { ensureJavy } from '../scripts/ensure-javy.ts' +import { generateHostCrate, resolveExtensions } from '../scripts/generate-host-crate.ts' +import { parseCompileFlags } from '../scripts/parse-compile-flags.ts' +import { JAVY_VERSION, run } from '../scripts/shared.ts' const __dirname = dirname(fileURLToPath(import.meta.url)) -const [jsFile, wasmFile] = process.argv.slice(2) +const DEFAULT_PLUGIN_PATH = resolve(__dirname, '..', 'dist', 'javy-chainlink-sdk.plugin.wasm') -if (!jsFile || !wasmFile) { - console.error('Usage: compile-workflow ') - process.exit(1) +function findBuiltWasm(targetDir: string): string { + const releaseDir = resolve(targetDir, 'wasm32-wasip1', 'release') + for (const name of ['cre_generated_host.wasm', 'libcre_generated_host.wasm']) { + const candidate = resolve(releaseDir, name) + if (existsSync(candidate)) return candidate + } + throw new Error(`Build succeeded but WASM not found in ${releaseDir}`) } -const javyPath = await ensureJavy({ version: 'v8.1.0' }) -const witPath = resolve(__dirname, '../dist/workflow.wit') -const pluginPath = resolve(__dirname, '../dist/javy-chainlink-sdk.plugin.wasm') +async function main() { + const argv = process.argv.slice(2) + const { creExports, plugin: pluginArg, rest } = parseCompileFlags(argv) + + if (rest.length < 2) { + console.error( + 'Usage: compile-workflow.ts [--plugin ] [--cre-exports ]... ', + ) + console.error(' --plugin: use pre-built .plugin.wasm (mutually exclusive with --cre-exports)') + console.error(' --cre-exports: path to a Rust extension crate directory (repeat for multiple)') + console.error(' If neither given, uses default pre-built plugin from dist/') + process.exit(1) + } + if (pluginArg !== null && creExports.length > 0) { + console.error( + '❌ Error: --plugin and --cre-exports are mutually exclusive. Use one or the other.', + ) + process.exit(1) + } + + const jsFile = rest[0] + const wasmFile = rest[1] + + if (!existsSync(jsFile)) { + console.error(`❌ Input file not found: ${jsFile}`) + process.exit(1) + } + + const pluginDir = resolve(__dirname, '..') + const witPath = resolve(pluginDir, 'dist', 'workflow.wit') + + let pluginPath: string -if (!existsSync(pluginPath)) { - console.error( - `❌ CRE SDK Javy plugin not found at: ${pluginPath}\n\n` + - 'The pre-built plugin WASM should be included in the package.\n' + - 'Try reinstalling @chainlink/cre-sdk-javy-plugin.\n' + - 'See: https://github.com/smartcontractkit/cre-sdk-typescript/blob/main/packages/cre-sdk-javy-plugin/README.md#quick-start', + if (pluginArg !== null) { + pluginPath = resolve(process.cwd(), pluginArg) + if (!existsSync(pluginPath)) { + console.error(`❌ Plugin file not found: ${pluginPath}`) + process.exit(1) + } + } else if (creExports.length > 0) { + const tmpDir = mkdtempSync(join(tmpdir(), 'cre-host-')) + const sharedTargetDir = resolve(pluginDir, '.cargo-target') + try { + const extensions = resolveExtensions(creExports) + generateHostCrate(tmpDir, pluginDir, extensions) + + const [, javyPath] = await Promise.all([ + run('cargo', ['build', '--target', 'wasm32-wasip1', '--release'], tmpDir, { + CARGO_TARGET_DIR: sharedTargetDir, + }), + ensureJavy({ version: JAVY_VERSION }), + ]) + + const builtWasm = findBuiltWasm(sharedTargetDir) + pluginPath = resolve(tmpDir, 'cre.plugin.wasm') + await run(javyPath, ['init-plugin', '--deterministic', builtWasm, '-o', pluginPath], tmpDir) + + await run( + javyPath, + [ + 'build', + '-C', + `wit=${witPath}`, + '-C', + 'wit-world=workflow', + '-C', + `plugin=${pluginPath}`, + '-C', + 'deterministic=y', + jsFile, + '-o', + wasmFile, + ], + process.cwd(), + ) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + return + } else { + pluginPath = DEFAULT_PLUGIN_PATH + if (!existsSync(pluginPath)) { + console.error(`❌ Default plugin not found: ${pluginPath}`) + console.error(' Run: bun run build (in packages/cre-sdk-javy-plugin) or bun x cre-setup') + process.exit(1) + } + } + + const javyPath = await ensureJavy({ version: JAVY_VERSION }) + await run( + javyPath, + [ + 'build', + '-C', + `wit=${witPath}`, + '-C', + 'wit-world=workflow', + '-C', + `plugin=${pluginPath}`, + '-C', + 'deterministic=y', + jsFile, + '-o', + wasmFile, + ], + process.cwd(), ) - process.exit(1) } -const javyArgs = [ - 'build', - '-C', - `wit=${witPath}`, - '-C', - 'wit-world=workflow', - '-C', - `plugin=${pluginPath}`, - '-C', - 'deterministic=y', - jsFile, - '-o', - wasmFile, -] - -const child = spawn(javyPath, javyArgs, { stdio: 'inherit' }) - -child.on('exit', (code, signal) => { - if (signal) process.kill(process.pid, signal) - else process.exit(code ?? 1) +main().catch((e) => { + console.error(e) + process.exit(1) }) diff --git a/packages/cre-sdk-javy-plugin/biome.json b/packages/cre-sdk-javy-plugin/biome.json index f4af170c..a85994df 100644 --- a/packages/cre-sdk-javy-plugin/biome.json +++ b/packages/cre-sdk-javy-plugin/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["src/**/*", "scripts/**/*", "bin/**/*", "**/*.json"] + "includes": ["src/**/*", "scripts/**/*", "bin/**/*", "runtime/**/*", "**/*.json"] }, "formatter": { "enabled": true, diff --git a/packages/cre-sdk-javy-plugin/dist/javy-chainlink-sdk.plugin.wasm b/packages/cre-sdk-javy-plugin/dist/javy-chainlink-sdk.plugin.wasm index 69ebf940..834e1d46 100644 Binary files a/packages/cre-sdk-javy-plugin/dist/javy-chainlink-sdk.plugin.wasm and b/packages/cre-sdk-javy-plugin/dist/javy-chainlink-sdk.plugin.wasm differ diff --git a/packages/cre-sdk-javy-plugin/dist/javy_chainlink_sdk.wasm b/packages/cre-sdk-javy-plugin/dist/javy_chainlink_sdk.wasm deleted file mode 100755 index 9c25dce1..00000000 Binary files a/packages/cre-sdk-javy-plugin/dist/javy_chainlink_sdk.wasm and /dev/null differ diff --git a/packages/cre-sdk-javy-plugin/package.json b/packages/cre-sdk-javy-plugin/package.json index 95d4ac87..15003d9e 100644 --- a/packages/cre-sdk-javy-plugin/package.json +++ b/packages/cre-sdk-javy-plugin/package.json @@ -13,8 +13,9 @@ "check": "biome check --write ${BIOME_PATHS:-.}", "check:ci": "biome ci .", "format": "biome format --write ${BIOME_PATHS:-.}", - "full-checks": "&& bun typecheck && bun check", + "full-checks": "bun run build && bun typecheck && bun check && bun test", "lint": "biome lint --write", + "test": "bun test", "typecheck": "tsc", "prepublishOnly": "bun typecheck && bun check" }, @@ -27,10 +28,39 @@ "url": "https://github.com/smartcontractkit/cre-sdk-typescript", "directory": "packages/cre-sdk-javy-plugin" }, + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.ts" + }, + "./scripts/parse-compile-flags": { + "types": "./scripts/parse-compile-flags.d.ts", + "default": "./scripts/parse-compile-flags.ts" + }, + "./scripts/shared": { + "types": "./scripts/shared.d.ts", + "default": "./scripts/shared.ts" + }, + "./runtime/validate-extension": { + "types": "./runtime/validate-extension.d.ts", + "default": "./runtime/validate-extension.ts" + } + }, + "peerDependencies": { + "zod": ">=3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + }, "files": [ "bin/", "scripts/", - "dist/" + "runtime/", + "dist/", + "src/javy_chainlink_sdk/", + "src/cre_wasm_exports/" ], "publishConfig": { "access": "public" diff --git a/packages/cre-sdk-javy-plugin/runtime/validate-extension.test.ts b/packages/cre-sdk-javy-plugin/runtime/validate-extension.test.ts new file mode 100644 index 00000000..3471f649 --- /dev/null +++ b/packages/cre-sdk-javy-plugin/runtime/validate-extension.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { createExtensionAccessor } from './validate-extension' + +const greetSchema = z.object({ + greet: z.function().args().returns(z.string()), +}) + +describe('createExtensionAccessor', () => { + afterEach(() => { + for (const key of ['testExt', 'cachedExt']) { + delete (globalThis as Record)[key] + } + }) + + test('returns validated extension from globalThis', () => { + ;(globalThis as Record).testExt = { greet: () => 'hello' } + + const accessor = createExtensionAccessor('testExt', greetSchema) + expect(accessor().greet()).toBe('hello') + }) + + test('caches the validated result across calls', () => { + ;(globalThis as Record).cachedExt = { greet: () => 'cached' } + + const accessor = createExtensionAccessor('cachedExt', greetSchema) + const first = accessor() + const second = accessor() + expect(first).toBe(second) + }) + + test('throws when extension is missing from globalThis', () => { + const accessor = createExtensionAccessor('nonExistent', greetSchema) + + expect(() => accessor()).toThrow(/"nonExistent" was not found on globalThis/) + expect(() => accessor()).toThrow(/must be provided by the nonExistent plugin/) + expect(() => accessor()).toThrow(/--plugin/) + expect(() => accessor()).toThrow(/--cre-exports/) + }) + + test('throws when extension exists but fails schema validation', () => { + ;(globalThis as Record).testExt = { wrong: 'shape' } + + const accessor = createExtensionAccessor('testExt', greetSchema) + + expect(() => accessor()).toThrow(/"testExt" failed validation/) + expect(() => accessor()).toThrow(/must be provided by the testExt plugin/) + }) +}) diff --git a/packages/cre-sdk-javy-plugin/runtime/validate-extension.ts b/packages/cre-sdk-javy-plugin/runtime/validate-extension.ts new file mode 100644 index 00000000..db0ac43d --- /dev/null +++ b/packages/cre-sdk-javy-plugin/runtime/validate-extension.ts @@ -0,0 +1,32 @@ +import type { z } from 'zod' + +/** + * Creates a lazy-validated accessor for a Rust extension registered on globalThis. + * + * Library authors call this with the globalThis key and a zod schema. + * The returned function validates on first access and caches the result. + */ +export function createExtensionAccessor(name: string, schema: z.ZodType): () => T { + let cached: T | null = null + return () => { + if (!cached) { + const obj = (globalThis as Record)[name] + try { + cached = schema.parse(obj) + } catch (_error) { + const detail = + obj == null + ? `"${name}" was not found on globalThis` + : `"${name}" failed validation: ${_error instanceof Error ? _error.message : String(_error)}` + throw new Error( + `${detail}. ` + + `It must be provided by the ${name} plugin. ` + + `This usually means the plugin has not been loaded. ` + + `Use --plugin to load a pre-built plugin, ` + + `or --cre-exports to compile from source when building your workflow.`, + ) + } + } + return cached + } +} diff --git a/packages/cre-sdk-javy-plugin/scripts/build-plugin-local.sh b/packages/cre-sdk-javy-plugin/scripts/build-plugin-local.sh index d24ae67e..488bab94 100755 --- a/packages/cre-sdk-javy-plugin/scripts/build-plugin-local.sh +++ b/packages/cre-sdk-javy-plugin/scripts/build-plugin-local.sh @@ -35,11 +35,10 @@ echo "---> Building javy_chainlink_sdk (wasm32-wasip1 release)" UNINIT="$PLUGIN_CRATE/target/wasm32-wasip1/release/javy_chainlink_sdk.wasm" mkdir -p "$PLUGIN_DIR/dist" -cp "$UNINIT" "$PLUGIN_DIR/dist/javy_chainlink_sdk.wasm" cp "$PLUGIN_DIR/src/workflow.wit" "$PLUGIN_DIR/dist/workflow.wit" echo "---> javy init-plugin --deterministic" -"$JAVY_BIN" init-plugin --deterministic "$PLUGIN_DIR/dist/javy_chainlink_sdk.wasm" \ +"$JAVY_BIN" init-plugin --deterministic "$UNINIT" \ -o "$PLUGIN_DIR/dist/javy-chainlink-sdk.plugin.wasm" echo "" diff --git a/packages/cre-sdk-javy-plugin/scripts/build-plugin.ts b/packages/cre-sdk-javy-plugin/scripts/build-plugin.ts new file mode 100644 index 00000000..ffab868c --- /dev/null +++ b/packages/cre-sdk-javy-plugin/scripts/build-plugin.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env bun +/** + * Builds a Javy plugin .plugin.wasm from extension crates. + * Used by examples that need a pre-built plugin (e.g. lib_alpha). + */ +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path, { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { ensureJavy } from './ensure-javy.ts' +import { generateHostCrate, resolveExtensions } from './generate-host-crate.ts' +import { JAVY_VERSION, run } from './shared.ts' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const pluginDir = resolve(__dirname, '..') + +function findBuiltWasm(targetDir: string): string { + const releaseDir = resolve(targetDir, 'wasm32-wasip1', 'release') + for (const name of ['cre_generated_host.wasm', 'libcre_generated_host.wasm']) { + const candidate = resolve(releaseDir, name) + if (existsSync(candidate)) return candidate + } + throw new Error(`Build succeeded but WASM not found in ${releaseDir}`) +} + +async function main() { + const argv = process.argv.slice(2) + const creExports: string[] = [] + let outputPath: string | null = null + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--cre-exports' && i + 1 < argv.length) { + creExports.push(argv[i + 1]) + i++ + } else if (argv[i] === '-o' && i + 1 < argv.length) { + outputPath = argv[i + 1] + i++ + } + } + if (creExports.length === 0 || outputPath === null) { + console.error('Usage: build-plugin.ts --cre-exports ... -o ') + process.exit(1) + } + + const tmpDir = mkdtempSync(join(tmpdir(), 'cre-plugin-')) + const sharedTargetDir = resolve(pluginDir, '.cargo-target') + try { + const extensions = resolveExtensions(creExports) + generateHostCrate(tmpDir, pluginDir, extensions) + + const [, javyPath] = await Promise.all([ + run('cargo', ['build', '--target', 'wasm32-wasip1', '--release'], tmpDir, { + CARGO_TARGET_DIR: sharedTargetDir, + }), + ensureJavy({ version: JAVY_VERSION }), + ]) + + const builtWasm = findBuiltWasm(sharedTargetDir) + const outAbs = resolve(process.cwd(), outputPath) + mkdirSync(path.dirname(outAbs), { recursive: true }) + await run(javyPath, ['init-plugin', '--deterministic', builtWasm, '-o', outAbs], tmpDir) + console.info(`✅ Plugin built: ${outAbs}`) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/packages/cre-sdk-javy-plugin/scripts/compile-javy-sdk-plugin.ts b/packages/cre-sdk-javy-plugin/scripts/compile-javy-sdk-plugin.ts index 187676eb..19e0fd5c 100755 --- a/packages/cre-sdk-javy-plugin/scripts/compile-javy-sdk-plugin.ts +++ b/packages/cre-sdk-javy-plugin/scripts/compile-javy-sdk-plugin.ts @@ -1,53 +1,57 @@ #!/usr/bin/env bun -import { spawn } from 'node:child_process' -import { copyFileSync, mkdirSync } from 'node:fs' -import { join } from 'node:path' - -const builtWasmPath = join( - process.cwd(), - 'src', - 'javy_chainlink_sdk', - 'target', - 'wasm32-wasip1', - 'release', - 'javy_chainlink_sdk.wasm', -) -const distWasmPath = join(process.cwd(), 'dist', 'javy_chainlink_sdk.wasm') -const witFilePath = join(process.cwd(), 'src', 'workflow.wit') -const distWitFilePath = join(process.cwd(), 'dist', 'workflow.wit') +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { ensureJavy } from './ensure-javy.ts' +import { generateHostCrate } from './generate-host-crate.ts' +import { JAVY_VERSION, run } from './shared.ts' + +const pluginDir = join(import.meta.dir, '..') +const distPluginWasmPath = join(pluginDir, 'dist', 'javy-chainlink-sdk.plugin.wasm') +const witFilePath = join(pluginDir, 'src', 'workflow.wit') +const distWitFilePath = join(pluginDir, 'dist', 'workflow.wit') + +function findBuiltWasm(targetDir: string): string { + const releaseDir = resolve(targetDir, 'wasm32-wasip1', 'release') + for (const name of ['cre_generated_host.wasm', 'libcre_generated_host.wasm']) { + const candidate = resolve(releaseDir, name) + if (existsSync(candidate)) return candidate + } + throw new Error(`Build succeeded but WASM not found in ${releaseDir}`) +} export const main = async () => { - const pluginDir = join(process.cwd(), 'src', 'javy_chainlink_sdk') - console.info('\n\n---> Compiling Chainlink SDK Javy plugin (Rust) \n\n') - return new Promise((resolve, reject) => { - const buildProcess = spawn('cargo', ['build', '--target', 'wasm32-wasip1', '--release'], { - cwd: pluginDir, - stdio: 'inherit', - shell: true, - }) - - buildProcess.on('close', (code) => { - if (code === 0) { - mkdirSync('dist', { recursive: true }) - copyFileSync(builtWasmPath, distWasmPath) - copyFileSync(witFilePath, distWitFilePath) - - console.info('✅ Done!') - resolve() - } else { - console.error(`❌ Plugin build failed with code ${code}`) - reject(new Error(`Plugin build failed with code ${code}`)) - } - }) - - buildProcess.on('error', (error) => { - console.error('❌ Failed to start build process:', error) - reject(error) - }) - }) + const tmpDir = mkdtempSync(join(tmpdir(), 'cre-plugin-')) + const sharedTargetDir = resolve(pluginDir, '.cargo-target') + + try { + generateHostCrate(tmpDir, pluginDir, []) + + const [, javyPath] = await Promise.all([ + run('cargo', ['build', '--target', 'wasm32-wasip1', '--release'], tmpDir, { + CARGO_TARGET_DIR: sharedTargetDir, + }), + ensureJavy({ version: JAVY_VERSION }), + ]) + + const builtWasm = findBuiltWasm(sharedTargetDir) + const distDir = join(pluginDir, 'dist') + mkdirSync(distDir, { recursive: true }) + copyFileSync(witFilePath, distWitFilePath) + + await run( + javyPath, + ['init-plugin', '--deterministic', builtWasm, '-o', distPluginWasmPath], + tmpDir, + ) + + console.info('✅ Done!') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } } main() diff --git a/packages/cre-sdk-javy-plugin/scripts/generate-host-crate.test.ts b/packages/cre-sdk-javy-plugin/scripts/generate-host-crate.test.ts new file mode 100644 index 00000000..84961f68 --- /dev/null +++ b/packages/cre-sdk-javy-plugin/scripts/generate-host-crate.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, test } from 'bun:test' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + generateHostCrate, + parseToolchainVersion, + readCrateName, + resolveExtensions, + resolveToolchain, +} from './generate-host-crate' + +describe('generate-host-crate', () => { + describe('readCrateName', () => { + test('reads crate name from Cargo.toml', () => { + const pluginDir = join(import.meta.dir, '..') + const cargoPath = join(pluginDir, 'src', 'javy_chainlink_sdk', 'Cargo.toml') + expect(readCrateName(cargoPath)).toBe('javy_chainlink_sdk') + }) + + test('throws when Cargo.toml has no name', () => { + const tmp = mkdtempSync(join(tmpdir(), 'cre-test-')) + try { + writeFileSync(join(tmp, 'Cargo.toml'), '[package]\nversion = "0.1.0"') + expect(() => readCrateName(join(tmp, 'Cargo.toml'))).toThrow(/Could not find/) + } finally { + rmSync(tmp, { recursive: true }) + } + }) + }) + + describe('resolveExtensions', () => { + test('resolves directory path to crate name and path', () => { + const pluginDir = join(import.meta.dir, '..') + const libAlphaDir = join(pluginDir, '..', 'cre-rust-inject-alpha') + const resolved = resolveExtensions([libAlphaDir]) + expect(resolved).toHaveLength(1) + expect(resolved[0].crateName).toBe('alpha') + expect(resolved[0].path).toBe(libAlphaDir) + }) + + test('resolves Cargo.toml path', () => { + const pluginDir = join(import.meta.dir, '..') + const cargoPath = join(pluginDir, '..', 'cre-rust-inject-alpha', 'Cargo.toml') + const resolved = resolveExtensions([cargoPath]) + expect(resolved).toHaveLength(1) + expect(resolved[0].crateName).toBe('alpha') + }) + }) + + describe('parseToolchainVersion', () => { + test('parses semver channel from rust-toolchain.toml', () => { + const tmp = mkdtempSync(join(tmpdir(), 'cre-tc-')) + try { + writeFileSync( + join(tmp, 'rust-toolchain.toml'), + '[toolchain]\nchannel = "1.85.0"\ntargets = ["wasm32-wasip1"]\n', + ) + expect(parseToolchainVersion(join(tmp, 'rust-toolchain.toml'))).toEqual([1, 85, 0]) + } finally { + rmSync(tmp, { recursive: true }) + } + }) + + test('returns null for non-semver channel (nightly)', () => { + const tmp = mkdtempSync(join(tmpdir(), 'cre-tc-')) + try { + writeFileSync( + join(tmp, 'rust-toolchain.toml'), + '[toolchain]\nchannel = "nightly-2024-01-01"\n', + ) + expect(parseToolchainVersion(join(tmp, 'rust-toolchain.toml'))).toBeNull() + } finally { + rmSync(tmp, { recursive: true }) + } + }) + + test('returns null for missing file', () => { + expect(parseToolchainVersion('/nonexistent/rust-toolchain.toml')).toBeNull() + }) + }) + + describe('resolveToolchain', () => { + test('uses SDK version when no extensions have toolchain files', () => { + const sdkDir = mkdtempSync(join(tmpdir(), 'cre-sdk-')) + try { + writeFileSync( + join(sdkDir, 'rust-toolchain.toml'), + '[toolchain]\nchannel = "1.85.0"\ntargets = ["wasm32-wasip1"]\n', + ) + const result = resolveToolchain(join(sdkDir, 'rust-toolchain.toml'), []) + expect(result).toContain('channel = "1.85.0"') + expect(result).toContain('wasm32-wasip1') + } finally { + rmSync(sdkDir, { recursive: true }) + } + }) + + test('picks higher extension version over SDK', () => { + const sdkDir = mkdtempSync(join(tmpdir(), 'cre-sdk-')) + const extDir = mkdtempSync(join(tmpdir(), 'cre-ext-')) + try { + writeFileSync(join(sdkDir, 'rust-toolchain.toml'), '[toolchain]\nchannel = "1.85.0"\n') + writeFileSync(join(extDir, 'rust-toolchain.toml'), '[toolchain]\nchannel = "1.87.0"\n') + const result = resolveToolchain(join(sdkDir, 'rust-toolchain.toml'), [ + { crateName: 'ext', path: extDir }, + ]) + expect(result).toContain('channel = "1.87.0"') + } finally { + rmSync(sdkDir, { recursive: true }) + rmSync(extDir, { recursive: true }) + } + }) + + test('keeps SDK version when extension is older', () => { + const sdkDir = mkdtempSync(join(tmpdir(), 'cre-sdk-')) + const extDir = mkdtempSync(join(tmpdir(), 'cre-ext-')) + try { + writeFileSync(join(sdkDir, 'rust-toolchain.toml'), '[toolchain]\nchannel = "1.85.0"\n') + writeFileSync(join(extDir, 'rust-toolchain.toml'), '[toolchain]\nchannel = "1.80.0"\n') + const result = resolveToolchain(join(sdkDir, 'rust-toolchain.toml'), [ + { crateName: 'ext', path: extDir }, + ]) + expect(result).toContain('channel = "1.85.0"') + } finally { + rmSync(sdkDir, { recursive: true }) + rmSync(extDir, { recursive: true }) + } + }) + + test('ignores extensions with nightly channel', () => { + const sdkDir = mkdtempSync(join(tmpdir(), 'cre-sdk-')) + const extDir = mkdtempSync(join(tmpdir(), 'cre-ext-')) + try { + writeFileSync(join(sdkDir, 'rust-toolchain.toml'), '[toolchain]\nchannel = "1.85.0"\n') + writeFileSync(join(extDir, 'rust-toolchain.toml'), '[toolchain]\nchannel = "nightly"\n') + const result = resolveToolchain(join(sdkDir, 'rust-toolchain.toml'), [ + { crateName: 'ext', path: extDir }, + ]) + expect(result).toContain('channel = "1.85.0"') + } finally { + rmSync(sdkDir, { recursive: true }) + rmSync(extDir, { recursive: true }) + } + }) + }) + + describe('generateHostCrate', () => { + test('generates host crate with zero extensions (standalone)', () => { + const outDir = mkdtempSync(join(tmpdir(), 'cre-host-')) + const pluginDir = join(import.meta.dir, '..') + try { + generateHostCrate(outDir, pluginDir, []) + const cargo = readFileSync(join(outDir, 'Cargo.toml'), 'utf8') + expect(cargo).toContain('name = "cre_generated_host"') + expect(cargo).toContain('crate-type = ["cdylib"]') + expect(cargo).toContain('javy_chainlink_sdk = { path') + expect(cargo).not.toContain('cre_wasm_exports') + const libRs = readFileSync(join(outDir, 'src', 'lib.rs'), 'utf8') + expect(libRs).toContain('javy_chainlink_sdk::config') + expect(libRs).toContain('javy_chainlink_sdk::modify_runtime') + expect(libRs).not.toContain('register') + expect(libRs).not.toContain('context().with') + } finally { + rmSync(outDir, { recursive: true }) + } + }) + + test('throws when extension is missing src/lib.rs', () => { + const outDir = mkdtempSync(join(tmpdir(), 'cre-host-')) + const extDir = mkdtempSync(join(tmpdir(), 'cre-ext-')) + const pluginDir = join(import.meta.dir, '..') + try { + writeFileSync(join(extDir, 'Cargo.toml'), '[package]\nname = "bad_ext"\nversion = "0.1.0"') + const extensions = [{ crateName: 'bad_ext', path: extDir }] + expect(() => generateHostCrate(outDir, pluginDir, extensions)).toThrow( + /missing src\/lib\.rs/, + ) + } finally { + rmSync(outDir, { recursive: true }) + rmSync(extDir, { recursive: true }) + } + }) + + test('throws when extension lacks pub fn register', () => { + const outDir = mkdtempSync(join(tmpdir(), 'cre-host-')) + const extDir = mkdtempSync(join(tmpdir(), 'cre-ext-')) + const pluginDir = join(import.meta.dir, '..') + try { + writeFileSync(join(extDir, 'Cargo.toml'), '[package]\nname = "no_reg"\nversion = "0.1.0"') + mkdirSync(join(extDir, 'src')) + writeFileSync(join(extDir, 'src', 'lib.rs'), 'pub fn init() {}') + const extensions = [{ crateName: 'no_reg', path: extDir }] + expect(() => generateHostCrate(outDir, pluginDir, extensions)).toThrow( + /does not export.*pub fn register/, + ) + } finally { + rmSync(outDir, { recursive: true }) + rmSync(extDir, { recursive: true }) + } + }) + + test('Cargo.toml uses path deps for javy_chainlink_sdk and extensions', () => { + const outDir = mkdtempSync(join(tmpdir(), 'cre-host-')) + const pluginDir = join(import.meta.dir, '..') + const packagesDir = join(pluginDir, '..') + const extensions = resolveExtensions([ + join(packagesDir, 'cre-rust-inject-alpha'), + join(packagesDir, 'cre-rust-inject-beta'), + ]) + try { + generateHostCrate(outDir, pluginDir, extensions) + const cargo = readFileSync(join(outDir, 'Cargo.toml'), 'utf8') + expect(cargo).toContain('name = "cre_generated_host"') + expect(cargo).toContain('crate-type = ["cdylib"]') + expect(cargo).toContain('javy-plugin-api = "6.0.0"') + expect(cargo).toContain('javy = "7.0.0"') + expect(cargo).toContain('javy_chainlink_sdk = { path') + expect(cargo).toContain('alpha = { path') + expect(cargo).toContain('lib_beta = { path') + const libRs = readFileSync(join(outDir, 'src', 'lib.rs'), 'utf8') + expect(libRs).toContain('alpha::register') + expect(libRs).toContain('lib_beta::register') + expect(libRs).toContain('javy_chainlink_sdk::config') + expect(libRs).toContain('javy_chainlink_sdk::modify_runtime') + } finally { + rmSync(outDir, { recursive: true }) + } + }) + + test('writes rust-toolchain.toml with resolved version', () => { + const outDir = mkdtempSync(join(tmpdir(), 'cre-host-')) + const pluginDir = join(import.meta.dir, '..') + try { + generateHostCrate(outDir, pluginDir, []) + const toolchainPath = join(outDir, 'rust-toolchain.toml') + expect(existsSync(toolchainPath)).toBe(true) + const content = readFileSync(toolchainPath, 'utf8') + expect(content).toContain('wasm32-wasip1') + expect(content).toMatch(/channel = "\d+\.\d+\.\d+"/) + } finally { + rmSync(outDir, { recursive: true }) + } + }) + + test('rust-toolchain.toml picks higher extension version', () => { + const outDir = mkdtempSync(join(tmpdir(), 'cre-host-')) + const extDir = mkdtempSync(join(tmpdir(), 'cre-ext-')) + const pluginDir = join(import.meta.dir, '..') + try { + writeFileSync( + join(extDir, 'Cargo.toml'), + '[package]\nname = "newer_ext"\nversion = "0.1.0"', + ) + mkdirSync(join(extDir, 'src')) + writeFileSync(join(extDir, 'src', 'lib.rs'), 'pub fn register(_ctx: &()) {}') + writeFileSync(join(extDir, 'rust-toolchain.toml'), '[toolchain]\nchannel = "1.99.0"\n') + const extensions = [{ crateName: 'newer_ext', path: extDir }] + generateHostCrate(outDir, pluginDir, extensions) + const content = readFileSync(join(outDir, 'rust-toolchain.toml'), 'utf8') + expect(content).toContain('channel = "1.99.0"') + } finally { + rmSync(outDir, { recursive: true }) + rmSync(extDir, { recursive: true }) + } + }) + + test('Cargo.toml does not use default-features = false', () => { + const outDir = mkdtempSync(join(tmpdir(), 'cre-host-')) + const extDir = mkdtempSync(join(tmpdir(), 'cre-ext-')) + const pluginDir = join(import.meta.dir, '..') + try { + writeFileSync(join(extDir, 'Cargo.toml'), '[package]\nname = "test_ext"\nversion = "0.1.0"') + mkdirSync(join(extDir, 'src')) + writeFileSync(join(extDir, 'src', 'lib.rs'), 'pub fn register(_ctx: &()) {}') + const extensions = [{ crateName: 'test_ext', path: extDir }] + generateHostCrate(outDir, pluginDir, extensions) + const cargo = readFileSync(join(outDir, 'Cargo.toml'), 'utf8') + expect(cargo).not.toContain('default-features') + } finally { + rmSync(outDir, { recursive: true }) + rmSync(extDir, { recursive: true }) + } + }) + + test('generated Cargo.toml paths use forward slashes', () => { + const outDir = mkdtempSync(join(tmpdir(), 'cre-host-')) + const pluginDir = join(import.meta.dir, '..') + const extensions = resolveExtensions([join(pluginDir, '..', 'cre-rust-inject-alpha')]) + try { + generateHostCrate(outDir, pluginDir, extensions) + const cargo = readFileSync(join(outDir, 'Cargo.toml'), 'utf8') + expect(cargo).not.toMatch(/path = "[^"]*\\/) + } finally { + rmSync(outDir, { recursive: true }) + } + }) + }) +}) diff --git a/packages/cre-sdk-javy-plugin/scripts/generate-host-crate.ts b/packages/cre-sdk-javy-plugin/scripts/generate-host-crate.ts new file mode 100644 index 00000000..affd1b4f --- /dev/null +++ b/packages/cre-sdk-javy-plugin/scripts/generate-host-crate.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env bun + +/** + * Generates a temporary host crate that statically links javy_chainlink_sdk + cre_wasm_exports + optional extensions. + * Core crates use path dependencies into this package's `src/` (see pluginDir). + * Used by compile-workflow.ts, build-plugin.ts, and the Dockerfile. + */ + +import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { semver } from 'bun' + +/** Normalizes a path for use in TOML strings (backslashes → forward slashes). */ +function toTomlPath(p: string): string { + return p.replace(/\\/g, '/') +} + +export interface ExtensionInfo { + crateName: string + path: string +} + +/** + * Reads the crate name from a Rust crate's Cargo.toml. + */ +export function readCrateName(cargoTomlPath: string): string { + const content = readFileSync(cargoTomlPath, 'utf8') + const match = content.match(/^name\s*=\s*"([^"]+)"/m) + if (!match) { + throw new Error(`Could not find [package] name in ${cargoTomlPath}`) + } + return match[1] +} + +/** + * Resolves extension paths to { crateName, path }. + * + * Accepts: + * - directory containing Cargo.toml (or path to Cargo.toml) + */ +export function resolveExtensions(extensionPaths: string[]): ExtensionInfo[] { + return extensionPaths.map((p) => { + // Resolve symlinks (e.g. node_modules file: symlinks) to canonical real paths so Cargo + // lockfile entries match the real paths resolved by path deps in dependent crates. + const resolved = realpathSync(resolve(p)) + + let cargoPath = resolved + if (!cargoPath.endsWith('Cargo.toml')) { + cargoPath = join(resolved, 'Cargo.toml') + } + const crateName = readCrateName(cargoPath) + const dir = cargoPath.endsWith('Cargo.toml') ? dirname(cargoPath) : resolved + return { crateName, path: dir } + }) +} + +/** + * Parses the `channel` field from a rust-toolchain.toml file as a semver triple. + * Returns null if the file doesn't exist or the channel isn't a simple x.y.z version. + */ +export function parseToolchainVersion(toolchainPath: string): [number, number, number] | null { + if (!existsSync(toolchainPath)) return null + const content = readFileSync(toolchainPath, 'utf8') + const match = content.match(/^channel\s*=\s*"(\d+)\.(\d+)\.(\d+)"/m) + if (!match) return null + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +function versionString(v: [number, number, number]): string { + return `${v[0]}.${v[1]}.${v[2]}` +} + +/** + * Resolves the Rust toolchain version to use for the generated host crate. + * Takes the maximum of the SDK's pinned version and any extension crate rust-toolchain.toml files. + * Always includes wasm32-wasip1 in targets. + */ +export function resolveToolchain(sdkToolchainPath: string, extensions: ExtensionInfo[]): string { + const sdkVersion = parseToolchainVersion(sdkToolchainPath) + if (!sdkVersion) { + throw new Error(`SDK rust-toolchain.toml missing or has no semver channel: ${sdkToolchainPath}`) + } + + let best = sdkVersion + + for (const ext of extensions) { + const extToolchain = join(ext.path, 'rust-toolchain.toml') + const extVersion = parseToolchainVersion(extToolchain) + if (extVersion && semver.order(versionString(extVersion), versionString(best)) > 0) { + best = extVersion + } + } + + return `[toolchain]\nchannel = "${versionString(best)}"\ntargets = ["wasm32-wasip1"]\n` +} + +/** + * Generates the host crate at outDir. + * @param outDir - Directory to write Cargo.toml, src/lib.rs, and rust-toolchain.toml + * @param pluginDir - Absolute path to packages/cre-sdk-javy-plugin (for path deps to src/javy_chainlink_sdk + src/cre_wasm_exports) + * @param extensions - Extension crates (source trees, as returned by resolveExtensions). May be empty for the default standalone plugin. + */ +export function generateHostCrate( + outDir: string, + pluginDir: string, + extensions: ExtensionInfo[] = [], +): void { + for (const ext of extensions) { + const libRsPath = join(ext.path, 'src', 'lib.rs') + if (!existsSync(libRsPath)) { + throw new Error( + `Extension "${ext.crateName}" is missing src/lib.rs at ${ext.path}. ` + + 'Each --cre-exports crate must have a src/lib.rs file.', + ) + } + const src = readFileSync(libRsPath, 'utf8') + if (!/pub\s+fn\s+register\s*\(/.test(src)) { + throw new Error( + `Extension "${ext.crateName}" does not export \`pub fn register(ctx: &Ctx<'_>)\` in ${libRsPath}. ` + + 'Each --cre-exports crate must expose this function for the generated host crate to call.', + ) + } + } + + mkdirSync(join(outDir, 'src'), { recursive: true }) + + // Resolve pluginDir symlinks so all Cargo path deps use canonical real paths. + // Without this, a node_modules symlink path and the real .packaged/ path would appear as + // two different cre_wasm_exports packages to Cargo and cause a lockfile collision. + const realPluginDir = realpathSync(resolve(pluginDir)) + const javySdkPath = toTomlPath(resolve(realPluginDir, 'src', 'javy_chainlink_sdk')) + + const depLines = [ + `javy-plugin-api = "6.0.0"`, + `javy = "7.0.0"`, + `javy_chainlink_sdk = { path = "${javySdkPath}" }`, + ] + for (const ext of extensions) { + depLines.push(`${ext.crateName} = { path = "${toTomlPath(ext.path)}" }`) + } + + const cargoToml = `[package] +name = "cre_generated_host" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +${depLines.join('\n')} +` + + let extensionBlock = '' + if (extensions.length > 0) { + const registerLines = extensions + .map((e) => `${e.crateName}::register(&ctx);`) + .join('\n ') + extensionBlock = ` + runtime.context().with(|ctx| { + ${registerLines} + });` + } + + const libRs = `// import_namespace!("javy_chainlink_sdk") is already emitted by the javy_chainlink_sdk path dep. +// Repeating it here would cause duplicate WIT namespace exports that break plugin initialisation. + +#[allow(unsafe_code)] +#[unsafe(export_name = "initialize-runtime")] +pub unsafe extern "C" fn initialize_runtime() { + javy_plugin_api::initialize_runtime( + javy_chainlink_sdk::config, + |runtime| { + let runtime = javy_chainlink_sdk::modify_runtime(runtime);${extensionBlock} + runtime + }, + ) + .unwrap(); +} +` + + writeFileSync(join(outDir, 'Cargo.toml'), cargoToml) + writeFileSync(join(outDir, 'src', 'lib.rs'), libRs) + + const sdkToolchainPath = resolve( + realPluginDir, + 'src', + 'javy_chainlink_sdk', + 'rust-toolchain.toml', + ) + const toolchainContent = resolveToolchain(sdkToolchainPath, extensions) + writeFileSync(join(outDir, 'rust-toolchain.toml'), toolchainContent) +} diff --git a/packages/cre-sdk-javy-plugin/scripts/parse-compile-flags.ts b/packages/cre-sdk-javy-plugin/scripts/parse-compile-flags.ts new file mode 100644 index 00000000..07ed3978 --- /dev/null +++ b/packages/cre-sdk-javy-plugin/scripts/parse-compile-flags.ts @@ -0,0 +1,21 @@ +export function parseCompileFlags(argv: string[]): { + creExports: string[] + plugin: string | null + rest: string[] +} { + const creExports: string[] = [] + let plugin: string | null = null + const rest: string[] = [] + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--cre-exports' && i + 1 < argv.length) { + creExports.push(argv[i + 1]) + i++ + } else if (argv[i] === '--plugin' && i + 1 < argv.length) { + plugin = argv[i + 1] + i++ + } else { + rest.push(argv[i]) + } + } + return { creExports, plugin, rest } +} diff --git a/packages/cre-sdk-javy-plugin/scripts/shared.ts b/packages/cre-sdk-javy-plugin/scripts/shared.ts new file mode 100644 index 00000000..2a540aaa --- /dev/null +++ b/packages/cre-sdk-javy-plugin/scripts/shared.ts @@ -0,0 +1,23 @@ +import { spawn } from 'node:child_process' + +export const JAVY_VERSION = 'v8.1.0' + +export function run( + cmd: string, + args: string[], + cwd: string, + env?: Record, +): Promise { + return new Promise((resolve, reject) => { + const p = spawn(cmd, args, { + cwd, + stdio: 'inherit', + env: env ? { ...process.env, ...env } : process.env, + }) + p.on('error', reject) + p.on('exit', (code) => { + if (code === 0) resolve() + else reject(new Error(`${cmd} exited with ${code}`)) + }) + }) +} diff --git a/packages/cre-sdk-javy-plugin/src/cre_wasm_exports/Cargo.toml b/packages/cre-sdk-javy-plugin/src/cre_wasm_exports/Cargo.toml new file mode 100644 index 00000000..b59320d5 --- /dev/null +++ b/packages/cre-sdk-javy-plugin/src/cre_wasm_exports/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "cre_wasm_exports" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["lib"] + +[dependencies] +javy-plugin-api = "6.0.0" diff --git a/packages/cre-sdk-javy-plugin/src/cre_wasm_exports/src/lib.rs b/packages/cre-sdk-javy-plugin/src/cre_wasm_exports/src/lib.rs new file mode 100644 index 00000000..e0890d67 --- /dev/null +++ b/packages/cre-sdk-javy-plugin/src/cre_wasm_exports/src/lib.rs @@ -0,0 +1,62 @@ +//! `cre_wasm_exports` — utilities for registering WASM exports with duplicate detection. +//! +//! Use `extend_wasm_exports(ctx, name, value)` instead of `ctx.globals().set(name, value)` +//! to track exports and detect duplicates at runtime. + +use std::cell::RefCell; +use std::collections::HashSet; + +use javy_plugin_api::javy::quickjs::{Ctx, IntoJs}; + +thread_local! { + static REGISTERED: RefCell> = RefCell::new(HashSet::new()); +} + +/// Registers a JS global and tracks it for duplicate detection. +/// +/// Panics immediately if `name` was already registered in the current +/// initialization cycle (i.e. two different crates both export the same name). +pub fn extend_wasm_exports<'js, V: IntoJs<'js>>(ctx: &Ctx<'js>, name: &'static str, value: V) { + REGISTERED.with(|cell| { + let mut set = cell.borrow_mut(); + if !set.insert(name) { + panic!("Duplicate WASM export: '{name}' is already registered"); + } + }); + ctx.globals().set(name, value).unwrap(); +} + +/// Resets the export registry for a new initialization cycle. +/// +/// **SDK-internal — do NOT call from extension code.** Calling this from an +/// extension would clear names registered by other crates, defeating +/// cross-extension duplicate detection. +/// +/// Required because Javy's `init-plugin` invokes `initialize-runtime` twice on +/// the same thread (the upstream `javy_plugin_api::initialize_runtime` calls +/// `RUNTIME.take()` to permit re-init). The `thread_local!` `REGISTERED` set +/// outlives the old QuickJS `Runtime`, so without this clear the second pass +/// panics on every previously-registered name. +#[doc(hidden)] +pub fn __clear_registry() { + REGISTERED.with(|cell| { + cell.borrow_mut().clear(); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use javy_plugin_api::javy::{Config, Runtime}; + + #[test] + #[should_panic(expected = "Duplicate WASM export")] + fn rejects_duplicate_export_name() { + REGISTERED.with(|cell| cell.borrow_mut().clear()); + let runtime = Runtime::new(Config::default()).unwrap(); + runtime.context().with(|ctx| { + extend_wasm_exports(&ctx, "duplicatedName", true); + extend_wasm_exports(&ctx, "duplicatedName", true); + }); + } +} diff --git a/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/Cargo.lock b/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/Cargo.lock index a282ec5b..7c5271e2 100644 --- a/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/Cargo.lock +++ b/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/Cargo.lock @@ -100,6 +100,13 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cre_wasm_exports" +version = "0.1.0" +dependencies = [ + "javy-plugin-api", +] + [[package]] name = "either" version = "1.15.0" @@ -287,6 +294,7 @@ name = "javy_chainlink_sdk" version = "0.1.0" dependencies = [ "base64", + "cre_wasm_exports", "javy", "javy-plugin-api", "rand 0.8.5", diff --git a/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/Cargo.toml b/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/Cargo.toml index b5309a2f..6a0b3c05 100644 --- a/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/Cargo.toml +++ b/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/Cargo.toml @@ -4,12 +4,13 @@ version = "0.1.0" edition = "2024" [lib] -crate-type = ["cdylib"] +crate-type = ["rlib"] [dependencies] +cre_wasm_exports = { path = "../cre_wasm_exports" } javy-plugin-api = "6.0.0" javy = "7.0.0" serde_json = "1.0" base64 = "0.21" rand = "0.8" -rand_chacha = "0.3" \ No newline at end of file +rand_chacha = "0.3" diff --git a/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/rust-toolchain.toml b/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/rust-toolchain.toml index dbac1f6c..6fcb46cc 100644 --- a/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/rust-toolchain.toml +++ b/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/rust-toolchain.toml @@ -1,5 +1,3 @@ -# Pin rustc so Docker and any host `cargo build` for this crate stay aligned with CI expectations. -# Edition 2024 requires Rust 1.85+. [toolchain] -channel = "1.85.0" +channel = "1.87.0" targets = ["wasm32-wasip1"] diff --git a/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/src/lib.rs b/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/src/lib.rs index 97d312b2..1c1faefe 100644 --- a/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/src/lib.rs +++ b/packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/src/lib.rs @@ -1,3 +1,4 @@ +use cre_wasm_exports::{__clear_registry, extend_wasm_exports}; use javy_plugin_api::{ import_namespace, javy::{Runtime, quickjs::prelude::*}, @@ -89,185 +90,174 @@ impl<'js> FromJs<'js> for ArgBytes { } } -fn config() -> Config { +pub fn config() -> Config { let mut config = Config::default(); config.event_loop(true).text_encoding(true).promise(true); config } -fn modify_runtime(runtime: Runtime) -> Runtime { +/// Applies CRE plugin globals and host bindings. Used by the default plugin build and by generated host crates that add `--cre-exports` extensions. +/// +/// Duplicate export names are caught eagerly by `extend_wasm_exports`. +pub fn modify_runtime(runtime: Runtime) -> Runtime { + __clear_registry(); runtime.context().with(|ctx| { RANDOM_GENERATORS.get_or_init(|| Mutex::new(HashMap::new())); - // callCapability(data: Uint8Array | ArrayBuffer | Base64 string) -> i64 - ctx.globals() - .set( - "callCapability", - Func::from(|_ctx: Ctx<'_>, data: ArgBytes| { - let req = data.0; - let rc = unsafe { call_capability(req.as_ptr(), req.len() as i32) }; - Ok::(rc) - }), - ) - .expect("failed to set global function 'callCapability'"); - - ctx.globals() - .set( - "awaitCapabilities", - Func::from(|ctx: Ctx<'_>, req: ArgBytes, max_len: i32| { - if max_len < 0 { - return Err(Exception::throw_range(&ctx, "maxLen < 0")); - } - let req_bytes = req.0; - let mut buf = vec![0u8; max_len as usize]; - - let n = unsafe { - await_capabilities( - req_bytes.as_ptr(), - req_bytes.len() as i32, - buf.as_mut_ptr(), - max_len, - ) - }; - if n < 0 { - let error_len = (-n) as usize; - let error_msg = - String::from_utf8_lossy(&buf[..error_len.min(max_len as usize)]) - .into_owned(); - let error_msg_static: &'static str = Box::leak(error_msg.into_boxed_str()); - return Err(Error::new_into_js("Error", error_msg_static)); - } - if n > max_len as i64 { - return Err(Error::new_into_js( - "Error", - "await_capabilities: host returned length exceeding buffer capacity", - )); - } - - let out = &buf[..n as usize]; - Ok::, Error>(out.to_vec()) - }), - ) - .expect("failed to set global function 'awaitCapabilities'"); - - ctx.globals() - .set( - "getSecrets", - Func::from(|ctx: Ctx<'_>, req: ArgBytes, max_len: i32| { - if max_len < 0 { - return Err(Exception::throw_range(&ctx, "maxLen < 0")); - } - let req_bytes = req.0; - let mut buf = vec![0u8; max_len as usize]; - - let n = unsafe { - get_secrets( - req_bytes.as_ptr(), - req_bytes.len() as i32, - buf.as_mut_ptr(), - max_len, - ) - }; - if n < 0 { - return Err(Error::new_into_js("Error", "get_secrets failed")); - } - if n > max_len as i64 { - return Err(Error::new_into_js( - "Error", - "get_secrets: host returned length exceeding buffer capacity", - )); - } - - let out = &buf[..n as usize]; - Ok::, Error>(out.to_vec()) - }), - ) - .expect("failed to set global function 'getSecrets'"); - - ctx.globals() - .set( - "awaitSecrets", - Func::from(|ctx: Ctx<'_>, req: ArgBytes, max_len: i32| { - if max_len < 0 { - return Err(Exception::throw_range(&ctx, "maxLen < 0")); - } - let req_bytes = req.0; - let mut buf = vec![0u8; max_len as usize]; - - let n = unsafe { - await_secrets( - req_bytes.as_ptr(), - req_bytes.len() as i32, - buf.as_mut_ptr(), - max_len, - ) - }; - if n < 0 { - return Err(Error::new_into_js("Error", "await_secrets failed")); - } - if n > max_len as i64 { - return Err(Error::new_into_js( - "Error", - "await_secrets: host returned length exceeding buffer capacity", - )); - } - - let out = &buf[..n as usize]; - Ok::, Error>(out.to_vec()) - }), - ) - .expect("failed to set global function 'awaitSecrets'"); - - // log(message: string) - ctx.globals() - .set( - "log", - Func::from(|message: String| { - let bytes = message.as_bytes(); - unsafe { log(bytes.as_ptr(), bytes.len() as i32) }; - }), - ) - .expect("failed to set global function 'log'"); - - // sendResponse(data: Uint8Array | ArrayBuffer | Base64 string) -> i32 (exits on rc==0) - ctx.globals() - .set( - "sendResponse", - Func::from(|_ctx: Ctx<'_>, data: ArgBytes| { - let bytes = data.0; - let rc = unsafe { send_response(bytes.as_ptr(), bytes.len() as i32) }; - if rc == 0 { - std::process::exit(0); - } - Ok::(rc) - }), - ) - .expect("failed to set global function 'sendResponse'"); - - // switchModes(mode: number) - ctx.globals() - .set( - "switchModes", - Func::from(|mode: i32| { - *CURRENT_MODE - .lock() - .expect("failed to lock CURRENT_MODE mutex in switchModes") = mode; - unsafe { switch_modes(mode) }; - }), - ) - .expect("failed to set global function 'switchModes'"); - - // versionV2(): void - ctx.globals() - .set( - "versionV2", - Func::from(|| { - unsafe { version_v2_typescript() }; - }), - ) - .expect("failed to set global function 'versionV2'"); + extend_wasm_exports( + &ctx, + "callCapability", + Func::from(|_ctx: Ctx<'_>, data: ArgBytes| { + let req = data.0; + let rc = unsafe { call_capability(req.as_ptr(), req.len() as i32) }; + Ok::(rc) + }), + ); + + extend_wasm_exports( + &ctx, + "awaitCapabilities", + Func::from(|ctx: Ctx<'_>, req: ArgBytes, max_len: i32| { + if max_len < 0 { + return Err(Exception::throw_range(&ctx, "maxLen < 0")); + } + let req_bytes = req.0; + let mut buf = vec![0u8; max_len as usize]; + + let n = unsafe { + await_capabilities( + req_bytes.as_ptr(), + req_bytes.len() as i32, + buf.as_mut_ptr(), + max_len, + ) + }; + if n < 0 { + let error_len = (-n) as usize; + let error_msg = + String::from_utf8_lossy(&buf[..error_len.min(max_len as usize)]).into_owned(); + let error_msg_static: &'static str = Box::leak(error_msg.into_boxed_str()); + return Err(Error::new_into_js("Error", error_msg_static)); + } + if n > max_len as i64 { + return Err(Error::new_into_js( + "Error", + "await_capabilities: host returned length exceeding buffer capacity", + )); + } + + let out = &buf[..n as usize]; + Ok::, Error>(out.to_vec()) + }), + ); + + extend_wasm_exports( + &ctx, + "getSecrets", + Func::from(|ctx: Ctx<'_>, req: ArgBytes, max_len: i32| { + if max_len < 0 { + return Err(Exception::throw_range(&ctx, "maxLen < 0")); + } + let req_bytes = req.0; + let mut buf = vec![0u8; max_len as usize]; + + let n = unsafe { + get_secrets( + req_bytes.as_ptr(), + req_bytes.len() as i32, + buf.as_mut_ptr(), + max_len, + ) + }; + if n < 0 { + return Err(Error::new_into_js("Error", "get_secrets failed")); + } + if n > max_len as i64 { + return Err(Error::new_into_js( + "Error", + "get_secrets: host returned length exceeding buffer capacity", + )); + } + + let out = &buf[..n as usize]; + Ok::, Error>(out.to_vec()) + }), + ); + + extend_wasm_exports( + &ctx, + "awaitSecrets", + Func::from(|ctx: Ctx<'_>, req: ArgBytes, max_len: i32| { + if max_len < 0 { + return Err(Exception::throw_range(&ctx, "maxLen < 0")); + } + let req_bytes = req.0; + let mut buf = vec![0u8; max_len as usize]; + + let n = unsafe { + await_secrets( + req_bytes.as_ptr(), + req_bytes.len() as i32, + buf.as_mut_ptr(), + max_len, + ) + }; + if n < 0 { + return Err(Error::new_into_js("Error", "await_secrets failed")); + } + if n > max_len as i64 { + return Err(Error::new_into_js( + "Error", + "await_secrets: host returned length exceeding buffer capacity", + )); + } + + let out = &buf[..n as usize]; + Ok::, Error>(out.to_vec()) + }), + ); + + extend_wasm_exports( + &ctx, + "log", + Func::from(|message: String| { + let bytes = message.as_bytes(); + unsafe { log(bytes.as_ptr(), bytes.len() as i32) }; + }), + ); + + extend_wasm_exports( + &ctx, + "sendResponse", + Func::from(|_ctx: Ctx<'_>, data: ArgBytes| { + let bytes = data.0; + let rc = unsafe { send_response(bytes.as_ptr(), bytes.len() as i32) }; + if rc == 0 { + std::process::exit(0); + } + Ok::(rc) + }), + ); + + extend_wasm_exports( + &ctx, + "switchModes", + Func::from(|mode: i32| { + *CURRENT_MODE + .lock() + .expect("failed to lock CURRENT_MODE mutex in switchModes") = mode; + unsafe { switch_modes(mode) }; + }), + ); + + extend_wasm_exports( + &ctx, + "versionV2", + Func::from(|| { + unsafe { version_v2_typescript() }; + }), + ); - // Override Math.random to use mode-based seeded generators let math_value = ctx .globals() .get::<_, Value>("Math") @@ -301,51 +291,43 @@ fn modify_runtime(runtime: Runtime) -> Runtime { ) .expect("failed to set 'Math.random' override"); - // getWasiArgs(): string (JSON array) - ctx.globals() - .set( - "getWasiArgs", - Func::from(|_ctx: Ctx<'_>| -> Result { - let args: Vec = env::args().collect(); - let args_json = serde_json::to_string(&args).map_err(|_| { - Error::new_into_js("Error", "Failed to serialize args to JSON") - })?; - Ok(args_json) - }), - ) - .expect("failed to set global function 'getWasiArgs'"); - - // now(): number (Unix timestamp in milliseconds) - ctx.globals() - .set( - "now", - Func::from(|_ctx: Ctx<'_>| -> Result { - let mut buffer = vec![0u8; 8]; - let rc = unsafe { now(buffer.as_mut_ptr()) }; - - if rc != 0 { - return Err(Error::new_into_js( - "Error", - "now() returned non-zero status", - )); - } - - let nanoseconds = u64::from_le_bytes([ - buffer[0], buffer[1], buffer[2], buffer[3], buffer[4], buffer[5], - buffer[6], buffer[7], - ]); - - let milliseconds = nanoseconds / 1_000_000; - Ok(milliseconds as f64) - }), - ) - .expect("failed to set global function 'now'"); + extend_wasm_exports( + &ctx, + "getWasiArgs", + Func::from(|_ctx: Ctx<'_>| -> Result { + let args: Vec = env::args().collect(); + let args_json = serde_json::to_string(&args).map_err(|_| { + Error::new_into_js("Error", "Failed to serialize args to JSON") + })?; + Ok(args_json) + }), + ); + + extend_wasm_exports( + &ctx, + "now", + Func::from(|_ctx: Ctx<'_>| -> Result { + let mut buffer = vec![0u8; 8]; + let rc = unsafe { now(buffer.as_mut_ptr()) }; + + if rc != 0 { + return Err(Error::new_into_js( + "Error", + "now() returned non-zero status", + )); + } + + let nanoseconds = u64::from_le_bytes([ + buffer[0], buffer[1], buffer[2], buffer[3], buffer[4], buffer[5], buffer[6], + buffer[7], + ]); + + let milliseconds = nanoseconds / 1_000_000; + Ok(milliseconds as f64) + }), + ); }); runtime } -#[unsafe(export_name = "initialize-runtime")] -fn initialize_runtime() { - javy_plugin_api::initialize_runtime(config, modify_runtime).unwrap(); -} diff --git a/packages/cre-sdk/bin/cre-compile.ts b/packages/cre-sdk/bin/cre-compile.ts index b2b893be..37003afd 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 { parseCompileFlags } from "@chainlink/cre-sdk-javy-plugin/scripts/parse-compile-flags"; import { parseCompileCliArgs, skipTypeChecksFlag, @@ -9,26 +10,34 @@ import { WorkflowTypecheckError } from "../scripts/src/typecheck-workflow"; import { WorkflowRuntimeCompatibilityError } from "../scripts/src/validate-workflow-runtime-compat"; const main = async () => { + const cliArgs = process.argv.slice(2); + const { creExports, plugin, rest } = parseCompileFlags(cliArgs); + let inputPath: string | undefined; let outputPathArg: string | undefined; let skipTypeChecks = false; try { - const parsed = parseCompileCliArgs(process.argv.slice(2)); + const parsed = parseCompileCliArgs(rest); inputPath = parsed.inputPath; outputPathArg = parsed.outputPath; skipTypeChecks = parsed.skipTypeChecks; } catch (error) { console.error(error instanceof Error ? error.message : error); console.error( - `Usage: cre-compile [path/to/output.wasm] [${skipTypeChecksFlag}]`, + `Usage: cre-compile [--plugin ] [--cre-exports ]... [path/to/output.wasm] [${skipTypeChecksFlag}]`, ); process.exit(1); } + if (plugin !== null && creExports.length > 0) { + console.error("❌ Error: --plugin and --cre-exports are mutually exclusive."); + process.exit(1); + } + if (!inputPath) { console.error( - `Usage: cre-compile [path/to/output.wasm] [${skipTypeChecksFlag}]`, + `Usage: cre-compile [--plugin ] [--cre-exports ]... [path/to/output.wasm] [${skipTypeChecksFlag}]`, ); console.error("Examples:"); console.error(" cre-compile src/standard_tests/secrets/test.ts"); @@ -41,10 +50,13 @@ const main = async () => { process.exit(1); } - await compileWorkflow(inputPath, outputPathArg, { skipTypeChecks }); + await compileWorkflow(inputPath, outputPathArg, { + skipTypeChecks, + creExports: creExports.length > 0 ? creExports : undefined, + plugin, + }); }; -// CLI entry point main().catch((e) => { if ( e instanceof WorkflowRuntimeCompatibilityError || diff --git a/packages/cre-sdk/scripts/run-standard-tests.sh b/packages/cre-sdk/scripts/run-standard-tests.sh index 2fe9360b..c29ab037 100755 --- a/packages/cre-sdk/scripts/run-standard-tests.sh +++ b/packages/cre-sdk/scripts/run-standard-tests.sh @@ -8,9 +8,9 @@ set -e # Create dist test workflow folder mkdir -p ./dist/workflows/standard_tests -# Build javy wasm -if [ ! -f ../cre-sdk-javy-plugin/dist/javy_chainlink_sdk.wasm ]; then - echo "Error: javy_chainlink_sdk.wasm not found" +# Plugin package must be built (initialized plugin is what we ship). +if [ ! -f ../cre-sdk-javy-plugin/dist/javy-chainlink-sdk.plugin.wasm ]; then + echo "Error: javy-chainlink-sdk.plugin.wasm not found (run cre-sdk-javy-plugin build first)" exit 1 fi diff --git a/packages/cre-sdk/scripts/src/compile-to-wasm.ts b/packages/cre-sdk/scripts/src/compile-to-wasm.ts index e0df8bf7..75cbfc6e 100644 --- a/packages/cre-sdk/scripts/src/compile-to-wasm.ts +++ b/packages/cre-sdk/scripts/src/compile-to-wasm.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdir } from 'node:fs/promises' import path from 'node:path' +import { parseCompileFlags } from '@chainlink/cre-sdk-javy-plugin/scripts/parse-compile-flags' function runBun(args: string[]): Promise { return new Promise((resolve, reject) => { @@ -19,20 +20,29 @@ function runBun(args: string[]): Promise { const isJsFile = (p: string) => ['.js', '.mjs', '.cjs'].includes(path.extname(p).toLowerCase()) -export const main = async (inputFile?: string, outputFile?: string) => { +export const main = async ( + inputFile?: string, + outputFile?: string, + creExportsPaths?: string[], + pluginPath?: string | null, +) => { const cliArgs = process.argv.slice(3) + const { creExports: cliCreExports, plugin: cliPlugin, rest: cliRest } = parseCompileFlags(cliArgs) - // Resolve input/output from params or CLI - const inputPath = inputFile ?? cliArgs[0] - const outputPathArg = outputFile ?? cliArgs[1] + const inputPath = inputFile ?? cliRest[0] + const outputPathArg = outputFile ?? cliRest[1] + const creExports = creExportsPaths ?? cliCreExports + const plugin = pluginPath !== undefined ? pluginPath : cliPlugin + + if (plugin !== null && plugin !== undefined && creExports.length > 0) { + console.error('❌ Error: --plugin and --cre-exports are mutually exclusive.') + process.exit(1) + } if (!inputPath) { console.error( 'Usage: bun compile:js-to-wasm [path/to/output.wasm]', ) - console.error('Examples:') - console.error(' bun compile:js-to-wasm ./build/workflows/test.js') - console.error(' bun compile:js-to-wasm ./build/workflows/test.mjs ./artifacts/test.wasm') process.exit(1) } @@ -47,31 +57,41 @@ export const main = async (inputFile?: string, outputFile?: string) => { process.exit(1) } - // Default output = same dir, same basename, .wasm extension const defaultOut = path.join( path.dirname(resolvedInput), path.basename(resolvedInput).replace(/\.(m|c)?js$/i, '.wasm'), ) const resolvedOutput = outputPathArg ? path.resolve(outputPathArg) : defaultOut - // Ensure output directory exists await mkdir(path.dirname(resolvedOutput), { recursive: true }) - console.info(`🔨 Compiling to WASM`) + console.info('🔨 Compiling to WASM') console.info(`📁 Input: ${resolvedInput}`) console.info(`🎯 Output: ${resolvedOutput}`) - // Prefer the sibling @chainlink/cre-sdk-javy-plugin install (same as monorepo layout). - // Bun's shell `$` template can throw EINVAL on some Linux/arm64 Docker setups; use spawn. - const scriptDir = import.meta.dir - const compilerPath = path.resolve( - scriptDir, - '../../../cre-sdk-javy-plugin/bin/compile-workflow.ts', - ) + const compileArgs: string[] = [] + if (plugin != null && plugin !== '') { + compileArgs.push('--plugin', path.resolve(plugin)) + } else { + compileArgs.push(...creExports.flatMap((p) => ['--cre-exports', path.resolve(p)])) + } + compileArgs.push(resolvedInput, resolvedOutput) + + let javyPluginRoot: string + if (process.env.CRE_SDK_JAVY_PLUGIN_HOME) { + javyPluginRoot = path.resolve(process.env.CRE_SDK_JAVY_PLUGIN_HOME) + } else { + try { + javyPluginRoot = path.dirname(require.resolve('@chainlink/cre-sdk-javy-plugin/package.json')) + } catch { + javyPluginRoot = path.resolve(import.meta.dir, '../../../cre-sdk-javy-plugin') + } + } + const compilerPath = path.join(javyPluginRoot, 'bin/compile-workflow.ts') if (existsSync(compilerPath)) { - await runBun(['--bun', compilerPath, resolvedInput, resolvedOutput]) + await runBun(['--bun', compilerPath, ...compileArgs]) } else { - await runBun(['x', 'cre-compile-workflow', resolvedInput, resolvedOutput]) + await runBun(['x', 'cre-compile-workflow', ...compileArgs]) } console.info(`✅ Compiled: ${resolvedOutput}`) diff --git a/packages/cre-sdk/scripts/src/compile-workflow.ts b/packages/cre-sdk/scripts/src/compile-workflow.ts index 2793afc3..b3edf402 100644 --- a/packages/cre-sdk/scripts/src/compile-workflow.ts +++ b/packages/cre-sdk/scripts/src/compile-workflow.ts @@ -1,17 +1,20 @@ import { existsSync } from 'node:fs' import { mkdir } from 'node:fs/promises' import path from 'node:path' +import { parseCompileFlags } from '@chainlink/cre-sdk-javy-plugin/scripts/parse-compile-flags' import { parseCompileCliArgs, skipTypeChecksFlag } from './compile-cli-args' import { main as compileToJs } from './compile-to-js' import { main as compileToWasm } from './compile-to-wasm' type CompileWorkflowOptions = { skipTypeChecks?: boolean + creExports?: string[] + plugin?: string | null } const printUsage = () => { console.error( - `Usage: bun compile:workflow [path/to/output.wasm] [${skipTypeChecksFlag}]`, + `Usage: bun compile:workflow [--plugin ] [--cre-exports ]... [path/to/output.wasm] [${skipTypeChecksFlag}]`, ) console.error('Examples:') console.error(' bun compile:workflow src/standard_tests/secrets/test.ts') @@ -31,17 +34,25 @@ export const main = async ( let parsedInputPath: string | undefined let parsedOutputPath: string | undefined let parsedSkipTypeChecks = false + let parsedCreExports: string[] = [] + let parsedPlugin: string | null = null - if (inputFile != null || outputWasmFile != null || options?.skipTypeChecks != null) { + if (inputFile != null || outputWasmFile != null || options != null) { parsedInputPath = inputFile parsedOutputPath = outputWasmFile parsedSkipTypeChecks = options?.skipTypeChecks ?? false + parsedCreExports = options?.creExports ?? [] + parsedPlugin = options?.plugin !== undefined ? options.plugin : null } else { try { - const parsed = parseCompileCliArgs(process.argv.slice(3)) + const cliArgs = process.argv.slice(3) + const { creExports, plugin, rest } = parseCompileFlags(cliArgs) + const parsed = parseCompileCliArgs(rest) parsedInputPath = parsed.inputPath parsedOutputPath = parsed.outputPath parsedSkipTypeChecks = parsed.skipTypeChecks + parsedCreExports = creExports + parsedPlugin = plugin } catch (error) { console.error(error instanceof Error ? error.message : error) printUsage() @@ -52,6 +63,11 @@ export const main = async ( const inputPath = parsedInputPath const outputPathArg = parsedOutputPath + if (parsedPlugin != null && parsedPlugin !== '' && parsedCreExports.length > 0) { + console.error('❌ Error: --plugin and --cre-exports are mutually exclusive.') + process.exit(1) + } + if (!inputPath) { printUsage() process.exit(1) @@ -63,33 +79,36 @@ export const main = async ( process.exit(1) } - // Default final output = same dir, same basename, .wasm const defaultWasmOut = path.join( path.dirname(resolvedInput), path.basename(resolvedInput).replace(/\.[^.]+$/, '.wasm'), ) const resolvedWasmOutput = outputPathArg ? path.resolve(outputPathArg) : defaultWasmOut - - // Put the intermediate JS next to the final wasm (so custom outputs stay together) const resolvedJsOutput = resolvedWasmOutput.replace(/\.wasm$/i, '.js') - // Ensure directories exist (handles both intermediate JS dir and wasm dir) await mkdir(path.dirname(resolvedJsOutput), { recursive: true }) - console.info(`🚀 Compiling workflow`) - console.info(`📁 Input: ${resolvedInput}\n`) + console.info('🚀 Compiling workflow') + console.info(`📁 Input: ${resolvedInput}`) + console.info(`🧪 JS out: ${resolvedJsOutput}`) + console.info(`🎯 WASM out:${resolvedWasmOutput}\n`) if (parsedSkipTypeChecks) { console.info(`⚠️ Skipping TypeScript checks (${skipTypeChecksFlag})`) } - // Step 1: TS/JS → JS (bundled) console.info('📦 Step 1: Compiling JS...') await compileToJs(resolvedInput, resolvedJsOutput, { skipTypeChecks: parsedSkipTypeChecks }) - // Step 2: JS → WASM console.info('\n🔨 Step 2: Compiling to WASM...') - await compileToWasm(resolvedJsOutput, resolvedWasmOutput) + await compileToWasm(resolvedJsOutput, resolvedWasmOutput, parsedCreExports, parsedPlugin) console.info(`\n✅ Workflow built: ${resolvedWasmOutput}`) return resolvedWasmOutput } + +if (import.meta.main) { + main().catch((e) => { + console.error(e) + process.exit(1) + }) +} diff --git a/scripts/e2e/simulate-rust-inject.sh b/scripts/e2e/simulate-rust-inject.sh new file mode 100755 index 00000000..32ab5e97 --- /dev/null +++ b/scripts/e2e/simulate-rust-inject.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +# E2E test for rust-inject examples — builds and validates using workspace packages: +# +# Step 1: Build lib_alpha → dist/alpha.plugin.wasm +# Step 2: Build prebuilt-plugin and source-extensions in parallel +# Step 3: Simulate and validate outputs + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +PACKAGES_DIR="$ROOT_DIR/packages" +OUTPUT_5A="$(mktemp)" +OUTPUT_5B="$(mktemp)" + +cleanup() { rm -f "$OUTPUT_5A" "$OUTPUT_5B"; } +trap cleanup EXIT + +# ── Step 1: Build lib_alpha ────────────────────────────────────────────────── +echo "=== Step 1: Building cre-rust-inject-alpha ===" +make -C "$PACKAGES_DIR/cre-rust-inject-alpha" build + +# ── Step 2: Build prebuilt-plugin and source-extensions in parallel ────────── +echo "" +echo "=== Step 2: Building prebuilt-plugin and source-extensions in parallel ===" +make -C "$PACKAGES_DIR/cre-rust-prebuilt-plugin-example" build & +PID_5A=$! +make -C "$PACKAGES_DIR/cre-rust-source-extensions-example" build & +PID_5B=$! + +FAIL=0 +wait $PID_5A || FAIL=1 +wait $PID_5B || FAIL=1 +if [ $FAIL -ne 0 ]; then + echo "❌ One or more builds failed" + exit 1 +fi + +# ── Step 3a: Simulate prebuilt-plugin ──────────────────────────────────────── +echo "" +echo "=== Step 3a: Simulating prebuilt-plugin ===" +cd "$PACKAGES_DIR/cre-rust-prebuilt-plugin-example" +cp -n "$PACKAGES_DIR/cre-sdk-examples/.env.example" .env 2>/dev/null || true + +cre workflow simulate . \ + --non-interactive \ + --trigger-index 0 \ + > "$OUTPUT_5A" 2>&1 || true +cat "$OUTPUT_5A" + +echo "" +echo "Validating prebuilt-plugin output..." +if ! grep -q "Hello from alpha" "$OUTPUT_5A"; then + echo "❌ ERROR: Expected 'Hello from alpha' not found" + exit 1 +fi +echo "✓ Found: Hello from alpha" + +# ── Step 3b: Simulate source-extensions ────────────────────────────────────── +echo "" +echo "=== Step 3b: Simulating source-extensions ===" +cd "$PACKAGES_DIR/cre-rust-source-extensions-example" +cp -n "$PACKAGES_DIR/cre-sdk-examples/.env.example" .env 2>/dev/null || true + +cre workflow simulate . \ + --non-interactive \ + --trigger-index 0 \ + > "$OUTPUT_5B" 2>&1 || true +cat "$OUTPUT_5B" + +echo "" +echo "Validating source-extensions output..." +if ! grep -q "Hello from alpha" "$OUTPUT_5B"; then + echo "❌ ERROR: Expected 'Hello from alpha' not found" + exit 1 +fi +if ! grep -q "Hello from beta" "$OUTPUT_5B"; then + echo "❌ ERROR: Expected 'Hello from beta' not found" + exit 1 +fi +echo "✓ Found: Hello from alpha" +echo "✓ Found: Hello from beta" + +echo "" +echo "✅ All E2E validation checks passed!"