-
Notifications
You must be signed in to change notification settings - Fork 286
feat(core): opt-in QuickJS WASM workflow runtime #2505
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f7865ab
46677be
7cdac8d
2184cac
25fbbce
6446d96
4dcc203
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@workflow/core": minor | ||
| --- | ||
|
|
||
| Add an opt-in QuickJS WASM workflow runtime (`WORKFLOW_RUNTIME=quickjs`, or per-run `executionContext.workflowRuntime`) as an alternative to the default `node:vm` runtime. It executes workflow orchestrator code inside a QuickJS WASM VM via `quickjs-wasi`, enabling workflow execution on runtimes that disallow `node:vm` / code-generation-from-strings (e.g. Cloudflare Workers). The default runtime is unchanged. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| name: QuickJS Runtime | ||
|
|
||
| # Exercises the opt-in QuickJS WASM workflow runtime (WORKFLOW_RUNTIME=quickjs) | ||
| # two ways: | ||
| # - node: the runtime running under Node.js (the regular host), via the | ||
| # nextjs-turbopack workbench + a curated e2e subset. | ||
| # - workerd: the QuickJS VM execution layer (quickjs-wasi + native extensions | ||
| # + core's VM serde bundle) running inside Cloudflare's workerd, | ||
| # where node:vm is unavailable. This is the foundation for running | ||
| # workflows on Cloudflare Workers. | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main, stable] | ||
| pull_request: | ||
| paths: | ||
| - 'packages/core/src/runtime/quickjs-runtime.ts' | ||
| - 'packages/core/src/runtime/quickjs-entrypoint.ts' | ||
| - 'packages/core/src/runtime/runtime-mode.ts' | ||
| - 'packages/core/src/serialization/**' | ||
| - 'packages/core/scripts/build-quickjs-assets.js' | ||
| - 'packages/core/scripts/build-vm-serde-bundle.js' | ||
| - 'packages/core/test/workerd-smoke/**' | ||
| - '.github/workflows/quickjs.yml' | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} | ||
|
|
||
| jobs: | ||
| # ---- QuickJS runtime under Node.js ------------------------------------- | ||
| # Builds the nextjs-turbopack workbench and runs a curated subset of the e2e | ||
| # suite with WORKFLOW_RUNTIME=quickjs so the workflow orchestrator executes | ||
| # inside the QuickJS VM instead of node:vm. The subset is the set of features | ||
| # verified to work end-to-end under QuickJS today; widen it as remaining gaps | ||
| # (streams, AbortController, advanced hook-conflict, attributes) are closed. | ||
| node: | ||
| name: QuickJS Runtime (Node) | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
| env: | ||
| TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} | ||
| TURBO_TEAM: ${{ vars.TURBO_TEAM }} | ||
| WORKFLOW_PUBLIC_MANIFEST: '1' | ||
| APP_NAME: nextjs-turbopack | ||
| steps: | ||
| - name: Checkout Repo | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ github.event.pull_request.head.sha || github.sha }} | ||
|
|
||
| - name: Setup environment and build packages | ||
| uses: ./.github/actions/setup-workflow-dev | ||
| with: | ||
| build-packages: 'true' | ||
|
|
||
| - name: Build workbench | ||
| # `next build --turbopack` can intermittently crash on CI runners with a | ||
| # SIGBUS / Go deadlock (exit 135). Retry a couple of times — turbo only | ||
| # caches successful tasks, so a crashed build is re-run cleanly. | ||
| run: | | ||
| for attempt in 1 2 3; do | ||
| if pnpm turbo run build --filter=nextjs-turbopack; then exit 0; fi | ||
| echo "::warning::workbench build failed (attempt $attempt/3), retrying..." | ||
| sleep 5 | ||
| done | ||
| echo "::error::workbench build failed after 3 attempts" | ||
| exit 1 | ||
|
|
||
| - name: Run QuickJS unit + integration tests | ||
| run: pnpm vitest run packages/core/src/runtime/runtime-mode.test.ts packages/core/src/runtime/quickjs-runtime.test.ts | ||
|
|
||
| - name: Run curated e2e subset (WORKFLOW_RUNTIME=quickjs) | ||
| env: | ||
| WORKFLOW_RUNTIME: quickjs | ||
| WORKFLOW_NO_UPDATE_CHECK: '1' | ||
| NODE_OPTIONS: '--enable-source-maps' | ||
| DEPLOYMENT_URL: 'http://localhost:3000' | ||
| run: | | ||
| (cd "workbench/$APP_NAME" && WORKFLOW_RUNTIME=quickjs pnpm start &) | ||
| echo "Waiting for workbench to start..." && sleep 12 | ||
| for i in $(seq 1 30); do | ||
| if curl -sf -o /dev/null http://localhost:3000/; then echo "ready"; break; fi | ||
| sleep 2 | ||
| done | ||
| # NOTE: \bhookWorkflow (not hookWorkflow) so the pattern does not also | ||
| # match webhookWorkflow / parallelStepsThenWebhookWorkflow, whose | ||
| # byte-stream framing is a known QuickJS gap (tracked separately). | ||
| pnpm vitest run packages/core/e2e/e2e.test.ts \ | ||
| -t "addTenWorkflow|promiseAllWorkflow|promiseRaceWorkflow|promiseAnyWorkflow|\bhookWorkflow|sleepingWorkflow|nullByteWorkflow|workflowAndStepMetadataWorkflow|customSerializationWorkflow|errorSubclassRoundTripWorkflow|resilient start" | ||
|
|
||
| # ---- QuickJS VM under workerd ------------------------------------------ | ||
| # Runs the committed smoke worker (packages/core/test/workerd-smoke) under a | ||
| # local workerd via `wrangler dev`, then fetches it. The worker instantiates | ||
| # quickjs-wasi + the native extensions (imported as pre-compiled | ||
| # WebAssembly.Modules — workerd bans runtime WASM compilation from bytes) and | ||
| # evaluates core's real VM serde bundle, proving the QuickJS VM executes on | ||
| # Workers. Returns HTTP 200 only if every in-VM check passes. | ||
| workerd: | ||
| name: QuickJS Runtime (workerd) | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 20 | ||
| env: | ||
| TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} | ||
| TURBO_TEAM: ${{ vars.TURBO_TEAM }} | ||
| steps: | ||
| - name: Checkout Repo | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ github.event.pull_request.head.sha || github.sha }} | ||
|
|
||
| - name: Setup environment | ||
| uses: ./.github/actions/setup-workflow-dev | ||
| with: | ||
| build-packages: 'false' | ||
|
|
||
| - name: Build @workflow/core (generates VM serde bundle + assets) | ||
| run: pnpm turbo run build --filter=@workflow/core | ||
|
|
||
| - name: Run workerd smoke test | ||
| working-directory: packages/core/test/workerd-smoke | ||
| run: | | ||
| npx --yes wrangler@4.100.0 dev --port 8788 > /tmp/workerd-smoke.log 2>&1 & | ||
| WRANGLER_PID=$! | ||
| trap 'kill $WRANGLER_PID 2>/dev/null || true' EXIT | ||
|
|
||
| echo "Waiting for workerd to be ready..." | ||
| ready=false | ||
| for i in $(seq 1 60); do | ||
| if grep -qiE "Ready on|localhost:8788" /tmp/workerd-smoke.log 2>/dev/null; then ready=true; break; fi | ||
| if grep -qiE "\[ERROR\]|Build failed" /tmp/workerd-smoke.log 2>/dev/null; then break; fi | ||
| sleep 2 | ||
| done | ||
| if [ "$ready" != "true" ]; then | ||
| echo "::error::workerd did not become ready"; cat /tmp/workerd-smoke.log; exit 1 | ||
| fi | ||
|
|
||
| # Retry the fetch a few times while the isolate warms up. | ||
| code=0 | ||
| for i in $(seq 1 10); do | ||
| code=$(curl -s -m 30 -o /tmp/workerd-smoke-resp.json -w "%{http_code}" http://localhost:8788/ || echo 000) | ||
| if [ "$code" = "200" ]; then break; fi | ||
| sleep 3 | ||
| done | ||
|
|
||
| echo "=== response ==="; cat /tmp/workerd-smoke-resp.json || true; echo | ||
| if [ "$code" != "200" ]; then | ||
| echo "::error::workerd smoke test failed (HTTP $code)"; cat /tmp/workerd-smoke.log; exit 1 | ||
| fi | ||
| echo "workerd smoke test passed" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,9 @@ | ||
| # Auto-generated version file | ||
| src/version.ts | ||
|
|
||
| # Auto-generated quickjs-wasi binary assets (base64-encoded WASM + .so files) | ||
| src/runtime/quickjs-assets.generated.ts | ||
|
|
||
| # Auto-generated VM serde bundle (devalue + format-prefix + reducers, | ||
| # packaged as a string for evaluation inside the QuickJS VM) | ||
| src/runtime/vm-serde-bundle.generated.ts |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| /** | ||
| * Build script: generates quickjs-assets.generated.ts | ||
| * | ||
| * Reads the quickjs-wasi WASM binary and native C extension .so files, | ||
| * base64-encodes them, and writes a TypeScript module that exports the | ||
| * decoded Buffer/Uint8Array values. This embeds the binaries directly | ||
| * in JavaScript, bypassing all bundler/framework/deployment issues with | ||
| * import.meta.url, require.resolve, and file tracing. Crucially, this means | ||
| * the QuickJS runtime does NO filesystem access at runtime, so it works on | ||
| * platforms like Cloudflare Workers where `node:vm` is unavailable. | ||
| * | ||
| * Targets quickjs-wasi@3.x: `btoa`/`atob`/`DOMException` are built-in | ||
| * intrinsics (enabled by default), so there is no separate `base64` | ||
| * extension to embed — only the C extensions that add Web APIs. | ||
| */ | ||
|
|
||
| import { readFileSync, writeFileSync } from 'fs'; | ||
| import { createRequire } from 'module'; | ||
| import { dirname, resolve } from 'path'; | ||
| import { fileURLToPath } from 'url'; | ||
|
|
||
| const __dirname = dirname(fileURLToPath(import.meta.url)); | ||
| const srcDir = resolve(__dirname, '../src'); | ||
|
|
||
| const require_ = createRequire(import.meta.url); | ||
|
|
||
| // quickjs-wasi 3.x exposes the binary + each extension as subpath exports. | ||
| const files = { | ||
| quickjsWasm: require_.resolve('quickjs-wasi/quickjs.wasm'), | ||
| encodingSo: require_.resolve('quickjs-wasi/encoding.so'), | ||
| headersSo: require_.resolve('quickjs-wasi/headers.so'), | ||
| urlSo: require_.resolve('quickjs-wasi/url.so'), | ||
| structuredCloneSo: require_.resolve('quickjs-wasi/structured-clone.so'), | ||
| }; | ||
|
|
||
| let output = `/** | ||
| * Auto-generated by scripts/build-quickjs-assets.js | ||
| * Do not edit manually. | ||
| * | ||
| * Contains base64-encoded quickjs-wasi WASM binary and native C extension | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: Note The generated module embeds ~3.3 MB of base64-encoded WASM/
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: Note (follow-up — corrects the framing above) I built the vite workbench for both the plain Node and the Vercel targets and inspected the output; the bundle impact is narrower than my note implied. It is code-split into a lazy chunk, not inlined into the entry, and not tree-shaken away (the bundler can't prove the
So, splitting the two senses of "in the bundle":
Net for node:vm-only users (the default): no main-bundle bloat and no cold-start parse/CPU penalty — the cost is purely ~3.3 MB of dead weight in every deployed function artifact regardless of runtime. Recoverable only by making
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the detailed follow-up — agreed with the corrected framing: code-split lazy chunk, so node:vm-only users pay no main-bundle or cold-start cost, just ~3.3 MB of dead weight in the deployed function artifact (counts toward Vercel's function size limit). For this PR I'm leaving it as a hard dep since the runtime is opt-in/experimental, but you're right that the proper fix before any broad rollout is to make |
||
| * .so files. Decoded at import time so they can be passed directly to | ||
| * QuickJS.create() without any filesystem access, import.meta.url | ||
| * resolution, or require.resolve calls. | ||
| */ | ||
| import type { ExtensionDescriptor } from 'quickjs-wasi'; | ||
|
|
||
| `; | ||
|
|
||
| let totalSize = 0; | ||
|
|
||
| for (const [name, filePath] of Object.entries(files)) { | ||
| const buf = readFileSync(filePath); | ||
| const b64 = buf.toString('base64'); | ||
| totalSize += buf.length; | ||
| output += `const ${name} = Buffer.from('${b64}', 'base64');\n\n`; | ||
| } | ||
|
|
||
| output += `export { quickjsWasm };\n\n`; | ||
|
|
||
| // initFn defaults to `qjs_ext_${name}_init` (with `-` replaced by `_`), so | ||
| // `structured-clone` resolves to `qjs_ext_structured_clone_init` automatically. | ||
| output += `export const quickjsExtensions: ExtensionDescriptor[] = [ | ||
| { name: 'encoding', wasm: encodingSo }, | ||
| { name: 'headers', wasm: headersSo }, | ||
| { name: 'url', wasm: urlSo }, | ||
| { name: 'structured-clone', wasm: structuredCloneSo }, | ||
| ];\n`; | ||
|
|
||
| const outPath = resolve(srcDir, 'runtime/quickjs-assets.generated.ts'); | ||
| writeFileSync(outPath, output); | ||
|
|
||
| const sizeKB = (totalSize / 1024).toFixed(0); | ||
| const b64SizeKB = (Buffer.byteLength(output) / 1024).toFixed(0); | ||
| console.log( | ||
| `Generated quickjs-assets.generated.ts (${sizeKB} KB binary → ${b64SizeKB} KB base64)` | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| /** | ||
| * Build script: generates the VM serialization bundle. | ||
| * | ||
| * Uses esbuild to bundle workflow-vm.ts into a self-contained IIFE. | ||
| * The output is written as a TypeScript file containing the bundle as | ||
| * a string constant, which can be imported by the snapshot runtime. | ||
| * | ||
| * TextEncoder, TextDecoder, and Headers are provided by native C | ||
| * extensions in quickjs-wasi, so no JS polyfills are needed. | ||
| */ | ||
|
|
||
| import { buildSync } from 'esbuild'; | ||
| import { writeFileSync } from 'fs'; | ||
| import { dirname, resolve } from 'path'; | ||
| import { fileURLToPath } from 'url'; | ||
|
|
||
| const __dirname = dirname(fileURLToPath(import.meta.url)); | ||
| const srcDir = resolve(__dirname, '../src'); | ||
|
|
||
| const result = buildSync({ | ||
| entryPoints: [resolve(srcDir, 'serialization/vm-bundle-entry.ts')], | ||
| // NOTE: TextEncoder, TextDecoder, and Headers are provided by native | ||
| // C extensions (encoding, headers) in quickjs-wasi, so the polyfill | ||
| // injection that was previously here has been removed. | ||
| bundle: true, | ||
| format: 'iife', | ||
| platform: 'neutral', | ||
| target: 'es2020', | ||
| write: false, | ||
| minify: true, | ||
| }); | ||
|
|
||
| const bundleCode = result.outputFiles[0].text; | ||
|
|
||
| // Write as a TS module using a template literal. Template literals avoid | ||
| // the escaping issues that occur with regular string literals — esbuild's | ||
| // minifier produces patterns like `typeof x<"u"` whose escaped quotes | ||
| // inside a JSON-stringified string break when downstream esbuild (e.g., | ||
| // Nitro) re-processes the compiled JS output. Template literals don't | ||
| // have this problem since backticks don't conflict with inner quotes. | ||
| const escaped = bundleCode | ||
| .replace(/\\/g, '\\\\') | ||
| .replace(/`/g, '\\`') | ||
| .replace(/\$\{/g, '\\${'); | ||
|
|
||
| const outPath = resolve(srcDir, 'runtime/vm-serde-bundle.generated.ts'); | ||
| writeFileSync( | ||
| outPath, | ||
| `/** | ||
| * Auto-generated by scripts/build-vm-serde-bundle.js | ||
| * Do not edit manually. | ||
| * | ||
| * This is the VM serialization bundle — a self-contained IIFE that sets up | ||
| * serialize/deserialize + TextEncoder/TextDecoder polyfills inside the | ||
| * QuickJS WASM VM. It includes devalue and all workflow-mode reducers. | ||
| * | ||
| * Size: ${(bundleCode.length / 1024).toFixed(1)} KB minified | ||
| */ | ||
| export const VM_SERDE_BUNDLE: string = \`${escaped}\`; | ||
| ` | ||
| ); | ||
|
|
||
| console.log( | ||
| `Generated vm-serde-bundle.generated.ts (${(bundleCode.length / 1024).toFixed(1)} KB)` | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AI Review: Blocking
The
QuickJS Runtime (Node)job is currently failing onhookWorkflowandhookWorkflow is not resumable via public webhook endpointwithHook token "…" is already in use by another workflow. Both are in this curated subset (\bhookWorkflow), i.e. claimed-supported. The job's recent history is flaky (1 pass / 3 fail), which is the signature of the determinism race flagged inruntime.ts— under turbo the resume invocation regenerates a different hook token/correlationId than the first invocation committed.Shouldn't merge with this job red on a feature the curated subset asserts as working. Fixing the turbo/seed integration should resolve these; please confirm the job goes green (not just re-run-to-pass).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right — this was the visible symptom of the turbo/seed divergence (fixed in 4dcc203, see the runtime.ts thread). Reproduced locally post-merge (turbo on by default):
hookWorkflow+hookWorkflow is not resumable via public webhook endpointwere failing; after excluding QuickJS from turbo they pass (8/8 in the matched subset, 0 fail) — confirmed green, not re-run-to-pass. The fresh CI run will reflect it.