diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..fcf7d04c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +**/*_test.go +**/testdata/ +**/test/ +docs/ +bin/ +cre +cre-admin +*.test +.env +.env.* +node_modules/ +**/.vite/ +c2w/ diff --git a/.gitignore b/.gitignore index 75064238..ac5f0263 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ cre-admin dist *.wasm *.wasm.br +!web/wasm-simulate-demo/assets/manifest.json +!web/wasm-simulate-demo/assets/cre-environments.json +!web/wasm-simulate-demo/assets/headless-simulation.sample.json +!web/wasm-simulate-demo/assets/.gitkeep # env files *.env diff --git a/go.mod b/go.mod index a1b4b70d..ba6c2dcc 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260323124644-faea187e6997 github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.5 github.com/smartcontractkit/chainlink/deployment v0.0.0-20260521170940-67f9a4b233f8 - github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0.0.20260521170940-67f9a4b233f8 + github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0 github.com/smartcontractkit/cre-sdk-go v1.11.0 github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm v1.0.0-beta.12 github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana v0.1.0-beta.1 @@ -330,6 +330,7 @@ require ( github.com/smartcontractkit/chainlink-ton v1.0.5-0.20260520103847-15ca4de9dba9 // indirect github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20260408092456-3c6369888d4a // indirect github.com/smartcontractkit/cld-changesets v0.4.0 // indirect + github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.0.0-beta.0 // indirect github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad // indirect github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 // indirect github.com/smartcontractkit/libocr v0.0.0-20260508200755-99940c85383c // indirect diff --git a/go.sum b/go.sum index 798e43aa..08b8ade1 100644 --- a/go.sum +++ b/go.sum @@ -1071,6 +1071,7 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prometheus/prometheus v0.304.2 h1:HhjbaAwet87x8Be19PFI/5W96UMubGy3zt24kayEuh4= github.com/prometheus/prometheus v0.311.3 h1:3IrVxQv6v5i/ZCGi6OrYeBhtCwaPTn6Z3DYruXoYm3M= github.com/prometheus/prometheus v0.311.3/go.mod h1:gjsCxTKtHO1Q8T9333u1s+lUR1OjPyM7ruuGH8RvVyo= github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= @@ -1234,6 +1235,8 @@ github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.202510141 github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20251014143056-a0c6328c91e9/go.mod h1:ea1LESxlSSOgc2zZBqf1RTkXTMthHaspdqUHd7W4lF0= github.com/smartcontractkit/chainlink/deployment v0.0.0-20260521170940-67f9a4b233f8 h1:xPDpOmxTlT2RW+pPUElGa0/y02V/MAHIPD8DEtEBLfE= github.com/smartcontractkit/chainlink/deployment v0.0.0-20260521170940-67f9a4b233f8/go.mod h1:WL7W/YQO5pQ1Nexm4lvd/SztM2OzbhaIhJKyrfMU8QQ= +github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0 h1:t30uKSMNtlj84nTH5qm2oNOGS6ff2RF8tSKlSaWCWgk= +github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0/go.mod h1:qIwJWlVQ9MxUFHfgQePZteAov/C+LIQ1LAhJ1abPBO0= github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0.0.20260521170940-67f9a4b233f8 h1:naZxYMHsgi+JVChySLtaqAfgRM3Az2+HpvIzWNFcPRo= github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0.0.20260521170940-67f9a4b233f8/go.mod h1:8Ppr/wQrc9GVwQow3Tw2JsNjNjqMhZTyU32cm5OFMzo= github.com/smartcontractkit/cld-changesets v0.4.0 h1:S6yNRj6FssyyKbxLHTbC9X9U4qsph17xiiBBT6DGyNE= diff --git a/scripts/build-wasm-demo.sh b/scripts/build-wasm-demo.sh new file mode 100755 index 00000000..48384ec7 --- /dev/null +++ b/scripts/build-wasm-demo.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "${ROOT}" +go run ./web/wasm-simulate-demo/tools/build_assets diff --git a/test/e2e_hello_world_wasm_build_simulate_test.go b/test/e2e_hello_world_wasm_build_simulate_test.go new file mode 100644 index 00000000..c4295dd3 --- /dev/null +++ b/test/e2e_hello_world_wasm_build_simulate_test.go @@ -0,0 +1,67 @@ +package test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func TestE2E_WorkflowBuildThenSimulateWithWasm_HelloWorldGo(t *testing.T) { + tempDir := t.TempDir() + projectName := "e2e-build-sim-wasm" + workflowName := "helloWorkflow" + projectRoot := filepath.Join(tempDir, projectName) + workflowDir := filepath.Join(projectRoot, workflowName) + // Simulate runs with cwd set to the workflow directory; --wasm is relative to that dir. + wasmPath := "binary.wasm" + + t.Setenv(settings.EthPrivateKeyEnvVar, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + t.Setenv(credentials.CreApiKeyVar, "test-api") + + gqlSrv := NewGraphQLMockServerGetOrganization(t) + defer gqlSrv.Close() + + scaffoldHelloWorldGoProject(t, projectRoot, workflowName) + + require.FileExists(t, filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName)) + require.DirExists(t, workflowDir) + require.FileExists(t, filepath.Join(workflowDir, "main.go")) + require.FileExists(t, filepath.Join(workflowDir, "workflow.go")) + + simulateArgs := []string{ + "workflow", "simulate", + workflowName, + "--project-root", projectRoot, + "--non-interactive", + "--trigger-index=0", + "--target=staging-settings", + } + + baselineOut := runCLI(t, projectRoot, simulateArgs...) + assertHelloWorldSimulationResult(t, baselineOut) + require.Contains(t, stripANSI(baselineOut), "Workflow compiled", + "baseline simulate should compile inline") + + buildOut := runCLI(t, projectRoot, "workflow", "build", workflowName) + require.FileExists(t, filepath.Join(workflowDir, "binary.wasm")) + info, err := os.Stat(filepath.Join(workflowDir, "binary.wasm")) + require.NoError(t, err) + require.Positive(t, info.Size()) + require.Contains(t, stripANSI(buildOut), "Build output written") + + wasmSimulateArgs := append([]string(nil), simulateArgs...) + wasmSimulateArgs = append(wasmSimulateArgs, "--wasm", wasmPath) + wasmOut := runCLI(t, projectRoot, wasmSimulateArgs...) + + cleanWasmOut := stripANSI(wasmOut) + require.Contains(t, cleanWasmOut, "Loaded WASM binary") + require.NotContains(t, cleanWasmOut, "Workflow compiled", + "--wasm should skip inline compilation") + assertHelloWorldSimulationResult(t, wasmOut) +} diff --git a/test/hello_world_scaffold.go b/test/hello_world_scaffold.go new file mode 100644 index 00000000..11361eb6 --- /dev/null +++ b/test/hello_world_scaffold.go @@ -0,0 +1,90 @@ +package test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/testutil" + "github.com/smartcontractkit/cre-cli/internal/templaterepo" +) + +// scaffoldHelloWorldGoProject lays out the built-in hello-world-go template with +// project/workflow settings and Go module deps (without cre init). +func scaffoldHelloWorldGoProject(t *testing.T, projectRoot, workflowName string) { + t.Helper() + + logger := testutil.NewTestLogger() + require.NoError(t, os.MkdirAll(projectRoot, 0755)) + require.NoError(t, templaterepo.ScaffoldBuiltIn(logger, "hello-world-go", projectRoot, workflowName)) + + projectYAML := `staging-settings: + rpcs: + - chain-name: ethereum-testnet-sepolia + url: https://sepolia.infura.io/v3 +production-settings: + rpcs: + - chain-name: ethereum-testnet-sepolia + url: https://sepolia.infura.io/v3 +` + workflowYAML := fmt.Sprintf(`staging-settings: + user-workflow: + workflow-name: "%s-staging" + workflow-artifacts: + workflow-path: "./main.go" + config-path: "./config.staging.json" + secrets-path: "../secrets.yaml" +production-settings: + user-workflow: + workflow-name: "%s-production" + workflow-artifacts: + workflow-path: "./main.go" + config-path: "./config.production.json" + secrets-path: "../secrets.yaml" +`, workflowName, workflowName) + + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName), + []byte(projectYAML), + 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, workflowName, constants.DefaultWorkflowSettingsFileName), + []byte(workflowYAML), + 0600, + )) + + initializeHelloWorldGoModule(t, projectRoot) +} + +func initializeHelloWorldGoModule(t *testing.T, projectRoot string) { + t.Helper() + + moduleName := filepath.Base(projectRoot) + cmd := exec.Command("go", "mod", "init", moduleName) + cmd.Dir = projectRoot + require.NoError(t, cmd.Run(), "go mod init failed") + + // cron@v1.3.0 does not resolve on the public module proxy (pulls cre-sdk-go@v1.3.0). + // v1.0.0-beta.0 matches test/test_project/blank_workflow and works with SdkVersion. + deps := []string{ + "github.com/smartcontractkit/cre-sdk-go@" + constants.SdkVersion, + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron@v1.0.0-beta.0", + } + for _, dep := range deps { + getCmd := exec.Command("go", "get", dep) + getCmd.Dir = projectRoot + out, err := getCmd.CombinedOutput() + require.NoError(t, err, "go get %s failed:\n%s", dep, out) + } + + tidyCmd := exec.Command("go", "mod", "tidy") + tidyCmd.Dir = projectRoot + out, err := tidyCmd.CombinedOutput() + require.NoError(t, err, "go mod tidy failed:\n%s", out) +} diff --git a/test/simulate_output.go b/test/simulate_output.go new file mode 100644 index 00000000..40ad596a --- /dev/null +++ b/test/simulate_output.go @@ -0,0 +1,84 @@ +package test + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const simulationResultMarker = "Workflow Simulation Result" + +var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// stripANSI removes ANSI escape codes from CLI output. +func stripANSI(s string) string { + return ansiRE.ReplaceAllString(s, "") +} + +type helloWorldExecutionResult struct { + Result string `json:"Result"` +} + +// extractSimulationResultJSON returns the JSON object printed after the simulation result marker. +func extractSimulationResultJSON(out string) (string, error) { + idx := strings.Index(out, simulationResultMarker) + if idx < 0 { + return "", fmt.Errorf("%q not found in output", simulationResultMarker) + } + + rest := out[idx+len(simulationResultMarker):] + start := strings.Index(rest, "{") + if start < 0 { + return "", fmt.Errorf("no JSON object after %q", simulationResultMarker) + } + + dec := json.NewDecoder(strings.NewReader(rest[start:])) + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + return "", fmt.Errorf("decode simulation result JSON: %w", err) + } + return string(raw), nil +} + +func parseHelloWorldSimulationResult(t *testing.T, out string) helloWorldExecutionResult { + t.Helper() + clean := stripANSI(out) + jsonStr, err := extractSimulationResultJSON(clean) + require.NoError(t, err, "output:\n%s", clean) + + var result helloWorldExecutionResult + require.NoError(t, json.Unmarshal([]byte(jsonStr), &result), "json: %s", jsonStr) + return result +} + +func assertHelloWorldSimulationResult(t *testing.T, out string) helloWorldExecutionResult { + t.Helper() + clean := stripANSI(out) + require.Contains(t, clean, simulationResultMarker, "output:\n%s", clean) + + result := parseHelloWorldSimulationResult(t, out) + require.Contains(t, result.Result, "Fired at", "Result field should contain cron timestamp prefix") + return result +} + +// runCLI runs the cre CLI from dir and returns combined stdout+stderr (for output parsing). +func runCLI(t *testing.T, dir string, args ...string) string { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, args...) + cmd.Dir = dir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "cre %s failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + strings.Join(args, " "), + stdout.String(), + stderr.String()) + return stdout.String() + stderr.String() +} diff --git a/web/wasm-simulate-demo/Dockerfile b/web/wasm-simulate-demo/Dockerfile new file mode 100644 index 00000000..46b67892 --- /dev/null +++ b/web/wasm-simulate-demo/Dockerfile @@ -0,0 +1,48 @@ +# Build hello-world workflow WASM, then serve the static demo with assets baked in. +FROM golang:1.26-bookworm AS wasm-builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go run ./web/wasm-simulate-demo/tools/build_assets + +# Verify assets were produced (fail the image build if WASM is missing). +RUN test -f /src/web/wasm-simulate-demo/assets/hello-world.wasm \ + && test -f /src/web/wasm-simulate-demo/assets/manifest.json + +FROM nginx:1.27-alpine AS runtime + +COPY web/wasm-simulate-demo/nginx.conf /etc/nginx/conf.d/default.conf + +# Static UI (vendor, JS, CSS) from context. +COPY web/wasm-simulate-demo/index.html \ + web/wasm-simulate-demo/wallet.html \ + web/wasm-simulate-demo/app.js \ + web/wasm-simulate-demo/wallet-app.js \ + web/wasm-simulate-demo/cre-host.js \ + web/wasm-simulate-demo/wasi-stubs.js \ + web/wasm-simulate-demo/payload.js \ + web/wasm-simulate-demo/styles.css \ + /usr/share/nginx/html/ + +COPY web/wasm-simulate-demo/lib/ /usr/share/nginx/html/lib/ + +COPY web/wasm-simulate-demo/assets/cre-environments.json \ + /usr/share/nginx/html/assets/ + +COPY web/wasm-simulate-demo/vendor/ /usr/share/nginx/html/vendor/ + +COPY web/wasm-simulate-demo/assets/headless-simulation.sample.json \ + /usr/share/nginx/html/assets/ + +# Prebuilt WASM + manifest from the Go builder stage. +COPY --from=wasm-builder /src/web/wasm-simulate-demo/assets/ /usr/share/nginx/html/assets/ + +EXPOSE 80 + +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/assets/manifest.json || exit 1 diff --git a/web/wasm-simulate-demo/README.md b/web/wasm-simulate-demo/README.md new file mode 100644 index 00000000..443ac75d --- /dev/null +++ b/web/wasm-simulate-demo/README.md @@ -0,0 +1,60 @@ +# Browser CRE workflow simulate demo + +Standalone page that runs the **hello-world-go** workflow WASM in the browser. A JavaScript **simulator host** implements the CRE `env` ABI (the same contract `cre workflow simulate` uses to drive guest WASM). No API key, GraphQL, or RPC. + +## Docker (recommended) + +From the **cre-cli repo root**: + +```bash +./web/wasm-simulate-demo/run.sh +# or: docker compose -f web/wasm-simulate-demo/docker-compose.yml up --build -d +``` + +Open [http://localhost:9090](http://localhost:9090) — the image compiles `hello-world.wasm` and bakes it into `/assets/`. The page **preloads** manifest + WASM on load. + +**Account & wallet** ([http://localhost:9090/wallet.html](http://localhost:9090/wallet.html)): + +- **Login** — OAuth popup (same redirect as `cre login`, port **53682** via `oauth-callback` service) +- **Connect MetaMask** — use extension instead of `CRE_ETH_PRIVATE_KEY` +- **Register wallet** — `cre account link-key` flow: GraphQL `initiateLinking` + MetaMask transaction +- **Publish workflow** — checks org access; full deploy still uses `cre workflow deploy` for artifact upload / on-chain register (MetaMask for each tx) + +OAuth callback must be reachable at `http://localhost:53682/callback` (started automatically by `docker compose`). + +**Environment** — toolbar **CRE_CLI_ENV** selector (default **STAGING**, same as `export CRE_CLI_ENV=STAGING` for the CLI). Changing it reloads the page and switches API/auth proxies. + +Port override: `WASM_DEMO_PORT=8787 ./web/wasm-simulate-demo/run.sh` + +### Page downloads instead of opening in Chrome? + +Chrome does that when the server sends `Content-Type: application/octet-stream` for HTML (common if nginx is missing `include mime.types`). Check: + +```bash +curl -sI http://localhost:9090/ | grep -i content-type +``` + +You want `Content-Type: text/html` (one header). If you see `application/octet-stream`, rebuild the demo image (`docker compose ... up --build`) and make sure nothing else is bound to port 8787. Do not open `index.html` via `file://` — use `http://localhost:8787/`. + +## Local build (without Docker) + +```bash +./scripts/build-wasm-demo.sh +python3 -m http.server 9090 --directory web/wasm-simulate-demo +``` + +Open [http://localhost:9090](http://localhost:9090) + +## Expected output + +Click **Run simulation**. The terminal shows subscribe + trigger phases and JSON like: + +```json +{ + "Result": "Fired at 2026-06-02T12:34:56.789Z" +} +``` + +## Architecture + +The Docker image runs `tools/build_assets` (Go `GOOS=wasip1` compile) then nginx serves static files with `assets/hello-world.wasm` and `assets/manifest.json` prelinked to `./assets/` URLs in the page. diff --git a/web/wasm-simulate-demo/app.js b/web/wasm-simulate-demo/app.js new file mode 100644 index 00000000..493735db --- /dev/null +++ b/web/wasm-simulate-demo/app.js @@ -0,0 +1,184 @@ +import { CreWorkflowHost } from "./cre-host.js"; +import { bindEnvSelector, getSelectedEnvName } from "./lib/cre-env.js"; + +const terminal = document.getElementById("terminal"); +const simulateBtn = document.getElementById("simulate-btn"); +const statusEl = document.getElementById("status"); +const wasmLink = document.getElementById("wasm-link"); +const manifestLink = document.getElementById("manifest-link"); +const simJsonLink = document.getElementById("sim-json-link"); +const fetchSimBtn = document.getElementById("fetch-sim-btn"); + +/** @type {{ manifest: object, wasmBytes: ArrayBuffer } | null} */ +let prelinked = null; + +/** @type {object | null} */ +let lastHeadlessJson = null; + +/** @type {string | null} */ +let lastSimBlobUrl = null; + +/** @type {string[]} */ +const terminalLines = []; + +/** @param {string} line @param {"dim" | "ok" | "err"} [kind] */ +function log(line, kind = "dim") { + terminalLines.push(line); + const row = document.createElement("div"); + row.className = "line line-" + kind; + row.textContent = line; + terminal.appendChild(row); + terminal.scrollTop = terminal.scrollHeight; +} + +function setStatus(text, busy = false) { + statusEl.textContent = text; + simulateBtn.disabled = busy || !prelinked; +} + +function revokeSimBlob() { + if (lastSimBlobUrl) { + URL.revokeObjectURL(lastSimBlobUrl); + lastSimBlobUrl = null; + } +} + +function publishHeadlessJson(doc) { + lastHeadlessJson = doc; + revokeSimBlob(); + const json = JSON.stringify(doc, null, 2); + const blob = new Blob([json], { type: "application/json" }); + lastSimBlobUrl = URL.createObjectURL(blob); + simJsonLink.href = lastSimBlobUrl; + simJsonLink.download = "headless-simulation.json"; + simJsonLink.classList.remove("disabled"); + simJsonLink.removeAttribute("aria-disabled"); + fetchSimBtn.disabled = false; +} + +function buildHeadlessDocument(manifest, wasmBytes, report) { + return { + mode: "browser-headless", + generatedAt: new Date().toISOString(), + workflowWasm: manifest.workflowWasm, + wasmBytes: wasmBytes.byteLength, + wasmUrl: manifest.workflowUrl || "./assets/" + manifest.workflowWasm, + manifestUrl: manifest.manifestUrl || "./assets/manifest.json", + workflowSimulationResult: report.trigger.workflowSimulationResult ?? null, + phases: { + subscribe: report.subscribe, + trigger: report.trigger, + }, + terminalLog: [...terminalLines], + }; +} + +async function loadManifest() { + const url = "./assets/manifest.json"; + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) { + throw new Error("Missing " + url); + } + return res.json(); +} + +async function loadWorkflowWasm(manifest) { + const url = manifest.workflowUrl || "./assets/" + manifest.workflowWasm; + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) { + throw new Error("Missing workflow WASM at " + url); + } + return res.arrayBuffer(); +} + +async function preloadAssets() { + setStatus("Loading prelinked WASM…", true); + const manifest = await loadManifest(); + const wasmBytes = await loadWorkflowWasm(manifest); + const expected = manifest.wasmBytes; + if (typeof expected === "number" && expected > 0 && wasmBytes.byteLength !== expected) { + throw new Error( + "WASM size mismatch: expected " + expected + " bytes, got " + wasmBytes.byteLength, + ); + } + prelinked = { manifest, wasmBytes }; + + const wasmUrl = manifest.workflowUrl || "./assets/" + manifest.workflowWasm; + wasmLink.href = wasmUrl; + wasmLink.download = manifest.workflowWasm; + manifestLink.href = manifest.manifestUrl || "./assets/manifest.json"; + + const mb = (wasmBytes.byteLength / (1024 * 1024)).toFixed(1); + setStatus("Ready — " + getSelectedEnvName() + " — " + manifest.workflowWasm + " (" + mb + " MB)"); +} + +simulateBtn.addEventListener("click", async () => { + terminal.textContent = ""; + terminalLines.length = 0; + setStatus("Simulating…", true); + simulateBtn.disabled = true; + + try { + if (!prelinked) { + await preloadAssets(); + } + const { manifest, wasmBytes } = prelinked; + log("=== cre workflow simulate (browser headless) ===", "dim"); + log("Guest: " + manifest.workflowWasm + " (" + wasmBytes.byteLength + " bytes)", "dim"); + log("", "dim"); + + const host = new CreWorkflowHost(log); + const report = await host.simulate(wasmBytes, manifest); + const headless = buildHeadlessDocument(manifest, wasmBytes, report); + publishHeadlessJson(headless); + + if (report.trigger.workflowSimulationResult) { + setStatus("Simulation complete"); + } else { + setStatus("Simulation finished (see terminal)"); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + log("Workflow execution failed: " + msg, "err"); + setStatus("Simulation failed"); + console.error(e); + } finally { + if (prelinked) { + simulateBtn.disabled = false; + } + } +}); + +fetchSimBtn.addEventListener("click", async () => { + if (!lastSimBlobUrl) { + log("No simulation JSON yet — click Simulate first", "err"); + return; + } + try { + const res = await fetch(lastSimBlobUrl); + const json = await res.json(); + log("--- fetched headless-simulation.json ---", "dim"); + log(JSON.stringify(json, null, 2), "ok"); + setStatus("Fetched simulation JSON into terminal"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + log("Fetch simulation JSON failed: " + msg, "err"); + } +}); + +simulateBtn.disabled = true; +fetchSimBtn.disabled = true; +simJsonLink.classList.add("disabled"); +simJsonLink.setAttribute("aria-disabled", "true"); + +const envSelect = document.getElementById("cre-env-select"); +if (envSelect) { + bindEnvSelector(envSelect).catch((e) => console.error(e)); +} + +preloadAssets().catch((e) => { + const msg = e instanceof Error ? e.message : String(e); + setStatus("Failed to load WASM"); + log("Could not preload workflow WASM: " + msg, "err"); + console.error(e); +}); diff --git a/web/wasm-simulate-demo/assets/.gitkeep b/web/wasm-simulate-demo/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/web/wasm-simulate-demo/assets/cre-environments.json b/web/wasm-simulate-demo/assets/cre-environments.json new file mode 100644 index 00000000..f26d88ec --- /dev/null +++ b/web/wasm-simulate-demo/assets/cre-environments.json @@ -0,0 +1,44 @@ +{ + "defaultEnv": "STAGING", + "environments": { + "DEVELOPMENT": { + "envName": "DEVELOPMENT", + "authBase": "https://login-dev.cre.cldev.cloud", + "clientId": "KERrSYowuRhVyXUrI3u7pI8nnY95bIGt", + "audience": "https://graphql.cre.dev.internal.griddle.sh/", + "graphqlUrlDirect": "https://graphql-cre-dev.tailf8f749.ts.net/graphql", + "workflowRegistryAddress": "0x7e69E853D9Ce50C2562a69823c80E01360019Cef", + "workflowRegistryChainName": "ethereum-testnet-sepolia", + "workflowRegistryChainIdHex": "0xaa36a7", + "workflowRegistryChainExplorerUrl": "https://sepolia.etherscan.io", + "donFamily": "zone-a" + }, + "STAGING": { + "envName": "STAGING", + "authBase": "https://login-stage.cre.cldev.cloud", + "clientId": "pKF1lgw56KKUo5LCl8kEREtVY50YB2Gd", + "audience": "https://graphql.cre.stage.internal.griddle.sh/", + "graphqlUrlDirect": "https://graphql-cre-stage.tailf8f749.ts.net/graphql", + "workflowRegistryAddress": "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135", + "workflowRegistryChainName": "ethereum-testnet-sepolia", + "workflowRegistryChainIdHex": "0xaa36a7", + "workflowRegistryChainExplorerUrl": "https://sepolia.etherscan.io", + "donFamily": "zone-a" + }, + "PRODUCTION": { + "envName": "PRODUCTION", + "authBase": "https://login.chain.link", + "clientId": "vMqPqJ3ApbeN8jNeA42Dn4UxbkfhQQkW", + "audience": "https://api.cre.chain.link/", + "graphqlUrlDirect": "https://api.cre.chain.link/graphql", + "workflowRegistryAddress": "0x4Ac54353FA4Fa961AfcC5ec4B118596d3305E7e5", + "workflowRegistryChainName": "ethereum-mainnet", + "workflowRegistryChainIdHex": "0x1", + "workflowRegistryChainExplorerUrl": "https://etherscan.io", + "donFamily": "zone-a" + } + }, + "demoWorkflowName": "helloWorkflow", + "demoWorkflowTag": "helloWorkflow", + "authRedirectUri": "http://localhost:53682/callback" +} diff --git a/web/wasm-simulate-demo/assets/headless-simulation.sample.json b/web/wasm-simulate-demo/assets/headless-simulation.sample.json new file mode 100644 index 00000000..ed09af0e --- /dev/null +++ b/web/wasm-simulate-demo/assets/headless-simulation.sample.json @@ -0,0 +1,9 @@ +{ + "mode": "browser-headless", + "note": "Run Simulate on the page to generate headless-simulation.json with live results.", + "workflowWasm": "hello-world.wasm", + "wasmUrl": "./assets/hello-world.wasm", + "workflowSimulationResult": { + "Result": "Fired at " + } +} diff --git a/web/wasm-simulate-demo/assets/manifest.json b/web/wasm-simulate-demo/assets/manifest.json new file mode 100644 index 00000000..0a97c8a5 --- /dev/null +++ b/web/wasm-simulate-demo/assets/manifest.json @@ -0,0 +1,8 @@ +{ + "cronTypeUrl": "type.googleapis.com/capabilities.scheduler.cron.v1.Payload", + "manifestUrl": "./assets/manifest.json", + "subscribeB64": "CgJ7fSCAEBIA", + "wasmBytes": 14794127, + "workflowUrl": "./assets/hello-world.wasm", + "workflowWasm": "hello-world.wasm" +} \ No newline at end of file diff --git a/web/wasm-simulate-demo/cre-host.js b/web/wasm-simulate-demo/cre-host.js new file mode 100644 index 00000000..26a13ad3 --- /dev/null +++ b/web/wasm-simulate-demo/cre-host.js @@ -0,0 +1,289 @@ +import { buildSubscribeExecuteRequest, buildTriggerExecuteRequest, toWasmArgv } from "./payload.js"; +import { patchWasiForGoWasip1 } from "./wasi-stubs.js"; + +/** @typedef {(line: string, kind?: "dim" | "ok" | "err") => void} LogFn */ + +/** + * @typedef {object} PhaseResult + * @property {boolean} ok + * @property {string} [triggerId] + * @property {{ Result?: string } | null} [workflowSimulationResult] + * @property {string | null} [error] + * @property {string} rawResponseBase64 + */ + +/** + * @typedef {object} SimulateReport + * @property {PhaseResult} subscribe + * @property {PhaseResult} trigger + */ + +/** + * Browser CRE simulator host: loads workflow WASM and implements the `env` imports + * used by cre-sdk-go (same contract as `cre workflow simulate`, without API keys). + */ +export class CreWorkflowHost { + /** @param {LogFn} log */ + constructor(log) { + this.log = log; + this.memory = null; + this.lastResponse = null; + } + + /** + * @param {ArrayBuffer} wasmBytes + * @param {import("./payload.js").DemoManifest} manifest + * @returns {Promise} + */ + async simulate(wasmBytes, manifest) { + this.log("Workflow compiled (loaded prelinked WASM)", "dim"); + this.log("[SIMULATION] Simulator Initialized", "ok"); + + const subscribeReq = buildSubscribeExecuteRequest(manifest); + this.log("Registering cron trigger (subscribe phase)...", "dim"); + const subResponse = await this.invokeGuest(wasmBytes, subscribeReq); + const subscribe = this.parseSubscribePhase(subResponse); + + const triggerReq = buildTriggerExecuteRequest(new Date(), manifest.cronTypeUrl); + this.log("Firing cron trigger (index 0)...", "dim"); + const execResponse = await this.invokeGuest(wasmBytes, triggerReq); + const trigger = this.parseTriggerPhase(execResponse); + + this.logExecutionToTerminal(trigger); + return { subscribe, trigger }; + } + + /** @param {Uint8Array} raw */ + parseSubscribePhase(raw) { + const b64 = bytesToBase64(raw); + if (raw.length === 0) { + this.log("Subscribe returned empty response", "err"); + return { ok: false, error: "empty subscribe response", rawResponseBase64: b64 }; + } + const text = new TextDecoder().decode(raw); + if (text.includes("cron-trigger@1.0.0")) { + this.log("Registered trigger: cron-trigger@1.0.0", "ok"); + } else { + this.log("Subscribe phase completed", "ok"); + } + return { + ok: true, + triggerId: text.includes("cron-trigger@1.0.0") ? "cron-trigger@1.0.0" : undefined, + rawResponseBase64: b64, + }; + } + + /** @param {Uint8Array} raw */ + parseTriggerPhase(raw) { + const b64 = bytesToBase64(raw); + const firedAt = extractUtf8String(raw, "Fired at"); + if (firedAt) { + return { + ok: true, + workflowSimulationResult: { Result: firedAt }, + rawResponseBase64: b64, + }; + } + const preview = new TextDecoder("utf-8", { fatal: false }).decode(raw.slice(0, 400)); + if (raw.length > 0 && (preview.includes("error") || preview.includes("Error"))) { + return { + ok: false, + error: preview, + rawResponseBase64: b64, + }; + } + return { + ok: raw.length > 0, + error: raw.length === 0 ? "empty trigger response" : null, + rawResponseBase64: b64, + }; + } + + /** @param {PhaseResult} trigger */ + logExecutionToTerminal(trigger) { + if (trigger.workflowSimulationResult) { + this.log("Workflow Simulation Result:", "ok"); + this.log(JSON.stringify(trigger.workflowSimulationResult, null, 2)); + return; + } + if (trigger.error) { + this.log("Execution resulted in an error: " + trigger.error, "err"); + return; + } + this.log("Workflow finished without a parsed result", "ok"); + } + + /** + * @param {ArrayBuffer} wasmBytes + * @param {Uint8Array} executeRequest + */ + async invokeGuest(wasmBytes, executeRequest) { + this.lastResponse = null; + const argv = toWasmArgv(executeRequest); + const env = this.buildEnvImports(); + const wasi = new WASI(argv, [], createDefaultWasiFds()); + patchWasiForGoWasip1(wasi); + wasiHackStdio(wasi, (chunk) => { + const line = new TextDecoder().decode(chunk).trimEnd(); + if (line) { + this.log(line, "dim"); + } + }); + + const { instance } = await WebAssembly.instantiate(wasmBytes, { + env, + wasi_snapshot_preview1: wasi.wasiImport, + }); + + this.memory = instance.exports.memory; + try { + wasi.start(instance); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (!msg.includes("exit with exit code")) { + throw e; + } + } + return this.lastResponse ?? new Uint8Array(); + } + + buildEnvImports() { + const host = this; + return { + send_response(ptr, len) { + const mem = host.memory; + if (!mem) { + return -1; + } + host.lastResponse = new Uint8Array(mem.buffer, ptr, len).slice(); + return 0; + }, + version_v2_go() {}, + switch_modes() {}, + now(ptr) { + const mem = host.memory; + if (!mem) { + return -1; + } + const view = new DataView(mem.buffer); + const ns = BigInt(Date.now()) * 1_000_000n; + view.setBigUint64(ptr, ns, true); + return 0; + }, + log(ptr, len) { + const mem = host.memory; + if (!mem) { + return; + } + const text = new TextDecoder().decode(new Uint8Array(mem.buffer, ptr, len)); + if (text.trim()) { + host.log(text.trimEnd(), "dim"); + } + }, + call_capability() { + // When Account & wallet page connected MetaMask, __creMetaMaskSigner is set. + // Full capability→MetaMask routing for chain-write WASM is not wired yet; + // use wallet.html for login, link-key, and publish txs. + if (globalThis.__creMetaMaskSigner) { + host.log("[host] MetaMask signer available (connect on Account & wallet page)", "dim"); + } + return -1n; + }, + await_capabilities(ptr, len, outPtr, outMax) { + const mem = host.memory; + if (!mem) { + return -1n; + } + const msg = "capability not available in browser demo host"; + const enc = new TextEncoder().encode(msg); + const n = Math.min(enc.length, outMax); + new Uint8Array(mem.buffer, outPtr, n).set(enc.subarray(0, n)); + return -BigInt(n); + }, + get_secrets(_ptr, _len, outPtr, outMax) { + const mem = host.memory; + if (!mem) { + return -1n; + } + const msg = "secrets not available in browser demo host"; + const enc = new TextEncoder().encode(msg); + const n = Math.min(enc.length, outMax); + new Uint8Array(mem.buffer, outPtr, n).set(enc.subarray(0, n)); + return -BigInt(n); + }, + await_secrets(_ptr, _len, outPtr, outMax) { + const mem = host.memory; + if (!mem) { + return -1n; + } + const msg = "secrets not available in browser demo host"; + const enc = new TextEncoder().encode(msg); + const n = Math.min(enc.length, outMax); + new Uint8Array(mem.buffer, outPtr, n).set(enc.subarray(0, n)); + return -BigInt(n); + }, + random_seed() { + return 42n; + }, + }; + } +} + +/** @returns {unknown[]} fds for browser_wasi_shim (stdin, stdout, stderr, preopened /) */ +function createDefaultWasiFds() { + const { File, OpenFile, PreopenDirectory } = globalThis; + const empty = () => new File(new ArrayBuffer(0)); + return [ + new OpenFile(empty()), + new OpenFile(empty()), + new OpenFile(empty()), + new PreopenDirectory("/", {}), + ]; +} + +/** @param {WASI} wasi @param {(chunk: Uint8Array) => void} onWrite */ +function wasiHackStdio(wasi, onWrite) { + const write = wasi.wasiImport.fd_write; + wasi.wasiImport.fd_write = (fd, iov, iovcnt, nwritten) => { + if (fd === 1 || fd === 2) { + const mem = wasi.inst?.exports?.memory; + if (mem) { + const view = new DataView(mem.buffer); + let total = 0; + for (let i = 0; i < iovcnt; i++) { + const buf = view.getUint32(iov + 8 * i, true); + const len = view.getUint32(iov + 8 * i + 4, true); + onWrite(new Uint8Array(mem.buffer, buf, len)); + total += len; + } + view.setUint32(nwritten, total, true); + return 0; + } + } + return write(fd, iov, iovcnt, nwritten); + }; +} + +/** @param {Uint8Array} buf @param {string} needle */ +function extractUtf8String(buf, needle) { + const text = new TextDecoder("utf-8", { fatal: false }).decode(buf); + const idx = text.indexOf(needle); + if (idx < 0) { + return null; + } + let end = idx + needle.length; + while (end < text.length && text.charCodeAt(end) >= 32 && text.charCodeAt(end) !== 0) { + end++; + } + return text.slice(idx, end).trim(); +} + +/** @param {Uint8Array} bytes */ +function bytesToBase64(bytes) { + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} diff --git a/web/wasm-simulate-demo/docker-compose.yml b/web/wasm-simulate-demo/docker-compose.yml new file mode 100644 index 00000000..ae1df4df --- /dev/null +++ b/web/wasm-simulate-demo/docker-compose.yml @@ -0,0 +1,20 @@ +name: cre-cli-wasm-simulate-demo + +services: + demo: + build: + context: ../.. + dockerfile: web/wasm-simulate-demo/Dockerfile + ports: + - "${WASM_DEMO_PORT:-9090}:80" + restart: unless-stopped + + # OAuth redirect target (must match cre login: http://localhost:53682/callback) + oauth-callback: + image: nginx:1.27-alpine + volumes: + - ./oauth/callback/index.html:/usr/share/nginx/html/callback/index.html:ro + - ./nginx-oauth.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - "53682:80" + restart: unless-stopped diff --git a/web/wasm-simulate-demo/index.html b/web/wasm-simulate-demo/index.html new file mode 100644 index 00000000..a5b496a7 --- /dev/null +++ b/web/wasm-simulate-demo/index.html @@ -0,0 +1,40 @@ + + + + + + CRE workflow simulate + + + +
+
+ + + + Loading prelinked WASM… +
+ +
+ +
+
+
+
+ + + + + + diff --git a/web/wasm-simulate-demo/lib/cre-auth.js b/web/wasm-simulate-demo/lib/cre-auth.js new file mode 100644 index 00000000..8c8f70e2 --- /dev/null +++ b/web/wasm-simulate-demo/lib/cre-auth.js @@ -0,0 +1,200 @@ +const STORAGE_KEY = "cre-demo-auth"; + +/** @typedef {{ accessToken: string, refreshToken?: string, idToken?: string, expiresAt?: number, apiKey?: string }} AuthState */ + +export { loadEnv, getSelectedEnvName, setSelectedEnvName, CRE_ENV_STORAGE_KEY } from "./cre-env.js"; + +/** @returns {AuthState | null} */ +export function loadAuth() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +/** @param {AuthState | null} state */ +export function saveAuth(state) { + if (!state) { + localStorage.removeItem(STORAGE_KEY); + return; + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); +} + +export function clearAuth() { + saveAuth(null); +} + +function b64Url(bytes) { + const bin = String.fromCharCode(...bytes); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export async function generatePkce() { + const verifierBytes = crypto.getRandomValues(new Uint8Array(32)); + const verifier = b64Url(verifierBytes); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + const challenge = b64Url(new Uint8Array(digest)); + return { verifier, challenge }; +} + +export function randomState() { + return b64Url(crypto.getRandomValues(new Uint8Array(16))); +} + +/** + * @param {object} env + * @param {string} challenge + * @param {string} state + */ +export function buildAuthorizeUrl(env, challenge, state) { + const params = new URLSearchParams({ + client_id: env.clientId, + redirect_uri: env.authRedirectUri, + response_type: "code", + scope: "openid profile email offline_access", + code_challenge: challenge, + code_challenge_method: "S256", + state, + }); + if (env.audience) { + params.set("audience", env.audience); + } + return env.authBase + "/authorize?" + params.toString(); +} + +/** + * @param {object} env + * @param {string} code + * @param {string} verifier + */ +export async function exchangeCode(env, code, verifier) { + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: env.clientId, + code, + redirect_uri: env.authRedirectUri, + code_verifier: verifier, + }); + const tokenUrl = env.authTokenUrl || "/cre-auth/oauth/token"; + const res = await fetch(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const text = await res.text(); + if (!res.ok) { + const preview = text.trim().startsWith("<") ? "HTML error page from server" : text.slice(0, 300); + throw new Error("Token exchange failed (" + res.status + "): " + preview); + } + let data; + try { + data = JSON.parse(text); + } catch { + throw new Error("Token exchange returned non-JSON: " + text.slice(0, 200)); + } + const expiresAt = data.expires_in ? Date.now() + data.expires_in * 1000 : undefined; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + idToken: data.id_token, + expiresAt, + }; +} + +/** + * @param {object} env + * @returns {Promise} + */ +export function loginWithBrowser(env) { + return new Promise((resolve, reject) => { + generatePkce() + .then(async ({ verifier, challenge }) => { + const state = randomState(); + const url = buildAuthorizeUrl(env, challenge, state); + sessionStorage.setItem("cre-oauth-verifier", verifier); + sessionStorage.setItem("cre-oauth-state", state); + + const onMessage = async (ev) => { + if (ev.origin !== window.location.origin && ev.origin !== "http://localhost:53682") { + return; + } + const data = ev.data; + if (!data || data.type !== "cre-oauth-callback") { + return; + } + window.removeEventListener("message", onMessage); + if (data.error) { + reject(new Error(data.errorDescription || data.error)); + return; + } + const expectedState = sessionStorage.getItem("cre-oauth-state"); + if (data.state && expectedState && data.state !== expectedState) { + reject(new Error("OAuth state mismatch")); + return; + } + const v = sessionStorage.getItem("cre-oauth-verifier"); + if (!v) { + reject(new Error("Missing PKCE verifier")); + return; + } + try { + const tokens = await exchangeCode(env, data.code, v); + saveAuth(tokens); + resolve(tokens); + } catch (e) { + reject(e); + } + }; + window.addEventListener("message", onMessage); + + const popup = window.open(url, "cre-login", "width=520,height=720"); + if (!popup) { + window.removeEventListener("message", onMessage); + reject(new Error("Popup blocked — allow popups for this site")); + return; + } + }) + .catch(reject); + }); +} + +/** @param {string} apiKey */ +export function saveApiKey(apiKey) { + saveAuth({ accessToken: "", apiKey: apiKey.trim() }); +} + +/** @param {AuthState | null} auth */ +export function authHeaders(auth) { + if (!auth) { + return {}; + } + if (auth.apiKey) { + return { Authorization: "Apikey " + auth.apiKey }; + } + if (auth.accessToken) { + return { Authorization: "Bearer " + auth.accessToken }; + } + return {}; +} + +/** @param {AuthState | null} auth */ +export function decodeDeployAccess(auth) { + if (!auth?.accessToken || auth.apiKey) { + return auth?.apiKey ? { hasAccess: true, status: "API_KEY" } : null; + } + try { + const payload = JSON.parse(atob(auth.accessToken.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))); + let status = ""; + for (const [k, v] of Object.entries(payload)) { + if (k.endsWith("organization_status") && typeof v === "string") { + status = v; + } + } + return { hasAccess: status === "FULL_ACCESS", status: status || "unknown" }; + } catch { + return null; + } +} diff --git a/web/wasm-simulate-demo/lib/cre-env.js b/web/wasm-simulate-demo/lib/cre-env.js new file mode 100644 index 00000000..35551041 --- /dev/null +++ b/web/wasm-simulate-demo/lib/cre-env.js @@ -0,0 +1,97 @@ +/** Same as CRE_CLI_ENV / cre-cli internal/environments. */ +export const CRE_ENV_STORAGE_KEY = "cre-demo-env"; + +/** @typedef {keyof typeof PROXY_PREFIX} CreEnvName */ + +const PROXY_PREFIX = { + DEVELOPMENT: { api: "/cre-api-dev", auth: "/cre-auth-dev" }, + STAGING: { api: "/cre-api-staging", auth: "/cre-auth-staging" }, + PRODUCTION: { api: "/cre-api", auth: "/cre-auth" }, +}; + +/** @type {{ defaultEnv: string, environments: Record, demoWorkflowName: string, demoWorkflowTag: string, authRedirectUri: string } | null} */ +let catalog = null; + +export async function loadEnvironmentsCatalog() { + if (catalog) { + return catalog; + } + const res = await fetch("./assets/cre-environments.json", { cache: "no-store" }); + if (!res.ok) { + throw new Error("Missing assets/cre-environments.json"); + } + catalog = await res.json(); + return catalog; +} + +/** @returns {CreEnvName} */ +export function getSelectedEnvName() { + try { + const stored = localStorage.getItem(CRE_ENV_STORAGE_KEY); + if (stored && stored in PROXY_PREFIX) { + return /** @type {CreEnvName} */ (stored); + } + } catch { + /* ignore */ + } + return "STAGING"; +} + +/** @param {CreEnvName} name */ +export function setSelectedEnvName(name) { + if (!(name in PROXY_PREFIX)) { + throw new Error("Unknown environment: " + name); + } + localStorage.setItem(CRE_ENV_STORAGE_KEY, name); +} + +/** + * Resolved config for the active CRE_CLI_ENV (proxied API paths for browser CORS). + * @returns {Promise} + */ +export async function loadEnv() { + const cat = await loadEnvironmentsCatalog(); + const envName = getSelectedEnvName(); + const base = cat.environments[envName]; + if (!base) { + throw new Error("Environment not in catalog: " + envName); + } + const proxy = PROXY_PREFIX[envName]; + return { + ...base, + envName, + graphqlUrl: proxy.api + "/graphql", + authTokenUrl: proxy.auth + "/oauth/token", + demoWorkflowName: cat.demoWorkflowName, + demoWorkflowTag: cat.demoWorkflowTag, + authRedirectUri: cat.authRedirectUri, + }; +} + +/** @param {HTMLElement} selectEl */ +export async function bindEnvSelector(selectEl) { + const cat = await loadEnvironmentsCatalog(); + const current = getSelectedEnvName(); + const defaultName = cat.defaultEnv || "STAGING"; + if (!localStorage.getItem(CRE_ENV_STORAGE_KEY)) { + setSelectedEnvName( + defaultName in PROXY_PREFIX ? /** @type {CreEnvName} */ (defaultName) : "STAGING", + ); + } + + selectEl.innerHTML = ""; + for (const name of Object.keys(cat.environments)) { + const opt = document.createElement("option"); + opt.value = name; + opt.textContent = name; + if (name === getSelectedEnvName()) { + opt.selected = true; + } + selectEl.appendChild(opt); + } + + selectEl.addEventListener("change", () => { + setSelectedEnvName(/** @type {CreEnvName} */ (selectEl.value)); + window.location.reload(); + }); +} diff --git a/web/wasm-simulate-demo/lib/cre-graphql.js b/web/wasm-simulate-demo/lib/cre-graphql.js new file mode 100644 index 00000000..57822f8d --- /dev/null +++ b/web/wasm-simulate-demo/lib/cre-graphql.js @@ -0,0 +1,81 @@ +import { authHeaders, loadAuth } from "./cre-auth.js"; + +function newIdempotencyKey() { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + return "demo-" + Date.now() + "-" + Math.random().toString(36).slice(2); +} + +function isMutation(query) { + return /^\s*mutation\b/i.test(query); +} + +/** + * @param {Response} res + */ +async function readJsonOrThrow(res, label) { + const text = await res.text(); + const trimmed = text.trim(); + if (trimmed.startsWith("<")) { + throw new Error( + label + + " HTTP " + + res.status + + ": server returned HTML (proxy/upstream error). STAGING GraphQL may require VPN.", + ); + } + try { + return JSON.parse(text); + } catch { + throw new Error(label + " HTTP " + res.status + ": " + trimmed.slice(0, 300)); + } +} + +/** + * @param {object} env + * @param {string} query + * @param {Record} [variables] + * @param {{ idempotency?: boolean }} [options] + */ +export async function gql(env, query, variables, options = {}) { + const auth = loadAuth(); + if (!auth?.accessToken && !auth?.apiKey) { + throw new Error("Not logged in — use Login or paste an API key"); + } + const headers = { + "Content-Type": "application/json", + "User-Agent": "cre-cli", + ...authHeaders(auth), + }; + if (options.idempotency ?? isMutation(query)) { + headers["Idempotency-Key"] = newIdempotencyKey(); + } + const res = await fetch(env.graphqlUrl, { + method: "POST", + headers, + body: JSON.stringify({ query, variables }), + }); + const json = await readJsonOrThrow(res, "GraphQL"); + if (!res.ok) { + throw new Error("GraphQL HTTP " + res.status + ": " + JSON.stringify(json).slice(0, 400)); + } + if (json.errors?.length) { + throw new Error(json.errors.map((e) => e.message).join("; ")); + } + return json.data; +} + +export async function whoami(env) { + const auth = loadAuth(); + const withEmail = !auth?.apiKey; + const query = withEmail + ? `query GetWhoamiDetails { + getAccountDetails { emailAddress } + getOrganization { displayName organizationId } + }` + : `query GetWhoamiDetails { + getOrganization { displayName organizationId } + }`; + return gql(env, query); +} diff --git a/web/wasm-simulate-demo/lib/cre-wallet-ops.js b/web/wasm-simulate-demo/lib/cre-wallet-ops.js new file mode 100644 index 00000000..7d9b3597 --- /dev/null +++ b/web/wasm-simulate-demo/lib/cre-wallet-ops.js @@ -0,0 +1,92 @@ +import { gql } from "./cre-graphql.js"; +import { resolveLinkTransactionData } from "./link-owner-tx.js"; +import { ensureChain, sendTransaction } from "./metamask.js"; + +const LINK_ENV = "PRODUCTION_TESTNET"; + +/** + * @param {object} env + * @param {string} ownerAddress + * @param {string} ownerLabel + */ +export async function initiateLinking(env, ownerAddress, ownerLabel) { + const mutation = ` +mutation InitiateLinking($request: InitiateLinkingRequest!) { + initiateLinking(request: $request) { + ownershipProofHash + workflowOwnerAddress + validUntil + signature + chainSelector + contractAddress + transactionData + functionSignature + functionArgs + } +}`; + const data = await gql(env, mutation, { + request: { + workflowOwnerAddress: ownerAddress, + workflowOwnerLabel: ownerLabel, + environment: LINK_ENV, + requestProcess: "EOA", + }, + }); + return data.initiateLinking; +} + +/** + * Submit link-owner tx via MetaMask using server-prepared calldata. + * @param {object} linking + * @param {object} env + */ +export async function registerWalletOnChain(linking, env) { + const chainId = env.workflowRegistryChainIdHex || "0x1"; + await ensureChain(chainId); + const to = linking.contractAddress || env.workflowRegistryAddress; + const data = resolveLinkTransactionData(linking); + const hash = await sendTransaction({ to, data, chainId }); + return { hash, explorer: env.workflowRegistryChainExplorerUrl + "/tx/" + hash }; +} + +/** + * Demo publish: whoami + optional offchain upsert when binary URL is known. + * @param {object} env + * @param {string} owner + * @param {{ workflowId?: string, binaryUrl?: string, workflowName?: string, tag?: string }} opts + */ +export async function publishWorkflowDemo(env, owner, opts) { + const steps = []; + const who = await gql( + env, + `query { getOrganization { displayName organizationId } }`, + ); + steps.push("Organization: " + who.getOrganization.displayName); + + if (!opts.binaryUrl) { + steps.push("Publish: upload WASM via cre workflow deploy (or set binary URL after upload)."); + steps.push("Browser demo does not yet compute workflow ID — use CLI deploy with MetaMask txs copied from Register wallet flow."); + return { steps, upserted: false }; + } + + const mutation = ` +mutation UpsertOffchainWorkflow($request: UpsertOffchainWorkflowRequest!) { + upsertOffchainWorkflow(request: $request) { + workflow { workflowId workflowName status binaryUrl configUrl owner } + } +}`; + const workflow = { + workflowId: opts.workflowId, + workflowName: opts.workflowName || env.demoWorkflowName, + status: "ACTIVE", + binaryUrl: opts.binaryUrl, + donFamily: env.donFamily, + owner, + }; + if (opts.tag) { + workflow.tag = opts.tag; + } + const result = await gql(env, mutation, { request: { workflow } }); + steps.push("Upserted offchain workflow: " + result.upsertOffchainWorkflow.workflow.workflowId); + return { steps, upserted: true, workflow: result.upsertOffchainWorkflow.workflow }; +} diff --git a/web/wasm-simulate-demo/lib/link-owner-tx.js b/web/wasm-simulate-demo/lib/link-owner-tx.js new file mode 100644 index 00000000..3ca39230 --- /dev/null +++ b/web/wasm-simulate-demo/lib/link-owner-tx.js @@ -0,0 +1,47 @@ +import { Interface } from "https://esm.sh/ethers@6.13.4"; + +const linkOwnerIface = new Interface([ + "function linkOwner(uint256 validityTimestamp, bytes32 proof, bytes signature)", +]); + +/** + * Build LinkOwner calldata like cre-cli cmd/account/link_key (EOA path). + * @param {object} linking initiateLinking response + */ +export function encodeLinkOwnerCalldata(linking) { + const expiresAt = Date.parse(linking.validUntil); + if (Number.isNaN(expiresAt)) { + throw new Error("invalid validUntil: " + linking.validUntil); + } + const validityTimestamp = BigInt(Math.floor(expiresAt / 1000)); + + let proof = linking.ownershipProofHash; + if (!proof) { + throw new Error("missing ownershipProofHash"); + } + if (!proof.startsWith("0x")) { + proof = "0x" + proof; + } + + let signature = linking.signature; + if (!signature) { + throw new Error("missing signature from initiateLinking"); + } + if (!signature.startsWith("0x")) { + signature = "0x" + signature; + } + + return linkOwnerIface.encodeFunctionData("linkOwner", [validityTimestamp, proof, signature]); +} + +/** + * @param {object} linking + * @returns {string} hex calldata + */ +export function resolveLinkTransactionData(linking) { + const raw = linking.transactionData; + if (raw && raw !== "0x" && raw !== "0X") { + return raw.startsWith("0x") ? raw : "0x" + raw; + } + return encodeLinkOwnerCalldata(linking); +} diff --git a/web/wasm-simulate-demo/lib/metamask.js b/web/wasm-simulate-demo/lib/metamask.js new file mode 100644 index 00000000..536b2000 --- /dev/null +++ b/web/wasm-simulate-demo/lib/metamask.js @@ -0,0 +1,278 @@ +/** + * In-page MetaMask signer — replaces CRE_ETH_PRIVATE_KEY for browser flows. + */ + +const MAINNET_CHAIN_ID = "0x1"; + +/** User canceled in MetaMask (EIP-1193 4001). */ +export class MetaMaskUserRejectedError extends Error { + /** @param {string} [message] */ + constructor(message = "Transaction canceled in MetaMask (you declined signing).") { + super(message); + this.name = "MetaMaskUserRejectedError"; + this.code = 4001; + } +} + +/** + * Turn MetaMask RPC / provider errors into readable strings (never "[object Object]"). + * @param {unknown} err + */ +export function formatMetaMaskError(err) { + if (err == null) { + return "Unknown MetaMask error"; + } + if (typeof err === "string") { + return err.replace(/^MetaMask - RPC Error:\s*/i, "").trim(); + } + if (err instanceof MetaMaskUserRejectedError) { + return err.message; + } + if (err instanceof Error && err.message) { + return err.message.replace(/^MetaMask - RPC Error:\s*/i, "").trim(); + } + + const obj = /** @type {{ code?: number | string; message?: string; data?: { code?: number; message?: string } }} */ (err); + const code = obj.code ?? obj.data?.code; + const message = (obj.message ?? obj.data?.message ?? "").replace(/^MetaMask - RPC Error:\s*/i, "").trim(); + + if (code === 4001 || code === "4001") { + if (/signature|transaction|tx/i.test(message)) { + return "Transaction canceled in MetaMask (you declined signing)."; + } + if (/connect|account/i.test(message)) { + return "Connection canceled in MetaMask."; + } + return "Request canceled in MetaMask."; + } + if (code === 4100) { + return "MetaMask: connect your wallet first."; + } + if (code === 4902) { + return "MetaMask: add the required network in your wallet."; + } + if (message) { + return message; + } + try { + return JSON.stringify(err); + } catch { + return "MetaMask error (could not parse details)"; + } +} + +/** @param {unknown} err */ +function rethrowMetaMaskError(err) { + const message = formatMetaMaskError(err); + const code = + typeof err === "object" && err != null && "code" in err + ? /** @type {{ code?: number }} */ (err).code + : undefined; + if (code === 4001) { + throw new MetaMaskUserRejectedError(message); + } + throw new Error(message); +} + +/** @returns {import('ethers').Eip1193Provider | null} */ +export function getEthereum() { + const eth = globalThis.ethereum; + if (!eth || typeof eth.request !== "function") { + return null; + } + return eth; +} + +export function hasMetaMask() { + return getEthereum() != null; +} + +/** @param {string} chainIdHex e.g. 0x1 */ +export async function getChainIdHex() { + const eth = getEthereum(); + if (!eth) { + return null; + } + return eth.request({ method: "eth_chainId" }); +} + +/** @param {string} chainIdHex e.g. 0x1 */ +export async function ensureChain(chainIdHex = MAINNET_CHAIN_ID) { + const eth = getEthereum(); + if (!eth) { + throw new Error("MetaMask is not installed"); + } + try { + const current = await eth.request({ method: "eth_chainId" }); + if (current?.toLowerCase() === chainIdHex.toLowerCase()) { + return; + } + await eth.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId: chainIdHex }], + }); + } catch (e) { + rethrowMetaMaskError(e); + } +} + +export async function connectWallet() { + const eth = getEthereum(); + if (!eth) { + throw new Error("MetaMask is not installed. Install the extension and refresh."); + } + try { + const accounts = await eth.request({ method: "eth_requestAccounts" }); + if (!accounts?.length) { + throw new Error("No account selected in MetaMask"); + } + return accounts[0]; + } catch (e) { + rethrowMetaMaskError(e); + } +} + +export async function getConnectedAddress() { + const eth = getEthereum(); + if (!eth) { + return null; + } + try { + const accounts = await eth.request({ method: "eth_accounts" }); + return accounts?.[0] ?? null; + } catch { + return null; + } +} + +/** Remove in-page signer used by the simulate host. */ +export function clearGlobalSigner() { + delete globalThis.__creMetaMaskSigner; +} + +/** Revoke site access to the selected account in MetaMask and clear the in-page signer. */ +export async function disconnectWallet() { + const eth = getEthereum(); + if (eth) { + try { + await eth.request({ + method: "wallet_revokePermissions", + params: [{ eth_accounts: {} }], + }); + } catch (e) { + const code = + typeof e === "object" && e != null && "code" in e + ? /** @type {{ code?: number }} */ (e).code + : undefined; + if (code === 4001) { + rethrowMetaMaskError(e); + } + // Unsupported or nothing to revoke — still clear app-side signer below. + } + } + clearGlobalSigner(); +} + +/** @param {string} address */ +export function shortAddress(address) { + if (!address || address.length < 10) { + return address ?? ""; + } + return address.slice(0, 6) + "…" + address.slice(-4); +} + +/** @param {string} message */ +export async function signMessage(message) { + const eth = getEthereum(); + if (!eth) { + throw new Error("MetaMask is not available"); + } + const from = await connectWallet(); + const hex = + "0x" + + [...new TextEncoder().encode(message)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + try { + return await eth.request({ + method: "personal_sign", + params: [hex, from], + }); + } catch (e) { + rethrowMetaMaskError(e); + } +} + +/** + * @param {{ to: string, data?: string, value?: string, gas?: string, chainId?: string }} tx + */ +export async function sendTransaction(tx) { + const eth = getEthereum(); + if (!eth) { + throw new Error("MetaMask is not available"); + } + const from = await connectWallet(); + if (tx.chainId) { + await ensureChain(tx.chainId); + } + try { + return await eth.request({ + method: "eth_sendTransaction", + params: [ + { + from, + to: tx.to, + data: tx.data ?? "0x", + value: tx.value ?? "0x0", + gas: tx.gas, + }, + ], + }); + } catch (e) { + rethrowMetaMaskError(e); + } +} + +/** + * @param {(payload: { address: string | null, chainId: string | null }) => void} listener + */ +export function onMetaMaskStateChange(listener) { + const eth = getEthereum(); + if (!eth?.on) { + return () => {}; + } + const refresh = () => { + Promise.all([getConnectedAddress(), getChainIdHex()]) + .then(([address, chainId]) => listener({ address, chainId })) + .catch(() => listener({ address: null, chainId: null })); + }; + eth.on("accountsChanged", refresh); + eth.on("chainChanged", refresh); + refresh(); + return () => { + eth.removeListener?.("accountsChanged", refresh); + eth.removeListener?.("chainChanged", refresh); + }; +} + +/** + * @returns {{ signMessage: (msg: string) => Promise, sendTransaction: (tx: object) => Promise, getAddress: () => Promise }} + */ +export function createExternalSigner() { + return { + async getAddress() { + const a = await getConnectedAddress(); + if (!a) { + throw new Error("Connect MetaMask first"); + } + return a; + }, + signMessage, + sendTransaction, + }; +} + +/** Install global signer used by simulate host when present. */ +export function installGlobalSigner() { + globalThis.__creMetaMaskSigner = createExternalSigner(); +} diff --git a/web/wasm-simulate-demo/nginx-oauth.conf b/web/wasm-simulate-demo/nginx-oauth.conf new file mode 100644 index 00000000..7919e3d9 --- /dev/null +++ b/web/wasm-simulate-demo/nginx-oauth.conf @@ -0,0 +1,11 @@ +server { + listen 80; + server_name localhost; + charset utf-8; + + # Must match cre AuthRedirectURI: http://localhost:53682/callback + location = /callback { + default_type text/html; + alias /usr/share/nginx/html/callback/index.html; + } +} diff --git a/web/wasm-simulate-demo/nginx.conf b/web/wasm-simulate-demo/nginx.conf new file mode 100644 index 00000000..a81d8ddc --- /dev/null +++ b/web/wasm-simulate-demo/nginx.conf @@ -0,0 +1,96 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + charset utf-8; + + # Defer upstream DNS (staging/dev hosts may be unreachable at container start). + resolver 127.0.0.11 valid=10s ipv6=off; + + location / { + try_files $uri $uri/ /index.html; + } + + location = /index.html { + default_type text/html; + } + + location = /wallet.html { + default_type text/html; + add_header Cache-Control "no-store"; + } + + location ~ \.(js|mjs)$ { + default_type application/javascript; + add_header Cache-Control "no-store"; + } + + location ~ \.css$ { + default_type text/css; + add_header Cache-Control "no-store"; + } + + location ~ \.wasm$ { + default_type application/wasm; + } + + location /assets/ { + add_header Cache-Control "public, max-age=3600"; + add_header Access-Control-Allow-Origin "*"; + } + + # Avoid browser CORS — paths match lib/cre-env.js PROXY_PREFIX (CRE_CLI_ENV). + # rewrite + proxy_pass (no URI suffix): strip /cre-* prefix before upstream. + location /cre-api/ { + rewrite ^/cre-api/(.*)$ /$1 break; + proxy_pass https://api.cre.chain.link; + proxy_ssl_server_name on; + proxy_set_header Host api.cre.chain.link; + } + + location /cre-auth/ { + rewrite ^/cre-auth/(.*)$ /$1 break; + proxy_pass https://login.chain.link; + proxy_ssl_server_name on; + proxy_set_header Host login.chain.link; + } + + location /cre-api-staging/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $cre_staging_api graphql-cre-stage.tailf8f749.ts.net; + rewrite ^/cre-api-staging/(.*)$ /$1 break; + proxy_pass https://$cre_staging_api; + proxy_ssl_server_name on; + proxy_set_header Host graphql-cre-stage.tailf8f749.ts.net; + } + + location /cre-auth-staging/ { + rewrite ^/cre-auth-staging/(.*)$ /$1 break; + proxy_pass https://login-stage.cre.cldev.cloud; + proxy_ssl_server_name on; + proxy_set_header Host login-stage.cre.cldev.cloud; + } + + location /cre-api-dev/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $cre_dev_api graphql-cre-dev.tailf8f749.ts.net; + rewrite ^/cre-api-dev/(.*)$ /$1 break; + proxy_pass https://$cre_dev_api; + proxy_ssl_server_name on; + proxy_set_header Host graphql-cre-dev.tailf8f749.ts.net; + } + + location /cre-auth-dev/ { + rewrite ^/cre-auth-dev/(.*)$ /$1 break; + proxy_pass https://login-dev.cre.cldev.cloud; + proxy_ssl_server_name on; + proxy_set_header Host login-dev.cre.cldev.cloud; + } + + gzip on; + gzip_types application/wasm application/json text/css application/javascript; +} diff --git a/web/wasm-simulate-demo/oauth/callback/index.html b/web/wasm-simulate-demo/oauth/callback/index.html new file mode 100644 index 00000000..b399a8e0 --- /dev/null +++ b/web/wasm-simulate-demo/oauth/callback/index.html @@ -0,0 +1,57 @@ + + + + + CRE login + + + +

Completing login…

+ + + diff --git a/web/wasm-simulate-demo/payload.js b/web/wasm-simulate-demo/payload.js new file mode 100644 index 00000000..a1914b9f --- /dev/null +++ b/web/wasm-simulate-demo/payload.js @@ -0,0 +1,96 @@ +/** @typedef {{ workflowWasm: string, subscribeB64: string, triggerB64: string, cronTypeUrl: string }} DemoManifest */ + +/** + * @param {number} fieldNum + * @param {Uint8Array} data + */ +function tagBytes(fieldNum, data) { + const tag = (fieldNum << 3) | 2; + const out = []; + out.push(...encodeVarint(tag)); + out.push(...encodeVarint(data.length)); + out.push(...data); + return new Uint8Array(out); +} + +/** @param {number} n */ +function encodeVarint(n) { + const out = []; + let v = n >>> 0; + while (v >= 0x80) { + out.push((v & 0x7f) | 0x80); + v >>>= 7; + } + out.push(v); + return out; +} + +/** @param {number} fieldNum @param {number} n */ +function tagVarint(fieldNum, n) { + const tag = (fieldNum << 3) | 0; + return new Uint8Array([...encodeVarint(tag), ...encodeVarint(n)]); +} + +/** @param {Date} date */ +function encodeTimestamp(date) { + const ms = date.getTime(); + const seconds = Math.floor(ms / 1000); + const nanos = (ms % 1000) * 1_000_000; + const parts = [tagVarint(1, seconds)]; + if (nanos !== 0) { + parts.push(tagVarint(2, nanos)); + } + return concatBytes(parts); +} + +/** @param {string} typeUrl @param {Uint8Array} value */ +function encodeAny(typeUrl, value) { + const enc = new TextEncoder(); + const urlBytes = enc.encode(typeUrl); + const parts = [tagBytes(1, urlBytes), tagBytes(2, value)]; + return concatBytes(parts); +} + +/** @param {Date} date */ +function encodeCronPayload(date) { + return tagBytes(1, encodeTimestamp(date)); +} + +/** @param {Date} scheduled @param {string} cronTypeUrl */ +export function buildTriggerExecuteRequest(scheduled, cronTypeUrl) { + const cronPayload = encodeCronPayload(scheduled); + const triggerMsg = concatBytes([ + tagVarint(1, 0), + tagBytes(2, encodeAny(cronTypeUrl, cronPayload)), + ]); + const config = new TextEncoder().encode("{}"); + return concatBytes([ + tagBytes(1, config), + tagBytes(3, triggerMsg), + tagVarint(4, 2048), + ]); +} + +/** @param {DemoManifest} manifest */ +export function buildSubscribeExecuteRequest(manifest) { + const raw = atob(manifest.subscribeB64); + return new Uint8Array([...raw].map((c) => c.charCodeAt(0))); +} + +/** @param {Uint8Array} bytes */ +export function toWasmArgv(bytes) { + const b64 = btoa(String.fromCharCode(...bytes)); + return ["wasm", b64]; +} + +/** @param {Uint8Array[]} parts */ +function concatBytes(parts) { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} diff --git a/web/wasm-simulate-demo/run.sh b/web/wasm-simulate-demo/run.sh new file mode 100755 index 00000000..cea35e1d --- /dev/null +++ b/web/wasm-simulate-demo/run.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Run the browser simulate demo (from cre-cli repo root or this directory). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PORT="${WASM_DEMO_PORT:-9090}" +cd "$ROOT" +export WASM_DEMO_PORT="$PORT" +docker compose -f "$ROOT/web/wasm-simulate-demo/docker-compose.yml" up --build -d +echo "Demo: http://localhost:${PORT}/" +curl -sfI "http://127.0.0.1:${PORT}/" | grep -i content-type || true diff --git a/web/wasm-simulate-demo/styles.css b/web/wasm-simulate-demo/styles.css new file mode 100644 index 00000000..05610aab --- /dev/null +++ b/web/wasm-simulate-demo/styles.css @@ -0,0 +1,407 @@ +:root { + color-scheme: dark; + --bg: #0d1117; + --panel: #161b22; + --border: #30363d; + --text: #e6edf3; + --muted: #8b949e; + --accent: #58a6ff; + --accent-strong: #1f6feb; + --ok: #3fb950; + --err: #f85149; + --mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); +} + +.page { + display: flex; + flex-direction: column; + height: 100vh; + max-height: 100dvh; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; + padding: 0.85rem 1.25rem; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +.toolbar-spacer { + flex: 1; + min-width: 0.5rem; +} + +.status { + font-size: 0.8125rem; + color: var(--muted); +} + +.btn-simulate { + font: inherit; + font-size: 0.9375rem; + font-weight: 600; + padding: 0.55rem 1.35rem; + border: 1px solid rgba(88, 166, 255, 0.55); + border-radius: 6px; + background: var(--accent-strong); + color: #fff; + cursor: pointer; +} + +.btn-simulate:hover:not(:disabled) { + filter: brightness(1.08); +} + +.btn-simulate:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn-ghost { + font: inherit; + font-size: 0.8125rem; + padding: 0.35rem 0.65rem; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.btn-ghost:hover:not(:disabled) { + color: var(--text); + border-color: var(--muted); +} + +.btn-ghost:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.asset-links { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; +} + +.asset-link { + font-family: var(--mono); + font-size: 0.75rem; + color: var(--accent); + text-decoration: none; + padding: 0.25rem 0.45rem; + border-radius: 4px; + border: 1px solid transparent; +} + +.asset-link:hover:not(.disabled) { + border-color: var(--border); + background: rgba(88, 166, 255, 0.08); +} + +.asset-link.disabled { + color: var(--muted); + pointer-events: none; + opacity: 0.55; +} + +.terminal-wrap { + flex: 1; + min-height: 0; + padding: 1rem 1.25rem 1.25rem; +} + +.terminal { + height: 100%; + overflow: auto; + padding: 1rem 1.1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: #010409; + font-family: var(--mono); + font-size: 0.8125rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.line { + margin: 0 0 0.15rem; +} + +.line-dim { + color: var(--muted); +} + +.line-ok { + color: var(--ok); +} + +.line-err { + color: var(--err); +} + +.nav-tabs { + display: flex; + gap: 0.35rem; + margin-right: 0.5rem; +} + +.nav-tab { + font-size: 0.8125rem; + padding: 0.35rem 0.7rem; + border-radius: 6px; + color: var(--muted); + text-decoration: none; + border: 1px solid transparent; +} + +.nav-tab:hover { + color: var(--text); + border-color: var(--border); +} + +.nav-tab-active { + color: var(--text); + border-color: var(--border); + background: rgba(88, 166, 255, 0.1); +} + +.env-select-wrap { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.75rem; + color: var(--muted); +} + +.env-select-label { + font-family: var(--mono); + white-space: nowrap; +} + +.env-select { + font: inherit; + font-family: var(--mono); + font-size: 0.75rem; + padding: 0.3rem 0.45rem; + border: 1px solid var(--border); + border-radius: 6px; + background: #010409; + color: var(--text); + cursor: pointer; +} + +.wallet-panel { + padding: 1rem 1.25rem 0; + border-bottom: 1px solid var(--border); +} + +.wallet-mm-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1rem; + margin-bottom: 1rem; + padding: 0.85rem 1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: #010409; +} + +.wallet-mm-title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + margin-bottom: 0.25rem; +} + +.wallet-mm-title { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +.wallet-mm-status { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + padding: 0.15rem 0.45rem; + border-radius: 999px; + border: 1px solid var(--border); +} + +.wallet-mm-status-off { + color: var(--muted); +} + +.wallet-mm-status-on { + color: var(--ok); + border-color: rgba(63, 185, 80, 0.35); + background: rgba(63, 185, 80, 0.08); +} + +.wallet-mm-status-warn { + color: #d29922; + border-color: rgba(210, 153, 34, 0.35); + background: rgba(210, 153, 34, 0.08); +} + +.wallet-mm-address { + font-family: var(--mono); + font-size: 0.8125rem; + color: var(--muted); + word-break: break-all; +} + +.wallet-mm-address-connected { + color: var(--ok); +} + +.wallet-mm-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.is-hidden { + display: none !important; +} + +#switch-mm-btn { + color: var(--text); + border-color: var(--border); +} + +#switch-mm-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.wallet-lead { + margin: 0 0 0.85rem; + font-size: 0.875rem; + color: var(--muted); + max-width: 52rem; + line-height: 1.5; +} + +.wallet-lead code { + font-family: var(--mono); + font-size: 0.8em; +} + +.wallet-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.wallet-advanced { + margin-bottom: 0.75rem; + font-size: 0.8125rem; + color: var(--muted); +} + +.api-key-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.api-key-input { + font: inherit; + font-family: var(--mono); + font-size: 0.8125rem; + padding: 0.4rem 0.55rem; + border: 1px solid var(--border); + border-radius: 6px; + background: #010409; + color: var(--text); + min-width: 12rem; + flex: 1; +} + +.wallet-field { + display: block; + font-size: 0.8125rem; + color: var(--muted); + margin-bottom: 0.5rem; +} + +.wallet-field .api-key-input { + display: block; + margin-top: 0.35rem; + width: 100%; + max-width: 20rem; +} + +.mm-indicator { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-family: var(--mono); + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 6px; + border: 1px solid var(--border); +} + +.mm-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--muted); + flex-shrink: 0; +} + +.mm-indicator.mm-on .mm-dot { + background: var(--ok); + box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.25); +} + +.mm-indicator.mm-warn .mm-dot { + background: #d29922; + box-shadow: 0 0 0 2px rgba(210, 153, 34, 0.25); +} + +.mm-indicator.mm-on { + color: var(--ok); + border-color: rgba(63, 185, 80, 0.35); +} + +.mm-indicator.mm-warn { + color: #d29922; + border-color: rgba(210, 153, 34, 0.35); +} + +.mm-indicator.mm-off { + color: var(--muted); +} diff --git a/web/wasm-simulate-demo/tools/build_assets/main.go b/web/wasm-simulate-demo/tools/build_assets/main.go new file mode 100644 index 00000000..e446e728 --- /dev/null +++ b/web/wasm-simulate-demo/tools/build_assets/main.go @@ -0,0 +1,118 @@ +// Build hello-world workflow WASM and copy into web/wasm-simulate-demo/assets/. +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" + cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" + "github.com/smartcontractkit/cre-cli/internal/templaterepo" + "github.com/smartcontractkit/cre-cli/internal/testutil" +) + +func main() { + repoRoot, err := os.Getwd() + if err != nil { + fatal(err) + } + if _, err := os.Stat(filepath.Join(repoRoot, "go.mod")); err != nil { + fatal(fmt.Errorf("run from cre-cli repo root (go.mod not found in %s)", repoRoot)) + } + + assetsDir := filepath.Join(repoRoot, "web", "wasm-simulate-demo", "assets") + if err := os.MkdirAll(assetsDir, 0755); err != nil { + fatal(err) + } + + tmp := filepath.Join(os.TempDir(), "cre-wasm-demo-build") + _ = os.RemoveAll(tmp) + projectRoot := filepath.Join(tmp, "demo-project") + workflowName := "helloWorkflow" + workflowDir := filepath.Join(projectRoot, workflowName) + + logger := testutil.NewTestLogger() + if err := os.MkdirAll(projectRoot, 0755); err != nil { + fatal(err) + } + if err := templaterepo.ScaffoldBuiltIn(logger, "hello-world-go", projectRoot, workflowName); err != nil { + fatal(err) + } + + modInit := exec.Command("go", "mod", "init", "wasm-demo") + modInit.Dir = projectRoot + if out, err := modInit.CombinedOutput(); err != nil { + fatal(fmt.Errorf("go mod init: %w\n%s", err, out)) + } + + deps := []string{ + "github.com/smartcontractkit/cre-sdk-go@v1.11.0", + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron@v1.0.0-beta.0", + } + for _, dep := range deps { + get := exec.Command("go", "get", dep) + get.Dir = projectRoot + if out, err := get.CombinedOutput(); err != nil { + fatal(fmt.Errorf("go get %s: %w\n%s", dep, err, out)) + } + } + tidy := exec.Command("go", "mod", "tidy") + tidy.Dir = projectRoot + if out, err := tidy.CombinedOutput(); err != nil { + fatal(fmt.Errorf("go mod tidy: %w\n%s", err, out)) + } + + mainGo := filepath.Join(workflowDir, "main.go") + wasmBytes, err := cmdcommon.CompileWorkflowToWasm(context.Background(), mainGo, cmdcommon.WorkflowCompileOptions{ + StripSymbols: true, + }) + if err != nil { + fatal(err) + } + wasmPath := filepath.Join(assetsDir, "hello-world.wasm") + if err := os.WriteFile(wasmPath, wasmBytes, 0644); err != nil { + fatal(err) + } + + subscribeReq := &sdk.ExecuteRequest{ + Config: []byte("{}"), + MaxResponseSize: 2048, + Request: &sdk.ExecuteRequest_Subscribe{Subscribe: &emptypb.Empty{}}, + } + subscribeBytes, err := proto.Marshal(subscribeReq) + if err != nil { + fatal(err) + } + + manifest := map[string]any{ + "workflowWasm": "hello-world.wasm", + "workflowUrl": "./assets/hello-world.wasm", + "manifestUrl": "./assets/manifest.json", + "wasmBytes": len(wasmBytes), + "subscribeB64": base64.StdEncoding.EncodeToString(subscribeBytes), + "cronTypeUrl": "type.googleapis.com/capabilities.scheduler.cron.v1.Payload", + } + manifestJSON, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + fatal(err) + } + if err := os.WriteFile(filepath.Join(assetsDir, "manifest.json"), manifestJSON, 0644); err != nil { + fatal(err) + } + + fmt.Printf("Wrote %s (%d bytes)\n", wasmPath, len(wasmBytes)) + fmt.Printf("Wrote %s\n", filepath.Join(assetsDir, "manifest.json")) +} + +func fatal(err error) { + fmt.Fprintf(os.Stderr, "build_assets: %v\n", err) + os.Exit(1) +} diff --git a/web/wasm-simulate-demo/vendor/browser_wasi_shim.js b/web/wasm-simulate-demo/vendor/browser_wasi_shim.js new file mode 100644 index 00000000..8120cc3e --- /dev/null +++ b/web/wasm-simulate-demo/vendor/browser_wasi_shim.js @@ -0,0 +1 @@ +!function(t,e){if("object"==typeof exports&&"object"==typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var r=e();for(var s in r)("object"==typeof exports?exports:t)[s]=r[s]}}(self,(()=>(()=>{"use strict";var t={759:(t,e,r)=>{r.d(e,{CLOCKID_MONOTONIC:()=>n,CLOCKID_REALTIME:()=>s,Ciovec:()=>_,Dirent:()=>c,ERRNO_BADF:()=>i,ERRNO_INVAL:()=>f,FILETYPE_DIRECTORY:()=>u,FILETYPE_REGULAR_FILE:()=>h,Fdstat:()=>p,Filestat:()=>b,Iovec:()=>l,OFLAGS_CREAT:()=>y,OFLAGS_DIRECTORY:()=>m,OFLAGS_EXCL:()=>w,OFLAGS_TRUNC:()=>g,Prestat:()=>E,WHENCE_CUR:()=>d,WHENCE_END:()=>o,WHENCE_SET:()=>a});const s=0,n=1,i=8,f=28;class l{static read_bytes(t,e){let r=new l;return r.buf=t.getUint32(e,!0),r.buf_len=t.getUint32(e+4,!0),r}static read_bytes_array(t,e,r){let s=[];for(let n=0;n{for(var s in e)r.o(e,s)&&!r.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:e[s]})},r.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})};var s={};return(()=>{r.r(s),r.d(s,{Directory:()=>f,Fd:()=>n,File:()=>i,OpenDirectory:()=>_,OpenFile:()=>l,PreopenDirectory:()=>a,WASI:()=>e,strace:()=>d});var t=r(759);let e=class{start(t){this.inst=t,t.exports._start()}initialize(t){this.inst=t,t.exports._initialize()}constructor(e,r,s){this.args=[],this.env=[],this.fds=[],this.args=e,this.env=r,this.fds=s;let n=this;this.wasiImport={args_sizes_get(t,e){let r=new DataView(n.inst.exports.memory.buffer);r.setUint32(t,n.args.length,!0);let s=0;for(let t of n.args)s+=t.length+1;return r.setUint32(e,s,!0),0},args_get(t,e){let r=new DataView(n.inst.exports.memory.buffer),s=new Uint8Array(n.inst.exports.memory.buffer);for(let i=0;inull!=n.fds[e]?n.fds[e].fd_advise(r,s,i):t.ERRNO_BADF,fd_allocate:(e,r,s)=>null!=n.fds[e]?n.fds[e].fd_allocate(r,s):t.ERRNO_BADF,fd_close(e){if(null!=n.fds[e]){let t=n.fds[e].fd_close();return n.fds[e]=void 0,t}return t.ERRNO_BADF},fd_datasync:e=>null!=n.fds[e]?n.fds[e].fd_datasync():t.ERRNO_BADF,fd_fdstat_get(e,r){if(null!=n.fds[e]){let{ret:t,fdstat:s}=n.fds[e].fd_fdstat_get();return null!=s&&s.write_bytes(new DataView(n.inst.exports.memory.buffer),r),t}return t.ERRNO_BADF},fd_fdstat_set_flags:(e,r)=>null!=n.fds[e]?n.fds[e].fd_fdstat_set_flags(r):t.ERRNO_BADF,fd_fdstat_set_rights:(e,r,s)=>null!=n.fds[e]?n.fds[e].fd_fdstat_set_rights(r,s):t.ERRNO_BADF,fd_filestat_get(e,r){if(null!=n.fds[e]){let{ret:t,filestat:s}=n.fds[e].fd_filestat_get();return null!=s&&s.write_bytes(new DataView(n.inst.exports.memory.buffer),r),t}return t.ERRNO_BADF},fd_filestat_set_size:(e,r)=>null!=n.fds[e]?n.fds[e].fd_filestat_set_size(r):t.ERRNO_BADF,fd_filestat_set_times:(e,r,s,i)=>null!=n.fds[e]?n.fds[e].fd_filestat_set_times(r,s,i):t.ERRNO_BADF,fd_pread(e,r,s,i,f){let l=new DataView(n.inst.exports.memory.buffer),_=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let a=t.Iovec.read_bytes_array(l,r,s),{ret:d,nread:o}=n.fds[e].fd_pread(_,a,i);return l.setUint32(f,o,!0),d}return t.ERRNO_BADF},fd_prestat_get(e,r){let s=new DataView(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let{ret:t,prestat:i}=n.fds[e].fd_prestat_get();return null!=i&&i.write_bytes(s,r),t}return t.ERRNO_BADF},fd_prestat_dir_name(e,r,s){if(null!=n.fds[e]){let{ret:t,prestat_dir_name:s}=n.fds[e].fd_prestat_dir_name();return null!=s&&new Uint8Array(n.inst.exports.memory.buffer).set(s,r),t}return t.ERRNO_BADF},fd_pwrite(e,r,s,i,f){let l=new DataView(n.inst.exports.memory.buffer),_=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let a=t.Ciovec.read_bytes_array(l,r,s),{ret:d,nwritten:o}=n.fds[e].fd_pwrite(_,a,i);return l.setUint32(f,o,!0),d}return t.ERRNO_BADF},fd_read(e,r,s,i){let f=new DataView(n.inst.exports.memory.buffer),l=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let _=t.Iovec.read_bytes_array(f,r,s),{ret:a,nread:d}=n.fds[e].fd_read(l,_);return f.setUint32(i,d,!0),a}return t.ERRNO_BADF},fd_readdir(e,r,s,i,f){let l=new DataView(n.inst.exports.memory.buffer),_=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let t=0;for(;;){let{ret:a,dirent:d}=n.fds[e].fd_readdir_single(i);if(0!=a)return l.setUint32(f,t,!0),a;if(null==d)break;let o=d.length();if(s-tnull!=n.fds[e]?n.fds[e].fd_sync():t.ERRNO_BADF,fd_tell(e,r){let s=new DataView(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let{ret:t,offset:i}=n.fds[e].fd_tell();return s.setUint32(r,i,!0),t}return t.ERRNO_BADF},fd_write(e,r,s,i){let f=new DataView(n.inst.exports.memory.buffer),l=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let _=t.Ciovec.read_bytes_array(f,r,s),{ret:a,nwritten:d}=n.fds[e].fd_write(l,_);return f.setUint32(i,d,!0),a}return t.ERRNO_BADF},path_create_directory(t,e,r){let s=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[t]){let i=new TextDecoder("utf-8").decode(s.slice(e,e+r));return n.fds[t].path_create_directory(i)}},path_filestat_get(e,r,s,i,f){let l=new DataView(n.inst.exports.memory.buffer),_=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let t=new TextDecoder("utf-8").decode(_.slice(s,s+i)),{ret:a,filestat:d}=n.fds[e].path_filestat_get(r,t);return null!=d&&d.write_bytes(l,f),a}return t.ERRNO_BADF},path_filestat_set_times(e,r,s,i,f,l,_){let a=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let t=new TextDecoder("utf-8").decode(a.slice(s,s+i));return n.fds[e].path_filestat_set_times(r,t,f,l,_)}return t.ERRNO_BADF},path_link(e,r,s,i,f,l,_){let a=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]&&null!=n.fds[f]){let t=new TextDecoder("utf-8").decode(a.slice(s,s+i)),d=new TextDecoder("utf-8").decode(a.slice(l,l+_));return n.fds[f].path_link(e,r,t,d)}return t.ERRNO_BADF},path_open(e,r,s,i,f,l,_,a,d){let o=new DataView(n.inst.exports.memory.buffer),u=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let t=new TextDecoder("utf-8").decode(u.slice(s,s+i)),{ret:h,fd_obj:c}=n.fds[e].path_open(r,t,f,l,_,a);if(0!=h)return h;n.fds.push(c);let p=n.fds.length-1;return o.setUint32(d,p,!0),0}return t.ERRNO_BADF},path_readlink(e,r,s,i,f,l){let _=new DataView(n.inst.exports.memory.buffer),a=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let d=new TextDecoder("utf-8").decode(a.slice(r,r+s)),{ret:o,data:u}=n.fds[e].path_readlink(d);if(null!=u){if(u.length>f)return _.setUint32(l,0,!0),t.ERRNO_BADF;a.set(u,i),_.setUint32(l,u.length,!0)}return o}return t.ERRNO_BADF},path_remove_directory(e,r,s){let i=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let t=new TextDecoder("utf-8").decode(i.slice(r,r+s));return n.fds[e].path_remove_directory(t)}return t.ERRNO_BADF},path_rename(t,e,r,s,n,i){throw"FIXME what is the best abstraction for this?"},path_symlink(e,r,s,i,f){let l=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[s]){let t=new TextDecoder("utf-8").decode(l.slice(e,e+r)),_=new TextDecoder("utf-8").decode(l.slice(i,i+f));return n.fds[s].path_symlink(t,_)}return t.ERRNO_BADF},path_unlink_file(e,r,s){let i=new Uint8Array(n.inst.exports.memory.buffer);if(null!=n.fds[e]){let t=new TextDecoder("utf-8").decode(i.slice(r,r+s));return n.fds[e].path_unlink_file(t)}return t.ERRNO_BADF},poll_oneoff(t,e,r){throw"async io not supported"},proc_exit(t){throw"exit with exit code "+t},proc_raise(t){throw"raised signal "+t},sched_yield(){},random_get(t,e){let r=new Uint8Array(n.inst.exports.memory.buffer);for(let s=0;s"/"!=t));for(let t=0;tthis.file.size){let t=this.file.data;this.file.data=new Uint8Array(Number(this.file_pos+BigInt(e.byteLength))),this.file.data.set(t)}this.file.data.set(e.slice(0,this.file.size-Number(this.file_pos)),Number(this.file_pos)),this.file_pos+=BigInt(e.byteLength),r+=s.buf_len}return{ret:0,nwritten:r}}fd_filestat_get(){return{ret:0,filestat:this.file.stat()}}constructor(t){super(),this.file_pos=0n,this.file=t}}class _ extends n{fd_fdstat_get(){return{ret:0,fdstat:new t.Fdstat(t.FILETYPE_DIRECTORY,0)}}fd_readdir_single(e){if(e>=BigInt(Object.keys(this.dir.contents).length))return{ret:0,dirent:null};let r=Object.keys(this.dir.contents)[Number(e)],s=this.dir.contents[r];return new TextEncoder("utf-8").encode(r),{ret:0,dirent:new t.Dirent(e+1n,r,s.stat().filetype)}}path_filestat_get(t,e){let r=this.dir.get_entry_for_path(e);return null==r?{ret:-1,filestat:null}:{ret:0,filestat:r.stat()}}path_open(e,r,s,n,a,d){let o=this.dir.get_entry_for_path(r);if(null==o){if((s&t.OFLAGS_CREAT)!=t.OFLAGS_CREAT)return{ret:-1,fd_obj:null};o=this.dir.create_entry_for_path(r)}else if((s&t.OFLAGS_EXCL)==t.OFLAGS_EXCL)return{ret:-1,fd_obj:null};if((s&t.OFLAGS_DIRECTORY)==t.OFLAGS_DIRECTORY&&o.stat().filetype!=t.FILETYPE_DIRECTORY)return{ret:-1,fd_obj:null};if((s&t.OFLAGS_TRUNC)==t.OFLAGS_TRUNC&&o.truncate(),o instanceof i)return{ret:0,fd_obj:new l(o)};if(o instanceof f)return{ret:0,fd_obj:new _(o)};throw"dir entry neither file nor dir"}constructor(t){super(),this.dir=t}}class a extends _{fd_prestat_get(){return{ret:0,prestat:t.Prestat.dir(this.prestat_name.length)}}fd_prestat_dir_name(){return{ret:0,prestat_dir_name:this.prestat_name}}constructor(t,e){super(new f(e)),this.prestat_name=new TextEncoder("utf-8").encode(t)}}function d(t,e){return new Proxy(t,{get(t,r,s){let n=Reflect.get(t,r,s);return e.includes(r)?n:function(...t){return console.log(r,"(",...t,")"),Reflect.apply(n,s,t)}}})}})(),s})())); diff --git a/web/wasm-simulate-demo/vendor/wasi_defs.js b/web/wasm-simulate-demo/vendor/wasi_defs.js new file mode 100644 index 00000000..5f7356d9 --- /dev/null +++ b/web/wasm-simulate-demo/vendor/wasi_defs.js @@ -0,0 +1 @@ +!function(_,R){if("object"==typeof exports&&"object"==typeof module)module.exports=R();else if("function"==typeof define&&define.amd)define([],R);else{var E=R();for(var N in E)("object"==typeof exports?exports:_)[N]=E[N]}}(self,(()=>(()=>{"use strict";var _={d:(R,E)=>{for(var N in E)_.o(E,N)&&!_.o(R,N)&&Object.defineProperty(R,N,{enumerable:!0,get:E[N]})},o:(_,R)=>Object.prototype.hasOwnProperty.call(_,R),r:_=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(_,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(_,"__esModule",{value:!0})}},R={};_.r(R),_.d(R,{ADVICE_DONTNEED:()=>hR,ADVICE_NOREUSE:()=>lR,ADVICE_NORMAL:()=>UR,ADVICE_RANDOM:()=>oR,ADVICE_SEQUENTIAL:()=>HR,ADVICE_WILLNEED:()=>aR,CLOCKID_MONOTONIC:()=>T,CLOCKID_PROCESS_CPUTIME_ID:()=>S,CLOCKID_REALTIME:()=>O,CLOCKID_THREAD_CPUTIME_ID:()=>e,Ciovec:()=>eR,Dirent:()=>PR,ERRNO_2BIG:()=>A,ERRNO_ACCES:()=>i,ERRNO_ADDRINUSE:()=>s,ERRNO_ADDRNOTAVAIL:()=>L,ERRNO_AFNOSUPPORT:()=>n,ERRNO_AGAIN:()=>D,ERRNO_ALREADY:()=>G,ERRNO_BADF:()=>C,ERRNO_BADMSG:()=>r,ERRNO_BUSY:()=>F,ERRNO_CANCELED:()=>P,ERRNO_CHILD:()=>U,ERRNO_CONNABORTED:()=>H,ERRNO_CONNREFUSED:()=>o,ERRNO_CONNRESET:()=>a,ERRNO_DEADLK:()=>h,ERRNO_DESTADDRREQ:()=>l,ERRNO_DOM:()=>c,ERRNO_DQUOT:()=>M,ERRNO_EXIST:()=>d,ERRNO_FAULT:()=>f,ERRNO_FBIG:()=>y,ERRNO_HOSTUNREACH:()=>g,ERRNO_IDRM:()=>b,ERRNO_ILSEQ:()=>Y,ERRNO_INPROGRESS:()=>B,ERRNO_INTR:()=>u,ERRNO_INVAL:()=>K,ERRNO_IO:()=>p,ERRNO_ISCONN:()=>V,ERRNO_ISDIR:()=>m,ERRNO_LOOP:()=>W,ERRNO_MFILE:()=>w,ERRNO_MLINK:()=>v,ERRNO_MSGSIZE:()=>X,ERRNO_MULTIHOP:()=>j,ERRNO_NAMETOOLONG:()=>x,ERRNO_NETDOWN:()=>Q,ERRNO_NETRESET:()=>Z,ERRNO_NETUNREACH:()=>k,ERRNO_NFILE:()=>z,ERRNO_NOBUFS:()=>q,ERRNO_NODEV:()=>J,ERRNO_NOENT:()=>$,ERRNO_NOEXEC:()=>__,ERRNO_NOLCK:()=>R_,ERRNO_NOLINK:()=>E_,ERRNO_NOMEM:()=>N_,ERRNO_NOMSG:()=>t_,ERRNO_NOPROTOOPT:()=>O_,ERRNO_NOSPC:()=>T_,ERRNO_NOSYS:()=>S_,ERRNO_NOTCAPABLE:()=>y_,ERRNO_NOTCONN:()=>e_,ERRNO_NOTDIR:()=>I_,ERRNO_NOTEMPTY:()=>A_,ERRNO_NOTRECOVERABLE:()=>i_,ERRNO_NOTSOCK:()=>s_,ERRNO_NOTSUP:()=>L_,ERRNO_NOTTY:()=>n_,ERRNO_NXIO:()=>D_,ERRNO_OVERFLOW:()=>G_,ERRNO_OWNERDEAD:()=>C_,ERRNO_PERM:()=>r_,ERRNO_PIPE:()=>F_,ERRNO_PROTO:()=>P_,ERRNO_PROTONOSUPPORT:()=>U_,ERRNO_PROTOTYPE:()=>H_,ERRNO_RANGE:()=>o_,ERRNO_ROFS:()=>a_,ERRNO_SPIPE:()=>h_,ERRNO_SRCH:()=>l_,ERRNO_STALE:()=>c_,ERRNO_SUCCESS:()=>I,ERRNO_TIMEDOUT:()=>M_,ERRNO_TXTBSY:()=>d_,ERRNO_XDEV:()=>f_,EVENTRWFLAGS_FD_READWRITE_HANGUP:()=>jR,EVENTTYPE_CLOCK:()=>wR,EVENTTYPE_FD_READ:()=>vR,EVENTTYPE_FD_WRITE:()=>XR,FDFLAGS_APPEND:()=>cR,FDFLAGS_DSYNC:()=>MR,FDFLAGS_NONBLOCK:()=>dR,FDFLAGS_RSYNC:()=>fR,FDFLAGS_SYNC:()=>yR,FD_STDERR:()=>t,FD_STDIN:()=>E,FD_STDOUT:()=>N,FILETYPE_BLOCK_DEVICE:()=>LR,FILETYPE_CHARACTER_DEVICE:()=>nR,FILETYPE_DIRECTORY:()=>DR,FILETYPE_REGULAR_FILE:()=>GR,FILETYPE_SOCKET_DGRAM:()=>CR,FILETYPE_SOCKET_STREAM:()=>rR,FILETYPE_SYMBOLIC_LINK:()=>FR,FILETYPE_UNKNOWN:()=>sR,FSTFLAGS_ATIM:()=>bR,FSTFLAGS_ATIM_NOW:()=>YR,FSTFLAGS_MTIM:()=>BR,FSTFLAGS_MTIM_NOW:()=>uR,Fdstat:()=>gR,Filestat:()=>WR,Iovec:()=>SR,OFLAGS_CREAT:()=>KR,OFLAGS_DIRECTORY:()=>pR,OFLAGS_EXCL:()=>VR,OFLAGS_TRUNC:()=>mR,PREOPENTYPE_DIR:()=>dE,Prestat:()=>yE,PrestatDir:()=>fE,RIFLAGS_RECV_PEEK:()=>aE,RIFLAGS_RECV_WAITALL:()=>hE,RIGHTS_FD_ADVISE:()=>V_,RIGHTS_FD_ALLOCATE:()=>m_,RIGHTS_FD_DATASYNC:()=>g_,RIGHTS_FD_FDSTAT_SET_FLAGS:()=>B_,RIGHTS_FD_FILESTAT_GET:()=>$_,RIGHTS_FD_FILESTAT_SET_SIZE:()=>_R,RIGHTS_FD_FILESTAT_SET_TIMES:()=>RR,RIGHTS_FD_READ:()=>b_,RIGHTS_FD_READDIR:()=>x_,RIGHTS_FD_SEEK:()=>Y_,RIGHTS_FD_SYNC:()=>u_,RIGHTS_FD_TELL:()=>K_,RIGHTS_FD_WRITE:()=>p_,RIGHTS_PATH_CREATE_DIRECTORY:()=>W_,RIGHTS_PATH_CREATE_FILE:()=>w_,RIGHTS_PATH_FILESTAT_GET:()=>z_,RIGHTS_PATH_FILESTAT_SET_SIZE:()=>q_,RIGHTS_PATH_FILESTAT_SET_TIMES:()=>J_,RIGHTS_PATH_LINK_SOURCE:()=>v_,RIGHTS_PATH_LINK_TARGET:()=>X_,RIGHTS_PATH_OPEN:()=>j_,RIGHTS_PATH_READLINK:()=>Q_,RIGHTS_PATH_REMOVE_DIRECTORY:()=>NR,RIGHTS_PATH_RENAME_SOURCE:()=>Z_,RIGHTS_PATH_RENAME_TARGET:()=>k_,RIGHTS_PATH_SYMLINK:()=>ER,RIGHTS_PATH_UNLINK_FILE:()=>tR,RIGHTS_POLL_FD_READWRITE:()=>OR,RIGHTS_SOCK_SHUTDOWN:()=>TR,ROFLAGS_RECV_DATA_TRUNCATED:()=>lE,SDFLAGS_RD:()=>cE,SDFLAGS_WR:()=>ME,SIGNAL_ABRT:()=>$R,SIGNAL_ALRM:()=>SE,SIGNAL_BUS:()=>_E,SIGNAL_CHLD:()=>IE,SIGNAL_CONT:()=>AE,SIGNAL_FPE:()=>RE,SIGNAL_HUP:()=>ZR,SIGNAL_ILL:()=>qR,SIGNAL_INT:()=>kR,SIGNAL_KILL:()=>EE,SIGNAL_NONE:()=>QR,SIGNAL_PIPE:()=>TE,SIGNAL_POLL:()=>UE,SIGNAL_PROF:()=>FE,SIGNAL_PWR:()=>HE,SIGNAL_QUIT:()=>zR,SIGNAL_SEGV:()=>tE,SIGNAL_STOP:()=>iE,SIGNAL_SYS:()=>oE,SIGNAL_TERM:()=>eE,SIGNAL_TRAP:()=>JR,SIGNAL_TSTP:()=>sE,SIGNAL_TTIN:()=>LE,SIGNAL_TTOU:()=>nE,SIGNAL_URG:()=>DE,SIGNAL_USR1:()=>NE,SIGNAL_USR2:()=>OE,SIGNAL_VTALRM:()=>rE,SIGNAL_WINCH:()=>PE,SIGNAL_XCPU:()=>GE,SIGNAL_XFSZ:()=>CE,SUBCLOCKFLAGS_SUBSCRIPTION_CLOCK_ABSTIME:()=>xR,WHENCE_CUR:()=>AR,WHENCE_END:()=>iR,WHENCE_SET:()=>IR});const E=0,N=1,t=2,O=0,T=1,S=2,e=3,I=0,A=1,i=2,s=3,L=4,n=5,D=6,G=7,C=8,r=9,F=10,P=11,U=12,H=13,o=14,a=15,h=16,l=17,c=18,M=19,d=20,f=21,y=22,g=23,b=24,Y=25,B=26,u=27,K=28,p=29,V=30,m=31,W=32,w=33,v=34,X=35,j=36,x=37,Q=38,Z=39,k=40,z=41,q=42,J=43,$=44,__=45,R_=46,E_=47,N_=48,t_=49,O_=50,T_=51,S_=52,e_=53,I_=54,A_=55,i_=56,s_=57,L_=58,n_=59,D_=60,G_=61,C_=62,r_=63,F_=64,P_=65,U_=66,H_=67,o_=68,a_=69,h_=70,l_=71,c_=72,M_=73,d_=74,f_=75,y_=76,g_=1,b_=2,Y_=4,B_=8,u_=16,K_=32,p_=64,V_=128,m_=256,W_=512,w_=1024,v_=2048,X_=4096,j_=8192,x_=16384,Q_=32768,Z_=65536,k_=1<<17,z_=1<<18,q_=1<<19,J_=1<<20,$_=1<<21,_R=1<<22,RR=1<<23,ER=1<<24,NR=1<<25,tR=1<<26,OR=1<<27,TR=1<<28;class SR{static read_bytes(_,R){let E=new SR;return E.buf=_.getUint32(R,!0),E.buf_len=_.getUint32(R+4,!0),E}static read_bytes_array(_,R,E){let N=[];for(let t=0;t