Nathan Rajlich
n@n8.io
90d · built 2026-05-28
90-day totals
- Commits
- 165
- Grow
- 12.2
- Maintenance
- 20.2
- Fixes
- 9.3
- Total ETV
- 41.7
Where this dev ranks
Percentile against the global top-100 leaderboard (all-time totals).
- By commits
- Top 58 %
- By Growth share
- Top 56 %
30-day trajectory
Last 30 days vs. the 30 days before. Up arrows on Growth and ETV mean improvement; up arrow on Fixes share means more time on fixes (worse).
Daily performance
Daily ETV, stacked by Growth, Maintenance and Fixes.
Work-mix over time
Share of Growth / Maintenance / Fixes over a rolling 7-day window. Reads as 'where is effort flowing right now'.
Bug flow over time
Monthly bug flow attributed to this developer. The left bar (red) is bug impact this dev authored that was addressed in the given month — combining bugs others fixed for them and bugs they fixed themselves. The right bar is fixes they personally shipped that month, split between self-fixes (overlap with the red bar) and fixes done for someone else. X-axis is fix-time, not introduction-time — the Navigara API attributes bugs backward to the author at the moment the fix lands.
- Self-fix share
- 27%
- Bugs you introduced
- 12.3
- Bugs you fixed
- 15.1
Repository spread
Where this developer's commits land. Concentrated work (top1 > 80%) vs polymath spread (top1 < 30%).
Most impactful commits
Top 20 by ETV in the 90-day window.
- 2.6ETVfix(swc-plugin): closure variable detection for `new` expressions and module-level declarations (#1368) * fix(swc-plugin): closure variable detection for `new` expressions and module-level declarations Fix two SWC compiler plugin bugs related to closure variable detection: 1. Add Expr::New handling to ClosureVariableCollector so `new Class(...args)` properly captures both the callee and arguments as closure variables. 2. Exclude module-level declarations (functions, variables, classes) from closure variable detection, preventing over-capturing of identifiers that are already available in all bundles. This also allows DCE to properly remove step-only helpers and their imports from the workflow bundle. Fixes #1365 * fix(swc-plugin): handle additional expression/statement types in closure variable collector Expand closure variable detection to cover more AST patterns: Expressions: Seq (comma), Yield, OptChain, Prop::Shorthand, computed property keys, Prop::Assign defaults, Class (skip bodies) Statements: Throw, Try/Catch/Finally, Switch, ForIn, ForOf, DoWhile, Labeled Also fix existing Prop::Shorthand bug where object shorthand properties like { url } were not being collected as closure vars. Extend test fixture with cases for all newly handled patterns. Fix spec.md wording per review feedback. * fix(swc-plugin): preserve original step function bodies in enclosing functions In step mode, nested step functions were replaced with bare references to the hoisted copy (e.g., `return hoisted$fn;`). This broke direct calls because the hoisted copy uses `__private_getClosureVars()` which only works in workflow context. Now the original function body is preserved inline with just the directive stripped, so JavaScript's normal closure semantics work for direct calls. The hoisted copy with `__private_getClosureVars()` is still registered for workflow-driven execution. Fixes #1369 * fix(swc-plugin): restore metadata tracking for object property steps in step mode The previous commit accidentally removed the object_property_workflow_conversions tracking from the step mode path, causing __internal_workflows metadata to be stripped from step bundle output for object property step functions. * fix(swc-plugin): detect closure variables inside nested function/method bodies The closure variable collector was skipping nested function expressions, arrow functions, and method bodies entirely. This meant closure variables used deep inside inner functions (e.g., a variable used inside a ReadableStream's start() method) were not captured. Now the collector walks into nested function/arrow/method/getter/setter bodies while adding their parameters to the local var set, so only truly free variables from the outer step scope are captured. Also add ReadableStream, WritableStream, TransformStream, and other common Web API globals to the known globals list. * update changeset to include Bug 4 * fix(swc-plugin): handle TypeScript expression wrappers and class bodies in closure detection After comparing with Next.js's SWC plugin closure detection approach, identified and fixed remaining gaps: - TypeScript expression wrappers (as, satisfies, !, type assertions, const assertions, instantiation expressions) now traverse to the inner expression instead of being silently skipped - Class expressions and declarations now walk their body members (methods, properties, constructors, static blocks) to detect closure variables used inside them - Document all remaining safe-to-skip Expr variants (This, Lit, SuperProp, MetaProp, PrivateName, Invalid, JSX) * test: add fixture cases for TypeScript wrappers and class body closure detection * test: add TypeScript fixture for closure detection through TS expression wrappers Add a proper input.ts fixture that tests closure variable detection through real TypeScript syntax: `as`, `satisfies`, `!` (non-null), angle-bracket type assertions, `as const`, and generic function calls. Update test harness to support input.ts files by adding swc_ecma_parser dev-dependency and auto-detecting TypeScript syntax from file extension. Remove the incorrectly placed TypeScript-related test cases from the JS fixture (they were using plain JS syntax, not actual TS wrappers).github.com-vercel-workflow · 5d95abf9 · 2026-03-16
- 2.4ETVInline all SWC plugin step registrations, remove workflow/internal/private (#1632) The SWC compiler plugin no longer generates import statements. All step function registrations and closure variable access are now self-contained inline IIFEs with zero module dependenciesgithub.com-vercel-workflow · 0a86de3a · 2026-04-08
- 2.0ETVSerialize `run_failed`/`step_failed` errors through serialization pipeline (#1851) * Serialize run_failed/step_failed errors through serialization pipeline Switch run_failed, step_failed, and step_retrying events to persist the full thrown value via the workflow serialization pipeline (as SerializedData / Uint8Array) instead of a lossy { message, stack, code } StructuredError shape. Consumers hydrate via hydrateRunError / hydrateStepError to reconstruct the original thrown value, preserving Error subclass identity, cause chains, and custom properties. - WorkflowRun.error and Step.error are now SerializedData - WorkflowRun gains a top-level errorCode plaintext field - WorkflowRunFailedError.cause is now the hydrated thrown value - Adds world-postgres migration 0010_add_error_code.sql - Legacy pre-pipeline errorJson records surface as undefined on read * Update Next.js workbenches for new WorkflowRunFailedError.cause type cause is now `unknown` (the hydrated thrown value) rather than `Error & { code }`. Defensively extract Error-shaped fields when the hydrated value is an Error, otherwise round-trip the raw value, and expose the new `errorCode` classification field. * Update docs for WorkflowRunFailedError.cause: unknown The hydrated `cause` is now `unknown` (the original thrown value through the serialization pipeline) and the error classification has moved to the top-level `errorCode` property. Update the two affected docs pages and the `TSDoc` interface to reflect the new shape, and narrow `cause` with `instanceof Error` before accessing fields. * Expand test coverage for the run/step error serialization pipeline Unit tests: - 19 new dehydrate/hydrate{Step,Run}Error round-trip tests covering FatalError, plain Error, built-in Error subclasses, non-Error thrown values (string, plain object), cause chains, encryption round-trip, the binary format prefix contract, and the unserializable / unknown- format error paths. - 5 new tests for Run.returnValue when the run is failed: hydrated FatalError + cause as cause, plain Error preservation, non-Error thrown values surfaced verbatim, cross-class cause chains, and the hydration-failure fallback that still surfaces errorCode. E2E tests (new, in 99_e2e.ts + e2e.test.ts): - Step throw → workflow catch round-trips a FatalError with a TypeError cause chain, asserting class identity, fatal marker, and cause name + message all survive the step_failed event pipeline. - Workflow throw → run_failed reaches status with the new top-level errorCode metadata exposed (cause-shape coverage lives at the unit level, since the SWC plugin's class registration is not invoked in the plain-Node e2e runner). - Workflow throw of a non-Error value round-trips that value verbatim as WorkflowRunFailedError.cause. Adjustments to existing assertions: - error.cause is now ; tests narrow with and use the new top-level field instead of . - step.error / run.error from CLI --withData are now hydrated payloads: unregistered class instances surface as Instance refs whose carries the original message + stack. Observability hydration: - hydrateStepIO / hydrateWorkflowIO in serialization-format.ts now hydrate the field via hydrateData, so the CLI and web UI continue to surface readable run/step error messages and stacks. * Tighten error serialization changeset description * Trim error serialization changeset to a single sentence * Resolve FatalError/RetryableError revivers via cross-realm registry When a workflow runs in a Node `vm` context, its bundled `@workflow/errors` is a different module instance than the host's import (separate prototype chains, separate class identity). Calling `new FatalError(...)` from the host-side reviver produces a host-realm instance that fails `err instanceof FatalError` checks in the workflow code — even when the serialized payload was correctly tagged via the dedicated `FatalError` reducer. Surfaced by the local-prod e2e "step throw round-trips FatalError" test on Next.js Turbopack: each route gets its own bundled chunk, so the flow handler's `@workflow/errors` and the workflow VM bundle's `@workflow/errors` are two distinct copies of the same module. Fix: - Each bundled copy of `@workflow/errors` self-registers its `FatalError` and `RetryableError` classes on `globalThis` via `Symbol.for("@workflow/errors//FatalError")` / `Symbol.for("@workflow/errors//RetryableError")`. First load wins per realm; the descriptor is non-writable / non-configurable to make accidental clobbering loud. - The revivers in `@workflow/core`'s common reducers module read the consumer's `globalThis` (passed in as `global`) to pick up the realm-local class, falling back to the host-imported class when no registration is present (e.g. in the CLI / test runner). * Use `types.isNativeError` to remap workflow stacks across VM realms The runtime's run-failure path computes a source-map-remapped stack and then assigns it back onto the thrown value via `if (err instanceof Error) err.stack = errorStack`. Workflows run inside a Node `vm` context, so a workflow-thrown error is an instance of the VM realm's `Error` — `instanceof` against the host realm's `Error` returns `false`, the assignment is skipped, and the serialized `run_failed` event carries the un-remapped (bundled-line- number) stack instead of the source-mapped one. Switch the gate to `types.isNativeError`, which uses V8's internal type tag and works across realms — same approach already in place for the serialization reducers. Caught by the local-prod e2e "nested function calls preserve message and stack trace" and "cross-file imports preserve message and stack trace" tests, which assert that the persisted run-error stack contains `99_e2e.ts` / `helpers.ts`. * Sync CLI revivers with core + add toJSON shim for Error subclasses Two issues with the CLI's hand-rolled reviver list: 1. It hadn't been updated for the new first-class Error subclass reducers (`TypeError`, `RangeError`, `FatalError`, `RetryableError`, etc.). devalue throws "Unknown type X" when it encounters a reduced value with no matching reviver, and `hydrateResourceIO` swallows that error and surfaces the raw `Uint8Array` payload — so `step.error` / `run.error` showed up as raw byte dumps in `workflow inspect` output. 2. Even with all the right revivers, `Error.prototype`'s `message` / `stack` / `cause` are non-enumerable, so `JSON.stringify` (used by `workflow inspect --json`) drops them — leaving the subclass-specific enumerable fields (e.g. `FatalError.fatal`) visible but the actual error data missing. Fix: - Build the CLI reviver set on top of `getCommonRevivers()` from `@workflow/core` so the CLI stays in sync with the runtime's reducer set automatically. New core reducers/revivers will Just Work without any CLI-side change. - Wrap each Error reviver from the common set with a thin shim that attaches a non-enumerable `toJSON` method to the produced `Error` instance. `JSON.stringify` calls `toJSON` and gets a full object (`name` + `message` + `stack` + `cause` + any enumerable subclass fields like `fatal` / `retryAfter` / `errors`); `util.inspect` ignores `toJSON` and renders the canonical `Error: msg\\n at ...` format. Best of both worlds for CLI output without compromising the runtime hydration path. Caught by the local-prod e2e "basic step error preserves" and "cross-file step error preserves" tests, which read `failedStep.error.message` / `.stack` from the CLI's JSON output. * Clarify parseErrorJson JSDoc to match its always-null return The previous JSDoc described preserving legacy values "for best-effort hydration" which contradicted the implementation, where legacy errors are intentionally surfaced as absent (the pre-pipeline shapes can't be hydrated by the new error revivers). Rewrite the comment so the contract matches behavior. Also rename the now-unused parameter to `_errorJson` to reflect that the function ignores it. Caught by a code review on #1851. * Refine error-handler ergonomics on the step / run hot paths Three review-driven adjustments that all touch the queue handlers and their interaction with the error serialization pipeline: 1. Memoize the per-run encryption key fetch. The step handler used to eagerly fetch + import the key at the top of every step delivery so the value would be in scope for every potential dehydrateStepError path. That pessimized step-started early-return cases (the fetch happens unconditionally even when the step never reaches user code) and required duplicating the same boilerplate at four call sites in runtime.ts. Introduce `memoizeEncryptionKey(world, run)` in runtime/helpers.ts that returns a lazy, single-fetch accessor; step-handler / runtime call sites use `await getEncryptionKey()` instead. The first caller pays the fetch cost, subsequent callers await the cached promise, and steps that fail before any encryption-aware work happens skip the fetch entirely. 2. Preserve the prior attempt's serialized error as the cause on the defensive max-retries-exceeded `step_failed` re-invocation guard. The existing comment explicitly opted out of cause attachment, but the symmetric post-failure path below already does this and the reviewer is right that consumers shouldn't have to walk the step_retrying event history to recover the underlying error. Best- effort: if hydration of the prior `step.error` throws, fall back to a FatalError without cause rather than letting the event write itself fail. 3. Document the intentional `unflatten` throw in `hydrateStepError` / `hydrateRunError` for non-Uint8Array input. SDK version is pinned per workflow run via skew protection so the non-binary branch is dead in production; if a misshapen value reaches it, surfacing the throw via the surrounding o11y try/catch is more debuggable than masking it. Add a comment so future reviewers don't reach for a defensive fallback. A standalone `falls back to plaintext` suggestion on the run_failed key fetch was rejected: when encryption is configured we should fail loudly rather than silently emit plaintext error data. The queue's redelivery semantics will retry the key fetch; persistent KMS outages get logged with the existing "persistent error preventing the run from being terminated" message rather than a security regression. * Hydrate `event.eventData.error` in event listings `hydrateEventData` enumerated the per-event fields that need hydration (`result`, `input`, `output`, `metadata`, `payload`) but omitted the new `error` field on `step_failed`, `step_retrying`, and `run_failed` events. Without this branch, o11y tools that list events (e.g. `workflow inspect events`) surface the raw `Uint8Array` payload instead of a hydrated `{ name, message, stack, … }` object even though the entity-level `Run.error` / `Step.error` paths already hydrate. Mirrors the existing per-field branches; the `try/catch` leaves the field un-hydrated on parse failure rather than failing the whole event view. Adds a unit test. * Use `.is()` static checks in `classifyRunError` for cross-realm safety Workflows execute inside a separate `vm` realm: the `WorkflowRuntimeError` class bundled into the workflow code and the host-imported one are distinct constructors, so an `err instanceof WorkflowRuntimeError` check on a VM-thrown error returns `false` and we'd misclassify genuine runtime errors (corrupted event log, missing timestamps, workflow/step not registered) as user errors. Switch to each subclass's `.is()` static (a name-based duck check that works across realms). Since `WorkflowRuntimeError.is` only matches its own concrete name, enumerate every concrete subclass we want to recognize (`StepNotRegisteredError`, `WorkflowNotRegisteredError`) in a `RUNTIME_ERROR_CHECKS` table; keep that table in sync with the class hierarchy in `@workflow/errors`. Existing `classify-error.test.ts` already covers `WorkflowRuntimeError` and `WorkflowNotRegisteredError` cases — both still pass. * Add e2e coverage for step throws of non-Error values We had `errorWorkflowThrowNonErrorValue` (workflow body throws a plain object — round-trips verbatim as `WorkflowRunFailedError.cause`) but no symmetric coverage for the step-throw side. Step-throw goes through a different code path: non-Error values aren't recognized as `FatalError` (no `name === 'FatalError'`) nor `RetryableError`, so they take the transient retry path. After max retries the runtime wraps the original thrown value as `cause` on a fresh `FatalError` which the workflow's catch block then sees. Add a workflow that throws a recognizable plain object from a step with `maxRetries = 0` (so we exhaust on first attempt and avoid a long test wait) and a workflow that asserts the wrapped FatalError shape: `isFatal`, `instanceof FatalError`, message includes the original object's serialized form, `cause` is the original non-Error object verbatim with structure preserved. Documents the current retry-then-wrap behavior so any future change to "non-Error throws skip retries" semantics has to update the test. * Note legacy postgres error-data loss in the run/step error changeset Pre-upgrade failed runs that wrote into world-postgres's deprecated `error` text column can't be hydrated through the new pipeline (the shape is incompatible with the new revivers). The new runtime intentionally surfaces them as `error: undefined` on read; the original payload is still readable directly from the `errorJson` column for manual inspection. Add a one-sentence note to the changeset's migration text so consumers upgrading don't get blindsided by suddenly-empty error fields on historical runs.github.com-vercel-workflow · 5f228326 · 2026-05-04
- 1.9ETVRefactor: Extract serialization into modular architecture and wire into existing pipeline (#1299) * Add serialization module foundation: types, codec interface, format prefix Start of the serialization refactor (separate from snapshot-runtime). New files: - serialization/types.ts — SerializationFormat enum, SerializableSpecial interface, Reducers/Revivers types - serialization/codec.ts — Codec interface with formatPrefix, serialize, deserialize, and optional deserializeLegacy - serialization/format.ts — Format prefix encode/decode/peek, moved from the monolithic serialization.ts The Codec interface enables future alternative formats (CBOR, JSON) while keeping the devalue implementation as the current default. * Add reducers, devalue codec, encryption, and mode-specific modules Serialization refactor Phase 1: create the new module structure alongside the existing monolithic serialization.ts (which continues to work). New files: - serialization/reducers/common.ts — Date, Error, Map, Set, URL, BigInt, typed arrays, Headers, Request, Response, RegExp, URLSearchParams - serialization/reducers/class.ts — Class/Instance with WORKFLOW_SERIALIZE/ DESERIALIZE support - serialization/reducers/step-function.ts — StepFunction with closure vars - serialization/codec-devalue.ts — devalue Codec implementation - serialization/encryption.ts — composable encrypt/decrypt layer - serialization/workflow.ts — synchronous, no encryption, for VM use - serialization/step.ts — async with encryption, for step handler - serialization/client.ts — async with encryption, for start() API - serialization/index.ts — re-exports all public API - serialization/serialization.test.ts — 25 focused tests All modes compose their reducer/reviver sets from the shared building blocks. Cross-mode compatibility verified: data serialized in any mode can be deserialized in any other mode (for common types). Existing 108 serialization tests continue to pass unchanged. * Add sub-path exports for workflow serialization module - Add ./serialization/workflow export to @workflow/core package.json - Add ./internal/serialization re-export to workflow meta-package - The workflow bundle can now import serialize/deserialize via: import { serialize, deserialize } from 'workflow/internal/serialization' Full test suite passes: 493 tests across 22 files (including 25 new serialization module tests). * Address code review feedback 1. Fix reducer composition order: Class/Instance reducers now come BEFORE common reducers in all three modes (workflow, step, client). This ensures custom Error subclasses with WORKFLOW_SERIALIZE are handled by the Instance reducer before the generic Error reducer (devalue uses first-match-wins semantics). 2. Fix encryption decrypt() to fail fast when encrypted data is encountered without a decryption key, instead of silently returning encrypted bytes that would fail later with an unhelpful format error. 3. Remove Request/Response from common reducers — they don't have matching common revivers, so including them caused asymmetric behavior (serialize as Request, deserialize as plain object). Request/Response handling belongs in mode-specific modules that can provide proper revivers. 4. Document Node.js dependency in the workflow serialization re-export. The current implementation uses node:util and Buffer. For the QuickJS VM (snapshot runtime), these will need polyfills — tracked separately. * Move reducer/reviver composition into the devalue codec The Codec interface now takes a SerializationMode ('workflow', 'step', 'client') instead of raw reducers/revivers. The reducer/reviver composition is internal to the devalue codec implementation. This is the right abstraction because reducers/revivers are devalue- specific concepts. A future CBOR codec would handle Date, typed arrays, Map, Set natively via the CBOR type system — it wouldn't use reducers at all. A JSON codec would only support standard JSON types. The mode-specific modules (workflow.ts, step.ts, client.ts) are now simpler — they just pass the mode string to the codec. * Replace SerializationFormatType enum with open-ended FormatPrefix type The format prefix is now a branded string type validated by isFormatPrefix() — any 4-character [a-z0-9] string is valid. This removes the hard-coded enum of known formats, making the system truly open for extension: type FormatPrefix = string & { __brand: 'FormatPrefix' }; function isFormatPrefix(value: string): value is FormatPrefix; The SerializationFormat object still provides well-known constants ('devl', 'encr') but they're now just typed constants, not an exhaustive enum. peekFormatPrefix() and decodeFormatPrefix() use isFormatPrefix() for validation instead of checking against a known list. Unknown but valid prefixes (e.g. 'cbor', 'json', 'v2b1') are accepted — the caller decides whether they can handle the format. 6 new isFormatPrefix tests covering: valid strings, too short, too long, uppercase, special characters. 1 new test for unknown-but-valid prefixes. * Wire modular serialization modules into serialization.ts, add 138 unit tests Replace duplicate format prefix, reducer/reviver, and encryption helper code in the monolithic serialization.ts with imports from the modular serialization/ directory. This completes the refactoring started in the earlier additive-only commits. Key changes: - serialization.ts now imports types, format prefix, common/class/step-function reducers and revivers, and encryption helpers from ./serialization/ modules - Removed ~450 lines of duplicate code from serialization.ts - Made encryption error messages consistent between old and new modules - Added 138 comprehensive unit tests covering types, format prefix, encryption, codec, all three reducer modules, all three mode modules, cross-mode compatibility, and edge cases - Updated one existing test assertion for new error message wording * Address code review feedback - encryption.ts: throw WorkflowRuntimeError instead of plain Error in decrypt() to preserve the error contract from legacy maybeDecrypt() - format.ts: document that open-ended prefix validation ([a-z0-9]{4}) is intentional for forward compatibility — callers check support - errors.ts: extract duplicated formatSerializationError into shared utility, remove 4 copies from workflow.ts, step.ts, client.ts - codec-devalue.ts: document that globalThis default is a known limitation; legacy dehydrate/hydrate path still supports custom global * Fix codec-devalue.ts comment: clarify modular modules are not used in current runtime The globalThis default is not a limitation for the current runtime — all serialization goes through dehydrate*/hydrate* in serialization.ts which passes the correct global. The modular modules are infrastructure for the future snapshot runtime where serialization runs inside the VM. * Wire dehydrate/hydrate functions through modular serialize/deserialize The dehydrate*/hydrate* functions in serialization.ts now delegate to the modular mode modules (workflowModule, stepModule, clientModule) instead of directly calling devalue stringify/parse/unflatten. Key changes: - Extended Codec interface with CodecOptions (global, extraReducers, extraRevivers) so the codec can receive VM globals and mode-specific stream/Request/Response handlers - devalueCodec threads global through to all reducer/reviver factories so instanceof checks work across VM boundaries - Mode modules (workflow.ts, step.ts, client.ts) accept CodecOptions and pass them through to the codec - dehydrate*/hydrate* functions now call module serialize/deserialize with stream and Request/Response reducers/revivers passed as extras - v1Compat path remains inline (pre-codec, uses stringify + revive) - Error context strings preserved via try/catch re-wrapping * Bump changeset from patch to minor for serialization refactor Return types of public get*Reducers/get*Revivers functions narrowed from Reducers/Revivers to Partial<Reducers>/Partial<Revivers>, which is a TypeScript-level breaking change. Also adds new sub-path exports (@workflow/core/serialization/workflow, workflow/internal/serialization) which is additive. Minor bump is the appropriate semver for both. * Remove unused workflow/internal/serialization re-export and @workflow/core/serialization/workflow sub-path Both exports had zero consumers in the repo. The workflow/internal/serialization export was previously removed on main in #1082 for the same reason. The modular workflow.serialize/deserialize is still reachable via @workflow/core/serialization when needed. These exports can be reintroduced by the snapshot runtime branch if/when it actually needs them. Also updates the changeset to drop the 'new sub-path exports' bullet. * Downgrade changeset from minor to patch After auditing actual consumers of the narrowed return types (getExternalReducers/getWorkflowReducers/getExternalRevivers/getWorkflowRevivers now return Partial<Reducers>/Partial<Revivers>), no in-repo or external consumer indexes specific keys on the returned object in a way that would break. The only internal caller that did (runtime/run.ts) was updated in this same PR. The narrowing is type-safer but effectively invisible at runtime and for idiomatic callers that spread or forward the object. Since the refactor is internally restructuring only, patch is the appropriate semver bump. * Trim serialization-refactor changeset * Dedup formatSerializationError: import from serialization/errors.ts The legacy serialization.ts had its own inlined copy of formatSerializationError. Now that the helper is exported from serialization/errors.ts (already consumed by workflow.ts/step.ts/client.ts), import it here too to keep the single source of truth.github.com-vercel-workflow · 9f3516ec · 2026-05-01
- 1.7ETV[swc-plugin] Capture lexical `this` for nested arrow step functions (#1935) * [swc-plugin] Capture lexical `this` for nested arrow step functions When a nested arrow `"use step"` references the enclosing function/method's `this`, plumb that `this` through the workflow runtime so the step body sees the correct receiver. - Workflow mode wraps the step proxy with `.bind(this)`, so invoking the proxy captures the caller's `this` as `thisVal` on the queue item. - Step mode hoists the body as a regular `function` (not an arrow) so the runtime's `stepFn.apply(thisVal, args)` rebinds `this` inside the hoisted body. Detection only fires for arrows, since arrows inherit `this` lexically. Nested non-arrow functions/methods/getters/setters introduce their own `this`, so the detector stops at those boundaries. The runtime already supported `thisVal` for instance-method steps; this PR is purely a compiler change to feed the existing pipeline. Caveat: capture works at runtime only when the captured value is serializable across the workflow->step boundary (i.e. the enclosing class implements `WORKFLOW_SERIALIZE`/`WORKFLOW_DESERIALIZE`). Refs vercel/workflow#1865 * Address PR review: preserve step proxy metadata + tighter `this` detection - core: Override `.bind` on step proxies so the bound function retains `stepId` and `__closureVarsFn`. Without this, a bound proxy that flows through workflow serialization (e.g. as a step argument) would be treated as a non-serializable plain function by `getStepFunctionReducer`. - swc-plugin: Detector now also walks `arrow.params` so `this` references in default values / destructuring initializers (e.g. `(x = this.foo) => ...`) trigger the `.bind(this)` path. - swc-plugin: Class bodies inside the arrow body are now treated as `this`-binding boundaries — `this` inside class field initializers, methods, etc. is bound to the class instance, not the outer arrow. The detector still walks `extends` clauses and computed property keys because those are evaluated in the surrounding scope. - spec.md: Sharpen the note about `this` in step bodies — it's syntactically allowed but only meaningful for instance-method steps and lexical-`this` arrow steps; other shapes compile but `this` will be whatever the caller of the step proxy passes. - Add `lexical-this-detector-edge-cases` fixture covering both the default-param positive case and the inner-class false-positive guard. - Strengthen the runtime test to assert `stepId` / `__closureVarsFn` survive `.bind(...)`. * [swc-plugin] Fix `arguments` closure-var capture; drop dead `this`/`arguments` checks - Add `arguments` to `is_global_identifier` so it's not captured as a closure variable. Previously a nested `function`-form step like function step() { 'use step'; return arguments[0]; } was hoisted with `const { arguments } = ...` (a strict-mode syntax error) and the body's `arguments[0]` resolved against the destructured binding instead of the function's intrinsic `arguments` object. - Remove dead `ForbiddenExpression` checks for `this` and `arguments` in `visit_mut_this_expr` / `visit_mut_ident`. The `'use step'` / `'use workflow'` directives are stripped during the module-level traversal before children are visited, so `in_step_function` / `in_workflow_function` are never observed as true here in practice. The existing `step-with-this-arguments-super` fixture explicitly documents that all three identifiers are allowed in step bodies. - Tighten the spec note about `arguments` accordingly: it works in `function`-form steps (reflecting positional args) but is not captured for arrow-form steps; use `...args` for that case. - Add `nested-step-arguments` fixture pinning down the new behavior.github.com-vercel-workflow · d0e3f272 · 2026-05-05
- 1.4ETVOpt-in decryption for o11y tooling (CLI + web) (#1256) * Add browser-compatible AES-GCM to core and HKDF key derivation to world-vercel * update changeset * Move HKDF key derivation server-side: API returns per-run derived key * Refactor encrypt/decrypt to accept CryptoKey, export importKey for callers to import once per run * Overload getEncryptionKeyForRun: accept context for start(), fetch WorkflowRun in resume-hook * Split changeset into per-package descriptions for world, world-vercel, and core * Remove unnecessary Uint8Array.from() wrapper around Buffer.from() * Use zod to parse Vercel API response * fix: restore world-vercel files to main versions The rebase incorrectly picked up older versions of these files from early encryption branch commits. The main versions are correct and up-to-date. * fix: add type cast for hydrateStepReturnValue return in hook.ts * Make decryption an explicit opt-in for o11y tooling * Restore encrypted data handling in o11y hydration layer * Use EncryptedDataRef with util.inspect.custom for CLI encrypted data display * Fix Decrypt button crash: use correct 'refresh' callback from useWorkflowResourceData * Implement client-side decryption for web o11y with getEncryptionKeyForRun RPC * Fix CLI decrypt: fetch WorkflowRun for key resolution, cache per runId * Use named constructor pattern for encrypted data display in web o11y * Decrypt event data when encryption key is available after Decrypt button click * Lift encryption key to run-level state, auto-decrypt on fetch, fix field pollution * Re-load expanded event data when encryption key becomes available * Consolidate Decrypt to title bar Button, remove sidebar decrypt card * Add hover tooltip to Decrypt button explaining scope and state * Show flat Encrypted label for encrypted fields, use Lucide Lock icon in DataInspector * Render eventData subfields individually to avoid encrypted markers in collapsed preview * Revert: render eventData subfields individually * Fix Lock icon vertical alignment in DataInspector encrypted label * update changeset * Update CLI, web, and stream callers for CryptoKey: importKey at resolution sites * Pass teamId to the get-key endpoint * fix: remove unused DataInspector import in events-list.tsx * fix: restore world-vercel files to base branch versions Cherry-pick conflict resolution incorrectly took the older opt-in-decrypt versions of these files, reverting improvements from main (dispatcher, createGetEncryptionKeyForRun extraction, nullable key response). * fix: address PR review feedback - Remove duplicate AttributePanel/EventsList rendering in entity-detail-panel.tsx. Thread encryptionKey into the existing EventsList render instead. - Restore missing re-exports (isClassInstanceRef, isStreamId, isStreamRef) in web-shared/src/index.ts to maintain backwards compatibility. - Add 'error' to replaceEncryptedWithMarkers field list in web-shared hydration.ts to match the decrypt path. - Extend CLI hydration eventData decrypt/placeholder to cover all known serialized fields (output, metadata, payload) not just result/input. - Add 'error' to CLI replaceEncryptedWithRef field list. - Remove invalid encryptionKey option from useWorkflowResourceData call (hook doesn't support it yet), add TODO. - Add 4 unit tests for hydrateDataWithKey in serialization-format.test.ts: encrypted+key decrypts, encrypted+noKey returns raw, non-encrypted hydrates normally, non-Uint8Array legacy data passes through. * feat: thread encryptionKey through useWorkflowResourceData hook Instead of leaving a TODO, implement the encryptionKey support directly: - Add optional encryptionKey to useWorkflowResourceData options - When key is available, use hydrateResourceIOWithKey (async decrypt) instead of hydrateResourceIO for all resource types - Remove redundant hydrateResourceIO from fetchResourceWithCorrelationId * fix: address comprehensive review feedback on PR #1256 High priority: - Gate showStream key fetch on --decrypt flag, warn when --decrypt used without --run - Fix workflow-server-actions.server.ts missing cryptoKey params (undefined for both getExternalRevivers and getDeserializeStream) - Add hydration + decryption to listEvents (was completely missing) - Fix error/eventData display: check isEncryptedMarker before hasDisplayContent so encrypted markers don't silently disappear Medium priority: - handleDecrypt: use toast.error() instead of console.error for user-visible feedback on key fetch failures - CLI maybeDecryptFields: add try/catch with graceful fallback to encrypted placeholders + warning, also decrypt error field - use-resource-data: wrap hook/sleep hydrate() in try/catch to prevent stuck loading state on decryption errors - Decrypt button: also check run.error and step input/output for encrypted markers, not just run.input/output Low priority: - event-list-view: add .catch() to re-load useEffect promise - Export ENCRYPTED_DISPLAY_NAME from hydration.ts and import in data-inspector.tsx instead of raw 'Encrypted' stringgithub.com-vercel-workflow · bbe40ff0 · 2026-03-04
- 1.3ETVfix(swc-plugin): rewrite anonymous export default class to const declaration (#1601) * fix(builders): override sideEffects:false for discovered workflow/step/serde entries When node_modules packages include "sideEffects": false in their package.json, esbuild drops bare imports from the virtual-entry.js file. This is incorrect because the SWC compiler transform injects side-effectful registration code (workflow IDs, step IDs, class serialization) into these modules. Fix: return the resolved path alongside sideEffects: true from the onResolve handler so esbuild uses the plugin's resolution result instead of re-reading the package.json. * refactor(builders): normalize sideEffectEntries with realpaths for symlink compatibility Extract withRealpaths() helper and use it for both normalizedEntriesToBundle and sideEffectEntries at all three bundle sites. This ensures the sideEffects override works correctly under pnpm/workspace symlinked layouts where enhanced-resolve may return realpaths that differ from the original discovered file paths. * perf(builders): skip enhanced-resolve for transitive imports when only sideEffectEntries is set When entriesToBundle is not set (workflow/client bundles), only top-level import statements need the sideEffects override — transitive imports from deep within the bundle are not bare imports and don't need resolution. Skip enhanced-resolve for non-import-statement kinds to reduce overhead. * fix(swc-plugin): use binding name for class expression method registrations When a pre-bundled package (e.g. via tsup) contains class expressions like `var Foo = class _Foo { ... }`, the internal name `_Foo` is only scoped inside the class body. The SWC plugin was incorrectly using the internal name for method step registrations and class serialization registrations emitted at module scope, causing ReferenceError at runtime. Fix: always use the binding name (registration_name) for current_class_name in visit_mut_class_expr, consistent with the existing handling for anonymous class expressions. This ensures: - registerStepFunction calls reference the binding name (Foo) - Only one class registration IIFE is emitted (not duplicates for both Foo and _Foo) - Step IDs use the binding name in their qualified path * refactor(swc-plugin): rename internal_class_name to tracked_class_name for clarity The variable no longer represents the internal class expression identifier after being reassigned to the binding name. Rename to tracked_class_name and eliminate the intermediate registration_name variable to make the intent clearer and reduce confusion for future readers. * fix(swc-plugin): rewrite anonymous export default class to const declaration When an anonymous class with serde/step methods is exported as a default export (`export default class { ... }`), the generated registration code (registerStepFunction, class registry IIFE) would reference a nonexistent variable at module scope, causing a ReferenceError at runtime. Fix: detect anonymous default class exports in visit_mut_export_default_decl and visit_mut_export_default_expr, generate a unique binding name (__defaultClass), and defer a rewrite in visit_mut_module_items that transforms the export into: const __defaultClass = class __defaultClass { ... }; export default __defaultClass; Named default class exports (export default class Foo { ... }) are handled by setting current_class_binding_name so the transformer uses the existing class name for registration code. * refactor(swc-plugin): rename __defaultClass to __DefaultClass for class naming convention * fix(swc-plugin): fix panic for step-only anonymous default class exports Address review feedback: - Remove expect() that panicked when anonymous default class had step methods but no serde methods (ident was not re-inserted). Keep const_name in a local variable instead of relying on class_expr.ident. - Add debug_assert for single default class export invariant. - Update spec.md and test fixture inputs to use new this() instead of referencing the generated binding name. - Add step-only anonymous default class fixture to cover the bug path. * refactor(swc-plugin): address review feedback for export default class handling - Remove dead Expr::Class handler in visit_mut_export_default_expr (SWC wraps parenthesized form in Expr::Paren, so it never fires) - Extract class_needs_binding_rewrite() helper, eliminating duplicated detection logic and unnecessary body clones - Add debug_assert for mutual exclusivity of default_workflow_exports and default_class_exports - Clarify spec.md on self-name behavior difference between serde and step-only classesgithub.com-vercel-workflow · 7c996a76 · 2026-04-03
- 1.1ETVWire AES-GCM encryption into serialization layer (#1251) * fix(core): chain unconsumed event check onto promiseQueue to prevent false positives The EventsConsumer's unconsumed event check (setTimeout(0)) was racing against the promiseQueue's async deserialization. When parallel steps completed and their hydrateStepReturnValue did real async work (e.g., decryption), the setTimeout(0) fired before the promise chain resolved the step results and triggered the next subscribe() call. This caused step_created events for sequential steps to be falsely flagged as unconsumed/orphaned. Fix: chain the unconsumed check onto the promiseQueue via getPromiseQueue() so it only fires after all pending async work completes. Use process.nextTick (not setTimeout) after the queue drains to give synchronous subscribe() calls from resolved user code a chance to cancel. Version-based cancellation replaces clearTimeout since the check is now promise-based. Adds getPromiseQueue option to EventsConsumerOptions. The workflow.ts context uses a getter/setter to keep the promiseQueue holder in sync. Reproduction test: parallel steps A+B with 10ms mock deserialization delay, followed by sequential step C. Previously failed with 'Unconsumed event: step_created(C)'. Now passes. * fix: chain hydrateWorkflowArguments onto promiseQueue to prevent false unconsumed events The unconsumed event check was firing during the async gap between run_started consumption and the workflow function subscribing its first step callbacks. This happened because hydrateWorkflowArguments is async, and during its await, the EventsConsumer advanced to step_created events that had no subscriber yet. Fix: chain hydrateWorkflowArguments onto the promiseQueue so the unconsumed check (which waits for the queue to drain) doesn't fire until after the workflow arguments are hydrated and the workflow function has been invoked. * fix: use setTimeout(0) macrotask for unconsumed check to ensure VM promise propagation completes The process.nextTick-based unconsumed check was still racing against VM promise propagation. After promiseQueue resolves and the user code's resolve() fires, there are multiple microtask hops through the VM boundary before the workflow code actually calls subscribe() for the next steps. process.nextTick fires before those VM microtasks complete. setTimeout(0) is a macrotask that is guaranteed to fire only after ALL microtasks (including VM promise chain propagation) have drained. The pendingUnconsumedTimeout handle is stored and cleared in subscribe() to prevent keeping the event loop alive unnecessarily. * fix: increase unconsumed event check delay to 100ms for cross-VM promise propagation setTimeout(0) is insufficient because Node.js does not guarantee that macrotasks fire after all cross-context (VM boundary) microtasks settle. After promiseQueue resolves and resolve() fires in the host context, there are multiple microtask hops through the VM boundary before the workflow code actually calls subscribe(). A 100ms delay provides sufficient time for this propagation while still detecting truly orphaned events promptly. Also update sleep.test.ts to wait 200ms for the unconsumed check. * Add browser-compatible AES-GCM to core and HKDF key derivation to world-vercel * update changeset * Move HKDF key derivation server-side: API returns per-run derived key * Refactor encrypt/decrypt to accept CryptoKey, export importKey for callers to import once per run * Overload getEncryptionKeyForRun: accept context for start(), fetch WorkflowRun in resume-hook * Split changeset into per-package descriptions for world, world-vercel, and core * Remove unnecessary Uint8Array.from() wrapper around Buffer.from() * Use zod to parse Vercel API response * Wire encryption into serialization layer * Wire AES-GCM encryption into serialization layer * update changeset * Add encryption unit tests: primitives, maybeEncrypt/maybeDecrypt, isEncrypted, complex type round-trips * Accept CryptoKey in encrypt/decrypt, export importKey for callers to import once per run * Fix review comments: cache stream encryption key, remove redundant casts, fix stale comments * Trying to clean up some type non-sense * fix: restore world-vercel files to main versions The rebase incorrectly picked up older versions of these files from early encryption branch commits. The main versions are correct and up-to-date. * fix: add type cast for hydrateStepReturnValue return in hook.ts * fix: address review feedback on encryption PR - Remove Vercel-specific error message from maybeDecrypt (core should not reference VERCEL_DEPLOYMENT_KEY) - Move stream encryption/decryption from transport layer (WorkflowServerReadableStream/WritableStream) to framing layer (getSerializeStream/getDeserializeStream). Frame length headers stay in the clear so frame boundaries are always parseable regardless of transport chunking; encryption wraps the frame payload. - Remove explicit Promise<unknown> return types from all 4 hydrate functions. On main these had inferred types (any from devalue), so callers didn't need casts. The encryption branch added explicit annotations that broke this. - Revert unnecessary type casts in run.ts, step-handler.ts, hook.ts that were only needed due to the explicit Promise<unknown> annotations - Revert closureVars type from unknown back to Record<string, any> in context-storage.ts to match the contract with getClosureVars - Fix hydrateWorkflowArguments JSDoc for unused _runId parameter * Revert more unnecessary changes * cleanup: remove unused runId param, deduplicate processFrames, add legacy comments - Remove unused _runId parameter from WorkflowServerReadableStream constructor and all 4 call sites - Deduplicate processFrames decryption: decrypt first and reassign format/payload, then fall through to single deserialization path - Add comments on all legacy non-Uint8Array branches explaining when this happens (specVersion 1 runs stored data as plain JSON arrays) - Fix duplicate code block in hydrateStepReturnValue * feat: wire cryptoKey through stream serialize/deserialize pipeline Thread the encryption key through the entire stream serialization chain so that ReadableStream and WritableStream values are encrypted/decrypted at the framing level. - Add optional cryptoKey param to getExternalReducers, getStepReducers, getExternalRevivers, getStepRevivers - Pass cryptoKey to getSerializeStream/getDeserializeStream at all 8 internal call sites within reducers/revivers - Thread key from dehydrate/hydrate functions into their reducers/revivers - Cache encryption key in Run class (resolved once via getEncryptionKey(), reused for returnValue, getReadable(), etc.) - Make Run#getReadable() async to resolve the cached key before creating the deserialize stream - Add encryptionKey to step context storage so getWritable() can access it during step execution * fix: make cryptoKey required-but-nullable to prevent silent omission, add stream encryption tests Change cryptoKey parameter from optional (cryptoKey?) to required-but- nullable (cryptoKey: CryptoKey | undefined) on all 6 functions: - getSerializeStream, getDeserializeStream - getExternalReducers, getStepReducers - getExternalRevivers, getStepRevivers This ensures every call site must explicitly pass the key or undefined, making it impossible to accidentally omit it and silently skip encryption. Add 7 stream encryption round-trip tests: - Encrypted frames have 'encr' prefix inside length header - Full round-trip: encrypt serialize -> decrypt deserialize - Concatenated encrypted frames (transport coalescing) - Split encrypted frames (transport splitting) - Error when encrypted data encountered without key - No encryption when key is undefined - Large payload round-trip Full audit confirms all encryption key threading is complete: - All 8 dehydrate/hydrate functions pass key to reducers/revivers - All stream serialize/deserialize call sites pass key - Run class caches key for reuse across returnValue and getReadable() - Step context storage carries key for getWritable() * fix: keep Run#getReadable() sync, resolve encryption key lazily in streams - Revert Run#getReadable() to synchronous (non-breaking API). The encryption key is passed as a Promise through the chain and resolved lazily inside the first async transform() call. - Add EncryptionKeyParam type alias that accepts CryptoKey, undefined, or Promise<CryptoKey | undefined>. Used by getSerializeStream, getDeserializeStream, and all reducer/reviver functions. - Key promises are resolved once on first use via a keyState cache object inside each stream's transform closure. - Fix CLI showStream to resolve encryption key from world when runId is provided via --run flag, instead of passing undefined. - Remove incorrect CLI warning that --run is not supported for streams (it is now needed for encrypted stream decryption). * . * fix: address review feedback from PR #1251 - Fix 4 broken dehydrateWorkflowArguments calls in workflow.test.ts that were passing ops as runId (missing runId and key params) - Use WorkflowRuntimeError instead of plain Error in decodeFormatPrefix for unknown serialization formats, for consistency and programmatic error handling - Document maybeDecrypt throw behavior: callers should be aware this surfaces as a rejected promise during key rotation/misconfiguration - Document key-fetch rejection timing in streams: promise rejection won't surface until the first chunk is processedgithub.com-vercel-workflow · 7618ac36 · 2026-03-04
- 1.1ETVSupport getter functions with "use step" directive (#1630) * Support getter functions with "use step" directive Add SWC compiler plugin support for JavaScript getters marked with "use step", enabling patterns like `await obj.prop` where the getter triggers a step function invocation. - Handle Prop::Getter in object literals and MethodKind::Getter in classes - Emit Object.getOwnPropertyDescriptor registration in step mode - Emit hoisted proxy + Object.defineProperty in workflow mode - Emit error for getters with "use workflow" - Fix @vercel/workflow -> @workflow/serde imports in existing fixtures - Update spec.md with getter transformation documentation * Add changeset for getter step support * Add e2e test for getter step functions * Add static getter support, sanitize hoisted var identifiers Address PR review feedback: - Support static getters with "use step" using ClassName (not .prototype) - Add sanitize_ident_part() to produce valid JS identifiers from getter names that may contain special characters (e.g. string literal keys) - Add static-getter-step test fixture - Update spec.md with static getter transformation documentation * Remove duplicate getter workflow error in visit_mut_prop_or_spreadgithub.com-vercel-workflow · bab8cddf · 2026-04-07
- 1.0ETVfeat(swc): dead code eliminate unreferenced private class members in workflow mode (#1671) * feat(swc): dead code eliminate unreferenced private class members in workflow mode After stripping 'use step' methods from a class body in workflow mode, eliminate private members (both JS native #field/#method and TypeScript private field/private method) that are no longer referenced by any remaining public member. The algorithm is iterative: references are seeded from public members, then expanded through surviving private members until a fixed point, enabling cascading elimination (e.g. a private field only referenced by a private method that is itself unreferenced). * test(swc): add fixture for JS native private member DCE (#field, #method) * fix(swc): address review feedback on private member DCE - Namespace JS native private names with # prefix to avoid collisions with TS private members of the same name - Track TS private member accesses on non-this receivers (e.g. a.x in static methods) by maintaining a set of known TS-private names - Use visit_children_with for full traversal including computed member expressions - Extract retain logic into shared retain_referenced_private_members() helper used by both visit_mut_class_decl and visit_mut_class_exprgithub.com-vercel-workflow · 66585fd4 · 2026-04-09
- 1.0ETVfix(world-local): prevent path traversal via request-supplied IDs (#1829) * fix(world-local): prevent path traversal via request-supplied IDs Request-supplied identifiers (runId, eventId, stepId, hookId, correlationId, stream names, and tags) flowed directly into path.join() calls, allowing a client to send values like '../../../package' and cause the backend to read or write files outside the workflow data directory. Add a centralized validator (assertSafeEntityId) that rejects IDs which are empty, start with '.', or contain path separators or NUL bytes. Apply it at each storage-layer entry point that composes IDs into filesystem paths: fs.taggedPath / readJSONWithFallback / paginatedFileSystemQuery, the runs / steps / events / hooks storage methods, and the streamer. * address review feedback - UnsafeEntityIdError now extends WorkflowWorldError for consistency with other storage-layer errors and the platform error-to-HTTP mapping. - Add resolveWithinBase(basedir, ...segments) containment helper and apply it at every taggedPath / readJSONWithFallback / .locks path construction site in events-storage and legacy, so a forgotten assertSafeEntityId at a future call site can't silently regress. - Truncate attacker-controlled values in the error message. - Drop unused assertSafeEntityIds helper and the unreachable typeof check under the TS signature. - Fix docstrings on assertSafeEntityId / taggedPath JSDoc example / filePrefix validation comment to match what the code actually does. - handleLegacyEvent now re-asserts runId locally so the invariant is documented at the call site instead of implicitly inherited from events.create. --------- Co-authored-by: JJ Kasper <jj@jjsweb.site>github.com-vercel-workflow · 3ad8ee7e · 2026-04-30
- 0.8ETVAllow synchronous functions to use "use step" directive (#1633) Lift the async function restriction from "use step" in both the SWC compiler plugin and the TypeScript language service plugin. This enables using "use step" as a mechanism to strip Node.js-dependent code from the workflow VM bundle without requiring the function to be async. The async restriction is preserved for "use workflow" functions. - SWC plugin: removed async guards from all step function code paths, updated should_transform_function, updated InvalidExport validation - TypeScript plugin: removed error 9002 for sync step functions - Added sync-step fixture test, cleaned up error test fixtures - Updated spec.md error documentationgithub.com-vercel-workflow · d0401829 · 2026-04-09
- 0.7ETVFollowup fixes for sync step function support (#1664) * Followup fixes for sync step function support Address review feedback from #1633 (merged): - Remove dangling incomplete comment from validate_async_function removal - Restore export validation for file-level 'use step' files: allow sync/async function exports, reject non-function exports (constants, classes, re-exports) which can pull Node-only code into bundles - Fix InvalidExport error message: 'Only functions can be exported' for step files vs 'Only async functions can be exported' for workflow - Update spec.md error table and supported function forms to document sync step support - Add sync-step-class test fixture (sync static methods, sync function expressions with var/let) - Add sync-workflow error test (sync workflow still errors) * Tighten step file export validation, fix spec completeness - Reject uninitialized var exports (export let x) in step files - Reject local named exports (export { value }) in step files since we cannot statically verify the binding is a function - Add test coverage for both new rejection cases - Add sync variants for let/var arrow functions in spec.md * Add test fixtures for remaining export validation edge cases - Add re-export with specifiers to invalid-exports (export { x } from) - Add error fixture for default class export in step files - Add fixture for sync default function export (should pass) - Total: 220 tests (165 fixture + 33 error + 22 unit)github.com-vercel-workflow · ebb0a4a4 · 2026-04-09
- 0.7ETVrefactor(swc-plugin): remove client transform mode, merge into step mode (#1686) * refactor(swc-plugin): remove client transform mode, merge into step mode Remove the `client` transform mode from the SWC compiler plugin. The `client` and `step` modes were nearly identical — both preserved step function bodies, replaced workflow bodies with throw stubs, and emitted the same JSON manifest. Step mode now absorbs all client-mode behaviors: - Dead code elimination (previously only workflow + client) - Hoisted variable references for object property steps - All integrations use mode: 'step' instead of 'client' BREAKING CHANGE: The `client` value for the SWC plugin `mode` option is no longer accepted. Use `step` instead. * fix(nitro): force-inline workflow packages in dev mode for serde classId registration In dev mode, Nitro's Rollup externalizes npm packages like @workflow/core, so the SWC transform plugin never processes files like run.js. This means serde classes (e.g. Run) never get the classId registration IIFE, causing serialization failures when step functions return Run instances. Uses a Rollup resolveId hook to force workflow SDK packages to be bundled (non-external) while leaving all other dependencies external. This is more targeted than noExternals=true which bundles everything and causes TDZ errors from circular imports in packages like vue-bundle-renderer/h3. The Nitro module now also ignores .nitro/workflow/** in watchOptions so writing generated workflow bundles does not retrigger Nitro's own dev bundle rebuild loop. Also wraps dev:reload workflow rebuilds and makes LocalBuilder.build() atomic (writes to temp files, renames on success) to avoid partial output state during HMR. For Nuxt, also configures Vite's ssr.noExternal to bundle workflow packages in the SSR context. * fix(nitro,nuxt): address review feedback on dev-mode classId fix - nitro builders: use crypto.randomUUID() for temp file suffix instead of Date.now() to avoid collisions under rapid/concurrent build() calls, and serialize concurrent build() calls through an internal queue so two overlapping dev rebuilds cannot clobber each other's temp outputs. - nitro index: use fileURLToPath() to convert file:// URLs to filesystem paths, which correctly handles Windows paths (file:///C:/... -> C:\...) and percent-decoding, instead of relying on new URL(...).pathname. - nuxt module: normalize vite.ssr.noExternal to an array (preserving any existing string/RegExp/array entry) before appending workflow package matchers, so the force-bundle behavior is not a no-op when noExternal is already set to a non-array value.github.com-vercel-workflow · 417c4930 · 2026-04-16
- 0.6ETVInline class serialization registration to fix 3rd-party package support (v2) (#1503) * Inline class serialization registration to fix 3rd-party package support (#1480) * Inline class serialization registration to fix 3rd-party package support The SWC plugin previously generated: import { registerSerializationClass } from "workflow/internal/class-serialization"; registerSerializationClass("class//...", ClassName); This broke for 3rd-party packages (e.g. @vercel/sandbox) that define serializable classes but don't depend on the 'workflow' package. The bare 'workflow' specifier is unresolvable from within node_modules of a package that doesn't list it as a dependency. Now the plugin generates a self-contained IIFE that uses Symbol.for('workflow-class-registry') on globalThis directly, with zero module dependencies: (function(__wf_cls, __wf_id) { var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map()); __wf_reg.set(__wf_id, __wf_cls); Object.defineProperty(__wf_cls, "classId", { ... }); })(ClassName, "class//..."); This is fully compatible with the existing deserialization side in @workflow/core which reads from the same globalThis registry. * Address review feedback: fix comment and update docstring - Fix inaccurate IIFE comment in lib.rs: the second arg is the generated class ID string, not the literal "classId" - Update registerSerializationClass docstring to reflect that the SWC plugin now inlines equivalent logic rather than importing it * Update CJS require fixture outputs for inline class serialization The original PR #1480 was merged but reverted because it didn't include updated fixtures for the CJS require patterns added by PR #1144 (custom-serialization-require-destructured and custom-serialization-require-namespace). These fixtures still had the old 'import { registerSerializationClass }' pattern instead of the new inline IIFE.github.com-vercel-workflow · 77fd9ad3 · 2026-03-24
- 0.6ETVfeat: add clickable Run reference rendering in observability UI (#1681) * feat: add clickable Run reference rendering in observability UI When a serialized Run object appears in step input/output data, it is now rendered as a clickable purple badge showing the runId. Clicking navigates to the target run's detail page. Changes: - serialization-format.ts: Add RunRef type, isRunRef(), serializedRunToRunRef(), and 'Run' entry in observabilityRevivers - data-inspector.tsx: Add RunRefInline component (purple badge), RunClickContext, collapseRefs() to make refs non-expandable in ObjectInspector - attribute-panel.tsx: Thread onRunClick prop, wrap in RunClickContext.Provider - entity-detail-panel.tsx: Thread onRunClick prop - run-trace-view.tsx: Thread onRunClick prop - workflow-trace-view.tsx: Thread onRunClick prop, reset selected span on run change - trace-span-construction.ts: Show step name for builtin steps instead of empty string - hydration.ts: Re-export RunRef types - run-detail-view.tsx: Add handleRunRefClick that navigates to /run/{targetRunId} * fix: guard collapseRefs against class instances and memoize result Only recurse into plain objects (prototype is Object.prototype or null) to avoid stripping class instances like Date, Error, Map, etc. that have their own rendering in NodeRenderer. Also memoize the collapsed result to avoid recomputing on every render. * fix: detect Run instances in Instance reviver instead of fake Run serde key The Run class goes through the standard Instance serialization pipeline (WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE), not a dedicated 'Run' key. Move the RunRef detection into serializedInstanceToRef() which checks if the className is 'Run' and the data contains a runId string, then returns a RunRef instead of a generic ClassInstanceRef. * fix: use serializedInstanceToRef in web and CLI Instance revivers Both the web and CLI hydration layers override the observabilityRevivers Instance handler with their own implementation (for react-inspector named constructors and CLI inspect.custom respectively), bypassing the RunRef detection in serializedInstanceToRef. Fix by calling serializedInstanceToRef first and returning a RunRef when detected.github.com-vercel-workflow · ac09f407 · 2026-04-14
- 0.6ETVfix(builders): override `sideEffects: false` for discovered workflow/step/serde entries (#1598) * fix(builders): override sideEffects:false for discovered workflow/step/serde entries When node_modules packages include "sideEffects": false in their package.json, esbuild drops bare imports from the virtual-entry.js file. This is incorrect because the SWC compiler transform injects side-effectful registration code (workflow IDs, step IDs, class serialization) into these modules. Fix: return the resolved path alongside sideEffects: true from the onResolve handler so esbuild uses the plugin's resolution result instead of re-reading the package.json. * refactor(builders): normalize sideEffectEntries with realpaths for symlink compatibility Extract withRealpaths() helper and use it for both normalizedEntriesToBundle and sideEffectEntries at all three bundle sites. This ensures the sideEffects override works correctly under pnpm/workspace symlinked layouts where enhanced-resolve may return realpaths that differ from the original discovered file paths. * perf(builders): skip enhanced-resolve for transitive imports when only sideEffectEntries is set When entriesToBundle is not set (workflow/client bundles), only top-level import statements need the sideEffects override — transitive imports from deep within the bundle are not bare imports and don't need resolution. Skip enhanced-resolve for non-import-statement kinds to reduce overhead.github.com-vercel-workflow · 443a9e62 · 2026-04-03
- 0.6ETV[world-vercel] Add /run-id sub-export with tagged ULID encode/decode (#1978) * [world-vercel] Add /run-id sub-export with tagged ULID encode/decode Encodes a tag bit, 5-bit version, and 6-bit Vercel region ID into a ULID-shaped string used for workflow run IDs. Tagged values remain valid 26-char Crockford-Base32 ULIDs so they still sort and round-trip through any system that accepts ULIDs. * [world-vercel] Add string-value assertions to run-id tests Add exact-string expectations for encoded outputs at known inputs, covering the default region/version pair, numeric region IDs, version overrides, boundary values (all-zero, all-max), the dirty-input overwrite case, and the lexicographic-order checks. Also adds an explicit byte-array expectation for the canonical ULID-spec example string and an additional first-char-range coverage test for isTagged. * [world-vercel] Remove internal-repo reference from regions doc comment * [world-vercel] Address PR review feedback on run-id sub-export - isTaggedString now fully validates the input as a 26-char Crockford Base32 ULID (delegating to ulidToBytes) instead of only inspecting the first character. This fixes false positives on inputs like '4UUUU...' that have a valid tag-bit position but invalid chars later in the string. - isTagged() now accepts `unknown` to match its documented behavior of safely rejecting non-string inputs without requiring callers to cast. - Introduce `RegionKey` for the full set of keys including 'unknown', and narrow `RegionCode` to `Exclude<RegionKey, 'unknown'>` so the return type of `lookupRegion` and the `DecodedRunId.region` field accurately reflect that 'unknown' is never produced. Updates `encode` to reject 'unknown' as a region code string at runtime (callers wanting the unknown sentinel should pass numeric 0). * [world-vercel] Move tagged-ULID metadata to the top of randomness Address review feedback on #1978: 1. **Metadata at top of randomness, not bottom.** Place `regionId` (6 bits) in the high bits of byte[6] and `version` (5 bits) straddling bytes 6 and 7, leaving the bottom 69 bits of randomness untouched by `encode`. This means a `monotonicFactory()`-style ULID generator's intra-millisecond bottom-bit increments survive encoding intact, so consecutive `encode(ulid(), region, { version })` calls with the same metadata produce strictly increasing strings. Previously the metadata sat in the bottom 11 bits — exactly the bits the monotonic factory uses — causing same-ms collisions/inversions. 2. **DecodedRunId is now a discriminated union.** When `tagged: false`, the `regionId`, `version`, and `region` fields are typed as `null` instead of being populated with garbage bits from arbitrary ULIDs. This forces callers to discriminate on `tagged` before reading metadata. 3. **regionIdFor: keep runtime backstop, mark as ignored for coverage.** The unreachable-in-TS branch stays as a defensive runtime check for callers crossing a JS/TS boundary; an istanbul/c8 ignore comment keeps coverage tools quiet. Doc strings and tests updated accordingly. The new layout adds a test verifying that a sequence of incrementing-bottom-bit ULIDs (simulating `monotonicFactory()`) round-trips through `encode` as a strictly increasing sequence. 108/108 world-vercel tests pass; typecheck clean.github.com-vercel-workflow · b0d0561a · 2026-05-26
- 0.6ETVMove SWC playground to client-side WASM and add Monaco type intellisense (#1553) * Move SWC playground transform from server action to client-side WASM Create a new swc-playground-wasm crate that bundles swc_ecma_parser, swc_ecma_codegen, and the swc_workflow transform visitor into a single WASM binary via wasm-bindgen/wasm-pack. This runs the code transformation entirely in the browser, eliminating the server action round-trip and serverless function cold starts on every keystroke. - New packages/swc-playground-wasm Rust crate targeting wasm32-unknown-unknown - Exposes transform() and transformAll() functions via wasm-bindgen - Client loads WASM + JS glue from public/wasm/ as static assets - Removed @swc/core and @workflow/swc-plugin server-side dependencies - Removed serverExternalPackages/outputFileTracingIncludes Next.js config - Added WASM loading indicator and error state in the UI - Reduced transform debounce from 500ms to 300ms (no network latency) * Add missing extends key to swc-playground-wasm turbo.json * Fix build: ensure rustup is available for wasm32-unknown-unknown target The Vercel build environment has a system Rust without rustup, so the wasm32-unknown-unknown target can't be managed. Now the build script checks for rustup specifically (not just cargo) and installs it when missing, matching the pattern in swc-plugin-workflow/build.js. * Add workflow type definitions to Monaco editor for intellisense Auto-generate type declarations from built .d.ts files of workflow, @workflow/core, @workflow/errors, @workflow/world, and @workflow/utils packages. Register them with Monaco's TypeScript language service via addExtraLib() so imports like 'workflow' resolve with full types, eliminating red squiggles and enabling autocomplete/hover info. * Fix Monaco type resolution: register root index.d.ts for each package Monaco's NodeJs module resolution looks for index.d.ts at the package root, not just in dist/. Register the main entry .d.ts content at both paths (dist/ and root) so bare imports resolve with full type info. Also remove the 2307 diagnostic suppression so non-existent imports correctly show errors. * Fix Monaco tooltip overflow by enabling fixedOverflowWidgets * Fix hydration mismatch, Monaco type resolution paths, and WASM init warning - Fix hydration mismatch: gate Reset button disabled state on isHydrated so server and client render consistently during hydration - Fix Monaco types: use bare node_modules/ paths instead of file:/// URIs for addExtraLib, which is what Monaco's NodeJs resolver expects - Remove virtual package.json entries (Monaco doesn't use them) - Fix wasm-bindgen init deprecation: pass object { module_or_path } instead of bare string argument * Fix Monaco module resolution: set model URI to file:/// and match addExtraLib paths Monaco's TypeScript NodeJs resolver needs the editor model and the addExtraLib entries to share the same URI scheme. Set the input editor model path to file:///src/input.tsx and register all type declarations at file:///node_modules/... so resolution of bare imports like 'workflow' correctly finds the virtual node_modules. Also register a root index.d.ts for packages like @workflow/world that lack an explicit 'types' field in their package.json exports. * Use declare module ambient declarations for reliable Monaco type resolution Replace the virtual node_modules filesystem approach with declare module ambient declarations. This is the standard approach used by TypeScript Playground and StackBlitz — it works regardless of Monaco's internal URI scheme and module resolution quirks. The generation script now: - Registers all .d.ts files at file:///node_modules/<pkg>/dist/... paths - Generates a global ambient declarations file with declare module blocks that map bare import specifiers to their .d.ts entry points - Includes @workflow/serde and workflow sub-exports (api, errors, observability) - Supports configurable sub-export mappings per package * Inline types into declare module blocks for full Monaco type support Replace the export-from-file approach with fully inlined declare module blocks. The script now reads each .d.ts entry point, recursively inlines all relative imports, strips external import statements (resolved via other declare module blocks), and produces a single ambient declarations string. This correctly handles: - unique symbol exports (@workflow/serde) - cross-package re-exports (workflow re-exporting from @workflow/core) - JSDoc comments preserved for hover documentation - Sub-path exports (workflow/api, workflow/errors, workflow/observability) - Added @workflow/serde package * Add workspace packages as dependencies so Turbo builds their types The generate-monaco-types script reads .d.ts files from the built dist/ directories of workflow, @workflow/core, @workflow/errors, etc. On Vercel, Turbo only builds explicit dependencies — without these workspace references, the packages were never built and the dist/ directories didn't exist, resulting in 0 type modules generated. * Add @types/node declarations to Monaco editor Register all @types/node .d.ts files via addExtraLib so Node.js built-in modules (crypto, fs, path, etc.) are available in the playground editor with full type information. * Collect @types/node .d.ts files recursively to include subpath modules The previous non-recursive scan missed subdirectory files like fs/promises.d.ts, stream/web.d.ts, dns/promises.d.ts, etc., causing 'Cannot find module node:fs/promises' errors.github.com-vercel-workflow · 781d64b7 · 2026-03-30
- 0.6ETVAuto-remove workflow packages from serverExternalPackages (#1481) * Warn when serverExternalPackages hides workflow-enabled packages Add a build-time warning when packages in serverExternalPackages contain workflow code ('use step', 'use workflow', or serialization classes). These packages are completely invisible to the workflow compiler when externalized, causing silent runtime failures. The warning detects workflow patterns via two methods: - Fast path: check package.json dependencies for @workflow/serde - Thorough path: read the package entry file and run pattern detection Also adds documentation in the serialization guide about the externalization footgun for 3rd-party packages. * Auto-remove workflow packages from serverExternalPackages When workflow-enabled dependencies are externalized in Next.js, compiler transforms are skipped and runtime failures follow. Detect those packages in withWorkflow, remove them from serverExternalPackages for the current build, and keep a generalized externalPackages warning fallback for non-Next builders. * Address review feedback: add entry-point limitation comment and missing test casegithub.com-vercel-workflow · 0c997ce5 · 2026-05-05