Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quickjs-runtime.md
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.
150 changes: 150 additions & 0 deletions .github/workflows/quickjs.yml
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"

Copy link
Copy Markdown
Member

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 on hookWorkflow and hookWorkflow is not resumable via public webhook endpoint with Hook 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 in runtime.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).

Copy link
Copy Markdown
Contributor Author

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 endpoint were 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.


# ---- 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"
7 changes: 7 additions & 0 deletions packages/core/.gitignore
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
4 changes: 3 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"./_workflow": "./dist/workflow/index.js"
},
"scripts": {
"build": "genversion --es6 src/version.ts && tsc",
"build": "genversion --es6 src/version.ts && node scripts/build-vm-serde-bundle.js && node scripts/build-quickjs-assets.js && tsc",
"dev": "genversion --es6 src/version.ts && tsc --watch",
"clean": "tsc --build --clean && rm -rf dist src/version.ts docs ||:",
"test": "cross-env WORKFLOW_TARGET_WORLD=local vitest run src",
Expand All @@ -101,6 +101,7 @@
"devalue": "5.8.1",
"ms": "2.1.3",
"nanoid": "5.1.6",
"quickjs-wasi": "3.0.0",
"seedrandom": "3.0.5",
"semver": "catalog:",
"ulid": "catalog:",
Expand All @@ -117,6 +118,7 @@
"@types/semver": "7.7.1",
"@workflow/tsconfig": "workspace:*",
"cross-env": "10.1.0",
"esbuild": "catalog:",
"genversion": "3.2.0"
},
"peerDependencies": {
Expand Down
76 changes: 76 additions & 0 deletions packages/core/scripts/build-quickjs-assets.js
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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/.so assets, which tsc compiles into dist and ships in the @workflow/core npm tarball for all consumers — even though node:vm is the default and the asset is only dynamically imported at runtime. The dynamic import defers runtime load, not package/tarball size. Worth confirming the bundle-size impact is acceptable, or splitting the assets into an optional dependency that only QuickJS users install.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 WORKFLOW_RUNTIME / per-run flag is never set, so the chunk is always emitted — but it's loaded only via the dynamic import() at runtime.ts:966).

vercel build.vercel/output/functions/__server.func/ (Nitro, nodejs24.x):

.vc-config.json   →  "handler": "index.mjs"
index.mjs                     2.2 MB   ← no WASM inlined; references the chunk via import("./_chunks/quickjs-entrypoint.mjs")
_chunks/quickjs-entrypoint.mjs  3.3 MB   ← the blob, its own chunk (~41% of the 8.0 MB function)

So, splitting the two senses of "in the bundle":

  • Same deployed function artifact as index.mjs? Yes — it ships inside __server.func/, counts toward the function's deployed size and toward Vercel's function size limit.
  • Same JS module / parsed at cold start? No — index.mjs loads it only via dynamic import(). The launcher boots index.mjs; the 3.3 MB chunk isn't read or compiled until a run actually selects QuickJS.

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 quickjs-wasi an optional/peer dependency of @workflow/core rather than a hard dep. Downgrading this from a bundle concern to a deploy-artifact-size one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 quickjs-wasi (and the generated assets) an optional/peer dependency that only QuickJS users install. I'll track that with the other Workers follow-ups (asset-loading variant + wasm-compression) rather than expand this PR's scope.

* .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)`
);
65 changes: 65 additions & 0 deletions packages/core/scripts/build-vm-serde-bundle.js
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)`
);
Loading
Loading