From 5f1bbff519b390207e16dbffb8c1baf78f8fa3f3 Mon Sep 17 00:00:00 2001 From: Sree Narayanan Date: Fri, 10 Jul 2026 16:35:59 +0400 Subject: [PATCH 1/2] standardize cf workbench integration --- bun.lock | 33 -- packages/sideffect/src/index.ts | 1 + packages/sideffect/src/types.ts | 23 +- .../sideffect/src/vite/generated-entry.ts | 7 +- packages/sideffect/src/workflow.ts | 16 +- packages/sideffect/tests/ctx-env.types.ts | 9 + packages/sideffect/tests/index.test.ts | 1 + .../cloudflare-workflows-shared/src/cases.ts | 69 --- .../cloudflare-workflows-shared/src/index.ts | 4 +- .../src/workflow-cases.ts | 138 +++++ .../test-support/workflow-http.ts | 77 +++ .../cloudflare-workflows-tanstack/bun.lock | 3 - .../package.json | 5 +- .../sideffect-env.d.ts | 43 +- .../src/routes/__root.tsx | 18 +- .../src/routes/index.tsx | 337 ++++++------ .../src/styles.css | 498 +++++++++++++++++- .../src/worker.ts | 16 +- .../src/workflow-cases.ts | 10 +- .../tests/cloudflare-workflows.e2e.test.ts | 194 ------- .../tests/discovery.e2e.test.ts | 54 ++ .../tests/integration.e2e.test.ts | 99 ++++ .../cloudflare-workflows-vite/package.json | 2 +- .../sideffect-env.d.ts | 43 +- .../cloudflare-workflows-vite/src/index.ts | 49 +- .../tests/cloudflare-workflows.e2e.test.ts | 199 ------- .../tests/discovery.e2e.test.ts | 56 ++ .../tests/workflows.e2e.test.ts | 144 +++++ 28 files changed, 1378 insertions(+), 770 deletions(-) delete mode 100644 workbench/cloudflare-workflows-shared/src/cases.ts create mode 100644 workbench/cloudflare-workflows-shared/src/workflow-cases.ts create mode 100644 workbench/cloudflare-workflows-shared/test-support/workflow-http.ts delete mode 100644 workbench/cloudflare-workflows-tanstack/tests/cloudflare-workflows.e2e.test.ts create mode 100644 workbench/cloudflare-workflows-tanstack/tests/discovery.e2e.test.ts create mode 100644 workbench/cloudflare-workflows-tanstack/tests/integration.e2e.test.ts delete mode 100644 workbench/cloudflare-workflows-vite/tests/cloudflare-workflows.e2e.test.ts create mode 100644 workbench/cloudflare-workflows-vite/tests/discovery.e2e.test.ts create mode 100644 workbench/cloudflare-workflows-vite/tests/workflows.e2e.test.ts diff --git a/bun.lock b/bun.lock index 6575e7c..30979a2 100644 --- a/bun.lock +++ b/bun.lock @@ -110,14 +110,11 @@ "dependencies": { "@cloudflare/vite-plugin": "^1.40.0", "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-devtools": "latest", "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", "@tanstack/react-router-ssr-query": "latest", "@tanstack/react-start": "latest", "@tanstack/router-plugin": "^1.132.0", "cloudflare-workflows-shared": "workspace:", - "effect": "4.0.0-beta.78", "lucide-react": "^0.545.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -714,18 +711,6 @@ "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="], - - "@solid-primitives/keyboard": ["@solid-primitives/keyboard@1.3.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-sav+l+PL+74z3yaftVs7qd8c2SXkqzuxPOVibUe5wYMt+U5Hxp3V3XCPgBPN2I6cANjvoFtz0NiU8uHVLdi9FQ=="], - - "@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw=="], - - "@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], - - "@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ=="], - - "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], - "@speed-highlight/core": ["@speed-highlight/core@1.2.16", "", {}, "sha512-yNm/fYEcnpRjYduLMaddTK9XKYil6xB88+qFg79ZdZhHu1PadfoQmFW7pVTx7FZqMBNcUuThiAhxhENgtAO2/w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -764,30 +749,22 @@ "@tailwindcss/webpack": ["@tailwindcss/webpack@4.2.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.0", "@tailwindcss/oxide": "4.2.0", "tailwindcss": "4.2.0" }, "peerDependencies": { "webpack": "^5" } }, "sha512-TohlILOzF/3l+Yolll9iD2b5bsLG/1TuxAXPTxJcA77YAgPyKKc5RIwj4vIjg4l9DFMidekx2Z0BWyPAle9TUA=="], - "@tanstack/devtools": ["@tanstack/devtools@0.12.2", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.6", "@tanstack/devtools-event-bus": "0.4.1", "@tanstack/devtools-ui": "0.5.2", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" }, "bin": { "intent": "./bin/intent.js" } }, "sha512-Xdl8pLzoDUvXaclQ0poY36WAPx0jEHk8vqUFd8FYFUm1BMshtB7RnTgD1HE9jCAXODxqw9I0gXBiUZLK3o3+Bw=="], - "@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.6", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1" } }, "sha512-f85ZJXJnDIFOoykG/BFIixuAevJovCvJF391LPs6YjBAPhGYC50NWlx1y4iF/UmK5/cCMx+/JqI5SBOz7FanQQ=="], "@tanstack/devtools-event-bus": ["@tanstack/devtools-event-bus@0.4.1", "", { "dependencies": { "ws": "^8.18.3" } }, "sha512-cNnJ89Q021Zf883rlbBTfsaxTfi2r73/qejGtyTa7ksErF3hyDyAq1aTbo5crK9dAL7zSHh9viKY1BtMls1QOA=="], "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], - "@tanstack/devtools-ui": ["@tanstack/devtools-ui@0.5.2", "", { "dependencies": { "clsx": "^2.1.1", "dayjs": "^1.11.19", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-GtaMk8kaGZ9ZdR8Pu5RAfcse/ZrxzH/xsAIFtHMapLs2VMqSPFfb1NvIDO1MAAfUcub8Ix8XKQEP0uYSPzoFKw=="], - "@tanstack/devtools-vite": ["@tanstack/devtools-vite@0.7.0", "", { "dependencies": { "@tanstack/devtools-client": "0.0.6", "@tanstack/devtools-event-bus": "0.4.1", "chalk": "^5.6.2", "launch-editor": "^2.11.1", "magic-string": "^0.30.0", "oxc-parser": "^0.120.0", "picomatch": "^4.0.3" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "bin": { "intent": "./bin/intent.js" } }, "sha512-VXki7K+Xwnpo3IKdNSWGe7YOvtZv33YlulGqaQ+YCpeQhYg8JFuxP50BXibDoRLj5EOX4r21Hs7COdxbRHXkTw=="], "@tanstack/history": ["@tanstack/history@1.162.0", "", {}, "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA=="], "@tanstack/query-core": ["@tanstack/query-core@5.101.0", "", {}, "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow=="], - "@tanstack/react-devtools": ["@tanstack/react-devtools@0.10.5", "", { "dependencies": { "@tanstack/devtools": "0.12.2" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-orVsRJ7oAXFb7oyafQCgx9YuK44jpILh5T/ddYuxAsolNfN5DZBr5/NLrWErD7HCGIzvYzg1TZI4sPxmiKvtvA=="], - "@tanstack/react-query": ["@tanstack/react-query@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg=="], "@tanstack/react-router": ["@tanstack/react-router@1.170.15", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.13", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-GawYz7HEjj8rTUUDoT/SemDEVm63pZUO+2mOcXHY9Jl3EwMS5gFBnPu/2UvcrwRm1jN1k79fokc0d4aFmrLatg=="], - "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.167.0", "", { "dependencies": { "@tanstack/router-devtools-core": "1.168.0" }, "peerDependencies": { "@tanstack/react-router": "^1.170.0", "@tanstack/router-core": "^1.170.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-nGw095EG7IHx0h5NtlEmzf6vcCTaFNPWdTSuDKazajhN0ct/v/TkekJ9J6KYUCeV1a8/2ZmToc58M+0rrOyn7w=="], - "@tanstack/react-router-ssr-query": ["@tanstack/react-router-ssr-query@1.167.1", "", { "dependencies": { "@tanstack/router-ssr-query-core": "1.169.1" }, "peerDependencies": { "@tanstack/query-core": ">=5.90.0", "@tanstack/react-query": ">=5.90.0", "@tanstack/react-router": ">=1.127.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-W9j5JPnBikyafvuUfykFfHIWod58OAbAAa5leNkXBcoDoocghMmu6w9uZOmUZvAWT7CSvgj5tBUtF7CM2OoHXQ=="], "@tanstack/react-start": ["@tanstack/react-start@1.168.25", "", { "dependencies": { "@tanstack/react-router": "1.170.15", "@tanstack/react-start-client": "1.168.13", "@tanstack/react-start-rsc": "0.1.24", "@tanstack/react-start-server": "1.167.19", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.12", "@tanstack/start-plugin-core": "1.171.17", "@tanstack/start-server-core": "1.169.14", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-aHlg9YTSeL12gWrYIHAEzoncPHc5JUbQ60Sc26OQ7J1zcsXqdKwdcqaApG4YV12S/keFdbndHjxaiYkUcJlx7Q=="], @@ -804,8 +781,6 @@ "@tanstack/router-core": ["@tanstack/router-core@1.171.13", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA=="], - "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.168.0", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.170.0", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-wQoQhlBK7nlZgqzaqdYXKWNTpdHdsaREdaPhFZVH0/Ador+F+eM3/NF2i3f2LPeS0GgKraZUQXe1Q/1+KHyEYg=="], - "@tanstack/router-generator": ["@tanstack/router-generator@1.167.17", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.13", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-xtB9tB2Ws0tWR6Pi7nc3Qk9IYgoh1mQCKWjHqIl9tf6BNUpKoqniJoPAQ4+LGrK8FeZYU0o0p/qlZEyj9FAulA=="], "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.18", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.13", "@tanstack/router-generator": "1.167.17", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.15", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-MofS28/axfnfnhOD2RSgJEaU882aX5RsAzhGz5Vc4XhAmvCjy919u9JrNs4QsTWFbTD1P7IJ8WFlFVsrg0pStg=="], @@ -1032,8 +1007,6 @@ "cloudflare-workflows-vite": ["cloudflare-workflows-vite@workspace:workbench/cloudflare-workflows-vite"], - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -1062,8 +1035,6 @@ "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], - "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], @@ -1192,8 +1163,6 @@ "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], - "goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="], - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "h3-v2": ["h3@2.0.1-rc.20", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.13" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg=="], @@ -1520,8 +1489,6 @@ "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - "solid-js": ["solid-js@1.9.13", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/packages/sideffect/src/index.ts b/packages/sideffect/src/index.ts index 8562fee..aa578cd 100644 --- a/packages/sideffect/src/index.ts +++ b/packages/sideffect/src/index.ts @@ -16,6 +16,7 @@ export type { WorkflowContext, WorkflowDefinition, WorkflowEvent, + WorkflowInput, WorkflowLayer, WorkflowRun, } from "./types.ts"; diff --git a/packages/sideffect/src/types.ts b/packages/sideffect/src/types.ts index 9aee679..8e5297d 100644 --- a/packages/sideffect/src/types.ts +++ b/packages/sideffect/src/types.ts @@ -183,13 +183,13 @@ export type WorkflowRun = ( ) => MaybeEffect; /** Sideffect workflow definition created by `Workflow.make(...)`. */ -export interface WorkflowDefinition { +export interface WorkflowDefinition { /** Runtime tag used by Sideffect to validate workflow definitions. */ readonly _tag: "WorkflowDefinition"; /** Cloudflare Workflow name. */ readonly name: string; /** Schema used to decode incoming workflow event payloads. */ - readonly payloadSchema: Schema.Schema; + readonly payloadSchema: Schema.Codec; /** * Binds a workflow definition to its implementation. * @@ -198,21 +198,32 @@ export interface WorkflowDefinition { */ toLayer( run: WorkflowRun, - ): WorkflowLayer; + ): WorkflowLayer; } /** Runnable Sideffect workflow layer. */ -export interface WorkflowLayer { +export interface WorkflowLayer< + Payload, + Result = unknown, + Env = DefaultCloudflareEnv, + Input = Payload, +> { /** Runtime tag used by Sideffect to validate workflow layers. */ readonly _tag: "WorkflowLayer"; /** Workflow definition and payload schema. */ - readonly workflow: WorkflowDefinition; + readonly workflow: WorkflowDefinition; /** Workflow implementation. */ readonly run: WorkflowRun; } +/** Extracts the encoded input accepted by a Sideffect workflow binding. */ +export type WorkflowInput = + Layer extends WorkflowLayer + ? Input + : never; + /** @internal Any Sideffect workflow layer. */ -export type WorkflowLayerAny = WorkflowLayer; +export type WorkflowLayerAny = WorkflowLayer; /** @internal Named workflow layers used to generate Cloudflare entrypoints. */ export type WorkflowLayerEntries = Record; diff --git a/packages/sideffect/src/vite/generated-entry.ts b/packages/sideffect/src/vite/generated-entry.ts index 5a86b1d..3affe09 100644 --- a/packages/sideffect/src/vite/generated-entry.ts +++ b/packages/sideffect/src/vite/generated-entry.ts @@ -112,23 +112,22 @@ function generateWorkflowEnvTypes( .join("\n"); const envBindings = workflows .map((workflow, index) => { - const payload = `__SideffectWorkflowPayload`; + const payload = `WorkflowInput`; return ` ${workflow.config.binding}: __SideffectCloudflareWorkflow<${payload}>;`; }) .join("\n"); const cloudflareEnvBindings = workflows .map((workflow, index) => { - const payload = `__SideffectWorkflowPayload`; + const payload = `WorkflowInput`; return ` ${workflow.config.binding}: __SideffectCloudflareWorkflow<${payload}>;`; }) .join("\n"); return `// Generated by Sideffect. Do not edit. -import type { WorkflowLayer } from "sideffect"; +import type { WorkflowInput } from "sideffect"; ${imports} type __SideffectCloudflareWorkflow = Workflow; -type __SideffectWorkflowPayload = T extends WorkflowLayer ? Payload : never; declare global { interface Env { diff --git a/packages/sideffect/src/workflow.ts b/packages/sideffect/src/workflow.ts index 8fbf97d..fe85b1f 100644 --- a/packages/sideffect/src/workflow.ts +++ b/packages/sideffect/src/workflow.ts @@ -8,11 +8,11 @@ import type { } from "./types.ts"; /** Options for creating a Sideffect workflow definition. */ -export interface WorkflowMakeOptions { +export interface WorkflowMakeOptions { /** Cloudflare Workflow name. */ readonly name: string; /** Schema used to decode incoming workflow event payloads. */ - readonly payload: Schema.Schema; + readonly payload: Schema.Codec; } /** Helpers for defining Sideffect workflows. */ @@ -35,14 +35,16 @@ export const Workflow = { * }); * ``` */ - make( - options: WorkflowMakeOptions, - ): WorkflowDefinition { - const definition: WorkflowDefinition = { + make( + options: WorkflowMakeOptions, + ): WorkflowDefinition { + const definition: WorkflowDefinition = { _tag: "WorkflowDefinition", name: options.name, payloadSchema: options.payload, - toLayer(run: WorkflowRun): WorkflowLayer { + toLayer( + run: WorkflowRun, + ): WorkflowLayer { return { _tag: "WorkflowLayer", workflow: definition, diff --git a/packages/sideffect/tests/ctx-env.types.ts b/packages/sideffect/tests/ctx-env.types.ts index 75dd6e3..b5f7981 100644 --- a/packages/sideffect/tests/ctx-env.types.ts +++ b/packages/sideffect/tests/ctx-env.types.ts @@ -1,4 +1,5 @@ import { Rollback, Schema, Step, Workflow } from "../src/index.ts"; +import type { WorkflowInput } from "../src/index.ts"; declare global { namespace Cloudflare { @@ -41,4 +42,12 @@ Workflow.make({ return step.do(envBackedStep, workflow.payload); }); +const encodedWorkflow = Workflow.make({ + name: "encoded-workflow", + payload: Schema.NumberFromString, +}).toLayer(async ({ payload }) => payload); + +const encodedInput: WorkflowInput = "42"; +void encodedInput; + export {}; diff --git a/packages/sideffect/tests/index.test.ts b/packages/sideffect/tests/index.test.ts index 4c2da0d..cc2e43d 100644 --- a/packages/sideffect/tests/index.test.ts +++ b/packages/sideffect/tests/index.test.ts @@ -759,6 +759,7 @@ test("Sideffect workflows plugin discovers workflows from Vite root before confi const envTypes = readFileSync(join(root, "sideffect-env.d.ts"), "utf8"); expect(envTypes).not.toContain("Workflow as CloudflareWorkflow"); expect(envTypes).not.toContain('from "cloudflare:workers"'); + expect(envTypes).toContain('import type { WorkflowInput } from "sideffect";'); expect(envTypes).toContain( "type __SideffectCloudflareWorkflow = Workflow;", ); diff --git a/workbench/cloudflare-workflows-shared/src/cases.ts b/workbench/cloudflare-workflows-shared/src/cases.ts deleted file mode 100644 index 5b9a864..0000000 --- a/workbench/cloudflare-workflows-shared/src/cases.ts +++ /dev/null @@ -1,69 +0,0 @@ -export interface SharedWorkflowCase { - readonly key: string; - readonly binding: string; - readonly className: string; - readonly params: unknown; -} - -export const sharedWorkflowCases = [ - { - key: "add-numbers", - binding: "ADD_NUMBERS", - className: "AddNumbers", - params: { left: 2, right: 3 }, - }, - { - key: "normal-async", - binding: "NORMAL_ASYNC", - className: "NormalAsync", - params: { message: "hello" }, - }, - { - key: "effect-wrapped", - binding: "EFFECT_WRAPPED", - className: "EffectWrapped", - params: { message: "effect" }, - }, - { - key: "binding-roundtrip", - binding: "BINDING_ROUNDTRIP", - className: "BindingRoundtrip", - params: { key: "e2e" }, - }, - { - key: "payload-decoding", - binding: "PAYLOAD_DECODING", - className: "PayloadDecoding", - params: { value: "42" }, - }, - { - key: "step-context", - binding: "STEP_CONTEXT", - className: "StepContext", - params: { label: "ctx" }, - }, - { - key: "pause-and-return", - binding: "PAUSE_AND_RETURN", - className: "PauseAndReturn", - params: { marker: "slept" }, - }, - { - key: "imported-definition", - binding: "IMPORTED_DEFINITION", - className: "ImportedDefinition", - params: { message: "imported" }, - }, - { - key: "default-direct", - binding: "DEFAULT_DIRECT", - className: "DefaultDirect", - params: { marker: "default-direct" }, - }, - { - key: "default-local-layer", - binding: "DEFAULT_LOCAL_LAYER", - className: "DefaultLocalLayer", - params: { message: "default-local" }, - }, -] satisfies Array; diff --git a/workbench/cloudflare-workflows-shared/src/index.ts b/workbench/cloudflare-workflows-shared/src/index.ts index 447c995..2071993 100644 --- a/workbench/cloudflare-workflows-shared/src/index.ts +++ b/workbench/cloudflare-workflows-shared/src/index.ts @@ -1,3 +1,3 @@ -export { sharedWorkflowCases } from "./cases"; -export type { SharedWorkflowCase } from "./cases"; +export { createSharedWorkflow, getSharedWorkflow, sharedWorkflowCases } from "./workflow-cases"; +export type { SharedWorkflowCase, WorkflowCase } from "./workflow-cases"; export * from "./workflows"; diff --git a/workbench/cloudflare-workflows-shared/src/workflow-cases.ts b/workbench/cloudflare-workflows-shared/src/workflow-cases.ts new file mode 100644 index 0000000..46d8fec --- /dev/null +++ b/workbench/cloudflare-workflows-shared/src/workflow-cases.ts @@ -0,0 +1,138 @@ +import type { WorkflowInput } from "sideffect"; + +import type defaultDirectWorkflow from "./default-direct-workflow"; +import type defaultLocalLayerWorkflow from "./default-local-layer-workflow"; +import type { + addNumbersLayer, + bindingRoundtripLayer, + effectWrappedLayer, + importedDefinitionLayer, + normalAsyncLayer, + pauseAndReturnLayer, + payloadDecodingLayer, + stepContextLayer, +} from "./workflows"; + +export interface WorkflowCase { + readonly key: string; + readonly binding: string; + readonly className: string; + readonly params: unknown; +} + +export const sharedWorkflowCases = [ + { + key: "add-numbers", + binding: "ADD_NUMBERS", + className: "AddNumbers", + params: { left: 2, right: 3 }, + }, + { + key: "normal-async", + binding: "NORMAL_ASYNC", + className: "NormalAsync", + params: { message: "hello" }, + }, + { + key: "effect-wrapped", + binding: "EFFECT_WRAPPED", + className: "EffectWrapped", + params: { message: "effect" }, + }, + { + key: "binding-roundtrip", + binding: "BINDING_ROUNDTRIP", + className: "BindingRoundtrip", + params: { key: "e2e" }, + }, + { + key: "payload-decoding", + binding: "PAYLOAD_DECODING", + className: "PayloadDecoding", + params: { value: "42" }, + }, + { + key: "step-context", + binding: "STEP_CONTEXT", + className: "StepContext", + params: { label: "ctx" }, + }, + { + key: "pause-and-return", + binding: "PAUSE_AND_RETURN", + className: "PauseAndReturn", + params: { marker: "slept" }, + }, + { + key: "imported-definition", + binding: "IMPORTED_DEFINITION", + className: "ImportedDefinition", + params: { message: "imported" }, + }, + { + key: "default-direct", + binding: "DEFAULT_DIRECT", + className: "DefaultDirect", + params: { marker: "default-direct" }, + }, + { + key: "default-local-layer", + binding: "DEFAULT_LOCAL_LAYER", + className: "DefaultLocalLayer", + params: { message: "default-local" }, + }, +] as const satisfies ReadonlyArray; + +export type SharedWorkflowCase = (typeof sharedWorkflowCases)[number]; + +interface SharedWorkflowBindings { + readonly ADD_NUMBERS: Workflow>; + readonly NORMAL_ASYNC: Workflow>; + readonly EFFECT_WRAPPED: Workflow>; + readonly BINDING_ROUNDTRIP: Workflow>; + readonly PAYLOAD_DECODING: Workflow>; + readonly STEP_CONTEXT: Workflow>; + readonly PAUSE_AND_RETURN: Workflow>; + readonly IMPORTED_DEFINITION: Workflow>; + readonly DEFAULT_DIRECT: Workflow>; + readonly DEFAULT_LOCAL_LAYER: Workflow>; +} + +/** Creates one of the shared Sideffect workflow examples through its generated binding. */ +export function createSharedWorkflow( + env: SharedWorkflowBindings, + workflowCase: SharedWorkflowCase, + id: string, +): Promise { + switch (workflowCase.binding) { + case "ADD_NUMBERS": + return env.ADD_NUMBERS.create({ id, params: workflowCase.params }); + case "NORMAL_ASYNC": + return env.NORMAL_ASYNC.create({ id, params: workflowCase.params }); + case "EFFECT_WRAPPED": + return env.EFFECT_WRAPPED.create({ id, params: workflowCase.params }); + case "BINDING_ROUNDTRIP": + return env.BINDING_ROUNDTRIP.create({ id, params: workflowCase.params }); + case "PAYLOAD_DECODING": + return env.PAYLOAD_DECODING.create({ id, params: workflowCase.params }); + case "STEP_CONTEXT": + return env.STEP_CONTEXT.create({ id, params: workflowCase.params }); + case "PAUSE_AND_RETURN": + return env.PAUSE_AND_RETURN.create({ id, params: workflowCase.params }); + case "IMPORTED_DEFINITION": + return env.IMPORTED_DEFINITION.create({ id, params: workflowCase.params }); + case "DEFAULT_DIRECT": + return env.DEFAULT_DIRECT.create({ id, params: workflowCase.params }); + case "DEFAULT_LOCAL_LAYER": + return env.DEFAULT_LOCAL_LAYER.create({ id, params: workflowCase.params }); + } +} + +/** Gets an existing instance of one of the shared Sideffect workflow examples. */ +export function getSharedWorkflow( + env: SharedWorkflowBindings, + workflowCase: SharedWorkflowCase, + id: string, +): Promise { + return env[workflowCase.binding].get(id); +} diff --git a/workbench/cloudflare-workflows-shared/test-support/workflow-http.ts b/workbench/cloudflare-workflows-shared/test-support/workflow-http.ts new file mode 100644 index 0000000..601eaa4 --- /dev/null +++ b/workbench/cloudflare-workflows-shared/test-support/workflow-http.ts @@ -0,0 +1,77 @@ +import { createServer } from "vite"; +import type { ViteDevServer } from "vite"; + +export interface WorkflowStatus { + readonly status: string; + readonly output?: unknown; +} + +export interface RunningWorkbench { + readonly baseUrl: string; + readonly close: () => Promise; +} + +/** Starts a workbench through the same Vite development server used locally. */ +export async function startWorkbench(root: string): Promise { + const server: ViteDevServer = await createServer({ root, logLevel: "warn" }); + await server.listen(0); + + const baseUrl = server.resolvedUrls?.local[0]?.replace(/\/$/, ""); + if (!baseUrl) { + await server.close(); + throw new Error("Vite did not expose a local URL for the workflow workbench."); + } + + return { + baseUrl, + close: () => server.close(), + }; +} + +/** Polls a workflow instance until the local Cloudflare runtime completes it. */ +export async function waitForWorkflow( + url: string, + options: { + readonly intervalMs?: number; + readonly timeoutMs?: number; + } = {}, +): Promise { + const intervalMs = options.intervalMs ?? 250; + const timeoutMs = options.timeoutMs ?? 20_000; + const deadline = Date.now() + timeoutMs; + let latest: WorkflowStatus | undefined; + + while (Date.now() < deadline) { + latest = await fetchWorkflowStatus(url); + if (latest.status === "complete") { + return latest; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error( + `Workflow did not complete within ${timeoutMs}ms. Last status: ${latest?.status ?? "unknown"}.`, + ); +} + +async function fetchWorkflowStatus(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request to ${url} failed with ${response.status}: ${await response.text()}`); + } + + const body: unknown = await response.json(); + if (typeof body !== "object" || body === null || !("status" in body)) { + throw new Error(`Request to ${url} returned an invalid workflow status.`); + } + + if (typeof body.status !== "string") { + throw new Error(`Request to ${url} returned a workflow status without a string state.`); + } + + return { + status: body.status, + output: "output" in body ? body.output : undefined, + }; +} diff --git a/workbench/cloudflare-workflows-tanstack/bun.lock b/workbench/cloudflare-workflows-tanstack/bun.lock index e1c320e..91c2b30 100644 --- a/workbench/cloudflare-workflows-tanstack/bun.lock +++ b/workbench/cloudflare-workflows-tanstack/bun.lock @@ -7,13 +7,10 @@ "dependencies": { "@cloudflare/vite-plugin": "^1.40.0", "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-devtools": "latest", "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", "@tanstack/react-router-ssr-query": "latest", "@tanstack/react-start": "latest", "@tanstack/router-plugin": "^1.132.0", - "effect": "4.0.0-beta.78", "lucide-react": "^0.545.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/workbench/cloudflare-workflows-tanstack/package.json b/workbench/cloudflare-workflows-tanstack/package.json index 696c8b0..58564aa 100644 --- a/workbench/cloudflare-workflows-tanstack/package.json +++ b/workbench/cloudflare-workflows-tanstack/package.json @@ -12,20 +12,17 @@ "preview": "bun run build && vp preview", "deploy": "bun run build && wrangler deploy", "deploy:dry-run": "bun run build && wrangler deploy --dry-run", - "test:e2e": "bun run generate-routes && vp test --config vitest.config.e2e.ts tests/cloudflare-workflows.e2e.test.ts", + "test:e2e": "bun run generate-routes && vp test --config vitest.config.e2e.ts tests", "cf-typegen": "wrangler types" }, "dependencies": { "@cloudflare/vite-plugin": "^1.40.0", "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-devtools": "latest", "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", "@tanstack/react-router-ssr-query": "latest", "@tanstack/react-start": "latest", "@tanstack/router-plugin": "^1.132.0", "cloudflare-workflows-shared": "workspace:", - "effect": "4.0.0-beta.78", "lucide-react": "^0.545.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/workbench/cloudflare-workflows-tanstack/sideffect-env.d.ts b/workbench/cloudflare-workflows-tanstack/sideffect-env.d.ts index 2e571cc..3f405fc 100644 --- a/workbench/cloudflare-workflows-tanstack/sideffect-env.d.ts +++ b/workbench/cloudflare-workflows-tanstack/sideffect-env.d.ts @@ -1,5 +1,5 @@ // Generated by Sideffect. Do not edit. -import type { WorkflowLayer } from "sideffect"; +import type { WorkflowInput } from "sideffect"; import type { addNumbersLayer as __SideffectWorkflow0 } from "../cloudflare-workflows-shared/src/workflows"; import type { normalAsyncLayer as __SideffectWorkflow1 } from "../cloudflare-workflows-shared/src/workflows"; import type { effectWrappedLayer as __SideffectWorkflow2 } from "../cloudflare-workflows-shared/src/workflows"; @@ -12,34 +12,33 @@ import type __SideffectWorkflow8 from "../cloudflare-workflows-shared/src/defaul import type __SideffectWorkflow9 from "../cloudflare-workflows-shared/src/default-local-layer-workflow"; type __SideffectCloudflareWorkflow = Workflow; -type __SideffectWorkflowPayload = T extends WorkflowLayer ? Payload : never; declare global { interface Env { - ADD_NUMBERS: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - NORMAL_ASYNC: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - EFFECT_WRAPPED: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - BINDING_ROUNDTRIP: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - PAYLOAD_DECODING: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - STEP_CONTEXT: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - PAUSE_AND_RETURN: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - IMPORTED_DEFINITION: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - DEFAULT_DIRECT: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - DEFAULT_LOCAL_LAYER: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; + ADD_NUMBERS: __SideffectCloudflareWorkflow>; + NORMAL_ASYNC: __SideffectCloudflareWorkflow>; + EFFECT_WRAPPED: __SideffectCloudflareWorkflow>; + BINDING_ROUNDTRIP: __SideffectCloudflareWorkflow>; + PAYLOAD_DECODING: __SideffectCloudflareWorkflow>; + STEP_CONTEXT: __SideffectCloudflareWorkflow>; + PAUSE_AND_RETURN: __SideffectCloudflareWorkflow>; + IMPORTED_DEFINITION: __SideffectCloudflareWorkflow>; + DEFAULT_DIRECT: __SideffectCloudflareWorkflow>; + DEFAULT_LOCAL_LAYER: __SideffectCloudflareWorkflow>; } namespace Cloudflare { interface Env { - ADD_NUMBERS: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - NORMAL_ASYNC: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - EFFECT_WRAPPED: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - BINDING_ROUNDTRIP: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - PAYLOAD_DECODING: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - STEP_CONTEXT: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - PAUSE_AND_RETURN: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - IMPORTED_DEFINITION: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - DEFAULT_DIRECT: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - DEFAULT_LOCAL_LAYER: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; + ADD_NUMBERS: __SideffectCloudflareWorkflow>; + NORMAL_ASYNC: __SideffectCloudflareWorkflow>; + EFFECT_WRAPPED: __SideffectCloudflareWorkflow>; + BINDING_ROUNDTRIP: __SideffectCloudflareWorkflow>; + PAYLOAD_DECODING: __SideffectCloudflareWorkflow>; + STEP_CONTEXT: __SideffectCloudflareWorkflow>; + PAUSE_AND_RETURN: __SideffectCloudflareWorkflow>; + IMPORTED_DEFINITION: __SideffectCloudflareWorkflow>; + DEFAULT_DIRECT: __SideffectCloudflareWorkflow>; + DEFAULT_LOCAL_LAYER: __SideffectCloudflareWorkflow>; } } } diff --git a/workbench/cloudflare-workflows-tanstack/src/routes/__root.tsx b/workbench/cloudflare-workflows-tanstack/src/routes/__root.tsx index 1dbbd99..958c0c2 100644 --- a/workbench/cloudflare-workflows-tanstack/src/routes/__root.tsx +++ b/workbench/cloudflare-workflows-tanstack/src/routes/__root.tsx @@ -1,6 +1,4 @@ import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; -import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; -import { TanStackDevtools } from "@tanstack/react-devtools"; import appCss from "../styles.css?url"; @@ -15,7 +13,7 @@ export const Route = createRootRoute({ content: "width=device-width, initial-scale=1", }, { - title: "TanStack Start Starter", + title: "Cloudflare Workflows · Sideffect", }, ], links: [ @@ -35,18 +33,10 @@ function RootDocument({ children }: { children: React.ReactNode }) { + + Skip to Content + {children} - , - }, - ]} - /> diff --git a/workbench/cloudflare-workflows-tanstack/src/routes/index.tsx b/workbench/cloudflare-workflows-tanstack/src/routes/index.tsx index 065d10c..1bde504 100644 --- a/workbench/cloudflare-workflows-tanstack/src/routes/index.tsx +++ b/workbench/cloudflare-workflows-tanstack/src/routes/index.tsx @@ -1,230 +1,249 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; + import { workflowCases } from "../workflow-cases"; type WorkflowCase = (typeof workflowCases)[number]; +type WorkflowKey = WorkflowCase["key"]; -type WorkflowRunState = - | { readonly status: "running" } - | { readonly status: "success"; readonly id: string; readonly details: unknown } - | { readonly status: "error"; readonly message: string }; +type WorkflowRun = + | { readonly state: "running" } + | { readonly state: "success"; readonly id: string; readonly status: unknown } + | { readonly state: "error"; readonly message: string }; -type WorkflowRunMap = Record; +type WorkflowRuns = Partial>; export const Route = createFileRoute("/")({ loader: () => workflowCases, - component: Home, + component: Workbench, }); -function Home() { - const cases = Route.useLoaderData(); - const [runs, setRuns] = useState({}); - const hasRunningWorkflow = Object.values(runs).some((run) => run.status === "running"); +function Workbench() { + const workflows = Route.useLoaderData(); + const [runs, setRuns] = useState({}); + const isRunning = Object.values(runs).some((run) => run?.state === "running"); - const handleTriggerWorkflow = async (key: string) => { - setRuns((previous) => ({ ...previous, [key]: { status: "running" } })); - const result = await createWorkflowRun(key); - setRuns((previous) => ({ ...previous, [key]: result })); + const runWorkflow = async (workflow: WorkflowCase) => { + setRuns((current) => ({ ...current, [workflow.key]: { state: "running" } })); + const result = await startWorkflow(workflow.key); + setRuns((current) => ({ ...current, [workflow.key]: result })); }; - const handleTriggerAll = async () => { - setRuns((previous) => ({ ...previous, ...createRunningMap(cases) })); + const runAllWorkflows = async () => { + setRuns(createRunningRuns(workflows)); + const results = await Promise.all( - cases.map(async (entry) => [entry.key, await createWorkflowRun(entry.key)] as const), + workflows.map(async (workflow) => [workflow.key, await startWorkflow(workflow.key)] as const), ); - setRuns((previous) => ({ ...previous, ...Object.fromEntries(results) })); + setRuns(Object.fromEntries(results)); }; return ( -
-
-
+
+
+ +
+ +
+
-

- Sideffect Workbench -

-

- TanStack Start uses the shared Cloudflare Workflow matrix -

-

- These cases are the same workflow definitions used by the plain Vite workbench. The - TanStack app adds API routes on top of the generated Cloudflare workflow bindings. +

Cloudflare Workflows

+

+ Run the shared Sideffect examples through TanStack Start and inspect each Cloudflare + workflow instance.

-
+ + +
+
+
+

Workflow Examples

+

Discovered from the shared Sideffect workflow definitions.

+
+ {workflows.length} workflows +
-
- {cases.map((entry) => { - const run = runs[entry.key]; - const isRunning = run?.status === "running"; - - return ( -
-
-
-

{entry.key}

-

{entry.className}

-
- - {entry.binding} - -
- -
-

- POST /api/e2e/workflows/{entry.key} -

- -
- - -
- ); - })} -
+ + +
    + {workflows.map((workflow) => ( + void runWorkflow(workflow)} + /> + ))} +
+
+ +
+ POST /api/workflows/:key + Cloudflare Workers · Sideffect · TanStack Start +
); } -function WorkflowRunResult({ run }: { readonly run: WorkflowRunState | undefined }) { - if (!run) { - return

Not triggered yet.

; - } +function WorkflowRow({ + workflow, + run, + onRun, +}: { + readonly workflow: WorkflowCase; + readonly run: WorkflowRun | undefined; + readonly onRun: () => void; +}) { + const state = run?.state ?? "idle"; + + return ( +
  • +
    +
    + {workflow.key} + {workflow.className} +
    + + + {workflow.binding} + + + + + +
    + + +
  • + ); +} + +function WorkflowStatus({ run }: { readonly run: WorkflowRun | undefined }) { + const state = run?.state ?? "idle"; + const label = + state === "idle" + ? "Ready" + : state === "running" + ? "Starting…" + : state === "success" + ? "Started" + : "Failed"; - if (run.status === "running") { - return

    Starting workflow instance...

    ; + return ( + + + ); +} + +function WorkflowResult({ run }: { readonly run: WorkflowRun | undefined }) { + if (!run || run.state === "running") { + return null; } - if (run.status === "error") { + if (run.state === "error") { return ( -

    - {run.message} -

    +
    + Workflow failed. {run.message} +
    ); } return ( -
    -

    Started instance {run.id}

    -
    -        {formatWorkflowDetails(run.details)}
    -      
    +
    +
    + Instance + {run.id} +
    +
    {formatStatus(run.status)}
    ); } -function createRunningMap(cases: ReadonlyArray): WorkflowRunMap { - const runs: WorkflowRunMap = {}; - for (const entry of cases) { - runs[entry.key] = { status: "running" }; - } - - return runs; -} +async function startWorkflow(key: WorkflowKey): Promise { + const id = `${key}-${crypto.randomUUID()}`; -async function createWorkflowRun(key: string): Promise { try { - const id = createWorkflowInstanceId(key); - const response = await fetch(`/api/e2e/workflows/${key}?id=${encodeURIComponent(id)}`, { + const response = await fetch(`/api/workflows/${key}?id=${encodeURIComponent(id)}`, { method: "POST", }); - const body = await readResponseBody(response); if (!response.ok) { - return { status: "error", message: formatWorkflowError(key, response, body) }; + return { + state: "error", + message: `The server returned ${response.status}. Check the worker logs and try again.`, + }; } - if (!isRecord(body)) { + const created = parseCreatedWorkflow(await response.json()); + if (!created) { return { - status: "error", - message: `Workflow ${key} was created, but the server returned an unreadable response. Check the worker logs for instance ${id}.`, + state: "error", + message: "The server returned an invalid response. Check the worker logs and try again.", }; } + return { state: "success", id: created.id, status: created.status }; + } catch (error: unknown) { return { - status: "success", - id: typeof body.id === "string" ? body.id : id, - details: body.status ?? body, - }; - } catch (error) { - return { - status: "error", - message: - error instanceof Error - ? `Workflow ${key} was not started: ${error.message}` - : `Workflow ${key} was not started because the browser reported an unknown error.`, + state: "error", + message: error instanceof Error ? error.message : "The browser could not reach the worker.", }; } } -function createWorkflowInstanceId(key: string): string { - const randomId = - typeof globalThis.crypto?.randomUUID === "function" - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2)}`; - - return `${key}-${randomId}`; -} - -async function readResponseBody(response: Response): Promise { - const text = await response.text(); - if (!text) { - return undefined; +function parseCreatedWorkflow( + value: unknown, +): { readonly id: string; readonly status: unknown } | null { + if ( + typeof value !== "object" || + value === null || + !("id" in value) || + typeof value.id !== "string" || + !("status" in value) + ) { + return null; } - try { - return JSON.parse(text) as unknown; - } catch { - return text; - } + return { id: value.id, status: value.status }; } -function formatWorkflowError(key: string, response: Response, body: unknown): string { - if (isRecord(body) && typeof body.error === "string") { - return `${body.error}. Workflow ${key} was not started.`; - } - - if (typeof body === "string" && body.trim().length > 0) { - return `Workflow ${key} was not started: ${body}`; - } - - const statusText = response.statusText ? ` ${response.statusText}` : ""; - return `Workflow ${key} was not started. The server returned ${response.status}${statusText}.`; -} - -function formatWorkflowDetails(details: unknown): string { - if (details === undefined) { - return "No workflow status returned."; - } - - if (typeof details === "string") { - return details; - } - - return JSON.stringify(details, null, 2); +function createRunningRuns(workflows: ReadonlyArray): WorkflowRuns { + return Object.fromEntries(workflows.map((workflow) => [workflow.key, { state: "running" }])); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +function formatStatus(status: unknown): string { + return typeof status === "string" ? status : JSON.stringify(status, null, 2); } diff --git a/workbench/cloudflare-workflows-tanstack/src/styles.css b/workbench/cloudflare-workflows-tanstack/src/styles.css index 7c1d673..dbd2a60 100644 --- a/workbench/cloudflare-workflows-tanstack/src/styles.css +++ b/workbench/cloudflare-workflows-tanstack/src/styles.css @@ -1,15 +1,507 @@ @import "tailwindcss"; +:root { + color: #171717; + background: #fafafa; + font-family: + "Geist Sans", + Geist, + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + * { box-sizing: border-box; } -html, -body, -#app { +html { + min-width: 320px; min-height: 100%; + scroll-behavior: smooth; } body { + min-height: 100%; + margin: 0; + background: #fafafa; +} + +button, +a { + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +button { + font: inherit; +} + +code, +pre { + font-family: "Geist Mono", ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.skip-link { + position: fixed; + top: 8px; + left: 8px; + z-index: 100; + padding: 8px 12px; + border-radius: 6px; + color: #fff; + background: #171717; + transform: translateY(-150%); +} + +.skip-link:focus-visible { + transform: translateY(0); +} + +.workbench-page { + min-height: 100vh; +} + +.site-header { + border-bottom: 1px solid #00000015; + background: #fff; +} + +.site-header__inner { + display: flex; + min-height: 64px; + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.brand { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 10px; + color: #171717; + font-size: 14px; + font-weight: 600; + line-height: 20px; + text-decoration: none; +} + +.brand__separator { + width: 1px; + height: 20px; + background: #e6e6e6; +} + +.brand__context { + overflow: hidden; + color: #4d4d4d; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.environment-badge, +.workflow-count { + color: #4d4d4d; + font-size: 12px; + line-height: 16px; +} + +.environment-badge { + padding: 4px 8px; + border: 1px solid #0000001a; + border-radius: 9999px; + background: #fafafa; +} + +.workbench-main { + max-width: 1200px; + margin: 0 auto; + padding: 64px 24px 40px; +} + +.hero { + display: flex; + margin-bottom: 40px; + align-items: flex-end; + justify-content: space-between; + gap: 32px; +} + +.eyebrow { + margin: 0 0 8px; + color: #006bff; + font-size: 13px; + font-weight: 500; + line-height: 16px; +} + +.hero h1 { + margin: 0; + color: #171717; + font-size: clamp(32px, 5vw, 48px); + font-weight: 600; + line-height: 1.08; + letter-spacing: -0.05em; + text-wrap: balance; +} + +.hero__copy { + max-width: 600px; + margin: 16px 0 0; + color: #4d4d4d; + font-size: 16px; + line-height: 24px; + text-wrap: pretty; +} + +.button { + display: inline-flex; + min-height: 40px; + padding: 0 12px; + border: 1px solid transparent; + border-radius: 6px; + align-items: center; + justify-content: center; + color: #171717; + background: transparent; + font-size: 14px; + font-weight: 500; + line-height: 20px; + white-space: nowrap; + cursor: pointer; + transition: + background-color 150ms ease, + border-color 150ms ease, + color 150ms ease, + transform 150ms cubic-bezier(0.23, 1, 0.32, 1); +} + +.button:hover:not(:disabled) { + background: #f2f2f2; +} + +.button:active:not(:disabled) { + transform: scale(0.97); +} + +.button:focus-visible, +.brand:focus-visible { + outline: none; + box-shadow: + 0 0 0 2px #fff, + 0 0 0 4px #006bff; +} + +.button:disabled { + color: #8f8f8f; + background: #f2f2f2; + cursor: not-allowed; +} + +.button--primary { + min-height: 40px; + padding: 0 14px; + color: #fff; + background: #171717; +} + +.button--primary:hover:not(:disabled) { + background: #4d4d4d; +} + +.button--secondary { + border-color: #0000001a; + background: #fff; + box-shadow: 0 1px 2px #0000000d; +} + +.button--small { + min-height: 32px; + padding: 0 10px; + font-size: 12px; + line-height: 16px; +} + +.workflow-panel { + overflow: hidden; + border: 1px solid #0000001a; + border-radius: 12px; + background: #fff; + box-shadow: 0 2px 2px rgb(0 0 0 / 4%); +} + +.workflow-panel__header { + display: flex; + min-height: 88px; + padding: 24px; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.workflow-panel__header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + line-height: 26px; + letter-spacing: -0.02em; +} + +.workflow-panel__header p { + margin: 4px 0 0; + color: #4d4d4d; + font-size: 14px; + line-height: 20px; +} + +.workflow-columns, +.workflow-row__summary { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(180px, 0.7fr) 112px 120px; + align-items: center; + gap: 24px; +} + +.workflow-columns { + min-height: 40px; + padding: 0 24px; + border-top: 1px solid #0000000d; + border-bottom: 1px solid #00000015; + color: #7d7d7d; + background: #fafafa; + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +.workflow-list { + margin: 0; + padding: 0; + list-style: none; +} + +.workflow-row + .workflow-row { + border-top: 1px solid #00000015; +} + +.workflow-row__summary { + min-height: 72px; + padding: 16px 24px; +} + +.workflow-name { + min-width: 0; +} + +.workflow-name strong, +.workflow-name span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-name strong { + font-size: 14px; + font-weight: 500; + line-height: 20px; +} + +.workflow-name span { + color: #7d7d7d; + font-size: 12px; + line-height: 16px; +} + +.binding-name { + overflow: hidden; + color: #4d4d4d; + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status { + display: inline-flex; + align-items: center; + gap: 8px; + color: #4d4d4d; + font-size: 13px; + line-height: 18px; +} + +.status__dot { + width: 6px; + height: 6px; + border-radius: 9999px; + background: #a8a8a8; +} + +.status[data-state="running"] .status__dot { + background: #ffae00; +} + +.status[data-state="success"] .status__dot { + background: #28a948; +} + +.status[data-state="error"] .status__dot { + background: #fc0035; +} + +.workflow-result { + margin: 0 24px 16px; + padding: 16px; + border: 1px solid #00000015; + border-radius: 6px; + background: #fafafa; +} + +.workflow-result--error { + border-color: #ffd7d6; + color: #d8001b; + background: #ffeeef; + font-size: 13px; + line-height: 18px; +} + +.workflow-result__meta { + display: flex; + min-width: 0; + margin-bottom: 12px; + align-items: center; + gap: 12px; + color: #7d7d7d; + font-size: 12px; + line-height: 16px; +} + +.workflow-result__meta code { + overflow: hidden; + color: #171717; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-result pre { + overflow: auto; + max-height: 240px; margin: 0; + color: #171717; + font-size: 12px; + line-height: 18px; + white-space: pre-wrap; + word-break: break-word; +} + +.workbench-footer { + display: flex; + padding: 24px 0; + justify-content: space-between; + gap: 24px; + color: #7d7d7d; + font-family: "Geist Mono", ui-monospace, "SFMono-Regular", Consolas, monospace; + font-size: 12px; + line-height: 16px; +} + +@media (max-width: 800px) { + .workbench-main { + padding-top: 40px; + } + + .hero { + align-items: stretch; + flex-direction: column; + } + + .hero .button { + align-self: flex-start; + } + + .workflow-columns { + display: none; + } + + .workflow-row__summary { + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px 16px; + } + + .binding-name, + .status { + grid-column: 1; + } + + .workflow-row__summary .button { + grid-column: 2; + grid-row: 1 / span 2; + } +} + +@media (max-width: 520px) { + .site-header__inner, + .workbench-main { + padding-right: 16px; + padding-left: 16px; + } + + .environment-badge, + .brand__separator, + .brand__context { + display: none; + } + + .workbench-main { + padding-top: 32px; + } + + .hero { + margin-bottom: 32px; + } + + .hero .button { + width: 100%; + } + + .workflow-panel__header, + .workflow-row__summary { + padding-right: 16px; + padding-left: 16px; + } + + .workflow-count { + display: none; + } + + .workflow-result { + margin-right: 16px; + margin-left: 16px; + } + + .workbench-footer { + align-items: flex-start; + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + .button, + .skip-link { + transition-duration: 0ms; + } } diff --git a/workbench/cloudflare-workflows-tanstack/src/worker.ts b/workbench/cloudflare-workflows-tanstack/src/worker.ts index c55c067..9e450db 100644 --- a/workbench/cloudflare-workflows-tanstack/src/worker.ts +++ b/workbench/cloudflare-workflows-tanstack/src/worker.ts @@ -1,5 +1,6 @@ import server from "@tanstack/react-start/server-entry"; import { DurableObject, WorkflowEntrypoint } from "cloudflare:workers"; +import { createSharedWorkflow, getSharedWorkflow } from "cloudflare-workflows-shared"; import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers"; import { workflowCases } from "./workflow-cases"; @@ -30,11 +31,11 @@ export default { async fetch(request, env, ctx) { const url = new URL(request.url); - if (url.pathname === "/api/e2e/workflows") { + if (url.pathname === "/api/workflows") { return Response.json(workflowCases); } - const workflowMatch = /^\/api\/e2e\/workflows\/([^/]+)$/.exec(url.pathname); + const workflowMatch = /^\/api\/workflows\/([^/]+)$/.exec(url.pathname); if (workflowMatch) { const [, key] = workflowMatch; const e2eCase = workflowCases.find((entry) => entry.key === key); @@ -42,10 +43,12 @@ export default { return Response.json({ error: `Unknown workflow case ${key}` }, { status: 404 }); } - const workflow = (env as unknown as Record>)[e2eCase.binding]; if (request.method === "POST") { const id = url.searchParams.get("id") ?? `${e2eCase.key}-${Date.now()}`; - const instance = await workflow.create({ id, params: e2eCase.params }); + const instance = + e2eCase.binding === "NATIVE_CHECK" + ? await env.NATIVE_CHECK.create({ id, params: e2eCase.params }) + : await createSharedWorkflow(env, e2eCase, id); return Response.json({ id: instance.id, status: await instance.status() }); } @@ -56,7 +59,10 @@ export default { return Response.json({ error: "Missing workflow instance id" }, { status: 400 }); } - const instance = await workflow.get(id); + const instance = + e2eCase.binding === "NATIVE_CHECK" + ? await env.NATIVE_CHECK.get(id) + : await getSharedWorkflow(env, e2eCase, id); return Response.json(await instance.status()); } diff --git a/workbench/cloudflare-workflows-tanstack/src/workflow-cases.ts b/workbench/cloudflare-workflows-tanstack/src/workflow-cases.ts index 150dbb9..151ed46 100644 --- a/workbench/cloudflare-workflows-tanstack/src/workflow-cases.ts +++ b/workbench/cloudflare-workflows-tanstack/src/workflow-cases.ts @@ -1,16 +1,14 @@ import { sharedWorkflowCases } from "cloudflare-workflows-shared"; -import type { SharedWorkflowCase } from "cloudflare-workflows-shared"; +import type { WorkflowCase } from "cloudflare-workflows-shared"; const nativeWorkflowCase = { key: "native-check", binding: "NATIVE_CHECK", className: "NativeCheck", params: { label: "native" }, -} satisfies SharedWorkflowCase; +} as const satisfies WorkflowCase; export const workflowCases = [ ...sharedWorkflowCases, - { - ...nativeWorkflowCase, - }, -] satisfies Array; + nativeWorkflowCase, +] as const satisfies ReadonlyArray; diff --git a/workbench/cloudflare-workflows-tanstack/tests/cloudflare-workflows.e2e.test.ts b/workbench/cloudflare-workflows-tanstack/tests/cloudflare-workflows.e2e.test.ts deleted file mode 100644 index 73326d6..0000000 --- a/workbench/cloudflare-workflows-tanstack/tests/cloudflare-workflows.e2e.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { readFileSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { sharedWorkflowCases } from "cloudflare-workflows-shared"; -import { afterEach, expect, test, vi } from "vite-plus/test"; -import { createServer } from "vite"; -import type { ViteDevServer } from "vite"; - -interface E2eCase { - readonly key: string; - readonly binding: string; - readonly className: string; - readonly params: unknown; -} - -interface WorkflowStatus { - readonly status: string; - readonly output?: unknown; -} - -interface WranglerConfig { - readonly workflows?: Array<{ - readonly binding: string; - readonly name: string; - readonly class_name: string; - }>; - readonly durable_objects?: { - readonly bindings?: Array<{ readonly name: string; readonly class_name: string }>; - }; - readonly migrations?: Array<{ - readonly tag: string; - readonly new_sqlite_classes?: Array; - }>; -} - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const dist = join(root, "dist/server"); -const expectedWorkflows = [ - ...sharedWorkflowCases.map((entry) => ({ - binding: entry.binding, - name: entry.key, - class_name: entry.className, - })), - { binding: "NATIVE_CHECK", name: "native-check", class_name: "NativeCheck" }, -]; - -let server: ViteDevServer | undefined; - -afterEach(async () => { - await server?.close(); - server = undefined; -}); - -test("TanStack Start dev server runs the shared Sideffect workflow matrix", async () => { - server = await createServer({ root, logLevel: "warn" }); - await server.listen(0); - - const baseUrl = server.resolvedUrls?.local[0]?.replace(/\/$/, ""); - if (!baseUrl) { - throw new Error( - "Vite did not expose a local dev server URL for the TanStack workflow E2E fixture.", - ); - } - - const cases = await fetchJson>(`${baseUrl}/api/e2e/workflows`); - expect(cases.map((entry) => entry.binding).sort()).toEqual( - expectedWorkflows.map((entry) => entry.binding).sort(), - ); - - for (const e2eCase of cases) { - const id = `sideffect-tanstack-${e2eCase.key}-${Date.now()}`; - await fetchJson(`${baseUrl}/api/e2e/workflows/${e2eCase.key}?id=${id}`, { method: "POST" }); - const status = await pollWorkflowStatus(baseUrl, e2eCase.key, id); - - expect(status.status).toBe("complete"); - expectWorkflowOutput(e2eCase.key, status.output); - } -}); - -test("TanStack Start build emits shared workflow config and Wrangler dry-run accepts it", () => { - rmSync(dist, { recursive: true, force: true }); - execFileSync("bun", ["run", "build"], { cwd: root, encoding: "utf8", stdio: "pipe" }); - - const config = JSON.parse(readFileSync(join(dist, "wrangler.json"), "utf8")) as WranglerConfig; - expect(config.workflows?.sort(byBinding)).toEqual([...expectedWorkflows].sort(byBinding)); - expect(config.durable_objects?.bindings).toEqual([{ name: "COUNTER", class_name: "Counter" }]); - expect(config.migrations).toEqual([{ tag: "v1", new_sqlite_classes: ["Counter"] }]); - - const bundle = readFileSync(join(dist, "index.js"), "utf8"); - for (const workflow of expectedWorkflows) { - expect(bundle).toContain(workflow.class_name); - } - expect(bundle).toContain("Counter"); - expect(bundle).toContain("NativeCheck"); - expect(bundle).toContain("default"); - - const dryRun = execFileSync("bun", ["x", "wrangler", "deploy", "--dry-run"], { - cwd: root, - encoding: "utf8", - stdio: "pipe", - }); - expect(dryRun).toContain("--dry-run"); -}); - -async function pollWorkflowStatus( - baseUrl: string, - key: string, - id: string, -): Promise { - let latest: WorkflowStatus | undefined; - await vi.waitFor( - async () => { - latest = await fetchJson(`${baseUrl}/api/e2e/workflows/${key}?id=${id}`); - expect(latest.status).toBe("complete"); - }, - { interval: 500, timeout: 20_000 }, - ); - - if (!latest) { - throw new Error(`Workflow ${key} did not return a status payload.`); - } - - return latest; -} - -async function fetchJson(url: string, init?: RequestInit): Promise { - const response = await fetch(url, init); - if (!response.ok) { - throw new Error(`Request to ${url} failed with ${response.status}: ${await response.text()}`); - } - - return response.json(); -} - -function expectWorkflowOutput(key: string, output: unknown) { - switch (key) { - case "add-numbers": - expect(output).toEqual({ sum: 5, doubled: 10, formatted: "value:10" }); - return; - case "normal-async": - expect(output).toEqual({ echoed: { message: "hello", mode: "async" } }); - return; - case "effect-wrapped": - expect(output).toEqual({ upper: "EFFECT" }); - return; - case "binding-roundtrip": - expect(output).toEqual({ - counter: { count: expect.any(Number) }, - metadata: { binding: "COUNTER", className: "Counter" }, - }); - return; - case "payload-decoding": - expect(output).toEqual({ - payload: { value: 42 }, - eventPayload: { value: 42 }, - decoded: { value: 42, label: "decoded:42" }, - }); - return; - case "step-context": - expect(output).toEqual({ - label: "ctx", - step: "read step context", - attempt: expect.any(Number), - timeout: "5 minutes", - }); - return; - case "pause-and-return": - expect(output).toEqual({ marker: "slept" }); - return; - case "imported-definition": - expect(output).toEqual({ echoed: { message: "imported", mode: "async" } }); - return; - case "default-direct": - expect(output).toEqual({ marker: "default-direct" }); - return; - case "default-local-layer": - expect(output).toEqual({ echoed: { message: "default-local", mode: "async" } }); - return; - case "native-check": - expect(output).toEqual({ label: "native", mode: "native" }); - return; - default: - throw new Error(`Unexpected workflow E2E case ${key}.`); - } -} - -function byBinding( - left: { readonly binding: string }, - right: { readonly binding: string }, -): number { - return left.binding.localeCompare(right.binding); -} diff --git a/workbench/cloudflare-workflows-tanstack/tests/discovery.e2e.test.ts b/workbench/cloudflare-workflows-tanstack/tests/discovery.e2e.test.ts new file mode 100644 index 0000000..7b29d20 --- /dev/null +++ b/workbench/cloudflare-workflows-tanstack/tests/discovery.e2e.test.ts @@ -0,0 +1,54 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { sharedWorkflowCases } from "cloudflare-workflows-shared"; +import { beforeAll, describe, expect, test } from "vite-plus/test"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const dist = join(root, "dist/server"); +const expectedWorkflows = [ + { binding: "NATIVE_CHECK", name: "native-check", class_name: "NativeCheck" }, + ...sharedWorkflowCases.map((workflow) => ({ + binding: workflow.binding, + name: workflow.key, + class_name: workflow.className, + })), +]; + +beforeAll(() => { + execFileSync("bun", ["run", "build"], { cwd: root, encoding: "utf8", stdio: "pipe" }); +}); + +describe("workflow discovery in the TanStack SSR environment", () => { + test("generates Sideffect and native Cloudflare bindings", () => { + const config: unknown = JSON.parse(readFileSync(join(dist, "wrangler.json"), "utf8")); + + expect(config).toMatchObject({ + workflows: expectedWorkflows, + durable_objects: { + bindings: [{ name: "COUNTER", class_name: "Counter" }], + }, + migrations: [{ tag: "v1", new_sqlite_classes: ["Counter"] }], + }); + }); + + test("includes every discovered workflow in the server bundle", () => { + const bundle = readFileSync(join(dist, "index.js"), "utf8"); + + for (const workflow of expectedWorkflows) { + expect(bundle).toContain(workflow.class_name); + } + }); + + test("produces a Wrangler-compatible TanStack worker", () => { + const output = execFileSync("bun", ["x", "wrangler", "deploy", "--dry-run"], { + cwd: root, + encoding: "utf8", + stdio: "pipe", + }); + + expect(output).toContain("--dry-run"); + }); +}); diff --git a/workbench/cloudflare-workflows-tanstack/tests/integration.e2e.test.ts b/workbench/cloudflare-workflows-tanstack/tests/integration.e2e.test.ts new file mode 100644 index 0000000..194ba5b --- /dev/null +++ b/workbench/cloudflare-workflows-tanstack/tests/integration.e2e.test.ts @@ -0,0 +1,99 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { sharedWorkflowCases } from "cloudflare-workflows-shared"; +import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test"; + +import { + startWorkbench, + waitForWorkflow, +} from "../../cloudflare-workflows-shared/test-support/workflow-http"; +import type { + RunningWorkbench, + WorkflowStatus, +} from "../../cloudflare-workflows-shared/test-support/workflow-http"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +let workbench: RunningWorkbench; + +beforeAll(async () => { + workbench = await startWorkbench(root); +}); + +afterAll(async () => { + await workbench.close(); +}); + +test("delegates application requests to TanStack Start", async () => { + const response = await fetch(workbench.baseUrl); + + expect(response.status).toBe(200); + expect(await response.text()).toContain("Cloudflare Workflows"); +}); + +test("exposes the shared Sideffect workflows through the application API", async () => { + const response = await fetch(`${workbench.baseUrl}/api/workflows`); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual([ + ...sharedWorkflowCases, + { + key: "native-check", + binding: "NATIVE_CHECK", + className: "NativeCheck", + params: { label: "native" }, + }, + ]); +}); + +test("runs a discovered Sideffect workflow inside TanStack Start", async () => { + const status = await runWorkflow("add-numbers"); + + expect(status.output).toEqual({ sum: 5, doubled: 10, formatted: "value:10" }); +}); + +test("runs an application-owned native Cloudflare workflow", async () => { + const status = await runWorkflow("native-check"); + + expect(status.output).toEqual({ label: "native", mode: "native" }); +}); + +describe("workflow HTTP API", () => { + test("returns 404 for an unknown workflow", async () => { + const response = await fetch(`${workbench.baseUrl}/api/workflows/not-registered`, { + method: "POST", + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Unknown workflow case not-registered" }); + }); + + test("requires an instance ID when reading status", async () => { + const response = await fetch(`${workbench.baseUrl}/api/workflows/add-numbers`); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Missing workflow instance id" }); + }); + + test("rejects unsupported methods", async () => { + const response = await fetch(`${workbench.baseUrl}/api/workflows/add-numbers`, { + method: "DELETE", + }); + + expect(response.status).toBe(405); + expect(await response.json()).toEqual({ error: "Unsupported method DELETE" }); + }); +}); + +async function runWorkflow(key: string): Promise { + const id = `sideffect-tanstack-${key}-${Date.now()}`; + const response = await fetch(`${workbench.baseUrl}/api/workflows/${key}?id=${id}`, { + method: "POST", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ id }); + + return waitForWorkflow(`${workbench.baseUrl}/api/workflows/${key}?id=${id}`); +} diff --git a/workbench/cloudflare-workflows-vite/package.json b/workbench/cloudflare-workflows-vite/package.json index 51fc26b..b9b0f12 100644 --- a/workbench/cloudflare-workflows-vite/package.json +++ b/workbench/cloudflare-workflows-vite/package.json @@ -10,7 +10,7 @@ "dev": "vp dev", "build": "vp build", "cf-typegen": "wrangler types", - "test:e2e": "vp test --config vitest.config.e2e.ts tests/cloudflare-workflows.e2e.test.ts" + "test:e2e": "vp test --config vitest.config.e2e.ts tests" }, "dependencies": { "cloudflare-workflows-shared": "workspace:", diff --git a/workbench/cloudflare-workflows-vite/sideffect-env.d.ts b/workbench/cloudflare-workflows-vite/sideffect-env.d.ts index 2e571cc..3f405fc 100644 --- a/workbench/cloudflare-workflows-vite/sideffect-env.d.ts +++ b/workbench/cloudflare-workflows-vite/sideffect-env.d.ts @@ -1,5 +1,5 @@ // Generated by Sideffect. Do not edit. -import type { WorkflowLayer } from "sideffect"; +import type { WorkflowInput } from "sideffect"; import type { addNumbersLayer as __SideffectWorkflow0 } from "../cloudflare-workflows-shared/src/workflows"; import type { normalAsyncLayer as __SideffectWorkflow1 } from "../cloudflare-workflows-shared/src/workflows"; import type { effectWrappedLayer as __SideffectWorkflow2 } from "../cloudflare-workflows-shared/src/workflows"; @@ -12,34 +12,33 @@ import type __SideffectWorkflow8 from "../cloudflare-workflows-shared/src/defaul import type __SideffectWorkflow9 from "../cloudflare-workflows-shared/src/default-local-layer-workflow"; type __SideffectCloudflareWorkflow = Workflow; -type __SideffectWorkflowPayload = T extends WorkflowLayer ? Payload : never; declare global { interface Env { - ADD_NUMBERS: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - NORMAL_ASYNC: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - EFFECT_WRAPPED: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - BINDING_ROUNDTRIP: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - PAYLOAD_DECODING: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - STEP_CONTEXT: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - PAUSE_AND_RETURN: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - IMPORTED_DEFINITION: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - DEFAULT_DIRECT: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - DEFAULT_LOCAL_LAYER: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; + ADD_NUMBERS: __SideffectCloudflareWorkflow>; + NORMAL_ASYNC: __SideffectCloudflareWorkflow>; + EFFECT_WRAPPED: __SideffectCloudflareWorkflow>; + BINDING_ROUNDTRIP: __SideffectCloudflareWorkflow>; + PAYLOAD_DECODING: __SideffectCloudflareWorkflow>; + STEP_CONTEXT: __SideffectCloudflareWorkflow>; + PAUSE_AND_RETURN: __SideffectCloudflareWorkflow>; + IMPORTED_DEFINITION: __SideffectCloudflareWorkflow>; + DEFAULT_DIRECT: __SideffectCloudflareWorkflow>; + DEFAULT_LOCAL_LAYER: __SideffectCloudflareWorkflow>; } namespace Cloudflare { interface Env { - ADD_NUMBERS: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - NORMAL_ASYNC: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - EFFECT_WRAPPED: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - BINDING_ROUNDTRIP: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - PAYLOAD_DECODING: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - STEP_CONTEXT: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - PAUSE_AND_RETURN: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - IMPORTED_DEFINITION: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - DEFAULT_DIRECT: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; - DEFAULT_LOCAL_LAYER: __SideffectCloudflareWorkflow<__SideffectWorkflowPayload>; + ADD_NUMBERS: __SideffectCloudflareWorkflow>; + NORMAL_ASYNC: __SideffectCloudflareWorkflow>; + EFFECT_WRAPPED: __SideffectCloudflareWorkflow>; + BINDING_ROUNDTRIP: __SideffectCloudflareWorkflow>; + PAYLOAD_DECODING: __SideffectCloudflareWorkflow>; + STEP_CONTEXT: __SideffectCloudflareWorkflow>; + PAUSE_AND_RETURN: __SideffectCloudflareWorkflow>; + IMPORTED_DEFINITION: __SideffectCloudflareWorkflow>; + DEFAULT_DIRECT: __SideffectCloudflareWorkflow>; + DEFAULT_LOCAL_LAYER: __SideffectCloudflareWorkflow>; } } } diff --git a/workbench/cloudflare-workflows-vite/src/index.ts b/workbench/cloudflare-workflows-vite/src/index.ts index 27b4dde..2706803 100644 --- a/workbench/cloudflare-workflows-vite/src/index.ts +++ b/workbench/cloudflare-workflows-vite/src/index.ts @@ -1,21 +1,23 @@ import { DurableObject, WorkflowEntrypoint } from "cloudflare:workers"; -import { sharedWorkflowCases } from "cloudflare-workflows-shared"; +import { + createSharedWorkflow, + getSharedWorkflow, + sharedWorkflowCases, +} from "cloudflare-workflows-shared"; import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers"; -import type { SharedWorkflowCase } from "cloudflare-workflows-shared"; +import type { WorkflowCase } from "cloudflare-workflows-shared"; const nativeWorkflowCase = { key: "native-check", binding: "NATIVE_CHECK", className: "NativeCheck", params: { label: "native" }, -} satisfies SharedWorkflowCase; +} as const satisfies WorkflowCase; const e2eCases = [ ...sharedWorkflowCases, - { - ...nativeWorkflowCase, - }, -] satisfies Array; + nativeWorkflowCase, +] as const satisfies ReadonlyArray; export class NativeCheck extends WorkflowEntrypoint { override async run(event: WorkflowEvent<{ label: string }>, step: WorkflowStep) { @@ -49,28 +51,41 @@ export default { return stub.fetch(req); } - if (url.pathname === "/e2e/workflows") { + if (url.pathname === "/api/workflows") { return Response.json(e2eCases); } - const workflowMatch = /^\/e2e\/workflows\/([^/]+)\/(create|status)$/.exec(url.pathname); + const workflowMatch = /^\/api\/workflows\/([^/]+)$/.exec(url.pathname); if (workflowMatch) { - const [, key, action] = workflowMatch; + const [, key] = workflowMatch; const e2eCase = e2eCases.find((entry) => entry.key === key); if (!e2eCase) { return Response.json({ error: `Unknown workflow case ${key}` }, { status: 404 }); } - const id = url.searchParams.get("id") ?? `${e2eCase.key}-${Date.now()}`; - const workflow = (env as unknown as Record>)[e2eCase.binding]; - - if (action === "create") { - const instance = await workflow.create({ id, params: e2eCase.params }); + if (req.method === "POST") { + const id = url.searchParams.get("id") ?? `${e2eCase.key}-${Date.now()}`; + const instance = + e2eCase.binding === "NATIVE_CHECK" + ? await env.NATIVE_CHECK.create({ id, params: e2eCase.params }) + : await createSharedWorkflow(env, e2eCase, id); return Response.json({ id: instance.id, status: await instance.status() }); } - const instance = await workflow.get(id); - return Response.json(await instance.status()); + if (req.method === "GET") { + const id = url.searchParams.get("id"); + if (!id) { + return Response.json({ error: "Missing workflow instance id" }, { status: 400 }); + } + + const instance = + e2eCase.binding === "NATIVE_CHECK" + ? await env.NATIVE_CHECK.get(id) + : await getSharedWorkflow(env, e2eCase, id); + return Response.json(await instance.status()); + } + + return Response.json({ error: `Unsupported method ${req.method}` }, { status: 405 }); } const id = url.searchParams.get("instanceId"); diff --git a/workbench/cloudflare-workflows-vite/tests/cloudflare-workflows.e2e.test.ts b/workbench/cloudflare-workflows-vite/tests/cloudflare-workflows.e2e.test.ts deleted file mode 100644 index 8b8b594..0000000 --- a/workbench/cloudflare-workflows-vite/tests/cloudflare-workflows.e2e.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { readFileSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { sharedWorkflowCases } from "cloudflare-workflows-shared"; -import { afterEach, expect, test, vi } from "vite-plus/test"; -import { createServer } from "vite"; -import type { ViteDevServer } from "vite"; - -interface E2eCase { - readonly key: string; - readonly binding: string; - readonly className: string; - readonly params: unknown; -} - -interface WorkflowStatus { - readonly status: string; - readonly output?: unknown; - readonly __LOCAL_DEV_STEP_OUTPUTS?: Array<{ readonly output?: unknown }>; -} - -interface WranglerConfig { - readonly workflows?: Array<{ - readonly binding: string; - readonly name: string; - readonly class_name: string; - }>; - readonly durable_objects?: { - readonly bindings?: Array<{ readonly name: string; readonly class_name: string }>; - }; - readonly migrations?: Array<{ - readonly tag: string; - readonly new_sqlite_classes?: Array; - }>; -} - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const dist = join(root, "dist/sideffect_cloudflare_workflows_vite"); -const expectedWorkflows = [ - ...sharedWorkflowCases.map((entry) => ({ - binding: entry.binding, - name: entry.key, - class_name: entry.className, - })), - { binding: "NATIVE_CHECK", name: "native-check", class_name: "NativeCheck" }, -]; - -let server: ViteDevServer | undefined; - -afterEach(async () => { - await server?.close(); - server = undefined; -}); - -test("Cloudflare Vite dev server runs discovered Sideffect workflows", async () => { - server = await createServer({ root, logLevel: "warn" }); - await server.listen(0); - - const baseUrl = server.resolvedUrls?.local[0]?.replace(/\/$/, ""); - if (!baseUrl) { - throw new Error( - "Vite did not expose a local dev server URL for the Cloudflare workflow E2E fixture.", - ); - } - - const counter = await fetchJson<{ readonly count: number }>(`${baseUrl}/counter`); - expect(counter.count).toBeGreaterThan(0); - - const cases = await fetchJson>(`${baseUrl}/e2e/workflows`); - expect(cases.map((entry) => entry.binding).sort()).toEqual( - expectedWorkflows.map((entry) => entry.binding).sort(), - ); - - for (const e2eCase of cases) { - const id = `sideffect-${e2eCase.key}-${Date.now()}`; - await fetchJson(`${baseUrl}/e2e/workflows/${e2eCase.key}/create?id=${id}`); - const status = await pollWorkflowStatus(baseUrl, e2eCase.key, id); - - expect(status.status).toBe("complete"); - expectWorkflowOutput(e2eCase.key, status.output); - } -}); - -test("Cloudflare Vite build emits workflow config and Wrangler dry-run accepts it", () => { - rmSync(dist, { recursive: true, force: true }); - execFileSync("bun", ["run", "build"], { cwd: root, encoding: "utf8", stdio: "pipe" }); - - const config = JSON.parse(readFileSync(join(dist, "wrangler.json"), "utf8")) as WranglerConfig; - expect(config.workflows?.sort(byBinding)).toEqual([...expectedWorkflows].sort(byBinding)); - expect(config.durable_objects?.bindings).toEqual([{ name: "COUNTER", class_name: "Counter" }]); - expect(config.migrations).toEqual([{ tag: "v1", new_sqlite_classes: ["Counter"] }]); - - const bundle = readFileSync(join(dist, "index.js"), "utf8"); - for (const workflow of expectedWorkflows) { - expect(bundle).toContain(workflow.class_name); - } - expect(bundle).toContain("export { AddNumbers"); - expect(bundle).toContain("Counter"); - expect(bundle).toContain("NativeCheck"); - expect(bundle).toContain("default"); - - const dryRun = execFileSync("bun", ["x", "wrangler", "deploy", "--dry-run"], { - cwd: root, - encoding: "utf8", - stdio: "pipe", - }); - expect(dryRun).toContain("--dry-run"); -}); - -async function pollWorkflowStatus( - baseUrl: string, - key: string, - id: string, -): Promise { - let latest: WorkflowStatus | undefined; - await vi.waitFor( - async () => { - latest = await fetchJson(`${baseUrl}/e2e/workflows/${key}/status?id=${id}`); - expect(latest.status).toBe("complete"); - }, - { interval: 500, timeout: 20_000 }, - ); - - if (!latest) { - throw new Error(`Workflow ${key} did not return a status payload.`); - } - - return latest; -} - -async function fetchJson(url: string): Promise { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Request to ${url} failed with ${response.status}: ${await response.text()}`); - } - - return response.json(); -} - -function expectWorkflowOutput(key: string, output: unknown) { - switch (key) { - case "add-numbers": - expect(output).toEqual({ sum: 5, doubled: 10, formatted: "value:10" }); - return; - case "normal-async": - expect(output).toEqual({ echoed: { message: "hello", mode: "async" } }); - return; - case "effect-wrapped": - expect(output).toEqual({ upper: "EFFECT" }); - return; - case "binding-roundtrip": - expect(output).toEqual({ - counter: { count: expect.any(Number) }, - metadata: { binding: "COUNTER", className: "Counter" }, - }); - return; - case "payload-decoding": - expect(output).toEqual({ - payload: { value: 42 }, - eventPayload: { value: 42 }, - decoded: { value: 42, label: "decoded:42" }, - }); - return; - case "step-context": - expect(output).toEqual({ - label: "ctx", - step: "read step context", - attempt: expect.any(Number), - timeout: "5 minutes", - }); - return; - case "pause-and-return": - expect(output).toEqual({ marker: "slept" }); - return; - case "imported-definition": - expect(output).toEqual({ echoed: { message: "imported", mode: "async" } }); - return; - case "default-direct": - expect(output).toEqual({ marker: "default-direct" }); - return; - case "default-local-layer": - expect(output).toEqual({ echoed: { message: "default-local", mode: "async" } }); - return; - case "native-check": - expect(output).toEqual({ label: "native", mode: "native" }); - return; - default: - throw new Error(`Unexpected workflow E2E case ${key}.`); - } -} - -function byBinding( - left: { readonly binding: string }, - right: { readonly binding: string }, -): number { - return left.binding.localeCompare(right.binding); -} diff --git a/workbench/cloudflare-workflows-vite/tests/discovery.e2e.test.ts b/workbench/cloudflare-workflows-vite/tests/discovery.e2e.test.ts new file mode 100644 index 0000000..100e9a8 --- /dev/null +++ b/workbench/cloudflare-workflows-vite/tests/discovery.e2e.test.ts @@ -0,0 +1,56 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { sharedWorkflowCases } from "cloudflare-workflows-shared"; +import { beforeAll, describe, expect, test } from "vite-plus/test"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const dist = join(root, "dist/sideffect_cloudflare_workflows_vite"); +const expectedWorkflows = [ + { binding: "NATIVE_CHECK", name: "native-check", class_name: "NativeCheck" }, + ...sharedWorkflowCases.map((workflow) => ({ + binding: workflow.binding, + name: workflow.key, + class_name: workflow.className, + })), +]; + +beforeAll(() => { + execFileSync("bun", ["run", "build"], { cwd: root, encoding: "utf8", stdio: "pipe" }); +}); + +describe("workflow discovery", () => { + test("generates Sideffect and native Cloudflare bindings", () => { + const config: unknown = JSON.parse(readFileSync(join(dist, "wrangler.json"), "utf8")); + + expect(config).toMatchObject({ + workflows: expectedWorkflows, + durable_objects: { + bindings: [{ name: "COUNTER", class_name: "Counter" }], + }, + migrations: [{ tag: "v1", new_sqlite_classes: ["Counter"] }], + }); + }); + + test("exports every discovered workflow from the generated worker", () => { + const bundle = readFileSync(join(dist, "index.js"), "utf8"); + + for (const workflow of expectedWorkflows) { + expect(bundle).toContain(workflow.class_name); + } + + expect(bundle).toContain("export { AddNumbers"); + }); + + test("produces a Wrangler-compatible worker", () => { + const output = execFileSync("bun", ["x", "wrangler", "deploy", "--dry-run"], { + cwd: root, + encoding: "utf8", + stdio: "pipe", + }); + + expect(output).toContain("--dry-run"); + }); +}); diff --git a/workbench/cloudflare-workflows-vite/tests/workflows.e2e.test.ts b/workbench/cloudflare-workflows-vite/tests/workflows.e2e.test.ts new file mode 100644 index 0000000..416ade5 --- /dev/null +++ b/workbench/cloudflare-workflows-vite/tests/workflows.e2e.test.ts @@ -0,0 +1,144 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test"; + +import { + startWorkbench, + waitForWorkflow, +} from "../../cloudflare-workflows-shared/test-support/workflow-http"; +import type { + RunningWorkbench, + WorkflowStatus, +} from "../../cloudflare-workflows-shared/test-support/workflow-http"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +let workbench: RunningWorkbench; + +beforeAll(async () => { + workbench = await startWorkbench(root); +}); + +afterAll(async () => { + await workbench.close(); +}); + +describe("Sideffect workflows", () => { + test("composes synchronous and asynchronous steps", async () => { + const status = await runWorkflow("add-numbers"); + + expect(status.output).toEqual({ sum: 5, doubled: 10, formatted: "value:10" }); + }); + + test("runs an ordinary async workflow", async () => { + const status = await runWorkflow("normal-async"); + + expect(status.output).toEqual({ echoed: { message: "hello", mode: "async" } }); + }); + + test("runs an Effect-backed workflow", async () => { + const status = await runWorkflow("effect-wrapped"); + + expect(status.output).toEqual({ upper: "EFFECT" }); + }); + + test("uses a Cloudflare binding from a Sideffect step", async () => { + const status = await runWorkflow("binding-roundtrip"); + + expect(status.output).toEqual({ + counter: { count: expect.any(Number) }, + metadata: { binding: "COUNTER", className: "Counter" }, + }); + }); + + test("decodes a workflow payload before running steps", async () => { + const status = await runWorkflow("payload-decoding"); + + expect(status.output).toEqual({ + payload: { value: 42 }, + eventPayload: { value: 42 }, + decoded: { value: 42, label: "decoded:42" }, + }); + }); + + test("passes Cloudflare step context to a Sideffect step", async () => { + const status = await runWorkflow("step-context"); + + expect(status.output).toEqual({ + label: "ctx", + step: "read step context", + attempt: expect.any(Number), + timeout: "5 minutes", + }); + }); + + test("resumes after sleeping", async () => { + const status = await runWorkflow("pause-and-return"); + + expect(status.output).toEqual({ marker: "slept" }); + }); + + test("runs a workflow created from an imported definition", async () => { + const status = await runWorkflow("imported-definition"); + + expect(status.output).toEqual({ echoed: { message: "imported", mode: "async" } }); + }); + + test("discovers a directly exported default workflow", async () => { + const status = await runWorkflow("default-direct"); + + expect(status.output).toEqual({ marker: "default-direct" }); + }); + + test("discovers a default-exported local layer", async () => { + const status = await runWorkflow("default-local-layer"); + + expect(status.output).toEqual({ echoed: { message: "default-local", mode: "async" } }); + }); +}); + +test("runs an application-owned native Cloudflare workflow", async () => { + const status = await runWorkflow("native-check"); + + expect(status.output).toEqual({ label: "native", mode: "native" }); +}); + +describe("workflow HTTP API", () => { + test("returns 404 for an unknown workflow", async () => { + const response = await fetch(`${workbench.baseUrl}/api/workflows/not-registered`, { + method: "POST", + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Unknown workflow case not-registered" }); + }); + + test("requires an instance ID when reading status", async () => { + const response = await fetch(`${workbench.baseUrl}/api/workflows/add-numbers`); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Missing workflow instance id" }); + }); + + test("rejects unsupported methods", async () => { + const response = await fetch(`${workbench.baseUrl}/api/workflows/add-numbers`, { + method: "DELETE", + }); + + expect(response.status).toBe(405); + expect(await response.json()).toEqual({ error: "Unsupported method DELETE" }); + }); +}); + +async function runWorkflow(key: string): Promise { + const id = `sideffect-${key}-${Date.now()}`; + const response = await fetch(`${workbench.baseUrl}/api/workflows/${key}?id=${id}`, { + method: "POST", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ id }); + + return waitForWorkflow(`${workbench.baseUrl}/api/workflows/${key}?id=${id}`); +} From 8203d846c30def2c2931abbb7749d24e9e8c673c Mon Sep 17 00:00:00 2001 From: Sree Narayanan Date: Fri, 10 Jul 2026 17:34:25 +0400 Subject: [PATCH 2/2] improve UI feedback for workflows --- .../src/routes/index.tsx | 197 ++++++++++-------- .../src/styles.css | 18 +- 2 files changed, 125 insertions(+), 90 deletions(-) diff --git a/workbench/cloudflare-workflows-tanstack/src/routes/index.tsx b/workbench/cloudflare-workflows-tanstack/src/routes/index.tsx index 1bde504..34253c7 100644 --- a/workbench/cloudflare-workflows-tanstack/src/routes/index.tsx +++ b/workbench/cloudflare-workflows-tanstack/src/routes/index.tsx @@ -6,10 +6,16 @@ import { workflowCases } from "../workflow-cases"; type WorkflowCase = (typeof workflowCases)[number]; type WorkflowKey = WorkflowCase["key"]; +type WorkflowStatus = { + readonly status: string; + readonly output: unknown; + readonly error: unknown; +}; + type WorkflowRun = - | { readonly state: "running" } - | { readonly state: "success"; readonly id: string; readonly status: unknown } - | { readonly state: "error"; readonly message: string }; + | { readonly state: "starting" } + | { readonly state: "started"; readonly id: string; readonly status: WorkflowStatus } + | { readonly state: "error" }; type WorkflowRuns = Partial>; @@ -21,22 +27,47 @@ export const Route = createFileRoute("/")({ function Workbench() { const workflows = Route.useLoaderData(); const [runs, setRuns] = useState({}); - const isRunning = Object.values(runs).some((run) => run?.state === "running"); + const isRunning = Object.values(runs).some(isWorkflowRunning); const runWorkflow = async (workflow: WorkflowCase) => { - setRuns((current) => ({ ...current, [workflow.key]: { state: "running" } })); - const result = await startWorkflow(workflow.key); - setRuns((current) => ({ ...current, [workflow.key]: result })); + const id = `${workflow.key}-${crypto.randomUUID()}`; + setRuns((runs) => ({ ...runs, [workflow.key]: { state: "starting" } })); + + try { + const startResponse = await fetch( + `/api/workflows/${workflow.key}?id=${encodeURIComponent(id)}`, + { method: "POST" }, + ); + if (!startResponse.ok) { + setRuns((runs) => ({ ...runs, [workflow.key]: { state: "error" } })); + return; + } + + const created = await startResponse.json<{ readonly status: WorkflowStatus }>(); + let status = created.status; + setRuns((runs) => ({ ...runs, [workflow.key]: { state: "started", id, status } })); + + while (isActiveStatus(status.status)) { + await new Promise((resolve) => setTimeout(resolve, 500)); + + const statusResponse = await fetch( + `/api/workflows/${workflow.key}?id=${encodeURIComponent(id)}`, + ); + if (!statusResponse.ok) { + setRuns((runs) => ({ ...runs, [workflow.key]: { state: "error" } })); + return; + } + + status = await statusResponse.json(); + setRuns((runs) => ({ ...runs, [workflow.key]: { state: "started", id, status } })); + } + } catch { + setRuns((runs) => ({ ...runs, [workflow.key]: { state: "error" } })); + } }; const runAllWorkflows = async () => { - setRuns(createRunningRuns(workflows)); - - const results = await Promise.all( - workflows.map(async (workflow) => [workflow.key, await startWorkflow(workflow.key)] as const), - ); - - setRuns(Object.fromEntries(results)); + await Promise.all(workflows.map(runWorkflow)); }; return ( @@ -68,7 +99,7 @@ function Workbench() { onClick={() => void runAllWorkflows()} type="button" > - {isRunning ? "Running Workflows…" : `Run All ${workflows.length} Workflows`} + {isRunning ? "Workflows Active…" : `Run All ${workflows.length} Workflows`} @@ -118,10 +149,10 @@ function WorkflowRow({ readonly run: WorkflowRun | undefined; readonly onRun: () => void; }) { - const state = run?.state ?? "idle"; + const isRunning = isWorkflowRunning(run); return ( -
  • +
  • {workflow.key} @@ -136,11 +167,11 @@ function WorkflowRow({
    @@ -150,18 +181,30 @@ function WorkflowRow({ } function WorkflowStatus({ run }: { readonly run: WorkflowRun | undefined }) { - const state = run?.state ?? "idle"; - const label = - state === "idle" - ? "Ready" - : state === "running" - ? "Starting…" - : state === "success" - ? "Started" - : "Failed"; + let label = "Ready"; + let tone = "neutral"; + + if (run?.state === "starting") { + label = "Creating…"; + tone = "active"; + } else if (run?.state === "error") { + label = "Failed"; + tone = "error"; + } else if (run?.state === "started") { + label = run.status.status; + tone = isActiveStatus(run.status.status) ? "active" : "neutral"; + + if (run.status.status === "complete") { + label = "Complete"; + tone = "success"; + } else if (run.status.status === "errored" || run.status.status === "terminated") { + label = "Failed"; + tone = "error"; + } + } return ( - + @@ -169,81 +212,61 @@ function WorkflowStatus({ run }: { readonly run: WorkflowRun | undefined }) { } function WorkflowResult({ run }: { readonly run: WorkflowRun | undefined }) { - if (!run || run.state === "running") { + if (!run || run.state === "starting") { return null; } if (run.state === "error") { return (
    - Workflow failed. {run.message} + The workflow request failed. Check the worker logs and try again.
    ); } - return ( -
    -
    - Instance - {run.id} + if (isActiveStatus(run.status.status)) { + return ( +
    + +

    The instance is active. Its status refreshes automatically.

    -
    {formatStatus(run.status)}
    -
    - ); -} - -async function startWorkflow(key: WorkflowKey): Promise { - const id = `${key}-${crypto.randomUUID()}`; - - try { - const response = await fetch(`/api/workflows/${key}?id=${encodeURIComponent(id)}`, { - method: "POST", - }); - - if (!response.ok) { - return { - state: "error", - message: `The server returned ${response.status}. Check the worker logs and try again.`, - }; - } + ); + } - const created = parseCreatedWorkflow(await response.json()); - if (!created) { - return { - state: "error", - message: "The server returned an invalid response. Check the worker logs and try again.", - }; - } + const className = + run.status.status === "errored" || run.status.status === "terminated" + ? "workflow-result workflow-result--error" + : "workflow-result"; - return { state: "success", id: created.id, status: created.status }; - } catch (error: unknown) { - return { - state: "error", - message: error instanceof Error ? error.message : "The browser could not reach the worker.", - }; - } + return ( +
    + +
    {JSON.stringify(run.status, null, 2)}
    +
    + ); } -function parseCreatedWorkflow( - value: unknown, -): { readonly id: string; readonly status: unknown } | null { - if ( - typeof value !== "object" || - value === null || - !("id" in value) || - typeof value.id !== "string" || - !("status" in value) - ) { - return null; - } - - return { id: value.id, status: value.status }; +function WorkflowInstanceId({ id }: { readonly id: string }) { + return ( +
    + Instance + {id} +
    + ); } -function createRunningRuns(workflows: ReadonlyArray): WorkflowRuns { - return Object.fromEntries(workflows.map((workflow) => [workflow.key, { state: "running" }])); +function isWorkflowRunning(run: WorkflowRun | undefined): boolean { + return ( + run?.state === "starting" || (run?.state === "started" && isActiveStatus(run.status.status)) + ); } -function formatStatus(status: unknown): string { - return typeof status === "string" ? status : JSON.stringify(status, null, 2); +function isActiveStatus(status: string): boolean { + return ( + status === "queued" || + status === "running" || + status === "waiting" || + status === "waitingForPause" || + status === "unknown" + ); } diff --git a/workbench/cloudflare-workflows-tanstack/src/styles.css b/workbench/cloudflare-workflows-tanstack/src/styles.css index dbd2a60..4f96bf0 100644 --- a/workbench/cloudflare-workflows-tanstack/src/styles.css +++ b/workbench/cloudflare-workflows-tanstack/src/styles.css @@ -347,15 +347,15 @@ pre { background: #a8a8a8; } -.status[data-state="running"] .status__dot { +.status[data-tone="active"] .status__dot { background: #ffae00; } -.status[data-state="success"] .status__dot { +.status[data-tone="success"] .status__dot { background: #28a948; } -.status[data-state="error"] .status__dot { +.status[data-tone="error"] .status__dot { background: #fc0035; } @@ -375,6 +375,18 @@ pre { line-height: 18px; } +.workflow-result--active { + border-color: #ffe4ad; + background: #fff9ed; +} + +.workflow-result--active p { + margin: 0; + color: #6a4a00; + font-size: 13px; + line-height: 18px; +} + .workflow-result__meta { display: flex; min-width: 0;